add somenew UI editor feature for more effêcncy
This commit is contained in:
@@ -0,0 +1,281 @@
|
||||
"use client";
|
||||
|
||||
import type { CSSProperties, ReactNode } from "react";
|
||||
import type { Entity } from "@/uhm/api/entities";
|
||||
import type { EntityGeometriesSearchItem, EntityGeometrySearchGeo } from "@/uhm/api/geometries";
|
||||
import type { Wiki } from "@/uhm/api/wikis";
|
||||
import UnifiedSearchBar, { type UnifiedSearchKind } from "@/uhm/components/ui/UnifiedSearchBar";
|
||||
|
||||
type EditorSearchResultsProps = {
|
||||
searchKind: UnifiedSearchKind;
|
||||
onSearchKindChange: (kind: UnifiedSearchKind) => void;
|
||||
searchQuery: string;
|
||||
onSearchQueryChange: (query: string) => void;
|
||||
onLocalSearchQueryChange: (query: string) => void;
|
||||
searchQueryDraft: string;
|
||||
entitySearchResults: Entity[];
|
||||
isEntitySearchLoading: boolean;
|
||||
onAddEntityRefToProject: (entity: Entity) => void;
|
||||
wikiSearchResults: Wiki[];
|
||||
isWikiSearching: boolean;
|
||||
onAddWikiRefToProject: (wiki: Wiki) => void;
|
||||
geoSearchResults: EntityGeometriesSearchItem[];
|
||||
isGeoSearching: boolean;
|
||||
onImportGeoFromSearch: (
|
||||
entityItem: EntityGeometriesSearchItem,
|
||||
geo: EntityGeometrySearchGeo
|
||||
) => void;
|
||||
};
|
||||
|
||||
export function EditorSearchResults({
|
||||
searchKind,
|
||||
onSearchKindChange,
|
||||
searchQuery,
|
||||
onSearchQueryChange,
|
||||
onLocalSearchQueryChange,
|
||||
searchQueryDraft,
|
||||
entitySearchResults,
|
||||
isEntitySearchLoading,
|
||||
onAddEntityRefToProject,
|
||||
wikiSearchResults,
|
||||
isWikiSearching,
|
||||
onAddWikiRefToProject,
|
||||
geoSearchResults,
|
||||
isGeoSearching,
|
||||
onImportGeoFromSearch,
|
||||
}: EditorSearchResultsProps) {
|
||||
// Draft query quyết định có render kết quả hay không; query chính đã debounce ở page.
|
||||
const hasQuery = searchQueryDraft.trim().length > 0;
|
||||
|
||||
return (
|
||||
<>
|
||||
<UnifiedSearchBar
|
||||
kind={searchKind}
|
||||
onKindChange={onSearchKindChange}
|
||||
query={searchQuery}
|
||||
onQueryChange={onSearchQueryChange}
|
||||
onLocalQueryChange={onLocalSearchQueryChange}
|
||||
/>
|
||||
|
||||
{searchKind === "entity" && hasQuery ? (
|
||||
<SearchBox
|
||||
title="Entity Results"
|
||||
status={isEntitySearchLoading ? "Searching..." : `${entitySearchResults.length} results`}
|
||||
>
|
||||
{entitySearchResults.slice(0, 8).map((entity) => (
|
||||
<ResultRow
|
||||
key={entity.id}
|
||||
title={entity.name}
|
||||
subtitle={entity.id}
|
||||
actionLabel="Add"
|
||||
actionTitle="Add entity ref to project snapshot"
|
||||
onAction={() => onAddEntityRefToProject(entity)}
|
||||
/>
|
||||
))}
|
||||
{!isEntitySearchLoading && entitySearchResults.length === 0 ? <EmptyResult /> : null}
|
||||
</SearchBox>
|
||||
) : null}
|
||||
|
||||
{searchKind === "wiki" && hasQuery ? (
|
||||
<SearchBox
|
||||
title="Wiki Results"
|
||||
status={isWikiSearching ? "Searching..." : `${wikiSearchResults.length} results`}
|
||||
>
|
||||
{wikiSearchResults.slice(0, 8).map((wiki) => (
|
||||
<ResultRow
|
||||
key={wiki.id}
|
||||
title={(wiki.title || "").trim() || "Untitled wiki"}
|
||||
subtitle={wiki.id}
|
||||
actionLabel="Add"
|
||||
actionTitle="Add wiki ref to project snapshot"
|
||||
onAction={() => onAddWikiRefToProject(wiki)}
|
||||
/>
|
||||
))}
|
||||
{!isWikiSearching && wikiSearchResults.length === 0 ? <EmptyResult /> : null}
|
||||
</SearchBox>
|
||||
) : null}
|
||||
|
||||
{searchKind === "geo" && hasQuery ? (
|
||||
<SearchBox
|
||||
title="Geo Results"
|
||||
status={isGeoSearching ? "Searching..." : `${geoSearchResults.length} entities`}
|
||||
>
|
||||
{geoSearchResults.slice(0, 6).map((item) => (
|
||||
<GeoResultGroup
|
||||
key={item.entity_id}
|
||||
item={item}
|
||||
onImportGeoFromSearch={onImportGeoFromSearch}
|
||||
/>
|
||||
))}
|
||||
{!isGeoSearching && geoSearchResults.length === 0 ? <EmptyResult /> : null}
|
||||
</SearchBox>
|
||||
) : null}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function SearchBox({
|
||||
title,
|
||||
status,
|
||||
children,
|
||||
}: {
|
||||
title: string;
|
||||
status: string;
|
||||
children: ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<div style={{ padding: 10, background: "#0b1220", borderRadius: 8, border: "1px solid #1f2937" }}>
|
||||
<div style={{ display: "flex", alignItems: "center", justifyContent: "space-between", gap: 8 }}>
|
||||
<div style={{ fontWeight: 700, fontSize: 13, color: "white" }}>{title}</div>
|
||||
<div style={{ fontSize: 12, color: "#94a3b8" }}>{status}</div>
|
||||
</div>
|
||||
<div style={{ marginTop: 8, display: "grid", gap: 6 }}>{children}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ResultRow({
|
||||
title,
|
||||
subtitle,
|
||||
actionLabel,
|
||||
actionTitle,
|
||||
onAction,
|
||||
}: {
|
||||
title: string;
|
||||
subtitle: string;
|
||||
actionLabel: string;
|
||||
actionTitle: string;
|
||||
onAction: () => void;
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: 8,
|
||||
padding: 8,
|
||||
borderRadius: 6,
|
||||
border: "1px solid #1f2937",
|
||||
background: "transparent",
|
||||
}}
|
||||
>
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<div style={{ color: "#e5e7eb", fontSize: 12, whiteSpace: "nowrap", overflow: "hidden", textOverflow: "ellipsis" }}>
|
||||
{title}
|
||||
</div>
|
||||
<div style={{ color: "#94a3b8", fontSize: 11, whiteSpace: "nowrap", overflow: "hidden", textOverflow: "ellipsis" }}>
|
||||
{subtitle}
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onAction}
|
||||
style={actionButtonStyle}
|
||||
title={actionTitle}
|
||||
>
|
||||
{actionLabel}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function GeoResultGroup({
|
||||
item,
|
||||
onImportGeoFromSearch,
|
||||
}: {
|
||||
item: EntityGeometriesSearchItem;
|
||||
onImportGeoFromSearch: (
|
||||
entityItem: EntityGeometriesSearchItem,
|
||||
geo: EntityGeometrySearchGeo
|
||||
) => void;
|
||||
}) {
|
||||
const geometries = Array.isArray(item.geometries) ? item.geometries : [];
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
padding: 8,
|
||||
borderRadius: 6,
|
||||
border: "1px solid #1f2937",
|
||||
background: "transparent",
|
||||
display: "grid",
|
||||
gap: 6,
|
||||
}}
|
||||
>
|
||||
<div style={{ display: "flex", alignItems: "center", justifyContent: "space-between", gap: 8 }}>
|
||||
<div style={{ minWidth: 0 }}>
|
||||
<div style={{ color: "#e5e7eb", fontSize: 12, fontWeight: 700, whiteSpace: "nowrap", overflow: "hidden", textOverflow: "ellipsis" }}>
|
||||
{item.name?.trim() || item.entity_id}
|
||||
</div>
|
||||
<div style={{ color: "#94a3b8", fontSize: 11, whiteSpace: "nowrap", overflow: "hidden", textOverflow: "ellipsis" }}>
|
||||
{item.entity_id}
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ fontSize: 12, color: "#94a3b8", flex: "0 0 auto" }}>
|
||||
{geometries.length} geos
|
||||
</div>
|
||||
</div>
|
||||
{item.description?.trim() ? (
|
||||
<div style={{ color: "#cbd5e1", fontSize: 12, lineHeight: 1.35 }}>
|
||||
{item.description.trim()}
|
||||
</div>
|
||||
) : null}
|
||||
{geometries.length ? (
|
||||
<div style={{ display: "grid", gap: 6, maxHeight: 200, overflowY: "auto", paddingRight: 4 }}>
|
||||
{geometries.map((geo) => (
|
||||
<div
|
||||
key={geo.id}
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "space-between",
|
||||
gap: 8,
|
||||
padding: 8,
|
||||
borderRadius: 6,
|
||||
border: "1px solid #243244",
|
||||
background: "#0f172a",
|
||||
}}
|
||||
>
|
||||
<div style={{ minWidth: 0 }}>
|
||||
<div style={{ color: "#e5e7eb", fontSize: 12, whiteSpace: "nowrap", overflow: "hidden", textOverflow: "ellipsis" }}>
|
||||
#{geo.id}
|
||||
</div>
|
||||
<div style={{ color: "#94a3b8", fontSize: 11 }}>
|
||||
type: {geo.type || "unknown"}{" "}
|
||||
{geo.time_start != null || geo.time_end != null
|
||||
? `| time: ${geo.time_start ?? "?"} -> ${geo.time_end ?? "?"}`
|
||||
: ""}
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onImportGeoFromSearch(item, geo)}
|
||||
style={{ ...actionButtonStyle, flex: "0 0 auto" }}
|
||||
title="Import geometry into current editor draft"
|
||||
>
|
||||
Import
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div style={{ fontSize: 12, color: "#94a3b8" }}>No geometry linked.</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function EmptyResult() {
|
||||
return <div style={{ fontSize: 12, color: "#94a3b8" }}>No results.</div>;
|
||||
}
|
||||
|
||||
const actionButtonStyle: CSSProperties = {
|
||||
border: "none",
|
||||
background: "#111827",
|
||||
color: "#93c5fd",
|
||||
cursor: "pointer",
|
||||
borderRadius: 6,
|
||||
padding: "6px 8px",
|
||||
fontSize: 12,
|
||||
fontWeight: 700,
|
||||
};
|
||||
@@ -0,0 +1,49 @@
|
||||
"use client";
|
||||
|
||||
import type { PointerEvent as ReactPointerEvent } from "react";
|
||||
|
||||
type ResizeHandleProps = {
|
||||
onDrag: (deltaX: number) => void;
|
||||
title: string;
|
||||
};
|
||||
|
||||
export function ResizeHandle({ onDrag, title }: ResizeHandleProps) {
|
||||
// Theo dõi pointer toàn window để resize vẫn mượt khi cursor đi ra khỏi handle.
|
||||
const handlePointerDown = (event: ReactPointerEvent<HTMLDivElement>) => {
|
||||
event.preventDefault();
|
||||
const startX = event.clientX;
|
||||
let lastX = startX;
|
||||
|
||||
const onMove = (e: PointerEvent) => {
|
||||
const deltaX = e.clientX - lastX;
|
||||
if (deltaX !== 0) {
|
||||
onDrag(deltaX);
|
||||
lastX = e.clientX;
|
||||
}
|
||||
};
|
||||
const onUp = () => {
|
||||
window.removeEventListener("pointermove", onMove);
|
||||
window.removeEventListener("pointerup", onUp);
|
||||
};
|
||||
|
||||
window.addEventListener("pointermove", onMove);
|
||||
window.addEventListener("pointerup", onUp);
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
role="separator"
|
||||
aria-orientation="vertical"
|
||||
title={title}
|
||||
onPointerDown={handlePointerDown}
|
||||
style={{
|
||||
width: 6,
|
||||
cursor: "col-resize",
|
||||
background: "rgba(148, 163, 184, 0.08)",
|
||||
borderLeft: "1px solid rgba(148, 163, 184, 0.18)",
|
||||
borderRight: "1px solid rgba(148, 163, 184, 0.18)",
|
||||
flex: "0 0 auto",
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,141 @@
|
||||
import type { ProjectCommit } from "@/uhm/api/projects";
|
||||
import type { EntitySnapshot } from "@/uhm/types/entities";
|
||||
import type { Feature, Geometry } from "@/uhm/types/geo";
|
||||
import type { BattleReplay } from "@/uhm/types/projects";
|
||||
import type { WikiSnapshot } from "@/uhm/types/wiki";
|
||||
|
||||
// Giới hạn kích thước panel khi drag resize để tránh layout bị vỡ.
|
||||
export function clampNumber(value: number, min: number, max: number): number {
|
||||
if (value < min) return min;
|
||||
if (value > max) return max;
|
||||
return value;
|
||||
}
|
||||
|
||||
// Tạo label ngắn cho commit history, ưu tiên summary người dùng nhập.
|
||||
export function formatCommitTitle(commit: ProjectCommit): string {
|
||||
return commit.edit_summary?.trim() || `Commit ${commit.id.slice(0, 8)}`;
|
||||
}
|
||||
|
||||
// Kiểm tra feature có nằm trong năm timeline đang active hay không.
|
||||
export function isFeatureVisibleAtYear(feature: Feature, year: number): boolean {
|
||||
const start = feature.properties.time_start;
|
||||
const end = feature.properties.time_end;
|
||||
if (typeof start === "number" && Number.isFinite(start) && year < start) return false;
|
||||
if (typeof end === "number" && Number.isFinite(end) && year > end) return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
// Chuẩn hóa wiki snapshot để so sánh dirty-state ổn định, không phụ thuộc thứ tự mảng.
|
||||
export function normalizeWikisForCompare(input: WikiSnapshot[] | null | undefined) {
|
||||
const list = Array.isArray(input) ? input : [];
|
||||
return list
|
||||
.filter((w) => w && typeof w.id === "string" && w.id.trim().length > 0)
|
||||
.filter((w) => {
|
||||
if (w.source === "ref") return true;
|
||||
if (w.operation === "create" || w.operation === "update" || w.operation === "delete") return true;
|
||||
const title = typeof w.title === "string" ? w.title.trim() : "";
|
||||
const doc = typeof w.doc === "string" ? w.doc.trim() : "";
|
||||
return title.length > 0 || (w.doc !== null && doc.length > 0);
|
||||
})
|
||||
.map((w) => ({
|
||||
id: w.id,
|
||||
source: w.source,
|
||||
title: typeof w.title === "string" ? w.title.trim() : "",
|
||||
slug: typeof w.slug === "string" ? w.slug : null,
|
||||
doc: w.doc === null ? null : typeof w.doc === "string" ? w.doc.trim() : null,
|
||||
}))
|
||||
.sort((a, b) => a.id.localeCompare(b.id));
|
||||
}
|
||||
|
||||
// Chuẩn hóa entity snapshot để phát hiện thay đổi name/description/source.
|
||||
export function normalizeEntitiesForCompare(input: EntitySnapshot[] | null | undefined) {
|
||||
const list = Array.isArray(input) ? input : [];
|
||||
return list
|
||||
.filter((e) => e && (typeof e.id === "string" || typeof e.id === "number"))
|
||||
.map((e) => ({
|
||||
id: String(e.id),
|
||||
source: e.source,
|
||||
name: typeof e.name === "string" ? e.name.trim() : "",
|
||||
description: e.description == null ? null : String(e.description),
|
||||
time_start: typeof e.time_start === "number" ? e.time_start : null,
|
||||
time_end: typeof e.time_end === "number" ? e.time_end : null,
|
||||
}))
|
||||
.sort((a, b) => a.id.localeCompare(b.id));
|
||||
}
|
||||
|
||||
// Chuẩn hóa binding entity-wiki để dirty check không bị nhiễu bởi thứ tự.
|
||||
export function normalizeEntityWikiLinksForCompare(
|
||||
input: Array<{ entity_id: string; wiki_id: string; operation?: string }> | null | undefined
|
||||
) {
|
||||
const list = Array.isArray(input) ? input : [];
|
||||
return list
|
||||
.filter((l) => l && typeof l.entity_id === "string" && typeof l.wiki_id === "string")
|
||||
.map((l) => ({
|
||||
entity_id: l.entity_id,
|
||||
wiki_id: l.wiki_id,
|
||||
operation: l.operation === "delete" ? "delete" : "binding",
|
||||
}))
|
||||
.sort((a, b) => (a.entity_id + a.wiki_id).localeCompare(b.entity_id + b.wiki_id));
|
||||
}
|
||||
|
||||
// Chuẩn hóa replay để phát hiện thay đổi script/target geometry.
|
||||
export function normalizeReplaysForCompare(input: BattleReplay[] | null | undefined) {
|
||||
const list = Array.isArray(input) ? input : [];
|
||||
return list
|
||||
.filter((replay) => replay && typeof replay.geometry_id === "string" && replay.geometry_id.trim().length > 0)
|
||||
.map((replay) => ({
|
||||
id: typeof replay.id === "string" ? replay.id : replay.geometry_id,
|
||||
geometry_id: replay.geometry_id,
|
||||
target_geometry_ids: normalizeReplayTargetGeometryIdsForCompare(
|
||||
replay.target_geometry_ids,
|
||||
replay.geometry_id
|
||||
),
|
||||
detail: Array.isArray(replay.detail) ? replay.detail : [],
|
||||
}))
|
||||
.sort((a, b) => a.geometry_id.localeCompare(b.geometry_id));
|
||||
}
|
||||
|
||||
// Bảo toàn geometry chính ở vị trí đầu và loại bỏ id trùng trong replay target list.
|
||||
function normalizeReplayTargetGeometryIdsForCompare(
|
||||
input: string[] | null | undefined,
|
||||
geometryId: string
|
||||
) {
|
||||
const orderedIds: string[] = [];
|
||||
const seen = new Set<string>();
|
||||
|
||||
const pushId = (rawId: string | number | null | undefined) => {
|
||||
if (rawId == null) return;
|
||||
const id = String(rawId).trim();
|
||||
if (!id || seen.has(id)) return;
|
||||
seen.add(id);
|
||||
orderedIds.push(id);
|
||||
};
|
||||
|
||||
pushId(geometryId);
|
||||
for (const rawId of input || []) pushId(rawId);
|
||||
return orderedIds;
|
||||
}
|
||||
|
||||
// Validate tối thiểu geometry trả về từ search trước khi đưa vào draft.
|
||||
export function normalizeGeoSearchGeometry(value: unknown): Geometry | null {
|
||||
if (!value || typeof value !== "object") return null;
|
||||
const geometry = value as Record<string, unknown>;
|
||||
if (typeof geometry.type !== "string") return null;
|
||||
if (!("coordinates" in geometry)) return null;
|
||||
return value as Geometry;
|
||||
}
|
||||
|
||||
// Chuẩn hóa danh sách binding id từ API search GEO.
|
||||
export function normalizeGeoSearchBindingIds(value: unknown): string[] {
|
||||
if (!Array.isArray(value)) return [];
|
||||
const deduped: string[] = [];
|
||||
const seen = new Set<string>();
|
||||
for (const rawId of value) {
|
||||
if (typeof rawId !== "string" && typeof rawId !== "number") continue;
|
||||
const id = String(rawId).trim();
|
||||
if (!id || seen.has(id)) continue;
|
||||
seen.add(id);
|
||||
deduped.push(id);
|
||||
}
|
||||
return deduped;
|
||||
}
|
||||
@@ -43,6 +43,7 @@ export function useFeatureCommands(options: Options) {
|
||||
setEntityFormStatus,
|
||||
} = options;
|
||||
|
||||
// Áp metadata GEO (type/time/binding) cho toàn bộ selectedFeatures.
|
||||
const applyGeometryMetadata = useCallback(async (): Promise<{ ok: boolean; error?: string }> => {
|
||||
if (!selectedFeatures || selectedFeatures.length === 0) {
|
||||
const msg = "Hãy chọn ít nhất một geometry trước.";
|
||||
@@ -50,12 +51,6 @@ export function useFeatureCommands(options: Options) {
|
||||
return { ok: false, error: msg };
|
||||
}
|
||||
|
||||
if (!geometryMetaForm.time_start.trim() || !geometryMetaForm.time_end.trim()) {
|
||||
const msg = "time_start và time_end là bắt buộc.";
|
||||
setEntityFormStatus(msg);
|
||||
return { ok: false, error: msg };
|
||||
}
|
||||
|
||||
let metadata;
|
||||
try {
|
||||
metadata = buildGeometryMetadataPatch(geometryMetaForm);
|
||||
@@ -90,6 +85,7 @@ export function useFeatureCommands(options: Options) {
|
||||
setIsEntitySubmitting,
|
||||
]);
|
||||
|
||||
// Áp danh sách entity đã chọn vào toàn bộ selectedFeatures.
|
||||
const applyEntitiesToSelectedGeometry = useCallback(async () => {
|
||||
if (!selectedFeatures || selectedFeatures.length === 0) {
|
||||
setEntityFormStatus("Hãy chọn ít nhất một geometry trước.");
|
||||
|
||||
+401
-398
File diff suppressed because it is too large
Load Diff
+128
-4
@@ -1,7 +1,131 @@
|
||||
import { redirect } from "next/navigation";
|
||||
"use client";
|
||||
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { ApiError } from "@/uhm/api/http";
|
||||
import { fetchProjects, type Project } from "@/uhm/api/projects";
|
||||
|
||||
export default function EditorIndexPage() {
|
||||
// Editor must be opened from a specific project (see /user/projects).
|
||||
redirect("/user/projects");
|
||||
}
|
||||
const router = useRouter();
|
||||
// State danh sách project mà user hiện tại có quyền mở trong editor.
|
||||
const [projects, setProjects] = useState<Project[]>([]);
|
||||
// State loading cho lần tải đầu của route /editor.
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
// State lỗi hiển thị trực tiếp khi API hoặc auth không hợp lệ.
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
// Sắp xếp project mới cập nhật lên đầu để user mở nhanh project đang làm.
|
||||
const sortedProjects = useMemo(() => {
|
||||
return [...projects].sort((a, b) => {
|
||||
const aTime = Date.parse(a.updated_at || a.created_at || "");
|
||||
const bTime = Date.parse(b.updated_at || b.created_at || "");
|
||||
return (Number.isFinite(bTime) ? bTime : 0) - (Number.isFinite(aTime) ? aTime : 0);
|
||||
});
|
||||
}, [projects]);
|
||||
|
||||
// Route /editor là landing page: tải project list và để /editor/[id] xử lý editor đầy đủ.
|
||||
useEffect(() => {
|
||||
let disposed = false;
|
||||
|
||||
async function loadProjects() {
|
||||
try {
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
const rows = await fetchProjects();
|
||||
if (!disposed) setProjects(rows || []);
|
||||
} catch (err) {
|
||||
if (disposed) return;
|
||||
if (err instanceof ApiError && err.status === 401) {
|
||||
router.replace("/signin");
|
||||
return;
|
||||
}
|
||||
setError(err instanceof Error ? err.message : "Không tải được danh sách project.");
|
||||
} finally {
|
||||
if (!disposed) setIsLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
void loadProjects();
|
||||
return () => {
|
||||
disposed = true;
|
||||
};
|
||||
}, [router]);
|
||||
|
||||
return (
|
||||
<main style={{ minHeight: "100vh", background: "#0b1220", color: "#e5e7eb", padding: 24 }}>
|
||||
<div style={{ maxWidth: 960, margin: "0 auto" }}>
|
||||
<div style={{ display: "flex", alignItems: "center", justifyContent: "space-between", gap: 16 }}>
|
||||
<div>
|
||||
<h1 style={{ margin: 0, fontSize: 28, fontWeight: 800 }}>Editor</h1>
|
||||
<p style={{ margin: "8px 0 0", color: "#94a3b8", fontSize: 14 }}>
|
||||
Chọn project để mở route <code>/editor/[id]</code>.
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => router.push("/user/projects")}
|
||||
style={{
|
||||
border: "1px solid #334155",
|
||||
background: "#111827",
|
||||
color: "#e5e7eb",
|
||||
borderRadius: 10,
|
||||
padding: "10px 12px",
|
||||
cursor: "pointer",
|
||||
fontWeight: 700,
|
||||
}}
|
||||
>
|
||||
Quản lý project
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<section
|
||||
style={{
|
||||
marginTop: 24,
|
||||
border: "1px solid #1f2937",
|
||||
borderRadius: 16,
|
||||
background: "#111827",
|
||||
overflow: "hidden",
|
||||
}}
|
||||
>
|
||||
{isLoading ? (
|
||||
<div style={{ padding: 24, color: "#94a3b8" }}>Đang tải project...</div>
|
||||
) : error ? (
|
||||
<div style={{ padding: 24, color: "#fecaca" }}>{error}</div>
|
||||
) : sortedProjects.length === 0 ? (
|
||||
<div style={{ padding: 24, color: "#94a3b8" }}>
|
||||
Chưa có project. Vào trang quản lý project để tạo mới.
|
||||
</div>
|
||||
) : (
|
||||
<div style={{ display: "grid" }}>
|
||||
{sortedProjects.map((project) => (
|
||||
<button
|
||||
key={project.id}
|
||||
type="button"
|
||||
onClick={() => router.push(`/editor/${project.id}`)}
|
||||
style={{
|
||||
display: "grid",
|
||||
gap: 6,
|
||||
textAlign: "left",
|
||||
border: 0,
|
||||
borderBottom: "1px solid #1f2937",
|
||||
background: "transparent",
|
||||
color: "#e5e7eb",
|
||||
padding: 16,
|
||||
cursor: "pointer",
|
||||
}}
|
||||
>
|
||||
<span style={{ fontSize: 15, fontWeight: 800 }}>
|
||||
{project.title || `Project ${project.id}`}
|
||||
</span>
|
||||
<span style={{ fontSize: 12, color: "#94a3b8" }}>
|
||||
{project.project_status || "ACTIVE"} · {project.id}
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -249,6 +249,10 @@ export default function Page() {
|
||||
const activeEntityGeometries = activeEntityId
|
||||
? relations.entityGeometriesById[activeEntityId] || EMPTY_FEATURE_COLLECTION
|
||||
: EMPTY_FEATURE_COLLECTION;
|
||||
const mapLabelContextDraft = useMemo(
|
||||
() => buildEntityLabelContextDraft(data, relations.geometryEntityIds, relations.entitiesById),
|
||||
[data, relations.entitiesById, relations.geometryEntityIds]
|
||||
);
|
||||
|
||||
const activeWiki = useMemo(() => {
|
||||
if (!activeWikiSlug) return null;
|
||||
@@ -466,6 +470,8 @@ export default function Page() {
|
||||
<Map
|
||||
mode="select"
|
||||
draft={data}
|
||||
labelContextDraft={mapLabelContextDraft}
|
||||
labelTimelineYear={timelineDraftYear}
|
||||
selectedFeatureIds={selectedFeatureIds}
|
||||
onSelectFeatureIds={setSelectedFeatureIds}
|
||||
backgroundVisibility={backgroundVisibility}
|
||||
@@ -768,6 +774,46 @@ function normalizeRelationArrays(target: Record<string, string[]>) {
|
||||
}
|
||||
}
|
||||
|
||||
function buildEntityLabelContextDraft(
|
||||
draft: FeatureCollection,
|
||||
geometryEntityIds: Record<string, string[]>,
|
||||
entitiesById: Record<string, Entity>
|
||||
): FeatureCollection {
|
||||
if (!draft.features.length) return draft;
|
||||
|
||||
return {
|
||||
...draft,
|
||||
features: draft.features.map((feature) => {
|
||||
const entityIds = geometryEntityIds[String(feature.properties.id)] || [];
|
||||
if (!entityIds.length) return feature;
|
||||
|
||||
const candidates = entityIds.map((id) => {
|
||||
const entity = entitiesById[id] || null;
|
||||
const name = String(entity?.name || id).trim();
|
||||
if (!name) return null;
|
||||
return {
|
||||
id,
|
||||
name,
|
||||
time_start: entity?.time_start ?? null,
|
||||
time_end: entity?.time_end ?? null,
|
||||
};
|
||||
}).filter((candidate) => candidate !== null);
|
||||
|
||||
return {
|
||||
...feature,
|
||||
properties: {
|
||||
...feature.properties,
|
||||
entity_id: entityIds[0] || null,
|
||||
entity_ids: entityIds,
|
||||
entity_name: candidates[0]?.name || null,
|
||||
entity_names: candidates.map((candidate) => candidate.name),
|
||||
entity_label_candidates: candidates,
|
||||
},
|
||||
};
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
function clampNumber(value: number, min: number, max: number): number {
|
||||
if (!Number.isFinite(value)) return min;
|
||||
if (value < min) return min;
|
||||
|
||||
Reference in New Issue
Block a user