feat(review): #90 photo strip — keep/remove, drag-reorder, leftmost=primary, persist

REVIEW-5 / PUBLISH-45: owner edits the stored photo lineup in Review — tap a
card to keep/remove, drag to reorder, leftmost kept photo = primary (Woo
cover). The kept set must contain a clean calibration + its dimension-overlay
copy; publish is blocked until it does.

- backend: role column on slab_photos (schema + idempotent _migrate ALTER),
  role in photo CRUD + new DELETE endpoint, Woo image order by role-rank
  (clean→overlay→batch) per PUBLISH-45
- frontend: Review photo strip (render from listPhotos, keep/remove,
  HTML5 drag-reorder, dynamic PRIMARY badge from .primary class),
  persistLineup() re-seq kept + drop removed before entering Publish
- tests: lineup.ts unit, e2e review-photo-strip.spec, backend role + order

Refs: #90, REVIEW-5, PUBLISH-45
This commit is contained in:
Ty
2026-09-21 01:34:22 -07:00
parent fe82133ca4
commit d9af8420bc
11 changed files with 806 additions and 29 deletions
+62
View File
@@ -0,0 +1,62 @@
// #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<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(kept: LineupItem[]): PhotoRole[] {
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';
}