feat: add user centric features
This commit is contained in:
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user