[object Object]

← back to Whatsmystyle

feat(productions): per-production résumé + closet/tryon tagging endpoints + clickable production cards + tag-prod pill on /credits

545c7d2b84772aaa39dd2c5789c410506380123a · 2026-05-12 07:28:22 -0700 · SteveStudio2

Files touched

Diff

commit 545c7d2b84772aaa39dd2c5789c410506380123a
Author: SteveStudio2 <stevestudio2@SteveStacStudio.lan>
Date:   Tue May 12 07:28:22 2026 -0700

    feat(productions): per-production résumé + closet/tryon tagging endpoints + clickable production cards + tag-prod pill on /credits
---
 public/css/app.css |  42 +++++++++++++++++++++
 public/js/app.js   | 106 ++++++++++++++++++++++++++++++++++++++++++++++++++++-
 server.js          |  84 ++++++++++++++++++++++++++++++++++++++++++
 3 files changed, 231 insertions(+), 1 deletion(-)

diff --git a/public/css/app.css b/public/css/app.css
index a1fa685..cfd0582 100644
--- a/public/css/app.css
+++ b/public/css/app.css
@@ -502,6 +502,48 @@ h2 { font-family: var(--font-display); font-weight: 600; font-size: 36px; margin
   background: #faf7f2; border: 1px solid #e6e1d8;
   padding: 3px 10px; border-radius: 999px; color: #555;
 }
+.production-clickable {
+  cursor: pointer;
+  transition: background 0.15s, border-color 0.15s;
+}
+.production-clickable:hover {
+  background: #faf7f2;
+  border-color: #c8c1b3;
+}
+.production-clickable:focus-visible {
+  outline: 2px solid #1d1d1f;
+  outline-offset: 2px;
+}
+.production-cta {
+  background: transparent !important;
+  border: 0 !important;
+  color: #555 !important;
+  font-style: italic;
+  margin-left: auto !important;
+}
+
+/* tag-with-production row inside /credits header */
+.tag-prod {
+  margin-top: 16px;
+  padding: 12px 16px;
+  background: #faf7f2;
+  border: 1px solid #e6e1d8;
+  border-radius: 14px;
+  display: flex; flex-wrap: wrap; gap: 10px; align-items: center;
+  font-size: 14px;
+}
+.tag-prod select {
+  font: inherit; padding: 6px 12px;
+  border: 1px solid #d6d0c4; border-radius: 10px;
+  background: #fff;
+}
+.tag-prod button.ghost {
+  background: #1d1d1f; color: #faf7f2; border: 0;
+  padding: 6px 16px; border-radius: 999px;
+  font-size: 13px; cursor: pointer;
+}
+.tag-prod-status { font-size: 13px; }
+.tag-prod a { color: #1d1d1f; }
 
 /* utils */
 [hidden] { display: none !important; }
diff --git a/public/js/app.js b/public/js/app.js
index 72386fc..bf7fe02 100644
--- a/public/js/app.js
+++ b/public/js/app.js
@@ -109,18 +109,73 @@ async function loadProductions() {
     return;
   }
   list.innerHTML = productions.map(p => `
-    <article class="production-row">
+    <article class="production-row production-clickable" data-production-id="${p.id}" tabindex="0" role="button" aria-label="Open credits for ${escapeHtml(p.title)}">
       <h3 class="production-title">${escapeHtml(p.title)}${p.year ? ` <span class="muted">(${p.year})</span>` : ''}</h3>
       <div class="production-meta">
         <span>${escapeHtml(p.kind || 'tv-episode')}</span>
         <span>${escapeHtml(p.role || 'Set Decoration Buyer')}</span>
         <span><strong>${p.total_credits}</strong> credits</span>
         <span class="muted">${p.closet_count} closet · ${p.tryon_count} try-on</span>
+        <span class="production-cta">view résumé →</span>
       </div>
     </article>
   `).join('');
 }
 
+async function openProductionCredits(productionId) {
+  show('credits');
+  $('#credits-title').textContent = 'Loading résumé…';
+  $('#credits-brand').textContent = '';
+  $('#credits-count').textContent = '0';
+  $('#credits-list').innerHTML = '<p class="muted">Loading…</p>';
+  try {
+    const r = await fetch(`/api/production-credits/${encodeURIComponent(productionId)}`);
+    if (!r.ok) { $('#credits-list').innerHTML = `<p class="muted">Could not load (${r.status}).</p>`; return; }
+    const data = await r.json();
+    const p = data.production;
+    $('#credits-title').textContent = p.title + (p.year ? ` (${p.year})` : '');
+    $('#credits-brand').textContent = `${p.kind || 'tv-episode'} · ${p.role || 'Set Decoration Buyer'}`;
+    $('#credits-count').textContent = data.total;
+    if (!data.credits.length) {
+      $('#credits-list').innerHTML = `<p class="muted">No closet pieces or try-ons tagged to this production yet. Open any closet item or try-on, tap "tag with production", and pick this one.</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.brand ? `<span>${escapeHtml(c.brand)}</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: ${escapeHtml(e.message)}</p>`;
+  }
+}
+
+document.addEventListener('click', e => {
+  const card = e.target.closest('.production-clickable');
+  if (card) {
+    e.preventDefault();
+    openProductionCredits(card.dataset.productionId);
+  }
+});
+document.addEventListener('keydown', e => {
+  if (e.key !== 'Enter') return;
+  const card = e.target.closest('.production-clickable');
+  if (card) { e.preventDefault(); openProductionCredits(card.dataset.productionId); }
+});
+
 document.addEventListener('submit', async e => {
   if (e.target.id !== 'production-add') return;
   e.preventDefault();
@@ -1271,6 +1326,11 @@ async function openCredits(kind, id) {
     $('#credits-title').textContent = data.item.title || `${kind} ${data.item.id}`;
     $('#credits-brand').textContent = data.item.brand || '';
     $('#credits-count').textContent = data.total;
+    // If the current viewer is a set decorator, prepend a "Tag with production"
+    // affordance so closet pieces / catalog items can be assigned to a project.
+    if (kind === 'closet' && (Role.effective === 'set_decorator' || Role.role === 'set_decorator')) {
+      await injectProductionTagger(kind, id);
+    }
     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;
@@ -1299,6 +1359,50 @@ async function openCredits(kind, id) {
 }
 function escapeHtml(s) { return String(s ?? '').replace(/[&<>"']/g, c => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c])); }
 
+async function injectProductionTagger(kind, id) {
+  // Renders a small "tag with production" pill row above the credits list.
+  // Only meaningful for set_decorator role. Bails silently if the user has
+  // zero productions — the empty case nudges them to /productions instead.
+  try {
+    const r = await fetch('/api/productions');
+    if (!r.ok) return;
+    const { productions } = await r.json();
+    const header = document.querySelector('.credits-header');
+    if (!header || header.querySelector('.tag-prod')) return;
+    const div = document.createElement('div');
+    div.className = 'tag-prod';
+    if (!productions.length) {
+      div.innerHTML = `<span class="muted">Tag this piece with a production?</span> <a href="#" data-screen="productions">Add a production first →</a>`;
+    } else {
+      div.innerHTML = `
+        <label class="muted" for="tag-prod-select">Tag with production:</label>
+        <select id="tag-prod-select">
+          <option value="">— none —</option>
+          ${productions.map(p => `<option value="${p.id}">${escapeHtml(p.title)}${p.year ? ' (' + p.year + ')' : ''}</option>`).join('')}
+        </select>
+        <button type="button" class="ghost" data-tag-prod-kind="${kind}" data-tag-prod-id="${id}">Tag</button>
+        <span class="tag-prod-status muted"></span>
+      `;
+    }
+    header.appendChild(div);
+  } catch {}
+}
+
+document.addEventListener('click', async e => {
+  const btn = e.target.closest('button[data-tag-prod-kind][data-tag-prod-id]');
+  if (!btn) return;
+  const pid = $('#tag-prod-select').value || '';
+  const kind = btn.dataset.tagProdKind;
+  const id = btn.dataset.tagProdId;
+  const endpoint = kind === 'closet' ? `/api/closet/${id}/production` : null;
+  if (!endpoint) return;
+  const r = await fetch(endpoint, { method: 'PUT', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ production_id: pid || null }) });
+  const j = await r.json().catch(() => ({}));
+  const status = btn.parentElement.querySelector('.tag-prod-status');
+  if (r.ok) { status.textContent = pid ? 'Tagged.' : 'Cleared.'; status.style.color = '#14572a'; }
+  else      { status.textContent = j.error || 'Failed'; status.style.color = '#7a1717'; }
+});
+
 document.addEventListener('click', e => {
   const tgt = e.target.closest('[data-credits-kind][data-credits-id]');
   if (tgt) {
diff --git a/server.js b/server.js
index cdbe1b6..449af95 100644
--- a/server.js
+++ b/server.js
@@ -386,6 +386,90 @@ app.post('/api/productions', (req, res) => {
   res.json({ ok: true, id: lastInsertRowid });
 });
 
+// Tick 15.6: per-production résumé view — every closet item + try-on row
+// tagged to one production, ordered date desc. Mirrors /api/item-credits
+// shape so the same /credits screen renderer can consume both endpoints.
+app.get('/api/production-credits/:id', (req, res) => {
+  const u = currentUser(req);
+  const id = Number(req.params.id);
+  const prod = db.prepare('SELECT * FROM productions WHERE id=? AND user_id=?').get(id, u.id);
+  if (!prod) return res.status(404).json({ error: 'production not found' });
+
+  const closetRows = db.prepare(
+    `SELECT id, vendor_guess AS brand, category, color, pattern, created_at
+       FROM closet WHERE production_id=? AND user_id=? ORDER BY id DESC`
+  ).all(id, u.id);
+  const tryonRows = db.prepare(
+    `SELECT id, mode, occasion, provider, status, item_id, closet_id, created_at, done_at
+       FROM tryon_jobs WHERE production_id=? AND user_id=? ORDER BY id DESC`
+  ).all(id, u.id);
+
+  const credits = [];
+  for (const c of closetRows) {
+    credits.push({
+      role: 'Set piece',
+      occasion: null,
+      date: c.created_at,
+      type: 'Closet',
+      with: [`${c.color || ''} ${c.pattern || ''} ${c.category || ''}`.trim() || 'piece'],
+      brand: c.brand,
+    });
+  }
+  for (const t of tryonRows) {
+    const role = ({
+      avatar: 'Avatar render',
+      photo_swap: 'Photo-swap render',
+      closet_to_oldphoto: 'Time-Travel render',
+    })[t.mode] || 'Try-on';
+    credits.push({
+      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: [],
+    });
+  }
+  credits.sort((a, b) => String(b.date || '').localeCompare(String(a.date || '')));
+
+  res.json({
+    production: prod,
+    item: { kind: 'production', id: prod.id, title: prod.title, brand: prod.role, category: prod.kind, since: prod.created_at },
+    total: credits.length,
+    credits,
+  });
+});
+
+// Tick 15.6: tag closet item or try-on with a production_id. Pass null/0 to clear.
+function clampProductionId(v) {
+  if (v === null || v === undefined || v === 0 || v === '') return null;
+  const n = Number(v);
+  return Number.isFinite(n) && n > 0 ? n : null;
+}
+app.put('/api/closet/:id/production', (req, res) => {
+  const u = currentUser(req);
+  const pid = clampProductionId(req.body?.production_id);
+  if (pid !== null) {
+    const owned = db.prepare('SELECT 1 FROM productions WHERE id=? AND user_id=?').get(pid, u.id);
+    if (!owned) return res.status(400).json({ error: 'production not yours' });
+  }
+  const result = db.prepare('UPDATE closet SET production_id=? WHERE id=? AND user_id=?').run(pid, req.params.id, u.id);
+  if (result.changes === 0) return res.status(404).json({ error: 'closet item not found' });
+  res.json({ ok: true, production_id: pid });
+});
+app.put('/api/tryon/:id/production', (req, res) => {
+  const u = currentUser(req);
+  const pid = clampProductionId(req.body?.production_id);
+  if (pid !== null) {
+    const owned = db.prepare('SELECT 1 FROM productions WHERE id=? AND user_id=?').get(pid, u.id);
+    if (!owned) return res.status(400).json({ error: 'production not yours' });
+  }
+  const result = db.prepare('UPDATE tryon_jobs SET production_id=? WHERE id=? AND user_id=?').run(pid, req.params.id, u.id);
+  if (result.changes === 0) return res.status(404).json({ error: 'tryon not found' });
+  res.json({ ok: true, production_id: pid });
+});
+
 app.post('/api/onboard', (req, res) => {
   const u = currentUser(req);
   const f = req.body || {};

← 68a7200 feat(roles): 3 user layers (admin/public/set_decorator) + ad  ·  back to Whatsmystyle  ·  feat(tick15): outfit rationale text + /privacy-policy page + 514d806 →