diff --git a/src/app/user/about-us/page.tsx b/src/app/about-us/page.tsx similarity index 99% rename from src/app/user/about-us/page.tsx rename to src/app/about-us/page.tsx index 6521029..2a57aa9 100644 --- a/src/app/user/about-us/page.tsx +++ b/src/app/about-us/page.tsx @@ -1,5 +1,3 @@ -"use client"; - import React from "react"; import Image from "next/image"; import Link from "next/link"; diff --git a/src/app/user/quick-qa/page.tsx b/src/app/faq/page.tsx similarity index 99% rename from src/app/user/quick-qa/page.tsx rename to src/app/faq/page.tsx index c4a4c0d..ede4ec7 100644 --- a/src/app/user/quick-qa/page.tsx +++ b/src/app/faq/page.tsx @@ -79,4 +79,4 @@ export default function Page() { ); -} \ No newline at end of file +} diff --git a/src/app/globals.css b/src/app/globals.css index 353911e..d75a64f 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -743,12 +743,7 @@ span.flatpickr-weekday, } html { - scrollbar-gutter: stable; - overflow-y: scroll; -} - -html { - overflow-y: scroll; + overflow-y: auto; } ::-webkit-scrollbar { diff --git a/src/app/page.tsx b/src/app/page.tsx index 29879ef..18df3f9 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -31,8 +31,8 @@ const CURRENT_YEAR = new Date().getUTCFullYear(); export default function Page() { const [selectedFeatureIds, setSelectedFeatureIds] = useState<(string | number)[]>([]); - const [timelineYear, setTimelineYear] = useState(() => clampYearToFixedRange(CURRENT_YEAR)); - const [timelineDraftYear, setTimelineDraftYear] = useState(() => clampYearToFixedRange(CURRENT_YEAR)); + const [timelineYear, setTimelineYear] = useState(1000); + const [timelineDraftYear, setTimelineDraftYear] = useState(1000); const [timeRange, setTimeRange] = useState(0); const [backgroundVisibility, setBackgroundVisibility] = useState( () => ({ ...HIDDEN_BACKGROUND_LAYER_VISIBILITY }) @@ -56,6 +56,7 @@ export default function Page() { const [isLargeScreen, setIsLargeScreen] = useState(false); const mapHandleRef = useRef(null); + const isFirstMount = useRef(true); const [replayMode, setReplayMode] = useState<"idle" | "playing">("idle"); const [selectedReplayStageId, setSelectedReplayStageId] = useState(null); const [selectedReplayStepIndex, setSelectedReplayStepIndex] = useState(null); @@ -139,6 +140,7 @@ export default function Page() { handleWikiLinkRequest, closeWikiSidebar, setLinkEntityPopup, + isManualSidebarOpen, } = usePublicPreviewInteraction({ data, relations, @@ -166,6 +168,30 @@ export default function Page() { return () => window.clearTimeout(timeoutId); }, [timelineDraftYear, timelineYear]); + useEffect(() => { + if (typeof window !== "undefined") { + const saved = localStorage.getItem("timeline-year"); + if (saved) { + const parsed = parseInt(saved, 10); + if (!isNaN(parsed)) { + const clamped = clampYearToFixedRange(parsed); + setTimelineYear(clamped); + setTimelineDraftYear(clamped); + } + } + } + }, []); + + useEffect(() => { + if (isFirstMount.current) { + isFirstMount.current = false; + return; + } + if (typeof window !== "undefined") { + localStorage.setItem("timeline-year", String(timelineYear)); + } + }, [timelineYear]); + useEffect(() => { const timeoutId = window.setTimeout(() => { setBackgroundVisibility(loadBackgroundLayerVisibilityFromStorage()); @@ -297,6 +323,32 @@ export default function Page() { const currentTimelineYear = replayMode === "playing" ? replayPreview.timelineYear : timelineDraftYear; + const activeStepLabel = useMemo(() => { + if ( + replayPreview.activeCursor.stageId == null || + replayPreview.activeCursor.stepIndex == null + ) { + return null; + } + return `Stage #${replayPreview.activeCursor.stageId} · Step ${replayPreview.activeCursor.stepIndex + 1}`; + }, [replayPreview.activeCursor.stageId, replayPreview.activeCursor.stepIndex]); + + const isSidebarOpen = replayMode === "playing" + ? (replayPreview.sidebarOpen || isManualSidebarOpen) + : Boolean(activeEntity); + + const displayedActiveEntity = isSidebarOpen ? activeEntity : null; + const displayedActiveWiki = isSidebarOpen ? activeWiki : null; + + const computedTimelineStyle = useMemo(() => { + const rightMargin = (displayedActiveEntity && isLargeScreen) ? sidebarWidth + 32 : 18; + return { + left: "88px", + 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)", + }; + }, [displayedActiveEntity, isLargeScreen, sidebarWidth]); + return ( <> {isBackgroundVisibilityReady ? ( @@ -322,11 +374,11 @@ export default function Page() { onTimelineTimeRangeChange={handleTimeRangeChange} isTimelineLoading={isTimelineLoading || isRelationsLoading} timelineStatusText={relationsStatus || timelineStatus} - timelineStyle={activeEntity && isLargeScreen ? { right: `${sidebarWidth + 32}px` } : undefined} + timelineStyle={computedTimelineStyle} hoverPopupEnabled getHoverPopupContent={getHoverPopupContent} - activeEntity={replayMode === "playing" ? (replayPreview.sidebarOpen ? activeEntity : null) : activeEntity} - activeWiki={replayMode === "playing" ? (replayPreview.sidebarOpen ? activeWiki : null) : activeWiki} + activeEntity={displayedActiveEntity} + activeWiki={displayedActiveWiki} isWikiLoading={isActiveWikiLoading} wikiError={activeWikiError} onCloseWikiSidebar={closeWikiSidebar} @@ -343,10 +395,10 @@ export default function Page() { isPlaying={replayPreview.isPlaying} dialog={replayPreview.dialog} toasts={replayPreview.toasts} - sidebarOpen={replayPreview.sidebarOpen} + sidebarOpen={isSidebarOpen} sidebarWidth={sidebarWidth} playbackSpeed={replayPreview.playbackSpeed} - activeStepLabel="" + activeStepLabel={activeStepLabel} activeStepNumber={replayPreview.activeStepNumber} totalSteps={replayPreview.totalSteps} onPlayPreview={replayPreview.playFromStart} @@ -361,7 +413,7 @@ export default function Page() { style={{ position: "absolute", top: 10, - left: 18, + left: 80, zIndex: 18, display: "flex", gap: "10px", @@ -369,53 +421,6 @@ export default function Page() { pointerEvents: "auto", }} > - - diff --git a/src/layout/AppSidebar.tsx b/src/layout/AppSidebar.tsx index c209650..ab61fdf 100644 --- a/src/layout/AppSidebar.tsx +++ b/src/layout/AppSidebar.tsx @@ -37,7 +37,7 @@ const ALL_NAV_ITEMS: NavItem[] = [ ]; const OTHERS_ITEMS: NavItem[] = [ - { icon: , name: "Hỗ trợ", path: "/user/quick-qa" }, + { icon: , name: "Hỗ trợ", path: "/faq" }, ]; const AppSidebar: React.FC = () => { @@ -356,12 +356,12 @@ const AppSidebar: React.FC = () => {
  • - + {isSidebarVisible && Về chúng tôi} diff --git a/src/uhm/components/Map.tsx b/src/uhm/components/Map.tsx index f490efc..7678185 100644 --- a/src/uhm/components/Map.tsx +++ b/src/uhm/components/Map.tsx @@ -145,21 +145,21 @@ const Map = memo(forwardRef(function Map({ // Ref danh sách geometry thuộc local project để context menu phân biệt global-only feature. const localFeatureIdsRef = useRef(localFeatureIds); - useEffect(() => { modeRef.current = mode; }, [mode]); - useEffect(() => { renderDraftRef.current = renderDraft; }, [renderDraft]); - useEffect(() => { onSelectFeatureIdsRef.current = onSelectFeatureIds; }, [onSelectFeatureIds]); - useEffect(() => { onSetModeRef.current = onSetMode; }, [onSetMode]); - useEffect(() => { onFeatureClickRef.current = onFeatureClick; }, [onFeatureClick]); - useEffect(() => { getHoverPopupContentRef.current = getHoverPopupContent; }, [getHoverPopupContent]); - useEffect(() => { onCreateRef.current = onCreateFeature; }, [onCreateFeature]); - useEffect(() => { onAddFeatureToProjectRef.current = onAddFeatureToProject; }, [onAddFeatureToProject]); - useEffect(() => { onDeleteRef.current = onDeleteFeature; }, [onDeleteFeature]); - useEffect(() => { onHideRef.current = onHideFeature; }, [onHideFeature]); - useEffect(() => { onUpdateRef.current = onUpdateFeature; }, [onUpdateFeature]); - useEffect(() => { imageOverlayRef.current = imageOverlay; }, [imageOverlay]); - useEffect(() => { onImageOverlayChangeRef.current = onImageOverlayChange; }, [onImageOverlayChange]); - useEffect(() => { onBindGeometriesRef.current = onBindGeometries; }, [onBindGeometries]); - useEffect(() => { localFeatureIdsRef.current = localFeatureIds; }, [localFeatureIds]); + modeRef.current = mode; + renderDraftRef.current = renderDraft; + onSelectFeatureIdsRef.current = onSelectFeatureIds; + onSetModeRef.current = onSetMode; + onFeatureClickRef.current = onFeatureClick; + getHoverPopupContentRef.current = getHoverPopupContent; + onCreateRef.current = onCreateFeature; + onAddFeatureToProjectRef.current = onAddFeatureToProject; + onDeleteRef.current = onDeleteFeature; + onHideRef.current = onHideFeature; + onUpdateRef.current = onUpdateFeature; + imageOverlayRef.current = imageOverlay; + onImageOverlayChangeRef.current = onImageOverlayChange; + onBindGeometriesRef.current = onBindGeometries; + localFeatureIdsRef.current = localFeatureIds; // Hook sở hữu lifecycle MapLibre instance và các control camera/projection. const { diff --git a/src/uhm/components/editor/ReplayEffectsSidebar.tsx b/src/uhm/components/editor/ReplayEffectsSidebar.tsx index 7750b6e..50d921b 100644 --- a/src/uhm/components/editor/ReplayEffectsSidebar.tsx +++ b/src/uhm/components/editor/ReplayEffectsSidebar.tsx @@ -155,7 +155,7 @@ const narrativeActionDefinitions: NarrativeActionDefinitionMap = { { name: "image_url", label: "Ảnh tư liệu", kind: "text", placeholder: "https://... (URL ảnh)" }, { name: "text", label: "Nội dung", kind: "rich-text", placeholder: "Nội dung dẫn chuyện..." }, ], - create: () => ({ function_name: "set_dialog", params: [{ avatar: "", text: "", image_url: "", image_caption: "" }] }), + create: () => ({ function_name: "set_dialog", params: [{ text: "", image_url: "" }] }), deserialize: (params) => { const data: any = params[0]; if (data === null) { @@ -176,9 +176,7 @@ const narrativeActionDefinitions: NarrativeActionDefinitionMap = { return [null]; } const data: any = { - avatar: "", text: asString(values.text), - image_caption: "", }; if (values.image_url) { data.image_url = asString(values.image_url); diff --git a/src/uhm/components/editor/ReplayPreviewLayerPanel.tsx b/src/uhm/components/editor/ReplayPreviewLayerPanel.tsx index 555a56c..b172af8 100644 --- a/src/uhm/components/editor/ReplayPreviewLayerPanel.tsx +++ b/src/uhm/components/editor/ReplayPreviewLayerPanel.tsx @@ -221,7 +221,7 @@ export default function ReplayPreviewLayerPanel({ alignItems: "center", boxShadow: "0 20px 48px rgba(2, 6, 23, 0.45)", backdropFilter: "blur(12px)", - maxHeight: "calc(100vh - 180px)", + maxHeight: "100%", overflowY: "auto", overflowX: "hidden", }} diff --git a/src/uhm/components/editor/ReplayTimelineSidebar.tsx b/src/uhm/components/editor/ReplayTimelineSidebar.tsx index 64c5705..5eee78e 100644 --- a/src/uhm/components/editor/ReplayTimelineSidebar.tsx +++ b/src/uhm/components/editor/ReplayTimelineSidebar.tsx @@ -1364,9 +1364,6 @@ function buildNarrativeActionEntry( const plainText = dialog.text.replace(/<[^>]*>/g, ""); parts.push(`text=${summarizeValue(plainText, "")}`); } - if (dialog.avatar) { - parts.push(`avatar=${summarizeValue(dialog.avatar, "")}`); - } if (dialog.image_url) { parts.push(`image=${summarizeValue(dialog.image_url, "")}`); } diff --git a/src/uhm/components/map/useMapInteraction.ts b/src/uhm/components/map/useMapInteraction.ts index 1bc713b..0bb9ae2 100644 --- a/src/uhm/components/map/useMapInteraction.ts +++ b/src/uhm/components/map/useMapInteraction.ts @@ -65,6 +65,12 @@ export function useMapInteraction({ const previousModeRef = useRef(mode); const mapCleanupFnsRef = useRef void>>([]); + const allowGeometryEditingRef = useRef(allowGeometryEditing); + allowGeometryEditingRef.current = allowGeometryEditing; + + const allowFeatureSelectionRef = useRef(allowFeatureSelection); + allowFeatureSelectionRef.current = allowFeatureSelection; + useEffect(() => { if (!editingEngineRef.current) { editingEngineRef.current = createEditingEngine({ @@ -151,41 +157,33 @@ export function useMapInteraction({ const selectEngine = initSelect( map, () => modeRef.current, - allowGeometryEditing - ? (id: string | number | (string | number)[]) => { - editingEngineRef.current?.clearEditing(); - onSelectFeatureIdsRef.current?.([]); - onDeleteRef.current?.(id); - } - : undefined, - allowGeometryEditing - ? (feature) => { - const rawId = feature.id ?? feature.properties?.id; - const originalFeature = renderDraftRef.current.features.find( - (item) => String(item.properties.id) === String(rawId) - ); - editingEngineRef.current?.beginEditing( - (originalFeature || feature) as unknown as maplibregl.MapGeoJSONFeature - ); - } - : undefined, - allowGeometryEditing - ? (id: string | number) => { - const originalFeature = renderDraftRef.current.features.find( - (item) => String(item.properties.id) === String(id) - ); - if (!originalFeature) return; + (id: string | number | (string | number)[]) => { + editingEngineRef.current?.clearEditing(); + onSelectFeatureIdsRef.current?.([]); + onDeleteRef.current?.(id); + }, + (feature) => { + const rawId = feature.id ?? feature.properties?.id; + const originalFeature = renderDraftRef.current.features.find( + (item) => String(item.properties.id) === String(rawId) + ); + editingEngineRef.current?.beginEditing( + (originalFeature || feature) as unknown as maplibregl.MapGeoJSONFeature + ); + }, + (id: string | number) => { + const originalFeature = renderDraftRef.current.features.find( + (item) => String(item.properties.id) === String(id) + ); + if (!originalFeature) return; - const nextFeature = buildDuplicatedFeatureShapeOnly(originalFeature); - onCreateRef.current?.(nextFeature); - } - : undefined, - allowGeometryEditing - ? (id: string | number) => { - onHideRef.current?.(id); - onSelectFeatureIdsRef.current?.([]); - } - : undefined, + const nextFeature = buildDuplicatedFeatureShapeOnly(originalFeature); + onCreateRef.current?.(nextFeature); + }, + (id: string | number) => { + onHideRef.current?.(id); + onSelectFeatureIdsRef.current?.([]); + }, (ids) => onSelectFeatureIdsRef.current?.(ids), (id: string | number) => onSetModeRef.current?.("replay", id), () => Boolean(editingEngineRef.current?.editingRef.current), @@ -206,27 +204,25 @@ export function useMapInteraction({ feature: currentFeature, }); }, - onAddFeatureToProjectRef?.current - ? (feature) => { - const rawId = feature.id ?? feature.properties?.id; - if (rawId === undefined || rawId === null) return; + (feature) => { + if (!onAddFeatureToProjectRef?.current) return; + const rawId = feature.id ?? feature.properties?.id; + if (rawId === undefined || rawId === null) return; - const originalFeature = renderDraftRef.current.features.find( - (item) => String(item.properties.id) === String(rawId) - ); - if (!originalFeature) return; - onAddFeatureToProjectRef.current?.(originalFeature); - } - : undefined, - onAddFeatureToProjectRef?.current - ? (id) => { - if (!onAddFeatureToProjectRef?.current) return true; - const localIds = localFeatureIdsRef?.current; - if (!Array.isArray(localIds)) return true; - return localIds.some((localId) => String(localId) === String(id)); - } - : undefined, - () => allowFeatureSelection + const originalFeature = renderDraftRef.current.features.find( + (item) => String(item.properties.id) === String(rawId) + ); + if (!originalFeature) return; + onAddFeatureToProjectRef.current?.(originalFeature); + }, + (id) => { + if (!onAddFeatureToProjectRef?.current) return true; + const localIds = localFeatureIdsRef?.current; + if (!Array.isArray(localIds)) return true; + return localIds.some((localId) => String(localId) === String(id)); + }, + () => allowFeatureSelectionRef.current, + () => allowGeometryEditingRef.current ); const cleanupPoint = initPoint( diff --git a/src/uhm/components/map/useMapSync.ts b/src/uhm/components/map/useMapSync.ts index 667d0a2..9c7d3f2 100644 --- a/src/uhm/components/map/useMapSync.ts +++ b/src/uhm/components/map/useMapSync.ts @@ -80,20 +80,20 @@ export function useMapSync({ const focusPaddingRef = useRef(focusPadding); const isPreviewModeRef = useRef(isPreviewMode); - const fitBoundsAppliedRef = useRef(false); + renderDraftRef.current = renderDraft; + labelContextDraftRef.current = labelContextDraft; + labelTimelineYearRef.current = labelTimelineYear; + backgroundVisibilityRef.current = backgroundVisibility; + geometryVisibilityRef.current = geometryVisibility; + selectedFeatureIdsRef.current = selectedFeatureIds; + applyGeometryBindingFilterRef.current = applyGeometryBindingFilter; + fitToDraftBoundsRef.current = fitToDraftBounds; + imageOverlayRef.current = imageOverlay || null; + focusFeatureCollectionRef.current = focusFeatureCollection; + focusPaddingRef.current = focusPadding; + isPreviewModeRef.current = isPreviewMode; - useEffect(() => { renderDraftRef.current = renderDraft; }, [renderDraft]); - useEffect(() => { labelContextDraftRef.current = labelContextDraft; }, [labelContextDraft]); - useEffect(() => { labelTimelineYearRef.current = labelTimelineYear; }, [labelTimelineYear]); - useEffect(() => { backgroundVisibilityRef.current = backgroundVisibility; }, [backgroundVisibility]); - useEffect(() => { geometryVisibilityRef.current = geometryVisibility; }, [geometryVisibility]); - useEffect(() => { selectedFeatureIdsRef.current = selectedFeatureIds; }, [selectedFeatureIds]); - useEffect(() => { applyGeometryBindingFilterRef.current = applyGeometryBindingFilter; }, [applyGeometryBindingFilter]); - useEffect(() => { fitToDraftBoundsRef.current = fitToDraftBounds; }, [fitToDraftBounds]); - useEffect(() => { imageOverlayRef.current = imageOverlay || null; }, [imageOverlay]); - useEffect(() => { focusFeatureCollectionRef.current = focusFeatureCollection; }, [focusFeatureCollection]); - useEffect(() => { focusPaddingRef.current = focusPadding; }, [focusPadding]); - useEffect(() => { isPreviewModeRef.current = isPreviewMode; }, [isPreviewMode]); + const fitBoundsAppliedRef = useRef(false); useEffect(() => { fitBoundsAppliedRef.current = false; diff --git a/src/uhm/components/preview/PreviewMapShell.tsx b/src/uhm/components/preview/PreviewMapShell.tsx index 311d702..f18bf49 100644 --- a/src/uhm/components/preview/PreviewMapShell.tsx +++ b/src/uhm/components/preview/PreviewMapShell.tsx @@ -1,6 +1,6 @@ "use client"; -import type { CSSProperties, ReactNode } from "react"; +import { type CSSProperties, type ReactNode, useState } from "react"; import Map, { type MapFeaturePayload } from "@/uhm/components/Map"; import ReplayPreviewLayerPanel from "@/uhm/components/editor/ReplayPreviewLayerPanel"; import PublicWikiSidebar from "@/uhm/components/wiki/PublicWikiSidebar"; @@ -87,6 +87,24 @@ export default function PreviewMapShell({ overlay, children, }: Props) { + const [isMenuOpen, setIsMenuOpen] = useState(false); + + 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 (
    @@ -123,22 +141,144 @@ export default function PreviewMapShell({ style={timelineStyle} /> +