"use client"; import { useEffect, useMemo, useState } from "react"; import { EditorContent, useEditor, type JSONContent } from "@tiptap/react"; import StarterKit from "@tiptap/starter-kit"; import TiptapLink from "@tiptap/extension-link"; import { searchWikisByTitle, type Wiki } from "@/uhm/api/wikis"; import { Modal } from "@/components/ui/modal"; import Button from "@/components/ui/button/Button"; import Badge from "@/components/ui/badge/Badge"; import Label from "@/components/form/Label"; import type { WikiSnapshot } from "@/uhm/types/wiki"; import { newId } from "@/uhm/lib/id"; type Props = { projectId: string; wikis: WikiSnapshot[]; setWikis: React.Dispatch>; autoOpen?: boolean; }; 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 }: 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 [searchQuery, setSearchQuery] = useState(""); const [searchResults, setSearchResults] = useState([]); const [isSearching, setIsSearching] = useState(false); const searchRequestRef = useState(() => ({ id: 0 }))[0]; const editor = useEditor({ extensions: [ StarterKit.configure({ heading: { levels: [1, 2, 3] }, }), TiptapLink.configure({ openOnClick: false, autolink: true, linkOnPaste: true, }), ], content: { type: "doc", content: [{ type: "paragraph", content: [{ type: "text", text: "" }] }] }, editorProps: { attributes: { class: "tiptap-editor focus:outline-none min-h-[320px] px-4 py-3", }, }, }); useEffect(() => { if (!autoOpen) return; // open once on mount setOpen(true); }, [autoOpen]); // keep editor content in sync when switching wiki useEffect(() => { if (!editor) return; if (!open) return; const doc = (activeWiki?.doc || null) as JSONContent | null; editor.commands.setContent( (doc && typeof doc === "object" ? doc : { type: "doc", content: [{ type: "paragraph" }] }) as any ); setWikiTitle(activeWiki?.title || ""); }, [activeWiki?.doc, activeWiki?.title, editor, 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]); useEffect(() => { const keyword = searchQuery.trim(); if (!keyword.length) { setSearchResults([]); setIsSearching(false); return; } let disposed = false; const requestId = ++searchRequestRef.id; const t = window.setTimeout(async () => { setIsSearching(true); try { const rows = await searchWikisByTitle(keyword, { limit: 12 }); if (disposed || requestId !== searchRequestRef.id) return; setSearchResults(rows); } catch (err) { if (disposed || requestId !== searchRequestRef.id) return; console.error("Search wikis failed", err); setSearchResults([]); } finally { if (disposed || requestId !== searchRequestRef.id) return; setIsSearching(false); } }, 250); return () => { disposed = true; window.clearTimeout(t); }; }, [searchQuery, searchRequestRef]); const addWikiRef = (wiki: Wiki) => { const id = String(wiki.id || "").trim(); if (!id) return; if (wikis.some((w) => w.id === id)) { setActiveId(id); return; } const title = (wiki.title || "").trim() || "Untitled wiki"; setWikis((prev) => [ { id, source: "ref", operation: "reference", title, doc: null, updated_at: wiki.updated_at, }, ...prev, ]); setActiveId(id); }; const openEditor = () => { if (!wikis.length) { const id = newId(); const seed: WikiSnapshot = { id, source: "inline", operation: "create", title: "Untitled wiki", doc: { type: "doc", content: [{ type: "paragraph", content: [{ type: "text", text: "" }] }] }, updated_at: new Date().toISOString(), }; setWikis((prev) => [seed, ...prev]); setActiveId(id); } setOpen(true); }; const createWiki = () => { const id = newId(); const next: WikiSnapshot = { id, source: "inline", operation: "create", title: "Untitled wiki", doc: { type: "doc", content: [{ type: "paragraph", content: [{ type: "text", text: "" }] }] }, updated_at: new Date().toISOString(), }; setWikis((prev) => [next, ...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 (!editor || !activeId) return; const payload = editor.getJSON(); 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); }; const setLink = () => { if (!editor) return; const prev = editor.getAttributes("link")?.href as string | undefined; const href = window.prompt("Link URL", prev || "https://"); if (href == null) return; const next = href.trim(); if (!next.length) { editor.chain().focus().extendMarkRange("link").unsetLink().run(); return; } editor.chain().focus().extendMarkRange("link").setLink({ href: next }).run(); }; return (
Wiki
{wikis.length}
Add existing wiki
setSearchQuery(e.target.value)} placeholder="Search by title…" style={{ width: "100%", border: "1px solid #1f2937", background: "#0b1220", color: "#e5e7eb", borderRadius: "6px", padding: "8px 10px", fontSize: "12px", outline: "none", }} /> {isSearching ? (
Searching…
) : null} {!isSearching && searchQuery.trim().length > 0 ? (
{searchResults.slice(0, 8).map((w) => (
{(w.title || "").trim() || "Untitled wiki"}
{w.id}
))} {!searchResults.length ? (
No results.
) : null}
) : null}
{wikis.length ? (
{wikis.slice(0, 8).map((w) => (
))} {wikis.length > 8 ? (
+{wikis.length - 8} more…
) : null}
) : (
No wiki yet for this project.
)} 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} />
{editor ? :
Loading editor...
}
Stored in snapshot_json on commit. This page does not write to DB yet.
); }