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