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:
@@ -159,6 +159,8 @@ export interface PhotoRow {
|
||||
id: string;
|
||||
seq: number;
|
||||
is_calibration: number;
|
||||
/** #90: lineup kind (clean cal / dimension-overlay copy / batch). */
|
||||
role: 'calibration' | 'overlay' | 'batch';
|
||||
width_px: number | null;
|
||||
height_px: number | null;
|
||||
has_original: number;
|
||||
@@ -169,6 +171,8 @@ export interface UploadPhotoInput {
|
||||
photoId: string; // client-generated UUIDv4
|
||||
seq?: number;
|
||||
isCalibration: boolean;
|
||||
/** #90: lineup kind; defaults to batch. */
|
||||
role?: 'calibration' | 'overlay' | 'batch';
|
||||
original: Blob;
|
||||
processed?: Blob | null;
|
||||
}
|
||||
@@ -178,6 +182,7 @@ export interface UploadPhotoResult {
|
||||
slab_id: string;
|
||||
seq: number;
|
||||
is_calibration: number;
|
||||
role: 'calibration' | 'overlay' | 'batch';
|
||||
original_retained: boolean;
|
||||
processed_retained: boolean;
|
||||
}
|
||||
@@ -193,6 +198,7 @@ export function uploadPhoto(slabId: string, input: UploadPhotoInput): Promise<Up
|
||||
form.append('photo_id', input.photoId);
|
||||
if (input.seq != null) form.append('seq', String(input.seq));
|
||||
form.append('is_calibration', input.isCalibration ? '1' : '0');
|
||||
form.append('role', input.role ?? 'batch');
|
||||
const origName = input.original instanceof File ? input.original.name : 'original.png';
|
||||
form.append('original', input.original, origName);
|
||||
if (input.processed) form.append('processed', input.processed, 'processed.png');
|
||||
@@ -202,6 +208,49 @@ export function uploadPhoto(slabId: string, input: UploadPhotoInput): Promise<Up
|
||||
}).then((r) => parse<UploadPhotoResult>(r));
|
||||
}
|
||||
|
||||
// #90: drop a photo the owner removed in Review (keep/remove edit).
|
||||
export function deletePhoto(slabId: string, photoId: string): Promise<void> {
|
||||
return fetch(`/api/v1/slabs/${encodeURIComponent(slabId)}/photos/${encodeURIComponent(photoId)}`, {
|
||||
method: 'DELETE',
|
||||
}).then((r) => parse<void>(r));
|
||||
}
|
||||
|
||||
/**
|
||||
* #90: persist an edited Review photo lineup. `kept` is the owner's final
|
||||
* order (leftmost = primary); each entry re-uploads with a fresh seq so the
|
||||
* stored `seq` reflects the keep order. `removed` photos are dropped. Every
|
||||
* kept photo's blob is re-sent (original + processed) because the server
|
||||
* stores only the BLOB — there is no separate re-seq endpoint.
|
||||
*/
|
||||
export interface LineupPhoto {
|
||||
id: string;
|
||||
role: 'calibration' | 'overlay' | 'batch';
|
||||
isCalibration: boolean;
|
||||
original: Blob;
|
||||
processed: Blob | null;
|
||||
}
|
||||
|
||||
export async function persistLineup(
|
||||
slabId: string,
|
||||
kept: LineupPhoto[],
|
||||
removed: string[],
|
||||
): Promise<void> {
|
||||
for (const id of removed) {
|
||||
await deletePhoto(slabId, id);
|
||||
}
|
||||
for (let i = 0; i < kept.length; i++) {
|
||||
const p = kept[i];
|
||||
await uploadPhoto(slabId, {
|
||||
photoId: p.id,
|
||||
seq: i + 1,
|
||||
isCalibration: p.isCalibration,
|
||||
role: p.role,
|
||||
original: p.original,
|
||||
processed: p.processed,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export function photoUrl(
|
||||
slabId: string,
|
||||
photoId: string,
|
||||
|
||||
@@ -2,11 +2,15 @@
|
||||
import { onMount, onDestroy } from 'svelte';
|
||||
import {
|
||||
getSlab, updateSlab, reviewSlab, lockSlab, getTaxonomy, getSettings, inferTaxon, inferContent,
|
||||
ApiError,
|
||||
type Slab, type Taxonomy, type TaxCategory, type TaxTag, type SettingsView,
|
||||
listPhotos, persistLineup, photoUrl, ApiError,
|
||||
type Slab, type Taxonomy, type TaxCategory, type TaxTag, type SettingsView, type PhotoRow,
|
||||
} from '../../api/slabs';
|
||||
import type { CalibratedCapture } from '../flow';
|
||||
import MultiSelect from './MultiSelect.svelte';
|
||||
import {
|
||||
keptInOrder, moveItem, missingMinimums, roleLabel,
|
||||
type LineupItem,
|
||||
} from './lineup';
|
||||
|
||||
// Props handed off by App (one-column flow, UX.md §1).
|
||||
export let slabId: string;
|
||||
@@ -132,6 +136,143 @@
|
||||
const fmt1 = (v: number) => { const s = v.toFixed(1); return s.endsWith('.0') ? s.slice(0, -2) : s; };
|
||||
const fmt2 = (v: number) => v.toFixed(2);
|
||||
|
||||
// --- #90 Photo strip (REVIEW-5 / PUBLISH-45) ---
|
||||
// The owner edits which photos stay/go and their order. Leftmost kept photo =
|
||||
// primary (Woo cover). Kept in-memory; persisted via persistLineup() on
|
||||
// "Continue to Publish". Sources are the STORED photos (listPhotos) so the
|
||||
// strip survives re-entry into Review; in-memory lineup blobs (from capture)
|
||||
// are preferred when present.
|
||||
interface StripPhoto {
|
||||
id: string;
|
||||
role: 'calibration' | 'overlay' | 'batch';
|
||||
isCalibration: boolean;
|
||||
kept: boolean;
|
||||
original: Blob;
|
||||
processed: Blob | null;
|
||||
url: string;
|
||||
}
|
||||
let photos: StripPhoto[] = [];
|
||||
let photosLoaded = false;
|
||||
let lineupDirty = false;
|
||||
let persistBusy = false;
|
||||
let persistError = '';
|
||||
|
||||
// In-memory blobs from the capture lineup, keyed by lineup order. The stored
|
||||
// photo id matches the capture's upload order only on the first entry; after
|
||||
// an edit the STORED row (listPhotos) is authoritative and we re-fetch blobs.
|
||||
const inMemByRole = (): Map<'calibration' | 'overlay' | 'batch', Blob[]> => {
|
||||
const m = new Map<'calibration' | 'overlay' | 'batch', Blob[]>();
|
||||
for (const it of capture?.lineup ?? []) {
|
||||
if (it.kind !== 'png') continue;
|
||||
const arr = m.get(it.role) ?? [];
|
||||
arr.push(it.blob);
|
||||
m.set(it.role, arr);
|
||||
}
|
||||
return m;
|
||||
};
|
||||
|
||||
async function loadPhotos() {
|
||||
try {
|
||||
const rows: PhotoRow[] = await listPhotos(slabId);
|
||||
const byRole = inMemByRole();
|
||||
const seqByRole: Record<string, number> = { calibration: 0, overlay: 0, batch: 0 };
|
||||
photos = rows.map((r) => {
|
||||
const role = r.role ?? 'batch';
|
||||
const n = seqByRole[role]++;
|
||||
const blob = (byRole.get(role)?.[n]) ?? null;
|
||||
const url =
|
||||
blob ??
|
||||
(photoUrl(slabId, r.id, (r.has_processed ? 'processed' : 'original') as 'original' | 'processed'));
|
||||
return {
|
||||
id: r.id,
|
||||
role,
|
||||
isCalibration: r.is_calibration === 1,
|
||||
kept: true,
|
||||
original: blob ?? new Blob(),
|
||||
processed: blob ?? null,
|
||||
url: String(url),
|
||||
};
|
||||
});
|
||||
photosLoaded = true;
|
||||
lineupDirty = false;
|
||||
} catch {
|
||||
photos = [];
|
||||
photosLoaded = false;
|
||||
}
|
||||
}
|
||||
|
||||
// Whole-card tap = keep/remove toggle. A real drag (>6px) reorders and does NOT
|
||||
// toggle keep/remove — the threshold lives in the pointer handlers below.
|
||||
function toggleKeep(id: string) {
|
||||
photos = photos.map((p) => (p.id === id ? { ...p, kept: !p.kept } : p));
|
||||
lineupDirty = true;
|
||||
}
|
||||
|
||||
function reorder(from: number, to: number) {
|
||||
photos = moveItem(photos, from, to);
|
||||
lineupDirty = true;
|
||||
}
|
||||
|
||||
let dragFrom = -1;
|
||||
|
||||
function onDragStart(e: DragEvent, index: number) {
|
||||
dragFrom = index;
|
||||
if (e.dataTransfer) {
|
||||
e.dataTransfer.effectAllowed = 'move';
|
||||
e.dataTransfer.setData('text/plain', String(index));
|
||||
}
|
||||
}
|
||||
|
||||
function onDragOver(e: DragEvent) {
|
||||
if (e.preventDefault) e.preventDefault();
|
||||
}
|
||||
|
||||
function onDrop(e: DragEvent, index: number) {
|
||||
if (e.preventDefault) e.preventDefault();
|
||||
if (dragFrom >= 0 && dragFrom !== index) reorder(dragFrom, index);
|
||||
dragFrom = -1;
|
||||
}
|
||||
|
||||
// REVIEW-5: the kept set must contain a clean photo and its overlay copy. Only
|
||||
// enforced once photos have loaded (photosLoaded); a failed/absent photo list
|
||||
// (e.g. legacy mock) leaves the lineup minimum neutral rather than blocking.
|
||||
let lineupMissing: ('calibration' | 'overlay')[] = [];
|
||||
$: lineupMissing = photosLoaded
|
||||
? missingMinimums(keptInOrder(photos as LineupItem[]) as LineupItem[])
|
||||
: [];
|
||||
$: lineupValid = lineupMissing.length === 0;
|
||||
// Leftmost kept photo id = primary (Woo cover). '' when nothing is kept.
|
||||
let primaryId = '';
|
||||
$: primaryId = photosLoaded
|
||||
? ((keptInOrder(photos as LineupItem[]) as StripPhoto[])[0]?.id ?? '')
|
||||
: '';
|
||||
// Persist the edited lineup (re-seq kept + drop removed), then hand to Publish.
|
||||
async function goPublish() {
|
||||
if (!lineupValid) return;
|
||||
persistBusy = true;
|
||||
persistError = '';
|
||||
try {
|
||||
const kept = keptInOrder(photos as LineupItem[]) as StripPhoto[];
|
||||
const removed = photos.filter((p) => !p.kept).map((p) => p.id);
|
||||
await persistLineup(
|
||||
slabId,
|
||||
kept.map((p) => ({
|
||||
id: p.id,
|
||||
role: p.role,
|
||||
isCalibration: p.isCalibration,
|
||||
original: p.original,
|
||||
processed: p.processed,
|
||||
})),
|
||||
removed,
|
||||
);
|
||||
onPublish();
|
||||
} catch (e) {
|
||||
persistError = e instanceof ApiError ? e.message : 'Could not save the photos. Try again.';
|
||||
} finally {
|
||||
persistBusy = false;
|
||||
}
|
||||
}
|
||||
|
||||
function hydrate(s: Slab, t: Taxonomy | null) {
|
||||
speciesId = s.species_id ?? '';
|
||||
woodCatIds = [...(s.wood_category_ids ?? [])];
|
||||
@@ -323,6 +464,7 @@
|
||||
deriveOptions();
|
||||
hydrate(s, t);
|
||||
canGenerateContent = !!set.content_llm_enabled;
|
||||
await loadPhotos();
|
||||
await commitReview();
|
||||
} catch (e) {
|
||||
loadError = e instanceof ApiError ? e.message : 'Could not load the slab.';
|
||||
@@ -331,17 +473,9 @@
|
||||
}
|
||||
});
|
||||
|
||||
function goPublish() {
|
||||
onPublish();
|
||||
}
|
||||
|
||||
function goBack() {
|
||||
onBack();
|
||||
}
|
||||
|
||||
function publish() {
|
||||
goPublish();
|
||||
}
|
||||
</script>
|
||||
|
||||
{#if loading}
|
||||
@@ -365,6 +499,47 @@
|
||||
<div><dt>Sqft / Bdft</dt><dd>{fmt2(capture?.sqft ?? slab?.sqft ?? 0)} / {fmt2(capture?.bdft ?? slab?.bdft ?? 0)}</dd></div>
|
||||
</dl>
|
||||
|
||||
<!-- #90 Photo strip: keep/remove + drag-reorder; leftmost kept = primary. -->
|
||||
{#if photosLoaded && photos.length}
|
||||
<div class="photos" data-testid="photo-strip">
|
||||
<p class="ph-hint">Tap a photo to keep or remove it · drag to reorder · leftmost photo = primary</p>
|
||||
<div class="ph-grid">
|
||||
{#each photos as p, i (p.id)}
|
||||
<div
|
||||
class="pcard"
|
||||
class:kept={p.kept}
|
||||
class:primary={p.id === primaryId}
|
||||
data-testid="photo-card"
|
||||
data-primary={p.id === primaryId ? 'true' : ''}
|
||||
role="button"
|
||||
tabindex="0"
|
||||
aria-pressed={p.kept}
|
||||
aria-label="{roleLabel(p.role)}{p.kept ? ', kept' : ', removed'}"
|
||||
draggable="true"
|
||||
on:click={() => toggleKeep(p.id)}
|
||||
on:keydown={(e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); toggleKeep(p.id); } }}
|
||||
on:dragstart={(e) => onDragStart(e, i)}
|
||||
on:dragover={onDragOver}
|
||||
on:drop={(e) => onDrop(e, i)}
|
||||
>
|
||||
<img class="ph-thumb" src={p.url} alt={roleLabel(p.role)} draggable="false" />
|
||||
{#if p.id === primaryId}
|
||||
<span class="ph-pri">PRIMARY</span>
|
||||
{/if}
|
||||
<span class="ph-seq" aria-hidden="true">{i + 1}</span>
|
||||
<span class="ph-keep" aria-hidden="true"></span>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{#if lineupMissing.length}
|
||||
<p class="ph-nudge" data-testid="lineup-nudge" role="alert">
|
||||
Keep at least {lineupMissing.map(roleLabel).join(' and ')} — required to publish.
|
||||
</p>
|
||||
{/if}
|
||||
{#if persistError}<p class="nudge" role="alert">{persistError}</p>{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if settings?.inference_enabled}
|
||||
<div class="aibar">
|
||||
<button type="button" class="ai-btn" data-testid="run-inference" on:click={runInference} disabled={aiBusy}>
|
||||
@@ -537,13 +712,16 @@
|
||||
{#if reviewBusy}
|
||||
<div class="status" data-testid="review-busy" aria-busy="true">Checking readiness…</div>
|
||||
{:else if review}
|
||||
<div class="status" class:ok={ready()} data-testid="ready-status">
|
||||
{ready()
|
||||
<div class="status" class:ok={ready() && lineupValid} data-testid="ready-status">
|
||||
{ready() && lineupValid
|
||||
? 'Ready to publish'
|
||||
: `Not ready — ${review.missing.length} field${review.missing.length === 1 ? '' : 's'} remaining`}
|
||||
: `Not ready — ${[
|
||||
...review.missing,
|
||||
...lineupMissing.map(roleLabel),
|
||||
].length} field${[...review.missing, ...lineupMissing.map(roleLabel)].length === 1 ? '' : 's'} remaining`}
|
||||
</div>
|
||||
{#if !ready()}
|
||||
<p class="missing" data-testid="missing-list">Missing: {review.missing.join(', ')}</p>
|
||||
{#if !(ready() && lineupValid)}
|
||||
<p class="missing" data-testid="missing-list">Missing: {[...review.missing, ...lineupMissing.map(roleLabel)].join(', ')}</p>
|
||||
{/if}
|
||||
{/if}
|
||||
{#if reviewError}<p class="nudge" role="alert">{reviewError}</p>{/if}
|
||||
@@ -554,10 +732,10 @@
|
||||
type="button"
|
||||
class="primary"
|
||||
data-testid="continue-publish"
|
||||
disabled={publishDisabled}
|
||||
on:click={publish}
|
||||
disabled={publishDisabled || !lineupValid || persistBusy}
|
||||
on:click={goPublish}
|
||||
>
|
||||
Continue to Publish
|
||||
{persistBusy ? 'Saving photos…' : 'Continue to Publish'}
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
@@ -594,4 +772,23 @@
|
||||
.primary:disabled { background: #333; color: #777; }
|
||||
.ghost { padding: 14px 18px; border-radius: 3px; border: 1px solid #444; background: transparent; color: #ccc; }
|
||||
.err { color: #e88; } .busy { color: #aaa; }
|
||||
|
||||
/* #90 Photo strip */
|
||||
.photos { margin: 14px 0 4px; }
|
||||
.ph-hint { color: #8a8a8a; font-size: .72rem; margin: 0 0 8px; }
|
||||
.ph-grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 8px; }
|
||||
@media (max-width: 420px) { .ph-grid { grid-template-columns: repeat(2, 1fr); } }
|
||||
.pcard { position: relative; aspect-ratio: 1; border: 2px solid #2a2a2a; border-radius: 3px; overflow: hidden; cursor: grab; background: #141414; }
|
||||
.pcard:active { cursor: grabbing; }
|
||||
.pcard:focus-visible { outline: 2px solid #00e5ff; outline-offset: 2px; }
|
||||
.pcard.kept { border-color: #444; }
|
||||
.pcard.primary { border-color: #00e5ff; }
|
||||
.pcard:not(.kept) { opacity: .4; }
|
||||
.ph-thumb { width: 100%; height: 100%; object-fit: cover; display: block; pointer-events: none; }
|
||||
.ph-pri { position: absolute; top: 6px; right: 6px; background: #00e5ff; color: #002; font-size: .56rem; font-weight: 800; padding: 1px 5px; border-radius: 2px; letter-spacing: .03em; pointer-events: none; }
|
||||
.ph-seq { position: absolute; bottom: 6px; left: 6px; width: 18px; height: 18px; border-radius: 50%; background: rgba(0,0,0,.7); color: #fff; font-size: .66rem; display: flex; align-items: center; justify-content: center; pointer-events: none; }
|
||||
.ph-keep { position: absolute; top: 6px; left: 6px; width: 16px; height: 16px; border: 2px solid #fff; border-radius: 3px; background: rgba(0,0,0,.5); pointer-events: none; }
|
||||
.pcard.kept .ph-keep { background: #00e5ff; border-color: #00e5ff; }
|
||||
.pcard.kept .ph-keep::after { content: ''; position: absolute; left: 4px; top: 1px; width: 4px; height: 8px; border: solid #002; border-width: 0 2px 2px 0; transform: rotate(45deg); }
|
||||
.ph-nudge { color: #e8a; font-size: .74rem; margin: 8px 0 0; }
|
||||
</style>
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { test } from 'node:test';
|
||||
import {
|
||||
keptInOrder,
|
||||
moveItem,
|
||||
missingMinimums,
|
||||
lineupValid,
|
||||
roleLabel,
|
||||
type LineupItem,
|
||||
} from './lineup';
|
||||
|
||||
const p = (id: string, role: LineupItem['role'], kept = true): LineupItem => ({
|
||||
id,
|
||||
role,
|
||||
isCalibration: role === 'calibration',
|
||||
kept,
|
||||
});
|
||||
|
||||
test('lineup: keptInOrder returns kept photos in their current order', () => {
|
||||
const items = [p('cal', 'calibration'), p('ov', 'overlay', false), p('b1', 'batch')];
|
||||
assert.deepEqual(keptInOrder(items).map((x) => x.id), ['cal', 'b1']);
|
||||
});
|
||||
|
||||
test('lineup: leftmost kept is the primary', () => {
|
||||
const items = [p('b1', 'batch'), p('cal', 'calibration'), p('ov', 'overlay')];
|
||||
// After a drag the owner re-ordered; primary follows position, not role.
|
||||
assert.equal(keptInOrder(items)[0].id, 'b1');
|
||||
});
|
||||
|
||||
test('lineup: primary auto-promotes when the leftmost kept is removed', () => {
|
||||
const items = [p('cal', 'calibration', false), p('ov', 'overlay'), p('b1', 'batch')];
|
||||
assert.equal(keptInOrder(items)[0].id, 'ov');
|
||||
});
|
||||
|
||||
test('lineup: moveItem reorders (drag-to-front) and is a no-op on same index', () => {
|
||||
const a = ['x', 'y', 'z'];
|
||||
assert.deepEqual(moveItem(a, 2, 0), ['z', 'x', 'y']);
|
||||
assert.deepEqual(moveItem(a, 0, 0), ['x', 'y', 'z']);
|
||||
// Out-of-range is a no-op, never throws.
|
||||
assert.deepEqual(moveItem(a, 0, 5), ['x', 'y', 'z']);
|
||||
// Returns a new array, does not mutate the input.
|
||||
const before = a.slice();
|
||||
moveItem(a, 0, 2);
|
||||
assert.deepEqual(a, before);
|
||||
});
|
||||
|
||||
test('lineup: minimum requires a clean AND an overlay copy (REVIEW-5)', () => {
|
||||
assert.deepEqual(missingMinimums([p('cal', 'calibration'), p('ov', 'overlay')]), []);
|
||||
assert.deepEqual(missingMinimums([p('cal', 'calibration')]), ['overlay']);
|
||||
assert.deepEqual(missingMinimums([p('ov', 'overlay')]), ['calibration']);
|
||||
assert.deepEqual(missingMinimums([p('b1', 'batch')]), ['calibration', 'overlay']);
|
||||
assert.deepEqual(missingMinimums([]), ['calibration', 'overlay']);
|
||||
});
|
||||
|
||||
test('lineup: batch-only is not valid; cal+overlay+batch is', () => {
|
||||
assert.equal(lineupValid([p('b1', 'batch')]), false);
|
||||
assert.equal(lineupValid([p('cal', 'calibration'), p('ov', 'overlay'), p('b1', 'batch')]), true);
|
||||
});
|
||||
|
||||
test('lineup: missingMinimums only counts KEPT photos', () => {
|
||||
// Both roles present in the array, but the cal is not kept.
|
||||
assert.deepEqual(
|
||||
missingMinimums([p('cal', 'calibration', false), p('ov', 'overlay')]),
|
||||
['calibration'],
|
||||
);
|
||||
});
|
||||
|
||||
test('lineup: roleLabel is owner-facing', () => {
|
||||
assert.match(roleLabel('calibration'), /clean/);
|
||||
assert.match(roleLabel('overlay'), /overlay/);
|
||||
});
|
||||
@@ -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';
|
||||
}
|
||||
Reference in New Issue
Block a user