feat: add user menu dropdown with settings link and sign out

Replace the plain "Sign out" button in the header with a user icon
that opens a dropdown menu containing Settings and Sign out options.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-03 20:19:53 +02:00
parent 3eccbb12fd
commit 0a40d7627f
3 changed files with 104 additions and 20 deletions

View File

@@ -0,0 +1,35 @@
# User Menu Design
## Summary
Replace the plain "Sign out" button in the header with a user icon that opens a dropdown menu containing Settings and Sign out options. This provides a way to navigate to the Settings page from the header.
## Components
### UserMenu (`src/client/components/UserMenu.tsx`)
New component rendered by `TotalsBar` when authenticated.
**Trigger:** Circular `CircleUser` icon button (Lucide). Styled consistently with surrounding header elements.
**Dropdown:** Absolutely-positioned popover anchored to the right edge, appearing below the icon:
1. **Settings**`Settings` (gear) icon + "Settings" label, `<Link to="/settings">`
2. **Divider** — thin horizontal line
3. **Sign out**`LogOut` icon + "Sign out" label, calls `logout.mutate()` from `useLogout()`
**Behavior:**
- Click icon toggles open/close
- Click outside closes (via `useEffect` with document click listener)
- Clicking a menu item closes the dropdown
- Dropdown anchored right so it doesn't overflow viewport
### TotalsBar Changes (`src/client/components/TotalsBar.tsx`)
- When `isAuthenticated`: render `<UserMenu />` in place of the current "Sign out" button
- When not authenticated: keep the existing "Sign in" link unchanged
- Remove the `useLogout` hook usage from TotalsBar (moved into UserMenu)
## No Backend Changes
The existing `/api/auth/me` endpoint and `useAuth` hook are sufficient. No username display needed — using a generic user icon.

View File

@@ -1,10 +1,11 @@
import { Link } from "@tanstack/react-router"; import { Link } from "@tanstack/react-router";
import { useAuth, useLogout } from "../hooks/useAuth"; import { useAuth } from "../hooks/useAuth";
import { useFormatters } from "../hooks/useFormatters"; import { useFormatters } from "../hooks/useFormatters";
import { useUpdateSetting } from "../hooks/useSettings"; import { useUpdateSetting } from "../hooks/useSettings";
import { useTotals } from "../hooks/useTotals"; import { useTotals } from "../hooks/useTotals";
import type { WeightUnit } from "../lib/formatters"; import type { WeightUnit } from "../lib/formatters";
import { LucideIcon } from "../lib/iconData"; import { LucideIcon } from "../lib/iconData";
import { UserMenu } from "./UserMenu";
const UNITS: WeightUnit[] = ["g", "oz", "lb", "kg"]; const UNITS: WeightUnit[] = ["g", "oz", "lb", "kg"];
@@ -21,7 +22,6 @@ export function TotalsBar({
}: TotalsBarProps) { }: TotalsBarProps) {
const { data } = useTotals(); const { data } = useTotals();
const { data: auth } = useAuth(); const { data: auth } = useAuth();
const logout = useLogout();
const isAuthenticated = !!auth?.user; const isAuthenticated = !!auth?.user;
const { weight, price, unit } = useFormatters(); const { weight, price, unit } = useFormatters();
const updateSetting = useUpdateSetting(); const updateSetting = useUpdateSetting();
@@ -104,24 +104,16 @@ export function TotalsBar({
))} ))}
</div> </div>
)} )}
<div className="flex items-center gap-2 ml-auto"> {isAuthenticated ? (
{isAuthenticated ? ( <UserMenu />
<button ) : (
type="button" <Link
onClick={() => logout.mutate()} to="/login"
className="text-xs text-gray-500 hover:text-gray-700 transition-colors" className="text-xs text-gray-500 hover:text-gray-700 transition-colors"
> >
Sign out Sign in
</button> </Link>
) : ( )}
<Link
to="/login"
className="text-xs text-gray-500 hover:text-gray-700 transition-colors"
>
Sign in
</Link>
)}
</div>
</div> </div>
</div> </div>
</div> </div>

View File

@@ -0,0 +1,57 @@
import { Link } from "@tanstack/react-router";
import { useEffect, useRef, useState } from "react";
import { useLogout } from "../hooks/useAuth";
import { LucideIcon } from "../lib/iconData";
export function UserMenu() {
const [open, setOpen] = useState(false);
const menuRef = useRef<HTMLDivElement>(null);
const logout = useLogout();
useEffect(() => {
if (!open) return;
function handleClick(e: MouseEvent) {
if (menuRef.current && !menuRef.current.contains(e.target as Node)) {
setOpen(false);
}
}
document.addEventListener("mousedown", handleClick);
return () => document.removeEventListener("mousedown", handleClick);
}, [open]);
return (
<div ref={menuRef} className="relative">
<button
type="button"
onClick={() => setOpen((prev) => !prev)}
className="flex items-center justify-center w-8 h-8 rounded-full text-gray-500 hover:text-gray-700 hover:bg-gray-100 transition-colors"
>
<LucideIcon name="circle-user" size={22} />
</button>
{open && (
<div className="absolute right-0 mt-1 w-40 bg-white rounded-lg shadow-lg border border-gray-200 py-1 z-50">
<Link
to="/settings"
onClick={() => setOpen(false)}
className="flex items-center gap-2 px-3 py-2 text-sm text-gray-700 hover:bg-gray-50 transition-colors"
>
<LucideIcon name="settings" size={16} className="text-gray-400" />
Settings
</Link>
<div className="border-t border-gray-100 my-1" />
<button
type="button"
onClick={() => {
setOpen(false);
logout.mutate();
}}
className="flex items-center gap-2 w-full px-3 py-2 text-sm text-gray-700 hover:bg-gray-50 transition-colors"
>
<LucideIcon name="log-out" size={16} className="text-gray-400" />
Sign out
</button>
</div>
)}
</div>
);
}