← 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 “{q}”</> : '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>
</>
);
}