Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions docs/PRODUCT_PLAN_AND_STATUS.md
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,8 @@ Codex / Claude Code / Other Agent
### Project / Local-first

- 项目创建、打开、重命名和本地 SQLite 持久化。
- 项目列表中选择 Studio 或 Canvas 后直接创建并进入对应视图,创建期间防止重复点击,失败重试保留原选择。
- 直接访问 `/studio` 或 `/canvas` 时只提供对应视图的创建按钮,访问本身不创建项目;创建后保留语言、模型和提示词入口参数。
- 界面支持英语、中文和日语,并可在主页、项目列表及工作区顶栏切换;英文使用无前缀路径,中文和日语分别使用 `/zh` 与 `/ja`。
- Canvas Snapshot 自动保存、页面隐藏时刷新和版本冲突保护。
- 本地上传图片、视频、音频后立即复制到项目目录。
Expand Down
4 changes: 3 additions & 1 deletion messages/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
4 changes: 3 additions & 1 deletion messages/ja.json
Original file line number Diff line number Diff line change
Expand Up @@ -745,7 +745,9 @@
"retry": "もう一度試す",
"backToHistory": "履歴に戻る",
"startTitle": "新しいプロジェクトを始める",
"startDescription": "StudioまたはCanvasを選択してプロジェクトを作成します。",
"startDescription": "ここでプロジェクトを作成します。StudioとCanvasはいつでも切り替えられます。",
"createStudio": "Studioで作成",
"createCanvas": "Canvasで作成",
"startStudio": "Studio",
"startCanvas": "Canvas",
"defaultName": "無題のプロジェクト"
Expand Down
4 changes: 3 additions & 1 deletion messages/zh.json
Original file line number Diff line number Diff line change
Expand Up @@ -745,7 +745,9 @@
"retry": "重试",
"backToHistory": "返回历史",
"startTitle": "开始新项目",
"startDescription": "选择创作台或画布来创建这个项目。",
"startDescription": "创建项目后即可开始,创作台和画布可随时切换。",
"createStudio": "创建并进入创作台",
"createCanvas": "创建并进入画布",
"startStudio": "创作台",
"startCanvas": "画布",
"defaultName": "未命名项目"
Expand Down
47 changes: 44 additions & 3 deletions src/components/app/create-project-route-page.test.ts
Original file line number Diff line number Diff line change
@@ -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),
Expand All @@ -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(/<button\b/g) ?? []).length, 1);
assert.ok(
html.includes(
workspaceMode === 'canvas' ? 'Create in Canvas' : 'Create in Studio'
)
);
assert.ok(
!html.includes(
workspaceMode === 'canvas' ? 'Create in Studio' : 'Create in Canvas'
)
);
assert.ok(!html.includes('Choose Studio or Canvas'));
}
});

test('the first-run screen covers the full viewport', () => {
Expand Down
88 changes: 23 additions & 65 deletions src/components/app/create-project-route-page.tsx
Original file line number Diff line number Diff line change
@@ -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;
Expand Down Expand Up @@ -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 }
),
Expand All @@ -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<string | null>(null);

async function createProject(workspaceMode: WorkspaceMode) {
if (creating) return;
setCreating(true);
setError(null);

try {
const payload = await apiJsonPost<CreateProjectResponse>(
'/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 (
<main className="flex min-h-screen items-center justify-center bg-[var(--beat-bg)] px-6 py-16 text-[var(--beat-text-1)]">
Expand Down Expand Up @@ -128,28 +94,20 @@ export function CreateProjectRoutePage({
<Button
type="button"
className="h-11 rounded-[var(--beat-radius-sm)] bg-[var(--beat-accent)] px-5 text-[var(--beat-accent-ink)] hover:bg-[var(--beat-accent-hover)]"
onClick={() => void createProject('studio')}
onClick={() => void retryProject()}
>
{copy.retry}
</Button>
) : (
<>
<Button
type="button"
className="h-11 rounded-[var(--beat-radius-sm)] bg-[var(--beat-accent)] px-5 text-[var(--beat-accent-ink)] hover:bg-[var(--beat-accent-hover)]"
onClick={() => void createProject('studio')}
>
{copy.startStudio}
</Button>
<Button
type="button"
variant="outline"
className="h-11 rounded-[16px] border-white/[0.16] bg-transparent px-5 text-white hover:bg-white/[0.06]"
onClick={() => void createProject('canvas')}
>
{copy.startCanvas}
</Button>
</>
<Button
type="button"
className="h-11 rounded-[var(--beat-radius-sm)] bg-[var(--beat-accent)] px-5 text-[var(--beat-accent-ink)] hover:bg-[var(--beat-accent-hover)]"
onClick={() => void createProject(workspaceMode)}
>
{workspaceMode === 'studio'
? copy.startStudio
: copy.startCanvas}
</Button>
)}
</div>
) : null}
Expand Down
5 changes: 3 additions & 2 deletions src/components/home/workspace-page.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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/);
});
Loading