"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, getContentByVersionWikiId, type Wiki } from "@/uhm/api/wikis"; type TocItem = { id: string; level: number; text: string; }; type WikiVersionRow = { id: string; title?: string; created_at?: string; content?: string; isCurrent: boolean; }; 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 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, options?: Intl.DateTimeFormatOptions): string { const raw = String(value || "").trim(); if (!raw) return "-"; const d = new Date(raw); if (Number.isNaN(d.getTime())) return raw; return d.toLocaleString( "vi-VN", options || { hour: "2-digit", minute: "2-digit", day: "numeric", month: "long", year: "numeric", } ); } 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 [viewMode, setViewMode] = useState<"read" | "history" | "compare">("read"); const [selectedVersionsForCompare, setSelectedVersionsForCompare] = useState>(new Set()); const [comparisonData, setComparisonData] = useState<{ id: string; content: string; createdAt: string; title: string }[]>([]); const [isComparing, setIsComparing] = useState(false); const [renderHtml, setRenderHtml] = useState(""); const [toc, setToc] = useState([]); const [activeHeadingId, setActiveHeadingId] = useState(null); const [linkPreview, setLinkPreview] = useState<{ slug: string; top: number; left: number; width: number; height: number; visible: boolean; } | null>(null); const [linkPreviewData, setLinkPreviewData] = useState<{ slug: string; title: string; quote: string | null; status: "idle" | "loading" | "ready" | "error"; } | null>(null); const normalizedSlug = useMemo(() => String(slug || "").trim(), [slug]); const contentRootRef = useRef(null); const hidePreviewTimerRef = useRef(null); const previewCacheRef = useRef>(new Map()); const allVersions = useMemo(() => { if (!wiki) return []; const current: WikiVersionRow = { id: wiki.id, created_at: wiki.updated_at, content: wiki.content, isCurrent: true, }; const history: WikiVersionRow[] = (wiki.content_sample || []).map((s) => ({ ...s, isCurrent: false })); const uniqueHistory = history.filter(h => h.id !== current.id); const combined = [current, ...uniqueHistory]; return combined .filter(v => v.id && v.created_at) .sort((a, b) => new Date(b.created_at!).getTime() - new Date(a.created_at!).getTime()); }, [wiki]); // 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); let versionContent = res?.content; try { if (res?.content_sample?.[0]?.id) { const contentResp = await getContentByVersionWikiId(res.content_sample[0].id); if (contentResp?.data?.content) { versionContent = contentResp.data.content; } } } catch (err) { console.error("Failed to fetch version content:", err); } if (disposed) return; if (!res) { setWiki(null); setStatus("ready"); setRenderHtml(""); setToc([]); return; } setWiki({ ...res, content: versionContent }); 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]); // Hover preview for internal wiki links (title + first blockquote). useEffect(() => { const root = contentRootRef.current; if (!root) return; if (typeof window === "undefined") return; const clearHideTimer = () => { if (hidePreviewTimerRef.current != null) { window.clearTimeout(hidePreviewTimerRef.current); hidePreviewTimerRef.current = null; } }; const hideSoon = () => { clearHideTimer(); hidePreviewTimerRef.current = window.setTimeout(() => { setLinkPreview((prev) => (prev ? { ...prev, visible: false } : prev)); }, 140); }; const resolveInternalWikiSlug = (href: string): string | null => { const h = href.trim(); if (!h.length) return null; if (h === "__missing__") return null; if (h.startsWith("#")) return null; const stripQueryHash = (s: string) => { const m = s.match(/^([^?#]+)([?#].*)?$/); return String(m?.[1] || ""); }; if (h.startsWith("/wiki/")) { const path = stripQueryHash(h); const slugPart = path.slice("/wiki/".length).trim(); return slugPart ? decodeURIComponent(slugPart) : null; } const originPrefix = window.location.origin + "/wiki/"; if (h.startsWith(originPrefix)) { const rest = stripQueryHash(h.slice(originPrefix.length)); const slugPart = rest.trim(); return slugPart ? decodeURIComponent(slugPart) : null; } return null; }; const fetchPreview = async (targetSlug: string) => { const key = targetSlug.trim(); if (!key.length) return; const cached = previewCacheRef.current.get(key); if (cached) { setLinkPreviewData({ slug: key, title: cached.title, quote: cached.quote, status: "ready" }); return; } setLinkPreviewData((prev) => ({ slug: key, title: prev?.title || key, quote: null, status: "loading" })); try { const row = await fetchWikiBySlug(key); if (!row) { setLinkPreviewData({ slug: key, title: key, quote: null, status: "error" }); return; } const html = normalizeWikiContentToHtml(row.content ?? ""); let quote: string | null = null; try { const parser = new DOMParser(); const doc = parser.parseFromString(html, "text/html"); const bq = doc.body.querySelector("blockquote"); const txt = String(bq?.textContent || "").trim(); quote = txt.length ? txt : null; } catch { quote = null; } const title = String(row.title || "").trim() || key; previewCacheRef.current.set(key, { title, quote }); setLinkPreviewData({ slug: key, title, quote, status: "ready" }); } catch { setLinkPreviewData({ slug: key, title: key, quote: null, status: "error" }); } }; const showForAnchor = (a: HTMLAnchorElement) => { const href = String(a.getAttribute("href") || "").trim(); const targetSlug = resolveInternalWikiSlug(href); if (!targetSlug) return; // Avoid previews on touch devices. if (window.matchMedia && window.matchMedia("(hover: none)").matches) return; const rect = a.getBoundingClientRect(); const width = 420; const height = 320; const margin = 12; const preferredLeft = rect.right + margin; const maxLeft = Math.max(margin, window.innerWidth - width - margin); const left = Math.min(preferredLeft, maxLeft); const preferredTop = rect.top; const maxTop = Math.max(margin, window.innerHeight - height - margin); const top = Math.max(margin, Math.min(preferredTop, maxTop)); clearHideTimer(); setLinkPreview({ slug: targetSlug, top, left, width, height, visible: true }); void fetchPreview(targetSlug); }; const onMouseOver = (evt: MouseEvent) => { const target = evt.target as HTMLElement | null; const a = target?.closest?.("a") as HTMLAnchorElement | null; if (!a) return; showForAnchor(a); }; const onMouseOut = (evt: MouseEvent) => { const target = evt.target as HTMLElement | null; const related = evt.relatedTarget as HTMLElement | null; const fromA = target?.closest?.("a"); if (!fromA) return; if (related && related.closest?.(".uhm-wiki-link-preview")) return; hideSoon(); }; const onKeyDown = (evt: KeyboardEvent) => { if (evt.key === "Escape") { clearHideTimer(); setLinkPreview((prev) => (prev ? { ...prev, visible: false } : prev)); } }; const onScroll = () => { setLinkPreview((prev) => (prev ? { ...prev, visible: false } : prev)); }; root.addEventListener("mouseover", onMouseOver); root.addEventListener("mouseout", onMouseOut); window.addEventListener("keydown", onKeyDown); window.addEventListener("scroll", onScroll, { passive: true }); return () => { root.removeEventListener("mouseover", onMouseOver); root.removeEventListener("mouseout", onMouseOut); window.removeEventListener("keydown", onKeyDown); window.removeEventListener("scroll", onScroll); clearHideTimer(); }; }, [renderHtml]); const handleToggleVersionForCompare = (versionId: string) => { setSelectedVersionsForCompare(prev => { const next = new Set(prev); if (next.has(versionId)) { next.delete(versionId); } else { if (next.size >= 3) { return prev; // Do not allow selecting more than 3 } next.add(versionId); } return next; }); }; const handleCompareVersions = async () => { if (selectedVersionsForCompare.size < 1) { alert("Vui lòng chọn ít nhất 1 phiên bản để so sánh."); return; } setIsComparing(true); setError(null); try { const versionsToFetch = Array.from(selectedVersionsForCompare); const promises = versionsToFetch.map(async (versionId) => { const sample = allVersions.find(s => s.id === versionId); const versionInfo = { id: versionId, createdAt: sample?.created_at || 'Unknown date', title: `Phiên bản lúc ${formatDate(sample?.created_at)}` }; if (sample?.isCurrent) { return { ...versionInfo, content: sample.content || "" }; } const contentResp = await getContentByVersionWikiId(versionId); return { ...versionInfo, content: contentResp?.data?.content || "" }; }); const results = await Promise.all(promises); const processedResults = results.map(r => { const { html } = rewriteHtmlAndBuildToc(normalizeWikiContentToHtml(r.content), `${window.location.origin}/wiki/`); return { ...r, content: html }; }); setComparisonData(processedResults.sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime())); setViewMode("compare"); } catch (err) { const msg = err instanceof ApiError ? err.message : err instanceof Error ? err.message : "Lỗi khi tải phiên bản để so sánh."; setError(msg); setViewMode("read"); } finally { setIsComparing(false); } }; return (
GeoHistory Wiki
Trang chủ
{status === "loading" &&
Đang tải...
} {status === "error" &&
{error}
} {status === "ready" && !wiki &&
Không tìm thấy wiki với slug: {normalizedSlug}
} {status === "ready" && wiki && ( <>

{wiki.title?.trim() || normalizedSlug}

{viewMode === 'compare' && (
Slug: {normalizedSlug || "-"}
ID: {wiki.id || "-"}
Dự án: {wiki.project_id || "-"}
Tạo lúc: {formatDate(wiki.created_at)}
Cập nhật: {formatDate(wiki.updated_at)}
)}
{viewMode === 'read' && (
)} {viewMode === 'history' && (

Lịch sử phiên bản của "{wiki.title}"

{allVersions.map((v) => { const isChecked = selectedVersionsForCompare.has(v.id!); const isDisabled = !isChecked && selectedVersionsForCompare.size >= 3; return ( ); })}
So sánh Ngày cập nhật Ghi chú
handleToggleVersionForCompare(v.id!)} checked={isChecked} disabled={isDisabled} className="h-4 w-4 disabled:cursor-not-allowed" /> {formatDate(v.created_at)} {v.isCurrent && (Phiên bản hiện tại)}
)} {viewMode === 'compare' && (

So sánh các phiên bản

= 3 ? 'xl:grid-cols-3' : ''} mx-auto px-4 sm:px-6`}> {comparisonData.map(version => (

{version.title}

))}
)}
{viewMode !== 'compare' && ( )}
)}
{linkPreview && linkPreview.visible ? (
{ if (hidePreviewTimerRef.current != null) { window.clearTimeout(hidePreviewTimerRef.current); hidePreviewTimerRef.current = null; } }} onMouseLeave={() => { setLinkPreview((prev) => (prev ? { ...prev, visible: false } : prev)); }} >
/wiki/{linkPreview.slug}
{linkPreviewData?.slug === linkPreview.slug ? linkPreviewData.status === "loading" ? "Loading..." : linkPreviewData.status === "error" ? "Not found" : linkPreviewData.title : "Loading..."}
{linkPreviewData?.slug === linkPreview.slug && linkPreviewData.status === "ready" ? ( linkPreviewData.quote ? (
{linkPreviewData.quote}
) : (
No resume.
) ) : (
Loading preview...
)}
) : null}
); }