← back to Nineoh Guide

apps/web/app/characters/[slug]/page.tsx

93 lines

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>
  );
}