Files
spsays-training/app/(app)/(reader)/transcriptions/[slug]/page.tsx
T
2026-10-17 02:41:32 +05:30

297 lines
9.2 KiB
TypeScript

export const dynamic = "force-dynamic";
import type { Metadata } from "next";
import Link from "next/link";
import { notFound } from "next/navigation";
import {
getTranscription,
getAdjacentTranscriptions,
} from "@/lib/transcriptions";
import { buildOgMeta, ogForTranscription, extractQuoteFromHl } from "@/lib/og";
import { PageHeader } from "@/components/layout/page-header";
import { QuizButton } from "@/components/quiz/quiz-button";
import { Card, CardContent } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import { buttonVariants } from "@/components/ui/button";
import { cn } from "@/lib/utils";
import { ChevronLeft, ChevronRight, Clock, MapPin, Users } from "lucide-react";
import { PlayAudioButton } from "./play-audio-button";
import { FindInPage } from "@/components/layout/find-in-page";
import { JsonLd } from "@/components/seo/json-ld";
import { articleJsonLd } from "@/lib/jsonld";
export async function generateMetadata({
params,
searchParams,
}: {
params: Promise<{ slug: string }>;
searchParams: Promise<Record<string, string | string[] | undefined>>;
}): Promise<Metadata> {
const { slug } = await params;
const sp = await searchParams;
const t = await getTranscription(slug);
if (!t) return {};
const detail = [t.date, t.place].filter(Boolean).join(" · ");
// Build paragraphs from content blocks for hl matching
const paragraphs = t.content.map((b) => {
const speakerPrefix = b.speaker ? `${b.speaker}: ` : "";
return speakerPrefix + b.text.replace(/<[^>]+>/g, "");
});
const hlQuote = extractQuoteFromHl(sp, paragraphs);
if (hlQuote) {
return buildOgMeta({
title: `Transcripts: ${t.name}`,
detail: detail || undefined,
quote: hlQuote,
});
}
// Fallback: find a good Prabhupāda quote from the content
const spQuote = t.content
.find((b) => b.speaker === "Prabhupāda" && b.text.length > 50)
?.text?.replace(/<[^>]+>/g, "")
.slice(0, 200);
if (spQuote) {
return buildOgMeta({
title: t.name,
detail: detail || undefined,
quote: spQuote,
});
}
return ogForTranscription(`Transcripts: ${t.name}`, detail || undefined);
}
export default async function TranscriptionPage({
params,
}: {
params: Promise<{ slug: string }>;
}) {
const { slug } = await params;
const t = await getTranscription(slug);
if (!t) notFound();
const { prev, next } = await getAdjacentTranscriptions(slug, t.year);
const backHref = t.year
? `/transcriptions/year/${t.year}`
: "/transcriptions";
const backLabel = t.year ? `Transcriptions from ${t.year}` : "Transcriptions";
return (
<div className="min-h-screen bg-background">
<JsonLd
data={articleJsonLd({
path: `/transcriptions/${slug}`,
headline: t.name,
description: t.content[0]?.text,
datePublished: t.year ? String(t.year) : undefined,
isPartOf: "Lectures & Conversations of Śrīla Prabhupāda",
})}
/>
<PageHeader
sticky
// With audio, the quiz button sits beside the play control below
// instead; without it, the header shows it as on any other page.
quiz={!t.audioUrl}
offline={{
type: "transcript",
title: t.name,
subtitle: [t.date, t.place].filter(Boolean).join(" · ") || undefined,
audioUrl: t.audioUrl ?? undefined,
}}
>
<Link
href={backHref}
className="text-sm text-muted-foreground hover:text-foreground transition-colors"
>
&larr; {backLabel}
</Link>
<div className="mt-3 flex items-start justify-between gap-3">
<h1 className="text-2xl font-bold text-foreground">{t.name}</h1>
{t.content.length > 0 && (
<FindInPage targetId="transcript-content" className="shrink-0 mt-0.5" />
)}
</div>
<div className="mt-2 flex items-center gap-3 flex-wrap text-sm text-muted-foreground">
<Badge variant={t.type === "lecture" ? "default" : "secondary"}>
{t.type === "lecture" ? "Lecture" : "Conversation"}
</Badge>
{t.date && <span>{t.date}</span>}
{t.place && (
<span className="flex items-center gap-1">
<MapPin className="size-3.5" />
{t.place}
</span>
)}
{t.reference && (
<span className="text-primary font-medium">{t.reference}</span>
)}
{t.durationMinutes && t.durationMinutes > 0 && (
<span className="flex items-center gap-1">
<Clock className="size-3.5" />
{t.durationMinutes} min
</span>
)}
</div>
</PageHeader>
<main className="max-w-5xl mx-auto px-6 py-8 space-y-8">
{/* Top nav */}
<TranscriptionNav prev={prev} next={next} />
{/* Audio + Participants row */}
<div className="w-full flex flex-col md:flex-row items-start md:items-start justify-between gap-4">
{/* Participants */}
{t.participants.length > 0 && (
<>
<div className="">
<div className="flex flex-row pl-2 mb-2 items-center gap-2 text-sm text-muted-foreground">
<Users className="size-3.5" />
<span>Participants:</span>
</div>
<div className="max-w-full flex items-center gap-2 flex-wrap text-sm text-muted-foreground">
{t.participants.map((p, i) => (
<Badge
key={i}
variant="outline"
className="text-xs font-normal"
>
{p}
</Badge>
))}
</div>
</div>
</>
)}
{/* Play Audio button */}
{t.audioUrl && (
<div className="w-full md:w-auto flex flex-wrap items-center justify-end gap-2">
<QuizButton path={`/transcriptions/${t.slug}`} size="lg" />
<PlayAudioButton
audioUrl={t.audioUrl}
title={t.name}
subtitle={[t.date, t.place].filter(Boolean).join(" · ")}
sourceUrl={`/transcriptions/${t.slug}`}
type={t.type}
fallbackYear={t.year}
/>
</div>
)}
</div>
{/* Transcription content */}
{t.content.length === 0 ? (
<Card>
<CardContent className="py-12 text-center text-muted-foreground">
<p className="text-lg font-medium">No transcription available</p>
<p className="text-sm mt-1">
This recording has not been transcribed into text.
</p>
</CardContent>
</Card>
) : (
<Card>
<CardContent className="py-8 px-6 sm:px-8">
{/* id targeted by the header's FindInPage control */}
<div id="transcript-content" className="space-y-3">
{t.content.map((block, i) => (
<ContentBlockView key={i} block={block} />
))}
</div>
</CardContent>
</Card>
)}
{/* Bottom nav */}
<TranscriptionNav prev={prev} next={next} />
</main>
</div>
);
}
function ContentBlockView({
block,
}: {
block: {
text: string;
format: string;
alignment: string;
speaker: string | null;
};
}) {
const isVerse = block.format === "verse";
const isQuote = block.format === "quote";
const isCenter = block.alignment === "center";
const className = cn(
"text-base leading-relaxed",
isVerse && "text-center italic my-4",
isQuote && "border-l-2 border-primary/30 pl-4 text-muted-foreground italic",
!isVerse && !isQuote && isCenter && "text-center",
);
return (
<div className={className}>
{block.speaker && (
<span className="font-bold text-foreground">{block.speaker}: </span>
)}
<span dangerouslySetInnerHTML={{ __html: block.text }} />
</div>
);
}
function TranscriptionNav({
prev,
next,
}: {
prev: { slug: string; name: string } | null;
next: { slug: string; name: string } | null;
}) {
return (
<div className="flex items-center justify-between gap-3">
{prev ? (
<Link
href={`/transcriptions/${prev.slug}`}
className={cn(
buttonVariants({ variant: "outline", size: "sm" }),
"gap-1.5",
)}
>
<ChevronLeft className="size-4" />
<span className="hidden sm:inline truncate max-w-[180px]">
{prev.name}
</span>
<span className="sm:hidden">Previous</span>
</Link>
) : (
<div />
)}
{next ? (
<Link
href={`/transcriptions/${next.slug}`}
className={cn(
buttonVariants({ variant: "outline", size: "sm" }),
"gap-1.5",
)}
>
<span className="hidden sm:inline truncate max-w-[180px]">
{next.name}
</span>
<span className="sm:hidden">Next</span>
<ChevronRight className="size-4" />
</Link>
) : (
<div />
)}
</div>
);
}