feat: add policy pages
This commit is contained in:
@@ -0,0 +1,264 @@
|
||||
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/page-header";
|
||||
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";
|
||||
|
||||
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">
|
||||
<PageHeader sticky>
|
||||
<Link
|
||||
href={backHref}
|
||||
className="text-sm text-muted-foreground hover:text-foreground transition-colors"
|
||||
>
|
||||
← {backLabel}
|
||||
</Link>
|
||||
<h1 className="mt-3 text-2xl font-bold text-foreground">{t.name}</h1>
|
||||
<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-[25%] flex justify-end">
|
||||
<PlayAudioButton
|
||||
audioUrl={t.audioUrl}
|
||||
title={t.name}
|
||||
subtitle={[t.date, t.place].filter(Boolean).join(" · ")}
|
||||
sourceUrl={`/transcriptions/${t.slug}`}
|
||||
type={t.type}
|
||||
/>
|
||||
</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">
|
||||
<div 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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
useAudioPlayer,
|
||||
type AudioTrack,
|
||||
AudioWaveAnimation,
|
||||
} from "@/components/audio-player";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Play, Pause } from "lucide-react";
|
||||
|
||||
type PlayAudioButtonProps = {
|
||||
audioUrl: string;
|
||||
title: string;
|
||||
subtitle?: string;
|
||||
sourceUrl: string;
|
||||
type?: string;
|
||||
className?: string;
|
||||
/** Compact icon-only variant for list items */
|
||||
variant?: "default" | "compact";
|
||||
};
|
||||
|
||||
export function PlayAudioButton({
|
||||
audioUrl,
|
||||
title,
|
||||
subtitle,
|
||||
sourceUrl,
|
||||
type,
|
||||
className,
|
||||
variant = "default",
|
||||
}: PlayAudioButtonProps) {
|
||||
const { currentTrack, isPlaying, play, togglePlayPause } = useAudioPlayer();
|
||||
|
||||
const isThisTrack = currentTrack?.url === audioUrl;
|
||||
const isThisPlaying = isThisTrack && isPlaying;
|
||||
|
||||
function handleClick() {
|
||||
if (isThisTrack) {
|
||||
togglePlayPause();
|
||||
} else {
|
||||
const track: AudioTrack = {
|
||||
url: audioUrl,
|
||||
title,
|
||||
subtitle,
|
||||
sourceUrl,
|
||||
type: type ?? "lecture",
|
||||
};
|
||||
play(track);
|
||||
}
|
||||
}
|
||||
|
||||
if (variant === "compact") {
|
||||
return (
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
handleClick();
|
||||
}}
|
||||
className={cn(
|
||||
"flex items-center justify-center size-8 rounded-full transition-all shrink-0 cursor-pointer!",
|
||||
isThisPlaying
|
||||
? "bg-primary text-primary-foreground shadow-sm"
|
||||
: "bg-primary/10 text-primary hover:bg-primary/20",
|
||||
className,
|
||||
)}
|
||||
title={isThisPlaying ? "Pause" : "Play audio"}
|
||||
aria-label={isThisPlaying ? "Pause" : "Play audio"}
|
||||
>
|
||||
{isThisPlaying ? (
|
||||
<AudioWaveAnimation
|
||||
paused={false}
|
||||
colorClassName="bg-primary-foreground"
|
||||
size="sm"
|
||||
/>
|
||||
) : (
|
||||
<Play className="size-3.5 ml-0.5" />
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<button
|
||||
onClick={handleClick}
|
||||
className={cn(
|
||||
"inline-flex cursor-pointer items-center gap-2 px-5 py-2.5 rounded-lg text-sm font-semibold transition-all shadow-sm",
|
||||
isThisPlaying
|
||||
? "bg-primary text-primary-foreground shadow-md"
|
||||
: "bg-primary text-primary-foreground hover:bg-primary/90 hover:shadow-md",
|
||||
className,
|
||||
)}
|
||||
aria-label={isThisPlaying ? "Pause audio" : "Play audio"}
|
||||
>
|
||||
{isThisPlaying ? (
|
||||
<>
|
||||
<AudioWaveAnimation
|
||||
paused={false}
|
||||
colorClassName="bg-primary-foreground"
|
||||
size="sm"
|
||||
/>
|
||||
<span>Now Playing</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Play className="size-4" />
|
||||
<span>Play Audio</span>
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user