default user UI
This commit is contained in:
+761
-178
@@ -1,205 +1,788 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useEffect, useMemo, useRef, useState } from "react";
|
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||||
import Map from "@/uhm/components/Map";
|
|
||||||
import BackgroundLayersPanel from "@/uhm/components/BackgroundLayersPanel";
|
import Map, { type MapHoverPayload } from "@/uhm/components/Map";
|
||||||
|
import PublicWikiSidebar from "@/uhm/components/PublicWikiSidebar";
|
||||||
import TimelineBar from "@/uhm/components/TimelineBar";
|
import TimelineBar from "@/uhm/components/TimelineBar";
|
||||||
|
import { fetchEntities, type Entity } from "@/uhm/api/entities";
|
||||||
import { fetchGeometriesByBBox } from "@/uhm/api/geometries";
|
import { fetchGeometriesByBBox } from "@/uhm/api/geometries";
|
||||||
import { ApiError } from "@/uhm/api/http";
|
import { ApiError } from "@/uhm/api/http";
|
||||||
import { API_BASE_URL } from "@/uhm/api/config";
|
import { fetchWikiBySlug, searchWikisByTitle, type Wiki } from "@/uhm/api/wikis";
|
||||||
import {
|
import {
|
||||||
BackgroundLayerId,
|
BACKGROUND_LAYER_OPTIONS,
|
||||||
BackgroundLayerVisibility,
|
type BackgroundLayerId,
|
||||||
DEFAULT_BACKGROUND_LAYER_VISIBILITY,
|
type BackgroundLayerVisibility,
|
||||||
HIDDEN_BACKGROUND_LAYER_VISIBILITY,
|
DEFAULT_BACKGROUND_LAYER_VISIBILITY,
|
||||||
|
HIDDEN_BACKGROUND_LAYER_VISIBILITY,
|
||||||
} from "@/uhm/lib/backgroundLayers";
|
} from "@/uhm/lib/backgroundLayers";
|
||||||
import {
|
import {
|
||||||
loadBackgroundLayerVisibilityFromStorage,
|
loadBackgroundLayerVisibilityFromStorage,
|
||||||
persistBackgroundLayerVisibility,
|
persistBackgroundLayerVisibility,
|
||||||
} from "@/uhm/lib/editor/background/backgroundVisibilityStorage";
|
} from "@/uhm/lib/editor/background/backgroundVisibilityStorage";
|
||||||
import { EMPTY_FEATURE_COLLECTION, WORLD_BBOX } from "@/uhm/lib/geo/constants";
|
import { EMPTY_FEATURE_COLLECTION, WORLD_BBOX } from "@/uhm/lib/geo/constants";
|
||||||
import { clampYearToFixedRange, TIMELINE_DEBOUNCE_MS } from "@/uhm/lib/timeline";
|
|
||||||
import { GEO_TYPE_KEYS } from "@/uhm/lib/geoTypeMap";
|
import { GEO_TYPE_KEYS } from "@/uhm/lib/geoTypeMap";
|
||||||
import type { Feature, FeatureCollection } from "@/uhm/types/geo";
|
import { clampYearToFixedRange, TIMELINE_DEBOUNCE_MS } from "@/uhm/lib/timeline";
|
||||||
|
import type { FeatureCollection } from "@/uhm/types/geo";
|
||||||
|
|
||||||
const CURRENT_YEAR = new Date().getUTCFullYear();
|
const CURRENT_YEAR = new Date().getUTCFullYear();
|
||||||
|
const ENTITY_PAGE_LIMIT = 100;
|
||||||
|
const WIKI_PAGE_LIMIT = 100;
|
||||||
|
const RELATION_CONCURRENCY = 6;
|
||||||
|
|
||||||
|
type RelationIndex = {
|
||||||
|
entitiesById: Record<string, Entity>;
|
||||||
|
entityGeometriesById: Record<string, FeatureCollection>;
|
||||||
|
entityWikisById: Record<string, Wiki[]>;
|
||||||
|
geometryEntityIds: Record<string, string[]>;
|
||||||
|
wikiEntityIdsBySlug: Record<string, string[]>;
|
||||||
|
wikiBySlug: Record<string, Wiki>;
|
||||||
|
};
|
||||||
|
|
||||||
|
type LinkEntityPopupState = {
|
||||||
|
slug: string;
|
||||||
|
entities: Entity[];
|
||||||
|
top: number;
|
||||||
|
left: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
const EMPTY_RELATIONS: RelationIndex = {
|
||||||
|
entitiesById: {},
|
||||||
|
entityGeometriesById: {},
|
||||||
|
entityWikisById: {},
|
||||||
|
geometryEntityIds: {},
|
||||||
|
wikiEntityIdsBySlug: {},
|
||||||
|
wikiBySlug: {},
|
||||||
|
};
|
||||||
|
|
||||||
export default function Page() {
|
export default function Page() {
|
||||||
const [data, setData] = useState<FeatureCollection>(EMPTY_FEATURE_COLLECTION);
|
const [data, setData] = useState<FeatureCollection>(EMPTY_FEATURE_COLLECTION);
|
||||||
const [selectedFeatureId, setSelectedFeatureId] = useState<string | number | null>(null);
|
const [selectedFeatureId, setSelectedFeatureId] = useState<string | number | null>(null);
|
||||||
const [timelineYear, setTimelineYear] = useState<number>(() => clampYearToFixedRange(CURRENT_YEAR));
|
const [timelineYear, setTimelineYear] = useState<number>(() => clampYearToFixedRange(CURRENT_YEAR));
|
||||||
const [timelineDraftYear, setTimelineDraftYear] = useState<number>(() => clampYearToFixedRange(CURRENT_YEAR));
|
const [timelineDraftYear, setTimelineDraftYear] = useState<number>(() => clampYearToFixedRange(CURRENT_YEAR));
|
||||||
const [timeRange, setTimeRange] = useState<number>(0);
|
const [timeRange, setTimeRange] = useState<number>(0);
|
||||||
const [isTimelineLoading, setIsTimelineLoading] = useState(false);
|
const [isTimelineLoading, setIsTimelineLoading] = useState(false);
|
||||||
const [timelineStatus, setTimelineStatus] = useState<string | null>(null);
|
const [timelineStatus, setTimelineStatus] = useState<string | null>(null);
|
||||||
const [backgroundVisibility, setBackgroundVisibility] = useState<BackgroundLayerVisibility>(
|
const [backgroundVisibility, setBackgroundVisibility] = useState<BackgroundLayerVisibility>(
|
||||||
() => ({ ...HIDDEN_BACKGROUND_LAYER_VISIBILITY })
|
() => ({ ...HIDDEN_BACKGROUND_LAYER_VISIBILITY })
|
||||||
);
|
|
||||||
const [isBackgroundVisibilityReady, setIsBackgroundVisibilityReady] = useState(false);
|
|
||||||
const timelineFetchRequestRef = useRef(0);
|
|
||||||
const [lastLoadedAt, setLastLoadedAt] = useState<string | null>(null);
|
|
||||||
const [geometryVisibility, setGeometryVisibility] = useState<Record<string, boolean>>(() => {
|
|
||||||
const init: Record<string, boolean> = {};
|
|
||||||
for (const key of GEO_TYPE_KEYS) init[key] = true;
|
|
||||||
return init;
|
|
||||||
});
|
|
||||||
|
|
||||||
const selectedFeature: Feature | null = useMemo(() => {
|
|
||||||
if (selectedFeatureId === null) return null;
|
|
||||||
return (
|
|
||||||
data.features.find((feature) => String(feature.properties.id) === String(selectedFeatureId)) || null
|
|
||||||
);
|
);
|
||||||
}, [data.features, selectedFeatureId]);
|
const [isBackgroundVisibilityReady, setIsBackgroundVisibilityReady] = useState(false);
|
||||||
|
const [geometryVisibility, setGeometryVisibility] = useState<Record<string, boolean>>(() => {
|
||||||
|
const init: Record<string, boolean> = {};
|
||||||
|
for (const key of GEO_TYPE_KEYS) init[key] = true;
|
||||||
|
return init;
|
||||||
|
});
|
||||||
|
const [relations, setRelations] = useState<RelationIndex>(EMPTY_RELATIONS);
|
||||||
|
const [isRelationsLoading, setIsRelationsLoading] = useState(false);
|
||||||
|
const [relationsStatus, setRelationsStatus] = useState<string | null>(null);
|
||||||
|
const [relationsProgress, setRelationsProgress] = useState<{ completed: number; total: number }>({
|
||||||
|
completed: 0,
|
||||||
|
total: 0,
|
||||||
|
});
|
||||||
|
const [hoverAnchor, setHoverAnchor] = useState<MapHoverPayload | null>(null);
|
||||||
|
const [activeEntityId, setActiveEntityId] = useState<string | null>(null);
|
||||||
|
const [activeWikiSlug, setActiveWikiSlug] = useState<string | null>(null);
|
||||||
|
const [wikiCache, setWikiCache] = useState<Record<string, Wiki>>({});
|
||||||
|
const [isActiveWikiLoading, setIsActiveWikiLoading] = useState(false);
|
||||||
|
const [activeWikiError, setActiveWikiError] = useState<string | null>(null);
|
||||||
|
const [linkEntityPopup, setLinkEntityPopup] = useState<LinkEntityPopupState | null>(null);
|
||||||
|
const [entityFocusToken, setEntityFocusToken] = useState(0);
|
||||||
|
|
||||||
useEffect(() => {
|
const timelineFetchRequestRef = useRef(0);
|
||||||
if (selectedFeatureId === null) return;
|
const hoverHideTimerRef = useRef<number | null>(null);
|
||||||
const stillExists = data.features.some((feature) => String(feature.properties.id) === String(selectedFeatureId));
|
const hoverPopupHoveredRef = useRef(false);
|
||||||
if (!stillExists) setSelectedFeatureId(null);
|
const linkEntityPopupRef = useRef<HTMLDivElement | null>(null);
|
||||||
}, [data.features, selectedFeatureId]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
const selectedFeature = useMemo(() => {
|
||||||
const timeoutId = window.setTimeout(() => {
|
if (selectedFeatureId === null) return null;
|
||||||
if (timelineDraftYear !== timelineYear) setTimelineYear(timelineDraftYear);
|
return (
|
||||||
}, TIMELINE_DEBOUNCE_MS);
|
data.features.find((feature) => String(feature.properties.id) === String(selectedFeatureId)) || null
|
||||||
return () => window.clearTimeout(timeoutId);
|
);
|
||||||
}, [timelineDraftYear, timelineYear]);
|
}, [data.features, selectedFeatureId]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setBackgroundVisibility(loadBackgroundLayerVisibilityFromStorage());
|
if (selectedFeatureId === null) return;
|
||||||
setIsBackgroundVisibilityReady(true);
|
const stillExists = data.features.some((feature) => String(feature.properties.id) === String(selectedFeatureId));
|
||||||
}, []);
|
if (!stillExists) setSelectedFeatureId(null);
|
||||||
|
}, [data.features, selectedFeatureId]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let disposed = false;
|
const timeoutId = window.setTimeout(() => {
|
||||||
const requestId = ++timelineFetchRequestRef.current;
|
if (timelineDraftYear !== timelineYear) setTimelineYear(timelineDraftYear);
|
||||||
|
}, TIMELINE_DEBOUNCE_MS);
|
||||||
|
return () => window.clearTimeout(timeoutId);
|
||||||
|
}, [timelineDraftYear, timelineYear]);
|
||||||
|
|
||||||
async function loadByTimeline() {
|
useEffect(() => {
|
||||||
setIsTimelineLoading(true);
|
setBackgroundVisibility(loadBackgroundLayerVisibilityFromStorage());
|
||||||
setTimelineStatus(null);
|
setIsBackgroundVisibilityReady(true);
|
||||||
try {
|
}, []);
|
||||||
const next = await fetchGeometriesByBBox({ ...WORLD_BBOX, time: timelineYear, timeRange });
|
|
||||||
if (disposed || requestId !== timelineFetchRequestRef.current) return;
|
useEffect(() => {
|
||||||
setData(next);
|
let disposed = false;
|
||||||
setLastLoadedAt(new Date().toISOString());
|
const requestId = ++timelineFetchRequestRef.current;
|
||||||
} catch (err) {
|
|
||||||
if (err instanceof ApiError) {
|
async function loadByTimeline() {
|
||||||
console.error("Load timeline data failed", err.body);
|
setIsTimelineLoading(true);
|
||||||
} else {
|
setTimelineStatus(null);
|
||||||
console.error("Load timeline data failed", err);
|
try {
|
||||||
|
const next = await fetchGeometriesByBBox({ ...WORLD_BBOX, time: timelineYear, timeRange });
|
||||||
|
if (disposed || requestId !== timelineFetchRequestRef.current) return;
|
||||||
|
setData(next);
|
||||||
|
} catch (err) {
|
||||||
|
if (err instanceof ApiError) {
|
||||||
|
console.error("Load timeline data failed", err.body);
|
||||||
|
} else {
|
||||||
|
console.error("Load timeline data failed", err);
|
||||||
|
}
|
||||||
|
if (!disposed && requestId === timelineFetchRequestRef.current) {
|
||||||
|
setTimelineStatus("Không tải được geometry tại mốc thời gian đã chọn.");
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
if (!disposed && requestId === timelineFetchRequestRef.current) {
|
||||||
|
setIsTimelineLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if (!disposed && requestId === timelineFetchRequestRef.current) {
|
|
||||||
setTimelineStatus("Không tải được geometry tại mốc thời gian đã chọn.");
|
loadByTimeline();
|
||||||
|
return () => {
|
||||||
|
disposed = true;
|
||||||
|
};
|
||||||
|
}, [timelineYear, timeRange]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let disposed = false;
|
||||||
|
|
||||||
|
async function loadRelations() {
|
||||||
|
setIsRelationsLoading(true);
|
||||||
|
setRelationsStatus(null);
|
||||||
|
setRelationsProgress({ completed: 0, total: 0 });
|
||||||
|
|
||||||
|
try {
|
||||||
|
const entities = await fetchAllEntities();
|
||||||
|
if (disposed) return;
|
||||||
|
|
||||||
|
const next: RelationIndex = {
|
||||||
|
entitiesById: {},
|
||||||
|
entityGeometriesById: {},
|
||||||
|
entityWikisById: {},
|
||||||
|
geometryEntityIds: {},
|
||||||
|
wikiEntityIdsBySlug: {},
|
||||||
|
wikiBySlug: {},
|
||||||
|
};
|
||||||
|
|
||||||
|
for (const entity of entities) {
|
||||||
|
next.entitiesById[entity.id] = entity;
|
||||||
|
}
|
||||||
|
|
||||||
|
setRelationsProgress({ completed: 0, total: entities.length });
|
||||||
|
|
||||||
|
await mapWithConcurrency(entities, RELATION_CONCURRENCY, async (entity, index) => {
|
||||||
|
const [geometries, wikis] = await Promise.all([
|
||||||
|
fetchGeometriesByBBox({ ...WORLD_BBOX, entity_id: entity.id }),
|
||||||
|
fetchAllWikisForEntity(entity.id),
|
||||||
|
]);
|
||||||
|
if (disposed) return;
|
||||||
|
|
||||||
|
next.entityGeometriesById[entity.id] = geometries;
|
||||||
|
next.entityWikisById[entity.id] = wikis;
|
||||||
|
|
||||||
|
for (const feature of geometries.features) {
|
||||||
|
pushUniqueString(next.geometryEntityIds, String(feature.properties.id), entity.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const wiki of wikis) {
|
||||||
|
const slug = String(wiki.slug || "").trim();
|
||||||
|
if (!slug.length) continue;
|
||||||
|
next.wikiBySlug[slug] = wiki;
|
||||||
|
pushUniqueString(next.wikiEntityIdsBySlug, slug, entity.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
const completed = index + 1;
|
||||||
|
if (completed === entities.length || completed % 5 === 0) {
|
||||||
|
setRelationsProgress({ completed, total: entities.length });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (disposed) return;
|
||||||
|
|
||||||
|
normalizeRelationArrays(next.geometryEntityIds);
|
||||||
|
normalizeRelationArrays(next.wikiEntityIdsBySlug);
|
||||||
|
|
||||||
|
setRelations(next);
|
||||||
|
setWikiCache((prev) => ({ ...next.wikiBySlug, ...prev }));
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Load relation index failed", err);
|
||||||
|
if (!disposed) {
|
||||||
|
setRelationsStatus("Không tải được liên kết entity/wiki cho bản đồ.");
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
if (!disposed) {
|
||||||
|
setIsRelationsLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} finally {
|
|
||||||
if (!disposed && requestId === timelineFetchRequestRef.current) {
|
loadRelations();
|
||||||
setIsTimelineLoading(false);
|
return () => {
|
||||||
|
disposed = true;
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const hoverEntityIds = useMemo(() => {
|
||||||
|
if (!hoverAnchor) return [];
|
||||||
|
return relations.geometryEntityIds[String(hoverAnchor.featureId)] || [];
|
||||||
|
}, [hoverAnchor, relations.geometryEntityIds]);
|
||||||
|
|
||||||
|
const hoverEntities = useMemo(() => {
|
||||||
|
return hoverEntityIds
|
||||||
|
.map((entityId) => relations.entitiesById[entityId] || null)
|
||||||
|
.filter((entity): entity is Entity => Boolean(entity));
|
||||||
|
}, [hoverEntityIds, relations.entitiesById]);
|
||||||
|
|
||||||
|
const activeEntity = activeEntityId ? relations.entitiesById[activeEntityId] || null : null;
|
||||||
|
const activeEntityGeometries = activeEntityId
|
||||||
|
? relations.entityGeometriesById[activeEntityId] || EMPTY_FEATURE_COLLECTION
|
||||||
|
: EMPTY_FEATURE_COLLECTION;
|
||||||
|
|
||||||
|
const activeWiki = useMemo(() => {
|
||||||
|
if (!activeWikiSlug) return null;
|
||||||
|
return wikiCache[activeWikiSlug] || relations.wikiBySlug[activeWikiSlug] || null;
|
||||||
|
}, [activeWikiSlug, relations.wikiBySlug, wikiCache]);
|
||||||
|
|
||||||
|
const updateBackgroundVisibility = (updater: (prev: BackgroundLayerVisibility) => BackgroundLayerVisibility) => {
|
||||||
|
setBackgroundVisibility((prev) => {
|
||||||
|
const next = updater(prev);
|
||||||
|
persistBackgroundLayerVisibility(next);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleToggleBackgroundLayer = (id: BackgroundLayerId) => {
|
||||||
|
updateBackgroundVisibility((prev) => ({ ...prev, [id]: !prev[id] }));
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleShowAllBackgroundLayers = () => {
|
||||||
|
updateBackgroundVisibility(() => ({ ...DEFAULT_BACKGROUND_LAYER_VISIBILITY }));
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleHideAllBackgroundLayers = () => {
|
||||||
|
updateBackgroundVisibility(() => ({ ...HIDDEN_BACKGROUND_LAYER_VISIBILITY }));
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleTimelineYearChange = (nextYear: number) => {
|
||||||
|
setTimelineDraftYear(clampYearToFixedRange(Math.trunc(nextYear)));
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleTimeRangeChange = (nextRange: number) => {
|
||||||
|
const safe = Number.isFinite(nextRange) ? Math.trunc(nextRange) : 0;
|
||||||
|
setTimeRange(Math.max(0, Math.min(30, safe)));
|
||||||
|
};
|
||||||
|
|
||||||
|
const clearHoverHideTimer = useCallback(() => {
|
||||||
|
if (hoverHideTimerRef.current !== null) {
|
||||||
|
window.clearTimeout(hoverHideTimerRef.current);
|
||||||
|
hoverHideTimerRef.current = null;
|
||||||
}
|
}
|
||||||
}
|
}, []);
|
||||||
|
|
||||||
|
const selectEntity = useCallback((
|
||||||
|
entityId: string,
|
||||||
|
options?: {
|
||||||
|
sourceFeatureId?: string | number | null;
|
||||||
|
preferredWikiSlug?: string | null;
|
||||||
|
focusMap?: boolean;
|
||||||
|
selectGeometry?: boolean;
|
||||||
|
}
|
||||||
|
) => {
|
||||||
|
const entity = relations.entitiesById[entityId] || null;
|
||||||
|
if (!entity) return;
|
||||||
|
|
||||||
|
const linkedWikis = relations.entityWikisById[entityId] || [];
|
||||||
|
const preferredWikiSlug = String(options?.preferredWikiSlug || "").trim();
|
||||||
|
const nextWikiSlug =
|
||||||
|
(preferredWikiSlug && linkedWikis.some((wiki) => String(wiki.slug || "").trim() === preferredWikiSlug)
|
||||||
|
? preferredWikiSlug
|
||||||
|
: "") ||
|
||||||
|
linkedWikis.map((wiki) => String(wiki.slug || "").trim()).find((slug) => slug.length > 0) ||
|
||||||
|
null;
|
||||||
|
|
||||||
|
setActiveEntityId(entityId);
|
||||||
|
setActiveWikiSlug(nextWikiSlug);
|
||||||
|
setActiveWikiError(null);
|
||||||
|
setLinkEntityPopup(null);
|
||||||
|
if (options?.focusMap !== false) {
|
||||||
|
setEntityFocusToken((prev) => prev + 1);
|
||||||
|
}
|
||||||
|
if (options?.selectGeometry && options?.sourceFeatureId !== undefined) {
|
||||||
|
setSelectedFeatureId(options.sourceFeatureId);
|
||||||
|
}
|
||||||
|
}, [relations.entitiesById, relations.entityWikisById]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (selectedFeatureId === null) return;
|
||||||
|
const linkedEntityIds = relations.geometryEntityIds[String(selectedFeatureId)] || [];
|
||||||
|
if (linkedEntityIds.length !== 1) return;
|
||||||
|
|
||||||
|
const onlyEntityId = linkedEntityIds[0];
|
||||||
|
if (activeEntityId === onlyEntityId) return;
|
||||||
|
|
||||||
|
selectEntity(onlyEntityId, {
|
||||||
|
sourceFeatureId: selectedFeatureId,
|
||||||
|
focusMap: false,
|
||||||
|
selectGeometry: false,
|
||||||
|
});
|
||||||
|
}, [activeEntityId, relations.geometryEntityIds, selectEntity, selectedFeatureId]);
|
||||||
|
|
||||||
|
const handleMapHoverChange = useCallback((payload: MapHoverPayload | null) => {
|
||||||
|
clearHoverHideTimer();
|
||||||
|
|
||||||
|
if (payload) {
|
||||||
|
setHoverAnchor(payload);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (hoverPopupHoveredRef.current) return;
|
||||||
|
hoverHideTimerRef.current = window.setTimeout(() => {
|
||||||
|
setHoverAnchor(null);
|
||||||
|
}, 120);
|
||||||
|
}, [clearHoverHideTimer]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
return () => {
|
||||||
|
if (hoverHideTimerRef.current !== null) {
|
||||||
|
window.clearTimeout(hoverHideTimerRef.current);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!linkEntityPopup) return;
|
||||||
|
|
||||||
|
const handleKeyDown = (event: KeyboardEvent) => {
|
||||||
|
if (event.key === "Escape") setLinkEntityPopup(null);
|
||||||
|
};
|
||||||
|
const handlePointerDown = (event: PointerEvent) => {
|
||||||
|
const target = event.target as Node | null;
|
||||||
|
if (target && linkEntityPopupRef.current?.contains(target)) return;
|
||||||
|
setLinkEntityPopup(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
window.addEventListener("keydown", handleKeyDown);
|
||||||
|
window.addEventListener("pointerdown", handlePointerDown);
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener("keydown", handleKeyDown);
|
||||||
|
window.removeEventListener("pointerdown", handlePointerDown);
|
||||||
|
};
|
||||||
|
}, [linkEntityPopup]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!activeWikiSlug) {
|
||||||
|
setIsActiveWikiLoading(false);
|
||||||
|
setActiveWikiError(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const cached = wikiCache[activeWikiSlug] || relations.wikiBySlug[activeWikiSlug] || null;
|
||||||
|
if (cached?.content) {
|
||||||
|
setIsActiveWikiLoading(false);
|
||||||
|
setActiveWikiError(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let disposed = false;
|
||||||
|
(async () => {
|
||||||
|
setIsActiveWikiLoading(true);
|
||||||
|
setActiveWikiError(null);
|
||||||
|
try {
|
||||||
|
const row = await fetchWikiBySlug(activeWikiSlug);
|
||||||
|
if (disposed) return;
|
||||||
|
if (row) {
|
||||||
|
setWikiCache((prev) => ({ ...prev, [activeWikiSlug]: row }));
|
||||||
|
} else {
|
||||||
|
setActiveWikiError("Không tìm thấy wiki cho entity đã chọn.");
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
if (disposed) return;
|
||||||
|
setActiveWikiError(err instanceof Error ? err.message : "Không tải được wiki.");
|
||||||
|
} finally {
|
||||||
|
if (!disposed) setIsActiveWikiLoading(false);
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
disposed = true;
|
||||||
|
};
|
||||||
|
}, [activeWikiSlug, relations.wikiBySlug, wikiCache]);
|
||||||
|
|
||||||
|
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));
|
||||||
|
|
||||||
|
if (linkedEntities.length === 1) {
|
||||||
|
selectEntity(linkedEntities[0].id, { preferredWikiSlug: slug });
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!linkedEntities.length) return;
|
||||||
|
|
||||||
|
const popupWidth = 240;
|
||||||
|
const popupHeight = Math.min(240, linkedEntities.length * 44 + 20);
|
||||||
|
const { top, left } = computeFixedPopupPosition(rect, popupWidth, popupHeight);
|
||||||
|
|
||||||
|
setLinkEntityPopup({
|
||||||
|
slug,
|
||||||
|
entities: linkedEntities,
|
||||||
|
top,
|
||||||
|
left,
|
||||||
|
});
|
||||||
|
}, [relations.entitiesById, relations.wikiBySlug, relations.wikiEntityIdsBySlug, selectEntity, wikiCache]);
|
||||||
|
|
||||||
|
const helperText = isRelationsLoading
|
||||||
|
? `Đang index entity/wiki ${relationsProgress.completed}/${relationsProgress.total || "?"}`
|
||||||
|
: relationsStatus || `Features: ${data.features.length}`;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="relative min-h-screen overflow-hidden bg-gray-950 text-gray-100">
|
||||||
|
<div className="relative min-h-screen">
|
||||||
|
{isBackgroundVisibilityReady ? (
|
||||||
|
<Map
|
||||||
|
mode="select"
|
||||||
|
draft={data}
|
||||||
|
selectedFeatureId={selectedFeatureId}
|
||||||
|
onSelectFeatureId={setSelectedFeatureId}
|
||||||
|
backgroundVisibility={backgroundVisibility}
|
||||||
|
geometryVisibility={geometryVisibility}
|
||||||
|
allowGeometryEditing={false}
|
||||||
|
respectBindingFilter={false}
|
||||||
|
onHoverFeatureChange={handleMapHoverChange}
|
||||||
|
highlightFeatures={activeEntityGeometries}
|
||||||
|
focusFeatureCollection={activeEntityGeometries}
|
||||||
|
focusRequestKey={entityFocusToken}
|
||||||
|
focusPadding={activeEntityId ? { top: 84, right: 500, bottom: 116, left: 84 } : { top: 84, right: 84, bottom: 116, left: 84 }}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div className="h-screen w-full bg-[#0b1220]" />
|
||||||
|
)}
|
||||||
|
|
||||||
|
<TimelineBar
|
||||||
|
year={timelineDraftYear}
|
||||||
|
onYearChange={handleTimelineYearChange}
|
||||||
|
timeRange={timeRange}
|
||||||
|
onTimeRangeChange={handleTimeRangeChange}
|
||||||
|
isLoading={isTimelineLoading}
|
||||||
|
disabled={false}
|
||||||
|
statusText={timelineStatus}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="absolute left-4 top-4 z-20 w-[280px] max-w-[calc(100vw-2rem)] overflow-hidden rounded-xl border border-white/10 bg-slate-950/92 shadow-xl backdrop-blur">
|
||||||
|
<div className="border-b border-white/10 px-4 py-3">
|
||||||
|
<div className="text-sm font-semibold text-white">Map Layers</div>
|
||||||
|
<div className="mt-1 text-xs text-slate-400">{helperText}</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid gap-4 px-4 py-4">
|
||||||
|
<div>
|
||||||
|
<div className="mb-2 flex items-center justify-between text-[11px] uppercase tracking-[0.08em] text-slate-500">
|
||||||
|
<span>Background</span>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<button type="button" onClick={handleShowAllBackgroundLayers} className="text-slate-300 transition hover:text-white">
|
||||||
|
All
|
||||||
|
</button>
|
||||||
|
<button type="button" onClick={handleHideAllBackgroundLayers} className="text-slate-300 transition hover:text-white">
|
||||||
|
Off
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{BACKGROUND_LAYER_OPTIONS.map((layer) => {
|
||||||
|
const active = Boolean(backgroundVisibility[layer.id]);
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={layer.id}
|
||||||
|
type="button"
|
||||||
|
onClick={() => handleToggleBackgroundLayer(layer.id)}
|
||||||
|
className={`rounded-md border px-2.5 py-1 text-xs transition ${
|
||||||
|
active
|
||||||
|
? "border-sky-400/40 bg-sky-500/10 text-sky-200"
|
||||||
|
: "border-white/10 bg-white/[0.03] text-slate-400 hover:text-slate-200"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{layer.label}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<div className="mb-2 text-[11px] uppercase tracking-[0.08em] text-slate-500">
|
||||||
|
Geometry
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{GEO_TYPE_KEYS.map((typeKey) => {
|
||||||
|
const active = geometryVisibility[typeKey] !== false;
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={typeKey}
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
setGeometryVisibility((prev) => ({
|
||||||
|
...prev,
|
||||||
|
[typeKey]: prev[typeKey] === false,
|
||||||
|
}));
|
||||||
|
}}
|
||||||
|
className={`rounded-md border px-2.5 py-1 text-xs capitalize transition ${
|
||||||
|
active
|
||||||
|
? "border-emerald-400/40 bg-emerald-500/10 text-emerald-200"
|
||||||
|
: "border-white/10 bg-white/[0.03] text-slate-400 hover:text-slate-200"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{typeKey.replaceAll("_", " ")}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{hoverAnchor && hoverEntities.length > 0 ? (
|
||||||
|
<div
|
||||||
|
className="absolute z-30 w-[320px] max-w-[calc(100vw-2rem)]"
|
||||||
|
style={{
|
||||||
|
left: clampNumber(hoverAnchor.point.x + 18, 16, typeof window !== "undefined" ? window.innerWidth - 340 : hoverAnchor.point.x + 18),
|
||||||
|
top: clampNumber(hoverAnchor.point.y - 8, 16, typeof window !== "undefined" ? window.innerHeight - 280 : hoverAnchor.point.y - 8),
|
||||||
|
}}
|
||||||
|
onMouseEnter={() => {
|
||||||
|
hoverPopupHoveredRef.current = true;
|
||||||
|
clearHoverHideTimer();
|
||||||
|
}}
|
||||||
|
onMouseLeave={() => {
|
||||||
|
hoverPopupHoveredRef.current = false;
|
||||||
|
setHoverAnchor(null);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className="overflow-hidden rounded-xl border border-white/10 bg-slate-950/95 shadow-xl backdrop-blur">
|
||||||
|
{hoverEntities.length > 1 ? (
|
||||||
|
<div className="border-b border-white/10 px-4 py-3">
|
||||||
|
<div className="text-sm font-semibold text-white">Related Entities</div>
|
||||||
|
<div className="mt-1 text-xs text-slate-400">
|
||||||
|
Geometry #{String(hoverAnchor.featureId)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
<div className="max-h-[252px] overflow-y-auto">
|
||||||
|
<div className="grid gap-2 p-3">
|
||||||
|
{hoverEntities.map((entity) => (
|
||||||
|
<button
|
||||||
|
key={entity.id}
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
selectEntity(entity.id, {
|
||||||
|
sourceFeatureId: hoverAnchor.featureId,
|
||||||
|
focusMap: true,
|
||||||
|
selectGeometry: true,
|
||||||
|
});
|
||||||
|
setHoverAnchor(null);
|
||||||
|
}}
|
||||||
|
className="w-full rounded-lg border border-white/10 bg-white/[0.03] px-3 py-3 text-left transition hover:border-sky-400/40 hover:bg-sky-500/10"
|
||||||
|
>
|
||||||
|
<div className="truncate text-sm font-semibold text-white">
|
||||||
|
{entity.name}
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
className="mt-1 text-xs leading-5 text-slate-400"
|
||||||
|
style={{
|
||||||
|
display: "-webkit-box",
|
||||||
|
WebkitLineClamp: 3,
|
||||||
|
WebkitBoxOrient: "vertical",
|
||||||
|
overflow: "hidden",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{entity.description?.trim() || "Không có mô tả."}
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{activeEntity ? (
|
||||||
|
<aside className="absolute bottom-4 right-4 top-4 z-20 w-[420px] max-w-[calc(100vw-2rem)]">
|
||||||
|
<PublicWikiSidebar
|
||||||
|
entity={activeEntity}
|
||||||
|
wiki={activeWiki}
|
||||||
|
isLoading={isActiveWikiLoading}
|
||||||
|
error={activeWikiError}
|
||||||
|
onClose={() => {
|
||||||
|
setActiveEntityId(null);
|
||||||
|
setActiveWikiSlug(null);
|
||||||
|
setActiveWikiError(null);
|
||||||
|
setLinkEntityPopup(null);
|
||||||
|
}}
|
||||||
|
onWikiLinkRequest={handleWikiLinkRequest}
|
||||||
|
/>
|
||||||
|
</aside>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{linkEntityPopup ? (
|
||||||
|
<div
|
||||||
|
ref={linkEntityPopupRef}
|
||||||
|
className="fixed z-[60] w-[240px] overflow-hidden rounded-xl border border-gray-200 bg-white shadow-xl dark:border-gray-800 dark:bg-gray-950"
|
||||||
|
style={{ top: linkEntityPopup.top, left: linkEntityPopup.left }}
|
||||||
|
>
|
||||||
|
<div className="border-b border-gray-200 px-3 py-2 dark:border-gray-800">
|
||||||
|
<div className="text-sm font-semibold text-gray-900 dark:text-gray-100">
|
||||||
|
Related Entities
|
||||||
|
</div>
|
||||||
|
<div className="mt-1 text-xs text-gray-500 dark:text-gray-400">
|
||||||
|
/wiki/{linkEntityPopup.slug}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="max-h-[220px] overflow-y-auto p-2">
|
||||||
|
<div className="grid gap-1">
|
||||||
|
{linkEntityPopup.entities.map((entity) => (
|
||||||
|
<button
|
||||||
|
key={entity.id}
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
selectEntity(entity.id, { preferredWikiSlug: linkEntityPopup.slug });
|
||||||
|
setLinkEntityPopup(null);
|
||||||
|
}}
|
||||||
|
className="rounded-lg px-3 py-2 text-left text-sm text-gray-700 transition hover:bg-gray-50 hover:text-gray-900 dark:text-gray-200 dark:hover:bg-white/[0.04] dark:hover:text-white"
|
||||||
|
>
|
||||||
|
{entity.name}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchAllEntities(): Promise<Entity[]> {
|
||||||
|
const items: Entity[] = [];
|
||||||
|
const seen = new Set<string>();
|
||||||
|
let cursor: string | undefined;
|
||||||
|
|
||||||
|
while (true) {
|
||||||
|
const page = await fetchEntities({ q: "", limit: ENTITY_PAGE_LIMIT, cursor });
|
||||||
|
if (!page.length) break;
|
||||||
|
|
||||||
|
for (const entity of page) {
|
||||||
|
if (!entity?.id || seen.has(entity.id)) continue;
|
||||||
|
seen.add(entity.id);
|
||||||
|
items.push(entity);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (page.length < ENTITY_PAGE_LIMIT) break;
|
||||||
|
const nextCursor = page[page.length - 1]?.id;
|
||||||
|
if (!nextCursor || nextCursor === cursor) break;
|
||||||
|
cursor = nextCursor;
|
||||||
}
|
}
|
||||||
|
|
||||||
loadByTimeline();
|
return items;
|
||||||
return () => {
|
}
|
||||||
disposed = true;
|
|
||||||
};
|
async function fetchAllWikisForEntity(entityId: string): Promise<Wiki[]> {
|
||||||
}, [timelineYear, timeRange]);
|
const items: Wiki[] = [];
|
||||||
|
const seen = new Set<string>();
|
||||||
const updateBackgroundVisibility = (updater: (prev: BackgroundLayerVisibility) => BackgroundLayerVisibility) => {
|
let cursor: string | undefined;
|
||||||
setBackgroundVisibility((prev) => {
|
|
||||||
const next = updater(prev);
|
while (true) {
|
||||||
persistBackgroundLayerVisibility(next);
|
const page = await searchWikisByTitle("", {
|
||||||
return next;
|
entityId,
|
||||||
});
|
limit: WIKI_PAGE_LIMIT,
|
||||||
};
|
cursor,
|
||||||
|
});
|
||||||
const handleToggleBackgroundLayer = (id: BackgroundLayerId) => {
|
if (!page.length) break;
|
||||||
updateBackgroundVisibility((prev) => ({ ...prev, [id]: !prev[id] }));
|
|
||||||
};
|
for (const wiki of page) {
|
||||||
|
if (!wiki?.id || seen.has(wiki.id)) continue;
|
||||||
const handleShowAllBackgroundLayers = () => {
|
seen.add(wiki.id);
|
||||||
updateBackgroundVisibility(() => ({ ...DEFAULT_BACKGROUND_LAYER_VISIBILITY }));
|
items.push(wiki);
|
||||||
};
|
}
|
||||||
|
|
||||||
const handleHideAllBackgroundLayers = () => {
|
if (page.length < WIKI_PAGE_LIMIT) break;
|
||||||
updateBackgroundVisibility(() => ({ ...HIDDEN_BACKGROUND_LAYER_VISIBILITY }));
|
const nextCursor = page[page.length - 1]?.id;
|
||||||
};
|
if (!nextCursor || nextCursor === cursor) break;
|
||||||
|
cursor = nextCursor;
|
||||||
const handleTimelineYearChange = (nextYear: number) => {
|
}
|
||||||
setTimelineDraftYear(clampYearToFixedRange(Math.trunc(nextYear)));
|
|
||||||
};
|
return items;
|
||||||
|
}
|
||||||
const handleTimeRangeChange = (nextRange: number) => {
|
|
||||||
const safe = Number.isFinite(nextRange) ? Math.trunc(nextRange) : 0;
|
async function mapWithConcurrency<T>(
|
||||||
setTimeRange(Math.max(0, Math.min(30, safe)));
|
items: T[],
|
||||||
};
|
concurrency: number,
|
||||||
|
worker: (item: T, index: number) => Promise<void>
|
||||||
return (
|
): Promise<void> {
|
||||||
<div style={{ display: "flex", minHeight: "100vh" }}>
|
const runnerCount = Math.max(1, Math.min(concurrency, items.length));
|
||||||
<div style={{ flex: 1, position: "relative", minHeight: "100vh" }}>
|
let nextIndex = 0;
|
||||||
{isBackgroundVisibilityReady ? (
|
|
||||||
<Map
|
await Promise.all(
|
||||||
mode="select"
|
Array.from({ length: runnerCount }, async () => {
|
||||||
draft={data}
|
while (true) {
|
||||||
selectedFeatureId={selectedFeatureId}
|
const current = nextIndex++;
|
||||||
onSelectFeatureId={setSelectedFeatureId}
|
if (current >= items.length) return;
|
||||||
backgroundVisibility={backgroundVisibility}
|
await worker(items[current], current);
|
||||||
geometryVisibility={geometryVisibility}
|
}
|
||||||
allowGeometryEditing={false}
|
})
|
||||||
respectBindingFilter={false}
|
);
|
||||||
/>
|
}
|
||||||
) : (
|
|
||||||
<div style={{ width: "100%", height: "100%", background: "#0b1220" }} />
|
function pushUniqueString(target: Record<string, string[]>, key: string, value: string) {
|
||||||
)}
|
if (!target[key]) {
|
||||||
|
target[key] = [value];
|
||||||
<TimelineBar
|
return;
|
||||||
year={timelineDraftYear}
|
}
|
||||||
onYearChange={handleTimelineYearChange}
|
if (!target[key].includes(value)) {
|
||||||
timeRange={timeRange}
|
target[key].push(value);
|
||||||
onTimeRangeChange={handleTimeRangeChange}
|
}
|
||||||
isLoading={isTimelineLoading}
|
}
|
||||||
disabled={false}
|
|
||||||
statusText={timelineStatus}
|
function normalizeRelationArrays(target: Record<string, string[]>) {
|
||||||
/>
|
for (const key of Object.keys(target)) {
|
||||||
</div>
|
target[key] = Array.from(new Set(target[key]));
|
||||||
|
}
|
||||||
<BackgroundLayersPanel
|
}
|
||||||
visibility={backgroundVisibility}
|
|
||||||
onToggleLayer={handleToggleBackgroundLayer}
|
function clampNumber(value: number, min: number, max: number): number {
|
||||||
onShowAll={handleShowAllBackgroundLayers}
|
if (!Number.isFinite(value)) return min;
|
||||||
onHideAll={handleHideAllBackgroundLayers}
|
if (value < min) return min;
|
||||||
geometryVisibility={geometryVisibility}
|
if (value > max) return max;
|
||||||
onToggleGeometryType={(typeKey) => {
|
return value;
|
||||||
setGeometryVisibility((prev) => ({ ...prev, [typeKey]: prev[typeKey] === false }));
|
}
|
||||||
}}
|
|
||||||
topContent={
|
function computeFixedPopupPosition(rect: DOMRect, width: number, height: number) {
|
||||||
<div
|
const margin = 12;
|
||||||
style={{
|
const viewportWidth = typeof window !== "undefined" ? window.innerWidth : 1440;
|
||||||
padding: "10px",
|
const viewportHeight = typeof window !== "undefined" ? window.innerHeight : 900;
|
||||||
background: "#0b1220",
|
const preferredLeft = rect.right + margin;
|
||||||
borderRadius: "8px",
|
const maxLeft = Math.max(margin, viewportWidth - width - margin);
|
||||||
border: "1px solid #1f2937",
|
const left = Math.min(preferredLeft, maxLeft);
|
||||||
display: "grid",
|
|
||||||
gap: "8px",
|
const preferredTop = rect.top;
|
||||||
}}
|
const maxTop = Math.max(margin, viewportHeight - height - margin);
|
||||||
>
|
const top = Math.max(margin, Math.min(preferredTop, maxTop));
|
||||||
<div style={{ fontWeight: 700, fontSize: "14px", color: "#f8fafc" }}>Viewer</div>
|
|
||||||
<div style={{ color: "#94a3b8", fontSize: "12px" }}>
|
return { top, left };
|
||||||
API: {API_BASE_URL}
|
|
||||||
</div>
|
|
||||||
<div style={{ color: "#94a3b8", fontSize: "12px" }}>
|
|
||||||
Year: {timelineYear} | Features: {data.features.length}
|
|
||||||
</div>
|
|
||||||
<div style={{ color: "#94a3b8", fontSize: "12px" }}>
|
|
||||||
{isTimelineLoading ? "Loading geometries..." : lastLoadedAt ? `Loaded: ${lastLoadedAt}` : "Not loaded yet"}
|
|
||||||
</div>
|
|
||||||
<div style={{ color: "#cbd5e1", fontSize: "13px", overflowWrap: "anywhere" }}>
|
|
||||||
{selectedFeature ? `ID: ${String(selectedFeature.properties.id)}` : "Chưa chọn geometry"}
|
|
||||||
</div>
|
|
||||||
<div style={{ color: "#94a3b8", fontSize: "12px" }}>
|
|
||||||
{selectedFeature?.properties?.type ? `Type: ${String(selectedFeature.properties.type)}` : "Type: -"}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|||||||
+17
-3
@@ -4,11 +4,25 @@ import type { Entity } from "@/uhm/types/entities";
|
|||||||
|
|
||||||
export type { Entity } from "@/uhm/types/entities";
|
export type { Entity } from "@/uhm/types/entities";
|
||||||
|
|
||||||
export async function fetchEntities(query?: { q?: string }): Promise<Entity[]> {
|
export async function fetchEntities(query?: {
|
||||||
|
q?: string;
|
||||||
|
limit?: number;
|
||||||
|
cursor?: string;
|
||||||
|
projectId?: string;
|
||||||
|
}): Promise<Entity[]> {
|
||||||
const params = new URLSearchParams();
|
const params = new URLSearchParams();
|
||||||
// API mới dùng `name` thay vì `q`.
|
// API mới dùng `name` thay vì `q`.
|
||||||
if (query?.q) {
|
if (query && "q" in query) {
|
||||||
params.set("name", query.q);
|
params.set("name", String(query.q ?? ""));
|
||||||
|
}
|
||||||
|
if (query?.limit && Number.isFinite(query.limit)) {
|
||||||
|
params.set("limit", String(Math.trunc(query.limit)));
|
||||||
|
}
|
||||||
|
if (query?.cursor) {
|
||||||
|
params.set("cursor", query.cursor);
|
||||||
|
}
|
||||||
|
if (query?.projectId) {
|
||||||
|
params.set("project_id", query.projectId);
|
||||||
}
|
}
|
||||||
const suffix = params.toString();
|
const suffix = params.toString();
|
||||||
const url = suffix ? `${API_ENDPOINTS.entities}?${suffix}` : API_ENDPOINTS.entities;
|
const url = suffix ? `${API_ENDPOINTS.entities}?${suffix}` : API_ENDPOINTS.entities;
|
||||||
|
|||||||
@@ -14,8 +14,6 @@ export type Wiki = {
|
|||||||
|
|
||||||
export async function searchWikisByTitle(title: string, options?: { limit?: number; cursor?: string; entityId?: string }): Promise<Wiki[]> {
|
export async function searchWikisByTitle(title: string, options?: { limit?: number; cursor?: string; entityId?: string }): Promise<Wiki[]> {
|
||||||
const keyword = title.trim();
|
const keyword = title.trim();
|
||||||
if (!keyword.length) return [];
|
|
||||||
|
|
||||||
const params = new URLSearchParams({ title: keyword });
|
const params = new URLSearchParams({ title: keyword });
|
||||||
if (options?.limit && Number.isFinite(options.limit)) params.set("limit", String(Math.trunc(options.limit)));
|
if (options?.limit && Number.isFinite(options.limit)) params.set("limit", String(Math.trunc(options.limit)));
|
||||||
if (options?.cursor) params.set("cursor", options.cursor);
|
if (options?.cursor) params.set("cursor", options.cursor);
|
||||||
@@ -52,11 +50,11 @@ export async function checkWikiSlugExists(slug: string): Promise<boolean> {
|
|||||||
|
|
||||||
if (typeof payload === "boolean") return payload;
|
if (typeof payload === "boolean") return payload;
|
||||||
if (payload && typeof payload === "object") {
|
if (payload && typeof payload === "object") {
|
||||||
const anyPayload = payload as any;
|
const source = payload as Record<string, unknown>;
|
||||||
if (typeof anyPayload.exists === "boolean") return anyPayload.exists;
|
if (typeof source.exists === "boolean") return source.exists;
|
||||||
if (typeof anyPayload.exists === "number") return anyPayload.exists !== 0;
|
if (typeof source.exists === "number") return source.exists !== 0;
|
||||||
if (typeof anyPayload.is_exists === "boolean") return anyPayload.is_exists;
|
if (typeof source.is_exists === "boolean") return source.is_exists;
|
||||||
if (typeof anyPayload.is_exists === "number") return anyPayload.is_exists !== 0;
|
if (typeof source.is_exists === "number") return source.is_exists !== 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Be conservative: unknown payload shape, treat as "exists" to prevent creating conflicting slugs.
|
// Be conservative: unknown payload shape, treat as "exists" to prevent creating conflicting slugs.
|
||||||
|
|||||||
+192
-8
@@ -53,6 +53,18 @@ type MapProps = {
|
|||||||
height?: CSSProperties["height"];
|
height?: CSSProperties["height"];
|
||||||
fitToDraftBounds?: boolean;
|
fitToDraftBounds?: boolean;
|
||||||
fitBoundsKey?: string | number | null;
|
fitBoundsKey?: string | number | null;
|
||||||
|
onHoverFeatureChange?: ((payload: MapHoverPayload | null) => void) | undefined;
|
||||||
|
highlightFeatures?: FeatureCollection | null;
|
||||||
|
focusFeatureCollection?: FeatureCollection | null;
|
||||||
|
focusRequestKey?: string | number | null;
|
||||||
|
focusPadding?: number | maplibregl.PaddingOptions;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type MapHoverPayload = {
|
||||||
|
featureId: string | number;
|
||||||
|
feature: Feature | null;
|
||||||
|
point: { x: number; y: number };
|
||||||
|
lngLat: { lng: number; lat: number };
|
||||||
};
|
};
|
||||||
|
|
||||||
type EngineBinding = {
|
type EngineBinding = {
|
||||||
@@ -82,6 +94,11 @@ export default function Map({
|
|||||||
height = "100vh",
|
height = "100vh",
|
||||||
fitToDraftBounds = false,
|
fitToDraftBounds = false,
|
||||||
fitBoundsKey = null,
|
fitBoundsKey = null,
|
||||||
|
onHoverFeatureChange,
|
||||||
|
highlightFeatures = null,
|
||||||
|
focusFeatureCollection = null,
|
||||||
|
focusRequestKey = null,
|
||||||
|
focusPadding,
|
||||||
}: MapProps) {
|
}: MapProps) {
|
||||||
// DOM container của map (dùng ref để tránh collision khi render nhiều map).
|
// DOM container của map (dùng ref để tránh collision khi render nhiều map).
|
||||||
const containerRef = useRef<HTMLDivElement | null>(null);
|
const containerRef = useRef<HTMLDivElement | null>(null);
|
||||||
@@ -102,10 +119,15 @@ export default function Map({
|
|||||||
const backgroundVisibilityRef = useRef<BackgroundLayerVisibility>(backgroundVisibility);
|
const backgroundVisibilityRef = useRef<BackgroundLayerVisibility>(backgroundVisibility);
|
||||||
// Mirror of geometry visibility for type filtering.
|
// Mirror of geometry visibility for type filtering.
|
||||||
const geometryVisibilityRef = useRef<MapProps["geometryVisibility"]>(geometryVisibility);
|
const geometryVisibilityRef = useRef<MapProps["geometryVisibility"]>(geometryVisibility);
|
||||||
|
const highlightFeaturesRef = useRef<FeatureCollection | null>(highlightFeatures);
|
||||||
|
const focusFeatureCollectionRef = useRef<FeatureCollection | null>(focusFeatureCollection);
|
||||||
|
const focusRequestKeyRef = useRef<MapProps["focusRequestKey"]>(focusRequestKey);
|
||||||
|
const focusPaddingRef = useRef<MapProps["focusPadding"]>(focusPadding);
|
||||||
// Mirror của selectedFeatureId để filter/select trên map (không phụ thuộc re-render).
|
// Mirror của selectedFeatureId để filter/select trên map (không phụ thuộc re-render).
|
||||||
const selectedFeatureIdRef = useRef<string | number | null>(selectedFeatureId);
|
const selectedFeatureIdRef = useRef<string | number | null>(selectedFeatureId);
|
||||||
// Mirror của callback onSelectFeatureId.
|
// Mirror của callback onSelectFeatureId.
|
||||||
const onSelectFeatureIdRef = useRef(onSelectFeatureId);
|
const onSelectFeatureIdRef = useRef(onSelectFeatureId);
|
||||||
|
const onHoverFeatureChangeRef = useRef<MapProps["onHoverFeatureChange"]>(onHoverFeatureChange);
|
||||||
// Mirror của callback onCreateFeature.
|
// Mirror của callback onCreateFeature.
|
||||||
const onCreateRef = useRef<MapProps["onCreateFeature"]>(onCreateFeature);
|
const onCreateRef = useRef<MapProps["onCreateFeature"]>(onCreateFeature);
|
||||||
// Mirror của callback onDeleteFeature.
|
// Mirror của callback onDeleteFeature.
|
||||||
@@ -206,6 +228,10 @@ export default function Map({
|
|||||||
selectedFeatureIdRef.current = selectedFeatureId;
|
selectedFeatureIdRef.current = selectedFeatureId;
|
||||||
}, [selectedFeatureId]);
|
}, [selectedFeatureId]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
onHoverFeatureChangeRef.current = onHoverFeatureChange;
|
||||||
|
}, [onHoverFeatureChange]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (mode !== "select" || selectedFeatureId === null) {
|
if (mode !== "select" || selectedFeatureId === null) {
|
||||||
editingEngineRef.current?.clearEditing();
|
editingEngineRef.current?.clearEditing();
|
||||||
@@ -228,12 +254,24 @@ export default function Map({
|
|||||||
}, [backgroundVisibility]);
|
}, [backgroundVisibility]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
geometryVisibilityRef.current = geometryVisibility;
|
highlightFeaturesRef.current = highlightFeatures;
|
||||||
// When toggling geometry types, refresh sources immediately (without waiting for parent re-mount).
|
|
||||||
const map = mapRef.current;
|
const map = mapRef.current;
|
||||||
if (!map) return;
|
if (!map || !map.isStyleLoaded()) return;
|
||||||
applyDraftToMap(draftRef.current);
|
const source = map.getSource("entity-focus") as maplibregl.GeoJSONSource | undefined;
|
||||||
}, [geometryVisibility]);
|
source?.setData(highlightFeatures || EMPTY_FEATURE_COLLECTION);
|
||||||
|
}, [highlightFeatures]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
focusFeatureCollectionRef.current = focusFeatureCollection;
|
||||||
|
}, [focusFeatureCollection]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
focusRequestKeyRef.current = focusRequestKey;
|
||||||
|
}, [focusRequestKey]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
focusPaddingRef.current = focusPadding;
|
||||||
|
}, [focusPadding]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
onCreateRef.current = onCreateFeature;
|
onCreateRef.current = onCreateFeature;
|
||||||
@@ -299,6 +337,22 @@ export default function Map({
|
|||||||
}
|
}
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
const applyHighlightToMap = useCallback((fc: FeatureCollection) => {
|
||||||
|
const map = mapRef.current;
|
||||||
|
if (!map) return;
|
||||||
|
|
||||||
|
const source = map.getSource("entity-focus") as maplibregl.GeoJSONSource | undefined;
|
||||||
|
if (!source) return;
|
||||||
|
source.setData(fc);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
geometryVisibilityRef.current = geometryVisibility;
|
||||||
|
const map = mapRef.current;
|
||||||
|
if (!map) return;
|
||||||
|
applyDraftToMap(draftRef.current);
|
||||||
|
}, [applyDraftToMap, geometryVisibility]);
|
||||||
|
|
||||||
const tryCenterToUserLocation = useCallback(() => {
|
const tryCenterToUserLocation = useCallback(() => {
|
||||||
if (geolocationCenteredRef.current) return;
|
if (geolocationCenteredRef.current) return;
|
||||||
// Nếu đang "fit to draft bounds" thì không nên override center.
|
// Nếu đang "fit to draft bounds" thì không nên override center.
|
||||||
@@ -899,7 +953,56 @@ export default function Map({
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
map.addSource("entity-focus", {
|
||||||
|
type: "geojson",
|
||||||
|
data: EMPTY_FEATURE_COLLECTION,
|
||||||
|
});
|
||||||
|
|
||||||
|
map.addLayer({
|
||||||
|
id: "entity-focus-fill",
|
||||||
|
type: "fill",
|
||||||
|
source: "entity-focus",
|
||||||
|
filter: ["==", ["geometry-type"], "Polygon"],
|
||||||
|
paint: {
|
||||||
|
"fill-color": "#fde047",
|
||||||
|
"fill-opacity": 0.2,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
map.addLayer({
|
||||||
|
id: "entity-focus-line",
|
||||||
|
type: "line",
|
||||||
|
source: "entity-focus",
|
||||||
|
paint: {
|
||||||
|
"line-color": "#f59e0b",
|
||||||
|
"line-width": [
|
||||||
|
"interpolate",
|
||||||
|
["linear"],
|
||||||
|
["zoom"],
|
||||||
|
1, 2.4,
|
||||||
|
4, 4,
|
||||||
|
6, 5.5,
|
||||||
|
],
|
||||||
|
"line-opacity": 0.98,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
map.addLayer({
|
||||||
|
id: "entity-focus-points",
|
||||||
|
type: "circle",
|
||||||
|
source: "entity-focus",
|
||||||
|
filter: ["==", ["geometry-type"], "Point"],
|
||||||
|
paint: {
|
||||||
|
"circle-color": "#f8fafc",
|
||||||
|
"circle-radius": 8,
|
||||||
|
"circle-stroke-color": "#f59e0b",
|
||||||
|
"circle-stroke-width": 3,
|
||||||
|
"circle-opacity": 1,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
addPointSymbolLayer(map);
|
addPointSymbolLayer(map);
|
||||||
|
applyHighlightToMap(highlightFeaturesRef.current || EMPTY_FEATURE_COLLECTION);
|
||||||
|
|
||||||
// init drawing
|
// init drawing
|
||||||
const drawingEngine = initDrawing(
|
const drawingEngine = initDrawing(
|
||||||
@@ -1047,10 +1150,59 @@ export default function Map({
|
|||||||
() => map.off("zoom", syncZoomLevel),
|
() => map.off("zoom", syncZoomLevel),
|
||||||
];
|
];
|
||||||
|
|
||||||
|
const handleHoverMove = (event: maplibregl.MapMouseEvent) => {
|
||||||
|
const callback = onHoverFeatureChangeRef.current;
|
||||||
|
if (!callback) return;
|
||||||
|
|
||||||
|
const selectableLayers = getSelectableLayers(map);
|
||||||
|
if (!selectableLayers.length) {
|
||||||
|
callback(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const features = map.queryRenderedFeatures(event.point, {
|
||||||
|
layers: selectableLayers,
|
||||||
|
}) as maplibregl.MapGeoJSONFeature[];
|
||||||
|
|
||||||
|
const feature = features[0];
|
||||||
|
const rawFeatureId = feature?.id ?? feature?.properties?.id;
|
||||||
|
if (rawFeatureId === undefined || rawFeatureId === null) {
|
||||||
|
callback(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const currentFeature =
|
||||||
|
draftRef.current.features.find(
|
||||||
|
(item) => String(item.properties.id) === String(rawFeatureId)
|
||||||
|
) || null;
|
||||||
|
|
||||||
|
callback({
|
||||||
|
featureId: rawFeatureId,
|
||||||
|
feature: currentFeature,
|
||||||
|
point: { x: event.point.x, y: event.point.y },
|
||||||
|
lngLat: { lng: event.lngLat.lng, lat: event.lngLat.lat },
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCanvasMouseLeave = () => {
|
||||||
|
onHoverFeatureChangeRef.current?.(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
map.on("mousemove", handleHoverMove);
|
||||||
|
mapCleanupFnsRef.current.push(() => map.off("mousemove", handleHoverMove));
|
||||||
|
|
||||||
|
map.getCanvasContainer().addEventListener("mouseleave", handleCanvasMouseLeave);
|
||||||
|
mapCleanupFnsRef.current.push(() => {
|
||||||
|
map.getCanvasContainer().removeEventListener("mouseleave", handleCanvasMouseLeave);
|
||||||
|
});
|
||||||
|
|
||||||
// after everything mounted, push current draft to sources
|
// after everything mounted, push current draft to sources
|
||||||
applyDraftToMap(draftRef.current);
|
applyDraftToMap(draftRef.current);
|
||||||
// Khi vao web, thu auto center theo vi tri user (neu co quyen).
|
// Khi vao web, thu auto center theo vi tri user (neu co quyen).
|
||||||
tryCenterToUserLocation();
|
tryCenterToUserLocation();
|
||||||
|
if (focusRequestKeyRef.current !== null && focusRequestKeyRef.current !== undefined && focusFeatureCollectionRef.current?.features.length) {
|
||||||
|
fitMapToFeatureCollection(map, focusFeatureCollectionRef.current, focusPaddingRef.current);
|
||||||
|
}
|
||||||
|
|
||||||
if (allowGeometryEditing) {
|
if (allowGeometryEditing) {
|
||||||
editingEngineRef.current?.bindEditEvents(map);
|
editingEngineRef.current?.bindEditEvents(map);
|
||||||
@@ -1072,7 +1224,7 @@ export default function Map({
|
|||||||
}
|
}
|
||||||
map.remove();
|
map.remove();
|
||||||
};
|
};
|
||||||
}, [allowGeometryEditing, applyDraftToMap, tryCenterToUserLocation]);
|
}, [allowGeometryEditing, applyDraftToMap, applyHighlightToMap, tryCenterToUserLocation]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const map = mapRef.current;
|
const map = mapRef.current;
|
||||||
@@ -1126,6 +1278,15 @@ export default function Map({
|
|||||||
}
|
}
|
||||||
}, [allowGeometryEditing, draft, selectedFeatureId, applyDraftToMap]);
|
}, [allowGeometryEditing, draft, selectedFeatureId, applyDraftToMap]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (focusRequestKey === null || focusRequestKey === undefined) return;
|
||||||
|
const map = mapRef.current;
|
||||||
|
if (!map || !map.isStyleLoaded()) return;
|
||||||
|
const target = focusFeatureCollectionRef.current;
|
||||||
|
if (!target || !target.features.length) return;
|
||||||
|
fitMapToFeatureCollection(map, target, focusPaddingRef.current);
|
||||||
|
}, [focusRequestKey]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div style={{ width: "100%", height, position: "relative" }}>
|
<div style={{ width: "100%", height, position: "relative" }}>
|
||||||
<div ref={containerRef} style={{ width: "100%", height: "100%" }} />
|
<div ref={containerRef} style={{ width: "100%", height: "100%" }} />
|
||||||
@@ -1374,6 +1535,19 @@ function createRasterBaseLayer() {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getSelectableLayers(map: maplibregl.Map): string[] {
|
||||||
|
return [
|
||||||
|
"countries-fill",
|
||||||
|
"countries-line",
|
||||||
|
"routes-line",
|
||||||
|
"routes-path-arrow-fill",
|
||||||
|
"routes-path-arrow-line",
|
||||||
|
"routes-path-hit",
|
||||||
|
"places-circle",
|
||||||
|
"places-symbol",
|
||||||
|
].filter((layerId) => Boolean(map.getLayer(layerId)));
|
||||||
|
}
|
||||||
|
|
||||||
function filterDraftByBinding(
|
function filterDraftByBinding(
|
||||||
fc: FeatureCollection,
|
fc: FeatureCollection,
|
||||||
selectedFeatureId: string | number | null
|
selectedFeatureId: string | number | null
|
||||||
@@ -1470,16 +1644,26 @@ function setSelectedFeatureState(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function fitMapToFeatureCollection(map: maplibregl.Map, fc: FeatureCollection): boolean {
|
function fitMapToFeatureCollection(
|
||||||
|
map: maplibregl.Map,
|
||||||
|
fc: FeatureCollection,
|
||||||
|
padding?: number | maplibregl.PaddingOptions
|
||||||
|
): boolean {
|
||||||
const bbox = getFeatureCollectionBBox(fc);
|
const bbox = getFeatureCollectionBBox(fc);
|
||||||
if (!bbox) return false;
|
if (!bbox) return false;
|
||||||
|
|
||||||
|
const resolvedPadding =
|
||||||
|
typeof padding === "number" || padding
|
||||||
|
? padding
|
||||||
|
: 58;
|
||||||
|
|
||||||
const lngSpan = Math.abs(bbox.maxLng - bbox.minLng);
|
const lngSpan = Math.abs(bbox.maxLng - bbox.minLng);
|
||||||
const latSpan = Math.abs(bbox.maxLat - bbox.minLat);
|
const latSpan = Math.abs(bbox.maxLat - bbox.minLat);
|
||||||
if (lngSpan < 0.000001 && latSpan < 0.000001) {
|
if (lngSpan < 0.000001 && latSpan < 0.000001) {
|
||||||
map.easeTo({
|
map.easeTo({
|
||||||
center: [bbox.minLng, bbox.minLat],
|
center: [bbox.minLng, bbox.minLat],
|
||||||
zoom: 6,
|
zoom: 6,
|
||||||
|
padding: resolvedPadding,
|
||||||
duration: 0,
|
duration: 0,
|
||||||
});
|
});
|
||||||
return true;
|
return true;
|
||||||
@@ -1491,7 +1675,7 @@ function fitMapToFeatureCollection(map: maplibregl.Map, fc: FeatureCollection):
|
|||||||
[bbox.maxLng, bbox.maxLat],
|
[bbox.maxLng, bbox.maxLat],
|
||||||
],
|
],
|
||||||
{
|
{
|
||||||
padding: 58,
|
padding: resolvedPadding,
|
||||||
maxZoom: 7,
|
maxZoom: 7,
|
||||||
duration: 0,
|
duration: 0,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,388 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect, useMemo, useRef, useState } from "react";
|
||||||
|
|
||||||
|
import type { Entity } from "@/uhm/api/entities";
|
||||||
|
import type { Wiki } from "@/uhm/api/wikis";
|
||||||
|
|
||||||
|
type TocItem = {
|
||||||
|
id: string;
|
||||||
|
level: number;
|
||||||
|
text: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
entity: Entity | null;
|
||||||
|
wiki: Wiki | null;
|
||||||
|
isLoading: boolean;
|
||||||
|
error?: string | null;
|
||||||
|
onClose: () => void;
|
||||||
|
onWikiLinkRequest: (request: { slug: string; rect: DOMRect }) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||||
|
return Boolean(value) && typeof value === "object" && !Array.isArray(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
function tiptapJsonToPlainText(node: unknown): string {
|
||||||
|
if (node == null) return "";
|
||||||
|
if (typeof node === "string") return node;
|
||||||
|
if (Array.isArray(node)) return node.map(tiptapJsonToPlainText).join("");
|
||||||
|
|
||||||
|
if (isRecord(node)) {
|
||||||
|
if (node.type === "text" && typeof node.text === "string") return node.text;
|
||||||
|
if (node.type === "hardBreak") return "\n";
|
||||||
|
if ("content" in node) return tiptapJsonToPlainText(node.content);
|
||||||
|
}
|
||||||
|
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
|
||||||
|
function escapeHtml(input: string): string {
|
||||||
|
return input
|
||||||
|
.replaceAll("&", "&")
|
||||||
|
.replaceAll("<", "<")
|
||||||
|
.replaceAll(">", ">")
|
||||||
|
.replaceAll("\"", """)
|
||||||
|
.replaceAll("'", "'");
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeWikiContentToHtml(raw: string | null | undefined): string {
|
||||||
|
const value = String(raw || "").trim();
|
||||||
|
if (!value.length) return "";
|
||||||
|
|
||||||
|
if (value[0] === "<") return value;
|
||||||
|
|
||||||
|
if (value[0] === "{") {
|
||||||
|
try {
|
||||||
|
const json: unknown = JSON.parse(value);
|
||||||
|
const text = tiptapJsonToPlainText(json).trim();
|
||||||
|
if (!text.length) return "";
|
||||||
|
return `<p>${escapeHtml(text).replace(/\n/g, "<br/>")}</p>`;
|
||||||
|
} catch {
|
||||||
|
// fall through
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return `<p>${escapeHtml(value).replace(/\n/g, "<br/>")}</p>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function slugifyHeading(raw: string): string {
|
||||||
|
const input = String(raw || "").trim();
|
||||||
|
if (!input.length) return "";
|
||||||
|
return input
|
||||||
|
.toLowerCase()
|
||||||
|
.normalize("NFKD")
|
||||||
|
.replace(/[\u0300-\u036f]/g, "")
|
||||||
|
.replace(/[^a-z0-9]+/g, "-")
|
||||||
|
.replace(/^-+/, "")
|
||||||
|
.replace(/-+$/, "")
|
||||||
|
.slice(0, 80);
|
||||||
|
}
|
||||||
|
|
||||||
|
function isExternalHref(href: string): boolean {
|
||||||
|
const h = href.trim().toLowerCase();
|
||||||
|
return (
|
||||||
|
h.startsWith("http://") ||
|
||||||
|
h.startsWith("https://") ||
|
||||||
|
h.startsWith("mailto:") ||
|
||||||
|
h.startsWith("tel:") ||
|
||||||
|
h.startsWith("sms:")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function prepareWikiHtml(inputHtml: string): { html: string; toc: TocItem[] } {
|
||||||
|
const parser = new DOMParser();
|
||||||
|
const doc = parser.parseFromString(inputHtml, "text/html");
|
||||||
|
|
||||||
|
for (const el of Array.from(doc.querySelectorAll("script"))) el.remove();
|
||||||
|
|
||||||
|
for (const a of Array.from(doc.querySelectorAll("a[href]"))) {
|
||||||
|
const href = String(a.getAttribute("href") || "").trim();
|
||||||
|
if (!href.length) continue;
|
||||||
|
if (href === "__missing__") continue;
|
||||||
|
if (href.startsWith("#")) continue;
|
||||||
|
if (href.startsWith("/")) continue;
|
||||||
|
|
||||||
|
if (isExternalHref(href)) {
|
||||||
|
a.setAttribute("target", "_blank");
|
||||||
|
a.setAttribute("rel", "noopener noreferrer");
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const match = href.match(/^([^?#]+)([?#].*)?$/);
|
||||||
|
const slugPart = String(match?.[1] || "").replace(/^\/+/, "").trim();
|
||||||
|
if (!slugPart.length) continue;
|
||||||
|
a.setAttribute("href", `#wiki:${slugPart}`);
|
||||||
|
a.setAttribute("data-wiki-slug", slugPart);
|
||||||
|
a.setAttribute("target", "_self");
|
||||||
|
}
|
||||||
|
|
||||||
|
const toc: TocItem[] = [];
|
||||||
|
const seen = new Map<string, number>();
|
||||||
|
const headings = Array.from(doc.body.querySelectorAll("h1,h2,h3,h4,h5,h6"));
|
||||||
|
for (const h of headings) {
|
||||||
|
const text = String(h.textContent || "").trim();
|
||||||
|
if (!text.length) continue;
|
||||||
|
|
||||||
|
const level = Number(String(h.tagName || "").replace(/^H/i, "")) || 1;
|
||||||
|
const existingId = String(h.getAttribute("id") || "").trim();
|
||||||
|
if (existingId) {
|
||||||
|
toc.push({ id: existingId, level, text });
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const base = slugifyHeading(text) || "heading";
|
||||||
|
const nextCount = (seen.get(base) || 0) + 1;
|
||||||
|
seen.set(base, nextCount);
|
||||||
|
const id = nextCount === 1 ? base : `${base}-${nextCount}`;
|
||||||
|
|
||||||
|
h.setAttribute("id", id);
|
||||||
|
toc.push({ id, level, text });
|
||||||
|
}
|
||||||
|
|
||||||
|
return { html: doc.body.innerHTML, toc };
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function PublicWikiSidebar({
|
||||||
|
entity,
|
||||||
|
wiki,
|
||||||
|
isLoading,
|
||||||
|
error,
|
||||||
|
onClose,
|
||||||
|
onWikiLinkRequest,
|
||||||
|
}: Props) {
|
||||||
|
const contentRootRef = useRef<HTMLDivElement | null>(null);
|
||||||
|
const [activeHeadingId, setActiveHeadingId] = useState<string | null>(null);
|
||||||
|
const processedWiki = useMemo(() => {
|
||||||
|
if (!wiki) return { html: "", toc: [] as TocItem[] };
|
||||||
|
|
||||||
|
const html = normalizeWikiContentToHtml(wiki.content ?? "");
|
||||||
|
try {
|
||||||
|
return prepareWikiHtml(html);
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Failed to process sidebar wiki HTML", err);
|
||||||
|
return { html, toc: [] as TocItem[] };
|
||||||
|
}
|
||||||
|
}, [wiki]);
|
||||||
|
const renderHtml = processedWiki.html;
|
||||||
|
const toc = processedWiki.toc;
|
||||||
|
const effectiveActiveHeadingId = toc.some((item) => item.id === activeHeadingId)
|
||||||
|
? activeHeadingId
|
||||||
|
: (toc[0]?.id ?? null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!toc.length) return;
|
||||||
|
const root = contentRootRef.current;
|
||||||
|
if (!root) return;
|
||||||
|
|
||||||
|
const headings = toc
|
||||||
|
.map((item) => root.querySelector<HTMLElement>(`#${CSS.escape(item.id)}`))
|
||||||
|
.filter((item): item is HTMLElement => Boolean(item));
|
||||||
|
if (!headings.length) return;
|
||||||
|
|
||||||
|
const observer = new IntersectionObserver(
|
||||||
|
(entries) => {
|
||||||
|
const visible = entries
|
||||||
|
.filter((entry) => entry.isIntersecting)
|
||||||
|
.sort((a, b) => (a.boundingClientRect.top ?? 0) - (b.boundingClientRect.top ?? 0));
|
||||||
|
const top = visible[0]?.target as HTMLElement | undefined;
|
||||||
|
if (top?.id) setActiveHeadingId(top.id);
|
||||||
|
},
|
||||||
|
{ root: null, rootMargin: "-18% 0px -70% 0px", threshold: [0, 1] }
|
||||||
|
);
|
||||||
|
|
||||||
|
for (const heading of headings) observer.observe(heading);
|
||||||
|
return () => observer.disconnect();
|
||||||
|
}, [toc]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const root = contentRootRef.current;
|
||||||
|
if (!root) return;
|
||||||
|
|
||||||
|
const handleClick = (event: MouseEvent) => {
|
||||||
|
const target = event.target as HTMLElement | null;
|
||||||
|
const link = target?.closest?.("a[data-wiki-slug]") as HTMLAnchorElement | null;
|
||||||
|
if (!link) return;
|
||||||
|
event.preventDefault();
|
||||||
|
|
||||||
|
const slug = String(link.getAttribute("data-wiki-slug") || "").trim();
|
||||||
|
if (!slug.length) return;
|
||||||
|
onWikiLinkRequest({ slug, rect: link.getBoundingClientRect() });
|
||||||
|
};
|
||||||
|
|
||||||
|
root.addEventListener("click", handleClick);
|
||||||
|
return () => root.removeEventListener("click", handleClick);
|
||||||
|
}, [onWikiLinkRequest, renderHtml]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex h-full min-h-0 flex-col overflow-hidden rounded-xl border border-gray-200 bg-white shadow-xl dark:border-gray-800 dark:bg-gray-950">
|
||||||
|
<div className="border-b border-gray-200 px-4 py-4 dark:border-gray-800">
|
||||||
|
<div className="flex items-start justify-between gap-3">
|
||||||
|
<div className="min-w-0">
|
||||||
|
<div className="text-[11px] uppercase tracking-[0.08em] text-gray-500 dark:text-gray-400">
|
||||||
|
Wiki
|
||||||
|
</div>
|
||||||
|
<div className="mt-1 text-lg font-semibold leading-tight text-gray-900 dark:text-gray-100">
|
||||||
|
{entity?.name?.trim() || wiki?.title?.trim() || "Wiki"}
|
||||||
|
</div>
|
||||||
|
{entity?.description?.trim() ? (
|
||||||
|
<div className="mt-2 text-sm leading-6 text-gray-600 dark:text-gray-300">
|
||||||
|
{entity.description.trim()}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
{wiki?.title?.trim() && wiki.title.trim() !== entity?.name?.trim() ? (
|
||||||
|
<div className="mt-2 text-xs text-gray-500 dark:text-gray-400">
|
||||||
|
{wiki.title.trim()}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onClose}
|
||||||
|
className="inline-flex h-8 w-8 items-center justify-center rounded-full border border-gray-200 text-sm text-gray-500 transition hover:bg-gray-50 hover:text-gray-800 dark:border-gray-800 dark:text-gray-400 dark:hover:bg-white/[0.04] dark:hover:text-gray-100"
|
||||||
|
aria-label="Close wiki sidebar"
|
||||||
|
>
|
||||||
|
x
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{toc.length ? (
|
||||||
|
<div className="border-b border-gray-200 px-3 py-2 dark:border-gray-800">
|
||||||
|
<div className="flex gap-2 overflow-x-auto pb-1">
|
||||||
|
{toc.slice(0, 8).map((item) => {
|
||||||
|
const isActive = effectiveActiveHeadingId === item.id;
|
||||||
|
return (
|
||||||
|
<a
|
||||||
|
key={item.id}
|
||||||
|
href={`#${item.id}`}
|
||||||
|
className={`shrink-0 rounded-full px-3 py-1 text-xs transition ${
|
||||||
|
isActive
|
||||||
|
? "bg-brand-50 text-brand-700 dark:bg-brand-500/10 dark:text-brand-300"
|
||||||
|
: "bg-gray-50 text-gray-600 hover:bg-gray-100 dark:bg-white/[0.03] dark:text-gray-300 dark:hover:bg-white/[0.06]"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{item.text}
|
||||||
|
</a>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<div className="min-h-0 flex-1 overflow-y-auto">
|
||||||
|
{isLoading ? (
|
||||||
|
<div className="space-y-3 px-4 py-4">
|
||||||
|
<div className="h-4 w-28 animate-pulse rounded bg-gray-100 dark:bg-white/[0.06]" />
|
||||||
|
<div className="h-4 w-full animate-pulse rounded bg-gray-100 dark:bg-white/[0.06]" />
|
||||||
|
<div className="h-4 w-4/5 animate-pulse rounded bg-gray-100 dark:bg-white/[0.06]" />
|
||||||
|
</div>
|
||||||
|
) : error ? (
|
||||||
|
<div className="px-4 py-4 text-sm text-red-600 dark:text-red-300">
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
) : wiki ? (
|
||||||
|
<div
|
||||||
|
ref={contentRootRef}
|
||||||
|
className="uhm-wiki-sidebar-view ql-editor text-sm text-gray-900 dark:text-gray-100"
|
||||||
|
dangerouslySetInnerHTML={{ __html: renderHtml }}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div className="px-4 py-4 text-sm text-gray-600 dark:text-gray-300">
|
||||||
|
Entity này chưa có wiki liên kết.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style jsx global>{`
|
||||||
|
.uhm-wiki-sidebar-view.ql-editor {
|
||||||
|
height: auto;
|
||||||
|
overflow-y: visible;
|
||||||
|
padding: 18px 18px 22px;
|
||||||
|
}
|
||||||
|
.uhm-wiki-sidebar-view.ql-editor p {
|
||||||
|
margin: 0 0 0.75em;
|
||||||
|
}
|
||||||
|
.uhm-wiki-sidebar-view.ql-editor h1 {
|
||||||
|
margin: 1.15em 0 0.6em;
|
||||||
|
font-size: 1.6em;
|
||||||
|
font-weight: 800;
|
||||||
|
line-height: 1.2;
|
||||||
|
}
|
||||||
|
.uhm-wiki-sidebar-view.ql-editor h2 {
|
||||||
|
margin: 1.05em 0 0.55em;
|
||||||
|
font-size: 1.3em;
|
||||||
|
font-weight: 800;
|
||||||
|
line-height: 1.25;
|
||||||
|
}
|
||||||
|
.uhm-wiki-sidebar-view.ql-editor h3,
|
||||||
|
.uhm-wiki-sidebar-view.ql-editor h4,
|
||||||
|
.uhm-wiki-sidebar-view.ql-editor h5,
|
||||||
|
.uhm-wiki-sidebar-view.ql-editor h6 {
|
||||||
|
margin: 0.95em 0 0.45em;
|
||||||
|
font-size: 1.05em;
|
||||||
|
font-weight: 700;
|
||||||
|
line-height: 1.3;
|
||||||
|
}
|
||||||
|
.uhm-wiki-sidebar-view.ql-editor ul,
|
||||||
|
.uhm-wiki-sidebar-view.ql-editor ol {
|
||||||
|
margin: 0 0 0.75em;
|
||||||
|
padding-left: 1.5em;
|
||||||
|
}
|
||||||
|
.uhm-wiki-sidebar-view.ql-editor blockquote {
|
||||||
|
margin: 0 0 0.75em;
|
||||||
|
padding-left: 12px;
|
||||||
|
border-left: 3px solid rgba(148, 163, 184, 0.6);
|
||||||
|
color: rgba(71, 85, 105, 1);
|
||||||
|
}
|
||||||
|
:is(.dark *) .uhm-wiki-sidebar-view.ql-editor blockquote {
|
||||||
|
border-left-color: rgba(100, 116, 139, 0.6);
|
||||||
|
color: rgba(203, 213, 225, 0.95);
|
||||||
|
}
|
||||||
|
.uhm-wiki-sidebar-view.ql-editor pre {
|
||||||
|
margin: 0 0 0.75em;
|
||||||
|
padding: 12px 14px;
|
||||||
|
border: 1px solid rgba(226, 232, 240, 1);
|
||||||
|
border-radius: 10px;
|
||||||
|
background: rgba(248, 250, 252, 1);
|
||||||
|
overflow: auto;
|
||||||
|
}
|
||||||
|
:is(.dark *) .uhm-wiki-sidebar-view.ql-editor pre {
|
||||||
|
border-color: rgba(51, 65, 85, 1);
|
||||||
|
background: rgba(2, 6, 23, 0.4);
|
||||||
|
}
|
||||||
|
.uhm-wiki-sidebar-view.ql-editor img {
|
||||||
|
max-width: 100%;
|
||||||
|
height: auto;
|
||||||
|
border-radius: 8px;
|
||||||
|
}
|
||||||
|
.uhm-wiki-sidebar-view.ql-editor a {
|
||||||
|
text-decoration: underline;
|
||||||
|
text-underline-offset: 2px;
|
||||||
|
}
|
||||||
|
.uhm-wiki-sidebar-view.ql-editor a[href]:not([href=""]):not([href="__missing__"]) {
|
||||||
|
color: #2563eb;
|
||||||
|
}
|
||||||
|
:is(.dark *) .uhm-wiki-sidebar-view.ql-editor a[href]:not([href=""]):not([href="__missing__"]) {
|
||||||
|
color: #60a5fa;
|
||||||
|
}
|
||||||
|
.uhm-wiki-sidebar-view.ql-editor a[href="__missing__"] {
|
||||||
|
cursor: default;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
.uhm-wiki-sidebar-view.ql-editor a:not([href]),
|
||||||
|
.uhm-wiki-sidebar-view.ql-editor a[href=""],
|
||||||
|
.uhm-wiki-sidebar-view.ql-editor a[href="__missing__"] {
|
||||||
|
color: #dc2626;
|
||||||
|
}
|
||||||
|
:is(.dark *) .uhm-wiki-sidebar-view.ql-editor a:not([href]),
|
||||||
|
:is(.dark *) .uhm-wiki-sidebar-view.ql-editor a[href=""],
|
||||||
|
:is(.dark *) .uhm-wiki-sidebar-view.ql-editor a[href="__missing__"] {
|
||||||
|
color: #f87171;
|
||||||
|
}
|
||||||
|
`}</style>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user