[object Object]

← back to Nineoh Guide

loop refine 4/n: character detail pages (/characters + /characters/[slug]) — profile, actor link, guest-cast appearances + series-regular fallback; nav + sitemap; ISR

c766b0ca0b748284b7a067cb35c89cddcc7933dd · 2026-07-26 20:03:01 -0700 · Steve

Files touched

Diff

commit c766b0ca0b748284b7a067cb35c89cddcc7933dd
Author: Steve <steve@designerwallcoverings.com>
Date:   Sun Jul 26 20:03:01 2026 -0700

    loop refine 4/n: character detail pages (/characters + /characters/[slug]) — profile, actor link, guest-cast appearances + series-regular fallback; nav + sitemap; ISR
---
 apps/web/app/characters/[slug]/page.tsx | 92 +++++++++++++++++++++++++++++++++
 apps/web/app/characters/page.tsx        | 53 +++++++++++++++++++
 apps/web/app/layout.tsx                 |  1 +
 apps/web/app/show/page.tsx              |  6 +--
 apps/web/app/sitemap.ts                 |  9 +++-
 5 files changed, 157 insertions(+), 4 deletions(-)

diff --git a/apps/web/app/characters/[slug]/page.tsx b/apps/web/app/characters/[slug]/page.tsx
new file mode 100644
index 0000000..087b0a8
--- /dev/null
+++ b/apps/web/app/characters/[slug]/page.tsx
@@ -0,0 +1,92 @@
+import type { Metadata } from "next";
+import { notFound } from "next/navigation";
+import { pool } from "@/lib/db";
+import { SITE_URL, slugify, castPath } from "@nineoh/core";
+
+export const revalidate = 3600;
+type Params = { slug: string };
+
+async function getCharacter(slug: string) {
+  try {
+    const { rows } = await pool.query(
+      `select c.id, 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`
+    );
+    const c = rows.find((r) => slugify(r.name) === slug);
+    if (!c) return null;
+    const eps = await pool.query(
+      `select e.season_number, e.episode_number, e.title
+         from episode_guest_cast g join episodes e on e.id = g.episode_id
+        where g.character_name = $1
+        order by e.season_number, e.episode_number`,
+      [c.name]
+    );
+    return { ...c, episodes: eps.rows };
+  } catch {
+    return null;
+  }
+}
+
+export async function generateMetadata({ params }: { params: Promise<Params> }): Promise<Metadata> {
+  const { slug } = await params;
+  const c = await getCharacter(slug);
+  if (!c) return { title: "Character not found" };
+  return {
+    title: `${c.name} — 90210 Character`,
+    description: c.description_original ?? `${c.name} on 90210.`,
+    alternates: { canonical: `${SITE_URL}/characters/${slug}` },
+  };
+}
+
+export default async function CharacterPage({ params }: { params: Promise<Params> }) {
+  const { slug } = await params;
+  const c = await getCharacter(slug);
+  if (!c) notFound();
+
+  return (
+    <article style={{ lineHeight: 1.7 }}>
+      <p style={{ fontSize: 12, color: "var(--muted)" }}>
+        <a href="/characters">← Characters</a>
+      </p>
+      <h1 style={{ marginBottom: 2 }}>{c.name}</h1>
+      {c.actor ? (
+        <p style={{ marginTop: 0, color: "var(--muted)", fontSize: 14 }}>
+          Played by{" "}
+          <a href={castPath(c.actor)} style={{ color: "var(--maroon)", fontWeight: 600 }}>
+            {c.actor}
+          </a>
+        </p>
+      ) : null}
+      <p style={{ fontSize: 15.5 }}>{c.description_original}</p>
+
+      {c.episodes.length === 0 && c.actor && (
+        <p style={{ color: "var(--muted)", fontSize: 13 }}>
+          A series regular — appears across the run of the show. (Per-episode
+          listings below cover guest &amp; recurring appearances only.)
+        </p>
+      )}
+
+      {c.episodes.length > 0 && (
+        <>
+          <h2 style={{ fontSize: 17 }}>Appears in ({c.episodes.length})</h2>
+          <div
+            style={{
+              display: "grid",
+              gap: 6,
+              gridTemplateColumns: "repeat(auto-fill, minmax(210px, 1fr))",
+              fontSize: 13,
+            }}
+          >
+            {c.episodes.map((e: any) => (
+              <a key={`${e.season_number}-${e.episode_number}`} href={`/episodes/${e.season_number}/${e.episode_number}`}>
+                <span className="se">S{e.season_number}E{e.episode_number}</span> {e.title}
+              </a>
+            ))}
+          </div>
+        </>
+      )}
+    </article>
+  );
+}
diff --git a/apps/web/app/characters/page.tsx b/apps/web/app/characters/page.tsx
new file mode 100644
index 0000000..899ee34
--- /dev/null
+++ b/apps/web/app/characters/page.tsx
@@ -0,0 +1,53 @@
+import { pool } from "@/lib/db";
+import { slugify } from "@nineoh/core";
+
+export const revalidate = 3600;
+export const metadata = {
+  title: "Characters",
+  description: "Every main character on 90210 — profiles, who plays them, and the episodes they appear in.",
+};
+
+async function getCharacters() {
+  try {
+    const { rows } = await pool.query(
+      `select c.name, c.description_original, cp.name as actor,
+              (select count(*) from episode_guest_cast g where g.character_name = c.name)::int as appearances
+         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`
+    );
+    return rows;
+  } catch {
+    return [];
+  }
+}
+
+export default async function Characters() {
+  const chars = await getCharacters();
+  return (
+    <section>
+      <h1>Characters</h1>
+      <p style={{ color: "var(--muted)", fontSize: 13 }}>
+        The people of the 90210 ZIP code — who they are, who plays them, and where they turn up.
+      </p>
+      <div style={{ display: "grid", gap: 12, gridTemplateColumns: "repeat(auto-fill, minmax(280px, 1fr))", marginTop: 16 }}>
+        {chars.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: 16 }}>{c.name}</strong>
+            {c.actor ? <span style={{ color: "var(--muted)", fontSize: 12 }}> · {c.actor}</span> : null}
+            <p style={{ margin: "5px 0 0", color: "var(--ink-soft)", fontSize: 12.5, lineHeight: 1.5 }}>
+              {c.description_original}
+            </p>
+            {c.appearances > 0 ? (
+              <span className="pill" style={{ marginTop: 8, display: "inline-block" }}>
+                {c.appearances} appearances
+              </span>
+            ) : null}
+          </a>
+        ))}
+      </div>
+    </section>
+  );
+}
diff --git a/apps/web/app/layout.tsx b/apps/web/app/layout.tsx
index 8060bd0..1df2cc5 100644
--- a/apps/web/app/layout.tsx
+++ b/apps/web/app/layout.tsx
@@ -47,6 +47,7 @@ export default function RootLayout({ children }: { children: React.ReactNode })
               <a href="/show">Series</a>
               <a href="/episodes">Episodes</a>
               <a href="/cast">Cast</a>
+              <a href="/characters">Characters</a>
               <a href="/news">News</a>
               <a href="/games">Games</a>
               <a href="/community">Community</a>
diff --git a/apps/web/app/show/page.tsx b/apps/web/app/show/page.tsx
index be66f21..7664585 100644
--- a/apps/web/app/show/page.tsx
+++ b/apps/web/app/show/page.tsx
@@ -1,5 +1,5 @@
 import { pool } from "@/lib/db";
-import { castPath } from "@nineoh/core";
+import { castPath, slugify } from "@nineoh/core";
 
 export const revalidate = 3600; // ISR: cached HTML, hourly background refresh
 export const metadata = {
@@ -111,7 +111,7 @@ export default async function Show() {
           <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">
+              <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>
@@ -119,7 +119,7 @@ export default async function Show() {
                 <p style={{ margin: "4px 0 0", color: "var(--ink-soft)", fontSize: 12.5, lineHeight: 1.5 }}>
                   {c.description_original}
                 </p>
-              </div>
+              </a>
             ))}
           </div>
         </>
diff --git a/apps/web/app/sitemap.ts b/apps/web/app/sitemap.ts
index 2b67be9..661d3e6 100644
--- a/apps/web/app/sitemap.ts
+++ b/apps/web/app/sitemap.ts
@@ -1,6 +1,6 @@
 import type { MetadataRoute } from "next";
 import { pool } from "@/lib/db";
-import { SITE_URL, episodePath, castPath } from "@nineoh/core";
+import { SITE_URL, episodePath, castPath, slugify } from "@nineoh/core";
 import { GAMES } from "@/lib/games";
 
 export const dynamic = "force-dynamic";
@@ -11,6 +11,7 @@ export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
     "/show",
     "/episodes",
     "/cast",
+    "/characters",
     "/news",
     "/games",
     "/community",
@@ -39,6 +40,12 @@ export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
     for (const c of cast.rows) {
       urls.push({ url: `${SITE_URL}${castPath(c.name)}`, changeFrequency: "monthly", priority: 0.5 });
     }
+    const chars = await pool.query(
+      `select name from characters where description_original is not null order by name`
+    );
+    for (const c of chars.rows) {
+      urls.push({ url: `${SITE_URL}/characters/${slugify(c.name)}`, changeFrequency: "monthly", priority: 0.5 });
+    }
   } catch {
     /* DB unreachable at build — static routes still returned */
   }

← 78a8f84 loop refine 3/n: ISR (hourly revalidate) on stable pages (ho  ·  back to Nineoh Guide  ·  chore: version-up to v0.2.0 (session close) — code tsc/CTA/5 b3f641d →