"use client"; import { useEffect, useMemo, useState, type ComponentProps } from "react"; import dynamic from "next/dynamic"; import "react-quill-new/dist/quill.snow.css"; import { Modal } from "@/components/ui/modal"; import Button from "@/components/ui/button/Button"; import Label from "@/components/form/Label"; import type { WikiSnapshot } from "@/uhm/types/wiki"; import { newId } from "@/uhm/lib/id"; import type ReactQuill from "react-quill-new"; type ReactQuillProps = ComponentProps; const ReactQuillEditor = dynamic(() => import("react-quill-new"), { ssr: false, loading: () =>
, }); type Props = { projectId: string; wikis: WikiSnapshot[]; setWikis: React.Dispatch>; autoOpen?: boolean; requestedActiveId?: string | null; }; function clampTitle(title: string) { const t = title.trim(); return t.length ? t.slice(0, 120) : "Untitled wiki"; } export default function WikiSidebarPanel({ projectId, wikis, setWikis, autoOpen, requestedActiveId }: Props) { const [open, setOpen] = useState(false); const [activeId, setActiveId] = useState(null); const activeWiki = useMemo(() => wikis.find((w) => w.id === activeId) || null, [activeId, wikis]); const [wikiTitle, setWikiTitle] = useState(""); const [wikiDocHtml, setWikiDocHtml] = useState(""); const [isCreateOpen, setIsCreateOpen] = useState(false); const [createTitle, setCreateTitle] = useState(""); useEffect(() => { if (!autoOpen) return; // open once on mount setOpen(true); }, [autoOpen]); useEffect(() => { if (!requestedActiveId) return; if (wikis.some((w) => w.id === requestedActiveId)) { setActiveId(requestedActiveId); } }, [requestedActiveId, wikis]); // keep editor content in sync when switching wiki useEffect(() => { if (!open) return; setWikiTitle(activeWiki?.title || ""); setWikiDocHtml(normalizeWikiDocForQuill(activeWiki?.doc || null)); }, [activeWiki?.doc, activeWiki?.title, open]); const ensureActive = () => { if (activeId && wikis.some((w) => w.id === activeId)) return; setActiveId(wikis[0]?.id || null); }; useEffect(() => { ensureActive(); // eslint-disable-next-line react-hooks/exhaustive-deps }, [wikis.length]); const openEditor = () => { if (!wikis.length) { const id = newId(); const seed: WikiSnapshot = { id, source: "inline", operation: "create", title: "Untitled wiki", doc: "", updated_at: new Date().toISOString(), }; setWikis((prev) => [seed, ...prev]); setActiveId(id); } setOpen(true); }; const createWikiAndOpen = (title?: string) => { const id = newId(); const seedTitle = clampTitle(title || "Untitled wiki"); const seed: WikiSnapshot = { id, source: "inline", operation: "create", title: seedTitle, doc: "", updated_at: new Date().toISOString(), }; setWikis((prev) => [seed, ...prev]); setActiveId(id); setOpen(true); }; const removeWiki = (id: string) => { setWikis((prev) => prev.filter((w) => w.id !== id)); if (activeId === id) setActiveId(null); }; const saveWiki = () => { if (!activeId) return; const payload = wikiDocHtml; const nextTitle = clampTitle(wikiTitle); setWikis((prev) => prev.map((w) => w.id !== activeId ? w : { ...w, source: w.source, operation: w.operation === "create" ? "create" : "update", title: nextTitle, doc: payload, updated_at: new Date().toISOString(), } ) ); setOpen(false); }; return (
Wiki
{wikis.length}
{wikis.length ? (
{wikis.slice(0, 8).map((w) => (
))} {wikis.length > 8 ? (
+{wikis.length - 8} more…
) : null}
) : (
No wiki yet for this project.
)}
Tạo wiki mới
{isCreateOpen ? ( <> setCreateTitle(e.target.value)} placeholder="Tieu de wiki" style={{ width: "100%", borderRadius: "6px", border: "1px solid #334155", background: "#111827", color: "#f8fafc", padding: "6px 8px", fontSize: "13px", }} /> ) : null}
setOpen(false)} showCloseButton={false} // Defensive: even if Modal defaults change, keep wiki popup free of the "X" close button. className="max-w-[1100px] m-4 [&>button]:hidden" >
Project
{projectId}
Wikis
{wikis.map((w) => ( ))}
setWikiTitle(e.target.value)} className="h-11 w-full rounded-xl border border-gray-200 bg-transparent px-4 py-2.5 text-sm text-gray-800 outline-none focus:border-brand-300 focus:ring-3 focus:ring-brand-500/10 dark:border-gray-800 dark:text-white/90 dark:focus:border-brand-800" placeholder="Wiki title" disabled={!activeId} />
setWikiDocHtml(content)} modules={QUILL_MODULES} className="min-h-[320px]" placeholder="Nhap noi dung wiki..." readOnly={!activeId} />
Stored in snapshot_json on commit. This page does not write to DB yet.
); } function PlusIcon() { return ( ); } function CloseIcon() { return ( ); } const QUILL_MODULES = { toolbar: [ [{ header: [1, 2, 3, false] }], ["bold", "italic", "underline", "strike"], [{ list: "ordered" }, { list: "bullet" }], ["blockquote", "code-block"], ["link", "image"], ["clean"], ], }; function normalizeWikiDocForQuill(doc: string | null): string { const raw = (doc || "").trim(); if (!raw.length) return ""; // New format (Quill): HTML string. if (raw[0] === "<") return raw; // Legacy format (Tiptap): JSON string. if (raw[0] === "{") { try { const json: unknown = JSON.parse(raw); const text = tiptapJsonToPlainText(json).trim(); if (!text.length) return ""; return `

${escapeHtml(text).replace(/\n/g, "
")}

`; } catch { // fall through } } // Unknown plaintext: treat as plain text. return `

${escapeHtml(raw).replace(/\n/g, "
")}

`; } function isRecord(value: unknown): value is Record { return Boolean(value) && typeof value === "object" && !Array.isArray(value); } function tiptapJsonToPlainText(node: unknown): string { if (node == null) return ""; if (typeof node === "string") return node; if (Array.isArray(node)) return node.map(tiptapJsonToPlainText).join(""); if (isRecord(node)) { if (node.type === "text" && typeof node.text === "string") return node.text; if (node.type === "hardBreak") return "\n"; if ("content" in node) return tiptapJsonToPlainText(node.content); } return ""; } function escapeHtml(input: string): string { return input .replaceAll("&", "&") .replaceAll("<", "<") .replaceAll(">", ">") .replaceAll("\"", """) .replaceAll("'", "'"); }