← back to AsSeenInMovies
feat(asseeninmovies/tick8): /movie/:id + /person/:id HTML pages
dcdead0643cde90390ec1a3f1642ed3606d819ca · 2026-05-12 07:44:45 -0700 · SteveStudio2
Renders dark luxe-magazine aesthetic (Cormorant Garamond + Inter, gold
on near-black) so the 56k people / 280k movies / 347k credits / 14
SPOTTED rows are actually browsable, not just /api/* JSON.
- /movie/:id: poster + meta + tagline + overview + credit table grouped
by department (Directing/Acting/Writing/Art/Camera/Editing/Sound) +
SPOTTED row with category badges + shop link.
- /person/:id: headshot + name + birth/death + profession chips + union
membership pills + bio + filmography table grouped by role. VERIFIED
badge if claimed_at set. Claim CTA otherwise.
- All images fall back to /img/made-with-ai.svg when poster_source is
'made-with-ai' or the URL is empty. Never sourced from IMDb.
- TMDB attribution in footer (required).
Smoke tested: /person/1 = Matthew Versteeg (set decorator, Goosebumps),
/movie/1 = Goosebumps with 5 SPOTTED rows + 12 credits.
Files touched
Diff
commit dcdead0643cde90390ec1a3f1642ed3606d819ca
Author: SteveStudio2 <stevestudio2@SteveStacStudio.lan>
Date: Tue May 12 07:44:45 2026 -0700
feat(asseeninmovies/tick8): /movie/:id + /person/:id HTML pages
Renders dark luxe-magazine aesthetic (Cormorant Garamond + Inter, gold
on near-black) so the 56k people / 280k movies / 347k credits / 14
SPOTTED rows are actually browsable, not just /api/* JSON.
- /movie/:id: poster + meta + tagline + overview + credit table grouped
by department (Directing/Acting/Writing/Art/Camera/Editing/Sound) +
SPOTTED row with category badges + shop link.
- /person/:id: headshot + name + birth/death + profession chips + union
membership pills + bio + filmography table grouped by role. VERIFIED
badge if claimed_at set. Claim CTA otherwise.
- All images fall back to /img/made-with-ai.svg when poster_source is
'made-with-ai' or the URL is empty. Never sourced from IMDb.
- TMDB attribution in footer (required).
Smoke tested: /person/1 = Matthew Versteeg (set decorator, Goosebumps),
/movie/1 = Goosebumps with 5 SPOTTED rows + 12 credits.
---
server.js | 208 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
1 file changed, 208 insertions(+)
diff --git a/server.js b/server.js
index dfd47ef..b626d5a 100644
--- a/server.js
+++ b/server.js
@@ -170,6 +170,214 @@ app.get('/api/spotted', async (req, res) => {
}
});
+// ────────────────────────────────────────────────────────────────────
+// HTML PAGES — /movie/:id + /person/:id + helpers. Built so Steve can
+// actually browse the data, not just hit /api/* JSON. Dark luxe-magazine
+// aesthetic (cream/gold/dark-ink, Cormorant Garamond + Inter), with
+// poster/headshot falling back to /img/made-with-ai.svg whenever the
+// source isn't rights-cleared. TMDB attribution mandatory on every page.
+// ────────────────────────────────────────────────────────────────────
+
+const esc = (s) => String(s ?? '').replace(/[&<>"']/g, c => ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c]));
+const posterFor = (m) => (m && m.poster_url && (m.poster_source || '').toLowerCase() !== 'made-with-ai') ? m.poster_url : '/img/made-with-ai.svg';
+const headshotFor = (p) => (p && p.headshot_url && (p.headshot_source || '').toLowerCase() !== 'made-with-ai') ? p.headshot_url : '/img/made-with-ai.svg';
+
+function htmlShell(title, body) {
+ return `<!doctype html>
+<html lang="en"><head>
+<meta charset="utf-8"><title>${esc(title)} · AsSeenInMovies</title>
+<meta name="viewport" content="width=device-width, initial-scale=1">
+<link rel="preconnect" href="https://fonts.googleapis.com">
+<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
+<link href="https://fonts.googleapis.com/css2?family=Cormorant+Garamond:ital,wght@0,300;0,400;0,600;1,400&family=Inter:wght@300;400;500;600&display=swap" rel="stylesheet">
+<style>
+ :root { --bg:#0d0c0a; --ink:#f0ebe3; --ink-soft:#c8beb2; --ink-faint:#7a706a; --gold:#c9a14b; --line:#2a2620; --card:#1a1714; --serif:'Cormorant Garamond',Georgia,serif; --sans:'Inter',-apple-system,system-ui,sans-serif; }
+ *,*::before,*::after { box-sizing:border-box; }
+ body { margin:0; background:var(--bg); color:var(--ink); font:15px/1.55 var(--sans); }
+ a { color:var(--ink); text-decoration:none; }
+ a:hover { color:var(--gold); }
+ header.site { display:flex; align-items:center; justify-content:space-between; padding:18px 32px; border-bottom:1px solid var(--line); background:var(--bg); position:sticky; top:0; z-index:10; }
+ header.site .brand { font-family:var(--serif); font-style:italic; font-size:20px; letter-spacing:0.04em; color:var(--gold); }
+ header.site nav { display:flex; gap:24px; font-size:12px; letter-spacing:0.12em; text-transform:uppercase; color:var(--ink-faint); }
+ header.site nav a:hover { color:var(--ink); }
+ main { max-width:1100px; margin:0 auto; padding:48px 32px 80px; }
+ .hero { display:grid; grid-template-columns:280px 1fr; gap:36px; margin-bottom:48px; }
+ @media (max-width:720px) { .hero { grid-template-columns:1fr; } }
+ .poster { aspect-ratio:2/3; background:#15130f no-repeat center/cover; border-radius:8px; border:1px solid var(--line); box-shadow:0 18px 60px rgba(0,0,0,0.55); }
+ .title { font-family:var(--serif); font-weight:400; font-size:clamp(32px,4.5vw,52px); line-height:1.08; margin:0 0 8px; letter-spacing:-0.01em; }
+ .meta-line { color:var(--ink-faint); font-size:13px; letter-spacing:0.06em; text-transform:uppercase; margin-bottom:18px; }
+ .tagline { font-family:var(--serif); font-style:italic; color:var(--gold); font-size:18px; margin:0 0 16px; }
+ .overview { color:var(--ink-soft); font-size:15.5px; line-height:1.65; max-width:60ch; }
+ .chips { display:flex; flex-wrap:wrap; gap:8px; margin:18px 0; }
+ .chip { display:inline-block; padding:5px 11px; border:1px solid var(--line); border-radius:999px; font-size:11px; letter-spacing:0.08em; color:var(--ink-soft); background:rgba(201,161,75,0.04); }
+ .chip.gold { background:var(--gold); color:#1a1410; border-color:var(--gold); font-weight:600; }
+ section { margin-top:48px; }
+ section h2 { font-family:var(--serif); font-weight:400; font-size:22px; letter-spacing:0.04em; margin:0 0 16px; padding-bottom:8px; border-bottom:1px solid var(--line); }
+ .dept { color:var(--gold); font-size:11px; letter-spacing:0.18em; text-transform:uppercase; margin:24px 0 10px; }
+ .credit-row { display:grid; grid-template-columns:1fr 1fr; gap:6px 24px; padding:6px 0; border-bottom:1px dotted var(--line); font-size:14px; }
+ .credit-row .name a { color:var(--ink); }
+ .credit-row .name a:hover { color:var(--gold); }
+ .credit-row .role { color:var(--ink-faint); font-size:12px; }
+ .credit-row .character { color:var(--ink-soft); font-style:italic; }
+ .premium-badge { display:inline-block; padding:2px 7px; background:var(--gold); color:#1a1410; font-size:9px; font-weight:700; letter-spacing:0.18em; border-radius:3px; margin-left:8px; vertical-align:middle; }
+ .spotted-row { display:grid; grid-template-columns:120px 1fr; gap:18px; padding:16px 0; border-bottom:1px dotted var(--line); }
+ .spotted-row .badge-col { font-size:10px; letter-spacing:0.14em; text-transform:uppercase; color:var(--gold); }
+ .spotted-row .desc { color:var(--ink-soft); font-size:13.5px; line-height:1.55; }
+ .spotted-row .shop { display:inline-block; margin-top:6px; padding:4px 10px; border:1px solid var(--gold); color:var(--gold); font-size:11px; letter-spacing:0.1em; border-radius:3px; }
+ .spotted-row .shop:hover { background:var(--gold); color:#1a1410; }
+ .filmography { display:grid; grid-template-columns:60px 1fr 100px; gap:8px 16px; align-items:center; padding:8px 0; border-bottom:1px dotted var(--line); font-size:14px; }
+ .filmography .yr { color:var(--ink-faint); font-variant-numeric:tabular-nums; font-size:13px; }
+ .filmography .role-tag { color:var(--ink-faint); font-size:11px; letter-spacing:0.08em; text-transform:uppercase; }
+ footer.site { padding:24px 32px 36px; border-top:1px solid var(--line); color:var(--ink-faint); font-size:11px; letter-spacing:0.06em; text-align:center; line-height:1.7; }
+ footer.site a { color:var(--gold); }
+ .empty { padding:60px 0; text-align:center; color:var(--ink-faint); font-style:italic; }
+ .ai-pill { display:inline-block; padding:3px 9px; border:1px dotted var(--ink-faint); color:var(--ink-faint); font-size:10px; letter-spacing:0.16em; border-radius:3px; text-transform:uppercase; }
+</style>
+</head><body>
+<header class="site">
+ <a class="brand" href="/">AsSeenInMovies</a>
+ <nav>
+ <a href="/api/spotted">Spotted</a>
+ <a href="/api/search?q=">Search</a>
+ </nav>
+</header>
+<main>${body}</main>
+<footer class="site">
+ AsSeenInMovies · public-data only · © Designer Wallcoverings<br>
+ Movie metadata via <a href="https://www.themoviedb.org/">TMDB</a> — this product uses the TMDB API but is not endorsed or certified by TMDB.<br>
+ Posters and headshots that aren't rights-cleared use a <span class="ai-pill">made with AI</span> placeholder. Never sourced from IMDb.
+</footer>
+</body></html>`;
+}
+
+// /movie/:id — hero (poster + title + meta + overview) + SPOTTED row + credits grouped by department
+app.get('/movie/:id', async (req, res) => {
+ const id = parseInt(req.params.id, 10);
+ if (!Number.isFinite(id) || id < 1) return res.status(404).type('html').send(htmlShell('Not found', '<div class="empty">Movie not found.</div>'));
+ try {
+ const m = await pool.query(`SELECT * FROM asim_movies WHERE id=$1 LIMIT 1`, [id]);
+ if (!m.rows[0]) return res.status(404).type('html').send(htmlShell('Not found', '<div class="empty">Movie not found.</div>'));
+ const mv = m.rows[0];
+ const credits = await pool.query(`
+ SELECT c.role, c.character_name, c.order_idx, c.job_title, c.department,
+ p.id AS person_id, p.full_name, p.headshot_url, p.headshot_source, p.premium_tier
+ FROM asim_credits c JOIN asim_people p ON p.id = c.person_id
+ WHERE c.movie_id=$1 ORDER BY
+ CASE c.department
+ WHEN 'Directing' THEN 1 WHEN 'Acting' THEN 2 WHEN 'Writing' THEN 3
+ WHEN 'Art' THEN 4 WHEN 'Camera' THEN 5 WHEN 'Editing' THEN 6
+ WHEN 'Sound' THEN 7 WHEN 'Production' THEN 8 ELSE 9 END,
+ c.order_idx NULLS LAST, p.full_name`, [id]);
+ const spotted = await pool.query(`
+ SELECT * FROM asim_spotted WHERE movie_id=$1
+ ORDER BY verified_at DESC NULLS LAST, created_at DESC`, [id]);
+
+ // Group credits by department
+ const groups = {};
+ for (const c of credits.rows) {
+ const d = c.department || 'Other';
+ (groups[d] = groups[d] || []).push(c);
+ }
+ const deptOrder = ['Directing','Acting','Writing','Art','Camera','Editing','Sound','Production','Other'];
+
+ const heroHtml = `
+ <div class="hero">
+ <div class="poster" style="background-image:url('${posterFor(mv)}')"></div>
+ <div>
+ <h1 class="title">${esc(mv.title || 'Untitled')}</h1>
+ <div class="meta-line">
+ ${mv.release_year ? esc(mv.release_year) + ' · ' : ''}${mv.runtime_min ? esc(mv.runtime_min) + ' min · ' : ''}${(mv.genres || []).map(esc).join(' · ')}
+ </div>
+ ${mv.tagline ? `<p class="tagline">${esc(mv.tagline)}</p>` : ''}
+ ${mv.overview ? `<p class="overview">${esc(mv.overview)}</p>` : ''}
+ <div class="chips">
+ ${mv.imdb_id ? `<span class="chip">imdb · ${esc(mv.imdb_id)}</span>` : ''}
+ ${mv.tmdb_id ? `<span class="chip">tmdb · ${esc(mv.tmdb_id)}</span>` : ''}
+ ${mv.status ? `<span class="chip">${esc(mv.status)}</span>` : ''}
+ ${mv.poster_source === 'made-with-ai' ? '<span class="ai-pill">made with AI</span>' : ''}
+ </div>
+ </div>
+ </div>`;
+
+ const spottedHtml = spotted.rows.length ? `
+ <section>
+ <h2>Spotted on set</h2>
+ ${spotted.rows.map(s => `
+ <div class="spotted-row">
+ <div class="badge-col">${esc(s.product_category || 'dressing')}${s.is_premium_pick ? '<br><span class="premium-badge">DOCUMENTED</span>' : ''}</div>
+ <div>
+ <div style="font-weight:500">${esc(s.product_name)}</div>
+ <div class="desc">${esc(s.scene_description || '')}</div>
+ ${s.shop_url ? `<a class="shop" href="${esc(s.shop_url)}" target="_blank" rel="noopener">View source →</a>` : ''}
+ </div>
+ </div>`).join('')}
+ </section>` : '';
+
+ const creditsHtml = deptOrder.filter(d => groups[d] && groups[d].length).map(d => `
+ <div class="dept">${esc(d)}</div>
+ ${groups[d].slice(0, d === 'Acting' ? 20 : 30).map(c => `
+ <div class="credit-row">
+ <div class="name"><a href="/person/${esc(c.person_id)}">${esc(c.full_name)}</a>${c.premium_tier ? '<span class="premium-badge">' + esc(c.premium_tier) + '</span>' : ''}</div>
+ <div class="role">${esc(c.character_name || c.job_title || c.role || '')}</div>
+ </div>`).join('')}
+ `).join('');
+
+ res.type('html').send(htmlShell(mv.title || ('Movie #' + id), heroHtml + spottedHtml + (creditsHtml ? `<section><h2>Cast & crew</h2>${creditsHtml}</section>` : '')));
+ } catch (e) {
+ res.status(500).type('html').send(htmlShell('Error', `<div class="empty">${esc(e.message)}</div>`));
+ }
+});
+
+// /person/:id — headshot + name + profession + filmography table
+app.get('/person/:id', async (req, res) => {
+ const id = parseInt(req.params.id, 10);
+ if (!Number.isFinite(id) || id < 1) return res.status(404).type('html').send(htmlShell('Not found', '<div class="empty">Person not found.</div>'));
+ try {
+ const p = await pool.query(`SELECT * FROM asim_people WHERE id=$1 LIMIT 1`, [id]);
+ if (!p.rows[0]) return res.status(404).type('html').send(htmlShell('Not found', '<div class="empty">Person not found.</div>'));
+ const person = p.rows[0];
+ const credits = await pool.query(`
+ SELECT c.role, c.character_name, c.job_title, c.department,
+ m.id AS movie_id, m.title, m.release_year, m.poster_url, m.poster_source
+ FROM asim_credits c JOIN asim_movies m ON m.id = c.movie_id
+ WHERE c.person_id=$1 ORDER BY m.release_year DESC NULLS LAST`, [id]);
+
+ const isPremium = person.claimed_at && (!person.premium_expires_at || new Date(person.premium_expires_at) > new Date());
+ const heroHtml = `
+ <div class="hero">
+ <div class="poster" style="aspect-ratio:1; background-image:url('${headshotFor(person)}')"></div>
+ <div>
+ <h1 class="title">${esc(person.full_name)}${isPremium ? '<span class="premium-badge">VERIFIED</span>' : ''}</h1>
+ <div class="meta-line">
+ ${person.birth_year ? esc(person.birth_year) + (person.death_year ? '–' + esc(person.death_year) : '') + ' · ' : ''}${person.birth_place ? esc(person.birth_place) : ''}
+ </div>
+ <div class="chips">
+ ${(person.primary_profession || []).map(p => `<span class="chip gold">${esc(p)}</span>`).join('')}
+ ${(person.union_memberships || []).map(u => `<span class="chip">${esc(u)}</span>`).join('')}
+ ${person.headshot_source === 'made-with-ai' ? '<span class="ai-pill">made with AI</span>' : ''}
+ </div>
+ ${person.bio ? `<p class="overview">${esc(person.bio).slice(0, 800)}${person.bio.length > 800 ? '…' : ''}</p>` : ''}
+ ${!isPremium ? `<p style="margin-top:18px"><a href="/claim/person/${esc(id)}" style="display:inline-block;padding:8px 16px;border:1px solid var(--gold);color:var(--gold);font-size:12px;letter-spacing:0.1em">Claim this profile →</a></p>` : ''}
+ </div>
+ </div>`;
+
+ const filmHtml = credits.rows.length ? `
+ <section>
+ <h2>Filmography · ${credits.rows.length}</h2>
+ ${credits.rows.slice(0, 200).map(c => `
+ <div class="filmography">
+ <div class="yr">${esc(c.release_year || '—')}</div>
+ <div><a href="/movie/${esc(c.movie_id)}">${esc(c.title)}</a>${c.character_name ? '<span class="role-tag"> · as ' + esc(c.character_name) + '</span>' : ''}</div>
+ <div class="role-tag">${esc(c.role || c.job_title || '')}</div>
+ </div>`).join('')}
+ </section>` : '<div class="empty">No credits loaded yet.</div>';
+
+ res.type('html').send(htmlShell(person.full_name, heroHtml + filmHtml));
+ } catch (e) {
+ res.status(500).type('html').send(htmlShell('Error', `<div class="empty">${esc(e.message)}</div>`));
+ }
+});
+
// Home — placeholder until tick 1 ships TMDB ingest + real UI.
app.get('/', (_req, res) => {
res.type('html').send(`<!doctype html>
← 9a12577 tick7: SPOTTED seed import — 14 placements from thesetdecora
·
back to AsSeenInMovies
·
feat(asseeninmovies/tick3): Wikidata SPARQL enrichment f69197c →