mirror of
https://github.com/maziggy/bambuddy.git
synced 2026-10-21 10:15:04 +02:00
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:
@@ -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'
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user