"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"; import { checkWikiSlugExists } from "@/uhm/api/wikis"; 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 [collapsed, setCollapsed] = useState(false); const activeWiki = useMemo(() => wikis.find((w) => w.id === activeId) || null, [activeId, wikis]); const [wikiTitle, setWikiTitle] = useState(""); const [wikiSlug, setWikiSlug] = useState(""); const [wikiDocHtml, setWikiDocHtml] = useState(""); const [wikiSaveError, setWikiSaveError] = useState(null); const [isCreateOpen, setIsCreateOpen] = useState(false); const [createTitle, setCreateTitle] = useState(""); const [createSlug, setCreateSlug] = useState(""); const [createSlugTouched, setCreateSlugTouched] = useState(false); const [createError, setCreateError] = useState(null); const [isCheckingCreateSlug, setIsCheckingCreateSlug] = useState(false); 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 || ""); setWikiSlug(typeof activeWiki?.slug === "string" ? activeWiki.slug : ""); setWikiDocHtml(normalizeWikiDocForQuill(activeWiki?.doc || null)); setWikiSaveError(null); }, [activeWiki?.doc, activeWiki?.slug, 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", slug: null, doc: "", updated_at: new Date().toISOString(), }; setWikis((prev) => [seed, ...prev]); setActiveId(id); } setOpen(true); }; const createWikiAndOpen = (title?: string, slug?: string | null) => { const id = newId(); const seedTitle = clampTitle(title || "Untitled wiki"); const seed: WikiSnapshot = { id, source: "inline", operation: "create", title: seedTitle, slug: slug ?? null, doc: "", updated_at: new Date().toISOString(), }; setWikis((prev) => [seed, ...prev]); setActiveId(id); setOpen(true); }; const handleCreateWikiFromPanel = async () => { const title = clampTitle(createTitle); const slug = normalizeWikiSlugInput(createSlug); if (!slug) { setCreateError("Slug la bat buoc. Hay thu mot slug khac."); return; } setIsCheckingCreateSlug(true); setCreateError(null); try { const exists = await checkWikiSlugExists(slug); if (exists) { setCreateError("Slug da ton tai. Hay thu slug khac."); return; } createWikiAndOpen(title, slug); setCreateTitle(""); setCreateSlug(""); setCreateSlugTouched(false); setIsCreateOpen(false); } catch (err) { const msg = err instanceof Error ? err.message : "Khong check duoc slug."; setCreateError(msg); } finally { setIsCheckingCreateSlug(false); } }; const removeWiki = (id: string) => { setWikis((prev) => prev.filter((w) => w.id !== id)); if (activeId === id) setActiveId(null); }; const saveWiki = async () => { if (!activeId) return; const payload = wikiDocHtml; const nextTitle = clampTitle(wikiTitle); const nextSlug = normalizeWikiSlugInput(wikiSlug); const current = wikis.find((w) => w.id === activeId) || null; // Check uniqueness only when creating a brand-new wiki. if (current?.operation === "create" && nextSlug) { try { const exists = await checkWikiSlugExists(nextSlug); if (exists) { setWikiSaveError("Slug da ton tai. Hay thu slug khac."); return; } } catch (err) { const msg = err instanceof Error ? err.message : "Khong check duoc slug."; setWikiSaveError(msg); return; } } setWikiSaveError(null); setWikis((prev) => prev.map((w) => w.id !== activeId ? w : { ...w, source: w.source, operation: w.operation === "create" ? "create" : "update", title: nextTitle, slug: nextSlug, doc: payload, updated_at: new Date().toISOString(), } ) ); setOpen(false); }; return (
Wiki
{wikis.length}
{collapsed ? null : wikis.length ? (
{wikis.slice(0, 8).map((w) => (
))} {wikis.length > 8 ? (
+{wikis.length - 8} more…
) : null}
) : (
No wiki yet for this project.
)} {collapsed ? null : (
Tạo wiki mới
{isCreateOpen ? ( <> { const nextTitle = e.target.value; setCreateTitle(nextTitle); setCreateError(null); if (!createSlugTouched) { setCreateSlug(slugifyWikiTitle(nextTitle)); } }} placeholder="Tieu de wiki" disabled={isCheckingCreateSlug} style={{ width: "100%", borderRadius: "6px", border: "1px solid #334155", background: "#111827", color: "#f8fafc", padding: "6px 8px", fontSize: "13px", }} /> { setCreateSlugTouched(true); setCreateSlug(e.target.value); setCreateError(null); }} placeholder="Slug" disabled={isCheckingCreateSlug} style={{ width: "100%", borderRadius: "6px", border: "1px solid #334155", background: "#111827", color: "#f8fafc", padding: "6px 8px", fontSize: "13px", }} /> {createError ? (
{createError}
) : null} ) : 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} />
setWikiSlug(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-slug" disabled={!activeId} />
{wikiSaveError ? (
{wikiSaveError}
) : null}
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 MinusIcon() { 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 normalizeWikiSlugInput(raw: string): string | null { const s = raw.trim(); return s.length ? s : null; } function slugifyWikiTitle(raw: string): string { const input = String(raw || "").trim(); if (!input.length) return ""; return input .toLowerCase() .normalize("NFKD") .replace(/[\u0300-\u036f]/g, "") .replace(/[^a-z0-9]+/g, "-") .replace(/^-+/, "") .replace(/-+$/, "") .slice(0, 80); } 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("'", "'"); }