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
+70
View File
@@ -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<string | null>(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 (
<AuthCard
title="Check your inbox"
description="If an account exists for that email, we've sent a reset link. It will expire in 1 hour."
footer={<AuthFooterLink prefix="Back to" href="/login" label="log in" />}
>
<p className="text-sm text-muted-foreground">
Didn&apos;t get an email? Check your spam folder or try again in a minute.
</p>
</AuthCard>
);
}
return (
<AuthCard
title="Reset your password"
description="Enter your email and we'll send you a reset link."
footer={<AuthFooterLink prefix="Remembered it?" href="/login" label="Log in" />}
>
<form onSubmit={onSubmit} className="space-y-6">
<div className="space-y-1.5">
<Label htmlFor="email">Email</Label>
<Input
id="email"
type="email"
autoComplete="email"
required
value={email}
onChange={(e) => setEmail(e.target.value)}
/>
</div>
<br />
{error ? <p className="text-sm text-destructive">{error}</p> : null}
<Button type="submit" disabled={submitting} className="w-full h-11 text-base">
{submitting ? "Sending…" : "Send reset link"}
</Button>
</form>
</AuthCard>
);
}
+16
View File
@@ -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 (
<main className="flex items-center justify-center px-4 py-12 min-h-[90vh]">
<div className="w-full max-w-md">{children}</div>
</main>
);
}
+77
View File
@@ -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<string | null>(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 (
<AuthCard
title="Welcome back"
description="Log in to access your bookmarks, notes, and collections."
footer={
<div className="space-y-1">
<AuthFooterLink prefix="No account?" href="/signup" label="Create one" />
<AuthFooterLink prefix="Forgot your password?" href="/forgot-password" label="Reset it" />
</div>
}
>
<form onSubmit={onSubmit} className="space-y-6">
<div className="space-y-1.5">
<Label htmlFor="email">Email</Label>
<Input
id="email"
type="email"
autoComplete="email"
required
value={email}
onChange={(e) => setEmail(e.target.value)}
/>
</div>
<div className="space-y-1.5">
<Label htmlFor="password">Password</Label>
<Input
id="password"
type="password"
autoComplete="current-password"
required
value={password}
onChange={(e) => setPassword(e.target.value)}
/>
</div>
<br />
{error ? <p className="text-sm text-destructive">{error}</p> : null}
<Button type="submit" disabled={submitting} className="w-full h-11 text-base">
{submitting ? "Logging in…" : "Log in"}
</Button>
</form>
</AuthCard>
);
}
+108
View File
@@ -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<string | null>(null);
const [submitting, setSubmitting] = useState(false);
const [done, setDone] = useState(false);
if (!token) {
return (
<AuthCard title="Invalid reset link" description="The link is missing a token. Please request a new one.">
<AuthFooterLink prefix="Need a new link?" href="/forgot-password" label="Request one" />
</AuthCard>
);
}
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 (
<AuthCard
title="Password updated"
description="Redirecting you to the login page…"
>
<AuthFooterLink prefix="Take me there now —" href="/login" label="Log in" />
</AuthCard>
);
}
return (
<AuthCard
title="Set a new password"
description="Choose a strong password you haven't used before."
>
<form onSubmit={onSubmit} className="space-y-6">
<div className="space-y-1.5">
<Label htmlFor="password">New password</Label>
<Input
id="password"
type="password"
autoComplete="new-password"
minLength={8}
required
value={password}
onChange={(e) => setPassword(e.target.value)}
/>
</div>
<div className="space-y-1.5">
<Label htmlFor="confirm">Confirm password</Label>
<Input
id="confirm"
type="password"
autoComplete="new-password"
minLength={8}
required
value={confirm}
onChange={(e) => setConfirm(e.target.value)}
/>
</div>
<br />
{error ? <p className="text-sm text-destructive">{error}</p> : null}
<Button type="submit" disabled={submitting} className="w-full h-11 text-base">
{submitting ? "Updating…" : "Update password"}
</Button>
</form>
</AuthCard>
);
}
export default function ResetPasswordPage() {
// useSearchParams must be inside Suspense for Next.js streaming.
return (
<Suspense fallback={null}>
<ResetPasswordForm />
</Suspense>
);
}
+89
View File
@@ -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<string | null>(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 (
<AuthCard
title="Create your account"
description="Save bookmarks, build collections, and follow Śrīla Prabhupāda's words across devices."
footer={
<AuthFooterLink prefix="Already registered?" href="/login" label="Log in" />
}
>
<form onSubmit={onSubmit} className="space-y-6">
<div className="space-y-1.5">
<Label htmlFor="name">Name</Label>
<Input
id="name"
type="text"
autoComplete="name"
required
value={name}
onChange={(e) => setName(e.target.value)}
/>
</div>
<div className="space-y-1.5">
<Label htmlFor="email">Email</Label>
<Input
id="email"
type="email"
autoComplete="email"
required
value={email}
onChange={(e) => setEmail(e.target.value)}
/>
</div>
<div className="space-y-1.5">
<Label htmlFor="password">Password</Label>
<Input
id="password"
type="password"
autoComplete="new-password"
minLength={8}
required
value={password}
onChange={(e) => setPassword(e.target.value)}
/>
<p className="text-xs text-muted-foreground">At least 8 characters.</p>
</div>
<br />
{error ? <p className="text-sm text-destructive">{error}</p> : null}
<Button type="submit" disabled={submitting} className="w-full h-11 text-base">
{submitting ? "Creating account…" : "Sign up"}
</Button>
</form>
</AuthCard>
);
}
+2
View File
@@ -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" },
+23 -1
View File
@@ -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 <html>. 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);
+40 -13
View File
@@ -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({
<html
lang="en"
suppressHydrationWarning
className={`${geistSans.variable} ${geistMono.variable} h-full antialiased`}
className={`${geistSans.variable} ${geistMono.variable} ${lora.variable} ${bricolage.variable} h-full antialiased`}
>
<body className="min-h-full flex flex-col">
<ThemeProvider
@@ -45,17 +68,21 @@ export default function RootLayout({
enableSystem
disableTransitionOnChange
>
<AudioPlayerProvider>
<TooltipProvider>
{children}
<TextSelectionToolbar />
<Suspense fallback={null}>
<TextHighlighter />
</Suspense>
<FloatingPlayer />
<SiteFooter />
</TooltipProvider>
</AudioPlayerProvider>
<AuthProvider>
<AudioPlayerProvider>
<TooltipProvider>
<VerifyEmailBanner />
<SiteHeader />
{children}
<TextSelectionToolbar />
<Suspense fallback={null}>
<TextHighlighter />
</Suspense>
<FloatingPlayer />
<SiteFooter />
</TooltipProvider>
</AudioPlayerProvider>
</AuthProvider>
</ThemeProvider>
</body>
</html>
+266 -267
View File
@@ -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 (
<div className="min-h-screen bg-background">
<PageHeader>
<Logo height={56} className="mb-1" />
</PageHeader>
<main className="max-w-5xl mx-auto px-6 py-8">
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{/* Structured Books (JSON) */}
<Link href="/books" className="block group h-full">
<Card className="h-full transition-all group-hover:shadow-md group-hover:ring-foreground/20">
<CardHeader>
<div className="flex items-center gap-3">
<div className="flex items-center justify-center size-10 rounded-lg bg-primary/10 text-primary">
<BookOpen className="size-5" />
</div>
<div className="flex-1 min-w-0">
<CardTitle className="text-xl font-bold">Books</CardTitle>
<CardDescription>
Structured verse-by-verse editions
</CardDescription>
</div>
</div>
</CardHeader>
<CardContent>
<p className="text-sm text-muted-foreground">
Bhagavad-gītā, Śrīmad-Bhāgavatam, Caitanya-caritāmṛta and
other books with Devanagari, Bengali, transliteration,
synonyms, translations &amp; purports.
</p>
</CardContent>
</Card>
</Link>
{/* Essays */}
<Link href="/essays" className="block group h-full">
<Card className="h-full transition-all group-hover:shadow-md group-hover:ring-foreground/20">
<CardHeader>
<div className="flex items-center gap-3">
<div className="flex items-center justify-center size-10 rounded-lg bg-primary/10 text-primary">
<PenLine className="size-5" />
</div>
<div className="flex-1 min-w-0">
<CardTitle className="text-xl font-bold">Essays</CardTitle>
<CardDescription>
Essays by Śrīla Prabhupāda
</CardDescription>
</div>
</div>
</CardHeader>
<CardContent>
<p className="text-sm text-muted-foreground">
Foundational essays including &ldquo;Krishna The Reservoir Of
Pleasure&rdquo; and &ldquo;Who Is Crazy?&rdquo; — timeless
introductions to the science of Kṛṣṇa consciousness.
</p>
</CardContent>
</Card>
</Link>
{/* Magazines */}
<Link href="/magazines" className="block group h-full">
<Card className="h-full transition-all group-hover:shadow-md group-hover:ring-foreground/20">
<CardHeader>
<div className="flex items-center gap-3">
<div className="flex items-center justify-center size-10 rounded-lg bg-primary/10 text-primary">
<Newspaper className="size-5" />
</div>
<div className="flex-1 min-w-0">
<CardTitle className="text-xl font-bold">
Magazines
</CardTitle>
<CardDescription>Back to Godhead magazines</CardDescription>
</div>
</div>
</CardHeader>
<CardContent>
<p className="text-sm text-muted-foreground">
The original Back to Godhead magazine founded by Śrīla
Prabhupāda with articles on Kṛṣṇa consciousness, Vedic
philosophy, and spiritual life.
</p>
</CardContent>
</Card>
</Link>
{/* Letters */}
<Link href="/letters" className="block group h-full">
<Card className="h-full transition-all group-hover:shadow-md group-hover:ring-foreground/20">
<CardHeader>
<div className="flex items-center gap-3">
<div className="flex items-center justify-center size-10 rounded-lg bg-primary/10 text-primary">
<Mail className="size-5" />
</div>
<div className="flex-1 min-w-0">
<CardTitle className="text-xl font-bold">Letters</CardTitle>
<CardDescription>
6,800+ letters (1947–1977)
</CardDescription>
</div>
</div>
</CardHeader>
<CardContent>
<p className="text-sm text-muted-foreground">
Śrīla Prabhupāda&apos;s personal correspondence — over 6,800
letters to disciples, Godbrothers, government officials, and
well-wishers. Browse by year, recipient, or location.
</p>
</CardContent>
</Card>
</Link>
{/* Transcriptions */}
<Link href="/transcriptions" className="block group h-full">
<Card className="h-full transition-all group-hover:shadow-md group-hover:ring-foreground/20">
<CardHeader>
<div className="flex items-center gap-3">
<div className="flex items-center justify-center size-10 rounded-lg bg-primary/10 text-primary">
<Mic className="size-5" />
</div>
<div className="flex-1 min-w-0">
<CardTitle className="text-xl font-bold">
Transcriptions
</CardTitle>
<CardDescription>
Lectures &amp; conversations
</CardDescription>
</div>
</div>
</CardHeader>
<CardContent>
<p className="text-sm text-muted-foreground">
Transcriptions of Śrīla Prabhupāda&apos;s lectures, morning
walks, room conversations, and dialogues — the living voice of
the Founder-Ācārya preserved in text.
</p>
</CardContent>
</Card>
</Link>
{/* Disciples */}
<Link href="/disciples" className="block group h-full">
<Card className="h-full transition-all group-hover:shadow-md group-hover:ring-foreground/20">
<CardHeader>
<div className="flex items-center gap-3">
<div className="flex items-center justify-center size-10 rounded-lg bg-primary/10 text-primary">
<Users className="size-5" />
</div>
<div className="flex-1 min-w-0">
<CardTitle className="text-xl font-bold">
Disciples
</CardTitle>
<CardDescription>
4,700+ initiated disciples
</CardDescription>
</div>
</div>
</CardHeader>
<CardContent>
<p className="text-sm text-muted-foreground">
A directory of disciples initiated by Śrīla Prabhupāda between
1966 and 1977 — browse by name, initiation place, or year.
</p>
</CardContent>
</Card>
</Link>
{/* Journals */}
<Link href="/journals" className="block group h-full">
<Card className="h-full transition-all group-hover:shadow-md group-hover:ring-foreground/20">
<CardHeader>
<div className="flex items-center gap-3">
<div className="flex items-center justify-center size-10 rounded-lg bg-primary/10 text-primary">
<NotebookPen className="size-5" />
</div>
<div className="flex-1 min-w-0">
<CardTitle className="text-xl font-bold">
Journals
</CardTitle>
<CardDescription>
Personal diaries &amp; journals
</CardDescription>
</div>
</div>
</CardHeader>
<CardContent>
<p className="text-sm text-muted-foreground">
Śrīla Prabhupāda&apos;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.
</p>
</CardContent>
</Card>
</Link>
{/* Glossary */}
<Link href="/glossary" className="block group h-full">
<Card className="h-full transition-all group-hover:shadow-md group-hover:ring-foreground/20">
<CardHeader>
<div className="flex items-center gap-3">
<div className="flex items-center justify-center size-10 rounded-lg bg-primary/10 text-primary">
<BookA className="size-5" />
</div>
<div className="flex-1 min-w-0">
<CardTitle className="text-xl font-bold">
Glossary
</CardTitle>
<CardDescription>
2,700+ Sanskrit &amp; Vedic terms
</CardDescription>
</div>
</div>
</CardHeader>
<CardContent>
<p className="text-sm text-muted-foreground">
A comprehensive, searchable glossary of Sanskrit and Vedic
terms from Śrīla Prabhupāda&apos;s books — definitions,
cross-references, and encyclopedic entries.
</p>
</CardContent>
</Card>
</Link>
{/* Other Documents */}
<Link href="/other-documents" className="block group h-full">
<Card className="h-full transition-all group-hover:shadow-md group-hover:ring-foreground/20">
<CardHeader>
<div className="flex items-center gap-3">
<div className="flex items-center justify-center size-10 rounded-lg bg-primary/10 text-primary">
<FileStack className="size-5" />
</div>
<div className="flex-1 min-w-0">
<CardTitle className="text-xl font-bold">
Other Documents
</CardTitle>
<CardDescription>
Miscellaneous analyses &amp; writings
</CardDescription>
</div>
</div>
</CardHeader>
<CardContent>
<p className="text-sm text-muted-foreground">
Miscellaneous documents, analyses, and writings by Śrīla
Prabhupāda that don&apos;t fit neatly into the books or essays
collections.
</p>
</CardContent>
</Card>
</Link>
<div className="flex-1">
<Hero />
<Featured />
<Explore />
</div>
);
}
// ─── Hero ────────────────────────────────────────────────────────────────
function Hero() {
return (
<section className="max-w-5xl mx-auto px-4 sm:px-6 pt-12 sm:pt-20 pb-10 sm:pb-14">
{/* Eyebrow */}
<p className="uppercase tracking-[0.22em] text-xs font-medium text-muted-foreground">
The Words of Śrīla Prabhupāda
</p>
{/* Hero quote — each sentence on its own line so the visual rhythm is
predictable regardless of viewport. text-balance keeps wraps tidy. */}
<blockquote className="mt-5 font-[family-name:var(--font-title)] font-semibold text-3xl sm:text-4xl md:text-5xl leading-[1.2] text-foreground max-w-3xl text-balance">
<span className="block">“Books are the basis.</span>
<span className="block">Purity is the force.</span>
<span className="block">Preaching is the essence.</span>
<span className="block">Utility is the principle.”</span>
</blockquote>
<p className="mt-6 text-base text-muted-foreground max-w-xl leading-relaxed">
A complete, searchable library of His Divine Grace A.C. Bhaktivedanta
Swami Prabhupāda&apos;s books, letters, lectures, and teachings — set
in modern Unicode Sanskrit, free to read.
</p>
{/* Stat strip */}
<dl className="mt-10 grid grid-cols-2 sm:grid-cols-4 gap-x-6 gap-y-4 max-w-2xl border-y border-border py-5">
{STATS.map((s) => (
<div key={s.label}>
<dd className="font-[family-name:var(--font-title)] font-semibold text-2xl text-foreground tabular-nums">
{s.value}
</dd>
<dt className="text-xs uppercase tracking-[0.14em] text-muted-foreground mt-0.5">
{s.label}
</dt>
</div>
))}
</dl>
</section>
);
}
// ─── Featured (3-up large tiles) ─────────────────────────────────────────
function Featured() {
return (
<section className="max-w-5xl mx-auto px-4 sm:px-6 py-10">
<SectionHeading eyebrow="Start here" title="Three doors in" />
<div className="grid grid-cols-1 md:grid-cols-3 gap-4 sm:gap-5">
{FEATURED.map((f) => (
<FeaturedTile key={f.href} feature={f} />
))}
</div>
</section>
);
}
function FeaturedTile({ feature }: { feature: Feature }) {
const Icon = feature.icon;
return (
<Link
href={feature.href}
className="group relative flex flex-col h-full rounded-2xl bg-card ring-1 ring-foreground/10 hover:ring-foreground/25 hover:shadow-md transition-all p-6"
>
<div className="flex items-start justify-between">
<div className="flex items-center justify-center size-11 rounded-xl bg-primary/12 text-primary">
<Icon className="size-5" />
</div>
</main>
<ArrowUpRight className="size-4 text-muted-foreground/60 group-hover:text-foreground transition-colors" />
</div>
<h3 className="mt-5 font-[family-name:var(--font-title)] font-semibold text-2xl sm:text-[1.6rem] leading-tight text-foreground">
{feature.label}
</h3>
<p className="mt-2 text-sm text-muted-foreground leading-relaxed flex-1">
{feature.blurb}
</p>
{feature.count ? (
<p className="mt-5 text-xs uppercase tracking-[0.14em] font-medium text-muted-foreground">
{feature.count}
</p>
) : null}
</Link>
);
}
// ─── Explore (smaller grid) ──────────────────────────────────────────────
function Explore() {
return (
<section className="max-w-5xl mx-auto px-4 sm:px-6 py-10 pb-16">
<SectionHeading eyebrow="Explore" title="The wider library" />
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-3 sm:gap-4">
{EXPLORE.map((f) => (
<ExploreTile key={f.href} feature={f} />
))}
</div>
</section>
);
}
function ExploreTile({ feature }: { feature: Feature }) {
const Icon = feature.icon;
return (
<Link
href={feature.href}
className="group flex items-start gap-4 rounded-xl bg-card/70 ring-1 ring-foreground/8 hover:ring-foreground/20 hover:bg-card transition-all p-4 sm:p-5"
>
<div className="flex items-center justify-center size-9 rounded-lg bg-secondary text-secondary-foreground/80 shrink-0">
<Icon className="size-4" />
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center justify-between gap-2">
<h4 className="font-[family-name:var(--font-title)] font-semibold text-lg text-foreground leading-tight">
{feature.label}
</h4>
<ArrowUpRight className="size-3.5 text-muted-foreground/50 group-hover:text-foreground transition-colors shrink-0" />
</div>
<p className="text-sm text-muted-foreground mt-1 leading-relaxed">
{feature.blurb}
</p>
{feature.count ? (
<p className="text-xs uppercase tracking-[0.14em] font-medium text-muted-foreground/80 mt-2">
{feature.count}
</p>
) : null}
</div>
</Link>
);
}
// ─── Section heading (eyebrow + title) ───────────────────────────────────
function SectionHeading({
eyebrow,
title,
}: {
eyebrow: string;
title: string;
}) {
return (
<div className="mb-6 flex items-baseline gap-4">
<span className="text-xs uppercase tracking-[0.2em] font-semibold text-muted-foreground">
{eyebrow}
</span>
<span className="flex-1 h-px bg-border" />
<h2 className="font-[family-name:var(--font-title)] font-semibold text-xl sm:text-2xl text-foreground">
{title}
</h2>
</div>
);
}
+47
View File
@@ -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 (
<main className="flex-1 flex items-center justify-center px-4 py-12">
<p className="text-sm text-muted-foreground">Loading…</p>
</main>
);
}
return (
<main className="flex-1 max-w-2xl w-full mx-auto px-4 py-12 space-y-8">
<header>
<h1 className="font-heading text-3xl font-medium">Your profile</h1>
<p className="text-sm text-muted-foreground mt-1">
Update how you appear across Prabhupāda Says.
</p>
</header>
<ProfileForm user={user} />
</main>
);
}
+197
View File
@@ -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=<random>` 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<HTMLInputElement>(null);
const [name, setName] = useState(user.name);
const [avatarUrl, setAvatarUrl] = useState<string | null>(user.avatar_url);
const [saving, setSaving] = useState(false);
const [uploading, setUploading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [success, setSuccess] = useState<string | null>(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<HTMLInputElement>) {
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 (
<div className="space-y-8">
<section className="space-y-4">
<h2 className="font-heading text-lg font-medium">Avatar</h2>
<div className="flex items-center gap-4">
<AvatarPreview src={avatarUrl} name={user.name} />
<div className="flex flex-col gap-2">
<input
ref={fileInput}
type="file"
accept="image/png,image/jpeg,image/webp,image/gif"
hidden
onChange={onAvatarChange}
/>
<Button
variant="outline"
type="button"
onClick={() => fileInput.current?.click()}
disabled={uploading}
className="h-11 px-5 text-base"
>
{uploading ? "Uploading…" : avatarUrl ? "Change avatar" : "Upload avatar"}
</Button>
{avatarUrl ? (
<Button
variant="ghost"
type="button"
onClick={onRemoveAvatar}
disabled={uploading}
className="h-11 px-5 text-base"
>
Remove
</Button>
) : null}
</div>
</div>
</section>
<form onSubmit={onSave} className="space-y-4">
<h2 className="font-heading text-lg font-medium">Details</h2>
<div className="space-y-1.5">
<Label htmlFor="email">Email</Label>
<Input id="email" value={user.email} disabled />
<p className="text-xs text-muted-foreground">
Email changes aren&apos;t supported yet — drop us a note if you need this.
</p>
</div>
<div className="space-y-1.5">
<Label htmlFor="name">Display name</Label>
<Input
id="name"
value={name}
onChange={(e) => setName(e.target.value)}
maxLength={120}
required
/>
</div>
{error ? <p className="text-sm text-destructive">{error}</p> : null}
{success ? <p className="text-sm text-emerald-600">{success}</p> : null}
<div className="flex justify-between items-center pt-2">
<Button
type="submit"
disabled={saving || name === user.name}
className="h-11 px-6 text-base"
>
{saving ? "Saving…" : "Save changes"}
</Button>
<Button
type="button"
variant="ghost"
onClick={onLogout}
className="h-11 px-5 text-base"
>
Log out
</Button>
</div>
</form>
</div>
);
}
function AvatarPreview({ src, name }: { src: string | null; name: string }) {
if (src) {
return (
<Image
src={src}
alt={name}
width={80}
height={80}
className="size-20 rounded-full object-cover ring-1 ring-foreground/10"
unoptimized
/>
);
}
const initials =
name
.split(/\s+/)
.map((p) => p[0])
.slice(0, 2)
.join("")
.toUpperCase() || "?";
return (
<div className="size-20 rounded-full bg-muted flex items-center justify-center text-2xl font-medium text-muted-foreground ring-1 ring-foreground/10">
{initials}
</div>
);
}
+136 -82
View File
@@ -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 (
<div className="min-h-screen bg-background">
<PageHeader>
<Link
href="/"
className="text-sm text-muted-foreground hover:text-foreground transition-colors"
>
&larr; Home
</Link>
<h1 className="mt-3 text-3xl font-bold text-foreground">Search</h1>
<p className="mt-1 text-muted-foreground">
Search across all of Śrīla Prabhupāda&apos;s books, letters, lectures,
and more
<div className="flex-1">
{/* Editorial hero — title in serif, supporting copy underneath */}
<section className="max-w-5xl mx-auto px-4 sm:px-6 pt-10 sm:pt-16 pb-6">
<p className="uppercase tracking-[0.22em] text-xs font-semibold text-muted-foreground">
Search
</p>
</PageHeader>
<h1 className="mt-4 font-[family-name:var(--font-title)] font-semibold text-4xl sm:text-5xl leading-tight text-foreground">
Find a word, a verse, a name.
</h1>
<p className="mt-3 max-w-xl text-base text-muted-foreground leading-relaxed">
Typo-tolerant search across every book, letter, lecture, essay,
journal, magazine, and glossary entry. Diacritics are ignored, so
&ldquo;Krsna&rdquo; finds &ldquo;Kṛṣṇa.&rdquo;
</p>
</section>
<main className="max-w-5xl mx-auto px-6 py-8">
{/* Search Input */}
<main className="max-w-5xl mx-auto px-4 sm:px-6 pb-16">
{/* 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. */}
<div className="relative">
<Search className="absolute left-4 top-1/2 -translate-y-1/2 size-5 text-muted-foreground pointer-events-none" />
<Input
ref={inputRef}
type="search"
placeholder="Search for verses, letters, lectures, terms..."
placeholder="Try “sarva dharman parityajya” or “letter to mukunda”…"
value={inputValue}
onChange={(e) => 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 && (
<Loader2 className="absolute right-4 top-1/2 -translate-y-1/2 size-5 text-muted-foreground animate-spin" />
<Loader2 className="absolute right-14 top-1/2 -translate-y-1/2 size-5 text-muted-foreground animate-spin" />
)}
<button
type="button"
onClick={() => setExactMatch((v) => !v)}
aria-pressed={exactMatch}
aria-label={
exactMatch
? "Exact match is on — only literal keyword matches"
: "Enable exact match — disables typo tolerance"
}
title={
exactMatch
? "Exact match: on (no typo tolerance, no partial words)"
: "Exact match: off — toggle to require literal keyword matches"
}
className={cn(
"absolute right-2 top-1/2 -translate-y-1/2 size-10 inline-flex items-center justify-center rounded-lg transition-colors outline-none",
"focus-visible:ring-2 focus-visible:ring-primary/40",
exactMatch
? "bg-primary text-primary-foreground hover:bg-primary/90"
: "text-muted-foreground hover:bg-muted hover:text-foreground",
)}
>
<WholeWord className="size-5" />
</button>
</div>
{/* Content Type Tabs */}
<div className="mt-6 flex flex-wrap gap-2">
<div className="mt-5 flex flex-wrap gap-2">
{CONTENT_TABS.map((tab) => {
const active = isTypeActive(tab.value);
return (
@@ -388,15 +426,9 @@ function SearchInner() {
</div>
)}
{/* No query */}
{/* No query — show editorial empty state with example queries */}
{!isLoading && !hasSearched && (
<div className="flex flex-col items-center justify-center py-16 text-center">
<Search className="size-12 text-muted-foreground/40 mb-4" />
<p className="text-lg text-muted-foreground">
Search across all of Śrīla Prabhupāda&apos;s books, letters,
lectures, and more
</p>
</div>
<EmptyStateExamples onPick={(q) => setInputValue(q)} />
)}
{/* No results */}
@@ -561,7 +593,7 @@ function ResultCard({ result }: { result: SearchResult }) {
return (
<Link
href={href}
className="block group"
className="block group rounded-xl bg-card/60 ring-1 ring-foreground/8 hover:ring-foreground/25 hover:bg-card transition-all px-4 sm:px-5 py-4"
onClick={() => {
try {
sessionStorage.setItem("spsays_from_search", "1");
@@ -570,40 +602,79 @@ function ResultCard({ result }: { result: SearchResult }) {
}
}}
>
<Card className="transition-all group-hover:shadow-md group-hover:ring-foreground/20 cursor-pointer">
<CardContent className="flex flex-col gap-2">
{/* Collection badge */}
<div className="flex items-center gap-2">
<span
className={cn(
"inline-flex items-center gap-1 rounded-full px-2 py-0.5 text-xs font-medium",
config.color,
)}
>
<Icon className="size-3" />
{config.label}
</span>
</div>
<div className="flex flex-col gap-2">
{/* Collection badge */}
<div className="flex items-center gap-2">
<span
className={cn(
"inline-flex items-center gap-1 rounded-full px-2 py-0.5 text-[0.65rem] uppercase tracking-[0.12em] font-medium",
config.color,
)}
>
<Icon className="size-3" />
{config.label}
</span>
</div>
{/* Title */}
<p className="text-base font-semibold text-foreground group-hover:text-primary transition-colors leading-snug">
{result.title}
</p>
{/* Title — serif for editorial weight */}
<p className="font-[family-name:var(--font-title)] font-semibold text-lg text-foreground group-hover:text-primary transition-colors leading-snug">
{result.title}
</p>
{/* Snippet with highlighted matches */}
<p
className="text-sm text-muted-foreground leading-relaxed [&_mark]:bg-yellow-200/80 [&_mark]:text-foreground [&_mark]:rounded-sm [&_mark]:px-0.5 dark:[&_mark]:bg-yellow-500/30"
dangerouslySetInnerHTML={{ __html: result.snippet }}
/>
{/* Snippet with highlighted matches */}
<p
className="text-sm text-muted-foreground leading-relaxed [&_mark]:bg-primary/20 [&_mark]:text-foreground [&_mark]:rounded-sm [&_mark]:px-0.5 dark:[&_mark]:bg-primary/30"
dangerouslySetInnerHTML={{ __html: result.snippet }}
/>
{/* Meta information */}
<ResultMeta result={result} />
</CardContent>
</Card>
{/* Meta information */}
<ResultMeta result={result} />
</div>
</Link>
);
}
// ---------------------------------------------------------------------------
// 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 (
<div className="py-12 sm:py-16">
<p className="text-xs uppercase tracking-[0.2em] font-semibold text-muted-foreground text-center">
Try one of these
</p>
<div className="mt-5 flex flex-wrap items-center justify-center gap-2 max-w-2xl mx-auto">
{EXAMPLE_QUERIES.map((ex) => (
<button
key={ex.query}
type="button"
onClick={() => onPick(ex.query)}
className="rounded-full bg-card ring-1 ring-foreground/10 hover:ring-foreground/30 px-3.5 py-1.5 text-sm font-medium text-foreground transition-all"
>
{ex.label}
</button>
))}
</div>
<p className="mt-8 text-center text-xs text-muted-foreground">
Press{" "}
<kbd className="rounded border border-border bg-card px-1.5 py-0.5 text-[0.7rem]">
/
</kbd>{" "}
from any page to jump here.
</p>
</div>
);
}
// ---------------------------------------------------------------------------
// Result Meta — varies by collection
// ---------------------------------------------------------------------------
@@ -665,25 +736,8 @@ export function SearchContent() {
return (
<Suspense
fallback={
<div className="min-h-screen bg-background">
<PageHeader>
<Link
href="/"
className="text-sm text-muted-foreground hover:text-foreground transition-colors"
>
&larr; Home
</Link>
<h1 className="mt-3 text-3xl font-bold text-foreground">Search</h1>
<p className="mt-1 text-muted-foreground">
Search across all of Śrīla Prabhupāda&apos;s books, letters,
lectures, and more
</p>
</PageHeader>
<main className="max-w-5xl mx-auto px-6 py-8">
<div className="flex items-center justify-center py-16 text-muted-foreground">
<Loader2 className="size-5 animate-spin" />
</div>
</main>
<div className="flex-1 max-w-5xl mx-auto px-4 sm:px-6 py-16 flex items-center justify-center text-muted-foreground">
<Loader2 className="size-5 animate-spin" />
</div>
}
>
+82
View File
@@ -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<string>("");
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 (
<main className="flex-1 flex items-center justify-center px-4 py-12">
<div className="w-full max-w-md">
<Card>
<CardHeader>
<CardTitle className="text-2xl">
{status === "pending"
? "Verifying your email…"
: status === "ok"
? "Email verified ✓"
: "Verification failed"}
</CardTitle>
<CardDescription>
{status === "pending"
? "One moment."
: status === "ok"
? "Thanks — your email is confirmed."
: message}
</CardDescription>
</CardHeader>
<CardContent>
<Link
href="/"
className="text-primary underline-offset-4 hover:underline"
>
Back to home
</Link>
</CardContent>
</Card>
</div>
</main>
);
}
export default function VerifyEmailPage() {
return (
<Suspense fallback={null}>
<VerifyEmailContent />
</Suspense>
);
}