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>
);
}