feat: add user centric features
This commit is contained in:
+11
-61
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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'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'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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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'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>
|
||||
);
|
||||
}
|
||||
@@ -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
|
||||
“Krsna” finds “Kṛṣṇa.”
|
||||
</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
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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't posted anything yet. Select any passage in the
|
||||
knowledgebase and use the toolbar to share it to your feed.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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,
|
||||
)}
|
||||
>
|
||||
|
||||
@@ -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'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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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
@@ -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,
|
||||
);
|
||||
}
|
||||
@@ -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>,
|
||||
|
||||
@@ -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?.();
|
||||
},
|
||||
},
|
||||
{
|
||||
|
||||
@@ -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
@@ -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'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
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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",
|
||||
};
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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}`);
|
||||
},
|
||||
};
|
||||
@@ -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
@@ -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();
|
||||
});
|
||||
}
|
||||
|
||||
Generated
+9103
File diff suppressed because it is too large
Load Diff
@@ -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"
|
||||
|
||||
@@ -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==
|
||||
|
||||
Reference in New Issue
Block a user