From 6b6ce0813e7f4b3c2b0cbe246bf6f8a545214170 Mon Sep 17 00:00:00 2001 From: gosangam Date: Sat, 30 May 2026 02:34:22 +0530 Subject: [PATCH] feat: add auth --- app/(auth)/forgot-password/page.tsx | 70 ++++ app/(auth)/layout.tsx | 16 + app/(auth)/login/page.tsx | 77 ++++ app/(auth)/reset-password/page.tsx | 108 +++++ app/(auth)/signup/page.tsx | 89 ++++ app/api/search/route.ts | 2 + app/globals.css | 24 +- app/layout.tsx | 53 ++- app/page.tsx | 533 ++++++++++++------------ app/profile/page.tsx | 47 +++ app/profile/profile-form.tsx | 197 +++++++++ app/search/search-content.tsx | 218 ++++++---- app/verify-email/page.tsx | 82 ++++ components/auth/auth-card.tsx | 52 +++ components/auth/auth-provider.tsx | 85 ++++ components/auth/user-menu.tsx | 121 ++++++ components/auth/verify-email-banner.tsx | 77 ++++ components/page-header.tsx | 118 +++--- components/site-footer.tsx | 103 ++++- components/site-header.tsx | 135 ++++++ components/site-mobile-nav.tsx | 96 +++++ components/ui/label.tsx | 22 + lib/auth.ts | 147 +++++++ 23 files changed, 2025 insertions(+), 447 deletions(-) create mode 100644 app/(auth)/forgot-password/page.tsx create mode 100644 app/(auth)/layout.tsx create mode 100644 app/(auth)/login/page.tsx create mode 100644 app/(auth)/reset-password/page.tsx create mode 100644 app/(auth)/signup/page.tsx create mode 100644 app/profile/page.tsx create mode 100644 app/profile/profile-form.tsx create mode 100644 app/verify-email/page.tsx create mode 100644 components/auth/auth-card.tsx create mode 100644 components/auth/auth-provider.tsx create mode 100644 components/auth/user-menu.tsx create mode 100644 components/auth/verify-email-banner.tsx create mode 100644 components/site-header.tsx create mode 100644 components/site-mobile-nav.tsx create mode 100644 components/ui/label.tsx create mode 100644 lib/auth.ts diff --git a/app/(auth)/forgot-password/page.tsx b/app/(auth)/forgot-password/page.tsx new file mode 100644 index 00000000..8610c54c --- /dev/null +++ b/app/(auth)/forgot-password/page.tsx @@ -0,0 +1,70 @@ +"use client"; + +import { useState } from "react"; +import { AuthCard, AuthFooterLink } from "@/components/auth/auth-card"; +import { auth, AuthError } from "@/lib/auth"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; + +export default function ForgotPasswordPage() { + const [email, setEmail] = useState(""); + const [submitted, setSubmitted] = useState(false); + const [error, setError] = useState(null); + const [submitting, setSubmitting] = useState(false); + + async function onSubmit(e: React.SyntheticEvent) { + e.preventDefault(); + setError(null); + setSubmitting(true); + try { + await auth.forgotPassword(email); + setSubmitted(true); + } catch (err) { + setError(err instanceof AuthError ? err.message : "Something went wrong."); + } finally { + setSubmitting(false); + } + } + + if (submitted) { + return ( + } + > +

+ Didn't get an email? Check your spam folder or try again in a minute. +

+
+ ); + } + + return ( + } + > +
+
+ + setEmail(e.target.value)} + /> +
+
+ {error ?

{error}

: null} + +
+
+ ); +} diff --git a/app/(auth)/layout.tsx b/app/(auth)/layout.tsx new file mode 100644 index 00000000..2118911d --- /dev/null +++ b/app/(auth)/layout.tsx @@ -0,0 +1,16 @@ +/** + * Shared layout for the auth pages (login, signup, forgot-password, + * reset-password). Provides the centered card frame so each page only + * has to render its own form. + */ +export default function AuthLayout({ + children, +}: { + children: React.ReactNode; +}) { + return ( +
+
{children}
+
+ ); +} diff --git a/app/(auth)/login/page.tsx b/app/(auth)/login/page.tsx new file mode 100644 index 00000000..7e3f495a --- /dev/null +++ b/app/(auth)/login/page.tsx @@ -0,0 +1,77 @@ +"use client"; + +import { useState } from "react"; +import { useRouter, useSearchParams } from "next/navigation"; +import { AuthCard, AuthFooterLink } from "@/components/auth/auth-card"; +import { useAuth } from "@/components/auth/auth-provider"; +import { AuthError } from "@/lib/auth"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; + +export default function LoginPage() { + const router = useRouter(); + const params = useSearchParams(); + const { logIn } = useAuth(); + const [email, setEmail] = useState(""); + const [password, setPassword] = useState(""); + const [error, setError] = useState(null); + const [submitting, setSubmitting] = useState(false); + + async function onSubmit(e: React.SyntheticEvent) { + e.preventDefault(); + setError(null); + setSubmitting(true); + try { + await logIn(email, password); + router.push(params.get("next") ?? "/profile"); + router.refresh(); + } catch (err) { + setError(err instanceof AuthError ? err.message : "Login failed."); + setSubmitting(false); + } + } + + return ( + + + + + } + > +
+
+ + setEmail(e.target.value)} + /> +
+
+ + setPassword(e.target.value)} + /> +
+
+ {error ?

{error}

: null} + +
+
+ ); +} diff --git a/app/(auth)/reset-password/page.tsx b/app/(auth)/reset-password/page.tsx new file mode 100644 index 00000000..171248bb --- /dev/null +++ b/app/(auth)/reset-password/page.tsx @@ -0,0 +1,108 @@ +"use client"; + +import { Suspense, useState } from "react"; +import { useSearchParams, useRouter } from "next/navigation"; +import { AuthCard, AuthFooterLink } from "@/components/auth/auth-card"; +import { auth, AuthError } from "@/lib/auth"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; + +function ResetPasswordForm() { + const params = useSearchParams(); + const router = useRouter(); + const token = params.get("token") ?? ""; + + const [password, setPassword] = useState(""); + const [confirm, setConfirm] = useState(""); + const [error, setError] = useState(null); + const [submitting, setSubmitting] = useState(false); + const [done, setDone] = useState(false); + + if (!token) { + return ( + + + + ); + } + + async function onSubmit(e: React.SyntheticEvent) { + e.preventDefault(); + setError(null); + if (password !== confirm) { + setError("Passwords do not match."); + return; + } + setSubmitting(true); + try { + await auth.resetPassword(token, password); + setDone(true); + setTimeout(() => router.push("/login"), 1500); + } catch (err) { + setError(err instanceof AuthError ? err.message : "Reset failed."); + } finally { + setSubmitting(false); + } + } + + if (done) { + return ( + + + + ); + } + + return ( + +
+
+ + setPassword(e.target.value)} + /> +
+ +
+ + setConfirm(e.target.value)} + /> +
+
+ {error ?

{error}

: null} + +
+
+ ); +} + +export default function ResetPasswordPage() { + // useSearchParams must be inside Suspense for Next.js streaming. + return ( + + + + ); +} diff --git a/app/(auth)/signup/page.tsx b/app/(auth)/signup/page.tsx new file mode 100644 index 00000000..57c57a63 --- /dev/null +++ b/app/(auth)/signup/page.tsx @@ -0,0 +1,89 @@ +"use client"; + +import { useState } from "react"; +import { useRouter } from "next/navigation"; +import { AuthCard, AuthFooterLink } from "@/components/auth/auth-card"; +import { useAuth } from "@/components/auth/auth-provider"; +import { AuthError } from "@/lib/auth"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; + +export default function SignUpPage() { + const router = useRouter(); + const { signUp } = useAuth(); + const [name, setName] = useState(""); + const [email, setEmail] = useState(""); + const [password, setPassword] = useState(""); + const [error, setError] = useState(null); + const [submitting, setSubmitting] = useState(false); + + async function onSubmit(e: React.SyntheticEvent) { + e.preventDefault(); + setError(null); + setSubmitting(true); + try { + await signUp(email, password, name); + router.push("/profile"); + router.refresh(); + } catch (err) { + setError(err instanceof AuthError ? err.message : "Sign-up failed."); + setSubmitting(false); + } + } + + return ( + + } + > +
+
+ + setName(e.target.value)} + /> +
+ +
+ + setEmail(e.target.value)} + /> +
+ +
+ + setPassword(e.target.value)} + /> +

At least 8 characters.

+
+
+ {error ?

{error}

: null} + +
+
+ ); +} diff --git a/app/api/search/route.ts b/app/api/search/route.ts index 21643a2e..86ccc20a 100644 --- a/app/api/search/route.ts +++ b/app/api/search/route.ts @@ -8,6 +8,7 @@ export async function GET(request: NextRequest) { const type = searchParams.get("type") || "all"; const page = searchParams.get("page") || "1"; const limit = searchParams.get("limit") || "20"; + const exact = searchParams.get("exact") === "1" ? "1" : ""; if (!q.trim()) { return NextResponse.json({ @@ -27,6 +28,7 @@ export async function GET(request: NextRequest) { url.searchParams.set("type", type); url.searchParams.set("page", page); url.searchParams.set("limit", limit); + if (exact) url.searchParams.set("exact", "1"); const res = await fetch(url.toString(), { headers: { Accept: "application/json" }, diff --git a/app/globals.css b/app/globals.css index de8d0a58..4fa51f88 100644 --- a/app/globals.css +++ b/app/globals.css @@ -9,7 +9,21 @@ --color-foreground: var(--foreground); --font-sans: var(--font-sans); --font-mono: var(--font-geist-mono); - --font-heading: var(--font-sans); + /* + * Font roles: + * --font-sans — Geist, the base UI/body font. + * --font-serif — Lora, used for verse text always, and surfaced + * through --font-title when reading-serif is on. + * --font-display — Bricolage Grotesque, the default for site titles. + * --font-title — the actual var components reference for titles; + * swaps between Bricolage and Lora based on the + * `reading-serif` class on . The toggle rule + * itself lives outside @theme inline, below. + */ + --font-serif: var(--font-serif); + --font-display: var(--font-display); + --font-title: var(--font-display); + --font-heading: var(--font-display); --color-sidebar-ring: var(--sidebar-ring); --color-sidebar-border: var(--sidebar-border); --color-sidebar-accent-foreground: var(--sidebar-accent-foreground); @@ -48,6 +62,14 @@ --radius-4xl: calc(var(--radius) * 2.6); } +/* When the user enables "Serif Font" in the reading settings, page titles + swap from Bricolage Grotesque to Lora. Verse text (font-serif Tailwind + utility) is already Lora regardless of this toggle. */ +:root.reading-serif { + --font-title: var(--font-serif); + --font-heading: var(--font-serif); +} + /* ── Light mode — "Aged Parchment" ── */ :root { --background: oklch(0.957 0.012 79.8); diff --git a/app/layout.tsx b/app/layout.tsx index bf1e5800..d0604c68 100644 --- a/app/layout.tsx +++ b/app/layout.tsx @@ -1,13 +1,16 @@ import type { Metadata } from "next"; import { Suspense } from "react"; -import { Geist, Geist_Mono } from "next/font/google"; +import { Geist, Geist_Mono, Lora, Bricolage_Grotesque } from "next/font/google"; import { ogDefault } from "@/lib/og"; import { ThemeProvider } from "@/components/theme-provider"; import { TooltipProvider } from "@/components/ui/tooltip"; +import { SiteHeader } from "@/components/site-header"; import { SiteFooter } from "@/components/site-footer"; import { TextSelectionToolbar } from "@/components/text-selection-toolbar"; import { TextHighlighter } from "@/components/text-highlighter"; import { AudioPlayerProvider, FloatingPlayer } from "@/components/audio-player"; +import { AuthProvider } from "@/components/auth/auth-provider"; +import { VerifyEmailBanner } from "@/components/auth/verify-email-banner"; import "./globals.css"; const geistSans = Geist({ @@ -20,6 +23,26 @@ const geistMono = Geist_Mono({ subsets: ["latin"], }); +// Editorial serif — used for verse text always, and for site titles when +// the user enables "Serif Font" in reading settings. +const lora = Lora({ + variable: "--font-serif", + weight: ["400", "500", "600", "700"], + style: ["normal", "italic"], + subsets: ["latin", "latin-ext"], + display: "swap", +}); + +// Display sans — used for site titles when Serif Font is OFF (the default). +// Bricolage Grotesque has slight humanist warmth so it pairs well with the +// reverent aesthetic without feeling cold or generic. +const bricolage = Bricolage_Grotesque({ + variable: "--font-display", + weight: ["400", "500", "600", "700"], + subsets: ["latin"], + display: "swap", +}); + export const metadata: Metadata = { title: "Prabhupāda Says", description: @@ -36,7 +59,7 @@ export default function RootLayout({ - - - {children} - - - - - - - - + + + + + + {children} + + + + + + + + + diff --git a/app/page.tsx b/app/page.tsx index 37e2c43d..5bc3f83c 100644 --- a/app/page.tsx +++ b/app/page.tsx @@ -1,280 +1,279 @@ import Link from "next/link"; -import { - Card, - CardHeader, - CardTitle, - CardDescription, - CardContent, -} from "@/components/ui/card"; -import { Badge } from "@/components/ui/badge"; -import { PageHeader } from "@/components/page-header"; -import { Logo } from "@/components/logo"; import { BookOpen, - BookA, - PenLine, - FileStack, - NotebookPen, - Newspaper, Mail, - Users, Mic, + PenLine, + Newspaper, + NotebookPen, + BookA, + Users, + FileStack, + ArrowUpRight, } from "lucide-react"; +/** + * Editorial homepage. + * + * Three layers, top to bottom: + * 1. Hero — a large serif quote attributed to Śrīla Prabhupāda + a stat + * strip ("36 books · 6,866 letters · 3,787 lectures · …"). + * 2. Start here — three primary entry points (Books, Letters, Lectures) + * rendered as full-width feature tiles with longer copy. + * 3. Explore — a six-card asymmetric grid for the remaining sections. + * + * SiteHeader handles brand + nav + search + user menu, so this page paints + * directly onto the background — no PageHeader needed. + */ + +type Feature = { + href: string; + label: string; + blurb: string; + count?: string; + icon: React.ComponentType<{ className?: string }>; +}; + +const FEATURED: Feature[] = [ + { + href: "/books", + label: "Books", + blurb: + "Bhagavad-gītā, Śrīmad-Bhāgavatam, Caitanya-caritāmṛta and 34 more — each verse with Devanāgarī, transliteration, synonyms, translation and purport.", + count: "37 books", + icon: BookOpen, + }, + { + href: "/letters", + label: "Letters", + blurb: + "Three decades of personal correspondence to disciples, family, and officials. Browse by year, recipient, or location.", + count: "6,866 letters", + icon: Mail, + }, + { + href: "/transcriptions", + label: "Lectures & Conversations", + blurb: + "Lectures, morning walks, room conversations, and dialogues — the living voice of the Founder-Ācārya preserved in text and audio.", + count: "3,787 transcripts", + icon: Mic, + }, +]; + +const EXPLORE: Feature[] = [ + { + href: "/essays", + label: "Essays", + blurb: "Foundational standalone essays.", + count: "23 essays", + icon: PenLine, + }, + { + href: "/magazines", + label: "Back to Godhead", + blurb: "The original 1944 – 1973 magazine archive.", + count: "1,124 articles", + icon: Newspaper, + }, + { + href: "/journals", + label: "Journals", + blurb: "The Jaladuta Diary and The Beginning.", + count: "300 entries", + icon: NotebookPen, + }, + { + href: "/glossary", + label: "Glossary", + blurb: "Sanskrit and Vedic terms with definitions.", + count: "2,749 terms", + icon: BookA, + }, + { + href: "/disciples", + label: "Disciples", + blurb: "Initiated disciples 1966 – 1977.", + count: "4,747 names", + icon: Users, + }, + { + href: "/other-documents", + label: "Other Documents", + blurb: "Miscellaneous analyses and writings.", + count: "12 documents", + icon: FileStack, + }, +]; + +const STATS = [ + { label: "Books", value: "37" }, + { label: "Letters", value: "6,866" }, + { label: "Lectures", value: "3,787" }, + { label: "Glossary", value: "2,749" }, +]; + export default function Home() { return ( -
- - - - -
-
- {/* Structured Books (JSON) */} - - - -
-
- -
-
- Books - - Structured verse-by-verse editions - -
-
-
- -

- Bhagavad-gītā, Śrīmad-Bhāgavatam, Caitanya-caritāmṛta and - other books with Devanagari, Bengali, transliteration, - synonyms, translations & purports. -

-
-
- - - {/* Essays */} - - - -
-
- -
-
- Essays - - Essays by Śrīla Prabhupāda - -
-
-
- -

- Foundational essays including “Krishna The Reservoir Of - Pleasure” and “Who Is Crazy?” — timeless - introductions to the science of Kṛṣṇa consciousness. -

-
-
- - - {/* Magazines */} - - - -
-
- -
-
- - Magazines - - Back to Godhead magazines -
-
-
- -

- The original Back to Godhead magazine founded by Śrīla - Prabhupāda with articles on Kṛṣṇa consciousness, Vedic - philosophy, and spiritual life. -

-
-
- - - {/* Letters */} - - - -
-
- -
-
- Letters - - 6,800+ letters (1947–1977) - -
-
-
- -

- Śrīla Prabhupāda's personal correspondence — over 6,800 - letters to disciples, Godbrothers, government officials, and - well-wishers. Browse by year, recipient, or location. -

-
-
- - - {/* Transcriptions */} - - - -
-
- -
-
- - Transcriptions - - - Lectures & conversations - -
-
-
- -

- Transcriptions of Śrīla Prabhupāda's lectures, morning - walks, room conversations, and dialogues — the living voice of - the Founder-Ācārya preserved in text. -

-
-
- - - {/* Disciples */} - - - -
-
- -
-
- - Disciples - - - 4,700+ initiated disciples - -
-
-
- -

- A directory of disciples initiated by Śrīla Prabhupāda between - 1966 and 1977 — browse by name, initiation place, or year. -

-
-
- - - {/* Journals */} - - - -
-
- -
-
- - Journals - - - Personal diaries & journals - -
-
-
- -

- Śrīla Prabhupāda's personal diaries — including the - historic Jaladuta Diary from his 1965 voyage to America and - The Beginning, his 1966 New York journal documenting the - founding of the Hare Kṛṣṇa movement. -

-
-
- - - {/* Glossary */} - - - -
-
- -
-
- - Glossary - - - 2,700+ Sanskrit & Vedic terms - -
-
-
- -

- A comprehensive, searchable glossary of Sanskrit and Vedic - terms from Śrīla Prabhupāda's books — definitions, - cross-references, and encyclopedic entries. -

-
-
- - - {/* Other Documents */} - - - -
-
- -
-
- - Other Documents - - - Miscellaneous analyses & writings - -
-
-
- -

- Miscellaneous documents, analyses, and writings by Śrīla - Prabhupāda that don't fit neatly into the books or essays - collections. -

-
-
- +
+ + + +
+ ); +} + +// ─── Hero ──────────────────────────────────────────────────────────────── + +function Hero() { + return ( +
+ {/* Eyebrow */} +

+ The Words of Śrīla Prabhupāda +

+ + {/* Hero quote — each sentence on its own line so the visual rhythm is + predictable regardless of viewport. text-balance keeps wraps tidy. */} +
+ “Books are the basis. + Purity is the force. + Preaching is the essence. + Utility is the principle.” +
+ +

+ A complete, searchable library of His Divine Grace A.C. Bhaktivedanta + Swami Prabhupāda's books, letters, lectures, and teachings — set + in modern Unicode Sanskrit, free to read. +

+ + {/* Stat strip */} +
+ {STATS.map((s) => ( +
+
+ {s.value} +
+
+ {s.label} +
+
+ ))} +
+
+ ); +} + +// ─── Featured (3-up large tiles) ───────────────────────────────────────── + +function Featured() { + return ( +
+ +
+ {FEATURED.map((f) => ( + + ))} +
+
+ ); +} + +function FeaturedTile({ feature }: { feature: Feature }) { + const Icon = feature.icon; + return ( + +
+
+
-
+ +
+ +

+ {feature.label} +

+ +

+ {feature.blurb} +

+ + {feature.count ? ( +

+ {feature.count} +

+ ) : null} + + ); +} + +// ─── Explore (smaller grid) ────────────────────────────────────────────── + +function Explore() { + return ( +
+ +
+ {EXPLORE.map((f) => ( + + ))} +
+
+ ); +} + +function ExploreTile({ feature }: { feature: Feature }) { + const Icon = feature.icon; + return ( + +
+ +
+
+
+

+ {feature.label} +

+ +
+

+ {feature.blurb} +

+ {feature.count ? ( +

+ {feature.count} +

+ ) : null} +
+ + ); +} + +// ─── Section heading (eyebrow + title) ─────────────────────────────────── + +function SectionHeading({ + eyebrow, + title, +}: { + eyebrow: string; + title: string; +}) { + return ( +
+ + {eyebrow} + + +

+ {title} +

); } diff --git a/app/profile/page.tsx b/app/profile/page.tsx new file mode 100644 index 00000000..5dee87bb --- /dev/null +++ b/app/profile/page.tsx @@ -0,0 +1,47 @@ +"use client"; + +import { useEffect } from "react"; +import { useRouter } from "next/navigation"; +import { useAuth } from "@/components/auth/auth-provider"; +import { ProfileForm } from "./profile-form"; + +/** + * /profile is a client-rendered page guarded by useAuth. + * + * We deliberately do NOT use Next.js middleware for the auth gate: the + * session cookie is httpOnly on the BFF domain, and middleware-level + * cookie forwarding to the BFF is more code than this approach is worth. + * + * Instead the page renders a loading state until AuthProvider hydrates, + * then either shows the form or redirects to /login. + */ +export default function ProfilePage() { + const router = useRouter(); + const { user, loading } = useAuth(); + + useEffect(() => { + if (!loading && !user) { + router.replace("/login?next=/profile"); + } + }, [loading, user, router]); + + if (loading || !user) { + return ( +
+

Loading…

+
+ ); + } + + return ( +
+
+

Your profile

+

+ Update how you appear across Prabhupāda Says. +

+
+ +
+ ); +} diff --git a/app/profile/profile-form.tsx b/app/profile/profile-form.tsx new file mode 100644 index 00000000..c48e6386 --- /dev/null +++ b/app/profile/profile-form.tsx @@ -0,0 +1,197 @@ +"use client"; + +import { useRef, useState } from "react"; +import { useRouter } from "next/navigation"; +import Image from "next/image"; +import { useAuth } from "@/components/auth/auth-provider"; +import { auth, AuthError, type User } from "@/lib/auth"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; + +/** + * Profile edit form. Two responsibilities: + * + * 1. Inline name edit (debounced save on form submit). + * 2. Avatar upload via R2 presigned PUT: + * click [Change] → presign → PUT to R2 → PATCH /users/me with new URL. + * + * The new avatar URL appended with a `?v=` cache-buster forces the + * browser to fetch the new image even if the key happened to collide + * (extremely unlikely given our random suffix, but cheap insurance). + */ +export function ProfileForm({ user }: { user: User }) { + const router = useRouter(); + const { logOut, setUser } = useAuth(); + const fileInput = useRef(null); + + const [name, setName] = useState(user.name); + const [avatarUrl, setAvatarUrl] = useState(user.avatar_url); + const [saving, setSaving] = useState(false); + const [uploading, setUploading] = useState(false); + const [error, setError] = useState(null); + const [success, setSuccess] = useState(null); + + async function onSave(e: React.SyntheticEvent) { + e.preventDefault(); + setError(null); + setSuccess(null); + setSaving(true); + try { + const updated = await auth.updateProfile(name); + setUser(updated); + setSuccess("Profile saved."); + } catch (err) { + setError(err instanceof AuthError ? err.message : "Could not save."); + } finally { + setSaving(false); + } + } + + async function onAvatarChange(e: React.ChangeEvent) { + const file = e.target.files?.[0]; + if (!file) return; + setError(null); + setSuccess(null); + setUploading(true); + try { + const publicURL = await auth.uploadAvatar(file); + const updated = await auth.updateProfile(undefined, publicURL); + setUser(updated); + setAvatarUrl(updated.avatar_url); + setSuccess("Avatar updated."); + } catch (err) { + setError(err instanceof AuthError ? err.message : "Upload failed."); + } finally { + setUploading(false); + if (fileInput.current) fileInput.current.value = ""; + } + } + + async function onRemoveAvatar() { + setError(null); + setUploading(true); + try { + const updated = await auth.updateProfile(undefined, ""); + setUser(updated); + setAvatarUrl(null); + } catch (err) { + setError(err instanceof AuthError ? err.message : "Could not remove avatar."); + } finally { + setUploading(false); + } + } + + async function onLogout() { + await logOut(); + router.push("/"); + router.refresh(); + } + + return ( +
+
+

Avatar

+
+ +
+ + + {avatarUrl ? ( + + ) : null} +
+
+
+ +
+

Details

+
+ + +

+ Email changes aren't supported yet — drop us a note if you need this. +

+
+
+ + setName(e.target.value)} + maxLength={120} + required + /> +
+ {error ?

{error}

: null} + {success ?

{success}

: null} +
+ + +
+
+
+ ); +} + +function AvatarPreview({ src, name }: { src: string | null; name: string }) { + if (src) { + return ( + {name} + ); + } + const initials = + name + .split(/\s+/) + .map((p) => p[0]) + .slice(0, 2) + .join("") + .toUpperCase() || "?"; + return ( +
+ {initials} +
+ ); +} diff --git a/app/search/search-content.tsx b/app/search/search-content.tsx index af224b1d..c10f79f0 100644 --- a/app/search/search-content.tsx +++ b/app/search/search-content.tsx @@ -3,11 +3,9 @@ import { useState, useEffect, useCallback, useRef, Suspense } from "react"; import { useSearchParams, useRouter } from "next/navigation"; import Link from "next/link"; -import { PageHeader } from "@/components/page-header"; import { Input } from "@/components/ui/input"; import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; -import { Card, CardContent } from "@/components/ui/card"; import { cn } from "@/lib/utils"; import { buildSearchResultUrl } from "@/lib/text-highlight"; import { @@ -23,6 +21,7 @@ import { FileStack, ChevronLeft, ChevronRight, + WholeWord, } from "lucide-react"; // --------------------------------------------------------------------------- @@ -185,6 +184,7 @@ function SearchInner() { const initialQuery = searchParams.get("q") || ""; const initialType = searchParams.get("type") || "all"; const initialPage = parseInt(searchParams.get("page") || "1", 10); + const initialExact = searchParams.get("exact") === "1"; const [inputValue, setInputValue] = useState(initialQuery); // selectedTypes holds tab values (e.g., "books", "letters") — not raw collection names. @@ -192,6 +192,9 @@ function SearchInner() { bffTypeToTabs(initialType), ); const [currentPage, setCurrentPage] = useState(initialPage); + // When `exactMatch` is on, the BFF disables typo tolerance + prefix expansion. + // Toggling it resets pagination (handled below) and resyncs the URL. + const [exactMatch, setExactMatch] = useState(initialExact); // Convert selected tabs to the BFF type param ("books" → "verses,prose") const activeTypeParam = tabsToBffType(selectedTypes); @@ -211,25 +214,32 @@ function SearchInner() { // Sync URL when state changes const pushUrl = useCallback( - (q: string, typeParam: string, page: number) => { + (q: string, typeParam: string, page: number, exact: boolean) => { const params = new URLSearchParams(); if (q.trim()) params.set("q", q.trim()); if (typeParam !== "all") params.set("type", typeParam); if (page > 1) params.set("page", String(page)); + if (exact) params.set("exact", "1"); const qs = params.toString(); router.replace(`/search${qs ? `?${qs}` : ""}`, { scroll: false }); }, [router], ); - // Reset page to 1 when query or type changes. + // Reset page to 1 when query, type, or exact-mode changes. // Uses the "compare previous value during render" pattern // recommended by React instead of a setState-in-effect. const [prevQuery, setPrevQuery] = useState(debouncedQuery); const [prevType, setPrevType] = useState(activeTypeParam); - if (prevQuery !== debouncedQuery || prevType !== activeTypeParam) { + const [prevExact, setPrevExact] = useState(exactMatch); + if ( + prevQuery !== debouncedQuery || + prevType !== activeTypeParam || + prevExact !== exactMatch + ) { setPrevQuery(debouncedQuery); setPrevType(activeTypeParam); + setPrevExact(exactMatch); setCurrentPage(1); } @@ -237,12 +247,12 @@ function SearchInner() { useEffect(() => { const query = debouncedQuery.trim(); if (!query) { - pushUrl("", urlTypeParam, 1); + pushUrl("", urlTypeParam, 1, exactMatch); return; } setIsLoading(true); - pushUrl(query, urlTypeParam, currentPage); + pushUrl(query, urlTypeParam, currentPage, exactMatch); const controller = new AbortController(); @@ -252,6 +262,7 @@ function SearchInner() { page: String(currentPage), limit: "20", }); + if (exactMatch) params.set("exact", "1"); fetch(`/api/search?${params.toString()}`, { signal: controller.signal }) .then((res) => res.json()) @@ -276,7 +287,7 @@ function SearchInner() { }); return () => controller.abort(); - }, [debouncedQuery, activeTypeParam, urlTypeParam, currentPage, pushUrl]); + }, [debouncedQuery, activeTypeParam, urlTypeParam, currentPage, exactMatch, pushUrl]); // Auto-focus the input on mount useEffect(() => { @@ -312,40 +323,67 @@ function SearchInner() { }; return ( -
- - - ← Home - -

Search

-

- Search across all of Śrīla Prabhupāda's books, letters, lectures, - and more +

+ {/* Editorial hero — title in serif, supporting copy underneath */} +
+

+ Search

- +

+ Find a word, a verse, a name. +

+

+ Typo-tolerant search across every book, letter, lecture, essay, + journal, magazine, and glossary entry. Diacritics are ignored, so + “Krsna” finds “Kṛṣṇa.” +

+
-
- {/* Search Input */} +
+ {/* Search input — the right edge carries the loading spinner (when + fetching) and the exact-match toggle button. Right padding on the + input reserves space for both so text never collides with them. */}
setInputValue(e.target.value)} - className="h-14 pl-12 pr-4 text-lg rounded-xl" + className="h-14 pl-12 pr-24 text-lg rounded-xl ring-1 ring-foreground/10 border-transparent shadow-sm focus-visible:ring-2 focus-visible:ring-primary/40" /> {isLoading && ( - + )} +
{/* Content Type Tabs */} -
+
{CONTENT_TABS.map((tab) => { const active = isTypeActive(tab.value); return ( @@ -388,15 +426,9 @@ function SearchInner() {
)} - {/* No query */} + {/* No query — show editorial empty state with example queries */} {!isLoading && !hasSearched && ( -
- -

- Search across all of Śrīla Prabhupāda's books, letters, - lectures, and more -

-
+ setInputValue(q)} /> )} {/* No results */} @@ -561,7 +593,7 @@ function ResultCard({ result }: { result: SearchResult }) { return ( { try { sessionStorage.setItem("spsays_from_search", "1"); @@ -570,40 +602,79 @@ function ResultCard({ result }: { result: SearchResult }) { } }} > - - - {/* Collection badge */} -
- - - {config.label} - -
+
+ {/* Collection badge */} +
+ + + {config.label} + +
- {/* Title */} -

- {result.title} -

+ {/* Title — serif for editorial weight */} +

+ {result.title} +

- {/* Snippet with highlighted matches */} -

+ {/* Snippet with highlighted matches */} +

- {/* Meta information */} - - - + {/* Meta information */} + +

); } +// --------------------------------------------------------------------------- +// Empty-state example chips +// --------------------------------------------------------------------------- + +const EXAMPLE_QUERIES: { label: string; query: string }[] = [ + { label: "sarva dharman parityajya", query: "sarva dharman parityajya" }, + { label: "letter to mukunda", query: "letter to mukunda" }, + { label: "bhakti yoga", query: "bhakti yoga" }, + { label: "tulasi", query: "tulasi" }, + { label: "morning walk los angeles", query: "morning walk los angeles" }, +]; + +function EmptyStateExamples({ onPick }: { onPick: (q: string) => void }) { + return ( +
+

+ Try one of these +

+
+ {EXAMPLE_QUERIES.map((ex) => ( + + ))} +
+

+ Press{" "} + + / + {" "} + from any page to jump here. +

+
+ ); +} + // --------------------------------------------------------------------------- // Result Meta — varies by collection // --------------------------------------------------------------------------- @@ -665,25 +736,8 @@ export function SearchContent() { return ( - - - ← Home - -

Search

-

- Search across all of Śrīla Prabhupāda's books, letters, - lectures, and more -

-
-
-
- -
-
+
+
} > diff --git a/app/verify-email/page.tsx b/app/verify-email/page.tsx new file mode 100644 index 00000000..3ac92cdd --- /dev/null +++ b/app/verify-email/page.tsx @@ -0,0 +1,82 @@ +"use client"; + +import { Suspense, useEffect, useState } from "react"; +import Link from "next/link"; +import { useSearchParams } from "next/navigation"; +import { auth, AuthError } from "@/lib/auth"; +import { useAuth } from "@/components/auth/auth-provider"; +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from "@/components/ui/card"; + +function VerifyEmailContent() { + const params = useSearchParams(); + const token = params.get("token") ?? ""; + const { refresh } = useAuth(); + const [status, setStatus] = useState<"pending" | "ok" | "error">("pending"); + const [message, setMessage] = useState(""); + + useEffect(() => { + if (!token) { + setStatus("error"); + setMessage("Verification link is missing a token."); + return; + } + void (async () => { + try { + await auth.verifyEmail(token); + // Pull the freshly-verified user so the banner disappears immediately. + await refresh(); + setStatus("ok"); + } catch (err) { + setStatus("error"); + setMessage(err instanceof AuthError ? err.message : "Verification failed."); + } + })(); + }, [token, refresh]); + + return ( +
+
+ + + + {status === "pending" + ? "Verifying your email…" + : status === "ok" + ? "Email verified ✓" + : "Verification failed"} + + + {status === "pending" + ? "One moment." + : status === "ok" + ? "Thanks — your email is confirmed." + : message} + + + + + Back to home + + + +
+
+ ); +} + +export default function VerifyEmailPage() { + return ( + + + + ); +} diff --git a/components/auth/auth-card.tsx b/components/auth/auth-card.tsx new file mode 100644 index 00000000..7a9445fe --- /dev/null +++ b/components/auth/auth-card.tsx @@ -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 ( + + + {title} + {description ? {description} : null} + + {children} + {footer ? ( +
{footer}
+ ) : null} +
+ ); +} + +export function AuthFooterLink({ + prefix, + href, + label, +}: { + prefix: string; + href: string; + label: string; +}) { + return ( +

+ {prefix}{" "} + + {label} + +

+ ); +} diff --git a/components/auth/auth-provider.tsx b/components/auth/auth-provider.tsx new file mode 100644 index 00000000..d1f3901a --- /dev/null +++ b/components/auth/auth-provider.tsx @@ -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; + logIn: (email: string, password: string) => Promise; + logOut: () => Promise; + refresh: () => Promise; + /** Replace the user state without a network round-trip (after profile edit). */ + setUser: (u: User | null) => void; +}; + +const AuthContext = createContext(null); + +export function AuthProvider({ children }: { children: React.ReactNode }) { + const [user, setUser] = useState(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( + () => ({ user, loading, signUp, logIn, logOut, refresh, setUser }), + [user, loading, signUp, logIn, logOut, refresh], + ); + + return {children}; +} + +export function useAuth(): AuthContextValue { + const ctx = useContext(AuthContext); + if (!ctx) { + throw new Error("useAuth must be used inside "); + } + return ctx; +} diff --git a/components/auth/user-menu.tsx b/components/auth/user-menu.tsx new file mode 100644 index 00000000..39c4d2a6 --- /dev/null +++ b/components/auth/user-menu.tsx @@ -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 ; + } + + 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 ( + + + Log in + + ); + } + + async function onLogOut() { + await logOut(); + router.push("/"); + router.refresh(); + } + + return ( + + + {user.avatar_url ? ( + {user.name} + ) : ( + + {initials(user.name)} + + )} + + + + +
+

+ {user.name} +

+

+ {user.email} +

+
+
+ + Profile + + + + + + + ); +} diff --git a/components/auth/verify-email-banner.tsx b/components/auth/verify-email-banner.tsx new file mode 100644 index 00000000..45b791e9 --- /dev/null +++ b/components/auth/verify-email-banner.tsx @@ -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(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 ( +
+
+

+ {sent + ? "✓ Verification email sent — check your inbox." + : error + ? error + : `Verify your email (${user.email}) to secure your account.`} +

+
+ {!sent ? ( + + ) : null} + +
+
+
+ ); +} diff --git a/components/page-header.tsx b/components/page-header.tsx index 5beade01..2f7dc7bd 100644 --- a/components/page-header.tsx +++ b/components/page-header.tsx @@ -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 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 (
- {/* - 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. - */} -
- {/* 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. */} -
- - {/* Hide reading settings on mobile when search is active */} -
- -
-
- - {/* Title / logo area — hidden on mobile when search is active */} -
- {fromSearch ? ( - <> - - {/* Hide the page's own breadcrumb link (always the first ) */} -
{children}
- - ) : ( - children - )} -
+
); diff --git a/components/site-footer.tsx b/components/site-footer.tsx index 1a4fa103..244cce85 100644 --- a/components/site-footer.tsx +++ b/components/site-footer.tsx @@ -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 ( -
-
-

- Team {" "} - Prabhupāda Says -

- - +

+ A complete, searchable library of His Divine Grace A.C. Bhaktivedanta + Swami Prabhupāda's books, letters, and teachings. +

+
+ + {/* Explore */} + + + {/* Resources */} + +
+ + {/* Copyright row */} +
+
+

+ © {new Date().getFullYear()} Team Prabhupāda Says · Hare Kṛṣṇa +

+

All glories to Śrīla Prabhupāda

+
); } + +function FooterColumn({ + title, + items, +}: { + title: string; + items: { href: string; label: string }[]; +}) { + return ( +
+

+ {title} +

+
    + {items.map((item) => ( +
  • + + {item.label} + +
  • + ))} +
+
+ ); +} diff --git a/components/site-header.tsx b/components/site-header.tsx new file mode 100644 index 00000000..aa67c872 --- /dev/null +++ b/components/site-header.tsx @@ -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 ( +
+
+ {/* Mobile hamburger — visible below md, hidden when search is expanded */} +
+ +
+ + {/* 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). */} + + + Prabhupāda + + + Says + + + + {/* 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). */} + + + {/* Spacer pushes the action cluster to the right on mobile */} +
+ + {/* 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. */} +
+ +
+ +
+
+ +
+
+
+
+ ); +} diff --git a/components/site-mobile-nav.tsx b/components/site-mobile-nav.tsx new file mode 100644 index 00000000..4b531621 --- /dev/null +++ b/components/site-mobile-nav.tsx @@ -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; +}) { + const [open, setOpen] = useState(false); + + return ( + + + + + + + + Prabhupāda Says + + + Quickly jump between sections of Prabhupāda Says. + + + + + + + ); +} diff --git a/components/ui/label.tsx b/components/ui/label.tsx new file mode 100644 index 00000000..9c60ae8a --- /dev/null +++ b/components/ui/label.tsx @@ -0,0 +1,22 @@ +import * as React from "react"; +import { cn } from "@/lib/utils"; + +// Minimal shadcn-style