// #90 Review photo strip (REVIEW-5 / PUBLISH-45). Pure logic split out so the // keep/remove + reorder + minimum enforcement is unit-testable without a browser. // // Model (approved in the wireframe): the owner taps a photo to keep/remove it, // drags to reorder, and the LEFTMOST kept photo is the primary (Woo cover). The // submission-ready minimum is at least one clean (calibration) photo AND one // dimension-overlay copy (REVIEW-5 "clean first pic plus its dimension-overlay // copy"). 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 kept photos in their current (left→right) order. The first kept photo is * the primary (Woo cover). Reordering is a plain array move; this helper just * exposes the kept subset so the caller can re-seq on persist. */ 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. The in-memory capture blob (fresh * photo) must become a `blob:` object URL — assigning a raw Blob (or * `String(blob)` = "object Blob") to `src` renders a broken image. That was * bug #1: the first/Primary (calibration) photo is the one carrying a blob. * When there is no usable blob, fall back to the stored photo 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; }