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
Build and Release / release (push) Successful in 37s
This commit is contained in:
@@ -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)}
|
||||||
|
|||||||
@@ -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,6 +619,7 @@ const Map = memo(forwardRef<MapHandle, MapProps>(function Map({
|
|||||||
</button>
|
</button>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
|
<div className="hidden sm:flex items-center gap-[10px] flex-shrink-0">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => handleZoomByStep(-0.8)}
|
onClick={() => handleZoomByStep(-0.8)}
|
||||||
@@ -679,6 +686,7 @@ const Map = memo(forwardRef<MapHandle, MapProps>(function Map({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
</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,12 +223,23 @@ 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()}
|
||||||
|
|
||||||
|
<div
|
||||||
|
className="replay-preview-layer-panel-scroll"
|
||||||
|
style={{
|
||||||
|
flexGrow: 1,
|
||||||
|
overflowY: "auto",
|
||||||
|
width: "100%",
|
||||||
|
display: "flex",
|
||||||
|
flexDirection: "column",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: 12,
|
||||||
|
}}
|
||||||
|
>
|
||||||
{/* Background layers */}
|
{/* Background layers */}
|
||||||
<div style={groupHeaderStyle}>Map</div>
|
<div style={groupHeaderStyle}>Map</div>
|
||||||
<div style={gridStyle}>
|
<div style={gridStyle}>
|
||||||
@@ -313,6 +325,33 @@ export default function ReplayPreviewLayerPanel({
|
|||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{onHide && (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
width: "100%",
|
||||||
|
display: "flex",
|
||||||
|
flexDirection: "column",
|
||||||
|
alignItems: "center",
|
||||||
|
marginTop: 6,
|
||||||
|
flexShrink: 0,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div style={dividerStyle} />
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
title="Ẩn bảng lớp bản đồ"
|
||||||
|
onClick={onHide}
|
||||||
|
style={getButtonStyles(true, "239, 68, 68")}
|
||||||
|
>
|
||||||
|
<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" />
|
||||||
|
<line x1="1" y1="1" x2="23" y2="23" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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";
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|
||||||
|
const countriesStr = JSON.stringify(labeledGeometries);
|
||||||
|
if (countriesStr !== lastCountriesStrRef.current) {
|
||||||
countriesSource.setData(labeledGeometries);
|
countriesSource.setData(labeledGeometries);
|
||||||
|
lastCountriesStrRef.current = countriesStr;
|
||||||
|
}
|
||||||
|
|
||||||
|
const placesStr = JSON.stringify(labeledPoints);
|
||||||
|
if (placesStr !== lastPlacesStrRef.current) {
|
||||||
placesSource.setData(labeledPoints);
|
placesSource.setData(labeledPoints);
|
||||||
|
lastPlacesStrRef.current = placesStr;
|
||||||
|
}
|
||||||
|
|
||||||
|
const polygonLabelsStr = JSON.stringify(polygonLabels);
|
||||||
|
if (polygonLabelsStr !== lastPolygonLabelStrRef.current) {
|
||||||
polygonLabelSource.setData(polygonLabels);
|
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) {
|
||||||
|
const pathArrowStr = JSON.stringify(pathArrowShapes);
|
||||||
|
if (pathArrowStr !== lastPathArrowStrRef.current) {
|
||||||
pathArrowSource.setData(pathArrowShapes);
|
pathArrowSource.setData(pathArrowShapes);
|
||||||
|
lastPathArrowStrRef.current = pathArrowStr;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
currentSelectedIds.forEach((id) => {
|
currentSelectedIds.forEach((id) => {
|
||||||
|
|||||||
@@ -137,7 +137,8 @@ export default function MapPlaceholder({ onEnter }: MapPlaceholderProps) {
|
|||||||
>
|
>
|
||||||
nhấn vào chỗ bất kì để vào
|
nhấn vào chỗ bất kì để 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); }
|
||||||
|
|||||||
@@ -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,
|
||||||
}}
|
}}
|
||||||
|
|||||||
@@ -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,10 +346,30 @@ 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>
|
||||||
|
|
||||||
|
{isLayerPanelVisible && (
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
flexGrow: 1,
|
flexGrow: 1,
|
||||||
@@ -300,14 +385,71 @@ export default function PreviewMapShell({
|
|||||||
geometryVisibility={geometryVisibility}
|
geometryVisibility={geometryVisibility}
|
||||||
onToggleBackground={onToggleBackground}
|
onToggleBackground={onToggleBackground}
|
||||||
onToggleGeometry={onToggleGeometry}
|
onToggleGeometry={onToggleGeometry}
|
||||||
|
onHide={() => setIsLayerPanelVisible(false)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</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>
|
||||||
|
|||||||
@@ -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") {
|
||||||
@@ -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))",
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -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]);
|
||||||
|
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
if (!disposed && requestId === timelineFetchRequestRef.current) {
|
||||||
|
setData(next);
|
||||||
setRelations(EMPTY_PREVIEW_RELATIONS);
|
setRelations(EMPTY_PREVIEW_RELATIONS);
|
||||||
setReplays([]);
|
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);
|
||||||
|
|||||||
@@ -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,18 +660,49 @@ function CanvasTimelineRuler({
|
|||||||
e.currentTarget.setPointerCapture(e.pointerId);
|
e.currentTarget.setPointerCapture(e.pointerId);
|
||||||
} catch {}
|
} catch {}
|
||||||
|
|
||||||
|
activePointersRef.current[e.pointerId] = { clientX: e.clientX, clientY: e.clientY };
|
||||||
|
|
||||||
|
const activePointerIds = Object.keys(activePointersRef.current);
|
||||||
|
if (activePointerIds.length === 2) {
|
||||||
|
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 = {
|
dragRef.current = {
|
||||||
isDragging: true,
|
isDragging: true,
|
||||||
startX: e.clientX,
|
startX: e.clientX,
|
||||||
startYear: displayYearRef.current,
|
startYear: displayYearRef.current,
|
||||||
hasDragged: false,
|
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();
|
||||||
|
|
||||||
|
activePointersRef.current[e.pointerId] = { clientX: e.clientX, clientY: e.clientY };
|
||||||
|
|
||||||
|
const activePointerIds = Object.keys(activePointersRef.current);
|
||||||
|
if (activePointerIds.length === 2 && initialPinchDistanceRef.current !== null && initialSpanRef.current !== null) {
|
||||||
|
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;
|
const dx = e.clientX - dragRef.current.startX;
|
||||||
if (Math.abs(dx) > 3) {
|
if (Math.abs(dx) > 3) {
|
||||||
dragRef.current.hasDragged = true;
|
dragRef.current.hasDragged = true;
|
||||||
@@ -524,26 +714,31 @@ function CanvasTimelineRuler({
|
|||||||
|
|
||||||
if (nextYear !== displayYearRef.current) {
|
if (nextYear !== displayYearRef.current) {
|
||||||
displayYearRef.current = nextYear;
|
displayYearRef.current = nextYear;
|
||||||
// Draw synchronously at 60fps
|
|
||||||
requestAnimationFrame(() => {
|
requestAnimationFrame(() => {
|
||||||
drawYear(displayYearRef.current);
|
drawYear(displayYearRef.current);
|
||||||
});
|
});
|
||||||
onYearChange(nextYear);
|
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();
|
||||||
|
|||||||
@@ -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 */}
|
||||||
|
|||||||
Reference in New Issue
Block a user