← back to Whatsmystyle
yolo tick 27: duel prefetch (next_pair returned + client Image() preload) + /brands index page (38 brands sorted by count, tier badges) + bulk closet upload (cap 50, single transaction)
12e6669fff1cf944c6226878e9542ad363133c46 · 2026-05-12 15:01:03 -0700 · SteveStudio2
Files touched
A public/brands.htmlM public/index.htmlM public/js/app.jsM server.js
Diff
commit 12e6669fff1cf944c6226878e9542ad363133c46
Author: SteveStudio2 <stevestudio2@SteveStacStudio.lan>
Date: Tue May 12 15:01:03 2026 -0700
yolo tick 27: duel prefetch (next_pair returned + client Image() preload) + /brands index page (38 brands sorted by count, tier badges) + bulk closet upload (cap 50, single transaction)
---
public/brands.html | 58 ++++++++++++++++++++++++++++++++++++++++++
public/index.html | 4 ++-
public/js/app.js | 40 ++++++++++++++++++++++++++---
server.js | 74 +++++++++++++++++++++++++++++++++++++++++++++++++++++-
4 files changed, 171 insertions(+), 5 deletions(-)
diff --git a/public/brands.html b/public/brands.html
new file mode 100644
index 0000000..b3c6377
--- /dev/null
+++ b/public/brands.html
@@ -0,0 +1,58 @@
+<!doctype html>
+<html lang="en">
+<head>
+ <meta charset="utf-8" />
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
+ <title>Brands — WhatsMyStyle</title>
+ <link rel="stylesheet" href="/css/app.css" />
+ <style>
+ body { font-family: ui-sans-serif, system-ui, -apple-system, sans-serif; background: #faf7f2; color: #1d1d1f; max-width: 1100px; margin: 40px auto; padding: 0 24px; }
+ h1 { font-size: 32px; font-weight: 600; letter-spacing: -0.02em; margin: 0 0 8px; }
+ .sub { color: #707070; margin-bottom: 28px; font-size: 14px; }
+ .nav { margin-bottom: 18px; }
+ .nav a { color: #707070; text-decoration: none; font-size: 14px; }
+ .nav a:hover { color: #1d1d1f; }
+ .brand-grid { display: grid; gap: 12px; grid-template-columns: repeat(auto-fill, minmax(220px, 1fr)); }
+ .brand-card {
+ background: #fff; border: 1px solid #e6e1d8; border-radius: 16px;
+ padding: 18px 22px; text-decoration: none; color: inherit;
+ display: flex; align-items: baseline; justify-content: space-between; gap: 12px;
+ transition: background 0.15s;
+ }
+ .brand-card:hover { background: #f9f5ed; border-color: #c8c1b3; }
+ .brand-card .name { font-size: 16px; font-weight: 600; letter-spacing: -0.01em; }
+ .brand-card .count { font-size: 12px; color: #707070; font-variant-numeric: tabular-nums; }
+ .brand-card .tier {
+ font-size: 11px; padding: 2px 8px; border-radius: 999px;
+ background: #f3eee2; color: #6e4f0d; margin-top: 4px;
+ }
+ .brand-card .tier-5 { background: #d4f4d4; color: #1d5e1d; }
+ .brand-card .tier-1 { background: #fbe4e4; color: #7a1717; }
+ </style>
+</head>
+<body>
+ <div class="nav"><a href="/">← back to app</a></div>
+ <h1>Brands</h1>
+ <p class="sub" id="sub">Every brand in our catalog, sorted by piece count.</p>
+ <div id="grid" class="brand-grid"></div>
+
+ <script>
+ const $ = s => document.querySelector(s);
+ function escapeHtml(s) { return String(s ?? '').replace(/[&<>"']/g, c => ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c])); }
+ (async function () {
+ const r = await fetch('/api/brands');
+ const j = await r.json();
+ $('#sub').textContent = `${j.brands.length} brand${j.brands.length === 1 ? '' : 's'} · sorted by piece count.`;
+ $('#grid').innerHTML = j.brands.map(b => `
+ <a class="brand-card" href="/?brand=${encodeURIComponent(b.brand)}" onclick="event.preventDefault();const u=new URL(location.origin);u.searchParams.set('brand',this.dataset.brand||'${escapeHtml(b.brand)}');location.href=u.toString();" data-brand="${escapeHtml(b.brand)}">
+ <div>
+ <div class="name">${escapeHtml(b.brand)}</div>
+ ${b.sustain_tier ? `<span class="tier tier-${b.sustain_tier}">♻ ${b.sustain_tier}/5</span>` : ''}
+ </div>
+ <div class="count">${b.item_count} pcs</div>
+ </a>
+ `).join('');
+ })();
+ </script>
+</body>
+</html>
diff --git a/public/index.html b/public/index.html
index 09b1539..51e7a05 100644
--- a/public/index.html
+++ b/public/index.html
@@ -39,6 +39,7 @@
<a href="#" data-screen="studio">Studio</a>
<a href="#" data-screen="tryons">My Try-Ons</a>
<a href="#" data-screen="favorites">My Favorites</a>
+ <a href="/brands">Brands</a>
<a href="#" data-screen="productions" data-role-only="set_decorator">My Productions</a>
<a href="#" data-screen="couple" data-role-only="couples_pilot">Couple</a>
<a href="/admin-config" data-role-only="admin">Admin Config</a>
@@ -104,11 +105,12 @@
<h2>My Closet</h2>
<p class="muted" id="closet-empty-state">Upload a photo of your closet — we'll catalogue each piece automatically.</p>
<form id="closet-form">
- <input type="file" name="photo" accept="image/*" capture="environment">
+ <input type="file" name="photo" accept="image/*" capture="environment" multiple>
<select name="production_id" id="closet-prod-select" data-role-only="set_decorator" hidden>
<option value="">— untagged —</option>
</select>
<button type="submit" id="closet-upload-btn">Upload</button>
+ <span id="closet-upload-status" class="muted"></span>
</form>
<div id="closet-grid" class="grid"></div>
</section>
diff --git a/public/js/app.js b/public/js/app.js
index cb23538..cf4c19c 100644
--- a/public/js/app.js
+++ b/public/js/app.js
@@ -744,6 +744,17 @@ async function loadDuel() {
$('#duel-q').textContent = j.question;
paintCard('a', j.a);
paintCard('b', j.b);
+ // Tick 27: prefetch next pair's images so the next tap is instant.
+ if (j.next_pair) {
+ [j.next_pair.a, j.next_pair.b].forEach(it => {
+ if (it?.image_url) {
+ const img = new Image();
+ img.decoding = 'async';
+ img.referrerPolicy = 'no-referrer';
+ img.src = it.image_url;
+ }
+ });
+ }
}
let avatarReady = false;
@@ -815,9 +826,32 @@ async function seedCatalog() {
// ---------- Closet ---------------------------------------------------------
$('#closet-form').addEventListener('submit', async e => {
e.preventDefault();
- const fd = new FormData(e.target);
- const r = await fetch('/api/closet/photo', { method: 'POST', body: fd });
- if (r.ok) loadCloset();
+ const fileInput = e.target.querySelector('input[type=file]');
+ const files = fileInput?.files || [];
+ const status = $('#closet-upload-status');
+ if (status) status.textContent = '';
+ if (files.length === 0) return;
+ // Tick 27: bulk path when >1 file selected.
+ if (files.length === 1) {
+ const fd = new FormData(e.target);
+ if (status) status.textContent = 'uploading…';
+ const r = await fetch('/api/closet/photo', { method: 'POST', body: fd });
+ if (r.ok) { if (status) status.textContent = 'done.'; loadCloset(); }
+ else if (status) status.textContent = 'failed.';
+ return;
+ }
+ // Multi: re-build FormData with photos[] field name + production_id passthrough.
+ const fd = new FormData();
+ for (const f of files) fd.append('photos', f);
+ const pid = $('#closet-prod-select')?.value;
+ if (pid) fd.append('production_id', pid);
+ if (status) status.textContent = `uploading ${files.length}…`;
+ const r = await fetch('/api/closet/photo/bulk', { method: 'POST', body: fd });
+ if (r.ok) {
+ const j = await r.json();
+ if (status) status.textContent = `uploaded ${j.count}. analyzing in background.`;
+ loadCloset();
+ } else if (status) status.textContent = 'bulk upload failed.';
});
async function loadCloset() {
const r = await fetch('/api/closet').then(r => r.json());
diff --git a/server.js b/server.js
index 10f3ed8..58f884a 100644
--- a/server.js
+++ b/server.js
@@ -624,7 +624,23 @@ app.get('/api/duel', (req, res) => {
const { lastInsertRowid } = db.prepare(
'INSERT INTO duels (user_id, item_a, item_b, question) VALUES (?, ?, ?, ?)'
).run(u.id, pair[0].id, pair[1].id, question);
- res.json({ duel_id: lastInsertRowid, a: pair[0], b: pair[1], question });
+ // Tick 27: prefetch the next pair so the client can preload images and
+ // taps feel instant. Cheap second random pick from the same category
+ // (skipping the items just served). No duels row inserted for the
+ // prefetch — that happens on the next /api/duel call.
+ let nextPair = null;
+ try {
+ const skipIds = [pair[0].id, pair[1].id];
+ const nextSql = cat
+ ? `SELECT id, title, brand, category, color, image_url, product_url, price_cents FROM items WHERE ${IMG_OK} AND category = ? AND id NOT IN (?, ?) ORDER BY RANDOM() LIMIT 2`
+ : `SELECT id, title, brand, category, color, image_url, product_url, price_cents FROM items WHERE ${IMG_OK} AND id NOT IN (?, ?) ORDER BY RANDOM() LIMIT 2`;
+ const prefetch = cat ? db.prepare(nextSql).all(cat, ...skipIds) : db.prepare(nextSql).all(...skipIds);
+ if (prefetch.length === 2) {
+ prefetch.forEach(p => p.sustain = tierFor(p.brand));
+ nextPair = { a: prefetch[0], b: prefetch[1] };
+ }
+ } catch {}
+ res.json({ duel_id: lastInsertRowid, a: pair[0], b: pair[1], question, next_pair: nextPair });
});
app.post('/api/duel', (req, res) => {
@@ -741,6 +757,31 @@ app.get('/api/recommend', (req, res) => {
// Server-side filter on items.brand (case-insensitive match). Sustainability
// tier pulled from scripts/sustainability.js. Bio sourced from brands.json
// (clothing-apis registry) for any registered brand.
+// Tick 27: brand index — all brands present in the catalog with item count +
+// sustain tier. Public, no auth. Sort desc by item count so the heavy hitters
+// surface first.
+app.get('/api/brands', (req, res) => {
+ const rows = db.prepare(
+ `SELECT brand, COUNT(*) c FROM items
+ WHERE brand IS NOT NULL AND brand != ''
+ AND image_url IS NOT NULL AND image_url != ''
+ GROUP BY brand
+ ORDER BY c DESC
+ LIMIT 200`
+ ).all();
+ let tierFor; try { ({ tierFor } = require('./scripts/sustainability')); } catch { tierFor = () => null; }
+ const items = rows.map(r => ({
+ brand: r.brand,
+ item_count: r.c,
+ sustain_tier: tierFor(r.brand),
+ }));
+ res.json({ brands: items });
+});
+
+app.get('/brands', (_req, res) => {
+ res.sendFile(path.join(__dirname, 'public', 'brands.html'));
+});
+
app.get('/api/brand/:name', (req, res) => {
const u = currentUser(req);
const name = decodeURIComponent(req.params.name || '').trim();
@@ -879,6 +920,37 @@ app.get('/api/closet', (req, res) => {
res.json({ items: withCounts });
});
+// Tick 27: bulk-upload — drop a folder of photos at once. Server caps at 50
+// per call, batches the closet inserts in a single transaction. Each row
+// goes through the existing closet-vision drain so categorization happens
+// in the background.
+app.post('/api/closet/photo/bulk', upload.array('photos', 50), (req, res) => {
+ const u = currentUser(req);
+ if (!req.files?.length) return res.status(400).json({ error: 'no files' });
+ let pid = null;
+ const raw = req.body?.production_id;
+ if (raw && raw !== '0' && raw !== '') {
+ const n = Number(raw);
+ if (Number.isFinite(n) && n > 0) {
+ const owned = db.prepare('SELECT 1 FROM productions WHERE id=? AND user_id=?').get(n, u.id);
+ if (owned) pid = n;
+ }
+ }
+ const insert = db.prepare(
+ 'INSERT INTO closet (user_id, photo_path, acquired_via, production_id) VALUES (?, ?, ?, ?)'
+ );
+ const tx = db.transaction((files) => {
+ const ids = [];
+ for (const f of files) {
+ const r = insert.run(u.id, f.path, 'photo', pid);
+ ids.push(r.lastInsertRowid);
+ }
+ return ids;
+ });
+ const ids = tx(req.files);
+ res.json({ ok: true, count: ids.length, closet_ids: ids, production_id: pid });
+});
+
app.post('/api/closet/photo', upload.single('photo'), (req, res) => {
const u = currentUser(req);
if (!req.file) return res.status(400).json({ error: 'no file' });
← e29fe3f yolo tick 26: image-isnull guard on duel/recs/outfit-builder
·
back to Whatsmystyle
·
yolo tick 28: brands sustainability filter pills (All/♻5/♻4/ 636d4e8 →