chore: code refactor

This commit is contained in:
gosangam
2026-06-13 02:24:42 +05:30
parent cc5633c37a
commit 439664a97d
151 changed files with 1387 additions and 1138 deletions
+286
View File
@@ -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,
)}
</>
);
}
+191
View File
@@ -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>
);
}
+43
View File
@@ -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
/>
);
}
+130
View File
@@ -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>
);
}
+33
View File
@@ -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>
);
}
+129
View File
@@ -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"
>
&larr; 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>
);
}
+134
View File
@@ -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>
);
}
+28
View File
@@ -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>
);
}
+67
View File
@@ -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>
);
}
+96
View File
@@ -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>
);
}
+323
View File
@@ -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>
);
}