← back to Stayclaim

src/app/search/page.tsx

248 lines

import Link from 'next/link';
import type { Metadata } from 'next';
import { headers } from 'next/headers';
import { BreadcrumbArchive } from '@/components/BreadcrumbArchive';
import { siteForHost } from '@/lib/site';
import { pool } from '@/lib/db';

export const metadata: Metadata = { title: 'Search', alternates: { canonical: '/search' } };
export const dynamic = 'force-dynamic';

type Result = {
  kind: string;
  id: string;
  title: string;
  subtitle?: string | null;
  snippet?: string | null;
  href: string;
  source_label?: string | null;
  source_url?: string | null;
  date?: string | null;
};

const KIND_LABEL: Record<string, string> = {
  address: 'Address',
  permit: 'Building permit',
  film: 'Film / TV',
  news: 'Newspaper page',
  mention: 'Address mention',
  entity: 'Person',
  parcel: 'Parcel',
};

const KIND_COLOR: Record<string, string> = {
  address: 'border-moss text-moss',
  permit: 'border-ink/40 text-ink/70',
  film: 'border-coral text-coral',
  news: 'border-purple-700 text-purple-700',
  mention: 'border-amber-700 text-amber-700',
  entity: 'border-blue-700 text-blue-700',
  parcel: 'border-emerald-700 text-emerald-700',
};

function isSafeHref(href: string): boolean {
  if (!href || href === '#') return false;
  if (href.startsWith('/')) return !href.startsWith('//');
  return /^https?:\/\//i.test(href);
}

function renderSnippet(snippet: string) {
  const parts = snippet.split(/(<\/?mark>)/g);
  let marked = false;
  return parts.map((part, index) => {
    if (part === '<mark>') {
      marked = true;
      return null;
    }
    if (part === '</mark>') {
      marked = false;
      return null;
    }
    return marked ? <mark key={index}>{part}</mark> : part;
  });
}

async function doSearch(q: string, kind: string | undefined): Promise<Result[]> {
  if (!q || q.length < 2) return [];
  // Escape LIKE meta-chars so '%' / '_' / '\' match literally.
  const likePattern = `%${q.replace(/[\\%_]/g, '\\$&')}%`;

  const wantAddress = !kind || kind === 'address';
  const wantEntity  = !kind || kind === 'entity';

  const queries: Array<Promise<{ rows: Array<Record<string, unknown>> }>> = [];
  const tags: Array<'address' | 'entity'> = [];

  if (wantAddress) {
    tags.push('address');
    queries.push(pool.query(
      `SELECT slug, title, address_line1, city, state,
              ts_headline('english', COALESCE(headline, description, ''),
                          websearch_to_tsquery('english', $1),
                          'StartSel=<mark>,StopSel=</mark>,MaxFragments=1,MaxWords=22,MinWords=8') AS snippet
       FROM listing
       WHERE is_public = true
         AND (search_doc @@ websearch_to_tsquery('english', $1)
              OR title ILIKE $2
              OR address_line1 ILIKE $2)
       ORDER BY ts_rank(search_doc, websearch_to_tsquery('english', $1)) DESC NULLS LAST, ingested_at DESC
       LIMIT 20`,
      [q, likePattern]
    ));
  }
  if (wantEntity) {
    tags.push('entity');
    queries.push(pool.query(
      `SELECT slug, display_name, kind AS entity_kind, birth_year, death_year, bio_short
       FROM entity
       WHERE display_name ILIKE $1
       ORDER BY display_name
       LIMIT 10`,
      [likePattern]
    ));
  }

  const settled = await Promise.allSettled(queries);
  const results: Result[] = [];

  for (let i = 0; i < settled.length; i++) {
    const s = settled[i];
    if (s.status !== 'fulfilled') continue;
    const tag = tags[i];
    if (tag === 'address') {
      for (const r of s.value.rows) {
        const slug = String(r.slug);
        const subtitle = [r.address_line1, r.city, r.state].filter(Boolean).join(', ') || null;
        results.push({
          kind: 'address',
          id: slug,
          title: String(r.title ?? slug),
          subtitle,
          snippet: typeof r.snippet === 'string' ? r.snippet : null,
          href: `/address/${encodeURIComponent(slug)}`,
        });
      }
    } else if (tag === 'entity') {
      for (const r of s.value.rows) {
        const slug = String(r.slug);
        const yrs = (r.birth_year || r.death_year)
          ? `${r.birth_year ?? '?'}–${r.death_year ?? '?'}`
          : null;
        const subtitle = [r.entity_kind, yrs].filter(Boolean).join(' · ') || null;
        results.push({
          kind: 'entity',
          id: slug,
          title: String(r.display_name ?? slug),
          subtitle,
          snippet: typeof r.bio_short === 'string' ? r.bio_short : null,
          href: `/entity/${encodeURIComponent(slug)}`,
        });
      }
    }
  }

  return results;
}

export default async function SearchPage({
  searchParams,
}: {
  searchParams: { q?: string; kind?: string };
}) {
  const rawQ = Array.isArray(searchParams.q) ? searchParams.q[0] : searchParams.q;
  const q = (rawQ ?? '').trim().slice(0, 200);
  const rawKind = Array.isArray(searchParams.kind) ? searchParams.kind[0] : searchParams.kind;
  const kind = rawKind && Object.prototype.hasOwnProperty.call(KIND_LABEL, rawKind) ? rawKind : undefined;
  const results = await doSearch(q, kind);
  const h = await headers();
  const surface = siteForHost(h.get('host') ?? 'wholivedthere.com');
  const byKind: Record<string, Result[]> = {};
  for (const r of results) (byKind[r.kind] ??= []).push(r);

  return (
    <>
      <div className="max-w-6xl mx-auto px-6 pt-6">
        <BreadcrumbArchive items={[{ label: 'Archive', href: '/' }, { label: 'Search' }]} />
      </div>

      <header className="max-w-6xl mx-auto px-6 py-10 border-b border-ink/10">
        <p className="text-xs uppercase tracking-[0.15em] text-ink/50 mb-2">Search the archive</p>
        <h1 className="font-display text-5xl md:text-6xl text-ink tracking-[-0.02em] leading-[1.02]">
          {q ? <>Results for &ldquo;{q}&rdquo;</> : 'What are you looking for?'}
        </h1>
        <p className="mt-3 text-sm text-ink/60">
          Address · permit · film · person · parcel · newspaper OCR. Searches every field across {surface.brandName}.
        </p>
        <form className="mt-6 flex gap-3" action="/search">
          <input
            name="q"
            type="search"
            defaultValue={q}
            placeholder="Address, name, movie, permit number, anything…"
            className="flex-1 max-w-2xl border border-ink/20 bg-sand px-4 py-3 text-base font-sans focus:outline-none focus:border-moss"
          />
          <button type="submit" className="bg-ink text-sand px-6 py-3 text-xs uppercase tracking-wider hover:bg-moss transition">
            Search
          </button>
        </form>
        {q && results.length > 0 && (
          <div className="mt-4 flex flex-wrap items-center gap-2 text-xs">
            <span className="text-ink/50 uppercase tracking-[0.15em]">Filter:</span>
            <Link href={`/search?q=${encodeURIComponent(q)}`} className={`px-3 py-1 border ${!kind ? 'border-ink bg-ink text-sand' : 'border-ink/20 hover:border-ink/50'}`}>
              All ({results.length})
            </Link>
            {Object.entries(byKind).map(([k, list]) => (
              <Link key={k} href={`/search?q=${encodeURIComponent(q)}&kind=${k}`}
                 className={`px-3 py-1 border ${kind === k ? 'border-ink bg-ink text-sand' : `${KIND_COLOR[k] ?? 'border-ink/20'} hover:bg-ink/5`}`}>
                {KIND_LABEL[k] ?? k} ({list.length})
              </Link>
            ))}
          </div>
        )}
      </header>

      <section className="max-w-6xl mx-auto px-6 py-10">
        {q && results.length === 0 ? (
          <p className="text-sm text-ink/55 italic py-10">No results across listings, permits, films, news OCR, mentions, parcels, or people.</p>
        ) : (
          <ul className="divide-y divide-ink/10">
            {results.map(r => (
              <li key={`${r.kind}-${r.id}`} className="py-5 flex flex-col md:flex-row md:items-baseline gap-3">
                <span className={`shrink-0 text-[10px] uppercase tracking-[0.15em] px-2 py-1 border ${KIND_COLOR[r.kind] ?? 'border-ink/20 text-ink/60'} self-start`}>
                  {KIND_LABEL[r.kind] ?? r.kind}
                </span>
                <div className="flex-1 min-w-0">
                  {isSafeHref(r.href) ? (
                    <Link href={r.href} className="font-display text-2xl text-ink hover:text-coral transition tracking-[-0.005em]" target={/^https?:\/\//i.test(r.href) ? '_blank' : undefined} rel={/^https?:\/\//i.test(r.href) ? 'noreferrer noopener' : undefined}>
                      {r.title}
                    </Link>
                  ) : (
                    <span className="font-display text-2xl text-ink/70">{r.title}</span>
                  )}
                  {r.subtitle && <p className="text-sm text-ink/65 mt-0.5">{r.subtitle}</p>}
                  {r.snippet && (
                    <p className="text-sm text-ink/70 mt-1 leading-relaxed max-w-prose">
                      {renderSnippet(r.snippet)}
                    </p>
                  )}
                  {(r.source_label || r.source_url) && (
                    <p className="mt-2 font-mono text-[11px] text-ink/45">
                      source:{' '}
                      {r.source_url && /^https?:\/\//i.test(r.source_url) ? (
                        <a href={r.source_url} target="_blank" rel="noreferrer noopener" className="hover:text-coral underline-offset-2 hover:underline">
                          {r.source_label ?? r.source_url} ↗
                        </a>
                      ) : (r.source_label)}
                      {r.date && ` · ${r.date}`}
                    </p>
                  )}
                </div>
              </li>
            ))}
          </ul>
        )}
      </section>
    </>
  );
}