[object Object]

← back to Whatsmystyle

yolo tick 25: share-favorites endpoint + public /share/favorites/:id page + uniform 1/1.15 card image lattice + quick-look modal on card-image tap (preserves affiliate-out)

113d543eb8175e15b4c66a4a9547d4b3c1287456 · 2026-05-12 13:59:58 -0700 · SteveStudio2

Files touched

Diff

commit 113d543eb8175e15b4c66a4a9547d4b3c1287456
Author: SteveStudio2 <stevestudio2@SteveStacStudio.lan>
Date:   Tue May 12 13:59:58 2026 -0700

    yolo tick 25: share-favorites endpoint + public /share/favorites/:id page + uniform 1/1.15 card image lattice + quick-look modal on card-image tap (preserves affiliate-out)
---
 public/css/app.css          | 74 +++++++++++++++++++++++++++++++++++++++++++--
 public/index.html           | 22 ++++++++++++++
 public/js/app.js            | 57 ++++++++++++++++++++++++++++++++++
 public/share-favorites.html | 46 ++++++++++++++++++++++++++++
 server.js                   | 42 +++++++++++++++++++++++++
 5 files changed, 239 insertions(+), 2 deletions(-)

diff --git a/public/css/app.css b/public/css/app.css
index c162707..d8dd68e 100644
--- a/public/css/app.css
+++ b/public/css/app.css
@@ -111,7 +111,10 @@ h2 { font-family: var(--font-display); font-weight: 600; font-size: 36px; margin
   display: flex; flex-direction: column;
 }
 .card:hover { transform: translateY(-3px); box-shadow: var(--shadow); border-color: var(--ink); }
-.card img { width: 100%; aspect-ratio: 3/4; object-fit: cover; background: var(--paper-2); }
+/* Tick 25: per Steve's standing rule feedback_chip_image_uniform_size —
+   every DW card/chip/thumb gets aspect-ratio 1/1.15 + object-fit cover so
+   the grid is a uniform lattice regardless of source image dimensions. */
+.card img { width: 100%; aspect-ratio: 1/1.15; object-fit: cover; background: var(--paper-2); display: block; }
 .card .meta { padding: 16px 18px 20px; }
 .card .title { font-weight: 600; }
 .card .brand { color: #777; font-size: 14px; margin-top: 4px; }
@@ -139,7 +142,7 @@ h2 { font-family: var(--font-display); font-weight: 600; font-size: 36px; margin
 .grid-controls select, .grid-controls input { font: inherit; }
 .recs-controls { display: flex; justify-content: space-between; align-items: end; flex-wrap: wrap; gap: 16px; margin-bottom: 14px; }
 .grid { display: grid; gap: 14px; margin-top: 16px; grid-template-columns: repeat(auto-fill, minmax(var(--card-min, 220px), 1fr)); }
-.grid .card img { aspect-ratio: 3/4; }
+.grid .card img { aspect-ratio: 1/1.15; }
 
 /* connections */
 .connect-list { display: grid; gap: 12px; margin: 18px 0; }
@@ -819,6 +822,73 @@ h2 { font-family: var(--font-display); font-weight: 600; font-size: 36px; margin
 .embed-chip-budget.budget-over { color: #ef4444; font-weight: 600; }
 .embed-chip-budget.budget-over .muted-sm { color: #ef4444; }
 
+/* ---- Quick-look modal + share-favorites (tick 25) ---- */
+.fav-actions { display: flex; gap: 14px; align-items: center; flex-wrap: wrap; margin: 6px 0 14px; font-size: 13px; }
+.fav-actions button.ghost { font: inherit; background: #fff; color: #1d1d1f; border: 1px solid #d6d0c4; padding: 8px 16px; border-radius: 999px; cursor: pointer; }
+.fav-actions button.ghost:hover { background: #faf7f2; }
+
+.quicklook-modal {
+  position: fixed; inset: 0; z-index: 200;
+  display: flex; align-items: center; justify-content: center;
+  padding: 24px;
+}
+.quicklook-backdrop {
+  position: absolute; inset: 0;
+  background: rgba(0,0,0,0.55);
+  backdrop-filter: blur(4px);
+}
+.quicklook-card {
+  position: relative;
+  background: #fff;
+  border-radius: 24px;
+  max-width: 760px;
+  width: 100%;
+  max-height: 88vh;
+  overflow: auto;
+  display: grid;
+  grid-template-columns: 1fr 1fr;
+  box-shadow: 0 24px 60px rgba(0,0,0,0.35);
+}
+@media (max-width: 640px) {
+  .quicklook-card { grid-template-columns: 1fr; max-height: 92vh; }
+}
+.quicklook-card img {
+  width: 100%; height: 100%; min-height: 320px;
+  object-fit: cover; background: #faf7f2;
+  border-radius: 24px 0 0 24px;
+}
+@media (max-width: 640px) { .quicklook-card img { border-radius: 24px 24px 0 0; min-height: 280px; } }
+.quicklook-body { padding: 24px 28px; }
+.quicklook-body h3 { font-size: 22px; font-weight: 600; letter-spacing: -0.01em; margin: 0 0 8px; }
+.quicklook-brand { margin: 0 0 8px; font-size: 14px; }
+.quicklook-brand a { color: #555; text-decoration: none; }
+.quicklook-brand a:hover { color: #1d1d1f; text-decoration: underline; }
+.quicklook-meta { margin: 0 0 16px; font-size: 14px; color: #1d1d1f; }
+.quicklook-actions { display: flex; gap: 10px; flex-wrap: wrap; }
+.quicklook-btn {
+  font: inherit;
+  padding: 10px 18px;
+  border: 1px solid #d6d0c4;
+  background: #fff;
+  border-radius: 999px;
+  text-decoration: none;
+  color: #1d1d1f;
+  cursor: pointer;
+}
+.quicklook-btn.primary { background: #1d1d1f; color: #faf7f2; border-color: #1d1d1f; }
+.quicklook-btn:hover { filter: brightness(0.95); }
+.quicklook-close {
+  position: absolute; top: 12px; right: 14px;
+  z-index: 1;
+  width: 36px; height: 36px;
+  border: 0; border-radius: 50%;
+  background: rgba(255,255,255,0.9);
+  font-size: 22px; line-height: 1; cursor: pointer;
+  display: flex; align-items: center; justify-content: center;
+  box-shadow: 0 2px 8px rgba(0,0,0,0.15);
+}
+.quicklook-close:hover { background: #fff; }
+
 /* ---- Star button + delta toast + brand page (tick 24) ---- */
 .star-btn {
   position: absolute;
diff --git a/public/index.html b/public/index.html
index a83b57e..80d0fce 100644
--- a/public/index.html
+++ b/public/index.html
@@ -445,6 +445,10 @@
   <section id="favorites" class="screen" hidden>
     <h2>My Favorites</h2>
     <p class="muted">Tap the star on any item to save it here.</p>
+    <div class="fav-actions">
+      <button id="fav-share-btn" type="button" class="ghost">Share my catalog favorites →</button>
+      <span id="fav-share-out" class="muted"></span>
+    </div>
     <div id="favorites-grid" class="grid"></div>
   </section>
 
@@ -462,6 +466,24 @@
   <!-- Delta toast (tick 24) — top movers after every duel pick -->
   <div id="delta-toast" class="delta-toast" hidden></div>
 
+  <!-- Quick-look modal (tick 25) — interstitial when tapping card image -->
+  <div id="quicklook-modal" class="quicklook-modal" hidden role="dialog" aria-labelledby="ql-title">
+    <div class="quicklook-backdrop" data-ql-close></div>
+    <article class="quicklook-card">
+      <button type="button" class="quicklook-close" data-ql-close aria-label="Close">×</button>
+      <img id="ql-img" alt="">
+      <div class="quicklook-body">
+        <h3 id="ql-title">…</h3>
+        <p class="quicklook-brand"><a id="ql-brand-link" href="#" data-brand="">…</a></p>
+        <p class="quicklook-meta"><span id="ql-price"></span> <span id="ql-tier"></span></p>
+        <div class="quicklook-actions">
+          <button id="ql-star" class="quicklook-btn">☆ Save</button>
+          <a id="ql-out" class="quicklook-btn primary" target="_blank" rel="noopener">View on brand site →</a>
+        </div>
+      </div>
+    </article>
+  </div>
+
   <!-- 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 d51dd5a..4fe5b0e 100644
--- a/public/js/app.js
+++ b/public/js/app.js
@@ -328,10 +328,67 @@ document.addEventListener('click', e => {
   if (brandLink) {
     e.preventDefault();
     openBrand(brandLink.dataset.brand);
+    return;
   }
+  // Tick 25: Quick-look modal on tap of catalog card image (preserves the
+  // affiliate-out path — the outer anchor opens product_url on tap of
+  // anywhere ELSE in the card).
+  const qlOn = (localStorage.getItem('wms.quicklook') ?? '1') === '1';
+  if (qlOn) {
+    const img = e.target.closest('.card > img, .grid .card a > img');
+    if (img) {
+      const card = img.closest('.card');
+      const data = {
+        title: card?.querySelector('.title')?.textContent || '',
+        brand: card?.querySelector('.brand')?.textContent || card?.querySelector('[data-brand]')?.dataset.brand || '',
+        price: card?.querySelector('.price')?.textContent || '',
+        image: img.src,
+        href:  card?.tagName === 'A' ? card.getAttribute('href') : (card?.querySelector('a')?.getAttribute('href') || '#'),
+      };
+      if (data.href && data.href !== '#') {
+        e.preventDefault(); e.stopPropagation();
+        openQuickLook(data);
+        return;
+      }
+    }
+  }
+  // Quick-look close
+  if (e.target.closest('[data-ql-close]')) closeQuickLook();
+});
+
+function openQuickLook(data) {
+  const modal = $('#quicklook-modal');
+  if (!modal) return;
+  $('#ql-img').src = data.image;
+  $('#ql-img').alt = data.title;
+  $('#ql-title').textContent = data.title;
+  $('#ql-brand-link').textContent = data.brand;
+  $('#ql-brand-link').dataset.brand = data.brand;
+  $('#ql-price').textContent = data.price;
+  $('#ql-out').href = data.href;
+  modal.hidden = false;
+  document.body.style.overflow = 'hidden';
+}
+function closeQuickLook() {
+  const modal = $('#quicklook-modal');
+  if (!modal) return;
+  modal.hidden = true;
+  document.body.style.overflow = '';
+}
+document.addEventListener('keydown', e => {
+  if (e.key === 'Escape' && !$('#quicklook-modal')?.hidden) closeQuickLook();
 });
 $('#brand-back')?.addEventListener('click', e => { e.preventDefault(); route('recs'); });
 
+// Share favorites (tick 25)
+$('#fav-share-btn')?.addEventListener('click', async () => {
+  const r = await fetch('/api/favorites/share', { method: 'POST' });
+  const j = await r.json();
+  const url = `${location.origin}${j.url}`;
+  try { await navigator.clipboard.writeText(url); $('#fav-share-out').textContent = 'Copied: ' + url; }
+  catch { $('#fav-share-out').innerHTML = `Share URL: <a href="${url}" target="_blank">${url}</a>`; }
+});
+
 // Delta toast (tick 24) — show top-3 taste movers after a duel pick
 function showDeltaToast(deltas) {
   if (!deltas?.length) return;
diff --git a/public/share-favorites.html b/public/share-favorites.html
new file mode 100644
index 0000000..9538037
--- /dev/null
+++ b/public/share-favorites.html
@@ -0,0 +1,46 @@
+<!doctype html>
+<html lang="en">
+<head>
+  <meta charset="utf-8" />
+  <meta name="viewport" content="width=device-width, initial-scale=1" />
+  <meta name="robots" content="noindex,nofollow" />
+  <title>Shared favorites — 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: 1200px; margin: 40px auto; padding: 0 24px; }
+    h1 { font-size: 32px; font-weight: 600; letter-spacing: -0.02em; margin: 0 0 6px; }
+    .sub { color: #707070; margin-bottom: 24px; }
+    .cta { margin-top: 24px; padding-top: 18px; border-top: 1px solid #e6e1d8; font-size: 14px; }
+    .cta a { color: #1d1d1f; }
+  </style>
+</head>
+<body>
+  <h1 id="title">Shared favorites</h1>
+  <p class="sub" id="sub">Loading…</p>
+  <div id="grid" class="grid"></div>
+  <p class="cta">Want a list like this? <a href="/">Build yours on WhatsMyStyle →</a></p>
+
+  <script>
+    const $ = s => document.querySelector(s);
+    function escapeHtml(s) { return String(s ?? '').replace(/[&<>"']/g, c => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c])); }
+    (async function () {
+      const shareId = location.pathname.split('/').pop();
+      const r = await fetch(`/api/favorites/share/${encodeURIComponent(shareId)}`);
+      if (!r.ok) { $('#sub').textContent = 'This share link is no longer active.'; return; }
+      const j = await r.json();
+      $('#title').textContent = `Favorites from ${j.owner}`;
+      $('#sub').textContent = `${j.count} piece${j.count === 1 ? '' : 's'} saved.`;
+      $('#grid').innerHTML = j.items.map(it => `
+        <a class="card" href="${escapeHtml(it.product_url || '#')}" target="_blank" rel="noopener">
+          <img src="${escapeHtml(it.image_url || '')}" alt="${escapeHtml(it.title || '')}" loading="lazy">
+          <div class="meta">
+            <div class="title">${escapeHtml(it.title || '')}</div>
+            <div class="brand">${escapeHtml(it.brand || '')}</div>
+            ${it.price_cents ? `<div class="price">$${(it.price_cents/100).toFixed(0)}</div>` : ''}
+          </div>
+        </a>
+      `).join('');
+    })();
+  </script>
+</body>
+</html>
diff --git a/server.js b/server.js
index 2739f09..551cda6 100644
--- a/server.js
+++ b/server.js
@@ -259,6 +259,8 @@ CREATE TABLE IF NOT EXISTS favorites (
 );
 CREATE INDEX IF NOT EXISTS favorites_user_idx ON favorites(user_id, created_at DESC);
 `);
+// Tick 25: per-user share_id for public favorites list. Stored on users.
+safeAddColumn('users', 'favorites_share_id TEXT');
 
 // Tick 15.5: productions — film/TV/commercial projects a set_decorator user
 // is buying for. Lets set-decorator-mode anchor closet + try-on rows to a
@@ -810,6 +812,46 @@ app.delete('/api/favorites/:kind/:id', (req, res) => {
   res.json({ ok: true, deleted: r.changes });
 });
 
+// Tick 25: share-favorites. Generates a stable share_id on first call; the
+// same id round-trips so the share URL is durable. POST to rotate (invalidates
+// the previous link). Public GET endpoint exposes ONLY catalog items (no
+// closet photos, no user identity).
+app.post('/api/favorites/share', (req, res) => {
+  const u = currentUser(req);
+  const existing = u.favorites_share_id;
+  const rotate = req.query.rotate === '1';
+  let id = existing;
+  if (!id || rotate) {
+    id = require('crypto').randomBytes(12).toString('hex');
+    db.prepare('UPDATE users SET favorites_share_id = ? WHERE id = ?').run(id, u.id);
+  }
+  res.json({ ok: true, share_id: id, url: `/share/favorites/${id}` });
+});
+
+app.get('/api/favorites/share/:share_id', (req, res) => {
+  const owner = db.prepare('SELECT id, display_name FROM users WHERE favorites_share_id = ?').get(req.params.share_id);
+  if (!owner) return res.status(404).json({ error: 'share link not found' });
+  const rows = db.prepare(
+    `SELECT f.item_kind, f.item_id, f.created_at FROM favorites f
+      WHERE f.user_id = ? AND f.item_kind = 'catalog'
+      ORDER BY f.created_at DESC LIMIT 60`
+  ).all(owner.id);
+  const items = rows.map(r => {
+    const it = db.prepare('SELECT id, title, brand, category, color, price_cents, image_url, product_url FROM items WHERE id=?').get(r.item_id);
+    return it ? { ...it, fav_at: r.created_at } : null;
+  }).filter(Boolean);
+  res.json({
+    owner: owner.display_name || 'a WhatsMyStyle user',
+    items,
+    count: items.length,
+  });
+});
+
+// Public share page (server-rendered noindex HTML stub).
+app.get('/share/favorites/:share_id', (req, res) => {
+  res.sendFile(path.join(__dirname, 'public', 'share-favorites.html'));
+});
+
 // ---- closet ---------------------------------------------------------------
 app.get('/api/closet', (req, res) => {
   const u = currentUser(req);

← 4a6d125 yolo tick 24: favorites table + ★ star icon on cards + /favo  ·  back to Whatsmystyle  ·  yolo tick 26: image-isnull guard on duel/recs/outfit-builder e29fe3f →