feat: add project export/import for cross-device transfers
Add the ability to export projects as .opencut JSON files and import them on another device. Export is available from the project context menu and dropdown menu. Import is available via a button in the projects header. The export format includes a version field for future compatibility. Imported projects receive a new ID to avoid collisions.
This commit is contained in:
parent
cbd1b82123
commit
e35635db4f
|
|
@ -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 "@/components/editor/dialogs/migration-dialog";
|
||||
|
|
@ -45,6 +45,8 @@ import {
|
|||
Edit03Icon,
|
||||
ArrowDown02Icon,
|
||||
InformationCircleIcon,
|
||||
Download04Icon,
|
||||
Upload04Icon,
|
||||
} from "@hugeicons/core-free-icons";
|
||||
import { OcVideoIcon } from "@/components/icons";
|
||||
import { Label } from "@/components/ui/label";
|
||||
|
|
@ -183,6 +185,7 @@ function ProjectsHeader() {
|
|||
|
||||
<div className="flex items-center gap-3 md:gap-4">
|
||||
<SearchBar className="hidden md:block" />
|
||||
<ImportProjectButton />
|
||||
<NewProjectButton />
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -526,6 +529,44 @@ function NewProjectButton() {
|
|||
);
|
||||
}
|
||||
|
||||
function ImportProjectButton() {
|
||||
const editor = useEditor();
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const handleImport = async (event: ChangeEvent<HTMLInputElement>) => {
|
||||
const file = event.target.files?.[0];
|
||||
if (!file) return;
|
||||
|
||||
await editor.project.importProjectFromFile({ file });
|
||||
|
||||
// Reset the input so the same file can be imported again
|
||||
if (fileInputRef.current) {
|
||||
fileInputRef.current.value = "";
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept=".opencut"
|
||||
onChange={handleImport}
|
||||
className="hidden"
|
||||
/>
|
||||
<Button
|
||||
size="lg"
|
||||
variant="outline"
|
||||
className="flex px-5 md:px-6"
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
>
|
||||
<HugeiconsIcon icon={Upload04Icon} className="size-4" />
|
||||
<span className="text-sm font-medium hidden md:block">Import</span>
|
||||
</Button>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function ProjectItem({
|
||||
project,
|
||||
allProjectIds,
|
||||
|
|
@ -555,6 +596,9 @@ function ProjectItem({
|
|||
const handleDuplicate = async () => {
|
||||
await duplicateProjects({ editor, ids: [project.id] });
|
||||
};
|
||||
const handleExport = async () => {
|
||||
await editor.project.exportProjectToFile({ id: project.id });
|
||||
};
|
||||
const handleDeleteClick = () => setIsDeleteDialogOpen(true);
|
||||
const handleInfoClick = () => setIsInfoDialogOpen(true);
|
||||
const handleDeleteConfirm = async () => {
|
||||
|
|
@ -674,6 +718,7 @@ function ProjectItem({
|
|||
variant="list"
|
||||
onRenameClick={handleRename}
|
||||
onDuplicateClick={handleDuplicate}
|
||||
onExportClick={handleExport}
|
||||
onDeleteClick={handleDeleteClick}
|
||||
onInfoClick={handleInfoClick}
|
||||
/>
|
||||
|
|
@ -715,6 +760,7 @@ function ProjectItem({
|
|||
onOpenChange={setIsDropdownOpen}
|
||||
onRenameClick={handleRename}
|
||||
onDuplicateClick={handleDuplicate}
|
||||
onExportClick={handleExport}
|
||||
onDeleteClick={handleDeleteClick}
|
||||
onInfoClick={handleInfoClick}
|
||||
/>
|
||||
|
|
@ -728,6 +774,7 @@ function ProjectItem({
|
|||
<ProjectContextMenuContent
|
||||
onRenameClick={handleRename}
|
||||
onDuplicateClick={handleDuplicate}
|
||||
onExportClick={handleExport}
|
||||
onDeleteClick={handleDeleteClick}
|
||||
onInfoClick={handleInfoClick}
|
||||
/>
|
||||
|
|
@ -762,11 +809,13 @@ function ProjectItem({
|
|||
function ProjectContextMenuContent({
|
||||
onRenameClick,
|
||||
onDuplicateClick,
|
||||
onExportClick,
|
||||
onDeleteClick,
|
||||
onInfoClick,
|
||||
}: {
|
||||
onRenameClick: () => void;
|
||||
onDuplicateClick: () => void;
|
||||
onExportClick: () => void;
|
||||
onDeleteClick: () => void;
|
||||
onInfoClick: () => void;
|
||||
}) {
|
||||
|
|
@ -784,6 +833,12 @@ function ProjectContextMenuContent({
|
|||
>
|
||||
Duplicate
|
||||
</ContextMenuItem>
|
||||
<ContextMenuItem
|
||||
icon={<HugeiconsIcon icon={Download04Icon} />}
|
||||
onClick={onExportClick}
|
||||
>
|
||||
Export
|
||||
</ContextMenuItem>
|
||||
<ContextMenuItem
|
||||
icon={<HugeiconsIcon icon={InformationCircleIcon} />}
|
||||
onClick={onInfoClick}
|
||||
|
|
@ -808,6 +863,7 @@ function ProjectMenu({
|
|||
variant = "grid",
|
||||
onRenameClick,
|
||||
onDuplicateClick,
|
||||
onExportClick,
|
||||
onDeleteClick,
|
||||
onInfoClick,
|
||||
}: {
|
||||
|
|
@ -816,6 +872,7 @@ function ProjectMenu({
|
|||
variant?: "grid" | "list";
|
||||
onRenameClick: () => void;
|
||||
onDuplicateClick: () => void;
|
||||
onExportClick: () => void;
|
||||
onDeleteClick: () => void;
|
||||
onInfoClick: () => void;
|
||||
}) {
|
||||
|
|
@ -850,6 +907,11 @@ function ProjectMenu({
|
|||
onOpenChange(false);
|
||||
};
|
||||
|
||||
const handleExport = () => {
|
||||
onExportClick();
|
||||
onOpenChange(false);
|
||||
};
|
||||
|
||||
const handleDeleteClick = () => {
|
||||
onDeleteClick();
|
||||
onOpenChange(false);
|
||||
|
|
@ -902,6 +964,10 @@ function ProjectMenu({
|
|||
<HugeiconsIcon icon={Copy01Icon} />
|
||||
Duplicate
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={handleExport}>
|
||||
<HugeiconsIcon icon={Download04Icon} />
|
||||
Export
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={handleInfoClick}>
|
||||
<HugeiconsIcon icon={InformationCircleIcon} />
|
||||
Info
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ import type {
|
|||
} from "@/lib/project/types";
|
||||
import type { ExportOptions, ExportResult, ExportState } from "@/lib/export";
|
||||
import { storageService } from "@/services/storage/service";
|
||||
import type { SerializedProject } from "@/services/storage/types";
|
||||
import { toast } from "sonner";
|
||||
import { generateUUID } from "@/utils/id";
|
||||
import { UpdateProjectSettingsCommand } from "@/lib/commands/project";
|
||||
|
|
@ -640,6 +641,128 @@ export class ProjectManager {
|
|||
this.notify();
|
||||
}
|
||||
|
||||
async exportProjectToFile({ id }: { id: string }): Promise<void> {
|
||||
try {
|
||||
const serializedProject =
|
||||
await storageService.exportProjectToJSON({ id });
|
||||
if (!serializedProject) {
|
||||
toast.error("Project not found");
|
||||
return;
|
||||
}
|
||||
|
||||
const exportData = {
|
||||
formatVersion: 1,
|
||||
exportedAt: new Date().toISOString(),
|
||||
project: serializedProject,
|
||||
};
|
||||
|
||||
const json = JSON.stringify(exportData, null, 2);
|
||||
const blob = new Blob([json], { type: "application/json" });
|
||||
const url = URL.createObjectURL(blob);
|
||||
|
||||
const safeName = serializedProject.metadata.name
|
||||
.replace(/[^a-zA-Z0-9_-]/g, "_")
|
||||
.substring(0, 100);
|
||||
const filename = `${safeName}.opencut`;
|
||||
|
||||
const link = document.createElement("a");
|
||||
link.href = url;
|
||||
link.download = filename;
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
document.body.removeChild(link);
|
||||
URL.revokeObjectURL(url);
|
||||
|
||||
toast.success("Project exported successfully");
|
||||
} catch (error) {
|
||||
console.error("Failed to export project:", error);
|
||||
toast.error("Failed to export project", {
|
||||
description:
|
||||
error instanceof Error ? error.message : "Please try again",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async importProjectFromFile({
|
||||
file,
|
||||
}: {
|
||||
file: File;
|
||||
}): Promise<string | null> {
|
||||
try {
|
||||
const text = await file.text();
|
||||
let parsed: unknown;
|
||||
|
||||
try {
|
||||
parsed = JSON.parse(text);
|
||||
} catch {
|
||||
toast.error("Invalid file", {
|
||||
description: "The file is not valid JSON",
|
||||
});
|
||||
return null;
|
||||
}
|
||||
|
||||
const data = parsed as Record<string, unknown>;
|
||||
|
||||
if (
|
||||
!data ||
|
||||
typeof data !== "object" ||
|
||||
!("formatVersion" in data) ||
|
||||
!("project" in data)
|
||||
) {
|
||||
toast.error("Invalid file format", {
|
||||
description: "This does not appear to be an OpenCut project file",
|
||||
});
|
||||
return null;
|
||||
}
|
||||
|
||||
const serializedProject = data.project as SerializedProject;
|
||||
|
||||
if (
|
||||
!serializedProject?.metadata?.id ||
|
||||
!serializedProject?.metadata?.name ||
|
||||
!Array.isArray(serializedProject?.scenes)
|
||||
) {
|
||||
toast.error("Invalid project data", {
|
||||
description: "The project data is incomplete or corrupted",
|
||||
});
|
||||
return null;
|
||||
}
|
||||
|
||||
// Assign a new ID to avoid collisions with existing projects
|
||||
const newId = generateUUID();
|
||||
serializedProject.metadata.id = newId;
|
||||
serializedProject.metadata.updatedAt = new Date().toISOString();
|
||||
|
||||
await storageService.importProjectFromJSON({ serializedProject });
|
||||
|
||||
// Reload projects list
|
||||
const metadata = {
|
||||
id: serializedProject.metadata.id,
|
||||
name: serializedProject.metadata.name,
|
||||
thumbnail: serializedProject.metadata.thumbnail,
|
||||
duration: serializedProject.metadata.duration ?? 0,
|
||||
createdAt: new Date(serializedProject.metadata.createdAt),
|
||||
updatedAt: new Date(serializedProject.metadata.updatedAt),
|
||||
};
|
||||
|
||||
this.savedProjects = [metadata, ...this.savedProjects];
|
||||
this.notify();
|
||||
|
||||
toast.success("Project imported successfully", {
|
||||
description: serializedProject.metadata.name,
|
||||
});
|
||||
|
||||
return newId;
|
||||
} catch (error) {
|
||||
console.error("Failed to import project:", error);
|
||||
toast.error("Failed to import project", {
|
||||
description:
|
||||
error instanceof Error ? error.message : "Please try again",
|
||||
});
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
subscribe(listener: () => void): () => void {
|
||||
this.listeners.add(listener);
|
||||
return () => this.listeners.delete(listener);
|
||||
|
|
|
|||
|
|
@ -517,6 +517,27 @@ class StorageService {
|
|||
return "indexedDB" in window;
|
||||
}
|
||||
|
||||
async exportProjectToJSON({
|
||||
id,
|
||||
}: {
|
||||
id: string;
|
||||
}): Promise<SerializedProject | null> {
|
||||
await this.ensureMigrations();
|
||||
const serializedProject = await this.projectsAdapter.get(id);
|
||||
return serializedProject ?? null;
|
||||
}
|
||||
|
||||
async importProjectFromJSON({
|
||||
serializedProject,
|
||||
}: {
|
||||
serializedProject: SerializedProject;
|
||||
}): Promise<void> {
|
||||
await this.projectsAdapter.set(
|
||||
serializedProject.metadata.id,
|
||||
serializedProject,
|
||||
);
|
||||
}
|
||||
|
||||
isFullySupported(): boolean {
|
||||
return this.isIndexedDBSupported() && this.isOPFSSupported();
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue