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
+12 -8
View File
@@ -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
>
<TooltipProvider>
{children}
<TextSelectionToolbar />
<Suspense fallback={null}>
<TextHighlighter />
</Suspense>
<SiteFooter />
</TooltipProvider>
<AudioPlayerProvider>
<TooltipProvider>
{children}
<TextSelectionToolbar />
<Suspense fallback={null}>
<TextHighlighter />
</Suspense>
<FloatingPlayer />
<SiteFooter />
</TooltipProvider>
</AudioPlayerProvider>
</ThemeProvider>
</body>
</html>
+8 -15
View File
@@ -44,15 +44,13 @@ export default function Home() {
Structured verse-by-verse editions
</CardDescription>
</div>
<Badge variant="secondary">Recommended</Badge>
</div>
</CardHeader>
<CardContent>
<p className="text-sm text-muted-foreground">
Bhagavad-gītā, Śrīmad-Bhāgavatam, and Caitanya-caritāmṛta with
Devanagari, Bengali, transliteration, synonyms, translations
&amp; 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 &amp; purports.
</p>
</CardContent>
</Card>
@@ -69,7 +67,7 @@ export default function Home() {
<div className="flex-1 min-w-0">
<CardTitle className="text-xl font-bold">Essays</CardTitle>
<CardDescription>
Standalone essays by Śrīla Prabhupāda
Essays by Śrīla Prabhupāda
</CardDescription>
</div>
</div>
@@ -96,18 +94,15 @@ export default function Home() {
<CardTitle className="text-xl font-bold">
Magazines
</CardTitle>
<CardDescription>
Back to Godhead &amp; other periodicals
</CardDescription>
<CardDescription>Back to Godhead magazines</CardDescription>
</div>
</div>
</CardHeader>
<CardContent>
<p className="text-sm text-muted-foreground">
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.
</p>
</CardContent>
</Card>
@@ -155,7 +150,6 @@ export default function Home() {
Lectures &amp; conversations
</CardDescription>
</div>
<Badge variant="secondary">Coming Soon</Badge>
</div>
</CardHeader>
<CardContent>
@@ -274,8 +268,7 @@ export default function Home() {
<p className="text-sm text-muted-foreground">
Miscellaneous documents, analyses, and writings by Śrīla
Prabhupāda that don&apos;t fit neatly into the books or essays
collections — including structured analyses with verse
references.
collections.
</p>
</CardContent>
</Card>
+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>
</>
);
}
+462
View File
@@ -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<AudioPlayerContextValue | null>(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<HTMLAudioElement>(null);
// --- State ---
const [currentTrack, setCurrentTrack] = useState<AudioTrack | null>(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<AudioTrack[]>([]);
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 <audio> element ---
useEffect(() => {
const audio = audioRef.current;
if (!audio) return;
audio.volume = isMuted ? 0 : volume;
}, [volume, isMuted]);
// --- Audio event listeners ---
useEffect(() => {
const audio = audioRef.current;
if (!audio) return;
function handleTimeUpdate() {
setCurrentTime(audio!.currentTime);
}
function handleLoadedMetadata() {
setDuration(audio!.duration);
}
function handleDurationChange() {
if (isFinite(audio!.duration)) {
setDuration(audio!.duration);
}
}
function handleCanPlay() {
setIsLoading(false);
}
function handleWaiting() {
setIsLoading(true);
}
function handlePlaying() {
setIsPlaying(true);
setIsLoading(false);
}
function handlePause() {
setIsPlaying(false);
}
function handleEnded() {
setIsPlaying(false);
// Auto-play next track if playlist has more
const idx = currentIndexRef.current;
const pl = playlistRef.current;
if (idx >= 0 && idx < pl.length - 1) {
const nextIdx = idx + 1;
const nextTrack = pl[nextIdx];
setCurrentIndex(nextIdx);
setCurrentTrack(nextTrack);
setCurrentTime(0);
setDuration(0);
setIsLoading(true);
if (audio) {
audio.src = nextTrack.url;
audio.load();
audio.play().catch((err) => {
console.error("[AudioPlayer] Auto-play next failed:", err);
setIsPlaying(false);
setIsLoading(false);
});
}
}
}
function handleError() {
console.error("[AudioPlayer] Audio error:", audio!.error);
setIsPlaying(false);
setIsLoading(false);
}
audio.addEventListener("timeupdate", handleTimeUpdate);
audio.addEventListener("loadedmetadata", handleLoadedMetadata);
audio.addEventListener("durationchange", handleDurationChange);
audio.addEventListener("canplay", handleCanPlay);
audio.addEventListener("waiting", handleWaiting);
audio.addEventListener("playing", handlePlaying);
audio.addEventListener("pause", handlePause);
audio.addEventListener("ended", handleEnded);
audio.addEventListener("error", handleError);
return () => {
audio.removeEventListener("timeupdate", handleTimeUpdate);
audio.removeEventListener("loadedmetadata", handleLoadedMetadata);
audio.removeEventListener("durationchange", handleDurationChange);
audio.removeEventListener("canplay", handleCanPlay);
audio.removeEventListener("waiting", handleWaiting);
audio.removeEventListener("playing", handlePlaying);
audio.removeEventListener("pause", handlePause);
audio.removeEventListener("ended", handleEnded);
audio.removeEventListener("error", handleError);
};
}, []);
// --- Actions ---
const play = useCallback((track: AudioTrack) => {
const audio = audioRef.current;
if (!audio) return;
setCurrentTrack(track);
setPlaylist([track]);
setCurrentIndex(0);
setCurrentTime(0);
setDuration(0);
setIsLoading(true);
audio.src = track.url;
audio.load();
audio.play().catch((err) => {
console.error("[AudioPlayer] Play failed:", err);
setIsPlaying(false);
setIsLoading(false);
});
}, []);
const playPlaylist = useCallback(
(tracks: AudioTrack[], startIndex: number = 0) => {
const audio = audioRef.current;
if (!audio || tracks.length === 0) return;
const idx = Math.max(0, Math.min(startIndex, tracks.length - 1));
const track = tracks[idx];
setPlaylist(tracks);
setCurrentIndex(idx);
setCurrentTrack(track);
setCurrentTime(0);
setDuration(0);
setIsLoading(true);
audio.src = track.url;
audio.load();
audio.play().catch((err) => {
console.error("[AudioPlayer] PlayPlaylist failed:", err);
setIsPlaying(false);
setIsLoading(false);
});
},
[],
);
const pause = useCallback(() => {
audioRef.current?.pause();
}, []);
const resume = useCallback(() => {
const audio = audioRef.current;
if (!audio || !audio.src) return;
audio.play().catch((err) => {
console.error("[AudioPlayer] Resume failed:", err);
setIsPlaying(false);
});
}, []);
const togglePlayPause = useCallback(() => {
const audio = audioRef.current;
if (!audio || !audio.src) return;
if (audio.paused) {
audio.play().catch((err) => {
console.error("[AudioPlayer] Toggle play failed:", err);
setIsPlaying(false);
});
} else {
audio.pause();
}
}, []);
const stop = useCallback(() => {
const audio = audioRef.current;
if (audio) {
audio.pause();
audio.removeAttribute("src");
audio.load();
}
setCurrentTrack(null);
setIsPlaying(false);
setCurrentTime(0);
setDuration(0);
setIsLoading(false);
setPlaylist([]);
setCurrentIndex(-1);
}, []);
const seekTo = useCallback((time: number) => {
const audio = audioRef.current;
if (!audio) return;
const clamped = Math.max(0, Math.min(time, audio.duration || 0));
audio.currentTime = clamped;
setCurrentTime(clamped);
}, []);
const setVolume = useCallback(
(vol: number) => {
const clamped = Math.max(0, Math.min(1, vol));
setVolumeState(clamped);
persistVolume(clamped);
// If setting volume while muted, unmute
if (isMuted && clamped > 0) {
setIsMuted(false);
persistMuted(false);
}
},
[isMuted],
);
const toggleMute = useCallback(() => {
setIsMuted((prev) => {
const next = !prev;
persistMuted(next);
return next;
});
}, []);
const next = useCallback(() => {
const audio = audioRef.current;
const idx = currentIndexRef.current;
const pl = playlistRef.current;
if (!audio || idx < 0 || idx >= pl.length - 1) return;
const nextIdx = idx + 1;
const nextTrack = pl[nextIdx];
setCurrentIndex(nextIdx);
setCurrentTrack(nextTrack);
setCurrentTime(0);
setDuration(0);
setIsLoading(true);
audio.src = nextTrack.url;
audio.load();
audio.play().catch((err) => {
console.error("[AudioPlayer] Next failed:", err);
setIsPlaying(false);
setIsLoading(false);
});
}, []);
const previous = useCallback(() => {
const audio = audioRef.current;
const idx = currentIndexRef.current;
const pl = playlistRef.current;
if (!audio || idx < 0) return;
// If more than 3 seconds in, restart the current track instead
if (audio.currentTime > 3) {
audio.currentTime = 0;
setCurrentTime(0);
return;
}
if (idx <= 0) {
// At start of playlist — just restart
audio.currentTime = 0;
setCurrentTime(0);
return;
}
const prevIdx = idx - 1;
const prevTrack = pl[prevIdx];
setCurrentIndex(prevIdx);
setCurrentTrack(prevTrack);
setCurrentTime(0);
setDuration(0);
setIsLoading(true);
audio.src = prevTrack.url;
audio.load();
audio.play().catch((err) => {
console.error("[AudioPlayer] Previous failed:", err);
setIsPlaying(false);
setIsLoading(false);
});
}, []);
// --- Context value ---
const value: AudioPlayerContextValue = {
currentTrack,
isPlaying,
currentTime,
duration,
volume,
isMuted,
isLoading,
playlist,
currentIndex,
play,
playPlaylist,
pause,
resume,
togglePlayPause,
stop,
seekTo,
setVolume,
toggleMute,
next,
previous,
};
return (
<AudioPlayerContext.Provider value={value}>
{children}
<audio ref={audioRef} preload="metadata" style={{ display: "none" }} />
</AudioPlayerContext.Provider>
);
}
// ---------------------------------------------------------------------------
// Hook
// ---------------------------------------------------------------------------
export function useAudioPlayer(): AudioPlayerContextValue {
const ctx = useContext(AudioPlayerContext);
if (!ctx) {
throw new Error(
"useAudioPlayer must be used within an <AudioPlayerProvider>",
);
}
return ctx;
}
+560
View File
@@ -0,0 +1,560 @@
"use client";
import { useRef, useCallback, useEffect } from "react";
import { useAudioPlayer } from "./audio-context";
import { cn } from "@/lib/utils";
import Link from "next/link";
import Image from "next/image";
import {
Play,
Pause,
SkipBack,
SkipForward,
Volume2,
VolumeX,
Volume1,
X,
ExternalLink,
Loader2,
} from "lucide-react";
// ---------------------------------------------------------------------------
// Constants
// ---------------------------------------------------------------------------
const FALLBACK_COVER = "/images/cover.png";
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
function formatTime(seconds: number): string {
if (!isFinite(seconds) || seconds < 0) return "0:00";
const h = Math.floor(seconds / 3600);
const m = Math.floor((seconds % 3600) / 60);
const s = Math.floor(seconds % 60);
if (h > 0)
return `${h}:${String(m).padStart(2, "0")}:${String(s).padStart(2, "0")}`;
return `${m}:${String(s).padStart(2, "0")}`;
}
function VolumeIcon({
volume,
isMuted,
className,
}: {
volume: number;
isMuted: boolean;
className?: string;
}) {
if (isMuted || volume === 0) return <VolumeX className={className} />;
if (volume < 0.5) return <Volume1 className={className} />;
return <Volume2 className={className} />;
}
// ---------------------------------------------------------------------------
// Progress Bar
// ---------------------------------------------------------------------------
function ProgressBar({
currentTime,
duration,
onSeek,
thin = false,
}: {
currentTime: number;
duration: number;
onSeek: (time: number) => void;
thin?: boolean;
}) {
const barRef = useRef<HTMLDivElement>(null);
const draggingRef = useRef(false);
const progress = duration > 0 ? (currentTime / duration) * 100 : 0;
const calculateSeekTime = useCallback(
(clientX: number) => {
const bar = barRef.current;
if (!bar || duration <= 0) return;
const rect = bar.getBoundingClientRect();
const fraction = Math.max(
0,
Math.min(1, (clientX - rect.left) / rect.width),
);
onSeek(fraction * duration);
},
[duration, onSeek],
);
const handlePointerDown = useCallback(
(e: React.PointerEvent) => {
e.preventDefault();
draggingRef.current = true;
(e.target as HTMLElement).setPointerCapture(e.pointerId);
calculateSeekTime(e.clientX);
},
[calculateSeekTime],
);
const handlePointerMove = useCallback(
(e: React.PointerEvent) => {
if (!draggingRef.current) return;
calculateSeekTime(e.clientX);
},
[calculateSeekTime],
);
const handlePointerUp = useCallback(() => {
draggingRef.current = false;
}, []);
if (thin) {
return (
<div
ref={barRef}
className="w-full h-2 bg-white/15 cursor-pointer shrink-0 relative"
onPointerDown={handlePointerDown}
onPointerMove={handlePointerMove}
onPointerUp={handlePointerUp}
role="slider"
aria-label="Playback progress"
aria-valuemin={0}
aria-valuemax={duration || 0}
aria-valuenow={currentTime}
tabIndex={0}
>
<div
className="h-full bg-primary transition-[width] duration-100 ease-linear"
style={{ width: `${progress}%` }}
/>
</div>
);
}
return (
<div className="flex items-center gap-2 w-full">
<span className="text-[10px] tabular-nums text-muted-foreground min-w-[32px] text-right select-none">
{formatTime(currentTime)}
</span>
<div
ref={barRef}
className="relative flex-1 h-1 bg-muted rounded-full cursor-pointer group/progress"
onPointerDown={handlePointerDown}
onPointerMove={handlePointerMove}
onPointerUp={handlePointerUp}
role="slider"
aria-label="Playback progress"
aria-valuemin={0}
aria-valuemax={duration || 0}
aria-valuenow={currentTime}
tabIndex={0}
>
<div
className="h-full bg-primary rounded-full transition-[width] duration-100 ease-linear"
style={{ width: `${progress}%` }}
/>
<div
className="absolute top-1/2 -translate-y-1/2 -translate-x-1/2 size-2.5 bg-primary rounded-full opacity-0 group-hover/progress:opacity-100 transition-opacity shadow-sm pointer-events-none"
style={{ left: `${progress}%` }}
/>
</div>
<span className="text-[10px] tabular-nums text-muted-foreground min-w-[32px] select-none">
{formatTime(duration)}
</span>
</div>
);
}
// ---------------------------------------------------------------------------
// Transport Button
// ---------------------------------------------------------------------------
function TransportButton({
onClick,
disabled = false,
label,
size = "default",
children,
className,
}: {
onClick: () => void;
disabled?: boolean;
label: string;
size?: "default" | "large";
children: React.ReactNode;
className?: string;
}) {
return (
<button
type="button"
onClick={onClick}
disabled={disabled}
aria-label={label}
className={cn(
"inline-flex items-center justify-center rounded-full transition-colors outline-none",
"focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-card",
"disabled:opacity-30 disabled:pointer-events-none",
size === "large"
? "size-9 bg-foreground text-background hover:bg-foreground/90"
: "size-8 text-foreground hover:bg-muted",
className,
)}
>
{children}
</button>
);
}
// ---------------------------------------------------------------------------
// Floating Player
// ---------------------------------------------------------------------------
export function FloatingPlayer() {
const {
currentTrack,
isPlaying,
currentTime,
duration,
volume,
isMuted,
isLoading,
playlist,
currentIndex,
togglePlayPause,
stop,
seekTo,
setVolume,
toggleMute,
next,
previous,
} = useAudioPlayer();
const playerRef = useRef<HTMLDivElement>(null);
// Add bottom padding to body when player is visible
useEffect(() => {
if (!currentTrack) return;
document.body.style.paddingBottom = "96px";
return () => {
document.body.style.paddingBottom = "";
};
}, [currentTrack]);
// Keyboard shortcuts when player is focused
const handleKeyDown = useCallback(
(e: React.KeyboardEvent) => {
switch (e.key) {
case " ":
e.preventDefault();
togglePlayPause();
break;
case "ArrowLeft":
e.preventDefault();
seekTo(Math.max(0, currentTime - 10));
break;
case "ArrowRight":
e.preventDefault();
seekTo(Math.min(duration, currentTime + 10));
break;
case "ArrowUp":
e.preventDefault();
setVolume(Math.min(1, volume + 0.05));
break;
case "ArrowDown":
e.preventDefault();
setVolume(Math.max(0, volume - 0.05));
break;
case "m":
case "M":
e.preventDefault();
toggleMute();
break;
case "Escape":
e.preventDefault();
stop();
break;
}
},
[
togglePlayPause,
seekTo,
setVolume,
toggleMute,
stop,
currentTime,
duration,
volume,
],
);
if (!currentTrack) return null;
const hasNext = currentIndex >= 0 && currentIndex < playlist.length - 1;
const hasPrev = currentIndex > 0;
const coverSrc = currentTrack.coverImage || FALLBACK_COVER;
return (
<div
ref={playerRef}
data-audio-player
role="region"
aria-label="Audio player"
tabIndex={0}
onKeyDown={handleKeyDown}
className={cn(
// Centered floating bar
"fixed z-40 bottom-5 left-1/2 -translate-x-1/2",
// Narrower width
"w-[calc(100%-2rem)] max-w-[680px]",
// Forced dark surface for contrast regardless of theme
"bg-[#1c1c1e] text-white",
"border border-white/10",
"rounded-2xl",
"shadow-2xl shadow-black/25",
// Animation
"animate-in fade-in-0 slide-in-from-bottom-4 duration-300",
// Clip seeker to rounded top corners
"overflow-hidden",
)}
>
{/* Thin progress bar — absolutely positioned at top edge */}
<ProgressBar
currentTime={currentTime}
duration={duration}
onSeek={seekTo}
thin
/>
{/* Time row — desktop (hidden on mobile, mobile has its own) */}
<div className="hidden md:flex items-center justify-between px-5 pt-1.5">
<span className="text-[10px] tabular-nums text-white/40 select-none">
{formatTime(currentTime)}
</span>
<span className="text-[10px] tabular-nums text-white/40 select-none">
{formatTime(duration)}
</span>
</div>
{/* ── Desktop layout ── */}
<div className="hidden md:flex items-center h-[64px] px-5 gap-5">
{/* Left: Cover + Track info */}
<div className="flex items-center gap-3 min-w-0 flex-1">
{/* Cover image */}
<div className="size-12 rounded-lg overflow-hidden bg-muted shrink-0 relative">
<Image
src={coverSrc}
alt=""
fill
className="object-cover"
unoptimized
/>
</div>
<div className="min-w-0">
<p className="text-sm font-medium text-white truncate leading-tight">
{currentTrack.title}
</p>
{currentTrack.subtitle && (
<p className="text-[11px] text-white/50 truncate leading-tight mt-0.5">
{currentTrack.subtitle}
</p>
)}
</div>
</div>
{/* Center: Transport controls */}
<div className="flex items-center gap-1.5">
<TransportButton
onClick={previous}
disabled={!hasPrev && currentTime <= 3}
label="Previous track"
className="text-white/60 hover:text-white hover:bg-white/10"
>
<SkipBack className="size-4" />
</TransportButton>
<TransportButton
onClick={togglePlayPause}
label={isPlaying ? "Pause" : "Play"}
size="large"
className="bg-white text-black hover:bg-white/90"
>
{isLoading ? (
<Loader2 className="size-4 animate-spin" />
) : isPlaying ? (
<Pause className="size-4" />
) : (
<Play className="size-4 ml-0.5" />
)}
</TransportButton>
<TransportButton
onClick={next}
disabled={!hasNext}
label="Next track"
className="text-white/60 hover:text-white hover:bg-white/10"
>
<SkipForward className="size-4" />
</TransportButton>
</div>
{/* Right: Volume + Source link + Close */}
<div className="flex items-center gap-1.5 shrink-0">
{/* Volume control */}
<div className="flex items-center gap-1">
<button
type="button"
onClick={toggleMute}
aria-label={isMuted ? "Unmute" : "Mute"}
className="size-7 inline-flex items-center justify-center rounded-md text-white/50 hover:text-white hover:bg-white/10 transition-colors outline-none focus-visible:ring-2 focus-visible:ring-ring"
>
<VolumeIcon
volume={volume}
isMuted={isMuted}
className="size-4"
/>
</button>
<input
type="range"
min={0}
max={100}
step={1}
value={isMuted ? 0 : Math.round(volume * 100)}
onChange={(e) => setVolume(Number(e.target.value) / 100)}
aria-label="Volume"
className={cn(
"w-16 h-1 appearance-none bg-white/15 rounded-full cursor-pointer",
"[&::-webkit-slider-thumb]:appearance-none",
"[&::-webkit-slider-thumb]:size-3",
"[&::-webkit-slider-thumb]:rounded-full",
"[&::-webkit-slider-thumb]:bg-white",
"[&::-webkit-slider-thumb]:cursor-pointer",
"[&::-moz-range-thumb]:size-3",
"[&::-moz-range-thumb]:rounded-full",
"[&::-moz-range-thumb]:bg-white",
"[&::-moz-range-thumb]:border-0",
"[&::-moz-range-thumb]:cursor-pointer",
)}
/>
</div>
{/* Source link */}
{currentTrack.sourceUrl && (
<Link
href={currentTrack.sourceUrl}
aria-label="Go to source page"
className="size-7 inline-flex items-center justify-center rounded-md text-white/50 hover:text-white hover:bg-white/10 transition-colors"
>
<ExternalLink className="size-3.5" />
</Link>
)}
{/* Close */}
<button
type="button"
onClick={stop}
aria-label="Close player"
className="size-7 inline-flex items-center justify-center rounded-md text-white/50 hover:text-white hover:bg-white/10 transition-colors outline-none focus-visible:ring-2 focus-visible:ring-ring"
>
<X className="size-4" />
</button>
</div>
</div>
{/* ── Mobile layout ── */}
<div className="flex md:hidden flex-col px-4 py-3 gap-2.5">
{/* Time row — right below seeker */}
<div className="flex items-center justify-between px-0.5">
<span className="text-[10px] tabular-nums text-white/40 select-none">
{formatTime(currentTime)}
</span>
<span className="text-[10px] tabular-nums text-white/40 select-none">
{formatTime(duration)}
</span>
</div>
{/* Row 1: Cover + Title + Source + Close */}
<div className="flex items-center gap-3">
{/* Cover image */}
<div className="size-10 rounded-lg overflow-hidden bg-muted shrink-0 relative">
<Image
src={coverSrc}
alt=""
fill
className="object-cover"
unoptimized
/>
</div>
{/* Track info */}
<div className="flex-1 min-w-0">
<p className="text-sm font-medium text-white truncate leading-tight">
{currentTrack.title}
</p>
{currentTrack.subtitle && (
<p className="text-[11px] text-white/50 truncate leading-tight mt-0.5">
{currentTrack.subtitle}
</p>
)}
</div>
{/* Source link */}
{currentTrack.sourceUrl && (
<Link
href={currentTrack.sourceUrl}
aria-label="Go to source page"
className="size-8 inline-flex items-center justify-center rounded-full text-white/50 hover:text-white hover:bg-white/10 transition-colors shrink-0"
>
<ExternalLink className="size-4" />
</Link>
)}
{/* Close */}
<button
type="button"
onClick={stop}
aria-label="Close player"
className="size-8 inline-flex items-center justify-center rounded-full text-white/50 hover:text-white hover:bg-white/10 transition-colors outline-none focus-visible:ring-2 focus-visible:ring-ring shrink-0"
>
<X className="size-4" />
</button>
</div>
{/* Row 2: Prev + Play/Pause + Next — centered */}
<div className="flex items-center justify-center gap-4 py-1">
<TransportButton
onClick={previous}
disabled={!hasPrev && currentTime <= 3}
label="Previous track"
className="text-white/60 hover:text-white hover:bg-white/10 !size-10"
>
<SkipBack className="size-5" />
</TransportButton>
<TransportButton
onClick={togglePlayPause}
label={isPlaying ? "Pause" : "Play"}
size="large"
className="bg-white text-black hover:bg-white/90 !size-12"
>
{isLoading ? (
<Loader2 className="size-5 animate-spin" />
) : isPlaying ? (
<Pause className="size-5" />
) : (
<Play className="size-5 ml-0.5" />
)}
</TransportButton>
<TransportButton
onClick={next}
disabled={!hasNext}
label="Next track"
className="text-white/60 hover:text-white hover:bg-white/10 !size-10"
>
<SkipForward className="size-5" />
</TransportButton>
</div>
</div>
</div>
);
}
+3
View File
@@ -0,0 +1,3 @@
export { AudioPlayerProvider, useAudioPlayer } from "./audio-context";
export type { AudioTrack } from "./audio-context";
export { FloatingPlayer } from "./floating-player";
+284
View File
@@ -0,0 +1,284 @@
/**
* Data access layer for Transcriptions.
*
* Uses Prisma to query the `transcriptions` collection in MongoDB.
* Replaces the previous local-JSON-file approach so that data is always
* served from the database (seeded via seed-transcriptions.ts).
*
* Some documents store their content as a JSON string in `contentJson`
* (a BSON wire-protocol workaround for very large nested arrays).
* The `getTranscription()` helper transparently parses that fallback.
*/
import { prisma } from "@/lib/db";
// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------
export type TranscriptionSummary = {
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;
};
export type ContentBlock = {
text: string;
format: string;
alignment: string;
speaker: string | null;
};
export type TranscriptionFull = TranscriptionSummary & {
content: ContentBlock[];
};
export type YearGroup = {
year: number;
count: number;
};
export type PlaceGroup = {
name: string;
count: number;
};
// ---------------------------------------------------------------------------
// Prisma select objects (keep queries lightweight)
// ---------------------------------------------------------------------------
/** Fields needed for summary views (no content). */
const SUMMARY_SELECT = {
slug: true,
name: true,
type: true,
date: true,
year: true,
month: true,
day: true,
place: true,
context: true,
reference: true,
durationMinutes: true,
participants: true,
audioUrl: true,
} as const;
// ---------------------------------------------------------------------------
// Sanitize
// ---------------------------------------------------------------------------
function sanitizeHtml(html: string): string {
let clean = html.replace(/<script[\s>][\s\S]*?<\/script>/gi, "");
clean = clean.replace(/<style[\s>][\s\S]*?<\/style>/gi, "");
clean = clean.replace(/<iframe[\s>][\s\S]*?<\/iframe>/gi, "");
clean = clean.replace(
/<\/?(?:script|style|iframe|object|embed|form|input|textarea|select|button)[^>]*>/gi,
"",
);
clean = clean.replace(/\s+on\w+\s*=\s*(?:"[^"]*"|'[^']*'|[^\s>]+)/gi, "");
return clean;
}
// ---------------------------------------------------------------------------
// Index / overview queries
// ---------------------------------------------------------------------------
export async function getTranscriptionYears(): Promise<YearGroup[]> {
const transcriptions = await prisma.transcription.findMany({
where: { year: { not: null }, isPublished: true },
select: { year: true },
});
const counts: Record<number, number> = {};
for (const t of transcriptions) {
if (t.year) {
counts[t.year] = (counts[t.year] || 0) + 1;
}
}
return Object.entries(counts)
.map(([year, count]) => ({ year: Number(year), count }))
.filter((g) => g.year >= 1960 && g.year <= 1980)
.sort((a, b) => a.year - b.year);
}
export async function getTranscriptionPlaces(): Promise<PlaceGroup[]> {
const transcriptions = await prisma.transcription.findMany({
where: { place: { not: null }, isPublished: true },
select: { place: true },
});
const counts: Record<string, number> = {};
for (const t of transcriptions) {
if (t.place) {
counts[t.place] = (counts[t.place] || 0) + 1;
}
}
return Object.entries(counts)
.map(([name, count]) => ({ name, count }))
.sort((a, b) => b.count - a.count);
}
export async function getTotalTranscriptionCount(): Promise<number> {
return prisma.transcription.count({ where: { isPublished: true } });
}
/**
* Return a lightweight index object with type distribution counts.
* Used by the index page to show lecture/conversation totals.
*/
export async function getTranscriptionIndex(): Promise<{
totalTranscriptions: number;
typeDistribution: Record<string, number>;
}> {
const [total, lectures, conversations] = await Promise.all([
prisma.transcription.count({ where: { isPublished: true } }),
prisma.transcription.count({
where: { isPublished: true, type: "lecture" },
}),
prisma.transcription.count({
where: { isPublished: true, type: "conversation" },
}),
]);
return {
totalTranscriptions: total,
typeDistribution: {
lecture: lectures,
conversation: conversations,
},
};
}
// ---------------------------------------------------------------------------
// List queries (return summaries without content)
// ---------------------------------------------------------------------------
export async function getTranscriptionsByYear(
year: number,
): Promise<TranscriptionSummary[]> {
const transcriptions = await prisma.transcription.findMany({
where: { year, isPublished: true },
orderBy: [{ month: "asc" }, { day: "asc" }],
select: SUMMARY_SELECT,
});
return transcriptions;
}
export async function getTranscriptionsByPlace(
place: string,
): Promise<TranscriptionSummary[]> {
const transcriptions = await prisma.transcription.findMany({
where: { place, isPublished: true },
orderBy: [{ year: "asc" }, { month: "asc" }, { day: "asc" }],
select: SUMMARY_SELECT,
});
return transcriptions;
}
export async function getTranscriptionsByType(
type: string,
): Promise<TranscriptionSummary[]> {
const transcriptions = await prisma.transcription.findMany({
where: { type, isPublished: true },
orderBy: [{ year: "asc" }, { month: "asc" }, { day: "asc" }],
select: SUMMARY_SELECT,
});
return transcriptions;
}
// ---------------------------------------------------------------------------
// Single transcription
// ---------------------------------------------------------------------------
export async function getTranscription(
slug: string,
): Promise<TranscriptionFull | null> {
const t = await prisma.transcription.findUnique({ where: { slug } });
if (!t || !t.isPublished) return null;
// Content may be stored as embedded array or as a JSON string fallback
let content: ContentBlock[] = [];
if (t.content && t.content.length > 0) {
content = t.content.map((block) => ({
text: sanitizeHtml(block.text),
format: block.format,
alignment: block.alignment,
speaker: block.speaker ?? null,
}));
} else {
// Check for contentJson fallback (BSON workaround for large documents)
const raw = t as unknown as Record<string, unknown>;
const contentJson = raw.contentJson;
if (typeof contentJson === "string" && contentJson.length > 2) {
try {
const parsed = JSON.parse(contentJson) as ContentBlock[];
content = parsed.map((block) => ({
text: sanitizeHtml(block.text),
format: block.format,
alignment: block.alignment,
speaker: block.speaker ?? null,
}));
} catch {
// Ignore parse errors — return empty content
}
}
}
return {
slug: t.slug,
name: t.name,
type: t.type,
date: t.date,
year: t.year,
month: t.month,
day: t.day,
place: t.place,
context: t.context,
reference: t.reference,
durationMinutes: t.durationMinutes,
participants: t.participants,
audioUrl: t.audioUrl,
content,
};
}
// ---------------------------------------------------------------------------
// Navigation helpers
// ---------------------------------------------------------------------------
export async function getAdjacentTranscriptions(
slug: string,
year: number | null,
): Promise<{
prev: TranscriptionSummary | null;
next: TranscriptionSummary | null;
}> {
if (!year) return { prev: null, next: null };
const yearTranscriptions = await prisma.transcription.findMany({
where: { year, isPublished: true },
orderBy: [{ month: "asc" }, { day: "asc" }, { slug: "asc" }],
select: SUMMARY_SELECT,
});
const idx = yearTranscriptions.findIndex((t) => t.slug === slug);
return {
prev: idx > 0 ? yearTranscriptions[idx - 1] : null,
next:
idx < yearTranscriptions.length - 1 ? yearTranscriptions[idx + 1] : null,
};
}
+1414
View File
File diff suppressed because it is too large Load Diff
+1
View File
@@ -9,6 +9,7 @@
"lint": "eslint"
},
"dependencies": {
"@aws-sdk/client-s3": "^3.1045.0",
"@base-ui/react": "^1.4.0",
"@prisma/client": "^6.19.3",
"class-variance-authority": "^0.7.1",
+1
View File
@@ -382,6 +382,7 @@ model Transcription {
language String @default("en")
source String?
durationMinutes Int?
audioUrl String?
isPublished Boolean @default(false)
notes String?
Binary file not shown.

After

Width:  |  Height:  |  Size: 504 KiB

+935 -1
View File
@@ -7,6 +7,557 @@
resolved "https://registry.npmjs.org/@alloc/quick-lru/-/quick-lru-5.2.0.tgz"
integrity sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw==
"@aws-crypto/crc32@5.2.0":
version "5.2.0"
resolved "https://registry.npmjs.org/@aws-crypto/crc32/-/crc32-5.2.0.tgz"
integrity sha512-nLbCWqQNgUiwwtFsen1AdzAtvuLRsQS8rYgMuxCrdKf9kOssamGLuPwyTY9wyYblNr9+1XM8v6zoDTPPSIeANg==
dependencies:
"@aws-crypto/util" "^5.2.0"
"@aws-sdk/types" "^3.222.0"
tslib "^2.6.2"
"@aws-crypto/crc32c@5.2.0":
version "5.2.0"
resolved "https://registry.npmjs.org/@aws-crypto/crc32c/-/crc32c-5.2.0.tgz"
integrity sha512-+iWb8qaHLYKrNvGRbiYRHSdKRWhto5XlZUEBwDjYNf+ly5SVYG6zEoYIdxvf5R3zyeP16w4PLBn3rH1xc74Rag==
dependencies:
"@aws-crypto/util" "^5.2.0"
"@aws-sdk/types" "^3.222.0"
tslib "^2.6.2"
"@aws-crypto/sha1-browser@5.2.0":
version "5.2.0"
resolved "https://registry.npmjs.org/@aws-crypto/sha1-browser/-/sha1-browser-5.2.0.tgz"
integrity sha512-OH6lveCFfcDjX4dbAvCFSYUjJZjDr/3XJ3xHtjn3Oj5b9RjojQo8npoLeA/bNwkOkrSQ0wgrHzXk4tDRxGKJeg==
dependencies:
"@aws-crypto/supports-web-crypto" "^5.2.0"
"@aws-crypto/util" "^5.2.0"
"@aws-sdk/types" "^3.222.0"
"@aws-sdk/util-locate-window" "^3.0.0"
"@smithy/util-utf8" "^2.0.0"
tslib "^2.6.2"
"@aws-crypto/sha256-browser@5.2.0":
version "5.2.0"
resolved "https://registry.npmjs.org/@aws-crypto/sha256-browser/-/sha256-browser-5.2.0.tgz"
integrity sha512-AXfN/lGotSQwu6HNcEsIASo7kWXZ5HYWvfOmSNKDsEqC4OashTp8alTmaz+F7TC2L083SFv5RdB+qU3Vs1kZqw==
dependencies:
"@aws-crypto/sha256-js" "^5.2.0"
"@aws-crypto/supports-web-crypto" "^5.2.0"
"@aws-crypto/util" "^5.2.0"
"@aws-sdk/types" "^3.222.0"
"@aws-sdk/util-locate-window" "^3.0.0"
"@smithy/util-utf8" "^2.0.0"
tslib "^2.6.2"
"@aws-crypto/sha256-js@^5.2.0", "@aws-crypto/sha256-js@5.2.0":
version "5.2.0"
resolved "https://registry.npmjs.org/@aws-crypto/sha256-js/-/sha256-js-5.2.0.tgz"
integrity sha512-FFQQyu7edu4ufvIZ+OadFpHHOt+eSTBaYaki44c+akjg7qZg9oOQeLlk77F6tSYqjDAFClrHJk9tMf0HdVyOvA==
dependencies:
"@aws-crypto/util" "^5.2.0"
"@aws-sdk/types" "^3.222.0"
tslib "^2.6.2"
"@aws-crypto/supports-web-crypto@^5.2.0":
version "5.2.0"
resolved "https://registry.npmjs.org/@aws-crypto/supports-web-crypto/-/supports-web-crypto-5.2.0.tgz"
integrity sha512-iAvUotm021kM33eCdNfwIN//F77/IADDSs58i+MDaOqFrVjZo9bAal0NK7HurRuWLLpF1iLX7gbWrjHjeo+YFg==
dependencies:
tslib "^2.6.2"
"@aws-crypto/util@^5.2.0", "@aws-crypto/util@5.2.0":
version "5.2.0"
resolved "https://registry.npmjs.org/@aws-crypto/util/-/util-5.2.0.tgz"
integrity sha512-4RkU9EsI6ZpBve5fseQlGNUWKMa1RLPQ1dnjnQoe07ldfIzcsGb5hC5W0Dm7u423KWzawlrpbjXBrXCEv9zazQ==
dependencies:
"@aws-sdk/types" "^3.222.0"
"@smithy/util-utf8" "^2.0.0"
tslib "^2.6.2"
"@aws-sdk/client-s3@^3.1045.0":
version "3.1045.0"
resolved "https://registry.npmjs.org/@aws-sdk/client-s3/-/client-s3-3.1045.0.tgz"
integrity sha512-fsuO3Y6t+3Ro9Bsg41DKj4Sfy53CGSrhnMldNplWmG8Tx0UbYk+YDa4RD1hVlJpERw4JBmPkl0+J9qlxMh1pcA==
dependencies:
"@aws-crypto/sha1-browser" "5.2.0"
"@aws-crypto/sha256-browser" "5.2.0"
"@aws-crypto/sha256-js" "5.2.0"
"@aws-sdk/core" "^3.974.8"
"@aws-sdk/credential-provider-node" "^3.972.39"
"@aws-sdk/middleware-bucket-endpoint" "^3.972.10"
"@aws-sdk/middleware-expect-continue" "^3.972.10"
"@aws-sdk/middleware-flexible-checksums" "^3.974.16"
"@aws-sdk/middleware-host-header" "^3.972.10"
"@aws-sdk/middleware-location-constraint" "^3.972.10"
"@aws-sdk/middleware-logger" "^3.972.10"
"@aws-sdk/middleware-recursion-detection" "^3.972.11"
"@aws-sdk/middleware-sdk-s3" "^3.972.37"
"@aws-sdk/middleware-ssec" "^3.972.10"
"@aws-sdk/middleware-user-agent" "^3.972.38"
"@aws-sdk/region-config-resolver" "^3.972.13"
"@aws-sdk/signature-v4-multi-region" "^3.996.25"
"@aws-sdk/types" "^3.973.8"
"@aws-sdk/util-endpoints" "^3.996.8"
"@aws-sdk/util-user-agent-browser" "^3.972.10"
"@aws-sdk/util-user-agent-node" "^3.973.24"
"@smithy/config-resolver" "^4.4.17"
"@smithy/core" "^3.23.17"
"@smithy/eventstream-serde-browser" "^4.2.14"
"@smithy/eventstream-serde-config-resolver" "^4.3.14"
"@smithy/eventstream-serde-node" "^4.2.14"
"@smithy/fetch-http-handler" "^5.3.17"
"@smithy/hash-blob-browser" "^4.2.15"
"@smithy/hash-node" "^4.2.14"
"@smithy/hash-stream-node" "^4.2.14"
"@smithy/invalid-dependency" "^4.2.14"
"@smithy/md5-js" "^4.2.14"
"@smithy/middleware-content-length" "^4.2.14"
"@smithy/middleware-endpoint" "^4.4.32"
"@smithy/middleware-retry" "^4.5.7"
"@smithy/middleware-serde" "^4.2.20"
"@smithy/middleware-stack" "^4.2.14"
"@smithy/node-config-provider" "^4.3.14"
"@smithy/node-http-handler" "^4.6.1"
"@smithy/protocol-http" "^5.3.14"
"@smithy/smithy-client" "^4.12.13"
"@smithy/types" "^4.14.1"
"@smithy/url-parser" "^4.2.14"
"@smithy/util-base64" "^4.3.2"
"@smithy/util-body-length-browser" "^4.2.2"
"@smithy/util-body-length-node" "^4.2.3"
"@smithy/util-defaults-mode-browser" "^4.3.49"
"@smithy/util-defaults-mode-node" "^4.2.54"
"@smithy/util-endpoints" "^3.4.2"
"@smithy/util-middleware" "^4.2.14"
"@smithy/util-retry" "^4.3.6"
"@smithy/util-stream" "^4.5.25"
"@smithy/util-utf8" "^4.2.2"
"@smithy/util-waiter" "^4.3.0"
tslib "^2.6.2"
"@aws-sdk/core@^3.974.8":
version "3.974.8"
resolved "https://registry.npmjs.org/@aws-sdk/core/-/core-3.974.8.tgz"
integrity sha512-njR2qoG6ZuB0kvAS2FyICsFZJ6gmCcf2X/7JcD14sUvGDm26wiZ5BrA6LOiUxKFEF+IVe7kdroxyE00YlkiYsw==
dependencies:
"@aws-sdk/types" "^3.973.8"
"@aws-sdk/xml-builder" "^3.972.22"
"@smithy/core" "^3.23.17"
"@smithy/node-config-provider" "^4.3.14"
"@smithy/property-provider" "^4.2.14"
"@smithy/protocol-http" "^5.3.14"
"@smithy/signature-v4" "^5.3.14"
"@smithy/smithy-client" "^4.12.13"
"@smithy/types" "^4.14.1"
"@smithy/util-base64" "^4.3.2"
"@smithy/util-middleware" "^4.2.14"
"@smithy/util-retry" "^4.3.6"
"@smithy/util-utf8" "^4.2.2"
tslib "^2.6.2"
"@aws-sdk/crc64-nvme@^3.972.7":
version "3.972.7"
resolved "https://registry.npmjs.org/@aws-sdk/crc64-nvme/-/crc64-nvme-3.972.7.tgz"
integrity sha512-QUagVVBbC8gODCF6e1aV0mE2TXWB9Opz4k8EJFdNrujUVQm5R4AjJa1mpOqzwOuROBzqJU9zawzig7M96L8Ejg==
dependencies:
"@smithy/types" "^4.14.1"
tslib "^2.6.2"
"@aws-sdk/credential-provider-env@^3.972.34":
version "3.972.34"
resolved "https://registry.npmjs.org/@aws-sdk/credential-provider-env/-/credential-provider-env-3.972.34.tgz"
integrity sha512-XT0jtf8Fw9JE6ppsQeoNnZRiG+jqRixMT1v1ZR17G60UvVdsQmTG8nbEyHuEPfMxDXEhfdARaM/XiEhca4lGHQ==
dependencies:
"@aws-sdk/core" "^3.974.8"
"@aws-sdk/types" "^3.973.8"
"@smithy/property-provider" "^4.2.14"
"@smithy/types" "^4.14.1"
tslib "^2.6.2"
"@aws-sdk/credential-provider-http@^3.972.36":
version "3.972.36"
resolved "https://registry.npmjs.org/@aws-sdk/credential-provider-http/-/credential-provider-http-3.972.36.tgz"
integrity sha512-DPoGWfy7J7RKxvbf5kOKIGQkD2ek3dbKgzKIGrnLuvZBz5myU+Im/H6pmc14QcnFbqHMqxvtWSgRDSJW3qXLQg==
dependencies:
"@aws-sdk/core" "^3.974.8"
"@aws-sdk/types" "^3.973.8"
"@smithy/fetch-http-handler" "^5.3.17"
"@smithy/node-http-handler" "^4.6.1"
"@smithy/property-provider" "^4.2.14"
"@smithy/protocol-http" "^5.3.14"
"@smithy/smithy-client" "^4.12.13"
"@smithy/types" "^4.14.1"
"@smithy/util-stream" "^4.5.25"
tslib "^2.6.2"
"@aws-sdk/credential-provider-ini@^3.972.38":
version "3.972.38"
resolved "https://registry.npmjs.org/@aws-sdk/credential-provider-ini/-/credential-provider-ini-3.972.38.tgz"
integrity sha512-oDzUBu2MGJFgoar05sPMCwSrhw44ASyccrHzj66vO69OZqi7I6hZZxXfuPLC8OCzW7C+sU+bI73XHij41yekgQ==
dependencies:
"@aws-sdk/core" "^3.974.8"
"@aws-sdk/credential-provider-env" "^3.972.34"
"@aws-sdk/credential-provider-http" "^3.972.36"
"@aws-sdk/credential-provider-login" "^3.972.38"
"@aws-sdk/credential-provider-process" "^3.972.34"
"@aws-sdk/credential-provider-sso" "^3.972.38"
"@aws-sdk/credential-provider-web-identity" "^3.972.38"
"@aws-sdk/nested-clients" "^3.997.6"
"@aws-sdk/types" "^3.973.8"
"@smithy/credential-provider-imds" "^4.2.14"
"@smithy/property-provider" "^4.2.14"
"@smithy/shared-ini-file-loader" "^4.4.9"
"@smithy/types" "^4.14.1"
tslib "^2.6.2"
"@aws-sdk/credential-provider-login@^3.972.38":
version "3.972.38"
resolved "https://registry.npmjs.org/@aws-sdk/credential-provider-login/-/credential-provider-login-3.972.38.tgz"
integrity sha512-g1NosS8qe4OF++G2UFCM5ovSkgipC7YYor5KCWatG0UoMSO5YFj9C8muePlyVmOBV/WTI16Jo3/s1NUo/o1Bww==
dependencies:
"@aws-sdk/core" "^3.974.8"
"@aws-sdk/nested-clients" "^3.997.6"
"@aws-sdk/types" "^3.973.8"
"@smithy/property-provider" "^4.2.14"
"@smithy/protocol-http" "^5.3.14"
"@smithy/shared-ini-file-loader" "^4.4.9"
"@smithy/types" "^4.14.1"
tslib "^2.6.2"
"@aws-sdk/credential-provider-node@^3.972.39":
version "3.972.39"
resolved "https://registry.npmjs.org/@aws-sdk/credential-provider-node/-/credential-provider-node-3.972.39.tgz"
integrity sha512-HEswDQyxUtadoZ/bJsPPENHg7R0Lzym5LuMksJeHvqhCOpP+rtkDLKI4/ZChH4w3cf5kG8n6bZuI8PzajoiqMg==
dependencies:
"@aws-sdk/credential-provider-env" "^3.972.34"
"@aws-sdk/credential-provider-http" "^3.972.36"
"@aws-sdk/credential-provider-ini" "^3.972.38"
"@aws-sdk/credential-provider-process" "^3.972.34"
"@aws-sdk/credential-provider-sso" "^3.972.38"
"@aws-sdk/credential-provider-web-identity" "^3.972.38"
"@aws-sdk/types" "^3.973.8"
"@smithy/credential-provider-imds" "^4.2.14"
"@smithy/property-provider" "^4.2.14"
"@smithy/shared-ini-file-loader" "^4.4.9"
"@smithy/types" "^4.14.1"
tslib "^2.6.2"
"@aws-sdk/credential-provider-process@^3.972.34":
version "3.972.34"
resolved "https://registry.npmjs.org/@aws-sdk/credential-provider-process/-/credential-provider-process-3.972.34.tgz"
integrity sha512-T3IFs4EVmVi1dVN5RciFnklCANSzvrQd/VuHY9ThHSQmYkTogjcGkoJEr+oNUPQZnso52183088NqysMPji1/Q==
dependencies:
"@aws-sdk/core" "^3.974.8"
"@aws-sdk/types" "^3.973.8"
"@smithy/property-provider" "^4.2.14"
"@smithy/shared-ini-file-loader" "^4.4.9"
"@smithy/types" "^4.14.1"
tslib "^2.6.2"
"@aws-sdk/credential-provider-sso@^3.972.38":
version "3.972.38"
resolved "https://registry.npmjs.org/@aws-sdk/credential-provider-sso/-/credential-provider-sso-3.972.38.tgz"
integrity sha512-5ZxG+t0+3Q3QPh8KEjX6syskhgNf7I0MN7oGioTf6Lm1NTjfP7sIcYGNsthXC2qR8vcD3edNZwCr2ovfSSWuRA==
dependencies:
"@aws-sdk/core" "^3.974.8"
"@aws-sdk/nested-clients" "^3.997.6"
"@aws-sdk/token-providers" "3.1041.0"
"@aws-sdk/types" "^3.973.8"
"@smithy/property-provider" "^4.2.14"
"@smithy/shared-ini-file-loader" "^4.4.9"
"@smithy/types" "^4.14.1"
tslib "^2.6.2"
"@aws-sdk/credential-provider-web-identity@^3.972.38":
version "3.972.38"
resolved "https://registry.npmjs.org/@aws-sdk/credential-provider-web-identity/-/credential-provider-web-identity-3.972.38.tgz"
integrity sha512-lYHFF30DGI20jZcYX8cm6Ns0V7f1dDN6g/MBDLTyD/5iw+bXs3yBr2iAiHDkx4RFU5JgsnZvCHYKiRVPRdmOgw==
dependencies:
"@aws-sdk/core" "^3.974.8"
"@aws-sdk/nested-clients" "^3.997.6"
"@aws-sdk/types" "^3.973.8"
"@smithy/property-provider" "^4.2.14"
"@smithy/shared-ini-file-loader" "^4.4.9"
"@smithy/types" "^4.14.1"
tslib "^2.6.2"
"@aws-sdk/middleware-bucket-endpoint@^3.972.10":
version "3.972.10"
resolved "https://registry.npmjs.org/@aws-sdk/middleware-bucket-endpoint/-/middleware-bucket-endpoint-3.972.10.tgz"
integrity sha512-Vbc2frZH7wXlMNd+ZZSXUEs/l1Sv8Jj4zUnIfwrYF5lwaLdXHZ9xx4U3rjUcaye3HRhFVc+E5DbBxpRAbB16BA==
dependencies:
"@aws-sdk/types" "^3.973.8"
"@aws-sdk/util-arn-parser" "^3.972.3"
"@smithy/node-config-provider" "^4.3.14"
"@smithy/protocol-http" "^5.3.14"
"@smithy/types" "^4.14.1"
"@smithy/util-config-provider" "^4.2.2"
tslib "^2.6.2"
"@aws-sdk/middleware-expect-continue@^3.972.10":
version "3.972.10"
resolved "https://registry.npmjs.org/@aws-sdk/middleware-expect-continue/-/middleware-expect-continue-3.972.10.tgz"
integrity sha512-2Yn0f1Qiq/DjxYR3wfI3LokXnjOhFM7Ssn4LTdFDIxRMCE6I32MAsVnhPX1cUZsuVA9tiZtwwhlSLAtFGxAZlQ==
dependencies:
"@aws-sdk/types" "^3.973.8"
"@smithy/protocol-http" "^5.3.14"
"@smithy/types" "^4.14.1"
tslib "^2.6.2"
"@aws-sdk/middleware-flexible-checksums@^3.974.16":
version "3.974.16"
resolved "https://registry.npmjs.org/@aws-sdk/middleware-flexible-checksums/-/middleware-flexible-checksums-3.974.16.tgz"
integrity sha512-6ru8doI0/XzszqLIPXf0E/V7HhAw1Pu94010XCKYtBUfD0LxF0BuOzrUf8OQGR6j2o6wgKTHUniOmndQycHwCA==
dependencies:
"@aws-crypto/crc32" "5.2.0"
"@aws-crypto/crc32c" "5.2.0"
"@aws-crypto/util" "5.2.0"
"@aws-sdk/core" "^3.974.8"
"@aws-sdk/crc64-nvme" "^3.972.7"
"@aws-sdk/types" "^3.973.8"
"@smithy/is-array-buffer" "^4.2.2"
"@smithy/node-config-provider" "^4.3.14"
"@smithy/protocol-http" "^5.3.14"
"@smithy/types" "^4.14.1"
"@smithy/util-middleware" "^4.2.14"
"@smithy/util-stream" "^4.5.25"
"@smithy/util-utf8" "^4.2.2"
tslib "^2.6.2"
"@aws-sdk/middleware-host-header@^3.972.10":
version "3.972.10"
resolved "https://registry.npmjs.org/@aws-sdk/middleware-host-header/-/middleware-host-header-3.972.10.tgz"
integrity sha512-IJSsIMeVQ8MMCPbuh1AbltkFhLBLXn7aejzfX5YKT/VLDHn++Dcz8886tXckE+wQssyPUhaXrJhdakO2VilRhg==
dependencies:
"@aws-sdk/types" "^3.973.8"
"@smithy/protocol-http" "^5.3.14"
"@smithy/types" "^4.14.1"
tslib "^2.6.2"
"@aws-sdk/middleware-location-constraint@^3.972.10":
version "3.972.10"
resolved "https://registry.npmjs.org/@aws-sdk/middleware-location-constraint/-/middleware-location-constraint-3.972.10.tgz"
integrity sha512-rI3NZvJcEvjoD0+0PI0iUAwlPw2IlSlhyvgBK/3WkKJQE/YiKFedd9dMN2lVacdNxPNhxL/jzQaKQdrGtQagjQ==
dependencies:
"@aws-sdk/types" "^3.973.8"
"@smithy/types" "^4.14.1"
tslib "^2.6.2"
"@aws-sdk/middleware-logger@^3.972.10":
version "3.972.10"
resolved "https://registry.npmjs.org/@aws-sdk/middleware-logger/-/middleware-logger-3.972.10.tgz"
integrity sha512-OOuGvvz1Dm20SjZo5oEBePFqxt5nf8AwkNDSyUHvD9/bfNASmstcYxFAHUowy4n6Io7mWUZ04JURZwSBvyQanQ==
dependencies:
"@aws-sdk/types" "^3.973.8"
"@smithy/types" "^4.14.1"
tslib "^2.6.2"
"@aws-sdk/middleware-recursion-detection@^3.972.11":
version "3.972.11"
resolved "https://registry.npmjs.org/@aws-sdk/middleware-recursion-detection/-/middleware-recursion-detection-3.972.11.tgz"
integrity sha512-+zz6f79Kj9V5qFK2P+D8Ehjnw4AhphAlCAsPjUqEcInA9umtSSKMrHbSagEeOIsDNuvVrH98bjRHcyQukTrhaQ==
dependencies:
"@aws-sdk/types" "^3.973.8"
"@aws/lambda-invoke-store" "^0.2.2"
"@smithy/protocol-http" "^5.3.14"
"@smithy/types" "^4.14.1"
tslib "^2.6.2"
"@aws-sdk/middleware-sdk-s3@^3.972.37":
version "3.972.37"
resolved "https://registry.npmjs.org/@aws-sdk/middleware-sdk-s3/-/middleware-sdk-s3-3.972.37.tgz"
integrity sha512-Km7M+i8DrLArVzrid1gfxeGhYHBd3uxvE77g0s5a52zPSVosxzQBnJ0gwWb6NIp/DOk8gsBMhi7V+cpJG0ndTA==
dependencies:
"@aws-sdk/core" "^3.974.8"
"@aws-sdk/types" "^3.973.8"
"@aws-sdk/util-arn-parser" "^3.972.3"
"@smithy/core" "^3.23.17"
"@smithy/node-config-provider" "^4.3.14"
"@smithy/protocol-http" "^5.3.14"
"@smithy/signature-v4" "^5.3.14"
"@smithy/smithy-client" "^4.12.13"
"@smithy/types" "^4.14.1"
"@smithy/util-config-provider" "^4.2.2"
"@smithy/util-middleware" "^4.2.14"
"@smithy/util-stream" "^4.5.25"
"@smithy/util-utf8" "^4.2.2"
tslib "^2.6.2"
"@aws-sdk/middleware-ssec@^3.972.10":
version "3.972.10"
resolved "https://registry.npmjs.org/@aws-sdk/middleware-ssec/-/middleware-ssec-3.972.10.tgz"
integrity sha512-Gli9A0u8EVVb+5bFDGS/QbSVg28w/wpEidg1ggVcSj65BDTdGR6punsOcVjqdiu1i42WHWo51MCvARPIIz9juw==
dependencies:
"@aws-sdk/types" "^3.973.8"
"@smithy/types" "^4.14.1"
tslib "^2.6.2"
"@aws-sdk/middleware-user-agent@^3.972.38":
version "3.972.38"
resolved "https://registry.npmjs.org/@aws-sdk/middleware-user-agent/-/middleware-user-agent-3.972.38.tgz"
integrity sha512-iz+B29TXcAZsJpwB+AwG/TTGA5l/VnmMZ2UxtiySOZjI6gCdmviXPwdgzcmuazMy16rXoPY4mYCGe7zdNKfx5A==
dependencies:
"@aws-sdk/core" "^3.974.8"
"@aws-sdk/types" "^3.973.8"
"@aws-sdk/util-endpoints" "^3.996.8"
"@smithy/core" "^3.23.17"
"@smithy/protocol-http" "^5.3.14"
"@smithy/types" "^4.14.1"
"@smithy/util-retry" "^4.3.6"
tslib "^2.6.2"
"@aws-sdk/nested-clients@^3.997.6":
version "3.997.6"
resolved "https://registry.npmjs.org/@aws-sdk/nested-clients/-/nested-clients-3.997.6.tgz"
integrity sha512-WBDnqatJl+kGObpfmfSxqnXeYTu3Me8wx8WCtvoxX3pfWrrTv8I4WTMSSs7PZqcRcVh8WeUKMgGFjMG+52SR1w==
dependencies:
"@aws-crypto/sha256-browser" "5.2.0"
"@aws-crypto/sha256-js" "5.2.0"
"@aws-sdk/core" "^3.974.8"
"@aws-sdk/middleware-host-header" "^3.972.10"
"@aws-sdk/middleware-logger" "^3.972.10"
"@aws-sdk/middleware-recursion-detection" "^3.972.11"
"@aws-sdk/middleware-user-agent" "^3.972.38"
"@aws-sdk/region-config-resolver" "^3.972.13"
"@aws-sdk/signature-v4-multi-region" "^3.996.25"
"@aws-sdk/types" "^3.973.8"
"@aws-sdk/util-endpoints" "^3.996.8"
"@aws-sdk/util-user-agent-browser" "^3.972.10"
"@aws-sdk/util-user-agent-node" "^3.973.24"
"@smithy/config-resolver" "^4.4.17"
"@smithy/core" "^3.23.17"
"@smithy/fetch-http-handler" "^5.3.17"
"@smithy/hash-node" "^4.2.14"
"@smithy/invalid-dependency" "^4.2.14"
"@smithy/middleware-content-length" "^4.2.14"
"@smithy/middleware-endpoint" "^4.4.32"
"@smithy/middleware-retry" "^4.5.7"
"@smithy/middleware-serde" "^4.2.20"
"@smithy/middleware-stack" "^4.2.14"
"@smithy/node-config-provider" "^4.3.14"
"@smithy/node-http-handler" "^4.6.1"
"@smithy/protocol-http" "^5.3.14"
"@smithy/smithy-client" "^4.12.13"
"@smithy/types" "^4.14.1"
"@smithy/url-parser" "^4.2.14"
"@smithy/util-base64" "^4.3.2"
"@smithy/util-body-length-browser" "^4.2.2"
"@smithy/util-body-length-node" "^4.2.3"
"@smithy/util-defaults-mode-browser" "^4.3.49"
"@smithy/util-defaults-mode-node" "^4.2.54"
"@smithy/util-endpoints" "^3.4.2"
"@smithy/util-middleware" "^4.2.14"
"@smithy/util-retry" "^4.3.6"
"@smithy/util-utf8" "^4.2.2"
tslib "^2.6.2"
"@aws-sdk/region-config-resolver@^3.972.13":
version "3.972.13"
resolved "https://registry.npmjs.org/@aws-sdk/region-config-resolver/-/region-config-resolver-3.972.13.tgz"
integrity sha512-CvJ2ZIjK/jVD/lbOpowBVElJyC1YxLTIJ13yM0AEo0t2v7swOzGjSA6lJGH+DwZXQhcjUjoYwc8bVYCX5MDr1A==
dependencies:
"@aws-sdk/types" "^3.973.8"
"@smithy/config-resolver" "^4.4.17"
"@smithy/node-config-provider" "^4.3.14"
"@smithy/types" "^4.14.1"
tslib "^2.6.2"
"@aws-sdk/signature-v4-multi-region@^3.996.25":
version "3.996.25"
resolved "https://registry.npmjs.org/@aws-sdk/signature-v4-multi-region/-/signature-v4-multi-region-3.996.25.tgz"
integrity sha512-+CMIt3e1VzlklAECmG+DtP1sV8iKq25FuA0OKpnJ4KA0kxUtd7CgClY7/RU6VzJBQwbN4EJ9Ue6plvqx1qGadw==
dependencies:
"@aws-sdk/middleware-sdk-s3" "^3.972.37"
"@aws-sdk/types" "^3.973.8"
"@smithy/protocol-http" "^5.3.14"
"@smithy/signature-v4" "^5.3.14"
"@smithy/types" "^4.14.1"
tslib "^2.6.2"
"@aws-sdk/token-providers@3.1041.0":
version "3.1041.0"
resolved "https://registry.npmjs.org/@aws-sdk/token-providers/-/token-providers-3.1041.0.tgz"
integrity sha512-Th7kPI6YPtvJUcdznooXJMy+9rQWjmEF81LxaJssngBzuysK4a/x+l8kjm1zb7nYsUPbndnBdUnwng/3PLvtGw==
dependencies:
"@aws-sdk/core" "^3.974.8"
"@aws-sdk/nested-clients" "^3.997.6"
"@aws-sdk/types" "^3.973.8"
"@smithy/property-provider" "^4.2.14"
"@smithy/shared-ini-file-loader" "^4.4.9"
"@smithy/types" "^4.14.1"
tslib "^2.6.2"
"@aws-sdk/types@^3.222.0", "@aws-sdk/types@^3.973.8":
version "3.973.8"
resolved "https://registry.npmjs.org/@aws-sdk/types/-/types-3.973.8.tgz"
integrity sha512-gjlAdtHMbtR9X5iIhVUvbVcy55KnznpC6bkDUWW9z915bi0ckdUr5cjf16Kp6xq0bP5HBD2xzgbL9F9Quv5vUw==
dependencies:
"@smithy/types" "^4.14.1"
tslib "^2.6.2"
"@aws-sdk/util-arn-parser@^3.972.3":
version "3.972.3"
resolved "https://registry.npmjs.org/@aws-sdk/util-arn-parser/-/util-arn-parser-3.972.3.tgz"
integrity sha512-HzSD8PMFrvgi2Kserxuff5VitNq2sgf3w9qxmskKDiDTThWfVteJxuCS9JXiPIPtmCrp+7N9asfIaVhBFORllA==
dependencies:
tslib "^2.6.2"
"@aws-sdk/util-endpoints@^3.996.8":
version "3.996.8"
resolved "https://registry.npmjs.org/@aws-sdk/util-endpoints/-/util-endpoints-3.996.8.tgz"
integrity sha512-oOZHcRDihk5iEe5V25NVWg45b3qEA8OpHWVdU/XQh8Zj4heVPAJqWvMphQnU7LkufmUo10EpvFPZuQMiFLJK3g==
dependencies:
"@aws-sdk/types" "^3.973.8"
"@smithy/types" "^4.14.1"
"@smithy/url-parser" "^4.2.14"
"@smithy/util-endpoints" "^3.4.2"
tslib "^2.6.2"
"@aws-sdk/util-locate-window@^3.0.0":
version "3.965.5"
resolved "https://registry.npmjs.org/@aws-sdk/util-locate-window/-/util-locate-window-3.965.5.tgz"
integrity sha512-WhlJNNINQB+9qtLtZJcpQdgZw3SCDCpXdUJP7cToGwHbCWCnRckGlc6Bx/OhWwIYFNAn+FIydY8SZ0QmVu3xTQ==
dependencies:
tslib "^2.6.2"
"@aws-sdk/util-user-agent-browser@^3.972.10":
version "3.972.10"
resolved "https://registry.npmjs.org/@aws-sdk/util-user-agent-browser/-/util-user-agent-browser-3.972.10.tgz"
integrity sha512-FAzqXvfEssGdSIz8ejatan0bOdx1qefBWKF/gWmVBXIP1HkS7v/wjjaqrAGGKvyihrXTXW00/2/1nTJtxpXz7g==
dependencies:
"@aws-sdk/types" "^3.973.8"
"@smithy/types" "^4.14.1"
bowser "^2.11.0"
tslib "^2.6.2"
"@aws-sdk/util-user-agent-node@^3.973.24":
version "3.973.24"
resolved "https://registry.npmjs.org/@aws-sdk/util-user-agent-node/-/util-user-agent-node-3.973.24.tgz"
integrity sha512-ZWwlkjcIp7cEL8ZfTpTAPNkwx25p7xol0xlKoWVVf22+nsjwmLcHYtTPjIV1cSpmB/b6DaK4cb1fSkvCXHgRdw==
dependencies:
"@aws-sdk/middleware-user-agent" "^3.972.38"
"@aws-sdk/types" "^3.973.8"
"@smithy/node-config-provider" "^4.3.14"
"@smithy/types" "^4.14.1"
"@smithy/util-config-provider" "^4.2.2"
tslib "^2.6.2"
"@aws-sdk/xml-builder@^3.972.22":
version "3.972.22"
resolved "https://registry.npmjs.org/@aws-sdk/xml-builder/-/xml-builder-3.972.22.tgz"
integrity sha512-PMYKKtJd70IsSG0yHrdAbxBr+ZWBKLvzFZfD3/urxgf6hXVMzuU5M+3MJ5G67RpOmLBu1fAUN65SbWuKUCOlAA==
dependencies:
"@nodable/entities" "2.1.0"
"@smithy/types" "^4.14.1"
fast-xml-parser "5.7.2"
tslib "^2.6.2"
"@aws/lambda-invoke-store@^0.2.2":
version "0.2.4"
resolved "https://registry.npmjs.org/@aws/lambda-invoke-store/-/lambda-invoke-store-0.2.4.tgz"
integrity sha512-iY8yvjE0y651BixKNPgmv1WrQc+GZ142sb0z4gYnChDDY2YqI4P/jsSopBWrKfAt7LOJAkOXt7rC/hms+WclQQ==
"@babel/code-frame@^7.0.0", "@babel/code-frame@^7.28.6", "@babel/code-frame@^7.29.0":
version "7.29.0"
resolved "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz"
@@ -591,6 +1142,11 @@
resolved "https://registry.npmjs.org/@noble/hashes/-/hashes-1.8.0.tgz"
integrity sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A==
"@nodable/entities@^2.1.0", "@nodable/entities@2.1.0":
version "2.1.0"
resolved "https://registry.npmjs.org/@nodable/entities/-/entities-2.1.0.tgz"
integrity sha512-nyT7T3nbMyBI/lvr6L5TyWbFJAI9FTgVRakNoBqCD+PmID8DzFrrNdLLtHMwMszOtqZa8PAOV24ZqDnQrhQINA==
"@nodelib/fs.scandir@2.1.5":
version "2.1.5"
resolved "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz"
@@ -830,6 +1386,346 @@
resolved "https://registry.npmjs.org/@sindresorhus/merge-streams/-/merge-streams-4.0.0.tgz"
integrity sha512-tlqY9xq5ukxTUZBmoOp+m61cqwQD5pHJtFY3Mn8CA8ps6yghLH/Hw8UPdqg4OLmFW3IFlcXnQNmo/dh8HzXYIQ==
"@smithy/config-resolver@^4.4.17":
version "4.5.0"
resolved "https://registry.npmjs.org/@smithy/config-resolver/-/config-resolver-4.5.0.tgz"
integrity sha512-m5PNfr7xKdIegNG8DlLz+Gf/DlAhHWFGmFbe0DZo9pnvBwuZ3P/9OMtQU0UyWMYy8zjl+HDFVS7rdD9p2xEFjQ==
dependencies:
"@smithy/core" "^3.24.0"
tslib "^2.6.2"
"@smithy/core@^3.23.17", "@smithy/core@^3.24.0":
version "3.24.0"
resolved "https://registry.npmjs.org/@smithy/core/-/core-3.24.0.tgz"
integrity sha512-rZ5YfycIXX6puoGjthnDiMpUgtKNOq3c7CndQYkCNYQTv26AiCrZQOJPy7ANSfZ6Okk3UvCRnmO1OYWlLnYZgg==
dependencies:
"@aws-crypto/crc32" "5.2.0"
"@smithy/types" "^4.14.1"
tslib "^2.6.2"
"@smithy/credential-provider-imds@^4.2.14":
version "4.3.0"
resolved "https://registry.npmjs.org/@smithy/credential-provider-imds/-/credential-provider-imds-4.3.0.tgz"
integrity sha512-5gi+28FH+RurB2+tcRH1CK7KiLJ0dVnabjWLY3DgeFLiU45dbyrsq7NOYvMUcHgu9LVZH5F7G+Qk1GdXF0y6jg==
dependencies:
"@smithy/core" "^3.24.0"
"@smithy/types" "^4.14.1"
tslib "^2.6.2"
"@smithy/eventstream-serde-browser@^4.2.14":
version "4.3.0"
resolved "https://registry.npmjs.org/@smithy/eventstream-serde-browser/-/eventstream-serde-browser-4.3.0.tgz"
integrity sha512-JlY17/ZwBJ2O7FK/bKt8PZR+HBkyFwvgssgT6LiB0xYtz5/E5XG/HeKr5q2NMaVm8u8xjFfGk/6DVlbBe1qNkA==
dependencies:
"@smithy/core" "^3.24.0"
tslib "^2.6.2"
"@smithy/eventstream-serde-config-resolver@^4.3.14":
version "4.4.0"
resolved "https://registry.npmjs.org/@smithy/eventstream-serde-config-resolver/-/eventstream-serde-config-resolver-4.4.0.tgz"
integrity sha512-1Pg7aqxIdMilTbGJKCHTx0toIkKSrHdO6VHCh9oCncWJG+1wkJa90O/xb9mmRPuoOFCg2DLZAqnRyuBiUQnNIA==
dependencies:
"@smithy/core" "^3.24.0"
tslib "^2.6.2"
"@smithy/eventstream-serde-node@^4.2.14":
version "4.3.0"
resolved "https://registry.npmjs.org/@smithy/eventstream-serde-node/-/eventstream-serde-node-4.3.0.tgz"
integrity sha512-Xte1Td6CQpc/D0WnPZ2k98CvF7y1GopylMoGY/r26a9wbRHV5xusRbT6O9vouSeZlvtxoVb4ON/1fLRofO7m4Q==
dependencies:
"@smithy/core" "^3.24.0"
tslib "^2.6.2"
"@smithy/fetch-http-handler@^5.3.17":
version "5.4.0"
resolved "https://registry.npmjs.org/@smithy/fetch-http-handler/-/fetch-http-handler-5.4.0.tgz"
integrity sha512-yxurumLvHfgYgM0FVtjOVIyBSJXfno4xKKOgD43wOk9Qh+2lTKfP9Qhu4JHU7IUwrqVPa888byUzomHMgvKVMg==
dependencies:
"@smithy/core" "^3.24.0"
"@smithy/types" "^4.14.1"
tslib "^2.6.2"
"@smithy/hash-blob-browser@^4.2.15":
version "4.3.0"
resolved "https://registry.npmjs.org/@smithy/hash-blob-browser/-/hash-blob-browser-4.3.0.tgz"
integrity sha512-xOQ7w5hSzTe8IAwQ6BAbX+1d9s9SRwWUbU8cYYh5DHgkYOi131Q5+B9Blelg9u0zoPfM4VYOuuBRAmregPkzpQ==
dependencies:
"@smithy/core" "^3.24.0"
tslib "^2.6.2"
"@smithy/hash-node@^4.2.14":
version "4.3.0"
resolved "https://registry.npmjs.org/@smithy/hash-node/-/hash-node-4.3.0.tgz"
integrity sha512-4a+KoVqr1SZtw7cZvY24XU1S5OL+c23MdDQ3jFmMCQ5s9diBFdMG/UIgp5dNqlwvDrWA0U5KO+z3Gzq1ize+LA==
dependencies:
"@smithy/core" "^3.24.0"
tslib "^2.6.2"
"@smithy/hash-stream-node@^4.2.14":
version "4.3.0"
resolved "https://registry.npmjs.org/@smithy/hash-stream-node/-/hash-stream-node-4.3.0.tgz"
integrity sha512-DPDT0UyOREMPwipO7BzSJfD8z2YYp/x1FMpHqEppBF+gmGr7FoqUUFlXfK+YPOfHhlr8HDJRQpmShNb9C9po5Q==
dependencies:
"@smithy/core" "^3.24.0"
tslib "^2.6.2"
"@smithy/invalid-dependency@^4.2.14":
version "4.3.0"
resolved "https://registry.npmjs.org/@smithy/invalid-dependency/-/invalid-dependency-4.3.0.tgz"
integrity sha512-TaoGtqi2ZNdGzxUgYcLczjW8rb/h5DQ8vlCMYDSdZ4LRzGQrrEYgUjlZVM9dAagTsLK5gZx1f7+44sFTjz5vuQ==
dependencies:
"@smithy/core" "^3.24.0"
tslib "^2.6.2"
"@smithy/is-array-buffer@^2.2.0":
version "2.2.0"
resolved "https://registry.npmjs.org/@smithy/is-array-buffer/-/is-array-buffer-2.2.0.tgz"
integrity sha512-GGP3O9QFD24uGeAXYUjwSTXARoqpZykHadOmA8G5vfJPK0/DC67qa//0qvqrJzL1xc8WQWX7/yc7fwudjPHPhA==
dependencies:
tslib "^2.6.2"
"@smithy/is-array-buffer@^4.2.2":
version "4.3.0"
resolved "https://registry.npmjs.org/@smithy/is-array-buffer/-/is-array-buffer-4.3.0.tgz"
integrity sha512-V9ZCT5mHNteWOKtu3LkGHTheEyBWzTU3XydeSOHrqG6znEoCARjxFZ0XP9JJyKV9jxEMx7hNbzGlQaXtKkmvSQ==
dependencies:
"@smithy/core" "^3.24.0"
tslib "^2.6.2"
"@smithy/md5-js@^4.2.14":
version "4.3.0"
resolved "https://registry.npmjs.org/@smithy/md5-js/-/md5-js-4.3.0.tgz"
integrity sha512-bODwWrXILREpCL7XZq1/QxiMqFxdWadw3noiKGjNKsPl1/nvWtLVFInNjK6pmHjM4BUOgfgZkrl9+V56ez2FNA==
dependencies:
"@smithy/core" "^3.24.0"
tslib "^2.6.2"
"@smithy/middleware-content-length@^4.2.14":
version "4.3.0"
resolved "https://registry.npmjs.org/@smithy/middleware-content-length/-/middleware-content-length-4.3.0.tgz"
integrity sha512-IbSiS/3nOxsimCthzElEoBrjQo+Na4bsQ63qyC8qSI8lkMjOv9+VlosDQd8gfNolAD9XmC5tLqYTI0bJGJsscg==
dependencies:
"@smithy/core" "^3.24.0"
tslib "^2.6.2"
"@smithy/middleware-endpoint@^4.4.32":
version "4.5.0"
resolved "https://registry.npmjs.org/@smithy/middleware-endpoint/-/middleware-endpoint-4.5.0.tgz"
integrity sha512-ux8LgN/m/X7ET2ISRc8G4aKFI1QhINZtkKpoayNPTrhwpsCVxb47mlpYFuWceTlesc0Wmb0S9y6DP195ReQoXA==
dependencies:
"@smithy/core" "^3.24.0"
tslib "^2.6.2"
"@smithy/middleware-retry@^4.5.7":
version "4.6.0"
resolved "https://registry.npmjs.org/@smithy/middleware-retry/-/middleware-retry-4.6.0.tgz"
integrity sha512-8CtxY9aHT4f3UvZUbU2O0bccRckqTDfTKk3t1DawUZa5DWRZdV2AMABLsdMTdj7KE1uumhzEaT0X7/jTcOtoBw==
dependencies:
"@smithy/core" "^3.24.0"
tslib "^2.6.2"
"@smithy/middleware-serde@^4.2.20":
version "4.3.0"
resolved "https://registry.npmjs.org/@smithy/middleware-serde/-/middleware-serde-4.3.0.tgz"
integrity sha512-c+V02hZlIStscI4ie2VllJjM4DLxdI2SymIBvXmqCqicrNb0NAbgDXDTBiwcMiruaBOqEFYxpKXbz6JjsNEN3Q==
dependencies:
"@smithy/core" "^3.24.0"
tslib "^2.6.2"
"@smithy/middleware-stack@^4.2.14":
version "4.3.0"
resolved "https://registry.npmjs.org/@smithy/middleware-stack/-/middleware-stack-4.3.0.tgz"
integrity sha512-KtYcs+sJn7AiT0YdM53/6MT0dKsaW2MSAr9MpprRVSfwN9qyKQf2dBIuCXt18/nEZaWerol/bGaQ63G949aovw==
dependencies:
"@smithy/core" "^3.24.0"
tslib "^2.6.2"
"@smithy/node-config-provider@^4.3.14":
version "4.4.0"
resolved "https://registry.npmjs.org/@smithy/node-config-provider/-/node-config-provider-4.4.0.tgz"
integrity sha512-5RutFJsYoqK4tWYZOjGQrPLowGf2Ku8rbNuVeGkNJ5axIDO4LV/fydBojPtwcDz2zf87YNCOXfNyuEyAwYgI7A==
dependencies:
"@smithy/core" "^3.24.0"
tslib "^2.6.2"
"@smithy/node-http-handler@^4.6.1":
version "4.7.0"
resolved "https://registry.npmjs.org/@smithy/node-http-handler/-/node-http-handler-4.7.0.tgz"
integrity sha512-PxF57Jr3dPm+RgZWekOL+o96FPdaT62xZUyDfi47uMRFi5rHpwO/ewFbrztrASQ/7H8moNi1sspIHihHpfoKsQ==
dependencies:
"@smithy/core" "^3.24.0"
"@smithy/types" "^4.14.1"
tslib "^2.6.2"
"@smithy/property-provider@^4.2.14":
version "4.3.0"
resolved "https://registry.npmjs.org/@smithy/property-provider/-/property-provider-4.3.0.tgz"
integrity sha512-/YBWtO2SdvPSAUk/Ke1Xpdg1E1lfaNGblla7mnIVGtaGkSQ5bK7KBZqpuj5IokHlU9UcLDvt2QwTLV7oRzBUTA==
dependencies:
"@smithy/core" "^3.24.0"
tslib "^2.6.2"
"@smithy/protocol-http@^5.3.14":
version "5.4.0"
resolved "https://registry.npmjs.org/@smithy/protocol-http/-/protocol-http-5.4.0.tgz"
integrity sha512-WG0LgSZg+WbvWYD04uwIYVyMEpyd0cPx1lkqx61JxunxiFti+wGoFiDKr6wswun1r25Z2f8yUoMQWyxjMnnXtw==
dependencies:
"@smithy/core" "^3.24.0"
tslib "^2.6.2"
"@smithy/shared-ini-file-loader@^4.4.9":
version "4.5.0"
resolved "https://registry.npmjs.org/@smithy/shared-ini-file-loader/-/shared-ini-file-loader-4.5.0.tgz"
integrity sha512-xATpw6gcurFztdsUrMNaKb2ugqk3545Whhqg7ZD4sxTg+zI27THjg3IY+InXsVWturOWdCdV+UHQx11g9Sp5Kw==
dependencies:
"@smithy/core" "^3.24.0"
tslib "^2.6.2"
"@smithy/signature-v4@^5.3.14":
version "5.4.0"
resolved "https://registry.npmjs.org/@smithy/signature-v4/-/signature-v4-5.4.0.tgz"
integrity sha512-nkdB9T8JS6iD5PukE5TB8KqcvMEPVPHVUY7J0odYJgyIM40Du2msUhBdoPNRqRArDDcGQqVQcbzu0CZA7b+Nkw==
dependencies:
"@smithy/core" "^3.24.0"
"@smithy/types" "^4.14.1"
tslib "^2.6.2"
"@smithy/smithy-client@^4.12.13":
version "4.13.0"
resolved "https://registry.npmjs.org/@smithy/smithy-client/-/smithy-client-4.13.0.tgz"
integrity sha512-lysfoRCr7PdD9CsPp9VQuJYRGI5mWYb8FRkbdBSQttxpQmW7tZsFgmpBNKVcgvBsAgBCkYX/UQs0NmznuBcZQQ==
dependencies:
"@smithy/core" "^3.24.0"
"@smithy/types" "^4.14.1"
tslib "^2.6.2"
"@smithy/types@^4.14.1":
version "4.14.1"
resolved "https://registry.npmjs.org/@smithy/types/-/types-4.14.1.tgz"
integrity sha512-59b5HtSVrVR/eYNei3BUj3DCPKD/G7EtDDe7OEJE7i7FtQFugYo6MxbotS8mVJkLNVf8gYaAlEBwwtJ9HzhWSg==
dependencies:
tslib "^2.6.2"
"@smithy/url-parser@^4.2.14":
version "4.3.0"
resolved "https://registry.npmjs.org/@smithy/url-parser/-/url-parser-4.3.0.tgz"
integrity sha512-I5tCWs/ndLrJrbvlnsN1cOt8PVAbQEqg0nNeQqebD5ynQcbhgch9uA7KmpX9vfq/vEudq0iVYAOxt+4aBkUlWA==
dependencies:
"@smithy/core" "^3.24.0"
tslib "^2.6.2"
"@smithy/util-base64@^4.3.2":
version "4.4.0"
resolved "https://registry.npmjs.org/@smithy/util-base64/-/util-base64-4.4.0.tgz"
integrity sha512-puJITyefgQ9a5F+wKylCLkf0VCwesWbaN4O3YCEalRin4N0CTPQu/XA3kz/QsMOTgd3knhd0BQwGCBm/tv0Y1A==
dependencies:
"@smithy/core" "^3.24.0"
tslib "^2.6.2"
"@smithy/util-body-length-browser@^4.2.2":
version "4.3.0"
resolved "https://registry.npmjs.org/@smithy/util-body-length-browser/-/util-body-length-browser-4.3.0.tgz"
integrity sha512-83U8xa8EmdExGzFuqBzgXvtmbLQIYcCuCNm5no4rlPqpGdOPGUufzMvLdlw+sPTb01qHIsDDNwOecm4s8ROOPw==
dependencies:
"@smithy/core" "^3.24.0"
tslib "^2.6.2"
"@smithy/util-body-length-node@^4.2.3":
version "4.3.0"
resolved "https://registry.npmjs.org/@smithy/util-body-length-node/-/util-body-length-node-4.3.0.tgz"
integrity sha512-Ok2v9zPFfd6uOJMTIIJ8HFdCpARD77q4OHYhwhG9y5X1Y9oeQ0CHUQVJD6LhT6l8FUkFYisqcUaZSg7SArFUTA==
dependencies:
"@smithy/core" "^3.24.0"
tslib "^2.6.2"
"@smithy/util-buffer-from@^2.2.0":
version "2.2.0"
resolved "https://registry.npmjs.org/@smithy/util-buffer-from/-/util-buffer-from-2.2.0.tgz"
integrity sha512-IJdWBbTcMQ6DA0gdNhh/BwrLkDR+ADW5Kr1aZmd4k3DIF6ezMV4R2NIAmT08wQJ3yUK82thHWmC/TnK/wpMMIA==
dependencies:
"@smithy/is-array-buffer" "^2.2.0"
tslib "^2.6.2"
"@smithy/util-config-provider@^4.2.2":
version "4.3.0"
resolved "https://registry.npmjs.org/@smithy/util-config-provider/-/util-config-provider-4.3.0.tgz"
integrity sha512-kAC6/UB9qW9r2xQAOko2iDxAXmRD2VGMZjnXSEacAhQySdJs58CwvoOE0tHWdtc/lWF4g78X6Z9ucLanJnuVUw==
dependencies:
"@smithy/core" "^3.24.0"
tslib "^2.6.2"
"@smithy/util-defaults-mode-browser@^4.3.49":
version "4.4.0"
resolved "https://registry.npmjs.org/@smithy/util-defaults-mode-browser/-/util-defaults-mode-browser-4.4.0.tgz"
integrity sha512-jKezW5Taa+N2gbkB02UVijH1rFlEJC+cskZzwasFqFJMBBi/bcVgHqcYOX0WOnUk6MDZfHf0gEsr5Br4XMHiAg==
dependencies:
"@smithy/core" "^3.24.0"
tslib "^2.6.2"
"@smithy/util-defaults-mode-node@^4.2.54":
version "4.3.0"
resolved "https://registry.npmjs.org/@smithy/util-defaults-mode-node/-/util-defaults-mode-node-4.3.0.tgz"
integrity sha512-xYRuNHHIztu5AzruMJ8kTyA1JsBL/yZKvX5z/A7OHUxsf+rkEESZFZWJDcAj5dDWSu6brWFe5KH6qJNTVztX/w==
dependencies:
"@smithy/core" "^3.24.0"
tslib "^2.6.2"
"@smithy/util-endpoints@^3.4.2":
version "3.5.0"
resolved "https://registry.npmjs.org/@smithy/util-endpoints/-/util-endpoints-3.5.0.tgz"
integrity sha512-pcvTCp9Wch/9UnWWfRGoG5GJogDXFPjevE+CqALxtPFGA4GqFQRD6eUtgJhHN+NPtohcozI12u1skF2/iubGrQ==
dependencies:
"@smithy/core" "^3.24.0"
tslib "^2.6.2"
"@smithy/util-middleware@^4.2.14":
version "4.3.0"
resolved "https://registry.npmjs.org/@smithy/util-middleware/-/util-middleware-4.3.0.tgz"
integrity sha512-X/DNQxgUCbjjs3HosLmt5Yi1NocxjRFiiOgHml4tVV3w4mIbqZxPR8kq7apGPEMnhIpyxeTgFyypMrfxfn2DlQ==
dependencies:
"@smithy/core" "^3.24.0"
tslib "^2.6.2"
"@smithy/util-retry@^4.3.6":
version "4.4.0"
resolved "https://registry.npmjs.org/@smithy/util-retry/-/util-retry-4.4.0.tgz"
integrity sha512-pV/Kq4jUuP9raOqwSPeBiut2IWmwbc9vM+nE3ly4YUkzPHbBZvfhikwMOyudER+KHPjakuc8r4TecEPMsI7nVg==
dependencies:
"@smithy/core" "^3.24.0"
tslib "^2.6.2"
"@smithy/util-stream@^4.5.25":
version "4.6.0"
resolved "https://registry.npmjs.org/@smithy/util-stream/-/util-stream-4.6.0.tgz"
integrity sha512-BlWg46UASokl3O5YqWmbLpINE5stmAxynXlyOe1nE4dx+tvwgqtT4ug/rPcRg0xVcBnj68XlcOqbXeaGGcH0DA==
dependencies:
"@smithy/core" "^3.24.0"
tslib "^2.6.2"
"@smithy/util-utf8@^2.0.0":
version "2.3.0"
resolved "https://registry.npmjs.org/@smithy/util-utf8/-/util-utf8-2.3.0.tgz"
integrity sha512-R8Rdn8Hy72KKcebgLiv8jQcQkXoLMOGGv5uI1/k0l+snqkOzQ1R0ChUBCxWMlBsFMekWjq0wRudIweFs7sKT5A==
dependencies:
"@smithy/util-buffer-from" "^2.2.0"
tslib "^2.6.2"
"@smithy/util-utf8@^4.2.2":
version "4.3.0"
resolved "https://registry.npmjs.org/@smithy/util-utf8/-/util-utf8-4.3.0.tgz"
integrity sha512-5hrmCc+dTgZkiFhX72Q16LemYPkvZ1M4pFMOhk0X9tQnLY7dn7zC1+C+aAJn0dw6CXldbqY/KMbMYCwm8yw14g==
dependencies:
"@smithy/core" "^3.24.0"
tslib "^2.6.2"
"@smithy/util-waiter@^4.3.0":
version "4.4.0"
resolved "https://registry.npmjs.org/@smithy/util-waiter/-/util-waiter-4.4.0.tgz"
integrity sha512-7kAlrB3n7/BHyw+uLq83d5jdadPUcDkdMOUSGxvpXjrJ++G0hTedTnoNChjybIxhZ/Gk7sCrfIOLkMAB0LhRBA==
dependencies:
"@smithy/core" "^3.24.0"
tslib "^2.6.2"
"@standard-schema/spec@^1.0.0":
version "1.1.0"
resolved "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz"
@@ -1294,6 +2190,11 @@ body-parser@^2.2.1:
raw-body "^3.0.1"
type-is "^2.0.1"
bowser@^2.11.0:
version "2.14.1"
resolved "https://registry.npmjs.org/bowser/-/bowser-2.14.1.tgz"
integrity sha512-tzPjzCxygAKWFOJP011oxFHs57HzIhOEracIgAePE4pqB3LikALKnSzUyU4MGs9/iCEUuHlAJTjTc5M+u7YEGg==
brace-expansion@^1.1.7:
version "1.1.13"
resolved "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.13.tgz"
@@ -2367,6 +3268,24 @@ fast-uri@^3.0.1:
resolved "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.0.tgz"
integrity sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA==
fast-xml-builder@^1.1.5:
version "1.2.0"
resolved "https://registry.npmjs.org/fast-xml-builder/-/fast-xml-builder-1.2.0.tgz"
integrity sha512-00aAWieqff+ZJhsXA4g1g7M8k+7AYoMUUHF+/zFb5U6Uv/P0Vl4QZo84/IcufzYalLuEj9928bXN9PbbFzMF0Q==
dependencies:
path-expression-matcher "^1.5.0"
xml-naming "^0.1.0"
fast-xml-parser@5.7.2:
version "5.7.2"
resolved "https://registry.npmjs.org/fast-xml-parser/-/fast-xml-parser-5.7.2.tgz"
integrity sha512-P7oW7tLbYnhOLQk/Gv7cZgzgMPP/XN03K02/Jy6Y/NHzyIAIpxuZIM/YqAkfiXFPxA2CTm7NtCijK9EDu09u2w==
dependencies:
"@nodable/entities" "^2.1.0"
fast-xml-builder "^1.1.5"
path-expression-matcher "^1.5.0"
strnum "^2.2.3"
fastq@^1.6.0:
version "1.20.1"
resolved "https://registry.npmjs.org/fastq/-/fastq-1.20.1.tgz"
@@ -3747,6 +4666,11 @@ path-exists@^4.0.0:
resolved "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz"
integrity sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==
path-expression-matcher@^1.5.0:
version "1.5.0"
resolved "https://registry.npmjs.org/path-expression-matcher/-/path-expression-matcher-1.5.0.tgz"
integrity sha512-cbrerZV+6rvdQrrD+iGMcZFEiiSrbv9Tfdkvnusy6y0x0GKBXREFg/Y65GhIfm0tnLntThhzCnfKwp1WRjeCyQ==
path-key@^3.0.0, path-key@^3.1.0:
version "3.1.1"
resolved "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz"
@@ -4545,6 +5469,11 @@ strip-json-comments@^3.1.1:
resolved "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz"
integrity sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==
strnum@^2.2.3:
version "2.3.0"
resolved "https://registry.npmjs.org/strnum/-/strnum-2.3.0.tgz"
integrity sha512-ums3KNd42PGyx5xaoVTO1mjU1bH3NpY4vsrVlnv9PNGqQj8wd7rJ6nEypLrJ7z5vxK5RP0yMLo6J/Gsm62DI5Q==
styled-jsx@5.1.6:
version "5.1.6"
resolved "https://registry.npmjs.org/styled-jsx/-/styled-jsx-5.1.6.tgz"
@@ -4672,7 +5601,7 @@ tsconfig-paths@^4.2.0:
minimist "^1.2.6"
strip-bom "^3.0.0"
tslib@^2.0.0, tslib@^2.0.1, tslib@^2.1.0, tslib@^2.8.0:
tslib@^2.0.0, tslib@^2.0.1, tslib@^2.1.0, tslib@^2.6.2, tslib@^2.8.0:
version "2.8.1"
resolved "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz"
integrity sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==
@@ -5015,6 +5944,11 @@ wsl-utils@^0.3.0:
is-wsl "^3.1.0"
powershell-utils "^0.1.0"
xml-naming@^0.1.0:
version "0.1.0"
resolved "https://registry.npmjs.org/xml-naming/-/xml-naming-0.1.0.tgz"
integrity sha512-k8KO9hrMyNk6tUWqUfkTEZbezRRpONVOzUTnc97VnCvyj6Tf9lyUR9EDAIeiVLv56jsMcoXEwjW8Kv5yPY52lw==
y18n@^5.0.5:
version "5.0.8"
resolved "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz"