Files
History-user/src/uhm/components/preview/PreviewMapShell.tsx
T
2026-06-04 18:49:58 +07:00

493 lines
23 KiB
TypeScript

"use client";
import Image from "next/image";
import { type CSSProperties, type ReactNode, useState, useEffect } from "react";
import { apiGetCurrentUser } from "@/service/auth";
import ChatbotWidget from "@/components/ui/chat/ChatbotWidget";
import Map, { type MapFeaturePayload } from "@/uhm/components/Map";
import ReplayPreviewLayerPanel from "@/uhm/components/editor/ReplayPreviewLayerPanel";
import PublicWikiSidebar from "@/uhm/components/wiki/PublicWikiSidebar";
import TimelineBar from "@/uhm/components/ui/TimelineBar";
import type { MapHoverPopupContent } from "@/uhm/components/map/useMapHoverPopup";
import type { Entity } from "@/uhm/api/entities";
import type { Wiki } from "@/uhm/api/wikis";
import type { BackgroundLayerId, BackgroundLayerVisibility } from "@/uhm/lib/map/styles/backgroundLayers";
import type { FeatureCollection } from "@/uhm/types/geo";
import type { Feature } from "@/uhm/lib/editor/state/useEditorState";
type Props = {
renderDraft: FeatureCollection;
labelContextDraft: FeatureCollection;
labelTimelineYear?: number | null;
selectedFeatureIds: (string | number)[];
onSelectFeatureIds: (ids: (string | number)[]) => void;
backgroundVisibility: BackgroundLayerVisibility;
geometryVisibility: Record<string, boolean>;
onToggleBackground: (id: BackgroundLayerId) => void;
onToggleGeometry: (typeKey: string) => void;
timelineYear: number;
onTimelineYearChange: (year: number) => void;
timelineTimeRange?: number;
onTimelineTimeRangeChange?: (range: number) => void;
timelineFilterEnabled?: boolean;
onTimelineFilterEnabledChange?: (enabled: boolean) => void;
isTimelineLoading: boolean;
timelineDisabled?: boolean;
timelineStatusText?: string | null;
timelineStyle?: CSSProperties;
onFeatureClick?: (payload: MapFeaturePayload | null) => void;
hoverPopupEnabled?: boolean;
getHoverPopupContent?: (feature: Feature) => MapHoverPopupContent | null;
activeEntity?: Entity | null;
activeWiki?: Wiki | null;
isWikiLoading?: boolean;
wikiError?: string | null;
onCloseWikiSidebar?: () => void;
onWikiLinkRequest?: (request: { slug: string; rect: DOMRect }) => void;
sidebarWidth?: number;
onSidebarWidthChange?: (width: number) => void;
maxSidebarDragWidth?: number;
onPlayPreviewReplay?: () => void;
mapHandleRef?: React.RefObject<import("@/uhm/components/Map").MapHandle | null>;
overlay?: ReactNode;
children?: ReactNode;
onLoad?: () => void;
instantLoad?: boolean;
onToggleInstantLoad?: (val: boolean) => void;
isLayerPanelVisible?: boolean;
onLayerPanelVisibleChange?: (visible: boolean) => void;
sidebarHeight?: number;
onSidebarHeightChange?: (height: number) => void;
};
export default function PreviewMapShell({
renderDraft,
labelContextDraft,
labelTimelineYear,
selectedFeatureIds,
onSelectFeatureIds,
backgroundVisibility,
geometryVisibility,
onToggleBackground,
onToggleGeometry,
timelineYear,
onTimelineYearChange,
timelineTimeRange,
onTimelineTimeRangeChange,
timelineFilterEnabled,
onTimelineFilterEnabledChange,
isTimelineLoading,
timelineDisabled = false,
timelineStatusText = null,
timelineStyle,
onFeatureClick,
hoverPopupEnabled = false,
getHoverPopupContent,
activeEntity = null,
activeWiki = null,
isWikiLoading = false,
wikiError = null,
onCloseWikiSidebar,
onWikiLinkRequest,
sidebarWidth,
onSidebarWidthChange,
maxSidebarDragWidth,
onPlayPreviewReplay,
mapHandleRef,
overlay,
children,
onLoad,
instantLoad = true,
onToggleInstantLoad,
isLayerPanelVisible: propsLayerPanelVisible,
onLayerPanelVisibleChange,
sidebarHeight,
onSidebarHeightChange,
}: Props) {
const [isMenuOpen, setIsMenuOpen] = useState(false);
const [avatarUrl, setAvatarUrl] = useState<string | null>(null);
const [localLayerPanelVisible, setLocalLayerPanelVisible] = useState(true);
const isLayerPanelVisible = propsLayerPanelVisible ?? localLayerPanelVisible;
const setIsLayerPanelVisible = onLayerPanelVisibleChange ?? setLocalLayerPanelVisible;
const [isMobileOrTablet, setIsMobileOrTablet] = useState(false);
useEffect(() => {
const checkDevice = () => setIsMobileOrTablet(window.innerWidth < 1024);
checkDevice();
window.addEventListener("resize", checkDevice);
return () => window.removeEventListener("resize", checkDevice);
}, []);
useEffect(() => {
const fetchUserAvatar = async () => {
try {
const userData = await apiGetCurrentUser({ skipRefresh: true } as any);
console.log("PreviewMapShell: Fetched userData:", userData);
if (userData?.data?.profile?.avatar_url) {
console.log("PreviewMapShell: Setting avatarUrl to:", userData.data.profile.avatar_url);
setAvatarUrl(userData.data.profile.avatar_url);
} else {
console.log("PreviewMapShell: No avatar_url in profile:", userData?.data?.profile);
}
} catch (err) {
console.log("PreviewMapShell: No active session (user is guest)");
}
};
fetchUserAvatar();
}, []);
const menuOptionStyle: CSSProperties = {
width: 46,
height: 46,
backgroundColor: "#1e293b",
color: "#cbd5e1",
border: "1px solid rgba(255, 255, 255, 0.08)",
borderRadius: 12,
display: "flex",
alignItems: "center",
justifyContent: "center",
cursor: "pointer",
transition: "all 0.2s ease",
boxShadow: "0 2px 8px rgba(0, 0, 0, 0.12)",
backdropFilter: "blur(6px)",
};
return (
<div className="relative min-h-screen overflow-hidden bg-gray-950 text-gray-100">
<div className="relative min-h-screen">
<Map
ref={mapHandleRef}
mode="preview"
renderDraft={renderDraft}
labelContextDraft={labelContextDraft}
labelTimelineYear={labelTimelineYear}
selectedFeatureIds={selectedFeatureIds}
onSelectFeatureIds={onSelectFeatureIds}
backgroundVisibility={backgroundVisibility}
geometryVisibility={geometryVisibility}
allowGeometryEditing={false}
allowFeatureSelection
applyGeometryBindingFilter
isPreviewMode
onFeatureClick={onFeatureClick}
hoverPopupEnabled={hoverPopupEnabled}
getHoverPopupContent={getHoverPopupContent}
onPlayPreviewReplay={onPlayPreviewReplay}
onLoad={onLoad}
showViewportControls={false}
/>
<TimelineBar
year={timelineYear}
onYearChange={onTimelineYearChange}
timeRange={timelineTimeRange}
onTimeRangeChange={onTimelineTimeRangeChange}
isLoading={isTimelineLoading}
disabled={timelineDisabled}
statusText={timelineStatusText}
filterEnabled={timelineFilterEnabled}
onFilterEnabledChange={onTimelineFilterEnabledChange}
style={timelineStyle}
/>
<style dangerouslySetInnerHTML={{ __html: `
@keyframes slideDown {
from {
opacity: 0;
transform: translateY(-8px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
`}} />
<aside
style={{
position: "absolute",
top: 10,
bottom: (activeEntity && isMobileOrTablet) ? `${(sidebarHeight || 400) + 20}px` : 20,
left: 18,
zIndex: 18,
display: "flex",
flexDirection: "column",
gap: 12,
width: 58,
pointerEvents: "none",
transition: "bottom 0.3s cubic-bezier(0.4, 0, 0.2, 1)",
}}
>
<div
style={{
display: "flex",
flexDirection: "column",
gap: 8,
alignItems: "center",
pointerEvents: "auto",
}}
>
<button
type="button"
onClick={() => setIsMenuOpen(!isMenuOpen)}
title={isMenuOpen ? "Đóng cài đặt" : "Tham gia hệ thống / Trợ giúp"}
aria-label="Cài đặt"
style={{
width: 46,
height: 46,
backgroundColor: "#1e293b",
border: "1px solid rgba(255, 255, 255, 0.15)",
borderRadius: "50%",
display: "flex",
alignItems: "center",
justifyContent: "center",
cursor: "pointer",
transition: "all 0.2s ease",
boxShadow: "0 4px 12px rgba(0, 0, 0, 0.15)",
backdropFilter: "blur(8px)",
flexShrink: 0,
overflow: "hidden",
padding: 0,
}}
>
{avatarUrl ? (
<Image
src={avatarUrl}
alt="Cài đặt"
width={46}
height={46}
style={{
width: "100%",
height: "100%",
objectFit: "cover",
}}
/>
) : (
<svg
width="20"
height="20"
viewBox="0 0 24 24"
fill="none"
stroke="#cbd5e1"
strokeWidth="2.5"
strokeLinecap="round"
strokeLinejoin="round"
style={{ transition: "color 0.2s ease" }}
>
<path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2" />
<circle cx="12" cy="7" r="4" />
</svg>
)}
</button>
{isMenuOpen && (
<div
style={{
display: "flex",
flexDirection: "column",
gap: 8,
alignItems: "center",
animation: "slideDown 0.2s ease-out",
}}
>
<button
type="button"
onClick={() => {
if (isMobileOrTablet) {
alert("Tính năng quản trị và chỉnh sửa chỉ hỗ trợ trên máy tính (Desktop)");
} else {
window.location.href = "/user";
}
}}
title={isMobileOrTablet ? "Tính năng này chỉ hoạt động trên Desktop" : "Quản trị & Chỉnh sửa (Edit)"}
style={{
...menuOptionStyle,
opacity: isMobileOrTablet ? 0.5 : 1,
cursor: isMobileOrTablet ? "not-allowed" : "pointer",
}}
>
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M12 20h9M16.5 3.5a2.121 2.121 0 0 1 3 3L7 19l-4 1 1-4L16.5 3.5z" />
</svg>
</button>
<button
type="button"
onClick={() => onToggleInstantLoad?.(!instantLoad)}
title="Bật lên để load nhanh hơn"
style={{
...menuOptionStyle,
color: instantLoad ? "#fbbf24" : "#cbd5e1",
border: instantLoad ? "1px solid rgba(251, 191, 36, 0.4)" : "1px solid rgba(255, 255, 255, 0.08)",
boxShadow: instantLoad ? "0 0 12px rgba(251, 191, 36, 0.25)" : "0 2px 8px rgba(0, 0, 0, 0.12)",
}}
>
<svg width="18" height="18" viewBox="0 0 24 24" fill={instantLoad ? "#fbbf24" : "none"} stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<polygon points="13 2 3 14 12 14 11 22 21 10 12 10 13 2" />
</svg>
</button>
<button
type="button"
onClick={() => {
window.dispatchEvent(new CustomEvent("toggle-chatbot"));
}}
title="Trợ lý AI Lịch sử (Chatbot)"
style={menuOptionStyle}
>
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z" />
</svg>
</button>
<button
type="button"
onClick={() => { window.location.href = "/faq"; }}
title="Hỏi đáp & Hướng dẫn (FAQ)"
style={menuOptionStyle}
>
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M2 3h6a4 4 0 0 1 4 4v14a3 3 0 0 0-3-3H2zM22 3h-6a4 4 0 0 0-4 4v14a3 3 0 0 1 3-3h7z" />
</svg>
</button>
<button
type="button"
onClick={() => { window.location.href = "/about-us"; }}
title="Về chúng tôi (About Us)"
style={menuOptionStyle}
>
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<circle cx="12" cy="12" r="10" />
<line x1="12" y1="16" x2="12" y2="12" />
<line x1="12" y1="8" x2="12.01" y2="8" />
</svg>
</button>
{!isLayerPanelVisible && (
<button
type="button"
onClick={() => setIsLayerPanelVisible(true)}
title="Hiện bảng lớp bản đồ"
style={{
...menuOptionStyle,
color: "#10b981",
border: "1px solid rgba(16, 185, 129, 0.3)",
boxShadow: "0 2px 8px rgba(16, 185, 129, 0.15)",
}}
>
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z" />
<circle cx="12" cy="12" r="3" />
</svg>
</button>
)}
</div>
)}
</div>
{isLayerPanelVisible && (
<div
style={{
flexGrow: 1,
flexShrink: 1,
minHeight: 0,
display: "flex",
flexDirection: "column",
pointerEvents: "auto",
}}
>
<ReplayPreviewLayerPanel
backgroundVisibility={backgroundVisibility}
geometryVisibility={geometryVisibility}
onToggleBackground={onToggleBackground}
onToggleGeometry={onToggleGeometry}
onHide={() => setIsLayerPanelVisible(false)}
/>
</div>
)}
</aside>
{onPlayPreviewReplay ? (
<button
type="button"
onClick={onPlayPreviewReplay}
style={{
position: "absolute",
top: 10,
right: 18,
width: 46,
height: 46,
backgroundColor: "#1e293b",
border: "1px solid rgba(56, 189, 248, 0.3)",
borderRadius: "50%",
display: "flex",
alignItems: "center",
justifyContent: "center",
cursor: "pointer",
color: "#38bdf8",
transition: "all 0.2s ease",
boxShadow: "0 4px 12px rgba(56, 189, 248, 0.15)",
backdropFilter: "blur(8px)",
zIndex: 22,
}}
title="Play replay của geometry đang chọn"
aria-label="Play selected replay"
>
<span
aria-hidden="true"
style={{
width: 0,
height: 0,
borderTop: "6px solid transparent",
borderBottom: "6px solid transparent",
borderLeft: "9px solid currentColor",
marginLeft: "3px",
}}
/>
</button>
) : null}
{overlay}
{activeEntity ? (
<aside
className={isMobileOrTablet ? "" : "absolute bottom-4 right-4 top-4 left-auto z-20 max-w-[calc(100vw-2rem)]"}
style={isMobileOrTablet ? {
position: "absolute",
bottom: 0,
left: 0,
right: 0,
top: "auto",
height: `${sidebarHeight || 400}px`,
maxHeight: "90vh",
width: "100%",
maxWidth: "100%",
zIndex: 20,
// Do not transition height during drag resizing for butter smoothness
} : undefined}
>
<PublicWikiSidebar
entity={activeEntity}
wiki={activeWiki}
isLoading={isWikiLoading}
error={wikiError}
onClose={onCloseWikiSidebar || (() => {})}
onWikiLinkRequest={onWikiLinkRequest || (() => {})}
sidebarWidth={sidebarWidth}
onSidebarWidthChange={onSidebarWidthChange}
maxDragWidth={maxSidebarDragWidth}
compactHeader
sidebarHeight={sidebarHeight}
onSidebarHeightChange={onSidebarHeightChange}
/>
</aside>
) : null}
<ChatbotWidget hideFloatingButton />
{children}
</div>
</div>
);
}