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