1a50fad0a5
Drop the metadata_locked column + "Save and lock" UI end-to-end. The review endpoint now builds the deterministic title/short_description on every call; the owner's Create title / Create short desc buttons fire the build and write the result (force-overwrite contract). commitReview auto-fills only empty fields (no owner-clobber) and folds the deterministic copy into its single PUT (kills the client_rev 409 cascade). Taken wholesale from the branch: photo strip, wood primary/secondary, server-side thickness band (hidden from UI, passed to Woo on publish), and fixes #80/#82/#83/#84. - backend: remove metadata_locked from schema.sql, db sentinels, slabs.py client fields/exposure, review.py lock action; review always computes title/short_desc - frontend: slabs.ts + Review.svelte drop the lock model, add Create-string workflow + wood primary + photo strip - e2e: m3-create + review-photo-strip rewritten for the Create-string contract; thickness-band test asserts absence (per #90) - deploy: recreate LAN binds (override-m3.yml was absent); verify metadata_locked gone, review builds title, PUT persists Gates: backend 117/117, unit 88/88, tsc clean, e2e 6/6 (m3-create + photo-strip). Live UAT verified at 192.168.1.202:10881.
964 lines
44 KiB
Svelte
964 lines
44 KiB
Svelte
<script lang="ts">
|
||
import { onMount, onDestroy } from 'svelte';
|
||
import {
|
||
getSlab, updateSlab, reviewSlab, getTaxonomy, getSettings, inferTaxon, inferContent,
|
||
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, resolvePhotoUrl,
|
||
type LineupItem,
|
||
} from './lineup';
|
||
import { nextPrimary } from './woodPrimary';
|
||
|
||
// Props handed off by App (one-column flow, UX.md §1).
|
||
export let slabId: string;
|
||
export let capture: CalibratedCapture | null = null;
|
||
export let onPublish: () => void;
|
||
export let onBack: () => void;
|
||
|
||
let slab: Slab | null = null;
|
||
let tax: Taxonomy | null = null;
|
||
let settings: SettingsView | null = null;
|
||
let loadError = '';
|
||
let loading = true;
|
||
|
||
// Picker option types (id + name + whether the AI recommended it).
|
||
interface Opt { id: number; name: string; ai: boolean }
|
||
const byId = <T extends { id: number }>(arr: T[]) => {
|
||
const m = new Map<number, T>();
|
||
for (const x of arr) m.set(x.id, x);
|
||
return m;
|
||
};
|
||
|
||
// --- Derived taxonomy option lists (resolve by slug, never hardcoded ids) ---
|
||
let speciesOpts: Opt[] = [];
|
||
let woodCatGroups: import('./MultiSelect.svelte').Group[] = [];
|
||
let edgeOpts: Opt[] = [];
|
||
let figureOpts: Opt[] = [];
|
||
let gradeOpts: Opt[] = [];
|
||
let moistureOpts: Opt[] = [];
|
||
let figTagOpts: Opt[] = [];
|
||
let featTagOpts: Opt[] = [];
|
||
|
||
function deriveOptions() {
|
||
if (!tax) return;
|
||
const cats: TaxCategory[] = tax.categories;
|
||
// Resolve the two top-level groupers BY SLUG (QUALITY-105 / METADATA-5 — never
|
||
// hardcode live Woo term ids): `species` → species options; `wcat` → wood
|
||
// categories. Both are parent_id:null groupers; their children are the leaves.
|
||
const speciesGrouper = cats.find((c) => c.parent_id === null && c.slug === 'species');
|
||
const wcatGrouper = cats.find((c) => c.parent_id === null && c.slug === 'wcat');
|
||
const childrenOf = (g: TaxCategory | undefined): TaxCategory[] =>
|
||
g ? cats.filter((c) => c.parent_id === g.id) : [];
|
||
// Attribute names are matched case-insensitively (live Woo uses 'Edge type').
|
||
const termsOf = (name: string): Opt[] => {
|
||
const attr = tax!.attributes.find(
|
||
(a) => a.name.toLowerCase() === name.toLowerCase(),
|
||
);
|
||
return (attr?.terms ?? []).map((t) => ({ id: t.id, name: t.name, ai: false }));
|
||
};
|
||
speciesOpts = childrenOf(speciesGrouper).map((c) => ({ id: c.id, name: c.name, ai: false }));
|
||
// Wood categories: parent/child grouped (METADATA-10 — parents group only, never
|
||
// assigned). Each direct child of `wcat` that has its own children becomes a parent
|
||
// group with those children as leaves; a child with no children is a leaf under the
|
||
// (implicit) top group. This keeps the full tree visible with non-selectable parents.
|
||
woodCatGroups = (childrenOf(wcatGrouper)).map((parent) => {
|
||
const kids = cats.filter((c) => c.parent_id === parent.id);
|
||
const leaves: Opt[] = kids.length
|
||
? kids.map((c) => ({ id: c.id, name: c.name, ai: false }))
|
||
: [{ id: parent.id, name: parent.name, ai: false }];
|
||
return { parent: { id: parent.id, name: parent.name }, leaves };
|
||
});
|
||
edgeOpts = termsOf('edge type');
|
||
figureOpts = termsOf('figure');
|
||
gradeOpts = termsOf('grade');
|
||
// No thickness options: the band is derived server-side and never shown here
|
||
// (METADATA-15 / QUALITY-95).
|
||
moistureOpts = termsOf('moisture');
|
||
// fig-* / feat-* product tags (METADATA-30): pulled from the live synced set,
|
||
// filtered by the family the backend keeps. Never invent a prefix — if the
|
||
// store has none, the picker is legitimately empty.
|
||
const tags: TaxTag[] = tax.tags ?? [];
|
||
figTagOpts = tags.filter((t) => t.slug.startsWith('fig')).map((t) => ({ id: t.id, name: t.name, ai: false }));
|
||
featTagOpts = tags.filter((t) => t.slug.startsWith('feat')).map((t) => ({ id: t.id, name: t.name, ai: false }));
|
||
}
|
||
|
||
// --- Editable model (mirrors the live slab) ---
|
||
let speciesId: number | '' = '';
|
||
let woodCatIds: number[] = [];
|
||
let edgeTypeId: number | '' = '';
|
||
let figureIds: number[] = [];
|
||
let gradeId: number | '' = '';
|
||
let moistureId: number | '' = '';
|
||
let figTagIds: number[] = [];
|
||
let featTagIds: number[] = [];
|
||
let primaryCatId: number | '' = '';
|
||
let title = '';
|
||
let shortDescription = '';
|
||
let description = '';
|
||
let contentSource = '';
|
||
let price = '';
|
||
let priceSource = 'recommendation';
|
||
let sku = '';
|
||
|
||
// --- Review (server-derived ready/missing/title/price) ---
|
||
interface ReviewOut {
|
||
ready: boolean; missing: string[]; title: string | null;
|
||
short_description: string | null; price: number | null;
|
||
price_suggestion: number | null; primary_wood_category_id: number | null;
|
||
}
|
||
let review: ReviewOut | null = null;
|
||
let reviewBusy = false;
|
||
let reviewError = '';
|
||
// #89: explicit "Create" actions. `createBusy` covers the deterministic
|
||
// title/short-desc generation; `canCreateTitle` is its gate (species + ≥1 wood
|
||
// category). `canCreateDesc` gates the AI long-description on title+short-desc
|
||
// being populated AND the content LLM being enabled.
|
||
let createBusy = false;
|
||
let createError = '';
|
||
$: canCreateTitle =
|
||
speciesId !== '' && woodCatIds.length > 0;
|
||
$: canCreateDesc =
|
||
canGenerateContent && title.trim() !== '' && shortDescription.trim() !== '';
|
||
// Monotonic token: each commit captures the current value and ignores its
|
||
// result if a newer commit has since started. Without this, two concurrent
|
||
// commits (e.g. a select change racing the SKU blur) resolve out of order and
|
||
// a stale "not ready" review clobbers the final ready one — the
|
||
// continue-to-Publish button stays disabled even though the status reads ready.
|
||
let commitToken = 0;
|
||
|
||
// --- Inference ---
|
||
let aiBusy = false;
|
||
let aiNote = '';
|
||
let genBusy = false;
|
||
let genError = '';
|
||
let canGenerateContent = false;
|
||
let aiRecommendations: number[] = []; // union of AI-suggested ids (for row bubbles)
|
||
|
||
const num = (v: unknown) => (typeof v === 'number' ? v : null);
|
||
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;
|
||
/** True for the mandatory photos (clean calibration + dimension-overlay
|
||
copy). Their keep checkbox is checked and disabled — the owner cannot
|
||
remove them (REVIEW-5 requires both). */
|
||
required: 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;
|
||
};
|
||
|
||
// Object URLs minted from in-memory blobs; revoked on unmount/reload so the
|
||
// <img> src stays valid. A raw Blob assigned to src renders broken — it must
|
||
// be a blob: URL (bug: the first/Primary photo was the one carrying a blob).
|
||
let photoUrls: string[] = [];
|
||
|
||
async function loadPhotos() {
|
||
// Revoke any previous object URLs before rebuilding the strip.
|
||
for (const u of photoUrls) URL.revokeObjectURL(u);
|
||
photoUrls = [];
|
||
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;
|
||
// Prefer the in-memory blob (fresh capture), as an object URL. Otherwise
|
||
// fall back to the stored photo endpoint (a plain string URL).
|
||
const endpoint = photoUrl(slabId, r.id, (r.has_processed ? 'processed' : 'original') as 'original' | 'processed');
|
||
const url = resolvePhotoUrl(blob, endpoint);
|
||
if (blob && blob.size > 0) photoUrls.push(url);
|
||
return {
|
||
id: r.id,
|
||
role,
|
||
isCalibration: r.is_calibration === 1,
|
||
// Mandatory: the clean calibration photo and the dimension-overlay
|
||
// copy. REVIEW-5 requires both to ship, so their keep control is
|
||
// checked + disabled (the owner can't remove them).
|
||
required: role === 'calibration' || role === 'overlay',
|
||
kept: true,
|
||
original: blob ?? new Blob(),
|
||
processed: blob ?? null,
|
||
url,
|
||
};
|
||
});
|
||
photosLoaded = true;
|
||
lineupDirty = false;
|
||
} catch {
|
||
photos = [];
|
||
photosLoaded = false;
|
||
}
|
||
}
|
||
|
||
onDestroy(() => {
|
||
for (const u of photoUrls) URL.revokeObjectURL(u);
|
||
photoUrls = [];
|
||
});
|
||
|
||
// 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) => {
|
||
if (p.id !== id) return p;
|
||
// Mandatory photos (clean cal + dimension-overlay) can't be removed.
|
||
if (p.required) return p;
|
||
return { ...p, kept: !p.kept };
|
||
});
|
||
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 ?? [])];
|
||
edgeTypeId = s.edge_type_term_id ?? '';
|
||
figureIds = [...(s.figure_term_ids ?? [])];
|
||
gradeId = s.grade_term_id ?? '';
|
||
moistureId = s.moisture_term_id ?? '';
|
||
figTagIds = [...(s.fig_tag_ids ?? [])];
|
||
featTagIds = [...(s.feat_tag_ids ?? [])];
|
||
title = s.title ?? '';
|
||
shortDescription = s.short_description ?? '';
|
||
description = s.description ?? '';
|
||
contentSource = s.content_source ?? 'manual';
|
||
price = s.price != null ? String(s.price) : '';
|
||
priceSource = s.price_source ?? 'recommendation';
|
||
sku = s.sku ?? '';
|
||
// REVIEW-15: primary defaults to first selected wood cat; keep if still valid.
|
||
primaryCatId = nextPrimary(woodCatIds, woodCatIds[0] ?? '');
|
||
// Seed AI-recommended markers from stored confidences (>=0.7 means the AI put
|
||
// its weight behind the value the owner kept).
|
||
aiRecommendations = [];
|
||
if (num(s.species_confidence) != null && num(s.species_confidence)! >= 0.7 && s.species_id)
|
||
aiRecommendations.push(s.species_id);
|
||
if (num(s.figure_confidence) != null && num(s.figure_confidence)! >= 0.7)
|
||
aiRecommendations.push(...(s.figure_term_ids ?? []));
|
||
}
|
||
|
||
// AI-marked option views (badge on rows after Call 1 / stored confidences).
|
||
$: speciesOptsView = speciesOpts.map((o) => ({ ...o, ai: aiRecommendations.includes(o.id) }));
|
||
$: woodCatGroupsView = woodCatGroups.map((g) => ({
|
||
parent: g.parent,
|
||
leaves: g.leaves.map((o) => ({ ...o, ai: aiRecommendations.includes(o.id) })),
|
||
}));
|
||
$: figureOptsView = figureOpts.map((o) => ({ ...o, ai: aiRecommendations.includes(o.id) }));
|
||
$: featTagOptsView = featTagOpts.map((o) => ({ ...o, ai: aiRecommendations.includes(o.id) }));
|
||
// Flat leaf name lookup for the primary radio set (REVIEW-15).
|
||
$: woodLeafById = new Map(
|
||
woodCatGroups.flatMap((g) => g.leaves.map((l) => [l.id, l] as [number, Opt])),
|
||
);
|
||
$: primaryOpts = woodCatIds
|
||
.map((id) => woodLeafById.get(id))
|
||
.filter((o): o is Opt => !!o)
|
||
.map((o) => ({ ...o, ai: aiRecommendations.includes(o.id) }));
|
||
|
||
// METADATA-15 / QUALITY-95: the thickness band is derived server-side from the
|
||
// measured thickness against the live synced Woo bands. It is deliberately absent
|
||
// from this screen — no owner control, no read-only echo. The only thickness the
|
||
// owner sees here is the measured value in the summary above.
|
||
const aiIn = (ids: number[]) => (id: number) => ids.includes(id);
|
||
// Persist taxonomy/price/sku edits to the slab (PUT), then recompute ready.
|
||
// #89: the deterministic title/short_description are written here — the server's
|
||
// review returns the build from the current metadata, and if the field is empty
|
||
// we fold the deterministic value into THIS same PUT (no extra request, no rev
|
||
// race). Owner-edited text is never clobbered (only an empty field is filled).
|
||
async function commitReview(): Promise<void> {
|
||
if (!slab) return;
|
||
const myToken = ++commitToken;
|
||
reviewBusy = true;
|
||
reviewError = '';
|
||
try {
|
||
// Run the deterministic review FIRST (reads the current metadata) to get the
|
||
// title/short_description build before we persist.
|
||
const probe = await reviewSlab(slabId, {
|
||
primary_wood_category_id: primaryCatId === '' ? undefined : Number(primaryCatId),
|
||
price: price === '' ? undefined : Number(price),
|
||
sku: sku || undefined,
|
||
});
|
||
if (myToken !== commitToken) return;
|
||
// If the owner hasn't typed a title/short-desc, adopt the deterministic build
|
||
// so this PUT persists it (the Create buttons do the same, on demand).
|
||
if (probe.title && title === '') title = probe.title;
|
||
if (probe.short_description && shortDescription === '') shortDescription = probe.short_description;
|
||
|
||
const patch = {
|
||
species_id: speciesId === '' ? null : Number(speciesId),
|
||
wood_category_ids: woodCatIds,
|
||
edge_type_term_id: edgeTypeId === '' ? null : Number(edgeTypeId),
|
||
figure_term_ids: figureIds,
|
||
grade_term_id: gradeId === '' ? null : Number(gradeId),
|
||
moisture_term_id: moistureId === '' ? null : Number(moistureId),
|
||
fig_tag_ids: figTagIds,
|
||
feat_tag_ids: featTagIds,
|
||
title: title || null,
|
||
short_description: shortDescription || null,
|
||
content_source: contentSource || null,
|
||
price: price === '' ? null : Number(price),
|
||
price_source: priceSource,
|
||
sku: sku || null,
|
||
client_rev: slab.server_rev,
|
||
};
|
||
const fresh = await updateSlab(slabId, patch);
|
||
if (myToken !== commitToken) return; // superseded; don't clobber the latest
|
||
slab = fresh;
|
||
// Re-run the review against the just-persisted row (authoritative ready/missing).
|
||
review = await reviewSlab(slabId, {
|
||
primary_wood_category_id: primaryCatId === '' ? undefined : Number(primaryCatId),
|
||
price: price === '' ? undefined : Number(price),
|
||
sku: sku || undefined,
|
||
});
|
||
if (myToken !== commitToken) return;
|
||
if (review.primary_wood_category_id != null) primaryCatId = review.primary_wood_category_id;
|
||
} catch (e) {
|
||
if (myToken !== commitToken) return;
|
||
reviewError = e instanceof ApiError ? e.message : 'Could not review. Try again.';
|
||
review = null;
|
||
} finally {
|
||
if (myToken === commitToken) reviewBusy = false;
|
||
}
|
||
}
|
||
|
||
// #89: explicit "Create title" / "Create short desc" — fire the deterministic
|
||
// build from the current metadata and write the result into the fields,
|
||
// overwriting whatever was there (empty, owner-typed, or previously generated).
|
||
// That is the whole contract: the button fires the build. commitReview()
|
||
// auto-fills only empty fields; this is the explicit "force overwrite" action.
|
||
async function createTitle(): Promise<void> {
|
||
if (!slab || createBusy) return;
|
||
createBusy = true;
|
||
createError = '';
|
||
try {
|
||
// 1. Run the deterministic review to get title / short_description.
|
||
const res = await reviewSlab(slabId, {
|
||
primary_wood_category_id: primaryCatId === '' ? undefined : Number(primaryCatId),
|
||
});
|
||
if (res.primary_wood_category_id != null) primaryCatId = res.primary_wood_category_id;
|
||
// 2. Write the deterministic values into the bound fields — always overwrite.
|
||
if (res.title) title = res.title;
|
||
if (res.short_description) shortDescription = res.short_description;
|
||
// 3. Persist the generated prose (single PUT, fresh client_rev).
|
||
if ((title || shortDescription) && (title !== (slab as Slab).title || shortDescription !== (slab as Slab).short_description)) {
|
||
const prosePatch = {
|
||
title: title || null,
|
||
short_description: shortDescription || null,
|
||
client_rev: (slab as Slab).server_rev,
|
||
};
|
||
const fresh = await updateSlab(slabId, prosePatch);
|
||
slab = fresh;
|
||
review = await reviewSlab(slabId, {
|
||
primary_wood_category_id: primaryCatId === '' ? undefined : Number(primaryCatId),
|
||
});
|
||
}
|
||
} catch (e) {
|
||
createError = e instanceof ApiError ? e.message : 'Could not build the title. Try again.';
|
||
} finally {
|
||
createBusy = false;
|
||
}
|
||
}
|
||
|
||
// #89: the description textarea commits on blur. commitReview() no longer sends
|
||
// description (see #89), so this persists the owner-typed description explicitly.
|
||
async function commitDescription(): Promise<void> {
|
||
if (!slab) return;
|
||
const myToken = ++commitToken;
|
||
reviewBusy = true;
|
||
reviewError = '';
|
||
try {
|
||
const fresh = await updateSlab(slabId, {
|
||
description: description || null,
|
||
content_source: contentSource || null,
|
||
client_rev: slab.server_rev,
|
||
});
|
||
if (myToken !== commitToken) return;
|
||
slab = fresh;
|
||
review = await reviewSlab(slabId, {
|
||
primary_wood_category_id: primaryCatId === '' ? undefined : Number(primaryCatId),
|
||
});
|
||
} catch (e) {
|
||
if (myToken !== commitToken) return;
|
||
reviewError = e instanceof ApiError ? e.message : 'Could not review. Try again.';
|
||
review = null;
|
||
} finally {
|
||
if (myToken === commitToken) reviewBusy = false;
|
||
}
|
||
}
|
||
|
||
async function runInference() {
|
||
if (!slab) return;
|
||
aiBusy = true;
|
||
aiNote = '';
|
||
try {
|
||
const r = await inferTaxon(slabId);
|
||
const s = r.suggestions;
|
||
const recs: number[] = [];
|
||
if (s.species_id) { speciesId = s.species_id; recs.push(s.species_id); }
|
||
if (s.wood_category_ids) {
|
||
woodCatIds = [...s.wood_category_ids];
|
||
primaryCatId = nextPrimary(woodCatIds, primaryCatId);
|
||
recs.push(...s.wood_category_ids);
|
||
}
|
||
if (s.edge_type_term_id) edgeTypeId = s.edge_type_term_id;
|
||
if (s.figure_term_ids) { figureIds = [...s.figure_term_ids]; recs.push(...s.figure_term_ids); }
|
||
if (s.feature_tag_ids?.length) {
|
||
featTagIds = [...s.feature_tag_ids];
|
||
recs.push(...s.feature_tag_ids);
|
||
}
|
||
// Grade is not AI-suggested (METADATA-40) — owner judgment. Do not auto-fill.
|
||
aiRecommendations = recs;
|
||
aiNote = recs.length ? `AI suggested ${recs.length} field${recs.length === 1 ? '' : 's'} — review & edit below.` : 'No confident AI suggestions.';
|
||
await commitReview();
|
||
} catch (e) {
|
||
aiNote = e instanceof ApiError ? e.message : 'Inference unavailable.';
|
||
} finally {
|
||
aiBusy = false;
|
||
}
|
||
}
|
||
|
||
async function generateContent() {
|
||
if (!slab || !canCreateDesc) return;
|
||
genBusy = true;
|
||
genError = '';
|
||
try {
|
||
const r = await inferContent(slabId);
|
||
description = r.description;
|
||
contentSource = 'llm';
|
||
// commitReview() no longer sends description (see #89), so persist it explicitly.
|
||
const myToken = ++commitToken;
|
||
const fresh = await updateSlab(slabId, {
|
||
description: description || null,
|
||
content_source: contentSource || null,
|
||
client_rev: slab.server_rev,
|
||
});
|
||
if (myToken !== commitToken) return;
|
||
slab = fresh;
|
||
} catch (e) {
|
||
genError = e instanceof ApiError ? e.message : 'Could not generate text.';
|
||
} finally {
|
||
genBusy = false;
|
||
}
|
||
}
|
||
|
||
const missingCount = () => (review ? review.missing.length : null);
|
||
const ready = () => review?.ready === true;
|
||
// The button's enabled state must derive from the SAME reactive `review` object
|
||
// the status text reads. A separate `ready()` function call in the template
|
||
// evaluates in a different reactive flush, leaving `disabled` stale (false→the
|
||
// button stays disabled) even after `review.ready` flips true. Binding
|
||
// `publishDisabled` to `review` (a reactive object) forces the same dependency.
|
||
let publishDisabled = true;
|
||
$: publishDisabled = review ? !review.ready : true;
|
||
|
||
// --- Multi-select list helpers (searchable, squared) ---
|
||
function toggleMulti(arr: number[], id: number): number[] {
|
||
return arr.includes(id) ? arr.filter((x) => x !== id) : [...arr, id];
|
||
}
|
||
|
||
/** Wood categories: toggle leaf + keep primary valid (REVIEW-15 / wireframe #90). */
|
||
function toggleWoodCat(id: number) {
|
||
woodCatIds = toggleMulti(woodCatIds, id);
|
||
primaryCatId = nextPrimary(woodCatIds, primaryCatId);
|
||
}
|
||
|
||
function pickPrimary(id: number) {
|
||
if (!woodCatIds.includes(id)) return;
|
||
primaryCatId = id;
|
||
void commitReview();
|
||
}
|
||
|
||
onMount(async () => {
|
||
try {
|
||
const [s, t, set] = await Promise.all([getSlab(slabId), getTaxonomy(), getSettings()]);
|
||
slab = s; tax = t; settings = set;
|
||
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.';
|
||
} finally {
|
||
loading = false;
|
||
}
|
||
});
|
||
|
||
function goBack() {
|
||
onBack();
|
||
}
|
||
</script>
|
||
|
||
{#if loading}
|
||
<section class="review" aria-busy="true" data-testid="review-loading">
|
||
<h1>Review to Ready</h1>
|
||
<p class="busy">Loading slab…</p>
|
||
</section>
|
||
{:else if loadError}
|
||
<section class="review">
|
||
<h1>Review to Ready</h1>
|
||
<p class="err" role="alert">{loadError}</p>
|
||
<button type="button" class="ghost" data-testid="back-capture" on:click={goBack}>Back to capture</button>
|
||
</section>
|
||
{:else}
|
||
<section class="review" data-testid="review-screen">
|
||
<h1>Review to Ready</h1>
|
||
|
||
<dl class="math">
|
||
<div><dt>Length</dt><dd>{fmt1(capture?.lengthIn ?? slab?.length_in ?? 0)}″</dd></div>
|
||
<div><dt>Thickness</dt><dd>{fmt1(capture?.thicknessIn ?? slab?.thickness_in ?? 0)}″</dd></div>
|
||
<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}
|
||
class:required={p.required}
|
||
data-testid="photo-card"
|
||
data-primary={p.id === primaryId ? 'true' : ''}
|
||
data-required={p.required ? 'true' : ''}
|
||
role="button"
|
||
tabindex="0"
|
||
aria-pressed={p.required ? true : p.kept}
|
||
aria-disabled={p.required ? 'true' : 'false'}
|
||
aria-label="{p.required ? 'Required: ' : ''}{roleLabel(p.role)}{p.required ? ' (cannot be removed)' : (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}
|
||
{#if p.required}
|
||
<span class="ph-req">REQUIRED</span>
|
||
{/if}
|
||
<span class="ph-seq" aria-hidden="true">{i + 1}</span>
|
||
<!-- Keep checkbox: always checked for required photos; a disabled
|
||
glyph (no check) when a batch photo is removed. -->
|
||
<span class="ph-keep" class:required={p.required} 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}>
|
||
{aiBusy ? 'Reading slab…' : 'Suggest with AI'}
|
||
</button>
|
||
{#if aiNote}<p class="ai-note" data-testid="ai-note">{aiNote}</p>{/if}
|
||
</div>
|
||
{/if}
|
||
|
||
<!-- Species (single, AI-assist) -->
|
||
<div class="field">
|
||
<label class="lbl" for="species">Species <span class="ai">AI assist</span></label>
|
||
<select id="species" class="sel" data-testid="species" bind:value={speciesId} on:change={commitReview}>
|
||
<option value="">— select —</option>
|
||
{#each speciesOptsView as o (o.id)}
|
||
<option value={o.id} data-ai={o.ai ? 'true' : ''}>{o.name}{o.ai ? ' (AI)' : ''}</option>
|
||
{/each}
|
||
</select>
|
||
</div>
|
||
|
||
<!-- Wood categories (multi + inline primary radio set, REVIEW-15 / #90 wireframe) -->
|
||
<div class="field" data-testid="wood-cat-field">
|
||
<label class="lbl">Wood categories <span class="ai">AI assist</span></label>
|
||
<MultiSelect
|
||
label="Wood categories"
|
||
groups={woodCatGroupsView}
|
||
selected={woodCatIds}
|
||
placeholder="Choose wood categories"
|
||
testid="wood-cat"
|
||
onToggle={toggleWoodCat}
|
||
on:commit={commitReview}
|
||
/>
|
||
{#if primaryOpts.length}
|
||
<div class="primary-set" data-testid="primary-wood-set" role="radiogroup" aria-label="Primary wood category">
|
||
<div class="ps-lbl">Primary wood category <span class="ps-req">drives title</span></div>
|
||
{#each primaryOpts as o (o.id)}
|
||
<button
|
||
type="button"
|
||
class="ps-opt"
|
||
class:on={primaryCatId === o.id}
|
||
role="radio"
|
||
aria-checked={primaryCatId === o.id}
|
||
data-testid={`primary-wood-${o.id}`}
|
||
on:click={() => pickPrimary(o.id)}
|
||
>
|
||
<span class="ps-radio" aria-hidden="true"></span>
|
||
<span class="ps-name">{o.name}</span>
|
||
{#if o.ai}<span class="ps-tag">AI</span>{/if}
|
||
</button>
|
||
{/each}
|
||
<p class="ps-note">
|
||
Primary selects the title & short-description pattern (METADATA-85).
|
||
Secondary categories feed the description. Default = first selected.
|
||
</p>
|
||
</div>
|
||
{/if}
|
||
</div>
|
||
|
||
<!-- Edge type (single, AI-assist) -->
|
||
<div class="field">
|
||
<label class="lbl" for="edge">Edge type <span class="ai">AI assist</span></label>
|
||
<select id="edge" class="sel" data-testid="edge-type" bind:value={edgeTypeId} on:change={commitReview}>
|
||
<option value="">— select —</option>
|
||
{#each edgeOpts as o (o.id)}
|
||
<option value={o.id}>{o.name}</option>
|
||
{/each}
|
||
</select>
|
||
</div>
|
||
|
||
<!-- Figure (multi, searchable list, AI-assist) -->
|
||
<div class="field">
|
||
<label class="lbl">Figure <span class="ai">AI assist</span></label>
|
||
<MultiSelect
|
||
label="Figure"
|
||
options={figureOptsView}
|
||
selected={figureIds}
|
||
placeholder="Choose figure"
|
||
testid="figure"
|
||
onToggle={(id) => (figureIds = toggleMulti(figureIds, id))}
|
||
on:commit={commitReview}
|
||
/>
|
||
</div>
|
||
|
||
<!-- Grade (single, owner judgment — NOT AI-assisted per METADATA-40) -->
|
||
<div class="field">
|
||
<label class="lbl" for="grade">Grade</label>
|
||
<select id="grade" class="sel" data-testid="grade" bind:value={gradeId} on:change={commitReview}>
|
||
<option value="">— select —</option>
|
||
{#each gradeOpts as o (o.id)}<option value={o.id}>{o.name}</option>{/each}
|
||
</select>
|
||
</div>
|
||
|
||
<!-- fig-* product tags (optional, owner-only, no AI) -->
|
||
<div class="field">
|
||
<label class="lbl">fig-* tags <span class="sug">optional</span></label>
|
||
<MultiSelect
|
||
label="fig-* tags"
|
||
options={figTagOpts}
|
||
selected={figTagIds}
|
||
placeholder="Choose fig-* tags"
|
||
testid="fig-tag"
|
||
onToggle={(id) => (figTagIds = toggleMulti(figTagIds, id))}
|
||
on:commit={commitReview}
|
||
/>
|
||
</div>
|
||
|
||
<!-- feat-* product tags (optional, AI-assisted by Call 1) -->
|
||
<div class="field">
|
||
<label class="lbl">feat-* tags <span class="sug">optional</span> <span class="ai">AI assist</span></label>
|
||
<MultiSelect
|
||
label="feat-* tags"
|
||
options={featTagOptsView}
|
||
selected={featTagIds}
|
||
placeholder="Choose feat-* tags"
|
||
testid="feat-tag"
|
||
onToggle={(id) => (featTagIds = toggleMulti(featTagIds, id))}
|
||
on:commit={commitReview}
|
||
/>
|
||
</div>
|
||
|
||
<div class="field">
|
||
<label class="lbl" for="moisture">Moisture</label>
|
||
<select id="moisture" class="sel" data-testid="moisture" bind:value={moistureId} on:change={commitReview}>
|
||
<option value="">— select —</option>
|
||
{#each moistureOpts as o (o.id)}<option value={o.id}>{o.name}</option>{/each}
|
||
</select>
|
||
</div>
|
||
|
||
<!-- Title / Short description + explicit "Create" build buttons (#89) -->
|
||
<div class="field">
|
||
<label class="lbl" for="title">Title</label>
|
||
<div class="create-row">
|
||
<input id="title" class="inp" data-testid="title" bind:value={title} />
|
||
<button type="button" class="create-btn" data-testid="create-title"
|
||
disabled={!canCreateTitle || createBusy} on:click={createTitle}>
|
||
{createBusy ? 'Building…' : 'Create title'}
|
||
</button>
|
||
</div>
|
||
</div>
|
||
<div class="field">
|
||
<label class="lbl" for="short-desc">Short description</label>
|
||
<div class="create-row">
|
||
<input id="short-desc" class="inp" data-testid="short-desc" bind:value={shortDescription} />
|
||
<button type="button" class="create-btn" data-testid="create-short-desc"
|
||
disabled={!canCreateTitle || createBusy} on:click={createTitle}>
|
||
{createBusy ? 'Building…' : 'Create short desc'}
|
||
</button>
|
||
</div>
|
||
</div>
|
||
<div class="field">
|
||
<label class="lbl" for="desc">Description</label>
|
||
<textarea id="desc" class="inp ta" data-testid="desc" rows="3" bind:value={description} on:blur={commitDescription}></textarea>
|
||
{#if canCreateDesc}
|
||
<div class="gen">
|
||
<button type="button" class="gen-btn" data-testid="create-desc" on:click={generateContent} disabled={genBusy}>
|
||
{genBusy ? 'Writing…' : 'Create desc'}
|
||
</button>
|
||
</div>
|
||
{/if}
|
||
{#if genError}<p class="nudge" role="alert">{genError}</p>{/if}
|
||
{#if createError}<p class="nudge" role="alert">{createError}</p>{/if}
|
||
</div>
|
||
|
||
<!-- Price / SKU (SKU last) -->
|
||
<div class="field">
|
||
<label class="lbl" for="price">Price ($)
|
||
{#if review?.price_suggestion != null}<span class="sug">suggested ${review.price_suggestion.toFixed(2)}</span>{/if}
|
||
</label>
|
||
<input id="price" class="inp" data-testid="price" type="number" step="0.01" bind:value={price} on:change={commitReview} />
|
||
</div>
|
||
<div class="field">
|
||
<label class="lbl" for="sku">SKU <span class="sug">last field, 3–24 chars</span></label>
|
||
<input id="sku" class="inp" data-testid="sku" placeholder="SLAB-…" bind:value={sku} on:blur={commitReview} />
|
||
{#if sku && !/^[A-Za-z0-9-]{3,24}$/.test(sku)}
|
||
<p class="nudge">SKU must be 3–24 alphanumerics or hyphens.</p>
|
||
{/if}
|
||
</div>
|
||
|
||
<!-- Ready status + actions -->
|
||
{#if reviewBusy}
|
||
<div class="status" data-testid="review-busy" aria-busy="true">Checking readiness…</div>
|
||
{:else if review}
|
||
<div class="status" class:ok={ready() && lineupValid} data-testid="ready-status">
|
||
{ready() && lineupValid
|
||
? 'Ready to publish'
|
||
: `Not ready — ${[
|
||
...review.missing,
|
||
...lineupMissing.map(roleLabel),
|
||
].length} field${[...review.missing, ...lineupMissing.map(roleLabel)].length === 1 ? '' : 's'} remaining`}
|
||
</div>
|
||
{#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}
|
||
|
||
<div class="row">
|
||
<button type="button" class="ghost" data-testid="back-capture" on:click={goBack}>Back</button>
|
||
<button
|
||
type="button"
|
||
class="primary"
|
||
data-testid="continue-publish"
|
||
disabled={publishDisabled || !lineupValid || persistBusy}
|
||
on:click={goPublish}
|
||
>
|
||
{persistBusy ? 'Saving photos…' : 'Continue to Publish'}
|
||
</button>
|
||
</div>
|
||
</section>
|
||
{/if}
|
||
|
||
<style>
|
||
.review { max-width: 560px; margin: 0 auto; color: #eee; }
|
||
h1 { font-size: 1.3rem; margin: 0 0 12px; }
|
||
.math div { display: flex; justify-content: space-between; border-bottom: 1px solid #2a2a2a; padding: 5px 0; font-size: .85rem; }
|
||
.math dt { color: #9a9a9a; margin: 0; } .math dd { margin: 0; font-variant-numeric: tabular-nums; }
|
||
.aibar { margin: 12px 0; }
|
||
.ai-btn { padding: 8px 12px; border: 0; border-radius: 3px; background: #00e5ff; color: #002; font-weight: 600; font-size: .85rem; }
|
||
.ai-btn:disabled { background: #333; color: #777; }
|
||
.ai-note { color: #aaa; font-size: .78rem; margin: 6px 0 0; }
|
||
.lbl { display: block; font-size: .8rem; color: #bbb; margin: 0 0 4px; }
|
||
.lbl .ai { margin-left: 6px; font-size: .58rem; font-weight: 700; padding: 1px 5px; border-radius: 2px; background: #00e5ff; color: #002; }
|
||
/* box-sizing: the app has no global reset, so a width:100% control with padding
|
||
and a border pokes out of its container by that padding+border (20px here) —
|
||
which becomes real page overflow whenever the column sits at the viewport edge
|
||
(measured: 375px mobile scrolled 20px). Border-box keeps the box inside. */
|
||
.inp, .sel { box-sizing: border-box; width: 100%; padding: 9px; background: #1d1d1d; border: 1px solid #444; border-radius: 3px; color: #eee; font-size: .9rem; text-align: left; }
|
||
.sel { appearance: none; background-image: linear-gradient(45deg,transparent 50%,#777 50%),linear-gradient(135deg,#777 50%,transparent 50%); background-position: calc(100% - 16px) 55%,calc(100% - 11px) 55%; background-size: 5px 5px; background-repeat: no-repeat; }
|
||
.ta { resize: vertical; }
|
||
.field { margin-bottom: 12px; }
|
||
.nudge { color: #e8a; font-size: .74rem; margin: 4px 0 0; }
|
||
.sug { color: #aaa; font-size: .74rem; font-weight: 400; margin-left: 6px; }
|
||
.gen { margin-top: 8px; } .gen-btn { padding: 8px 12px; border: 0; border-radius: 3px; background: #00e5ff; color: #002; font-weight: 600; font-size: .82rem; }
|
||
/* Lock chrome matches agreed #90 wireframe: muted dark green, not jarring neon. */
|
||
/* #89: explicit "Create" build buttons beside the title / short-desc fields. */
|
||
.create-row { display: flex; gap: 8px; align-items: stretch; }
|
||
.create-row .inp { flex: 1; }
|
||
.create-btn { box-sizing: border-box; padding: 8px 12px; border: 1px solid #333; border-radius: 3px;
|
||
background: #1d1d1d; color: #eee; font-size: .82rem; font-weight: 600; cursor: pointer; white-space: nowrap; }
|
||
.create-btn:hover { background: #242424; }
|
||
.create-btn:disabled { background: #222; color: #666; border-color: #333; cursor: not-allowed; }
|
||
|
||
/* Primary wood category — inline under wood MultiSelect (REVIEW-15 / #90 wireframe). */
|
||
.primary-set { margin-top: 8px; padding: 10px 12px; border: 1px solid #333; border-radius: 3px; background: #141414; }
|
||
.primary-set .ps-lbl { font-size: .74rem; color: #9a9a9a; margin-bottom: 6px; display: flex; align-items: center; gap: 6px; }
|
||
.primary-set .ps-req { font-size: .58rem; font-weight: 700; padding: 1px 5px; border-radius: 2px; background: #2a2a2a; color: #888; }
|
||
.ps-opt { box-sizing: border-box; width: 100%; display: flex; align-items: center; gap: 9px; padding: 8px 6px; margin: 0; border: 0; border-radius: 3px; background: transparent; color: #ddd; font-size: .88rem; cursor: pointer; text-align: left; }
|
||
.ps-opt:hover { background: #1c1c1c; }
|
||
.ps-opt.on { background: #12262b; color: #fff; }
|
||
.ps-opt:focus-visible { outline: 2px solid #00e5ff; outline-offset: 1px; }
|
||
.ps-radio { width: 14px; height: 14px; border: 2px solid #555; border-radius: 50%; flex: none; position: relative; }
|
||
.ps-opt.on .ps-radio { border-color: #00e5ff; }
|
||
.ps-opt.on .ps-radio::after { content: ''; position: absolute; inset: 2px; border-radius: 50%; background: #00e5ff; }
|
||
.ps-name { flex: 1; }
|
||
.ps-tag { font-size: .56rem; font-weight: 700; padding: 0 4px; border-radius: 2px; background: #00e5ff; color: #002; letter-spacing: .3px; }
|
||
.ps-note { font-size: .7rem; color: #777; margin: 6px 0 0; line-height: 1.4; }
|
||
.status { margin-top: 14px; font-weight: 700; padding: 10px 12px; border-radius: 3px; background: #2a1512; color: #ffb4a2; font-size: .9rem; }
|
||
.status.ok { background: #122a12; color: #7fd37f; }
|
||
.missing { color: #999; font-size: .76rem; margin: 6px 0 0; }
|
||
.row { display: flex; gap: 10px; margin-top: 16px; }
|
||
.primary { flex: 1; padding: 14px; font-size: 1rem; border-radius: 3px; border: 0; background: #00e5ff; color: #002; font-weight: 700; }
|
||
.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;
|
||
/* Gray checkerboard, matching the Capture lineup (MeasurePanel .lineup-item.png)
|
||
so transparent inventory PNGs read the same on both screens (bug2). */
|
||
background-color: #eee;
|
||
background-image: linear-gradient(45deg,#ccc 25%,transparent 25%),linear-gradient(-45deg,#ccc 25%,transparent 25%),linear-gradient(45deg,transparent 75%,#ccc 75%),linear-gradient(-45deg,transparent 75%,#ccc 75%);
|
||
background-size: 16px 16px; background-position: 0 0,0 8px,8px -8px,-8px 0; }
|
||
.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-req { position: absolute; top: 6px; left: 50%; transform: translateX(-50%); background: #ffb020; color: #201500; font-size: .52rem; font-weight: 800; padding: 1px 5px; border-radius: 2px; letter-spacing: .04em; 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); }
|
||
/* Mandatory photos (clean cal + dimension-overlay): the keep checkbox is
|
||
checked AND disabled. A bold padlock glyph in the box signals "required —
|
||
cannot be removed", and the card carries a thin required outline so the
|
||
owner can tell mandatory photos apart from optional batch photos. */
|
||
.pcard.required { border-color: #00e5ff55; }
|
||
.pcard.required .ph-keep { background: #00e5ff; border-color: #00e5ff; }
|
||
/* Padlock: shackle (arc) + body, drawn large enough to read on a 16px box. */
|
||
.pcard.required .ph-keep::after {
|
||
content: ''; position: absolute; left: 3px; top: 2px; width: 8px; height: 8px;
|
||
border: 2px solid #002; border-bottom: none; border-radius: 4px 4px 0 0;
|
||
}
|
||
.pcard.required .ph-keep::before {
|
||
content: ''; position: absolute; left: 2.5px; top: 6px; width: 8px; height: 6px;
|
||
background: #002; border-radius: 1px;
|
||
}
|
||
.pcard.required { cursor: default; }
|
||
.ph-nudge { color: #e8a; font-size: .74rem; margin: 8px 0 0; }
|
||
</style>
|