feat: complete TASK-WP2-01 project foundations
This commit is contained in:
@@ -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<ProjectService["listProjects"]>[number];
|
||||
type ProjectDetailView = ReturnType<ProjectService["getProject"]>;
|
||||
|
||||
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",
|
||||
{
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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<ProjectRatio, { height: number; width: number }> = {
|
||||
"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<GenerationStatus, "succeeded">;
|
||||
}) {
|
||||
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<GenerationStatus, "succeeded">;
|
||||
}) {
|
||||
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<ProjectRow & { image_count: number; latest_status: string | null }>;
|
||||
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<GenerationStatus, "succeeded">;
|
||||
}, 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;
|
||||
`);
|
||||
}
|
||||
}
|
||||
@@ -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 = ?")
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
@@ -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();
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user