[object Object]

← 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

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 => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[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 →