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({
|
||||
projectId = "",
|
||||
hideFloatingButton = false,
|
||||
}: {
|
||||
projectId?: string;
|
||||
hideFloatingButton?: boolean;
|
||||
}) {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [messages, setMessages] = useState<Message[]>([
|
||||
@@ -33,6 +35,14 @@ export default function ChatbotWidget({
|
||||
messagesEndRef.current?.scrollIntoView({ behavior: "smooth" });
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const handleToggle = () => setIsOpen((prev) => !prev);
|
||||
window.addEventListener("toggle-chatbot", handleToggle);
|
||||
return () => {
|
||||
window.removeEventListener("toggle-chatbot", handleToggle);
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
scrollToBottom();
|
||||
@@ -93,7 +103,7 @@ export default function ChatbotWidget({
|
||||
|
||||
return (
|
||||
<div className="fixed bottom-8 right-8 z-50">
|
||||
{!isOpen && (
|
||||
{!isOpen && !hideFloatingButton && (
|
||||
<button
|
||||
name="AI chat"
|
||||
onClick={() => setIsOpen(true)}
|
||||
|
||||
@@ -416,6 +416,11 @@ const Map = memo(forwardRef<MapHandle, MapProps>(function Map({
|
||||
cursor: pointer;
|
||||
outline: none;
|
||||
}
|
||||
@media (max-width: 768px) {
|
||||
.premium-zoom-slider {
|
||||
display: none !important;
|
||||
}
|
||||
}
|
||||
.premium-zoom-slider::-webkit-slider-runnable-track {
|
||||
width: 100%;
|
||||
height: 6px;
|
||||
@@ -514,6 +519,7 @@ const Map = memo(forwardRef<MapHandle, MapProps>(function Map({
|
||||
minWidth: "40px",
|
||||
transition: "color 0.25s ease",
|
||||
}}
|
||||
className="hidden sm:block"
|
||||
>
|
||||
{isGlobeProjection ? "Globe" : "Flat"}
|
||||
</span>
|
||||
@@ -613,6 +619,7 @@ const Map = memo(forwardRef<MapHandle, MapProps>(function Map({
|
||||
</button>
|
||||
) : null}
|
||||
|
||||
<div className="hidden sm:flex items-center gap-[10px] flex-shrink-0">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleZoomByStep(-0.8)}
|
||||
@@ -679,6 +686,7 @@ const Map = memo(forwardRef<MapHandle, MapProps>(function Map({
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -282,7 +282,8 @@ export default function PresentPlaceSearch({
|
||||
style={{
|
||||
position: "absolute",
|
||||
top: 10,
|
||||
left: leftOffset,
|
||||
left: "50%",
|
||||
transform: "translateX(-50%)",
|
||||
zIndex: 18,
|
||||
width: "min(392px, calc(100vw - 36px))",
|
||||
pointerEvents: "auto",
|
||||
|
||||
@@ -9,6 +9,7 @@ type Props = {
|
||||
geometryVisibility: Record<string, boolean>;
|
||||
onToggleBackground: (id: BackgroundLayerId) => void;
|
||||
onToggleGeometry: (typeKey: string) => void;
|
||||
onHide?: () => void;
|
||||
};
|
||||
|
||||
// Map each layer ID/geometry type to a premium inline SVG icon
|
||||
@@ -172,6 +173,7 @@ export default function ReplayPreviewLayerPanel({
|
||||
geometryVisibility,
|
||||
onToggleBackground,
|
||||
onToggleGeometry,
|
||||
onHide,
|
||||
}: Props) {
|
||||
// Categorize geometry types for logical grouping
|
||||
const polygonKeys = ["country", "state", "faction", "rebellion_zone"];
|
||||
@@ -196,10 +198,10 @@ export default function ReplayPreviewLayerPanel({
|
||||
|
||||
const renderStyles = () => (
|
||||
<style dangerouslySetInnerHTML={{ __html: `
|
||||
.replay-preview-layer-panel::-webkit-scrollbar {
|
||||
.replay-preview-layer-panel-scroll::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
.replay-preview-layer-panel {
|
||||
.replay-preview-layer-panel-scroll {
|
||||
scrollbar-width: none;
|
||||
-ms-overflow-style: none;
|
||||
}
|
||||
@@ -212,7 +214,6 @@ export default function ReplayPreviewLayerPanel({
|
||||
style={{
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
gap: 12,
|
||||
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)",
|
||||
borderRadius: 20,
|
||||
@@ -222,12 +223,23 @@ export default function ReplayPreviewLayerPanel({
|
||||
boxShadow: "0 20px 48px rgba(2, 6, 23, 0.45)",
|
||||
backdropFilter: "blur(12px)",
|
||||
maxHeight: "100%",
|
||||
overflowY: "auto",
|
||||
overflowX: "hidden",
|
||||
overflow: "hidden",
|
||||
}}
|
||||
>
|
||||
{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 */}
|
||||
<div style={groupHeaderStyle}>Map</div>
|
||||
<div style={gridStyle}>
|
||||
@@ -313,6 +325,33 @@ export default function ReplayPreviewLayerPanel({
|
||||
})}
|
||||
</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.padding = "12px";
|
||||
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.boxShadow = "0 18px 36px rgba(0, 0, 0, 0.35)";
|
||||
root.style.backdropFilter = "blur(8px)";
|
||||
@@ -201,7 +201,7 @@ function buildPopupNode(content: MapHoverPopupContent): HTMLElement {
|
||||
}
|
||||
card.style.width = "100%";
|
||||
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.padding = "12px";
|
||||
card.style.textAlign = "left";
|
||||
@@ -262,7 +262,7 @@ function stylePopupChrome(popup: maplibregl.Popup) {
|
||||
const content = element.querySelector(".maplibregl-popup-content") as HTMLElement | null;
|
||||
if (content) {
|
||||
content.style.padding = "0";
|
||||
content.style.borderRadius = "12px";
|
||||
content.style.borderRadius = "0px";
|
||||
content.style.background = "transparent";
|
||||
content.style.boxShadow = "none";
|
||||
}
|
||||
|
||||
@@ -94,6 +94,10 @@ export function useMapSync({
|
||||
isPreviewModeRef.current = isPreviewMode;
|
||||
|
||||
const fitBoundsAppliedRef = useRef(false);
|
||||
const lastCountriesStrRef = useRef("");
|
||||
const lastPlacesStrRef = useRef("");
|
||||
const lastPolygonLabelStrRef = useRef("");
|
||||
const lastPathArrowStrRef = useRef("");
|
||||
|
||||
useEffect(() => {
|
||||
fitBoundsAppliedRef.current = false;
|
||||
@@ -134,13 +138,31 @@ export function useMapSync({
|
||||
const polygonLabels = buildPolygonLabelFeatureCollection(polygons, labelContext, labelTimelineYear);
|
||||
const pathArrowShapes = buildPathArrowFeatureCollection(mapSourceDraft);
|
||||
|
||||
const countriesStr = JSON.stringify(labeledGeometries);
|
||||
if (countriesStr !== lastCountriesStrRef.current) {
|
||||
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;
|
||||
if (pathArrowSource) {
|
||||
const pathArrowStr = JSON.stringify(pathArrowShapes);
|
||||
if (pathArrowStr !== lastPathArrowStrRef.current) {
|
||||
pathArrowSource.setData(pathArrowShapes);
|
||||
lastPathArrowStrRef.current = pathArrowStr;
|
||||
}
|
||||
}
|
||||
|
||||
currentSelectedIds.forEach((id) => {
|
||||
|
||||
@@ -137,7 +137,8 @@ export default function MapPlaceholder({ onEnter }: MapPlaceholderProps) {
|
||||
>
|
||||
nhấn vào chỗ bất kì để vào
|
||||
</div>
|
||||
<style dangerouslySetInnerHTML={{ __html: `
|
||||
<style dangerouslySetInnerHTML={{
|
||||
__html: `
|
||||
@keyframes placeholder-pulse {
|
||||
0%, 100% { opacity: 0.35; transform: scale(0.98); }
|
||||
50% { opacity: 0.95; transform: scale(1); }
|
||||
|
||||
@@ -567,7 +567,6 @@ const PreviewLayout = forwardRef<PreviewLayoutHandle, Props>(({
|
||||
onFocusPlace={handleFocusPresentPlace}
|
||||
onFocusHistoricalGeometry={handleFocusHistoricalGeometry}
|
||||
onClearFocus={clearPresentPlaceFocus}
|
||||
leftOffset={18}
|
||||
/>
|
||||
|
||||
{isReplayPreviewMode ? (
|
||||
@@ -596,6 +595,7 @@ const PreviewLayout = forwardRef<PreviewLayoutHandle, Props>(({
|
||||
top: 16,
|
||||
right: 16,
|
||||
bottom: 16,
|
||||
left: isLargeScreen ? "auto" : 16,
|
||||
maxWidth: "calc(100vw - 2rem)",
|
||||
zIndex: 20,
|
||||
}}
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
"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 ReplayPreviewLayerPanel from "@/uhm/components/editor/ReplayPreviewLayerPanel";
|
||||
import PublicWikiSidebar from "@/uhm/components/wiki/PublicWikiSidebar";
|
||||
@@ -51,6 +54,10 @@ type Props = {
|
||||
onLoad?: () => void;
|
||||
instantLoad?: boolean;
|
||||
onToggleInstantLoad?: (val: boolean) => void;
|
||||
isLayerPanelVisible?: boolean;
|
||||
onLayerPanelVisibleChange?: (visible: boolean) => void;
|
||||
sidebarHeight?: number;
|
||||
onSidebarHeightChange?: (height: number) => void;
|
||||
};
|
||||
|
||||
export default function PreviewMapShell({
|
||||
@@ -92,8 +99,42 @@ export default function PreviewMapShell({
|
||||
onLoad,
|
||||
instantLoad = true,
|
||||
onToggleInstantLoad,
|
||||
isLayerPanelVisible: propsLayerPanelVisible,
|
||||
onLayerPanelVisibleChange,
|
||||
sidebarHeight,
|
||||
onSidebarHeightChange,
|
||||
}: Props) {
|
||||
const [isMenuOpen, setIsMenuOpen] = useState(false);
|
||||
const [avatarUrl, setAvatarUrl] = useState<string | null>(null);
|
||||
const [localLayerPanelVisible, setLocalLayerPanelVisible] = useState(true);
|
||||
const isLayerPanelVisible = propsLayerPanelVisible ?? localLayerPanelVisible;
|
||||
const setIsLayerPanelVisible = onLayerPanelVisibleChange ?? setLocalLayerPanelVisible;
|
||||
const [isMobileOrTablet, setIsMobileOrTablet] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const checkDevice = () => setIsMobileOrTablet(window.innerWidth < 1024);
|
||||
checkDevice();
|
||||
window.addEventListener("resize", checkDevice);
|
||||
return () => window.removeEventListener("resize", checkDevice);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const fetchUserAvatar = async () => {
|
||||
try {
|
||||
const userData = await apiGetCurrentUser();
|
||||
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 = {
|
||||
width: 46,
|
||||
@@ -133,6 +174,7 @@ export default function PreviewMapShell({
|
||||
getHoverPopupContent={getHoverPopupContent}
|
||||
onPlayPreviewReplay={onPlayPreviewReplay}
|
||||
onLoad={onLoad}
|
||||
showViewportControls={false}
|
||||
/>
|
||||
|
||||
<TimelineBar
|
||||
@@ -165,7 +207,7 @@ export default function PreviewMapShell({
|
||||
style={{
|
||||
position: "absolute",
|
||||
top: 10,
|
||||
bottom: 20,
|
||||
bottom: (activeEntity && isMobileOrTablet) ? `${(sidebarHeight || 400) + 20}px` : 20,
|
||||
left: 18,
|
||||
zIndex: 18,
|
||||
display: "flex",
|
||||
@@ -173,6 +215,7 @@ export default function PreviewMapShell({
|
||||
gap: 12,
|
||||
width: 58,
|
||||
pointerEvents: "none",
|
||||
transition: "bottom 0.3s cubic-bezier(0.4, 0, 0.2, 1)",
|
||||
}}
|
||||
>
|
||||
<div
|
||||
@@ -193,9 +236,8 @@ export default function PreviewMapShell({
|
||||
width: 46,
|
||||
height: 46,
|
||||
backgroundColor: "#1e293b",
|
||||
color: "#f8fafc",
|
||||
border: "1px solid rgba(255, 255, 255, 0.1)",
|
||||
borderRadius: 12,
|
||||
border: "1px solid rgba(255, 255, 255, 0.15)",
|
||||
borderRadius: "50%",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
@@ -204,21 +246,21 @@ export default function PreviewMapShell({
|
||||
boxShadow: "0 4px 12px rgba(0, 0, 0, 0.15)",
|
||||
backdropFilter: "blur(8px)",
|
||||
flexShrink: 0,
|
||||
overflow: "hidden",
|
||||
padding: 0,
|
||||
}}
|
||||
>
|
||||
<svg
|
||||
width="20"
|
||||
height="20"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
>
|
||||
<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>
|
||||
<Image
|
||||
src={avatarUrl || "/images/no-images.jpg"}
|
||||
alt="Cài đặt"
|
||||
width={46}
|
||||
height={46}
|
||||
style={{
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
objectFit: "cover",
|
||||
}}
|
||||
/>
|
||||
</button>
|
||||
|
||||
{isMenuOpen && (
|
||||
@@ -233,9 +275,19 @@ export default function PreviewMapShell({
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => { window.location.href = "/user"; }}
|
||||
title="Quản trị & Chỉnh sửa (Edit)"
|
||||
style={menuOptionStyle}
|
||||
onClick={() => {
|
||||
if (isMobileOrTablet) {
|
||||
alert("Tính năng quản trị và chỉnh sửa chỉ hỗ trợ trên máy tính (Desktop)");
|
||||
} else {
|
||||
window.location.href = "/user";
|
||||
}
|
||||
}}
|
||||
title={isMobileOrTablet ? "Tính năng này chỉ hoạt động trên Desktop" : "Quản trị & Chỉnh sửa (Edit)"}
|
||||
style={{
|
||||
...menuOptionStyle,
|
||||
opacity: isMobileOrTablet ? 0.5 : 1,
|
||||
cursor: isMobileOrTablet ? "not-allowed" : "pointer",
|
||||
}}
|
||||
>
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M12 20h9M16.5 3.5a2.121 2.121 0 0 1 3 3L7 19l-4 1 1-4L16.5 3.5z" />
|
||||
@@ -258,6 +310,19 @@ export default function PreviewMapShell({
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
window.dispatchEvent(new CustomEvent("toggle-chatbot"));
|
||||
}}
|
||||
title="Trợ lý AI Lịch sử (Chatbot)"
|
||||
style={menuOptionStyle}
|
||||
>
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z" />
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => { window.location.href = "/faq"; }}
|
||||
@@ -281,10 +346,30 @@ export default function PreviewMapShell({
|
||||
<line x1="12" y1="8" x2="12.01" y2="8" />
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
{!isLayerPanelVisible && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setIsLayerPanelVisible(true)}
|
||||
title="Hiện bảng lớp bản đồ"
|
||||
style={{
|
||||
...menuOptionStyle,
|
||||
color: "#10b981",
|
||||
border: "1px solid rgba(16, 185, 129, 0.3)",
|
||||
boxShadow: "0 2px 8px rgba(16, 185, 129, 0.15)",
|
||||
}}
|
||||
>
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z" />
|
||||
<circle cx="12" cy="12" r="3" />
|
||||
</svg>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{isLayerPanelVisible && (
|
||||
<div
|
||||
style={{
|
||||
flexGrow: 1,
|
||||
@@ -300,14 +385,71 @@ export default function PreviewMapShell({
|
||||
geometryVisibility={geometryVisibility}
|
||||
onToggleBackground={onToggleBackground}
|
||||
onToggleGeometry={onToggleGeometry}
|
||||
onHide={() => setIsLayerPanelVisible(false)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</aside>
|
||||
|
||||
{onPlayPreviewReplay ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onPlayPreviewReplay}
|
||||
style={{
|
||||
position: "absolute",
|
||||
top: 10,
|
||||
right: 18,
|
||||
width: 46,
|
||||
height: 46,
|
||||
backgroundColor: "#1e293b",
|
||||
border: "1px solid rgba(56, 189, 248, 0.3)",
|
||||
borderRadius: "50%",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
cursor: "pointer",
|
||||
color: "#38bdf8",
|
||||
transition: "all 0.2s ease",
|
||||
boxShadow: "0 4px 12px rgba(56, 189, 248, 0.15)",
|
||||
backdropFilter: "blur(8px)",
|
||||
zIndex: 22,
|
||||
}}
|
||||
title="Play replay của geometry đang chọn"
|
||||
aria-label="Play selected replay"
|
||||
>
|
||||
<span
|
||||
aria-hidden="true"
|
||||
style={{
|
||||
width: 0,
|
||||
height: 0,
|
||||
borderTop: "6px solid transparent",
|
||||
borderBottom: "6px solid transparent",
|
||||
borderLeft: "9px solid currentColor",
|
||||
marginLeft: "3px",
|
||||
}}
|
||||
/>
|
||||
</button>
|
||||
) : null}
|
||||
|
||||
{overlay}
|
||||
|
||||
{activeEntity ? (
|
||||
<aside className="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
|
||||
entity={activeEntity}
|
||||
wiki={activeWiki}
|
||||
@@ -319,10 +461,13 @@ export default function PreviewMapShell({
|
||||
onSidebarWidthChange={onSidebarWidthChange}
|
||||
maxDragWidth={maxSidebarDragWidth}
|
||||
compactHeader
|
||||
sidebarHeight={sidebarHeight}
|
||||
onSidebarHeightChange={onSidebarHeightChange}
|
||||
/>
|
||||
</aside>
|
||||
) : null}
|
||||
|
||||
<ChatbotWidget hideFloatingButton />
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -69,8 +69,25 @@ export default function PublicPreviewClientPage({
|
||||
}
|
||||
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 [loadInteractiveMap, setLoadInteractiveMap] = useState(false);
|
||||
const [isLayerPanelVisible, setIsLayerPanelVisible] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
if (typeof window !== "undefined") {
|
||||
@@ -376,13 +393,16 @@ export default function PublicPreviewClientPage({
|
||||
const displayedActiveWiki = isSidebarOpen ? activeWiki : null;
|
||||
|
||||
const computedTimelineStyle = useMemo(() => {
|
||||
const leftMargin = isLayerPanelVisible ? 88 : 18;
|
||||
const rightMargin = (displayedActiveEntity && isLargeScreen) ? sidebarWidth + 32 : 18;
|
||||
const bottomOffset = (displayedActiveEntity && !isLargeScreen) ? `${sidebarHeight + 16}px` : undefined;
|
||||
return {
|
||||
left: "88px",
|
||||
left: `${leftMargin}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 (
|
||||
<>
|
||||
@@ -396,6 +416,8 @@ export default function PublicPreviewClientPage({
|
||||
onSelectFeatureIds={setSelectedFeatureIds}
|
||||
instantLoad={instantLoad}
|
||||
onToggleInstantLoad={toggleInstantLoad}
|
||||
isLayerPanelVisible={isLayerPanelVisible}
|
||||
onLayerPanelVisibleChange={setIsLayerPanelVisible}
|
||||
backgroundVisibility={backgroundVisibility}
|
||||
geometryVisibility={geometryVisibility}
|
||||
onToggleBackground={handleToggleBackgroundLayer}
|
||||
@@ -423,6 +445,8 @@ export default function PublicPreviewClientPage({
|
||||
sidebarWidth={sidebarWidth}
|
||||
onSidebarWidthChange={setSidebarWidth}
|
||||
maxSidebarDragWidth={maxDragWidth}
|
||||
sidebarHeight={sidebarHeight}
|
||||
onSidebarHeightChange={handleSidebarHeightChange}
|
||||
onPlayPreviewReplay={activeReplay && replayMode === "idle" ? handlePlayPreviewReplay : undefined}
|
||||
timelineDisabled={replayMode === "playing"}
|
||||
overlay={
|
||||
@@ -450,7 +474,8 @@ export default function PublicPreviewClientPage({
|
||||
style={{
|
||||
position: "absolute",
|
||||
top: 10,
|
||||
left: 80,
|
||||
left: "50%",
|
||||
transform: "translateX(-50%)",
|
||||
zIndex: 18,
|
||||
display: "flex",
|
||||
gap: "10px",
|
||||
@@ -467,6 +492,7 @@ export default function PublicPreviewClientPage({
|
||||
position: "relative",
|
||||
top: 0,
|
||||
left: 0,
|
||||
transform: "none",
|
||||
width: "min(392px, calc(100vw - 120px))",
|
||||
}}
|
||||
/>
|
||||
|
||||
@@ -95,10 +95,24 @@ export default function PublicPreviewWrapper() {
|
||||
window.addEventListener("touchstart", 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 () => {
|
||||
window.removeEventListener("click", handleInteraction);
|
||||
window.removeEventListener("touchstart", handleInteraction);
|
||||
window.removeEventListener("keydown", handleInteraction);
|
||||
if (timerId) {
|
||||
clearTimeout(timerId);
|
||||
}
|
||||
};
|
||||
}, [mapEntered]);
|
||||
|
||||
|
||||
@@ -63,8 +63,6 @@ export function usePublicPreviewData(options: {
|
||||
try {
|
||||
next = await fetchGeometriesByBBox({ ...WORLD_BBOX, time: timelineYear, timeRange });
|
||||
if (disposed || requestId !== timelineFetchRequestRef.current) return;
|
||||
setRelations(EMPTY_PREVIEW_RELATIONS);
|
||||
setData(next);
|
||||
} catch (err) {
|
||||
if (err instanceof ApiError) {
|
||||
console.error("Load public map geometries failed", err.body);
|
||||
@@ -89,8 +87,11 @@ export function usePublicPreviewData(options: {
|
||||
.map((feature) => String(feature.properties.id))
|
||||
.filter((id) => Boolean(id) && UUID_REGEX.test(id));
|
||||
if (!geometryIds.length) {
|
||||
if (!disposed && requestId === timelineFetchRequestRef.current) {
|
||||
setData(next);
|
||||
setRelations(EMPTY_PREVIEW_RELATIONS);
|
||||
setReplays([]);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -120,7 +121,6 @@ export function usePublicPreviewData(options: {
|
||||
fetchedReplays = Array.from(uniqueReplaysMap.values());
|
||||
|
||||
if (disposed || requestId !== timelineFetchRequestRef.current) return;
|
||||
setReplays(fetchedReplays);
|
||||
} catch (err) {
|
||||
if (err instanceof ApiError) {
|
||||
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);
|
||||
}
|
||||
if (!disposed && requestId === timelineFetchRequestRef.current) {
|
||||
setData(next); // Fallback to new geometry even if relations failed
|
||||
setRelations(EMPTY_PREVIEW_RELATIONS);
|
||||
setReplays([]);
|
||||
setRelationsStatus("Không tải được liên kết entity/wiki cho bản đồ.");
|
||||
setIsRelationsLoading(false);
|
||||
}
|
||||
@@ -143,7 +145,11 @@ export function usePublicPreviewData(options: {
|
||||
const entityOnlyRelations = buildPublicPreviewRelationIndex(
|
||||
buildRelationInputFromGeometryRelations(next, entitiesByGeometryId, {})
|
||||
);
|
||||
|
||||
// Apply BOTH geometries and relations at the exact same React render cycle!
|
||||
setData(next);
|
||||
setRelations(entityOnlyRelations);
|
||||
setReplays(fetchedReplays);
|
||||
|
||||
// Mark loading as complete immediately so map transitions and becomes interactive
|
||||
setIsRelationsLoading(false);
|
||||
|
||||
@@ -145,6 +145,161 @@ export default function TimelineBar({
|
||||
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 (
|
||||
<div
|
||||
className={`${styles.container} ${isLoading ? styles.containerLoading : ""} ${effectiveDisabled ? styles.disabled : ""}`}
|
||||
@@ -298,6 +453,10 @@ function CanvasTimelineRuler({
|
||||
hasDragged: boolean;
|
||||
} | 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
|
||||
useEffect(() => {
|
||||
const container = containerRef.current;
|
||||
@@ -325,7 +484,7 @@ function CanvasTimelineRuler({
|
||||
const width = dimensions.width;
|
||||
const height = dimensions.height;
|
||||
|
||||
ctx.clearRect(0, 0, width, height);
|
||||
ctx.clearRect(0, 0, canvas.width, canvas.height);
|
||||
|
||||
ctx.save();
|
||||
ctx.scale(dpr, dpr);
|
||||
@@ -501,18 +660,49 @@ function CanvasTimelineRuler({
|
||||
e.currentTarget.setPointerCapture(e.pointerId);
|
||||
} 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 = {
|
||||
isDragging: true,
|
||||
startX: e.clientX,
|
||||
startYear: displayYearRef.current,
|
||||
hasDragged: false,
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
const handlePointerMove = (e: React.PointerEvent<HTMLCanvasElement>) => {
|
||||
if (!dragRef.current || !dragRef.current.isDragging) return;
|
||||
if (disabled) return;
|
||||
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;
|
||||
if (Math.abs(dx) > 3) {
|
||||
dragRef.current.hasDragged = true;
|
||||
@@ -524,26 +714,31 @@ function CanvasTimelineRuler({
|
||||
|
||||
if (nextYear !== displayYearRef.current) {
|
||||
displayYearRef.current = nextYear;
|
||||
// Draw synchronously at 60fps
|
||||
requestAnimationFrame(() => {
|
||||
drawYear(displayYearRef.current);
|
||||
});
|
||||
onYearChange(nextYear);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handlePointerUp = (e: React.PointerEvent<HTMLCanvasElement>) => {
|
||||
if (!dragRef.current) return;
|
||||
e.preventDefault();
|
||||
try {
|
||||
e.currentTarget.releasePointerCapture(e.pointerId);
|
||||
} 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;
|
||||
dragRef.current = null;
|
||||
|
||||
if (!dragInfo.hasDragged) {
|
||||
// Click to jump
|
||||
if (dragInfo.isDragging && !dragInfo.hasDragged) {
|
||||
const canvas = canvasRef.current;
|
||||
if (canvas) {
|
||||
const rect = canvas.getBoundingClientRect();
|
||||
|
||||
@@ -23,6 +23,8 @@ type Props = {
|
||||
onSidebarWidthChange?: (width: number) => void;
|
||||
maxDragWidth?: number;
|
||||
compactHeader?: boolean;
|
||||
sidebarHeight?: number;
|
||||
onSidebarHeightChange?: (height: number) => void;
|
||||
};
|
||||
|
||||
function escapeHtml(input: string): string {
|
||||
@@ -135,6 +137,8 @@ function PublicWikiSidebar({
|
||||
onSidebarWidthChange,
|
||||
maxDragWidth,
|
||||
compactHeader = false,
|
||||
sidebarHeight,
|
||||
onSidebarHeightChange,
|
||||
}: Props) {
|
||||
const contentRootRef = useRef<HTMLDivElement | null>(null);
|
||||
const tocContainerRef = useRef<HTMLDivElement | null>(null);
|
||||
@@ -201,6 +205,48 @@ function PublicWikiSidebar({
|
||||
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 processedWiki = useMemo(() => {
|
||||
if (!wiki) return { html: "", toc: [] as TocItem[] };
|
||||
@@ -281,23 +327,62 @@ function PublicWikiSidebar({
|
||||
return () => root.removeEventListener("click", handleClick);
|
||||
}, [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 (
|
||||
<div
|
||||
style={{
|
||||
width: `${width}px`,
|
||||
width: "100%",
|
||||
maxWidth: isMobileOrTablet ? "100%" : `${width}px`,
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
height: "100%",
|
||||
minHeight: 0,
|
||||
overflow: "hidden",
|
||||
borderRadius: 20,
|
||||
border: "1px solid rgba(148, 163, 184, 0.22)",
|
||||
borderRadius: isMobileOrTablet ? "24px 24px 0 0" : 20,
|
||||
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))",
|
||||
boxShadow: "0 20px 48px rgba(2, 6, 23, 0.45)",
|
||||
backdropFilter: "blur(12px)",
|
||||
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 */}
|
||||
<div
|
||||
onPointerDown={handlePointerDown}
|
||||
@@ -311,7 +396,7 @@ function PublicWikiSidebar({
|
||||
zIndex: 50,
|
||||
userSelect: "none",
|
||||
}}
|
||||
className="group"
|
||||
className="group hidden lg:block"
|
||||
title="Kéo để chỉnh kích thước"
|
||||
>
|
||||
{/* Visual drag line overlay */}
|
||||
|
||||
Reference in New Issue
Block a user