diff --git a/apps/backend/collaboration/src/extensions/git-sync.ts b/apps/backend/collaboration/src/extensions/git-sync.ts index 281467b8..b691e9a6 100644 --- a/apps/backend/collaboration/src/extensions/git-sync.ts +++ b/apps/backend/collaboration/src/extensions/git-sync.ts @@ -62,7 +62,13 @@ class GitSync implements Extension { context, document }: Pick): void { - if (documentName.startsWith("workspace:") || documentName.startsWith("snippet:")) return; + if ( + documentName.startsWith("workspace:") || + documentName.startsWith("snippet:") || + documentName.startsWith("version:") + ) { + return; + } const [contentPieceId, variantId = null] = documentName.split(":"); const update = (): void => { diff --git a/apps/backend/collaboration/src/extensions/search-indexing.ts b/apps/backend/collaboration/src/extensions/search-indexing.ts index 305899e6..a25e7841 100644 --- a/apps/backend/collaboration/src/extensions/search-indexing.ts +++ b/apps/backend/collaboration/src/extensions/search-indexing.ts @@ -45,7 +45,13 @@ class SearchIndexing implements Extension { document, context }: Pick): void { - if (documentName.startsWith("workspace:") || documentName.startsWith("snippet:")) return; + if ( + documentName.startsWith("workspace:") || + documentName.startsWith("snippet:") || + documentName.startsWith("version:") + ) { + return; + } const [contentPieceId, variantId] = documentName.split(":"); const state = docToBuffer(document); diff --git a/apps/backend/collaboration/src/extensions/version-history.ts b/apps/backend/collaboration/src/extensions/version-history.ts new file mode 100644 index 00000000..7f5a42c3 --- /dev/null +++ b/apps/backend/collaboration/src/extensions/version-history.ts @@ -0,0 +1,151 @@ +import { Extension, onChangePayload } from "@hocuspocus/server"; +import { + docToJSON, + getContentVersionsCollection, + getVersionsCollection, + jsonToBuffer, + publishVersionEvent, + fetchEntryMembers +} from "@vrite/backend"; +import { FastifyInstance } from "fastify"; +import { Binary, ObjectId } from "mongodb"; + +interface Configuration { + debounce: number | false | null; +} + +class VersionHistory implements Extension { + private configuration: Configuration = { + debounce: 45000 + }; + + private fastify: FastifyInstance; + + private versionsCollection: ReturnType; + + private contentVersionsCollection: ReturnType; + + private debounced: Map = + new Map(); + + public constructor(fastify: FastifyInstance, configuration?: Partial) { + this.fastify = fastify; + this.configuration = { + ...this.configuration, + ...configuration + }; + this.versionsCollection = getVersionsCollection(fastify.mongo.db!); + this.contentVersionsCollection = getContentVersionsCollection(fastify.mongo.db!); + } + + public async onChange({ + documentName, + document, + context, + update, + ...x + }: onChangePayload): Promise { + return this.debounceUpdate({ documentName, document, context }); + } + + private debounceUpdate({ + documentName, + context, + document + }: Pick): void { + if ( + documentName.startsWith("workspace:") || + documentName.startsWith("snippet:") || + documentName.startsWith("version:") + ) { + return; + } + + const [contentPieceId, variantId = null] = documentName.split(":"); + const update = (): void => { + const debouncedData = this.debounced.get(documentName); + + this.createVersion(contentPieceId, variantId, debouncedData?.members || [], { + context, + document + }); + }; + + this.debounce( + documentName, + update, + [...document.awareness.getStates().values()] + .map((state) => state.user.membershipId) + .filter(Boolean) + ); + } + + private async createVersion( + contentPieceId: string, + variantId: string | null, + members: string[], + details: Pick + ): Promise { + if (variantId) return; + + const ctx = { + db: this.fastify.mongo.db!, + auth: { + workspaceId: new ObjectId(`${details.context.workspaceId}`), + userId: new ObjectId(`${details.context.userId}`) + } + }; + const json = docToJSON(details.document); + const buffer = jsonToBuffer(json); + const versionId = new ObjectId(); + const date = new Date(); + const version = { + _id: versionId, + date, + contentPieceId: new ObjectId(contentPieceId), + ...(variantId ? { variantId: new ObjectId(variantId) } : {}), + members: members.map((id) => new ObjectId(id)), + workspaceId: ctx.auth.workspaceId + }; + + await this.versionsCollection.insertOne(version); + await this.contentVersionsCollection.insertOne({ + _id: new ObjectId(), + contentPieceId: new ObjectId(contentPieceId), + versionId, + ...(variantId ? { variantId: new ObjectId(variantId) } : {}), + content: new Binary(buffer) + }); + publishVersionEvent({ fastify: this.fastify }, `${details.context.workspaceId}`, { + action: "create", + userId: `${details.context.userId}`, + data: { + id: `${versionId}`, + date: date.toISOString(), + contentPieceId: `${contentPieceId}`, + variantId: variantId ? `${variantId}` : null, + members: await fetchEntryMembers(ctx.db, version), + workspaceId: `${ctx.auth.workspaceId}` + } + }); + } + + private debounce(id: string, func: Function, members: string[]): void { + const old = this.debounced.get(id); + const start = old?.start || Date.now(); + const run = (): void => { + func(); + this.debounced.delete(id); + }; + + if (old?.timeout) clearTimeout(old.timeout); + + this.debounced.set(id, { + start, + timeout: setTimeout(run, this.configuration.debounce as number), + members: [...new Set([...(old?.members || []), ...members])] + }); + } +} + +export { VersionHistory }; diff --git a/apps/backend/collaboration/src/writing.ts b/apps/backend/collaboration/src/writing.ts index 85e53f05..71c71219 100644 --- a/apps/backend/collaboration/src/writing.ts +++ b/apps/backend/collaboration/src/writing.ts @@ -4,7 +4,8 @@ import { getContentVariantsCollection, errors, SessionData, - getSnippetContentsCollection + getSnippetContentsCollection, + getContentVersionsCollection } from "@vrite/backend"; import { Server } from "@hocuspocus/server"; import { Database } from "@hocuspocus/extension-database"; @@ -12,9 +13,11 @@ import { Redis } from "@hocuspocus/extension-redis"; import { ObjectId, Binary } from "mongodb"; import { SearchIndexing } from "#extensions/search-indexing"; import { GitSync } from "#extensions/git-sync"; +import { VersionHistory } from "#extensions/version-history"; const writingPlugin = createPlugin(async (fastify) => { const snippetContentsCollection = getSnippetContentsCollection(fastify.mongo.db!); + const contentVersionsCollection = getContentVersionsCollection(fastify.mongo.db!); const contentsCollection = getContentsCollection(fastify.mongo.db!); const contentVariantsCollection = getContentVariantsCollection(fastify.mongo.db!); const server = Server.configure({ @@ -55,6 +58,18 @@ const writingPlugin = createPlugin(async (fastify) => { return null; } + if (documentName.startsWith("version:")) { + const contentVersion = await contentVersionsCollection.findOne({ + versionId: new ObjectId(documentName.split(":")[1]) + }); + + if (contentVersion && contentVersion.content) { + return new Uint8Array(contentVersion.content.buffer); + } + + return null; + } + if (documentName.startsWith("snippet:")) { const snippetContent = await snippetContentsCollection.findOne({ snippetId: new ObjectId(documentName.split(":")[1]) @@ -91,7 +106,7 @@ const writingPlugin = createPlugin(async (fastify) => { return null; }, async store({ documentName, state, ...details }) { - if (documentName.startsWith("workspace:")) { + if (documentName.startsWith("workspace:") || documentName.startsWith("version:")) { return; } @@ -140,7 +155,8 @@ const writingPlugin = createPlugin(async (fastify) => { } }), new SearchIndexing(fastify), - new GitSync(fastify) + new GitSync(fastify), + new VersionHistory(fastify) ] }); diff --git a/apps/web/src/app.tsx b/apps/web/src/app.tsx index dd2e213c..3a992811 100644 --- a/apps/web/src/app.tsx +++ b/apps/web/src/app.tsx @@ -33,6 +33,11 @@ const SnippetEditorView = lazy(async () => { return { default: SnippetEditorView }; }); +const VersionEditorView = lazy(async () => { + const { VersionEditorView } = await import("#views/editor"); + + return { default: VersionEditorView }; +}); const DashboardView = lazy(async () => { const { DashboardView } = await import("#views/dashboard"); @@ -69,6 +74,7 @@ const App: Component = () => { + diff --git a/apps/web/src/context/history.tsx b/apps/web/src/context/history.tsx new file mode 100644 index 00000000..2f460df6 --- /dev/null +++ b/apps/web/src/context/history.tsx @@ -0,0 +1,134 @@ +import { + Accessor, + ParentComponent, + Setter, + createContext, + createEffect, + createMemo, + createSignal, + on, + onCleanup, + useContext +} from "solid-js"; +import { createStore, reconcile } from "solid-js/store"; +import { useLocation, useParams } from "@solidjs/router"; +import { App, useClient, useContentData } from "#context"; + +interface HistoryActions { + updateVersion(update: Pick): void; +} +interface HistoryDataContextData { + versions: Record; + entryIds: Accessor; + loading: Accessor; + setLoading: Setter; + moreToLoad: Accessor; + historyActions: HistoryActions; + loadMore(): Promise; + activeVersionId(): string; +} + +const HistoryDataContext = createContext(); +const HistoryDataProvider: ParentComponent = (props) => { + const client = useClient(); + const params = useParams(); + const location = useLocation(); + const { activeContentPieceId, activeVariantId } = useContentData(); + const [versions, setVersions] = createStore>({}); + const [entryIds, setEntryIds] = createSignal([]); + const [loading, setLoading] = createSignal(false); + const [moreToLoad, setMoreToLoad] = createSignal(true); + const loadMore = async (): Promise => { + const lastId = entryIds().at(-1); + + if (loading() || !moreToLoad()) return; + + setLoading(true); + + const data = await client.versions.list.query({ + contentPieceId: activeContentPieceId()!, + ...(activeVariantId() && { variantId: activeVariantId()! }), + perPage: 100, + lastId + }); + + setEntryIds((ids) => [...ids, ...data.map((entry) => entry.id)]); + data.forEach((entry) => { + setVersions(entry.id, entry); + }); + setLoading(false); + setMoreToLoad(data.length === 100); + }; + const activeVersionId = createMemo((): string => { + if (!location.pathname.startsWith("/version")) return ""; + + return params.versionId; + }); + const historyActions: HistoryActions = { + updateVersion: (update) => { + if (versions[update.id]) { + setVersions(update.id, { ...versions[update.id], label: update.label }); + } + } + }; + const versionsSubscription = client.versions.changes.subscribe(undefined, { + onData({ action, data }) { + if (action === "update") { + historyActions.updateVersion(data); + } else if (action === "create") { + setEntryIds((entries) => [data.id, ...entries]); + setVersions(data.id, data); + } + } + }); + + createEffect( + on(activeContentPieceId, (activeContentPieceId) => { + setEntryIds([]); + setMoreToLoad(true); + setLoading(false); + setVersions(reconcile({})); + + if (activeContentPieceId) { + loadMore(); + } + }) + ); + createEffect( + on(activeVersionId, (activeVersionId) => { + if (activeVersionId && !versions[activeVersionId]) { + client.versions.get + .query({ id: activeVersionId }) + .then((version) => { + setVersions(version.id, version); + }) + .catch(() => {}); + } + }) + ); + onCleanup(() => { + versionsSubscription.unsubscribe(); + }); + + return ( + + {props.children} + + ); +}; +const useHistoryData = (): HistoryDataContextData => { + return useContext(HistoryDataContext)!; +}; + +export { HistoryDataProvider, useHistoryData }; diff --git a/apps/web/src/context/index.tsx b/apps/web/src/context/index.tsx index b01b5769..fcd1dabb 100644 --- a/apps/web/src/context/index.tsx +++ b/apps/web/src/context/index.tsx @@ -9,3 +9,4 @@ export * from "./shared-state"; export * from "./command-palette"; export * from "./host-config"; export * from "./content"; +export * from "./history"; diff --git a/apps/web/src/context/snippets.tsx b/apps/web/src/context/snippets.tsx index 76cd294f..9679c0f8 100644 --- a/apps/web/src/context/snippets.tsx +++ b/apps/web/src/context/snippets.tsx @@ -77,22 +77,17 @@ const SnippetsDataProvider: ParentComponent = (props) => { }); } }; - const snippetsSubscription = client.snippets.changes.subscribe( - { - workspaceId: workspace()!.id || "" - }, - { - onData({ action, data }) { - if (action === "create") { - snippetsActions.createSnippet(data); - } else if (action === "update") { - snippetsActions.updateSnippet(data); - } else if (action === "delete") { - snippetsActions.deleteSnippet(data); - } + const snippetsSubscription = client.snippets.changes.subscribe(undefined, { + onData({ action, data }) { + if (action === "create") { + snippetsActions.createSnippet(data); + } else if (action === "update") { + snippetsActions.updateSnippet(data); + } else if (action === "delete") { + snippetsActions.deleteSnippet(data); } } - ); + }); onMount(() => { load(); diff --git a/apps/web/src/layout/secured-layout.tsx b/apps/web/src/layout/secured-layout.tsx index 60cacc5d..6951a775 100644 --- a/apps/web/src/layout/secured-layout.tsx +++ b/apps/web/src/layout/secured-layout.tsx @@ -17,7 +17,8 @@ import { ContentDataProvider, useLocalStorage, useHostConfig, - useAuthenticatedUserData + useAuthenticatedUserData, + HistoryDataProvider } from "#context"; import { IconButton, Tooltip } from "#components/primitives"; import { SubscriptionBanner } from "#ee"; @@ -63,73 +64,77 @@ const SecuredLayout: ParentComponent = (props) => { - - -
- - - -
- - { - setStorage((storage) => ({ ...storage, zenMode: false })); - }} - /> - - } - > - + + + +
+ +
-
- - - -
+ + { + setStorage((storage) => ({ ...storage, zenMode: false })); + }} + /> + + } + > + + +
+
- + -
- {props.children} +
+ + + +
+ {props.children} +
+ + +
- - -
+ + +
- - - -
- - + + + diff --git a/apps/web/src/layout/side-panel-right.tsx b/apps/web/src/layout/side-panel-right.tsx index 2b37883a..db76ddd0 100644 --- a/apps/web/src/layout/side-panel-right.tsx +++ b/apps/web/src/layout/side-panel-right.tsx @@ -2,15 +2,27 @@ import { debounce } from "@solid-primitives/scheduled"; import clsx from "clsx"; import { createSignal, createMemo, onCleanup, Component, For, Show } from "solid-js"; import { Dynamic } from "solid-js/web"; -import { mdiCommentMultipleOutline, mdiFileMultipleOutline, mdiShapeOutline } from "@mdi/js"; +import { + mdiCommentMultipleOutline, + mdiFileMultipleOutline, + mdiHistory, + mdiShapeOutline +} from "@mdi/js"; import { useLocation } from "@solidjs/router"; -import { useContentData, useLocalStorage, useSharedState } from "#context"; +import { useContentData, useHistoryData, useLocalStorage } from "#context"; import { createRef } from "#lib/utils"; import { ExplorerView } from "#views/explorer"; import { IconButton, Tooltip } from "#components/primitives"; import { SnippetsView } from "#views/snippets"; import { CommentsView } from "#views/comments"; +import { HistoryView } from "#views/history"; +const showEditorSpecificView = (): boolean => { + const { activeContentPieceId } = useContentData(); + const location = useLocation(); + + return Boolean(activeContentPieceId() && location.pathname.includes("editor")); +}; const sidePanelRightViews: Record< string, { @@ -24,28 +36,23 @@ const sidePanelRightViews: Record< explorer: { view: ExplorerView, icon: mdiFileMultipleOutline, label: "Explorer", id: "explorer" }, snippets: { view: SnippetsView, icon: mdiShapeOutline, label: "Snippets", id: "snippets" }, comments: { - show: () => { - const { activeContentPieceId } = useContentData(); - const location = useLocation(); - - return Boolean(activeContentPieceId() && location.pathname.includes("editor")); - }, + show: showEditorSpecificView, view: CommentsView, icon: mdiCommentMultipleOutline, label: "Comments", id: "comments" - } - /* history: { + }, + history: { show: () => { - const { activeContentPieceId } = useContentData(); + const location = useLocation(); - return Boolean(activeContentPieceId()); + return showEditorSpecificView() || location.pathname.startsWith("/version"); }, view: HistoryView, icon: mdiHistory, label: "History", id: "history" - }*/ + } }; const SidePanelRight: Component = () => { const { storage, setStorage } = useLocalStorage(); @@ -59,7 +66,9 @@ const SidePanelRight: Component = () => { const viewId = storage().sidePanelRightView || "explorer"; const { show } = sidePanelRightViews[viewId]; - if (show && !show()) return "explorer"; + if (show && !show()) { + return "explorer"; + } return viewId; }); diff --git a/apps/web/src/layout/toolbar/index.tsx b/apps/web/src/layout/toolbar/index.tsx index 7e375bcd..73dc748d 100644 --- a/apps/web/src/layout/toolbar/index.tsx +++ b/apps/web/src/layout/toolbar/index.tsx @@ -2,13 +2,12 @@ import { UserList } from "./user-list"; import { RightPanelMenu } from "./right-panel-menu"; import { Breadcrumb } from "./breadcrumb"; import { ExportMenu } from "./export-menu"; +import { RestoreVersion } from "./restore-version"; import { mdiAppleKeyboardCommand, - mdiBookOpenBlankVariant, mdiConsoleLine, mdiFileOutline, mdiFullscreen, - mdiGithub, mdiMagnify, mdiMenu, mdiViewDashboard, @@ -17,138 +16,20 @@ import { import { Component, Show, createEffect, createMemo, createSignal, on } from "solid-js"; import { Dynamic } from "solid-js/web"; import clsx from "clsx"; -import { JSONContent } from "@vrite/sdk"; import { useClient, useCommandPalette, useContentData, + useHistoryData, useHostConfig, useLocalStorage, useNotifications, useSharedState } from "#context"; -import { Button, Dropdown, Icon, IconButton, Tooltip } from "#components/primitives"; -import { logoIcon } from "#assets/icons"; +import { Button, Dropdown, Icon, IconButton } from "#components/primitives"; import { breakpoints, isAppleDevice } from "#lib/utils"; const toolbarViews: Record>> = { - editorStandalone: () => { - const { useSharedSignal } = useSharedState(); - const [sharedEditor] = useSharedSignal("editor"); - const { setStorage } = useLocalStorage(); - const [menuOpened, setMenuOpened] = createSignal(false); - - return ( -
-
- - rite -
-
- - - ( - - )} - > -
- - setMenuOpened(false)} - class="w-full justify-start" - wrapperClass="w-full" - /> - - { - setStorage((storage) => ({ ...storage, zenMode: true })); - }} - class="m-0 justify-start w-full whitespace-nowrap" - variant="text" - text="soft" - path={mdiFullscreen} - label="Zen mode" - /> - - -
-
- - } - > -
- - - - { - setStorage((storage) => ({ ...storage, zenMode: true })); - }} - class="m-0 whitespace-nowrap" - variant="text" - text="soft" - path={mdiFullscreen} - label="Zen mode" - /> - - - - - - - - -
-
-
- ); - }, conflict: () => { const { useSharedSignal } = useSharedState(); const client = useClient(); @@ -211,6 +92,7 @@ const toolbarViews: Record>> = { }, editor: () => { const { activeContentPieceId, contentPieces } = useContentData(); + const { activeVersionId } = useHistoryData(); const { useSharedSignal } = useSharedState(); const { registerCommand } = useCommandPalette(); const [sharedProvider] = useSharedSignal("provider"); @@ -250,40 +132,46 @@ const toolbarViews: Record>> = { >
+ setMenuOpened(false)} /> setMenuOpened(false)} class="w-full justify-start" wrapperClass="w-full" /> - { - setMenuOpened(false); - setStorage((storage) => ({ ...storage, zenMode: true })); - }} - class="m-0 w-full md:w-auto justify-start md:justify-center whitespace-nowrap" - variant="text" - text="soft" - path={mdiFullscreen} - label="Zen mode" - /> + + { + setMenuOpened(false); + setStorage((storage) => ({ ...storage, zenMode: true })); + }} + class="m-0 w-full md:w-auto justify-start md:justify-center whitespace-nowrap" + variant="text" + text="soft" + path={mdiFullscreen} + label="Zen mode" + /> +
} > + - { - setStorage((storage) => ({ ...storage, zenMode: true })); - }} - class="m-0 w-full md:w-auto justify-start md:justify-center whitespace-nowrap" - variant="text" - text="soft" - path={mdiFullscreen} - label="Zen mode" - /> + + { + setStorage((storage) => ({ ...storage, zenMode: true })); + }} + class="m-0 w-full md:w-auto justify-start md:justify-center whitespace-nowrap" + variant="text" + text="soft" + path={mdiFullscreen} + label="Zen mode" + /> + diff --git a/apps/web/src/layout/toolbar/restore-version.tsx b/apps/web/src/layout/toolbar/restore-version.tsx new file mode 100644 index 00000000..2f354a91 --- /dev/null +++ b/apps/web/src/layout/toolbar/restore-version.tsx @@ -0,0 +1,49 @@ +import { Component, Show, createSignal } from "solid-js"; +import { mdiRestore } from "@mdi/js"; +import { useNavigate } from "@solidjs/router"; +import { IconButton } from "#components/primitives"; +import { useClient, useContentData, useHistoryData, useLocalStorage } from "#context"; + +interface RestoreVersionProps { + onClick?(): void; +} + +const RestoreVersion: Component = (props) => { + const { activeContentPieceId } = useContentData(); + const { activeVersionId } = useHistoryData(); + const { setStorage } = useLocalStorage(); + const navigate = useNavigate(); + const client = useClient(); + const [loading, setLoading] = createSignal(false); + const restore = async (): Promise => { + try { + setLoading(true); + await client.versions.restore.mutate({ id: activeVersionId() }); + } finally { + props.onClick?.(); + setLoading(false); + setStorage((storage) => ({ + ...storage, + sidePanelRightView: + storage.sidePanelRightView === "history" ? "explorer" : storage.sidePanelRightView + })); + navigate(`/editor/${activeContentPieceId() || ""}`); + } + }; + + return ( + + + + ); +}; + +export { RestoreVersion }; diff --git a/apps/web/src/lib/editor/editing.ts b/apps/web/src/lib/editor/editing.ts index 1e1543b9..12f1f175 100644 --- a/apps/web/src/lib/editor/editing.ts +++ b/apps/web/src/lib/editor/editing.ts @@ -24,7 +24,8 @@ import { Table, TableCell, TableHeader, - TableRow + TableRow, + Comment } from "@vrite/editor"; import { HocuspocusProvider } from "@hocuspocus/provider"; import { @@ -180,7 +181,8 @@ const createExtensions = ( } return extension.extend(resetExtensionConfig); - }) + }), + Comment ]; }; const createBlockMenuOptions = (settings?: App.WorkspaceSettings): SlashMenuItem[] => { diff --git a/apps/web/src/lib/editor/extensions/collab-cursor.tsx b/apps/web/src/lib/editor/extensions/collab-cursor.tsx index 0943741f..82c8faad 100644 --- a/apps/web/src/lib/editor/extensions/collab-cursor.tsx +++ b/apps/web/src/lib/editor/extensions/collab-cursor.tsx @@ -19,7 +19,13 @@ import { GapCursor } from "@tiptap/pm/gapcursor"; import { useAuthenticatedUserData } from "#context"; import { getSelectionColor, selectionClasses, selectionColors } from "#lib/utils"; -type AwarenessUser = { name: string; avatar: string; id: string; selectionColor: string }; +type AwarenessUser = { + name: string; + membershipId: string; + avatar: string; + id: string; + selectionColor: string; +}; type AwarenessState = { clientId: number } & AwarenessUser & { fields: Record }; type CollabCursorStorage = { users: Accessor; @@ -41,7 +47,7 @@ const awarenessStatesToArray = (states: Map>): Aware }); }; const CollabCursor = (provider: HocuspocusProvider): Extension => { - const { profile } = useAuthenticatedUserData(); + const { profile, membership } = useAuthenticatedUserData(); return CollaborationCursor.extend({ onSelectionUpdate() { @@ -93,6 +99,7 @@ const CollabCursor = (provider: HocuspocusProvider): Extension => { provider, user: { name: profile()?.username || "", + membershipId: membership()?.id || "", avatar: profile()?.avatar || "", id: profile()?.id || "", selectionColor: getSelectionColor() diff --git a/apps/web/src/lib/editor/extensions/comment-menu/plugin.tsx b/apps/web/src/lib/editor/extensions/comment-menu/plugin.tsx index 87c20479..cf6b6bcf 100644 --- a/apps/web/src/lib/editor/extensions/comment-menu/plugin.tsx +++ b/apps/web/src/lib/editor/extensions/comment-menu/plugin.tsx @@ -1,6 +1,6 @@ import { CommentMenu } from "./component"; import { Comment } from "@vrite/editor"; -import { Editor, getAttributes } from "@tiptap/core"; +import { Editor, Extension, getAttributes } from "@tiptap/core"; import { SolidEditor, SolidRenderer } from "@vrite/tiptap-solid"; import { debounce } from "@solid-primitives/scheduled"; import { Plugin, PluginKey } from "@tiptap/pm/state"; @@ -41,7 +41,7 @@ const updatePosition = (editor: Editor): void => { })); }; const CommentMenuPluginKey = new PluginKey("commentMenu"); -const CommentMenuPlugin = Comment.extend< +const CommentMenuPlugin = Extension.create< { commentData: CommentDataContextData }, { resizeHandler(): void; @@ -49,7 +49,7 @@ const CommentMenuPlugin = Comment.extend< setActiveFragmentId(id: string): void; } >({ - exitable: true, + name: "commentMenu", addOptions() { return { commentData: {} as CommentDataContextData }; }, diff --git a/apps/web/src/lib/editor/extensions/embed/view.tsx b/apps/web/src/lib/editor/extensions/embed/view.tsx index 96f29810..e08559e0 100644 --- a/apps/web/src/lib/editor/extensions/embed/view.tsx +++ b/apps/web/src/lib/editor/extensions/embed/view.tsx @@ -97,29 +97,31 @@ const EmbedView: Component = () => {
-
- { - state().editor.commands.setNodeSelection(state().getPos()); - }} - /> -
+ +
+ { + state().editor.commands.setNodeSelection(state().getPos()); + }} + /> +
+
{ class="object-contain w-full m-0 transition-opacity duration-300 border-2 border-gray-300 dark:border-gray-700 aspect-video min-h-96 rounded-2xl" /> - -
- -
-
+ + +
+ +
+
+
); diff --git a/apps/web/src/lib/editor/extensions/image/view.tsx b/apps/web/src/lib/editor/extensions/image/view.tsx index 11ffd9fd..65b92268 100644 --- a/apps/web/src/lib/editor/extensions/image/view.tsx +++ b/apps/web/src/lib/editor/extensions/image/view.tsx @@ -207,32 +207,34 @@ const ImageView: Component = () => {
-
- { - state().editor.commands.setNodeSelection(state().getPos()); - }} - /> -
+ +
+ { + state().editor.commands.setNodeSelection(state().getPos()); + }} + /> +
+
{
+ + <> +
.ProseMirror, .content, blockquote, li, td, th) { + :where(div > .ProseMirror, .content, blockquote, li, td, th) { & > :where(div, ul, ol, p, blockquote, hr):not(.ProseMirror-gapcursor) + :where(div, ul, ol, p, blockquote, hr, h1, h2, h3, h4, h5, h6):not( diff --git a/apps/web/src/views/comments/index.tsx b/apps/web/src/views/comments/index.tsx index b282a577..421d80e4 100644 --- a/apps/web/src/views/comments/index.tsx +++ b/apps/web/src/views/comments/index.tsx @@ -148,9 +148,13 @@ const CommentViewThread: Component = (props) => { fallback={ No comments

} + fallback={ + +

No comments

+
+ } > -
+
diff --git a/apps/web/src/views/dashboard/index.tsx b/apps/web/src/views/dashboard/index.tsx index c6336128..fdafdb21 100644 --- a/apps/web/src/views/dashboard/index.tsx +++ b/apps/web/src/views/dashboard/index.tsx @@ -14,7 +14,7 @@ import { getSelectionColor } from "#lib/utils"; const DashboardView: Component = () => { const { useSharedSignal } = useSharedState(); - const { workspace, profile } = useAuthenticatedUserData(); + const { workspace, profile, membership } = useAuthenticatedUserData(); const { storage, setStorage } = useLocalStorage(); const { activeContentPieceId } = useContentData(); const [provider, setProvider] = useSharedSignal("provider"); @@ -28,6 +28,7 @@ const DashboardView: Component = () => { name: profile()?.username || "", avatar: profile()?.avatar || "", id: profile()?.id || "", + membershipId: membership()?.id || "", selectionColor: getSelectionColor() }); onCleanup(() => { diff --git a/apps/web/src/views/editor/editor.tsx b/apps/web/src/views/editor/editor.tsx index 66c9f851..b958a412 100644 --- a/apps/web/src/views/editor/editor.tsx +++ b/apps/web/src/views/editor/editor.tsx @@ -228,33 +228,36 @@ const Editor: ParentComponent - - {(link, tippyInstance) => { - return ( - - ); - }} - - - { - if (!breakpoints.md() && shouldShowFloatingMenu(editor as SolidEditor)) { - setForceBubbleMenu("block"); - - return true; - } - - setForceBubbleMenu(undefined); - - if (isNodeSelection()) { - bubbleMenuInstance()?.setProps({ - placement: isNodeSelection() ? "top-start" : "top" - }); - } - - return shouldShow(editor as SolidEditor); - }} - > - { - editor().commands.blur(); - setActiveElement(null); - bubbleMenuInstance()?.hide(); + + + {(link, tippyInstance) => { + return ( + + ); }} - /> - - - + + { - return shouldShowFloatingMenu(editor as SolidEditor); + if (!breakpoints.md() && shouldShowFloatingMenu(editor as SolidEditor)) { + setForceBubbleMenu("block"); + + return true; + } + + setForceBubbleMenu(undefined); + + if (isNodeSelection()) { + bubbleMenuInstance()?.setProps({ + placement: isNodeSelection() ? "top-start" : "top" + }); + } + + return shouldShow(editor as SolidEditor); }} > - - - - -
} - opened={blockMenuOpened()} - setOpened={setBlockMenuOpened} - > - setBlockMenuOpened(false)} + { + editor().commands.blur(); + setActiveElement(null); + bubbleMenuInstance()?.hide(); + }} /> - + + + { + return shouldShowFloatingMenu(editor as SolidEditor); + }} + > + + + + +
} + opened={blockMenuOpened()} + setOpened={setBlockMenuOpened} + > + setBlockMenuOpened(false)} + editor={editor()} + /> + + {props.children} diff --git a/apps/web/src/views/editor/index.tsx b/apps/web/src/views/editor/index.tsx index fa2a67f6..5e563477 100644 --- a/apps/web/src/views/editor/index.tsx +++ b/apps/web/src/views/editor/index.tsx @@ -1,2 +1,3 @@ export * from "./content-piece-editor"; export * from "./snippet-editor"; +export * from "./version-editor"; diff --git a/apps/web/src/views/editor/version-editor.tsx b/apps/web/src/views/editor/version-editor.tsx new file mode 100644 index 00000000..e0cada15 --- /dev/null +++ b/apps/web/src/views/editor/version-editor.tsx @@ -0,0 +1,159 @@ +import { Editor } from "./editor"; +import clsx from "clsx"; +import { Component, createSignal, createEffect, on, Show, For, createMemo } from "solid-js"; +import { useNavigate } from "@solidjs/router"; +import dayjs from "dayjs"; +import { Title } from "@solidjs/meta"; +import { Loader } from "#components/primitives"; +import { createRef } from "#lib/utils"; +import { + useLocalStorage, + useExtensions, + useAuthenticatedUserData, + useContentData, + useHistoryData, + App +} from "#context"; + +const VersionEditorView: Component = () => { + const { activeVersionId, versions } = useHistoryData(); + const { storage, setStorage } = useLocalStorage(); + const { activeContentPieceId, contentPieces } = useContentData(); + const { loadingInstalledExtensions, installedExtensions } = useExtensions(); + const { workspaceSettings } = useAuthenticatedUserData(); + const navigate = useNavigate(); + const [syncing, setSyncing] = createSignal(true); + const [lastScrollTop, setLastScrollTop] = createSignal(0); + const [reloaded, setReloaded] = createSignal(false); + const [scrollableContainerRef, setScrollableContainerRef] = createRef(null); + const contentPiece = (): + | App.ExtendedContentPieceWithAdditionalData<"order" | "coverWidth"> + | undefined => contentPieces[activeContentPieceId() || ""]; + const versionName = createMemo((): string => { + if (!activeVersionId()) return ""; + + const version = versions[activeVersionId()]; + + if (!version) return ""; + + return version.label || dayjs(version.date).format("MMMM DD, HH:mm"); + }); + const docName = (): string => { + return `version:${activeVersionId() || ""}`; + }; + + createEffect( + on( + [workspaceSettings], + () => { + setSyncing(true); + setLastScrollTop(scrollableContainerRef()?.scrollTop || 0); + }, + { defer: true } + ) + ); + createEffect( + on( + installedExtensions, + (_installedExtensions, _previousInstalledExtensions, previousLoading = true) => { + const loading = loadingInstalledExtensions(); + + if (!loading && !previousLoading) { + setSyncing(true); + setLastScrollTop(scrollableContainerRef()?.scrollTop || 0); + } + + return loading; + }, + { defer: true } + ) + ); + setStorage((storage) => ({ ...storage, toolbarView: "editor" })); + createEffect( + on( + activeVersionId, + (newVersion, previousVersion) => { + if (!newVersion) { + navigate(`/editor/${activeContentPieceId() || ""}`, { replace: true }); + } + + if (newVersion !== previousVersion) { + setSyncing(true); + } + }, + { defer: true } + ) + ); + + return ( + <> + + + Select version for preview + +
+ } + > +
+
+ + + { + setReloaded(true); + }} + onLoad={() => { + setTimeout(() => { + scrollableContainerRef()?.scrollTo({ top: lastScrollTop() }); + }, 0); + setSyncing(false); + }} + > + + {(position) => { + return ( +
+
+ Version {versionName()} +
+
+ ); + }} + + + + +
+
+ +
+ +
+
+
+ + ); +}; + +export { VersionEditorView }; diff --git a/apps/web/src/views/explorer/content-piece-row.tsx b/apps/web/src/views/explorer/content-piece-row.tsx index ec2e63c3..06a4bdc7 100644 --- a/apps/web/src/views/explorer/content-piece-row.tsx +++ b/apps/web/src/views/explorer/content-piece-row.tsx @@ -165,7 +165,6 @@ const ContentPieceRow: Component = (props) => { active() && !activeDraggableContentGroupId() && !activeDraggableContentPieceId() && - pathnameData().view === "editor" && "fill-[url(#gradient)]" )} path={mdiFileDocumentOutline} @@ -214,7 +213,6 @@ const ContentPieceRow: Component = (props) => { active() && !activeDraggableContentGroupId() && !activeDraggableContentPieceId() && - pathnameData().view === "editor" && "text-transparent bg-clip-text bg-gradient-to-tr" )} > diff --git a/apps/web/src/views/history/history-context.tsx b/apps/web/src/views/history/history-context.tsx new file mode 100644 index 00000000..9b9d6380 --- /dev/null +++ b/apps/web/src/views/history/history-context.tsx @@ -0,0 +1,52 @@ +import { + Accessor, + ParentComponent, + Setter, + createContext, + createEffect, + createSignal, + on, + useContext +} from "solid-js"; +import { createStore, reconcile } from "solid-js/store"; +import { useContentData } from "#context"; + +interface HistoryMenuContextData { + labelling: Accessor; + setLabelling: Setter; + useExpanded(id: string): [() => boolean, (value: boolean) => void]; +} + +const HistoryMenuDataContext = createContext(); +const HistoryMenuDataProvider: ParentComponent = (props) => { + const { activeContentPieceId } = useContentData(); + const [expanded, setExpanded] = createStore({} as Record); + const [labelling, setLabelling] = createSignal(""); + const useExpanded = (id: string): [() => boolean, (value: boolean) => void] => { + return [() => expanded[id], (value: boolean) => setExpanded(id, value)]; + }; + + createEffect( + on(activeContentPieceId, () => { + setLabelling(""); + setExpanded(reconcile({})); + }) + ); + + return ( + + {props.children} + + ); +}; +const useHistoryMenuData = (): HistoryMenuContextData => { + return useContext(HistoryMenuDataContext)!; +}; + +export { HistoryMenuDataProvider, useHistoryMenuData }; diff --git a/apps/web/src/views/history/history-entry.tsx b/apps/web/src/views/history/history-entry.tsx new file mode 100644 index 00000000..cbc7247f --- /dev/null +++ b/apps/web/src/views/history/history-entry.tsx @@ -0,0 +1,247 @@ +import { useHistoryMenuData } from "./history-context"; +import { Component, For, Match, Show, Switch, createMemo, createSignal } from "solid-js"; +import { + mdiAccountCircle, + mdiCheck, + mdiChevronRight, + mdiCircleOutline, + mdiDotsVertical, + mdiRename +} from "@mdi/js"; +import clsx from "clsx"; +import dayjs from "dayjs"; +import { Dropdown, Icon, IconButton, Input, Tooltip } from "#components/primitives"; +import { App, useClient, useHistoryData } from "#context"; + +interface HistoryEntryProps { + entry: App.VersionWithAdditionalData; + subEntries?: App.VersionWithAdditionalData[]; + onClick?(entry: App.VersionWithAdditionalData): void; +} + +const HistoryEntry: Component = (props) => { + const { labelling, setLabelling, useExpanded } = useHistoryMenuData(); + const { historyActions, activeVersionId } = useHistoryData(); + const [expanded, setExpanded] = useExpanded(props.entry.id); + const client = useClient(); + const [dropdownOpened, setDropdownOpened] = createSignal(false); + const menuOptions = createMemo(() => { + const menuOptions: Array<{ + icon: string; + label: string; + class?: string; + color?: "danger" | "success"; + onClick(): void; + } | null> = []; + + menuOptions.push({ + icon: mdiRename, + label: "Assign label", + class: "justify-start", + onClick() { + setDropdownOpened(false); + setLabelling(props.entry.id); + } + }); + + return menuOptions; + }); + const active = (): boolean => { + return activeVersionId() === props.entry.id; + }; + + return ( +
+
+
+ +
+ + + + { + setLabelling(""); + }} + /> + + + ( + { + event.stopPropagation(); + setDropdownOpened(true); + }} + /> + )} + > +
+ + {(item) => { + if (!item) { + return ( + + + + +
+ +
+
+
+ + {(entry) => { + return ; + }} + +
+
+
+
+ ); +}; + +export { HistoryEntry }; diff --git a/apps/web/src/views/history/index.tsx b/apps/web/src/views/history/index.tsx new file mode 100644 index 00000000..50a207fd --- /dev/null +++ b/apps/web/src/views/history/index.tsx @@ -0,0 +1,186 @@ +import { HistoryEntry } from "./history-entry"; +import { HistoryMenuDataProvider } from "./history-context"; +import { Component, For, Show, createMemo } from "solid-js"; +import { mdiClose, mdiDotsHorizontalCircleOutline } from "@mdi/js"; +import dayjs from "dayjs"; +import { useNavigate } from "@solidjs/router"; +import { Card, Heading, Icon, IconButton, Loader } from "#components/primitives"; +import { App, useContentData, useHistoryData, useLocalStorage } from "#context"; +import { ScrollShadow } from "#components/fragments"; +import { createRef } from "#lib/utils"; + +interface EntriesByHour { + date: string; + entries: App.VersionWithAdditionalData[]; +} +interface EntriesByHourByGroup { + group: string; + entries: EntriesByHour[]; +} + +const HistoryEntriesList: Component = () => { + const { setStorage } = useLocalStorage(); + const { activeContentPieceId } = useContentData(); + const [scrollableContainerRef, setScrollableContainerRef] = createRef(null); + const { entryIds, versions, moreToLoad, loadMore, loading } = useHistoryData(); + const navigate = useNavigate(); + const dayEntries = createMemo(() => { + const entriesByHour: EntriesByHour[] = []; + + let lastGroup: string | null = null; + + entryIds().forEach((entryId) => { + const entry = versions[entryId]; + + if (!entry) return; + + const date = dayjs(entry.date).startOf("hour").toISOString(); + const group = entry.label || date; + + if (lastGroup === group) { + entriesByHour.at(-1)?.entries.push(entry); + } else { + lastGroup = group; + entriesByHour.push({ + entries: [entry], + date + }); + } + }); + + return entriesByHour; + }); + const dayEntriesByGroup = createMemo(() => { + const entriesByGroup: EntriesByHourByGroup[] = []; + const today = dayjs().startOf("day"); + const yesterday = today.subtract(1, "day"); + const lastWeek = today.subtract(7, "day"); + + let lastGroup: string | null = null; + + dayEntries().forEach((dayEntry) => { + const date = dayjs(dayEntry.date).add(1, "ms"); + + let group = ""; + + if (date.isAfter(today)) { + group = "Today"; + } else if (date.isAfter(yesterday)) { + group = "Yesterday"; + } else if (date.isAfter(lastWeek)) { + group = "Last week"; + } else { + group = date.format("MMMM YYYY"); + } + + if (lastGroup === group) { + entriesByGroup.at(-1)?.entries.push(dayEntry); + } else { + lastGroup = group; + entriesByGroup.push({ + group, + entries: [dayEntry] + }); + } + }); + + return entriesByGroup; + }); + + return ( +
+
+
+ { + setStorage((storage) => ({ + ...storage, + rightPanelWidth: 0 + })); + }} + /> + + History + +
+
+
+
+ + + + + No history records + + New versions will appear here as you make changes to the content. + + + + } + > + {({ group, entries }) => { + return ( + <> + {group} + + {(entriesByHour) => { + return ( + { + navigate(`/version/${activeContentPieceId() || ""}/${entry.id}`); + }} + /> + ); + }} + + + ); + }} + + + +
+ +
+
+
+
+
+
+ ); +}; +const HistoryView: Component = () => { + return ( +
+ + + +
+ ); +}; + +export { HistoryView }; diff --git a/apps/web/src/views/snippets/snippets-context.tsx b/apps/web/src/views/snippets/snippets-context.tsx index e6503e3a..7ef62e76 100644 --- a/apps/web/src/views/snippets/snippets-context.tsx +++ b/apps/web/src/views/snippets/snippets-context.tsx @@ -10,27 +10,22 @@ import { interface SnippetsMenuContextData { renaming: Accessor; loading: Accessor; - highlight: Accessor; setRenaming: Setter; setLoading: Setter; - setHighlight: Setter; } const SnippetsMenuDataContext = createContext(); const SnippetsMenuDataProvider: ParentComponent = (props) => { const [renaming, setRenaming] = createSignal(""); const [loading, setLoading] = createSignal(""); - const [highlight, setHighlight] = createSignal(null); return ( {props.children} diff --git a/packages/backend/src/collections/content-versions.ts b/packages/backend/src/collections/content-versions.ts new file mode 100644 index 00000000..f0c9a88a --- /dev/null +++ b/packages/backend/src/collections/content-versions.ts @@ -0,0 +1,21 @@ +import { Binary, Collection, Db, ObjectId } from "mongodb"; +import { UnderscoreID } from "#lib/mongo"; + +interface ContentVersion { + contentPieceId: ID; + versionId: ID; + variantId?: ID; + content?: Binary; + id: ID; +} + +interface FullContentVersion extends ContentVersion {} + +const getContentVersionsCollection = ( + db: Db +): Collection>> => { + return db.collection("content-versions"); +}; + +export { getContentVersionsCollection }; +export type { ContentVersion, FullContentVersion }; diff --git a/packages/backend/src/collections/index.ts b/packages/backend/src/collections/index.ts index 9d70225d..7abe0029 100644 --- a/packages/backend/src/collections/index.ts +++ b/packages/backend/src/collections/index.ts @@ -21,3 +21,5 @@ export * from "./variants"; export * from "./transformers"; export * from "./snippets"; export * from "./snippet-contents"; +export * from "./content-versions"; +export * from "./versions"; diff --git a/packages/backend/src/collections/versions.ts b/packages/backend/src/collections/versions.ts new file mode 100644 index 00000000..66e431bf --- /dev/null +++ b/packages/backend/src/collections/versions.ts @@ -0,0 +1,58 @@ +import { Profile, profile } from "./users"; +import { Collection, Db, ObjectId } from "mongodb"; +import { z } from "zod"; +import { UnderscoreID, zodId } from "#lib/mongo"; + +const version = z.object({ + id: zodId().describe("ID of the version"), + label: z.string().optional().describe("Custom label assigned to the version"), + date: z.string().describe("ISO-formatted date of the version"), + contentPieceId: zodId().describe("ID of the content piece"), + variantId: zodId().optional().describe("ID of the variant"), + members: z.array(zodId()).describe("IDs of the workspace members associated with the version") +}); +const versionMember = z.object({ + id: zodId().describe("ID of the workspace member"), + profile: profile.omit({ bio: true }).describe("Profile data of the user") +}); + +interface VersionMember { + id: ID; + profile: Omit, "bio">; +} +interface Version + extends Omit< + z.infer, + "id" | "contentPieceId" | "variantId" | "date" | "members" + > { + id: ID; + date: ID extends string ? string : Date; + contentPieceId: ID; + variantId?: ID; + members: ID[]; +} +interface VersionWithAdditionalData + extends Omit, "members"> { + members: Array>; +} + +interface FullVersion extends Version { + workspaceId: ID; +} +interface FullVersionWithAdditionalData + extends Omit, "members"> { + members: Array>; +} + +const getVersionsCollection = (db: Db): Collection>> => { + return db.collection("versions"); +}; + +export { version, versionMember, getVersionsCollection }; +export type { + Version, + VersionWithAdditionalData, + FullVersion, + FullVersionWithAdditionalData, + VersionMember +}; diff --git a/packages/backend/src/events/index.ts b/packages/backend/src/events/index.ts index 2c822d01..fd557ff5 100644 --- a/packages/backend/src/events/index.ts +++ b/packages/backend/src/events/index.ts @@ -15,3 +15,4 @@ export * from "./workspace"; export * from "./git-data"; export * from "./role"; export * from "./snippet"; +export * from "./versions"; diff --git a/packages/backend/src/events/versions.ts b/packages/backend/src/events/versions.ts new file mode 100644 index 00000000..e7ff4139 --- /dev/null +++ b/packages/backend/src/events/versions.ts @@ -0,0 +1,25 @@ +import { Observable } from "@trpc/server/observable"; +import { Context } from "#lib/context"; +import { createEventPublisher, createEventSubscription } from "#lib/pub-sub"; +import { FullVersionWithAdditionalData } from "#collections"; + +type VersionEvent = + | { action: "create"; userId: string; data: FullVersionWithAdditionalData } + | { + action: "update"; + userId: string; + data: Partial & { id: string }; + }; + +const publishVersionEvent = createEventPublisher((contentGroupId) => { + return `versions:${contentGroupId}`; +}); +const subscribeToVersionEvents = ( + ctx: Context, + workspaceId: string +): Observable => { + return createEventSubscription(ctx, `versions:${workspaceId}`); +}; + +export { publishVersionEvent, subscribeToVersionEvents }; +export type { VersionEvent }; diff --git a/packages/backend/src/index.ts b/packages/backend/src/index.ts index 3f7640f3..21207ba4 100644 --- a/packages/backend/src/index.ts +++ b/packages/backend/src/index.ts @@ -7,6 +7,7 @@ export * from "./lib/mongo"; export * from "./lib/session"; export * from "./lib/content-processing"; export * from "./lib/git-sync"; +export * from "./lib/utils"; export * from "./plugins"; export * from "./collections"; export * from "./events"; diff --git a/packages/backend/src/lib/utils.ts b/packages/backend/src/lib/utils.ts index 9c51b4df..1e456872 100644 --- a/packages/backend/src/lib/utils.ts +++ b/packages/backend/src/lib/utils.ts @@ -36,11 +36,11 @@ const fetchContentPieceTags = async ( }) .filter((value) => value) as Tag[]; }; -const fetchContentPieceMembers = async ( +const fetchEntryMembers = async ( db: Db, - contentPiece: UnderscoreID> + entry: Pick, "members"> ): Promise> => { - const memberIds = contentPiece.members || []; + const memberIds = entry.members || []; const workspaceMembershipsCollection = getWorkspaceMembershipsCollection(db); const usersCollection = getUsersCollection(db); const memberships = await workspaceMembershipsCollection @@ -129,7 +129,7 @@ const createToken = async ( export { stringToRegex, fetchContentPieceTags, - fetchContentPieceMembers, + fetchEntryMembers, getCanonicalLinkFromPattern, createToken }; diff --git a/packages/backend/src/lib/workspace.ts b/packages/backend/src/lib/workspace.ts index 2d944345..cf8cadef 100644 --- a/packages/backend/src/lib/workspace.ts +++ b/packages/backend/src/lib/workspace.ts @@ -18,8 +18,10 @@ import { getContentPieceVariantsCollection, getContentPiecesCollection, getContentVariantsCollection, + getContentVersionsCollection, getContentsCollection, - getVariantsCollection + getVariantsCollection, + getVersionsCollection } from "#collections"; import initialContent from "#assets/initial-content.json"; @@ -167,6 +169,8 @@ const deleteWorkspace = async (workspaceId: ObjectId, fastify: FastifyInstance): const variantsCollection = getVariantsCollection(db); const contentPieceVariantsCollection = getContentPieceVariantsCollection(db); const contentVariantsCollection = getContentVariantsCollection(db); + const versionsCollection = getVersionsCollection(db); + const contentVersionsCollection = getContentVersionsCollection(db); const contentPieceIds = await contentPiecesCollection .find({ workspaceId }) .map(({ _id }) => _id) @@ -199,6 +203,12 @@ const deleteWorkspace = async (workspaceId: ObjectId, fastify: FastifyInstance): await contentVariantsCollection.deleteMany({ contentPieceId: { $in: contentPieceIds } }); + await versionsCollection.deleteMany({ + workspaceId + }); + await contentVersionsCollection.deleteMany({ + workspaceId + }); await fastify.search.deleteTenant(workspaceId); await fastify.billing.deleteCustomer(`${workspaceId}`); }; diff --git a/packages/backend/src/plugins/database.ts b/packages/backend/src/plugins/database.ts index 71d50402..c37b99be 100644 --- a/packages/backend/src/plugins/database.ts +++ b/packages/backend/src/plugins/database.ts @@ -19,7 +19,9 @@ import { getGitDataCollection, getWorkspacesCollection, getSnippetsCollection, - getSnippetContentsCollection + getSnippetContentsCollection, + getVersionsCollection, + getContentVersionsCollection } from "#collections"; const databasePlugin = createPlugin(async (fastify) => { @@ -50,6 +52,8 @@ const databasePlugin = createPlugin(async (fastify) => { const workspacesCollection = getWorkspacesCollection(db); const snippetsCollection = getSnippetsCollection(db); const snippetContentsCollection = getSnippetContentsCollection(db); + const versionsCollection = getVersionsCollection(db); + const contentVersionsCollection = getContentVersionsCollection(db); const createIndexes = [ contentPiecesCollection.createIndex({ workspaceId: 1 }), contentPiecesCollection.createIndex({ contentGroupId: 1 }), @@ -96,7 +100,11 @@ const databasePlugin = createPlugin(async (fastify) => { gitDataCollection.createIndex({ workspaceId: 1 }, { unique: true }), gitDataCollection.createIndex({ "records.contentPieceId": 1 }), snippetsCollection.createIndex({ workspaceId: 1 }), - snippetContentsCollection.createIndex({ snippetId: 1 }) + snippetContentsCollection.createIndex({ snippetId: 1 }), + versionsCollection.createIndex({ workspaceId: 1 }), + versionsCollection.createIndex({ contentPieceId: 1 }), + versionsCollection.createIndex({ contentPieceId: 1, variantId: 1 }), + contentVersionsCollection.createIndex({ versionId: 1 }) ]; if (fastify.hostConfig.billing) { diff --git a/packages/backend/src/plugins/oauth.ts b/packages/backend/src/plugins/oauth.ts index 1ae38720..81142328 100644 --- a/packages/backend/src/plugins/oauth.ts +++ b/packages/backend/src/plugins/oauth.ts @@ -53,7 +53,7 @@ const registerGitHubOAuth = (fastify: FastifyInstance): void => { } }); const userData = await client.get<{ - name: string; + name?: string; id: string; login: string; }>("/user"); diff --git a/packages/backend/src/routes/content-groups/handlers/delete.ts b/packages/backend/src/routes/content-groups/handlers/delete.ts index 0dd0fbda..3906cc4d 100644 --- a/packages/backend/src/routes/content-groups/handlers/delete.ts +++ b/packages/backend/src/routes/content-groups/handlers/delete.ts @@ -9,7 +9,9 @@ import { getContentPieceVariantsCollection, getContentVariantsCollection, FullContentGroup, - contentGroup + contentGroup, + getVersionsCollection, + getContentVersionsCollection } from "#collections"; import { publishContentGroupEvent } from "#events"; import { errors } from "#lib/errors"; @@ -38,6 +40,8 @@ const handler = async ( const contentsCollection = getContentsCollection(ctx.db); const contentPieceVariantsCollection = getContentPieceVariantsCollection(ctx.db); const contentVariantsCollection = getContentVariantsCollection(ctx.db); + const versionsCollection = getVersionsCollection(ctx.db); + const contentVersionsCollection = getContentVersionsCollection(ctx.db); const contentGroupId = new ObjectId(input.id); const contentGroup = await contentGroupsCollection.findOne({ _id: contentGroupId, @@ -89,6 +93,13 @@ const handler = async ( await contentVariantsCollection.deleteMany({ contentPieceId: { $in: contentPieceIds } }); + await versionsCollection.deleteMany({ + contentPieceId: { $in: contentPieceIds }, + workspaceId: ctx.auth.workspaceId + }); + await contentVersionsCollection.deleteMany({ + contentPieceId: { $in: contentPieceIds } + }); publishContentGroupEvent(ctx, `${ctx.auth.workspaceId}`, { action: "delete", userId: `${ctx.auth.userId}`, diff --git a/packages/backend/src/routes/content-pieces/handlers/create.ts b/packages/backend/src/routes/content-pieces/handlers/create.ts index 1659dc87..5bc502f0 100644 --- a/packages/backend/src/routes/content-pieces/handlers/create.ts +++ b/packages/backend/src/routes/content-pieces/handlers/create.ts @@ -15,11 +15,7 @@ import { jsonToBuffer, htmlToJSON } from "#lib/content-processing"; import { errors } from "#lib/errors"; import { UnderscoreID, zodId } from "#lib/mongo"; import { publishContentPieceEvent } from "#events"; -import { - fetchContentPieceTags, - fetchContentPieceMembers, - getCanonicalLinkFromPattern -} from "#lib/utils"; +import { fetchContentPieceTags, fetchEntryMembers, getCanonicalLinkFromPattern } from "#lib/utils"; declare module "fastify" { interface RouteCallbacks { @@ -116,7 +112,7 @@ const handler = async ( }); const tags = await fetchContentPieceTags(ctx.db, contentPiece); - const members = await fetchContentPieceMembers(ctx.db, contentPiece); + const members = await fetchEntryMembers(ctx.db, contentPiece); publishContentPieceEvent(ctx, `${contentPiece.contentGroupId}`, { action: "create", diff --git a/packages/backend/src/routes/content-pieces/handlers/delete.ts b/packages/backend/src/routes/content-pieces/handlers/delete.ts index 2c225a04..37caf2d1 100644 --- a/packages/backend/src/routes/content-pieces/handlers/delete.ts +++ b/packages/backend/src/routes/content-pieces/handlers/delete.ts @@ -8,7 +8,9 @@ import { getContentPiecesCollection, getContentPieceVariantsCollection, getContentsCollection, - getContentVariantsCollection + getContentVariantsCollection, + getContentVersionsCollection, + getVersionsCollection } from "#collections"; import { errors } from "#lib/errors"; import { AuthenticatedContext } from "#lib/middleware"; @@ -37,6 +39,8 @@ const handler = async ( const contentVariantsCollection = getContentVariantsCollection(ctx.db); const commentThreadsCollection = getCommentThreadsCollection(ctx.db); const commentsCollection = getCommentsCollection(ctx.db); + const versionsCollection = getVersionsCollection(ctx.db); + const contentVersionsCollection = getContentVersionsCollection(ctx.db); const contentPiece = await contentPiecesCollection.findOne({ _id: new ObjectId(input.id), workspaceId: ctx.auth.workspaceId @@ -57,6 +61,14 @@ const handler = async ( workspaceId: ctx.auth.workspaceId }); await contentVariantsCollection.deleteMany({ + contentPieceId: contentPiece._id, + workspaceId: ctx.auth.workspaceId + }); + await versionsCollection.deleteMany({ + contentPieceId: contentPiece._id, + workspaceId: ctx.auth.workspaceId + }); + await contentVersionsCollection.deleteMany({ contentPieceId: contentPiece._id }); await commentThreadsCollection.deleteMany({ diff --git a/packages/backend/src/routes/content-pieces/handlers/get.ts b/packages/backend/src/routes/content-pieces/handlers/get.ts index 60d503e5..c74f9530 100644 --- a/packages/backend/src/routes/content-pieces/handlers/get.ts +++ b/packages/backend/src/routes/content-pieces/handlers/get.ts @@ -15,11 +15,7 @@ import { DocJSON, bufferToJSON } from "#lib/content-processing"; import { errors } from "#lib/errors"; import { AuthenticatedContext } from "#lib/middleware"; import { zodId } from "#lib/mongo"; -import { - fetchContentPieceTags, - fetchContentPieceMembers, - getCanonicalLinkFromPattern -} from "#lib/utils"; +import { fetchContentPieceTags, fetchEntryMembers, getCanonicalLinkFromPattern } from "#lib/utils"; const inputSchema = z.object({ id: zodId().describe("ID of the content piece"), @@ -107,7 +103,7 @@ const handler = async ( } const tags = await fetchContentPieceTags(ctx.db, contentPiece); - const members = await fetchContentPieceMembers(ctx.db, contentPiece); + const members = await fetchEntryMembers(ctx.db, contentPiece); const getDescription = (): string => { if (input.description === "html") { return contentPiece.description || ""; diff --git a/packages/backend/src/routes/content-pieces/handlers/list.ts b/packages/backend/src/routes/content-pieces/handlers/list.ts index ff77fb27..5e8577bd 100644 --- a/packages/backend/src/routes/content-pieces/handlers/list.ts +++ b/packages/backend/src/routes/content-pieces/handlers/list.ts @@ -11,7 +11,7 @@ import { import { AuthenticatedContext } from "#lib/middleware"; import { zodId } from "#lib/mongo"; import { - fetchContentPieceMembers, + fetchEntryMembers, fetchContentPieceTags, getCanonicalLinkFromPattern, stringToRegex @@ -101,7 +101,7 @@ const handler = async ( return Promise.all( contentPieces.map(async (contentPiece) => { const tags = await fetchContentPieceTags(ctx.db, contentPiece); - const members = await fetchContentPieceMembers(ctx.db, contentPiece); + const members = await fetchEntryMembers(ctx.db, contentPiece); return { ...contentPiece, diff --git a/packages/backend/src/routes/content-pieces/handlers/move.ts b/packages/backend/src/routes/content-pieces/handlers/move.ts index 30a2b7f4..c4d7e319 100644 --- a/packages/backend/src/routes/content-pieces/handlers/move.ts +++ b/packages/backend/src/routes/content-pieces/handlers/move.ts @@ -10,11 +10,7 @@ import { import { UnderscoreID, zodId } from "#lib/mongo"; import { errors } from "#lib/errors"; import { AuthenticatedContext } from "#lib/middleware"; -import { - fetchContentPieceMembers, - fetchContentPieceTags, - getCanonicalLinkFromPattern -} from "#lib/utils"; +import { fetchEntryMembers, fetchContentPieceTags, getCanonicalLinkFromPattern } from "#lib/utils"; import { publishContentPieceEvent } from "#events"; declare module "fastify" { @@ -123,7 +119,7 @@ const handler = async ( ...update }; const tags = await fetchContentPieceTags(ctx.db, updatedContentPiece); - const members = await fetchContentPieceMembers(ctx.db, updatedContentPiece); + const members = await fetchEntryMembers(ctx.db, updatedContentPiece); const sameContentGroup = contentPiece.contentGroupId.equals(updatedContentPiece.contentGroupId); publishContentPieceEvent( diff --git a/packages/backend/src/routes/content-pieces/handlers/update.ts b/packages/backend/src/routes/content-pieces/handlers/update.ts index 4a0914ec..865d337a 100644 --- a/packages/backend/src/routes/content-pieces/handlers/update.ts +++ b/packages/backend/src/routes/content-pieces/handlers/update.ts @@ -17,11 +17,7 @@ import { errors } from "#lib/errors"; import { AuthenticatedContext } from "#lib/middleware"; import { UnderscoreID, zodId } from "#lib/mongo"; import { publishContentPieceEvent } from "#events"; -import { - fetchContentPieceTags, - fetchContentPieceMembers, - getCanonicalLinkFromPattern -} from "#lib/utils"; +import { fetchContentPieceTags, fetchEntryMembers, getCanonicalLinkFromPattern } from "#lib/utils"; declare module "fastify" { interface RouteCallbacks { @@ -188,7 +184,7 @@ const handler = async ( } const tags = await fetchContentPieceTags(ctx.db, newContentPiece); - const members = await fetchContentPieceMembers(ctx.db, newContentPiece); + const members = await fetchEntryMembers(ctx.db, newContentPiece); publishContentPieceEvent(ctx, `${newContentPiece.contentGroupId}`, { action: "update", diff --git a/packages/backend/src/routes/index.ts b/packages/backend/src/routes/index.ts index 8b38ce7c..4e9d5616 100644 --- a/packages/backend/src/routes/index.ts +++ b/packages/backend/src/routes/index.ts @@ -19,6 +19,7 @@ import { gitRouter } from "./git"; import { searchRouter } from "./search"; import { transformersRouter } from "./transformers"; import { snippetsRouter } from "./snippets"; +import { versionsRouter } from "./versions"; import type { TRPCClientError } from "@trpc/client"; import { billingRouter } from "#ee/billing"; import { Context, createContext } from "#lib/context"; @@ -46,7 +47,8 @@ const appRouter = router({ git: gitRouter, search: searchRouter, transformers: transformersRouter, - billing: billingRouter + billing: billingRouter, + versions: versionsRouter }); type Router = typeof appRouter; diff --git a/packages/backend/src/routes/snippets/index.ts b/packages/backend/src/routes/snippets/index.ts index 6e1db8bc..a62c36d4 100644 --- a/packages/backend/src/routes/snippets/index.ts +++ b/packages/backend/src/routes/snippets/index.ts @@ -64,11 +64,9 @@ const snippetsRouter = router({ .mutation(async ({ ctx, input }) => { return deleteSnippet.handler(ctx, input); }), - changes: authenticatedProcedure - .input(z.object({ workspaceId: zodId() })) - .subscription(async ({ ctx, input }) => { - return subscribeToSnippetEvents(ctx, input.workspaceId); - }) + changes: authenticatedProcedure.subscription(async ({ ctx }) => { + return subscribeToSnippetEvents(ctx, `${ctx.auth.workspaceId}`); + }) }); export { snippetsRouter }; diff --git a/packages/backend/src/routes/variants/handlers/delete.ts b/packages/backend/src/routes/variants/handlers/delete.ts index e72bfe23..5c6adafd 100644 --- a/packages/backend/src/routes/variants/handlers/delete.ts +++ b/packages/backend/src/routes/variants/handlers/delete.ts @@ -5,7 +5,9 @@ import { getVariantsCollection, getContentPieceVariantsCollection, getContentVariantsCollection, - variant + variant, + getVersionsCollection, + getContentVersionsCollection } from "#collections"; import { publishVariantEvent } from "#events"; import { errors } from "#lib/errors"; @@ -27,6 +29,8 @@ const handler = async ( input: z.infer ): Promise => { const variantsCollection = getVariantsCollection(ctx.db); + const versionsCollection = getVersionsCollection(ctx.db); + const contentVersionsCollection = getContentVersionsCollection(ctx.db); const contentPieceVariantsCollection = getContentPieceVariantsCollection(ctx.db); const contentVariantsCollection = getContentVariantsCollection(ctx.db); const variantId = new ObjectId(input.id); @@ -42,6 +46,13 @@ const handler = async ( await contentVariantsCollection.deleteMany({ variantId }); + await versionsCollection.deleteMany({ + variantId, + workspaceId: ctx.auth.workspaceId + }); + await contentVersionsCollection.deleteMany({ + variantId + }); if (!deletedCount) throw errors.notFound("variant"); diff --git a/packages/backend/src/routes/versions/handlers/get.ts b/packages/backend/src/routes/versions/handlers/get.ts new file mode 100644 index 00000000..48661cc6 --- /dev/null +++ b/packages/backend/src/routes/versions/handlers/get.ts @@ -0,0 +1,39 @@ +import { z } from "zod"; +import { ObjectId } from "mongodb"; +import { version, versionMember, getVersionsCollection } from "#collections"; +import { errors } from "#lib/errors"; +import { AuthenticatedContext } from "#lib/middleware"; +import { zodId } from "#lib/mongo"; +import { fetchEntryMembers } from "#lib/utils"; + +const inputSchema = z.object({ + id: zodId().describe("ID of the version") +}); +const outputSchema = version.omit({ members: true }).extend({ + members: z.array(versionMember) +}); +const handler = async ( + ctx: AuthenticatedContext, + input: z.infer +): Promise> => { + const versionsCollection = getVersionsCollection(ctx.db); + const version = await versionsCollection.findOne({ + _id: new ObjectId(input.id), + workspaceId: ctx.auth.workspaceId + }); + + if (!version) throw errors.notFound("version"); + + const members = await fetchEntryMembers(ctx.db, version); + + return { + id: `${version._id}`, + contentPieceId: `${version.contentPieceId}`, + date: version.date.toISOString(), + members, + label: version.label, + ...(version.variantId && { variantId: `${version.variantId}` }) + }; +}; + +export { inputSchema, outputSchema, handler }; diff --git a/packages/backend/src/routes/versions/handlers/list.ts b/packages/backend/src/routes/versions/handlers/list.ts new file mode 100644 index 00000000..91484e42 --- /dev/null +++ b/packages/backend/src/routes/versions/handlers/list.ts @@ -0,0 +1,56 @@ +import { z } from "zod"; +import { ObjectId } from "mongodb"; +import { getVersionsCollection, version, versionMember } from "#collections"; +import { AuthenticatedContext } from "#lib/middleware"; +import { zodId } from "#lib/mongo"; +import { fetchEntryMembers } from "#lib/utils"; + +const inputSchema = z.object({ + contentPieceId: zodId(), + variantId: zodId().optional(), + perPage: z.number().describe("Number of content pieces per page").default(20), + page: z.number().describe("Page number to fetch").default(1), + lastId: zodId().describe("Last token ID to starting fetching tokens from").optional() +}); +const outputSchema = z.array( + version.omit({ members: true }).extend({ + members: z.array(versionMember) + }) +); +const handler = async ( + ctx: AuthenticatedContext, + input: z.infer +): Promise> => { + const versionsCollection = getVersionsCollection(ctx.db); + const cursor = versionsCollection + .find({ + workspaceId: ctx.auth.workspaceId, + contentPieceId: new ObjectId(input.contentPieceId), + ...(input.variantId ? { variantId: new ObjectId(input.variantId) } : {}), + ...(input.lastId ? { _id: { $lt: new ObjectId(input.lastId) } } : {}) + }) + .sort({ date: -1 }); + + if (!input.lastId) { + cursor.skip((input.page - 1) * input.perPage); + } + + const versions = await cursor.limit(input.perPage).toArray(); + + return await Promise.all( + versions.map(async (version) => { + const members = await fetchEntryMembers(ctx.db, version); + + return { + id: `${version._id}`, + contentPieceId: `${version.contentPieceId}`, + date: version.date.toISOString(), + members, + label: version.label, + ...(version.variantId && { variantId: `${version.variantId}` }) + }; + }) + ); +}; + +export { inputSchema, outputSchema, handler }; diff --git a/packages/backend/src/routes/versions/handlers/restore.ts b/packages/backend/src/routes/versions/handlers/restore.ts new file mode 100644 index 00000000..f4a1ab44 --- /dev/null +++ b/packages/backend/src/routes/versions/handlers/restore.ts @@ -0,0 +1,51 @@ +import { z } from "zod"; +import { ObjectId } from "mongodb"; +import { + getVersionsCollection, + getContentVersionsCollection, + getContentsCollection, + getContentVariantsCollection +} from "#collections"; +import { errors } from "#lib/errors"; +import { AuthenticatedContext } from "#lib/middleware"; +import { zodId } from "#lib/mongo"; + +const inputSchema = z.object({ + id: zodId().describe("ID of the version to restore") +}); +const handler = async ( + ctx: AuthenticatedContext, + input: z.infer +): Promise => { + const versionsCollection = getVersionsCollection(ctx.db); + const contentVersionsCollection = getContentVersionsCollection(ctx.db); + const contentsCollection = getContentsCollection(ctx.db); + const contentVariantsCollection = getContentVariantsCollection(ctx.db); + const version = await versionsCollection.findOne({ + _id: new ObjectId(input.id), + workspaceId: ctx.auth.workspaceId + }); + + if (!version) throw errors.notFound("version"); + + const contentVersion = await contentVersionsCollection.findOne({ + versionId: version._id + }); + + if (version.variantId) { + await contentVariantsCollection.updateOne( + { + contentPieceId: new ObjectId(version.contentPieceId), + variantId: new ObjectId(version.variantId) + }, + { $set: { content: contentVersion?.content } } + ); + } else { + await contentsCollection.updateOne( + { contentPieceId: new ObjectId(version.contentPieceId) }, + { $set: { content: contentVersion?.content } } + ); + } +}; + +export { inputSchema, handler }; diff --git a/packages/backend/src/routes/versions/handlers/update.ts b/packages/backend/src/routes/versions/handlers/update.ts new file mode 100644 index 00000000..c350e7e6 --- /dev/null +++ b/packages/backend/src/routes/versions/handlers/update.ts @@ -0,0 +1,48 @@ +import { ObjectId } from "mongodb"; +import { z } from "zod"; +import { version, getVersionsCollection } from "#collections"; +import { errors } from "#lib/errors"; +import { AuthenticatedContext } from "#lib/middleware"; +import { publishVersionEvent } from "#events"; +import { fetchEntryMembers } from "#lib/utils"; + +const inputSchema = version + .pick({ + id: true, + label: true + }) + .partial() + .required({ id: true }); +const handler = async ( + ctx: AuthenticatedContext, + input: z.infer +): Promise => { + const versionsCollection = getVersionsCollection(ctx.db); + const version = await versionsCollection.findOne({ + _id: new ObjectId(input.id), + workspaceId: ctx.auth.workspaceId + }); + + if (!version) throw errors.notFound("version"); + + await versionsCollection.updateOne( + { _id: version._id }, + { ...(input.label ? { $set: { label: input.label } } : { $unset: { label: true } }) } + ); + publishVersionEvent(ctx, `${ctx.auth.workspaceId}`, { + action: "update", + userId: `${ctx.auth.userId}`, + data: { + ...version, + id: `${version._id}`, + workspaceId: `${version.workspaceId}`, + contentPieceId: `${version.contentPieceId}`, + variantId: `${version.variantId}`, + date: version.date?.toISOString(), + label: input.label, + members: await fetchEntryMembers(ctx.db, version) + } + }); +}; + +export { handler, inputSchema }; diff --git a/packages/backend/src/routes/versions/index.ts b/packages/backend/src/routes/versions/index.ts new file mode 100644 index 00000000..4715ffab --- /dev/null +++ b/packages/backend/src/routes/versions/index.ts @@ -0,0 +1,41 @@ +import * as listVersions from "./handlers/list"; +import * as updateVersions from "./handlers/update"; +import * as getVersion from "./handlers/get"; +import * as restoreVersion from "./handlers/restore"; +import { z } from "zod"; +import { subscribeToVersionEvents } from "#events"; +import { isAuthenticated } from "#lib/middleware"; +import { procedure, router } from "#lib/trpc"; + +const authenticatedProcedure = procedure.use(isAuthenticated); +const versionsRouter = router({ + list: authenticatedProcedure + .input(listVersions.inputSchema) + .output(listVersions.outputSchema) + .query(async ({ ctx, input }) => { + return listVersions.handler(ctx, input); + }), + update: authenticatedProcedure + .input(updateVersions.inputSchema) + .output(z.void()) + .mutation(async ({ ctx, input }) => { + return updateVersions.handler(ctx, input); + }), + get: authenticatedProcedure + .input(getVersion.inputSchema) + .output(getVersion.outputSchema) + .query(async ({ ctx, input }) => { + return getVersion.handler(ctx, input); + }), + restore: authenticatedProcedure + .input(restoreVersion.inputSchema) + .output(z.void()) + .mutation(async ({ ctx, input }) => { + return restoreVersion.handler(ctx, input); + }), + changes: authenticatedProcedure.subscription(async ({ ctx }) => { + return subscribeToVersionEvents(ctx, `${ctx.auth.workspaceId}`); + }) +}); + +export { versionsRouter }; diff --git a/packages/editor/src/comment.ts b/packages/editor/src/comment.ts index d3864e12..f6c281f6 100644 --- a/packages/editor/src/comment.ts +++ b/packages/editor/src/comment.ts @@ -45,18 +45,6 @@ const Comment = Mark.create({ mergeAttributes(this.options.HTMLAttributes, HTMLAttributes, { "data-comment": true }), 0 ]; - }, - addCommands() { - return { - setComment: (attributes) => { - return ({ commands }) => commands.setMark("comment", attributes); - }, - unsetComment: () => { - return ({ commands }) => { - return commands.unsetMark("comment"); - }; - } - }; } });