← back to Nineoh Guide
apps/web/app/show/page.tsx
143 lines
import { pool } from "@/lib/db";
import { castPath, slugify } from "@nineoh/core";
export const revalidate = 3600; // ISR: cached HTML, hourly background refresh
export const metadata = {
title: "90210 — Series Overview",
description:
"An unofficial overview of the 2008 CW teen drama 90210: five seasons, 114 episodes, cast, and where to watch.",
};
// Original season overviews — static editorial content (5 entries), no DB column needed.
const SEASON_SUMMARIES: Record<number, string> = {
1: "The debut season follows the Wilson family's move from Kansas to Beverly Hills as siblings Annie and Dixon start at West Beverly Hills High — first loves, new friendships with Naomi, Silver, Ethan, Navid and Adrianna, and a family secret that reshapes the household.",
2: "Sophomore year deepens the drama: the bond between Naomi, Silver and Adrianna is tested, Silver faces a family cancer crisis, and a predatory teacher, Mr. Cannon, arrives at West Bev — setting up the season's darkest turn.",
3: "Senior year confronts the aftermath of the Mr. Cannon storyline head-on as Naomi seeks justice, the group chases music and studio dreams at Shirazi, and graduation looms over everyone's next chapter.",
4: "The college years begin at CU: Naomi rushes a sorority and clashes with rival Holly, Annie hides a secret double life, Dixon pursues music, and Naomi's whirlwind path toward marriage drives the season.",
5: "The final season pushes the group into adulthood — marriages tested, careers launched (a surf shop, a restaurant, event planning for mogul Jordan Welland), and a catastrophic finale that puts the friendships on the line one last time.",
};
async function getData() {
try {
const show = await pool.query(
"select display_title, description_original from shows limit 1"
);
const seasons = await pool.query(
`select season_number,
count(*)::int as eps,
min(air_date) as first_aired,
max(air_date) as last_aired
from episodes group by season_number order by season_number`
);
const characters = await pool.query(
`select c.name, c.description_original, cp.name as actor
from characters c
left join credits cr on cr.character_id = c.id and cr.credit_type='main-cast'
left join cast_people cp on cp.id = cr.person_id
where c.description_original is not null
order by c.name`
);
const totals = await pool.query(
`select count(*)::int as eps,
(select count(*)::int from cast_people) as cast,
min(air_date) as first, max(air_date) as last
from episodes`
);
const cast = await pool.query(`select name from cast_people order by name`);
return {
show: show.rows[0] ?? null,
seasons: seasons.rows,
totals: totals.rows[0],
cast: cast.rows,
characters: characters.rows,
};
} catch {
return { show: null, seasons: [], totals: null, cast: [], characters: [] };
}
}
const yr = (d: any) => (d ? new Date(d).getFullYear() : "");
export default async function Show() {
const { show, seasons, totals, cast, characters } = await getData();
const jsonLd = {
"@context": "https://schema.org",
"@type": "TVSeries",
name: "90210 (2008)",
description: show?.description_original ?? undefined,
numberOfSeasons: seasons.length,
numberOfEpisodes: totals?.eps ?? 0,
startDate: totals?.first ? new Date(totals.first).toISOString().slice(0, 10) : undefined,
endDate: totals?.last ? new Date(totals.last).toISOString().slice(0, 10) : undefined,
};
return (
<section>
<script type="application/ld+json" dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd) }} />
<h1>{show?.display_title ?? "90210"} — Series Overview</h1>
<p style={{ color: "var(--ink-soft)", lineHeight: 1.6 }}>{show?.description_original}</p>
<p style={{ color: "var(--muted)", fontSize: 13 }}>
{seasons.length} seasons · {totals?.eps ?? 0} episodes · The CW ·{" "}
{yr(totals?.first)}–{yr(totals?.last)} ·{" "}
<a href="https://www.justwatch.com/us/tv-show/90210" target="_blank" rel="noopener noreferrer">
Where to watch ↗
</a>
</p>
<h2>Seasons</h2>
<div style={{ display: "grid", gap: 12 }}>
{seasons.map((s) => (
<div key={s.season_number} className="card">
<div style={{ display: "flex", gap: 12, alignItems: "baseline" }}>
<strong className="se" style={{ fontFamily: "var(--font-display)", fontSize: 16 }}>
Season {s.season_number}
</strong>
<span style={{ flex: 1, color: "var(--muted)", fontSize: 13 }}>
{s.eps} episodes · {yr(s.first_aired)}
{yr(s.last_aired) && yr(s.last_aired) !== yr(s.first_aired) ? `–${yr(s.last_aired)}` : ""}
</span>
</div>
{SEASON_SUMMARIES[s.season_number] ? (
<p style={{ margin: "6px 0 0", color: "var(--ink-soft)", fontSize: 13.5, lineHeight: 1.55 }}>
{SEASON_SUMMARIES[s.season_number]}
</p>
) : null}
</div>
))}
</div>
{characters.length > 0 && (
<>
<h2>Characters</h2>
<div style={{ display: "grid", gap: 10, gridTemplateColumns: "repeat(auto-fill, minmax(280px, 1fr))" }}>
{characters.map((c) => (
<a key={c.name} className="card" href={`/characters/${slugify(c.name)}`} style={{ display: "block", color: "inherit" }}>
<strong style={{ fontFamily: "var(--font-display)", fontSize: 15 }}>{c.name}</strong>
{c.actor ? (
<span style={{ color: "var(--muted)", fontSize: 12 }}> · {c.actor}</span>
) : null}
<p style={{ margin: "4px 0 0", color: "var(--ink-soft)", fontSize: 12.5, lineHeight: 1.5 }}>
{c.description_original}
</p>
</a>
))}
</div>
</>
)}
<h2>Cast at a glance</h2>
<div style={{ display: "flex", flexWrap: "wrap", gap: 8 }}>
{cast.map((c) => (
<a key={c.name} className="pill" href={castPath(c.name)} style={{ textDecoration: "none" }}>
{c.name}
</a>
))}
</div>
<p style={{ marginTop: 24 }}>
<a href="/episodes">Full episode guide →</a>
</p>
</section>
);
}