Compare commits
43
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
684ea2ae97 | ||
|
|
0f433c5708 | ||
|
|
48dd450a15 | ||
|
|
ddc6d3c7bf | ||
|
|
fd333159f6 | ||
|
|
0bcda5a791 | ||
|
|
c89cc8b779 | ||
|
|
ec67ddf852 | ||
|
|
086f1dd8aa | ||
|
|
b61c3df473 | ||
|
|
cb70a84dd5 | ||
|
|
dc01d09f09 | ||
|
|
84d86c45b5 | ||
|
|
4d45e20cb1 | ||
|
|
e72679d1b6 | ||
|
|
91d5d272d2 | ||
|
|
0352cb2003 | ||
|
|
8a8ce24479 | ||
|
|
7d76afe102 | ||
|
|
8c1f3a1b7b | ||
|
|
3624c19d2d | ||
|
|
5988d02373 | ||
|
|
17fb83087f | ||
|
|
489adb0eeb | ||
|
|
d8de0a18e5 | ||
|
|
c20d6bffe8 | ||
|
|
01bcc057fa | ||
|
|
8bd82a0e8a | ||
|
|
5506afa758 | ||
|
|
88f1b08675 | ||
|
|
336f478f8b | ||
|
|
03b8f85e06 | ||
|
|
0ff8005df2 | ||
|
|
0ae3334d98 | ||
|
|
ec20eb996b | ||
|
|
dcdd2383c6 | ||
|
|
7bd2952caa | ||
|
|
6b6ce0813e | ||
|
|
3767a3a9b6 | ||
|
|
a22fcf360d | ||
|
|
804cf1ad9a | ||
|
|
30d08c665d | ||
|
|
cc94296b46 |
@@ -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,60 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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.
|
||||||
|
*
|
||||||
|
* Also gates the routes: a signed-in user landing on any /login, /signup,
|
||||||
|
* etc. URL is bounced to /u/me (or the `next` query param if present).
|
||||||
|
* The check happens client-side because auth state lives in
|
||||||
|
* AuthProvider — there's no server-side cookie inspection here yet, and
|
||||||
|
* the redirect is purely cosmetic (logged-in users don't gain anything
|
||||||
|
* from re-seeing these forms, and signing in over an existing session
|
||||||
|
* would just no-op).
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { Suspense, useEffect } from "react";
|
||||||
|
import { useRouter, useSearchParams } from "next/navigation";
|
||||||
|
import { useAuth } from "@/components/auth/auth-provider";
|
||||||
|
|
||||||
|
export default function AuthLayout({
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
children: React.ReactNode;
|
||||||
|
}) {
|
||||||
|
// useSearchParams forces a CSR bailout during prerender, so Next.js
|
||||||
|
// requires a Suspense boundary anywhere it's read. The gate is the
|
||||||
|
// only consumer here — isolate it so the rest of the layout can still
|
||||||
|
// SSR normally.
|
||||||
|
return (
|
||||||
|
<main className="flex items-center justify-center px-4 py-12 min-h-[90vh]">
|
||||||
|
<Suspense fallback={<AuthShell>{children}</AuthShell>}>
|
||||||
|
<AuthGate>{children}</AuthGate>
|
||||||
|
</Suspense>
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function AuthShell({ children }: { children: React.ReactNode }) {
|
||||||
|
return <div className="w-full max-w-md">{children}</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function AuthGate({ children }: { children: React.ReactNode }) {
|
||||||
|
const { user, loading } = useAuth();
|
||||||
|
const router = useRouter();
|
||||||
|
const params = useSearchParams();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (loading || !user) return;
|
||||||
|
const next = params.get("next");
|
||||||
|
router.replace(next && next.startsWith("/") ? next : "/u/me");
|
||||||
|
}, [loading, user, router, params]);
|
||||||
|
|
||||||
|
// Don't flash the form for an instant before the redirect fires.
|
||||||
|
if (!loading && user) {
|
||||||
|
return <p className="text-sm text-muted-foreground">Redirecting…</p>;
|
||||||
|
}
|
||||||
|
|
||||||
|
return <AuthShell>{children}</AuthShell>;
|
||||||
|
}
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { Suspense } from "react";
|
||||||
|
import { useRouter, useSearchParams } from "next/navigation";
|
||||||
|
import { AuthCard, AuthFooterLink } from "@/components/auth/auth-card";
|
||||||
|
import { LoginForm } from "@/components/auth/login-form";
|
||||||
|
|
||||||
|
function LoginContent() {
|
||||||
|
const router = useRouter();
|
||||||
|
const params = useSearchParams();
|
||||||
|
const next = params.get("next") ?? "/u/me";
|
||||||
|
const oauthError = params.get("oauth_error");
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AuthCard
|
||||||
|
title="Welcome back"
|
||||||
|
description="Log in to access your bookmarks, notes, and collections."
|
||||||
|
footer={
|
||||||
|
<AuthFooterLink prefix="No account?" href="/signup" label="Create one" />
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{oauthError ? (
|
||||||
|
<p className="text-sm text-destructive">
|
||||||
|
{oauthErrorMessage(oauthError)}
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
<LoginForm
|
||||||
|
oauthNext={next}
|
||||||
|
onSuccess={() => {
|
||||||
|
router.push(next);
|
||||||
|
router.refresh();
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</AuthCard>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function oauthErrorMessage(code: string): string {
|
||||||
|
switch (code) {
|
||||||
|
case "invalid_state":
|
||||||
|
case "missing_state":
|
||||||
|
return "Your sign-in link expired. Please try again.";
|
||||||
|
case "exchange_failed":
|
||||||
|
case "profile_failed":
|
||||||
|
return "Sign-in failed while talking to the provider. Please try again.";
|
||||||
|
case "unknown_provider":
|
||||||
|
return "That sign-in provider isn't configured. Please use email or another method.";
|
||||||
|
case "unavailable":
|
||||||
|
return "Social sign-in is temporarily unavailable. Please use email/password.";
|
||||||
|
case "server":
|
||||||
|
return "Something went wrong on our side. Please try again in a moment.";
|
||||||
|
default:
|
||||||
|
return "Sign-in failed. Please try again.";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function LoginPage() {
|
||||||
|
return (
|
||||||
|
<Suspense fallback={null}>
|
||||||
|
<LoginContent />
|
||||||
|
</Suspense>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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,26 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
import { AuthCard, AuthFooterLink } from "@/components/auth/auth-card";
|
||||||
|
import { SignUpForm } from "@/components/auth/signup-form";
|
||||||
|
|
||||||
|
export default function SignUpPage() {
|
||||||
|
const router = useRouter();
|
||||||
|
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" />
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<SignUpForm
|
||||||
|
oauthNext="/u/me"
|
||||||
|
onSuccess={() => {
|
||||||
|
router.push("/u/me");
|
||||||
|
router.refresh();
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</AuthCard>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -67,7 +67,7 @@ export function generateMetadata(): Metadata {
|
|||||||
return buildOgMeta({
|
return buildOgMeta({
|
||||||
title: "About — Prabhupāda Says",
|
title: "About — Prabhupāda Says",
|
||||||
quote:
|
quote:
|
||||||
"Built by a small team of Prabhupādānugas — preserving and presenting the teachings of Śrīla Prabhupāda exactly as he gave them, without alteration.",
|
"Built by a small team of Rupānugas — preserving and presenting the teachings of Śrīla Prabhupāda exactly as he gave them, without alteration.",
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -96,7 +96,7 @@ export default function AboutPage() {
|
|||||||
<span className="font-medium text-foreground">
|
<span className="font-medium text-foreground">
|
||||||
Prabhupāda Says
|
Prabhupāda Says
|
||||||
</span>{" "}
|
</span>{" "}
|
||||||
is built by a small team of Prabhupādānugas — devotees dedicated
|
is built by a small team of Rupānugas. We are dedicated
|
||||||
to preserving and presenting the teachings of His Divine Grace
|
to preserving and presenting the teachings of His Divine Grace
|
||||||
A.C. Bhaktivedanta Swami Prabhupāda exactly as he gave them,
|
A.C. Bhaktivedanta Swami Prabhupāda exactly as he gave them,
|
||||||
without alteration, reinterpretation, or institutional bias.
|
without alteration, reinterpretation, or institutional bias.
|
||||||
+1
-1
@@ -3,7 +3,7 @@ import Link from "next/link";
|
|||||||
import { PageHeader } from "@/components/page-header";
|
import { PageHeader } from "@/components/page-header";
|
||||||
import { notFound } from "next/navigation";
|
import { notFound } from "next/navigation";
|
||||||
import { getBookMeta, getSingleVerse, getChapterVerseIndex } from "@/lib/api";
|
import { getBookMeta, getSingleVerse, getChapterVerseIndex } from "@/lib/api";
|
||||||
import { ChapterViewer } from "@/app/books/chapter-viewer";
|
import { ChapterViewer } from "@/app/(app)/books/chapter-viewer";
|
||||||
import type { MobileNavGroup } from "@/components/mobile-nav";
|
import type { MobileNavGroup } from "@/components/mobile-nav";
|
||||||
import { ogForVerse, extractQuoteFromHl, buildOgMeta } from "@/lib/og";
|
import { ogForVerse, extractQuoteFromHl, buildOgMeta } from "@/lib/og";
|
||||||
|
|
||||||
+1
-1
@@ -11,7 +11,7 @@ import {
|
|||||||
type VerseIndexEntry,
|
type VerseIndexEntry,
|
||||||
type VerseRecord,
|
type VerseRecord,
|
||||||
} from "@/lib/api";
|
} from "@/lib/api";
|
||||||
import { ChapterViewer } from "@/app/books/chapter-viewer";
|
import { ChapterViewer } from "@/app/(app)/books/chapter-viewer";
|
||||||
import type { MobileNavGroup } from "@/components/mobile-nav";
|
import type { MobileNavGroup } from "@/components/mobile-nav";
|
||||||
import { ogForVerse, extractQuoteFromHl, buildOgMeta } from "@/lib/og";
|
import { ogForVerse, extractQuoteFromHl, buildOgMeta } from "@/lib/og";
|
||||||
|
|
||||||
+1
-1
@@ -5,7 +5,7 @@ import Link from "next/link";
|
|||||||
import { PageHeader } from "@/components/page-header";
|
import { PageHeader } from "@/components/page-header";
|
||||||
import { notFound } from "next/navigation";
|
import { notFound } from "next/navigation";
|
||||||
import { getBookMeta, getSingleVerse, getChapterVerseIndex } from "@/lib/api";
|
import { getBookMeta, getSingleVerse, getChapterVerseIndex } from "@/lib/api";
|
||||||
import { ChapterViewer } from "@/app/books/chapter-viewer";
|
import { ChapterViewer } from "@/app/(app)/books/chapter-viewer";
|
||||||
import type { MobileNavGroup } from "@/components/mobile-nav";
|
import type { MobileNavGroup } from "@/components/mobile-nav";
|
||||||
import { ogForVerse, extractQuoteFromHl, buildOgMeta } from "@/lib/og";
|
import { ogForVerse, extractQuoteFromHl, buildOgMeta } from "@/lib/og";
|
||||||
|
|
||||||
@@ -390,7 +390,11 @@ export function ChapterViewer({
|
|||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{/* ---- Sidebar: Verse Index ---- */}
|
{/* ---- Sidebar: Verse Index ---- */}
|
||||||
<aside className="hidden lg:block w-64 shrink-0 sticky top-[89px] self-start max-h-[calc(100vh-89px)] overflow-y-auto py-8 pr-4">
|
{/* Sticky sidebar. The max-h leaves 76px at the viewport bottom for
|
||||||
|
the site footer so the sidebar doesn't slide behind it when the
|
||||||
|
page is scrolled to the bottom. (89 sticky-top + 76 bottom gap
|
||||||
|
= 165, hence calc(100vh - 165px).) */}
|
||||||
|
<aside className="hidden lg:block w-64 shrink-0 sticky top-[89px] self-start max-h-[calc(100vh-165px)] overflow-y-auto py-8 pr-4">
|
||||||
<h2 className="text-sm font-bold uppercase tracking-wider text-muted-foreground mb-3 px-2">
|
<h2 className="text-sm font-bold uppercase tracking-wider text-muted-foreground mb-3 px-2">
|
||||||
Verses
|
Verses
|
||||||
</h2>
|
</h2>
|
||||||
+1
-1
@@ -10,7 +10,7 @@ import {
|
|||||||
type VerseIndexEntry,
|
type VerseIndexEntry,
|
||||||
type VerseRecord,
|
type VerseRecord,
|
||||||
} from "@/lib/api";
|
} from "@/lib/api";
|
||||||
import { ChapterViewer } from "@/app/books/chapter-viewer";
|
import { ChapterViewer } from "@/app/(app)/books/chapter-viewer";
|
||||||
import type { MobileNavGroup } from "@/components/mobile-nav";
|
import type { MobileNavGroup } from "@/components/mobile-nav";
|
||||||
import type { Metadata } from "next";
|
import type { Metadata } from "next";
|
||||||
import { ogForVerse, extractQuoteFromHl, buildOgMeta } from "@/lib/og";
|
import { ogForVerse, extractQuoteFromHl, buildOgMeta } from "@/lib/og";
|
||||||
+1
-1
@@ -14,7 +14,7 @@ import {
|
|||||||
type VerseRecord,
|
type VerseRecord,
|
||||||
} from "@/lib/api";
|
} from "@/lib/api";
|
||||||
import { ogForVerse, extractQuoteFromHl, buildOgMeta } from "@/lib/og";
|
import { ogForVerse, extractQuoteFromHl, buildOgMeta } from "@/lib/og";
|
||||||
import { ChapterViewer } from "@/app/books/chapter-viewer";
|
import { ChapterViewer } from "@/app/(app)/books/chapter-viewer";
|
||||||
import type { MobileNavGroup } from "@/components/mobile-nav";
|
import type { MobileNavGroup } from "@/components/mobile-nav";
|
||||||
|
|
||||||
type NbsMeta = {
|
type NbsMeta = {
|
||||||
+1
-1
@@ -5,7 +5,7 @@ import Link from "next/link";
|
|||||||
import { PageHeader } from "@/components/page-header";
|
import { PageHeader } from "@/components/page-header";
|
||||||
import { notFound } from "next/navigation";
|
import { notFound } from "next/navigation";
|
||||||
import { getBookMeta, getSingleVerse, getChapterVerseIndex } from "@/lib/api";
|
import { getBookMeta, getSingleVerse, getChapterVerseIndex } from "@/lib/api";
|
||||||
import { ChapterViewer } from "@/app/books/chapter-viewer";
|
import { ChapterViewer } from "@/app/(app)/books/chapter-viewer";
|
||||||
import type { MobileNavGroup } from "@/components/mobile-nav";
|
import type { MobileNavGroup } from "@/components/mobile-nav";
|
||||||
import { ogForVerse, extractQuoteFromHl, buildOgMeta } from "@/lib/og";
|
import { ogForVerse, extractQuoteFromHl, buildOgMeta } from "@/lib/og";
|
||||||
|
|
||||||
+1
-1
@@ -3,7 +3,7 @@ import Link from "next/link";
|
|||||||
import { PageHeader } from "@/components/page-header";
|
import { PageHeader } from "@/components/page-header";
|
||||||
import { notFound } from "next/navigation";
|
import { notFound } from "next/navigation";
|
||||||
import { getBookMeta, getSingleVerse, getChapterVerseIndex } from "@/lib/api";
|
import { getBookMeta, getSingleVerse, getChapterVerseIndex } from "@/lib/api";
|
||||||
import { ChapterViewer } from "@/app/books/chapter-viewer";
|
import { ChapterViewer } from "@/app/(app)/books/chapter-viewer";
|
||||||
import type { MobileNavGroup } from "@/components/mobile-nav";
|
import type { MobileNavGroup } from "@/components/mobile-nav";
|
||||||
import { ogForVerse, extractQuoteFromHl, buildOgMeta } from "@/lib/og";
|
import { ogForVerse, extractQuoteFromHl, buildOgMeta } from "@/lib/og";
|
||||||
|
|
||||||
@@ -0,0 +1,96 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Public collection at /c/[id]. Anyone can view if `is_public=true`.
|
||||||
|
* Private collections return 403 from the BFF, which we treat as "not
|
||||||
|
* found" to avoid leaking existence.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { use, useEffect, useState } from "react";
|
||||||
|
import Link from "next/link";
|
||||||
|
import { notFound } from "next/navigation";
|
||||||
|
import { Globe, Lock, FolderPlus } from "lucide-react";
|
||||||
|
import {
|
||||||
|
collections,
|
||||||
|
SocialError,
|
||||||
|
type Collection,
|
||||||
|
type Post,
|
||||||
|
} from "@/lib/social";
|
||||||
|
import { PostViews } from "@/components/social/post-views";
|
||||||
|
|
||||||
|
export default function PublicCollectionPage(props: {
|
||||||
|
params: Promise<{ id: string }>;
|
||||||
|
}) {
|
||||||
|
const { id } = use(props.params);
|
||||||
|
const [c, setC] = useState<Collection | null>(null);
|
||||||
|
const [posts, setPosts] = useState<Post[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [missing, setMissing] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false;
|
||||||
|
setLoading(true);
|
||||||
|
collections
|
||||||
|
.get(id)
|
||||||
|
.then(({ collection, posts }) => {
|
||||||
|
if (cancelled) return;
|
||||||
|
setC(collection);
|
||||||
|
setPosts(posts ?? []);
|
||||||
|
})
|
||||||
|
.catch((err) => {
|
||||||
|
if (
|
||||||
|
err instanceof SocialError &&
|
||||||
|
(err.status === 404 || err.status === 403)
|
||||||
|
) {
|
||||||
|
setMissing(true);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.finally(() => !cancelled && setLoading(false));
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
}, [id]);
|
||||||
|
|
||||||
|
if (missing) notFound();
|
||||||
|
if (loading || !c)
|
||||||
|
return (
|
||||||
|
<main className="flex-1 max-w-3xl w-full mx-auto px-4 py-12">
|
||||||
|
<p className="text-sm text-muted-foreground">Loading…</p>
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<main className="flex-1 max-w-3xl w-full mx-auto px-4 sm:px-6 py-10 space-y-8">
|
||||||
|
<header className="rounded-2xl bg-card ring-1 ring-foreground/10 p-6 sm:p-8">
|
||||||
|
<div className="flex items-center gap-2 text-xs uppercase tracking-[0.2em] text-muted-foreground">
|
||||||
|
{c.is_public ? <Globe className="size-3.5" /> : <Lock className="size-3.5" />}
|
||||||
|
{c.is_public ? "Public collection" : "Private collection"}
|
||||||
|
</div>
|
||||||
|
<h1 className="mt-3 font-[family-name:var(--font-title)] font-semibold text-3xl sm:text-4xl text-foreground leading-tight">
|
||||||
|
{c.title}
|
||||||
|
</h1>
|
||||||
|
{c.description ? (
|
||||||
|
<p className="mt-3 text-base text-muted-foreground leading-relaxed">
|
||||||
|
{c.description}
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
<p className="mt-4 text-xs text-muted-foreground tabular-nums">
|
||||||
|
{c.item_count} post{c.item_count === 1 ? "" : "s"}
|
||||||
|
</p>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<section className="space-y-3">
|
||||||
|
{posts.length === 0 ? (
|
||||||
|
<div className="rounded-xl border border-dashed border-border p-8 text-center">
|
||||||
|
<FolderPlus className="size-6 text-muted-foreground mx-auto" />
|
||||||
|
<p className="text-sm text-muted-foreground mt-2">
|
||||||
|
This collection has no items yet.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<PostViews posts={posts} />
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* /chat — placeholder for the Kautilya AI study companion.
|
||||||
|
*
|
||||||
|
* The actual chat surface (an AI tuned on Śrīla Prabhupāda's books,
|
||||||
|
* letters, and lectures) isn't built yet. Until it ships we render a
|
||||||
|
* coming-soon page so the nav entries (sidebar + bottom bar) have a
|
||||||
|
* real route to point at. When the feature lands, swap this file for
|
||||||
|
* the real chat UI — nav wiring stays the same.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import Link from "next/link";
|
||||||
|
import { Compass } from "lucide-react";
|
||||||
|
import { KautilyaIcon } from "@/components/selection-toolbar/shared";
|
||||||
|
|
||||||
|
export default function ChatPage() {
|
||||||
|
return (
|
||||||
|
<main className="flex-1 max-w-2xl w-full mx-auto px-4 sm:px-6 py-16 sm:py-24">
|
||||||
|
<div className="flex flex-col items-center text-center gap-5">
|
||||||
|
<span
|
||||||
|
aria-hidden
|
||||||
|
className="inline-flex size-14 items-center justify-center rounded-full bg-primary/10 text-primary"
|
||||||
|
>
|
||||||
|
<KautilyaIcon className="size-7" />
|
||||||
|
</span>
|
||||||
|
|
||||||
|
<h1 className="font-[family-name:var(--font-title)] font-semibold text-2xl sm:text-3xl text-foreground leading-tight text-balance">
|
||||||
|
Kautilya is coming soon
|
||||||
|
</h1>
|
||||||
|
|
||||||
|
<p className="text-sm text-muted-foreground leading-relaxed max-w-md">
|
||||||
|
An AI study companion tuned on Śrīla Prabhupāda's books,
|
||||||
|
letters, and lectures — ask any question, get answers grounded in
|
||||||
|
his own words with citations back to the source passages. Until
|
||||||
|
it lands, the full library is already waiting.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div className="flex flex-col sm:flex-row items-center gap-3 mt-2">
|
||||||
|
<Link
|
||||||
|
href="/search"
|
||||||
|
className="inline-flex items-center justify-center gap-2 rounded-lg bg-primary text-primary-foreground px-5 h-11 text-sm font-medium hover:bg-primary/90 transition-colors min-w-[12rem]"
|
||||||
|
>
|
||||||
|
<Compass className="size-4" />
|
||||||
|
Explore Knowledgebase
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,180 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* /feedback — single-page form for bug reports, feature requests, and
|
||||||
|
* general comments. Submissions persist to the BFF and trigger an
|
||||||
|
* admin notification email. No follow-up UI beyond a success toast
|
||||||
|
* and a redirect back to /.
|
||||||
|
*
|
||||||
|
* Authed visitors don't see the contact-email field — the BFF reads
|
||||||
|
* their account email from the session cookie. Anonymous visitors get
|
||||||
|
* the field and must fill it in so we have a reply path.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useState } from "react";
|
||||||
|
import Link from "next/link";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
import { ArrowLeft, MessageSquare, Send } from "lucide-react";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
import { feedback as feedbackAPI, SocialError } from "@/lib/social";
|
||||||
|
import { useAuth } from "@/components/auth/auth-provider";
|
||||||
|
|
||||||
|
type Category = "bug" | "feature" | "general" | "other";
|
||||||
|
|
||||||
|
const CATEGORIES: { value: Category; label: string; description: string }[] = [
|
||||||
|
{ value: "general", label: "General feedback", description: "Thoughts, impressions, suggestions — anything else." },
|
||||||
|
{ value: "bug", label: "Bug report", description: "Something is broken or behaving unexpectedly." },
|
||||||
|
{ value: "feature", label: "Feature request", description: "An idea for something new on the site." },
|
||||||
|
{ value: "other", label: "Other", description: "Doesn't fit the above? Use this." },
|
||||||
|
];
|
||||||
|
|
||||||
|
export default function FeedbackPage() {
|
||||||
|
const router = useRouter();
|
||||||
|
const { user, loading } = useAuth();
|
||||||
|
const authed = !loading && !!user;
|
||||||
|
|
||||||
|
const [category, setCategory] = useState<Category>("general");
|
||||||
|
const [message, setMessage] = useState("");
|
||||||
|
const [contactEmail, setContactEmail] = useState("");
|
||||||
|
const [submitting, setSubmitting] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
async function submit(e: React.FormEvent) {
|
||||||
|
e.preventDefault();
|
||||||
|
setError(null);
|
||||||
|
const trimmed = message.trim();
|
||||||
|
if (!trimmed) {
|
||||||
|
setError("Message is required.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!authed && !contactEmail.trim()) {
|
||||||
|
setError("Email is required so we can reply.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setSubmitting(true);
|
||||||
|
try {
|
||||||
|
await feedbackAPI.submit({
|
||||||
|
category,
|
||||||
|
message: trimmed,
|
||||||
|
contact_email: authed ? undefined : contactEmail.trim(),
|
||||||
|
});
|
||||||
|
toast.success("Thanks for the feedback!", {
|
||||||
|
description: "We've forwarded it to the team.",
|
||||||
|
duration: 5000,
|
||||||
|
});
|
||||||
|
setMessage("");
|
||||||
|
setContactEmail("");
|
||||||
|
router.push("/");
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof SocialError ? err.message : "Could not send feedback.");
|
||||||
|
} finally {
|
||||||
|
setSubmitting(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const selected = CATEGORIES.find((c) => c.value === category);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<main className="flex-1 max-w-xl w-full mx-auto px-4 sm:px-6 py-10 sm:py-14">
|
||||||
|
<Link
|
||||||
|
href="/"
|
||||||
|
className="inline-flex items-center gap-1 text-sm text-muted-foreground hover:text-foreground"
|
||||||
|
>
|
||||||
|
<ArrowLeft className="size-4" /> Back to home
|
||||||
|
</Link>
|
||||||
|
|
||||||
|
<header className="mt-6 mb-6 flex items-start gap-3">
|
||||||
|
<span
|
||||||
|
aria-hidden
|
||||||
|
className="inline-flex size-11 items-center justify-center rounded-full bg-primary/10 text-primary shrink-0"
|
||||||
|
>
|
||||||
|
<MessageSquare className="size-5" />
|
||||||
|
</span>
|
||||||
|
<div>
|
||||||
|
<h1 className="font-[family-name:var(--font-title)] font-semibold text-2xl text-foreground leading-tight">
|
||||||
|
Send feedback
|
||||||
|
</h1>
|
||||||
|
<p className="mt-1 text-sm text-muted-foreground">
|
||||||
|
Tell us what's working, what's broken, or what you'd like to see next. We read every message.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<form
|
||||||
|
onSubmit={submit}
|
||||||
|
className="space-y-5 rounded-2xl bg-card ring-1 ring-foreground/10 p-5"
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
<label htmlFor="category" className="text-sm font-medium text-foreground">
|
||||||
|
What's this about?
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
id="category"
|
||||||
|
value={category}
|
||||||
|
onChange={(e) => setCategory(e.target.value as Category)}
|
||||||
|
className="mt-1.5 h-10 w-full rounded-lg border border-border bg-card px-3 text-sm text-foreground focus:outline-none focus:ring-2 focus:ring-ring"
|
||||||
|
disabled={submitting}
|
||||||
|
>
|
||||||
|
{CATEGORIES.map((c) => (
|
||||||
|
<option key={c.value} value={c.value}>{c.label}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
<p className="mt-1 text-xs text-muted-foreground">
|
||||||
|
{selected?.description}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label htmlFor="message" className="text-sm font-medium text-foreground">
|
||||||
|
Message
|
||||||
|
</label>
|
||||||
|
<textarea
|
||||||
|
id="message"
|
||||||
|
value={message}
|
||||||
|
onChange={(e) => setMessage(e.target.value)}
|
||||||
|
placeholder="Share as much detail as you'd like…"
|
||||||
|
rows={6}
|
||||||
|
maxLength={5000}
|
||||||
|
disabled={submitting}
|
||||||
|
className="mt-1.5 w-full rounded-lg border border-border bg-card px-3 py-2 text-sm text-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring resize-y"
|
||||||
|
/>
|
||||||
|
<p className="mt-1 text-xs text-muted-foreground text-right tabular-nums">
|
||||||
|
{message.length}/5000
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{!loading && !authed ? (
|
||||||
|
<div>
|
||||||
|
<label htmlFor="contact_email" className="text-sm font-medium text-foreground">
|
||||||
|
Your email
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="contact_email"
|
||||||
|
type="email"
|
||||||
|
value={contactEmail}
|
||||||
|
onChange={(e) => setContactEmail(e.target.value)}
|
||||||
|
placeholder="you@example.com"
|
||||||
|
required
|
||||||
|
disabled={submitting}
|
||||||
|
className="mt-1.5 h-10 w-full rounded-lg border border-border bg-card px-3 text-sm text-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring"
|
||||||
|
/>
|
||||||
|
<p className="mt-1 text-xs text-muted-foreground">
|
||||||
|
So we can write back if needed. We don't add you to a mailing list.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{error ? <p className="text-sm text-destructive">{error}</p> : null}
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={submitting || !message.trim()}
|
||||||
|
className="w-full h-11 inline-flex items-center justify-center gap-2 rounded-lg bg-primary text-primary-foreground text-base font-medium hover:bg-primary/90 transition-colors disabled:opacity-60"
|
||||||
|
>
|
||||||
|
<Send className="size-4" />
|
||||||
|
{submitting ? "Sending…" : "Send feedback"}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user