feat: add user centric features
This commit is contained in:
@@ -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