← back to Nineoh Guide
comprehensive pass: /show season overviews + 14 character profiles + hamburger mobile nav + bigger hero + full search (news/pages/games) + per-episode Reddit discuss links
e2003291d39e2eaefb22a9c72dab0e572800e7af · 2026-07-26 08:36:42 -0700 · Steve
Files touched
M apps/web/app/episodes/[season]/[episode]/page.tsxM apps/web/app/globals.cssM apps/web/app/layout.tsxM apps/web/app/search/page.tsxM apps/web/app/show/page.tsxA db/enrich-wiki.mjs
Diff
commit e2003291d39e2eaefb22a9c72dab0e572800e7af
Author: Steve <steve@designerwallcoverings.com>
Date: Sun Jul 26 08:36:42 2026 -0700
comprehensive pass: /show season overviews + 14 character profiles + hamburger mobile nav + bigger hero + full search (news/pages/games) + per-episode Reddit discuss links
---
apps/web/app/episodes/[season]/[episode]/page.tsx | 11 +++-
apps/web/app/globals.css | 27 +++++++-
apps/web/app/layout.tsx | 4 ++
apps/web/app/search/page.tsx | 75 +++++++++++++++++++++--
apps/web/app/show/page.tsx | 73 +++++++++++++++++-----
db/enrich-wiki.mjs | 37 +++++++++++
6 files changed, 201 insertions(+), 26 deletions(-)
diff --git a/apps/web/app/episodes/[season]/[episode]/page.tsx b/apps/web/app/episodes/[season]/[episode]/page.tsx
index a4d29d1..246adf8 100644
--- a/apps/web/app/episodes/[season]/[episode]/page.tsx
+++ b/apps/web/app/episodes/[season]/[episode]/page.tsx
@@ -119,13 +119,20 @@ export default async function EpisodePage({
{ep.writer ? ` · Written by ${ep.writer}` : ""}
{ep.director ? ` · Directed by ${ep.director}` : ""}
</p>
- <p style={{ fontSize: 12.5, marginTop: -4 }}>
+ <p style={{ fontSize: 12.5, marginTop: -4, display: "flex", gap: 14, flexWrap: "wrap" }}>
<a
href="https://www.justwatch.com/us/tv-show/90210"
target="_blank"
rel="noopener noreferrer"
>
- Where to watch the series ↗
+ Where to watch ↗
+ </a>
+ <a
+ href={`https://www.reddit.com/r/90210/search/?q=${encodeURIComponent(ep.title)}&restrict_sr=1`}
+ target="_blank"
+ rel="noopener noreferrer"
+ >
+ Discuss on r/90210 ↗
</a>
</p>
diff --git a/apps/web/app/globals.css b/apps/web/app/globals.css
index 7774e1b..1773fca 100644
--- a/apps/web/app/globals.css
+++ b/apps/web/app/globals.css
@@ -69,6 +69,27 @@ h2 { font-size: clamp(19px, 3vw, 23px); margin: 28px 0 10px; }
.nav { margin-left: auto; display: flex; gap: 20px; font-size: 14px; font-weight: 500; }
.nav a { color: var(--ink-soft); }
+/* CSS-only hamburger (no client JS) */
+.nav-toggle { display: none; }
+.hamburger {
+ display: none; margin-left: auto; cursor: pointer;
+ font-size: 22px; line-height: 1; color: var(--ink); padding: 4px 8px;
+ border: 1px solid var(--line); border-radius: 8px; background: rgba(255,255,255,0.5);
+}
+@media (max-width: 680px) {
+ .site-header .bar { position: relative; flex-wrap: wrap; }
+ .hamburger { display: inline-block; }
+ .nav {
+ display: none; position: absolute; top: 100%; left: 0; right: 0;
+ flex-direction: column; gap: 16px; padding: 16px 20px; margin: 0;
+ background: rgba(250,246,239,0.96);
+ -webkit-backdrop-filter: blur(18px); backdrop-filter: blur(18px);
+ border-bottom: 1px solid var(--line); box-shadow: 0 8px 24px rgba(23,19,15,0.10);
+ }
+ .nav-toggle:checked ~ .nav { display: flex; }
+ .nav a { font-size: 16px; }
+}
+
.wrap { max-width: var(--maxw); margin: 0 auto; padding: 28px 20px 60px; }
.site-footer {
@@ -80,10 +101,12 @@ h2 { font-size: clamp(19px, 3vw, 23px); margin: 28px 0 10px; }
/* ── Hero ──────────────────────────────────────────────────────────────── */
.hero {
position: relative; overflow: hidden;
- border-radius: 18px; padding: 56px 32px 48px; margin-bottom: 8px;
+ border-radius: 20px; padding: 96px 44px 80px; margin-bottom: 8px;
+ min-height: 480px; display: flex; flex-direction: column; justify-content: center;
color: #fff5ec; isolation: isolate;
box-shadow: var(--shadow);
}
+@media (max-width: 680px) { .hero { padding: 64px 24px 52px; min-height: 420px; } }
.hero .sky {
position: absolute; inset: 0; z-index: -2;
/* Photoshop-baked grainy ombre (dusk→blush→gold) over the animated CSS gradient fallback. */
@@ -116,7 +139,7 @@ h2 { font-size: clamp(19px, 3vw, 23px); margin: 28px 0 10px; }
font-size: 11px; letter-spacing: 0.22em; text-transform: uppercase;
color: var(--gold-2); font-weight: 700; margin-bottom: 12px;
}
-.hero h1 { color: #fff8f0; font-size: clamp(32px, 6vw, 52px); text-shadow: 0 2px 18px rgba(0,0,0,0.28); }
+.hero h1 { color: #fff8f0; font-size: clamp(40px, 7.5vw, 68px); line-height: 1.04; text-shadow: 0 2px 22px rgba(0,0,0,0.3); }
.hero p.lede { max-width: 40ch; font-size: 16px; color: rgba(255,246,236,0.9); }
.hero .cta {
display: inline-flex; align-items: center; gap: 8px; margin-top: 20px;
diff --git a/apps/web/app/layout.tsx b/apps/web/app/layout.tsx
index 7533dad..8060bd0 100644
--- a/apps/web/app/layout.tsx
+++ b/apps/web/app/layout.tsx
@@ -39,6 +39,10 @@ export default function RootLayout({ children }: { children: React.ReactNode })
</strong>
<span className="badge">Unofficial</span>
</a>
+ <input type="checkbox" id="navtoggle" className="nav-toggle" aria-hidden="true" />
+ <label htmlFor="navtoggle" className="hamburger" aria-label="Open menu">
+ ☰
+ </label>
<nav className="nav">
<a href="/show">Series</a>
<a href="/episodes">Episodes</a>
diff --git a/apps/web/app/search/page.tsx b/apps/web/app/search/page.tsx
index cfaf1da..e73dcdc 100644
--- a/apps/web/app/search/page.tsx
+++ b/apps/web/app/search/page.tsx
@@ -20,17 +20,39 @@ async function search(q: string) {
from cast_people cp
left join credits cr on cr.person_id = cp.id and cr.credit_type='main-cast'
left join characters ch on ch.id = cr.character_id
- where cp.name ilike $1 or cp.biography_original ilike $1 or ch.name ilike $1
+ where cp.name ilike $1 or cp.biography_original ilike $1
+ or ch.name ilike $1 or ch.description_original ilike $1
order by cp.name
limit 50`,
[like]
);
- return { eps: eps.rows, cast: cast.rows };
+ const news = await pool.query(
+ `select id, headline, canonical_url, publisher_name from news_items
+ where headline ilike $1 order by published_at desc nulls last limit 40`,
+ [like]
+ );
+ return { eps: eps.rows, cast: cast.rows, news: news.rows };
} catch {
- return { eps: [], cast: [] };
+ return { eps: [], cast: [], news: [] };
}
}
+// Static pages + games are searchable too (no DB — always current).
+const PAGES = [
+ { label: "Series overview", href: "/show", kw: "series overview show seasons about" },
+ { label: "Episode Guide", href: "/episodes", kw: "episodes guide recaps seasons" },
+ { label: "Cast", href: "/cast", kw: "cast actors bios" },
+ { label: "News", href: "/news", kw: "news articles press" },
+ { label: "Community", href: "/community", kw: "community reddit discord podcast forum" },
+ { label: "Where to Watch", href: "https://www.justwatch.com/us/tv-show/90210", kw: "where to watch stream streaming hulu justwatch" },
+ { label: "🎮 90210 Trivia", href: "/games/trivia.html", kw: "games trivia quiz play" },
+ { label: "🎲 90210 Backgammon", href: "/games/backgammon.html", kw: "games backgammon board play" },
+ { label: "🏎️ Rodeo Drive Runner", href: "/games/rodeo-runner.html", kw: "games runner arcade play" },
+ { label: "🎰 Beverly Hills Slots", href: "/games/slots.html", kw: "games slots casino play jackpot" },
+ { label: "🂡 90210 Blackjack", href: "/games/blackjack.html", kw: "games blackjack casino cards play" },
+ { label: "🎡 90210 Roulette", href: "/games/roulette.html", kw: "games roulette casino wheel play" },
+];
+
export default async function Search({
searchParams,
}: {
@@ -38,8 +60,12 @@ export default async function Search({
}) {
const { q } = await searchParams;
const query = (q ?? "").trim();
- const results = query ? await search(query) : { eps: [], cast: [] };
- const total = results.eps.length + results.cast.length;
+ const results = query ? await search(query) : { eps: [], cast: [], news: [] };
+ const pageHits = query
+ ? PAGES.filter((p) => `${p.label} ${p.kw}`.toLowerCase().includes(query.toLowerCase()))
+ : [];
+ const total =
+ results.eps.length + results.cast.length + results.news.length + pageHits.length;
return (
<section>
@@ -68,6 +94,25 @@ export default async function Search({
</p>
)}
+ {pageHits.length > 0 && (
+ <>
+ <h2 style={{ fontSize: 16 }}>Pages & Games</h2>
+ <ul style={{ listStyle: "none", padding: 0 }}>
+ {pageHits.map((p) => (
+ <li key={p.href} style={{ padding: "6px 0", borderBottom: "1px solid var(--line)" }}>
+ <a
+ href={p.href}
+ {...(p.href.startsWith("http") ? { target: "_blank", rel: "noopener noreferrer" } : {})}
+ >
+ {p.label}
+ {p.href.startsWith("http") ? " ↗" : " →"}
+ </a>
+ </li>
+ ))}
+ </ul>
+ </>
+ )}
+
{results.cast.length > 0 && (
<>
<h2 style={{ fontSize: 16 }}>Cast</h2>
@@ -107,7 +152,25 @@ export default async function Search({
</>
)}
- {query && total === 0 && <p>No matches. Try a character name or episode title.</p>}
+ {results.news.length > 0 && (
+ <>
+ <h2 style={{ fontSize: 16 }}>News ({results.news.length})</h2>
+ <ul style={{ listStyle: "none", padding: 0 }}>
+ {results.news.map((n) => (
+ <li key={n.id} style={{ padding: "6px 0", borderBottom: "1px solid var(--line)" }}>
+ <a href={n.canonical_url} target="_blank" rel="noopener noreferrer">
+ {n.headline}
+ </a>
+ {n.publisher_name ? (
+ <span style={{ color: "var(--muted)", fontSize: 12 }}> · {n.publisher_name} ↗</span>
+ ) : null}
+ </li>
+ ))}
+ </ul>
+ </>
+ )}
+
+ {query && total === 0 && <p>No matches. Try a character name, episode title, or game.</p>}
</section>
);
}
diff --git a/apps/web/app/show/page.tsx b/apps/web/app/show/page.tsx
index 21ba14d..d87fc37 100644
--- a/apps/web/app/show/page.tsx
+++ b/apps/web/app/show/page.tsx
@@ -8,6 +8,15 @@ export const metadata = {
"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(
@@ -20,6 +29,14 @@ async function getData() {
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,
@@ -32,16 +49,17 @@ async function getData() {
seasons: seasons.rows,
totals: totals.rows[0],
cast: cast.rows,
+ characters: characters.rows,
};
} catch {
- return { show: null, seasons: [], totals: null, cast: [] };
+ 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 } = await getData();
+ const { show, seasons, totals, cast, characters } = await getData();
const jsonLd = {
"@context": "https://schema.org",
"@type": "TVSeries",
@@ -67,23 +85,46 @@ export default async function Show() {
</p>
<h2>Seasons</h2>
- <div style={{ background: "var(--paper-2)", border: "1px solid var(--line)", borderRadius: "var(--radius)", overflow: "hidden", boxShadow: "var(--shadow)" }}>
- {seasons.map((s, i) => (
- <a
- key={s.season_number}
- href={`/episodes#s${s.season_number}`}
- style={{ display: "flex", gap: 12, padding: "11px 16px", borderTop: i === 0 ? "none" : "1px solid var(--line)", color: "inherit", alignItems: "baseline" }}
- >
- <strong className="se" style={{ minWidth: 78 }}>Season {s.season_number}</strong>
- <span style={{ flex: 1 }}>{s.eps} episodes</span>
- <span style={{ color: "var(--muted)", fontSize: 12 }}>
- {yr(s.first_aired)}
- {yr(s.last_aired) && yr(s.last_aired) !== yr(s.first_aired) ? `–${yr(s.last_aired)}` : ""}
- </span>
- </a>
+ <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) => (
+ <div key={c.name} className="card">
+ <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>
+ </div>
+ ))}
+ </div>
+ </>
+ )}
+
<h2>Cast at a glance</h2>
<div style={{ display: "flex", flexWrap: "wrap", gap: 8 }}>
{cast.map((c) => (
diff --git a/db/enrich-wiki.mjs b/db/enrich-wiki.mjs
new file mode 100644
index 0000000..02ee286
--- /dev/null
+++ b/db/enrich-wiki.mjs
@@ -0,0 +1,37 @@
+// Comprehensiveness pass: fill characters.description_original with original
+// profiles grounded in factual series knowledge. (Season overviews are static
+// editorial content in the /show page — no DB column needed.)
+// Run: node db/enrich-wiki.mjs (idempotent)
+import pg from "pg";
+const DATABASE_URL =
+ process.env.DATABASE_URL ?? "postgresql://localhost/nineoh_guide?host=/tmp";
+const pool = new pg.Pool({ connectionString: DATABASE_URL });
+const show = (await pool.query(`select id from shows where canonical_title='90210'`)).rows[0];
+
+const CHARACTERS = {
+ "Annie Wilson": "The Kansas transplant at the show's center — earnest and stage-struck, repeatedly tested by Beverly Hills.",
+ "Erin Silver": "Sharp, creative and guarded — a budding filmmaker who weathers family loss and mental-health struggles.",
+ "Naomi Clark": "The wealthy, impulsive queen bee whose schemes and heartbreaks drive much of the series.",
+ "Adrianna Tate-Duncan": "A former child star and aspiring singer navigating addiction, motherhood and fame.",
+ "Dixon Wilson": "Annie's adopted brother — an athlete turned aspiring music producer with a searching streak.",
+ "William 'Liam' Court": "The brooding, motorcycle-riding bad boy with a soft center and a knack for trouble.",
+ "Navid Shirazi": "An earnest aspiring journalist and filmmaker — the group's steady conscience.",
+ "Debbie Wilson": "The Wilson matriarch, a photographer balancing her career with her kids' chaos.",
+ "Ivy Sullivan": "A free-spirited surfer and artist who joins the group as the ensemble widens.",
+ "Teddy Montgomery": "A tennis prodigy and heir whose journey of self-discovery is one of the show's landmark arcs.",
+ "Ryan Matthews": "The young West Bev teacher pulled into the students' orbit in the early seasons.",
+ "Harrison 'Harry' Wilson": "The Wilson patriarch and West Bev principal, steering the family through its move west.",
+ "Ethan Ward": "A first-season jock and Annie's early love interest, torn between image and conscience.",
+ "Tabitha Wilson": "Annie and Dixon's glamorous, acid-tongued grandmother — a former actress who never lost the spotlight.",
+};
+
+let c = 0;
+for (const [name, desc] of Object.entries(CHARACTERS)) {
+ const r = await pool.query(
+ `update characters set description_original=$1 where name=$2 and show_id=$3`,
+ [desc, name, show.id]
+ );
+ c += r.rowCount;
+}
+console.log(`character profiles: ${c}/${Object.keys(CHARACTERS).length}`);
+await pool.end();
← 0158b8e go-all: approve S2-S5 recaps (all 114 reviewed) + More-from-
·
back to Nineoh Guide
·
richer episodes: per-episode guest/recurring cast (1160 cred 59829db →