diff --git a/src/app/editor/[id]/page.tsx b/src/app/editor/[id]/page.tsx index 9164233..39f7381 100644 --- a/src/app/editor/[id]/page.tsx +++ b/src/app/editor/[id]/page.tsx @@ -1,6 +1,6 @@ "use client"; -import { useCallback, useEffect, useMemo, useRef, useState, type PointerEvent as ReactPointerEvent } from "react"; +import { useCallback, useEffect, useMemo, useRef, useState, type SetStateAction, type PointerEvent as ReactPointerEvent } from "react"; import { useParams, useRouter, useSearchParams } from "next/navigation"; import Map from "@/uhm/components/Map"; import Editor from "@/uhm/components/Editor"; @@ -10,6 +10,7 @@ import SelectedGeometryPanel from "@/uhm/components/SelectedGeometryPanel"; import WikiSidebarPanel from "@/uhm/components/WikiSidebarPanel"; import ProjectEntityRefsPanel from "@/uhm/components/ProjectEntityRefsPanel"; import EntityWikiBindingsPanel from "@/uhm/components/EntityWikiBindingsPanel"; +import GeometryBindingPanel from "@/uhm/components/GeometryBindingPanel"; import { Entity, fetchEntities, searchEntitiesByName } from "@/uhm/api/entities"; import { ApiError } from "@/uhm/api/http"; import { fetchCurrentUser } from "@/uhm/api/auth"; @@ -62,6 +63,7 @@ import { FIXED_TIMELINE_RANGE, clampYearToFixedRange } from "@/uhm/lib/timeline" import { useFeatureCommands } from "./featureCommands"; import { deleteSubmission } from "@/uhm/api/sections"; import type { WikiSnapshot } from "@/uhm/types/wiki"; +import type { EntityWikiLinkSnapshot } from "@/uhm/types/sections"; import UnifiedSearchBar, { type UnifiedSearchKind } from "@/uhm/components/UnifiedSearchBar"; const CURRENT_YEAR = new Date().getUTCFullYear(); @@ -87,7 +89,10 @@ export default function Page() { const [leftPanelWidth, setLeftPanelWidth] = useState(280); const [rightPanelWidth, setRightPanelWidth] = useState(420); const [timelineFilterEnabled, setTimelineFilterEnabled] = useState(true); + const [geometryBindingFilterEnabled, setGeometryBindingFilterEnabled] = useState(true); const entityFormStatusTimeoutRef = useRef(null); + const geoBindingStatusTimeoutRef = useRef(null); + const [geoBindingStatus, setGeoBindingStatus] = useState(null); const lastSelectedFeatureIdRef = useRef(null); const { @@ -162,7 +167,39 @@ export default function Page() { const wikiSearchRequestRef = useRef(0); const geoSearchRequestRef = useRef(0); - const editor = useEditorState(initialData); + const snapshotEntitiesRef = useRef(snapshotEntities); + const snapshotWikisRef = useRef(snapshotWikis); + const snapshotEntityWikiLinksRef = useRef(snapshotEntityWikiLinks); + useEffect(() => { + snapshotEntitiesRef.current = snapshotEntities; + }, [snapshotEntities]); + useEffect(() => { + snapshotWikisRef.current = snapshotWikis; + }, [snapshotWikis]); + useEffect(() => { + snapshotEntityWikiLinksRef.current = snapshotEntityWikiLinks; + }, [snapshotEntityWikiLinks]); + + const editor = useEditorState(initialData, { + snapshotEntitiesRef, + setSnapshotEntities, + snapshotWikisRef, + setSnapshotWikis, + snapshotEntityWikiLinksRef, + setSnapshotEntityWikiLinks, + }); + const setSnapshotWikisUndoable = useCallback( + (next: SetStateAction) => { + editor.setSnapshotWikis(next, "Cập nhật wiki"); + }, + [editor] + ); + const setSnapshotEntityWikiLinksUndoable = useCallback( + (next: SetStateAction) => { + editor.setSnapshotEntityWikiLinks(next, "Cập nhật entity-wiki"); + }, + [editor] + ); const editorUserId = normalizeEditorUserId(editorUserIdInput); const snapshotEntitiesAsEntities = useMemo(() => { const rows = snapshotEntities || []; @@ -229,6 +266,24 @@ export default function Page() { String(feature.properties.id) === String(selectedFeatureId) ) || null; + const geometryChoices = useMemo(() => { + const rows = (editor.draft.features || []) + .filter((f) => f && f.properties && (typeof f.properties.id === "string" || typeof f.properties.id === "number")) + .map((f) => { + const id = String(f.properties.id); + const semantic = String(f.properties.type || getDefaultTypeIdForFeature(f) || "").trim(); + const label = semantic.length ? `${semantic} (${f.geometry.type})` : f.geometry.type; + return { id, label }; + }); + rows.sort((a, b) => a.id.localeCompare(b.id)); + return rows; + }, [editor.draft.features]); + + const selectedGeometryBindingIds = useMemo(() => { + if (!selectedFeature) return []; + return normalizeFeatureBindingIds(selectedFeature); + }, [selectedFeature]); + const createdEntities = useMemo(() => { return (snapshotEntities || []) .filter((e) => e && e.source === "inline" && e.operation === "create") @@ -691,6 +746,20 @@ export default function Page() { } }, [setEntityFormStatus]); + const flashGeoBindingStatus = useCallback((msg: string | null, timeoutMs = 3000) => { + if (geoBindingStatusTimeoutRef.current) { + window.clearTimeout(geoBindingStatusTimeoutRef.current); + geoBindingStatusTimeoutRef.current = null; + } + setGeoBindingStatus(msg); + if (msg && timeoutMs > 0) { + geoBindingStatusTimeoutRef.current = window.setTimeout(() => { + setGeoBindingStatus(null); + geoBindingStatusTimeoutRef.current = null; + }, timeoutMs); + } + }, [setGeoBindingStatus]); + useEffect(() => { setBackgroundVisibility(loadBackgroundLayerVisibilityFromStorage()); setIsBackgroundVisibilityReady(true); @@ -740,7 +809,7 @@ export default function Page() { const handleAddEntityRefToProject = useCallback((entity: Entity) => { const id = String(entity.id || "").trim(); if (!id) return; - setSnapshotEntities((prev) => { + editor.setSnapshotEntities((prev) => { if (prev.some((e) => String(e.id) === id)) return prev; return [ { @@ -752,7 +821,7 @@ export default function Page() { }, ...prev, ]; - }); + }, `Thêm entity ref #${id}`); // Keep entity catalog centralized as a single in-memory list. setEntityCatalog((prev) => { const byId = new globalThis.Map(); @@ -763,7 +832,38 @@ export default function Page() { byId.set(id, entity); return Array.from(byId.values()); }); - }, [setEntityCatalog, setSnapshotEntities]); + }, [editor, setEntityCatalog]); + + const handleUpdateEntityInProject = useCallback((entityId: string, payload: { name: string; description: string | null }) => { + const id = String(entityId || "").trim(); + if (!id) return; + const nextName = String(payload?.name || "").trim(); + if (!nextName.length) { + flashEntityFormStatus("Ten entity la bat buoc."); + return; + } + const nextDescription = payload?.description == null ? null : String(payload.description); + + editor.setSnapshotEntities((prev) => prev.map((e) => { + if (!e || String(e.id) !== id) return e; + const source = e.source === "inline" ? "inline" : "ref"; + const operation = + source === "ref" + ? "reference" + : e.operation === "create" + ? "create" + : "update"; + return { + ...e, + id, + source, + operation, + name: nextName, + description: nextDescription, + }; + }), `Cap nhat entity #${id}`); + flashEntityFormStatus("Da cap nhat entity. Commit khi san sang.", 3000); + }, [editor, flashEntityFormStatus]); const handleToggleBindEntityForSelectedGeometry = useCallback((entityId: string, nextChecked: boolean) => { if (!selectedFeature) { @@ -810,11 +910,53 @@ export default function Page() { setSelectedGeometryEntityIds, ]); + const handleToggleBindGeometryForSelectedGeometry = useCallback((geoId: string, nextChecked: boolean) => { + if (!selectedFeature) { + flashGeoBindingStatus("Chưa chọn geometry để bind."); + return; + } + const id = String(geoId || "").trim(); + if (!id) return; + if (String(selectedFeature.properties.id) === id) return; + + const prevBindingIds = normalizeFeatureBindingIds(selectedFeature); + const has = prevBindingIds.includes(id); + const nextBindingIds = (() => { + if (nextChecked) { + if (has) return prevBindingIds; + return [...prevBindingIds, id]; + } + if (!has) return prevBindingIds; + return prevBindingIds.filter((x) => x !== id); + })(); + + setIsEntitySubmitting(true); + flashGeoBindingStatus(null, 0); + try { + editor.patchFeatureProperties(selectedFeature.properties.id, { binding: nextBindingIds }); + setGeometryMetaForm((prev) => ({ ...prev, binding: nextBindingIds.join(", ") })); + flashGeoBindingStatus( + nextChecked + ? "Đã bind geometry vào binding. Commit khi sẵn sàng." + : "Đã gỡ binding geometry. Commit khi sẵn sàng.", + 3000 + ); + } finally { + setIsEntitySubmitting(false); + } + }, [ + editor, + flashGeoBindingStatus, + selectedFeature, + setGeometryMetaForm, + setIsEntitySubmitting, + ]); + const handleAddWikiRefToProject = useCallback((wiki: Wiki) => { const id = String(wiki.id || "").trim(); if (!id) return; const title = (wiki.title || "").trim() || "Untitled wiki"; - setSnapshotWikis((prev) => { + editor.setSnapshotWikis((prev) => { if (prev.some((w) => w.id === id)) return prev; return [ { @@ -827,9 +969,9 @@ export default function Page() { }, ...prev, ]; - }); + }, `Thêm wiki ref #${id}`); setRequestedActiveWikiId(id); - }, [setSnapshotWikis]); + }, [editor, setRequestedActiveWikiId]); const handleImportGeoFromSearch = useCallback(( entityItem: EntityGeometriesSearchItem, @@ -932,7 +1074,7 @@ export default function Page() { setIsEntitySubmitting(true); setEntityFormStatus(null); try { - setSnapshotEntities((prev) => { + editor.setSnapshotEntities((prev) => { if (prev.some((e) => String(e.id) === entityId)) return prev; return [ { @@ -946,7 +1088,7 @@ export default function Page() { }, ...prev, ]; - }); + }, `Tạo entity #${entityId}`); setEntityCatalog((prev) => { const byId = new globalThis.Map(); for (const row of prev || []) { @@ -1069,6 +1211,7 @@ export default function Page() { onDeleteFeature={editor.deleteFeature} onUpdateFeature={editor.updateFeature} backgroundVisibility={backgroundVisibility} + respectBindingFilter={geometryBindingFilterEnabled} /> ) : (
@@ -1333,30 +1476,42 @@ export default function Page() {
) : null} - - + + + + {!wikiOnly && selectedFeature ? ( ([]); const [isLoading, setIsLoading] = useState(true); const [isSubmitting, setIsSubmitting] = useState(false); + const [isExportingProjectId, setIsExportingProjectId] = useState(null); const [sortBy, setSortBy] = useState("updated_at"); const [sortOrder, setSortOrder] = useState<"asc" | "desc">("desc"); const { isOpen, openModal, closeModal } = useModal(); const [formData, setFormData] = useState({ title: "", description: "", project_status: "PRIVATE" }); + const importJsonInputRef = useRef(null); + const [importSnapshot, setImportSnapshot] = useState(null); + const [importSnapshotName, setImportSnapshotName] = useState(null); const fetchProjects = async () => { try { @@ -58,11 +64,15 @@ export default function ProjectsPage() { } try { setIsSubmitting(true); - await apiCreateProject(formData); + const created = await apiCreateProject(formData); + const projectId = created?.data?.id; toast.success("Tạo dự án mới thành công!"); closeModal(); setFormData({ title: "", description: "", project_status: "PRIVATE" }); + setImportSnapshot(null); + setImportSnapshotName(null); fetchProjects(); + if (projectId) router.push(`/editor/${projectId}`); } catch (error) { console.error("Lỗi tạo dự án:", error); toast.error("Có lỗi xảy ra khi tạo dự án."); @@ -71,6 +81,103 @@ export default function ProjectsPage() { } }; + const handlePickImportJson = () => { + importJsonInputRef.current?.click(); + }; + + const handleImportJsonFile = async (file: File | null) => { + if (!file) return; + try { + const text = await file.text(); + const raw = JSON.parse(text) as unknown; + const normalized = normalizeEditorSnapshot(raw); + if (!normalized) { + toast.error("JSON snapshot không hợp lệ."); + return; + } + setImportSnapshot(normalized); + setImportSnapshotName(file.name); + toast.success("Đã nạp JSON snapshot. Bấm 'Tạo với JSON' để khởi tạo dự án."); + } catch (err) { + console.error("Import JSON failed", err); + toast.error("Không đọc được file JSON."); + } + }; + + const handleCreateProjectWithJson = async () => { + if (!formData.title.trim()) { + toast.warning("Vui lòng nhập tên dự án!"); + return; + } + if (!importSnapshot) { + toast.warning("Chưa chọn JSON snapshot."); + handlePickImportJson(); + return; + } + try { + setIsSubmitting(true); + const created = await apiCreateProject(formData); + const projectId = created?.data?.id; + if (!projectId) { + toast.error("Tạo dự án thất bại: thiếu project id."); + return; + } + await apiCreateProjectCommit(projectId, { + edit_summary: "Init project from JSON", + snapshot_json: importSnapshot as any, + } as any); + toast.success("Tạo dự án (kèm JSON) thành công!"); + closeModal(); + setFormData({ title: "", description: "", project_status: "PRIVATE" }); + setImportSnapshot(null); + setImportSnapshotName(null); + fetchProjects(); + router.push(`/editor/${projectId}`); + } catch (error) { + console.error("Lỗi tạo dự án với JSON:", error); + toast.error("Có lỗi xảy ra khi tạo dự án với JSON."); + } finally { + setIsSubmitting(false); + } + }; + + const handleExportHeadSnapshot = async (project: Project) => { + const projectId = String(project.id || "").trim(); + if (!projectId) return; + const headCommitId = project.latest_commit_id ? String(project.latest_commit_id) : ""; + if (!headCommitId) { + toast.warning("Dự án chưa có head commit để export."); + return; + } + setIsExportingProjectId(projectId); + try { + const res: any = await apiGetProjectCommits(projectId); + const rawList = res?.data?.items ?? res?.data ?? res?.items ?? []; + const commits = Array.isArray(rawList) ? rawList : []; + const head = commits.find((c: any) => String(c?.id || "") === headCommitId) || null; + const snapshot = head?.snapshot_json ?? null; + if (!snapshot) { + toast.error("Không tìm thấy snapshot_json của head commit."); + return; + } + const blob = new Blob([JSON.stringify(snapshot, null, 2)], { type: "application/json;charset=utf-8" }); + const url = URL.createObjectURL(blob); + const a = document.createElement("a"); + a.href = url; + a.download = `project-${projectId}-head-${headCommitId}.json`; + document.body.appendChild(a); + a.click(); + a.remove(); + URL.revokeObjectURL(url); + toast.success("Đã export JSON snapshot."); + } catch (err) { + console.error("Export snapshot failed", err); + toast.error("Export thất bại."); + } finally { + setIsExportingProjectId(null); + } + }; + const handleSort = (column: ProjectSortColumn) => { if (sortBy === column) { setSortOrder(sortOrder === "asc" ? "desc" : "asc"); @@ -132,6 +239,11 @@ export default function ProjectsPage() { console.log(projects); + const importLabel = useMemo(() => { + if (!importSnapshotName) return "Chưa chọn JSON snapshot"; + return `JSON: ${importSnapshotName}`; + }, [importSnapshotName]); + return (
@@ -225,6 +337,15 @@ export default function ProjectsPage() { > Editor +
+
+ +
+ +
+ {importLabel} +
+
+ handleImportJsonFile(e.target.files?.[0] || null)} + /> +
+
diff --git a/src/uhm/api/wikis.ts b/src/uhm/api/wikis.ts index 6e436c7..2cbf31a 100644 --- a/src/uhm/api/wikis.ts +++ b/src/uhm/api/wikis.ts @@ -28,3 +28,23 @@ export async function fetchWikiById(id: string): Promise { return requestJson(`${API_ENDPOINTS.wikis}/${encodeURIComponent(wikiId)}`); } +export async function checkWikiSlugExists(slug: string): Promise { + const value = String(slug || "").trim(); + if (!value.length) return false; + + const params = new URLSearchParams({ slug: value }); + const url = `${API_ENDPOINTS.wikis}/slug/exists?${params.toString()}`; + const payload = await requestJson(url); + + if (typeof payload === "boolean") return payload; + if (payload && typeof payload === "object") { + const anyPayload = payload as any; + if (typeof anyPayload.exists === "boolean") return anyPayload.exists; + if (typeof anyPayload.exists === "number") return anyPayload.exists !== 0; + if (typeof anyPayload.is_exists === "boolean") return anyPayload.is_exists; + if (typeof anyPayload.is_exists === "number") return anyPayload.is_exists !== 0; + } + + // Be conservative: unknown payload shape, treat as "exists" to prevent creating conflicting slugs. + return true; +} diff --git a/src/uhm/components/Editor.tsx b/src/uhm/components/Editor.tsx index 2aa246a..816ba03 100644 --- a/src/uhm/components/Editor.tsx +++ b/src/uhm/components/Editor.tsx @@ -514,6 +514,10 @@ function formatUndoLabel(action: UndoAction) { return `Chỉnh sửa #${action.id}`; case "properties": return `Cập nhật thuộc tính #${action.id}`; + case "snapshot_entities": + case "snapshot_wikis": + case "snapshot_entity_wiki": + return action.label; default: return "Tác vụ"; } diff --git a/src/uhm/components/EntityWikiBindingsPanel.tsx b/src/uhm/components/EntityWikiBindingsPanel.tsx index b0833cf..8bcfc8a 100644 --- a/src/uhm/components/EntityWikiBindingsPanel.tsx +++ b/src/uhm/components/EntityWikiBindingsPanel.tsx @@ -1,6 +1,6 @@ "use client"; -import { useMemo, useState } from "react"; +import { useEffect, useMemo, useState } from "react"; import type { Entity } from "@/uhm/types/entities"; import type { WikiSnapshot } from "@/uhm/types/wiki"; import type { EntityWikiLinkSnapshot } from "@/uhm/types/sections"; @@ -23,6 +23,7 @@ function wikiTitle(w: WikiSnapshot): string { export default function EntityWikiBindingsPanel({ entities, wikis, links, setLinks }: Props) { const [activeEntityId, setActiveEntityId] = useState(""); const [activeWikiId, setActiveWikiId] = useState(""); + const [collapsed, setCollapsed] = useState(false); const wikiChoices: WikiChoice[] = useMemo( () => @@ -38,6 +39,17 @@ export default function EntityWikiBindingsPanel({ entities, wikis, links, setLin return cleaned; }, [entities]); + // Don't auto-select entity. The user must explicitly pick one. + // Only clear the selection if the currently selected entity is no longer available. + useEffect(() => { + if (!activeEntityId) return; + const stillExists = entityChoices.some((e) => e.id === activeEntityId); + if (!stillExists) { + setActiveEntityId(""); + setActiveWikiId(""); + } + }, [activeEntityId, entityChoices]); + const activeLinks = useMemo(() => { const set = new Set(); for (const l of links || []) { @@ -54,23 +66,16 @@ export default function EntityWikiBindingsPanel({ entities, wikis, links, setLin if (!id) return; setLinks((prev) => { - const next = [...prev]; - const idx = next.findIndex((l) => l.entity_id === activeEntityId && l.wiki_id === id); - if (idx >= 0) { - const existing = next[idx]; - const currentlyOn = existing.operation !== "delete"; - next[idx] = { - ...existing, - operation: currentlyOn ? "delete" : "binding", - }; - return next; + const idx = prev.findIndex((l) => l.entity_id === activeEntityId && l.wiki_id === id); + // If link exists (reference/binding), unlink by removing the row entirely. + if (idx >= 0 && prev[idx]?.operation !== "delete") { + return prev.filter((_, i) => i !== idx); } - next.push({ - entity_id: activeEntityId, - wiki_id: id, - operation: "binding", - }); - return next; + // If link doesn't exist, add as a new binding (create for relation). + return [ + ...prev.filter((l) => !(l.entity_id === activeEntityId && l.wiki_id === id)), + { entity_id: activeEntityId, wiki_id: id, operation: "binding" }, + ]; }); }; @@ -88,10 +93,34 @@ export default function EntityWikiBindingsPanel({ entities, wikis, links, setLin >
Entity ↔ Wiki
-
{links.length}
+
+
{links.length}
+ +
-
+ {collapsed ? null : ( +
Entity
onBindingFilterEnabledChange(e.target.checked)} + style={{ width: 14, height: 14 }} + /> + Filter + +
+
+
{rows.length}
+ +
+
+ + {collapsed ? null : rows.length ? ( +
+ {visibleRows + .filter((g) => g.id !== selectedGeometryId) + .map((g) => { + const isBound = bindingSet.has(g.id); + return ( +
+
+
+ {g.label || g.id} +
+
+ {g.id} +
+
+ + {canBindToggle ? ( + + ) : null} +
+ ); + })} + {rows.length > visibleRows.length ? ( +
+ +{rows.length - visibleRows.length} more… +
+ ) : null} +
+ ) : ( +
+ No geometry yet for this project. +
+ )} + + {collapsed ? null : statusText ? ( +
+ {statusText} +
+ ) : null} +
+ ); +} + +function LockIcon() { + return ( + + ); +} + +function UnlockIcon() { + return ( + + ); +} + +function PlusIcon() { + return ( + + ); +} + +function MinusIcon() { + return ( + + ); +} diff --git a/src/uhm/components/Map.tsx b/src/uhm/components/Map.tsx index 0f993ff..8c7b28e 100644 --- a/src/uhm/components/Map.tsx +++ b/src/uhm/components/Map.tsx @@ -1366,21 +1366,33 @@ function filterDraftByBinding( selectedFeatureId: string | number | null ): FeatureCollection { const selectedId = selectedFeatureId !== null ? String(selectedFeatureId) : null; - if (selectedId === null) { - return { - ...fc, - features: fc.features.filter((feature) => !normalizeBindingIds(feature.properties.binding).length), - }; + // Semantics: + // - A feature's `binding` is a list of "child" geometry ids. + // - Child geometries are hidden by default, and only shown when their parent is selected. + const childIds = new Set(); + for (const feature of fc.features) { + for (const id of normalizeBindingIds(feature.properties.binding)) { + childIds.add(id); + } } + if (selectedId === null) { + return { ...fc, features: fc.features.filter((f) => !childIds.has(String(f.properties.id))) }; + } + + const selectedFeature = + fc.features.find((feature) => String(feature.properties.id) === selectedId) || null; + const selectedChildren = new Set( + normalizeBindingIds(selectedFeature?.properties.binding) + ); + return { ...fc, features: fc.features.filter((feature) => { const featureId = String(feature.properties.id); if (featureId === selectedId) return true; - const bindingIds = normalizeBindingIds(feature.properties.binding); - if (!bindingIds.length) return true; - return bindingIds.includes(selectedId); + if (selectedChildren.has(featureId)) return true; + return !childIds.has(featureId); }), }; } diff --git a/src/uhm/components/ProjectEntityRefsPanel.tsx b/src/uhm/components/ProjectEntityRefsPanel.tsx index ea0f898..ae8e2c1 100644 --- a/src/uhm/components/ProjectEntityRefsPanel.tsx +++ b/src/uhm/components/ProjectEntityRefsPanel.tsx @@ -1,6 +1,6 @@ "use client"; -import { useState, type CSSProperties } from "react"; +import { useEffect, useMemo, useState, type CSSProperties } from "react"; import type { EntitySnapshot } from "@/uhm/types/entities"; import type { EntityFormState } from "@/uhm/lib/editor/session/sessionTypes"; @@ -10,6 +10,7 @@ type Props = { onEntityFormChange: (key: keyof EntityFormState, value: string) => void; isEntitySubmitting: boolean; onCreateEntityOnly: () => void; + onUpdateEntity?: (entityId: string, payload: { name: string; description: string | null }) => void; entityFormStatus: string | null; selectedGeometryEntityIds?: string[]; hasSelectedGeometry?: boolean; @@ -22,6 +23,7 @@ export default function ProjectEntityRefsPanel({ onEntityFormChange, isEntitySubmitting, onCreateEntityOnly, + onUpdateEntity, entityFormStatus, selectedGeometryEntityIds, hasSelectedGeometry, @@ -32,7 +34,30 @@ export default function ProjectEntityRefsPanel({ Array.isArray(selectedGeometryEntityIds) && typeof onToggleBindEntityForSelectedGeometry === "function"; + const canEditEntity = typeof onUpdateEntity === "function"; const [isCreateOpen, setIsCreateOpen] = useState(false); + const [collapsed, setCollapsed] = useState(false); + const [activeEntityId, setActiveEntityId] = useState(null); + + const activeEntity = useMemo( + () => (activeEntityId ? entityRefs.find((e) => String(e.id) === String(activeEntityId)) || null : null), + [activeEntityId, entityRefs] + ); + const [editName, setEditName] = useState(""); + const [editDescription, setEditDescription] = useState(""); + + useEffect(() => { + if (!activeEntityId) return; + if (!entityRefs.some((e) => String(e.id) === String(activeEntityId))) { + setActiveEntityId(null); + } + }, [activeEntityId, entityRefs]); + + useEffect(() => { + if (!activeEntity) return; + setEditName(typeof activeEntity.name === "string" ? activeEntity.name : ""); + setEditDescription(activeEntity.description == null ? "" : String(activeEntity.description)); + }, [activeEntity?.description, activeEntity?.id, activeEntity?.name]); return (
Entities
-
{entityRefs.length}
+
+
{entityRefs.length}
+ +
- {entityRefs.length ? ( + {collapsed ? null : entityRefs.length ? (
{entityRefs.slice(0, 8).map((e) => (
-
+
+ {canBindToggle ? (
)} + {collapsed ? null : canEditEntity && activeEntity ? ( +
+
+
+ Sua entity +
+ +
+ +
+ {String(activeEntity.id)} +
+ setEditName(event.target.value)} + placeholder="Ten entity" + disabled={isEntitySubmitting} + style={entityInputStyle} + /> + setEditDescription(event.target.value)} + placeholder="Description" + disabled={isEntitySubmitting} + style={entityInputStyle} + /> + + +
+ ) : null} + + {collapsed ? null : ( + <>
) : null} + + )}
); } @@ -263,6 +406,14 @@ function PlusIcon() { ); } +function MinusIcon() { + return ( + + ); +} + function CloseIcon() { return (
- Entity & Geometry +
+
+ Entity & Geometry +
+
+ {collapsed ? null : (
ID: {String(selectedFeature.properties.id)} @@ -231,6 +256,13 @@ export default function SelectedGeometryPanel({ disabled={isEntitySubmitting} style={entityInputStyle} /> + {/* onGeometryMetaFormChange("binding", event.target.value)}*/} + {/* placeholder="binding (geometry ids, comma separated)"*/} + {/* disabled={isEntitySubmitting}*/} + {/* style={entityInputStyle}*/} + {/*/>*/}
+ )}
); } @@ -292,6 +325,22 @@ const primaryGeometryButtonStyle: CSSProperties = { fontWeight: 600, }; +function PlusIcon() { + return ( + + ); +} + +function MinusIcon() { + return ( + + ); +} + function resolveFeatureGeometryPreset(feature: Feature): EntityGeometryPreset { const explicitPreset = normalizeGeometryPreset(feature.properties.geometry_preset); if (explicitPreset) return explicitPreset; diff --git a/src/uhm/components/WikiSidebarPanel.tsx b/src/uhm/components/WikiSidebarPanel.tsx index 9a50e6b..fb83a94 100644 --- a/src/uhm/components/WikiSidebarPanel.tsx +++ b/src/uhm/components/WikiSidebarPanel.tsx @@ -11,6 +11,7 @@ import Label from "@/components/form/Label"; import type { WikiSnapshot } from "@/uhm/types/wiki"; import { newId } from "@/uhm/lib/id"; import type ReactQuill from "react-quill-new"; +import { checkWikiSlugExists } from "@/uhm/api/wikis"; type ReactQuillProps = ComponentProps; @@ -35,12 +36,19 @@ function clampTitle(title: string) { export default function WikiSidebarPanel({ projectId, wikis, setWikis, autoOpen, requestedActiveId }: Props) { const [open, setOpen] = useState(false); const [activeId, setActiveId] = useState(null); + const [collapsed, setCollapsed] = useState(false); const activeWiki = useMemo(() => wikis.find((w) => w.id === activeId) || null, [activeId, wikis]); const [wikiTitle, setWikiTitle] = useState(""); + const [wikiSlug, setWikiSlug] = useState(""); const [wikiDocHtml, setWikiDocHtml] = useState(""); + const [wikiSaveError, setWikiSaveError] = useState(null); const [isCreateOpen, setIsCreateOpen] = useState(false); const [createTitle, setCreateTitle] = useState(""); + const [createSlug, setCreateSlug] = useState(""); + const [createSlugTouched, setCreateSlugTouched] = useState(false); + const [createError, setCreateError] = useState(null); + const [isCheckingCreateSlug, setIsCheckingCreateSlug] = useState(false); useEffect(() => { if (!autoOpen) return; @@ -60,8 +68,10 @@ export default function WikiSidebarPanel({ projectId, wikis, setWikis, autoOpen, if (!open) return; setWikiTitle(activeWiki?.title || ""); + setWikiSlug(typeof activeWiki?.slug === "string" ? activeWiki.slug : ""); setWikiDocHtml(normalizeWikiDocForQuill(activeWiki?.doc || null)); - }, [activeWiki?.doc, activeWiki?.title, open]); + setWikiSaveError(null); + }, [activeWiki?.doc, activeWiki?.slug, activeWiki?.title, open]); const ensureActive = () => { if (activeId && wikis.some((w) => w.id === activeId)) return; @@ -81,6 +91,7 @@ export default function WikiSidebarPanel({ projectId, wikis, setWikis, autoOpen, source: "inline", operation: "create", title: "Untitled wiki", + slug: null, doc: "", updated_at: new Date().toISOString(), }; @@ -90,7 +101,7 @@ export default function WikiSidebarPanel({ projectId, wikis, setWikis, autoOpen, setOpen(true); }; - const createWikiAndOpen = (title?: string) => { + const createWikiAndOpen = (title?: string, slug?: string | null) => { const id = newId(); const seedTitle = clampTitle(title || "Untitled wiki"); const seed: WikiSnapshot = { @@ -98,6 +109,7 @@ export default function WikiSidebarPanel({ projectId, wikis, setWikis, autoOpen, source: "inline", operation: "create", title: seedTitle, + slug: slug ?? null, doc: "", updated_at: new Date().toISOString(), }; @@ -106,15 +118,63 @@ export default function WikiSidebarPanel({ projectId, wikis, setWikis, autoOpen, setOpen(true); }; + const handleCreateWikiFromPanel = async () => { + const title = clampTitle(createTitle); + const slug = normalizeWikiSlugInput(createSlug); + if (!slug) { + setCreateError("Slug la bat buoc. Hay thu mot slug khac."); + return; + } + + setIsCheckingCreateSlug(true); + setCreateError(null); + try { + const exists = await checkWikiSlugExists(slug); + if (exists) { + setCreateError("Slug da ton tai. Hay thu slug khac."); + return; + } + createWikiAndOpen(title, slug); + setCreateTitle(""); + setCreateSlug(""); + setCreateSlugTouched(false); + setIsCreateOpen(false); + } catch (err) { + const msg = err instanceof Error ? err.message : "Khong check duoc slug."; + setCreateError(msg); + } finally { + setIsCheckingCreateSlug(false); + } + }; + const removeWiki = (id: string) => { setWikis((prev) => prev.filter((w) => w.id !== id)); if (activeId === id) setActiveId(null); }; - const saveWiki = () => { + const saveWiki = async () => { if (!activeId) return; const payload = wikiDocHtml; const nextTitle = clampTitle(wikiTitle); + const nextSlug = normalizeWikiSlugInput(wikiSlug); + + const current = wikis.find((w) => w.id === activeId) || null; + // Check uniqueness only when creating a brand-new wiki. + if (current?.operation === "create" && nextSlug) { + try { + const exists = await checkWikiSlugExists(nextSlug); + if (exists) { + setWikiSaveError("Slug da ton tai. Hay thu slug khac."); + return; + } + } catch (err) { + const msg = err instanceof Error ? err.message : "Khong check duoc slug."; + setWikiSaveError(msg); + return; + } + } + + setWikiSaveError(null); setWikis((prev) => prev.map((w) => w.id !== activeId @@ -124,6 +184,7 @@ export default function WikiSidebarPanel({ projectId, wikis, setWikis, autoOpen, source: w.source, operation: w.operation === "create" ? "create" : "update", title: nextTitle, + slug: nextSlug, doc: payload, updated_at: new Date().toISOString(), } @@ -143,10 +204,33 @@ export default function WikiSidebarPanel({ projectId, wikis, setWikis, autoOpen, >
Wiki
-
{wikis.length}
+
+
{wikis.length}
+ +
- {wikis.length ? ( + {collapsed ? null : wikis.length ? (
{wikis.slice(0, 8).map((w) => (
)} + {collapsed ? null : (
+ {createError ? ( +
+ {createError} +
+ ) : null} ) : null}
+ )}
+
+ + 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} + /> +
+ {wikiSaveError ? ( +
+ {wikiSaveError} +
+ ) : null}