← back to AsSeenInMovies
asseeninmovies: /movies + /people sort dropdowns + density slider (per standing grid rule); localStorage-persisted density
67784fe1d15fdcbcc663946988e433b33ed0eb2b · 2026-05-12 15:36:59 -0700 · SteveStudio2
Files touched
Diff
commit 67784fe1d15fdcbcc663946988e433b33ed0eb2b
Author: SteveStudio2 <stevestudio2@SteveStacStudio.lan>
Date: Tue May 12 15:36:59 2026 -0700
asseeninmovies: /movies + /people sort dropdowns + density slider (per standing grid rule); localStorage-persisted density
---
server.js | 78 +++++++++++++++++++++++++++++++++++++++++++++++++++++++--------
1 file changed, 68 insertions(+), 10 deletions(-)
diff --git a/server.js b/server.js
index 0f9ca7e..58fa8dc 100644
--- a/server.js
+++ b/server.js
@@ -411,12 +411,21 @@ app.get('/person/:id', async (req, res) => {
// release year and genre. Query: ?page= ?year= ?genre= ?q=
app.get('/movies', async (req, res) => {
try {
+ const MOVIE_SORTS = {
+ popular: `(popularity IS NOT NULL) DESC, popularity DESC NULLS LAST, release_year DESC NULLS LAST, id DESC`,
+ newest: `release_year DESC NULLS LAST, id DESC`,
+ oldest: `release_year ASC NULLS LAST, id`,
+ title_az: `title ASC, id`,
+ title_za: `title DESC, id`,
+ rating: `vote_average DESC NULLS LAST, id`,
+ };
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 sort = MOVIE_SORTS[req.query.sort] ? req.query.sort : 'popular';
const params = [];
let where = `poster_url IS NOT NULL`;
if (year) { params.push(year); where += ` AND release_year = $${params.length}`; }
@@ -427,7 +436,7 @@ app.get('/movies', async (req, res) => {
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
+ ORDER BY ${MOVIE_SORTS[sort]}
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);
@@ -445,6 +454,7 @@ app.get('/movies', async (req, res) => {
if (year) u.set('year', year);
if (genre) u.set('genre', genre);
if (q) u.set('q', q);
+ if (sort && sort !== 'popular') u.set('sort', sort);
Object.entries(overrides).forEach(([k,v]) => v == null ? u.delete(k) : u.set(k, v));
return u.toString();
};
@@ -454,18 +464,36 @@ app.get('/movies', async (req, res) => {
<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 sortOpt = (val, label) => `<option value="${val}"${sort===val?' selected':''}>${label}</option>`;
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">
+ <label class="control"><span style="font-size:11px;letter-spacing:0.08em;color:var(--ink-faint);margin-right:4px">Sort</span>
+ <select name="sort" onchange="this.form.submit()">
+ ${sortOpt('popular', 'Most popular')}
+ ${sortOpt('newest', 'Newest')}
+ ${sortOpt('oldest', 'Oldest')}
+ ${sortOpt('title_az', 'Title A→Z')}
+ ${sortOpt('title_za', 'Title Z→A')}
+ ${sortOpt('rating', 'Highest rated')}
+ </select>
+ </label>
+ <label class="control"><span style="font-size:11px;letter-spacing:0.08em;color:var(--ink-faint);margin-right:4px">Density</span>
+ <input type="range" min="140" max="280" step="20" value="180" data-density="movies">
+ </label>
<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}
+ :root{--movies-card:180px}
+ .filter-bar{display:flex;gap:10px;flex-wrap:wrap;align-items:center;margin-bottom:24px}
+ .filter-bar input[type=search],.filter-bar input[type=text]{background:transparent;border:1px solid var(--line);color:inherit;padding:7px 12px;border-radius:6px;font:inherit;min-width:140px}
+ .filter-bar .control{display:inline-flex;align-items:center;gap:6px}
+ .filter-bar select{background:transparent;border:1px solid var(--line);color:inherit;padding:7px 12px;border-radius:6px;font:inherit}
+ .filter-bar input[type=range]{accent-color:var(--gold)}
+ .grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(var(--movies-card),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}
@@ -478,7 +506,8 @@ app.get('/movies', async (req, res) => {
<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}`;
+ ${pager}
+ <script>(function(){var KEY='asim_movies_density';var saved=parseInt(localStorage.getItem(KEY)||'180',10);var r=document.querySelector('[data-density="movies"]');if(!r)return;if(saved&&saved>=140&&saved<=280){r.value=saved;document.documentElement.style.setProperty('--movies-card',saved+'px')}r.addEventListener('input',function(){document.documentElement.style.setProperty('--movies-card',r.value+'px');localStorage.setItem(KEY,r.value)})})();</script>`;
res.type('html').send(htmlShell('Movies', body));
} catch (e) {
res.status(500).type('html').send(htmlShell('Error', `<div class="empty">${esc(e.message)}</div>`));
@@ -488,10 +517,19 @@ app.get('/movies', async (req, res) => {
// /people — paginated grid of people with headshots, popularity-sorted.
app.get('/people', async (req, res) => {
try {
+ const PEOPLE_SORTS = {
+ default: `(claimed_at IS NOT NULL) DESC, full_name`,
+ name_az: `full_name ASC`,
+ name_za: `full_name DESC`,
+ youngest: `birth_year DESC NULLS LAST, full_name`,
+ oldest: `birth_year ASC NULLS LAST, full_name`,
+ living: `(death_year IS NULL) DESC, full_name`,
+ };
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 sort = PEOPLE_SORTS[req.query.sort] ? req.query.sort : 'default';
const params = [];
let where = `headshot_url IS NOT NULL`;
if (q) { params.push(`%${q.toLowerCase()}%`); where += ` AND LOWER(full_name) LIKE $${params.length}`; }
@@ -500,7 +538,7 @@ app.get('/people', async (req, res) => {
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
+ ORDER BY ${PEOPLE_SORTS[sort]}
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);
@@ -516,6 +554,7 @@ app.get('/people', async (req, res) => {
const qs = (overrides = {}) => {
const u = new URLSearchParams();
if (q) u.set('q', q);
+ if (sort && sort !== 'default') u.set('sort', sort);
Object.entries(overrides).forEach(([k,v]) => v == null ? u.delete(k) : u.set(k, v));
return u.toString();
};
@@ -525,11 +564,16 @@ app.get('/people', async (req, res) => {
<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 sortOpt = (val, label) => `<option value="${val}"${sort===val?' selected':''}>${label}</option>`;
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}
+ :root{--people-card:160px}
+ .filter-bar{display:flex;gap:10px;flex-wrap:wrap;align-items:center;margin-bottom:24px}
+ .filter-bar input[type=search]{background:transparent;border:1px solid var(--line);color:inherit;padding:7px 12px;border-radius:6px;font:inherit;min-width:200px}
+ .filter-bar .control{display:inline-flex;align-items:center;gap:6px}
+ .filter-bar select{background:transparent;border:1px solid var(--line);color:inherit;padding:7px 12px;border-radius:6px;font:inherit}
+ .filter-bar input[type=range]{accent-color:var(--gold)}
+ .grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(var(--people-card),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}
@@ -542,10 +586,24 @@ app.get('/people', async (req, res) => {
<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 || '')}">
+ <label class="control"><span style="font-size:11px;letter-spacing:0.08em;color:var(--ink-faint);margin-right:4px">Sort</span>
+ <select name="sort" onchange="this.form.submit()">
+ ${sortOpt('default', 'Claimed first')}
+ ${sortOpt('name_az', 'Name A→Z')}
+ ${sortOpt('name_za', 'Name Z→A')}
+ ${sortOpt('youngest', 'Youngest')}
+ ${sortOpt('oldest', 'Oldest')}
+ ${sortOpt('living', 'Living first')}
+ </select>
+ </label>
+ <label class="control"><span style="font-size:11px;letter-spacing:0.08em;color:var(--ink-faint);margin-right:4px">Density</span>
+ <input type="range" min="120" max="280" step="20" value="160" data-density="people">
+ </label>
<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}`;
+ ${pager}
+ <script>(function(){var KEY='asim_people_density';var saved=parseInt(localStorage.getItem(KEY)||'160',10);var r=document.querySelector('[data-density="people"]');if(!r)return;if(saved&&saved>=120&&saved<=280){r.value=saved;document.documentElement.style.setProperty('--people-card',saved+'px')}r.addEventListener('input',function(){document.documentElement.style.setProperty('--people-card',r.value+'px');localStorage.setItem(KEY,r.value)})})();</script>`;
res.type('html').send(htmlShell('People', body));
} catch (e) {
res.status(500).type('html').send(htmlShell('Error', `<div class="empty">${esc(e.message)}</div>`));
← 96cf4db scaffold wikidata-commons-fallback.js — search Commons for h
·
back to AsSeenInMovies
·
wikidata-enrich: add --byCast (filter to role=actor) to targ 8205ad7 →