feat: refine letters UI

This commit is contained in:
gosangam
2026-05-05 01:08:19 +05:30
parent 545a7e2020
commit ea1914b283
103 changed files with 1177 additions and 286 deletions
+1 -1
View File
@@ -42,7 +42,7 @@ export default async function LetterPage({
</p>
</PageHeader>
<main className="max-w-3xl mx-auto px-6 py-8 space-y-8">
<main className="max-w-5xl mx-auto px-6 py-8 space-y-8">
{/* Top nav */}
<LetterNav prev={prev} next={next} />
+536
View File
@@ -0,0 +1,536 @@
"use client";
import { useState, useMemo, useRef, useEffect } from "react";
import Link from "next/link";
import { Card, CardContent } from "@/components/ui/card";
import { buttonVariants } from "@/components/ui/button";
import { ReadingSettings } from "@/components/reading-settings";
import { cn } from "@/lib/utils";
import { ChevronLeft, ChevronRight, Menu } from "lucide-react";
import {
Drawer,
DrawerClose,
DrawerContent,
DrawerHeader,
DrawerTitle,
DrawerTrigger,
} from "@/components/ui/drawer";
// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------
export type LetterItem = {
slug: string;
recipientDisplay: string;
date: string | null;
year: number | null;
month: number | null;
location: string | null;
};
type YearGroup = {
year: number;
label: string;
count: number;
letters: LetterItem[];
};
type MonthGroup = {
month: number;
label: string;
letters: LetterItem[];
};
// ---------------------------------------------------------------------------
// Constants
// ---------------------------------------------------------------------------
const MONTH_NAMES = [
"",
"January",
"February",
"March",
"April",
"May",
"June",
"July",
"August",
"September",
"October",
"November",
"December",
];
// ---------------------------------------------------------------------------
// Grouping helpers
// ---------------------------------------------------------------------------
function buildYearGroups(letters: LetterItem[]): YearGroup[] {
const map = new Map<number, LetterItem[]>();
for (const letter of letters) {
const y = letter.year ?? 0;
if (!map.has(y)) map.set(y, []);
map.get(y)!.push(letter);
}
const groups: YearGroup[] = [];
// Sorted years first, then undated (0) at the end
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,
letters: items,
});
}
const undated = map.get(0);
if (undated && undated.length > 0) {
groups.push({
year: 0,
label: "Undated",
count: undated.length,
letters: undated,
});
}
return groups;
}
function buildMonthGroups(letters: LetterItem[]): MonthGroup[] {
const map = new Map<number, LetterItem[]>();
for (const letter of letters) {
const m =
letter.month && letter.month >= 1 && letter.month <= 12
? letter.month
: 0;
if (!map.has(m)) map.set(m, []);
map.get(m)!.push(letter);
}
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], letters: items });
}
}
const unknown = map.get(0);
if (unknown && unknown.length > 0) {
groups.push({ month: 0, label: "Unknown Month", letters: unknown });
}
return groups;
}
// ---------------------------------------------------------------------------
// Year Sidebar — matches book chapter 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 — matches MobileNav
// ---------------------------------------------------------------------------
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 — matches BbdNav
// ---------------------------------------------------------------------------
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 letters 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.letters.length}{" "}
{group.letters.length === 1 ? "letter" : "letters"}
</span>
</button>
{open && (
<ul className="divide-y divide-border">
{group.letters.map((letter) => (
<li key={letter.slug}>
<Link
href={`/letters/${letter.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">
<p className="font-medium text-foreground text-sm">
To: {letter.recipientDisplay}
</p>
<p className="text-xs text-muted-foreground mt-0.5">
{letter.date || "Undated"}
{letter.location ? ` · ${letter.location}` : ""}
</p>
</div>
</Link>
</li>
))}
</ul>
)}
</div>
);
}
// ---------------------------------------------------------------------------
// Main component — header + sidebar (years) + content (month-grouped letters)
//
// Props:
// letters — all letters for this filter (recipient or location)
// filterName — display name for the filter ("Mukunda", "New York", etc.)
// filterType — "recipient" | "location" (used for back link and title)
// totalCount — total letter count
// ---------------------------------------------------------------------------
export function FilterYearBrowser({
letters,
filterName,
filterType,
totalCount,
}: {
letters: LetterItem[];
filterName: string;
filterType: "recipient" | "location";
totalCount: number;
}) {
const yearGroups = useMemo(() => buildYearGroups(letters), [letters]);
const [activeYear, setActiveYear] = useState<number>(
yearGroups.length > 0 ? yearGroups[0].year : 0,
);
const contentRef = useRef<HTMLDivElement>(null);
// Keep active year valid
useEffect(() => {
if (
yearGroups.length > 0 &&
!yearGroups.find((g) => g.year === activeYear)
) {
setActiveYear(yearGroups[0].year);
}
}, [yearGroups, activeYear]);
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?.letters ?? []),
[activeGroup],
);
// Header text
const backHref =
filterType === "recipient"
? "/letters?view=recipient"
: "/letters?view=location";
const backLabel =
filterType === "recipient" ? "Letters by Recipient" : "Letters by Location";
const titlePrefix =
filterType === "recipient" ? "Letters to" : "Letters from";
const drawerTitle = `${titlePrefix} ${filterName}`;
const activeYearLabel = activeGroup?.label ?? "Unknown";
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={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">
<span className="text-primary">
{titlePrefix} {filterName}:{" "}
</span>
{activeYearLabel}
</h1>
<p className="mt-1 text-muted-foreground">
{activeGroup?.count ?? 0}{" "}
{(activeGroup?.count ?? 0) === 1 ? "letter" : "letters"} 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 — letters grouped by month */}
{monthGroups.length === 0 ? (
<div className="py-12 text-center text-muted-foreground">
<p className="text-lg font-medium">No letters 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>
</>
);
}
+16 -43
View File
@@ -1,10 +1,8 @@
export const dynamic = "force-dynamic";
import Link from "next/link";
import { notFound } from "next/navigation";
import { getLettersByLocation } from "@/lib/letters";
import { PageHeader } from "@/components/page-header";
import { Card, CardContent } from "@/components/ui/card";
import { FilterYearBrowser } from "../../filter-year-browser";
export default async function LettersByLocationPage({
params,
@@ -17,48 +15,23 @@ export default async function LettersByLocationPage({
const letters = await getLettersByLocation(name);
if (letters.length === 0) notFound();
const serialized = letters.map((l) => ({
slug: l.slug,
recipientDisplay: l.recipientDisplay,
date: l.date,
year: l.year,
month: l.month,
location: l.location,
}));
return (
<div className="min-h-screen bg-background">
<PageHeader>
<Link
href="/letters?view=location"
className="text-sm text-muted-foreground hover:text-foreground transition-colors"
>
&larr; Letters by Location
</Link>
<h1 className="mt-3 text-3xl font-bold text-foreground">
Letters from {name}
</h1>
<p className="mt-1 text-muted-foreground">
{letters.length} {letters.length === 1 ? "letter" : "letters"}
</p>
</PageHeader>
<main className="max-w-3xl mx-auto px-6 py-8">
<Card className="divide-y divide-border">
<CardContent className="p-0">
<ul className="divide-y divide-border">
{letters.map((letter) => (
<li key={letter.slug}>
<Link
href={`/letters/${letter.slug}`}
className="flex flex-col sm:flex-row sm:items-center sm:justify-between px-5 py-4 gap-1 sm:gap-4 hover:bg-muted transition-colors"
>
<div className="min-w-0">
<p className="font-medium text-foreground">
To: {letter.recipientDisplay}
</p>
<p className="text-sm text-muted-foreground">
{letter.date || "Undated"}
</p>
</div>
</Link>
</li>
))}
</ul>
</CardContent>
</Card>
</main>
<FilterYearBrowser
letters={serialized}
filterName={name}
filterType="location"
totalCount={letters.length}
/>
</div>
);
}
+16 -44
View File
@@ -1,10 +1,8 @@
export const dynamic = "force-dynamic";
import Link from "next/link";
import { notFound } from "next/navigation";
import { getLettersByRecipient } from "@/lib/letters";
import { PageHeader } from "@/components/page-header";
import { Card, CardContent } from "@/components/ui/card";
import { FilterYearBrowser } from "../../filter-year-browser";
export default async function LettersByRecipientPage({
params,
@@ -17,49 +15,23 @@ export default async function LettersByRecipientPage({
const letters = await getLettersByRecipient(name);
if (letters.length === 0) notFound();
const serialized = letters.map((l) => ({
slug: l.slug,
recipientDisplay: l.recipientDisplay,
date: l.date,
year: l.year,
month: l.month,
location: l.location,
}));
return (
<div className="min-h-screen bg-background">
<PageHeader>
<Link
href="/letters?view=recipient"
className="text-sm text-muted-foreground hover:text-foreground transition-colors"
>
&larr; Letters by Recipient
</Link>
<h1 className="mt-3 text-3xl font-bold text-foreground">
Letters to or including {name}
</h1>
<p className="mt-1 text-muted-foreground">
{letters.length} {letters.length === 1 ? "letter" : "letters"}
</p>
</PageHeader>
<main className="max-w-3xl mx-auto px-6 py-8">
<Card className="divide-y divide-border">
<CardContent className="p-0">
<ul className="divide-y divide-border">
{letters.map((letter) => (
<li key={letter.slug}>
<Link
href={`/letters/${letter.slug}`}
className="flex flex-col sm:flex-row sm:items-center sm:justify-between px-5 py-4 gap-1 sm:gap-4 hover:bg-muted transition-colors"
>
<div className="min-w-0">
<p className="font-medium text-foreground">
To: {letter.recipientDisplay}
</p>
<p className="text-sm text-muted-foreground">
{letter.date || "Undated"}
{letter.location ? ` · ${letter.location}` : ""}
</p>
</div>
</Link>
</li>
))}
</ul>
</CardContent>
</Card>
</main>
<FilterYearBrowser
letters={serialized}
filterName={name}
filterType="recipient"
totalCount={letters.length}
/>
</div>
);
}
+15 -44
View File
@@ -1,10 +1,8 @@
export const dynamic = "force-dynamic";
import Link from "next/link";
import { notFound } from "next/navigation";
import { getLettersByYear } from "@/lib/letters";
import { PageHeader } from "@/components/page-header";
import { Card, CardContent } from "@/components/ui/card";
import { YearMonthBrowser } from "./year-month-browser";
export default async function LettersByYearPage({
params,
@@ -18,49 +16,22 @@ export default async function LettersByYearPage({
const letters = await getLettersByYear(year);
if (letters.length === 0) notFound();
const serialized = letters.map((l) => ({
slug: l.slug,
recipientDisplay: l.recipientDisplay,
date: l.date,
year: l.year,
month: l.month,
location: l.location,
}));
return (
<div className="min-h-screen bg-background">
<PageHeader>
<Link
href="/letters?view=year"
className="text-sm text-muted-foreground hover:text-foreground transition-colors"
>
&larr; Letters by Year
</Link>
<h1 className="mt-3 text-3xl font-bold text-foreground">
Letters from {year}
</h1>
<p className="mt-1 text-muted-foreground">
{letters.length} {letters.length === 1 ? "letter" : "letters"}
</p>
</PageHeader>
<main className="max-w-3xl mx-auto px-6 py-8">
<Card className="divide-y divide-border">
<CardContent className="p-0">
<ul className="divide-y divide-border">
{letters.map((letter) => (
<li key={letter.slug}>
<Link
href={`/letters/${letter.slug}`}
className="flex flex-col sm:flex-row sm:items-center sm:justify-between px-5 py-4 gap-1 sm:gap-4 hover:bg-muted transition-colors"
>
<div className="min-w-0">
<p className="font-medium text-foreground">
To: {letter.recipientDisplay}
</p>
<p className="text-sm text-muted-foreground">
{letter.date}
{letter.location ? ` · ${letter.location}` : ""}
</p>
</div>
</Link>
</li>
))}
</ul>
</CardContent>
</Card>
</main>
<YearMonthBrowser
letters={serialized}
year={year}
totalCount={letters.length}
/>
</div>
);
}
@@ -0,0 +1,451 @@
"use client";
import { useState, useMemo, useRef, useEffect } from "react";
import Link from "next/link";
import { Card, CardContent } from "@/components/ui/card";
import { buttonVariants } from "@/components/ui/button";
import { ReadingSettings } from "@/components/reading-settings";
import { cn } from "@/lib/utils";
import { ChevronLeft, ChevronRight, Menu } from "lucide-react";
import {
Drawer,
DrawerClose,
DrawerContent,
DrawerHeader,
DrawerTitle,
DrawerTrigger,
} from "@/components/ui/drawer";
// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------
export type LetterItem = {
slug: string;
recipientDisplay: string;
date: string | null;
year: number | null;
month: number | null;
location: string | null;
};
type MonthGroup = {
month: number;
label: string;
shortLabel: string;
count: number;
letters: LetterItem[];
};
// ---------------------------------------------------------------------------
// Constants
// ---------------------------------------------------------------------------
const MONTH_NAMES = [
"",
"January",
"February",
"March",
"April",
"May",
"June",
"July",
"August",
"September",
"October",
"November",
"December",
];
const MONTH_SHORT = [
"",
"Jan",
"Feb",
"Mar",
"Apr",
"May",
"Jun",
"Jul",
"Aug",
"Sep",
"Oct",
"Nov",
"Dec",
];
// ---------------------------------------------------------------------------
// Build month groups from flat letter list
// ---------------------------------------------------------------------------
function buildMonthGroups(letters: LetterItem[]): MonthGroup[] {
const map = new Map<number, LetterItem[]>();
for (const letter of letters) {
const m =
letter.month && letter.month >= 1 && letter.month <= 12
? letter.month
: 0;
if (!map.has(m)) map.set(m, []);
map.get(m)!.push(letter);
}
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],
shortLabel: MONTH_SHORT[m],
count: items.length,
letters: items,
});
}
}
const unknown = map.get(0);
if (unknown && unknown.length > 0) {
groups.push({
month: 0,
label: "Unknown Month",
shortLabel: "Unknown",
count: unknown.length,
letters: unknown,
});
}
return groups;
}
// ---------------------------------------------------------------------------
// Sidebar — matches book chapter sidebar
// ---------------------------------------------------------------------------
function MonthSidebar({
groups,
activeMonth,
onSelect,
}: {
groups: MonthGroup[];
activeMonth: number;
onSelect: (month: 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">
Months
</h2>
<nav className="flex flex-col gap-0.5">
{groups.map((g) => {
const isActive = g.month === activeMonth;
return (
<button
key={g.month}
onClick={() => onSelect(g.month)}
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 — matches MobileNav
// ---------------------------------------------------------------------------
function MonthMobileNav({
groups,
activeMonth,
onSelect,
year,
}: {
groups: MonthGroup[];
activeMonth: number;
onSelect: (month: number) => void;
year: number;
}) {
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">Months</span>
</DrawerTrigger>
<DrawerContent direction="left" className="h-full">
<DrawerHeader className="text-left border-b border-border pb-4 mb-2">
<DrawerTitle>Letters from {year}</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.month === activeMonth;
return (
<DrawerClose key={g.month} asChild>
<button
onClick={() => {
onSelect(g.month);
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 — matches BbdNav
// ---------------------------------------------------------------------------
function MonthNav({
prev,
next,
groups,
activeMonth,
onSelect,
year,
}: {
prev: MonthGroup | null;
next: MonthGroup | null;
groups: MonthGroup[];
activeMonth: number;
onSelect: (month: number) => void;
year: number;
}) {
return (
<div className="flex items-center justify-between gap-3">
<div className="flex items-center gap-2">
<MonthMobileNav
groups={groups}
activeMonth={activeMonth}
onSelect={onSelect}
year={year}
/>
{prev ? (
<button
onClick={() => onSelect(prev.month)}
className={cn(
buttonVariants({ variant: "outline", size: "sm" }),
"gap-1.5",
)}
>
<ChevronLeft className="size-4" />
<span className="hidden sm:inline truncate max-w-[180px]">
{prev.shortLabel}
</span>
<span className="sm:hidden">Previous</span>
</button>
) : (
<div className="hidden lg:block" />
)}
</div>
{next ? (
<button
onClick={() => onSelect(next.month)}
className={cn(
buttonVariants({ variant: "outline", size: "sm" }),
"gap-1.5",
)}
>
<span className="hidden sm:inline truncate max-w-[180px]">
{next.shortLabel}
</span>
<span className="sm:hidden">Next</span>
<ChevronRight className="size-4" />
</button>
) : (
<div />
)}
</div>
);
}
// ---------------------------------------------------------------------------
// Main component — renders header + sidebar + content (entire page layout)
// ---------------------------------------------------------------------------
export function YearMonthBrowser({
letters,
year,
totalCount,
}: {
letters: LetterItem[];
year: number;
totalCount: number;
}) {
const allGroups = useMemo(() => buildMonthGroups(letters), [letters]);
const [activeMonth, setActiveMonth] = useState<number>(
allGroups.length > 0 ? allGroups[0].month : 1,
);
const contentRef = useRef<HTMLDivElement>(null);
// Keep active month valid
useEffect(() => {
if (
allGroups.length > 0 &&
!allGroups.find((g) => g.month === activeMonth)
) {
setActiveMonth(allGroups[0].month);
}
}, [allGroups, activeMonth]);
const handleMonthSelect = (month: number) => {
setActiveMonth(month);
contentRef.current?.scrollIntoView({ behavior: "smooth", block: "start" });
};
// Active group + neighbors
const activeIdx = allGroups.findIndex((g) => g.month === activeMonth);
const activeGroup = activeIdx >= 0 ? allGroups[activeIdx] : null;
const prev = activeIdx > 0 ? allGroups[activeIdx - 1] : null;
const next =
activeIdx >= 0 && activeIdx < allGroups.length - 1
? allGroups[activeIdx + 1]
: null;
const activeLetters = activeGroup?.letters ?? [];
const activeLabel = activeGroup?.label ?? "Unknown Month";
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="/letters?view=year"
className="text-sm text-muted-foreground hover:text-foreground transition-colors"
>
&larr; Letters by Year
</Link>
<h1 className="mt-3 text-2xl font-bold text-foreground">
<span className="text-primary">Letters: </span>
{activeLabel} {year}
</h1>
<p className="mt-1 text-muted-foreground">
{activeGroup?.count ?? 0}{" "}
{(activeGroup?.count ?? 0) === 1 ? "letter" : "letters"} ·{" "}
{totalCount} total in {year}
</p>
</div>
<div className="shrink-0 pt-1">
<ReadingSettings />
</div>
</div>
</header>
{/* Sidebar + Main */}
<div className="max-w-5xl mx-auto flex">
{/* Left sidebar */}
<MonthSidebar
groups={allGroups}
activeMonth={activeMonth}
onSelect={handleMonthSelect}
/>
{/* Main content */}
<main ref={contentRef} className="flex-1 min-w-0 px-6 py-8 space-y-8">
{/* Top navigation */}
<MonthNav
prev={prev}
next={next}
groups={allGroups}
activeMonth={activeMonth}
onSelect={handleMonthSelect}
year={year}
/>
{/* Content */}
{activeLetters.length === 0 ? (
<div className="py-12 text-center text-muted-foreground">
<p className="text-lg font-medium">No letters for this month</p>
</div>
) : (
<Card>
<CardContent className="p-0">
<ul className="divide-y divide-border">
{activeLetters.map((letter) => (
<li key={letter.slug}>
<Link
href={`/letters/${letter.slug}`}
className="flex flex-col sm:flex-row sm:items-center sm:justify-between px-5 py-4 gap-1 sm:gap-4 hover:bg-muted transition-colors"
>
<div className="min-w-0">
<p className="font-medium text-foreground">
To: {letter.recipientDisplay}
</p>
<p className="text-sm text-muted-foreground">
{letter.date || "Undated"}
{letter.location ? ` · ${letter.location}` : ""}
</p>
</div>
</Link>
</li>
))}
</ul>
</CardContent>
</Card>
)}
{/* Bottom navigation */}
<MonthNav
prev={prev}
next={next}
groups={allGroups}
activeMonth={activeMonth}
onSelect={handleMonthSelect}
year={year}
/>
</main>
</div>
</>
);
}