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>
42 lines
1.4 KiB
TypeScript
42 lines
1.4 KiB
TypeScript
// Initials-only avatar. We don't have an image-upload story yet (the
|
|
// `users` table has no avatar URL column), so initials are the
|
|
// implementation. When images land, this component is the single
|
|
// place to add the image fallback.
|
|
|
|
interface Props {
|
|
displayName: string | null;
|
|
username: string;
|
|
size?: "sm" | "md" | "lg";
|
|
className?: string;
|
|
}
|
|
|
|
function initialsOf(displayName: string | null, username: string): string {
|
|
const source = (displayName ?? username).trim();
|
|
if (source.length === 0) return "?";
|
|
// Two-letter initials: first letter of the first two whitespace-
|
|
// separated words, falling back to the first two characters when
|
|
// the source is a single token.
|
|
const parts = source.split(/\s+/).filter(Boolean);
|
|
if (parts.length >= 2) {
|
|
return (parts[0]![0]! + parts[1]![0]!).toUpperCase();
|
|
}
|
|
return source.slice(0, 2).toUpperCase();
|
|
}
|
|
|
|
const SIZE_CLASS: Record<NonNullable<Props["size"]>, string> = {
|
|
sm: "h-7 w-7 text-[11px]",
|
|
md: "h-9 w-9 text-sm",
|
|
lg: "h-12 w-12 text-base",
|
|
};
|
|
|
|
export function Avatar({ displayName, username, size = "md", className = "" }: Props) {
|
|
const initials = initialsOf(displayName, username);
|
|
return (
|
|
<span
|
|
className={`inline-flex shrink-0 items-center justify-center rounded-full bg-gray-200 font-semibold text-gray-700 ${SIZE_CLASS[size]} ${className}`}
|
|
aria-hidden="true"
|
|
>
|
|
{initials}
|
|
</span>
|
|
);
|
|
}
|