[object Object]

← back to Nineoh Guide

go-all: approve S2-S5 recaps (all 114 reviewed) + More-from-Season links on detail + /show series hub (JSON-LD, season breakdown, cast, where-to-watch) + real legal email info@agentabrams.com

0158b8e730da8c2e69709b8cc38380e0a17966a7 · 2026-07-26 08:25:08 -0700 · Steve

Files touched

Diff

commit 0158b8e730da8c2e69709b8cc38380e0a17966a7
Author: Steve <steve@designerwallcoverings.com>
Date:   Sun Jul 26 08:25:08 2026 -0700

    go-all: approve S2-S5 recaps (all 114 reviewed) + More-from-Season links on detail + /show series hub (JSON-LD, season breakdown, cast, where-to-watch) + real legal email info@agentabrams.com
---
 apps/web/app/episodes/[season]/[episode]/page.tsx |  35 ++++++++
 apps/web/app/layout.tsx                           |   1 +
 apps/web/app/show/page.tsx                        | 101 ++++++++++++++++++++++
 apps/web/app/sitemap.ts                           |   1 +
 4 files changed, 138 insertions(+)

diff --git a/apps/web/app/episodes/[season]/[episode]/page.tsx b/apps/web/app/episodes/[season]/[episode]/page.tsx
index b8a0d53..a4d29d1 100644
--- a/apps/web/app/episodes/[season]/[episode]/page.tsx
+++ b/apps/web/app/episodes/[season]/[episode]/page.tsx
@@ -41,6 +41,20 @@ async function getAdjacent(season: number, episode: number) {
   }
 }
 
+async function getSeasonEpisodes(season: number, exclude: number) {
+  try {
+    const { rows } = await pool.query(
+      `select episode_number, title from episodes
+        where season_number=$1 and episode_number<>$2
+        order by episode_number`,
+      [season, exclude]
+    );
+    return rows;
+  } catch {
+    return [];
+  }
+}
+
 export async function generateMetadata({
   params,
 }: {
@@ -70,6 +84,7 @@ export default async function EpisodePage({
   const ep = await getEpisode(Number(season), Number(episode));
   if (!ep) notFound();
   const { prev, next } = await getAdjacent(ep.season_number, ep.episode_number);
+  const seasonEps = await getSeasonEpisodes(ep.season_number, ep.episode_number);
 
   const jsonLd = {
     "@context": "https://schema.org",
@@ -156,6 +171,26 @@ export default async function EpisodePage({
           <span />
         )}
       </nav>
+
+      {seasonEps.length > 0 && (
+        <div style={{ marginTop: 24 }}>
+          <h2 style={{ fontSize: 17 }}>More from Season {ep.season_number}</h2>
+          <div
+            style={{
+              display: "grid",
+              gap: 6,
+              gridTemplateColumns: "repeat(auto-fill, minmax(200px, 1fr))",
+              fontSize: 13,
+            }}
+          >
+            {seasonEps.map((e) => (
+              <a key={e.episode_number} href={`/episodes/${ep.season_number}/${e.episode_number}`}>
+                <span className="se">S{ep.season_number}E{e.episode_number}</span> {e.title}
+              </a>
+            ))}
+          </div>
+        </div>
+      )}
     </article>
   );
 }
diff --git a/apps/web/app/layout.tsx b/apps/web/app/layout.tsx
index ddefa24..7533dad 100644
--- a/apps/web/app/layout.tsx
+++ b/apps/web/app/layout.tsx
@@ -40,6 +40,7 @@ export default function RootLayout({ children }: { children: React.ReactNode })
               <span className="badge">Unofficial</span>
             </a>
             <nav className="nav">
+              <a href="/show">Series</a>
               <a href="/episodes">Episodes</a>
               <a href="/cast">Cast</a>
               <a href="/news">News</a>
diff --git a/apps/web/app/show/page.tsx b/apps/web/app/show/page.tsx
new file mode 100644
index 0000000..21ba14d
--- /dev/null
+++ b/apps/web/app/show/page.tsx
@@ -0,0 +1,101 @@
+import { pool } from "@/lib/db";
+import { castPath } from "@nineoh/core";
+
+export const dynamic = "force-dynamic";
+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.",
+};
+
+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 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,
+    };
+  } catch {
+    return { show: null, seasons: [], totals: null, cast: [] };
+  }
+}
+
+const yr = (d: any) => (d ? new Date(d).getFullYear() : "");
+
+export default async function Show() {
+  const { show, seasons, totals, cast } = 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={{ 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>
+
+      <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>
+  );
+}
diff --git a/apps/web/app/sitemap.ts b/apps/web/app/sitemap.ts
index b34fece..2b67be9 100644
--- a/apps/web/app/sitemap.ts
+++ b/apps/web/app/sitemap.ts
@@ -8,6 +8,7 @@ export const dynamic = "force-dynamic";
 export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
   const urls: MetadataRoute.Sitemap = [
     "",
+    "/show",
     "/episodes",
     "/cast",
     "/news",

← c2dadda where to watch: JustWatch live-availability links on home +  ·  back to Nineoh Guide  ·  comprehensive pass: /show season overviews + 14 character pr e200329 →