e5afc7524b
ensureServerSlab sent calibration_photo_id at create, but slab_photos rows are
only created by POST /slabs/{id}/photos, which the frontend never called - so
the INSERT hit FOREIGN KEY (calibration_photo_id REFERENCES slab_photos(id))
and every create failed with a generic 400, blocking the whole create->publish
walk.
- stop sending calibration_photo_id at create
- upload every draft photo (original + processed PNG) right after the slab row
exists, via new review/photoUpload.ts (multipart FormData, same error
envelope so failures surface through fail(e))
- link calibration_photo_id in the existing review PUT, once the row exists
- extract the role rule into lineup.photoRole() so the review strip and the
uploader cannot disagree
91 lines
3.3 KiB
TypeScript
91 lines
3.3 KiB
TypeScript
/**
|
|
* Review photo-strip lineup (REVIEW-5 / PUBLISH-45). Pure logic so keep/remove,
|
|
* reorder, and the submission minimum are unit-testable without a browser.
|
|
*
|
|
* The owner taps photos to keep/remove and drags to reorder; the LEFTMOST kept
|
|
* photo is the primary (Woo cover). Submission minimum: at least one clean
|
|
* calibration photo AND its dimension-overlay copy (REVIEW-5). Batch photos
|
|
* are optional.
|
|
*/
|
|
|
|
export type PhotoRole = 'calibration' | 'overlay' | 'batch';
|
|
|
|
export interface LineupItem {
|
|
id: string;
|
|
role: PhotoRole;
|
|
isCalibration: boolean;
|
|
/** True = the owner keeps this photo in the submission. */
|
|
kept: boolean;
|
|
}
|
|
|
|
/**
|
|
* The submission role of a photo: the calibration photo is `calibration`; a
|
|
* photo carrying its dimension-overlay copy (`processedBlob`) is `overlay`;
|
|
* everything else is `batch`. Single definition of the role rule — the review
|
|
* strip (ReviewForm) and the server photo upload (photoUpload) both derive from
|
|
* this so they can never disagree.
|
|
*/
|
|
export function photoRole(id: string, calibrationPhotoId: string, hasProcessed: boolean): PhotoRole {
|
|
if (id === calibrationPhotoId) return 'calibration';
|
|
if (hasProcessed) return 'overlay';
|
|
return 'batch';
|
|
}
|
|
|
|
/** The kept photos in their current (left→right) order; the first is the primary. */
|
|
export function keptInOrder(items: LineupItem[]): LineupItem[] {
|
|
return items.filter((it) => it.kept);
|
|
}
|
|
|
|
/** Move an item from one index to another (drag-reorder). Returns a new array. */
|
|
export function moveItem<T>(arr: T[], from: number, to: number): T[] {
|
|
if (from === to || from < 0 || to < 0 || from >= arr.length || to >= arr.length) {
|
|
return arr;
|
|
}
|
|
const next = arr.slice();
|
|
const [item] = next.splice(from, 1);
|
|
next.splice(to, 0, item);
|
|
return next;
|
|
}
|
|
|
|
/**
|
|
* Whether the kept lineup meets the submission minimum (REVIEW-5): at least one
|
|
* clean calibration photo and one dimension-overlay copy. Returns the missing
|
|
* role(s) so the UI can name them in a nudge.
|
|
*/
|
|
export function missingMinimums(items: LineupItem[]): PhotoRole[] {
|
|
const kept = items.filter((it) => it.kept);
|
|
const missing: PhotoRole[] = [];
|
|
if (!kept.some((it) => it.role === 'calibration')) missing.push('calibration');
|
|
if (!kept.some((it) => it.role === 'overlay')) missing.push('overlay');
|
|
return missing;
|
|
}
|
|
|
|
/** True when the kept lineup is publishable from a photo standpoint. */
|
|
export function lineupValid(kept: LineupItem[]): boolean {
|
|
return missingMinimums(kept).length === 0;
|
|
}
|
|
|
|
/** A short owner-facing label for a role (used in the minimum nudge). */
|
|
export function roleLabel(role: PhotoRole): string {
|
|
if (role === 'calibration') return 'a clean photo';
|
|
if (role === 'overlay') return 'its dimension-overlay copy';
|
|
return 'a batch photo';
|
|
}
|
|
|
|
/**
|
|
* Resolve the <img> src for a strip photo. A fresh in-memory blob must become
|
|
* a `blob:` object URL — assigning a raw Blob renders a broken image. With no
|
|
* usable blob, fall back to the stored endpoint.
|
|
*
|
|
* Pure + injectable: `createObjectUrl` defaults to `URL.createObjectURL` so it
|
|
* is unit-testable without a real DOM/Blob.
|
|
*/
|
|
export function resolvePhotoUrl(
|
|
blob: Blob | null,
|
|
endpoint: string,
|
|
createObjectUrl: (b: Blob) => string = (b) => URL.createObjectURL(b),
|
|
): string {
|
|
if (blob && blob.size > 0) return createObjectUrl(blob);
|
|
return endpoint;
|
|
}
|