feat(greenfield): rebuild SlabUploader M1-M3 on clean foundation, all gates green
Clean re-implementation of the full product (not a port of the old M3 tree): - P0 foundation: 13-table schema + per-call sqlite factory (thread-safe), FastAPI app (health, JSON logging + secret redaction, CORS, startup guard). - P1 M1 Capture: mask/sheetKeep, geometry/rect (min-area rect), measure, png crop/inventory/overlay, EXIF decodeOrientedBitmap. - P2 M2 Photos: stateful capture session (batch, pick-cal, apply mask/crop to batch) + photos-ready lineup. - P3 M3 Create: real backend (crypto, woo client, taxonomy sync, pricing, inference Call1/Call2, review, publish, settings, slabs, photos) + real FE review/publish/settings screens consuming the API (no hardcoded Woo ids). - P4 gate exit: check_requirements + check_coverage both exit 0 (137/155 IDs, 0 gaps); backend 130/130, frontend 150/150, build green. Cite: docs/REQUIREMENTS.md (155 rows), docs/MILESTONES.md M1-M3.
This commit is contained in:
@@ -0,0 +1,191 @@
|
||||
<script lang="ts">
|
||||
import { createEventDispatcher, onMount } from 'svelte';
|
||||
import { apiFetch } from '../lib/api';
|
||||
|
||||
const dispatch = createEventDispatcher<{ close: void }>();
|
||||
|
||||
interface SecretState {
|
||||
configured: boolean;
|
||||
}
|
||||
interface Settings {
|
||||
woo_base_url: string;
|
||||
woo_wp_username: string | null;
|
||||
woo_app_password: SecretState | string;
|
||||
inference_base_url: SecretState | string;
|
||||
inference_api_key: SecretState | string;
|
||||
inference_model: string | null;
|
||||
inference_enabled: boolean;
|
||||
content_llm_enabled: boolean;
|
||||
woo_create_status: string;
|
||||
brand_voice: string | null;
|
||||
geo_context: string | null;
|
||||
crop_fill: number;
|
||||
taxonomy_anchor: string | null;
|
||||
taxonomy_last_sync_at: string | null;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
let settings: Settings | null = null;
|
||||
let saveMsg = '';
|
||||
let saveError = '';
|
||||
let saving = false;
|
||||
let testMsg = '';
|
||||
let testErr = '';
|
||||
let testing = '' as '' | 'woo' | 'inference';
|
||||
|
||||
let wooUser = '';
|
||||
let wooPass = '';
|
||||
let infBase = '';
|
||||
let infKey = '';
|
||||
let infModel = '';
|
||||
let infEnabled = false;
|
||||
let contentEnabled = false;
|
||||
let createStatus = 'draft';
|
||||
let brandVoice = '';
|
||||
let geoContext = '';
|
||||
let cropFill = 90;
|
||||
|
||||
$: configured = (v: SecretState | string | null | undefined): boolean =>
|
||||
typeof v === 'object' && v !== null ? v.configured : Boolean(v);
|
||||
|
||||
async function load() {
|
||||
try {
|
||||
const s = await apiFetch<Settings>('/settings');
|
||||
settings = s;
|
||||
wooUser = s.woo_wp_username ?? '';
|
||||
infModel = s.inference_model ?? '';
|
||||
infEnabled = s.inference_enabled;
|
||||
contentEnabled = s.content_llm_enabled;
|
||||
createStatus = s.woo_create_status || 'draft';
|
||||
brandVoice = s.brand_voice ?? '';
|
||||
geoContext = s.geo_context ?? '';
|
||||
cropFill = s.crop_fill ?? 90;
|
||||
} catch (err) {
|
||||
saveError = err instanceof Error ? err.message : 'Failed to load settings';
|
||||
}
|
||||
}
|
||||
|
||||
async function save() {
|
||||
saving = true;
|
||||
saveMsg = '';
|
||||
saveError = '';
|
||||
const payload: Record<string, unknown> = {
|
||||
woo_wp_username: wooUser,
|
||||
inference_model: infModel,
|
||||
inference_enabled: infEnabled,
|
||||
content_llm_enabled: contentEnabled,
|
||||
woo_create_status: createStatus,
|
||||
brand_voice: brandVoice,
|
||||
geo_context: geoContext,
|
||||
crop_fill: cropFill,
|
||||
};
|
||||
if (wooPass) payload.woo_app_password = wooPass;
|
||||
if (infBase) payload.inference_base_url = infBase;
|
||||
if (infKey) payload.inference_api_key = infKey;
|
||||
try {
|
||||
settings = await apiFetch<Settings>('/settings', { method: 'PUT', body: JSON.stringify(payload) });
|
||||
wooPass = '';
|
||||
infBase = '';
|
||||
infKey = '';
|
||||
saveMsg = 'Saved.';
|
||||
} catch (err) {
|
||||
saveError = err instanceof Error ? err.message : 'Save failed';
|
||||
} finally {
|
||||
saving = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function runTest(which: 'woo' | 'inference') {
|
||||
testing = which;
|
||||
testMsg = '';
|
||||
testErr = '';
|
||||
const path = which === 'woo' ? '/settings/test-woo' : '/admin/test-inference';
|
||||
try {
|
||||
const res = await apiFetch<Record<string, unknown>>(path, { method: 'POST' });
|
||||
testMsg = which === 'woo'
|
||||
? `Woo: ok=${String(res.ok)} — ${String(res.detail ?? '')}`
|
||||
: `Inference: ok=${String(res.ok)} — ${String(res.detail ?? '')}`;
|
||||
} catch (err) {
|
||||
testErr = err instanceof Error ? err.message : 'Test failed';
|
||||
} finally {
|
||||
testing = '';
|
||||
}
|
||||
}
|
||||
|
||||
onMount(load);
|
||||
</script>
|
||||
|
||||
<section>
|
||||
<h2>Settings (SETTINGS)</h2>
|
||||
{#if !settings}
|
||||
<p>{saveError || 'Loading…'}</p>
|
||||
{:else}
|
||||
<div class="grid">
|
||||
<fieldset>
|
||||
<legend>Woo (SETTINGS-10/25/75)</legend>
|
||||
<p>Base URL (env-only, SETTINGS-85): <code>{settings.woo_base_url || 'not set'}</code></p>
|
||||
<label>WP username <input bind:value={wooUser} /></label>
|
||||
<label>App password {configured(settings.woo_app_password) ? '(set — leave blank to keep)' : '(not set)'}
|
||||
<input type="password" bind:value={wooPass} autocomplete="new-password" />
|
||||
</label>
|
||||
<label>Create status
|
||||
<select bind:value={createStatus}>
|
||||
<option value="draft">draft</option>
|
||||
<option value="publish">publish</option>
|
||||
</select>
|
||||
</label>
|
||||
</fieldset>
|
||||
|
||||
<fieldset>
|
||||
<legend>Inference (SETTINGS-30/45/90)</legend>
|
||||
<label>Base URL {configured(settings.inference_base_url) ? '(set — leave blank to keep)' : '(not set)'}
|
||||
<input bind:value={infBase} placeholder="https://…" />
|
||||
</label>
|
||||
<label>API key {configured(settings.inference_api_key) ? '(set — leave blank to keep)' : '(not set)'}
|
||||
<input type="password" bind:value={infKey} autocomplete="new-password" />
|
||||
</label>
|
||||
<label>Model <input bind:value={infModel} placeholder="gpt-4o-mini" /></label>
|
||||
<label><input type="checkbox" bind:checked={infEnabled} /> Enable Call 1 (vision classification)</label>
|
||||
<label><input type="checkbox" bind:checked={contentEnabled} /> Enable Call 2 (long description)</label>
|
||||
</fieldset>
|
||||
|
||||
<fieldset>
|
||||
<legend>Store copy & crop</legend>
|
||||
<label>Brand voice <input bind:value={brandVoice} /></label>
|
||||
<label>Geo context <input bind:value={geoContext} placeholder="Whidbey Island, WA" /></label>
|
||||
<label>Crop fill % (1–100) <input type="number" min="1" max="100" bind:value={cropFill} /></label>
|
||||
</fieldset>
|
||||
</div>
|
||||
|
||||
<div class="meta">
|
||||
<small>Taxonomy: {settings.taxonomy_last_sync_at ?? 'never synced'}{settings.taxonomy_anchor ? ` (anchor ${String(settings.taxonomy_anchor).slice(0, 8)}…)` : ''}</small>
|
||||
</div>
|
||||
|
||||
<div class="actions">
|
||||
<button on:click={save} disabled={saving}>{saving ? 'Saving…' : 'Save'}</button>
|
||||
<button on:click={() => runTest('woo')} disabled={testing !== ''}>Test Woo {testing === 'woo' ? '…' : ''}</button>
|
||||
<button on:click={() => runTest('inference')} disabled={testing !== ''}>Test inference {testing === 'inference' ? '…' : ''}</button>
|
||||
<button on:click={() => dispatch('close')}>Close</button>
|
||||
</div>
|
||||
|
||||
{#if saveMsg}<p class="ok">{saveMsg}</p>{/if}
|
||||
{#if saveError}<p class="err">{saveError}</p>{/if}
|
||||
{#if testMsg}<p class="ok">{testMsg}</p>{/if}
|
||||
{#if testErr}<p class="err">{testErr}</p>{/if}
|
||||
|
||||
<small>Secrets are write-only: GET shows a configured flag only (SETTINGS-75). Re-enter a secret to replace it.</small>
|
||||
{/if}
|
||||
</section>
|
||||
|
||||
<style>
|
||||
.grid { display:grid; grid-template-columns:repeat(auto-fit,minmax(260px,1fr)); gap:1rem; }
|
||||
fieldset { border:1px solid #333; border-radius:4px; padding:0.75rem; }
|
||||
legend { color:#888; font-size:0.85rem; }
|
||||
label { display:block; font-size:0.9rem; margin:0.4rem 0; }
|
||||
input,select { width:100%; padding:0.35rem; background:#222; color:#eee; border:1px solid #555; margin-top:0.2rem; box-sizing:border-box; }
|
||||
code { color:#0af; }
|
||||
.actions { display:flex; gap:0.5rem; margin-top:1rem; flex-wrap:wrap; }
|
||||
.ok { color:#0a7; }
|
||||
.err { color:#f88; }
|
||||
.meta { margin-top:0.75rem; }
|
||||
</style>
|
||||
Reference in New Issue
Block a user