feat: add auth
This commit is contained in:
@@ -0,0 +1,52 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
|
||||
/**
|
||||
* Visual wrapper used by every /(auth) page. Centralises the card chrome
|
||||
* so the per-page files only deal with form state.
|
||||
*/
|
||||
export function AuthCard({
|
||||
title,
|
||||
description,
|
||||
footer,
|
||||
children,
|
||||
}: {
|
||||
title: string;
|
||||
description?: string;
|
||||
footer?: React.ReactNode;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader className="pb-8">
|
||||
<CardTitle className="text-2xl">{title}</CardTitle>
|
||||
{description ? <CardDescription>{description}</CardDescription> : null}
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">{children}</CardContent>
|
||||
{footer ? (
|
||||
<div className="px-6 pb-6 text-sm text-muted-foreground">{footer}</div>
|
||||
) : null}
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
export function AuthFooterLink({
|
||||
prefix,
|
||||
href,
|
||||
label,
|
||||
}: {
|
||||
prefix: string;
|
||||
href: string;
|
||||
label: string;
|
||||
}) {
|
||||
return (
|
||||
<p>
|
||||
{prefix}{" "}
|
||||
<Link href={href} className="text-primary underline-offset-4 hover:underline">
|
||||
{label}
|
||||
</Link>
|
||||
</p>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
createContext,
|
||||
useCallback,
|
||||
useContext,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useState,
|
||||
} from "react";
|
||||
import { auth, type User } from "@/lib/auth";
|
||||
|
||||
/**
|
||||
* AuthProvider holds the current-user state and exposes the auth actions
|
||||
* to the rest of the app.
|
||||
*
|
||||
* On mount it calls /auth/me to hydrate. Until that completes, `loading`
|
||||
* is true and consumers should render a placeholder rather than assuming
|
||||
* the user is logged out.
|
||||
*
|
||||
* After a successful login/signup/logout the provider re-fetches /auth/me
|
||||
* to keep state in sync with the cookie.
|
||||
*/
|
||||
type AuthContextValue = {
|
||||
user: User | null;
|
||||
loading: boolean;
|
||||
signUp: (email: string, password: string, name: string) => Promise<User>;
|
||||
logIn: (email: string, password: string) => Promise<User>;
|
||||
logOut: () => Promise<void>;
|
||||
refresh: () => Promise<void>;
|
||||
/** Replace the user state without a network round-trip (after profile edit). */
|
||||
setUser: (u: User | null) => void;
|
||||
};
|
||||
|
||||
const AuthContext = createContext<AuthContextValue | null>(null);
|
||||
|
||||
export function AuthProvider({ children }: { children: React.ReactNode }) {
|
||||
const [user, setUser] = useState<User | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
const refresh = useCallback(async () => {
|
||||
try {
|
||||
const me = await auth.me();
|
||||
setUser(me);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
void refresh();
|
||||
}, [refresh]);
|
||||
|
||||
const signUp = useCallback(async (email: string, password: string, name: string) => {
|
||||
const u = await auth.signUp(email, password, name);
|
||||
setUser(u);
|
||||
return u;
|
||||
}, []);
|
||||
|
||||
const logIn = useCallback(async (email: string, password: string) => {
|
||||
const u = await auth.logIn(email, password);
|
||||
setUser(u);
|
||||
return u;
|
||||
}, []);
|
||||
|
||||
const logOut = useCallback(async () => {
|
||||
await auth.logOut();
|
||||
setUser(null);
|
||||
}, []);
|
||||
|
||||
const value = useMemo<AuthContextValue>(
|
||||
() => ({ user, loading, signUp, logIn, logOut, refresh, setUser }),
|
||||
[user, loading, signUp, logIn, logOut, refresh],
|
||||
);
|
||||
|
||||
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
|
||||
}
|
||||
|
||||
export function useAuth(): AuthContextValue {
|
||||
const ctx = useContext(AuthContext);
|
||||
if (!ctx) {
|
||||
throw new Error("useAuth must be used inside <AuthProvider>");
|
||||
}
|
||||
return ctx;
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* UserMenu — the right-most item in SiteHeader. Three states:
|
||||
*
|
||||
* 1. Loading (auth context hasn't hydrated) — render a quiet placeholder
|
||||
* so the row width doesn't jump when the user data arrives.
|
||||
* 2. Logged out — single "Log in" link.
|
||||
* 3. Logged in — avatar / initials chip that opens a popover with
|
||||
* "Profile" and "Log out". Built on @base-ui/react/popover so we
|
||||
* don't pull in additional shadcn primitives.
|
||||
*/
|
||||
|
||||
import { Popover } from "@base-ui/react/popover";
|
||||
import Link from "next/link";
|
||||
import Image from "next/image";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { UserRound } from "lucide-react";
|
||||
import { useAuth } from "./auth-provider";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
function initials(name: string): string {
|
||||
return (
|
||||
name
|
||||
.split(/\s+/)
|
||||
.map((p) => p[0])
|
||||
.slice(0, 2)
|
||||
.join("")
|
||||
.toUpperCase() || "?"
|
||||
);
|
||||
}
|
||||
|
||||
export function UserMenu() {
|
||||
const { user, loading, logOut } = useAuth();
|
||||
const router = useRouter();
|
||||
|
||||
if (loading) {
|
||||
// Placeholder keeps the action row width stable until auth hydrates.
|
||||
return <span aria-hidden className="size-9 inline-block" />;
|
||||
}
|
||||
|
||||
if (!user) {
|
||||
// Mobile: compact icon button so the header row fits on 375px viewports.
|
||||
// Desktop: full "Log in" text — there's plenty of space.
|
||||
// Same muted→foreground pattern as the search / reading-settings buttons
|
||||
// so the icon sits at a consistent quiet shade across all themes.
|
||||
return (
|
||||
<Link
|
||||
href="/login"
|
||||
aria-label="Log in"
|
||||
className="inline-flex items-center rounded-lg text-muted-foreground hover:text-foreground hover:bg-muted transition-colors size-9 justify-center md:size-auto md:h-9 md:px-3 md:gap-1.5"
|
||||
>
|
||||
<UserRound className="size-5 md:size-4" />
|
||||
<span className="hidden md:inline text-sm font-medium">Log in</span>
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
async function onLogOut() {
|
||||
await logOut();
|
||||
router.push("/");
|
||||
router.refresh();
|
||||
}
|
||||
|
||||
return (
|
||||
<Popover.Root>
|
||||
<Popover.Trigger
|
||||
className="size-9 rounded-full overflow-hidden ring-1 ring-foreground/10 hover:ring-foreground/20 transition-shadow"
|
||||
aria-label="Account menu"
|
||||
>
|
||||
{user.avatar_url ? (
|
||||
<Image
|
||||
src={user.avatar_url}
|
||||
alt={user.name}
|
||||
width={36}
|
||||
height={36}
|
||||
className="size-full object-cover"
|
||||
unoptimized
|
||||
/>
|
||||
) : (
|
||||
<span
|
||||
className={cn(
|
||||
"flex items-center justify-center size-full text-xs font-semibold",
|
||||
"bg-primary/15 text-primary",
|
||||
)}
|
||||
>
|
||||
{initials(user.name)}
|
||||
</span>
|
||||
)}
|
||||
</Popover.Trigger>
|
||||
<Popover.Portal>
|
||||
<Popover.Positioner sideOffset={6} align="end">
|
||||
<Popover.Popup className="min-w-44 rounded-xl bg-card border border-border shadow-md p-1 outline-none">
|
||||
<div className="px-3 py-2">
|
||||
<p className="text-sm font-medium text-foreground truncate">
|
||||
{user.name}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground truncate">
|
||||
{user.email}
|
||||
</p>
|
||||
</div>
|
||||
<div className="h-px bg-border my-1" />
|
||||
<Link
|
||||
href="/profile"
|
||||
className="block rounded-lg px-3 py-2 text-sm text-foreground hover:bg-muted transition-colors"
|
||||
>
|
||||
Profile
|
||||
</Link>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onLogOut}
|
||||
className="block w-full text-left rounded-lg px-3 py-2 text-sm text-foreground hover:bg-muted transition-colors"
|
||||
>
|
||||
Log out
|
||||
</button>
|
||||
</Popover.Popup>
|
||||
</Popover.Positioner>
|
||||
</Popover.Portal>
|
||||
</Popover.Root>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { auth, AuthError } from "@/lib/auth";
|
||||
import { useAuth } from "./auth-provider";
|
||||
|
||||
/**
|
||||
* Persistent top ribbon shown when a logged-in user has not yet verified
|
||||
* their email. Lets them re-trigger the verification mail without leaving
|
||||
* the page.
|
||||
*
|
||||
* Renders nothing when:
|
||||
* - auth state is still loading
|
||||
* - user is logged out
|
||||
* - user.email_verified_at is set
|
||||
* - user dismissed it for this session (banner is meant to be persistent
|
||||
* across navigations but skippable per-tab; we keep the dismissed flag
|
||||
* in component state so a refresh brings it back)
|
||||
*/
|
||||
export function VerifyEmailBanner() {
|
||||
const { user, loading } = useAuth();
|
||||
const [sent, setSent] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [dismissed, setDismissed] = useState(false);
|
||||
|
||||
if (loading || !user || user.email_verified_at || dismissed) return null;
|
||||
|
||||
async function onResend() {
|
||||
setError(null);
|
||||
setBusy(true);
|
||||
try {
|
||||
await auth.resendVerification();
|
||||
setSent(true);
|
||||
} catch (err) {
|
||||
setError(
|
||||
err instanceof AuthError ? err.message : "Could not send verification email.",
|
||||
);
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="w-full bg-amber-50 dark:bg-amber-950/40 text-amber-900 dark:text-amber-100 border-b border-amber-200/60 dark:border-amber-800/60">
|
||||
<div className="max-w-5xl mx-auto px-4 py-2 flex flex-wrap items-center justify-between gap-3 text-sm">
|
||||
<p>
|
||||
{sent
|
||||
? "✓ Verification email sent — check your inbox."
|
||||
: error
|
||||
? error
|
||||
: `Verify your email (${user.email}) to secure your account.`}
|
||||
</p>
|
||||
<div className="flex items-center gap-2">
|
||||
{!sent ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onResend}
|
||||
disabled={busy}
|
||||
className="font-medium underline-offset-2 hover:underline disabled:opacity-60"
|
||||
>
|
||||
{busy ? "Sending…" : "Resend email"}
|
||||
</button>
|
||||
) : null}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setDismissed(true)}
|
||||
aria-label="Dismiss"
|
||||
className="opacity-70 hover:opacity-100"
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
+49
-69
@@ -1,29 +1,50 @@
|
||||
"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.
|
||||
*
|
||||
* 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 { useState } from "react";
|
||||
import { useRouter, usePathname } from "next/navigation";
|
||||
import { ReadingSettings } from "@/components/reading-settings";
|
||||
import { HeaderSearchButton } from "@/components/header-search";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const SEARCH_RETURN_KEY = "spsays_from_search";
|
||||
|
||||
export function PageHeader({
|
||||
children,
|
||||
sticky = false,
|
||||
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-14 md:top-16` to clear SiteHeader's height.
|
||||
*/
|
||||
sticky?: boolean;
|
||||
className?: string;
|
||||
}) {
|
||||
const [searchExpanded, setSearchExpanded] = useState(false);
|
||||
const router = useRouter();
|
||||
const pathname = usePathname();
|
||||
|
||||
// Read (and consume) the search-return flag once on first render.
|
||||
// The lazy initialiser runs exactly once; on the server
|
||||
// (typeof window === "undefined") it returns false.
|
||||
const [fromSearch, setFromSearch] = useState(() => {
|
||||
if (typeof window === "undefined") return false;
|
||||
try {
|
||||
@@ -43,72 +64,31 @@ export function PageHeader({
|
||||
return (
|
||||
<header
|
||||
className={cn(
|
||||
"border-b border-border bg-card px-6",
|
||||
sticky ? "py-4 sticky top-0 z-20" : "py-4",
|
||||
"px-4 sm:px-6",
|
||||
bare ? "py-6" : "border-b border-border bg-card/85 backdrop-blur py-5",
|
||||
sticky && "sticky top-14 md:top-16 z-20",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{/*
|
||||
Layout strategy:
|
||||
- Mobile: actions float to the right so the title text wraps around
|
||||
them on the first line and uses the FULL container width on every
|
||||
subsequent line (which is what `pr-*` cannot achieve since padding
|
||||
applies to all lines).
|
||||
- Desktop (md+): standard flex row so title and actions sit side-by-side.
|
||||
*/}
|
||||
<div className="max-w-5xl mx-auto md:flex md:items-start md:justify-between md:gap-4">
|
||||
{/* Actions — float on mobile (rendered first so float works correctly),
|
||||
inline flex on desktop. Order doesn't matter visually on desktop
|
||||
because the parent's `justify-between` places them at the end. */}
|
||||
<div
|
||||
className={cn(
|
||||
"flex items-center gap-1.5",
|
||||
// Desktop: inline at the right of the flex row
|
||||
"md:pt-1 md:order-1",
|
||||
// Mobile float (for title wrapping) only applies when collapsed.
|
||||
// When expanded the search bar fills the full header width and
|
||||
// should NOT have a left margin or be floated.
|
||||
searchExpanded
|
||||
? "max-md:w-full max-md:min-w-0"
|
||||
: "max-md:float-right max-md:ml-2 md:shrink-0",
|
||||
)}
|
||||
>
|
||||
<HeaderSearchButton
|
||||
expanded={searchExpanded}
|
||||
onExpandedChange={setSearchExpanded}
|
||||
/>
|
||||
{/* Hide reading settings on mobile when search is active */}
|
||||
<div className={cn(searchExpanded && "max-md:hidden")}>
|
||||
<ReadingSettings />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Title / logo area — hidden on mobile when search is active */}
|
||||
<div
|
||||
className={cn(
|
||||
"md:flex-1 md:min-w-0",
|
||||
searchExpanded && "max-md:hidden",
|
||||
)}
|
||||
>
|
||||
{fromSearch ? (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setFromSearch(false);
|
||||
router.back();
|
||||
}}
|
||||
className="block text-sm text-muted-foreground hover:text-foreground transition-colors"
|
||||
>
|
||||
← 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>
|
||||
<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>
|
||||
);
|
||||
|
||||
+88
-15
@@ -1,26 +1,99 @@
|
||||
/**
|
||||
* SiteFooter — editorial three-column footer.
|
||||
*
|
||||
* Column 1: Wordmark + dedication
|
||||
* Column 2: Explore (links to the main content sections)
|
||||
* Column 3: Resources (about, search, profile, etc.)
|
||||
*
|
||||
* A thin copyright row sits at the bottom.
|
||||
*/
|
||||
|
||||
import Link from "next/link";
|
||||
|
||||
const EXPLORE = [
|
||||
{ href: "/books", label: "Books" },
|
||||
{ href: "/letters", label: "Letters" },
|
||||
{ href: "/transcriptions", label: "Lectures" },
|
||||
{ href: "/essays", label: "Essays" },
|
||||
{ href: "/magazines", label: "Magazines" },
|
||||
{ href: "/journals", label: "Journals" },
|
||||
];
|
||||
|
||||
const RESOURCES = [
|
||||
{ href: "/glossary", label: "Glossary" },
|
||||
{ href: "/disciples", label: "Disciples" },
|
||||
{ href: "/other-documents", label: "Other Documents" },
|
||||
{ href: "/search", label: "Search" },
|
||||
{ href: "/about", label: "About" },
|
||||
];
|
||||
|
||||
export function SiteFooter() {
|
||||
return (
|
||||
<footer className="border-t border-border bg-card mt-auto">
|
||||
<div className="max-w-5xl mx-auto px-6 py-6 flex flex-col sm:flex-row items-center justify-between gap-4 text-sm text-muted-foreground">
|
||||
<p>
|
||||
Team {" "}
|
||||
<span className="font-medium text-foreground">Prabhupāda Says</span>
|
||||
</p>
|
||||
|
||||
<nav className="flex items-center gap-6">
|
||||
<footer className="mt-auto border-t border-border bg-secondary text-secondary-foreground">
|
||||
{/* Three-column main */}
|
||||
<div className="max-w-5xl mx-auto px-4 sm:px-6 py-10 grid grid-cols-1 sm:grid-cols-3 gap-8">
|
||||
{/* Brand */}
|
||||
<div>
|
||||
<Link
|
||||
href="/about"
|
||||
className="hover:text-foreground transition-colors"
|
||||
href="/"
|
||||
aria-label="Prabhupāda Says — home"
|
||||
className="font-[family-name:var(--font-title)] font-semibold uppercase tracking-[0.12em] text-base text-foreground"
|
||||
>
|
||||
About
|
||||
Prabhupāda{" "}
|
||||
<span className="text-muted-foreground tracking-[0.22em] font-medium">
|
||||
Says
|
||||
</span>
|
||||
</Link>
|
||||
<Link href="/" className="hover:text-foreground transition-colors">
|
||||
Home
|
||||
</Link>
|
||||
</nav>
|
||||
<p className="mt-4 text-sm text-muted-foreground leading-relaxed max-w-xs">
|
||||
A complete, searchable library of His Divine Grace A.C. Bhaktivedanta
|
||||
Swami Prabhupāda's books, letters, and teachings.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Explore */}
|
||||
<FooterColumn title="Explore" items={EXPLORE} />
|
||||
|
||||
{/* Resources */}
|
||||
<FooterColumn title="Resources" items={RESOURCES} />
|
||||
</div>
|
||||
|
||||
{/* Copyright row */}
|
||||
<div className="border-t border-border">
|
||||
<div className="max-w-5xl mx-auto px-4 sm:px-6 py-5 flex flex-col sm:flex-row items-center justify-between gap-2 text-xs text-muted-foreground">
|
||||
<p>
|
||||
© {new Date().getFullYear()} Team Prabhupāda Says · Hare Kṛṣṇa
|
||||
</p>
|
||||
<p>All glories to Śrīla Prabhupāda</p>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
);
|
||||
}
|
||||
|
||||
function FooterColumn({
|
||||
title,
|
||||
items,
|
||||
}: {
|
||||
title: string;
|
||||
items: { href: string; label: string }[];
|
||||
}) {
|
||||
return (
|
||||
<div>
|
||||
<p className="text-[0.72rem] uppercase tracking-[0.2em] font-semibold text-foreground/80">
|
||||
{title}
|
||||
</p>
|
||||
<ul className="mt-4 space-y-2">
|
||||
{items.map((item) => (
|
||||
<li key={item.href}>
|
||||
<Link
|
||||
href={item.href}
|
||||
className="text-sm text-foreground/80 hover:text-foreground transition-colors"
|
||||
>
|
||||
{item.label}
|
||||
</Link>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,135 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* SiteHeader — the single global navigation header.
|
||||
*
|
||||
* Sits at the top of every page (mounted in app/layout.tsx) so the page-specific
|
||||
* PageHeader can stay focused on titles and breadcrumbs.
|
||||
*
|
||||
* Layout:
|
||||
*
|
||||
* ┌─────────────────────────────────────────────────────────────┐
|
||||
* │ PRABHUPĀDA Books · Letters · Lectures · Glossary ⌕ ⚙ ◐ │
|
||||
* │ SAYS │
|
||||
* └─────────────────────────────────────────────────────────────┘
|
||||
*
|
||||
* - Serif uppercase wordmark on the left (links home)
|
||||
* - Section nav inline on md+; collapsed into the mobile drawer below
|
||||
* - Right cluster: HeaderSearchButton, ReadingSettings, UserMenu
|
||||
* - Sticky with a subtle border + backdrop blur when scrolled
|
||||
*
|
||||
* The HeaderSearchButton's expand-into-full-width behavior is preserved by
|
||||
* giving it the same conditional flex treatment that PageHeader used to do —
|
||||
* when expanded on mobile the wordmark + nav collapse out of the way.
|
||||
*/
|
||||
|
||||
import { useState } from "react";
|
||||
import Link from "next/link";
|
||||
import { usePathname } from "next/navigation";
|
||||
import { HeaderSearchButton } from "@/components/header-search";
|
||||
import { ReadingSettings } from "@/components/reading-settings";
|
||||
import { UserMenu } from "@/components/auth/user-menu";
|
||||
import { MobileNavToggle } from "@/components/site-mobile-nav";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const PRIMARY_NAV = [
|
||||
{ href: "/books", label: "Books" },
|
||||
{ href: "/letters", label: "Letters" },
|
||||
{ href: "/transcriptions", label: "Lectures" },
|
||||
{ href: "/essays", label: "Essays" },
|
||||
{ href: "/glossary", label: "Glossary" },
|
||||
] as const;
|
||||
|
||||
export function SiteHeader() {
|
||||
const [searchExpanded, setSearchExpanded] = useState(false);
|
||||
const pathname = usePathname();
|
||||
|
||||
function isActive(href: string): boolean {
|
||||
if (href === "/") return pathname === "/";
|
||||
return pathname.startsWith(href);
|
||||
}
|
||||
|
||||
return (
|
||||
<header className="sticky top-0 z-30 border-b border-border bg-secondary text-secondary-foreground">
|
||||
<div className="max-w-5xl mx-auto px-3 sm:px-6 h-14 md:h-16 flex items-center gap-2 md:gap-6">
|
||||
{/* Mobile hamburger — visible below md, hidden when search is expanded */}
|
||||
<div className={cn("md:hidden", searchExpanded && "hidden")}>
|
||||
<MobileNavToggle items={PRIMARY_NAV} />
|
||||
</div>
|
||||
|
||||
{/* Brand wordmark — two-line stacked "Prabhupāda / SAYS". Smaller
|
||||
type sizes on mobile so the row fits next to the hamburger and
|
||||
action icons on narrow phones (≤375px). */}
|
||||
<Link
|
||||
href="/"
|
||||
aria-label="Prabhupāda Says — home"
|
||||
className={cn(
|
||||
"shrink-0 leading-none text-foreground transition-opacity hover:opacity-80",
|
||||
"font-[family-name:var(--font-title)]",
|
||||
searchExpanded && "max-md:hidden",
|
||||
)}
|
||||
>
|
||||
<span className="block text-[0.78rem] md:text-lg font-semibold uppercase tracking-[0.08em] md:tracking-[0.12em]">
|
||||
Prabhupāda
|
||||
</span>
|
||||
<span className="block text-[0.55rem] md:text-[0.78rem] font-medium uppercase text-muted-foreground tracking-[0.18em] md:tracking-[0.22em] mt-0.5">
|
||||
Says
|
||||
</span>
|
||||
</Link>
|
||||
|
||||
{/* Primary nav — desktop only; mobile uses the drawer.
|
||||
Stays visible even when search is expanded — on desktop the search
|
||||
input has a fixed max-width and fits alongside the nav, so there's
|
||||
no need to remove items (which would cause layout shift). */}
|
||||
<nav
|
||||
className="hidden md:flex items-center gap-5 flex-1 ml-2"
|
||||
aria-label="Primary"
|
||||
>
|
||||
{PRIMARY_NAV.map((item) => (
|
||||
<Link
|
||||
key={item.href}
|
||||
href={item.href}
|
||||
className={cn(
|
||||
"text-sm transition-colors relative",
|
||||
isActive(item.href)
|
||||
? "text-foreground after:absolute after:left-0 after:right-0 after:-bottom-1 after:h-px after:bg-primary"
|
||||
: "text-muted-foreground hover:text-foreground",
|
||||
)}
|
||||
>
|
||||
{item.label}
|
||||
</Link>
|
||||
))}
|
||||
</nav>
|
||||
|
||||
{/* Spacer pushes the action cluster to the right on mobile */}
|
||||
<div
|
||||
className={cn("flex-1 md:hidden", searchExpanded && "hidden")}
|
||||
aria-hidden
|
||||
/>
|
||||
|
||||
{/* Action cluster — search, reading-settings, user menu.
|
||||
On mobile when search is expanded, the cluster fills the row so
|
||||
the input can grow. On desktop it always stays shrink-0 — the
|
||||
expanded search input has its own fixed max-width and doesn't
|
||||
need to steal space from the nav. */}
|
||||
<div
|
||||
className={cn(
|
||||
"flex items-center gap-1 shrink-0",
|
||||
searchExpanded && "max-md:flex-1 max-md:min-w-0 max-md:shrink",
|
||||
)}
|
||||
>
|
||||
<HeaderSearchButton
|
||||
expanded={searchExpanded}
|
||||
onExpandedChange={setSearchExpanded}
|
||||
/>
|
||||
<div className={cn(searchExpanded && "max-md:hidden")}>
|
||||
<ReadingSettings />
|
||||
</div>
|
||||
<div className={cn(searchExpanded && "max-md:hidden")}>
|
||||
<UserMenu />
|
||||
</div>
|
||||
</div>
|
||||
</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,22 @@
|
||||
import * as React from "react";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
// Minimal shadcn-style <Label>. We use a native <label> instead of pulling
|
||||
// in @radix-ui/react-label so the dependency footprint stays small.
|
||||
function Label({
|
||||
className,
|
||||
...props
|
||||
}: React.LabelHTMLAttributes<HTMLLabelElement>) {
|
||||
return (
|
||||
<label
|
||||
data-slot="label"
|
||||
className={cn(
|
||||
"text-sm font-medium leading-none text-foreground select-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export { Label };
|
||||
Reference in New Issue
Block a user