diff --git a/.vscode/settings.json b/.vscode/settings.json index e39adcf4..8b647dbf 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -9,6 +9,7 @@ "hget", "Hocuspocus", "hset", + "kanban", "Kanbans", "Lexo", "listitem", diff --git a/apps/web/src/components/fragments/mini-code-editor.tsx b/apps/web/src/components/fragments/mini-code-editor.tsx index 11857544..ac12a86d 100644 --- a/apps/web/src/components/fragments/mini-code-editor.tsx +++ b/apps/web/src/components/fragments/mini-code-editor.tsx @@ -151,7 +151,7 @@ const MiniCodeEditor: Component = (props) => {
{ const [currentWorkspaceId] = createResource( async () => { try { - return await client.userSettings.getWorkspaceId.query(); + const { workspaceId } = await client.userSettings.getWorkspaceId.query(); + + return workspaceId; } catch (error) { const clientError = error as App.ClientError; diff --git a/apps/web/src/context/command-palette/index.tsx b/apps/web/src/context/command-palette/index.tsx index 5165dc8f..e50e7fda 100644 --- a/apps/web/src/context/command-palette/index.tsx +++ b/apps/web/src/context/command-palette/index.tsx @@ -34,6 +34,7 @@ import { debounce } from "@solid-primitives/scheduled"; import { fetchEventSource } from "@microsoft/fetch-event-source"; import { marked } from "marked"; import { useNavigate } from "@solidjs/router"; +import { Dynamic } from "solid-js/web"; import { ScrollShadow } from "#components/fragments"; import { Button, @@ -57,19 +58,31 @@ interface CommandCategory { interface Command { name: string; category?: string; - icon: string; + icon?: string; subCommands?: Array>; + render?: Component<{ + selected?: boolean; + command: Command; + }>; action?(): void; } +type CommandPaletteMode = "command" | "search" | "ask"; + interface CommandPaletteProps { opened: boolean; + mode: CommandPaletteMode; commands: Command[]; - setOpened(opened: boolean): void; + openedCommand: Command | null; + setOpened: Setter; + setMode: Setter; + setOpenedCommand: Setter; } + interface CommandPaletteContextData { opened: Accessor; - setOpened: Setter; registerCommand(command: Command | Command[]): void; + open(mode?: CommandPaletteMode, openedCommand?: Command | null): void; + close(): void; } const categories: CommandCategory[] = [ @@ -98,9 +111,6 @@ const CommandPalette: Component = (props) => { const { setStorage } = useLocalStorage(); const [inputRef, setInputRef] = createSignal(null); const [abortControllerRef, setAbortControllerRef] = createSignal(null); - const [mode, setMode] = createSignal<"command" | "search" | "ask">( - hostConfig.search ? "search" : "command" - ); const [searchResults, setSearchResults] = createSignal< Array<{ content: string; breadcrumb: string[]; contentPieceId: string }> >([]); @@ -112,8 +122,6 @@ const CommandPalette: Component = (props) => { const [mouseHoverEnabled, setMouseHoverEnabled] = createSignal(false); const [selectedIndex, setSelectedIndex] = createSignal(0); const [query, setQuery] = createSignal(""); - const [badge, setBadge] = createSignal(""); - const [commands, setCommands] = createSignal(props.commands); const ask = async (): Promise => { let content = ""; @@ -176,7 +184,7 @@ const CommandPalette: Component = (props) => { const filteredCommands = createMemo(() => { // Sort commands by categories const sortedCommands = [null, ...categories].flatMap((category) => { - return commands().filter((command) => { + return (props.openedCommand?.subCommands || props.commands).filter((command) => { if (!category) return !command.category; return command.category === category?.id; @@ -212,6 +220,8 @@ const CommandPalette: Component = (props) => { }; const unsubscribeTinykeys = tinykeys(window, { "$mod+KeyK": (event) => { + props.setMode(hostConfig.search ? "search" : "command"); + props.setOpenedCommand(null); props.setOpened(!props.opened); }, "escape": (event) => { @@ -229,10 +239,10 @@ const CommandPalette: Component = (props) => { if (selectedIndex() > 0) { setSelectedIndex(selectedIndex() - 1); scrollToSelectedCommand(); - } else if (mode() === "command") { + } else if (props.mode === "command") { setSelectedIndex(filteredCommands().length - 1); scrollToSelectedCommand(true); - } else if (mode() === "search") { + } else if (props.mode === "search") { setSelectedIndex(searchResults().length - 1); scrollToSelectedCommand(true); } @@ -245,8 +255,8 @@ const CommandPalette: Component = (props) => { event.stopPropagation(); if ( - (mode() === "command" && selectedIndex() < filteredCommands().length - 1) || - (mode() === "search" && selectedIndex() < searchResults().length - 1) + (props.mode === "command" && selectedIndex() < filteredCommands().length - 1) || + (props.mode === "search" && selectedIndex() < searchResults().length - 1) ) { setSelectedIndex(selectedIndex() + 1); scrollToSelectedCommand(); @@ -258,23 +268,20 @@ const CommandPalette: Component = (props) => { "Enter": () => { if (!props.opened) return; - if (mode() === "command") { + if (props.mode === "command") { const selectedCommand = filteredCommands()[selectedIndex()]; if (selectedCommand) { if (selectedCommand.action) { selectedCommand.action(); - setCommands(props.commands); - setBadge(""); - setQuery(""); props.setOpened(false); + setQuery(""); } else if (selectedCommand.subCommands) { - setCommands(selectedCommand.subCommands); - setBadge(selectedCommand.name); + props.setOpenedCommand(selectedCommand); setQuery(""); } } - } else if (mode() === "search") { + } else if (props.mode === "search") { goToContentPiece( searchResults()[selectedIndex()].contentPieceId, searchResults()[selectedIndex()].breadcrumb @@ -288,13 +295,13 @@ const CommandPalette: Component = (props) => { query, (query) => { if (query === ">") { - setMode("command"); + props.setMode("command"); setQuery(""); return; } - if (mode() === "search") { + if (props.mode === "search") { search.clear(); search(); } @@ -303,32 +310,34 @@ const CommandPalette: Component = (props) => { ) ); createEffect( - on(mode, () => { - setMouseHoverEnabled(false); - setSelectedIndex(0); - setLoading(false); - setAnswer(""); - setSearchResults([]); - setQuery(""); - }) + on( + () => props.mode, + () => { + setMouseHoverEnabled(false); + setSelectedIndex(0); + setLoading(false); + setAnswer(""); + setSearchResults([]); + setQuery(""); + } + ) ); createEffect(() => { - if (inputRef() && props.opened && mode() && commands()) { + props.openedCommand; + props.mode; + + if (inputRef() && props.opened) { setTimeout(() => { inputRef()?.focus(); }, 300); } }); - createEffect(() => { - setCommands(props.commands); - setBadge(""); - }); onCleanup(() => { unsubscribeTinykeys(); }); const getIcon = (): string => { - switch (mode()) { + switch (props.mode) { case "command": return mdiConsoleLine; case "search": @@ -338,7 +347,7 @@ const CommandPalette: Component = (props) => { } }; const getLabel = (): string => { - switch (mode()) { + switch (props.mode) { case "command": return "Command"; case "ask": @@ -369,15 +378,15 @@ const CommandPalette: Component = (props) => { >
- + { - if (mode() === "search") { + if (props.mode === "search") { setLoading(true); } @@ -389,7 +398,7 @@ const CommandPalette: Component = (props) => { id="command-palette-input" class="m-0 bg-transparent" onEnter={() => { - if (mode() === "ask" && hostConfig.aiSearch) { + if (props.mode === "ask" && hostConfig.aiSearch) { setLoading(true); setAnswer(""); ask(); @@ -397,29 +406,28 @@ const CommandPalette: Component = (props) => { }} onKeyDown={(event) => { if ( - mode() === "command" && + props.mode === "command" && event.key === "Backspace" && !query() && hostConfig.search ) { - if (badge()) { - setCommands(props.commands); - setBadge(""); + if (props.openedCommand) { + props.setOpenedCommand(null); } else { - setMode("search"); + props.setMode("search"); } } }} adornment={() => ( - + { - setMode((mode) => (mode === "ask" ? "search" : "ask")); + props.setMode((mode) => (mode === "ask" ? "search" : "ask")); }} variant="text" /> @@ -435,7 +443,7 @@ const CommandPalette: Component = (props) => { > - + = (props) => { - + = (props) => { - + No results @@ -573,23 +581,14 @@ const CommandPalette: Component = (props) => { {(command) => { return ( - { if (command.action) { command.action(); - setCommands(props.commands); - setQuery(""); - setBadge(""); props.setOpened(false); + setQuery(""); } else if (command.subCommands) { - setCommands(command.subCommands); - setBadge(command.name); + props.setOpenedCommand(command); setQuery(""); } }} @@ -598,15 +597,37 @@ const CommandPalette: Component = (props) => { setSelectedIndex(filteredCommands().indexOf(command)); }} - color="base" data-selected={command === selectedCommand()} > - - {command.name} - + + + + + {command.name} + + } + > + + +
); }} @@ -655,10 +676,10 @@ const CommandPalette: Component = (props) => { label="Command" size="small" variant="text" - color={mode() === "command" ? "primary" : "base"} - text={mode() === "command" ? "base" : "soft"} + color={props.mode === "command" ? "primary" : "base"} + text={props.mode === "command" ? "base" : "soft"} onClick={() => { - setMode((mode) => (mode === "command" ? "search" : "command")); + props.setMode((mode) => (mode === "command" ? "search" : "command")); }} /> @@ -668,8 +689,13 @@ const CommandPalette: Component = (props) => { ); }; const CommandPaletteProvider: ParentComponent = (props) => { + const hostConfig = useHostConfig(); const [opened, setOpened] = createSignal(false); const [commands, setCommands] = createSignal([]); + const [mode, setMode] = createSignal( + hostConfig.search ? "search" : "command" + ); + const [openedCommand, setOpenedCommand] = createSignal(null); const registerCommand = (command: Command | Command[]): void => { if (Array.isArray(command)) { setCommands((commands) => [...commands, ...command]); @@ -689,17 +715,44 @@ const CommandPaletteProvider: ParentComponent = (props) => { }); }); }; + const open = (mode?: CommandPaletteMode, openedCommand?: Command | null): void => { + setOpened(true); + setMode(mode || (hostConfig.search ? "search" : "command")); + setOpenedCommand(openedCommand || null); + }; + const close = (): void => { + setOpened(false); + setMode(hostConfig.search ? "search" : "command"); + setOpenedCommand(null); + }; + + createEffect( + on(commands, () => { + if (openedCommand() && !commands().includes(openedCommand()!)) { + setOpenedCommand(null); + } + }) + ); return ( {props.children} - + ); }; @@ -708,3 +761,4 @@ const useCommandPalette = (): CommandPaletteContextData => { }; export { CommandPaletteProvider, useCommandPalette }; +export type { Command, CommandPaletteMode }; diff --git a/apps/web/src/context/content/index.tsx b/apps/web/src/context/content/index.tsx index d3e0c363..87ddf63f 100644 --- a/apps/web/src/context/content/index.tsx +++ b/apps/web/src/context/content/index.tsx @@ -1,8 +1,8 @@ import { ContentActions, createContentActions } from "./actions"; import { ContentLoader, createContentLoader } from "./loader"; import { createContext, ParentComponent, useContext } from "solid-js"; -import { createSignal, createEffect, on, onCleanup } from "solid-js"; -import { createStore } from "solid-js/store"; +import { createEffect, on, onCleanup } from "solid-js"; +import { createStore, reconcile } from "solid-js/store"; import { useClient, useLocalStorage, App, useAuthenticatedUserData } from "#context"; interface ContentLevel { @@ -19,13 +19,16 @@ interface ContentDataContextData { App.ExtendedContentPieceWithAdditionalData<"order" | "coverWidth"> | undefined >; contentLevels: Record; + variants: Record; contentActions: ContentActions; contentLoader: ContentLoader; activeContentGroupId(): string | null; activeContentPieceId(): string | null; + activeVariantId(): string | null; expandedContentLevels(): string[]; setActiveContentGroupId(contentGroupId: string | null): void; setActiveContentPieceId(contentPieceId: string | null): void; + setActiveVariantId(variantId: string | null): void; expandContentLevel(contentGroupId: string): void; collapseContentLevel(contentGroupId: string): void; } @@ -35,12 +38,7 @@ const ContentDataProvider: ParentComponent = (props) => { const client = useClient(); const { profile } = useAuthenticatedUserData(); const { storage, setStorage } = useLocalStorage(); - const [activeDraggableContentGroupId, setActiveDraggableContentGroupId] = createSignal< - string | null - >(null); - const [activeDraggableContentPieceId, setActiveDraggableContentPieceId] = createSignal< - string | null - >(null); + const [variants, setVariants] = createStore>({}); const [contentLevels, setContentLevels] = createStore>( {} ); @@ -50,12 +48,21 @@ const ContentDataProvider: ParentComponent = (props) => { const [contentPieces, setContentPieces] = createStore< Record | undefined> >({}); + const activeVariantId = (): string | null => { + return storage().activeVariantId || null; + }; const activeContentGroupId = (): string | null => { return storage().activeContentGroupId || null; }; const activeContentPieceId = (): string | null => { return storage().activeContentPieceId || null; }; + const setActiveVariantId = (variantId: string | null): void => { + setStorage((storage) => ({ + ...storage, + activeVariantId: variantId || undefined + })); + }; const setActiveContentGroupId = (contentGroupId: string | null): void => { setStorage((storage) => ({ ...storage, @@ -109,6 +116,7 @@ const ContentDataProvider: ParentComponent = (props) => { contentGroups, contentPieces, contentLevels, + activeVariantId, setContentGroups, setContentPieces, setContentLevels @@ -134,7 +142,50 @@ const ContentDataProvider: ParentComponent = (props) => { } } }); + const load = (): void => { + setContentLevels(reconcile({})); + setContentGroups(reconcile({})); + setContentPieces(reconcile({})); + expandedContentLevels().forEach(async (id) => { + try { + await contentLoader.loadContentLevel(id); + } catch (e) { + collapseContentLevel(id); + } + }); + client.variants.list.query().then((variants) => { + variants.forEach((variant) => { + setVariants(variant.id, variant); + }); + }); + }; + createEffect( + on(activeVariantId, () => { + load(); + + const variantsSubscription = client.variants.changes.subscribe(undefined, { + onData({ action, data }) { + if (action === "create") { + setVariants(data.id, data); + } else if (action === "update") { + if (variants[data.id]) { + setVariants(data.id, (variant) => ({ + ...variant, + ...data + })); + } + } else if (action === "delete") { + setVariants(data.id, undefined); + } + } + }); + + onCleanup(() => { + variantsSubscription.unsubscribe(); + }); + }) + ); createEffect(() => { for (const contentGroupId in contentGroups) { createEffect( @@ -170,16 +221,24 @@ const ContentDataProvider: ParentComponent = (props) => { ); } }); + createEffect(() => { + const id = activeContentPieceId(); + const variantId = activeVariantId(); + + if (!id || contentPieces[id]) return; + + client.contentPieces.get + .query({ + id, + variant: variantId || undefined + }) + .then((contentPiece) => { + setContentPieces(id, contentPiece); + }); + }); onCleanup(() => { contentGroupsSubscription.unsubscribe(); }); - expandedContentLevels().forEach((id) => { - try { - contentLoader.loadContentLevel(id); - } catch (e) { - collapseContentLevel(id); - } - }); return ( { contentLevels, contentActions, contentLoader, + variants, expandedContentLevels, activeContentGroupId, activeContentPieceId, + activeVariantId, setActiveContentGroupId, setActiveContentPieceId, + setActiveVariantId, expandContentLevel, collapseContentLevel }} diff --git a/apps/web/src/context/content/loader.ts b/apps/web/src/context/content/loader.ts index dc2e4a6d..c1e88ef5 100644 --- a/apps/web/src/context/content/loader.ts +++ b/apps/web/src/context/content/loader.ts @@ -1,4 +1,5 @@ import { SetStoreFunction } from "solid-js/store"; +import { Accessor } from "solid-js"; import { App, ContentLevel, useClient } from "#context"; interface ContentActionsInput { @@ -8,6 +9,7 @@ interface ContentActionsInput { App.ExtendedContentPieceWithAdditionalData<"order" | "coverWidth"> | undefined >; contentLevels: Record; + activeVariantId: Accessor; setContentGroups: SetStoreFunction>; setContentPieces: SetStoreFunction< Record | undefined> @@ -21,6 +23,7 @@ interface ContentLoader { const createContentLoader = ({ contentPieces, contentLevels, + activeVariantId, setContentGroups, setContentPieces, setContentLevels @@ -49,7 +52,8 @@ const createContentLoader = ({ const newContentPieces = await client.contentPieces.list.query({ contentGroupId, - lastOrder: lastPiece.order + lastOrder: lastPiece.order, + variant: activeVariantId() || undefined }); level.pieces.push(...newContentPieces.map((contentPiece) => contentPiece.id)); @@ -88,9 +92,11 @@ const createContentLoader = ({ if (contentGroupId) { const contentPieces = await client.contentPieces.list.query({ - contentGroupId + contentGroupId, + variant: activeVariantId() || undefined }); + console.log("load", activeVariantId(), contentPieces); level.pieces = contentPieces.map((contentPiece) => contentPiece.id); contentPieces.forEach((contentPiece) => { setContentPieces(contentPiece.id, contentPiece); diff --git a/apps/web/src/context/content/opened-piece.tsx b/apps/web/src/context/content/opened-piece.tsx index 48b375c1..01ee17b6 100644 --- a/apps/web/src/context/content/opened-piece.tsx +++ b/apps/web/src/context/content/opened-piece.tsx @@ -1,4 +1,4 @@ -type ContentPiecePropertyKey = keyof App.ExtendedContentPieceWithAdditionalData<"coverWidth">; +/* type ContentPiecePropertyKey = keyof App.ExtendedContentPieceWithAdditionalData<"coverWidth">; interface UseContentGroups { contentGroups: Accessor; @@ -191,3 +191,4 @@ const moveContentGroup = (contentGroup: App.ContentGroup): void => { contentGroups: newContentGroups }); }; +*/ diff --git a/apps/web/src/context/content/variants.ts b/apps/web/src/context/content/variants.ts new file mode 100644 index 00000000..e69de29b diff --git a/apps/web/src/views/content-piece/sections/variants.tsx b/apps/web/src/context/content/variants.tsx similarity index 100% rename from apps/web/src/views/content-piece/sections/variants.tsx rename to apps/web/src/context/content/variants.tsx diff --git a/apps/web/src/context/local-storage.tsx b/apps/web/src/context/local-storage.tsx index 8e9496e3..571f9375 100644 --- a/apps/web/src/context/local-storage.tsx +++ b/apps/web/src/context/local-storage.tsx @@ -12,6 +12,7 @@ interface StorageData { sourceControlConfiguredProvider: string; activeContentGroupId: string; activeContentPieceId: string; + activeVariantId: string; expandedContentLevels: string[]; dashboardView: string; sidePanelView: string; diff --git a/apps/web/src/layout/bottom-menu.tsx b/apps/web/src/layout/bottom-menu.tsx index e40535f5..96af543b 100644 --- a/apps/web/src/layout/bottom-menu.tsx +++ b/apps/web/src/layout/bottom-menu.tsx @@ -1,54 +1,181 @@ -import { mdiHexagonSlice6, mdiCards, mdiSourceBranch } from "@mdi/js"; -import { IconButton } from "@vrite/components"; -import { Component } from "solid-js"; -import { useCommandPalette } from "#context"; +import { mdiHexagonSlice6, mdiCards } from "@mdi/js"; +import { Component, Show, createEffect, createSignal, onCleanup } from "solid-js"; +import clsx from "clsx"; +import { IconButton, Card, Button } from "#components/primitives"; +import { + App, + Command, + useAuthenticatedUserData, + useCommandPalette, + useContentData, + useSharedState +} from "#context"; -const BottomMenu: Component = () => { - const { setOpened, registerCommand } = useCommandPalette(); +const StatDisplay: Component<{ + value: number; + label: string; +}> = (props) => { + return ( +
+ {props.value} + {props.label} +
+ ); +}; +const StatsMenu: Component = () => { + const { useSharedSignal } = useSharedState(); + const [sharedEditor] = useSharedSignal("editor"); + const [stats, setStats] = createSignal({ + words: 0, + textCharacters: 0, + paragraphs: 0, + locs: 0 + }); + const updateStats = (): void => { + let words = 0; + let paragraphs = 0; + let textCharacters = 0; + let locs = 0; + + sharedEditor()?.state.doc.descendants((node) => { + if (node.type.name !== "codeBlock") { + words += + sharedEditor()?.storage.characterCount.words({ + node + }) || 0; + textCharacters += + sharedEditor()?.storage.characterCount.characters({ + node + }) || 0; + } + + if (node.type.name === "paragraph") { + paragraphs += 1; + } + + if (node.type.name === "codeBlock") { + locs += node.textContent.split("\n").length; + } + + return false; + }); + setStats({ + words, + paragraphs, + textCharacters, + locs + }); + }; + + createEffect(() => { + sharedEditor()?.on("update", updateStats); + onCleanup(() => { + sharedEditor()?.off("update", updateStats); + }); + }); + updateStats(); - registerCommand({ + return ( + +
+ + + + +
+
+ ); +}; +const BottomMenu: Component = () => { + const { variants, setActiveVariantId, activeVariantId } = useContentData(); + const { workspace } = useAuthenticatedUserData(); + const { open, registerCommand } = useCommandPalette(); + const VariantCommand: Component<{ selected: boolean; variant: App.Variant }> = (props) => { + return ( + + {props.variant.label} + + + ); + }; + const selectVariantCommand = { category: "workspace", icon: mdiCards, name: "Select Variant", - subCommands: [ - { - icon: "", - name: "Polish", - action: () => { - console.log("polish"); - } - }, - { - icon: "", - name: "Base", - action: () => { - console.log("base"); + get subCommands() { + return [ + ...Object.values(variants) + .map((variant) => { + if (!variant) return; + + return { + icon: "", + name: variant.label, + render: (props) => , + action: () => { + setActiveVariantId(variant.id); + } + }; + }) + .filter(Boolean), + { + icon: "", + name: "Base", + action: () => { + setActiveVariantId(null); + } } - } - ] - }); + ] as Array<{ + icon: string; + name: string; + action(): void; + }>; + } + }; + + registerCommand(selectVariantCommand); return ( -
- +
+ + + { - setOpened(true); + open("command", selectVariantCommand); }} /> +
+
); }; diff --git a/apps/web/src/layout/secured-layout.tsx b/apps/web/src/layout/secured-layout.tsx index 18a6284a..325b75fc 100644 --- a/apps/web/src/layout/secured-layout.tsx +++ b/apps/web/src/layout/secured-layout.tsx @@ -29,8 +29,8 @@ const SecuredLayout: ParentComponent = (props) => { - - + +
{
-
-
+ +
diff --git a/apps/web/src/layout/sidebar-menu.tsx b/apps/web/src/layout/sidebar-menu.tsx index 09431127..38878acc 100644 --- a/apps/web/src/layout/sidebar-menu.tsx +++ b/apps/web/src/layout/sidebar-menu.tsx @@ -251,7 +251,7 @@ const ProfileMenu: Component<{ close(): void }> = (props) => {
- + {workspace()?.name}
diff --git a/apps/web/src/layout/toolbar/index.tsx b/apps/web/src/layout/toolbar/index.tsx index 80205a2c..03f6eab5 100644 --- a/apps/web/src/layout/toolbar/index.tsx +++ b/apps/web/src/layout/toolbar/index.tsx @@ -16,7 +16,6 @@ import { Dynamic } from "solid-js/web"; import clsx from "clsx"; import { JSONContent } from "@vrite/sdk"; import { - App, useClient, useCommandPalette, useContentData, @@ -25,7 +24,7 @@ import { useNotifications, useSharedState } from "#context"; -import { ExportMenu, StatsMenu } from "#views/editor/menus"; +import { ExportMenu } from "#views/editor/menus"; import { Button, Dropdown, Icon, IconButton, Tooltip } from "#components/primitives"; import { logoIcon } from "#assets/icons"; import { breakpoints, isAppleDevice } from "#lib/utils"; @@ -65,12 +64,6 @@ const toolbarViews: Record>> = { >
- setMenuOpened(false)} - class="w-full justify-start" - wrapperClass="w-full" - /> setMenuOpened(false)} @@ -113,7 +106,6 @@ const toolbarViews: Record>> = { >
- >> = { onClick={async () => { try { setLoading(true); - await client.git.github.resolveConflict.mutate({ + await client.git.resolveConflict.mutate({ content: resolvedContent()!, contentPieceId: conflictData()!.contentPieceId, syncedHash: conflictData()!.pulledHash, @@ -256,14 +248,6 @@ const toolbarViews: Record>> = { )} >
- - setMenuOpened(false)} - class="w-full justify-start" - wrapperClass="w-full" - /> - >> = { } > - - - >> = { const hostConfig = useHostConfig(); const { useSharedSignal } = useSharedState(); const { storage, setStorage } = useLocalStorage(); - const { setOpened, registerCommand } = useCommandPalette(); + const { open, registerCommand } = useCommandPalette(); const [provider] = useSharedSignal("provider"); const [viewSelectorOpened, setViewSelectorOpened] = createSignal(false); const view = (): string => storage().dashboardView || "kanban"; @@ -404,7 +385,7 @@ const toolbarViews: Record>> = { onClick={() => { // Force mobile keyboard to open (focus must be in user-triggered event handler) document.getElementById("command-palette-input")?.focus({ preventScroll: true }); - setOpened(true); + open(); }} />
diff --git a/apps/web/src/layout/toolbar/user-list.tsx b/apps/web/src/layout/toolbar/user-list.tsx index b39a6649..a92abdc9 100644 --- a/apps/web/src/layout/toolbar/user-list.tsx +++ b/apps/web/src/layout/toolbar/user-list.tsx @@ -87,7 +87,7 @@ const UserList: Component<{ provider?: HocuspocusProvider }> = (props) => { }); } }; - const users = [...awareness.getStates().entries()].map(([awarenessId, state]) => { + const users = [...(awareness?.getStates().entries() || [])].map(([awarenessId, state]) => { return { awarenessId, ...state.user @@ -95,7 +95,7 @@ const UserList: Component<{ provider?: HocuspocusProvider }> = (props) => { }) as UserAwarenessData[]; setState("users", users); - awareness.on("change", updateUserList); + awareness?.on("change", updateUserList); onCleanup(() => { if (!awareness) return; diff --git a/apps/web/src/lib/editor/extensions/code-block/menu.tsx b/apps/web/src/lib/editor/extensions/code-block/menu.tsx index 4df9cca4..28a24a7f 100644 --- a/apps/web/src/lib/editor/extensions/code-block/menu.tsx +++ b/apps/web/src/lib/editor/extensions/code-block/menu.tsx @@ -38,7 +38,7 @@ const CodeBlockMenu: Component = (props) => { return (
- + = (props) => { const { storage } = props.state.extension; - const [inputMode, setInputMode] = createSignal<"alt" | "src">("src"); + const [inputMode, setInputMode] = createSignal<"alt" | "src" | "caption">("src"); const [uploading, setUploading] = createSignal(false); const attrs = (): ImageAttributes => props.state.node.attrs; const options = (): ImageOptions => props.state.extension.options; const placeholder = (): string => { + if (inputMode() === "caption") return "Caption"; + if (inputMode() === "src") { return options().cover ? "Cover image URL" : "Image URL"; } return options().cover ? "Cover alt description" : "Alt description"; }; - const updateAttribute = debounce((attribute: "src" | "alt", value: string) => { + const updateAttribute = debounce((attribute: "src" | "alt" | "caption", value: string) => { return props.state.updateAttributes({ [attribute]: value }); }, 200); const uploadFile = async (file?: File | null): Promise => { @@ -58,7 +60,7 @@ const ImageMenu: Component = (props) => { @@ -73,18 +75,20 @@ const ImageMenu: Component = (props) => { }} > - - { - setInputMode("src"); - }} - > - + + + { + setInputMode("caption"); + }} + > + + = (props) => { + + { + setInputMode("src"); + }} + > + {" "} { - onKeyDown?(props: SuggestionKeyDownProps): void; - setOnKeyDown(callback: (props: SuggestionKeyDownProps) => void): void; + onKeyDown?(props: SuggestionKeyDownProps): boolean; + setOnKeyDown(callback: (props: SuggestionKeyDownProps) => boolean): void; } interface SlashMenuProps { state: SlashMenuState; diff --git a/apps/web/src/lib/editor/extensions/slash-menu/plugin.ts b/apps/web/src/lib/editor/extensions/slash-menu/plugin.ts index a20b435a..cd7cc216 100644 --- a/apps/web/src/lib/editor/extensions/slash-menu/plugin.ts +++ b/apps/web/src/lib/editor/extensions/slash-menu/plugin.ts @@ -130,7 +130,7 @@ const SlashMenuPlugin = Extension.create({ return true; } - return component?.state().onKeyDown?.(props); + return component?.state().onKeyDown?.(props) ?? false; }, onExit() { diff --git a/apps/web/src/styles/styles.scss b/apps/web/src/styles/styles.scss index 7afb5238..6296fc54 100644 --- a/apps/web/src/styles/styles.scss +++ b/apps/web/src/styles/styles.scss @@ -34,6 +34,9 @@ kbd { font-family: "JetBrainsMonoVariable", monospace; } +.font-mono { + font-family: "JetBrainsMonoVariable", monospace; +} #side-panel > div { height: 100%; } diff --git a/apps/web/src/views/conflict/index.tsx b/apps/web/src/views/conflict/index.tsx index b8837184..3bdeb0e8 100644 --- a/apps/web/src/views/conflict/index.tsx +++ b/apps/web/src/views/conflict/index.tsx @@ -34,7 +34,7 @@ const ConflictView: Component<{ monaco: typeof monaco }> = (props) => { if (!contentPieceId) return null; try { - const result = await client.git.github.getConflictedContent.query({ + const result = await client.git.getConflictedContent.query({ contentPieceId }); diff --git a/apps/web/src/views/content-piece/index.tsx b/apps/web/src/views/content-piece/index.tsx index 300a8e0a..36ff2337 100644 --- a/apps/web/src/views/content-piece/index.tsx +++ b/apps/web/src/views/content-piece/index.tsx @@ -40,8 +40,7 @@ const ContentPieceView: Component = () => { const sections = [ { label: "Details", id: "details", icon: mdiInformationOutline }, { label: "Custom data", id: "custom-data", icon: mdiCodeJson }, - hostConfig.extensions && { label: "Extensions", id: "extensions", icon: mdiPuzzleOutline }, - { label: "Variants", id: "variants", icon: mdiCardsOutline } + hostConfig.extensions && { label: "Extensions", id: "extensions", icon: mdiPuzzleOutline } ].filter(Boolean) as Array<{ label: string; id: string; @@ -228,21 +227,6 @@ const ContentPieceView: Component = () => {
-
- - { - setActiveSection(sections[3]); - }} - /> - -
= (props) => { - const hostConfig = useHostConfig(); const [menuOpened, setMenuOpened] = createSignal(false); const [scrollableSectionRef, setScrollableSectionRef] = createRef(null); @@ -35,7 +34,7 @@ const ContentPieceMetadata: Component = (props) => { scrollableSection.addEventListener("scroll", (event) => { const sectionIndex = Math.round( - (scrollableSection.scrollLeft / scrollableSection.scrollWidth) * 4 + (scrollableSection.scrollLeft / scrollableSection.scrollWidth) * 3 ); props.setActiveSection(props.sections[sectionIndex]); @@ -59,6 +58,7 @@ const ContentPieceMetadata: Component = (props) => { )} opened={menuOpened()} setOpened={setMenuOpened} + cardProps={{ class: "m-0" }} >
@@ -74,9 +74,8 @@ const ContentPieceMetadata: Component = (props) => { onClick={() => { props.setActiveSection(section); scrollableSectionRef()?.scrollTo({ - behavior: "smooth", left: - scrollableSectionRef()!.scrollWidth * (props.sections.indexOf(section) / 4) + scrollableSectionRef()!.scrollWidth * (props.sections.indexOf(section) / 3) }); setMenuOpened(false); }} @@ -87,7 +86,7 @@ const ContentPieceMetadata: Component = (props) => {
@@ -146,12 +145,6 @@ const ContentPieceMetadata: Component = (props) => { }} />
-
- -
); diff --git a/apps/web/src/views/content-piece/sections/custom-data.tsx b/apps/web/src/views/content-piece/sections/custom-data.tsx index f13dab3b..058c540f 100644 --- a/apps/web/src/views/content-piece/sections/custom-data.tsx +++ b/apps/web/src/views/content-piece/sections/custom-data.tsx @@ -23,12 +23,15 @@ const CustomDataSection: Component = (props) => { }; return ( - +
+ +
); }; diff --git a/apps/web/src/views/content-piece/sections/extensions.tsx b/apps/web/src/views/content-piece/sections/extensions.tsx index 43657fe3..4b8d60fb 100644 --- a/apps/web/src/views/content-piece/sections/extensions.tsx +++ b/apps/web/src/views/content-piece/sections/extensions.tsx @@ -70,39 +70,41 @@ const ExtensionsSection: Component = (props) => { } when={true} > -
-
- - {(extension) => { - return ( - - - - ); - }} - -
+
+ +
+ + {(extension) => { + return ( + + + + ); + }} + +
+
= (props) => { - const { activeContentPieceId } = useContentData(); + const { activeContentPieceId, setActiveContentPieceId } = useContentData(); const { setStorage } = useLocalStorage(); const { deletedTags } = useAuthenticatedUserData(); const navigate = useNavigate(); @@ -57,9 +57,9 @@ const ContentPieceCard: Component = (props) => { setStorage((storage) => ({ ...storage, sidePanelView: "contentPiece", - sidePanelWidth: storage.sidePanelWidth || 375, - contentPieceId: props.contentPiece.id + sidePanelWidth: storage.sidePanelWidth || 375 })); + setActiveContentPieceId(props.contentPiece.id); }} data-content-piece-id={props.contentPiece.id} data-index={props.index} @@ -147,9 +147,9 @@ const ContentPieceCard: Component = (props) => { setStorage((storage) => ({ ...storage, sidePanelWidth: breakpoints.md() ? storage.sidePanelWidth || 375 : 0, - sidePanelView: "contentPiece", - contentPieceId: props.contentPiece.id + sidePanelView: "contentPiece" })); + setActiveContentPieceId(props.contentPiece.id); navigate("/editor"); }} /> diff --git a/apps/web/src/views/editor/editor.tsx b/apps/web/src/views/editor/editor.tsx index 36ed6998..983a009f 100644 --- a/apps/web/src/views/editor/editor.tsx +++ b/apps/web/src/views/editor/editor.tsx @@ -69,13 +69,11 @@ interface EditorProps { } const Editor: Component = (props) => { + const { activeVariantId } = useContentData(); const hostConfig = useHostConfig(); const navigate = useNavigate(); const location = useLocation<{ breadcrumb?: string[] }>(); const { useSharedSignal } = useSharedState(); - /* TODO: Use ContentData - const [activeVariant] = createSharedSignal("activeVariant"); */ - const activeVariant = (): null => null; const ydoc = new Y.Doc(); const [containerRef, setContainerRef] = createRef(null); const handleReload = async (): Promise => { @@ -107,8 +105,8 @@ const Editor: Component = (props) => { }, onDisconnect: handleReload, onAuthenticationFailed: handleReload, - name: `${props.editedContentPiece.id || ""}${activeVariant() ? ":" : ""}${ - activeVariant()?.id || "" + name: `${props.editedContentPiece.id || ""}${activeVariantId() ? ":" : ""}${ + activeVariantId() || "" }`, document: ydoc }); @@ -248,7 +246,6 @@ const Editor: Component = (props) => { provider.destroy(); setSharedEditor(undefined); setSharedProvider(undefined); - setActiveContentPieceId(null); }); createEffect( on( diff --git a/apps/web/src/views/editor/index.tsx b/apps/web/src/views/editor/index.tsx index 1b578479..13c31352 100644 --- a/apps/web/src/views/editor/index.tsx +++ b/apps/web/src/views/editor/index.tsx @@ -38,16 +38,17 @@ const EditorView: Component = () => { }); } }); + setStorage((storage) => ({ ...storage, toolbarView: "editor" })); createEffect( on( - activeContentPieceId, - () => { - setSyncing(true); - }, - { defer: true } + () => contentPieces[activeContentPieceId() || ""], + (newContentPiece, previousContentPiece) => { + if (newContentPiece !== previousContentPiece) { + setSyncing(true); + } + } ) ); - setStorage((storage) => ({ ...storage, toolbarView: "editor" })); return ( <> @@ -55,11 +56,9 @@ const EditorView: Component = () => { when={activeContentPieceId()} fallback={
- {/* */} To edit, select an article in the dashboard - {/* */}
} > @@ -76,25 +75,29 @@ const EditorView: Component = () => { storage().zenMode ? "items-center" : "items-start" )} > - - { - setReloaded(true); - }} - onLoad={() => { - setTimeout(() => { - scrollableContainerRef()?.scrollTo({ top: lastScrollTop() }); - }, 0); - setSyncing(false); - }} - /> + + + { + setReloaded(true); + }} + onLoad={() => { + setTimeout(() => { + scrollableContainerRef()?.scrollTo({ top: lastScrollTop() }); + }, 0); + setSyncing(false); + }} + /> +
- +
diff --git a/apps/web/src/views/editor/menus/index.ts b/apps/web/src/views/editor/menus/index.ts index 185895e7..d6206ffe 100644 --- a/apps/web/src/views/editor/menus/index.ts +++ b/apps/web/src/views/editor/menus/index.ts @@ -3,4 +3,3 @@ export * from "./comment-threads"; export * from "./export"; export * from "./floating"; export * from "./link-preview"; -export * from "./stats"; diff --git a/apps/web/src/views/editor/menus/stats/index.tsx b/apps/web/src/views/editor/menus/stats/index.tsx deleted file mode 100644 index eb4f56ec..00000000 --- a/apps/web/src/views/editor/menus/stats/index.tsx +++ /dev/null @@ -1,137 +0,0 @@ -import { SolidEditor } from "@vrite/tiptap-solid"; -import { mdiInformationOutline } from "@mdi/js"; -import { Component, createSignal, onCleanup } from "solid-js"; -import clsx from "clsx"; -import { Button, Dropdown, IconButton } from "#components/primitives"; -import { useCommandPalette } from "#context"; - -interface StatsMenuProps { - editor?: SolidEditor; - wrapperClass?: string; - class?: string; - onClick?(): void; -} - -const StatsMenu: Component = (props) => { - const { registerCommand = () => {} } = useCommandPalette() || {}; - const [opened, setOpened] = createSignal(false); - const [stats, setStats] = createSignal({ - words: 0, - textCharacters: 0, - paragraphs: 0, - locs: 0 - }); - const updateStats = (): void => { - let words = 0; - let paragraphs = 0; - let textCharacters = 0; - let locs = 0; - - props.editor?.state.doc.descendants((node) => { - if (node.type.name !== "codeBlock") { - words += - props.editor?.storage.characterCount.words({ - node - }) || 0; - textCharacters += - props.editor?.storage.characterCount.characters({ - node - }) || 0; - } - - if (node.type.name === "paragraph") { - paragraphs += 1; - } - - if (node.type.name === "codeBlock") { - locs += node.textContent.split("\n").length; - } - - return false; - }); - setStats({ - words, - paragraphs, - textCharacters, - locs - }); - }; - - props.editor?.on("update", updateStats); - updateStats(); - onCleanup(() => { - props.editor?.off("update", updateStats); - }); - registerCommand({ - action() { - setOpened(true); - }, - category: "editor", - icon: mdiInformationOutline, - name: "View stats" - }); - - return ( - ( - - )} - > -
- - - - -
-
- ); -}; - -export { StatsMenu }; diff --git a/apps/web/src/views/explorer/tree-level.tsx b/apps/web/src/views/explorer/tree-level.tsx index 1982104c..85f06817 100644 --- a/apps/web/src/views/explorer/tree-level.tsx +++ b/apps/web/src/views/explorer/tree-level.tsx @@ -2,12 +2,12 @@ import { ContentGroupRow } from "./content-group-row"; import { ContentPieceRow } from "./content-piece-row"; import { useExplorerData } from "./explorer-context"; import clsx from "clsx"; -import { Component, createEffect, Show, For, createSignal } from "solid-js"; +import { Component, createEffect, Show, For } from "solid-js"; import SortableLib from "sortablejs"; import { Icon, Loader } from "@vrite/components"; import { mdiDotsHorizontalCircleOutline } from "@mdi/js"; import { useLocation, useNavigate } from "@solidjs/router"; -import { App, useClient, useContentData, useLocalStorage } from "#context"; +import { App, useClient, useContentData } from "#context"; const TreeLevel: Component<{ parentId?: string; @@ -20,12 +20,13 @@ const TreeLevel: Component<{ expandContentLevel, collapseContentLevel, activeContentGroupId, + setActiveContentGroupId, + setActiveContentPieceId, contentLoader, contentActions } = useContentData(); const { highlight, setHighlight, activeDraggableContentGroupId, activeDraggableContentPieceId } = useExplorerData(); - const { setStorage } = useLocalStorage(); const client = useClient(); const location = useLocation(); const navigate = useNavigate(); @@ -187,10 +188,7 @@ const TreeLevel: Component<{ }} onClick={() => { navigate("/"); - setStorage((storage) => ({ - ...storage, - activeContentGroupId: groupId - })); + setActiveContentGroupId(groupId); }} onExpand={(forceOpen) => { if (expandedContentLevels().includes(groupId) && !forceOpen) { @@ -215,10 +213,7 @@ const TreeLevel: Component<{ contentPiece={contentPieces[contentPieceId]!} onClick={() => { navigate("/editor"); - setStorage((storage) => ({ - ...storage, - contentPieceId - })); + setActiveContentPieceId(contentPieceId); }} onDragEnd={() => { const contentPiece = contentPieces[contentPieceId]!; diff --git a/apps/web/src/views/git/provider-configuration-view/github.tsx b/apps/web/src/views/git/provider-configuration-view/github.tsx index 225ad6d8..b08d9213 100644 --- a/apps/web/src/views/git/provider-configuration-view/github.tsx +++ b/apps/web/src/views/git/provider-configuration-view/github.tsx @@ -387,9 +387,9 @@ const GitHubConfigurationView: Component = (props) label="Base Variant Directory" color="contrast" type="text" - value={savedGitHubConfig()?.variantsDirectory || variantsDirectory()} + value={savedGitHubConfig()?.baseVariantDirectory || baseVariantDirectory()} disabled={Boolean(savedGitHubConfig())} - setValue={setVariantsDirectory} + setValue={setBaseVariantDirectory} placeholder="/en" > Directory of the base variant. Only files that exist in both base and other variant diff --git a/apps/web/src/views/git/sync-view/index.tsx b/apps/web/src/views/git/sync-view/index.tsx index 315eab23..74b34c46 100644 --- a/apps/web/src/views/git/sync-view/index.tsx +++ b/apps/web/src/views/git/sync-view/index.tsx @@ -73,7 +73,7 @@ const InitialSyncCard: Component = () => { setLoading(true); try { - await client.git.github.initialSync.mutate(); + await client.git.initialSync.mutate(); notify({ text: "Latest content pulled", type: "success" }); } catch (error) { notify({ text: "Couldn't pull content", type: "error" }); @@ -112,9 +112,9 @@ const CommitCard: Component<{ changedRecords: App.GitRecord[] }> = (props) => { setLoading(true); try { - const { status } = await client.git.github.commit.mutate({ message: message() }); + const { status } = await client.git.commit.mutate({ message: message() }); - if (status === "committed") { + if (status === "success") { notify({ text: "Changes committed", type: "success" }); setMessage(""); } else { @@ -236,7 +236,7 @@ const PullCard: Component = () => { setLoading(true); try { - const data = await client.git.github.pull.mutate({}); + const data = await client.git.pull.mutate({}); if (data.status === "conflict") { setConflicts(data.conflicted || []); diff --git a/packages/backend/src/collections/git-data.ts b/packages/backend/src/collections/git-data.ts index c158f0bf..acff5492 100644 --- a/packages/backend/src/collections/git-data.ts +++ b/packages/backend/src/collections/git-data.ts @@ -36,8 +36,9 @@ const gitData = z.object({ }); interface GitRecord - extends Omit, "contentPieceId"> { + extends Omit, "contentPieceId" | "variantId"> { contentPieceId: ID; + variantId?: ID; } interface GitDirectory extends Omit, "contentGroupId"> { diff --git a/packages/backend/src/collections/variants.ts b/packages/backend/src/collections/variants.ts index e55e14af..6d9a1eb2 100644 --- a/packages/backend/src/collections/variants.ts +++ b/packages/backend/src/collections/variants.ts @@ -6,11 +6,7 @@ const variant = z.object({ id: zodId(), label: z.string().min(1).max(50), description: z.string().optional(), - key: z - .string() - .min(1) - .max(20) - .regex(/^[a-z0-9_]*$/) + key: z.string().min(1).max(50) }); interface Variant @@ -26,4 +22,4 @@ const getVariantsCollection = (db: Db): Collection>>; + newVariants: Map>>; +}> => { + const variantsCollection = getVariantsCollection(ctx.db); + const newVariants = new Map>>(); + const variants = new Map>>(); + const variantsCursor = variantsCollection.find({ + key: { $in: variantKeys.map((variantKey) => variantKey.toLowerCase()) }, + workspaceId: ctx.auth.workspaceId + }); + + for await (const variant of variantsCursor) { + variants.set(variant.key, variant); + } + + for (const variantKey of variantKeys) { + if (!variants.has(variantKey)) { + const newVariant: UnderscoreID> = { + _id: new ObjectId(), + key: variantKey.toLowerCase(), + label: variantKey, + workspaceId: ctx.auth.workspaceId + }; + + newVariants.set(variantKey.toLowerCase(), newVariant); + variants.set(variantKey.toLowerCase(), newVariant); + } + } + + return { variants, newVariants }; +}; const initialSync: GitSyncConfiguration["initialSync"] = async ({ ctx, gitData }) => { if (!gitData?.github) throw errors.notFound("gitData"); + // Content Data const newContentGroups: UnderscoreID>[] = []; const newContentPieces: UnderscoreID>[] = []; const newContentPieceVariants: UnderscoreID>[] = []; @@ -27,93 +85,293 @@ const initialSync: GitSyncConfiguration["initialSync"] = async ({ ctx, gitData } const newContentVariants: UnderscoreID>[] = []; const newRecords: Array> = []; const newDirectories: Array> = []; + // Setup const octokit = await ctx.fastify.github.getInstallationOctokit(gitData?.github.installationId); - const { baseDirectory } = gitData.github; - const basePath = baseDirectory.startsWith("/") ? baseDirectory.slice(1) : baseDirectory; const inputContentProcessor = await createInputContentProcessor(ctx, gitData.github.transformer); - const syncDirectory = async ( - path: string, - ancestors: ObjectId[] - ): Promise>> => { - const syncedPath = path.startsWith("/") ? path.slice(1) : path; - const recordPath = path.replace(basePath, "").split("/").filter(Boolean).join("/"); - const entries = await getDirectory({ - githubData: gitData.github!, - octokit, - payload: { path: syncedPath } - }); - const name = recordPath.split("/").pop() || gitData.github?.repositoryName || ""; - const contentGroupId = new ObjectId(); - const descendants: ObjectId[] = []; - const createSyncedPiecesSource: Array<{ - path: string; - content: string; - workspaceId: ObjectId; - contentGroupId: ObjectId; - order: string; - }> = []; - - let order = LexoRank.min(); - - for await (const entry of entries) { - if (entry.type === "tree") { - const descendantContentGroup = await syncDirectory( - syncedPath.split("/").filter(Boolean).concat(entry.name).join("/"), - [...ancestors, contentGroupId] - ); - - descendants.push(descendantContentGroup._id); - } else if ( - entry.type === "blob" && - entry.object.text && - minimatch(entry.name, gitData.github!.matchPattern) - ) { - createSyncedPiecesSource.push({ - content: entry.object.text, - path: [...recordPath.split("/"), entry.name].filter(Boolean).join("/"), - workspaceId: ctx.auth.workspaceId, - contentGroupId, - order: order.toString() - }); - order = order.genNext(); - } - } - const syncedPieces = await createSyncedPieces(createSyncedPiecesSource, inputContentProcessor); + let { baseDirectory, variantsDirectory, baseVariantDirectory } = gitData.github; + + if (baseDirectory.startsWith("/")) baseDirectory = baseDirectory.slice(1); + if (variantsDirectory.startsWith("/")) variantsDirectory = variantsDirectory.slice(1); + if (baseVariantDirectory.startsWith("/")) baseVariantDirectory = baseVariantDirectory.slice(1); + + // Latest commit + const latestGitHubCommit = await getLastCommit({ octokit, githubData: gitData.github! }); - syncedPieces.forEach(({ contentPiece, content, contentHash }, index) => { - const { path } = createSyncedPiecesSource[index]; + if (!latestGitHubCommit) throw errors.notFound("lastCommit"); - newContentPieces.push(contentPiece); - newContents.push(content); - newRecords.push({ - contentPieceId: contentPiece._id, - currentHash: contentHash, - syncedHash: contentHash, - path + // GitHub Entries -> Raw Git Records + const fetchRawGitData = async (): Promise<{ + rawGitDirectory: RawGitDirectory; + rawGitDirectoriesByPath: Map; + rawGitRecordsByPath: Map; + }> => { + const rawGitDirectoriesByPath = new Map(); + const rawGitRecordsByPath = new Map(); + const fetchRawGitDirectory = async (path: string): Promise => { + const syncedPath = path.startsWith("/") ? path.slice(1) : path; + const recordPath = path.replace(baseDirectory, "").split("/").filter(Boolean).join("/"); + const name = recordPath.split("/").pop() || gitData.github?.repositoryName || ""; + const rawGitRecords: RawGitRecord[] = []; + const rawGitRecordsWithoutContent: Array> = + []; + const rawGitRecordsContents = new Map(); + const rawGitDirectories: RawGitDirectory[] = []; + const entries = await getDirectory({ + githubData: gitData.github!, + octokit, + payload: { path: syncedPath } }); - }); - const contentGroup: UnderscoreID> = { + let order = LexoRank.min(); + + for await (const entry of entries) { + if (entry.type === "tree") { + const rawGitDirectory = await fetchRawGitDirectory( + syncedPath.split("/").filter(Boolean).concat(entry.name).join("/") + ); + + rawGitDirectories.push(rawGitDirectory); + rawGitDirectoriesByPath.set(rawGitDirectory.path, rawGitDirectory); + } else if ( + entry.type === "blob" && + entry.object.text && + minimatch(entry.name, gitData.github!.matchPattern) + ) { + const path = [...recordPath.split("/"), entry.name].filter(Boolean).join("/"); + + rawGitRecordsWithoutContent.push({ + order: `${order}`, + path + }); + rawGitRecordsContents.set(path, entry.object.text); + order = order.genNext(); + } + } + + for await (const rawGitRecordWithoutContent of rawGitRecordsWithoutContent) { + const rawGitRecordContent = rawGitRecordsContents.get(rawGitRecordWithoutContent.path); + + if (rawGitRecordContent) { + const { buffer, hash, metadata } = + await inputContentProcessor.process(rawGitRecordContent); + const rawGitRecord = { + ...rawGitRecordWithoutContent, + hash, + buffer, + metadata + }; + + rawGitRecords.push(rawGitRecord); + rawGitRecordsByPath.set(rawGitRecord.path, rawGitRecord); + } + } + + return { + records: rawGitRecords, + directories: rawGitDirectories, + path: syncedPath, + name + }; + }; + + return { + rawGitDirectory: await fetchRawGitDirectory(baseDirectory), + rawGitDirectoriesByPath, + rawGitRecordsByPath + }; + }; + const rawGitData = await fetchRawGitData(); + // Raw Git Records [Process Variants] + const processVariants = ({ + rawGitDirectory, + rawGitRecordsByPath + }: { + rawGitDirectory: RawGitDirectory; + rawGitDirectoriesByPath: Map; + rawGitRecordsByPath: Map; + }): { variantKeys: string[]; rawGitDirectory: RawGitDirectory } => { + let variantKeys: string[] = []; + + const processRawGitVariantsDirectory = (rawGitDirectory: RawGitDirectory): RawGitDirectory => { + if (rawGitDirectory.path.startsWith(variantsDirectory)) { + const baseVariantRawGitDirectory = rawGitDirectory.directories.find((directory) => { + return directory.path.startsWith(`${variantsDirectory}/${baseVariantDirectory}`); + }); + + variantKeys = rawGitDirectory.directories + .filter((directory) => { + return !directory.path.startsWith(`${variantsDirectory}/${baseVariantDirectory}`); + }) + .map((directory) => directory.name); + + if (!baseVariantRawGitDirectory) return rawGitDirectory; + + const processBaseVariantDirectory = (rawGitDirectory: RawGitDirectory): RawGitDirectory => { + const newRecords = rawGitDirectory.records.flatMap((record) => { + return [ + { + buffer: record.buffer, + hash: record.hash, + metadata: record.metadata, + order: record.order, + path: record.path.replace( + `${variantsDirectory}/${baseVariantDirectory}`, + variantsDirectory + ) + }, + ...(variantKeys + .map((variantKey) => { + const rawGitRecord = rawGitRecordsByPath.get( + record.path.replace( + `${variantsDirectory}/${baseVariantDirectory}`, + `${variantsDirectory}/${variantKey}` + ) + ); + + if (!rawGitRecord) return null; + + return { + buffer: rawGitRecord.buffer, + hash: rawGitRecord.hash, + metadata: rawGitRecord.metadata, + order: record.order, + variantKey, + path: rawGitRecord.path.replace( + `${variantsDirectory}/${variantKey}`, + variantsDirectory + ) + }; + }) + .filter(Boolean) as RawGitRecord[]) + ]; + }); + const newDirectories = rawGitDirectory.directories.map((directory) => { + return processBaseVariantDirectory(directory); + }); + + return { + name: rawGitDirectory.name, + path: rawGitDirectory.path.replace( + `${variantsDirectory}/${baseVariantDirectory}`, + variantsDirectory + ), + records: newRecords, + directories: newDirectories + }; + }; + + return { + ...processBaseVariantDirectory(baseVariantRawGitDirectory), + name: rawGitDirectory.name + }; + } + + return { + ...rawGitDirectory, + directories: rawGitDirectory.directories.map((directory) => { + return processRawGitVariantsDirectory(directory); + }) + }; + }; + + return { rawGitDirectory: processRawGitVariantsDirectory(rawGitDirectory), variantKeys }; + }; + const result = processVariants(rawGitData); + const { variants, newVariants } = await retrieveVariants(ctx, result.variantKeys); + // Raw Git Records -> Content Data + const rawGitDirectoryToContentGroup = ( + rawGitDirectory: RawGitDirectory, + ancestors: ObjectId[] = [] + ): UnderscoreID> => { + const contentGroupId = new ObjectId(); + const contentGroup = { _id: contentGroupId, - workspaceId: ctx.auth.workspaceId, - name, ancestors, - descendants + descendants: [] as ObjectId[], + name: rawGitDirectory.name, + workspaceId: ctx.auth.workspaceId }; newContentGroups.push(contentGroup); newDirectories.push({ - path: recordPath, - contentGroupId + contentGroupId, + path: rawGitDirectory.path }); + rawGitDirectory.records + .filter((record) => !record.variantKey) + .forEach((record) => { + const filename = record.path.split("/").pop() || ""; + const { members, tags, date, ...inputMetadata } = record.metadata; + const contentPiece: UnderscoreID> = { + _id: new ObjectId(), + workspaceId: ctx.auth.workspaceId, + order: record.order, + members: [], + slug: convertToSlug(filename), + tags: [], + title: filename, + contentGroupId, + filename, + ...inputMetadata, + ...(date && { date: new Date(date) }), + ...(members && { members: members.map((memberId) => new ObjectId(memberId)) }), + ...(tags && { tags: tags.map((tagId) => new ObjectId(tagId)) }) + }; + const content: UnderscoreID> = { + _id: new ObjectId(), + contentPieceId: contentPiece._id, + content: new Binary(record.buffer) + }; + + newContentPieces.push(contentPiece); + newContents.push(content); + newRecords.push({ + contentPieceId: contentPiece._id, + currentHash: record.hash, + syncedHash: record.hash, + path: record.path + }); + rawGitDirectory.records + .filter((variantRecord) => variantRecord.variantKey && variantRecord.path === record.path) + .forEach((record) => { + const { members, tags, date, ...inputMetadata } = record.metadata; + const variantId = variants.get(record.variantKey!.toLowerCase())!._id; + const contentPieceVariant: UnderscoreID> = { + _id: new ObjectId(), + workspaceId: ctx.auth.workspaceId, + members: [], + slug: convertToSlug(filename), + tags: [], + title: filename, + filename, + contentPieceId: contentPiece._id, + variantId, + ...inputMetadata, + ...(date && { date: new Date(date) }), + ...(members && { members: members.map((memberId) => new ObjectId(memberId)) }), + ...(tags && { tags: tags.map((tagId) => new ObjectId(tagId)) }) + }; + const contentVariant: UnderscoreID> = { + _id: new ObjectId(), + contentPieceId: contentPiece._id, + content: new Binary(record.buffer), + variantId + }; + + newContentPieceVariants.push(contentPieceVariant); + newContentVariants.push(contentVariant); + }); + }); + contentGroup.descendants = rawGitDirectory.directories + .map((directory) => { + return rawGitDirectoryToContentGroup(directory, [contentGroupId, ...ancestors]); + }) + .map((contentGroup) => contentGroup._id); return contentGroup; }; - const topContentGroup = await syncDirectory(basePath, []); - const latestGitHubCommit = await getLastCommit({ octokit, githubData: gitData.github! }); - - if (!latestGitHubCommit) throw errors.notFound("lastCommit"); + const topContentGroup = rawGitDirectoryToContentGroup(result.rawGitDirectory); return { newContentGroups, @@ -123,6 +381,7 @@ const initialSync: GitSyncConfiguration["initialSync"] = async ({ ctx, gitData } newContentVariants, newRecords, newDirectories, + newVariants: [...newVariants.values()], topContentGroup, lastCommit: { date: latestGitHubCommit.committedDate, diff --git a/packages/backend/src/lib/git-sync/integration.ts b/packages/backend/src/lib/git-sync/integration.ts index e884df61..7cbcfea6 100644 --- a/packages/backend/src/lib/git-sync/integration.ts +++ b/packages/backend/src/lib/git-sync/integration.ts @@ -6,6 +6,7 @@ import { FullContentVariant, FullContents, FullGitData, + FullVariant, GitDirectory, GitRecord } from "#collections"; @@ -43,8 +44,11 @@ interface GitSyncConfiguration { newContentGroups: UnderscoreID>[]; newContentPieces: UnderscoreID>[]; newContents: UnderscoreID>[]; - newRecords: Array>; - newDirectories: Array>; + newRecords: GitRecord[]; + newDirectories: GitDirectory[]; + newVariants: UnderscoreID>[]; + newContentPieceVariants: UnderscoreID>[]; + newContentVariants: UnderscoreID>[]; topContentGroup: UnderscoreID>; lastCommit: GitSyncCommit; }>; diff --git a/packages/backend/src/lib/git-sync/process-content.ts b/packages/backend/src/lib/git-sync/process-content.ts index 33203f06..abd4a242 100644 --- a/packages/backend/src/lib/git-sync/process-content.ts +++ b/packages/backend/src/lib/git-sync/process-content.ts @@ -23,7 +23,7 @@ import { interface ProcessInputResult { buffer: Buffer; - contentHash: string; + hash: string; metadata: Partial< Pick["contentPiece"]>> >; @@ -106,12 +106,12 @@ const createInputContentProcessor = async ( const { content, contentPiece } = transformed[index]; const buffer = jsonToBuffer(htmlToJSON(content || "

")); const metadata = contentPiece || {}; - const contentHash = crypto.createHash("md5").update(inputContent).digest("hex"); + const hash = crypto.createHash("md5").update(inputContent).digest("hex"); return { buffer, metadata, - contentHash + hash }; }); }, @@ -119,12 +119,12 @@ const createInputContentProcessor = async ( const [{ content, contentPiece }] = await transformInputContent([inputContent]); const buffer = jsonToBuffer(htmlToJSON(content || "

")); const metadata = contentPiece || {}; - const contentHash = crypto.createHash("md5").update(inputContent).digest("hex"); + const hash = crypto.createHash("md5").update(inputContent).digest("hex"); return { buffer, metadata, - contentHash + hash }; } }; @@ -255,4 +255,9 @@ const createOutputContentProcessor = async ( }; export { createInputContentProcessor, createOutputContentProcessor }; -export type { InputContentProcessor, OutputContentProcessor, OutputContentProcessorInput }; +export type { + InputContentProcessor, + OutputContentProcessor, + OutputContentProcessorInput, + ProcessInputResult +}; diff --git a/packages/backend/src/lib/git-sync/process-pulled-records.ts b/packages/backend/src/lib/git-sync/process-pulled-records.ts index 396a8898..61c36029 100644 --- a/packages/backend/src/lib/git-sync/process-pulled-records.ts +++ b/packages/backend/src/lib/git-sync/process-pulled-records.ts @@ -193,7 +193,7 @@ const processPulledRecords = async ({ if (!contentGroupId) continue; if (existingRecord) { - const { buffer, contentHash, metadata } = await inputContentProcessor.process( + const { buffer, hash, metadata } = await inputContentProcessor.process( changedRecord.content || "" ); const { date, members, tags, ...restMetadata } = metadata; @@ -222,8 +222,8 @@ const processPulledRecords = async ({ contentPieceId: existingRecord.contentPieceId, content: new Binary(buffer) }); - existingRecord.syncedHash = contentHash; - existingRecord.currentHash = contentHash; + existingRecord.syncedHash = hash; + existingRecord.currentHash = hash; } continue; @@ -254,15 +254,15 @@ const processPulledRecords = async ({ const syncedPieces = await createSyncedPieces(createSyncedPiecesSource, inputContentProcessor); - syncedPieces.forEach(({ contentPiece, content, contentHash }, index) => { + syncedPieces.forEach(({ contentPiece, content, hash }, index) => { const { path } = createSyncedPiecesSource[index]; newContentPieces.push(contentPiece); newContents.push(content); newRecords.push({ contentPieceId: contentPiece._id, - currentHash: contentHash, - syncedHash: contentHash, + currentHash: hash, + syncedHash: hash, path }); }); diff --git a/packages/backend/src/lib/git-sync/synced-pieces.ts b/packages/backend/src/lib/git-sync/synced-pieces.ts index 8b201368..1222b041 100644 --- a/packages/backend/src/lib/git-sync/synced-pieces.ts +++ b/packages/backend/src/lib/git-sync/synced-pieces.ts @@ -17,7 +17,7 @@ const createSyncedPieces = async ( Array<{ contentPiece: UnderscoreID>; content: UnderscoreID>; - contentHash: string; + hash: string; }> > => { const inputContentProcessorOutput = await inputContentProcessor.processBatch( @@ -26,7 +26,7 @@ const createSyncedPieces = async ( return inputs.map((input, index) => { const filename = input.path.split("/").pop() || ""; - const { buffer, contentHash, metadata } = inputContentProcessorOutput[index]; + const { buffer, hash, metadata } = inputContentProcessorOutput[index]; const { members, tags, date, ...inputMetadata } = metadata; const contentPiece: UnderscoreID> = { _id: new ObjectId(), @@ -51,7 +51,7 @@ const createSyncedPieces = async ( return { contentPiece, - contentHash, + hash, content }; }); diff --git a/packages/backend/src/plugins/database.ts b/packages/backend/src/plugins/database.ts index 020db514..3a814f4e 100644 --- a/packages/backend/src/plugins/database.ts +++ b/packages/backend/src/plugins/database.ts @@ -86,7 +86,7 @@ const databasePlugin = createPlugin(async (fastify) => { extensionsCollection.createIndex({ workspaceId: 1 }), extensionsCollection.createIndex({ name: 1 }), variantsCollection.createIndex({ workspaceId: 1 }), - variantsCollection.createIndex({ name: 1 }), + variantsCollection.createIndex({ key: 1 }), gitDataCollection.createIndex({ workspaceId: 1 }, { unique: true }), gitDataCollection.createIndex({ "records.contentPieceId": 1 }) ]); diff --git a/packages/backend/src/plugins/git-sync/utils.ts b/packages/backend/src/plugins/git-sync/utils.ts index 91ec1d70..b13d5687 100644 --- a/packages/backend/src/plugins/git-sync/utils.ts +++ b/packages/backend/src/plugins/git-sync/utils.ts @@ -111,7 +111,8 @@ const createGitSyncHandler = (process: GitSyncHookProcessor })), records: output.records.map((record) => ({ ...record, - contentPieceId: `${record.contentPieceId}` + contentPieceId: `${record.contentPieceId}`, + variantId: record.variantId ? `${record.variantId}` : undefined })) } }); diff --git a/packages/backend/src/routes/content-pieces/handlers/list.ts b/packages/backend/src/routes/content-pieces/handlers/list.ts index 6da39e32..12f78ec2 100644 --- a/packages/backend/src/routes/content-pieces/handlers/list.ts +++ b/packages/backend/src/routes/content-pieces/handlers/list.ts @@ -2,7 +2,6 @@ import { getVariantDetails, mergeVariantData } from "../utils"; import { ObjectId } from "mongodb"; import { z } from "zod"; import { - getWorkspaceSettingsCollection, getContentPiecesCollection, getContentPieceVariantsCollection, contentPieceMember, @@ -47,12 +46,8 @@ const handler = async ( ctx: AuthenticatedContext, input: z.infer ): Promise> => { - const workspaceSettingsCollection = getWorkspaceSettingsCollection(ctx.db); const contentPiecesCollection = getContentPiecesCollection(ctx.db); const contentPieceVariantsCollection = getContentPieceVariantsCollection(ctx.db); - const workspaceSettings = await workspaceSettingsCollection.findOne({ - workspaceId: ctx.auth.workspaceId - }); const contentGroupId = new ObjectId(input.contentGroupId); const { variantId, variantKey } = await getVariantDetails(ctx.db, input.variant); const cursor = contentPiecesCollection diff --git a/packages/backend/src/routes/git/handlers/commit.ts b/packages/backend/src/routes/git/handlers/commit.ts index 49af9690..fcd90d0b 100644 --- a/packages/backend/src/routes/git/handlers/commit.ts +++ b/packages/backend/src/routes/git/handlers/commit.ts @@ -112,7 +112,8 @@ const handler = async ( data: { records: outputRecords.map((record) => ({ ...record, - contentPieceId: `${record.contentPieceId}` + contentPieceId: `${record.contentPieceId}`, + variantId: record.variantId ? `${record.variantId}` : undefined })) } }); diff --git a/packages/backend/src/routes/git/handlers/get-config.ts b/packages/backend/src/routes/git/handlers/get-config.ts index 06effdae..4c61b310 100644 --- a/packages/backend/src/routes/git/handlers/get-config.ts +++ b/packages/backend/src/routes/git/handlers/get-config.ts @@ -27,7 +27,8 @@ const handler = async (ctx: AuthenticatedContext): Promise ({ ...record, - contentPieceId: `${record.contentPieceId}` + contentPieceId: `${record.contentPieceId}`, + variantId: record.variantId ? `${record.variantId}` : undefined })), ...(gitData.contentGroupId ? { contentGroupId: `${gitData.contentGroupId}` } : {}) }; diff --git a/packages/backend/src/routes/git/handlers/initial-sync.ts b/packages/backend/src/routes/git/handlers/initial-sync.ts index a369337f..d661ccb1 100644 --- a/packages/backend/src/routes/git/handlers/initial-sync.ts +++ b/packages/backend/src/routes/git/handlers/initial-sync.ts @@ -5,7 +5,10 @@ import { getContentPiecesCollection, getContentsCollection, getWorkspacesCollection, - FullContentPiece + FullContentPiece, + getContentPieceVariantsCollection, + getContentVariantsCollection, + getVariantsCollection } from "#collections"; import { publishGitDataEvent, publishContentGroupEvent } from "#events"; import { errors } from "#lib/errors"; @@ -19,6 +22,9 @@ const handler = async (ctx: AuthenticatedContext): Promise => { const contentPiecesCollection = getContentPiecesCollection(ctx.db); const contentsCollection = getContentsCollection(ctx.db); const workspaceCollection = getWorkspacesCollection(ctx.db); + const contentPieceVariantsCollection = getContentPieceVariantsCollection(ctx.db); + const contentVariantsCollection = getContentVariantsCollection(ctx.db); + const variantsCollection = getVariantsCollection(ctx.db); const gitData = await gitDataCollection.findOne({ workspaceId: ctx.auth.workspaceId }); if (!gitData) throw errors.notFound("gitData"); @@ -34,12 +40,36 @@ const handler = async (ctx: AuthenticatedContext): Promise => { newContents, newDirectories, newRecords, + newContentPieceVariants, + newContentVariants, + newVariants, topContentGroup } = await gitSyncIntegration.initialSync(); - await contentGroupsCollection.insertMany(newContentGroups); - await contentPiecesCollection.insertMany(newContentPieces); - await contentsCollection.insertMany(newContents); + if (newContentGroups.length) { + await contentGroupsCollection.insertMany(newContentGroups); + } + + if (newContentPieces.length) { + await contentPiecesCollection.insertMany(newContentPieces); + } + + if (newContents.length) { + await contentsCollection.insertMany(newContents); + } + + if (newContentPieceVariants.length) { + await contentPieceVariantsCollection.insertMany(newContentPieceVariants); + } + + if (newContentVariants.length) { + await contentVariantsCollection.insertMany(newContentVariants); + } + + if (newVariants.length) { + await variantsCollection.insertMany(newVariants); + } + await gitDataCollection.updateOne( { workspaceId: ctx.auth.workspaceId }, { @@ -63,6 +93,7 @@ const handler = async (ctx: AuthenticatedContext): Promise => { data: { records: newRecords.map((record) => ({ ...record, + variantId: record.variantId ? `${record.variantId}` : undefined, contentPieceId: `${record.contentPieceId}` })), directories: newDirectories.map((directory) => ({ diff --git a/packages/backend/src/routes/git/handlers/resolve-conflict.ts b/packages/backend/src/routes/git/handlers/resolve-conflict.ts index c8d15001..43a1334c 100644 --- a/packages/backend/src/routes/git/handlers/resolve-conflict.ts +++ b/packages/backend/src/routes/git/handlers/resolve-conflict.ts @@ -35,7 +35,7 @@ const handler = async ( ctx, gitSyncIntegration.getTransformer() ); - const { buffer, metadata, contentHash } = await inputContentProcessor.process(input.content); + const { buffer, metadata, hash } = await inputContentProcessor.process(input.content); const { date, members, tags, ...restMetadata } = metadata; await contentsCollection.updateOne( @@ -69,7 +69,7 @@ const handler = async ( { $set: { "records.$.syncedHash": input.syncedHash, - "records.$.currentHash": contentHash + "records.$.currentHash": hash } } ); @@ -81,12 +81,17 @@ const handler = async ( return { ...record, contentPieceId: `${record.contentPieceId}`, + variantId: record.variantId ? `${record.variantId}` : undefined, syncedHash: input.syncedHash, - currentHash: contentHash + currentHash: hash }; } - return { ...record, contentPieceId: `${record.contentPieceId}` }; + return { + ...record, + variantId: record.variantId ? `${record.variantId}` : undefined, + contentPieceId: `${record.contentPieceId}` + }; }) } }); diff --git a/packages/editor/src/image.ts b/packages/editor/src/image.ts index 4f055c27..7e56dbf0 100644 --- a/packages/editor/src/image.ts +++ b/packages/editor/src/image.ts @@ -4,6 +4,7 @@ import { Node, mergeAttributes } from "@tiptap/core"; interface ImageAttributes { src?: string; alt?: string; + caption?: string; width?: string; aspectRatio?: string; } @@ -51,6 +52,9 @@ const Image = Node.create({ alt: { default: null }, + caption: { + default: null + }, width: { default: null }, diff --git a/packages/emails/src/index.ts b/packages/emails/src/index.ts index b21c88d6..f554a738 100644 --- a/packages/emails/src/index.ts +++ b/packages/emails/src/index.ts @@ -20,7 +20,7 @@ type RenderEmail = ( plainText?: boolean ) => string; -const renderEmail: RenderEmail = renderEmailFn; +const renderEmail = renderEmailFn as RenderEmail; const getSubject = (template: keyof TemplateMap): string => { const subjectMap = { "magic-link": "Magic sign-in link | Vrite",