/** * 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(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 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; }