Announcements: history, collapsed list, instant refresh, footer icon

- Expired messages stay readable under a collapsed "Earlier" section, as
  long as the feed keeps them (12 months, at most 50). They never count as
  unread or raise a banner; withdrawn ones are gone everywhere.
- Each message is one row (level, date, title) that opens in place.
  Unread ones carry a dot and a New chip, and opening one is what marks
  it read; the banner's Read more opens the panel on its message.
- A fetch that brings a newer feed broadcasts an empty
  announcements_changed event, so open pages show the new dot and banner
  without a reload.
- The sidebar entry is a megaphone icon with an unread badge, in the
  footer row left of System. Footer icons are 32px with no gap so seven
  fit an expanded sidebar; with authentication on, logout used to wrap
  onto a line of its own.
This commit is contained in:
maziggy
2026-10-01 11:37:06 +02:00
parent 5cd1e211a5
commit 3fae2b402b
35 changed files with 517 additions and 247 deletions
@@ -6,7 +6,8 @@ import { announcementText } from '../hooks/useAnnouncements';
interface AnnouncementBannerProps {
// Unread important/critical announcements, most severe first.
items: Announcement[];
onOpen: () => void;
// Opens the panel with this message expanded.
onOpen: (id: string) => void;
markRead: (id: string) => void;
}
@@ -40,7 +41,7 @@ export function AnnouncementBanner({ items, onOpen, markRead }: AnnouncementBann
{text.title}
</span>
<button
onClick={onOpen}
onClick={() => onOpen(first.id)}
className={`font-medium underline ${
critical
? 'text-red-700 dark:text-red-400 hover:text-red-900 dark:hover:text-red-300'
+129 -48
View File
@@ -1,6 +1,6 @@
import { useEffect, useRef, useState } from 'react';
import { useCallback, useEffect, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { ExternalLink, Megaphone, X } from 'lucide-react';
import { ChevronDown, ChevronRight, ExternalLink, Megaphone, X } from 'lucide-react';
import type { Announcement, AnnouncementLevel } from '../api/client';
import { announcementText, isAllowedAnnouncementLink } from '../hooks/useAnnouncements';
import { formatDateOnly } from '../utils/date';
@@ -16,30 +16,56 @@ interface AnnouncementsPanelProps {
onClose: () => void;
announcements: Announcement[];
markRead: (id: string) => void;
// Expanded when the panel opens: the banner's "Read more" lands on its message.
focusId?: string | null;
}
/**
* Slide-over list of announcements from the Bambuddy maintainers.
*
* Opening it marks everything in it read -- the dot and the banner go -- but the
* ones that were unread keep a "New" chip until it closes, so the reader can
* still tell what they came for.
* Each message is one row -- level, date, title -- and opens in place. Unread
* ones carry a dot and a "New" chip until opened; opening is what marks a
* message read, so the sidebar count is what is still unopened. Expired messages
* the feed keeps as history sit in a collapsed "Earlier" section below; they are
* never unread.
*/
export function AnnouncementsPanel({ open, onClose, announcements, markRead }: AnnouncementsPanelProps) {
const { t, i18n } = useTranslation();
const [newIds, setNewIds] = useState<Set<string>>(() => new Set());
export function AnnouncementsPanel({ open, onClose, announcements, markRead, focusId }: AnnouncementsPanelProps) {
const { t } = useTranslation();
const [expanded, setExpanded] = useState<Set<string>>(() => new Set());
const [showEarlier, setShowEarlier] = useState(false);
const closeRef = useRef<HTMLButtonElement>(null);
// The list at the moment of opening; later refetches don't re-mark anything.
const latest = useRef({ announcements, markRead });
latest.current = { announcements, markRead };
const expand = useCallback((id: string) => {
setExpanded((prev) => new Set(prev).add(id));
const a = latest.current.announcements.find((x) => x.id === id);
if (a && !a.read && !a.archived) latest.current.markRead(id);
}, []);
const toggle = useCallback(
(id: string) => {
if (expanded.has(id)) {
setExpanded((prev) => {
const next = new Set(prev);
next.delete(id);
return next;
});
} else {
expand(id);
}
},
[expanded, expand]
);
useEffect(() => {
if (!open) return;
const unreadIds = latest.current.announcements.filter((a) => !a.read).map((a) => a.id);
setNewIds(new Set(unreadIds));
unreadIds.forEach((id) => latest.current.markRead(id));
// Every visit starts collapsed, apart from the message the banner pointed at.
setExpanded(new Set());
setShowEarlier(false);
if (focusId) expand(focusId);
closeRef.current?.focus();
}, [open]);
}, [open, focusId, expand]);
useEffect(() => {
if (!open) return;
@@ -52,6 +78,9 @@ export function AnnouncementsPanel({ open, onClose, announcements, markRead }: A
if (!open) return null;
const current = announcements.filter((a) => !a.archived);
const earlier = announcements.filter((a) => a.archived);
return (
<>
<div className="fixed inset-0 bg-black/60 z-[60]" onClick={onClose} aria-hidden="true" />
@@ -78,46 +107,34 @@ export function AnnouncementsPanel({ open, onClose, announcements, markRead }: A
</header>
<div className="flex-1 overflow-y-auto">
{announcements.length === 0 ? (
{current.length === 0 ? (
<p className="p-6 text-center text-bambu-gray">{t('announcements.empty')}</p>
) : (
<ul className="divide-y divide-bambu-dark-tertiary">
{announcements.map((a) => {
const text = announcementText(a, i18n.language);
const link = isAllowedAnnouncementLink(a.link_url) ? a.link_url : null;
return (
<li key={a.id} className="px-5 py-4 space-y-2">
<div className="flex items-center gap-2 flex-wrap text-xs">
<span className={`px-2 py-0.5 rounded-full font-semibold ${LEVEL_CHIP[a.level]}`}>
{t(`announcements.level.${a.level}`)}
</span>
{newIds.has(a.id) && (
<span className="px-2 py-0.5 rounded-full font-semibold bg-bambu-green/20 text-bambu-green">
{t('announcements.new')}
</span>
)}
{a.published_at && (
<span className="ml-auto text-bambu-gray">{formatDateOnly(a.published_at)}</span>
)}
</div>
<h3 className="text-white font-semibold text-balance">{text.title}</h3>
<p className="text-sm text-bambu-gray-light whitespace-pre-line break-words">{text.body}</p>
{link && (
<a
href={link}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-1 text-sm font-medium text-bambu-green hover:underline"
>
{text.link_label || t('announcements.readMore')}
<ExternalLink className="w-3.5 h-3.5" />
</a>
)}
</li>
);
})}
{current.map((a) => (
<AnnouncementItem key={a.id} announcement={a} expanded={expanded.has(a.id)} onToggle={toggle} />
))}
</ul>
)}
{earlier.length > 0 && (
<section className="border-t border-bambu-dark-tertiary">
<button
onClick={() => setShowEarlier((v) => !v)}
aria-expanded={showEarlier}
className="w-full flex items-center gap-2 px-5 py-3 text-sm font-medium text-bambu-gray-light hover:text-white hover:bg-bambu-dark-tertiary transition-colors"
>
{showEarlier ? <ChevronDown className="w-4 h-4" /> : <ChevronRight className="w-4 h-4" />}
{t('announcements.earlier', { count: earlier.length })}
</button>
{showEarlier && (
<ul className="divide-y divide-bambu-dark-tertiary opacity-80">
{earlier.map((a) => (
<AnnouncementItem key={a.id} announcement={a} expanded={expanded.has(a.id)} onToggle={toggle} />
))}
</ul>
)}
</section>
)}
</div>
<footer className="px-5 py-3 border-t border-bambu-dark-tertiary text-xs text-bambu-gray">
@@ -127,3 +144,67 @@ export function AnnouncementsPanel({ open, onClose, announcements, markRead }: A
</>
);
}
interface AnnouncementItemProps {
announcement: Announcement;
expanded: boolean;
onToggle: (id: string) => void;
}
function AnnouncementItem({ announcement: a, expanded, onToggle }: AnnouncementItemProps) {
const { t, i18n } = useTranslation();
const text = announcementText(a, i18n.language);
const link = isAllowedAnnouncementLink(a.link_url) ? a.link_url : null;
const unread = !a.read && !a.archived;
const bodyId = `announcement-body-${a.id}`;
return (
<li>
<button
onClick={() => onToggle(a.id)}
aria-expanded={expanded}
aria-controls={bodyId}
className="w-full text-left px-5 py-3 flex items-start gap-3 hover:bg-bambu-dark-tertiary transition-colors"
>
<span className="mt-1.5 w-2 h-2 flex-shrink-0 rounded-full" aria-hidden="true">
{unread && <span className="block w-2 h-2 rounded-full bg-bambu-green" />}
</span>
<span className="flex-1 min-w-0 space-y-1">
<span className="flex items-center gap-2 flex-wrap text-xs">
<span className={`px-2 py-0.5 rounded-full font-semibold ${LEVEL_CHIP[a.level]}`}>
{t(`announcements.level.${a.level}`)}
</span>
{unread && (
<span className="px-2 py-0.5 rounded-full font-semibold bg-bambu-green/20 text-bambu-green">
{t('announcements.new')}
</span>
)}
{a.published_at && <span className="ml-auto text-bambu-gray">{formatDateOnly(a.published_at)}</span>}
</span>
<span className={`block text-balance ${unread ? 'text-white font-semibold' : 'text-bambu-gray-light font-medium'}`}>
{text.title}
</span>
</span>
<ChevronDown
className={`w-4 h-4 mt-1 flex-shrink-0 text-bambu-gray transition-transform ${expanded ? 'rotate-180' : ''}`}
aria-hidden="true"
/>
</button>
{expanded && (
<div id={bodyId} className="pl-10 pr-5 pb-4 space-y-2">
<p className="text-sm text-bambu-gray-light whitespace-pre-line break-words">{text.body}</p>
{link && (
<a
href={link}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-1 text-sm font-medium text-bambu-green hover:underline"
>
{text.link_label || t('announcements.readMore')}
<ExternalLink className="w-3.5 h-3.5" />
</a>
)}
</div>
)}
</li>
);
}
+29 -25
View File
@@ -157,8 +157,10 @@ export function Layout() {
const { announcements, unread: unreadAnnouncements, bannerItems, markRead: markAnnouncementRead } =
useAnnouncements();
const [announcementsOpen, setAnnouncementsOpen] = useState(false);
const openAnnouncements = useCallback(() => {
const [announcementFocus, setAnnouncementFocus] = useState<string | null>(null);
const openAnnouncements = useCallback((focusId?: string) => {
setMobileDrawerOpen(false);
setAnnouncementFocus(focusId ?? null);
setAnnouncementsOpen(true);
}, []);
const closeAnnouncements = useCallback(() => setAnnouncementsOpen(false), []);
@@ -746,29 +748,11 @@ export function Layout() {
{/* Footer */}
<div className="flex-shrink-0 p-2 border-t border-bambu-dark-tertiary">
{isSidebarCompact || sidebarExpanded ? (
<div className="flex flex-col gap-2 px-2">
{/* Announcements: a row of its own above the icons, so it isn't one
more glyph in a row people have stopped reading. Only while
there is something to show. */}
{announcements.length > 0 && (
<button
onClick={openAnnouncements}
className="flex items-center gap-2 px-2 py-1.5 rounded-lg hover:bg-bambu-dark-tertiary transition-colors text-bambu-gray-light hover:text-white text-sm"
>
<Megaphone className="w-5 h-5 flex-shrink-0" />
<span>{t('announcements.title')}</span>
{unreadAnnouncements.length > 0 && (
<span
className="ml-auto min-w-[20px] h-5 px-1.5 flex items-center justify-center text-[11px] font-bold rounded-full bg-bambu-green text-white"
aria-label={t('announcements.unread', { count: unreadAnnouncements.length })}
>
{unreadAnnouncements.length}
</span>
)}
</button>
)}
{/* Top row: icons */}
<div className="flex items-center justify-center gap-1 flex-wrap">
<div className="flex flex-col gap-2">
{/* Top row: icons. 32px each with no gap so seven fit the 239px
of an expanded sidebar -- announcements, System, GitHub,
shortcuts, theme, password, logout -- without wrapping. */}
<div className="flex items-center justify-center flex-wrap [&>a]:p-1.5 [&>button]:p-1.5 [&>span]:p-1.5 [&>div>button]:p-1.5">
{hasSwitchbarPlugs && (
<div className="relative">
<button
@@ -785,6 +769,25 @@ export function Layout() {
)}
</div>
)}
{announcements.length > 0 && (
<button
onClick={() => openAnnouncements()}
className="relative p-2 rounded-lg hover:bg-bambu-dark-tertiary transition-colors text-bambu-gray-light hover:text-white"
title={t('announcements.title')}
aria-label={
unreadAnnouncements.length > 0
? t('announcements.unread', { count: unreadAnnouncements.length })
: t('announcements.title')
}
>
<Megaphone className="w-5 h-5" />
{unreadAnnouncements.length > 0 && (
<span className="absolute -top-0.5 -right-0.5 min-w-[16px] h-4 px-1 flex items-center justify-center text-[10px] font-bold rounded-full bg-bambu-green text-white">
{unreadAnnouncements.length}
</span>
)}
</button>
)}
{hasPermission('system:read') ? (
<NavLink
to="/system"
@@ -892,7 +895,7 @@ export function Layout() {
)}
{announcements.length > 0 && (
<button
onClick={openAnnouncements}
onClick={() => openAnnouncements()}
className="relative p-2 rounded-lg hover:bg-bambu-dark-tertiary transition-colors text-bambu-gray-light hover:text-white"
title={t('announcements.title')}
aria-label={
@@ -1065,6 +1068,7 @@ export function Layout() {
onClose={closeAnnouncements}
announcements={announcements}
markRead={markAnnouncementRead}
focusId={announcementFocus}
/>
<UnknownSpoolModal