"use client"; import { useEffect, useMemo, useRef, useState } from "react"; import type { Entity } from "@/uhm/api/entities"; import type { Wiki } from "@/uhm/api/wikis"; type TocItem = { id: string; level: number; text: string; }; type Props = { entity: Entity | null; wiki: Wiki | null; isLoading: boolean; error?: string | null; onClose: () => void; onWikiLinkRequest: (request: { slug: string; rect: DOMRect }) => void; }; 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 ""; if (value[0] === "<") return value; 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 prepareWikiHtml(inputHtml: string): { html: string; toc: TocItem[] } { const parser = new DOMParser(); const doc = parser.parseFromString(inputHtml, "text/html"); for (const el of Array.from(doc.querySelectorAll("script"))) el.remove(); 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)) { a.setAttribute("target", "_blank"); a.setAttribute("rel", "noopener noreferrer"); continue; } const match = href.match(/^([^?#]+)([?#].*)?$/); const slugPart = String(match?.[1] || "").replace(/^\/+/, "").trim(); if (!slugPart.length) continue; a.setAttribute("href", `#wiki:${slugPart}`); a.setAttribute("data-wiki-slug", slugPart); a.setAttribute("target", "_self"); } 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 nextCount = (seen.get(base) || 0) + 1; seen.set(base, nextCount); const id = nextCount === 1 ? base : `${base}-${nextCount}`; h.setAttribute("id", id); toc.push({ id, level, text }); } return { html: doc.body.innerHTML, toc }; } export default function PublicWikiSidebar({ entity, wiki, isLoading, error, onClose, onWikiLinkRequest, }: Props) { const contentRootRef = useRef(null); const [activeHeadingId, setActiveHeadingId] = useState(null); const processedWiki = useMemo(() => { if (!wiki) return { html: "", toc: [] as TocItem[] }; const html = normalizeWikiContentToHtml(wiki.content ?? ""); try { return prepareWikiHtml(html); } catch (err) { console.error("Failed to process sidebar wiki HTML", err); return { html, toc: [] as TocItem[] }; } }, [wiki]); const renderHtml = processedWiki.html; const toc = processedWiki.toc; const effectiveActiveHeadingId = toc.some((item) => item.id === activeHeadingId) ? activeHeadingId : (toc[0]?.id ?? null); useEffect(() => { if (!toc.length) return; const root = contentRootRef.current; if (!root) return; const headings = toc .map((item) => root.querySelector(`#${CSS.escape(item.id)}`)) .filter((item): item is HTMLElement => Boolean(item)); if (!headings.length) return; const observer = new IntersectionObserver( (entries) => { const visible = entries .filter((entry) => entry.isIntersecting) .sort((a, b) => (a.boundingClientRect.top ?? 0) - (b.boundingClientRect.top ?? 0)); const top = visible[0]?.target as HTMLElement | undefined; if (top?.id) setActiveHeadingId(top.id); }, { root: null, rootMargin: "-18% 0px -70% 0px", threshold: [0, 1] } ); for (const heading of headings) observer.observe(heading); return () => observer.disconnect(); }, [toc]); useEffect(() => { const root = contentRootRef.current; if (!root) return; const handleClick = (event: MouseEvent) => { const target = event.target as HTMLElement | null; const link = target?.closest?.("a[data-wiki-slug]") as HTMLAnchorElement | null; if (!link) return; event.preventDefault(); const slug = String(link.getAttribute("data-wiki-slug") || "").trim(); if (!slug.length) return; onWikiLinkRequest({ slug, rect: link.getBoundingClientRect() }); }; root.addEventListener("click", handleClick); return () => root.removeEventListener("click", handleClick); }, [onWikiLinkRequest, renderHtml]); return (
Wiki
{entity?.name?.trim() || wiki?.title?.trim() || "Wiki"}
{entity?.description?.trim() ? (
{entity.description.trim()}
) : null} {wiki?.title?.trim() && wiki.title.trim() !== entity?.name?.trim() ? (
{wiki.title.trim()}
) : null}
{toc.length ? (
{toc.slice(0, 8).map((item) => { const isActive = effectiveActiveHeadingId === item.id; return ( {item.text} ); })}
) : null}
{isLoading ? (
) : error ? (
{error}
) : wiki ? (
) : (
Entity này chưa có wiki liên kết.
)}
); }