diff --git a/apps/api/src/app.ts b/apps/api/src/app.ts index 2ec522a..eb057ea 100644 --- a/apps/api/src/app.ts +++ b/apps/api/src/app.ts @@ -29,6 +29,20 @@ import { LogoutResponseSchema, ModelConfigSseEventSchema, ModelRuntimeSseEventSchema, + FailedEmptyTrashRequestSchema, + FailedEmptyTrashResponseSchema, + GenerationProjectItemSchema, + ProjectDetailResponseSchema, + ProjectIdSchema, + ProjectImageItemSchema, + ProjectListQuerySchema, + ProjectListResponseSchema, + ProjectParamsSchema, + ProjectRatioSchema, + ProjectRenameRequestSchema, + ProjectRenameResponseSchema, + ProjectSummarySchema, + ProjectViewStatusSchema, RegistrationCompleteHeadersSchema, RegistrationCompleteRequestSchema, RegistrationCompleteResponseSchema, @@ -48,6 +62,10 @@ import { type AccountProfileUpdateRequest, type LoginCompleteRequest, type LoginSendRequest, + type FailedEmptyTrashRequest, + type ProjectListQuery, + type ProjectParams, + type ProjectRenameRequest, type RegistrationCompleteRequest, type RegistrationSendRequest, } from "@dada/shared-contracts"; @@ -70,6 +88,8 @@ import { import { EventHub } from "./event-hub.js"; import type { PublicAssetResolver } from "./local-data-root.js"; import { isAllowedNetworkRequest, type NetworkBoundaryOptions } from "./network-boundary.js"; +import { ProjectError } from "./project-errors.js"; +import type { ProjectService } from "./projects.js"; import { RegistrationError, registrationFieldError, @@ -96,6 +116,7 @@ export interface CreateAppOptions { eventHub?: EventHub; networkBoundary?: NetworkBoundaryOptions; publicAssets?: PublicAssetResolver; + projects?: ProjectService; registration?: RegistrationService; } @@ -163,6 +184,64 @@ function registrationValidationFailure(reply: FastifyReply, correlationId: strin ); } +function projectFailure(reply: FastifyReply, correlationId: string, error: unknown) { + if (!(error instanceof ProjectError)) { + return reply.code(503).send(createErrorEnvelope({ code: "AUTH_SERVICE_UNAVAILABLE", correlationId })); + } + const mapping = { + generation_state_invalid: 400, + project_active_limit: 409, + project_history_limit: 409, + project_name_invalid: 400, + project_not_found: 404, + project_ratio_fixed: 409, + project_retry_not_allowed: 409, + } as const; + return reply.code(mapping[error.code]).send(null); +} + +type ProjectSummaryView = ReturnType[number]; +type ProjectDetailView = ReturnType; + +function projectSummaryResponse(project: ProjectSummaryView) { + return { + current_image_id: project.currentImageId, + deleted_at: project.deletedAt, + name: project.name, + project_id: project.projectId, + purge_at: project.purgeAt, + ratio: project.ratio, + state_version: project.stateVersion, + status: project.status, + successful_image_count: project.successfulImageCount, + updated_at: project.updatedAt, + }; +} + +function projectDetailResponse(project: ProjectDetailView) { + return { + ...projectSummaryResponse(project), + created_at: project.createdAt, + draft_prompt: project.draftPrompt, + generations: project.generations.map((generation) => ({ + created_at: generation.createdAt, + error_category: generation.errorCategory, + generation_id: generation.generationId, + prompt: generation.prompt, + ratio: generation.ratio, + status: generation.status, + updated_at: generation.updatedAt, + })), + images: project.images.map((image) => ({ + created_at: image.createdAt, + generation_id: image.generationId, + image_id: image.imageId, + })), + pixel_height: project.pixelHeight, + pixel_width: project.pixelWidth, + }; +} + function isSupportGateRequest(method: string, path: string) { if (method === "POST" && path === "/api/v1/support/check") return true; if (method !== "GET" && method !== "HEAD") return false; @@ -261,6 +340,20 @@ export async function createApp(options: CreateAppOptions = {}) { ModelConfigSseEventSchema, ModelRuntimeSseEventSchema, SseEventSchema, + ProjectIdSchema, + ProjectRatioSchema, + ProjectViewStatusSchema, + ProjectSummarySchema, + ProjectListQuerySchema, + ProjectListResponseSchema, + ProjectParamsSchema, + GenerationProjectItemSchema, + ProjectImageItemSchema, + ProjectDetailResponseSchema, + ProjectRenameRequestSchema, + ProjectRenameResponseSchema, + FailedEmptyTrashRequestSchema, + FailedEmptyTrashResponseSchema, ]) { app.addSchema(schema); } @@ -920,6 +1013,163 @@ export async function createApp(options: CreateAppOptions = {}) { }, ); + app.get( + "/api/v1/projects", + { + attachValidation: true, + schema: { + operationId: "listProjects", + querystring: Type.Ref(ProjectListQuerySchema), + response: { + 200: Type.Ref(ProjectListResponseSchema), + 400: Type.Null(), + 401: Type.Ref(ErrorEnvelopeSchema), + 503: Type.Ref(ErrorEnvelopeSchema), + }, + tags: ["Projects"], + }, + }, + async (request, reply) => { + if (request.validationError) { + return reply.code(400).send(null); + } + if (!options.registration || !options.projects) { + return reply.code(503).send(createErrorEnvelope({ code: "AUTH_SERVICE_UNAVAILABLE", correlationId: request.id })); + } + const token = cookieValue(headerValue(request.headers.cookie), userSessionCookieName); + const session = token ? options.registration.readUserSession(token) : undefined; + if (!session) return reply.code(401).send(createErrorEnvelope({ code: "AUTH_SESSION_INVALID", correlationId: request.id })); + const query = request.query as ProjectListQuery; + const status = query.status ?? "active"; + return { + active_count: options.projects.activeProjectCount(session.userId), + active_limit: 20 as const, + projects: options.projects.listProjects(session.userId, status).slice(0, 20).map(projectSummaryResponse), + }; + }, + ); + + app.get( + "/api/v1/projects/:projectId", + { + attachValidation: true, + schema: { + operationId: "getProject", + params: Type.Ref(ProjectParamsSchema), + response: { + 200: Type.Ref(ProjectDetailResponseSchema), + 400: Type.Null(), + 401: Type.Ref(ErrorEnvelopeSchema), + 404: Type.Null(), + 503: Type.Ref(ErrorEnvelopeSchema), + }, + tags: ["Projects"], + }, + }, + async (request, reply) => { + if (request.validationError) { + return reply.code(400).send(null); + } + if (!options.registration || !options.projects) { + return reply.code(503).send(createErrorEnvelope({ code: "AUTH_SERVICE_UNAVAILABLE", correlationId: request.id })); + } + const token = cookieValue(headerValue(request.headers.cookie), userSessionCookieName); + const session = token ? options.registration.readUserSession(token) : undefined; + if (!session) return reply.code(401).send(createErrorEnvelope({ code: "AUTH_SESSION_INVALID", correlationId: request.id })); + try { + return projectDetailResponse(options.projects.getProject(session.userId, (request.params as ProjectParams).projectId)); + } catch (error) { + return projectFailure(reply, request.id, error); + } + }, + ); + + app.patch( + "/api/v1/projects/:projectId", + { + attachValidation: true, + schema: { + body: Type.Ref(ProjectRenameRequestSchema), + headers: Type.Ref(CsrfHeadersSchema), + operationId: "renameProject", + params: Type.Ref(ProjectParamsSchema), + response: { + 200: Type.Ref(ProjectRenameResponseSchema), + 400: Type.Null(), + 401: Type.Ref(ErrorEnvelopeSchema), + 403: Type.Ref(ErrorEnvelopeSchema), + 404: Type.Null(), + 503: Type.Ref(ErrorEnvelopeSchema), + }, + tags: ["Projects"], + }, + }, + async (request, reply) => { + if (request.validationError) { + return reply.code(400).send(null); + } + if (!options.registration || !options.projects) { + return reply.code(503).send(createErrorEnvelope({ code: "AUTH_SERVICE_UNAVAILABLE", correlationId: request.id })); + } + const token = cookieValue(headerValue(request.headers.cookie), userSessionCookieName); + const csrfToken = headerValue(request.headers["x-csrf-token"]); + if (!token || !csrfToken) return reply.code(401).send(createErrorEnvelope({ code: "AUTH_SESSION_INVALID", correlationId: request.id })); + try { + const owner = options.registration.authorizeUserMutation({ csrfToken, sessionToken: token }); + const renamed = options.projects.renameProject( + owner.userId, + (request.params as ProjectParams).projectId, + (request.body as ProjectRenameRequest).name, + ); + return { name: renamed.name, state_version: renamed.stateVersion, status: "renamed" as const }; + } catch (error) { + return error instanceof RegistrationError + ? registrationFailure(reply, request.id, error) + : projectFailure(reply, request.id, error); + } + }, + ); + + app.post( + "/api/v1/projects/failed-empty/trash", + { + attachValidation: true, + schema: { + body: Type.Ref(FailedEmptyTrashRequestSchema), + headers: Type.Ref(CsrfHeadersSchema), + operationId: "trashFailedEmptyProjects", + response: { + 200: Type.Ref(FailedEmptyTrashResponseSchema), + 400: Type.Null(), + 401: Type.Ref(ErrorEnvelopeSchema), + 403: Type.Ref(ErrorEnvelopeSchema), + 503: Type.Ref(ErrorEnvelopeSchema), + }, + tags: ["Projects"], + }, + }, + async (request, reply) => { + if (request.validationError) { + return reply.code(400).send(null); + } + if (!options.registration || !options.projects) { + return reply.code(503).send(createErrorEnvelope({ code: "AUTH_SERVICE_UNAVAILABLE", correlationId: request.id })); + } + const token = cookieValue(headerValue(request.headers.cookie), userSessionCookieName); + const csrfToken = headerValue(request.headers["x-csrf-token"]); + if (!token || !csrfToken) return reply.code(401).send(createErrorEnvelope({ code: "AUTH_SESSION_INVALID", correlationId: request.id })); + try { + const owner = options.registration.authorizeUserMutation({ csrfToken, sessionToken: token }); + const result = options.projects.trashFailedEmpty(owner.userId, (request.body as FailedEmptyTrashRequest).project_ids); + return { ignored_project_ids: result.ignoredProjectIds, trashed_project_ids: result.trashedProjectIds }; + } catch (error) { + return error instanceof RegistrationError + ? registrationFailure(reply, request.id, error) + : projectFailure(reply, request.id, error); + } + }, + ); + app.post( "/api/v1/support/check", { diff --git a/apps/api/src/main.ts b/apps/api/src/main.ts index 6320357..811c1a3 100644 --- a/apps/api/src/main.ts +++ b/apps/api/src/main.ts @@ -7,6 +7,7 @@ import { createApp } from "./app.js"; import { readBrowserSupportRelease } from "./browser-support.js"; import { defaultInstanceConfigPath, readConfiguredLocalDataRoot } from "./local-data-root.js"; import { ManagedStorage } from "./managed-storage.js"; +import { ProjectService } from "./projects.js"; import { RegistrationService } from "./registration.js"; import { MockResendAdapter } from "./resend-adapter.js"; import { readSecureConfigCandidate } from "./secure-config.js"; @@ -15,6 +16,7 @@ import { attachApiSupervisorControl, initializeApiCredentialClients, receiveApiC const credentialChannelEnabled = process.argv.includes("--dada-credential-stdin"); let registration: RegistrationService | undefined; +let projects: ProjectService | undefined; const instanceConfigPath = process.env.DADA_INSTANCE_CONFIG_PATH ?? defaultInstanceConfigPath(); if (credentialChannelEnabled) { const clients = initializeApiCredentialClients(await receiveApiCredentials()); @@ -23,17 +25,21 @@ if (credentialChannelEnabled) { .update(`Dada/P0A/${purpose}/v1`, "utf8") .digest(); const dataRoot = readConfiguredLocalDataRoot(instanceConfigPath); + const databasePath = join(dataRoot, "db", "dada.sqlite3"); registration = new RegistrationService({ adminAllowlistPepper: Buffer.from(clients.adminAllowlistPepper), challengePepper: derivePepper("challenge-pepper"), currentPrivacyNoticeVersion: registrationNotice.version, - databasePath: join(dataRoot, "db", "dada.sqlite3"), + databasePath, invitePepper: derivePepper("invite-pepper"), resend: new MockResendAdapter(), sessionPepper: derivePepper("session-pepper"), }); + projects = new ProjectService({ databasePath }); registration.applySecureConfig(readSecureConfigCandidate(instanceConfigPath)); } catch (error) { + projects?.close(); + projects = undefined; registration?.close(); registration = undefined; throw error; @@ -45,6 +51,7 @@ if (credentialChannelEnabled) { const browserSupportRelease = readBrowserSupportRelease(resolve("RELEASE.json")); const app = await createApp({ ...(browserSupportRelease ? { browserSupportRelease } : {}), + ...(projects ? { projects } : {}), ...(registration ? { registration } : {}), }); @@ -60,6 +67,7 @@ if (controlPipeIndex >= 0) { let storage: ManagedStorage | undefined; const control = attachApiSupervisorControl(controlPipe, async () => { await app.close(); + projects?.close(); registration?.close(); storage?.close(); }); diff --git a/apps/api/src/project-errors.ts b/apps/api/src/project-errors.ts new file mode 100644 index 0000000..b9ee655 --- /dev/null +++ b/apps/api/src/project-errors.ts @@ -0,0 +1,17 @@ +export type ProjectErrorCode = + | "generation_state_invalid" + | "project_active_limit" + | "project_history_limit" + | "project_name_invalid" + | "project_not_found" + | "project_ratio_fixed" + | "project_retry_not_allowed"; + +export class ProjectError extends Error { + readonly code: ProjectErrorCode; + + constructor(code: ProjectErrorCode) { + super(code); + this.code = code; + } +} diff --git a/apps/api/src/projects.ts b/apps/api/src/projects.ts new file mode 100644 index 0000000..173ac07 --- /dev/null +++ b/apps/api/src/projects.ts @@ -0,0 +1,463 @@ +import { randomUUID } from "node:crypto"; +import { createRequire } from "node:module"; + +import type BetterSqlite3 from "better-sqlite3"; + +import { ProjectError } from "./project-errors.js"; +export { ProjectError } from "./project-errors.js"; + +const require = createRequire(import.meta.url); +const Database = require("better-sqlite3") as typeof BetterSqlite3; + +export const projectRatios = ["3:4", "1:1", "4:3", "9:16"] as const; +export type ProjectRatio = typeof projectRatios[number]; +export type GenerationStatus = "queued" | "running" | "succeeded" | "failed" | "rejected"; +export type ProjectViewStatus = "active" | "failed_empty" | "trashed"; +const ratioPixels: Record = { + "3:4": { height: 1440, width: 1080 }, + "1:1": { height: 1080, width: 1080 }, + "4:3": { height: 1080, width: 1440 }, + "9:16": { height: 1920, width: 1080 }, +}; +const projectLimit = 20; +const historyLimit = 10; +const trashRetentionMilliseconds = 720 * 60 * 60 * 1_000; +const generationErrorCategories = new Set([ + "upstream_timeout", "upstream_failed", "safety_rejected", "model_disabled", + "gateway_balance_insufficient", "gateway_contract_invalid", "reference_invalid", + "unknown_retryable", "unknown_non_retryable", +]); + +interface ProjectRow { + created_at: number; + current_image_id: string | null; + deleted_at: number | null; + draft_prompt: string; + name: string; + owner_id: string; + pixel_height: number; + pixel_width: number; + project_id: string; + purge_at: number | null; + ratio: ProjectRatio; + state_version: number; + status: "active" | "trashed" | "purged"; + updated_at: number; +} + +interface GenerationRow { + created_at: number; + error_category: string | null; + generation_id: string; + prompt: string; + project_id: string; + ratio: ProjectRatio; + status: GenerationStatus; + updated_at: number; +} + +interface ImageRow { + created_at: number; + generation_id: string; + image_id: string; +} + +function isProjectRatio(value: string): value is ProjectRatio { + return projectRatios.includes(value as ProjectRatio); +} + +function normalizePrompt(value: string) { + const normalized = value.trim().replace(/\s+/gu, " "); + if (!normalized || normalized.length > 4_000) throw new ProjectError("generation_state_invalid"); + return normalized; +} + +function takeGraphemes(value: string, count: number) { + const Segmenter = Intl.Segmenter; + if (Segmenter) { + return [...new Segmenter("zh-CN", { granularity: "grapheme" }).segment(value)] + .slice(0, count) + .map((entry) => entry.segment) + .join(""); + } + return Array.from(value).slice(0, count).join(""); +} + +function localDate(timestamp: number) { + const date = new Date(timestamp); + return [date.getFullYear(), date.getMonth() + 1, date.getDate()] + .map((part, index) => index === 0 ? String(part) : String(part).padStart(2, "0")) + .join("-"); +} + +function defaultProjectName(prompt: string, timestamp: number) { + const summary = takeGraphemes(normalizePrompt(prompt), 24) || "未命名创作"; + return `${summary} ${localDate(timestamp)}`; +} + +function normalizeProjectName(value: string) { + const normalized = value.trim().replace(/\s+/gu, " "); + if (!normalized || [...normalized].length > 80) throw new ProjectError("project_name_invalid"); + return normalized; +} + +function iso(timestamp: number) { + return new Date(timestamp).toISOString(); +} + +export class ProjectService { + readonly database: BetterSqlite3.Database; + private readonly clock: () => number; + + constructor(input: { clock?: () => number; databasePath: string }) { + this.clock = input.clock ?? Date.now; + const nativeBinding = process.env.DADA_SQLITE_NATIVE_BINDING; + this.database = new Database(input.databasePath, nativeBinding ? { nativeBinding } : undefined); + this.database.pragma("journal_mode = WAL"); + this.database.pragma("foreign_keys = ON"); + this.database.pragma("synchronous = FULL"); + this.database.pragma("busy_timeout = 5000"); + this.migrate(); + } + + close() { + this.database.close(); + } + + createProjectForGeneration(input: { + ownerId: string; + prompt: string; + ratio: ProjectRatio; + status: Exclude; + }) { + if (!isProjectRatio(input.ratio)) throw new ProjectError("generation_state_invalid"); + const prompt = normalizePrompt(input.prompt); + const projectId = randomUUID(); + const generationId = randomUUID(); + const now = this.clock(); + const pixels = ratioPixels[input.ratio]; + const transaction = this.database.transaction(() => { + const active = this.database.prepare("SELECT COUNT(*) AS count FROM projects WHERE owner_id = ? AND status = 'active'") + .get(input.ownerId) as { count: number }; + if (active.count >= projectLimit) throw new ProjectError("project_active_limit"); + this.database.prepare(` + INSERT INTO projects ( + project_id, owner_id, name, draft_prompt, ratio, pixel_width, pixel_height, + status, state_version, current_image_id, created_at, updated_at, deleted_at, purge_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, 'active', 1, NULL, ?, ?, NULL, NULL) + `).run( + projectId, input.ownerId, defaultProjectName(prompt, now), prompt, input.ratio, + pixels.width, pixels.height, now, now, + ); + this.insertGeneration({ generationId, ownerId: input.ownerId, projectId, prompt, ratio: input.ratio, status: input.status }, now); + }); + transaction.immediate(); + return { generation: this.readGeneration(generationId), project: this.getProject(input.ownerId, projectId) }; + } + + continueProjectGeneration(input: { + ownerId: string; + projectId: string; + prompt: string; + ratio: ProjectRatio; + status: Exclude; + }) { + if (!isProjectRatio(input.ratio)) throw new ProjectError("generation_state_invalid"); + const generationId = randomUUID(); + const prompt = normalizePrompt(input.prompt); + const now = this.clock(); + const transaction = this.database.transaction(() => { + const project = this.readOwnedProject(input.ownerId, input.projectId); + if (project.status !== "active") throw new ProjectError("project_not_found"); + if (project.ratio !== input.ratio) throw new ProjectError("project_ratio_fixed"); + if (this.successfulImageCount(input.projectId) >= historyLimit) throw new ProjectError("project_history_limit"); + this.insertGeneration({ generationId, ownerId: input.ownerId, projectId: input.projectId, prompt, ratio: project.ratio, status: input.status }, now); + this.database.prepare("UPDATE projects SET draft_prompt = ?, updated_at = ? WHERE project_id = ?") + .run(prompt, now, input.projectId); + }); + transaction.immediate(); + return this.readGeneration(generationId); + } + + retryFailedDraft(input: { ownerId: string; projectId: string; prompt: string }) { + const project = this.getProject(input.ownerId, input.projectId); + const latest = project.generations.at(-1); + if (project.status !== "failed_empty" || !latest || !["failed", "rejected"].includes(latest.status)) { + throw new ProjectError("project_retry_not_allowed"); + } + return this.continueProjectGeneration({ + ownerId: input.ownerId, + projectId: input.projectId, + prompt: input.prompt, + ratio: project.ratio, + status: "queued", + }); + } + + recordSuccessfulImage(input: { generationId: string; imageId: string }) { + const now = this.clock(); + const transaction = this.database.transaction(() => { + const generation = this.database.prepare("SELECT * FROM generation_jobs WHERE generation_id = ?") + .get(input.generationId) as GenerationRow | undefined; + if (!generation || !["queued", "running"].includes(generation.status)) throw new ProjectError("generation_state_invalid"); + if (this.successfulImageCount(generation.project_id) >= historyLimit) throw new ProjectError("project_history_limit"); + this.database.prepare(` + UPDATE generation_jobs SET status = 'succeeded', error_category = NULL, updated_at = ? WHERE generation_id = ? + `).run(now, input.generationId); + this.database.prepare(` + INSERT INTO project_images (image_id, project_id, generation_id, created_at) VALUES (?, ?, ?, ?) + `).run(input.imageId, generation.project_id, input.generationId, now); + this.database.prepare(` + UPDATE projects + SET current_image_id = COALESCE(current_image_id, ?), updated_at = ?, state_version = state_version + 1 + WHERE project_id = ? + `).run(input.imageId, now, generation.project_id); + }); + transaction.immediate(); + } + + markGenerationFailed(generationId: string, errorCategory: string) { + if (!generationErrorCategories.has(errorCategory)) throw new ProjectError("generation_state_invalid"); + const status = errorCategory === "safety_rejected" ? "rejected" : "failed"; + const changed = this.database.prepare(` + UPDATE generation_jobs SET status = ?, error_category = ?, updated_at = ? + WHERE generation_id = ? AND status IN ('queued', 'running') + `).run(status, errorCategory, this.clock(), generationId); + if (changed.changes !== 1) throw new ProjectError("generation_state_invalid"); + } + + renameProject(ownerId: string, projectId: string, name: string) { + const normalized = normalizeProjectName(name); + const now = this.clock(); + const changed = this.database.prepare(` + UPDATE projects SET name = ?, updated_at = ?, state_version = state_version + 1 + WHERE owner_id = ? AND project_id = ? AND status = 'active' + `).run(normalized, now, ownerId, projectId); + if (changed.changes !== 1) throw new ProjectError("project_not_found"); + return { name: normalized, stateVersion: this.readOwnedProject(ownerId, projectId).state_version }; + } + + trashFailedEmpty(ownerId: string, projectIds: string[]) { + const uniqueIds = [...new Set(projectIds)]; + if (uniqueIds.length === 0 || uniqueIds.length > projectLimit) throw new ProjectError("generation_state_invalid"); + const trashedProjectIds: string[] = []; + const ignoredProjectIds: string[] = []; + const now = this.clock(); + const transaction = this.database.transaction(() => { + for (const projectId of uniqueIds) { + const row = this.database.prepare(` + SELECT p.project_id, + (SELECT COUNT(*) FROM project_images i WHERE i.project_id = p.project_id) AS image_count, + (SELECT status FROM generation_jobs g WHERE g.project_id = p.project_id ORDER BY g.created_at DESC, g.rowid DESC LIMIT 1) AS latest_status + FROM projects p WHERE p.owner_id = ? AND p.project_id = ? AND p.status = 'active' + `).get(ownerId, projectId) as { image_count: number; latest_status: string | null; project_id: string } | undefined; + if (!row || row.image_count !== 0 || !row.latest_status || !["failed", "rejected"].includes(row.latest_status)) { + ignoredProjectIds.push(projectId); + continue; + } + this.database.prepare(` + UPDATE projects SET status = 'trashed', deleted_at = ?, purge_at = ?, updated_at = ?, state_version = state_version + 1 + WHERE project_id = ? + `).run(now, now + trashRetentionMilliseconds, now, projectId); + trashedProjectIds.push(projectId); + } + }); + transaction.immediate(); + return { ignoredProjectIds, trashedProjectIds }; + } + + listProjects(ownerId: string, status: "active" | "trashed") { + const rows = this.database.prepare(` + SELECT p.*, + (SELECT COUNT(*) FROM project_images i WHERE i.project_id = p.project_id) AS image_count, + (SELECT status FROM generation_jobs g WHERE g.project_id = p.project_id ORDER BY g.created_at DESC, g.rowid DESC LIMIT 1) AS latest_status + FROM projects p + WHERE p.owner_id = ? AND p.status = ? + ORDER BY p.updated_at DESC, p.project_id DESC + `).all(ownerId, status) as Array; + return rows.map((row) => this.projectSummary(row, row.image_count, row.latest_status)); + } + + getProject(ownerId: string, projectId: string) { + const row = this.readOwnedProject(ownerId, projectId); + if (row.status === "purged") throw new ProjectError("project_not_found"); + const generations = this.database.prepare(` + SELECT * FROM generation_jobs WHERE project_id = ? ORDER BY created_at, rowid + `).all(projectId) as GenerationRow[]; + const images = this.database.prepare(` + SELECT image_id, generation_id, created_at FROM project_images WHERE project_id = ? ORDER BY created_at, rowid + `).all(projectId) as ImageRow[]; + const summary = this.projectSummary(row, images.length, generations.at(-1)?.status ?? null); + return { + ...summary, + createdAt: iso(row.created_at), + draftPrompt: row.draft_prompt, + generations: generations.map((generation) => this.generationView(generation)), + images: images.map((image) => ({ createdAt: iso(image.created_at), generationId: image.generation_id, imageId: image.image_id })), + pixelHeight: row.pixel_height, + pixelWidth: row.pixel_width, + }; + } + + activeProjectCount(ownerId: string) { + const row = this.database.prepare("SELECT COUNT(*) AS count FROM projects WHERE owner_id = ? AND status = 'active'") + .get(ownerId) as { count: number }; + return row.count; + } + + private insertGeneration(input: { + generationId: string; + ownerId: string; + projectId: string; + prompt: string; + ratio: ProjectRatio; + status: Exclude; + }, now: number) { + const errorCategory = input.status === "failed" ? "upstream_failed" : input.status === "rejected" ? "safety_rejected" : null; + this.database.prepare(` + INSERT INTO generation_jobs ( + generation_id, owner_id, project_id, prompt, ratio, status, error_category, created_at, updated_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) + `).run(input.generationId, input.ownerId, input.projectId, input.prompt, input.ratio, input.status, errorCategory, now, now); + } + + private readGeneration(generationId: string) { + const row = this.database.prepare("SELECT * FROM generation_jobs WHERE generation_id = ?").get(generationId) as GenerationRow | undefined; + if (!row) throw new ProjectError("generation_state_invalid"); + return this.generationView(row); + } + + private generationView(row: GenerationRow) { + return { + createdAt: iso(row.created_at), + errorCategory: row.error_category, + generationId: row.generation_id, + projectId: row.project_id, + prompt: row.prompt, + ratio: row.ratio, + status: row.status, + updatedAt: iso(row.updated_at), + }; + } + + private readOwnedProject(ownerId: string, projectId: string) { + const row = this.database.prepare("SELECT * FROM projects WHERE owner_id = ? AND project_id = ?") + .get(ownerId, projectId) as ProjectRow | undefined; + if (!row) throw new ProjectError("project_not_found"); + return row; + } + + private successfulImageCount(projectId: string) { + const row = this.database.prepare("SELECT COUNT(*) AS count FROM project_images WHERE project_id = ?") + .get(projectId) as { count: number }; + return row.count; + } + + private projectSummary(row: ProjectRow, imageCount: number, latestStatus: string | null) { + const status: ProjectViewStatus = row.status === "trashed" + ? "trashed" + : imageCount === 0 && latestStatus && ["failed", "rejected"].includes(latestStatus) + ? "failed_empty" + : "active"; + return { + currentImageId: row.current_image_id, + deletedAt: row.deleted_at === null ? null : iso(row.deleted_at), + name: row.name, + projectId: row.project_id, + purgeAt: row.purge_at === null ? null : iso(row.purge_at), + ratio: row.ratio, + stateVersion: row.state_version, + status, + successfulImageCount: imageCount, + updatedAt: iso(row.updated_at), + }; + } + + private migrate() { + this.database.exec(` + CREATE TABLE IF NOT EXISTS projects ( + project_id TEXT PRIMARY KEY, + owner_id TEXT NOT NULL, + name TEXT NOT NULL CHECK (length(name) BETWEEN 1 AND 160), + draft_prompt TEXT NOT NULL CHECK (length(draft_prompt) BETWEEN 1 AND 4000), + ratio TEXT NOT NULL CHECK (ratio IN ('3:4', '1:1', '4:3', '9:16')), + pixel_width INTEGER NOT NULL, + pixel_height INTEGER NOT NULL, + status TEXT NOT NULL CHECK (status IN ('active', 'trashed', 'purged')), + state_version INTEGER NOT NULL CHECK (state_version >= 1), + current_image_id TEXT, + created_at INTEGER NOT NULL, + updated_at INTEGER NOT NULL, + deleted_at INTEGER, + purge_at INTEGER, + UNIQUE(project_id, ratio), + CHECK ( + (ratio = '3:4' AND pixel_width = 1080 AND pixel_height = 1440) OR + (ratio = '1:1' AND pixel_width = 1080 AND pixel_height = 1080) OR + (ratio = '4:3' AND pixel_width = 1440 AND pixel_height = 1080) OR + (ratio = '9:16' AND pixel_width = 1080 AND pixel_height = 1920) + ), + CHECK ( + (status = 'active' AND deleted_at IS NULL AND purge_at IS NULL) OR + (status = 'trashed' AND deleted_at IS NOT NULL AND purge_at = deleted_at + ${trashRetentionMilliseconds}) OR + (status = 'purged' AND deleted_at IS NOT NULL AND purge_at IS NOT NULL) + ) + ); + CREATE INDEX IF NOT EXISTS projects_owner_status_updated ON projects(owner_id, status, updated_at DESC); + DROP TRIGGER IF EXISTS projects_active_insert_limit; + CREATE TRIGGER projects_active_insert_limit + BEFORE INSERT ON projects + WHEN NEW.status = 'active' AND ( + SELECT COUNT(*) FROM projects WHERE owner_id = NEW.owner_id AND status = 'active' + ) >= ${projectLimit} + BEGIN SELECT RAISE(ABORT, 'project_active_limit'); END; + DROP TRIGGER IF EXISTS projects_active_restore_limit; + CREATE TRIGGER projects_active_restore_limit + BEFORE UPDATE OF status ON projects + WHEN OLD.status <> 'active' AND NEW.status = 'active' AND ( + SELECT COUNT(*) FROM projects WHERE owner_id = NEW.owner_id AND status = 'active' + ) >= ${projectLimit} + BEGIN SELECT RAISE(ABORT, 'project_active_limit'); END; + CREATE TABLE IF NOT EXISTS generation_jobs ( + generation_id TEXT PRIMARY KEY, + owner_id TEXT NOT NULL, + project_id TEXT NOT NULL, + prompt TEXT NOT NULL CHECK (length(prompt) BETWEEN 1 AND 4000), + ratio TEXT NOT NULL CHECK (ratio IN ('3:4', '1:1', '4:3', '9:16')), + status TEXT NOT NULL CHECK (status IN ('queued', 'running', 'succeeded', 'failed', 'rejected')), + error_category TEXT CHECK (error_category IS NULL OR error_category IN ( + 'upstream_timeout', 'upstream_failed', 'safety_rejected', 'model_disabled', + 'gateway_balance_insufficient', 'gateway_contract_invalid', 'reference_invalid', + 'unknown_retryable', 'unknown_non_retryable' + )), + created_at INTEGER NOT NULL, + updated_at INTEGER NOT NULL, + UNIQUE(generation_id, project_id), + FOREIGN KEY (project_id, ratio) REFERENCES projects(project_id, ratio) ON DELETE CASCADE, + CHECK ( + (status IN ('queued', 'running', 'succeeded') AND error_category IS NULL) OR + (status IN ('failed', 'rejected') AND error_category IS NOT NULL) + ) + ); + CREATE INDEX IF NOT EXISTS generation_jobs_project_created ON generation_jobs(project_id, created_at, generation_id); + CREATE TABLE IF NOT EXISTS project_images ( + image_id TEXT PRIMARY KEY, + project_id TEXT NOT NULL, + generation_id TEXT NOT NULL UNIQUE, + created_at INTEGER NOT NULL, + FOREIGN KEY (generation_id, project_id) REFERENCES generation_jobs(generation_id, project_id) ON DELETE CASCADE + ); + CREATE INDEX IF NOT EXISTS project_images_project_created ON project_images(project_id, created_at, image_id); + DROP TRIGGER IF EXISTS projects_ratio_immutable; + CREATE TRIGGER projects_ratio_immutable + BEFORE UPDATE OF ratio, pixel_width, pixel_height ON projects + WHEN NEW.ratio <> OLD.ratio OR NEW.pixel_width <> OLD.pixel_width OR NEW.pixel_height <> OLD.pixel_height + BEGIN SELECT RAISE(ABORT, 'project_ratio_fixed'); END; + DROP TRIGGER IF EXISTS project_images_history_limit; + CREATE TRIGGER project_images_history_limit + BEFORE INSERT ON project_images + WHEN (SELECT COUNT(*) FROM project_images WHERE project_id = NEW.project_id) >= ${historyLimit} + BEGIN SELECT RAISE(ABORT, 'project_history_limit'); END; + `); + } +} diff --git a/apps/api/src/registration.ts b/apps/api/src/registration.ts index 2b33d23..6b0ae6e 100644 --- a/apps/api/src/registration.ts +++ b/apps/api/src/registration.ts @@ -985,6 +985,11 @@ export class RegistrationService { }); } + authorizeUserMutation(input: { csrfToken: string; sessionToken: string }) { + const session = this.authenticatedUserMutationSession(input.sessionToken, input.csrfToken, this.options.clock()); + return { userId: session.user_id }; + } + logoutUser(input: { csrfToken: string; sessionToken: string }) { const now = this.options.clock(); this.runImmediate("session_revoke", () => { @@ -1196,6 +1201,10 @@ export class RegistrationService { this.database.prepare("DELETE FROM privacy_consents WHERE user_id = ?").run(session.user_id); this.database.prepare("DELETE FROM credit_accounts WHERE user_id = ?").run(session.user_id); this.database.prepare("DELETE FROM user_profiles WHERE user_id = ?").run(session.user_id); + const projectsTable = this.database.prepare(` + SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = 'projects' + `).get(); + if (projectsTable) this.database.prepare("DELETE FROM projects WHERE owner_id = ?").run(session.user_id); this.database.prepare("DELETE FROM account_deletion_challenges WHERE user_id = ?").run(session.user_id); this.database.prepare("DELETE FROM email_challenges WHERE email = ?").run(session.normalized_email); this.database.prepare("DELETE FROM auth_rate_limits WHERE rate_key = ?") diff --git a/apps/web/src/generated/api/sdk.gen.ts b/apps/web/src/generated/api/sdk.gen.ts index f4a9135..d878bf6 100644 --- a/apps/web/src/generated/api/sdk.gen.ts +++ b/apps/web/src/generated/api/sdk.gen.ts @@ -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 = {}): string return `${options.baseUrl ?? ""}/api/v1/events`; } +export async function getProject(options: ClientOptions = {}): Promise { + 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; +} + export async function getUserSession(options: ClientOptions = {}): Promise { 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; } +export async function listProjects(options: ClientOptions = {}): Promise { + 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; +} + export async function logoutUser(options: ClientOptions = {}): Promise { 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; } +export async function renameProject(body: ProjectRenameRequest, options: ClientOptions = {}): Promise { + 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; +} + export async function sendAccountDeletionCode(options: ClientOptions = {}): Promise { 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; } +export async function trashFailedEmptyProjects(body: FailedEmptyTrashRequest, options: ClientOptions = {}): Promise { + 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; +} + export async function updateAccountProfile(body: AccountProfileUpdateRequest, options: ClientOptions = {}): Promise { const request = options.fetch ?? globalThis.fetch; const headers = new Headers(options.headers); diff --git a/apps/web/src/generated/api/types.gen.ts b/apps/web/src/generated/api/types.gen.ts index 3904aba..d7f13a5 100644 --- a/apps/web/src/generated/api/types.gen.ts +++ b/apps/web/src/generated/api/types.gen.ts @@ -187,8 +187,27 @@ export type ErrorEnvelope = { }; }; +export type FailedEmptyTrashRequest = { + "project_ids": Array; +}; + +export type FailedEmptyTrashResponse = { + "ignored_project_ids": Array; + "trashed_project_ids": Array; +}; + 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; + "images": Array; + "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; +}; + +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; }; diff --git a/apps/web/src/main.tsx b/apps/web/src/main.tsx index 4c48dab..4cedb35 100644 --- a/apps/web/src/main.tsx +++ b/apps/web/src/main.tsx @@ -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" - ? - : window.location.pathname.startsWith("/admin") - ? - : ; + const projectDetail = window.location.pathname.match(/^\/app\/projects\/([0-9a-f-]{36})$/i); + let authenticationPage; + if (window.location.pathname === "/app/settings") authenticationPage = ; + else if (projectDetail?.[1]) authenticationPage = ; + else if (window.location.pathname === "/app/projects") authenticationPage = ; + else if (window.location.pathname === "/app") authenticationPage = ; + else if (window.location.pathname.startsWith("/admin")) authenticationPage = ; + else authenticationPage = ; appRoot.render( {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(); diff --git a/apps/web/src/project-pages.css b/apps/web/src/project-pages.css new file mode 100644 index 0000000..cc80687 --- /dev/null +++ b/apps/web/src/project-pages.css @@ -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; + } +} diff --git a/apps/web/src/project-pages.tsx b/apps/web/src/project-pages.tsx new file mode 100644 index 0000000..6895cab --- /dev/null +++ b/apps/web/src/project-pages.tsx @@ -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(url: string, init?: RequestInit): Promise { + 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; +} + +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 ( +
+ DADA + +
+ ); +} + +function LocalOnlyFooter() { + return
测试数据仅保存在本机,不自动备份,也不会迁移到正式系统。
; +} + +function LoadingPage({ label }: { label: string }) { + return
{label}
; +} + +function ProjectPlaceholder({ ratio, status }: { ratio: Ratio; status: ProjectStatus }) { + return ( + + ); +} + +export function WorkspacePage() { + const promptId = useId(); + const [session, setSession] = useState(); + const [projects, setProjects] = useState(); + const [loadingFailed, setLoadingFailed] = useState(false); + const [prompt, setPrompt] = useState(""); + const [ratio, setRatio] = useState("3:4"); + const [references, setReferences] = useState([]); + + useEffect(() => { + let active = true; + Promise.all([ + readJson("/api/v1/auth/session"), + readJson("/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 ( +
+

创作工作台暂时无法读取。

+ +
+ ); + } + if (!session || !projects) return ; + const empty = projects.projects.length === 0; + + return ( +
+ + {empty ? ( +
+
+ DADA +

开始一张新作品

+
+ +
+ ) : null} +
+
+ {!empty ? ( +
+

NEW PROJECT

新建创作

+ 当前可用 {session.credits.available_balance} 点 +
+ ) : null} + +