feat: add transcripts with audio links

This commit is contained in:
gosangam
2026-05-09 10:25:14 +05:30
parent 52924096d1
commit 9eb2a06b50
21 changed files with 5482 additions and 48 deletions
+240
View File
@@ -0,0 +1,240 @@
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 } 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,
}: {
params: Promise<{ slug: string }>;
}): Promise<Metadata> {
const { slug } = await params;
const t = await getTranscription(slug);
if (!t) return {};
const detail = [t.date, t.place].filter(Boolean).join(" · ");
return buildOgMeta({
title: t.name,
detail: detail || undefined,
quote:
t.content.length > 0
? t.content
.find((b) => b.speaker === "Prabhupāda" && b.text.length > 50)
?.text?.replace(/<[^>]+>/g, "")
.slice(0, 200)
: 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"
>
&larr; {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-end justify-between gap-4">
{/* Participants */}
{t.participants.length > 0 && (
<>
<div className="flex flex-col gap-2">
<div className="flex pl-2 items-center gap-2 flex-wrap text-sm text-muted-foreground">
<Users className="size-3.5" />
<span>Participants:</span>
</div>
<div className="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 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,99 @@
"use client";
import { useAudioPlayer, type AudioTrack } from "@/components/audio-player";
import { cn } from "@/lib/utils";
import { Play, Pause, Radio } 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 ? (
<Pause className="size-3.5" />
) : (
<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 ? (
<>
<Radio className="size-4 animate-pulse" />
<span>Now Playing</span>
</>
) : (
<>
<Play className="size-4" />
<span>Play Audio</span>
</>
)}
</button>
);
}
+36 -24
View File
@@ -1,19 +1,41 @@
export const dynamic = "force-dynamic";
import type { Metadata } from "next";
import Link from "next/link";
import { PageHeader } from "@/components/page-header";
import { buildOgMeta } from "@/lib/og";
import { Mic, Clock } from "lucide-react";
import {
getTranscriptionYears,
getTranscriptionPlaces,
getTotalTranscriptionCount,
getTranscriptionIndex,
} from "@/lib/transcriptions";
import { TranscriptionsNav } from "./transcriptions-nav";
import { TranscriptionsBrowser } from "./transcriptions-browser";
export function generateMetadata(): Metadata {
return buildOgMeta({
title: "Transcriptions",
detail: "Lectures & conversations",
detail: "3,700+ lectures & conversations (1966–1977)",
quote:
"Transcriptions of Śrīla Prabhupāda's lectures, morning walks, room conversations, and dialogues — the living voice of the Founder-Ācārya preserved in text.",
});
}
export default function TranscriptionsPage() {
export default async function TranscriptionsPage({
searchParams,
}: {
searchParams: Promise<{ view?: string }>;
}) {
const { view = "year" } = await searchParams;
const [years, places, totalCount, index] = await Promise.all([
getTranscriptionYears(),
getTranscriptionPlaces(),
getTotalTranscriptionCount(),
getTranscriptionIndex(),
]);
return (
<div className="min-h-screen bg-background">
<PageHeader>
@@ -27,31 +49,21 @@ export default function TranscriptionsPage() {
Transcriptions
</h1>
<p className="mt-1 text-muted-foreground">
Lectures &amp; conversations of Śrīla Prabhupāda
{totalCount.toLocaleString()} lectures &amp; conversations of Śrīla
Prabhupāda (1966–1977)
</p>
</PageHeader>
<main className="max-w-5xl mx-auto px-6 py-8">
<div className="flex flex-col items-center justify-center py-24 text-center">
<div className="flex items-center justify-center size-20 rounded-2xl bg-primary/10 text-primary mb-6">
<Mic className="size-10" />
</div>
<main className="max-w-4xl mx-auto px-6 py-8 space-y-6">
<TranscriptionsNav currentView={view} />
<h2 className="text-2xl font-bold text-foreground mb-2">
Coming Soon
</h2>
<p className="text-muted-foreground max-w-md mb-8">
We are working on indexing thousands of transcriptions of Śrīla
Prabhupāda&apos;s lectures, morning walks, room conversations, and
dialogues into our database. This section will be available shortly.
</p>
<div className="flex items-center gap-2 text-sm text-muted-foreground/60">
<Clock className="size-4" />
<span>Under active development</span>
</div>
</div>
<TranscriptionsBrowser
currentView={view}
years={years}
places={places}
lectureCount={index.typeDistribution.lecture}
conversationCount={index.typeDistribution.conversation}
/>
</main>
</div>
);
+97
View File
@@ -0,0 +1,97 @@
export const dynamic = "force-dynamic";
import type { Metadata } from "next";
import Link from "next/link";
import { notFound } from "next/navigation";
import { getTranscriptionsByPlace } from "@/lib/transcriptions";
import { buildOgMeta } from "@/lib/og";
import { PageHeader } from "@/components/page-header";
import { Card, CardContent } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import { Clock } from "lucide-react";
export async function generateMetadata({
params,
}: {
params: Promise<{ place: string }>;
}): Promise<Metadata> {
const { place: encodedPlace } = await params;
const place = decodeURIComponent(encodedPlace);
const transcriptions = await getTranscriptionsByPlace(place);
return buildOgMeta({
title: "Transcriptions",
detail: `${transcriptions.length} transcriptions from ${place}`,
quote: `Lectures and conversations of Śrīla Prabhupāda in ${place}.`,
});
}
export default async function TranscriptionsByPlacePage({
params,
}: {
params: Promise<{ place: string }>;
}) {
const { place: encodedPlace } = await params;
const place = decodeURIComponent(encodedPlace);
const transcriptions = await getTranscriptionsByPlace(place);
if (transcriptions.length === 0) notFound();
return (
<div className="min-h-screen bg-background">
<PageHeader sticky>
<Link
href="/transcriptions?view=place"
className="text-sm text-muted-foreground hover:text-foreground transition-colors"
>
&larr; Transcriptions by Place
</Link>
<h1 className="mt-3 text-2xl font-bold text-foreground">
Transcriptions in {place}
</h1>
<p className="mt-1 text-sm text-muted-foreground">
{transcriptions.length} transcriptions
</p>
</PageHeader>
<main className="max-w-5xl mx-auto px-6 py-8">
<Card className="overflow-hidden">
<CardContent className="p-0 divide-y divide-border">
{transcriptions.map((t) => (
<Link
key={t.slug}
href={`/transcriptions/${t.slug}`}
className="flex flex-col sm:flex-row sm:items-center sm:justify-between px-5 py-3.5 gap-1 sm:gap-4 hover:bg-muted transition-colors"
>
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2 flex-wrap">
<p className="font-medium text-foreground text-sm">
{t.name}
</p>
<Badge
variant={t.type === "lecture" ? "default" : "secondary"}
className="text-[10px] px-1.5 py-0"
>
{t.type === "lecture" ? "Lecture" : "Conversation"}
</Badge>
</div>
<div className="flex items-center gap-3 mt-1 text-xs text-muted-foreground">
{t.date && <span>{t.date}</span>}
{t.reference && (
<span className="text-primary/70">{t.reference}</span>
)}
{t.durationMinutes && t.durationMinutes > 0 && (
<span className="flex items-center gap-1">
<Clock className="size-3" />
{t.durationMinutes} min
</span>
)}
</div>
</div>
</Link>
))}
</CardContent>
</Card>
</main>
</div>
);
}
@@ -0,0 +1,426 @@
"use client";
import { useState, useMemo, useRef, useEffect } from "react";
import Link from "next/link";
import { Card, CardContent } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { cn } from "@/lib/utils";
import {
Search,
ChevronLeft,
ChevronRight,
ArrowUpDown,
ArrowDownAZ,
ArrowUpAZ,
Hash,
Check,
BookOpen,
MessageCircle,
} from "lucide-react";
// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------
export type YearGroup = { year: number; count: number };
export type PlaceGroup = { name: string; count: number };
type SortMode = "count" | "asc" | "desc";
// ---------------------------------------------------------------------------
// Diacritic-aware normalize (matches books-grid pattern)
// ---------------------------------------------------------------------------
function normalize(text: string): string {
return text
.toLowerCase()
.replace(/ṛ/g, "r")
.replace(/ṝ/g, "r")
.replace(/ṣ/g, "s")
.replace(/ś/g, "s")
.replace(/ṭ/g, "t")
.replace(/ḍ/g, "d")
.replace(/ṇ/g, "n")
.replace(/ñ/g, "n")
.replace(/ṅ/g, "n")
.replace(/ḥ/g, "h")
.replace(/ṁ/g, "m")
.replace(/ṃ/g, "m")
.replace(/ḷ/g, "l")
.replace(/ā/g, "a")
.replace(/ī/g, "i")
.replace(/ū/g, "u")
.normalize("NFD")
.replace(/[\u0300-\u036f]/g, "");
}
// ---------------------------------------------------------------------------
// Sort Popover
// ---------------------------------------------------------------------------
const SORT_OPTIONS: { key: SortMode; label: string; icon: typeof Hash }[] = [
{ key: "count", label: "Most transcriptions first", icon: Hash },
{ key: "asc", label: "A → Z", icon: ArrowDownAZ },
{ key: "desc", label: "Z → A", icon: ArrowUpAZ },
];
const SORT_OPTIONS_YEAR: { key: SortMode; label: string; icon: typeof Hash }[] =
[
{ key: "count", label: "Most transcriptions first", icon: Hash },
{ key: "asc", label: "Oldest first", icon: ArrowDownAZ },
{ key: "desc", label: "Newest first", icon: ArrowUpAZ },
];
function SortPopover({
sort,
onSortChange,
isYear,
}: {
sort: SortMode;
onSortChange: (s: SortMode) => void;
isYear?: boolean;
}) {
const [open, setOpen] = useState(false);
const ref = useRef<HTMLDivElement>(null);
// Close on outside click
useEffect(() => {
if (!open) return;
function handleClick(e: MouseEvent) {
if (ref.current && !ref.current.contains(e.target as Node)) {
setOpen(false);
}
}
document.addEventListener("mousedown", handleClick);
return () => document.removeEventListener("mousedown", handleClick);
}, [open]);
const options = isYear ? SORT_OPTIONS_YEAR : SORT_OPTIONS;
return (
<div ref={ref} className="relative">
<button
onClick={() => setOpen(!open)}
className={cn(
"flex items-center justify-center size-10 rounded-lg border transition-colors",
open
? "bg-primary/10 border-primary/30 text-primary"
: "border-border text-muted-foreground hover:bg-muted hover:text-foreground",
)}
title="Sort options"
aria-label="Sort options"
>
<ArrowUpDown className="size-4" />
</button>
{open && (
<div className="absolute right-0 top-12 z-50 w-52 rounded-lg border border-border bg-card shadow-lg py-1 animate-in fade-in-0 zoom-in-95">
{options.map((opt) => {
const Icon = opt.icon;
const isActive = sort === opt.key;
return (
<button
key={opt.key}
onClick={() => {
onSortChange(opt.key);
setOpen(false);
}}
className={cn(
"flex items-center gap-3 w-full px-3 py-2.5 text-sm transition-colors text-left",
isActive
? "bg-primary/10 text-primary font-medium"
: "text-foreground hover:bg-muted",
)}
>
<Icon className="size-4 shrink-0" />
<span className="flex-1">{opt.label}</span>
{isActive && <Check className="size-3.5 shrink-0" />}
</button>
);
})}
</div>
)}
</div>
);
}
// ---------------------------------------------------------------------------
// Pagination
// ---------------------------------------------------------------------------
const PAGE_SIZE_PLACES = 60;
const PAGE_SIZE_YEARS = 100;
function Pagination({
page,
totalPages,
onPageChange,
}: {
page: number;
totalPages: number;
onPageChange: (p: number) => void;
}) {
if (totalPages <= 1) return null;
return (
<div className="flex items-center justify-center gap-3 pt-4">
<button
onClick={() => onPageChange(page - 1)}
disabled={page <= 1}
className={cn(
"flex items-center gap-1 px-3 py-1.5 rounded-md text-sm font-medium transition-colors",
page <= 1
? "text-muted-foreground/40 cursor-not-allowed"
: "text-muted-foreground hover:bg-muted hover:text-foreground",
)}
>
<ChevronLeft className="size-4" />
Previous
</button>
<span className="text-sm text-muted-foreground">
Page {page} of {totalPages}
</span>
<button
onClick={() => onPageChange(page + 1)}
disabled={page >= totalPages}
className={cn(
"flex items-center gap-1 px-3 py-1.5 rounded-md text-sm font-medium transition-colors",
page >= totalPages
? "text-muted-foreground/40 cursor-not-allowed"
: "text-muted-foreground hover:bg-muted hover:text-foreground",
)}
>
Next
<ChevronRight className="size-4" />
</button>
</div>
);
}
// ---------------------------------------------------------------------------
// Sorting helpers
// ---------------------------------------------------------------------------
function sortYears(data: YearGroup[], mode: SortMode): YearGroup[] {
const sorted = [...data];
switch (mode) {
case "count":
return sorted.sort((a, b) => b.count - a.count);
case "asc":
return sorted.sort((a, b) => a.year - b.year);
case "desc":
return sorted.sort((a, b) => b.year - a.year);
}
}
function sortPlaces(data: PlaceGroup[], mode: SortMode): PlaceGroup[] {
const sorted = [...data];
switch (mode) {
case "count":
return sorted.sort((a, b) => b.count - a.count);
case "asc":
return sorted.sort((a, b) => a.name.localeCompare(b.name));
case "desc":
return sorted.sort((a, b) => b.name.localeCompare(a.name));
}
}
// ---------------------------------------------------------------------------
// Component
// ---------------------------------------------------------------------------
export function TranscriptionsBrowser({
currentView,
years,
places,
lectureCount,
conversationCount,
}: {
currentView: string;
years: YearGroup[];
places: PlaceGroup[];
lectureCount: number;
conversationCount: number;
}) {
const [query, setQuery] = useState("");
const [page, setPage] = useState(1);
const [sortYear, setSortYear] = useState<SortMode>("asc");
const [sortPlace, setSortPlace] = useState<SortMode>("count");
const handleQueryChange = (q: string) => {
setQuery(q);
setPage(1);
};
const handleSortChange = (mode: SortMode) => {
if (currentView === "year") setSortYear(mode);
else setSortPlace(mode);
setPage(1);
};
const currentSort = currentView === "year" ? sortYear : sortPlace;
// --- Filtered + sorted data ---
const filteredYears = useMemo(() => {
let data = years;
if (query.trim()) {
const q = normalize(query.trim());
data = data.filter((yg) => String(yg.year).includes(q));
}
return sortYears(data, sortYear);
}, [years, query, sortYear]);
const filteredPlaces = useMemo(() => {
let data = places;
if (query.trim()) {
const q = normalize(query.trim());
data = data.filter((pg) => normalize(pg.name).includes(q));
}
return sortPlaces(data, sortPlace);
}, [places, query, sortPlace]);
// --- Paginated data ---
const activeData =
currentView === "year" ? filteredYears : filteredPlaces;
const pageSize =
currentView === "year" ? PAGE_SIZE_YEARS : PAGE_SIZE_PLACES;
const totalPages = Math.max(1, Math.ceil(activeData.length / pageSize));
const safePage = Math.min(page, totalPages);
const startIdx = (safePage - 1) * pageSize;
const endIdx = startIdx + pageSize;
const placeholder =
currentView === "year" ? "Search years..." : "Search places...";
const resultCount = activeData.length;
const totalCount =
currentView === "year" ? years.length : places.length;
// --- Type view (no search/sort/pagination needed) ---
if (currentView === "type") {
return (
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<Link href="/transcriptions/type/lecture">
<Card className="transition-all hover:shadow-md hover:ring-foreground/20">
<CardContent className="py-8 px-6 text-center">
<BookOpen className="size-8 mx-auto mb-3 text-primary" />
<p className="text-xl font-bold text-foreground">Lectures</p>
<p className="text-sm text-muted-foreground mt-1">
{lectureCount.toLocaleString()} transcriptions
</p>
<p className="text-xs text-muted-foreground/60 mt-2">
Bhagavad-gītā, Śrīmad-Bhāgavatam, Caitanya-caritāmṛta &amp;
more
</p>
</CardContent>
</Card>
</Link>
<Link href="/transcriptions/type/conversation">
<Card className="transition-all hover:shadow-md hover:ring-foreground/20">
<CardContent className="py-8 px-6 text-center">
<MessageCircle className="size-8 mx-auto mb-3 text-primary" />
<p className="text-xl font-bold text-foreground">Conversations</p>
<p className="text-sm text-muted-foreground mt-1">
{conversationCount.toLocaleString()} transcriptions
</p>
<p className="text-xs text-muted-foreground/60 mt-2">
Morning walks, room conversations, interviews &amp; more
</p>
</CardContent>
</Card>
</Link>
</div>
);
}
// --- Year / Place views ---
return (
<div className="space-y-4">
{/* Search + Sort */}
<div className="flex gap-2">
<div className="relative flex-1">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 size-4 text-muted-foreground" />
<Input
type="text"
placeholder={placeholder}
value={query}
onChange={(e) => handleQueryChange(e.target.value)}
className="pl-9"
/>
</div>
<SortPopover
sort={currentSort}
onSortChange={handleSortChange}
isYear={currentView === "year"}
/>
</div>
{/* Result count */}
{query.trim() && (
<p className="text-sm text-muted-foreground">
{resultCount === 0
? "No results found"
: `${resultCount} of ${totalCount} ${currentView === "year" ? "years" : "places"}`}
</p>
)}
{/* Year view */}
{currentView === "year" && (
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 gap-3">
{(filteredYears.slice(startIdx, endIdx) as YearGroup[]).map((yg) => (
<Link key={yg.year} href={`/transcriptions/year/${yg.year}`}>
<Card className="transition-all hover:shadow-md hover:ring-foreground/20">
<CardContent className="py-4 px-4 text-center">
<p className="text-lg font-bold text-foreground">{yg.year}</p>
<p className="text-xs text-muted-foreground">
{yg.count}{" "}
{yg.count === 1 ? "transcription" : "transcriptions"}
</p>
</CardContent>
</Card>
</Link>
))}
</div>
)}
{/* Place view */}
{currentView === "place" && (
<div className="grid grid-cols-2 md:grid-cols-3 gap-3">
{(filteredPlaces.slice(startIdx, endIdx) as PlaceGroup[]).map(
(pg) => (
<Link
key={pg.name}
href={`/transcriptions/place/${encodeURIComponent(pg.name)}`}
>
<Card className="transition-all hover:shadow-md hover:ring-foreground/20">
<CardContent className="py-4 px-4 text-center">
<p className="font-medium text-foreground">{pg.name}</p>
<p className="text-xs text-muted-foreground">
{pg.count}{" "}
{pg.count === 1 ? "transcription" : "transcriptions"}
</p>
</CardContent>
</Card>
</Link>
),
)}
</div>
)}
{/* Pagination */}
<Pagination
page={safePage}
totalPages={totalPages}
onPageChange={setPage}
/>
</div>
);
}
+37
View File
@@ -0,0 +1,37 @@
"use client";
import Link from "next/link";
import { cn } from "@/lib/utils";
import { Calendar, MapPin, Mic } from "lucide-react";
const views = [
{ key: "year", label: "Year", icon: Calendar },
{ key: "place", label: "Place", icon: MapPin },
{ key: "type", label: "Type", icon: Mic },
];
export function TranscriptionsNav({ currentView }: { currentView: string }) {
return (
<div className="flex gap-2 border-b border-border pb-3">
{views.map((v) => {
const Icon = v.icon;
const isActive = currentView === v.key;
return (
<Link
key={v.key}
href={`/transcriptions?view=${v.key}`}
className={cn(
"flex items-center gap-2 px-4 py-2 rounded-lg text-sm font-medium transition-colors",
isActive
? "bg-primary/10 text-primary"
: "text-muted-foreground hover:bg-muted hover:text-foreground",
)}
>
<Icon className="size-4" />
{v.label}
</Link>
);
})}
</div>
);
}
+55
View File
@@ -0,0 +1,55 @@
export const dynamic = "force-dynamic";
import type { Metadata } from "next";
import { notFound } from "next/navigation";
import { getTranscriptionsByType } from "@/lib/transcriptions";
import { buildOgMeta } from "@/lib/og";
import { TranscriptionTypeBrowser } from "./type-browser";
const TYPE_LABELS: Record<string, string> = {
lecture: "Lectures",
conversation: "Conversations",
};
const TYPE_DESCRIPTIONS: Record<string, string> = {
lecture: "Bhagavad-gītā, Śrīmad-Bhāgavatam, Caitanya-caritāmṛta & more",
conversation: "Morning walks, room conversations, interviews & more",
};
export async function generateMetadata({
params,
}: {
params: Promise<{ type: string }>;
}): Promise<Metadata> {
const { type } = await params;
if (!TYPE_LABELS[type]) return {};
const transcriptions = await getTranscriptionsByType(type);
return buildOgMeta({
title: TYPE_LABELS[type],
detail: `${transcriptions.length} transcriptions`,
quote: `${TYPE_LABELS[type]} of Śrīla Prabhupāda — ${TYPE_DESCRIPTIONS[type]}.`,
});
}
export default async function TranscriptionsByTypePage({
params,
}: {
params: Promise<{ type: string }>;
}) {
const { type } = await params;
if (!TYPE_LABELS[type]) notFound();
const transcriptions = await getTranscriptionsByType(type);
if (transcriptions.length === 0) notFound();
return (
<div className="min-h-screen bg-background">
<TranscriptionTypeBrowser
transcriptions={transcriptions}
type={type}
typeLabel={TYPE_LABELS[type]}
totalCount={transcriptions.length}
/>
</div>
);
}
@@ -0,0 +1,549 @@
"use client";
import { useState, useMemo, useRef } from "react";
import Link from "next/link";
import { Card, CardContent } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import { buttonVariants } from "@/components/ui/button";
import { ReadingSettings } from "@/components/reading-settings";
import { cn } from "@/lib/utils";
import { ChevronLeft, ChevronRight, Menu, Clock, MapPin } from "lucide-react";
import {
Drawer,
DrawerClose,
DrawerContent,
DrawerHeader,
DrawerTitle,
DrawerTrigger,
} from "@/components/ui/drawer";
// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------
export type TranscriptionItem = {
slug: string;
name: string;
type: string;
date: string | null;
year: number | null;
month: number | null;
day: number | null;
place: string | null;
context: string | null;
reference: string | null;
durationMinutes: number | null;
participants: string[];
};
type YearGroup = {
year: number;
label: string;
count: number;
transcriptions: TranscriptionItem[];
};
type MonthGroup = {
month: number;
label: string;
transcriptions: TranscriptionItem[];
};
// ---------------------------------------------------------------------------
// Constants
// ---------------------------------------------------------------------------
const MONTH_NAMES = [
"",
"January",
"February",
"March",
"April",
"May",
"June",
"July",
"August",
"September",
"October",
"November",
"December",
];
// ---------------------------------------------------------------------------
// Grouping helpers
// ---------------------------------------------------------------------------
function buildYearGroups(transcriptions: TranscriptionItem[]): YearGroup[] {
const map = new Map<number, TranscriptionItem[]>();
for (const t of transcriptions) {
const y = t.year ?? 0;
if (!map.has(y)) map.set(y, []);
map.get(y)!.push(t);
}
const groups: YearGroup[] = [];
const years = Array.from(map.keys()).sort((a, b) => a - b);
for (const y of years) {
if (y === 0) continue;
const items = map.get(y)!;
groups.push({
year: y,
label: String(y),
count: items.length,
transcriptions: items,
});
}
const undated = map.get(0);
if (undated && undated.length > 0) {
groups.push({
year: 0,
label: "Undated",
count: undated.length,
transcriptions: undated,
});
}
return groups;
}
function buildMonthGroups(transcriptions: TranscriptionItem[]): MonthGroup[] {
const map = new Map<number, TranscriptionItem[]>();
for (const t of transcriptions) {
const m = t.month && t.month >= 1 && t.month <= 12 ? t.month : 0;
if (!map.has(m)) map.set(m, []);
map.get(m)!.push(t);
}
const groups: MonthGroup[] = [];
for (let m = 1; m <= 12; m++) {
const items = map.get(m);
if (items && items.length > 0) {
groups.push({ month: m, label: MONTH_NAMES[m], transcriptions: items });
}
}
const unknown = map.get(0);
if (unknown && unknown.length > 0) {
groups.push({ month: 0, label: "Unknown Month", transcriptions: unknown });
}
return groups;
}
// ---------------------------------------------------------------------------
// Year Sidebar
// ---------------------------------------------------------------------------
function YearSidebar({
groups,
activeYear,
onSelect,
}: {
groups: YearGroup[];
activeYear: number;
onSelect: (year: number) => void;
}) {
return (
<aside className="hidden lg:block w-56 shrink-0 sticky top-[89px] self-start max-h-[calc(100vh-89px)] overflow-y-auto py-8 pr-4">
<h2 className="text-xs font-bold uppercase tracking-wider text-muted-foreground mb-3 px-2">
Years
</h2>
<nav className="flex flex-col gap-0.5">
{groups.map((g) => {
const isActive = g.year === activeYear;
return (
<button
key={g.year}
onClick={() => onSelect(g.year)}
className={cn(
"flex items-center justify-between gap-2 px-2 py-1.5 rounded-md text-sm transition-colors text-left",
isActive
? "bg-primary/10 text-primary font-medium"
: "text-muted-foreground hover:bg-muted hover:text-foreground",
)}
>
<span className="truncate">{g.label}</span>
<span
className={cn(
"text-xs tabular-nums shrink-0",
isActive ? "text-primary/70" : "text-muted-foreground/60",
)}
>
{g.count}
</span>
</button>
);
})}
</nav>
</aside>
);
}
// ---------------------------------------------------------------------------
// Mobile drawer
// ---------------------------------------------------------------------------
function YearMobileNav({
groups,
activeYear,
onSelect,
title,
}: {
groups: YearGroup[];
activeYear: number;
onSelect: (year: number) => void;
title: string;
}) {
const [open, setOpen] = useState(false);
return (
<Drawer open={open} onOpenChange={setOpen} direction="left">
<DrawerTrigger
className={cn(
buttonVariants({ variant: "outline", size: "sm" }),
"lg:hidden gap-1.5",
)}
>
<Menu className="size-4" />
<span className="hidden sm:inline">Years</span>
</DrawerTrigger>
<DrawerContent direction="left" className="h-full">
<DrawerHeader className="text-left border-b border-border pb-4 mb-2">
<DrawerTitle>{title}</DrawerTitle>
</DrawerHeader>
<nav className="overflow-y-auto px-4 pb-6 pt-2">
<div className="flex flex-col gap-0.5">
{groups.map((g) => {
const isActive = g.year === activeYear;
return (
<DrawerClose key={g.year} asChild>
<button
onClick={() => {
onSelect(g.year);
setOpen(false);
}}
className={cn(
"flex items-center justify-between gap-2 px-2 py-2 rounded-md text-sm transition-colors text-left",
isActive
? "bg-primary/10 text-primary font-medium"
: "text-muted-foreground hover:bg-muted hover:text-foreground",
)}
>
<span>{g.label}</span>
<span
className={cn(
"text-xs tabular-nums",
isActive
? "text-primary/70"
: "text-muted-foreground/60",
)}
>
{g.count}
</span>
</button>
</DrawerClose>
);
})}
</div>
</nav>
</DrawerContent>
</Drawer>
);
}
// ---------------------------------------------------------------------------
// Prev / Next nav
// ---------------------------------------------------------------------------
function YearNav({
prev,
next,
groups,
activeYear,
onSelect,
drawerTitle,
}: {
prev: YearGroup | null;
next: YearGroup | null;
groups: YearGroup[];
activeYear: number;
onSelect: (year: number) => void;
drawerTitle: string;
}) {
return (
<div className="flex items-center justify-between gap-3">
<div className="flex items-center gap-2">
<YearMobileNav
groups={groups}
activeYear={activeYear}
onSelect={onSelect}
title={drawerTitle}
/>
{prev ? (
<button
onClick={() => onSelect(prev.year)}
className={cn(
buttonVariants({ variant: "outline", size: "sm" }),
"gap-1.5",
)}
>
<ChevronLeft className="size-4" />
<span className="hidden sm:inline truncate max-w-[180px]">
{prev.label}
</span>
<span className="sm:hidden">Previous</span>
</button>
) : (
<div className="hidden lg:block" />
)}
</div>
{next ? (
<button
onClick={() => onSelect(next.year)}
className={cn(
buttonVariants({ variant: "outline", size: "sm" }),
"gap-1.5",
)}
>
<span className="hidden sm:inline truncate max-w-[180px]">
{next.label}
</span>
<span className="sm:hidden">Next</span>
<ChevronRight className="size-4" />
</button>
) : (
<div />
)}
</div>
);
}
// ---------------------------------------------------------------------------
// Month section — collapsible group of transcriptions for a single month
// ---------------------------------------------------------------------------
function MonthSection({
group,
defaultOpen,
}: {
group: MonthGroup;
defaultOpen: boolean;
}) {
const [open, setOpen] = useState(defaultOpen);
return (
<div>
<button
onClick={() => setOpen(!open)}
className="flex items-center gap-3 w-full px-5 py-3 text-left hover:bg-muted/50 transition-colors"
>
<ChevronRight
className={cn(
"size-4 text-muted-foreground shrink-0 transition-transform",
open && "rotate-90",
)}
/>
<span className="font-semibold text-foreground text-sm">
{group.label}
</span>
<span className="text-xs text-muted-foreground tabular-nums">
{group.transcriptions.length}{" "}
{group.transcriptions.length === 1
? "transcription"
: "transcriptions"}
</span>
</button>
{open && (
<ul className="divide-y divide-border">
{group.transcriptions.map((t) => (
<li key={t.slug}>
<Link
href={`/transcriptions/${t.slug}`}
className="flex flex-col sm:flex-row sm:items-center sm:justify-between px-5 pl-12 py-3.5 gap-1 sm:gap-4 hover:bg-muted transition-colors"
>
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2 flex-wrap">
<p className="font-medium text-foreground text-sm">
{t.name}
</p>
<Badge
variant={t.type === "lecture" ? "default" : "secondary"}
className="text-[10px] px-1.5 py-0"
>
{t.type === "lecture" ? "Lecture" : "Conversation"}
</Badge>
</div>
<div className="flex items-center gap-3 mt-1 text-xs text-muted-foreground">
{t.place && (
<span className="flex items-center gap-1">
<MapPin className="size-3" />
{t.place}
</span>
)}
{t.reference && (
<span className="text-primary/70">{t.reference}</span>
)}
{t.durationMinutes && t.durationMinutes > 0 && (
<span className="flex items-center gap-1">
<Clock className="size-3" />
{t.durationMinutes} min
</span>
)}
</div>
</div>
</Link>
</li>
))}
</ul>
)}
</div>
);
}
// ---------------------------------------------------------------------------
// Main component
// ---------------------------------------------------------------------------
export function TranscriptionTypeBrowser({
transcriptions,
type,
typeLabel,
totalCount,
}: {
transcriptions: TranscriptionItem[];
type: string;
typeLabel: string;
totalCount: number;
}) {
const yearGroups = useMemo(
() => buildYearGroups(transcriptions),
[transcriptions],
);
const [activeYearRaw, setActiveYear] = useState<number>(
yearGroups.length > 0 ? yearGroups[0].year : 0,
);
// Derive a safe active year
const activeYear = useMemo(() => {
if (yearGroups.length === 0) return activeYearRaw;
if (yearGroups.find((g) => g.year === activeYearRaw)) return activeYearRaw;
return yearGroups[0].year;
}, [yearGroups, activeYearRaw]);
const contentRef = useRef<HTMLDivElement>(null);
const handleYearSelect = (year: number) => {
setActiveYear(year);
contentRef.current?.scrollIntoView({ behavior: "smooth", block: "start" });
};
// Active group + neighbors
const activeIdx = yearGroups.findIndex((g) => g.year === activeYear);
const activeGroup = activeIdx >= 0 ? yearGroups[activeIdx] : null;
const prev = activeIdx > 0 ? yearGroups[activeIdx - 1] : null;
const next =
activeIdx >= 0 && activeIdx < yearGroups.length - 1
? yearGroups[activeIdx + 1]
: null;
// Month groups within the active year
const monthGroups = useMemo(
() => buildMonthGroups(activeGroup?.transcriptions ?? []),
[activeGroup],
);
const activeYearLabel = activeGroup?.label ?? "Unknown";
const drawerTitle = typeLabel;
return (
<>
{/* Header */}
<header className="border-b border-border bg-card px-6 py-4 sticky top-0 z-20">
<div className="max-w-5xl mx-auto flex items-start justify-between gap-4">
<div className="flex-1 min-w-0">
<Link
href="/transcriptions?view=type"
className="text-sm text-muted-foreground hover:text-foreground transition-colors"
>
&larr; Transcriptions by Type
</Link>
<h1 className="mt-3 text-2xl font-bold text-foreground">
<span className="text-primary">{typeLabel}: </span>
{activeYearLabel}
</h1>
<p className="mt-1 text-muted-foreground">
{activeGroup?.count ?? 0}{" "}
{(activeGroup?.count ?? 0) === 1
? "transcription"
: "transcriptions"}{" "}
in {activeYearLabel} · {totalCount} total
</p>
</div>
<div className="shrink-0 pt-1">
<ReadingSettings />
</div>
</div>
</header>
{/* Sidebar + Main */}
<div className="max-w-5xl mx-auto flex">
{/* Left sidebar — years */}
<YearSidebar
groups={yearGroups}
activeYear={activeYear}
onSelect={handleYearSelect}
/>
{/* Main content */}
<main ref={contentRef} className="flex-1 min-w-0 px-6 py-8 space-y-8">
{/* Top navigation */}
<YearNav
prev={prev}
next={next}
groups={yearGroups}
activeYear={activeYear}
onSelect={handleYearSelect}
drawerTitle={drawerTitle}
/>
{/* Content — transcriptions grouped by month */}
{monthGroups.length === 0 ? (
<div className="py-12 text-center text-muted-foreground">
<p className="text-lg font-medium">
No transcriptions for this year
</p>
</div>
) : (
<Card className="overflow-hidden">
<CardContent className="p-0 divide-y divide-border">
{monthGroups.map((mg, i) => (
<MonthSection
key={mg.month}
group={mg}
defaultOpen={i === 0 || monthGroups.length <= 3}
/>
))}
</CardContent>
</Card>
)}
{/* Bottom navigation */}
<YearNav
prev={prev}
next={next}
groups={yearGroups}
activeYear={activeYear}
onSelect={handleYearSelect}
drawerTitle={drawerTitle}
/>
</main>
</div>
</>
);
}
+46
View File
@@ -0,0 +1,46 @@
export const dynamic = "force-dynamic";
import type { Metadata } from "next";
import { notFound } from "next/navigation";
import { getTranscriptionsByYear } from "@/lib/transcriptions";
import { buildOgMeta } from "@/lib/og";
import { TranscriptionYearBrowser } from "./year-browser";
export async function generateMetadata({
params,
}: {
params: Promise<{ year: string }>;
}): Promise<Metadata> {
const { year: yearStr } = await params;
const year = parseInt(yearStr, 10);
if (isNaN(year)) return {};
const transcriptions = await getTranscriptionsByYear(year);
return buildOgMeta({
title: "Transcriptions",
detail: `${transcriptions.length} transcriptions from ${year}`,
quote: `Lectures and conversations of Śrīla Prabhupāda from ${year}.`,
});
}
export default async function TranscriptionsByYearPage({
params,
}: {
params: Promise<{ year: string }>;
}) {
const { year: yearStr } = await params;
const year = parseInt(yearStr, 10);
if (isNaN(year)) notFound();
const transcriptions = await getTranscriptionsByYear(year);
if (transcriptions.length === 0) notFound();
return (
<div className="min-h-screen bg-background">
<TranscriptionYearBrowser
transcriptions={transcriptions}
year={year}
totalCount={transcriptions.length}
/>
</div>
);
}
@@ -0,0 +1,217 @@
"use client";
import { useState, useMemo } from "react";
import Link from "next/link";
import { PageHeader } from "@/components/page-header";
import { Card, CardContent } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import { cn } from "@/lib/utils";
import { ChevronRight, Clock, MapPin } from "lucide-react";
import { PlayAudioButton } from "@/app/transcriptions/[slug]/play-audio-button";
type TranscriptionItem = {
slug: string;
name: string;
type: string;
date: string | null;
year: number | null;
month: number | null;
day: number | null;
place: string | null;
context: string | null;
reference: string | null;
durationMinutes: number | null;
participants: string[];
audioUrl: string | null;
};
const MONTH_NAMES = [
"",
"January",
"February",
"March",
"April",
"May",
"June",
"July",
"August",
"September",
"October",
"November",
"December",
];
function MonthSection({
month,
label,
items,
defaultOpen,
}: {
month: number;
label: string;
items: TranscriptionItem[];
defaultOpen: boolean;
}) {
const [open, setOpen] = useState(defaultOpen);
return (
<div>
<button
onClick={() => setOpen(!open)}
className="flex items-center gap-3 w-full px-5 py-3 text-left hover:bg-muted/50 transition-colors"
>
<ChevronRight
className={cn(
"size-4 text-muted-foreground shrink-0 transition-transform",
open && "rotate-90",
)}
/>
<span className="font-semibold text-foreground text-sm">{label}</span>
<span className="text-xs text-muted-foreground tabular-nums">
{items.length}{" "}
{items.length === 1 ? "transcription" : "transcriptions"}
</span>
</button>
{open && (
<ul className="divide-y divide-border">
{items.map((t) => (
<li key={t.slug}>
<Link
href={`/transcriptions/${t.slug}`}
className="flex items-center px-5 pl-12 py-3.5 gap-3 hover:bg-muted transition-colors"
>
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2 flex-wrap">
<p className="font-medium text-foreground text-sm">
{t.name}
</p>
<Badge
variant={t.type === "lecture" ? "default" : "secondary"}
className="text-[10px] px-1.5 py-0"
>
{t.type === "lecture" ? "Lecture" : "Conversation"}
</Badge>
</div>
<div className="flex items-center gap-3 mt-1 text-xs text-muted-foreground">
{t.place && (
<span className="flex items-center gap-1">
<MapPin className="size-3" />
{t.place}
</span>
)}
{t.reference && (
<span className="text-primary/70">{t.reference}</span>
)}
{t.durationMinutes && t.durationMinutes > 0 && (
<span className="flex items-center gap-1">
<Clock className="size-3" />
{t.durationMinutes} min
</span>
)}
</div>
</div>
{t.audioUrl && (
<PlayAudioButton
audioUrl={t.audioUrl}
title={t.name}
subtitle={[t.date, t.place].filter(Boolean).join(" · ")}
sourceUrl={`/transcriptions/${t.slug}`}
type={t.type}
variant="compact"
/>
)}
</Link>
</li>
))}
</ul>
)}
</div>
);
}
export function TranscriptionYearBrowser({
transcriptions,
year,
totalCount,
}: {
transcriptions: TranscriptionItem[];
year: number;
totalCount: number;
}) {
const monthGroups = useMemo(() => {
const map = new Map<number, TranscriptionItem[]>();
for (const t of transcriptions) {
const m = t.month && t.month >= 1 && t.month <= 12 ? t.month : 0;
if (!map.has(m)) map.set(m, []);
map.get(m)!.push(t);
}
const groups: {
month: number;
label: string;
items: TranscriptionItem[];
}[] = [];
for (let m = 1; m <= 12; m++) {
const items = map.get(m);
if (items && items.length > 0) {
groups.push({ month: m, label: MONTH_NAMES[m], items });
}
}
const unknown = map.get(0);
if (unknown && unknown.length > 0) {
groups.push({ month: 0, label: "Unknown Month", items: unknown });
}
return groups;
}, [transcriptions]);
const lectureCount = transcriptions.filter(
(t) => t.type === "lecture",
).length;
const conversationCount = transcriptions.filter(
(t) => t.type === "conversation",
).length;
return (
<>
<PageHeader sticky>
<Link
href="/transcriptions"
className="text-sm text-muted-foreground hover:text-foreground transition-colors"
>
&larr; Transcriptions
</Link>
<h1 className="mt-3 text-2xl font-bold text-foreground">
Transcriptions from {year}
</h1>
<p className="mt-1 text-sm text-muted-foreground">
{totalCount} transcriptions · {lectureCount} lectures ·{" "}
{conversationCount} conversations
</p>
</PageHeader>
<main className="max-w-5xl mx-auto px-6 py-8 space-y-6">
{monthGroups.length === 0 ? (
<div className="py-12 text-center text-muted-foreground">
<p className="text-lg font-medium">
No transcriptions for this year
</p>
</div>
) : (
<Card className="overflow-hidden">
<CardContent className="p-0 divide-y divide-border">
{monthGroups.map((mg, i) => (
<MonthSection
key={mg.month}
month={mg.month}
label={mg.label}
items={mg.items}
defaultOpen={i === 0 || monthGroups.length <= 3}
/>
))}
</CardContent>
</Card>
)}
</main>
</>
);
}