"use client"; import { useEffect, useMemo, useRef, useState } from "react"; import "react-quill-new/dist/quill.snow.css"; 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; sidebarWidth?: number; onSidebarWidthChange?: (width: number) => void; maxDragWidth?: number; }; function escapeHtml(input: string): string { return input .replaceAll("&", "&") .replaceAll("<", "<") .replaceAll(">", ">") .replaceAll("\"", """) .replaceAll("'", "'"); } function normalizeWikiContentToHtml(raw: string | null | undefined): string { let value = String(raw || "").trim(); if (!value.length) return ""; // Replace non-breaking spaces to allow text wrap value = value.replaceAll(" ", " ").replaceAll("\u00a0", " "); 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, sidebarWidth, onSidebarWidthChange, maxDragWidth, }: Props) { const contentRootRef = useRef(null); const tocContainerRef = useRef(null); const [localWidth, setLocalWidth] = useState(() => { if (typeof window !== "undefined") { const saved = localStorage.getItem("public-wiki-sidebar-width"); if (saved) { const parsed = parseInt(saved, 10); if (!isNaN(parsed) && parsed >= 320 && parsed <= 800) { return parsed; } } } return 420; }); const width = sidebarWidth ?? localWidth; const setWidth = onSidebarWidthChange ?? setLocalWidth; const maxDragWidthLimit = maxDragWidth ?? 800; const handlePointerDown = (event: React.PointerEvent) => { event.preventDefault(); const startX = event.clientX; const startWidth = width; const onMove = (e: PointerEvent) => { const deltaX = e.clientX - startX; const nextWidth = Math.max(320, Math.min(maxDragWidthLimit, startWidth - deltaX)); setWidth(nextWidth); if (typeof window !== "undefined") { localStorage.setItem("public-wiki-sidebar-width", String(nextWidth)); } }; const onUp = () => { window.removeEventListener("pointermove", onMove); window.removeEventListener("pointerup", onUp); }; window.addEventListener("pointermove", onMove); window.addEventListener("pointerup", onUp); }; 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 scrollContainer = root.parentElement; 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: scrollContainer || null, rootMargin: "-18% 0px -70% 0px", threshold: [0, 1] } ); for (const heading of headings) observer.observe(heading); return () => observer.disconnect(); }, [toc]); useEffect(() => { const container = tocContainerRef.current; if (!container) return; const handleWheel = (e: WheelEvent) => { if (e.deltaY !== 0) { e.preventDefault(); container.scrollLeft += e.deltaY; } }; container.addEventListener("wheel", handleWheel, { passive: false }); return () => { container.removeEventListener("wheel", handleWheel); }; }, [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 (
{/* Drag Handle on the left edge */}
{/* Visual drag line overlay */}
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 ( { e.preventDefault(); setActiveHeadingId(item.id); const root = contentRootRef.current; if (root) { const targetElement = root.querySelector(`#${CSS.escape(item.id)}`) as HTMLElement | null; const scrollContainer = root.parentElement; if (targetElement && scrollContainer) { const containerTop = scrollContainer.getBoundingClientRect().top; const targetTop = targetElement.getBoundingClientRect().top; const scrollOffset = targetTop - containerTop + scrollContainer.scrollTop; scrollContainer.scrollTo({ top: scrollOffset - 12, behavior: "smooth" }); } } }} style={{ flexShrink: 0, borderRadius: 9999, padding: "4px 10px", fontSize: 11, fontWeight: 650, textDecoration: "none", transition: "all 0.2s", background: isActive ? "rgba(56, 189, 248, 0.15)" : "rgba(30, 41, 59, 0.4)", color: isActive ? "#38bdf8" : "#94a3b8", border: isActive ? "1px solid rgba(56, 189, 248, 0.3)" : "1px solid rgba(148, 163, 184, 0.1)", }} className={isActive ? "" : "hover:bg-slate-700/40 hover:text-slate-200"} > {item.text} ); })}
) : null}
{isLoading ? (
) : error ? (
{error}
) : wiki ? (
) : (
Entity này chưa có wiki liên kết.
)}
); }