From e5d587079deaca8f70d817ea1adc9934f13f6502 Mon Sep 17 00:00:00 2001 From: KKKK Date: Sun, 23 Aug 2026 01:09:43 +0800 Subject: [PATCH 1/2] feat: add shared assets workspace and studio history Add a project-scoped Assets view, persistent Studio generation feed, shared composer controls, localized copy, release safeguards, and regression coverage. --- .dockerignore | 1 + .gitignore | 1 + ARCHITECTURE.md | 2 +- DESIGN.md | 11 +- README.md | 2 +- messages/en.json | 34 +- messages/zh.json | 32 +- .../app/create-project-route-page.test.ts | 4 +- .../app/create-project-route-page.tsx | 89 +++-- .../app/product-page-shell-title.test.ts | 1 + src/components/app/product-page-shell.test.ts | 3 +- src/components/app/product-page-shell.tsx | 22 +- src/components/app/project-assets-dialog.tsx | 180 ++++++----- .../app/project-assets-workspace.tsx | 27 ++ src/components/app/workspace-project-hub.tsx | 2 +- .../app/workspace-project-route-page.tsx | 3 + .../beatcanvas/beatcanvas-shell.tsx | 91 +----- .../beatcanvas/use-canvas-composer-labels.ts | 99 ++++++ src/components/home/workspace-page.tsx | 2 +- .../marketing/beatapi-product-home.tsx | 32 +- .../marketing/beatapi-product-shell.test.ts | 3 +- .../marketing/beatapi-product-shell.tsx | 37 +-- .../studio/beat-studio-workspace.test.ts | 51 ++- .../studio/beat-studio-workspace.tsx | 303 +++++++----------- src/components/studio/studio-composer.tsx | 283 ++++++++++++++++ .../studio/studio-generation-feed.tsx | 236 ++++++++++++++ src/config/workspace-modes.test.ts | 5 +- src/config/workspace-modes.ts | 8 +- src/core/effects/project-generations.test.ts | 57 ++++ src/core/effects/project-generations.ts | 141 ++++++++ src/core/projects/project-entry.ts | 13 +- src/core/studio/studio-draft.ts | 51 +++ src/core/studio/studio-history.test.ts | 31 ++ src/core/studio/studio-history.ts | 31 ++ src/core/studio/studio-runtime.test.ts | 25 ++ src/core/studio/studio-runtime.ts | 32 +- .../workspace-lib/app/workspace-client-api.ts | 7 + .../app/workspace-query-invalidation.ts | 2 + .../workspace-lib/app/workspace-query-keys.ts | 6 + src/core/workspace-lib/shims/routes.ts | 1 + src/lib/i18n-ui-copy-regression.test.ts | 2 +- ...pace-project-route.client-boundary.test.ts | 6 +- .../app/projects/$projectId/generations.ts | 20 ++ src/routes/assets/$projectId.tsx | 19 ++ src/routes/assets/route.tsx | 5 + 45 files changed, 1472 insertions(+), 541 deletions(-) create mode 100644 src/components/app/project-assets-workspace.tsx create mode 100644 src/components/beatcanvas/use-canvas-composer-labels.ts create mode 100644 src/components/studio/studio-composer.tsx create mode 100644 src/components/studio/studio-generation-feed.tsx create mode 100644 src/core/effects/project-generations.test.ts create mode 100644 src/core/effects/project-generations.ts create mode 100644 src/core/studio/studio-draft.ts create mode 100644 src/core/studio/studio-history.test.ts create mode 100644 src/core/studio/studio-history.ts create mode 100644 src/routes/api/app/projects/$projectId/generations.ts create mode 100644 src/routes/assets/$projectId.tsx create mode 100644 src/routes/assets/route.tsx diff --git a/.dockerignore b/.dockerignore index fc8ca90..c87c4e0 100644 --- a/.dockerignore +++ b/.dockerignore @@ -43,6 +43,7 @@ LICENSE *.pem *.tsbuildinfo public/uploads +public/demo-assets *-before.png design-qa-*.png canvas-rollback-empty.png diff --git a/.gitignore b/.gitignore index a714c2a..8587721 100644 --- a/.gitignore +++ b/.gitignore @@ -75,6 +75,7 @@ package-lock.json # Runtime uploads and local QA artifacts /public/uploads/ +/public/demo-assets/ /*-before.png /design-qa-*.png /canvas-rollback-empty.png diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 7a4a840..74abc1c 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -15,7 +15,7 @@ Projects + Storage configuration ``` -Studio and Canvas are two views over the same project, generation, and asset services. Switching modes never creates a second project or backend. +Studio, Canvas, and Assets are three views over the same project, generation, and asset services. Switching modes never creates a second project or backend. ## Runtime flow diff --git a/DESIGN.md b/DESIGN.md index 81568de..47afeac 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -1,11 +1,11 @@ -# Design System — BeatAPI SaaS Template +# Design System — BeatDesign ## Product Context -- **What this is:** An open-source AI SaaS starter and a working demonstration of building on BeatAPI. +- **What this is:** An open-source, local-first AI creative workbench and a working demonstration of building on BeatAPI. - **Who it is for:** Independent developers and small product teams shipping AI media products for an international audience. - **Space:** Developer tools, AI creative software, and SaaS infrastructure. -- **Project type:** Marketing site, authenticated SaaS dashboard, guided studio, and node canvas. +- **Project type:** Marketing site, local project workspace, guided Studio, node Canvas, and shared Assets view. ## Aesthetic Direction @@ -52,9 +52,10 @@ ## Layout - **Marketing grid:** 12 columns, max width 1280px, 24–32px gutters. -- **App grid:** Creative project routes use a 56px top bar and fluid main region without a persistent sidebar. Administrative routes may keep their own navigation. -- **Studio:** No sidebar. A full-bleed media wall supports a bottom floating composer; media type, model, ratio, credits, and Generate live in that composer. Content max width is 1380px and composer max width is 1180px. +- **App grid:** Creative project routes use a 56px top bar and fluid main region without a persistent sidebar. +- **Studio:** No sidebar. A full-bleed media wall supports a bottom floating composer; media type, model, ratio, parameters, and Generate live in that composer. Content max width is 1380px and composer max width is 1180px. - **Canvas:** Full-bleed workspace inside the same AppShell. Floating toolbars use the shared panel tokens. +- **Assets:** A project-scoped media library shares the same AppShell and content width as Studio. - **Responsive:** Sidebar becomes a drawer below 960px. Canvas remains usable with touch-safe controls and a collapsible inspector. ## Component Language diff --git a/README.md b/README.md index 667ea8a..11b8e92 100644 --- a/README.md +++ b/README.md @@ -37,7 +37,7 @@ Open `http://localhost:3020`. Home is the default route. Opening Studio or Canva ## How data flows ```text -Studio / Canvas +Studio / Canvas / Assets -> local server routes -> imported files saved under data/project-assets and indexed in SQLite -> generation precheck and a one-time SQLite generation intent diff --git a/messages/en.json b/messages/en.json index 935ea6b..dbfa7be 100644 --- a/messages/en.json +++ b/messages/en.json @@ -1,6 +1,7 @@ { "$schema": "https://inlang.com/schema/inlang-message-format", "product.shell.home": "Home", + "product.shell.create": "Create", "product.shell.studio": "Studio", "product.shell.canvas": "Canvas", "product.shell.pricing": "Pricing", @@ -21,10 +22,10 @@ "product.home.createInStudio": "Create in Studio", "product.home.sectionTitle": "Choose your workspace", "product.home.viewProjects": "View projects", - "product.home.studioDescription": "Generate and compare images or videos in a focused workspace.", - "product.home.canvasDescription": "Arrange shared assets and connect them into a visual workflow.", + "product.home.studioDescription": "Generate images and videos in one workspace.", + "product.home.canvasDescription": "Connect assets in a visual workflow.", "product.home.projectsTitle": "Projects", - "product.home.projectsDescription": "Continue recent work with Studio and Canvas state kept together.", + "product.home.projectsDescription": "Continue recent Studio and Canvas work.", "product.home.openWorkspace": "Open workspace", "product.home.mediaImage": "Image", "product.home.mediaVideo": "Video", @@ -387,7 +388,8 @@ "status": { "queued": "Queued", "generating": "Generating", - "ready": "Ready", + "ready": "Success", + "success": "Success", "failed": "Failed" }, "fromCanvas": "From canvas", @@ -432,6 +434,23 @@ "freeGenerate": "Free generation", "uploadStart": "Upload image" }, + "feed": { + "history": "History", + "result": "Generation", + "generating": "Generating...", + "failed": "Failed", + "ready": "Ready", + "success": "Success", + "noPrompt": "No prompt", + "openPreview": "Open preview", + "closePreview": "Close preview", + "prompt": "Prompt", + "model": "Model", + "params": "Parameters", + "image": "Image", + "video": "Video", + "references": "References" + }, "textNote": { "template": "Text note\n\nCapture composition ideas, shot notes, or production reminders here." }, @@ -624,10 +643,9 @@ "retry": "Try again", "backToHistory": "Back to history", "startTitle": "Start a new project", - "startStudioDescription": "A Studio project is created only after you choose to start.", - "startCanvasDescription": "A Canvas project is created only after you choose to start.", - "startStudio": "Start Studio project", - "startCanvas": "Start Canvas project", + "startDescription": "Choose Studio or Canvas to create this project.", + "startStudio": "Studio", + "startCanvas": "Canvas", "defaultName": "Untitled project" } }, diff --git a/messages/zh.json b/messages/zh.json index fb0c565..bd1b53c 100644 --- a/messages/zh.json +++ b/messages/zh.json @@ -1,6 +1,7 @@ { "$schema": "https://inlang.com/schema/inlang-message-format", "product.shell.home": "首页", + "product.shell.create": "创作", "product.shell.studio": "Studio", "product.shell.canvas": "Canvas", "product.shell.pricing": "定价", @@ -22,10 +23,10 @@ "product.home.createInStudio": "在 Studio 中创作", "product.home.sectionTitle": "选择你的工作空间", "product.home.viewProjects": "查看项目", - "product.home.studioDescription": "在专注的工作区中生成和比较图片或视频。", - "product.home.canvasDescription": "排列共享素材,并把它们连接成可视化工作流。", + "product.home.studioDescription": "在专注工作区生成图片和视频。", + "product.home.canvasDescription": "把素材连成可视化工作流。", "product.home.projectsTitle": "项目", - "product.home.projectsDescription": "继续最近的创作,Studio 与 Canvas 状态会保存在同一个项目中。", + "product.home.projectsDescription": "继续最近的 Studio 与 Canvas 创作。", "product.home.openWorkspace": "打开工作空间", "product.home.mediaImage": "图片", "product.home.mediaVideo": "视频", @@ -389,6 +390,7 @@ "queued": "排队中", "generating": "生成中", "ready": "已就绪", + "success": "成功", "failed": "失败" }, "fromCanvas": "来自画布", @@ -433,6 +435,23 @@ "freeGenerate": "自由生成", "uploadStart": "上传图片" }, + "feed": { + "history": "历史", + "result": "生成结果", + "generating": "生成中...", + "failed": "失败", + "ready": "成功", + "success": "成功", + "noPrompt": "无提示词", + "openPreview": "打开预览", + "closePreview": "关闭预览", + "prompt": "提示词", + "model": "模型", + "params": "参数", + "image": "图片", + "video": "视频", + "references": "参考图" + }, "textNote": { "template": "文本备注\n\n在这里记录构图想法、镜头说明或制作备注。" }, @@ -625,10 +644,9 @@ "retry": "重试", "backToHistory": "返回历史", "startTitle": "开始新项目", - "startStudioDescription": "只有确认开始后,才会创建 Studio 项目。", - "startCanvasDescription": "只有确认开始后,才会创建 Canvas 项目。", - "startStudio": "开始 Studio 项目", - "startCanvas": "开始 Canvas 项目", + "startDescription": "选择 Studio 或 Canvas 来创建这个项目。", + "startStudio": "Studio", + "startCanvas": "Canvas", "defaultName": "未命名项目" } }, diff --git a/src/components/app/create-project-route-page.test.ts b/src/components/app/create-project-route-page.test.ts index fb0c2d6..6ba4749 100644 --- a/src/components/app/create-project-route-page.test.ts +++ b/src/components/app/create-project-route-page.test.ts @@ -9,7 +9,9 @@ const source = readFileSync( test('visiting Studio or Canvas does not create a project automatically', () => { assert.doesNotMatch(source, /useEffect/); - assert.match(source, /onClick=\{\(\) => void createProject\(\)\}/); + assert.match(source, /createProject\('studio'\)/); + assert.match(source, /createProject\('canvas'\)/); + assert.match(source, /envConfigs\.app_logo/); assert.match(source, /'\/api\/app\/projects'/); }); diff --git a/src/components/app/create-project-route-page.tsx b/src/components/app/create-project-route-page.tsx index e7ea2df..c94ba00 100644 --- a/src/components/app/create-project-route-page.tsx +++ b/src/components/app/create-project-route-page.tsx @@ -1,15 +1,10 @@ import { useState } from 'react'; -import { - AlertCircleIcon, - LayoutPanelTopIcon, - Loader2Icon, - WorkflowIcon, -} from 'lucide-react'; +import { AlertCircleIcon, Loader2Icon } from 'lucide-react'; import { Button } from '@/components/ui/button'; +import { envConfigs } from '@/config'; import type { WorkspaceMode } from '@/config/workspace-modes'; import { buildPostCreateProjectDetailPath } from '@/core/projects/project-entry'; -import { useLocaleRouter } from '@/core/i18n/navigation'; import { apiJsonPost } from '@/lib/api-client'; import { m } from '@/paraglide/messages.js'; @@ -41,13 +36,8 @@ function getCopy(locale: string) { { locale: messageLocale } ), retry: m['BeatAPI.project.retry']({}, { locale: messageLocale }), - back: m['BeatAPI.project.backToHistory']({}, { locale: messageLocale }), startTitle: m['BeatAPI.project.startTitle']({}, { locale: messageLocale }), - startStudioDescription: m['BeatAPI.project.startStudioDescription']( - {}, - { locale: messageLocale } - ), - startCanvasDescription: m['BeatAPI.project.startCanvasDescription']( + startDescription: m['BeatAPI.project.startDescription']( {}, { locale: messageLocale } ), @@ -67,20 +57,12 @@ export function CreateProjectRoutePage({ }: { data: CreateProjectRouteData; }) { - const { - locale, - target, - model, - prompt, - workspaceMode, - projectName, - } = data; - const router = useLocaleRouter(); + const { locale, target, model, prompt, projectName } = data; const copy = getCopy(locale); const [creating, setCreating] = useState(false); const [error, setError] = useState(null); - async function createProject() { + async function createProject(workspaceMode: WorkspaceMode) { if (creating) return; setCreating(true); setError(null); @@ -113,13 +95,6 @@ export function CreateProjectRoutePage({ } } - const isStudio = workspaceMode === 'studio'; - const description = isStudio - ? copy.startStudioDescription - : copy.startCanvasDescription; - const startLabel = isStudio ? copy.startStudio : copy.startCanvas; - const WorkspaceIcon = isStudio ? LayoutPanelTopIcon : WorkflowIcon; - return (
@@ -132,35 +107,49 @@ export function CreateProjectRoutePage({ ) : ( -
- -
+ {envConfigs.app_name} )} -

+

{error ? copy.errorTitle : creating ? copy.title : copy.startTitle}

- {error || (creating ? copy.description : description)} + {error || (creating ? copy.description : copy.startDescription)}

{!creating ? (
- - + {error ? ( + + ) : ( + <> + + + + )}
) : null}
diff --git a/src/components/app/product-page-shell-title.test.ts b/src/components/app/product-page-shell-title.test.ts index 3707d53..1e2309c 100644 --- a/src/components/app/product-page-shell-title.test.ts +++ b/src/components/app/product-page-shell-title.test.ts @@ -9,6 +9,7 @@ test('project title field uses the available header width instead of a short cha ); assert.doesNotMatch(source, /20\)\}ch/); + assert.doesNotMatch(source, /maxLength=/); assert.match(source, /className="flex min-w-0 flex-1 items-center gap-3"/); assert.match(source, /className="min-w-0 flex-1"/); assert.match(source, /className="h-9 w-full/); diff --git a/src/components/app/product-page-shell.test.ts b/src/components/app/product-page-shell.test.ts index f7b0d46..816ff40 100644 --- a/src/components/app/product-page-shell.test.ts +++ b/src/components/app/product-page-shell.test.ts @@ -18,7 +18,8 @@ const apiConfigSource = readFileSync( test('workspace header keeps mode switching, shared assets, and API configuration actions', () => { assert.match(shellSource, /WorkspaceApiConfigDialog/); - assert.match(shellSource, /ProjectAssetsDialog/); + assert.match(shellSource, /\/assets\//); + assert.match(shellSource, />Assets<\/span>/); assert.match(shellSource, /GitHubIcon/); assert.match( shellSource, diff --git a/src/components/app/product-page-shell.tsx b/src/components/app/product-page-shell.tsx index 91f922f..6be0669 100644 --- a/src/components/app/product-page-shell.tsx +++ b/src/components/app/product-page-shell.tsx @@ -2,10 +2,9 @@ import type { ReactNode } from 'react'; import { useCallback, useEffect, useRef, useState } from 'react'; -import { LayoutPanelTop, Workflow } from 'lucide-react'; +import { Images, LayoutPanelTop, Workflow } from 'lucide-react'; import { WorkspaceApiConfigDialog } from '@/components/app/workspace-api-config-dialog'; -import { ProjectAssetsDialog } from '@/components/app/project-assets-dialog'; import { GitHubIcon } from '@/components/icons/github'; import { Link } from '@/core/i18n/navigation'; import { useTranslations } from '@/core/workspace-lib/shims/next-intl'; @@ -99,7 +98,7 @@ export function ProductPageShell({ } }} aria-label={t('header.workspaceNameLabel')} - className="h-9 w-full min-w-0 rounded-md border-0 bg-transparent px-0 text-[15px] font-semibold text-[var(--beat-text-1)] outline-none placeholder:text-white/35 focus:ring-0" + className="h-9 w-full min-w-0 truncate rounded-md border-0 bg-transparent px-0 text-[15px] font-semibold text-[var(--beat-text-1)] outline-none placeholder:text-white/35 focus:ring-0" /> ) : ( @@ -154,14 +153,19 @@ export function ProductPageShell({ > Canvas + + Assets + ) : null} - {projectId ? ( - - ) : null} diff --git a/src/components/app/project-assets-dialog.tsx b/src/components/app/project-assets-dialog.tsx index 7313813..2ff983d 100644 --- a/src/components/app/project-assets-dialog.tsx +++ b/src/components/app/project-assets-dialog.tsx @@ -26,6 +26,7 @@ import { } from '@/core/workspace-lib/app/workspace-client-api'; import { recentAssetsKeys } from '@/core/workspace-lib/app/workspace-query-keys'; import { useTranslations } from '@/core/workspace-lib/shims/next-intl'; +import { beatPanelLabelClassName } from '@/components/app/composer-styles'; import { PROJECT_ASSET_DRAG_MIME, PROJECT_ASSET_INSERT_EVENT, @@ -83,7 +84,7 @@ function AssetTile({ onDragEnd={(event) => { if (event.dataTransfer.dropEffect === 'copy') onAdded(); }} - className={`group relative aspect-square overflow-hidden rounded-[14px] border border-white/[0.08] bg-[#09090b] transition hover:-translate-y-0.5 hover:border-white/[0.22] ${ + className={`group relative h-[200px] w-fit overflow-hidden rounded-[14px] bg-transparent transition hover:-translate-y-0.5 ${ canAddToCanvas ? 'cursor-grab active:cursor-grabbing' : '' }`} title={asset.filename || addLabel} @@ -94,13 +95,13 @@ function AssetTile({ muted playsInline preload="metadata" - className="size-full object-cover opacity-85 transition duration-300 group-hover:opacity-100" + className="h-full w-auto object-contain" /> ) : ( {asset.filename )} {mediaType === 'video' ? ( @@ -148,15 +149,16 @@ function AssetTile({ ); } -export function ProjectAssetsDialog({ +export function ProjectAssetsLibrary({ projectId, canAddToCanvas = false, + onAdded, }: { projectId: string; canAddToCanvas?: boolean; + onAdded?: () => void; }) { const t = useTranslations('AppShell.header.projectAssets'); - const [open, setOpen] = useState(false); const assetsQuery = useQuery({ queryKey: recentAssetsKeys.lists(projectId), queryFn: () => fetchRecentAssets(projectId), @@ -166,6 +168,93 @@ export function ProjectAssetsDialog({ const videos = assetsQuery.data?.videos ?? []; const total = images.length + videos.length; + return ( + <> + {assetsQuery.isLoading ? ( +
+ + {t('loading')} +
+ ) : null} + + {assetsQuery.isError ? ( +
+ {t('loadFailed')} +
+ ) : null} + + {!assetsQuery.isLoading && !assetsQuery.isError && total === 0 ? ( +
+ + + +

+ {t('empty')} +

+
+ ) : null} + + {!assetsQuery.isLoading && !assetsQuery.isError && total > 0 ? ( +
+ {images.length > 0 ? ( +
+

+ {t('images')} +

+
+ {images.map((asset) => ( + onAdded?.()} + addLabel={t('addToCanvas')} + openLabel={t('openOriginal')} + /> + ))} +
+
+ ) : null} + + {videos.length > 0 ? ( +
+

+ {t('videos')} +

+
+ {videos.map((asset) => ( + onAdded?.()} + addLabel={t('addToCanvas')} + openLabel={t('openOriginal')} + /> + ))} +
+
+ ) : null} +
+ ) : null} + + ); +} + +export function ProjectAssetsDialog({ + projectId, + canAddToCanvas = false, +}: { + projectId: string; + canAddToCanvas?: boolean; +}) { + const t = useTranslations('AppShell.header.projectAssets'); + const [open, setOpen] = useState(false); + return ( {t('triggerLabel')}
- {total > 0 ? ( - - {total} - - ) : null}
- {assetsQuery.isLoading ? ( -
- - {t('loading')} -
- ) : null} - - {assetsQuery.isError ? ( -
- {t('loadFailed')} -
- ) : null} - - {!assetsQuery.isLoading && !assetsQuery.isError && total === 0 ? ( -
- - - -

- {t('empty')} -

-
- ) : null} - - {!assetsQuery.isLoading && !assetsQuery.isError && total > 0 ? ( -
- {images.length > 0 ? ( -
-

- {t('images')} -

-
- {images.map((asset) => ( - setOpen(false)} - addLabel={t('addToCanvas')} - openLabel={t('openOriginal')} - /> - ))} -
-
- ) : null} - - {videos.length > 0 ? ( -
-

- {t('videos')} -

-
- {videos.map((asset) => ( - setOpen(false)} - addLabel={t('addToCanvas')} - openLabel={t('openOriginal')} - /> - ))} -
-
- ) : null} -
- ) : null} + setOpen(false)} + />
diff --git a/src/components/app/project-assets-workspace.tsx b/src/components/app/project-assets-workspace.tsx new file mode 100644 index 0000000..e74cc75 --- /dev/null +++ b/src/components/app/project-assets-workspace.tsx @@ -0,0 +1,27 @@ +'use client'; + +import { ProjectAssetsLibrary } from '@/components/app/project-assets-dialog'; +import { useTranslations } from '@/core/workspace-lib/shims/next-intl'; + +export function ProjectAssetsWorkspace({ projectId }: { projectId: string }) { + const t = useTranslations('AppShell.header.projectAssets'); + + return ( +
+
+
+
+

+ {t('title')} +

+

+ {t('description')} +

+
+ +
+
+
+
+ ); +} diff --git a/src/components/app/workspace-project-hub.tsx b/src/components/app/workspace-project-hub.tsx index 99b4ce0..e005f71 100644 --- a/src/components/app/workspace-project-hub.tsx +++ b/src/components/app/workspace-project-hub.tsx @@ -17,7 +17,7 @@ import { startTransition, useDeferredValue, useMemo, useState } from 'react'; export type WorkspaceProjectCardItem = { id: string; name: string; - lastWorkspaceMode?: 'studio' | 'canvas'; + lastWorkspaceMode?: 'studio' | 'canvas' | 'assets'; activityAt: string; activityLabel: string; coverImageUrl?: string | null; diff --git a/src/components/app/workspace-project-route-page.tsx b/src/components/app/workspace-project-route-page.tsx index 0c7257a..b25b5e2 100644 --- a/src/components/app/workspace-project-route-page.tsx +++ b/src/components/app/workspace-project-route-page.tsx @@ -1,4 +1,5 @@ import { ProductPageShell } from '@/components/app/product-page-shell'; +import { ProjectAssetsWorkspace } from '@/components/app/project-assets-workspace'; import { BeatStudioWorkspace } from '@/components/studio/beat-studio-workspace'; import { BeatCanvasShell } from '@/components/beatcanvas/beatcanvas-shell'; import type { loadWorkspaceProjectRoute } from '@/core/projects/workspace-project-route-loader'; @@ -25,6 +26,8 @@ export function WorkspaceProjectRoutePage({ initialModelId={data.modelId} initialPrompt={data.prompt} /> + ) : data.workspaceMode === 'assets' ? ( + ) : ( ({ - imageTitle: studioT('canvas.frame.imageTitle'), - videoTitle: studioT('canvas.frame.videoTitle'), - imageModeLabel: studioT('canvas.composer.imageMode'), - videoModeLabel: studioT('canvas.composer.videoMode'), - createGenerationCardLabel: studioT('canvas.connector.createGeneration'), - createImageGenerationCardLabel: studioT( - 'canvas.connector.createImageGeneration' - ), - createVideoGenerationCardLabel: studioT( - 'canvas.connector.createVideoGeneration' - ), - connectorUploadLabel: studioT('canvas.connector.upload'), - imagePromptPlaceholder: studioT('canvas.frame.imagePromptPlaceholder'), - videoPromptPlaceholder: studioT('canvas.frame.videoPromptPlaceholder'), - zoomLabel: studioT('canvas.zoom.label'), - zoomOutLabel: studioT('canvas.zoom.zoomOut'), - zoomInLabel: studioT('canvas.zoom.zoomIn'), - selectToolLabel: studioT('canvas.zoom.select'), - panToolLabel: studioT('canvas.zoom.pan'), - fitViewLabel: studioT('canvas.zoom.fitView'), - hideEdgesLabel: studioT('canvas.zoom.hideEdges'), - showEdgesLabel: studioT('canvas.zoom.showEdges'), - snapToGridLabel: studioT('canvas.zoom.snapToGrid'), - undoLabel: studioT('canvas.zoom.undo'), - redoLabel: studioT('canvas.zoom.redo'), - historyLabel: studioT('canvas.shapes.history'), - latestResultLabel: studioT('canvas.shapes.latestResult'), - emptyStateTitle: studioT('emptyState.title'), - emptyStateDescription: studioT('emptyState.description'), - emptyGuideTitle: studioT('emptyState.guideTitle'), - emptyGuideDescription: studioT('emptyState.guideDescription'), - emptyFreeGenerateLabel: studioT('emptyState.freeGenerate'), - emptyUploadStartLabel: studioT('emptyState.uploadStart'), - typeLabel: studioT('canvas.composer.type'), - modelLabel: studioT('single.labels.model'), - parameterLabel: studioT('canvas.composer.parameters'), - aspectRatioLabel: studioT('single.labels.aspectRatio'), - outputQualityLabel: studioT('single.labels.outputQuality'), - durationLabel: studioT('single.labels.duration'), - characterOrientationLabel: studioT( - 'canvas.composer.characterOrientation' - ), - backgroundSourceLabel: studioT('canvas.composer.backgroundSource'), - languageLabel: studioT('canvas.composer.language'), - uploadImageLabel: studioT('actions.uploadImage'), - uploadVideoLabel: studioT('actions.uploadVideo'), - fromCanvasLabel: studioT('canvas.composer.fromCanvas'), - currentReferencesLabel: studioT('canvas.composer.currentReferences'), - noCanvasReferencesLabel: studioT('canvas.composer.noCanvasReferences'), - removeReferenceLabel: studioT('canvas.composer.removeReference'), - generateLabel: studioT('canvas.composer.generate'), - regenerateLabel: studioT('canvas.composer.regenerate'), - generatingLabel: studioT('canvas.composer.generating'), - closeComposerLabel: studioT('canvas.composer.close'), - defaultSetupLabel: studioT('canvas.composer.defaultSetup'), - modeOptionLabel: studioT('canvas.composer.mode'), - variantOptionLabel: studioT('canvas.composer.variant'), - qualityOptionLabel: studioT('canvas.composer.quality'), - tokenQualityLabel: studioT('canvas.composer.tokens.quality'), - tokenFastLabel: studioT('canvas.composer.tokens.fast'), - tokenLiteLabel: studioT('canvas.composer.tokens.lite'), - tokenLowLabel: studioT('canvas.composer.tokens.low'), - tokenMediumLabel: studioT('canvas.composer.tokens.medium'), - tokenStandardLabel: studioT('canvas.composer.tokens.standard'), - tokenHighLabel: studioT('canvas.composer.tokens.high'), - tokenProLabel: studioT('canvas.composer.tokens.pro'), - tokenAdaptiveLabel: studioT('canvas.composer.tokens.adaptive'), - tokenAutoLabel: studioT('canvas.composer.tokens.auto'), - tokenLandscapeLabel: studioT('canvas.composer.tokens.landscape'), - tokenPortraitLabel: studioT('canvas.composer.tokens.portrait'), - tokenChineseLabel: studioT('canvas.composer.tokens.chinese'), - tokenEnglishLabel: studioT('canvas.composer.tokens.english'), - tokenImageOrientationLabel: studioT( - 'canvas.composer.tokens.imageOrientation' - ), - tokenVideoOrientationLabel: studioT( - 'canvas.composer.tokens.videoOrientation' - ), - tokenInputImageLabel: studioT('canvas.composer.tokens.inputImage'), - tokenInputVideoLabel: studioT('canvas.composer.tokens.inputVideo'), - queuedStatusLabel: studioT('canvas.composer.status.queued'), - generatingStatusLabel: studioT('canvas.composer.status.generating'), - readyStatusLabel: studioT('canvas.composer.status.ready'), - failedStatusLabel: studioT('canvas.composer.status.failed'), - }), - [studioT] - ); + const canvasCopy = useCanvasComposerLabels(); const canvasComponents = useMemo( () => ({ diff --git a/src/components/beatcanvas/use-canvas-composer-labels.ts b/src/components/beatcanvas/use-canvas-composer-labels.ts new file mode 100644 index 0000000..c28664f --- /dev/null +++ b/src/components/beatcanvas/use-canvas-composer-labels.ts @@ -0,0 +1,99 @@ +import { useMemo } from 'react'; + +import { useTranslations } from '@/core/workspace-lib/shims/next-intl'; + +import type { CanvasLabels } from './beatcanvas-front-layer-context'; + +export function useCanvasComposerLabels(): CanvasLabels { + const studioT = useTranslations('AppShell.studio'); + + return useMemo( + () => ({ + imageTitle: studioT('canvas.frame.imageTitle'), + videoTitle: studioT('canvas.frame.videoTitle'), + imageModeLabel: studioT('canvas.composer.imageMode'), + videoModeLabel: studioT('canvas.composer.videoMode'), + createGenerationCardLabel: studioT('canvas.connector.createGeneration'), + createImageGenerationCardLabel: studioT( + 'canvas.connector.createImageGeneration' + ), + createVideoGenerationCardLabel: studioT( + 'canvas.connector.createVideoGeneration' + ), + connectorUploadLabel: studioT('canvas.connector.upload'), + imagePromptPlaceholder: studioT('canvas.frame.imagePromptPlaceholder'), + videoPromptPlaceholder: studioT('canvas.frame.videoPromptPlaceholder'), + zoomLabel: studioT('canvas.zoom.label'), + zoomOutLabel: studioT('canvas.zoom.zoomOut'), + zoomInLabel: studioT('canvas.zoom.zoomIn'), + selectToolLabel: studioT('canvas.zoom.select'), + panToolLabel: studioT('canvas.zoom.pan'), + fitViewLabel: studioT('canvas.zoom.fitView'), + hideEdgesLabel: studioT('canvas.zoom.hideEdges'), + showEdgesLabel: studioT('canvas.zoom.showEdges'), + snapToGridLabel: studioT('canvas.zoom.snapToGrid'), + undoLabel: studioT('canvas.zoom.undo'), + redoLabel: studioT('canvas.zoom.redo'), + historyLabel: studioT('canvas.shapes.history'), + latestResultLabel: studioT('canvas.shapes.latestResult'), + emptyStateTitle: studioT('emptyState.title'), + emptyStateDescription: studioT('emptyState.description'), + emptyGuideTitle: studioT('emptyState.guideTitle'), + emptyGuideDescription: studioT('emptyState.guideDescription'), + emptyFreeGenerateLabel: studioT('emptyState.freeGenerate'), + emptyUploadStartLabel: studioT('emptyState.uploadStart'), + typeLabel: studioT('canvas.composer.type'), + modelLabel: studioT('single.labels.model'), + parameterLabel: studioT('canvas.composer.parameters'), + aspectRatioLabel: studioT('single.labels.aspectRatio'), + outputQualityLabel: studioT('single.labels.outputQuality'), + durationLabel: studioT('single.labels.duration'), + characterOrientationLabel: studioT( + 'canvas.composer.characterOrientation' + ), + backgroundSourceLabel: studioT('canvas.composer.backgroundSource'), + languageLabel: studioT('canvas.composer.language'), + uploadImageLabel: studioT('actions.uploadImage'), + uploadVideoLabel: studioT('actions.uploadVideo'), + fromCanvasLabel: studioT('canvas.composer.fromCanvas'), + currentReferencesLabel: studioT('canvas.composer.currentReferences'), + noCanvasReferencesLabel: studioT('canvas.composer.noCanvasReferences'), + removeReferenceLabel: studioT('canvas.composer.removeReference'), + generateLabel: studioT('canvas.composer.generate'), + regenerateLabel: studioT('canvas.composer.regenerate'), + generatingLabel: studioT('canvas.composer.generating'), + closeComposerLabel: studioT('canvas.composer.close'), + defaultSetupLabel: studioT('canvas.composer.defaultSetup'), + modeOptionLabel: studioT('canvas.composer.mode'), + variantOptionLabel: studioT('canvas.composer.variant'), + qualityOptionLabel: studioT('canvas.composer.quality'), + tokenQualityLabel: studioT('canvas.composer.tokens.quality'), + tokenFastLabel: studioT('canvas.composer.tokens.fast'), + tokenLiteLabel: studioT('canvas.composer.tokens.lite'), + tokenLowLabel: studioT('canvas.composer.tokens.low'), + tokenMediumLabel: studioT('canvas.composer.tokens.medium'), + tokenStandardLabel: studioT('canvas.composer.tokens.standard'), + tokenHighLabel: studioT('canvas.composer.tokens.high'), + tokenProLabel: studioT('canvas.composer.tokens.pro'), + tokenAdaptiveLabel: studioT('canvas.composer.tokens.adaptive'), + tokenAutoLabel: studioT('canvas.composer.tokens.auto'), + tokenLandscapeLabel: studioT('canvas.composer.tokens.landscape'), + tokenPortraitLabel: studioT('canvas.composer.tokens.portrait'), + tokenChineseLabel: studioT('canvas.composer.tokens.chinese'), + tokenEnglishLabel: studioT('canvas.composer.tokens.english'), + tokenImageOrientationLabel: studioT( + 'canvas.composer.tokens.imageOrientation' + ), + tokenVideoOrientationLabel: studioT( + 'canvas.composer.tokens.videoOrientation' + ), + tokenInputImageLabel: studioT('canvas.composer.tokens.inputImage'), + tokenInputVideoLabel: studioT('canvas.composer.tokens.inputVideo'), + queuedStatusLabel: studioT('canvas.composer.status.queued'), + generatingStatusLabel: studioT('canvas.composer.status.generating'), + readyStatusLabel: studioT('canvas.composer.status.ready'), + failedStatusLabel: studioT('canvas.composer.status.failed'), + }), + [studioT] + ); +} diff --git a/src/components/home/workspace-page.tsx b/src/components/home/workspace-page.tsx index dbdc777..cc92abd 100644 --- a/src/components/home/workspace-page.tsx +++ b/src/components/home/workspace-page.tsx @@ -26,7 +26,7 @@ import { m } from '@/paraglide/messages.js'; type WorkspaceProject = { id: string; name: string; - lastWorkspaceMode?: 'studio' | 'canvas'; + lastWorkspaceMode?: 'studio' | 'canvas' | 'assets'; activityAt: string; activityLabel: string; coverImageUrl?: string | null; diff --git a/src/components/marketing/beatapi-product-home.tsx b/src/components/marketing/beatapi-product-home.tsx index 4d572d6..1967182 100644 --- a/src/components/marketing/beatapi-product-home.tsx +++ b/src/components/marketing/beatapi-product-home.tsx @@ -1,9 +1,7 @@ import { ArrowRight, ArrowUp, - Film, FolderClock, - ImagePlus, PanelsTopLeft, Workflow, } from 'lucide-react'; @@ -39,12 +37,7 @@ function getCopy(locale: string) { { locale: messageLocale } ), open: m['product.home.openWorkspace']({}, { locale: messageLocale }), - createInStudio: m['product.home.createInStudio']( - {}, - { locale: messageLocale } - ), - mediaImage: m['product.home.mediaImage']({}, { locale: messageLocale }), - mediaVideo: m['product.home.mediaVideo']({}, { locale: messageLocale }), + create: m['product.shell.create']({}, { locale: messageLocale }), }; } @@ -79,7 +72,7 @@ function WorkspaceCard({

{title}

-

+

{description}

@@ -93,10 +86,6 @@ function WorkspaceCard({ function PromptComposer({ copy }: { copy: HomeCopy }) { const [prompt, setPrompt] = useState(''); - const mediaActions = [ - { label: copy.mediaImage, icon: ImagePlus }, - { label: copy.mediaVideo, icon: Film }, - ] as const; return (
-
-
- {mediaActions.map(({ label, icon: Icon }) => ( - - ))} -
+
{open ? ( -
+
{options.map((option) => ( ))} diff --git a/src/components/studio/beat-studio-workspace.test.ts b/src/components/studio/beat-studio-workspace.test.ts index 1873fbb..ba199b1 100644 --- a/src/components/studio/beat-studio-workspace.test.ts +++ b/src/components/studio/beat-studio-workspace.test.ts @@ -6,6 +6,14 @@ const studioSource = readFileSync( new URL('./beat-studio-workspace.tsx', import.meta.url), 'utf8' ); +const composerSource = readFileSync( + new URL('./studio-composer.tsx', import.meta.url), + 'utf8' +); +const feedSource = readFileSync( + new URL('./studio-generation-feed.tsx', import.meta.url), + 'utf8' +); const startHereSource = readFileSync( new URL('./studio-start-here.tsx', import.meta.url), 'utf8' @@ -17,38 +25,29 @@ test('studio uses the shared black creative surface without a persistent media s assert.doesNotMatch(studioSource, /w-14 shrink-0 flex-col/); }); -test('studio mirrors the BeatAPI start state and composer rhythm', () => { - const composerIndex = studioSource.indexOf('max-w-[1138px]'); - const mediaSelectIndex = studioSource.indexOf( - 'ariaLabel="Media type"', - composerIndex - ); - - assert.ok(composerIndex >= 0); - assert.ok(mediaSelectIndex > composerIndex); +test('studio keeps the original wide composer and a project generation feed', () => { + assert.match(studioSource, /StudioComposer/); + assert.match(studioSource, /StudioGenerationFeed/); assert.match(studioSource, /StudioStartHere/); + assert.match(composerSource, /max-w-\[1138px\]/); + assert.match(composerSource, /ariaLabel="Media type"/); + assert.match(composerSource, /WorkspaceSelect/); + assert.match(composerSource, /BeatCanvasComposerParameterPicker/); + assert.match(composerSource, /truncatePromptToMaxChars/); + assert.match(composerSource, /labels\.regenerateLabel/); + assert.match(feedSource, /max-w-\[1138px\]/); + assert.match(feedSource, /formatStudioHistoryDateTime/); + assert.match(feedSource, /h-\[240px\]/); + assert.match(feedSource, /object-contain/); + assert.doesNotMatch(feedSource, /item\.prompt/); + assert.match(studioSource, /justify-end/); assert.match(startHereSource, /Create Here/); - assert.match( - startHereSource, - /Imagine the scene\. Shape the mood\. Bring it to life\./ - ); - assert.match(startHereSource, /pointer-events-auto/); - assert.match(startHereSource, /create-thumb-forest\.webp/); - assert.match(startHereSource, /aria-pressed/); - assert.match(startHereSource, /setSelectedSrc/); - assert.doesNotMatch(startHereSource, /onSelect/); - assert.doesNotMatch(studioSource, /selectStarterIdea/); - assert.match(studioSource, /data-beatapi-composer/); - assert.match(studioSource, /WorkspaceSelect/); + assert.match(studioSource, /fetchProjectGenerations/); }); test('studio model selector follows the selected model name width', () => { assert.match( - studioSource, + composerSource, /ariaLabel="Model"[\s\S]*?triggerClassName="w-fit max-w-full"/ ); - assert.doesNotMatch( - studioSource, - /ariaLabel="Model"[\s\S]*?triggerClassName="w-\[224px\]"/ - ); }); diff --git a/src/components/studio/beat-studio-workspace.tsx b/src/components/studio/beat-studio-workspace.tsx index c5686d8..e3c7b91 100644 --- a/src/components/studio/beat-studio-workspace.tsx +++ b/src/components/studio/beat-studio-workspace.tsx @@ -1,33 +1,32 @@ 'use client'; import { useEffect, useMemo, useState } from 'react'; -import { useMutation, useQueryClient } from '@tanstack/react-query'; -import { - ArrowUp, - Film, - ImageIcon, - ImagePlus, - Loader2, - Sparkles, -} from 'lucide-react'; +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; -import { resolveOutputMedia } from '@/core/effects/output-media'; +import { StudioComposer } from '@/components/studio/studio-composer'; +import { StudioGenerationFeed } from '@/components/studio/studio-generation-feed'; +import { StudioStartHere } from '@/components/studio/studio-start-here'; +import type { CanvasCardMediaType } from '@/core/beatcanvas/canvas-types'; import { resolveWmTaskId } from '@/core/effects/client-api'; -import { getModelIconPathByModelId } from '@/core/workspace-lib/model-icons'; -import { WorkspaceSelect } from '@/components/app/workspace-select'; +import { resolveOutputMedia } from '@/core/effects/output-media'; +import { generationValidationConstraints } from '@/core/effects/validation'; import { - composerCardClassName, - composerGenerateButtonClassName, -} from '@/components/app/composer-styles'; + findWorkspaceModelOption, + getDefaultSelectableWorkspaceModel, +} from '@/core/effects/workspace-models'; +import { + applyStudioDraftModel, + createStudioDraftCard, +} from '@/core/studio/studio-draft'; import { buildStudioEffectInput, getStudioModels, type StudioMedia, } from '@/core/studio/studio-runtime'; -import { apiJsonGet, apiJsonPost } from '@/lib/api-client'; +import { fetchProjectGenerations } from '@/core/workspace-lib/app/workspace-client-api'; import { invalidateWorkspaceAfterGeneration } from '@/core/workspace-lib/app/workspace-query-invalidation'; -import { StudioStartHere } from '@/components/studio/studio-start-here'; -import { cn } from '@/lib/utils'; +import { projectGenerationsKeys } from '@/core/workspace-lib/app/workspace-query-keys'; +import { apiJsonGet, apiJsonPost } from '@/lib/api-client'; type GenerationResponse = { status?: 'pending' | 'processing' | 'succeeded' | 'failed'; @@ -76,42 +75,67 @@ export function BeatStudioWorkspace({ const queryClient = useQueryClient(); const initialMedia: StudioMedia = initialTarget === 'video' ? 'video' : 'image'; - const [media, setMedia] = useState(initialMedia); - const [prompt, setPrompt] = useState(initialPrompt || ''); - const [aspectRatio, setAspectRatio] = useState( - initialMedia === 'video' ? '16:9' : '1:1' - ); - const [modelId, setModelId] = useState(initialModelId || ''); - const [resultUrl, setResultUrl] = useState(null); + const imageModels = useMemo(() => getStudioModels('image'), []); + const videoModels = useMemo(() => getStudioModels('video'), []); + const [draft, setDraft] = useState(() => { + const models = initialMedia === 'video' ? videoModels : imageModels; + const model = + findWorkspaceModelOption(models, initialModelId) ?? + getDefaultSelectableWorkspaceModel( + initialMedia === 'video' ? 'ai-video' : 'ai-image' + ); + return createStudioDraftCard({ + type: initialMedia, + model, + prompt: initialPrompt || '', + }); + }); const [error, setError] = useState(''); - const models = useMemo(() => getStudioModels(media), [media]); + const models = draft.type === 'video' ? videoModels : imageModels; const selectedModel = - models.find((model) => model.id === modelId) ?? models[0] ?? null; + findWorkspaceModelOption(models, draft.modelId) ?? models[0] ?? null; + + const generationsQuery = useQuery({ + queryKey: projectGenerationsKeys.list(projectId), + queryFn: () => fetchProjectGenerations(projectId), + refetchInterval: (query) => + query.state.data?.items.some( + (item) => item.status === 'pending' || item.status === 'processing' + ) + ? 2500 + : false, + }); + const feedItems = (generationsQuery.data?.items ?? []).filter( + (item) => item.status !== 'failed' + ); useEffect(() => { - if (!selectedModel) return; - if (selectedModel.id !== modelId) setModelId(selectedModel.id); - const ratios = selectedModel.supportedAspectRatios || []; - if (ratios.length && !ratios.includes(aspectRatio as never)) { - setAspectRatio(selectedModel.defaultAspectRatio || ratios[0]); - } - }, [aspectRatio, modelId, selectedModel]); + if (!selectedModel || selectedModel.id === draft.modelId) return; + setDraft((current) => + applyStudioDraftModel({ draft: current, model: selectedModel }) + ); + }, [draft.modelId, selectedModel]); const generation = useMutation({ mutationFn: async () => { if (!selectedModel) { throw new Error('No model is available for this media type.'); } - if (!prompt.trim()) { + if (!draft.prompt.trim()) { throw new Error('Describe what you want to create first.'); } const payload = { effectId: selectedModel.effectId, input: buildStudioEffectInput({ - media, + media: draft.type, model: selectedModel, - prompt, - aspectRatio, + prompt: draft.prompt, + aspectRatio: draft.aspectRatio, + duration: draft.duration, + outputQuality: draft.outputQuality, + mode: draft.mode, + quality: draft.quality, + language: draft.language, }), projectId, }; @@ -133,26 +157,25 @@ export function BeatStudioWorkspace({ if (created.status === 'failed') { throw new Error(created.error || 'Generation failed'); } - let output = created.output; + await invalidateWorkspaceAfterGeneration(queryClient); const wmTaskId = resolveWmTaskId(created); if (wmTaskId && created.status !== 'succeeded') { - output = await waitForGeneration({ + await waitForGeneration({ wmTaskId, effectId: selectedModel.effectId, }); - } - const mediaOutput = resolveOutputMedia(output); - if (!mediaOutput.resultUrl) { + } else if (!resolveOutputMedia(created.output).resultUrl) { throw new Error('Generation completed without a media URL.'); } - return mediaOutput.resultUrl; }, - onSuccess: (url) => { - setResultUrl(url); + onSuccess: () => { setError(''); void invalidateWorkspaceAfterGeneration(queryClient); }, - onError: (generationError: Error) => setError(generationError.message), + onError: (generationError: Error) => { + setError(generationError.message); + void invalidateWorkspaceAfterGeneration(queryClient); + }, }); return ( @@ -160,152 +183,56 @@ export function BeatStudioWorkspace({
-
- {resultUrl ? ( -
- {media === 'video' ? ( -