[object Object]

← back to AsSeenInMovies

feat(asseeninmovies): /movies + /people paginated index pages with filters (year/genre/q)

bdd362c9c547abaef95cfb6229840ec8b83e20dc · 2026-05-12 15:24:48 -0700 · SteveStudio2

Files touched

Diff

commit bdd362c9c547abaef95cfb6229840ec8b83e20dc
Author: SteveStudio2 <stevestudio2@SteveStacStudio.lan>
Date:   Tue May 12 15:24:48 2026 -0700

    feat(asseeninmovies): /movies + /people paginated index pages with filters (year/genre/q)
---
 server.js | 148 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
 1 file changed, 148 insertions(+)

diff --git a/server.js b/server.js
index 95f7580..0f9ca7e 100644
--- a/server.js
+++ b/server.js
@@ -257,6 +257,8 @@ function htmlShell(title, body) {
 <header class="site">
   <a class="brand" href="/">AsSeenInMovies</a>
   <nav>
+    <a href="/movies">Movies</a>
+    <a href="/people">People</a>
     <a href="/api/spotted">Spotted</a>
     <a href="/api/search?q=">Search</a>
   </nav>
@@ -404,6 +406,152 @@ app.get('/person/:id', async (req, res) => {
   }
 });
 
+// /movies — paginated grid of movies. Defaults to credited-poster-first so the
+// gallery looks alive even with 6.46M unenriched stubs in the tail. Filters by
+// release year and genre. Query: ?page= ?year= ?genre= ?q=
+app.get('/movies', async (req, res) => {
+  try {
+    const page  = Math.max(1, parseInt(req.query.page  || '1', 10));
+    const limit = 48;
+    const offset = (page - 1) * limit;
+    const year  = /^\d{4}$/.test(req.query.year || '') ? parseInt(req.query.year, 10) : null;
+    const genre = typeof req.query.genre === 'string' && req.query.genre.length < 32 ? req.query.genre : null;
+    const q     = typeof req.query.q === 'string' && req.query.q.length < 80 ? req.query.q.trim() : null;
+    const params = [];
+    let where = `poster_url IS NOT NULL`;
+    if (year)  { params.push(year);  where += ` AND release_year = $${params.length}`; }
+    if (genre) { params.push(genre); where += ` AND $${params.length} = ANY(genres)`; }
+    if (q)     { params.push(`%${q.toLowerCase()}%`); where += ` AND LOWER(title) LIKE $${params.length}`; }
+    params.push(limit, offset);
+    const r = await pool.query(`
+      SELECT id, title, release_year, genres, poster_url, poster_source, vote_average
+        FROM asim_movies
+       WHERE ${where}
+       ORDER BY (popularity IS NOT NULL) DESC, popularity DESC NULLS LAST, release_year DESC NULLS LAST, id DESC
+       LIMIT $${params.length-1} OFFSET $${params.length}`, params);
+    const totalR = await pool.query(`SELECT count(*) AS n FROM asim_movies WHERE ${where}`, params.slice(0, params.length - 2));
+    const total = parseInt(totalR.rows[0].n, 10);
+    const totalPages = Math.max(1, Math.ceil(total / limit));
+    const cards = r.rows.map(m => `
+      <a class="grid-card" href="/movie/${m.id}">
+        <div class="grid-poster" style="background-image:url('${esc(m.poster_url || '')}')"></div>
+        <div class="grid-body">
+          <div class="grid-title">${esc(m.title)}</div>
+          <div class="grid-meta">${m.release_year || '—'}${m.vote_average ? ' · ★ ' + Number(m.vote_average).toFixed(1) : ''}</div>
+        </div>
+      </a>`).join('');
+    const qs = (overrides = {}) => {
+      const u = new URLSearchParams();
+      if (year)  u.set('year', year);
+      if (genre) u.set('genre', genre);
+      if (q)     u.set('q', q);
+      Object.entries(overrides).forEach(([k,v]) => v == null ? u.delete(k) : u.set(k, v));
+      return u.toString();
+    };
+    const pager = `
+      <nav class="pager" aria-label="Pagination">
+        ${page > 1 ? `<a class="chip" href="/movies?${qs({page: page-1})}">← Prev</a>` : ''}
+        <span class="chip" style="background:var(--gold);color:#1a1410">Page ${page} of ${totalPages.toLocaleString()}</span>
+        ${page < totalPages ? `<a class="chip" href="/movies?${qs({page: page+1})}">Next →</a>` : ''}
+      </nav>`;
+    const filterBar = `
+      <form class="filter-bar" method="get" action="/movies">
+        <input name="q"     type="search" placeholder="Search title" value="${esc(q || '')}">
+        <input name="year"  type="text"   placeholder="Year (e.g. 1994)" value="${esc(year || '')}" maxlength="4" size="6">
+        <input name="genre" type="text"   placeholder="Genre (e.g. Drama)" value="${esc(genre || '')}" size="14">
+        <button type="submit" class="chip" style="background:transparent">Filter</button>
+      </form>`;
+    const body = `
+      <style>
+        .filter-bar{display:flex;gap:10px;flex-wrap:wrap;margin-bottom:24px}
+        .filter-bar input{background:transparent;border:1px solid var(--line);color:inherit;padding:7px 12px;border-radius:6px;font:inherit;min-width:140px}
+        .grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(180px,1fr));gap:20px}
+        .grid-card{display:block;border:1px solid var(--line);border-radius:8px;overflow:hidden;background:var(--card);transition:transform .15s}
+        .grid-card:hover{transform:translateY(-2px)}
+        .grid-poster{aspect-ratio:2/3;background:#15130f no-repeat center/cover}
+        .grid-body{padding:10px 12px}
+        .grid-title{font-size:13.5px;line-height:1.35;margin-bottom:4px;color:var(--ink);overflow:hidden;display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical}
+        .grid-meta{color:var(--ink-faint);font-size:11px;letter-spacing:0.04em}
+        .pager{display:flex;gap:10px;align-items:center;justify-content:center;margin:32px 0}
+      </style>
+      <h1 class="title">Movies</h1>
+      <p class="meta-line">${total.toLocaleString()} movies with rights-cleared posters</p>
+      ${filterBar}
+      <div class="grid">${cards || '<div class="empty">No movies match those filters yet.</div>'}</div>
+      ${pager}`;
+    res.type('html').send(htmlShell('Movies', body));
+  } catch (e) {
+    res.status(500).type('html').send(htmlShell('Error', `<div class="empty">${esc(e.message)}</div>`));
+  }
+});
+
+// /people — paginated grid of people with headshots, popularity-sorted.
+app.get('/people', async (req, res) => {
+  try {
+    const page   = Math.max(1, parseInt(req.query.page || '1', 10));
+    const limit  = 60;
+    const offset = (page - 1) * limit;
+    const q      = typeof req.query.q === 'string' && req.query.q.length < 80 ? req.query.q.trim() : null;
+    const params = [];
+    let where = `headshot_url IS NOT NULL`;
+    if (q) { params.push(`%${q.toLowerCase()}%`); where += ` AND LOWER(full_name) LIKE $${params.length}`; }
+    params.push(limit, offset);
+    const r = await pool.query(`
+      SELECT id, full_name, headshot_url, headshot_source, primary_profession, birth_year, death_year
+        FROM asim_people
+       WHERE ${where}
+       ORDER BY (claimed_at IS NOT NULL) DESC, full_name
+       LIMIT $${params.length-1} OFFSET $${params.length}`, params);
+    const totalR = await pool.query(`SELECT count(*) AS n FROM asim_people WHERE ${where}`, params.slice(0, params.length - 2));
+    const total = parseInt(totalR.rows[0].n, 10);
+    const totalPages = Math.max(1, Math.ceil(total / limit));
+    const cards = r.rows.map(p => `
+      <a class="grid-card person" href="/person/${p.id}">
+        <div class="grid-headshot" style="background-image:url('${esc(p.headshot_url || '')}')"></div>
+        <div class="grid-body">
+          <div class="grid-title">${esc(p.full_name)}</div>
+          <div class="grid-meta">${(p.primary_profession || []).slice(0,2).join(' · ')}${p.birth_year ? ' · ' + p.birth_year + (p.death_year ? '–' + p.death_year : '') : ''}</div>
+        </div>
+      </a>`).join('');
+    const qs = (overrides = {}) => {
+      const u = new URLSearchParams();
+      if (q) u.set('q', q);
+      Object.entries(overrides).forEach(([k,v]) => v == null ? u.delete(k) : u.set(k, v));
+      return u.toString();
+    };
+    const pager = `
+      <nav class="pager" aria-label="Pagination">
+        ${page > 1 ? `<a class="chip" href="/people?${qs({page: page-1})}">← Prev</a>` : ''}
+        <span class="chip" style="background:var(--gold);color:#1a1410">Page ${page} of ${totalPages.toLocaleString()}</span>
+        ${page < totalPages ? `<a class="chip" href="/people?${qs({page: page+1})}">Next →</a>` : ''}
+      </nav>`;
+    const body = `
+      <style>
+        .filter-bar{display:flex;gap:10px;flex-wrap:wrap;margin-bottom:24px}
+        .filter-bar input{background:transparent;border:1px solid var(--line);color:inherit;padding:7px 12px;border-radius:6px;font:inherit;min-width:200px}
+        .grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(160px,1fr));gap:18px}
+        .grid-card{display:block;border:1px solid var(--line);border-radius:8px;overflow:hidden;background:var(--card);transition:transform .15s}
+        .grid-card:hover{transform:translateY(-2px)}
+        .grid-headshot{aspect-ratio:3/4;background:#15130f no-repeat center/cover}
+        .grid-body{padding:10px 12px}
+        .grid-title{font-size:13.5px;line-height:1.35;margin-bottom:4px;color:var(--ink)}
+        .grid-meta{color:var(--ink-faint);font-size:11px;letter-spacing:0.04em;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
+        .pager{display:flex;gap:10px;align-items:center;justify-content:center;margin:32px 0}
+      </style>
+      <h1 class="title">People</h1>
+      <p class="meta-line">${total.toLocaleString()} people with public headshots</p>
+      <form class="filter-bar" method="get" action="/people">
+        <input name="q" type="search" placeholder="Search name" value="${esc(q || '')}">
+        <button type="submit" class="chip" style="background:transparent">Filter</button>
+      </form>
+      <div class="grid">${cards || '<div class="empty">No people match that filter yet.</div>'}</div>
+      ${pager}`;
+    res.type('html').send(htmlShell('People', body));
+  } 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>

← 2b42577 feat(asseeninmovies): inject Big Red widget into htmlShell f  ·  back to AsSeenInMovies  ·  wikidata-enrich: add --byCredits to rank people by credit co 40aa91f →