mirror of
https://github.com/docmost/docmost.git
synced 2026-05-16 14:14:06 +08:00
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:
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user