← back to Nineoh Guide

apps/web/app/page.tsx

172 lines

import { pool } from "@/lib/db";
import { HeroArt } from "@/components/HeroArt";

export const revalidate = 3600; // ISR: cached HTML, hourly background refresh

async function getStats() {
  try {
    const show = await pool.query(
      "select display_title, description_original from shows limit 1"
    );
    const eps = await pool.query("select count(*)::int as n from episodes");
    const cast = await pool.query("select count(*)::int as n from cast_people");
    const recapped = await pool.query(
      "select count(*)::int as n from episodes where summary_short_original is not null"
    );
    return {
      show: show.rows[0] ?? null,
      episodes: eps.rows[0]?.n ?? 0,
      cast: cast.rows[0]?.n ?? 0,
      recapped: recapped.rows[0]?.n ?? 0,
    };
  } catch {
    return { show: null, episodes: 0, cast: 0, recapped: 0 };
  }
}

async function getScenery() {
  try {
    const { rows } = await pool.query(
      `select file_url, caption, attribution_text
         from assets where rights_notes = 'beverly-hills-location'
         order by created_at limit 6`
    );
    return rows;
  } catch {
    return [];
  }
}

export default async function Home() {
  const { episodes, cast, recapped } = await getStats();
  const scenery = await getScenery();
  const jsonLd = {
    "@context": "https://schema.org",
    "@type": "TVSeries",
    name: "90210 (2008)",
    description:
      "An unofficial fan guide to the 2008 teen drama 90210 — original episode recaps and cast profiles.",
    numberOfSeasons: 5,
    numberOfEpisodes: episodes,
  };

  return (
    <>
      <script type="application/ld+json" dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd) }} />

      <section className="hero">
        <div className="sky" />
        <HeroArt />
        <p className="eyebrow rise">Unofficial Fan Guide</p>
        <h1 className="rise-2">Every episode of 90210, recapped.</h1>
        <p className="lede rise-3">
          An independent companion to the 2008 Beverly Hills teen drama —
          original recaps, cast profiles, and a spoiler guard that keeps you one
          step ahead. Written by fans, for fans.
        </p>
        <a className="cta rise-3" href="/episodes">
          Browse the episode guide →
        </a>

        <div className="hero-stats rise-3">
          <GlassStat num={episodes} lab="Episodes" />
          <GlassStat num={recapped} lab="Recaps" />
          <GlassStat num={cast} lab="Cast" />
        </div>
      </section>

      <h2>Start exploring</h2>
      <div style={{ display: "grid", gap: 12, gridTemplateColumns: "repeat(auto-fit, minmax(220px, 1fr))" }}>
        <NavCard href="/episodes" title="Episode Guide" sub="All 5 seasons · spoiler-safe recaps" />
        <NavCard href="/cast" title="Cast & Characters" sub="Who played who, with original bios" />
        <NavCard href="/games" title="Games" sub="Trivia, slots, backgammon & more — free to play" />
        <NavCard href="/search" title="Search" sub="Find any episode, character, or moment" />
        <a
          className="card"
          href="https://www.justwatch.com/us/tv-show/90210"
          target="_blank"
          rel="noopener noreferrer"
          style={{ display: "block", color: "inherit" }}
        >
          <strong style={{ fontFamily: "var(--font-display)", fontSize: 17 }}>
            Where to Watch ↗
          </strong>
          <div style={{ color: "var(--muted)", fontSize: 13, marginTop: 4 }}>
            Current streaming, rent &amp; buy options (live via JustWatch)
          </div>
        </a>
      </div>

      {scenery.length > 0 && (
        <>
          <h2>The Beverly Hills backdrop</h2>
          <p style={{ color: "var(--muted)", fontSize: 13, marginTop: -4 }}>
            The real 90210 — photographed by the Wikimedia Commons community, used
            under free licenses.
          </p>
          <div
            style={{
              display: "grid",
              gap: 10,
              gridTemplateColumns: "repeat(auto-fill, minmax(190px, 1fr))",
              marginTop: 12,
            }}
          >
            {scenery.map((s) => (
              <figure
                key={s.file_url}
                style={{
                  margin: 0,
                  borderRadius: "var(--radius)",
                  overflow: "hidden",
                  border: "1px solid var(--line)",
                  boxShadow: "var(--shadow)",
                  background: "var(--paper-2)",
                }}
              >
                {/* eslint-disable-next-line @next/next/no-img-element */}
                <img
                  src={s.file_url}
                  alt={s.caption ?? "Beverly Hills"}
                  loading="lazy"
                  style={{ width: "100%", height: 130, objectFit: "cover", display: "block" }}
                />
                <figcaption style={{ padding: "6px 8px" }}>
                  <div style={{ fontSize: 12, fontWeight: 600 }}>{s.caption}</div>
                  <div style={{ fontSize: 10, color: "var(--muted)" }}>
                    {s.attribution_text}
                  </div>
                </figcaption>
              </figure>
            ))}
          </div>
        </>
      )}

      {episodes === 0 && (
        <p style={{ marginTop: 20, color: "var(--maroon)", fontSize: 13 }}>
          Content is being curated — recaps and profiles will populate shortly.
        </p>
      )}
    </>
  );
}

function GlassStat({ num, lab }: { num: number; lab: string }) {
  return (
    <div className="glass-dark hero-stat">
      <div className="hero-stat-num">{num}</div>
      <div className="hero-stat-lab">{lab}</div>
    </div>
  );
}

function NavCard({ href, title, sub }: { href: string; title: string; sub: string }) {
  return (
    <a className="card" href={href} style={{ display: "block", color: "inherit" }}>
      <strong style={{ fontFamily: "var(--font-display)", fontSize: 17 }}>{title}</strong>
      <div style={{ color: "var(--muted)", fontSize: 13, marginTop: 4 }}>{sub}</div>
    </a>
  );
}