chore: refactor settings route
This commit is contained in:
@@ -0,0 +1,290 @@
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
import type { Metadata } from "next";
|
||||
import Link from "next/link";
|
||||
import { notFound } from "next/navigation";
|
||||
import {
|
||||
getTranscription,
|
||||
getAdjacentTranscriptions,
|
||||
} from "@/lib/transcriptions";
|
||||
import { buildOgMeta, ogForTranscription, extractQuoteFromHl } from "@/lib/og";
|
||||
import { PageHeader } from "@/components/layout/page-header";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { buttonVariants } from "@/components/ui/button";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { ChevronLeft, ChevronRight, Clock, MapPin, Users } from "lucide-react";
|
||||
import { PlayAudioButton } from "./play-audio-button";
|
||||
import { FindInPage } from "@/components/layout/find-in-page";
|
||||
import { JsonLd } from "@/components/seo/json-ld";
|
||||
import { articleJsonLd } from "@/lib/jsonld";
|
||||
|
||||
export async function generateMetadata({
|
||||
params,
|
||||
searchParams,
|
||||
}: {
|
||||
params: Promise<{ slug: string }>;
|
||||
searchParams: Promise<Record<string, string | string[] | undefined>>;
|
||||
}): Promise<Metadata> {
|
||||
const { slug } = await params;
|
||||
const sp = await searchParams;
|
||||
const t = await getTranscription(slug);
|
||||
if (!t) return {};
|
||||
|
||||
const detail = [t.date, t.place].filter(Boolean).join(" · ");
|
||||
|
||||
// Build paragraphs from content blocks for hl matching
|
||||
const paragraphs = t.content.map((b) => {
|
||||
const speakerPrefix = b.speaker ? `${b.speaker}: ` : "";
|
||||
return speakerPrefix + b.text.replace(/<[^>]+>/g, "");
|
||||
});
|
||||
|
||||
const hlQuote = extractQuoteFromHl(sp, paragraphs);
|
||||
|
||||
if (hlQuote) {
|
||||
return buildOgMeta({
|
||||
title: `Transcripts: ${t.name}`,
|
||||
detail: detail || undefined,
|
||||
quote: hlQuote,
|
||||
});
|
||||
}
|
||||
|
||||
// Fallback: find a good Prabhupāda quote from the content
|
||||
const spQuote = t.content
|
||||
.find((b) => b.speaker === "Prabhupāda" && b.text.length > 50)
|
||||
?.text?.replace(/<[^>]+>/g, "")
|
||||
.slice(0, 200);
|
||||
|
||||
if (spQuote) {
|
||||
return buildOgMeta({
|
||||
title: t.name,
|
||||
detail: detail || undefined,
|
||||
quote: spQuote,
|
||||
});
|
||||
}
|
||||
|
||||
return ogForTranscription(`Transcripts: ${t.name}`, detail || undefined);
|
||||
}
|
||||
|
||||
export default async function TranscriptionPage({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ slug: string }>;
|
||||
}) {
|
||||
const { slug } = await params;
|
||||
const t = await getTranscription(slug);
|
||||
if (!t) notFound();
|
||||
|
||||
const { prev, next } = await getAdjacentTranscriptions(slug, t.year);
|
||||
|
||||
const backHref = t.year
|
||||
? `/transcriptions/year/${t.year}`
|
||||
: "/transcriptions";
|
||||
const backLabel = t.year ? `Transcriptions from ${t.year}` : "Transcriptions";
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-background">
|
||||
<JsonLd
|
||||
data={articleJsonLd({
|
||||
path: `/transcriptions/${slug}`,
|
||||
headline: t.name,
|
||||
description: t.content[0]?.text,
|
||||
datePublished: t.year ? String(t.year) : undefined,
|
||||
isPartOf: "Lectures & Conversations of Śrīla Prabhupāda",
|
||||
})}
|
||||
/>
|
||||
<PageHeader
|
||||
sticky
|
||||
offline={{
|
||||
type: "transcript",
|
||||
title: t.name,
|
||||
subtitle: [t.date, t.place].filter(Boolean).join(" · ") || undefined,
|
||||
audioUrl: t.audioUrl ?? undefined,
|
||||
}}
|
||||
>
|
||||
<Link
|
||||
href={backHref}
|
||||
className="text-sm text-muted-foreground hover:text-foreground transition-colors"
|
||||
>
|
||||
← {backLabel}
|
||||
</Link>
|
||||
<div className="mt-3 flex items-start justify-between gap-3">
|
||||
<h1 className="text-2xl font-bold text-foreground">{t.name}</h1>
|
||||
{t.content.length > 0 && (
|
||||
<FindInPage targetId="transcript-content" className="shrink-0 mt-0.5" />
|
||||
)}
|
||||
</div>
|
||||
<div className="mt-2 flex items-center gap-3 flex-wrap text-sm text-muted-foreground">
|
||||
<Badge variant={t.type === "lecture" ? "default" : "secondary"}>
|
||||
{t.type === "lecture" ? "Lecture" : "Conversation"}
|
||||
</Badge>
|
||||
{t.date && <span>{t.date}</span>}
|
||||
{t.place && (
|
||||
<span className="flex items-center gap-1">
|
||||
<MapPin className="size-3.5" />
|
||||
{t.place}
|
||||
</span>
|
||||
)}
|
||||
{t.reference && (
|
||||
<span className="text-primary font-medium">{t.reference}</span>
|
||||
)}
|
||||
{t.durationMinutes && t.durationMinutes > 0 && (
|
||||
<span className="flex items-center gap-1">
|
||||
<Clock className="size-3.5" />
|
||||
{t.durationMinutes} min
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</PageHeader>
|
||||
|
||||
<main className="max-w-5xl mx-auto px-6 py-8 space-y-8">
|
||||
{/* Top nav */}
|
||||
<TranscriptionNav prev={prev} next={next} />
|
||||
|
||||
{/* Audio + Participants row */}
|
||||
<div className="w-full flex flex-col md:flex-row items-start md:items-start justify-between gap-4">
|
||||
{/* Participants */}
|
||||
{t.participants.length > 0 && (
|
||||
<>
|
||||
<div className="">
|
||||
<div className="flex flex-row pl-2 mb-2 items-center gap-2 text-sm text-muted-foreground">
|
||||
<Users className="size-3.5" />
|
||||
<span>Participants:</span>
|
||||
</div>
|
||||
<div className="max-w-full flex items-center gap-2 flex-wrap text-sm text-muted-foreground">
|
||||
{t.participants.map((p, i) => (
|
||||
<Badge
|
||||
key={i}
|
||||
variant="outline"
|
||||
className="text-xs font-normal"
|
||||
>
|
||||
{p}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Play Audio button */}
|
||||
{t.audioUrl && (
|
||||
<div className="w-full md:w-[25%] flex justify-end">
|
||||
<PlayAudioButton
|
||||
audioUrl={t.audioUrl}
|
||||
title={t.name}
|
||||
subtitle={[t.date, t.place].filter(Boolean).join(" · ")}
|
||||
sourceUrl={`/transcriptions/${t.slug}`}
|
||||
type={t.type}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Transcription content */}
|
||||
{t.content.length === 0 ? (
|
||||
<Card>
|
||||
<CardContent className="py-12 text-center text-muted-foreground">
|
||||
<p className="text-lg font-medium">No transcription available</p>
|
||||
<p className="text-sm mt-1">
|
||||
This recording has not been transcribed into text.
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : (
|
||||
<Card>
|
||||
<CardContent className="py-8 px-6 sm:px-8">
|
||||
{/* id targeted by the header's FindInPage control */}
|
||||
<div id="transcript-content" className="space-y-3">
|
||||
{t.content.map((block, i) => (
|
||||
<ContentBlockView key={i} block={block} />
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Bottom nav */}
|
||||
<TranscriptionNav prev={prev} next={next} />
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ContentBlockView({
|
||||
block,
|
||||
}: {
|
||||
block: {
|
||||
text: string;
|
||||
format: string;
|
||||
alignment: string;
|
||||
speaker: string | null;
|
||||
};
|
||||
}) {
|
||||
const isVerse = block.format === "verse";
|
||||
const isQuote = block.format === "quote";
|
||||
const isCenter = block.alignment === "center";
|
||||
|
||||
const className = cn(
|
||||
"text-base leading-relaxed",
|
||||
isVerse && "text-center italic my-4",
|
||||
isQuote && "border-l-2 border-primary/30 pl-4 text-muted-foreground italic",
|
||||
!isVerse && !isQuote && isCenter && "text-center",
|
||||
);
|
||||
|
||||
return (
|
||||
<div className={className}>
|
||||
{block.speaker && (
|
||||
<span className="font-bold text-foreground">{block.speaker}: </span>
|
||||
)}
|
||||
<span dangerouslySetInnerHTML={{ __html: block.text }} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function TranscriptionNav({
|
||||
prev,
|
||||
next,
|
||||
}: {
|
||||
prev: { slug: string; name: string } | null;
|
||||
next: { slug: string; name: string } | null;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
{prev ? (
|
||||
<Link
|
||||
href={`/transcriptions/${prev.slug}`}
|
||||
className={cn(
|
||||
buttonVariants({ variant: "outline", size: "sm" }),
|
||||
"gap-1.5",
|
||||
)}
|
||||
>
|
||||
<ChevronLeft className="size-4" />
|
||||
<span className="hidden sm:inline truncate max-w-[180px]">
|
||||
{prev.name}
|
||||
</span>
|
||||
<span className="sm:hidden">Previous</span>
|
||||
</Link>
|
||||
) : (
|
||||
<div />
|
||||
)}
|
||||
|
||||
{next ? (
|
||||
<Link
|
||||
href={`/transcriptions/${next.slug}`}
|
||||
className={cn(
|
||||
buttonVariants({ variant: "outline", size: "sm" }),
|
||||
"gap-1.5",
|
||||
)}
|
||||
>
|
||||
<span className="hidden sm:inline truncate max-w-[180px]">
|
||||
{next.name}
|
||||
</span>
|
||||
<span className="sm:hidden">Next</span>
|
||||
<ChevronRight className="size-4" />
|
||||
</Link>
|
||||
) : (
|
||||
<div />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
useAudioPlayer,
|
||||
type AudioTrack,
|
||||
AudioWaveAnimation,
|
||||
} from "@/components/audio-player";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Play } from "lucide-react";
|
||||
|
||||
type PlayAudioButtonProps = {
|
||||
audioUrl: string;
|
||||
title: string;
|
||||
subtitle?: string;
|
||||
sourceUrl: string;
|
||||
type?: string;
|
||||
className?: string;
|
||||
/** Compact icon-only variant for list items */
|
||||
variant?: "default" | "compact";
|
||||
};
|
||||
|
||||
export function PlayAudioButton({
|
||||
audioUrl,
|
||||
title,
|
||||
subtitle,
|
||||
sourceUrl,
|
||||
type,
|
||||
className,
|
||||
variant = "default",
|
||||
}: PlayAudioButtonProps) {
|
||||
const { currentTrack, isPlaying, play, togglePlayPause } = useAudioPlayer();
|
||||
|
||||
const isThisTrack = currentTrack?.url === audioUrl;
|
||||
const isThisPlaying = isThisTrack && isPlaying;
|
||||
|
||||
function handleClick() {
|
||||
if (isThisTrack) {
|
||||
togglePlayPause();
|
||||
} else {
|
||||
const track: AudioTrack = {
|
||||
url: audioUrl,
|
||||
title,
|
||||
subtitle,
|
||||
sourceUrl,
|
||||
type: type ?? "lecture",
|
||||
};
|
||||
play(track);
|
||||
}
|
||||
}
|
||||
|
||||
if (variant === "compact") {
|
||||
return (
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
handleClick();
|
||||
}}
|
||||
className={cn(
|
||||
"flex items-center justify-center size-8 rounded-full transition-all shrink-0 cursor-pointer!",
|
||||
isThisPlaying
|
||||
? "bg-primary text-primary-foreground shadow-sm"
|
||||
: "bg-primary/10 text-primary hover:bg-primary/20",
|
||||
className,
|
||||
)}
|
||||
title={isThisPlaying ? "Pause" : "Play audio"}
|
||||
aria-label={isThisPlaying ? "Pause" : "Play audio"}
|
||||
>
|
||||
{isThisPlaying ? (
|
||||
<AudioWaveAnimation
|
||||
paused={false}
|
||||
colorClassName="bg-primary-foreground"
|
||||
size="sm"
|
||||
/>
|
||||
) : (
|
||||
<Play className="size-3.5 ml-0.5" />
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<button
|
||||
onClick={handleClick}
|
||||
className={cn(
|
||||
"inline-flex cursor-pointer items-center gap-2 px-5 py-2.5 rounded-lg text-sm font-semibold transition-all shadow-sm",
|
||||
isThisPlaying
|
||||
? "bg-primary text-primary-foreground shadow-md"
|
||||
: "bg-primary text-primary-foreground hover:bg-primary/90 hover:shadow-md",
|
||||
className,
|
||||
)}
|
||||
aria-label={isThisPlaying ? "Pause audio" : "Play audio"}
|
||||
>
|
||||
{isThisPlaying ? (
|
||||
<>
|
||||
<AudioWaveAnimation
|
||||
paused={false}
|
||||
colorClassName="bg-primary-foreground"
|
||||
size="sm"
|
||||
/>
|
||||
<span>Now Playing</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Play className="size-4" />
|
||||
<span>Play Audio</span>
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,506 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useMemo, useRef } from "react";
|
||||
import Link from "next/link";
|
||||
import { usePathname, useSearchParams } from "next/navigation";
|
||||
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, Menu, Calendar, Clock, MapPin } from "lucide-react";
|
||||
import { PlayAudioButton } from "@/app/(app)/(reader)/transcriptions/[slug]/play-audio-button";
|
||||
import { parseChapter, parseVerse } from "@/lib/book-reference";
|
||||
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[];
|
||||
audioUrl: string | null;
|
||||
};
|
||||
|
||||
type ChapterGroup = {
|
||||
key: string;
|
||||
label: string;
|
||||
transcriptions: TranscriptionItem[];
|
||||
};
|
||||
|
||||
// A section is a canto (SB) or līlā (CC). Books without that level produce a
|
||||
// single section with a null label (rendered without a heading).
|
||||
type SectionGroup = {
|
||||
key: string;
|
||||
label: string | null;
|
||||
chapters: ChapterGroup[];
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Grouping
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// Group all of a book's transcripts into sections (canto/līlā) → chapters,
|
||||
// ordered by the parsed sort tuple. Books without a canto/līlā level collapse
|
||||
// into a single section with a null label. Only sections/chapters that have
|
||||
// transcripts appear. Transcripts arrive already sorted chronologically.
|
||||
function buildSections(
|
||||
transcriptions: TranscriptionItem[],
|
||||
code: string,
|
||||
): SectionGroup[] {
|
||||
const sections = new Map<
|
||||
string,
|
||||
{
|
||||
label: string | null;
|
||||
order: number;
|
||||
chapters: Map<
|
||||
string,
|
||||
{ label: string; sort: [number, number]; items: TranscriptionItem[] }
|
||||
>;
|
||||
}
|
||||
>();
|
||||
|
||||
for (const t of transcriptions) {
|
||||
const ch = parseChapter(code, t.reference);
|
||||
const secKey = ch.section ? ch.section.key : "__flat__";
|
||||
if (!sections.has(secKey)) {
|
||||
sections.set(secKey, {
|
||||
label: ch.section ? ch.section.label : null,
|
||||
order: ch.section ? ch.section.order : 0,
|
||||
chapters: new Map(),
|
||||
});
|
||||
}
|
||||
const sec = sections.get(secKey)!;
|
||||
if (!sec.chapters.has(ch.key)) {
|
||||
sec.chapters.set(ch.key, { label: ch.label, sort: ch.sort, items: [] });
|
||||
}
|
||||
sec.chapters.get(ch.key)!.items.push(t);
|
||||
}
|
||||
|
||||
return Array.from(sections.entries())
|
||||
.sort((a, b) => a[1].order - b[1].order)
|
||||
.map(([key, s]) => ({
|
||||
key,
|
||||
label: s.label,
|
||||
chapters: Array.from(s.chapters.entries())
|
||||
.sort(
|
||||
(a, b) => a[1].sort[0] - b[1].sort[0] || a[1].sort[1] - b[1].sort[1],
|
||||
)
|
||||
.map(([k, v]) => ({
|
||||
key: k,
|
||||
label: v.label,
|
||||
// Order lectures by verse ascending (e.g. 2.1, 2.3, 2.4…),
|
||||
// irrespective of year. Stable sort keeps year order within a verse.
|
||||
transcriptions: v.items
|
||||
.slice()
|
||||
.sort((a, b) => parseVerse(a.reference) - parseVerse(b.reference)),
|
||||
})),
|
||||
}));
|
||||
}
|
||||
|
||||
// Flatten sections into a linear chapter list for prev/next navigation and
|
||||
// active-chapter lookup, carrying each chapter's section label for display.
|
||||
type FlatChapter = { sectionLabel: string | null; chapter: ChapterGroup };
|
||||
|
||||
function flattenChapters(sections: SectionGroup[]): FlatChapter[] {
|
||||
return sections.flatMap((s) =>
|
||||
s.chapters.map((chapter) => ({ sectionLabel: s.label, chapter })),
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Chapter nav list (shared by sidebar + mobile drawer)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function ChapterNavList({
|
||||
sections,
|
||||
activeKey,
|
||||
onSelect,
|
||||
}: {
|
||||
sections: SectionGroup[];
|
||||
activeKey: string;
|
||||
onSelect: (key: string) => void;
|
||||
}) {
|
||||
return (
|
||||
<nav className="flex flex-col gap-3">
|
||||
{sections.map((section) => (
|
||||
<div key={section.key} className="flex flex-col gap-0.5">
|
||||
{section.label && (
|
||||
<div className="px-2 pt-1 pb-0.5 text-xs font-bold uppercase tracking-wider text-muted-foreground/80">
|
||||
{section.label}
|
||||
</div>
|
||||
)}
|
||||
{section.chapters.map((ch) => {
|
||||
const isActive = ch.key === activeKey;
|
||||
return (
|
||||
<button
|
||||
key={ch.key}
|
||||
onClick={() => onSelect(ch.key)}
|
||||
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">{ch.label}</span>
|
||||
<span
|
||||
className={cn(
|
||||
"text-xs tabular-nums shrink-0",
|
||||
isActive ? "text-primary/70" : "text-muted-foreground/60",
|
||||
)}
|
||||
>
|
||||
{ch.transcriptions.length}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
))}
|
||||
</nav>
|
||||
);
|
||||
}
|
||||
|
||||
function ChapterSidebar(props: {
|
||||
sections: SectionGroup[];
|
||||
activeKey: string;
|
||||
onSelect: (key: string) => void;
|
||||
}) {
|
||||
return (
|
||||
<aside className="hidden lg:block w-60 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">
|
||||
Chapters
|
||||
</h2>
|
||||
<ChapterNavList {...props} />
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
|
||||
function ChapterMobileNav({
|
||||
sections,
|
||||
activeKey,
|
||||
onSelect,
|
||||
title,
|
||||
}: {
|
||||
sections: SectionGroup[];
|
||||
activeKey: string;
|
||||
onSelect: (key: string) => 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">Chapters</span>
|
||||
</DrawerTrigger>
|
||||
<DrawerContent direction="left" className="h-full">
|
||||
<DrawerHeader className="text-left border-b border-border pb-4 mb-2">
|
||||
<DrawerTitle>{title}</DrawerTitle>
|
||||
</DrawerHeader>
|
||||
<div className="overflow-y-auto px-4 pb-6 pt-2">
|
||||
<ChapterNavList
|
||||
sections={sections}
|
||||
activeKey={activeKey}
|
||||
onSelect={(key) => {
|
||||
onSelect(key);
|
||||
setOpen(false);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</DrawerContent>
|
||||
</Drawer>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Prev / Next nav
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function ChapterNav({
|
||||
prev,
|
||||
next,
|
||||
sections,
|
||||
activeKey,
|
||||
onSelect,
|
||||
drawerTitle,
|
||||
}: {
|
||||
prev: FlatChapter | null;
|
||||
next: FlatChapter | null;
|
||||
sections: SectionGroup[];
|
||||
activeKey: string;
|
||||
onSelect: (key: string) => void;
|
||||
drawerTitle: string;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<ChapterMobileNav
|
||||
sections={sections}
|
||||
activeKey={activeKey}
|
||||
onSelect={onSelect}
|
||||
title={drawerTitle}
|
||||
/>
|
||||
|
||||
{prev ? (
|
||||
<button
|
||||
onClick={() => onSelect(prev.chapter.key)}
|
||||
className={cn(
|
||||
buttonVariants({ variant: "outline", size: "sm" }),
|
||||
"gap-1.5",
|
||||
)}
|
||||
>
|
||||
<ChevronLeft className="size-4" />
|
||||
<span className="hidden sm:inline truncate max-w-[180px]">
|
||||
{prev.chapter.label}
|
||||
</span>
|
||||
<span className="sm:hidden">Previous</span>
|
||||
</button>
|
||||
) : (
|
||||
<div className="hidden lg:block" />
|
||||
)}
|
||||
</div>
|
||||
|
||||
{next ? (
|
||||
<button
|
||||
onClick={() => onSelect(next.chapter.key)}
|
||||
className={cn(
|
||||
buttonVariants({ variant: "outline", size: "sm" }),
|
||||
"gap-1.5",
|
||||
)}
|
||||
>
|
||||
<span className="hidden sm:inline truncate max-w-[180px]">
|
||||
{next.chapter.label}
|
||||
</span>
|
||||
<span className="sm:hidden">Next</span>
|
||||
<ChevronRight className="size-4" />
|
||||
</button>
|
||||
) : (
|
||||
<div />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Lecture row
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function LectureRow({ t }: { t: TranscriptionItem }) {
|
||||
return (
|
||||
<Link
|
||||
href={`/transcriptions/${t.slug}`}
|
||||
className="flex items-center px-5 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 flex-wrap items-center gap-x-3 gap-y-1 mt-1 text-xs text-muted-foreground">
|
||||
{t.date && (
|
||||
<span className="flex items-center gap-1">
|
||||
<Calendar className="size-3" />
|
||||
{t.date}
|
||||
</span>
|
||||
)}
|
||||
{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>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Main component
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function TranscriptionBookBrowser({
|
||||
transcriptions,
|
||||
code,
|
||||
title,
|
||||
totalCount,
|
||||
}: {
|
||||
transcriptions: TranscriptionItem[];
|
||||
code: string;
|
||||
title: string;
|
||||
totalCount: number;
|
||||
}) {
|
||||
const sections = useMemo(
|
||||
() => buildSections(transcriptions, code),
|
||||
[transcriptions, code],
|
||||
);
|
||||
|
||||
const flat = useMemo(() => flattenChapters(sections), [sections]);
|
||||
|
||||
// The active chapter lives in the URL (?chapter=<key>) so it's bookmarkable
|
||||
// and survives back-navigation: opening a lecture then pressing Back returns
|
||||
// to the chapter you were on rather than resetting to the first one. We seed
|
||||
// local state from the URL on mount and keep the URL in sync on selection.
|
||||
const pathname = usePathname();
|
||||
const searchParams = useSearchParams();
|
||||
|
||||
const [activeKeyRaw, setActiveKey] = useState<string>(() => {
|
||||
const fromUrl = searchParams.get("chapter");
|
||||
if (fromUrl && flat.find((f) => f.chapter.key === fromUrl)) return fromUrl;
|
||||
return flat.length > 0 ? flat[0].chapter.key : "";
|
||||
});
|
||||
|
||||
// Derive a safe active chapter key (the data could change underneath us).
|
||||
const activeKey = useMemo(() => {
|
||||
if (flat.length === 0) return activeKeyRaw;
|
||||
if (flat.find((f) => f.chapter.key === activeKeyRaw)) return activeKeyRaw;
|
||||
return flat[0].chapter.key;
|
||||
}, [flat, activeKeyRaw]);
|
||||
|
||||
const contentRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const handleSelect = (key: string) => {
|
||||
setActiveKey(key);
|
||||
// Reflect the choice in the URL without a new history entry (so Back leaves
|
||||
// the page rather than cycling chapters) or a server round-trip.
|
||||
const params = new URLSearchParams(Array.from(searchParams.entries()));
|
||||
params.set("chapter", key);
|
||||
window.history.replaceState(null, "", `${pathname}?${params.toString()}`);
|
||||
contentRef.current?.scrollIntoView({ behavior: "smooth", block: "start" });
|
||||
};
|
||||
|
||||
const activeIdx = flat.findIndex((f) => f.chapter.key === activeKey);
|
||||
const active = activeIdx >= 0 ? flat[activeIdx] : null;
|
||||
const prev = activeIdx > 0 ? flat[activeIdx - 1] : null;
|
||||
const next =
|
||||
activeIdx >= 0 && activeIdx < flat.length - 1 ? flat[activeIdx + 1] : null;
|
||||
|
||||
const activeFullLabel = active
|
||||
? active.sectionLabel
|
||||
? `${active.sectionLabel} · ${active.chapter.label}`
|
||||
: active.chapter.label
|
||||
: "";
|
||||
const activeCount = active?.chapter.transcriptions.length ?? 0;
|
||||
|
||||
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">
|
||||
<Link
|
||||
href="/transcriptions?view=book"
|
||||
className="text-sm text-muted-foreground hover:text-foreground transition-colors"
|
||||
>
|
||||
← Transcriptions by Book
|
||||
</Link>
|
||||
<h1 className="mt-3 text-2xl font-bold text-foreground">
|
||||
<span className="text-primary">{title}</span>
|
||||
{activeFullLabel ? `: ${activeFullLabel}` : ""}
|
||||
</h1>
|
||||
<p className="mt-1 text-muted-foreground">
|
||||
{activeCount} {activeCount === 1 ? "lecture" : "lectures"} in this
|
||||
chapter · {totalCount} total
|
||||
</p>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{/* Sidebar + Main */}
|
||||
<div className="max-w-5xl mx-auto flex">
|
||||
{/* Left sidebar — chapters */}
|
||||
<ChapterSidebar
|
||||
sections={sections}
|
||||
activeKey={activeKey}
|
||||
onSelect={handleSelect}
|
||||
/>
|
||||
|
||||
{/* Main content */}
|
||||
<main ref={contentRef} className="flex-1 min-w-0 px-6 py-8 space-y-8">
|
||||
{/* Top navigation */}
|
||||
<ChapterNav
|
||||
prev={prev}
|
||||
next={next}
|
||||
sections={sections}
|
||||
activeKey={activeKey}
|
||||
onSelect={handleSelect}
|
||||
drawerTitle={title}
|
||||
/>
|
||||
|
||||
{/* Content — every lecture of the active chapter (all years/places) */}
|
||||
{!active || active.chapter.transcriptions.length === 0 ? (
|
||||
<div className="py-12 text-center text-muted-foreground">
|
||||
<p className="text-lg font-medium">
|
||||
No transcriptions for this chapter
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<Card className="overflow-hidden">
|
||||
<CardContent className="p-0 divide-y divide-border">
|
||||
{active.chapter.transcriptions.map((t) => (
|
||||
<LectureRow key={t.slug} t={t} />
|
||||
))}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Bottom navigation */}
|
||||
<ChapterNav
|
||||
prev={prev}
|
||||
next={next}
|
||||
sections={sections}
|
||||
activeKey={activeKey}
|
||||
onSelect={handleSelect}
|
||||
drawerTitle={title}
|
||||
/>
|
||||
</main>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
import type { Metadata } from "next";
|
||||
import { notFound } from "next/navigation";
|
||||
import { getTranscriptionsByBook } from "@/lib/transcriptions";
|
||||
import { bookTitle } from "@/lib/book-reference";
|
||||
import { buildOgMeta } from "@/lib/og";
|
||||
import { TranscriptionBookBrowser } from "./book-browser";
|
||||
|
||||
export async function generateMetadata({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ book: string }>;
|
||||
}): Promise<Metadata> {
|
||||
const { book } = await params;
|
||||
const title = bookTitle(book);
|
||||
if (!title) return {};
|
||||
const transcriptions = await getTranscriptionsByBook(book.toUpperCase());
|
||||
return buildOgMeta({
|
||||
title: "Transcriptions",
|
||||
detail: `${transcriptions.length} lectures on ${title}`,
|
||||
quote: `Śrīla Prabhupāda's lectures on ${title}, browsable by chapter — the living voice of the Founder-Ācārya preserved in text.`,
|
||||
});
|
||||
}
|
||||
|
||||
export default async function TranscriptionsByBookPage({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ book: string }>;
|
||||
}) {
|
||||
const { book } = await params;
|
||||
const code = book.toUpperCase();
|
||||
const title = bookTitle(code);
|
||||
if (!title) notFound();
|
||||
|
||||
const transcriptions = await getTranscriptionsByBook(code);
|
||||
if (transcriptions.length === 0) notFound();
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-background">
|
||||
<TranscriptionBookBrowser
|
||||
transcriptions={transcriptions}
|
||||
code={code}
|
||||
title={title}
|
||||
totalCount={transcriptions.length}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
import type { Metadata } from "next";
|
||||
import Link from "next/link";
|
||||
import { PageHeader } from "@/components/layout/page-header";
|
||||
import { ogForTranscriptions } from "@/lib/og";
|
||||
import {
|
||||
getTranscriptionYears,
|
||||
getTranscriptionPlaces,
|
||||
getTranscriptionBooks,
|
||||
getTotalTranscriptionCount,
|
||||
getTranscriptionIndex,
|
||||
} from "@/lib/transcriptions";
|
||||
import { TranscriptionsNav } from "./transcriptions-nav";
|
||||
import { TranscriptionsBrowser } from "./transcriptions-browser";
|
||||
|
||||
export function generateMetadata(): Metadata {
|
||||
return ogForTranscriptions();
|
||||
}
|
||||
|
||||
export default async function TranscriptionsPage({
|
||||
searchParams,
|
||||
}: {
|
||||
searchParams: Promise<{ view?: string }>;
|
||||
}) {
|
||||
const { view = "year" } = await searchParams;
|
||||
|
||||
const [years, places, books, totalCount, index] = await Promise.all([
|
||||
getTranscriptionYears(),
|
||||
getTranscriptionPlaces(),
|
||||
getTranscriptionBooks(),
|
||||
getTotalTranscriptionCount(),
|
||||
getTranscriptionIndex(),
|
||||
]);
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-background">
|
||||
<PageHeader>
|
||||
<Link
|
||||
href="/"
|
||||
className="text-sm text-muted-foreground hover:text-foreground transition-colors"
|
||||
>
|
||||
← Home
|
||||
</Link>
|
||||
<h1 className="mt-3 text-3xl font-bold text-foreground">
|
||||
Transcriptions
|
||||
</h1>
|
||||
<p className="mt-1 text-muted-foreground">
|
||||
{totalCount.toLocaleString()} lectures & conversations of
|
||||
Śrīla Prabhupāda (1966–1977)
|
||||
</p>
|
||||
</PageHeader>
|
||||
|
||||
<main className="max-w-4xl mx-auto px-6 py-8 space-y-6">
|
||||
<TranscriptionsNav currentView={view} />
|
||||
|
||||
<TranscriptionsBrowser
|
||||
currentView={view}
|
||||
years={years}
|
||||
places={places}
|
||||
books={books}
|
||||
lectureCount={index.typeDistribution.lecture}
|
||||
conversationCount={index.typeDistribution.conversation}
|
||||
/>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
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/layout/page-header";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Calendar, Clock } from "lucide-react";
|
||||
import { PlayAudioButton } from "@/app/(app)/(reader)/transcriptions/[slug]/play-audio-button";
|
||||
|
||||
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"
|
||||
>
|
||||
← 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 items-center px-5 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 flex-wrap items-center gap-x-3 gap-y-1 mt-1 text-xs text-muted-foreground">
|
||||
{t.date && (
|
||||
<span className="flex items-center gap-1">
|
||||
<Calendar className="size-3" />
|
||||
{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>
|
||||
{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>
|
||||
))}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,505 @@
|
||||
"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 };
|
||||
export type BookGroup = { code: string; title: 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 transcripts 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 transcripts 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;
|
||||
const PAGE_SIZE_BOOKS = 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));
|
||||
}
|
||||
}
|
||||
|
||||
function sortBooks(data: BookGroup[], mode: SortMode): BookGroup[] {
|
||||
const sorted = [...data];
|
||||
switch (mode) {
|
||||
case "count":
|
||||
return sorted.sort((a, b) => b.count - a.count);
|
||||
case "asc":
|
||||
return sorted.sort((a, b) => a.title.localeCompare(b.title));
|
||||
case "desc":
|
||||
return sorted.sort((a, b) => b.title.localeCompare(a.title));
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Component
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function TranscriptionsBrowser({
|
||||
currentView,
|
||||
years,
|
||||
places,
|
||||
books,
|
||||
lectureCount,
|
||||
conversationCount,
|
||||
}: {
|
||||
currentView: string;
|
||||
years: YearGroup[];
|
||||
places: PlaceGroup[];
|
||||
books: BookGroup[];
|
||||
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 [sortBook, setSortBook] = useState<SortMode>("count");
|
||||
|
||||
const handleQueryChange = (q: string) => {
|
||||
setQuery(q);
|
||||
setPage(1);
|
||||
};
|
||||
|
||||
const handleSortChange = (mode: SortMode) => {
|
||||
if (currentView === "year") setSortYear(mode);
|
||||
else if (currentView === "book") setSortBook(mode);
|
||||
else setSortPlace(mode);
|
||||
setPage(1);
|
||||
};
|
||||
|
||||
const currentSort =
|
||||
currentView === "year"
|
||||
? sortYear
|
||||
: currentView === "book"
|
||||
? sortBook
|
||||
: 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]);
|
||||
|
||||
const filteredBooks = useMemo(() => {
|
||||
let data = books;
|
||||
if (query.trim()) {
|
||||
const q = normalize(query.trim());
|
||||
data = data.filter((bg) => normalize(bg.title).includes(q));
|
||||
}
|
||||
return sortBooks(data, sortBook);
|
||||
}, [books, query, sortBook]);
|
||||
|
||||
// --- Paginated data ---
|
||||
|
||||
const activeData =
|
||||
currentView === "year"
|
||||
? filteredYears
|
||||
: currentView === "book"
|
||||
? filteredBooks
|
||||
: filteredPlaces;
|
||||
|
||||
const pageSize =
|
||||
currentView === "year"
|
||||
? PAGE_SIZE_YEARS
|
||||
: currentView === "book"
|
||||
? PAGE_SIZE_BOOKS
|
||||
: 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..."
|
||||
: currentView === "book"
|
||||
? "Search books..."
|
||||
: "Search places...";
|
||||
|
||||
const resultCount = activeData.length;
|
||||
const totalCount =
|
||||
currentView === "year"
|
||||
? years.length
|
||||
: currentView === "book"
|
||||
? books.length
|
||||
: places.length;
|
||||
|
||||
const noun =
|
||||
currentView === "year"
|
||||
? "years"
|
||||
: currentView === "book"
|
||||
? "books"
|
||||
: "places";
|
||||
|
||||
// --- 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 &
|
||||
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 & 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} ${noun}`}
|
||||
</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>
|
||||
)}
|
||||
|
||||
{/* Book view */}
|
||||
{currentView === "book" && (
|
||||
<div className="grid grid-cols-2 sm:grid-cols-3 gap-3">
|
||||
{(filteredBooks.slice(startIdx, endIdx) as BookGroup[]).map((bg) => (
|
||||
<Link
|
||||
key={bg.code}
|
||||
href={`/transcriptions/book/${bg.code}`}
|
||||
>
|
||||
<Card className="transition-all hover:shadow-md hover:ring-foreground/20">
|
||||
<CardContent className="py-6 px-4 text-center">
|
||||
<p className="font-semibold text-foreground leading-tight">
|
||||
{bg.title}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
{bg.count}{" "}
|
||||
{bg.count === 1 ? "transcription" : "transcriptions"}
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Pagination */}
|
||||
<Pagination
|
||||
page={safePage}
|
||||
totalPages={totalPages}
|
||||
onPageChange={setPage}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { BookOpen, Calendar, MapPin, Mic } from "lucide-react";
|
||||
|
||||
const views = [
|
||||
{ key: "year", label: "Year", icon: Calendar },
|
||||
{ key: "place", label: "Place", icon: MapPin },
|
||||
{ key: "book", label: "Books", icon: BookOpen },
|
||||
{ key: "type", label: "Type", icon: Mic },
|
||||
];
|
||||
|
||||
export function TranscriptionsNav({ currentView }: { currentView: string }) {
|
||||
return (
|
||||
<div className="flex gap-2 border-b border-border pb-3 overflow-x-auto [scrollbar-width:none] [-ms-overflow-style:none] [&::-webkit-scrollbar]:hidden">
|
||||
{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 shrink-0 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>
|
||||
);
|
||||
}
|
||||
@@ -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,567 @@
|
||||
"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 { cn } from "@/lib/utils";
|
||||
import {
|
||||
Calendar,
|
||||
ChevronLeft,
|
||||
ChevronRight,
|
||||
Menu,
|
||||
Clock,
|
||||
MapPin,
|
||||
} from "lucide-react";
|
||||
import { PlayAudioButton } from "@/app/(app)/(reader)/transcriptions/[slug]/play-audio-button";
|
||||
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[];
|
||||
audioUrl: string | null;
|
||||
};
|
||||
|
||||
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 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 flex-wrap items-center gap-x-3 gap-y-1 mt-1 text-xs text-muted-foreground">
|
||||
{t.date && (
|
||||
<span className="flex items-center gap-1">
|
||||
<Calendar className="size-3" />
|
||||
{t.date}
|
||||
</span>
|
||||
)}
|
||||
{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>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Main component
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function TranscriptionTypeBrowser({
|
||||
transcriptions,
|
||||
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">
|
||||
<Link
|
||||
href="/transcriptions?view=type"
|
||||
className="text-sm text-muted-foreground hover:text-foreground transition-colors"
|
||||
>
|
||||
← 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>
|
||||
</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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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} lectures & conversations from ${year}`,
|
||||
quote: `Lectures and conversations of Śrīla Prabhupāda from ${year} — the living voice of the Founder-Ācārya preserved in text.`,
|
||||
});
|
||||
}
|
||||
|
||||
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,222 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useMemo } from "react";
|
||||
import Link from "next/link";
|
||||
import { PageHeader } from "@/components/layout/page-header";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Calendar, ChevronRight, Clock, MapPin } from "lucide-react";
|
||||
import { PlayAudioButton } from "@/app/(app)/(reader)/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({
|
||||
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 flex-wrap items-center gap-x-3 gap-y-1 mt-1 text-xs text-muted-foreground">
|
||||
{t.date && (
|
||||
<span className="flex items-center gap-1">
|
||||
<Calendar className="size-3" />
|
||||
{t.date}
|
||||
</span>
|
||||
)}
|
||||
{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"
|
||||
>
|
||||
← 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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user