diff --git a/app/editor/featureCommands.ts b/app/editor/featureCommands.ts new file mode 100644 index 0000000..850ea19 --- /dev/null +++ b/app/editor/featureCommands.ts @@ -0,0 +1,114 @@ +"use client"; + +import { useCallback } from "react"; +import type { Dispatch, SetStateAction } from "react"; +import type { Entity } from "@/types/entities"; +import type { Feature, FeatureProperties } from "@/types/geo"; +import { ApiError } from "@/api/http"; +import { buildFeatureEntityPatch } from "@/lib/editor/entity/entityBinding"; +import { buildGeometryMetadataPatch } from "@/lib/editor/geometry/geometryMetadata"; +import { uniqueEntityIds } from "@/lib/editor/snapshot/editorSnapshot"; +import type { GeometryMetaFormState } from "@/lib/editor/session/sessionTypes"; + +type EditorDraftApi = { + patchFeatureProperties: (id: FeatureProperties["id"], patch: Partial) => void; +}; + +type Options = { + editor: EditorDraftApi; + selectedFeature: Feature | null; + geometryMetaForm: GeometryMetaFormState; + setGeometryMetaForm: Dispatch>; + selectedGeometryEntityIds: string[]; + setSelectedGeometryEntityIds: Dispatch>; + entities: Entity[]; + setIsEntitySubmitting: Dispatch>; + setEntityFormStatus: Dispatch>; +}; + +export function useFeatureCommands(options: Options) { + const { + editor, + selectedFeature, + geometryMetaForm, + setGeometryMetaForm, + selectedGeometryEntityIds, + setSelectedGeometryEntityIds, + entities, + setIsEntitySubmitting, + setEntityFormStatus, + } = options; + + const applyGeometryMetadata = useCallback(async () => { + if (!selectedFeature) { + setEntityFormStatus("Hãy chọn một geometry trước."); + return; + } + + let metadata; + try { + metadata = buildGeometryMetadataPatch(geometryMetaForm); + } catch (err) { + setEntityFormStatus(err instanceof Error ? err.message : "Thời gian không hợp lệ."); + return; + } + + setIsEntitySubmitting(true); + setEntityFormStatus(null); + try { + editor.patchFeatureProperties(selectedFeature.properties.id, metadata.patch); + setGeometryMetaForm(metadata.formState); + setEntityFormStatus("Đã cập nhật thuộc tính GEO. Commit khi sẵn sàng."); + } finally { + setIsEntitySubmitting(false); + } + }, [ + editor, + geometryMetaForm, + selectedFeature, + setEntityFormStatus, + setGeometryMetaForm, + setIsEntitySubmitting, + ]); + + const applyEntitiesToSelectedGeometry = useCallback(async () => { + if (!selectedFeature) { + setEntityFormStatus("Hãy chọn một geometry trước."); + return; + } + + const entityIds = uniqueEntityIds(selectedGeometryEntityIds); + setIsEntitySubmitting(true); + setEntityFormStatus(null); + try { + editor.patchFeatureProperties( + selectedFeature.properties.id, + buildFeatureEntityPatch(selectedFeature, entityIds, entities) + ); + setSelectedGeometryEntityIds(entityIds); + setEntityFormStatus("Đã cập nhật danh sách entity. Commit khi sẵn sàng."); + } catch (err) { + if (err instanceof ApiError) { + setEntityFormStatus(`Lưu thất bại: ${err.body}`); + } else { + setEntityFormStatus("Lưu thất bại."); + } + } finally { + setIsEntitySubmitting(false); + } + }, [ + editor, + entities, + selectedFeature, + selectedGeometryEntityIds, + setEntityFormStatus, + setIsEntitySubmitting, + setSelectedGeometryEntityIds, + ]); + + return { + applyGeometryMetadata, + applyEntitiesToSelectedGeometry, + }; +} + diff --git a/app/editor/page.tsx b/app/editor/page.tsx index a9d1f65..c0849fe 100644 --- a/app/editor/page.tsx +++ b/app/editor/page.tsx @@ -15,7 +15,6 @@ import { } from "@/api/sections"; import { Feature, - FeatureCollection, useEditorState, } from "@/lib/useEditorState"; import { @@ -33,7 +32,6 @@ import { import { EntityFormState, PendingEntityCreate, - TimelineRange, useEditorSessionState, } from "@/lib/useEditorSessionState"; import { @@ -44,13 +42,11 @@ import { } from "@/lib/editor/snapshot/editorSnapshot"; import { buildClientEntityId, - buildFeatureEntityPatch, formatEntityNamesForDisplay, mergeEntitiesWithPending, mergeEntitySearchResults, } from "@/lib/editor/entity/entityBinding"; import { - buildGeometryMetadataPatch, formatBindingIdsForDisplay, } from "@/lib/editor/geometry/geometryMetadata"; import { @@ -58,20 +54,11 @@ import { persistBackgroundLayerVisibility, } from "@/lib/editor/background/backgroundVisibilityStorage"; import { useSectionCommands } from "@/lib/editor/section/useSectionCommands"; +import { EMPTY_FEATURE_COLLECTION, WORLD_BBOX } from "@/lib/geo/constants"; +import { FIXED_TIMELINE_RANGE, clampYearToFixedRange, TIMELINE_DEBOUNCE_MS } from "@/lib/timeline"; +import { useFeatureCommands } from "./featureCommands"; -const EMPTY_FC: FeatureCollection = { type: "FeatureCollection", features: [] }; -const WORLD_BBOX = { - minLng: -180, - minLat: -90, - maxLng: 180, - maxLat: 90, -} as const; const CURRENT_YEAR = new Date().getUTCFullYear(); -const FALLBACK_TIMELINE_RANGE: TimelineRange = { - min: -2000, - max: 2000, -}; -const TIMELINE_DEBOUNCE_MS = 180; const DEFAULT_EDITOR_USER_ID = "local-editor"; export default function Page() { @@ -147,12 +134,14 @@ export default function Page() { isBackgroundVisibilityReady, setIsBackgroundVisibilityReady, } = useEditorSessionState({ - emptyFeatureCollection: EMPTY_FC, + emptyFeatureCollection: EMPTY_FEATURE_COLLECTION, defaultEditorUserId: DEFAULT_EDITOR_USER_ID, - fallbackTimelineRange: FALLBACK_TIMELINE_RANGE, + fallbackTimelineRange: FIXED_TIMELINE_RANGE, currentYear: CURRENT_YEAR, }); + // Counter để bỏ qua response cũ khi user đổi timeline/section liên tục. const timelineFetchRequestRef = useRef(0); + // Counter để bỏ qua response cũ khi user gõ search entity liên tục. const entitySearchRequestRef = useRef(0); const editor = useEditorState(initialData); @@ -196,7 +185,7 @@ export default function Page() { const sectionCommands = useSectionCommands({ editor, editorUserId, - emptyFeatureCollection: EMPTY_FC, + emptyFeatureCollection: EMPTY_FEATURE_COLLECTION, activeSection, sectionState, selectedSectionId, @@ -534,7 +523,7 @@ export default function Page() { }; const handleTimelineYearChange = (nextYear: number) => { - setTimelineDraftYear(clampYear(Math.trunc(nextYear), FALLBACK_TIMELINE_RANGE)); + setTimelineDraftYear(clampYearToFixedRange(Math.trunc(nextYear))); }; const handleEntityFormChange = (key: keyof EntityFormState, value: string) => { @@ -562,57 +551,17 @@ export default function Page() { setEntityFormStatus(null); }; - const handleApplyGeometryMetadata = async () => { - if (!selectedFeature) { - setEntityFormStatus("Hãy chọn một geometry trước."); - return; - } - - let metadata; - try { - metadata = buildGeometryMetadataPatch(geometryMetaForm); - } catch (err) { - setEntityFormStatus(err instanceof Error ? err.message : "Thời gian không hợp lệ."); - return; - } - - setIsEntitySubmitting(true); - setEntityFormStatus(null); - try { - editor.patchFeatureProperties(selectedFeature.properties.id, metadata.patch); - setGeometryMetaForm(metadata.formState); - setEntityFormStatus("Đã cập nhật thuộc tính GEO. Commit khi sẵn sàng."); - } finally { - setIsEntitySubmitting(false); - } - }; - - const handleApplyEntitiesForSelectedGeometry = async () => { - if (!selectedFeature) { - setEntityFormStatus("Hãy chọn một geometry trước."); - return; - } - - const entityIds = uniqueEntityIds(selectedGeometryEntityIds); - setIsEntitySubmitting(true); - setEntityFormStatus(null); - try { - editor.patchFeatureProperties( - selectedFeature.properties.id, - buildFeatureEntityPatch(selectedFeature, entityIds, entities) - ); - setSelectedGeometryEntityIds(entityIds); - setEntityFormStatus("Đã cập nhật danh sách entity. Commit khi sẵn sàng."); - } catch (err) { - if (err instanceof ApiError) { - setEntityFormStatus(`Lưu thất bại: ${err.body}`); - } else { - setEntityFormStatus("Lưu thất bại."); - } - } finally { - setIsEntitySubmitting(false); - } - }; + const featureCommands = useFeatureCommands({ + editor, + selectedFeature, + geometryMetaForm, + setGeometryMetaForm, + selectedGeometryEntityIds, + setSelectedGeometryEntityIds, + entities, + setIsEntitySubmitting, + setEntityFormStatus, + }); const handleCreateEntityOnly = async () => { const name = entityForm.name.trim(); @@ -797,8 +746,8 @@ export default function Page() { onGeometryMetaFormChange={handleGeometryMetaFormChange} isEntitySubmitting={isEntitySubmitting} onCreateEntityOnly={handleCreateEntityOnly} - onApplyGeometryMetadata={handleApplyGeometryMetadata} - onApplyEntitiesForSelectedGeometry={handleApplyEntitiesForSelectedGeometry} + onApplyGeometryMetadata={featureCommands.applyGeometryMetadata} + onApplyEntitiesForSelectedGeometry={featureCommands.applyEntitiesToSelectedGeometry} changeCount={editor.changeCount} entityFormStatus={entityFormStatus} /> @@ -813,18 +762,6 @@ function normalizeEditorUserId(value: string): string { return normalized || DEFAULT_EDITOR_USER_ID; } -function clampYear(year: number, range: TimelineRange): number { - return clampYearValue(year, range.min, range.max); -} - -function clampYearValue(year: number, minYear: number, maxYear: number): number { - const lower = Math.min(minYear, maxYear); - const upper = Math.max(minYear, maxYear); - if (year < lower) return lower; - if (year > upper) return upper; - return year; -} - function formatCommitTitle(commit: SectionCommit): string { return commit.title?.trim() || `Commit #${commit.commit_no}`; } diff --git a/app/globals.css b/app/globals.css index a2dc41e..0065625 100644 --- a/app/globals.css +++ b/app/globals.css @@ -22,5 +22,5 @@ body { background: var(--background); color: var(--foreground); - font-family: Arial, Helvetica, sans-serif; + font-family: var(--font-sans), system-ui, -apple-system, Segoe UI, Roboto, Helvetica, Arial, sans-serif; } diff --git a/app/layout.tsx b/app/layout.tsx index f7fa87e..dc5d315 100644 --- a/app/layout.tsx +++ b/app/layout.tsx @@ -13,8 +13,8 @@ const geistMono = Geist_Mono({ }); export const metadata: Metadata = { - title: "Create Next App", - description: "Generated by create next app", + title: "Ultimate History Map", + description: "Map editor and viewer for historical entities, geometries, and timelines.", }; export default function RootLayout({ @@ -23,7 +23,7 @@ export default function RootLayout({ children: React.ReactNode; }>) { return ( - + diff --git a/app/page.tsx b/app/page.tsx index 12481b5..291a2d4 100644 --- a/app/page.tsx +++ b/app/page.tsx @@ -19,41 +19,35 @@ import { loadBackgroundLayerVisibilityFromStorage, persistBackgroundLayerVisibility, } from "@/lib/editor/background/backgroundVisibilityStorage"; +import { EMPTY_FEATURE_COLLECTION, WORLD_BBOX } from "@/lib/geo/constants"; +import { clampYearToFixedRange, TIMELINE_DEBOUNCE_MS } from "@/lib/timeline"; -const EMPTY_FC: FeatureCollection = { type: "FeatureCollection", features: [] }; -const WORLD_BBOX = { - minLng: -180, - minLat: -90, - maxLng: 180, - maxLat: 90, -} as const; const CURRENT_YEAR = new Date().getUTCFullYear(); -const FALLBACK_TIMELINE_RANGE: TimelineRange = { - min: -2000, - max: 2000, -}; -const TIMELINE_DEBOUNCE_MS = 180; - -type TimelineRange = { - min: number; - max: number; -}; export default function Page() { - const [initialData, setInitialData] = useState(EMPTY_FC); + // Dữ liệu GeoJSON hiện đang hiển thị trên map (theo timelineYear). + const [initialData, setInitialData] = useState(EMPTY_FEATURE_COLLECTION); + // ID geometry đang được chọn trên map (null nếu chưa chọn). const [selectedFeatureId, setSelectedFeatureId] = useState(null); + // Năm timeline "đã chốt" để trigger fetch dữ liệu. const [timelineYear, setTimelineYear] = useState(() => - clampYearValue(CURRENT_YEAR, FALLBACK_TIMELINE_RANGE.min, FALLBACK_TIMELINE_RANGE.max) + clampYearToFixedRange(CURRENT_YEAR) ); + // Năm timeline đang kéo/nhập (debounce rồi mới đẩy sang timelineYear). const [timelineDraftYear, setTimelineDraftYear] = useState(() => - clampYearValue(CURRENT_YEAR, FALLBACK_TIMELINE_RANGE.min, FALLBACK_TIMELINE_RANGE.max) + clampYearToFixedRange(CURRENT_YEAR) ); + // Cờ loading khi fetch geometries theo timeline. const [isTimelineLoading, setIsTimelineLoading] = useState(false); + // Thông báo trạng thái/lỗi khi load timeline. const [timelineStatus, setTimelineStatus] = useState(null); + // Trạng thái bật/tắt các layer nền. const [backgroundVisibility, setBackgroundVisibility] = useState( () => ({ ...HIDDEN_BACKGROUND_LAYER_VISIBILITY }) ); + // Đảm bảo đã load backgroundVisibility từ storage trước khi render map. const [isBackgroundVisibilityReady, setIsBackgroundVisibilityReady] = useState(false); + // Counter để bỏ qua response cũ khi user đổi timeline liên tục. const timelineFetchRequestRef = useRef(0); const selectedFeature = @@ -154,7 +148,7 @@ export default function Page() { }; const handleTimelineYearChange = (nextYear: number) => { - setTimelineDraftYear(clampYear(Math.trunc(nextYear), FALLBACK_TIMELINE_RANGE)); + setTimelineDraftYear(clampYearToFixedRange(Math.trunc(nextYear))); }; const selectedType = resolveSelectedTypeSummary(selectedFeature); @@ -250,18 +244,6 @@ export default function Page() { ); } -function clampYear(year: number, range: TimelineRange): number { - return clampYearValue(year, range.min, range.max); -} - -function clampYearValue(year: number, minYear: number, maxYear: number): number { - const lower = Math.min(minYear, maxYear); - const upper = Math.max(minYear, maxYear); - if (year < lower) return lower; - if (year > upper) return upper; - return year; -} - function isYearNumber(value: number | null | undefined): value is number { return typeof value === "number" && Number.isFinite(value); } diff --git a/app/submited/page.tsx b/app/submited/page.tsx index 1088df4..69df5ce 100644 --- a/app/submited/page.tsx +++ b/app/submited/page.tsx @@ -1,1317 +1,5 @@ "use client"; -import { type CSSProperties, useEffect, useMemo, useState } from "react"; -import Map from "@/components/Map"; -import { ApiError } from "@/api/http"; -import { - approveSubmission, - fetchSections, - fetchSectionSubmissions, - rejectSubmission, - Section, - SectionSubmission, -} from "@/api/sections"; -import { HIDDEN_BACKGROUND_LAYER_VISIBILITY } from "@/lib/backgroundLayers"; -import { Feature, FeatureCollection, Geometry } from "@/lib/useEditorState"; +// Backward-compat route (typo). Keep this thin wrapper so canonical code lives in `/submitted`. +export { default } from "../submitted/page"; -type SubmissionStatusFilter = "pending" | "all"; -type ReviewAction = "approve" | "reject"; - -type SubmissionRow = { - section: Section; - submission: SectionSubmission; -}; - -type SnapshotSummary = { - featureCount: number; - entityCount: number; - geometryCount: number; - linkScopeCount: number; - entityOperations: Record; - geometryOperations: Record; - linkOperations: Record; -}; - -const DEFAULT_REVIEWER_ID = "admin"; -const EMPTY_FEATURE_COLLECTION: FeatureCollection = { - type: "FeatureCollection", - features: [], -}; -const REVIEW_BACKGROUND_LAYER_VISIBILITY = { - ...HIDDEN_BACKGROUND_LAYER_VISIBILITY, - "bg-countries-fill": true, - "bg-country-borders-line": true, -}; - -const STATUS_LABELS: Record = { - pending: "Chờ duyệt", - approved: "Đã duyệt", - rejected: "Từ chối", - conflicted: "Conflict", -}; - -export default function SubmittedPage() { - const [rows, setRows] = useState([]); - const [selectedSubmissionId, setSelectedSubmissionId] = useState(null); - const [statusFilter, setStatusFilter] = useState("pending"); - const [reviewerId, setReviewerId] = useState(DEFAULT_REVIEWER_ID); - const [reviewNote, setReviewNote] = useState(""); - const [isLoading, setIsLoading] = useState(true); - const [reviewingSubmissionId, setReviewingSubmissionId] = useState(null); - const [selectedPreviewFeatureId, setSelectedPreviewFeatureId] = useState(null); - const [isJsonPopupOpen, setIsJsonPopupOpen] = useState(false); - const [statusMessage, setStatusMessage] = useState(null); - const [errorMessage, setErrorMessage] = useState(null); - - const loadSubmissions = async () => { - setIsLoading(true); - setErrorMessage(null); - setStatusMessage(null); - - try { - const sections = await fetchSections(); - const submissionGroups = await Promise.all( - sections.map(async (section) => { - const submissions = await fetchSectionSubmissions(section.id, { - includeSnapshot: true, - }); - return submissions.map((submission) => ({ - section, - submission, - })); - }) - ); - const nextRows = submissionGroups - .flat() - .sort((a, b) => - Date.parse(b.submission.submitted_at) - Date.parse(a.submission.submitted_at) - ); - - setRows(nextRows); - setSelectedSubmissionId((current) => { - if (current && nextRows.some((row) => row.submission.id === current)) { - return current; - } - return nextRows.find((row) => row.submission.status === "pending")?.submission.id - || nextRows[0]?.submission.id - || null; - }); - } catch (err) { - setErrorMessage(getErrorMessage(err, "Không tải được danh sách section đã submit.")); - } finally { - setIsLoading(false); - } - }; - - useEffect(() => { - loadSubmissions(); - }, []); - - const visibleRows = useMemo(() => { - if (statusFilter === "all") return rows; - return rows.filter((row) => row.submission.status === "pending"); - }, [rows, statusFilter]); - - const selectedRow = useMemo(() => { - if (!selectedSubmissionId) return visibleRows[0] || null; - return ( - visibleRows.find((row) => row.submission.id === selectedSubmissionId) || - rows.find((row) => row.submission.id === selectedSubmissionId) || - visibleRows[0] || - null - ); - }, [rows, selectedSubmissionId, visibleRows]); - - const pendingCount = rows.filter((row) => row.submission.status === "pending").length; - const reviewedCount = rows.length - pendingCount; - const selectedSummary = selectedRow - ? summarizeSnapshot(selectedRow.submission.snapshot) - : null; - const selectedFeatureCollection = selectedRow - ? extractSnapshotFeatureCollection(selectedRow.submission.snapshot) - : EMPTY_FEATURE_COLLECTION; - const selectedPreviewFeature = selectedFeatureCollection.features.find((feature) => - String(feature.properties.id) === String(selectedPreviewFeatureId) - ) || null; - - useEffect(() => { - setSelectedPreviewFeatureId(null); - setIsJsonPopupOpen(false); - }, [selectedRow?.submission.id]); - - const handleReview = async (action: ReviewAction) => { - if (!selectedRow) return; - const actor = reviewerId.trim(); - if (!actor) { - setErrorMessage("Reviewer là bắt buộc."); - return; - } - if (selectedRow.submission.status !== "pending") { - setErrorMessage("Submission này không còn ở trạng thái chờ duyệt."); - return; - } - - const label = action === "approve" ? "duyệt" : "từ chối"; - const confirmed = window.confirm(`Xác nhận ${label} section "${selectedRow.section.title}"?`); - if (!confirmed) return; - - setReviewingSubmissionId(selectedRow.submission.id); - setErrorMessage(null); - setStatusMessage(null); - - try { - const input = { - reviewed_by: actor, - review_note: reviewNote.trim() || null, - }; - const updatedSubmission = action === "approve" - ? await approveSubmission(selectedRow.submission.id, input) - : await rejectSubmission(selectedRow.submission.id, input); - - setRows((currentRows) => - currentRows.map((row) => - row.submission.id === updatedSubmission.id - ? { - ...row, - section: { - ...row.section, - state: { - ...row.section.state, - status: updatedSubmission.status === "approved" - ? "approved" - : updatedSubmission.status === "rejected" - ? "rejected" - : row.section.state.status, - }, - }, - submission: updatedSubmission, - } - : row - ) - ); - setReviewNote(""); - setStatusMessage( - action === "approve" - ? "Đã duyệt submission và apply vào published data." - : "Đã từ chối submission." - ); - } catch (err) { - setErrorMessage(getErrorMessage(err, `Không thể ${label} submission.`)); - } finally { - setReviewingSubmissionId(null); - } - }; - - const isReviewingSelected = reviewingSubmissionId === selectedRow?.submission.id; - const canReviewSelected = Boolean( - selectedRow && - selectedRow.submission.status === "pending" && - !isReviewingSelected - ); - - return ( -
-
-
-

Review sections

-

Submitted sections

-

- Xem các section đã submit, kiểm tra snapshot và duyệt dữ liệu vào published map. -

-
-
- Mở editor - -
-
- -
- - - -
- - {statusMessage ?
{statusMessage}
: null} - {errorMessage ?
{errorMessage}
: null} - -
- - - -
- -
-
-
-

Sections

- {visibleRows.length} item -
- - {isLoading ? ( -
Đang tải submissions...
- ) : visibleRows.length === 0 ? ( -
- Không có submission nào trong bộ lọc hiện tại. -
- ) : ( -
- {visibleRows.map((row) => { - const selected = selectedRow?.submission.id === row.submission.id; - return ( - - ); - })} -
- )} -
- -
- {!selectedRow ? ( -
Chọn một section để xem chi tiết.
- ) : ( - <> -
-
-

{selectedRow.section.title}

-

- Section status: {selectedRow.section.state.status} · Version {selectedRow.section.state.version} -

-
- -
- -
- - - - - - -
- -
- -
- - {selectedRow.submission.review_note ? ( -
- Review note -

{selectedRow.submission.review_note}

-
- ) : null} - - {selectedSummary ? ( - <> -

Map preview

- {selectedFeatureCollection.features.length > 0 ? ( -
-
- -
-
- Selected geometry - {selectedPreviewFeature ? ( -
- - - - - - -
- ) : ( -

- Chọn một geometry trên preview để xem metadata. -

- )} -
-
- ) : ( -
- Submission này không có geometry renderable trong phần cần duyệt. - Nếu đây là thay đổi delete-only, hãy xem phần operation summary bên dưới. -
- )} - -

Snapshot

-
- - - - -
-
- - - -
- - ) : ( -
Submission này không có snapshot để hiển thị.
- )} - -
- - -
- - setIsJsonPopupOpen(false)} - /> - - )} -
-
-
- ); -} - -function Metric({ label, value }: { label: string; value: number }) { - return ( -
- {value} - {label} -
- ); -} - -function Info({ label, value }: { label: string; value: string }) { - return ( -
- {label} - {value} -
- ); -} - -function JsonDataPopup({ - open, - title, - data, - onClose, - }: { - open: boolean; - title: string; - data: unknown; - onClose: () => void; -}) { - useEffect(() => { - if (!open) return; - - const handleKeyDown = (event: KeyboardEvent) => { - if (event.key === "Escape") { - onClose(); - } - }; - - window.addEventListener("keydown", handleKeyDown); - return () => window.removeEventListener("keydown", handleKeyDown); - }, [open, onClose]); - - if (!open) return null; - - return ( -
-
event.stopPropagation()} - style={styles.jsonDialog} - > -
-
-

{title}

-

Raw data của submission đang duyệt.

-
- -
-
{JSON.stringify(data, null, 2)}
-
-
- ); -} - -function StatusBadge({ status }: { status: SectionSubmission["status"] }) { - return ( - - {STATUS_LABELS[status] || status} - - ); -} - -function OperationBlock({ - title, - operations, -}: { - title: string; - operations: Record; -}) { - const entries = Object.entries(operations); - return ( -
- {title} - {entries.length === 0 ? ( -

Không có operation.

- ) : ( -
- {entries.map(([operation, count]) => ( - - {operation}: {count} - - ))} -
- )} -
- ); -} - -function extractSnapshotFeatureCollection(rawSnapshot: unknown): FeatureCollection { - if (!rawSnapshot || typeof rawSnapshot !== "object" || Array.isArray(rawSnapshot)) { - return EMPTY_FEATURE_COLLECTION; - } - - const snapshot = rawSnapshot as Record; - const geometries = Array.isArray(snapshot.geometries) ? snapshot.geometries : []; - const reviewGeometryIds = getReviewGeometryIds(snapshot); - const renderableGeometryIds = new Set(); - for (const item of geometries) { - if (!item || typeof item !== "object" || Array.isArray(item)) continue; - const source = item as Record; - const id = normalizeSnapshotId(source.id); - if (!id || !reviewGeometryIds.has(id)) continue; - if (normalizeOperationName(source.operation) === "delete") continue; - renderableGeometryIds.add(id); - } - - const editorCollection = snapshot.editor_feature_collection; - if (isFeatureCollection(editorCollection)) { - return { - type: "FeatureCollection", - features: editorCollection.features - .filter((feature) => renderableGeometryIds.has(String(feature.properties.id))) - .map(cloneFeature), - }; - } - - const features = geometries - .map((item): Feature | null => { - if (!item || typeof item !== "object" || Array.isArray(item)) return null; - const source = item as Record; - - const id = normalizeSnapshotId(source.id); - if (!id || !renderableGeometryIds.has(id)) return null; - const geometry = source.draw_geometry || source.geometry; - if ((typeof id !== "string" && typeof id !== "number") || !isGeometry(geometry)) { - return null; - } - - return { - type: "Feature", - properties: { - id, - type: typeof source.type === "string" ? source.type : null, - geometry_preset: null, - entity_id: null, - entity_ids: [], - entity_name: null, - entity_type_id: null, - binding: normalizeStringArray(source.binding), - time_start: normalizeOptionalNumber(source.time_start), - time_end: normalizeOptionalNumber(source.time_end), - }, - geometry: cloneGeometry(geometry), - }; - }) - .filter((feature): feature is Feature => feature !== null); - - return { - type: "FeatureCollection", - features, - }; -} - -function summarizeSnapshot(rawSnapshot: unknown): SnapshotSummary | null { - if (!rawSnapshot || typeof rawSnapshot !== "object" || Array.isArray(rawSnapshot)) { - return null; - } - - const snapshot = rawSnapshot as Record; - const entities = Array.isArray(snapshot.entities) ? snapshot.entities : []; - const geometries = Array.isArray(snapshot.geometries) ? snapshot.geometries : []; - const linkScopes = Array.isArray(snapshot.link_scopes) ? snapshot.link_scopes : []; - const reviewGeometryIds = getReviewGeometryIds(snapshot); - const reviewEntities = entities.filter((item) => - normalizeOperationName(getRecord(item)?.operation) !== "reference" - ); - const reviewGeometries = geometries.filter((item) => { - const record = getRecord(item); - if (!record) return false; - const id = normalizeSnapshotId(record.id); - return Boolean(id && reviewGeometryIds.has(id)); - }); - - return { - featureCount: extractSnapshotFeatureCollection(rawSnapshot).features.length, - entityCount: reviewEntities.length, - geometryCount: reviewGeometries.length, - linkScopeCount: linkScopes.length, - entityOperations: countOperations(reviewEntities), - geometryOperations: countOperations(reviewGeometries), - linkOperations: countOperations(linkScopes), - }; -} - -function getReviewGeometryIds(snapshot: Record): Set { - const ids = new Set(); - const geometries = Array.isArray(snapshot.geometries) ? snapshot.geometries : []; - const linkScopes = Array.isArray(snapshot.link_scopes) ? snapshot.link_scopes : []; - - for (const item of geometries) { - const record = getRecord(item); - if (!record) continue; - const id = normalizeSnapshotId(record.id); - if (!id) continue; - const operation = normalizeOperationName(record.operation); - if (operation && operation !== "reference") { - ids.add(id); - } - } - - for (const item of linkScopes) { - const record = getRecord(item); - if (!record) continue; - const geometryId = normalizeSnapshotId(record.geometry_id); - if (geometryId) ids.add(geometryId); - } - - return ids; -} - -function getRecord(value: unknown): Record | null { - if (!value || typeof value !== "object" || Array.isArray(value)) return null; - return value as Record; -} - -function normalizeSnapshotId(value: unknown): string | null { - if (typeof value !== "string" && typeof value !== "number") return null; - const normalized = String(value).trim(); - return normalized.length ? normalized : null; -} - -function normalizeOperationName(value: unknown): string { - return typeof value === "string" ? value.trim().toLowerCase() : ""; -} - -function isFeatureCollection(value: unknown): value is FeatureCollection { - if (!value || typeof value !== "object" || Array.isArray(value)) return false; - const source = value as Record; - return source.type === "FeatureCollection" && - Array.isArray(source.features) && - source.features.every(isFeature); -} - -function isFeature(value: unknown): value is Feature { - if (!value || typeof value !== "object" || Array.isArray(value)) return false; - const source = value as Record; - const properties = source.properties; - if (source.type !== "Feature" || !isGeometry(source.geometry)) return false; - if (!properties || typeof properties !== "object" || Array.isArray(properties)) return false; - const id = (properties as Record).id; - return typeof id === "string" || typeof id === "number"; -} - -function isGeometry(value: unknown): value is Geometry { - if (!value || typeof value !== "object" || Array.isArray(value)) return false; - const source = value as Record; - return typeof source.type === "string" && Array.isArray(source.coordinates); -} - -function cloneFeature(feature: Feature): Feature { - return JSON.parse(JSON.stringify(feature)) as Feature; -} - -function cloneGeometry(geometry: Geometry): Geometry { - return JSON.parse(JSON.stringify(geometry)) as Geometry; -} - -function normalizeStringArray(value: unknown): string[] { - if (!Array.isArray(value)) return []; - return value - .map((item) => { - if (typeof item !== "string" && typeof item !== "number") return ""; - return String(item).trim(); - }) - .filter((item) => item.length > 0); -} - -function normalizeOptionalNumber(value: unknown): number | null { - if (value === null || value === undefined || value === "") return null; - const parsed = Number(value); - return Number.isFinite(parsed) ? parsed : null; -} - -function formatFeatureEntities(feature: Feature): string { - const named = normalizeStringArray(feature.properties.entity_names); - if (named.length > 0) return named.join(", "); - if (feature.properties.entity_name) return feature.properties.entity_name; - const ids = normalizeStringArray(feature.properties.entity_ids); - if (ids.length > 0) return ids.join(", "); - return feature.properties.entity_id || "Chưa gắn"; -} - -function formatFeatureTimeRange(feature: Feature): string { - const start = feature.properties.time_start; - const end = feature.properties.time_end; - if (start == null && end == null) return "Không giới hạn"; - if (start != null && end != null) return `${start} - ${end}`; - if (start != null) return `Từ ${start}`; - return `Đến ${end}`; -} - -function formatFeatureBinding(feature: Feature): string { - const binding = normalizeStringArray(feature.properties.binding); - return binding.length > 0 ? binding.join(", ") : "Không có"; -} - -function countOperations(items: unknown[]): Record { - return items.reduce>((acc, item) => { - const source = item && typeof item === "object" && !Array.isArray(item) - ? item as Record - : {}; - const operation = typeof source.operation === "string" && source.operation.trim() - ? source.operation.trim() - : "unknown"; - acc[operation] = (acc[operation] || 0) + 1; - return acc; - }, {}); -} - -function formatDateTime(value: string | null | undefined): string { - if (!value) return "Chưa có"; - const date = new Date(value); - if (Number.isNaN(date.getTime())) return value; - return new Intl.DateTimeFormat("vi-VN", { - dateStyle: "medium", - timeStyle: "short", - }).format(date); -} - -function getErrorMessage(err: unknown, fallback: string): string { - if (err instanceof ApiError) { - const parsed = parseErrorBody(err.body); - return parsed || err.body || fallback; - } - if (err instanceof Error) return err.message; - return fallback; -} - -function parseErrorBody(body: string): string | null { - try { - const parsed = JSON.parse(body) as unknown; - if (parsed && typeof parsed === "object" && "error" in parsed) { - const error = (parsed as { error?: unknown }).error; - return typeof error === "string" ? error : null; - } - } catch { - return null; - } - return null; -} - -function getStatusStyle(status: SectionSubmission["status"]): CSSProperties { - if (status === "approved") { - return { - background: "#d9f5df", - color: "#17652a", - borderColor: "#8bd19a", - }; - } - if (status === "rejected") { - return { - background: "#ffe2dd", - color: "#8a1f12", - borderColor: "#ef9a8d", - }; - } - if (status === "conflicted") { - return { - background: "#fff1c7", - color: "#755400", - borderColor: "#dfbf59", - }; - } - return { - background: "#e2f1ff", - color: "#0c4f85", - borderColor: "#8bbde4", - }; -} - -const styles: Record = { - page: { - minHeight: "100vh", - padding: "32px", - background: "#f7f8f3", - color: "#1f241f", - }, - header: { - display: "flex", - justifyContent: "space-between", - gap: "20px", - alignItems: "flex-start", - flexWrap: "wrap", - marginBottom: "22px", - }, - kicker: { - margin: "0 0 8px", - color: "#4f6f39", - fontSize: "13px", - fontWeight: 700, - textTransform: "uppercase", - }, - title: { - margin: 0, - fontSize: "36px", - lineHeight: 1.15, - letterSpacing: 0, - }, - subtitle: { - maxWidth: "680px", - margin: "10px 0 0", - color: "#596055", - lineHeight: 1.5, - }, - headerActions: { - display: "flex", - gap: "10px", - flexWrap: "wrap", - justifyContent: "flex-end", - }, - primaryButton: { - minHeight: "38px", - border: "1px solid #284f2f", - borderRadius: "8px", - padding: "8px 14px", - background: "#2f6d3a", - color: "#ffffff", - fontWeight: 700, - cursor: "pointer", - }, - secondaryLink: { - minHeight: "38px", - border: "1px solid #c7cebc", - borderRadius: "8px", - padding: "8px 14px", - background: "#ffffff", - color: "#2a3326", - fontWeight: 700, - textDecoration: "none", - }, - statsGrid: { - display: "grid", - gridTemplateColumns: "repeat(auto-fit, minmax(180px, 1fr))", - gap: "12px", - marginBottom: "16px", - }, - metric: { - border: "1px solid #d5dbc8", - borderRadius: "8px", - padding: "14px", - background: "#ffffff", - }, - metricValue: { - display: "block", - fontSize: "28px", - lineHeight: 1.1, - fontWeight: 800, - color: "#254b2b", - }, - metricLabel: { - display: "block", - marginTop: "6px", - color: "#5a6356", - fontSize: "13px", - }, - successBanner: { - border: "1px solid #9dcc9c", - borderRadius: "8px", - padding: "12px 14px", - marginBottom: "12px", - background: "#e6f6e5", - color: "#1d5b25", - }, - errorBanner: { - border: "1px solid #ef9a8d", - borderRadius: "8px", - padding: "12px 14px", - marginBottom: "12px", - background: "#ffe2dd", - color: "#8a1f12", - }, - toolbar: { - display: "grid", - gridTemplateColumns: "repeat(auto-fit, minmax(min(100%, 220px), 1fr))", - gap: "12px", - marginBottom: "16px", - alignItems: "end", - }, - fieldLabel: { - display: "grid", - gap: "6px", - color: "#424a3f", - fontSize: "13px", - fontWeight: 700, - }, - fieldLabelWide: { - display: "grid", - gap: "6px", - color: "#424a3f", - fontSize: "13px", - fontWeight: 700, - }, - input: { - width: "100%", - minHeight: "38px", - border: "1px solid #c9d0bd", - borderRadius: "8px", - padding: "8px 10px", - background: "#ffffff", - color: "#202820", - outline: "none", - }, - contentGrid: { - display: "grid", - gridTemplateColumns: "repeat(auto-fit, minmax(min(100%, 360px), 1fr))", - gap: "16px", - alignItems: "start", - }, - listPanel: { - border: "1px solid #d5dbc8", - borderRadius: "8px", - background: "#ffffff", - minHeight: "520px", - overflow: "hidden", - }, - detailPanel: { - border: "1px solid #d5dbc8", - borderRadius: "8px", - background: "#ffffff", - minHeight: "520px", - padding: "18px", - }, - panelHeader: { - display: "flex", - justifyContent: "space-between", - alignItems: "center", - padding: "14px", - borderBottom: "1px solid #e3e7da", - }, - panelTitle: { - margin: 0, - fontSize: "18px", - }, - mutedText: { - margin: 0, - color: "#66705f", - fontSize: "13px", - lineHeight: 1.45, - }, - emptyState: { - padding: "18px", - color: "#66705f", - lineHeight: 1.5, - }, - submissionList: { - display: "grid", - gap: "8px", - padding: "10px", - maxHeight: "calc(100vh - 310px)", - overflow: "auto", - }, - submissionItem: { - width: "100%", - display: "grid", - gap: "6px", - textAlign: "left", - border: "1px solid #e1e5d9", - borderRadius: "8px", - padding: "12px", - background: "#ffffff", - color: "#202820", - cursor: "pointer", - }, - submissionItemSelected: { - border: "1px solid #71a66a", - background: "#f0f8ee", - }, - itemTopLine: { - display: "flex", - gap: "8px", - justifyContent: "space-between", - alignItems: "center", - }, - itemTitle: { - minWidth: 0, - overflowWrap: "anywhere", - }, - itemMeta: { - color: "#5f695a", - fontSize: "13px", - overflowWrap: "anywhere", - }, - statusBadge: { - flex: "0 0 auto", - display: "inline-flex", - alignItems: "center", - minHeight: "24px", - border: "1px solid", - borderRadius: "8px", - padding: "2px 8px", - fontSize: "12px", - fontWeight: 800, - whiteSpace: "nowrap", - }, - detailHeader: { - display: "flex", - justifyContent: "space-between", - gap: "16px", - alignItems: "flex-start", - marginBottom: "16px", - }, - detailTitle: { - margin: "0 0 6px", - fontSize: "24px", - lineHeight: 1.25, - overflowWrap: "anywhere", - }, - metaGrid: { - display: "grid", - gridTemplateColumns: "repeat(auto-fit, minmax(220px, 1fr))", - gap: "10px", - marginBottom: "16px", - }, - infoCell: { - border: "1px solid #e1e5d9", - borderRadius: "8px", - padding: "10px", - background: "#fbfcf8", - minWidth: 0, - }, - infoLabel: { - display: "block", - color: "#66705f", - fontSize: "12px", - fontWeight: 700, - marginBottom: "5px", - }, - infoValue: { - display: "block", - color: "#202820", - fontSize: "13px", - overflowWrap: "anywhere", - }, - noteBox: { - border: "1px solid #e1e5d9", - borderRadius: "8px", - padding: "12px", - marginBottom: "16px", - background: "#fbfcf8", - }, - noteText: { - margin: "6px 0 0", - color: "#40483c", - lineHeight: 1.5, - overflowWrap: "anywhere", - }, - dataActions: { - display: "flex", - justifyContent: "flex-end", - marginBottom: "16px", - }, - secondaryButton: { - minHeight: "38px", - border: "1px solid #c7cebc", - borderRadius: "8px", - padding: "8px 14px", - background: "#ffffff", - color: "#2a3326", - fontWeight: 800, - cursor: "pointer", - }, - sectionHeading: { - margin: "18px 0 10px", - fontSize: "18px", - }, - summaryGrid: { - display: "grid", - gridTemplateColumns: "repeat(auto-fit, minmax(150px, 1fr))", - gap: "10px", - marginBottom: "12px", - }, - operationGrid: { - display: "grid", - gridTemplateColumns: "repeat(auto-fit, minmax(220px, 1fr))", - gap: "10px", - }, - operationBlock: { - border: "1px solid #e1e5d9", - borderRadius: "8px", - padding: "12px", - background: "#fbfcf8", - }, - operationRows: { - display: "flex", - flexWrap: "wrap", - gap: "8px", - marginTop: "10px", - }, - operationPill: { - border: "1px solid #cfd8c2", - borderRadius: "8px", - padding: "4px 8px", - background: "#ffffff", - color: "#394136", - fontSize: "13px", - }, - previewGrid: { - display: "grid", - gridTemplateColumns: "minmax(min(100%, 420px), 1fr) minmax(min(100%, 260px), 320px)", - gap: "12px", - marginBottom: "16px", - alignItems: "stretch", - }, - mapPreview: { - height: "420px", - minHeight: "320px", - border: "1px solid #d5dbc8", - borderRadius: "8px", - overflow: "hidden", - background: "#0b1220", - }, - featurePanel: { - border: "1px solid #e1e5d9", - borderRadius: "8px", - padding: "12px", - background: "#fbfcf8", - minWidth: 0, - }, - featureDetails: { - display: "grid", - gap: "8px", - marginTop: "10px", - }, - reviewActions: { - display: "flex", - justifyContent: "flex-end", - gap: "10px", - marginTop: "22px", - paddingTop: "16px", - borderTop: "1px solid #e3e7da", - }, - dangerButton: { - minHeight: "40px", - border: "1px solid #a93322", - borderRadius: "8px", - padding: "8px 16px", - background: "#b94330", - color: "#ffffff", - fontWeight: 800, - cursor: "pointer", - }, - approveButton: { - minHeight: "40px", - border: "1px solid #276131", - borderRadius: "8px", - padding: "8px 16px", - background: "#2f7a3b", - color: "#ffffff", - fontWeight: 800, - cursor: "pointer", - }, - jsonOverlay: { - position: "fixed", - inset: 0, - zIndex: 1000, - padding: "24px", - background: "rgba(31, 36, 31, 0.72)", - display: "flex", - alignItems: "center", - justifyContent: "center", - }, - jsonDialog: { - width: "min(980px, calc(100vw - 48px))", - maxHeight: "min(760px, calc(100vh - 48px))", - border: "1px solid #9da891", - borderRadius: "8px", - background: "#ffffff", - color: "#1f241f", - boxShadow: "0 24px 70px rgba(0, 0, 0, 0.32)", - display: "flex", - flexDirection: "column", - overflow: "hidden", - }, - jsonDialogHeader: { - display: "flex", - alignItems: "flex-start", - justifyContent: "space-between", - gap: "16px", - padding: "14px 16px", - borderBottom: "1px solid #dfe5d6", - background: "#f7f8f3", - }, - jsonDialogTitle: { - margin: "0 0 5px", - fontSize: "18px", - overflowWrap: "anywhere", - }, - jsonDialogSubtitle: { - margin: 0, - color: "#66705f", - fontSize: "13px", - }, - jsonCloseButton: { - minHeight: "34px", - border: "1px solid #c7cebc", - borderRadius: "8px", - padding: "7px 12px", - background: "#ffffff", - color: "#2a3326", - fontWeight: 800, - cursor: "pointer", - }, - jsonPre: { - margin: 0, - padding: "16px", - overflow: "auto", - background: "#10140f", - color: "#edf7e7", - fontSize: "12px", - lineHeight: 1.55, - fontFamily: "ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace", - tabSize: 2, - }, -}; diff --git a/app/submitted/page.tsx b/app/submitted/page.tsx new file mode 100644 index 0000000..1088df4 --- /dev/null +++ b/app/submitted/page.tsx @@ -0,0 +1,1317 @@ +"use client"; + +import { type CSSProperties, useEffect, useMemo, useState } from "react"; +import Map from "@/components/Map"; +import { ApiError } from "@/api/http"; +import { + approveSubmission, + fetchSections, + fetchSectionSubmissions, + rejectSubmission, + Section, + SectionSubmission, +} from "@/api/sections"; +import { HIDDEN_BACKGROUND_LAYER_VISIBILITY } from "@/lib/backgroundLayers"; +import { Feature, FeatureCollection, Geometry } from "@/lib/useEditorState"; + +type SubmissionStatusFilter = "pending" | "all"; +type ReviewAction = "approve" | "reject"; + +type SubmissionRow = { + section: Section; + submission: SectionSubmission; +}; + +type SnapshotSummary = { + featureCount: number; + entityCount: number; + geometryCount: number; + linkScopeCount: number; + entityOperations: Record; + geometryOperations: Record; + linkOperations: Record; +}; + +const DEFAULT_REVIEWER_ID = "admin"; +const EMPTY_FEATURE_COLLECTION: FeatureCollection = { + type: "FeatureCollection", + features: [], +}; +const REVIEW_BACKGROUND_LAYER_VISIBILITY = { + ...HIDDEN_BACKGROUND_LAYER_VISIBILITY, + "bg-countries-fill": true, + "bg-country-borders-line": true, +}; + +const STATUS_LABELS: Record = { + pending: "Chờ duyệt", + approved: "Đã duyệt", + rejected: "Từ chối", + conflicted: "Conflict", +}; + +export default function SubmittedPage() { + const [rows, setRows] = useState([]); + const [selectedSubmissionId, setSelectedSubmissionId] = useState(null); + const [statusFilter, setStatusFilter] = useState("pending"); + const [reviewerId, setReviewerId] = useState(DEFAULT_REVIEWER_ID); + const [reviewNote, setReviewNote] = useState(""); + const [isLoading, setIsLoading] = useState(true); + const [reviewingSubmissionId, setReviewingSubmissionId] = useState(null); + const [selectedPreviewFeatureId, setSelectedPreviewFeatureId] = useState(null); + const [isJsonPopupOpen, setIsJsonPopupOpen] = useState(false); + const [statusMessage, setStatusMessage] = useState(null); + const [errorMessage, setErrorMessage] = useState(null); + + const loadSubmissions = async () => { + setIsLoading(true); + setErrorMessage(null); + setStatusMessage(null); + + try { + const sections = await fetchSections(); + const submissionGroups = await Promise.all( + sections.map(async (section) => { + const submissions = await fetchSectionSubmissions(section.id, { + includeSnapshot: true, + }); + return submissions.map((submission) => ({ + section, + submission, + })); + }) + ); + const nextRows = submissionGroups + .flat() + .sort((a, b) => + Date.parse(b.submission.submitted_at) - Date.parse(a.submission.submitted_at) + ); + + setRows(nextRows); + setSelectedSubmissionId((current) => { + if (current && nextRows.some((row) => row.submission.id === current)) { + return current; + } + return nextRows.find((row) => row.submission.status === "pending")?.submission.id + || nextRows[0]?.submission.id + || null; + }); + } catch (err) { + setErrorMessage(getErrorMessage(err, "Không tải được danh sách section đã submit.")); + } finally { + setIsLoading(false); + } + }; + + useEffect(() => { + loadSubmissions(); + }, []); + + const visibleRows = useMemo(() => { + if (statusFilter === "all") return rows; + return rows.filter((row) => row.submission.status === "pending"); + }, [rows, statusFilter]); + + const selectedRow = useMemo(() => { + if (!selectedSubmissionId) return visibleRows[0] || null; + return ( + visibleRows.find((row) => row.submission.id === selectedSubmissionId) || + rows.find((row) => row.submission.id === selectedSubmissionId) || + visibleRows[0] || + null + ); + }, [rows, selectedSubmissionId, visibleRows]); + + const pendingCount = rows.filter((row) => row.submission.status === "pending").length; + const reviewedCount = rows.length - pendingCount; + const selectedSummary = selectedRow + ? summarizeSnapshot(selectedRow.submission.snapshot) + : null; + const selectedFeatureCollection = selectedRow + ? extractSnapshotFeatureCollection(selectedRow.submission.snapshot) + : EMPTY_FEATURE_COLLECTION; + const selectedPreviewFeature = selectedFeatureCollection.features.find((feature) => + String(feature.properties.id) === String(selectedPreviewFeatureId) + ) || null; + + useEffect(() => { + setSelectedPreviewFeatureId(null); + setIsJsonPopupOpen(false); + }, [selectedRow?.submission.id]); + + const handleReview = async (action: ReviewAction) => { + if (!selectedRow) return; + const actor = reviewerId.trim(); + if (!actor) { + setErrorMessage("Reviewer là bắt buộc."); + return; + } + if (selectedRow.submission.status !== "pending") { + setErrorMessage("Submission này không còn ở trạng thái chờ duyệt."); + return; + } + + const label = action === "approve" ? "duyệt" : "từ chối"; + const confirmed = window.confirm(`Xác nhận ${label} section "${selectedRow.section.title}"?`); + if (!confirmed) return; + + setReviewingSubmissionId(selectedRow.submission.id); + setErrorMessage(null); + setStatusMessage(null); + + try { + const input = { + reviewed_by: actor, + review_note: reviewNote.trim() || null, + }; + const updatedSubmission = action === "approve" + ? await approveSubmission(selectedRow.submission.id, input) + : await rejectSubmission(selectedRow.submission.id, input); + + setRows((currentRows) => + currentRows.map((row) => + row.submission.id === updatedSubmission.id + ? { + ...row, + section: { + ...row.section, + state: { + ...row.section.state, + status: updatedSubmission.status === "approved" + ? "approved" + : updatedSubmission.status === "rejected" + ? "rejected" + : row.section.state.status, + }, + }, + submission: updatedSubmission, + } + : row + ) + ); + setReviewNote(""); + setStatusMessage( + action === "approve" + ? "Đã duyệt submission và apply vào published data." + : "Đã từ chối submission." + ); + } catch (err) { + setErrorMessage(getErrorMessage(err, `Không thể ${label} submission.`)); + } finally { + setReviewingSubmissionId(null); + } + }; + + const isReviewingSelected = reviewingSubmissionId === selectedRow?.submission.id; + const canReviewSelected = Boolean( + selectedRow && + selectedRow.submission.status === "pending" && + !isReviewingSelected + ); + + return ( +
+
+
+

Review sections

+

Submitted sections

+

+ Xem các section đã submit, kiểm tra snapshot và duyệt dữ liệu vào published map. +

+
+
+ Mở editor + +
+
+ +
+ + + +
+ + {statusMessage ?
{statusMessage}
: null} + {errorMessage ?
{errorMessage}
: null} + +
+ + + +
+ +
+
+
+

Sections

+ {visibleRows.length} item +
+ + {isLoading ? ( +
Đang tải submissions...
+ ) : visibleRows.length === 0 ? ( +
+ Không có submission nào trong bộ lọc hiện tại. +
+ ) : ( +
+ {visibleRows.map((row) => { + const selected = selectedRow?.submission.id === row.submission.id; + return ( + + ); + })} +
+ )} +
+ +
+ {!selectedRow ? ( +
Chọn một section để xem chi tiết.
+ ) : ( + <> +
+
+

{selectedRow.section.title}

+

+ Section status: {selectedRow.section.state.status} · Version {selectedRow.section.state.version} +

+
+ +
+ +
+ + + + + + +
+ +
+ +
+ + {selectedRow.submission.review_note ? ( +
+ Review note +

{selectedRow.submission.review_note}

+
+ ) : null} + + {selectedSummary ? ( + <> +

Map preview

+ {selectedFeatureCollection.features.length > 0 ? ( +
+
+ +
+
+ Selected geometry + {selectedPreviewFeature ? ( +
+ + + + + + +
+ ) : ( +

+ Chọn một geometry trên preview để xem metadata. +

+ )} +
+
+ ) : ( +
+ Submission này không có geometry renderable trong phần cần duyệt. + Nếu đây là thay đổi delete-only, hãy xem phần operation summary bên dưới. +
+ )} + +

Snapshot

+
+ + + + +
+
+ + + +
+ + ) : ( +
Submission này không có snapshot để hiển thị.
+ )} + +
+ + +
+ + setIsJsonPopupOpen(false)} + /> + + )} +
+
+
+ ); +} + +function Metric({ label, value }: { label: string; value: number }) { + return ( +
+ {value} + {label} +
+ ); +} + +function Info({ label, value }: { label: string; value: string }) { + return ( +
+ {label} + {value} +
+ ); +} + +function JsonDataPopup({ + open, + title, + data, + onClose, + }: { + open: boolean; + title: string; + data: unknown; + onClose: () => void; +}) { + useEffect(() => { + if (!open) return; + + const handleKeyDown = (event: KeyboardEvent) => { + if (event.key === "Escape") { + onClose(); + } + }; + + window.addEventListener("keydown", handleKeyDown); + return () => window.removeEventListener("keydown", handleKeyDown); + }, [open, onClose]); + + if (!open) return null; + + return ( +
+
event.stopPropagation()} + style={styles.jsonDialog} + > +
+
+

{title}

+

Raw data của submission đang duyệt.

+
+ +
+
{JSON.stringify(data, null, 2)}
+
+
+ ); +} + +function StatusBadge({ status }: { status: SectionSubmission["status"] }) { + return ( + + {STATUS_LABELS[status] || status} + + ); +} + +function OperationBlock({ + title, + operations, +}: { + title: string; + operations: Record; +}) { + const entries = Object.entries(operations); + return ( +
+ {title} + {entries.length === 0 ? ( +

Không có operation.

+ ) : ( +
+ {entries.map(([operation, count]) => ( + + {operation}: {count} + + ))} +
+ )} +
+ ); +} + +function extractSnapshotFeatureCollection(rawSnapshot: unknown): FeatureCollection { + if (!rawSnapshot || typeof rawSnapshot !== "object" || Array.isArray(rawSnapshot)) { + return EMPTY_FEATURE_COLLECTION; + } + + const snapshot = rawSnapshot as Record; + const geometries = Array.isArray(snapshot.geometries) ? snapshot.geometries : []; + const reviewGeometryIds = getReviewGeometryIds(snapshot); + const renderableGeometryIds = new Set(); + for (const item of geometries) { + if (!item || typeof item !== "object" || Array.isArray(item)) continue; + const source = item as Record; + const id = normalizeSnapshotId(source.id); + if (!id || !reviewGeometryIds.has(id)) continue; + if (normalizeOperationName(source.operation) === "delete") continue; + renderableGeometryIds.add(id); + } + + const editorCollection = snapshot.editor_feature_collection; + if (isFeatureCollection(editorCollection)) { + return { + type: "FeatureCollection", + features: editorCollection.features + .filter((feature) => renderableGeometryIds.has(String(feature.properties.id))) + .map(cloneFeature), + }; + } + + const features = geometries + .map((item): Feature | null => { + if (!item || typeof item !== "object" || Array.isArray(item)) return null; + const source = item as Record; + + const id = normalizeSnapshotId(source.id); + if (!id || !renderableGeometryIds.has(id)) return null; + const geometry = source.draw_geometry || source.geometry; + if ((typeof id !== "string" && typeof id !== "number") || !isGeometry(geometry)) { + return null; + } + + return { + type: "Feature", + properties: { + id, + type: typeof source.type === "string" ? source.type : null, + geometry_preset: null, + entity_id: null, + entity_ids: [], + entity_name: null, + entity_type_id: null, + binding: normalizeStringArray(source.binding), + time_start: normalizeOptionalNumber(source.time_start), + time_end: normalizeOptionalNumber(source.time_end), + }, + geometry: cloneGeometry(geometry), + }; + }) + .filter((feature): feature is Feature => feature !== null); + + return { + type: "FeatureCollection", + features, + }; +} + +function summarizeSnapshot(rawSnapshot: unknown): SnapshotSummary | null { + if (!rawSnapshot || typeof rawSnapshot !== "object" || Array.isArray(rawSnapshot)) { + return null; + } + + const snapshot = rawSnapshot as Record; + const entities = Array.isArray(snapshot.entities) ? snapshot.entities : []; + const geometries = Array.isArray(snapshot.geometries) ? snapshot.geometries : []; + const linkScopes = Array.isArray(snapshot.link_scopes) ? snapshot.link_scopes : []; + const reviewGeometryIds = getReviewGeometryIds(snapshot); + const reviewEntities = entities.filter((item) => + normalizeOperationName(getRecord(item)?.operation) !== "reference" + ); + const reviewGeometries = geometries.filter((item) => { + const record = getRecord(item); + if (!record) return false; + const id = normalizeSnapshotId(record.id); + return Boolean(id && reviewGeometryIds.has(id)); + }); + + return { + featureCount: extractSnapshotFeatureCollection(rawSnapshot).features.length, + entityCount: reviewEntities.length, + geometryCount: reviewGeometries.length, + linkScopeCount: linkScopes.length, + entityOperations: countOperations(reviewEntities), + geometryOperations: countOperations(reviewGeometries), + linkOperations: countOperations(linkScopes), + }; +} + +function getReviewGeometryIds(snapshot: Record): Set { + const ids = new Set(); + const geometries = Array.isArray(snapshot.geometries) ? snapshot.geometries : []; + const linkScopes = Array.isArray(snapshot.link_scopes) ? snapshot.link_scopes : []; + + for (const item of geometries) { + const record = getRecord(item); + if (!record) continue; + const id = normalizeSnapshotId(record.id); + if (!id) continue; + const operation = normalizeOperationName(record.operation); + if (operation && operation !== "reference") { + ids.add(id); + } + } + + for (const item of linkScopes) { + const record = getRecord(item); + if (!record) continue; + const geometryId = normalizeSnapshotId(record.geometry_id); + if (geometryId) ids.add(geometryId); + } + + return ids; +} + +function getRecord(value: unknown): Record | null { + if (!value || typeof value !== "object" || Array.isArray(value)) return null; + return value as Record; +} + +function normalizeSnapshotId(value: unknown): string | null { + if (typeof value !== "string" && typeof value !== "number") return null; + const normalized = String(value).trim(); + return normalized.length ? normalized : null; +} + +function normalizeOperationName(value: unknown): string { + return typeof value === "string" ? value.trim().toLowerCase() : ""; +} + +function isFeatureCollection(value: unknown): value is FeatureCollection { + if (!value || typeof value !== "object" || Array.isArray(value)) return false; + const source = value as Record; + return source.type === "FeatureCollection" && + Array.isArray(source.features) && + source.features.every(isFeature); +} + +function isFeature(value: unknown): value is Feature { + if (!value || typeof value !== "object" || Array.isArray(value)) return false; + const source = value as Record; + const properties = source.properties; + if (source.type !== "Feature" || !isGeometry(source.geometry)) return false; + if (!properties || typeof properties !== "object" || Array.isArray(properties)) return false; + const id = (properties as Record).id; + return typeof id === "string" || typeof id === "number"; +} + +function isGeometry(value: unknown): value is Geometry { + if (!value || typeof value !== "object" || Array.isArray(value)) return false; + const source = value as Record; + return typeof source.type === "string" && Array.isArray(source.coordinates); +} + +function cloneFeature(feature: Feature): Feature { + return JSON.parse(JSON.stringify(feature)) as Feature; +} + +function cloneGeometry(geometry: Geometry): Geometry { + return JSON.parse(JSON.stringify(geometry)) as Geometry; +} + +function normalizeStringArray(value: unknown): string[] { + if (!Array.isArray(value)) return []; + return value + .map((item) => { + if (typeof item !== "string" && typeof item !== "number") return ""; + return String(item).trim(); + }) + .filter((item) => item.length > 0); +} + +function normalizeOptionalNumber(value: unknown): number | null { + if (value === null || value === undefined || value === "") return null; + const parsed = Number(value); + return Number.isFinite(parsed) ? parsed : null; +} + +function formatFeatureEntities(feature: Feature): string { + const named = normalizeStringArray(feature.properties.entity_names); + if (named.length > 0) return named.join(", "); + if (feature.properties.entity_name) return feature.properties.entity_name; + const ids = normalizeStringArray(feature.properties.entity_ids); + if (ids.length > 0) return ids.join(", "); + return feature.properties.entity_id || "Chưa gắn"; +} + +function formatFeatureTimeRange(feature: Feature): string { + const start = feature.properties.time_start; + const end = feature.properties.time_end; + if (start == null && end == null) return "Không giới hạn"; + if (start != null && end != null) return `${start} - ${end}`; + if (start != null) return `Từ ${start}`; + return `Đến ${end}`; +} + +function formatFeatureBinding(feature: Feature): string { + const binding = normalizeStringArray(feature.properties.binding); + return binding.length > 0 ? binding.join(", ") : "Không có"; +} + +function countOperations(items: unknown[]): Record { + return items.reduce>((acc, item) => { + const source = item && typeof item === "object" && !Array.isArray(item) + ? item as Record + : {}; + const operation = typeof source.operation === "string" && source.operation.trim() + ? source.operation.trim() + : "unknown"; + acc[operation] = (acc[operation] || 0) + 1; + return acc; + }, {}); +} + +function formatDateTime(value: string | null | undefined): string { + if (!value) return "Chưa có"; + const date = new Date(value); + if (Number.isNaN(date.getTime())) return value; + return new Intl.DateTimeFormat("vi-VN", { + dateStyle: "medium", + timeStyle: "short", + }).format(date); +} + +function getErrorMessage(err: unknown, fallback: string): string { + if (err instanceof ApiError) { + const parsed = parseErrorBody(err.body); + return parsed || err.body || fallback; + } + if (err instanceof Error) return err.message; + return fallback; +} + +function parseErrorBody(body: string): string | null { + try { + const parsed = JSON.parse(body) as unknown; + if (parsed && typeof parsed === "object" && "error" in parsed) { + const error = (parsed as { error?: unknown }).error; + return typeof error === "string" ? error : null; + } + } catch { + return null; + } + return null; +} + +function getStatusStyle(status: SectionSubmission["status"]): CSSProperties { + if (status === "approved") { + return { + background: "#d9f5df", + color: "#17652a", + borderColor: "#8bd19a", + }; + } + if (status === "rejected") { + return { + background: "#ffe2dd", + color: "#8a1f12", + borderColor: "#ef9a8d", + }; + } + if (status === "conflicted") { + return { + background: "#fff1c7", + color: "#755400", + borderColor: "#dfbf59", + }; + } + return { + background: "#e2f1ff", + color: "#0c4f85", + borderColor: "#8bbde4", + }; +} + +const styles: Record = { + page: { + minHeight: "100vh", + padding: "32px", + background: "#f7f8f3", + color: "#1f241f", + }, + header: { + display: "flex", + justifyContent: "space-between", + gap: "20px", + alignItems: "flex-start", + flexWrap: "wrap", + marginBottom: "22px", + }, + kicker: { + margin: "0 0 8px", + color: "#4f6f39", + fontSize: "13px", + fontWeight: 700, + textTransform: "uppercase", + }, + title: { + margin: 0, + fontSize: "36px", + lineHeight: 1.15, + letterSpacing: 0, + }, + subtitle: { + maxWidth: "680px", + margin: "10px 0 0", + color: "#596055", + lineHeight: 1.5, + }, + headerActions: { + display: "flex", + gap: "10px", + flexWrap: "wrap", + justifyContent: "flex-end", + }, + primaryButton: { + minHeight: "38px", + border: "1px solid #284f2f", + borderRadius: "8px", + padding: "8px 14px", + background: "#2f6d3a", + color: "#ffffff", + fontWeight: 700, + cursor: "pointer", + }, + secondaryLink: { + minHeight: "38px", + border: "1px solid #c7cebc", + borderRadius: "8px", + padding: "8px 14px", + background: "#ffffff", + color: "#2a3326", + fontWeight: 700, + textDecoration: "none", + }, + statsGrid: { + display: "grid", + gridTemplateColumns: "repeat(auto-fit, minmax(180px, 1fr))", + gap: "12px", + marginBottom: "16px", + }, + metric: { + border: "1px solid #d5dbc8", + borderRadius: "8px", + padding: "14px", + background: "#ffffff", + }, + metricValue: { + display: "block", + fontSize: "28px", + lineHeight: 1.1, + fontWeight: 800, + color: "#254b2b", + }, + metricLabel: { + display: "block", + marginTop: "6px", + color: "#5a6356", + fontSize: "13px", + }, + successBanner: { + border: "1px solid #9dcc9c", + borderRadius: "8px", + padding: "12px 14px", + marginBottom: "12px", + background: "#e6f6e5", + color: "#1d5b25", + }, + errorBanner: { + border: "1px solid #ef9a8d", + borderRadius: "8px", + padding: "12px 14px", + marginBottom: "12px", + background: "#ffe2dd", + color: "#8a1f12", + }, + toolbar: { + display: "grid", + gridTemplateColumns: "repeat(auto-fit, minmax(min(100%, 220px), 1fr))", + gap: "12px", + marginBottom: "16px", + alignItems: "end", + }, + fieldLabel: { + display: "grid", + gap: "6px", + color: "#424a3f", + fontSize: "13px", + fontWeight: 700, + }, + fieldLabelWide: { + display: "grid", + gap: "6px", + color: "#424a3f", + fontSize: "13px", + fontWeight: 700, + }, + input: { + width: "100%", + minHeight: "38px", + border: "1px solid #c9d0bd", + borderRadius: "8px", + padding: "8px 10px", + background: "#ffffff", + color: "#202820", + outline: "none", + }, + contentGrid: { + display: "grid", + gridTemplateColumns: "repeat(auto-fit, minmax(min(100%, 360px), 1fr))", + gap: "16px", + alignItems: "start", + }, + listPanel: { + border: "1px solid #d5dbc8", + borderRadius: "8px", + background: "#ffffff", + minHeight: "520px", + overflow: "hidden", + }, + detailPanel: { + border: "1px solid #d5dbc8", + borderRadius: "8px", + background: "#ffffff", + minHeight: "520px", + padding: "18px", + }, + panelHeader: { + display: "flex", + justifyContent: "space-between", + alignItems: "center", + padding: "14px", + borderBottom: "1px solid #e3e7da", + }, + panelTitle: { + margin: 0, + fontSize: "18px", + }, + mutedText: { + margin: 0, + color: "#66705f", + fontSize: "13px", + lineHeight: 1.45, + }, + emptyState: { + padding: "18px", + color: "#66705f", + lineHeight: 1.5, + }, + submissionList: { + display: "grid", + gap: "8px", + padding: "10px", + maxHeight: "calc(100vh - 310px)", + overflow: "auto", + }, + submissionItem: { + width: "100%", + display: "grid", + gap: "6px", + textAlign: "left", + border: "1px solid #e1e5d9", + borderRadius: "8px", + padding: "12px", + background: "#ffffff", + color: "#202820", + cursor: "pointer", + }, + submissionItemSelected: { + border: "1px solid #71a66a", + background: "#f0f8ee", + }, + itemTopLine: { + display: "flex", + gap: "8px", + justifyContent: "space-between", + alignItems: "center", + }, + itemTitle: { + minWidth: 0, + overflowWrap: "anywhere", + }, + itemMeta: { + color: "#5f695a", + fontSize: "13px", + overflowWrap: "anywhere", + }, + statusBadge: { + flex: "0 0 auto", + display: "inline-flex", + alignItems: "center", + minHeight: "24px", + border: "1px solid", + borderRadius: "8px", + padding: "2px 8px", + fontSize: "12px", + fontWeight: 800, + whiteSpace: "nowrap", + }, + detailHeader: { + display: "flex", + justifyContent: "space-between", + gap: "16px", + alignItems: "flex-start", + marginBottom: "16px", + }, + detailTitle: { + margin: "0 0 6px", + fontSize: "24px", + lineHeight: 1.25, + overflowWrap: "anywhere", + }, + metaGrid: { + display: "grid", + gridTemplateColumns: "repeat(auto-fit, minmax(220px, 1fr))", + gap: "10px", + marginBottom: "16px", + }, + infoCell: { + border: "1px solid #e1e5d9", + borderRadius: "8px", + padding: "10px", + background: "#fbfcf8", + minWidth: 0, + }, + infoLabel: { + display: "block", + color: "#66705f", + fontSize: "12px", + fontWeight: 700, + marginBottom: "5px", + }, + infoValue: { + display: "block", + color: "#202820", + fontSize: "13px", + overflowWrap: "anywhere", + }, + noteBox: { + border: "1px solid #e1e5d9", + borderRadius: "8px", + padding: "12px", + marginBottom: "16px", + background: "#fbfcf8", + }, + noteText: { + margin: "6px 0 0", + color: "#40483c", + lineHeight: 1.5, + overflowWrap: "anywhere", + }, + dataActions: { + display: "flex", + justifyContent: "flex-end", + marginBottom: "16px", + }, + secondaryButton: { + minHeight: "38px", + border: "1px solid #c7cebc", + borderRadius: "8px", + padding: "8px 14px", + background: "#ffffff", + color: "#2a3326", + fontWeight: 800, + cursor: "pointer", + }, + sectionHeading: { + margin: "18px 0 10px", + fontSize: "18px", + }, + summaryGrid: { + display: "grid", + gridTemplateColumns: "repeat(auto-fit, minmax(150px, 1fr))", + gap: "10px", + marginBottom: "12px", + }, + operationGrid: { + display: "grid", + gridTemplateColumns: "repeat(auto-fit, minmax(220px, 1fr))", + gap: "10px", + }, + operationBlock: { + border: "1px solid #e1e5d9", + borderRadius: "8px", + padding: "12px", + background: "#fbfcf8", + }, + operationRows: { + display: "flex", + flexWrap: "wrap", + gap: "8px", + marginTop: "10px", + }, + operationPill: { + border: "1px solid #cfd8c2", + borderRadius: "8px", + padding: "4px 8px", + background: "#ffffff", + color: "#394136", + fontSize: "13px", + }, + previewGrid: { + display: "grid", + gridTemplateColumns: "minmax(min(100%, 420px), 1fr) minmax(min(100%, 260px), 320px)", + gap: "12px", + marginBottom: "16px", + alignItems: "stretch", + }, + mapPreview: { + height: "420px", + minHeight: "320px", + border: "1px solid #d5dbc8", + borderRadius: "8px", + overflow: "hidden", + background: "#0b1220", + }, + featurePanel: { + border: "1px solid #e1e5d9", + borderRadius: "8px", + padding: "12px", + background: "#fbfcf8", + minWidth: 0, + }, + featureDetails: { + display: "grid", + gap: "8px", + marginTop: "10px", + }, + reviewActions: { + display: "flex", + justifyContent: "flex-end", + gap: "10px", + marginTop: "22px", + paddingTop: "16px", + borderTop: "1px solid #e3e7da", + }, + dangerButton: { + minHeight: "40px", + border: "1px solid #a93322", + borderRadius: "8px", + padding: "8px 16px", + background: "#b94330", + color: "#ffffff", + fontWeight: 800, + cursor: "pointer", + }, + approveButton: { + minHeight: "40px", + border: "1px solid #276131", + borderRadius: "8px", + padding: "8px 16px", + background: "#2f7a3b", + color: "#ffffff", + fontWeight: 800, + cursor: "pointer", + }, + jsonOverlay: { + position: "fixed", + inset: 0, + zIndex: 1000, + padding: "24px", + background: "rgba(31, 36, 31, 0.72)", + display: "flex", + alignItems: "center", + justifyContent: "center", + }, + jsonDialog: { + width: "min(980px, calc(100vw - 48px))", + maxHeight: "min(760px, calc(100vh - 48px))", + border: "1px solid #9da891", + borderRadius: "8px", + background: "#ffffff", + color: "#1f241f", + boxShadow: "0 24px 70px rgba(0, 0, 0, 0.32)", + display: "flex", + flexDirection: "column", + overflow: "hidden", + }, + jsonDialogHeader: { + display: "flex", + alignItems: "flex-start", + justifyContent: "space-between", + gap: "16px", + padding: "14px 16px", + borderBottom: "1px solid #dfe5d6", + background: "#f7f8f3", + }, + jsonDialogTitle: { + margin: "0 0 5px", + fontSize: "18px", + overflowWrap: "anywhere", + }, + jsonDialogSubtitle: { + margin: 0, + color: "#66705f", + fontSize: "13px", + }, + jsonCloseButton: { + minHeight: "34px", + border: "1px solid #c7cebc", + borderRadius: "8px", + padding: "7px 12px", + background: "#ffffff", + color: "#2a3326", + fontWeight: 800, + cursor: "pointer", + }, + jsonPre: { + margin: 0, + padding: "16px", + overflow: "auto", + background: "#10140f", + color: "#edf7e7", + fontSize: "12px", + lineHeight: 1.55, + fontFamily: "ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace", + tabSize: 2, + }, +}; diff --git a/components/Editor.tsx b/components/Editor.tsx index f06ea1e..3588b3a 100644 --- a/components/Editor.tsx +++ b/components/Editor.tsx @@ -3,12 +3,11 @@ import { useState } from "react"; import CommitTreePopup from "@/components/CommitTreePopup"; import { UndoAction } from "@/lib/useEditorState"; - -type Mode = "draw" | "select" | "idle" | "add-point" | "add-line" | "add-path" | "add-circle"; +import type { EditorMode } from "@/lib/editor/session/sessionTypes"; type Props = { - mode: Mode; - setMode: (mode: Mode) => void; + mode: EditorMode; + setMode: (mode: EditorMode) => void; entityStatus?: string | null; onUndo: () => void; onCommit: () => void; @@ -103,12 +102,13 @@ export default function Editor({ createdEntities, createdGeometries, }: Props) { + // Bật/tắt popup cây commit. const [isCommitTreeOpen, setIsCommitTreeOpen] = useState(false); const formatCommitTitle = (commit: Props["commits"][number]) => commit.title?.trim() || `Commit #${commit.commit_no}`; - const toggleMode = (newMode: Mode) => { + const toggleMode = (newMode: EditorMode) => { if (mode === newMode) { setMode("idle"); // bấm lại → tắt } else { @@ -129,7 +129,7 @@ export default function Editor({ return labels.reverse(); })(); - const getButtonStyle = (btnMode: Mode) => ({ + const getButtonStyle = (btnMode: EditorMode) => ({ width: "100%", padding: "8px", marginBottom: "6px", diff --git a/components/Map.tsx b/components/Map.tsx index b394504..0ed2795 100644 --- a/components/Map.tsx +++ b/components/Map.tsx @@ -14,9 +14,31 @@ import { initCircle } from "@/lib/engine/circleEngine"; import { createEditingEngine } from "@/lib/engine/editingEngine"; import { Feature, FeatureCollection, Geometry } from "@/lib/useEditorState"; import { BACKGROUND_LAYER_OPTIONS, BackgroundLayerVisibility } from "@/lib/backgroundLayers"; +import type { EditorMode } from "@/lib/editor/session/sessionTypes"; +import { EMPTY_FEATURE_COLLECTION } from "@/lib/geo/constants"; +import { + DEFAULT_POINT_ICON_ID, + FEATURE_STATE_SOURCE_IDS, + MAP_MAX_ZOOM, + MAP_MIN_ZOOM, + PATH_ARROW_ICON_ID, + PATH_ARROW_SOURCE_ID, + POINT_ICON_URL, + RASTER_BASE_INSERT_BEFORE_LAYER_ID, + RASTER_BASE_LAYER_ID, + RASTER_BASE_SOURCE_ID, +} from "@/lib/map/constants"; +import { + COUNTRY_FILL_COLOR_EXPRESSION, + LINE_COLOR_BY_TYPE, + PATH_RENDER_BY_TYPE, + POLYGON_FILL_BY_TYPE, + POLYGON_OPACITY_BY_TYPE, + POLYGON_STROKE_BY_TYPE, +} from "@/lib/map/style"; type MapProps = { - mode: "idle" | "draw" | "select" | "add-point" | "add-line" | "add-path" | "add-circle"; + mode: EditorMode; draft: FeatureCollection; backgroundVisibility: BackgroundLayerVisibility; selectedFeatureId: string | number | null; @@ -37,93 +59,6 @@ type EngineBinding = { clearSelection?: () => void; }; -const DEFAULT_POINT_ICON_ID = "point-icon-default"; -const POINT_ICON_URL = "/point.png"; -const PATH_ARROW_ICON_ID = "path-arrow-icon"; -const MAP_MIN_ZOOM = 1; -const MAP_MAX_ZOOM = 10; -const RASTER_BASE_SOURCE_ID = "rasterBase"; -const RASTER_BASE_LAYER_ID = "raster-base-layer"; -const RASTER_BASE_INSERT_BEFORE_LAYER_ID = "graticules-line"; -const PATH_ARROW_SOURCE_ID = "path-arrow-shapes"; -const FEATURE_STATE_SOURCE_IDS = ["countries", "places", PATH_ARROW_SOURCE_ID] as const; -const EMPTY_FEATURE_COLLECTION: FeatureCollection = { - type: "FeatureCollection", - features: [], -}; -const COUNTRY_COLOR_KEY_EXPRESSION: maplibregl.ExpressionSpecification = [ - "coalesce", - ["get", "MAPCOLOR7"], - ["get", "MAPCOLOR9"], - ["get", "scalerank"], - 0, -]; -const COUNTRY_FILL_COLOR_EXPRESSION: maplibregl.ExpressionSpecification = [ - "match", - COUNTRY_COLOR_KEY_EXPRESSION, - 1, "#ef4444", - 2, "#f97316", - 3, "#f59e0b", - 4, "#22c55e", - 5, "#06b6d4", - 6, "#3b82f6", - 7, "#8b5cf6", - 8, "#a855f7", - 9, "#d946ef", - 10, "#14b8a6", - "#64748b", -]; - -const POLYGON_FILL_BY_TYPE: Record = { - country: "#2563eb", - state: "#0ea5e9", - empire: "#f59e0b", - kingdom: "#d97706", - war: "#dc2626", - battle: "#f43f5e", - civilization: "#14b8a6", - rebellion_zone: "#7c3aed", -}; - -const POLYGON_STROKE_BY_TYPE: Record = { - country: "#1e3a8a", - state: "#0c4a6e", - empire: "#7c2d12", - kingdom: "#9a3412", - war: "#7f1d1d", - battle: "#9f1239", - civilization: "#134e4a", - rebellion_zone: "#4c1d95", -}; - -const POLYGON_OPACITY_BY_TYPE: Record = { - war: 0.3, - battle: 0.34, - civilization: 0.38, - rebellion_zone: 0.32, -}; - -const LINE_COLOR_BY_TYPE: Record = { - defense_line: "#f97316", - attack_route: "#ef4444", - retreat_route: "#94a3b8", - invasion_route: "#b91c1c", - migration_route: "#0ea5e9", - refugee_route: "#06b6d4", - trade_route: "#eab308", - shipping_route: "#2563eb", -}; - -const PATH_RENDER_BY_TYPE: Record = { - attack_route: true, - retreat_route: true, - invasion_route: true, - migration_route: true, - refugee_route: true, - trade_route: true, - shipping_route: true, -}; - export default function Map({ mode, draft, @@ -139,24 +74,57 @@ export default function Map({ fitToDraftBounds = false, fitBoundsKey = null, }: MapProps) { + // DOM container của map (dùng ref để tránh collision khi render nhiều map). + const containerRef = useRef(null); + // Nếu init map fail (throw trong onLoad), show overlay thay vì crash âm thầm. + const [fatalInitError, setFatalInitError] = useState(null); + + // Mirror các flags props để tránh phải re-create map khi props thay đổi. + const fitToDraftBoundsRef = useRef(fitToDraftBounds); + const respectBindingFilterRef = useRef(respectBindingFilter); + + // Instance maplibre (được tạo 1 lần khi component mount). const mapRef = useRef(null); + // Mirror của props mode để event handlers/engines đọc giá trị mới nhất (tránh stale closure). const modeRef = useRef(mode); + // Mirror của draft để engines đọc dữ liệu mới nhất trong callbacks. const draftRef = useRef(draft); + // Mirror của backgroundVisibility để sync visibility khi map đã load. const backgroundVisibilityRef = useRef(backgroundVisibility); + // Mirror của selectedFeatureId để filter/select trên map (không phụ thuộc re-render). const selectedFeatureIdRef = useRef(selectedFeatureId); + // Mirror của callback onSelectFeatureId. const onSelectFeatureIdRef = useRef(onSelectFeatureId); + // Mirror của callback onCreateFeature. const onCreateRef = useRef(onCreateFeature); + // Mirror của callback onDeleteFeature. const onDeleteRef = useRef(onDeleteFeature); + // Mirror của callback onUpdateFeature. const onUpdateRef = useRef(onUpdateFeature); + // Zoom hiện tại để render UI zoom control. const [zoomLevel, setZoomLevel] = useState(2); + // Min/max zoom dùng cho slider và clamp thao tác zoom. const [zoomBounds, setZoomBounds] = useState({ min: MAP_MIN_ZOOM, max: MAP_MAX_ZOOM }); + // Engine chỉnh sửa polygon (kéo đỉnh/insert đỉnh), chỉ khởi tạo 1 lần. const editingEngineRef = useRef | null>(null); + // Đánh dấu đã fitBounds cho fitBoundsKey hiện tại (tránh fit lặp). const fitBoundsAppliedRef = useRef(false); + // Danh sách cleanup fns để dọn listeners/engines khi unmount map. const mapCleanupFnsRef = useRef void>>([]); + // Các engine bindings theo mode để gọi cancel/cleanup khi đổi mode. const engineBindingsRef = useRef>>({}); + // Lưu mode trước đó để cancel engine đúng lúc khi switch mode. const previousModeRef = useRef(mode); + useEffect(() => { + fitToDraftBoundsRef.current = fitToDraftBounds; + }, [fitToDraftBounds]); + + useEffect(() => { + respectBindingFilterRef.current = respectBindingFilter; + }, [respectBindingFilter]); + useEffect(() => { modeRef.current = mode; }, [mode]); @@ -266,7 +234,7 @@ export default function Map({ } } - const visibleDraft = respectBindingFilter + const visibleDraft = respectBindingFilterRef.current ? filterDraftByBinding(fc, selectedFeatureIdRef.current) : fc; const { polygons, points } = splitDraftFeatures(visibleDraft); @@ -283,14 +251,17 @@ export default function Map({ if (mapRef.current !== map) return; setSelectedFeatureState(map, selectedId, true); }); - if (fitToDraftBounds && !fitBoundsAppliedRef.current) { + if (fitToDraftBoundsRef.current && !fitBoundsAppliedRef.current) { fitBoundsAppliedRef.current = fitMapToFeatureCollection(map, visibleDraft); } - }, [fitToDraftBounds, respectBindingFilter]); + }, []); useEffect(() => { + const container = containerRef.current; + if (!container) return; + const map = new maplibregl.Map({ - container: "map", + container, attributionControl: false, minZoom: MAP_MIN_ZOOM, maxZoom: MAP_MAX_ZOOM, @@ -434,24 +405,25 @@ export default function Map({ mapRef.current = map; map.on("load", async () => { - const syncZoomLevel = () => { - setZoomLevel(roundZoom(map.getZoom())); - }; + try { + const syncZoomLevel = () => { + setZoomLevel(roundZoom(map.getZoom())); + }; - applyBackgroundLayerVisibility(map, backgroundVisibilityRef.current); - const hasPathArrowIcon = ensurePathArrowIcon(map); - setZoomBounds({ min: MAP_MIN_ZOOM, max: MAP_MAX_ZOOM }); - syncZoomLevel(); - map.on("zoom", syncZoomLevel); + applyBackgroundLayerVisibility(map, backgroundVisibilityRef.current); + const hasPathArrowIcon = ensurePathArrowIcon(map); + setZoomBounds({ min: MAP_MIN_ZOOM, max: MAP_MAX_ZOOM }); + syncZoomLevel(); + map.on("zoom", syncZoomLevel); - // preview (drawing) - map.addSource("draw-preview", { - type: "geojson", - data: { - type: "FeatureCollection", - features: [], - }, - }); + // preview (drawing) + map.addSource("draw-preview", { + type: "geojson", + data: { + type: "FeatureCollection", + features: [], + }, + }); map.addLayer({ id: "draw-preview-fill", @@ -821,7 +793,7 @@ export default function Map({ map, () => modeRef.current, (geometry: Geometry) => { - const id = crypto.randomUUID ? crypto.randomUUID() : Date.now(); + const id = buildClientFeatureId(); onCreateRef.current?.({ type: "Feature", properties: { @@ -860,7 +832,7 @@ export default function Map({ map, () => modeRef.current, (geometry: Geometry) => { - const id = crypto.randomUUID ? crypto.randomUUID() : Date.now(); + const id = buildClientFeatureId(); onCreateRef.current?.({ type: "Feature", properties: { @@ -882,7 +854,7 @@ export default function Map({ map, () => modeRef.current, (geometry: Geometry) => { - const id = crypto.randomUUID ? crypto.randomUUID() : Date.now(); + const id = buildClientFeatureId(); onCreateRef.current?.({ type: "Feature", properties: { @@ -904,7 +876,7 @@ export default function Map({ map, () => modeRef.current, (geometry: Geometry) => { - const id = crypto.randomUUID ? crypto.randomUUID() : Date.now(); + const id = buildClientFeatureId(); onCreateRef.current?.({ type: "Feature", properties: { @@ -926,7 +898,7 @@ export default function Map({ map, () => modeRef.current, (geometry: Geometry) => { - const id = crypto.randomUUID ? crypto.randomUUID() : Date.now(); + const id = buildClientFeatureId(); onCreateRef.current?.({ type: "Feature", properties: { @@ -968,6 +940,10 @@ export default function Map({ if (allowGeometryEditing) { editingEngineRef.current?.bindEditEvents(map); } + } catch (err) { + console.error("Map initialization failed", err); + setFatalInitError(err instanceof Error ? err.message : "Map initialization failed."); + } }); return () => { @@ -1011,7 +987,39 @@ export default function Map({ return (
-
+
+ + {fatalInitError ? ( +
+
+
+ Map khong khoi tao duoc +
+
+ {fatalInitError} +
+
+
+ ) : null}
f.geometry.type !== "Point"), + features: fc.features.filter((f) => + f.geometry.type !== "Point" && f.geometry.type !== "MultiPoint" + ), } as FeatureCollection; const points = { type: "FeatureCollection", - features: fc.features.filter((f) => f.geometry.type === "Point"), + features: fc.features.filter((f) => + f.geometry.type === "Point" || f.geometry.type === "MultiPoint" + ), } as FeatureCollection; return { polygons, points }; @@ -1540,22 +1552,27 @@ function createPathArrowImageData(): ImageData | null { function addPointSymbolLayer(map: maplibregl.Map) { void ensurePointAssetIcon(map).then((hasPointIcon) => { - if (!hasPointIcon || !map.getSource("places") || map.getLayer("places-symbol")) return; + try { + if (!hasPointIcon || !map.getSource("places") || map.getLayer("places-symbol")) return; - map.addLayer({ - id: "places-symbol", - type: "symbol", - source: "places", - layout: { - "icon-image": DEFAULT_POINT_ICON_ID, - "icon-size": 0.06, - "icon-anchor": "center", - "icon-allow-overlap": true, - }, - }); + map.addLayer({ + id: "places-symbol", + type: "symbol", + source: "places", + layout: { + "icon-image": DEFAULT_POINT_ICON_ID, + "icon-size": 0.06, + "icon-anchor": "center", + "icon-allow-overlap": true, + }, + }); - if (map.getLayer("places-circle")) { - map.setLayoutProperty("places-circle", "visibility", "none"); + if (map.getLayer("places-circle")) { + map.setLayoutProperty("places-circle", "visibility", "none"); + } + } catch (err) { + // Map might have been removed while icon was loading. + console.warn("Add point symbol layer skipped", err); } }); } @@ -1602,6 +1619,14 @@ function roundZoom(value: number): number { return Math.round(value * 10) / 10; } +function buildClientFeatureId(): string { + if (typeof crypto !== "undefined" && typeof crypto.randomUUID === "function") { + return crypto.randomUUID(); + } + // Fallback đảm bảo tránh collision khi user tạo nhiều feature trong cùng 1ms. + return `feature-${Date.now()}-${Math.random().toString(16).slice(2, 10)}`; +} + function clampNumber(value: number, min: number, max: number): number { if (value < min) return min; if (value > max) return max; diff --git a/components/SelectedGeometryPanel.tsx b/components/SelectedGeometryPanel.tsx index 6f7da6f..5d347b5 100644 --- a/components/SelectedGeometryPanel.tsx +++ b/components/SelectedGeometryPanel.tsx @@ -10,18 +10,7 @@ import { findEntityTypeOption, groupEntityTypeOptions, } from "@/lib/entityTypeOptions"; - -type EntityFormState = { - name: string; - slug: string; - type_id: string; -}; - -type GeometryMetaFormState = { - time_start: string; - time_end: string; - binding: string; -}; +import type { EntityFormState, GeometryMetaFormState } from "@/lib/editor/session/sessionTypes"; type Props = { selectedFeature: Feature | null; diff --git a/components/TimelineBar.tsx b/components/TimelineBar.tsx index 4daefe4..e44710c 100644 --- a/components/TimelineBar.tsx +++ b/components/TimelineBar.tsx @@ -1,5 +1,7 @@ "use client"; +import { FIXED_TIMELINE_END_YEAR, FIXED_TIMELINE_START_YEAR, clampYearValue } from "@/lib/timeline"; + type Props = { year: number; onYearChange: (year: number) => void; @@ -8,9 +10,6 @@ type Props = { statusText?: string | null; }; -const FIXED_TIMELINE_START_YEAR = -2000; -const FIXED_TIMELINE_END_YEAR = 2000; - export default function TimelineBar({ year, onYearChange, @@ -21,14 +20,14 @@ export default function TimelineBar({ const lower = FIXED_TIMELINE_START_YEAR; const upper = FIXED_TIMELINE_END_YEAR; const effectiveDisabled = disabled; - const safeYear = clampYear(year, lower, upper); + const safeYear = clampYearValue(year, lower, upper); const helperText = isLoading ? "Đang tải geometry theo mốc thời gian..." : statusText || "Kéo thanh hoặc nhập số năm để query chính xác."; const handleYearChange = (nextYear: number) => { - onYearChange(clampYear(Math.trunc(nextYear), lower, upper)); + onYearChange(clampYearValue(Math.trunc(nextYear), lower, upper)); }; return ( @@ -75,22 +74,22 @@ export default function TimelineBar({ gap: "10px", }} > - handleYearChange(Number(event.target.value))} - disabled={effectiveDisabled} - aria-label="Timeline year" - style={{ - width: "100%", - accentColor: "#22c55e", - cursor: effectiveDisabled ? "not-allowed" : "pointer", - opacity: effectiveDisabled ? 0.6 : 1, - }} - /> + disabled={effectiveDisabled} + aria-label="Timeline year" + style={{ + width: "100%", + accentColor: "#22c55e", + cursor: effectiveDisabled ? "not-allowed" : "pointer", + opacity: effectiveDisabled ? 0.6 : 1, + }} + /> maxYear) return maxYear; - return year; -} - function formatYear(year: number): string { if (year < 0) { return `${Math.abs(year)} TCN`; diff --git a/lib/editor/draft/useDraftState.ts b/lib/editor/draft/useDraftState.ts index 4bedb52..bd4a486 100644 --- a/lib/editor/draft/useDraftState.ts +++ b/lib/editor/draft/useDraftState.ts @@ -3,7 +3,9 @@ import type { FeatureCollection } from "@/types/geo"; import { deepClone } from "@/lib/editor/draft/draftDiff"; export function useDraftState(initialData: FeatureCollection) { + // Draft hiện tại (React state) để UI re-render khi dữ liệu thay đổi. const [draft, setDraft] = useState(() => deepClone(initialData)); + // Draft ref để đọc giá trị mới nhất trong event handlers/engines mà không cần deps. const draftRef = useRef(deepClone(initialData)); const commitDraft = useCallback((nextDraft: FeatureCollection) => { diff --git a/lib/editor/draft/useUndoStack.ts b/lib/editor/draft/useUndoStack.ts index 9d36c8f..17f1d9a 100644 --- a/lib/editor/draft/useUndoStack.ts +++ b/lib/editor/draft/useUndoStack.ts @@ -8,6 +8,7 @@ type Options = { export function useUndoStack(options: Options) { const { applyUndoAction } = options; + // Stack thao tác undo (append-only, pop khi undo). const [undoStack, setUndoStack] = useState([]); const pushUndo = useCallback((action: UndoAction) => { diff --git a/lib/editor/session/useBackgroundSessionState.ts b/lib/editor/session/useBackgroundSessionState.ts index 2684d3b..ad20a30 100644 --- a/lib/editor/session/useBackgroundSessionState.ts +++ b/lib/editor/session/useBackgroundSessionState.ts @@ -5,9 +5,11 @@ import { } from "@/lib/backgroundLayers"; export function useBackgroundSessionState() { + // Trạng thái bật/tắt layer nền (khởi tạo default hidden; sẽ load từ storage ở page). const [backgroundVisibility, setBackgroundVisibility] = useState( () => ({ ...HIDDEN_BACKGROUND_LAYER_VISIBILITY }) ); + // Đảm bảo đã load visibility trước khi render map thật. const [isBackgroundVisibilityReady, setIsBackgroundVisibilityReady] = useState(false); return { @@ -17,4 +19,3 @@ export function useBackgroundSessionState() { setIsBackgroundVisibilityReady, }; } - diff --git a/lib/editor/session/useEntitySessionState.ts b/lib/editor/session/useEntitySessionState.ts index 7c50ac5..967f2c6 100644 --- a/lib/editor/session/useEntitySessionState.ts +++ b/lib/editor/session/useEntitySessionState.ts @@ -10,27 +10,41 @@ import type { } from "@/lib/editor/session/sessionTypes"; export function useEntitySessionState() { + // Entities đã persisted từ backend (dùng cho search/binding). const [persistedEntities, setPersistedEntities] = useState([]); + // Entities tạo mới trong phiên nhưng chưa commit lên backend. const [pendingEntityCreates, setPendingEntityCreates] = useState([]); + // Tóm tắt entities đã tạo (để hiển thị nhanh ở sidebar). const [createdEntities, setCreatedEntities] = useState([]); + // Thông báo trạng thái/lỗi liên quan entity/session. const [entityStatus, setEntityStatus] = useState(null); + // Feature đang được chọn để thao tác bind entities/metadata. const [selectedFeatureId, setSelectedFeatureId] = useState(null); + // Form tạo entity mới (độc lập). const [entityForm, setEntityForm] = useState({ name: "", slug: "", type_id: DEFAULT_ENTITY_TYPE_ID, }); + // Danh sách entity IDs đang chọn để bind vào geometry hiện tại. const [selectedGeometryEntityIds, setSelectedGeometryEntityIds] = useState([]); + // Form metadata geometry (time range + binding ids). const [geometryMetaForm, setGeometryMetaForm] = useState({ time_start: "", time_end: "", binding: "", }); + // Cờ loading khi apply entity/metadata (local submit). const [isEntitySubmitting, setIsEntitySubmitting] = useState(false); + // Thông báo trạng thái/lỗi cho form entity/metadata. const [entityFormStatus, setEntityFormStatus] = useState(null); + // Keyword search entity theo name. const [entitySearchQuery, setEntitySearchQuery] = useState(""); + // Kết quả search entity để user chọn. const [entitySearchResults, setEntitySearchResults] = useState([]); + // Entity ID đang được chọn trong dropdown kết quả search. const [selectedSearchEntityId, setSelectedSearchEntityId] = useState(null); + // Cờ loading khi search entity. const [isEntitySearchLoading, setIsEntitySearchLoading] = useState(false); return { diff --git a/lib/editor/session/useSectionSessionState.ts b/lib/editor/session/useSectionSessionState.ts index 5a8f13c..1a0bf5c 100644 --- a/lib/editor/session/useSectionSessionState.ts +++ b/lib/editor/session/useSectionSessionState.ts @@ -9,6 +9,7 @@ type Options = { type SectionTask = "idle" | "saving" | "submitting" | "opening-section"; export function useSectionSessionState(options: Options) { + // Single state machine cho các tác vụ async của section (saving/submitting/opening). const [sectionTask, setSectionTask] = useState("idle"); const setTaskFlag = useCallback((task: Exclude, next: SetStateAction) => { setSectionTask((prev) => { @@ -32,15 +33,25 @@ export function useSectionSessionState(options: Options) { setTaskFlag("opening-section", next); }, [setTaskFlag]); + // Danh sách sections để user chọn mở. const [availableSections, setAvailableSections] = useState([]); + // Section ID đang được chọn trong dropdown. const [selectedSectionId, setSelectedSectionId] = useState(""); + // Title section mới (để create). const [newSectionTitle, setNewSectionTitle] = useState(""); + // Input title cho commit. const [commitTitle, setCommitTitle] = useState(""); + // Input note cho commit. const [commitNote, setCommitNote] = useState(""); + // User ID dùng để gắn vào commit/submit/lock. const [editorUserIdInput, setEditorUserIdInput] = useState(options.defaultEditorUserId); + // Section đang mở để edit (null nếu chưa mở). const [activeSection, setActiveSection] = useState
(null); + // Trạng thái section (version/head/status/lock). const [sectionState, setSectionState] = useState(null); + // Danh sách commits của section đang mở. const [sectionCommits, setSectionCommits] = useState([]); + // Snapshot gần nhất đã load (để build snapshot diff/metadata). const [lastSectionSnapshot, setLastSectionSnapshot] = useState(null); return { diff --git a/lib/editor/session/useTimelineState.ts b/lib/editor/session/useTimelineState.ts index 82f3e08..a166be9 100644 --- a/lib/editor/session/useTimelineState.ts +++ b/lib/editor/session/useTimelineState.ts @@ -1,5 +1,6 @@ import { useState } from "react"; import type { TimelineRange } from "@/lib/editor/session/sessionTypes"; +import { clampYearValue } from "@/lib/timeline"; type Options = { currentYear: number; @@ -7,6 +8,7 @@ type Options = { }; export function useTimelineState(options: Options) { + // Năm timeline "đã chốt" để fetch dữ liệu. const [timelineYear, setTimelineYear] = useState(() => clampYearValue( options.currentYear, @@ -14,6 +16,7 @@ export function useTimelineState(options: Options) { options.fallbackTimelineRange.max ) ); + // Năm timeline đang chỉnh (debounce rồi đẩy sang timelineYear). const [timelineDraftYear, setTimelineDraftYear] = useState(() => clampYearValue( options.currentYear, @@ -21,7 +24,9 @@ export function useTimelineState(options: Options) { options.fallbackTimelineRange.max ) ); + // Cờ loading khi fetch theo timeline. const [isTimelineLoading, setIsTimelineLoading] = useState(false); + // Thông báo trạng thái/lỗi khi fetch theo timeline. const [timelineStatus, setTimelineStatus] = useState(null); return { @@ -35,11 +40,3 @@ export function useTimelineState(options: Options) { setTimelineStatus, }; } - -function clampYearValue(year: number, minYear: number, maxYear: number): number { - const lower = Math.min(minYear, maxYear); - const upper = Math.max(minYear, maxYear); - if (year < lower) return lower; - if (year > upper) return upper; - return year; -} diff --git a/lib/engine/circleEngine.ts b/lib/engine/circleEngine.ts index dbceaee..7db4bab 100644 --- a/lib/engine/circleEngine.ts +++ b/lib/engine/circleEngine.ts @@ -1,7 +1,6 @@ import maplibregl from "maplibre-gl"; import { Geometry } from "@/lib/useEditorState"; - -type ModeGetter = () => "idle" | "draw" | "select" | "add-point" | "add-line" | "add-path" | "add-circle"; +import type { ModeGetter } from "@/lib/engine/engineTypes"; const EARTH_RADIUS_METERS = 6371008.8; const CIRCLE_SEGMENTS = 72; diff --git a/lib/engine/drawingEngine.ts b/lib/engine/drawingEngine.ts index 5eb4820..60e1d22 100644 --- a/lib/engine/drawingEngine.ts +++ b/lib/engine/drawingEngine.ts @@ -1,7 +1,6 @@ import maplibregl from "maplibre-gl"; import { Geometry } from "@/lib/useEditorState"; - -type ModeGetter = () => "idle" | "draw" | "select" | "add-point" | "add-line" | "add-path" | "add-circle"; +import type { ModeGetter } from "@/lib/engine/engineTypes"; // Khởi tạo engine vẽ polygon tự do theo chuỗi click. export function initDrawing( diff --git a/lib/engine/engineTypes.ts b/lib/engine/engineTypes.ts new file mode 100644 index 0000000..a689d23 --- /dev/null +++ b/lib/engine/engineTypes.ts @@ -0,0 +1,4 @@ +import type { EditorMode } from "@/lib/editor/session/sessionTypes"; + +export type ModeGetter = () => EditorMode; + diff --git a/lib/engine/lineEngine.ts b/lib/engine/lineEngine.ts index f09629e..aa79c5f 100644 --- a/lib/engine/lineEngine.ts +++ b/lib/engine/lineEngine.ts @@ -1,7 +1,6 @@ import maplibregl from "maplibre-gl"; import { Geometry } from "@/lib/useEditorState"; - -type ModeGetter = () => "idle" | "draw" | "select" | "add-point" | "add-line" | "add-path" | "add-circle"; +import type { ModeGetter } from "@/lib/engine/engineTypes"; const EMPTY_PREVIEW: GeoJSON.FeatureCollection = { type: "FeatureCollection", diff --git a/lib/engine/pathEngine.ts b/lib/engine/pathEngine.ts index b243fea..0046a51 100644 --- a/lib/engine/pathEngine.ts +++ b/lib/engine/pathEngine.ts @@ -1,7 +1,6 @@ import maplibregl from "maplibre-gl"; import { Geometry } from "@/lib/useEditorState"; - -type ModeGetter = () => "idle" | "draw" | "select" | "add-point" | "add-line" | "add-path" | "add-circle"; +import type { ModeGetter } from "@/lib/engine/engineTypes"; const EMPTY_PREVIEW: GeoJSON.FeatureCollection = { type: "FeatureCollection", diff --git a/lib/engine/pointEngine.ts b/lib/engine/pointEngine.ts index 754fb25..fdac124 100644 --- a/lib/engine/pointEngine.ts +++ b/lib/engine/pointEngine.ts @@ -1,7 +1,6 @@ import maplibregl from "maplibre-gl"; import { Geometry } from "@/lib/useEditorState"; - -type ModeGetter = () => "idle" | "draw" | "select" | "add-point" | "add-line" | "add-path" | "add-circle"; +import type { ModeGetter } from "@/lib/engine/engineTypes"; // Khởi tạo engine thêm point bằng click đơn. export function initPoint( diff --git a/lib/engine/selectingEngine.ts b/lib/engine/selectingEngine.ts index c760991..70b8f24 100644 --- a/lib/engine/selectingEngine.ts +++ b/lib/engine/selectingEngine.ts @@ -1,6 +1,5 @@ import maplibregl from "maplibre-gl"; - -type ModeGetter = () => "idle" | "draw" | "select" | "add-point" | "add-line" | "add-path" | "add-circle"; +import type { ModeGetter } from "@/lib/engine/engineTypes"; // Khởi tạo engine chọn feature và context menu edit/delete. export function initSelect( diff --git a/lib/geo/constants.ts b/lib/geo/constants.ts new file mode 100644 index 0000000..93cbf72 --- /dev/null +++ b/lib/geo/constants.ts @@ -0,0 +1,14 @@ +import type { FeatureCollection } from "@/types/geo"; + +export const WORLD_BBOX = { + minLng: -180, + minLat: -90, + maxLng: 180, + maxLat: 90, +} as const; + +export const EMPTY_FEATURE_COLLECTION: FeatureCollection = { + type: "FeatureCollection", + features: [], +}; + diff --git a/lib/map/constants.ts b/lib/map/constants.ts new file mode 100644 index 0000000..f090651 --- /dev/null +++ b/lib/map/constants.ts @@ -0,0 +1,13 @@ +export const DEFAULT_POINT_ICON_ID = "point-icon-default"; +export const POINT_ICON_URL = "/point.png"; +export const PATH_ARROW_ICON_ID = "path-arrow-icon"; + +export const MAP_MIN_ZOOM = 1; +export const MAP_MAX_ZOOM = 10; + +export const RASTER_BASE_SOURCE_ID = "rasterBase"; +export const RASTER_BASE_LAYER_ID = "raster-base-layer"; +export const RASTER_BASE_INSERT_BEFORE_LAYER_ID = "graticules-line"; + +export const PATH_ARROW_SOURCE_ID = "path-arrow-shapes"; +export const FEATURE_STATE_SOURCE_IDS = ["countries", "places", PATH_ARROW_SOURCE_ID] as const; diff --git a/lib/map/style.ts b/lib/map/style.ts new file mode 100644 index 0000000..31b420c --- /dev/null +++ b/lib/map/style.ts @@ -0,0 +1,76 @@ +import maplibregl from "maplibre-gl"; + +export const COUNTRY_COLOR_KEY_EXPRESSION: maplibregl.ExpressionSpecification = [ + "coalesce", + ["get", "MAPCOLOR7"], + ["get", "MAPCOLOR9"], + ["get", "scalerank"], + 0, +]; + +export const COUNTRY_FILL_COLOR_EXPRESSION: maplibregl.ExpressionSpecification = [ + "match", + COUNTRY_COLOR_KEY_EXPRESSION, + 1, "#ef4444", + 2, "#f97316", + 3, "#f59e0b", + 4, "#22c55e", + 5, "#06b6d4", + 6, "#3b82f6", + 7, "#8b5cf6", + 8, "#a855f7", + 9, "#d946ef", + 10, "#14b8a6", + "#64748b", +]; + +export const POLYGON_FILL_BY_TYPE: Record = { + country: "#2563eb", + state: "#0ea5e9", + empire: "#f59e0b", + kingdom: "#d97706", + war: "#dc2626", + battle: "#f43f5e", + civilization: "#14b8a6", + rebellion_zone: "#7c3aed", +}; + +export const POLYGON_STROKE_BY_TYPE: Record = { + country: "#1e3a8a", + state: "#0c4a6e", + empire: "#7c2d12", + kingdom: "#9a3412", + war: "#7f1d1d", + battle: "#9f1239", + civilization: "#134e4a", + rebellion_zone: "#4c1d95", +}; + +export const POLYGON_OPACITY_BY_TYPE: Record = { + war: 0.3, + battle: 0.34, + civilization: 0.38, + rebellion_zone: 0.32, +}; + +export const LINE_COLOR_BY_TYPE: Record = { + defense_line: "#f97316", + attack_route: "#ef4444", + retreat_route: "#94a3b8", + invasion_route: "#b91c1c", + migration_route: "#0ea5e9", + refugee_route: "#06b6d4", + trade_route: "#eab308", + shipping_route: "#2563eb", +}; + +export const PATH_RENDER_BY_TYPE: Record = { + attack_route: true, + retreat_route: true, + invasion_route: true, + migration_route: true, + refugee_route: true, + trade_route: true, + shipping_route: true, +}; + diff --git a/lib/timeline.ts b/lib/timeline.ts new file mode 100644 index 0000000..cbce653 --- /dev/null +++ b/lib/timeline.ts @@ -0,0 +1,25 @@ +import type { TimelineRange } from "@/lib/editor/session/sessionTypes"; + +// Single source of truth for the app-wide timeline range. +export const FIXED_TIMELINE_START_YEAR = -2000; +export const FIXED_TIMELINE_END_YEAR = 2000; + +export const FIXED_TIMELINE_RANGE: TimelineRange = { + min: FIXED_TIMELINE_START_YEAR, + max: FIXED_TIMELINE_END_YEAR, +}; + +// UI debounce when user drags timeline before triggering data fetch. +export const TIMELINE_DEBOUNCE_MS = 180; + +export function clampYearValue(year: number, minYear: number, maxYear: number): number { + const lower = Math.min(minYear, maxYear); + const upper = Math.max(minYear, maxYear); + if (year < lower) return lower; + if (year > upper) return upper; + return year; +} + +export function clampYearToFixedRange(year: number): number { + return clampYearValue(year, FIXED_TIMELINE_START_YEAR, FIXED_TIMELINE_END_YEAR); +} diff --git a/lib/useEditorSessionState.ts b/lib/useEditorSessionState.ts index 2961545..f04dc81 100644 --- a/lib/useEditorSessionState.ts +++ b/lib/useEditorSessionState.ts @@ -23,7 +23,9 @@ type Options = { }; export function useEditorSessionState(options: Options) { + // Mode thao tác map/editor hiện tại. const [mode, setMode] = useState("idle"); + // FeatureCollection "gốc" của session hiện tại (global timeline hoặc section snapshot). const [initialData, setInitialData] = useState(options.emptyFeatureCollection); const section = useSectionSessionState({ diff --git a/lib/useEditorState.ts b/lib/useEditorState.ts index 7faf752..d843601 100644 --- a/lib/useEditorState.ts +++ b/lib/useEditorState.ts @@ -20,9 +20,11 @@ export type { Change, UndoAction } from "@/lib/editor/draft/editorTypes"; export function useEditorState(initialData: FeatureCollection) { const { draft, draftRef, commitDraft, resetDraft } = useDraftState(initialData); + // Map baseline (id -> feature) để diff draft hiện tại ra changes. const initialMapRef = useRef>( buildInitialMap(initialData) ); + // Version counter để ép diff recalculation sau khi reset/clear baseline. const [baselineVersion, setBaselineVersion] = useState(0); const applyUndoAction = useCallback((action: UndoAction): boolean => {