Files
2026-06-02 01:57:08 +05:30

444 lines
13 KiB
TypeScript

"use client";
import { useState, useMemo, useRef } from "react";
import Link from "next/link";
import { Card, CardContent } from "@/components/ui/card";
import { buttonVariants } from "@/components/ui/button";
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 [activeMonthRaw, setActiveMonth] = useState<number>(
allGroups.length > 0 ? allGroups[0].month : 1,
);
// Derive a safe active month — if the raw value is no longer valid, fall back to the first group
const activeMonth = useMemo(() => {
if (allGroups.length === 0) return activeMonthRaw;
if (allGroups.find((g) => g.month === activeMonthRaw))
return activeMonthRaw;
return allGroups[0].month;
}, [allGroups, activeMonthRaw]);
const contentRef = useRef<HTMLDivElement>(null);
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">
<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>
</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>
</>
);
}