feat: add Template option to empty page get-started row (#2565)
This commit is contained in:
parent
6a06d7fd7d
commit
297cc29bba
8 changed files with 173 additions and 43 deletions
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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(() => {
|
||||
|
|
|
|||
16
apps/client/src/ee/template/template.utils.ts
Normal file
16
apps/client/src/ee/template/template.utils.ts
Normal file
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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 (
|
||||
<div className={classes.wrapper} contentEditable={false}>
|
||||
<span className={classes.label}>{t("Get started with")}</span>
|
||||
<div className={classes.chipRow}>
|
||||
{chips.map((chip) => (
|
||||
<Button
|
||||
key={chip.key}
|
||||
variant="default"
|
||||
size="xs"
|
||||
radius="xl"
|
||||
leftSection={<chip.icon size={16} />}
|
||||
onClick={chip.onClick}
|
||||
disabled={chip.disabled}
|
||||
>
|
||||
{chip.label}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<>
|
||||
{showChips && (
|
||||
<div className={classes.wrapper} contentEditable={false}>
|
||||
<span className={classes.label}>{t("Get started with")}</span>
|
||||
<div className={classes.chipRow}>
|
||||
{chips.map((chip) => (
|
||||
<Button
|
||||
key={chip.key}
|
||||
variant="default"
|
||||
size="xs"
|
||||
radius="xl"
|
||||
leftSection={<chip.icon size={16} />}
|
||||
onClick={chip.onClick}
|
||||
disabled={chip.disabled}
|
||||
>
|
||||
{chip.label}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{hasTemplates && templatePickerOpened && (
|
||||
<ErrorBoundary fallbackRender={() => null}>
|
||||
<Suspense fallback={null}>
|
||||
<TemplatePickerModal
|
||||
opened={templatePickerOpened}
|
||||
onClose={closeTemplatePicker}
|
||||
initialSpaceId={spaceId}
|
||||
onUse={handleUseTemplate}
|
||||
/>
|
||||
</Suspense>
|
||||
</ErrorBoundary>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
/>
|
||||
<EmptyPageGetStarted pageId={pageId} editable={editable} />
|
||||
<EmptyPageGetStarted
|
||||
pageId={pageId}
|
||||
spaceId={spaceId}
|
||||
editable={editable}
|
||||
/>
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -1 +1 @@
|
|||
Subproject commit 662a0042eb6c56a01f8958447780fe2354467b14
|
||||
Subproject commit e90378d63dd5f75e771d2bed05a550fad6e0d5ef
|
||||
Loading…
Reference in a new issue