← back to Stayclaim
src/app/timeline/page.tsx
242 lines
import Link from 'next/link';
import type { Metadata } from 'next';
import { getArchiveTimeline, listNeighborhoods, yearOf } from '@/lib/db';
import { BreadcrumbArchive } from '@/components/BreadcrumbArchive';
import { TierBadge, isPublicTier } from '@/components/TierBadge';
export async function generateMetadata({
searchParams,
}: {
searchParams: { kind?: string; decade?: string; city?: string };
}): Promise<Metadata> {
const filtered = !!(searchParams.kind || searchParams.decade || searchParams.city);
return {
title: 'Timeline',
description: 'The archive in chronological order — every event, every address, every source.',
alternates: { canonical: '/timeline' },
robots: filtered ? { index: false, follow: true } : { index: true, follow: true },
};
}
const KIND_LABEL: Record<string, string> = {
permit: 'Permit',
sale: 'Sale',
remodel: 'Remodel',
demolition: 'Demolition',
designation: 'Historic designation',
media: 'Media',
occupancy: 'Occupancy',
};
const KINDS = ['permit', 'sale', 'remodel', 'media'] as const;
const DECADES = ['2020s', '2010s', '2000s', '1990s', '1980s', '1970s', '1960s', '1950s', '1940s', '1930s', '1920s'] as const;
/** Allow only http/https links from external/scraped data. Blocks javascript:, data:, vbscript:, etc. */
function isSafeHttpUrl(u: string | null | undefined): u is string {
if (!u) return false;
try {
const parsed = new URL(u);
return parsed.protocol === 'http:' || parsed.protocol === 'https:';
} catch {
return false;
}
}
function pickFromAllowlist<T extends string>(value: string | undefined, allowed: readonly T[]): T | undefined {
if (!value) return undefined;
return (allowed as readonly string[]).includes(value) ? (value as T) : undefined;
}
function FilterChip({
active,
href,
children,
}: {
active: boolean;
href: string;
children: React.ReactNode;
}) {
return (
<Link
href={href}
className={`inline-flex items-center px-3 py-1 text-[11px] uppercase tracking-[0.15em] border transition ${
active
? 'bg-ink text-sand border-ink'
: 'border-ink/20 text-ink/65 hover:border-coral hover:text-coral'
}`}
>
{children}
</Link>
);
}
function buildHref(current: { kind?: string; decade?: string; city?: string }, patch: Record<string, string | undefined>) {
const next = { ...current, ...patch };
const params = new URLSearchParams();
for (const [k, v] of Object.entries(next)) {
if (v) params.set(k, v);
}
const qs = params.toString();
return qs ? `/timeline?${qs}` : '/timeline';
}
export default async function TimelinePage({
searchParams,
}: {
searchParams: { kind?: string; decade?: string; city?: string };
}) {
const safeKind = pickFromAllowlist(searchParams.kind, KINDS);
const safeDecade = pickFromAllowlist(searchParams.decade, DECADES);
const cities = await listNeighborhoods();
const safeCity = searchParams.city && cities.some(c => c.city === searchParams.city) ? searchParams.city : undefined;
const events = await getArchiveTimeline({
kind: safeKind,
decade: safeDecade,
city: safeCity,
limit: 400,
});
const byDecade: Record<string, typeof events> = {};
for (const e of events) {
const dateBasis = e.valid_from ?? e.recorded_at;
const yr = yearOf(dateBasis);
const yrNum = Number(yr);
const decade = yr === '?' || !Number.isFinite(yrNum) ? 'Undated' : `${Math.floor(yrNum / 10) * 10}s`;
(byDecade[decade] ??= []).push(e);
}
const decades = Object.keys(byDecade).sort((a, b) =>
a === 'Undated' ? 1 : b === 'Undated' ? -1 : b.localeCompare(a)
);
const filtered = !!(searchParams.kind || searchParams.decade || searchParams.city);
return (
<>
<div className="max-w-4xl mx-auto px-6 pt-6">
<BreadcrumbArchive items={[{ label: 'Archive', href: '/' }, { label: 'Timeline' }]} />
</div>
<header className="max-w-4xl mx-auto px-6 py-12 border-b border-ink/10">
<p className="text-xs uppercase tracking-[0.15em] text-ink/50 mb-2">Timeline</p>
<h1 className="font-display text-5xl md:text-6xl text-ink tracking-[-0.02em] leading-[1.02]">
The archive, in order.
</h1>
<p className="mt-4 font-display italic text-xl text-ink/70 max-w-prose">
Every public event, sorted by year. Filter by kind, decade, or neighborhood.
</p>
<p className="mt-3 font-mono text-[11px] text-ink/45">
{events.length} events · {filtered ? 'filtered' : 'all sources'}
</p>
{/* filter chips */}
<div className="mt-8 flex flex-col gap-3">
<div className="flex flex-wrap items-center gap-2">
<span className="text-[10px] uppercase tracking-[0.15em] text-ink/45 mr-2">Kind</span>
<FilterChip active={!searchParams.kind} href={buildHref(searchParams, { kind: undefined })}>
All
</FilterChip>
{KINDS.map(k => (
<FilterChip
key={k}
active={searchParams.kind === k}
href={buildHref(searchParams, { kind: k })}
>
{KIND_LABEL[k]}
</FilterChip>
))}
</div>
<div className="flex flex-wrap items-center gap-2">
<span className="text-[10px] uppercase tracking-[0.15em] text-ink/45 mr-2">Decade</span>
<FilterChip active={!searchParams.decade} href={buildHref(searchParams, { decade: undefined })}>
All
</FilterChip>
{DECADES.map(d => (
<FilterChip
key={d}
active={searchParams.decade === d}
href={buildHref(searchParams, { decade: d })}
>
{d}
</FilterChip>
))}
</div>
{cities.length > 1 && (
<div className="flex flex-wrap items-center gap-2">
<span className="text-[10px] uppercase tracking-[0.15em] text-ink/45 mr-2">City</span>
<FilterChip active={!searchParams.city} href={buildHref(searchParams, { city: undefined })}>
All
</FilterChip>
{cities.map(c => (
<FilterChip
key={c.city}
active={searchParams.city === c.city}
href={buildHref(searchParams, { city: c.city })}
>
{c.city}
</FilterChip>
))}
</div>
)}
</div>
</header>
<section className="max-w-4xl mx-auto px-6 py-10">
{events.length === 0 ? (
<p className="text-sm text-ink/55 italic py-10">No events match these filters.</p>
) : (
decades.map(decade => (
<div key={decade} className="mb-12">
<div className="flex items-baseline gap-4 mb-4 pb-2 border-b border-ink/10">
<h2 className="font-display text-4xl text-ink tracking-[-0.01em]">{decade}</h2>
<span className="font-mono text-[11px] text-ink/45">
{byDecade[decade]!.length} {byDecade[decade]!.length === 1 ? 'event' : 'events'}
</span>
</div>
<ol className="space-y-4">
{byDecade[decade]!.map(e => (
<li
key={e.id}
className="grid md:grid-cols-[80px_1fr] gap-2 md:gap-6 py-3 border-b border-ink/5"
>
<time className="font-mono text-xs uppercase tracking-wider text-ink/60">
{yearOf(e.valid_from ?? e.recorded_at)}
</time>
<div>
<div className="flex items-baseline gap-3 flex-wrap">
<span className="text-[10px] uppercase tracking-[0.15em] text-ink/40">
{KIND_LABEL[e.kind] ?? e.kind}
</span>
<Link
href={`/address/${e.listing_slug}`}
className="font-display text-lg text-ink hover:text-coral transition leading-tight"
>
{e.listing_title}
</Link>
{e.listing_city && (
<span className="text-[11px] text-ink/50">{e.listing_city}</span>
)}
</div>
<p className="mt-1 text-sm text-ink/75 leading-relaxed">{e.summary}</p>
{isPublicTier(e.source_tier) && (
<div className="mt-2">
{isSafeHttpUrl(e.source_url) ? (
<a href={e.source_url} target="_blank" rel="noopener noreferrer">
<TierBadge tier={e.source_tier} sourceLabel={e.source_label ?? undefined} />
</a>
) : (
<TierBadge tier={e.source_tier} sourceLabel={e.source_label ?? undefined} />
)}
</div>
)}
</div>
</li>
))}
</ol>
</div>
))
)}
</section>
</>
);
}