feat: complete TASK-WP2-02 project autosave
This commit is contained in:
@@ -15,6 +15,9 @@ import {
|
||||
AdminLoginSendRequestSchema,
|
||||
AdminSessionResponseSchema,
|
||||
BootstrapResponseSchema,
|
||||
CanvasBackgroundAdjustmentsSchema,
|
||||
CanvasElementSchema,
|
||||
CanvasStateSchema,
|
||||
CorrelationIdSchema,
|
||||
AuthenticatedUserSchema,
|
||||
CreditSummarySchema,
|
||||
@@ -33,6 +36,7 @@ import {
|
||||
FailedEmptyTrashResponseSchema,
|
||||
GenerationProjectItemSchema,
|
||||
ProjectDetailResponseSchema,
|
||||
ProjectEditableStateSchema,
|
||||
ProjectIdSchema,
|
||||
ProjectImageItemSchema,
|
||||
ProjectListQuerySchema,
|
||||
@@ -41,6 +45,9 @@ import {
|
||||
ProjectRatioSchema,
|
||||
ProjectRenameRequestSchema,
|
||||
ProjectRenameResponseSchema,
|
||||
ProjectStateConflictResponseSchema,
|
||||
ProjectStateSaveHeadersSchema,
|
||||
ProjectStateSaveResponseSchema,
|
||||
ProjectSummarySchema,
|
||||
ProjectViewStatusSchema,
|
||||
RegistrationCompleteHeadersSchema,
|
||||
@@ -66,6 +73,8 @@ import {
|
||||
type ProjectListQuery,
|
||||
type ProjectParams,
|
||||
type ProjectRenameRequest,
|
||||
type ProjectEditableState,
|
||||
type ProjectStateSaveHeaders,
|
||||
type RegistrationCompleteRequest,
|
||||
type RegistrationSendRequest,
|
||||
} from "@dada/shared-contracts";
|
||||
@@ -196,6 +205,9 @@ function projectFailure(reply: FastifyReply, correlationId: string, error: unkno
|
||||
project_not_found: 404,
|
||||
project_ratio_fixed: 409,
|
||||
project_retry_not_allowed: 409,
|
||||
project_state_conflict: 412,
|
||||
project_state_idempotency_conflict: 409,
|
||||
project_state_invalid: 400,
|
||||
} as const;
|
||||
return reply.code(mapping[error.code]).send(null);
|
||||
}
|
||||
@@ -221,6 +233,7 @@ function projectSummaryResponse(project: ProjectSummaryView) {
|
||||
function projectDetailResponse(project: ProjectDetailView) {
|
||||
return {
|
||||
...projectSummaryResponse(project),
|
||||
canvas_state: project.canvasState,
|
||||
created_at: project.createdAt,
|
||||
draft_prompt: project.draftPrompt,
|
||||
generations: project.generations.map((generation) => ({
|
||||
@@ -239,6 +252,7 @@ function projectDetailResponse(project: ProjectDetailView) {
|
||||
})),
|
||||
pixel_height: project.pixelHeight,
|
||||
pixel_width: project.pixelWidth,
|
||||
save_status: project.saveStatus,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -336,6 +350,9 @@ export async function createApp(options: CreateAppOptions = {}) {
|
||||
BrowserSupportRequestSchema,
|
||||
BrowserSupportSuccessSchema,
|
||||
BootstrapResponseSchema,
|
||||
CanvasBackgroundAdjustmentsSchema,
|
||||
CanvasElementSchema,
|
||||
CanvasStateSchema,
|
||||
StateSseEventSchema,
|
||||
ModelConfigSseEventSchema,
|
||||
ModelRuntimeSseEventSchema,
|
||||
@@ -350,8 +367,12 @@ export async function createApp(options: CreateAppOptions = {}) {
|
||||
GenerationProjectItemSchema,
|
||||
ProjectImageItemSchema,
|
||||
ProjectDetailResponseSchema,
|
||||
ProjectEditableStateSchema,
|
||||
ProjectRenameRequestSchema,
|
||||
ProjectRenameResponseSchema,
|
||||
ProjectStateSaveHeadersSchema,
|
||||
ProjectStateSaveResponseSchema,
|
||||
ProjectStateConflictResponseSchema,
|
||||
FailedEmptyTrashRequestSchema,
|
||||
FailedEmptyTrashResponseSchema,
|
||||
]) {
|
||||
@@ -1084,6 +1105,62 @@ export async function createApp(options: CreateAppOptions = {}) {
|
||||
},
|
||||
);
|
||||
|
||||
app.put(
|
||||
"/api/v1/projects/:projectId/state",
|
||||
{
|
||||
attachValidation: true,
|
||||
schema: {
|
||||
body: Type.Ref(ProjectEditableStateSchema),
|
||||
headers: Type.Ref(ProjectStateSaveHeadersSchema),
|
||||
operationId: "saveProjectState",
|
||||
params: Type.Ref(ProjectParamsSchema),
|
||||
response: {
|
||||
200: Type.Ref(ProjectStateSaveResponseSchema),
|
||||
400: Type.Null(),
|
||||
401: Type.Ref(ErrorEnvelopeSchema),
|
||||
403: Type.Ref(ErrorEnvelopeSchema),
|
||||
404: Type.Null(),
|
||||
409: Type.Ref(ErrorEnvelopeSchema),
|
||||
412: Type.Ref(ProjectStateConflictResponseSchema),
|
||||
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 headers = request.headers as ProjectStateSaveHeaders;
|
||||
if (!token) return reply.code(401).send(createErrorEnvelope({ code: "AUTH_SESSION_INVALID", correlationId: request.id }));
|
||||
try {
|
||||
const owner = options.registration.authorizeUserMutation({ csrfToken: headers["x-csrf-token"], sessionToken: token });
|
||||
const saved = options.projects.saveProjectState({
|
||||
expectedStateVersion: Number(headers["if-match"]),
|
||||
idempotencyKey: headers["idempotency-key"],
|
||||
ownerId: owner.userId,
|
||||
projectId: (request.params as ProjectParams).projectId,
|
||||
state: request.body as ProjectEditableState,
|
||||
});
|
||||
return { save_status: "saved" as const, state_version: saved.stateVersion };
|
||||
} catch (error) {
|
||||
if (error instanceof RegistrationError) return registrationFailure(reply, request.id, error);
|
||||
if (error instanceof ProjectError && error.code === "project_state_conflict") {
|
||||
return reply.code(412).send({
|
||||
latest_state_version: error.latestStateVersion ?? 1,
|
||||
save_status: "conflicted" as const,
|
||||
});
|
||||
}
|
||||
if (error instanceof ProjectError && error.code === "project_state_idempotency_conflict") {
|
||||
return reply.code(409).send(createErrorEnvelope({ code: "IDEMPOTENCY_KEY_CONFLICT", correlationId: request.id }));
|
||||
}
|
||||
return projectFailure(reply, request.id, error);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
app.patch(
|
||||
"/api/v1/projects/:projectId",
|
||||
{
|
||||
|
||||
@@ -5,13 +5,18 @@ export type ProjectErrorCode =
|
||||
| "project_name_invalid"
|
||||
| "project_not_found"
|
||||
| "project_ratio_fixed"
|
||||
| "project_retry_not_allowed";
|
||||
| "project_retry_not_allowed"
|
||||
| "project_state_conflict"
|
||||
| "project_state_invalid"
|
||||
| "project_state_idempotency_conflict";
|
||||
|
||||
export class ProjectError extends Error {
|
||||
readonly code: ProjectErrorCode;
|
||||
readonly latestStateVersion: number | undefined;
|
||||
|
||||
constructor(code: ProjectErrorCode) {
|
||||
constructor(code: ProjectErrorCode, latestStateVersion?: number) {
|
||||
super(code);
|
||||
this.code = code;
|
||||
this.latestStateVersion = latestStateVersion;
|
||||
}
|
||||
}
|
||||
|
||||
+192
-6
@@ -1,7 +1,8 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { createHash, randomUUID } from "node:crypto";
|
||||
import { createRequire } from "node:module";
|
||||
|
||||
import type BetterSqlite3 from "better-sqlite3";
|
||||
import { isProjectEditableState, type CanvasState, type ProjectEditableState } from "@dada/shared-contracts";
|
||||
|
||||
import { ProjectError } from "./project-errors.js";
|
||||
export { ProjectError } from "./project-errors.js";
|
||||
@@ -62,6 +63,14 @@ interface ImageRow {
|
||||
image_id: string;
|
||||
}
|
||||
|
||||
interface ProjectStateRow {
|
||||
canvas_json: string;
|
||||
created_at: number;
|
||||
name: string;
|
||||
project_id: string;
|
||||
state_version: number;
|
||||
}
|
||||
|
||||
function isProjectRatio(value: string): value is ProjectRatio {
|
||||
return projectRatios.includes(value as ProjectRatio);
|
||||
}
|
||||
@@ -105,6 +114,38 @@ function iso(timestamp: number) {
|
||||
return new Date(timestamp).toISOString();
|
||||
}
|
||||
|
||||
function defaultCanvasState(ratio: ProjectRatio, pixels: { height: number; width: number }, assetId: string | null): CanvasState {
|
||||
return {
|
||||
background: {
|
||||
adjustments: {
|
||||
brightness: 0,
|
||||
contrast: 0,
|
||||
crop: null,
|
||||
filter: "none",
|
||||
fit: "fill",
|
||||
saturation: 0,
|
||||
sharpness: 0,
|
||||
temperature: 0,
|
||||
},
|
||||
asset_id: assetId,
|
||||
},
|
||||
elements: [],
|
||||
pixel_height: pixels.height,
|
||||
pixel_width: pixels.width,
|
||||
ratio,
|
||||
schema_version: 1,
|
||||
};
|
||||
}
|
||||
|
||||
function stableJson(value: unknown): string {
|
||||
if (Array.isArray(value)) return `[${value.map(stableJson).join(",")}]`;
|
||||
if (value && typeof value === "object") {
|
||||
return `{${Object.entries(value).sort(([left], [right]) => left < right ? -1 : left > right ? 1 : 0)
|
||||
.map(([key, entry]) => `${JSON.stringify(key)}:${stableJson(entry)}`).join(",")}}`;
|
||||
}
|
||||
return JSON.stringify(value);
|
||||
}
|
||||
|
||||
export class ProjectService {
|
||||
readonly database: BetterSqlite3.Database;
|
||||
private readonly clock: () => number;
|
||||
@@ -149,6 +190,12 @@ export class ProjectService {
|
||||
projectId, input.ownerId, defaultProjectName(prompt, now), prompt, input.ratio,
|
||||
pixels.width, pixels.height, now, now,
|
||||
);
|
||||
this.insertProjectState({
|
||||
canvasState: defaultCanvasState(input.ratio, pixels, null),
|
||||
name: defaultProjectName(prompt, now),
|
||||
projectId,
|
||||
stateVersion: 1,
|
||||
}, now);
|
||||
this.insertGeneration({ generationId, ownerId: input.ownerId, projectId, prompt, ratio: input.ratio, status: input.status }, now);
|
||||
});
|
||||
transaction.immediate();
|
||||
@@ -201,6 +248,10 @@ export class ProjectService {
|
||||
.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");
|
||||
const project = this.database.prepare("SELECT * FROM projects WHERE project_id = ?").get(generation.project_id) as ProjectRow;
|
||||
const currentState = this.readProjectState(generation.project_id);
|
||||
const nextCanvas = structuredClone(currentState.canvasState);
|
||||
if (project.current_image_id === null) nextCanvas.background.asset_id = input.imageId;
|
||||
this.database.prepare(`
|
||||
UPDATE generation_jobs SET status = 'succeeded', error_category = NULL, updated_at = ? WHERE generation_id = ?
|
||||
`).run(now, input.generationId);
|
||||
@@ -212,6 +263,12 @@ export class ProjectService {
|
||||
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);
|
||||
this.insertProjectState({
|
||||
canvasState: nextCanvas,
|
||||
name: project.name,
|
||||
projectId: generation.project_id,
|
||||
stateVersion: project.state_version + 1,
|
||||
}, now);
|
||||
});
|
||||
transaction.immediate();
|
||||
}
|
||||
@@ -229,14 +286,85 @@ export class ProjectService {
|
||||
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");
|
||||
const transaction = this.database.transaction(() => {
|
||||
const project = this.readOwnedProject(ownerId, projectId);
|
||||
if (project.status !== "active") throw new ProjectError("project_not_found");
|
||||
const currentState = this.readProjectState(projectId);
|
||||
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);
|
||||
this.insertProjectState({
|
||||
canvasState: currentState.canvasState,
|
||||
name: normalized,
|
||||
projectId,
|
||||
stateVersion: project.state_version + 1,
|
||||
}, now);
|
||||
});
|
||||
transaction.immediate();
|
||||
return { name: normalized, stateVersion: this.readOwnedProject(ownerId, projectId).state_version };
|
||||
}
|
||||
|
||||
saveProjectState(input: {
|
||||
expectedStateVersion: number;
|
||||
idempotencyKey: string;
|
||||
ownerId: string;
|
||||
projectId: string;
|
||||
state: ProjectEditableState;
|
||||
}) {
|
||||
if (!Number.isSafeInteger(input.expectedStateVersion) || input.expectedStateVersion < 1 || !isProjectEditableState(input.state)) {
|
||||
throw new ProjectError("project_state_invalid");
|
||||
}
|
||||
const name = normalizeProjectName(input.state.name);
|
||||
const state = { canvas_state: structuredClone(input.state.canvas_state), name } satisfies ProjectEditableState;
|
||||
const requestHash = createHash("sha256").update(stableJson({ expected: input.expectedStateVersion, state })).digest("hex");
|
||||
const now = this.clock();
|
||||
let result!: { stateVersion: number };
|
||||
const transaction = this.database.transaction(() => {
|
||||
const replay = this.database.prepare(`
|
||||
SELECT request_hash, response_state_version FROM project_state_idempotency
|
||||
WHERE owner_id = ? AND project_id = ? AND idempotency_key = ?
|
||||
`).get(input.ownerId, input.projectId, input.idempotencyKey) as { request_hash: string; response_state_version: number } | undefined;
|
||||
if (replay) {
|
||||
if (replay.request_hash !== requestHash) throw new ProjectError("project_state_idempotency_conflict");
|
||||
result = { stateVersion: replay.response_state_version };
|
||||
return;
|
||||
}
|
||||
const project = this.readOwnedProject(input.ownerId, input.projectId);
|
||||
if (project.status !== "active") throw new ProjectError("project_not_found");
|
||||
if (project.state_version !== input.expectedStateVersion) {
|
||||
throw new ProjectError("project_state_conflict", project.state_version);
|
||||
}
|
||||
const canvas = state.canvas_state;
|
||||
if (canvas.ratio !== project.ratio || canvas.pixel_width !== project.pixel_width || canvas.pixel_height !== project.pixel_height) {
|
||||
throw new ProjectError("project_state_invalid");
|
||||
}
|
||||
if (canvas.background.asset_id) {
|
||||
const owned = this.database.prepare("SELECT 1 FROM project_images WHERE project_id = ? AND image_id = ?")
|
||||
.get(input.projectId, canvas.background.asset_id);
|
||||
if (!owned) throw new ProjectError("project_state_invalid");
|
||||
}
|
||||
const nextVersion = project.state_version + 1;
|
||||
const changed = this.database.prepare(`
|
||||
UPDATE projects SET name = ?, state_version = ?, updated_at = ?
|
||||
WHERE project_id = ? AND owner_id = ? AND state_version = ? AND status = 'active'
|
||||
`).run(name, nextVersion, now, input.projectId, input.ownerId, input.expectedStateVersion);
|
||||
if (changed.changes !== 1) {
|
||||
const latest = this.readOwnedProject(input.ownerId, input.projectId).state_version;
|
||||
throw new ProjectError("project_state_conflict", latest);
|
||||
}
|
||||
this.insertProjectState({ canvasState: canvas, name, projectId: input.projectId, stateVersion: nextVersion }, now);
|
||||
this.database.prepare(`
|
||||
INSERT INTO project_state_idempotency (
|
||||
owner_id, project_id, idempotency_key, request_hash, response_state_version, created_at
|
||||
) VALUES (?, ?, ?, ?, ?, ?)
|
||||
`).run(input.ownerId, input.projectId, input.idempotencyKey, requestHash, nextVersion, now);
|
||||
result = { stateVersion: nextVersion };
|
||||
});
|
||||
transaction.immediate();
|
||||
return result;
|
||||
}
|
||||
|
||||
trashFailedEmpty(ownerId: string, projectIds: string[]) {
|
||||
const uniqueIds = [...new Set(projectIds)];
|
||||
if (uniqueIds.length === 0 || uniqueIds.length > projectLimit) throw new ProjectError("generation_state_invalid");
|
||||
@@ -288,14 +416,17 @@ export class ProjectService {
|
||||
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);
|
||||
const projectState = this.readProjectState(projectId);
|
||||
return {
|
||||
...summary,
|
||||
canvasState: projectState.canvasState,
|
||||
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,
|
||||
saveStatus: "saved" as const,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -347,6 +478,26 @@ export class ProjectService {
|
||||
return row;
|
||||
}
|
||||
|
||||
private insertProjectState(input: { canvasState: CanvasState; name: string; projectId: string; stateVersion: number }, now: number) {
|
||||
this.database.prepare(`
|
||||
INSERT INTO project_states (project_id, state_version, name, canvas_json, created_at)
|
||||
VALUES (?, ?, ?, ?, ?)
|
||||
`).run(input.projectId, input.stateVersion, input.name, stableJson(input.canvasState), now);
|
||||
}
|
||||
|
||||
private readProjectState(projectId: string) {
|
||||
const row = this.database.prepare(`
|
||||
SELECT project_id, state_version, name, canvas_json, created_at
|
||||
FROM project_states WHERE project_id = ? ORDER BY state_version DESC LIMIT 1
|
||||
`).get(projectId) as ProjectStateRow | undefined;
|
||||
if (!row) throw new ProjectError("project_state_invalid");
|
||||
const canvasState: unknown = JSON.parse(row.canvas_json);
|
||||
if (!isProjectEditableState({ canvas_state: canvasState, name: row.name })) throw new ProjectError("project_state_invalid");
|
||||
return { canvasState, name: row.name, stateVersion: row.state_version } as {
|
||||
canvasState: CanvasState; name: string; stateVersion: number;
|
||||
};
|
||||
}
|
||||
|
||||
private successfulImageCount(projectId: string) {
|
||||
const row = this.database.prepare("SELECT COUNT(*) AS count FROM project_images WHERE project_id = ?")
|
||||
.get(projectId) as { count: number };
|
||||
@@ -411,6 +562,26 @@ export class ProjectService {
|
||||
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 project_states (
|
||||
project_id TEXT NOT NULL,
|
||||
state_version INTEGER NOT NULL CHECK (state_version >= 1),
|
||||
name TEXT NOT NULL CHECK (length(name) BETWEEN 1 AND 80),
|
||||
canvas_json TEXT NOT NULL CHECK (json_valid(canvas_json)),
|
||||
created_at INTEGER NOT NULL,
|
||||
PRIMARY KEY (project_id, state_version),
|
||||
FOREIGN KEY (project_id) REFERENCES projects(project_id) ON DELETE CASCADE
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS project_states_latest ON project_states(project_id, state_version DESC);
|
||||
CREATE TABLE IF NOT EXISTS project_state_idempotency (
|
||||
owner_id TEXT NOT NULL,
|
||||
project_id TEXT NOT NULL,
|
||||
idempotency_key TEXT NOT NULL,
|
||||
request_hash TEXT NOT NULL CHECK (length(request_hash) = 64),
|
||||
response_state_version INTEGER NOT NULL CHECK (response_state_version >= 2),
|
||||
created_at INTEGER NOT NULL,
|
||||
PRIMARY KEY (owner_id, project_id, idempotency_key),
|
||||
FOREIGN KEY (project_id) REFERENCES projects(project_id) ON DELETE CASCADE
|
||||
);
|
||||
DROP TRIGGER IF EXISTS projects_active_restore_limit;
|
||||
CREATE TRIGGER projects_active_restore_limit
|
||||
BEFORE UPDATE OF status ON projects
|
||||
@@ -459,5 +630,20 @@ export class ProjectService {
|
||||
WHEN (SELECT COUNT(*) FROM project_images WHERE project_id = NEW.project_id) >= ${historyLimit}
|
||||
BEGIN SELECT RAISE(ABORT, 'project_history_limit'); END;
|
||||
`);
|
||||
const missingStates = this.database.prepare(`
|
||||
SELECT p.* FROM projects p
|
||||
WHERE NOT EXISTS (SELECT 1 FROM project_states s WHERE s.project_id = p.project_id)
|
||||
`).all() as ProjectRow[];
|
||||
const insertBackfill = this.database.transaction(() => {
|
||||
for (const project of missingStates) {
|
||||
this.insertProjectState({
|
||||
canvasState: defaultCanvasState(project.ratio, { height: project.pixel_height, width: project.pixel_width }, project.current_image_id),
|
||||
name: project.name,
|
||||
projectId: project.project_id,
|
||||
stateVersion: project.state_version,
|
||||
}, project.updated_at);
|
||||
}
|
||||
});
|
||||
insertBackfill.immediate();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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, ProjectDetailResponse, UserSessionResponse, ProjectListResponse, LogoutResponse, ProjectRenameResponse, ProjectRenameRequest, AccountDeletionSendResponse, RegistrationSendResponse, AdminLoginSendRequest, LoginSendRequest, RegistrationSendRequest, FailedEmptyTrashResponse, FailedEmptyTrashRequest, AccountProfileUpdateResponse, AccountProfileUpdateRequest } from "./types.gen.js";
|
||||
import type { AccountDeletionResponse, AccountDeletionCompleteRequest, AdminLoginCompleteResponse, AdminLoginCompleteRequest, LoginCompleteResponse, LoginCompleteRequest, RegistrationCompleteResponse, RegistrationCompleteRequest, AccountSettingsResponse, AdminSessionResponse, ProjectDetailResponse, UserSessionResponse, ProjectListResponse, LogoutResponse, ProjectRenameResponse, ProjectRenameRequest, ProjectStateSaveResponse, ProjectEditableState, AccountDeletionSendResponse, RegistrationSendResponse, AdminLoginSendRequest, LoginSendRequest, RegistrationSendRequest, FailedEmptyTrashResponse, FailedEmptyTrashRequest, AccountProfileUpdateResponse, AccountProfileUpdateRequest } from "./types.gen.js";
|
||||
|
||||
export interface ClientOptions { baseUrl?: string; fetch?: typeof globalThis.fetch; headers?: HeadersInit; }
|
||||
|
||||
@@ -175,6 +175,15 @@ export async function renameProject(body: ProjectRenameRequest, options: ClientO
|
||||
return response.json() as Promise<ProjectRenameResponse>;
|
||||
}
|
||||
|
||||
export async function saveProjectState(body: ProjectEditableState, options: ClientOptions = {}): Promise<ProjectStateSaveResponse> {
|
||||
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}/state`, { body: JSON.stringify(body), method: "PUT", headers });
|
||||
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
||||
return response.json() as Promise<ProjectStateSaveResponse>;
|
||||
}
|
||||
|
||||
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 ?? {} });
|
||||
|
||||
@@ -136,6 +136,65 @@ export type BrowserSupportSuccess = {
|
||||
|
||||
export type BrowserUnsupportedReason = "platform_unsupported" | "brand_unsupported" | "version_unsupported" | "identity_unavailable";
|
||||
|
||||
export type CanvasBackgroundAdjustments = {
|
||||
"brightness": number;
|
||||
"contrast": number;
|
||||
"crop": {
|
||||
"height": number;
|
||||
"width": number;
|
||||
"x": number;
|
||||
"y": number;
|
||||
} | null;
|
||||
"filter": string;
|
||||
"fit": "fill" | "fit" | "crop";
|
||||
"saturation": number;
|
||||
"sharpness": number;
|
||||
"temperature": number;
|
||||
};
|
||||
|
||||
export type CanvasElement = {
|
||||
"colors"?: Array<string>;
|
||||
"content"?: string;
|
||||
"coordinates"?: {
|
||||
"latitude": number;
|
||||
"longitude": number;
|
||||
};
|
||||
"created_at": string;
|
||||
"dynamic_fields"?: Record<string, never>;
|
||||
"element_id": string;
|
||||
"font_override"?: string;
|
||||
"font_size"?: number;
|
||||
"formatted_value"?: string;
|
||||
"opacity": number;
|
||||
"position": {
|
||||
"x": number;
|
||||
"y": number;
|
||||
};
|
||||
"resource_version": string;
|
||||
"rotation": number;
|
||||
"scale": {
|
||||
"x": number;
|
||||
"y": number;
|
||||
};
|
||||
"style_id"?: string;
|
||||
"style_parameters"?: Record<string, never>;
|
||||
"template_or_asset_id": string;
|
||||
"type": "text_template" | "static_sticker" | "color_card" | "dynamic_sticker";
|
||||
"z_index": number;
|
||||
};
|
||||
|
||||
export type CanvasState = {
|
||||
"background": {
|
||||
"adjustments": CanvasBackgroundAdjustments;
|
||||
"asset_id": string | null;
|
||||
};
|
||||
"elements": Array<CanvasElement>;
|
||||
"pixel_height": number;
|
||||
"pixel_width": number;
|
||||
"ratio": "3:4" | "1:1" | "4:3" | "9:16";
|
||||
"schema_version": 1;
|
||||
};
|
||||
|
||||
export type CorrelationId = string;
|
||||
|
||||
export type CreditSummary = {
|
||||
@@ -251,6 +310,7 @@ export type ModelRuntimeSseEvent = {
|
||||
};
|
||||
|
||||
export type ProjectDetailResponse = {
|
||||
"canvas_state": CanvasState;
|
||||
"created_at": string;
|
||||
"current_image_id": ProjectId | null;
|
||||
"deleted_at": string | null;
|
||||
@@ -263,12 +323,18 @@ export type ProjectDetailResponse = {
|
||||
"project_id": ProjectId;
|
||||
"purge_at": string | null;
|
||||
"ratio": ProjectRatio;
|
||||
"save_status": "saved";
|
||||
"state_version": number;
|
||||
"status": ProjectViewStatus;
|
||||
"successful_image_count": number;
|
||||
"updated_at": string;
|
||||
};
|
||||
|
||||
export type ProjectEditableState = {
|
||||
"canvas_state": CanvasState;
|
||||
"name": string;
|
||||
};
|
||||
|
||||
export type ProjectId = string;
|
||||
|
||||
export type ProjectImageItem = {
|
||||
@@ -303,6 +369,22 @@ export type ProjectRenameResponse = {
|
||||
"status": "renamed";
|
||||
};
|
||||
|
||||
export type ProjectStateConflictResponse = {
|
||||
"latest_state_version": number;
|
||||
"save_status": "conflicted";
|
||||
};
|
||||
|
||||
export type ProjectStateSaveHeaders = {
|
||||
"idempotency-key": string;
|
||||
"if-match": string;
|
||||
"x-csrf-token": string;
|
||||
};
|
||||
|
||||
export type ProjectStateSaveResponse = {
|
||||
"save_status": "saved";
|
||||
"state_version": number;
|
||||
};
|
||||
|
||||
export type ProjectSummary = {
|
||||
"current_image_id": ProjectId | null;
|
||||
"deleted_at": string | null;
|
||||
|
||||
@@ -0,0 +1,187 @@
|
||||
import type { ProjectEditableState } from "@dada/shared-contracts";
|
||||
|
||||
export type ProjectSaveStatus = "dirty" | "saving" | "saved" | "failed" | "conflicted";
|
||||
|
||||
export class ProjectStateConflict extends Error {
|
||||
readonly latestStateVersion: number;
|
||||
|
||||
constructor(latestStateVersion: number) {
|
||||
super("project_state_conflict");
|
||||
this.latestStateVersion = latestStateVersion;
|
||||
}
|
||||
}
|
||||
|
||||
export class ConflictExportGuard {
|
||||
used = false;
|
||||
|
||||
async run(action: () => Promise<void>) {
|
||||
if (this.used) return false;
|
||||
this.used = true;
|
||||
await action();
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
export class SessionHistory<T> {
|
||||
private current: T;
|
||||
private readonly past: T[] = [];
|
||||
private readonly future: T[] = [];
|
||||
|
||||
constructor(initial: T) {
|
||||
this.current = structuredClone(initial);
|
||||
}
|
||||
|
||||
get canRedo() { return this.future.length > 0; }
|
||||
get canUndo() { return this.past.length > 0; }
|
||||
get value() { return structuredClone(this.current); }
|
||||
|
||||
commit(next: T) {
|
||||
this.past.push(structuredClone(this.current));
|
||||
this.current = structuredClone(next);
|
||||
this.future.length = 0;
|
||||
}
|
||||
|
||||
undo() {
|
||||
const previous = this.past.pop();
|
||||
if (previous === undefined) return undefined;
|
||||
this.future.push(structuredClone(this.current));
|
||||
this.current = previous;
|
||||
return structuredClone(this.current);
|
||||
}
|
||||
|
||||
redo() {
|
||||
const next = this.future.pop();
|
||||
if (next === undefined) return undefined;
|
||||
this.past.push(structuredClone(this.current));
|
||||
this.current = next;
|
||||
return structuredClone(this.current);
|
||||
}
|
||||
}
|
||||
|
||||
interface PendingSave {
|
||||
operationId: string;
|
||||
snapshot: ProjectEditableState;
|
||||
}
|
||||
|
||||
export class ProjectAutoSaveQueue {
|
||||
status: ProjectSaveStatus = "saved";
|
||||
stateVersion: number;
|
||||
conflictVersion?: number;
|
||||
|
||||
private disposed = false;
|
||||
private inFlight: Promise<boolean> | undefined;
|
||||
private lastSaved: ProjectEditableState;
|
||||
private pending: PendingSave | undefined;
|
||||
private retryIndex = 0;
|
||||
private timer: ReturnType<typeof setTimeout> | undefined;
|
||||
private readonly debounceMs: number;
|
||||
private readonly onConflict: ((latestVersion: number) => void) | undefined;
|
||||
private readonly onSaved: ((snapshot: ProjectEditableState, stateVersion: number) => void) | undefined;
|
||||
private readonly onStatus: ((status: ProjectSaveStatus) => void) | undefined;
|
||||
private readonly retryDelaysMs: number[];
|
||||
private readonly save: (snapshot: ProjectEditableState, stateVersion: number, operationId: string) => Promise<{ stateVersion: number }>;
|
||||
|
||||
constructor(input: {
|
||||
debounceMs?: number;
|
||||
initialState: ProjectEditableState;
|
||||
initialVersion: number;
|
||||
onConflict?: (latestVersion: number) => void;
|
||||
onSaved?: (snapshot: ProjectEditableState, stateVersion: number) => void;
|
||||
onStatus?: (status: ProjectSaveStatus) => void;
|
||||
retryDelaysMs?: number[];
|
||||
save: (snapshot: ProjectEditableState, stateVersion: number, operationId: string) => Promise<{ stateVersion: number }>;
|
||||
}) {
|
||||
this.debounceMs = input.debounceMs ?? 1_000;
|
||||
this.lastSaved = structuredClone(input.initialState);
|
||||
this.onConflict = input.onConflict;
|
||||
this.onSaved = input.onSaved;
|
||||
this.onStatus = input.onStatus;
|
||||
this.retryDelaysMs = input.retryDelaysMs ?? [1_000, 2_000, 4_000, 8_000, 15_000];
|
||||
this.save = input.save;
|
||||
this.stateVersion = input.initialVersion;
|
||||
}
|
||||
|
||||
commit(snapshot: ProjectEditableState) {
|
||||
if (this.disposed || this.status === "conflicted") return;
|
||||
this.pending = { operationId: crypto.randomUUID(), snapshot: structuredClone(snapshot) };
|
||||
if (!this.inFlight) {
|
||||
this.setStatus("dirty");
|
||||
this.schedule(this.debounceMs);
|
||||
}
|
||||
}
|
||||
|
||||
async saveNow() {
|
||||
if (this.disposed || (this.status as ProjectSaveStatus) === "conflicted") return false;
|
||||
this.clearTimer();
|
||||
if (this.inFlight) await this.inFlight;
|
||||
if (this.disposed || this.status === "conflicted") return false;
|
||||
if (!this.pending) return this.status === "saved";
|
||||
return this.startSave();
|
||||
}
|
||||
|
||||
dispose() {
|
||||
this.disposed = true;
|
||||
this.clearTimer();
|
||||
}
|
||||
|
||||
private clearTimer() {
|
||||
if (this.timer) clearTimeout(this.timer);
|
||||
this.timer = undefined;
|
||||
}
|
||||
|
||||
private schedule(delay: number) {
|
||||
this.clearTimer();
|
||||
this.timer = setTimeout(() => {
|
||||
this.timer = undefined;
|
||||
void this.startSave();
|
||||
}, delay);
|
||||
}
|
||||
|
||||
private startSave() {
|
||||
if (this.inFlight || !this.pending || this.disposed || this.status === "conflicted") {
|
||||
return this.inFlight ?? Promise.resolve(false);
|
||||
}
|
||||
const request = this.pending;
|
||||
this.pending = undefined;
|
||||
this.setStatus("saving");
|
||||
const attempt = this.save(structuredClone(request.snapshot), this.stateVersion, request.operationId)
|
||||
.then((result) => {
|
||||
this.stateVersion = result.stateVersion;
|
||||
this.lastSaved = structuredClone(request.snapshot);
|
||||
this.retryIndex = 0;
|
||||
this.onSaved?.(structuredClone(request.snapshot), this.stateVersion);
|
||||
if (this.pending) {
|
||||
this.setStatus("dirty");
|
||||
this.schedule(this.debounceMs);
|
||||
} else {
|
||||
this.setStatus("saved");
|
||||
}
|
||||
return true;
|
||||
})
|
||||
.catch((error: unknown) => {
|
||||
if (error instanceof ProjectStateConflict) {
|
||||
this.pending = undefined;
|
||||
this.conflictVersion = error.latestStateVersion;
|
||||
this.setStatus("conflicted");
|
||||
this.onConflict?.(error.latestStateVersion);
|
||||
return false;
|
||||
}
|
||||
if (!this.pending) this.pending = request;
|
||||
this.setStatus("failed");
|
||||
const delay = this.retryDelaysMs[Math.min(this.retryIndex, this.retryDelaysMs.length - 1)] ?? 15_000;
|
||||
this.retryIndex += 1;
|
||||
this.schedule(delay);
|
||||
return false;
|
||||
})
|
||||
.finally(() => {
|
||||
if (this.inFlight === attempt) this.inFlight = undefined;
|
||||
});
|
||||
this.inFlight = attempt;
|
||||
return attempt;
|
||||
}
|
||||
|
||||
private setStatus(status: ProjectSaveStatus) {
|
||||
this.status = status;
|
||||
this.onStatus?.(status);
|
||||
}
|
||||
}
|
||||
@@ -647,6 +647,59 @@
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.project-conflict {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(260px, 0.75fr) minmax(280px, 1fr) auto;
|
||||
gap: 24px;
|
||||
align-items: center;
|
||||
margin: 24px 0 0;
|
||||
padding: 20px;
|
||||
border: 2px solid #b42318;
|
||||
background: #fff4f2;
|
||||
}
|
||||
|
||||
.project-conflict h2,
|
||||
.project-conflict p {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.project-conflict > div:first-child p {
|
||||
font-family: Consolas, monospace;
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.project-conflict > div:first-child span {
|
||||
display: inline-block;
|
||||
margin: 8px 14px 0 0;
|
||||
font-family: Consolas, monospace;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.project-conflict-actions {
|
||||
display: grid;
|
||||
min-width: 190px;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.project-conflict button {
|
||||
min-height: 42px;
|
||||
padding: 9px 14px;
|
||||
border: 1px solid #111111;
|
||||
border-radius: 0;
|
||||
background: #ffffff;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.project-conflict button:first-child:not(:disabled) {
|
||||
background: #f2f500;
|
||||
}
|
||||
|
||||
.project-conflict > strong {
|
||||
grid-column: 1 / -1;
|
||||
color: #8f1d14;
|
||||
}
|
||||
|
||||
.project-identity {
|
||||
display: grid;
|
||||
grid-template-columns: 240px minmax(0, 1fr);
|
||||
@@ -661,6 +714,25 @@
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.save-status {
|
||||
display: inline-block;
|
||||
margin-top: 8px;
|
||||
padding-left: 9px;
|
||||
border-left: 3px solid #2f7d4a;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.save-status.dirty,
|
||||
.save-status.saving {
|
||||
border-color: #8a6a00;
|
||||
}
|
||||
|
||||
.save-status.failed,
|
||||
.save-status.conflicted {
|
||||
border-color: #b42318;
|
||||
color: #8f1d14;
|
||||
}
|
||||
|
||||
.project-identity p {
|
||||
margin-top: 4px;
|
||||
color: #6b6b65;
|
||||
@@ -830,6 +902,61 @@
|
||||
color: #65655f;
|
||||
}
|
||||
|
||||
.project-leave-overlay {
|
||||
position: fixed;
|
||||
z-index: 30;
|
||||
inset: 0;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
padding: 24px;
|
||||
background: rgb(17 17 17 / 58%);
|
||||
}
|
||||
|
||||
.project-leave-dialog {
|
||||
width: min(560px, 100%);
|
||||
padding: 28px;
|
||||
border: 2px solid #111111;
|
||||
background: #ffffff;
|
||||
box-shadow: 10px 10px 0 #f2f500;
|
||||
}
|
||||
|
||||
.project-leave-dialog > p {
|
||||
margin: 0 0 8px;
|
||||
font-family: Consolas, monospace;
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.project-leave-dialog > h2 {
|
||||
margin: 0 0 8px;
|
||||
}
|
||||
|
||||
.project-leave-dialog > div {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
gap: 8px;
|
||||
margin-top: 22px;
|
||||
}
|
||||
|
||||
.project-leave-dialog button {
|
||||
min-height: 44px;
|
||||
padding: 9px;
|
||||
border: 1px solid #111111;
|
||||
border-radius: 0;
|
||||
background: #ffffff;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.project-leave-dialog button:first-child {
|
||||
background: #f2f500;
|
||||
}
|
||||
|
||||
.project-leave-dialog > strong {
|
||||
display: block;
|
||||
margin-top: 14px;
|
||||
color: #8f1d14;
|
||||
}
|
||||
|
||||
.local-only-footer {
|
||||
display: grid;
|
||||
width: 100%;
|
||||
@@ -972,6 +1099,14 @@
|
||||
justify-self: start;
|
||||
}
|
||||
|
||||
.project-conflict {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.project-conflict-actions {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.project-identity {
|
||||
display: grid;
|
||||
gap: 14px;
|
||||
@@ -981,6 +1116,10 @@
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.project-leave-dialog > div {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.project-actions {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
+227
-23
@@ -1,4 +1,12 @@
|
||||
import { useEffect, useId, useMemo, useState, type FormEvent } from "react";
|
||||
import { useEffect, useId, useMemo, useRef, useState, type FormEvent } from "react";
|
||||
import type { CanvasState, ProjectEditableState } from "@dada/shared-contracts";
|
||||
|
||||
import {
|
||||
ConflictExportGuard,
|
||||
ProjectAutoSaveQueue,
|
||||
ProjectStateConflict,
|
||||
type ProjectSaveStatus,
|
||||
} from "./project-autosave.js";
|
||||
|
||||
import "./project-pages.css";
|
||||
|
||||
@@ -31,6 +39,7 @@ interface ProjectListPayload {
|
||||
}
|
||||
|
||||
interface ProjectDetailPayload extends ProjectSummary {
|
||||
canvas_state: CanvasState;
|
||||
created_at: string;
|
||||
draft_prompt: string;
|
||||
generations: Array<{
|
||||
@@ -45,6 +54,7 @@ interface ProjectDetailPayload extends ProjectSummary {
|
||||
images: Array<{ created_at: string; generation_id: string; image_id: string }>;
|
||||
pixel_height?: number;
|
||||
pixel_width?: number;
|
||||
save_status: "saved";
|
||||
}
|
||||
|
||||
async function readJson<T>(url: string, init?: RequestInit): Promise<T> {
|
||||
@@ -61,6 +71,52 @@ function formatUpdatedAt(value: string) {
|
||||
return new Intl.DateTimeFormat("zh-CN", { dateStyle: "medium", timeStyle: "short" }).format(new Date(value));
|
||||
}
|
||||
|
||||
function initialCanvasState(project: Pick<ProjectDetailPayload, "current_image_id" | "pixel_height" | "pixel_width" | "ratio">): CanvasState {
|
||||
return {
|
||||
background: {
|
||||
adjustments: {
|
||||
brightness: 0, contrast: 0, crop: null, filter: "none", fit: "fill",
|
||||
saturation: 0, sharpness: 0, temperature: 0,
|
||||
},
|
||||
asset_id: project.current_image_id,
|
||||
},
|
||||
elements: [],
|
||||
pixel_height: project.pixel_height ?? (project.ratio === "9:16" ? 1920 : project.ratio === "3:4" ? 1440 : 1080),
|
||||
pixel_width: project.pixel_width ?? (project.ratio === "4:3" ? 1440 : 1080),
|
||||
ratio: project.ratio,
|
||||
schema_version: 1,
|
||||
};
|
||||
}
|
||||
|
||||
async function downloadConflictPng(canvasState: CanvasState, projectName: string) {
|
||||
const canvas = document.createElement("canvas");
|
||||
canvas.width = canvasState.pixel_width;
|
||||
canvas.height = canvasState.pixel_height;
|
||||
const context = canvas.getContext("2d");
|
||||
if (!context) throw new Error("canvas_unavailable");
|
||||
context.fillStyle = "#f6f6f4";
|
||||
context.fillRect(0, 0, canvas.width, canvas.height);
|
||||
context.fillStyle = "#d4d4cf";
|
||||
const stripe = canvas.width / 4;
|
||||
for (let index = 0; index < 4; index += 1) {
|
||||
if (index % 2 === 1) context.fillRect(index * stripe, 0, stripe, canvas.height);
|
||||
}
|
||||
context.fillStyle = "#111111";
|
||||
context.font = `900 ${Math.max(64, Math.floor(canvas.width / 7))}px Arial`;
|
||||
context.textAlign = "center";
|
||||
context.textBaseline = "middle";
|
||||
context.fillText("DADA", canvas.width / 2, canvas.height / 2);
|
||||
const blob = await new Promise<Blob>((resolve, reject) => {
|
||||
canvas.toBlob((value) => value ? resolve(value) : reject(new Error("canvas_export_failed")), "image/png");
|
||||
});
|
||||
const url = URL.createObjectURL(blob);
|
||||
const anchor = document.createElement("a");
|
||||
anchor.download = `${projectName.trim().replace(/[\\/:*?"<>|]+/g, "-") || "Dada"}-本页版本.png`;
|
||||
anchor.href = url;
|
||||
anchor.click();
|
||||
setTimeout(() => URL.revokeObjectURL(url), 0);
|
||||
}
|
||||
|
||||
function ProductHeader({ current }: { current: "workspace" | "projects" }) {
|
||||
return (
|
||||
<header className="product-header">
|
||||
@@ -351,9 +407,18 @@ 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 [saveStatus, setSaveStatus] = useState<ProjectSaveStatus>("saved");
|
||||
const [conflictVersions, setConflictVersions] = useState<{ latest: number; page: number }>();
|
||||
const [conflictExportBusy, setConflictExportBusy] = useState(false);
|
||||
const [conflictExportUsed, setConflictExportUsed] = useState(false);
|
||||
const [conflictNotice, setConflictNotice] = useState("");
|
||||
const [pendingNavigation, setPendingNavigation] = useState<string>();
|
||||
const [leaving, setLeaving] = useState(false);
|
||||
const [leaveStatus, setLeaveStatus] = useState("");
|
||||
const [loadingFailed, setLoadingFailed] = useState(false);
|
||||
const queueRef = useRef<ProjectAutoSaveQueue | undefined>(undefined);
|
||||
const saveStatusRef = useRef<ProjectSaveStatus>("saved");
|
||||
const conflictExportGuard = useRef(new ConflictExportGuard());
|
||||
|
||||
useEffect(() => {
|
||||
let active = true;
|
||||
@@ -363,7 +428,12 @@ export function ProjectDetailPage({ projectId }: { projectId: string }) {
|
||||
]).then(([nextSession, nextProject]) => {
|
||||
if (!active) return;
|
||||
setSession(nextSession);
|
||||
setProject(nextProject);
|
||||
const normalizedProject = {
|
||||
...nextProject,
|
||||
canvas_state: nextProject.canvas_state ?? initialCanvasState(nextProject),
|
||||
save_status: nextProject.save_status ?? "saved",
|
||||
};
|
||||
setProject(normalizedProject);
|
||||
setName(nextProject.name);
|
||||
}).catch((error) => {
|
||||
if (active && error instanceof Error && error.message !== "session_invalid") setLoadingFailed(true);
|
||||
@@ -371,30 +441,134 @@ export function ProjectDetailPage({ projectId }: { projectId: string }) {
|
||||
return () => { active = false; };
|
||||
}, [projectId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!project || !session) return;
|
||||
const queue = new ProjectAutoSaveQueue({
|
||||
initialState: { canvas_state: project.canvas_state, name: project.name },
|
||||
initialVersion: project.state_version,
|
||||
onConflict: (latestVersion) => setConflictVersions({ latest: latestVersion, page: queue.stateVersion }),
|
||||
onSaved: (snapshot, stateVersion) => {
|
||||
setProject((current) => current ? {
|
||||
...current, canvas_state: snapshot.canvas_state, name: snapshot.name,
|
||||
save_status: "saved", state_version: stateVersion,
|
||||
} : current);
|
||||
setName(snapshot.name);
|
||||
},
|
||||
onStatus: (status) => {
|
||||
saveStatusRef.current = status;
|
||||
setSaveStatus(status);
|
||||
},
|
||||
save: async (snapshot, stateVersion, operationId) => {
|
||||
const response = await fetch(`/api/v1/projects/${projectId}/state`, {
|
||||
body: JSON.stringify(snapshot),
|
||||
credentials: "same-origin",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"Idempotency-Key": operationId,
|
||||
"If-Match": String(stateVersion),
|
||||
"X-CSRF-Token": session.csrf_token,
|
||||
},
|
||||
method: "PUT",
|
||||
});
|
||||
if (response.status === 401) {
|
||||
window.dispatchEvent(new Event("dada:session-invalid"));
|
||||
throw new Error("session_invalid");
|
||||
}
|
||||
if (response.status === 412) {
|
||||
const conflict = await response.json() as { latest_state_version: number };
|
||||
throw new ProjectStateConflict(conflict.latest_state_version);
|
||||
}
|
||||
if (!response.ok) throw new Error("project_save_failed");
|
||||
const saved = await response.json() as { state_version: number };
|
||||
return { stateVersion: saved.state_version };
|
||||
},
|
||||
});
|
||||
queueRef.current = queue;
|
||||
saveStatusRef.current = "saved";
|
||||
setSaveStatus("saved");
|
||||
return () => {
|
||||
queue.dispose();
|
||||
if (queueRef.current === queue) queueRef.current = undefined;
|
||||
};
|
||||
}, [project?.created_at, projectId, session?.csrf_token]);
|
||||
|
||||
useEffect(() => {
|
||||
const guardActive = () => ["dirty", "saving", "failed"].includes(saveStatusRef.current);
|
||||
const beforeUnload = (event: BeforeUnloadEvent) => {
|
||||
if (!guardActive()) return;
|
||||
event.preventDefault();
|
||||
event.returnValue = "";
|
||||
void queueRef.current?.saveNow();
|
||||
};
|
||||
const interceptNavigation = (event: MouseEvent) => {
|
||||
if (!guardActive() || event.defaultPrevented || event.button !== 0) return;
|
||||
const target = event.target instanceof Element ? event.target.closest("a[href]") : null;
|
||||
if (!(target instanceof HTMLAnchorElement) || target.target || target.download) return;
|
||||
const destination = new URL(target.href, window.location.href);
|
||||
if (destination.origin !== window.location.origin) return;
|
||||
event.preventDefault();
|
||||
setLeaveStatus("");
|
||||
setPendingNavigation(destination.href);
|
||||
};
|
||||
window.addEventListener("beforeunload", beforeUnload);
|
||||
document.addEventListener("click", interceptNavigation, true);
|
||||
return () => {
|
||||
window.removeEventListener("beforeunload", beforeUnload);
|
||||
document.removeEventListener("click", interceptNavigation, true);
|
||||
};
|
||||
}, []);
|
||||
|
||||
function updateName(value: string) {
|
||||
if (!project || saveStatus === "conflicted") return;
|
||||
setName(value);
|
||||
const snapshot: ProjectEditableState = { canvas_state: project.canvas_state, name: value };
|
||||
queueRef.current?.commit(snapshot);
|
||||
}
|
||||
|
||||
async function rename(event: FormEvent) {
|
||||
event.preventDefault();
|
||||
if (!session || !project || savingName || !name.trim()) return;
|
||||
setSavingName(true);
|
||||
setNameStatus("");
|
||||
if (!name.trim() || saveStatus === "conflicted") return;
|
||||
await queueRef.current?.saveNow();
|
||||
}
|
||||
|
||||
async function exportConflictVersion() {
|
||||
if (!project || conflictExportBusy) return;
|
||||
setConflictExportBusy(true);
|
||||
setConflictNotice("");
|
||||
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("项目名已保存");
|
||||
await conflictExportGuard.current.run(() => downloadConflictPng(project.canvas_state, name));
|
||||
setConflictNotice("仅下载本页版本,未写入项目");
|
||||
} catch {
|
||||
setNameStatus("项目名保存失败");
|
||||
setConflictNotice("本页版本导出失败,未写入项目");
|
||||
} finally {
|
||||
setSavingName(false);
|
||||
setConflictExportUsed(conflictExportGuard.current.used);
|
||||
setConflictExportBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function saveAndLeave() {
|
||||
if (!pendingNavigation) return;
|
||||
setLeaving(true);
|
||||
setLeaveStatus("");
|
||||
const saved = await queueRef.current?.saveNow();
|
||||
if (saved) window.location.assign(pendingNavigation);
|
||||
else setLeaveStatus("保存未完成,仍停留在当前页面");
|
||||
setLeaving(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;
|
||||
const conflicted = saveStatus === "conflicted";
|
||||
const saveLabel = {
|
||||
conflicted: "版本冲突",
|
||||
dirty: "有未保存修改",
|
||||
failed: "未保存",
|
||||
saved: "已保存",
|
||||
saving: "正在保存",
|
||||
}[saveStatus];
|
||||
|
||||
return (
|
||||
<div className="product-page">
|
||||
@@ -405,12 +579,27 @@ export function ProjectDetailPage({ projectId }: { projectId: string }) {
|
||||
<div><p>{project.status === "failed_empty" ? "FAILED EMPTY" : project.status.toUpperCase()}</p><h1>{project.name}</h1></div>
|
||||
<span>固定比例 {project.ratio}</span>
|
||||
</header>
|
||||
{conflicted && conflictVersions ? (
|
||||
<section className="project-conflict" aria-live="assertive">
|
||||
<div>
|
||||
<p>STATE VERSION CONFLICT</p>
|
||||
<h2>版本冲突</h2>
|
||||
<span>本页版本 {conflictVersions.page}</span>
|
||||
<span>最新版本 {conflictVersions.latest}</span>
|
||||
</div>
|
||||
<p>此页面已转为只读。可本地导出当前页一次,然后刷新本机后端保存的最新版本。</p>
|
||||
<div className="project-conflict-actions">
|
||||
<button disabled={conflictExportBusy || conflictExportUsed} onClick={exportConflictVersion} type="button">本地导出本页版本</button>
|
||||
<button onClick={() => window.location.reload()} type="button">刷新最新版本</button>
|
||||
</div>
|
||||
{conflictNotice ? <strong role="status">{conflictNotice}</strong> : null}
|
||||
</section>
|
||||
) : null}
|
||||
<section className="project-identity" aria-labelledby="rename-title">
|
||||
<div><h2 id="rename-title">项目名称</h2><p>state version {project.state_version}</p></div>
|
||||
<div><h2 id="rename-title">项目名称</h2><p>state version {project.state_version}</p><strong className={`save-status ${saveStatus}`} aria-live={saveStatus === "failed" || conflicted ? "assertive" : "polite"}>{saveLabel}</strong></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}
|
||||
<input aria-label="项目名称" disabled={conflicted} maxLength={80} onChange={(event) => updateName(event.target.value)} value={name} />
|
||||
<button disabled={conflicted || saveStatus === "saving" || !name.trim() || (saveStatus === "saved" && name.trim() === project.name)} type="submit">立即保存</button>
|
||||
</form>
|
||||
</section>
|
||||
<div className="project-detail-grid">
|
||||
@@ -418,12 +607,12 @@ export function ProjectDetailPage({ projectId }: { projectId: string }) {
|
||||
<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>
|
||||
<button disabled={conflicted || atHistoryLimit} onClick={() => window.location.assign(`/app?continue=${project.project_id}`)} type="button">继续生成</button>
|
||||
<button disabled={conflicted || !project.current_image_id} type="button">进入编辑器</button>
|
||||
<button disabled={conflicted || !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}
|
||||
{project.status === "failed_empty" && !conflicted ? <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>
|
||||
@@ -442,6 +631,21 @@ export function ProjectDetailPage({ projectId }: { projectId: string }) {
|
||||
</section>
|
||||
</div>
|
||||
</main>
|
||||
{pendingNavigation ? (
|
||||
<div className="project-leave-overlay" role="presentation">
|
||||
<section aria-labelledby="leave-project-title" aria-modal="true" className="project-leave-dialog" role="dialog">
|
||||
<p>UNSAVED PROJECT</p>
|
||||
<h2 id="leave-project-title">有未保存修改</h2>
|
||||
<span>保存成功后才会离开当前项目。</span>
|
||||
<div>
|
||||
<button disabled={leaving} onClick={saveAndLeave} type="button">保存并离开</button>
|
||||
<button disabled={leaving} onClick={() => window.location.assign(pendingNavigation)} type="button">放弃修改</button>
|
||||
<button disabled={leaving} onClick={() => setPendingNavigation(undefined)} type="button">取消</button>
|
||||
</div>
|
||||
{leaveStatus ? <strong role="alert">{leaveStatus}</strong> : null}
|
||||
</section>
|
||||
</div>
|
||||
) : null}
|
||||
<LocalOnlyFooter />
|
||||
</div>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user