← back to Stayclaim
src/app/houses/page.tsx
154 lines
/**
* /houses — public index of all named historic estates ingested from
* Wikipedia + LA Historic-Cultural Monuments.
*
* Editorial register-of-houses, magazine-style. Each entry drill-downs to
* the Wikipedia article (or NavigateLA HCM detail page) for the canonical
* source.
*/
import Link from 'next/link';
import type { Metadata } from 'next';
import { pool } from '@/lib/db';
import { BreadcrumbArchive } from '@/components/BreadcrumbArchive';
import { GridSlider, ListingGrid } from '@/components/ListingGrid';
export const dynamic = 'force-dynamic';
export const metadata: Metadata = {
title: 'Named houses',
description: 'Every named historic estate in the archive — Pickfair, Greystone, Falcon Lair, and 250+ more, each linked to its primary source.',
alternates: { canonical: '/houses' },
};
type Row = {
id: string;
slug: string;
title: string;
description: string | null;
city: string | null;
latitude: string | null;
longitude: string | null;
source_url: string | null;
kind: 'wikipedia' | 'hcm';
};
async function getHouses(filter: string | undefined): Promise<Row[]> {
const where: string[] = ["l.is_public = true", "l.source IN ('wikipedia_house','la_hcm')"];
const params: any[] = [];
if (filter) {
// Escape LIKE meta-chars so user input matches literally.
const escaped = filter.replace(/[\\%_]/g, (c) => `\\${c}`);
params.push(`%${escaped}%`);
where.push(`(l.title ILIKE $${params.length} OR l.description ILIKE $${params.length} OR l.city ILIKE $${params.length})`);
}
const { rows } = await pool.query<any>(
`SELECT l.id, l.slug, l.title, l.description, l.city,
l.latitude::text as latitude, l.longitude::text as longitude,
(SELECT pe.source_url FROM place_event pe WHERE pe.listing_id = l.id AND pe.kind IN ('named_estate','historic_designation') LIMIT 1) as source_url,
CASE WHEN l.source = 'wikipedia_house' THEN 'wikipedia' ELSE 'hcm' END as kind
FROM listing l
WHERE ${where.join(' AND ')}
ORDER BY l.title
LIMIT 500`,
params
);
return rows;
}
export default async function HousesPage({ searchParams }: { searchParams: { q?: string | string[] } }) {
const rawQ = Array.isArray(searchParams.q) ? searchParams.q[0] : searchParams.q;
let q = rawQ?.trim();
if (q && q.length > 100) q = q.slice(0, 100); // cap to prevent DoS via huge ILIKE patterns
const houses = await getHouses(q);
const wpCount = houses.filter(h => h.kind === 'wikipedia').length;
const hcmCount = houses.filter(h => h.kind === 'hcm').length;
return (
<>
<div className="max-w-6xl mx-auto px-6 pt-6">
<BreadcrumbArchive items={[{ label: 'Archive', href: '/' }, { label: 'Houses' }]} />
</div>
<header className="max-w-6xl mx-auto px-6 py-12 border-b border-ink/10">
<p className="text-[10px] uppercase tracking-[0.25em] text-moss font-mono mb-3">Register of houses</p>
<h1 className="font-display text-6xl md:text-8xl text-ink tracking-[-0.03em] leading-[0.92]">
{houses.length} named<br/>
<span className="italic text-ink/65">houses.</span>
</h1>
<p className="mt-6 font-display italic text-2xl text-ink/65 max-w-3xl leading-tight">
Every estate, mansion, and named historic building in the archive — each linked back to the source.
</p>
<div className="mt-6 flex gap-3 flex-wrap items-center text-xs">
<span className="font-mono uppercase tracking-[0.15em] text-ink/45">
{wpCount} via Wikipedia · {hcmCount} via LA Historic-Cultural Monuments
</span>
</div>
<form action="/houses" className="mt-8 flex gap-3 max-w-2xl">
<input
type="search"
name="q"
defaultValue={q ?? ''}
placeholder="Pickfair · Greystone · Beverly Hills · architect…"
className="flex-1 border-2 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-[0.2em] hover:bg-moss transition font-mono">
Filter
</button>
{q && <Link href="/houses" className="self-center text-xs uppercase tracking-wider text-ink/50 hover:text-coral">Clear</Link>}
</form>
</header>
<section className="max-w-6xl mx-auto px-6 py-12">
{houses.length === 0 ? (
<p className="text-sm italic text-ink/55">No named houses match.</p>
) : (
<>
<GridSlider className="mb-6 pb-3 border-b border-ink/10" />
<ListingGrid>
{houses.map(h => (
<article key={h.id} className="border border-ink/10 bg-white p-5 hover:border-ink/40 transition group flex flex-col">
<p className="font-mono text-[10px] uppercase tracking-[0.2em] text-coral/80">
{h.kind === 'wikipedia' ? 'Wikipedia' : 'LA Historic-Cultural Monument'}
</p>
<Link href={`/address/${h.slug}`} className="font-display text-2xl text-ink mt-2 leading-tight tracking-[-0.005em] group-hover:text-coral transition">
{h.title}
</Link>
{h.city && (
<p className="font-mono text-[10px] uppercase tracking-wider text-ink/45 mt-1">{h.city}</p>
)}
{h.description && (
<p className="mt-3 text-sm text-ink/65 leading-relaxed flex-1">
{h.description.slice(0, 140)}{h.description.length > 140 ? '…' : ''}
</p>
)}
<div className="mt-4 pt-3 border-t border-ink/10 flex items-baseline justify-between gap-2">
{(() => {
const lat = h.latitude ? parseFloat(h.latitude) : NaN;
const lng = h.longitude ? parseFloat(h.longitude) : NaN;
return Number.isFinite(lat) && Number.isFinite(lng) ? (
<span className="font-mono text-[10px] tabular-nums text-ink/40">
{lat.toFixed(4)}, {lng.toFixed(4)}
</span>
) : null;
})()}
{h.source_url && /^https?:\/\//i.test(h.source_url) && (
<a
href={h.source_url}
target="_blank"
rel="noreferrer noopener"
className="font-mono text-[10px] uppercase tracking-wider text-coral hover:underline shrink-0"
>
source ↗
</a>
)}
</div>
</article>
))}
</ListingGrid>
</>
)}
</section>
</>
);
}