feat: add user centric features
This commit is contained in:
@@ -0,0 +1,109 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* AuthGate — a global "require login before continuing" prompt.
|
||||
*
|
||||
* Why a gate at all? When an unauthenticated user clicks something that
|
||||
* needs them to be logged in (Bookmark, Save to collection, etc.) we want to
|
||||
*
|
||||
* 1. Show a login/signup modal *on the same page* (no redirect).
|
||||
* 2. Let them finish the original action right after they log in.
|
||||
* 3. Keep their URL — bookmarks, scroll position, selection — untouched.
|
||||
*
|
||||
* The trick is the imperative `requireAuth()` call: it returns a Promise that
|
||||
* resolves `true` once the user is authenticated, or `false` if they close
|
||||
* the modal without doing so. Calling code reads like a sync check:
|
||||
*
|
||||
* if (!(await requireAuth())) return;
|
||||
* // ... proceed with the protected API call
|
||||
*
|
||||
* Implementation sketch:
|
||||
* - We hold a single "pending" promise resolver in a ref so a follow-on
|
||||
* call to requireAuth (e.g. from another button) hooks onto the same
|
||||
* modal session.
|
||||
* - When AuthProvider reports a user (via useAuth) and the modal is open,
|
||||
* we resolve the promise(s) with `true` and close.
|
||||
* - When the modal closes without a user, we resolve with `false`.
|
||||
*/
|
||||
|
||||
import {
|
||||
createContext,
|
||||
useCallback,
|
||||
useContext,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
import { useAuth } from "./auth-provider";
|
||||
import { AuthModal } from "./auth-modal";
|
||||
|
||||
type AuthGateValue = {
|
||||
/** Returns true if the user is (or becomes) authenticated; false if they dismiss. */
|
||||
requireAuth: () => Promise<boolean>;
|
||||
/** Force-open the modal regardless of auth state — for explicit "Log in" buttons. */
|
||||
openModal: () => void;
|
||||
};
|
||||
|
||||
const AuthGateContext = createContext<AuthGateValue | null>(null);
|
||||
|
||||
export function AuthGateProvider({ children }: { children: React.ReactNode }) {
|
||||
const { user, loading } = useAuth();
|
||||
const [open, setOpen] = useState(false);
|
||||
const resolversRef = useRef<((v: boolean) => void)[]>([]);
|
||||
|
||||
const requireAuth = useCallback(async () => {
|
||||
// Fast path: already logged in.
|
||||
if (user) return true;
|
||||
setOpen(true);
|
||||
return new Promise<boolean>((resolve) => {
|
||||
resolversRef.current.push(resolve);
|
||||
});
|
||||
}, [user]);
|
||||
|
||||
const openModal = useCallback(() => {
|
||||
if (!user) setOpen(true);
|
||||
}, [user]);
|
||||
|
||||
// Auto-resolve pending promises the moment the user becomes authenticated.
|
||||
// This is what flips us from "modal open, waiting" to "modal closed, action
|
||||
// continues" without any extra wiring at the call site.
|
||||
useEffect(() => {
|
||||
if (user && (open || resolversRef.current.length > 0)) {
|
||||
const pending = resolversRef.current;
|
||||
resolversRef.current = [];
|
||||
setOpen(false);
|
||||
pending.forEach((r) => r(true));
|
||||
}
|
||||
}, [user, open]);
|
||||
|
||||
const onDismiss = useCallback(() => {
|
||||
const pending = resolversRef.current;
|
||||
resolversRef.current = [];
|
||||
setOpen(false);
|
||||
pending.forEach((r) => r(false));
|
||||
}, []);
|
||||
|
||||
const value = useMemo<AuthGateValue>(
|
||||
() => ({ requireAuth, openModal }),
|
||||
[requireAuth, openModal],
|
||||
);
|
||||
|
||||
return (
|
||||
<AuthGateContext.Provider value={value}>
|
||||
{children}
|
||||
{/* Render the modal only when we genuinely need it. `!user` guards
|
||||
against the brief window during which the AuthProvider hasn't
|
||||
finished hydrating; we'd rather wait than flash the modal. */}
|
||||
{open && !user && !loading ? <AuthModal onDismiss={onDismiss} /> : null}
|
||||
</AuthGateContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function useAuthGate(): AuthGateValue {
|
||||
const ctx = useContext(AuthGateContext);
|
||||
if (!ctx) {
|
||||
throw new Error("useAuthGate must be used inside <AuthGateProvider>");
|
||||
}
|
||||
return ctx;
|
||||
}
|
||||
@@ -0,0 +1,158 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* AuthModal — login + signup tabs in a portal-rendered dialog.
|
||||
*
|
||||
* The two forms share the existing LoginForm / SignUpForm widgets, so the
|
||||
* behaviour is identical to the standalone /login and /signup pages.
|
||||
*
|
||||
* The forms call `onSuccess` after they finish, but the modal does NOT close
|
||||
* from that callback — AuthGate's effect on `user` change handles the close
|
||||
* + promise resolution. This lets a single source of truth (AuthProvider's
|
||||
* user state) drive the whole flow.
|
||||
*
|
||||
* No router pushes here — the user's URL stays exactly where it was.
|
||||
*/
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import Link from "next/link";
|
||||
import { X } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { LoginForm } from "./login-form";
|
||||
import { SignUpForm } from "./signup-form";
|
||||
|
||||
type Mode = "login" | "signup";
|
||||
|
||||
export function AuthModal({ onDismiss }: { onDismiss: () => void }) {
|
||||
const [mode, setMode] = useState<Mode>("login");
|
||||
|
||||
// Esc-to-close — matches the pattern used elsewhere (reading settings,
|
||||
// search overlay) so the keyboard behavior feels consistent.
|
||||
useEffect(() => {
|
||||
function onKey(e: KeyboardEvent) {
|
||||
if (e.key === "Escape") onDismiss();
|
||||
}
|
||||
document.addEventListener("keydown", onKey);
|
||||
return () => document.removeEventListener("keydown", onKey);
|
||||
}, [onDismiss]);
|
||||
|
||||
// Lock scroll on the body while the modal is open.
|
||||
useEffect(() => {
|
||||
const prev = document.body.style.overflow;
|
||||
document.body.style.overflow = "hidden";
|
||||
return () => {
|
||||
document.body.style.overflow = prev;
|
||||
};
|
||||
}, []);
|
||||
|
||||
if (typeof document === "undefined") return null;
|
||||
|
||||
return createPortal(
|
||||
<>
|
||||
<div
|
||||
className="fixed inset-0 z-100 bg-foreground/40 backdrop-blur-sm animate-in fade-in-0"
|
||||
onClick={onDismiss}
|
||||
aria-hidden
|
||||
/>
|
||||
<div
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="auth-modal-title"
|
||||
className="fixed left-1/2 top-1/2 z-101 w-full max-w-md -translate-x-1/2 -translate-y-1/2 px-4"
|
||||
>
|
||||
<div className="rounded-2xl border border-border bg-card shadow-xl overflow-hidden">
|
||||
<header className="flex items-center justify-between px-5 py-4 border-b border-border">
|
||||
<h2
|
||||
id="auth-modal-title"
|
||||
className="font-[family-name:var(--font-title)] font-semibold text-xl text-foreground"
|
||||
>
|
||||
{mode === "login" ? "Welcome back" : "Create your account"}
|
||||
</h2>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onDismiss}
|
||||
className="size-8 rounded-lg text-muted-foreground hover:text-foreground hover:bg-muted inline-flex items-center justify-center"
|
||||
aria-label="Close"
|
||||
>
|
||||
<X className="size-4" />
|
||||
</button>
|
||||
</header>
|
||||
|
||||
{/* Tab toggle */}
|
||||
<div className="px-5 pt-4 grid grid-cols-2 gap-1 rounded-lg">
|
||||
<ModeTab active={mode === "login"} onClick={() => setMode("login")}>
|
||||
Log in
|
||||
</ModeTab>
|
||||
<ModeTab
|
||||
active={mode === "signup"}
|
||||
onClick={() => setMode("signup")}
|
||||
>
|
||||
Sign up
|
||||
</ModeTab>
|
||||
</div>
|
||||
|
||||
<div className="px-5 py-5">
|
||||
{mode === "login" ? (
|
||||
<LoginForm
|
||||
/* onSuccess is a no-op here — AuthGate watches `user` directly
|
||||
and closes the modal + resolves the pending promise itself. */
|
||||
onSuccess={() => {}}
|
||||
forgotPasswordHref="/forgot-password"
|
||||
/>
|
||||
) : (
|
||||
<SignUpForm onSuccess={() => {}} />
|
||||
)}
|
||||
</div>
|
||||
|
||||
<footer className="px-5 pb-5 -mt-1 text-xs text-muted-foreground">
|
||||
{mode === "login" ? (
|
||||
<>
|
||||
Need to reset your password?{" "}
|
||||
<Link
|
||||
href="/forgot-password"
|
||||
className="text-primary hover:underline"
|
||||
onClick={onDismiss}
|
||||
>
|
||||
Click here
|
||||
</Link>
|
||||
.
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
By signing up you agree to our acceptable-use policy. Posts on
|
||||
your feed are public.
|
||||
</>
|
||||
)}
|
||||
</footer>
|
||||
</div>
|
||||
</div>
|
||||
</>,
|
||||
document.body,
|
||||
);
|
||||
}
|
||||
|
||||
function ModeTab({
|
||||
active,
|
||||
onClick,
|
||||
children,
|
||||
}: {
|
||||
active: boolean;
|
||||
onClick: () => void;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
className={cn(
|
||||
"h-10 rounded-md text-sm font-medium transition-colors",
|
||||
active
|
||||
? "bg-primary text-primary-foreground"
|
||||
: "text-muted-foreground hover:text-foreground hover:bg-muted",
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* LoginForm — pure form widget reused by both the dedicated /login page and
|
||||
* the AuthGate modal that pops up when an unauthenticated user tries to use
|
||||
* a protected feature.
|
||||
*
|
||||
* `onSuccess` is invoked once login completes. Callers decide what happens
|
||||
* next (a page redirect, a modal close, etc.). The form itself doesn't
|
||||
* navigate — that keeps it environment-agnostic.
|
||||
*
|
||||
* `forgotPasswordHref` is configurable so the modal can override (it may
|
||||
* want to dismiss itself or open a different mode).
|
||||
*/
|
||||
|
||||
import { useState } from "react";
|
||||
import Link from "next/link";
|
||||
import { useAuth } from "@/components/auth/auth-provider";
|
||||
import { AuthError } from "@/lib/auth";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
|
||||
export function LoginForm({
|
||||
onSuccess,
|
||||
forgotPasswordHref = "/forgot-password",
|
||||
showForgotLink = true,
|
||||
}: {
|
||||
onSuccess?: () => void;
|
||||
forgotPasswordHref?: string;
|
||||
showForgotLink?: boolean;
|
||||
}) {
|
||||
const { logIn } = useAuth();
|
||||
const [email, setEmail] = useState("");
|
||||
const [password, setPassword] = useState("");
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
|
||||
async function onSubmit(e: React.SyntheticEvent) {
|
||||
e.preventDefault();
|
||||
setError(null);
|
||||
setSubmitting(true);
|
||||
try {
|
||||
await logIn(email, password);
|
||||
onSuccess?.();
|
||||
} catch (err) {
|
||||
setError(err instanceof AuthError ? err.message : "Login failed.");
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<form onSubmit={onSubmit} className="space-y-5">
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="login-email">Email</Label>
|
||||
<Input
|
||||
id="login-email"
|
||||
type="email"
|
||||
autoComplete="email"
|
||||
required
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<div className="flex items-center justify-between">
|
||||
<Label htmlFor="login-password">Password</Label>
|
||||
{showForgotLink ? (
|
||||
<Link
|
||||
href={forgotPasswordHref}
|
||||
className="text-xs text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
Forgot?
|
||||
</Link>
|
||||
) : null}
|
||||
</div>
|
||||
<Input
|
||||
id="login-password"
|
||||
type="password"
|
||||
autoComplete="current-password"
|
||||
required
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
{error ? <p className="text-sm text-destructive">{error}</p> : null}
|
||||
<Button type="submit" disabled={submitting} className="w-full h-11 text-base">
|
||||
{submitting ? "Logging in…" : "Log in"}
|
||||
</Button>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* SignUpForm — paired with LoginForm. Same callback contract:
|
||||
* `onSuccess` fires once the new account is logged in; callers decide
|
||||
* what to do next.
|
||||
*/
|
||||
|
||||
import { useState } from "react";
|
||||
import { useAuth } from "@/components/auth/auth-provider";
|
||||
import { AuthError } from "@/lib/auth";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
|
||||
export function SignUpForm({ onSuccess }: { onSuccess?: () => void }) {
|
||||
const { signUp } = useAuth();
|
||||
const [name, setName] = useState("");
|
||||
const [email, setEmail] = useState("");
|
||||
const [password, setPassword] = useState("");
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
|
||||
async function onSubmit(e: React.SyntheticEvent) {
|
||||
e.preventDefault();
|
||||
setError(null);
|
||||
setSubmitting(true);
|
||||
try {
|
||||
await signUp(email, password, name);
|
||||
onSuccess?.();
|
||||
} catch (err) {
|
||||
setError(err instanceof AuthError ? err.message : "Sign-up failed.");
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<form onSubmit={onSubmit} className="space-y-5">
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="signup-name">Name</Label>
|
||||
<Input
|
||||
id="signup-name"
|
||||
type="text"
|
||||
autoComplete="name"
|
||||
required
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="signup-email">Email</Label>
|
||||
<Input
|
||||
id="signup-email"
|
||||
type="email"
|
||||
autoComplete="email"
|
||||
required
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="signup-password">Password</Label>
|
||||
<Input
|
||||
id="signup-password"
|
||||
type="password"
|
||||
autoComplete="new-password"
|
||||
minLength={8}
|
||||
required
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">At least 8 characters.</p>
|
||||
</div>
|
||||
{error ? <p className="text-sm text-destructive">{error}</p> : null}
|
||||
<Button type="submit" disabled={submitting} className="w-full h-11 text-base">
|
||||
{submitting ? "Creating account…" : "Sign up"}
|
||||
</Button>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
@@ -5,13 +5,17 @@
|
||||
*
|
||||
* 1. Loading (auth context hasn't hydrated) — render a quiet placeholder
|
||||
* so the row width doesn't jump when the user data arrives.
|
||||
* 2. Logged out — single "Log in" link.
|
||||
* 2. Logged out — single "Log in" icon/button.
|
||||
* 3. Logged in — avatar / initials chip that opens a popover with
|
||||
* "Profile" and "Log out". Built on @base-ui/react/popover so we
|
||||
* don't pull in additional shadcn primitives.
|
||||
* "Profile" and "Log out".
|
||||
*
|
||||
* Why hand-rolled instead of `@base-ui/react/popover`: outside-click
|
||||
* dismissal was unreliable for the avatar trigger here. The pattern below
|
||||
* mirrors `ReadingSettings`: mousedown listener for outside clicks, keyup
|
||||
* for Escape. That's already proven across the site and stays simple.
|
||||
*/
|
||||
|
||||
import { Popover } from "@base-ui/react/popover";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import Link from "next/link";
|
||||
import Image from "next/image";
|
||||
import { useRouter } from "next/navigation";
|
||||
@@ -33,6 +37,33 @@ function initials(name: string): string {
|
||||
export function UserMenu() {
|
||||
const { user, loading, logOut } = useAuth();
|
||||
const router = useRouter();
|
||||
const [open, setOpen] = useState(false);
|
||||
const panelRef = useRef<HTMLDivElement>(null);
|
||||
const triggerRef = useRef<HTMLButtonElement>(null);
|
||||
|
||||
// Close on outside click + Escape, mirroring ReadingSettings.
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
function onMouseDown(e: MouseEvent) {
|
||||
if (
|
||||
panelRef.current &&
|
||||
!panelRef.current.contains(e.target as Node) &&
|
||||
triggerRef.current &&
|
||||
!triggerRef.current.contains(e.target as Node)
|
||||
) {
|
||||
setOpen(false);
|
||||
}
|
||||
}
|
||||
function onKey(e: KeyboardEvent) {
|
||||
if (e.key === "Escape") setOpen(false);
|
||||
}
|
||||
document.addEventListener("mousedown", onMouseDown);
|
||||
document.addEventListener("keydown", onKey);
|
||||
return () => {
|
||||
document.removeEventListener("mousedown", onMouseDown);
|
||||
document.removeEventListener("keydown", onKey);
|
||||
};
|
||||
}, [open]);
|
||||
|
||||
if (loading) {
|
||||
// Placeholder keeps the action row width stable until auth hydrates.
|
||||
@@ -42,8 +73,6 @@ export function UserMenu() {
|
||||
if (!user) {
|
||||
// Mobile: compact icon button so the header row fits on 375px viewports.
|
||||
// Desktop: full "Log in" text — there's plenty of space.
|
||||
// Same muted→foreground pattern as the search / reading-settings buttons
|
||||
// so the icon sits at a consistent quiet shade across all themes.
|
||||
return (
|
||||
<Link
|
||||
href="/login"
|
||||
@@ -57,16 +86,22 @@ export function UserMenu() {
|
||||
}
|
||||
|
||||
async function onLogOut() {
|
||||
setOpen(false);
|
||||
await logOut();
|
||||
router.push("/");
|
||||
router.refresh();
|
||||
}
|
||||
|
||||
return (
|
||||
<Popover.Root>
|
||||
<Popover.Trigger
|
||||
className="size-9 rounded-full overflow-hidden ring-1 ring-foreground/10 hover:ring-foreground/20 transition-shadow"
|
||||
<div className="relative">
|
||||
<button
|
||||
ref={triggerRef}
|
||||
type="button"
|
||||
onClick={() => setOpen((v) => !v)}
|
||||
aria-haspopup="menu"
|
||||
aria-expanded={open}
|
||||
aria-label="Account menu"
|
||||
className="size-9 rounded-full overflow-hidden ring-1 ring-foreground/10 hover:ring-foreground/20 transition-shadow"
|
||||
>
|
||||
{user.avatar_url ? (
|
||||
<Image
|
||||
@@ -87,35 +122,42 @@ export function UserMenu() {
|
||||
{initials(user.name)}
|
||||
</span>
|
||||
)}
|
||||
</Popover.Trigger>
|
||||
<Popover.Portal>
|
||||
<Popover.Positioner sideOffset={6} align="end">
|
||||
<Popover.Popup className="min-w-44 rounded-xl bg-card border border-border shadow-md p-1 outline-none">
|
||||
<div className="px-3 py-2">
|
||||
<p className="text-sm font-medium text-foreground truncate">
|
||||
{user.name}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground truncate">
|
||||
{user.email}
|
||||
</p>
|
||||
</div>
|
||||
<div className="h-px bg-border my-1" />
|
||||
<Link
|
||||
href="/profile"
|
||||
className="block rounded-lg px-3 py-2 text-sm text-foreground hover:bg-muted transition-colors"
|
||||
>
|
||||
Profile
|
||||
</Link>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onLogOut}
|
||||
className="block w-full text-left rounded-lg px-3 py-2 text-sm text-foreground hover:bg-muted transition-colors"
|
||||
>
|
||||
Log out
|
||||
</button>
|
||||
</Popover.Popup>
|
||||
</Popover.Positioner>
|
||||
</Popover.Portal>
|
||||
</Popover.Root>
|
||||
</button>
|
||||
|
||||
{open ? (
|
||||
<div
|
||||
ref={panelRef}
|
||||
role="menu"
|
||||
className={cn(
|
||||
"absolute right-0 top-full mt-2 min-w-48 rounded-xl bg-card",
|
||||
"border border-border shadow-lg p-1 z-50",
|
||||
)}
|
||||
>
|
||||
<div className="px-3 py-2">
|
||||
<p className="text-sm font-medium text-foreground truncate">
|
||||
{user.name}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground truncate">
|
||||
{user.email}
|
||||
</p>
|
||||
</div>
|
||||
<div className="h-px bg-border my-1" />
|
||||
<Link
|
||||
href="/u/me"
|
||||
onClick={() => setOpen(false)}
|
||||
className="block rounded-lg px-3 py-2 text-sm text-foreground hover:bg-muted transition-colors"
|
||||
>
|
||||
Profile
|
||||
</Link>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onLogOut}
|
||||
className="block w-full text-left rounded-lg px-3 py-2 text-sm text-foreground hover:bg-muted transition-colors"
|
||||
>
|
||||
Log out
|
||||
</button>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user