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