feat: favorites (#2103)

* feat: favorites and templates(ee)

* rename migrations

* fix sidebar

* cleanup tabs

* fix

* turn off templates

* cleanup

* uuid validation
This commit is contained in:
Philip Okugbe
2026-04-12 22:06:25 +01:00
committed by GitHub
parent 57efb91bd3
commit d42091ccb1
90 changed files with 4557 additions and 187 deletions
@@ -11,53 +11,9 @@ import {
useSearchChatsQuery,
} from "../queries/ai-chat-query";
import AiChatSidebarItem from "./ai-chat-sidebar-item";
import type { AiChat } from "../types/ai-chat.types";
import { groupChatsByAge } from "../utils/group-chats-by-age";
import classes from "../styles/chat-sidebar.module.css";
type ChatGroup = { key: string; label: string; chats: AiChat[] };
function groupChatsByAge(
chats: AiChat[],
t: (key: string) => string,
): ChatGroup[] {
if (chats.length === 0) return [];
const now = new Date();
const startOfToday = new Date(
now.getFullYear(),
now.getMonth(),
now.getDate(),
).getTime();
const startOfYesterday = startOfToday - 24 * 60 * 60 * 1000;
const startOfLast7 = startOfToday - 7 * 24 * 60 * 60 * 1000;
const startOfLast30 = startOfToday - 30 * 24 * 60 * 60 * 1000;
const buckets: Record<string, ChatGroup> = {
today: { key: "today", label: t("Today"), chats: [] },
yesterday: { key: "yesterday", label: t("Yesterday"), chats: [] },
last7: { key: "last7", label: t("Previous 7 days"), chats: [] },
last30: { key: "last30", label: t("Previous 30 days"), chats: [] },
older: { key: "older", label: t("Older"), chats: [] },
};
for (const chat of chats) {
const ts = new Date(chat.updatedAt).getTime();
if (ts >= startOfToday) buckets.today.chats.push(chat);
else if (ts >= startOfYesterday) buckets.yesterday.chats.push(chat);
else if (ts >= startOfLast7) buckets.last7.chats.push(chat);
else if (ts >= startOfLast30) buckets.last30.chats.push(chat);
else buckets.older.chats.push(chat);
}
return [
buckets.today,
buckets.yesterday,
buckets.last7,
buckets.last30,
buckets.older,
].filter((b) => b.chats.length > 0);
}
export default function AiChatSidebar() {
const { t } = useTranslation();
const navigate = useNavigate();
@@ -0,0 +1,45 @@
import type { AiChat } from "../types/ai-chat.types";
export type ChatGroup = { key: string; label: string; chats: AiChat[] };
export function groupChatsByAge(
chats: AiChat[],
t: (key: string) => string,
): ChatGroup[] {
if (chats.length === 0) return [];
const now = new Date();
const startOfToday = new Date(
now.getFullYear(),
now.getMonth(),
now.getDate(),
).getTime();
const startOfYesterday = startOfToday - 24 * 60 * 60 * 1000;
const startOfLast7 = startOfToday - 7 * 24 * 60 * 60 * 1000;
const startOfLast30 = startOfToday - 30 * 24 * 60 * 60 * 1000;
const buckets: Record<string, ChatGroup> = {
today: { key: "today", label: t("Today"), chats: [] },
yesterday: { key: "yesterday", label: t("Yesterday"), chats: [] },
last7: { key: "last7", label: t("Previous 7 days"), chats: [] },
last30: { key: "last30", label: t("Previous 30 days"), chats: [] },
older: { key: "older", label: t("Older"), chats: [] },
};
for (const chat of chats) {
const ts = new Date(chat.updatedAt).getTime();
if (ts >= startOfToday) buckets.today.chats.push(chat);
else if (ts >= startOfYesterday) buckets.yesterday.chats.push(chat);
else if (ts >= startOfLast7) buckets.last7.chats.push(chat);
else if (ts >= startOfLast30) buckets.last30.chats.push(chat);
else buckets.older.chats.push(chat);
}
return [
buckets.today,
buckets.yesterday,
buckets.last7,
buckets.last30,
buckets.older,
].filter((b) => b.chats.length > 0);
}
+1
View File
@@ -16,5 +16,6 @@ export const Feature = {
AUDIT_LOGS: 'audit:logs',
RETENTION: 'retention',
SHARING_CONTROLS: 'sharing:controls',
TEMPLATES: 'templates',
VIEWER_COMMENTS: 'comment:viewer',
} as const;
@@ -0,0 +1,70 @@
import { Group, Text, Switch, Tooltip } from "@mantine/core";
import { useAtom } from "jotai";
import { workspaceAtom } from "@/features/user/atoms/current-user-atom.ts";
import { useState } from "react";
import { useTranslation } from "react-i18next";
import { updateWorkspace } from "@/features/workspace/services/workspace-service.ts";
import { notifications } from "@mantine/notifications";
import { useHasFeature } from "@/ee/hooks/use-feature";
import { Feature } from "@/ee/features";
import { useUpgradeLabel } from "@/ee/hooks/use-upgrade-label.ts";
export default function AllowMemberTemplates() {
const { t } = useTranslation();
return (
<Group justify="space-between" wrap="nowrap" gap="xl">
<div>
<Text size="md">{t("Allow members to create templates")}</Text>
<Text size="sm" c="dimmed">
{t(
"Allow non-admin members to create and manage templates in their spaces.",
)}
</Text>
</div>
<AllowMemberTemplatesToggle />
</Group>
);
}
function AllowMemberTemplatesToggle() {
const { t } = useTranslation();
const [workspace, setWorkspace] = useAtom(workspaceAtom);
const [checked, setChecked] = useState(
workspace?.settings?.templates?.allowMemberTemplates === true,
);
const hasSecuritySettings = useHasFeature(Feature.SECURITY_SETTINGS);
const upgradeLabel = useUpgradeLabel();
const handleChange = async (event: React.ChangeEvent<HTMLInputElement>) => {
const value = event.currentTarget.checked;
try {
const updatedWorkspace = await updateWorkspace({
allowMemberTemplates: value,
});
setChecked(value);
setWorkspace(updatedWorkspace);
} catch (err) {
notifications.show({
message: err?.response?.data?.message,
color: "red",
});
}
};
return (
<Tooltip
label={upgradeLabel}
disabled={hasSecuritySettings}
refProp="rootRef"
>
<Switch
checked={checked}
onChange={handleChange}
disabled={!hasSecuritySettings}
aria-label={t("Toggle allow members to create templates")}
/>
</Tooltip>
);
}
@@ -12,6 +12,7 @@ import { useTranslation } from "react-i18next";
import EnforceMfa from "@/ee/security/components/enforce-mfa.tsx";
import DisablePublicSharing from "@/ee/security/components/disable-public-sharing.tsx";
import TrashRetention from "@/ee/security/components/trash-retention.tsx";
import { useHasFeature } from "@/ee/hooks/use-feature";
import { Feature } from "@/ee/features";
@@ -0,0 +1,118 @@
import { useState } from "react";
import {
Modal,
TextInput,
Select,
Button,
Stack,
Group,
} from "@mantine/core";
import { useTranslation } from "react-i18next";
import { useNavigate } from "react-router-dom";
import { useCreateTemplateMutation } from "../queries/template-query";
import { useGetSpacesQuery } from "@/features/space/queries/space-query";
import useUserRole from "@/hooks/use-user-role";
type CreateTemplateModalProps = {
opened: boolean;
onClose: () => void;
};
export default function CreateTemplateModal({
opened,
onClose,
}: CreateTemplateModalProps) {
const { t } = useTranslation();
const navigate = useNavigate();
const { isAdmin: isWorkspaceAdmin } = useUserRole();
const createMutation = useCreateTemplateMutation();
const { data: spaces } = useGetSpacesQuery({ limit: 100 });
const [title, setTitle] = useState("");
const [spaceId, setSpaceId] = useState<string | null>(null);
const scopeOptions = [
...(isWorkspaceAdmin
? [
{ group: t("Workspace"), items: [{ value: "", label: t("Global") }] },
]
: []),
...(spaces?.items?.length
? [
{
group: t("Spaces"),
items: spaces.items.map((s) => ({ value: s.id, label: s.name })),
},
]
: []),
];
const handleCreate = async () => {
if (!title.trim()) return;
try {
const result = await createMutation.mutateAsync({
title: title.trim(),
spaceId: spaceId || undefined,
});
handleClose();
navigate(`/templates/${result.id}`);
} catch {
// error notification handled by mutation's onError
}
};
const handleClose = () => {
setTitle("");
setSpaceId(null);
onClose();
};
return (
<Modal
opened={opened}
onClose={handleClose}
title={t("New template")}
centered
>
<Stack gap="md">
<TextInput
label={t("Title")}
placeholder={t("Untitled")}
value={title}
onChange={(e) => setTitle(e.currentTarget.value)}
data-autofocus
onKeyDown={(e) => {
if (e.key === "Enter" && title.trim() && !createMutation.isPending) {
handleCreate();
}
}}
/>
<Select
label={t("Scope")}
description={t("Choose which space this template belongs to")}
data={scopeOptions}
value={spaceId || ""}
onChange={(val) => setSpaceId(val || null)}
searchable
placeholder={t("Select scope")}
/>
<Group justify="flex-end" mt="sm">
<Button variant="default" onClick={handleClose}>
{t("Cancel")}
</Button>
<Button
onClick={handleCreate}
loading={createMutation.isPending}
disabled={!title.trim()}
>
{t("Create")}
</Button>
</Group>
</Stack>
</Modal>
);
}
@@ -0,0 +1,49 @@
import "@/features/editor/styles/index.css";
import { useMemo } from "react";
import { Title } from "@mantine/core";
import { EditorProvider } from "@tiptap/react";
import { mainExtensions } from "@/features/editor/extensions/extensions";
import { UniqueID } from "@docmost/editor-ext";
import { ITemplate } from "@/ee/template/types/template.types";
import TemplateMeta from "@/ee/template/components/template-meta";
type ReadonlyTemplateEditorProps = {
template: ITemplate;
};
export default function ReadonlyTemplateEditor({
template,
}: ReadonlyTemplateEditorProps) {
const extensions = useMemo(() => {
const filteredExtensions = mainExtensions.filter(
(ext) => ext.name !== "uniqueID",
);
return [
...filteredExtensions,
UniqueID.configure({
types: ["heading", "paragraph"],
updateDocument: false,
}),
];
}, []);
return (
<>
<div style={{ padding: "0 3rem" }}>
<Title order={1} size="2.5rem" lh={1.2}>
{template.title || "Untitled"}
</Title>
<TemplateMeta template={template} />
</div>
<EditorProvider
editable={false}
immediatelyRender={true}
extensions={extensions}
content={template.content}
/>
</>
);
}
@@ -0,0 +1,67 @@
.card {
display: flex;
flex-direction: column;
background-color: light-dark(var(--mantine-color-white), var(--mantine-color-dark-6));
transition: transform 150ms ease;
box-shadow: light-dark(rgba(0, 0, 0, 0.07) 0px 2px 45px 4px, none);
@mixin hover {
transform: scale(1.02);
}
}
.cardBody {
display: flex;
flex-direction: column;
flex: 1;
min-height: 0;
}
.icon {
font-size: 22px;
line-height: 1;
width: 36px;
height: 36px;
display: flex;
align-items: center;
justify-content: center;
border-radius: var(--mantine-radius-md);
background-color: light-dark(var(--mantine-color-gray-1), var(--mantine-color-dark-5));
flex-shrink: 0;
}
.iconFallback {
composes: icon;
color: light-dark(var(--mantine-color-gray-5), var(--mantine-color-dark-3));
}
.title {
font-weight: 600;
font-size: var(--mantine-font-size-md);
line-height: 1.35;
color: light-dark(var(--mantine-color-gray-9), var(--mantine-color-gray-0));
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
word-break: break-word;
}
.footer {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--mantine-spacing-xs);
padding-top: var(--mantine-spacing-sm);
margin-top: var(--mantine-spacing-lg);
border-top: 1px solid light-dark(var(--mantine-color-gray-2), var(--mantine-color-dark-5));
}
.menuTarget {
opacity: 0;
transition: opacity 100ms ease;
.card:hover & {
opacity: 1;
}
}
@@ -0,0 +1,100 @@
import { Card, Text, ActionIcon, Menu, Group } from "@mantine/core";
import {
IconDots,
IconEdit,
IconTrash,
IconFileText,
} from "@tabler/icons-react";
import { ITemplate } from "@/ee/template/types/template.types";
import { useTranslation } from "react-i18next";
import classes from "./template-card.module.css";
type TemplateCardProps = {
template: ITemplate;
spaceName?: string;
onUse: (template: ITemplate) => void;
onEdit?: (template: ITemplate) => void;
onDelete?: (template: ITemplate) => void;
canManage?: boolean;
};
export default function TemplateCard({
template,
spaceName,
onUse,
onEdit,
onDelete,
canManage,
}: TemplateCardProps) {
const { t } = useTranslation();
return (
<Card
radius="md"
padding="lg"
className={classes.card}
style={{ cursor: "pointer" }}
onClick={() => onUse(template)}
>
<div className={classes.cardBody}>
<Group justify="space-between" align="flex-start" wrap="nowrap" mb="md">
{template.icon ? (
<div className={classes.icon}>{template.icon}</div>
) : (
<div className={classes.iconFallback}>
<IconFileText size={20} stroke={1.5} />
</div>
)}
<Group gap={6} wrap="nowrap">
{canManage && (
<Menu width={150} shadow="md" withArrow>
<Menu.Target>
<ActionIcon
variant="subtle"
size="sm"
color="gray"
className={classes.menuTarget}
onClick={(e) => e.stopPropagation()}
>
<IconDots size={16} />
</ActionIcon>
</Menu.Target>
<Menu.Dropdown>
<Menu.Item
leftSection={<IconEdit size={14} />}
onClick={(e) => {
e.stopPropagation();
onEdit?.(template);
}}
>
{t("Edit")}
</Menu.Item>
<Menu.Item
color="red"
leftSection={<IconTrash size={14} />}
onClick={(e) => {
e.stopPropagation();
onDelete?.(template);
}}
>
{t("Delete")}
</Menu.Item>
</Menu.Dropdown>
</Menu>
)}
</Group>
</Group>
<div className={classes.title}>{template.title}</div>
<div className={classes.footer}>
<Text size="sm" fw={500} c="dimmed">
{template.spaceId ? (spaceName || t("Space")) : t("Global")}
</Text>
</div>
</div>
</Card>
);
}
@@ -0,0 +1,37 @@
import { Group, Text } from "@mantine/core";
import { CustomAvatar } from "@/components/ui/custom-avatar";
import { useTranslation } from "react-i18next";
import { useTimeAgo } from "@/hooks/use-time-ago";
import { ITemplate } from "@/ee/template/types/template.types";
type TemplateMetaProps = {
template: ITemplate;
};
export default function TemplateMeta({ template }: TemplateMetaProps) {
const { t } = useTranslation();
const updatedAtAgo = useTimeAgo(template.updatedAt);
return (
<Group gap={8} mt="xs" wrap="nowrap" style={{ cursor: "default" }}>
{template.creator?.name && (
<>
<CustomAvatar
size={24}
radius="xl"
name={template.creator.name}
avatarUrl={template.creator.avatarUrl}
/>
<Text size="sm" c="dimmed" fw={500}>
{t("By {{name}}", { name: template.creator.name })}
</Text>
</>
)}
{updatedAtAgo && (
<Text size="sm" c="dimmed">
{t("Updated {{time}}", { time: updatedAtAgo })}
</Text>
)}
</Group>
);
}
@@ -0,0 +1,65 @@
import { Modal, Text, ScrollArea, Button, Group, Center, Loader } from "@mantine/core";
import { useTranslation } from "react-i18next";
import { useGetTemplateByIdQuery } from "@/ee/template/queries/template-query";
import ReadonlyTemplateEditor from "@/ee/template/components/readonly-template-editor";
type TemplatePreviewModalProps = {
templateId: string;
opened: boolean;
onClose: () => void;
onUse: () => void;
onEdit?: () => void;
};
export default function TemplatePreviewModal({
templateId,
opened,
onClose,
onUse,
onEdit,
}: TemplatePreviewModalProps) {
const { t } = useTranslation();
const { data: template, isLoading } = useGetTemplateByIdQuery(templateId);
const title = template?.title || t("Untitled");
return (
<Modal.Root size={1200} opened={opened} onClose={onClose}>
<Modal.Overlay />
<Modal.Content style={{ overflow: "hidden" }}>
<Modal.Header>
<Modal.Title>
<Group gap="xs">
{template?.icon && <Text size="lg">{template.icon}</Text>}
<Text size="md" fw={500}>
{title}
</Text>
</Group>
</Modal.Title>
<Group gap="sm">
{onEdit && (
<Button size="xs" variant="default" onClick={onEdit}>
{t("Edit")}
</Button>
)}
<Button size="xs" onClick={onUse}>
{t("Use template")}
</Button>
<Modal.CloseButton />
</Group>
</Modal.Header>
<Modal.Body p={0}>
{isLoading ? (
<Center py="xl">
<Loader size="sm" />
</Center>
) : (
<ScrollArea h="80vh" w="100%" scrollbarSize={5}>
{template && <ReadonlyTemplateEditor template={template} />}
</ScrollArea>
)}
</Modal.Body>
</Modal.Content>
</Modal.Root>
);
}
@@ -0,0 +1,59 @@
import { useTranslation } from "react-i18next";
import { useNavigate } from "react-router-dom";
import { ITemplate } from "@/ee/template/types/template.types";
import { useUseTemplateMutation } from "@/ee/template/queries/template-query";
import { buildPageUrl } from "@/features/page/page.utils";
import { DestinationPickerModal } from "@/components/ui/destination-picker/destination-picker-modal";
import { DestinationSelection } from "@/components/ui/destination-picker/destination-picker.types";
type UseTemplateModalProps = {
template: ITemplate;
opened: boolean;
onClose: () => void;
};
export default function UseTemplateModal({
template,
opened,
onClose,
}: UseTemplateModalProps) {
const { t } = useTranslation();
const navigate = useNavigate();
const useTemplateMutation = useUseTemplateMutation();
const handleSelect = async (selection: DestinationSelection) => {
const spaceId = selection.spaceId;
const parentPageId =
selection.type === "page" ? selection.pageId : undefined;
try {
const page = await useTemplateMutation.mutateAsync({
templateId: template.id,
spaceId,
parentPageId,
});
onClose();
if (page?.slugId) {
const space = selection.space;
if (space?.slug) {
navigate(buildPageUrl(space.slug, page.slugId, page.title));
}
}
} catch {
// error notification handled by mutation's onError
}
};
return (
<DestinationPickerModal
opened={opened}
onClose={onClose}
title={t("Choose destination")}
actionLabel={t("Create page")}
onSelect={handleSelect}
loading={useTemplateMutation.isPending}
/>
);
}
@@ -0,0 +1,68 @@
.header {
height: 45px;
background-color: var(--mantine-color-body);
padding-left: var(--mantine-spacing-md);
padding-right: var(--mantine-spacing-md);
position: fixed;
z-index: 99;
top: var(--app-shell-header-offset, 0rem);
inset-inline-start: var(--app-shell-navbar-offset, 0rem);
inset-inline-end: var(--app-shell-aside-offset, 0rem);
@media (max-width: $mantine-breakpoint-sm) {
padding-left: var(--mantine-spacing-xs);
padding-right: var(--mantine-spacing-xs);
}
}
.editor {
height: 100%;
padding: 8px 0;
margin: 48px auto;
}
.titleArea {
padding: 0 3rem;
margin-bottom: 1.5em;
}
.emojiButton {
display: flex;
align-items: center;
margin-bottom: 0.25em;
}
.titleInput {
font-size: 2.5rem;
font-weight: 700;
line-height: 1.2;
border: none;
outline: none;
width: 100%;
padding: 0;
background: transparent;
color: inherit;
&::placeholder {
color: light-dark(var(--mantine-color-gray-4), var(--mantine-color-dark-3));
}
}
.emojiIcon {
font-size: 3rem;
line-height: 1;
}
.backLink {
color: light-dark(var(--mantine-color-gray-6), var(--mantine-color-dark-2));
text-decoration: none;
font-size: var(--mantine-font-size-sm);
font-weight: 500;
display: flex;
align-items: center;
gap: 4px;
@mixin hover {
color: light-dark(var(--mantine-color-gray-9), var(--mantine-color-gray-0));
}
}
@@ -0,0 +1,373 @@
import "@/features/editor/styles/index.css";
import { useCallback, useEffect, useRef, useState } from "react";
import {
Button,
Container,
Group,
Select,
Popover,
Stack,
ActionIcon,
Text,
} from "@mantine/core";
import {
IconArrowLeft,
IconSettings,
IconMoodSmile,
IconCheck,
} from "@tabler/icons-react";
import EmojiPicker from "@/components/ui/emoji-picker";
import TemplateMeta from "@/ee/template/components/template-meta";
import { useTranslation } from "react-i18next";
import { useDisclosure, useWindowEvent } from "@mantine/hooks";
import { notifications } from "@mantine/notifications";
import { Link, useParams } from "react-router-dom";
import { Helmet } from "react-helmet-async";
import { getAppName } from "@/lib/config";
import { useEditor, EditorContent } from "@tiptap/react";
import { templateExtensions } from "@/features/editor/extensions/extensions";
import {
useUpdateTemplateMutation,
useGetTemplateByIdQuery,
} from "../queries/template-query";
import { useGetSpacesQuery } from "@/features/space/queries/space-query";
import useUserRole from "@/hooks/use-user-role";
import classes from "./template-editor.module.css";
export default function TemplateEditor() {
const { t } = useTranslation();
const { templateId } = useParams<{ templateId: string }>();
const { isAdmin: isWorkspaceAdmin } = useUserRole();
const { data: existingTemplate } = useGetTemplateByIdQuery(templateId || "");
const { data: spaces } = useGetSpacesQuery({ limit: 100 });
const updateMutation = useUpdateTemplateMutation();
const updateMutationRef = useRef(updateMutation.mutateAsync);
updateMutationRef.current = updateMutation.mutateAsync;
const [title, setTitle] = useState("");
const [icon, setIcon] = useState<string | null>(null);
const [spaceId, setSpaceId] = useState<string | null>(null);
const [draftSpaceId, setDraftSpaceId] = useState<string | null>(null);
const [settingsOpened, { open: openSettings, close: closeSettings }] =
useDisclosure(false);
useWindowEvent("keydown", (event) => {
if (settingsOpened && event.key === "Escape") {
event.stopPropagation();
event.preventDefault();
closeSettings();
}
});
const [saveStatus, setSaveStatus] = useState<
"idle" | "saving" | "saved" | "error"
>("idle");
const titleRef = useRef(title);
const iconRef = useRef(icon);
const spaceIdRef = useRef(spaceId);
const loadedRef = useRef(false);
const isDirtyRef = useRef(false);
const saveTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const savedFadeTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const editor = useEditor({
extensions: templateExtensions,
content: "",
onUpdate() {
if (loadedRef.current) {
markDirty();
}
},
});
// Load template data into editor
useEffect(() => {
if (existingTemplate && editor) {
loadedRef.current = false;
setTitle(existingTemplate.title || "");
setIcon(existingTemplate.icon || null);
setSpaceId(existingTemplate.spaceId || null);
titleRef.current = existingTemplate.title || "";
iconRef.current = existingTemplate.icon || null;
spaceIdRef.current = existingTemplate.spaceId || null;
if (existingTemplate.content) {
editor.commands.setContent(existingTemplate.content);
}
requestAnimationFrame(() => {
loadedRef.current = true;
});
}
}, [existingTemplate, editor]);
const spaceOptions = [
...(isWorkspaceAdmin
? [
{ group: t("Workspace"), items: [{ value: "", label: t("Global") }] },
]
: []),
...(spaces?.items?.length
? [
{
group: t("Spaces"),
items: spaces.items.map((s) => ({ value: s.id, label: s.name })),
},
]
: []),
];
// Save function
const save = useCallback(async () => {
if (!editor || !templateId || !titleRef.current.trim()) return;
if (!isDirtyRef.current) return;
setSaveStatus("saving");
try {
await updateMutationRef.current({
templateId,
title: titleRef.current,
icon: iconRef.current || undefined,
content: editor.getJSON(),
spaceId: spaceIdRef.current,
});
isDirtyRef.current = false;
setSaveStatus("saved");
if (savedFadeTimerRef.current) clearTimeout(savedFadeTimerRef.current);
savedFadeTimerRef.current = setTimeout(() => {
setSaveStatus((prev) => (prev === "saved" ? "idle" : prev));
}, 3000);
} catch {
setSaveStatus("error");
}
}, [editor, templateId]);
// Schedule save 30s after last change
const scheduleSave = useCallback(() => {
if (saveTimerRef.current) clearTimeout(saveTimerRef.current);
saveTimerRef.current = setTimeout(() => {
save();
}, 30000);
}, [save]);
// Mark content as dirty and schedule save
const markDirty = useCallback(() => {
isDirtyRef.current = true;
setSaveStatus("idle");
scheduleSave();
}, [scheduleSave]);
const handleTitleChange = useCallback(
(value: string) => {
setTitle(value);
titleRef.current = value;
if (loadedRef.current) markDirty();
},
[markDirty],
);
const handleIconChange = useCallback(
(value: string | null) => {
setIcon(value);
iconRef.current = value;
if (loadedRef.current) markDirty();
},
[markDirty],
);
const handleSpaceIdChange = useCallback(
(value: string | null) => {
setSpaceId(value);
spaceIdRef.current = value;
if (loadedRef.current) markDirty();
},
[markDirty],
);
// beforeunload warning for unsaved changes
// If user cancels (stays on page), the save fires and completes.
// If user leaves, the save is fire-and-forget.
useEffect(() => {
const handleBeforeUnload = (e: BeforeUnloadEvent) => {
if (isDirtyRef.current) {
e.preventDefault();
e.returnValue = "";
save();
}
};
window.addEventListener("beforeunload", handleBeforeUnload);
return () => {
window.removeEventListener("beforeunload", handleBeforeUnload);
};
}, [save]);
// Save on unmount if dirty
useEffect(() => {
return () => {
if (saveTimerRef.current) clearTimeout(saveTimerRef.current);
if (savedFadeTimerRef.current) clearTimeout(savedFadeTimerRef.current);
if (isDirtyRef.current) {
save();
}
};
}, [save]);
// Manual retry for error state
const handleRetry = useCallback(() => {
save();
}, [save]);
return (
<>
<Helmet>
<title>
{t("Edit template")} - {getAppName()}
</title>
</Helmet>
<div className={classes.header}>
<Container size={900} h="100%" px={0}>
<Group justify="space-between" h="100%" wrap="nowrap">
<Link to="/templates" className={classes.backLink}>
<IconArrowLeft size={16} />
{t("Templates")}
</Link>
<Group gap="xs" wrap="nowrap">
{saveStatus === "saving" && (
<Text size="xs" c="dimmed">
{t("Saving...")}
</Text>
)}
{saveStatus === "saved" && (
<Group gap={4} wrap="nowrap">
<IconCheck size={14} color="var(--mantine-color-green-6)" />
<Text size="xs" c="dimmed">
{t("Saved")}
</Text>
</Group>
)}
{saveStatus === "error" && (
<Text
size="xs"
c="red"
style={{ cursor: "pointer" }}
onClick={handleRetry}
>
{t("Save failed. Retry")}
</Text>
)}
<Popover
width={300}
position="bottom"
shadow="md"
opened={settingsOpened}
onDismiss={closeSettings}
>
<Popover.Target>
<ActionIcon
variant="subtle"
color="gray"
size="md"
onClick={() => {
setDraftSpaceId(spaceId);
openSettings();
}}
>
<IconSettings size={18} />
</ActionIcon>
</Popover.Target>
<Popover.Dropdown>
<Stack gap="sm">
<Select
label={t("Scope")}
description={t("Choose which space this template belongs to")}
data={spaceOptions}
value={draftSpaceId || ""}
onChange={(val) =>
setDraftSpaceId(val || null)
}
searchable
size="sm"
comboboxProps={{ withinPortal: false }}
/>
<Group justify="flex-end" mt="xs">
<Button
variant="default"
size="xs"
onClick={closeSettings}
>
{t("Cancel")}
</Button>
<Button
size="xs"
onClick={() => {
const scopeChanged = draftSpaceId !== spaceId;
handleSpaceIdChange(draftSpaceId);
closeSettings();
if (scopeChanged) {
notifications.show({
message: t("Template scope updated"),
});
}
}}
>
{t("Save")}
</Button>
</Group>
</Stack>
</Popover.Dropdown>
</Popover>
</Group>
</Group>
</Container>
</div>
<Container size={900} className={classes.editor}>
<div className={classes.titleArea}>
<div className={classes.emojiButton}>
<EmojiPicker
onEmojiSelect={(emoji: { native: string }) =>
handleIconChange(emoji.native)
}
icon={
icon ? (
<span className={classes.emojiIcon}>{icon}</span>
) : (
<IconMoodSmile size={20} stroke={1.5} />
)
}
removeEmojiAction={() =>
handleIconChange(null)
}
readOnly={false}
actionIconProps={icon ? { size: "3rem", variant: "transparent" } : undefined}
/>
</div>
<input
className={classes.titleInput}
placeholder={t("Untitled")}
autoFocus
value={title}
onChange={(e) =>
handleTitleChange(e.currentTarget.value)
}
onKeyDown={(e) => {
if (e.key === "Enter") {
e.preventDefault();
editor?.commands.focus("start");
}
}}
/>
{existingTemplate && (
<TemplateMeta template={existingTemplate} />
)}
</div>
<EditorContent editor={editor} />
<div style={{ paddingBottom: "20vh" }} />
</Container>
</>
);
}
@@ -0,0 +1,213 @@
import { useState } from "react";
import {
Container,
Title,
Group,
Button,
SimpleGrid,
Select,
Text,
Center,
Skeleton,
Card,
} from "@mantine/core";
import { modals } from "@mantine/modals";
import { IconPlus } from "@tabler/icons-react";
import { Helmet } from "react-helmet-async";
import { useTranslation } from "react-i18next";
import { useNavigate } from "react-router-dom";
import { useDisclosure } from "@mantine/hooks";
import { getAppName } from "@/lib/config";
import {
useGetTemplatesQuery,
useDeleteTemplateMutation,
} from "@/ee/template/queries/template-query";
import TemplateCard from "@/ee/template/components/template-card";
import { ITemplate } from "@/ee/template/types/template.types";
import { useGetSpacesQuery } from "@/features/space/queries/space-query";
import UseTemplateModal from "@/ee/template/components/use-template-modal";
import TemplatePreviewModal from "@/ee/template/components/template-preview-modal";
import useUserRole from "@/hooks/use-user-role";
import CreateTemplateModal from "@/ee/template/components/create-template-modal";
export default function TemplateList() {
const { t } = useTranslation();
const navigate = useNavigate();
const { isAdmin: isWorkspaceAdmin } = useUserRole();
const [spaceFilter, setSpaceFilter] = useState<string | null>(null);
const [selectedTemplate, setSelectedTemplate] = useState<ITemplate | null>(
null,
);
const [useModalOpened, { open: openUseModal, close: closeUseModal }] =
useDisclosure(false);
const [previewOpened, { open: openPreview, close: closePreview }] =
useDisclosure(false);
const [createModalOpened, { open: openCreateModal, close: closeCreateModal }] =
useDisclosure(false);
const {
data,
isLoading,
hasNextPage,
fetchNextPage,
isFetchingNextPage,
} = useGetTemplatesQuery({
spaceId: spaceFilter || undefined,
});
const templates = data?.pages.flatMap((p) => p.items) ?? [];
const { data: spaces } = useGetSpacesQuery({ limit: 100 });
const deleteTemplateMutation = useDeleteTemplateMutation();
const spaceOptions = [
{ value: "", label: t("All templates") },
...(spaces?.items?.map((s) => ({ value: s.id, label: s.name })) || []),
];
const spaceNameMap = new Map(
spaces?.items?.map((s) => [s.id, s.name]) || [],
);
const handlePreview = (template: ITemplate) => {
setSelectedTemplate(template);
openPreview();
};
const handleUse = (template: ITemplate) => {
setSelectedTemplate(template);
closePreview();
openUseModal();
};
const handleEdit = (template: ITemplate) => {
navigate(`/templates/${template.id}`);
};
const handleDelete = (template: ITemplate) => {
modals.openConfirmModal({
title: t("Are you sure you want to delete this template?"),
centered: true,
labels: { confirm: t("Delete"), cancel: t("Cancel") },
confirmProps: { color: "red" },
onConfirm: () => deleteTemplateMutation.mutate(template.id),
});
};
return (
<>
<Helmet>
<title>
{t("Templates")} - {getAppName()}
</title>
</Helmet>
<Container size="900" pt="xl">
<Group justify="space-between" mb="xl">
<Title order={3}>{t("Templates")}</Title>
{isWorkspaceAdmin && (
<Button
leftSection={<IconPlus size={16} />}
onClick={openCreateModal}
>
{t("New template")}
</Button>
)}
</Group>
<Group mb="lg">
<Select
data={spaceOptions}
value={spaceFilter || ""}
onChange={(val) => setSpaceFilter(val || null)}
placeholder={t("Filter by space")}
clearable={false}
searchable
size="sm"
w={220}
comboboxProps={{ width: "target" }}
/>
</Group>
{isLoading ? (
<SimpleGrid cols={{ base: 1, xs: 2, sm: 3 }}>
{Array.from({ length: 6 }).map((_, i) => (
<Card key={i} radius="md" padding="lg" style={{ boxShadow: "rgba(0, 0, 0, 0.07) 0px 2px 45px 4px" }}>
<Group justify="space-between" align="flex-start" mb="md">
<Skeleton width={36} height={36} radius="md" />
</Group>
<Skeleton height={14} width="70%" mb={8} />
<Skeleton height={10} width="50%" mb="sm" />
<Group justify="space-between" pt="sm" style={{ borderTop: "1px solid var(--mantine-color-gray-2)", marginTop: "auto" }}>
<Skeleton height={20} width={60} radius="xl" />
<Group gap={6}>
<Skeleton height={18} circle />
<Skeleton height={10} width={80} />
</Group>
</Group>
</Card>
))}
</SimpleGrid>
) : templates.length ? (
<>
<SimpleGrid cols={{ base: 1, xs: 2, sm: 3 }}>
{templates.map((template) => (
<TemplateCard
key={template.id}
template={template}
spaceName={
template.spaceId
? spaceNameMap.get(template.spaceId)
: undefined
}
onUse={handlePreview}
onEdit={handleEdit}
onDelete={handleDelete}
canManage={isWorkspaceAdmin}
/>
))}
</SimpleGrid>
{hasNextPage && (
<Button
variant="subtle"
fullWidth
mt="sm"
mb="xl"
onClick={() => fetchNextPage()}
loading={isFetchingNextPage}
>
{t("Load more")}
</Button>
)}
</>
) : (
<Center py="xl">
<Text c="dimmed">{t("No templates found")}</Text>
</Center>
)}
</Container>
<CreateTemplateModal
opened={createModalOpened}
onClose={closeCreateModal}
/>
{selectedTemplate && (
<>
<TemplatePreviewModal
templateId={selectedTemplate.id}
opened={previewOpened}
onClose={closePreview}
onUse={() => handleUse(selectedTemplate)}
onEdit={isWorkspaceAdmin ? () => handleEdit(selectedTemplate) : undefined}
/>
<UseTemplateModal
template={selectedTemplate}
opened={useModalOpened}
onClose={closeUseModal}
/>
</>
)}
</>
);
}
@@ -0,0 +1,167 @@
import {
useInfiniteQuery,
useMutation,
useQuery,
useQueryClient,
UseQueryResult,
InfiniteData,
} from "@tanstack/react-query";
import {
getTemplates,
getTemplateById,
createTemplate,
updateTemplate,
deleteTemplate,
useTemplate,
} from "@/ee/template/services/template-service.ts";
import { ITemplate } from "@/ee/template/types/template.types";
import { IPagination } from "@/lib/types.ts";
import { notifications } from "@mantine/notifications";
import { useTranslation } from "react-i18next";
export function useGetTemplatesQuery(params?: { spaceId?: string }) {
const { spaceId } = params ?? {};
return useInfiniteQuery({
queryKey: ["templates", { spaceId }],
queryFn: ({ pageParam }) =>
getTemplates({ spaceId, cursor: pageParam, limit: 30 }),
initialPageParam: undefined as string | undefined,
getNextPageParam: (lastPage) =>
lastPage.meta.hasNextPage ? lastPage.meta.nextCursor : undefined,
});
}
export function useGetTemplateByIdQuery(
templateId: string,
): UseQueryResult<ITemplate, Error> {
return useQuery({
queryKey: ["template", templateId],
queryFn: () => getTemplateById(templateId),
enabled: !!templateId,
});
}
export function useCreateTemplateMutation() {
const queryClient = useQueryClient();
const { t } = useTranslation();
return useMutation<ITemplate, Error, Partial<ITemplate>>({
mutationFn: (data) => createTemplate(data),
onSuccess: (newTemplate) => {
queryClient.setQueriesData<InfiniteData<IPagination<ITemplate>>>(
{ queryKey: ["templates"] },
(old) => {
if (!old) return old;
const firstPage = old.pages[0];
return {
...old,
pages: [
{ ...firstPage, items: [newTemplate, ...firstPage.items] },
...old.pages.slice(1),
],
};
},
);
notifications.show({ message: t("Template created successfully") });
},
onError: (error) => {
const errorMessage = error["response"]?.data?.message;
notifications.show({
message: errorMessage || t("Failed to create template"),
color: "red",
});
},
});
}
export function useUpdateTemplateMutation() {
const queryClient = useQueryClient();
const { t } = useTranslation();
return useMutation<
ITemplate,
Error,
Partial<ITemplate> & { templateId: string }
>({
mutationFn: (data) => updateTemplate(data),
onSuccess: (updatedTemplate) => {
queryClient.setQueriesData<InfiniteData<IPagination<ITemplate>>>(
{ queryKey: ["templates"] },
(old) => {
if (!old) return old;
return {
...old,
pages: old.pages.map((page) => ({
...page,
items: page.items.map((item) =>
item.id === updatedTemplate.id ? updatedTemplate : item,
),
})),
};
},
);
queryClient.setQueryData(
["template", updatedTemplate.id],
updatedTemplate,
);
},
onError: (error) => {
const errorMessage = error["response"]?.data?.message;
notifications.show({
message: errorMessage || t("Failed to update template"),
color: "red",
});
},
});
}
export function useDeleteTemplateMutation() {
const queryClient = useQueryClient();
const { t } = useTranslation();
return useMutation<void, Error, string>({
mutationFn: (templateId) => deleteTemplate(templateId),
onSuccess: (_data, templateId) => {
queryClient.setQueriesData<InfiniteData<IPagination<ITemplate>>>(
{ queryKey: ["templates"] },
(old) => {
if (!old) return old;
return {
...old,
pages: old.pages.map((page) => ({
...page,
items: page.items.filter((item) => item.id !== templateId),
})),
};
},
);
notifications.show({ message: t("Template deleted") });
},
onError: (error) => {
const errorMessage = error["response"]?.data?.message;
notifications.show({
message: errorMessage || t("Failed to delete template"),
color: "red",
});
},
});
}
export function useUseTemplateMutation() {
const { t } = useTranslation();
return useMutation({
mutationFn: (data: {
templateId: string;
spaceId: string;
parentPageId?: string;
}) => useTemplate(data),
onError: (error) => {
const errorMessage = error["response"]?.data?.message;
notifications.show({
message: errorMessage || t("Failed to create page from template"),
color: "red",
});
},
});
}
@@ -0,0 +1,46 @@
import api from "@/lib/api-client";
import { ITemplate } from "@/ee/template/types/template.types";
import { IPagination } from "@/lib/types.ts";
export async function getTemplates(params?: {
spaceId?: string;
cursor?: string;
limit?: number;
}): Promise<IPagination<ITemplate>> {
const req = await api.post("/templates", params);
return req.data;
}
export async function getTemplateById(
templateId: string,
): Promise<ITemplate> {
const req = await api.post<ITemplate>("/templates/info", { templateId });
return req.data;
}
export async function createTemplate(
data: Partial<ITemplate>,
): Promise<ITemplate> {
const req = await api.post<ITemplate>("/templates/create", data);
return req.data;
}
export async function updateTemplate(
data: Partial<ITemplate> & { templateId: string },
): Promise<ITemplate> {
const req = await api.post<ITemplate>("/templates/update", data);
return req.data;
}
export async function deleteTemplate(templateId: string): Promise<void> {
await api.post<void>("/templates/delete", { templateId });
}
export async function useTemplate(data: {
templateId: string;
spaceId: string;
parentPageId?: string;
}): Promise<any> {
const req = await api.post("/templates/use", data);
return req.data;
}
@@ -0,0 +1,18 @@
export interface ITemplate {
id: string;
title: string;
description?: string;
content?: any;
icon?: string;
spaceId?: string;
workspaceId: string;
creatorId: string;
lastUpdatedById?: string;
creator?: {
id: string;
name: string;
avatarUrl?: string;
};
createdAt: string;
updatedAt: string;
}