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>
);
}
+109
View File
@@ -0,0 +1,109 @@
"use client";
/**
* AuthGate — a global "require login before continuing" prompt.
*
* Why a gate at all? When an unauthenticated user clicks something that
* needs them to be logged in (Bookmark, Save to collection, etc.) we want to
*
* 1. Show a login/signup modal *on the same page* (no redirect).
* 2. Let them finish the original action right after they log in.
* 3. Keep their URL — bookmarks, scroll position, selection — untouched.
*
* The trick is the imperative `requireAuth()` call: it returns a Promise that
* resolves `true` once the user is authenticated, or `false` if they close
* the modal without doing so. Calling code reads like a sync check:
*
* if (!(await requireAuth())) return;
* // ... proceed with the protected API call
*
* Implementation sketch:
* - We hold a single "pending" promise resolver in a ref so a follow-on
* call to requireAuth (e.g. from another button) hooks onto the same
* modal session.
* - When AuthProvider reports a user (via useAuth) and the modal is open,
* we resolve the promise(s) with `true` and close.
* - When the modal closes without a user, we resolve with `false`.
*/
import {
createContext,
useCallback,
useContext,
useEffect,
useMemo,
useRef,
useState,
} from "react";
import { useAuth } from "./auth-provider";
import { AuthModal } from "./auth-modal";
type AuthGateValue = {
/** Returns true if the user is (or becomes) authenticated; false if they dismiss. */
requireAuth: () => Promise<boolean>;
/** Force-open the modal regardless of auth state — for explicit "Log in" buttons. */
openModal: () => void;
};
const AuthGateContext = createContext<AuthGateValue | null>(null);
export function AuthGateProvider({ children }: { children: React.ReactNode }) {
const { user, loading } = useAuth();
const [open, setOpen] = useState(false);
const resolversRef = useRef<((v: boolean) => void)[]>([]);
const requireAuth = useCallback(async () => {
// Fast path: already logged in.
if (user) return true;
setOpen(true);
return new Promise<boolean>((resolve) => {
resolversRef.current.push(resolve);
});
}, [user]);
const openModal = useCallback(() => {
if (!user) setOpen(true);
}, [user]);
// Auto-resolve pending promises the moment the user becomes authenticated.
// This is what flips us from "modal open, waiting" to "modal closed, action
// continues" without any extra wiring at the call site.
useEffect(() => {
if (user && (open || resolversRef.current.length > 0)) {
const pending = resolversRef.current;
resolversRef.current = [];
setOpen(false);
pending.forEach((r) => r(true));
}
}, [user, open]);
const onDismiss = useCallback(() => {
const pending = resolversRef.current;
resolversRef.current = [];
setOpen(false);
pending.forEach((r) => r(false));
}, []);
const value = useMemo<AuthGateValue>(
() => ({ requireAuth, openModal }),
[requireAuth, openModal],
);
return (
<AuthGateContext.Provider value={value}>
{children}
{/* Render the modal only when we genuinely need it. `!user` guards
against the brief window during which the AuthProvider hasn't
finished hydrating; we'd rather wait than flash the modal. */}
{open && !user && !loading ? <AuthModal onDismiss={onDismiss} /> : null}
</AuthGateContext.Provider>
);
}
export function useAuthGate(): AuthGateValue {
const ctx = useContext(AuthGateContext);
if (!ctx) {
throw new Error("useAuthGate must be used inside <AuthGateProvider>");
}
return ctx;
}
+158
View File
@@ -0,0 +1,158 @@
"use client";
/**
* AuthModal — login + signup tabs in a portal-rendered dialog.
*
* The two forms share the existing LoginForm / SignUpForm widgets, so the
* behaviour is identical to the standalone /login and /signup pages.
*
* The forms call `onSuccess` after they finish, but the modal does NOT close
* from that callback — AuthGate's effect on `user` change handles the close
* + promise resolution. This lets a single source of truth (AuthProvider's
* user state) drive the whole flow.
*
* No router pushes here — the user's URL stays exactly where it was.
*/
import { useState, useEffect } from "react";
import { createPortal } from "react-dom";
import Link from "next/link";
import { X } from "lucide-react";
import { cn } from "@/lib/utils";
import { LoginForm } from "./login-form";
import { SignUpForm } from "./signup-form";
type Mode = "login" | "signup";
export function AuthModal({ onDismiss }: { onDismiss: () => void }) {
const [mode, setMode] = useState<Mode>("login");
// Esc-to-close — matches the pattern used elsewhere (reading settings,
// search overlay) so the keyboard behavior feels consistent.
useEffect(() => {
function onKey(e: KeyboardEvent) {
if (e.key === "Escape") onDismiss();
}
document.addEventListener("keydown", onKey);
return () => document.removeEventListener("keydown", onKey);
}, [onDismiss]);
// Lock scroll on the body while the modal is open.
useEffect(() => {
const prev = document.body.style.overflow;
document.body.style.overflow = "hidden";
return () => {
document.body.style.overflow = prev;
};
}, []);
if (typeof document === "undefined") return null;
return createPortal(
<>
<div
className="fixed inset-0 z-100 bg-foreground/40 backdrop-blur-sm animate-in fade-in-0"
onClick={onDismiss}
aria-hidden
/>
<div
role="dialog"
aria-modal="true"
aria-labelledby="auth-modal-title"
className="fixed left-1/2 top-1/2 z-101 w-full max-w-md -translate-x-1/2 -translate-y-1/2 px-4"
>
<div className="rounded-2xl border border-border bg-card shadow-xl overflow-hidden">
<header className="flex items-center justify-between px-5 py-4 border-b border-border">
<h2
id="auth-modal-title"
className="font-[family-name:var(--font-title)] font-semibold text-xl text-foreground"
>
{mode === "login" ? "Welcome back" : "Create your account"}
</h2>
<button
type="button"
onClick={onDismiss}
className="size-8 rounded-lg text-muted-foreground hover:text-foreground hover:bg-muted inline-flex items-center justify-center"
aria-label="Close"
>
<X className="size-4" />
</button>
</header>
{/* Tab toggle */}
<div className="px-5 pt-4 grid grid-cols-2 gap-1 rounded-lg">
<ModeTab active={mode === "login"} onClick={() => setMode("login")}>
Log in
</ModeTab>
<ModeTab
active={mode === "signup"}
onClick={() => setMode("signup")}
>
Sign up
</ModeTab>
</div>
<div className="px-5 py-5">
{mode === "login" ? (
<LoginForm
/* onSuccess is a no-op here — AuthGate watches `user` directly
and closes the modal + resolves the pending promise itself. */
onSuccess={() => {}}
forgotPasswordHref="/forgot-password"
/>
) : (
<SignUpForm onSuccess={() => {}} />
)}
</div>
<footer className="px-5 pb-5 -mt-1 text-xs text-muted-foreground">
{mode === "login" ? (
<>
Need to reset your password?{" "}
<Link
href="/forgot-password"
className="text-primary hover:underline"
onClick={onDismiss}
>
Click here
</Link>
.
</>
) : (
<>
By signing up you agree to our acceptable-use policy. Posts on
your feed are public.
</>
)}
</footer>
</div>
</div>
</>,
document.body,
);
}
function ModeTab({
active,
onClick,
children,
}: {
active: boolean;
onClick: () => void;
children: React.ReactNode;
}) {
return (
<button
type="button"
onClick={onClick}
className={cn(
"h-10 rounded-md text-sm font-medium transition-colors",
active
? "bg-primary text-primary-foreground"
: "text-muted-foreground hover:text-foreground hover:bg-muted",
)}
>
{children}
</button>
);
}
+93
View File
@@ -0,0 +1,93 @@
"use client";
/**
* LoginForm — pure form widget reused by both the dedicated /login page and
* the AuthGate modal that pops up when an unauthenticated user tries to use
* a protected feature.
*
* `onSuccess` is invoked once login completes. Callers decide what happens
* next (a page redirect, a modal close, etc.). The form itself doesn't
* navigate — that keeps it environment-agnostic.
*
* `forgotPasswordHref` is configurable so the modal can override (it may
* want to dismiss itself or open a different mode).
*/
import { useState } from "react";
import Link from "next/link";
import { useAuth } from "@/components/auth/auth-provider";
import { AuthError } from "@/lib/auth";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
export function LoginForm({
onSuccess,
forgotPasswordHref = "/forgot-password",
showForgotLink = true,
}: {
onSuccess?: () => void;
forgotPasswordHref?: string;
showForgotLink?: boolean;
}) {
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);
onSuccess?.();
} catch (err) {
setError(err instanceof AuthError ? err.message : "Login failed.");
} finally {
setSubmitting(false);
}
}
return (
<form onSubmit={onSubmit} className="space-y-5">
<div className="space-y-1.5">
<Label htmlFor="login-email">Email</Label>
<Input
id="login-email"
type="email"
autoComplete="email"
required
value={email}
onChange={(e) => setEmail(e.target.value)}
/>
</div>
<div className="space-y-1.5">
<div className="flex items-center justify-between">
<Label htmlFor="login-password">Password</Label>
{showForgotLink ? (
<Link
href={forgotPasswordHref}
className="text-xs text-muted-foreground hover:text-foreground"
>
Forgot?
</Link>
) : null}
</div>
<Input
id="login-password"
type="password"
autoComplete="current-password"
required
value={password}
onChange={(e) => setPassword(e.target.value)}
/>
</div>
{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>
);
}
+81
View File
@@ -0,0 +1,81 @@
"use client";
/**
* SignUpForm — paired with LoginForm. Same callback contract:
* `onSuccess` fires once the new account is logged in; callers decide
* what to do next.
*/
import { useState } from "react";
import { useAuth } from "@/components/auth/auth-provider";
import { AuthError } from "@/lib/auth";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
export function SignUpForm({ onSuccess }: { onSuccess?: () => void }) {
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);
onSuccess?.();
} catch (err) {
setError(err instanceof AuthError ? err.message : "Sign-up failed.");
} finally {
setSubmitting(false);
}
}
return (
<form onSubmit={onSubmit} className="space-y-5">
<div className="space-y-1.5">
<Label htmlFor="signup-name">Name</Label>
<Input
id="signup-name"
type="text"
autoComplete="name"
required
value={name}
onChange={(e) => setName(e.target.value)}
/>
</div>
<div className="space-y-1.5">
<Label htmlFor="signup-email">Email</Label>
<Input
id="signup-email"
type="email"
autoComplete="email"
required
value={email}
onChange={(e) => setEmail(e.target.value)}
/>
</div>
<div className="space-y-1.5">
<Label htmlFor="signup-password">Password</Label>
<Input
id="signup-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>
{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>
);
}
+81 -39
View File
@@ -5,13 +5,17 @@
*
* 1. Loading (auth context hasn't hydrated) — render a quiet placeholder
* so the row width doesn't jump when the user data arrives.
* 2. Logged out — single "Log in" link.
* 2. Logged out — single "Log in" icon/button.
* 3. Logged in — avatar / initials chip that opens a popover with
* "Profile" and "Log out". Built on @base-ui/react/popover so we
* don't pull in additional shadcn primitives.
* "Profile" and "Log out".
*
* Why hand-rolled instead of `@base-ui/react/popover`: outside-click
* dismissal was unreliable for the avatar trigger here. The pattern below
* mirrors `ReadingSettings`: mousedown listener for outside clicks, keyup
* for Escape. That's already proven across the site and stays simple.
*/
import { Popover } from "@base-ui/react/popover";
import { useEffect, useRef, useState } from "react";
import Link from "next/link";
import Image from "next/image";
import { useRouter } from "next/navigation";
@@ -33,6 +37,33 @@ function initials(name: string): string {
export function UserMenu() {
const { user, loading, logOut } = useAuth();
const router = useRouter();
const [open, setOpen] = useState(false);
const panelRef = useRef<HTMLDivElement>(null);
const triggerRef = useRef<HTMLButtonElement>(null);
// Close on outside click + Escape, mirroring ReadingSettings.
useEffect(() => {
if (!open) return;
function onMouseDown(e: MouseEvent) {
if (
panelRef.current &&
!panelRef.current.contains(e.target as Node) &&
triggerRef.current &&
!triggerRef.current.contains(e.target as Node)
) {
setOpen(false);
}
}
function onKey(e: KeyboardEvent) {
if (e.key === "Escape") setOpen(false);
}
document.addEventListener("mousedown", onMouseDown);
document.addEventListener("keydown", onKey);
return () => {
document.removeEventListener("mousedown", onMouseDown);
document.removeEventListener("keydown", onKey);
};
}, [open]);
if (loading) {
// Placeholder keeps the action row width stable until auth hydrates.
@@ -42,8 +73,6 @@ export function UserMenu() {
if (!user) {
// Mobile: compact icon button so the header row fits on 375px viewports.
// Desktop: full "Log in" text — there's plenty of space.
// Same muted→foreground pattern as the search / reading-settings buttons
// so the icon sits at a consistent quiet shade across all themes.
return (
<Link
href="/login"
@@ -57,16 +86,22 @@ export function UserMenu() {
}
async function onLogOut() {
setOpen(false);
await logOut();
router.push("/");
router.refresh();
}
return (
<Popover.Root>
<Popover.Trigger
className="size-9 rounded-full overflow-hidden ring-1 ring-foreground/10 hover:ring-foreground/20 transition-shadow"
<div className="relative">
<button
ref={triggerRef}
type="button"
onClick={() => setOpen((v) => !v)}
aria-haspopup="menu"
aria-expanded={open}
aria-label="Account menu"
className="size-9 rounded-full overflow-hidden ring-1 ring-foreground/10 hover:ring-foreground/20 transition-shadow"
>
{user.avatar_url ? (
<Image
@@ -87,35 +122,42 @@ export function UserMenu() {
{initials(user.name)}
</span>
)}
</Popover.Trigger>
<Popover.Portal>
<Popover.Positioner sideOffset={6} align="end">
<Popover.Popup className="min-w-44 rounded-xl bg-card border border-border shadow-md p-1 outline-none">
<div className="px-3 py-2">
<p className="text-sm font-medium text-foreground truncate">
{user.name}
</p>
<p className="text-xs text-muted-foreground truncate">
{user.email}
</p>
</div>
<div className="h-px bg-border my-1" />
<Link
href="/profile"
className="block rounded-lg px-3 py-2 text-sm text-foreground hover:bg-muted transition-colors"
>
Profile
</Link>
<button
type="button"
onClick={onLogOut}
className="block w-full text-left rounded-lg px-3 py-2 text-sm text-foreground hover:bg-muted transition-colors"
>
Log out
</button>
</Popover.Popup>
</Popover.Positioner>
</Popover.Portal>
</Popover.Root>
</button>
{open ? (
<div
ref={panelRef}
role="menu"
className={cn(
"absolute right-0 top-full mt-2 min-w-48 rounded-xl bg-card",
"border border-border shadow-lg p-1 z-50",
)}
>
<div className="px-3 py-2">
<p className="text-sm font-medium text-foreground truncate">
{user.name}
</p>
<p className="text-xs text-muted-foreground truncate">
{user.email}
</p>
</div>
<div className="h-px bg-border my-1" />
<Link
href="/u/me"
onClick={() => setOpen(false)}
className="block rounded-lg px-3 py-2 text-sm text-foreground hover:bg-muted transition-colors"
>
Profile
</Link>
<button
type="button"
onClick={onLogOut}
className="block w-full text-left rounded-lg px-3 py-2 text-sm text-foreground hover:bg-muted transition-colors"
>
Log out
</button>
</div>
) : null}
</div>
);
}
+213
View File
@@ -0,0 +1,213 @@
/**
* KnowledgebaseIndex — the books / letters / lectures / essays / etc.
* directory layout. Rendered as the search page's empty state so an
* untyped search bar still gives users an editorial entry point into the
* library.
*
* Two sections, top to bottom:
* 1. Start here — three primary entry points (Books, Letters, Lectures)
* rendered as full-width feature tiles with longer copy.
* 2. Explore — a six-card asymmetric grid for the remaining sections.
*
* No hero block; the parent page (Search) carries its own hero so two
* heroes don't stack on top of each other.
*/
import Link from "next/link";
import {
ArrowUpRight,
BookA,
BookOpen,
FileStack,
Mail,
Mic,
Newspaper,
NotebookPen,
PenLine,
Users,
} from "lucide-react";
type Feature = {
href: string;
label: string;
blurb: string;
count?: string;
icon: React.ComponentType<{ className?: string }>;
};
const FEATURED: Feature[] = [
{
href: "/books",
label: "Books",
blurb:
"Bhagavad-gītā, Śrīmad-Bhāgavatam, Caitanya-caritāmṛta and 34 more — each verse with Devanāgarī, transliteration, synonyms, translation and purport.",
count: "37 books",
icon: BookOpen,
},
{
href: "/letters",
label: "Letters",
blurb:
"Three decades of personal correspondence to disciples, family, and officials. Browse by year, recipient, or location.",
count: "6,866 letters",
icon: Mail,
},
{
href: "/transcriptions",
label: "Lectures & Conversations",
blurb:
"Lectures, morning walks, room conversations, and dialogues — the living voice of the Founder-Ācārya preserved in text and audio.",
count: "3,787 transcripts",
icon: Mic,
},
];
const EXPLORE: Feature[] = [
{
href: "/essays",
label: "Essays",
blurb: "Foundational standalone essays.",
count: "23 essays",
icon: PenLine,
},
{
href: "/magazines",
label: "Back to Godhead",
blurb: "The original 1944 – 1973 magazine archive.",
count: "1,124 articles",
icon: Newspaper,
},
{
href: "/journals",
label: "Journals",
blurb: "The Jaladuta Diary and The Beginning.",
count: "300 entries",
icon: NotebookPen,
},
{
href: "/glossary",
label: "Glossary",
blurb: "Sanskrit and Vedic terms with definitions.",
count: "2,749 terms",
icon: BookA,
},
{
href: "/disciples",
label: "Disciples",
blurb: "Initiated disciples 1966 – 1977.",
count: "4,747 names",
icon: Users,
},
{
href: "/other-documents",
label: "Other Documents",
blurb: "Miscellaneous analyses and writings.",
count: "12 documents",
icon: FileStack,
},
];
export function KnowledgebaseIndex() {
return (
<div>
<section className="py-4">
<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>
<section className="py-6 sm:py-8">
<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>
</div>
);
}
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>
) : null}
</Link>
);
}
function ExploreTile({ feature }: { feature: Feature }) {
const Icon = feature.icon;
return (
<Link
href={feature.href}
className="group flex items-start gap-4 rounded-xl bg-card/70 ring-1 ring-foreground/8 hover:ring-foreground/20 hover:bg-card transition-all p-4 sm:p-5"
>
<div className="flex items-center justify-center size-9 rounded-lg bg-secondary text-secondary-foreground/80 shrink-0">
<Icon className="size-4" />
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center justify-between gap-2">
<h4 className="font-[family-name:var(--font-title)] font-semibold text-lg text-foreground leading-tight">
{feature.label}
</h4>
<ArrowUpRight className="size-3.5 text-muted-foreground/50 group-hover:text-foreground transition-colors shrink-0" />
</div>
<p className="text-sm text-muted-foreground mt-1 leading-relaxed">
{feature.blurb}
</p>
{feature.count ? (
<p className="text-xs uppercase tracking-[0.14em] font-medium text-muted-foreground/80 mt-2">
{feature.count}
</p>
) : null}
</div>
</Link>
);
}
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>
);
}
+6 -4
View File
@@ -34,9 +34,11 @@ export function PageHeader({
/** Drop the bordered chrome — useful for pages that want flush title text. */
bare?: boolean;
/**
* Stick to the top below the global SiteHeader. Used by reading pages so
* the chapter title stays visible as the user scrolls long content.
* The offset is `top-14 md:top-16` to clear SiteHeader's height.
* Stick to the top below the global SiteHeader. Used by reading pages
* so the chapter title stays visible as the user scrolls long content.
* The offset is `top-12 sm:top-14` — SiteHeader is `h-12 sm:h-14`, so
* these values land the PageHeader flush with its bottom edge with no
* visible gap.
*/
sticky?: boolean;
className?: string;
@@ -66,7 +68,7 @@ export function PageHeader({
className={cn(
"px-4 sm:px-6",
bare ? "py-6" : "border-b border-border bg-card/85 backdrop-blur py-5",
sticky && "sticky top-14 md:top-16 z-20",
sticky && "sticky top-12 sm:top-14 z-20",
className,
)}
>
+225
View File
@@ -0,0 +1,225 @@
"use client";
/**
* ProfileEditForm — the editor block used in /u/me's edit mode.
*
* Three editable fields:
* • Display name (PATCH /users/me { name })
* • Handle (PATCH /users/me { handle }) — 409 if taken
* • Avatar (R2 presign + PATCH /users/me { avatar_url })
*
* `onDone` is invoked after a successful save OR when the user clicks
* "Cancel" — the parent flips back to view mode.
*
* No log-out button here — that's handled by the sidebar footer so it lives
* outside the edit context.
*/
import { useRef, useState } from "react";
import Image from "next/image";
import { ImagePlus, Save, X } from "lucide-react";
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";
export function ProfileEditForm({
user,
onDone,
}: {
user: User;
onDone: () => void;
}) {
const { setUser } = useAuth();
const fileInput = useRef<HTMLInputElement>(null);
const [name, setName] = useState(user.name);
const [handle, setHandle] = useState(user.handle);
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 dirty =
name.trim() !== user.name ||
handle.trim() !== user.handle ||
avatarUrl !== user.avatar_url;
async function onSubmit(e: React.SyntheticEvent) {
e.preventDefault();
setError(null);
setSaving(true);
try {
// Patch only what's changed — keeps the audit log tidy and avoids
// bumping updated_at when the user just clicked "Save" without edits.
const updated = await auth.updateProfile(
name.trim() !== user.name ? name.trim() : undefined,
avatarUrl === user.avatar_url ? undefined : (avatarUrl ?? ""),
handle.trim() !== user.handle ? handle.trim() : undefined,
);
setUser(updated);
onDone();
} 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 (fileInput.current) fileInput.current.value = "";
if (!file) return;
setError(null);
setUploading(true);
try {
const publicURL = await auth.uploadAvatar(file);
setAvatarUrl(publicURL);
} catch (err) {
setError(err instanceof AuthError ? err.message : "Upload failed.");
} finally {
setUploading(false);
}
}
return (
<form onSubmit={onSubmit} className="space-y-6 rounded-2xl bg-card ring-1 ring-foreground/10 p-5 sm:p-6">
<div className="flex items-center justify-between">
<h2 className="font-[family-name:var(--font-title)] text-xl sm:text-2xl font-semibold text-foreground">
Edit profile
</h2>
<button
type="button"
onClick={onDone}
aria-label="Cancel"
className="size-9 rounded-lg text-muted-foreground hover:text-foreground hover:bg-muted inline-flex items-center justify-center"
>
<X className="size-4" />
</button>
</div>
{/* Avatar — same widget pattern as the old /profile form */}
<section 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 || saving}
className="h-10 px-4 text-sm gap-1.5"
>
<ImagePlus className="size-4" />
{uploading ? "Uploading…" : avatarUrl ? "Change" : "Upload"}
</Button>
{avatarUrl ? (
<Button
variant="ghost"
type="button"
onClick={() => setAvatarUrl(null)}
disabled={uploading || saving}
className="h-10 px-4 text-sm"
>
Remove
</Button>
) : null}
</div>
</section>
<div className="space-y-4">
<div className="space-y-1.5">
<Label htmlFor="profile-name">Display name</Label>
<Input
id="profile-name"
value={name}
onChange={(e) => setName(e.target.value)}
maxLength={120}
required
/>
</div>
<div className="space-y-1.5">
<Label htmlFor="profile-handle">Handle</Label>
<div className="flex items-center gap-2">
<span className="text-sm text-muted-foreground">@</span>
<Input
id="profile-handle"
value={handle}
onChange={(e) => setHandle(e.target.value)}
maxLength={32}
pattern="[a-zA-Z0-9._-]+"
required
/>
</div>
<p className="text-xs text-muted-foreground">
Letters, digits, dashes. Your public profile lives at /u/{handle || user.handle}.
</p>
</div>
<div className="space-y-1.5">
<Label htmlFor="profile-email">Email</Label>
<Input id="profile-email" value={user.email} disabled />
<p className="text-xs text-muted-foreground">
Email changes aren&apos;t supported yet.
</p>
</div>
</div>
{error ? <p className="text-sm text-destructive">{error}</p> : null}
<div className="flex items-center justify-end gap-2 pt-2 border-t border-border">
<Button
type="button"
variant="ghost"
onClick={onDone}
className="h-10 px-4 text-sm"
>
Cancel
</Button>
<Button
type="submit"
disabled={saving || uploading || !dirty}
className="h-10 px-4 text-sm gap-1.5"
>
<Save className="size-4" />
{saving ? "Saving…" : "Save changes"}
</Button>
</div>
</form>
);
}
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>
);
}
+88
View File
@@ -0,0 +1,88 @@
"use client";
/**
* ProfileHeader — Instagram-style centered header used at the top of
* /u/me and /u/[handle]. Avatar, display name, @handle, a single stat
* line, and an optional action slot (Edit profile button on owner view).
*
* Replaces the older ProfileViewHeader (left-aligned avatar + sidebar).
*/
import Image from "next/image";
import Link from "next/link";
export function ProfileHeader({
avatarURL,
name,
handle,
postCount,
joinedAt,
editHref,
}: {
avatarURL: string | null;
name: string;
handle: string;
postCount?: number;
joinedAt?: string;
/** When provided, shows an "Edit profile" CTA below the handle. */
editHref?: string;
}) {
return (
<header className="flex flex-col items-center text-center gap-4 pt-4 pb-6 sm:pt-6 sm:pb-8">
<Avatar src={avatarURL} name={name} />
<div className="space-y-1">
<h1 className="font-[family-name:var(--font-title)] font-semibold text-xl sm:text-2xl text-foreground leading-tight">
{name}
</h1>
<p className="text-sm text-muted-foreground">@{handle}</p>
{typeof postCount === "number" || joinedAt ? (
<p className="text-xs text-muted-foreground">
{typeof postCount === "number" ? (
<>
<span className="tabular-nums">{postCount}</span> post
{postCount === 1 ? "" : "s"}
</>
) : null}
{typeof postCount === "number" && joinedAt ? " · " : null}
{joinedAt ? <>Joined {new Date(joinedAt).toLocaleDateString()}</> : null}
</p>
) : null}
</div>
{editHref ? (
<Link
href={editHref}
className="inline-flex items-center justify-center h-9 px-5 rounded-lg border border-border text-sm font-medium text-foreground hover:bg-muted transition-colors"
>
Edit profile
</Link>
) : null}
</header>
);
}
function Avatar({ src, name }: { src: string | null; name: string }) {
if (src) {
return (
<Image
src={src}
alt={name}
width={112}
height={112}
className="size-24 sm:size-28 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-24 sm:size-28 rounded-full bg-primary/15 text-primary flex items-center justify-center text-3xl font-semibold ring-1 ring-foreground/10">
{initials}
</div>
);
}
+189
View File
@@ -0,0 +1,189 @@
"use client";
/**
* ProfileSidebar — the navigation rail for /u/me.
*
* • Desktop (md+): permanent left column, ~240px wide, sticky below the
* SiteHeader so it stays in view while content scrolls.
* • Mobile: a hamburger button reveals the same nav as a left drawer.
*
* Items are intentionally just three (Profile / Collections / Bookmarks) to
* keep the surface focused; "Log out" sits as a footer action separated by
* a divider.
*/
import { useState } from "react";
import Link from "next/link";
import Image from "next/image";
import { usePathname, useRouter } from "next/navigation";
import { User as UserIcon, FolderOpen, Bookmark, LogOut, Menu } from "lucide-react";
import {
Drawer,
DrawerContent,
DrawerDescription,
DrawerHeader,
DrawerTitle,
DrawerTrigger,
} from "@/components/ui/drawer";
import { useAuth } from "@/components/auth/auth-provider";
import type { User } from "@/lib/auth";
import { cn } from "@/lib/utils";
type NavItem = {
href: string;
label: string;
icon: typeof UserIcon;
/** Match only when pathname === href; otherwise startsWith is used. */
exact?: boolean;
};
const ITEMS: NavItem[] = [
{ href: "/u/me", label: "Profile", icon: UserIcon, exact: true },
{ href: "/u/me/collections", label: "Collections", icon: FolderOpen },
{ href: "/u/me/bookmarks", label: "Bookmarks", icon: Bookmark },
];
export function ProfileSidebar({ user }: { user: User }) {
return (
<>
{/* Desktop rail — always visible on md+ */}
<aside className="hidden md:block w-60 shrink-0">
<div className="sticky top-20 space-y-4">
<SidebarHeader user={user} />
<SidebarNav onItemClick={() => {}} />
<SidebarFooter />
</div>
</aside>
{/* Mobile trigger + drawer */}
<MobileSidebar user={user} />
</>
);
}
function MobileSidebar({ user }: { user: User }) {
const [open, setOpen] = useState(false);
return (
<div className="md:hidden mb-4">
<Drawer open={open} onOpenChange={setOpen} direction="left">
<DrawerTrigger
className={cn(
"inline-flex items-center gap-2 rounded-lg border border-border bg-card",
"px-3 h-10 text-sm font-medium text-foreground hover:bg-muted transition-colors",
)}
>
<Menu className="size-4" />
Library menu
</DrawerTrigger>
<DrawerContent direction="left" className="h-full w-72 max-w-[80%]">
<DrawerHeader className="text-left border-b border-border pb-4">
<DrawerTitle className="font-[family-name:var(--font-title)] font-semibold uppercase tracking-[0.14em] text-sm">
Your library
</DrawerTitle>
<DrawerDescription className="sr-only">
Switch between profile, collections, and bookmarks.
</DrawerDescription>
</DrawerHeader>
<div className="overflow-y-auto flex-1 p-4 space-y-4">
<SidebarHeader user={user} />
<SidebarNav onItemClick={() => setOpen(false)} />
<SidebarFooter />
</div>
</DrawerContent>
</Drawer>
</div>
);
}
function SidebarHeader({ user }: { user: User }) {
return (
<div className="flex items-center gap-3 px-3 py-3 rounded-lg bg-card ring-1 ring-foreground/10">
<Avatar src={user.avatar_url} name={user.name} />
<div className="min-w-0">
<p className="text-sm font-semibold text-foreground truncate">
{user.name}
</p>
<p className="text-xs text-muted-foreground truncate">@{user.handle}</p>
</div>
</div>
);
}
function SidebarNav({ onItemClick }: { onItemClick: () => void }) {
const pathname = usePathname();
return (
<nav className="space-y-0.5" aria-label="Library sections">
{ITEMS.map((item) => {
const active = item.exact
? pathname === item.href
: pathname.startsWith(item.href);
const Icon = item.icon;
return (
<Link
key={item.href}
href={item.href}
onClick={onItemClick}
className={cn(
"flex items-center gap-3 px-3 py-2.5 rounded-lg text-sm font-medium transition-colors",
active
? "bg-primary/10 text-primary"
: "text-muted-foreground hover:bg-muted hover:text-foreground",
)}
>
<Icon className="size-4" />
{item.label}
</Link>
);
})}
</nav>
);
}
function SidebarFooter() {
const { logOut } = useAuth();
const router = useRouter();
async function onLogOut() {
await logOut();
router.push("/");
router.refresh();
}
return (
<div className="pt-3 border-t border-border">
<button
type="button"
onClick={onLogOut}
className="flex items-center gap-3 w-full px-3 py-2.5 rounded-lg text-sm text-muted-foreground hover:bg-muted hover:text-foreground transition-colors"
>
<LogOut className="size-4" />
Log out
</button>
</div>
);
}
function Avatar({ src, name }: { src: string | null; name: string }) {
if (src) {
return (
<Image
src={src}
alt={name}
width={40}
height={40}
className="size-10 rounded-full object-cover ring-1 ring-foreground/10 shrink-0"
unoptimized
/>
);
}
const initials =
name
.split(/\s+/)
.map((p) => p[0])
.slice(0, 2)
.join("")
.toUpperCase() || "?";
return (
<div className="size-10 rounded-full bg-primary/15 text-primary flex items-center justify-center text-sm font-semibold ring-1 ring-foreground/10 shrink-0">
{initials}
</div>
);
}
+71
View File
@@ -0,0 +1,71 @@
"use client";
/**
* ProfileTabs — Instagram-style icon tab strip used under ProfileHeader.
*
* Renders a horizontal row of equal-width icon tabs. The active tab gets
* a top-border indicator and stronger color. Labels are screen-reader
* only by default (Instagram pattern), but a `showLabels` prop opts them
* back in for surfaces with more real estate.
*/
import Link from "next/link";
import { usePathname } from "next/navigation";
import { cn } from "@/lib/utils";
export type ProfileTab = {
href: string;
label: string;
icon: React.ReactNode;
/** Additional pathnames that should mark this tab active. Used to keep
* the Posts tab lit while the user is on the scroll feed at
* /u/<handle>/posts (a sibling route to /u/<handle>). */
activeFor?: string[];
};
export function ProfileTabs({
tabs,
showLabels = false,
}: {
tabs: ProfileTab[];
showLabels?: boolean;
}) {
const pathname = usePathname();
return (
<nav aria-label="Profile sections" className="border-b border-border">
<ul className="flex items-stretch max-w-md mx-auto">
{tabs.map((tab) => {
const active =
pathname === tab.href ||
(tab.activeFor?.includes(pathname) ?? false);
return (
<li key={tab.href} className="flex-1">
<Link
href={tab.href}
aria-current={active ? "page" : undefined}
title={tab.label}
className={cn(
"h-12 -mb-px flex items-center justify-center gap-2 border-b-2",
active
? "border-foreground text-foreground"
: "border-transparent text-muted-foreground hover:text-foreground",
)}
>
<span>{tab.icon}</span>
<span
className={cn(
"text-[0.65rem] uppercase tracking-[0.2em] font-semibold",
showLabels ? "" : "sr-only",
)}
>
{tab.label}
</span>
</Link>
</li>
);
})}
</ul>
</nav>
);
}
@@ -0,0 +1,71 @@
"use client";
/**
* ProfileViewHeader — the read-only header used in view mode on /u/me.
* Mirrors the shape of /u/[handle]'s top block so the owner sees the same
* thing visitors do, plus an action slot for an "Edit profile" button.
*/
import Image from "next/image";
import type { User } from "@/lib/auth";
export function ProfileViewHeader({
user,
postCount,
actionSlot,
}: {
user: User;
postCount: number;
actionSlot?: React.ReactNode;
}) {
return (
<header className="flex flex-col sm:flex-row sm:items-center gap-5 sm:gap-6">
<Avatar src={user.avatar_url} name={user.name} />
<div className="flex-1 min-w-0">
<div className="flex items-start justify-between gap-3">
<div className="min-w-0">
<h1 className="font-[family-name:var(--font-title)] font-semibold text-2xl sm:text-3xl text-foreground leading-tight truncate">
{user.name}
</h1>
<p className="text-sm text-muted-foreground mt-0.5">
@{user.handle} ·{" "}
<span className="tabular-nums">{postCount}</span> post
{postCount === 1 ? "" : "s"}
</p>
<p className="text-xs text-muted-foreground mt-1">
Joined {new Date(user.created_at).toLocaleDateString()}
</p>
</div>
{actionSlot ? <div className="shrink-0">{actionSlot}</div> : null}
</div>
</div>
</header>
);
}
function Avatar({ src, name }: { src: string | null; name: string }) {
if (src) {
return (
<Image
src={src}
alt={name}
width={88}
height={88}
className="size-20 sm:size-22 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 sm:size-22 rounded-full bg-primary/15 text-primary flex items-center justify-center text-2xl font-semibold ring-1 ring-foreground/10">
{initials}
</div>
);
}
+134 -16
View File
@@ -1,6 +1,7 @@
"use client";
import { useState, useRef, useEffect, useCallback } from "react";
import { createPortal } from "react-dom";
import { useTheme } from "next-themes";
import { Settings, Sun, Moon, Monitor } from "lucide-react";
import { Button } from "@/components/ui/button";
@@ -204,13 +205,88 @@ function Toggle({
);
}
export function ReadingSettings() {
/** Optional render-prop trigger — lets sidebar/bottom-nav variants compose
* their own row layout while reusing ReadingSettings' state + popover. */
type TriggerRenderer = (state: {
open: boolean;
toggle: () => void;
buttonRef: React.RefObject<HTMLButtonElement | null>;
}) => React.ReactNode;
/** Where the panel renders relative to its trigger.
*
* - "below-right" (default) — header-style: panel hangs below the
* trigger, right-aligned. Works when the trigger sits near the top.
* - "above-left" — sidebar-style: panel rises above the trigger and
* extends to the right so a bottom-of-sidebar button doesn't push
* the popover off the bottom of the viewport. */
type Placement = "below-right" | "above-left";
export function ReadingSettings({
renderTrigger,
placement = "below-right",
onOpenChange,
}: {
renderTrigger?: TriggerRenderer;
placement?: Placement;
/** Fires every time the popover opens or closes. Used by the sidebar
* to keep the rail expanded for as long as the popover is visible. */
onOpenChange?: (open: boolean) => void;
} = {}) {
const [open, setOpen] = useState(false);
const [prefs, setPrefs] = useState<ReadingPrefs>(() => loadPrefs());
const panelRef = useRef<HTMLDivElement>(null);
const buttonRef = useRef<HTMLButtonElement>(null);
const { theme, setTheme, resolvedTheme } = useTheme();
const mounted = useMounted();
// When the panel sits next to a sidebar, we render it through a portal
// with viewport-fixed coordinates so it (a) escapes the sidebar's
// bounds, (b) doesn't shift when the sidebar collapses on mouseleave,
// and (c) stays a comfortable gap to the right of the rail.
const [portalPos, setPortalPos] = useState<{
left: number;
bottom: number;
} | null>(null);
useEffect(() => {
if (!open || placement !== "above-left") {
setPortalPos(null);
return;
}
function recompute() {
const btn = buttonRef.current;
if (!btn) return;
const rect = btn.getBoundingClientRect();
// Anchor by `bottom` (distance from viewport bottom) so the panel
// grows upward from the trigger. `left` clears the rail by 16px.
const left = rect.right + 16;
const bottom = Math.max(8, window.innerHeight - rect.bottom);
setPortalPos({ left, bottom });
}
recompute();
// The sidebar width transitions when the rail expands — observe the
// trigger so the popover slides along with it instead of staying at
// the collapsed-rail x-coordinate.
const btn = buttonRef.current;
let observer: ResizeObserver | null = null;
if (btn && typeof ResizeObserver !== "undefined") {
observer = new ResizeObserver(() => recompute());
observer.observe(btn);
}
window.addEventListener("resize", recompute);
return () => {
observer?.disconnect();
window.removeEventListener("resize", recompute);
};
}, [open, placement]);
// Notify caller whenever the popover open state changes. We can't fire
// this synchronously inside setOpen because some consumers (e.g. the
// sidebar) feed it back into their own state — using useEffect breaks
// the render cycle cleanly.
useEffect(() => {
onOpenChange?.(open);
}, [open, onOpenChange]);
// Apply classes on mount (prefs already loaded via lazy initializer)
useEffect(() => {
@@ -275,27 +351,51 @@ export function ReadingSettings() {
);
}
const toggle = () => setOpen((v) => !v);
return (
<div className="relative">
<button
ref={buttonRef as React.Ref<HTMLButtonElement>}
aria-label="Reading settings"
aria-expanded={open}
onClick={() => setOpen((v) => !v)}
className={cn(
"size-9 inline-flex items-center justify-center rounded-lg",
"text-muted-foreground hover:text-foreground hover:bg-muted",
"transition-colors outline-none",
"focus-visible:ring-2 focus-visible:ring-ring",
)}
>
<Settings className="size-5 md:size-4" />
</button>
{renderTrigger ? (
renderTrigger({ open, toggle, buttonRef })
) : (
<button
ref={buttonRef as React.Ref<HTMLButtonElement>}
aria-label="Reading settings"
aria-expanded={open}
onClick={toggle}
className={cn(
"size-9 inline-flex items-center justify-center rounded-lg",
"text-muted-foreground hover:text-foreground hover:bg-muted",
"transition-colors outline-none",
"focus-visible:ring-2 focus-visible:ring-ring",
)}
>
<Settings className="size-5 md:size-4" />
</button>
)}
{open && (
<MaybePortal portal={placement === "above-left" && portalPos !== null}>
<div
ref={panelRef}
className="absolute right-0 top-full mt-2 w-72 rounded-lg border border-border bg-card shadow-lg z-50 p-4"
style={
placement === "above-left" && portalPos
? {
position: "fixed",
left: portalPos.left,
bottom: portalPos.bottom,
}
: undefined
}
className={cn(
"w-72 rounded-lg border border-border bg-card shadow-lg p-4",
"max-h-[80vh] overflow-y-auto",
placement === "above-left" && portalPos
? "z-[100]"
: placement === "above-left"
? "absolute bottom-full left-0 mb-2 z-50"
: "absolute top-full right-0 mt-2 z-50",
)}
>
{/* Theme selector */}
<div className="mb-4">
@@ -424,7 +524,25 @@ export function ReadingSettings() {
</div>
</div>
</div>
</MaybePortal>
)}
</div>
);
}
/** Conditionally render `children` through a React portal to document.body.
* When `portal` is false (or SSR), children render in place. We use this
* to escape stacking + overflow contexts (e.g. inside the sidebar) without
* duplicating the panel JSX. */
function MaybePortal({
portal,
children,
}: {
portal: boolean;
children: React.ReactNode;
}) {
if (portal && typeof document !== "undefined") {
return createPortal(children, document.body);
}
return <>{children}</>;
}
@@ -1,24 +1,196 @@
"use client";
import { PSEUDO_COLLECTIONS } from "./shared";
import { FolderPlus } from "lucide-react";
/**
* CollectionsDrawer — "Save to Collection" picker, reused in two flows:
*
* • Selection toolbar — caller passes `selectedText`. On row click we
* POST a new public post first, then add it to the chosen collection.
*
* • Post card footer — caller passes `existingPostId` (the post is
* already saved). We skip the create step and only call addItem.
*
* Exactly one of `selectedText` or `existingPostId` is meaningful per
* mount. Per the product spec: collections always hold posts, every
* post is public on the user's feed, and bookmarks are the private save.
*/
import { useEffect, useState } from "react";
import { FolderPlus, Check, Plus, Loader2 } from "lucide-react";
import {
Drawer,
DrawerClose,
DrawerContent,
DrawerHeader,
DrawerTitle,
} from "@/components/ui/drawer";
import {
collections,
posts as postsApi,
SocialError,
type Collection,
} from "@/lib/social";
import { buildHighlightUrl } from "@/lib/text-highlight";
import { getSourceReference } from "@/lib/source-reference";
type Status = "idle" | "saving" | "saved" | "auth" | "limit" | "error";
export function CollectionsDrawer({
open,
onClose,
isMobile,
selectedText,
existingPostId,
}: {
open: boolean;
onClose: () => void;
isMobile: boolean;
/** Provided by the selection toolbar — the drawer will create a fresh
* post from this text before adding it to a collection. */
selectedText?: string;
/** Provided by post-card footers — the drawer skips post creation and
* attaches the existing post id to the chosen collection. */
existingPostId?: string;
}) {
const [list, setList] = useState<Collection[]>([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
// For the existing-post mode: which collections already contain the
// post, so we can disable those rows. Empty for the toolbar mode
// (a brand-new post can't belong to anything yet).
const [alreadyIn, setAlreadyIn] = useState<Set<string>>(new Set());
// Per-row save status — keyed by collection id so each row can show its
// own "saving / saved" state without blocking the others.
const [rowStatus, setRowStatus] = useState<Record<string, Status>>({});
const [rowMsg, setRowMsg] = useState<Record<string, string>>({});
// Inline "new collection" form state.
const [showCreate, setShowCreate] = useState(false);
const [newTitle, setNewTitle] = useState("");
const [newIsPublic, setNewIsPublic] = useState(true);
const [creating, setCreating] = useState(false);
useEffect(() => {
if (!open) return;
setLoading(true);
setError(null);
// Fetch the user's collections in parallel with the post→collection
// membership lookup so the rows render with the disabled state set
// on first paint (no flicker from "Save" → "Saved").
const tasks: Promise<unknown>[] = [
collections
.mine()
.then((rows) => setList(rows ?? []))
.catch((err) => {
if (err instanceof SocialError && err.status === 401) {
setError("Log in to use collections.");
} else {
setError(
err instanceof SocialError ? err.message : "Could not load collections.",
);
}
}),
];
if (existingPostId) {
tasks.push(
collections
.containingPost(existingPostId)
.then((ids) => setAlreadyIn(new Set(ids)))
.catch(() => {
/* Non-fatal — drawer still works without the disabled hints. */
}),
);
} else {
setAlreadyIn(new Set());
}
Promise.all(tasks).finally(() => setLoading(false));
}, [open, existingPostId]);
// Reset transient UI state when drawer closes.
useEffect(() => {
if (!open) {
setRowStatus({});
setRowMsg({});
setShowCreate(false);
setNewTitle("");
setAlreadyIn(new Set());
}
}, [open]);
async function saveTo(collectionID: string) {
setRowStatus((s) => ({ ...s, [collectionID]: "saving" }));
setRowMsg((m) => ({ ...m, [collectionID]: "" }));
try {
// Two paths:
// - existingPostId provided → just attach.
// - selectedText provided → create a post first, then attach.
let postID = existingPostId ?? null;
if (!postID) {
const sourceURL = buildHighlightUrl(selectedText ?? "");
const ref = getSourceReference();
const sourceTitle = ref
? ref.detail
? `${ref.title} — ${ref.detail}`
: ref.title
: typeof document !== "undefined"
? document.title
: "";
const post = await postsApi.create({
caption: selectedText ?? "",
source_url: sourceURL,
source_title: sourceTitle,
});
postID = post.id;
}
await collections.addItem(collectionID, postID);
setRowStatus((s) => ({ ...s, [collectionID]: "saved" }));
// Bump the local item_count so the user sees the change without a refetch.
setList((rows) =>
rows.map((c) =>
c.id === collectionID ? { ...c, item_count: c.item_count + 1 } : c,
),
);
setTimeout(onClose, 800);
} catch (err) {
if (err instanceof SocialError) {
const tone: Status =
err.status === 401 ? "auth" : err.status === 402 ? "limit" : "error";
setRowStatus((s) => ({ ...s, [collectionID]: tone }));
setRowMsg((m) => ({ ...m, [collectionID]: err.message }));
} else {
setRowStatus((s) => ({ ...s, [collectionID]: "error" }));
setRowMsg((m) => ({ ...m, [collectionID]: "Save failed." }));
}
}
}
async function createCollection() {
const title = newTitle.trim();
if (!title) return;
setCreating(true);
setError(null);
try {
const c = await collections.create({ title, is_public: newIsPublic });
setList((rows) => [c, ...rows]);
setNewTitle("");
setShowCreate(false);
// Auto-save the quote into the new collection so the user gets to the
// payoff in one step.
void saveTo(c.id);
} catch (err) {
if (err instanceof SocialError && err.status === 402) {
setError(err.message);
} else if (err instanceof SocialError && err.status === 401) {
setError("Log in to create a collection.");
} else {
setError(
err instanceof SocialError ? err.message : "Could not create collection.",
);
}
} finally {
setCreating(false);
}
}
const direction = isMobile ? "bottom" : "right";
return (
@@ -32,47 +204,125 @@ export function CollectionsDrawer({
className="bg-black dark:bg-[#1a1a1a] border-white/10"
>
<DrawerHeader className="text-left border-b border-white/10 pb-4">
<DrawerTitle className="text-white">Save to Collection</DrawerTitle>
<DrawerTitle className="text-white">Save to collection</DrawerTitle>
{selectedText ? (
<p className="text-xs text-white/50 mt-1 line-clamp-2">
“{selectedText.slice(0, 140)}
{selectedText.length > 140 ? "…" : ""}”
</p>
) : null}
</DrawerHeader>
<div className="overflow-y-auto flex-1 p-4 space-y-1">
{PSEUDO_COLLECTIONS.map((col) => (
<button
key={col.id}
onClick={() => {
// TODO: Implement save to collection
onClose();
}}
className="flex items-center justify-between gap-3 w-full px-3 py-3 rounded-lg text-left hover:bg-white/10 transition-colors"
>
<div className="flex items-center gap-3">
<div className="flex items-center justify-center size-9 rounded-lg bg-white/10">
<FolderPlus className="size-4 text-white/50" />
{loading ? (
<div className="flex items-center justify-center py-10 text-white/60">
<Loader2 className="size-5 animate-spin" />
</div>
) : error ? (
<p className="text-sm text-amber-400 px-3 py-2">{error}</p>
) : null}
{!loading && !error && list.length === 0 ? (
<p className="text-sm text-white/50 px-3 py-2">
No collections yet — create one below.
</p>
) : null}
{list.map((c) => {
const st = rowStatus[c.id] ?? "idle";
// A row is "already saved" if the BFF told us this post is
// already in this collection. We treat that the same as
// st === "saved" (button locked, check mark shown), just
// with a different sub-line for the user.
const alreadySaved = alreadyIn.has(c.id);
const locked = alreadySaved || st === "saving" || st === "saved";
return (
<button
key={c.id}
onClick={() => saveTo(c.id)}
disabled={locked}
aria-disabled={locked}
className="flex items-center justify-between gap-3 w-full px-3 py-3 rounded-lg text-left hover:bg-white/10 transition-colors disabled:hover:bg-transparent disabled:cursor-not-allowed disabled:opacity-60"
>
<div className="flex items-center gap-3 min-w-0">
<div className="flex items-center justify-center size-9 rounded-lg bg-white/10 shrink-0">
<FolderPlus className="size-4 text-white/60" />
</div>
<div className="min-w-0">
<p className="text-sm font-medium text-white/90 truncate">
{c.title}
</p>
<p className="text-xs text-white/50 truncate">
{alreadySaved
? "Already in this collection"
: `${c.is_public ? "Public" : "Private"} · ${c.item_count} item${
c.item_count === 1 ? "" : "s"
}${rowMsg[c.id] ? ` · ${rowMsg[c.id]}` : ""}`}
</p>
</div>
</div>
<span className="text-sm font-medium text-white/90">
{col.name}
<span className="shrink-0 text-xs text-white/50">
{st === "saving" ? (
<Loader2 className="size-4 animate-spin" />
) : alreadySaved || st === "saved" ? (
<Check className="size-4 text-emerald-400" />
) : null}
</span>
</button>
);
})}
{/* Inline "new collection" panel */}
{!loading && !error && !showCreate ? (
<button
onClick={() => setShowCreate(true)}
className="flex items-center gap-3 w-full px-3 py-3 rounded-lg text-left hover:bg-white/10 transition-colors mt-2 border border-dashed border-white/20"
>
<div className="flex items-center justify-center size-9 rounded-lg bg-amber-400/15">
<Plus className="size-4 text-amber-400" />
</div>
<span className="text-xs text-white/50 tabular-nums">
{col.count}
<span className="text-sm font-medium text-amber-400">
New collection
</span>
</button>
))}
) : null}
{/* Create new collection */}
<button
onClick={() => {
// TODO: Implement create collection
onClose();
}}
className="flex items-center gap-3 w-full px-3 py-3 rounded-lg text-left hover:bg-white/10 transition-colors mt-2 border border-dashed border-white/20"
>
<div className="flex items-center justify-center size-9 rounded-lg bg-amber-400/15">
<FolderPlus className="size-4 text-amber-400" />
{showCreate ? (
<div className="mt-2 rounded-lg border border-white/10 p-3 space-y-3">
<input
value={newTitle}
onChange={(e) => setNewTitle(e.target.value)}
placeholder="Collection title"
maxLength={120}
className="w-full bg-white/5 text-white text-sm rounded-md px-3 py-2 placeholder:text-white/30 outline-none focus:bg-white/10"
/>
<div className="flex items-center justify-between text-xs text-white/60">
<label className="flex items-center gap-2 cursor-pointer">
<input
type="checkbox"
checked={newIsPublic}
onChange={(e) => setNewIsPublic(e.target.checked)}
/>
Public
</label>
<div className="flex items-center gap-2">
<button
onClick={() => setShowCreate(false)}
className="px-3 py-1.5 rounded-md hover:bg-white/10"
>
Cancel
</button>
<button
onClick={createCollection}
disabled={creating || !newTitle.trim()}
className="px-3 py-1.5 rounded-md bg-primary text-primary-foreground disabled:opacity-60"
>
{creating ? "Creating…" : "Create & save"}
</button>
</div>
</div>
</div>
<span className="text-sm font-medium text-amber-400">
New Collection
</span>
</button>
) : null}
</div>
</DrawerContent>
</Drawer>
@@ -0,0 +1,258 @@
"use client";
/**
* ComposePost — drawer (mobile) / modal (desktop) for "Share to Feed".
*
* The caption is locked to the exact selected text per spec:
* "Each post must have only the selected text as its caption with a link
* to the original text URL from where it was copied."
*
* Users can attach up to 5 images. Each image is uploaded to R2 via the
* BFF presign endpoint before submit, so submit only sends already-stored
* public URLs (no multipart upload through the BFF).
*/
import { useRef, useState } from "react";
import { createPortal } from "react-dom";
import { useRouter } from "next/navigation";
import Image from "next/image";
import { X, ImagePlus, Trash2, Send } from "lucide-react";
import {
Drawer,
DrawerContent,
DrawerHeader,
DrawerTitle,
} from "@/components/ui/drawer";
import { posts, SocialError } from "@/lib/social";
import { buildHighlightUrl } from "@/lib/text-highlight";
import { getSourceReference } from "@/lib/source-reference";
const MAX_IMAGES = 5;
const PRESS_IMAGE_TYPES = "image/png,image/jpeg,image/webp,image/gif";
export function ComposePost({
open,
onClose,
selectedText,
isMobile,
onPosted,
}: {
open: boolean;
onClose: () => void;
selectedText: string;
isMobile: boolean;
onPosted?: () => void;
}) {
const router = useRouter();
const fileInput = useRef<HTMLInputElement>(null);
const [images, setImages] = useState<{ url: string; uploading: boolean }[]>(
[],
);
const [submitting, setSubmitting] = useState(false);
const [error, setError] = useState<string | null>(null);
async function onFiles(e: React.ChangeEvent<HTMLInputElement>) {
const files = Array.from(e.target.files ?? []);
if (fileInput.current) fileInput.current.value = "";
if (files.length === 0) return;
const slotsLeft = MAX_IMAGES - images.length;
if (slotsLeft <= 0) {
setError(`A post can hold at most ${MAX_IMAGES} images.`);
return;
}
setError(null);
const picked = files.slice(0, slotsLeft);
// Optimistically show "uploading" tiles; replace with real URLs as each
// upload settles. We track each upload by its index so ordering stays stable.
const baseIndex = images.length;
setImages((prev) => [
...prev,
...picked.map(() => ({ url: "", uploading: true })),
]);
await Promise.all(
picked.map(async (file, i) => {
try {
const url = await posts.uploadImage(file);
setImages((prev) => {
const next = [...prev];
next[baseIndex + i] = { url, uploading: false };
return next;
});
} catch (err) {
setImages((prev) => prev.filter((_, idx) => idx !== baseIndex + i));
setError(
err instanceof SocialError ? err.message : "Image upload failed.",
);
}
}),
);
}
function removeImage(idx: number) {
setImages((prev) => prev.filter((_, i) => i !== idx));
}
async function submit() {
setError(null);
setSubmitting(true);
try {
const sourceURL = buildHighlightUrl(selectedText);
// Prefer the structured reference (e.g. "Bhagavad-gītā As It Is —
// Chapter 1, Verse 1") over the raw <title>, which often carries
// site branding ("— Prabhupāda Says") and isn't a clean citation.
const ref = getSourceReference();
const sourceTitle = ref
? ref.detail
? `${ref.title} — ${ref.detail}`
: ref.title
: typeof document !== "undefined"
? document.title
: "";
await posts.create({
caption: selectedText,
source_url: sourceURL,
source_title: sourceTitle,
image_urls: images
.filter((img) => !img.uploading && img.url)
.map((img) => img.url),
});
onPosted?.();
onClose();
router.refresh();
} catch (err) {
if (err instanceof SocialError && err.status === 401) {
setError("Please log in to post.");
} else {
setError(err instanceof SocialError ? err.message : "Post failed.");
}
} finally {
setSubmitting(false);
}
}
const body = (
<div className="space-y-4">
<div className="rounded-lg border border-border bg-muted/40 p-4">
<p className="text-[0.7rem] uppercase tracking-[0.18em] text-muted-foreground">
Caption (locked)
</p>
<p className="mt-2 font-[family-name:var(--font-title)] text-base leading-relaxed text-foreground">
“{selectedText}”
</p>
</div>
<div>
<div className="flex items-center justify-between">
<p className="text-xs uppercase tracking-[0.18em] text-muted-foreground">
Images ({images.length}/{MAX_IMAGES})
</p>
<button
type="button"
onClick={() => fileInput.current?.click()}
disabled={images.length >= MAX_IMAGES || submitting}
className="inline-flex items-center gap-1.5 text-sm text-primary hover:underline disabled:opacity-50"
>
<ImagePlus className="size-4" />
Add
</button>
<input
ref={fileInput}
type="file"
multiple
accept={PRESS_IMAGE_TYPES}
hidden
onChange={onFiles}
/>
</div>
{images.length > 0 ? (
<div className="mt-3 grid grid-cols-3 sm:grid-cols-4 gap-2">
{images.map((img, i) => (
<div
key={i}
className="relative aspect-square rounded-lg overflow-hidden bg-muted ring-1 ring-foreground/10"
>
{img.uploading ? (
<span className="absolute inset-0 flex items-center justify-center text-xs text-muted-foreground">
Uploading…
</span>
) : (
<>
<Image
src={img.url}
alt=""
fill
sizes="120px"
className="object-cover"
unoptimized
/>
<button
type="button"
onClick={() => removeImage(i)}
className="absolute top-1 right-1 size-6 rounded-full bg-black/70 text-white inline-flex items-center justify-center hover:bg-black/85"
aria-label="Remove image"
>
<Trash2 className="size-3.5" />
</button>
</>
)}
</div>
))}
</div>
) : null}
</div>
{error ? <p className="text-sm text-destructive">{error}</p> : null}
<button
type="button"
onClick={submit}
disabled={
submitting || images.some((i) => i.uploading) || !selectedText
}
className="w-full h-11 inline-flex items-center justify-center gap-2 rounded-lg bg-primary text-primary-foreground text-base font-medium disabled:opacity-60"
>
<Send className="size-4" />
{submitting ? "Posting…" : "Post to feed"}
</button>
</div>
);
if (isMobile) {
return (
<Drawer open={open} onOpenChange={(o) => !o && onClose()}>
<DrawerContent className="bg-card border-border">
<DrawerHeader className="text-left border-b border-border pb-4">
<DrawerTitle>Share to feed</DrawerTitle>
</DrawerHeader>
<div className="p-4 pb-8">{body}</div>
</DrawerContent>
</Drawer>
);
}
if (!open) return null;
return createPortal(
<>
<div
className="fixed inset-0 z-100 bg-foreground/40 backdrop-blur-sm"
onClick={onClose}
/>
<div className="fixed left-1/2 top-1/2 z-101 w-full max-w-md -translate-x-1/2 -translate-y-1/2 rounded-2xl border border-border bg-card shadow-xl">
<div className="flex items-center justify-between px-5 py-4 border-b border-border">
<h2 className="font-[family-name:var(--font-title)] font-semibold text-lg">
Share to feed
</h2>
<button
onClick={onClose}
className="size-8 rounded-lg text-muted-foreground hover:text-foreground hover:bg-muted inline-flex items-center justify-center"
aria-label="Close"
>
<X className="size-4" />
</button>
</div>
<div className="p-5">{body}</div>
</div>
</>,
document.body,
);
}
+63 -79
View File
@@ -1,9 +1,18 @@
"use client";
import { useEffect, useRef } from "react";
/**
* MorePopover — generic secondary-action menu for the selection toolbar.
*
* The parent (`TextSelectionToolbar`) owns all side effects and builds
* the appropriate `items` array based on auth state. Items can be marked
* `locked` to render a small Lock badge — used for the public flow
* where every More action sits behind the auth wall.
*/
import { type ReactNode, useEffect, useRef } from "react";
import { createPortal } from "react-dom";
import { useCopyFeedback, type Position } from "./shared";
import { Bookmark, FolderPlus, AtSign } from "lucide-react";
import { Lock } from "lucide-react";
import { type Position } from "./shared";
import {
Drawer,
DrawerClose,
@@ -12,35 +21,37 @@ import {
DrawerTitle,
} from "@/components/ui/drawer";
export type MoreItem = {
key: string;
label: string;
description: string;
icon: ReactNode;
onClick: () => void;
/** Show a lock badge — indicates the action is behind the auth wall. */
locked?: boolean;
};
export function MorePopover({
open,
position,
onClose,
onBookmark,
onSaveToCollection,
onMention,
items,
isMobile,
selectedText,
}: {
open: boolean;
position: Position;
onClose: () => void;
onBookmark: () => void;
onSaveToCollection: () => void;
onMention: () => void;
items: MoreItem[];
isMobile: boolean;
selectedText: string;
}) {
const ref = useRef<HTMLDivElement>(null);
const { copied, copy } = useCopyFeedback();
useEffect(() => {
if (!open || isMobile) return;
function handleClick(e: MouseEvent) {
if (ref.current && !ref.current.contains(e.target as Node)) {
onClose();
}
if (ref.current && !ref.current.contains(e.target as Node)) onClose();
}
// Delay so the click that opened us doesn't immediately fire it.
const timer = setTimeout(() => {
document.addEventListener("mousedown", handleClick);
}, 10);
@@ -52,30 +63,6 @@ export function MorePopover({
if (!open) return null;
const actions = [
{
key: "bookmark",
label: "Add Bookmark",
description: "Save your place",
icon: <Bookmark className="size-5" />,
onClick: onBookmark,
},
{
key: "collection",
label: "Save to Collection",
description: "Organize into a collection",
icon: <FolderPlus className="size-5" />,
onClick: onSaveToCollection,
},
{
key: "mention",
label: "Mention",
description: "Tag someone with this quote",
icon: <AtSign className="size-5" />,
onClick: onMention,
},
];
// Mobile: bottom drawer
if (isMobile) {
return (
@@ -85,27 +72,29 @@ export function MorePopover({
<DrawerTitle className="text-white">More Options</DrawerTitle>
</DrawerHeader>
<div className="p-4 space-y-1">
{actions.map((action) => (
{items.map((item) => (
<button
key={action.key}
key={item.key}
onClick={(e) => {
e.stopPropagation();
action.onClick();
if (action.key !== "copy-link") {
onClose();
}
item.onClick();
}}
className="flex items-center gap-3 w-full px-3 py-3 rounded-lg text-left hover:bg-white/10 transition-colors"
>
<div className="flex items-center justify-center size-10 rounded-lg bg-white/10 text-white/60">
{action.icon}
{item.icon}
</div>
<div>
<p className="text-sm font-medium text-white/90">
{action.label}
<div className="min-w-0 flex-1">
<p className="text-sm font-medium text-white/90 truncate">
{item.label}
</p>
<p className="text-xs text-white/50 truncate">
{item.description}
</p>
<p className="text-xs text-white/50">{action.description}</p>
</div>
{item.locked ? (
<Lock className="size-3.5 text-white/40 shrink-0" />
) : null}
</button>
))}
</div>
@@ -121,54 +110,49 @@ export function MorePopover({
);
}
// Desktop: floating popover
const popoverW = 220;
const popoverH = actions.length * 44 + 8;
// Desktop: floating popover anchored to `position`, flipping when it
// would overflow the viewport.
const popoverW = 260;
const popoverH = items.length * 56 + 8;
const pad = 8;
let popLeft = position.x;
let popTop = position.y + 8;
if (popLeft + popoverW > window.innerWidth - pad) {
popLeft = window.innerWidth - popoverW - pad;
}
if (popLeft < pad) {
popLeft = pad;
}
if (popLeft < pad) popLeft = pad;
if (popTop + popoverH > window.innerHeight - pad) {
popTop = position.y - popoverH - 8;
}
if (popTop < pad) {
popTop = pad;
}
const style: React.CSSProperties = {
position: "fixed",
zIndex: 90,
left: popLeft,
top: popTop,
};
if (popTop < pad) popTop = pad;
return createPortal(
<div
ref={ref}
style={style}
className="w-55 rounded-lg border border-white/10 bg-black shadow-2xl py-1.5 animate-in fade-in-0 zoom-in-95 dark:bg-[#1a1a1a] dark:border-white/15"
style={{ position: "fixed", zIndex: 90, left: popLeft, top: popTop }}
className="w-64 rounded-lg border border-white/10 bg-black shadow-2xl py-1.5 animate-in fade-in-0 zoom-in-95 dark:bg-[#1a1a1a] dark:border-white/15"
>
{actions.map((action) => (
{items.map((item) => (
<button
key={action.key}
key={item.key}
onClick={(e) => {
e.stopPropagation();
action.onClick();
if (action.key !== "copy-link") {
onClose();
}
item.onClick();
}}
className="flex items-center gap-3 w-full px-4 py-2.5 text-sm text-white/90 hover:bg-white/10 transition-colors text-left"
className="flex items-start gap-3 w-full px-3 py-2.5 text-left hover:bg-white/10 transition-colors"
>
<span className="text-white/50">{action.icon}</span>
{action.label}
<span className="text-white/60 mt-0.5">{item.icon}</span>
<span className="min-w-0 flex-1">
<span className="block text-sm text-white/90 truncate">
{item.label}
</span>
<span className="block text-xs text-white/50 truncate">
{item.description}
</span>
</span>
{item.locked ? (
<Lock className="size-3.5 text-white/40 shrink-0 mt-1" />
) : null}
</button>
))}
</div>,
+4 -1
View File
@@ -18,11 +18,14 @@ export function ShareModal({
onClose,
selectedText,
isMobile,
onShareToFeed,
}: {
open: boolean;
onClose: () => void;
selectedText: string;
isMobile: boolean;
/** Called when the user picks "Share to feed" — parent opens the compose modal. */
onShareToFeed?: () => void;
}) {
const { copied, copy } = useCopyFeedback();
const [generating, setGenerating] = useState(false);
@@ -45,8 +48,8 @@ export function ShareModal({
icon: <MessageSquare className="size-4" />,
description: "Post this quote to your feed",
onClick: () => {
// TODO: Implement share to feed
onClose();
onShareToFeed?.();
},
},
{
+105
View File
@@ -0,0 +1,105 @@
"use client";
/**
* SiteBottomNav — mobile-only bottom tab bar.
*
* Four items: Home (Feed) · Knowledgebase · Search · Account.
* Mirrors the Instagram pattern — fixed to the viewport bottom, full
* width, equal-width tabs, the active tab uses the primary color.
*
* Hidden on md+ — desktop uses SiteSidebar.
*/
import Link from "next/link";
import { usePathname } from "next/navigation";
import { Home, Search, User as UserIcon } from "lucide-react";
import { useAuth } from "@/components/auth/auth-provider";
import { cn } from "@/lib/utils";
type Item = {
href: string;
label: string;
icon: React.ReactNode;
matchPrefixes?: string[];
};
export function SiteBottomNav() {
const pathname = usePathname();
const { user } = useAuth();
const items: Item[] = [
{ href: "/", label: "Home", icon: <Home className="size-5" /> },
{
href: "/search",
label: "Search",
icon: <Search className="size-5" />,
// Search is the knowledgebase entry point too — keep the tab lit
// while the user is reading anywhere in the library.
matchPrefixes: [
"/search",
"/books",
"/letters",
"/transcriptions",
"/essays",
"/glossary",
"/journals",
"/magazines",
"/other-documents",
"/disciples",
],
},
{
href: user ? "/u/me" : "/login",
label: user ? "You" : "Sign in",
icon: <UserIcon className="size-5" />,
matchPrefixes: ["/u/me", "/login", "/signup"],
},
];
function isActive(item: Item): boolean {
if (item.matchPrefixes) {
return item.matchPrefixes.some((p) =>
p === "/" ? pathname === "/" : pathname.startsWith(p),
);
}
if (item.href === "/") return pathname === "/";
return pathname === item.href || pathname.startsWith(`${item.href}/`);
}
return (
<nav
aria-label="Primary"
className={cn(
"md:hidden fixed inset-x-0 bottom-0 z-40",
"bg-background/95 backdrop-blur border-t border-border",
"pb-[env(safe-area-inset-bottom)]",
)}
>
<ul className="flex items-stretch">
{items.map((item) => {
const active = isActive(item);
return (
<li key={item.href} className="flex-1">
<Link
href={item.href}
aria-label={item.label}
aria-current={active ? "page" : undefined}
className={cn(
"h-14 flex flex-col items-center justify-center gap-0.5",
active
? "text-primary"
: "text-muted-foreground hover:text-foreground",
)}
>
{item.icon}
<span className="text-[0.65rem] font-medium">
{item.label}
</span>
</Link>
</li>
);
})}
</ul>
</nav>
);
}
+16 -83
View File
@@ -1,99 +1,32 @@
/**
* SiteFooter — editorial three-column footer.
*
* Column 1: Wordmark + dedication
* Column 2: Explore (links to the main content sections)
* Column 3: Resources (about, search, profile, etc.)
*
* A thin copyright row sits at the bottom.
* SiteFooter — one slim row. Wordmark on the left, dedication on the
* right, About link in the middle. That's all.
*/
import Link from "next/link";
const EXPLORE = [
{ href: "/books", label: "Books" },
{ href: "/letters", label: "Letters" },
{ href: "/transcriptions", label: "Lectures" },
{ href: "/essays", label: "Essays" },
{ href: "/magazines", label: "Magazines" },
{ href: "/journals", label: "Journals" },
];
const RESOURCES = [
{ href: "/glossary", label: "Glossary" },
{ href: "/disciples", label: "Disciples" },
{ href: "/other-documents", label: "Other Documents" },
{ href: "/search", label: "Search" },
{ href: "/about", label: "About" },
];
export function SiteFooter() {
return (
<footer className="mt-auto border-t border-border bg-secondary text-secondary-foreground">
{/* Three-column main */}
<div className="max-w-5xl mx-auto px-4 sm:px-6 py-10 grid grid-cols-1 sm:grid-cols-3 gap-8">
{/* Brand */}
<div>
<div className="max-w-5xl mx-auto px-4 sm:px-6 py-4 flex flex-col sm:flex-row items-center justify-between gap-2 text-xs text-muted-foreground">
<Link
href="/"
aria-label="Prabhupāda Says — home"
className="font-[family-name:var(--font-title)] font-semibold uppercase tracking-[0.12em] text-[0.7rem] text-foreground/80 hover:text-foreground transition-colors"
>
Prabhupāda Says
</Link>
<div className="flex items-center gap-4">
<Link
href="/"
aria-label="Prabhupāda Says — home"
className="font-[family-name:var(--font-title)] font-semibold uppercase tracking-[0.12em] text-base text-foreground"
href="/about"
className="hover:text-foreground transition-colors"
>
Prabhupāda{" "}
<span className="text-muted-foreground tracking-[0.22em] font-medium">
Says
</span>
About
</Link>
<p className="mt-4 text-sm text-muted-foreground leading-relaxed max-w-xs">
A complete, searchable library of His Divine Grace A.C. Bhaktivedanta
Swami Prabhupāda&apos;s books, letters, and teachings.
</p>
</div>
{/* Explore */}
<FooterColumn title="Explore" items={EXPLORE} />
{/* Resources */}
<FooterColumn title="Resources" items={RESOURCES} />
</div>
{/* Copyright row */}
<div className="border-t border-border">
<div className="max-w-5xl mx-auto px-4 sm:px-6 py-5 flex flex-col sm:flex-row items-center justify-between gap-2 text-xs text-muted-foreground">
<p>
© {new Date().getFullYear()} Team Prabhupāda Says · Hare Kṛṣṇa
</p>
<p>All glories to Śrīla Prabhupāda</p>
<span aria-hidden>·</span>
<span>All glories to Śrīla Prabhupāda</span>
</div>
</div>
</footer>
);
}
function FooterColumn({
title,
items,
}: {
title: string;
items: { href: string; label: string }[];
}) {
return (
<div>
<p className="text-[0.72rem] uppercase tracking-[0.2em] font-semibold text-foreground/80">
{title}
</p>
<ul className="mt-4 space-y-2">
{items.map((item) => (
<li key={item.href}>
<Link
href={item.href}
className="text-sm text-foreground/80 hover:text-foreground transition-colors"
>
{item.label}
</Link>
</li>
))}
</ul>
</div>
);
}
+19 -107
View File
@@ -1,133 +1,45 @@
"use client";
/**
* SiteHeader — the single global navigation header.
* SiteHeader — minimal sticky transparent header with the brand wordmark
* centred. Navigation lives in SiteSidebar (desktop) and SiteBottomNav
* (mobile), so the header's only job here is to anchor the brand.
*
* Sits at the top of every page (mounted in app/layout.tsx) so the page-specific
* PageHeader can stay focused on titles and breadcrumbs.
* Mobile also gets a Reading-settings affordance pinned to the right —
* the sidebar rail is hidden on mobile, so this is the only easy entry
* point to the theme + palette + font controls.
*
* Layout:
* Layout (mobile):
*
* ┌─────────────────────────────────────────────────────────────┐
* │ PRABHUPĀDA Books · Letters · Lectures · Glossary ⌕ ⚙ ◐ │
* │ SAYS │
* └─────────────────────────────────────────────────────────────┘
* ┌─────────────────────────────────────────────────────┐
* │ Prabhupāda ⚙ │
* │ SAYS │
* └─────────────────────────────────────────────────────┘
*
* - Serif uppercase wordmark on the left (links home)
* - Section nav inline on md+; collapsed into the mobile drawer below
* - Right cluster: HeaderSearchButton, ReadingSettings, UserMenu
* - Sticky with a subtle border + backdrop blur when scrolled
*
* The HeaderSearchButton's expand-into-full-width behavior is preserved by
* giving it the same conditional flex treatment that PageHeader used to do —
* when expanded on mobile the wordmark + nav collapse out of the way.
* On md+ the gear hides and the sidebar carries it instead.
*/
import { useState } from "react";
import Link from "next/link";
import { usePathname } from "next/navigation";
import { HeaderSearchButton } from "@/components/header-search";
import { ReadingSettings } from "@/components/reading-settings";
import { UserMenu } from "@/components/auth/user-menu";
import { MobileNavToggle } from "@/components/site-mobile-nav";
import { cn } from "@/lib/utils";
const PRIMARY_NAV = [
{ href: "/books", label: "Books" },
{ href: "/letters", label: "Letters" },
{ href: "/transcriptions", label: "Lectures" },
{ href: "/essays", label: "Essays" },
{ href: "/glossary", label: "Glossary" },
] as const;
export function SiteHeader() {
const [searchExpanded, setSearchExpanded] = useState(false);
const pathname = usePathname();
function isActive(href: string): boolean {
if (href === "/") return pathname === "/";
return pathname.startsWith(href);
}
return (
<header className="sticky top-0 z-30 border-b border-border bg-secondary text-secondary-foreground">
<div className="max-w-5xl mx-auto px-3 sm:px-6 h-14 md:h-16 flex items-center gap-2 md:gap-6">
{/* Mobile hamburger — visible below md, hidden when search is expanded */}
<div className={cn("md:hidden", searchExpanded && "hidden")}>
<MobileNavToggle items={PRIMARY_NAV} />
</div>
{/* Brand wordmark — two-line stacked "Prabhupāda / SAYS". Smaller
type sizes on mobile so the row fits next to the hamburger and
action icons on narrow phones (≤375px). */}
<header className="sticky top-0 z-30 bg-background/60 backdrop-blur-md">
<div className="relative h-12 sm:h-14 flex items-center justify-center px-4">
<Link
href="/"
aria-label="Prabhupāda Says — home"
className={cn(
"shrink-0 leading-none text-foreground transition-opacity hover:opacity-80",
"font-[family-name:var(--font-title)]",
searchExpanded && "max-md:hidden",
)}
className="leading-none text-foreground font-[family-name:var(--font-title)] transition-opacity hover:opacity-80"
>
<span className="block text-[0.78rem] md:text-lg font-semibold uppercase tracking-[0.08em] md:tracking-[0.12em]">
<span className="block text-center text-[0.8rem] sm:text-base font-semibold uppercase tracking-[0.12em]">
Prabhupāda
</span>
<span className="block text-[0.55rem] md:text-[0.78rem] font-medium uppercase text-muted-foreground tracking-[0.18em] md:tracking-[0.22em] mt-0.5">
<span className="block text-center text-[0.55rem] sm:text-[0.7rem] font-medium uppercase text-muted-foreground tracking-[0.22em] mt-0.5">
Says
</span>
</Link>
{/* Primary nav — desktop only; mobile uses the drawer.
Stays visible even when search is expanded — on desktop the search
input has a fixed max-width and fits alongside the nav, so there's
no need to remove items (which would cause layout shift). */}
<nav
className="hidden md:flex items-center gap-5 flex-1 ml-2"
aria-label="Primary"
>
{PRIMARY_NAV.map((item) => (
<Link
key={item.href}
href={item.href}
className={cn(
"text-sm transition-colors relative",
isActive(item.href)
? "text-foreground after:absolute after:left-0 after:right-0 after:-bottom-1 after:h-px after:bg-primary"
: "text-muted-foreground hover:text-foreground",
)}
>
{item.label}
</Link>
))}
</nav>
{/* Spacer pushes the action cluster to the right on mobile */}
<div
className={cn("flex-1 md:hidden", searchExpanded && "hidden")}
aria-hidden
/>
{/* Action cluster — search, reading-settings, user menu.
On mobile when search is expanded, the cluster fills the row so
the input can grow. On desktop it always stays shrink-0 — the
expanded search input has its own fixed max-width and doesn't
need to steal space from the nav. */}
<div
className={cn(
"flex items-center gap-1 shrink-0",
searchExpanded && "max-md:flex-1 max-md:min-w-0 max-md:shrink",
)}
>
<HeaderSearchButton
expanded={searchExpanded}
onExpandedChange={setSearchExpanded}
/>
<div className={cn(searchExpanded && "max-md:hidden")}>
<ReadingSettings />
</div>
<div className={cn(searchExpanded && "max-md:hidden")}>
<UserMenu />
</div>
<div className="md:hidden absolute right-2 top-1/2 -translate-y-1/2">
<ReadingSettings />
</div>
</div>
</header>
+299
View File
@@ -0,0 +1,299 @@
"use client";
/**
* SiteSidebar — desktop-only thin rail that expands on hover.
*
* Layout (collapsed, w-16):
*
* ┌──┐
* │🏠│ ← top group (Home / Knowledgebase / Collections /
* │📚│ Bookmarks / Mentions / Search)
* │📁│
* │🔖│
* │@ │
* │🔍│
* │ │
* │⋮ │ ← bottom group (Reading settings, Profile)
* │👤│
* └──┘
*
* Hovering anywhere on the rail expands it to w-56 and reveals labels.
* The rail floats above content (fixed position, the main area is
* padded by w-16) so the expansion never causes layout shift.
*
* Hidden below md — mobile uses SiteBottomNav.
*/
import { useState } from "react";
import Link from "next/link";
import { usePathname } from "next/navigation";
import {
AtSign,
Bookmark,
FolderClosed,
Home,
LogIn,
Search,
Settings2,
User as UserIcon,
} from "lucide-react";
import { useAuth } from "@/components/auth/auth-provider";
import { ReadingSettings } from "@/components/reading-settings";
import { cn } from "@/lib/utils";
type NavItem = {
href: string;
label: string;
icon: React.ReactNode;
/** Optional: routes to mark active when the path starts with these.
* Used for /books variants (lectures, letters, etc.) all flagging the
* Knowledgebase entry. */
matchPrefixes?: string[];
};
const TOP_ITEMS: NavItem[] = [
{ href: "/", label: "Home", icon: <Home className="size-5" /> },
{
href: "/search",
label: "Search",
icon: <Search className="size-5" />,
// Search doubles as the knowledgebase browse surface — light it up
// any time the user is deep inside the library so they have a clear
// path back.
matchPrefixes: [
"/search",
"/books",
"/letters",
"/transcriptions",
"/essays",
"/glossary",
"/journals",
"/magazines",
"/other-documents",
"/disciples",
],
},
{
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" />,
},
];
export function SiteSidebar() {
const pathname = usePathname();
const { user } = useAuth();
// While the reading-settings popover is open we pin the rail to its
// expanded width so the popover (which anchors to the trigger button)
// doesn't visually drift when the user's mouse leaves the rail.
const [readingOpen, setReadingOpen] = useState(false);
function isActive(item: NavItem): boolean {
if (item.matchPrefixes) {
return item.matchPrefixes.some((p) =>
p === "/" ? pathname === "/" : pathname.startsWith(p),
);
}
if (item.href === "/") return pathname === "/";
return pathname === item.href || pathname.startsWith(`${item.href}/`);
}
return (
<aside
aria-label="Primary navigation"
data-expanded={readingOpen ? "true" : undefined}
className={cn(
"hidden md:flex group fixed inset-y-0 left-0 z-40",
"transition-[width] duration-200 ease-out",
readingOpen ? "w-56" : "w-16 hover:w-56",
"bg-background/95 backdrop-blur border-r border-border",
"flex-col",
)}
>
<nav
className="flex-1 overflow-y-auto py-4 flex flex-col gap-0.5"
aria-label="Primary"
>
{TOP_ITEMS.map((item) => (
<SidebarLink
key={item.href}
item={item}
active={isActive(item)}
expandedOverride={readingOpen}
/>
))}
</nav>
<div className="border-t border-border py-4 flex flex-col gap-0.5">
<SidebarReadingSettings
expanded={readingOpen}
onOpenChange={setReadingOpen}
/>
<SidebarProfile
authed={!!user}
handle={user?.handle ?? null}
name={user?.name ?? null}
active={pathname.startsWith("/u/me")}
expandedOverride={readingOpen}
/>
</div>
</aside>
);
}
function SidebarLink({
item,
active,
expandedOverride,
}: {
item: NavItem;
active: boolean;
/** Force labels visible when the rail is pinned open (e.g. reading
* settings popover is up) and the mouse isn't actually hovering. */
expandedOverride?: boolean;
}) {
return (
<Link
href={item.href}
aria-label={item.label}
title={item.label}
className={cn(
"mx-2 h-11 flex items-center gap-3 px-3 rounded-lg overflow-hidden whitespace-nowrap text-sm font-medium",
"transition-colors",
active
? "bg-primary/10 text-primary"
: "text-muted-foreground hover:text-foreground hover:bg-muted",
)}
>
<span className="shrink-0">{item.icon}</span>
<span
className={cn(
"transition-opacity duration-150",
expandedOverride
? "opacity-100"
: "opacity-0 group-hover:opacity-100 delay-0 group-hover:delay-100",
)}
>
{item.label}
</span>
</Link>
);
}
function SidebarReadingSettings({
expanded,
onOpenChange,
}: {
expanded: boolean;
onOpenChange: (open: boolean) => void;
}) {
return (
<div className="mx-2">
<ReadingSettings
placement="above-left"
onOpenChange={onOpenChange}
renderTrigger={({ open, toggle, buttonRef }) => (
<button
ref={buttonRef}
type="button"
aria-label="Reading settings"
aria-expanded={open}
onClick={toggle}
className={cn(
"h-11 w-full flex items-center gap-3 px-3 rounded-lg overflow-hidden whitespace-nowrap text-sm font-medium",
"text-muted-foreground hover:text-foreground hover:bg-muted transition-colors",
)}
>
<Settings2 className="size-5 shrink-0" />
<span
className={cn(
"transition-opacity duration-150",
expanded
? "opacity-100"
: "opacity-0 group-hover:opacity-100 delay-0 group-hover:delay-100",
)}
>
Reading
</span>
</button>
)}
/>
</div>
);
}
function SidebarProfile({
authed,
handle,
name,
active,
expandedOverride,
}: {
authed: boolean;
handle: string | null;
name: string | null;
active: boolean;
expandedOverride?: boolean;
}) {
if (!authed) {
return (
<Link
href="/login"
className={cn(
"mx-2 h-11 flex items-center gap-3 px-3 rounded-lg overflow-hidden whitespace-nowrap text-sm font-medium",
"text-muted-foreground hover:text-foreground hover:bg-muted transition-colors",
)}
aria-label="Sign in"
title="Sign in"
>
<LogIn className="size-5 shrink-0" />
<span
className={cn(
"transition-opacity duration-150",
expandedOverride
? "opacity-100"
: "opacity-0 group-hover:opacity-100 delay-0 group-hover:delay-100",
)}
>
Sign in
</span>
</Link>
);
}
return (
<Link
href="/u/me"
aria-label="Profile"
title={handle ? `@${handle}` : "Profile"}
className={cn(
"mx-2 h-11 flex items-center gap-3 px-3 rounded-lg overflow-hidden whitespace-nowrap text-sm font-medium",
"transition-colors",
active
? "bg-primary/10 text-primary"
: "text-muted-foreground hover:text-foreground hover:bg-muted",
)}
>
<UserIcon className="size-5 shrink-0" />
<span
className={cn(
"min-w-0 truncate transition-opacity duration-150",
expandedOverride
? "opacity-100"
: "opacity-0 group-hover:opacity-100 delay-0 group-hover:delay-100",
)}
>
{name || (handle ? `@${handle}` : "Profile")}
</span>
</Link>
);
}
@@ -0,0 +1,86 @@
"use client";
/**
* CollectionGridCard — square collection card for the 2-per-row grid view.
*
* • With cover: cover fills the square; bottom-up overlay for legibility.
* • Without cover: the same flat palette-aware surface that post cards
* fall back to — pure white on light themes, palette-specific dark
* contrast on dark themes (see `--quote-card-bg`).
*
* Visibility badge (Globe/Lock) sits top-right. Title + count read
* bottom-centre in white over the cover image's dark overlay; on the
* flat fallback they use `text-foreground` so they invert cleanly with
* the theme.
*/
import Image from "next/image";
import Link from "next/link";
import { Globe, Lock } from "lucide-react";
import { cn } from "@/lib/utils";
import type { Collection } from "@/lib/social";
import { quoteCardBgStyle } from "./post-card-shared";
export function CollectionGridCard({
collection: c,
href,
}: {
collection: Collection;
href: string;
}) {
const hasCover = !!c.cover_url;
return (
<Link
href={href}
className="group relative block aspect-square rounded-2xl overflow-hidden ring-1 ring-foreground/10 hover:ring-foreground/25 hover:shadow-md transition-all select-none"
>
{hasCover ? (
<>
<Image
src={c.cover_url!}
alt=""
fill
sizes="(max-width: 640px) 50vw, 320px"
className="object-cover"
unoptimized
/>
<div className="absolute inset-0 bg-gradient-to-t from-black/80 via-black/30 to-transparent" />
</>
) : (
<div className="absolute inset-0" style={quoteCardBgStyle()} />
)}
{/* Visibility badge — black pill works on both image overlay and
flat surface (white or dark). */}
<div className="absolute top-3 right-3 inline-flex items-center gap-1 rounded-full bg-black/40 backdrop-blur-sm px-2.5 py-1 text-[0.6rem] uppercase tracking-[0.18em] font-medium text-white">
{c.is_public ? <Globe className="size-3" /> : <Lock className="size-3" />}
{c.is_public ? "Public" : "Private"}
</div>
{/* Title + count — text colour follows the surface beneath. */}
<div
className={cn(
"absolute inset-0 flex flex-col items-center justify-center text-center px-5 sm:px-7",
hasCover ? "text-white" : "text-foreground",
)}
>
<p
className={cn(
"font-[family-name:var(--font-title)] text-lg sm:text-2xl font-semibold leading-snug line-clamp-4 break-words",
hasCover && "drop-shadow-sm",
)}
>
{c.title}
</p>
<p
className={cn(
"mt-2 text-[0.65rem] uppercase tracking-[0.22em] font-medium",
hasCover ? "text-white/85" : "text-muted-foreground",
)}
>
{c.item_count} post{c.item_count === 1 ? "" : "s"}
</p>
</div>
</Link>
);
}
@@ -0,0 +1,325 @@
"use client";
/**
* CollectionScrollCard — Instagram-style horizontal carousel for one
* collection.
*
* • Slide 1 is the collection's "cover" — cover image (or themed
* gradient if none) overlaid with the title and item count.
* • Slides 2..N are PostGridCards for each post in the collection.
*
* Posts are fetched lazily: an IntersectionObserver triggers
* `collections.get(id)` the first time the carousel scrolls into view,
* so a feed of many collections doesn't blow N+1 requests on mount.
*
* The carousel uses native CSS scroll-snap (horizontal, mandatory) so
* touch swipes feel right on mobile. A page-dot indicator below tracks
* the current slide.
*/
import { useEffect, useRef, useState } from "react";
import Image from "next/image";
import Link from "next/link";
import {
ChevronLeft,
ChevronRight,
Globe,
Loader2,
Lock,
Pencil,
} from "lucide-react";
import { cn } from "@/lib/utils";
import { collections, type Collection, type Post } from "@/lib/social";
import { PostGridCard } from "./post-grid-card";
import { quoteCardBgStyle } from "./post-card-shared";
type FetchState = "idle" | "loading" | "loaded" | "error";
export function CollectionScrollCard({
collection: c,
href,
initialPosts,
onEdit,
}: {
collection: Collection;
/** Link target for the "Open" footer + cover slide click-through. */
href: string;
/** Pre-loaded posts — skip the lazy fetch when the parent already has
* them (e.g. the single-collection page renders one card after its
* own collections.get call). */
initialPosts?: Post[];
/** When provided, shows a compact Edit chip in the cover slide's
* bottom-right corner. Owner-only — caller decides whether to pass it. */
onEdit?: () => void;
}) {
const scrollRef = useRef<HTMLDivElement>(null);
const cardRef = useRef<HTMLElement>(null);
const [posts, setPosts] = useState<Post[]>(initialPosts ?? []);
const [fetchState, setFetchState] = useState<FetchState>(
initialPosts ? "loaded" : "idle",
);
const [activeIndex, setActiveIndex] = useState(0);
// Reconcile when the parent supplies fresh posts (e.g. after editing).
useEffect(() => {
if (!initialPosts) return;
setPosts(initialPosts);
setFetchState("loaded");
}, [initialPosts]);
// Fetch posts the first time the carousel enters the viewport (with a
// 200px rootMargin so we kick it off just before it's visible). Skipped
// entirely when `initialPosts` was provided.
useEffect(() => {
if (fetchState !== "idle") return;
const el = cardRef.current;
if (!el) return;
const observer = new IntersectionObserver(
(entries) => {
for (const entry of entries) {
if (entry.isIntersecting) {
observer.disconnect();
setFetchState("loading");
collections
.get(c.id)
.then(({ posts: rows }) => {
setPosts(rows ?? []);
setFetchState("loaded");
})
.catch(() => setFetchState("error"));
return;
}
}
},
{ rootMargin: "200px" },
);
observer.observe(el);
return () => observer.disconnect();
}, [c.id, fetchState]);
// Track which slide is currently snapped. We use scrollLeft / slideWidth
// rather than another observer because the slides are full-width siblings
// — much cheaper to compute.
function onScroll() {
const el = scrollRef.current;
if (!el) return;
const slideWidth = el.offsetWidth;
if (slideWidth === 0) return;
setActiveIndex(Math.round(el.scrollLeft / slideWidth));
}
function scrollByOne(direction: 1 | -1) {
const el = scrollRef.current;
if (!el) return;
el.scrollBy({ left: el.offsetWidth * direction, behavior: "smooth" });
}
const slideCount = 1 + posts.length;
const showArrows = slideCount > 1;
return (
<article ref={cardRef} className="space-y-3">
<div className="relative max-w-xl mx-auto">
<div
ref={scrollRef}
onScroll={onScroll}
className={cn(
"flex overflow-x-auto snap-x snap-mandatory rounded-2xl",
// Hide scrollbar — the carousel is gesture-driven; the dot
// indicator below tracks position.
"[scrollbar-width:none] [&::-webkit-scrollbar]:hidden",
)}
>
{/* Cover slide */}
<CoverSlide collection={c} href={href} onEdit={onEdit} />
{/* Post slides (or placeholders while loading) */}
{fetchState === "loaded" &&
posts.map((p) => (
<div
key={p.id}
className="w-full shrink-0 snap-start p-px"
aria-roledescription="slide"
>
<PostGridCard post={p} />
</div>
))}
{fetchState === "loading" ? (
<div className="w-full shrink-0 snap-start aspect-square rounded-2xl bg-card ring-1 ring-foreground/10 flex items-center justify-center text-muted-foreground">
<Loader2 className="size-5 animate-spin" />
</div>
) : null}
</div>
{showArrows ? (
<>
<CarouselArrow
direction="prev"
onClick={() => scrollByOne(-1)}
disabled={activeIndex === 0}
/>
<CarouselArrow
direction="next"
onClick={() => scrollByOne(1)}
disabled={activeIndex >= slideCount - 1}
/>
</>
) : null}
</div>
{/* Dot indicator */}
{slideCount > 1 ? (
<div className="flex items-center justify-center gap-1.5">
{Array.from({ length: slideCount }).map((_, i) => (
<span
key={i}
className={cn(
"rounded-full transition-all",
i === activeIndex
? "size-1.5 bg-foreground"
: "size-1.5 bg-muted-foreground/40",
)}
aria-hidden
/>
))}
</div>
) : null}
</article>
);
}
// ─── Slide pieces ────────────────────────────────────────────────────────
function CoverSlide({
collection: c,
href,
onEdit,
}: {
collection: Collection;
href: string;
onEdit?: () => void;
}) {
// The slide wrapper hosts the Link AND any owner-action chip as
// siblings. Nesting a button inside the Link is an a11y anti-pattern
// (interactive nested in interactive), so we keep them peers and let
// DOM z-order put the chip on top of the Link's bottom-right corner.
return (
<div className="w-full shrink-0 snap-start relative aspect-square">
<Link
href={href}
className="absolute inset-0 rounded-2xl overflow-hidden ring-1 ring-foreground/10 select-none"
aria-label={`Open collection ${c.title}`}
>
{c.cover_url ? (
<>
<Image
src={c.cover_url}
alt=""
fill
sizes="(max-width: 768px) 100vw, 640px"
className="object-cover"
unoptimized
/>
<div className="absolute inset-0 bg-gradient-to-t from-black/85 via-black/40 to-black/10" />
</>
) : (
<div className="absolute inset-0" style={quoteCardBgStyle()} />
)}
<div className="absolute top-4 right-4 inline-flex items-center gap-1.5 rounded-full bg-black/40 backdrop-blur-sm px-3 py-1 text-[0.6rem] uppercase tracking-[0.18em] font-medium text-white">
{c.is_public ? <Globe className="size-3" /> : <Lock className="size-3" />}
Collection
</div>
<div
className={cn(
"absolute inset-0 flex flex-col p-6 sm:p-8",
// Text colour follows the surface underneath: white over the
// image's dark overlay, theme foreground over the flat
// fallback so it inverts cleanly with the palette.
c.cover_url ? "text-white" : "text-foreground",
c.cover_url
? "justify-end"
: "justify-center items-center text-center",
)}
>
<p
className={cn(
"font-[family-name:var(--font-title)] text-2xl sm:text-3xl font-semibold leading-tight line-clamp-3 break-words",
c.cover_url && "drop-shadow-md",
)}
>
{c.title}
</p>
{c.description ? (
<p
className={cn(
"mt-2 text-sm line-clamp-2",
c.cover_url ? "text-white/85 max-w-md" : "text-muted-foreground max-w-md mx-auto",
)}
>
{c.description}
</p>
) : null}
<p
className={cn(
"mt-3 text-[0.7rem] uppercase tracking-[0.22em] font-semibold",
c.cover_url ? "text-white/75" : "text-muted-foreground",
)}
>
{c.item_count} post{c.item_count === 1 ? "" : "s"} · Swipe to browse →
</p>
</div>
</Link>
{onEdit ? (
<button
type="button"
onClick={onEdit}
aria-label="Edit collection"
title="Edit collection"
className={cn(
"absolute bottom-3 right-3 z-10 inline-flex items-center gap-1",
"rounded-full bg-black/50 backdrop-blur-sm hover:bg-black/70",
"text-white text-[0.7rem] font-medium uppercase tracking-[0.16em]",
"px-2.5 py-1 transition-colors",
)}
>
<Pencil className="size-3" />
Edit
</button>
) : null}
</div>
);
}
function CarouselArrow({
direction,
onClick,
disabled,
}: {
direction: "prev" | "next";
onClick: () => void;
disabled: boolean;
}) {
const Icon = direction === "prev" ? ChevronLeft : ChevronRight;
return (
<button
type="button"
onClick={onClick}
disabled={disabled}
aria-label={direction === "prev" ? "Previous slide" : "Next slide"}
className={cn(
"hidden md:inline-flex absolute top-1/2 -translate-y-1/2 size-9 rounded-full",
"bg-black/40 text-white items-center justify-center backdrop-blur-sm",
"hover:bg-black/60 transition-all",
"disabled:opacity-0 disabled:pointer-events-none",
direction === "prev" ? "left-3" : "right-3",
)}
>
<Icon className="size-4" />
</button>
);
}
+38
View File
@@ -0,0 +1,38 @@
"use client";
/**
* CollectionViews — grid-only renderer for collection lists.
*
* `hrefFor(c)` chooses where each card links: the owner page sends users
* to /u/me/collections/<id>; the public profile sends them to /c/<id>.
* The carousel "scroll view" is reached by clicking through to the
* collection detail page.
*/
import type { Collection } from "@/lib/social";
import { CollectionGridCard } from "./collection-grid-card";
export function CollectionViews({
collections,
hrefFor,
emptyMessage,
}: {
collections: Collection[];
hrefFor: (c: Collection) => string;
emptyMessage?: string;
}) {
if (collections.length === 0) {
return (
<p className="text-sm text-muted-foreground">
{emptyMessage ?? "No collections yet."}
</p>
);
}
return (
<div className="grid grid-cols-2 gap-3 sm:gap-4">
{collections.map((c) => (
<CollectionGridCard key={c.id} collection={c} href={hrefFor(c)} />
))}
</div>
);
}
@@ -0,0 +1,504 @@
"use client";
/**
* EditCollectionDrawer — single-stop editor for a collection.
*
* • Title, description, public/private toggle
* • Cover image upload (signed-URL PUT to R2)
* • Reorder posts via drag-and-drop, toggle posts in/out of the collection
* • Delete button at the bottom (with confirm modal)
*
* Right-side panel on `md+`, bottom drawer on mobile. The body scrolls;
* header and footer stay pinned so action buttons are always reachable.
*
* Drag-and-drop is powered by framer-motion's Reorder primitives so we
* get touch + mouse support without an extra dependency. Each row has a
* dedicated grip handle (Reorder.Item with `dragListener={false}` plus
* `dragControls`) so the rest of the row stays clickable for toggling.
*/
import { useEffect, useMemo, useRef, useState } from "react";
import Image from "next/image";
import { Reorder, useDragControls } from "framer-motion";
import {
Globe,
GripVertical,
ImagePlus,
Loader2,
Lock,
Trash2,
X,
} from "lucide-react";
import { toast } from "sonner";
import {
Drawer,
DrawerClose,
DrawerContent,
DrawerHeader,
DrawerTitle,
} from "@/components/ui/drawer";
import { Checkbox } from "@/components/ui/checkbox";
import { ConfirmModal } from "@/components/ui/confirm-modal";
import { useMediaQuery } from "@/components/selection-toolbar/shared";
import {
collections,
posts as postsApi,
SocialError,
type Collection,
type Post,
} from "@/lib/social";
type SavedShape = {
collection: Collection;
posts: Post[];
};
export function EditCollectionDrawer({
open,
onClose,
collection,
currentPostIds,
onSaved,
onDeleted,
}: {
open: boolean;
onClose: () => void;
collection: Collection;
/** Post ids currently in the collection, in display order. Used to
* seed the drag-sortable list and to diff against the user's
* selection + new ordering on save. */
currentPostIds: string[];
onSaved: (next: SavedShape) => void;
onDeleted: () => void;
}) {
const isMobile = useMediaQuery("(max-width: 767px)");
const direction: "bottom" | "right" = isMobile ? "bottom" : "right";
const fileInput = useRef<HTMLInputElement>(null);
const [title, setTitle] = useState(collection.title);
const [description, setDescription] = useState(collection.description ?? "");
const [isPublic, setIsPublic] = useState(collection.is_public);
const [coverURL, setCoverURL] = useState<string | null>(collection.cover_url);
// Source of truth for the in-collection set + order: a plain array of
// post ids. We push/pull from this when toggling, and reorder it on
// drag. On save we diff against `currentPostIds` to find adds/removes
// and push the final order to /items/order.
const [orderedIds, setOrderedIds] = useState<string[]>(currentPostIds);
const [allPosts, setAllPosts] = useState<Post[] | null>(null);
const [loadingPosts, setLoadingPosts] = useState(false);
const [saving, setSaving] = useState(false);
const [uploadingCover, setUploadingCover] = useState(false);
const [error, setError] = useState<string | null>(null);
const [confirmDelete, setConfirmDelete] = useState(false);
useEffect(() => {
if (!open) return;
setTitle(collection.title);
setDescription(collection.description ?? "");
setIsPublic(collection.is_public);
setCoverURL(collection.cover_url);
setOrderedIds(currentPostIds);
setError(null);
}, [open, collection, currentPostIds]);
useEffect(() => {
if (!open || allPosts !== null) return;
setLoadingPosts(true);
postsApi
.mine()
.then((rows) => setAllPosts(rows ?? []))
.catch((err) =>
setError(err instanceof SocialError ? err.message : "Could not load posts."),
)
.finally(() => setLoadingPosts(false));
}, [open, allPosts]);
// Partition the user's posts into "in the collection" (ordered by
// `orderedIds`) and "available to add" (everything else, stable).
const { inCollection, available } = useMemo(() => {
if (!allPosts) return { inCollection: [] as Post[], available: [] as Post[] };
const byId = new Map(allPosts.map((p) => [p.id, p]));
const selected = new Set(orderedIds);
const inCollection = orderedIds
.map((id) => byId.get(id))
.filter((p): p is Post => !!p);
const available = allPosts.filter((p) => !selected.has(p.id));
return { inCollection, available };
}, [allPosts, orderedIds]);
function addToCollection(id: string) {
setOrderedIds((prev) => (prev.includes(id) ? prev : [...prev, id]));
}
function removeFromCollection(id: string) {
setOrderedIds((prev) => prev.filter((x) => x !== id));
}
async function onCoverChange(e: React.ChangeEvent<HTMLInputElement>) {
const f = e.target.files?.[0];
if (fileInput.current) fileInput.current.value = "";
if (!f) return;
setUploadingCover(true);
try {
const url = await collections.uploadCover(f);
setCoverURL(url);
} catch (err) {
toast.error(
err instanceof SocialError ? err.message : "Cover upload failed.",
);
} finally {
setUploadingCover(false);
}
}
function removeCover() {
setCoverURL(null);
}
async function save() {
setSaving(true);
setError(null);
try {
const before = new Set(currentPostIds);
const after = new Set(orderedIds);
const toAdd = orderedIds.filter((id) => !before.has(id));
const toRemove = currentPostIds.filter((id) => !after.has(id));
await collections.update(collection.id, {
title,
description,
is_public: isPublic,
cover_url: coverURL ?? "",
});
// Sequential mutations — the count trigger races on parallel calls.
for (const id of toAdd) await collections.addItem(collection.id, id);
for (const id of toRemove)
await collections.removeItem(collection.id, id);
// Final position pass — overwrites whatever positions addItem
// assigned (MAX+1) so the user's drag order wins.
if (orderedIds.length > 0) {
await collections.reorderItems(collection.id, orderedIds);
}
const fresh = await collections.get(collection.id);
onSaved({ collection: fresh.collection, posts: fresh.posts ?? [] });
toast.success("Collection updated");
onClose();
} catch (err) {
setError(err instanceof SocialError ? err.message : "Save failed.");
} finally {
setSaving(false);
}
}
async function performDelete() {
try {
await collections.delete(collection.id);
onDeleted();
} catch (err) {
throw new Error(
err instanceof SocialError ? err.message : "Could not delete.",
);
}
}
return (
<>
<Drawer
open={open}
onOpenChange={(o) => !o && !saving && onClose()}
direction={direction}
shouldScaleBackground={direction === "bottom"}
>
<DrawerContent
direction={direction}
className={
direction === "bottom"
? "max-h-[90vh]"
: "max-w-md sm:max-w-lg"
}
>
<DrawerHeader className="shrink-0 text-left flex items-center justify-between pb-2">
<DrawerTitle>Edit collection</DrawerTitle>
<DrawerClose asChild>
<button
type="button"
aria-label="Close"
disabled={saving}
className="size-8 rounded-md text-muted-foreground hover:text-foreground hover:bg-muted inline-flex items-center justify-center disabled:opacity-50"
>
<X className="size-4" />
</button>
</DrawerClose>
</DrawerHeader>
<div className="flex-1 min-h-0 overflow-y-auto px-4 pb-4 space-y-5">
{/* Cover */}
<section className="flex items-start gap-4">
<div className="relative size-24 rounded-lg overflow-hidden bg-muted ring-1 ring-foreground/10 shrink-0">
{coverURL ? (
<Image
src={coverURL}
alt=""
fill
sizes="96px"
className="object-cover"
unoptimized
/>
) : (
<span className="absolute inset-0 flex items-center justify-center text-[0.6rem] uppercase tracking-[0.2em] text-muted-foreground text-center px-2">
No cover
</span>
)}
</div>
<div className="flex-1 flex flex-col gap-2 min-w-0">
<input
ref={fileInput}
type="file"
accept="image/png,image/jpeg,image/webp,image/gif"
hidden
onChange={onCoverChange}
/>
<button
type="button"
onClick={() => fileInput.current?.click()}
disabled={uploadingCover}
className="inline-flex items-center gap-1.5 rounded-md px-3 h-9 text-sm text-foreground hover:bg-muted border border-border w-fit disabled:opacity-50"
>
{uploadingCover ? (
<Loader2 className="size-4 animate-spin" />
) : (
<ImagePlus className="size-4" />
)}
{coverURL ? "Change cover" : "Add cover"}
</button>
{coverURL ? (
<button
type="button"
onClick={removeCover}
className="text-xs text-muted-foreground hover:text-destructive w-fit"
>
Remove cover
</button>
) : null}
</div>
</section>
{/* Title */}
<section className="space-y-1">
<label className="text-xs uppercase tracking-[0.18em] font-semibold text-muted-foreground">
Title
</label>
<input
value={title}
onChange={(e) => setTitle(e.target.value)}
maxLength={120}
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"
/>
</section>
{/* Description */}
<section className="space-y-1">
<label className="text-xs uppercase tracking-[0.18em] font-semibold text-muted-foreground">
Description
</label>
<textarea
value={description}
onChange={(e) => setDescription(e.target.value)}
rows={3}
placeholder="Optional"
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"
/>
</section>
{/* Visibility */}
<section>
<label className="flex items-center gap-2.5 text-sm cursor-pointer text-foreground">
<Checkbox
checked={isPublic}
onCheckedChange={setIsPublic}
ariaLabel="Public collection"
/>
{isPublic ? (
<Globe className="size-4 text-muted-foreground" />
) : (
<Lock className="size-4 text-muted-foreground" />
)}
Public — anyone with the link can view
</label>
</section>
{/* Posts in this collection (drag-sortable) */}
<section className="space-y-2 pt-2">
<div className="flex items-baseline justify-between">
<label className="text-xs uppercase tracking-[0.18em] font-semibold text-muted-foreground">
In this collection
</label>
<span className="text-xs text-muted-foreground tabular-nums">
{orderedIds.length}
</span>
</div>
{loadingPosts ? (
<p className="text-sm text-muted-foreground">Loading…</p>
) : inCollection.length === 0 ? (
<p className="text-sm text-muted-foreground">
No posts in this collection yet — pick one from below.
</p>
) : (
<Reorder.Group
axis="y"
values={orderedIds}
onReorder={setOrderedIds}
className="rounded-lg border border-border divide-y divide-border bg-background"
as="ul"
>
{inCollection.map((p) => (
<SortableRow
key={p.id}
post={p}
onRemove={() => removeFromCollection(p.id)}
/>
))}
</Reorder.Group>
)}
</section>
{/* Available posts */}
{available.length > 0 ? (
<section className="space-y-2">
<label className="text-xs uppercase tracking-[0.18em] font-semibold text-muted-foreground">
Add posts
</label>
<ul className="rounded-lg border border-border divide-y divide-border bg-background">
{available.map((p) => (
<AvailableRow
key={p.id}
post={p}
onAdd={() => addToCollection(p.id)}
/>
))}
</ul>
</section>
) : null}
{error ? (
<p className="text-sm text-destructive">{error}</p>
) : null}
</div>
{/* Footer actions */}
<div className="shrink-0 border-t border-border p-4 flex items-center justify-between gap-2">
<button
type="button"
onClick={() => setConfirmDelete(true)}
disabled={saving}
className="inline-flex items-center gap-1.5 rounded-md px-3 h-10 text-sm text-destructive hover:bg-destructive/10 disabled:opacity-50"
>
<Trash2 className="size-4" />
Delete
</button>
<div className="flex items-center gap-2">
<button
type="button"
onClick={onClose}
disabled={saving}
className="inline-flex items-center rounded-md px-4 h-10 text-sm text-foreground hover:bg-muted disabled:opacity-50"
>
Cancel
</button>
<button
type="button"
onClick={save}
disabled={saving || uploadingCover || title.trim().length === 0}
className="inline-flex items-center gap-1.5 rounded-md px-4 h-10 bg-primary text-primary-foreground text-sm hover:bg-primary/90 disabled:opacity-60"
>
{saving ? "Saving…" : "Save"}
</button>
</div>
</div>
</DrawerContent>
</Drawer>
<ConfirmModal
open={confirmDelete}
onClose={() => setConfirmDelete(false)}
title={`Delete "${collection.title}"?`}
message="The collection will be removed permanently. The posts inside stay on your feed."
confirmLabel="Delete collection"
onConfirm={performDelete}
/>
</>
);
}
// ─── Row pieces ──────────────────────────────────────────────────────────
function SortableRow({
post,
onRemove,
}: {
post: Post;
onRemove: () => void;
}) {
// dragListener={false} disables the default whole-item drag — only the
// grip handle starts a drag via dragControls.start.
const controls = useDragControls();
return (
<Reorder.Item
value={post.id}
dragListener={false}
dragControls={controls}
className="flex items-start gap-3 p-3 hover:bg-muted/40 transition-colors bg-background select-none"
>
<button
type="button"
onPointerDown={(e) => controls.start(e)}
aria-label="Drag to reorder"
title="Drag to reorder"
className="mt-0.5 text-muted-foreground hover:text-foreground cursor-grab active:cursor-grabbing touch-none"
>
<GripVertical className="size-4" />
</button>
<Checkbox
checked
onCheckedChange={() => onRemove()}
ariaLabel="Remove from collection"
className="mt-0.5"
/>
<PostSummary post={post} />
</Reorder.Item>
);
}
function AvailableRow({ post, onAdd }: { post: Post; onAdd: () => void }) {
return (
<li>
<label className="flex items-start gap-3 p-3 hover:bg-muted/40 transition-colors cursor-pointer">
<Checkbox
checked={false}
onCheckedChange={() => onAdd()}
ariaLabel="Add to collection"
className="mt-0.5"
/>
<PostSummary post={post} />
</label>
</li>
);
}
function PostSummary({ post }: { post: Post }) {
return (
<span className="min-w-0 flex-1">
<span className="block text-sm text-foreground line-clamp-2 break-words">
{post.caption}
</span>
{post.source_title ? (
<span className="block text-[0.65rem] uppercase tracking-[0.18em] text-muted-foreground mt-1 truncate">
{post.source_title}
</span>
) : null}
</span>
);
}
+241
View File
@@ -0,0 +1,241 @@
"use client";
/**
* PageBookmarks — persistent in-text bookmark highlighting + floating X.
*
* For every bookmark the logged-in user has whose `source_url` path
* matches the current pathname, we:
*
* 1. Wrap the passage in a `<mark data-bookmark-id="…">` with a violet
* wash so the text stays highlighted across the whole session.
* 2. Anchor a floating "Bookmarked × remove" pill 32px above the first
* mark, scrolling with the page.
* 3. Remove both the mark AND the pill when the user clicks ×.
*
* Cross-component sync: the selection toolbar dispatches
* `spsays:bookmarks-changed` whenever it creates a bookmark; we re-fetch
* and re-mark on that event, so a freshly-bookmarked passage lights up
* immediately without a page reload.
*/
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { createPortal } from "react-dom";
import { usePathname } from "next/navigation";
import { Bookmark, X } from "lucide-react";
import { toast } from "sonner";
import { useAuth } from "@/components/auth/auth-provider";
import {
bookmarks as bookmarksApi,
type Bookmark as BookmarkRec,
} from "@/lib/social";
import {
decodeTextFragment,
markFragmentAsBookmark,
removeBookmarkMarks,
} from "@/lib/text-highlight";
type Marker = {
bookmark: BookmarkRec;
top: number;
left: number;
};
export function PageBookmarks() {
const { user, loading } = useAuth();
const pathname = usePathname();
const [list, setList] = useState<BookmarkRec[]>([]);
const [markers, setMarkers] = useState<Marker[]>([]);
// Track which bookmark ids have already been applied to the DOM so we
// don't double-wrap when state churns (e.g. an optimistic delete that
// then errors and gets restored).
const appliedIdsRef = useRef<Set<string>>(new Set());
const onThisPage = useMemo(
() => list.filter((b) => pathOf(b.source_url) === pathname),
[list, pathname],
);
// Fetch bookmarks once per user. The toolbar fires a custom event when
// it creates a new one so we refresh without polling.
useEffect(() => {
if (loading) return;
if (!user) {
setList([]);
return;
}
let cancelled = false;
const refetch = () =>
bookmarksApi
.list()
.then((rows) => {
if (!cancelled) setList(rows ?? []);
})
.catch(() => {
/* overlay is non-essential — swallow errors */
});
refetch();
const onChanged = () => {
void refetch();
};
window.addEventListener("spsays:bookmarks-changed", onChanged);
return () => {
cancelled = true;
window.removeEventListener("spsays:bookmarks-changed", onChanged);
};
}, [user, loading]);
// Apply marks + measure icon positions. Runs whenever the relevant
// bookmarks for this page change, OR when the viewport resizes so a
// reflowed paragraph doesn't strand the floating icon.
const applyAndMeasure = useCallback(() => {
if (typeof window === "undefined") return;
// Garbage-collect marks for bookmarks that no longer exist on this
// page (deleted, or navigated to a different URL).
const wantedIds = new Set(onThisPage.map((b) => b.id));
for (const id of appliedIdsRef.current) {
if (!wantedIds.has(id)) {
removeBookmarkMarks(id);
appliedIdsRef.current.delete(id);
}
}
const next: Marker[] = [];
for (const bookmark of onThisPage) {
const hl = hlOf(bookmark.source_url);
if (!hl) continue;
const fragment = decodeTextFragment(hl);
if (!fragment) continue;
// Wrap text in <mark> if not already wrapped. Idempotent.
const marks = markFragmentAsBookmark(fragment, bookmark.id);
if (marks.length === 0) continue;
appliedIdsRef.current.add(bookmark.id);
// Anchor the floating icon to the first mark's bounding rect.
const rect = marks[0].getBoundingClientRect();
if (rect.width === 0 && rect.height === 0) continue;
next.push({
bookmark,
top: rect.top + window.scrollY,
left: rect.left + window.scrollX,
});
}
setMarkers(next);
}, [onThisPage]);
// Run the apply/measure cycle when this page's bookmarks change. Some
// pages stream content in (the verses page), so we retry a few times to
// catch text nodes added shortly after mount.
useEffect(() => {
if (onThisPage.length === 0) {
// No bookmarks for this page — strip any leftover marks (e.g. from
// a stale render before list rehydrated) and stop.
removeBookmarkMarks();
appliedIdsRef.current.clear();
setMarkers([]);
return;
}
let attempts = 0;
let timer: ReturnType<typeof setTimeout> | null = null;
const tick = () => {
applyAndMeasure();
attempts++;
if (attempts < 6) timer = setTimeout(tick, 300);
};
tick();
window.addEventListener("resize", applyAndMeasure);
return () => {
if (timer) clearTimeout(timer);
window.removeEventListener("resize", applyAndMeasure);
};
}, [onThisPage, applyAndMeasure]);
// Belt-and-braces: when this component unmounts (route change), tear
// down every mark so the next page starts clean.
useEffect(() => {
return () => {
removeBookmarkMarks();
appliedIdsRef.current.clear();
};
}, []);
async function remove(id: string) {
const previous = list;
setList((rows) => rows.filter((b) => b.id !== id));
// Yank the in-text mark immediately for snappy feedback. The
// applyAndMeasure effect would also catch this, but we don't want a
// visible flash of the still-highlighted text while it runs.
removeBookmarkMarks(id);
appliedIdsRef.current.delete(id);
try {
await bookmarksApi.delete(id);
toast.success("Bookmark removed");
} catch {
setList(previous);
toast.error("Could not remove bookmark");
}
}
if (typeof document === "undefined") return null;
if (markers.length === 0) return null;
return createPortal(
<>
{markers.map((m) => (
<div
key={m.bookmark.id}
style={{
position: "absolute",
// top = top-of-text minus pill height (~26) minus tail (6) minus
// a 4px breathing gap so the tail tip floats just above the
// highlighted line.
top: m.top - 36,
left: m.left,
zIndex: 40,
}}
className="pointer-events-none"
>
<div className="pointer-events-auto relative inline-flex items-center gap-1 rounded-full bg-violet-500 shadow-lg px-2.5 py-1 animate-in fade-in-0 zoom-in-95">
<Bookmark className="size-3.5 text-white" />
<span className="text-[0.65rem] uppercase tracking-[0.18em] font-semibold text-white">
Bookmarked
</span>
<button
type="button"
onClick={() => remove(m.bookmark.id)}
aria-label="Remove bookmark"
className="size-5 ml-0.5 inline-flex items-center justify-center rounded-full text-white/80 hover:bg-white/20 hover:text-white transition-colors"
>
<X className="size-3" />
</button>
{/* Teardrop pointer to the text below. CSS triangle made with
borders: transparent sides + violet top create a down-arrow
that visually connects the pill to its highlighted passage. */}
<span
aria-hidden
className="absolute left-3 top-full size-0 border-x-[5px] border-x-transparent border-t-[6px] border-t-violet-500"
/>
</div>
</div>
))}
</>,
document.body,
);
}
// --- helpers ---
function pathOf(sourceURL: string): string {
const q = sourceURL.indexOf("?");
return q < 0 ? sourceURL : sourceURL.slice(0, q);
}
function hlOf(sourceURL: string): string | null {
const q = sourceURL.indexOf("?");
if (q < 0) return null;
const params = new URLSearchParams(sourceURL.slice(q + 1));
return params.get("hl");
}
+109
View File
@@ -0,0 +1,109 @@
"use client";
/**
* PostActionsFooter — the icon-button row at the bottom of a post card.
*
* Left cluster: Like · Comment · Reshare
* Right cluster: Save to Collection (folder-plus)
*
* Like / Comment / Reshare are placeholders for now — they fire a
* "coming soon" toast on click. Save to Collection mounts the existing
* CollectionsDrawer in `existingPostId` mode so the post is attached
* directly without creating a duplicate.
*
* Auth gating is handled per-action via `useAuthGate` — anonymous users
* see the in-place auth modal before the underlying action runs.
*/
import { useState } from "react";
import { FolderPlus, Heart, MessageCircle, Repeat2 } from "lucide-react";
import { toast } from "sonner";
import { cn } from "@/lib/utils";
import { useAuthGate } from "@/components/auth/auth-gate";
import { useMediaQuery } from "@/components/selection-toolbar/shared";
import { CollectionsDrawer } from "@/components/selection-toolbar/collections-drawer";
export function PostActionsFooter({ postID }: { postID: string }) {
const { requireAuth } = useAuthGate();
const isMobile = useMediaQuery("(max-width: 767px)");
const [collectionsOpen, setCollectionsOpen] = useState(false);
async function withAuth(fn: () => void | Promise<void>) {
if (!(await requireAuth())) return;
await fn();
}
return (
<>
<div className="flex items-center justify-between px-2">
<div className="flex items-center gap-1">
<ActionButton
label="Like"
onClick={() =>
withAuth(() => {
toast("Likes are coming soon", { duration: 2000 });
})
}
icon={<Heart className="size-5" />}
/>
<ActionButton
label="Comment"
onClick={() =>
withAuth(() => {
toast("Comments are coming soon", { duration: 2000 });
})
}
icon={<MessageCircle className="size-5" />}
/>
<ActionButton
label="Reshare"
onClick={() =>
withAuth(() => {
toast("Resharing is coming soon", { duration: 2000 });
})
}
icon={<Repeat2 className="size-5" />}
/>
</div>
<ActionButton
label="Save to collection"
onClick={() => withAuth(() => setCollectionsOpen(true))}
icon={<FolderPlus className="size-5" />}
/>
</div>
<CollectionsDrawer
open={collectionsOpen}
onClose={() => setCollectionsOpen(false)}
isMobile={isMobile}
existingPostId={postID}
/>
</>
);
}
function ActionButton({
label,
onClick,
icon,
}: {
label: string;
onClick: () => void;
icon: React.ReactNode;
}) {
return (
<button
type="button"
onClick={onClick}
aria-label={label}
title={label}
className={cn(
"size-10 inline-flex items-center justify-center rounded-full",
"text-muted-foreground hover:text-foreground hover:bg-muted",
"transition-colors",
)}
>
{icon}
</button>
);
}
+145
View File
@@ -0,0 +1,145 @@
"use client";
/**
* Helpers shared by PostGridCard and PostScrollCard.
*
* • postSourceHref — synthesizes `?hl=` if the stored URL lacks one
* • themeGradientStyle — picks one of four palette-aware gradients
* deterministically from a seed (post id) so a
* feed of image-less posts looks varied without
* shifting between renders.
*/
import type { Post } from "@/lib/social";
import { appendHighlight, hasHighlight } from "@/lib/text-highlight";
export function postSourceHref(post: Post): string {
return hasHighlight(post.source_url) || !post.caption
? post.source_url
: appendHighlight(post.source_url, post.caption);
}
// Quote-card gradients.
//
// Four CSS custom properties (`--quote-gradient-1` … 4) are defined per
// palette in `app/globals.css`. Each palette ships its own hand-tuned
// gradient set so the cards swap colour with the active theme:
//
// • Aged Parchment → mahogany binding + temple gold
// • Tulasī Garden → forest depths + amber edge
// • Yamunā Mist → river depths + dusk ochre
// • Ancient Manuscript → midnight leather + saffron
// • Nīlamaṇi → Kṛṣṇa's sapphire hour (deep blues)
// • Vraja Twilight → deep wine + rose dusk
//
// All variants are dark-leaning so `text-white` reads cleanly. The card
// picks one of the four deterministically from `post.id`.
const GRADIENT_VARS = [
"var(--quote-gradient-1)",
"var(--quote-gradient-2)",
"var(--quote-gradient-3)",
"var(--quote-gradient-4)",
];
/** Returns an inline-style object that applies the chosen gradient. The
* style approach (vs a className) lets us reference CSS custom properties
* that vary per palette — Tailwind can't generate utility classes for
* values that change at runtime via `:root` selectors. */
export function themeGradientStyle(seed: string): React.CSSProperties {
// Cheap, stable hash — same seed always picks the same gradient.
let h = 0;
for (let i = 0; i < seed.length; i++) h = (h * 31 + seed.charCodeAt(i)) >>> 0;
return { backgroundImage: GRADIENT_VARS[h % GRADIENT_VARS.length] };
}
/** Flat background for post cards without an image: pure white on every
* light palette, palette-specific darker contrast on dark palettes.
* Defined as `--quote-card-bg` per `:root` / `.dark` block in
* globals.css. Combine with `text-foreground` for legibility — both
* sides of the theme switch reverse together. */
export function quoteCardBgStyle(): React.CSSProperties {
return { backgroundColor: "var(--quote-card-bg)" };
}
// ───────────────────────────────────────────────────────────────────────────
// Variable quote sizing
// ───────────────────────────────────────────────────────────────────────────
//
// A pithy two-word quote and a full paragraph need very different
// treatments inside a fixed-size card. We pick a tier from the caption's
// character count:
//
// tier 1 (very short) — biggest font, dead-centre, no truncation needed
// tier 2 (short) — large font, centred, ~5-line clamp
// tier 3 (medium) — medium font, centred, ~6/7-line clamp
// tier 4 (long) — smallest font, anchored bottom-LEFT and left-
// aligned (a centred wall of small text would
// cover the entire card and look claustrophobic;
// bottom-left mimics the image variant's caption
// placement and breathes better).
//
// Two scales: "grid" for the small square card, "scroll" for the larger
// Instagram-style card.
export type QuoteSize = {
/** Tailwind font-size classes, e.g. "text-xl sm:text-2xl" */
fontClass: string;
/** Tailwind line-clamp class limiting overflow */
clampClass: string;
/** Where to anchor the text block inside its container */
alignment: "center" | "bottom-left";
};
export function quoteSize(text: string, variant: "grid" | "scroll"): QuoteSize {
const len = (text ?? "").trim().length;
if (variant === "grid") {
if (len <= 50)
return {
fontClass: "text-xl sm:text-2xl",
clampClass: "line-clamp-3",
alignment: "center",
};
if (len <= 150)
return {
fontClass: "text-base sm:text-lg",
clampClass: "line-clamp-5",
alignment: "center",
};
if (len <= 300)
return {
fontClass: "text-sm sm:text-base",
clampClass: "line-clamp-[7]",
alignment: "center",
};
return {
fontClass: "text-xs sm:text-sm",
clampClass: "line-clamp-[9]",
alignment: "bottom-left",
};
}
// scroll — bigger card, larger fonts at every tier, higher thresholds
// so paragraph-length quotes still get centred at a comfortable size.
if (len <= 60)
return {
fontClass: "text-3xl sm:text-4xl",
clampClass: "line-clamp-3",
alignment: "center",
};
if (len <= 180)
return {
fontClass: "text-2xl sm:text-3xl",
clampClass: "line-clamp-5",
alignment: "center",
};
if (len <= 380)
return {
fontClass: "text-lg sm:text-xl",
clampClass: "line-clamp-[8]",
alignment: "center",
};
return {
fontClass: "text-base sm:text-lg",
clampClass: "line-clamp-[10]",
alignment: "bottom-left",
};
}
+101
View File
@@ -0,0 +1,101 @@
"use client";
/**
* PostCard — renders one post in the public feed and the /u/me feed.
*
* • Caption in serif (locked, can't be edited)
* • Image grid (max 5; collapses to one column on the cheap)
* • "Source" link back to the page the quote came from
* • Optional onDelete callback shown only when the owner is viewing
*
* The source link always lands the user on the original passage with `?hl=`
* highlighting. If the stored source_url already carries `?hl=` we use it
* verbatim; otherwise we synthesise one from the post's caption so older
* posts (or imported data) still scroll to + highlight the text on click.
*/
import Image from "next/image";
import Link from "next/link";
import { Trash2, ExternalLink } from "lucide-react";
import type { Post } from "@/lib/social";
import { displaySourceLabel } from "@/lib/source-reference";
import { appendHighlight, hasHighlight } from "@/lib/text-highlight";
import { PostActionsFooter } from "./post-actions-footer";
export function PostCard({
post,
onDelete,
}: {
post: Post;
onDelete?: (id: string) => void;
}) {
const sourceHref =
hasHighlight(post.source_url) || !post.caption
? post.source_url
: appendHighlight(post.source_url, post.caption);
return (
<article className="rounded-xl bg-card ring-1 ring-foreground/10 p-5">
<p className="font-[family-name:var(--font-title)] text-lg text-foreground leading-relaxed">
“{post.caption}”
</p>
{post.images && post.images.length > 0 ? (
<div
className={`mt-4 grid gap-2 ${
post.images.length === 1
? "grid-cols-1"
: post.images.length === 2
? "grid-cols-2"
: "grid-cols-3"
}`}
>
{post.images.map((img) => (
<div
key={img.id}
className="relative aspect-square rounded-lg overflow-hidden bg-muted ring-1 ring-foreground/10"
>
<Image
src={img.url}
alt=""
fill
sizes="(max-width: 640px) 30vw, 200px"
className="object-cover"
unoptimized
/>
</div>
))}
</div>
) : null}
<div className="mt-4 flex items-center justify-between text-xs text-muted-foreground">
<Link
href={sourceHref}
className="inline-flex items-center gap-1 hover:text-foreground transition-colors"
>
<ExternalLink className="size-3" />
{displaySourceLabel(post.source_url, post.source_title)}
</Link>
<div className="flex items-center gap-3">
<span>{new Date(post.created_at).toLocaleDateString()}</span>
{onDelete ? (
<button
type="button"
onClick={() => onDelete(post.id)}
className="inline-flex items-center gap-1 text-muted-foreground hover:text-destructive transition-colors"
aria-label="Delete post"
>
<Trash2 className="size-3.5" />
</button>
) : null}
</div>
</div>
{/* Action footer — like / comment / reshare on the left, add to
collection on the right. */}
<div className="mt-3 pt-2 border-t border-border">
<PostActionsFooter postID={post.id} />
</div>
</article>
);
}
+158
View File
@@ -0,0 +1,158 @@
"use client";
/**
* PostGridCard — square card used in the 2-per-row grid view.
*
* • With image: first attached image fills the card; a bottom-up dark
* gradient makes the caption legible without dropping the image's
* atmosphere.
* • Without image: a palette-aware Tailwind gradient stands in; the
* caption sits as a serif quote bottom-left in the foreground colour.
*
* `onDelete` shows a small X in the corner for owner views. Used both for
* actual post deletion (on /u/me) and for removing from a collection
* (on /u/me/collections/[id]); the parent decides which.
*/
import { useState } from "react";
import Image from "next/image";
import Link from "next/link";
import { ImageIcon, X } from "lucide-react";
import { cn } from "@/lib/utils";
import type { Post } from "@/lib/social";
import { displaySourceLabel } from "@/lib/source-reference";
import { useMediaQuery } from "@/components/selection-toolbar/shared";
import { postSourceHref, quoteCardBgStyle, quoteSize } from "./post-card-shared";
import { UpdateBackgroundModal } from "./update-background-modal";
export function PostGridCard({
post,
onDelete,
onUpdated,
href: hrefProp,
}: {
post: Post;
onDelete?: (id: string) => void;
/** Owner-only: shown when defined so the user can swap/remove the
* card's background image. */
onUpdated?: (updated: Post) => void;
/** Override the click target. Defaults to the source-passage URL.
* Profile grids pass a `/u/<handle>/posts#post-<id>` anchor so the
* thumbnail jumps to the Instagram-style scroll feed instead of the
* knowledgebase page. */
href?: string;
}) {
const isMobile = useMediaQuery("(max-width: 767px)");
const [bgModal, setBgModal] = useState(false);
const firstImage = post.images?.[0]?.url;
const href = hrefProp ?? postSourceHref(post);
const size = quoteSize(post.caption, "grid");
// Image variant always anchors text bottom-left over the image's dark
// overlay. No-image variant lets `size.alignment` decide — short quotes
// sit centred so the gradient frames them; long quotes fall back to
// bottom-left so they don't cover the whole card.
const bottomLeft = !!firstImage || size.alignment === "bottom-left";
return (
<div className="relative group select-none">
<Link
href={href}
className={cn(
"block aspect-square rounded-2xl overflow-hidden ring-1 ring-foreground/10",
"hover:ring-foreground/25 hover:shadow-md transition-all relative",
)}
>
{firstImage ? (
<>
<Image
src={firstImage}
alt=""
fill
sizes="(max-width: 640px) 50vw, 320px"
className="object-cover"
unoptimized
/>
{/* Bottom-up gradient for caption legibility — keeps the image
visible up top while text sits over solid darkness below. */}
<div className="absolute inset-0 bg-gradient-to-t from-black/80 via-black/40 to-transparent" />
</>
) : (
<div className="absolute inset-0" style={quoteCardBgStyle()} />
)}
<div
className={cn(
"absolute inset-0 flex flex-col p-4 sm:p-5",
// Text color: white on the dark image overlay, theme
// foreground on the flat quote-card background.
firstImage ? "text-white" : "text-foreground",
bottomLeft
? "justify-end items-start text-left"
: "justify-center items-center text-center",
)}
>
<p
className={cn(
"font-[family-name:var(--font-title)] leading-snug break-words",
size.fontClass,
size.clampClass,
// Drop-shadow only helps when text sits on imagery; on the
// flat bg it just blurs the type.
firstImage && "drop-shadow-sm",
// Centred quotes need a soft horizontal cap so a long line
// doesn't kiss the card edge — looks better with margins.
!bottomLeft ? "max-w-[90%]" : "w-full",
)}
>
“{post.caption}”
</p>
<p
className={cn(
"mt-2 text-[0.65rem] uppercase tracking-[0.2em] font-medium truncate",
firstImage ? "text-white/80" : "text-muted-foreground",
bottomLeft ? "w-full" : "max-w-[90%]",
)}
>
{displaySourceLabel(post.source_url, post.source_title)}
</p>
</div>
</Link>
{onUpdated || onDelete ? (
<div className="absolute top-2 right-2 flex items-center gap-1.5 opacity-0 group-hover:opacity-100 focus-within:opacity-100 transition-opacity">
{onUpdated ? (
<button
type="button"
onClick={() => setBgModal(true)}
aria-label="Update background"
title="Update background"
className="size-8 rounded-full inline-flex items-center justify-center bg-black/40 text-white hover:bg-black/60 transition-colors"
>
<ImageIcon className="size-4" />
</button>
) : null}
{onDelete ? (
<button
type="button"
onClick={() => onDelete(post.id)}
aria-label="Remove"
className="size-8 rounded-full inline-flex items-center justify-center bg-black/40 text-white hover:bg-black/60 transition-colors"
>
<X className="size-4" />
</button>
) : null}
</div>
) : null}
{onUpdated ? (
<UpdateBackgroundModal
open={bgModal}
onClose={() => setBgModal(false)}
post={post}
isMobile={isMobile}
onUpdated={onUpdated}
/>
) : null}
</div>
);
}
+329
View File
@@ -0,0 +1,329 @@
"use client";
/**
* PostScrollCard — Instagram-style post used on the scroll feed.
*
* Layout:
*
* ┌───────────────────────────────────────┐
* │ ◯ name @handle ⋮ │ ← author header (TBD)
* ├───────────────────────────────────────┤
* │ │
* │ image carousel │ ← square, swipeable
* │ (or quote) │ (no-image variant
* │ │ fills with gradient
* │ • ○ ○ ○ │ panel + serif quote)
* ├───────────────────────────────────────┤
* │ "Quoted passage shown as caption…" │ ← truncates with "more"
* │ ŚRĪMAD-BHĀGAVATAM — CANTO 4, CH 5 │ ← citation
* └───────────────────────────────────────┘
*
* • Multi-image carousel uses native CSS scroll-snap with dot indicators
* below; the same primitive CollectionScrollCard uses for posts.
* • Long captions clamp to 3 lines until the user expands via "more".
* • Owner action cluster (Update background / Delete) sits in the
* top-right of the media area, fading in on hover (desktop) and
* always visible on touch (mobile).
*/
import { useState } from "react";
import Image from "next/image";
import Link from "next/link";
import { ImageIcon, Trash2 } from "lucide-react";
import { cn } from "@/lib/utils";
import type { Post, PostImage } from "@/lib/social";
import { displaySourceLabel } from "@/lib/source-reference";
import { useMediaQuery } from "@/components/selection-toolbar/shared";
import { postSourceHref, quoteCardBgStyle } from "./post-card-shared";
import { PostActionsFooter } from "./post-actions-footer";
import { UpdateBackgroundModal } from "./update-background-modal";
export function PostScrollCard({
post,
onDelete,
onUpdated,
}: {
post: Post;
onDelete?: (id: string) => void;
onUpdated?: (updated: Post) => void;
}) {
const images = post.images ?? [];
const hasImages = images.length > 0;
const sourceHref = postSourceHref(post);
const isMobile = useMediaQuery("(max-width: 767px)");
const [bgModal, setBgModal] = useState(false);
const [expanded, setExpanded] = useState(false);
const ownerCluster =
onUpdated || onDelete ? (
<div className="absolute top-3 right-3 z-10 flex items-center gap-1.5">
{onUpdated ? (
<OwnerButton
label="Update background"
onClick={() => setBgModal(true)}
icon={<ImageIcon className="size-4" />}
/>
) : null}
{onDelete ? (
<OwnerButton
label="Remove"
onClick={() => onDelete(post.id)}
icon={<Trash2 className="size-4" />}
/>
) : null}
</div>
) : null;
return (
<article
id={`post-${post.id}`}
className="rounded-2xl overflow-hidden bg-card ring-1 ring-foreground/10 scroll-mt-20 sm:scroll-mt-24"
>
{/* Author header */}
<header className="flex items-center gap-3 px-4 py-3">
<AuthorAvatar
src={post.author_avatar_url ?? null}
name={post.author_name ?? "Unknown"}
/>
<div className="min-w-0">
<Link
href={
post.author_handle ? `/u/${post.author_handle}` : "#"
}
className="block text-sm font-semibold text-foreground truncate hover:underline"
>
{post.author_name || "Unknown"}
</Link>
{post.author_handle ? (
<span className="block text-xs text-muted-foreground truncate">
@{post.author_handle}
</span>
) : null}
</div>
</header>
{/* Media — image carousel or gradient-with-quote */}
<div className="relative">
{hasImages ? (
<ImageCarousel images={images} />
) : (
<QuotePanel post={post} />
)}
{ownerCluster}
</div>
{/* Caption + citation — reference sits directly under the media
so it's read first, with the action row below it. */}
<div className="px-4 pt-4 pb-3 space-y-2">
{hasImages ? (
// When images carry the visual, the caption sits as text below.
<Caption
text={post.caption}
expanded={expanded}
onToggle={() => setExpanded((v) => !v)}
/>
) : null}
<Link
href={sourceHref}
className="block max-w-full truncate text-[0.7rem] uppercase tracking-[0.2em] font-medium text-muted-foreground hover:text-foreground transition-colors"
>
{displaySourceLabel(post.source_url, post.source_title)} →
</Link>
</div>
{/* Action footer — like / comment / reshare on the left, add to
collection on the right. */}
<div className="pt-1 pb-2 border-t border-border">
<PostActionsFooter postID={post.id} />
</div>
{onUpdated ? (
<UpdateBackgroundModal
open={bgModal}
onClose={() => setBgModal(false)}
post={post}
isMobile={isMobile}
onUpdated={onUpdated}
/>
) : null}
</article>
);
}
// ─── Pieces ──────────────────────────────────────────────────────────────
function ImageCarousel({ images }: { images: PostImage[] }) {
const [active, setActive] = useState(0);
function onScroll(e: React.UIEvent<HTMLDivElement>) {
const el = e.currentTarget;
const w = el.offsetWidth;
if (w === 0) return;
setActive(Math.round(el.scrollLeft / w));
}
if (images.length === 1) {
return (
<div className="relative aspect-square bg-muted">
<Image
src={images[0].url}
alt=""
fill
sizes="(max-width: 768px) 100vw, 640px"
className="object-cover"
unoptimized
/>
</div>
);
}
return (
<div className="relative">
<div
onScroll={onScroll}
className={cn(
"flex overflow-x-auto snap-x snap-mandatory",
"[scrollbar-width:none] [&::-webkit-scrollbar]:hidden",
)}
>
{images.map((img, i) => (
<div
key={img.id}
className="relative w-full shrink-0 snap-start aspect-square bg-muted"
aria-roledescription="slide"
aria-label={`${i + 1} of ${images.length}`}
>
<Image
src={img.url}
alt=""
fill
sizes="(max-width: 768px) 100vw, 640px"
className="object-cover"
unoptimized
/>
</div>
))}
</div>
<div className="absolute inset-x-0 bottom-3 flex items-center justify-center gap-1.5 pointer-events-none">
{images.map((_, i) => (
<span
key={i}
aria-hidden
className={cn(
"size-1.5 rounded-full transition-all",
i === active ? "bg-white" : "bg-white/50",
)}
/>
))}
</div>
</div>
);
}
function QuotePanel({ post }: { post: Post }) {
// No-image post: the entire square is a flat palette-aware surface
// (pure white on light themes, palette-specific dark on dark themes)
// with the quote in serif inside. Text uses --foreground so it inverts
// automatically with the theme.
return (
<div
className="relative aspect-square overflow-hidden select-none"
style={quoteCardBgStyle()}
>
<div className="absolute inset-0 flex flex-col justify-center items-center p-8 sm:p-10 text-center text-foreground">
<p className="font-[family-name:var(--font-title)] text-xl sm:text-2xl leading-relaxed max-w-md line-clamp-[8] break-words">
“{post.caption}”
</p>
</div>
</div>
);
}
function Caption({
text,
expanded,
onToggle,
}: {
text: string;
expanded: boolean;
onToggle: () => void;
}) {
// Show 3 lines collapsed. The "more" toggle appears only when the text
// is long enough to warrant it (a cheap char-length check beats a
// measure-and-compare pass for our purposes — captions over ~180 chars
// will always overflow 3 lines at the rendered font size).
const longEnough = text.length > 180;
return (
<div className="text-sm text-foreground leading-relaxed">
<p
className={cn(
"font-[family-name:var(--font-title)] break-words",
!expanded && longEnough && "line-clamp-3",
)}
>
“{text}”
</p>
{longEnough ? (
<button
type="button"
onClick={onToggle}
className="mt-1 text-xs font-medium text-muted-foreground hover:text-foreground transition-colors"
>
{expanded ? "Show less" : "More"}
</button>
) : null}
</div>
);
}
function AuthorAvatar({
src,
name,
}: {
src: string | null;
name: string;
}) {
if (src) {
return (
<Image
src={src}
alt={name}
width={36}
height={36}
className="size-9 rounded-full object-cover ring-1 ring-foreground/10"
unoptimized
/>
);
}
const initial = name.charAt(0).toUpperCase() || "?";
return (
<div className="size-9 rounded-full bg-primary/15 text-primary flex items-center justify-center text-sm font-semibold ring-1 ring-foreground/10">
{initial}
</div>
);
}
function OwnerButton({
label,
onClick,
icon,
}: {
label: string;
onClick: () => void;
icon: React.ReactNode;
}) {
return (
<button
type="button"
onClick={onClick}
aria-label={label}
title={label}
className={cn(
"size-9 rounded-full inline-flex items-center justify-center",
"bg-black/40 text-white hover:bg-black/60 transition-colors backdrop-blur-sm",
)}
>
{icon}
</button>
);
}
+52
View File
@@ -0,0 +1,52 @@
"use client";
/**
* PostViews — grid-only renderer for post lists.
*
* Renders a 3-up square grid (Instagram pattern). The previous
* Grid/Scroll tab toggle is gone — the scroll feed lives at
* /u/<handle>/posts and is reached by clicking a grid thumbnail
* (which sets `?hl=` and a hash anchor).
*
* Callers pass `hrefFor(post)` if they want each thumbnail to link to a
* specific URL (e.g. the scroll feed). When omitted, each card defaults
* to its source-passage URL.
*/
import type { Post } from "@/lib/social";
import { PostGridCard } from "./post-grid-card";
export function PostViews({
posts,
onDelete,
onUpdated,
emptyMessage,
hrefFor,
}: {
posts: Post[];
onDelete?: (id: string) => void;
onUpdated?: (updated: Post) => void;
emptyMessage?: string;
hrefFor?: (post: Post) => string;
}) {
if (posts.length === 0) {
return (
<p className="text-sm text-muted-foreground">
{emptyMessage ?? "Nothing here yet."}
</p>
);
}
return (
<div className="grid grid-cols-2 gap-2 sm:gap-3">
{posts.map((p) => (
<PostGridCard
key={p.id}
post={p}
onDelete={onDelete}
onUpdated={onUpdated}
href={hrefFor?.(p)}
/>
))}
</div>
);
}
@@ -0,0 +1,240 @@
"use client";
/**
* UpdateBackgroundModal — swap or remove the image behind a post card.
*
* Two actions:
* • Upload Image — pick a file → signed-URL upload → PUT to BFF
* → returns the refreshed post.
* • Remove Background — PUT empty image set → post falls back to the
* palette-aware quote-card gradient.
*
* Renders as a centred modal on desktop and a bottom drawer on mobile,
* mirroring the pattern used by AuthModal / ConfirmModal so the UX stays
* consistent across the app.
*/
import { useEffect, useRef, useState } from "react";
import { createPortal } from "react-dom";
import { ImageIcon, Loader2, Trash2, Upload, X } from "lucide-react";
import { toast } from "sonner";
import {
Drawer,
DrawerClose,
DrawerContent,
DrawerHeader,
DrawerTitle,
} from "@/components/ui/drawer";
import { posts, SocialError, type Post } from "@/lib/social";
type Busy = "upload" | "remove" | null;
export function UpdateBackgroundModal({
open,
onClose,
post,
isMobile,
onUpdated,
}: {
open: boolean;
onClose: () => void;
post: Post;
isMobile: boolean;
/** Fires with the refreshed post after a successful upload or removal. */
onUpdated?: (updated: Post) => void;
}) {
const fileInput = useRef<HTMLInputElement>(null);
const [busy, setBusy] = useState<Busy>(null);
const hasImage = (post.images?.length ?? 0) > 0;
// Body-scroll lock + Esc-to-close — desktop modal only. The drawer
// handles both internally.
useEffect(() => {
if (!open || isMobile) return;
const prevOverflow = document.body.style.overflow;
document.body.style.overflow = "hidden";
function onKey(e: KeyboardEvent) {
if (e.key === "Escape" && busy === null) onClose();
}
document.addEventListener("keydown", onKey);
return () => {
document.body.style.overflow = prevOverflow;
document.removeEventListener("keydown", onKey);
};
}, [open, isMobile, busy, onClose]);
async function onUpload(e: React.ChangeEvent<HTMLInputElement>) {
const file = e.target.files?.[0];
if (fileInput.current) fileInput.current.value = "";
if (!file) return;
setBusy("upload");
try {
const url = await posts.uploadImage(file);
const updated = await posts.replaceImages(post.id, [url]);
toast.success("Background updated");
onUpdated?.(updated);
onClose();
} catch (err) {
toast.error(
err instanceof SocialError ? err.message : "Could not update background.",
);
} finally {
setBusy(null);
}
}
async function onRemove() {
setBusy("remove");
try {
const updated = await posts.replaceImages(post.id, []);
toast.success("Background removed");
onUpdated?.(updated);
onClose();
} catch (err) {
toast.error(
err instanceof SocialError ? err.message : "Could not remove background.",
);
} finally {
setBusy(null);
}
}
if (!open) return null;
const body = (
<div className="space-y-2">
<input
ref={fileInput}
type="file"
accept="image/*"
hidden
onChange={onUpload}
/>
<button
type="button"
onClick={() => fileInput.current?.click()}
disabled={busy !== null}
className="w-full flex items-center gap-3 p-3 rounded-lg border border-border hover:bg-accent transition-colors text-left disabled:opacity-60"
>
<span className="flex items-center justify-center size-10 rounded-lg bg-primary/10 text-primary shrink-0">
{busy === "upload" ? (
<Loader2 className="size-5 animate-spin" />
) : (
<Upload className="size-5" />
)}
</span>
<span className="min-w-0 flex-1">
<span className="block text-sm font-medium text-foreground">
{hasImage ? "Replace Image" : "Upload Image"}
</span>
<span className="block text-xs text-muted-foreground">
JPEG or PNG, recommended ≥ 1080×1080
</span>
</span>
</button>
{hasImage ? (
<button
type="button"
onClick={onRemove}
disabled={busy !== null}
className="w-full flex items-center gap-3 p-3 rounded-lg border border-border hover:bg-destructive/5 transition-colors text-left disabled:opacity-60"
>
<span className="flex items-center justify-center size-10 rounded-lg bg-destructive/10 text-destructive shrink-0">
{busy === "remove" ? (
<Loader2 className="size-5 animate-spin" />
) : (
<Trash2 className="size-5" />
)}
</span>
<span className="min-w-0 flex-1">
<span className="block text-sm font-medium text-foreground">
Remove Background
</span>
<span className="block text-xs text-muted-foreground">
Falls back to a theme-coloured gradient
</span>
</span>
</button>
) : null}
</div>
);
if (isMobile) {
return (
<Drawer open={open} onOpenChange={(o) => !o && busy === null && onClose()}>
<DrawerContent>
<DrawerHeader className="text-left">
<DrawerTitle>Update Background</DrawerTitle>
</DrawerHeader>
<div className="px-4 pb-4">{body}</div>
<div className="p-4 pt-0">
<DrawerClose asChild>
<button
disabled={busy !== null}
className="w-full py-2.5 rounded-lg border border-border text-sm font-medium text-muted-foreground hover:bg-accent disabled:opacity-50"
>
Cancel
</button>
</DrawerClose>
</div>
</DrawerContent>
</Drawer>
);
}
// Desktop: centred modal with portal + backdrop, same pattern as
// ConfirmModal/AuthModal so behaviour stays consistent.
if (typeof document === "undefined") return null;
return createPortal(
<>
<div
className="fixed inset-0 z-100 bg-foreground/40 backdrop-blur-sm animate-in fade-in-0"
onClick={() => busy === null && onClose()}
aria-hidden
/>
<div
role="dialog"
aria-modal="true"
aria-labelledby="update-bg-title"
className="fixed left-1/2 top-1/2 z-101 w-full max-w-md -translate-x-1/2 -translate-y-1/2 px-4"
>
<div className="rounded-2xl border border-border bg-card shadow-xl overflow-hidden">
<div className="flex items-start gap-3 px-5 pt-5 pb-3">
<span
className="shrink-0 size-10 rounded-full bg-primary/10 text-primary flex items-center justify-center"
aria-hidden
>
<ImageIcon className="size-5" />
</span>
<div className="flex-1 min-w-0">
<h2
id="update-bg-title"
className="font-[family-name:var(--font-title)] font-semibold text-lg text-foreground"
>
Update Background
</h2>
<p className="mt-1 text-sm text-muted-foreground leading-relaxed">
Pick an image to set as the card's background, or remove the
current one.
</p>
</div>
<button
type="button"
onClick={() => busy === null && onClose()}
aria-label="Close"
disabled={busy !== null}
className="shrink-0 size-8 rounded-lg text-muted-foreground hover:text-foreground hover:bg-muted inline-flex items-center justify-center disabled:opacity-50"
>
<X className="size-4" />
</button>
</div>
<div className="px-5 pb-5">{body}</div>
</div>
</div>
</>,
document.body,
);
}
+293 -66
View File
@@ -1,8 +1,23 @@
"use client";
import { useState, useEffect, useRef, useCallback } from "react";
import { Share2, Copy, MoreHorizontal, Check } from "lucide-react";
import { formatCopyText } from "@/lib/source-reference";
import {
AtSign,
Bookmark as BookmarkIcon,
Copy,
FolderPlus,
Link as LinkIcon,
MoreHorizontal,
Send,
Share2,
} from "lucide-react";
import { toast } from "sonner";
import { formatCopyText, getSourceReference } from "@/lib/source-reference";
import {
buildHighlightUrl,
buildShortHighlightUrl,
} from "@/lib/text-highlight";
import { bookmarks, SocialError } from "@/lib/social";
import {
KautilyaIcon,
useMediaQuery,
@@ -11,9 +26,11 @@ import {
type ToolbarAction,
} from "./selection-toolbar/shared";
import { FloatingToolbar } from "./selection-toolbar/floating-toolbar";
import { ShareModal } from "./selection-toolbar/share-modal";
import { CollectionsDrawer } from "./selection-toolbar/collections-drawer";
import { MorePopover } from "./selection-toolbar/more-popover";
import { MorePopover, type MoreItem } from "./selection-toolbar/more-popover";
import { ComposePost } from "./selection-toolbar/compose-post";
import { useAuth } from "./auth/auth-provider";
import { useAuthGate } from "./auth/auth-gate";
// ---------------------------------------------------------------------------
// Main Component
@@ -24,10 +41,45 @@ export function TextSelectionToolbar() {
const [toolbarPos, setToolbarPos] = useState<Position | null>(null);
const [showMore, setShowMore] = useState(false);
const [morePos, setMorePos] = useState<Position>({ x: 0, y: 0 });
const [showShare, setShowShare] = useState(false);
const [showCollections, setShowCollections] = useState(false);
const { copied, copy } = useCopyFeedback();
const [showCompose, setShowCompose] = useState(false);
const { copy } = useCopyFeedback();
const isMobile = useMediaQuery("(max-width: 767px)");
const { requireAuth } = useAuthGate();
const { user } = useAuth();
// Cached bookmark count → drives the "Bookmark" / "Collect" swap.
// We fetch on auth state change and react to the toolbar's own
// `spsays:bookmarks-changed` event so the label flips immediately
// after saving the user's last allowed bookmark.
const [bookmarkCount, setBookmarkCount] = useState(0);
useEffect(() => {
if (!user) {
setBookmarkCount(0);
return;
}
let cancelled = false;
const refetch = () => {
bookmarks
.list()
.then((rows) => {
if (!cancelled) setBookmarkCount(rows?.length ?? 0);
})
.catch(() => {
/* non-essential — swallow */
});
};
refetch();
const onChanged = () => refetch();
window.addEventListener("spsays:bookmarks-changed", onChanged);
return () => {
cancelled = true;
window.removeEventListener("spsays:bookmarks-changed", onChanged);
};
}, [user]);
const bookmarkLimit = user?.plan === "premium" ? 5 : 1;
const limitReached = !!user && bookmarkCount >= bookmarkLimit;
// Track whether the user is interacting with our toolbar/popovers
const interactingRef = useRef(false);
@@ -66,7 +118,12 @@ export function TextSelectionToolbar() {
useEffect(() => {
function handleSelectionChange() {
// Don't dismiss while interacting with toolbar menus
if (interactingRef.current || showMore || showShare || showCollections) {
if (
interactingRef.current ||
showMore ||
showCollections ||
showCompose
) {
return;
}
@@ -186,7 +243,7 @@ export function TextSelectionToolbar() {
document.removeEventListener("touchstart", onTouchStart);
document.removeEventListener("keyup", onKeyUp);
};
}, [clearSelection, showMore, showShare, showCollections]);
}, [clearSelection, showMore, showCollections, showCompose]);
// Intercept native Ctrl+C / Cmd+C to append source reference
useEffect(() => {
@@ -230,7 +287,7 @@ export function TextSelectionToolbar() {
const target = e.target as Element;
if (target.closest("[data-selection-toolbar]")) return;
if (showMore || showShare || showCollections) return;
if (showMore || showCollections || showCompose) return;
const sel = window.getSelection();
const text = sel?.toString().trim() ?? "";
@@ -241,7 +298,7 @@ export function TextSelectionToolbar() {
document.addEventListener("pointerdown", handlePointerDown);
return () => document.removeEventListener("pointerdown", handlePointerDown);
}, [clearSelection, showMore, showShare, showCollections]);
}, [clearSelection, showMore, showCollections, showCompose]);
// Don't render on RTF viewer or if nothing is selected
if (
@@ -253,60 +310,245 @@ export function TextSelectionToolbar() {
if (!toolbarPos || !selectedText) return null;
// --- Toolbar actions ---
// ─── Action handlers ──────────────────────────────────────────────────
const toolbarActions: ToolbarAction[] = [
function openCollectionsDrawer() {
if (isMobile) saveAndClearSelection();
else interactingRef.current = true;
setShowCollections(true);
}
async function onPost() {
if (!(await requireAuth())) return;
if (isMobile) saveAndClearSelection();
else interactingRef.current = true;
setShowCompose(true);
}
async function onBookmarkOrCollect() {
// Fast path: already at cap → reuse this click to open Save to
// Collection. The label is "Collect" in this state so the user knows.
if (limitReached) {
openCollectionsDrawer();
return;
}
if (!(await requireAuth())) return;
try {
const sourceURL = buildHighlightUrl(selectedText);
const ref = getSourceReference();
const sourceTitle = ref
? ref.detail
? `${ref.title} — ${ref.detail}`
: ref.title
: typeof document !== "undefined"
? document.title
: "";
await bookmarks.create({
source_url: sourceURL,
source_title: sourceTitle,
excerpt: selectedText,
});
toast.success("Bookmark saved", { duration: 2000 });
window.dispatchEvent(new CustomEvent("spsays:bookmarks-changed"));
clearSelection();
} catch (err) {
if (err instanceof SocialError && err.status === 402) {
toast.error("Bookmark limit reached", {
description:
"Free plan allows 1 bookmark. We'll save this to a collection instead.",
duration: 6000,
});
openCollectionsDrawer();
} else if (err instanceof SocialError) {
toast.error(err.message);
} else {
toast.error("Could not save bookmark.");
}
}
}
async function onCopyText() {
await copy(formatCopyText(selectedText));
toast.success("Copied to clipboard", { duration: 1500 });
clearSelection();
}
async function onCopyLink() {
const url = await buildShortHighlightUrl(selectedText);
await copy(url);
toast.success("Link copied", { duration: 1500 });
clearSelection();
}
async function onSaveToCollection() {
setShowMore(false);
if (!(await requireAuth())) return;
openCollectionsDrawer();
}
// ─── Toolbar actions ──────────────────────────────────────────────────
//
// Authed users get action-oriented top-level buttons (Post / Bookmark)
// and More holds copy/share. Public users get the universally-usable
// copy/share at the top and every account-requiring action sits behind
// a lock in More.
const isAuthed = !!user;
const askAction: ToolbarAction = {
key: "ask",
label: "Ask Kautilya",
icon: <KautilyaIcon className="size-3.5" />,
onClick: () => {
// TODO: Implement Ask Kautilya
clearSelection();
},
className: "text-amber-400 hover:bg-amber-400/15 hover:text-amber-300",
};
const moreAction: ToolbarAction = {
key: "more",
label: "More",
icon: <MoreHorizontal className="size-3.5" />,
onClick: () => {
if (isMobile) saveAndClearSelection();
else interactingRef.current = true;
setMorePos({
x: toolbarPos.x + 60,
y: toolbarPos.y,
});
setShowMore(true);
},
};
const authedToolbarActions: ToolbarAction[] = [
askAction,
{
key: "ask",
label: "Ask Kautilya",
icon: <KautilyaIcon className="size-3.5" />,
onClick: () => {
// TODO: Implement Ask Kautilya
clearSelection();
},
className: "text-amber-400 hover:bg-amber-400/15 hover:text-amber-300",
key: "post",
label: "Post",
icon: <Send className="size-3.5" />,
onClick: onPost,
},
{
key: "bookmark",
label: limitReached ? "Collect" : "Bookmark",
icon: limitReached ? (
<FolderPlus className="size-3.5" />
) : (
<BookmarkIcon className="size-3.5" />
),
onClick: onBookmarkOrCollect,
},
moreAction,
];
const publicToolbarActions: ToolbarAction[] = [
askAction,
{
key: "copy",
label: "Copy",
icon: <Copy className="size-3.5" />,
onClick: onCopyText,
},
{
key: "share",
label: "Share",
icon: <Share2 className="size-3.5" />,
onClick: () => {
if (isMobile) saveAndClearSelection();
else interactingRef.current = true;
setShowShare(true);
onClick: onCopyLink,
},
moreAction,
];
const toolbarActions = isAuthed ? authedToolbarActions : publicToolbarActions;
const authedMoreItems: MoreItem[] = [
{
key: "copy-text",
label: "Copy Text",
description: "Passage with source citation",
icon: <Copy className="size-5" />,
onClick: async () => {
setShowMore(false);
await onCopyText();
},
},
{
key: "copy",
label: copied ? "Copied!" : "Copy",
icon: copied ? (
<Check className="size-3.5" />
) : (
<Copy className="size-3.5" />
),
onClick: () => {
copy(formatCopyText(selectedText));
key: "copy-link",
label: "Copy Link",
description: "Short URL that highlights this passage",
icon: <LinkIcon className="size-5" />,
onClick: async () => {
setShowMore(false);
await onCopyLink();
},
className: copied
? "text-green-400 bg-green-400/15"
: "text-white/70 hover:bg-white/10 hover:text-white",
},
{
key: "more",
label: "More",
icon: <MoreHorizontal className="size-3.5" />,
key: "collection",
label: "Save to Collection",
description: "Organise into a collection",
icon: <FolderPlus className="size-5" />,
onClick: onSaveToCollection,
},
{
key: "mention",
label: "Mention",
description: "Tag someone with this quote",
icon: <AtSign className="size-5" />,
onClick: () => {
if (isMobile) saveAndClearSelection();
else interactingRef.current = true;
setMorePos({
x: toolbarPos.x + 60,
y: toolbarPos.y,
});
setShowMore(true);
setShowMore(false);
if (isMobile) restoreSelection();
else interactingRef.current = false;
},
},
];
const publicMoreItems: MoreItem[] = [
{
key: "share-as-post",
label: "Share as Post",
description: "Publish this passage to your feed",
icon: <Send className="size-5" />,
locked: true,
onClick: async () => {
setShowMore(false);
await onPost();
},
},
{
key: "save-to-collection-public",
label: "Save to Collection",
description: "Organise into a collection",
icon: <FolderPlus className="size-5" />,
locked: true,
onClick: onSaveToCollection,
},
{
key: "bookmark-public",
label: "Bookmark",
description: "Save your place — always private",
icon: <BookmarkIcon className="size-5" />,
locked: true,
onClick: async () => {
setShowMore(false);
await onBookmarkOrCollect();
},
},
{
key: "mention-public",
label: "Mention",
description: "Tag someone with this quote",
icon: <AtSign className="size-5" />,
locked: true,
onClick: async () => {
setShowMore(false);
if (!(await requireAuth())) return;
// TODO: Implement Mention
},
},
];
const moreItems = isAuthed ? authedMoreItems : publicMoreItems;
// --- Render ---
return (
@@ -321,34 +563,18 @@ export function TextSelectionToolbar() {
open={showMore}
position={morePos}
isMobile={isMobile}
selectedText={selectedText}
items={moreItems}
onClose={() => {
setShowMore(false);
if (isMobile) restoreSelection();
else interactingRef.current = false;
}}
onBookmark={() => {
setShowMore(false);
if (isMobile) restoreSelection();
else interactingRef.current = false;
}}
onSaveToCollection={() => {
setShowMore(false);
if (isMobile) saveAndClearSelection();
else interactingRef.current = true;
setShowCollections(true);
}}
onMention={() => {
setShowMore(false);
if (isMobile) restoreSelection();
else interactingRef.current = false;
}}
/>
<ShareModal
open={showShare}
<ComposePost
open={showCompose}
onClose={() => {
setShowShare(false);
setShowCompose(false);
if (isMobile) restoreSelection();
else interactingRef.current = false;
}}
@@ -364,6 +590,7 @@ export function TextSelectionToolbar() {
else interactingRef.current = false;
}}
isMobile={isMobile}
selectedText={selectedText}
/>
</div>
);
+71
View File
@@ -0,0 +1,71 @@
"use client";
/**
* Checkbox — themed replacement for the native `<input type="checkbox" />`.
*
* The native control is rendered visually hidden (sr-only) so screen
* readers and keyboard focus still work the way browsers expect, while
* we paint a small rounded box next to it that fills with the primary
* color when checked. Fully controlled — caller owns `checked` state.
*
* Usage:
*
* <Checkbox checked={ok} onCheckedChange={setOk} />
*
* <label className="flex items-center gap-2">
* <Checkbox checked={ok} onCheckedChange={setOk} />
* Accept terms
* </label>
*/
import { Check } from "lucide-react";
import { cn } from "@/lib/utils";
export function Checkbox({
checked,
onCheckedChange,
disabled,
className,
ariaLabel,
}: {
checked: boolean;
onCheckedChange: (next: boolean) => void;
disabled?: boolean;
className?: string;
ariaLabel?: string;
}) {
return (
<span
className={cn(
"relative inline-flex items-center justify-center shrink-0",
className,
)}
>
<input
type="checkbox"
checked={checked}
disabled={disabled}
aria-label={ariaLabel}
onChange={(e) => onCheckedChange(e.target.checked)}
className="peer absolute inset-0 size-full appearance-none m-0 cursor-pointer disabled:cursor-not-allowed rounded-md"
/>
<span
aria-hidden
className={cn(
"pointer-events-none size-5 rounded-md border transition-colors",
"flex items-center justify-center",
checked
? "bg-primary border-primary"
: "bg-background border-border",
"peer-hover:border-foreground/40",
"peer-focus-visible:ring-2 peer-focus-visible:ring-ring peer-focus-visible:ring-offset-1 peer-focus-visible:ring-offset-background",
disabled && "opacity-50",
)}
>
{checked ? (
<Check className="size-3.5 text-primary-foreground" strokeWidth={3} />
) : null}
</span>
</span>
);
}
+188
View File
@@ -0,0 +1,188 @@
"use client";
/**
* ConfirmModal — palette-themed replacement for native `window.confirm`.
*
* Mirrors the structure of AuthModal (portal + backdrop + Esc-to-close +
* click-outside dismiss + body-scroll lock) but with a single primary
* action button. The default tone is "destructive" since that's the
* common case (delete this thing), but the `tone` prop swaps to neutral
* for less-scary confirms.
*
* Usage:
*
* const [open, setOpen] = useState(false);
* <ConfirmModal
* open={open}
* onClose={() => setOpen(false)}
* title="Delete this collection?"
* message="Posts inside will stay on your feed."
* confirmLabel="Delete"
* onConfirm={async () => { await collections.delete(id); ... }}
* />
*
* The `onConfirm` may return a promise — the dialog shows a pending state
* and stays open until it resolves, then auto-closes on success. Errors
* thrown from onConfirm surface inline below the buttons.
*/
import { useEffect, useState } from "react";
import { createPortal } from "react-dom";
import { AlertTriangle, X } from "lucide-react";
import { cn } from "@/lib/utils";
type Tone = "destructive" | "neutral";
export function ConfirmModal({
open,
onClose,
title,
message,
confirmLabel = "Confirm",
cancelLabel = "Cancel",
tone = "destructive",
onConfirm,
}: {
open: boolean;
onClose: () => void;
title: string;
message?: string;
confirmLabel?: string;
cancelLabel?: string;
tone?: Tone;
/** Called when the user confirms. May return a promise. Throw to keep the modal open with an error message. */
onConfirm: () => void | Promise<void>;
}) {
const [working, setWorking] = useState(false);
const [error, setError] = useState<string | null>(null);
// Reset transient state every time the modal opens — otherwise an error
// from a previous attempt would linger on the next open.
useEffect(() => {
if (open) {
setWorking(false);
setError(null);
}
}, [open]);
useEffect(() => {
if (!open) return;
function onKey(e: KeyboardEvent) {
if (e.key === "Escape" && !working) onClose();
}
document.addEventListener("keydown", onKey);
return () => document.removeEventListener("keydown", onKey);
}, [open, working, onClose]);
// Body-scroll lock while open.
useEffect(() => {
if (!open) return;
const prev = document.body.style.overflow;
document.body.style.overflow = "hidden";
return () => {
document.body.style.overflow = prev;
};
}, [open]);
if (!open) return null;
if (typeof document === "undefined") return null;
async function handleConfirm() {
setError(null);
setWorking(true);
try {
await onConfirm();
onClose();
} catch (err) {
setError(
err instanceof Error ? err.message : "Something went wrong.",
);
} finally {
setWorking(false);
}
}
return createPortal(
<>
<div
className="fixed inset-0 z-100 bg-foreground/40 backdrop-blur-sm animate-in fade-in-0"
onClick={() => !working && onClose()}
aria-hidden
/>
<div
role="alertdialog"
aria-modal="true"
aria-labelledby="confirm-modal-title"
aria-describedby={message ? "confirm-modal-message" : undefined}
className="fixed left-1/2 top-1/2 z-101 w-full max-w-md -translate-x-1/2 -translate-y-1/2 px-4"
>
<div className="rounded-2xl border border-border bg-card shadow-xl overflow-hidden">
<div className="flex items-start gap-3 px-5 pt-5">
{tone === "destructive" ? (
<span
className="shrink-0 size-10 rounded-full bg-destructive/10 text-destructive flex items-center justify-center"
aria-hidden
>
<AlertTriangle className="size-5" />
</span>
) : null}
<div className="flex-1 min-w-0">
<h2
id="confirm-modal-title"
className="font-[family-name:var(--font-title)] font-semibold text-lg text-foreground"
>
{title}
</h2>
{message ? (
<p
id="confirm-modal-message"
className="mt-1 text-sm text-muted-foreground leading-relaxed"
>
{message}
</p>
) : null}
</div>
<button
type="button"
onClick={() => !working && onClose()}
aria-label="Close"
className="shrink-0 size-8 rounded-lg text-muted-foreground hover:text-foreground hover:bg-muted inline-flex items-center justify-center disabled:opacity-50"
disabled={working}
>
<X className="size-4" />
</button>
</div>
{error ? (
<p className="mx-5 mt-3 text-sm text-destructive">{error}</p>
) : null}
<div className="px-5 py-4 mt-2 flex items-center justify-end gap-2 border-t border-border bg-muted/30">
<button
type="button"
onClick={onClose}
disabled={working}
className="h-10 px-4 rounded-md text-sm font-medium text-foreground hover:bg-muted transition-colors disabled:opacity-50"
>
{cancelLabel}
</button>
<button
type="button"
onClick={handleConfirm}
disabled={working}
className={cn(
"h-10 px-4 rounded-md text-sm font-medium transition-colors disabled:opacity-60",
tone === "destructive"
? "bg-destructive text-white hover:bg-destructive/90"
: "bg-primary text-primary-foreground hover:bg-primary/90",
)}
>
{working ? "Working…" : confirmLabel}
</button>
</div>
</div>
</div>
</>,
document.body,
);
}
+37
View File
@@ -0,0 +1,37 @@
"use client";
/**
* Toaster — sonner mounted with our palette tokens, so toasts read the
* same `--card`, `--foreground`, etc. variables the rest of the site uses.
* That keeps notifications on-theme across all six light/dark palettes
* without per-palette overrides.
*/
import { useTheme } from "next-themes";
import { Toaster as SonnerToaster } from "sonner";
export function Toaster() {
const { resolvedTheme } = useTheme();
return (
<SonnerToaster
theme={(resolvedTheme as "light" | "dark") || "light"}
position="bottom-right"
richColors={false}
closeButton
toastOptions={{
// Inline styles win over sonner's defaults, but stay token-driven
// so the active palette is honoured.
style: {
background: "var(--card)",
color: "var(--foreground)",
border: "1px solid var(--border)",
},
classNames: {
toast: "font-[family-name:var(--font-title)]",
description: "text-[var(--muted-foreground)]",
actionButton: "bg-[var(--primary)] text-[var(--primary-foreground)]",
},
}}
/>
);
}
+8 -1
View File
@@ -17,9 +17,11 @@ const BFF_BASE =
export type User = {
id: string;
email: string;
handle: string;
name: string;
avatar_url: string | null;
role: string;
plan: "free" | "premium";
email_verified_at: string | null;
created_at: string;
updated_at: string;
@@ -110,12 +112,17 @@ export const auth = {
resendVerification: () =>
authFetch<{ ok: boolean }>("/auth/resend-verification", { method: "POST" }),
updateProfile: (name?: string, avatarURL?: string | null) =>
updateProfile: (
name?: string,
avatarURL?: string | null,
handle?: string,
) =>
authFetch<User>("/users/me", {
method: "PATCH",
body: JSON.stringify({
name: name ?? undefined,
avatar_url: avatarURL === undefined ? undefined : avatarURL,
handle: handle ?? undefined,
}),
}),
+288
View File
@@ -0,0 +1,288 @@
/**
* Browser-side BFF client for the social features: posts, bookmarks,
* collections, public profiles.
*
* All requests run with `credentials: "include"` so the auth cookie set
* by /auth/login is sent along automatically. Errors come back as
* `SocialError` instances carrying the HTTP status code and the message
* from the BFF envelope.
*
* Endpoint paths mirror the BFF routes 1:1 — there's no Next.js proxy
* for these in the current setup (BFF is on the same registrable domain
* in prod, and localhost is same-site in dev).
*/
const BFF_BASE =
process.env.NEXT_PUBLIC_BFF_URL || "http://localhost:8080";
export class SocialError extends Error {
constructor(
public status: number,
message: string,
) {
super(message);
this.name = "SocialError";
}
}
type Envelope<T> = {
success: boolean;
data?: T;
error?: { code: number; message: string };
meta?: { page: number; limit: number; total: number; total_pages: number };
};
async function call<T>(path: string, init?: RequestInit): Promise<T> {
const res = await fetch(`${BFF_BASE}/api/v1${path}`, {
credentials: "include",
headers: { "Content-Type": "application/json", ...(init?.headers ?? {}) },
...init,
});
if (res.status === 204) return undefined as T;
const body = (await res.json().catch(() => ({}))) as Envelope<T>;
if (!res.ok || body.success === false) {
throw new SocialError(res.status, body.error?.message || res.statusText);
}
return (body.data ?? (undefined as unknown as T)) as T;
}
// ─── Types ───────────────────────────────────────────────────────────────
export type PostImage = { id: string; url: string; position: number };
export type Post = {
id: string;
user_id: string;
caption: string;
source_url: string;
source_title: string;
images: PostImage[];
created_at: string;
updated_at: string;
author_name?: string;
author_handle?: string;
author_avatar_url?: string | null;
};
export type Bookmark = {
id: string;
user_id: string;
source_url: string;
source_title: string;
excerpt: string | null;
note: string | null;
created_at: string;
};
export type Collection = {
id: string;
user_id: string;
title: string;
description: string | null;
cover_url: string | null;
is_public: boolean;
item_count: number;
created_at: string;
updated_at: string;
};
export type PublicProfile = {
handle: string;
name: string;
avatar_url: string | null;
joined_at: string;
post_count: number;
public_collections: Collection[];
};
// ─── Posts ───────────────────────────────────────────────────────────────
export const posts = {
create(input: {
caption: string;
source_url: string;
source_title?: string;
image_urls?: string[];
}) {
return call<Post>("/posts", {
method: "POST",
body: JSON.stringify({
caption: input.caption,
source_url: input.source_url,
source_title: input.source_title ?? "",
image_urls: input.image_urls ?? [],
}),
});
},
get(id: string) {
return call<Post>(`/posts/${id}`);
},
delete(id: string) {
return call<void>(`/posts/${id}`, { method: "DELETE" });
},
/** Swap the post's image set. Empty array removes all images. */
replaceImages(id: string, image_urls: string[]) {
return call<Post>(`/posts/${id}/images`, {
method: "PUT",
body: JSON.stringify({ image_urls }),
});
},
mine() {
return call<Post[]>("/posts/mine");
},
/** R2 presigned PUT for a single post image. Returns the public URL the post should persist. */
async uploadImage(file: File): Promise<string> {
const { upload_url, public_url } = await call<{
upload_url: string;
public_url: string;
}>("/posts/image-presign", {
method: "POST",
body: JSON.stringify({ content_type: file.type }),
});
const put = await fetch(upload_url, {
method: "PUT",
body: file,
headers: { "Content-Type": file.type },
});
if (!put.ok) throw new SocialError(put.status, "image upload failed");
return public_url;
},
};
// ─── Bookmarks ───────────────────────────────────────────────────────────
export const bookmarks = {
create(input: {
source_url: string;
source_title?: string;
excerpt?: string;
note?: string;
}) {
return call<Bookmark>("/bookmarks", {
method: "POST",
body: JSON.stringify({
source_url: input.source_url,
source_title: input.source_title ?? "",
excerpt: input.excerpt ?? null,
note: input.note ?? null,
}),
});
},
list() {
return call<Bookmark[]>("/bookmarks");
},
delete(id: string) {
return call<void>(`/bookmarks/${id}`, { method: "DELETE" });
},
};
// ─── Collections ─────────────────────────────────────────────────────────
export const collections = {
create(input: {
title: string;
description?: string;
cover_url?: string;
is_public?: boolean;
}) {
return call<Collection>("/collections", {
method: "POST",
body: JSON.stringify({
title: input.title,
description: input.description ?? null,
cover_url: input.cover_url ?? null,
is_public: !!input.is_public,
}),
});
},
update(
id: string,
input: Partial<{
title: string;
description: string;
cover_url: string;
is_public: boolean;
}>,
) {
return call<Collection>(`/collections/${id}`, {
method: "PATCH",
body: JSON.stringify({
title: input.title ?? undefined,
description: input.description ?? undefined,
cover_url: input.cover_url === undefined ? undefined : input.cover_url,
is_public: input.is_public ?? undefined,
}),
});
},
delete(id: string) {
return call<void>(`/collections/${id}`, { method: "DELETE" });
},
mine() {
return call<Collection[]>("/collections");
},
/** Public-or-private depending on visibility. Anonymous viewers get 403 on private collections. */
get(id: string) {
return call<{ collection: Collection; posts: Post[] }>(`/collections/${id}`);
},
addItem(collectionID: string, postID: string) {
return call<{ ok: boolean }>(`/collections/${collectionID}/items`, {
method: "POST",
body: JSON.stringify({ post_id: postID }),
});
},
removeItem(collectionID: string, postID: string) {
return call<void>(`/collections/${collectionID}/items/${postID}`, {
method: "DELETE",
});
},
/** Returns the ids of the caller's collections that already contain
* the given post. The save-to-collection drawer uses this to disable
* rows so the post can't be added a second time. */
async containingPost(postID: string): Promise<string[]> {
const r = await call<{ collection_ids: string[] }>(
`/collections/containing/${postID}`,
);
return r.collection_ids ?? [];
},
/** Rewrite the position of every post in `post_ids` to match the array
* order. The BFF runs the updates in a single tx — readers see either
* the old order or the new one, never a half-applied state. */
reorderItems(collectionID: string, post_ids: string[]) {
return call<void>(`/collections/${collectionID}/items/order`, {
method: "PUT",
body: JSON.stringify({ post_ids }),
});
},
/** R2 presigned PUT for the collection cover. */
async uploadCover(file: File): Promise<string> {
const { upload_url, public_url } = await call<{
upload_url: string;
public_url: string;
}>("/collections/cover-presign", {
method: "POST",
body: JSON.stringify({ content_type: file.type }),
});
const put = await fetch(upload_url, {
method: "PUT",
body: file,
headers: { "Content-Type": file.type },
});
if (!put.ok) throw new SocialError(put.status, "cover upload failed");
return public_url;
},
};
// ─── Public profile ──────────────────────────────────────────────────────
export const profile = {
get(handle: string) {
return call<PublicProfile>(`/u/${encodeURIComponent(handle)}`);
},
feed(handle: string, limit = 20, offset = 0) {
const qs = new URLSearchParams({
limit: String(limit),
offset: String(offset),
});
return call<Post[]>(`/u/${encodeURIComponent(handle)}/feed?${qs}`);
},
};
+122
View File
@@ -247,6 +247,128 @@ export function getSourceReference(): {
return null;
}
/**
* URL-only variant of getSourceReference — pure function, safe on server.
*
* For `/books/...` paths we can derive a clean citation (e.g.
* "Bhagavad-gītā As It Is — Chapter 1, Verse 1") from the path alone.
* Mirrors the logic in `lib/og.ts`'s `ogForVerse` so post cards display
* the same reference the OG image carries.
*
* For non-book paths the title lives in the page DOM (h1) which we don't
* have at card-render time, so we return null — callers should fall back
* to whatever `source_title` was saved at compose time.
*/
export function referenceFromUrl(
sourceUrl: string,
): { title: string; detail: string | null } | null {
if (!sourceUrl) return null;
let path: string;
try {
if (sourceUrl.startsWith("/")) {
path = sourceUrl.split("?")[0];
} else {
const u = new URL(sourceUrl);
path = u.pathname;
}
} catch {
return null;
}
const parts = path.split("/").filter(Boolean);
if (parts[0] !== "books" || parts.length < 2) return null;
const code = parts[1];
const bookName = BOOK_NAMES[code] || code.toUpperCase();
// Verse-based: /books/bg/[chapter]/[verse]
if (code === "bg" && parts.length >= 4) {
return {
title: bookName,
detail: `Chapter ${parts[2]}, Verse ${parts[3]}`,
};
}
// SB: /books/sb/[canto]/[chapter]/[verse]
if (code === "sb" && parts.length >= 5) {
return {
title: bookName,
detail: `Canto ${parts[2]}, Chapter ${parts[3]}, Verse ${parts[4]}`,
};
}
// CC: /books/cc/[lila]/[chapter]/[verse]
if (code === "cc" && parts.length >= 5) {
const lila = CC_LILAS[parts[2]] || parts[2];
return {
title: bookName,
detail: `${lila}, Chapter ${parts[3]}, Verse ${parts[4]}`,
};
}
// CC lila index: /books/cc/[lila]
if (code === "cc" && parts.length === 3) {
const lila = CC_LILAS[parts[2]] || parts[2];
return { title: bookName, detail: lila };
}
// SB1962: /books/sb1962/[chapter]/[verse]
if (code === "sb1962" && parts.length >= 4) {
return {
title: bookName,
detail: `Chapter ${parts[2]}, Verse ${parts[3]}`,
};
}
// ISO / NOI: /books/{code}/[mantra]
if ((code === "iso" || code === "noi") && parts.length >= 3) {
const num = parts[2];
const label = code === "iso" ? "Mantra" : "Text";
return { title: bookName, detail: `${label} ${num}` };
}
// BS/MM/NBS verse: /books/{code}/[chapter]/[verse]
if (
(code === "bs" || code === "mm" || code === "nbs") &&
parts.length >= 4
) {
return {
title: bookName,
detail: `Chapter ${parts[2]}, Verse ${parts[3]}`,
};
}
// KB: /books/kb/[volume]/[slug]
if (code === "kb" && parts.length >= 4) {
return {
title: bookName,
detail: `Volume ${parts[2]}, Chapter ${parts[3]}`,
};
}
// Generic prose: /books/{code}/[chapter]
if (parts.length >= 3) {
const chSlug = parts[2];
const chNum = parseInt(chSlug, 10);
const detail = isNaN(chNum)
? chSlug.replace(/-/g, " ")
: `Chapter ${chNum}`;
return { title: bookName, detail };
}
// Book index
return { title: bookName, detail: null };
}
/**
* Render a one-line citation string for a post/bookmark card.
*
* For book URLs we always prefer the URL-derived reference (canonical
* format, no stale data). For non-book content (letters, essays, etc.)
* the saved `source_title` is the source of truth.
*/
export function displaySourceLabel(
sourceUrl: string,
storedTitle?: string | null,
): string {
const ref = referenceFromUrl(sourceUrl);
if (ref) {
return ref.detail ? `${ref.title} — ${ref.detail}` : ref.title;
}
return storedTitle?.trim() || "Source";
}
/**
* Format selected text with source attribution appended.
*/
+506 -6
View File
@@ -296,6 +296,50 @@ export function buildHighlightUrl(selectedText: string): string {
return url.toString();
}
/**
* Append (or replace) the `?hl=…` fragment on a path-only URL.
*
* Unlike `buildHighlightUrl`, this works without `window` — useful for
* server-rendered links and for rebuilding bookmark/post source URLs at
* render time (e.g. a bookmark saved by an older client without an `?hl=`
* still scrolls to its text when you click it, because we synthesise the
* fragment from the stored excerpt/caption).
*
* Accepts:
* path — same-origin path, may already include a `?query` (e.g.
* `/books/bg/1/1` or `/books/bg/1/1?foo=bar`).
* text — the text we want to highlight on the destination page.
*
* Returns the path with `hl` set to the encoded fragment. If `text`
* produces no fragment (e.g. empty string) returns the input unchanged.
*/
export function appendHighlight(path: string, text: string): string {
if (!text || !path) return path;
const fragment = encodeTextFragment(text);
if (!fragment) return path;
const hashIdx = path.indexOf("#");
const hash = hashIdx >= 0 ? path.slice(hashIdx) : "";
const noHash = hashIdx >= 0 ? path.slice(0, hashIdx) : path;
const qIdx = noHash.indexOf("?");
const head = qIdx >= 0 ? noHash.slice(0, qIdx) : noHash;
const query = qIdx >= 0 ? noHash.slice(qIdx + 1) : "";
const params = new URLSearchParams(query);
params.delete("hl");
params.set("hl", fragment);
return `${head}?${params.toString()}${hash}`;
}
/**
* Returns true if `path` already carries an `?hl=` query param.
* Cheap string check — avoids a URL parse on the render hot path.
*/
export function hasHighlight(path: string): boolean {
if (!path) return false;
return /[?&]hl=/.test(path);
}
/**
* Build a shortened, shareable URL for the current selection. POSTs the
* `?hl=…` URL to the BFF, which stores the mapping in Postgres (Redis-queued)
@@ -327,6 +371,204 @@ export async function buildShortHighlightUrl(
return new URL(`/share/${code}`, u.origin).toString();
}
// HTML block-level tags. When concatenating text nodes for matching, we
// insert a synthetic space at boundaries between *different* block-level
// ancestors so cross-paragraph selections still match (React often
// renders adjacent <p>s with no whitespace between them).
//
// Crucially, crossing an *inline* boundary (e.g. <p>In <i>Caitanya</i>
// (...) it</p>) must NOT insert a space — otherwise the prefix
// "In Caitanya (X) it" gets reconstructed as "In Caitanya ( X ) it" and
// the match fails on every passage with italicised Sanskrit terms.
const BLOCK_TAGS = new Set([
"P", "DIV", "H1", "H2", "H3", "H4", "H5", "H6",
"LI", "UL", "OL", "BLOCKQUOTE", "SECTION", "ARTICLE",
"HEADER", "FOOTER", "NAV", "ASIDE", "MAIN", "FIGURE",
"FIGCAPTION", "PRE", "TR", "TD", "TH", "TABLE", "ADDRESS",
"HR", "DL", "DT", "DD", "BODY", "HTML",
]);
function nearestBlockAncestor(el: Element | null): Element | null {
let cur: Element | null = el;
while (cur) {
if (BLOCK_TAGS.has(cur.tagName)) return cur;
cur = cur.parentElement;
}
return null;
}
/**
* Find a DOM Range matching a decoded text fragment WITHOUT modifying the
* DOM. Returns null when no match is found. Used by the bookmark-indicator
* overlay: we need each bookmark's bounding rect to anchor a floating
* icon, but we don't want a second wrapping pass on top of whatever
* findAndHighlightText (`?hl=`) might already have done on the same page.
*
* Shares the same prefix+suffix+length normalisation that
* findAndHighlightText uses so a bookmark's saved fragment matches the
* same DOM text the toolbar selected.
*/
export function findTextRange(fragment: {
prefix: string;
suffix: string;
length: number;
}): Range | null {
const contentAreas = document.querySelectorAll(
"main, [data-slot=card-content], article, [data-content-area]",
);
if (contentAreas.length === 0) return null;
const allTextNodes: Text[] = [];
contentAreas.forEach((area) => {
const walker = document.createTreeWalker(area, NodeFilter.SHOW_TEXT);
let node: Text | null;
while ((node = walker.nextNode() as Text | null)) {
if (node.textContent && node.textContent.trim().length > 0) {
allTextNodes.push(node);
}
}
});
// Same segment/normalisation pipeline as findAndHighlightText; see comments
// there. We trade a little duplication for guaranteed parity in matching.
const segments: { node: Text; start: number; end: number }[] = [];
let rawText = "";
let normText = "";
const posMap: number[] = [];
let prevParent: Element | null = null;
let prevBlock: Element | null = null;
for (const node of allTextNodes) {
const text = node.textContent || "";
const parent = node.parentElement;
const block = nearestBlockAncestor(parent);
if (
prevParent &&
prevBlock !== block &&
normText.length > 0 &&
!normText.endsWith(" ") &&
!text.startsWith(" ")
) {
posMap.push(rawText.length);
normText += " ";
}
prevParent = parent;
prevBlock = block;
const segStart = rawText.length;
segments.push({ node, start: segStart, end: segStart + text.length });
for (let i = 0; i < text.length; i++) {
posMap.push(rawText.length + i);
normText += text[i];
}
rawText += text;
}
let collapsedNorm = "";
const collapsedPosMap: number[] = [];
let lastWasSpace = false;
for (let i = 0; i < normText.length; i++) {
const ch = normText[i];
const isSpace = /\s/.test(ch);
if (isSpace) {
if (!lastWasSpace) {
collapsedPosMap.push(posMap[i]);
collapsedNorm += " ";
}
lastWasSpace = true;
} else {
collapsedPosMap.push(posMap[i]);
collapsedNorm += ch;
lastWasSpace = false;
}
}
const normalizedFullLower = normalizeForMatch(collapsedNorm);
const normalizedPrefix = normalizeForMatch(
fragment.prefix.replace(/\s+/g, " "),
);
let searchFrom = 0;
let matchStartNorm = -1;
while (searchFrom < normalizedFullLower.length) {
const idx = normalizedFullLower.indexOf(normalizedPrefix, searchFrom);
if (idx === -1) break;
if (fragment.suffix) {
const normalizedSuffix = normalizeForMatch(
fragment.suffix.replace(/\s+/g, " "),
);
const expectedEndNorm = idx + fragment.length;
const suffixSearchStart = Math.max(
idx,
expectedEndNorm - normalizedSuffix.length - 50,
);
const suffixSearchEnd = Math.min(
normalizedFullLower.length,
expectedEndNorm + 50,
);
const searchWindow = normalizedFullLower.slice(
suffixSearchStart,
suffixSearchEnd,
);
if (searchWindow.includes(normalizedSuffix)) {
matchStartNorm = idx;
break;
}
} else {
matchStartNorm = idx;
break;
}
searchFrom = idx + 1;
}
if (matchStartNorm === -1) return null;
const matchEndNorm = Math.min(
matchStartNorm + fragment.length,
collapsedNorm.length,
);
let matchStart = collapsedPosMap[matchStartNorm] ?? 0;
let matchEnd =
matchEndNorm < collapsedPosMap.length
? collapsedPosMap[matchEndNorm]
: rawText.length;
// Defensive raw-text alignment — same logic as the other matchers.
// Use fragment.length for range fragments so the selection isn't
// collapsed to just the prefix.
const rawLower = rawText.toLowerCase();
const prefixLower = fragment.prefix.toLowerCase();
const directIdx = rawLower.indexOf(prefixLower);
if (directIdx >= 0) {
matchStart = directIdx;
const endByLength =
fragment.suffix && fragment.length > fragment.prefix.length
? directIdx + fragment.length
: directIdx + fragment.prefix.length;
matchEnd = Math.min(endByLength, rawText.length);
}
// Map raw-text offsets back to (textNode, offsetInNode) anchors for the Range.
let startAnchor: { node: Text; offset: number } | null = null;
let endAnchor: { node: Text; offset: number } | null = null;
for (const seg of segments) {
if (
!startAnchor &&
matchStart >= seg.start &&
matchStart <= seg.end
) {
startAnchor = { node: seg.node, offset: matchStart - seg.start };
}
if (matchEnd >= seg.start && matchEnd <= seg.end) {
endAnchor = { node: seg.node, offset: matchEnd - seg.start };
}
if (startAnchor && endAnchor) break;
}
if (!startAnchor || !endAnchor) return null;
try {
const range = document.createRange();
range.setStart(startAnchor.node, startAnchor.offset);
range.setEnd(endAnchor.node, endAnchor.offset);
return range;
} catch {
return null;
}
}
/**
* Find and highlight text in the DOM that matches a decoded text fragment.
* Returns true if found and highlighted.
@@ -363,17 +605,22 @@ export function findAndHighlightText(fragment: {
let normText = "";
const posMap: number[] = []; // posMap[normIdx] = rawIdx
let prevParent: Element | null = null;
let prevBlock: Element | null = null;
for (const node of allTextNodes) {
const text = node.textContent || "";
const parent = node.parentElement;
const block = nearestBlockAncestor(parent);
// Insert a space in normText when crossing element boundaries
// (so cross-paragraph searches work), but NOT in rawText
// (so segment offsets stay accurate for DOM highlighting).
// Insert a space in normText only when crossing a *block-level*
// boundary (so cross-paragraph searches still work), but NOT when
// crossing inline boundaries like <i>, <em>, <span> — otherwise a
// selection like "(Madhya-līlā) it" gets reconstructed as
// "( Madhya-līlā ) it" and the prefix match fails. rawText stays
// exact so DOM offsets remain accurate for highlighting.
if (
prevParent &&
parent !== prevParent &&
prevBlock !== block &&
normText.length > 0 &&
!normText.endsWith(" ") &&
!text.startsWith(" ")
@@ -382,6 +629,7 @@ export function findAndHighlightText(fragment: {
normText += " ";
}
prevParent = parent;
prevBlock = block;
const segStart = rawText.length;
segments.push({
@@ -473,12 +721,35 @@ export function findAndHighlightText(fragment: {
collapsedNorm.length,
);
const matchStart = collapsedPosMap[matchStartNorm] ?? 0;
const matchEnd =
let matchStart = collapsedPosMap[matchStartNorm] ?? 0;
let matchEnd =
matchEndNorm < collapsedPosMap.length
? collapsedPosMap[matchEndNorm]
: rawText.length;
// Defensive raw-text alignment — same logic as markFragmentAsBookmark.
// Prefer the raw-text index when the prefix is plain enough to match
// directly, since collapsedPosMap can drift on certain inputs.
//
// The matchEnd choice depends on the fragment shape (see encoder):
// • prefix-only (no suffix) → the prefix IS the full selection, so
// matchEnd = directIdx + prefix.length.
// • prefix + suffix → the selection spans from prefix-start to
// suffix-end, and `fragment.length` is that full span. We must use
// it; collapsing to just prefix.length here was the bug that made
// long highlights show only the first 4 words.
const rawLower = rawText.toLowerCase();
const prefixLower = fragment.prefix.toLowerCase();
const directIdx = rawLower.indexOf(prefixLower);
if (directIdx >= 0) {
matchStart = directIdx;
const endByLength =
fragment.suffix && fragment.length > fragment.prefix.length
? directIdx + fragment.length
: directIdx + fragment.prefix.length;
matchEnd = Math.min(endByLength, rawText.length);
}
// Map the match range back to DOM text nodes and wrap with <mark>
for (const seg of segments) {
const overlapStart = Math.max(matchStart, seg.start);
@@ -523,3 +794,232 @@ export function findAndHighlightText(fragment: {
return true;
}
// ---------------------------------------------------------------------------
// Persistent bookmark marks
// ---------------------------------------------------------------------------
/**
* Wrap the bookmarked passage in a `<mark data-bookmark-id="X">` with a
* violet wash so the user sees their saved passage every time they visit
* the page, until they remove the bookmark.
*
* Distinct from `findAndHighlightText` in three ways:
* • Persistent — does NOT scroll into view (only the floating icon
* handles that affordance) and uses a different colour from `?hl=`
* (amber) so transient share-link landings stay visually distinct.
* • Idempotent — if a mark for this bookmark id already exists, we
* return the existing nodes without re-wrapping.
* • Returns the created (or existing) `<mark>` elements so the caller
* can measure their bounding rect to anchor a floating indicator.
*
* The matching pipeline mirrors `findAndHighlightText`; we intentionally
* inline the duplicated logic for now (clear-and-obvious beats clever
* abstraction while we iterate on this feature).
*/
export function markFragmentAsBookmark(
fragment: { prefix: string; suffix: string; length: number },
bookmarkId: string,
): HTMLElement[] {
// Idempotency — bail if marks for this bookmark are already in the DOM.
const existing = Array.from(
document.querySelectorAll<HTMLElement>(
`[data-bookmark-id="${CSS.escape(bookmarkId)}"]`,
),
);
if (existing.length > 0) return existing;
const contentAreas = document.querySelectorAll(
"main, [data-slot=card-content], article, [data-content-area]",
);
if (contentAreas.length === 0) return [];
const allTextNodes: Text[] = [];
contentAreas.forEach((area) => {
const walker = document.createTreeWalker(area, NodeFilter.SHOW_TEXT);
let node: Text | null;
while ((node = walker.nextNode() as Text | null)) {
if (node.textContent && node.textContent.trim().length > 0) {
allTextNodes.push(node);
}
}
});
// Same segment/normalisation pipeline as findAndHighlightText; see the
// BLOCK_TAGS comment above for the inline-vs-block boundary rule.
const segments: { node: Text; start: number; end: number }[] = [];
let rawText = "";
let normText = "";
const posMap: number[] = [];
let prevParent: Element | null = null;
let prevBlock: Element | null = null;
for (const node of allTextNodes) {
const text = node.textContent || "";
const parent = node.parentElement;
const block = nearestBlockAncestor(parent);
if (
prevParent &&
prevBlock !== block &&
normText.length > 0 &&
!normText.endsWith(" ") &&
!text.startsWith(" ")
) {
posMap.push(rawText.length);
normText += " ";
}
prevParent = parent;
prevBlock = block;
const segStart = rawText.length;
segments.push({ node, start: segStart, end: segStart + text.length });
for (let i = 0; i < text.length; i++) {
posMap.push(rawText.length + i);
normText += text[i];
}
rawText += text;
}
let collapsedNorm = "";
const collapsedPosMap: number[] = [];
let lastWasSpace = false;
for (let i = 0; i < normText.length; i++) {
const ch = normText[i];
const isSpace = /\s/.test(ch);
if (isSpace) {
if (!lastWasSpace) {
collapsedPosMap.push(posMap[i]);
collapsedNorm += " ";
}
lastWasSpace = true;
} else {
collapsedPosMap.push(posMap[i]);
collapsedNorm += ch;
lastWasSpace = false;
}
}
const normalizedFullLower = normalizeForMatch(collapsedNorm);
const normalizedPrefix = normalizeForMatch(
fragment.prefix.replace(/\s+/g, " "),
);
let searchFrom = 0;
let matchStartNorm = -1;
while (searchFrom < normalizedFullLower.length) {
const idx = normalizedFullLower.indexOf(normalizedPrefix, searchFrom);
if (idx === -1) break;
if (fragment.suffix) {
const normalizedSuffix = normalizeForMatch(
fragment.suffix.replace(/\s+/g, " "),
);
const expectedEndNorm = idx + fragment.length;
const suffixSearchStart = Math.max(
idx,
expectedEndNorm - normalizedSuffix.length - 50,
);
const suffixSearchEnd = Math.min(
normalizedFullLower.length,
expectedEndNorm + 50,
);
const searchWindow = normalizedFullLower.slice(
suffixSearchStart,
suffixSearchEnd,
);
if (searchWindow.includes(normalizedSuffix)) {
matchStartNorm = idx;
break;
}
} else {
matchStartNorm = idx;
break;
}
searchFrom = idx + 1;
}
if (matchStartNorm === -1) return [];
const matchEndNorm = Math.min(
matchStartNorm + fragment.length,
collapsedNorm.length,
);
let matchStart = collapsedPosMap[matchStartNorm] ?? 0;
let matchEnd =
matchEndNorm < collapsedPosMap.length
? collapsedPosMap[matchEndNorm]
: rawText.length;
// Defensive: cross-check the position against a direct case-insensitive
// search in rawText. If the prefix is plain enough to find directly,
// prefer the raw-text index — collapsedPosMap can drift by one when the
// normalization pipeline produces different code-unit counts than the
// original (NFD decomposition, Unicode lowercase widening, etc.), and
// that one-character shift is what dropped the leading character of
// matches in the wild.
//
// For range fragments (prefix + suffix), `fragment.length` is the full
// span — using prefix.length here would shrink the highlight to just
// the first few words.
const rawLower = rawText.toLowerCase();
const prefixLower = fragment.prefix.toLowerCase();
const directIdx = rawLower.indexOf(prefixLower);
if (directIdx >= 0) {
matchStart = directIdx;
const endByLength =
fragment.suffix && fragment.length > fragment.prefix.length
? directIdx + fragment.length
: directIdx + fragment.prefix.length;
matchEnd = Math.min(endByLength, rawText.length);
}
// Tailwind classes — violet wash + slight rounded corners. Picked to be
// visually distinct from the amber used by ?hl= so a user landing on a
// share-link sees the transient amber on TOP of any persistent violet
// bookmark mark (two colours = clearly two concepts).
const MARK_CLASS =
"bg-violet-300/45 dark:bg-violet-400/30 text-foreground rounded-sm px-0.5";
const created: HTMLElement[] = [];
for (const seg of segments) {
const overlapStart = Math.max(matchStart, seg.start);
const overlapEnd = Math.min(matchEnd, seg.end);
if (overlapStart >= overlapEnd) continue;
const node = seg.node;
const text = node.textContent || "";
const localStart = overlapStart - seg.start;
const localEnd = overlapEnd - seg.start;
const before = text.slice(0, localStart);
const matched = text.slice(localStart, localEnd);
const after = text.slice(localEnd);
const parent = node.parentNode;
if (!parent) continue;
const frag = document.createDocumentFragment();
if (before) frag.appendChild(document.createTextNode(before));
const mark = document.createElement("mark");
mark.className = MARK_CLASS;
mark.setAttribute("data-bookmark-id", bookmarkId);
mark.textContent = matched;
frag.appendChild(mark);
if (after) frag.appendChild(document.createTextNode(after));
parent.replaceChild(frag, node);
created.push(mark);
}
return created;
}
/**
* Unwrap bookmark marks. Pass an id to remove a single bookmark's marks;
* pass nothing to remove every bookmark mark in the document (used when
* the PageBookmarks overlay unmounts on route change).
*/
export function removeBookmarkMarks(bookmarkId?: string): void {
const selector = bookmarkId
? `[data-bookmark-id="${CSS.escape(bookmarkId)}"]`
: `[data-bookmark-id]`;
document.querySelectorAll<HTMLElement>(selector).forEach((mark) => {
const parent = mark.parentNode;
if (!parent) return;
parent.replaceChild(
document.createTextNode(mark.textContent ?? ""),
mark,
);
// Merge adjacent text nodes so subsequent matchers see a clean tree.
parent.normalize();
});
}
+9103
View File
File diff suppressed because it is too large Load Diff
+1
View File
@@ -19,6 +19,7 @@
"react": "19.2.4",
"react-dom": "19.2.4",
"shadcn": "^4.2.0",
"sonner": "^2.0.7",
"tailwind-merge": "^3.5.0",
"tw-animate-css": "^1.4.0",
"vaul": "^1.1.2"
+93 -600
View File
@@ -21,7 +21,7 @@
resolved "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.0.tgz"
integrity sha512-T1NCJqT/j9+cn8fvkt7jtwbLBfLC/1y1c7NtCeXFRgzGTsafi68MRv8yzkYSapBnFA6L3U2VSc02ciDzoAJhJg==
"@babel/core@^7.24.4", "@babel/core@^7.28.0":
"@babel/core@^7.0.0", "@babel/core@^7.0.0-0", "@babel/core@^7.24.4", "@babel/core@^7.28.0":
version "7.29.0"
resolved "https://registry.npmjs.org/@babel/core/-/core-7.29.0.tgz"
integrity sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==
@@ -273,6 +273,11 @@
reselect "^5.1.1"
use-sync-external-store "^1.6.0"
"@date-fns/tz@^1.2.0":
version "1.5.0"
resolved "https://registry.npmjs.org/@date-fns/tz/-/tz-1.5.0.tgz"
integrity sha512-lwYN/vDPeNRULcepoE/LO2Pgx+7/RV+S9ARfbc9lr2DtGkOD7pAiruHvbR1RX3Qyf6ja47EWJDMsNK5vK08DJg==
"@dotenvx/dotenvx@^1.48.4":
version "1.61.0"
resolved "https://registry.npmjs.org/@dotenvx/dotenvx/-/dotenvx-1.61.0.tgz"
@@ -294,158 +299,11 @@
resolved "https://registry.npmjs.org/@ecies/ciphers/-/ciphers-0.2.6.tgz"
integrity sha512-patgsRPKGkhhoBjETV4XxD0En4ui5fbX0hzayqI3M8tvNMGUoUvmyYAIWwlxBc1KX5cturfqByYdj5bYGRpN9g==
"@emnapi/core@^1.4.3", "@emnapi/core@^1.8.1":
version "1.10.0"
resolved "https://registry.yarnpkg.com/@emnapi/core/-/core-1.10.0.tgz#380ccc8f2412ea22d1d972df7f8ee23a3b9c7467"
integrity sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw==
dependencies:
"@emnapi/wasi-threads" "1.2.1"
tslib "^2.4.0"
"@emnapi/runtime@^1.4.3", "@emnapi/runtime@^1.7.0", "@emnapi/runtime@^1.8.1":
version "1.10.0"
resolved "https://registry.yarnpkg.com/@emnapi/runtime/-/runtime-1.10.0.tgz#4b260c0d3534204e98c6110b8db1a987d26ec87c"
integrity sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==
dependencies:
tslib "^2.4.0"
"@emnapi/wasi-threads@1.2.1", "@emnapi/wasi-threads@^1.1.0":
version "1.2.1"
resolved "https://registry.yarnpkg.com/@emnapi/wasi-threads/-/wasi-threads-1.2.1.tgz#28fed21a1ba1ce797c44a070abc94d42f3ae8548"
integrity sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==
dependencies:
tslib "^2.4.0"
"@esbuild/aix-ppc64@0.27.7":
version "0.27.7"
resolved "https://registry.yarnpkg.com/@esbuild/aix-ppc64/-/aix-ppc64-0.27.7.tgz#82b74f92aa78d720b714162939fb248c90addf53"
integrity sha512-EKX3Qwmhz1eMdEJokhALr0YiD0lhQNwDqkPYyPhiSwKrh7/4KRjQc04sZ8db+5DVVnZ1LmbNDI1uAMPEUBnQPg==
"@esbuild/android-arm64@0.27.7":
version "0.27.7"
resolved "https://registry.yarnpkg.com/@esbuild/android-arm64/-/android-arm64-0.27.7.tgz#f78cb8a3121fc205a53285adb24972db385d185d"
integrity sha512-62dPZHpIXzvChfvfLJow3q5dDtiNMkwiRzPylSCfriLvZeq0a1bWChrGx/BbUbPwOrsWKMn8idSllklzBy+dgQ==
"@esbuild/android-arm@0.27.7":
version "0.27.7"
resolved "https://registry.yarnpkg.com/@esbuild/android-arm/-/android-arm-0.27.7.tgz#593e10a1450bbfcac6cb321f61f468453bac209d"
integrity sha512-jbPXvB4Yj2yBV7HUfE2KHe4GJX51QplCN1pGbYjvsyCZbQmies29EoJbkEc+vYuU5o45AfQn37vZlyXy4YJ8RQ==
"@esbuild/android-x64@0.27.7":
version "0.27.7"
resolved "https://registry.yarnpkg.com/@esbuild/android-x64/-/android-x64-0.27.7.tgz#453143d073326033d2d22caf9e48de4bae274b07"
integrity sha512-x5VpMODneVDb70PYV2VQOmIUUiBtY3D3mPBG8NxVk5CogneYhkR7MmM3yR/uMdITLrC1ml/NV1rj4bMJuy9MCg==
"@esbuild/darwin-arm64@0.27.7":
version "0.27.7"
resolved "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.7.tgz"
integrity sha512-5lckdqeuBPlKUwvoCXIgI2D9/ABmPq3Rdp7IfL70393YgaASt7tbju3Ac+ePVi3KDH6N2RqePfHnXkaDtY9fkw==
"@esbuild/darwin-x64@0.27.7":
version "0.27.7"
resolved "https://registry.yarnpkg.com/@esbuild/darwin-x64/-/darwin-x64-0.27.7.tgz#27393dd18bb1263c663979c5f1576e00c2d024be"
integrity sha512-rYnXrKcXuT7Z+WL5K980jVFdvVKhCHhUwid+dDYQpH+qu+TefcomiMAJpIiC2EM3Rjtq0sO3StMV/+3w3MyyqQ==
"@esbuild/freebsd-arm64@0.27.7":
version "0.27.7"
resolved "https://registry.yarnpkg.com/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.7.tgz#22e4638fa502d1c0027077324c97640e3adf3a62"
integrity sha512-B48PqeCsEgOtzME2GbNM2roU29AMTuOIN91dsMO30t+Ydis3z/3Ngoj5hhnsOSSwNzS+6JppqWsuhTp6E82l2w==
"@esbuild/freebsd-x64@0.27.7":
version "0.27.7"
resolved "https://registry.yarnpkg.com/@esbuild/freebsd-x64/-/freebsd-x64-0.27.7.tgz#9224b8e4fea924ce2194e3efc3e9aebf822192d6"
integrity sha512-jOBDK5XEjA4m5IJK3bpAQF9/Lelu/Z9ZcdhTRLf4cajlB+8VEhFFRjWgfy3M1O4rO2GQ/b2dLwCUGpiF/eATNQ==
"@esbuild/linux-arm64@0.27.7":
version "0.27.7"
resolved "https://registry.yarnpkg.com/@esbuild/linux-arm64/-/linux-arm64-0.27.7.tgz#4f5d1c27527d817b35684ae21419e57c2bda0966"
integrity sha512-RZPHBoxXuNnPQO9rvjh5jdkRmVizktkT7TCDkDmQ0W2SwHInKCAV95GRuvdSvA7w4VMwfCjUiPwDi0ZO6Nfe9A==
"@esbuild/linux-arm@0.27.7":
version "0.27.7"
resolved "https://registry.yarnpkg.com/@esbuild/linux-arm/-/linux-arm-0.27.7.tgz#b9e9d070c8c1c0449cf12b20eac37d70a4595921"
integrity sha512-RkT/YXYBTSULo3+af8Ib0ykH8u2MBh57o7q/DAs3lTJlyVQkgQvlrPTnjIzzRPQyavxtPtfg0EopvDyIt0j1rA==
"@esbuild/linux-ia32@0.27.7":
version "0.27.7"
resolved "https://registry.yarnpkg.com/@esbuild/linux-ia32/-/linux-ia32-0.27.7.tgz#3f80fb696aa96051a94047f35c85b08b21c36f9e"
integrity sha512-GA48aKNkyQDbd3KtkplYWT102C5sn/EZTY4XROkxONgruHPU72l+gW+FfF8tf2cFjeHaRbWpOYa/uRBz/Xq1Pg==
"@esbuild/linux-loong64@0.27.7":
version "0.27.7"
resolved "https://registry.yarnpkg.com/@esbuild/linux-loong64/-/linux-loong64-0.27.7.tgz#9be1f2c28210b13ebb4156221bba356fe1675205"
integrity sha512-a4POruNM2oWsD4WKvBSEKGIiWQF8fZOAsycHOt6JBpZ+JN2n2JH9WAv56SOyu9X5IqAjqSIPTaJkqN8F7XOQ5Q==
"@esbuild/linux-mips64el@0.27.7":
version "0.27.7"
resolved "https://registry.yarnpkg.com/@esbuild/linux-mips64el/-/linux-mips64el-0.27.7.tgz#4ab5ee67a3dfcbcb5e8fd7883dae6e735b1163b8"
integrity sha512-KabT5I6StirGfIz0FMgl1I+R1H73Gp0ofL9A3nG3i/cYFJzKHhouBV5VWK1CSgKvVaG4q1RNpCTR2LuTVB3fIw==
"@esbuild/linux-ppc64@0.27.7":
version "0.27.7"
resolved "https://registry.yarnpkg.com/@esbuild/linux-ppc64/-/linux-ppc64-0.27.7.tgz#dac78c689f6499459c4321e5c15032c12307e7ea"
integrity sha512-gRsL4x6wsGHGRqhtI+ifpN/vpOFTQtnbsupUF5R5YTAg+y/lKelYR1hXbnBdzDjGbMYjVJLJTd2OFmMewAgwlQ==
"@esbuild/linux-riscv64@0.27.7":
version "0.27.7"
resolved "https://registry.yarnpkg.com/@esbuild/linux-riscv64/-/linux-riscv64-0.27.7.tgz#050f7d3b355c3a98308e935bc4d6325da91b0027"
integrity sha512-hL25LbxO1QOngGzu2U5xeXtxXcW+/GvMN3ejANqXkxZ/opySAZMrc+9LY/WyjAan41unrR3YrmtTsUpwT66InQ==
"@esbuild/linux-s390x@0.27.7":
version "0.27.7"
resolved "https://registry.yarnpkg.com/@esbuild/linux-s390x/-/linux-s390x-0.27.7.tgz#d61f715ce61d43fe5844ad0d8f463f88cbe4fef6"
integrity sha512-2k8go8Ycu1Kb46vEelhu1vqEP+UeRVj2zY1pSuPdgvbd5ykAw82Lrro28vXUrRmzEsUV0NzCf54yARIK8r0fdw==
"@esbuild/linux-x64@0.27.7":
version "0.27.7"
resolved "https://registry.yarnpkg.com/@esbuild/linux-x64/-/linux-x64-0.27.7.tgz#ca8e1aa478fc8209257bf3ac8f79c4dc2982f32a"
integrity sha512-hzznmADPt+OmsYzw1EE33ccA+HPdIqiCRq7cQeL1Jlq2gb1+OyWBkMCrYGBJ+sxVzve2ZJEVeePbLM2iEIZSxA==
"@esbuild/netbsd-arm64@0.27.7":
version "0.27.7"
resolved "https://registry.yarnpkg.com/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.7.tgz#1650f2c1b948deeb3ef948f2fc30614723c09690"
integrity sha512-b6pqtrQdigZBwZxAn1UpazEisvwaIDvdbMbmrly7cDTMFnw/+3lVxxCTGOrkPVnsYIosJJXAsILG9XcQS+Yu6w==
"@esbuild/netbsd-x64@0.27.7":
version "0.27.7"
resolved "https://registry.yarnpkg.com/@esbuild/netbsd-x64/-/netbsd-x64-0.27.7.tgz#65772ab342c4b3319bf0705a211050aac1b6e320"
integrity sha512-OfatkLojr6U+WN5EDYuoQhtM+1xco+/6FSzJJnuWiUw5eVcicbyK3dq5EeV/QHT1uy6GoDhGbFpprUiHUYggrw==
"@esbuild/openbsd-arm64@0.27.7":
version "0.27.7"
resolved "https://registry.yarnpkg.com/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.7.tgz#37ed7cfa66549d7955852fce37d0c3de4e715ea1"
integrity sha512-AFuojMQTxAz75Fo8idVcqoQWEHIXFRbOc1TrVcFSgCZtQfSdc1RXgB3tjOn/krRHENUB4j00bfGjyl2mJrU37A==
"@esbuild/openbsd-x64@0.27.7":
version "0.27.7"
resolved "https://registry.yarnpkg.com/@esbuild/openbsd-x64/-/openbsd-x64-0.27.7.tgz#01bf3d385855ef50cb33db7c4b52f957c34cd179"
integrity sha512-+A1NJmfM8WNDv5CLVQYJ5PshuRm/4cI6WMZRg1by1GwPIQPCTs1GLEUHwiiQGT5zDdyLiRM/l1G0Pv54gvtKIg==
"@esbuild/openharmony-arm64@0.27.7":
version "0.27.7"
resolved "https://registry.yarnpkg.com/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.7.tgz#6c1f94b34086599aabda4eac8f638294b9877410"
integrity sha512-+KrvYb/C8zA9CU/g0sR6w2RBw7IGc5J2BPnc3dYc5VJxHCSF1yNMxTV5LQ7GuKteQXZtspjFbiuW5/dOj7H4Yw==
"@esbuild/sunos-x64@0.27.7":
version "0.27.7"
resolved "https://registry.yarnpkg.com/@esbuild/sunos-x64/-/sunos-x64-0.27.7.tgz#4b0dd17ae0a6941d2d0fd35a906392517071a90d"
integrity sha512-ikktIhFBzQNt/QDyOL580ti9+5mL/YZeUPKU2ivGtGjdTYoqz6jObj6nOMfhASpS4GU4Q/Clh1QtxWAvcYKamA==
"@esbuild/win32-arm64@0.27.7":
version "0.27.7"
resolved "https://registry.yarnpkg.com/@esbuild/win32-arm64/-/win32-arm64-0.27.7.tgz#34193ab5565d6ff68ca928ac04be75102ccb2e77"
integrity sha512-7yRhbHvPqSpRUV7Q20VuDwbjW5kIMwTHpptuUzV+AA46kiPze5Z7qgt6CLCK3pWFrHeNfDd1VKgyP4O+ng17CA==
"@esbuild/win32-ia32@0.27.7":
version "0.27.7"
resolved "https://registry.yarnpkg.com/@esbuild/win32-ia32/-/win32-ia32-0.27.7.tgz#eb67f0e4482515d8c1894ede631c327a4da9fc4d"
integrity sha512-SmwKXe6VHIyZYbBLJrhOoCJRB/Z1tckzmgTLfFYOfpMAx63BJEaL9ExI8x7v0oAO3Zh6D/Oi1gVxEYr5oUCFhw==
"@esbuild/win32-x64@0.27.7":
version "0.27.7"
resolved "https://registry.yarnpkg.com/@esbuild/win32-x64/-/win32-x64-0.27.7.tgz#8fe30b3088b89b4873c3a6cc87597ae3920c0a8b"
integrity sha512-56hiAJPhwQ1R4i+21FVF7V8kSD5zZTdHcVuRFMW0hn753vVfQN8xlx4uOPT4xoGH0Z/oVATuR82AiqSTDIpaHg==
"@eslint-community/eslint-utils@^4.8.0", "@eslint-community/eslint-utils@^4.9.1":
version "4.9.1"
resolved "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.9.1.tgz"
@@ -581,141 +439,11 @@
optionalDependencies:
"@img/sharp-libvips-darwin-arm64" "1.2.4"
"@img/sharp-darwin-x64@0.34.5":
version "0.34.5"
resolved "https://registry.yarnpkg.com/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.34.5.tgz#19bc1dd6eba6d5a96283498b9c9f401180ee9c7b"
integrity sha512-YNEFAF/4KQ/PeW0N+r+aVVsoIY0/qxxikF2SWdp+NRkmMB7y9LBZAVqQ4yhGCm/H3H270OSykqmQMKLBhBJDEw==
optionalDependencies:
"@img/sharp-libvips-darwin-x64" "1.2.4"
"@img/sharp-libvips-darwin-arm64@1.2.4":
version "1.2.4"
resolved "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.2.4.tgz"
integrity sha512-zqjjo7RatFfFoP0MkQ51jfuFZBnVE2pRiaydKJ1G/rHZvnsrHAOcQALIi9sA5co5xenQdTugCvtb1cuf78Vf4g==
"@img/sharp-libvips-darwin-x64@1.2.4":
version "1.2.4"
resolved "https://registry.yarnpkg.com/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.2.4.tgz#e63681f4539a94af9cd17246ed8881734386f8cc"
integrity sha512-1IOd5xfVhlGwX+zXv2N93k0yMONvUlANylbJw1eTah8K/Jtpi15KC+WSiaX/nBmbm2HxRM1gZ0nSdjSsrZbGKg==
"@img/sharp-libvips-linux-arm64@1.2.4":
version "1.2.4"
resolved "https://registry.yarnpkg.com/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.2.4.tgz#b1b288b36864b3bce545ad91fa6dadcf1a4ad318"
integrity sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw==
"@img/sharp-libvips-linux-arm@1.2.4":
version "1.2.4"
resolved "https://registry.yarnpkg.com/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.2.4.tgz#b9260dd1ebe6f9e3bdbcbdcac9d2ac125f35852d"
integrity sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A==
"@img/sharp-libvips-linux-ppc64@1.2.4":
version "1.2.4"
resolved "https://registry.yarnpkg.com/@img/sharp-libvips-linux-ppc64/-/sharp-libvips-linux-ppc64-1.2.4.tgz#4b83ecf2a829057222b38848c7b022e7b4d07aa7"
integrity sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA==
"@img/sharp-libvips-linux-riscv64@1.2.4":
version "1.2.4"
resolved "https://registry.yarnpkg.com/@img/sharp-libvips-linux-riscv64/-/sharp-libvips-linux-riscv64-1.2.4.tgz#880b4678009e5a2080af192332b00b0aaf8a48de"
integrity sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA==
"@img/sharp-libvips-linux-s390x@1.2.4":
version "1.2.4"
resolved "https://registry.yarnpkg.com/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.2.4.tgz#74f343c8e10fad821b38f75ced30488939dc59ec"
integrity sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ==
"@img/sharp-libvips-linux-x64@1.2.4":
version "1.2.4"
resolved "https://registry.yarnpkg.com/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.2.4.tgz#df4183e8bd8410f7d61b66859a35edeab0a531ce"
integrity sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw==
"@img/sharp-libvips-linuxmusl-arm64@1.2.4":
version "1.2.4"
resolved "https://registry.yarnpkg.com/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.2.4.tgz#c8d6b48211df67137541007ee8d1b7b1f8ca8e06"
integrity sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw==
"@img/sharp-libvips-linuxmusl-x64@1.2.4":
version "1.2.4"
resolved "https://registry.yarnpkg.com/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.2.4.tgz#be11c75bee5b080cbee31a153a8779448f919f75"
integrity sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg==
"@img/sharp-linux-arm64@0.34.5":
version "0.34.5"
resolved "https://registry.yarnpkg.com/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.34.5.tgz#7aa7764ef9c001f15e610546d42fce56911790cc"
integrity sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg==
optionalDependencies:
"@img/sharp-libvips-linux-arm64" "1.2.4"
"@img/sharp-linux-arm@0.34.5":
version "0.34.5"
resolved "https://registry.yarnpkg.com/@img/sharp-linux-arm/-/sharp-linux-arm-0.34.5.tgz#5fb0c3695dd12522d39c3ff7a6bc816461780a0d"
integrity sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw==
optionalDependencies:
"@img/sharp-libvips-linux-arm" "1.2.4"
"@img/sharp-linux-ppc64@0.34.5":
version "0.34.5"
resolved "https://registry.yarnpkg.com/@img/sharp-linux-ppc64/-/sharp-linux-ppc64-0.34.5.tgz#9c213a81520a20caf66978f3d4c07456ff2e0813"
integrity sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA==
optionalDependencies:
"@img/sharp-libvips-linux-ppc64" "1.2.4"
"@img/sharp-linux-riscv64@0.34.5":
version "0.34.5"
resolved "https://registry.yarnpkg.com/@img/sharp-linux-riscv64/-/sharp-linux-riscv64-0.34.5.tgz#cdd28182774eadbe04f62675a16aabbccb833f60"
integrity sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw==
optionalDependencies:
"@img/sharp-libvips-linux-riscv64" "1.2.4"
"@img/sharp-linux-s390x@0.34.5":
version "0.34.5"
resolved "https://registry.yarnpkg.com/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.34.5.tgz#93eac601b9f329bb27917e0e19098c722d630df7"
integrity sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg==
optionalDependencies:
"@img/sharp-libvips-linux-s390x" "1.2.4"
"@img/sharp-linux-x64@0.34.5":
version "0.34.5"
resolved "https://registry.yarnpkg.com/@img/sharp-linux-x64/-/sharp-linux-x64-0.34.5.tgz#55abc7cd754ffca5002b6c2b719abdfc846819a8"
integrity sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ==
optionalDependencies:
"@img/sharp-libvips-linux-x64" "1.2.4"
"@img/sharp-linuxmusl-arm64@0.34.5":
version "0.34.5"
resolved "https://registry.yarnpkg.com/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.34.5.tgz#d6515ee971bb62f73001a4829b9d865a11b77086"
integrity sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg==
optionalDependencies:
"@img/sharp-libvips-linuxmusl-arm64" "1.2.4"
"@img/sharp-linuxmusl-x64@0.34.5":
version "0.34.5"
resolved "https://registry.yarnpkg.com/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.34.5.tgz#d97978aec7c5212f999714f2f5b736457e12ee9f"
integrity sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q==
optionalDependencies:
"@img/sharp-libvips-linuxmusl-x64" "1.2.4"
"@img/sharp-wasm32@0.34.5":
version "0.34.5"
resolved "https://registry.yarnpkg.com/@img/sharp-wasm32/-/sharp-wasm32-0.34.5.tgz#2f15803aa626f8c59dd7c9d0bbc766f1ab52cfa0"
integrity sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw==
dependencies:
"@emnapi/runtime" "^1.7.0"
"@img/sharp-win32-arm64@0.34.5":
version "0.34.5"
resolved "https://registry.yarnpkg.com/@img/sharp-win32-arm64/-/sharp-win32-arm64-0.34.5.tgz#3706e9e3ac35fddfc1c87f94e849f1b75307ce0a"
integrity sha512-WQ3AgWCWYSb2yt+IG8mnC6Jdk9Whs7O0gxphblsLvdhSpSTtmu69ZG1Gkb6NuvxsNACwiPV6cNSZNzt0KPsw7g==
"@img/sharp-win32-ia32@0.34.5":
version "0.34.5"
resolved "https://registry.yarnpkg.com/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.34.5.tgz#0b71166599b049e032f085fb9263e02f4e4788de"
integrity sha512-FV9m/7NmeCmSHDD5j4+4pNI8Cp3aW+JvLoXcTUo0IqyjSfAZJ8dIUmijx1qaJsIiU+Hosw6xM5KijAWRJCSgNg==
"@img/sharp-win32-x64@0.34.5":
version "0.34.5"
resolved "https://registry.yarnpkg.com/@img/sharp-win32-x64/-/sharp-win32-x64-0.34.5.tgz#a81ffb00e69267cd0a1d626eaedb8a8430b2b2f8"
integrity sha512-+29YMsqY2/9eFEiW93eqWnuLcWcufowXewwSNIT6UwZdUUCrM3oFjMWH/Z6/TMmb4hlFenmfAVbpWeup2jryCw==
"@inquirer/ansi@^1.0.2":
version "1.0.2"
resolved "https://registry.npmjs.org/@inquirer/ansi/-/ansi-1.0.2.tgz"
@@ -822,22 +550,6 @@
outvariant "^1.4.3"
strict-event-emitter "^0.5.1"
"@napi-rs/wasm-runtime@^0.2.11":
version "0.2.12"
resolved "https://registry.yarnpkg.com/@napi-rs/wasm-runtime/-/wasm-runtime-0.2.12.tgz#3e78a8b96e6c33a6c517e1894efbd5385a7cb6f2"
integrity sha512-ZVWUcfwY4E/yPitQJl481FjFo3K22D6qF0DuFH6Y/nbnE11GY5uguDxZMGXPQ8WQ0128MXQD7TnfHyK4oWoIJQ==
dependencies:
"@emnapi/core" "^1.4.3"
"@emnapi/runtime" "^1.4.3"
"@tybys/wasm-util" "^0.10.0"
"@napi-rs/wasm-runtime@^1.1.1":
version "1.1.4"
resolved "https://registry.yarnpkg.com/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.4.tgz#a46bbfedc29751b7170c5d23bc1d8ee8c7e3c1e1"
integrity sha512-3NQNNgA1YSlJb/kMH1ildASP9HW7/7kYnRI2szWJaofaS1hWmbGI4H+d3+22aGzXXN9IJ+n+GiFVcGipJP18ow==
dependencies:
"@tybys/wasm-util" "^0.10.1"
"@next/env@16.2.2":
version "16.2.2"
resolved "https://registry.npmjs.org/@next/env/-/env-16.2.2.tgz"
@@ -855,42 +567,7 @@
resolved "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-16.2.2.tgz"
integrity sha512-B92G3ulrwmkDSEJEp9+XzGLex5wC1knrmCSIylyVeiAtCIfvEJYiN3v5kXPlYt5R4RFlsfO/v++aKV63Acrugg==
"@next/swc-darwin-x64@16.2.2":
version "16.2.2"
resolved "https://registry.yarnpkg.com/@next/swc-darwin-x64/-/swc-darwin-x64-16.2.2.tgz#729584bfae41fca5e381229a3d1fe0eaf313cc0e"
integrity sha512-7ZwSgNKJNQiwW0CKhNm9B1WS2L1Olc4B2XY0hPYCAL3epFnugMhuw5TMWzMilQ3QCZcCHoYm9NGWTHbr5REFxw==
"@next/swc-linux-arm64-gnu@16.2.2":
version "16.2.2"
resolved "https://registry.yarnpkg.com/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-16.2.2.tgz#edd526da879fe56e4cb82d57aeb5174956c65493"
integrity sha512-c3m8kBHMziMgo2fICOP/cd/5YlrxDU5YYjAJeQLyFsCqVF8xjOTH/QYG4a2u48CvvZZSj1eHQfBCbyh7kBr30Q==
"@next/swc-linux-arm64-musl@16.2.2":
version "16.2.2"
resolved "https://registry.yarnpkg.com/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-16.2.2.tgz#f5990229520663cd759b0eaa426dace2b0510a10"
integrity sha512-VKLuscm0P/mIfzt+SDdn2+8TNNJ7f0qfEkA+az7OqQbjzKdBxAHs0UvuiVoCtbwX+dqMEL9U54b5wQ/aN3dHeg==
"@next/swc-linux-x64-gnu@16.2.2":
version "16.2.2"
resolved "https://registry.yarnpkg.com/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-16.2.2.tgz#49b3fcdf73e0403fde8dc9309488c5dd3d19b587"
integrity sha512-kU3OPHJq6sBUjOk7wc5zJ7/lipn8yGldMoAv4z67j6ov6Xo/JvzA7L7LCsyzzsXmgLEhk3Qkpwqaq/1+XpNR3g==
"@next/swc-linux-x64-musl@16.2.2":
version "16.2.2"
resolved "https://registry.yarnpkg.com/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-16.2.2.tgz#c7fa2e5cb97876efcc8773ae892e426aec1b6f97"
integrity sha512-CKXRILyErMtUftp+coGcZ38ZwE/Aqq45VMCcRLr2I4OXKrgxIBDXHnBgeX/UMil0S09i2JXaDL3Q+TN8D/cKmg==
"@next/swc-win32-arm64-msvc@16.2.2":
version "16.2.2"
resolved "https://registry.yarnpkg.com/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-16.2.2.tgz#4d144d95344d684b62710246b15f306b3ee24341"
integrity sha512-sS/jSk5VUoShUqINJFvNjVT7JfR5ORYj/+/ZpOYbbIohv/lQfduWnGAycq2wlknbOql2xOR0DoV0s6Xfcy49+g==
"@next/swc-win32-x64-msvc@16.2.2":
version "16.2.2"
resolved "https://registry.yarnpkg.com/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-16.2.2.tgz#1120617f423b81a4ba588b289aeafc9c03526b71"
integrity sha512-aHaKceJgdySReT7qeck5oShucxWRiiEuwCGK8HHALe6yZga8uyFpLkPgaRw3kkF04U7ROogL/suYCNt/+CuXGA==
"@noble/ciphers@^1.3.0":
"@noble/ciphers@^1.0.0", "@noble/ciphers@^1.3.0":
version "1.3.0"
resolved "https://registry.npmjs.org/@noble/ciphers/-/ciphers-1.3.0.tgz"
integrity sha512-2I0gnIVPtfnMw9ee9h1dJG7tp81+8Ob3OJb3Mv37rx5L40/b0i7djjCVvGOVqc9AEIQyvyu1i6ypKdFw8R8gQw==
@@ -902,7 +579,7 @@
dependencies:
"@noble/hashes" "1.8.0"
"@noble/hashes@1.8.0", "@noble/hashes@^1.8.0":
"@noble/hashes@^1.8.0", "@noble/hashes@1.8.0":
version "1.8.0"
resolved "https://registry.npmjs.org/@noble/hashes/-/hashes-1.8.0.tgz"
integrity sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A==
@@ -915,7 +592,7 @@
"@nodelib/fs.stat" "2.0.5"
run-parallel "^1.1.9"
"@nodelib/fs.stat@2.0.5", "@nodelib/fs.stat@^2.0.2":
"@nodelib/fs.stat@^2.0.2", "@nodelib/fs.stat@2.0.5":
version "2.0.5"
resolved "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz"
integrity sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==
@@ -1115,73 +792,11 @@
source-map-js "^1.2.1"
tailwindcss "4.2.2"
"@tailwindcss/oxide-android-arm64@4.2.2":
version "4.2.2"
resolved "https://registry.yarnpkg.com/@tailwindcss/oxide-android-arm64/-/oxide-android-arm64-4.2.2.tgz#61d9ec5c18394fe7a972e99e19e6065e833da77c"
integrity sha512-dXGR1n+P3B6748jZO/SvHZq7qBOqqzQ+yFrXpoOWWALWndF9MoSKAT3Q0fYgAzYzGhxNYOoysRvYlpixRBBoDg==
"@tailwindcss/oxide-darwin-arm64@4.2.2":
version "4.2.2"
resolved "https://registry.npmjs.org/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.2.2.tgz"
integrity sha512-iq9Qjr6knfMpZHj55/37ouZeykwbDqF21gPFtfnhCCKGDcPI/21FKC9XdMO/XyBM7qKORx6UIhGgg6jLl7BZlg==
"@tailwindcss/oxide-darwin-x64@4.2.2":
version "4.2.2"
resolved "https://registry.yarnpkg.com/@tailwindcss/oxide-darwin-x64/-/oxide-darwin-x64-4.2.2.tgz#a5899f1fbe55c4eddcbc871b835d5183ba34658c"
integrity sha512-BlR+2c3nzc8f2G639LpL89YY4bdcIdUmiOOkv2GQv4/4M0vJlpXEa0JXNHhCHU7VWOKWT/CjqHdTP8aUuDJkuw==
"@tailwindcss/oxide-freebsd-x64@4.2.2":
version "4.2.2"
resolved "https://registry.yarnpkg.com/@tailwindcss/oxide-freebsd-x64/-/oxide-freebsd-x64-4.2.2.tgz#76185bb1bea9af915a5b9f465323861646587e21"
integrity sha512-YUqUgrGMSu2CDO82hzlQ5qSb5xmx3RUrke/QgnoEx7KvmRJHQuZHZmZTLSuuHwFf0DJPybFMXMYf+WJdxHy/nQ==
"@tailwindcss/oxide-linux-arm-gnueabihf@4.2.2":
version "4.2.2"
resolved "https://registry.yarnpkg.com/@tailwindcss/oxide-linux-arm-gnueabihf/-/oxide-linux-arm-gnueabihf-4.2.2.tgz#74c17c69b2015f7600d566ab0990aaac8701128e"
integrity sha512-FPdhvsW6g06T9BWT0qTwiVZYE2WIFo2dY5aCSpjG/S/u1tby+wXoslXS0kl3/KXnULlLr1E3NPRRw0g7t2kgaQ==
"@tailwindcss/oxide-linux-arm64-gnu@4.2.2":
version "4.2.2"
resolved "https://registry.yarnpkg.com/@tailwindcss/oxide-linux-arm64-gnu/-/oxide-linux-arm64-gnu-4.2.2.tgz#38a846d9d5795bc3b57951172044d8dbb3c79aa6"
integrity sha512-4og1V+ftEPXGttOO7eCmW7VICmzzJWgMx+QXAJRAhjrSjumCwWqMfkDrNu1LXEQzNAwz28NCUpucgQPrR4S2yw==
"@tailwindcss/oxide-linux-arm64-musl@4.2.2":
version "4.2.2"
resolved "https://registry.yarnpkg.com/@tailwindcss/oxide-linux-arm64-musl/-/oxide-linux-arm64-musl-4.2.2.tgz#f4cc4129c17d3f2bcb01efef4d7a2f381e5e3f53"
integrity sha512-oCfG/mS+/+XRlwNjnsNLVwnMWYH7tn/kYPsNPh+JSOMlnt93mYNCKHYzylRhI51X+TbR+ufNhhKKzm6QkqX8ag==
"@tailwindcss/oxide-linux-x64-gnu@4.2.2":
version "4.2.2"
resolved "https://registry.yarnpkg.com/@tailwindcss/oxide-linux-x64-gnu/-/oxide-linux-x64-gnu-4.2.2.tgz#7c4a00b0829e12736bd72ec74e1c08205448cc2e"
integrity sha512-rTAGAkDgqbXHNp/xW0iugLVmX62wOp2PoE39BTCGKjv3Iocf6AFbRP/wZT/kuCxC9QBh9Pu8XPkv/zCZB2mcMg==
"@tailwindcss/oxide-linux-x64-musl@4.2.2":
version "4.2.2"
resolved "https://registry.yarnpkg.com/@tailwindcss/oxide-linux-x64-musl/-/oxide-linux-x64-musl-4.2.2.tgz#711756d7bbe97e221fc041b63a4f385b85ba4321"
integrity sha512-XW3t3qwbIwiSyRCggeO2zxe3KWaEbM0/kW9e8+0XpBgyKU4ATYzcVSMKteZJ1iukJ3HgHBjbg9P5YPRCVUxlnQ==
"@tailwindcss/oxide-wasm32-wasi@4.2.2":
version "4.2.2"
resolved "https://registry.yarnpkg.com/@tailwindcss/oxide-wasm32-wasi/-/oxide-wasm32-wasi-4.2.2.tgz#ed6d28567b7abb8505f824457c236d2cd07ee18e"
integrity sha512-eKSztKsmEsn1O5lJ4ZAfyn41NfG7vzCg496YiGtMDV86jz1q/irhms5O0VrY6ZwTUkFy/EKG3RfWgxSI3VbZ8Q==
dependencies:
"@emnapi/core" "^1.8.1"
"@emnapi/runtime" "^1.8.1"
"@emnapi/wasi-threads" "^1.1.0"
"@napi-rs/wasm-runtime" "^1.1.1"
"@tybys/wasm-util" "^0.10.1"
tslib "^2.8.1"
"@tailwindcss/oxide-win32-arm64-msvc@4.2.2":
version "4.2.2"
resolved "https://registry.yarnpkg.com/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.2.2.tgz#f2d0360e5bc06fe201537fb08193d3780e7dd24f"
integrity sha512-qPmaQM4iKu5mxpsrWZMOZRgZv1tOZpUm+zdhhQP0VhJfyGGO3aUKdbh3gDZc/dPLQwW4eSqWGrrcWNBZWUWaXQ==
"@tailwindcss/oxide-win32-x64-msvc@4.2.2":
version "4.2.2"
resolved "https://registry.yarnpkg.com/@tailwindcss/oxide-win32-x64-msvc/-/oxide-win32-x64-msvc-4.2.2.tgz#10fc71b73883f9c3999b5b8c338fd96a45240dcb"
integrity sha512-1T/37VvI7WyH66b+vqHj/cLwnCxt7Qt3WFu5Q8hk65aOvlwAhs7rAp1VkulBJw/N4tMirXjVnylTR72uI0HGcA==
"@tailwindcss/oxide@4.2.2":
version "4.2.2"
resolved "https://registry.npmjs.org/@tailwindcss/oxide/-/oxide-4.2.2.tgz"
@@ -1220,13 +835,6 @@
minimatch "^10.0.1"
path-browserify "^1.0.1"
"@tybys/wasm-util@^0.10.0", "@tybys/wasm-util@^0.10.1":
version "0.10.2"
resolved "https://registry.yarnpkg.com/@tybys/wasm-util/-/wasm-util-0.10.2.tgz#12b3a1b33db1f9cad4ddff1f604ab7dd00bf464e"
integrity sha512-RoBvJ2X0wuKlWFIjrwffGw1IqZHKQqzIchKaadZZfnNpsAYp2mM0h36JtPCjNDAHGgYez/15uMBpfGwchhiMgg==
dependencies:
tslib "^2.4.0"
"@types/estree@^1.0.6":
version "1.0.8"
resolved "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz"
@@ -1242,19 +850,19 @@
resolved "https://registry.npmjs.org/@types/json5/-/json5-0.0.29.tgz"
integrity sha512-dRLjCWHYg4oaA77cxO64oO+7JwCwnIzkZPdrrC71jQmQtlhM556pwKo5bUzqvZndkVbeFLIIi+9TC40JNF5hNQ==
"@types/node@^20":
"@types/node@^20", "@types/node@>=18":
version "20.19.38"
resolved "https://registry.npmjs.org/@types/node/-/node-20.19.38.tgz"
integrity sha512-grSwveyouVpXDwUvnpIb5noOpZQGOzbVdZXdjw8P9WOjnrUenKj2YuIh35OpXQ+UCmMQEgyvRobT5uuK9iDCUQ==
dependencies:
undici-types "~6.21.0"
"@types/react-dom@^19":
"@types/react-dom@*", "@types/react-dom@^19":
version "19.2.3"
resolved "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.3.tgz"
integrity sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==
"@types/react@^19":
"@types/react@*", "@types/react@^17 || ^18 || ^19", "@types/react@^19", "@types/react@^19.2.0":
version "19.2.14"
resolved "https://registry.npmjs.org/@types/react/-/react-19.2.14.tgz"
integrity sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==
@@ -1285,7 +893,7 @@
natural-compare "^1.4.0"
ts-api-utils "^2.5.0"
"@typescript-eslint/parser@8.58.0":
"@typescript-eslint/parser@^8.58.0", "@typescript-eslint/parser@8.58.0":
version "8.58.0"
resolved "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.58.0.tgz"
integrity sha512-rLoGZIf9afaRBYsPUMtvkDWykwXwUPL60HebR4JgTI8mxfFe2cQTu3AGitANp4b9B2QlVru6WzjgB2IzJKiCSA==
@@ -1313,7 +921,7 @@
"@typescript-eslint/types" "8.58.0"
"@typescript-eslint/visitor-keys" "8.58.0"
"@typescript-eslint/tsconfig-utils@8.58.0", "@typescript-eslint/tsconfig-utils@^8.58.0":
"@typescript-eslint/tsconfig-utils@^8.58.0", "@typescript-eslint/tsconfig-utils@8.58.0":
version "8.58.0"
resolved "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.58.0.tgz"
integrity sha512-doNSZEVJsWEu4htiVC+PR6NpM+pa+a4ClH9INRWOWCUzMst/VA9c4gXq92F8GUD1rwhNvRLkgjfYtFXegXQF7A==
@@ -1329,7 +937,7 @@
debug "^4.4.3"
ts-api-utils "^2.5.0"
"@typescript-eslint/types@8.58.0", "@typescript-eslint/types@^8.58.0":
"@typescript-eslint/types@^8.58.0", "@typescript-eslint/types@8.58.0":
version "8.58.0"
resolved "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.58.0.tgz"
integrity sha512-O9CjxypDT89fbHxRfETNoAnHj/i6IpRK0CvbVN3qibxlLdo5p5hcLmUuCCrHMpxiWSwKyI8mCP7qRNYuOJ0Uww==
@@ -1367,103 +975,11 @@
"@typescript-eslint/types" "8.58.0"
eslint-visitor-keys "^5.0.0"
"@unrs/resolver-binding-android-arm-eabi@1.11.1":
version "1.11.1"
resolved "https://registry.yarnpkg.com/@unrs/resolver-binding-android-arm-eabi/-/resolver-binding-android-arm-eabi-1.11.1.tgz#9f5b04503088e6a354295e8ea8fe3cb99e43af81"
integrity sha512-ppLRUgHVaGRWUx0R0Ut06Mjo9gBaBkg3v/8AxusGLhsIotbBLuRk51rAzqLC8gq6NyyAojEXglNjzf6R948DNw==
"@unrs/resolver-binding-android-arm64@1.11.1":
version "1.11.1"
resolved "https://registry.yarnpkg.com/@unrs/resolver-binding-android-arm64/-/resolver-binding-android-arm64-1.11.1.tgz#7414885431bd7178b989aedc4d25cccb3865bc9f"
integrity sha512-lCxkVtb4wp1v+EoN+HjIG9cIIzPkX5OtM03pQYkG+U5O/wL53LC4QbIeazgiKqluGeVEeBlZahHalCaBvU1a2g==
"@unrs/resolver-binding-darwin-arm64@1.11.1":
version "1.11.1"
resolved "https://registry.npmjs.org/@unrs/resolver-binding-darwin-arm64/-/resolver-binding-darwin-arm64-1.11.1.tgz"
integrity sha512-gPVA1UjRu1Y/IsB/dQEsp2V1pm44Of6+LWvbLc9SDk1c2KhhDRDBUkQCYVWe6f26uJb3fOK8saWMgtX8IrMk3g==
"@unrs/resolver-binding-darwin-x64@1.11.1":
version "1.11.1"
resolved "https://registry.yarnpkg.com/@unrs/resolver-binding-darwin-x64/-/resolver-binding-darwin-x64-1.11.1.tgz#fd4d81257b13f4d1a083890a6a17c00de571f0dc"
integrity sha512-cFzP7rWKd3lZaCsDze07QX1SC24lO8mPty9vdP+YVa3MGdVgPmFc59317b2ioXtgCMKGiCLxJ4HQs62oz6GfRQ==
"@unrs/resolver-binding-freebsd-x64@1.11.1":
version "1.11.1"
resolved "https://registry.yarnpkg.com/@unrs/resolver-binding-freebsd-x64/-/resolver-binding-freebsd-x64-1.11.1.tgz#d2513084d0f37c407757e22f32bd924a78cfd99b"
integrity sha512-fqtGgak3zX4DCB6PFpsH5+Kmt/8CIi4Bry4rb1ho6Av2QHTREM+47y282Uqiu3ZRF5IQioJQ5qWRV6jduA+iGw==
"@unrs/resolver-binding-linux-arm-gnueabihf@1.11.1":
version "1.11.1"
resolved "https://registry.yarnpkg.com/@unrs/resolver-binding-linux-arm-gnueabihf/-/resolver-binding-linux-arm-gnueabihf-1.11.1.tgz#844d2605d057488d77fab09705f2866b86164e0a"
integrity sha512-u92mvlcYtp9MRKmP+ZvMmtPN34+/3lMHlyMj7wXJDeXxuM0Vgzz0+PPJNsro1m3IZPYChIkn944wW8TYgGKFHw==
"@unrs/resolver-binding-linux-arm-musleabihf@1.11.1":
version "1.11.1"
resolved "https://registry.yarnpkg.com/@unrs/resolver-binding-linux-arm-musleabihf/-/resolver-binding-linux-arm-musleabihf-1.11.1.tgz#204892995cefb6bd1d017d52d097193bc61ddad3"
integrity sha512-cINaoY2z7LVCrfHkIcmvj7osTOtm6VVT16b5oQdS4beibX2SYBwgYLmqhBjA1t51CarSaBuX5YNsWLjsqfW5Cw==
"@unrs/resolver-binding-linux-arm64-gnu@1.11.1":
version "1.11.1"
resolved "https://registry.yarnpkg.com/@unrs/resolver-binding-linux-arm64-gnu/-/resolver-binding-linux-arm64-gnu-1.11.1.tgz#023eb0c3aac46066a10be7a3f362e7b34f3bdf9d"
integrity sha512-34gw7PjDGB9JgePJEmhEqBhWvCiiWCuXsL9hYphDF7crW7UgI05gyBAi6MF58uGcMOiOqSJ2ybEeCvHcq0BCmQ==
"@unrs/resolver-binding-linux-arm64-musl@1.11.1":
version "1.11.1"
resolved "https://registry.yarnpkg.com/@unrs/resolver-binding-linux-arm64-musl/-/resolver-binding-linux-arm64-musl-1.11.1.tgz#9e6f9abb06424e3140a60ac996139786f5d99be0"
integrity sha512-RyMIx6Uf53hhOtJDIamSbTskA99sPHS96wxVE/bJtePJJtpdKGXO1wY90oRdXuYOGOTuqjT8ACccMc4K6QmT3w==
"@unrs/resolver-binding-linux-ppc64-gnu@1.11.1":
version "1.11.1"
resolved "https://registry.yarnpkg.com/@unrs/resolver-binding-linux-ppc64-gnu/-/resolver-binding-linux-ppc64-gnu-1.11.1.tgz#b111417f17c9d1b02efbec8e08398f0c5527bb44"
integrity sha512-D8Vae74A4/a+mZH0FbOkFJL9DSK2R6TFPC9M+jCWYia/q2einCubX10pecpDiTmkJVUH+y8K3BZClycD8nCShA==
"@unrs/resolver-binding-linux-riscv64-gnu@1.11.1":
version "1.11.1"
resolved "https://registry.yarnpkg.com/@unrs/resolver-binding-linux-riscv64-gnu/-/resolver-binding-linux-riscv64-gnu-1.11.1.tgz#92ffbf02748af3e99873945c9a8a5ead01d508a9"
integrity sha512-frxL4OrzOWVVsOc96+V3aqTIQl1O2TjgExV4EKgRY09AJ9leZpEg8Ak9phadbuX0BA4k8U5qtvMSQQGGmaJqcQ==
"@unrs/resolver-binding-linux-riscv64-musl@1.11.1":
version "1.11.1"
resolved "https://registry.yarnpkg.com/@unrs/resolver-binding-linux-riscv64-musl/-/resolver-binding-linux-riscv64-musl-1.11.1.tgz#0bec6f1258fc390e6b305e9ff44256cb207de165"
integrity sha512-mJ5vuDaIZ+l/acv01sHoXfpnyrNKOk/3aDoEdLO/Xtn9HuZlDD6jKxHlkN8ZhWyLJsRBxfv9GYM2utQ1SChKew==
"@unrs/resolver-binding-linux-s390x-gnu@1.11.1":
version "1.11.1"
resolved "https://registry.yarnpkg.com/@unrs/resolver-binding-linux-s390x-gnu/-/resolver-binding-linux-s390x-gnu-1.11.1.tgz#577843a084c5952f5906770633ccfb89dac9bc94"
integrity sha512-kELo8ebBVtb9sA7rMe1Cph4QHreByhaZ2QEADd9NzIQsYNQpt9UkM9iqr2lhGr5afh885d/cB5QeTXSbZHTYPg==
"@unrs/resolver-binding-linux-x64-gnu@1.11.1":
version "1.11.1"
resolved "https://registry.yarnpkg.com/@unrs/resolver-binding-linux-x64-gnu/-/resolver-binding-linux-x64-gnu-1.11.1.tgz#36fb318eebdd690f6da32ac5e0499a76fa881935"
integrity sha512-C3ZAHugKgovV5YvAMsxhq0gtXuwESUKc5MhEtjBpLoHPLYM+iuwSj3lflFwK3DPm68660rZ7G8BMcwSro7hD5w==
"@unrs/resolver-binding-linux-x64-musl@1.11.1":
version "1.11.1"
resolved "https://registry.yarnpkg.com/@unrs/resolver-binding-linux-x64-musl/-/resolver-binding-linux-x64-musl-1.11.1.tgz#bfb9af75f783f98f6a22c4244214efe4df1853d6"
integrity sha512-rV0YSoyhK2nZ4vEswT/QwqzqQXw5I6CjoaYMOX0TqBlWhojUf8P94mvI7nuJTeaCkkds3QE4+zS8Ko+GdXuZtA==
"@unrs/resolver-binding-wasm32-wasi@1.11.1":
version "1.11.1"
resolved "https://registry.yarnpkg.com/@unrs/resolver-binding-wasm32-wasi/-/resolver-binding-wasm32-wasi-1.11.1.tgz#752c359dd875684b27429500d88226d7cc72f71d"
integrity sha512-5u4RkfxJm+Ng7IWgkzi3qrFOvLvQYnPBmjmZQ8+szTK/b31fQCnleNl1GgEt7nIsZRIf5PLhPwT0WM+q45x/UQ==
dependencies:
"@napi-rs/wasm-runtime" "^0.2.11"
"@unrs/resolver-binding-win32-arm64-msvc@1.11.1":
version "1.11.1"
resolved "https://registry.yarnpkg.com/@unrs/resolver-binding-win32-arm64-msvc/-/resolver-binding-win32-arm64-msvc-1.11.1.tgz#ce5735e600e4c2fbb409cd051b3b7da4a399af35"
integrity sha512-nRcz5Il4ln0kMhfL8S3hLkxI85BXs3o8EYoattsJNdsX4YUU89iOkVn7g0VHSRxFuVMdM4Q1jEpIId1Ihim/Uw==
"@unrs/resolver-binding-win32-ia32-msvc@1.11.1":
version "1.11.1"
resolved "https://registry.yarnpkg.com/@unrs/resolver-binding-win32-ia32-msvc/-/resolver-binding-win32-ia32-msvc-1.11.1.tgz#72fc57bc7c64ec5c3de0d64ee0d1810317bc60a6"
integrity sha512-DCEI6t5i1NmAZp6pFonpD5m7i6aFrpofcp4LA2i8IIq60Jyo28hamKBxNrZcyOwVOZkgsRp9O2sXWBWP8MnvIQ==
"@unrs/resolver-binding-win32-x64-msvc@1.11.1":
version "1.11.1"
resolved "https://registry.yarnpkg.com/@unrs/resolver-binding-win32-x64-msvc/-/resolver-binding-win32-x64-msvc-1.11.1.tgz#538b1e103bf8d9864e7b85cc96fa8d6fb6c40777"
integrity sha512-lrW200hZdbfRtztbygyaq/6jP6AKE8qQN2KvPcJ+x7wiD038YtnYtZ82IMNJ69GJibV7bwL3y9FgK+5w/pYt6g==
accepts@^2.0.0:
version "2.0.0"
resolved "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz"
@@ -1477,7 +993,7 @@ acorn-jsx@^5.3.2:
resolved "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz"
integrity sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==
acorn@^8.15.0:
"acorn@^6.0.0 || ^7.0.0 || ^8.0.0", acorn@^8.15.0:
version "8.16.0"
resolved "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz"
integrity sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==
@@ -1725,7 +1241,7 @@ braces@^3.0.3:
dependencies:
fill-range "^7.1.1"
browserslist@^4.24.0, browserslist@^4.26.2:
browserslist@^4.24.0, browserslist@^4.26.2, "browserslist@>= 4.21.0":
version "4.28.2"
resolved "https://registry.npmjs.org/browserslist/-/browserslist-4.28.2.tgz"
integrity sha512-48xSriZYYg+8qXna9kwqjIVzuQxi+KYWp2+5nCYnYKPTr0LvD89Jqk2Or5ogxz0NUMfIjhh2lIUX/LyX9B4oIg==
@@ -1976,12 +1492,10 @@ data-view-byte-offset@^1.0.1:
es-errors "^1.3.0"
is-data-view "^1.0.1"
debug@4, debug@^4.1.0, debug@^4.3.1, debug@^4.3.2, debug@^4.4.0, debug@^4.4.3:
version "4.4.3"
resolved "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz"
integrity sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==
dependencies:
ms "^2.1.3"
date-fns@^4.0.0:
version "4.4.0"
resolved "https://registry.npmjs.org/date-fns/-/date-fns-4.4.0.tgz"
integrity sha512-+1UMbeh68lH1SegH83CGWwpb6OHHbpSgr3+s5Eww5M4CAgswBpoWS0AjTOfEJ33HiYKz1hdj/KTFprzXHmq/6w==
debug@^3.2.7:
version "3.2.7"
@@ -1990,6 +1504,13 @@ debug@^3.2.7:
dependencies:
ms "^2.1.1"
debug@^4.1.0, debug@^4.3.1, debug@^4.3.2, debug@^4.4.0, debug@^4.4.3, debug@4:
version "4.4.3"
resolved "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz"
integrity sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==
dependencies:
ms "^2.1.3"
dedent@^1.6.0:
version "1.7.2"
resolved "https://registry.npmjs.org/dedent/-/dedent-1.7.2.tgz"
@@ -2359,7 +1880,7 @@ eslint-module-utils@^2.12.1:
dependencies:
debug "^3.2.7"
eslint-plugin-import@^2.32.0:
eslint-plugin-import@*, eslint-plugin-import@^2.32.0:
version "2.32.0"
resolved "https://registry.npmjs.org/eslint-plugin-import/-/eslint-plugin-import-2.32.0.tgz"
integrity sha512-whOE1HFo/qJDyX4SnXzP4N6zOWn79WhnCUY/iDR0mPfQZO8wcYE4JClzI2oZrhBnnMUCBCHZhO6VQyoBU95mZA==
@@ -2463,7 +1984,7 @@ eslint-visitor-keys@^5.0.0:
resolved "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz"
integrity sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==
eslint@^9:
eslint@*, "eslint@^2 || ^3 || ^4 || ^5 || ^6 || ^7.2.0 || ^8 || ^9", "eslint@^3 || ^4 || ^5 || ^6 || ^7 || ^8 || ^9", "eslint@^3 || ^4 || ^5 || ^6 || ^7 || ^8 || ^9.7", "eslint@^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0 || ^9.0.0", "eslint@^6.0.0 || ^7.0.0 || >=8.0.0", "eslint@^8.57.0 || ^9.0.0 || ^10.0.0", eslint@^9, eslint@>=9.0.0:
version "9.39.4"
resolved "https://registry.npmjs.org/eslint/-/eslint-9.39.4.tgz"
integrity sha512-XoMjdBOwe/esVgEvLmNsD3IRHkm7fbKIUGvrleloJXUZgDHig2IPWNniv+GwjyJXzuNqVjlr5+4yVUZjycJwfQ==
@@ -2598,7 +2119,7 @@ express-rate-limit@^8.2.1:
dependencies:
ip-address "10.1.0"
express@^5.2.1:
express@^5.2.1, "express@>= 4.11":
version "5.2.1"
resolved "https://registry.npmjs.org/express/-/express-5.2.1.tgz"
integrity sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==
@@ -2637,17 +2158,6 @@ fast-deep-equal@^3.1.1, fast-deep-equal@^3.1.3:
resolved "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz"
integrity sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==
fast-glob@3.3.1:
version "3.3.1"
resolved "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.1.tgz"
integrity sha512-kNFPyjhh5cKjrUltxs+wFx+ZkbRaxxmZ+X0ZU31SOsxCEtP9VPgtq2teZw1DebupL5GmDaNQ6yKMMVcM41iqDg==
dependencies:
"@nodelib/fs.stat" "^2.0.2"
"@nodelib/fs.walk" "^1.2.3"
glob-parent "^5.1.2"
merge2 "^1.3.0"
micromatch "^4.0.4"
fast-glob@^3.3.3:
version "3.3.3"
resolved "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.3.tgz"
@@ -2659,6 +2169,17 @@ fast-glob@^3.3.3:
merge2 "^1.3.0"
micromatch "^4.0.8"
fast-glob@3.3.1:
version "3.3.1"
resolved "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.1.tgz"
integrity sha512-kNFPyjhh5cKjrUltxs+wFx+ZkbRaxxmZ+X0ZU31SOsxCEtP9VPgtq2teZw1DebupL5GmDaNQ6yKMMVcM41iqDg==
dependencies:
"@nodelib/fs.stat" "^2.0.2"
"@nodelib/fs.walk" "^1.2.3"
glob-parent "^5.1.2"
merge2 "^1.3.0"
micromatch "^4.0.4"
fast-json-stable-stringify@^2.0.0:
version "2.1.0"
resolved "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz"
@@ -2769,7 +2290,7 @@ forwarded@0.2.0:
framer-motion@^12.38.0:
version "12.38.0"
resolved "https://registry.yarnpkg.com/framer-motion/-/framer-motion-12.38.0.tgz#cf28e072a95942881ca4e33fd33be41192fd146b"
resolved "https://registry.npmjs.org/framer-motion/-/framer-motion-12.38.0.tgz"
integrity sha512-rFYkY/pigbcswl1XQSb7q424kSTQ8q6eAC+YUsSKooHQYuLdzdHjrt6uxUC+PRAO++q5IS7+TamgIw1AphxR+g==
dependencies:
motion-dom "^12.38.0"
@@ -2919,16 +2440,16 @@ glob-parent@^6.0.2:
dependencies:
is-glob "^4.0.3"
globals@16.4.0:
version "16.4.0"
resolved "https://registry.npmjs.org/globals/-/globals-16.4.0.tgz"
integrity sha512-ob/2LcVVaVGCYN+r14cnwnoDPUufjiYgSqRhiFD0Q1iI4Odora5RE8Iv1D24hAz5oMophRGkGz+yuvQmmUMnMw==
globals@^14.0.0:
version "14.0.0"
resolved "https://registry.npmjs.org/globals/-/globals-14.0.0.tgz"
integrity sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==
globals@16.4.0:
version "16.4.0"
resolved "https://registry.npmjs.org/globals/-/globals-16.4.0.tgz"
integrity sha512-ob/2LcVVaVGCYN+r14cnwnoDPUufjiYgSqRhiFD0Q1iI4Odora5RE8Iv1D24hAz5oMophRGkGz+yuvQmmUMnMw==
globalthis@^1.0.4:
version "1.0.4"
resolved "https://registry.npmjs.org/globalthis/-/globalthis-1.0.4.tgz"
@@ -3012,7 +2533,7 @@ hermes-parser@^0.25.1:
dependencies:
hermes-estree "0.25.1"
hono@^4.11.4:
hono@^4, hono@^4.11.4:
version "4.12.12"
resolved "https://registry.npmjs.org/hono/-/hono-4.12.12.tgz"
integrity sha512-p1JfQMKaceuCbpJKAPKVqyqviZdS0eUxH9v82oWo1kb9xjQ5wA6iP3FNVAPDFlz5/p7d45lO+BpSk1tuSZMF4Q==
@@ -3401,7 +2922,7 @@ iterator.prototype@^1.1.5:
has-symbols "^1.1.0"
set-function-name "^2.0.2"
jiti@^2.6.1:
jiti@*, jiti@^2.6.1:
version "2.6.1"
resolved "https://registry.npmjs.org/jiti/-/jiti-2.6.1.tgz"
integrity sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==
@@ -3526,61 +3047,11 @@ levn@^0.4.1:
prelude-ls "^1.2.1"
type-check "~0.4.0"
lightningcss-android-arm64@1.32.0:
version "1.32.0"
resolved "https://registry.yarnpkg.com/lightningcss-android-arm64/-/lightningcss-android-arm64-1.32.0.tgz#f033885116dfefd9c6f54787523e3514b61e1968"
integrity sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==
lightningcss-darwin-arm64@1.32.0:
version "1.32.0"
resolved "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.32.0.tgz"
integrity sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==
lightningcss-darwin-x64@1.32.0:
version "1.32.0"
resolved "https://registry.yarnpkg.com/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.32.0.tgz#35f3e97332d130b9ca181e11b568ded6aebc6d5e"
integrity sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==
lightningcss-freebsd-x64@1.32.0:
version "1.32.0"
resolved "https://registry.yarnpkg.com/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.32.0.tgz#9777a76472b64ed6ff94342ad64c7bafd794a575"
integrity sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==
lightningcss-linux-arm-gnueabihf@1.32.0:
version "1.32.0"
resolved "https://registry.yarnpkg.com/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.32.0.tgz#13ae652e1ab73b9135d7b7da172f666c410ad53d"
integrity sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==
lightningcss-linux-arm64-gnu@1.32.0:
version "1.32.0"
resolved "https://registry.yarnpkg.com/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.32.0.tgz#417858795a94592f680123a1b1f9da8a0e1ef335"
integrity sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==
lightningcss-linux-arm64-musl@1.32.0:
version "1.32.0"
resolved "https://registry.yarnpkg.com/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.32.0.tgz#6be36692e810b718040802fd809623cffe732133"
integrity sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==
lightningcss-linux-x64-gnu@1.32.0:
version "1.32.0"
resolved "https://registry.yarnpkg.com/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.32.0.tgz#0b7803af4eb21cfd38dd39fe2abbb53c7dd091f6"
integrity sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==
lightningcss-linux-x64-musl@1.32.0:
version "1.32.0"
resolved "https://registry.yarnpkg.com/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.32.0.tgz#88dc8ba865ddddb1ac5ef04b0f161804418c163b"
integrity sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==
lightningcss-win32-arm64-msvc@1.32.0:
version "1.32.0"
resolved "https://registry.yarnpkg.com/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.32.0.tgz#4f30ba3fa5e925f5b79f945e8cc0d176c3b1ab38"
integrity sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==
lightningcss-win32-x64-msvc@1.32.0:
version "1.32.0"
resolved "https://registry.yarnpkg.com/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.32.0.tgz#141aa5605645064928902bb4af045fa7d9f4220a"
integrity sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==
lightningcss@1.32.0:
version "1.32.0"
resolved "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz"
@@ -3706,7 +3177,14 @@ mimic-function@^5.0.0:
resolved "https://registry.npmjs.org/mimic-function/-/mimic-function-5.0.1.tgz"
integrity sha512-VP79XUPxV2CigYP3jWwAUFSku2aKqBH7uTAapFWCBqutsbmDo96KY5o8uh6U+/YSIn5OxJnXp73beVkpqMIGhA==
minimatch@^10.0.1, minimatch@^10.2.2:
minimatch@^10.0.1:
version "10.2.5"
resolved "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz"
integrity sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==
dependencies:
brace-expansion "^5.0.5"
minimatch@^10.2.2:
version "10.2.5"
resolved "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz"
integrity sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==
@@ -3727,14 +3205,14 @@ minimist@^1.2.0, minimist@^1.2.6:
motion-dom@^12.38.0:
version "12.38.0"
resolved "https://registry.yarnpkg.com/motion-dom/-/motion-dom-12.38.0.tgz#9ef3253ea0fb28b6757588327073848d940e9aab"
resolved "https://registry.npmjs.org/motion-dom/-/motion-dom-12.38.0.tgz"
integrity sha512-pdkHLD8QYRp8VfiNLb8xIBJis1byQ9gPT3Jnh2jqfFtAsWUA3dEepDlsWe/xMpO8McV+VdpKVcp+E+TGJEtOoA==
dependencies:
motion-utils "^12.36.0"
motion-utils@^12.36.0:
version "12.36.0"
resolved "https://registry.yarnpkg.com/motion-utils/-/motion-utils-12.36.0.tgz#cff2df2a28c3fe53a3de7e0103ba7f73ff7d77a7"
resolved "https://registry.npmjs.org/motion-utils/-/motion-utils-12.36.0.tgz"
integrity sha512-eHWisygbiwVvf6PZ1vhaHCLamvkSbPIeAYxWUuL3a2PD/TROgE7FvfHWTIH4vMl798QLfMw15nRqIaRDXTlYRg==
ms@^2.1.1, ms@^2.1.3:
@@ -4100,7 +3578,7 @@ picomatch@^2.3.1:
resolved "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz"
integrity sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==
picomatch@^4.0.2, picomatch@^4.0.3:
"picomatch@^3 || ^4", picomatch@^4.0.2, picomatch@^4.0.3:
version "4.0.4"
resolved "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz"
integrity sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==
@@ -4123,15 +3601,6 @@ postcss-selector-parser@^7.1.0:
cssesc "^3.0.0"
util-deprecate "^1.0.2"
postcss@8.4.31:
version "8.4.31"
resolved "https://registry.npmjs.org/postcss/-/postcss-8.4.31.tgz"
integrity sha512-PS08Iboia9mts/2ygV3eLpY5ghnUcfLV/EXTOW1E2qYxJKGGBUtNjN76FYHnMs36RmARn41bC0AZmn+rR0OVpQ==
dependencies:
nanoid "^3.3.6"
picocolors "^1.0.0"
source-map-js "^1.0.2"
postcss@^8.5.6:
version "8.5.8"
resolved "https://registry.npmjs.org/postcss/-/postcss-8.5.8.tgz"
@@ -4141,6 +3610,15 @@ postcss@^8.5.6:
picocolors "^1.1.1"
source-map-js "^1.2.1"
postcss@8.4.31:
version "8.4.31"
resolved "https://registry.npmjs.org/postcss/-/postcss-8.4.31.tgz"
integrity sha512-PS08Iboia9mts/2ygV3eLpY5ghnUcfLV/EXTOW1E2qYxJKGGBUtNjN76FYHnMs36RmARn41bC0AZmn+rR0OVpQ==
dependencies:
nanoid "^3.3.6"
picocolors "^1.0.0"
source-map-js "^1.0.2"
powershell-utils@^0.1.0:
version "0.1.0"
resolved "https://registry.npmjs.org/powershell-utils/-/powershell-utils-0.1.0.tgz"
@@ -4215,7 +3693,7 @@ raw-body@^3.0.0, raw-body@^3.0.1:
iconv-lite "~0.7.0"
unpipe "~1.0.0"
react-dom@19.2.4:
"react-dom@^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc", "react-dom@^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom@^16.8 || ^17.0 || ^18.0 || ^19.0.0 || ^19.0.0-rc", "react-dom@^17 || ^18 || ^19", "react-dom@^18.0.0 || ^19.0.0", "react-dom@^18.0.0 || ^19.0.0 || ^19.0.0-rc", "react-dom@^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", react-dom@>=16.8.0, react-dom@19.2.4:
version "19.2.4"
resolved "https://registry.npmjs.org/react-dom/-/react-dom-19.2.4.tgz"
integrity sha512-AXJdLo8kgMbimY95O2aKQqsz2iWi9jMgKJhRBAxECE4IFxfcazB2LmzloIoibJI3C12IlY20+KFaLv+71bUJeQ==
@@ -4254,7 +3732,7 @@ react-style-singleton@^2.2.2, react-style-singleton@^2.2.3:
get-nonce "^1.0.0"
tslib "^2.0.0"
react@19.2.4:
"react@^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0", "react@^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc", "react@^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react@^16.8 || ^17.0 || ^18.0 || ^19.0.0 || ^19.0.0-rc", "react@^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "react@^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc", "react@^17 || ^18 || ^19", "react@^18.0.0 || ^19.0.0", "react@^18.0.0 || ^19.0.0 || ^19.0.0-rc", "react@^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", react@^19.2.4, "react@>= 16.8.0 || 17.x.x || ^18.0.0-0 || ^19.0.0-0", react@>=16.8.0, react@19.2.4:
version "19.2.4"
resolved "https://registry.npmjs.org/react/-/react-19.2.4.tgz"
integrity sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ==
@@ -4417,7 +3895,12 @@ semver@^6.3.1:
resolved "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz"
integrity sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==
semver@^7.7.1, semver@^7.7.3:
semver@^7.7.1:
version "7.7.4"
resolved "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz"
integrity sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==
semver@^7.7.3:
version "7.7.4"
resolved "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz"
integrity sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==
@@ -4626,6 +4109,11 @@ sisteransi@^1.0.5:
resolved "https://registry.npmjs.org/sisteransi/-/sisteransi-1.0.5.tgz"
integrity sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg==
sonner@^2.0.7:
version "2.0.7"
resolved "https://registry.npmjs.org/sonner/-/sonner-2.0.7.tgz"
integrity sha512-W6ZN4p58k8aDKA4XPcx2hpIQXBRAgyiWVkYhT7CvK6D3iAu7xjvVyhQHg2/iaKJZ1XVJ4r7XuwGL+WGEK37i9w==
source-map-js@^1.0.2, source-map-js@^1.2.1:
version "1.2.1"
resolved "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz"
@@ -4822,7 +4310,7 @@ tailwind-merge@^3.0.1, tailwind-merge@^3.5.0:
resolved "https://registry.npmjs.org/tailwind-merge/-/tailwind-merge-3.5.0.tgz"
integrity sha512-I8K9wewnVDkL1NTGoqWmVEIlUcB9gFriAEkXkfCjX5ib8ezGxtR3xD7iZIxrfArjEsH7F1CHD4RFUtxefdqV/A==
tailwindcss@4.2.2, tailwindcss@^4:
tailwindcss@^4, tailwindcss@4.2.2:
version "4.2.2"
resolved "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.2.2.tgz"
integrity sha512-KWBIxs1Xb6NoLdMVqhbhgwZf2PGBpPEiwOqgI4pFIYbNTfBXiKYyWoTsXgBQ9WFg/OlhnvHaY+AEpW7wSmFo2Q==
@@ -4908,7 +4396,7 @@ tsconfig-paths@^4.2.0:
minimist "^1.2.6"
strip-bom "^3.0.0"
tslib@^2.0.0, tslib@^2.0.1, tslib@^2.1.0, tslib@^2.4.0, tslib@^2.8.0, tslib@^2.8.1:
tslib@^2.0.0, tslib@^2.0.1, tslib@^2.1.0, tslib@^2.4.0, tslib@^2.8.0:
version "2.8.1"
resolved "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz"
integrity sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==
@@ -5006,7 +4494,7 @@ typescript-eslint@^8.46.0:
"@typescript-eslint/typescript-estree" "8.58.0"
"@typescript-eslint/utils" "8.58.0"
typescript@^5:
typescript@^5, "typescript@>= 4.8.x", typescript@>=3.3.1, typescript@>=4.8.4, "typescript@>=4.8.4 <6.1.0", typescript@>=4.9.5:
version "5.9.3"
resolved "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz"
integrity sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==
@@ -5298,12 +4786,17 @@ zod-to-json-schema@^3.24.6, zod-to-json-schema@^3.25.1:
resolved "https://registry.npmjs.org/zod-validation-error/-/zod-validation-error-4.0.2.tgz"
integrity sha512-Q6/nZLe6jxuU80qb/4uJ4t5v2VEZ44lzQjPDhYJNztRQ4wyWc6VF3D3Kb/fAuPetZQnhS3hnajCf9CsWesghLQ==
zod@^3.24.1:
zod@^3.24.1, "zod@^3.25.28 || ^4":
version "3.25.76"
resolved "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz"
integrity sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==
"zod@^3.25 || ^4.0", "zod@^3.25.0 || ^4.0.0":
"zod@^3.25 || ^4.0":
version "4.3.6"
resolved "https://registry.npmjs.org/zod/-/zod-4.3.6.tgz"
integrity sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==
"zod@^3.25.0 || ^4.0.0":
version "4.3.6"
resolved "https://registry.npmjs.org/zod/-/zod-4.3.6.tgz"
integrity sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==