← back to Stayclaim
src/components/FilmsGrid.tsx
295 lines
'use client';
/**
* FilmsGrid — public-domain film image grid with adjustable column count.
*
* Three surfaces, three real visual personalities (per Four Horsemen design spec):
*
* flagship (wholivedthere) — Museum wall.
* 4 cols default · 3:4 portrait · hard-cropped · no card borders ·
* staggered first row (col 1 drops 32px) · thin hairline slider ·
* hover desaturates the image (close inspection of a photograph).
*
* claim (claimmyaddress) — Evidence board.
* 5 cols default · 16:9 landscape · 4px red left border on every card ·
* pill-shaped red slider · sans-serif heading (no serif on this surface) ·
* hover lifts card 4px and brightens the red flag · ghost-grid empty state.
*
* community (bubbesblock) — Scrapbook.
* 4 cols default · mixed aspect ratios · slight rotation per nth-child
* (cards look pinned to a board) · soft gold shadow (no hard border) ·
* slider labelled "Show more / fewer" (community language, not "Density") ·
* hover straightens the card to 0deg.
*
* Each card always links to its source page (Wikimedia / IA / LoC / NARA / MHDL /
* Smithsonian / Openverse / Met / DPLA) so provenance is one click away.
*/
import { useState, useMemo, useCallback } from 'react';
import type { SurfaceKey } from '@/lib/site';
import { safeHttpUrl as safeHttp } from '@/lib/url-guards';
export type FilmsGridImage = {
id: string;
source: 'wikimedia' | 'archive_org' | 'loc' | 'nara' | 'mhdl' | 'smithsonian' | 'nypl' | 'flickr_commons' | 'openverse' | 'met' | 'dpla';
title: string;
year: number | null;
image_url: string;
thumb_url: string | null;
source_url: string;
};
const SOURCE_LABEL: Record<FilmsGridImage['source'], string> = {
wikimedia: 'Wikimedia',
archive_org: 'Internet Archive',
loc: 'Library of Congress',
nara: 'National Archives',
mhdl: 'Media History DL',
smithsonian: 'Smithsonian',
nypl: 'NYPL',
flickr_commons: 'Flickr Commons',
openverse: 'Openverse',
met: 'Met Museum',
dpla: 'DPLA',
};
const MIN_COLS = 3;
const MAX_COLS = 12;
type SurfaceTreatment = {
defaultCols: number;
gap: string; // CSS gap value
cardClass: string; // outer card classes
cardStyle?: (idx: number) => React.CSSProperties | undefined;
thumbClass: string; // image classes
captionClass: string; // text-block under image
headerKickerClass: string;
headerH2Class: string;
supportingCopy?: string; // sentence under heading
sliderLabel: string; // "Density" vs "Show more / fewer"
sliderTrackClass: string;
sliderFillClass: string;
hoverOverlayCss: string; // additional hover behavior in <style>
emptyStateRender: () => React.ReactNode;
surfaceWrapperClass: string;
};
const SURFACE: Record<SurfaceKey, SurfaceTreatment> = {
// ───────── flagship: museum wall ─────────
flagship: {
defaultCols: 4,
gap: '1.5rem 1.25rem',
cardClass: 'group block bg-transparent transition fg-card',
cardStyle: (idx) => idx === 0 ? { transform: 'translateY(32px)' } : undefined,
thumbClass: 'w-full block aspect-[3/4] object-cover',
captionClass: 'pt-3 pb-5 text-[#1a1a1a]',
headerKickerClass: 'text-[10px] uppercase tracking-[0.18em] text-[#1f4d40]',
headerH2Class: 'font-display text-4xl md:text-5xl tracking-[-0.02em] text-[#1a1a1a] font-light leading-[1.05]',
supportingCopy: 'Public-domain films and television, drawn from Wikimedia, the Internet Archive, the Library of Congress, the Smithsonian, the Met, and the Media History Digital Library.',
sliderLabel: 'Density',
sliderTrackClass: 'bg-[#1f4d40]/15',
sliderFillClass: 'bg-[#1f4d40]',
hoverOverlayCss: '.fg-card { position: relative; } .fg-card img { transition: filter 320ms ease; } .fg-card::after { content: ""; position: absolute; inset: 0; pointer-events: none; box-shadow: inset 0 0 80px rgba(31,77,64,0); transition: box-shadow 320ms ease; } .fg-card:hover img { filter: saturate(0.30) brightness(0.96); } .fg-card:hover::after { box-shadow: inset 0 0 80px rgba(31,77,64,0.35); } .fg-card:hover .fg-title { text-decoration: underline; text-decoration-color: #1f4d40; text-underline-offset: 4px; }',
emptyStateRender: () => (
<div className="grid grid-cols-3 gap-6 max-w-2xl mx-auto">
{[0,1,2].map(i => (
<div key={i} className="aspect-[3/4] border border-dashed border-[#1f4d40]/40 bg-[#f3ebd9]" />
))}
<p className="col-span-3 mt-6 text-center font-display italic text-lg text-[#6b6157]">
Images loading from the public archive.
</p>
</div>
),
surfaceWrapperClass: 'max-w-6xl mx-auto px-6 py-16',
},
// ───────── claim: evidence board ─────────
// v2: structural redo per UX critique — drop the thin red border (read as
// "admin dashboard"), shift to a case-file/index-card metaphor:
// * card on warm off-white over a deeper bone field
// * faint horizontal ruled-paper lines via repeating-linear-gradient
// * red corner stamp ("FILED") instead of left border
// * 4:5 case-photo crop instead of 16:9 video-thumbnail
// * cards rotate slightly on hover ("dropping into the file")
claim: {
defaultCols: 5,
gap: '1.25rem 1rem',
cardClass: 'group block bg-[#fdf9ee] transition cl-card relative',
thumbClass: 'w-full block aspect-[4/5] object-cover',
captionClass: 'px-3 pt-2 pb-3 text-[#1a1a1a] cl-caption',
headerKickerClass: 'inline-block text-[10px] uppercase tracking-[0.25em] text-white bg-[#c9292e] px-2 py-1 font-mono font-bold',
// Typewriter-adjacent institutional H2 — UX critique called out type uniformity
headerH2Class: 'mt-3 font-mono text-xl md:text-2xl tracking-tight text-[#1a1a1a] font-bold uppercase',
sliderLabel: 'Density',
sliderTrackClass: 'bg-[#1a1a1a]/15',
sliderFillClass: 'bg-[#c9292e]',
hoverOverlayCss: [
// Card baseline — case-file paper texture via faint horizontal lines on the caption strip
'.cl-card { box-shadow: 0 1px 0 rgba(0,0,0,0.08); transition: transform 180ms ease, box-shadow 180ms ease; will-change: transform; }',
// Subtle case-file ruled lines on the caption block
'.cl-caption { background-image: repeating-linear-gradient(to bottom, transparent 0, transparent 12px, rgba(201,41,46,0.08) 12px, rgba(201,41,46,0.08) 13px); }',
// Red corner triangle "stamp" — top-right corner of every card
'.cl-card::before { content: ""; position: absolute; top: 0; right: 0; width: 28px; height: 28px; background: linear-gradient(225deg, #c9292e 0 50%, transparent 50% 100%); pointer-events: none; z-index: 1; }',
// FILED label — small red chevron mark inside the corner
'.cl-card::after { content: "F"; position: absolute; top: 1px; right: 4px; font: 700 9px/1 monospace; color: white; z-index: 2; pointer-events: none; }',
// Hover — slight rotation + lift like dropping into the file
'.cl-card:hover { transform: rotate(-0.6deg) translateY(-3px); box-shadow: 0 10px 24px -10px rgba(201,41,46,0.45), 0 1px 0 rgba(0,0,0,0.08); }',
'.cl-card:hover .fg-title { color: #c9292e; }',
].join(' '),
emptyStateRender: () => (
<div className="border-2 border-dashed border-[#c9292e]/50 p-10 text-center bg-[#fdf9ee]">
<p className="font-mono text-xs uppercase tracking-[0.2em] text-[#c9292e] mb-3">— Empty file —</p>
<p className="font-sans text-sm text-[#1a1a1a]">
No productions found at this address yet. Results populate as you search.
</p>
</div>
),
// Cooler/grayer bone (Manila-paper, institutional) — distinguishes from flagship's warm parchment
surfaceWrapperClass: 'max-w-7xl mx-auto px-6 py-12 bg-[#ddd5c2]',
},
// ───────── community: scrapbook ─────────
community: {
defaultCols: 4,
gap: '1.25rem 1.5rem',
cardClass: 'group block bg-[#f0e3c9] rounded-md transition co-card',
cardStyle: (idx) => {
const rotations = [-1.5, 1, -0.5, 0.8, -1.2, 0.5];
const r = rotations[idx % rotations.length];
const offset = idx % 2 === 1 ? 16 : 0;
return { transform: `rotate(${r}deg) translateX(${offset}px)`, transformOrigin: 'center' };
},
// mixed aspects via nth-child in the hover style block
thumbClass: 'w-full block object-cover rounded-sm co-thumb',
captionClass: 'px-3 py-2 text-[#6b6157] font-display italic',
headerKickerClass: 'text-[13px] tracking-[0.05em] text-[#c5a572] font-display italic',
// Warmer/heavier per UX critique — italic + heavier display weight, larger feel
headerH2Class: 'font-display italic text-4xl md:text-5xl tracking-[-0.01em] text-[#1a1a1a] leading-[1.1]',
supportingCopy: 'Films and shows that brought these streets to life — preserved in the public record.',
sliderLabel: 'Show fewer / more',
sliderTrackClass: 'bg-[#c5a572]/30',
sliderFillClass: 'bg-[#c5a572]',
// R4 fix: landscape-dominant aspects (4:3 / 16:9 / 3:2 / 1:1) so bubbe stops
// looking like flagship's portrait grid. Scrapbook = horizontal clippings.
hoverOverlayCss: '.co-card { transition: transform 320ms cubic-bezier(0.34, 1.56, 0.64, 1), box-shadow 280ms ease; box-shadow: 0 4px 12px -6px rgba(197,165,114,0.35); } .co-card:hover { transform: rotate(0deg) translateX(0) !important; box-shadow: 0 12px 28px -10px rgba(197,165,114,0.55); z-index: 2; } .co-thumb { aspect-ratio: 4 / 3; } .co-card:nth-child(3n) .co-thumb { aspect-ratio: 16 / 9; } .co-card:nth-child(4n) .co-thumb { aspect-ratio: 3 / 2; } .co-card:nth-child(5n) .co-thumb { aspect-ratio: 1 / 1; }',
emptyStateRender: () => (
<p className="text-center font-display italic text-3xl md:text-4xl text-[#c5a572] py-16">
The neighborhood hasn’t made its debut yet.
</p>
),
// Warmer field bg than flagship's parchment — distinguishes scrapbook from museum
surfaceWrapperClass: 'max-w-6xl mx-auto px-8 py-16 bg-[#ede0c4]',
},
};
export function FilmsGrid({
images,
surfaceKey,
heading,
kicker,
defaultCols,
}: {
images: FilmsGridImage[];
surfaceKey: SurfaceKey;
heading: string;
kicker: string;
defaultCols?: number;
}) {
const T = SURFACE[surfaceKey];
const initialCols = Math.max(MIN_COLS, Math.min(MAX_COLS, defaultCols ?? T.defaultCols));
const [cols, setCols] = useState<number>(initialCols);
const onSlide = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
setCols(Number(e.target.value));
}, []);
const gridStyle = useMemo<React.CSSProperties>(
() => ({ gridTemplateColumns: `repeat(${cols}, minmax(0, 1fr))`, gap: T.gap }),
[cols, T.gap]
);
const fillPct = ((cols - MIN_COLS) / (MAX_COLS - MIN_COLS)) * 100;
if (!images.length) {
return (
<section className={T.surfaceWrapperClass}>
<p className={`mb-3 ${T.headerKickerClass}`}>{kicker}</p>
<h2 className={`mb-8 ${T.headerH2Class}`}>{heading}</h2>
{T.emptyStateRender()}
</section>
);
}
return (
<section className={T.surfaceWrapperClass}>
<style>{T.hoverOverlayCss}</style>
<div className="flex items-end justify-between gap-6 mb-10 flex-wrap">
<div className="max-w-2xl">
<p className={`mb-3 ${T.headerKickerClass}`}>{kicker}</p>
<h2 className={T.headerH2Class}>{heading}</h2>
{T.supportingCopy && (
<p className="mt-4 text-sm md:text-base text-[#6b6157] leading-relaxed max-w-prose">
{T.supportingCopy}
</p>
)}
</div>
<label className="flex items-center gap-3 select-none shrink-0">
<span className="text-[10px] uppercase tracking-[0.15em] text-[#6b6157] hidden sm:inline">
{T.sliderLabel}
</span>
<span className="font-mono text-[11px] text-[#6b6157] tabular-nums w-6 text-right">{cols}</span>
<span className="relative h-1 w-44 sm:w-56">
<span className={`absolute inset-0 ${T.sliderTrackClass}`} aria-hidden />
<span
className={`absolute inset-y-0 left-0 ${T.sliderFillClass}`}
style={{ width: `${fillPct}%` }}
aria-hidden
/>
<input
type="range"
min={MIN_COLS}
max={MAX_COLS}
step={1}
value={cols}
onChange={onSlide}
aria-label={`Grid columns: ${cols} of ${MAX_COLS}`}
className="absolute inset-0 w-full h-full opacity-0 cursor-pointer"
/>
</span>
</label>
</div>
<div className="grid" style={gridStyle}>
{images.map((img, idx) => {
const href = safeHttp(img.source_url);
const imgSrc = safeHttp(img.thumb_url ?? img.image_url);
if (!href || !imgSrc) return null;
return (
<a
key={img.id}
href={href}
target="_blank"
rel="noopener noreferrer"
className={T.cardClass}
style={T.cardStyle?.(idx)}
title={`${img.title}${img.year ? ` (${img.year})` : ''} — ${SOURCE_LABEL[img.source]}`}
>
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={imgSrc}
alt={img.title}
loading="lazy"
className={T.thumbClass}
/>
<div className={T.captionClass}>
<span className="block truncate fg-title text-sm" title={img.title}>{img.title}</span>
<span className="block opacity-60 text-[10px] mt-1">
{img.year ?? '—'} · {SOURCE_LABEL[img.source]}
</span>
</div>
</a>
);
})}
</div>
</section>
);
}