← 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&rsquo;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>
  );
}