mirror of
https://github.com/docmost/docmost.git
synced 2026-06-11 02:36:56 +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:
@@ -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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user