452 lines
13 KiB
TypeScript
452 lines
13 KiB
TypeScript
"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"
|
|
>
|
|
← 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>
|
|
</>
|
|
);
|
|
}
|