feat: update public preview UI with adjustable sidebar height, layer panel toggle, and synchronized data loading states(mobile UI)
Build and Release / release (push) Successful in 37s

This commit is contained in:
taDuc
2026-06-04 18:39:46 +07:00
parent 794ad2913f
commit 9a5dfdb2ed
14 changed files with 801 additions and 249 deletions
+11 -1
View File
@@ -14,8 +14,10 @@ type Message = {
export default function ChatbotWidget({ export default function ChatbotWidget({
projectId = "", projectId = "",
hideFloatingButton = false,
}: { }: {
projectId?: string; projectId?: string;
hideFloatingButton?: boolean;
}) { }) {
const [isOpen, setIsOpen] = useState(false); const [isOpen, setIsOpen] = useState(false);
const [messages, setMessages] = useState<Message[]>([ const [messages, setMessages] = useState<Message[]>([
@@ -33,6 +35,14 @@ export default function ChatbotWidget({
messagesEndRef.current?.scrollIntoView({ behavior: "smooth" }); messagesEndRef.current?.scrollIntoView({ behavior: "smooth" });
}; };
useEffect(() => {
const handleToggle = () => setIsOpen((prev) => !prev);
window.addEventListener("toggle-chatbot", handleToggle);
return () => {
window.removeEventListener("toggle-chatbot", handleToggle);
};
}, []);
useEffect(() => { useEffect(() => {
if (isOpen) { if (isOpen) {
scrollToBottom(); scrollToBottom();
@@ -93,7 +103,7 @@ export default function ChatbotWidget({
return ( return (
<div className="fixed bottom-8 right-8 z-50"> <div className="fixed bottom-8 right-8 z-50">
{!isOpen && ( {!isOpen && !hideFloatingButton && (
<button <button
name="AI chat" name="AI chat"
onClick={() => setIsOpen(true)} onClick={() => setIsOpen(true)}
+68 -60
View File
@@ -416,6 +416,11 @@ const Map = memo(forwardRef<MapHandle, MapProps>(function Map({
cursor: pointer; cursor: pointer;
outline: none; outline: none;
} }
@media (max-width: 768px) {
.premium-zoom-slider {
display: none !important;
}
}
.premium-zoom-slider::-webkit-slider-runnable-track { .premium-zoom-slider::-webkit-slider-runnable-track {
width: 100%; width: 100%;
height: 6px; height: 6px;
@@ -514,6 +519,7 @@ const Map = memo(forwardRef<MapHandle, MapProps>(function Map({
minWidth: "40px", minWidth: "40px",
transition: "color 0.25s ease", transition: "color 0.25s ease",
}} }}
className="hidden sm:block"
> >
{isGlobeProjection ? "Globe" : "Flat"} {isGlobeProjection ? "Globe" : "Flat"}
</span> </span>
@@ -613,69 +619,71 @@ const Map = memo(forwardRef<MapHandle, MapProps>(function Map({
</button> </button>
) : null} ) : null}
<button <div className="hidden sm:flex items-center gap-[10px] flex-shrink-0">
type="button" <button
onClick={() => handleZoomByStep(-0.8)} type="button"
className="premium-zoom-btn" onClick={() => handleZoomByStep(-0.8)}
style={{ flexShrink: 0 }} className="premium-zoom-btn"
aria-label="Zoom out" style={{ flexShrink: 0 }}
> aria-label="Zoom out"
- >
</button> -
</button>
<input <input
type="range" type="range"
min={zoomBounds.min} min={zoomBounds.min}
max={zoomBounds.max} max={zoomBounds.max}
step={0.1} step={0.1}
value={zoomLevel} value={zoomLevel}
className="premium-zoom-slider" className="premium-zoom-slider"
onPointerDown={(event) => { onPointerDown={(event) => {
event.stopPropagation(); event.stopPropagation();
try { try {
event.currentTarget.setPointerCapture(event.pointerId); event.currentTarget.setPointerCapture(event.pointerId);
} catch { } catch {
// Browser may reject capture for non-primary pointers; drag lock still works. // Browser may reject capture for non-primary pointers; drag lock still works.
} }
beginZoomSliderDrag(); beginZoomSliderDrag();
}} }}
onPointerUp={(event) => { onPointerUp={(event) => {
event.stopPropagation(); event.stopPropagation();
try { try {
event.currentTarget.releasePointerCapture(event.pointerId); event.currentTarget.releasePointerCapture(event.pointerId);
} catch { } catch {
// Ignore if capture was already released. // Ignore if capture was already released.
} }
endZoomSliderDrag(); endZoomSliderDrag();
}} }}
onPointerCancel={endZoomSliderDrag} onPointerCancel={endZoomSliderDrag}
onBlur={endZoomSliderDrag} onBlur={endZoomSliderDrag}
onChange={(event) => handleZoomSliderChange(Number(event.target.value))} onChange={(event) => handleZoomSliderChange(Number(event.target.value))}
aria-label="Map zoom" aria-label="Map zoom"
/> />
<button <button
type="button" type="button"
onClick={() => handleZoomByStep(0.8)} onClick={() => handleZoomByStep(0.8)}
className="premium-zoom-btn" className="premium-zoom-btn"
style={{ flexShrink: 0 }} style={{ flexShrink: 0 }}
aria-label="Zoom in" aria-label="Zoom in"
> >
+ +
</button> </button>
<div <div
style={{ style={{
minWidth: "48px", minWidth: "48px",
textAlign: "right", textAlign: "right",
fontSize: "12px", fontSize: "12px",
fontWeight: 700, fontWeight: 700,
color: "#94a3b8", color: "#94a3b8",
fontVariantNumeric: "tabular-nums", fontVariantNumeric: "tabular-nums",
flexShrink: 0, flexShrink: 0,
}} }}
> >
{zoomLevel.toFixed(1)}x {zoomLevel.toFixed(1)}x
</div>
</div> </div>
</div> </div>
</div> </div>
@@ -282,7 +282,8 @@ export default function PresentPlaceSearch({
style={{ style={{
position: "absolute", position: "absolute",
top: 10, top: 10,
left: leftOffset, left: "50%",
transform: "translateX(-50%)",
zIndex: 18, zIndex: 18,
width: "min(392px, calc(100vw - 36px))", width: "min(392px, calc(100vw - 36px))",
pointerEvents: "auto", pointerEvents: "auto",
@@ -9,6 +9,7 @@ type Props = {
geometryVisibility: Record<string, boolean>; geometryVisibility: Record<string, boolean>;
onToggleBackground: (id: BackgroundLayerId) => void; onToggleBackground: (id: BackgroundLayerId) => void;
onToggleGeometry: (typeKey: string) => void; onToggleGeometry: (typeKey: string) => void;
onHide?: () => void;
}; };
// Map each layer ID/geometry type to a premium inline SVG icon // Map each layer ID/geometry type to a premium inline SVG icon
@@ -172,6 +173,7 @@ export default function ReplayPreviewLayerPanel({
geometryVisibility, geometryVisibility,
onToggleBackground, onToggleBackground,
onToggleGeometry, onToggleGeometry,
onHide,
}: Props) { }: Props) {
// Categorize geometry types for logical grouping // Categorize geometry types for logical grouping
const polygonKeys = ["country", "state", "faction", "rebellion_zone"]; const polygonKeys = ["country", "state", "faction", "rebellion_zone"];
@@ -196,10 +198,10 @@ export default function ReplayPreviewLayerPanel({
const renderStyles = () => ( const renderStyles = () => (
<style dangerouslySetInnerHTML={{ __html: ` <style dangerouslySetInnerHTML={{ __html: `
.replay-preview-layer-panel::-webkit-scrollbar { .replay-preview-layer-panel-scroll::-webkit-scrollbar {
display: none; display: none;
} }
.replay-preview-layer-panel { .replay-preview-layer-panel-scroll {
scrollbar-width: none; scrollbar-width: none;
-ms-overflow-style: none; -ms-overflow-style: none;
} }
@@ -212,7 +214,6 @@ export default function ReplayPreviewLayerPanel({
style={{ style={{
display: "flex", display: "flex",
flexDirection: "column", flexDirection: "column",
gap: 12,
background: "linear-gradient(145deg, rgba(15, 23, 42, 0.9), rgba(30, 41, 59, 0.8))", background: "linear-gradient(145deg, rgba(15, 23, 42, 0.9), rgba(30, 41, 59, 0.8))",
border: "1px solid rgba(148, 163, 184, 0.22)", border: "1px solid rgba(148, 163, 184, 0.22)",
borderRadius: 20, borderRadius: 20,
@@ -222,96 +223,134 @@ export default function ReplayPreviewLayerPanel({
boxShadow: "0 20px 48px rgba(2, 6, 23, 0.45)", boxShadow: "0 20px 48px rgba(2, 6, 23, 0.45)",
backdropFilter: "blur(12px)", backdropFilter: "blur(12px)",
maxHeight: "100%", maxHeight: "100%",
overflowY: "auto", overflow: "hidden",
overflowX: "hidden",
}} }}
> >
{renderStyles()} {renderStyles()}
{/* Background layers */} <div
<div style={groupHeaderStyle}>Map</div> className="replay-preview-layer-panel-scroll"
<div style={gridStyle}> style={{
{BACKGROUND_LAYER_OPTIONS.map((layer) => { flexGrow: 1,
const active = Boolean(backgroundVisibility[layer.id]); overflowY: "auto",
return ( width: "100%",
<button display: "flex",
key={layer.id} flexDirection: "column",
type="button" alignItems: "center",
title={layer.label} gap: 12,
onClick={() => onToggleBackground(layer.id)} }}
style={getButtonStyles(active, "56, 189, 248")} // sky-400 >
> {/* Background layers */}
{LAYER_ICONS[layer.id] || "?"} <div style={groupHeaderStyle}>Map</div>
</button> <div style={gridStyle}>
); {BACKGROUND_LAYER_OPTIONS.map((layer) => {
})} const active = Boolean(backgroundVisibility[layer.id]);
return (
<button
key={layer.id}
type="button"
title={layer.label}
onClick={() => onToggleBackground(layer.id)}
style={getButtonStyles(active, "56, 189, 248")} // sky-400
>
{LAYER_ICONS[layer.id] || "?"}
</button>
);
})}
</div>
<div style={dividerStyle} />
{/* Territories / Polygons */}
<div style={groupHeaderStyle}>Areas</div>
<div style={gridStyle}>
{polygonKeys.map((typeKey) => {
const active = geometryVisibility[typeKey] !== false;
const label = typeKey.replace("_", " ").toUpperCase();
return (
<button
key={typeKey}
type="button"
title={label}
onClick={() => onToggleGeometry(typeKey)}
style={getButtonStyles(active, "249, 115, 22")} // orange-500
>
{LAYER_ICONS[typeKey] || "?"}
</button>
);
})}
</div>
<div style={dividerStyle} />
{/* Routes / Lines */}
<div style={groupHeaderStyle}>Routes</div>
<div style={gridStyle}>
{lineKeys.map((typeKey) => {
const active = geometryVisibility[typeKey] !== false;
const label = typeKey.replace("_", " ").toUpperCase();
return (
<button
key={typeKey}
type="button"
title={label}
onClick={() => onToggleGeometry(typeKey)}
style={getButtonStyles(active, "192, 132, 252")} // purple-400
>
{LAYER_ICONS[typeKey] || "?"}
</button>
);
})}
</div>
<div style={dividerStyle} />
{/* Places & Events / Points */}
<div style={groupHeaderStyle}>Points</div>
<div style={gridStyle}>
{pointKeys.map((typeKey) => {
const active = geometryVisibility[typeKey] !== false;
const label = typeKey.replace("_", " ").toUpperCase();
return (
<button
key={typeKey}
type="button"
title={label}
onClick={() => onToggleGeometry(typeKey)}
style={getButtonStyles(active, "245, 158, 11")} // amber-500
>
{LAYER_ICONS[typeKey] || "?"}
</button>
);
})}
</div>
</div> </div>
<div style={dividerStyle} /> {onHide && (
<div
{/* Territories / Polygons */} style={{
<div style={groupHeaderStyle}>Areas</div> width: "100%",
<div style={gridStyle}> display: "flex",
{polygonKeys.map((typeKey) => { flexDirection: "column",
const active = geometryVisibility[typeKey] !== false; alignItems: "center",
const label = typeKey.replace("_", " ").toUpperCase(); marginTop: 6,
return ( flexShrink: 0,
<button }}
key={typeKey} >
type="button" <div style={dividerStyle} />
title={label} <button
onClick={() => onToggleGeometry(typeKey)} type="button"
style={getButtonStyles(active, "249, 115, 22")} // orange-500 title="Ẩn bảng lớp bản đồ"
> onClick={onHide}
{LAYER_ICONS[typeKey] || "?"} style={getButtonStyles(true, "239, 68, 68")}
</button> >
); <svg viewBox="0 0 24 24" width="20" height="20" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
})} <path d="M17.94 17.94A10.07 10.07 0 0 1 12 20c-7 0-11-8-11-8a18.45 18.45 0 0 1 5.06-5.94M9.9 4.24A9.12 9.12 0 0 1 12 4c7 0 11 8 11 8a18.5 18.5 0 0 1-2.16 3.19m-6.72-1.07a3 3 0 1 1-4.24-4.24" />
</div> <line x1="1" y1="1" x2="23" y2="23" />
</svg>
<div style={dividerStyle} /> </button>
</div>
{/* Routes / Lines */} )}
<div style={groupHeaderStyle}>Routes</div>
<div style={gridStyle}>
{lineKeys.map((typeKey) => {
const active = geometryVisibility[typeKey] !== false;
const label = typeKey.replace("_", " ").toUpperCase();
return (
<button
key={typeKey}
type="button"
title={label}
onClick={() => onToggleGeometry(typeKey)}
style={getButtonStyles(active, "192, 132, 252")} // purple-400
>
{LAYER_ICONS[typeKey] || "?"}
</button>
);
})}
</div>
<div style={dividerStyle} />
{/* Places & Events / Points */}
<div style={groupHeaderStyle}>Points</div>
<div style={gridStyle}>
{pointKeys.map((typeKey) => {
const active = geometryVisibility[typeKey] !== false;
const label = typeKey.replace("_", " ").toUpperCase();
return (
<button
key={typeKey}
type="button"
title={label}
onClick={() => onToggleGeometry(typeKey)}
style={getButtonStyles(active, "245, 158, 11")} // amber-500
>
{LAYER_ICONS[typeKey] || "?"}
</button>
);
})}
</div>
</div> </div>
); );
} }
+3 -3
View File
@@ -173,7 +173,7 @@ function buildPopupNode(content: MapHoverPopupContent): HTMLElement {
root.style.overflowY = "auto"; root.style.overflowY = "auto";
root.style.padding = "12px"; root.style.padding = "12px";
root.style.border = "1px solid rgba(255, 255, 255, 0.10)"; root.style.border = "1px solid rgba(255, 255, 255, 0.10)";
root.style.borderRadius = "12px"; root.style.borderRadius = "0px";
root.style.background = "rgba(2, 6, 23, 0.95)"; root.style.background = "rgba(2, 6, 23, 0.95)";
root.style.boxShadow = "0 18px 36px rgba(0, 0, 0, 0.35)"; root.style.boxShadow = "0 18px 36px rgba(0, 0, 0, 0.35)";
root.style.backdropFilter = "blur(8px)"; root.style.backdropFilter = "blur(8px)";
@@ -201,7 +201,7 @@ function buildPopupNode(content: MapHoverPopupContent): HTMLElement {
} }
card.style.width = "100%"; card.style.width = "100%";
card.style.border = "1px solid rgba(255, 255, 255, 0.10)"; card.style.border = "1px solid rgba(255, 255, 255, 0.10)";
card.style.borderRadius = "8px"; card.style.borderRadius = "0px";
card.style.background = "rgba(255, 255, 255, 0.03)"; card.style.background = "rgba(255, 255, 255, 0.03)";
card.style.padding = "12px"; card.style.padding = "12px";
card.style.textAlign = "left"; card.style.textAlign = "left";
@@ -262,7 +262,7 @@ function stylePopupChrome(popup: maplibregl.Popup) {
const content = element.querySelector(".maplibregl-popup-content") as HTMLElement | null; const content = element.querySelector(".maplibregl-popup-content") as HTMLElement | null;
if (content) { if (content) {
content.style.padding = "0"; content.style.padding = "0";
content.style.borderRadius = "12px"; content.style.borderRadius = "0px";
content.style.background = "transparent"; content.style.background = "transparent";
content.style.boxShadow = "none"; content.style.boxShadow = "none";
} }
+26 -4
View File
@@ -94,6 +94,10 @@ export function useMapSync({
isPreviewModeRef.current = isPreviewMode; isPreviewModeRef.current = isPreviewMode;
const fitBoundsAppliedRef = useRef(false); const fitBoundsAppliedRef = useRef(false);
const lastCountriesStrRef = useRef("");
const lastPlacesStrRef = useRef("");
const lastPolygonLabelStrRef = useRef("");
const lastPathArrowStrRef = useRef("");
useEffect(() => { useEffect(() => {
fitBoundsAppliedRef.current = false; fitBoundsAppliedRef.current = false;
@@ -134,13 +138,31 @@ export function useMapSync({
const polygonLabels = buildPolygonLabelFeatureCollection(polygons, labelContext, labelTimelineYear); const polygonLabels = buildPolygonLabelFeatureCollection(polygons, labelContext, labelTimelineYear);
const pathArrowShapes = buildPathArrowFeatureCollection(mapSourceDraft); const pathArrowShapes = buildPathArrowFeatureCollection(mapSourceDraft);
countriesSource.setData(labeledGeometries); const countriesStr = JSON.stringify(labeledGeometries);
placesSource.setData(labeledPoints); if (countriesStr !== lastCountriesStrRef.current) {
polygonLabelSource.setData(polygonLabels); countriesSource.setData(labeledGeometries);
lastCountriesStrRef.current = countriesStr;
}
const placesStr = JSON.stringify(labeledPoints);
if (placesStr !== lastPlacesStrRef.current) {
placesSource.setData(labeledPoints);
lastPlacesStrRef.current = placesStr;
}
const polygonLabelsStr = JSON.stringify(polygonLabels);
if (polygonLabelsStr !== lastPolygonLabelStrRef.current) {
polygonLabelSource.setData(polygonLabels);
lastPolygonLabelStrRef.current = polygonLabelsStr;
}
const pathArrowSource = map.getSource(PATH_ARROW_SOURCE_ID) as maplibregl.GeoJSONSource | undefined; const pathArrowSource = map.getSource(PATH_ARROW_SOURCE_ID) as maplibregl.GeoJSONSource | undefined;
if (pathArrowSource) { if (pathArrowSource) {
pathArrowSource.setData(pathArrowShapes); const pathArrowStr = JSON.stringify(pathArrowShapes);
if (pathArrowStr !== lastPathArrowStrRef.current) {
pathArrowSource.setData(pathArrowShapes);
lastPathArrowStrRef.current = pathArrowStr;
}
} }
currentSelectedIds.forEach((id) => { currentSelectedIds.forEach((id) => {
@@ -8,7 +8,7 @@ interface MapPlaceholderProps {
export default function MapPlaceholder({ onEnter }: MapPlaceholderProps) { export default function MapPlaceholder({ onEnter }: MapPlaceholderProps) {
return ( return (
<div <div
onClick={onEnter} onClick={onEnter}
style={{ style={{
position: "fixed", position: "fixed",
@@ -50,7 +50,7 @@ export default function MapPlaceholder({ onEnter }: MapPlaceholderProps) {
/> />
{/* Glowing background gradient lights - Warm Candle & Antique ambiance */} {/* Glowing background gradient lights - Warm Candle & Antique ambiance */}
<div <div
style={{ style={{
position: "absolute", position: "absolute",
inset: 0, inset: 0,
@@ -60,7 +60,7 @@ export default function MapPlaceholder({ onEnter }: MapPlaceholderProps) {
/> />
{/* Content Container (Antique, Luxurious serif typography) */} {/* Content Container (Antique, Luxurious serif typography) */}
<div <div
style={{ style={{
position: "relative", position: "relative",
zIndex: 3, zIndex: 3,
@@ -74,7 +74,7 @@ export default function MapPlaceholder({ onEnter }: MapPlaceholderProps) {
}} }}
> >
{/* Title (Largest, gold/yellow color, antique Georgia font) */} {/* Title (Largest, gold/yellow color, antique Georgia font) */}
<h1 <h1
style={{ style={{
fontFamily: "Georgia, serif", fontFamily: "Georgia, serif",
fontSize: "min(52px, 10vw)", fontSize: "min(52px, 10vw)",
@@ -91,7 +91,7 @@ export default function MapPlaceholder({ onEnter }: MapPlaceholderProps) {
</h1> </h1>
{/* Subtitle / Description (Right below title, italic, elegant, muted color) */} {/* Subtitle / Description (Right below title, italic, elegant, muted color) */}
<p <p
style={{ style={{
fontFamily: "Georgia, serif", fontFamily: "Georgia, serif",
fontStyle: "italic", fontStyle: "italic",
@@ -108,7 +108,7 @@ export default function MapPlaceholder({ onEnter }: MapPlaceholderProps) {
</div> </div>
{/* Bottom hint "nhấn vào chỗ bất kì để vào" with a slow breathing/fade pulse animation */} {/* Bottom hint "nhấn vào chỗ bất kì để vào" with a slow breathing/fade pulse animation */}
<div <div
style={{ style={{
position: "absolute", position: "absolute",
bottom: "50px", bottom: "50px",
@@ -122,7 +122,7 @@ export default function MapPlaceholder({ onEnter }: MapPlaceholderProps) {
pointerEvents: "none", pointerEvents: "none",
}} }}
> >
<div <div
style={{ style={{
fontFamily: "Georgia, serif", fontFamily: "Georgia, serif",
fontSize: "12px", fontSize: "12px",
@@ -137,7 +137,8 @@ export default function MapPlaceholder({ onEnter }: MapPlaceholderProps) {
> >
nhấn vào chỗ bất đ vào nhấn vào chỗ bất đ vào
</div> </div>
<style dangerouslySetInnerHTML={{ __html: ` <style dangerouslySetInnerHTML={{
__html: `
@keyframes placeholder-pulse { @keyframes placeholder-pulse {
0%, 100% { opacity: 0.35; transform: scale(0.98); } 0%, 100% { opacity: 0.35; transform: scale(0.98); }
50% { opacity: 0.95; transform: scale(1); } 50% { opacity: 0.95; transform: scale(1); }
+1 -1
View File
@@ -567,7 +567,6 @@ const PreviewLayout = forwardRef<PreviewLayoutHandle, Props>(({
onFocusPlace={handleFocusPresentPlace} onFocusPlace={handleFocusPresentPlace}
onFocusHistoricalGeometry={handleFocusHistoricalGeometry} onFocusHistoricalGeometry={handleFocusHistoricalGeometry}
onClearFocus={clearPresentPlaceFocus} onClearFocus={clearPresentPlaceFocus}
leftOffset={18}
/> />
{isReplayPreviewMode ? ( {isReplayPreviewMode ? (
@@ -596,6 +595,7 @@ const PreviewLayout = forwardRef<PreviewLayoutHandle, Props>(({
top: 16, top: 16,
right: 16, right: 16,
bottom: 16, bottom: 16,
left: isLargeScreen ? "auto" : 16,
maxWidth: "calc(100vw - 2rem)", maxWidth: "calc(100vw - 2rem)",
zIndex: 20, zIndex: 20,
}} }}
+184 -39
View File
@@ -1,6 +1,9 @@
"use client"; "use client";
import Image from "next/image";
import { type CSSProperties, type ReactNode, useState } from "react"; 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 Map, { type MapFeaturePayload } from "@/uhm/components/Map";
import ReplayPreviewLayerPanel from "@/uhm/components/editor/ReplayPreviewLayerPanel"; import ReplayPreviewLayerPanel from "@/uhm/components/editor/ReplayPreviewLayerPanel";
import PublicWikiSidebar from "@/uhm/components/wiki/PublicWikiSidebar"; import PublicWikiSidebar from "@/uhm/components/wiki/PublicWikiSidebar";
@@ -51,6 +54,10 @@ type Props = {
onLoad?: () => void; onLoad?: () => void;
instantLoad?: boolean; instantLoad?: boolean;
onToggleInstantLoad?: (val: boolean) => void; onToggleInstantLoad?: (val: boolean) => void;
isLayerPanelVisible?: boolean;
onLayerPanelVisibleChange?: (visible: boolean) => void;
sidebarHeight?: number;
onSidebarHeightChange?: (height: number) => void;
}; };
export default function PreviewMapShell({ export default function PreviewMapShell({
@@ -92,8 +99,42 @@ export default function PreviewMapShell({
onLoad, onLoad,
instantLoad = true, instantLoad = true,
onToggleInstantLoad, onToggleInstantLoad,
isLayerPanelVisible: propsLayerPanelVisible,
onLayerPanelVisibleChange,
sidebarHeight,
onSidebarHeightChange,
}: Props) { }: Props) {
const [isMenuOpen, setIsMenuOpen] = useState(false); 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();
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.error("PreviewMapShell: Failed to fetch user avatar:", err);
}
};
fetchUserAvatar();
}, []);
const menuOptionStyle: CSSProperties = { const menuOptionStyle: CSSProperties = {
width: 46, width: 46,
@@ -133,6 +174,7 @@ export default function PreviewMapShell({
getHoverPopupContent={getHoverPopupContent} getHoverPopupContent={getHoverPopupContent}
onPlayPreviewReplay={onPlayPreviewReplay} onPlayPreviewReplay={onPlayPreviewReplay}
onLoad={onLoad} onLoad={onLoad}
showViewportControls={false}
/> />
<TimelineBar <TimelineBar
@@ -165,7 +207,7 @@ export default function PreviewMapShell({
style={{ style={{
position: "absolute", position: "absolute",
top: 10, top: 10,
bottom: 20, bottom: (activeEntity && isMobileOrTablet) ? `${(sidebarHeight || 400) + 20}px` : 20,
left: 18, left: 18,
zIndex: 18, zIndex: 18,
display: "flex", display: "flex",
@@ -173,6 +215,7 @@ export default function PreviewMapShell({
gap: 12, gap: 12,
width: 58, width: 58,
pointerEvents: "none", pointerEvents: "none",
transition: "bottom 0.3s cubic-bezier(0.4, 0, 0.2, 1)",
}} }}
> >
<div <div
@@ -193,9 +236,8 @@ export default function PreviewMapShell({
width: 46, width: 46,
height: 46, height: 46,
backgroundColor: "#1e293b", backgroundColor: "#1e293b",
color: "#f8fafc", border: "1px solid rgba(255, 255, 255, 0.15)",
border: "1px solid rgba(255, 255, 255, 0.1)", borderRadius: "50%",
borderRadius: 12,
display: "flex", display: "flex",
alignItems: "center", alignItems: "center",
justifyContent: "center", justifyContent: "center",
@@ -204,21 +246,21 @@ export default function PreviewMapShell({
boxShadow: "0 4px 12px rgba(0, 0, 0, 0.15)", boxShadow: "0 4px 12px rgba(0, 0, 0, 0.15)",
backdropFilter: "blur(8px)", backdropFilter: "blur(8px)",
flexShrink: 0, flexShrink: 0,
overflow: "hidden",
padding: 0,
}} }}
> >
<svg <Image
width="20" src={avatarUrl || "/images/no-images.jpg"}
height="20" alt="Cài đặt"
viewBox="0 0 24 24" width={46}
fill="none" height={46}
stroke="currentColor" style={{
strokeWidth="2" width: "100%",
strokeLinecap="round" height: "100%",
strokeLinejoin="round" objectFit: "cover",
> }}
<circle cx="12" cy="12" r="3" /> />
<path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 1 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 1 1-2.83-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 1 1 2.83-2.83l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 1 1 2.83 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z" />
</svg>
</button> </button>
{isMenuOpen && ( {isMenuOpen && (
@@ -233,9 +275,19 @@ export default function PreviewMapShell({
> >
<button <button
type="button" type="button"
onClick={() => { window.location.href = "/user"; }} onClick={() => {
title="Quản trị & Chỉnh sửa (Edit)" if (isMobileOrTablet) {
style={menuOptionStyle} 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"> <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" /> <path d="M12 20h9M16.5 3.5a2.121 2.121 0 0 1 3 3L7 19l-4 1 1-4L16.5 3.5z" />
@@ -258,6 +310,19 @@ export default function PreviewMapShell({
</svg> </svg>
</button> </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 <button
type="button" type="button"
onClick={() => { window.location.href = "/faq"; }} onClick={() => { window.location.href = "/faq"; }}
@@ -281,33 +346,110 @@ export default function PreviewMapShell({
<line x1="12" y1="8" x2="12.01" y2="8" /> <line x1="12" y1="8" x2="12.01" y2="8" />
</svg> </svg>
</button> </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>
)} )}
</div> </div>
<div {isLayerPanelVisible && (
style={{ <div
flexGrow: 1, style={{
flexShrink: 1, flexGrow: 1,
minHeight: 0, flexShrink: 1,
display: "flex", minHeight: 0,
flexDirection: "column", display: "flex",
pointerEvents: "auto", flexDirection: "column",
}} pointerEvents: "auto",
> }}
<ReplayPreviewLayerPanel >
backgroundVisibility={backgroundVisibility} <ReplayPreviewLayerPanel
geometryVisibility={geometryVisibility} backgroundVisibility={backgroundVisibility}
onToggleBackground={onToggleBackground} geometryVisibility={geometryVisibility}
onToggleGeometry={onToggleGeometry} onToggleBackground={onToggleBackground}
/> onToggleGeometry={onToggleGeometry}
</div> onHide={() => setIsLayerPanelVisible(false)}
/>
</div>
)}
</aside> </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} {overlay}
{activeEntity ? ( {activeEntity ? (
<aside className="absolute bottom-4 right-4 top-4 z-20 max-w-[calc(100vw-2rem)]"> <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 <PublicWikiSidebar
entity={activeEntity} entity={activeEntity}
wiki={activeWiki} wiki={activeWiki}
@@ -319,10 +461,13 @@ export default function PreviewMapShell({
onSidebarWidthChange={onSidebarWidthChange} onSidebarWidthChange={onSidebarWidthChange}
maxDragWidth={maxSidebarDragWidth} maxDragWidth={maxSidebarDragWidth}
compactHeader compactHeader
sidebarHeight={sidebarHeight}
onSidebarHeightChange={onSidebarHeightChange}
/> />
</aside> </aside>
) : null} ) : null}
<ChatbotWidget hideFloatingButton />
{children} {children}
</div> </div>
</div> </div>
@@ -40,8 +40,8 @@ interface PublicPreviewClientPageProps {
toggleInstantLoad: (val: boolean) => void; toggleInstantLoad: (val: boolean) => void;
} }
export default function PublicPreviewClientPage({ export default function PublicPreviewClientPage({
userHasEntered, userHasEntered,
onEnter, onEnter,
instantLoad, instantLoad,
toggleInstantLoad toggleInstantLoad
@@ -69,8 +69,25 @@ export default function PublicPreviewClientPage({
} }
return 420; return 420;
}); });
const [sidebarHeight, setSidebarHeight] = useState<number>(() => {
if (typeof window !== "undefined") {
const saved = localStorage.getItem("public-wiki-sidebar-height");
if (saved) {
const parsed = parseInt(saved, 10);
if (!isNaN(parsed) && parsed >= 200 && parsed <= 1200) return parsed;
}
}
return 400;
});
const handleSidebarHeightChange = (height: number) => {
setSidebarHeight(height);
if (typeof window !== "undefined") {
localStorage.setItem("public-wiki-sidebar-height", String(height));
}
};
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);
useEffect(() => { useEffect(() => {
if (typeof window !== "undefined") { if (typeof window !== "undefined") {
@@ -100,7 +117,7 @@ export default function PublicPreviewClientPage({
setLoadInteractiveMap(true); setLoadInteractiveMap(true);
} }
}, [userHasEntered]); }, [userHasEntered]);
const mapHandleRef = useRef<MapHandle>(null); const mapHandleRef = useRef<MapHandle>(null);
const isFirstMount = useRef(true); const isFirstMount = useRef(true);
const [replayMode, setReplayMode] = useState<"idle" | "playing">("idle"); const [replayMode, setReplayMode] = useState<"idle" | "playing">("idle");
@@ -376,13 +393,16 @@ export default function PublicPreviewClientPage({
const displayedActiveWiki = isSidebarOpen ? activeWiki : null; const displayedActiveWiki = isSidebarOpen ? activeWiki : null;
const computedTimelineStyle = useMemo(() => { const computedTimelineStyle = useMemo(() => {
const leftMargin = isLayerPanelVisible ? 88 : 18;
const rightMargin = (displayedActiveEntity && isLargeScreen) ? sidebarWidth + 32 : 18; const rightMargin = (displayedActiveEntity && isLargeScreen) ? sidebarWidth + 32 : 18;
const bottomOffset = (displayedActiveEntity && !isLargeScreen) ? `${sidebarHeight + 16}px` : undefined;
return { return {
left: "88px", left: `${leftMargin}px`,
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)", 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)",
}; };
}, [displayedActiveEntity, isLargeScreen, sidebarWidth]); }, [isLayerPanelVisible, displayedActiveEntity, isLargeScreen, sidebarWidth, sidebarHeight]);
return ( return (
<> <>
@@ -396,6 +416,8 @@ export default function PublicPreviewClientPage({
onSelectFeatureIds={setSelectedFeatureIds} onSelectFeatureIds={setSelectedFeatureIds}
instantLoad={instantLoad} instantLoad={instantLoad}
onToggleInstantLoad={toggleInstantLoad} onToggleInstantLoad={toggleInstantLoad}
isLayerPanelVisible={isLayerPanelVisible}
onLayerPanelVisibleChange={setIsLayerPanelVisible}
backgroundVisibility={backgroundVisibility} backgroundVisibility={backgroundVisibility}
geometryVisibility={geometryVisibility} geometryVisibility={geometryVisibility}
onToggleBackground={handleToggleBackgroundLayer} onToggleBackground={handleToggleBackgroundLayer}
@@ -423,6 +445,8 @@ export default function PublicPreviewClientPage({
sidebarWidth={sidebarWidth} sidebarWidth={sidebarWidth}
onSidebarWidthChange={setSidebarWidth} onSidebarWidthChange={setSidebarWidth}
maxSidebarDragWidth={maxDragWidth} maxSidebarDragWidth={maxDragWidth}
sidebarHeight={sidebarHeight}
onSidebarHeightChange={handleSidebarHeightChange}
onPlayPreviewReplay={activeReplay && replayMode === "idle" ? handlePlayPreviewReplay : undefined} onPlayPreviewReplay={activeReplay && replayMode === "idle" ? handlePlayPreviewReplay : undefined}
timelineDisabled={replayMode === "playing"} timelineDisabled={replayMode === "playing"}
overlay={ overlay={
@@ -450,7 +474,8 @@ export default function PublicPreviewClientPage({
style={{ style={{
position: "absolute", position: "absolute",
top: 10, top: 10,
left: 80, left: "50%",
transform: "translateX(-50%)",
zIndex: 18, zIndex: 18,
display: "flex", display: "flex",
gap: "10px", gap: "10px",
@@ -467,6 +492,7 @@ export default function PublicPreviewClientPage({
position: "relative", position: "relative",
top: 0, top: 0,
left: 0, left: 0,
transform: "none",
width: "min(392px, calc(100vw - 120px))", width: "min(392px, calc(100vw - 120px))",
}} }}
/> />
@@ -475,7 +501,7 @@ export default function PublicPreviewClientPage({
)} )}
{/* Smooth transition loading overlay */} {/* Smooth transition loading overlay */}
<div <div
style={{ style={{
position: "fixed", position: "fixed",
inset: 0, inset: 0,
@@ -95,10 +95,24 @@ export default function PublicPreviewWrapper() {
window.addEventListener("touchstart", handleInteraction, { passive: true, once: true }); window.addEventListener("touchstart", handleInteraction, { passive: true, once: true });
window.addEventListener("keydown", handleInteraction, { passive: true, once: true }); window.addEventListener("keydown", handleInteraction, { passive: true, once: true });
// Auto enter after 3 seconds on mobile
let timerId: ReturnType<typeof setTimeout> | null = null;
if (typeof window !== "undefined") {
const isMobile = window.innerWidth < 768 || /Mobi|Android|iPhone/i.test(navigator.userAgent);
if (isMobile) {
timerId = setTimeout(() => {
handleEnter();
}, 3000);
}
}
return () => { return () => {
window.removeEventListener("click", handleInteraction); window.removeEventListener("click", handleInteraction);
window.removeEventListener("touchstart", handleInteraction); window.removeEventListener("touchstart", handleInteraction);
window.removeEventListener("keydown", handleInteraction); window.removeEventListener("keydown", handleInteraction);
if (timerId) {
clearTimeout(timerId);
}
}; };
}, [mapEntered]); }, [mapEntered]);
@@ -111,9 +125,9 @@ export default function PublicPreviewWrapper() {
} }
return ( return (
<PublicPreviewClientPage <PublicPreviewClientPage
userHasEntered={mapEntered} userHasEntered={mapEntered}
onEnter={handleEnter} onEnter={handleEnter}
instantLoad={instantLoad} instantLoad={instantLoad}
toggleInstantLoad={toggleInstantLoad} toggleInstantLoad={toggleInstantLoad}
/> />
@@ -63,8 +63,6 @@ export function usePublicPreviewData(options: {
try { try {
next = await fetchGeometriesByBBox({ ...WORLD_BBOX, time: timelineYear, timeRange }); next = await fetchGeometriesByBBox({ ...WORLD_BBOX, time: timelineYear, timeRange });
if (disposed || requestId !== timelineFetchRequestRef.current) return; if (disposed || requestId !== timelineFetchRequestRef.current) return;
setRelations(EMPTY_PREVIEW_RELATIONS);
setData(next);
} catch (err) { } catch (err) {
if (err instanceof ApiError) { if (err instanceof ApiError) {
console.error("Load public map geometries failed", err.body); console.error("Load public map geometries failed", err.body);
@@ -89,8 +87,11 @@ export function usePublicPreviewData(options: {
.map((feature) => String(feature.properties.id)) .map((feature) => String(feature.properties.id))
.filter((id) => Boolean(id) && UUID_REGEX.test(id)); .filter((id) => Boolean(id) && UUID_REGEX.test(id));
if (!geometryIds.length) { if (!geometryIds.length) {
setRelations(EMPTY_PREVIEW_RELATIONS); if (!disposed && requestId === timelineFetchRequestRef.current) {
setReplays([]); setData(next);
setRelations(EMPTY_PREVIEW_RELATIONS);
setReplays([]);
}
return; return;
} }
@@ -120,7 +121,6 @@ export function usePublicPreviewData(options: {
fetchedReplays = Array.from(uniqueReplaysMap.values()); fetchedReplays = Array.from(uniqueReplaysMap.values());
if (disposed || requestId !== timelineFetchRequestRef.current) return; if (disposed || requestId !== timelineFetchRequestRef.current) return;
setReplays(fetchedReplays);
} catch (err) { } catch (err) {
if (err instanceof ApiError) { if (err instanceof ApiError) {
console.error("Load public map geometry-entity relations failed", err.body); console.error("Load public map geometry-entity relations failed", err.body);
@@ -128,7 +128,9 @@ export function usePublicPreviewData(options: {
console.error("Load public map geometry-entity relations failed", err); console.error("Load public map geometry-entity relations failed", err);
} }
if (!disposed && requestId === timelineFetchRequestRef.current) { if (!disposed && requestId === timelineFetchRequestRef.current) {
setData(next); // Fallback to new geometry even if relations failed
setRelations(EMPTY_PREVIEW_RELATIONS); setRelations(EMPTY_PREVIEW_RELATIONS);
setReplays([]);
setRelationsStatus("Không tải được liên kết entity/wiki cho bản đồ."); setRelationsStatus("Không tải được liên kết entity/wiki cho bản đồ.");
setIsRelationsLoading(false); setIsRelationsLoading(false);
} }
@@ -143,7 +145,11 @@ export function usePublicPreviewData(options: {
const entityOnlyRelations = buildPublicPreviewRelationIndex( const entityOnlyRelations = buildPublicPreviewRelationIndex(
buildRelationInputFromGeometryRelations(next, entitiesByGeometryId, {}) buildRelationInputFromGeometryRelations(next, entitiesByGeometryId, {})
); );
// Apply BOTH geometries and relations at the exact same React render cycle!
setData(next);
setRelations(entityOnlyRelations); setRelations(entityOnlyRelations);
setReplays(fetchedReplays);
// Mark loading as complete immediately so map transitions and becomes interactive // Mark loading as complete immediately so map transitions and becomes interactive
setIsRelationsLoading(false); setIsRelationsLoading(false);
+220 -25
View File
@@ -145,6 +145,161 @@ export default function TimelineBar({
onTimeRangeChange(Math.max(0, Math.min(30, safe))); onTimeRangeChange(Math.max(0, Math.min(30, safe)));
}; };
const [isMobile, setIsMobile] = useState(false);
useEffect(() => {
const checkMobile = () => setIsMobile(window.innerWidth < 768);
checkMobile();
window.addEventListener("resize", checkMobile);
return () => window.removeEventListener("resize", checkMobile);
}, []);
useEffect(() => {
if (isMobile && typeof timeRange === "number" && timeRange !== 0 && onTimeRangeChange) {
onTimeRangeChange(0);
}
}, [isMobile, timeRange, onTimeRangeChange]);
if (isMobile) {
return (
<div
style={{
position: "absolute",
zIndex: style?.zIndex ?? 10,
display: "flex",
flexDirection: "column",
gap: 8,
pointerEvents: "none",
...style,
left: style?.left ?? 18,
right: style?.right ?? 18,
bottom: style?.bottom ?? 16,
}}
title={helperText || undefined}
>
{/* Year Controls row above */}
<div
style={{
display: "flex",
alignItems: "center",
justifyContent: "center",
gap: 12,
margin: "0 auto",
background: "transparent",
border: "none",
borderRadius: "0px",
padding: "6px 0",
pointerEvents: "auto",
boxShadow: "none",
backdropFilter: "none",
WebkitBackdropFilter: "none",
}}
>
{typeof filterEnabled === "boolean" && onFilterEnabledChange ? (
<>
<button
type="button"
role="switch"
aria-checked={filterEnabled}
aria-label="Toggle timeline filter"
title={filterEnabled ? "Đang bật lọc timeline" : "Đang tắt lọc timeline (hiển thị tất cả geometry)"}
className={`${styles.toggleContainer} ${effectiveDisabled ? styles.disabled : ""}`}
onClick={() => onFilterEnabledChange(!filterEnabled)}
disabled={effectiveDisabled}
>
<span
aria-hidden="true"
className={`${styles.toggleTrack} ${filterEnabled ? styles.toggleTrackActive : ""}`}
>
<span
className={`${styles.toggleThumb} ${filterEnabled ? styles.toggleThumbActive : ""}`}
/>
</span>
</button>
<div style={{ width: 1, height: 16, backgroundColor: "rgba(255, 255, 255, 0.15)" }} />
</>
) : null}
{/* Adjust decrease button on the left of input */}
<button
type="button"
onMouseDown={() => startChangingYear(-1)}
onMouseUp={stopChangingYear}
onMouseLeave={stopChangingYear}
onTouchStart={() => startChangingYear(-1)}
onTouchEnd={stopChangingYear}
disabled={effectiveDisabled}
className={styles.adjustBtn}
style={{ width: 32, height: 32, borderRadius: 8, fontSize: 16 }}
title="Giảm 1 năm"
aria-label="Giảm 1 năm"
>
-
</button>
{/* Year Input */}
<input
type="number"
min={lower}
max={upper}
step={1}
value={displayYear}
onChange={(event) => handleLocalYearChange(Number(event.target.value))}
onBlur={finishLocalYearChange}
onKeyDown={(e) => {
if (e.key === "Enter") {
finishLocalYearChange();
}
}}
disabled={effectiveDisabled}
className={styles.numberInput}
style={{ width: 90, textAlign: "center", height: 32, padding: "0 6px" }}
aria-label="Timeline exact year"
/>
{/* Adjust increase button on the right of input */}
<button
type="button"
onMouseDown={() => startChangingYear(1)}
onMouseUp={stopChangingYear}
onMouseLeave={stopChangingYear}
onTouchStart={() => startChangingYear(1)}
onTouchEnd={stopChangingYear}
disabled={effectiveDisabled}
className={styles.adjustBtn}
style={{ width: 32, height: 32, borderRadius: 8, fontSize: 16 }}
title="Tăng 1 năm"
aria-label="Tăng 1 năm"
>
+
</button>
</div>
{/* Ruler row below: full width */}
<div
className={`${styles.container} ${isLoading ? styles.containerLoading : ""} ${effectiveDisabled ? styles.disabled : ""}`}
style={{
position: "static", // Override absolute positioning of .container
padding: "8px 12px",
pointerEvents: "auto",
width: "100%",
borderRadius: "24px",
}}
>
<div className={styles.flexWrapper} style={{ width: "100%", display: "flex" }}>
<CanvasTimelineRuler
year={displayYear}
onYearChange={handleLocalYearChange}
onYearCommit={finishLocalYearChange}
minYear={lower}
maxYear={upper}
disabled={effectiveDisabled}
/>
</div>
</div>
</div>
);
}
return ( return (
<div <div
className={`${styles.container} ${isLoading ? styles.containerLoading : ""} ${effectiveDisabled ? styles.disabled : ""}`} className={`${styles.container} ${isLoading ? styles.containerLoading : ""} ${effectiveDisabled ? styles.disabled : ""}`}
@@ -298,6 +453,10 @@ function CanvasTimelineRuler({
hasDragged: boolean; hasDragged: boolean;
} | null>(null); } | null>(null);
const activePointersRef = useRef<Record<number, { clientX: number; clientY: number }>>({});
const initialPinchDistanceRef = useRef<number | null>(null);
const initialSpanRef = useRef<number | null>(null);
// Sync dimensions using ResizeObserver // Sync dimensions using ResizeObserver
useEffect(() => { useEffect(() => {
const container = containerRef.current; const container = containerRef.current;
@@ -325,7 +484,7 @@ function CanvasTimelineRuler({
const width = dimensions.width; const width = dimensions.width;
const height = dimensions.height; const height = dimensions.height;
ctx.clearRect(0, 0, width, height); ctx.clearRect(0, 0, canvas.width, canvas.height);
ctx.save(); ctx.save();
ctx.scale(dpr, dpr); ctx.scale(dpr, dpr);
@@ -501,49 +660,85 @@ function CanvasTimelineRuler({
e.currentTarget.setPointerCapture(e.pointerId); e.currentTarget.setPointerCapture(e.pointerId);
} catch {} } catch {}
dragRef.current = { activePointersRef.current[e.pointerId] = { clientX: e.clientX, clientY: e.clientY };
isDragging: true,
startX: e.clientX, const activePointerIds = Object.keys(activePointersRef.current);
startYear: displayYearRef.current, if (activePointerIds.length === 2) {
hasDragged: false, const p1 = activePointersRef.current[Number(activePointerIds[0])];
}; const p2 = activePointersRef.current[Number(activePointerIds[1])];
const dx = p1.clientX - p2.clientX;
const dy = p1.clientY - p2.clientY;
initialPinchDistanceRef.current = Math.sqrt(dx * dx + dy * dy);
initialSpanRef.current = span;
if (dragRef.current) {
dragRef.current.isDragging = false;
}
} else {
dragRef.current = {
isDragging: true,
startX: e.clientX,
startYear: displayYearRef.current,
hasDragged: false,
};
}
}; };
const handlePointerMove = (e: React.PointerEvent<HTMLCanvasElement>) => { const handlePointerMove = (e: React.PointerEvent<HTMLCanvasElement>) => {
if (!dragRef.current || !dragRef.current.isDragging) return; if (disabled) return;
e.preventDefault(); e.preventDefault();
const dx = e.clientX - dragRef.current.startX; activePointersRef.current[e.pointerId] = { clientX: e.clientX, clientY: e.clientY };
if (Math.abs(dx) > 3) {
dragRef.current.hasDragged = true;
}
const yearsPerPixel = span / dimensions.width; const activePointerIds = Object.keys(activePointersRef.current);
const deltaYears = -dx * yearsPerPixel; if (activePointerIds.length === 2 && initialPinchDistanceRef.current !== null && initialSpanRef.current !== null) {
const nextYear = clampYearValue(Math.round(dragRef.current.startYear + deltaYears), minYear, maxYear); const p1 = activePointersRef.current[Number(activePointerIds[0])];
const p2 = activePointersRef.current[Number(activePointerIds[1])];
const dx = p1.clientX - p2.clientX;
const dy = p1.clientY - p2.clientY;
const currentDist = Math.sqrt(dx * dx + dy * dy);
if (currentDist > 5) {
const zoomFactor = initialPinchDistanceRef.current / currentDist;
const nextSpan = Math.max(10, Math.min(10000, initialSpanRef.current * zoomFactor));
setSpan(Math.round(nextSpan));
}
} else if (dragRef.current && dragRef.current.isDragging) {
const dx = e.clientX - dragRef.current.startX;
if (Math.abs(dx) > 3) {
dragRef.current.hasDragged = true;
}
if (nextYear !== displayYearRef.current) { const yearsPerPixel = span / dimensions.width;
displayYearRef.current = nextYear; const deltaYears = -dx * yearsPerPixel;
// Draw synchronously at 60fps const nextYear = clampYearValue(Math.round(dragRef.current.startYear + deltaYears), minYear, maxYear);
requestAnimationFrame(() => {
drawYear(displayYearRef.current); if (nextYear !== displayYearRef.current) {
}); displayYearRef.current = nextYear;
onYearChange(nextYear); requestAnimationFrame(() => {
drawYear(displayYearRef.current);
});
onYearChange(nextYear);
}
} }
}; };
const handlePointerUp = (e: React.PointerEvent<HTMLCanvasElement>) => { const handlePointerUp = (e: React.PointerEvent<HTMLCanvasElement>) => {
if (!dragRef.current) return;
e.preventDefault(); e.preventDefault();
try { try {
e.currentTarget.releasePointerCapture(e.pointerId); e.currentTarget.releasePointerCapture(e.pointerId);
} catch {} } catch {}
delete activePointersRef.current[e.pointerId];
if (Object.keys(activePointersRef.current).length < 2) {
initialPinchDistanceRef.current = null;
initialSpanRef.current = null;
}
if (!dragRef.current) return;
const dragInfo = dragRef.current; const dragInfo = dragRef.current;
dragRef.current = null; dragRef.current = null;
if (!dragInfo.hasDragged) { if (dragInfo.isDragging && !dragInfo.hasDragged) {
// Click to jump
const canvas = canvasRef.current; const canvas = canvasRef.current;
if (canvas) { if (canvas) {
const rect = canvas.getBoundingClientRect(); const rect = canvas.getBoundingClientRect();
+89 -4
View File
@@ -23,6 +23,8 @@ type Props = {
onSidebarWidthChange?: (width: number) => void; onSidebarWidthChange?: (width: number) => void;
maxDragWidth?: number; maxDragWidth?: number;
compactHeader?: boolean; compactHeader?: boolean;
sidebarHeight?: number;
onSidebarHeightChange?: (height: number) => void;
}; };
function escapeHtml(input: string): string { function escapeHtml(input: string): string {
@@ -135,6 +137,8 @@ function PublicWikiSidebar({
onSidebarWidthChange, onSidebarWidthChange,
maxDragWidth, maxDragWidth,
compactHeader = false, compactHeader = false,
sidebarHeight,
onSidebarHeightChange,
}: Props) { }: Props) {
const contentRootRef = useRef<HTMLDivElement | null>(null); const contentRootRef = useRef<HTMLDivElement | null>(null);
const tocContainerRef = useRef<HTMLDivElement | null>(null); const tocContainerRef = useRef<HTMLDivElement | null>(null);
@@ -201,6 +205,48 @@ function PublicWikiSidebar({
window.addEventListener("pointerup", onUp); window.addEventListener("pointerup", onUp);
}; };
const handleHeightPointerDown = (event: React.PointerEvent<HTMLDivElement>) => {
event.preventDefault();
const startY = event.clientY;
const startHeight = sidebarHeight || 400;
// Tạo đường ghost ảo nằm ngang chỉ vị trí kéo chiều cao
const ghost = document.createElement("div");
ghost.style.position = "fixed";
ghost.style.left = "0";
ghost.style.right = "0";
ghost.style.height = "4px";
ghost.style.backgroundColor = "#38bdf8";
ghost.style.boxShadow = "0 0 12px rgba(56, 189, 248, 0.8)";
ghost.style.zIndex = "99999";
ghost.style.cursor = "row-resize";
ghost.style.pointerEvents = "none";
const startScreenY = window.innerHeight - startHeight;
ghost.style.top = `${startScreenY}px`;
document.body.appendChild(ghost);
const onMove = (e: PointerEvent) => {
ghost.style.top = `${e.clientY}px`;
};
const onUp = (e: PointerEvent) => {
window.removeEventListener("pointermove", onMove);
window.removeEventListener("pointerup", onUp);
if (ghost.parentNode) {
ghost.parentNode.removeChild(ghost);
}
const deltaY = startY - e.clientY;
const nextHeight = Math.max(200, Math.min(window.innerHeight * 0.9, startHeight + deltaY));
if (onSidebarHeightChange) {
onSidebarHeightChange(nextHeight);
}
};
window.addEventListener("pointermove", onMove);
window.addEventListener("pointerup", onUp);
};
const [activeHeadingId, setActiveHeadingId] = useState<string | null>(null); const [activeHeadingId, setActiveHeadingId] = useState<string | null>(null);
const processedWiki = useMemo(() => { const processedWiki = useMemo(() => {
if (!wiki) return { html: "", toc: [] as TocItem[] }; if (!wiki) return { html: "", toc: [] as TocItem[] };
@@ -281,23 +327,62 @@ function PublicWikiSidebar({
return () => root.removeEventListener("click", handleClick); return () => root.removeEventListener("click", handleClick);
}, [onWikiLinkRequest, renderHtml]); }, [onWikiLinkRequest, renderHtml]);
const [isMobileOrTablet, setIsMobileOrTablet] = useState(false);
useEffect(() => {
const checkDevice = () => setIsMobileOrTablet(window.innerWidth < 1024);
checkDevice();
window.addEventListener("resize", checkDevice);
return () => window.removeEventListener("resize", checkDevice);
}, []);
return ( return (
<div <div
style={{ style={{
width: `${width}px`, width: "100%",
maxWidth: isMobileOrTablet ? "100%" : `${width}px`,
display: "flex", display: "flex",
flexDirection: "column", flexDirection: "column",
height: "100%", height: "100%",
minHeight: 0, minHeight: 0,
overflow: "hidden", overflow: "hidden",
borderRadius: 20, borderRadius: isMobileOrTablet ? "24px 24px 0 0" : 20,
border: "1px solid rgba(148, 163, 184, 0.22)", border: isMobileOrTablet ? "1px solid rgba(148, 163, 184, 0.22)" : "1px solid rgba(148, 163, 184, 0.22)",
borderBottom: isMobileOrTablet ? "none" : "1px solid rgba(148, 163, 184, 0.22)",
borderLeft: isMobileOrTablet ? "none" : "1px solid rgba(148, 163, 184, 0.22)",
borderRight: isMobileOrTablet ? "none" : "1px solid rgba(148, 163, 184, 0.22)",
background: "linear-gradient(145deg, rgba(15, 23, 42, 0.95), rgba(30, 41, 59, 0.85))", 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)", boxShadow: "0 20px 48px rgba(2, 6, 23, 0.45)",
backdropFilter: "blur(12px)", backdropFilter: "blur(12px)",
position: "relative", position: "relative",
}} }}
> >
{/* Grab Handle for bottom sheet on mobile */}
{isMobileOrTablet ? (
<div
onPointerDown={handleHeightPointerDown}
style={{
width: "100%",
height: 24,
display: "flex",
alignItems: "center",
justifyContent: "center",
cursor: "row-resize",
zIndex: 60,
userSelect: "none",
flexShrink: 0,
}}
>
<div
style={{
width: 36,
height: 4,
borderRadius: 2,
backgroundColor: "rgba(255, 255, 255, 0.3)",
}}
/>
</div>
) : null}
{/* Drag Handle on the left edge */} {/* Drag Handle on the left edge */}
<div <div
onPointerDown={handlePointerDown} onPointerDown={handlePointerDown}
@@ -311,7 +396,7 @@ function PublicWikiSidebar({
zIndex: 50, zIndex: 50,
userSelect: "none", userSelect: "none",
}} }}
className="group" className="group hidden lg:block"
title="Kéo để chỉnh kích thước" title="Kéo để chỉnh kích thước"
> >
{/* Visual drag line overlay */} {/* Visual drag line overlay */}