← back to Nineoh Guide

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

104 lines

import type { Metadata } from "next";
import { notFound } from "next/navigation";
import { pool } from "@/lib/db";
import { SITE_URL, slugify, castPath, type Asset } from "@nineoh/core";
import { RightsImage } from "@/components/RightsImage";

function toAsset(r: any): Asset | null {
  if (!r?.file_url) return null;
  return {
    id: r.id ?? "x",
    type: "image",
    fileUrl: r.file_url,
    thumbnailUrl: r.file_url,
    licenseType: r.license_type,
    licenseUrl: r.license_url ?? null,
    attributionText: r.attribution_text ?? null,
    usageScope: "editorial",
    approvedForMarketing: false,
    licensePurchaseStatus: "none",
  };
}

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

type Params = { slug: string };

async function getPersonBySlug(slug: string) {
  try {
    const { rows } = await pool.query(
      `select cp.id, cp.name, cp.biography_original, cp.bio_status, ch.name as character_name,
              a.file_url, a.license_type, a.license_url, a.attribution_text
         from cast_people cp
         left join credits cr on cr.person_id = cp.id and cr.credit_type='main-cast'
         left join characters ch on ch.id = cr.character_id
         left join assets a on a.id = cp.headshot_asset_id`
    );
    return rows.find((r) => slugify(r.name) === slug) ?? null;
  } catch {
    return null;
  }
}

export async function generateMetadata({
  params,
}: {
  params: Promise<Params>;
}): Promise<Metadata> {
  const { slug } = await params;
  const p = await getPersonBySlug(slug);
  if (!p) return { title: "Cast member not found" };
  const t = p.character_name ? `${p.name} — ${p.character_name}` : p.name;
  const desc = p.biography_original ?? `${p.name} in the 90210 cast.`;
  return {
    title: t,
    description: desc,
    alternates: { canonical: `${SITE_URL}${castPath(p.name)}` },
    openGraph: { title: t, description: desc, type: "profile" },
  };
}

export default async function CastMember({
  params,
}: {
  params: Promise<Params>;
}) {
  const { slug } = await params;
  const p = await getPersonBySlug(slug);
  if (!p) notFound();

  const jsonLd = {
    "@context": "https://schema.org",
    "@type": "Person",
    name: p.name,
    ...(p.biography_original ? { description: p.biography_original } : {}),
    ...(p.character_name
      ? { performerIn: { "@type": "TVSeries", name: "90210 (2008)" } }
      : {}),
  };

  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="/cast">← Cast</a>
      </p>
      <h1 style={{ marginBottom: 2 }}>{p.name}</h1>
      {p.character_name ? (
        <p style={{ color: "var(--maroon)", marginTop: 0, fontWeight: 600 }}>
          as {p.character_name}
        </p>
      ) : null}
      <p style={{ fontSize: 15 }}>
        {p.biography_original ?? "An original bio for this cast member is coming soon."}
      </p>
      {p.bio_status === "ai-draft" ? (
        <p style={{ fontSize: 11, color: "var(--muted)" }}>Editorial bio draft — pending review.</p>
      ) : null}
    </article>
  );
}