fix hover popup bug
This commit is contained in:
+1
-23
@@ -20,32 +20,10 @@ const srOnlyStyle: React.CSSProperties = {
|
|||||||
|
|
||||||
export default function Page() {
|
export default function Page() {
|
||||||
return (
|
return (
|
||||||
<div style={{ position: "relative", width: "100%", height: "100svh", overflow: "hidden", backgroundColor: "#0b1220" }}>
|
<div style={{ position: "relative", width: "100%", height: "100svh", overflow: "hidden" }}>
|
||||||
{/* Preload LCP image */}
|
{/* Preload LCP image */}
|
||||||
<link rel="preload" as="image" href="/images/map_placeholder.webp" fetchPriority="high" />
|
<link rel="preload" as="image" href="/images/map_placeholder.webp" fetchPriority="high" />
|
||||||
|
|
||||||
{/* Permanent, static LCP image that is NEVER hidden or unmounted */}
|
|
||||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
|
||||||
<img
|
|
||||||
src="/images/map_placeholder.webp"
|
|
||||||
alt="Map Background"
|
|
||||||
fetchPriority="high"
|
|
||||||
loading="eager"
|
|
||||||
decoding="sync"
|
|
||||||
width={1920}
|
|
||||||
height={1080}
|
|
||||||
style={{
|
|
||||||
position: "absolute",
|
|
||||||
inset: 0,
|
|
||||||
width: "100%",
|
|
||||||
height: "100%",
|
|
||||||
objectFit: "cover",
|
|
||||||
zIndex: 0,
|
|
||||||
backgroundImage: "url('data:image/webp;base64,UklGRmgAAABXRUJQVlA4IFwAAAAQAgCdASoQAAkAAgA0JbACdAD0j9ruBiEAAP71e6Hb3PzxBzEI1XGXkdQ3Wq1ek8XLa1nPPm65FhrFIjmR0%2BxZwNUJBvg15I7CuzvhuunZ%2FUF83IaP8Evo6gAAAA%3D%3D')",
|
|
||||||
backgroundSize: "cover",
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* Header (SSR & SEO) */}
|
{/* Header (SSR & SEO) */}
|
||||||
<header style={srOnlyStyle}>
|
<header style={srOnlyStyle}>
|
||||||
<nav>
|
<nav>
|
||||||
|
|||||||
@@ -298,22 +298,7 @@ const Map = memo(forwardRef<MapHandle, MapProps>(function Map({
|
|||||||
}, [hasImageOverlay, isMapLoaded, mapRef]);
|
}, [hasImageOverlay, isMapLoaded, mapRef]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div style={{ width: "100%", height, position: "relative", backgroundColor: "#0b1220" }}>
|
<div style={{ width: "100%", height, position: "relative" }}>
|
||||||
{/* Opaque map placeholder image for LCP optimization */}
|
|
||||||
<img
|
|
||||||
src="/images/map_placeholder.webp"
|
|
||||||
alt="Map Loading Placeholder"
|
|
||||||
fetchPriority="high"
|
|
||||||
style={{
|
|
||||||
position: "absolute",
|
|
||||||
inset: 0,
|
|
||||||
width: "100%",
|
|
||||||
height: "100%",
|
|
||||||
objectFit: "cover",
|
|
||||||
zIndex: 0,
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<div
|
<div
|
||||||
ref={containerRef}
|
ref={containerRef}
|
||||||
style={{
|
style={{
|
||||||
|
|||||||
@@ -58,6 +58,7 @@ export function useMapHoverPopup({
|
|||||||
let currentContent: MapHoverPopupContent | null = null;
|
let currentContent: MapHoverPopupContent | null = null;
|
||||||
let selectedRowIndex = 0;
|
let selectedRowIndex = 0;
|
||||||
let selectionVisible = false;
|
let selectionVisible = false;
|
||||||
|
let selectionDirection: "next" | "prev" | null = null;
|
||||||
let lastSelectedRowClick: (() => void) | null = null;
|
let lastSelectedRowClick: (() => void) | null = null;
|
||||||
let lastSelectedRowAt = 0;
|
let lastSelectedRowAt = 0;
|
||||||
let hoverLayerIds = getHoverLayerIds(map);
|
let hoverLayerIds = getHoverLayerIds(map);
|
||||||
@@ -98,14 +99,23 @@ export function useMapHoverPopup({
|
|||||||
selectedRowIndex = wrapIndex(selectedRowIndex, rows.length);
|
selectedRowIndex = wrapIndex(selectedRowIndex, rows.length);
|
||||||
lastSelectedRowClick = rows[selectedRowIndex]?.onClick || null;
|
lastSelectedRowClick = rows[selectedRowIndex]?.onClick || null;
|
||||||
lastSelectedRowAt = Date.now();
|
lastSelectedRowAt = Date.now();
|
||||||
updatePopupRowSelection(popup, selectedRowIndex, selectionVisible);
|
updatePopupRowSelection(popup, selectedRowIndex, selectionVisible, selectionDirection);
|
||||||
};
|
};
|
||||||
|
|
||||||
const cyclePopupRow = (direction: "next" | "prev") => {
|
const cyclePopupRow = (direction: "next" | "prev") => {
|
||||||
const rows = getCurrentRows();
|
const rows = getCurrentRows();
|
||||||
if (!rows.length) return;
|
if (!rows.length) return;
|
||||||
selectedRowIndex += direction === "prev" ? -1 : 1;
|
const currentIndex = wrapIndex(selectedRowIndex, rows.length);
|
||||||
selectedRowIndex = wrapIndex(selectedRowIndex, rows.length);
|
const nextIndex = direction === "prev"
|
||||||
|
? Math.max(0, currentIndex - 1)
|
||||||
|
: Math.min(rows.length - 1, currentIndex + 1);
|
||||||
|
selectionDirection = direction;
|
||||||
|
if (nextIndex === currentIndex) {
|
||||||
|
selectedRowIndex = currentIndex;
|
||||||
|
syncSelectedRow();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
selectedRowIndex = nextIndex;
|
||||||
syncSelectedRow();
|
syncSelectedRow();
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -153,7 +163,7 @@ export function useMapHoverPopup({
|
|||||||
if (event.repeat) return;
|
if (event.repeat) return;
|
||||||
if (isEditableEventTarget(event.target)) return;
|
if (isEditableEventTarget(event.target)) return;
|
||||||
selectionVisible = true;
|
selectionVisible = true;
|
||||||
updatePopupRowSelection(popup, selectedRowIndex, selectionVisible);
|
updatePopupRowSelection(popup, selectedRowIndex, selectionVisible, selectionDirection);
|
||||||
requestPopupUpdateFromLastMouseEvent();
|
requestPopupUpdateFromLastMouseEvent();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -488,7 +498,7 @@ function buildPopupNode(content: MapHoverPopupContent, selectedRowIndex: number,
|
|||||||
grid.appendChild(card);
|
grid.appendChild(card);
|
||||||
});
|
});
|
||||||
|
|
||||||
updatePopupNodeRowSelection(root, selectedRowIndex, selectionVisible);
|
updatePopupNodeRowSelection(root, selectedRowIndex, selectionVisible, null);
|
||||||
|
|
||||||
return root;
|
return root;
|
||||||
}
|
}
|
||||||
@@ -598,11 +608,21 @@ function wrapIndex(index: number, length: number): number {
|
|||||||
return ((index % length) + length) % length;
|
return ((index % length) + length) % length;
|
||||||
}
|
}
|
||||||
|
|
||||||
function updatePopupRowSelection(popup: maplibregl.Popup, selectedRowIndex: number, selectionVisible: boolean) {
|
function updatePopupRowSelection(
|
||||||
updatePopupNodeRowSelection(popup.getElement() || null, selectedRowIndex, selectionVisible);
|
popup: maplibregl.Popup,
|
||||||
|
selectedRowIndex: number,
|
||||||
|
selectionVisible: boolean,
|
||||||
|
selectionDirection: "next" | "prev" | null
|
||||||
|
) {
|
||||||
|
updatePopupNodeRowSelection(popup.getElement() || null, selectedRowIndex, selectionVisible, selectionDirection);
|
||||||
}
|
}
|
||||||
|
|
||||||
function updatePopupNodeRowSelection(root: HTMLElement | null, selectedRowIndex: number, selectionVisible: boolean) {
|
function updatePopupNodeRowSelection(
|
||||||
|
root: HTMLElement | null,
|
||||||
|
selectedRowIndex: number,
|
||||||
|
selectionVisible: boolean,
|
||||||
|
selectionDirection: "next" | "prev" | null
|
||||||
|
) {
|
||||||
if (!root) return;
|
if (!root) return;
|
||||||
const cards = Array.from(root.querySelectorAll<HTMLElement>("[data-hover-popup-row-index]"));
|
const cards = Array.from(root.querySelectorAll<HTMLElement>("[data-hover-popup-row-index]"));
|
||||||
let selectedCard: HTMLElement | null = null;
|
let selectedCard: HTMLElement | null = null;
|
||||||
@@ -616,8 +636,8 @@ function updatePopupNodeRowSelection(root: HTMLElement | null, selectedRowIndex:
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (selectedCard) {
|
if (selectedCard) {
|
||||||
ensurePopupRowVisible(selectedCard);
|
ensurePopupRowVisible(selectedCard, selectionDirection);
|
||||||
window.requestAnimationFrame(() => ensurePopupRowVisible(selectedCard));
|
window.requestAnimationFrame(() => ensurePopupRowVisible(selectedCard, selectionDirection));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -627,20 +647,20 @@ function applyPopupRowStyle(card: HTMLElement, selected: boolean) {
|
|||||||
card.style.boxShadow = selected ? "inset 2px 0 0 rgba(56, 189, 248, 0.95)" : "none";
|
card.style.boxShadow = selected ? "inset 2px 0 0 rgba(56, 189, 248, 0.95)" : "none";
|
||||||
}
|
}
|
||||||
|
|
||||||
function ensurePopupRowVisible(card: HTMLElement) {
|
function ensurePopupRowVisible(card: HTMLElement, direction: "next" | "prev" | null) {
|
||||||
const scrollRoot = card.closest<HTMLElement>("[data-hover-popup-scroll-root='true']");
|
const scrollRoot = card.closest<HTMLElement>("[data-hover-popup-scroll-root='true']");
|
||||||
if (!scrollRoot) return;
|
if (!scrollRoot) return;
|
||||||
|
|
||||||
const padding = 8;
|
const padding = 8;
|
||||||
const groupHeader = findPreviousGroupHeader(card);
|
const groupHeader = direction === "prev" ? findPreviousGroupHeader(card) : null;
|
||||||
const cardTop = card.offsetTop;
|
const cardTop = card.offsetTop;
|
||||||
const cardBottom = cardTop + card.offsetHeight;
|
const cardBottom = cardTop + card.offsetHeight;
|
||||||
const contextTop = groupHeader?.offsetTop ?? cardTop;
|
const targetTop = groupHeader?.offsetTop ?? cardTop;
|
||||||
const visibleTop = scrollRoot.scrollTop + padding;
|
const visibleTop = scrollRoot.scrollTop + padding;
|
||||||
const visibleBottom = scrollRoot.scrollTop + scrollRoot.clientHeight - padding;
|
const visibleBottom = scrollRoot.scrollTop + scrollRoot.clientHeight - padding;
|
||||||
|
|
||||||
if (contextTop < visibleTop) {
|
if (targetTop < visibleTop) {
|
||||||
scrollRoot.scrollTop = Math.max(0, contextTop - padding);
|
scrollRoot.scrollTop = Math.max(0, targetTop - padding);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,99 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import { useEffect, useRef } from "react";
|
|
||||||
import type { Entity } from "@/uhm/api/entities";
|
|
||||||
import type { Wiki } from "@/uhm/api/wikis";
|
|
||||||
|
|
||||||
type PopupRow = {
|
|
||||||
entity: Entity;
|
|
||||||
wiki: Wiki | null;
|
|
||||||
quote: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
type Props = {
|
|
||||||
rows: PopupRow[];
|
|
||||||
featureId: string | number;
|
|
||||||
top: number;
|
|
||||||
left: number;
|
|
||||||
onClose: () => void;
|
|
||||||
onSelectRow: (entityId: string, wikiId?: string) => void;
|
|
||||||
};
|
|
||||||
|
|
||||||
export default function PinnedWikiPopup({
|
|
||||||
rows,
|
|
||||||
featureId,
|
|
||||||
top,
|
|
||||||
left,
|
|
||||||
onClose,
|
|
||||||
onSelectRow,
|
|
||||||
}: Props) {
|
|
||||||
const containerRef = useRef<HTMLDivElement | null>(null);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const handleKeyDown = (event: KeyboardEvent) => {
|
|
||||||
if (event.key === "Escape") {
|
|
||||||
onClose();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handlePointerDown = (event: PointerEvent) => {
|
|
||||||
const target = event.target as Node | null;
|
|
||||||
if (target && containerRef.current?.contains(target)) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
onClose();
|
|
||||||
};
|
|
||||||
|
|
||||||
window.addEventListener("keydown", handleKeyDown);
|
|
||||||
window.addEventListener("pointerdown", handlePointerDown);
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
window.removeEventListener("keydown", handleKeyDown);
|
|
||||||
window.removeEventListener("pointerdown", handlePointerDown);
|
|
||||||
};
|
|
||||||
}, [onClose]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
ref={containerRef}
|
|
||||||
className="absolute z-30 w-[320px] max-w-[calc(100vw-2rem)]"
|
|
||||||
style={{ left, top }}
|
|
||||||
>
|
|
||||||
<div className="overflow-hidden rounded-xl border border-white/10 bg-slate-950/95 shadow-xl backdrop-blur">
|
|
||||||
<div className="max-h-[300px] overflow-y-auto p-3">
|
|
||||||
<div className="grid gap-2">
|
|
||||||
{rows.map(({ entity, wiki, quote }) => (
|
|
||||||
<button
|
|
||||||
key={`${entity.id}:${wiki?.id || "entity-only"}`}
|
|
||||||
type="button"
|
|
||||||
onClick={() => {
|
|
||||||
onSelectRow(entity.id, wiki?.id);
|
|
||||||
}}
|
|
||||||
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 || String(entity.id)}
|
|
||||||
</div>
|
|
||||||
{quote ? (
|
|
||||||
<div
|
|
||||||
className="mt-2 pl-3 pr-1 text-sm italic leading-relaxed text-slate-300"
|
|
||||||
style={{
|
|
||||||
borderLeft: "3px solid rgba(56, 189, 248, 0.4)",
|
|
||||||
display: "-webkit-box",
|
|
||||||
WebkitLineClamp: 4,
|
|
||||||
WebkitBoxOrient: "vertical",
|
|
||||||
overflow: "hidden",
|
|
||||||
whiteSpace: "normal",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{quote}
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -10,7 +10,7 @@ import ReplayPreviewLayerPanel from "@/uhm/components/editor/ReplayPreviewLayerP
|
|||||||
import PublicWikiSidebar from "@/uhm/components/wiki/PublicWikiSidebar";
|
import PublicWikiSidebar from "@/uhm/components/wiki/PublicWikiSidebar";
|
||||||
import TimelineBar from "@/uhm/components/ui/TimelineBar";
|
import TimelineBar from "@/uhm/components/ui/TimelineBar";
|
||||||
import RelatedEntityPopup from "./RelatedEntityPopup";
|
import RelatedEntityPopup from "./RelatedEntityPopup";
|
||||||
import PinnedWikiPopup from "./PinnedWikiPopup";
|
import WikiSelectionPanel from "./WikiSelectionPanel";
|
||||||
import { fitMapToFeatureCollection } from "@/uhm/components/map/mapUtils";
|
import { fitMapToFeatureCollection } from "@/uhm/components/map/mapUtils";
|
||||||
|
|
||||||
import { fetchWikiById, type Wiki } from "@/uhm/api/wikis";
|
import { fetchWikiById, type Wiki } from "@/uhm/api/wikis";
|
||||||
@@ -104,7 +104,8 @@ const PreviewLayout = forwardRef<PreviewLayoutHandle, Props>(({
|
|||||||
// Preview specific UI states
|
// Preview specific UI states
|
||||||
const [previewWikiError, setPreviewWikiError] = useState<string | null>(null);
|
const [previewWikiError, setPreviewWikiError] = useState<string | null>(null);
|
||||||
const [isPreviewWikiLoading, setIsPreviewWikiLoading] = useState(false);
|
const [isPreviewWikiLoading, setIsPreviewWikiLoading] = useState(false);
|
||||||
const [previewPinnedWikiPopupAnchor, setPreviewPinnedWikiPopupAnchor] = useState<MapFeaturePayload | null>(null);
|
const [previewWikiSelectionPanelAnchor, setPreviewWikiSelectionPanelAnchor] = useState<MapFeaturePayload | null>(null);
|
||||||
|
const [previewRightPanelMode, setPreviewRightPanelMode] = useState<"wiki" | "selection" | null>(null);
|
||||||
const [isPreviewEntitySidebarOpen, setIsPreviewEntitySidebarOpen] = useState(false);
|
const [isPreviewEntitySidebarOpen, setIsPreviewEntitySidebarOpen] = useState(false);
|
||||||
const [previewLinkEntityPopup, setPreviewLinkEntityPopup] = useState<{
|
const [previewLinkEntityPopup, setPreviewLinkEntityPopup] = useState<{
|
||||||
slug: string;
|
slug: string;
|
||||||
@@ -122,7 +123,8 @@ const PreviewLayout = forwardRef<PreviewLayoutHandle, Props>(({
|
|||||||
setPreviewWikiCache({});
|
setPreviewWikiCache({});
|
||||||
setPreviewWikiError(null);
|
setPreviewWikiError(null);
|
||||||
setIsPreviewWikiLoading(false);
|
setIsPreviewWikiLoading(false);
|
||||||
setPreviewPinnedWikiPopupAnchor(null);
|
setPreviewWikiSelectionPanelAnchor(null);
|
||||||
|
setPreviewRightPanelMode(null);
|
||||||
setPreviewActiveEntityId(null);
|
setPreviewActiveEntityId(null);
|
||||||
setIsPreviewEntitySidebarOpen(false);
|
setIsPreviewEntitySidebarOpen(false);
|
||||||
setPreviewLinkEntityPopup(null);
|
setPreviewLinkEntityPopup(null);
|
||||||
@@ -317,7 +319,9 @@ const PreviewLayout = forwardRef<PreviewLayoutHandle, Props>(({
|
|||||||
: null;
|
: null;
|
||||||
|
|
||||||
|
|
||||||
const isReplayPreviewWikiSidebarOpen = mode && (replayPreviewSidebarOpen || isPreviewEntitySidebarOpen);
|
const isPreviewWikiChooserOpen = previewRightPanelMode === "selection" && Boolean(previewWikiSelectionPanelAnchor);
|
||||||
|
const isReplayPreviewWikiSidebarOpen = mode && !isPreviewWikiChooserOpen && (replayPreviewSidebarOpen || isPreviewEntitySidebarOpen);
|
||||||
|
const isReplayPreviewRightPanelOpen = isReplayPreviewWikiSidebarOpen || isPreviewWikiChooserOpen;
|
||||||
|
|
||||||
// Handle replay preview entity selection
|
// Handle replay preview entity selection
|
||||||
const selectReplayPreviewEntity = useCallback((
|
const selectReplayPreviewEntity = useCallback((
|
||||||
@@ -345,8 +349,9 @@ const PreviewLayout = forwardRef<PreviewLayoutHandle, Props>(({
|
|||||||
|
|
||||||
setPreviewActiveEntityId(id);
|
setPreviewActiveEntityId(id);
|
||||||
setIsPreviewEntitySidebarOpen(true);
|
setIsPreviewEntitySidebarOpen(true);
|
||||||
|
setPreviewRightPanelMode("wiki");
|
||||||
setPreviewWikiError(null);
|
setPreviewWikiError(null);
|
||||||
setPreviewPinnedWikiPopupAnchor(null);
|
setPreviewWikiSelectionPanelAnchor(null);
|
||||||
setPreviewLinkEntityPopup(null);
|
setPreviewLinkEntityPopup(null);
|
||||||
|
|
||||||
if (options?.focusMap === true) {
|
if (options?.focusMap === true) {
|
||||||
@@ -368,6 +373,7 @@ const PreviewLayout = forwardRef<PreviewLayoutHandle, Props>(({
|
|||||||
closeReplayPreviewWikiPanel();
|
closeReplayPreviewWikiPanel();
|
||||||
setPreviewActiveEntityId(null);
|
setPreviewActiveEntityId(null);
|
||||||
setIsPreviewEntitySidebarOpen(false);
|
setIsPreviewEntitySidebarOpen(false);
|
||||||
|
setPreviewRightPanelMode(null);
|
||||||
setPreviewWikiError(null);
|
setPreviewWikiError(null);
|
||||||
setPreviewLinkEntityPopup(null);
|
setPreviewLinkEntityPopup(null);
|
||||||
}, [closeReplayPreviewWikiPanel, setPreviewActiveEntityId]);
|
}, [closeReplayPreviewWikiPanel, setPreviewActiveEntityId]);
|
||||||
@@ -396,7 +402,8 @@ const PreviewLayout = forwardRef<PreviewLayoutHandle, Props>(({
|
|||||||
setPreviewLinkEntityPopup(null);
|
setPreviewLinkEntityPopup(null);
|
||||||
|
|
||||||
if (!payload) {
|
if (!payload) {
|
||||||
setPreviewPinnedWikiPopupAnchor(null);
|
setPreviewWikiSelectionPanelAnchor(null);
|
||||||
|
setPreviewRightPanelMode(null);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -406,15 +413,12 @@ const PreviewLayout = forwardRef<PreviewLayoutHandle, Props>(({
|
|||||||
if (!entity) return [];
|
if (!entity) return [];
|
||||||
|
|
||||||
const linkedWikis = previewRelations.entityWikisById[entity.id] || [];
|
const linkedWikis = previewRelations.entityWikisById[entity.id] || [];
|
||||||
if (!linkedWikis.length) {
|
|
||||||
return [{ entity, wiki: null as Wiki | null }];
|
|
||||||
}
|
|
||||||
|
|
||||||
return linkedWikis.map((wiki) => ({ entity, wiki }));
|
return linkedWikis.map((wiki) => ({ entity, wiki }));
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!rows.length) {
|
if (!rows.length) {
|
||||||
setPreviewPinnedWikiPopupAnchor(null);
|
setPreviewWikiSelectionPanelAnchor(null);
|
||||||
|
setPreviewRightPanelMode(null);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -422,20 +426,28 @@ const PreviewLayout = forwardRef<PreviewLayoutHandle, Props>(({
|
|||||||
const row = rows[0];
|
const row = rows[0];
|
||||||
selectReplayPreviewEntity(row.entity.id, {
|
selectReplayPreviewEntity(row.entity.id, {
|
||||||
sourceFeatureId: payload.featureId,
|
sourceFeatureId: payload.featureId,
|
||||||
preferredWikiId: row.wiki?.id,
|
preferredWikiId: row.wiki.id,
|
||||||
focusMap: false,
|
focusMap: false,
|
||||||
selectGeometry: false,
|
selectGeometry: false,
|
||||||
});
|
});
|
||||||
setPreviewPinnedWikiPopupAnchor(null);
|
setPreviewWikiSelectionPanelAnchor(null);
|
||||||
|
setPreviewRightPanelMode("wiki");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
setPreviewPinnedWikiPopupAnchor(payload);
|
closeReplayPreviewWikiPanel();
|
||||||
|
setPreviewActiveEntityId(null);
|
||||||
|
setIsPreviewEntitySidebarOpen(false);
|
||||||
|
setPreviewWikiError(null);
|
||||||
|
setPreviewWikiSelectionPanelAnchor(payload);
|
||||||
|
setPreviewRightPanelMode("selection");
|
||||||
}, [
|
}, [
|
||||||
|
closeReplayPreviewWikiPanel,
|
||||||
previewRelations.entitiesById,
|
previewRelations.entitiesById,
|
||||||
previewRelations.entityWikisById,
|
previewRelations.entityWikisById,
|
||||||
previewRelations.geometryEntityIds,
|
previewRelations.geometryEntityIds,
|
||||||
selectReplayPreviewEntity,
|
selectReplayPreviewEntity,
|
||||||
|
setPreviewActiveEntityId,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
// Hover popup content provider
|
// Hover popup content provider
|
||||||
@@ -617,7 +629,7 @@ const PreviewLayout = forwardRef<PreviewLayoutHandle, Props>(({
|
|||||||
setPreviewActiveEntityId(null);
|
setPreviewActiveEntityId(null);
|
||||||
setIsPreviewEntitySidebarOpen(true);
|
setIsPreviewEntitySidebarOpen(true);
|
||||||
setPreviewWikiError(null);
|
setPreviewWikiError(null);
|
||||||
setPreviewPinnedWikiPopupAnchor(null);
|
setPreviewWikiSelectionPanelAnchor(null);
|
||||||
setPreviewLinkEntityPopup(null);
|
setPreviewLinkEntityPopup(null);
|
||||||
openReplayPreviewWikiPanelById(wiki.id);
|
openReplayPreviewWikiPanelById(wiki.id);
|
||||||
}
|
}
|
||||||
@@ -628,7 +640,7 @@ const PreviewLayout = forwardRef<PreviewLayoutHandle, Props>(({
|
|||||||
}, [geometryVisibility]);
|
}, [geometryVisibility]);
|
||||||
|
|
||||||
const computedTimelineStyle = useMemo(() => {
|
const computedTimelineStyle = useMemo(() => {
|
||||||
const rightMargin = (isReplayPreviewWikiSidebarOpen && isLargeScreen)
|
const rightMargin = (isReplayPreviewRightPanelOpen && isLargeScreen)
|
||||||
? previewSidebarWidth + 32
|
? previewSidebarWidth + 32
|
||||||
: 18;
|
: 18;
|
||||||
return {
|
return {
|
||||||
@@ -636,31 +648,27 @@ const PreviewLayout = forwardRef<PreviewLayoutHandle, Props>(({
|
|||||||
right: `${rightMargin}px`,
|
right: `${rightMargin}px`,
|
||||||
transition: "right 0.3s cubic-bezier(0.4, 0, 0.2, 1), left 0.3s cubic-bezier(0.4, 0, 0.2, 1)",
|
transition: "right 0.3s cubic-bezier(0.4, 0, 0.2, 1), left 0.3s cubic-bezier(0.4, 0, 0.2, 1)",
|
||||||
};
|
};
|
||||||
}, [isReplayPreviewWikiSidebarOpen, isLargeScreen, previewSidebarWidth]);
|
}, [isReplayPreviewRightPanelOpen, isLargeScreen, previewSidebarWidth]);
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
// Popup PinnedWikiPopup rows
|
// WikiSelectionPanel rows
|
||||||
const previewPinnedWikiPopupRows = useMemo(() => {
|
const previewWikiSelectionPanelRows = useMemo(() => {
|
||||||
if (!previewPinnedWikiPopupAnchor) return [];
|
if (!previewWikiSelectionPanelAnchor) return [];
|
||||||
|
|
||||||
const entityIds = previewRelations.geometryEntityIds[String(previewPinnedWikiPopupAnchor.featureId)] || [];
|
const entityIds = previewRelations.geometryEntityIds[String(previewWikiSelectionPanelAnchor.featureId)] || [];
|
||||||
return entityIds.flatMap((entityId) => {
|
return entityIds.flatMap((entityId) => {
|
||||||
const entity = previewRelations.entitiesById[entityId] || null;
|
const entity = previewRelations.entitiesById[entityId] || null;
|
||||||
if (!entity) return [];
|
if (!entity) return [];
|
||||||
|
|
||||||
const linkedWikis = previewRelations.entityWikisById[entity.id] || [];
|
const linkedWikis = previewRelations.entityWikisById[entity.id] || [];
|
||||||
if (!linkedWikis.length) {
|
|
||||||
return [{ entity, wiki: null as Wiki | null, quote: "" }];
|
|
||||||
}
|
|
||||||
|
|
||||||
return linkedWikis.map((wiki) => ({
|
return linkedWikis.map((wiki) => ({
|
||||||
entity,
|
entity,
|
||||||
wiki,
|
wiki,
|
||||||
quote: extractWikiBlockquoteText(wiki.content),
|
quote: cleanWikiPreviewQuote(wiki.preview_quote) || extractWikiBlockquoteText(wiki.content),
|
||||||
}));
|
}));
|
||||||
});
|
});
|
||||||
}, [previewPinnedWikiPopupAnchor, previewRelations]);
|
}, [previewWikiSelectionPanelAnchor, previewRelations]);
|
||||||
|
|
||||||
useImperativeHandle(ref, () => ({
|
useImperativeHandle(ref, () => ({
|
||||||
handleFeatureClick: handlePreviewMapFeatureClick,
|
handleFeatureClick: handlePreviewMapFeatureClick,
|
||||||
@@ -730,6 +738,39 @@ const PreviewLayout = forwardRef<PreviewLayoutHandle, Props>(({
|
|||||||
</aside>
|
</aside>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
|
{isPreviewWikiChooserOpen ? (
|
||||||
|
<aside
|
||||||
|
style={{
|
||||||
|
position: "absolute",
|
||||||
|
top: 16,
|
||||||
|
right: 16,
|
||||||
|
bottom: 16,
|
||||||
|
left: isLargeScreen ? "auto" : 16,
|
||||||
|
width: isLargeScreen ? `min(${previewSidebarWidth}px, calc(100vw - 2rem))` : "auto",
|
||||||
|
maxWidth: "calc(100vw - 2rem)",
|
||||||
|
zIndex: 20,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<WikiSelectionPanel
|
||||||
|
rows={previewWikiSelectionPanelRows}
|
||||||
|
onClose={() => {
|
||||||
|
setPreviewWikiSelectionPanelAnchor(null);
|
||||||
|
setPreviewRightPanelMode(null);
|
||||||
|
}}
|
||||||
|
onSelectRow={(entityId, wikiId) => {
|
||||||
|
setPreviewWikiSelectionPanelAnchor(null);
|
||||||
|
setPreviewRightPanelMode("wiki");
|
||||||
|
selectReplayPreviewEntity(entityId, {
|
||||||
|
sourceFeatureId: previewWikiSelectionPanelAnchor?.featureId,
|
||||||
|
preferredWikiId: wikiId,
|
||||||
|
focusMap: false,
|
||||||
|
selectGeometry: false,
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</aside>
|
||||||
|
) : null}
|
||||||
|
|
||||||
<aside
|
<aside
|
||||||
style={{
|
style={{
|
||||||
position: "absolute",
|
position: "absolute",
|
||||||
@@ -772,32 +813,6 @@ const PreviewLayout = forwardRef<PreviewLayoutHandle, Props>(({
|
|||||||
</div>
|
</div>
|
||||||
</aside>
|
</aside>
|
||||||
|
|
||||||
{previewPinnedWikiPopupAnchor && previewPinnedWikiPopupRows.length > 0 ? (
|
|
||||||
<PinnedWikiPopup
|
|
||||||
rows={previewPinnedWikiPopupRows}
|
|
||||||
featureId={previewPinnedWikiPopupAnchor.featureId}
|
|
||||||
top={clampNumber(
|
|
||||||
previewPinnedWikiPopupAnchor.point.y - 8,
|
|
||||||
16,
|
|
||||||
typeof window !== "undefined" ? window.innerHeight - 280 : previewPinnedWikiPopupAnchor.point.y - 8
|
|
||||||
)}
|
|
||||||
left={clampNumber(
|
|
||||||
previewPinnedWikiPopupAnchor.point.x + 18,
|
|
||||||
16,
|
|
||||||
typeof window !== "undefined" ? window.innerWidth - 340 : previewPinnedWikiPopupAnchor.point.x + 18
|
|
||||||
)}
|
|
||||||
onClose={() => setPreviewPinnedWikiPopupAnchor(null)}
|
|
||||||
onSelectRow={(entityId, wikiId) => {
|
|
||||||
selectReplayPreviewEntity(entityId, {
|
|
||||||
sourceFeatureId: previewPinnedWikiPopupAnchor.featureId,
|
|
||||||
preferredWikiId: wikiId,
|
|
||||||
focusMap: false,
|
|
||||||
selectGeometry: false,
|
|
||||||
});
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
{timelineBarVisible ? (
|
{timelineBarVisible ? (
|
||||||
<TimelineBar
|
<TimelineBar
|
||||||
year={activeTimelineYear}
|
year={activeTimelineYear}
|
||||||
@@ -847,21 +862,38 @@ export default PreviewLayout;
|
|||||||
function extractWikiBlockquoteText(content: string | null | undefined): string {
|
function extractWikiBlockquoteText(content: string | null | undefined): string {
|
||||||
if (!content) return "";
|
if (!content) return "";
|
||||||
|
|
||||||
const blockquoteMatch = content.match(/<blockquote[^>]*>([\s\S]*?)<\/blockquote>/i);
|
const decoded = decodeHtmlEntities(content);
|
||||||
|
const blockquoteMatch = decoded.match(/<blockquote[^>]*>([\s\S]*?)<\/blockquote>/i);
|
||||||
const rawText = blockquoteMatch?.[1]?.trim() || "";
|
const rawText = blockquoteMatch?.[1]?.trim() || "";
|
||||||
if (!rawText) return "";
|
if (!rawText) return "";
|
||||||
|
|
||||||
return rawText
|
return cleanWikiPlainText(rawText);
|
||||||
|
}
|
||||||
|
|
||||||
|
function cleanWikiPreviewQuote(raw: string | null | undefined): string {
|
||||||
|
const decoded = decodeHtmlEntities(String(raw || ""));
|
||||||
|
const blockquote = extractWikiBlockquoteText(decoded);
|
||||||
|
return cleanWikiPlainText(blockquote || decoded);
|
||||||
|
}
|
||||||
|
|
||||||
|
function cleanWikiPlainText(raw: string): string {
|
||||||
|
return decodeHtmlEntities(raw)
|
||||||
.replace(/<[^>]*>/g, "")
|
.replace(/<[^>]*>/g, "")
|
||||||
.replace(/ /gi, " ")
|
|
||||||
.replace(/\u00a0/g, " ")
|
.replace(/\u00a0/g, " ")
|
||||||
|
.replace(/\s+/g, " ")
|
||||||
|
.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
function decodeHtmlEntities(raw: string): string {
|
||||||
|
return raw
|
||||||
|
.replace(/ /gi, " ")
|
||||||
|
.replace(/ /gi, " ")
|
||||||
.replace(/&/gi, "&")
|
.replace(/&/gi, "&")
|
||||||
.replace(/</gi, "<")
|
.replace(/</gi, "<")
|
||||||
.replace(/>/gi, ">")
|
.replace(/>/gi, ">")
|
||||||
.replace(/"/gi, '"')
|
.replace(/"/gi, '"')
|
||||||
.replace(/'/g, "'")
|
.replace(/'/g, "'")
|
||||||
.replace(/\s+/g, " ")
|
.replace(/'/gi, "'");
|
||||||
.trim();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function getWikiHoverTitle(wiki: { title?: string | null } | null | undefined, fallbackTitle: string): string {
|
function getWikiHoverTitle(wiki: { title?: string | null } | null | undefined, fallbackTitle: string): string {
|
||||||
|
|||||||
@@ -9,9 +9,10 @@ const PreviewMapShell = dynamic(
|
|||||||
|
|
||||||
import ReplayPreviewOverlay from "@/uhm/components/editor/ReplayPreviewOverlay";
|
import ReplayPreviewOverlay from "@/uhm/components/editor/ReplayPreviewOverlay";
|
||||||
import MapPlaceholder from "@/uhm/components/preview/MapPlaceholder";
|
import MapPlaceholder from "@/uhm/components/preview/MapPlaceholder";
|
||||||
|
import WikiSelectionPanel from "@/uhm/components/preview/WikiSelectionPanel";
|
||||||
import { usePublicPreviewData } from "@/uhm/components/preview/hooks/usePublicPreviewData";
|
import { usePublicPreviewData } from "@/uhm/components/preview/hooks/usePublicPreviewData";
|
||||||
import { useReplayPreview } from "@/uhm/lib/replay/useReplayPreview";
|
import { useReplayPreview } from "@/uhm/lib/replay/useReplayPreview";
|
||||||
import type { MapHandle } from "@/uhm/components/Map";
|
import type { MapFeaturePayload, MapHandle } from "@/uhm/components/Map";
|
||||||
import { useRef, useMemo, useCallback, useState, useEffect } from "react";
|
import { useRef, useMemo, useCallback, useState, useEffect } from "react";
|
||||||
import { usePublicPreviewInteraction } from "@/uhm/components/preview/hooks/usePublicPreviewInteraction";
|
import { usePublicPreviewInteraction } from "@/uhm/components/preview/hooks/usePublicPreviewInteraction";
|
||||||
import PresentPlaceSearch, {
|
import PresentPlaceSearch, {
|
||||||
@@ -89,6 +90,8 @@ export default function PublicPreviewClientPage({
|
|||||||
const [isLargeScreen, setIsLargeScreen] = useState(false);
|
const [isLargeScreen, setIsLargeScreen] = useState(false);
|
||||||
const [loadInteractiveMap, setLoadInteractiveMap] = useState(false);
|
const [loadInteractiveMap, setLoadInteractiveMap] = useState(false);
|
||||||
const [isLayerPanelVisible, setIsLayerPanelVisible] = useState(true);
|
const [isLayerPanelVisible, setIsLayerPanelVisible] = useState(true);
|
||||||
|
const [wikiSelectionPanelAnchor, setWikiSelectionPanelAnchor] = useState<MapFeaturePayload | null>(null);
|
||||||
|
const [rightPanelMode, setRightPanelMode] = useState<"wiki" | "selection" | null>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (typeof window !== "undefined") {
|
if (typeof window !== "undefined") {
|
||||||
@@ -205,6 +208,7 @@ export default function PublicPreviewClientPage({
|
|||||||
selectWiki,
|
selectWiki,
|
||||||
handleWikiLinkRequest,
|
handleWikiLinkRequest,
|
||||||
closeWikiSidebar,
|
closeWikiSidebar,
|
||||||
|
closeWikiSidebarPreserveSelection,
|
||||||
setLinkEntityPopup,
|
setLinkEntityPopup,
|
||||||
isManualSidebarOpen,
|
isManualSidebarOpen,
|
||||||
} = usePublicPreviewInteraction({
|
} = usePublicPreviewInteraction({
|
||||||
@@ -375,6 +379,8 @@ export default function PublicPreviewClientPage({
|
|||||||
|
|
||||||
const handleFocusWiki = useCallback((wiki: Wiki) => {
|
const handleFocusWiki = useCallback((wiki: Wiki) => {
|
||||||
setFocusedPresentPlace(null);
|
setFocusedPresentPlace(null);
|
||||||
|
setWikiSelectionPanelAnchor(null);
|
||||||
|
setRightPanelMode("wiki");
|
||||||
selectWiki(wiki);
|
selectWiki(wiki);
|
||||||
|
|
||||||
// Focus geometries if any
|
// Focus geometries if any
|
||||||
@@ -390,6 +396,77 @@ export default function PublicPreviewClientPage({
|
|||||||
}
|
}
|
||||||
}, [relations.wikiEntityIdsById, relations.entityGeometriesById, selectWiki]);
|
}, [relations.wikiEntityIdsById, relations.entityGeometriesById, selectWiki]);
|
||||||
|
|
||||||
|
const handleCloseWikiSidebar = useCallback(() => {
|
||||||
|
setRightPanelMode(null);
|
||||||
|
setWikiSelectionPanelAnchor(null);
|
||||||
|
closeWikiSidebar();
|
||||||
|
}, [closeWikiSidebar]);
|
||||||
|
|
||||||
|
const wikiSelectionPanelRows = useMemo(() => {
|
||||||
|
if (!wikiSelectionPanelAnchor) return [];
|
||||||
|
|
||||||
|
const entityIds = relations.geometryEntityIds[String(wikiSelectionPanelAnchor.featureId)] || [];
|
||||||
|
return entityIds.flatMap((entityId) => {
|
||||||
|
const entity = relations.entitiesById[entityId] || null;
|
||||||
|
if (!entity) return [];
|
||||||
|
|
||||||
|
const linkedWikis = relations.entityWikisById[entity.id] || [];
|
||||||
|
return linkedWikis.map((wiki) => ({
|
||||||
|
entity,
|
||||||
|
wiki,
|
||||||
|
quote: cleanWikiPreviewQuote(wiki.preview_quote) || extractWikiBlockquoteText(wiki.content),
|
||||||
|
}));
|
||||||
|
});
|
||||||
|
}, [wikiSelectionPanelAnchor, relations.entitiesById, relations.entityWikisById, relations.geometryEntityIds]);
|
||||||
|
|
||||||
|
const handleMapFeatureClick = useCallback((payload: MapFeaturePayload | null) => {
|
||||||
|
setLinkEntityPopup(null);
|
||||||
|
|
||||||
|
if (!payload) {
|
||||||
|
setWikiSelectionPanelAnchor(null);
|
||||||
|
setRightPanelMode(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const entityIds = relations.geometryEntityIds[String(payload.featureId)] || [];
|
||||||
|
const rows = entityIds.flatMap((entityId) => {
|
||||||
|
const entity = relations.entitiesById[entityId] || null;
|
||||||
|
if (!entity) return [];
|
||||||
|
|
||||||
|
const linkedWikis = relations.entityWikisById[entity.id] || [];
|
||||||
|
return linkedWikis.map((wiki) => ({ entity, wiki }));
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!rows.length) {
|
||||||
|
setWikiSelectionPanelAnchor(null);
|
||||||
|
setRightPanelMode(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (rows.length === 1) {
|
||||||
|
const row = rows[0];
|
||||||
|
selectEntity(row.entity.id, {
|
||||||
|
sourceFeatureId: payload.featureId,
|
||||||
|
preferredWikiSlug: row.wiki.slug,
|
||||||
|
selectGeometry: false,
|
||||||
|
});
|
||||||
|
setWikiSelectionPanelAnchor(null);
|
||||||
|
setRightPanelMode("wiki");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
closeWikiSidebarPreserveSelection();
|
||||||
|
setWikiSelectionPanelAnchor(payload);
|
||||||
|
setRightPanelMode("selection");
|
||||||
|
}, [
|
||||||
|
closeWikiSidebarPreserveSelection,
|
||||||
|
relations.entitiesById,
|
||||||
|
relations.entityWikisById,
|
||||||
|
relations.geometryEntityIds,
|
||||||
|
selectEntity,
|
||||||
|
setLinkEntityPopup,
|
||||||
|
]);
|
||||||
|
|
||||||
const filteredRenderDraft = useMemo(() => {
|
const filteredRenderDraft = useMemo(() => {
|
||||||
if (replayMode !== "playing" || !replayPreview.hiddenGeometryIds?.length) {
|
if (replayMode !== "playing" || !replayPreview.hiddenGeometryIds?.length) {
|
||||||
return renderDraft;
|
return renderDraft;
|
||||||
@@ -428,24 +505,26 @@ export default function PublicPreviewClientPage({
|
|||||||
return `Stage #${replayPreview.activeCursor.stageId} · Step ${replayPreview.activeCursor.stepIndex + 1}`;
|
return `Stage #${replayPreview.activeCursor.stageId} · Step ${replayPreview.activeCursor.stepIndex + 1}`;
|
||||||
}, [replayPreview.activeCursor.stageId, replayPreview.activeCursor.stepIndex]);
|
}, [replayPreview.activeCursor.stageId, replayPreview.activeCursor.stepIndex]);
|
||||||
|
|
||||||
|
const isWikiChooserOpen = rightPanelMode === "selection" && Boolean(wikiSelectionPanelAnchor);
|
||||||
const isSidebarOpen = replayMode === "playing"
|
const isSidebarOpen = replayMode === "playing"
|
||||||
? (replayPreview.sidebarOpen || isManualSidebarOpen)
|
? (replayPreview.sidebarOpen || isManualSidebarOpen)
|
||||||
: Boolean(activeEntity || activeWiki || isManualSidebarOpen);
|
: Boolean(activeEntity || activeWiki || isManualSidebarOpen);
|
||||||
|
|
||||||
const displayedActiveEntity = isSidebarOpen ? activeEntity : null;
|
const displayedActiveEntity = rightPanelMode !== "selection" && isSidebarOpen ? activeEntity : null;
|
||||||
const displayedActiveWiki = isSidebarOpen ? activeWiki : null;
|
const displayedActiveWiki = rightPanelMode !== "selection" && isSidebarOpen ? activeWiki : null;
|
||||||
|
|
||||||
const computedTimelineStyle = useMemo(() => {
|
const computedTimelineStyle = useMemo(() => {
|
||||||
const leftMargin = isLayerPanelVisible ? 88 : 18;
|
const leftMargin = isLayerPanelVisible ? 88 : 18;
|
||||||
const rightMargin = ((displayedActiveEntity || displayedActiveWiki) && isLargeScreen) ? sidebarWidth + 32 : 18;
|
const rightPanelOpen = Boolean(displayedActiveEntity || displayedActiveWiki || isWikiChooserOpen);
|
||||||
const bottomOffset = ((displayedActiveEntity || displayedActiveWiki) && !isLargeScreen) ? `${sidebarHeight + 16}px` : undefined;
|
const rightMargin = (rightPanelOpen && isLargeScreen) ? sidebarWidth + 32 : 18;
|
||||||
|
const bottomOffset = (rightPanelOpen && !isLargeScreen) ? `${sidebarHeight + 16}px` : undefined;
|
||||||
return {
|
return {
|
||||||
left: `${leftMargin}px`,
|
left: `${leftMargin}px`,
|
||||||
right: `${rightMargin}px`,
|
right: `${rightMargin}px`,
|
||||||
bottom: bottomOffset,
|
bottom: bottomOffset,
|
||||||
transition: "right 0.3s cubic-bezier(0.4, 0, 0.2, 1), left 0.3s cubic-bezier(0.4, 0, 0.2, 1), bottom 0.3s cubic-bezier(0.4, 0, 0.2, 1)",
|
transition: "right 0.3s cubic-bezier(0.4, 0, 0.2, 1), left 0.3s cubic-bezier(0.4, 0, 0.2, 1), bottom 0.3s cubic-bezier(0.4, 0, 0.2, 1)",
|
||||||
};
|
};
|
||||||
}, [isLayerPanelVisible, displayedActiveEntity, displayedActiveWiki, isLargeScreen, sidebarWidth, sidebarHeight]);
|
}, [isLayerPanelVisible, displayedActiveEntity, displayedActiveWiki, isWikiChooserOpen, isLargeScreen, sidebarWidth, sidebarHeight]);
|
||||||
|
|
||||||
const searchBarWidth = useMemo(() => {
|
const searchBarWidth = useMemo(() => {
|
||||||
if (isLargeScreen) {
|
if (isLargeScreen) {
|
||||||
@@ -513,13 +592,14 @@ export default function PublicPreviewClientPage({
|
|||||||
isTimelineLoading={isTimelineLoading || isRelationsLoading}
|
isTimelineLoading={isTimelineLoading || isRelationsLoading}
|
||||||
timelineStatusText={relationsStatus || timelineStatus}
|
timelineStatusText={relationsStatus || timelineStatus}
|
||||||
timelineStyle={computedTimelineStyle}
|
timelineStyle={computedTimelineStyle}
|
||||||
|
onFeatureClick={handleMapFeatureClick}
|
||||||
hoverPopupEnabled
|
hoverPopupEnabled
|
||||||
getHoverPopupContent={getHoverPopupContent}
|
getHoverPopupContent={getHoverPopupContent}
|
||||||
activeEntity={displayedActiveEntity}
|
activeEntity={displayedActiveEntity}
|
||||||
activeWiki={displayedActiveWiki}
|
activeWiki={displayedActiveWiki}
|
||||||
isWikiLoading={isActiveWikiLoading}
|
isWikiLoading={isActiveWikiLoading}
|
||||||
wikiError={activeWikiError}
|
wikiError={activeWikiError}
|
||||||
onCloseWikiSidebar={closeWikiSidebar}
|
onCloseWikiSidebar={handleCloseWikiSidebar}
|
||||||
onWikiLinkRequest={handleWikiLinkRequest}
|
onWikiLinkRequest={handleWikiLinkRequest}
|
||||||
sidebarWidth={sidebarWidth}
|
sidebarWidth={sidebarWidth}
|
||||||
onSidebarWidthChange={setSidebarWidth}
|
onSidebarWidthChange={setSidebarWidth}
|
||||||
@@ -604,6 +684,8 @@ export default function PublicPreviewClientPage({
|
|||||||
key={entity.id}
|
key={entity.id}
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
|
setWikiSelectionPanelAnchor(null);
|
||||||
|
setRightPanelMode("wiki");
|
||||||
selectEntity(entity.id, { preferredWikiSlug: linkEntityPopup.slug });
|
selectEntity(entity.id, { preferredWikiSlug: linkEntityPopup.slug });
|
||||||
setLinkEntityPopup(null);
|
setLinkEntityPopup(null);
|
||||||
}}
|
}}
|
||||||
@@ -616,6 +698,76 @@ export default function PublicPreviewClientPage({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
|
{isWikiChooserOpen ? (
|
||||||
|
<aside
|
||||||
|
className={isLargeScreen ? "fixed bottom-4 right-4 top-4 left-auto z-20 max-w-[calc(100vw-2rem)]" : "fixed bottom-0 left-0 right-0 top-auto z-20"}
|
||||||
|
style={isLargeScreen ? {
|
||||||
|
width: `min(${sidebarWidth}px, calc(100vw - 2rem))`,
|
||||||
|
} : {
|
||||||
|
height: `${sidebarHeight || 400}px`,
|
||||||
|
maxHeight: "90vh",
|
||||||
|
width: "100%",
|
||||||
|
maxWidth: "100%",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<WikiSelectionPanel
|
||||||
|
rows={wikiSelectionPanelRows}
|
||||||
|
onClose={() => {
|
||||||
|
setWikiSelectionPanelAnchor(null);
|
||||||
|
setRightPanelMode(null);
|
||||||
|
}}
|
||||||
|
onSelectRow={(entityId, wikiId) => {
|
||||||
|
const wiki = wikiSelectionPanelRows.find((row) => row.entity.id === entityId && row.wiki.id === wikiId)?.wiki || null;
|
||||||
|
const sourceFeatureId = wikiSelectionPanelAnchor?.featureId ?? null;
|
||||||
|
setWikiSelectionPanelAnchor(null);
|
||||||
|
setRightPanelMode("wiki");
|
||||||
|
selectEntity(entityId, {
|
||||||
|
sourceFeatureId,
|
||||||
|
preferredWikiSlug: wiki?.slug,
|
||||||
|
selectGeometry: false,
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</aside>
|
||||||
|
) : null}
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function cleanWikiPreviewQuote(raw: string | null | undefined): string {
|
||||||
|
const decoded = decodeHtmlEntities(String(raw || ""));
|
||||||
|
const blockquote = extractWikiBlockquoteText(decoded);
|
||||||
|
return cleanWikiPlainText(blockquote || decoded);
|
||||||
|
}
|
||||||
|
|
||||||
|
function extractWikiBlockquoteText(content: string | null | undefined): string {
|
||||||
|
if (!content) return "";
|
||||||
|
|
||||||
|
const decoded = decodeHtmlEntities(content);
|
||||||
|
const blockquoteMatch = decoded.match(/<blockquote[^>]*>([\s\S]*?)<\/blockquote>/i);
|
||||||
|
const rawText = blockquoteMatch?.[1]?.trim() || "";
|
||||||
|
if (!rawText) return "";
|
||||||
|
|
||||||
|
return cleanWikiPlainText(rawText);
|
||||||
|
}
|
||||||
|
|
||||||
|
function cleanWikiPlainText(raw: string): string {
|
||||||
|
return decodeHtmlEntities(raw)
|
||||||
|
.replace(/<[^>]*>/g, "")
|
||||||
|
.replace(/\u00a0/g, " ")
|
||||||
|
.replace(/\s+/g, " ")
|
||||||
|
.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
function decodeHtmlEntities(raw: string): string {
|
||||||
|
return raw
|
||||||
|
.replace(/ /gi, " ")
|
||||||
|
.replace(/ /gi, " ")
|
||||||
|
.replace(/&/gi, "&")
|
||||||
|
.replace(/</gi, "<")
|
||||||
|
.replace(/>/gi, ">")
|
||||||
|
.replace(/"/gi, '"')
|
||||||
|
.replace(/'/g, "'")
|
||||||
|
.replace(/'/gi, "'");
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,230 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect, useRef } from "react";
|
||||||
|
import type { Entity } from "@/uhm/api/entities";
|
||||||
|
import type { Wiki } from "@/uhm/api/wikis";
|
||||||
|
|
||||||
|
type WikiSelectionRow = {
|
||||||
|
entity: Entity;
|
||||||
|
wiki: Wiki;
|
||||||
|
quote: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
rows: WikiSelectionRow[];
|
||||||
|
onClose: () => void;
|
||||||
|
onSelectRow: (entityId: string, wikiId: string) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function WikiSelectionPanel({
|
||||||
|
rows,
|
||||||
|
onClose,
|
||||||
|
onSelectRow,
|
||||||
|
}: 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",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Wiki
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
marginTop: 4,
|
||||||
|
fontSize: 18,
|
||||||
|
fontWeight: 700,
|
||||||
|
lineHeight: 1.3,
|
||||||
|
color: "#f8fafc",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Chọn wiki để mở
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onClose}
|
||||||
|
style={{
|
||||||
|
display: "inline-flex",
|
||||||
|
height: 28,
|
||||||
|
width: 28,
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
borderRadius: "50%",
|
||||||
|
border: "1px solid rgba(148, 163, 184, 0.25)",
|
||||||
|
background: "rgba(30, 41, 59, 0.4)",
|
||||||
|
color: "#94a3b8",
|
||||||
|
cursor: "pointer",
|
||||||
|
fontSize: 12,
|
||||||
|
transition: "all 0.2s",
|
||||||
|
outline: "none",
|
||||||
|
}}
|
||||||
|
className="hover:bg-slate-700/50 hover:text-slate-100"
|
||||||
|
aria-label="Close wiki chooser"
|
||||||
|
>
|
||||||
|
x
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="uhm-pinned-wiki-panel-scroll" style={{ flex: 1, minHeight: 0, overflowY: "auto", padding: 16 }}>
|
||||||
|
<div style={{ display: "grid", gap: 10 }}>
|
||||||
|
{rows.map(({ entity, wiki, quote }, index) => {
|
||||||
|
const previous = rows[index - 1];
|
||||||
|
const startsEntityGroup = !previous || previous.entity.id !== entity.id;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div key={`${entity.id}:${wiki.id}`}>
|
||||||
|
{startsEntityGroup ? (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
paddingTop: index > 0 ? 12 : 0,
|
||||||
|
marginTop: index > 0 ? 4 : 0,
|
||||||
|
borderTop: index > 0 ? "1px solid rgba(148, 163, 184, 0.16)" : "none",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div style={{ fontSize: 14, fontWeight: 800, color: "#f8fafc", lineHeight: "20px" }}>
|
||||||
|
{entity.name || String(entity.id)}
|
||||||
|
</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}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => onSelectRow(entity.id, wiki.id)}
|
||||||
|
style={{
|
||||||
|
width: "100%",
|
||||||
|
marginTop: 6,
|
||||||
|
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: 13,
|
||||||
|
fontWeight: 800,
|
||||||
|
lineHeight: "19px",
|
||||||
|
color: "#e2e8f0",
|
||||||
|
overflow: "hidden",
|
||||||
|
textOverflow: "ellipsis",
|
||||||
|
whiteSpace: "nowrap",
|
||||||
|
minWidth: 0,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{wiki.title?.trim() || entity.name || String(wiki.id)}
|
||||||
|
</div>
|
||||||
|
{quote ? (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
marginTop: 6,
|
||||||
|
paddingLeft: 10,
|
||||||
|
borderLeft: "2px solid rgba(56, 189, 248, 0.48)",
|
||||||
|
fontSize: 12.5,
|
||||||
|
fontStyle: "italic",
|
||||||
|
lineHeight: "18px",
|
||||||
|
color: "#cbd5e1",
|
||||||
|
display: "-webkit-box",
|
||||||
|
WebkitLineClamp: 4,
|
||||||
|
WebkitBoxOrient: "vertical",
|
||||||
|
overflow: "hidden",
|
||||||
|
whiteSpace: "normal",
|
||||||
|
overflowWrap: "anywhere",
|
||||||
|
wordBreak: "break-word",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{quote}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style jsx>{`
|
||||||
|
.uhm-pinned-wiki-panel-scroll {
|
||||||
|
scrollbar-width: thin;
|
||||||
|
scrollbar-color: rgba(56, 189, 248, 0.58) rgba(15, 23, 42, 0.72);
|
||||||
|
}
|
||||||
|
.uhm-pinned-wiki-panel-scroll::-webkit-scrollbar {
|
||||||
|
width: 9px;
|
||||||
|
}
|
||||||
|
.uhm-pinned-wiki-panel-scroll::-webkit-scrollbar-track {
|
||||||
|
background: rgba(15, 23, 42, 0.72);
|
||||||
|
}
|
||||||
|
.uhm-pinned-wiki-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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -217,12 +217,15 @@ export function usePublicPreviewInteraction(options: {
|
|||||||
|
|
||||||
const onlyEntityId = linkedEntityIds[0];
|
const onlyEntityId = linkedEntityIds[0];
|
||||||
if (activeEntityId === onlyEntityId) return;
|
if (activeEntityId === onlyEntityId) return;
|
||||||
|
const linkedWikis = relations.entityWikisById[onlyEntityId] || [];
|
||||||
|
if (linkedWikis.length !== 1) return;
|
||||||
|
|
||||||
selectEntity(onlyEntityId, {
|
selectEntity(onlyEntityId, {
|
||||||
sourceFeatureId: selectedFeatureIds[0],
|
sourceFeatureId: selectedFeatureIds[0],
|
||||||
|
preferredWikiSlug: linkedWikis[0]?.slug,
|
||||||
selectGeometry: false,
|
selectGeometry: false,
|
||||||
});
|
});
|
||||||
}, [activeEntityId, relations.geometryEntityIds, selectEntity, selectedFeatureIds]);
|
}, [activeEntityId, relations.entityWikisById, relations.geometryEntityIds, selectEntity, selectedFeatureIds]);
|
||||||
|
|
||||||
const loadHoverWikiPreviewForEntity = useCallback(async (entityId: string) => {
|
const loadHoverWikiPreviewForEntity = useCallback(async (entityId: string) => {
|
||||||
try {
|
try {
|
||||||
@@ -520,6 +523,14 @@ export function usePublicPreviewInteraction(options: {
|
|||||||
setIsManualSidebarOpen(false);
|
setIsManualSidebarOpen(false);
|
||||||
}, [setSelectedFeatureIds]);
|
}, [setSelectedFeatureIds]);
|
||||||
|
|
||||||
|
const closeWikiSidebarPreserveSelection = useCallback(() => {
|
||||||
|
setActiveEntityId(null);
|
||||||
|
setActiveWikiSlug(null);
|
||||||
|
setActiveWikiError(null);
|
||||||
|
setLinkEntityPopup(null);
|
||||||
|
setIsManualSidebarOpen(false);
|
||||||
|
}, []);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
activeEntity,
|
activeEntity,
|
||||||
activeWiki,
|
activeWiki,
|
||||||
@@ -532,6 +543,7 @@ export function usePublicPreviewInteraction(options: {
|
|||||||
selectWiki,
|
selectWiki,
|
||||||
handleWikiLinkRequest,
|
handleWikiLinkRequest,
|
||||||
closeWikiSidebar,
|
closeWikiSidebar,
|
||||||
|
closeWikiSidebarPreserveSelection,
|
||||||
setLinkEntityPopup,
|
setLinkEntityPopup,
|
||||||
isManualSidebarOpen,
|
isManualSidebarOpen,
|
||||||
setIsManualSidebarOpen,
|
setIsManualSidebarOpen,
|
||||||
|
|||||||
@@ -441,31 +441,8 @@ function PublicWikiSidebar({
|
|||||||
color: "#f8fafc",
|
color: "#f8fafc",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{entity?.name?.trim() || wiki?.title?.trim() || "Wiki"}
|
{wiki?.title?.trim() || entity?.name?.trim() || "Wiki"}
|
||||||
</div>
|
</div>
|
||||||
{entity?.description?.trim() ? (
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
marginTop: 8,
|
|
||||||
fontSize: 13,
|
|
||||||
lineHeight: 1.5,
|
|
||||||
color: "#cbd5e1",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{entity.description.trim()}
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
{!compactHeader && wiki?.title?.trim() && wiki.title.trim() !== entity?.name?.trim() ? (
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
marginTop: 6,
|
|
||||||
fontSize: 12,
|
|
||||||
color: "#94a3b8",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{wiki.title.trim()}
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
|
|||||||
Reference in New Issue
Block a user