feat: add user centric features

This commit is contained in:
gosangam
2026-05-30 23:44:08 +05:30
parent 7bd2952caa
commit dcdd2383c6
63 changed files with 16783 additions and 1735 deletions
+109
View File
@@ -0,0 +1,109 @@
"use client";
/**
* AuthGate — a global "require login before continuing" prompt.
*
* Why a gate at all? When an unauthenticated user clicks something that
* needs them to be logged in (Bookmark, Save to collection, etc.) we want to
*
* 1. Show a login/signup modal *on the same page* (no redirect).
* 2. Let them finish the original action right after they log in.
* 3. Keep their URL — bookmarks, scroll position, selection — untouched.
*
* The trick is the imperative `requireAuth()` call: it returns a Promise that
* resolves `true` once the user is authenticated, or `false` if they close
* the modal without doing so. Calling code reads like a sync check:
*
* if (!(await requireAuth())) return;
* // ... proceed with the protected API call
*
* Implementation sketch:
* - We hold a single "pending" promise resolver in a ref so a follow-on
* call to requireAuth (e.g. from another button) hooks onto the same
* modal session.
* - When AuthProvider reports a user (via useAuth) and the modal is open,
* we resolve the promise(s) with `true` and close.
* - When the modal closes without a user, we resolve with `false`.
*/
import {
createContext,
useCallback,
useContext,
useEffect,
useMemo,
useRef,
useState,
} from "react";
import { useAuth } from "./auth-provider";
import { AuthModal } from "./auth-modal";
type AuthGateValue = {
/** Returns true if the user is (or becomes) authenticated; false if they dismiss. */
requireAuth: () => Promise<boolean>;
/** Force-open the modal regardless of auth state — for explicit "Log in" buttons. */
openModal: () => void;
};
const AuthGateContext = createContext<AuthGateValue | null>(null);
export function AuthGateProvider({ children }: { children: React.ReactNode }) {
const { user, loading } = useAuth();
const [open, setOpen] = useState(false);
const resolversRef = useRef<((v: boolean) => void)[]>([]);
const requireAuth = useCallback(async () => {
// Fast path: already logged in.
if (user) return true;
setOpen(true);
return new Promise<boolean>((resolve) => {
resolversRef.current.push(resolve);
});
}, [user]);
const openModal = useCallback(() => {
if (!user) setOpen(true);
}, [user]);
// Auto-resolve pending promises the moment the user becomes authenticated.
// This is what flips us from "modal open, waiting" to "modal closed, action
// continues" without any extra wiring at the call site.
useEffect(() => {
if (user && (open || resolversRef.current.length > 0)) {
const pending = resolversRef.current;
resolversRef.current = [];
setOpen(false);
pending.forEach((r) => r(true));
}
}, [user, open]);
const onDismiss = useCallback(() => {
const pending = resolversRef.current;
resolversRef.current = [];
setOpen(false);
pending.forEach((r) => r(false));
}, []);
const value = useMemo<AuthGateValue>(
() => ({ requireAuth, openModal }),
[requireAuth, openModal],
);
return (
<AuthGateContext.Provider value={value}>
{children}
{/* Render the modal only when we genuinely need it. `!user` guards
against the brief window during which the AuthProvider hasn't
finished hydrating; we'd rather wait than flash the modal. */}
{open && !user && !loading ? <AuthModal onDismiss={onDismiss} /> : null}
</AuthGateContext.Provider>
);
}
export function useAuthGate(): AuthGateValue {
const ctx = useContext(AuthGateContext);
if (!ctx) {
throw new Error("useAuthGate must be used inside <AuthGateProvider>");
}
return ctx;
}
+158
View File
@@ -0,0 +1,158 @@
"use client";
/**
* AuthModal — login + signup tabs in a portal-rendered dialog.
*
* The two forms share the existing LoginForm / SignUpForm widgets, so the
* behaviour is identical to the standalone /login and /signup pages.
*
* The forms call `onSuccess` after they finish, but the modal does NOT close
* from that callback — AuthGate's effect on `user` change handles the close
* + promise resolution. This lets a single source of truth (AuthProvider's
* user state) drive the whole flow.
*
* No router pushes here — the user's URL stays exactly where it was.
*/
import { useState, useEffect } from "react";
import { createPortal } from "react-dom";
import Link from "next/link";
import { X } from "lucide-react";
import { cn } from "@/lib/utils";
import { LoginForm } from "./login-form";
import { SignUpForm } from "./signup-form";
type Mode = "login" | "signup";
export function AuthModal({ onDismiss }: { onDismiss: () => void }) {
const [mode, setMode] = useState<Mode>("login");
// Esc-to-close — matches the pattern used elsewhere (reading settings,
// search overlay) so the keyboard behavior feels consistent.
useEffect(() => {
function onKey(e: KeyboardEvent) {
if (e.key === "Escape") onDismiss();
}
document.addEventListener("keydown", onKey);
return () => document.removeEventListener("keydown", onKey);
}, [onDismiss]);
// Lock scroll on the body while the modal is open.
useEffect(() => {
const prev = document.body.style.overflow;
document.body.style.overflow = "hidden";
return () => {
document.body.style.overflow = prev;
};
}, []);
if (typeof document === "undefined") return null;
return createPortal(
<>
<div
className="fixed inset-0 z-100 bg-foreground/40 backdrop-blur-sm animate-in fade-in-0"
onClick={onDismiss}
aria-hidden
/>
<div
role="dialog"
aria-modal="true"
aria-labelledby="auth-modal-title"
className="fixed left-1/2 top-1/2 z-101 w-full max-w-md -translate-x-1/2 -translate-y-1/2 px-4"
>
<div className="rounded-2xl border border-border bg-card shadow-xl overflow-hidden">
<header className="flex items-center justify-between px-5 py-4 border-b border-border">
<h2
id="auth-modal-title"
className="font-[family-name:var(--font-title)] font-semibold text-xl text-foreground"
>
{mode === "login" ? "Welcome back" : "Create your account"}
</h2>
<button
type="button"
onClick={onDismiss}
className="size-8 rounded-lg text-muted-foreground hover:text-foreground hover:bg-muted inline-flex items-center justify-center"
aria-label="Close"
>
<X className="size-4" />
</button>
</header>
{/* Tab toggle */}
<div className="px-5 pt-4 grid grid-cols-2 gap-1 rounded-lg">
<ModeTab active={mode === "login"} onClick={() => setMode("login")}>
Log in
</ModeTab>
<ModeTab
active={mode === "signup"}
onClick={() => setMode("signup")}
>
Sign up
</ModeTab>
</div>
<div className="px-5 py-5">
{mode === "login" ? (
<LoginForm
/* onSuccess is a no-op here — AuthGate watches `user` directly
and closes the modal + resolves the pending promise itself. */
onSuccess={() => {}}
forgotPasswordHref="/forgot-password"
/>
) : (
<SignUpForm onSuccess={() => {}} />
)}
</div>
<footer className="px-5 pb-5 -mt-1 text-xs text-muted-foreground">
{mode === "login" ? (
<>
Need to reset your password?{" "}
<Link
href="/forgot-password"
className="text-primary hover:underline"
onClick={onDismiss}
>
Click here
</Link>
.
</>
) : (
<>
By signing up you agree to our acceptable-use policy. Posts on
your feed are public.
</>
)}
</footer>
</div>
</div>
</>,
document.body,
);
}
function ModeTab({
active,
onClick,
children,
}: {
active: boolean;
onClick: () => void;
children: React.ReactNode;
}) {
return (
<button
type="button"
onClick={onClick}
className={cn(
"h-10 rounded-md text-sm font-medium transition-colors",
active
? "bg-primary text-primary-foreground"
: "text-muted-foreground hover:text-foreground hover:bg-muted",
)}
>
{children}
</button>
);
}
+93
View File
@@ -0,0 +1,93 @@
"use client";
/**
* LoginForm — pure form widget reused by both the dedicated /login page and
* the AuthGate modal that pops up when an unauthenticated user tries to use
* a protected feature.
*
* `onSuccess` is invoked once login completes. Callers decide what happens
* next (a page redirect, a modal close, etc.). The form itself doesn't
* navigate — that keeps it environment-agnostic.
*
* `forgotPasswordHref` is configurable so the modal can override (it may
* want to dismiss itself or open a different mode).
*/
import { useState } from "react";
import Link from "next/link";
import { useAuth } from "@/components/auth/auth-provider";
import { AuthError } from "@/lib/auth";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
export function LoginForm({
onSuccess,
forgotPasswordHref = "/forgot-password",
showForgotLink = true,
}: {
onSuccess?: () => void;
forgotPasswordHref?: string;
showForgotLink?: boolean;
}) {
const { logIn } = useAuth();
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const [error, setError] = useState<string | null>(null);
const [submitting, setSubmitting] = useState(false);
async function onSubmit(e: React.SyntheticEvent) {
e.preventDefault();
setError(null);
setSubmitting(true);
try {
await logIn(email, password);
onSuccess?.();
} catch (err) {
setError(err instanceof AuthError ? err.message : "Login failed.");
} finally {
setSubmitting(false);
}
}
return (
<form onSubmit={onSubmit} className="space-y-5">
<div className="space-y-1.5">
<Label htmlFor="login-email">Email</Label>
<Input
id="login-email"
type="email"
autoComplete="email"
required
value={email}
onChange={(e) => setEmail(e.target.value)}
/>
</div>
<div className="space-y-1.5">
<div className="flex items-center justify-between">
<Label htmlFor="login-password">Password</Label>
{showForgotLink ? (
<Link
href={forgotPasswordHref}
className="text-xs text-muted-foreground hover:text-foreground"
>
Forgot?
</Link>
) : null}
</div>
<Input
id="login-password"
type="password"
autoComplete="current-password"
required
value={password}
onChange={(e) => setPassword(e.target.value)}
/>
</div>
{error ? <p className="text-sm text-destructive">{error}</p> : null}
<Button type="submit" disabled={submitting} className="w-full h-11 text-base">
{submitting ? "Logging in…" : "Log in"}
</Button>
</form>
);
}
+81
View File
@@ -0,0 +1,81 @@
"use client";
/**
* SignUpForm — paired with LoginForm. Same callback contract:
* `onSuccess` fires once the new account is logged in; callers decide
* what to do next.
*/
import { useState } from "react";
import { useAuth } from "@/components/auth/auth-provider";
import { AuthError } from "@/lib/auth";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
export function SignUpForm({ onSuccess }: { onSuccess?: () => void }) {
const { signUp } = useAuth();
const [name, setName] = useState("");
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const [error, setError] = useState<string | null>(null);
const [submitting, setSubmitting] = useState(false);
async function onSubmit(e: React.SyntheticEvent) {
e.preventDefault();
setError(null);
setSubmitting(true);
try {
await signUp(email, password, name);
onSuccess?.();
} catch (err) {
setError(err instanceof AuthError ? err.message : "Sign-up failed.");
} finally {
setSubmitting(false);
}
}
return (
<form onSubmit={onSubmit} className="space-y-5">
<div className="space-y-1.5">
<Label htmlFor="signup-name">Name</Label>
<Input
id="signup-name"
type="text"
autoComplete="name"
required
value={name}
onChange={(e) => setName(e.target.value)}
/>
</div>
<div className="space-y-1.5">
<Label htmlFor="signup-email">Email</Label>
<Input
id="signup-email"
type="email"
autoComplete="email"
required
value={email}
onChange={(e) => setEmail(e.target.value)}
/>
</div>
<div className="space-y-1.5">
<Label htmlFor="signup-password">Password</Label>
<Input
id="signup-password"
type="password"
autoComplete="new-password"
minLength={8}
required
value={password}
onChange={(e) => setPassword(e.target.value)}
/>
<p className="text-xs text-muted-foreground">At least 8 characters.</p>
</div>
{error ? <p className="text-sm text-destructive">{error}</p> : null}
<Button type="submit" disabled={submitting} className="w-full h-11 text-base">
{submitting ? "Creating account…" : "Sign up"}
</Button>
</form>
);
}
+81 -39
View File
@@ -5,13 +5,17 @@
*
* 1. Loading (auth context hasn't hydrated) — render a quiet placeholder
* so the row width doesn't jump when the user data arrives.
* 2. Logged out — single "Log in" link.
* 2. Logged out — single "Log in" icon/button.
* 3. Logged in — avatar / initials chip that opens a popover with
* "Profile" and "Log out". Built on @base-ui/react/popover so we
* don't pull in additional shadcn primitives.
* "Profile" and "Log out".
*
* Why hand-rolled instead of `@base-ui/react/popover`: outside-click
* dismissal was unreliable for the avatar trigger here. The pattern below
* mirrors `ReadingSettings`: mousedown listener for outside clicks, keyup
* for Escape. That's already proven across the site and stays simple.
*/
import { Popover } from "@base-ui/react/popover";
import { useEffect, useRef, useState } from "react";
import Link from "next/link";
import Image from "next/image";
import { useRouter } from "next/navigation";
@@ -33,6 +37,33 @@ function initials(name: string): string {
export function UserMenu() {
const { user, loading, logOut } = useAuth();
const router = useRouter();
const [open, setOpen] = useState(false);
const panelRef = useRef<HTMLDivElement>(null);
const triggerRef = useRef<HTMLButtonElement>(null);
// Close on outside click + Escape, mirroring ReadingSettings.
useEffect(() => {
if (!open) return;
function onMouseDown(e: MouseEvent) {
if (
panelRef.current &&
!panelRef.current.contains(e.target as Node) &&
triggerRef.current &&
!triggerRef.current.contains(e.target as Node)
) {
setOpen(false);
}
}
function onKey(e: KeyboardEvent) {
if (e.key === "Escape") setOpen(false);
}
document.addEventListener("mousedown", onMouseDown);
document.addEventListener("keydown", onKey);
return () => {
document.removeEventListener("mousedown", onMouseDown);
document.removeEventListener("keydown", onKey);
};
}, [open]);
if (loading) {
// Placeholder keeps the action row width stable until auth hydrates.
@@ -42,8 +73,6 @@ export function UserMenu() {
if (!user) {
// Mobile: compact icon button so the header row fits on 375px viewports.
// Desktop: full "Log in" text — there's plenty of space.
// Same muted→foreground pattern as the search / reading-settings buttons
// so the icon sits at a consistent quiet shade across all themes.
return (
<Link
href="/login"
@@ -57,16 +86,22 @@ export function UserMenu() {
}
async function onLogOut() {
setOpen(false);
await logOut();
router.push("/");
router.refresh();
}
return (
<Popover.Root>
<Popover.Trigger
className="size-9 rounded-full overflow-hidden ring-1 ring-foreground/10 hover:ring-foreground/20 transition-shadow"
<div className="relative">
<button
ref={triggerRef}
type="button"
onClick={() => setOpen((v) => !v)}
aria-haspopup="menu"
aria-expanded={open}
aria-label="Account menu"
className="size-9 rounded-full overflow-hidden ring-1 ring-foreground/10 hover:ring-foreground/20 transition-shadow"
>
{user.avatar_url ? (
<Image
@@ -87,35 +122,42 @@ export function UserMenu() {
{initials(user.name)}
</span>
)}
</Popover.Trigger>
<Popover.Portal>
<Popover.Positioner sideOffset={6} align="end">
<Popover.Popup className="min-w-44 rounded-xl bg-card border border-border shadow-md p-1 outline-none">
<div className="px-3 py-2">
<p className="text-sm font-medium text-foreground truncate">
{user.name}
</p>
<p className="text-xs text-muted-foreground truncate">
{user.email}
</p>
</div>
<div className="h-px bg-border my-1" />
<Link
href="/profile"
className="block rounded-lg px-3 py-2 text-sm text-foreground hover:bg-muted transition-colors"
>
Profile
</Link>
<button
type="button"
onClick={onLogOut}
className="block w-full text-left rounded-lg px-3 py-2 text-sm text-foreground hover:bg-muted transition-colors"
>
Log out
</button>
</Popover.Popup>
</Popover.Positioner>
</Popover.Portal>
</Popover.Root>
</button>
{open ? (
<div
ref={panelRef}
role="menu"
className={cn(
"absolute right-0 top-full mt-2 min-w-48 rounded-xl bg-card",
"border border-border shadow-lg p-1 z-50",
)}
>
<div className="px-3 py-2">
<p className="text-sm font-medium text-foreground truncate">
{user.name}
</p>
<p className="text-xs text-muted-foreground truncate">
{user.email}
</p>
</div>
<div className="h-px bg-border my-1" />
<Link
href="/u/me"
onClick={() => setOpen(false)}
className="block rounded-lg px-3 py-2 text-sm text-foreground hover:bg-muted transition-colors"
>
Profile
</Link>
<button
type="button"
onClick={onLogOut}
className="block w-full text-left rounded-lg px-3 py-2 text-sm text-foreground hover:bg-muted transition-colors"
>
Log out
</button>
</div>
) : null}
</div>
);
}
+213
View File
@@ -0,0 +1,213 @@
/**
* KnowledgebaseIndex — the books / letters / lectures / essays / etc.
* directory layout. Rendered as the search page's empty state so an
* untyped search bar still gives users an editorial entry point into the
* library.
*
* Two sections, top to bottom:
* 1. Start here — three primary entry points (Books, Letters, Lectures)
* rendered as full-width feature tiles with longer copy.
* 2. Explore — a six-card asymmetric grid for the remaining sections.
*
* No hero block; the parent page (Search) carries its own hero so two
* heroes don't stack on top of each other.
*/
import Link from "next/link";
import {
ArrowUpRight,
BookA,
BookOpen,
FileStack,
Mail,
Mic,
Newspaper,
NotebookPen,
PenLine,
Users,
} from "lucide-react";
type Feature = {
href: string;
label: string;
blurb: string;
count?: string;
icon: React.ComponentType<{ className?: string }>;
};
const FEATURED: Feature[] = [
{
href: "/books",
label: "Books",
blurb:
"Bhagavad-gītā, Śrīmad-Bhāgavatam, Caitanya-caritāmṛta and 34 more — each verse with Devanāgarī, transliteration, synonyms, translation and purport.",
count: "37 books",
icon: BookOpen,
},
{
href: "/letters",
label: "Letters",
blurb:
"Three decades of personal correspondence to disciples, family, and officials. Browse by year, recipient, or location.",
count: "6,866 letters",
icon: Mail,
},
{
href: "/transcriptions",
label: "Lectures & Conversations",
blurb:
"Lectures, morning walks, room conversations, and dialogues — the living voice of the Founder-Ācārya preserved in text and audio.",
count: "3,787 transcripts",
icon: Mic,
},
];
const EXPLORE: Feature[] = [
{
href: "/essays",
label: "Essays",
blurb: "Foundational standalone essays.",
count: "23 essays",
icon: PenLine,
},
{
href: "/magazines",
label: "Back to Godhead",
blurb: "The original 1944 – 1973 magazine archive.",
count: "1,124 articles",
icon: Newspaper,
},
{
href: "/journals",
label: "Journals",
blurb: "The Jaladuta Diary and The Beginning.",
count: "300 entries",
icon: NotebookPen,
},
{
href: "/glossary",
label: "Glossary",
blurb: "Sanskrit and Vedic terms with definitions.",
count: "2,749 terms",
icon: BookA,
},
{
href: "/disciples",
label: "Disciples",
blurb: "Initiated disciples 1966 – 1977.",
count: "4,747 names",
icon: Users,
},
{
href: "/other-documents",
label: "Other Documents",
blurb: "Miscellaneous analyses and writings.",
count: "12 documents",
icon: FileStack,
},
];
export function KnowledgebaseIndex() {
return (
<div>
<section className="py-4">
<SectionHeading eyebrow="Start here" title="Three doors in" />
<div className="grid grid-cols-1 md:grid-cols-3 gap-4 sm:gap-5">
{FEATURED.map((f) => (
<FeaturedTile key={f.href} feature={f} />
))}
</div>
</section>
<section className="py-6 sm:py-8">
<SectionHeading eyebrow="Explore" title="The wider library" />
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-3 sm:gap-4">
{EXPLORE.map((f) => (
<ExploreTile key={f.href} feature={f} />
))}
</div>
</section>
</div>
);
}
function FeaturedTile({ feature }: { feature: Feature }) {
const Icon = feature.icon;
return (
<Link
href={feature.href}
className="group relative flex flex-col h-full rounded-2xl bg-card ring-1 ring-foreground/10 hover:ring-foreground/25 hover:shadow-md transition-all p-6"
>
<div className="flex items-start justify-between">
<div className="flex items-center justify-center size-11 rounded-xl bg-primary/12 text-primary">
<Icon className="size-5" />
</div>
<ArrowUpRight className="size-4 text-muted-foreground/60 group-hover:text-foreground transition-colors" />
</div>
<h3 className="mt-5 font-[family-name:var(--font-title)] font-semibold text-2xl sm:text-[1.6rem] leading-tight text-foreground">
{feature.label}
</h3>
<p className="mt-2 text-sm text-muted-foreground leading-relaxed flex-1">
{feature.blurb}
</p>
{feature.count ? (
<p className="mt-5 text-xs uppercase tracking-[0.14em] font-medium text-muted-foreground">
{feature.count}
</p>
) : null}
</Link>
);
}
function ExploreTile({ feature }: { feature: Feature }) {
const Icon = feature.icon;
return (
<Link
href={feature.href}
className="group flex items-start gap-4 rounded-xl bg-card/70 ring-1 ring-foreground/8 hover:ring-foreground/20 hover:bg-card transition-all p-4 sm:p-5"
>
<div className="flex items-center justify-center size-9 rounded-lg bg-secondary text-secondary-foreground/80 shrink-0">
<Icon className="size-4" />
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center justify-between gap-2">
<h4 className="font-[family-name:var(--font-title)] font-semibold text-lg text-foreground leading-tight">
{feature.label}
</h4>
<ArrowUpRight className="size-3.5 text-muted-foreground/50 group-hover:text-foreground transition-colors shrink-0" />
</div>
<p className="text-sm text-muted-foreground mt-1 leading-relaxed">
{feature.blurb}
</p>
{feature.count ? (
<p className="text-xs uppercase tracking-[0.14em] font-medium text-muted-foreground/80 mt-2">
{feature.count}
</p>
) : null}
</div>
</Link>
);
}
function SectionHeading({
eyebrow,
title,
}: {
eyebrow: string;
title: string;
}) {
return (
<div className="mb-6 flex items-baseline gap-4">
<span className="text-xs uppercase tracking-[0.2em] font-semibold text-muted-foreground">
{eyebrow}
</span>
<span className="flex-1 h-px bg-border" />
<h2 className="font-[family-name:var(--font-title)] font-semibold text-xl sm:text-2xl text-foreground">
{title}
</h2>
</div>
);
}
+6 -4
View File
@@ -34,9 +34,11 @@ export function PageHeader({
/** Drop the bordered chrome — useful for pages that want flush title text. */
bare?: boolean;
/**
* Stick to the top below the global SiteHeader. Used by reading pages so
* the chapter title stays visible as the user scrolls long content.
* The offset is `top-14 md:top-16` to clear SiteHeader's height.
* Stick to the top below the global SiteHeader. Used by reading pages
* so the chapter title stays visible as the user scrolls long content.
* The offset is `top-12 sm:top-14` — SiteHeader is `h-12 sm:h-14`, so
* these values land the PageHeader flush with its bottom edge with no
* visible gap.
*/
sticky?: boolean;
className?: string;
@@ -66,7 +68,7 @@ export function PageHeader({
className={cn(
"px-4 sm:px-6",
bare ? "py-6" : "border-b border-border bg-card/85 backdrop-blur py-5",
sticky && "sticky top-14 md:top-16 z-20",
sticky && "sticky top-12 sm:top-14 z-20",
className,
)}
>
+225
View File
@@ -0,0 +1,225 @@
"use client";
/**
* ProfileEditForm — the editor block used in /u/me's edit mode.
*
* Three editable fields:
* • Display name (PATCH /users/me { name })
* • Handle (PATCH /users/me { handle }) — 409 if taken
* • Avatar (R2 presign + PATCH /users/me { avatar_url })
*
* `onDone` is invoked after a successful save OR when the user clicks
* "Cancel" — the parent flips back to view mode.
*
* No log-out button here — that's handled by the sidebar footer so it lives
* outside the edit context.
*/
import { useRef, useState } from "react";
import Image from "next/image";
import { ImagePlus, Save, X } from "lucide-react";
import { useAuth } from "@/components/auth/auth-provider";
import { auth, AuthError, type User } from "@/lib/auth";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
export function ProfileEditForm({
user,
onDone,
}: {
user: User;
onDone: () => void;
}) {
const { setUser } = useAuth();
const fileInput = useRef<HTMLInputElement>(null);
const [name, setName] = useState(user.name);
const [handle, setHandle] = useState(user.handle);
const [avatarUrl, setAvatarUrl] = useState<string | null>(user.avatar_url);
const [saving, setSaving] = useState(false);
const [uploading, setUploading] = useState(false);
const [error, setError] = useState<string | null>(null);
const dirty =
name.trim() !== user.name ||
handle.trim() !== user.handle ||
avatarUrl !== user.avatar_url;
async function onSubmit(e: React.SyntheticEvent) {
e.preventDefault();
setError(null);
setSaving(true);
try {
// Patch only what's changed — keeps the audit log tidy and avoids
// bumping updated_at when the user just clicked "Save" without edits.
const updated = await auth.updateProfile(
name.trim() !== user.name ? name.trim() : undefined,
avatarUrl === user.avatar_url ? undefined : (avatarUrl ?? ""),
handle.trim() !== user.handle ? handle.trim() : undefined,
);
setUser(updated);
onDone();
} catch (err) {
setError(err instanceof AuthError ? err.message : "Could not save.");
} finally {
setSaving(false);
}
}
async function onAvatarChange(e: React.ChangeEvent<HTMLInputElement>) {
const file = e.target.files?.[0];
if (fileInput.current) fileInput.current.value = "";
if (!file) return;
setError(null);
setUploading(true);
try {
const publicURL = await auth.uploadAvatar(file);
setAvatarUrl(publicURL);
} catch (err) {
setError(err instanceof AuthError ? err.message : "Upload failed.");
} finally {
setUploading(false);
}
}
return (
<form onSubmit={onSubmit} className="space-y-6 rounded-2xl bg-card ring-1 ring-foreground/10 p-5 sm:p-6">
<div className="flex items-center justify-between">
<h2 className="font-[family-name:var(--font-title)] text-xl sm:text-2xl font-semibold text-foreground">
Edit profile
</h2>
<button
type="button"
onClick={onDone}
aria-label="Cancel"
className="size-9 rounded-lg text-muted-foreground hover:text-foreground hover:bg-muted inline-flex items-center justify-center"
>
<X className="size-4" />
</button>
</div>
{/* Avatar — same widget pattern as the old /profile form */}
<section className="flex items-center gap-4">
<AvatarPreview src={avatarUrl} name={user.name} />
<div className="flex flex-col gap-2">
<input
ref={fileInput}
type="file"
accept="image/png,image/jpeg,image/webp,image/gif"
hidden
onChange={onAvatarChange}
/>
<Button
variant="outline"
type="button"
onClick={() => fileInput.current?.click()}
disabled={uploading || saving}
className="h-10 px-4 text-sm gap-1.5"
>
<ImagePlus className="size-4" />
{uploading ? "Uploading…" : avatarUrl ? "Change" : "Upload"}
</Button>
{avatarUrl ? (
<Button
variant="ghost"
type="button"
onClick={() => setAvatarUrl(null)}
disabled={uploading || saving}
className="h-10 px-4 text-sm"
>
Remove
</Button>
) : null}
</div>
</section>
<div className="space-y-4">
<div className="space-y-1.5">
<Label htmlFor="profile-name">Display name</Label>
<Input
id="profile-name"
value={name}
onChange={(e) => setName(e.target.value)}
maxLength={120}
required
/>
</div>
<div className="space-y-1.5">
<Label htmlFor="profile-handle">Handle</Label>
<div className="flex items-center gap-2">
<span className="text-sm text-muted-foreground">@</span>
<Input
id="profile-handle"
value={handle}
onChange={(e) => setHandle(e.target.value)}
maxLength={32}
pattern="[a-zA-Z0-9._-]+"
required
/>
</div>
<p className="text-xs text-muted-foreground">
Letters, digits, dashes. Your public profile lives at /u/{handle || user.handle}.
</p>
</div>
<div className="space-y-1.5">
<Label htmlFor="profile-email">Email</Label>
<Input id="profile-email" value={user.email} disabled />
<p className="text-xs text-muted-foreground">
Email changes aren&apos;t supported yet.
</p>
</div>
</div>
{error ? <p className="text-sm text-destructive">{error}</p> : null}
<div className="flex items-center justify-end gap-2 pt-2 border-t border-border">
<Button
type="button"
variant="ghost"
onClick={onDone}
className="h-10 px-4 text-sm"
>
Cancel
</Button>
<Button
type="submit"
disabled={saving || uploading || !dirty}
className="h-10 px-4 text-sm gap-1.5"
>
<Save className="size-4" />
{saving ? "Saving…" : "Save changes"}
</Button>
</div>
</form>
);
}
function AvatarPreview({ src, name }: { src: string | null; name: string }) {
if (src) {
return (
<Image
src={src}
alt={name}
width={80}
height={80}
className="size-20 rounded-full object-cover ring-1 ring-foreground/10"
unoptimized
/>
);
}
const initials =
name
.split(/\s+/)
.map((p) => p[0])
.slice(0, 2)
.join("")
.toUpperCase() || "?";
return (
<div className="size-20 rounded-full bg-muted flex items-center justify-center text-2xl font-medium text-muted-foreground ring-1 ring-foreground/10">
{initials}
</div>
);
}
+88
View File
@@ -0,0 +1,88 @@
"use client";
/**
* ProfileHeader — Instagram-style centered header used at the top of
* /u/me and /u/[handle]. Avatar, display name, @handle, a single stat
* line, and an optional action slot (Edit profile button on owner view).
*
* Replaces the older ProfileViewHeader (left-aligned avatar + sidebar).
*/
import Image from "next/image";
import Link from "next/link";
export function ProfileHeader({
avatarURL,
name,
handle,
postCount,
joinedAt,
editHref,
}: {
avatarURL: string | null;
name: string;
handle: string;
postCount?: number;
joinedAt?: string;
/** When provided, shows an "Edit profile" CTA below the handle. */
editHref?: string;
}) {
return (
<header className="flex flex-col items-center text-center gap-4 pt-4 pb-6 sm:pt-6 sm:pb-8">
<Avatar src={avatarURL} name={name} />
<div className="space-y-1">
<h1 className="font-[family-name:var(--font-title)] font-semibold text-xl sm:text-2xl text-foreground leading-tight">
{name}
</h1>
<p className="text-sm text-muted-foreground">@{handle}</p>
{typeof postCount === "number" || joinedAt ? (
<p className="text-xs text-muted-foreground">
{typeof postCount === "number" ? (
<>
<span className="tabular-nums">{postCount}</span> post
{postCount === 1 ? "" : "s"}
</>
) : null}
{typeof postCount === "number" && joinedAt ? " · " : null}
{joinedAt ? <>Joined {new Date(joinedAt).toLocaleDateString()}</> : null}
</p>
) : null}
</div>
{editHref ? (
<Link
href={editHref}
className="inline-flex items-center justify-center h-9 px-5 rounded-lg border border-border text-sm font-medium text-foreground hover:bg-muted transition-colors"
>
Edit profile
</Link>
) : null}
</header>
);
}
function Avatar({ src, name }: { src: string | null; name: string }) {
if (src) {
return (
<Image
src={src}
alt={name}
width={112}
height={112}
className="size-24 sm:size-28 rounded-full object-cover ring-1 ring-foreground/10"
unoptimized
/>
);
}
const initials =
name
.split(/\s+/)
.map((p) => p[0])
.slice(0, 2)
.join("")
.toUpperCase() || "?";
return (
<div className="size-24 sm:size-28 rounded-full bg-primary/15 text-primary flex items-center justify-center text-3xl font-semibold ring-1 ring-foreground/10">
{initials}
</div>
);
}
+189
View File
@@ -0,0 +1,189 @@
"use client";
/**
* ProfileSidebar — the navigation rail for /u/me.
*
* • Desktop (md+): permanent left column, ~240px wide, sticky below the
* SiteHeader so it stays in view while content scrolls.
* • Mobile: a hamburger button reveals the same nav as a left drawer.
*
* Items are intentionally just three (Profile / Collections / Bookmarks) to
* keep the surface focused; "Log out" sits as a footer action separated by
* a divider.
*/
import { useState } from "react";
import Link from "next/link";
import Image from "next/image";
import { usePathname, useRouter } from "next/navigation";
import { User as UserIcon, FolderOpen, Bookmark, LogOut, Menu } from "lucide-react";
import {
Drawer,
DrawerContent,
DrawerDescription,
DrawerHeader,
DrawerTitle,
DrawerTrigger,
} from "@/components/ui/drawer";
import { useAuth } from "@/components/auth/auth-provider";
import type { User } from "@/lib/auth";
import { cn } from "@/lib/utils";
type NavItem = {
href: string;
label: string;
icon: typeof UserIcon;
/** Match only when pathname === href; otherwise startsWith is used. */
exact?: boolean;
};
const ITEMS: NavItem[] = [
{ href: "/u/me", label: "Profile", icon: UserIcon, exact: true },
{ href: "/u/me/collections", label: "Collections", icon: FolderOpen },
{ href: "/u/me/bookmarks", label: "Bookmarks", icon: Bookmark },
];
export function ProfileSidebar({ user }: { user: User }) {
return (
<>
{/* Desktop rail — always visible on md+ */}
<aside className="hidden md:block w-60 shrink-0">
<div className="sticky top-20 space-y-4">
<SidebarHeader user={user} />
<SidebarNav onItemClick={() => {}} />
<SidebarFooter />
</div>
</aside>
{/* Mobile trigger + drawer */}
<MobileSidebar user={user} />
</>
);
}
function MobileSidebar({ user }: { user: User }) {
const [open, setOpen] = useState(false);
return (
<div className="md:hidden mb-4">
<Drawer open={open} onOpenChange={setOpen} direction="left">
<DrawerTrigger
className={cn(
"inline-flex items-center gap-2 rounded-lg border border-border bg-card",
"px-3 h-10 text-sm font-medium text-foreground hover:bg-muted transition-colors",
)}
>
<Menu className="size-4" />
Library menu
</DrawerTrigger>
<DrawerContent direction="left" className="h-full w-72 max-w-[80%]">
<DrawerHeader className="text-left border-b border-border pb-4">
<DrawerTitle className="font-[family-name:var(--font-title)] font-semibold uppercase tracking-[0.14em] text-sm">
Your library
</DrawerTitle>
<DrawerDescription className="sr-only">
Switch between profile, collections, and bookmarks.
</DrawerDescription>
</DrawerHeader>
<div className="overflow-y-auto flex-1 p-4 space-y-4">
<SidebarHeader user={user} />
<SidebarNav onItemClick={() => setOpen(false)} />
<SidebarFooter />
</div>
</DrawerContent>
</Drawer>
</div>
);
}
function SidebarHeader({ user }: { user: User }) {
return (
<div className="flex items-center gap-3 px-3 py-3 rounded-lg bg-card ring-1 ring-foreground/10">
<Avatar src={user.avatar_url} name={user.name} />
<div className="min-w-0">
<p className="text-sm font-semibold text-foreground truncate">
{user.name}
</p>
<p className="text-xs text-muted-foreground truncate">@{user.handle}</p>
</div>
</div>
);
}
function SidebarNav({ onItemClick }: { onItemClick: () => void }) {
const pathname = usePathname();
return (
<nav className="space-y-0.5" aria-label="Library sections">
{ITEMS.map((item) => {
const active = item.exact
? pathname === item.href
: pathname.startsWith(item.href);
const Icon = item.icon;
return (
<Link
key={item.href}
href={item.href}
onClick={onItemClick}
className={cn(
"flex items-center gap-3 px-3 py-2.5 rounded-lg text-sm font-medium transition-colors",
active
? "bg-primary/10 text-primary"
: "text-muted-foreground hover:bg-muted hover:text-foreground",
)}
>
<Icon className="size-4" />
{item.label}
</Link>
);
})}
</nav>
);
}
function SidebarFooter() {
const { logOut } = useAuth();
const router = useRouter();
async function onLogOut() {
await logOut();
router.push("/");
router.refresh();
}
return (
<div className="pt-3 border-t border-border">
<button
type="button"
onClick={onLogOut}
className="flex items-center gap-3 w-full px-3 py-2.5 rounded-lg text-sm text-muted-foreground hover:bg-muted hover:text-foreground transition-colors"
>
<LogOut className="size-4" />
Log out
</button>
</div>
);
}
function Avatar({ src, name }: { src: string | null; name: string }) {
if (src) {
return (
<Image
src={src}
alt={name}
width={40}
height={40}
className="size-10 rounded-full object-cover ring-1 ring-foreground/10 shrink-0"
unoptimized
/>
);
}
const initials =
name
.split(/\s+/)
.map((p) => p[0])
.slice(0, 2)
.join("")
.toUpperCase() || "?";
return (
<div className="size-10 rounded-full bg-primary/15 text-primary flex items-center justify-center text-sm font-semibold ring-1 ring-foreground/10 shrink-0">
{initials}
</div>
);
}
+71
View File
@@ -0,0 +1,71 @@
"use client";
/**
* ProfileTabs — Instagram-style icon tab strip used under ProfileHeader.
*
* Renders a horizontal row of equal-width icon tabs. The active tab gets
* a top-border indicator and stronger color. Labels are screen-reader
* only by default (Instagram pattern), but a `showLabels` prop opts them
* back in for surfaces with more real estate.
*/
import Link from "next/link";
import { usePathname } from "next/navigation";
import { cn } from "@/lib/utils";
export type ProfileTab = {
href: string;
label: string;
icon: React.ReactNode;
/** Additional pathnames that should mark this tab active. Used to keep
* the Posts tab lit while the user is on the scroll feed at
* /u/<handle>/posts (a sibling route to /u/<handle>). */
activeFor?: string[];
};
export function ProfileTabs({
tabs,
showLabels = false,
}: {
tabs: ProfileTab[];
showLabels?: boolean;
}) {
const pathname = usePathname();
return (
<nav aria-label="Profile sections" className="border-b border-border">
<ul className="flex items-stretch max-w-md mx-auto">
{tabs.map((tab) => {
const active =
pathname === tab.href ||
(tab.activeFor?.includes(pathname) ?? false);
return (
<li key={tab.href} className="flex-1">
<Link
href={tab.href}
aria-current={active ? "page" : undefined}
title={tab.label}
className={cn(
"h-12 -mb-px flex items-center justify-center gap-2 border-b-2",
active
? "border-foreground text-foreground"
: "border-transparent text-muted-foreground hover:text-foreground",
)}
>
<span>{tab.icon}</span>
<span
className={cn(
"text-[0.65rem] uppercase tracking-[0.2em] font-semibold",
showLabels ? "" : "sr-only",
)}
>
{tab.label}
</span>
</Link>
</li>
);
})}
</ul>
</nav>
);
}
@@ -0,0 +1,71 @@
"use client";
/**
* ProfileViewHeader — the read-only header used in view mode on /u/me.
* Mirrors the shape of /u/[handle]'s top block so the owner sees the same
* thing visitors do, plus an action slot for an "Edit profile" button.
*/
import Image from "next/image";
import type { User } from "@/lib/auth";
export function ProfileViewHeader({
user,
postCount,
actionSlot,
}: {
user: User;
postCount: number;
actionSlot?: React.ReactNode;
}) {
return (
<header className="flex flex-col sm:flex-row sm:items-center gap-5 sm:gap-6">
<Avatar src={user.avatar_url} name={user.name} />
<div className="flex-1 min-w-0">
<div className="flex items-start justify-between gap-3">
<div className="min-w-0">
<h1 className="font-[family-name:var(--font-title)] font-semibold text-2xl sm:text-3xl text-foreground leading-tight truncate">
{user.name}
</h1>
<p className="text-sm text-muted-foreground mt-0.5">
@{user.handle} ·{" "}
<span className="tabular-nums">{postCount}</span> post
{postCount === 1 ? "" : "s"}
</p>
<p className="text-xs text-muted-foreground mt-1">
Joined {new Date(user.created_at).toLocaleDateString()}
</p>
</div>
{actionSlot ? <div className="shrink-0">{actionSlot}</div> : null}
</div>
</div>
</header>
);
}
function Avatar({ src, name }: { src: string | null; name: string }) {
if (src) {
return (
<Image
src={src}
alt={name}
width={88}
height={88}
className="size-20 sm:size-22 rounded-full object-cover ring-1 ring-foreground/10"
unoptimized
/>
);
}
const initials =
name
.split(/\s+/)
.map((p) => p[0])
.slice(0, 2)
.join("")
.toUpperCase() || "?";
return (
<div className="size-20 sm:size-22 rounded-full bg-primary/15 text-primary flex items-center justify-center text-2xl font-semibold ring-1 ring-foreground/10">
{initials}
</div>
);
}
+134 -16
View File
@@ -1,6 +1,7 @@
"use client";
import { useState, useRef, useEffect, useCallback } from "react";
import { createPortal } from "react-dom";
import { useTheme } from "next-themes";
import { Settings, Sun, Moon, Monitor } from "lucide-react";
import { Button } from "@/components/ui/button";
@@ -204,13 +205,88 @@ function Toggle({
);
}
export function ReadingSettings() {
/** Optional render-prop trigger — lets sidebar/bottom-nav variants compose
* their own row layout while reusing ReadingSettings' state + popover. */
type TriggerRenderer = (state: {
open: boolean;
toggle: () => void;
buttonRef: React.RefObject<HTMLButtonElement | null>;
}) => React.ReactNode;
/** Where the panel renders relative to its trigger.
*
* - "below-right" (default) — header-style: panel hangs below the
* trigger, right-aligned. Works when the trigger sits near the top.
* - "above-left" — sidebar-style: panel rises above the trigger and
* extends to the right so a bottom-of-sidebar button doesn't push
* the popover off the bottom of the viewport. */
type Placement = "below-right" | "above-left";
export function ReadingSettings({
renderTrigger,
placement = "below-right",
onOpenChange,
}: {
renderTrigger?: TriggerRenderer;
placement?: Placement;
/** Fires every time the popover opens or closes. Used by the sidebar
* to keep the rail expanded for as long as the popover is visible. */
onOpenChange?: (open: boolean) => void;
} = {}) {
const [open, setOpen] = useState(false);
const [prefs, setPrefs] = useState<ReadingPrefs>(() => loadPrefs());
const panelRef = useRef<HTMLDivElement>(null);
const buttonRef = useRef<HTMLButtonElement>(null);
const { theme, setTheme, resolvedTheme } = useTheme();
const mounted = useMounted();
// When the panel sits next to a sidebar, we render it through a portal
// with viewport-fixed coordinates so it (a) escapes the sidebar's
// bounds, (b) doesn't shift when the sidebar collapses on mouseleave,
// and (c) stays a comfortable gap to the right of the rail.
const [portalPos, setPortalPos] = useState<{
left: number;
bottom: number;
} | null>(null);
useEffect(() => {
if (!open || placement !== "above-left") {
setPortalPos(null);
return;
}
function recompute() {
const btn = buttonRef.current;
if (!btn) return;
const rect = btn.getBoundingClientRect();
// Anchor by `bottom` (distance from viewport bottom) so the panel
// grows upward from the trigger. `left` clears the rail by 16px.
const left = rect.right + 16;
const bottom = Math.max(8, window.innerHeight - rect.bottom);
setPortalPos({ left, bottom });
}
recompute();
// The sidebar width transitions when the rail expands — observe the
// trigger so the popover slides along with it instead of staying at
// the collapsed-rail x-coordinate.
const btn = buttonRef.current;
let observer: ResizeObserver | null = null;
if (btn && typeof ResizeObserver !== "undefined") {
observer = new ResizeObserver(() => recompute());
observer.observe(btn);
}
window.addEventListener("resize", recompute);
return () => {
observer?.disconnect();
window.removeEventListener("resize", recompute);
};
}, [open, placement]);
// Notify caller whenever the popover open state changes. We can't fire
// this synchronously inside setOpen because some consumers (e.g. the
// sidebar) feed it back into their own state — using useEffect breaks
// the render cycle cleanly.
useEffect(() => {
onOpenChange?.(open);
}, [open, onOpenChange]);
// Apply classes on mount (prefs already loaded via lazy initializer)
useEffect(() => {
@@ -275,27 +351,51 @@ export function ReadingSettings() {
);
}
const toggle = () => setOpen((v) => !v);
return (
<div className="relative">
<button
ref={buttonRef as React.Ref<HTMLButtonElement>}
aria-label="Reading settings"
aria-expanded={open}
onClick={() => setOpen((v) => !v)}
className={cn(
"size-9 inline-flex items-center justify-center rounded-lg",
"text-muted-foreground hover:text-foreground hover:bg-muted",
"transition-colors outline-none",
"focus-visible:ring-2 focus-visible:ring-ring",
)}
>
<Settings className="size-5 md:size-4" />
</button>
{renderTrigger ? (
renderTrigger({ open, toggle, buttonRef })
) : (
<button
ref={buttonRef as React.Ref<HTMLButtonElement>}
aria-label="Reading settings"
aria-expanded={open}
onClick={toggle}
className={cn(
"size-9 inline-flex items-center justify-center rounded-lg",
"text-muted-foreground hover:text-foreground hover:bg-muted",
"transition-colors outline-none",
"focus-visible:ring-2 focus-visible:ring-ring",
)}
>
<Settings className="size-5 md:size-4" />
</button>
)}
{open && (
<MaybePortal portal={placement === "above-left" && portalPos !== null}>
<div
ref={panelRef}
className="absolute right-0 top-full mt-2 w-72 rounded-lg border border-border bg-card shadow-lg z-50 p-4"
style={
placement === "above-left" && portalPos
? {
position: "fixed",
left: portalPos.left,
bottom: portalPos.bottom,
}
: undefined
}
className={cn(
"w-72 rounded-lg border border-border bg-card shadow-lg p-4",
"max-h-[80vh] overflow-y-auto",
placement === "above-left" && portalPos
? "z-[100]"
: placement === "above-left"
? "absolute bottom-full left-0 mb-2 z-50"
: "absolute top-full right-0 mt-2 z-50",
)}
>
{/* Theme selector */}
<div className="mb-4">
@@ -424,7 +524,25 @@ export function ReadingSettings() {
</div>
</div>
</div>
</MaybePortal>
)}
</div>
);
}
/** Conditionally render `children` through a React portal to document.body.
* When `portal` is false (or SSR), children render in place. We use this
* to escape stacking + overflow contexts (e.g. inside the sidebar) without
* duplicating the panel JSX. */
function MaybePortal({
portal,
children,
}: {
portal: boolean;
children: React.ReactNode;
}) {
if (portal && typeof document !== "undefined") {
return createPortal(children, document.body);
}
return <>{children}</>;
}
@@ -1,24 +1,196 @@
"use client";
import { PSEUDO_COLLECTIONS } from "./shared";
import { FolderPlus } from "lucide-react";
/**
* CollectionsDrawer — "Save to Collection" picker, reused in two flows:
*
* • Selection toolbar — caller passes `selectedText`. On row click we
* POST a new public post first, then add it to the chosen collection.
*
* • Post card footer — caller passes `existingPostId` (the post is
* already saved). We skip the create step and only call addItem.
*
* Exactly one of `selectedText` or `existingPostId` is meaningful per
* mount. Per the product spec: collections always hold posts, every
* post is public on the user's feed, and bookmarks are the private save.
*/
import { useEffect, useState } from "react";
import { FolderPlus, Check, Plus, Loader2 } from "lucide-react";
import {
Drawer,
DrawerClose,
DrawerContent,
DrawerHeader,
DrawerTitle,
} from "@/components/ui/drawer";
import {
collections,
posts as postsApi,
SocialError,
type Collection,
} from "@/lib/social";
import { buildHighlightUrl } from "@/lib/text-highlight";
import { getSourceReference } from "@/lib/source-reference";
type Status = "idle" | "saving" | "saved" | "auth" | "limit" | "error";
export function CollectionsDrawer({
open,
onClose,
isMobile,
selectedText,
existingPostId,
}: {
open: boolean;
onClose: () => void;
isMobile: boolean;
/** Provided by the selection toolbar — the drawer will create a fresh
* post from this text before adding it to a collection. */
selectedText?: string;
/** Provided by post-card footers — the drawer skips post creation and
* attaches the existing post id to the chosen collection. */
existingPostId?: string;
}) {
const [list, setList] = useState<Collection[]>([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
// For the existing-post mode: which collections already contain the
// post, so we can disable those rows. Empty for the toolbar mode
// (a brand-new post can't belong to anything yet).
const [alreadyIn, setAlreadyIn] = useState<Set<string>>(new Set());
// Per-row save status — keyed by collection id so each row can show its
// own "saving / saved" state without blocking the others.
const [rowStatus, setRowStatus] = useState<Record<string, Status>>({});
const [rowMsg, setRowMsg] = useState<Record<string, string>>({});
// Inline "new collection" form state.
const [showCreate, setShowCreate] = useState(false);
const [newTitle, setNewTitle] = useState("");
const [newIsPublic, setNewIsPublic] = useState(true);
const [creating, setCreating] = useState(false);
useEffect(() => {
if (!open) return;
setLoading(true);
setError(null);
// Fetch the user's collections in parallel with the post→collection
// membership lookup so the rows render with the disabled state set
// on first paint (no flicker from "Save" → "Saved").
const tasks: Promise<unknown>[] = [
collections
.mine()
.then((rows) => setList(rows ?? []))
.catch((err) => {
if (err instanceof SocialError && err.status === 401) {
setError("Log in to use collections.");
} else {
setError(
err instanceof SocialError ? err.message : "Could not load collections.",
);
}
}),
];
if (existingPostId) {
tasks.push(
collections
.containingPost(existingPostId)
.then((ids) => setAlreadyIn(new Set(ids)))
.catch(() => {
/* Non-fatal — drawer still works without the disabled hints. */
}),
);
} else {
setAlreadyIn(new Set());
}
Promise.all(tasks).finally(() => setLoading(false));
}, [open, existingPostId]);
// Reset transient UI state when drawer closes.
useEffect(() => {
if (!open) {
setRowStatus({});
setRowMsg({});
setShowCreate(false);
setNewTitle("");
setAlreadyIn(new Set());
}
}, [open]);
async function saveTo(collectionID: string) {
setRowStatus((s) => ({ ...s, [collectionID]: "saving" }));
setRowMsg((m) => ({ ...m, [collectionID]: "" }));
try {
// Two paths:
// - existingPostId provided → just attach.
// - selectedText provided → create a post first, then attach.
let postID = existingPostId ?? null;
if (!postID) {
const sourceURL = buildHighlightUrl(selectedText ?? "");
const ref = getSourceReference();
const sourceTitle = ref
? ref.detail
? `${ref.title} — ${ref.detail}`
: ref.title
: typeof document !== "undefined"
? document.title
: "";
const post = await postsApi.create({
caption: selectedText ?? "",
source_url: sourceURL,
source_title: sourceTitle,
});
postID = post.id;
}
await collections.addItem(collectionID, postID);
setRowStatus((s) => ({ ...s, [collectionID]: "saved" }));
// Bump the local item_count so the user sees the change without a refetch.
setList((rows) =>
rows.map((c) =>
c.id === collectionID ? { ...c, item_count: c.item_count + 1 } : c,
),
);
setTimeout(onClose, 800);
} catch (err) {
if (err instanceof SocialError) {
const tone: Status =
err.status === 401 ? "auth" : err.status === 402 ? "limit" : "error";
setRowStatus((s) => ({ ...s, [collectionID]: tone }));
setRowMsg((m) => ({ ...m, [collectionID]: err.message }));
} else {
setRowStatus((s) => ({ ...s, [collectionID]: "error" }));
setRowMsg((m) => ({ ...m, [collectionID]: "Save failed." }));
}
}
}
async function createCollection() {
const title = newTitle.trim();
if (!title) return;
setCreating(true);
setError(null);
try {
const c = await collections.create({ title, is_public: newIsPublic });
setList((rows) => [c, ...rows]);
setNewTitle("");
setShowCreate(false);
// Auto-save the quote into the new collection so the user gets to the
// payoff in one step.
void saveTo(c.id);
} catch (err) {
if (err instanceof SocialError && err.status === 402) {
setError(err.message);
} else if (err instanceof SocialError && err.status === 401) {
setError("Log in to create a collection.");
} else {
setError(
err instanceof SocialError ? err.message : "Could not create collection.",
);
}
} finally {
setCreating(false);
}
}
const direction = isMobile ? "bottom" : "right";
return (
@@ -32,47 +204,125 @@ export function CollectionsDrawer({
className="bg-black dark:bg-[#1a1a1a] border-white/10"
>
<DrawerHeader className="text-left border-b border-white/10 pb-4">
<DrawerTitle className="text-white">Save to Collection</DrawerTitle>
<DrawerTitle className="text-white">Save to collection</DrawerTitle>
{selectedText ? (
<p className="text-xs text-white/50 mt-1 line-clamp-2">
“{selectedText.slice(0, 140)}
{selectedText.length > 140 ? "…" : ""}”
</p>
) : null}
</DrawerHeader>
<div className="overflow-y-auto flex-1 p-4 space-y-1">
{PSEUDO_COLLECTIONS.map((col) => (
<button
key={col.id}
onClick={() => {
// TODO: Implement save to collection
onClose();
}}
className="flex items-center justify-between gap-3 w-full px-3 py-3 rounded-lg text-left hover:bg-white/10 transition-colors"
>
<div className="flex items-center gap-3">
<div className="flex items-center justify-center size-9 rounded-lg bg-white/10">
<FolderPlus className="size-4 text-white/50" />
{loading ? (
<div className="flex items-center justify-center py-10 text-white/60">
<Loader2 className="size-5 animate-spin" />
</div>
) : error ? (
<p className="text-sm text-amber-400 px-3 py-2">{error}</p>
) : null}
{!loading && !error && list.length === 0 ? (
<p className="text-sm text-white/50 px-3 py-2">
No collections yet — create one below.
</p>
) : null}
{list.map((c) => {
const st = rowStatus[c.id] ?? "idle";
// A row is "already saved" if the BFF told us this post is
// already in this collection. We treat that the same as
// st === "saved" (button locked, check mark shown), just
// with a different sub-line for the user.
const alreadySaved = alreadyIn.has(c.id);
const locked = alreadySaved || st === "saving" || st === "saved";
return (
<button
key={c.id}
onClick={() => saveTo(c.id)}
disabled={locked}
aria-disabled={locked}
className="flex items-center justify-between gap-3 w-full px-3 py-3 rounded-lg text-left hover:bg-white/10 transition-colors disabled:hover:bg-transparent disabled:cursor-not-allowed disabled:opacity-60"
>
<div className="flex items-center gap-3 min-w-0">
<div className="flex items-center justify-center size-9 rounded-lg bg-white/10 shrink-0">
<FolderPlus className="size-4 text-white/60" />
</div>
<div className="min-w-0">
<p className="text-sm font-medium text-white/90 truncate">
{c.title}
</p>
<p className="text-xs text-white/50 truncate">
{alreadySaved
? "Already in this collection"
: `${c.is_public ? "Public" : "Private"} · ${c.item_count} item${
c.item_count === 1 ? "" : "s"
}${rowMsg[c.id] ? ` · ${rowMsg[c.id]}` : ""}`}
</p>
</div>
</div>
<span className="text-sm font-medium text-white/90">
{col.name}
<span className="shrink-0 text-xs text-white/50">
{st === "saving" ? (
<Loader2 className="size-4 animate-spin" />
) : alreadySaved || st === "saved" ? (
<Check className="size-4 text-emerald-400" />
) : null}
</span>
</button>
);
})}
{/* Inline "new collection" panel */}
{!loading && !error && !showCreate ? (
<button
onClick={() => setShowCreate(true)}
className="flex items-center gap-3 w-full px-3 py-3 rounded-lg text-left hover:bg-white/10 transition-colors mt-2 border border-dashed border-white/20"
>
<div className="flex items-center justify-center size-9 rounded-lg bg-amber-400/15">
<Plus className="size-4 text-amber-400" />
</div>
<span className="text-xs text-white/50 tabular-nums">
{col.count}
<span className="text-sm font-medium text-amber-400">
New collection
</span>
</button>
))}
) : null}
{/* Create new collection */}
<button
onClick={() => {
// TODO: Implement create collection
onClose();
}}
className="flex items-center gap-3 w-full px-3 py-3 rounded-lg text-left hover:bg-white/10 transition-colors mt-2 border border-dashed border-white/20"
>
<div className="flex items-center justify-center size-9 rounded-lg bg-amber-400/15">
<FolderPlus className="size-4 text-amber-400" />
{showCreate ? (
<div className="mt-2 rounded-lg border border-white/10 p-3 space-y-3">
<input
value={newTitle}
onChange={(e) => setNewTitle(e.target.value)}
placeholder="Collection title"
maxLength={120}
className="w-full bg-white/5 text-white text-sm rounded-md px-3 py-2 placeholder:text-white/30 outline-none focus:bg-white/10"
/>
<div className="flex items-center justify-between text-xs text-white/60">
<label className="flex items-center gap-2 cursor-pointer">
<input
type="checkbox"
checked={newIsPublic}
onChange={(e) => setNewIsPublic(e.target.checked)}
/>
Public
</label>
<div className="flex items-center gap-2">
<button
onClick={() => setShowCreate(false)}
className="px-3 py-1.5 rounded-md hover:bg-white/10"
>
Cancel
</button>
<button
onClick={createCollection}
disabled={creating || !newTitle.trim()}
className="px-3 py-1.5 rounded-md bg-primary text-primary-foreground disabled:opacity-60"
>
{creating ? "Creating…" : "Create & save"}
</button>
</div>
</div>
</div>
<span className="text-sm font-medium text-amber-400">
New Collection
</span>
</button>
) : null}
</div>
</DrawerContent>
</Drawer>
@@ -0,0 +1,258 @@
"use client";
/**
* ComposePost — drawer (mobile) / modal (desktop) for "Share to Feed".
*
* The caption is locked to the exact selected text per spec:
* "Each post must have only the selected text as its caption with a link
* to the original text URL from where it was copied."
*
* Users can attach up to 5 images. Each image is uploaded to R2 via the
* BFF presign endpoint before submit, so submit only sends already-stored
* public URLs (no multipart upload through the BFF).
*/
import { useRef, useState } from "react";
import { createPortal } from "react-dom";
import { useRouter } from "next/navigation";
import Image from "next/image";
import { X, ImagePlus, Trash2, Send } from "lucide-react";
import {
Drawer,
DrawerContent,
DrawerHeader,
DrawerTitle,
} from "@/components/ui/drawer";
import { posts, SocialError } from "@/lib/social";
import { buildHighlightUrl } from "@/lib/text-highlight";
import { getSourceReference } from "@/lib/source-reference";
const MAX_IMAGES = 5;
const PRESS_IMAGE_TYPES = "image/png,image/jpeg,image/webp,image/gif";
export function ComposePost({
open,
onClose,
selectedText,
isMobile,
onPosted,
}: {
open: boolean;
onClose: () => void;
selectedText: string;
isMobile: boolean;
onPosted?: () => void;
}) {
const router = useRouter();
const fileInput = useRef<HTMLInputElement>(null);
const [images, setImages] = useState<{ url: string; uploading: boolean }[]>(
[],
);
const [submitting, setSubmitting] = useState(false);
const [error, setError] = useState<string | null>(null);
async function onFiles(e: React.ChangeEvent<HTMLInputElement>) {
const files = Array.from(e.target.files ?? []);
if (fileInput.current) fileInput.current.value = "";
if (files.length === 0) return;
const slotsLeft = MAX_IMAGES - images.length;
if (slotsLeft <= 0) {
setError(`A post can hold at most ${MAX_IMAGES} images.`);
return;
}
setError(null);
const picked = files.slice(0, slotsLeft);
// Optimistically show "uploading" tiles; replace with real URLs as each
// upload settles. We track each upload by its index so ordering stays stable.
const baseIndex = images.length;
setImages((prev) => [
...prev,
...picked.map(() => ({ url: "", uploading: true })),
]);
await Promise.all(
picked.map(async (file, i) => {
try {
const url = await posts.uploadImage(file);
setImages((prev) => {
const next = [...prev];
next[baseIndex + i] = { url, uploading: false };
return next;
});
} catch (err) {
setImages((prev) => prev.filter((_, idx) => idx !== baseIndex + i));
setError(
err instanceof SocialError ? err.message : "Image upload failed.",
);
}
}),
);
}
function removeImage(idx: number) {
setImages((prev) => prev.filter((_, i) => i !== idx));
}
async function submit() {
setError(null);
setSubmitting(true);
try {
const sourceURL = buildHighlightUrl(selectedText);
// Prefer the structured reference (e.g. "Bhagavad-gītā As It Is —
// Chapter 1, Verse 1") over the raw <title>, which often carries
// site branding ("— Prabhupāda Says") and isn't a clean citation.
const ref = getSourceReference();
const sourceTitle = ref
? ref.detail
? `${ref.title} — ${ref.detail}`
: ref.title
: typeof document !== "undefined"
? document.title
: "";
await posts.create({
caption: selectedText,
source_url: sourceURL,
source_title: sourceTitle,
image_urls: images
.filter((img) => !img.uploading && img.url)
.map((img) => img.url),
});
onPosted?.();
onClose();
router.refresh();
} catch (err) {
if (err instanceof SocialError && err.status === 401) {
setError("Please log in to post.");
} else {
setError(err instanceof SocialError ? err.message : "Post failed.");
}
} finally {
setSubmitting(false);
}
}
const body = (
<div className="space-y-4">
<div className="rounded-lg border border-border bg-muted/40 p-4">
<p className="text-[0.7rem] uppercase tracking-[0.18em] text-muted-foreground">
Caption (locked)
</p>
<p className="mt-2 font-[family-name:var(--font-title)] text-base leading-relaxed text-foreground">
“{selectedText}”
</p>
</div>
<div>
<div className="flex items-center justify-between">
<p className="text-xs uppercase tracking-[0.18em] text-muted-foreground">
Images ({images.length}/{MAX_IMAGES})
</p>
<button
type="button"
onClick={() => fileInput.current?.click()}
disabled={images.length >= MAX_IMAGES || submitting}
className="inline-flex items-center gap-1.5 text-sm text-primary hover:underline disabled:opacity-50"
>
<ImagePlus className="size-4" />
Add
</button>
<input
ref={fileInput}
type="file"
multiple
accept={PRESS_IMAGE_TYPES}
hidden
onChange={onFiles}
/>
</div>
{images.length > 0 ? (
<div className="mt-3 grid grid-cols-3 sm:grid-cols-4 gap-2">
{images.map((img, i) => (
<div
key={i}
className="relative aspect-square rounded-lg overflow-hidden bg-muted ring-1 ring-foreground/10"
>
{img.uploading ? (
<span className="absolute inset-0 flex items-center justify-center text-xs text-muted-foreground">
Uploading…
</span>
) : (
<>
<Image
src={img.url}
alt=""
fill
sizes="120px"
className="object-cover"
unoptimized
/>
<button
type="button"
onClick={() => removeImage(i)}
className="absolute top-1 right-1 size-6 rounded-full bg-black/70 text-white inline-flex items-center justify-center hover:bg-black/85"
aria-label="Remove image"
>
<Trash2 className="size-3.5" />
</button>
</>
)}
</div>
))}
</div>
) : null}
</div>
{error ? <p className="text-sm text-destructive">{error}</p> : null}
<button
type="button"
onClick={submit}
disabled={
submitting || images.some((i) => i.uploading) || !selectedText
}
className="w-full h-11 inline-flex items-center justify-center gap-2 rounded-lg bg-primary text-primary-foreground text-base font-medium disabled:opacity-60"
>
<Send className="size-4" />
{submitting ? "Posting…" : "Post to feed"}
</button>
</div>
);
if (isMobile) {
return (
<Drawer open={open} onOpenChange={(o) => !o && onClose()}>
<DrawerContent className="bg-card border-border">
<DrawerHeader className="text-left border-b border-border pb-4">
<DrawerTitle>Share to feed</DrawerTitle>
</DrawerHeader>
<div className="p-4 pb-8">{body}</div>
</DrawerContent>
</Drawer>
);
}
if (!open) return null;
return createPortal(
<>
<div
className="fixed inset-0 z-100 bg-foreground/40 backdrop-blur-sm"
onClick={onClose}
/>
<div className="fixed left-1/2 top-1/2 z-101 w-full max-w-md -translate-x-1/2 -translate-y-1/2 rounded-2xl border border-border bg-card shadow-xl">
<div className="flex items-center justify-between px-5 py-4 border-b border-border">
<h2 className="font-[family-name:var(--font-title)] font-semibold text-lg">
Share to feed
</h2>
<button
onClick={onClose}
className="size-8 rounded-lg text-muted-foreground hover:text-foreground hover:bg-muted inline-flex items-center justify-center"
aria-label="Close"
>
<X className="size-4" />
</button>
</div>
<div className="p-5">{body}</div>
</div>
</>,
document.body,
);
}
+63 -79
View File
@@ -1,9 +1,18 @@
"use client";
import { useEffect, useRef } from "react";
/**
* MorePopover — generic secondary-action menu for the selection toolbar.
*
* The parent (`TextSelectionToolbar`) owns all side effects and builds
* the appropriate `items` array based on auth state. Items can be marked
* `locked` to render a small Lock badge — used for the public flow
* where every More action sits behind the auth wall.
*/
import { type ReactNode, useEffect, useRef } from "react";
import { createPortal } from "react-dom";
import { useCopyFeedback, type Position } from "./shared";
import { Bookmark, FolderPlus, AtSign } from "lucide-react";
import { Lock } from "lucide-react";
import { type Position } from "./shared";
import {
Drawer,
DrawerClose,
@@ -12,35 +21,37 @@ import {
DrawerTitle,
} from "@/components/ui/drawer";
export type MoreItem = {
key: string;
label: string;
description: string;
icon: ReactNode;
onClick: () => void;
/** Show a lock badge — indicates the action is behind the auth wall. */
locked?: boolean;
};
export function MorePopover({
open,
position,
onClose,
onBookmark,
onSaveToCollection,
onMention,
items,
isMobile,
selectedText,
}: {
open: boolean;
position: Position;
onClose: () => void;
onBookmark: () => void;
onSaveToCollection: () => void;
onMention: () => void;
items: MoreItem[];
isMobile: boolean;
selectedText: string;
}) {
const ref = useRef<HTMLDivElement>(null);
const { copied, copy } = useCopyFeedback();
useEffect(() => {
if (!open || isMobile) return;
function handleClick(e: MouseEvent) {
if (ref.current && !ref.current.contains(e.target as Node)) {
onClose();
}
if (ref.current && !ref.current.contains(e.target as Node)) onClose();
}
// Delay so the click that opened us doesn't immediately fire it.
const timer = setTimeout(() => {
document.addEventListener("mousedown", handleClick);
}, 10);
@@ -52,30 +63,6 @@ export function MorePopover({
if (!open) return null;
const actions = [
{
key: "bookmark",
label: "Add Bookmark",
description: "Save your place",
icon: <Bookmark className="size-5" />,
onClick: onBookmark,
},
{
key: "collection",
label: "Save to Collection",
description: "Organize into a collection",
icon: <FolderPlus className="size-5" />,
onClick: onSaveToCollection,
},
{
key: "mention",
label: "Mention",
description: "Tag someone with this quote",
icon: <AtSign className="size-5" />,
onClick: onMention,
},
];
// Mobile: bottom drawer
if (isMobile) {
return (
@@ -85,27 +72,29 @@ export function MorePopover({
<DrawerTitle className="text-white">More Options</DrawerTitle>
</DrawerHeader>
<div className="p-4 space-y-1">
{actions.map((action) => (
{items.map((item) => (
<button
key={action.key}
key={item.key}
onClick={(e) => {
e.stopPropagation();
action.onClick();
if (action.key !== "copy-link") {
onClose();
}
item.onClick();
}}
className="flex items-center gap-3 w-full px-3 py-3 rounded-lg text-left hover:bg-white/10 transition-colors"
>
<div className="flex items-center justify-center size-10 rounded-lg bg-white/10 text-white/60">
{action.icon}
{item.icon}
</div>
<div>
<p className="text-sm font-medium text-white/90">
{action.label}
<div className="min-w-0 flex-1">
<p className="text-sm font-medium text-white/90 truncate">
{item.label}
</p>
<p className="text-xs text-white/50 truncate">
{item.description}
</p>
<p className="text-xs text-white/50">{action.description}</p>
</div>
{item.locked ? (
<Lock className="size-3.5 text-white/40 shrink-0" />
) : null}
</button>
))}
</div>
@@ -121,54 +110,49 @@ export function MorePopover({
);
}
// Desktop: floating popover
const popoverW = 220;
const popoverH = actions.length * 44 + 8;
// Desktop: floating popover anchored to `position`, flipping when it
// would overflow the viewport.
const popoverW = 260;
const popoverH = items.length * 56 + 8;
const pad = 8;
let popLeft = position.x;
let popTop = position.y + 8;
if (popLeft + popoverW > window.innerWidth - pad) {
popLeft = window.innerWidth - popoverW - pad;
}
if (popLeft < pad) {
popLeft = pad;
}
if (popLeft < pad) popLeft = pad;
if (popTop + popoverH > window.innerHeight - pad) {
popTop = position.y - popoverH - 8;
}
if (popTop < pad) {
popTop = pad;
}
const style: React.CSSProperties = {
position: "fixed",
zIndex: 90,
left: popLeft,
top: popTop,
};
if (popTop < pad) popTop = pad;
return createPortal(
<div
ref={ref}
style={style}
className="w-55 rounded-lg border border-white/10 bg-black shadow-2xl py-1.5 animate-in fade-in-0 zoom-in-95 dark:bg-[#1a1a1a] dark:border-white/15"
style={{ position: "fixed", zIndex: 90, left: popLeft, top: popTop }}
className="w-64 rounded-lg border border-white/10 bg-black shadow-2xl py-1.5 animate-in fade-in-0 zoom-in-95 dark:bg-[#1a1a1a] dark:border-white/15"
>
{actions.map((action) => (
{items.map((item) => (
<button
key={action.key}
key={item.key}
onClick={(e) => {
e.stopPropagation();
action.onClick();
if (action.key !== "copy-link") {
onClose();
}
item.onClick();
}}
className="flex items-center gap-3 w-full px-4 py-2.5 text-sm text-white/90 hover:bg-white/10 transition-colors text-left"
className="flex items-start gap-3 w-full px-3 py-2.5 text-left hover:bg-white/10 transition-colors"
>
<span className="text-white/50">{action.icon}</span>
{action.label}
<span className="text-white/60 mt-0.5">{item.icon}</span>
<span className="min-w-0 flex-1">
<span className="block text-sm text-white/90 truncate">
{item.label}
</span>
<span className="block text-xs text-white/50 truncate">
{item.description}
</span>
</span>
{item.locked ? (
<Lock className="size-3.5 text-white/40 shrink-0 mt-1" />
) : null}
</button>
))}
</div>,
+4 -1
View File
@@ -18,11 +18,14 @@ export function ShareModal({
onClose,
selectedText,
isMobile,
onShareToFeed,
}: {
open: boolean;
onClose: () => void;
selectedText: string;
isMobile: boolean;
/** Called when the user picks "Share to feed" — parent opens the compose modal. */
onShareToFeed?: () => void;
}) {
const { copied, copy } = useCopyFeedback();
const [generating, setGenerating] = useState(false);
@@ -45,8 +48,8 @@ export function ShareModal({
icon: <MessageSquare className="size-4" />,
description: "Post this quote to your feed",
onClick: () => {
// TODO: Implement share to feed
onClose();
onShareToFeed?.();
},
},
{
+105
View File
@@ -0,0 +1,105 @@
"use client";
/**
* SiteBottomNav — mobile-only bottom tab bar.
*
* Four items: Home (Feed) · Knowledgebase · Search · Account.
* Mirrors the Instagram pattern — fixed to the viewport bottom, full
* width, equal-width tabs, the active tab uses the primary color.
*
* Hidden on md+ — desktop uses SiteSidebar.
*/
import Link from "next/link";
import { usePathname } from "next/navigation";
import { Home, Search, User as UserIcon } from "lucide-react";
import { useAuth } from "@/components/auth/auth-provider";
import { cn } from "@/lib/utils";
type Item = {
href: string;
label: string;
icon: React.ReactNode;
matchPrefixes?: string[];
};
export function SiteBottomNav() {
const pathname = usePathname();
const { user } = useAuth();
const items: Item[] = [
{ href: "/", label: "Home", icon: <Home className="size-5" /> },
{
href: "/search",
label: "Search",
icon: <Search className="size-5" />,
// Search is the knowledgebase entry point too — keep the tab lit
// while the user is reading anywhere in the library.
matchPrefixes: [
"/search",
"/books",
"/letters",
"/transcriptions",
"/essays",
"/glossary",
"/journals",
"/magazines",
"/other-documents",
"/disciples",
],
},
{
href: user ? "/u/me" : "/login",
label: user ? "You" : "Sign in",
icon: <UserIcon className="size-5" />,
matchPrefixes: ["/u/me", "/login", "/signup"],
},
];
function isActive(item: Item): boolean {
if (item.matchPrefixes) {
return item.matchPrefixes.some((p) =>
p === "/" ? pathname === "/" : pathname.startsWith(p),
);
}
if (item.href === "/") return pathname === "/";
return pathname === item.href || pathname.startsWith(`${item.href}/`);
}
return (
<nav
aria-label="Primary"
className={cn(
"md:hidden fixed inset-x-0 bottom-0 z-40",
"bg-background/95 backdrop-blur border-t border-border",
"pb-[env(safe-area-inset-bottom)]",
)}
>
<ul className="flex items-stretch">
{items.map((item) => {
const active = isActive(item);
return (
<li key={item.href} className="flex-1">
<Link
href={item.href}
aria-label={item.label}
aria-current={active ? "page" : undefined}
className={cn(
"h-14 flex flex-col items-center justify-center gap-0.5",
active
? "text-primary"
: "text-muted-foreground hover:text-foreground",
)}
>
{item.icon}
<span className="text-[0.65rem] font-medium">
{item.label}
</span>
</Link>
</li>
);
})}
</ul>
</nav>
);
}
+16 -83
View File
@@ -1,99 +1,32 @@
/**
* SiteFooter — editorial three-column footer.
*
* Column 1: Wordmark + dedication
* Column 2: Explore (links to the main content sections)
* Column 3: Resources (about, search, profile, etc.)
*
* A thin copyright row sits at the bottom.
* SiteFooter — one slim row. Wordmark on the left, dedication on the
* right, About link in the middle. That's all.
*/
import Link from "next/link";
const EXPLORE = [
{ href: "/books", label: "Books" },
{ href: "/letters", label: "Letters" },
{ href: "/transcriptions", label: "Lectures" },
{ href: "/essays", label: "Essays" },
{ href: "/magazines", label: "Magazines" },
{ href: "/journals", label: "Journals" },
];
const RESOURCES = [
{ href: "/glossary", label: "Glossary" },
{ href: "/disciples", label: "Disciples" },
{ href: "/other-documents", label: "Other Documents" },
{ href: "/search", label: "Search" },
{ href: "/about", label: "About" },
];
export function SiteFooter() {
return (
<footer className="mt-auto border-t border-border bg-secondary text-secondary-foreground">
{/* Three-column main */}
<div className="max-w-5xl mx-auto px-4 sm:px-6 py-10 grid grid-cols-1 sm:grid-cols-3 gap-8">
{/* Brand */}
<div>
<div className="max-w-5xl mx-auto px-4 sm:px-6 py-4 flex flex-col sm:flex-row items-center justify-between gap-2 text-xs text-muted-foreground">
<Link
href="/"
aria-label="Prabhupāda Says — home"
className="font-[family-name:var(--font-title)] font-semibold uppercase tracking-[0.12em] text-[0.7rem] text-foreground/80 hover:text-foreground transition-colors"
>
Prabhupāda Says
</Link>
<div className="flex items-center gap-4">
<Link
href="/"
aria-label="Prabhupāda Says — home"
className="font-[family-name:var(--font-title)] font-semibold uppercase tracking-[0.12em] text-base text-foreground"
href="/about"
className="hover:text-foreground transition-colors"
>
Prabhupāda{" "}
<span className="text-muted-foreground tracking-[0.22em] font-medium">
Says
</span>
About
</Link>
<p className="mt-4 text-sm text-muted-foreground leading-relaxed max-w-xs">
A complete, searchable library of His Divine Grace A.C. Bhaktivedanta
Swami Prabhupāda&apos;s books, letters, and teachings.
</p>
</div>
{/* Explore */}
<FooterColumn title="Explore" items={EXPLORE} />
{/* Resources */}
<FooterColumn title="Resources" items={RESOURCES} />
</div>
{/* Copyright row */}
<div className="border-t border-border">
<div className="max-w-5xl mx-auto px-4 sm:px-6 py-5 flex flex-col sm:flex-row items-center justify-between gap-2 text-xs text-muted-foreground">
<p>
© {new Date().getFullYear()} Team Prabhupāda Says · Hare Kṛṣṇa
</p>
<p>All glories to Śrīla Prabhupāda</p>
<span aria-hidden>·</span>
<span>All glories to Śrīla Prabhupāda</span>
</div>
</div>
</footer>
);
}
function FooterColumn({
title,
items,
}: {
title: string;
items: { href: string; label: string }[];
}) {
return (
<div>
<p className="text-[0.72rem] uppercase tracking-[0.2em] font-semibold text-foreground/80">
{title}
</p>
<ul className="mt-4 space-y-2">
{items.map((item) => (
<li key={item.href}>
<Link
href={item.href}
className="text-sm text-foreground/80 hover:text-foreground transition-colors"
>
{item.label}
</Link>
</li>
))}
</ul>
</div>
);
}
+19 -107
View File
@@ -1,133 +1,45 @@
"use client";
/**
* SiteHeader — the single global navigation header.
* SiteHeader — minimal sticky transparent header with the brand wordmark
* centred. Navigation lives in SiteSidebar (desktop) and SiteBottomNav
* (mobile), so the header's only job here is to anchor the brand.
*
* Sits at the top of every page (mounted in app/layout.tsx) so the page-specific
* PageHeader can stay focused on titles and breadcrumbs.
* Mobile also gets a Reading-settings affordance pinned to the right —
* the sidebar rail is hidden on mobile, so this is the only easy entry
* point to the theme + palette + font controls.
*
* Layout:
* Layout (mobile):
*
* ┌─────────────────────────────────────────────────────────────┐
* │ PRABHUPĀDA Books · Letters · Lectures · Glossary ⌕ ⚙ ◐ │
* │ SAYS │
* └─────────────────────────────────────────────────────────────┘
* ┌─────────────────────────────────────────────────────┐
* │ Prabhupāda ⚙ │
* │ SAYS │
* └─────────────────────────────────────────────────────┘
*
* - Serif uppercase wordmark on the left (links home)
* - Section nav inline on md+; collapsed into the mobile drawer below
* - Right cluster: HeaderSearchButton, ReadingSettings, UserMenu
* - Sticky with a subtle border + backdrop blur when scrolled
*
* The HeaderSearchButton's expand-into-full-width behavior is preserved by
* giving it the same conditional flex treatment that PageHeader used to do —
* when expanded on mobile the wordmark + nav collapse out of the way.
* On md+ the gear hides and the sidebar carries it instead.
*/
import { useState } from "react";
import Link from "next/link";
import { usePathname } from "next/navigation";
import { HeaderSearchButton } from "@/components/header-search";
import { ReadingSettings } from "@/components/reading-settings";
import { UserMenu } from "@/components/auth/user-menu";
import { MobileNavToggle } from "@/components/site-mobile-nav";
import { cn } from "@/lib/utils";
const PRIMARY_NAV = [
{ href: "/books", label: "Books" },
{ href: "/letters", label: "Letters" },
{ href: "/transcriptions", label: "Lectures" },
{ href: "/essays", label: "Essays" },
{ href: "/glossary", label: "Glossary" },
] as const;
export function SiteHeader() {
const [searchExpanded, setSearchExpanded] = useState(false);
const pathname = usePathname();
function isActive(href: string): boolean {
if (href === "/") return pathname === "/";
return pathname.startsWith(href);
}
return (
<header className="sticky top-0 z-30 border-b border-border bg-secondary text-secondary-foreground">
<div className="max-w-5xl mx-auto px-3 sm:px-6 h-14 md:h-16 flex items-center gap-2 md:gap-6">
{/* Mobile hamburger — visible below md, hidden when search is expanded */}
<div className={cn("md:hidden", searchExpanded && "hidden")}>
<MobileNavToggle items={PRIMARY_NAV} />
</div>
{/* Brand wordmark — two-line stacked "Prabhupāda / SAYS". Smaller
type sizes on mobile so the row fits next to the hamburger and
action icons on narrow phones (≤375px). */}
<header className="sticky top-0 z-30 bg-background/60 backdrop-blur-md">
<div className="relative h-12 sm:h-14 flex items-center justify-center px-4">
<Link
href="/"
aria-label="Prabhupāda Says — home"
className={cn(
"shrink-0 leading-none text-foreground transition-opacity hover:opacity-80",
"font-[family-name:var(--font-title)]",
searchExpanded && "max-md:hidden",
)}
className="leading-none text-foreground font-[family-name:var(--font-title)] transition-opacity hover:opacity-80"
>
<span className="block text-[0.78rem] md:text-lg font-semibold uppercase tracking-[0.08em] md:tracking-[0.12em]">
<span className="block text-center text-[0.8rem] sm:text-base font-semibold uppercase tracking-[0.12em]">
Prabhupāda
</span>
<span className="block text-[0.55rem] md:text-[0.78rem] font-medium uppercase text-muted-foreground tracking-[0.18em] md:tracking-[0.22em] mt-0.5">
<span className="block text-center text-[0.55rem] sm:text-[0.7rem] font-medium uppercase text-muted-foreground tracking-[0.22em] mt-0.5">
Says
</span>
</Link>
{/* Primary nav — desktop only; mobile uses the drawer.
Stays visible even when search is expanded — on desktop the search
input has a fixed max-width and fits alongside the nav, so there's
no need to remove items (which would cause layout shift). */}
<nav
className="hidden md:flex items-center gap-5 flex-1 ml-2"
aria-label="Primary"
>
{PRIMARY_NAV.map((item) => (
<Link
key={item.href}
href={item.href}
className={cn(
"text-sm transition-colors relative",
isActive(item.href)
? "text-foreground after:absolute after:left-0 after:right-0 after:-bottom-1 after:h-px after:bg-primary"
: "text-muted-foreground hover:text-foreground",
)}
>
{item.label}
</Link>
))}
</nav>
{/* Spacer pushes the action cluster to the right on mobile */}
<div
className={cn("flex-1 md:hidden", searchExpanded && "hidden")}
aria-hidden
/>
{/* Action cluster — search, reading-settings, user menu.
On mobile when search is expanded, the cluster fills the row so
the input can grow. On desktop it always stays shrink-0 — the
expanded search input has its own fixed max-width and doesn't
need to steal space from the nav. */}
<div
className={cn(
"flex items-center gap-1 shrink-0",
searchExpanded && "max-md:flex-1 max-md:min-w-0 max-md:shrink",
)}
>
<HeaderSearchButton
expanded={searchExpanded}
onExpandedChange={setSearchExpanded}
/>
<div className={cn(searchExpanded && "max-md:hidden")}>
<ReadingSettings />
</div>
<div className={cn(searchExpanded && "max-md:hidden")}>
<UserMenu />
</div>
<div className="md:hidden absolute right-2 top-1/2 -translate-y-1/2">
<ReadingSettings />
</div>
</div>
</header>
+299
View File
@@ -0,0 +1,299 @@
"use client";
/**
* SiteSidebar — desktop-only thin rail that expands on hover.
*
* Layout (collapsed, w-16):
*
* ┌──┐
* │🏠│ ← top group (Home / Knowledgebase / Collections /
* │📚│ Bookmarks / Mentions / Search)
* │📁│
* │🔖│
* │@ │
* │🔍│
* │ │
* │⋮ │ ← bottom group (Reading settings, Profile)
* │👤│
* └──┘
*
* Hovering anywhere on the rail expands it to w-56 and reveals labels.
* The rail floats above content (fixed position, the main area is
* padded by w-16) so the expansion never causes layout shift.
*
* Hidden below md — mobile uses SiteBottomNav.
*/
import { useState } from "react";
import Link from "next/link";
import { usePathname } from "next/navigation";
import {
AtSign,
Bookmark,
FolderClosed,
Home,
LogIn,
Search,
Settings2,
User as UserIcon,
} from "lucide-react";
import { useAuth } from "@/components/auth/auth-provider";
import { ReadingSettings } from "@/components/reading-settings";
import { cn } from "@/lib/utils";
type NavItem = {
href: string;
label: string;
icon: React.ReactNode;
/** Optional: routes to mark active when the path starts with these.
* Used for /books variants (lectures, letters, etc.) all flagging the
* Knowledgebase entry. */
matchPrefixes?: string[];
};
const TOP_ITEMS: NavItem[] = [
{ href: "/", label: "Home", icon: <Home className="size-5" /> },
{
href: "/search",
label: "Search",
icon: <Search className="size-5" />,
// Search doubles as the knowledgebase browse surface — light it up
// any time the user is deep inside the library so they have a clear
// path back.
matchPrefixes: [
"/search",
"/books",
"/letters",
"/transcriptions",
"/essays",
"/glossary",
"/journals",
"/magazines",
"/other-documents",
"/disciples",
],
},
{
href: "/u/me/collections",
label: "Collections",
icon: <FolderClosed className="size-5" />,
},
{
href: "/u/me/bookmarks",
label: "Bookmarks",
icon: <Bookmark className="size-5" />,
},
{
href: "/u/me/mentions",
label: "Mentions",
icon: <AtSign className="size-5" />,
},
];
export function SiteSidebar() {
const pathname = usePathname();
const { user } = useAuth();
// While the reading-settings popover is open we pin the rail to its
// expanded width so the popover (which anchors to the trigger button)
// doesn't visually drift when the user's mouse leaves the rail.
const [readingOpen, setReadingOpen] = useState(false);
function isActive(item: NavItem): boolean {
if (item.matchPrefixes) {
return item.matchPrefixes.some((p) =>
p === "/" ? pathname === "/" : pathname.startsWith(p),
);
}
if (item.href === "/") return pathname === "/";
return pathname === item.href || pathname.startsWith(`${item.href}/`);
}
return (
<aside
aria-label="Primary navigation"
data-expanded={readingOpen ? "true" : undefined}
className={cn(
"hidden md:flex group fixed inset-y-0 left-0 z-40",
"transition-[width] duration-200 ease-out",
readingOpen ? "w-56" : "w-16 hover:w-56",
"bg-background/95 backdrop-blur border-r border-border",
"flex-col",
)}
>
<nav
className="flex-1 overflow-y-auto py-4 flex flex-col gap-0.5"
aria-label="Primary"
>
{TOP_ITEMS.map((item) => (
<SidebarLink
key={item.href}
item={item}
active={isActive(item)}
expandedOverride={readingOpen}
/>
))}
</nav>
<div className="border-t border-border py-4 flex flex-col gap-0.5">
<SidebarReadingSettings
expanded={readingOpen}
onOpenChange={setReadingOpen}
/>
<SidebarProfile
authed={!!user}
handle={user?.handle ?? null}
name={user?.name ?? null}
active={pathname.startsWith("/u/me")}
expandedOverride={readingOpen}
/>
</div>
</aside>
);
}
function SidebarLink({
item,
active,
expandedOverride,
}: {
item: NavItem;
active: boolean;
/** Force labels visible when the rail is pinned open (e.g. reading
* settings popover is up) and the mouse isn't actually hovering. */
expandedOverride?: boolean;
}) {
return (
<Link
href={item.href}
aria-label={item.label}
title={item.label}
className={cn(
"mx-2 h-11 flex items-center gap-3 px-3 rounded-lg overflow-hidden whitespace-nowrap text-sm font-medium",
"transition-colors",
active
? "bg-primary/10 text-primary"
: "text-muted-foreground hover:text-foreground hover:bg-muted",
)}
>
<span className="shrink-0">{item.icon}</span>
<span
className={cn(
"transition-opacity duration-150",
expandedOverride
? "opacity-100"
: "opacity-0 group-hover:opacity-100 delay-0 group-hover:delay-100",
)}
>
{item.label}
</span>
</Link>
);
}
function SidebarReadingSettings({
expanded,
onOpenChange,
}: {
expanded: boolean;
onOpenChange: (open: boolean) => void;
}) {
return (
<div className="mx-2">
<ReadingSettings
placement="above-left"
onOpenChange={onOpenChange}
renderTrigger={({ open, toggle, buttonRef }) => (
<button
ref={buttonRef}
type="button"
aria-label="Reading settings"
aria-expanded={open}
onClick={toggle}
className={cn(
"h-11 w-full flex items-center gap-3 px-3 rounded-lg overflow-hidden whitespace-nowrap text-sm font-medium",
"text-muted-foreground hover:text-foreground hover:bg-muted transition-colors",
)}
>
<Settings2 className="size-5 shrink-0" />
<span
className={cn(
"transition-opacity duration-150",
expanded
? "opacity-100"
: "opacity-0 group-hover:opacity-100 delay-0 group-hover:delay-100",
)}
>
Reading
</span>
</button>
)}
/>
</div>
);
}
function SidebarProfile({
authed,
handle,
name,
active,
expandedOverride,
}: {
authed: boolean;
handle: string | null;
name: string | null;
active: boolean;
expandedOverride?: boolean;
}) {
if (!authed) {
return (
<Link
href="/login"
className={cn(
"mx-2 h-11 flex items-center gap-3 px-3 rounded-lg overflow-hidden whitespace-nowrap text-sm font-medium",
"text-muted-foreground hover:text-foreground hover:bg-muted transition-colors",
)}
aria-label="Sign in"
title="Sign in"
>
<LogIn className="size-5 shrink-0" />
<span
className={cn(
"transition-opacity duration-150",
expandedOverride
? "opacity-100"
: "opacity-0 group-hover:opacity-100 delay-0 group-hover:delay-100",
)}
>
Sign in
</span>
</Link>
);
}
return (
<Link
href="/u/me"
aria-label="Profile"
title={handle ? `@${handle}` : "Profile"}
className={cn(
"mx-2 h-11 flex items-center gap-3 px-3 rounded-lg overflow-hidden whitespace-nowrap text-sm font-medium",
"transition-colors",
active
? "bg-primary/10 text-primary"
: "text-muted-foreground hover:text-foreground hover:bg-muted",
)}
>
<UserIcon className="size-5 shrink-0" />
<span
className={cn(
"min-w-0 truncate transition-opacity duration-150",
expandedOverride
? "opacity-100"
: "opacity-0 group-hover:opacity-100 delay-0 group-hover:delay-100",
)}
>
{name || (handle ? `@${handle}` : "Profile")}
</span>
</Link>
);
}
@@ -0,0 +1,86 @@
"use client";
/**
* CollectionGridCard — square collection card for the 2-per-row grid view.
*
* • With cover: cover fills the square; bottom-up overlay for legibility.
* • Without cover: the same flat palette-aware surface that post cards
* fall back to — pure white on light themes, palette-specific dark
* contrast on dark themes (see `--quote-card-bg`).
*
* Visibility badge (Globe/Lock) sits top-right. Title + count read
* bottom-centre in white over the cover image's dark overlay; on the
* flat fallback they use `text-foreground` so they invert cleanly with
* the theme.
*/
import Image from "next/image";
import Link from "next/link";
import { Globe, Lock } from "lucide-react";
import { cn } from "@/lib/utils";
import type { Collection } from "@/lib/social";
import { quoteCardBgStyle } from "./post-card-shared";
export function CollectionGridCard({
collection: c,
href,
}: {
collection: Collection;
href: string;
}) {
const hasCover = !!c.cover_url;
return (
<Link
href={href}
className="group relative block aspect-square rounded-2xl overflow-hidden ring-1 ring-foreground/10 hover:ring-foreground/25 hover:shadow-md transition-all select-none"
>
{hasCover ? (
<>
<Image
src={c.cover_url!}
alt=""
fill
sizes="(max-width: 640px) 50vw, 320px"
className="object-cover"
unoptimized
/>
<div className="absolute inset-0 bg-gradient-to-t from-black/80 via-black/30 to-transparent" />
</>
) : (
<div className="absolute inset-0" style={quoteCardBgStyle()} />
)}
{/* Visibility badge — black pill works on both image overlay and
flat surface (white or dark). */}
<div className="absolute top-3 right-3 inline-flex items-center gap-1 rounded-full bg-black/40 backdrop-blur-sm px-2.5 py-1 text-[0.6rem] uppercase tracking-[0.18em] font-medium text-white">
{c.is_public ? <Globe className="size-3" /> : <Lock className="size-3" />}
{c.is_public ? "Public" : "Private"}
</div>
{/* Title + count — text colour follows the surface beneath. */}
<div
className={cn(
"absolute inset-0 flex flex-col items-center justify-center text-center px-5 sm:px-7",
hasCover ? "text-white" : "text-foreground",
)}
>
<p
className={cn(
"font-[family-name:var(--font-title)] text-lg sm:text-2xl font-semibold leading-snug line-clamp-4 break-words",
hasCover && "drop-shadow-sm",
)}
>
{c.title}
</p>
<p
className={cn(
"mt-2 text-[0.65rem] uppercase tracking-[0.22em] font-medium",
hasCover ? "text-white/85" : "text-muted-foreground",
)}
>
{c.item_count} post{c.item_count === 1 ? "" : "s"}
</p>
</div>
</Link>
);
}
@@ -0,0 +1,325 @@
"use client";
/**
* CollectionScrollCard — Instagram-style horizontal carousel for one
* collection.
*
* • Slide 1 is the collection's "cover" — cover image (or themed
* gradient if none) overlaid with the title and item count.
* • Slides 2..N are PostGridCards for each post in the collection.
*
* Posts are fetched lazily: an IntersectionObserver triggers
* `collections.get(id)` the first time the carousel scrolls into view,
* so a feed of many collections doesn't blow N+1 requests on mount.
*
* The carousel uses native CSS scroll-snap (horizontal, mandatory) so
* touch swipes feel right on mobile. A page-dot indicator below tracks
* the current slide.
*/
import { useEffect, useRef, useState } from "react";
import Image from "next/image";
import Link from "next/link";
import {
ChevronLeft,
ChevronRight,
Globe,
Loader2,
Lock,
Pencil,
} from "lucide-react";
import { cn } from "@/lib/utils";
import { collections, type Collection, type Post } from "@/lib/social";
import { PostGridCard } from "./post-grid-card";
import { quoteCardBgStyle } from "./post-card-shared";
type FetchState = "idle" | "loading" | "loaded" | "error";
export function CollectionScrollCard({
collection: c,
href,
initialPosts,
onEdit,
}: {
collection: Collection;
/** Link target for the "Open" footer + cover slide click-through. */
href: string;
/** Pre-loaded posts — skip the lazy fetch when the parent already has
* them (e.g. the single-collection page renders one card after its
* own collections.get call). */
initialPosts?: Post[];
/** When provided, shows a compact Edit chip in the cover slide's
* bottom-right corner. Owner-only — caller decides whether to pass it. */
onEdit?: () => void;
}) {
const scrollRef = useRef<HTMLDivElement>(null);
const cardRef = useRef<HTMLElement>(null);
const [posts, setPosts] = useState<Post[]>(initialPosts ?? []);
const [fetchState, setFetchState] = useState<FetchState>(
initialPosts ? "loaded" : "idle",
);
const [activeIndex, setActiveIndex] = useState(0);
// Reconcile when the parent supplies fresh posts (e.g. after editing).
useEffect(() => {
if (!initialPosts) return;
setPosts(initialPosts);
setFetchState("loaded");
}, [initialPosts]);
// Fetch posts the first time the carousel enters the viewport (with a
// 200px rootMargin so we kick it off just before it's visible). Skipped
// entirely when `initialPosts` was provided.
useEffect(() => {
if (fetchState !== "idle") return;
const el = cardRef.current;
if (!el) return;
const observer = new IntersectionObserver(
(entries) => {
for (const entry of entries) {
if (entry.isIntersecting) {
observer.disconnect();
setFetchState("loading");
collections
.get(c.id)
.then(({ posts: rows }) => {
setPosts(rows ?? []);
setFetchState("loaded");
})
.catch(() => setFetchState("error"));
return;
}
}
},
{ rootMargin: "200px" },
);
observer.observe(el);
return () => observer.disconnect();
}, [c.id, fetchState]);
// Track which slide is currently snapped. We use scrollLeft / slideWidth
// rather than another observer because the slides are full-width siblings
// — much cheaper to compute.
function onScroll() {
const el = scrollRef.current;
if (!el) return;
const slideWidth = el.offsetWidth;
if (slideWidth === 0) return;
setActiveIndex(Math.round(el.scrollLeft / slideWidth));
}
function scrollByOne(direction: 1 | -1) {
const el = scrollRef.current;
if (!el) return;
el.scrollBy({ left: el.offsetWidth * direction, behavior: "smooth" });
}
const slideCount = 1 + posts.length;
const showArrows = slideCount > 1;
return (
<article ref={cardRef} className="space-y-3">
<div className="relative max-w-xl mx-auto">
<div
ref={scrollRef}
onScroll={onScroll}
className={cn(
"flex overflow-x-auto snap-x snap-mandatory rounded-2xl",
// Hide scrollbar — the carousel is gesture-driven; the dot
// indicator below tracks position.
"[scrollbar-width:none] [&::-webkit-scrollbar]:hidden",
)}
>
{/* Cover slide */}
<CoverSlide collection={c} href={href} onEdit={onEdit} />
{/* Post slides (or placeholders while loading) */}
{fetchState === "loaded" &&
posts.map((p) => (
<div
key={p.id}
className="w-full shrink-0 snap-start p-px"
aria-roledescription="slide"
>
<PostGridCard post={p} />
</div>
))}
{fetchState === "loading" ? (
<div className="w-full shrink-0 snap-start aspect-square rounded-2xl bg-card ring-1 ring-foreground/10 flex items-center justify-center text-muted-foreground">
<Loader2 className="size-5 animate-spin" />
</div>
) : null}
</div>
{showArrows ? (
<>
<CarouselArrow
direction="prev"
onClick={() => scrollByOne(-1)}
disabled={activeIndex === 0}
/>
<CarouselArrow
direction="next"
onClick={() => scrollByOne(1)}
disabled={activeIndex >= slideCount - 1}
/>
</>
) : null}
</div>
{/* Dot indicator */}
{slideCount > 1 ? (
<div className="flex items-center justify-center gap-1.5">
{Array.from({ length: slideCount }).map((_, i) => (
<span
key={i}
className={cn(
"rounded-full transition-all",
i === activeIndex
? "size-1.5 bg-foreground"
: "size-1.5 bg-muted-foreground/40",
)}
aria-hidden
/>
))}
</div>
) : null}
</article>
);
}
// ─── Slide pieces ────────────────────────────────────────────────────────
function CoverSlide({
collection: c,
href,
onEdit,
}: {
collection: Collection;
href: string;
onEdit?: () => void;
}) {
// The slide wrapper hosts the Link AND any owner-action chip as
// siblings. Nesting a button inside the Link is an a11y anti-pattern
// (interactive nested in interactive), so we keep them peers and let
// DOM z-order put the chip on top of the Link's bottom-right corner.
return (
<div className="w-full shrink-0 snap-start relative aspect-square">
<Link
href={href}
className="absolute inset-0 rounded-2xl overflow-hidden ring-1 ring-foreground/10 select-none"
aria-label={`Open collection ${c.title}`}
>
{c.cover_url ? (
<>
<Image
src={c.cover_url}
alt=""
fill
sizes="(max-width: 768px) 100vw, 640px"
className="object-cover"
unoptimized
/>
<div className="absolute inset-0 bg-gradient-to-t from-black/85 via-black/40 to-black/10" />
</>
) : (
<div className="absolute inset-0" style={quoteCardBgStyle()} />
)}
<div className="absolute top-4 right-4 inline-flex items-center gap-1.5 rounded-full bg-black/40 backdrop-blur-sm px-3 py-1 text-[0.6rem] uppercase tracking-[0.18em] font-medium text-white">
{c.is_public ? <Globe className="size-3" /> : <Lock className="size-3" />}
Collection
</div>
<div
className={cn(
"absolute inset-0 flex flex-col p-6 sm:p-8",
// Text colour follows the surface underneath: white over the
// image's dark overlay, theme foreground over the flat
// fallback so it inverts cleanly with the palette.
c.cover_url ? "text-white" : "text-foreground",
c.cover_url
? "justify-end"
: "justify-center items-center text-center",
)}
>
<p
className={cn(
"font-[family-name:var(--font-title)] text-2xl sm:text-3xl font-semibold leading-tight line-clamp-3 break-words",
c.cover_url && "drop-shadow-md",
)}
>
{c.title}
</p>
{c.description ? (
<p
className={cn(
"mt-2 text-sm line-clamp-2",
c.cover_url ? "text-white/85 max-w-md" : "text-muted-foreground max-w-md mx-auto",
)}
>
{c.description}
</p>
) : null}
<p
className={cn(
"mt-3 text-[0.7rem] uppercase tracking-[0.22em] font-semibold",
c.cover_url ? "text-white/75" : "text-muted-foreground",
)}
>
{c.item_count} post{c.item_count === 1 ? "" : "s"} · Swipe to browse →
</p>
</div>
</Link>
{onEdit ? (
<button
type="button"
onClick={onEdit}
aria-label="Edit collection"
title="Edit collection"
className={cn(
"absolute bottom-3 right-3 z-10 inline-flex items-center gap-1",
"rounded-full bg-black/50 backdrop-blur-sm hover:bg-black/70",
"text-white text-[0.7rem] font-medium uppercase tracking-[0.16em]",
"px-2.5 py-1 transition-colors",
)}
>
<Pencil className="size-3" />
Edit
</button>
) : null}
</div>
);
}
function CarouselArrow({
direction,
onClick,
disabled,
}: {
direction: "prev" | "next";
onClick: () => void;
disabled: boolean;
}) {
const Icon = direction === "prev" ? ChevronLeft : ChevronRight;
return (
<button
type="button"
onClick={onClick}
disabled={disabled}
aria-label={direction === "prev" ? "Previous slide" : "Next slide"}
className={cn(
"hidden md:inline-flex absolute top-1/2 -translate-y-1/2 size-9 rounded-full",
"bg-black/40 text-white items-center justify-center backdrop-blur-sm",
"hover:bg-black/60 transition-all",
"disabled:opacity-0 disabled:pointer-events-none",
direction === "prev" ? "left-3" : "right-3",
)}
>
<Icon className="size-4" />
</button>
);
}
+38
View File
@@ -0,0 +1,38 @@
"use client";
/**
* CollectionViews — grid-only renderer for collection lists.
*
* `hrefFor(c)` chooses where each card links: the owner page sends users
* to /u/me/collections/<id>; the public profile sends them to /c/<id>.
* The carousel "scroll view" is reached by clicking through to the
* collection detail page.
*/
import type { Collection } from "@/lib/social";
import { CollectionGridCard } from "./collection-grid-card";
export function CollectionViews({
collections,
hrefFor,
emptyMessage,
}: {
collections: Collection[];
hrefFor: (c: Collection) => string;
emptyMessage?: string;
}) {
if (collections.length === 0) {
return (
<p className="text-sm text-muted-foreground">
{emptyMessage ?? "No collections yet."}
</p>
);
}
return (
<div className="grid grid-cols-2 gap-3 sm:gap-4">
{collections.map((c) => (
<CollectionGridCard key={c.id} collection={c} href={hrefFor(c)} />
))}
</div>
);
}
@@ -0,0 +1,504 @@
"use client";
/**
* EditCollectionDrawer — single-stop editor for a collection.
*
* • Title, description, public/private toggle
* • Cover image upload (signed-URL PUT to R2)
* • Reorder posts via drag-and-drop, toggle posts in/out of the collection
* • Delete button at the bottom (with confirm modal)
*
* Right-side panel on `md+`, bottom drawer on mobile. The body scrolls;
* header and footer stay pinned so action buttons are always reachable.
*
* Drag-and-drop is powered by framer-motion's Reorder primitives so we
* get touch + mouse support without an extra dependency. Each row has a
* dedicated grip handle (Reorder.Item with `dragListener={false}` plus
* `dragControls`) so the rest of the row stays clickable for toggling.
*/
import { useEffect, useMemo, useRef, useState } from "react";
import Image from "next/image";
import { Reorder, useDragControls } from "framer-motion";
import {
Globe,
GripVertical,
ImagePlus,
Loader2,
Lock,
Trash2,
X,
} from "lucide-react";
import { toast } from "sonner";
import {
Drawer,
DrawerClose,
DrawerContent,
DrawerHeader,
DrawerTitle,
} from "@/components/ui/drawer";
import { Checkbox } from "@/components/ui/checkbox";
import { ConfirmModal } from "@/components/ui/confirm-modal";
import { useMediaQuery } from "@/components/selection-toolbar/shared";
import {
collections,
posts as postsApi,
SocialError,
type Collection,
type Post,
} from "@/lib/social";
type SavedShape = {
collection: Collection;
posts: Post[];
};
export function EditCollectionDrawer({
open,
onClose,
collection,
currentPostIds,
onSaved,
onDeleted,
}: {
open: boolean;
onClose: () => void;
collection: Collection;
/** Post ids currently in the collection, in display order. Used to
* seed the drag-sortable list and to diff against the user's
* selection + new ordering on save. */
currentPostIds: string[];
onSaved: (next: SavedShape) => void;
onDeleted: () => void;
}) {
const isMobile = useMediaQuery("(max-width: 767px)");
const direction: "bottom" | "right" = isMobile ? "bottom" : "right";
const fileInput = useRef<HTMLInputElement>(null);
const [title, setTitle] = useState(collection.title);
const [description, setDescription] = useState(collection.description ?? "");
const [isPublic, setIsPublic] = useState(collection.is_public);
const [coverURL, setCoverURL] = useState<string | null>(collection.cover_url);
// Source of truth for the in-collection set + order: a plain array of
// post ids. We push/pull from this when toggling, and reorder it on
// drag. On save we diff against `currentPostIds` to find adds/removes
// and push the final order to /items/order.
const [orderedIds, setOrderedIds] = useState<string[]>(currentPostIds);
const [allPosts, setAllPosts] = useState<Post[] | null>(null);
const [loadingPosts, setLoadingPosts] = useState(false);
const [saving, setSaving] = useState(false);
const [uploadingCover, setUploadingCover] = useState(false);
const [error, setError] = useState<string | null>(null);
const [confirmDelete, setConfirmDelete] = useState(false);
useEffect(() => {
if (!open) return;
setTitle(collection.title);
setDescription(collection.description ?? "");
setIsPublic(collection.is_public);
setCoverURL(collection.cover_url);
setOrderedIds(currentPostIds);
setError(null);
}, [open, collection, currentPostIds]);
useEffect(() => {
if (!open || allPosts !== null) return;
setLoadingPosts(true);
postsApi
.mine()
.then((rows) => setAllPosts(rows ?? []))
.catch((err) =>
setError(err instanceof SocialError ? err.message : "Could not load posts."),
)
.finally(() => setLoadingPosts(false));
}, [open, allPosts]);
// Partition the user's posts into "in the collection" (ordered by
// `orderedIds`) and "available to add" (everything else, stable).
const { inCollection, available } = useMemo(() => {
if (!allPosts) return { inCollection: [] as Post[], available: [] as Post[] };
const byId = new Map(allPosts.map((p) => [p.id, p]));
const selected = new Set(orderedIds);
const inCollection = orderedIds
.map((id) => byId.get(id))
.filter((p): p is Post => !!p);
const available = allPosts.filter((p) => !selected.has(p.id));
return { inCollection, available };
}, [allPosts, orderedIds]);
function addToCollection(id: string) {
setOrderedIds((prev) => (prev.includes(id) ? prev : [...prev, id]));
}
function removeFromCollection(id: string) {
setOrderedIds((prev) => prev.filter((x) => x !== id));
}
async function onCoverChange(e: React.ChangeEvent<HTMLInputElement>) {
const f = e.target.files?.[0];
if (fileInput.current) fileInput.current.value = "";
if (!f) return;
setUploadingCover(true);
try {
const url = await collections.uploadCover(f);
setCoverURL(url);
} catch (err) {
toast.error(
err instanceof SocialError ? err.message : "Cover upload failed.",
);
} finally {
setUploadingCover(false);
}
}
function removeCover() {
setCoverURL(null);
}
async function save() {
setSaving(true);
setError(null);
try {
const before = new Set(currentPostIds);
const after = new Set(orderedIds);
const toAdd = orderedIds.filter((id) => !before.has(id));
const toRemove = currentPostIds.filter((id) => !after.has(id));
await collections.update(collection.id, {
title,
description,
is_public: isPublic,
cover_url: coverURL ?? "",
});
// Sequential mutations — the count trigger races on parallel calls.
for (const id of toAdd) await collections.addItem(collection.id, id);
for (const id of toRemove)
await collections.removeItem(collection.id, id);
// Final position pass — overwrites whatever positions addItem
// assigned (MAX+1) so the user's drag order wins.
if (orderedIds.length > 0) {
await collections.reorderItems(collection.id, orderedIds);
}
const fresh = await collections.get(collection.id);
onSaved({ collection: fresh.collection, posts: fresh.posts ?? [] });
toast.success("Collection updated");
onClose();
} catch (err) {
setError(err instanceof SocialError ? err.message : "Save failed.");
} finally {
setSaving(false);
}
}
async function performDelete() {
try {
await collections.delete(collection.id);
onDeleted();
} catch (err) {
throw new Error(
err instanceof SocialError ? err.message : "Could not delete.",
);
}
}
return (
<>
<Drawer
open={open}
onOpenChange={(o) => !o && !saving && onClose()}
direction={direction}
shouldScaleBackground={direction === "bottom"}
>
<DrawerContent
direction={direction}
className={
direction === "bottom"
? "max-h-[90vh]"
: "max-w-md sm:max-w-lg"
}
>
<DrawerHeader className="shrink-0 text-left flex items-center justify-between pb-2">
<DrawerTitle>Edit collection</DrawerTitle>
<DrawerClose asChild>
<button
type="button"
aria-label="Close"
disabled={saving}
className="size-8 rounded-md text-muted-foreground hover:text-foreground hover:bg-muted inline-flex items-center justify-center disabled:opacity-50"
>
<X className="size-4" />
</button>
</DrawerClose>
</DrawerHeader>
<div className="flex-1 min-h-0 overflow-y-auto px-4 pb-4 space-y-5">
{/* Cover */}
<section className="flex items-start gap-4">
<div className="relative size-24 rounded-lg overflow-hidden bg-muted ring-1 ring-foreground/10 shrink-0">
{coverURL ? (
<Image
src={coverURL}
alt=""
fill
sizes="96px"
className="object-cover"
unoptimized
/>
) : (
<span className="absolute inset-0 flex items-center justify-center text-[0.6rem] uppercase tracking-[0.2em] text-muted-foreground text-center px-2">
No cover
</span>
)}
</div>
<div className="flex-1 flex flex-col gap-2 min-w-0">
<input
ref={fileInput}
type="file"
accept="image/png,image/jpeg,image/webp,image/gif"
hidden
onChange={onCoverChange}
/>
<button
type="button"
onClick={() => fileInput.current?.click()}
disabled={uploadingCover}
className="inline-flex items-center gap-1.5 rounded-md px-3 h-9 text-sm text-foreground hover:bg-muted border border-border w-fit disabled:opacity-50"
>
{uploadingCover ? (
<Loader2 className="size-4 animate-spin" />
) : (
<ImagePlus className="size-4" />
)}
{coverURL ? "Change cover" : "Add cover"}
</button>
{coverURL ? (
<button
type="button"
onClick={removeCover}
className="text-xs text-muted-foreground hover:text-destructive w-fit"
>
Remove cover
</button>
) : null}
</div>
</section>
{/* Title */}
<section className="space-y-1">
<label className="text-xs uppercase tracking-[0.18em] font-semibold text-muted-foreground">
Title
</label>
<input
value={title}
onChange={(e) => setTitle(e.target.value)}
maxLength={120}
className="w-full bg-background text-foreground text-base rounded-md border border-border px-3 py-2 outline-none focus-visible:ring-2 focus-visible:ring-primary/40"
/>
</section>
{/* Description */}
<section className="space-y-1">
<label className="text-xs uppercase tracking-[0.18em] font-semibold text-muted-foreground">
Description
</label>
<textarea
value={description}
onChange={(e) => setDescription(e.target.value)}
rows={3}
placeholder="Optional"
className="w-full bg-background text-foreground text-sm rounded-md border border-border px-3 py-2 outline-none focus-visible:ring-2 focus-visible:ring-primary/40 resize-none"
/>
</section>
{/* Visibility */}
<section>
<label className="flex items-center gap-2.5 text-sm cursor-pointer text-foreground">
<Checkbox
checked={isPublic}
onCheckedChange={setIsPublic}
ariaLabel="Public collection"
/>
{isPublic ? (
<Globe className="size-4 text-muted-foreground" />
) : (
<Lock className="size-4 text-muted-foreground" />
)}
Public — anyone with the link can view
</label>
</section>
{/* Posts in this collection (drag-sortable) */}
<section className="space-y-2 pt-2">
<div className="flex items-baseline justify-between">
<label className="text-xs uppercase tracking-[0.18em] font-semibold text-muted-foreground">
In this collection
</label>
<span className="text-xs text-muted-foreground tabular-nums">
{orderedIds.length}
</span>
</div>
{loadingPosts ? (
<p className="text-sm text-muted-foreground">Loading…</p>
) : inCollection.length === 0 ? (
<p className="text-sm text-muted-foreground">
No posts in this collection yet — pick one from below.
</p>
) : (
<Reorder.Group
axis="y"
values={orderedIds}
onReorder={setOrderedIds}
className="rounded-lg border border-border divide-y divide-border bg-background"
as="ul"
>
{inCollection.map((p) => (
<SortableRow
key={p.id}
post={p}
onRemove={() => removeFromCollection(p.id)}
/>
))}
</Reorder.Group>
)}
</section>
{/* Available posts */}
{available.length > 0 ? (
<section className="space-y-2">
<label className="text-xs uppercase tracking-[0.18em] font-semibold text-muted-foreground">
Add posts
</label>
<ul className="rounded-lg border border-border divide-y divide-border bg-background">
{available.map((p) => (
<AvailableRow
key={p.id}
post={p}
onAdd={() => addToCollection(p.id)}
/>
))}
</ul>
</section>
) : null}
{error ? (
<p className="text-sm text-destructive">{error}</p>
) : null}
</div>
{/* Footer actions */}
<div className="shrink-0 border-t border-border p-4 flex items-center justify-between gap-2">
<button
type="button"
onClick={() => setConfirmDelete(true)}
disabled={saving}
className="inline-flex items-center gap-1.5 rounded-md px-3 h-10 text-sm text-destructive hover:bg-destructive/10 disabled:opacity-50"
>
<Trash2 className="size-4" />
Delete
</button>
<div className="flex items-center gap-2">
<button
type="button"
onClick={onClose}
disabled={saving}
className="inline-flex items-center rounded-md px-4 h-10 text-sm text-foreground hover:bg-muted disabled:opacity-50"
>
Cancel
</button>
<button
type="button"
onClick={save}
disabled={saving || uploadingCover || title.trim().length === 0}
className="inline-flex items-center gap-1.5 rounded-md px-4 h-10 bg-primary text-primary-foreground text-sm hover:bg-primary/90 disabled:opacity-60"
>
{saving ? "Saving…" : "Save"}
</button>
</div>
</div>
</DrawerContent>
</Drawer>
<ConfirmModal
open={confirmDelete}
onClose={() => setConfirmDelete(false)}
title={`Delete "${collection.title}"?`}
message="The collection will be removed permanently. The posts inside stay on your feed."
confirmLabel="Delete collection"
onConfirm={performDelete}
/>
</>
);
}
// ─── Row pieces ──────────────────────────────────────────────────────────
function SortableRow({
post,
onRemove,
}: {
post: Post;
onRemove: () => void;
}) {
// dragListener={false} disables the default whole-item drag — only the
// grip handle starts a drag via dragControls.start.
const controls = useDragControls();
return (
<Reorder.Item
value={post.id}
dragListener={false}
dragControls={controls}
className="flex items-start gap-3 p-3 hover:bg-muted/40 transition-colors bg-background select-none"
>
<button
type="button"
onPointerDown={(e) => controls.start(e)}
aria-label="Drag to reorder"
title="Drag to reorder"
className="mt-0.5 text-muted-foreground hover:text-foreground cursor-grab active:cursor-grabbing touch-none"
>
<GripVertical className="size-4" />
</button>
<Checkbox
checked
onCheckedChange={() => onRemove()}
ariaLabel="Remove from collection"
className="mt-0.5"
/>
<PostSummary post={post} />
</Reorder.Item>
);
}
function AvailableRow({ post, onAdd }: { post: Post; onAdd: () => void }) {
return (
<li>
<label className="flex items-start gap-3 p-3 hover:bg-muted/40 transition-colors cursor-pointer">
<Checkbox
checked={false}
onCheckedChange={() => onAdd()}
ariaLabel="Add to collection"
className="mt-0.5"
/>
<PostSummary post={post} />
</label>
</li>
);
}
function PostSummary({ post }: { post: Post }) {
return (
<span className="min-w-0 flex-1">
<span className="block text-sm text-foreground line-clamp-2 break-words">
{post.caption}
</span>
{post.source_title ? (
<span className="block text-[0.65rem] uppercase tracking-[0.18em] text-muted-foreground mt-1 truncate">
{post.source_title}
</span>
) : null}
</span>
);
}
+241
View File
@@ -0,0 +1,241 @@
"use client";
/**
* PageBookmarks — persistent in-text bookmark highlighting + floating X.
*
* For every bookmark the logged-in user has whose `source_url` path
* matches the current pathname, we:
*
* 1. Wrap the passage in a `<mark data-bookmark-id="…">` with a violet
* wash so the text stays highlighted across the whole session.
* 2. Anchor a floating "Bookmarked × remove" pill 32px above the first
* mark, scrolling with the page.
* 3. Remove both the mark AND the pill when the user clicks ×.
*
* Cross-component sync: the selection toolbar dispatches
* `spsays:bookmarks-changed` whenever it creates a bookmark; we re-fetch
* and re-mark on that event, so a freshly-bookmarked passage lights up
* immediately without a page reload.
*/
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { createPortal } from "react-dom";
import { usePathname } from "next/navigation";
import { Bookmark, X } from "lucide-react";
import { toast } from "sonner";
import { useAuth } from "@/components/auth/auth-provider";
import {
bookmarks as bookmarksApi,
type Bookmark as BookmarkRec,
} from "@/lib/social";
import {
decodeTextFragment,
markFragmentAsBookmark,
removeBookmarkMarks,
} from "@/lib/text-highlight";
type Marker = {
bookmark: BookmarkRec;
top: number;
left: number;
};
export function PageBookmarks() {
const { user, loading } = useAuth();
const pathname = usePathname();
const [list, setList] = useState<BookmarkRec[]>([]);
const [markers, setMarkers] = useState<Marker[]>([]);
// Track which bookmark ids have already been applied to the DOM so we
// don't double-wrap when state churns (e.g. an optimistic delete that
// then errors and gets restored).
const appliedIdsRef = useRef<Set<string>>(new Set());
const onThisPage = useMemo(
() => list.filter((b) => pathOf(b.source_url) === pathname),
[list, pathname],
);
// Fetch bookmarks once per user. The toolbar fires a custom event when
// it creates a new one so we refresh without polling.
useEffect(() => {
if (loading) return;
if (!user) {
setList([]);
return;
}
let cancelled = false;
const refetch = () =>
bookmarksApi
.list()
.then((rows) => {
if (!cancelled) setList(rows ?? []);
})
.catch(() => {
/* overlay is non-essential — swallow errors */
});
refetch();
const onChanged = () => {
void refetch();
};
window.addEventListener("spsays:bookmarks-changed", onChanged);
return () => {
cancelled = true;
window.removeEventListener("spsays:bookmarks-changed", onChanged);
};
}, [user, loading]);
// Apply marks + measure icon positions. Runs whenever the relevant
// bookmarks for this page change, OR when the viewport resizes so a
// reflowed paragraph doesn't strand the floating icon.
const applyAndMeasure = useCallback(() => {
if (typeof window === "undefined") return;
// Garbage-collect marks for bookmarks that no longer exist on this
// page (deleted, or navigated to a different URL).
const wantedIds = new Set(onThisPage.map((b) => b.id));
for (const id of appliedIdsRef.current) {
if (!wantedIds.has(id)) {
removeBookmarkMarks(id);
appliedIdsRef.current.delete(id);
}
}
const next: Marker[] = [];
for (const bookmark of onThisPage) {
const hl = hlOf(bookmark.source_url);
if (!hl) continue;
const fragment = decodeTextFragment(hl);
if (!fragment) continue;
// Wrap text in <mark> if not already wrapped. Idempotent.
const marks = markFragmentAsBookmark(fragment, bookmark.id);
if (marks.length === 0) continue;
appliedIdsRef.current.add(bookmark.id);
// Anchor the floating icon to the first mark's bounding rect.
const rect = marks[0].getBoundingClientRect();
if (rect.width === 0 && rect.height === 0) continue;
next.push({
bookmark,
top: rect.top + window.scrollY,
left: rect.left + window.scrollX,
});
}
setMarkers(next);
}, [onThisPage]);
// Run the apply/measure cycle when this page's bookmarks change. Some
// pages stream content in (the verses page), so we retry a few times to
// catch text nodes added shortly after mount.
useEffect(() => {
if (onThisPage.length === 0) {
// No bookmarks for this page — strip any leftover marks (e.g. from
// a stale render before list rehydrated) and stop.
removeBookmarkMarks();
appliedIdsRef.current.clear();
setMarkers([]);
return;
}
let attempts = 0;
let timer: ReturnType<typeof setTimeout> | null = null;
const tick = () => {
applyAndMeasure();
attempts++;
if (attempts < 6) timer = setTimeout(tick, 300);
};
tick();
window.addEventListener("resize", applyAndMeasure);
return () => {
if (timer) clearTimeout(timer);
window.removeEventListener("resize", applyAndMeasure);
};
}, [onThisPage, applyAndMeasure]);
// Belt-and-braces: when this component unmounts (route change), tear
// down every mark so the next page starts clean.
useEffect(() => {
return () => {
removeBookmarkMarks();
appliedIdsRef.current.clear();
};
}, []);
async function remove(id: string) {
const previous = list;
setList((rows) => rows.filter((b) => b.id !== id));
// Yank the in-text mark immediately for snappy feedback. The
// applyAndMeasure effect would also catch this, but we don't want a
// visible flash of the still-highlighted text while it runs.
removeBookmarkMarks(id);
appliedIdsRef.current.delete(id);
try {
await bookmarksApi.delete(id);
toast.success("Bookmark removed");
} catch {
setList(previous);
toast.error("Could not remove bookmark");
}
}
if (typeof document === "undefined") return null;
if (markers.length === 0) return null;
return createPortal(
<>
{markers.map((m) => (
<div
key={m.bookmark.id}
style={{
position: "absolute",
// top = top-of-text minus pill height (~26) minus tail (6) minus
// a 4px breathing gap so the tail tip floats just above the
// highlighted line.
top: m.top - 36,
left: m.left,
zIndex: 40,
}}
className="pointer-events-none"
>
<div className="pointer-events-auto relative inline-flex items-center gap-1 rounded-full bg-violet-500 shadow-lg px-2.5 py-1 animate-in fade-in-0 zoom-in-95">
<Bookmark className="size-3.5 text-white" />
<span className="text-[0.65rem] uppercase tracking-[0.18em] font-semibold text-white">
Bookmarked
</span>
<button
type="button"
onClick={() => remove(m.bookmark.id)}
aria-label="Remove bookmark"
className="size-5 ml-0.5 inline-flex items-center justify-center rounded-full text-white/80 hover:bg-white/20 hover:text-white transition-colors"
>
<X className="size-3" />
</button>
{/* Teardrop pointer to the text below. CSS triangle made with
borders: transparent sides + violet top create a down-arrow
that visually connects the pill to its highlighted passage. */}
<span
aria-hidden
className="absolute left-3 top-full size-0 border-x-[5px] border-x-transparent border-t-[6px] border-t-violet-500"
/>
</div>
</div>
))}
</>,
document.body,
);
}
// --- helpers ---
function pathOf(sourceURL: string): string {
const q = sourceURL.indexOf("?");
return q < 0 ? sourceURL : sourceURL.slice(0, q);
}
function hlOf(sourceURL: string): string | null {
const q = sourceURL.indexOf("?");
if (q < 0) return null;
const params = new URLSearchParams(sourceURL.slice(q + 1));
return params.get("hl");
}
+109
View File
@@ -0,0 +1,109 @@
"use client";
/**
* PostActionsFooter — the icon-button row at the bottom of a post card.
*
* Left cluster: Like · Comment · Reshare
* Right cluster: Save to Collection (folder-plus)
*
* Like / Comment / Reshare are placeholders for now — they fire a
* "coming soon" toast on click. Save to Collection mounts the existing
* CollectionsDrawer in `existingPostId` mode so the post is attached
* directly without creating a duplicate.
*
* Auth gating is handled per-action via `useAuthGate` — anonymous users
* see the in-place auth modal before the underlying action runs.
*/
import { useState } from "react";
import { FolderPlus, Heart, MessageCircle, Repeat2 } from "lucide-react";
import { toast } from "sonner";
import { cn } from "@/lib/utils";
import { useAuthGate } from "@/components/auth/auth-gate";
import { useMediaQuery } from "@/components/selection-toolbar/shared";
import { CollectionsDrawer } from "@/components/selection-toolbar/collections-drawer";
export function PostActionsFooter({ postID }: { postID: string }) {
const { requireAuth } = useAuthGate();
const isMobile = useMediaQuery("(max-width: 767px)");
const [collectionsOpen, setCollectionsOpen] = useState(false);
async function withAuth(fn: () => void | Promise<void>) {
if (!(await requireAuth())) return;
await fn();
}
return (
<>
<div className="flex items-center justify-between px-2">
<div className="flex items-center gap-1">
<ActionButton
label="Like"
onClick={() =>
withAuth(() => {
toast("Likes are coming soon", { duration: 2000 });
})
}
icon={<Heart className="size-5" />}
/>
<ActionButton
label="Comment"
onClick={() =>
withAuth(() => {
toast("Comments are coming soon", { duration: 2000 });
})
}
icon={<MessageCircle className="size-5" />}
/>
<ActionButton
label="Reshare"
onClick={() =>
withAuth(() => {
toast("Resharing is coming soon", { duration: 2000 });
})
}
icon={<Repeat2 className="size-5" />}
/>
</div>
<ActionButton
label="Save to collection"
onClick={() => withAuth(() => setCollectionsOpen(true))}
icon={<FolderPlus className="size-5" />}
/>
</div>
<CollectionsDrawer
open={collectionsOpen}
onClose={() => setCollectionsOpen(false)}
isMobile={isMobile}
existingPostId={postID}
/>
</>
);
}
function ActionButton({
label,
onClick,
icon,
}: {
label: string;
onClick: () => void;
icon: React.ReactNode;
}) {
return (
<button
type="button"
onClick={onClick}
aria-label={label}
title={label}
className={cn(
"size-10 inline-flex items-center justify-center rounded-full",
"text-muted-foreground hover:text-foreground hover:bg-muted",
"transition-colors",
)}
>
{icon}
</button>
);
}
+145
View File
@@ -0,0 +1,145 @@
"use client";
/**
* Helpers shared by PostGridCard and PostScrollCard.
*
* • postSourceHref — synthesizes `?hl=` if the stored URL lacks one
* • themeGradientStyle — picks one of four palette-aware gradients
* deterministically from a seed (post id) so a
* feed of image-less posts looks varied without
* shifting between renders.
*/
import type { Post } from "@/lib/social";
import { appendHighlight, hasHighlight } from "@/lib/text-highlight";
export function postSourceHref(post: Post): string {
return hasHighlight(post.source_url) || !post.caption
? post.source_url
: appendHighlight(post.source_url, post.caption);
}
// Quote-card gradients.
//
// Four CSS custom properties (`--quote-gradient-1` … 4) are defined per
// palette in `app/globals.css`. Each palette ships its own hand-tuned
// gradient set so the cards swap colour with the active theme:
//
// • Aged Parchment → mahogany binding + temple gold
// • Tulasī Garden → forest depths + amber edge
// • Yamunā Mist → river depths + dusk ochre
// • Ancient Manuscript → midnight leather + saffron
// • Nīlamaṇi → Kṛṣṇa's sapphire hour (deep blues)
// • Vraja Twilight → deep wine + rose dusk
//
// All variants are dark-leaning so `text-white` reads cleanly. The card
// picks one of the four deterministically from `post.id`.
const GRADIENT_VARS = [
"var(--quote-gradient-1)",
"var(--quote-gradient-2)",
"var(--quote-gradient-3)",
"var(--quote-gradient-4)",
];
/** Returns an inline-style object that applies the chosen gradient. The
* style approach (vs a className) lets us reference CSS custom properties
* that vary per palette — Tailwind can't generate utility classes for
* values that change at runtime via `:root` selectors. */
export function themeGradientStyle(seed: string): React.CSSProperties {
// Cheap, stable hash — same seed always picks the same gradient.
let h = 0;
for (let i = 0; i < seed.length; i++) h = (h * 31 + seed.charCodeAt(i)) >>> 0;
return { backgroundImage: GRADIENT_VARS[h % GRADIENT_VARS.length] };
}
/** Flat background for post cards without an image: pure white on every
* light palette, palette-specific darker contrast on dark palettes.
* Defined as `--quote-card-bg` per `:root` / `.dark` block in
* globals.css. Combine with `text-foreground` for legibility — both
* sides of the theme switch reverse together. */
export function quoteCardBgStyle(): React.CSSProperties {
return { backgroundColor: "var(--quote-card-bg)" };
}
// ───────────────────────────────────────────────────────────────────────────
// Variable quote sizing
// ───────────────────────────────────────────────────────────────────────────
//
// A pithy two-word quote and a full paragraph need very different
// treatments inside a fixed-size card. We pick a tier from the caption's
// character count:
//
// tier 1 (very short) — biggest font, dead-centre, no truncation needed
// tier 2 (short) — large font, centred, ~5-line clamp
// tier 3 (medium) — medium font, centred, ~6/7-line clamp
// tier 4 (long) — smallest font, anchored bottom-LEFT and left-
// aligned (a centred wall of small text would
// cover the entire card and look claustrophobic;
// bottom-left mimics the image variant's caption
// placement and breathes better).
//
// Two scales: "grid" for the small square card, "scroll" for the larger
// Instagram-style card.
export type QuoteSize = {
/** Tailwind font-size classes, e.g. "text-xl sm:text-2xl" */
fontClass: string;
/** Tailwind line-clamp class limiting overflow */
clampClass: string;
/** Where to anchor the text block inside its container */
alignment: "center" | "bottom-left";
};
export function quoteSize(text: string, variant: "grid" | "scroll"): QuoteSize {
const len = (text ?? "").trim().length;
if (variant === "grid") {
if (len <= 50)
return {
fontClass: "text-xl sm:text-2xl",
clampClass: "line-clamp-3",
alignment: "center",
};
if (len <= 150)
return {
fontClass: "text-base sm:text-lg",
clampClass: "line-clamp-5",
alignment: "center",
};
if (len <= 300)
return {
fontClass: "text-sm sm:text-base",
clampClass: "line-clamp-[7]",
alignment: "center",
};
return {
fontClass: "text-xs sm:text-sm",
clampClass: "line-clamp-[9]",
alignment: "bottom-left",
};
}
// scroll — bigger card, larger fonts at every tier, higher thresholds
// so paragraph-length quotes still get centred at a comfortable size.
if (len <= 60)
return {
fontClass: "text-3xl sm:text-4xl",
clampClass: "line-clamp-3",
alignment: "center",
};
if (len <= 180)
return {
fontClass: "text-2xl sm:text-3xl",
clampClass: "line-clamp-5",
alignment: "center",
};
if (len <= 380)
return {
fontClass: "text-lg sm:text-xl",
clampClass: "line-clamp-[8]",
alignment: "center",
};
return {
fontClass: "text-base sm:text-lg",
clampClass: "line-clamp-[10]",
alignment: "bottom-left",
};
}
+101
View File
@@ -0,0 +1,101 @@
"use client";
/**
* PostCard — renders one post in the public feed and the /u/me feed.
*
* • Caption in serif (locked, can't be edited)
* • Image grid (max 5; collapses to one column on the cheap)
* • "Source" link back to the page the quote came from
* • Optional onDelete callback shown only when the owner is viewing
*
* The source link always lands the user on the original passage with `?hl=`
* highlighting. If the stored source_url already carries `?hl=` we use it
* verbatim; otherwise we synthesise one from the post's caption so older
* posts (or imported data) still scroll to + highlight the text on click.
*/
import Image from "next/image";
import Link from "next/link";
import { Trash2, ExternalLink } from "lucide-react";
import type { Post } from "@/lib/social";
import { displaySourceLabel } from "@/lib/source-reference";
import { appendHighlight, hasHighlight } from "@/lib/text-highlight";
import { PostActionsFooter } from "./post-actions-footer";
export function PostCard({
post,
onDelete,
}: {
post: Post;
onDelete?: (id: string) => void;
}) {
const sourceHref =
hasHighlight(post.source_url) || !post.caption
? post.source_url
: appendHighlight(post.source_url, post.caption);
return (
<article className="rounded-xl bg-card ring-1 ring-foreground/10 p-5">
<p className="font-[family-name:var(--font-title)] text-lg text-foreground leading-relaxed">
“{post.caption}”
</p>
{post.images && post.images.length > 0 ? (
<div
className={`mt-4 grid gap-2 ${
post.images.length === 1
? "grid-cols-1"
: post.images.length === 2
? "grid-cols-2"
: "grid-cols-3"
}`}
>
{post.images.map((img) => (
<div
key={img.id}
className="relative aspect-square rounded-lg overflow-hidden bg-muted ring-1 ring-foreground/10"
>
<Image
src={img.url}
alt=""
fill
sizes="(max-width: 640px) 30vw, 200px"
className="object-cover"
unoptimized
/>
</div>
))}
</div>
) : null}
<div className="mt-4 flex items-center justify-between text-xs text-muted-foreground">
<Link
href={sourceHref}
className="inline-flex items-center gap-1 hover:text-foreground transition-colors"
>
<ExternalLink className="size-3" />
{displaySourceLabel(post.source_url, post.source_title)}
</Link>
<div className="flex items-center gap-3">
<span>{new Date(post.created_at).toLocaleDateString()}</span>
{onDelete ? (
<button
type="button"
onClick={() => onDelete(post.id)}
className="inline-flex items-center gap-1 text-muted-foreground hover:text-destructive transition-colors"
aria-label="Delete post"
>
<Trash2 className="size-3.5" />
</button>
) : null}
</div>
</div>
{/* Action footer — like / comment / reshare on the left, add to
collection on the right. */}
<div className="mt-3 pt-2 border-t border-border">
<PostActionsFooter postID={post.id} />
</div>
</article>
);
}
+158
View File
@@ -0,0 +1,158 @@
"use client";
/**
* PostGridCard — square card used in the 2-per-row grid view.
*
* • With image: first attached image fills the card; a bottom-up dark
* gradient makes the caption legible without dropping the image's
* atmosphere.
* • Without image: a palette-aware Tailwind gradient stands in; the
* caption sits as a serif quote bottom-left in the foreground colour.
*
* `onDelete` shows a small X in the corner for owner views. Used both for
* actual post deletion (on /u/me) and for removing from a collection
* (on /u/me/collections/[id]); the parent decides which.
*/
import { useState } from "react";
import Image from "next/image";
import Link from "next/link";
import { ImageIcon, X } from "lucide-react";
import { cn } from "@/lib/utils";
import type { Post } from "@/lib/social";
import { displaySourceLabel } from "@/lib/source-reference";
import { useMediaQuery } from "@/components/selection-toolbar/shared";
import { postSourceHref, quoteCardBgStyle, quoteSize } from "./post-card-shared";
import { UpdateBackgroundModal } from "./update-background-modal";
export function PostGridCard({
post,
onDelete,
onUpdated,
href: hrefProp,
}: {
post: Post;
onDelete?: (id: string) => void;
/** Owner-only: shown when defined so the user can swap/remove the
* card's background image. */
onUpdated?: (updated: Post) => void;
/** Override the click target. Defaults to the source-passage URL.
* Profile grids pass a `/u/<handle>/posts#post-<id>` anchor so the
* thumbnail jumps to the Instagram-style scroll feed instead of the
* knowledgebase page. */
href?: string;
}) {
const isMobile = useMediaQuery("(max-width: 767px)");
const [bgModal, setBgModal] = useState(false);
const firstImage = post.images?.[0]?.url;
const href = hrefProp ?? postSourceHref(post);
const size = quoteSize(post.caption, "grid");
// Image variant always anchors text bottom-left over the image's dark
// overlay. No-image variant lets `size.alignment` decide — short quotes
// sit centred so the gradient frames them; long quotes fall back to
// bottom-left so they don't cover the whole card.
const bottomLeft = !!firstImage || size.alignment === "bottom-left";
return (
<div className="relative group select-none">
<Link
href={href}
className={cn(
"block aspect-square rounded-2xl overflow-hidden ring-1 ring-foreground/10",
"hover:ring-foreground/25 hover:shadow-md transition-all relative",
)}
>
{firstImage ? (
<>
<Image
src={firstImage}
alt=""
fill
sizes="(max-width: 640px) 50vw, 320px"
className="object-cover"
unoptimized
/>
{/* Bottom-up gradient for caption legibility — keeps the image
visible up top while text sits over solid darkness below. */}
<div className="absolute inset-0 bg-gradient-to-t from-black/80 via-black/40 to-transparent" />
</>
) : (
<div className="absolute inset-0" style={quoteCardBgStyle()} />
)}
<div
className={cn(
"absolute inset-0 flex flex-col p-4 sm:p-5",
// Text color: white on the dark image overlay, theme
// foreground on the flat quote-card background.
firstImage ? "text-white" : "text-foreground",
bottomLeft
? "justify-end items-start text-left"
: "justify-center items-center text-center",
)}
>
<p
className={cn(
"font-[family-name:var(--font-title)] leading-snug break-words",
size.fontClass,
size.clampClass,
// Drop-shadow only helps when text sits on imagery; on the
// flat bg it just blurs the type.
firstImage && "drop-shadow-sm",
// Centred quotes need a soft horizontal cap so a long line
// doesn't kiss the card edge — looks better with margins.
!bottomLeft ? "max-w-[90%]" : "w-full",
)}
>
“{post.caption}”
</p>
<p
className={cn(
"mt-2 text-[0.65rem] uppercase tracking-[0.2em] font-medium truncate",
firstImage ? "text-white/80" : "text-muted-foreground",
bottomLeft ? "w-full" : "max-w-[90%]",
)}
>
{displaySourceLabel(post.source_url, post.source_title)}
</p>
</div>
</Link>
{onUpdated || onDelete ? (
<div className="absolute top-2 right-2 flex items-center gap-1.5 opacity-0 group-hover:opacity-100 focus-within:opacity-100 transition-opacity">
{onUpdated ? (
<button
type="button"
onClick={() => setBgModal(true)}
aria-label="Update background"
title="Update background"
className="size-8 rounded-full inline-flex items-center justify-center bg-black/40 text-white hover:bg-black/60 transition-colors"
>
<ImageIcon className="size-4" />
</button>
) : null}
{onDelete ? (
<button
type="button"
onClick={() => onDelete(post.id)}
aria-label="Remove"
className="size-8 rounded-full inline-flex items-center justify-center bg-black/40 text-white hover:bg-black/60 transition-colors"
>
<X className="size-4" />
</button>
) : null}
</div>
) : null}
{onUpdated ? (
<UpdateBackgroundModal
open={bgModal}
onClose={() => setBgModal(false)}
post={post}
isMobile={isMobile}
onUpdated={onUpdated}
/>
) : null}
</div>
);
}
+329
View File
@@ -0,0 +1,329 @@
"use client";
/**
* PostScrollCard — Instagram-style post used on the scroll feed.
*
* Layout:
*
* ┌───────────────────────────────────────┐
* │ ◯ name @handle ⋮ │ ← author header (TBD)
* ├───────────────────────────────────────┤
* │ │
* │ image carousel │ ← square, swipeable
* │ (or quote) │ (no-image variant
* │ │ fills with gradient
* │ • ○ ○ ○ │ panel + serif quote)
* ├───────────────────────────────────────┤
* │ "Quoted passage shown as caption…" │ ← truncates with "more"
* │ ŚRĪMAD-BHĀGAVATAM — CANTO 4, CH 5 │ ← citation
* └───────────────────────────────────────┘
*
* • Multi-image carousel uses native CSS scroll-snap with dot indicators
* below; the same primitive CollectionScrollCard uses for posts.
* • Long captions clamp to 3 lines until the user expands via "more".
* • Owner action cluster (Update background / Delete) sits in the
* top-right of the media area, fading in on hover (desktop) and
* always visible on touch (mobile).
*/
import { useState } from "react";
import Image from "next/image";
import Link from "next/link";
import { ImageIcon, Trash2 } from "lucide-react";
import { cn } from "@/lib/utils";
import type { Post, PostImage } from "@/lib/social";
import { displaySourceLabel } from "@/lib/source-reference";
import { useMediaQuery } from "@/components/selection-toolbar/shared";
import { postSourceHref, quoteCardBgStyle } from "./post-card-shared";
import { PostActionsFooter } from "./post-actions-footer";
import { UpdateBackgroundModal } from "./update-background-modal";
export function PostScrollCard({
post,
onDelete,
onUpdated,
}: {
post: Post;
onDelete?: (id: string) => void;
onUpdated?: (updated: Post) => void;
}) {
const images = post.images ?? [];
const hasImages = images.length > 0;
const sourceHref = postSourceHref(post);
const isMobile = useMediaQuery("(max-width: 767px)");
const [bgModal, setBgModal] = useState(false);
const [expanded, setExpanded] = useState(false);
const ownerCluster =
onUpdated || onDelete ? (
<div className="absolute top-3 right-3 z-10 flex items-center gap-1.5">
{onUpdated ? (
<OwnerButton
label="Update background"
onClick={() => setBgModal(true)}
icon={<ImageIcon className="size-4" />}
/>
) : null}
{onDelete ? (
<OwnerButton
label="Remove"
onClick={() => onDelete(post.id)}
icon={<Trash2 className="size-4" />}
/>
) : null}
</div>
) : null;
return (
<article
id={`post-${post.id}`}
className="rounded-2xl overflow-hidden bg-card ring-1 ring-foreground/10 scroll-mt-20 sm:scroll-mt-24"
>
{/* Author header */}
<header className="flex items-center gap-3 px-4 py-3">
<AuthorAvatar
src={post.author_avatar_url ?? null}
name={post.author_name ?? "Unknown"}
/>
<div className="min-w-0">
<Link
href={
post.author_handle ? `/u/${post.author_handle}` : "#"
}
className="block text-sm font-semibold text-foreground truncate hover:underline"
>
{post.author_name || "Unknown"}
</Link>
{post.author_handle ? (
<span className="block text-xs text-muted-foreground truncate">
@{post.author_handle}
</span>
) : null}
</div>
</header>
{/* Media — image carousel or gradient-with-quote */}
<div className="relative">
{hasImages ? (
<ImageCarousel images={images} />
) : (
<QuotePanel post={post} />
)}
{ownerCluster}
</div>
{/* Caption + citation — reference sits directly under the media
so it's read first, with the action row below it. */}
<div className="px-4 pt-4 pb-3 space-y-2">
{hasImages ? (
// When images carry the visual, the caption sits as text below.
<Caption
text={post.caption}
expanded={expanded}
onToggle={() => setExpanded((v) => !v)}
/>
) : null}
<Link
href={sourceHref}
className="block max-w-full truncate text-[0.7rem] uppercase tracking-[0.2em] font-medium text-muted-foreground hover:text-foreground transition-colors"
>
{displaySourceLabel(post.source_url, post.source_title)} →
</Link>
</div>
{/* Action footer — like / comment / reshare on the left, add to
collection on the right. */}
<div className="pt-1 pb-2 border-t border-border">
<PostActionsFooter postID={post.id} />
</div>
{onUpdated ? (
<UpdateBackgroundModal
open={bgModal}
onClose={() => setBgModal(false)}
post={post}
isMobile={isMobile}
onUpdated={onUpdated}
/>
) : null}
</article>
);
}
// ─── Pieces ──────────────────────────────────────────────────────────────
function ImageCarousel({ images }: { images: PostImage[] }) {
const [active, setActive] = useState(0);
function onScroll(e: React.UIEvent<HTMLDivElement>) {
const el = e.currentTarget;
const w = el.offsetWidth;
if (w === 0) return;
setActive(Math.round(el.scrollLeft / w));
}
if (images.length === 1) {
return (
<div className="relative aspect-square bg-muted">
<Image
src={images[0].url}
alt=""
fill
sizes="(max-width: 768px) 100vw, 640px"
className="object-cover"
unoptimized
/>
</div>
);
}
return (
<div className="relative">
<div
onScroll={onScroll}
className={cn(
"flex overflow-x-auto snap-x snap-mandatory",
"[scrollbar-width:none] [&::-webkit-scrollbar]:hidden",
)}
>
{images.map((img, i) => (
<div
key={img.id}
className="relative w-full shrink-0 snap-start aspect-square bg-muted"
aria-roledescription="slide"
aria-label={`${i + 1} of ${images.length}`}
>
<Image
src={img.url}
alt=""
fill
sizes="(max-width: 768px) 100vw, 640px"
className="object-cover"
unoptimized
/>
</div>
))}
</div>
<div className="absolute inset-x-0 bottom-3 flex items-center justify-center gap-1.5 pointer-events-none">
{images.map((_, i) => (
<span
key={i}
aria-hidden
className={cn(
"size-1.5 rounded-full transition-all",
i === active ? "bg-white" : "bg-white/50",
)}
/>
))}
</div>
</div>
);
}
function QuotePanel({ post }: { post: Post }) {
// No-image post: the entire square is a flat palette-aware surface
// (pure white on light themes, palette-specific dark on dark themes)
// with the quote in serif inside. Text uses --foreground so it inverts
// automatically with the theme.
return (
<div
className="relative aspect-square overflow-hidden select-none"
style={quoteCardBgStyle()}
>
<div className="absolute inset-0 flex flex-col justify-center items-center p-8 sm:p-10 text-center text-foreground">
<p className="font-[family-name:var(--font-title)] text-xl sm:text-2xl leading-relaxed max-w-md line-clamp-[8] break-words">
“{post.caption}”
</p>
</div>
</div>
);
}
function Caption({
text,
expanded,
onToggle,
}: {
text: string;
expanded: boolean;
onToggle: () => void;
}) {
// Show 3 lines collapsed. The "more" toggle appears only when the text
// is long enough to warrant it (a cheap char-length check beats a
// measure-and-compare pass for our purposes — captions over ~180 chars
// will always overflow 3 lines at the rendered font size).
const longEnough = text.length > 180;
return (
<div className="text-sm text-foreground leading-relaxed">
<p
className={cn(
"font-[family-name:var(--font-title)] break-words",
!expanded && longEnough && "line-clamp-3",
)}
>
“{text}”
</p>
{longEnough ? (
<button
type="button"
onClick={onToggle}
className="mt-1 text-xs font-medium text-muted-foreground hover:text-foreground transition-colors"
>
{expanded ? "Show less" : "More"}
</button>
) : null}
</div>
);
}
function AuthorAvatar({
src,
name,
}: {
src: string | null;
name: string;
}) {
if (src) {
return (
<Image
src={src}
alt={name}
width={36}
height={36}
className="size-9 rounded-full object-cover ring-1 ring-foreground/10"
unoptimized
/>
);
}
const initial = name.charAt(0).toUpperCase() || "?";
return (
<div className="size-9 rounded-full bg-primary/15 text-primary flex items-center justify-center text-sm font-semibold ring-1 ring-foreground/10">
{initial}
</div>
);
}
function OwnerButton({
label,
onClick,
icon,
}: {
label: string;
onClick: () => void;
icon: React.ReactNode;
}) {
return (
<button
type="button"
onClick={onClick}
aria-label={label}
title={label}
className={cn(
"size-9 rounded-full inline-flex items-center justify-center",
"bg-black/40 text-white hover:bg-black/60 transition-colors backdrop-blur-sm",
)}
>
{icon}
</button>
);
}
+52
View File
@@ -0,0 +1,52 @@
"use client";
/**
* PostViews — grid-only renderer for post lists.
*
* Renders a 3-up square grid (Instagram pattern). The previous
* Grid/Scroll tab toggle is gone — the scroll feed lives at
* /u/<handle>/posts and is reached by clicking a grid thumbnail
* (which sets `?hl=` and a hash anchor).
*
* Callers pass `hrefFor(post)` if they want each thumbnail to link to a
* specific URL (e.g. the scroll feed). When omitted, each card defaults
* to its source-passage URL.
*/
import type { Post } from "@/lib/social";
import { PostGridCard } from "./post-grid-card";
export function PostViews({
posts,
onDelete,
onUpdated,
emptyMessage,
hrefFor,
}: {
posts: Post[];
onDelete?: (id: string) => void;
onUpdated?: (updated: Post) => void;
emptyMessage?: string;
hrefFor?: (post: Post) => string;
}) {
if (posts.length === 0) {
return (
<p className="text-sm text-muted-foreground">
{emptyMessage ?? "Nothing here yet."}
</p>
);
}
return (
<div className="grid grid-cols-2 gap-2 sm:gap-3">
{posts.map((p) => (
<PostGridCard
key={p.id}
post={p}
onDelete={onDelete}
onUpdated={onUpdated}
href={hrefFor?.(p)}
/>
))}
</div>
);
}
@@ -0,0 +1,240 @@
"use client";
/**
* UpdateBackgroundModal — swap or remove the image behind a post card.
*
* Two actions:
* • Upload Image — pick a file → signed-URL upload → PUT to BFF
* → returns the refreshed post.
* • Remove Background — PUT empty image set → post falls back to the
* palette-aware quote-card gradient.
*
* Renders as a centred modal on desktop and a bottom drawer on mobile,
* mirroring the pattern used by AuthModal / ConfirmModal so the UX stays
* consistent across the app.
*/
import { useEffect, useRef, useState } from "react";
import { createPortal } from "react-dom";
import { ImageIcon, Loader2, Trash2, Upload, X } from "lucide-react";
import { toast } from "sonner";
import {
Drawer,
DrawerClose,
DrawerContent,
DrawerHeader,
DrawerTitle,
} from "@/components/ui/drawer";
import { posts, SocialError, type Post } from "@/lib/social";
type Busy = "upload" | "remove" | null;
export function UpdateBackgroundModal({
open,
onClose,
post,
isMobile,
onUpdated,
}: {
open: boolean;
onClose: () => void;
post: Post;
isMobile: boolean;
/** Fires with the refreshed post after a successful upload or removal. */
onUpdated?: (updated: Post) => void;
}) {
const fileInput = useRef<HTMLInputElement>(null);
const [busy, setBusy] = useState<Busy>(null);
const hasImage = (post.images?.length ?? 0) > 0;
// Body-scroll lock + Esc-to-close — desktop modal only. The drawer
// handles both internally.
useEffect(() => {
if (!open || isMobile) return;
const prevOverflow = document.body.style.overflow;
document.body.style.overflow = "hidden";
function onKey(e: KeyboardEvent) {
if (e.key === "Escape" && busy === null) onClose();
}
document.addEventListener("keydown", onKey);
return () => {
document.body.style.overflow = prevOverflow;
document.removeEventListener("keydown", onKey);
};
}, [open, isMobile, busy, onClose]);
async function onUpload(e: React.ChangeEvent<HTMLInputElement>) {
const file = e.target.files?.[0];
if (fileInput.current) fileInput.current.value = "";
if (!file) return;
setBusy("upload");
try {
const url = await posts.uploadImage(file);
const updated = await posts.replaceImages(post.id, [url]);
toast.success("Background updated");
onUpdated?.(updated);
onClose();
} catch (err) {
toast.error(
err instanceof SocialError ? err.message : "Could not update background.",
);
} finally {
setBusy(null);
}
}
async function onRemove() {
setBusy("remove");
try {
const updated = await posts.replaceImages(post.id, []);
toast.success("Background removed");
onUpdated?.(updated);
onClose();
} catch (err) {
toast.error(
err instanceof SocialError ? err.message : "Could not remove background.",
);
} finally {
setBusy(null);
}
}
if (!open) return null;
const body = (
<div className="space-y-2">
<input
ref={fileInput}
type="file"
accept="image/*"
hidden
onChange={onUpload}
/>
<button
type="button"
onClick={() => fileInput.current?.click()}
disabled={busy !== null}
className="w-full flex items-center gap-3 p-3 rounded-lg border border-border hover:bg-accent transition-colors text-left disabled:opacity-60"
>
<span className="flex items-center justify-center size-10 rounded-lg bg-primary/10 text-primary shrink-0">
{busy === "upload" ? (
<Loader2 className="size-5 animate-spin" />
) : (
<Upload className="size-5" />
)}
</span>
<span className="min-w-0 flex-1">
<span className="block text-sm font-medium text-foreground">
{hasImage ? "Replace Image" : "Upload Image"}
</span>
<span className="block text-xs text-muted-foreground">
JPEG or PNG, recommended ≥ 1080×1080
</span>
</span>
</button>
{hasImage ? (
<button
type="button"
onClick={onRemove}
disabled={busy !== null}
className="w-full flex items-center gap-3 p-3 rounded-lg border border-border hover:bg-destructive/5 transition-colors text-left disabled:opacity-60"
>
<span className="flex items-center justify-center size-10 rounded-lg bg-destructive/10 text-destructive shrink-0">
{busy === "remove" ? (
<Loader2 className="size-5 animate-spin" />
) : (
<Trash2 className="size-5" />
)}
</span>
<span className="min-w-0 flex-1">
<span className="block text-sm font-medium text-foreground">
Remove Background
</span>
<span className="block text-xs text-muted-foreground">
Falls back to a theme-coloured gradient
</span>
</span>
</button>
) : null}
</div>
);
if (isMobile) {
return (
<Drawer open={open} onOpenChange={(o) => !o && busy === null && onClose()}>
<DrawerContent>
<DrawerHeader className="text-left">
<DrawerTitle>Update Background</DrawerTitle>
</DrawerHeader>
<div className="px-4 pb-4">{body}</div>
<div className="p-4 pt-0">
<DrawerClose asChild>
<button
disabled={busy !== null}
className="w-full py-2.5 rounded-lg border border-border text-sm font-medium text-muted-foreground hover:bg-accent disabled:opacity-50"
>
Cancel
</button>
</DrawerClose>
</div>
</DrawerContent>
</Drawer>
);
}
// Desktop: centred modal with portal + backdrop, same pattern as
// ConfirmModal/AuthModal so behaviour stays consistent.
if (typeof document === "undefined") return null;
return createPortal(
<>
<div
className="fixed inset-0 z-100 bg-foreground/40 backdrop-blur-sm animate-in fade-in-0"
onClick={() => busy === null && onClose()}
aria-hidden
/>
<div
role="dialog"
aria-modal="true"
aria-labelledby="update-bg-title"
className="fixed left-1/2 top-1/2 z-101 w-full max-w-md -translate-x-1/2 -translate-y-1/2 px-4"
>
<div className="rounded-2xl border border-border bg-card shadow-xl overflow-hidden">
<div className="flex items-start gap-3 px-5 pt-5 pb-3">
<span
className="shrink-0 size-10 rounded-full bg-primary/10 text-primary flex items-center justify-center"
aria-hidden
>
<ImageIcon className="size-5" />
</span>
<div className="flex-1 min-w-0">
<h2
id="update-bg-title"
className="font-[family-name:var(--font-title)] font-semibold text-lg text-foreground"
>
Update Background
</h2>
<p className="mt-1 text-sm text-muted-foreground leading-relaxed">
Pick an image to set as the card's background, or remove the
current one.
</p>
</div>
<button
type="button"
onClick={() => busy === null && onClose()}
aria-label="Close"
disabled={busy !== null}
className="shrink-0 size-8 rounded-lg text-muted-foreground hover:text-foreground hover:bg-muted inline-flex items-center justify-center disabled:opacity-50"
>
<X className="size-4" />
</button>
</div>
<div className="px-5 pb-5">{body}</div>
</div>
</div>
</>,
document.body,
);
}
+293 -66
View File
@@ -1,8 +1,23 @@
"use client";
import { useState, useEffect, useRef, useCallback } from "react";
import { Share2, Copy, MoreHorizontal, Check } from "lucide-react";
import { formatCopyText } from "@/lib/source-reference";
import {
AtSign,
Bookmark as BookmarkIcon,
Copy,
FolderPlus,
Link as LinkIcon,
MoreHorizontal,
Send,
Share2,
} from "lucide-react";
import { toast } from "sonner";
import { formatCopyText, getSourceReference } from "@/lib/source-reference";
import {
buildHighlightUrl,
buildShortHighlightUrl,
} from "@/lib/text-highlight";
import { bookmarks, SocialError } from "@/lib/social";
import {
KautilyaIcon,
useMediaQuery,
@@ -11,9 +26,11 @@ import {
type ToolbarAction,
} from "./selection-toolbar/shared";
import { FloatingToolbar } from "./selection-toolbar/floating-toolbar";
import { ShareModal } from "./selection-toolbar/share-modal";
import { CollectionsDrawer } from "./selection-toolbar/collections-drawer";
import { MorePopover } from "./selection-toolbar/more-popover";
import { MorePopover, type MoreItem } from "./selection-toolbar/more-popover";
import { ComposePost } from "./selection-toolbar/compose-post";
import { useAuth } from "./auth/auth-provider";
import { useAuthGate } from "./auth/auth-gate";
// ---------------------------------------------------------------------------
// Main Component
@@ -24,10 +41,45 @@ export function TextSelectionToolbar() {
const [toolbarPos, setToolbarPos] = useState<Position | null>(null);
const [showMore, setShowMore] = useState(false);
const [morePos, setMorePos] = useState<Position>({ x: 0, y: 0 });
const [showShare, setShowShare] = useState(false);
const [showCollections, setShowCollections] = useState(false);
const { copied, copy } = useCopyFeedback();
const [showCompose, setShowCompose] = useState(false);
const { copy } = useCopyFeedback();
const isMobile = useMediaQuery("(max-width: 767px)");
const { requireAuth } = useAuthGate();
const { user } = useAuth();
// Cached bookmark count → drives the "Bookmark" / "Collect" swap.
// We fetch on auth state change and react to the toolbar's own
// `spsays:bookmarks-changed` event so the label flips immediately
// after saving the user's last allowed bookmark.
const [bookmarkCount, setBookmarkCount] = useState(0);
useEffect(() => {
if (!user) {
setBookmarkCount(0);
return;
}
let cancelled = false;
const refetch = () => {
bookmarks
.list()
.then((rows) => {
if (!cancelled) setBookmarkCount(rows?.length ?? 0);
})
.catch(() => {
/* non-essential — swallow */
});
};
refetch();
const onChanged = () => refetch();
window.addEventListener("spsays:bookmarks-changed", onChanged);
return () => {
cancelled = true;
window.removeEventListener("spsays:bookmarks-changed", onChanged);
};
}, [user]);
const bookmarkLimit = user?.plan === "premium" ? 5 : 1;
const limitReached = !!user && bookmarkCount >= bookmarkLimit;
// Track whether the user is interacting with our toolbar/popovers
const interactingRef = useRef(false);
@@ -66,7 +118,12 @@ export function TextSelectionToolbar() {
useEffect(() => {
function handleSelectionChange() {
// Don't dismiss while interacting with toolbar menus
if (interactingRef.current || showMore || showShare || showCollections) {
if (
interactingRef.current ||
showMore ||
showCollections ||
showCompose
) {
return;
}
@@ -186,7 +243,7 @@ export function TextSelectionToolbar() {
document.removeEventListener("touchstart", onTouchStart);
document.removeEventListener("keyup", onKeyUp);
};
}, [clearSelection, showMore, showShare, showCollections]);
}, [clearSelection, showMore, showCollections, showCompose]);
// Intercept native Ctrl+C / Cmd+C to append source reference
useEffect(() => {
@@ -230,7 +287,7 @@ export function TextSelectionToolbar() {
const target = e.target as Element;
if (target.closest("[data-selection-toolbar]")) return;
if (showMore || showShare || showCollections) return;
if (showMore || showCollections || showCompose) return;
const sel = window.getSelection();
const text = sel?.toString().trim() ?? "";
@@ -241,7 +298,7 @@ export function TextSelectionToolbar() {
document.addEventListener("pointerdown", handlePointerDown);
return () => document.removeEventListener("pointerdown", handlePointerDown);
}, [clearSelection, showMore, showShare, showCollections]);
}, [clearSelection, showMore, showCollections, showCompose]);
// Don't render on RTF viewer or if nothing is selected
if (
@@ -253,60 +310,245 @@ export function TextSelectionToolbar() {
if (!toolbarPos || !selectedText) return null;
// --- Toolbar actions ---
// ─── Action handlers ──────────────────────────────────────────────────
const toolbarActions: ToolbarAction[] = [
function openCollectionsDrawer() {
if (isMobile) saveAndClearSelection();
else interactingRef.current = true;
setShowCollections(true);
}
async function onPost() {
if (!(await requireAuth())) return;
if (isMobile) saveAndClearSelection();
else interactingRef.current = true;
setShowCompose(true);
}
async function onBookmarkOrCollect() {
// Fast path: already at cap → reuse this click to open Save to
// Collection. The label is "Collect" in this state so the user knows.
if (limitReached) {
openCollectionsDrawer();
return;
}
if (!(await requireAuth())) return;
try {
const sourceURL = buildHighlightUrl(selectedText);
const ref = getSourceReference();
const sourceTitle = ref
? ref.detail
? `${ref.title} — ${ref.detail}`
: ref.title
: typeof document !== "undefined"
? document.title
: "";
await bookmarks.create({
source_url: sourceURL,
source_title: sourceTitle,
excerpt: selectedText,
});
toast.success("Bookmark saved", { duration: 2000 });
window.dispatchEvent(new CustomEvent("spsays:bookmarks-changed"));
clearSelection();
} catch (err) {
if (err instanceof SocialError && err.status === 402) {
toast.error("Bookmark limit reached", {
description:
"Free plan allows 1 bookmark. We'll save this to a collection instead.",
duration: 6000,
});
openCollectionsDrawer();
} else if (err instanceof SocialError) {
toast.error(err.message);
} else {
toast.error("Could not save bookmark.");
}
}
}
async function onCopyText() {
await copy(formatCopyText(selectedText));
toast.success("Copied to clipboard", { duration: 1500 });
clearSelection();
}
async function onCopyLink() {
const url = await buildShortHighlightUrl(selectedText);
await copy(url);
toast.success("Link copied", { duration: 1500 });
clearSelection();
}
async function onSaveToCollection() {
setShowMore(false);
if (!(await requireAuth())) return;
openCollectionsDrawer();
}
// ─── Toolbar actions ──────────────────────────────────────────────────
//
// Authed users get action-oriented top-level buttons (Post / Bookmark)
// and More holds copy/share. Public users get the universally-usable
// copy/share at the top and every account-requiring action sits behind
// a lock in More.
const isAuthed = !!user;
const askAction: ToolbarAction = {
key: "ask",
label: "Ask Kautilya",
icon: <KautilyaIcon className="size-3.5" />,
onClick: () => {
// TODO: Implement Ask Kautilya
clearSelection();
},
className: "text-amber-400 hover:bg-amber-400/15 hover:text-amber-300",
};
const moreAction: ToolbarAction = {
key: "more",
label: "More",
icon: <MoreHorizontal className="size-3.5" />,
onClick: () => {
if (isMobile) saveAndClearSelection();
else interactingRef.current = true;
setMorePos({
x: toolbarPos.x + 60,
y: toolbarPos.y,
});
setShowMore(true);
},
};
const authedToolbarActions: ToolbarAction[] = [
askAction,
{
key: "ask",
label: "Ask Kautilya",
icon: <KautilyaIcon className="size-3.5" />,
onClick: () => {
// TODO: Implement Ask Kautilya
clearSelection();
},
className: "text-amber-400 hover:bg-amber-400/15 hover:text-amber-300",
key: "post",
label: "Post",
icon: <Send className="size-3.5" />,
onClick: onPost,
},
{
key: "bookmark",
label: limitReached ? "Collect" : "Bookmark",
icon: limitReached ? (
<FolderPlus className="size-3.5" />
) : (
<BookmarkIcon className="size-3.5" />
),
onClick: onBookmarkOrCollect,
},
moreAction,
];
const publicToolbarActions: ToolbarAction[] = [
askAction,
{
key: "copy",
label: "Copy",
icon: <Copy className="size-3.5" />,
onClick: onCopyText,
},
{
key: "share",
label: "Share",
icon: <Share2 className="size-3.5" />,
onClick: () => {
if (isMobile) saveAndClearSelection();
else interactingRef.current = true;
setShowShare(true);
onClick: onCopyLink,
},
moreAction,
];
const toolbarActions = isAuthed ? authedToolbarActions : publicToolbarActions;
const authedMoreItems: MoreItem[] = [
{
key: "copy-text",
label: "Copy Text",
description: "Passage with source citation",
icon: <Copy className="size-5" />,
onClick: async () => {
setShowMore(false);
await onCopyText();
},
},
{
key: "copy",
label: copied ? "Copied!" : "Copy",
icon: copied ? (
<Check className="size-3.5" />
) : (
<Copy className="size-3.5" />
),
onClick: () => {
copy(formatCopyText(selectedText));
key: "copy-link",
label: "Copy Link",
description: "Short URL that highlights this passage",
icon: <LinkIcon className="size-5" />,
onClick: async () => {
setShowMore(false);
await onCopyLink();
},
className: copied
? "text-green-400 bg-green-400/15"
: "text-white/70 hover:bg-white/10 hover:text-white",
},
{
key: "more",
label: "More",
icon: <MoreHorizontal className="size-3.5" />,
key: "collection",
label: "Save to Collection",
description: "Organise into a collection",
icon: <FolderPlus className="size-5" />,
onClick: onSaveToCollection,
},
{
key: "mention",
label: "Mention",
description: "Tag someone with this quote",
icon: <AtSign className="size-5" />,
onClick: () => {
if (isMobile) saveAndClearSelection();
else interactingRef.current = true;
setMorePos({
x: toolbarPos.x + 60,
y: toolbarPos.y,
});
setShowMore(true);
setShowMore(false);
if (isMobile) restoreSelection();
else interactingRef.current = false;
},
},
];
const publicMoreItems: MoreItem[] = [
{
key: "share-as-post",
label: "Share as Post",
description: "Publish this passage to your feed",
icon: <Send className="size-5" />,
locked: true,
onClick: async () => {
setShowMore(false);
await onPost();
},
},
{
key: "save-to-collection-public",
label: "Save to Collection",
description: "Organise into a collection",
icon: <FolderPlus className="size-5" />,
locked: true,
onClick: onSaveToCollection,
},
{
key: "bookmark-public",
label: "Bookmark",
description: "Save your place — always private",
icon: <BookmarkIcon className="size-5" />,
locked: true,
onClick: async () => {
setShowMore(false);
await onBookmarkOrCollect();
},
},
{
key: "mention-public",
label: "Mention",
description: "Tag someone with this quote",
icon: <AtSign className="size-5" />,
locked: true,
onClick: async () => {
setShowMore(false);
if (!(await requireAuth())) return;
// TODO: Implement Mention
},
},
];
const moreItems = isAuthed ? authedMoreItems : publicMoreItems;
// --- Render ---
return (
@@ -321,34 +563,18 @@ export function TextSelectionToolbar() {
open={showMore}
position={morePos}
isMobile={isMobile}
selectedText={selectedText}
items={moreItems}
onClose={() => {
setShowMore(false);
if (isMobile) restoreSelection();
else interactingRef.current = false;
}}
onBookmark={() => {
setShowMore(false);
if (isMobile) restoreSelection();
else interactingRef.current = false;
}}
onSaveToCollection={() => {
setShowMore(false);
if (isMobile) saveAndClearSelection();
else interactingRef.current = true;
setShowCollections(true);
}}
onMention={() => {
setShowMore(false);
if (isMobile) restoreSelection();
else interactingRef.current = false;
}}
/>
<ShareModal
open={showShare}
<ComposePost
open={showCompose}
onClose={() => {
setShowShare(false);
setShowCompose(false);
if (isMobile) restoreSelection();
else interactingRef.current = false;
}}
@@ -364,6 +590,7 @@ export function TextSelectionToolbar() {
else interactingRef.current = false;
}}
isMobile={isMobile}
selectedText={selectedText}
/>
</div>
);
+71
View File
@@ -0,0 +1,71 @@
"use client";
/**
* Checkbox — themed replacement for the native `<input type="checkbox" />`.
*
* The native control is rendered visually hidden (sr-only) so screen
* readers and keyboard focus still work the way browsers expect, while
* we paint a small rounded box next to it that fills with the primary
* color when checked. Fully controlled — caller owns `checked` state.
*
* Usage:
*
* <Checkbox checked={ok} onCheckedChange={setOk} />
*
* <label className="flex items-center gap-2">
* <Checkbox checked={ok} onCheckedChange={setOk} />
* Accept terms
* </label>
*/
import { Check } from "lucide-react";
import { cn } from "@/lib/utils";
export function Checkbox({
checked,
onCheckedChange,
disabled,
className,
ariaLabel,
}: {
checked: boolean;
onCheckedChange: (next: boolean) => void;
disabled?: boolean;
className?: string;
ariaLabel?: string;
}) {
return (
<span
className={cn(
"relative inline-flex items-center justify-center shrink-0",
className,
)}
>
<input
type="checkbox"
checked={checked}
disabled={disabled}
aria-label={ariaLabel}
onChange={(e) => onCheckedChange(e.target.checked)}
className="peer absolute inset-0 size-full appearance-none m-0 cursor-pointer disabled:cursor-not-allowed rounded-md"
/>
<span
aria-hidden
className={cn(
"pointer-events-none size-5 rounded-md border transition-colors",
"flex items-center justify-center",
checked
? "bg-primary border-primary"
: "bg-background border-border",
"peer-hover:border-foreground/40",
"peer-focus-visible:ring-2 peer-focus-visible:ring-ring peer-focus-visible:ring-offset-1 peer-focus-visible:ring-offset-background",
disabled && "opacity-50",
)}
>
{checked ? (
<Check className="size-3.5 text-primary-foreground" strokeWidth={3} />
) : null}
</span>
</span>
);
}
+188
View File
@@ -0,0 +1,188 @@
"use client";
/**
* ConfirmModal — palette-themed replacement for native `window.confirm`.
*
* Mirrors the structure of AuthModal (portal + backdrop + Esc-to-close +
* click-outside dismiss + body-scroll lock) but with a single primary
* action button. The default tone is "destructive" since that's the
* common case (delete this thing), but the `tone` prop swaps to neutral
* for less-scary confirms.
*
* Usage:
*
* const [open, setOpen] = useState(false);
* <ConfirmModal
* open={open}
* onClose={() => setOpen(false)}
* title="Delete this collection?"
* message="Posts inside will stay on your feed."
* confirmLabel="Delete"
* onConfirm={async () => { await collections.delete(id); ... }}
* />
*
* The `onConfirm` may return a promise — the dialog shows a pending state
* and stays open until it resolves, then auto-closes on success. Errors
* thrown from onConfirm surface inline below the buttons.
*/
import { useEffect, useState } from "react";
import { createPortal } from "react-dom";
import { AlertTriangle, X } from "lucide-react";
import { cn } from "@/lib/utils";
type Tone = "destructive" | "neutral";
export function ConfirmModal({
open,
onClose,
title,
message,
confirmLabel = "Confirm",
cancelLabel = "Cancel",
tone = "destructive",
onConfirm,
}: {
open: boolean;
onClose: () => void;
title: string;
message?: string;
confirmLabel?: string;
cancelLabel?: string;
tone?: Tone;
/** Called when the user confirms. May return a promise. Throw to keep the modal open with an error message. */
onConfirm: () => void | Promise<void>;
}) {
const [working, setWorking] = useState(false);
const [error, setError] = useState<string | null>(null);
// Reset transient state every time the modal opens — otherwise an error
// from a previous attempt would linger on the next open.
useEffect(() => {
if (open) {
setWorking(false);
setError(null);
}
}, [open]);
useEffect(() => {
if (!open) return;
function onKey(e: KeyboardEvent) {
if (e.key === "Escape" && !working) onClose();
}
document.addEventListener("keydown", onKey);
return () => document.removeEventListener("keydown", onKey);
}, [open, working, onClose]);
// Body-scroll lock while open.
useEffect(() => {
if (!open) return;
const prev = document.body.style.overflow;
document.body.style.overflow = "hidden";
return () => {
document.body.style.overflow = prev;
};
}, [open]);
if (!open) return null;
if (typeof document === "undefined") return null;
async function handleConfirm() {
setError(null);
setWorking(true);
try {
await onConfirm();
onClose();
} catch (err) {
setError(
err instanceof Error ? err.message : "Something went wrong.",
);
} finally {
setWorking(false);
}
}
return createPortal(
<>
<div
className="fixed inset-0 z-100 bg-foreground/40 backdrop-blur-sm animate-in fade-in-0"
onClick={() => !working && onClose()}
aria-hidden
/>
<div
role="alertdialog"
aria-modal="true"
aria-labelledby="confirm-modal-title"
aria-describedby={message ? "confirm-modal-message" : undefined}
className="fixed left-1/2 top-1/2 z-101 w-full max-w-md -translate-x-1/2 -translate-y-1/2 px-4"
>
<div className="rounded-2xl border border-border bg-card shadow-xl overflow-hidden">
<div className="flex items-start gap-3 px-5 pt-5">
{tone === "destructive" ? (
<span
className="shrink-0 size-10 rounded-full bg-destructive/10 text-destructive flex items-center justify-center"
aria-hidden
>
<AlertTriangle className="size-5" />
</span>
) : null}
<div className="flex-1 min-w-0">
<h2
id="confirm-modal-title"
className="font-[family-name:var(--font-title)] font-semibold text-lg text-foreground"
>
{title}
</h2>
{message ? (
<p
id="confirm-modal-message"
className="mt-1 text-sm text-muted-foreground leading-relaxed"
>
{message}
</p>
) : null}
</div>
<button
type="button"
onClick={() => !working && onClose()}
aria-label="Close"
className="shrink-0 size-8 rounded-lg text-muted-foreground hover:text-foreground hover:bg-muted inline-flex items-center justify-center disabled:opacity-50"
disabled={working}
>
<X className="size-4" />
</button>
</div>
{error ? (
<p className="mx-5 mt-3 text-sm text-destructive">{error}</p>
) : null}
<div className="px-5 py-4 mt-2 flex items-center justify-end gap-2 border-t border-border bg-muted/30">
<button
type="button"
onClick={onClose}
disabled={working}
className="h-10 px-4 rounded-md text-sm font-medium text-foreground hover:bg-muted transition-colors disabled:opacity-50"
>
{cancelLabel}
</button>
<button
type="button"
onClick={handleConfirm}
disabled={working}
className={cn(
"h-10 px-4 rounded-md text-sm font-medium transition-colors disabled:opacity-60",
tone === "destructive"
? "bg-destructive text-white hover:bg-destructive/90"
: "bg-primary text-primary-foreground hover:bg-primary/90",
)}
>
{working ? "Working…" : confirmLabel}
</button>
</div>
</div>
</div>
</>,
document.body,
);
}
+37
View File
@@ -0,0 +1,37 @@
"use client";
/**
* Toaster — sonner mounted with our palette tokens, so toasts read the
* same `--card`, `--foreground`, etc. variables the rest of the site uses.
* That keeps notifications on-theme across all six light/dark palettes
* without per-palette overrides.
*/
import { useTheme } from "next-themes";
import { Toaster as SonnerToaster } from "sonner";
export function Toaster() {
const { resolvedTheme } = useTheme();
return (
<SonnerToaster
theme={(resolvedTheme as "light" | "dark") || "light"}
position="bottom-right"
richColors={false}
closeButton
toastOptions={{
// Inline styles win over sonner's defaults, but stay token-driven
// so the active palette is honoured.
style: {
background: "var(--card)",
color: "var(--foreground)",
border: "1px solid var(--border)",
},
classNames: {
toast: "font-[family-name:var(--font-title)]",
description: "text-[var(--muted-foreground)]",
actionButton: "bg-[var(--primary)] text-[var(--primary-foreground)]",
},
}}
/>
);
}