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