diff --git a/src/app/(admin)/(others-pages)/(management)/project/page.tsx b/src/app/(admin)/(others-pages)/(management)/project/page.tsx index 57f0e7d..6305ab4 100644 --- a/src/app/(admin)/(others-pages)/(management)/project/page.tsx +++ b/src/app/(admin)/(others-pages)/(management)/project/page.tsx @@ -167,6 +167,10 @@ export default function ProjectsPage(_props: { router.push(`/projects/${id}`); }; + const handleViewMap = (id: string) => { + router.push(`/projects/${id}/map`); + }; + const pagination = tableData?.pagination; return ( @@ -236,6 +240,7 @@ export default function ProjectsPage(_props: { sortBy={sortBy} sortOrder={sortOrder} onViewDetails={handleViewDetails} + onViewMap={handleViewMap} /> diff --git a/src/app/(full-width-pages)/projects/[id]/map/page.tsx b/src/app/(full-width-pages)/projects/[id]/map/page.tsx new file mode 100644 index 0000000..fdaf472 --- /dev/null +++ b/src/app/(full-width-pages)/projects/[id]/map/page.tsx @@ -0,0 +1,714 @@ +"use client"; + +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { useParams, useRouter } from "next/navigation"; +import { useShallow } from "zustand/react/shallow"; +import { toast } from "sonner"; + +import Map from "@/uhm/components/Map"; +import BackgroundLayersPanel from "@/uhm/components/editor/BackgroundLayersPanel"; +import TimelineBar from "@/uhm/components/ui/TimelineBar"; +import ProjectEntityRefsPanel from "@/uhm/components/editor/ProjectEntityRefsPanel"; +import EntityWikiBindingsPanel from "@/uhm/components/editor/EntityWikiBindingsPanel"; +import GeometryBindingPanel from "@/uhm/components/editor/GeometryBindingPanel"; +import SelectedGeometryPanel from "@/uhm/components/editor/SelectedGeometryPanel"; +import WikiSidebarPanel from "@/uhm/components/wiki/WikiSidebarPanel"; + +import { fetchProjectCommits } from "@/uhm/api/projects"; +import { requestJson } from "@/uhm/api/http"; +import { API_ENDPOINTS } from "@/uhm/api/config"; +import { normalizeEditorSnapshot, getDefaultTypeIdForFeature, normalizeFeatureEntityIds } from "@/uhm/lib/editor/snapshot/editorSnapshot"; +import { normalizeTimelineYearValue } from "@/uhm/lib/utils/timeline"; +import { isFeatureVisibleAtYear } from "@/uhm/lib/editor/editorPageUtils"; +import { getDirectGeometryChildIds } from "@/uhm/lib/editor/geometry/geometryBinding"; +import { ResizeHandle } from "@/uhm/components/ui/ResizeHandle"; +import { EMPTY_FEATURE_COLLECTION } from "@/uhm/lib/map/geo/constants"; +import { FIXED_TIMELINE_RANGE, clampYearToFixedRange } from "@/uhm/lib/utils/timeline"; +import { loadBackgroundLayerVisibilityFromStorage } from "@/uhm/lib/editor/background/backgroundVisibilityStorage"; + +import { + EditorStoreProvider, + useEditorStore, + useEditorStoreApi, +} from "@/uhm/store/editorStore"; + +import type { Feature, FeatureCollection, GeometryEntitySnapshot, GeometrySnapshot } from "@/uhm/types/geo"; +import type { EditorSnapshot, ProjectCommit, EntityWikiLinkSnapshot, Project } from "@/uhm/types/projects"; +import type { EntitySnapshot } from "@/uhm/types/entities"; +import type { WikiSnapshot } from "@/uhm/types/wiki"; + +const CURRENT_YEAR = new Date().getUTCFullYear(); +const DEFAULT_EDITOR_USER_ID = "admin-viewer"; + +// Helper functions to build read-only session snapshot. +function toEditorSessionSnapshot(snapshot: EditorSnapshot): EditorSnapshot { + return { + ...snapshot, + entities: toEditorSessionEntities(snapshot.entities), + geometries: toEditorSessionGeometries(snapshot.geometries), + geometry_entity: toEditorSessionGeometryEntity(snapshot.geometry_entity), + wikis: toEditorSessionWikis(snapshot.wikis), + entity_wiki: toEditorSessionEntityWikiLinks(snapshot.entity_wiki), + }; +} + +function toEditorSessionEntities(input: EditorSnapshot["entities"]): EntitySnapshot[] { + const rows = Array.isArray(input) ? input : []; + return rows + .filter((e): e is any => Boolean(e) && (typeof e.id === "string" || typeof e.id === "number")) + .filter((e) => e.operation !== "delete") + .map((e) => { + const id = String(e.id); + const source: EntitySnapshot["source"] = e.source === "inline" ? "inline" : "ref"; + return { + id, + source, + operation: "reference", + name: typeof e.name === "string" ? e.name : undefined, + description: typeof e.description === "string" ? e.description : e.description ?? null, + time_start: normalizeTimelineYearValue(e.time_start) ?? undefined, + time_end: normalizeTimelineYearValue(e.time_end) ?? undefined, + }; + }); +} + +function toEditorSessionGeometries(input: EditorSnapshot["geometries"]): GeometrySnapshot[] { + const rows = Array.isArray(input) ? input : []; + return rows + .filter((g): g is any => Boolean(g) && (typeof g.id === "string" || typeof g.id === "number")) + .filter((g) => g.operation !== "delete") + .map((g) => { + const id = String(g.id); + const source: GeometrySnapshot["source"] = g.source === "inline" ? "inline" : "ref"; + return { + id, + source, + operation: "reference", + type: g.type ?? undefined, + draw_geometry: g.draw_geometry, + geometry: g.geometry, + bound_with: g.bound_with ?? null, + time_start: normalizeTimelineYearValue(g.time_start) ?? undefined, + time_end: normalizeTimelineYearValue(g.time_end) ?? undefined, + bbox: g.bbox + ? { + min_lng: g.bbox.min_lng, + min_lat: g.bbox.min_lat, + max_lng: g.bbox.max_lng, + max_lat: g.bbox.max_lat, + } + : g.bbox ?? undefined, + }; + }); +} + +function toEditorSessionGeometryEntity(input: EditorSnapshot["geometry_entity"]): GeometryEntitySnapshot[] { + const rows = Array.isArray(input) ? input : []; + const deduped = new globalThis.Map(); + for (const row of rows) { + if (!row) continue; + const safeRow = row as any; + if (safeRow.operation === "delete") continue; + const geometry_id = typeof safeRow.geometry_id === "string" || typeof safeRow.geometry_id === "number" + ? String(safeRow.geometry_id).trim() + : ""; + const entity_id = typeof safeRow.entity_id === "string" || typeof safeRow.entity_id === "number" + ? String(safeRow.entity_id).trim() + : ""; + if (!geometry_id || !entity_id) continue; + const key = `${geometry_id}::${entity_id}`; + deduped.set(key, { + geometry_id, + entity_id, + operation: "reference", + }); + } + return Array.from(deduped.values()).sort((a, b) => { + const g = a.geometry_id.localeCompare(b.geometry_id); + if (g !== 0) return g; + return a.entity_id.localeCompare(b.entity_id); + }); +} + +function toEditorSessionWikis(input: EditorSnapshot["wikis"]): WikiSnapshot[] { + const rows = Array.isArray(input) ? input : []; + return rows + .filter((w): w is any => Boolean(w) && typeof w.id === "string" && w.id.trim().length > 0) + .filter((w) => w.operation !== "delete") + .map((w) => { + const source: WikiSnapshot["source"] = w.source === "inline" ? "inline" : "ref"; + return { + id: w.id, + source, + operation: "reference", + title: typeof w.title === "string" ? w.title : "", + slug: w.slug ?? null, + doc: w.doc ?? null, + }; + }); +} + +function toEditorSessionEntityWikiLinks(input: EditorSnapshot["entity_wiki"]): EntityWikiLinkSnapshot[] { + const rows = Array.isArray(input) ? input : []; + const deduped = new globalThis.Map(); + for (const row of rows) { + if (!row) continue; + const safeRow = row as any; + if (safeRow.operation === "delete") continue; + const entity_id = typeof safeRow.entity_id === "string" || typeof safeRow.entity_id === "number" + ? String(safeRow.entity_id).trim() + : ""; + const wiki_id = typeof safeRow.wiki_id === "string" || typeof safeRow.wiki_id === "number" + ? String(safeRow.wiki_id).trim() + : ""; + if (!entity_id || !wiki_id) continue; + const key = `${entity_id}::${wiki_id}`; + deduped.set(key, { + entity_id, + wiki_id, + operation: "reference", + }); + } + return Array.from(deduped.values()).sort((a, b) => { + const e = a.entity_id.localeCompare(b.entity_id); + if (e !== 0) return e; + return a.wiki_id.localeCompare(b.wiki_id); + }); +} + +export default function ProjectMapPage() { + return ( + + + + ); +} + +function StoreInitializer({ + project, + sessionSnapshot, +}: { + project: Project; + sessionSnapshot: EditorSnapshot; +}) { + const store = useEditorStoreApi(); + useEffect(() => { + if (!project || !sessionSnapshot) return; + const state = store.getState(); + state.setActiveSection(project); + state.setSelectedProjectId(project.id); + state.setBaselineSnapshot(sessionSnapshot); + state.setBaselineFeatureCollection(sessionSnapshot?.editor_feature_collection || EMPTY_FEATURE_COLLECTION); + state.setSnapshotEntityRows(sessionSnapshot?.entities || []); + state.setSnapshotWikis(sessionSnapshot?.wikis || []); + state.setSnapshotEntityWikiLinks(sessionSnapshot?.entity_wiki || []); + state.setBackgroundVisibility(loadBackgroundLayerVisibilityFromStorage()); + state.setIsBackgroundVisibilityReady(true); + + // Auto-detect the earliest year from geometries or entities to center the timeline. + let minYear: number | null = null; + if (sessionSnapshot?.geometries && sessionSnapshot.geometries.length > 0) { + for (const g of sessionSnapshot.geometries) { + if (g.time_start !== undefined && g.time_start !== null) { + const y = Number(g.time_start); + if (!Number.isNaN(y)) { + if (minYear === null || y < minYear) { + minYear = y; + } + } + } + } + } + if (minYear === null && sessionSnapshot?.entities && sessionSnapshot.entities.length > 0) { + for (const e of sessionSnapshot.entities) { + if (e.time_start !== undefined && e.time_start !== null) { + const y = Number(e.time_start); + if (!Number.isNaN(y)) { + if (minYear === null || y < minYear) { + minYear = y; + } + } + } + } + } + if (minYear !== null) { + state.setTimelineDraftYear(clampYearToFixedRange(minYear)); + } + }, [project, sessionSnapshot, store]); + + return null; +} + +function clampNumber(value: number, min: number, max: number): number { + if (value < min) return min; + if (value > max) return max; + return value; +} + +function ProjectMapPageContent() { + const params = useParams(); + const router = useRouter(); + const id = String(params.id || ""); + + const [project, setProject] = useState(null); + const [latestCommit, setLatestCommit] = useState(null); + const [sessionSnapshot, setSessionSnapshot] = useState(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + // Retrieve Zustand state + const { + baselineFeatureCollection, + selectedFeatureIds, + setSelectedFeatureIds, + timelineDraftYear, + setTimelineDraftYear, + backgroundVisibility, + geometryVisibility, + leftPanelWidth, + setLeftPanelWidth, + rightPanelWidth, + setRightPanelWidth, + timelineFilterEnabled, + setTimelineFilterEnabled, + } = useEditorStore(useShallow((state) => ({ + baselineFeatureCollection: state.baselineFeatureCollection, + selectedFeatureIds: state.selectedFeatureIds, + setSelectedFeatureIds: state.setSelectedFeatureIds, + timelineDraftYear: state.timelineDraftYear, + setTimelineDraftYear: state.setTimelineDraftYear, + backgroundVisibility: state.backgroundVisibility, + geometryVisibility: state.geometryVisibility, + leftPanelWidth: state.leftPanelWidth, + setLeftPanelWidth: state.setLeftPanelWidth, + rightPanelWidth: state.rightPanelWidth, + setRightPanelWidth: state.setRightPanelWidth, + timelineFilterEnabled: state.timelineFilterEnabled, + setTimelineFilterEnabled: state.setTimelineFilterEnabled, + }))); + + // Fetch project details and latest commit snapshot + useEffect(() => { + async function loadData() { + try { + setLoading(true); + setError(null); + + const projRes = await requestJson(`${API_ENDPOINTS.projects}/${encodeURIComponent(id)}`); + if (!projRes) { + throw new Error("Không thể tải thông tin dự án"); + } + setProject(projRes); + + const commits = await fetchProjectCommits(id); + const headCommitId = projRes.latest_commit_id ?? null; + const targetCommit = headCommitId ? commits.find((c) => c.id === headCommitId) || null : null; + + if (targetCommit) { + const snapshot = normalizeEditorSnapshot(targetCommit.snapshot_json); + const session = snapshot ? toEditorSessionSnapshot(snapshot) : null; + setSessionSnapshot(session); + setLatestCommit(targetCommit); + } else if (commits.length > 0) { + const fallbackCommit = commits[0]; + const snapshot = normalizeEditorSnapshot(fallbackCommit.snapshot_json); + const session = snapshot ? toEditorSessionSnapshot(snapshot) : null; + setSessionSnapshot(session); + setLatestCommit(fallbackCommit); + } else { + const emptySnapshot: EditorSnapshot = { + editor_feature_collection: EMPTY_FEATURE_COLLECTION, + entities: [], + geometries: [], + geometry_entity: [], + wikis: [], + entity_wiki: [], + }; + setSessionSnapshot(emptySnapshot); + } + } catch (err: any) { + console.error("Error loading project map details:", err); + setError(err.message || "Lỗi hệ thống"); + } finally { + setLoading(false); + } + } + if (id) { + loadData(); + } + }, [id]); + + const activeTimelineYear = timelineDraftYear; + const activeTimelineFilterEnabled = timelineFilterEnabled; + + const activeMapDraft = useMemo(() => { + const draft = baselineFeatureCollection || EMPTY_FEATURE_COLLECTION; + if (!activeTimelineFilterEnabled) return draft; + return { + type: "FeatureCollection", + features: draft.features.filter((f) => isFeatureVisibleAtYear(f, activeTimelineYear)), + }; + }, [baselineFeatureCollection, activeTimelineFilterEnabled, activeTimelineYear]); + + const mapLabelContextDraft = useMemo(() => { + return baselineFeatureCollection || EMPTY_FEATURE_COLLECTION; + }, [baselineFeatureCollection]); + + const geometryChoices = useMemo(() => { + const draft = baselineFeatureCollection || EMPTY_FEATURE_COLLECTION; + const mapRenderGeometryIds = new Set( + activeMapDraft.features.map((feature) => String(feature.properties.id)) + ); + + const rows = draft.features + .filter((f) => f && f.properties && (typeof f.properties.id === "string" || typeof f.properties.id === "number")) + .map((f) => { + const fid = String(f.properties.id); + const semantic = String(f.properties.type || getDefaultTypeIdForFeature(f) || "").trim(); + const label = semantic.length ? `${semantic} (${f.geometry.type})` : "Geometry"; + const timeStart = normalizeTimelineYearValue(f.properties.time_start); + const timeEnd = normalizeTimelineYearValue(f.properties.time_end); + const hasStart = timeStart !== null; + const hasEnd = timeEnd !== null; + const timeStatus: "missing" | "partial" | "complete" = + !hasStart && !hasEnd + ? "missing" + : !hasStart || !hasEnd + ? "partial" + : "complete"; + const isTimelineVisible = mapRenderGeometryIds.has(fid); + const timelineStatus: "off" | "visible" | "filteredOut" = !activeTimelineFilterEnabled + ? "off" + : isTimelineVisible + ? "visible" + : "filteredOut"; + return { + id: fid, + label, + time_start: timeStart, + time_end: timeEnd, + isTimelineVisible, + isOrphan: normalizeFeatureEntityIds(f).length === 0, + timeStatus, + timelineStatus, + isNew: false, + }; + }); + + rows.sort((a, b) => { + const na = String(a.label || a.id); + const nb = String(b.label || b.id); + return na.localeCompare(nb); + }); + return rows; + }, [baselineFeatureCollection, activeMapDraft, activeTimelineFilterEnabled]); + + const selectedFeatures = useMemo(() => { + const draft = baselineFeatureCollection || EMPTY_FEATURE_COLLECTION; + return selectedFeatureIds + .map((fid) => draft.features.find((f) => f.properties.id === fid) || null) + .filter((f): f is Feature => Boolean(f)); + }, [baselineFeatureCollection, selectedFeatureIds]); + + const selectedFeature = selectedFeatures[0] || null; + + const selectedGeometryChildIds = useMemo(() => { + if (!selectedFeature) return []; + return getDirectGeometryChildIds( + baselineFeatureCollection || EMPTY_FEATURE_COLLECTION, + String(selectedFeature.properties.id) + ); + }, [baselineFeatureCollection, selectedFeature]); + + const selectedGeometryTime = useMemo(() => { + if (!selectedFeature) return null; + const start = normalizeTimelineYearValue(selectedFeature.properties.time_start); + const end = normalizeTimelineYearValue(selectedFeature.properties.time_end); + return { time_start: start, time_end: end }; + }, [selectedFeature]); + + if (loading) { + return ( +
+
+
Đang tải dữ liệu bản đồ...
+ +
+ ); + } + + if (error || !project || !sessionSnapshot) { + return ( +
+ + + + + +
{error || "Không thể tìm thấy dự án"}
+ +
+ ); + } + + return ( +
+ + + + + {/* Left Sidebar */} +
+ {/* Header */} +
+ +
+
Chi tiết bản đồ dự án
+
{project.title}
+
+
+ + {/* Info Area */} +
+
+
Mô tả dự án
+
{project.description || "Không có mô tả dự án."}
+
+ +
+
+
Trạng thái
+
+ + {project.project_status || "N/A"} + +
+
+
+
Người sở hữu (Owner)
+
{project.user_id || "N/A"}
+
+
+ + {project.user && ( +
+
Thông tin Owner
+
+ {project.user.avatar_url ? ( + avatar + ) : ( +
+ {project.user.display_name?.charAt(0).toUpperCase() || "U"} +
+ )} +
+
{project.user.display_name || "N/A"}
+
{project.user.email || ""}
+
+
+
+ )} + +
+
Thông tin Commit mới nhất
+ {latestCommit ? ( +
+
+
Nội dung
+
{latestCommit.edit_summary || "(Không có nội dung)"}
+
+
+
ID Commit
+
{latestCommit.id}
+
+ {latestCommit.created_at && ( +
+
Thời gian
+
{new Date(latestCommit.created_at).toLocaleString("vi-VN")}
+
+ )} +
+ ) : ( +
+ Dự án chưa có commit nào. Bản đồ trống. +
+ )} +
+
+
+ + {/* Resize left panel */} + { + setLeftPanelWidth((prev) => clampNumber(prev + deltaX, 220, 520)); + }} + /> + + {/* Map Area */} +
+ + + +
+ + {/* Resize right panel */} + { + setRightPanelWidth((prev) => clampNumber(prev - deltaX, 260, 720)); + }} + /> + + {/* Right Sidebar */} + + {}} // no-op read-only + onFocusGeometry={(id) => { + const target = baselineFeatureCollection?.features.find((f) => String(f.properties.id) === String(id)); + if (target) { + setSelectedFeatureIds([target.properties.id]); + } + }} + readOnly={true} + /> + + {}} + onUpdateEntity={() => {}} + hasSelectedGeometry={Boolean(selectedFeature)} + selectedGeometryTime={selectedGeometryTime} + onToggleBindEntityForSelectedGeometry={() => {}} + onRerollEntityId={() => {}} + onDeleteEntity={() => {}} + readOnly={true} + /> + + {}} + onRemoveWiki={() => {}} + readOnly={true} + /> + + {}} + readOnly={true} + /> + + {selectedFeatures.length > 0 ? ( + ({ ok: true })} + onDeleteFeatures={() => {}} + onDeselectAll={() => setSelectedFeatureIds([])} + changeCount={0} + onReplayEdit={() => {}} + onRerollGeometryId={() => {}} + readOnly={true} + /> + ) : null} +
+ } + /> +
+ ); +} diff --git a/src/app/(full-width-pages)/submissions/[id]/page.tsx b/src/app/(full-width-pages)/submissions/[id]/page.tsx index 8a4cb48..fa633d9 100644 --- a/src/app/(full-width-pages)/submissions/[id]/page.tsx +++ b/src/app/(full-width-pages)/submissions/[id]/page.tsx @@ -772,6 +772,7 @@ function SubmissionDetailPageContent() { setSelectedFeatureIds([target.properties.id]); } }} + readOnly={true} /> {}} onRerollEntityId={() => {}} onDeleteEntity={() => {}} + readOnly={true} /> {}} onRemoveWiki={() => {}} + readOnly={true} /> {}} + readOnly={true} /> {selectedFeatures.length > 0 ? ( @@ -803,6 +807,7 @@ function SubmissionDetailPageContent() { changeCount={0} onReplayEdit={() => {}} onRerollGeometryId={() => {}} + readOnly={true} /> ) : null} diff --git a/src/components/tables/ProjectsTable.tsx b/src/components/tables/ProjectsTable.tsx index e44b15a..ac53758 100644 --- a/src/components/tables/ProjectsTable.tsx +++ b/src/components/tables/ProjectsTable.tsx @@ -33,6 +33,7 @@ interface ProjectsTableProps { sortBy?: ProjectSortColumn; sortOrder?: "asc" | "desc"; onViewDetails: (id: string) => void; + onViewMap: (id: string) => void; } export default function ProjectsTable({ @@ -41,6 +42,7 @@ export default function ProjectsTable({ sortBy, sortOrder, onViewDetails, + onViewMap, }: ProjectsTableProps) { const formatDate = (dateString: string | null | undefined) => { if (!dateString) return "-"; @@ -105,7 +107,7 @@ export default function ProjectsTable({ return (
-
+
@@ -116,19 +118,25 @@ export default function ProjectsTable({
-
+
-
+
-
+
Thành viên
+ +
+ + Thao tác + +
@@ -182,15 +190,15 @@ export default function ProjectsTable({
-
+
{formatDate(item.created_at)}
-
+
{formatDate(item.updated_at)}
-
+
{item.members && item.members.length > 0 ? ( <> @@ -235,6 +243,23 @@ export default function ProjectsTable({
+
+ + + Chi tiết + +
+
)) ) : ( diff --git a/src/uhm/components/editor/EntityWikiBindingsPanel.tsx b/src/uhm/components/editor/EntityWikiBindingsPanel.tsx index cb0836e..21f8c27 100644 --- a/src/uhm/components/editor/EntityWikiBindingsPanel.tsx +++ b/src/uhm/components/editor/EntityWikiBindingsPanel.tsx @@ -21,6 +21,7 @@ type BindingRow = { type Props = { setLinks: React.Dispatch>; + readOnly?: boolean; }; function wikiTitle(w: WikiSnapshot): string { @@ -28,7 +29,7 @@ function wikiTitle(w: WikiSnapshot): string { return t.length ? t : "Untitled wiki"; } -function EntityWikiBindingsPanel({ setLinks }: Props) { +function EntityWikiBindingsPanel({ setLinks, readOnly }: Props) { const { entityCatalog, snapshotEntityRows, @@ -189,186 +190,189 @@ function EntityWikiBindingsPanel({ setLinks }: Props) { {collapsed ? null : (
-
-
Entity
- - {activeEntityId ? ( - - ) : null} -
- -
-
Wikis
-
- - {activeWikiChoice ? ( - - ) : null} - - {wikiChoices.length === 0 ? ( -
No wiki in project yet.
- ) : ( - <> - +
+
Wikis
+
+ + {activeWikiChoice ? ( + + ) : null} + + {wikiChoices.length === 0 ? ( +
No wiki in project yet.
+ ) : ( + <> + + + {activeWikiChoice ? ( +
+ {activeWikiChoice.id}
- ); - })} + ) : null} -
- ) : ( -
No wiki linked yet.
- )} - - )} -
-
+ {!activeEntityId ? ( +
Pick an entity to see/link wikis.
+ ) : activeLinks.size ? ( +
+
Linked wikis ({activeLinks.size})
+ {Array.from(activeLinks).map((id) => { + const w = wikiChoices.find((x) => x.id === id) || null; + return ( +
+
+
+ + {w?.title || "Untitled wiki"} + +
+
+ {id} +
+
+ +
+ ); + })} +
+ ) : ( +
No wiki linked yet.
+ )} + + )} +
+
+ + )} -
-
- All bindings ({activeBindingRows.length}) -
+
+
+ All bindings ({activeBindingRows.length}) +
{activeBindingRows.length ? (
{activeBindingRows.map((row) => ( diff --git a/src/uhm/components/editor/GeometryBindingPanel.tsx b/src/uhm/components/editor/GeometryBindingPanel.tsx index 51d77aa..4ccb349 100644 --- a/src/uhm/components/editor/GeometryBindingPanel.tsx +++ b/src/uhm/components/editor/GeometryBindingPanel.tsx @@ -32,6 +32,7 @@ type Props = { selectedGeometryChildIds: string[]; onToggleBindGeometryForSelectedGeometry?: (geometryId: string, nextChecked: boolean) => void; onFocusGeometry?: (geometryId: string) => void; + readOnly?: boolean; }; function GeometryBindingPanel({ @@ -40,6 +41,7 @@ function GeometryBindingPanel({ selectedGeometryChildIds, onToggleBindGeometryForSelectedGeometry, onFocusGeometry, + readOnly, }: Props) { const { selectedFeatureIds, @@ -62,7 +64,7 @@ function GeometryBindingPanel({ selectedGeometryId ?? (selectedFeatureIds.length > 0 ? String(selectedFeatureIds[0]) : null); const canBindToggle = - Boolean(effectiveSelectedGeometryId) && typeof onToggleBindGeometryForSelectedGeometry === "function"; + !readOnly && Boolean(effectiveSelectedGeometryId) && typeof onToggleBindGeometryForSelectedGeometry === "function"; const canFocusGeometry = typeof onFocusGeometry === "function"; const [collapsed, setCollapsed] = useState(false); diff --git a/src/uhm/components/editor/ProjectEntityRefsPanel.tsx b/src/uhm/components/editor/ProjectEntityRefsPanel.tsx index ba15ab0..d56fc6b 100644 --- a/src/uhm/components/editor/ProjectEntityRefsPanel.tsx +++ b/src/uhm/components/editor/ProjectEntityRefsPanel.tsx @@ -15,6 +15,7 @@ type Props = { onToggleBindEntityForSelectedGeometry?: (entityId: string, nextChecked: boolean) => void; onRerollEntityId?: (oldId: string, nextId: string) => void; onDeleteEntity?: (entityId: string) => void; + readOnly?: boolean; }; function ProjectEntityRefsPanel({ @@ -25,6 +26,7 @@ function ProjectEntityRefsPanel({ onToggleBindEntityForSelectedGeometry, onRerollEntityId, onDeleteEntity, + readOnly, }: Props) { const { snapshotEntityRows, @@ -44,11 +46,12 @@ function ProjectEntityRefsPanel({ })) ); const canBindToggle = + !readOnly && Boolean(hasSelectedGeometry) && Array.isArray(selectedGeometryEntityIds) && typeof onToggleBindEntityForSelectedGeometry === "function"; - const canEditEntity = typeof onUpdateEntity === "function"; + const canEditEntity = !readOnly && typeof onUpdateEntity === "function"; const [isCreateOpen, setIsCreateOpen] = useState(false); const [collapsed, setCollapsed] = useState(false); const [activeEntityId, setActiveEntityId] = useState(null); @@ -236,7 +239,7 @@ function ProjectEntityRefsPanel({ )} ) : null} - {typeof onDeleteEntity === "function" ? ( + {!readOnly && typeof onDeleteEntity === "function" ? ( - + {!readOnly && ( + <> + + + + )} - {onReplayEdit && !isBulkMode && selectedFeatures.length > 0 && ( + {!readOnly && ( + + )} + {!readOnly && onReplayEdit && !isBulkMode && selectedFeatures.length > 0 && (
- {changeCount > 0 ? ( + {!readOnly && changeCount > 0 ? (
Thay đổi sẽ vào lịch sử khi Commit.
diff --git a/src/uhm/components/wiki/WikiSidebarPanel.tsx b/src/uhm/components/wiki/WikiSidebarPanel.tsx index 6be7e27..c571ff6 100644 --- a/src/uhm/components/wiki/WikiSidebarPanel.tsx +++ b/src/uhm/components/wiki/WikiSidebarPanel.tsx @@ -57,6 +57,7 @@ type Props = { projectId: string; setWikis: React.Dispatch>; onRemoveWiki?: (wikiId: string) => void; + readOnly?: boolean; }; function clampTitle(title: string) { @@ -64,7 +65,7 @@ function clampTitle(title: string) { return t.length ? t.slice(0, 120) : "Untitled wiki"; } -function WikiSidebarPanel({ projectId, setWikis, onRemoveWiki }: Props) { +function WikiSidebarPanel({ projectId, setWikis, onRemoveWiki, readOnly }: Props) { const { wikis, requestedActiveId } = useEditorStore( useShallow((state) => ({ wikis: state.snapshotWikis, @@ -672,26 +673,28 @@ function WikiSidebarPanel({ projectId, setWikis, onRemoveWiki }: Props) { {isNewWiki(w) ? : null} - + {!readOnly && ( + + )}
))} @@ -702,7 +705,7 @@ function WikiSidebarPanel({ projectId, setWikis, onRemoveWiki }: Props) {
)} - {collapsed ? null : ( + {collapsed || readOnly ? null : (
{projectId}
- + {!readOnly && ( + + )} - + {!readOnly && ( + + )}
@@ -880,7 +887,7 @@ function WikiSidebarPanel({ projectId, setWikis, onRemoveWiki }: Props) { onChange={(e) => setWikiTitle(e.target.value)} className="h-11 w-full rounded-xl border border-gray-200 bg-transparent px-4 py-2.5 text-sm text-gray-800 outline-none focus:border-brand-300 focus:ring-3 focus:ring-brand-500/10 dark:border-gray-800 dark:text-white/90 dark:focus:border-brand-800" placeholder="Wiki title" - disabled={!activeId} + disabled={readOnly || !activeId} />
@@ -890,7 +897,7 @@ function WikiSidebarPanel({ projectId, setWikis, onRemoveWiki }: Props) { onChange={(e) => setWikiSlug(e.target.value)} className="h-11 w-full rounded-xl border border-gray-200 bg-transparent px-4 py-2.5 text-sm text-gray-800 outline-none focus:border-brand-300 focus:ring-3 focus:ring-brand-500/10 dark:border-gray-800 dark:text-white/90 dark:focus:border-brand-800" placeholder="wiki-slug" - disabled={!activeId} + disabled={readOnly || !activeId} />
{wikiSaveError ? ( @@ -907,7 +914,7 @@ function WikiSidebarPanel({ projectId, setWikis, onRemoveWiki }: Props) { modules={quillModules} className="min-h-[320px] uhm-wiki-quill" placeholder="Nhap noi dung wiki..." - readOnly={!activeId} + readOnly={readOnly || !activeId} />