Stream C from docs/information-architecture.md. The navbar's account
cluster (<username> + Settings + Logout) was three controls for the
same concept; the cluster now collapses behind an avatar dropdown.
On mobile, all primary nav and account controls move into a
hamburger drawer so the top bar stays at logo + bell + hamburger
without wrapping at small viewports.
- New apps/journal/app/components/Avatar.tsx — initials-only avatar
(no image-upload story yet; the component is the single place to
add image fallback when one lands).
- New apps/journal/app/components/AccountDropdown.tsx — click the
avatar trigger to reveal Profile / Settings / Log Out menuitems.
Click-outside + Escape-to-close. role="menu" + role="menuitem" for
a11y.
- New apps/journal/app/components/MobileNavMenu.tsx — hamburger
trigger + slide-out drawer at md and below. Contains all primary
nav (Feed, Explore, Routes, Activities, Notifications) plus the
account cluster (Profile, Settings, Log Out). Auto-closes on
navigation; locks body scroll while open.
- apps/journal/app/root.tsx — refactor NavBar:
* Brand + primary nav links hide at md and below (drawer covers
them).
* Right cluster: bell on every viewport; avatar dropdown on
desktop, hamburger on mobile.
* Loader exposes user.displayName so Avatar can compute initials.
* Drops the now-unused Form import; Form moved into the
dropdown/drawer components.
E2E tests updated:
- e2e/auth.test.ts — logout helper opens the avatar dropdown via
aria-label = displayName||username, then clicks the Log Out
menuitem. Username assertions in nav switch from getByText to
getByRole("button", {name: username}) (the avatar button).
- e2e/settings.test.ts — "settings link visible in nav" opens the
avatar dropdown first, then asserts the Settings menuitem.
i18n: nav.openMenu, nav.closeMenu keys for the hamburger trigger
labels (EN + DE).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
87 lines
3 KiB
TypeScript
87 lines
3 KiB
TypeScript
import { useEffect, useRef, useState } from "react";
|
|
import { Form, Link } from "react-router";
|
|
import { useTranslation } from "react-i18next";
|
|
import { Avatar } from "./Avatar";
|
|
|
|
interface Props {
|
|
user: { username: string; displayName: string | null };
|
|
}
|
|
|
|
export function AccountDropdown({ user }: Props) {
|
|
const { t } = useTranslation("journal");
|
|
const [open, setOpen] = useState(false);
|
|
const containerRef = useRef<HTMLDivElement>(null);
|
|
|
|
// Click-outside + Escape close. Mounted only while open to keep the
|
|
// listener cost zero in the steady state.
|
|
useEffect(() => {
|
|
if (!open) return;
|
|
const onClick = (e: MouseEvent) => {
|
|
if (!containerRef.current) return;
|
|
if (!containerRef.current.contains(e.target as Node)) setOpen(false);
|
|
};
|
|
const onKey = (e: KeyboardEvent) => {
|
|
if (e.key === "Escape") setOpen(false);
|
|
};
|
|
document.addEventListener("mousedown", onClick);
|
|
document.addEventListener("keydown", onKey);
|
|
return () => {
|
|
document.removeEventListener("mousedown", onClick);
|
|
document.removeEventListener("keydown", onKey);
|
|
};
|
|
}, [open]);
|
|
|
|
return (
|
|
<div ref={containerRef} className="relative">
|
|
<button
|
|
type="button"
|
|
aria-haspopup="menu"
|
|
aria-expanded={open}
|
|
aria-label={user.displayName ?? user.username}
|
|
onClick={() => setOpen((o) => !o)}
|
|
className="inline-flex items-center rounded-full focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2"
|
|
>
|
|
<Avatar displayName={user.displayName} username={user.username} size="md" />
|
|
</button>
|
|
|
|
{open && (
|
|
<div
|
|
role="menu"
|
|
className="absolute right-0 top-full z-20 mt-2 w-56 origin-top-right rounded-md border border-gray-200 bg-white py-1 shadow-lg ring-1 ring-black ring-opacity-5"
|
|
>
|
|
<div className="border-b border-gray-100 px-3 py-2">
|
|
<p className="truncate text-sm font-medium text-gray-900">
|
|
{user.displayName ?? user.username}
|
|
</p>
|
|
<p className="truncate text-xs text-gray-500">@{user.username}</p>
|
|
</div>
|
|
<Link
|
|
to={`/users/${user.username}`}
|
|
role="menuitem"
|
|
onClick={() => setOpen(false)}
|
|
className="block px-3 py-2 text-sm text-gray-700 hover:bg-gray-50"
|
|
>
|
|
{t("nav.profile")}
|
|
</Link>
|
|
<Link
|
|
to="/settings"
|
|
role="menuitem"
|
|
onClick={() => setOpen(false)}
|
|
className="block px-3 py-2 text-sm text-gray-700 hover:bg-gray-50"
|
|
>
|
|
{t("nav.settings")}
|
|
</Link>
|
|
<Form method="post" action="/auth/logout">
|
|
<button
|
|
type="submit"
|
|
role="menuitem"
|
|
className="block w-full px-3 py-2 text-left text-sm text-gray-700 hover:bg-gray-50"
|
|
>
|
|
{t("nav.logout")}
|
|
</button>
|
|
</Form>
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|