feat: clean up pages, adding e2e, connecting to cloud
This commit is contained in:
parent
229c42aa94
commit
322f5d3c4f
|
|
@ -333,4 +333,10 @@ Follow `package.json` scripts if different.
|
|||
|
||||
- Choose the simplest interpretation.
|
||||
- Write tests that document the behavior.
|
||||
- Keep changes minimal and reversible.
|
||||
- Keep changes minimal and reversible.
|
||||
|
||||
---
|
||||
|
||||
## 13) Commit message
|
||||
|
||||
- Follow commit message convention standard
|
||||
|
|
@ -8,3 +8,5 @@
|
|||
.next/
|
||||
coverage/
|
||||
storybook-static/
|
||||
playwright-report/
|
||||
test-results/
|
||||
|
|
|
|||
|
|
@ -0,0 +1,93 @@
|
|||
import { expect, test } from "@playwright/test";
|
||||
|
||||
test("login loads backend projects list on /projects", async ({ context, page }) => {
|
||||
await page.addInitScript(() => {
|
||||
window.localStorage.setItem("opencut_workspace_id", "ws-1");
|
||||
});
|
||||
|
||||
await context.addCookies([
|
||||
{
|
||||
name: "refresh_token",
|
||||
value: "e2e-refresh-token",
|
||||
domain: "127.0.0.1",
|
||||
path: "/",
|
||||
httpOnly: false,
|
||||
secure: false,
|
||||
sameSite: "Lax",
|
||||
},
|
||||
]);
|
||||
|
||||
await page.route("**/api/auth/refresh", async (route) => {
|
||||
await route.fulfill({
|
||||
status: 401,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify({
|
||||
status: "error",
|
||||
message: "Invalid refresh token",
|
||||
statusCode: 401,
|
||||
}),
|
||||
});
|
||||
});
|
||||
|
||||
await page.route("**/api/auth/user/login", async (route) => {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify({
|
||||
token: "backend-jwt-token",
|
||||
user: {
|
||||
id: "u-1",
|
||||
username: "john",
|
||||
email: "john@example.com",
|
||||
},
|
||||
}),
|
||||
});
|
||||
});
|
||||
|
||||
await page.route("**/api/editor/projects**", async (route) => {
|
||||
const workspaceHeader = route.request().headers()["x-workspace-id"];
|
||||
if (!workspaceHeader) {
|
||||
await route.fulfill({
|
||||
status: 400,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify({
|
||||
message: "x-workspace-id header is required",
|
||||
error: "Bad Request",
|
||||
statusCode: 400,
|
||||
}),
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify({
|
||||
items: [
|
||||
{
|
||||
id: "proj-remote-1",
|
||||
workspaceId: "ws-1",
|
||||
ownerId: 1,
|
||||
name: "Backend Project Alpha",
|
||||
version: 4,
|
||||
updatedAt: "2026-02-09T12:00:00.000Z",
|
||||
createdAt: "2026-02-08T12:00:00.000Z",
|
||||
},
|
||||
],
|
||||
total: 1,
|
||||
}),
|
||||
});
|
||||
});
|
||||
|
||||
await page.goto("/auth/login");
|
||||
|
||||
await page.getByLabel("Username").fill("john");
|
||||
await page.getByLabel("Password").fill("12345678");
|
||||
await page.getByRole("button", { name: "Sign in" }).click();
|
||||
|
||||
await expect(page).toHaveURL(/\/projects/);
|
||||
await page.waitForResponse((response) =>
|
||||
response.url().includes("/api/editor/projects"),
|
||||
);
|
||||
await expect(page.getByText("Backend Project Alpha")).toBeVisible();
|
||||
});
|
||||
|
|
@ -0,0 +1,174 @@
|
|||
import { expect, test, type Page, type Route } from "@playwright/test";
|
||||
|
||||
const PROJECT_ID = "5e9bd215-7891-4c94-90c5-b87a6f3ddc50";
|
||||
|
||||
const CLOUD_PROJECT_RESPONSE = {
|
||||
id: PROJECT_ID,
|
||||
workspaceId: "ws-1",
|
||||
ownerId: 1,
|
||||
name: "Cloud Synced Project",
|
||||
version: 3,
|
||||
updatedAt: "2026-02-09T12:00:00.000Z",
|
||||
createdAt: "2026-02-08T12:00:00.000Z",
|
||||
state: {
|
||||
schemaVersion: 3,
|
||||
currentSceneId: "scene-main",
|
||||
metadata: {
|
||||
id: PROJECT_ID,
|
||||
name: "Cloud Synced Project",
|
||||
duration: 30,
|
||||
updatedAt: "2026-02-09T12:00:00.000Z",
|
||||
},
|
||||
settings: {
|
||||
fps: 30,
|
||||
canvasSize: { width: 1920, height: 1080 },
|
||||
background: { type: "color", color: "#000000" },
|
||||
},
|
||||
scenes: [
|
||||
{
|
||||
id: "scene-main",
|
||||
name: "Main scene",
|
||||
isMain: true,
|
||||
bookmarks: [],
|
||||
createdAt: "2026-02-08T12:00:00.000Z",
|
||||
updatedAt: "2026-02-09T12:00:00.000Z",
|
||||
tracks: [
|
||||
{
|
||||
id: "track-video-main",
|
||||
name: "Main Track",
|
||||
type: "video",
|
||||
elements: [],
|
||||
muted: false,
|
||||
hidden: false,
|
||||
isMain: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
async function mockAuthAndCloudEditorApis({
|
||||
page,
|
||||
withPutRoute = false,
|
||||
}: {
|
||||
page: Page;
|
||||
withPutRoute?: boolean;
|
||||
}) {
|
||||
await page.route("**/api/auth/refresh", async (route: Route) => {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify({
|
||||
token: "backend-jwt-token",
|
||||
user: {
|
||||
id: "u-1",
|
||||
username: "john",
|
||||
email: "john@example.com",
|
||||
},
|
||||
}),
|
||||
});
|
||||
});
|
||||
|
||||
await page.route("**/api/editor/projects/*", async (route: Route) => {
|
||||
const workspaceHeader = route.request().headers()["x-workspace-id"];
|
||||
if (!workspaceHeader) {
|
||||
await route.fulfill({
|
||||
status: 400,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify({
|
||||
message: "x-workspace-id header is required",
|
||||
error: "Bad Request",
|
||||
statusCode: 400,
|
||||
}),
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (route.request().method() === "GET") {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify(CLOUD_PROJECT_RESPONSE),
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (route.request().method() === "PUT" && withPutRoute) {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify({
|
||||
id: PROJECT_ID,
|
||||
version: 4,
|
||||
updatedAt: new Date().toISOString(),
|
||||
}),
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
await route.fulfill({ status: 405 });
|
||||
});
|
||||
}
|
||||
|
||||
test.beforeEach(async ({ context, page }) => {
|
||||
await context.addCookies([
|
||||
{
|
||||
name: "refresh_token",
|
||||
value: "e2e-refresh-token",
|
||||
domain: "127.0.0.1",
|
||||
path: "/",
|
||||
httpOnly: false,
|
||||
secure: false,
|
||||
sameSite: "Lax",
|
||||
},
|
||||
]);
|
||||
|
||||
await page.addInitScript(() => {
|
||||
window.localStorage.setItem("opencut_workspace_id", "ws-1");
|
||||
});
|
||||
});
|
||||
|
||||
test("editor loads project from cloud when local project is missing", async ({
|
||||
page,
|
||||
}) => {
|
||||
await mockAuthAndCloudEditorApis({ page });
|
||||
|
||||
await page.goto(`/editor/${PROJECT_ID}`);
|
||||
|
||||
await expect(page.getByText("Cloud Synced Project")).toBeVisible();
|
||||
});
|
||||
|
||||
test("adding media to timeline triggers cloud sync PUT", async ({ page }) => {
|
||||
await mockAuthAndCloudEditorApis({ page, withPutRoute: true });
|
||||
|
||||
const putRequestPromise = page.waitForRequest(
|
||||
(request) =>
|
||||
request.method() === "PUT" &&
|
||||
request.url().includes(`/api/editor/projects/${PROJECT_ID}`),
|
||||
);
|
||||
|
||||
await page.goto(`/editor/${PROJECT_ID}`);
|
||||
await expect(page.getByText("Cloud Synced Project")).toBeVisible();
|
||||
|
||||
const pngBuffer = Buffer.from(
|
||||
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAusB9WlqvNQAAAAASUVORK5CYII=",
|
||||
"base64",
|
||||
);
|
||||
|
||||
await page.locator('input[type="file"]').setInputFiles({
|
||||
name: "test.png",
|
||||
mimeType: "image/png",
|
||||
buffer: pngBuffer,
|
||||
});
|
||||
|
||||
const uploadedMediaCard = page.locator("div", { hasText: "test.png" }).first();
|
||||
await expect(uploadedMediaCard).toBeVisible();
|
||||
await uploadedMediaCard.hover();
|
||||
await uploadedMediaCard.locator("button").first().click({ force: true });
|
||||
|
||||
const putRequest = await putRequestPromise;
|
||||
const putPayload = putRequest.postDataJSON() as Record<string, unknown>;
|
||||
expect(putPayload).toHaveProperty("state");
|
||||
expect(putPayload).toHaveProperty("assetFileIds");
|
||||
});
|
||||
|
|
@ -11,6 +11,11 @@
|
|||
"build:storybook": "storybook build",
|
||||
"test": "bun test",
|
||||
"test:coverage": "bun test --coverage --coverage-reporter=text --coverage-reporter=lcov --coverage-dir=coverage",
|
||||
"test:e2e": "playwright test",
|
||||
"test:e2e:ui": "playwright test --ui",
|
||||
"test:e2e:headed": "playwright test --headed",
|
||||
"test:e2e:debug": "playwright test --debug",
|
||||
"test:e2e:install": "playwright install",
|
||||
"lint": "biome check src/",
|
||||
"lint:fix": "biome check src/ --write",
|
||||
"format": "biome format src/ --write",
|
||||
|
|
@ -84,6 +89,7 @@
|
|||
"zustand": "^5.0.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@playwright/test": "^1.54.2",
|
||||
"@storybook/addon-a11y": "^8.6.14",
|
||||
"@storybook/addon-essentials": "^8.6.15",
|
||||
"@storybook/react": "^8.6.15",
|
||||
|
|
|
|||
|
|
@ -0,0 +1,20 @@
|
|||
import { defineConfig } from "@playwright/test";
|
||||
|
||||
export default defineConfig({
|
||||
testDir: "./e2e",
|
||||
fullyParallel: true,
|
||||
retries: process.env.CI ? 2 : 0,
|
||||
reporter: [["list"], ["html", { open: "never" }]],
|
||||
use: {
|
||||
baseURL: process.env.PLAYWRIGHT_BASE_URL ?? "http://127.0.0.1:3000",
|
||||
trace: "on-first-retry",
|
||||
},
|
||||
webServer: process.env.PLAYWRIGHT_BASE_URL
|
||||
? undefined
|
||||
: {
|
||||
command: "next dev --turbopack --hostname 127.0.0.1 --port 3000",
|
||||
url: "http://127.0.0.1:3000",
|
||||
reuseExistingServer: !process.env.CI,
|
||||
timeout: 120_000,
|
||||
},
|
||||
});
|
||||
|
|
@ -12,7 +12,6 @@ import { Timeline } from "@/components/editor/timeline";
|
|||
import { PreviewPanel } from "@/components/editor/panels/preview";
|
||||
import { EditorHeader } from "@/components/editor/editor-header";
|
||||
import { EditorProvider } from "@/components/providers/editor-provider";
|
||||
import { Onboarding } from "@/components/editor/onboarding";
|
||||
import { MigrationDialog } from "@/components/editor/dialogs/migration-dialog";
|
||||
import { usePanelStore } from "@/stores/panel-store";
|
||||
|
||||
|
|
@ -27,7 +26,6 @@ export default function Editor() {
|
|||
<div className="min-h-0 min-w-0 flex-1">
|
||||
<EditorLayout />
|
||||
</div>
|
||||
<Onboarding />
|
||||
<MigrationDialog />
|
||||
</div>
|
||||
</EditorProvider>
|
||||
|
|
|
|||
|
|
@ -49,6 +49,10 @@ import { Label } from "@/components/ui/label";
|
|||
import { useAuthStore } from "@/stores/auth-store";
|
||||
import { backendAuthApi } from "@/lib/auth/api-client";
|
||||
import { authSession } from "@/lib/auth/session";
|
||||
import {
|
||||
getSelectedWorkspaceId,
|
||||
setSelectedWorkspaceId,
|
||||
} from "@/lib/backend-api";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuCheckboxItem,
|
||||
|
|
@ -77,12 +81,17 @@ const formatProjectDuration = ({
|
|||
export default function ProjectsPage() {
|
||||
const { searchQuery, sortKey, sortOrder, viewMode } = useProjectsStore();
|
||||
const editor = useEditor();
|
||||
const authStatus = useAuthStore((state) => state.status);
|
||||
|
||||
useEffect(() => {
|
||||
if (authStatus === "authenticated") {
|
||||
editor.project.loadAllProjects();
|
||||
return;
|
||||
}
|
||||
if (!editor.project.getIsInitialized()) {
|
||||
editor.project.loadAllProjects();
|
||||
}
|
||||
}, [editor.project]);
|
||||
}, [authStatus, editor.project]);
|
||||
|
||||
const sortOption: TProjectSortOption = `${sortKey}-${sortOrder}`;
|
||||
const projectsToDisplay = editor.project.getFilteredAndSortedProjects({
|
||||
|
|
@ -146,6 +155,9 @@ function ProjectsHeader() {
|
|||
.getWorkspaces({ token })
|
||||
.then((workspaces) => {
|
||||
if (workspaces.length > 0) {
|
||||
if (!getSelectedWorkspaceId()) {
|
||||
setSelectedWorkspaceId({ workspaceId: workspaces[0].id });
|
||||
}
|
||||
setCurrentWorkspaceName(workspaces[0].name);
|
||||
} else {
|
||||
setCurrentWorkspaceName("No workspace");
|
||||
|
|
|
|||
|
|
@ -6,9 +6,14 @@ import { Button } from "@/components/ui/button";
|
|||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { backendAuthApi, type TWorkspace } from "@/lib/auth/api-client";
|
||||
import { authSession } from "@/lib/auth/session";
|
||||
import { getSelectedWorkspaceId, setSelectedWorkspaceId } from "@/lib/backend-api";
|
||||
import { toast } from "sonner";
|
||||
|
||||
function WorkspacesContent() {
|
||||
const [workspaces, setWorkspaces] = useState<TWorkspace[]>([]);
|
||||
const [selectedWorkspaceId, setSelectedWorkspaceIdState] = useState<string | null>(
|
||||
null,
|
||||
);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
|
|
@ -24,6 +29,13 @@ function WorkspacesContent() {
|
|||
.getWorkspaces({ token })
|
||||
.then((data) => {
|
||||
setWorkspaces(data);
|
||||
const current = getSelectedWorkspaceId();
|
||||
if (current) {
|
||||
setSelectedWorkspaceIdState(current);
|
||||
} else if (data.length > 0) {
|
||||
setSelectedWorkspaceId({ workspaceId: data[0].id });
|
||||
setSelectedWorkspaceIdState(data[0].id);
|
||||
}
|
||||
})
|
||||
.catch((requestError) => {
|
||||
setError(
|
||||
|
|
@ -51,9 +63,31 @@ function WorkspacesContent() {
|
|||
<div className="text-muted-foreground">No workspaces available.</div>
|
||||
) : (
|
||||
workspaces.map((workspace) => (
|
||||
<Card key={workspace.id}>
|
||||
<Card
|
||||
key={workspace.id}
|
||||
className={
|
||||
selectedWorkspaceId === workspace.id ? "border-primary" : undefined
|
||||
}
|
||||
>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">{workspace.name}</CardTitle>
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<CardTitle className="text-base">{workspace.name}</CardTitle>
|
||||
<Button
|
||||
variant={
|
||||
selectedWorkspaceId === workspace.id
|
||||
? "secondary"
|
||||
: "outline"
|
||||
}
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
setSelectedWorkspaceId({ workspaceId: workspace.id });
|
||||
setSelectedWorkspaceIdState(workspace.id);
|
||||
toast.success("Workspace selected");
|
||||
}}
|
||||
>
|
||||
{selectedWorkspaceId === workspace.id ? "Selected" : "Use"}
|
||||
</Button>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="text-sm text-muted-foreground">
|
||||
<div>ID: {workspace.id}</div>
|
||||
|
|
|
|||
|
|
@ -30,12 +30,19 @@ import { HugeiconsIcon } from "@hugeicons/react";
|
|||
import { ShortcutsDialog } from "./dialogs/shortcuts-dialog";
|
||||
|
||||
export function EditorHeader() {
|
||||
const editor = useEditor();
|
||||
const cloudSync = editor.project.getCloudSyncState();
|
||||
|
||||
return (
|
||||
<header className="bg-background flex h-[3.2rem] items-center justify-between px-3 pt-0.5">
|
||||
<div className="flex items-center gap-2">
|
||||
<ProjectDropdown />
|
||||
</div>
|
||||
<nav className="flex items-center gap-2">
|
||||
<CloudSyncBadge
|
||||
status={cloudSync.status}
|
||||
message={cloudSync.message}
|
||||
/>
|
||||
<ExportButton />
|
||||
<ThemeToggle />
|
||||
</nav>
|
||||
|
|
@ -43,6 +50,47 @@ export function EditorHeader() {
|
|||
);
|
||||
}
|
||||
|
||||
function CloudSyncBadge({
|
||||
status,
|
||||
message,
|
||||
}: {
|
||||
status:
|
||||
| "idle"
|
||||
| "disabled"
|
||||
| "syncing"
|
||||
| "synced"
|
||||
| "error"
|
||||
| "conflict";
|
||||
message: string | null;
|
||||
}) {
|
||||
const labelByStatus: Record<typeof status, string> = {
|
||||
idle: "Cloud idle",
|
||||
disabled: "Cloud off",
|
||||
syncing: "Syncing...",
|
||||
synced: "Cloud synced",
|
||||
error: "Sync error",
|
||||
conflict: "Sync conflict",
|
||||
};
|
||||
|
||||
const classNameByStatus: Record<typeof status, string> = {
|
||||
idle: "text-muted-foreground",
|
||||
disabled: "text-muted-foreground",
|
||||
syncing: "text-foreground",
|
||||
synced: "text-constructive",
|
||||
error: "text-destructive",
|
||||
conflict: "text-amber-500",
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`hidden md:block text-xs ${classNameByStatus[status]}`}
|
||||
title={message ?? undefined}
|
||||
>
|
||||
{labelByStatus[status]}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ProjectDropdown() {
|
||||
const [openDialog, setOpenDialog] = useState<
|
||||
"delete" | "rename" | "shortcuts" | null
|
||||
|
|
|
|||
|
|
@ -27,6 +27,17 @@ import {
|
|||
type MigrationProgress,
|
||||
} from "@/services/storage/migrations";
|
||||
import { DEFAULT_TIMELINE_VIEW_STATE } from "@/constants/timeline-constants";
|
||||
import { authSession } from "@/lib/auth/session";
|
||||
import {
|
||||
EditorSyncError,
|
||||
editorCloudApi,
|
||||
EditorVersionConflictError,
|
||||
} from "@/lib/cloud-sync/editor-api";
|
||||
import {
|
||||
buildEditorProjectState,
|
||||
buildLocalProjectFromCloudState,
|
||||
extractAssetFileIds,
|
||||
} from "@/lib/cloud-sync/editor-mapper";
|
||||
|
||||
export interface MigrationState {
|
||||
isMigrating: boolean;
|
||||
|
|
@ -35,6 +46,20 @@ export interface MigrationState {
|
|||
projectName: string | null;
|
||||
}
|
||||
|
||||
export type CloudSyncStatus =
|
||||
| "idle"
|
||||
| "disabled"
|
||||
| "syncing"
|
||||
| "synced"
|
||||
| "error"
|
||||
| "conflict";
|
||||
|
||||
export interface CloudSyncState {
|
||||
status: CloudSyncStatus;
|
||||
message: string | null;
|
||||
updatedAt: Date | null;
|
||||
}
|
||||
|
||||
export class ProjectManager {
|
||||
private active: TProject | null = null;
|
||||
private savedProjects: TProjectMetadata[] = [];
|
||||
|
|
@ -43,6 +68,12 @@ export class ProjectManager {
|
|||
private invalidProjectIds = new Set<string>();
|
||||
private storageMigrationPromise: Promise<void> | null = null;
|
||||
private listeners = new Set<() => void>();
|
||||
private remoteVersionByProjectId = new Map<string, number>();
|
||||
private cloudSyncState: CloudSyncState = {
|
||||
status: "idle",
|
||||
message: null,
|
||||
updatedAt: null,
|
||||
};
|
||||
private migrationState: MigrationState = {
|
||||
isMigrating: false,
|
||||
fromVersion: null,
|
||||
|
|
@ -106,6 +137,7 @@ export class ProjectManager {
|
|||
|
||||
try {
|
||||
await storageService.saveProject({ project: newProject });
|
||||
this.remoteVersionByProjectId.set(newProject.metadata.id, 0);
|
||||
this.updateMetadata(newProject);
|
||||
|
||||
return newProject.metadata.id;
|
||||
|
|
@ -127,7 +159,38 @@ export class ProjectManager {
|
|||
this.editor.scenes.clearScenes();
|
||||
|
||||
try {
|
||||
const result = await storageService.loadProject({ id });
|
||||
let result = await storageService.loadProject({ id });
|
||||
|
||||
if (!result) {
|
||||
const token = authSession.getToken();
|
||||
if (token) {
|
||||
try {
|
||||
const remoteProject = await editorCloudApi.getProject({
|
||||
projectId: id,
|
||||
token,
|
||||
});
|
||||
const hydratedProject = buildLocalProjectFromCloudState({
|
||||
projectId: remoteProject.id,
|
||||
version: remoteProject.version,
|
||||
state: remoteProject.state,
|
||||
updatedAt: remoteProject.updatedAt,
|
||||
createdAt: remoteProject.createdAt,
|
||||
});
|
||||
await storageService.saveProject({ project: hydratedProject });
|
||||
result = { project: hydratedProject };
|
||||
this.remoteVersionByProjectId.set(id, remoteProject.version);
|
||||
} catch (cloudError) {
|
||||
if (
|
||||
cloudError instanceof EditorSyncError &&
|
||||
cloudError.statusCode === 404
|
||||
) {
|
||||
throw new Error(`Project with id ${id} not found`);
|
||||
}
|
||||
throw cloudError;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!result) {
|
||||
throw new Error(`Project with id ${id} not found`);
|
||||
}
|
||||
|
|
@ -145,6 +208,7 @@ export class ProjectManager {
|
|||
}
|
||||
|
||||
await this.editor.media.loadProjectMedia({ projectId: id });
|
||||
void this.initializeRemoteVersionFromCloud({ projectId: id });
|
||||
|
||||
if (!project.metadata.thumbnail) {
|
||||
const didUpdateThumbnail = await this.updateThumbnailFromTimeline();
|
||||
|
|
@ -180,6 +244,7 @@ export class ProjectManager {
|
|||
await storageService.saveProject({ project: updatedProject });
|
||||
this.active = updatedProject;
|
||||
this.updateMetadata(updatedProject);
|
||||
void this.syncProjectToCloud({ project: updatedProject });
|
||||
} catch (error) {
|
||||
console.error("Failed to save project:", error);
|
||||
}
|
||||
|
|
@ -197,11 +262,31 @@ export class ProjectManager {
|
|||
|
||||
await this.ensureStorageMigrations();
|
||||
try {
|
||||
const metadata = await storageService.loadAllProjectsMetadata();
|
||||
this.savedProjects = metadata;
|
||||
const token = authSession.getToken();
|
||||
if (token) {
|
||||
const remoteProjects = await editorCloudApi.listProjects({ token });
|
||||
this.savedProjects = remoteProjects.items.map((project) => ({
|
||||
id: project.id,
|
||||
name: project.name,
|
||||
duration: 0,
|
||||
createdAt: new Date(project.createdAt),
|
||||
updatedAt: new Date(project.updatedAt),
|
||||
}));
|
||||
} else {
|
||||
const metadata = await storageService.loadAllProjectsMetadata();
|
||||
this.savedProjects = metadata;
|
||||
}
|
||||
this.notify();
|
||||
} catch (error) {
|
||||
console.error("Failed to load projects:", error);
|
||||
// Fallback to local metadata when cloud list fails.
|
||||
try {
|
||||
const metadata = await storageService.loadAllProjectsMetadata();
|
||||
this.savedProjects = metadata;
|
||||
this.notify();
|
||||
} catch (fallbackError) {
|
||||
console.error("Failed to load local projects fallback:", fallbackError);
|
||||
}
|
||||
} finally {
|
||||
this.isLoading = false;
|
||||
this.isInitialized = true;
|
||||
|
|
@ -555,6 +640,10 @@ export class ProjectManager {
|
|||
return this.migrationState;
|
||||
}
|
||||
|
||||
getCloudSyncState(): CloudSyncState {
|
||||
return this.cloudSyncState;
|
||||
}
|
||||
|
||||
setActiveProject({ project }: { project: TProject }): void {
|
||||
this.active = project;
|
||||
this.notify();
|
||||
|
|
@ -623,4 +712,92 @@ export class ProjectManager {
|
|||
private notify(): void {
|
||||
this.listeners.forEach((fn) => fn());
|
||||
}
|
||||
|
||||
private setCloudSyncState({
|
||||
status,
|
||||
message = null,
|
||||
}: {
|
||||
status: CloudSyncStatus;
|
||||
message?: string | null;
|
||||
}): void {
|
||||
this.cloudSyncState = {
|
||||
status,
|
||||
message,
|
||||
updatedAt: new Date(),
|
||||
};
|
||||
this.notify();
|
||||
}
|
||||
|
||||
private async syncProjectToCloud({ project }: { project: TProject }): Promise<void> {
|
||||
const token = authSession.getToken();
|
||||
if (!token) {
|
||||
this.setCloudSyncState({
|
||||
status: "disabled",
|
||||
message: "Login required for cloud sync",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
this.setCloudSyncState({ status: "syncing", message: null });
|
||||
|
||||
const projectId = project.metadata.id;
|
||||
const baseVersion = this.remoteVersionByProjectId.get(projectId) ?? 0;
|
||||
|
||||
try {
|
||||
const response = await editorCloudApi.putProject({
|
||||
projectId,
|
||||
token,
|
||||
payload: {
|
||||
name: project.metadata.name,
|
||||
baseVersion,
|
||||
state: buildEditorProjectState({ project }),
|
||||
assetFileIds: extractAssetFileIds({ project }),
|
||||
clientRequestId: `${projectId}:${project.metadata.updatedAt.toISOString()}`,
|
||||
},
|
||||
});
|
||||
|
||||
this.remoteVersionByProjectId.set(projectId, response.version);
|
||||
this.setCloudSyncState({ status: "synced", message: null });
|
||||
} catch (error) {
|
||||
if (error instanceof EditorVersionConflictError) {
|
||||
this.remoteVersionByProjectId.set(projectId, error.serverVersion);
|
||||
this.setCloudSyncState({
|
||||
status: "conflict",
|
||||
message: "Cloud version conflict. Save again to overwrite with local state.",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (error instanceof EditorSyncError) {
|
||||
this.setCloudSyncState({
|
||||
status: "error",
|
||||
message: error.message,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
this.setCloudSyncState({
|
||||
status: "error",
|
||||
message: "Cloud sync failed",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private async initializeRemoteVersionFromCloud({
|
||||
projectId,
|
||||
}: {
|
||||
projectId: string;
|
||||
}): Promise<void> {
|
||||
const token = authSession.getToken();
|
||||
if (!token) return;
|
||||
|
||||
try {
|
||||
const response = await editorCloudApi.getProject({ projectId, token });
|
||||
this.remoteVersionByProjectId.set(projectId, response.version);
|
||||
} catch (error) {
|
||||
if (error instanceof EditorSyncError && error.statusCode === 404) {
|
||||
this.remoteVersionByProjectId.set(projectId, 0);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,3 +1,5 @@
|
|||
import { getBackendApiBaseUrl } from "@/lib/backend-api";
|
||||
|
||||
export type TBackendAuthError = {
|
||||
status?: string;
|
||||
message?: string;
|
||||
|
|
@ -42,14 +44,6 @@ export class BackendAuthError extends Error {
|
|||
}
|
||||
}
|
||||
|
||||
const getAuthApiBaseUrl = () => {
|
||||
const configuredBase = process.env.NEXT_PUBLIC_AUTH_API_BASE_URL;
|
||||
return (configuredBase && configuredBase.length > 0 ? configuredBase : "/api").replace(
|
||||
/\/$/,
|
||||
"",
|
||||
);
|
||||
};
|
||||
|
||||
const unwrapSuccessPayload = <T,>(payload: TAuthSuccessPayload<T>): T => {
|
||||
if (payload && typeof payload === "object" && "data" in payload) {
|
||||
return payload.data;
|
||||
|
|
@ -88,7 +82,7 @@ async function request<T>({
|
|||
body?: unknown;
|
||||
token?: string | null;
|
||||
}): Promise<T> {
|
||||
const url = `${getAuthApiBaseUrl()}${path}`;
|
||||
const url = `${getBackendApiBaseUrl()}${path}`;
|
||||
const response = await fetch(url, {
|
||||
method,
|
||||
credentials: "include",
|
||||
|
|
|
|||
|
|
@ -0,0 +1,21 @@
|
|||
export const getBackendApiBaseUrl = () => {
|
||||
const configuredBase = process.env.NEXT_PUBLIC_AUTH_API_BASE_URL;
|
||||
return (configuredBase && configuredBase.length > 0 ? configuredBase : "/api").replace(
|
||||
/\/$/,
|
||||
"",
|
||||
);
|
||||
};
|
||||
|
||||
const WORKSPACE_STORAGE_KEY = "opencut_workspace_id";
|
||||
|
||||
export const getSelectedWorkspaceId = (): string | null => {
|
||||
if (typeof window === "undefined") return null;
|
||||
|
||||
const fromStorage = window.localStorage.getItem(WORKSPACE_STORAGE_KEY);
|
||||
return fromStorage || null;
|
||||
};
|
||||
|
||||
export const setSelectedWorkspaceId = ({ workspaceId }: { workspaceId: string }) => {
|
||||
if (typeof window === "undefined") return;
|
||||
window.localStorage.setItem(WORKSPACE_STORAGE_KEY, workspaceId);
|
||||
};
|
||||
|
|
@ -0,0 +1,246 @@
|
|||
import {
|
||||
getBackendApiBaseUrl,
|
||||
getSelectedWorkspaceId,
|
||||
setSelectedWorkspaceId,
|
||||
} from "@/lib/backend-api";
|
||||
import { backendAuthApi } from "@/lib/auth/api-client";
|
||||
import { authSession } from "@/lib/auth/session";
|
||||
|
||||
export type ISODateString = string;
|
||||
export type UUID = string;
|
||||
|
||||
export interface EditorProjectState {
|
||||
schemaVersion: number;
|
||||
currentSceneId: string;
|
||||
metadata: {
|
||||
id: string;
|
||||
name: string;
|
||||
duration: number;
|
||||
updatedAt: ISODateString;
|
||||
};
|
||||
settings: Record<string, unknown>;
|
||||
scenes: Array<Record<string, unknown>>;
|
||||
}
|
||||
|
||||
export interface PutEditorProjectRequest {
|
||||
name: string;
|
||||
baseVersion: number;
|
||||
state: EditorProjectState;
|
||||
assetFileIds: UUID[];
|
||||
clientRequestId?: string;
|
||||
}
|
||||
|
||||
export interface PutEditorProjectResponse {
|
||||
id: UUID;
|
||||
version: number;
|
||||
updatedAt: ISODateString;
|
||||
}
|
||||
|
||||
export interface EditorProjectListItem {
|
||||
id: UUID;
|
||||
workspaceId: string;
|
||||
ownerId: number;
|
||||
name: string;
|
||||
version: number;
|
||||
updatedAt: ISODateString;
|
||||
createdAt: ISODateString;
|
||||
}
|
||||
|
||||
export interface ListEditorProjectsResponse {
|
||||
items: EditorProjectListItem[];
|
||||
total: number;
|
||||
}
|
||||
|
||||
export interface GetEditorProjectResponse {
|
||||
id: UUID;
|
||||
workspaceId: string;
|
||||
ownerId: number;
|
||||
name: string;
|
||||
version: number;
|
||||
state: EditorProjectState;
|
||||
updatedAt: ISODateString;
|
||||
createdAt: ISODateString;
|
||||
}
|
||||
|
||||
export class EditorSyncError extends Error {
|
||||
public readonly statusCode: number;
|
||||
|
||||
constructor({ message, statusCode }: { message: string; statusCode: number }) {
|
||||
super(message);
|
||||
this.name = "EditorSyncError";
|
||||
this.statusCode = statusCode;
|
||||
}
|
||||
}
|
||||
|
||||
export class EditorVersionConflictError extends Error {
|
||||
public readonly serverVersion: number;
|
||||
public readonly serverUpdatedAt: string;
|
||||
|
||||
constructor({
|
||||
message,
|
||||
serverVersion,
|
||||
serverUpdatedAt,
|
||||
}: {
|
||||
message: string;
|
||||
serverVersion: number;
|
||||
serverUpdatedAt: string;
|
||||
}) {
|
||||
super(message);
|
||||
this.name = "EditorVersionConflictError";
|
||||
this.serverVersion = serverVersion;
|
||||
this.serverUpdatedAt = serverUpdatedAt;
|
||||
}
|
||||
}
|
||||
|
||||
const requestWithAuthRetry = async <T>({
|
||||
path,
|
||||
method,
|
||||
token,
|
||||
body,
|
||||
}: {
|
||||
path: string;
|
||||
method: "GET" | "PUT";
|
||||
token: string;
|
||||
body?: unknown;
|
||||
}): Promise<T> => {
|
||||
const resolveWorkspaceId = async ({
|
||||
currentToken,
|
||||
}: {
|
||||
currentToken: string;
|
||||
}): Promise<string> => {
|
||||
const selectedWorkspaceId = getSelectedWorkspaceId();
|
||||
if (selectedWorkspaceId) {
|
||||
return selectedWorkspaceId;
|
||||
}
|
||||
|
||||
const workspaces = await backendAuthApi.getWorkspaces({ token: currentToken });
|
||||
const firstWorkspace = workspaces[0];
|
||||
if (!firstWorkspace) {
|
||||
throw new EditorSyncError({
|
||||
message: "No workspace available for this account.",
|
||||
statusCode: 400,
|
||||
});
|
||||
}
|
||||
setSelectedWorkspaceId({ workspaceId: firstWorkspace.id });
|
||||
return firstWorkspace.id;
|
||||
};
|
||||
|
||||
const runRequest = async ({
|
||||
currentToken,
|
||||
}: {
|
||||
currentToken: string;
|
||||
}): Promise<Response> => {
|
||||
const workspaceId = await resolveWorkspaceId({ currentToken });
|
||||
return fetch(`${getBackendApiBaseUrl()}${path}`, {
|
||||
method,
|
||||
credentials: "include",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: `Bearer ${currentToken}`,
|
||||
"x-workspace-id": workspaceId,
|
||||
},
|
||||
body: body ? JSON.stringify(body) : undefined,
|
||||
});
|
||||
};
|
||||
|
||||
let response = await runRequest({ currentToken: token });
|
||||
if (response.status === 401) {
|
||||
try {
|
||||
const refreshed = await backendAuthApi.refresh();
|
||||
authSession.setToken({ token: refreshed.token });
|
||||
response = await runRequest({ currentToken: refreshed.token });
|
||||
} catch {
|
||||
throw new EditorSyncError({
|
||||
message: "Session expired",
|
||||
statusCode: 401,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const responseJson = (await response.json().catch(() => null)) as
|
||||
| Record<string, unknown>
|
||||
| null;
|
||||
|
||||
if (!response.ok) {
|
||||
if (response.status === 409 && responseJson?.error === "VERSION_CONFLICT") {
|
||||
throw new EditorVersionConflictError({
|
||||
message:
|
||||
typeof responseJson.message === "string"
|
||||
? responseJson.message
|
||||
: "Project has a newer version on server",
|
||||
serverVersion:
|
||||
typeof responseJson.serverVersion === "number"
|
||||
? responseJson.serverVersion
|
||||
: 0,
|
||||
serverUpdatedAt:
|
||||
typeof responseJson.serverUpdatedAt === "string"
|
||||
? responseJson.serverUpdatedAt
|
||||
: "",
|
||||
});
|
||||
}
|
||||
|
||||
throw new EditorSyncError({
|
||||
message:
|
||||
typeof responseJson?.message === "string"
|
||||
? responseJson.message
|
||||
: `Editor API request failed (${response.status})`,
|
||||
statusCode: response.status,
|
||||
});
|
||||
}
|
||||
|
||||
return responseJson as T;
|
||||
};
|
||||
|
||||
export const editorCloudApi = {
|
||||
listProjects: ({
|
||||
token,
|
||||
offset = 0,
|
||||
limit = 50,
|
||||
search,
|
||||
}: {
|
||||
token: string;
|
||||
offset?: number;
|
||||
limit?: number;
|
||||
search?: string;
|
||||
}) => {
|
||||
const params = new URLSearchParams({
|
||||
offset: String(offset),
|
||||
limit: String(limit),
|
||||
});
|
||||
if (search) {
|
||||
params.set("search", search);
|
||||
}
|
||||
return requestWithAuthRetry<ListEditorProjectsResponse>({
|
||||
path: `/editor/projects?${params.toString()}`,
|
||||
method: "GET",
|
||||
token,
|
||||
});
|
||||
},
|
||||
getProject: ({
|
||||
projectId,
|
||||
token,
|
||||
}: {
|
||||
projectId: string;
|
||||
token: string;
|
||||
}) =>
|
||||
requestWithAuthRetry<GetEditorProjectResponse>({
|
||||
path: `/editor/projects/${projectId}`,
|
||||
method: "GET",
|
||||
token,
|
||||
}),
|
||||
putProject: ({
|
||||
projectId,
|
||||
token,
|
||||
payload,
|
||||
}: {
|
||||
projectId: string;
|
||||
token: string;
|
||||
payload: PutEditorProjectRequest;
|
||||
}) =>
|
||||
requestWithAuthRetry<PutEditorProjectResponse>({
|
||||
path: `/editor/projects/${projectId}`,
|
||||
method: "PUT",
|
||||
token,
|
||||
body: payload,
|
||||
}),
|
||||
};
|
||||
|
|
@ -0,0 +1,109 @@
|
|||
import type { TProject } from "@/types/project";
|
||||
import type { TimelineTrack } from "@/types/timeline";
|
||||
import { hasMediaId } from "@/lib/timeline/element-utils";
|
||||
import type { EditorProjectState } from "./editor-api";
|
||||
import type { TScene } from "@/types/timeline";
|
||||
|
||||
const stripAudioBuffers = ({ tracks }: { tracks: TimelineTrack[] }): TimelineTrack[] =>
|
||||
tracks.map((track) => {
|
||||
if (track.type !== "audio") return track;
|
||||
return {
|
||||
...track,
|
||||
elements: track.elements.map((element) => {
|
||||
const { buffer: _buffer, ...rest } = element;
|
||||
return rest;
|
||||
}),
|
||||
};
|
||||
});
|
||||
|
||||
const toPlainJson = <T,>(value: T): T =>
|
||||
JSON.parse(
|
||||
JSON.stringify(value, (_key, item) => {
|
||||
if (item instanceof Date) {
|
||||
return item.toISOString();
|
||||
}
|
||||
return item;
|
||||
}),
|
||||
) as T;
|
||||
|
||||
export const buildEditorProjectState = ({
|
||||
project,
|
||||
}: {
|
||||
project: TProject;
|
||||
}): EditorProjectState => {
|
||||
const scenes = project.scenes.map((scene) => ({
|
||||
...scene,
|
||||
tracks: stripAudioBuffers({ tracks: scene.tracks }),
|
||||
}));
|
||||
|
||||
return toPlainJson({
|
||||
schemaVersion: project.version,
|
||||
currentSceneId: project.currentSceneId,
|
||||
metadata: {
|
||||
id: project.metadata.id,
|
||||
name: project.metadata.name,
|
||||
duration: project.metadata.duration,
|
||||
updatedAt: project.metadata.updatedAt.toISOString(),
|
||||
},
|
||||
settings: project.settings as unknown as Record<string, unknown>,
|
||||
scenes: scenes as unknown as Array<Record<string, unknown>>,
|
||||
}) as EditorProjectState;
|
||||
};
|
||||
|
||||
export const extractAssetFileIds = ({ project }: { project: TProject }): string[] => {
|
||||
const ids = new Set<string>();
|
||||
|
||||
for (const scene of project.scenes) {
|
||||
for (const track of scene.tracks) {
|
||||
for (const element of track.elements) {
|
||||
if (hasMediaId(element)) {
|
||||
ids.add(element.mediaId);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return Array.from(ids);
|
||||
};
|
||||
|
||||
export const buildLocalProjectFromCloudState = ({
|
||||
projectId,
|
||||
version,
|
||||
state,
|
||||
updatedAt,
|
||||
createdAt,
|
||||
}: {
|
||||
projectId: string;
|
||||
version: number;
|
||||
state: EditorProjectState;
|
||||
updatedAt: string;
|
||||
createdAt: string;
|
||||
}): TProject => {
|
||||
const now = new Date();
|
||||
const scenes = (state.scenes as unknown as TScene[]).map((scene) => ({
|
||||
...scene,
|
||||
createdAt:
|
||||
scene.createdAt instanceof Date
|
||||
? scene.createdAt
|
||||
: new Date((scene.createdAt as unknown as string) ?? now),
|
||||
updatedAt:
|
||||
scene.updatedAt instanceof Date
|
||||
? scene.updatedAt
|
||||
: new Date((scene.updatedAt as unknown as string) ?? now),
|
||||
bookmarks: Array.isArray(scene.bookmarks) ? scene.bookmarks : [],
|
||||
}));
|
||||
|
||||
return {
|
||||
metadata: {
|
||||
id: projectId,
|
||||
name: state.metadata.name,
|
||||
duration: state.metadata.duration,
|
||||
createdAt: new Date(createdAt),
|
||||
updatedAt: new Date(updatedAt),
|
||||
},
|
||||
currentSceneId: state.currentSceneId,
|
||||
settings: state.settings as unknown as TProject["settings"],
|
||||
scenes,
|
||||
version: Math.max(1, version),
|
||||
};
|
||||
};
|
||||
File diff suppressed because one or more lines are too long
9
bun.lock
9
bun.lock
|
|
@ -83,6 +83,7 @@
|
|||
"zustand": "^5.0.2",
|
||||
},
|
||||
"devDependencies": {
|
||||
"@playwright/test": "^1.54.2",
|
||||
"@storybook/addon-a11y": "^8.6.14",
|
||||
"@storybook/addon-essentials": "^8.6.15",
|
||||
"@storybook/react": "^8.6.15",
|
||||
|
|
@ -373,6 +374,8 @@
|
|||
|
||||
"@pkgjs/parseargs": ["@pkgjs/parseargs@0.11.0", "", {}, "sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg=="],
|
||||
|
||||
"@playwright/test": ["@playwright/test@1.58.2", "", { "dependencies": { "playwright": "1.58.2" }, "bin": { "playwright": "cli.js" } }, "sha512-akea+6bHYBBfA9uQqSYmlJXn61cTa+jbO87xVLCWbTqbWadRVmhxlXATaOjOgcBaWU4ePo0wB41KMFv3o35IXA=="],
|
||||
|
||||
"@protobufjs/aspromise": ["@protobufjs/aspromise@1.1.2", "", {}, "sha512-j+gKExEuLmKwvz3OgROXtrJ2UG2x8Ch2YZUxahh+s1F2HZ+wAceUNLkvy6zKCPVRkU++ZWQrdxsUeQXmcg4uoQ=="],
|
||||
|
||||
"@protobufjs/base64": ["@protobufjs/base64@1.1.2", "", {}, "sha512-AZkcAA5vnN/v4PDqKyMR5lx7hZttPDgClv83E//FMNhR2TMcLUhfRUBHCmSl0oi9zMgDDqRUJkSxO3wm85+XLg=="],
|
||||
|
|
@ -1301,6 +1304,10 @@
|
|||
|
||||
"platform": ["platform@1.3.6", "", {}, "sha512-fnWVljUchTro6RiCFvCXBbNhJc2NijN7oIQxbwsyL0buWJPG85v81ehlHI9fXrJsMNgTofEoWIQeClKpgxFLrg=="],
|
||||
|
||||
"playwright": ["playwright@1.58.2", "", { "dependencies": { "playwright-core": "1.58.2" }, "optionalDependencies": { "fsevents": "2.3.2" }, "bin": { "playwright": "cli.js" } }, "sha512-vA30H8Nvkq/cPBnNw4Q8TWz1EJyqgpuinBcHET0YVJVFldr8JDNiU9LaWAE1KqSkRYazuaBhTpB5ZzShOezQ6A=="],
|
||||
|
||||
"playwright-core": ["playwright-core@1.58.2", "", { "bin": { "playwright-core": "cli.js" } }, "sha512-yZkEtftgwS8CsfYo7nm0KE8jsvm6i/PTgVtB8DL726wNf6H2IMsDuxCpJj59KDaxCtSnrWan2AeDqM7JBaultg=="],
|
||||
|
||||
"polished": ["polished@4.3.1", "", { "dependencies": { "@babel/runtime": "^7.17.8" } }, "sha512-OBatVyC/N7SCW/FaDHrSd+vn0o5cS855TOmYi4OkdWUMSJCET/xip//ch8xGUvtr3i44X9LVyWwQlRMTN3pwSA=="],
|
||||
|
||||
"possible-typed-array-names": ["possible-typed-array-names@1.1.0", "", {}, "sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg=="],
|
||||
|
|
@ -1753,6 +1760,8 @@
|
|||
|
||||
"parse-entities/@types/unist": ["@types/unist@2.0.11", "", {}, "sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA=="],
|
||||
|
||||
"playwright/fsevents": ["fsevents@2.3.2", "", { "os": "darwin" }, "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA=="],
|
||||
|
||||
"postcss/nanoid": ["nanoid@3.3.11", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w=="],
|
||||
|
||||
"pretty-format/ansi-styles": ["ansi-styles@5.2.0", "", {}, "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA=="],
|
||||
|
|
|
|||
|
|
@ -18,7 +18,9 @@
|
|||
"format:web": "biome format apps/web/src/services/renderer --write --max-diagnostics=1000",
|
||||
"test": "bun test",
|
||||
"test:web": "turbo run test --filter=@opencut/web",
|
||||
"test:web:coverage": "turbo run test:coverage --filter=@opencut/web"
|
||||
"test:web:coverage": "turbo run test:coverage --filter=@opencut/web",
|
||||
"test:e2e:web": "bun run --cwd apps/web test:e2e",
|
||||
"test:e2e:web:ui": "bun run --cwd apps/web test:e2e:ui"
|
||||
},
|
||||
"dependencies": {
|
||||
"@types/react": "^19.2.10",
|
||||
|
|
|
|||
|
|
@ -20,10 +20,14 @@ Provide reliable sync of project state and media assets across devices/accounts.
|
|||
|
||||
## Technical Plan
|
||||
1. Backend API
|
||||
- `GET /api/projects`
|
||||
- `GET /api/projects/:id`
|
||||
- `PUT /api/projects/:id` (optimistic concurrency)
|
||||
- `POST /api/projects/:id/media/presign`
|
||||
- Editor state endpoints (timeline, config, and project snapshot data):
|
||||
- `GET /api/editor/projects`
|
||||
- `GET /api/editor/projects/:id`
|
||||
- `PUT /api/editor/projects/:id` (optimistic concurrency)
|
||||
- File asset endpoints (reuse existing file service):
|
||||
- Base path: `/api/files`
|
||||
- Use existing endpoints for upload/list/download/metadata (`/api/files`, `/api/files/:id`, `/api/files/:id/download`, etc.)
|
||||
- Keep media upload/download concerns in file service; editor project payload stores file references (`fileId`/`key`) instead of raw binaries.
|
||||
|
||||
2. Client Sync Service
|
||||
- Hook into existing autosave flow after local save success.
|
||||
|
|
|
|||
|
|
@ -0,0 +1,188 @@
|
|||
# Editor Backend API Spec (`/api/editor`)
|
||||
|
||||
## Purpose
|
||||
Provide a backend contract for storing and syncing editor project state (timeline, scenes, settings, metadata).
|
||||
|
||||
## Scope
|
||||
- Persist editor state as JSON snapshots.
|
||||
- Support optimistic concurrency for autosave sync.
|
||||
- Support project listing and retrieval across devices.
|
||||
|
||||
## Out of Scope (v1)
|
||||
- Real-time collaborative editing.
|
||||
- Server-side timeline merge.
|
||||
- Asset transcoding.
|
||||
|
||||
## Auth and Tenancy
|
||||
- All endpoints require `Authorization: Bearer <JWT>`.
|
||||
- All reads/writes are scoped to the authenticated user and workspace context.
|
||||
- Return `401` when JWT/context is invalid.
|
||||
- Return `404` when project does not exist in the caller workspace.
|
||||
|
||||
## Data Model
|
||||
|
||||
### `editor_projects`
|
||||
- `id` UUID PK
|
||||
- `workspace_id` string not null
|
||||
- `owner_id` bigint/int not null
|
||||
- `name` string(255) not null
|
||||
- `state_json` JSONB not null
|
||||
- `version` bigint not null default `1`
|
||||
- `created_at` timestamptz not null
|
||||
- `updated_at` timestamptz not null
|
||||
- `deleted_at` timestamptz nullable
|
||||
|
||||
Indexes:
|
||||
- `(workspace_id, updated_at desc)`
|
||||
- `(workspace_id, owner_id, updated_at desc)`
|
||||
- Optional GIN on `state_json` if querying inside JSON is needed later.
|
||||
|
||||
## Project State Envelope (stored in `state_json`)
|
||||
Use a versioned envelope so backend can validate shape at a high level while keeping timeline details client-owned.
|
||||
|
||||
```json
|
||||
{
|
||||
"schemaVersion": 3,
|
||||
"currentSceneId": "scene_main",
|
||||
"metadata": {
|
||||
"id": "proj_123",
|
||||
"name": "My Project",
|
||||
"duration": 42.5,
|
||||
"updatedAt": "2026-02-09T16:00:00.000Z"
|
||||
},
|
||||
"settings": {},
|
||||
"scenes": []
|
||||
}
|
||||
```
|
||||
|
||||
Validation (v1):
|
||||
- `schemaVersion` required integer.
|
||||
- `scenes` required array.
|
||||
- JSON payload size limit: recommend `<= 5 MB` (return `413` if exceeded).
|
||||
- Backend should not mutate timeline internals except metadata timestamps/version fields it owns.
|
||||
|
||||
## API Contract
|
||||
|
||||
Base path: `/api/editor`
|
||||
|
||||
### 1) List Projects
|
||||
`GET /api/editor/projects?offset=0&limit=20&search=my`
|
||||
|
||||
Query:
|
||||
- `offset` optional int `>= 0`, default `0`
|
||||
- `limit` optional int `1..100`, default `20`
|
||||
- `search` optional string (name contains)
|
||||
|
||||
Response `200`:
|
||||
```json
|
||||
{
|
||||
"items": [
|
||||
{
|
||||
"id": "uuid",
|
||||
"workspaceId": "ws_123",
|
||||
"ownerId": 1,
|
||||
"name": "My Project",
|
||||
"version": 8,
|
||||
"updatedAt": "2026-02-09T16:00:00.000Z",
|
||||
"createdAt": "2026-02-08T16:00:00.000Z"
|
||||
}
|
||||
],
|
||||
"total": 1
|
||||
}
|
||||
```
|
||||
|
||||
### 2) Get Project
|
||||
`GET /api/editor/projects/:id`
|
||||
|
||||
Response `200`:
|
||||
```json
|
||||
{
|
||||
"id": "uuid",
|
||||
"workspaceId": "ws_123",
|
||||
"ownerId": 1,
|
||||
"name": "My Project",
|
||||
"version": 8,
|
||||
"state": {},
|
||||
"updatedAt": "2026-02-09T16:00:00.000Z",
|
||||
"createdAt": "2026-02-08T16:00:00.000Z"
|
||||
}
|
||||
```
|
||||
|
||||
Errors:
|
||||
- `404` project not found in workspace
|
||||
|
||||
### 3) Upsert Project State (Optimistic Concurrency)
|
||||
`PUT /api/editor/projects/:id`
|
||||
|
||||
Request:
|
||||
```json
|
||||
{
|
||||
"name": "My Project",
|
||||
"baseVersion": 8,
|
||||
"state": {},
|
||||
"clientRequestId": "optional-idempotency-key"
|
||||
}
|
||||
```
|
||||
|
||||
Rules:
|
||||
- If project does not exist, allow create when caller has write access. New record starts at `version = 1`.
|
||||
- If project exists, `baseVersion` must equal current `version`.
|
||||
- On success, increment version by 1 (or set to 1 for create), set `updated_at = now()`.
|
||||
- If `baseVersion` mismatch, return conflict.
|
||||
|
||||
Success `200`:
|
||||
```json
|
||||
{
|
||||
"id": "uuid",
|
||||
"version": 9,
|
||||
"updatedAt": "2026-02-09T16:00:00.000Z"
|
||||
}
|
||||
```
|
||||
|
||||
Conflict `409`:
|
||||
```json
|
||||
{
|
||||
"error": "VERSION_CONFLICT",
|
||||
"message": "Project has a newer version on server",
|
||||
"serverVersion": 10,
|
||||
"serverUpdatedAt": "2026-02-09T16:02:00.000Z"
|
||||
}
|
||||
```
|
||||
|
||||
Validation errors:
|
||||
- `400` invalid payload
|
||||
- `413` payload too large
|
||||
|
||||
## Sync and Conflict Behavior (Client Contract)
|
||||
- Client reads project `version` from `GET /api/editor/projects/:id`.
|
||||
- Autosave sends `PUT` with `baseVersion`.
|
||||
- On `409`, client fetches latest server project and prompts user: keep local or server (v1 policy).
|
||||
- Do not silently overwrite newer server versions.
|
||||
|
||||
## Security and Limits
|
||||
- Enforce workspace isolation on every query.
|
||||
- Validate project ownership or workspace role before writes.
|
||||
- Rate limit write endpoint (`PUT`) to protect autosave storms.
|
||||
- Sanitize/validate `name` length and UTF-8 validity.
|
||||
|
||||
## Observability
|
||||
- Log structured fields: `workspaceId`, `projectId`, `userId`, `version`, `status`, latency.
|
||||
- Emit metrics:
|
||||
- `editor_api_put_success_total`
|
||||
- `editor_api_put_conflict_total`
|
||||
- `editor_api_put_validation_error_total`
|
||||
- `editor_api_payload_bytes`
|
||||
|
||||
## Suggested Backend Tasks
|
||||
1. Add DB migration for `editor_projects`.
|
||||
2. Implement `GET /api/editor/projects`.
|
||||
3. Implement `GET /api/editor/projects/:id`.
|
||||
4. Implement `PUT /api/editor/projects/:id` with optimistic concurrency transaction.
|
||||
5. Add request/response validation schema and shared error format.
|
||||
6. Add integration tests for auth, tenancy, conflict, payload size, and create/update flows.
|
||||
|
||||
## Acceptance Criteria
|
||||
- Project created on device A appears in list on device B.
|
||||
- Autosave updates increase `version` monotonically.
|
||||
- Concurrent writes produce deterministic `409` conflict responses.
|
||||
- Unauthorized cross-workspace access is blocked.
|
||||
|
|
@ -0,0 +1,247 @@
|
|||
# Frontend-Backend Contract for Cloud Editor Sync
|
||||
|
||||
## Audience
|
||||
- Backend engineers who are not familiar with this frontend codebase.
|
||||
- Frontend engineers integrating cloud sync with `EditorCore`.
|
||||
|
||||
## Context: How the Frontend Editor Works
|
||||
- Frontend editor state is managed by a singleton `EditorCore`.
|
||||
- React components use `useEditor()` and read/write through editor managers.
|
||||
- Local save is offline-first and remains the immediate source of truth.
|
||||
- Cloud sync runs after local save and mirrors the same state to backend.
|
||||
|
||||
## What Backend Must Treat as Source of Truth
|
||||
- `state` payload sent by frontend is the canonical editor snapshot.
|
||||
- Backend stores it as opaque JSON (`state_json`) with light validation only.
|
||||
- Backend must not rewrite timeline internals (track layout, element ordering, etc).
|
||||
|
||||
## API Base Paths
|
||||
- Editor state API: `/api/editor`
|
||||
- File binary API: `/api/files` (already exists)
|
||||
|
||||
## TypeScript Contract (Shared Shapes)
|
||||
|
||||
```ts
|
||||
export type ISODateString = string;
|
||||
export type UUID = string;
|
||||
|
||||
export interface EditorProjectState {
|
||||
schemaVersion: number;
|
||||
currentSceneId: string;
|
||||
metadata: {
|
||||
id: string;
|
||||
name: string;
|
||||
duration: number;
|
||||
updatedAt: ISODateString;
|
||||
};
|
||||
settings: Record<string, unknown>;
|
||||
scenes: Array<Record<string, unknown>>;
|
||||
}
|
||||
|
||||
export interface EditorProjectListItem {
|
||||
id: UUID;
|
||||
workspaceId: string;
|
||||
ownerId: number;
|
||||
name: string;
|
||||
version: number;
|
||||
updatedAt: ISODateString;
|
||||
createdAt: ISODateString;
|
||||
}
|
||||
|
||||
export interface ListEditorProjectsResponse {
|
||||
items: EditorProjectListItem[];
|
||||
total: number;
|
||||
}
|
||||
|
||||
export interface GetEditorProjectResponse {
|
||||
id: UUID;
|
||||
workspaceId: string;
|
||||
ownerId: number;
|
||||
name: string;
|
||||
version: number;
|
||||
state: EditorProjectState;
|
||||
updatedAt: ISODateString;
|
||||
createdAt: ISODateString;
|
||||
}
|
||||
|
||||
export interface PutEditorProjectRequest {
|
||||
name: string;
|
||||
baseVersion: number;
|
||||
state: EditorProjectState;
|
||||
assetFileIds: UUID[];
|
||||
clientRequestId?: string;
|
||||
}
|
||||
|
||||
export interface PutEditorProjectResponse {
|
||||
id: UUID;
|
||||
version: number;
|
||||
updatedAt: ISODateString;
|
||||
}
|
||||
|
||||
export interface VersionConflictResponse {
|
||||
error: "VERSION_CONFLICT";
|
||||
message: string;
|
||||
serverVersion: number;
|
||||
serverUpdatedAt: ISODateString;
|
||||
}
|
||||
```
|
||||
|
||||
## Endpoint Details
|
||||
|
||||
### `GET /api/editor/projects`
|
||||
Purpose:
|
||||
- Load project picker and recent projects list.
|
||||
|
||||
Query:
|
||||
- `offset` default `0`
|
||||
- `limit` default `20`, max `100`
|
||||
- `search` optional
|
||||
|
||||
Returns:
|
||||
- `200` with `ListEditorProjectsResponse`
|
||||
|
||||
### `GET /api/editor/projects/:id`
|
||||
Purpose:
|
||||
- Open project on app load or after conflict.
|
||||
|
||||
Returns:
|
||||
- `200` with `GetEditorProjectResponse`
|
||||
- `404` if not in workspace
|
||||
|
||||
### `PUT /api/editor/projects/:id`
|
||||
Purpose:
|
||||
- Autosave cloud snapshot with optimistic concurrency.
|
||||
|
||||
Request:
|
||||
- `PutEditorProjectRequest`
|
||||
|
||||
Returns:
|
||||
- `200` with `PutEditorProjectResponse`
|
||||
- `409` with `VersionConflictResponse` when `baseVersion` mismatches current
|
||||
- `400` invalid body
|
||||
- `401` invalid auth
|
||||
- `404` project/workspace not found
|
||||
- `413` payload too large
|
||||
|
||||
## Required Backend Semantics
|
||||
|
||||
### Optimistic concurrency
|
||||
- Compare `baseVersion` with current server `version`.
|
||||
- If equal: write state and increment version atomically.
|
||||
- If different: reject with `409 VERSION_CONFLICT`.
|
||||
|
||||
### Idempotency (recommended)
|
||||
- Respect `clientRequestId` for repeated retries of the same autosave.
|
||||
- If same `(projectId, clientRequestId)` is seen again, return prior success response.
|
||||
|
||||
### Transaction behavior
|
||||
- `PUT` should run in one transaction:
|
||||
1. Read current row with lock.
|
||||
2. Validate `baseVersion`.
|
||||
3. Update `name`, `state_json`, `version`, `updated_at`.
|
||||
4. Upsert `editor_project_assets` from `assetFileIds` (if implemented).
|
||||
|
||||
## File Service Integration (`/api/files`)
|
||||
|
||||
### Frontend upload sequence
|
||||
1. User adds media file in editor.
|
||||
2. Frontend calls existing `POST /api/files` with filename/contentType/size.
|
||||
3. Frontend uploads binary to returned `uploadUrl`.
|
||||
4. Frontend stores returned `file.id` in timeline element (`fileId`).
|
||||
5. Next autosave sends `assetFileIds` including that `fileId`.
|
||||
|
||||
### Backend expectations
|
||||
- Do not add editor-specific binary upload endpoint.
|
||||
- Validate `assetFileIds` belong to same workspace (strongly recommended).
|
||||
- Keep editor API and file API responsibilities separate.
|
||||
|
||||
## Frontend Sync Lifecycle (Backend Should Support)
|
||||
|
||||
### A) Open project
|
||||
1. Frontend requests `GET /api/editor/projects/:id`.
|
||||
2. Frontend hydrates `EditorCore` from `state`.
|
||||
3. Frontend stores returned `version` as `remoteVersion`.
|
||||
|
||||
### B) Autosave success path
|
||||
1. Local save completes.
|
||||
2. Frontend builds snapshot from `EditorCore`.
|
||||
3. Frontend calls `PUT` with `baseVersion = remoteVersion`.
|
||||
4. Backend returns new `version`.
|
||||
5. Frontend updates `remoteVersion`.
|
||||
|
||||
### C) Autosave conflict path
|
||||
1. Frontend sends `PUT`.
|
||||
2. Backend returns `409 VERSION_CONFLICT`.
|
||||
3. Frontend fetches latest `GET /api/editor/projects/:id`.
|
||||
4. Frontend prompts user: keep local or use server.
|
||||
5. Frontend resolves by re-saving chosen state with latest `baseVersion`.
|
||||
|
||||
## Non-Functional Requirements
|
||||
- P95 `PUT` latency target: under 300ms for normal payloads.
|
||||
- Support bursty autosave traffic (same project every few seconds).
|
||||
- Enforce per-user/workspace rate limiting without breaking normal autosave.
|
||||
|
||||
## Validation Rules (Minimum)
|
||||
- `name`: required, string, max 255
|
||||
- `baseVersion`: required, integer, `>= 0`
|
||||
- `state.schemaVersion`: required integer
|
||||
- `state.scenes`: required array
|
||||
- `assetFileIds`: required array (can be empty), each UUID format
|
||||
|
||||
## Error Format (Consistent Across Endpoints)
|
||||
```json
|
||||
{
|
||||
"error": "SOME_ERROR_CODE",
|
||||
"message": "Human-readable message"
|
||||
}
|
||||
```
|
||||
|
||||
Conflict is the only error that must include extra fields:
|
||||
```json
|
||||
{
|
||||
"error": "VERSION_CONFLICT",
|
||||
"message": "Project has a newer version on server",
|
||||
"serverVersion": 10,
|
||||
"serverUpdatedAt": "2026-02-09T16:02:00.000Z"
|
||||
}
|
||||
```
|
||||
|
||||
## Backend Test Cases (Must Implement)
|
||||
1. Auth required: all `/api/editor/*` return `401` without valid JWT.
|
||||
2. Workspace isolation: project from another workspace returns `404`.
|
||||
3. Create/update path: `PUT` creates new and increments version on subsequent saves.
|
||||
4. Conflict path: stale `baseVersion` returns deterministic `409`.
|
||||
5. Payload validation: malformed `state` returns `400`.
|
||||
6. Payload size limit: oversized request returns `413`.
|
||||
7. File reference check: invalid cross-workspace `assetFileId` rejected.
|
||||
8. Idempotent retry (if enabled): duplicate `clientRequestId` returns same result.
|
||||
|
||||
## Copy/Paste Prompt for Backend Agent
|
||||
```text
|
||||
Use these two specs as the source of truth:
|
||||
- project-management/04a-editor-backend-spec.md
|
||||
- project-management/04b-editor-frontend-backend-contract.md
|
||||
|
||||
Implement `/api/editor` for cloud project sync with optimistic concurrency.
|
||||
Important: frontend editor state is an opaque JSON snapshot from EditorCore; do not rewrite timeline internals.
|
||||
|
||||
Build:
|
||||
1) Migrations:
|
||||
- editor_projects
|
||||
- editor_project_assets (recommended)
|
||||
2) Endpoints:
|
||||
- GET /api/editor/projects
|
||||
- GET /api/editor/projects/:id
|
||||
- PUT /api/editor/projects/:id
|
||||
3) Validation and error shapes exactly as documented.
|
||||
4) Workspace isolation + JWT auth on every endpoint.
|
||||
5) Integration with existing /api/files via file references only.
|
||||
6) Integration tests for auth, tenancy, concurrency conflict, validation, and size limits.
|
||||
|
||||
Return:
|
||||
- Migration files
|
||||
- Route handlers
|
||||
- Validation schemas
|
||||
- Tests
|
||||
- Short implementation notes for frontend consumers
|
||||
```
|
||||
Loading…
Reference in New Issue