diff --git a/src/app/wiki/[slug]/page.tsx b/src/app/wiki/[slug]/page.tsx new file mode 100644 index 0000000..c2c3c29 --- /dev/null +++ b/src/app/wiki/[slug]/page.tsx @@ -0,0 +1,10 @@ +import WikiBySlugClient from "./wiki-by-slug-client"; + +export default async function WikiBySlugPage({ + params, +}: { + params: Promise<{ slug: string }> | { slug: string }; +}) { + const resolved = await params; + return ; +} diff --git a/src/app/wiki/[slug]/wiki-by-slug-client.tsx b/src/app/wiki/[slug]/wiki-by-slug-client.tsx new file mode 100644 index 0000000..e39c2a5 --- /dev/null +++ b/src/app/wiki/[slug]/wiki-by-slug-client.tsx @@ -0,0 +1,468 @@ +"use client"; + +import { useEffect, useMemo, useRef, useState } from "react"; +import Link from "next/link"; +import "react-quill-new/dist/quill.snow.css"; + +import { ApiError } from "@/uhm/api/http"; +import { fetchWikiBySlug, type Wiki } from "@/uhm/api/wikis"; + +type TocItem = { + id: string; + level: number; + text: string; +}; + +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("'", "'"); +} + +function normalizeWikiContentToHtml(raw: string | null | undefined): string { + const value = String(raw || "").trim(); + if (!value.length) return ""; + + // New format: HTML string. + if (value[0] === "<") return value; + + // Legacy format: Tiptap JSON string. + if (value[0] === "{") { + try { + const json: unknown = JSON.parse(value); + 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(value).replace(/\n/g, "
")}

`; +} + +function slugifyHeading(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 isExternalHref(href: string): boolean { + const h = href.trim().toLowerCase(); + return ( + h.startsWith("http://") || + h.startsWith("https://") || + h.startsWith("mailto:") || + h.startsWith("tel:") || + h.startsWith("sms:") + ); +} + +function rewriteHtmlAndBuildToc(inputHtml: string, wikiBaseUrl: string): { html: string; toc: TocItem[] } { + const parser = new DOMParser(); + const doc = parser.parseFromString(inputHtml, "text/html"); + + // Basic hardening: do not render scripts in user content. + for (const el of Array.from(doc.querySelectorAll("script"))) el.remove(); + + // Rewrite internal wiki links: Quill stores slug as ... + for (const a of Array.from(doc.querySelectorAll("a[href]"))) { + const href = String(a.getAttribute("href") || "").trim(); + if (!href.length) continue; + if (href === "__missing__") continue; + if (href.startsWith("#")) continue; + if (href.startsWith("/")) continue; + if (isExternalHref(href)) continue; + + const match = href.match(/^([^?#]+)([?#].*)?$/); + const slugPart = String(match?.[1] || "").replace(/^\/+/, "").trim(); + const suffix = String(match?.[2] || ""); + + const normalizedSlug = slugPart; + if (!normalizedSlug.length) continue; + + a.setAttribute("href", `${wikiBaseUrl}${encodeURIComponent(normalizedSlug)}${suffix}`); + a.setAttribute("target", "_self"); + } + + // Build TOC from headings and ensure they have stable IDs. + const toc: TocItem[] = []; + const seen = new Map(); + const headings = Array.from(doc.body.querySelectorAll("h1,h2,h3,h4,h5,h6")); + for (const h of headings) { + const text = String(h.textContent || "").trim(); + if (!text.length) continue; + + const level = Number(String(h.tagName || "").replace(/^H/i, "")) || 1; + const existingId = String(h.getAttribute("id") || "").trim(); + if (existingId) { + toc.push({ id: existingId, level, text }); + continue; + } + + const base = slugifyHeading(text) || "heading"; + const n = (seen.get(base) || 0) + 1; + seen.set(base, n); + const id = n === 1 ? base : `${base}-${n}`; + + h.setAttribute("id", id); + toc.push({ id, level, text }); + } + + return { html: doc.body.innerHTML, toc }; +} + +function formatDate(value?: string | null): string { + const raw = String(value || "").trim(); + if (!raw) return "-"; + const d = new Date(raw); + if (Number.isNaN(d.getTime())) return raw; + return d.toLocaleString(); +} + +export default function WikiBySlugClient({ slug }: { slug: string }) { + const [wiki, setWiki] = useState(null); + const [status, setStatus] = useState<"idle" | "loading" | "error" | "ready">("idle"); + const [error, setError] = useState(null); + const [renderHtml, setRenderHtml] = useState(""); + const [toc, setToc] = useState([]); + const [activeHeadingId, setActiveHeadingId] = useState(null); + + const normalizedSlug = useMemo(() => String(slug || "").trim(), [slug]); + const contentRootRef = useRef(null); + + // Load wiki data by slug. + useEffect(() => { + const value = String(normalizedSlug || "").trim(); + if (!value.length) { + setWiki(null); + setStatus("error"); + setError("Missing wiki slug."); + return; + } + + let disposed = false; + (async () => { + setStatus("loading"); + setError(null); + try { + const res = await fetchWikiBySlug(value); + if (disposed) return; + if (!res) { + setWiki(null); + setStatus("ready"); + setRenderHtml(""); + setToc([]); + return; + } + setWiki(res); + setStatus("ready"); + } catch (err) { + if (disposed) return; + const msg = + err instanceof ApiError + ? err.message + : err instanceof Error + ? err.message + : "Failed to load wiki."; + setStatus("error"); + setError(msg); + } + })(); + + return () => { + disposed = true; + }; + }, [normalizedSlug]); + + // Transform content: normalize -> rewrite internal links -> inject heading ids + toc. + useEffect(() => { + if (!wiki) { + setRenderHtml(""); + setToc([]); + return; + } + + const raw = + (wiki.content ?? (wiki as unknown as { doc?: string | null }).doc ?? "") || ""; + const html = normalizeWikiContentToHtml(raw); + + try { + const base = `${window.location.origin}/wiki/`; + const processed = rewriteHtmlAndBuildToc(html, base); + setRenderHtml(processed.html); + setToc(processed.toc); + setActiveHeadingId(processed.toc[0]?.id ?? null); + } catch (err) { + console.error("Failed to process wiki HTML", err); + setRenderHtml(html); + setToc([]); + } + }, [wiki]); + + // Track active heading for TOC highlight. + useEffect(() => { + if (!toc.length) return; + const root = contentRootRef.current; + if (!root) return; + + const headings = toc + .map((t) => root.querySelector(`#${CSS.escape(t.id)}`)) + .filter((el): el is HTMLElement => Boolean(el)); + if (!headings.length) return; + + const obs = new IntersectionObserver( + (entries) => { + const visible = entries + .filter((e) => e.isIntersecting) + .sort((a, b) => (a.boundingClientRect.top ?? 0) - (b.boundingClientRect.top ?? 0)); + const top = visible[0]?.target as HTMLElement | undefined; + const id = top?.id || null; + if (id) setActiveHeadingId(id); + }, + { root: null, rootMargin: "-20% 0px -70% 0px", threshold: [0, 1] } + ); + + for (const h of headings) obs.observe(h); + return () => obs.disconnect(); + }, [toc]); + + return ( +
+
+
+
+
Wiki
+

+ {wiki?.title?.trim() || normalizedSlug || "Wiki"} +

+
+ + Slug: {normalizedSlug || "-"} + + + ID: {wiki?.id || "-"} + + + Project:{" "} + {wiki?.project_id || "-"} + + + Created: {formatDate(wiki?.created_at)} + + + Updated: {formatDate(wiki?.updated_at)} + +
+
+ +
+ + Home + +
+
+ + {status === "loading" ? ( +
+
+
+
+ ) : status === "error" ? ( +
+ {error || "Failed to load wiki."} +
+ ) : wiki == null ? ( +
+ Không tìm thấy wiki với slug: {normalizedSlug} +
+ ) : ( +
+ + +
+
+
+
+
+
+ )} +
+ + +
+ ); +} diff --git a/src/uhm/api/wikis.ts b/src/uhm/api/wikis.ts index ff448d0..626c9cc 100644 --- a/src/uhm/api/wikis.ts +++ b/src/uhm/api/wikis.ts @@ -3,6 +3,7 @@ import { ApiError, requestJson } from "@/uhm/api/http"; export type Wiki = { id: string; + project_id?: string; title?: string; slug?: string | null; content?: string; diff --git a/src/uhm/components/WikiSidebarPanel.tsx b/src/uhm/components/WikiSidebarPanel.tsx index 7683476..4b45f73 100644 --- a/src/uhm/components/WikiSidebarPanel.tsx +++ b/src/uhm/components/WikiSidebarPanel.tsx @@ -20,6 +20,8 @@ const ReactQuillEditor = dynamic(() => import("react-quill-new" loading: () =>
, }); +let quillLinkSanitizePatched = false; + type Props = { projectId: string; wikis: WikiSnapshot[]; @@ -42,6 +44,7 @@ export default function WikiSidebarPanel({ projectId, wikis, setWikis, autoOpen, 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(""); @@ -66,6 +69,7 @@ export default function WikiSidebarPanel({ projectId, wikis, setWikis, autoOpen, const [isGlobalWikiSearching, setIsGlobalWikiSearching] = useState(false); const [globalWikiSearchError, setGlobalWikiSearchError] = useState(null); const globalWikiSearchRequestRef = useRef(0); + const importFileInputRef = useRef(null); useEffect(() => { if (!autoOpen) return; @@ -73,6 +77,38 @@ export default function WikiSidebarPanel({ projectId, wikis, setWikis, autoOpen, setOpen(true); }, [autoOpen]); + // 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: any = await import("react-quill-new"); + const Quill = mod?.Quill; + if (!Quill) return; + const Link = Quill.import?.("formats/link"); + if (!Link) return; + + const anyLink = Link as any; + 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)) { @@ -210,6 +246,77 @@ export default function WikiSidebarPanel({ projectId, wikis, setWikis, autoOpen, setOpen(false); }; + const exportCurrentWikiDoc = useCallback(() => { + if (!activeId) return; + + const fmt = detectWikiDocStorageFormat(wikiDocHtml); + const label = fmt === "json" ? "json" : fmt === "text" ? "txt" : "html"; + const mime = + fmt === "json" + ? "application/json;charset=utf-8" + : 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; + } + + // Quill drops tags that do not have a valid href. + // Preserve the intent by inserting a placeholder href. + const normalized = ensureAnchorsHaveHref(raw); + setWikiDocHtml(normalized); + setWikiSaveError(null); + } catch (err) { + const msg = err instanceof Error ? err.message : "Khong import duoc file."; + setWikiSaveError(msg); + } + }, + [activeId] + ); + const closeWikiLinkModal = useCallback(() => { setIsWikiLinkOpen(false); setWikiLinkQuery(""); @@ -635,13 +742,38 @@ export default function WikiSidebarPanel({ projectId, wikis, setWikis, autoOpen, // 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}
+ + @@ -651,7 +783,7 @@ export default function WikiSidebarPanel({ projectId, wikis, setWikis, autoOpen,
-
+
Wikis
@@ -711,7 +843,7 @@ export default function WikiSidebarPanel({ projectId, wikis, setWikis, autoOpen, value={wikiDocHtml} onChange={(content: string) => setWikiDocHtml(content)} modules={quillModules} - className="min-h-[320px]" + className="min-h-[320px] uhm-wiki-quill" placeholder="Nhap noi dung wiki..." readOnly={!activeId} /> @@ -719,13 +851,20 @@ export default function WikiSidebarPanel({ projectId, wikis, setWikis, autoOpen,
- -
- Stored in snapshot_json on commit. This page does not write to DB yet. -
+ + URL.revokeObjectURL(url), 0); +} + +function ensureAnchorsHaveHref(html: string): string { + const raw = String(html || "").trim(); + if (!raw.length) return ""; + if (typeof window === "undefined") return raw; + + try { + const parser = new DOMParser(); + const doc = parser.parseFromString(raw, "text/html"); + const anchors = Array.from(doc.querySelectorAll("a")); + for (const a of anchors) { + const href = a.getAttribute("href"); + if (href == null || String(href).trim() === "") { + // Placeholder: the viewer will render this as "missing" (red) and will not rewrite it. + a.setAttribute("href", "__missing__"); + } + } + return doc.body.innerHTML; + } catch { + return raw; + } +}