From 30a36e45686db503fded68e4da5d4c465a06f978 Mon Sep 17 00:00:00 2001 From: cashmere Date: Mon, 27 Jul 2026 01:21:20 +0200 Subject: feat: single-file project import/export (.ocp) fflate zip container: project.json (serialized project incl. schema version) + media files with a manifest. Export gathers all project media (missing blobs warn, never abort) after save-flush; import validates everything before any write, migrates older archives through the storage migration registry, remaps media ids, and creates a new project with rollback on failure. Entry points: projects page import/export, editor header menu, export-project-archive action. 29 tests incl. full round-trip. --- apps/web/src/app/projects/page.tsx | 89 +++++++++++++++++++++++++++++++++++++- 1 file changed, 87 insertions(+), 2 deletions(-) (limited to 'apps/web/src/app/projects/page.tsx') diff --git a/apps/web/src/app/projects/page.tsx b/apps/web/src/app/projects/page.tsx index c39c4fd9..d238af9e 100644 --- a/apps/web/src/app/projects/page.tsx +++ b/apps/web/src/app/projects/page.tsx @@ -3,8 +3,8 @@ import Image from "next/image"; import Link from "next/link"; import { useRouter } from "next/navigation"; -import type { KeyboardEvent, MouseEvent } from "react"; -import { useEffect, useState } from "react"; +import type { ChangeEvent, KeyboardEvent, MouseEvent } from "react"; +import { useEffect, useRef, useState } from "react"; import { toast } from "sonner"; import type { EditorCore } from "@/core"; import { MigrationDialog } from "@/project/components/migration-dialog"; @@ -45,6 +45,8 @@ import { Edit03Icon, ArrowDown02Icon, InformationCircleIcon, + Download01Icon, + Upload01Icon, } from "@hugeicons/core-free-icons"; import { OcVideoIcon } from "@/components/icons"; import { Label } from "@/components/ui/label"; @@ -184,6 +186,7 @@ function ProjectsHeader() {
+
@@ -527,6 +530,63 @@ function NewProjectButton() { ); } +function ImportProjectButton() { + const editor = useEditor(); + const router = useRouter(); + const fileInputRef = useRef(null); + const [isImporting, setIsImporting] = useState(false); + + const handleImportClick = () => { + fileInputRef.current?.click(); + }; + + const handleFileChange = async ({ + event, + }: { + event: ChangeEvent; + }) => { + const file = event.target.files?.[0]; + event.target.value = ""; + if (!file || isImporting) return; + + setIsImporting(true); + try { + const projectId = await editor.project.importArchive({ file }); + if (projectId) { + router.push(`/editor/${projectId}`); + } + } finally { + setIsImporting(false); + } + }; + + return ( + <> + void handleFileChange({ event })} + /> + + + ); +} + function ProjectItem({ project, allProjectIds, @@ -558,6 +618,9 @@ function ProjectItem({ }; const handleDeleteClick = () => setIsDeleteDialogOpen(true); const handleInfoClick = () => setIsInfoDialogOpen(true); + const handleExport = async () => { + await editor.project.exportArchive({ id: project.id }); + }; const handleDeleteConfirm = async () => { await deleteProjects({ editor, ids: [project.id] }); setIsDeleteDialogOpen(false); @@ -675,6 +738,7 @@ function ProjectItem({ variant="list" onRenameClick={handleRename} onDuplicateClick={handleDuplicate} + onExportClick={handleExport} onDeleteClick={handleDeleteClick} onInfoClick={handleInfoClick} /> @@ -716,6 +780,7 @@ function ProjectItem({ onOpenChange={setIsDropdownOpen} onRenameClick={handleRename} onDuplicateClick={handleDuplicate} + onExportClick={handleExport} onDeleteClick={handleDeleteClick} onInfoClick={handleInfoClick} /> @@ -729,6 +794,7 @@ function ProjectItem({ @@ -763,11 +829,13 @@ function ProjectItem({ function ProjectContextMenuContent({ onRenameClick, onDuplicateClick, + onExportClick, onDeleteClick, onInfoClick, }: { onRenameClick: () => void; onDuplicateClick: () => void; + onExportClick: () => void; onDeleteClick: () => void; onInfoClick: () => void; }) { @@ -785,6 +853,12 @@ function ProjectContextMenuContent({ > Duplicate + } + onClick={onExportClick} + > + Export + } onClick={onInfoClick} @@ -809,6 +883,7 @@ function ProjectMenu({ variant = "grid", onRenameClick, onDuplicateClick, + onExportClick, onDeleteClick, onInfoClick, }: { @@ -817,6 +892,7 @@ function ProjectMenu({ variant?: "grid" | "list"; onRenameClick: () => void; onDuplicateClick: () => void; + onExportClick: () => void; onDeleteClick: () => void; onInfoClick: () => void; }) { @@ -851,6 +927,11 @@ function ProjectMenu({ onOpenChange(false); }; + const handleExport = () => { + onExportClick(); + onOpenChange(false); + }; + const handleDeleteClick = () => { onDeleteClick(); onOpenChange(false); @@ -903,6 +984,10 @@ function ProjectMenu({ Duplicate + + + Export + Info -- cgit v1.3.1