Add button and custom modal flow implemented

This commit is contained in:
adilallo
2026-05-07 21:15:27 -06:00
parent dee2dd800e
commit 26bcd61ea3
43 changed files with 1444 additions and 81 deletions
+14
View File
@@ -161,6 +161,20 @@ export function buildPublishPayload(
document.methodSelections = methodSelections;
}
const avatar =
typeof state.communityAvatarUrl === "string" &&
state.communityAvatarUrl.trim().length > 0
? state.communityAvatarUrl.trim()
: undefined;
if (avatar) {
document.communityAvatarUrl = avatar;
}
const fieldBlocks = state.customMethodCardFieldBlocksById;
if (fieldBlocks && Object.keys(fieldBlocks).length > 0) {
document.customMethodCardFieldBlocksById = fieldBlocks;
}
if (summary !== undefined) {
return { ok: true, title, summary, document };
}
+8
View File
@@ -0,0 +1,8 @@
/** Multipart field `purpose` for `POST /api/uploads` — keep in sync with server validation. */
export const CREATE_FLOW_UPLOAD_PURPOSES = [
"communityAvatar",
"customMethodAttachment",
] as const;
export type CreateFlowUploadPurpose =
(typeof CREATE_FLOW_UPLOAD_PURPOSES)[number];
@@ -19,6 +19,8 @@ export type CustomMethodCardFieldBlock =
id: string;
blockTitle: string;
fileName?: string;
/** App path from `POST /api/uploads` (e.g. `/api/uploads/{uuid}`). */
assetUrl?: string;
}
| {
kind: "proportion";
@@ -51,6 +53,7 @@ const customMethodUploadBlockSchema = z
id: z.string().max(80),
blockTitle: z.string().max(200),
fileName: z.string().max(500).optional(),
assetUrl: z.string().max(512).optional(),
})
.strict();
@@ -0,0 +1,73 @@
/**
* IndexedDB staging for community avatar when the user picks a file before
* a session exists. Cleared after successful upload or explicit clear.
*/
const DB_NAME = "community-rule-pending-uploads";
const DB_VERSION = 1;
const STORE = "communityAvatar";
const KEY = "pending";
function openDb(): Promise<IDBDatabase> {
return new Promise((resolve, reject) => {
const req = indexedDB.open(DB_NAME, DB_VERSION);
req.onerror = () => reject(req.error ?? new Error("indexedDB open failed"));
req.onsuccess = () => resolve(req.result);
req.onupgradeneeded = () => {
const db = req.result;
if (!db.objectStoreNames.contains(STORE)) {
db.createObjectStore(STORE);
}
};
});
}
export async function storePendingCommunityAvatarFile(file: File): Promise<void> {
const db = await openDb();
try {
await new Promise<void>((resolve, reject) => {
const tx = db.transaction(STORE, "readwrite");
tx.oncomplete = () => resolve();
tx.onerror = () => reject(tx.error ?? new Error("indexedDB write failed"));
tx.objectStore(STORE).put(file, KEY);
});
} finally {
db.close();
}
}
/** Read staged file without removing it (caller clears after successful upload). */
export async function readPendingCommunityAvatarFile(): Promise<File | null> {
const db = await openDb();
try {
return await new Promise<File | null>((resolve, reject) => {
const tx = db.transaction(STORE, "readonly");
tx.onerror = () => reject(tx.error ?? new Error("indexedDB read failed"));
const getReq = tx.objectStore(STORE).get(KEY);
getReq.onsuccess = () => {
const v = getReq.result;
resolve(v instanceof File ? v : null);
};
getReq.onerror = () => reject(getReq.error);
});
} finally {
db.close();
}
}
export async function clearPendingCommunityAvatarFile(): Promise<void> {
if (typeof indexedDB === "undefined") return;
const db = await openDb();
try {
await new Promise<void>((resolve, reject) => {
const tx = db.transaction(STORE, "readwrite");
tx.oncomplete = () => resolve();
tx.onerror = () => reject(tx.error ?? new Error("indexedDB clear failed"));
tx.objectStore(STORE).delete(KEY);
});
} catch {
// ignore missing DB / quota
} finally {
db.close();
}
}
@@ -113,6 +113,24 @@ export function createFlowStateFromPublishedRule(
out.coreValueDetailsByChipId = coreValueDetailsByChipId;
}
const avatarUrl =
typeof doc.communityAvatarUrl === "string"
? doc.communityAvatarUrl.trim()
: "";
if (avatarUrl.length > 0) {
out.communityAvatarUrl = avatarUrl;
}
const blocksRaw = doc.customMethodCardFieldBlocksById;
if (
blocksRaw &&
typeof blocksRaw === "object" &&
!Array.isArray(blocksRaw)
) {
out.customMethodCardFieldBlocksById =
blocksRaw as NonNullable<CreateFlowState["customMethodCardFieldBlocksById"]>;
}
const msRaw = doc.methodSelections;
if (!msRaw || typeof msRaw !== "object" || Array.isArray(msRaw)) {
out.sections = [];
@@ -0,0 +1,18 @@
/**
* Immutable reorder for custom method card field blocks (wizard step 3, edit modal).
*/
export function reorderCustomMethodCardFieldBlocks<T>(
blocks: readonly T[],
fromIndex: number,
toIndex: number,
): T[] {
if (fromIndex === toIndex) return [...blocks];
if (fromIndex < 0 || toIndex < 0 || fromIndex >= blocks.length) {
return [...blocks];
}
if (toIndex >= blocks.length) return [...blocks];
const next = [...blocks];
const [removed] = next.splice(fromIndex, 1);
next.splice(toIndex, 0, removed);
return next;
}
+93
View File
@@ -0,0 +1,93 @@
import type { CreateFlowUploadPurpose } from "./createFlowUploadPurpose";
export type UploadToServerResult = {
url: string;
id: string;
mimeType: string;
byteLength: number;
};
/**
* Authenticated multipart upload to `POST /api/uploads`.
* Caller must have a session cookie (same-origin fetch).
*/
export async function uploadCreateFlowFile(
file: File,
purpose: CreateFlowUploadPurpose,
): Promise<UploadToServerResult> {
const formData = new FormData();
formData.append("purpose", purpose);
formData.append("file", file);
const res = await fetch("/api/uploads", {
method: "POST",
body: formData,
credentials: "same-origin",
});
let body: unknown;
try {
body = await res.json();
} catch {
body = {};
}
const errParts = (() => {
if (body && typeof body === "object" && "error" in body) {
const e = (body as {
error?: { message?: string; code?: string };
}).error;
if (!e) return { message: null as string | null, code: null as string | null };
return {
message: typeof e.message === "string" ? e.message : null,
code: typeof e.code === "string" ? e.code : null,
};
}
return { message: null, code: null };
})();
if (!res.ok) {
const fallback =
res.status === 413
? "FILE_TOO_LARGE"
: res.status === 401
? "UNAUTHORIZED"
: "UPLOAD_FAILED";
const code = errParts.code ?? errParts.message ?? fallback;
throw new UploadToServerError(res.status, code);
}
const data = body as {
url?: string;
id?: string;
mimeType?: string;
byteLength?: number;
};
if (
typeof data.url !== "string" ||
typeof data.id !== "string" ||
typeof data.mimeType !== "string" ||
typeof data.byteLength !== "number"
) {
throw new UploadToServerError(res.status, "INVALID_RESPONSE");
}
return {
url: data.url,
id: data.id,
mimeType: data.mimeType,
byteLength: data.byteLength,
};
}
export class UploadToServerError extends Error {
readonly status: number;
readonly code: string;
constructor(status: number, code: string) {
super(code);
this.name = "UploadToServerError";
this.status = status;
this.code = code;
}
}