feat: add auth
This commit is contained in:
@@ -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'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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -8,6 +8,7 @@ export async function GET(request: NextRequest) {
|
|||||||
const type = searchParams.get("type") || "all";
|
const type = searchParams.get("type") || "all";
|
||||||
const page = searchParams.get("page") || "1";
|
const page = searchParams.get("page") || "1";
|
||||||
const limit = searchParams.get("limit") || "20";
|
const limit = searchParams.get("limit") || "20";
|
||||||
|
const exact = searchParams.get("exact") === "1" ? "1" : "";
|
||||||
|
|
||||||
if (!q.trim()) {
|
if (!q.trim()) {
|
||||||
return NextResponse.json({
|
return NextResponse.json({
|
||||||
@@ -27,6 +28,7 @@ export async function GET(request: NextRequest) {
|
|||||||
url.searchParams.set("type", type);
|
url.searchParams.set("type", type);
|
||||||
url.searchParams.set("page", page);
|
url.searchParams.set("page", page);
|
||||||
url.searchParams.set("limit", limit);
|
url.searchParams.set("limit", limit);
|
||||||
|
if (exact) url.searchParams.set("exact", "1");
|
||||||
|
|
||||||
const res = await fetch(url.toString(), {
|
const res = await fetch(url.toString(), {
|
||||||
headers: { Accept: "application/json" },
|
headers: { Accept: "application/json" },
|
||||||
|
|||||||
+23
-1
@@ -9,7 +9,21 @@
|
|||||||
--color-foreground: var(--foreground);
|
--color-foreground: var(--foreground);
|
||||||
--font-sans: var(--font-sans);
|
--font-sans: var(--font-sans);
|
||||||
--font-mono: var(--font-geist-mono);
|
--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-ring: var(--sidebar-ring);
|
||||||
--color-sidebar-border: var(--sidebar-border);
|
--color-sidebar-border: var(--sidebar-border);
|
||||||
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
|
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
|
||||||
@@ -48,6 +62,14 @@
|
|||||||
--radius-4xl: calc(var(--radius) * 2.6);
|
--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" ── */
|
/* ── Light mode — "Aged Parchment" ── */
|
||||||
:root {
|
:root {
|
||||||
--background: oklch(0.957 0.012 79.8);
|
--background: oklch(0.957 0.012 79.8);
|
||||||
|
|||||||
+29
-2
@@ -1,13 +1,16 @@
|
|||||||
import type { Metadata } from "next";
|
import type { Metadata } from "next";
|
||||||
import { Suspense } from "react";
|
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 { ogDefault } from "@/lib/og";
|
||||||
import { ThemeProvider } from "@/components/theme-provider";
|
import { ThemeProvider } from "@/components/theme-provider";
|
||||||
import { TooltipProvider } from "@/components/ui/tooltip";
|
import { TooltipProvider } from "@/components/ui/tooltip";
|
||||||
|
import { SiteHeader } from "@/components/site-header";
|
||||||
import { SiteFooter } from "@/components/site-footer";
|
import { SiteFooter } from "@/components/site-footer";
|
||||||
import { TextSelectionToolbar } from "@/components/text-selection-toolbar";
|
import { TextSelectionToolbar } from "@/components/text-selection-toolbar";
|
||||||
import { TextHighlighter } from "@/components/text-highlighter";
|
import { TextHighlighter } from "@/components/text-highlighter";
|
||||||
import { AudioPlayerProvider, FloatingPlayer } from "@/components/audio-player";
|
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";
|
import "./globals.css";
|
||||||
|
|
||||||
const geistSans = Geist({
|
const geistSans = Geist({
|
||||||
@@ -20,6 +23,26 @@ const geistMono = Geist_Mono({
|
|||||||
subsets: ["latin"],
|
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 = {
|
export const metadata: Metadata = {
|
||||||
title: "Prabhupāda Says",
|
title: "Prabhupāda Says",
|
||||||
description:
|
description:
|
||||||
@@ -36,7 +59,7 @@ export default function RootLayout({
|
|||||||
<html
|
<html
|
||||||
lang="en"
|
lang="en"
|
||||||
suppressHydrationWarning
|
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">
|
<body className="min-h-full flex flex-col">
|
||||||
<ThemeProvider
|
<ThemeProvider
|
||||||
@@ -45,8 +68,11 @@ export default function RootLayout({
|
|||||||
enableSystem
|
enableSystem
|
||||||
disableTransitionOnChange
|
disableTransitionOnChange
|
||||||
>
|
>
|
||||||
|
<AuthProvider>
|
||||||
<AudioPlayerProvider>
|
<AudioPlayerProvider>
|
||||||
<TooltipProvider>
|
<TooltipProvider>
|
||||||
|
<VerifyEmailBanner />
|
||||||
|
<SiteHeader />
|
||||||
{children}
|
{children}
|
||||||
<TextSelectionToolbar />
|
<TextSelectionToolbar />
|
||||||
<Suspense fallback={null}>
|
<Suspense fallback={null}>
|
||||||
@@ -56,6 +82,7 @@ export default function RootLayout({
|
|||||||
<SiteFooter />
|
<SiteFooter />
|
||||||
</TooltipProvider>
|
</TooltipProvider>
|
||||||
</AudioPlayerProvider>
|
</AudioPlayerProvider>
|
||||||
|
</AuthProvider>
|
||||||
</ThemeProvider>
|
</ThemeProvider>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
+248
-249
@@ -1,280 +1,279 @@
|
|||||||
import Link from "next/link";
|
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 {
|
import {
|
||||||
BookOpen,
|
BookOpen,
|
||||||
BookA,
|
|
||||||
PenLine,
|
|
||||||
FileStack,
|
|
||||||
NotebookPen,
|
|
||||||
Newspaper,
|
|
||||||
Mail,
|
Mail,
|
||||||
Users,
|
|
||||||
Mic,
|
Mic,
|
||||||
|
PenLine,
|
||||||
|
Newspaper,
|
||||||
|
NotebookPen,
|
||||||
|
BookA,
|
||||||
|
Users,
|
||||||
|
FileStack,
|
||||||
|
ArrowUpRight,
|
||||||
} from "lucide-react";
|
} 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() {
|
export default function Home() {
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-background">
|
<div className="flex-1">
|
||||||
<PageHeader>
|
<Hero />
|
||||||
<Logo height={56} className="mb-1" />
|
<Featured />
|
||||||
</PageHeader>
|
<Explore />
|
||||||
|
|
||||||
<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>
|
||||||
<div className="flex-1 min-w-0">
|
);
|
||||||
<CardTitle className="text-xl font-bold">Books</CardTitle>
|
}
|
||||||
<CardDescription>
|
|
||||||
Structured verse-by-verse editions
|
// ─── Hero ────────────────────────────────────────────────────────────────
|
||||||
</CardDescription>
|
|
||||||
</div>
|
function Hero() {
|
||||||
</div>
|
return (
|
||||||
</CardHeader>
|
<section className="max-w-5xl mx-auto px-4 sm:px-6 pt-12 sm:pt-20 pb-10 sm:pb-14">
|
||||||
<CardContent>
|
{/* Eyebrow */}
|
||||||
<p className="text-sm text-muted-foreground">
|
<p className="uppercase tracking-[0.22em] text-xs font-medium text-muted-foreground">
|
||||||
Bhagavad-gītā, Śrīmad-Bhāgavatam, Caitanya-caritāmṛta and
|
The Words of Śrīla Prabhupāda
|
||||||
other books with Devanagari, Bengali, transliteration,
|
|
||||||
synonyms, translations & purports.
|
|
||||||
</p>
|
</p>
|
||||||
</CardContent>
|
|
||||||
</Card>
|
{/* Hero quote — each sentence on its own line so the visual rhythm is
|
||||||
</Link>
|
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>
|
||||||
|
|
||||||
{/* Essays */}
|
<p className="mt-6 text-base text-muted-foreground max-w-xl leading-relaxed">
|
||||||
<Link href="/essays" className="block group h-full">
|
A complete, searchable library of His Divine Grace A.C. Bhaktivedanta
|
||||||
<Card className="h-full transition-all group-hover:shadow-md group-hover:ring-foreground/20">
|
Swami Prabhupāda's books, letters, lectures, and teachings — set
|
||||||
<CardHeader>
|
in modern Unicode Sanskrit, free to read.
|
||||||
<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 “Krishna The Reservoir Of
|
|
||||||
Pleasure” and “Who Is Crazy?” — timeless
|
|
||||||
introductions to the science of Kṛṣṇa consciousness.
|
|
||||||
</p>
|
</p>
|
||||||
</CardContent>
|
|
||||||
</Card>
|
{/* Stat strip */}
|
||||||
</Link>
|
<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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
{/* Magazines */}
|
function FeaturedTile({ feature }: { feature: Feature }) {
|
||||||
<Link href="/magazines" className="block group h-full">
|
const Icon = feature.icon;
|
||||||
<Card className="h-full transition-all group-hover:shadow-md group-hover:ring-foreground/20">
|
return (
|
||||||
<CardHeader>
|
<Link
|
||||||
<div className="flex items-center gap-3">
|
href={feature.href}
|
||||||
<div className="flex items-center justify-center size-10 rounded-lg bg-primary/10 text-primary">
|
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"
|
||||||
<Newspaper className="size-5" />
|
>
|
||||||
|
<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>
|
||||||
|
<ArrowUpRight className="size-4 text-muted-foreground/60 group-hover:text-foreground transition-colors" />
|
||||||
</div>
|
</div>
|
||||||
<div className="flex-1 min-w-0">
|
|
||||||
<CardTitle className="text-xl font-bold">
|
<h3 className="mt-5 font-[family-name:var(--font-title)] font-semibold text-2xl sm:text-[1.6rem] leading-tight text-foreground">
|
||||||
Magazines
|
{feature.label}
|
||||||
</CardTitle>
|
</h3>
|
||||||
<CardDescription>Back to Godhead magazines</CardDescription>
|
|
||||||
</div>
|
<p className="mt-2 text-sm text-muted-foreground leading-relaxed flex-1">
|
||||||
</div>
|
{feature.blurb}
|
||||||
</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>
|
</p>
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</Link>
|
|
||||||
|
|
||||||
{/* Letters */}
|
{feature.count ? (
|
||||||
<Link href="/letters" className="block group h-full">
|
<p className="mt-5 text-xs uppercase tracking-[0.14em] font-medium text-muted-foreground">
|
||||||
<Card className="h-full transition-all group-hover:shadow-md group-hover:ring-foreground/20">
|
{feature.count}
|
||||||
<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's personal correspondence — over 6,800
|
|
||||||
letters to disciples, Godbrothers, government officials, and
|
|
||||||
well-wishers. Browse by year, recipient, or location.
|
|
||||||
</p>
|
</p>
|
||||||
</CardContent>
|
) : null}
|
||||||
</Card>
|
|
||||||
</Link>
|
</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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
{/* Transcriptions */}
|
function ExploreTile({ feature }: { feature: Feature }) {
|
||||||
<Link href="/transcriptions" className="block group h-full">
|
const Icon = feature.icon;
|
||||||
<Card className="h-full transition-all group-hover:shadow-md group-hover:ring-foreground/20">
|
return (
|
||||||
<CardHeader>
|
<Link
|
||||||
<div className="flex items-center gap-3">
|
href={feature.href}
|
||||||
<div className="flex items-center justify-center size-10 rounded-lg bg-primary/10 text-primary">
|
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"
|
||||||
<Mic className="size-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>
|
||||||
<div className="flex-1 min-w-0">
|
<div className="flex-1 min-w-0">
|
||||||
<CardTitle className="text-xl font-bold">
|
<div className="flex items-center justify-between gap-2">
|
||||||
Transcriptions
|
<h4 className="font-[family-name:var(--font-title)] font-semibold text-lg text-foreground leading-tight">
|
||||||
</CardTitle>
|
{feature.label}
|
||||||
<CardDescription>
|
</h4>
|
||||||
Lectures & conversations
|
<ArrowUpRight className="size-3.5 text-muted-foreground/50 group-hover:text-foreground transition-colors shrink-0" />
|
||||||
</CardDescription>
|
</div>
|
||||||
</div>
|
<p className="text-sm text-muted-foreground mt-1 leading-relaxed">
|
||||||
</div>
|
{feature.blurb}
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
<p className="text-sm text-muted-foreground">
|
|
||||||
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.
|
|
||||||
</p>
|
</p>
|
||||||
</CardContent>
|
{feature.count ? (
|
||||||
</Card>
|
<p className="text-xs uppercase tracking-[0.14em] font-medium text-muted-foreground/80 mt-2">
|
||||||
</Link>
|
{feature.count}
|
||||||
|
|
||||||
{/* 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>
|
</p>
|
||||||
</CardContent>
|
) : null}
|
||||||
</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>
|
||||||
<div className="flex-1 min-w-0">
|
|
||||||
<CardTitle className="text-xl font-bold">
|
|
||||||
Journals
|
|
||||||
</CardTitle>
|
|
||||||
<CardDescription>
|
|
||||||
Personal diaries & journals
|
|
||||||
</CardDescription>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
<p className="text-sm text-muted-foreground">
|
|
||||||
Ś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.
|
|
||||||
</p>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</Link>
|
</Link>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
{/* Glossary */}
|
// ─── Section heading (eyebrow + title) ───────────────────────────────────
|
||||||
<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 & 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's books — definitions,
|
|
||||||
cross-references, and encyclopedic entries.
|
|
||||||
</p>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</Link>
|
|
||||||
|
|
||||||
{/* Other Documents */}
|
function SectionHeading({
|
||||||
<Link href="/other-documents" className="block group h-full">
|
eyebrow,
|
||||||
<Card className="h-full transition-all group-hover:shadow-md group-hover:ring-foreground/20">
|
title,
|
||||||
<CardHeader>
|
}: {
|
||||||
<div className="flex items-center gap-3">
|
eyebrow: string;
|
||||||
<div className="flex items-center justify-center size-10 rounded-lg bg-primary/10 text-primary">
|
title: string;
|
||||||
<FileStack className="size-5" />
|
}) {
|
||||||
</div>
|
return (
|
||||||
<div className="flex-1 min-w-0">
|
<div className="mb-6 flex items-baseline gap-4">
|
||||||
<CardTitle className="text-xl font-bold">
|
<span className="text-xs uppercase tracking-[0.2em] font-semibold text-muted-foreground">
|
||||||
Other Documents
|
{eyebrow}
|
||||||
</CardTitle>
|
</span>
|
||||||
<CardDescription>
|
<span className="flex-1 h-px bg-border" />
|
||||||
Miscellaneous analyses & writings
|
<h2 className="font-[family-name:var(--font-title)] font-semibold text-xl sm:text-2xl text-foreground">
|
||||||
</CardDescription>
|
{title}
|
||||||
</div>
|
</h2>
|
||||||
</div>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
<p className="text-sm text-muted-foreground">
|
|
||||||
Miscellaneous documents, analyses, and writings by Śrīla
|
|
||||||
Prabhupāda that don't fit neatly into the books or essays
|
|
||||||
collections.
|
|
||||||
</p>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</Link>
|
|
||||||
</div>
|
|
||||||
</main>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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'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>
|
||||||
|
);
|
||||||
|
}
|
||||||
+116
-62
@@ -3,11 +3,9 @@
|
|||||||
import { useState, useEffect, useCallback, useRef, Suspense } from "react";
|
import { useState, useEffect, useCallback, useRef, Suspense } from "react";
|
||||||
import { useSearchParams, useRouter } from "next/navigation";
|
import { useSearchParams, useRouter } from "next/navigation";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { PageHeader } from "@/components/page-header";
|
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Card, CardContent } from "@/components/ui/card";
|
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import { buildSearchResultUrl } from "@/lib/text-highlight";
|
import { buildSearchResultUrl } from "@/lib/text-highlight";
|
||||||
import {
|
import {
|
||||||
@@ -23,6 +21,7 @@ import {
|
|||||||
FileStack,
|
FileStack,
|
||||||
ChevronLeft,
|
ChevronLeft,
|
||||||
ChevronRight,
|
ChevronRight,
|
||||||
|
WholeWord,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -185,6 +184,7 @@ function SearchInner() {
|
|||||||
const initialQuery = searchParams.get("q") || "";
|
const initialQuery = searchParams.get("q") || "";
|
||||||
const initialType = searchParams.get("type") || "all";
|
const initialType = searchParams.get("type") || "all";
|
||||||
const initialPage = parseInt(searchParams.get("page") || "1", 10);
|
const initialPage = parseInt(searchParams.get("page") || "1", 10);
|
||||||
|
const initialExact = searchParams.get("exact") === "1";
|
||||||
|
|
||||||
const [inputValue, setInputValue] = useState(initialQuery);
|
const [inputValue, setInputValue] = useState(initialQuery);
|
||||||
// selectedTypes holds tab values (e.g., "books", "letters") — not raw collection names.
|
// selectedTypes holds tab values (e.g., "books", "letters") — not raw collection names.
|
||||||
@@ -192,6 +192,9 @@ function SearchInner() {
|
|||||||
bffTypeToTabs(initialType),
|
bffTypeToTabs(initialType),
|
||||||
);
|
);
|
||||||
const [currentPage, setCurrentPage] = useState(initialPage);
|
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")
|
// Convert selected tabs to the BFF type param ("books" → "verses,prose")
|
||||||
const activeTypeParam = tabsToBffType(selectedTypes);
|
const activeTypeParam = tabsToBffType(selectedTypes);
|
||||||
@@ -211,25 +214,32 @@ function SearchInner() {
|
|||||||
|
|
||||||
// Sync URL when state changes
|
// Sync URL when state changes
|
||||||
const pushUrl = useCallback(
|
const pushUrl = useCallback(
|
||||||
(q: string, typeParam: string, page: number) => {
|
(q: string, typeParam: string, page: number, exact: boolean) => {
|
||||||
const params = new URLSearchParams();
|
const params = new URLSearchParams();
|
||||||
if (q.trim()) params.set("q", q.trim());
|
if (q.trim()) params.set("q", q.trim());
|
||||||
if (typeParam !== "all") params.set("type", typeParam);
|
if (typeParam !== "all") params.set("type", typeParam);
|
||||||
if (page > 1) params.set("page", String(page));
|
if (page > 1) params.set("page", String(page));
|
||||||
|
if (exact) params.set("exact", "1");
|
||||||
const qs = params.toString();
|
const qs = params.toString();
|
||||||
router.replace(`/search${qs ? `?${qs}` : ""}`, { scroll: false });
|
router.replace(`/search${qs ? `?${qs}` : ""}`, { scroll: false });
|
||||||
},
|
},
|
||||||
[router],
|
[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
|
// Uses the "compare previous value during render" pattern
|
||||||
// recommended by React instead of a setState-in-effect.
|
// recommended by React instead of a setState-in-effect.
|
||||||
const [prevQuery, setPrevQuery] = useState(debouncedQuery);
|
const [prevQuery, setPrevQuery] = useState(debouncedQuery);
|
||||||
const [prevType, setPrevType] = useState(activeTypeParam);
|
const [prevType, setPrevType] = useState(activeTypeParam);
|
||||||
if (prevQuery !== debouncedQuery || prevType !== activeTypeParam) {
|
const [prevExact, setPrevExact] = useState(exactMatch);
|
||||||
|
if (
|
||||||
|
prevQuery !== debouncedQuery ||
|
||||||
|
prevType !== activeTypeParam ||
|
||||||
|
prevExact !== exactMatch
|
||||||
|
) {
|
||||||
setPrevQuery(debouncedQuery);
|
setPrevQuery(debouncedQuery);
|
||||||
setPrevType(activeTypeParam);
|
setPrevType(activeTypeParam);
|
||||||
|
setPrevExact(exactMatch);
|
||||||
setCurrentPage(1);
|
setCurrentPage(1);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -237,12 +247,12 @@ function SearchInner() {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const query = debouncedQuery.trim();
|
const query = debouncedQuery.trim();
|
||||||
if (!query) {
|
if (!query) {
|
||||||
pushUrl("", urlTypeParam, 1);
|
pushUrl("", urlTypeParam, 1, exactMatch);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
pushUrl(query, urlTypeParam, currentPage);
|
pushUrl(query, urlTypeParam, currentPage, exactMatch);
|
||||||
|
|
||||||
const controller = new AbortController();
|
const controller = new AbortController();
|
||||||
|
|
||||||
@@ -252,6 +262,7 @@ function SearchInner() {
|
|||||||
page: String(currentPage),
|
page: String(currentPage),
|
||||||
limit: "20",
|
limit: "20",
|
||||||
});
|
});
|
||||||
|
if (exactMatch) params.set("exact", "1");
|
||||||
|
|
||||||
fetch(`/api/search?${params.toString()}`, { signal: controller.signal })
|
fetch(`/api/search?${params.toString()}`, { signal: controller.signal })
|
||||||
.then((res) => res.json())
|
.then((res) => res.json())
|
||||||
@@ -276,7 +287,7 @@ function SearchInner() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
return () => controller.abort();
|
return () => controller.abort();
|
||||||
}, [debouncedQuery, activeTypeParam, urlTypeParam, currentPage, pushUrl]);
|
}, [debouncedQuery, activeTypeParam, urlTypeParam, currentPage, exactMatch, pushUrl]);
|
||||||
|
|
||||||
// Auto-focus the input on mount
|
// Auto-focus the input on mount
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -312,40 +323,67 @@ function SearchInner() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-background">
|
<div className="flex-1">
|
||||||
<PageHeader>
|
{/* Editorial hero — title in serif, supporting copy underneath */}
|
||||||
<Link
|
<section className="max-w-5xl mx-auto px-4 sm:px-6 pt-10 sm:pt-16 pb-6">
|
||||||
href="/"
|
<p className="uppercase tracking-[0.22em] text-xs font-semibold text-muted-foreground">
|
||||||
className="text-sm text-muted-foreground hover:text-foreground transition-colors"
|
Search
|
||||||
>
|
|
||||||
← 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's books, letters, lectures,
|
|
||||||
and more
|
|
||||||
</p>
|
</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
|
||||||
|
“Krsna” finds “Kṛṣṇa.”
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
<main className="max-w-5xl mx-auto px-6 py-8">
|
<main className="max-w-5xl mx-auto px-4 sm:px-6 pb-16">
|
||||||
{/* 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. */}
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
<Search className="absolute left-4 top-1/2 -translate-y-1/2 size-5 text-muted-foreground pointer-events-none" />
|
<Search className="absolute left-4 top-1/2 -translate-y-1/2 size-5 text-muted-foreground pointer-events-none" />
|
||||||
<Input
|
<Input
|
||||||
ref={inputRef}
|
ref={inputRef}
|
||||||
type="search"
|
type="search"
|
||||||
placeholder="Search for verses, letters, lectures, terms..."
|
placeholder="Try “sarva dharman parityajya” or “letter to mukunda”…"
|
||||||
value={inputValue}
|
value={inputValue}
|
||||||
onChange={(e) => setInputValue(e.target.value)}
|
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 && (
|
{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>
|
</div>
|
||||||
|
|
||||||
{/* Content Type Tabs */}
|
{/* 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) => {
|
{CONTENT_TABS.map((tab) => {
|
||||||
const active = isTypeActive(tab.value);
|
const active = isTypeActive(tab.value);
|
||||||
return (
|
return (
|
||||||
@@ -388,15 +426,9 @@ function SearchInner() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* No query */}
|
{/* No query — show editorial empty state with example queries */}
|
||||||
{!isLoading && !hasSearched && (
|
{!isLoading && !hasSearched && (
|
||||||
<div className="flex flex-col items-center justify-center py-16 text-center">
|
<EmptyStateExamples onPick={(q) => setInputValue(q)} />
|
||||||
<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's books, letters,
|
|
||||||
lectures, and more
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* No results */}
|
{/* No results */}
|
||||||
@@ -561,7 +593,7 @@ function ResultCard({ result }: { result: SearchResult }) {
|
|||||||
return (
|
return (
|
||||||
<Link
|
<Link
|
||||||
href={href}
|
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={() => {
|
onClick={() => {
|
||||||
try {
|
try {
|
||||||
sessionStorage.setItem("spsays_from_search", "1");
|
sessionStorage.setItem("spsays_from_search", "1");
|
||||||
@@ -570,13 +602,12 @@ function ResultCard({ result }: { result: SearchResult }) {
|
|||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Card className="transition-all group-hover:shadow-md group-hover:ring-foreground/20 cursor-pointer">
|
<div className="flex flex-col gap-2">
|
||||||
<CardContent className="flex flex-col gap-2">
|
|
||||||
{/* Collection badge */}
|
{/* Collection badge */}
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<span
|
<span
|
||||||
className={cn(
|
className={cn(
|
||||||
"inline-flex items-center gap-1 rounded-full px-2 py-0.5 text-xs font-medium",
|
"inline-flex items-center gap-1 rounded-full px-2 py-0.5 text-[0.65rem] uppercase tracking-[0.12em] font-medium",
|
||||||
config.color,
|
config.color,
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
@@ -585,25 +616,65 @@ function ResultCard({ result }: { result: SearchResult }) {
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Title */}
|
{/* Title — serif for editorial weight */}
|
||||||
<p className="text-base font-semibold text-foreground group-hover:text-primary transition-colors leading-snug">
|
<p className="font-[family-name:var(--font-title)] font-semibold text-lg text-foreground group-hover:text-primary transition-colors leading-snug">
|
||||||
{result.title}
|
{result.title}
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
{/* Snippet with highlighted matches */}
|
{/* Snippet with highlighted matches */}
|
||||||
<p
|
<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"
|
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 }}
|
dangerouslySetInnerHTML={{ __html: result.snippet }}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Meta information */}
|
{/* Meta information */}
|
||||||
<ResultMeta result={result} />
|
<ResultMeta result={result} />
|
||||||
</CardContent>
|
</div>
|
||||||
</Card>
|
|
||||||
</Link>
|
</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
|
// Result Meta — varies by collection
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -665,26 +736,9 @@ export function SearchContent() {
|
|||||||
return (
|
return (
|
||||||
<Suspense
|
<Suspense
|
||||||
fallback={
|
fallback={
|
||||||
<div className="min-h-screen bg-background">
|
<div className="flex-1 max-w-5xl mx-auto px-4 sm:px-6 py-16 flex items-center justify-center text-muted-foreground">
|
||||||
<PageHeader>
|
|
||||||
<Link
|
|
||||||
href="/"
|
|
||||||
className="text-sm text-muted-foreground hover:text-foreground transition-colors"
|
|
||||||
>
|
|
||||||
← 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'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" />
|
<Loader2 className="size-5 animate-spin" />
|
||||||
</div>
|
</div>
|
||||||
</main>
|
|
||||||
</div>
|
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<SearchInner />
|
<SearchInner />
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import Link from "next/link";
|
||||||
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Visual wrapper used by every /(auth) page. Centralises the card chrome
|
||||||
|
* so the per-page files only deal with form state.
|
||||||
|
*/
|
||||||
|
export function AuthCard({
|
||||||
|
title,
|
||||||
|
description,
|
||||||
|
footer,
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
title: string;
|
||||||
|
description?: string;
|
||||||
|
footer?: React.ReactNode;
|
||||||
|
children: React.ReactNode;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="pb-8">
|
||||||
|
<CardTitle className="text-2xl">{title}</CardTitle>
|
||||||
|
{description ? <CardDescription>{description}</CardDescription> : null}
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-4">{children}</CardContent>
|
||||||
|
{footer ? (
|
||||||
|
<div className="px-6 pb-6 text-sm text-muted-foreground">{footer}</div>
|
||||||
|
) : null}
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AuthFooterLink({
|
||||||
|
prefix,
|
||||||
|
href,
|
||||||
|
label,
|
||||||
|
}: {
|
||||||
|
prefix: string;
|
||||||
|
href: string;
|
||||||
|
label: string;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<p>
|
||||||
|
{prefix}{" "}
|
||||||
|
<Link href={href} className="text-primary underline-offset-4 hover:underline">
|
||||||
|
{label}
|
||||||
|
</Link>
|
||||||
|
</p>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,85 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import {
|
||||||
|
createContext,
|
||||||
|
useCallback,
|
||||||
|
useContext,
|
||||||
|
useEffect,
|
||||||
|
useMemo,
|
||||||
|
useState,
|
||||||
|
} from "react";
|
||||||
|
import { auth, type User } from "@/lib/auth";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* AuthProvider holds the current-user state and exposes the auth actions
|
||||||
|
* to the rest of the app.
|
||||||
|
*
|
||||||
|
* On mount it calls /auth/me to hydrate. Until that completes, `loading`
|
||||||
|
* is true and consumers should render a placeholder rather than assuming
|
||||||
|
* the user is logged out.
|
||||||
|
*
|
||||||
|
* After a successful login/signup/logout the provider re-fetches /auth/me
|
||||||
|
* to keep state in sync with the cookie.
|
||||||
|
*/
|
||||||
|
type AuthContextValue = {
|
||||||
|
user: User | null;
|
||||||
|
loading: boolean;
|
||||||
|
signUp: (email: string, password: string, name: string) => Promise<User>;
|
||||||
|
logIn: (email: string, password: string) => Promise<User>;
|
||||||
|
logOut: () => Promise<void>;
|
||||||
|
refresh: () => Promise<void>;
|
||||||
|
/** Replace the user state without a network round-trip (after profile edit). */
|
||||||
|
setUser: (u: User | null) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
const AuthContext = createContext<AuthContextValue | null>(null);
|
||||||
|
|
||||||
|
export function AuthProvider({ children }: { children: React.ReactNode }) {
|
||||||
|
const [user, setUser] = useState<User | null>(null);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
|
||||||
|
const refresh = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
const me = await auth.me();
|
||||||
|
setUser(me);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
void refresh();
|
||||||
|
}, [refresh]);
|
||||||
|
|
||||||
|
const signUp = useCallback(async (email: string, password: string, name: string) => {
|
||||||
|
const u = await auth.signUp(email, password, name);
|
||||||
|
setUser(u);
|
||||||
|
return u;
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const logIn = useCallback(async (email: string, password: string) => {
|
||||||
|
const u = await auth.logIn(email, password);
|
||||||
|
setUser(u);
|
||||||
|
return u;
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const logOut = useCallback(async () => {
|
||||||
|
await auth.logOut();
|
||||||
|
setUser(null);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const value = useMemo<AuthContextValue>(
|
||||||
|
() => ({ user, loading, signUp, logIn, logOut, refresh, setUser }),
|
||||||
|
[user, loading, signUp, logIn, logOut, refresh],
|
||||||
|
);
|
||||||
|
|
||||||
|
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useAuth(): AuthContextValue {
|
||||||
|
const ctx = useContext(AuthContext);
|
||||||
|
if (!ctx) {
|
||||||
|
throw new Error("useAuth must be used inside <AuthProvider>");
|
||||||
|
}
|
||||||
|
return ctx;
|
||||||
|
}
|
||||||
@@ -0,0 +1,121 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* UserMenu — the right-most item in SiteHeader. Three states:
|
||||||
|
*
|
||||||
|
* 1. Loading (auth context hasn't hydrated) — render a quiet placeholder
|
||||||
|
* so the row width doesn't jump when the user data arrives.
|
||||||
|
* 2. Logged out — single "Log in" link.
|
||||||
|
* 3. Logged in — avatar / initials chip that opens a popover with
|
||||||
|
* "Profile" and "Log out". Built on @base-ui/react/popover so we
|
||||||
|
* don't pull in additional shadcn primitives.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { Popover } from "@base-ui/react/popover";
|
||||||
|
import Link from "next/link";
|
||||||
|
import Image from "next/image";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
import { UserRound } from "lucide-react";
|
||||||
|
import { useAuth } from "./auth-provider";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
function initials(name: string): string {
|
||||||
|
return (
|
||||||
|
name
|
||||||
|
.split(/\s+/)
|
||||||
|
.map((p) => p[0])
|
||||||
|
.slice(0, 2)
|
||||||
|
.join("")
|
||||||
|
.toUpperCase() || "?"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function UserMenu() {
|
||||||
|
const { user, loading, logOut } = useAuth();
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
// Placeholder keeps the action row width stable until auth hydrates.
|
||||||
|
return <span aria-hidden className="size-9 inline-block" />;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
|
// Mobile: compact icon button so the header row fits on 375px viewports.
|
||||||
|
// Desktop: full "Log in" text — there's plenty of space.
|
||||||
|
// Same muted→foreground pattern as the search / reading-settings buttons
|
||||||
|
// so the icon sits at a consistent quiet shade across all themes.
|
||||||
|
return (
|
||||||
|
<Link
|
||||||
|
href="/login"
|
||||||
|
aria-label="Log in"
|
||||||
|
className="inline-flex items-center rounded-lg text-muted-foreground hover:text-foreground hover:bg-muted transition-colors size-9 justify-center md:size-auto md:h-9 md:px-3 md:gap-1.5"
|
||||||
|
>
|
||||||
|
<UserRound className="size-5 md:size-4" />
|
||||||
|
<span className="hidden md:inline text-sm font-medium">Log in</span>
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function onLogOut() {
|
||||||
|
await logOut();
|
||||||
|
router.push("/");
|
||||||
|
router.refresh();
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Popover.Root>
|
||||||
|
<Popover.Trigger
|
||||||
|
className="size-9 rounded-full overflow-hidden ring-1 ring-foreground/10 hover:ring-foreground/20 transition-shadow"
|
||||||
|
aria-label="Account menu"
|
||||||
|
>
|
||||||
|
{user.avatar_url ? (
|
||||||
|
<Image
|
||||||
|
src={user.avatar_url}
|
||||||
|
alt={user.name}
|
||||||
|
width={36}
|
||||||
|
height={36}
|
||||||
|
className="size-full object-cover"
|
||||||
|
unoptimized
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
"flex items-center justify-center size-full text-xs font-semibold",
|
||||||
|
"bg-primary/15 text-primary",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{initials(user.name)}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</Popover.Trigger>
|
||||||
|
<Popover.Portal>
|
||||||
|
<Popover.Positioner sideOffset={6} align="end">
|
||||||
|
<Popover.Popup className="min-w-44 rounded-xl bg-card border border-border shadow-md p-1 outline-none">
|
||||||
|
<div className="px-3 py-2">
|
||||||
|
<p className="text-sm font-medium text-foreground truncate">
|
||||||
|
{user.name}
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-muted-foreground truncate">
|
||||||
|
{user.email}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="h-px bg-border my-1" />
|
||||||
|
<Link
|
||||||
|
href="/profile"
|
||||||
|
className="block rounded-lg px-3 py-2 text-sm text-foreground hover:bg-muted transition-colors"
|
||||||
|
>
|
||||||
|
Profile
|
||||||
|
</Link>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onLogOut}
|
||||||
|
className="block w-full text-left rounded-lg px-3 py-2 text-sm text-foreground hover:bg-muted transition-colors"
|
||||||
|
>
|
||||||
|
Log out
|
||||||
|
</button>
|
||||||
|
</Popover.Popup>
|
||||||
|
</Popover.Positioner>
|
||||||
|
</Popover.Portal>
|
||||||
|
</Popover.Root>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState } from "react";
|
||||||
|
import { auth, AuthError } from "@/lib/auth";
|
||||||
|
import { useAuth } from "./auth-provider";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Persistent top ribbon shown when a logged-in user has not yet verified
|
||||||
|
* their email. Lets them re-trigger the verification mail without leaving
|
||||||
|
* the page.
|
||||||
|
*
|
||||||
|
* Renders nothing when:
|
||||||
|
* - auth state is still loading
|
||||||
|
* - user is logged out
|
||||||
|
* - user.email_verified_at is set
|
||||||
|
* - user dismissed it for this session (banner is meant to be persistent
|
||||||
|
* across navigations but skippable per-tab; we keep the dismissed flag
|
||||||
|
* in component state so a refresh brings it back)
|
||||||
|
*/
|
||||||
|
export function VerifyEmailBanner() {
|
||||||
|
const { user, loading } = useAuth();
|
||||||
|
const [sent, setSent] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [busy, setBusy] = useState(false);
|
||||||
|
const [dismissed, setDismissed] = useState(false);
|
||||||
|
|
||||||
|
if (loading || !user || user.email_verified_at || dismissed) return null;
|
||||||
|
|
||||||
|
async function onResend() {
|
||||||
|
setError(null);
|
||||||
|
setBusy(true);
|
||||||
|
try {
|
||||||
|
await auth.resendVerification();
|
||||||
|
setSent(true);
|
||||||
|
} catch (err) {
|
||||||
|
setError(
|
||||||
|
err instanceof AuthError ? err.message : "Could not send verification email.",
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="w-full bg-amber-50 dark:bg-amber-950/40 text-amber-900 dark:text-amber-100 border-b border-amber-200/60 dark:border-amber-800/60">
|
||||||
|
<div className="max-w-5xl mx-auto px-4 py-2 flex flex-wrap items-center justify-between gap-3 text-sm">
|
||||||
|
<p>
|
||||||
|
{sent
|
||||||
|
? "✓ Verification email sent — check your inbox."
|
||||||
|
: error
|
||||||
|
? error
|
||||||
|
: `Verify your email (${user.email}) to secure your account.`}
|
||||||
|
</p>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{!sent ? (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onResend}
|
||||||
|
disabled={busy}
|
||||||
|
className="font-medium underline-offset-2 hover:underline disabled:opacity-60"
|
||||||
|
>
|
||||||
|
{busy ? "Sending…" : "Resend email"}
|
||||||
|
</button>
|
||||||
|
) : null}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setDismissed(true)}
|
||||||
|
aria-label="Dismiss"
|
||||||
|
className="opacity-70 hover:opacity-100"
|
||||||
|
>
|
||||||
|
✕
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
+32
-52
@@ -1,29 +1,50 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* PageHeader — per-page sub-header carrying the page title / breadcrumb.
|
||||||
|
*
|
||||||
|
* The global SiteHeader (mounted in app/layout.tsx) owns the wordmark,
|
||||||
|
* primary nav, search, reading settings, and user menu. PageHeader is now
|
||||||
|
* purely typographic — it renders whatever `children` the page passes and
|
||||||
|
* applies a subtle bordered band so the page's title area has visual weight
|
||||||
|
* separate from the body content.
|
||||||
|
*
|
||||||
|
* Behaviour preserved from the previous (busier) implementation:
|
||||||
|
* • If sessionStorage has `spsays_from_search`, the first <Link> child is
|
||||||
|
* hidden and replaced with a "← Back to search results" button. This
|
||||||
|
* keeps the cross-page-navigation polish that the search page relies on.
|
||||||
|
*
|
||||||
|
* Pages that don't want a sub-header (e.g. the redesigned homepage which
|
||||||
|
* paints its own editorial hero) can simply not render PageHeader.
|
||||||
|
*/
|
||||||
|
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { useRouter, usePathname } from "next/navigation";
|
import { useRouter, usePathname } from "next/navigation";
|
||||||
import { ReadingSettings } from "@/components/reading-settings";
|
|
||||||
import { HeaderSearchButton } from "@/components/header-search";
|
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
const SEARCH_RETURN_KEY = "spsays_from_search";
|
const SEARCH_RETURN_KEY = "spsays_from_search";
|
||||||
|
|
||||||
export function PageHeader({
|
export function PageHeader({
|
||||||
children,
|
children,
|
||||||
sticky = false,
|
|
||||||
className,
|
className,
|
||||||
|
bare = false,
|
||||||
|
sticky = false,
|
||||||
}: {
|
}: {
|
||||||
children: React.ReactNode;
|
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;
|
sticky?: boolean;
|
||||||
className?: string;
|
className?: string;
|
||||||
}) {
|
}) {
|
||||||
const [searchExpanded, setSearchExpanded] = useState(false);
|
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const pathname = usePathname();
|
const pathname = usePathname();
|
||||||
|
|
||||||
// Read (and consume) the search-return flag once on first render.
|
// 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(() => {
|
const [fromSearch, setFromSearch] = useState(() => {
|
||||||
if (typeof window === "undefined") return false;
|
if (typeof window === "undefined") return false;
|
||||||
try {
|
try {
|
||||||
@@ -43,53 +64,13 @@ export function PageHeader({
|
|||||||
return (
|
return (
|
||||||
<header
|
<header
|
||||||
className={cn(
|
className={cn(
|
||||||
"border-b border-border bg-card px-6",
|
"px-4 sm:px-6",
|
||||||
sticky ? "py-4 sticky top-0 z-20" : "py-4",
|
bare ? "py-6" : "border-b border-border bg-card/85 backdrop-blur py-5",
|
||||||
|
sticky && "sticky top-14 md:top-16 z-20",
|
||||||
className,
|
className,
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{/*
|
<div className="max-w-5xl mx-auto">
|
||||||
Layout strategy:
|
|
||||||
- Mobile: actions float to the right so the title text wraps around
|
|
||||||
them on the first line and uses the FULL container width on every
|
|
||||||
subsequent line (which is what `pr-*` cannot achieve since padding
|
|
||||||
applies to all lines).
|
|
||||||
- Desktop (md+): standard flex row so title and actions sit side-by-side.
|
|
||||||
*/}
|
|
||||||
<div className="max-w-5xl mx-auto md:flex md:items-start md:justify-between md:gap-4">
|
|
||||||
{/* Actions — float on mobile (rendered first so float works correctly),
|
|
||||||
inline flex on desktop. Order doesn't matter visually on desktop
|
|
||||||
because the parent's `justify-between` places them at the end. */}
|
|
||||||
<div
|
|
||||||
className={cn(
|
|
||||||
"flex items-center gap-1.5",
|
|
||||||
// Desktop: inline at the right of the flex row
|
|
||||||
"md:pt-1 md:order-1",
|
|
||||||
// Mobile float (for title wrapping) only applies when collapsed.
|
|
||||||
// When expanded the search bar fills the full header width and
|
|
||||||
// should NOT have a left margin or be floated.
|
|
||||||
searchExpanded
|
|
||||||
? "max-md:w-full max-md:min-w-0"
|
|
||||||
: "max-md:float-right max-md:ml-2 md:shrink-0",
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<HeaderSearchButton
|
|
||||||
expanded={searchExpanded}
|
|
||||||
onExpandedChange={setSearchExpanded}
|
|
||||||
/>
|
|
||||||
{/* Hide reading settings on mobile when search is active */}
|
|
||||||
<div className={cn(searchExpanded && "max-md:hidden")}>
|
|
||||||
<ReadingSettings />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Title / logo area — hidden on mobile when search is active */}
|
|
||||||
<div
|
|
||||||
className={cn(
|
|
||||||
"md:flex-1 md:min-w-0",
|
|
||||||
searchExpanded && "max-md:hidden",
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{fromSearch ? (
|
{fromSearch ? (
|
||||||
<>
|
<>
|
||||||
<button
|
<button
|
||||||
@@ -98,7 +79,7 @@ export function PageHeader({
|
|||||||
setFromSearch(false);
|
setFromSearch(false);
|
||||||
router.back();
|
router.back();
|
||||||
}}
|
}}
|
||||||
className="block text-sm text-muted-foreground hover:text-foreground transition-colors"
|
className="block text-xs text-muted-foreground hover:text-foreground transition-colors mb-2"
|
||||||
>
|
>
|
||||||
← Back to search results
|
← Back to search results
|
||||||
</button>
|
</button>
|
||||||
@@ -109,7 +90,6 @@ export function PageHeader({
|
|||||||
children
|
children
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
</header>
|
</header>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
+88
-15
@@ -1,26 +1,99 @@
|
|||||||
|
/**
|
||||||
|
* SiteFooter — editorial three-column footer.
|
||||||
|
*
|
||||||
|
* Column 1: Wordmark + dedication
|
||||||
|
* Column 2: Explore (links to the main content sections)
|
||||||
|
* Column 3: Resources (about, search, profile, etc.)
|
||||||
|
*
|
||||||
|
* A thin copyright row sits at the bottom.
|
||||||
|
*/
|
||||||
|
|
||||||
import Link from "next/link";
|
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() {
|
export function SiteFooter() {
|
||||||
return (
|
return (
|
||||||
<footer className="border-t border-border bg-card mt-auto">
|
<footer className="mt-auto border-t border-border bg-secondary text-secondary-foreground">
|
||||||
<div className="max-w-5xl mx-auto px-6 py-6 flex flex-col sm:flex-row items-center justify-between gap-4 text-sm text-muted-foreground">
|
{/* Three-column main */}
|
||||||
<p>
|
<div className="max-w-5xl mx-auto px-4 sm:px-6 py-10 grid grid-cols-1 sm:grid-cols-3 gap-8">
|
||||||
Team {" "}
|
{/* Brand */}
|
||||||
<span className="font-medium text-foreground">Prabhupāda Says</span>
|
<div>
|
||||||
</p>
|
|
||||||
|
|
||||||
<nav className="flex items-center gap-6">
|
|
||||||
<Link
|
<Link
|
||||||
href="/about"
|
href="/"
|
||||||
className="hover:text-foreground transition-colors"
|
aria-label="Prabhupāda Says — home"
|
||||||
|
className="font-[family-name:var(--font-title)] font-semibold uppercase tracking-[0.12em] text-base text-foreground"
|
||||||
>
|
>
|
||||||
About
|
Prabhupāda{" "}
|
||||||
|
<span className="text-muted-foreground tracking-[0.22em] font-medium">
|
||||||
|
Says
|
||||||
|
</span>
|
||||||
</Link>
|
</Link>
|
||||||
<Link href="/" className="hover:text-foreground transition-colors">
|
<p className="mt-4 text-sm text-muted-foreground leading-relaxed max-w-xs">
|
||||||
Home
|
A complete, searchable library of His Divine Grace A.C. Bhaktivedanta
|
||||||
</Link>
|
Swami Prabhupāda's books, letters, and teachings.
|
||||||
</nav>
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Explore */}
|
||||||
|
<FooterColumn title="Explore" items={EXPLORE} />
|
||||||
|
|
||||||
|
{/* Resources */}
|
||||||
|
<FooterColumn title="Resources" items={RESOURCES} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Copyright row */}
|
||||||
|
<div className="border-t border-border">
|
||||||
|
<div className="max-w-5xl mx-auto px-4 sm:px-6 py-5 flex flex-col sm:flex-row items-center justify-between gap-2 text-xs text-muted-foreground">
|
||||||
|
<p>
|
||||||
|
© {new Date().getFullYear()} Team Prabhupāda Says · Hare Kṛṣṇa
|
||||||
|
</p>
|
||||||
|
<p>All glories to Śrīla Prabhupāda</p>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</footer>
|
</footer>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function FooterColumn({
|
||||||
|
title,
|
||||||
|
items,
|
||||||
|
}: {
|
||||||
|
title: string;
|
||||||
|
items: { href: string; label: string }[];
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<p className="text-[0.72rem] uppercase tracking-[0.2em] font-semibold text-foreground/80">
|
||||||
|
{title}
|
||||||
|
</p>
|
||||||
|
<ul className="mt-4 space-y-2">
|
||||||
|
{items.map((item) => (
|
||||||
|
<li key={item.href}>
|
||||||
|
<Link
|
||||||
|
href={item.href}
|
||||||
|
className="text-sm text-foreground/80 hover:text-foreground transition-colors"
|
||||||
|
>
|
||||||
|
{item.label}
|
||||||
|
</Link>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,135 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* SiteHeader — the single global navigation header.
|
||||||
|
*
|
||||||
|
* Sits at the top of every page (mounted in app/layout.tsx) so the page-specific
|
||||||
|
* PageHeader can stay focused on titles and breadcrumbs.
|
||||||
|
*
|
||||||
|
* Layout:
|
||||||
|
*
|
||||||
|
* ┌─────────────────────────────────────────────────────────────┐
|
||||||
|
* │ PRABHUPĀDA Books · Letters · Lectures · Glossary ⌕ ⚙ ◐ │
|
||||||
|
* │ SAYS │
|
||||||
|
* └─────────────────────────────────────────────────────────────┘
|
||||||
|
*
|
||||||
|
* - Serif uppercase wordmark on the left (links home)
|
||||||
|
* - Section nav inline on md+; collapsed into the mobile drawer below
|
||||||
|
* - Right cluster: HeaderSearchButton, ReadingSettings, UserMenu
|
||||||
|
* - Sticky with a subtle border + backdrop blur when scrolled
|
||||||
|
*
|
||||||
|
* The HeaderSearchButton's expand-into-full-width behavior is preserved by
|
||||||
|
* giving it the same conditional flex treatment that PageHeader used to do —
|
||||||
|
* when expanded on mobile the wordmark + nav collapse out of the way.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useState } from "react";
|
||||||
|
import Link from "next/link";
|
||||||
|
import { usePathname } from "next/navigation";
|
||||||
|
import { HeaderSearchButton } from "@/components/header-search";
|
||||||
|
import { ReadingSettings } from "@/components/reading-settings";
|
||||||
|
import { UserMenu } from "@/components/auth/user-menu";
|
||||||
|
import { MobileNavToggle } from "@/components/site-mobile-nav";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
const PRIMARY_NAV = [
|
||||||
|
{ href: "/books", label: "Books" },
|
||||||
|
{ href: "/letters", label: "Letters" },
|
||||||
|
{ href: "/transcriptions", label: "Lectures" },
|
||||||
|
{ href: "/essays", label: "Essays" },
|
||||||
|
{ href: "/glossary", label: "Glossary" },
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
export function SiteHeader() {
|
||||||
|
const [searchExpanded, setSearchExpanded] = useState(false);
|
||||||
|
const pathname = usePathname();
|
||||||
|
|
||||||
|
function isActive(href: string): boolean {
|
||||||
|
if (href === "/") return pathname === "/";
|
||||||
|
return pathname.startsWith(href);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<header className="sticky top-0 z-30 border-b border-border bg-secondary text-secondary-foreground">
|
||||||
|
<div className="max-w-5xl mx-auto px-3 sm:px-6 h-14 md:h-16 flex items-center gap-2 md:gap-6">
|
||||||
|
{/* Mobile hamburger — visible below md, hidden when search is expanded */}
|
||||||
|
<div className={cn("md:hidden", searchExpanded && "hidden")}>
|
||||||
|
<MobileNavToggle items={PRIMARY_NAV} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Brand wordmark — two-line stacked "Prabhupāda / SAYS". Smaller
|
||||||
|
type sizes on mobile so the row fits next to the hamburger and
|
||||||
|
action icons on narrow phones (≤375px). */}
|
||||||
|
<Link
|
||||||
|
href="/"
|
||||||
|
aria-label="Prabhupāda Says — home"
|
||||||
|
className={cn(
|
||||||
|
"shrink-0 leading-none text-foreground transition-opacity hover:opacity-80",
|
||||||
|
"font-[family-name:var(--font-title)]",
|
||||||
|
searchExpanded && "max-md:hidden",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<span className="block text-[0.78rem] md:text-lg font-semibold uppercase tracking-[0.08em] md:tracking-[0.12em]">
|
||||||
|
Prabhupāda
|
||||||
|
</span>
|
||||||
|
<span className="block text-[0.55rem] md:text-[0.78rem] font-medium uppercase text-muted-foreground tracking-[0.18em] md:tracking-[0.22em] mt-0.5">
|
||||||
|
Says
|
||||||
|
</span>
|
||||||
|
</Link>
|
||||||
|
|
||||||
|
{/* Primary nav — desktop only; mobile uses the drawer.
|
||||||
|
Stays visible even when search is expanded — on desktop the search
|
||||||
|
input has a fixed max-width and fits alongside the nav, so there's
|
||||||
|
no need to remove items (which would cause layout shift). */}
|
||||||
|
<nav
|
||||||
|
className="hidden md:flex items-center gap-5 flex-1 ml-2"
|
||||||
|
aria-label="Primary"
|
||||||
|
>
|
||||||
|
{PRIMARY_NAV.map((item) => (
|
||||||
|
<Link
|
||||||
|
key={item.href}
|
||||||
|
href={item.href}
|
||||||
|
className={cn(
|
||||||
|
"text-sm transition-colors relative",
|
||||||
|
isActive(item.href)
|
||||||
|
? "text-foreground after:absolute after:left-0 after:right-0 after:-bottom-1 after:h-px after:bg-primary"
|
||||||
|
: "text-muted-foreground hover:text-foreground",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{item.label}
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
{/* Spacer pushes the action cluster to the right on mobile */}
|
||||||
|
<div
|
||||||
|
className={cn("flex-1 md:hidden", searchExpanded && "hidden")}
|
||||||
|
aria-hidden
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Action cluster — search, reading-settings, user menu.
|
||||||
|
On mobile when search is expanded, the cluster fills the row so
|
||||||
|
the input can grow. On desktop it always stays shrink-0 — the
|
||||||
|
expanded search input has its own fixed max-width and doesn't
|
||||||
|
need to steal space from the nav. */}
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"flex items-center gap-1 shrink-0",
|
||||||
|
searchExpanded && "max-md:flex-1 max-md:min-w-0 max-md:shrink",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<HeaderSearchButton
|
||||||
|
expanded={searchExpanded}
|
||||||
|
onExpandedChange={setSearchExpanded}
|
||||||
|
/>
|
||||||
|
<div className={cn(searchExpanded && "max-md:hidden")}>
|
||||||
|
<ReadingSettings />
|
||||||
|
</div>
|
||||||
|
<div className={cn(searchExpanded && "max-md:hidden")}>
|
||||||
|
<UserMenu />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,96 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* MobileNavToggle — hamburger button that opens the global section drawer
|
||||||
|
* below the md breakpoint.
|
||||||
|
*
|
||||||
|
* Built on the project's wrapped shadcn Drawer (which composes vaul). The
|
||||||
|
* raw `vaul` primitive + `asChild` pattern was unreliable here — the
|
||||||
|
* wrapped components handle ref forwarding and portal mounting correctly
|
||||||
|
* out of the box, and they already encode the project's z-index convention.
|
||||||
|
*
|
||||||
|
* Kept separate from the per-page MobileNav (which renders a book's
|
||||||
|
* sidebar) so reading pages keep their existing drawer untouched.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useState } from "react";
|
||||||
|
import Link from "next/link";
|
||||||
|
import { Menu } from "lucide-react";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import {
|
||||||
|
Drawer,
|
||||||
|
DrawerContent,
|
||||||
|
DrawerDescription,
|
||||||
|
DrawerHeader,
|
||||||
|
DrawerTitle,
|
||||||
|
DrawerTrigger,
|
||||||
|
} from "@/components/ui/drawer";
|
||||||
|
|
||||||
|
type NavItem = { href: string; label: string };
|
||||||
|
|
||||||
|
const SECONDARY: NavItem[] = [
|
||||||
|
{ href: "/disciples", label: "Disciples" },
|
||||||
|
{ href: "/journals", label: "Journals" },
|
||||||
|
{ href: "/magazines", label: "Magazines" },
|
||||||
|
{ href: "/other-documents", label: "Other Documents" },
|
||||||
|
{ href: "/about", label: "About" },
|
||||||
|
];
|
||||||
|
|
||||||
|
export function MobileNavToggle({
|
||||||
|
items,
|
||||||
|
}: {
|
||||||
|
items: ReadonlyArray<NavItem>;
|
||||||
|
}) {
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Drawer open={open} onOpenChange={setOpen} direction="left">
|
||||||
|
<DrawerTrigger
|
||||||
|
aria-label="Open menu"
|
||||||
|
className={cn(
|
||||||
|
"size-9 inline-flex items-center justify-center rounded-lg",
|
||||||
|
"text-foreground/80 hover:text-foreground hover:bg-foreground/5 transition-colors",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Menu className="size-5" />
|
||||||
|
</DrawerTrigger>
|
||||||
|
<DrawerContent
|
||||||
|
direction="left"
|
||||||
|
className="h-full w-72 max-w-[80%]"
|
||||||
|
>
|
||||||
|
<DrawerHeader className="text-left border-b border-border pb-4">
|
||||||
|
<DrawerTitle className="font-[family-name:var(--font-title)] font-semibold uppercase tracking-[0.14em] text-sm">
|
||||||
|
Prabhupāda Says
|
||||||
|
</DrawerTitle>
|
||||||
|
<DrawerDescription className="sr-only">
|
||||||
|
Quickly jump between sections of Prabhupāda Says.
|
||||||
|
</DrawerDescription>
|
||||||
|
</DrawerHeader>
|
||||||
|
|
||||||
|
<nav className="flex-1 overflow-y-auto px-3 py-2">
|
||||||
|
{items.map((item) => (
|
||||||
|
<Link
|
||||||
|
key={item.href}
|
||||||
|
href={item.href}
|
||||||
|
onClick={() => setOpen(false)}
|
||||||
|
className="block rounded-lg px-3 py-2.5 text-base text-foreground hover:bg-muted transition-colors"
|
||||||
|
>
|
||||||
|
{item.label}
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
|
<div className="my-3 border-t border-border" />
|
||||||
|
{SECONDARY.map((item) => (
|
||||||
|
<Link
|
||||||
|
key={item.href}
|
||||||
|
href={item.href}
|
||||||
|
onClick={() => setOpen(false)}
|
||||||
|
className="block rounded-lg px-3 py-2.5 text-sm text-muted-foreground hover:bg-muted hover:text-foreground transition-colors"
|
||||||
|
>
|
||||||
|
{item.label}
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
|
</nav>
|
||||||
|
</DrawerContent>
|
||||||
|
</Drawer>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
import * as React from "react";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
// Minimal shadcn-style <Label>. We use a native <label> instead of pulling
|
||||||
|
// in @radix-ui/react-label so the dependency footprint stays small.
|
||||||
|
function Label({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.LabelHTMLAttributes<HTMLLabelElement>) {
|
||||||
|
return (
|
||||||
|
<label
|
||||||
|
data-slot="label"
|
||||||
|
className={cn(
|
||||||
|
"text-sm font-medium leading-none text-foreground select-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Label };
|
||||||
+147
@@ -0,0 +1,147 @@
|
|||||||
|
/**
|
||||||
|
* Client-side BFF auth helper.
|
||||||
|
*
|
||||||
|
* All calls run in the browser with `credentials: "include"` so the httpOnly
|
||||||
|
* session cookie set by the BFF on /auth/login is sent back on every
|
||||||
|
* subsequent request.
|
||||||
|
*
|
||||||
|
* For dev: localhost:3000 (Next.js) and localhost:8080 (BFF) are same-site
|
||||||
|
* per browser definition, so SameSite=Lax cookies are accepted.
|
||||||
|
* For prod: set COOKIE_DOMAIN=.prabhupadasays.org on the BFF so the cookie
|
||||||
|
* is shared between prabhupadasays.org and api.prabhupadasays.org.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const BFF_BASE =
|
||||||
|
process.env.NEXT_PUBLIC_BFF_URL || "http://localhost:8080";
|
||||||
|
|
||||||
|
export type User = {
|
||||||
|
id: string;
|
||||||
|
email: string;
|
||||||
|
name: string;
|
||||||
|
avatar_url: string | null;
|
||||||
|
role: string;
|
||||||
|
email_verified_at: string | null;
|
||||||
|
created_at: string;
|
||||||
|
updated_at: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Error shape returned by the BFF envelope. */
|
||||||
|
export class AuthError extends Error {
|
||||||
|
constructor(
|
||||||
|
public status: number,
|
||||||
|
message: string,
|
||||||
|
) {
|
||||||
|
super(message);
|
||||||
|
this.name = "AuthError";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type Envelope<T> = {
|
||||||
|
success: boolean;
|
||||||
|
data?: T;
|
||||||
|
error?: { code: number; message: string };
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Low-level fetch helper that unwraps the BFF envelope. */
|
||||||
|
async function authFetch<T>(
|
||||||
|
path: string,
|
||||||
|
init?: RequestInit,
|
||||||
|
): Promise<T> {
|
||||||
|
const res = await fetch(`${BFF_BASE}/api/v1${path}`, {
|
||||||
|
credentials: "include",
|
||||||
|
headers: { "Content-Type": "application/json", ...(init?.headers ?? {}) },
|
||||||
|
...init,
|
||||||
|
});
|
||||||
|
|
||||||
|
// 204 No Content
|
||||||
|
if (res.status === 204) return undefined as T;
|
||||||
|
|
||||||
|
const body = (await res.json().catch(() => ({}))) as Envelope<T>;
|
||||||
|
|
||||||
|
if (!res.ok || body.success === false) {
|
||||||
|
throw new AuthError(res.status, body.error?.message || res.statusText);
|
||||||
|
}
|
||||||
|
return (body.data ?? (undefined as unknown as T)) as T;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const auth = {
|
||||||
|
signUp: (email: string, password: string, name: string) =>
|
||||||
|
authFetch<User>("/auth/signup", {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify({ email, password, name }),
|
||||||
|
}),
|
||||||
|
|
||||||
|
logIn: (email: string, password: string) =>
|
||||||
|
authFetch<User>("/auth/login", {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify({ email, password }),
|
||||||
|
}),
|
||||||
|
|
||||||
|
logOut: () => authFetch<{ ok: boolean }>("/auth/logout", { method: "POST" }),
|
||||||
|
|
||||||
|
/** Returns the current user, or null when not authenticated. */
|
||||||
|
me: async (): Promise<User | null> => {
|
||||||
|
try {
|
||||||
|
return await authFetch<User>("/auth/me");
|
||||||
|
} catch (e) {
|
||||||
|
if (e instanceof AuthError && e.status === 401) return null;
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
forgotPassword: (email: string) =>
|
||||||
|
authFetch<{ ok: boolean }>("/auth/forgot-password", {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify({ email }),
|
||||||
|
}),
|
||||||
|
|
||||||
|
resetPassword: (token: string, newPassword: string) =>
|
||||||
|
authFetch<{ ok: boolean }>("/auth/reset-password", {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify({ token, new_password: newPassword }),
|
||||||
|
}),
|
||||||
|
|
||||||
|
verifyEmail: (token: string) =>
|
||||||
|
authFetch<{ ok: boolean }>("/auth/verify-email", {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify({ token }),
|
||||||
|
}),
|
||||||
|
|
||||||
|
resendVerification: () =>
|
||||||
|
authFetch<{ ok: boolean }>("/auth/resend-verification", { method: "POST" }),
|
||||||
|
|
||||||
|
updateProfile: (name?: string, avatarURL?: string | null) =>
|
||||||
|
authFetch<User>("/users/me", {
|
||||||
|
method: "PATCH",
|
||||||
|
body: JSON.stringify({
|
||||||
|
name: name ?? undefined,
|
||||||
|
avatar_url: avatarURL === undefined ? undefined : avatarURL,
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Two-step avatar upload:
|
||||||
|
* 1. Ask the BFF for a presigned R2 PUT URL.
|
||||||
|
* 2. PUT the file directly to R2.
|
||||||
|
* 3. Caller persists the returned publicURL via updateProfile().
|
||||||
|
*/
|
||||||
|
uploadAvatar: async (file: File): Promise<string> => {
|
||||||
|
const { upload_url, public_url } = await authFetch<{
|
||||||
|
upload_url: string;
|
||||||
|
public_url: string;
|
||||||
|
}>("/users/me/avatar-presign", {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify({ content_type: file.type }),
|
||||||
|
});
|
||||||
|
|
||||||
|
const put = await fetch(upload_url, {
|
||||||
|
method: "PUT",
|
||||||
|
body: file,
|
||||||
|
headers: { "Content-Type": file.type },
|
||||||
|
});
|
||||||
|
if (!put.ok) {
|
||||||
|
throw new AuthError(put.status, "avatar upload failed");
|
||||||
|
}
|
||||||
|
return public_url;
|
||||||
|
},
|
||||||
|
};
|
||||||
Reference in New Issue
Block a user