feat: add user centric features

This commit is contained in:
gosangam
2026-05-30 23:44:08 +05:30
parent 7bd2952caa
commit dcdd2383c6
63 changed files with 16783 additions and 1735 deletions
+11 -61
View File
@@ -1,86 +1,36 @@
"use client";
import { Suspense, useState } from "react";
import { Suspense } 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";
import { LoginForm } from "@/components/auth/login-form";
function LoginForm() {
function LoginContent() {
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>
<AuthFooterLink prefix="No account?" href="/signup" label="Create one" />
}
>
<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>
<LoginForm
onSuccess={() => {
router.push(params.get("next") ?? "/u/me");
router.refresh();
}}
/>
</AuthCard>
);
}
export default function LoginPage() {
// useSearchParams must be inside Suspense for Next.js streaming.
return (
<Suspense fallback={null}>
<LoginForm />
<LoginContent />
</Suspense>
);
}
+7 -71
View File
@@ -1,37 +1,11 @@
"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";
import { SignUpForm } from "@/components/auth/signup-form";
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"
@@ -40,50 +14,12 @@ export default function SignUpPage() {
<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>
<SignUpForm
onSuccess={() => {
router.push("/u/me");
router.refresh();
}}
/>
</AuthCard>
);
}
+5 -1
View File
@@ -390,7 +390,11 @@ export function ChapterViewer({
return (
<>
{/* ---- 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">
Verses
</h2>
+96
View File
@@ -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>
);
}
+55
View File
@@ -109,6 +109,19 @@
--reading-font-size: 1rem;
--reading-font-family: inherit;
--reading-text-align: start;
/* Post cards without an image use a solid background instead of a
gradient — crisp white on every light palette, palette-specific
darker contrast on dark palettes. */
--quote-card-bg: #ffffff;
/* Quote-card gradients — Aged Parchment (mahogany binding + temple gold).
Used by collection cards' fallback cover. Each palette below
overrides these so the gradient swaps with the active theme. */
--quote-gradient-1: linear-gradient(135deg, #3b2410 0%, #6b3e1f 45%, #c8902a 100%);
--quote-gradient-2: linear-gradient(135deg, #5c2918 0%, #a04a1c 50%, #d4a235 100%);
--quote-gradient-3: linear-gradient(135deg, #4a2e15 0%, #8a5a1f 60%, #d9bf80 100%);
--quote-gradient-4: linear-gradient(135deg, #4a1612 0%, #8b2c1a 50%, #c47525 100%);
}
/* ── Light palette — "Tulasī Garden" ──
@@ -145,6 +158,14 @@
--sidebar-accent-foreground: oklch(0.22 0.09 155);
--sidebar-border: oklch(0.76 0.12 150);
--sidebar-ring: oklch(0.56 0.17 150);
--quote-card-bg: #ffffff;
/* Quote-card gradients — Tulasī Garden (forest depths + amber edge). */
--quote-gradient-1: linear-gradient(135deg, #102818 0%, #1f4d2f 45%, #b88a3c 100%);
--quote-gradient-2: linear-gradient(135deg, #1a2e1f 0%, #2d5a3a 50%, #9a8a4c 100%);
--quote-gradient-3: linear-gradient(135deg, #0c2e22 0%, #1d5f3e 60%, #d9c08a 100%);
--quote-gradient-4: linear-gradient(135deg, #0a1f15 0%, #1f3d2a 50%, #c8a880 100%);
}
/* ── Light palette — "Yamunā Mist" ──
@@ -181,6 +202,14 @@
--sidebar-accent-foreground: oklch(0.24 0.09 240);
--sidebar-border: oklch(0.76 0.1 232);
--sidebar-ring: oklch(0.62 0.15 235);
--quote-card-bg: #ffffff;
/* Quote-card gradients — Yamunā Mist (river depths + dusk ochre). */
--quote-gradient-1: linear-gradient(135deg, #0a1e3c 0%, #1a3d6e 45%, #c89058 100%);
--quote-gradient-2: linear-gradient(135deg, #14213d 0%, #2a4d7a 50%, #c8bcb0 100%);
--quote-gradient-3: linear-gradient(135deg, #1a2a40 0%, #3a5878 50%, #c4805a 100%);
--quote-gradient-4: linear-gradient(135deg, #0e1f3c 0%, #28447a 60%, #d4c4b0 100%);
}
/* ── Dark mode — "Ancient Manuscript" ── */
@@ -216,6 +245,16 @@
--sidebar-accent-foreground: oklch(0.899 0.03 80.7);
--sidebar-border: oklch(0.777 0.059 84.4 / 15%);
--sidebar-ring: oklch(0.636 0.08 86.9);
/* Post-card flat bg — darker than --card so it reads as a distinct
surface even on the homepage where cards stack onto background. */
--quote-card-bg: oklch(0.13 0.018 86);
/* Quote-card gradients — Ancient Manuscript (midnight leather + saffron). */
--quote-gradient-1: linear-gradient(135deg, #0a0805 0%, #4a2f0e 45%, #d4a235 100%);
--quote-gradient-2: linear-gradient(135deg, #0c0a05 0%, #3e2614 50%, #c89028 100%);
--quote-gradient-3: linear-gradient(135deg, #14100a 0%, #4a3018 60%, #b87c2a 100%);
--quote-gradient-4: linear-gradient(135deg, #100c08 0%, #3a2010 50%, #c4762b 100%);
}
/* ── Dark palette — "Nīlamaṇi" ──
@@ -252,6 +291,14 @@
--sidebar-accent-foreground: oklch(0.92 0.04 220);
--sidebar-border: oklch(0.7 0.12 235 / 22%);
--sidebar-ring: oklch(0.66 0.14 235);
--quote-card-bg: oklch(0.1 0.05 252);
/* Quote-card gradients — Nīlamaṇi (Kṛṣṇa's sapphire hour). */
--quote-gradient-1: linear-gradient(135deg, #050a1c 0%, #14254a 45%, #5a8acc 100%);
--quote-gradient-2: linear-gradient(135deg, #0a1228 0%, #1e3458 50%, #b8c8dc 100%);
--quote-gradient-3: linear-gradient(135deg, #060c1e 0%, #1a2c4e 60%, #a8b8cc 100%);
--quote-gradient-4: linear-gradient(135deg, #080e22 0%, #1c3458 50%, #c8d4e8 100%);
}
/* ── Dark palette — "Vraja Twilight" ──
@@ -288,6 +335,14 @@
--sidebar-accent-foreground: oklch(0.92 0.03 25);
--sidebar-border: oklch(0.7 0.12 25 / 22%);
--sidebar-ring: oklch(0.66 0.14 25);
--quote-card-bg: oklch(0.11 0.05 12);
/* Quote-card gradients — Vraja Twilight (deep wine + rose dusk). */
--quote-gradient-1: linear-gradient(135deg, #1c0a10 0%, #4a1a28 45%, #c4756a 100%);
--quote-gradient-2: linear-gradient(135deg, #1a0a14 0%, #3e1828 50%, #d4a08c 100%);
--quote-gradient-3: linear-gradient(135deg, #18080e 0%, #4a182a 60%, #e0a8a0 100%);
--quote-gradient-4: linear-gradient(135deg, #14080c 0%, #401628 50%, #c47868 100%);
}
@layer base {
+26 -13
View File
@@ -5,12 +5,17 @@ import { ogDefault } from "@/lib/og";
import { ThemeProvider } from "@/components/theme-provider";
import { TooltipProvider } from "@/components/ui/tooltip";
import { SiteHeader } from "@/components/site-header";
import { SiteSidebar } from "@/components/site-sidebar";
import { SiteBottomNav } from "@/components/site-bottom-nav";
import { SiteFooter } from "@/components/site-footer";
import { TextSelectionToolbar } from "@/components/text-selection-toolbar";
import { TextHighlighter } from "@/components/text-highlighter";
import { AudioPlayerProvider, FloatingPlayer } from "@/components/audio-player";
import { AuthProvider } from "@/components/auth/auth-provider";
import { AuthGateProvider } from "@/components/auth/auth-gate";
import { VerifyEmailBanner } from "@/components/auth/verify-email-banner";
import { PageBookmarks } from "@/components/social/page-bookmarks";
import { Toaster } from "@/components/ui/toaster";
import "./globals.css";
const geistSans = Geist({
@@ -69,19 +74,27 @@ export default function RootLayout({
disableTransitionOnChange
>
<AuthProvider>
<AudioPlayerProvider>
<TooltipProvider>
<VerifyEmailBanner />
<SiteHeader />
{children}
<TextSelectionToolbar />
<Suspense fallback={null}>
<TextHighlighter />
</Suspense>
<FloatingPlayer />
<SiteFooter />
</TooltipProvider>
</AudioPlayerProvider>
<AuthGateProvider>
<AudioPlayerProvider>
<TooltipProvider>
<VerifyEmailBanner />
<SiteSidebar />
<div className="flex-1 flex flex-col md:pl-16 pb-16 md:pb-0">
<SiteHeader />
{children}
<SiteFooter />
</div>
<SiteBottomNav />
<TextSelectionToolbar />
<Suspense fallback={null}>
<TextHighlighter />
</Suspense>
<FloatingPlayer />
<PageBookmarks />
<Toaster />
</TooltipProvider>
</AudioPlayerProvider>
</AuthGateProvider>
</AuthProvider>
</ThemeProvider>
</body>
+65 -260
View File
@@ -1,279 +1,84 @@
"use client";
import Link from "next/link";
import {
BookOpen,
Mail,
Mic,
PenLine,
Newspaper,
NotebookPen,
BookA,
Users,
FileStack,
ArrowUpRight,
} from "lucide-react";
import { Compass, LogIn, Sparkles } from "lucide-react";
import { useAuth } from "@/components/auth/auth-provider";
/**
* Editorial homepage.
* / — the Home (Feed) gate.
*
* 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.
* The Instagram-inspired infinite-scroll feed of posts from people you
* follow will live here. Until that ships we show two paths:
*
* SiteHeader handles brand + nav + search + user menu, so this page paints
* directly onto the background — no PageHeader needed.
* • Sign in — for visitors who don't have an account yet; the eventual
* feed is personalised, so an account is the prerequisite.
* • Explore the Knowledgebase — Śrīla Prabhupāda's full library is
* ready today, regardless of feed status.
*
* The "Sign in" CTA hides once the user is authenticated — at that point
* there's nothing to gate, just a "feed coming soon" note above the
* always-on Knowledgebase entry.
*/
type Feature = {
href: string;
label: string;
blurb: string;
count?: string;
icon: React.ComponentType<{ className?: string }>;
};
export default function HomePage() {
const { user, loading } = useAuth();
const authed = !loading && !!user;
const FEATURED: Feature[] = [
{
href: "/books",
label: "Books",
blurb:
"Bhagavad-gītā, Śrīmad-Bhāgavatam, Caitanya-caritāmṛta and 34 more — each verse with Devanāgarī, transliteration, synonyms, translation and purport.",
count: "37 books",
icon: BookOpen,
},
{
href: "/letters",
label: "Letters",
blurb:
"Three decades of personal correspondence to disciples, family, and officials. Browse by year, recipient, or location.",
count: "6,866 letters",
icon: Mail,
},
{
href: "/transcriptions",
label: "Lectures & Conversations",
blurb:
"Lectures, morning walks, room conversations, and dialogues — the living voice of the Founder-Ācārya preserved in text and audio.",
count: "3,787 transcripts",
icon: Mic,
},
];
const EXPLORE: Feature[] = [
{
href: "/essays",
label: "Essays",
blurb: "Foundational standalone essays.",
count: "23 essays",
icon: PenLine,
},
{
href: "/magazines",
label: "Back to Godhead",
blurb: "The original 1944 – 1973 magazine archive.",
count: "1,124 articles",
icon: Newspaper,
},
{
href: "/journals",
label: "Journals",
blurb: "The Jaladuta Diary and The Beginning.",
count: "300 entries",
icon: NotebookPen,
},
{
href: "/glossary",
label: "Glossary",
blurb: "Sanskrit and Vedic terms with definitions.",
count: "2,749 terms",
icon: BookA,
},
{
href: "/disciples",
label: "Disciples",
blurb: "Initiated disciples 1966 – 1977.",
count: "4,747 names",
icon: Users,
},
{
href: "/other-documents",
label: "Other Documents",
blurb: "Miscellaneous analyses and writings.",
count: "12 documents",
icon: FileStack,
},
];
const STATS = [
{ label: "Books", value: "37" },
{ label: "Letters", value: "6,866" },
{ label: "Lectures", value: "3,787" },
{ label: "Glossary", value: "2,749" },
];
export default function Home() {
return (
<div className="flex-1">
<Hero />
<Featured />
<Explore />
</div>
);
}
<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"
>
<Sparkles className="size-6" />
</span>
// ─── Hero ────────────────────────────────────────────────────────────────
<h1 className="font-[family-name:var(--font-title)] font-semibold text-2xl sm:text-3xl text-foreground leading-tight text-balance">
{authed
? "Your feed is coming soon"
: "Welcome to Prabhupāda Says"}
</h1>
function Hero() {
return (
<section className="max-w-5xl mx-auto px-4 sm:px-6 pt-12 sm:pt-20 pb-10 sm:pb-14">
{/* Eyebrow */}
<p className="uppercase tracking-[0.22em] text-xs font-medium text-muted-foreground">
The Words of Śrīla Prabhupāda
</p>
{/* Hero quote — each sentence on its own line so the visual rhythm is
predictable regardless of viewport. text-balance keeps wraps tidy. */}
<blockquote className="mt-5 font-[family-name:var(--font-title)] font-semibold text-3xl sm:text-4xl md:text-5xl leading-[1.2] text-foreground max-w-3xl text-balance">
<span className="block">“Books are the basis.</span>
<span className="block">Purity is the force.</span>
<span className="block">Preaching is the essence.</span>
<span className="block">Utility is the principle.”</span>
</blockquote>
<p className="mt-6 text-base text-muted-foreground max-w-xl leading-relaxed">
A complete, searchable library of His Divine Grace A.C. Bhaktivedanta
Swami Prabhupāda&apos;s books, letters, lectures, and teachings — set
in modern Unicode Sanskrit, free to read.
</p>
{/* Stat strip */}
<dl className="mt-10 grid grid-cols-2 sm:grid-cols-4 gap-x-6 gap-y-4 max-w-2xl border-y border-border py-5">
{STATS.map((s) => (
<div key={s.label}>
<dd className="font-[family-name:var(--font-title)] font-semibold text-2xl text-foreground tabular-nums">
{s.value}
</dd>
<dt className="text-xs uppercase tracking-[0.14em] text-muted-foreground mt-0.5">
{s.label}
</dt>
</div>
))}
</dl>
</section>
);
}
// ─── Featured (3-up large tiles) ─────────────────────────────────────────
function Featured() {
return (
<section className="max-w-5xl mx-auto px-4 sm:px-6 py-10">
<SectionHeading eyebrow="Start here" title="Three doors in" />
<div className="grid grid-cols-1 md:grid-cols-3 gap-4 sm:gap-5">
{FEATURED.map((f) => (
<FeaturedTile key={f.href} feature={f} />
))}
</div>
</section>
);
}
function FeaturedTile({ feature }: { feature: Feature }) {
const Icon = feature.icon;
return (
<Link
href={feature.href}
className="group relative flex flex-col h-full rounded-2xl bg-card ring-1 ring-foreground/10 hover:ring-foreground/25 hover:shadow-md transition-all p-6"
>
<div className="flex items-start justify-between">
<div className="flex items-center justify-center size-11 rounded-xl bg-primary/12 text-primary">
<Icon className="size-5" />
</div>
<ArrowUpRight className="size-4 text-muted-foreground/60 group-hover:text-foreground transition-colors" />
</div>
<h3 className="mt-5 font-[family-name:var(--font-title)] font-semibold text-2xl sm:text-[1.6rem] leading-tight text-foreground">
{feature.label}
</h3>
<p className="mt-2 text-sm text-muted-foreground leading-relaxed flex-1">
{feature.blurb}
</p>
{feature.count ? (
<p className="mt-5 text-xs uppercase tracking-[0.14em] font-medium text-muted-foreground">
{feature.count}
<p className="text-sm text-muted-foreground leading-relaxed max-w-md">
{authed
? "We're building a feed of quotes shared by the community. Until it lands, dive into the Knowledgebase — Śrīla Prabhupāda's books, letters, lectures, and teachings."
: "Sign in to follow seekers and see their quotes here once the feed launches, or jump straight into the Knowledgebase — the original, unedited writings of His Divine Grace A.C. Bhaktivedanta Swami Prabhupāda."}
</p>
) : null}
</Link>
);
}
// ─── Explore (smaller grid) ──────────────────────────────────────────────
function Explore() {
return (
<section className="max-w-5xl mx-auto px-4 sm:px-6 py-10 pb-16">
<SectionHeading eyebrow="Explore" title="The wider library" />
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-3 sm:gap-4">
{EXPLORE.map((f) => (
<ExploreTile key={f.href} feature={f} />
))}
</div>
</section>
);
}
function ExploreTile({ feature }: { feature: Feature }) {
const Icon = feature.icon;
return (
<Link
href={feature.href}
className="group flex items-start gap-4 rounded-xl bg-card/70 ring-1 ring-foreground/8 hover:ring-foreground/20 hover:bg-card transition-all p-4 sm:p-5"
>
<div className="flex items-center justify-center size-9 rounded-lg bg-secondary text-secondary-foreground/80 shrink-0">
<Icon className="size-4" />
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center justify-between gap-2">
<h4 className="font-[family-name:var(--font-title)] font-semibold text-lg text-foreground leading-tight">
{feature.label}
</h4>
<ArrowUpRight className="size-3.5 text-muted-foreground/50 group-hover:text-foreground transition-colors shrink-0" />
<div className="flex flex-col sm:flex-row items-center gap-3 mt-2">
{!authed ? (
<Link
href="/login"
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]"
>
<LogIn className="size-4" />
Sign in to view feed
</Link>
) : null}
<Link
href="/search"
className={
authed
? "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]"
: "inline-flex items-center justify-center gap-2 rounded-lg border border-border text-foreground px-5 h-11 text-sm font-medium hover:bg-muted transition-colors min-w-[12rem]"
}
>
<Compass className="size-4" />
Explore Knowledgebase
</Link>
</div>
<p className="text-sm text-muted-foreground mt-1 leading-relaxed">
{feature.blurb}
</p>
{feature.count ? (
<p className="text-xs uppercase tracking-[0.14em] font-medium text-muted-foreground/80 mt-2">
{feature.count}
{!authed ? (
<p className="text-xs text-muted-foreground mt-1">
No account?{" "}
<Link href="/signup" className="text-foreground hover:underline">
Create one
</Link>{" "}
— it&apos;s free.
</p>
) : null}
</div>
</Link>
);
}
// ─── Section heading (eyebrow + title) ───────────────────────────────────
function SectionHeading({
eyebrow,
title,
}: {
eyebrow: string;
title: string;
}) {
return (
<div className="mb-6 flex items-baseline gap-4">
<span className="text-xs uppercase tracking-[0.2em] font-semibold text-muted-foreground">
{eyebrow}
</span>
<span className="flex-1 h-px bg-border" />
<h2 className="font-[family-name:var(--font-title)] font-semibold text-xl sm:text-2xl text-foreground">
{title}
</h2>
</div>
</main>
);
}
-47
View File
@@ -1,47 +0,0 @@
"use client";
import { useEffect } from "react";
import { useRouter } from "next/navigation";
import { useAuth } from "@/components/auth/auth-provider";
import { ProfileForm } from "./profile-form";
/**
* /profile is a client-rendered page guarded by useAuth.
*
* We deliberately do NOT use Next.js middleware for the auth gate: the
* session cookie is httpOnly on the BFF domain, and middleware-level
* cookie forwarding to the BFF is more code than this approach is worth.
*
* Instead the page renders a loading state until AuthProvider hydrates,
* then either shows the form or redirects to /login.
*/
export default function ProfilePage() {
const router = useRouter();
const { user, loading } = useAuth();
useEffect(() => {
if (!loading && !user) {
router.replace("/login?next=/profile");
}
}, [loading, user, router]);
if (loading || !user) {
return (
<main className="flex-1 flex items-center justify-center px-4 py-12">
<p className="text-sm text-muted-foreground">Loading…</p>
</main>
);
}
return (
<main className="flex-1 max-w-2xl w-full mx-auto px-4 py-12 space-y-8">
<header>
<h1 className="font-heading text-3xl font-medium">Your profile</h1>
<p className="text-sm text-muted-foreground mt-1">
Update how you appear across Prabhupāda Says.
</p>
</header>
<ProfileForm user={user} />
</main>
);
}
-197
View File
@@ -1,197 +0,0 @@
"use client";
import { useRef, useState } from "react";
import { useRouter } from "next/navigation";
import Image from "next/image";
import { useAuth } from "@/components/auth/auth-provider";
import { auth, AuthError, type User } from "@/lib/auth";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
/**
* Profile edit form. Two responsibilities:
*
* 1. Inline name edit (debounced save on form submit).
* 2. Avatar upload via R2 presigned PUT:
* click [Change] → presign → PUT to R2 → PATCH /users/me with new URL.
*
* The new avatar URL appended with a `?v=<random>` cache-buster forces the
* browser to fetch the new image even if the key happened to collide
* (extremely unlikely given our random suffix, but cheap insurance).
*/
export function ProfileForm({ user }: { user: User }) {
const router = useRouter();
const { logOut, setUser } = useAuth();
const fileInput = useRef<HTMLInputElement>(null);
const [name, setName] = useState(user.name);
const [avatarUrl, setAvatarUrl] = useState<string | null>(user.avatar_url);
const [saving, setSaving] = useState(false);
const [uploading, setUploading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [success, setSuccess] = useState<string | null>(null);
async function onSave(e: React.SyntheticEvent) {
e.preventDefault();
setError(null);
setSuccess(null);
setSaving(true);
try {
const updated = await auth.updateProfile(name);
setUser(updated);
setSuccess("Profile saved.");
} catch (err) {
setError(err instanceof AuthError ? err.message : "Could not save.");
} finally {
setSaving(false);
}
}
async function onAvatarChange(e: React.ChangeEvent<HTMLInputElement>) {
const file = e.target.files?.[0];
if (!file) return;
setError(null);
setSuccess(null);
setUploading(true);
try {
const publicURL = await auth.uploadAvatar(file);
const updated = await auth.updateProfile(undefined, publicURL);
setUser(updated);
setAvatarUrl(updated.avatar_url);
setSuccess("Avatar updated.");
} catch (err) {
setError(err instanceof AuthError ? err.message : "Upload failed.");
} finally {
setUploading(false);
if (fileInput.current) fileInput.current.value = "";
}
}
async function onRemoveAvatar() {
setError(null);
setUploading(true);
try {
const updated = await auth.updateProfile(undefined, "");
setUser(updated);
setAvatarUrl(null);
} catch (err) {
setError(err instanceof AuthError ? err.message : "Could not remove avatar.");
} finally {
setUploading(false);
}
}
async function onLogout() {
await logOut();
router.push("/");
router.refresh();
}
return (
<div className="space-y-8">
<section className="space-y-4">
<h2 className="font-heading text-lg font-medium">Avatar</h2>
<div className="flex items-center gap-4">
<AvatarPreview src={avatarUrl} name={user.name} />
<div className="flex flex-col gap-2">
<input
ref={fileInput}
type="file"
accept="image/png,image/jpeg,image/webp,image/gif"
hidden
onChange={onAvatarChange}
/>
<Button
variant="outline"
type="button"
onClick={() => fileInput.current?.click()}
disabled={uploading}
className="h-11 px-5 text-base"
>
{uploading ? "Uploading…" : avatarUrl ? "Change avatar" : "Upload avatar"}
</Button>
{avatarUrl ? (
<Button
variant="ghost"
type="button"
onClick={onRemoveAvatar}
disabled={uploading}
className="h-11 px-5 text-base"
>
Remove
</Button>
) : null}
</div>
</div>
</section>
<form onSubmit={onSave} className="space-y-4">
<h2 className="font-heading text-lg font-medium">Details</h2>
<div className="space-y-1.5">
<Label htmlFor="email">Email</Label>
<Input id="email" value={user.email} disabled />
<p className="text-xs text-muted-foreground">
Email changes aren&apos;t supported yet — drop us a note if you need this.
</p>
</div>
<div className="space-y-1.5">
<Label htmlFor="name">Display name</Label>
<Input
id="name"
value={name}
onChange={(e) => setName(e.target.value)}
maxLength={120}
required
/>
</div>
{error ? <p className="text-sm text-destructive">{error}</p> : null}
{success ? <p className="text-sm text-emerald-600">{success}</p> : null}
<div className="flex justify-between items-center pt-2">
<Button
type="submit"
disabled={saving || name === user.name}
className="h-11 px-6 text-base"
>
{saving ? "Saving…" : "Save changes"}
</Button>
<Button
type="button"
variant="ghost"
onClick={onLogout}
className="h-11 px-5 text-base"
>
Log out
</Button>
</div>
</form>
</div>
);
}
function AvatarPreview({ src, name }: { src: string | null; name: string }) {
if (src) {
return (
<Image
src={src}
alt={name}
width={80}
height={80}
className="size-20 rounded-full object-cover ring-1 ring-foreground/10"
unoptimized
/>
);
}
const initials =
name
.split(/\s+/)
.map((p) => p[0])
.slice(0, 2)
.join("")
.toUpperCase() || "?";
return (
<div className="size-20 rounded-full bg-muted flex items-center justify-center text-2xl font-medium text-muted-foreground ring-1 ring-foreground/10">
{initials}
</div>
);
}
+7 -48
View File
@@ -8,6 +8,7 @@ import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils";
import { buildSearchResultUrl } from "@/lib/text-highlight";
import { KnowledgebaseIndex } from "@/components/knowledgebase-index";
import {
Search,
Loader2,
@@ -329,12 +330,12 @@ function SearchInner() {
<p className="uppercase tracking-[0.22em] text-xs font-semibold text-muted-foreground">
Search
</p>
<h1 className="mt-4 font-[family-name:var(--font-title)] font-semibold text-4xl sm:text-5xl leading-tight text-foreground">
<h1 className="mt-4 font-[family-name:var(--font-title)] font-semibold text-2xl sm:text-4xl 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
Smart search across books, letters, lectures, essays,
journals and magazines. Diacritics are ignored, so
&ldquo;Krsna&rdquo; finds &ldquo;Kṛṣṇa.&rdquo;
</p>
</section>
@@ -426,10 +427,9 @@ function SearchInner() {
</div>
)}
{/* No query — show editorial empty state with example queries */}
{!isLoading && !hasSearched && (
<EmptyStateExamples onPick={(q) => setInputValue(q)} />
)}
{/* No query — render the full Knowledgebase index so the search
page doubles as the browsing entry point. */}
{!isLoading && !hasSearched && <KnowledgebaseIndex />}
{/* No results */}
{!isLoading &&
@@ -634,47 +634,6 @@ function ResultCard({ result }: { result: SearchResult }) {
);
}
// ---------------------------------------------------------------------------
// 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
// ---------------------------------------------------------------------------
+131
View File
@@ -0,0 +1,131 @@
"use client";
/**
* Public profile at /u/[handle].
*
* Anonymous-friendly. Mirrors /u/me's shell — ProfileHeader at the top,
* a smaller tab strip (Posts + Collections only; Bookmarks and Mentions
* are private), then a grid of the active tab's content.
*
* Owner-handle redirect: when the logged-in user lands on their own
* /u/<handle>, we send them to /u/me where the owner controls live.
*/
import { use, useEffect, useState } from "react";
import Link from "next/link";
import { notFound, useRouter } from "next/navigation";
import { FolderClosed, LayoutGrid } from "lucide-react";
import {
profile,
SocialError,
type Post,
type PublicProfile,
} from "@/lib/social";
import { ProfileHeader } from "@/components/profile/profile-header";
import {
ProfileTabs,
type ProfileTab,
} from "@/components/profile/profile-tabs";
import { PostViews } from "@/components/social/post-views";
import { CollectionViews } from "@/components/social/collection-views";
import { useAuth } from "@/components/auth/auth-provider";
type Tab = "posts" | "collections";
export default function PublicProfilePage(props: {
params: Promise<{ handle: string }>;
searchParams?: Promise<{ tab?: string }>;
}) {
const { handle } = use(props.params);
const search = props.searchParams ? use(props.searchParams) : undefined;
const tab: Tab = search?.tab === "collections" ? "collections" : "posts";
const router = useRouter();
const { user } = useAuth();
const [data, setData] = useState<PublicProfile | null>(null);
const [feed, setFeed] = useState<Post[]>([]);
const [loading, setLoading] = useState(true);
const [notFoundState, setNotFound] = useState(false);
const isOwn = !!user && user.handle.toLowerCase() === handle.toLowerCase();
useEffect(() => {
if (isOwn) router.replace("/u/me");
}, [isOwn, router]);
useEffect(() => {
if (isOwn) return;
let cancelled = false;
setLoading(true);
Promise.all([profile.get(handle), profile.feed(handle, 30)])
.then(([p, posts]) => {
if (cancelled) return;
setData(p);
setFeed(posts ?? []);
})
.catch((err) => {
if (err instanceof SocialError && err.status === 404) {
setNotFound(true);
}
})
.finally(() => !cancelled && setLoading(false));
return () => {
cancelled = true;
};
}, [handle, isOwn]);
if (notFoundState) notFound();
if (isOwn) return null;
if (loading || !data) {
return (
<main className="flex-1 max-w-3xl w-full mx-auto px-4 py-12">
<p className="text-sm text-muted-foreground">Loading profile…</p>
</main>
);
}
const tabs: ProfileTab[] = [
{
href: `/u/${data.handle}`,
label: "Posts",
icon: <LayoutGrid className="size-5" />,
},
{
href: `/u/${data.handle}?tab=collections`,
label: "Collections",
icon: <FolderClosed className="size-5" />,
},
];
return (
<main className="flex-1 max-w-3xl w-full mx-auto px-4 sm:px-6">
<ProfileHeader
avatarURL={data.avatar_url}
name={data.name}
handle={data.handle}
postCount={data.post_count}
joinedAt={data.joined_at}
/>
<ProfileTabs tabs={tabs} />
<div className="py-6">
{tab === "collections" ? (
data.public_collections && data.public_collections.length > 0 ? (
<CollectionViews
collections={data.public_collections}
hrefFor={(c) => `/c/${c.id}`}
/>
) : (
<p className="text-sm text-muted-foreground">
No public collections yet.
</p>
)
) : (
<PostViews
posts={feed}
hrefFor={(p) => `/u/${data.handle}/posts#post-${p.id}`}
emptyMessage="No posts yet — quotes shared by this user will appear here."
/>
)}
</div>
</main>
);
}
+70
View File
@@ -0,0 +1,70 @@
"use client";
/**
* /u/[handle]/posts — public Instagram-style scroll feed.
*
* Mirrors /u/me/posts but read-only (no Delete / Update background). The
* grid thumbnail on /u/[handle] links here with a `#post-<id>` hash so
* the feed scrolls to the tapped post on mount.
*/
import { use, useEffect, useRef, useState } from "react";
import { notFound } from "next/navigation";
import { profile, SocialError, type Post } from "@/lib/social";
import { PostScrollCard } from "@/components/social/post-scroll-card";
export default function PublicPostsFeedPage(props: {
params: Promise<{ handle: string }>;
}) {
const { handle } = use(props.params);
const [feed, setFeed] = useState<Post[]>([]);
const [loading, setLoading] = useState(true);
const [missing, setMissing] = useState(false);
const scrolledToHashRef = useRef(false);
useEffect(() => {
let cancelled = false;
setLoading(true);
profile
.feed(handle, 60)
.then((rows) => {
if (cancelled) return;
setFeed(rows ?? []);
})
.catch((err) => {
if (err instanceof SocialError && err.status === 404) {
setMissing(true);
}
})
.finally(() => !cancelled && setLoading(false));
return () => {
cancelled = true;
};
}, [handle]);
useEffect(() => {
if (loading || feed.length === 0 || scrolledToHashRef.current) return;
if (typeof window === "undefined") return;
const hash = window.location.hash;
if (!hash) return;
scrolledToHashRef.current = true;
requestAnimationFrame(() => {
const el = document.getElementById(hash.slice(1));
if (el) el.scrollIntoView({ block: "start", behavior: "auto" });
});
}, [loading, feed.length]);
if (missing) notFound();
return (
<main className="flex-1 max-w-xl w-full mx-auto px-3 sm:px-4 py-4 sm:py-6 space-y-6">
{loading ? (
<p className="text-sm text-muted-foreground">Loading…</p>
) : feed.length === 0 ? (
<p className="text-sm text-muted-foreground">No posts yet.</p>
) : (
feed.map((p) => <PostScrollCard key={p.id} post={p} />)
)}
</main>
);
}
+117
View File
@@ -0,0 +1,117 @@
"use client";
import { useEffect, useState } from "react";
import Link from "next/link";
import { Trash2, ExternalLink } from "lucide-react";
import { bookmarks, SocialError, type Bookmark } from "@/lib/social";
import { useAuth } from "@/components/auth/auth-provider";
import { appendHighlight, hasHighlight } from "@/lib/text-highlight";
import { displaySourceLabel } from "@/lib/source-reference";
const FREE_LIMIT = 1;
const PREMIUM_LIMIT = 5;
export default function MyBookmarksPage() {
const { user } = useAuth();
const [list, setList] = useState<Bookmark[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
setLoading(true);
bookmarks
.list()
.then((r) => setList(r ?? []))
.catch((err) =>
setError(
err instanceof SocialError ? err.message : "Could not load bookmarks.",
),
)
.finally(() => setLoading(false));
}, []);
async function remove(id: string) {
const prev = list;
setList((rows) => rows.filter((b) => b.id !== id));
try {
await bookmarks.delete(id);
} catch (err) {
setList(prev);
setError(err instanceof SocialError ? err.message : "Could not delete.");
}
}
const limit = user?.role === "admin" || user?.plan === "premium" ? PREMIUM_LIMIT : FREE_LIMIT;
if (loading) return <p className="text-sm text-muted-foreground">Loading…</p>;
return (
<div className="space-y-5">
<div className="rounded-lg bg-card/60 ring-1 ring-foreground/8 px-4 py-3 text-sm flex items-center justify-between">
<span className="text-muted-foreground">
Bookmarks used:{" "}
<span className="text-foreground tabular-nums">
{list.length} / {limit}
</span>
</span>
{user?.plan !== "premium" && list.length >= limit ? (
<span className="text-xs text-amber-600 dark:text-amber-400">
Upgrade to premium for 5 bookmarks
</span>
) : null}
</div>
{error ? <p className="text-sm text-destructive">{error}</p> : null}
{list.length === 0 ? (
<p className="text-sm text-muted-foreground">
No bookmarks yet. Select text anywhere and use “Add bookmark” in the
more menu.
</p>
) : (
<ul className="space-y-3">
{list.map((b) => {
// If the stored URL doesn't already carry a `?hl=` (old data, or
// a bookmark saved without a selection), synthesise one from the
// excerpt so clicking still scrolls to + highlights the passage.
const href =
hasHighlight(b.source_url) || !b.excerpt
? b.source_url
: appendHighlight(b.source_url, b.excerpt);
return (
<li
key={b.id}
className="rounded-xl bg-card ring-1 ring-foreground/10 p-4 flex items-start gap-3"
>
<div className="flex-1 min-w-0">
<Link
href={href}
className="font-[family-name:var(--font-title)] text-base text-foreground hover:underline inline-flex items-center gap-1"
>
<ExternalLink className="size-3.5" />
{displaySourceLabel(b.source_url, b.source_title)}
</Link>
{b.excerpt ? (
<p className="mt-1 text-sm text-muted-foreground italic line-clamp-2">
“{b.excerpt}”
</p>
) : null}
<p className="text-xs text-muted-foreground mt-2">
Saved {new Date(b.created_at).toLocaleDateString()}
</p>
</div>
<button
onClick={() => remove(b.id)}
aria-label="Delete bookmark"
className="size-9 inline-flex items-center justify-center rounded-md text-muted-foreground hover:text-destructive hover:bg-destructive/10 transition-colors"
>
<Trash2 className="size-4" />
</button>
</li>
);
})}
</ul>
)}
</div>
);
}
+91
View File
@@ -0,0 +1,91 @@
"use client";
/**
* /u/me/collections/[id] — single-collection view.
*
* The whole page is the collection's CollectionScrollCard (carousel of
* cover + posts) and an Edit button below it. All editing — title,
* description, cover, member posts, delete — lives in the EditCollectionDrawer
* so the page itself stays focused on the carousel preview.
*/
import { use, useEffect, useState } from "react";
import { useRouter } from "next/navigation";
import { ArrowLeft } from "lucide-react";
import {
collections,
SocialError,
type Collection,
type Post,
} from "@/lib/social";
import { CollectionScrollCard } from "@/components/social/collection-scroll-card";
import { EditCollectionDrawer } from "@/components/social/edit-collection-drawer";
export default function CollectionDetailPage(props: {
params: Promise<{ id: string }>;
}) {
const { id } = use(props.params);
const router = useRouter();
const [c, setC] = useState<Collection | null>(null);
const [posts, setPosts] = useState<Post[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [editOpen, setEditOpen] = useState(false);
useEffect(() => {
let cancelled = false;
setLoading(true);
collections
.get(id)
.then(({ collection, posts }) => {
if (cancelled) return;
setC(collection);
setPosts(posts ?? []);
})
.catch((err) => {
setError(
err instanceof SocialError ? err.message : "Could not load collection.",
);
})
.finally(() => !cancelled && setLoading(false));
return () => {
cancelled = true;
};
}, [id]);
if (loading) return <p className="text-sm text-muted-foreground">Loading…</p>;
if (error && !c) return <p className="text-sm text-destructive">{error}</p>;
if (!c) return null;
return (
<div className="space-y-6">
<button
type="button"
onClick={() => router.push("/u/me/collections")}
className="inline-flex items-center gap-1 text-sm text-muted-foreground hover:text-foreground"
>
<ArrowLeft className="size-4" /> All collections
</button>
<CollectionScrollCard
collection={c}
href={`/u/me/collections/${c.id}`}
initialPosts={posts}
onEdit={() => setEditOpen(true)}
/>
<EditCollectionDrawer
open={editOpen}
onClose={() => setEditOpen(false)}
collection={c}
currentPostIds={posts.map((p) => p.id)}
onSaved={({ collection, posts: rows }) => {
setC(collection);
setPosts(rows);
}}
onDeleted={() => router.push("/u/me/collections")}
/>
</div>
);
}
+150
View File
@@ -0,0 +1,150 @@
"use client";
import { useEffect, useState } from "react";
import { Plus } from "lucide-react";
import { collections, SocialError, type Collection } from "@/lib/social";
import { useAuth } from "@/components/auth/auth-provider";
import { CollectionViews } from "@/components/social/collection-views";
export default function MyCollectionsPage() {
const { user } = useAuth();
const [list, setList] = useState<Collection[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
// Inline create form
const [showCreate, setShowCreate] = useState(false);
const [title, setTitle] = useState("");
const [description, setDescription] = useState("");
const [isPublic, setIsPublic] = useState(true);
const [creating, setCreating] = useState(false);
useEffect(() => {
setLoading(true);
collections
.mine()
.then((r) => setList(r ?? []))
.catch((err) =>
setError(
err instanceof SocialError ? err.message : "Could not load collections.",
),
)
.finally(() => setLoading(false));
}, []);
async function createOne(e: React.SyntheticEvent) {
e.preventDefault();
if (!title.trim()) return;
setCreating(true);
setError(null);
try {
const c = await collections.create({
title,
description: description || undefined,
is_public: isPublic,
});
setList((rows) => [c, ...rows]);
setTitle("");
setDescription("");
setShowCreate(false);
} catch (err) {
setError(err instanceof SocialError ? err.message : "Could not create.");
} finally {
setCreating(false);
}
}
const isPremium = user?.plan === "premium";
const publicCount = list.filter((c) => c.is_public).length;
const privateCount = list.filter((c) => !c.is_public).length;
return (
<div className="space-y-5">
<div className="flex items-center justify-between">
<p className="text-sm text-muted-foreground">
{isPremium ? (
<>Premium — unlimited collections.</>
) : (
<>
<span className="tabular-nums">{publicCount}</span> / 5 public ·{" "}
<span className="tabular-nums">{privateCount}</span> / 5 private
</>
)}
</p>
{!showCreate ? (
<button
onClick={() => setShowCreate(true)}
className="inline-flex items-center gap-1.5 rounded-lg bg-primary text-primary-foreground px-3 h-10 text-sm font-medium"
>
<Plus className="size-4" />
New
</button>
) : null}
</div>
{showCreate ? (
<form
onSubmit={createOne}
className="rounded-xl bg-card ring-1 ring-foreground/10 p-4 space-y-3"
>
<input
value={title}
onChange={(e) => setTitle(e.target.value)}
placeholder="Collection title"
maxLength={120}
required
className="w-full bg-background text-foreground text-base rounded-md border border-border px-3 py-2 outline-none focus-visible:ring-2 focus-visible:ring-primary/40"
/>
<textarea
value={description}
onChange={(e) => setDescription(e.target.value)}
placeholder="Description (optional)"
rows={2}
className="w-full bg-background text-foreground text-sm rounded-md border border-border px-3 py-2 outline-none focus-visible:ring-2 focus-visible:ring-primary/40 resize-none"
/>
<div className="flex items-center justify-between text-sm">
<label className="flex items-center gap-2 cursor-pointer">
<input
type="checkbox"
checked={isPublic}
onChange={(e) => setIsPublic(e.target.checked)}
/>
<span className="text-foreground">Public</span>
</label>
<div className="flex items-center gap-2">
<button
type="button"
onClick={() => {
setShowCreate(false);
setTitle("");
}}
className="px-3 h-9 rounded-md text-muted-foreground hover:bg-muted"
>
Cancel
</button>
<button
type="submit"
disabled={creating || !title.trim()}
className="px-3 h-9 rounded-md bg-primary text-primary-foreground disabled:opacity-60"
>
{creating ? "Creating…" : "Create"}
</button>
</div>
</div>
</form>
) : null}
{error ? <p className="text-sm text-destructive">{error}</p> : null}
{loading ? (
<p className="text-sm text-muted-foreground">Loading…</p>
) : (
<CollectionViews
collections={list}
hrefFor={(c) => `/u/me/collections/${c.id}`}
emptyMessage="No collections yet — create one above, or save a quote into a new collection from the selection toolbar."
/>
)}
</div>
);
}
+93
View File
@@ -0,0 +1,93 @@
"use client";
/**
* /u/me shared layout — auth gate + Instagram-style profile shell.
*
* • Anonymous visitors trigger the global AuthModal and are bounced
* home if they dismiss it (unchanged from before).
* • Renders the ProfileHeader + ProfileTabs above every /u/me/* page.
* Tabs: Posts / Collections / Bookmarks / Mentions.
*
* The old left ProfileSidebar is gone. The "Edit profile" affordance
* moved onto the header as a button that toggles edit mode on /u/me
* via a query param (kept simple — no new route for the form).
*/
import { useEffect } from "react";
import { useRouter } from "next/navigation";
import {
AtSign,
Bookmark,
FolderClosed,
LayoutGrid,
} from "lucide-react";
import { useAuth } from "@/components/auth/auth-provider";
import { useAuthGate } from "@/components/auth/auth-gate";
import { ProfileHeader } from "@/components/profile/profile-header";
import { ProfileTabs, type ProfileTab } from "@/components/profile/profile-tabs";
export default function MeLayout({ children }: { children: React.ReactNode }) {
const { user, loading } = useAuth();
const { requireAuth } = useAuthGate();
const router = useRouter();
useEffect(() => {
if (loading || user) return;
let cancelled = false;
(async () => {
const ok = await requireAuth();
if (!ok && !cancelled) router.replace("/");
})();
return () => {
cancelled = true;
};
}, [loading, user, requireAuth, 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 ? "Loading…" : "Sign in to view your library."}
</p>
</main>
);
}
const tabs: ProfileTab[] = [
{
href: "/u/me",
label: "Posts",
icon: <LayoutGrid className="size-5" />,
activeFor: ["/u/me/posts"],
},
{
href: "/u/me/collections",
label: "Collections",
icon: <FolderClosed className="size-5" />,
},
{
href: "/u/me/bookmarks",
label: "Bookmarks",
icon: <Bookmark className="size-5" />,
},
{
href: "/u/me/mentions",
label: "Mentions",
icon: <AtSign className="size-5" />,
},
];
return (
<main className="flex-1 max-w-3xl w-full mx-auto px-4 sm:px-6">
<ProfileHeader
avatarURL={user.avatar_url}
name={user.name}
handle={user.handle}
joinedAt={user.created_at}
editHref="/u/me?edit=1"
/>
<ProfileTabs tabs={tabs} />
<div className="py-6">{children}</div>
</main>
);
}
+21
View File
@@ -0,0 +1,21 @@
"use client";
/**
* /u/me/mentions — placeholder until the Mentions feature ships.
*
* The tab itself is wired into the profile shell already so users see
* an empty state instead of a 404 when they tap it.
*/
import { AtSign } from "lucide-react";
export default function MentionsPage() {
return (
<div className="flex flex-col items-center text-center gap-3 py-12 text-muted-foreground">
<AtSign className="size-8" />
<p className="text-sm">
Mentions of you across posts will appear here. Coming soon.
</p>
</div>
);
}
+124
View File
@@ -0,0 +1,124 @@
"use client";
/**
* /u/me — owner Posts grid (the default tab).
*
* Grid-only — no Grid/Scroll toggle. Tapping a thumbnail jumps to
* /u/me/posts#post-<id> where the Instagram-style scroll feed renders
* the full passages.
*
* `?edit=1` (set by the header's Edit profile button) swaps the grid
* for the inline edit form. Saving returns to /u/me. Keeps the URL
* shareable while avoiding a separate route + layout.
*/
import { useEffect, useState } from "react";
import { useRouter, useSearchParams } from "next/navigation";
import { LayoutGrid } from "lucide-react";
import { useAuth } from "@/components/auth/auth-provider";
import { ProfileEditForm } from "@/components/profile/profile-edit-form";
import { PostGridCard } from "@/components/social/post-grid-card";
import { ConfirmModal } from "@/components/ui/confirm-modal";
import { posts, SocialError, type Post } from "@/lib/social";
export default function MyProfilePage() {
const { user } = useAuth();
const router = useRouter();
const searchParams = useSearchParams();
const editMode = searchParams.get("edit") === "1";
const [feed, setFeed] = useState<Post[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
// Post id pending confirmation — modal opens when non-null and runs
// the actual delete on confirm.
const [pendingDelete, setPendingDelete] = useState<string | null>(null);
useEffect(() => {
if (!user) return;
setLoading(true);
posts
.mine()
.then((rows) => setFeed(rows ?? []))
.catch((err) =>
setError(
err instanceof SocialError ? err.message : "Could not load posts.",
),
)
.finally(() => setLoading(false));
}, [user]);
// Card delete buttons stage an id; ConfirmModal runs `performDelete`
// when the user confirms. We throw on failure so the modal stays open
// with an inline error message.
async function performDelete() {
const id = pendingDelete;
if (!id) return;
const prev = feed;
setFeed((rows) => rows.filter((p) => p.id !== id));
try {
await posts.delete(id);
} catch (err) {
setFeed(prev);
const msg =
err instanceof SocialError ? err.message : "Could not delete.";
setError(msg);
throw new Error(msg);
}
}
function onPostUpdated(updated: Post) {
setFeed((rows) => rows.map((p) => (p.id === updated.id ? updated : p)));
}
if (!user) return null;
if (editMode) {
return <ProfileEditForm user={user} onDone={() => router.replace("/u/me")} />;
}
return (
<section>
{error ? (
<p className="text-sm text-destructive">{error}</p>
) : loading ? (
<p className="text-sm text-muted-foreground">Loading…</p>
) : feed.length === 0 ? (
<EmptyPosts />
) : (
<div className="grid grid-cols-2 gap-2 sm:gap-3">
{feed.map((p) => (
<PostGridCard
key={p.id}
post={p}
onDelete={(id) => setPendingDelete(id)}
onUpdated={onPostUpdated}
href={`/u/me/posts#post-${p.id}`}
/>
))}
</div>
)}
<ConfirmModal
open={!!pendingDelete}
onClose={() => setPendingDelete(null)}
title="Delete this post?"
message="The post will be removed permanently from your feed and from any collections it's in. This can't be undone."
confirmLabel="Delete post"
onConfirm={performDelete}
/>
</section>
);
}
function EmptyPosts() {
return (
<div className="flex flex-col items-center text-center gap-3 py-12 text-muted-foreground">
<LayoutGrid className="size-8" />
<p className="text-sm">
You haven&apos;t posted anything yet. Select any passage in the
knowledgebase and use the toolbar to share it to your feed.
</p>
</div>
);
}
+108
View File
@@ -0,0 +1,108 @@
"use client";
/**
* /u/me/posts — the owner's Instagram-style scroll feed.
*
* Reached by tapping a thumbnail from the /u/me Posts grid. The clicked
* post's id arrives as a `#post-<id>` hash, and `scroll-mt-*` on each
* card spaces it below the sticky header so the anchor lands cleanly.
*
* Owner controls (Delete + Update background) are mounted per-card.
*/
import { useEffect, useRef, useState } from "react";
import { useAuth } from "@/components/auth/auth-provider";
import { PostScrollCard } from "@/components/social/post-scroll-card";
import { ConfirmModal } from "@/components/ui/confirm-modal";
import { posts, SocialError, type Post } from "@/lib/social";
export default function MyPostsFeedPage() {
const { user } = useAuth();
const [feed, setFeed] = useState<Post[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [pendingDelete, setPendingDelete] = useState<string | null>(null);
const scrolledToHashRef = useRef(false);
useEffect(() => {
if (!user) return;
setLoading(true);
posts
.mine()
.then((rows) => setFeed(rows ?? []))
.catch((err) =>
setError(
err instanceof SocialError ? err.message : "Could not load posts.",
),
)
.finally(() => setLoading(false));
}, [user]);
// Hash anchor: when the feed mounts, jump to the post identified in the
// URL hash. Defer one tick so the cards have laid out and `scroll-mt-*`
// can position the anchor below the sticky header.
useEffect(() => {
if (loading || feed.length === 0 || scrolledToHashRef.current) return;
if (typeof window === "undefined") return;
const hash = window.location.hash;
if (!hash) return;
scrolledToHashRef.current = true;
requestAnimationFrame(() => {
const el = document.getElementById(hash.slice(1));
if (el) el.scrollIntoView({ block: "start", behavior: "auto" });
});
}, [loading, feed.length]);
async function performDelete() {
const id = pendingDelete;
if (!id) return;
const prev = feed;
setFeed((rows) => rows.filter((p) => p.id !== id));
try {
await posts.delete(id);
} catch (err) {
setFeed(prev);
const msg =
err instanceof SocialError ? err.message : "Could not delete.";
setError(msg);
// Re-throw so ConfirmModal surfaces the failure and stays open.
throw new Error(msg);
}
}
function onPostUpdated(updated: Post) {
setFeed((rows) => rows.map((p) => (p.id === updated.id ? updated : p)));
}
if (!user) return null;
return (
<main className="flex-1 max-w-xl w-full mx-auto px-3 sm:px-4 py-4 sm:py-6 space-y-6">
{error ? (
<p className="text-sm text-destructive">{error}</p>
) : loading ? (
<p className="text-sm text-muted-foreground">Loading…</p>
) : feed.length === 0 ? (
<p className="text-sm text-muted-foreground">No posts yet.</p>
) : (
feed.map((p) => (
<PostScrollCard
key={p.id}
post={p}
onDelete={(id) => setPendingDelete(id)}
onUpdated={onPostUpdated}
/>
))
)}
<ConfirmModal
open={!!pendingDelete}
onClose={() => setPendingDelete(null)}
title="Delete this post?"
message="The post will be removed permanently from your feed and from any collections it's in. This can't be undone."
confirmLabel="Delete post"
onConfirm={performDelete}
/>
</main>
);
}