refactor UI recusion for wiki

This commit is contained in:
taDuc
2026-06-07 14:17:39 +07:00
parent 501d562025
commit f41b14349c
9 changed files with 1755 additions and 154 deletions
+35
View File
@@ -2,6 +2,7 @@ import { API_ENDPOINTS } from "@/uhm/api/config";
import { requestJson } from "@/uhm/api/http";
import type { Entity } from "@/uhm/api/entities";
import type { Wiki } from "@/uhm/api/wikis";
import type { Geometry } from "@/uhm/types/geo";
const RELATION_BATCH_SIZE = 10;
const RELATION_BATCH_CONCURRENCY = 4;
@@ -12,8 +13,42 @@ export type WikiContentPreview = {
created_at?: string | null;
};
export type RelationGeometry = {
id: string;
draw_geometry: Geometry;
type?: string | null;
geo_type?: number | null;
bound_with?: string | null;
time_start?: number | null;
time_end?: number | null;
};
type RelationType =
| "wiki-entity"
| "entity-wiki"
| "geometry-entity"
| "entity-geometry"
| "entity-geometry-child"
| "entity-geometry-alone";
const entitiesPromiseCache: Record<string, Promise<Entity[]>> = {};
export async function fetchRelationMap<T>(type: RelationType, ids: string[]): Promise<Record<string, T[]>> {
const uniqueIds = uniqueStrings(ids);
if (!uniqueIds.length) return {};
return requestJson<Record<string, T[]>>(
`${API_ENDPOINTS.relations}?type=${encodeURIComponent(type)}&${buildArrayQuery("ids", uniqueIds)}`
);
}
export async function fetchEntitiesByWikiIds(ids: string[]): Promise<Record<string, Entity[]>> {
return fetchRelationMap<Entity>("wiki-entity", ids);
}
export async function fetchGeometriesByEntityIds(ids: string[]): Promise<Record<string, RelationGeometry[]>> {
return fetchRelationMap<RelationGeometry>("entity-geometry", ids);
}
export async function fetchEntitiesByGeometryIds(ids: string[]): Promise<Record<string, Entity[]>> {
const uniqueIds = uniqueStrings(ids);
const missingIds = uniqueIds.filter(id => !entitiesPromiseCache[id]);
@@ -15,6 +15,7 @@ type Props = {
activeStepLabel: string | null;
activeStepNumber: number | null;
totalSteps: number;
playButtonLabel?: string;
onPlayPreview: () => void;
onStopPreview: () => void;
onResetPreview: () => void;
@@ -32,6 +33,7 @@ export default function ReplayPreviewOverlay({
activeStepLabel,
activeStepNumber,
totalSteps,
playButtonLabel = "Phát lại",
onPlayPreview,
onStopPreview,
onResetPreview,
@@ -279,7 +281,7 @@ export default function ReplayPreviewOverlay({
onClick={onPlayPreview}
style={previewButtonStyle("#166534")}
>
Phát lại
{playButtonLabel}
</button>
)}
<button
@@ -0,0 +1,276 @@
"use client";
import { useEffect, useRef } from "react";
import type { Entity } from "@/uhm/api/entities";
import type { FeatureCollection } from "@/uhm/types/geo";
export type GeometrySelectionGeometry = {
id: string;
center: [number, number] | null;
adminLabel: string | null;
adminAddress: string | null;
};
export type GeometrySelectionRow = {
entity: Entity;
geometries: GeometrySelectionGeometry[];
featureCollection: FeatureCollection;
};
type Props = {
wikiSlug: string;
rows: GeometrySelectionRow[];
isLoading: boolean;
error?: string | null;
onClose: () => void;
onSelectEntity: (entityId: string) => void;
};
export default function GeometrySelectionPanel({
wikiSlug,
rows,
isLoading,
error,
onClose,
onSelectEntity,
}: Props) {
const containerRef = useRef<HTMLDivElement | null>(null);
useEffect(() => {
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === "Escape") {
onClose();
}
};
window.addEventListener("keydown", handleKeyDown);
return () => window.removeEventListener("keydown", handleKeyDown);
}, [onClose]);
return (
<div
ref={containerRef}
style={{
width: "100%",
maxWidth: "100%",
display: "flex",
flexDirection: "column",
height: "100%",
minHeight: 0,
overflow: "hidden",
borderRadius: 20,
border: "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",
}}
>
<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 }}>
<div
style={{
fontSize: 10,
textTransform: "uppercase",
letterSpacing: "1.2px",
fontWeight: 900,
color: "#94a3b8",
}}
>
Geometry
</div>
<div
style={{
marginTop: 4,
fontSize: 18,
fontWeight: 700,
lineHeight: 1.3,
color: "#f8fafc",
}}
>
Chọn entity đ zoom
</div>
{wikiSlug ? (
<div
style={{
marginTop: 4,
fontSize: 12,
lineHeight: "17px",
color: "#94a3b8",
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap",
}}
>
/wiki/{wikiSlug}
</div>
) : null}
</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 geometry chooser"
>
x
</button>
</div>
</div>
<div className="uhm-pinned-geometry-panel-scroll" style={{ flex: 1, minHeight: 0, overflowY: "auto", padding: 16 }}>
{isLoading ? (
<div style={{ display: "grid", gap: 10 }}>
{[0, 1, 2].map((index) => (
<div
key={index}
style={{
height: index === 0 ? 64 : 82,
borderRadius: 10,
background: "rgba(148, 163, 184, 0.12)",
}}
className="animate-pulse"
/>
))}
</div>
) : error ? (
<div style={{ fontSize: 14, lineHeight: "20px", color: "#f87171" }}>
{error}
</div>
) : rows.length ? (
<div style={{ display: "grid", gap: 10 }}>
{rows.map(({ entity, geometries }, index) => {
const adminText = getGeometryAdminText(geometries);
return (
<div
key={entity.id}
style={{
paddingTop: index > 0 ? 12 : 0,
marginTop: index > 0 ? 4 : 0,
borderTop: index > 0 ? "1px solid rgba(148, 163, 184, 0.16)" : "none",
}}
>
<button
type="button"
onClick={() => onSelectEntity(entity.id)}
style={{
width: "100%",
padding: "9px 10px 9px 12px",
border: "1px solid transparent",
borderRadius: 10,
background: "rgba(15, 23, 42, 0.34)",
boxShadow: "inset 2px 0 0 rgba(56, 189, 248, 0.52)",
textAlign: "left",
cursor: "pointer",
transition: "background 0.15s ease, border-color 0.15s ease",
}}
className="hover:border-sky-400/30 hover:bg-sky-500/10"
>
<div
style={{
fontSize: 14,
fontWeight: 800,
lineHeight: "20px",
color: "#f8fafc",
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap",
}}
>
{entity.name || String(entity.id)} {formatEntityYears(entity)}
</div>
{adminText ? (
<div
style={{
marginTop: 4,
fontSize: 12,
lineHeight: "17px",
color: "#94a3b8",
overflowWrap: "anywhere",
}}
>
{adminText}
</div>
) : entity.description?.trim() ? (
<div
style={{
marginTop: 4,
fontSize: 12,
lineHeight: "17px",
color: "#94a3b8",
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap",
}}
>
{entity.description.trim()}
</div>
) : null}
</button>
</div>
);
})}
</div>
) : (
<div style={{ fontSize: 14, lineHeight: "20px", color: "#94a3b8" }}>
Wiki này chưa entity hoặc geometry liên quan.
</div>
)}
</div>
<style jsx>{`
.uhm-pinned-geometry-panel-scroll {
scrollbar-width: thin;
scrollbar-color: rgba(56, 189, 248, 0.58) rgba(15, 23, 42, 0.72);
}
.uhm-pinned-geometry-panel-scroll::-webkit-scrollbar {
width: 9px;
}
.uhm-pinned-geometry-panel-scroll::-webkit-scrollbar-track {
background: rgba(15, 23, 42, 0.72);
}
.uhm-pinned-geometry-panel-scroll::-webkit-scrollbar-thumb {
border: 2px solid rgba(15, 23, 42, 0.95);
border-radius: 999px;
background: linear-gradient(180deg, rgba(56, 189, 248, 0.86), rgba(14, 165, 233, 0.58));
}
`}</style>
</div>
);
}
function formatEntityYears(entity: Entity): string {
const start = Number.isFinite(entity.time_start) ? String(entity.time_start) : "";
const end = Number.isFinite(entity.time_end) ? String(entity.time_end) : "";
if (!start && !end) return "";
return `(${start || "?"}-${end || "?"})`;
}
function getGeometryAdminText(geometries: GeometrySelectionGeometry[]): string {
const labels = geometries
.map((geometry) => geometry.adminLabel || geometry.adminAddress || "")
.map((label) => label.trim())
.filter((label) => label.length > 0);
return Array.from(new Set(labels)).join(" / ");
}
+18 -24
View File
@@ -527,34 +527,28 @@ const PreviewLayout = forwardRef<PreviewLayoutHandle, Props>(({
}
const linkedEntityIds = previewRelations.wikiEntityIdsBySlug[nextSlug] || [];
const linkedEntities = linkedEntityIds
.map((entityId) => previewRelations.entitiesById[entityId] || null)
.filter((entity): entity is Entity => Boolean(entity));
const firstEntityId = linkedEntityIds[0] || null;
if (!firstEntityId) return;
if (linkedEntities.length === 1) {
selectReplayPreviewEntity(linkedEntities[0].id, {
preferredWikiId: localWiki.id,
focusMap: false,
});
return;
setPreviewActiveEntityId(firstEntityId);
setIsPreviewEntitySidebarOpen(true);
setPreviewRightPanelMode("wiki");
setPreviewWikiSelectionPanelAnchor(null);
setPreviewWikiError(null);
setPreviewLinkEntityPopup(null);
openReplayPreviewWikiPanelById(localWiki.id);
const geometries = previewRelations.entityGeometriesById[firstEntityId];
const map = mapHandleRef?.current?.getMap();
if (map && geometries && geometries.features.length > 0) {
fitMapToFeatureCollection(map, geometries, 96, { duration: 1000, maxZoom: 8, pointZoom: 6 });
}
if (!linkedEntities.length) return;
const popupWidth = 240;
const popupHeight = Math.min(240, linkedEntities.length * 44 + 20);
const { top, left } = computeFixedPopupPosition(rect, popupWidth, popupHeight);
setPreviewLinkEntityPopup({
slug: nextSlug,
entities: linkedEntities,
top,
left,
});
}, [
previewRelations.entitiesById,
mapHandleRef,
openReplayPreviewWikiPanelById,
previewRelations.entityGeometriesById,
previewRelations.wikiEntityIdsBySlug,
selectReplayPreviewEntity,
setPreviewActiveEntityId,
wikis,
]);
+10 -3
View File
@@ -45,6 +45,7 @@ type Props = {
wikiError?: string | null;
onCloseWikiSidebar?: () => void;
onWikiLinkRequest?: (request: { slug: string; rect: DOMRect }) => void;
onWikiLinkEntitySelectionRequest?: (request: { slug: string; rect: DOMRect }) => void;
sidebarWidth?: number;
onSidebarWidthChange?: (width: number) => void;
maxSidebarDragWidth?: number;
@@ -59,6 +60,7 @@ type Props = {
onLayerPanelVisibleChange?: (visible: boolean) => void;
sidebarHeight?: number;
onSidebarHeightChange?: (height: number) => void;
showViewportControls?: boolean;
};
export default function PreviewMapShell({
@@ -91,6 +93,7 @@ export default function PreviewMapShell({
wikiError = null,
onCloseWikiSidebar,
onWikiLinkRequest,
onWikiLinkEntitySelectionRequest,
sidebarWidth,
onSidebarWidthChange,
maxSidebarDragWidth,
@@ -105,6 +108,7 @@ export default function PreviewMapShell({
onLayerPanelVisibleChange,
sidebarHeight,
onSidebarHeightChange,
showViewportControls = true,
}: Props) {
const [isMenuOpen, setIsMenuOpen] = useState(false);
const [avatarUrl, setAvatarUrl] = useState<string | null>(null);
@@ -133,6 +137,8 @@ export default function PreviewMapShell({
fetchUserAvatar();
}, []);
const hasWikiSidebar = Boolean(activeEntity || activeWiki || isWikiLoading || wikiError);
const menuOptionStyle: CSSProperties = {
width: 46,
height: 46,
@@ -172,7 +178,7 @@ export default function PreviewMapShell({
onHoverFeatureChange={onHoverFeatureChange}
onPlayPreviewReplay={onPlayPreviewReplay}
onLoad={onLoad}
showViewportControls={!isMobileOrTablet}
showViewportControls={showViewportControls && !isMobileOrTablet}
height="100svh"
/>
@@ -207,7 +213,7 @@ export default function PreviewMapShell({
style={{
position: "absolute",
top: 10,
bottom: ((activeEntity || activeWiki) && isMobileOrTablet) ? `${(sidebarHeight || 400) + 20}px` : 20,
bottom: (hasWikiSidebar && isMobileOrTablet) ? `${(sidebarHeight || 400) + 20}px` : 20,
left: 18,
zIndex: 18,
display: "flex",
@@ -412,7 +418,7 @@ export default function PreviewMapShell({
{overlay}
{activeEntity || activeWiki ? (
{hasWikiSidebar ? (
<aside
className={isMobileOrTablet ? "uhm-public-wiki-sidebar" : "uhm-public-wiki-sidebar absolute bottom-4 right-4 top-4 left-auto z-20 max-w-[calc(100vw-2rem)]"}
style={isMobileOrTablet ? {
@@ -438,6 +444,7 @@ export default function PreviewMapShell({
error={wikiError}
onClose={onCloseWikiSidebar || (() => {})}
onWikiLinkRequest={onWikiLinkRequest || (() => {})}
onWikiLinkEntitySelectionRequest={onWikiLinkEntitySelectionRequest}
sidebarWidth={sidebarWidth}
onSidebarWidthChange={onSidebarWidthChange}
maxDragWidth={maxSidebarDragWidth}
File diff suppressed because it is too large Load Diff
@@ -39,9 +39,10 @@ export function usePublicPreviewInteraction(options: {
setSelectedFeatureIds: React.Dispatch<React.SetStateAction<(string | number)[]>>;
timelineYear?: number | null;
replayActiveWikiId?: string | null;
replayMode?: "idle" | "playing";
replayMode?: "idle" | "playing" | "paused";
onWikiLinkNavigate?: (wiki: Wiki) => void | Promise<void>;
}) {
const { data, relations, setRelations, selectedFeatureIds, setSelectedFeatureIds, timelineYear, replayActiveWikiId, replayMode } = options;
const { data, relations, setRelations, selectedFeatureIds, setSelectedFeatureIds, timelineYear, replayActiveWikiId, replayMode, onWikiLinkNavigate } = options;
const [activeEntityId, setActiveEntityId] = useState<string | null>(null);
const [activeWikiSlug, setActiveWikiSlug] = useState<string | null>(null);
const [isManualSidebarOpen, setIsManualSidebarOpen] = useState(false);
@@ -50,15 +51,20 @@ export function usePublicPreviewInteraction(options: {
setIsManualSidebarOpen(false);
}, [replayMode]);
const [wikiCache, setWikiCache] = useState<Record<string, CachedWiki>>({});
const [visibleWiki, setVisibleWiki] = useState<CachedWiki | null>(null);
const [hoverWikiPreviewByEntityId, setHoverWikiPreviewByEntityId] = useState<Record<string, HoverWikiPreview>>({});
const [isActiveWikiLoading, setIsActiveWikiLoading] = useState(false);
const [activeWikiError, setActiveWikiError] = useState<string | null>(null);
const [linkEntityPopup, setLinkEntityPopup] = useState<LinkEntityPopupState | null>(null);
const linkEntityPopupRef = useRef<HTMLDivElement | null>(null);
const hoverWikiPreviewRequestsRef = useRef<Set<string>>(new Set());
const wikiLinkRequestSeqRef = useRef(0);
const wikiLinkInFlightSlugRef = useRef<string | null>(null);
const fullWikiFetchAttemptedSlugRef = useRef<Set<string>>(new Set());
const suppressSelectedFeatureAutoSelectRef = useRef(false);
useEffect(() => {
if (replayMode === "playing" && replayActiveWikiId) {
if (replayMode !== "idle" && replayActiveWikiId) {
const activeWikiEntityIds = relations.wikiEntityIdsById[String(replayActiveWikiId)] || [];
const entityId = activeWikiEntityIds[0] || null;
const wikiSlug = relations.wikiById[String(replayActiveWikiId)]?.slug || null;
@@ -83,9 +89,15 @@ export function usePublicPreviewInteraction(options: {
const activeEntity = activeEntityId ? relations.entitiesById[activeEntityId] || null : null;
const activeWiki = useMemo(() => {
if (visibleWiki) return visibleWiki;
if (!activeWikiSlug) return null;
return wikiCache[activeWikiSlug] || relations.wikiBySlug[activeWikiSlug] || null;
}, [activeWikiSlug, relations.wikiBySlug, wikiCache]);
const cachedWiki = findWikiBySlug(wikiCache, activeWikiSlug) || null;
const relationWiki = findWikiBySlug(relations.wikiBySlug, activeWikiSlug) || null;
if (!cachedWiki) return relationWiki;
if (hasWikiContent(cachedWiki) || cachedWiki.id === "__not_found__") return cachedWiki;
if (relationWiki && hasWikiContent(relationWiki)) return relationWiki;
return cachedWiki;
}, [activeWikiSlug, relations.wikiBySlug, visibleWiki, wikiCache]);
const selectEntity = useCallback(async (
entityId: string,
@@ -172,15 +184,17 @@ export function usePublicPreviewInteraction(options: {
: "") ||
firstWikiSlug(linkedWikis);
const cachedFullWiki = nextWikiSlug ? findWikiWithContentBySlug(wikiCache, nextWikiSlug) || null : null;
setActiveEntityId(entityId);
setActiveWikiSlug(nextWikiSlug);
setVisibleWiki(cachedFullWiki);
setActiveWikiError(null);
setLinkEntityPopup(null);
setIsManualSidebarOpen(true);
if (selectOptions?.selectGeometry && selectOptions?.sourceFeatureId != null) {
setSelectedFeatureIds([selectOptions.sourceFeatureId]);
}
}, [relations.entitiesById, relations.entityWikisById, setRelations, setSelectedFeatureIds]);
}, [relations.entitiesById, relations.entityWikisById, setRelations, setSelectedFeatureIds, wikiCache]);
const selectWiki = useCallback(async (
wiki: Wiki
@@ -195,23 +209,30 @@ export function usePublicPreviewInteraction(options: {
if (wiki.slug) {
const slug = wiki.slug;
const cachedFullWiki = findWikiWithContentBySlug(wikiCache, slug) || null;
setWikiCache((prev) => ({
...prev,
[slug]: {
[slug]: cachedFullWiki || {
...wiki,
__fetched: false,
},
}));
setActiveWikiSlug(slug);
setVisibleWiki(cachedFullWiki);
}
setActiveEntityId(null);
setActiveWikiError(null);
setLinkEntityPopup(null);
setIsManualSidebarOpen(true);
}, [relations.wikiEntityIdsById, selectEntity]);
}, [relations.wikiEntityIdsById, selectEntity, wikiCache]);
useEffect(() => {
if (!selectedFeatureIds.length) return;
if (!selectedFeatureIds.length) {
suppressSelectedFeatureAutoSelectRef.current = false;
return;
}
if (suppressSelectedFeatureAutoSelectRef.current) return;
const linkedEntityIds = relations.geometryEntityIds[String(selectedFeatureIds[0])] || [];
if (linkedEntityIds.length !== 1) return;
@@ -422,17 +443,99 @@ export function usePublicPreviewInteraction(options: {
};
}, [activeEntityId, activeWikiSlug, relations.entityWikisById, setRelations]);
const cachedWiki = activeWikiSlug ? wikiCache[activeWikiSlug] : undefined;
const cachedWiki = activeWikiSlug ? findWikiBySlug(wikiCache, activeWikiSlug) : undefined;
const fetchFullWikiBySlug = useCallback(async (slug: string): Promise<Wiki | null> => {
const row = await fetchWikiBySlug(slug);
if (!row) return null;
let versionContent = row.content;
try {
if (row.content_sample?.[0]?.id) {
const res = await getContentByVersionWikiId(row.content_sample[0].id);
const content = extractWikiContentFromResponse(res);
if (content) versionContent = content;
}
} catch (err) {
console.error("Failed to fetch version content:", err);
}
return { ...row, content: versionContent };
}, []);
const focusWikiLinkAfterPaint = useCallback((wiki: Wiki) => {
if (!onWikiLinkNavigate) return;
const run = () => {
void Promise.resolve(onWikiLinkNavigate(wiki)).catch((err) => {
console.error("Failed to focus map for wiki link:", err);
});
};
if (typeof window === "undefined") {
run();
return;
}
window.requestAnimationFrame(() => {
window.requestAnimationFrame(run);
});
}, [onWikiLinkNavigate]);
const publishWikiToPanel = useCallback((wiki: Wiki, requestedSlug?: string | null): CachedWiki => {
const canonicalSlug = String(wiki.slug || "").trim() || String(requestedSlug || "").trim();
const fullWiki: CachedWiki = {
...wiki,
slug: canonicalSlug,
__fetched: true,
};
if (requestedSlug) fullWikiFetchAttemptedSlugRef.current.add(requestedSlug);
if (canonicalSlug) fullWikiFetchAttemptedSlugRef.current.add(canonicalSlug);
setWikiCache((prev) => cacheWikiBySlug(prev, fullWiki, requestedSlug));
setActiveWikiSlug(canonicalSlug);
setVisibleWiki(fullWiki);
setActiveWikiError(hasWikiContent(fullWiki) ? null : "Wiki này chưa có nội dung.");
setIsActiveWikiLoading(false);
return fullWiki;
}, []);
const prepareManualWikiNavigation = useCallback(() => {
suppressSelectedFeatureAutoSelectRef.current = true;
setSelectedFeatureIds([]);
setActiveEntityId(null);
setActiveWikiError(null);
setLinkEntityPopup(null);
setIsManualSidebarOpen(true);
}, [setSelectedFeatureIds]);
useEffect(() => {
if (!activeWikiSlug) {
setIsActiveWikiLoading(false);
setActiveWikiError(null);
setVisibleWiki(null);
return;
}
if (wikiLinkInFlightSlugRef.current === activeWikiSlug) {
return;
}
if (cachedWiki?.id === "__not_found__") {
setIsActiveWikiLoading(false);
setActiveWikiError("Không tìm thấy wiki cho entity đã chọn.");
return;
}
if (cachedWiki && cachedWiki.__fetched && hasWikiContent(cachedWiki)) {
setVisibleWiki(cachedWiki);
setIsActiveWikiLoading(false);
setActiveWikiError(null);
return;
}
if (cachedWiki && (cachedWiki.__fetched || cachedWiki.id === "__not_found__")) {
if (cachedWiki?.__fetched && fullWikiFetchAttemptedSlugRef.current.has(activeWikiSlug)) {
setIsActiveWikiLoading(false);
setActiveWikiError(cachedWiki.id === "__not_found__" ? "Không tìm thấy wiki cho entity đã chọn." : null);
setActiveWikiError(hasWikiContent(cachedWiki) ? null : "Wiki này chưa có nội dung.");
return;
}
@@ -441,26 +544,13 @@ export function usePublicPreviewInteraction(options: {
setIsActiveWikiLoading(true);
setActiveWikiError(null);
try {
const row = await fetchWikiBySlug(activeWikiSlug);
const row = await fetchFullWikiBySlug(activeWikiSlug);
if (disposed) return;
if (row) {
let versionContent = row.content;
try {
if (row.content_sample?.[0]?.id) {
const res = await getContentByVersionWikiId(row.content_sample[0].id);
if (res?.data?.content) versionContent = res.data.content;
}
} catch (err) {
console.error("Failed to fetch version content:", err);
}
if (disposed) return;
setWikiCache((prev) => ({
...prev,
[activeWikiSlug]: { ...row, content: versionContent, __fetched: true },
}));
publishWikiToPanel(row, activeWikiSlug);
} else {
fullWikiFetchAttemptedSlugRef.current.add(activeWikiSlug);
setWikiCache((prev) => ({
...prev,
[activeWikiSlug]: { id: "__not_found__", project_id: "" },
@@ -478,45 +568,66 @@ export function usePublicPreviewInteraction(options: {
return () => {
disposed = true;
};
}, [activeWikiSlug, cachedWiki]);
}, [activeWikiSlug, cachedWiki, fetchFullWikiBySlug, publishWikiToPanel]);
const handleWikiLinkRequest = useCallback(async ({ slug, rect }: { slug: string; rect: DOMRect }) => {
const linkedEntityIds = relations.wikiEntityIdsBySlug[slug] || [];
const linkedEntities = linkedEntityIds
.map((entityId) => relations.entitiesById[entityId] || null)
.filter((entity): entity is Entity => Boolean(entity));
const nextSlug = String(slug || "").trim();
if (!nextSlug) return;
if (linkedEntities.length === 1) {
selectEntity(linkedEntities[0].id, { preferredWikiSlug: slug });
const requestSeq = ++wikiLinkRequestSeqRef.current;
wikiLinkInFlightSlugRef.current = nextSlug;
prepareManualWikiNavigation();
const cachedWikiForSlug =
findWikiWithContentBySlug(wikiCache, nextSlug) ||
findWikiWithContentBySlug(relations.wikiBySlug, nextSlug) ||
null;
if (cachedWikiForSlug && cachedWikiForSlug.id !== "__not_found__" && hasWikiContent(cachedWikiForSlug)) {
wikiLinkInFlightSlugRef.current = null;
const fullWiki = publishWikiToPanel(cachedWikiForSlug, nextSlug);
focusWikiLinkAfterPaint(fullWiki);
return;
}
if (!wikiCache[slug] && !relations.wikiBySlug[slug]) {
try {
const row = await fetchWikiBySlug(slug);
if (row) setWikiCache((prev) => ({ ...prev, [slug]: row }));
} catch (err) {
console.error("Load wiki by slug failed", err);
}
setIsActiveWikiLoading(true);
let row: Wiki | null = null;
try {
row = await fetchFullWikiBySlug(nextSlug);
} catch (err) {
console.error("Load wiki by slug failed", err);
if (requestSeq !== wikiLinkRequestSeqRef.current) return;
if (wikiLinkInFlightSlugRef.current === nextSlug) wikiLinkInFlightSlugRef.current = null;
setActiveWikiError(err instanceof Error ? err.message : "Không tải được wiki.");
setIsActiveWikiLoading(false);
return;
}
if (!linkedEntities.length) return;
if (requestSeq !== wikiLinkRequestSeqRef.current) return;
if (wikiLinkInFlightSlugRef.current === nextSlug) wikiLinkInFlightSlugRef.current = null;
const popupWidth = 240;
const popupHeight = Math.min(240, linkedEntities.length * 44 + 20);
const { top, left } = computeFixedPopupPosition(rect, popupWidth, popupHeight);
if (!row) {
setActiveWikiError("Không tìm thấy wiki.");
setIsActiveWikiLoading(false);
return;
}
setLinkEntityPopup({
slug,
entities: linkedEntities,
top,
left,
});
}, [relations.entitiesById, relations.wikiBySlug, relations.wikiEntityIdsBySlug, selectEntity, wikiCache]);
const fullWiki = publishWikiToPanel(row, nextSlug);
focusWikiLinkAfterPaint(fullWiki);
}, [
fetchFullWikiBySlug,
focusWikiLinkAfterPaint,
prepareManualWikiNavigation,
publishWikiToPanel,
relations.wikiBySlug,
wikiCache,
]);
const closeWikiSidebar = useCallback(() => {
setActiveEntityId(null);
setActiveWikiSlug(null);
setVisibleWiki(null);
setActiveWikiError(null);
setLinkEntityPopup(null);
setSelectedFeatureIds([]);
@@ -526,6 +637,7 @@ export function usePublicPreviewInteraction(options: {
const closeWikiSidebarPreserveSelection = useCallback(() => {
setActiveEntityId(null);
setActiveWikiSlug(null);
setVisibleWiki(null);
setActiveWikiError(null);
setLinkEntityPopup(null);
setIsManualSidebarOpen(false);
@@ -555,6 +667,113 @@ async function fetchRelationWikisForEntity(entityId: string): Promise<Wiki[]> {
return rows[entityId] || [];
}
function extractWikiContentFromResponse(response: unknown): string {
if (typeof response === "string") return response;
if (!response || typeof response !== "object") return "";
const source = response as Record<string, unknown>;
if (typeof source.content === "string") return source.content;
const data = source.data;
if (data && typeof data === "object" && typeof (data as Record<string, unknown>).content === "string") {
return (data as Record<string, unknown>).content as string;
}
return "";
}
function hasWikiContent(wiki: Wiki | null | undefined): boolean {
return typeof wiki?.content === "string" && wiki.content.trim().length > 0;
}
function cacheWikiBySlug(
prev: Record<string, CachedWiki>,
wiki: CachedWiki,
requestedSlug?: string | null
): Record<string, CachedWiki> {
const next = { ...prev };
for (const key of wikiSlugCacheKeys(requestedSlug, wiki.slug)) {
next[key] = wiki;
}
return next;
}
function findWikiWithContentBySlug<T extends Wiki>(
source: Record<string, T>,
slug: string | null | undefined
): T | undefined {
const direct = findWikiBySlug(source, slug);
if (direct && hasWikiContent(direct)) return direct;
const targetKey = normalizeWikiSlugForCompare(slug);
if (!targetKey) return undefined;
for (const [key, wiki] of Object.entries(source)) {
if (!hasWikiContent(wiki)) continue;
const keyMatches = normalizeWikiSlugForCompare(key) === targetKey;
const slugMatches = normalizeWikiSlugForCompare(wiki.slug) === targetKey;
if (keyMatches || slugMatches) return wiki;
}
return undefined;
}
function findWikiBySlug<T extends Wiki>(
source: Record<string, T>,
slug: string | null | undefined
): T | undefined {
const keys = wikiSlugCacheKeys(slug);
for (const key of keys) {
const direct = source[key];
if (direct) return direct;
}
const targetKey = normalizeWikiSlugForCompare(slug);
if (!targetKey) return undefined;
for (const [key, wiki] of Object.entries(source)) {
const keyMatches = normalizeWikiSlugForCompare(key) === targetKey;
const slugMatches = normalizeWikiSlugForCompare(wiki.slug) === targetKey;
if (keyMatches || slugMatches) return wiki;
}
return undefined;
}
function wikiSlugCacheKeys(...values: Array<string | null | undefined>): string[] {
const keys = new Set<string>();
for (const value of values) {
const raw = String(value || "").trim();
if (!raw) continue;
keys.add(raw);
let decoded = raw;
try {
decoded = decodeURIComponent(raw);
} catch {
decoded = raw;
}
decoded = decoded.replace(/^\/+/, "").replace(/^wiki\//i, "").trim();
if (!decoded) continue;
keys.add(decoded);
keys.add(decoded.replace(/_/g, " "));
keys.add(decoded.replace(/\s+/g, "_"));
keys.add(normalizeWikiSlugForCompare(decoded));
}
return Array.from(keys).filter((key) => key.length > 0);
}
function normalizeWikiSlugForCompare(value: string | null | undefined): string {
let raw = String(value || "").trim();
if (!raw) return "";
try {
raw = decodeURIComponent(raw);
} catch {
// Keep the original value if it is not valid percent-encoded text.
}
return raw
.replace(/^\/+/, "")
.replace(/^wiki\//i, "")
.replace(/_/g, " ")
.replace(/\s+/g, " ")
.trim()
.toLocaleLowerCase("vi-VN");
}
function cleanPreviewQuoteText(content: string | null | undefined): string {
if (!content) return "";
+275 -30
View File
@@ -1,6 +1,7 @@
"use client";
import { useEffect, useMemo, useRef, useState, memo } from "react";
import { useEffect, useLayoutEffect, useMemo, useRef, useState, memo } from "react";
import { createPortal } from "react-dom";
// Loaded dynamically inside the component to prevent render-blocking
import type { Entity } from "@/uhm/api/entities";
@@ -19,6 +20,7 @@ type Props = {
error?: string | null;
onClose: () => void;
onWikiLinkRequest: (request: { slug: string; rect: DOMRect }) => void;
onWikiLinkEntitySelectionRequest?: (request: { slug: string; rect: DOMRect }) => void;
sidebarWidth?: number;
onSidebarWidthChange?: (width: number) => void;
maxDragWidth?: number;
@@ -73,6 +75,42 @@ function isExternalHref(href: string): boolean {
);
}
function extractWikiSlugFromHref(href: string): string {
const raw = String(href || "").trim();
if (!raw.length || raw === "__missing__") return "";
if (raw.startsWith("#wiki:")) return raw.slice("#wiki:".length).trim();
if (raw.startsWith("#")) return "";
const isAbsoluteUrl = /^[a-z][a-z\d+.-]*:/i.test(raw);
const baseOrigin = typeof window !== "undefined" ? window.location.origin : "http://localhost";
if (isAbsoluteUrl) {
try {
const url = new URL(raw, baseOrigin);
if (typeof window !== "undefined" && url.origin !== window.location.origin) return "";
const path = url.pathname.replace(/\/+$/, "");
if (!path.startsWith("/wiki/")) return "";
return decodeWikiSlug(path.slice("/wiki/".length));
} catch {
return "";
}
}
const match = raw.match(/^([^?#]+)([?#].*)?$/);
let slug = String(match?.[1] || "").replace(/^\/+/, "").replace(/\/+$/, "").trim();
if (slug.startsWith("wiki/")) {
slug = slug.slice("wiki/".length).trim();
}
return decodeWikiSlug(slug);
}
function decodeWikiSlug(slug: string): string {
try {
return decodeURIComponent(slug).trim();
} catch {
return slug.trim();
}
}
function prepareWikiHtml(inputHtml: string): { html: string; toc: TocItem[] } {
const parser = new DOMParser();
const doc = parser.parseFromString(inputHtml, "text/html");
@@ -83,21 +121,18 @@ function prepareWikiHtml(inputHtml: string): { html: string; toc: TocItem[] } {
const href = String(a.getAttribute("href") || "").trim();
if (!href.length) continue;
if (href === "__missing__") continue;
if (href.startsWith("#")) continue;
if (href.startsWith("/")) continue;
const slugPart = extractWikiSlugFromHref(href);
if (slugPart.length) {
a.setAttribute("href", `#wiki:${slugPart}`);
a.setAttribute("data-wiki-slug", slugPart);
a.setAttribute("target", "_self");
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[] = [];
@@ -133,6 +168,7 @@ function PublicWikiSidebar({
error,
onClose,
onWikiLinkRequest,
onWikiLinkEntitySelectionRequest,
sidebarWidth,
onSidebarWidthChange,
maxDragWidth,
@@ -142,6 +178,12 @@ function PublicWikiSidebar({
}: Props) {
const contentRootRef = useRef<HTMLDivElement | null>(null);
const tocContainerRef = useRef<HTMLDivElement | null>(null);
const [wikiLinkMenu, setWikiLinkMenu] = useState<{
slug: string;
rect: DOMRect;
top: number;
left: number;
} | null>(null);
useEffect(() => {
import("react-quill-new/dist/quill.snow.css");
@@ -225,6 +267,15 @@ function PublicWikiSidebar({
? activeHeadingId
: (toc[0]?.id ?? null);
useLayoutEffect(() => {
const firstHeadingId = toc[0]?.id ?? null;
setActiveHeadingId(firstHeadingId);
const scrollContainer = contentRootRef.current?.parentElement;
scrollContainer?.scrollTo({ top: 0, behavior: "auto" });
tocContainerRef.current?.scrollTo({ left: 0, behavior: "auto" });
}, [wiki?.id, wiki?.slug, renderHtml, toc]);
useEffect(() => {
if (!toc.length) return;
const root = contentRootRef.current;
@@ -236,19 +287,33 @@ function PublicWikiSidebar({
if (!headings.length) return;
const scrollContainer = root.parentElement;
const updateActiveHeading = () => {
const containerRect = scrollContainer?.getBoundingClientRect();
const topBoundary = (containerRect?.top ?? 0) + (containerRect?.height ?? window.innerHeight) * 0.18;
const bottomBoundary = (containerRect?.top ?? 0) + (containerRect?.height ?? window.innerHeight) * 0.82;
const visibleHeadings = headings
.map((heading) => ({ heading, rect: heading.getBoundingClientRect() }))
.filter(({ rect }) => rect.bottom >= topBoundary && rect.top <= bottomBoundary)
.sort((a, b) => {
const aDistance = Math.abs(a.rect.top - topBoundary);
const bDistance = Math.abs(b.rect.top - topBoundary);
return aDistance - bDistance;
});
const nextHeading = visibleHeadings[0]?.heading || headings[0];
if (nextHeading?.id) setActiveHeadingId(nextHeading.id);
};
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);
},
updateActiveHeading,
{ root: scrollContainer || null, rootMargin: "-18% 0px -70% 0px", threshold: [0, 1] }
);
for (const heading of headings) observer.observe(heading);
return () => observer.disconnect();
scrollContainer?.addEventListener("scroll", updateActiveHeading, { passive: true });
return () => {
observer.disconnect();
scrollContainer?.removeEventListener("scroll", updateActiveHeading);
};
}, [toc]);
useEffect(() => {
@@ -275,18 +340,85 @@ function PublicWikiSidebar({
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 fallbackLink = target?.closest?.("a[href]") as HTMLAnchorElement | null;
const sourceLink = link || fallbackLink;
if (!sourceLink) return;
const slug = String(link.getAttribute("data-wiki-slug") || "").trim();
const slug = String(
sourceLink.getAttribute("data-wiki-slug") ||
extractWikiSlugFromHref(sourceLink.getAttribute("href") || "")
).trim();
if (!slug.length) return;
onWikiLinkRequest({ slug, rect: link.getBoundingClientRect() });
event.preventDefault();
onWikiLinkRequest({ slug, rect: sourceLink.getBoundingClientRect() });
};
root.addEventListener("click", handleClick);
return () => root.removeEventListener("click", handleClick);
}, [onWikiLinkRequest, renderHtml]);
useEffect(() => {
const root = contentRootRef.current;
if (!root) return;
const handleContextMenu = (event: MouseEvent) => {
const target = event.target as HTMLElement | null;
const link = target?.closest?.("a[data-wiki-slug]") as HTMLAnchorElement | null;
const fallbackLink = target?.closest?.("a[href]") as HTMLAnchorElement | null;
const sourceLink = link || fallbackLink;
if (!sourceLink) return;
const slug = String(
sourceLink.getAttribute("data-wiki-slug") ||
extractWikiSlugFromHref(sourceLink.getAttribute("href") || "")
).trim();
if (!slug.length) return;
event.preventDefault();
setWikiLinkMenu({
slug,
rect: sourceLink.getBoundingClientRect(),
...computeContextMenuPosition(event.clientX, event.clientY, 220, 88),
});
};
root.addEventListener("contextmenu", handleContextMenu, true);
return () => root.removeEventListener("contextmenu", handleContextMenu, true);
}, [renderHtml]);
useEffect(() => {
if (!wikiLinkMenu) return;
const handlePointerDown = (event: PointerEvent) => {
const target = event.target as HTMLElement | null;
if (target?.closest?.("[data-wiki-link-context-menu='true']")) return;
setWikiLinkMenu(null);
};
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === "Escape") setWikiLinkMenu(null);
};
const closeMenu = () => setWikiLinkMenu(null);
window.addEventListener("pointerdown", handlePointerDown);
window.addEventListener("keydown", handleKeyDown);
window.addEventListener("resize", closeMenu);
window.addEventListener("scroll", closeMenu, true);
return () => {
window.removeEventListener("pointerdown", handlePointerDown);
window.removeEventListener("keydown", handleKeyDown);
window.removeEventListener("resize", closeMenu);
window.removeEventListener("scroll", closeMenu, true);
};
}, [wikiLinkMenu]);
const handleOpenStandaloneWiki = (slug: string) => {
if (typeof window === "undefined") return;
const url = `/wiki/${encodeURIComponent(slug)}`;
const nextWindow = window.open(url, "_blank", "noopener,noreferrer");
if (nextWindow) nextWindow.opener = null;
};
const isExpanded = useMemo(() => {
if (typeof window === "undefined") return false;
const fullHeight = Math.round(window.innerHeight * 0.70);
@@ -546,7 +678,7 @@ function PublicWikiSidebar({
overflowY: "auto",
}}
>
{isLoading ? (
{isLoading && !wiki ? (
<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)" }}
@@ -566,12 +698,38 @@ function PublicWikiSidebar({
{error}
</div>
) : wiki ? (
<div
ref={contentRootRef}
className="uhm-wiki-sidebar-view ql-editor"
style={{ fontSize: 14, color: "#cbd5e1" }}
dangerouslySetInnerHTML={{ __html: renderHtml }}
/>
<div style={{ position: "relative", minHeight: "100%" }}>
{isLoading ? (
<div
aria-hidden="true"
style={{
position: "sticky",
top: 0,
left: 0,
right: 0,
height: 2,
zIndex: 2,
overflow: "hidden",
background: "rgba(56, 189, 248, 0.08)",
}}
>
<div
className="uhm-wiki-sidebar-loading-bar"
style={{
height: "100%",
width: "42%",
background: "linear-gradient(90deg, transparent, #38bdf8, transparent)",
}}
/>
</div>
) : null}
<div
ref={contentRootRef}
className="uhm-wiki-sidebar-view ql-editor"
style={{ fontSize: 14, color: "#cbd5e1" }}
dangerouslySetInnerHTML={{ __html: renderHtml }}
/>
</div>
) : (
<div style={{ padding: 16, fontSize: 14, color: "#94a3b8" }}>
Entity này chưa wiki liên kết.
@@ -579,6 +737,57 @@ function PublicWikiSidebar({
)}
</div>
{wikiLinkMenu && typeof document !== "undefined"
? createPortal(
<div
data-wiki-link-context-menu="true"
style={{
position: "fixed",
top: wikiLinkMenu.top,
left: wikiLinkMenu.left,
zIndex: 100000,
width: 220,
overflow: "hidden",
borderRadius: 10,
border: "1px solid rgba(148, 163, 184, 0.28)",
background: "rgba(15, 23, 42, 0.98)",
boxShadow: "0 18px 42px rgba(2, 6, 23, 0.48)",
color: "#e2e8f0",
padding: 6,
}}
>
<button
type="button"
onClick={() => {
handleOpenStandaloneWiki(wikiLinkMenu.slug);
setWikiLinkMenu(null);
}}
style={wikiLinkMenuButtonStyle}
className="hover:bg-sky-500/15 hover:text-sky-100"
>
Mở wiki riêng
</button>
<button
type="button"
onClick={() => {
const request = { slug: wikiLinkMenu.slug, rect: wikiLinkMenu.rect };
if (onWikiLinkEntitySelectionRequest) {
onWikiLinkEntitySelectionRequest(request);
} else {
onWikiLinkRequest(request);
}
setWikiLinkMenu(null);
}}
style={wikiLinkMenuButtonStyle}
className="hover:bg-sky-500/15 hover:text-sky-100"
>
Mở bảng chọn entity
</button>
</div>,
document.body
)
: null}
<style jsx global>{`
.uhm-public-wiki-sidebar-content::-webkit-scrollbar {
width: 6px;
@@ -606,6 +815,17 @@ function PublicWikiSidebar({
.uhm-public-wiki-toc-list::-webkit-scrollbar-thumb:hover {
background: rgba(148, 163, 184, 0.4);
}
@keyframes uhm-wiki-sidebar-loading-bar {
from {
transform: translateX(-120%);
}
to {
transform: translateX(260%);
}
}
.uhm-wiki-sidebar-loading-bar {
animation: uhm-wiki-sidebar-loading-bar 1.1s ease-in-out infinite;
}
.uhm-wiki-sidebar-view.ql-editor {
height: auto;
overflow-y: visible;
@@ -726,4 +946,29 @@ function PublicWikiSidebar({
);
}
const wikiLinkMenuButtonStyle = {
display: "block",
width: "100%",
border: 0,
borderRadius: 7,
background: "transparent",
padding: "9px 10px",
color: "inherit",
cursor: "pointer",
fontSize: 13,
fontWeight: 700,
lineHeight: "18px",
textAlign: "left" as const,
};
function computeContextMenuPosition(clientX: number, clientY: number, width: number, height: number) {
const margin = 8;
const viewportWidth = typeof window !== "undefined" ? window.innerWidth : 1440;
const viewportHeight = typeof window !== "undefined" ? window.innerHeight : 900;
return {
left: Math.max(margin, Math.min(clientX, viewportWidth - width - margin)),
top: Math.max(margin, Math.min(clientY, viewportHeight - height - margin)),
};
}
export default memo(PublicWikiSidebar);
+4 -2
View File
@@ -214,8 +214,10 @@ export function useReplayPreview({
const stopPreview = useCallback(() => {
runIdRef.current += 1;
restorePreviewState();
}, [restorePreviewState]);
isPlayingRef.current = false;
setIsPlaying(false);
getMapInstance()?.stop();
}, [getMapInstance]);
useEffect(() => {
runIdRef.current += 1;