← back to Whatsmystyle
ux(credits): shotonwhat-style item résumé — every try-on/outfit a garment appeared in, with role+occasion+date+collaborators
46330771a8bca31ff74c3007682478552462de72 · 2026-05-12 07:04:58 -0700 · SteveStudio2
Files touched
M public/css/app.cssM public/index.htmlM public/js/app.jsM server.js
Diff
commit 46330771a8bca31ff74c3007682478552462de72
Author: SteveStudio2 <stevestudio2@SteveStacStudio.lan>
Date: Tue May 12 07:04:58 2026 -0700
ux(credits): shotonwhat-style item résumé — every try-on/outfit a garment appeared in, with role+occasion+date+collaborators
---
public/css/app.css | 68 ++++++++++++++++++++++++++++++++++++
public/index.html | 17 +++++++++
public/js/app.js | 65 ++++++++++++++++++++++++++++++++--
server.js | 100 +++++++++++++++++++++++++++++++++++++++++++++++++++++
4 files changed, 248 insertions(+), 2 deletions(-)
diff --git a/public/css/app.css b/public/css/app.css
index 6e8b18b..d4590ca 100644
--- a/public/css/app.css
+++ b/public/css/app.css
@@ -384,5 +384,73 @@ h2 { font-family: var(--font-display); font-weight: 600; font-size: 36px; margin
letter-spacing: .04em;
}
+/* ---- Item Credits (tick 15, shotonwhat.com-style résumé) ---- */
+.credits-header { margin: 16px 0 22px; }
+.credits-header h2 { font-size: 36px; font-weight: 600; letter-spacing: -0.02em; margin: 0 0 6px; }
+.credits-header .dot { margin: 0 8px; color: #999; }
+.back-pill {
+ display: inline-block; padding: 8px 16px; border-radius: 999px;
+ border: 1px solid #d6d0c4; background: #fff; color: #1d1d1f;
+ text-decoration: none; font-size: 14px; margin-bottom: 16px;
+}
+.back-pill:hover { background: #faf7f2; }
+
+.credits-list { display: flex; flex-direction: column; gap: 14px; }
+.credit-row {
+ background: #fff;
+ border: 1px solid #e6e1d8;
+ border-radius: 20px;
+ padding: 18px 22px;
+}
+.credit-row .credit-role {
+ font-size: 13px;
+ text-transform: uppercase;
+ letter-spacing: 0.12em;
+ color: #707070;
+ font-weight: 600;
+ margin: 0 0 6px;
+}
+.credit-row .credit-title {
+ font-size: 18px;
+ font-weight: 500;
+ margin: 0 0 8px;
+ display: flex;
+ flex-wrap: wrap;
+ gap: 10px;
+ align-items: baseline;
+}
+.credit-row .credit-occasion { color: #1d1d1f; }
+.credit-row .credit-year { color: #707070; font-size: 14px; font-weight: 400; }
+.credit-row .credit-meta {
+ display: flex; flex-wrap: wrap; gap: 8px;
+ font-size: 12px; margin: 6px 0 4px;
+}
+.credit-row .credit-meta > span {
+ background: #faf7f2; border: 1px solid #e6e1d8;
+ padding: 3px 10px; border-radius: 999px; color: #555;
+}
+.credit-row .credit-status-done { background: #e6f6ec; border-color: #b7e0c4; color: #14572a; }
+.credit-row .credit-status-failed { background: #fbe4e4; border-color: #e9b8b8; color: #7a1717; }
+.credit-row .credit-with {
+ margin-top: 8px; font-size: 13px; color: #555;
+}
+.credit-row .credit-with > span { color: #1d1d1f; }
+.credits-footnote { margin-top: 32px; font-size: 12px; }
+.credits-footnote a { color: #555; }
+
+/* credits-pill on item cards */
+.credits-pill {
+ display: inline-block;
+ padding: 4px 10px;
+ border: 1px solid #d6d0c4;
+ border-radius: 999px;
+ font-size: 11px;
+ text-decoration: none;
+ color: #555;
+ background: rgba(255,255,255,0.7);
+ margin-top: 6px;
+}
+.credits-pill:hover { background: #fff; color: #1d1d1f; }
+
/* utils */
[hidden] { display: none !important; }
diff --git a/public/index.html b/public/index.html
index 3968f61..bcb2356 100644
--- a/public/index.html
+++ b/public/index.html
@@ -312,6 +312,23 @@
<div id="tt-result"></div>
</section>
+ <!-- Item Credits — shotonwhat-style résumé for a garment (tick 15) -->
+ <section id="credits" class="screen" hidden>
+ <a href="#" id="credits-back" class="back-pill">← back</a>
+ <header class="credits-header">
+ <h2 id="credits-title">Credits</h2>
+ <p class="muted">
+ <span id="credits-brand"></span>
+ <span class="dot">·</span>
+ <span id="credits-count">0</span> appearances
+ </p>
+ </header>
+ <div id="credits-list" class="credits-list"></div>
+ <p class="muted credits-footnote">
+ Inspired by <a href="https://shotonwhat.com" target="_blank" rel="noopener">shotonwhat.com</a> — a single garment's working résumé across every try-on, render, and outfit pick.
+ </p>
+ </section>
+
<!-- Try-on history -->
<section id="tryons" class="screen" hidden>
<h2>My Try-Ons</h2>
diff --git a/public/js/app.js b/public/js/app.js
index 704fc11..fd809ae 100644
--- a/public/js/app.js
+++ b/public/js/app.js
@@ -317,7 +317,7 @@ async function loadCloset() {
(r.items || []).forEach(it => {
const c = document.createElement('div');
c.className = 'card';
- c.innerHTML = `<img src="/api/closet/photo/${it.id}" alt=""><div class="meta"><div class="title">${it.category || 'analyzing…'}</div><div class="brand">${it.color || ''}</div></div>`;
+ c.innerHTML = `<img src="/api/closet/photo/${it.id}" alt=""><div class="meta"><div class="title">${it.category || 'analyzing…'}</div><div class="brand">${it.color || ''}</div><a class="credits-pill" href="#" data-credits-kind="closet" data-credits-id="${it.id}">view credits →</a></div>`;
g.appendChild(c);
});
}
@@ -344,7 +344,7 @@ async function loadRecs() {
a.target = '_blank';
a.rel = 'noopener';
const sustain = it.sustain ? `<span class="sustain" aria-label="Sustainability ${it.sustain} of 5">♻ ${it.sustain}/5</span>` : '';
- a.innerHTML = `<img src="${it.image_url || ''}" alt="${it.title}${it.brand ? ' by ' + it.brand : ''}" loading="lazy"><div class="meta"><div class="title">${it.title}</div><div class="brand">${it.brand || ''}${sustain}</div><div class="price">${it.price_cents ? '$' + (it.price_cents/100).toFixed(0) : ''}</div></div>`;
+ a.innerHTML = `<img src="${it.image_url || ''}" alt="${it.title}${it.brand ? ' by ' + it.brand : ''}" loading="lazy"><div class="meta"><div class="title">${it.title}</div><div class="brand">${it.brand || ''}${sustain}</div><div class="price">${it.price_cents ? '$' + (it.price_cents/100).toFixed(0) : ''}</div><a class="credits-pill" href="#" data-credits-kind="catalog" data-credits-id="${it.id}" onclick="event.stopPropagation();">view credits →</a></div>`;
g.appendChild(a);
});
}
@@ -1156,6 +1156,67 @@ function route(name) {
if (name === 'connect') fetch('/api/connect').then(r => r.json()).then(c => $('#connect-state').textContent = JSON.stringify(c.connections, null, 2));
}
+// ---------- Item credits (shotonwhat-style résumé per garment) -------------
+function fmtCreditDate(s) {
+ if (!s) return '';
+ const d = new Date(s.replace(' ', 'T') + 'Z');
+ if (isNaN(d)) return s;
+ return d.toLocaleDateString(undefined, { year: 'numeric', month: 'short', day: 'numeric' });
+}
+
+async function openCredits(kind, id) {
+ show('credits');
+ $('#credits-title').textContent = 'Loading credits…';
+ $('#credits-brand').textContent = '';
+ $('#credits-count').textContent = '0';
+ $('#credits-list').innerHTML = '<p class="muted">Loading…</p>';
+ try {
+ const r = await fetch(`/api/item-credits/${encodeURIComponent(kind)}/${encodeURIComponent(id)}`);
+ if (!r.ok) { $('#credits-list').innerHTML = `<p class="muted">No credits found (${r.status}).</p>`; return; }
+ const data = await r.json();
+ $('#credits-title').textContent = data.item.title || `${kind} ${data.item.id}`;
+ $('#credits-brand').textContent = data.item.brand || '';
+ $('#credits-count').textContent = data.total;
+ if (!data.credits.length) {
+ $('#credits-list').innerHTML = `<p class="muted">No appearances yet. Try it on, build an outfit with it, or time-travel it into an old photo — every render lands here.</p>`;
+ return;
+ }
+ $('#credits-list').innerHTML = data.credits.map(c => `
+ <article class="credit-row">
+ <h3 class="credit-role">${escapeHtml(c.role)}</h3>
+ <h4 class="credit-title">
+ ${c.occasion ? `<span class="credit-occasion">${escapeHtml(c.occasion)}</span>` : '<span class="credit-occasion muted">no occasion tag</span>'}
+ <span class="credit-year">${fmtCreditDate(c.date)}</span>
+ </h4>
+ <div class="credit-meta">
+ <span class="credit-type">${escapeHtml(c.type)}</span>
+ ${c.provider ? `<span class="credit-provider">via ${escapeHtml(c.provider)}</span>` : ''}
+ ${c.status ? `<span class="credit-status credit-status-${escapeHtml(c.status)}">${escapeHtml(c.status)}</span>` : ''}
+ ${c.slot ? `<span class="credit-slot">slot: ${escapeHtml(c.slot)}</span>` : ''}
+ </div>
+ ${c.with && c.with.length
+ ? `<div class="credit-with"><span class="muted">With:</span> ${c.with.map(w => `<span>${escapeHtml(w)}</span>`).join(' · ')}</div>`
+ : ''}
+ </article>
+ `).join('');
+ } catch (e) {
+ $('#credits-list').innerHTML = `<p class="muted">Could not load credits: ${escapeHtml(e.message)}</p>`;
+ }
+}
+function escapeHtml(s) { return String(s ?? '').replace(/[&<>"']/g, c => ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c])); }
+
+document.addEventListener('click', e => {
+ const tgt = e.target.closest('[data-credits-kind][data-credits-id]');
+ if (tgt) {
+ e.preventDefault();
+ openCredits(tgt.dataset.creditsKind, tgt.dataset.creditsId);
+ }
+ if (e.target.id === 'credits-back') {
+ e.preventDefault();
+ route('closet');
+ }
+});
+
// ---------- Boot -----------------------------------------------------------
(async function boot() {
const me = await fetch('/api/me').then(r => r.json());
diff --git a/server.js b/server.js
index 43b177a..9289586 100644
--- a/server.js
+++ b/server.js
@@ -883,6 +883,106 @@ app.post('/api/outfits/:id/pick', (req, res) => {
res.json({ ok: true });
});
+// ---- Item credits (tick 15) ----------------------------------------------
+// UX borrowed from shotonwhat.com/o/{id}/{slug}: a credit résumé for a
+// person, one row per film/TV title with role + year + collaborators.
+// Our analogue is per-garment: for a closet or catalog item, list every
+// appearance (try-on render, outfit pick, time-travel render, photo swap)
+// with the occasion, date, and the rest of the outfit it appeared with.
+//
+// GET /api/item-credits/:kind/:id kind ∈ { closet, catalog }
+//
+// Returns { item, total, credits: [{role, occasion, date, type, with: [...]}] }
+// where role is the verb ("Worn", "Tried on", "Time-Travel render", "Outfit pick"),
+// type is the production-type tag ("Try-On" / "Outfit" / "Photo Swap"),
+// and `with` is the rest of the slots that appeared in the same beat.
+app.get('/api/item-credits/:kind/:id', (req, res) => {
+ const u = currentUser(req);
+ const kind = req.params.kind;
+ const id = Number(req.params.id);
+ if (!['closet', 'catalog'].includes(kind)) return res.status(400).json({ error: 'kind must be closet|catalog' });
+ if (!Number.isFinite(id)) return res.status(400).json({ error: 'bad id' });
+
+ // 1. Resolve the item header.
+ let item = null;
+ if (kind === 'closet') {
+ const c = db.prepare('SELECT id, vendor_guess AS brand, category, color, pattern, material_guess AS material, created_at FROM closet WHERE id=? AND user_id=?').get(id, u.id);
+ if (!c) return res.status(404).json({ error: 'closet item not found' });
+ item = { kind, id: c.id, title: [c.color, c.pattern, c.category].filter(Boolean).join(' ').trim() || 'closet piece', brand: c.brand || null, category: c.category, since: c.created_at };
+ } else {
+ const it = db.prepare('SELECT id, title, brand, category, color, pattern, material, image_url FROM items WHERE id=?').get(id);
+ if (!it) return res.status(404).json({ error: 'catalog item not found' });
+ item = { kind, id: it.id, title: it.title, brand: it.brand, category: it.category, image_url: it.image_url };
+ }
+
+ // 2. Try-on renders that anchor on this item.
+ const tryonRows = kind === 'closet'
+ ? db.prepare(`SELECT id, mode, occasion, provider, status, source_photo_path, created_at, done_at FROM tryon_jobs WHERE user_id=? AND closet_id=? ORDER BY id DESC`).all(u.id, id)
+ : db.prepare(`SELECT id, mode, occasion, provider, status, source_photo_path, created_at, done_at FROM tryon_jobs WHERE user_id=? AND item_id=? ORDER BY id DESC`).all(u.id, id);
+
+ // 3. Outfit picks that include this item in any slot.
+ // outfits column holds JSON array of {slots:{top|bottom|shoes|bag|outerwear:{id,kind,...}}, score}.
+ const allPicks = db.prepare(`SELECT id, anchor_kind, anchor_id, outfits, picked_index, created_at, picked_at FROM outfit_picks WHERE user_id=? ORDER BY id DESC`).all(u.id);
+ const outfitCredits = [];
+ for (const row of allPicks) {
+ let outfits;
+ try { outfits = JSON.parse(row.outfits || '[]'); } catch { continue; }
+ for (let oi = 0; oi < outfits.length; oi++) {
+ const o = outfits[oi];
+ const slots = o?.slots || {};
+ const slotEntries = Object.entries(slots);
+ const hit = slotEntries.find(([, v]) => v && (
+ (kind === 'closet' && v.kind === 'closet' && Number(v.id) === id) ||
+ (kind === 'catalog' && (v.kind === 'catalog' || !v.kind) && Number(v.id) === id)
+ ));
+ if (!hit) continue;
+ const mySlot = hit[0];
+ const others = slotEntries
+ .filter(([slot]) => slot !== mySlot)
+ .map(([slot, v]) => v ? `${slot}: ${v.title || v.brand || 'piece'}` : null)
+ .filter(Boolean);
+ outfitCredits.push({
+ role: row.picked_index === oi ? 'Picked outfit' : 'Outfit candidate',
+ occasion: null,
+ date: row.picked_at || row.created_at,
+ type: 'Outfit',
+ slot: mySlot,
+ with: others.slice(0, 4),
+ });
+ // one row per pick (don't double-count if same item appears in multiple slot candidates)
+ break;
+ }
+ }
+
+ // 4. Stitch try-on rows into credits.
+ const tryonCredits = tryonRows.map(t => {
+ const role = ({
+ avatar: 'Avatar render',
+ photo_swap: 'Photo-swap render',
+ closet_to_oldphoto: 'Time-Travel render',
+ })[t.mode] || 'Try-on';
+ return {
+ role,
+ occasion: t.occasion || null,
+ date: t.done_at || t.created_at,
+ type: t.mode === 'closet_to_oldphoto' ? 'Time-Travel' : (t.mode === 'photo_swap' ? 'Photo Swap' : 'Try-On'),
+ provider: t.provider,
+ status: t.status,
+ with: t.source_photo_path ? ['source: old photo'] : [],
+ };
+ });
+
+ // 5. Merge + sort by date desc.
+ const credits = [...tryonCredits, ...outfitCredits]
+ .sort((a, b) => String(b.date || '').localeCompare(String(a.date || '')));
+
+ res.json({
+ item,
+ total: credits.length,
+ credits,
+ });
+});
+
// ---- Resale guardrails (no resale UX yet — see scripts/resale-guardrails.js) ----
const guardrails = require('./scripts/resale-guardrails');
← ca023c6 yolo tick 14: couples invite/accept/exit API (flag-gated) +
·
back to Whatsmystyle
·
feat(roles): 3 user layers (admin/public/set_decorator) + ad 68a7200 →