← back to Nineoh Guide

apps/web/app/episodes/[season]/[episode]/page.tsx

244 lines

import type { Metadata } from "next";
import { notFound } from "next/navigation";
import { pool } from "@/lib/db";
import { SITE_URL, episodePath } from "@nineoh/core";

export const revalidate = 3600; // ISR: cached HTML, hourly background refresh

type Params = { season: string; episode: string };

async function getEpisode(season: number, episode: number) {
  try {
    const { rows } = await pool.query(
      `select id, season_number, episode_number, title, air_date, writer, director,
              summary_short_original, summary_full_original, recap_status
         from episodes where season_number=$1 and episode_number=$2 limit 1`,
      [season, episode]
    );
    return rows[0] ?? null;
  } catch {
    return null;
  }
}

async function getAdjacent(season: number, episode: number) {
  try {
    const next = await pool.query(
      `select season_number, episode_number, title from episodes
        where (season_number, episode_number) > ($1,$2)
        order by season_number, episode_number limit 1`,
      [season, episode]
    );
    const prev = await pool.query(
      `select season_number, episode_number, title from episodes
        where (season_number, episode_number) < ($1,$2)
        order by season_number desc, episode_number desc limit 1`,
      [season, episode]
    );
    return { prev: prev.rows[0] ?? null, next: next.rows[0] ?? null };
  } catch {
    return { prev: null, next: null };
  }
}

async function getGuestCast(episodeId: string) {
  try {
    const { rows } = await pool.query(
      `select person_name, character_name from episode_guest_cast
        where episode_id=$1 order by ord`,
      [episodeId]
    );
    return rows;
  } catch {
    return [];
  }
}

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,
}: {
  params: Promise<Params>;
}): Promise<Metadata> {
  const { season, episode } = await params;
  const ep = await getEpisode(Number(season), Number(episode));
  if (!ep) return { title: "Episode not found" };
  const t = `90210 S${ep.season_number}E${ep.episode_number}: ${ep.title}`;
  const desc =
    ep.summary_short_original ??
    `Episode guide for 90210 Season ${ep.season_number}, Episode ${ep.episode_number}: ${ep.title}.`;
  return {
    title: t,
    description: desc,
    alternates: { canonical: `${SITE_URL}${episodePath(ep.season_number, ep.episode_number)}` },
    openGraph: { title: t, description: desc, type: "article" },
  };
}

export default async function EpisodePage({
  params,
}: {
  params: Promise<Params>;
}) {
  const { season, episode } = await params;
  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 guestCast = await getGuestCast(ep.id);

  const jsonLd = {
    "@context": "https://schema.org",
    "@type": "TVEpisode",
    name: ep.title,
    episodeNumber: ep.episode_number,
    partOfSeason: { "@type": "TVSeason", seasonNumber: ep.season_number },
    partOfSeries: { "@type": "TVSeries", name: "90210 (2008)" },
    ...(ep.air_date ? { datePublished: new Date(ep.air_date).toISOString().slice(0, 10) } : {}),
    ...(ep.summary_short_original ? { description: ep.summary_short_original } : {}),
  };

  return (
    <article style={{ lineHeight: 1.7 }}>
      <script
        type="application/ld+json"
        dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd) }}
      />
      <p style={{ fontSize: 12, color: "var(--muted)" }}>
        <a href="/episodes">← Episode Guide</a>
      </p>
      <h1 style={{ marginBottom: 4 }}>
        <span style={{ color: "var(--maroon)" }}>
          S{ep.season_number}E{ep.episode_number}
        </span>{" "}
        · {ep.title}
      </h1>
      <p style={{ color: "var(--muted)", fontSize: 13, marginTop: 0 }}>
        {ep.air_date
          ? `First aired ${new Date(ep.air_date).toLocaleDateString()}`
          : "Air date TBD"}
        {ep.writer ? ` · Written by ${ep.writer}` : ""}
        {ep.director ? ` · Directed by ${ep.director}` : ""}
      </p>
      <p style={{ fontSize: 12.5, marginTop: -4, display: "flex", gap: 14, flexWrap: "wrap" }}>
        <a
          href={`https://www.justwatch.com/us/tv-show/90210/season-${ep.season_number}`}
          target="_blank"
          rel="noopener noreferrer"
        >
          Where to watch Season {ep.season_number} ↗
        </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>

      {ep.summary_short_original ? (
        <>
          <p style={{ fontSize: 16 }}>{ep.summary_short_original}</p>
          {ep.recap_status === "ai-draft" ? (
            <p style={{ fontSize: 11, color: "var(--muted)" }}>
              Editorial recap draft — pending review.
            </p>
          ) : null}
        </>
      ) : (
        <p style={{ color: "var(--muted)" }}>An original recap for this episode is coming soon.</p>
      )}

      {guestCast.length > 0 && (
        <div style={{ marginTop: 22 }}>
          <h2 style={{ fontSize: 17 }}>Cast in this episode</h2>
          <p style={{ color: "var(--muted)", fontSize: 12, marginTop: -4 }}>
            Guest &amp; recurring cast ({guestCast.length})
          </p>
          <div
            style={{
              display: "grid",
              gap: 6,
              gridTemplateColumns: "repeat(auto-fill, minmax(220px, 1fr))",
              fontSize: 13,
            }}
          >
            {guestCast.map((g, i) => (
              <div key={i}>
                <strong>{g.person_name}</strong>
                {g.character_name ? (
                  <span style={{ color: "var(--muted)" }}> as {g.character_name}</span>
                ) : null}
              </div>
            ))}
          </div>
        </div>
      )}

      <nav
        style={{
          display: "flex",
          justifyContent: "space-between",
          gap: 12,
          marginTop: 30,
          paddingTop: 16,
          borderTop: "1px solid var(--line)",
          fontSize: 13,
        }}
      >
        {prev ? (
          <a href={`/episodes/${prev.season_number}/${prev.episode_number}`}>
            ← S{prev.season_number}E{prev.episode_number} · {prev.title}
          </a>
        ) : (
          <span />
        )}
        {next ? (
          <a
            href={`/episodes/${next.season_number}/${next.episode_number}`}
            style={{ textAlign: "right", marginLeft: "auto" }}
          >
            S{next.season_number}E{next.episode_number} · {next.title} →
          </a>
        ) : (
          <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>
  );
}