diff --git a/apps/journal/app/components/AccountDropdown.tsx b/apps/journal/app/components/AccountDropdown.tsx new file mode 100644 index 0000000..fd12a9e --- /dev/null +++ b/apps/journal/app/components/AccountDropdown.tsx @@ -0,0 +1,87 @@ +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(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 ( +
+ + + {open && ( +
+
+

+ {user.displayName ?? user.username} +

+

@{user.username}

+
+ setOpen(false)} + className="block px-3 py-2 text-sm text-gray-700 hover:bg-gray-50" + > + {t("nav.profile")} + + setOpen(false)} + className="block px-3 py-2 text-sm text-gray-700 hover:bg-gray-50" + > + {t("nav.settings")} + +
+ +
+
+ )} +
+ ); +} diff --git a/apps/journal/app/components/Avatar.tsx b/apps/journal/app/components/Avatar.tsx new file mode 100644 index 0000000..cc74702 --- /dev/null +++ b/apps/journal/app/components/Avatar.tsx @@ -0,0 +1,42 @@ +// 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, 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 ( + + ); +} diff --git a/apps/journal/app/components/MobileNavMenu.tsx b/apps/journal/app/components/MobileNavMenu.tsx new file mode 100644 index 0000000..cf81241 --- /dev/null +++ b/apps/journal/app/components/MobileNavMenu.tsx @@ -0,0 +1,152 @@ +import { useEffect, useState } from "react"; +import { Form, Link, useLocation } from "react-router"; +import { useTranslation } from "react-i18next"; +import { Avatar } from "./Avatar"; + +interface Props { + user: { username: string; displayName: string | null }; +} + +// Mobile drawer for the navbar. Replaces the "everything in a row" +// desktop layout with a hamburger trigger and a slide-out panel that +// holds all the same destinations. Bell + the dropdown's account +// section move inside; the bell badge still surfaces on the trigger +// itself via the parent navbar. +export function MobileNavMenu({ user }: Props) { + const { t } = useTranslation("journal"); + const [open, setOpen] = useState(false); + const location = useLocation(); + + // Close the drawer on navigation. React Router pushes a new location + // when a Link is followed; this effect picks that up. + useEffect(() => { + setOpen(false); + }, [location.pathname]); + + // Close on Escape; lock body scroll while open. + useEffect(() => { + if (!open) return; + const onKey = (e: KeyboardEvent) => { + if (e.key === "Escape") setOpen(false); + }; + document.addEventListener("keydown", onKey); + const prevOverflow = document.body.style.overflow; + document.body.style.overflow = "hidden"; + return () => { + document.removeEventListener("keydown", onKey); + document.body.style.overflow = prevOverflow; + }; + }, [open]); + + const linkClass = (path: string) => { + const active = location.pathname === path || location.pathname.startsWith(path + "/"); + return `block rounded-md px-3 py-2 text-base font-medium ${ + active ? "bg-blue-50 text-blue-700" : "text-gray-700 hover:bg-gray-50" + }`; + }; + + return ( + <> + + + {open && ( +
+ {/* Backdrop */} + +
+ + + + + )} + + ); +} diff --git a/apps/journal/app/root.tsx b/apps/journal/app/root.tsx index ebdf5c0..2530ef3 100644 --- a/apps/journal/app/root.tsx +++ b/apps/journal/app/root.tsx @@ -1,5 +1,5 @@ import { useEffect } from "react"; -import { Links, Meta, Outlet, Scripts, ScrollRestoration, isRouteErrorResponse, useLocation, Form, Link, redirect } from "react-router"; +import { Links, Meta, Outlet, Scripts, ScrollRestoration, isRouteErrorResponse, useLocation, Link, redirect } from "react-router"; import type { LinksFunction } from "react-router"; import type { Route } from "./+types/root"; import * as Sentry from "@sentry/react"; @@ -10,6 +10,8 @@ import { LocaleProvider } from "~/components/LocaleContext"; import { AlphaBanner } from "~/components/AlphaBanner"; import { useUnreadNotifications } from "~/hooks/useUnreadNotifications"; import { Footer } from "~/components/Footer"; +import { AccountDropdown } from "~/components/AccountDropdown"; +import { MobileNavMenu } from "~/components/MobileNavMenu"; import { initSentryClient, stopSentryClient } from "~/lib/sentry.client"; import { TERMS_VERSION } from "~/lib/legal"; import stylesheet from "@trails-cool/ui/styles.css?url"; @@ -76,17 +78,51 @@ export async function loader({ request }: Route.LoaderArgs) { } return { - user: user ? { id: user.id, username: user.username } : null, + user: user + ? { id: user.id, username: user.username, displayName: user.displayName } + : null, locale, unreadNotifications, }; } +function BellLink({ unread, label }: { unread: number; label: string }) { + return ( + + + {unread > 0 && ( + + {unread} + + )} + + ); +} + function NavBar({ user, unreadNotifications, }: { - user: { id: string; username: string } | null; + user: { id: string; username: string; displayName: string | null } | null; unreadNotifications: number; }) { const { t } = useTranslation("journal"); @@ -100,20 +136,19 @@ function NavBar({ const linkClass = (path: string) => `text-sm font-medium ${ - isActive(path) - ? "text-blue-600" - : "text-gray-600 hover:text-gray-900" + isActive(path) ? "text-blue-600" : "text-gray-600 hover:text-gray-900" }`; return (