json import export project | UI cleaner | binding geometry to each other
This commit is contained in:
+173
-18
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useMemo, useRef, useState, type PointerEvent as ReactPointerEvent } from "react";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState, type SetStateAction, type PointerEvent as ReactPointerEvent } from "react";
|
||||
import { useParams, useRouter, useSearchParams } from "next/navigation";
|
||||
import Map from "@/uhm/components/Map";
|
||||
import Editor from "@/uhm/components/Editor";
|
||||
@@ -10,6 +10,7 @@ import SelectedGeometryPanel from "@/uhm/components/SelectedGeometryPanel";
|
||||
import WikiSidebarPanel from "@/uhm/components/WikiSidebarPanel";
|
||||
import ProjectEntityRefsPanel from "@/uhm/components/ProjectEntityRefsPanel";
|
||||
import EntityWikiBindingsPanel from "@/uhm/components/EntityWikiBindingsPanel";
|
||||
import GeometryBindingPanel from "@/uhm/components/GeometryBindingPanel";
|
||||
import { Entity, fetchEntities, searchEntitiesByName } from "@/uhm/api/entities";
|
||||
import { ApiError } from "@/uhm/api/http";
|
||||
import { fetchCurrentUser } from "@/uhm/api/auth";
|
||||
@@ -62,6 +63,7 @@ import { FIXED_TIMELINE_RANGE, clampYearToFixedRange } from "@/uhm/lib/timeline"
|
||||
import { useFeatureCommands } from "./featureCommands";
|
||||
import { deleteSubmission } from "@/uhm/api/sections";
|
||||
import type { WikiSnapshot } from "@/uhm/types/wiki";
|
||||
import type { EntityWikiLinkSnapshot } from "@/uhm/types/sections";
|
||||
import UnifiedSearchBar, { type UnifiedSearchKind } from "@/uhm/components/UnifiedSearchBar";
|
||||
|
||||
const CURRENT_YEAR = new Date().getUTCFullYear();
|
||||
@@ -87,7 +89,10 @@ export default function Page() {
|
||||
const [leftPanelWidth, setLeftPanelWidth] = useState(280);
|
||||
const [rightPanelWidth, setRightPanelWidth] = useState(420);
|
||||
const [timelineFilterEnabled, setTimelineFilterEnabled] = useState(true);
|
||||
const [geometryBindingFilterEnabled, setGeometryBindingFilterEnabled] = useState(true);
|
||||
const entityFormStatusTimeoutRef = useRef<number | null>(null);
|
||||
const geoBindingStatusTimeoutRef = useRef<number | null>(null);
|
||||
const [geoBindingStatus, setGeoBindingStatus] = useState<string | null>(null);
|
||||
const lastSelectedFeatureIdRef = useRef<string | null>(null);
|
||||
|
||||
const {
|
||||
@@ -162,7 +167,39 @@ export default function Page() {
|
||||
const wikiSearchRequestRef = useRef(0);
|
||||
const geoSearchRequestRef = useRef(0);
|
||||
|
||||
const editor = useEditorState(initialData);
|
||||
const snapshotEntitiesRef = useRef(snapshotEntities);
|
||||
const snapshotWikisRef = useRef(snapshotWikis);
|
||||
const snapshotEntityWikiLinksRef = useRef(snapshotEntityWikiLinks);
|
||||
useEffect(() => {
|
||||
snapshotEntitiesRef.current = snapshotEntities;
|
||||
}, [snapshotEntities]);
|
||||
useEffect(() => {
|
||||
snapshotWikisRef.current = snapshotWikis;
|
||||
}, [snapshotWikis]);
|
||||
useEffect(() => {
|
||||
snapshotEntityWikiLinksRef.current = snapshotEntityWikiLinks;
|
||||
}, [snapshotEntityWikiLinks]);
|
||||
|
||||
const editor = useEditorState(initialData, {
|
||||
snapshotEntitiesRef,
|
||||
setSnapshotEntities,
|
||||
snapshotWikisRef,
|
||||
setSnapshotWikis,
|
||||
snapshotEntityWikiLinksRef,
|
||||
setSnapshotEntityWikiLinks,
|
||||
});
|
||||
const setSnapshotWikisUndoable = useCallback(
|
||||
(next: SetStateAction<WikiSnapshot[]>) => {
|
||||
editor.setSnapshotWikis(next, "Cập nhật wiki");
|
||||
},
|
||||
[editor]
|
||||
);
|
||||
const setSnapshotEntityWikiLinksUndoable = useCallback(
|
||||
(next: SetStateAction<EntityWikiLinkSnapshot[]>) => {
|
||||
editor.setSnapshotEntityWikiLinks(next, "Cập nhật entity-wiki");
|
||||
},
|
||||
[editor]
|
||||
);
|
||||
const editorUserId = normalizeEditorUserId(editorUserIdInput);
|
||||
const snapshotEntitiesAsEntities = useMemo(() => {
|
||||
const rows = snapshotEntities || [];
|
||||
@@ -229,6 +266,24 @@ export default function Page() {
|
||||
String(feature.properties.id) === String(selectedFeatureId)
|
||||
) || null;
|
||||
|
||||
const geometryChoices = useMemo(() => {
|
||||
const rows = (editor.draft.features || [])
|
||||
.filter((f) => f && f.properties && (typeof f.properties.id === "string" || typeof f.properties.id === "number"))
|
||||
.map((f) => {
|
||||
const id = String(f.properties.id);
|
||||
const semantic = String(f.properties.type || getDefaultTypeIdForFeature(f) || "").trim();
|
||||
const label = semantic.length ? `${semantic} (${f.geometry.type})` : f.geometry.type;
|
||||
return { id, label };
|
||||
});
|
||||
rows.sort((a, b) => a.id.localeCompare(b.id));
|
||||
return rows;
|
||||
}, [editor.draft.features]);
|
||||
|
||||
const selectedGeometryBindingIds = useMemo(() => {
|
||||
if (!selectedFeature) return [];
|
||||
return normalizeFeatureBindingIds(selectedFeature);
|
||||
}, [selectedFeature]);
|
||||
|
||||
const createdEntities = useMemo(() => {
|
||||
return (snapshotEntities || [])
|
||||
.filter((e) => e && e.source === "inline" && e.operation === "create")
|
||||
@@ -691,6 +746,20 @@ export default function Page() {
|
||||
}
|
||||
}, [setEntityFormStatus]);
|
||||
|
||||
const flashGeoBindingStatus = useCallback((msg: string | null, timeoutMs = 3000) => {
|
||||
if (geoBindingStatusTimeoutRef.current) {
|
||||
window.clearTimeout(geoBindingStatusTimeoutRef.current);
|
||||
geoBindingStatusTimeoutRef.current = null;
|
||||
}
|
||||
setGeoBindingStatus(msg);
|
||||
if (msg && timeoutMs > 0) {
|
||||
geoBindingStatusTimeoutRef.current = window.setTimeout(() => {
|
||||
setGeoBindingStatus(null);
|
||||
geoBindingStatusTimeoutRef.current = null;
|
||||
}, timeoutMs);
|
||||
}
|
||||
}, [setGeoBindingStatus]);
|
||||
|
||||
useEffect(() => {
|
||||
setBackgroundVisibility(loadBackgroundLayerVisibilityFromStorage());
|
||||
setIsBackgroundVisibilityReady(true);
|
||||
@@ -740,7 +809,7 @@ export default function Page() {
|
||||
const handleAddEntityRefToProject = useCallback((entity: Entity) => {
|
||||
const id = String(entity.id || "").trim();
|
||||
if (!id) return;
|
||||
setSnapshotEntities((prev) => {
|
||||
editor.setSnapshotEntities((prev) => {
|
||||
if (prev.some((e) => String(e.id) === id)) return prev;
|
||||
return [
|
||||
{
|
||||
@@ -752,7 +821,7 @@ export default function Page() {
|
||||
},
|
||||
...prev,
|
||||
];
|
||||
});
|
||||
}, `Thêm entity ref #${id}`);
|
||||
// Keep entity catalog centralized as a single in-memory list.
|
||||
setEntityCatalog((prev) => {
|
||||
const byId = new globalThis.Map<string, Entity>();
|
||||
@@ -763,7 +832,38 @@ export default function Page() {
|
||||
byId.set(id, entity);
|
||||
return Array.from(byId.values());
|
||||
});
|
||||
}, [setEntityCatalog, setSnapshotEntities]);
|
||||
}, [editor, setEntityCatalog]);
|
||||
|
||||
const handleUpdateEntityInProject = useCallback((entityId: string, payload: { name: string; description: string | null }) => {
|
||||
const id = String(entityId || "").trim();
|
||||
if (!id) return;
|
||||
const nextName = String(payload?.name || "").trim();
|
||||
if (!nextName.length) {
|
||||
flashEntityFormStatus("Ten entity la bat buoc.");
|
||||
return;
|
||||
}
|
||||
const nextDescription = payload?.description == null ? null : String(payload.description);
|
||||
|
||||
editor.setSnapshotEntities((prev) => prev.map((e) => {
|
||||
if (!e || String(e.id) !== id) return e;
|
||||
const source = e.source === "inline" ? "inline" : "ref";
|
||||
const operation =
|
||||
source === "ref"
|
||||
? "reference"
|
||||
: e.operation === "create"
|
||||
? "create"
|
||||
: "update";
|
||||
return {
|
||||
...e,
|
||||
id,
|
||||
source,
|
||||
operation,
|
||||
name: nextName,
|
||||
description: nextDescription,
|
||||
};
|
||||
}), `Cap nhat entity #${id}`);
|
||||
flashEntityFormStatus("Da cap nhat entity. Commit khi san sang.", 3000);
|
||||
}, [editor, flashEntityFormStatus]);
|
||||
|
||||
const handleToggleBindEntityForSelectedGeometry = useCallback((entityId: string, nextChecked: boolean) => {
|
||||
if (!selectedFeature) {
|
||||
@@ -810,11 +910,53 @@ export default function Page() {
|
||||
setSelectedGeometryEntityIds,
|
||||
]);
|
||||
|
||||
const handleToggleBindGeometryForSelectedGeometry = useCallback((geoId: string, nextChecked: boolean) => {
|
||||
if (!selectedFeature) {
|
||||
flashGeoBindingStatus("Chưa chọn geometry để bind.");
|
||||
return;
|
||||
}
|
||||
const id = String(geoId || "").trim();
|
||||
if (!id) return;
|
||||
if (String(selectedFeature.properties.id) === id) return;
|
||||
|
||||
const prevBindingIds = normalizeFeatureBindingIds(selectedFeature);
|
||||
const has = prevBindingIds.includes(id);
|
||||
const nextBindingIds = (() => {
|
||||
if (nextChecked) {
|
||||
if (has) return prevBindingIds;
|
||||
return [...prevBindingIds, id];
|
||||
}
|
||||
if (!has) return prevBindingIds;
|
||||
return prevBindingIds.filter((x) => x !== id);
|
||||
})();
|
||||
|
||||
setIsEntitySubmitting(true);
|
||||
flashGeoBindingStatus(null, 0);
|
||||
try {
|
||||
editor.patchFeatureProperties(selectedFeature.properties.id, { binding: nextBindingIds });
|
||||
setGeometryMetaForm((prev) => ({ ...prev, binding: nextBindingIds.join(", ") }));
|
||||
flashGeoBindingStatus(
|
||||
nextChecked
|
||||
? "Đã bind geometry vào binding. Commit khi sẵn sàng."
|
||||
: "Đã gỡ binding geometry. Commit khi sẵn sàng.",
|
||||
3000
|
||||
);
|
||||
} finally {
|
||||
setIsEntitySubmitting(false);
|
||||
}
|
||||
}, [
|
||||
editor,
|
||||
flashGeoBindingStatus,
|
||||
selectedFeature,
|
||||
setGeometryMetaForm,
|
||||
setIsEntitySubmitting,
|
||||
]);
|
||||
|
||||
const handleAddWikiRefToProject = useCallback((wiki: Wiki) => {
|
||||
const id = String(wiki.id || "").trim();
|
||||
if (!id) return;
|
||||
const title = (wiki.title || "").trim() || "Untitled wiki";
|
||||
setSnapshotWikis((prev) => {
|
||||
editor.setSnapshotWikis((prev) => {
|
||||
if (prev.some((w) => w.id === id)) return prev;
|
||||
return [
|
||||
{
|
||||
@@ -827,9 +969,9 @@ export default function Page() {
|
||||
},
|
||||
...prev,
|
||||
];
|
||||
});
|
||||
}, `Thêm wiki ref #${id}`);
|
||||
setRequestedActiveWikiId(id);
|
||||
}, [setSnapshotWikis]);
|
||||
}, [editor, setRequestedActiveWikiId]);
|
||||
|
||||
const handleImportGeoFromSearch = useCallback((
|
||||
entityItem: EntityGeometriesSearchItem,
|
||||
@@ -932,7 +1074,7 @@ export default function Page() {
|
||||
setIsEntitySubmitting(true);
|
||||
setEntityFormStatus(null);
|
||||
try {
|
||||
setSnapshotEntities((prev) => {
|
||||
editor.setSnapshotEntities((prev) => {
|
||||
if (prev.some((e) => String(e.id) === entityId)) return prev;
|
||||
return [
|
||||
{
|
||||
@@ -946,7 +1088,7 @@ export default function Page() {
|
||||
},
|
||||
...prev,
|
||||
];
|
||||
});
|
||||
}, `Tạo entity #${entityId}`);
|
||||
setEntityCatalog((prev) => {
|
||||
const byId = new globalThis.Map<string, Entity>();
|
||||
for (const row of prev || []) {
|
||||
@@ -1069,6 +1211,7 @@ export default function Page() {
|
||||
onDeleteFeature={editor.deleteFeature}
|
||||
onUpdateFeature={editor.updateFeature}
|
||||
backgroundVisibility={backgroundVisibility}
|
||||
respectBindingFilter={geometryBindingFilterEnabled}
|
||||
/>
|
||||
) : (
|
||||
<div style={{ width: "100%", height: "100%", background: "#0b1220" }} />
|
||||
@@ -1333,30 +1476,42 @@ export default function Page() {
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<WikiSidebarPanel
|
||||
projectId={projectId}
|
||||
wikis={snapshotWikis}
|
||||
setWikis={setSnapshotWikis}
|
||||
autoOpen={autoOpenWiki}
|
||||
requestedActiveId={requestedActiveWikiId}
|
||||
<GeometryBindingPanel
|
||||
geometries={geometryChoices}
|
||||
selectedGeometryId={selectedFeature ? String(selectedFeature.properties.id) : null}
|
||||
selectedGeometryBindingIds={selectedGeometryBindingIds}
|
||||
onToggleBindGeometryForSelectedGeometry={handleToggleBindGeometryForSelectedGeometry}
|
||||
statusText={geoBindingStatus}
|
||||
bindingFilterEnabled={geometryBindingFilterEnabled}
|
||||
onBindingFilterEnabledChange={setGeometryBindingFilterEnabled}
|
||||
/>
|
||||
|
||||
<ProjectEntityRefsPanel
|
||||
entityRefs={snapshotEntitiesVisible}
|
||||
entityForm={entityForm}
|
||||
onEntityFormChange={handleEntityFormChange}
|
||||
isEntitySubmitting={isEntitySubmitting}
|
||||
onCreateEntityOnly={handleCreateEntityOnly}
|
||||
onUpdateEntity={handleUpdateEntityInProject}
|
||||
entityFormStatus={entityFormStatus}
|
||||
hasSelectedGeometry={Boolean(selectedFeature)}
|
||||
selectedGeometryEntityIds={selectedGeometryEntityIds}
|
||||
onToggleBindEntityForSelectedGeometry={handleToggleBindEntityForSelectedGeometry}
|
||||
/>
|
||||
|
||||
<WikiSidebarPanel
|
||||
projectId={projectId}
|
||||
wikis={snapshotWikis}
|
||||
setWikis={setSnapshotWikisUndoable}
|
||||
autoOpen={autoOpenWiki}
|
||||
requestedActiveId={requestedActiveWikiId}
|
||||
/>
|
||||
|
||||
<EntityWikiBindingsPanel
|
||||
entities={projectEntityChoices}
|
||||
wikis={snapshotWikis}
|
||||
links={snapshotEntityWikiLinks}
|
||||
setLinks={setSnapshotEntityWikiLinks}
|
||||
setLinks={setSnapshotEntityWikiLinksUndoable}
|
||||
/>
|
||||
{!wikiOnly && selectedFeature ? (
|
||||
<SelectedGeometryPanel
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import React, { useEffect, useState } from "react";
|
||||
import React, { useEffect, useMemo, useRef, useState } from "react";
|
||||
import Image from "next/image";
|
||||
import { useRouter } from "next/navigation";
|
||||
import PageBreadcrumb from "@/components/common/PageBreadCrumb";
|
||||
@@ -12,7 +12,9 @@ import Button from "@/components/ui/button/Button";
|
||||
import Label from "@/components/form/Label";
|
||||
import Badge from "@/components/ui/badge/Badge";
|
||||
import { CreateProjectPayload, Project } from "@/interface/project";
|
||||
import { apiCreateProject, getCurrentProject } from "@/service/projectService";
|
||||
import { apiCreateProject, apiCreateProjectCommit, apiGetProjectCommits, getCurrentProject } from "@/service/projectService";
|
||||
import { normalizeEditorSnapshot } from "@/uhm/lib/editor/snapshot/editorSnapshot";
|
||||
import type { EditorSnapshot } from "@/uhm/types/sections";
|
||||
|
||||
export type ProjectSortColumn = "created_at" | "updated_at" | "title";
|
||||
|
||||
@@ -21,12 +23,16 @@ export default function ProjectsPage() {
|
||||
const [projects, setProjects] = useState<Project[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const [isExportingProjectId, setIsExportingProjectId] = useState<string | null>(null);
|
||||
|
||||
const [sortBy, setSortBy] = useState<ProjectSortColumn>("updated_at");
|
||||
const [sortOrder, setSortOrder] = useState<"asc" | "desc">("desc");
|
||||
|
||||
const { isOpen, openModal, closeModal } = useModal();
|
||||
const [formData, setFormData] = useState<CreateProjectPayload>({ title: "", description: "", project_status: "PRIVATE" });
|
||||
const importJsonInputRef = useRef<HTMLInputElement | null>(null);
|
||||
const [importSnapshot, setImportSnapshot] = useState<EditorSnapshot | null>(null);
|
||||
const [importSnapshotName, setImportSnapshotName] = useState<string | null>(null);
|
||||
|
||||
const fetchProjects = async () => {
|
||||
try {
|
||||
@@ -58,11 +64,15 @@ export default function ProjectsPage() {
|
||||
}
|
||||
try {
|
||||
setIsSubmitting(true);
|
||||
await apiCreateProject(formData);
|
||||
const created = await apiCreateProject(formData);
|
||||
const projectId = created?.data?.id;
|
||||
toast.success("Tạo dự án mới thành công!");
|
||||
closeModal();
|
||||
setFormData({ title: "", description: "", project_status: "PRIVATE" });
|
||||
setImportSnapshot(null);
|
||||
setImportSnapshotName(null);
|
||||
fetchProjects();
|
||||
if (projectId) router.push(`/editor/${projectId}`);
|
||||
} catch (error) {
|
||||
console.error("Lỗi tạo dự án:", error);
|
||||
toast.error("Có lỗi xảy ra khi tạo dự án.");
|
||||
@@ -71,6 +81,103 @@ export default function ProjectsPage() {
|
||||
}
|
||||
};
|
||||
|
||||
const handlePickImportJson = () => {
|
||||
importJsonInputRef.current?.click();
|
||||
};
|
||||
|
||||
const handleImportJsonFile = async (file: File | null) => {
|
||||
if (!file) return;
|
||||
try {
|
||||
const text = await file.text();
|
||||
const raw = JSON.parse(text) as unknown;
|
||||
const normalized = normalizeEditorSnapshot(raw);
|
||||
if (!normalized) {
|
||||
toast.error("JSON snapshot không hợp lệ.");
|
||||
return;
|
||||
}
|
||||
setImportSnapshot(normalized);
|
||||
setImportSnapshotName(file.name);
|
||||
toast.success("Đã nạp JSON snapshot. Bấm 'Tạo với JSON' để khởi tạo dự án.");
|
||||
} catch (err) {
|
||||
console.error("Import JSON failed", err);
|
||||
toast.error("Không đọc được file JSON.");
|
||||
}
|
||||
};
|
||||
|
||||
const handleCreateProjectWithJson = async () => {
|
||||
if (!formData.title.trim()) {
|
||||
toast.warning("Vui lòng nhập tên dự án!");
|
||||
return;
|
||||
}
|
||||
if (!importSnapshot) {
|
||||
toast.warning("Chưa chọn JSON snapshot.");
|
||||
handlePickImportJson();
|
||||
return;
|
||||
}
|
||||
try {
|
||||
setIsSubmitting(true);
|
||||
const created = await apiCreateProject(formData);
|
||||
const projectId = created?.data?.id;
|
||||
if (!projectId) {
|
||||
toast.error("Tạo dự án thất bại: thiếu project id.");
|
||||
return;
|
||||
}
|
||||
await apiCreateProjectCommit(projectId, {
|
||||
edit_summary: "Init project from JSON",
|
||||
snapshot_json: importSnapshot as any,
|
||||
} as any);
|
||||
toast.success("Tạo dự án (kèm JSON) thành công!");
|
||||
closeModal();
|
||||
setFormData({ title: "", description: "", project_status: "PRIVATE" });
|
||||
setImportSnapshot(null);
|
||||
setImportSnapshotName(null);
|
||||
fetchProjects();
|
||||
router.push(`/editor/${projectId}`);
|
||||
} catch (error) {
|
||||
console.error("Lỗi tạo dự án với JSON:", error);
|
||||
toast.error("Có lỗi xảy ra khi tạo dự án với JSON.");
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleExportHeadSnapshot = async (project: Project) => {
|
||||
const projectId = String(project.id || "").trim();
|
||||
if (!projectId) return;
|
||||
const headCommitId = project.latest_commit_id ? String(project.latest_commit_id) : "";
|
||||
if (!headCommitId) {
|
||||
toast.warning("Dự án chưa có head commit để export.");
|
||||
return;
|
||||
}
|
||||
setIsExportingProjectId(projectId);
|
||||
try {
|
||||
const res: any = await apiGetProjectCommits(projectId);
|
||||
const rawList = res?.data?.items ?? res?.data ?? res?.items ?? [];
|
||||
const commits = Array.isArray(rawList) ? rawList : [];
|
||||
const head = commits.find((c: any) => String(c?.id || "") === headCommitId) || null;
|
||||
const snapshot = head?.snapshot_json ?? null;
|
||||
if (!snapshot) {
|
||||
toast.error("Không tìm thấy snapshot_json của head commit.");
|
||||
return;
|
||||
}
|
||||
const blob = new Blob([JSON.stringify(snapshot, null, 2)], { type: "application/json;charset=utf-8" });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement("a");
|
||||
a.href = url;
|
||||
a.download = `project-${projectId}-head-${headCommitId}.json`;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
a.remove();
|
||||
URL.revokeObjectURL(url);
|
||||
toast.success("Đã export JSON snapshot.");
|
||||
} catch (err) {
|
||||
console.error("Export snapshot failed", err);
|
||||
toast.error("Export thất bại.");
|
||||
} finally {
|
||||
setIsExportingProjectId(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSort = (column: ProjectSortColumn) => {
|
||||
if (sortBy === column) {
|
||||
setSortOrder(sortOrder === "asc" ? "desc" : "asc");
|
||||
@@ -132,6 +239,11 @@ export default function ProjectsPage() {
|
||||
|
||||
console.log(projects);
|
||||
|
||||
const importLabel = useMemo(() => {
|
||||
if (!importSnapshotName) return "Chưa chọn JSON snapshot";
|
||||
return `JSON: ${importSnapshotName}`;
|
||||
}, [importSnapshotName]);
|
||||
|
||||
return (
|
||||
<div className="max-w-7xl mx-auto pb-10">
|
||||
<PageBreadcrumb pageTitle="Quản lý dự án" />
|
||||
@@ -225,6 +337,15 @@ export default function ProjectsPage() {
|
||||
>
|
||||
Editor
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
disabled={isExportingProjectId === String(project.id)}
|
||||
onClick={() => handleExportHeadSnapshot(project)}
|
||||
title="Export head commit snapshot_json"
|
||||
>
|
||||
ExportJSON
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
@@ -334,11 +455,39 @@ export default function ProjectsPage() {
|
||||
placeholder="Mô tả ngắn gọn về dự án..."
|
||||
></textarea>
|
||||
</div>
|
||||
<div>
|
||||
<Label>Khởi tạo từ JSON</Label>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button size="sm" variant="outline" type="button" onClick={handlePickImportJson}>
|
||||
Chọn JSON
|
||||
</Button>
|
||||
<div className="text-xs text-gray-500 dark:text-gray-400 truncate">
|
||||
{importLabel}
|
||||
</div>
|
||||
</div>
|
||||
<input
|
||||
ref={importJsonInputRef}
|
||||
type="file"
|
||||
accept="application/json"
|
||||
className="hidden"
|
||||
onChange={(e) => handleImportJsonFile(e.target.files?.[0] || null)}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center justify-end gap-3 mt-4">
|
||||
<Button size="sm" variant="outline" type="button" onClick={closeModal}>Hủy</Button>
|
||||
<Button size="sm" type="submit" disabled={isSubmitting} className="bg-brand-500 hover:bg-brand-600 text-white">
|
||||
{isSubmitting ? "Đang tạo..." : "Khởi tạo"}
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
type="button"
|
||||
disabled={isSubmitting}
|
||||
className="bg-gray-900 hover:bg-gray-800 text-white"
|
||||
onClick={handleCreateProjectWithJson}
|
||||
title="Tạo dự án và tạo commit đầu tiên từ JSON snapshot"
|
||||
>
|
||||
Tạo với JSON
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user