feat: add auth

This commit is contained in:
gosangam
2026-05-30 02:34:22 +05:30
parent 3767a3a9b6
commit 6b6ce0813e
23 changed files with 2025 additions and 447 deletions
+52
View File
@@ -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>
);
}
+85
View File
@@ -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;
}
+121
View File
@@ -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>
);
}
+77
View File
@@ -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
View File
@@ -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"
>
&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>
<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>
);
+88 -15
View File
@@ -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&apos;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>
);
}
+135
View File
@@ -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>
);
}
+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>
);
}
+22
View File
@@ -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 };