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(null); const [spaceId, setSpaceId] = useState(null); const [draftSpaceId, setDraftSpaceId] = useState(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 | null>(null); const savedFadeTimerRef = useRef | 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 ( <> {t("Edit template")} - {getAppName()}
{t("Templates")} {saveStatus === "saving" && ( {t("Saving...")} )} {saveStatus === "saved" && ( {t("Saved")} )} {saveStatus === "error" && ( {t("Save failed. Retry")} )} { setDraftSpaceId(spaceId); openSettings(); }} > handleTitleChange(e.currentTarget.value) } onKeyDown={(e) => { if (e.key === "Enter") { e.preventDefault(); editor?.commands.focus("start"); } }} /> {existingTemplate && ( )}
); }