From 9eb2a06b50869e4d4cc028f5507a97cd0cdceee8 Mon Sep 17 00:00:00 2001 From: gosangam Date: Sat, 9 May 2026 10:25:14 +0530 Subject: [PATCH] feat: add transcripts with audio links --- app/layout.tsx | 20 +- app/page.tsx | 23 +- app/transcriptions/[slug]/page.tsx | 240 +++ .../[slug]/play-audio-button.tsx | 99 ++ app/transcriptions/page.tsx | 60 +- app/transcriptions/place/[place]/page.tsx | 97 ++ app/transcriptions/transcriptions-browser.tsx | 426 +++++ app/transcriptions/transcriptions-nav.tsx | 37 + app/transcriptions/type/[type]/page.tsx | 55 + .../type/[type]/type-browser.tsx | 549 +++++++ app/transcriptions/year/[year]/page.tsx | 46 + .../year/[year]/year-browser.tsx | 217 +++ components/audio-player/audio-context.tsx | 462 ++++++ components/audio-player/floating-player.tsx | 560 +++++++ components/audio-player/index.ts | 3 + lib/transcriptions.ts | 284 ++++ package-lock.json | 1414 +++++++++++++++++ package.json | 1 + prisma/schema.prisma | 1 + public/images/cover.png | Bin 0 -> 515922 bytes yarn.lock | 936 ++++++++++- 21 files changed, 5482 insertions(+), 48 deletions(-) create mode 100644 app/transcriptions/[slug]/page.tsx create mode 100644 app/transcriptions/[slug]/play-audio-button.tsx create mode 100644 app/transcriptions/place/[place]/page.tsx create mode 100644 app/transcriptions/transcriptions-browser.tsx create mode 100644 app/transcriptions/transcriptions-nav.tsx create mode 100644 app/transcriptions/type/[type]/page.tsx create mode 100644 app/transcriptions/type/[type]/type-browser.tsx create mode 100644 app/transcriptions/year/[year]/page.tsx create mode 100644 app/transcriptions/year/[year]/year-browser.tsx create mode 100644 components/audio-player/audio-context.tsx create mode 100644 components/audio-player/floating-player.tsx create mode 100644 components/audio-player/index.ts create mode 100644 lib/transcriptions.ts create mode 100644 public/images/cover.png diff --git a/app/layout.tsx b/app/layout.tsx index 6e71c131..bf1e5800 100644 --- a/app/layout.tsx +++ b/app/layout.tsx @@ -7,6 +7,7 @@ import { TooltipProvider } from "@/components/ui/tooltip"; import { SiteFooter } from "@/components/site-footer"; import { TextSelectionToolbar } from "@/components/text-selection-toolbar"; import { TextHighlighter } from "@/components/text-highlighter"; +import { AudioPlayerProvider, FloatingPlayer } from "@/components/audio-player"; import "./globals.css"; const geistSans = Geist({ @@ -44,14 +45,17 @@ export default function RootLayout({ enableSystem disableTransitionOnChange > - - {children} - - - - - - + + + {children} + + + + + + + + diff --git a/app/page.tsx b/app/page.tsx index ce6f1e74..37e2c43d 100644 --- a/app/page.tsx +++ b/app/page.tsx @@ -44,15 +44,13 @@ export default function Home() { Structured verse-by-verse editions - Recommended

- Bhagavad-gītā, Śrīmad-Bhāgavatam, and Caitanya-caritāmṛta with - Devanagari, Bengali, transliteration, synonyms, translations - & purports. Extraction is still in progress but offers a - refined, searchable reading experience. + Bhagavad-gītā, Śrīmad-Bhāgavatam, Caitanya-caritāmṛta and + other books with Devanagari, Bengali, transliteration, + synonyms, translations & purports.

@@ -69,7 +67,7 @@ export default function Home() {
Essays - Standalone essays by Śrīla Prabhupāda + Essays by Śrīla Prabhupāda
@@ -96,18 +94,15 @@ export default function Home() { Magazines - - Back to Godhead & other periodicals - + Back to Godhead magazines

The original Back to Godhead magazine founded by Śrīla - Prabhupāda in 1944 — 29 issues across 3 volumes with 89 - articles on Kṛṣṇa consciousness, Vedic philosophy, and - spiritual life. + Prabhupāda with articles on Kṛṣṇa consciousness, Vedic + philosophy, and spiritual life.

@@ -155,7 +150,6 @@ export default function Home() { Lectures & conversations - Coming Soon @@ -274,8 +268,7 @@ export default function Home() {

Miscellaneous documents, analyses, and writings by Śrīla Prabhupāda that don't fit neatly into the books or essays - collections — including structured analyses with verse - references. + collections.

diff --git a/app/transcriptions/[slug]/page.tsx b/app/transcriptions/[slug]/page.tsx new file mode 100644 index 00000000..755c5626 --- /dev/null +++ b/app/transcriptions/[slug]/page.tsx @@ -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 { + 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 ( +
+ + + ← {backLabel} + +

{t.name}

+
+ + {t.type === "lecture" ? "Lecture" : "Conversation"} + + {t.date && {t.date}} + {t.place && ( + + + {t.place} + + )} + {t.reference && ( + {t.reference} + )} + {t.durationMinutes && t.durationMinutes > 0 && ( + + + {t.durationMinutes} min + + )} +
+
+ +
+ {/* Top nav */} + + + {/* Audio + Participants row */} +
+ {/* Participants */} + {t.participants.length > 0 && ( + <> +
+
+ + Participants: +
+
+ {t.participants.map((p, i) => ( + + {p} + + ))} +
+
+ + )} + + {/* Play Audio button */} + {t.audioUrl && ( +
+ +
+ )} +
+ + {/* Transcription content */} + {t.content.length === 0 ? ( + + +

No transcription available

+

+ This recording has not been transcribed into text. +

+
+
+ ) : ( + + +
+ {t.content.map((block, i) => ( + + ))} +
+
+
+ )} + + {/* Bottom nav */} + +
+
+ ); +} + +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 ( +
+ {block.speaker && ( + {block.speaker}: + )} + +
+ ); +} + +function TranscriptionNav({ + prev, + next, +}: { + prev: { slug: string; name: string } | null; + next: { slug: string; name: string } | null; +}) { + return ( +
+ {prev ? ( + + + + {prev.name} + + Previous + + ) : ( +
+ )} + + {next ? ( + + + {next.name} + + Next + + + ) : ( +
+ )} +
+ ); +} diff --git a/app/transcriptions/[slug]/play-audio-button.tsx b/app/transcriptions/[slug]/play-audio-button.tsx new file mode 100644 index 00000000..c4914fca --- /dev/null +++ b/app/transcriptions/[slug]/play-audio-button.tsx @@ -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 ( + + ); + } + + return ( + + ); +} diff --git a/app/transcriptions/page.tsx b/app/transcriptions/page.tsx index fd285e71..6242639a 100644 --- a/app/transcriptions/page.tsx +++ b/app/transcriptions/page.tsx @@ -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 (
@@ -27,31 +49,21 @@ export default function TranscriptionsPage() { Transcriptions

- Lectures & conversations of Śrīla Prabhupāda + {totalCount.toLocaleString()} lectures & conversations of Śrīla + Prabhupāda (1966–1977)

-
-
-
- -
+
+ -

- Coming Soon -

- -

- We are working on indexing thousands of transcriptions of Śrīla - Prabhupāda's lectures, morning walks, room conversations, and - dialogues into our database. This section will be available shortly. -

- -
- - Under active development -
-
+
); diff --git a/app/transcriptions/place/[place]/page.tsx b/app/transcriptions/place/[place]/page.tsx new file mode 100644 index 00000000..3e628be7 --- /dev/null +++ b/app/transcriptions/place/[place]/page.tsx @@ -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 { + 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 ( +
+ + + ← Transcriptions by Place + +

+ Transcriptions in {place} +

+

+ {transcriptions.length} transcriptions +

+
+ +
+ + + {transcriptions.map((t) => ( + +
+
+

+ {t.name} +

+ + {t.type === "lecture" ? "Lecture" : "Conversation"} + +
+
+ {t.date && {t.date}} + {t.reference && ( + {t.reference} + )} + {t.durationMinutes && t.durationMinutes > 0 && ( + + + {t.durationMinutes} min + + )} +
+
+ + ))} +
+
+
+
+ ); +} diff --git a/app/transcriptions/transcriptions-browser.tsx b/app/transcriptions/transcriptions-browser.tsx new file mode 100644 index 00000000..dcf03fd7 --- /dev/null +++ b/app/transcriptions/transcriptions-browser.tsx @@ -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(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 ( +
+ + + {open && ( +
+ {options.map((opt) => { + const Icon = opt.icon; + const isActive = sort === opt.key; + return ( + + ); + })} +
+ )} +
+ ); +} + +// --------------------------------------------------------------------------- +// 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 ( +
+ + + + Page {page} of {totalPages} + + + +
+ ); +} + +// --------------------------------------------------------------------------- +// 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("asc"); + const [sortPlace, setSortPlace] = useState("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 ( +
+ + + + +

Lectures

+

+ {lectureCount.toLocaleString()} transcriptions +

+

+ Bhagavad-gītā, Śrīmad-Bhāgavatam, Caitanya-caritāmṛta & + more +

+
+
+ + + + + +

Conversations

+

+ {conversationCount.toLocaleString()} transcriptions +

+

+ Morning walks, room conversations, interviews & more +

+
+
+ +
+ ); + } + + // --- Year / Place views --- + + return ( +
+ {/* Search + Sort */} +
+
+ + handleQueryChange(e.target.value)} + className="pl-9" + /> +
+ +
+ + {/* Result count */} + {query.trim() && ( +

+ {resultCount === 0 + ? "No results found" + : `${resultCount} of ${totalCount} ${currentView === "year" ? "years" : "places"}`} +

+ )} + + {/* Year view */} + {currentView === "year" && ( +
+ {(filteredYears.slice(startIdx, endIdx) as YearGroup[]).map((yg) => ( + + + +

{yg.year}

+

+ {yg.count}{" "} + {yg.count === 1 ? "transcription" : "transcriptions"} +

+
+
+ + ))} +
+ )} + + {/* Place view */} + {currentView === "place" && ( +
+ {(filteredPlaces.slice(startIdx, endIdx) as PlaceGroup[]).map( + (pg) => ( + + + +

{pg.name}

+

+ {pg.count}{" "} + {pg.count === 1 ? "transcription" : "transcriptions"} +

+
+
+ + ), + )} +
+ )} + + {/* Pagination */} + +
+ ); +} diff --git a/app/transcriptions/transcriptions-nav.tsx b/app/transcriptions/transcriptions-nav.tsx new file mode 100644 index 00000000..2e238b22 --- /dev/null +++ b/app/transcriptions/transcriptions-nav.tsx @@ -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 ( +
+ {views.map((v) => { + const Icon = v.icon; + const isActive = currentView === v.key; + return ( + + + {v.label} + + ); + })} +
+ ); +} diff --git a/app/transcriptions/type/[type]/page.tsx b/app/transcriptions/type/[type]/page.tsx new file mode 100644 index 00000000..4f2b2427 --- /dev/null +++ b/app/transcriptions/type/[type]/page.tsx @@ -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 = { + lecture: "Lectures", + conversation: "Conversations", +}; + +const TYPE_DESCRIPTIONS: Record = { + 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 { + 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 ( +
+ +
+ ); +} diff --git a/app/transcriptions/type/[type]/type-browser.tsx b/app/transcriptions/type/[type]/type-browser.tsx new file mode 100644 index 00000000..6b9e0ac7 --- /dev/null +++ b/app/transcriptions/type/[type]/type-browser.tsx @@ -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(); + + 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(); + + 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 ( + + ); +} + +// --------------------------------------------------------------------------- +// Mobile drawer +// --------------------------------------------------------------------------- + +function YearMobileNav({ + groups, + activeYear, + onSelect, + title, +}: { + groups: YearGroup[]; + activeYear: number; + onSelect: (year: number) => void; + title: string; +}) { + const [open, setOpen] = useState(false); + + return ( + + + + Years + + + + {title} + + + + + ); +} + +// --------------------------------------------------------------------------- +// 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 ( +
+
+ + + {prev ? ( + + ) : ( +
+ )} +
+ + {next ? ( + + ) : ( +
+ )} +
+ ); +} + +// --------------------------------------------------------------------------- +// Month section — collapsible group of transcriptions for a single month +// --------------------------------------------------------------------------- + +function MonthSection({ + group, + defaultOpen, +}: { + group: MonthGroup; + defaultOpen: boolean; +}) { + const [open, setOpen] = useState(defaultOpen); + + return ( +
+ + + {open && ( +
    + {group.transcriptions.map((t) => ( +
  • + +
    +
    +

    + {t.name} +

    + + {t.type === "lecture" ? "Lecture" : "Conversation"} + +
    +
    + {t.place && ( + + + {t.place} + + )} + {t.reference && ( + {t.reference} + )} + {t.durationMinutes && t.durationMinutes > 0 && ( + + + {t.durationMinutes} min + + )} +
    +
    + +
  • + ))} +
+ )} +
+ ); +} + +// --------------------------------------------------------------------------- +// 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( + 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(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 */} +
+
+
+ + ← Transcriptions by Type + +

+ {typeLabel}: + {activeYearLabel} +

+

+ {activeGroup?.count ?? 0}{" "} + {(activeGroup?.count ?? 0) === 1 + ? "transcription" + : "transcriptions"}{" "} + in {activeYearLabel} · {totalCount} total +

+
+
+ +
+
+
+ + {/* Sidebar + Main */} +
+ {/* Left sidebar — years */} + + + {/* Main content */} +
+ {/* Top navigation */} + + + {/* Content — transcriptions grouped by month */} + {monthGroups.length === 0 ? ( +
+

+ No transcriptions for this year +

+
+ ) : ( + + + {monthGroups.map((mg, i) => ( + + ))} + + + )} + + {/* Bottom navigation */} + +
+
+ + ); +} diff --git a/app/transcriptions/year/[year]/page.tsx b/app/transcriptions/year/[year]/page.tsx new file mode 100644 index 00000000..1938ff44 --- /dev/null +++ b/app/transcriptions/year/[year]/page.tsx @@ -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 { + 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 ( +
+ +
+ ); +} diff --git a/app/transcriptions/year/[year]/year-browser.tsx b/app/transcriptions/year/[year]/year-browser.tsx new file mode 100644 index 00000000..c1137649 --- /dev/null +++ b/app/transcriptions/year/[year]/year-browser.tsx @@ -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 ( +
+ + + {open && ( +
    + {items.map((t) => ( +
  • + +
    +
    +

    + {t.name} +

    + + {t.type === "lecture" ? "Lecture" : "Conversation"} + +
    +
    + {t.place && ( + + + {t.place} + + )} + {t.reference && ( + {t.reference} + )} + {t.durationMinutes && t.durationMinutes > 0 && ( + + + {t.durationMinutes} min + + )} +
    +
    + {t.audioUrl && ( + + )} + +
  • + ))} +
+ )} +
+ ); +} + +export function TranscriptionYearBrowser({ + transcriptions, + year, + totalCount, +}: { + transcriptions: TranscriptionItem[]; + year: number; + totalCount: number; +}) { + const monthGroups = useMemo(() => { + const map = new Map(); + 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 ( + <> + + + ← Transcriptions + +

+ Transcriptions from {year} +

+

+ {totalCount} transcriptions · {lectureCount} lectures ·{" "} + {conversationCount} conversations +

+
+ +
+ {monthGroups.length === 0 ? ( +
+

+ No transcriptions for this year +

+
+ ) : ( + + + {monthGroups.map((mg, i) => ( + + ))} + + + )} +
+ + ); +} diff --git a/components/audio-player/audio-context.tsx b/components/audio-player/audio-context.tsx new file mode 100644 index 00000000..911c0204 --- /dev/null +++ b/components/audio-player/audio-context.tsx @@ -0,0 +1,462 @@ +"use client"; + +import { + createContext, + useContext, + useRef, + useState, + useCallback, + useEffect, + type ReactNode, +} from "react"; + +// --------------------------------------------------------------------------- +// Types +// --------------------------------------------------------------------------- + +export type AudioTrack = { + url: string; + title: string; + subtitle?: string; + coverImage?: string; + sourceUrl?: string; + type?: "lecture" | "conversation" | "kirtan" | "music" | string; +}; + +type AudioPlayerState = { + currentTrack: AudioTrack | null; + isPlaying: boolean; + currentTime: number; + duration: number; + volume: number; + isMuted: boolean; + isLoading: boolean; + playlist: AudioTrack[]; + currentIndex: number; +}; + +type AudioPlayerActions = { + play: (track: AudioTrack) => void; + playPlaylist: (tracks: AudioTrack[], startIndex?: number) => void; + pause: () => void; + resume: () => void; + togglePlayPause: () => void; + stop: () => void; + seekTo: (time: number) => void; + setVolume: (vol: number) => void; + toggleMute: () => void; + next: () => void; + previous: () => void; +}; + +type AudioPlayerContextValue = AudioPlayerState & AudioPlayerActions; + +// --------------------------------------------------------------------------- +// Context +// --------------------------------------------------------------------------- + +const AudioPlayerContext = createContext(null); + +// --------------------------------------------------------------------------- +// localStorage helpers +// --------------------------------------------------------------------------- + +const VOLUME_KEY = "audio-player-volume"; +const MUTED_KEY = "audio-player-muted"; + +function readStoredVolume(): number { + if (typeof window === "undefined") return 0.75; + try { + const raw = localStorage.getItem(VOLUME_KEY); + if (raw !== null) { + const v = parseFloat(raw); + if (isFinite(v) && v >= 0 && v <= 1) return v; + } + } catch { + // localStorage unavailable + } + return 0.75; +} + +function readStoredMuted(): boolean { + if (typeof window === "undefined") return false; + try { + return localStorage.getItem(MUTED_KEY) === "true"; + } catch { + return false; + } +} + +function persistVolume(vol: number) { + try { + localStorage.setItem(VOLUME_KEY, String(vol)); + } catch { + // ignore + } +} + +function persistMuted(muted: boolean) { + try { + localStorage.setItem(MUTED_KEY, String(muted)); + } catch { + // ignore + } +} + +// --------------------------------------------------------------------------- +// Provider +// --------------------------------------------------------------------------- + +export function AudioPlayerProvider({ children }: { children: ReactNode }) { + const audioRef = useRef(null); + + // --- State --- + const [currentTrack, setCurrentTrack] = useState(null); + const [isPlaying, setIsPlaying] = useState(false); + const [currentTime, setCurrentTime] = useState(0); + const [duration, setDuration] = useState(0); + const [volume, setVolumeState] = useState(readStoredVolume); + const [isMuted, setIsMuted] = useState(readStoredMuted); + const [isLoading, setIsLoading] = useState(false); + const [playlist, setPlaylist] = useState([]); + const [currentIndex, setCurrentIndex] = useState(-1); + + // Keep refs for values accessed in callbacks to avoid stale closures + const playlistRef = useRef(playlist); + const currentIndexRef = useRef(currentIndex); + + // Sync refs in an effect to avoid "cannot access refs during render" + useEffect(() => { + playlistRef.current = playlist; + }, [playlist]); + useEffect(() => { + currentIndexRef.current = currentIndex; + }, [currentIndex]); + + // --- Sync volume/muted to the