Files
SlabUploader/frontend/src/App.svelte
T
grokbot_ty 1b5ad112cc fix(capture): warn 300–799px PNG floor; hard-block only below 300
Split FLOOR_WARN_PX=800 / FLOOR_BLOCK_PX=300. Warn-band still encodes and
includes the photo (Photos-ready + batchApply ok+warn). Block excludes
siblings and clean cal. CAPTURE-105 / CAPTURE.md / PHOTOS.md updated.
2026-09-26 09:11:34 -07:00

275 lines
9.6 KiB
Svelte

<script lang="ts">
import { onMount, onDestroy } from 'svelte';
import { supported, uuid } from './browser';
import { createCaptureSession, type CaptureSession } from './capture/session';
import type { Capture, Measures } from './capture/capture.types';
import { eighthsToIn } from './capture/capture.types';
import { buildPhotosReadyLineup } from './capture/photosReadyLineup';
import Resume from './capture/Resume.svelte';
import BatchPhotos from './capture/BatchPhotos.svelte';
import PickCalibration from './capture/PickCalibration.svelte';
import MaskPanel from './capture/MaskPanel.svelte';
import MeasurePanel from './capture/MeasurePanel.svelte';
import Settings from './create/settings/Settings.svelte';
import SlabList from './create/slabs/SlabList.svelte';
import Review from './create/review/Review.svelte';
import Publish from './create/publish/Publish.svelte';
import {
flow, startSaving, enterReview, enterPublish, markDone, backToCapture, backToReview, type CalibratedCapture,
} from './create/flow';
import { createSlab, uploadPhoto, getSettings, ApiError } from './api/slabs';
import { clearDraft } from './draft/idb';
const gate = typeof window !== 'undefined' ? supported() : 'window unavailable';
const session: CaptureSession = createCaptureSession();
let cap: Capture = { k: 'batch', photos: [] };
let busy = false;
let pngUrl: string | null = null;
let showSettings = false;
let showSlabs = false;
let curFlow: import('./create/flow').Flow = { phase: 'capture' };
$: curFlow = $flow;
let saving = false;
let saveError = '';
const unsubCap = session.capture.subscribe((c) => {
cap = c;
pngUrl = c.k === 'measure' ? session.getInventoryUrl() : null;
});
const unsubBusy = session.busy.subscribe((b) => (busy = b));
onMount(() => {
// e2e boot guard: `?noload` skips the resume probe so a test can seed the
// device draft into IndexedDB after the origin exists, then reload without
// ?noload to trigger the Resume prompt deterministically.
const boot = new URLSearchParams(window.location.search);
if (!boot.get('noload')) void session.resumeProbe();
});
onDestroy(() => {
unsubCap();
unsubBusy();
session.dispose();
});
// APP-15: the device draft is cleared only on publish success / "new slab".
function startNewSlab(): void {
void clearDraft().then(() => {
session.startFresh();
backToCapture();
});
}
// C2: Photos-ready → build the calibrated slab, upload the lineup PNGs, then
// hand off to Review. The device draft is retained here (APP-10); it is
// cleared only on publish success (APP-15).
async function continueToReview(): Promise<void> {
const m = session.currentMeasure();
if (!m || !m.results) return;
const r: Measures = m.results;
const capture: CalibratedCapture = {
lengthIn: eighthsToIn(m.lengthEighths),
thicknessIn: eighthsToIn(m.thicknessEighths),
speciesId: m.speciesId,
sqft: r.sqft,
bdft: r.bdft,
widthMinIn: eighthsToIn(r.widths.min),
widthMaxIn: eighthsToIn(r.widths.max),
widthAvgIn: eighthsToIn(r.widths.avg),
sourceBelowBlock: r.sourceBelowBlock,
sourceBelowWarn: r.sourceBelowWarn,
lineup: buildPhotosReadyLineup({
inventoryPng: r.inventoryPng,
overlayPng: r.overlayPng,
batchApply: m.batchApply,
sourceBelowBlock: r.sourceBelowBlock,
sourceBelowWarn: r.sourceBelowWarn,
}),
};
saving = true;
saveError = '';
startSaving();
try {
const id = uuid();
await createSlab({
id,
sku: '',
length_in: capture.lengthIn,
thickness_in: capture.thicknessIn,
sqft: capture.sqft,
bdft: capture.bdft,
width_min_in: capture.widthMinIn,
width_max_in: capture.widthMaxIn,
width_avg_in: capture.widthAvgIn,
species_id: capture.speciesId,
price: null,
price_source: 'recommendation',
content_source: 'manual',
client_rev: 0,
});
// #90 / REVIEW-5: persist the full lineup with correct roles so the Review
// strip matches in-memory blobs by role. The overlay is the dimension-drawn
// copy — it must be persisted (its blob already has the dimensions) and is
// the photo the owner sees with the measurement overlay. Excluding it (the
// old `role !== 'overlay'` filter) dropped the dimension copy from Review.
const pngItems = capture.lineup.filter((it) => it.kind === 'png');
for (let i = 0; i < pngItems.length; i++) {
const it = pngItems[i];
await uploadPhoto(id, {
photoId: uuid(),
seq: i + 1,
isCalibration: it.role === 'calibration',
role: it.role,
original: it.blob,
// The calibration/batch PNGs have no separate processed variant; the
// overlay blob already carries the drawn dimensions.
processed: it.blob,
});
}
enterReview(id, capture);
} catch (e) {
saveError =
e instanceof ApiError ? e.message : 'Could not start the slab. Check your connection and try again.';
backToCapture();
} finally {
saving = false;
}
}
</script>
{#if gate}
<main class="hardstop">
<h1>Unsupported browser</h1>
<p>
SlabUploader needs a modern browser with canvas, workers, and IndexedDB support:
iOS Safari, Android Chrome, or desktop Chrome, Edge, or Firefox.
</p>
<p class="detail">Missing: {gate}</p>
</main>
{:else}
<main class="app">
{#if curFlow.phase === 'review'}
<Review
slabId={curFlow.slabId}
capture={curFlow.capture}
onPublish={() => {
const f = curFlow;
if (f.phase === 'review') enterPublish(f.slabId, f.capture);
}}
onBack={() => backToCapture()}
/>
{:else if curFlow.phase === 'publish'}
<Publish
slabId={curFlow.slabId}
onPublished={(wooId, draft) => markDone(curFlow.slabId, wooId, draft)}
onBack={() => {
const f = curFlow;
if (f.phase === 'publish') backToReview(f.slabId, f.capture);
}}
/>
{:else if curFlow.phase === 'done'}
<section class="panel done" aria-label="Published">
<h1>Published</h1>
<p class="ok">
{#if curFlow.draft}
Created as a UAT draft in the store.
{:else}
Created in the store.
{/if}
</p>
<p>Woo product #{curFlow.wooProductId} · photos and device draft cleared.</p>
<div class="row">
<button type="button" class="primary" data-testid="new-slab" on:click={startNewSlab}>
Start a new slab
</button>
</div>
</section>
{:else if curFlow.phase === 'saving'}
<section class="panel" aria-busy="true">
<h1>Saving slab…</h1>
</section>
{:else}
<!-- capture column (single slab, CAPTURE-25/30) -->
{#if cap.k === 'resume'}
<Resume onContinue={() => session.continueDraft()} onFresh={() => void session.startFresh()} />
{:else if cap.k === 'batch'}
<BatchPhotos
photos={cap.photos}
onAdd={(f) => void session.addToBatch(f)}
onRemove={session.removeFromBatch}
onContinue={session.continueBatch}
/>
{:else if cap.k === 'pickCalibration'}
<PickCalibration
photos={cap.photos}
onPick={session.pickCalibration}
onBack={session.back}
/>
{:else if cap.k === 'mask'}
<MaskPanel
state={cap}
{busy}
setSensitivity={session.setSensitivity}
setSheet={session.setSheet}
approveMask={session.approveMask}
retakes={session.retakes}
back={session.back}
/>
{:else if cap.k === 'measure'}
<MeasurePanel
state={cap}
setLength={session.setLength}
setThickness={session.setThickness}
setSpecies={session.setSpecies}
setAxisAngle={session.setAxisAngle}
setTape={session.setTape}
calculate={session.calculate}
back={session.back}
{pngUrl}
onContinue={saving ? undefined : () => void continueToReview()}
/>
{/if}
{#if saving}
<div class="row"><span class="busy" aria-live="polite">Saving slab…</span></div>
{/if}
{#if saveError}
<div class="save-err" role="alert" data-testid="save-error">{saveError}</div>
{/if}
{/if}
<!-- Separate pages, never in the flow (UX.md §1). -->
<div style="margin-top:1rem;display:flex;gap:8px">
<button on:click={() => (showSettings = !showSettings)} data-testid="open-settings">Settings</button>
<button on:click={() => (showSlabs = !showSlabs)} data-testid="open-slabs">Slabs</button>
</div>
{#if showSettings}
<Settings />
{/if}
{#if showSlabs}
<SlabList />
{/if}
</main>
{/if}
<style>
:global(body) {
margin: 0;
background: #121212;
font-family: system-ui, -apple-system, Segoe UI, sans-serif;
}
.app, .hardstop { max-width: 720px; margin: 0 auto; min-height: 100vh; }
.hardstop { padding: 48px 24px; color: #eee; text-align: center; }
.hardstop h1 { font-size: 1.6rem; }
.detail { color: #888; font-size: 0.85rem; }
.panel { padding: 16px; color: #eee; }
.done .ok { color: #7fd37f; font-weight: 600; }
.row { display: flex; gap: 10px; margin-top: 16px; }
.primary {
flex: 1; padding: 14px; font-size: 1.05rem; border-radius: 10px; border: 0;
background: #00e5ff; color: #002; font-weight: 700;
}
.busy { color: #aaa; }
.save-err { margin-top: 12px; color: #ffb4a2; font-weight: 600; }
</style>