feat: implement resizable public wiki sidebar with improved content fetching, map zoom limits, and layout adjustments
Build and Release / release (push) Successful in 36s
Build and Release / release (push) Successful in 36s
This commit is contained in:
+67
-8
@@ -8,7 +8,7 @@ import TimelineBar from "@/uhm/components/ui/TimelineBar";
|
|||||||
import { fetchEntities, type Entity } from "@/uhm/api/entities";
|
import { fetchEntities, type Entity } from "@/uhm/api/entities";
|
||||||
import { fetchGeometriesByBBox } from "@/uhm/api/geometries";
|
import { fetchGeometriesByBBox } from "@/uhm/api/geometries";
|
||||||
import { ApiError } from "@/uhm/api/http";
|
import { ApiError } from "@/uhm/api/http";
|
||||||
import { fetchWikiBySlug, searchWikisByTitle, type Wiki } from "@/uhm/api/wikis";
|
import { fetchWikiBySlug, getContentByVersionWikiId, searchWikisByTitle, type Wiki } from "@/uhm/api/wikis";
|
||||||
import {
|
import {
|
||||||
BACKGROUND_LAYER_OPTIONS,
|
BACKGROUND_LAYER_OPTIONS,
|
||||||
type BackgroundLayerId,
|
type BackgroundLayerId,
|
||||||
@@ -88,6 +88,34 @@ export default function Page() {
|
|||||||
const [linkEntityPopup, setLinkEntityPopup] = useState<LinkEntityPopupState | null>(null);
|
const [linkEntityPopup, setLinkEntityPopup] = useState<LinkEntityPopupState | null>(null);
|
||||||
const [entityFocusToken, setEntityFocusToken] = useState(0);
|
const [entityFocusToken, setEntityFocusToken] = useState(0);
|
||||||
|
|
||||||
|
const [sidebarWidth, setSidebarWidth] = useState<number>(() => {
|
||||||
|
if (typeof window !== "undefined") {
|
||||||
|
const saved = localStorage.getItem("public-wiki-sidebar-width");
|
||||||
|
if (saved) {
|
||||||
|
const parsed = parseInt(saved, 10);
|
||||||
|
if (!isNaN(parsed) && parsed >= 320 && parsed <= 800) {
|
||||||
|
return parsed;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return 420;
|
||||||
|
});
|
||||||
|
const [isLargeScreen, setIsLargeScreen] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (typeof window === "undefined") return;
|
||||||
|
const handleResize = () => {
|
||||||
|
setIsLargeScreen(window.innerWidth >= 1024);
|
||||||
|
};
|
||||||
|
handleResize();
|
||||||
|
window.addEventListener("resize", handleResize);
|
||||||
|
return () => window.removeEventListener("resize", handleResize);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const maxDragWidth = typeof window !== "undefined"
|
||||||
|
? Math.min(800, window.innerWidth - 340)
|
||||||
|
: 800;
|
||||||
|
|
||||||
const timelineFetchRequestRef = useRef(0);
|
const timelineFetchRequestRef = useRef(0);
|
||||||
const hoverHideTimerRef = useRef<number | null>(null);
|
const hoverHideTimerRef = useRef<number | null>(null);
|
||||||
const hoverPopupHoveredRef = useRef(false);
|
const hoverPopupHoveredRef = useRef(false);
|
||||||
@@ -339,7 +367,7 @@ export default function Page() {
|
|||||||
|
|
||||||
selectEntity(onlyEntityId, {
|
selectEntity(onlyEntityId, {
|
||||||
sourceFeatureId: selectedFeatureIds[0],
|
sourceFeatureId: selectedFeatureIds[0],
|
||||||
focusMap: false,
|
focusMap: true,
|
||||||
selectGeometry: false,
|
selectGeometry: false,
|
||||||
});
|
});
|
||||||
}, [activeEntityId, relations.geometryEntityIds, selectEntity, selectedFeatureIds]);
|
}, [activeEntityId, relations.geometryEntityIds, selectEntity, selectedFeatureIds]);
|
||||||
@@ -386,6 +414,8 @@ export default function Page() {
|
|||||||
};
|
};
|
||||||
}, [linkEntityPopup]);
|
}, [linkEntityPopup]);
|
||||||
|
|
||||||
|
const cachedWiki = activeWikiSlug ? (wikiCache[activeWikiSlug] as Wiki & { __fetched?: boolean }) : undefined;
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!activeWikiSlug) {
|
if (!activeWikiSlug) {
|
||||||
setIsActiveWikiLoading(false);
|
setIsActiveWikiLoading(false);
|
||||||
@@ -393,10 +423,13 @@ export default function Page() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const cached = wikiCache[activeWikiSlug] || relations.wikiBySlug[activeWikiSlug] || null;
|
if (cachedWiki && (cachedWiki.__fetched || cachedWiki.id === "__not_found__")) {
|
||||||
if (cached?.content) {
|
|
||||||
setIsActiveWikiLoading(false);
|
setIsActiveWikiLoading(false);
|
||||||
|
if (cachedWiki.id === "__not_found__") {
|
||||||
|
setActiveWikiError("Không tìm thấy wiki cho entity đã chọn.");
|
||||||
|
} else {
|
||||||
setActiveWikiError(null);
|
setActiveWikiError(null);
|
||||||
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -407,9 +440,30 @@ export default function Page() {
|
|||||||
try {
|
try {
|
||||||
const row = await fetchWikiBySlug(activeWikiSlug);
|
const row = await fetchWikiBySlug(activeWikiSlug);
|
||||||
if (disposed) return;
|
if (disposed) return;
|
||||||
|
|
||||||
if (row) {
|
if (row) {
|
||||||
setWikiCache((prev) => ({ ...prev, [activeWikiSlug]: row }));
|
let versionContent = row.content;
|
||||||
|
try {
|
||||||
|
if (row.content_sample?.[0]?.id) {
|
||||||
|
const res = await getContentByVersionWikiId(row.content_sample[0].id);
|
||||||
|
if (res?.data?.content) {
|
||||||
|
versionContent = res.data.content;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Failed to fetch version content:", err);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (disposed) return;
|
||||||
|
setWikiCache((prev) => ({
|
||||||
|
...prev,
|
||||||
|
[activeWikiSlug]: { ...row, content: versionContent, __fetched: true } as any,
|
||||||
|
}));
|
||||||
} else {
|
} else {
|
||||||
|
setWikiCache((prev) => ({
|
||||||
|
...prev,
|
||||||
|
[activeWikiSlug]: { id: "__not_found__", project_id: "" },
|
||||||
|
}));
|
||||||
setActiveWikiError("Không tìm thấy wiki cho entity đã chọn.");
|
setActiveWikiError("Không tìm thấy wiki cho entity đã chọn.");
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -423,7 +477,7 @@ export default function Page() {
|
|||||||
return () => {
|
return () => {
|
||||||
disposed = true;
|
disposed = true;
|
||||||
};
|
};
|
||||||
}, [activeWikiSlug, relations.wikiBySlug, wikiCache]);
|
}, [activeWikiSlug, cachedWiki]);
|
||||||
|
|
||||||
const handleWikiLinkRequest = useCallback(async ({ slug, rect }: { slug: string; rect: DOMRect }) => {
|
const handleWikiLinkRequest = useCallback(async ({ slug, rect }: { slug: string; rect: DOMRect }) => {
|
||||||
const linkedEntityIds = relations.wikiEntityIdsBySlug[slug] || [];
|
const linkedEntityIds = relations.wikiEntityIdsBySlug[slug] || [];
|
||||||
@@ -482,7 +536,7 @@ export default function Page() {
|
|||||||
highlightFeatures={activeEntityGeometries}
|
highlightFeatures={activeEntityGeometries}
|
||||||
focusFeatureCollection={activeEntityGeometries}
|
focusFeatureCollection={activeEntityGeometries}
|
||||||
focusRequestKey={entityFocusToken}
|
focusRequestKey={entityFocusToken}
|
||||||
focusPadding={activeEntityId ? { top: 84, right: 500, bottom: 116, left: 84 } : { top: 84, right: 84, bottom: 116, left: 84 }}
|
focusPadding={activeEntityId && isLargeScreen ? { top: 84, right: sidebarWidth + 80, bottom: 116, left: 84 } : { top: 84, right: 84, bottom: 116, left: 84 }}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<div className="h-screen w-full bg-[#0b1220]" />
|
<div className="h-screen w-full bg-[#0b1220]" />
|
||||||
@@ -496,6 +550,7 @@ export default function Page() {
|
|||||||
isLoading={isTimelineLoading}
|
isLoading={isTimelineLoading}
|
||||||
disabled={false}
|
disabled={false}
|
||||||
statusText={timelineStatus}
|
statusText={timelineStatus}
|
||||||
|
style={activeEntityId && isLargeScreen ? { right: `${sidebarWidth + 32}px` } : undefined}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div className="absolute left-4 top-4 z-20 w-[280px] max-w-[calc(100vw-2rem)] overflow-hidden rounded-xl border border-white/10 bg-slate-950/92 shadow-xl backdrop-blur">
|
<div className="absolute left-4 top-4 z-20 w-[280px] max-w-[calc(100vw-2rem)] overflow-hidden rounded-xl border border-white/10 bg-slate-950/92 shadow-xl backdrop-blur">
|
||||||
@@ -632,7 +687,7 @@ export default function Page() {
|
|||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
{activeEntity ? (
|
{activeEntity ? (
|
||||||
<aside className="absolute bottom-4 right-4 top-4 z-20 w-[420px] max-w-[calc(100vw-2rem)]">
|
<aside className="absolute bottom-4 right-4 top-4 z-20 max-w-[calc(100vw-2rem)]">
|
||||||
<PublicWikiSidebar
|
<PublicWikiSidebar
|
||||||
entity={activeEntity}
|
entity={activeEntity}
|
||||||
wiki={activeWiki}
|
wiki={activeWiki}
|
||||||
@@ -643,8 +698,12 @@ export default function Page() {
|
|||||||
setActiveWikiSlug(null);
|
setActiveWikiSlug(null);
|
||||||
setActiveWikiError(null);
|
setActiveWikiError(null);
|
||||||
setLinkEntityPopup(null);
|
setLinkEntityPopup(null);
|
||||||
|
setSelectedFeatureIds([]);
|
||||||
}}
|
}}
|
||||||
onWikiLinkRequest={handleWikiLinkRequest}
|
onWikiLinkRequest={handleWikiLinkRequest}
|
||||||
|
sidebarWidth={sidebarWidth}
|
||||||
|
onSidebarWidthChange={setSidebarWidth}
|
||||||
|
maxDragWidth={maxDragWidth}
|
||||||
/>
|
/>
|
||||||
</aside>
|
</aside>
|
||||||
) : null}
|
) : null}
|
||||||
|
|||||||
@@ -61,13 +61,13 @@ export default function LandingPage() {
|
|||||||
<div className="fixed inset-0 bg-gradient-to-b from-[#FDFBF7]/80 via-[#FDFBF7]/70 to-[#FDFBF7]/90 -z-10 pointer-events-none"></div>
|
<div className="fixed inset-0 bg-gradient-to-b from-[#FDFBF7]/80 via-[#FDFBF7]/70 to-[#FDFBF7]/90 -z-10 pointer-events-none"></div>
|
||||||
|
|
||||||
{/* --- HEADER NAVBAR --- */}
|
{/* --- HEADER NAVBAR --- */}
|
||||||
<header className="fixed top-0 w-full px-6 py-4 flex justify-between items-center backdrop-blur-sm bg-[#FDFBF7]/70 z-50 border-b border-[#A88B4C]/20">
|
<header className="sticky top-0 w-full px-6 py-4 flex justify-between items-center backdrop-blur-sm bg-[#FDFBF7]/70 z-40 border-b border-[#A88B4C]/20">
|
||||||
<div className="text-xl font-bold tracking-widest text-[#2D3A3A] uppercase">
|
<div className="text-xl font-bold tracking-widest text-[#2D3A3A] uppercase">
|
||||||
<span className="text-[#A88B4C]">Geo</span>History
|
<span className="text-[#A88B4C]">Geo</span>History
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<main className="max-w-6xl mx-auto px-6 pt-32 pb-24 flex flex-col gap-32 w-full relative">
|
<main className="max-w-6xl mx-auto px-6 pt-8 pb-24 flex flex-col gap-32 w-full relative">
|
||||||
{/* --- PHẦN 1: GIỚI THIỆU TỔNG QUAN --- */}
|
{/* --- PHẦN 1: GIỚI THIỆU TỔNG QUAN --- */}
|
||||||
<section className="min-h-[70vh] flex flex-col justify-center relative">
|
<section className="min-h-[70vh] flex flex-col justify-center relative">
|
||||||
<h1 className="text-5xl md:text-7xl font-black leading-tight tracking-tight mb-6">
|
<h1 className="text-5xl md:text-7xl font-black leading-tight tracking-tight mb-6">
|
||||||
|
|||||||
@@ -34,7 +34,7 @@ export default function AdminLayout({
|
|||||||
? "ml-0"
|
? "ml-0"
|
||||||
: isExpanded || isHovered
|
: isExpanded || isHovered
|
||||||
? "lg:ml-[290px]"
|
? "lg:ml-[290px]"
|
||||||
: "lg:ml-[0px]";
|
: "lg:ml-[88px]";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen xl:flex">
|
<div className="min-h-screen xl:flex">
|
||||||
|
|||||||
@@ -446,8 +446,8 @@ export function buildPathArrowGeometry(coords: [number, number][]): Geometry | n
|
|||||||
|
|
||||||
if (bodyPoints.length < 2) return null;
|
if (bodyPoints.length < 2) return null;
|
||||||
|
|
||||||
const tailWidth = clampNumber(totalLength * 0.005, 8000, 40000);
|
const tailWidth = clampNumber(totalLength * 0.02, 5, 40000);
|
||||||
const shoulderWidth = clampNumber(totalLength * 0.015, 18000, 100000);
|
const shoulderWidth = clampNumber(totalLength * 0.1, 10, 100000);
|
||||||
const headWidth = shoulderWidth * 2.0;
|
const headWidth = shoulderWidth * 2.0;
|
||||||
|
|
||||||
const leftBody: ProjectedPoint[] = [];
|
const leftBody: ProjectedPoint[] = [];
|
||||||
|
|||||||
@@ -72,6 +72,8 @@ export function useMapSync({
|
|||||||
const fitToDraftBoundsRef = useRef(fitToDraftBounds);
|
const fitToDraftBoundsRef = useRef(fitToDraftBounds);
|
||||||
const highlightFeaturesRef = useRef<FeatureCollection | null>(highlightFeatures || null);
|
const highlightFeaturesRef = useRef<FeatureCollection | null>(highlightFeatures || null);
|
||||||
const imageOverlayRef = useRef<MapImageOverlay | null>(imageOverlay || null);
|
const imageOverlayRef = useRef<MapImageOverlay | null>(imageOverlay || null);
|
||||||
|
const focusFeatureCollectionRef = useRef<FeatureCollection | null | undefined>(focusFeatureCollection);
|
||||||
|
const focusPaddingRef = useRef<number | maplibregl.PaddingOptions | undefined>(focusPadding);
|
||||||
|
|
||||||
const fitBoundsAppliedRef = useRef(false);
|
const fitBoundsAppliedRef = useRef(false);
|
||||||
|
|
||||||
@@ -85,6 +87,8 @@ export function useMapSync({
|
|||||||
useEffect(() => { fitToDraftBoundsRef.current = fitToDraftBounds; }, [fitToDraftBounds]);
|
useEffect(() => { fitToDraftBoundsRef.current = fitToDraftBounds; }, [fitToDraftBounds]);
|
||||||
useEffect(() => { highlightFeaturesRef.current = highlightFeatures || null; }, [highlightFeatures]);
|
useEffect(() => { highlightFeaturesRef.current = highlightFeatures || null; }, [highlightFeatures]);
|
||||||
useEffect(() => { imageOverlayRef.current = imageOverlay || null; }, [imageOverlay]);
|
useEffect(() => { imageOverlayRef.current = imageOverlay || null; }, [imageOverlay]);
|
||||||
|
useEffect(() => { focusFeatureCollectionRef.current = focusFeatureCollection; }, [focusFeatureCollection]);
|
||||||
|
useEffect(() => { focusPaddingRef.current = focusPadding; }, [focusPadding]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fitBoundsAppliedRef.current = false;
|
fitBoundsAppliedRef.current = false;
|
||||||
@@ -217,7 +221,7 @@ export function useMapSync({
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (focusRequestKey === null || focusRequestKey === undefined) return;
|
if (focusRequestKey === null || focusRequestKey === undefined) return;
|
||||||
const map = mapRef.current;
|
const map = mapRef.current;
|
||||||
const target = focusFeatureCollection;
|
const target = focusFeatureCollectionRef.current;
|
||||||
if (!target || !target.features.length) return;
|
if (!target || !target.features.length) return;
|
||||||
if (!map) return;
|
if (!map) return;
|
||||||
|
|
||||||
@@ -226,7 +230,7 @@ export function useMapSync({
|
|||||||
|
|
||||||
const focus = () => {
|
const focus = () => {
|
||||||
if (cancelled || mapRef.current !== map || !map.isStyleLoaded()) return;
|
if (cancelled || mapRef.current !== map || !map.isStyleLoaded()) return;
|
||||||
fitMapToFeatureCollection(map, target, focusPadding, {
|
fitMapToFeatureCollection(map, target, focusPaddingRef.current, {
|
||||||
duration: 550,
|
duration: 550,
|
||||||
maxZoom: 10,
|
maxZoom: 10,
|
||||||
pointZoom: 9,
|
pointZoom: 9,
|
||||||
@@ -243,7 +247,7 @@ export function useMapSync({
|
|||||||
cancelled = true;
|
cancelled = true;
|
||||||
if (rafId !== null) cancelAnimationFrame(rafId);
|
if (rafId !== null) cancelAnimationFrame(rafId);
|
||||||
};
|
};
|
||||||
}, [focusFeatureCollection, focusPadding, focusRequestKey, mapRef]);
|
}, [focusRequestKey, mapRef]);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
applyDraftToMap,
|
applyDraftToMap,
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ type Props = {
|
|||||||
statusText?: string | null;
|
statusText?: string | null;
|
||||||
filterEnabled?: boolean;
|
filterEnabled?: boolean;
|
||||||
onFilterEnabledChange?: (enabled: boolean) => void;
|
onFilterEnabledChange?: (enabled: boolean) => void;
|
||||||
|
style?: React.CSSProperties;
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function TimelineBar({
|
export default function TimelineBar({
|
||||||
@@ -24,6 +25,7 @@ export default function TimelineBar({
|
|||||||
statusText,
|
statusText,
|
||||||
filterEnabled,
|
filterEnabled,
|
||||||
onFilterEnabledChange,
|
onFilterEnabledChange,
|
||||||
|
style,
|
||||||
}: Props) {
|
}: Props) {
|
||||||
const lower = FIXED_TIMELINE_START_YEAR;
|
const lower = FIXED_TIMELINE_START_YEAR;
|
||||||
const upper = FIXED_TIMELINE_END_YEAR;
|
const upper = FIXED_TIMELINE_END_YEAR;
|
||||||
@@ -58,6 +60,7 @@ export default function TimelineBar({
|
|||||||
padding: "10px 12px",
|
padding: "10px 12px",
|
||||||
color: "#e2e8f0",
|
color: "#e2e8f0",
|
||||||
backdropFilter: "blur(2px)",
|
backdropFilter: "blur(2px)",
|
||||||
|
...style,
|
||||||
}}
|
}}
|
||||||
title={helperText || undefined}
|
title={helperText || undefined}
|
||||||
>
|
>
|
||||||
@@ -65,7 +68,9 @@ export default function TimelineBar({
|
|||||||
style={{
|
style={{
|
||||||
display: "flex",
|
display: "flex",
|
||||||
alignItems: "center",
|
alignItems: "center",
|
||||||
gap: "10px",
|
flexWrap: "wrap",
|
||||||
|
rowGap: "8px",
|
||||||
|
columnGap: "10px",
|
||||||
fontSize: "12px",
|
fontSize: "12px",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
@@ -129,7 +134,7 @@ export default function TimelineBar({
|
|||||||
aria-label="Timeline year"
|
aria-label="Timeline year"
|
||||||
style={{
|
style={{
|
||||||
flex: 1,
|
flex: 1,
|
||||||
minWidth: 0,
|
minWidth: "120px",
|
||||||
accentColor: "#22c55e",
|
accentColor: "#22c55e",
|
||||||
cursor: effectiveDisabled ? "not-allowed" : "pointer",
|
cursor: effectiveDisabled ? "not-allowed" : "pointer",
|
||||||
opacity: effectiveDisabled ? 0.6 : 1,
|
opacity: effectiveDisabled ? 0.6 : 1,
|
||||||
|
|||||||
@@ -18,6 +18,9 @@ type Props = {
|
|||||||
error?: string | null;
|
error?: string | null;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
onWikiLinkRequest: (request: { slug: string; rect: DOMRect }) => void;
|
onWikiLinkRequest: (request: { slug: string; rect: DOMRect }) => void;
|
||||||
|
sidebarWidth?: number;
|
||||||
|
onSidebarWidthChange?: (width: number) => void;
|
||||||
|
maxDragWidth?: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
function escapeHtml(input: string): string {
|
function escapeHtml(input: string): string {
|
||||||
@@ -30,9 +33,12 @@ function escapeHtml(input: string): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function normalizeWikiContentToHtml(raw: string | null | undefined): string {
|
function normalizeWikiContentToHtml(raw: string | null | undefined): string {
|
||||||
const value = String(raw || "").trim();
|
let value = String(raw || "").trim();
|
||||||
if (!value.length) return "";
|
if (!value.length) return "";
|
||||||
|
|
||||||
|
// Replace non-breaking spaces to allow text wrap
|
||||||
|
value = value.replaceAll(" ", " ").replaceAll("\u00a0", " ");
|
||||||
|
|
||||||
if (value[0] === "<") return value;
|
if (value[0] === "<") return value;
|
||||||
|
|
||||||
return `<p>${escapeHtml(value).replace(/\n/g, "<br/>")}</p>`;
|
return `<p>${escapeHtml(value).replace(/\n/g, "<br/>")}</p>`;
|
||||||
@@ -122,8 +128,52 @@ export default function PublicWikiSidebar({
|
|||||||
error,
|
error,
|
||||||
onClose,
|
onClose,
|
||||||
onWikiLinkRequest,
|
onWikiLinkRequest,
|
||||||
|
sidebarWidth,
|
||||||
|
onSidebarWidthChange,
|
||||||
|
maxDragWidth,
|
||||||
}: Props) {
|
}: Props) {
|
||||||
const contentRootRef = useRef<HTMLDivElement | null>(null);
|
const contentRootRef = useRef<HTMLDivElement | null>(null);
|
||||||
|
|
||||||
|
const [localWidth, setLocalWidth] = useState<number>(() => {
|
||||||
|
if (typeof window !== "undefined") {
|
||||||
|
const saved = localStorage.getItem("public-wiki-sidebar-width");
|
||||||
|
if (saved) {
|
||||||
|
const parsed = parseInt(saved, 10);
|
||||||
|
if (!isNaN(parsed) && parsed >= 320 && parsed <= 800) {
|
||||||
|
return parsed;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return 420;
|
||||||
|
});
|
||||||
|
|
||||||
|
const width = sidebarWidth ?? localWidth;
|
||||||
|
const setWidth = onSidebarWidthChange ?? setLocalWidth;
|
||||||
|
const maxDragWidthLimit = maxDragWidth ?? 800;
|
||||||
|
|
||||||
|
const handlePointerDown = (event: React.PointerEvent<HTMLDivElement>) => {
|
||||||
|
event.preventDefault();
|
||||||
|
const startX = event.clientX;
|
||||||
|
const startWidth = width;
|
||||||
|
|
||||||
|
const onMove = (e: PointerEvent) => {
|
||||||
|
const deltaX = e.clientX - startX;
|
||||||
|
const nextWidth = Math.max(320, Math.min(maxDragWidthLimit, startWidth - deltaX));
|
||||||
|
setWidth(nextWidth);
|
||||||
|
if (typeof window !== "undefined") {
|
||||||
|
localStorage.setItem("public-wiki-sidebar-width", String(nextWidth));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const onUp = () => {
|
||||||
|
window.removeEventListener("pointermove", onMove);
|
||||||
|
window.removeEventListener("pointerup", onUp);
|
||||||
|
};
|
||||||
|
|
||||||
|
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[] };
|
||||||
@@ -187,7 +237,19 @@ export default function PublicWikiSidebar({
|
|||||||
}, [onWikiLinkRequest, renderHtml]);
|
}, [onWikiLinkRequest, renderHtml]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex h-full min-h-0 flex-col overflow-hidden rounded-xl border border-gray-200 bg-white shadow-xl dark:border-gray-800 dark:bg-gray-950">
|
<div
|
||||||
|
style={{ width: `${width}px` }}
|
||||||
|
className="relative flex h-full min-h-0 flex-col overflow-hidden rounded-xl border border-gray-200 bg-white shadow-xl dark:border-gray-800 dark:bg-gray-950"
|
||||||
|
>
|
||||||
|
{/* Drag Handle on the left edge */}
|
||||||
|
<div
|
||||||
|
onPointerDown={handlePointerDown}
|
||||||
|
className="absolute left-0 top-0 bottom-0 w-[6px] cursor-col-resize z-50 group select-none hover:bg-black/[0.03] dark:hover:bg-white/[0.02]"
|
||||||
|
title="Kéo để chỉnh kích thước"
|
||||||
|
>
|
||||||
|
{/* Visual drag line overlay */}
|
||||||
|
<div className="absolute left-[2px] top-0 bottom-0 w-[2px] bg-transparent group-hover:bg-brand-500/50 group-active:bg-brand-500 transition-colors" />
|
||||||
|
</div>
|
||||||
<div className="border-b border-gray-200 px-4 py-4 dark:border-gray-800">
|
<div className="border-b border-gray-200 px-4 py-4 dark:border-gray-800">
|
||||||
<div className="flex items-start justify-between gap-3">
|
<div className="flex items-start justify-between gap-3">
|
||||||
<div className="min-w-0">
|
<div className="min-w-0">
|
||||||
@@ -271,6 +333,11 @@ export default function PublicWikiSidebar({
|
|||||||
height: auto;
|
height: auto;
|
||||||
overflow-y: visible;
|
overflow-y: visible;
|
||||||
padding: 18px 18px 22px;
|
padding: 18px 18px 22px;
|
||||||
|
line-height: 1.6;
|
||||||
|
font-size: 14.5px;
|
||||||
|
word-wrap: break-word;
|
||||||
|
word-break: break-word;
|
||||||
|
overflow-wrap: break-word;
|
||||||
}
|
}
|
||||||
.uhm-wiki-sidebar-view.ql-editor p {
|
.uhm-wiki-sidebar-view.ql-editor p {
|
||||||
margin: 0 0 0.75em;
|
margin: 0 0 0.75em;
|
||||||
@@ -317,16 +384,22 @@ export default function PublicWikiSidebar({
|
|||||||
border: 1px solid rgba(226, 232, 240, 1);
|
border: 1px solid rgba(226, 232, 240, 1);
|
||||||
border-radius: 10px;
|
border-radius: 10px;
|
||||||
background: rgba(248, 250, 252, 1);
|
background: rgba(248, 250, 252, 1);
|
||||||
overflow: auto;
|
overflow-x: auto;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
word-break: break-all;
|
||||||
}
|
}
|
||||||
:is(.dark *) .uhm-wiki-sidebar-view.ql-editor pre {
|
:is(.dark *) .uhm-wiki-sidebar-view.ql-editor pre {
|
||||||
border-color: rgba(51, 65, 85, 1);
|
border-color: rgba(51, 65, 85, 1);
|
||||||
background: rgba(2, 6, 23, 0.4);
|
background: rgba(2, 6, 23, 0.4);
|
||||||
}
|
}
|
||||||
.uhm-wiki-sidebar-view.ql-editor img {
|
.uhm-wiki-sidebar-view.ql-editor img {
|
||||||
max-width: 100%;
|
display: block !important;
|
||||||
height: auto;
|
max-width: 100% !important;
|
||||||
|
height: auto !important;
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
|
float: none !important;
|
||||||
|
margin: 1.25em auto !important;
|
||||||
|
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.05);
|
||||||
}
|
}
|
||||||
.uhm-wiki-sidebar-view.ql-editor a {
|
.uhm-wiki-sidebar-view.ql-editor a {
|
||||||
text-decoration: underline;
|
text-decoration: underline;
|
||||||
@@ -352,6 +425,18 @@ export default function PublicWikiSidebar({
|
|||||||
:is(.dark *) .uhm-wiki-sidebar-view.ql-editor a[href="__missing__"] {
|
:is(.dark *) .uhm-wiki-sidebar-view.ql-editor a[href="__missing__"] {
|
||||||
color: #f87171;
|
color: #f87171;
|
||||||
}
|
}
|
||||||
|
@media (max-width: 640px) {
|
||||||
|
.uhm-wiki-sidebar-view.ql-editor {
|
||||||
|
padding: 14px 14px 20px;
|
||||||
|
font-size: 13.5px;
|
||||||
|
}
|
||||||
|
.uhm-wiki-sidebar-view.ql-editor h1 {
|
||||||
|
font-size: 1.4em;
|
||||||
|
}
|
||||||
|
.uhm-wiki-sidebar-view.ql-editor h2 {
|
||||||
|
font-size: 1.2em;
|
||||||
|
}
|
||||||
|
}
|
||||||
`}</style>
|
`}</style>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
export const PATH_ARROW_ICON_ID = "path-arrow-icon";
|
export const PATH_ARROW_ICON_ID = "path-arrow-icon";
|
||||||
|
|
||||||
export const MAP_MIN_ZOOM = 2;
|
export const MAP_MIN_ZOOM = 2;
|
||||||
export const MAP_MAX_ZOOM = 10;
|
export const MAP_MAX_ZOOM = 14;
|
||||||
|
|
||||||
export const RASTER_BASE_SOURCE_ID = "rasterBase";
|
export const RASTER_BASE_SOURCE_ID = "rasterBase";
|
||||||
export const RASTER_BASE_LAYER_ID = "raster-base-layer";
|
export const RASTER_BASE_LAYER_ID = "raster-base-layer";
|
||||||
|
|||||||
@@ -102,7 +102,7 @@ export function buildLineGeotypeLayers(
|
|||||||
source: pathArrowSourceId,
|
source: pathArrowSourceId,
|
||||||
filter: ["==", TYPE_MATCH_EXPR, style.typeId],
|
filter: ["==", TYPE_MATCH_EXPR, style.typeId],
|
||||||
paint: {
|
paint: {
|
||||||
"fill-color": statusColor(style.color),
|
"fill-color": statusFillColor(style.color),
|
||||||
"fill-opacity": [
|
"fill-opacity": [
|
||||||
"case",
|
"case",
|
||||||
SELECTED_EXPR,
|
SELECTED_EXPR,
|
||||||
@@ -140,7 +140,7 @@ export function buildPolygonGeotypeLayers(
|
|||||||
source: sourceId,
|
source: sourceId,
|
||||||
filter: polygonFilter(style.typeId),
|
filter: polygonFilter(style.typeId),
|
||||||
paint: {
|
paint: {
|
||||||
"fill-color": statusColor(style.fillColor),
|
"fill-color": statusFillColor(style.fillColor),
|
||||||
"fill-opacity": [
|
"fill-opacity": [
|
||||||
"case",
|
"case",
|
||||||
SELECTED_EXPR,
|
SELECTED_EXPR,
|
||||||
@@ -183,13 +183,22 @@ function statusStroke(normalColor: string): maplibregl.ExpressionSpecification {
|
|||||||
return [
|
return [
|
||||||
"case",
|
"case",
|
||||||
SELECTED_EXPR,
|
SELECTED_EXPR,
|
||||||
SELECTED_STROKE,
|
SELECTED_COLOR,
|
||||||
DRAFT_ENTITY_EXPR,
|
DRAFT_ENTITY_EXPR,
|
||||||
DRAFT_STROKE,
|
DRAFT_STROKE,
|
||||||
normalColor,
|
normalColor,
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function statusFillColor(normalColor: string): maplibregl.ExpressionSpecification {
|
||||||
|
return [
|
||||||
|
"case",
|
||||||
|
DRAFT_ENTITY_EXPR,
|
||||||
|
DRAFT_COLOR,
|
||||||
|
normalColor,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
function lineFilter(typeId: string): maplibregl.ExpressionSpecification {
|
function lineFilter(typeId: string): maplibregl.ExpressionSpecification {
|
||||||
return ["all", LINE_GEOMETRY_FILTER, ["==", TYPE_MATCH_EXPR, typeId]];
|
return ["all", LINE_GEOMETRY_FILTER, ["==", TYPE_MATCH_EXPR, typeId]];
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user