← back to Stayclaim
src/app/film/[slug]/page.tsx
197 lines
import Link from 'next/link';
import { notFound } from 'next/navigation';
import type { Metadata } from 'next';
import { getProductionBySlug, getLocationsForProduction, yearOf } from '@/lib/db';
import { BreadcrumbArchive } from '@/components/BreadcrumbArchive';
import { TierBadge } from '@/components/TierBadge';
import { JsonLd } from '@/components/JsonLd';
import { InteractiveMapMount, type MapPin } from '@/components/InteractiveMapMount';
import { PosterPlaceholder } from '@/components/PosterPlaceholder';
import { CrossPromo } from '@/components/CrossPromo';
import { FilmsGridServer } from '@/components/FilmsGridServer';
import { SITE_DOMAIN, currentSurface } from '@/lib/site';
const KIND_LABEL: Record<string, string> = {
film: 'Feature film',
tv: 'Television',
commercial: 'Commercial',
music_video: 'Music video',
documentary: 'Documentary',
short: 'Short',
student: 'Student',
};
export async function generateMetadata({ params }: { params: { slug: string } }): Promise<Metadata> {
const p = await getProductionBySlug(params.slug);
if (!p) return { robots: { index: false, follow: false } };
return {
title: `${p.title}${p.year ? ` (${p.year})` : ''}`,
description: `${KIND_LABEL[p.kind] ?? p.kind} — addresses in the archive used as filming locations.`,
alternates: { canonical: `/film/${p.slug}` },
openGraph: { title: p.title, type: 'article' },
};
}
export default async function FilmPage({ params }: { params: { slug: string } }) {
const production = await getProductionBySlug(params.slug);
if (!production) notFound();
const [surface, locs] = await Promise.all([
currentSurface(),
getLocationsForProduction(production.id),
]);
const baseUrl = process.env.NEXT_PUBLIC_BASE_URL ?? `https://${SITE_DOMAIN}`;
const jsonLd: Record<string, unknown> = {
'@context': 'https://schema.org',
'@type': production.kind === 'tv' ? 'TVSeries' : 'Movie',
name: production.title,
url: `${baseUrl}/film/${production.slug}`,
};
if (production.year) jsonLd.dateCreated = String(production.year);
if (production.imdb_id) jsonLd.sameAs = [`https://www.imdb.com/title/${encodeURIComponent(production.imdb_id)}/`];
const pins: MapPin[] = locs
.map(l => {
const lat = Number(l.listing_lat);
const lng = Number(l.listing_lng);
if (!Number.isFinite(lat) || !Number.isFinite(lng)) return null;
return {
slug: l.listing_slug,
title: l.listing_title,
subtitle: l.role ?? 'Location',
lat,
lng,
};
})
.filter((p): p is NonNullable<typeof p> => p !== null);
return (
<>
<JsonLd data={jsonLd} />
<div className="max-w-4xl mx-auto px-6 pt-6">
<BreadcrumbArchive
items={[
{ label: 'Archive', href: '/' },
{ label: 'Films + TV', href: '/films' },
{ label: production.title },
]}
/>
</div>
<header className="max-w-4xl mx-auto px-6 py-12 border-b border-ink/10 grid md:grid-cols-[200px_1fr] gap-8 items-start">
<div className="border border-ink/10 overflow-hidden">
{production.poster_url && /^https?:\/\//i.test(production.poster_url) ? (
// eslint-disable-next-line @next/next/no-img-element
<img src={production.poster_url} alt={production.title} className="w-full block" />
) : (
<PosterPlaceholder
title={production.title}
year={production.year}
kind={production.kind}
width={200}
height={300}
className="w-full block"
/>
)}
</div>
<div>
<p className="text-xs uppercase tracking-[0.15em] text-ink/50 mb-2">
{KIND_LABEL[production.kind] ?? production.kind} {production.year ? `· ${production.year}` : ''}
</p>
<h1 className="font-display text-5xl md:text-6xl text-ink tracking-[-0.02em] leading-[1.02]">
{production.title}
</h1>
{production.blurb && (
<p className="mt-5 font-display italic text-xl text-ink/80 max-w-prose">{production.blurb}</p>
)}
<div className="mt-4 flex flex-wrap items-center gap-4">
<p className="font-mono text-[11px] text-ink/50">
{locs.length} {locs.length === 1 ? 'location' : 'locations'} pulled from public film permits
</p>
{production.imdb_id && (
<a
href={`https://www.imdb.com/title/${encodeURIComponent(production.imdb_id)}/`}
target="_blank"
rel="noopener noreferrer"
className="font-mono text-[11px] uppercase tracking-[0.15em] text-ink/60 hover:text-coral transition"
>
IMDb · {production.imdb_id} →
</a>
)}
{production.wikidata_qid && (
<a
href={`https://www.wikidata.org/wiki/${encodeURIComponent(production.wikidata_qid)}`}
target="_blank"
rel="noopener noreferrer"
className="font-mono text-[11px] uppercase tracking-[0.15em] text-ink/60 hover:text-coral transition"
>
Wikidata · {production.wikidata_qid} →
</a>
)}
</div>
</div>
</header>
{pins.length > 0 && (
<div className="max-w-4xl mx-auto px-6 mt-10">
<InteractiveMapMount pins={pins} height={320} variant="positron" />
</div>
)}
<section className="max-w-4xl mx-auto px-6 py-12">
<h2 className="font-display text-3xl text-ink tracking-[-0.01em] mb-6">Where it filmed</h2>
{locs.length === 0 ? (
<p className="text-sm italic text-ink/55">No public location permits on file yet.</p>
) : (
<ol className="divide-y divide-ink/10">
{locs.map(l => (
<li key={l.id} className="py-5 grid md:grid-cols-[120px_1fr_auto] gap-3 md:gap-6 items-baseline">
<time className="font-mono text-xs uppercase tracking-wider text-ink/60">
{yearOf(l.shoot_date_from)}
</time>
<div>
<Link href={`/address/${l.listing_slug}`} className="font-display text-2xl text-ink hover:text-coral transition leading-tight">
{l.listing_title}
</Link>
{l.scene_note && (
<p className="mt-1 text-sm text-ink/65 leading-relaxed">{l.scene_note}</p>
)}
<div className="mt-2 flex gap-3 items-center flex-wrap">
<span className="text-[10px] uppercase tracking-[0.15em] text-ink/50">{l.role ?? 'on location'}</span>
{(() => {
const tier = (['A', 'B', 'C'] as const).includes(l.source_tier as 'A' | 'B' | 'C')
? (l.source_tier as 'A' | 'B' | 'C')
: 'C';
const safeUrl = l.source_url && /^https?:\/\//i.test(l.source_url) ? l.source_url : null;
return safeUrl ? (
<a href={safeUrl} target="_blank" rel="noopener noreferrer">
<TierBadge tier={tier} sourceLabel={l.source_label ?? undefined} />
</a>
) : (
<TierBadge tier={tier} sourceLabel={l.source_label ?? undefined} />
);
})()}
</div>
</div>
<span className="font-mono text-[10px] uppercase tracking-[0.15em] text-ink/45 md:text-right">
{l.permit_number}
</span>
</li>
))}
</ol>
)}
</section>
<FilmsGridServer
heading="Public-domain stills + posters"
kicker="Provenance: Wikimedia · Internet Archive · LoC · NARA"
defaultCols={5}
productionId={production.id}
limit={36}
/>
<CrossPromo current={surface.key} />
</>
);
}