← back to Stayclaim
src/components/HeroCollage.tsx
196 lines
/**
* HeroCollage — full-bleed collage of public-domain images shown above the
* fold on each homepage. Visual entry-point that gives the surface immediate
* personality before the user sees anything else.
*
* Per-surface metaphor (matches FilmsGrid spec):
* flagship → museum wall: 4 portraits in a row, hairline framed, staggered
* claim → evidence board: 5 images with red corner stamps, tight grid
* community → scrapbook: 4 images mixed-aspect, rotated, gold soft shadow
*
* Reads from pd_film_image (excluding ORB-blocked sources). Server component.
*/
import { listPdFilmImages, type PdFilmImage } from '@/lib/db';
import { currentSurface, type SurfaceKey } from '@/lib/site';
import { safeHttpUrl } from '@/lib/url-guards';
const FLAGSHIP_COUNT = 4;
const CLAIM_COUNT = 5;
const COMMUNITY_COUNT = 6;
// Per-surface source affinity — breaks the homogenized image pool that the
// UX agent flagged in R3 as the highest-leverage fix. Each surface pulls
// from a distinct mix so the underlying image character matches the metaphor:
// flagship → Met Museum + LoC (architectural, photographic, archival)
// claim → Internet Archive + MHDL (movie posters, lobby cards, stills)
// community → Openverse + Wikimedia (community-uploaded, documentary)
// Per-surface source affinity — each surface pulls from a visually distinct
// PD pool so the metaphor (museum / case-file / scrapbook) is reinforced by
// the underlying image character, not just the chrome around it.
const SOURCE_PREFERENCE: Record<SurfaceKey, PdFilmImage['source'][]> = {
flagship: ['met', 'loc'], // architectural, photographic, museum-archival
claim: ['archive_org', 'mhdl'], // movie posters, lobby cards, dense pulpy stills
community: ['mhdl'], // R4 fix: MHDL only = magazine pages + newspapers + flyers
// (horizontal, text-heavy, scrapbook character)
};
export async function HeroCollage() {
const site = await currentSurface();
const count = site.key === 'flagship' ? FLAGSHIP_COUNT : site.key === 'claim' ? CLAIM_COUNT : COMMUNITY_COUNT;
// Try preferred sources first; fall back to global pool if too few.
const preferred = await listPdFilmImages({ limit: count * 3, sources: SOURCE_PREFERENCE[site.key] });
const pool = preferred.length >= count ? preferred : await listPdFilmImages({ limit: count * 3 });
if (!pool.length) return null;
// Pick deterministically per source mix (interleave already gives source variety).
const images = pool.slice(0, count);
if (site.key === 'flagship') return <FlagshipHero images={images} />;
if (site.key === 'claim') return <ClaimHero images={images} />;
return <CommunityHero images={images} />;
}
function FlagshipHero({ images }: { images: PdFilmImage[] }) {
// Museum wall — 4 portraits in a row, hairline frame, first drops 24px
return (
<section className="bg-[#f3ebd9] py-10 md:py-14 border-b border-[#1f4d40]/10">
<div className="max-w-6xl mx-auto px-6">
<p className="text-[10px] uppercase tracking-[0.18em] text-[#1f4d40] mb-2">Today on the wall</p>
<h1 className="font-display text-3xl md:text-5xl text-[#1a1a1a] tracking-[-0.02em] leading-[1.05] mb-4 max-w-2xl">
Every address has a story. We’re writing them down.
</h1>
<p className="font-display italic text-base md:text-lg text-[#6b6157] mb-8 max-w-2xl">
From the public archive — films, photographs, deeds, and the records that survive a place.
</p>
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 md:gap-6">
{images.map((img, i) => (
<a
key={img.id}
href={safeHttpUrl(img.source_url) ?? "#"}
target="_blank"
rel="noopener noreferrer"
className="block group"
style={i === 0 ? { transform: 'translateY(24px)' } : undefined}
title={`${img.title}${img.year ? ` (${img.year})` : ''}`}
>
<div className="border border-[#1f4d40]/20 p-1 bg-white">
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={safeHttpUrl(img.thumb_url ?? img.image_url) ?? ""}
alt={img.title}
loading="eager"
className="w-full block aspect-[3/4] object-cover transition group-hover:opacity-85"
/>
</div>
<p className="font-display italic text-sm text-[#6b6157] mt-2 truncate">{img.title}</p>
</a>
))}
</div>
</div>
</section>
);
}
function ClaimHero({ images }: { images: PdFilmImage[] }) {
// Evidence board — 5 case-photo cards with red FILED corner stamps
return (
<section className="bg-[#ddd5c2] py-10 md:py-14 border-b-2 border-[#c9292e]">
<div className="max-w-7xl mx-auto px-6">
<span className="inline-block bg-[#c9292e] text-white px-3 py-1 text-[10px] font-mono tracking-[0.25em] uppercase mb-3">
On file
</span>
<h1 className="font-mono text-2xl md:text-4xl text-[#1a1a1a] uppercase font-bold tracking-tight mb-4 max-w-3xl leading-tight">
Your address has a record. We’ll write it down for you.
</h1>
<p className="font-sans text-sm md:text-base text-[#1a1a1a]/70 mb-8 max-w-2xl">
Permits. Sales. The films that were shot at your door. A free, permanent file of your home’s public history.
</p>
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-5 gap-3">
{images.map(img => (
<a
key={img.id}
href={safeHttpUrl(img.source_url) ?? "#"}
target="_blank"
rel="noopener noreferrer"
className="block bg-[#fdf9ee] relative cl-hero-card"
title={`${img.title}${img.year ? ` (${img.year})` : ''}`}
>
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={safeHttpUrl(img.thumb_url ?? img.image_url) ?? ""}
alt={img.title}
loading="eager"
className="w-full block aspect-[4/5] object-cover"
/>
<div className="px-2 py-2 font-mono text-[10px] uppercase tracking-[0.1em] text-[#1a1a1a] truncate">
{img.title}
</div>
</a>
))}
</div>
</div>
<style>{`.cl-hero-card { box-shadow: 0 1px 0 rgba(0,0,0,0.08); transition: transform 180ms ease, box-shadow 180ms ease; } .cl-hero-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; } .cl-hero-card::after { content: "F"; position: absolute; top: 1px; right: 4px; font: 700 9px/1 monospace; color: white; z-index: 2; } .cl-hero-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); }`}</style>
</section>
);
}
function CommunityHero({ images }: { images: PdFilmImage[] }) {
// Scrapbook v2 — R4 fix: landscape/horizontal aspects so it stops looking
// like a vertical portrait grid identical to flagship. MHDL pool = magazine
// pages + newspaper clippings + flyers, naturally text-heavy.
// Layout: hand-pinned bulletin board with overlapping cards, varied scale.
const rotations = [-3, 1.8, -1.5, 2.2, -2, 1, -1.2];
// Mix landscape + square; NO portraits. Different sizes — some span 2 cols.
const layout = [
{ aspect: '4/3', span: 'md:col-span-2' }, // first card big landscape
{ aspect: '3/2', span: 'md:col-span-1' },
{ aspect: '16/9', span: 'md:col-span-1' }, // wide newspaper-strip
{ aspect: '1/1', span: 'md:col-span-1' },
{ aspect: '3/2', span: 'md:col-span-2' }, // big landscape again
{ aspect: '4/3', span: 'md:col-span-1' },
];
return (
<section className="bg-[#ede0c4] py-12 md:py-16">
<div className="max-w-6xl mx-auto px-6">
<p className="font-display italic text-[#c5a572] text-sm md:text-base mb-2">Pinned on the corkboard</p>
<h1 className="font-display italic text-4xl md:text-6xl text-[#1a1a1a] tracking-[-0.01em] leading-[1.1] mb-4 max-w-3xl">
The block has stories. We’re saving the pages.
</h1>
<p className="font-display italic text-base md:text-lg text-[#6b6157] mb-10 max-w-2xl">
Old clippings, neighborhood pages, family things. The history a block keeps in its drawers.
</p>
<div className="grid grid-cols-2 md:grid-cols-4 gap-5 md:gap-7 items-start">
{images.map((img, i) => {
const r = rotations[i % rotations.length];
const { aspect, span } = layout[i % layout.length];
return (
<a
key={img.id}
href={safeHttpUrl(img.source_url) ?? "#"}
target="_blank"
rel="noopener noreferrer"
className={`block bg-[#f0e3c9] rounded-md p-2 co-hero-card ${span}`}
style={{ transform: `rotate(${r}deg)` }}
title={`${img.title}${img.year ? ` (${img.year})` : ''}`}
>
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={safeHttpUrl(img.thumb_url ?? img.image_url) ?? ""}
alt={img.title}
loading="eager"
className="w-full block object-cover rounded-sm"
style={{ aspectRatio: aspect }}
/>
<p className="font-display italic text-sm text-[#6b6157] mt-2 px-1 truncate">{img.title}</p>
{/* Push-pin dot in upper-left for the bulletin-board cue */}
<span aria-hidden className="absolute top-1 left-1 w-2 h-2 rounded-full bg-[#c9292e] shadow-sm" />
</a>
);
})}
</div>
</div>
<style>{`.co-hero-card { position: relative; transition: transform 320ms cubic-bezier(0.34, 1.56, 0.64, 1), box-shadow 280ms ease; box-shadow: 0 6px 14px -6px rgba(197,165,114,0.5); } .co-hero-card:hover { transform: rotate(0deg) !important; box-shadow: 0 14px 32px -10px rgba(197,165,114,0.6); z-index: 2; }`}</style>
</section>
);
}