493 lines
23 KiB
TypeScript
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>
|
|
);
|
|
}
|