"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}
) : (
)}
); }