[object Object]

← 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

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 &amp; 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 →