← 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
M public/css/app.cssM public/js/app.jsM server.js
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 => ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[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 →