chore: code refactor
This commit is contained in:
@@ -0,0 +1,286 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* FindInPage — in-page text search for long reading pages (transcripts etc.).
|
||||
*
|
||||
* Renders a "Find in page" trigger for a page header; while open, a floating
|
||||
* find bar with a query input, match counter, and prev/next navigation.
|
||||
* Matches are highlighted by wrapping text nodes in <mark data-find> directly
|
||||
* in the DOM, so it works against server-rendered content (including
|
||||
* dangerouslySetInnerHTML blocks) without needing the text as props.
|
||||
*
|
||||
* Matching is case- AND diacritic-insensitive — "krsna" finds "Kṛṣṇa",
|
||||
* "sanjaya" finds "Sañjaya" — essential for IAST transliteration content
|
||||
* that users can't easily type.
|
||||
*
|
||||
* The bar is portaled to <body>: the sticky PageHeader animates with a
|
||||
* transform when it collapses on scroll, which would hijack any
|
||||
* position:fixed descendant and drag the bar off-screen with it.
|
||||
*/
|
||||
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { ChevronDown, ChevronUp, Search, X } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const MIN_QUERY = 2;
|
||||
const DEBOUNCE_MS = 250;
|
||||
const HIT_CLASS = "bg-primary/20 text-inherit rounded-[2px]";
|
||||
const CURRENT_CLASS = "bg-primary text-primary-foreground rounded-[2px]";
|
||||
|
||||
/** Case + diacritic fold: "Kṛṣṇa" → "krsna". */
|
||||
function fold(s: string): string {
|
||||
return s
|
||||
.normalize("NFD")
|
||||
.replace(/[\u0300-\u036f]/g, "")
|
||||
.toLowerCase();
|
||||
}
|
||||
|
||||
/**
|
||||
* Fold a string keeping a map from each folded char back to the index of
|
||||
* the original char it came from, so match offsets translate back.
|
||||
*/
|
||||
function foldWithMap(s: string): { folded: string; map: number[] } {
|
||||
let folded = "";
|
||||
const map: number[] = [];
|
||||
for (let i = 0; i < s.length; i++) {
|
||||
const f = fold(s[i]);
|
||||
for (let j = 0; j < f.length; j++) {
|
||||
folded += f[j];
|
||||
map.push(i);
|
||||
}
|
||||
}
|
||||
return { folded, map };
|
||||
}
|
||||
|
||||
/** Unwrap every mark this component added and re-merge the text nodes. */
|
||||
function clearHighlights(container: HTMLElement) {
|
||||
container.querySelectorAll("mark[data-find]").forEach((mark) => {
|
||||
const parent = mark.parentNode;
|
||||
if (!parent) return;
|
||||
parent.replaceChild(
|
||||
document.createTextNode(mark.textContent ?? ""),
|
||||
mark,
|
||||
);
|
||||
parent.normalize();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Highlight every occurrence of `query` inside `container`, returning the
|
||||
* created marks in document order. Searches each text node independently
|
||||
* (matches spanning inline-tag boundaries are not found — acceptable for
|
||||
* prose where emphasis runs are whole words/phrases).
|
||||
*/
|
||||
function applyHighlights(container: HTMLElement, query: string): HTMLElement[] {
|
||||
const foldedQuery = fold(query);
|
||||
if (!foldedQuery) return [];
|
||||
|
||||
// Snapshot the nodes first — wrapping mutates the tree under the walker.
|
||||
const walker = document.createTreeWalker(container, NodeFilter.SHOW_TEXT);
|
||||
const textNodes: Text[] = [];
|
||||
for (let n = walker.nextNode(); n; n = walker.nextNode()) {
|
||||
textNodes.push(n as Text);
|
||||
}
|
||||
|
||||
const marks: HTMLElement[] = [];
|
||||
for (const node of textNodes) {
|
||||
const text = node.textContent ?? "";
|
||||
if (!text.trim()) continue;
|
||||
const { folded, map } = foldWithMap(text);
|
||||
|
||||
// Collect match ranges in original-string coordinates.
|
||||
const ranges: Array<[number, number]> = [];
|
||||
let from = 0;
|
||||
for (;;) {
|
||||
const at = folded.indexOf(foldedQuery, from);
|
||||
if (at === -1) break;
|
||||
const endFolded = at + foldedQuery.length;
|
||||
const start = map[at];
|
||||
const end = endFolded < map.length ? map[endFolded] : text.length;
|
||||
if (end > start) ranges.push([start, end]);
|
||||
from = endFolded;
|
||||
}
|
||||
if (ranges.length === 0) continue;
|
||||
|
||||
// Rebuild the node as text/mark fragments.
|
||||
const frag = document.createDocumentFragment();
|
||||
let cursor = 0;
|
||||
for (const [start, end] of ranges) {
|
||||
if (start > cursor) {
|
||||
frag.appendChild(document.createTextNode(text.slice(cursor, start)));
|
||||
}
|
||||
const mark = document.createElement("mark");
|
||||
mark.setAttribute("data-find", "");
|
||||
mark.className = HIT_CLASS;
|
||||
mark.textContent = text.slice(start, end);
|
||||
frag.appendChild(mark);
|
||||
marks.push(mark);
|
||||
cursor = end;
|
||||
}
|
||||
if (cursor < text.length) {
|
||||
frag.appendChild(document.createTextNode(text.slice(cursor)));
|
||||
}
|
||||
node.parentNode?.replaceChild(frag, node);
|
||||
}
|
||||
return marks;
|
||||
}
|
||||
|
||||
/** Style the active mark and bring it into view. */
|
||||
function focusMark(marks: HTMLElement[], index: number) {
|
||||
marks.forEach((m, i) => {
|
||||
m.className = i === index ? CURRENT_CLASS : HIT_CLASS;
|
||||
});
|
||||
marks[index]?.scrollIntoView({ block: "center", behavior: "smooth" });
|
||||
}
|
||||
|
||||
export function FindInPage({
|
||||
targetId,
|
||||
className,
|
||||
}: {
|
||||
/** id of the element whose text content is searched. */
|
||||
targetId: string;
|
||||
className?: string;
|
||||
}) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [query, setQuery] = useState("");
|
||||
const [count, setCount] = useState(0);
|
||||
const [current, setCurrent] = useState(0);
|
||||
const marksRef = useRef<HTMLElement[]>([]);
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const close = useCallback(() => {
|
||||
const el = document.getElementById(targetId);
|
||||
if (el) clearHighlights(el);
|
||||
marksRef.current = [];
|
||||
setOpen(false);
|
||||
setQuery("");
|
||||
setCount(0);
|
||||
setCurrent(0);
|
||||
}, [targetId]);
|
||||
|
||||
// Debounced (re)search on every query change while open.
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const id = window.setTimeout(() => {
|
||||
const el = document.getElementById(targetId);
|
||||
if (!el) return;
|
||||
clearHighlights(el);
|
||||
const q = query.trim();
|
||||
const marks = q.length >= MIN_QUERY ? applyHighlights(el, q) : [];
|
||||
marksRef.current = marks;
|
||||
setCount(marks.length);
|
||||
setCurrent(0);
|
||||
if (marks.length > 0) focusMark(marks, 0);
|
||||
}, DEBOUNCE_MS);
|
||||
return () => window.clearTimeout(id);
|
||||
}, [query, open, targetId]);
|
||||
|
||||
// Focus the input when the bar opens.
|
||||
useEffect(() => {
|
||||
if (open) inputRef.current?.focus();
|
||||
}, [open]);
|
||||
|
||||
// Drop any leftover highlights if the component unmounts mid-search.
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
const el = document.getElementById(targetId);
|
||||
if (el) clearHighlights(el);
|
||||
};
|
||||
}, [targetId]);
|
||||
|
||||
const step = useCallback(
|
||||
(dir: 1 | -1) => {
|
||||
const marks = marksRef.current;
|
||||
if (marks.length === 0) return;
|
||||
setCurrent((c) => {
|
||||
const next = (c + dir + marks.length) % marks.length;
|
||||
focusMark(marks, next);
|
||||
return next;
|
||||
});
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOpen(true)}
|
||||
aria-label="Find in page"
|
||||
className={cn(
|
||||
"inline-flex h-8 items-center gap-1.5 rounded-md border border-border bg-background px-2.5 text-sm text-muted-foreground hover:text-foreground hover:bg-muted transition-colors",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<Search className="size-4" />
|
||||
<span className="hidden sm:inline">Find in page</span>
|
||||
</button>
|
||||
|
||||
{open &&
|
||||
createPortal(
|
||||
<div className="fixed top-14 sm:top-16 inset-x-0 z-30 flex justify-center px-4 pointer-events-none">
|
||||
<div className="pointer-events-auto flex w-full max-w-md items-center gap-1 rounded-xl border border-border bg-card px-2 py-1.5 shadow-lg">
|
||||
<Search className="ml-1 size-4 shrink-0 text-muted-foreground" />
|
||||
<input
|
||||
ref={inputRef}
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") {
|
||||
e.preventDefault();
|
||||
step(e.shiftKey ? -1 : 1);
|
||||
} else if (e.key === "Escape") {
|
||||
e.preventDefault();
|
||||
close();
|
||||
}
|
||||
}}
|
||||
placeholder="Find in page…"
|
||||
aria-label="Find in page"
|
||||
// text-base (16px), not text-sm: iOS/iPadOS Safari auto-zooms
|
||||
// the page when focusing an input with a font-size below 16px.
|
||||
className="min-w-0 flex-1 bg-transparent py-1 text-base outline-none placeholder:text-muted-foreground"
|
||||
/>
|
||||
<span
|
||||
aria-live="polite"
|
||||
className="shrink-0 select-none text-xs tabular-nums text-muted-foreground"
|
||||
>
|
||||
{count > 0
|
||||
? `${current + 1}/${count}`
|
||||
: query.trim().length >= MIN_QUERY
|
||||
? "0 results"
|
||||
: ""}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => step(-1)}
|
||||
disabled={count === 0}
|
||||
aria-label="Previous match"
|
||||
className="inline-flex size-7 shrink-0 items-center justify-center rounded-md text-muted-foreground hover:bg-muted hover:text-foreground transition-colors disabled:opacity-40 disabled:pointer-events-none"
|
||||
>
|
||||
<ChevronUp className="size-4" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => step(1)}
|
||||
disabled={count === 0}
|
||||
aria-label="Next match"
|
||||
className="inline-flex size-7 shrink-0 items-center justify-center rounded-md text-muted-foreground hover:bg-muted hover:text-foreground transition-colors disabled:opacity-40 disabled:pointer-events-none"
|
||||
>
|
||||
<ChevronDown className="size-4" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={close}
|
||||
aria-label="Close find bar"
|
||||
className="inline-flex size-7 shrink-0 items-center justify-center rounded-md text-muted-foreground hover:bg-muted hover:text-foreground transition-colors"
|
||||
>
|
||||
<X className="size-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>,
|
||||
document.body,
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,191 @@
|
||||
"use client";
|
||||
|
||||
import { useRef, useEffect, useCallback } from "react";
|
||||
import { useRouter, usePathname } from "next/navigation";
|
||||
import { motion } from "framer-motion";
|
||||
import { Search, CornerDownLeft } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
/**
|
||||
* HeaderSearchButton — a search icon in the page header that expands
|
||||
* into a full search input on click. Typing a query and pressing Enter
|
||||
* (or clicking the search icon again) navigates to /search?q=...
|
||||
*
|
||||
* State is controlled by the parent (PageHeader) so the header can
|
||||
* hide/show adjacent elements (logo, reading settings) on mobile.
|
||||
*
|
||||
* Pressing Escape or clicking outside collapses the input.
|
||||
* Keyboard shortcut: "/" focuses the search from anywhere on the page.
|
||||
*/
|
||||
/** Map the current pathname to the search `type` filter value. */
|
||||
function searchTypeFromPath(pathname: string): string | null {
|
||||
if (pathname.startsWith("/books")) return "books";
|
||||
if (pathname.startsWith("/letters")) return "letters";
|
||||
if (pathname.startsWith("/essays")) return "essays";
|
||||
if (pathname.startsWith("/transcriptions")) return "transcriptions";
|
||||
if (pathname.startsWith("/glossary")) return "glossary";
|
||||
if (pathname.startsWith("/journals")) return "journals";
|
||||
if (pathname.startsWith("/magazines")) return "magazines";
|
||||
if (pathname.startsWith("/other-documents")) return "other_documents";
|
||||
return null; // homepage, search page, about, etc. → "all"
|
||||
}
|
||||
|
||||
export function HeaderSearchButton({
|
||||
expanded,
|
||||
onExpandedChange,
|
||||
}: {
|
||||
expanded: boolean;
|
||||
onExpandedChange: (expanded: boolean) => void;
|
||||
}) {
|
||||
const queryRef = useRef("");
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const router = useRouter();
|
||||
const pathname = usePathname();
|
||||
|
||||
// Focus input when expanded
|
||||
useEffect(() => {
|
||||
if (expanded && inputRef.current) {
|
||||
const timer = setTimeout(() => inputRef.current?.focus(), 150);
|
||||
return () => clearTimeout(timer);
|
||||
}
|
||||
}, [expanded]);
|
||||
|
||||
// Close on outside click
|
||||
useEffect(() => {
|
||||
if (!expanded) return;
|
||||
|
||||
function handleClick(e: MouseEvent) {
|
||||
if (
|
||||
containerRef.current &&
|
||||
!containerRef.current.contains(e.target as Node)
|
||||
) {
|
||||
onExpandedChange(false);
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener("mousedown", handleClick);
|
||||
return () => document.removeEventListener("mousedown", handleClick);
|
||||
}, [expanded, onExpandedChange]);
|
||||
|
||||
// Global "/" shortcut to open search
|
||||
useEffect(() => {
|
||||
function handleKeyDown(e: KeyboardEvent) {
|
||||
const target = e.target as HTMLElement;
|
||||
if (
|
||||
target.tagName === "INPUT" ||
|
||||
target.tagName === "TEXTAREA" ||
|
||||
target.tagName === "SELECT" ||
|
||||
target.isContentEditable
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (e.key === "/") {
|
||||
e.preventDefault();
|
||||
onExpandedChange(true);
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener("keydown", handleKeyDown);
|
||||
return () => document.removeEventListener("keydown", handleKeyDown);
|
||||
}, [onExpandedChange]);
|
||||
|
||||
const handleSubmit = useCallback(() => {
|
||||
const trimmed = queryRef.current.trim();
|
||||
const params = new URLSearchParams();
|
||||
if (trimmed) params.set("q", trimmed);
|
||||
|
||||
// Pre-select the search filter matching the current section
|
||||
const type = searchTypeFromPath(pathname);
|
||||
if (type) params.set("type", type);
|
||||
|
||||
const qs = params.toString();
|
||||
router.push(`/search${qs ? `?${qs}` : ""}`);
|
||||
onExpandedChange(false);
|
||||
queryRef.current = "";
|
||||
if (inputRef.current) inputRef.current.value = "";
|
||||
}, [router, onExpandedChange, pathname]);
|
||||
|
||||
const handleKeyDown = useCallback(
|
||||
(e: React.KeyboardEvent) => {
|
||||
if (e.key === "Enter") {
|
||||
e.preventDefault();
|
||||
handleSubmit();
|
||||
} else if (e.key === "Escape") {
|
||||
e.preventDefault();
|
||||
onExpandedChange(false);
|
||||
queryRef.current = "";
|
||||
if (inputRef.current) inputRef.current.value = "";
|
||||
}
|
||||
},
|
||||
[handleSubmit, onExpandedChange],
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={containerRef}
|
||||
className={cn(
|
||||
"relative flex items-center",
|
||||
expanded && "max-md:flex-1 max-md:min-w-0",
|
||||
)}
|
||||
>
|
||||
{expanded ? (
|
||||
<motion.div
|
||||
key="search-input"
|
||||
initial={{ width: 36, opacity: 0 }}
|
||||
animate={{ width: "100%", opacity: 1 }}
|
||||
transition={{
|
||||
width: { type: "spring", stiffness: 400, damping: 30 },
|
||||
opacity: { duration: 0.15 },
|
||||
}}
|
||||
className={cn(
|
||||
"flex items-center gap-2 h-12 md:h-10 rounded-lg border border-border bg-background px-3 overflow-hidden",
|
||||
"max-md:w-full",
|
||||
"md:max-w-100",
|
||||
)}
|
||||
>
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="text"
|
||||
defaultValue=""
|
||||
autoFocus
|
||||
onChange={(e) => {
|
||||
queryRef.current = e.target.value;
|
||||
}}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder="Search..."
|
||||
className="flex-1 bg-transparent text-md md:text-sm text-foreground placeholder:text-muted-foreground outline-none min-w-0"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleSubmit}
|
||||
className="shrink-0 inline-flex items-center gap-1.5 rounded-md bg-primary px-3 py-1.5 text-xs font-medium text-primary-foreground hover:bg-primary/90 transition-colors"
|
||||
>
|
||||
Search
|
||||
<CornerDownLeft className="size-3 opacity-70" />
|
||||
</button>
|
||||
</motion.div>
|
||||
) : (
|
||||
<motion.button
|
||||
key="search-icon"
|
||||
initial={{ opacity: 0, scale: 0.85 }}
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
transition={{ duration: 0.12 }}
|
||||
type="button"
|
||||
onClick={() => onExpandedChange(true)}
|
||||
aria-label="Search (press /)"
|
||||
title="Search (press /)"
|
||||
className={cn(
|
||||
"size-9 inline-flex items-center justify-center rounded-lg",
|
||||
"text-muted-foreground hover:text-foreground hover:bg-muted",
|
||||
"transition-colors outline-none",
|
||||
"focus-visible:ring-2 focus-visible:ring-ring",
|
||||
)}
|
||||
>
|
||||
<Search className="size-4.5" />
|
||||
</motion.button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
"use client";
|
||||
|
||||
import Image from "next/image";
|
||||
import { useTheme } from "next-themes";
|
||||
import { useSyncExternalStore } from "react";
|
||||
|
||||
const emptySubscribe = () => () => {};
|
||||
|
||||
function useMounted() {
|
||||
return useSyncExternalStore(
|
||||
emptySubscribe,
|
||||
() => true,
|
||||
() => false,
|
||||
);
|
||||
}
|
||||
|
||||
export function Logo({
|
||||
className,
|
||||
height = 48,
|
||||
}: {
|
||||
className?: string;
|
||||
height?: number;
|
||||
}) {
|
||||
const { resolvedTheme } = useTheme();
|
||||
const mounted = useMounted();
|
||||
|
||||
// Aspect ratio from the original images: 480x200 = 2.4:1
|
||||
const width = Math.round(height * 2.4);
|
||||
|
||||
// Before mount, show the dark logo (works on both themes as a fallback)
|
||||
const isDark = mounted ? resolvedTheme === "dark" : false;
|
||||
|
||||
return (
|
||||
<Image
|
||||
src={isDark ? "/logo_light.png" : "/logo_dark.png"}
|
||||
alt="Prabhupāda Says"
|
||||
width={width}
|
||||
height={height}
|
||||
className={className}
|
||||
priority
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,130 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import Link from "next/link";
|
||||
import { Menu } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { buttonVariants } from "@/components/ui/button";
|
||||
import {
|
||||
Drawer,
|
||||
DrawerClose,
|
||||
DrawerContent,
|
||||
DrawerHeader,
|
||||
DrawerTitle,
|
||||
DrawerTrigger,
|
||||
} from "@/components/ui/drawer";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Types
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export type MobileNavItem = {
|
||||
slug: string;
|
||||
label: string;
|
||||
href: string;
|
||||
type: string;
|
||||
/** For hierarchical books (SSR, DS) — parent chapter number */
|
||||
parentChapter?: number | null;
|
||||
/** Whether this is a non-clickable parent heading */
|
||||
isParent?: boolean;
|
||||
};
|
||||
|
||||
export type MobileNavGroup = {
|
||||
title?: string;
|
||||
items: MobileNavItem[];
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Component
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Mobile sidebar drawer triggered by a menu button.
|
||||
*
|
||||
* Usage: Place this component alongside the prev/next buttons in any
|
||||
* book reader page. It renders a menu icon button (visible only on
|
||||
* mobile / below `lg` breakpoint) that opens a bottom drawer with
|
||||
* the full sidebar navigation.
|
||||
*
|
||||
* Supports two modes:
|
||||
* 1. **Flat groups** — pass `groups` for simple grouped lists
|
||||
* (Front Matter, Chapters, etc.)
|
||||
* 2. **Hierarchical** — pass `groups` where some items have
|
||||
* `isParent: true` to render parent headings with indented children
|
||||
*/
|
||||
export function MobileNav({
|
||||
groups,
|
||||
currentSlug,
|
||||
bookTitle,
|
||||
}: {
|
||||
groups: MobileNavGroup[];
|
||||
currentSlug: string;
|
||||
bookTitle?: 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">Contents</span>
|
||||
</DrawerTrigger>
|
||||
<DrawerContent direction="left" className="h-full">
|
||||
<DrawerHeader className="text-left border-b border-border pb-4 mb-2">
|
||||
<DrawerTitle>{bookTitle ?? "Contents"}</DrawerTitle>
|
||||
</DrawerHeader>
|
||||
<nav className="overflow-y-auto px-4 pb-6 pt-2 space-y-4">
|
||||
{groups.map((group, gi) => (
|
||||
<div key={gi}>
|
||||
{group.title && (
|
||||
<h3 className="text-xs font-bold uppercase tracking-wider text-muted-foreground mb-2 px-2">
|
||||
{group.title}
|
||||
</h3>
|
||||
)}
|
||||
<div className="flex flex-col gap-0.5">
|
||||
{group.items.map((item, ii) => {
|
||||
if (item.isParent) {
|
||||
return (
|
||||
<div
|
||||
key={`${gi}-${ii}`}
|
||||
className="px-2 py-1.5 text-xs font-bold text-muted-foreground mt-2"
|
||||
>
|
||||
{item.label}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const isActive = item.slug === currentSlug;
|
||||
const isChild = item.parentChapter != null && !item.isParent;
|
||||
|
||||
return (
|
||||
<DrawerClose key={`${gi}-${ii}`} asChild>
|
||||
<Link
|
||||
href={item.href}
|
||||
className={cn(
|
||||
"px-2 py-2 rounded-md text-sm transition-colors truncate block",
|
||||
isChild && "pl-4",
|
||||
isActive
|
||||
? "bg-primary/10 text-primary font-medium"
|
||||
: "text-muted-foreground hover:bg-muted hover:text-foreground",
|
||||
)}
|
||||
onClick={() => setOpen(false)}
|
||||
>
|
||||
{item.label}
|
||||
</Link>
|
||||
</DrawerClose>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</nav>
|
||||
</DrawerContent>
|
||||
</Drawer>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* NotificationBell — header affordance for signed-in users. Links to the
|
||||
* /notifications page and shows a dot when there are unread items.
|
||||
*
|
||||
* The unread count is read from NotificationsProvider, which seeds it from
|
||||
* the REST endpoint and keeps it live over the Soketi WebSocket. Visiting
|
||||
* the page marks everything read and clears the badge.
|
||||
*/
|
||||
|
||||
import Link from "next/link";
|
||||
import { Bell } from "lucide-react";
|
||||
import { useNotifications } from "@/components/notifications/notifications-provider";
|
||||
|
||||
export function NotificationBell() {
|
||||
const { unread } = useNotifications();
|
||||
|
||||
return (
|
||||
<Link
|
||||
href="/notifications"
|
||||
aria-label={
|
||||
unread > 0 ? `Notifications (${unread} unread)` : "Notifications"
|
||||
}
|
||||
className="relative size-9 inline-flex items-center justify-center rounded-lg text-foreground hover:bg-muted transition-colors outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||
>
|
||||
<Bell className="size-5" />
|
||||
{unread > 0 ? (
|
||||
<span className="absolute top-1.5 right-1.5 size-2 rounded-full bg-primary ring-2 ring-background" />
|
||||
) : null}
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* PageHeader — per-page sub-header carrying the page title / breadcrumb.
|
||||
*
|
||||
* The global SiteHeader (mounted in app/layout.tsx) owns the wordmark,
|
||||
* primary nav, search, reading settings, and user menu. PageHeader is now
|
||||
* purely typographic — it renders whatever `children` the page passes and
|
||||
* applies a subtle bordered band so the page's title area has visual weight
|
||||
* separate from the body content.
|
||||
*
|
||||
* Behaviour preserved from the previous (busier) implementation:
|
||||
* • If sessionStorage has `spsays_from_search`, the first <Link> child is
|
||||
* hidden and replaced with a "← Back to search results" button. This
|
||||
* keeps the cross-page-navigation polish that the search page relies on.
|
||||
*
|
||||
* Sticky headers collapse on scroll-down and re-appear on scroll-up
|
||||
* (mobile-address-bar pattern). Long reading pages get back the vertical
|
||||
* real estate without losing the title anchor on demand. Only applies
|
||||
* when `sticky` — a non-sticky header has nothing to slide behind.
|
||||
*
|
||||
* Pages that don't want a sub-header (e.g. the redesigned homepage which
|
||||
* paints its own editorial hero) can simply not render PageHeader.
|
||||
*/
|
||||
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { useRouter, usePathname } from "next/navigation";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const SEARCH_RETURN_KEY = "spsays_from_search";
|
||||
|
||||
export function PageHeader({
|
||||
children,
|
||||
className,
|
||||
bare = false,
|
||||
sticky = false,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
/** Drop the bordered chrome — useful for pages that want flush title text. */
|
||||
bare?: boolean;
|
||||
/**
|
||||
* Stick to the top below the global SiteHeader. Used by reading pages
|
||||
* so the chapter title stays visible as the user scrolls long content.
|
||||
* The offset is `top-12 sm:top-14` — SiteHeader is `h-12 sm:h-14`, so
|
||||
* these values land the PageHeader flush with its bottom edge with no
|
||||
* visible gap.
|
||||
*/
|
||||
sticky?: boolean;
|
||||
className?: string;
|
||||
}) {
|
||||
const router = useRouter();
|
||||
const pathname = usePathname();
|
||||
|
||||
// Read (and consume) the search-return flag once on first render.
|
||||
const [fromSearch, setFromSearch] = useState(() => {
|
||||
if (typeof window === "undefined") return false;
|
||||
try {
|
||||
if (
|
||||
sessionStorage.getItem(SEARCH_RETURN_KEY) === "1" &&
|
||||
!pathname.startsWith("/search")
|
||||
) {
|
||||
sessionStorage.removeItem(SEARCH_RETURN_KEY);
|
||||
return true;
|
||||
}
|
||||
} catch {
|
||||
// sessionStorage unavailable
|
||||
}
|
||||
return false;
|
||||
});
|
||||
|
||||
// Scroll-direction-driven collapse. Hidden when the user is scrolling
|
||||
// down past the first viewport-tenth and revealed again as soon as
|
||||
// they scroll up. Near the top we always show so quick reading doesn't
|
||||
// feel jumpy. Only wired up when `sticky` — that's the only case where
|
||||
// the header has a fixed slot to slide behind.
|
||||
const [collapsed, setCollapsed] = useState(false);
|
||||
const lastScrollYRef = useRef(0);
|
||||
useEffect(() => {
|
||||
if (!sticky) return;
|
||||
lastScrollYRef.current = window.scrollY;
|
||||
function onScroll() {
|
||||
const y = window.scrollY;
|
||||
const delta = y - lastScrollYRef.current;
|
||||
// Threshold of ~8px filters out scroll-jitter / momentum noise.
|
||||
if (y < 80) setCollapsed(false);
|
||||
else if (delta > 8) setCollapsed(true);
|
||||
else if (delta < -8) setCollapsed(false);
|
||||
lastScrollYRef.current = y;
|
||||
}
|
||||
window.addEventListener("scroll", onScroll, { passive: true });
|
||||
return () => window.removeEventListener("scroll", onScroll);
|
||||
}, [sticky]);
|
||||
|
||||
return (
|
||||
<header
|
||||
className={cn(
|
||||
"px-4 sm:px-6",
|
||||
bare ? "py-6" : "border-b border-border bg-card/85 backdrop-blur py-5",
|
||||
sticky &&
|
||||
"sticky top-12 sm:top-14 z-20 transition-transform duration-200 ease-out",
|
||||
// -translate-y-full tucks the band fully behind the SiteHeader
|
||||
// (which sits at a higher z-index), so it visually disappears.
|
||||
sticky && collapsed && "-translate-y-full",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<div className="max-w-5xl mx-auto">
|
||||
{fromSearch ? (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setFromSearch(false);
|
||||
router.back();
|
||||
}}
|
||||
className="block text-xs text-muted-foreground hover:text-foreground transition-colors mb-2"
|
||||
>
|
||||
← Back to search results
|
||||
</button>
|
||||
{/* Hide the page's own breadcrumb link (always the first <a>) */}
|
||||
<div className="[&>a:first-child]:hidden">{children}</div>
|
||||
</>
|
||||
) : (
|
||||
children
|
||||
)}
|
||||
</div>
|
||||
</header>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* SiteBottomNav — mobile-only bottom tab bar.
|
||||
*
|
||||
* Four items: Home · Search · Kautilya · Account.
|
||||
* Mirrors the Instagram pattern — fixed to the viewport bottom, full
|
||||
* width, equal-width tabs, the active tab uses the primary color.
|
||||
*
|
||||
* Hidden on md+ — desktop uses SiteSidebar.
|
||||
*/
|
||||
|
||||
import Image from "next/image";
|
||||
import Link from "next/link";
|
||||
import { usePathname } from "next/navigation";
|
||||
import { Home, Search, User as UserIcon } from "lucide-react";
|
||||
import { useAuth } from "@/components/auth/auth-provider";
|
||||
import { KautilyaIcon } from "@/components/selection-toolbar/shared";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
type Item = {
|
||||
/** Tab label, used for the aria-label. Bottom nav is icon-only. */
|
||||
label: string;
|
||||
href: string;
|
||||
icon: React.ReactNode;
|
||||
matchPrefixes?: string[];
|
||||
};
|
||||
|
||||
export function SiteBottomNav() {
|
||||
const pathname = usePathname();
|
||||
const { user } = useAuth();
|
||||
|
||||
const items: Item[] = [
|
||||
{ href: "/", label: "Home", icon: <Home className="size-5" /> },
|
||||
{
|
||||
href: "/search",
|
||||
label: "Search",
|
||||
icon: <Search className="size-5" />,
|
||||
// Search is the knowledgebase entry point too — keep the tab lit
|
||||
// while the user is reading anywhere in the library.
|
||||
matchPrefixes: [
|
||||
"/search",
|
||||
"/books",
|
||||
"/letters",
|
||||
"/transcriptions",
|
||||
"/essays",
|
||||
"/glossary",
|
||||
"/journals",
|
||||
"/magazines",
|
||||
"/other-documents",
|
||||
"/disciples",
|
||||
],
|
||||
},
|
||||
{
|
||||
href: "/chat",
|
||||
label: "Kautilya",
|
||||
icon: <KautilyaIcon className="size-5" />,
|
||||
},
|
||||
{
|
||||
href: user ? "/u/me" : "/login",
|
||||
label: user ? "You" : "Sign in",
|
||||
// Show the user's uploaded avatar when available so the nav
|
||||
// feels personalized. Falls back to the generic person glyph
|
||||
// for anonymous viewers and signed-in users who haven't
|
||||
// uploaded a picture yet. The avatar circle is slightly larger
|
||||
// (size-6 vs the size-5 icons elsewhere) so it reads as an
|
||||
// identifying glyph rather than fading in with the other tabs.
|
||||
icon: user?.avatar_url ? (
|
||||
<Image
|
||||
src={user.avatar_url}
|
||||
alt=""
|
||||
width={28}
|
||||
height={28}
|
||||
className="size-7 rounded-full object-cover ring-1 ring-foreground/10"
|
||||
unoptimized
|
||||
/>
|
||||
) : (
|
||||
<UserIcon className="size-5" />
|
||||
),
|
||||
matchPrefixes: ["/u/me", "/login", "/signup"],
|
||||
},
|
||||
];
|
||||
|
||||
function isActive(item: Item): boolean {
|
||||
if (item.matchPrefixes) {
|
||||
return item.matchPrefixes.some((p) =>
|
||||
p === "/" ? pathname === "/" : pathname.startsWith(p),
|
||||
);
|
||||
}
|
||||
if (item.href === "/") return pathname === "/";
|
||||
return pathname === item.href || pathname.startsWith(`${item.href}/`);
|
||||
}
|
||||
|
||||
return (
|
||||
<nav
|
||||
aria-label="Primary"
|
||||
className={cn(
|
||||
"md:hidden fixed inset-x-0 bottom-0 z-40",
|
||||
"bg-background/95 backdrop-blur border-t border-border",
|
||||
"pb-[env(safe-area-inset-bottom)]",
|
||||
)}
|
||||
>
|
||||
<ul className="flex items-stretch justify-around py-2">
|
||||
{items.map((item) => {
|
||||
const active = isActive(item);
|
||||
return (
|
||||
<li
|
||||
key={item.label}
|
||||
// flex + items-center + justify-center so the fixed-size
|
||||
// pill below sits centred inside its equal-width cell;
|
||||
// without these the pill collapses to the left edge of
|
||||
// each `flex-1` slot.
|
||||
className="flex-1 flex items-center justify-center"
|
||||
>
|
||||
<Link
|
||||
href={item.href}
|
||||
aria-label={item.label}
|
||||
aria-current={active ? "page" : undefined}
|
||||
className={cn(
|
||||
"w-12 h-12 rounded-md flex flex-col items-center justify-center",
|
||||
active
|
||||
? "text-white bg-primary"
|
||||
: "text-muted-foreground hover:text-foreground",
|
||||
)}
|
||||
>
|
||||
{item.icon}
|
||||
</Link>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
</nav>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
/**
|
||||
* SiteFooter — a single slim row carrying the feedback link. Mounted in
|
||||
* both (app) and (legal) layouts so the affordance follows the user
|
||||
* everywhere on the site without taking up perceptible vertical space.
|
||||
*
|
||||
* No wordmark, no about / privacy / terms links, no dedication line —
|
||||
* those live in settings and the legal pages themselves. This footer
|
||||
* exists solely to keep "send us feedback" one tap away from anywhere.
|
||||
*/
|
||||
|
||||
import Link from "next/link";
|
||||
import { MessageSquare } from "lucide-react";
|
||||
|
||||
export function SiteFooter() {
|
||||
return (
|
||||
<footer className="mt-auto border-t border-border bg-secondary text-secondary-foreground">
|
||||
<div className="max-w-5xl mx-auto px-4 sm:px-6 py-3 flex items-center justify-center">
|
||||
<Link
|
||||
href="/feedback"
|
||||
className="inline-flex items-center gap-1.5 text-xs text-muted-foreground hover:text-foreground underline-offset-4 hover:underline transition-colors"
|
||||
>
|
||||
<MessageSquare className="size-3.5" />
|
||||
Send feedback
|
||||
</Link>
|
||||
</div>
|
||||
</footer>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* SiteHeader — minimal sticky transparent header with the brand wordmark
|
||||
* centred. Navigation lives in SiteSidebar (desktop) and SiteBottomNav
|
||||
* (mobile), so the header's only job here is to anchor the brand.
|
||||
*
|
||||
* Mobile gets a Reading-settings affordance pinned to the right ONLY for
|
||||
* anonymous visitors — auth users access the same controls from the
|
||||
* gear next to Edit Profile in ProfileHeader (and from the desktop
|
||||
* sidebar rail). The split avoids two icons doing the same job for
|
||||
* signed-in users while keeping the affordance reachable for guests who
|
||||
* have no profile.
|
||||
*
|
||||
* Layout (mobile, anonymous):
|
||||
*
|
||||
* ┌─────────────────────────────────────────────────────┐
|
||||
* │ Prabhupāda ⚙ │
|
||||
* │ SAYS │
|
||||
* └─────────────────────────────────────────────────────┘
|
||||
*
|
||||
* On md+ the gear hides and the sidebar carries it instead.
|
||||
*/
|
||||
|
||||
import Link from "next/link";
|
||||
import { ReadingSettings } from "@/components/reading-settings";
|
||||
import { NotificationBell } from "@/components/layout/notification-bell";
|
||||
import { useAuth } from "@/components/auth/auth-provider";
|
||||
|
||||
export function SiteHeader() {
|
||||
const { user, loading } = useAuth();
|
||||
// Only show the mobile gear for anonymous viewers. During the initial
|
||||
// auth check we treat `loading` as anonymous so the gear doesn't flash
|
||||
// in and out — once auth resolves, it hides for signed-in users.
|
||||
const showMobileGear = !loading && !user;
|
||||
|
||||
return (
|
||||
<header className="sticky top-0 z-30 bg-background/60 backdrop-blur-md">
|
||||
<div className="relative h-12 sm:h-14 flex items-center justify-center px-4">
|
||||
<Link
|
||||
href="/"
|
||||
aria-label="Prabhupāda Says — home"
|
||||
className="leading-none text-foreground font-[family-name:var(--font-title)] transition-opacity hover:opacity-80"
|
||||
>
|
||||
<span className="block text-center text-[0.8rem] sm:text-base font-semibold uppercase tracking-[0.12em]">
|
||||
Prabhupāda
|
||||
</span>
|
||||
<span className="block text-center text-[0.55rem] sm:text-[0.7rem] font-medium uppercase text-muted-foreground tracking-[0.22em] mt-0.5">
|
||||
Says
|
||||
</span>
|
||||
</Link>
|
||||
{showMobileGear ? (
|
||||
<div className="md:hidden absolute right-2 top-1/2 -translate-y-1/2">
|
||||
<ReadingSettings />
|
||||
</div>
|
||||
) : null}
|
||||
{/* Notification bell — signed-in users only (anonymous viewers get
|
||||
the reading-settings gear above instead). */}
|
||||
{!loading && user ? (
|
||||
<div className="absolute right-2 top-1/2 -translate-y-1/2">
|
||||
<NotificationBell />
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</header>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* MobileNavToggle — hamburger button that opens the global section drawer
|
||||
* below the md breakpoint.
|
||||
*
|
||||
* Built on the project's wrapped shadcn Drawer (which composes vaul). The
|
||||
* raw `vaul` primitive + `asChild` pattern was unreliable here — the
|
||||
* wrapped components handle ref forwarding and portal mounting correctly
|
||||
* out of the box, and they already encode the project's z-index convention.
|
||||
*
|
||||
* Kept separate from the per-page MobileNav (which renders a book's
|
||||
* sidebar) so reading pages keep their existing drawer untouched.
|
||||
*/
|
||||
|
||||
import { useState } from "react";
|
||||
import Link from "next/link";
|
||||
import { Menu } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import {
|
||||
Drawer,
|
||||
DrawerContent,
|
||||
DrawerDescription,
|
||||
DrawerHeader,
|
||||
DrawerTitle,
|
||||
DrawerTrigger,
|
||||
} from "@/components/ui/drawer";
|
||||
|
||||
type NavItem = { href: string; label: string };
|
||||
|
||||
const SECONDARY: NavItem[] = [
|
||||
{ href: "/disciples", label: "Disciples" },
|
||||
{ href: "/journals", label: "Journals" },
|
||||
{ href: "/magazines", label: "Magazines" },
|
||||
{ href: "/other-documents", label: "Other Documents" },
|
||||
{ href: "/about", label: "About" },
|
||||
];
|
||||
|
||||
export function MobileNavToggle({
|
||||
items,
|
||||
}: {
|
||||
items: ReadonlyArray<NavItem>;
|
||||
}) {
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
return (
|
||||
<Drawer open={open} onOpenChange={setOpen} direction="left">
|
||||
<DrawerTrigger
|
||||
aria-label="Open menu"
|
||||
className={cn(
|
||||
"size-9 inline-flex items-center justify-center rounded-lg",
|
||||
"text-foreground/80 hover:text-foreground hover:bg-foreground/5 transition-colors",
|
||||
)}
|
||||
>
|
||||
<Menu className="size-5" />
|
||||
</DrawerTrigger>
|
||||
<DrawerContent
|
||||
direction="left"
|
||||
className="h-full w-72 max-w-[80%]"
|
||||
>
|
||||
<DrawerHeader className="text-left border-b border-border pb-4">
|
||||
<DrawerTitle className="font-[family-name:var(--font-title)] font-semibold uppercase tracking-[0.14em] text-sm">
|
||||
Prabhupāda Says
|
||||
</DrawerTitle>
|
||||
<DrawerDescription className="sr-only">
|
||||
Quickly jump between sections of Prabhupāda Says.
|
||||
</DrawerDescription>
|
||||
</DrawerHeader>
|
||||
|
||||
<nav className="flex-1 overflow-y-auto px-3 py-2">
|
||||
{items.map((item) => (
|
||||
<Link
|
||||
key={item.href}
|
||||
href={item.href}
|
||||
onClick={() => setOpen(false)}
|
||||
className="block rounded-lg px-3 py-2.5 text-base text-foreground hover:bg-muted transition-colors"
|
||||
>
|
||||
{item.label}
|
||||
</Link>
|
||||
))}
|
||||
<div className="my-3 border-t border-border" />
|
||||
{SECONDARY.map((item) => (
|
||||
<Link
|
||||
key={item.href}
|
||||
href={item.href}
|
||||
onClick={() => setOpen(false)}
|
||||
className="block rounded-lg px-3 py-2.5 text-sm text-muted-foreground hover:bg-muted hover:text-foreground transition-colors"
|
||||
>
|
||||
{item.label}
|
||||
</Link>
|
||||
))}
|
||||
</nav>
|
||||
</DrawerContent>
|
||||
</Drawer>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,323 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* SiteSidebar — desktop-only thin rail that expands on hover.
|
||||
*
|
||||
* Layout (collapsed, w-16):
|
||||
*
|
||||
* ┌──┐
|
||||
* │🏠│ ← top group (Home / Search / Kautilya /
|
||||
* │🔍│ Collections / Bookmarks / Mentions)
|
||||
* │✦ │
|
||||
* │📁│
|
||||
* │🔖│
|
||||
* │@ │
|
||||
* │ │
|
||||
* │⋮ │ ← bottom group (Reading settings, Profile)
|
||||
* │👤│
|
||||
* └──┘
|
||||
*
|
||||
* Hovering anywhere on the rail expands it to w-56 and reveals labels.
|
||||
* The rail floats above content (fixed position, the main area is
|
||||
* padded by w-16) so the expansion never causes layout shift.
|
||||
*
|
||||
* Hidden below md — mobile uses SiteBottomNav.
|
||||
*/
|
||||
|
||||
import { useState } from "react";
|
||||
import Image from "next/image";
|
||||
import Link from "next/link";
|
||||
import { usePathname } from "next/navigation";
|
||||
import {
|
||||
AtSign,
|
||||
Bookmark,
|
||||
FolderClosed,
|
||||
Home,
|
||||
LogIn,
|
||||
Search,
|
||||
Settings2,
|
||||
User as UserIcon,
|
||||
} from "lucide-react";
|
||||
import { useAuth } from "@/components/auth/auth-provider";
|
||||
import { ReadingSettings } from "@/components/reading-settings";
|
||||
import { KautilyaIcon } from "@/components/selection-toolbar/shared";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
type NavItem = {
|
||||
href: string;
|
||||
label: string;
|
||||
icon: React.ReactNode;
|
||||
/** Optional: routes to mark active when the path starts with these.
|
||||
* Used for /books variants (lectures, letters, etc.) all flagging the
|
||||
* Knowledgebase entry. */
|
||||
matchPrefixes?: string[];
|
||||
};
|
||||
|
||||
const TOP_ITEMS: NavItem[] = [
|
||||
{ href: "/", label: "Home", icon: <Home className="size-5" /> },
|
||||
{
|
||||
href: "/search",
|
||||
label: "Search",
|
||||
icon: <Search className="size-5" />,
|
||||
// Search doubles as the knowledgebase browse surface — light it up
|
||||
// any time the user is deep inside the library so they have a clear
|
||||
// path back.
|
||||
matchPrefixes: [
|
||||
"/search",
|
||||
"/books",
|
||||
"/letters",
|
||||
"/transcriptions",
|
||||
"/essays",
|
||||
"/glossary",
|
||||
"/journals",
|
||||
"/magazines",
|
||||
"/other-documents",
|
||||
"/disciples",
|
||||
],
|
||||
},
|
||||
{
|
||||
href: "/chat",
|
||||
label: "Kautilya",
|
||||
icon: <KautilyaIcon className="size-5" />,
|
||||
},
|
||||
{
|
||||
href: "/u/me/collections",
|
||||
label: "Collections",
|
||||
icon: <FolderClosed className="size-5" />,
|
||||
},
|
||||
{
|
||||
href: "/u/me/bookmarks",
|
||||
label: "Bookmarks",
|
||||
icon: <Bookmark className="size-5" />,
|
||||
},
|
||||
{
|
||||
href: "/u/me/mentions",
|
||||
label: "Mentions",
|
||||
icon: <AtSign className="size-5" />,
|
||||
},
|
||||
];
|
||||
|
||||
export function SiteSidebar() {
|
||||
const pathname = usePathname();
|
||||
const { user } = useAuth();
|
||||
// While the reading-settings popover is open we pin the rail to its
|
||||
// expanded width so the popover (which anchors to the trigger button)
|
||||
// doesn't visually drift when the user's mouse leaves the rail.
|
||||
const [readingOpen, setReadingOpen] = useState(false);
|
||||
|
||||
function isActive(item: NavItem): boolean {
|
||||
if (item.matchPrefixes) {
|
||||
return item.matchPrefixes.some((p) =>
|
||||
p === "/" ? pathname === "/" : pathname.startsWith(p),
|
||||
);
|
||||
}
|
||||
if (item.href === "/") return pathname === "/";
|
||||
return pathname === item.href || pathname.startsWith(`${item.href}/`);
|
||||
}
|
||||
|
||||
return (
|
||||
<aside
|
||||
aria-label="Primary navigation"
|
||||
data-expanded={readingOpen ? "true" : undefined}
|
||||
className={cn(
|
||||
"hidden md:flex group fixed inset-y-0 left-0 z-40",
|
||||
"transition-[width] duration-200 ease-out",
|
||||
readingOpen ? "w-56" : "w-16 hover:w-56",
|
||||
"bg-background/95 backdrop-blur border-r border-border",
|
||||
"flex-col",
|
||||
)}
|
||||
>
|
||||
<nav
|
||||
className="flex-1 overflow-y-auto py-4 flex flex-col gap-0.5"
|
||||
aria-label="Primary"
|
||||
>
|
||||
{TOP_ITEMS.map((item) => (
|
||||
<SidebarLink
|
||||
key={item.href}
|
||||
item={item}
|
||||
active={isActive(item)}
|
||||
expandedOverride={readingOpen}
|
||||
/>
|
||||
))}
|
||||
</nav>
|
||||
|
||||
<div className="border-t border-border py-4 flex flex-col gap-0.5">
|
||||
<SidebarReadingSettings
|
||||
expanded={readingOpen}
|
||||
onOpenChange={setReadingOpen}
|
||||
/>
|
||||
<SidebarProfile
|
||||
authed={!!user}
|
||||
handle={user?.handle ?? null}
|
||||
name={user?.name ?? null}
|
||||
avatarURL={user?.avatar_url ?? null}
|
||||
active={pathname.startsWith("/u/me")}
|
||||
expandedOverride={readingOpen}
|
||||
/>
|
||||
</div>
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
|
||||
function SidebarLink({
|
||||
item,
|
||||
active,
|
||||
expandedOverride,
|
||||
}: {
|
||||
item: NavItem;
|
||||
active: boolean;
|
||||
/** Force labels visible when the rail is pinned open (e.g. reading
|
||||
* settings popover is up) and the mouse isn't actually hovering. */
|
||||
expandedOverride?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<Link
|
||||
href={item.href}
|
||||
aria-label={item.label}
|
||||
title={item.label}
|
||||
className={cn(
|
||||
"mx-2 h-11 flex items-center gap-3 px-3 rounded-lg overflow-hidden whitespace-nowrap text-sm font-medium",
|
||||
"transition-colors",
|
||||
active
|
||||
? "bg-primary/10 text-primary"
|
||||
: "text-muted-foreground hover:text-foreground hover:bg-muted",
|
||||
)}
|
||||
>
|
||||
<span className="shrink-0">{item.icon}</span>
|
||||
<span
|
||||
className={cn(
|
||||
"transition-opacity duration-150",
|
||||
expandedOverride
|
||||
? "opacity-100"
|
||||
: "opacity-0 group-hover:opacity-100 delay-0 group-hover:delay-100",
|
||||
)}
|
||||
>
|
||||
{item.label}
|
||||
</span>
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
function SidebarReadingSettings({
|
||||
expanded,
|
||||
onOpenChange,
|
||||
}: {
|
||||
expanded: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
}) {
|
||||
return (
|
||||
<div className="mx-2">
|
||||
<ReadingSettings
|
||||
placement="above-left"
|
||||
onOpenChange={onOpenChange}
|
||||
renderTrigger={({ open, toggle, buttonRef }) => (
|
||||
<button
|
||||
ref={buttonRef}
|
||||
type="button"
|
||||
aria-label="Reading settings"
|
||||
aria-expanded={open}
|
||||
onClick={toggle}
|
||||
className={cn(
|
||||
"h-11 w-full flex items-center gap-3 px-3 rounded-lg overflow-hidden whitespace-nowrap text-sm font-medium",
|
||||
"text-muted-foreground hover:text-foreground hover:bg-muted transition-colors",
|
||||
)}
|
||||
>
|
||||
<Settings2 className="size-5 shrink-0" />
|
||||
<span
|
||||
className={cn(
|
||||
"transition-opacity duration-150",
|
||||
expanded
|
||||
? "opacity-100"
|
||||
: "opacity-0 group-hover:opacity-100 delay-0 group-hover:delay-100",
|
||||
)}
|
||||
>
|
||||
Reading
|
||||
</span>
|
||||
</button>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SidebarProfile({
|
||||
authed,
|
||||
handle,
|
||||
name,
|
||||
avatarURL,
|
||||
active,
|
||||
expandedOverride,
|
||||
}: {
|
||||
authed: boolean;
|
||||
handle: string | null;
|
||||
name: string | null;
|
||||
/** Uploaded avatar URL — when set, the rail glyph becomes a small
|
||||
* circular picture instead of the generic person icon so the user
|
||||
* recognises their seat in the nav at a glance. */
|
||||
avatarURL?: string | null;
|
||||
active: boolean;
|
||||
expandedOverride?: boolean;
|
||||
}) {
|
||||
if (!authed) {
|
||||
return (
|
||||
<Link
|
||||
href="/login"
|
||||
className={cn(
|
||||
"mx-2 h-11 flex items-center gap-3 px-3 rounded-lg overflow-hidden whitespace-nowrap text-sm font-medium",
|
||||
"text-muted-foreground hover:text-foreground hover:bg-muted transition-colors",
|
||||
)}
|
||||
aria-label="Sign in"
|
||||
title="Sign in"
|
||||
>
|
||||
<LogIn className="size-5 shrink-0" />
|
||||
<span
|
||||
className={cn(
|
||||
"transition-opacity duration-150",
|
||||
expandedOverride
|
||||
? "opacity-100"
|
||||
: "opacity-0 group-hover:opacity-100 delay-0 group-hover:delay-100",
|
||||
)}
|
||||
>
|
||||
Sign in
|
||||
</span>
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<Link
|
||||
href="/u/me"
|
||||
aria-label="Profile"
|
||||
title={handle ? `@${handle}` : "Profile"}
|
||||
className={cn(
|
||||
"mx-2 h-11 flex items-center gap-3 px-3 rounded-lg overflow-hidden whitespace-nowrap text-sm font-medium",
|
||||
"transition-colors",
|
||||
active
|
||||
? "bg-primary/10 text-primary"
|
||||
: "text-muted-foreground hover:text-foreground hover:bg-muted",
|
||||
)}
|
||||
>
|
||||
{avatarURL ? (
|
||||
<Image
|
||||
src={avatarURL}
|
||||
alt=""
|
||||
width={20}
|
||||
height={20}
|
||||
className="size-5 rounded-full object-cover ring-1 ring-foreground/10 shrink-0"
|
||||
unoptimized
|
||||
/>
|
||||
) : (
|
||||
<UserIcon className="size-5 shrink-0" />
|
||||
)}
|
||||
<span
|
||||
className={cn(
|
||||
"min-w-0 truncate transition-opacity duration-150",
|
||||
expandedOverride
|
||||
? "opacity-100"
|
||||
: "opacity-0 group-hover:opacity-100 delay-0 group-hover:delay-100",
|
||||
)}
|
||||
>
|
||||
{name || (handle ? `@${handle}` : "Profile")}
|
||||
</span>
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user