← back to Stayclaim

src/app/entity/[slug]/page.tsx

183 lines

import { notFound } from 'next/navigation';
import { cache } from 'react';
import Link from 'next/link';
import type { Metadata } from 'next';
import {
  getEntityBySlug as _getEntityBySlug,
  getEntityAssociations as _getEntityAssociations,
  yearOf,
} from '@/lib/db';

// Per-request memoization so generateMetadata + page body share one PG hit each.
const getEntityBySlug = cache(_getEntityBySlug);
const getEntityAssociations = cache(_getEntityAssociations);

export async function generateMetadata({ params }: { params: { slug: string } }): Promise<Metadata> {
  const entity = await getEntityBySlug(params.slug);
  if (!entity) return { robots: { index: false, follow: false } };
  const assocs = await getEntityAssociations(entity.id);
  const visible = assocs.filter(a => a.source_tier !== 'D');
  return {
    title: entity.display_name,
    description: entity.bio_short ?? `${entity.display_name} — addresses associated, in the archive.`,
    alternates: { canonical: `/entity/${entity.slug}` },
    robots: visible.length > 0
      ? { index: true, follow: true }
      : { index: false, follow: true },
    openGraph: {
      title: entity.display_name,
      url: `/entity/${entity.slug}`,
      type: 'profile',
    },
  };
}
import { BreadcrumbArchive } from '@/components/BreadcrumbArchive';
import { TierBadge, isPublicTier } from '@/components/TierBadge';
import { InteractiveMapMount, type MapPin } from '@/components/InteractiveMapMount';
import { JsonLd } from '@/components/JsonLd';
import { SITE_DOMAIN } from '@/lib/site';
import { safeHttpUrlUnknown as safeHttpUrl } from '@/lib/url-guards';

const SLUG_RE = /^[a-z0-9-]{1,128}$/;

export default async function EntityPage({ params }: { params: { slug: string } }) {
  if (!SLUG_RE.test(params.slug)) notFound();
  const entity = await getEntityBySlug(params.slug);
  if (!entity) notFound();
  const safeWikiUrl = safeHttpUrl(entity.wiki_url);

  // generateMetadata already populated the cache, so this is in-memory.
  const associations = await getEntityAssociations(entity.id);
  const visible = associations.filter(a => isPublicTier(a.source_tier));

  const vitals = [entity.birth_year, entity.death_year]
    .map(y => y ?? '?')
    .filter((_, i, arr) => !(arr[0] === '?' && arr[1] === '?'))
    .join('–');

  // schema.org/Person — only fields shown on the page.
  const baseUrl = process.env.NEXT_PUBLIC_BASE_URL ?? `https://${SITE_DOMAIN}`;
  const jsonLd: Record<string, unknown> = {
    '@context': 'https://schema.org',
    '@type': entity.kind === 'business' ? 'Organization' : 'Person',
    name: entity.display_name,
    url: `${baseUrl}/entity/${entity.slug}`,
  };
  if (entity.birth_year) jsonLd.birthDate = String(entity.birth_year);
  if (entity.death_year) jsonLd.deathDate = String(entity.death_year);
  if (entity.bio_short) jsonLd.description = entity.bio_short;
  if (safeWikiUrl) jsonLd.sameAs = [safeWikiUrl];
  if (entity.kind === 'architect' && visible.length > 0) {
    jsonLd.jobTitle = 'Architect';
  }

  return (
    <>
      <JsonLd data={jsonLd} />
      <div className="max-w-4xl mx-auto px-6 pt-6">
        <BreadcrumbArchive
          items={[
            { label: 'Archive', href: '/browse' },
            { label: `People`, href: '/people' },
            { label: entity.display_name },
          ]}
        />
      </div>

      <header className="max-w-4xl mx-auto px-6 py-10 border-b border-ink/10">
        <p className="text-xs uppercase tracking-[0.15em] text-ink/50 mb-2">{entity.kind}</p>
        <h1 className="font-display text-5xl md:text-6xl text-ink tracking-[-0.02em] leading-[1.02]">
          {entity.display_name}
        </h1>
        {vitals && <p className="mt-3 text-base text-ink/60 font-mono">{vitals}</p>}
        {entity.bio_short && (
          <p className="mt-5 font-display italic text-xl text-ink/80 max-w-prose">{entity.bio_short}</p>
        )}
        {safeWikiUrl && (
          <a
            href={safeWikiUrl}
            target="_blank"
            rel="noopener noreferrer"
            className="mt-4 inline-flex text-xs uppercase tracking-wider text-ink/60 hover:text-coral underline-offset-4 hover:underline"
          >
            Reference
          </a>
        )}
      </header>

      <div className="max-w-4xl mx-auto px-6 py-10">
        {(() => {
          const pins: MapPin[] = [];
          for (const a of visible) {
            const lat = Number(a.listing_lat);
            const lng = Number(a.listing_lng);
            if (!Number.isFinite(lat) || !Number.isFinite(lng)) continue;
            if (lat === 0 && lng === 0) continue;
            pins.push({
              slug: a.listing_slug,
              title: a.listing_title,
              subtitle: a.relation ?? 'Associated',
              lat,
              lng,
            });
          }
          if (pins.length === 0) return null;
          return (
            <div className="mb-10">
              <InteractiveMapMount pins={pins} height={320} variant="positron" />
              <p className="mt-2 font-mono text-[10px] uppercase tracking-[0.15em] text-ink/50">
                {pins.length} {pins.length === 1 ? 'location' : 'locations'} on record
              </p>
            </div>
          );
        })()}

        <h2 className="font-display text-3xl text-ink mb-6 tracking-[-0.01em]">
          {entity.kind === 'architect' || entity.kind === 'studio'
            ? 'Oeuvre — known works'
            : 'Addresses associated'}
        </h2>
        {visible.length === 0 ? (
          <p className="text-sm text-ink/50 italic">
            No public addresses recorded for this person yet. Privacy-by-design: if they are living and
            the association is recent, it will not appear here.
          </p>
        ) : (
          <ol className="space-y-5">
            {visible.map(a => (
              <li key={a.id} className="border-b border-ink/5 pb-5">
                <div className="flex items-baseline justify-between gap-3 flex-wrap">
                  <Link
                    href={`/address/${a.listing_slug}`}
                    className="font-display text-2xl text-ink hover:text-coral transition"
                  >
                    {a.listing_title}
                  </Link>
                  <span className="text-xs uppercase tracking-wider text-ink/50">{a.relation}</span>
                </div>
                <div className="mt-1 flex items-baseline gap-3 flex-wrap">
                  <span className="text-sm text-ink/60">{a.listing_city}</span>
                  {(a.date_from || a.date_to) && (
                    <span className="font-mono text-xs text-ink/60">
                      {yearOf(a.date_from)}–{yearOf(a.date_to)}
                    </span>
                  )}
                </div>
                {a.summary && <p className="mt-2 text-sm text-ink/70">{a.summary}</p>}
                <div className="mt-2">
                  <TierBadge tier={a.source_tier as 'A' | 'B' | 'C'} sourceLabel={a.source_urls?.[0]} />
                </div>
              </li>
            ))}
          </ol>
        )}
        <p className="mt-10 text-xs text-ink/40 max-w-prose leading-relaxed">
          Associations are stored as dated claims with evidence, not as eternal truths. Current addresses
          of living persons are blocked from public display at the database level and will not appear
          regardless of search, share, or scrape.
        </p>
      </div>
    </>
  );
}