Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
70 changes: 68 additions & 2 deletions apps/web/src/app/projects/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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";
Expand Down Expand Up @@ -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>
Expand Down Expand Up @@ -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>
Comment thread
renezander030 marked this conversation as resolved.
</>
);
}

function ProjectItem({
project,
allProjectIds,
Expand Down Expand Up @@ -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 () => {
Expand Down Expand Up @@ -674,6 +718,7 @@ function ProjectItem({
variant="list"
onRenameClick={handleRename}
onDuplicateClick={handleDuplicate}
onExportClick={handleExport}
onDeleteClick={handleDeleteClick}
onInfoClick={handleInfoClick}
/>
Expand Down Expand Up @@ -715,6 +760,7 @@ function ProjectItem({
onOpenChange={setIsDropdownOpen}
onRenameClick={handleRename}
onDuplicateClick={handleDuplicate}
onExportClick={handleExport}
onDeleteClick={handleDeleteClick}
onInfoClick={handleInfoClick}
/>
Expand All @@ -728,6 +774,7 @@ function ProjectItem({
<ProjectContextMenuContent
onRenameClick={handleRename}
onDuplicateClick={handleDuplicate}
onExportClick={handleExport}
onDeleteClick={handleDeleteClick}
onInfoClick={handleInfoClick}
/>
Expand Down Expand Up @@ -762,11 +809,13 @@ function ProjectItem({
function ProjectContextMenuContent({
onRenameClick,
onDuplicateClick,
onExportClick,
onDeleteClick,
onInfoClick,
}: {
onRenameClick: () => void;
onDuplicateClick: () => void;
onExportClick: () => void;
onDeleteClick: () => void;
onInfoClick: () => void;
}) {
Expand All @@ -784,6 +833,12 @@ function ProjectContextMenuContent({
>
Duplicate
</ContextMenuItem>
<ContextMenuItem
icon={<HugeiconsIcon icon={Download04Icon} />}
onClick={onExportClick}
>
Export
</ContextMenuItem>
<ContextMenuItem
icon={<HugeiconsIcon icon={InformationCircleIcon} />}
onClick={onInfoClick}
Expand All @@ -808,6 +863,7 @@ function ProjectMenu({
variant = "grid",
onRenameClick,
onDuplicateClick,
onExportClick,
onDeleteClick,
onInfoClick,
}: {
Expand All @@ -816,6 +872,7 @@ function ProjectMenu({
variant?: "grid" | "list";
onRenameClick: () => void;
onDuplicateClick: () => void;
onExportClick: () => void;
onDeleteClick: () => void;
onInfoClick: () => void;
}) {
Expand Down Expand Up @@ -850,6 +907,11 @@ function ProjectMenu({
onOpenChange(false);
};

const handleExport = () => {
onExportClick();
onOpenChange(false);
};

const handleDeleteClick = () => {
onDeleteClick();
onOpenChange(false);
Expand Down Expand Up @@ -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
Expand Down
123 changes: 123 additions & 0 deletions apps/web/src/core/managers/project-manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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,
};
Comment thread
renezander030 marked this conversation as resolved.

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;
}
Comment thread
renezander030 marked this conversation as resolved.
Outdated

// 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);
Expand Down
21 changes: 21 additions & 0 deletions apps/web/src/services/storage/service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
);
Comment thread
renezander030 marked this conversation as resolved.
}

isFullySupported(): boolean {
return this.isIndexedDBSupported() && this.isOPFSSupported();
}
Expand Down