feat: add read-only mode to editor panels and implement project map viewing functionality
Build and Release / release (push) Successful in 38s
Build and Release / release (push) Successful in 38s
This commit is contained in:
@@ -167,6 +167,10 @@ export default function ProjectsPage(_props: {
|
|||||||
router.push(`/projects/${id}`);
|
router.push(`/projects/${id}`);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleViewMap = (id: string) => {
|
||||||
|
router.push(`/projects/${id}/map`);
|
||||||
|
};
|
||||||
|
|
||||||
const pagination = tableData?.pagination;
|
const pagination = tableData?.pagination;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -236,6 +240,7 @@ export default function ProjectsPage(_props: {
|
|||||||
sortBy={sortBy}
|
sortBy={sortBy}
|
||||||
sortOrder={sortOrder}
|
sortOrder={sortOrder}
|
||||||
onViewDetails={handleViewDetails}
|
onViewDetails={handleViewDetails}
|
||||||
|
onViewMap={handleViewMap}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -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<string, GeometryEntitySnapshot>();
|
||||||
|
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<string, EntityWikiLinkSnapshot>();
|
||||||
|
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 (
|
||||||
|
<EditorStoreProvider
|
||||||
|
options={{
|
||||||
|
emptyFeatureCollection: EMPTY_FEATURE_COLLECTION,
|
||||||
|
defaultEditorUserId: DEFAULT_EDITOR_USER_ID,
|
||||||
|
fallbackTimelineRange: FIXED_TIMELINE_RANGE,
|
||||||
|
currentYear: CURRENT_YEAR,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<ProjectMapPageContent />
|
||||||
|
</EditorStoreProvider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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<any | null>(null);
|
||||||
|
const [latestCommit, setLatestCommit] = useState<ProjectCommit | null>(null);
|
||||||
|
const [sessionSnapshot, setSessionSnapshot] = useState<EditorSnapshot | null>(null);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState<string | null>(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<any>(`${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<FeatureCollection>(() => {
|
||||||
|
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<FeatureCollection>(() => {
|
||||||
|
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<Feature[]>(() => {
|
||||||
|
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<string[]>(() => {
|
||||||
|
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 (
|
||||||
|
<div style={{ display: "flex", width: "100vw", height: "100vh", background: "#0b1220", alignItems: "center", justifyContent: "center", flexDirection: "column", gap: "16px" }}>
|
||||||
|
<div style={{ width: "48px", height: "48px", border: "4px solid #1f2937", borderTop: "4px solid #38bdf8", borderRadius: "50%", animation: "spin 1s linear infinite" }} />
|
||||||
|
<div style={{ color: "#94a3b8", fontSize: "14px", fontWeight: 500 }}>Đang tải dữ liệu bản đồ...</div>
|
||||||
|
<style>{`
|
||||||
|
@keyframes spin {
|
||||||
|
0% { transform: rotate(0deg); }
|
||||||
|
100% { transform: rotate(360deg); }
|
||||||
|
}
|
||||||
|
`}</style>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error || !project || !sessionSnapshot) {
|
||||||
|
return (
|
||||||
|
<div style={{ display: "flex", width: "100vw", height: "100vh", background: "#0b1220", alignItems: "center", justifyContent: "center", flexDirection: "column", gap: "20px", color: "#f8fafc" }}>
|
||||||
|
<svg width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="#ef4444" strokeWidth="2">
|
||||||
|
<circle cx="12" cy="12" r="10" />
|
||||||
|
<line x1="12" y1="8" x2="12" y2="12" />
|
||||||
|
<line x1="12" y1="16" x2="12.01" y2="16" />
|
||||||
|
</svg>
|
||||||
|
<div style={{ fontSize: "16px", fontWeight: 600 }}>{error || "Không thể tìm thấy dự án"}</div>
|
||||||
|
<button
|
||||||
|
onClick={() => router.push("/project")}
|
||||||
|
style={{
|
||||||
|
background: "#3b82f6",
|
||||||
|
color: "white",
|
||||||
|
border: "none",
|
||||||
|
padding: "10px 20px",
|
||||||
|
borderRadius: "8px",
|
||||||
|
cursor: "pointer",
|
||||||
|
fontWeight: 600,
|
||||||
|
fontSize: "14px",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Quay lại danh sách
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ display: "flex", minHeight: "100vh", background: "#0b1220" }}>
|
||||||
|
<style>{`
|
||||||
|
html, body {
|
||||||
|
overflow: hidden !important;
|
||||||
|
scrollbar-width: none !important;
|
||||||
|
}
|
||||||
|
html::-webkit-scrollbar, body::-webkit-scrollbar {
|
||||||
|
display: none !important;
|
||||||
|
}
|
||||||
|
`}</style>
|
||||||
|
|
||||||
|
<StoreInitializer project={project} sessionSnapshot={sessionSnapshot} />
|
||||||
|
|
||||||
|
{/* Left Sidebar */}
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
width: leftPanelWidth,
|
||||||
|
background: "#0b1220",
|
||||||
|
borderRight: "1px solid #1f2937",
|
||||||
|
display: "flex",
|
||||||
|
flexDirection: "column",
|
||||||
|
height: "100vh",
|
||||||
|
color: "#f8fafc",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* Header */}
|
||||||
|
<div style={{ padding: "16px", borderBottom: "1px solid #1f2937", display: "flex", alignItems: "center", gap: "12px" }}>
|
||||||
|
<button
|
||||||
|
onClick={() => router.push("/project")}
|
||||||
|
style={{
|
||||||
|
background: "transparent",
|
||||||
|
border: "none",
|
||||||
|
color: "#94a3b8",
|
||||||
|
cursor: "pointer",
|
||||||
|
padding: "4px",
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
}}
|
||||||
|
title="Quay lại danh sách"
|
||||||
|
>
|
||||||
|
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||||
|
<path d="M19 12H5M12 19l-7-7 7-7" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
<div>
|
||||||
|
<div style={{ fontSize: "11px", color: "#94a3b8", fontWeight: 500 }}>Chi tiết bản đồ dự án</div>
|
||||||
|
<div style={{ fontSize: "14px", fontWeight: 700, color: "#f8fafc" }}>{project.title}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Info Area */}
|
||||||
|
<div style={{ flex: 1, overflowY: "auto", padding: "16px", display: "flex", flexDirection: "column", gap: "20px" }}>
|
||||||
|
<div>
|
||||||
|
<div style={{ fontSize: "10px", textTransform: "uppercase", color: "#94a3b8", fontWeight: 600, letterSpacing: "0.05em", marginBottom: "6px" }}>Mô tả dự án</div>
|
||||||
|
<div style={{ fontSize: "12px", color: "#cbd5e1", lineHeight: "1.5" }}>{project.description || "Không có mô tả dự án."}</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: "12px" }}>
|
||||||
|
<div>
|
||||||
|
<div style={{ fontSize: "10px", textTransform: "uppercase", color: "#94a3b8", fontWeight: 600, letterSpacing: "0.05em", marginBottom: "4px" }}>Trạng thái</div>
|
||||||
|
<div>
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
display: "inline-block",
|
||||||
|
padding: "2px 8px",
|
||||||
|
borderRadius: "4px",
|
||||||
|
fontSize: "11px",
|
||||||
|
fontWeight: 700,
|
||||||
|
background: project.project_status === "PUBLIC" ? "#22c55e" : project.project_status === "PRIVATE" ? "#eab308" : "#64748b",
|
||||||
|
color: "#0f172a",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{project.project_status || "N/A"}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div style={{ fontSize: "10px", textTransform: "uppercase", color: "#94a3b8", fontWeight: 600, letterSpacing: "0.05em", marginBottom: "4px" }}>Người sở hữu (Owner)</div>
|
||||||
|
<div style={{ fontSize: "12px", color: "#cbd5e1" }}>{project.user_id || "N/A"}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{project.user && (
|
||||||
|
<div>
|
||||||
|
<div style={{ fontSize: "10px", textTransform: "uppercase", color: "#94a3b8", fontWeight: 600, letterSpacing: "0.05em", marginBottom: "6px" }}>Thông tin Owner</div>
|
||||||
|
<div style={{ display: "flex", alignItems: "center", gap: "10px" }}>
|
||||||
|
{project.user.avatar_url ? (
|
||||||
|
<img src={project.user.avatar_url} alt="avatar" style={{ width: "32px", height: "32px", borderRadius: "50%" }} />
|
||||||
|
) : (
|
||||||
|
<div style={{ width: "32px", height: "32px", borderRadius: "50%", background: "#334155", display: "flex", alignItems: "center", justifyContent: "center", fontWeight: "bold", fontSize: "12px", color: "#e2e8f0" }}>
|
||||||
|
{project.user.display_name?.charAt(0).toUpperCase() || "U"}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div>
|
||||||
|
<div style={{ fontSize: "13px", fontWeight: 600 }}>{project.user.display_name || "N/A"}</div>
|
||||||
|
<div style={{ fontSize: "12px", color: "#94a3b8" }}>{project.user.email || ""}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div style={{ borderTop: "1px solid #1f2937", paddingTop: "16px", display: "flex", flexDirection: "column", gap: "12px" }}>
|
||||||
|
<div style={{ fontSize: "10px", textTransform: "uppercase", color: "#94a3b8", fontWeight: 600, letterSpacing: "0.05em" }}>Thông tin Commit mới nhất</div>
|
||||||
|
{latestCommit ? (
|
||||||
|
<div style={{ display: "flex", flexDirection: "column", gap: "8px", background: "#0f172a", padding: "12px", borderRadius: "8px", border: "1px solid #1f2937" }}>
|
||||||
|
<div>
|
||||||
|
<div style={{ fontSize: "10px", color: "#94a3b8" }}>Nội dung</div>
|
||||||
|
<div style={{ fontSize: "12px", fontWeight: 600, color: "#e2e8f0" }}>{latestCommit.edit_summary || "(Không có nội dung)"}</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div style={{ fontSize: "10px", color: "#94a3b8" }}>ID Commit</div>
|
||||||
|
<div style={{ fontSize: "11px", color: "#cbd5e1", fontFamily: "monospace" }}>{latestCommit.id}</div>
|
||||||
|
</div>
|
||||||
|
{latestCommit.created_at && (
|
||||||
|
<div>
|
||||||
|
<div style={{ fontSize: "10px", color: "#94a3b8" }}>Thời gian</div>
|
||||||
|
<div style={{ fontSize: "11px", color: "#cbd5e1" }}>{new Date(latestCommit.created_at).toLocaleString("vi-VN")}</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div style={{ fontSize: "12px", color: "#94a3b8", fontStyle: "italic" }}>
|
||||||
|
Dự án chưa có commit nào. Bản đồ trống.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Resize left panel */}
|
||||||
|
<ResizeHandle
|
||||||
|
title="Resize left panel"
|
||||||
|
onDrag={(deltaX) => {
|
||||||
|
setLeftPanelWidth((prev) => clampNumber(prev + deltaX, 220, 520));
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Map Area */}
|
||||||
|
<div style={{ flex: 1, position: "relative", minHeight: "100vh" }}>
|
||||||
|
<Map
|
||||||
|
mode="idle"
|
||||||
|
renderDraft={activeMapDraft}
|
||||||
|
labelContextDraft={mapLabelContextDraft}
|
||||||
|
labelTimelineYear={activeTimelineFilterEnabled ? activeTimelineYear : null}
|
||||||
|
selectedFeatureIds={selectedFeatureIds}
|
||||||
|
onSelectFeatureIds={setSelectedFeatureIds}
|
||||||
|
allowGeometryEditing={false}
|
||||||
|
allowFeatureSelection={true}
|
||||||
|
backgroundVisibility={backgroundVisibility}
|
||||||
|
geometryVisibility={geometryVisibility}
|
||||||
|
applyGeometryBindingFilter={activeTimelineFilterEnabled}
|
||||||
|
hoverPopupEnabled={true}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<TimelineBar
|
||||||
|
year={activeTimelineYear}
|
||||||
|
onYearChange={setTimelineDraftYear}
|
||||||
|
isLoading={false}
|
||||||
|
disabled={false}
|
||||||
|
statusText={null}
|
||||||
|
filterEnabled={activeTimelineFilterEnabled}
|
||||||
|
onFilterEnabledChange={setTimelineFilterEnabled}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Resize right panel */}
|
||||||
|
<ResizeHandle
|
||||||
|
title="Resize right panel"
|
||||||
|
onDrag={(deltaX) => {
|
||||||
|
setRightPanelWidth((prev) => clampNumber(prev - deltaX, 260, 720));
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Right Sidebar */}
|
||||||
|
<BackgroundLayersPanel
|
||||||
|
width={rightPanelWidth}
|
||||||
|
topContent={
|
||||||
|
<div style={{ display: "grid", gap: "12px" }}>
|
||||||
|
<GeometryBindingPanel
|
||||||
|
geometries={geometryChoices}
|
||||||
|
selectedGeometryId={selectedFeature ? String(selectedFeature.properties.id) : null}
|
||||||
|
selectedGeometryChildIds={selectedGeometryChildIds}
|
||||||
|
onToggleBindGeometryForSelectedGeometry={() => {}} // 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}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<ProjectEntityRefsPanel
|
||||||
|
onCreateEntityOnly={() => {}}
|
||||||
|
onUpdateEntity={() => {}}
|
||||||
|
hasSelectedGeometry={Boolean(selectedFeature)}
|
||||||
|
selectedGeometryTime={selectedGeometryTime}
|
||||||
|
onToggleBindEntityForSelectedGeometry={() => {}}
|
||||||
|
onRerollEntityId={() => {}}
|
||||||
|
onDeleteEntity={() => {}}
|
||||||
|
readOnly={true}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<WikiSidebarPanel
|
||||||
|
projectId={project.id}
|
||||||
|
setWikis={() => {}}
|
||||||
|
onRemoveWiki={() => {}}
|
||||||
|
readOnly={true}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<EntityWikiBindingsPanel
|
||||||
|
setLinks={() => {}}
|
||||||
|
readOnly={true}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{selectedFeatures.length > 0 ? (
|
||||||
|
<SelectedGeometryPanel
|
||||||
|
selectedFeatures={selectedFeatures}
|
||||||
|
onApplyGeometryMetadata={async () => ({ ok: true })}
|
||||||
|
onDeleteFeatures={() => {}}
|
||||||
|
onDeselectAll={() => setSelectedFeatureIds([])}
|
||||||
|
changeCount={0}
|
||||||
|
onReplayEdit={() => {}}
|
||||||
|
onRerollGeometryId={() => {}}
|
||||||
|
readOnly={true}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -772,6 +772,7 @@ function SubmissionDetailPageContent() {
|
|||||||
setSelectedFeatureIds([target.properties.id]);
|
setSelectedFeatureIds([target.properties.id]);
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
|
readOnly={true}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<ProjectEntityRefsPanel
|
<ProjectEntityRefsPanel
|
||||||
@@ -782,16 +783,19 @@ function SubmissionDetailPageContent() {
|
|||||||
onToggleBindEntityForSelectedGeometry={() => {}}
|
onToggleBindEntityForSelectedGeometry={() => {}}
|
||||||
onRerollEntityId={() => {}}
|
onRerollEntityId={() => {}}
|
||||||
onDeleteEntity={() => {}}
|
onDeleteEntity={() => {}}
|
||||||
|
readOnly={true}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<WikiSidebarPanel
|
<WikiSidebarPanel
|
||||||
projectId={project.id}
|
projectId={project.id}
|
||||||
setWikis={() => {}}
|
setWikis={() => {}}
|
||||||
onRemoveWiki={() => {}}
|
onRemoveWiki={() => {}}
|
||||||
|
readOnly={true}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<EntityWikiBindingsPanel
|
<EntityWikiBindingsPanel
|
||||||
setLinks={() => {}}
|
setLinks={() => {}}
|
||||||
|
readOnly={true}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{selectedFeatures.length > 0 ? (
|
{selectedFeatures.length > 0 ? (
|
||||||
@@ -803,6 +807,7 @@ function SubmissionDetailPageContent() {
|
|||||||
changeCount={0}
|
changeCount={0}
|
||||||
onReplayEdit={() => {}}
|
onReplayEdit={() => {}}
|
||||||
onRerollGeometryId={() => {}}
|
onRerollGeometryId={() => {}}
|
||||||
|
readOnly={true}
|
||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -33,6 +33,7 @@ interface ProjectsTableProps {
|
|||||||
sortBy?: ProjectSortColumn;
|
sortBy?: ProjectSortColumn;
|
||||||
sortOrder?: "asc" | "desc";
|
sortOrder?: "asc" | "desc";
|
||||||
onViewDetails: (id: string) => void;
|
onViewDetails: (id: string) => void;
|
||||||
|
onViewMap: (id: string) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function ProjectsTable({
|
export default function ProjectsTable({
|
||||||
@@ -41,6 +42,7 @@ export default function ProjectsTable({
|
|||||||
sortBy,
|
sortBy,
|
||||||
sortOrder,
|
sortOrder,
|
||||||
onViewDetails,
|
onViewDetails,
|
||||||
|
onViewMap,
|
||||||
}: ProjectsTableProps) {
|
}: ProjectsTableProps) {
|
||||||
const formatDate = (dateString: string | null | undefined) => {
|
const formatDate = (dateString: string | null | undefined) => {
|
||||||
if (!dateString) return "-";
|
if (!dateString) return "-";
|
||||||
@@ -105,7 +107,7 @@ export default function ProjectsTable({
|
|||||||
return (
|
return (
|
||||||
<div className="overflow-hidden rounded-xl border border-gray-200 bg-white dark:border-gray-800 dark:bg-[#0d1117]">
|
<div className="overflow-hidden rounded-xl border border-gray-200 bg-white dark:border-gray-800 dark:bg-[#0d1117]">
|
||||||
<div className="max-w-full overflow-x-auto custom-scrollbar">
|
<div className="max-w-full overflow-x-auto custom-scrollbar">
|
||||||
<div className="min-w-[700px]">
|
<div className="min-w-[850px]">
|
||||||
|
|
||||||
<div className="flex items-center px-5 py-3 border-b border-gray-200 dark:border-gray-800 bg-gray-50/50 dark:bg-[#161b22]">
|
<div className="flex items-center px-5 py-3 border-b border-gray-200 dark:border-gray-800 bg-gray-50/50 dark:bg-[#161b22]">
|
||||||
|
|
||||||
@@ -116,19 +118,25 @@ export default function ProjectsTable({
|
|||||||
<SortButton column="title" label="Tên dự án" />
|
<SortButton column="title" label="Tên dự án" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="w-40 shrink-0">
|
<div className="w-32 shrink-0">
|
||||||
<SortButton column="created_at" label="Ngày tạo" />
|
<SortButton column="created_at" label="Ngày tạo" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="w-40 shrink-0">
|
<div className="w-32 shrink-0">
|
||||||
<SortButton column="updated_at" label="Cập nhật" />
|
<SortButton column="updated_at" label="Cập nhật" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="w-[120px] shrink-0 text-right">
|
<div className="w-[100px] shrink-0 text-right">
|
||||||
<span className="text-sm font-medium text-gray-500 dark:text-gray-400">
|
<span className="text-sm font-medium text-gray-500 dark:text-gray-400">
|
||||||
Thành viên
|
Thành viên
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className="w-[180px] shrink-0 text-right">
|
||||||
|
<span className="text-sm font-medium text-gray-500 dark:text-gray-400">
|
||||||
|
Thao tác
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex flex-col divide-y divide-gray-200 dark:divide-gray-800">
|
<div className="flex flex-col divide-y divide-gray-200 dark:divide-gray-800">
|
||||||
@@ -182,15 +190,15 @@ export default function ProjectsTable({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="md:w-40 shrink-0 text-xs text-gray-500 dark:text-[#8b949e]">
|
<div className="md:w-32 shrink-0 text-xs text-gray-500 dark:text-[#8b949e]">
|
||||||
<span>{formatDate(item.created_at)}</span>
|
<span>{formatDate(item.created_at)}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="md:w-40 shrink-0 text-xs text-gray-500 dark:text-[#8b949e]">
|
<div className="md:w-32 shrink-0 text-xs text-gray-500 dark:text-[#8b949e]">
|
||||||
<span>{formatDate(item.updated_at)}</span>
|
<span>{formatDate(item.updated_at)}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center md:w-[120px] md:justify-end shrink-0">
|
<div className="flex items-center md:w-[100px] md:justify-end shrink-0">
|
||||||
<div className="flex -space-x-2 overflow-hidden">
|
<div className="flex -space-x-2 overflow-hidden">
|
||||||
{item.members && item.members.length > 0 ? (
|
{item.members && item.members.length > 0 ? (
|
||||||
<>
|
<>
|
||||||
@@ -235,6 +243,23 @@ export default function ProjectsTable({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center md:w-[180px] md:justify-end shrink-0 gap-2">
|
||||||
|
<button
|
||||||
|
onClick={() => onViewDetails(item.id)}
|
||||||
|
className="px-2.5 py-1.5 text-xs font-medium text-gray-600 bg-gray-100 hover:bg-gray-200 rounded-lg dark:bg-gray-800 dark:text-gray-300 dark:hover:bg-gray-700 transition-colors"
|
||||||
|
>
|
||||||
|
Quản lý
|
||||||
|
</button>
|
||||||
|
<a
|
||||||
|
href={`/projects/${item.id}/map`}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="px-2.5 py-1.5 text-xs font-medium text-blue-600 bg-blue-50 hover:bg-blue-100 rounded-lg dark:bg-blue-900/30 dark:text-blue-400 dark:hover:bg-blue-900/50 transition-colors inline-block"
|
||||||
|
>
|
||||||
|
Chi tiết
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
))
|
))
|
||||||
) : (
|
) : (
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ type BindingRow = {
|
|||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
setLinks: React.Dispatch<React.SetStateAction<EntityWikiLinkSnapshot[]>>;
|
setLinks: React.Dispatch<React.SetStateAction<EntityWikiLinkSnapshot[]>>;
|
||||||
|
readOnly?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
function wikiTitle(w: WikiSnapshot): string {
|
function wikiTitle(w: WikiSnapshot): string {
|
||||||
@@ -28,7 +29,7 @@ function wikiTitle(w: WikiSnapshot): string {
|
|||||||
return t.length ? t : "Untitled wiki";
|
return t.length ? t : "Untitled wiki";
|
||||||
}
|
}
|
||||||
|
|
||||||
function EntityWikiBindingsPanel({ setLinks }: Props) {
|
function EntityWikiBindingsPanel({ setLinks, readOnly }: Props) {
|
||||||
const {
|
const {
|
||||||
entityCatalog,
|
entityCatalog,
|
||||||
snapshotEntityRows,
|
snapshotEntityRows,
|
||||||
@@ -189,186 +190,189 @@ function EntityWikiBindingsPanel({ setLinks }: Props) {
|
|||||||
|
|
||||||
{collapsed ? null : (
|
{collapsed ? null : (
|
||||||
<div style={{ marginTop: "10px", display: "grid", gap: "8px" }}>
|
<div style={{ marginTop: "10px", display: "grid", gap: "8px" }}>
|
||||||
<div>
|
{!readOnly && (
|
||||||
<div style={{ fontSize: "12px", color: "#94a3b8", marginBottom: "6px" }}>Entity</div>
|
<>
|
||||||
<select
|
<div>
|
||||||
value={activeEntityId}
|
<div style={{ fontSize: "12px", color: "#94a3b8", marginBottom: "6px" }}>Entity</div>
|
||||||
onChange={(e) => setActiveEntityId(e.target.value)}
|
<select
|
||||||
style={{
|
value={activeEntityId}
|
||||||
width: "100%",
|
onChange={(e) => setActiveEntityId(e.target.value)}
|
||||||
border: "1px solid #1f2937",
|
|
||||||
background: "#0b1220",
|
|
||||||
color: "#e5e7eb",
|
|
||||||
borderRadius: "6px",
|
|
||||||
padding: "8px 10px",
|
|
||||||
fontSize: "12px",
|
|
||||||
outline: "none",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<option value="">Select entity…</option>
|
|
||||||
{entityChoices.map((e) => (
|
|
||||||
<option key={e.id} value={e.id}>
|
|
||||||
{e.name}
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
{activeEntityId ? (
|
|
||||||
<ActiveSelectionLabel
|
|
||||||
label={activeEntityChoice?.name || activeEntityId}
|
|
||||||
id={activeEntityId}
|
|
||||||
isNew={Boolean(activeEntityChoice?.isNew)}
|
|
||||||
/>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<div style={{ fontSize: "12px", color: "#94a3b8", marginBottom: "6px" }}>Wikis</div>
|
|
||||||
<div style={{ display: "grid", gap: "8px" }}>
|
|
||||||
<select
|
|
||||||
value={activeWikiId}
|
|
||||||
onChange={(e) => setActiveWikiId(e.target.value)}
|
|
||||||
disabled={wikiChoices.length === 0}
|
|
||||||
style={{
|
|
||||||
width: "100%",
|
|
||||||
border: "1px solid #1f2937",
|
|
||||||
background: "#0b1220",
|
|
||||||
color: "#e5e7eb",
|
|
||||||
borderRadius: "6px",
|
|
||||||
padding: "8px 10px",
|
|
||||||
fontSize: "12px",
|
|
||||||
outline: "none",
|
|
||||||
opacity: wikiChoices.length === 0 ? 0.7 : 1,
|
|
||||||
cursor: wikiChoices.length === 0 ? "not-allowed" : "pointer",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<option value="">
|
|
||||||
{wikiChoices.length === 0 ? "No wikis available" : "Select wiki…"}
|
|
||||||
</option>
|
|
||||||
{wikiChoices.map((w) => (
|
|
||||||
<option key={w.id} value={w.id}>
|
|
||||||
{w.title}
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
{activeWikiChoice ? (
|
|
||||||
<ActiveSelectionLabel
|
|
||||||
label={activeWikiChoice.title}
|
|
||||||
id={activeWikiChoice.id}
|
|
||||||
isNew={Boolean(activeWikiChoice.isNew)}
|
|
||||||
/>
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
{wikiChoices.length === 0 ? (
|
|
||||||
<div style={{ fontSize: "12px", color: "#94a3b8" }}>No wiki in project yet.</div>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
disabled={!activeEntityId || !activeWikiId}
|
|
||||||
onClick={() => toggle(activeWikiId)}
|
|
||||||
style={{
|
style={{
|
||||||
border: "none",
|
width: "100%",
|
||||||
|
border: "1px solid #1f2937",
|
||||||
|
background: "#0b1220",
|
||||||
|
color: "#e5e7eb",
|
||||||
borderRadius: "6px",
|
borderRadius: "6px",
|
||||||
padding: "8px 10px",
|
padding: "8px 10px",
|
||||||
cursor: !activeEntityId || !activeWikiId ? "not-allowed" : "pointer",
|
fontSize: "12px",
|
||||||
background: activeWikiLinked ? "#334155" : "#16a34a",
|
outline: "none",
|
||||||
color: "white",
|
|
||||||
fontWeight: 800,
|
|
||||||
fontSize: 12,
|
|
||||||
opacity: !activeEntityId || !activeWikiId ? 0.65 : 1,
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{activeWikiLinked ? "Unlink wiki" : "Link wiki"}
|
<option value="">Select entity…</option>
|
||||||
</button>
|
{entityChoices.map((e) => (
|
||||||
|
<option key={e.id} value={e.id}>
|
||||||
{activeWikiChoice ? (
|
{e.name}
|
||||||
<div style={{ fontSize: 12, color: "#94a3b8", overflowWrap: "anywhere" }}>
|
</option>
|
||||||
{activeWikiChoice.id}
|
))}
|
||||||
</div>
|
</select>
|
||||||
|
{activeEntityId ? (
|
||||||
|
<ActiveSelectionLabel
|
||||||
|
label={activeEntityChoice?.name || activeEntityId}
|
||||||
|
id={activeEntityId}
|
||||||
|
isNew={Boolean(activeEntityChoice?.isNew)}
|
||||||
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
|
</div>
|
||||||
|
|
||||||
{!activeEntityId ? (
|
<div>
|
||||||
<div style={{ fontSize: 12, color: "#94a3b8" }}>Pick an entity to see/link wikis.</div>
|
<div style={{ fontSize: "12px", color: "#94a3b8", marginBottom: "6px" }}>Wikis</div>
|
||||||
) : activeLinks.size ? (
|
<div style={{ display: "grid", gap: "8px" }}>
|
||||||
<div style={{ display: "grid", gap: "6px", maxHeight: 250, overflowY: "auto", paddingRight: 4 }}>
|
<select
|
||||||
<div style={{ fontSize: 12, color: "#94a3b8" }}>Linked wikis ({activeLinks.size})</div>
|
value={activeWikiId}
|
||||||
{Array.from(activeLinks).map((id) => {
|
onChange={(e) => setActiveWikiId(e.target.value)}
|
||||||
const w = wikiChoices.find((x) => x.id === id) || null;
|
disabled={wikiChoices.length === 0}
|
||||||
return (
|
style={{
|
||||||
<div
|
width: "100%",
|
||||||
key={id}
|
border: "1px solid #1f2937",
|
||||||
style={{
|
background: "#0b1220",
|
||||||
padding: "8px",
|
color: "#e5e7eb",
|
||||||
borderRadius: "6px",
|
borderRadius: "6px",
|
||||||
border: "1px solid #1f2937",
|
padding: "8px 10px",
|
||||||
background: "#111827",
|
fontSize: "12px",
|
||||||
display: "flex",
|
outline: "none",
|
||||||
alignItems: "center",
|
opacity: wikiChoices.length === 0 ? 0.7 : 1,
|
||||||
justifyContent: "space-between",
|
cursor: wikiChoices.length === 0 ? "not-allowed" : "pointer",
|
||||||
gap: 8,
|
}}
|
||||||
}}
|
>
|
||||||
title={id}
|
<option value="">
|
||||||
>
|
{wikiChoices.length === 0 ? "No wikis available" : "Select wiki…"}
|
||||||
<div style={{ minWidth: 0 }}>
|
</option>
|
||||||
<div style={{ display: "flex", alignItems: "center", gap: 6, minWidth: 0 }}>
|
{wikiChoices.map((w) => (
|
||||||
<span
|
<option key={w.id} value={w.id}>
|
||||||
style={{
|
{w.title}
|
||||||
color: "#e5e7eb",
|
</option>
|
||||||
fontSize: 12,
|
))}
|
||||||
whiteSpace: "nowrap",
|
</select>
|
||||||
overflow: "hidden",
|
{activeWikiChoice ? (
|
||||||
textOverflow: "ellipsis",
|
<ActiveSelectionLabel
|
||||||
fontWeight: 700,
|
label={activeWikiChoice.title}
|
||||||
}}
|
id={activeWikiChoice.id}
|
||||||
>
|
isNew={Boolean(activeWikiChoice.isNew)}
|
||||||
{w?.title || "Untitled wiki"}
|
/>
|
||||||
</span>
|
) : null}
|
||||||
</div>
|
|
||||||
<div style={{ color: "#94a3b8", fontSize: 11, whiteSpace: "nowrap", overflow: "hidden", textOverflow: "ellipsis" }}>
|
{wikiChoices.length === 0 ? (
|
||||||
{id}
|
<div style={{ fontSize: "12px", color: "#94a3b8" }}>No wiki in project yet.</div>
|
||||||
</div>
|
) : (
|
||||||
</div>
|
<>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => toggle(id)}
|
disabled={!activeEntityId || !activeWikiId}
|
||||||
style={{
|
onClick={() => toggle(activeWikiId)}
|
||||||
border: "none",
|
style={{
|
||||||
background: "#0b1220",
|
border: "none",
|
||||||
color: "#fecaca",
|
borderRadius: "6px",
|
||||||
cursor: "pointer",
|
padding: "8px 10px",
|
||||||
borderRadius: 6,
|
cursor: !activeEntityId || !activeWikiId ? "not-allowed" : "pointer",
|
||||||
padding: "6px 8px",
|
background: activeWikiLinked ? "#334155" : "#16a34a",
|
||||||
fontSize: 12,
|
color: "white",
|
||||||
fontWeight: 800,
|
fontWeight: 800,
|
||||||
flex: "0 0 auto",
|
fontSize: 12,
|
||||||
}}
|
opacity: !activeEntityId || !activeWikiId ? 0.65 : 1,
|
||||||
>
|
}}
|
||||||
Unlink
|
>
|
||||||
</button>
|
{activeWikiLinked ? "Unlink wiki" : "Link wiki"}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{activeWikiChoice ? (
|
||||||
|
<div style={{ fontSize: 12, color: "#94a3b8", overflowWrap: "anywhere" }}>
|
||||||
|
{activeWikiChoice.id}
|
||||||
</div>
|
</div>
|
||||||
);
|
) : null}
|
||||||
})}
|
|
||||||
|
|
||||||
</div>
|
{!activeEntityId ? (
|
||||||
) : (
|
<div style={{ fontSize: 12, color: "#94a3b8" }}>Pick an entity to see/link wikis.</div>
|
||||||
<div style={{ fontSize: 12, color: "#94a3b8" }}>No wiki linked yet.</div>
|
) : activeLinks.size ? (
|
||||||
)}
|
<div style={{ display: "grid", gap: "6px", maxHeight: 250, overflowY: "auto", paddingRight: 4 }}>
|
||||||
</>
|
<div style={{ fontSize: 12, color: "#94a3b8" }}>Linked wikis ({activeLinks.size})</div>
|
||||||
)}
|
{Array.from(activeLinks).map((id) => {
|
||||||
</div>
|
const w = wikiChoices.find((x) => x.id === id) || null;
|
||||||
</div>
|
return (
|
||||||
|
<div
|
||||||
|
key={id}
|
||||||
|
style={{
|
||||||
|
padding: "8px",
|
||||||
|
borderRadius: "6px",
|
||||||
|
border: "1px solid #1f2937",
|
||||||
|
background: "#111827",
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "space-between",
|
||||||
|
gap: 8,
|
||||||
|
}}
|
||||||
|
title={id}
|
||||||
|
>
|
||||||
|
<div style={{ minWidth: 0 }}>
|
||||||
|
<div style={{ display: "flex", alignItems: "center", gap: 6, minWidth: 0 }}>
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
color: "#e5e7eb",
|
||||||
|
fontSize: 12,
|
||||||
|
whiteSpace: "nowrap",
|
||||||
|
overflow: "hidden",
|
||||||
|
textOverflow: "ellipsis",
|
||||||
|
fontWeight: 700,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{w?.title || "Untitled wiki"}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div style={{ color: "#94a3b8", fontSize: 11, whiteSpace: "nowrap", overflow: "hidden", textOverflow: "ellipsis" }}>
|
||||||
|
{id}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => toggle(id)}
|
||||||
|
style={{
|
||||||
|
border: "none",
|
||||||
|
background: "#0b1220",
|
||||||
|
color: "#fecaca",
|
||||||
|
cursor: "pointer",
|
||||||
|
borderRadius: 6,
|
||||||
|
padding: "6px 8px",
|
||||||
|
fontSize: 12,
|
||||||
|
fontWeight: 800,
|
||||||
|
flex: "0 0 auto",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Unlink
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div style={{ fontSize: 12, color: "#94a3b8" }}>No wiki linked yet.</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
borderTop: "1px solid #1f2937",
|
borderTop: readOnly ? "none" : "1px solid #1f2937",
|
||||||
paddingTop: 8,
|
paddingTop: readOnly ? 0 : 8,
|
||||||
display: "grid",
|
display: "grid",
|
||||||
gap: 6,
|
gap: 6,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div style={{ fontSize: 12, color: "#94a3b8" }}>
|
<div style={{ fontSize: 12, color: "#94a3b8" }}>
|
||||||
All bindings ({activeBindingRows.length})
|
All bindings ({activeBindingRows.length})
|
||||||
</div>
|
</div>
|
||||||
{activeBindingRows.length ? (
|
{activeBindingRows.length ? (
|
||||||
<div style={{ display: "grid", gap: 6, maxHeight: 260, overflowY: "auto", paddingRight: 4 }}>
|
<div style={{ display: "grid", gap: 6, maxHeight: 260, overflowY: "auto", paddingRight: 4 }}>
|
||||||
{activeBindingRows.map((row) => (
|
{activeBindingRows.map((row) => (
|
||||||
|
|||||||
@@ -32,6 +32,7 @@ type Props = {
|
|||||||
selectedGeometryChildIds: string[];
|
selectedGeometryChildIds: string[];
|
||||||
onToggleBindGeometryForSelectedGeometry?: (geometryId: string, nextChecked: boolean) => void;
|
onToggleBindGeometryForSelectedGeometry?: (geometryId: string, nextChecked: boolean) => void;
|
||||||
onFocusGeometry?: (geometryId: string) => void;
|
onFocusGeometry?: (geometryId: string) => void;
|
||||||
|
readOnly?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
function GeometryBindingPanel({
|
function GeometryBindingPanel({
|
||||||
@@ -40,6 +41,7 @@ function GeometryBindingPanel({
|
|||||||
selectedGeometryChildIds,
|
selectedGeometryChildIds,
|
||||||
onToggleBindGeometryForSelectedGeometry,
|
onToggleBindGeometryForSelectedGeometry,
|
||||||
onFocusGeometry,
|
onFocusGeometry,
|
||||||
|
readOnly,
|
||||||
}: Props) {
|
}: Props) {
|
||||||
const {
|
const {
|
||||||
selectedFeatureIds,
|
selectedFeatureIds,
|
||||||
@@ -62,7 +64,7 @@ function GeometryBindingPanel({
|
|||||||
selectedGeometryId ??
|
selectedGeometryId ??
|
||||||
(selectedFeatureIds.length > 0 ? String(selectedFeatureIds[0]) : null);
|
(selectedFeatureIds.length > 0 ? String(selectedFeatureIds[0]) : null);
|
||||||
const canBindToggle =
|
const canBindToggle =
|
||||||
Boolean(effectiveSelectedGeometryId) && typeof onToggleBindGeometryForSelectedGeometry === "function";
|
!readOnly && Boolean(effectiveSelectedGeometryId) && typeof onToggleBindGeometryForSelectedGeometry === "function";
|
||||||
const canFocusGeometry = typeof onFocusGeometry === "function";
|
const canFocusGeometry = typeof onFocusGeometry === "function";
|
||||||
|
|
||||||
const [collapsed, setCollapsed] = useState(false);
|
const [collapsed, setCollapsed] = useState(false);
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ type Props = {
|
|||||||
onToggleBindEntityForSelectedGeometry?: (entityId: string, nextChecked: boolean) => void;
|
onToggleBindEntityForSelectedGeometry?: (entityId: string, nextChecked: boolean) => void;
|
||||||
onRerollEntityId?: (oldId: string, nextId: string) => void;
|
onRerollEntityId?: (oldId: string, nextId: string) => void;
|
||||||
onDeleteEntity?: (entityId: string) => void;
|
onDeleteEntity?: (entityId: string) => void;
|
||||||
|
readOnly?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
function ProjectEntityRefsPanel({
|
function ProjectEntityRefsPanel({
|
||||||
@@ -25,6 +26,7 @@ function ProjectEntityRefsPanel({
|
|||||||
onToggleBindEntityForSelectedGeometry,
|
onToggleBindEntityForSelectedGeometry,
|
||||||
onRerollEntityId,
|
onRerollEntityId,
|
||||||
onDeleteEntity,
|
onDeleteEntity,
|
||||||
|
readOnly,
|
||||||
}: Props) {
|
}: Props) {
|
||||||
const {
|
const {
|
||||||
snapshotEntityRows,
|
snapshotEntityRows,
|
||||||
@@ -44,11 +46,12 @@ function ProjectEntityRefsPanel({
|
|||||||
}))
|
}))
|
||||||
);
|
);
|
||||||
const canBindToggle =
|
const canBindToggle =
|
||||||
|
!readOnly &&
|
||||||
Boolean(hasSelectedGeometry) &&
|
Boolean(hasSelectedGeometry) &&
|
||||||
Array.isArray(selectedGeometryEntityIds) &&
|
Array.isArray(selectedGeometryEntityIds) &&
|
||||||
typeof onToggleBindEntityForSelectedGeometry === "function";
|
typeof onToggleBindEntityForSelectedGeometry === "function";
|
||||||
|
|
||||||
const canEditEntity = typeof onUpdateEntity === "function";
|
const canEditEntity = !readOnly && typeof onUpdateEntity === "function";
|
||||||
const [isCreateOpen, setIsCreateOpen] = useState(false);
|
const [isCreateOpen, setIsCreateOpen] = useState(false);
|
||||||
const [collapsed, setCollapsed] = useState(false);
|
const [collapsed, setCollapsed] = useState(false);
|
||||||
const [activeEntityId, setActiveEntityId] = useState<string | null>(null);
|
const [activeEntityId, setActiveEntityId] = useState<string | null>(null);
|
||||||
@@ -236,7 +239,7 @@ function ProjectEntityRefsPanel({
|
|||||||
)}
|
)}
|
||||||
</button>
|
</button>
|
||||||
) : null}
|
) : null}
|
||||||
{typeof onDeleteEntity === "function" ? (
|
{!readOnly && typeof onDeleteEntity === "function" ? (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
title="Xóa thực thể khỏi dự án"
|
title="Xóa thực thể khỏi dự án"
|
||||||
@@ -420,7 +423,7 @@ function ProjectEntityRefsPanel({
|
|||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
{collapsed ? null : (
|
{collapsed || readOnly ? null : (
|
||||||
<>
|
<>
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ type Props = {
|
|||||||
onDeleteFeatures?: (ids: (string | number)[]) => void;
|
onDeleteFeatures?: (ids: (string | number)[]) => void;
|
||||||
onDeselectAll?: () => void;
|
onDeselectAll?: () => void;
|
||||||
onRerollGeometryId?: (oldId: string | number) => void;
|
onRerollGeometryId?: (oldId: string | number) => void;
|
||||||
|
readOnly?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
function SelectedGeometryPanel({
|
function SelectedGeometryPanel({
|
||||||
@@ -31,6 +32,7 @@ function SelectedGeometryPanel({
|
|||||||
onDeleteFeatures,
|
onDeleteFeatures,
|
||||||
onDeselectAll,
|
onDeselectAll,
|
||||||
onRerollGeometryId,
|
onRerollGeometryId,
|
||||||
|
readOnly,
|
||||||
}: Props) {
|
}: Props) {
|
||||||
const {
|
const {
|
||||||
geometryMetaForm,
|
geometryMetaForm,
|
||||||
@@ -156,41 +158,45 @@ function SelectedGeometryPanel({
|
|||||||
HÀNH ĐỘNG NHANH
|
HÀNH ĐỘNG NHANH
|
||||||
</div>
|
</div>
|
||||||
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: "6px" }}>
|
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: "6px" }}>
|
||||||
<button
|
{!readOnly && (
|
||||||
type="button"
|
<>
|
||||||
onClick={() => onReplayEdit?.(representativeFeature.properties.id)}
|
<button
|
||||||
style={{
|
type="button"
|
||||||
border: "none",
|
onClick={() => onReplayEdit?.(representativeFeature.properties.id)}
|
||||||
borderRadius: "6px",
|
style={{
|
||||||
padding: "8px 10px",
|
border: "none",
|
||||||
cursor: "pointer",
|
borderRadius: "6px",
|
||||||
background: "#2563eb",
|
padding: "8px 10px",
|
||||||
color: "#ffffff",
|
cursor: "pointer",
|
||||||
fontWeight: 700,
|
background: "#2563eb",
|
||||||
fontSize: "13px",
|
color: "#ffffff",
|
||||||
textAlign: "center",
|
fontWeight: 700,
|
||||||
gridColumn: "span 2",
|
fontSize: "13px",
|
||||||
}}
|
textAlign: "center",
|
||||||
>
|
gridColumn: "span 2",
|
||||||
Vào Replay ({selectedFeatures.length} geo)
|
}}
|
||||||
</button>
|
>
|
||||||
<button
|
Vào Replay ({selectedFeatures.length} geo)
|
||||||
type="button"
|
</button>
|
||||||
onClick={() => onDeleteFeatures?.(selectedFeatures.map(f => f.properties.id))}
|
<button
|
||||||
style={{
|
type="button"
|
||||||
border: "none",
|
onClick={() => onDeleteFeatures?.(selectedFeatures.map(f => f.properties.id))}
|
||||||
borderRadius: "6px",
|
style={{
|
||||||
padding: "7px 10px",
|
border: "none",
|
||||||
cursor: "pointer",
|
borderRadius: "6px",
|
||||||
background: "#dc2626",
|
padding: "7px 10px",
|
||||||
color: "#ffffff",
|
cursor: "pointer",
|
||||||
fontWeight: 600,
|
background: "#dc2626",
|
||||||
fontSize: "12px",
|
color: "#ffffff",
|
||||||
textAlign: "center",
|
fontWeight: 600,
|
||||||
}}
|
fontSize: "12px",
|
||||||
>
|
textAlign: "center",
|
||||||
Xóa ({selectedFeatures.length} geo)
|
}}
|
||||||
</button>
|
>
|
||||||
|
Xóa ({selectedFeatures.length} geo)
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => onDeselectAll?.()}
|
onClick={() => onDeselectAll?.()}
|
||||||
@@ -204,6 +210,7 @@ function SelectedGeometryPanel({
|
|||||||
fontWeight: 600,
|
fontWeight: 600,
|
||||||
fontSize: "12px",
|
fontSize: "12px",
|
||||||
textAlign: "center",
|
textAlign: "center",
|
||||||
|
gridColumn: readOnly ? "span 2" : undefined,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
Bỏ chọn tất cả
|
Bỏ chọn tất cả
|
||||||
@@ -229,7 +236,7 @@ function SelectedGeometryPanel({
|
|||||||
<div style={{ color: "#94a3b8", fontSize: "11px", overflowWrap: "anywhere", minWidth: 0, flex: 1 }}>
|
<div style={{ color: "#94a3b8", fontSize: "11px", overflowWrap: "anywhere", minWidth: 0, flex: 1 }}>
|
||||||
{isBulkMode ? `Đang chọn ${selectedFeatures.length} geometries` : `ID: ${representativeFeature.properties.id}`}
|
{isBulkMode ? `Đang chọn ${selectedFeatures.length} geometries` : `ID: ${representativeFeature.properties.id}`}
|
||||||
</div>
|
</div>
|
||||||
{canRerollGeometryId && onRerollGeometryId && (
|
{!readOnly && canRerollGeometryId && onRerollGeometryId && (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => onRerollGeometryId(representativeFeature.properties.id)}
|
onClick={() => onRerollGeometryId(representativeFeature.properties.id)}
|
||||||
@@ -268,7 +275,7 @@ function SelectedGeometryPanel({
|
|||||||
type_key: event.target.value,
|
type_key: event.target.value,
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
disabled={isEntitySubmitting}
|
disabled={readOnly || isEntitySubmitting}
|
||||||
style={entityInputStyle}
|
style={entityInputStyle}
|
||||||
>
|
>
|
||||||
{!hasCurrentVisibleTypeOption && geometryMetaForm.type_key ? (
|
{!hasCurrentVisibleTypeOption && geometryMetaForm.type_key ? (
|
||||||
@@ -307,7 +314,7 @@ function SelectedGeometryPanel({
|
|||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
placeholder="time_start"
|
placeholder="time_start"
|
||||||
disabled={isEntitySubmitting}
|
disabled={readOnly || isEntitySubmitting}
|
||||||
style={entityInputStyle}
|
style={entityInputStyle}
|
||||||
/>
|
/>
|
||||||
<input
|
<input
|
||||||
@@ -319,18 +326,20 @@ function SelectedGeometryPanel({
|
|||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
placeholder="time_end"
|
placeholder="time_end"
|
||||||
disabled={isEntitySubmitting}
|
disabled={readOnly || isEntitySubmitting}
|
||||||
style={entityInputStyle}
|
style={entityInputStyle}
|
||||||
/>
|
/>
|
||||||
<button
|
{!readOnly && (
|
||||||
type="button"
|
<button
|
||||||
onClick={handleApplyGeoMeta}
|
type="button"
|
||||||
disabled={isEntitySubmitting}
|
onClick={handleApplyGeoMeta}
|
||||||
style={primaryGeometryButtonStyle}
|
disabled={isEntitySubmitting}
|
||||||
>
|
style={primaryGeometryButtonStyle}
|
||||||
{isBulkMode ? `Apply cho ${selectedFeatures.length} geo` : "Apply"}
|
>
|
||||||
</button>
|
{isBulkMode ? `Apply cho ${selectedFeatures.length} geo` : "Apply"}
|
||||||
{onReplayEdit && !isBulkMode && selectedFeatures.length > 0 && (
|
</button>
|
||||||
|
)}
|
||||||
|
{!readOnly && onReplayEdit && !isBulkMode && selectedFeatures.length > 0 && (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => onReplayEdit(selectedFeatures[0].properties.id)}
|
onClick={() => onReplayEdit(selectedFeatures[0].properties.id)}
|
||||||
@@ -359,7 +368,7 @@ function SelectedGeometryPanel({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{changeCount > 0 ? (
|
{!readOnly && changeCount > 0 ? (
|
||||||
<div style={{ color: "#fca5a5", fontSize: "12px" }}>
|
<div style={{ color: "#fca5a5", fontSize: "12px" }}>
|
||||||
Thay đổi sẽ vào lịch sử khi Commit.
|
Thay đổi sẽ vào lịch sử khi Commit.
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -57,6 +57,7 @@ type Props = {
|
|||||||
projectId: string;
|
projectId: string;
|
||||||
setWikis: React.Dispatch<React.SetStateAction<WikiSnapshot[]>>;
|
setWikis: React.Dispatch<React.SetStateAction<WikiSnapshot[]>>;
|
||||||
onRemoveWiki?: (wikiId: string) => void;
|
onRemoveWiki?: (wikiId: string) => void;
|
||||||
|
readOnly?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
function clampTitle(title: string) {
|
function clampTitle(title: string) {
|
||||||
@@ -64,7 +65,7 @@ function clampTitle(title: string) {
|
|||||||
return t.length ? t.slice(0, 120) : "Untitled wiki";
|
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(
|
const { wikis, requestedActiveId } = useEditorStore(
|
||||||
useShallow((state) => ({
|
useShallow((state) => ({
|
||||||
wikis: state.snapshotWikis,
|
wikis: state.snapshotWikis,
|
||||||
@@ -672,26 +673,28 @@ function WikiSidebarPanel({ projectId, setWikis, onRemoveWiki }: Props) {
|
|||||||
{isNewWiki(w) ? <NewBadge /> : null}
|
{isNewWiki(w) ? <NewBadge /> : null}
|
||||||
</span>
|
</span>
|
||||||
</button>
|
</button>
|
||||||
<button
|
{!readOnly && (
|
||||||
type="button"
|
<button
|
||||||
onClick={() => removeWiki(w.id)}
|
type="button"
|
||||||
style={{
|
onClick={() => removeWiki(w.id)}
|
||||||
display: "inline-flex",
|
style={{
|
||||||
alignItems: "center",
|
display: "inline-flex",
|
||||||
justifyContent: "center",
|
alignItems: "center",
|
||||||
width: 22,
|
justifyContent: "center",
|
||||||
height: 22,
|
width: 22,
|
||||||
borderRadius: 6,
|
height: 22,
|
||||||
border: "1px solid #334155",
|
borderRadius: 6,
|
||||||
background: "#0b1220",
|
border: "1px solid #334155",
|
||||||
cursor: "pointer",
|
background: "#0b1220",
|
||||||
flex: "0 0 auto",
|
cursor: "pointer",
|
||||||
}}
|
flex: "0 0 auto",
|
||||||
title="Xóa wiki khỏi dự án"
|
}}
|
||||||
aria-label={`Xóa wiki ${w.id}`}
|
title="Xóa wiki khỏi dự án"
|
||||||
>
|
aria-label={`Xóa wiki ${w.id}`}
|
||||||
<TrashIcon />
|
>
|
||||||
</button>
|
<TrashIcon />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
|
|
||||||
@@ -702,7 +705,7 @@ function WikiSidebarPanel({ projectId, setWikis, onRemoveWiki }: Props) {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{collapsed ? null : (
|
{collapsed || readOnly ? null : (
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
marginTop: "10px",
|
marginTop: "10px",
|
||||||
@@ -842,15 +845,17 @@ function WikiSidebarPanel({ projectId, setWikis, onRemoveWiki }: Props) {
|
|||||||
<div className="text-sm font-mono break-all text-gray-700 dark:text-gray-200">{projectId}</div>
|
<div className="text-sm font-mono break-all text-gray-700 dark:text-gray-200">{projectId}</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
<Button
|
{!readOnly && (
|
||||||
size="sm"
|
<Button
|
||||||
variant="outline"
|
size="sm"
|
||||||
onClick={openImportPicker}
|
variant="outline"
|
||||||
disabled={!activeId}
|
onClick={openImportPicker}
|
||||||
title="Import HTML"
|
disabled={!activeId}
|
||||||
>
|
title="Import HTML"
|
||||||
Import
|
>
|
||||||
</Button>
|
Import
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
<Button
|
<Button
|
||||||
size="sm"
|
size="sm"
|
||||||
variant="outline"
|
variant="outline"
|
||||||
@@ -861,11 +866,13 @@ function WikiSidebarPanel({ projectId, setWikis, onRemoveWiki }: Props) {
|
|||||||
Export {wikiDocStorageFormat.toUpperCase()}
|
Export {wikiDocStorageFormat.toUpperCase()}
|
||||||
</Button>
|
</Button>
|
||||||
<Button size="sm" variant="outline" onClick={() => setOpen(false)}>
|
<Button size="sm" variant="outline" onClick={() => setOpen(false)}>
|
||||||
Cancel
|
{readOnly ? "Close" : "Cancel"}
|
||||||
</Button>
|
|
||||||
<Button size="sm" className="bg-brand-500 hover:bg-brand-600 text-white" onClick={saveWiki} disabled={!activeId}>
|
|
||||||
Save
|
|
||||||
</Button>
|
</Button>
|
||||||
|
{!readOnly && (
|
||||||
|
<Button size="sm" className="bg-brand-500 hover:bg-brand-600 text-white" onClick={saveWiki} disabled={!activeId}>
|
||||||
|
Save
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -880,7 +887,7 @@ function WikiSidebarPanel({ projectId, setWikis, onRemoveWiki }: Props) {
|
|||||||
onChange={(e) => setWikiTitle(e.target.value)}
|
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"
|
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"
|
placeholder="Wiki title"
|
||||||
disabled={!activeId}
|
disabled={readOnly || !activeId}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
@@ -890,7 +897,7 @@ function WikiSidebarPanel({ projectId, setWikis, onRemoveWiki }: Props) {
|
|||||||
onChange={(e) => setWikiSlug(e.target.value)}
|
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"
|
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"
|
placeholder="wiki-slug"
|
||||||
disabled={!activeId}
|
disabled={readOnly || !activeId}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
{wikiSaveError ? (
|
{wikiSaveError ? (
|
||||||
@@ -907,7 +914,7 @@ function WikiSidebarPanel({ projectId, setWikis, onRemoveWiki }: Props) {
|
|||||||
modules={quillModules}
|
modules={quillModules}
|
||||||
className="min-h-[320px] uhm-wiki-quill"
|
className="min-h-[320px] uhm-wiki-quill"
|
||||||
placeholder="Nhap noi dung wiki..."
|
placeholder="Nhap noi dung wiki..."
|
||||||
readOnly={!activeId}
|
readOnly={readOnly || !activeId}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user