From dd2e6befc91cc5677cd7ba32f83e0d73724a5710 Mon Sep 17 00:00:00 2001 From: Duncan Appleby Date: Fri, 14 Aug 2026 12:40:04 +0100 Subject: [PATCH 1/4] refactor: extract announcements UI out of Navbar into own component Splits the desktop popover and mobile dropdown-panel announcement markup out of Navbar.tsx into a new Announcements.tsx (AnnouncementsBell, MobileAnnouncements), sharing one internal list-rendering component instead of duplicating the accordion markup. Navbar keeps ownership of the data layer (queries/mutations) and passes it down as props. Also fixes the announcements bell rendering taller than the other nav buttons: it hard-coded a 2.4rem box instead of deriving its height from padding + line-height like its siblings. Co-Authored-By: Claude Sonnet 5 --- .../layout/Navbar/Announcements.module.scss | 156 ++++++++++++++ frontend/src/layout/Navbar/Announcements.tsx | 163 ++++++++++++++ frontend/src/layout/Navbar/Navbar.module.scss | 157 -------------- frontend/src/layout/Navbar/Navbar.tsx | 198 ++---------------- 4 files changed, 340 insertions(+), 334 deletions(-) create mode 100644 frontend/src/layout/Navbar/Announcements.module.scss create mode 100644 frontend/src/layout/Navbar/Announcements.tsx diff --git a/frontend/src/layout/Navbar/Announcements.module.scss b/frontend/src/layout/Navbar/Announcements.module.scss new file mode 100644 index 00000000..266d9256 --- /dev/null +++ b/frontend/src/layout/Navbar/Announcements.module.scss @@ -0,0 +1,156 @@ +@use '../../styles/base/variables' as v; +@use '../../styles/semantic/colors' as c; +@use '../../styles/semantic/spacing' as sp; + +.badge { + display: inline-flex; + align-items: center; + justify-content: center; + min-width: 1.2rem; + height: 1.2rem; + border-radius: 999px; + padding: 0 0.35rem; + font-size: 0.7rem; + font-weight: 700; + background: rgba(c.$color-border-accent, 0.8); +} + +.badgeOnTrigger { + position: absolute; + top: -0.25rem; + right: -0.25rem; + margin-left: 0; +} + +// Doubled selector to win the cascade over navLink's own padding rule - +// both are single-class selectors from separate CSS Modules, so which one +// wins otherwise depends on unpredictable module bundle order. +.trigger.trigger { + // No explicit height: navLink's siblings (Home, Timer, etc.) don't set + // one either - their height comes from line-height + this same padding. + // Fixing a height here would only approximate that and drift out of + // sync whenever the button's typography changes. + position: relative; + padding: sp.$spacing-xs; + display: inline-flex; + align-items: center; + justify-content: center; +} + +.popoverContent { + width: min(22rem, 92vw); + max-height: 65vh; + overflow: auto; + background: c.$color-bg; + border: 1px solid rgba(c.$color-border-primary, 0.25); + border-radius: sp.$border-radius; + box-shadow: 0 10px 30px rgba(0, 0, 0, 0.12); + padding: sp.$spacing-sm; + z-index: v.$z-index-dropdown; +} + +.header { + display: flex; + align-items: center; + justify-content: space-between; + gap: sp.$spacing-sm; + margin-bottom: sp.$spacing-sm; +} + +.list { + list-style: none; + margin: 0; + padding: 0; + display: flex; + flex-direction: column; + gap: sp.$spacing-sm; +} + +.item { + border: 1px solid rgba(c.$color-border-primary, 0.2); + border-radius: sp.$border-radius; + padding: sp.$spacing-sm; +} + +.titleRow { + display: flex; + align-items: center; + justify-content: space-between; + gap: sp.$spacing-sm; + font-weight: 600; +} + +.summary { + margin: sp.$spacing-xs 0; + font-size: 0.9rem; + opacity: 0.9; +} + +.accordionTrigger { + width: 100%; + border: none; + background: transparent; + padding: 0; + margin: 0; + text-align: left; + cursor: pointer; + color: inherit; + + &:focus-visible { + outline: 2px solid rgba(c.$color-border-primary, 0.5); + outline-offset: 2px; + border-radius: sp.$border-radius; + } +} + +.accordionContent { + overflow: hidden; +} + +.metaRight { + display: inline-flex; + align-items: center; + gap: sp.$spacing-xs; +} + +.body { + margin: sp.$spacing-xs 0 0; + font-size: 0.9rem; + white-space: pre-wrap; + line-height: 1.35; +} + +.empty { + margin: 0; + opacity: 0.8; +} + +.actionButton { + border: none; + background: transparent; + color: inherit; + cursor: pointer; + text-decoration: underline; + font-size: 0.85rem; + padding: 0; + + &:disabled { + opacity: 0.5; + cursor: not-allowed; + } +} + +.unreadDot { + width: 0.45rem; + height: 0.45rem; + border-radius: 50%; + background: c.$color-border-primary; +} + +.mobilePanel { + margin: 0 sp.$spacing-sm sp.$spacing-sm; + padding: sp.$spacing-sm; + border: 1px solid rgba(c.$color-border-primary, 0.2); + border-radius: sp.$border-radius; + background: rgba(c.$color-bg, 0.95); +} diff --git a/frontend/src/layout/Navbar/Announcements.tsx b/frontend/src/layout/Navbar/Announcements.tsx new file mode 100644 index 00000000..abed4985 --- /dev/null +++ b/frontend/src/layout/Navbar/Announcements.tsx @@ -0,0 +1,163 @@ +import { useState } from "react"; +import * as Popover from "@radix-ui/react-popover"; +import * as DropdownMenu from "@radix-ui/react-dropdown-menu"; +import * as Accordion from "@radix-ui/react-accordion"; +import Button from "../../components/Button/Button"; +import styles from "./Announcements.module.scss"; +import type { Announcement } from "../../types"; + +interface AnnouncementsData { + announcements: Announcement[]; + unreadCount: number; + isLoading: boolean; + onMarkOneRead: (announcementId: number) => void; + onMarkAllRead: () => void; + isMarkingOneRead: boolean; + isMarkingAllRead: boolean; +} + +/** Shared header + accordion markup rendered inside both the desktop popover and the mobile panel. */ +function AnnouncementsListContent({ + announcements, + unreadCount, + isLoading, + onMarkOneRead, + onMarkAllRead, + isMarkingOneRead, + isMarkingAllRead, + idPrefix, +}: AnnouncementsData & { idPrefix: string }) { + return ( + <> +
+ Announcements + +
+ + {isLoading &&

Loading announcements...

} + + {!isLoading && announcements.length === 0 && ( +

No announcements yet.

+ )} + + {!isLoading && announcements.length > 0 && ( + + {announcements.map((announcement) => ( + + + +
+ {announcement.title} + + {!announcement.is_read && ( + +
+ {announcement.summary && ( +

{announcement.summary}

+ )} +
+
+ +

{announcement.body}

+
+ {!announcement.is_read && ( + + )} +
+ ))} +
+ )} + + ); +} + +interface AnnouncementsBellProps extends AnnouncementsData { + /** Navbar's shared nav-link styling, composed with this component's own trigger styling. */ + triggerClassName: string; +} + +/** Desktop bell icon + unread badge that opens the announcements list in a popover. */ +export function AnnouncementsBell({ triggerClassName, ...data }: AnnouncementsBellProps) { + return ( + + + + + + + + + + + ); +} + +interface MobileAnnouncementsProps extends AnnouncementsData { + /** Navbar's shared dropdown-item styling. */ + itemClassName: string; +} + +/** Mobile dropdown item that toggles an inline announcements panel open/closed. */ +export function MobileAnnouncements({ itemClassName, ...data }: MobileAnnouncementsProps) { + const [open, setOpen] = useState(false); + + return ( + <> + { + event.preventDefault(); + setOpen((prev) => !prev); + }} + > + Announcements + {data.unreadCount > 0 && ( + + {data.unreadCount > 99 ? "99+" : data.unreadCount} + + )} + + {open && ( +
+ +
+ )} + + ); +} diff --git a/frontend/src/layout/Navbar/Navbar.module.scss b/frontend/src/layout/Navbar/Navbar.module.scss index 0ef161cc..04a2a7d0 100644 --- a/frontend/src/layout/Navbar/Navbar.module.scss +++ b/frontend/src/layout/Navbar/Navbar.module.scss @@ -66,147 +66,6 @@ padding: sp.$spacing-xs sp.$spacing-sm; } -.unreadBadge { - display: inline-flex; - align-items: center; - justify-content: center; - min-width: 1.2rem; - height: 1.2rem; - border-radius: 999px; - padding: 0 0.35rem; - font-size: 0.7rem; - font-weight: 700; - background: rgba(c.$color-border-accent, 0.8); -} - -.announcementsTrigger { - position: relative; - width: 2.4rem; - min-width: 2.4rem; - height: 2.4rem; - padding: 0; - display: inline-flex; - align-items: center; - justify-content: center; -} - -.unreadBadgeOnTrigger { - position: absolute; - top: -0.25rem; - right: -0.25rem; - margin-left: 0; -} - -.announcementsPopoverContent { - width: min(22rem, 92vw); - max-height: 65vh; - overflow: auto; - background: c.$color-bg; - border: 1px solid rgba(c.$color-border-primary, 0.25); - border-radius: sp.$border-radius; - box-shadow: 0 10px 30px rgba(0, 0, 0, 0.12); - padding: sp.$spacing-sm; - z-index: v.$z-index-dropdown; -} - -.announcementsPopoverHeader { - display: flex; - align-items: center; - justify-content: space-between; - gap: sp.$spacing-sm; - margin-bottom: sp.$spacing-sm; -} - -.announcementsList { - list-style: none; - margin: 0; - padding: 0; - display: flex; - flex-direction: column; - gap: sp.$spacing-sm; -} - -.announcementItem { - border: 1px solid rgba(c.$color-border-primary, 0.2); - border-radius: sp.$border-radius; - padding: sp.$spacing-sm; -} - -.announcementTitleRow { - display: flex; - align-items: center; - justify-content: space-between; - gap: sp.$spacing-sm; - font-weight: 600; -} - -.announcementSummary { - margin: sp.$spacing-xs 0; - font-size: 0.9rem; - opacity: 0.9; -} - -.announcementAccordionTrigger { - width: 100%; - border: none; - background: transparent; - padding: 0; - margin: 0; - text-align: left; - cursor: pointer; - color: inherit; - - &:focus-visible { - outline: 2px solid rgba(c.$color-border-primary, 0.5); - outline-offset: 2px; - border-radius: sp.$border-radius; - } -} - -.announcementAccordionContent { - overflow: hidden; -} - -.announcementMetaRight { - display: inline-flex; - align-items: center; - gap: sp.$spacing-xs; -} - -.announcementBody { - margin: sp.$spacing-xs 0 0; - font-size: 0.9rem; - white-space: pre-wrap; - line-height: 1.35; -} - -.announcementsEmpty { - margin: 0; - opacity: 0.8; -} - -.popoverActionButton { - border: none; - background: transparent; - color: inherit; - cursor: pointer; - text-decoration: underline; - font-size: 0.85rem; - padding: 0; - - &:disabled { - opacity: 0.5; - cursor: not-allowed; - } -} - -.unreadDot { - width: 0.45rem; - height: 0.45rem; - border-radius: 50%; - background: c.$color-border-primary; -} - .accountMenu { position: relative; display: flex; @@ -306,22 +165,6 @@ } } -.mobileAnnouncementsPanel { - margin: 0 sp.$spacing-sm sp.$spacing-sm; - padding: sp.$spacing-sm; - border: 1px solid rgba(c.$color-border-primary, 0.2); - border-radius: sp.$border-radius; - background: rgba(c.$color-bg, 0.95); -} - -.mobileAnnouncementsHeader { - display: flex; - align-items: center; - justify-content: space-between; - gap: sp.$spacing-sm; - margin-bottom: sp.$spacing-sm; -} - //.account { // Reserved for future use (icons, dropdowns, etc.) //} diff --git a/frontend/src/layout/Navbar/Navbar.tsx b/frontend/src/layout/Navbar/Navbar.tsx index e5e4fe50..78c8ba51 100644 --- a/frontend/src/layout/Navbar/Navbar.tsx +++ b/frontend/src/layout/Navbar/Navbar.tsx @@ -1,10 +1,9 @@ -import React, { useEffect, useState } from "react"; +import React, { useEffect } from "react"; import { useLocation, Link } from "react-router"; import * as DropdownMenu from "@radix-ui/react-dropdown-menu"; -import * as Popover from "@radix-ui/react-popover"; -import * as Accordion from "@radix-ui/react-accordion"; import styles from "./Navbar.module.scss"; import Button from "../../components/Button/Button"; +import { AnnouncementsBell, MobileAnnouncements } from "./Announcements"; import { useAuth } from "../../context/AuthContext"; import { useGame } from "../../hooks/useGame"; import { useFeatureFlag } from "../../hooks/useFeatureFlag"; @@ -39,7 +38,6 @@ export default function Navbar({ onMenuClick, onHelpClick }: NavbarProps) { const announcements = announcementsData?.results ?? []; const unreadCount = announcementsData?.unread_count ?? announcementUnreadCount; - const [mobileAnnouncementsOpen, setMobileAnnouncementsOpen] = useState(false); useEffect(() => { if (announcementsData) { @@ -57,6 +55,20 @@ export default function Navbar({ onMenuClick, onHelpClick }: NavbarProps) { setAnnouncementUnreadCount(result.unread_count); }; + const announcementsProps = { + announcements, + unreadCount, + isLoading: announcementsLoading, + onMarkOneRead: (announcementId: number) => { + void handleMarkOneRead(announcementId); + }, + onMarkAllRead: () => { + void handleMarkAllRead(); + }, + isMarkingOneRead: markAnnouncementReadMutation.isPending, + isMarkingAllRead: markAllAnnouncementsReadMutation.isPending, + }; + return (