"use client"; import { useCallback, useEffect, useMemo, useRef, useState, type ComponentProps } from "react"; import dynamic from "next/dynamic"; import "react-quill-new/dist/quill.snow.css"; import { useShallow } from "zustand/react/shallow"; 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/utils/id"; import type ReactQuill from "react-quill-new"; import { checkWikiSlugExists, fetchWikiBySlug, searchWikisByTitle, type Wiki } from "@/uhm/api/wikis"; import NewBadge from "@/uhm/components/editor/NewBadge"; import { useEditorStore } from "@/uhm/store/editorStore"; type ReactQuillProps = ComponentProps; type QuillRange = { index: number; length: number }; type QuillLike = { getSelection?: () => QuillRange | null; getFormat?: (...args: unknown[]) => Record; setSelection?: (...args: unknown[]) => void; formatText?: (...args: unknown[]) => void; insertText?: (...args: unknown[]) => void; format?: (...args: unknown[]) => void; getText?: (index: number, length: number) => string; }; type QuillModule = { Quill?: { import?: (path: string) => unknown; register?: (pathOrModule: unknown, moduleOrOverwrite?: unknown, overwrite?: boolean) => void; }; }; type QuillLinkFormat = { sanitize?: (url: unknown) => unknown; __uhmAllowSlugHref?: boolean; __uhmOriginalSanitize?: unknown; }; type QuillImageFormatCtor = { new (): { domNode: Element; format(name: string, value: string): void; }; formats: (domNode: Element) => Record; }; const ReactQuillEditor = dynamic(() => import("react-quill-new"), { ssr: false, loading: () =>
, }); let quillLinkSanitizePatched = false; type Props = { projectId: string; setWikis: React.Dispatch>; onRemoveWiki?: (wikiId: string) => void; }; function clampTitle(title: string) { const t = title.trim(); return t.length ? t.slice(0, 120) : "Untitled wiki"; } export default function WikiSidebarPanel({ projectId, setWikis, onRemoveWiki }: Props) { const { wikis, requestedActiveId } = useEditorStore( useShallow((state) => ({ wikis: state.snapshotWikis, requestedActiveId: state.requestedActiveWikiId, })) ); 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 wikiDocStorageFormat = useMemo(() => detectWikiDocStorageFormat(wikiDocHtml), [wikiDocHtml]); 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); // Quill: custom link UI (link-to-wiki by slug). const wikiLinkIntentRef = useRef<{ quill: QuillLike; range: QuillRange | null; activeWikiId: string | null; existingHref: string | null; } | null>(null); const wikiLinkHandlerRef = useRef<(quill: QuillLike | null | undefined) => void>(() => {}); const [isWikiLinkOpen, setIsWikiLinkOpen] = useState(false); const [wikiLinkQuery, setWikiLinkQuery] = useState(""); const [wikiLinkError, setWikiLinkError] = useState(null); const [wikiLinkSearchMode, setWikiLinkSearchMode] = useState<"title" | "slug">("title"); const [globalWikiResults, setGlobalWikiResults] = useState([]); const [isGlobalWikiSearching, setIsGlobalWikiSearching] = useState(false); const [globalWikiSearchError, setGlobalWikiSearchError] = useState(null); const globalWikiSearchRequestRef = useRef(0); const importFileInputRef = useRef(null); // Allow Quill to keep wiki links where href is a slug (no scheme). useEffect(() => { if (typeof window === "undefined") return; if (quillLinkSanitizePatched) return; quillLinkSanitizePatched = true; (async () => { try { const mod = await import("react-quill-new") as QuillModule; const Quill = mod?.Quill; if (!Quill) return; try { const BlotFormatterModule = await import("quill-blot-formatter"); const BlotFormatter = BlotFormatterModule.default; // Only register if not already registered to avoid errors in dev/HMR Quill.register?.("modules/blotFormatter", BlotFormatter, true); } catch (err) { console.error("Failed to load quill-blot-formatter", err); } const ImageFormat = Quill.import?.("formats/image") as QuillImageFormatCtor | undefined; if (ImageFormat) { const BaseImageFormat = ImageFormat; class CustomImage extends BaseImageFormat { static formats(domNode: Element) { const formats = BaseImageFormat.formats(domNode) || {}; const style = domNode.getAttribute("style"); const width = domNode.getAttribute("width"); const height = domNode.getAttribute("height"); const className = domNode.getAttribute("class"); if (style) formats.style = style; if (width) formats.width = width; if (height) formats.height = height; if (className) formats.class = className; return formats; } format(name: string, value: string) { if (["style", "width", "height", "class"].includes(name)) { if (value) { this.domNode.setAttribute(name, value); } else { this.domNode.removeAttribute(name); } } else { super.format(name, value); } } } Quill.register?.(CustomImage, true); } const Link = Quill.import?.("formats/link"); if (!Link) return; const anyLink = Link as QuillLinkFormat; if (anyLink.__uhmAllowSlugHref) return; const original = anyLink.sanitize; anyLink.sanitize = (url: unknown) => { const value = String(url ?? "").trim(); const lower = value.toLowerCase(); if (lower.startsWith("javascript:")) return ""; // Keep slug/relative/external as-is; rendering layer will rewrite slug links for navigation. return value; }; anyLink.__uhmAllowSlugHref = true; anyLink.__uhmOriginalSanitize = original; } catch { // ignore } })(); }, []); 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 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: "", }; 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) => { if (onRemoveWiki) { onRemoveWiki(id); } else { 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, } ) ); setOpen(false); }; const exportCurrentWikiDoc = useCallback(() => { if (!activeId) return; const fmt = detectWikiDocStorageFormat(wikiDocHtml); const label = fmt === "text" ? "txt" : "html"; const mime = fmt === "text" ? "text/plain;charset=utf-8" : "text/html;charset=utf-8"; const base = normalizeWikiSlugInput(wikiSlug) || slugifyWikiTitle(wikiTitle) || String(activeId).slice(0, 8); const filename = `${base}.${label}`; downloadTextFile(filename, wikiDocHtml || "", mime); }, [activeId, wikiDocHtml, wikiSlug, wikiTitle]); const openImportPicker = useCallback(() => { if (!activeId) return; importFileInputRef.current?.click(); }, [activeId]); const handleImportFile = useCallback( async (e: React.ChangeEvent) => { const file = e.target.files?.[0] || null; // Allow selecting the same file again. e.target.value = ""; if (!file) return; if (!activeId) return; try { // Only accept HTML import to match the current Quill storage format. const name = (file.name || "").toLowerCase(); const isHtml = name.endsWith(".html") || name.endsWith(".htm") || String(file.type || "").toLowerCase().includes("text/html"); if (!isHtml) { setWikiSaveError("Chi ho tro import file HTML (.html/.htm)."); return; } const text = await file.text(); const raw = String(text || "").trim(); if (!raw.length) { setWikiDocHtml(""); setWikiSaveError(null); return; } if (raw[0] !== "<") { setWikiSaveError("Noi dung file khong phai HTML hop le."); return; } setWikiDocHtml(raw); setWikiSaveError(null); } catch (err) { const msg = err instanceof Error ? err.message : "Khong import duoc file."; setWikiSaveError(msg); } }, [activeId] ); const closeWikiLinkModal = useCallback(() => { setIsWikiLinkOpen(false); setWikiLinkQuery(""); setWikiLinkError(null); setGlobalWikiResults([]); setIsGlobalWikiSearching(false); setGlobalWikiSearchError(null); wikiLinkIntentRef.current = null; }, []); type WikiLinkOption = { key: string; title: string; slug: string; source: "local" | "global"; }; const localWikiLinkCandidates = useMemo(() => { if (!isWikiLinkOpen) return []; const q = wikiLinkQuery.trim().toLowerCase(); const active = wikiLinkIntentRef.current?.activeWikiId ?? activeId; const base = (wikis || []) .filter((w) => w && typeof w.id === "string") .filter((w) => w.id !== active) // Link value must be slug. .filter((w) => typeof w.slug === "string" && w.slug.trim().length > 0); const filtered = (() => { if (!q.length) return base; if (wikiLinkSearchMode === "slug") { return base.filter((w) => String(w.slug || "").toLowerCase().includes(q)); } return base.filter((w) => (w.title || "").toLowerCase().includes(q)); })(); return filtered.slice(0, 20).map((w) => ({ key: `local:${w.id}`, title: (w.title || "").trim() || "Untitled wiki", slug: String(w.slug).trim(), source: "local", })); }, [activeId, isWikiLinkOpen, wikiLinkQuery, wikiLinkSearchMode, wikis]); useEffect(() => { if (!isWikiLinkOpen) return; const keyword = wikiLinkQuery.trim(); if (!keyword.length) { setGlobalWikiResults([]); setIsGlobalWikiSearching(false); setGlobalWikiSearchError(null); return; } let disposed = false; const requestId = ++globalWikiSearchRequestRef.current; const timeoutId = window.setTimeout(async () => { setIsGlobalWikiSearching(true); setGlobalWikiSearchError(null); try { const rows = wikiLinkSearchMode === "slug" ? (() => fetchWikiBySlug(keyword))() : (() => searchWikisByTitle(keyword, { limit: 12 }))(); const resolved = await rows; if (disposed || requestId !== globalWikiSearchRequestRef.current) return; const list = Array.isArray(resolved) ? resolved : resolved ? [resolved] : []; setGlobalWikiResults(list); } catch (err) { if (disposed || requestId !== globalWikiSearchRequestRef.current) return; console.error("Search global wikis failed", err); setGlobalWikiResults([]); setGlobalWikiSearchError("Khong search duoc wiki tren server."); } finally { if (!disposed && requestId === globalWikiSearchRequestRef.current) { setIsGlobalWikiSearching(false); } } }, 260); return () => { disposed = true; window.clearTimeout(timeoutId); }; }, [isWikiLinkOpen, wikiLinkQuery, wikiLinkSearchMode]); const globalWikiLinkCandidates = useMemo(() => { if (!isWikiLinkOpen) return []; const active = wikiLinkIntentRef.current?.activeWikiId ?? activeId; const activeSlug = (wikis || []).find((w) => w.id === active)?.slug ?? null; const normalizedActiveSlug = typeof activeSlug === "string" ? activeSlug.trim() : ""; const out: WikiLinkOption[] = []; for (const row of globalWikiResults || []) { const slug = typeof row?.slug === "string" ? row.slug.trim() : ""; if (!slug.length) continue; if (normalizedActiveSlug && slug === normalizedActiveSlug) continue; out.push({ key: `global:${row.id || slug}`, title: (row.title || "").trim() || "Untitled wiki", slug, source: "global", }); } return out.slice(0, 20); }, [activeId, globalWikiResults, isWikiLinkOpen, wikis]); const wikiLinkCandidates = useMemo(() => { const localSlugs = new Set(localWikiLinkCandidates.map((w) => w.slug)); const dedupedGlobal = globalWikiLinkCandidates.filter((w) => !localSlugs.has(w.slug)); return [...localWikiLinkCandidates, ...dedupedGlobal]; }, [globalWikiLinkCandidates, localWikiLinkCandidates]); const applyWikiLink = useCallback((target: WikiLinkOption) => { const intent = wikiLinkIntentRef.current; const quill = intent?.quill; if (!quill) return; const slug = target.slug.trim(); const range = intent?.range ?? quill.getSelection?.() ?? null; if (!range) { setWikiLinkError("Khong lay duoc vi tri selection trong editor."); return; } // Restore selection to ensure format applies to the expected range. quill.setSelection?.(range.index, range.length, "silent"); if (range.length > 0) { quill.formatText?.(range.index, range.length, "link", slug, "user"); closeWikiLinkModal(); return; } // No selection: insert the wiki title (or slug) and link it. const label = (target.title || "").trim() || slug; quill.insertText?.(range.index, label, { link: slug }, "user"); quill.setSelection?.(range.index + label.length, 0, "silent"); closeWikiLinkModal(); }, [closeWikiLinkModal]); const applyMissingWikiLink = useCallback(() => { const intent = wikiLinkIntentRef.current; const quill = intent?.quill; if (!quill) return; const href = "__missing__"; const range = intent?.range ?? quill.getSelection?.() ?? null; if (!range) { setWikiLinkError("Khong lay duoc vi tri selection trong editor."); return; } quill.setSelection?.(range.index, range.length, "silent"); if (range.length > 0) { quill.formatText?.(range.index, range.length, "link", href, "user"); closeWikiLinkModal(); return; } // No selection: insert query text (best effort) and mark it as a missing link. const label = wikiLinkQuery.trim().slice(0, 120) || "link"; quill.insertText?.(range.index, label, { link: href }, "user"); quill.setSelection?.(range.index + label.length, 0, "silent"); closeWikiLinkModal(); }, [closeWikiLinkModal, wikiLinkQuery]); const removeWikiLink = useCallback(() => { const intent = wikiLinkIntentRef.current; const quill = intent?.quill; if (!quill) return; const range = intent?.range ?? quill.getSelection?.() ?? null; if (!range) return; quill.setSelection?.(range.index, range.length, "silent"); if (range.length > 0) { quill.formatText?.(range.index, range.length, "link", false, "user"); } else { quill.format?.("link", false, "user"); } closeWikiLinkModal(); }, [closeWikiLinkModal]); // Keep handler ref updated while keeping modules object stable. wikiLinkHandlerRef.current = (quill: QuillLike | null | undefined) => { if (!quill) return; const range = quill.getSelection?.() ?? null; // Try to read current link format (if any) from the selection. const existingHref = range && (quill.getFormat?.(range)?.link ?? quill.getFormat?.(range.index, range.length)?.link) || null; wikiLinkIntentRef.current = { quill, range, activeWikiId: activeId, existingHref: typeof existingHref === "string" ? existingHref : null, }; // Seed query with selected text (best effort). const selectedText = range && range.length > 0 ? String(quill.getText?.(range.index, range.length) || "").trim() : ""; setWikiLinkQuery(selectedText.slice(0, 80)); setWikiLinkError(null); setIsWikiLinkOpen(true); }; const quillModules = useMemo(() => { return { toolbar: { container: QUILL_TOOLBAR, handlers: { // NOTE: use function() to preserve Quill toolbar `this` binding. link: function (this: { quill?: QuillLike }) { wikiLinkHandlerRef.current(this?.quill); }, }, }, blotFormatter: {}, }; }, []); return (
Wiki
{wikis.length}
{collapsed ? null : wikis.length ? (
{wikis.map((w) => (
))}
) : (
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}
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={quillModules} className="min-h-[320px] uhm-wiki-quill" placeholder="Nhap noi dung wiki..." readOnly={!activeId} />
Link wiki
setWikiLinkQuery(e.target.value)} className="h-11 flex-1 min-w-0 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={wikiLinkSearchMode === "slug" ? "Nhap slug..." : "Nhap title wiki..."} autoFocus />
{wikiLinkError ? (
{wikiLinkError}
) : null} {globalWikiSearchError ? (
{globalWikiSearchError}
) : null}
{isGlobalWikiSearching ? (
Searching…
) : null} {wikiLinkCandidates.map((w) => ( ))} {wikiLinkCandidates.length === 0 ? (
Khong tim thay wiki phu hop (hoac cac wiki khac chua co slug).
) : null}
{wikiLinkIntentRef.current?.existingHref ? ( ) : null}
); } function isNewWiki(wiki: WikiSnapshot | null | undefined): boolean { return wiki?.source === "inline" && wiki?.operation === "create"; } function PlusIcon() { return ( ); } function MinusIcon() { return ( ); } function CloseIcon() { return ( ); } function TrashIcon() { return ( ); } const QUILL_TOOLBAR = [ [{ header: [1, 2, 3, false] }], [{ align: [] }, { align: "center" }, { align: "right" }], ["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 ""; if (raw[0] === "<") return raw; 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 escapeHtml(input: string): string { return input .replaceAll("&", "&") .replaceAll("<", "<") .replaceAll(">", ">") .replaceAll("\"", """) .replaceAll("'", "'"); } type WikiDocStorageFormat = "html" | "text"; function detectWikiDocStorageFormat(doc: string): WikiDocStorageFormat { const raw = String(doc || "").trim(); if (!raw.length) return "html"; return raw[0] === "<" ? "html" : "text"; } function downloadTextFile(filename: string, contents: string, mime: string): void { if (typeof window === "undefined") return; const safeName = String(filename || "export.txt").replace(/[\\/]+/g, "_"); const blob = new Blob([contents], { type: mime || "text/plain;charset=utf-8" }); const url = URL.createObjectURL(blob); const a = document.createElement("a"); a.href = url; a.download = safeName; a.style.display = "none"; document.body.appendChild(a); a.click(); a.remove(); window.setTimeout(() => URL.revokeObjectURL(url), 0); }