diff --git a/docs/PRODUCT_PLAN_AND_STATUS.md b/docs/PRODUCT_PLAN_AND_STATUS.md index 367f27c..163c3f1 100644 --- a/docs/PRODUCT_PLAN_AND_STATUS.md +++ b/docs/PRODUCT_PLAN_AND_STATUS.md @@ -97,6 +97,8 @@ Codex / Claude Code / Other Agent ### Project / Local-first - 项目创建、打开、重命名和本地 SQLite 持久化。 +- 项目列表中选择 Studio 或 Canvas 后直接创建并进入对应视图,创建期间防止重复点击,失败重试保留原选择。 +- 直接访问 `/studio` 或 `/canvas` 时只提供对应视图的创建按钮,访问本身不创建项目;创建后保留语言、模型和提示词入口参数。 - 界面支持英语、中文和日语,并可在主页、项目列表及工作区顶栏切换;英文使用无前缀路径,中文和日语分别使用 `/zh` 与 `/ja`。 - Canvas Snapshot 自动保存、页面隐藏时刷新和版本冲突保护。 - 本地上传图片、视频、音频后立即复制到项目目录。 diff --git a/messages/en.json b/messages/en.json index 3ca8c74..af811a9 100644 --- a/messages/en.json +++ b/messages/en.json @@ -745,7 +745,9 @@ "retry": "Try again", "backToHistory": "Back to history", "startTitle": "Start a new project", - "startDescription": "Choose Studio or Canvas to create this project.", + "startDescription": "Create your project here. You can switch between Studio and Canvas at any time.", + "createStudio": "Create in Studio", + "createCanvas": "Create in Canvas", "startStudio": "Studio", "startCanvas": "Canvas", "defaultName": "Untitled project" diff --git a/messages/ja.json b/messages/ja.json index 6a05122..2444b39 100644 --- a/messages/ja.json +++ b/messages/ja.json @@ -745,7 +745,9 @@ "retry": "もう一度試す", "backToHistory": "履歴に戻る", "startTitle": "新しいプロジェクトを始める", - "startDescription": "StudioまたはCanvasを選択してプロジェクトを作成します。", + "startDescription": "ここでプロジェクトを作成します。StudioとCanvasはいつでも切り替えられます。", + "createStudio": "Studioで作成", + "createCanvas": "Canvasで作成", "startStudio": "Studio", "startCanvas": "Canvas", "defaultName": "無題のプロジェクト" diff --git a/messages/zh.json b/messages/zh.json index 1ae6210..bef98a6 100644 --- a/messages/zh.json +++ b/messages/zh.json @@ -745,7 +745,9 @@ "retry": "重试", "backToHistory": "返回历史", "startTitle": "开始新项目", - "startDescription": "选择创作台或画布来创建这个项目。", + "startDescription": "创建项目后即可开始,创作台和画布可随时切换。", + "createStudio": "创建并进入创作台", + "createCanvas": "创建并进入画布", "startStudio": "创作台", "startCanvas": "画布", "defaultName": "未命名项目" diff --git a/src/components/app/create-project-route-page.test.ts b/src/components/app/create-project-route-page.test.ts index 94bf924..c47bf6a 100644 --- a/src/components/app/create-project-route-page.test.ts +++ b/src/components/app/create-project-route-page.test.ts @@ -1,6 +1,9 @@ import assert from 'node:assert/strict'; import { readFileSync } from 'node:fs'; import test from 'node:test'; +import { createElement } from 'react'; +import { renderToStaticMarkup } from 'react-dom/server'; +import { CreateProjectRoutePage } from './create-project-route-page'; const source = readFileSync( new URL('./create-project-route-page.tsx', import.meta.url), @@ -9,10 +12,48 @@ const source = readFileSync( test('visiting Studio or Canvas does not create a project automatically', () => { assert.doesNotMatch(source, /useEffect/); - assert.match(source, /createProject\('studio'\)/); - assert.match(source, /createProject\('canvas'\)/); + const hook = readFileSync( + new URL( + '../../core/workspace-hooks/use-create-project.ts', + import.meta.url + ), + 'utf8' + ); + assert.doesNotMatch(hook, /useEffect/); + assert.match( + source, + /onClick=\{\(\) => void createProject\(workspaceMode\)\}/ + ); assert.match(source, /appConfig\.app_logo/); - assert.match(source, /'\/api\/app\/projects'/); +}); + +test('direct entry only offers the workspace selected by its route', () => { + for (const workspaceMode of ['studio', 'canvas'] as const) { + const html = renderToStaticMarkup( + createElement(CreateProjectRoutePage, { + data: { + locale: 'en', + target: null, + model: null, + prompt: null, + projectName: 'Entry test', + workspaceMode, + }, + }) + ); + assert.equal((html.match(/ { diff --git a/src/components/app/create-project-route-page.tsx b/src/components/app/create-project-route-page.tsx index a03c53d..5548127 100644 --- a/src/components/app/create-project-route-page.tsx +++ b/src/components/app/create-project-route-page.tsx @@ -1,20 +1,12 @@ -import { useState } from 'react'; import { AlertCircleIcon, Loader2Icon } from 'lucide-react'; import { Button } from '@/components/ui/button'; import { appConfig } from '@/config'; import { normalizeLocale } from '@/config/locale'; import type { WorkspaceMode } from '@/config/workspace-modes'; -import { buildPostCreateProjectDetailPath } from '@/core/projects/project-entry'; -import { apiJsonPost } from '@/lib/api-client'; +import { useCreateProject } from '@/core/workspace-hooks/use-create-project'; import { m } from '@/paraglide/messages.js'; -type CreateProjectResponse = { - id?: string; - name?: string; - error?: string; -}; - export type CreateProjectRouteData = { locale: string; target: string | null; @@ -42,11 +34,11 @@ function getCopy(locale: string) { {}, { locale: messageLocale } ), - startStudio: m['BeatAPI.project.startStudio']( + startStudio: m['BeatAPI.project.createStudio']( {}, { locale: messageLocale } ), - startCanvas: m['BeatAPI.project.startCanvas']( + startCanvas: m['BeatAPI.project.createCanvas']( {}, { locale: messageLocale } ), @@ -58,43 +50,17 @@ export function CreateProjectRoutePage({ }: { data: CreateProjectRouteData; }) { - const { locale, target, model, prompt, projectName } = data; + const { locale, target, model, prompt, projectName, workspaceMode } = data; const copy = getCopy(locale); - const [creating, setCreating] = useState(false); - const [error, setError] = useState(null); - - async function createProject(workspaceMode: WorkspaceMode) { - if (creating) return; - setCreating(true); - setError(null); - - try { - const payload = await apiJsonPost( - '/api/app/projects', - { name: projectName, workspaceMode } - ); - - if (!payload?.id) { - throw new Error(payload?.error || copy.errorTitle); - } - - window.location.replace( - buildPostCreateProjectDetailPath({ - locale, - projectId: payload.id, - target: target ?? undefined, - model: model ?? undefined, - prompt: prompt ?? undefined, - mode: workspaceMode, - }) - ); - } catch (createError) { - setError( - createError instanceof Error ? createError.message : copy.errorTitle - ); - setCreating(false); - } - } + const { creating, error, createProject, retryProject } = useCreateProject({ + locale, + projectName, + errorMessage: copy.errorTitle, + replace: true, + target: target ?? undefined, + model: model ?? undefined, + prompt: prompt ?? undefined, + }); return (
@@ -128,28 +94,20 @@ export function CreateProjectRoutePage({ ) : ( - <> - - - + )} ) : null} diff --git a/src/components/home/workspace-page.test.ts b/src/components/home/workspace-page.test.ts index 0993616..579820f 100644 --- a/src/components/home/workspace-page.test.ts +++ b/src/components/home/workspace-page.test.ts @@ -12,7 +12,8 @@ test('projects page stays simple and resumes each project in its last workspace' assert.doesNotMatch(source, /copy\.allProjects/); assert.match(source, /mode: project\.lastWorkspaceMode/); assert.match(source, /copy\.createTitle/); - assert.match(source, /href="\/studio"/); - assert.match(source, /href="\/canvas"/); + assert.match(source, /onClick=\{\(\) => void createProject\('studio'\)\}/); + assert.match(source, /onClick=\{\(\) => void createProject\('canvas'\)\}/); + assert.doesNotMatch(source, /href="\/(studio|canvas)"/); assert.match(source, /sm:whitespace-nowrap/); }); diff --git a/src/components/home/workspace-page.tsx b/src/components/home/workspace-page.tsx index b30ff4a..1b5dd25 100644 --- a/src/components/home/workspace-page.tsx +++ b/src/components/home/workspace-page.tsx @@ -4,6 +4,7 @@ import { Plus, Trash2, Workflow, + Loader2, } from 'lucide-react'; import { useState } from 'react'; @@ -20,6 +21,7 @@ import { import { Link } from '@/core/i18n/navigation'; import { buildLocalizedProjectDetailPath } from '@/core/projects/project-entry'; import { useWorkspaceProjects } from '@/core/workspace-hooks/use-workspace-projects'; +import { useCreateProject } from '@/core/workspace-hooks/use-create-project'; import type { WorkspaceProjectCardItem } from '@/core/workspace-lib/app/workspace-client-api'; import { m } from '@/paraglide/messages.js'; @@ -32,6 +34,16 @@ function getHistoryCopy(locale: string) { newProject: m['product.history.newProject']({}, { locale: messageLocale }), newStudio: m['product.history.newStudio']({}, { locale: messageLocale }), newCanvas: m['product.history.newCanvas']({}, { locale: messageLocale }), + creating: m['BeatAPI.project.newTitle']({}, { locale: messageLocale }), + createError: m['BeatAPI.project.newErrorTitle']( + {}, + { locale: messageLocale } + ), + retry: m['BeatAPI.project.retry']({}, { locale: messageLocale }), + defaultName: m['BeatAPI.project.defaultName']( + {}, + { locale: messageLocale } + ), createTitle: m['product.history.createTitle']( {}, { locale: messageLocale } @@ -68,10 +80,7 @@ function getHistoryCopy(locale: string) { {}, { locale: messageLocale } ), - startFirst: m['product.history.startFirst']( - {}, - { locale: messageLocale } - ), + startFirst: m['product.history.startFirst']({}, { locale: messageLocale }), emptyDescription: m['product.history.emptyDescription']( {}, { locale: messageLocale } @@ -97,13 +106,28 @@ function CreateProjectDialog({ open, onOpenChange, copy, + locale, }: { open: boolean; onOpenChange: (open: boolean) => void; copy: HistoryCopy; + locale: string; }) { + const { creating, error, createProject, retryProject, clearError } = + useCreateProject({ + locale, + projectName: copy.defaultName, + errorMessage: copy.createError, + }); return ( - + { + if (creating) return; + clearError(); + onOpenChange(nextOpen); + }} + > @@ -115,36 +139,62 @@ function CreateProjectDialog({
- void createProject('studio')} + className="group relative min-h-[190px] overflow-hidden rounded-[20px] border border-[#ff7a33]/35 bg-[radial-gradient(circle_at_82%_8%,rgba(255,122,51,0.22),transparent_42%),#171719] p-5 text-left transition hover:-translate-y-0.5 hover:border-[#ff7a33]/65 hover:bg-[#1b1b1e] disabled:cursor-wait disabled:opacity-60" > -

+ {copy.newStudio} -

-

+ + {copy.studioDescription} -

- + + - void createProject('canvas')} + className="group relative min-h-[190px] overflow-hidden rounded-[20px] border border-white/[0.10] bg-[radial-gradient(circle_at_82%_8%,rgba(255,255,255,0.09),transparent_42%),#171719] p-5 text-left transition hover:-translate-y-0.5 hover:border-white/[0.22] hover:bg-[#1b1b1e] disabled:cursor-wait disabled:opacity-60" > -

+ {copy.newCanvas} -

-

+ + {copy.canvasDescription} -

- + +
+ {creating ? ( +

+ + {copy.creating} +

+ ) : error ? ( +
+

+ {error} +

+ +
+ ) : null}
); @@ -228,7 +278,10 @@ export function WorkspacePage({ /> ) : (
- +
)}
@@ -278,6 +331,7 @@ export function WorkspacePage({ open={createOpen} onOpenChange={setCreateOpen} copy={copy} + locale={locale} /> (null); + const [creating, setCreating] = useState(false); + const [error, setError] = useState(null); + + async function createProject(workspaceMode: WorkspaceMode) { + // State updates may not render before a second click arrives. + if (inFlight.current) return; + inFlight.current = true; + lastMode.current = workspaceMode; + setCreating(true); + setError(null); + try { + const payload = await createWorkspaceProject({ + name: projectName, + workspaceMode, + }); + if (!payload?.id) throw new Error(payload?.error || errorMessage); + const path = buildPostCreateProjectDetailPath({ + locale, + projectId: payload.id, + ...intent, + mode: workspaceMode, + }); + if (replace) window.location.replace(path); + else window.location.assign(path); + } catch (createError) { + setError( + createError instanceof Error ? createError.message : errorMessage + ); + inFlight.current = false; + setCreating(false); + } + } + + return { + creating, + error, + createProject, + clearError: () => setError(null), + retryProject: () => + lastMode.current ? createProject(lastMode.current) : Promise.resolve(), + }; +} diff --git a/src/core/workspace-lib/app/workspace-client-api.ts b/src/core/workspace-lib/app/workspace-client-api.ts index c4bb41c..b6acd05 100644 --- a/src/core/workspace-lib/app/workspace-client-api.ts +++ b/src/core/workspace-lib/app/workspace-client-api.ts @@ -1,6 +1,6 @@ import type { WorkspaceMode } from '@/config/workspace-modes'; import type { ProjectGenerationItem } from '@/core/effects/project-generations'; -import { apiJsonDelete, apiJsonGet } from '@/lib/api-client'; +import { apiJsonDelete, apiJsonGet, apiJsonPost } from '@/lib/api-client'; export type WorkspaceProjectCardItem = { id: string; @@ -51,6 +51,16 @@ export async function deleteWorkspaceProjects(projectIds: string[]) { return apiJsonDelete<{ success: true }>('/api/app/projects', { projectIds }); } +export async function createWorkspaceProject(input: { + name: string; + workspaceMode: WorkspaceMode; +}) { + return apiJsonPost<{ id?: string; name?: string; error?: string }>( + '/api/app/projects', + input + ); +} + export async function fetchProjectGenerations(projectId: string) { return apiJsonGet<{ items: ProjectGenerationItem[] }>( `/api/app/projects/${encodeURIComponent(projectId)}/generations` diff --git a/tests/e2e/project-creation.spec.ts b/tests/e2e/project-creation.spec.ts new file mode 100644 index 0000000..753285b --- /dev/null +++ b/tests/e2e/project-creation.spec.ts @@ -0,0 +1,93 @@ +import { expect, test } from '@playwright/test'; + +test.use({ locale: 'en-US' }); + +for (const mode of ['canvas', 'studio'] as const) { + test(`choosing ${mode} creates one project and opens it without a second chooser`, async ({ + page, + }) => { + const creations: string[] = []; + page.on('request', (request) => { + if ( + request.method() === 'POST' && + new URL(request.url()).pathname === '/api/app/projects' + ) { + creations.push(request.postData() ?? ''); + } + }); + await page.goto('/projects'); + await page + .getByRole('button', { name: 'New project', exact: true }) + .first() + .click(); + const choice = mode === 'canvas' ? 'Start in Canvas' : 'Start in Studio'; + await page.getByRole('button', { name: new RegExp(`^${choice}`) }).click(); + await expect(page).toHaveURL(new RegExp(`/${mode}/[^/?]+$`)); + await expect( + page.getByRole('heading', { name: 'Start a new project', exact: true }) + ).toHaveCount(0); + expect(creations).toHaveLength(1); + expect(JSON.parse(creations[0]).workspaceMode).toBe(mode); + }); +} + +test('failed Canvas creation retries in Canvas', async ({ page }) => { + let attempts = 0; + await page.route('**/api/app/projects', async (route) => { + if (route.request().method() !== 'POST') return route.continue(); + expect(route.request().postDataJSON().workspaceMode).toBe('canvas'); + attempts += 1; + if (attempts === 1) { + return route.fulfill({ + status: 503, + contentType: 'application/json', + body: JSON.stringify({ error: 'Creation temporarily unavailable' }), + }); + } + return route.continue(); + }); + await page.goto('/projects'); + await page + .getByRole('button', { name: 'New project', exact: true }) + .first() + .click(); + await page.getByRole('button', { name: /^Start in Canvas/ }).click(); + await expect(page.getByRole('alert')).toHaveText( + 'Creation temporarily unavailable' + ); + await page.getByRole('button', { name: 'Try again', exact: true }).click(); + await expect(page).toHaveURL(/\/canvas\/[^/?]+$/); + expect(attempts).toBe(2); +}); + +test('direct Canvas entry requires one explicit create action and retains entry parameters', async ({ + page, +}) => { + let creations = 0; + page.on('request', (request) => { + if ( + request.method() === 'POST' && + new URL(request.url()).pathname === '/api/app/projects' + ) + creations += 1; + }); + await page.goto( + '/canvas?target=image&model=nano-banana-pro&prompt=Entry+test' + ); + const create = page.getByRole('button', { + name: 'Create in Canvas', + exact: true, + }); + await expect(create).toBeVisible(); + await expect( + page.getByRole('button', { name: 'Create in Studio', exact: true }) + ).toHaveCount(0); + expect(creations).toBe(0); + await create.click(); + await expect(page).toHaveURL(/\/canvas\/[^/?]+\?/); + const url = new URL(page.url()); + expect(url.searchParams.get('target')).toBe('image'); + expect(url.searchParams.get('model')).toBe('nano-banana-pro'); + expect(url.searchParams.get('prompt')).toBe('Entry test'); + expect(creations).toBe(1); +});