diff --git a/apps/client/public/locales/en-US/translation.json b/apps/client/public/locales/en-US/translation.json index edd6458f..08426c25 100644 --- a/apps/client/public/locales/en-US/translation.json +++ b/apps/client/public/locales/en-US/translation.json @@ -1004,6 +1004,9 @@ "Use template": "Use template", "Preview template: {{title}}": "Preview template: {{title}}", "Use a template": "Use a template", + "Template": "Template", + "Failed to load template": "Failed to load template", + "This page is no longer empty": "This page is no longer empty", "Search templates...": "Search templates...", "Search spaces...": "Search spaces...", "No templates found": "No templates found", diff --git a/apps/client/src/ee/template/components/template-picker-modal.tsx b/apps/client/src/ee/template/components/template-picker-modal.tsx index 609862fe..76c55295 100644 --- a/apps/client/src/ee/template/components/template-picker-modal.tsx +++ b/apps/client/src/ee/template/components/template-picker-modal.tsx @@ -34,6 +34,8 @@ type TemplatePickerModalProps = { onClose: () => void; /** Pre-select this space in the destination picker after a template is chosen. */ initialSpaceId?: string; + /** Receives the chosen template instead of creating a page from it. */ + onUse?: (template: ITemplate) => void; }; type ScopeFilter = "current" | "all"; @@ -42,6 +44,7 @@ export default function TemplatePickerModal({ opened, onClose, initialSpaceId, + onUse, }: TemplatePickerModalProps) { const { t } = useTranslation(); const navigate = useNavigate(); @@ -101,6 +104,11 @@ export default function TemplatePickerModal({ }; const handleQuickUse = (tpl: ITemplate) => { + if (onUse) { + onUse(tpl); + handleClose(); + return; + } if (initialSpaceId) { createInInitialSpace(tpl); return; @@ -114,6 +122,11 @@ export default function TemplatePickerModal({ }; const handlePreviewUse = () => { + if (onUse && previewTemplate) { + onUse(previewTemplate); + handleClose(); + return; + } if (initialSpaceId && previewTemplate) { createInInitialSpace(previewTemplate); return; diff --git a/apps/client/src/ee/template/queries/template-query.ts b/apps/client/src/ee/template/queries/template-query.ts index 3157daa0..f3a28d00 100644 --- a/apps/client/src/ee/template/queries/template-query.ts +++ b/apps/client/src/ee/template/queries/template-query.ts @@ -23,6 +23,10 @@ import { useTranslation } from "react-i18next"; import { invalidateOnCreatePage } from "@/features/page/queries/page-query.ts"; import { treeDataAtom } from "@/features/page/tree/atoms/tree-data-atom.ts"; import { treeModel } from "@/features/page/tree/model/tree-model"; +import { + spaceRoots, + updateSpaceRoots, +} from "@/features/page/tree/utils/utils.ts"; import { SpaceTreeNode } from "@/features/page/tree/types.ts"; import { IPage } from "@/features/page/types/page.types.ts"; import { useQueryEmit } from "@/features/websocket/use-query-emit.ts"; @@ -185,15 +189,14 @@ export function useUseTemplateMutation() { children: [], }; - // Only mutate the tree atom and broadcast if it currently represents - // this space. Cross-space template-use (e.g., from the gallery picking + // Only mutate the tree atom and broadcast if this space's tree is + // loaded. Cross-space template-use (e.g., from the gallery picking // a different space) lets the target space's clients pick up the new // page on their next React Query refetch (focus, navigation, etc.). // Without this guard we'd both pollute the local tree and send a wrong // `index` to remote clients in the target space. - const current = store.get(treeDataAtom); - const treeIsForThisSpace = current[0]?.spaceId === page.spaceId; - if (!treeIsForThisSpace) return; + const current = spaceRoots(store.get(treeDataAtom), page.spaceId); + if (current.length === 0) return; const lastIndex = parentId === null @@ -201,7 +204,9 @@ export function useUseTemplateMutation() { : (treeModel.find(current, parentId)?.children?.length ?? 0); setTreeData((prev) => - treeModel.insert(prev, parentId, newNode, lastIndex), + updateSpaceRoots(prev, page.spaceId, (roots) => + treeModel.insert(roots, parentId, newNode, lastIndex), + ), ); setTimeout(() => { diff --git a/apps/client/src/ee/template/template.utils.ts b/apps/client/src/ee/template/template.utils.ts new file mode 100644 index 00000000..14698bef --- /dev/null +++ b/apps/client/src/ee/template/template.utils.ts @@ -0,0 +1,16 @@ +import { Editor } from "@tiptap/core"; +import { ITemplate } from "@/ee/template/types/template.types"; + +export function applyTemplateToPage( + editor: Editor, + titleEditor: Editor, + template: ITemplate, +): boolean { + if (!editor.isEmpty) return false; + + if (titleEditor.isEmpty && template.title) { + titleEditor.commands.setContent(template.title, { emitUpdate: true }); + } + editor.commands.setContent(template.content); + return true; +} diff --git a/apps/client/src/features/editor/components/empty-page/empty-page-get-started.tsx b/apps/client/src/features/editor/components/empty-page/empty-page-get-started.tsx index 5b180402..acc0ce88 100644 --- a/apps/client/src/features/editor/components/empty-page/empty-page-get-started.tsx +++ b/apps/client/src/features/editor/components/empty-page/empty-page-get-started.tsx @@ -1,31 +1,54 @@ -import { useEffect, useState } from "react"; +import { lazy, Suspense, useEffect, useState } from "react"; import { Button } from "@mantine/core"; -import { IconTable, IconLayoutKanban } from "@tabler/icons-react"; +import { useDisclosure } from "@mantine/hooks"; +import { notifications } from "@mantine/notifications"; +import { + IconTable, + IconLayoutKanban, + IconTemplate, +} from "@tabler/icons-react"; import { useTranslation } from "react-i18next"; import { useAtomValue } from "jotai"; +import { ErrorBoundary } from "react-error-boundary"; import { useConvertPageToBaseMutation } from "@/ee/base/queries/base-query"; import { pageEditorAtom, + titleEditorAtom, yjsSyncedAtom, } from "@/features/editor/atoms/editor-atoms"; import { useHasFeature } from "@/ee/hooks/use-feature"; import { Feature } from "@/ee/features"; +import { ITemplate } from "@/ee/template/types/template.types"; +import { getTemplateById } from "@/ee/template/services/template-service.ts"; +import { applyTemplateToPage } from "@/ee/template/template.utils"; import classes from "./empty-page-get-started.module.css"; +const TemplatePickerModal = lazy( + () => import("@/ee/template/components/template-picker-modal"), +); + type EmptyPageGetStartedProps = { pageId: string; + spaceId: string; editable: boolean; }; export function EmptyPageGetStarted({ pageId, + spaceId, editable, }: EmptyPageGetStartedProps) { const { t } = useTranslation(); const editor = useAtomValue(pageEditorAtom); + const titleEditor = useAtomValue(titleEditorAtom); const isSynced = useAtomValue(yjsSyncedAtom); const hasBases = useHasFeature(Feature.BASES); + const hasTemplates = useHasFeature(Feature.TEMPLATES); const convertMutation = useConvertPageToBaseMutation(); + const [ + templatePickerOpened, + { open: openTemplatePicker, close: closeTemplatePicker }, + ] = useDisclosure(false); const [isEmpty, setIsEmpty] = useState(false); useEffect(() => { @@ -40,43 +63,106 @@ export function EmptyPageGetStarted({ }; }, [editor]); - if (!editable || !hasBases || !editor || !isSynced || !isEmpty) return null; + const handleUseTemplate = async (selected: ITemplate) => { + let template: ITemplate; + try { + template = await getTemplateById(selected.id); + } catch { + notifications.show({ + message: t("Failed to load template"), + color: "red", + }); + return; + } + + if ( + !editor || + editor.isDestroyed || + !titleEditor || + titleEditor.isDestroyed + ) { + return; + } + + if (!applyTemplateToPage(editor, titleEditor, template)) { + notifications.show({ + message: t("This page is no longer empty"), + color: "red", + }); + } + }; const chips = [ - { - key: "base", - label: t("Base"), - icon: IconTable, - onClick: () => convertMutation.mutate({ pageId }), - disabled: convertMutation.isPending, - }, - { - key: "kanban", - label: t("Kanban"), - icon: IconLayoutKanban, - onClick: () => convertMutation.mutate({ pageId, template: "kanban" }), - disabled: convertMutation.isPending, - }, + ...(hasBases + ? [ + { + key: "base", + label: t("Base"), + icon: IconTable, + onClick: () => convertMutation.mutate({ pageId }), + disabled: convertMutation.isPending, + }, + { + key: "kanban", + label: t("Kanban"), + icon: IconLayoutKanban, + onClick: () => + convertMutation.mutate({ pageId, template: "kanban" }), + disabled: convertMutation.isPending, + }, + ] + : []), + ...(hasTemplates + ? [ + { + key: "template", + label: t("Template"), + icon: IconTemplate, + onClick: openTemplatePicker, + disabled: false, + }, + ] + : []), ]; + const showChips = + editable && !!editor && isSynced && isEmpty && chips.length > 0; + return ( -
- {t("Get started with")} -
- {chips.map((chip) => ( - - ))} -
-
+ <> + {showChips && ( +
+ {t("Get started with")} +
+ {chips.map((chip) => ( + + ))} +
+
+ )} + + {hasTemplates && templatePickerOpened && ( + null}> + + + + + )} + ); } diff --git a/apps/client/src/features/editor/full-editor.tsx b/apps/client/src/features/editor/full-editor.tsx index f41fe0f7..b37ab541 100644 --- a/apps/client/src/features/editor/full-editor.tsx +++ b/apps/client/src/features/editor/full-editor.tsx @@ -48,6 +48,7 @@ export interface FullEditorProps { slugId: string; title: string; content: string; + spaceId: string; spaceSlug: string; editable: boolean; creator?: PageUser; @@ -60,6 +61,7 @@ export function FullEditor({ title, slugId, content, + spaceId, spaceSlug, editable, creator, @@ -115,7 +117,11 @@ export function FullEditor({ content={content} canComment={canComment} /> - + ); } diff --git a/apps/client/src/pages/page/page.tsx b/apps/client/src/pages/page/page.tsx index b809c9b0..b04ad568 100644 --- a/apps/client/src/pages/page/page.tsx +++ b/apps/client/src/pages/page/page.tsx @@ -184,6 +184,7 @@ function PageContent({ pageSlug }: { pageSlug: string | undefined }) { title={page.title} content={page.content} slugId={page.slugId} + spaceId={page.spaceId} spaceSlug={page?.space?.slug} editable={canEdit} creator={page.creator} diff --git a/apps/server/src/ee b/apps/server/src/ee index 662a0042..e90378d6 160000 --- a/apps/server/src/ee +++ b/apps/server/src/ee @@ -1 +1 @@ -Subproject commit 662a0042eb6c56a01f8958447780fe2354467b14 +Subproject commit e90378d63dd5f75e771d2bed05a550fad6e0d5ef