Files
History-user/src/uhm/components/wiki/PublicWikiSidebar.tsx
T
2026-06-07 10:50:44 +07:00

730 lines
28 KiB
TypeScript

"use client";
import { useEffect, useMemo, useRef, useState, memo } from "react";
// Loaded dynamically inside the component to prevent render-blocking
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;
compactHeader?: boolean;
sidebarHeight?: number;
onSidebarHeightChange?: (height: number) => void;
};
function escapeHtml(input: string): string {
return input
.replaceAll("&", "&")
.replaceAll("<", "&lt;")
.replaceAll(">", "&gt;")
.replaceAll("\"", "&quot;")
.replaceAll("'", "&#39;");
}
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("&nbsp;", " ").replaceAll("\u00a0", " ");
if (value[0] === "<") return value;
return `<p>${escapeHtml(value).replace(/\n/g, "<br/>")}</p>`;
}
function slugifyHeading(raw: string): string {
const input = String(raw || "").trim();
if (!input.length) return "";
return input
.toLowerCase()
.replace(/đ/g, "d")
.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<string, number>();
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 };
}
function PublicWikiSidebar({
entity,
wiki,
isLoading,
error,
onClose,
onWikiLinkRequest,
sidebarWidth,
onSidebarWidthChange,
maxDragWidth,
compactHeader = false,
sidebarHeight,
onSidebarHeightChange,
}: Props) {
const contentRootRef = useRef<HTMLDivElement | null>(null);
const tocContainerRef = useRef<HTMLDivElement | null>(null);
useEffect(() => {
import("react-quill-new/dist/quill.snow.css");
}, []);
const [localWidth, setLocalWidth] = useState<number>(() => {
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<HTMLDivElement>) => {
event.preventDefault();
const startX = event.clientX;
const startWidth = width;
// Tạo đường ghost ảo chỉ vị trí kéo thay vì kích hoạt re-render liên tục
const ghost = document.createElement("div");
ghost.style.position = "fixed";
ghost.style.top = "0";
ghost.style.bottom = "0";
ghost.style.width = "4px";
ghost.style.backgroundColor = "#38bdf8";
ghost.style.boxShadow = "0 0 12px rgba(56, 189, 248, 0.8)";
ghost.style.zIndex = "99999";
ghost.style.cursor = "col-resize";
ghost.style.pointerEvents = "none";
ghost.style.left = `${startX}px`;
document.body.appendChild(ghost);
const onMove = (e: PointerEvent) => {
ghost.style.left = `${e.clientX}px`;
};
const onUp = (e: PointerEvent) => {
window.removeEventListener("pointermove", onMove);
window.removeEventListener("pointerup", onUp);
if (ghost.parentNode) {
ghost.parentNode.removeChild(ghost);
}
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));
}
};
window.addEventListener("pointermove", onMove);
window.addEventListener("pointerup", onUp);
};
const [activeHeadingId, setActiveHeadingId] = useState<string | null>(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<HTMLElement>(`#${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]);
const isExpanded = useMemo(() => {
if (typeof window === "undefined") return false;
const fullHeight = Math.round(window.innerHeight * 0.70);
return (sidebarHeight || 400) >= fullHeight;
}, [sidebarHeight]);
const handleHeightToggle = () => {
if (typeof window === "undefined") return;
const halfHeight = Math.round(window.innerHeight * 0.45);
const fullHeight = Math.round(window.innerHeight * 0.85);
const currentHeight = sidebarHeight || 400;
const nextHeight = Math.abs(currentHeight - halfHeight) < Math.abs(currentHeight - fullHeight)
? fullHeight
: halfHeight;
if (onSidebarHeightChange) {
onSidebarHeightChange(nextHeight);
}
};
const [isMobileOrTablet, setIsMobileOrTablet] = useState(false);
useEffect(() => {
const checkDevice = () => setIsMobileOrTablet(window.innerWidth < 1024);
checkDevice();
window.addEventListener("resize", checkDevice);
return () => window.removeEventListener("resize", checkDevice);
}, []);
return (
<div
style={{
width: isMobileOrTablet ? "100%" : `${width}px`,
maxWidth: "100%",
display: "flex",
flexDirection: "column",
height: "100%",
minHeight: 0,
overflow: "hidden",
borderRadius: isMobileOrTablet ? "24px 24px 0 0" : 20,
border: isMobileOrTablet ? "1px solid rgba(148, 163, 184, 0.22)" : "1px solid rgba(148, 163, 184, 0.22)",
borderBottom: isMobileOrTablet ? "none" : "1px solid rgba(148, 163, 184, 0.22)",
borderLeft: isMobileOrTablet ? "none" : "1px solid rgba(148, 163, 184, 0.22)",
borderRight: isMobileOrTablet ? "none" : "1px solid rgba(148, 163, 184, 0.22)",
background: "linear-gradient(145deg, rgba(15, 23, 42, 0.95), rgba(30, 41, 59, 0.85))",
boxShadow: "0 20px 48px rgba(2, 6, 23, 0.45)",
backdropFilter: "blur(12px)",
position: "relative",
}}
>
{/* Grab Handle for bottom sheet on mobile */}
{isMobileOrTablet ? (
<div
onClick={handleHeightToggle}
style={{
width: "100%",
height: 28,
display: "flex",
alignItems: "center",
justifyContent: "center",
cursor: "pointer",
zIndex: 60,
userSelect: "none",
flexShrink: 0,
gap: 8,
}}
>
<div
style={{
width: 36,
height: 4,
borderRadius: 2,
backgroundColor: "rgba(255, 255, 255, 0.3)",
}}
/>
<svg
width="12"
height="12"
viewBox="0 0 24 24"
fill="none"
stroke="rgba(255, 255, 255, 0.5)"
strokeWidth="3"
strokeLinecap="round"
strokeLinejoin="round"
style={{
transform: isExpanded ? "rotate(180deg)" : "rotate(0deg)",
transition: "transform 0.3s ease",
}}
>
<polyline points="18 15 12 9 6 15" />
</svg>
</div>
) : null}
{/* Drag Handle on the left edge */}
<div
onPointerDown={handlePointerDown}
style={{
position: "absolute",
left: 0,
top: 0,
bottom: 0,
width: 6,
cursor: "col-resize",
zIndex: 50,
userSelect: "none",
}}
className="group hidden lg:block"
title="Kéo để chỉnh kích thước"
>
{/* Visual drag line overlay */}
<div
style={{
position: "absolute",
left: 2,
top: 0,
bottom: 0,
width: 2,
background: "transparent",
transition: "background-color 0.2s",
}}
className="group-hover:bg-sky-500/50 group-active:bg-sky-500"
/>
</div>
<div
style={{
borderBottom: "1px solid rgba(148, 163, 184, 0.15)",
padding: "16px",
}}
>
<div style={{ display: "flex", alignItems: "start", justifyContent: "space-between", gap: 12 }}>
<div style={{ minWidth: 0, flex: 1 }}>
{compactHeader ? null : (
<div
style={{
fontSize: 10,
textTransform: "uppercase",
letterSpacing: "1.2px",
fontWeight: 900,
color: "#94a3b8",
}}
>
Wiki
</div>
)}
<div
style={{
marginTop: compactHeader ? 0 : 4,
fontSize: 18,
fontWeight: 700,
lineHeight: 1.3,
color: "#f8fafc",
}}
>
{wiki?.title?.trim() || entity?.name?.trim() || "Wiki"}
</div>
</div>
<button
type="button"
onClick={onClose}
style={{
display: "inline-flex",
height: 28,
width: 28,
alignItems: "center",
justifyContent: "center",
borderRadius: "50%",
border: "1px solid rgba(148, 163, 184, 0.25)",
background: "rgba(30, 41, 59, 0.4)",
color: "#94a3b8",
cursor: "pointer",
fontSize: 12,
transition: "all 0.2s",
outline: "none",
}}
className="hover:bg-slate-700/50 hover:text-slate-100"
aria-label="Close wiki sidebar"
>
x
</button>
</div>
</div>
{toc.length ? (
<div
style={{
borderBottom: "1px solid rgba(148, 163, 184, 0.15)",
padding: "8px 12px",
}}
>
<div
ref={tocContainerRef}
className="uhm-public-wiki-toc-list"
style={{
display: "flex",
gap: 8,
overflowX: "auto",
paddingBottom: 4,
}}
>
{toc.slice(0, 8).map((item) => {
const isActive = effectiveActiveHeadingId === item.id;
return (
<a
key={item.id}
href={`#${item.id}`}
onClick={(e) => {
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}
</a>
);
})}
</div>
</div>
) : null}
<div
className="uhm-public-wiki-sidebar-content"
style={{
minHeight: 0,
flex: 1,
overflowY: "auto",
}}
>
{isLoading ? (
<div style={{ display: "flex", flexDirection: "column", gap: 12, padding: 16 }}>
<div
style={{ height: 16, width: 110, borderRadius: 4, background: "rgba(148, 163, 184, 0.15)" }}
className="animate-pulse"
/>
<div
style={{ height: 16, width: "100%", borderRadius: 4, background: "rgba(148, 163, 184, 0.15)" }}
className="animate-pulse"
/>
<div
style={{ height: 16, width: "80%", borderRadius: 4, background: "rgba(148, 163, 184, 0.15)" }}
className="animate-pulse"
/>
</div>
) : error ? (
<div style={{ padding: 16, fontSize: 14, color: "#f87171" }}>
{error}
</div>
) : wiki ? (
<div
ref={contentRootRef}
className="uhm-wiki-sidebar-view ql-editor"
style={{ fontSize: 14, color: "#cbd5e1" }}
dangerouslySetInnerHTML={{ __html: renderHtml }}
/>
) : (
<div style={{ padding: 16, fontSize: 14, color: "#94a3b8" }}>
Entity này chưa wiki liên kết.
</div>
)}
</div>
<style jsx global>{`
.uhm-public-wiki-sidebar-content::-webkit-scrollbar {
width: 6px;
}
.uhm-public-wiki-sidebar-content::-webkit-scrollbar-track {
background: transparent;
}
.uhm-public-wiki-sidebar-content::-webkit-scrollbar-thumb {
background: rgba(148, 163, 184, 0.22);
border-radius: 3px;
}
.uhm-public-wiki-sidebar-content::-webkit-scrollbar-thumb:hover {
background: rgba(148, 163, 184, 0.4);
}
.uhm-public-wiki-toc-list::-webkit-scrollbar {
height: 4px;
}
.uhm-public-wiki-toc-list::-webkit-scrollbar-track {
background: transparent;
}
.uhm-public-wiki-toc-list::-webkit-scrollbar-thumb {
background: rgba(148, 163, 184, 0.22);
border-radius: 2px;
}
.uhm-public-wiki-toc-list::-webkit-scrollbar-thumb:hover {
background: rgba(148, 163, 184, 0.4);
}
.uhm-wiki-sidebar-view.ql-editor {
height: auto;
overflow-y: visible;
padding: 18px 18px 22px;
line-height: 1.6;
font-size: 14.5px;
word-wrap: break-word;
word-break: break-word;
overflow-wrap: break-word;
color: #cbd5e1 !important;
}
.uhm-wiki-sidebar-view.ql-editor p {
margin: 0 0 0.75em;
}
.uhm-wiki-sidebar-view.ql-editor h1 {
margin: 1.15em 0 0.6em;
font-size: 1.6em;
font-weight: 800;
line-height: 1.2;
color: #f8fafc !important;
}
.uhm-wiki-sidebar-view.ql-editor h2 {
margin: 1.05em 0 0.55em;
font-size: 1.3em;
font-weight: 800;
line-height: 1.25;
color: #f8fafc !important;
}
.uhm-wiki-sidebar-view.ql-editor h3,
.uhm-wiki-sidebar-view.ql-editor h4,
.uhm-wiki-sidebar-view.ql-editor h5,
.uhm-wiki-sidebar-view.ql-editor h6 {
margin: 0.95em 0 0.45em;
font-size: 1.05em;
font-weight: 700;
line-height: 1.3;
color: #f8fafc !important;
}
.uhm-wiki-sidebar-view.ql-editor ul,
.uhm-wiki-sidebar-view.ql-editor ol {
margin: 0 0 0.75em;
padding-left: 1.5em;
}
.uhm-wiki-sidebar-view.ql-editor blockquote {
margin: 0 0 0.75em;
padding-left: 12px;
border-left: 3px solid rgba(148, 163, 184, 0.4);
color: rgba(203, 213, 225, 0.95);
}
.uhm-wiki-sidebar-view.ql-editor pre {
margin: 0 0 0.75em;
padding: 12px 14px;
border: 1px solid rgba(148, 163, 184, 0.22);
border-radius: 10px;
background: rgba(15, 23, 42, 0.4);
overflow-x: auto;
white-space: pre-wrap;
word-break: break-all;
color: #cbd5e1;
}
.uhm-wiki-sidebar-view.ql-editor img {
max-width: 100%;
height: auto;
border-radius: 8px;
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.2);
}
.uhm-wiki-sidebar-view.ql-editor img[style*="float: left"],
.uhm-wiki-sidebar-view.ql-editor img.ql-align-left {
float: left !important;
margin: 4px 14px 14px 0px !important;
display: inline !important;
}
.uhm-wiki-sidebar-view.ql-editor img[style*="float: right"],
.uhm-wiki-sidebar-view.ql-editor img.ql-align-right {
float: right !important;
margin: 4px 0px 14px 14px !important;
display: inline !important;
}
.uhm-wiki-sidebar-view.ql-editor img[style*="display: block"],
.uhm-wiki-sidebar-view.ql-editor img.ql-align-center {
display: block !important;
margin: 1.25em auto !important;
}
.uhm-wiki-sidebar-view.ql-editor a {
text-decoration: underline;
text-underline-offset: 2px;
}
.uhm-wiki-sidebar-view.ql-editor a[href]:not([href=""]):not([href="__missing__"]) {
color: #38bdf8 !important;
transition: color 0.15s ease;
}
.uhm-wiki-sidebar-view.ql-editor a[href]:not([href=""]):not([href="__missing__"]):hover {
color: #7dd3fc !important;
}
.uhm-wiki-sidebar-view.ql-editor a[href="__missing__"] {
cursor: default;
pointer-events: none;
}
.uhm-wiki-sidebar-view.ql-editor a:not([href]),
.uhm-wiki-sidebar-view.ql-editor a[href=""],
.uhm-wiki-sidebar-view.ql-editor a[href="__missing__"] {
color: #f87171 !important;
}
@media (max-width: 640px) {
.uhm-wiki-sidebar-view.ql-editor {
padding: 14px 14px 20px;
font-size: 13.5px;
}
.uhm-wiki-sidebar-view.ql-editor h1 {
font-size: 1.4em;
}
.uhm-wiki-sidebar-view.ql-editor h2 {
font-size: 1.2em;
}
}
`}</style>
</div>
);
}
export default memo(PublicWikiSidebar);