feat: complete TASK-WP2-01 project foundations

This commit is contained in:
suyx
2026-07-28 20:12:12 +08:00
parent 59e566885b
commit 4d38530361
19 changed files with 3696 additions and 12 deletions
+33 -1
View File
@@ -1,6 +1,6 @@
// Generated from openapi/openapi.json. Do not edit by hand.
import type { AccountDeletionResponse, AccountDeletionCompleteRequest, AdminLoginCompleteResponse, AdminLoginCompleteRequest, LoginCompleteResponse, LoginCompleteRequest, RegistrationCompleteResponse, RegistrationCompleteRequest, AccountSettingsResponse, AdminSessionResponse, UserSessionResponse, LogoutResponse, AccountDeletionSendResponse, RegistrationSendResponse, AdminLoginSendRequest, LoginSendRequest, RegistrationSendRequest, AccountProfileUpdateResponse, AccountProfileUpdateRequest } from "./types.gen.js";
import type { AccountDeletionResponse, AccountDeletionCompleteRequest, AdminLoginCompleteResponse, AdminLoginCompleteRequest, LoginCompleteResponse, LoginCompleteRequest, RegistrationCompleteResponse, RegistrationCompleteRequest, AccountSettingsResponse, AdminSessionResponse, ProjectDetailResponse, UserSessionResponse, ProjectListResponse, LogoutResponse, ProjectRenameResponse, ProjectRenameRequest, AccountDeletionSendResponse, RegistrationSendResponse, AdminLoginSendRequest, LoginSendRequest, RegistrationSendRequest, FailedEmptyTrashResponse, FailedEmptyTrashRequest, AccountProfileUpdateResponse, AccountProfileUpdateRequest } from "./types.gen.js";
export interface ClientOptions { baseUrl?: string; fetch?: typeof globalThis.fetch; headers?: HeadersInit; }
@@ -138,6 +138,13 @@ export function getEvents(options: Pick<ClientOptions, "baseUrl"> = {}): string
return `${options.baseUrl ?? ""}/api/v1/events`;
}
export async function getProject(options: ClientOptions = {}): Promise<ProjectDetailResponse> {
const request = options.fetch ?? globalThis.fetch;
const response = await request(`${options.baseUrl ?? ""}/api/v1/projects/{projectId}`, { method: "GET", headers: options.headers ?? {} });
if (!response.ok) throw new Error(`HTTP ${response.status}`);
return response.json() as Promise<ProjectDetailResponse>;
}
export async function getUserSession(options: ClientOptions = {}): Promise<UserSessionResponse> {
const request = options.fetch ?? globalThis.fetch;
const response = await request(`${options.baseUrl ?? ""}/api/v1/auth/session`, { method: "GET", headers: options.headers ?? {} });
@@ -145,6 +152,13 @@ export async function getUserSession(options: ClientOptions = {}): Promise<UserS
return response.json() as Promise<UserSessionResponse>;
}
export async function listProjects(options: ClientOptions = {}): Promise<ProjectListResponse> {
const request = options.fetch ?? globalThis.fetch;
const response = await request(`${options.baseUrl ?? ""}/api/v1/projects`, { method: "GET", headers: options.headers ?? {} });
if (!response.ok) throw new Error(`HTTP ${response.status}`);
return response.json() as Promise<ProjectListResponse>;
}
export async function logoutUser(options: ClientOptions = {}): Promise<LogoutResponse> {
const request = options.fetch ?? globalThis.fetch;
const response = await request(`${options.baseUrl ?? ""}/api/v1/auth/logout`, { method: "POST", headers: options.headers ?? {} });
@@ -152,6 +166,15 @@ export async function logoutUser(options: ClientOptions = {}): Promise<LogoutRes
return response.json() as Promise<LogoutResponse>;
}
export async function renameProject(body: ProjectRenameRequest, options: ClientOptions = {}): Promise<ProjectRenameResponse> {
const request = options.fetch ?? globalThis.fetch;
const headers = new Headers(options.headers);
headers.set("Content-Type", "application/json");
const response = await request(`${options.baseUrl ?? ""}/api/v1/projects/{projectId}`, { body: JSON.stringify(body), method: "PATCH", headers });
if (!response.ok) throw new Error(`HTTP ${response.status}`);
return response.json() as Promise<ProjectRenameResponse>;
}
export async function sendAccountDeletionCode(options: ClientOptions = {}): Promise<AccountDeletionSendResponse> {
const request = options.fetch ?? globalThis.fetch;
const response = await request(`${options.baseUrl ?? ""}/api/v1/account/deletion/send`, { method: "POST", headers: options.headers ?? {} });
@@ -186,6 +209,15 @@ export async function sendRegistrationCode(body: RegistrationSendRequest, option
return response.json() as Promise<RegistrationSendResponse>;
}
export async function trashFailedEmptyProjects(body: FailedEmptyTrashRequest, options: ClientOptions = {}): Promise<FailedEmptyTrashResponse> {
const request = options.fetch ?? globalThis.fetch;
const headers = new Headers(options.headers);
headers.set("Content-Type", "application/json");
const response = await request(`${options.baseUrl ?? ""}/api/v1/projects/failed-empty/trash`, { body: JSON.stringify(body), method: "POST", headers });
if (!response.ok) throw new Error(`HTTP ${response.status}`);
return response.json() as Promise<FailedEmptyTrashResponse>;
}
export async function updateAccountProfile(body: AccountProfileUpdateRequest, options: ClientOptions = {}): Promise<AccountProfileUpdateResponse> {
const request = options.fetch ?? globalThis.fetch;
const headers = new Headers(options.headers);
+87
View File
@@ -187,8 +187,27 @@ export type ErrorEnvelope = {
};
};
export type FailedEmptyTrashRequest = {
"project_ids": Array<ProjectId>;
};
export type FailedEmptyTrashResponse = {
"ignored_project_ids": Array<ProjectId>;
"trashed_project_ids": Array<ProjectId>;
};
export type GenerationErrorCategory = "upstream_timeout" | "upstream_failed" | "safety_rejected" | "model_disabled" | "gateway_balance_insufficient" | "gateway_contract_invalid" | "reference_invalid" | "unknown_retryable" | "unknown_non_retryable";
export type GenerationProjectItem = {
"created_at": string;
"error_category": GenerationErrorCategory | null;
"generation_id": string;
"prompt": string;
"ratio": ProjectRatio;
"status": "queued" | "running" | "succeeded" | "failed" | "rejected";
"updated_at": string;
};
export type LoginCompleteRequest = {
"registration_id": string;
"verification_code": string;
@@ -231,6 +250,74 @@ export type ModelRuntimeSseEvent = {
"runtime_availability_version": number;
};
export type ProjectDetailResponse = {
"created_at": string;
"current_image_id": ProjectId | null;
"deleted_at": string | null;
"draft_prompt": string;
"generations": Array<GenerationProjectItem>;
"images": Array<ProjectImageItem>;
"name": string;
"pixel_height": number;
"pixel_width": number;
"project_id": ProjectId;
"purge_at": string | null;
"ratio": ProjectRatio;
"state_version": number;
"status": ProjectViewStatus;
"successful_image_count": number;
"updated_at": string;
};
export type ProjectId = string;
export type ProjectImageItem = {
"created_at": string;
"generation_id": string;
"image_id": ProjectId;
};
export type ProjectListQuery = {
"status"?: "active" | "trashed";
};
export type ProjectListResponse = {
"active_count": number;
"active_limit": 20;
"projects": Array<ProjectSummary>;
};
export type ProjectParams = {
"projectId": ProjectId;
};
export type ProjectRatio = "3:4" | "1:1" | "4:3" | "9:16";
export type ProjectRenameRequest = {
"name": string;
};
export type ProjectRenameResponse = {
"name": string;
"state_version": number;
"status": "renamed";
};
export type ProjectSummary = {
"current_image_id": ProjectId | null;
"deleted_at": string | null;
"name": string;
"project_id": ProjectId;
"purge_at": string | null;
"ratio": ProjectRatio;
"state_version": number;
"status": ProjectViewStatus;
"successful_image_count": number;
"updated_at": string;
};
export type ProjectViewStatus = "active" | "failed_empty" | "trashed";
export type RegistrationCompleteHeaders = {
"idempotency-key": string;
};
+13 -6
View File
@@ -5,6 +5,7 @@ import { registerPublicAssetServiceWorker } from "./public-asset-cache.js";
import { AdminAuthPage } from "./admin-auth.js";
import { UserAuthPage } from "./user-auth.js";
import { AccountSettingsPage } from "./account-settings.js";
import { ProjectDetailPage, ProjectsPage, WorkspacePage } from "./project-pages.js";
const root = document.getElementById("root");
@@ -20,11 +21,14 @@ let authRevision = 0;
function renderAuthenticationEntry() {
authRevision += 1;
const authenticationPage = window.location.pathname === "/app/settings"
? <AccountSettingsPage key={authRevision} />
: window.location.pathname.startsWith("/admin")
? <AdminAuthPage key={authRevision} />
: <UserAuthPage key={authRevision} />;
const projectDetail = window.location.pathname.match(/^\/app\/projects\/([0-9a-f-]{36})$/i);
let authenticationPage;
if (window.location.pathname === "/app/settings") authenticationPage = <AccountSettingsPage key={authRevision} />;
else if (projectDetail?.[1]) authenticationPage = <ProjectDetailPage key={authRevision} projectId={projectDetail[1]} />;
else if (window.location.pathname === "/app/projects") authenticationPage = <ProjectsPage key={authRevision} />;
else if (window.location.pathname === "/app") authenticationPage = <WorkspacePage key={authRevision} />;
else if (window.location.pathname.startsWith("/admin")) authenticationPage = <AdminAuthPage key={authRevision} />;
else authenticationPage = <UserAuthPage key={authRevision} />;
appRoot.render(
<StrictMode>
{authenticationPage}
@@ -33,5 +37,8 @@ function renderAuthenticationEntry() {
}
// Any authenticated surface can dispatch this after a revoked/invalid session response.
window.addEventListener("dada:session-invalid", renderAuthenticationEntry);
window.addEventListener("dada:session-invalid", () => {
if (window.location.pathname.startsWith("/app")) window.history.replaceState(null, "", "/");
renderAuthenticationEntry();
});
renderAuthenticationEntry();
+995
View File
@@ -0,0 +1,995 @@
.product-page,
.product-loading {
min-height: 100vh;
color: #111111;
background: #f6f6f4;
}
.product-page {
display: flex;
flex-direction: column;
}
.product-loading {
display: grid;
place-content: center;
gap: 16px;
text-align: center;
}
.product-loading button,
.product-loading a {
min-height: 44px;
padding: 11px 18px;
border: 1px solid #111111;
color: #111111;
background: #ffffff;
text-decoration: none;
}
.product-header {
display: flex;
min-height: 68px;
align-items: stretch;
padding: 0 max(3vw, 32px);
border-bottom: 1px solid #9c9c96;
background: #ffffff;
}
.product-brand {
display: flex;
align-items: center;
margin-right: 24px;
color: #111111;
font-family: Arial Black, "Segoe UI", sans-serif;
font-size: 25px;
font-weight: 900;
text-decoration: none;
}
.product-header nav {
display: flex;
align-items: stretch;
gap: 4px;
}
.product-header nav a {
display: flex;
min-width: 72px;
align-items: center;
justify-content: center;
padding: 0 14px;
color: #111111;
font-weight: 700;
text-decoration: none;
}
.product-header nav a[aria-current="page"] {
background: #f2f500;
}
.workspace-art {
position: relative;
display: grid;
min-height: 238px;
grid-template-columns: minmax(330px, 0.7fr) minmax(500px, 1.7fr);
gap: 40px;
align-items: center;
overflow: hidden;
padding: 30px max(3vw, 32px);
border-bottom: 1px solid #9c9c96;
background: #eeeee9;
}
.workspace-art > div:first-child {
z-index: 2;
}
.workspace-art strong {
display: block;
font-family: Arial Black, "Segoe UI", sans-serif;
font-size: 90px;
font-weight: 900;
line-height: 0.9;
}
.workspace-art h1 {
margin: 20px 0 0;
font-size: 25px;
}
.workspace-art-system {
position: relative;
display: grid;
min-height: 160px;
align-content: center;
justify-items: end;
padding: 32px 14%;
border-bottom: 1px solid #65655f;
font-family: Consolas, monospace;
font-size: 12px;
}
.workspace-art-system::before {
position: absolute;
top: 20px;
left: 4%;
width: 50%;
height: 105px;
background: #b9b9b4;
content: "";
}
.workspace-art-system::after {
position: absolute;
top: 62px;
right: 8%;
width: 58%;
height: 70px;
background: #f2f500;
content: "";
}
.workspace-art-system i {
position: absolute;
z-index: 1;
top: 0;
right: 33%;
width: 1px;
height: 100%;
background: #4f4f4a;
}
.workspace-art-system b {
z-index: 2;
min-width: 190px;
}
.workspace-main {
display: grid;
grid-template-columns: minmax(0, 1fr) 360px;
gap: 22px;
max-width: 1540px;
margin: 0 auto;
padding: 28px max(3vw, 32px) 64px;
}
.generation-area {
min-width: 0;
}
.workspace-compact-heading,
.recent-projects header,
.project-current > header,
.project-history > header {
display: flex;
align-items: end;
justify-content: space-between;
gap: 20px;
}
.workspace-compact-heading {
margin-bottom: 18px;
}
.workspace-compact-heading p,
.projects-title p,
.project-detail-header p {
margin: 0 0 4px;
font-family: Consolas, monospace;
font-size: 11px;
font-weight: 700;
}
.workspace-compact-heading h1,
.projects-title h1,
.project-detail-header h1 {
margin: 0;
}
.prompt-label {
display: block;
margin-bottom: 10px;
font-size: 17px;
font-weight: 800;
}
.generation-area textarea {
width: 100%;
min-height: 124px;
resize: vertical;
padding: 16px;
border: 1px solid #6f6f69;
border-radius: 0;
background: #ffffff;
line-height: 1.6;
}
.generation-options {
display: grid;
grid-template-columns: minmax(260px, 1fr) minmax(360px, 0.8fr);
gap: 10px;
margin-top: 12px;
}
.model-status,
.ratio-control,
.reference-input {
border: 1px solid #8f8f89;
background: #ffffff;
}
.model-status {
display: grid;
gap: 6px;
align-content: center;
min-height: 76px;
padding: 12px 14px;
}
.model-status span,
.ratio-control legend,
.reference-input small {
color: #65655f;
font-size: 12px;
}
.ratio-control {
min-width: 0;
margin: 0;
padding: 8px 12px 12px;
}
.ratio-control legend {
padding: 0 4px;
}
.ratio-control > div {
display: grid;
grid-template-columns: repeat(4, minmax(58px, 1fr));
gap: 6px;
}
.ratio-control label {
min-width: 0;
}
.ratio-control input {
position: absolute;
opacity: 0;
pointer-events: none;
}
.ratio-control span {
display: grid;
min-height: 38px;
place-items: center;
border: 1px solid #85857f;
font-family: Consolas, monospace;
font-weight: 700;
}
.ratio-control input:checked + span {
border-color: #111111;
background: #f2f500;
}
.ratio-control input:focus-visible + span {
outline: 2px solid #1769aa;
outline-offset: 2px;
}
.reference-input {
display: grid;
grid-template-columns: auto 1fr;
gap: 6px 20px;
align-items: center;
min-height: 76px;
margin-top: 12px;
padding: 13px 16px;
cursor: pointer;
}
.reference-input > span {
font-weight: 800;
}
.reference-input small {
overflow: hidden;
text-align: right;
text-overflow: ellipsis;
white-space: nowrap;
}
.reference-input input {
grid-column: 1 / -1;
width: 100%;
}
.generation-submit {
display: flex;
align-items: center;
justify-content: space-between;
gap: 20px;
margin-top: 16px;
}
.generation-submit span {
color: #5b5b55;
font-size: 13px;
}
.generation-submit button,
.failed-draft-bar button {
min-width: 220px;
min-height: 48px;
border: 1px solid #111111;
background: #f2f500;
font-weight: 800;
}
.generation-submit button:disabled,
.failed-draft-bar button:disabled {
cursor: not-allowed;
background: #deded9;
color: #777770;
}
.current-task {
grid-column: 2;
grid-row: 1 / span 2;
min-height: 430px;
padding: 22px;
border: 1px solid #75756f;
background: #ffffff;
}
.current-task h2 {
margin: 0;
font-size: 18px;
}
.current-task-empty {
display: grid;
min-height: 330px;
place-content: center;
gap: 12px;
justify-items: center;
text-align: center;
}
.current-task-empty i {
width: 34px;
height: 34px;
border: 3px dashed #777770;
border-radius: 50%;
}
.current-task-empty span {
color: #6a6a64;
font-size: 13px;
}
.recent-projects {
grid-column: 1;
margin-top: 28px;
}
.recent-projects h2 {
margin: 0;
font-size: 18px;
}
.recent-projects a {
color: #1769aa;
font-weight: 700;
}
.projects-page,
.project-detail-page {
width: min(1340px, calc(100% - 64px));
margin: 0 auto;
padding: 44px 0 80px;
}
.projects-title {
display: flex;
align-items: end;
justify-content: space-between;
gap: 24px;
padding-bottom: 24px;
border-bottom: 1px solid #a4a49e;
}
.projects-title h1 {
font-size: 40px;
}
.projects-title > strong {
font-family: Consolas, monospace;
font-size: 15px;
}
.projects-tabs {
display: flex;
gap: 0;
margin-top: 22px;
}
.projects-tabs button {
min-width: 132px;
min-height: 44px;
border: 1px solid #868680;
background: #ffffff;
font-weight: 700;
}
.projects-tabs button + button {
border-left: 0;
}
.projects-tabs button[aria-selected="true"] {
background: #111111;
color: #ffffff;
}
.projects-controls {
display: grid;
grid-template-columns: minmax(220px, 1fr) 170px 170px;
gap: 10px;
margin: 14px 0 24px;
}
.projects-controls input,
.projects-controls select {
min-width: 0;
min-height: 44px;
padding: 8px 12px;
border: 1px solid #8a8a84;
border-radius: 0;
background: #ffffff;
}
.project-grid {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 16px;
}
.project-grid.compact {
margin-top: 14px;
}
.project-card {
position: relative;
min-width: 0;
border: 1px solid #7b7b75;
background: #ffffff;
}
.project-select {
position: absolute;
z-index: 2;
top: 12px;
left: 12px;
display: grid;
width: 34px;
height: 34px;
place-items: center;
border: 1px solid #111111;
background: #ffffff;
}
.project-select input {
width: 18px;
height: 18px;
}
.project-placeholder {
display: grid;
height: 154px;
grid-template-columns: repeat(4, 1fr);
overflow: hidden;
border-bottom: 1px solid #a5a59f;
background: #d8d8d3;
}
.project-placeholder span {
display: grid;
place-items: end center;
padding-bottom: 14px;
color: #b2b2ad;
font-family: Arial Black, sans-serif;
font-size: 54px;
}
.project-placeholder span:nth-child(2) {
background: #c4c4bf;
}
.project-placeholder span:nth-child(3) {
background: #ecece8;
}
.project-card[data-status="failed_empty"] .project-placeholder {
background: #e7e7e2;
filter: grayscale(1);
}
.project-card[data-status="active"] .project-placeholder span:nth-child(3) {
background: #f2f500;
}
.project-card-body {
display: grid;
min-height: 170px;
grid-template-rows: minmax(48px, auto) auto auto 1fr;
gap: 8px;
padding: 16px;
}
.project-card-body > div {
display: flex;
align-items: start;
justify-content: space-between;
gap: 12px;
}
.project-card h3 {
display: -webkit-box;
overflow: hidden;
margin: 0;
font-size: 17px;
line-height: 1.35;
overflow-wrap: anywhere;
-webkit-box-orient: vertical;
-webkit-line-clamp: 2;
}
.project-card-body > div span {
flex: 0 0 auto;
padding: 3px 6px;
background: #e9e9e5;
font-family: Consolas, monospace;
font-size: 10px;
}
.project-card-body p,
.project-card-body time {
margin: 0;
color: #676761;
font-size: 12px;
}
.project-card-body > a {
align-self: end;
justify-self: start;
color: #111111;
font-weight: 800;
}
.projects-stale,
.projects-notice {
padding: 12px 14px;
border-left: 5px solid #d14a3b;
background: #fff1ef;
font-weight: 700;
}
.projects-notice {
border-color: #287b45;
background: #edf8f0;
}
.projects-empty {
display: grid;
min-height: 360px;
place-content: center;
justify-items: center;
text-align: center;
}
.projects-empty a,
.projects-empty button {
min-height: 44px;
padding: 11px 18px;
border: 1px solid #111111;
color: #111111;
background: #f2f500;
font-weight: 700;
text-decoration: none;
}
.failed-draft-bar {
position: sticky;
z-index: 5;
bottom: 58px;
display: flex;
align-items: center;
justify-content: space-between;
gap: 20px;
margin-top: 24px;
padding: 14px 18px;
border: 1px solid #111111;
background: #ffffff;
box-shadow: 6px 6px 0 #111111;
}
.project-detail-header {
display: grid;
grid-template-columns: 44px minmax(0, 1fr) auto;
gap: 18px;
align-items: center;
padding-bottom: 22px;
border-bottom: 1px solid #9f9f99;
}
.project-detail-header > a {
display: grid;
width: 44px;
height: 44px;
place-items: center;
border: 1px solid #111111;
color: #111111;
font-size: 20px;
text-decoration: none;
}
.project-detail-header h1 {
overflow-wrap: anywhere;
font-size: 34px;
}
.project-detail-header > span {
padding: 9px 12px;
border: 1px solid #8e8e88;
font-family: Consolas, monospace;
font-weight: 700;
}
.project-identity {
display: grid;
grid-template-columns: 240px minmax(0, 1fr);
gap: 28px;
align-items: center;
padding: 22px 0;
border-bottom: 1px solid #c2c2bc;
}
.project-identity h2,
.project-identity p {
margin: 0;
}
.project-identity p {
margin-top: 4px;
color: #6b6b65;
font-family: Consolas, monospace;
font-size: 11px;
}
.project-identity form {
display: grid;
grid-template-columns: minmax(0, 1fr) 130px;
gap: 8px;
}
.project-identity input,
.project-identity button {
min-width: 0;
min-height: 44px;
border: 1px solid #85857f;
border-radius: 0;
}
.project-identity input {
padding: 9px 12px;
background: #ffffff;
}
.project-identity button {
background: #f2f500;
font-weight: 800;
}
.project-identity form span {
grid-column: 1 / -1;
font-size: 12px;
}
.project-detail-grid {
display: grid;
grid-template-columns: minmax(0, 1.25fr) minmax(360px, 0.75fr);
gap: 28px;
padding-top: 28px;
}
.project-current > header,
.project-history > header {
min-height: 42px;
margin-bottom: 12px;
}
.project-current h2,
.project-history h2 {
margin: 0;
font-size: 19px;
}
.project-current > .project-placeholder {
height: auto;
min-height: 480px;
border: 1px solid #73736d;
}
.project-current > .project-placeholder span {
font-size: 80px;
}
.project-actions {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 8px;
margin-top: 12px;
}
.project-actions a,
.project-actions button,
.project-retry {
display: grid;
min-height: 44px;
place-items: center;
border: 1px solid #111111;
color: #111111;
background: #ffffff;
font-weight: 700;
text-align: center;
text-decoration: none;
}
.project-actions > :first-child {
background: #f2f500;
}
.project-actions button:disabled {
border-color: #b2b2ac;
color: #777770;
background: #dfdfda;
cursor: not-allowed;
}
.project-blocker {
margin: 10px 0 0;
color: #a52e24;
font-weight: 700;
}
.project-retry {
width: 180px;
margin-top: 12px;
background: #f2f500;
}
.project-history ol {
display: grid;
max-height: 650px;
gap: 10px;
overflow-y: auto;
margin: 0;
padding: 0;
list-style: none;
}
.project-history li {
display: grid;
grid-template-columns: 132px minmax(0, 1fr);
gap: 12px;
padding: 8px;
border: 1px solid #a4a49e;
background: #ffffff;
}
.project-history li[data-current="true"] {
border: 3px solid #111111;
padding: 6px;
}
.project-history li .project-placeholder {
height: 88px;
border: 0;
}
.project-history li .project-placeholder span {
padding-bottom: 7px;
font-size: 18px;
}
.project-history li > div:last-child {
display: grid;
align-content: center;
gap: 8px;
}
.project-history time {
color: #6b6b65;
font-size: 11px;
}
.history-empty {
display: grid;
min-height: 320px;
place-content: center;
padding: 28px;
border: 1px dashed #94948e;
text-align: center;
}
.history-empty p {
max-width: 320px;
overflow-wrap: anywhere;
color: #65655f;
}
.local-only-footer {
display: grid;
width: 100%;
min-height: 48px;
margin-top: auto;
place-items: center;
padding: 8px 20px;
color: #ffffff;
background: #111111;
font-size: 13px;
font-weight: 700;
text-align: center;
}
@media (max-width: 920px) {
.workspace-art {
grid-template-columns: 1fr;
}
.workspace-art-system {
display: none;
}
.workspace-main {
grid-template-columns: 1fr;
}
.current-task {
grid-column: 1;
grid-row: auto;
min-height: 260px;
}
.current-task-empty {
min-height: 190px;
}
.recent-projects {
grid-column: 1;
}
.generation-options,
.project-detail-grid {
grid-template-columns: 1fr;
}
.project-grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
}
@media (max-width: 600px) {
.product-header {
display: grid;
padding: 0 16px;
}
.product-brand {
min-height: 54px;
margin: 0;
}
.product-header nav {
min-width: 0;
overflow-x: auto;
border-top: 1px solid #d0d0ca;
}
.product-header nav a {
min-width: 68px;
min-height: 48px;
padding: 0 10px;
}
.workspace-art {
min-height: 190px;
padding: 28px 20px;
}
.workspace-art strong {
font-size: 66px;
}
.workspace-art h1 {
font-size: 23px;
}
.workspace-main,
.projects-page,
.project-detail-page {
width: 100%;
padding-right: 16px;
padding-left: 16px;
}
.generation-options,
.generation-submit,
.reference-input,
.projects-title,
.failed-draft-bar,
.project-identity {
grid-template-columns: 1fr;
}
.generation-submit,
.projects-title,
.failed-draft-bar {
align-items: stretch;
flex-direction: column;
}
.generation-submit button,
.failed-draft-bar button {
width: 100%;
min-width: 0;
}
.reference-input small {
text-align: left;
}
.projects-title {
display: grid;
}
.projects-controls {
grid-template-columns: 1fr;
}
.project-grid {
grid-template-columns: 1fr;
}
.project-detail-header {
grid-template-columns: 44px minmax(0, 1fr);
}
.project-detail-header > span {
grid-column: 2;
justify-self: start;
}
.project-identity {
display: grid;
gap: 14px;
}
.project-identity form {
grid-template-columns: 1fr;
}
.project-actions {
grid-template-columns: 1fr;
}
.project-current > .project-placeholder {
min-height: 360px;
}
.local-only-footer {
font-size: 11px;
}
}
+448
View File
@@ -0,0 +1,448 @@
import { useEffect, useId, useMemo, useState, type FormEvent } from "react";
import "./project-pages.css";
type Ratio = "3:4" | "1:1" | "4:3" | "9:16";
type ProjectStatus = "active" | "failed_empty" | "trashed";
interface SessionPayload {
credits: { available_balance: number; reserved_balance: number };
csrf_token: string;
user: { creator_name: string };
}
interface ProjectSummary {
current_image_id: string | null;
deleted_at?: string | null;
name: string;
project_id: string;
purge_at?: string | null;
ratio: Ratio;
state_version: number;
status: ProjectStatus;
successful_image_count: number;
updated_at: string;
}
interface ProjectListPayload {
active_count: number;
active_limit: 20;
projects: ProjectSummary[];
}
interface ProjectDetailPayload extends ProjectSummary {
created_at: string;
draft_prompt: string;
generations: Array<{
created_at: string;
error_category: string | null;
generation_id: string;
prompt: string;
ratio: Ratio;
status: "queued" | "running" | "succeeded" | "failed" | "rejected";
updated_at: string;
}>;
images: Array<{ created_at: string; generation_id: string; image_id: string }>;
pixel_height?: number;
pixel_width?: number;
}
async function readJson<T>(url: string, init?: RequestInit): Promise<T> {
const response = await fetch(url, { credentials: "same-origin", ...init });
if (response.status === 401) {
window.dispatchEvent(new Event("dada:session-invalid"));
throw new Error("session_invalid");
}
if (!response.ok) throw new Error("request_failed");
return response.json() as Promise<T>;
}
function formatUpdatedAt(value: string) {
return new Intl.DateTimeFormat("zh-CN", { dateStyle: "medium", timeStyle: "short" }).format(new Date(value));
}
function ProductHeader({ current }: { current: "workspace" | "projects" }) {
return (
<header className="product-header">
<a className="product-brand" href="/app">DADA</a>
<nav aria-label="主导航">
<a aria-current={current === "workspace" ? "page" : undefined} href="/app"></a>
<a aria-current={current === "projects" ? "page" : undefined} href="/app/projects"></a>
<a href="/app/credits"></a>
<a href="/app/settings"></a>
</nav>
</header>
);
}
function LocalOnlyFooter() {
return <footer className="local-only-footer"></footer>;
}
function LoadingPage({ label }: { label: string }) {
return <main className="product-loading" aria-live="polite">{label}</main>;
}
function ProjectPlaceholder({ ratio, status }: { ratio: Ratio; status: ProjectStatus }) {
return (
<div className="project-placeholder" data-ratio={ratio} data-status={status} aria-hidden="true">
<span>D</span><span>A</span><span>D</span><span>A</span>
</div>
);
}
export function WorkspacePage() {
const promptId = useId();
const [session, setSession] = useState<SessionPayload>();
const [projects, setProjects] = useState<ProjectListPayload>();
const [loadingFailed, setLoadingFailed] = useState(false);
const [prompt, setPrompt] = useState("");
const [ratio, setRatio] = useState<Ratio>("3:4");
const [references, setReferences] = useState<string[]>([]);
useEffect(() => {
let active = true;
Promise.all([
readJson<SessionPayload>("/api/v1/auth/session"),
readJson<ProjectListPayload>("/api/v1/projects?status=active"),
]).then(([nextSession, nextProjects]) => {
if (!active) return;
setSession(nextSession);
setProjects(nextProjects);
}).catch((error) => {
if (active && error instanceof Error && error.message !== "session_invalid") setLoadingFailed(true);
});
return () => { active = false; };
}, []);
if (loadingFailed) {
return (
<main className="product-loading">
<p role="alert"></p>
<button onClick={() => window.location.reload()} type="button"></button>
</main>
);
}
if (!session || !projects) return <LoadingPage label="正在读取创作工作台" />;
const empty = projects.projects.length === 0;
return (
<div className="product-page">
<ProductHeader current="workspace" />
{empty ? (
<section className="workspace-art" aria-labelledby="workspace-title">
<div>
<strong>DADA</strong>
<h1 id="workspace-title"></h1>
</div>
<div className="workspace-art-system" aria-hidden="true">
<i /><b>NEW PROJECT</b><b>LOCAL ONLY</b><b>NO CLOUD SYNC</b>
</div>
</section>
) : null}
<main className={`workspace-main ${empty ? "is-empty" : "has-projects"}`}>
<section className="generation-area" aria-labelledby={empty ? undefined : "workspace-compact-title"}>
{!empty ? (
<div className="workspace-compact-heading">
<div><p>NEW PROJECT</p><h1 id="workspace-compact-title"></h1></div>
<span> {session.credits.available_balance} </span>
</div>
) : null}
<label className="prompt-label" htmlFor={promptId}></label>
<textarea
id={promptId}
maxLength={4_000}
onChange={(event) => setPrompt(event.target.value)}
placeholder="输入提示词…"
value={prompt}
/>
<div className="generation-options">
<div className="model-status" aria-live="polite">
<span></span>
<strong></strong>
</div>
<fieldset className="ratio-control">
<legend></legend>
<div>
{(["3:4", "1:1", "4:3", "9:16"] as const).map((value) => (
<label key={value}>
<input checked={ratio === value} name="ratio" onChange={() => setRatio(value)} type="radio" value={value} />
<span>{value}</span>
</label>
))}
</div>
</fieldset>
</div>
<label className="reference-input">
<span></span>
<small>{references.length > 0 ? references.join("、") : "尚未提交,仅保留在当前页面"}</small>
<input
accept="image/jpeg,image/png,image/webp"
multiple
onChange={(event) => setReferences(Array.from(event.target.files ?? []).map((file) => file.name))}
type="file"
/>
</label>
<div className="generation-submit">
<span></span>
<button disabled type="button"></button>
</div>
</section>
<aside className="current-task" aria-labelledby="current-task-title">
<h2 id="current-task-title"></h2>
<div className="current-task-empty"><i /><strong></strong><span></span></div>
</aside>
{!empty ? (
<section className="recent-projects" aria-labelledby="recent-title">
<header><h2 id="recent-title"></h2><a href="/app/projects"></a></header>
<div className="project-grid compact">
{projects.projects.slice(0, 6).map((project) => <ProjectCard key={project.project_id} project={project} />)}
</div>
</section>
) : null}
</main>
<LocalOnlyFooter />
</div>
);
}
function ProjectCard({ project, selectable, selected, onSelect }: {
onSelect?: (selected: boolean) => void;
project: ProjectSummary;
selectable?: boolean;
selected?: boolean;
}) {
return (
<article className="project-card" data-status={project.status}>
{selectable ? (
<label className="project-select">
<input
aria-label={`选择失败草稿:${project.name}`}
checked={selected}
onChange={(event) => onSelect?.(event.target.checked)}
type="checkbox"
/>
</label>
) : null}
<ProjectPlaceholder ratio={project.ratio} status={project.status} />
<div className="project-card-body">
<div><h3 title={project.name}>{project.name}</h3><span>{project.status === "failed_empty" ? "生成失败" : project.status === "trashed" ? "回收站" : "项目"}</span></div>
<p>{project.successful_image_count} · {project.ratio}</p>
<time dateTime={project.updated_at}>{formatUpdatedAt(project.updated_at)}</time>
<a aria-label={`打开项目:${project.name}`} href={`/app/projects/${project.project_id}`}></a>
</div>
</article>
);
}
export function ProjectsPage() {
const [session, setSession] = useState<SessionPayload>();
const [payload, setPayload] = useState<ProjectListPayload>();
const [status, setStatus] = useState<"active" | "trashed">("active");
const [query, setQuery] = useState("");
const [sort, setSort] = useState<"updated" | "name">("updated");
const [selected, setSelected] = useState<string[]>([]);
const [loadingFailed, setLoadingFailed] = useState(false);
const [notice, setNotice] = useState("");
useEffect(() => {
let active = true;
setLoadingFailed(false);
Promise.all([
session ? Promise.resolve(session) : readJson<SessionPayload>("/api/v1/auth/session"),
readJson<ProjectListPayload>(`/api/v1/projects?status=${status}`),
]).then(([nextSession, nextProjects]) => {
if (!active) return;
setSession(nextSession);
setPayload(nextProjects);
setSelected([]);
}).catch((error) => {
if (active && error instanceof Error && error.message !== "session_invalid") setLoadingFailed(true);
});
return () => { active = false; };
}, [status]);
const visible = useMemo(() => {
const normalized = query.trim().toLocaleLowerCase("zh-CN");
const items = (payload?.projects ?? []).filter((project) => project.name.toLocaleLowerCase("zh-CN").includes(normalized));
return items.toSorted((left, right) => sort === "name"
? left.name.localeCompare(right.name, "zh-CN")
: Date.parse(right.updated_at) - Date.parse(left.updated_at));
}, [payload, query, sort]);
async function batchTrash() {
if (!session || selected.length === 0) return;
try {
const result = await readJson<{ ignored_project_ids: string[]; trashed_project_ids: string[] }>("/api/v1/projects/failed-empty/trash", {
body: JSON.stringify({ project_ids: selected }),
headers: { "Content-Type": "application/json", "X-CSRF-Token": session.csrf_token },
method: "POST",
});
setPayload((current) => current ? {
...current,
active_count: Math.max(0, current.active_count - result.trashed_project_ids.length),
projects: current.projects.filter((project) => !result.trashed_project_ids.includes(project.project_id)),
} : current);
setSelected([]);
setNotice("失败草稿已移入回收站");
} catch {
setNotice("操作未完成,请重试");
}
}
if (!payload && !loadingFailed) return <LoadingPage label="正在读取项目" />;
return (
<div className="product-page">
<ProductHeader current="projects" />
<main className="projects-page">
<header className="projects-title">
<div><p>LOCAL PROJECTS</p><h1></h1></div>
<strong>{payload?.active_count ?? 0} / 20 active</strong>
</header>
<div className="projects-tabs" role="tablist" aria-label="项目状态">
<button aria-selected={status === "active"} onClick={() => setStatus("active")} role="tab" type="button"></button>
<button aria-selected={status === "trashed"} onClick={() => setStatus("trashed")} role="tab" type="button"></button>
</div>
<div className="projects-controls">
<input aria-label="搜索项目" onChange={(event) => setQuery(event.target.value)} placeholder="搜索项目" type="search" value={query} />
<select aria-label="状态筛选" value={status} onChange={(event) => setStatus(event.target.value as "active" | "trashed")}>
<option value="active">active</option><option value="trashed">trashed</option>
</select>
<select aria-label="项目排序" value={sort} onChange={(event) => setSort(event.target.value as "updated" | "name")}>
<option value="updated"></option><option value="name"></option>
</select>
</div>
{loadingFailed ? <p className="projects-stale" role="alert">{payload ? "当前显示上次读取的数据。" : ""}</p> : null}
{notice ? <p className="projects-notice" role="status">{notice}</p> : null}
{visible.length === 0 ? (
<section className="projects-empty">
<h2>{query ? "没有符合条件的项目" : status === "active" ? "还没有项目" : "回收站为空"}</h2>
{query ? <button onClick={() => setQuery("")} type="button"></button> : status === "active" ? <a href="/app"></a> : null}
</section>
) : (
<div className="project-grid">
{visible.map((project) => (
<ProjectCard
key={project.project_id}
onSelect={(checked) => setSelected((current) => checked
? [...current, project.project_id]
: current.filter((projectId) => projectId !== project.project_id))}
project={project}
selectable={status === "active" && project.status === "failed_empty"}
selected={selected.includes(project.project_id)}
/>
))}
</div>
)}
{status === "active" && payload?.projects.some((project) => project.status === "failed_empty") ? (
<section className="failed-draft-bar" aria-label="失败草稿快捷清理">
<span> {selected.length} 稿</span>
<button disabled={selected.length === 0} onClick={batchTrash} type="button"></button>
</section>
) : null}
</main>
<LocalOnlyFooter />
</div>
);
}
export function ProjectDetailPage({ projectId }: { projectId: string }) {
const [session, setSession] = useState<SessionPayload>();
const [project, setProject] = useState<ProjectDetailPayload>();
const [name, setName] = useState("");
const [savingName, setSavingName] = useState(false);
const [nameStatus, setNameStatus] = useState("");
const [loadingFailed, setLoadingFailed] = useState(false);
useEffect(() => {
let active = true;
Promise.all([
readJson<SessionPayload>("/api/v1/auth/session"),
readJson<ProjectDetailPayload>(`/api/v1/projects/${projectId}`),
]).then(([nextSession, nextProject]) => {
if (!active) return;
setSession(nextSession);
setProject(nextProject);
setName(nextProject.name);
}).catch((error) => {
if (active && error instanceof Error && error.message !== "session_invalid") setLoadingFailed(true);
});
return () => { active = false; };
}, [projectId]);
async function rename(event: FormEvent) {
event.preventDefault();
if (!session || !project || savingName || !name.trim()) return;
setSavingName(true);
setNameStatus("");
try {
const result = await readJson<{ name: string; state_version: number }>(`/api/v1/projects/${projectId}`, {
body: JSON.stringify({ name }), headers: { "Content-Type": "application/json", "X-CSRF-Token": session.csrf_token }, method: "PATCH",
});
setProject({ ...project, name: result.name, state_version: result.state_version });
setName(result.name);
setNameStatus("项目名已保存");
} catch {
setNameStatus("项目名保存失败");
} finally {
setSavingName(false);
}
}
if (!project && !loadingFailed) return <LoadingPage label="正在读取项目详情" />;
if (!project) {
return <main className="product-loading"><p role="alert"></p><a href="/app/projects"></a></main>;
}
const atHistoryLimit = project.successful_image_count >= 10;
return (
<div className="product-page">
<ProductHeader current="projects" />
<main className="project-detail-page">
<header className="project-detail-header">
<a href="/app/projects" aria-label="返回项目列表"></a>
<div><p>{project.status === "failed_empty" ? "FAILED EMPTY" : project.status.toUpperCase()}</p><h1>{project.name}</h1></div>
<span> {project.ratio}</span>
</header>
<section className="project-identity" aria-labelledby="rename-title">
<div><h2 id="rename-title"></h2><p>state version {project.state_version}</p></div>
<form onSubmit={rename}>
<input aria-label="项目名称" maxLength={80} onChange={(event) => setName(event.target.value)} value={name} />
<button disabled={savingName || !name.trim() || name.trim() === project.name} type="submit">{savingName ? "保存中" : "保存名称"}</button>
{nameStatus ? <span role="status">{nameStatus}</span> : null}
</form>
</section>
<div className="project-detail-grid">
<section className="project-current" aria-labelledby="current-image-title">
<header><h2 id="current-image-title"></h2><span>{project.pixel_width ?? 1080} × {project.pixel_height ?? 1440}</span></header>
<ProjectPlaceholder ratio={project.ratio} status={project.status} />
<div className="project-actions">
<button disabled={atHistoryLimit} onClick={() => window.location.assign(`/app?continue=${project.project_id}`)} type="button"></button>
<button disabled={!project.current_image_id} type="button"></button>
<button disabled={!project.current_image_id} type="button"></button>
</div>
{atHistoryLimit ? <p className="project-blocker"></p> : null}
{project.status === "failed_empty" ? <a className="project-retry" href={`/app?retry=${project.project_id}`}></a> : null}
</section>
<section className="project-history" aria-labelledby="history-title">
<header><h2 id="history-title"></h2><strong>{project.successful_image_count} / 10 </strong></header>
{project.images.length === 0 ? (
<div className="history-empty"><strong></strong><p>{project.draft_prompt}</p></div>
) : (
<ol>
{project.images.toReversed().map((image, index) => (
<li key={image.image_id} data-current={image.image_id === project.current_image_id}>
<ProjectPlaceholder ratio={project.ratio} status="active" />
<div><strong> {project.images.length - index}</strong><time dateTime={image.created_at}>{formatUpdatedAt(image.created_at)}</time></div>
</li>
))}
</ol>
)}
</section>
</div>
</main>
<LocalOnlyFooter />
</div>
);
}