refactor UI recusion for wiki
This commit is contained in:
@@ -2,6 +2,7 @@ import { API_ENDPOINTS } from "@/uhm/api/config";
|
|||||||
import { requestJson } from "@/uhm/api/http";
|
import { requestJson } from "@/uhm/api/http";
|
||||||
import type { Entity } from "@/uhm/api/entities";
|
import type { Entity } from "@/uhm/api/entities";
|
||||||
import type { Wiki } from "@/uhm/api/wikis";
|
import type { Wiki } from "@/uhm/api/wikis";
|
||||||
|
import type { Geometry } from "@/uhm/types/geo";
|
||||||
|
|
||||||
const RELATION_BATCH_SIZE = 10;
|
const RELATION_BATCH_SIZE = 10;
|
||||||
const RELATION_BATCH_CONCURRENCY = 4;
|
const RELATION_BATCH_CONCURRENCY = 4;
|
||||||
@@ -12,8 +13,42 @@ export type WikiContentPreview = {
|
|||||||
created_at?: string | null;
|
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[]>> = {};
|
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[]>> {
|
export async function fetchEntitiesByGeometryIds(ids: string[]): Promise<Record<string, Entity[]>> {
|
||||||
const uniqueIds = uniqueStrings(ids);
|
const uniqueIds = uniqueStrings(ids);
|
||||||
const missingIds = uniqueIds.filter(id => !entitiesPromiseCache[id]);
|
const missingIds = uniqueIds.filter(id => !entitiesPromiseCache[id]);
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ type Props = {
|
|||||||
activeStepLabel: string | null;
|
activeStepLabel: string | null;
|
||||||
activeStepNumber: number | null;
|
activeStepNumber: number | null;
|
||||||
totalSteps: number;
|
totalSteps: number;
|
||||||
|
playButtonLabel?: string;
|
||||||
onPlayPreview: () => void;
|
onPlayPreview: () => void;
|
||||||
onStopPreview: () => void;
|
onStopPreview: () => void;
|
||||||
onResetPreview: () => void;
|
onResetPreview: () => void;
|
||||||
@@ -32,6 +33,7 @@ export default function ReplayPreviewOverlay({
|
|||||||
activeStepLabel,
|
activeStepLabel,
|
||||||
activeStepNumber,
|
activeStepNumber,
|
||||||
totalSteps,
|
totalSteps,
|
||||||
|
playButtonLabel = "Phát lại",
|
||||||
onPlayPreview,
|
onPlayPreview,
|
||||||
onStopPreview,
|
onStopPreview,
|
||||||
onResetPreview,
|
onResetPreview,
|
||||||
@@ -279,7 +281,7 @@ export default function ReplayPreviewOverlay({
|
|||||||
onClick={onPlayPreview}
|
onClick={onPlayPreview}
|
||||||
style={previewButtonStyle("#166534")}
|
style={previewButtonStyle("#166534")}
|
||||||
>
|
>
|
||||||
Phát lại
|
{playButtonLabel}
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
<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 có 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(" / ");
|
||||||
|
}
|
||||||
@@ -527,34 +527,28 @@ const PreviewLayout = forwardRef<PreviewLayoutHandle, Props>(({
|
|||||||
}
|
}
|
||||||
|
|
||||||
const linkedEntityIds = previewRelations.wikiEntityIdsBySlug[nextSlug] || [];
|
const linkedEntityIds = previewRelations.wikiEntityIdsBySlug[nextSlug] || [];
|
||||||
const linkedEntities = linkedEntityIds
|
const firstEntityId = linkedEntityIds[0] || null;
|
||||||
.map((entityId) => previewRelations.entitiesById[entityId] || null)
|
if (!firstEntityId) return;
|
||||||
.filter((entity): entity is Entity => Boolean(entity));
|
|
||||||
|
|
||||||
if (linkedEntities.length === 1) {
|
setPreviewActiveEntityId(firstEntityId);
|
||||||
selectReplayPreviewEntity(linkedEntities[0].id, {
|
setIsPreviewEntitySidebarOpen(true);
|
||||||
preferredWikiId: localWiki.id,
|
setPreviewRightPanelMode("wiki");
|
||||||
focusMap: false,
|
setPreviewWikiSelectionPanelAnchor(null);
|
||||||
});
|
setPreviewWikiError(null);
|
||||||
return;
|
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,
|
previewRelations.wikiEntityIdsBySlug,
|
||||||
selectReplayPreviewEntity,
|
setPreviewActiveEntityId,
|
||||||
wikis,
|
wikis,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
|||||||
@@ -45,6 +45,7 @@ type Props = {
|
|||||||
wikiError?: string | null;
|
wikiError?: string | null;
|
||||||
onCloseWikiSidebar?: () => void;
|
onCloseWikiSidebar?: () => void;
|
||||||
onWikiLinkRequest?: (request: { slug: string; rect: DOMRect }) => void;
|
onWikiLinkRequest?: (request: { slug: string; rect: DOMRect }) => void;
|
||||||
|
onWikiLinkEntitySelectionRequest?: (request: { slug: string; rect: DOMRect }) => void;
|
||||||
sidebarWidth?: number;
|
sidebarWidth?: number;
|
||||||
onSidebarWidthChange?: (width: number) => void;
|
onSidebarWidthChange?: (width: number) => void;
|
||||||
maxSidebarDragWidth?: number;
|
maxSidebarDragWidth?: number;
|
||||||
@@ -59,6 +60,7 @@ type Props = {
|
|||||||
onLayerPanelVisibleChange?: (visible: boolean) => void;
|
onLayerPanelVisibleChange?: (visible: boolean) => void;
|
||||||
sidebarHeight?: number;
|
sidebarHeight?: number;
|
||||||
onSidebarHeightChange?: (height: number) => void;
|
onSidebarHeightChange?: (height: number) => void;
|
||||||
|
showViewportControls?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function PreviewMapShell({
|
export default function PreviewMapShell({
|
||||||
@@ -91,6 +93,7 @@ export default function PreviewMapShell({
|
|||||||
wikiError = null,
|
wikiError = null,
|
||||||
onCloseWikiSidebar,
|
onCloseWikiSidebar,
|
||||||
onWikiLinkRequest,
|
onWikiLinkRequest,
|
||||||
|
onWikiLinkEntitySelectionRequest,
|
||||||
sidebarWidth,
|
sidebarWidth,
|
||||||
onSidebarWidthChange,
|
onSidebarWidthChange,
|
||||||
maxSidebarDragWidth,
|
maxSidebarDragWidth,
|
||||||
@@ -105,6 +108,7 @@ export default function PreviewMapShell({
|
|||||||
onLayerPanelVisibleChange,
|
onLayerPanelVisibleChange,
|
||||||
sidebarHeight,
|
sidebarHeight,
|
||||||
onSidebarHeightChange,
|
onSidebarHeightChange,
|
||||||
|
showViewportControls = true,
|
||||||
}: Props) {
|
}: Props) {
|
||||||
const [isMenuOpen, setIsMenuOpen] = useState(false);
|
const [isMenuOpen, setIsMenuOpen] = useState(false);
|
||||||
const [avatarUrl, setAvatarUrl] = useState<string | null>(null);
|
const [avatarUrl, setAvatarUrl] = useState<string | null>(null);
|
||||||
@@ -133,6 +137,8 @@ export default function PreviewMapShell({
|
|||||||
fetchUserAvatar();
|
fetchUserAvatar();
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
const hasWikiSidebar = Boolean(activeEntity || activeWiki || isWikiLoading || wikiError);
|
||||||
|
|
||||||
const menuOptionStyle: CSSProperties = {
|
const menuOptionStyle: CSSProperties = {
|
||||||
width: 46,
|
width: 46,
|
||||||
height: 46,
|
height: 46,
|
||||||
@@ -172,7 +178,7 @@ export default function PreviewMapShell({
|
|||||||
onHoverFeatureChange={onHoverFeatureChange}
|
onHoverFeatureChange={onHoverFeatureChange}
|
||||||
onPlayPreviewReplay={onPlayPreviewReplay}
|
onPlayPreviewReplay={onPlayPreviewReplay}
|
||||||
onLoad={onLoad}
|
onLoad={onLoad}
|
||||||
showViewportControls={!isMobileOrTablet}
|
showViewportControls={showViewportControls && !isMobileOrTablet}
|
||||||
height="100svh"
|
height="100svh"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
@@ -207,7 +213,7 @@ export default function PreviewMapShell({
|
|||||||
style={{
|
style={{
|
||||||
position: "absolute",
|
position: "absolute",
|
||||||
top: 10,
|
top: 10,
|
||||||
bottom: ((activeEntity || activeWiki) && isMobileOrTablet) ? `${(sidebarHeight || 400) + 20}px` : 20,
|
bottom: (hasWikiSidebar && isMobileOrTablet) ? `${(sidebarHeight || 400) + 20}px` : 20,
|
||||||
left: 18,
|
left: 18,
|
||||||
zIndex: 18,
|
zIndex: 18,
|
||||||
display: "flex",
|
display: "flex",
|
||||||
@@ -412,7 +418,7 @@ export default function PreviewMapShell({
|
|||||||
|
|
||||||
{overlay}
|
{overlay}
|
||||||
|
|
||||||
{activeEntity || activeWiki ? (
|
{hasWikiSidebar ? (
|
||||||
<aside
|
<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)]"}
|
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 ? {
|
style={isMobileOrTablet ? {
|
||||||
@@ -438,6 +444,7 @@ export default function PreviewMapShell({
|
|||||||
error={wikiError}
|
error={wikiError}
|
||||||
onClose={onCloseWikiSidebar || (() => {})}
|
onClose={onCloseWikiSidebar || (() => {})}
|
||||||
onWikiLinkRequest={onWikiLinkRequest || (() => {})}
|
onWikiLinkRequest={onWikiLinkRequest || (() => {})}
|
||||||
|
onWikiLinkEntitySelectionRequest={onWikiLinkEntitySelectionRequest}
|
||||||
sidebarWidth={sidebarWidth}
|
sidebarWidth={sidebarWidth}
|
||||||
onSidebarWidthChange={onSidebarWidthChange}
|
onSidebarWidthChange={onSidebarWidthChange}
|
||||||
maxDragWidth={maxSidebarDragWidth}
|
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)[]>>;
|
setSelectedFeatureIds: React.Dispatch<React.SetStateAction<(string | number)[]>>;
|
||||||
timelineYear?: number | null;
|
timelineYear?: number | null;
|
||||||
replayActiveWikiId?: string | 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 [activeEntityId, setActiveEntityId] = useState<string | null>(null);
|
||||||
const [activeWikiSlug, setActiveWikiSlug] = useState<string | null>(null);
|
const [activeWikiSlug, setActiveWikiSlug] = useState<string | null>(null);
|
||||||
const [isManualSidebarOpen, setIsManualSidebarOpen] = useState(false);
|
const [isManualSidebarOpen, setIsManualSidebarOpen] = useState(false);
|
||||||
@@ -50,15 +51,20 @@ export function usePublicPreviewInteraction(options: {
|
|||||||
setIsManualSidebarOpen(false);
|
setIsManualSidebarOpen(false);
|
||||||
}, [replayMode]);
|
}, [replayMode]);
|
||||||
const [wikiCache, setWikiCache] = useState<Record<string, CachedWiki>>({});
|
const [wikiCache, setWikiCache] = useState<Record<string, CachedWiki>>({});
|
||||||
|
const [visibleWiki, setVisibleWiki] = useState<CachedWiki | null>(null);
|
||||||
const [hoverWikiPreviewByEntityId, setHoverWikiPreviewByEntityId] = useState<Record<string, HoverWikiPreview>>({});
|
const [hoverWikiPreviewByEntityId, setHoverWikiPreviewByEntityId] = useState<Record<string, HoverWikiPreview>>({});
|
||||||
const [isActiveWikiLoading, setIsActiveWikiLoading] = useState(false);
|
const [isActiveWikiLoading, setIsActiveWikiLoading] = useState(false);
|
||||||
const [activeWikiError, setActiveWikiError] = useState<string | null>(null);
|
const [activeWikiError, setActiveWikiError] = useState<string | null>(null);
|
||||||
const [linkEntityPopup, setLinkEntityPopup] = useState<LinkEntityPopupState | null>(null);
|
const [linkEntityPopup, setLinkEntityPopup] = useState<LinkEntityPopupState | null>(null);
|
||||||
const linkEntityPopupRef = useRef<HTMLDivElement | null>(null);
|
const linkEntityPopupRef = useRef<HTMLDivElement | null>(null);
|
||||||
const hoverWikiPreviewRequestsRef = useRef<Set<string>>(new Set());
|
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(() => {
|
useEffect(() => {
|
||||||
if (replayMode === "playing" && replayActiveWikiId) {
|
if (replayMode !== "idle" && replayActiveWikiId) {
|
||||||
const activeWikiEntityIds = relations.wikiEntityIdsById[String(replayActiveWikiId)] || [];
|
const activeWikiEntityIds = relations.wikiEntityIdsById[String(replayActiveWikiId)] || [];
|
||||||
const entityId = activeWikiEntityIds[0] || null;
|
const entityId = activeWikiEntityIds[0] || null;
|
||||||
const wikiSlug = relations.wikiById[String(replayActiveWikiId)]?.slug || 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 activeEntity = activeEntityId ? relations.entitiesById[activeEntityId] || null : null;
|
||||||
const activeWiki = useMemo(() => {
|
const activeWiki = useMemo(() => {
|
||||||
|
if (visibleWiki) return visibleWiki;
|
||||||
if (!activeWikiSlug) return null;
|
if (!activeWikiSlug) return null;
|
||||||
return wikiCache[activeWikiSlug] || relations.wikiBySlug[activeWikiSlug] || null;
|
const cachedWiki = findWikiBySlug(wikiCache, activeWikiSlug) || null;
|
||||||
}, [activeWikiSlug, relations.wikiBySlug, wikiCache]);
|
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 (
|
const selectEntity = useCallback(async (
|
||||||
entityId: string,
|
entityId: string,
|
||||||
@@ -172,15 +184,17 @@ export function usePublicPreviewInteraction(options: {
|
|||||||
: "") ||
|
: "") ||
|
||||||
firstWikiSlug(linkedWikis);
|
firstWikiSlug(linkedWikis);
|
||||||
|
|
||||||
|
const cachedFullWiki = nextWikiSlug ? findWikiWithContentBySlug(wikiCache, nextWikiSlug) || null : null;
|
||||||
setActiveEntityId(entityId);
|
setActiveEntityId(entityId);
|
||||||
setActiveWikiSlug(nextWikiSlug);
|
setActiveWikiSlug(nextWikiSlug);
|
||||||
|
setVisibleWiki(cachedFullWiki);
|
||||||
setActiveWikiError(null);
|
setActiveWikiError(null);
|
||||||
setLinkEntityPopup(null);
|
setLinkEntityPopup(null);
|
||||||
setIsManualSidebarOpen(true);
|
setIsManualSidebarOpen(true);
|
||||||
if (selectOptions?.selectGeometry && selectOptions?.sourceFeatureId != null) {
|
if (selectOptions?.selectGeometry && selectOptions?.sourceFeatureId != null) {
|
||||||
setSelectedFeatureIds([selectOptions.sourceFeatureId]);
|
setSelectedFeatureIds([selectOptions.sourceFeatureId]);
|
||||||
}
|
}
|
||||||
}, [relations.entitiesById, relations.entityWikisById, setRelations, setSelectedFeatureIds]);
|
}, [relations.entitiesById, relations.entityWikisById, setRelations, setSelectedFeatureIds, wikiCache]);
|
||||||
|
|
||||||
const selectWiki = useCallback(async (
|
const selectWiki = useCallback(async (
|
||||||
wiki: Wiki
|
wiki: Wiki
|
||||||
@@ -195,23 +209,30 @@ export function usePublicPreviewInteraction(options: {
|
|||||||
|
|
||||||
if (wiki.slug) {
|
if (wiki.slug) {
|
||||||
const slug = wiki.slug;
|
const slug = wiki.slug;
|
||||||
|
const cachedFullWiki = findWikiWithContentBySlug(wikiCache, slug) || null;
|
||||||
setWikiCache((prev) => ({
|
setWikiCache((prev) => ({
|
||||||
...prev,
|
...prev,
|
||||||
[slug]: {
|
[slug]: cachedFullWiki || {
|
||||||
...wiki,
|
...wiki,
|
||||||
__fetched: false,
|
__fetched: false,
|
||||||
},
|
},
|
||||||
}));
|
}));
|
||||||
setActiveWikiSlug(slug);
|
setActiveWikiSlug(slug);
|
||||||
|
setVisibleWiki(cachedFullWiki);
|
||||||
}
|
}
|
||||||
setActiveEntityId(null);
|
setActiveEntityId(null);
|
||||||
setActiveWikiError(null);
|
setActiveWikiError(null);
|
||||||
setLinkEntityPopup(null);
|
setLinkEntityPopup(null);
|
||||||
setIsManualSidebarOpen(true);
|
setIsManualSidebarOpen(true);
|
||||||
}, [relations.wikiEntityIdsById, selectEntity]);
|
}, [relations.wikiEntityIdsById, selectEntity, wikiCache]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!selectedFeatureIds.length) return;
|
if (!selectedFeatureIds.length) {
|
||||||
|
suppressSelectedFeatureAutoSelectRef.current = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (suppressSelectedFeatureAutoSelectRef.current) return;
|
||||||
|
|
||||||
const linkedEntityIds = relations.geometryEntityIds[String(selectedFeatureIds[0])] || [];
|
const linkedEntityIds = relations.geometryEntityIds[String(selectedFeatureIds[0])] || [];
|
||||||
if (linkedEntityIds.length !== 1) return;
|
if (linkedEntityIds.length !== 1) return;
|
||||||
|
|
||||||
@@ -422,17 +443,99 @@ export function usePublicPreviewInteraction(options: {
|
|||||||
};
|
};
|
||||||
}, [activeEntityId, activeWikiSlug, relations.entityWikisById, setRelations]);
|
}, [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(() => {
|
useEffect(() => {
|
||||||
if (!activeWikiSlug) {
|
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);
|
setIsActiveWikiLoading(false);
|
||||||
setActiveWikiError(null);
|
setActiveWikiError(null);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (cachedWiki && (cachedWiki.__fetched || cachedWiki.id === "__not_found__")) {
|
if (cachedWiki?.__fetched && fullWikiFetchAttemptedSlugRef.current.has(activeWikiSlug)) {
|
||||||
setIsActiveWikiLoading(false);
|
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;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -441,26 +544,13 @@ export function usePublicPreviewInteraction(options: {
|
|||||||
setIsActiveWikiLoading(true);
|
setIsActiveWikiLoading(true);
|
||||||
setActiveWikiError(null);
|
setActiveWikiError(null);
|
||||||
try {
|
try {
|
||||||
const row = await fetchWikiBySlug(activeWikiSlug);
|
const row = await fetchFullWikiBySlug(activeWikiSlug);
|
||||||
if (disposed) return;
|
if (disposed) return;
|
||||||
|
|
||||||
if (row) {
|
if (row) {
|
||||||
let versionContent = row.content;
|
publishWikiToPanel(row, activeWikiSlug);
|
||||||
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 },
|
|
||||||
}));
|
|
||||||
} else {
|
} else {
|
||||||
|
fullWikiFetchAttemptedSlugRef.current.add(activeWikiSlug);
|
||||||
setWikiCache((prev) => ({
|
setWikiCache((prev) => ({
|
||||||
...prev,
|
...prev,
|
||||||
[activeWikiSlug]: { id: "__not_found__", project_id: "" },
|
[activeWikiSlug]: { id: "__not_found__", project_id: "" },
|
||||||
@@ -478,45 +568,66 @@ export function usePublicPreviewInteraction(options: {
|
|||||||
return () => {
|
return () => {
|
||||||
disposed = true;
|
disposed = true;
|
||||||
};
|
};
|
||||||
}, [activeWikiSlug, cachedWiki]);
|
}, [activeWikiSlug, cachedWiki, fetchFullWikiBySlug, publishWikiToPanel]);
|
||||||
|
|
||||||
const handleWikiLinkRequest = useCallback(async ({ slug, rect }: { slug: string; rect: DOMRect }) => {
|
const handleWikiLinkRequest = useCallback(async ({ slug, rect }: { slug: string; rect: DOMRect }) => {
|
||||||
const linkedEntityIds = relations.wikiEntityIdsBySlug[slug] || [];
|
const nextSlug = String(slug || "").trim();
|
||||||
const linkedEntities = linkedEntityIds
|
if (!nextSlug) return;
|
||||||
.map((entityId) => relations.entitiesById[entityId] || null)
|
|
||||||
.filter((entity): entity is Entity => Boolean(entity));
|
|
||||||
|
|
||||||
if (linkedEntities.length === 1) {
|
const requestSeq = ++wikiLinkRequestSeqRef.current;
|
||||||
selectEntity(linkedEntities[0].id, { preferredWikiSlug: slug });
|
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;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!wikiCache[slug] && !relations.wikiBySlug[slug]) {
|
setIsActiveWikiLoading(true);
|
||||||
|
|
||||||
|
let row: Wiki | null = null;
|
||||||
try {
|
try {
|
||||||
const row = await fetchWikiBySlug(slug);
|
row = await fetchFullWikiBySlug(nextSlug);
|
||||||
if (row) setWikiCache((prev) => ({ ...prev, [slug]: row }));
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error("Load wiki by slug failed", 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;
|
if (!row) {
|
||||||
const popupHeight = Math.min(240, linkedEntities.length * 44 + 20);
|
setActiveWikiError("Không tìm thấy wiki.");
|
||||||
const { top, left } = computeFixedPopupPosition(rect, popupWidth, popupHeight);
|
setIsActiveWikiLoading(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
setLinkEntityPopup({
|
const fullWiki = publishWikiToPanel(row, nextSlug);
|
||||||
slug,
|
focusWikiLinkAfterPaint(fullWiki);
|
||||||
entities: linkedEntities,
|
}, [
|
||||||
top,
|
fetchFullWikiBySlug,
|
||||||
left,
|
focusWikiLinkAfterPaint,
|
||||||
});
|
prepareManualWikiNavigation,
|
||||||
}, [relations.entitiesById, relations.wikiBySlug, relations.wikiEntityIdsBySlug, selectEntity, wikiCache]);
|
publishWikiToPanel,
|
||||||
|
relations.wikiBySlug,
|
||||||
|
wikiCache,
|
||||||
|
]);
|
||||||
|
|
||||||
const closeWikiSidebar = useCallback(() => {
|
const closeWikiSidebar = useCallback(() => {
|
||||||
setActiveEntityId(null);
|
setActiveEntityId(null);
|
||||||
setActiveWikiSlug(null);
|
setActiveWikiSlug(null);
|
||||||
|
setVisibleWiki(null);
|
||||||
setActiveWikiError(null);
|
setActiveWikiError(null);
|
||||||
setLinkEntityPopup(null);
|
setLinkEntityPopup(null);
|
||||||
setSelectedFeatureIds([]);
|
setSelectedFeatureIds([]);
|
||||||
@@ -526,6 +637,7 @@ export function usePublicPreviewInteraction(options: {
|
|||||||
const closeWikiSidebarPreserveSelection = useCallback(() => {
|
const closeWikiSidebarPreserveSelection = useCallback(() => {
|
||||||
setActiveEntityId(null);
|
setActiveEntityId(null);
|
||||||
setActiveWikiSlug(null);
|
setActiveWikiSlug(null);
|
||||||
|
setVisibleWiki(null);
|
||||||
setActiveWikiError(null);
|
setActiveWikiError(null);
|
||||||
setLinkEntityPopup(null);
|
setLinkEntityPopup(null);
|
||||||
setIsManualSidebarOpen(false);
|
setIsManualSidebarOpen(false);
|
||||||
@@ -555,6 +667,113 @@ async function fetchRelationWikisForEntity(entityId: string): Promise<Wiki[]> {
|
|||||||
return rows[entityId] || [];
|
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 {
|
function cleanPreviewQuoteText(content: string | null | undefined): string {
|
||||||
if (!content) return "";
|
if (!content) return "";
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
"use client";
|
"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
|
// Loaded dynamically inside the component to prevent render-blocking
|
||||||
|
|
||||||
import type { Entity } from "@/uhm/api/entities";
|
import type { Entity } from "@/uhm/api/entities";
|
||||||
@@ -19,6 +20,7 @@ type Props = {
|
|||||||
error?: string | null;
|
error?: string | null;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
onWikiLinkRequest: (request: { slug: string; rect: DOMRect }) => void;
|
onWikiLinkRequest: (request: { slug: string; rect: DOMRect }) => void;
|
||||||
|
onWikiLinkEntitySelectionRequest?: (request: { slug: string; rect: DOMRect }) => void;
|
||||||
sidebarWidth?: number;
|
sidebarWidth?: number;
|
||||||
onSidebarWidthChange?: (width: number) => void;
|
onSidebarWidthChange?: (width: number) => void;
|
||||||
maxDragWidth?: number;
|
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[] } {
|
function prepareWikiHtml(inputHtml: string): { html: string; toc: TocItem[] } {
|
||||||
const parser = new DOMParser();
|
const parser = new DOMParser();
|
||||||
const doc = parser.parseFromString(inputHtml, "text/html");
|
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();
|
const href = String(a.getAttribute("href") || "").trim();
|
||||||
if (!href.length) continue;
|
if (!href.length) continue;
|
||||||
if (href === "__missing__") continue;
|
if (href === "__missing__") continue;
|
||||||
if (href.startsWith("#")) continue;
|
const slugPart = extractWikiSlugFromHref(href);
|
||||||
if (href.startsWith("/")) continue;
|
if (slugPart.length) {
|
||||||
|
a.setAttribute("href", `#wiki:${slugPart}`);
|
||||||
|
a.setAttribute("data-wiki-slug", slugPart);
|
||||||
|
a.setAttribute("target", "_self");
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
if (isExternalHref(href)) {
|
if (isExternalHref(href)) {
|
||||||
a.setAttribute("target", "_blank");
|
a.setAttribute("target", "_blank");
|
||||||
a.setAttribute("rel", "noopener noreferrer");
|
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 toc: TocItem[] = [];
|
||||||
@@ -133,6 +168,7 @@ function PublicWikiSidebar({
|
|||||||
error,
|
error,
|
||||||
onClose,
|
onClose,
|
||||||
onWikiLinkRequest,
|
onWikiLinkRequest,
|
||||||
|
onWikiLinkEntitySelectionRequest,
|
||||||
sidebarWidth,
|
sidebarWidth,
|
||||||
onSidebarWidthChange,
|
onSidebarWidthChange,
|
||||||
maxDragWidth,
|
maxDragWidth,
|
||||||
@@ -142,6 +178,12 @@ function PublicWikiSidebar({
|
|||||||
}: Props) {
|
}: Props) {
|
||||||
const contentRootRef = useRef<HTMLDivElement | null>(null);
|
const contentRootRef = useRef<HTMLDivElement | null>(null);
|
||||||
const tocContainerRef = 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(() => {
|
useEffect(() => {
|
||||||
import("react-quill-new/dist/quill.snow.css");
|
import("react-quill-new/dist/quill.snow.css");
|
||||||
@@ -225,6 +267,15 @@ function PublicWikiSidebar({
|
|||||||
? activeHeadingId
|
? activeHeadingId
|
||||||
: (toc[0]?.id ?? null);
|
: (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(() => {
|
useEffect(() => {
|
||||||
if (!toc.length) return;
|
if (!toc.length) return;
|
||||||
const root = contentRootRef.current;
|
const root = contentRootRef.current;
|
||||||
@@ -236,19 +287,33 @@ function PublicWikiSidebar({
|
|||||||
if (!headings.length) return;
|
if (!headings.length) return;
|
||||||
|
|
||||||
const scrollContainer = root.parentElement;
|
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(
|
const observer = new IntersectionObserver(
|
||||||
(entries) => {
|
updateActiveHeading,
|
||||||
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] }
|
{ root: scrollContainer || null, rootMargin: "-18% 0px -70% 0px", threshold: [0, 1] }
|
||||||
);
|
);
|
||||||
|
|
||||||
for (const heading of headings) observer.observe(heading);
|
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]);
|
}, [toc]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -275,18 +340,85 @@ function PublicWikiSidebar({
|
|||||||
const handleClick = (event: MouseEvent) => {
|
const handleClick = (event: MouseEvent) => {
|
||||||
const target = event.target as HTMLElement | null;
|
const target = event.target as HTMLElement | null;
|
||||||
const link = target?.closest?.("a[data-wiki-slug]") as HTMLAnchorElement | null;
|
const link = target?.closest?.("a[data-wiki-slug]") as HTMLAnchorElement | null;
|
||||||
if (!link) return;
|
const fallbackLink = target?.closest?.("a[href]") as HTMLAnchorElement | null;
|
||||||
event.preventDefault();
|
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;
|
if (!slug.length) return;
|
||||||
onWikiLinkRequest({ slug, rect: link.getBoundingClientRect() });
|
|
||||||
|
event.preventDefault();
|
||||||
|
onWikiLinkRequest({ slug, rect: sourceLink.getBoundingClientRect() });
|
||||||
};
|
};
|
||||||
|
|
||||||
root.addEventListener("click", handleClick);
|
root.addEventListener("click", handleClick);
|
||||||
return () => root.removeEventListener("click", handleClick);
|
return () => root.removeEventListener("click", handleClick);
|
||||||
}, [onWikiLinkRequest, renderHtml]);
|
}, [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(() => {
|
const isExpanded = useMemo(() => {
|
||||||
if (typeof window === "undefined") return false;
|
if (typeof window === "undefined") return false;
|
||||||
const fullHeight = Math.round(window.innerHeight * 0.70);
|
const fullHeight = Math.round(window.innerHeight * 0.70);
|
||||||
@@ -546,7 +678,7 @@ function PublicWikiSidebar({
|
|||||||
overflowY: "auto",
|
overflowY: "auto",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{isLoading ? (
|
{isLoading && !wiki ? (
|
||||||
<div style={{ display: "flex", flexDirection: "column", gap: 12, padding: 16 }}>
|
<div style={{ display: "flex", flexDirection: "column", gap: 12, padding: 16 }}>
|
||||||
<div
|
<div
|
||||||
style={{ height: 16, width: 110, borderRadius: 4, background: "rgba(148, 163, 184, 0.15)" }}
|
style={{ height: 16, width: 110, borderRadius: 4, background: "rgba(148, 163, 184, 0.15)" }}
|
||||||
@@ -566,12 +698,38 @@ function PublicWikiSidebar({
|
|||||||
{error}
|
{error}
|
||||||
</div>
|
</div>
|
||||||
) : wiki ? (
|
) : wiki ? (
|
||||||
|
<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
|
<div
|
||||||
ref={contentRootRef}
|
ref={contentRootRef}
|
||||||
className="uhm-wiki-sidebar-view ql-editor"
|
className="uhm-wiki-sidebar-view ql-editor"
|
||||||
style={{ fontSize: 14, color: "#cbd5e1" }}
|
style={{ fontSize: 14, color: "#cbd5e1" }}
|
||||||
dangerouslySetInnerHTML={{ __html: renderHtml }}
|
dangerouslySetInnerHTML={{ __html: renderHtml }}
|
||||||
/>
|
/>
|
||||||
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div style={{ padding: 16, fontSize: 14, color: "#94a3b8" }}>
|
<div style={{ padding: 16, fontSize: 14, color: "#94a3b8" }}>
|
||||||
Entity này chưa có wiki liên kết.
|
Entity này chưa có wiki liên kết.
|
||||||
@@ -579,6 +737,57 @@ function PublicWikiSidebar({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</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>{`
|
<style jsx global>{`
|
||||||
.uhm-public-wiki-sidebar-content::-webkit-scrollbar {
|
.uhm-public-wiki-sidebar-content::-webkit-scrollbar {
|
||||||
width: 6px;
|
width: 6px;
|
||||||
@@ -606,6 +815,17 @@ function PublicWikiSidebar({
|
|||||||
.uhm-public-wiki-toc-list::-webkit-scrollbar-thumb:hover {
|
.uhm-public-wiki-toc-list::-webkit-scrollbar-thumb:hover {
|
||||||
background: rgba(148, 163, 184, 0.4);
|
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 {
|
.uhm-wiki-sidebar-view.ql-editor {
|
||||||
height: auto;
|
height: auto;
|
||||||
overflow-y: visible;
|
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);
|
export default memo(PublicWikiSidebar);
|
||||||
|
|||||||
@@ -214,8 +214,10 @@ export function useReplayPreview({
|
|||||||
|
|
||||||
const stopPreview = useCallback(() => {
|
const stopPreview = useCallback(() => {
|
||||||
runIdRef.current += 1;
|
runIdRef.current += 1;
|
||||||
restorePreviewState();
|
isPlayingRef.current = false;
|
||||||
}, [restorePreviewState]);
|
setIsPlaying(false);
|
||||||
|
getMapInstance()?.stop();
|
||||||
|
}, [getMapInstance]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
runIdRef.current += 1;
|
runIdRef.current += 1;
|
||||||
|
|||||||
Reference in New Issue
Block a user