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 13:05:15 +02:00
34 changed files with 516 additions and 246 deletions
+33 -14
View File
@@ -251,13 +251,21 @@ def parse_entry(raw: object) -> Entry | None:
return None
link = raw.get("link_url")
link = link if isinstance(link, str) and link_allowed(link) else None
published_at = _parse_time(raw.get("published_at"))
expires_at = _parse_time(raw.get("expires_at"))
# History is whatever has expired; the feed's ``archived`` flag says the same
# thing for messages the registrar kept after their expiry. One flagged but
# still in date by this install's clock is history anyway: expire it now.
now = datetime.now(timezone.utc).replace(tzinfo=None)
if raw.get("archived") is True and (expires_at is None or expires_at > now):
expires_at = now
return Entry(
public_id=public_id,
level=level,
texts=texts,
link_url=link,
published_at=_parse_time(raw.get("published_at")),
expires_at=_parse_time(raw.get("expires_at")),
published_at=published_at,
expires_at=expires_at,
)
@@ -344,6 +352,7 @@ async def refresh(db: AsyncSession) -> int | None:
"""
if not await is_enabled(db):
return None
seen_before = int(await _get(db, SERIAL_KEY) or 0)
try:
content = await _download()
payload = verify_feed(content)
@@ -360,27 +369,36 @@ async def refresh(db: AsyncSession) -> int | None:
await _set(db, LAST_FETCH_KEY, datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ"))
await db.commit()
logger.info("Announcements feed #%s: %d for this install", payload["serial"], count)
if payload["serial"] > seen_before:
await _tell_open_pages()
return count
async def _tell_open_pages() -> None:
"""Have every open Bambuddy page re-read the list, so a new message's dot and
banner appear without a reload. The event carries nothing: each page asks
GET /announcements, which answers by who is asking."""
from backend.app.core.websocket import ws_manager
try:
await ws_manager.broadcast({"type": "announcements_changed"})
except Exception: # A page that misses it catches up on its own poll.
logger.debug("announcements_changed broadcast failed", exc_info=True)
# ---- read state ----------------------------------------------------------------------
def _visible_filter(now: datetime):
return (Announcement.expires_at.is_(None)) | (Announcement.expires_at > now)
async def list_for(db: AsyncSession, user_id: int | None) -> list[dict]:
"""Live announcements, newest first, each with whether this user has read it."""
"""Every stored announcement, newest first, with this user's read state.
``archived`` marks history: messages past their expiry, which the panel lists
under "Earlier" and which never count as unread or raise a banner. The feed
decides how much history there is; a message it drops is deleted here.
"""
now = datetime.now(timezone.utc).replace(tzinfo=None)
rows = (
(
await db.execute(
select(Announcement)
.where(_visible_filter(now))
.order_by(Announcement.published_at.desc(), Announcement.id.desc())
)
)
(await db.execute(select(Announcement).order_by(Announcement.published_at.desc(), Announcement.id.desc())))
.scalars()
.all()
)
@@ -400,6 +418,7 @@ async def list_for(db: AsyncSession, user_id: int | None) -> list[dict]:
"link_url": a.link_url,
"published_at": a.published_at.isoformat() + "Z" if a.published_at else None,
"expires_at": a.expires_at.isoformat() + "Z" if a.expires_at else None,
"archived": a.expires_at is not None and a.expires_at <= now,
"read": a.id in read_ids,
}
)
@@ -244,13 +244,30 @@ class TestApply:
class TestReadState:
@pytest.mark.asyncio
async def test_expired_ones_are_not_listed(self, db_session):
async def test_expired_ones_are_history(self, db_session):
past = (datetime.now(timezone.utc) - timedelta(minutes=1)).strftime("%Y-%m-%dT%H:%M:%SZ")
future = (datetime.now(timezone.utc) + timedelta(days=1)).strftime("%Y-%m-%dT%H:%M:%SZ")
p = payload(entry("gone", expires_at=past), entry("live", expires_at=future), entry("forever"))
await svc.apply_payload(db_session, p, STABLE_DOCKER)
await db_session.commit()
assert sorted(i["id"] for i in await svc.list_for(db_session, None)) == ["forever", "live"]
listed = {i["id"]: i["archived"] for i in await svc.list_for(db_session, None)}
assert listed == {"gone": True, "live": False, "forever": False}
@pytest.mark.asyncio
async def test_archived_in_the_feed_is_history_even_without_an_expiry(self, db_session):
await svc.apply_payload(db_session, payload(entry("old", archived=True)), STABLE_DOCKER)
await db_session.commit()
[item] = await svc.list_for(db_session, None)
assert item["archived"] is True
@pytest.mark.asyncio
async def test_history_dropped_by_the_feed_is_deleted(self, db_session):
past = (datetime.now(timezone.utc) - timedelta(days=2)).strftime("%Y-%m-%dT%H:%M:%SZ")
await svc.apply_payload(db_session, payload(entry("old", expires_at=past, archived=True)), STABLE_DOCKER)
await db_session.commit()
await svc.apply_payload(db_session, payload(serial=2), STABLE_DOCKER)
await db_session.commit()
assert await svc.list_for(db_session, None) == []
@pytest.mark.asyncio
async def test_reading_twice_with_auth_off_records_once(self, db_session):
@@ -286,6 +303,21 @@ class TestRefresh:
assert await svc.refresh(db_session) == 1
assert await _stored(db_session) == ["a1"]
@pytest.mark.asyncio
async def test_open_pages_are_told_only_when_the_feed_moved_on(self, db_session):
feed = signed(payload(entry("a1"), serial=3))
with (
patch.object(svc, "_download", AsyncMock(return_value=feed)),
patch.object(svc, "TRUSTED_KEYS", TRUSTED),
patch.object(svc, "install_facts", AsyncMock(return_value=STABLE_DOCKER)),
patch("backend.app.core.websocket.ws_manager.broadcast", AsyncMock()) as broadcast,
):
await svc.refresh(db_session)
broadcast.assert_awaited_once_with({"type": "announcements_changed"})
# The same feed again: nothing new to tell anyone.
await svc.refresh(db_session)
assert broadcast.await_count == 1
@pytest.mark.asyncio
async def test_a_bad_feed_keeps_the_last_good_list(self, db_session):
with (
@@ -21,27 +21,64 @@ function announcement(overrides: Partial<Announcement> = {}): Announcement {
link_url: null,
published_at: '2026-10-01T12:00:00Z',
expires_at: null,
archived: false,
read: false,
...overrides,
};
}
describe('AnnouncementsPanel', () => {
it('marks what was unread as read on opening, and keeps a New chip on it', () => {
it('lists titles collapsed, marks unread ones New, and marks read only on opening one', () => {
const markRead = vi.fn();
render(
<AnnouncementsPanel
open
onClose={() => {}}
markRead={markRead}
announcements={[announcement(), announcement({ id: 'a2', read: true, texts: { en: { title: 'Old news', body: 'x' } } })]}
announcements={[
announcement({ texts: { en: { title: 'Fresh', body: 'Fresh body' } } }),
announcement({ id: 'a2', read: true, texts: { en: { title: 'Old news', body: 'Old body' } } }),
]}
/>
);
expect(markRead).toHaveBeenCalledTimes(1);
expect(markRead).toHaveBeenCalledWith('a1');
const items = screen.getAllByRole('listitem');
expect(within(items[0]).getByText('New')).toBeInTheDocument();
expect(within(items[1]).queryByText('New')).not.toBeInTheDocument();
// Collapsed: titles only, nothing read yet.
expect(screen.queryByText('Fresh body')).not.toBeInTheDocument();
expect(markRead).not.toHaveBeenCalled();
fireEvent.click(screen.getByRole('button', { name: /Fresh/ }));
expect(screen.getByText('Fresh body')).toBeInTheDocument();
expect(markRead).toHaveBeenCalledWith('a1');
// Opening one already read doesn't mark it again.
fireEvent.click(screen.getByRole('button', { name: /Old news/ }));
expect(screen.getByText('Old body')).toBeInTheDocument();
expect(markRead).toHaveBeenCalledTimes(1);
// And it closes again.
fireEvent.click(screen.getByRole('button', { name: /Fresh/ }));
expect(screen.queryByText('Fresh body')).not.toBeInTheDocument();
});
it('opens with the message the banner pointed at expanded', () => {
const markRead = vi.fn();
render(
<AnnouncementsPanel
open
focusId="b"
onClose={() => {}}
markRead={markRead}
announcements={[
announcement({ id: 'a', texts: { en: { title: 'A', body: 'A body' } } }),
announcement({ id: 'b', texts: { en: { title: 'B', body: 'B body' } } }),
]}
/>
);
expect(screen.getByText('B body')).toBeInTheDocument();
expect(screen.queryByText('A body')).not.toBeInTheDocument();
expect(markRead).toHaveBeenCalledWith('b');
});
it('renders the body as plain text, never as HTML', () => {
@@ -53,6 +90,7 @@ describe('AnnouncementsPanel', () => {
announcements={[announcement({ texts: { en: { title: 'T', body: '<img src=x onerror=alert(1)>' } } })]}
/>
);
fireEvent.click(screen.getByText('T'));
expect(screen.getByText('<img src=x onerror=alert(1)>')).toBeInTheDocument();
expect(document.querySelector('img[src="x"]')).toBeNull();
});
@@ -69,6 +107,8 @@ describe('AnnouncementsPanel', () => {
]}
/>
);
fireEvent.click(screen.getByText('A'));
fireEvent.click(screen.getByText('B'));
const link = screen.getByRole('link', { name: /Details/ });
expect(link).toHaveAttribute('href', 'https://wiki.bambuddy.cool/x/');
expect(link).toHaveAttribute('rel', 'noopener noreferrer');
@@ -83,6 +123,29 @@ describe('AnnouncementsPanel', () => {
});
});
describe('AnnouncementsPanel history', () => {
it('lists expired messages under a collapsed Earlier section and never marks them', () => {
const markRead = vi.fn();
render(
<AnnouncementsPanel
open
onClose={() => {}}
markRead={markRead}
announcements={[
announcement({ id: 'old', archived: true, texts: { en: { title: 'Old news', body: 'x' } } }),
]}
/>
);
expect(screen.getByText('No announcements right now.')).toBeInTheDocument();
expect(screen.queryByText('Old news')).not.toBeInTheDocument();
fireEvent.click(screen.getByRole('button', { name: 'Earlier (1)' }));
expect(screen.getByText('Old news')).toBeInTheDocument();
fireEvent.click(screen.getByRole('button', { name: /Old news/ }));
expect(screen.queryByText('New')).not.toBeInTheDocument();
expect(markRead).not.toHaveBeenCalled();
});
});
describe('AnnouncementBanner', () => {
it('shows the first item, offers the rest, and Got it marks it read', () => {
const markRead = vi.fn();
@@ -99,7 +162,7 @@ describe('AnnouncementBanner', () => {
);
expect(screen.getByRole('alert')).toHaveTextContent('Update now');
fireEvent.click(screen.getByRole('button', { name: 'Read more (+1)' }));
expect(onOpen).toHaveBeenCalled();
expect(onOpen).toHaveBeenCalledWith('crit');
fireEvent.click(screen.getByRole('button', { name: 'Got it' }));
expect(markRead).toHaveBeenCalledWith('crit');
});
@@ -127,6 +190,18 @@ describe('Layout with announcements', () => {
expect(screen.queryByText('Announcements')).not.toBeInTheDocument();
});
it('keeps the entry for history alone, with no count and no banner', async () => {
server.use(
http.get('/api/v1/announcements', () =>
HttpResponse.json([announcement({ id: 'old', level: 'critical', archived: true })])
)
);
render(<Layout />);
const entry = await screen.findByRole('button', { name: /announcements/i });
expect(within(entry).queryByText('1')).not.toBeInTheDocument();
expect(screen.queryByRole('alert')).not.toBeInTheDocument();
});
it('shows the entry with an unread count, the banner for important, and opens the list', async () => {
let reads: string[] = [];
server.use(
@@ -143,7 +218,7 @@ describe('Layout with announcements', () => {
);
render(<Layout />);
const entry = await screen.findByRole('button', { name: /Announcements/ });
const entry = await screen.findByRole('button', { name: /announcements/i });
expect(within(entry).getByText('2')).toBeInTheDocument();
// Only the important one earns a banner.
expect(screen.getByRole('status')).toHaveTextContent('Breaking change in 2.0');
@@ -152,8 +227,12 @@ describe('Layout with announcements', () => {
fireEvent.click(entry);
const dialog = await screen.findByRole('dialog');
expect(within(dialog).getByText('Testers wanted')).toBeInTheDocument();
await waitFor(() => expect(reads.sort()).toEqual(['imp', 'inf']));
// Read now: the banner is gone.
// Opening the panel reads nothing; opening a message reads that one.
expect(reads).toEqual([]);
fireEvent.click(within(dialog).getByRole('button', { name: /Breaking change in 2.0/ }));
await waitFor(() => expect(reads).toEqual(['imp']));
// Read now: its banner is gone, the info one is still unread.
await waitFor(() => expect(screen.queryByRole('status')).not.toBeInTheDocument());
expect(within(dialog).getAllByText('New')).toHaveLength(1);
});
});
@@ -3,7 +3,7 @@ import { announcementText, isAllowedAnnouncementLink } from '../../hooks/useAnno
import type { Announcement } from '../../api/client';
const a = (texts: Announcement['texts']): Announcement => ({
id: 'x', level: 'info', texts, link_url: null, published_at: null, expires_at: null, read: false,
id: 'x', level: 'info', texts, link_url: null, published_at: null, expires_at: null, archived: false, read: false,
});
describe('announcementText', () => {
@@ -438,6 +438,36 @@ describe('useWebSocket hook', () => {
vi.unstubAllGlobals();
});
it('re-reads announcements on announcements_changed, so the dot appears without a reload', async () => {
vi.useFakeTimers();
const { useWebSocket } = await import('../../hooks/useWebSocket');
const invalidateSpy = vi.spyOn(queryClient, 'invalidateQueries');
renderHook(() => useWebSocket(), {
wrapper: createWrapper(queryClient),
});
const ws = await waitForWs();
act(() => {
ws.open();
});
act(() => {
ws.simulateMessage({ type: 'announcements_changed' });
});
await act(async () => {
vi.advanceTimersByTime(5000);
});
expect(invalidateSpy).toHaveBeenCalledWith({ queryKey: ['announcements'] });
vi.useRealTimers();
vi.unstubAllGlobals();
});
it('invalidates inventory queries on inventory_changed message', async () => {
vi.useFakeTimers();
const { useWebSocket } = await import('../../hooks/useWebSocket');
+2
View File
@@ -9216,6 +9216,8 @@ export interface Announcement {
link_url: string | null;
published_at: string | null;
expires_at: string | null;
// Past its expiry: kept as history, listed under "Earlier", never unread.
archived: boolean;
read: boolean;
}
@@ -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
+2 -1
View File
@@ -69,7 +69,8 @@ export function useAnnouncements() {
const { mutate } = markReadMutation;
const markRead = useCallback((id: string) => mutate(id), [mutate]);
const unread = useMemo(() => announcements.filter((a) => !a.read), [announcements]);
// History is never unread: it was current once, and either read then or missed.
const unread = useMemo(() => announcements.filter((a) => !a.read && !a.archived), [announcements]);
// What earns a banner: important or critical, not yet read. Most severe first,
// then newest (the list already comes newest first).
+6
View File
@@ -403,6 +403,12 @@ export function useWebSocket() {
}));
break;
case 'announcements_changed':
// The backend fetched a newer feed: re-read the list so the sidebar dot
// and the banner appear without a reload. The event says nothing itself.
debouncedInvalidate('announcements');
break;
case 'inventory_changed':
// Spool created/updated/deleted/archived/restored - refresh inventory across all tabs
debouncedInvalidate('inventory-spools');
+1
View File
@@ -37,6 +37,7 @@ export default {
title: 'Ankündigungen',
unread: 'Ungelesene Ankündigungen: {{count}}',
empty: 'Derzeit keine Ankündigungen.',
earlier: 'Frühere ({{count}})',
new: 'Neu',
readMore: 'Mehr erfahren',
readMoreCount: 'Mehr erfahren (+{{count}})',
+1
View File
@@ -37,6 +37,7 @@ export default {
title: 'Announcements',
unread: 'Unread announcements: {{count}}',
empty: 'No announcements right now.',
earlier: 'Earlier ({{count}})',
new: 'New',
readMore: 'Read more',
readMoreCount: 'Read more (+{{count}})',
+1
View File
@@ -37,6 +37,7 @@ export default {
title: 'Anuncios',
unread: 'Anuncios sin leer: {{count}}',
empty: 'No hay anuncios por ahora.',
earlier: 'Anteriores ({{count}})',
new: 'Nuevo',
readMore: 'Leer más',
readMoreCount: 'Leer más (+{{count}})',
+1
View File
@@ -37,6 +37,7 @@ export default {
title: 'Annonces',
unread: 'Annonces non lues : {{count}}',
empty: 'Aucune annonce pour le moment.',
earlier: 'Précédentes ({{count}})',
new: 'Nouveau',
readMore: 'En savoir plus',
readMoreCount: 'En savoir plus (+{{count}})',
+1
View File
@@ -37,6 +37,7 @@ export default {
title: 'Annunci',
unread: 'Annunci non letti: {{count}}',
empty: 'Nessun annuncio al momento.',
earlier: 'Precedenti ({{count}})',
new: 'Nuovo',
readMore: 'Scopri di più',
readMoreCount: 'Scopri di più (+{{count}})',
+1
View File
@@ -37,6 +37,7 @@ export default {
title: 'お知らせ',
unread: '未読のお知らせ: {{count}}',
empty: '現在お知らせはありません。',
earlier: '過去のお知らせ ({{count}})',
new: '新着',
readMore: '詳細を見る',
readMoreCount: '詳細を見る (+{{count}})',
+1
View File
@@ -34,6 +34,7 @@ export default {
title: '공지사항',
unread: '읽지 않은 공지사항: {{count}}',
empty: '현재 공지사항이 없습니다.',
earlier: '이전 공지 ({{count}})',
new: '새 소식',
readMore: '자세히 보기',
readMoreCount: '자세히 보기 (+{{count}})',
+1
View File
@@ -37,6 +37,7 @@ export default {
title: 'Aankondigingen',
unread: 'Ongelezen aankondigingen: {{count}}',
empty: 'Op dit moment geen aankondigingen.',
earlier: 'Eerder ({{count}})',
new: 'Nieuw',
readMore: 'Meer lezen',
readMoreCount: 'Meer lezen (+{{count}})',
+1
View File
@@ -37,6 +37,7 @@ export default {
title: 'Anúncios',
unread: 'Anúncios não lidos: {{count}}',
empty: 'Nenhum anúncio no momento.',
earlier: 'Anteriores ({{count}})',
new: 'Novo',
readMore: 'Saiba mais',
readMoreCount: 'Saiba mais (+{{count}})',
+1
View File
@@ -34,6 +34,7 @@ export default {
title: 'Объявления',
unread: 'Непрочитанные объявления: {{count}}',
empty: 'Сейчас объявлений нет.',
earlier: 'Ранее ({{count}})',
new: 'Новое',
readMore: 'Подробнее',
readMoreCount: 'Подробнее (+{{count}})',
+1
View File
@@ -37,6 +37,7 @@ export default {
title: 'Meddelanden',
unread: 'Olästa meddelanden: {{count}}',
empty: 'Inga meddelanden just nu.',
earlier: 'Tidigare ({{count}})',
new: 'Ny',
readMore: 'Läs mer',
readMoreCount: 'Läs mer (+{{count}})',
+1
View File
@@ -37,6 +37,7 @@ export default {
title: 'Duyurular',
unread: 'Okunmamış duyurular: {{count}}',
empty: 'Şu anda duyuru yok.',
earlier: 'Önceki ({{count}})',
new: 'Yeni',
readMore: 'Devamını oku',
readMoreCount: 'Devamını oku (+{{count}})',
+1
View File
@@ -37,6 +37,7 @@ export default {
title: 'Оголошення',
unread: 'Непрочитані оголошення: {{count}}',
empty: 'Наразі оголошень немає.',
earlier: 'Раніше ({{count}})',
new: 'Нове',
readMore: 'Докладніше',
readMoreCount: 'Докладніше (+{{count}})',
+1
View File
@@ -37,6 +37,7 @@ export default {
title: '公告',
unread: '未读公告:{{count}}',
empty: '暂无公告。',
earlier: '更早 ({{count}})',
new: '新',
readMore: '了解更多',
readMoreCount: '了解更多 (+{{count}})',
+1
View File
@@ -37,6 +37,7 @@ export default {
title: '公告',
unread: '未讀公告:{{count}}',
empty: '目前沒有公告。',
earlier: '更早 ({{count}})',
new: '新',
readMore: '了解更多',
readMoreCount: '了解更多 (+{{count}})',
@@ -1 +1 @@
import{i as e}from"./chunk-aKtaBQYM.js";import{a as t,c as n,d as r,g as i,i as a,l as o,n as s,o as c,r as l,s as u,t as d,u as f,v as p,y as m}from"./index-CceJ9Gq0.js";var h=e(m(),1),g=i(),_=.4,v=8,y=1.25,b=30,x=Math.log(y)/b,S=e=>Math.min(v,Math.max(_,e)),C={x:0,y:0};function w(e){return e.deltaMode===1?e.deltaY*16:e.deltaMode===2?e.deltaY*100:e.deltaY}function T({libraryFileId:e,filename:i,fileSize:m,onClose:_}){let{t:v}=p(),T=d(),{isFullscreen:E,toggleFullscreen:D}=T,O=(0,h.useRef)(null),k=(0,h.useRef)(null),[A,j]=(0,h.useState)(null),[M,N]=(0,h.useState)(!1),[P,F]=(0,h.useState)(null),[I,L]=(0,h.useState)(1),[R,z]=(0,h.useState)(C),[B,V]=(0,h.useState)(!1),H=(0,h.useRef)(1),U=(0,h.useRef)(C),W=(0,h.useRef)(new Map),G=(0,h.useRef)(null),K=(0,h.useCallback)((e,t)=>{let n=O.current,r=k.current;if(!n||!r)return e;let i=Math.max(0,(r.offsetWidth*t-n.clientWidth)/2),a=Math.max(0,(r.offsetHeight*t-n.clientHeight)/2);return{x:Math.min(i,Math.max(-i,e.x)),y:Math.min(a,Math.max(-a,e.y))}},[]),q=(0,h.useCallback)((e,t)=>{H.current=e,U.current=t,L(e),z(t)},[]),J=(0,h.useCallback)((e,t,n)=>{let r=H.current,i=S(r*e);if(i===r)return;let a=O.current,o=U.current,s={x:o.x*(i/r),y:o.y*(i/r)};if(a){let e=a.getBoundingClientRect(),c=(t??e.left+e.width/2)-(e.left+e.width/2),l=(n??e.top+e.height/2)-(e.top+e.height/2),u=i/r;s={x:c-(c-o.x)*u,y:l-(l-o.y)*u}}q(i,K(s,i))},[K,q]),Y=(0,h.useCallback)(()=>q(1,C),[q]);(0,h.useEffect)(()=>{let n=!1,r=null;if(j(null),N(!1),F(null),q(1,C),m>52428800){F(v(`fileManager.preview.tooLarge`,{size:a(m)}));return}let i={},o=c();return o&&(i.Authorization=`Bearer ${o}`),(async()=>{let a=await fetch(t.getLibraryFileDownloadUrl(e),{headers:i});if(!a.ok)throw Error(`HTTP ${a.status}`);let o=await a.blob();n||(r=URL.createObjectURL(o),j(r))})().catch(e=>{console.error(`[image-preview] load failed`,e),n||F(v(`fileManager.preview.error`))}),()=>{n=!0,r&&URL.revokeObjectURL(r)}},[e,m,v,q]),(0,h.useEffect)(()=>{let e=e=>{e.ctrlKey||e.metaKey||e.altKey||(e.key===`+`||e.key===`=`?(e.preventDefault(),J(y)):e.key===`-`?(e.preventDefault(),J(1/y)):e.key===`0`&&(e.preventDefault(),Y()))};return window.addEventListener(`keydown`,e),()=>window.removeEventListener(`keydown`,e)},[Y,J]),(0,h.useEffect)(()=>{let e=O.current;if(!e)return;let t=e=>{e.preventDefault();let t=Math.max(-30,Math.min(b,w(e)));J(Math.exp(-t*x),e.clientX,e.clientY)};return e.addEventListener(`wheel`,t,{passive:!1}),()=>e.removeEventListener(`wheel`,t)},[J,A]);let X=e=>{W.current.set(e.pointerId,{x:e.clientX,y:e.clientY}),G.current=null,W.current.size===1&&H.current>1&&(e.currentTarget.setPointerCapture(e.pointerId),V(!0))},Z=e=>{W.current.delete(e.pointerId),G.current=null,W.current.size===0&&V(!1)},Q=e=>{let t=W.current,n=t.get(e.pointerId);if(!n)return;if(e.pointerType===`mouse`&&e.buttons===0){Z(e);return}if(t.set(e.pointerId,{x:e.clientX,y:e.clientY}),t.size===1){if(H.current<=1)return;let t={x:U.current.x+(e.clientX-n.x),y:U.current.y+(e.clientY-n.y)};q(H.current,K(t,H.current));return}if(t.size!==2)return;let[r,i]=Array.from(t.values()),a=Math.hypot(r.x-i.x,r.y-i.y),o=G.current;G.current=a,o!=null&&o>0&&a>0&&J(a/o,(r.x+i.x)/2,(r.y+i.y)/2)},$=I>1;return(0,g.jsx)(s,{title:i,fullscreen:T,onClose:_,icon:(0,g.jsx)(r,{className:`w-5 h-5 text-bambu-green flex-shrink-0`}),actions:A?(0,g.jsxs)(g.Fragment,{children:[(0,g.jsx)(`button`,{onClick:()=>J(1/y),className:l,"aria-label":v(`fileManager.preview.zoomOut`),children:(0,g.jsx)(u,{className:`w-4 h-4`})}),(0,g.jsx)(`button`,{onClick:()=>J(y),className:l,"aria-label":v(`fileManager.preview.zoomIn`),children:(0,g.jsx)(n,{className:`w-4 h-4`})}),(0,g.jsx)(`button`,{onClick:Y,className:l,"aria-label":v(`fileManager.preview.resetZoom`),children:(0,g.jsx)(o,{className:`w-4 h-4`})})]}):void 0,children:(0,g.jsx)(`div`,{ref:O,"data-testid":`image-preview-content`,className:`relative flex-1 min-h-0 overflow-hidden bg-bambu-dark flex items-center justify-center touch-none ${E?``:`rounded-b-lg`} ${$?B?`cursor-grabbing`:`cursor-grab`:``}`,onDoubleClick:D,onPointerDown:X,onPointerMove:Q,onPointerUp:Z,onPointerCancel:Z,onPointerLeave:Z,children:P?(0,g.jsx)(`p`,{className:`text-bambu-gray text-center p-6`,children:P}):(0,g.jsxs)(g.Fragment,{children:[A&&(0,g.jsx)(`img`,{ref:k,src:A,alt:i,draggable:!1,onLoad:()=>N(!0),onError:()=>F(v(`fileManager.preview.error`)),style:{transform:`translate(${R.x}px, ${R.y}px) scale(${I})`},className:`max-w-full max-h-full object-contain select-none`}),!M&&(0,g.jsx)(`div`,{className:`absolute inset-0 flex items-center justify-center pointer-events-none`,children:(0,g.jsx)(f,{className:`w-8 h-8 text-bambu-green animate-spin`})})]})})})}export{T as ImagePreviewModal};
import{i as e}from"./chunk-aKtaBQYM.js";import{a as t,c as n,d as r,g as i,i as a,l as o,n as s,o as c,r as l,s as u,t as d,u as f,v as p,y as m}from"./index-XSkcXiLG.js";var h=e(m(),1),g=i(),_=.4,v=8,y=1.25,b=30,x=Math.log(y)/b,S=e=>Math.min(v,Math.max(_,e)),C={x:0,y:0};function w(e){return e.deltaMode===1?e.deltaY*16:e.deltaMode===2?e.deltaY*100:e.deltaY}function T({libraryFileId:e,filename:i,fileSize:m,onClose:_}){let{t:v}=p(),T=d(),{isFullscreen:E,toggleFullscreen:D}=T,O=(0,h.useRef)(null),k=(0,h.useRef)(null),[A,j]=(0,h.useState)(null),[M,N]=(0,h.useState)(!1),[P,F]=(0,h.useState)(null),[I,L]=(0,h.useState)(1),[R,z]=(0,h.useState)(C),[B,V]=(0,h.useState)(!1),H=(0,h.useRef)(1),U=(0,h.useRef)(C),W=(0,h.useRef)(new Map),G=(0,h.useRef)(null),K=(0,h.useCallback)((e,t)=>{let n=O.current,r=k.current;if(!n||!r)return e;let i=Math.max(0,(r.offsetWidth*t-n.clientWidth)/2),a=Math.max(0,(r.offsetHeight*t-n.clientHeight)/2);return{x:Math.min(i,Math.max(-i,e.x)),y:Math.min(a,Math.max(-a,e.y))}},[]),q=(0,h.useCallback)((e,t)=>{H.current=e,U.current=t,L(e),z(t)},[]),J=(0,h.useCallback)((e,t,n)=>{let r=H.current,i=S(r*e);if(i===r)return;let a=O.current,o=U.current,s={x:o.x*(i/r),y:o.y*(i/r)};if(a){let e=a.getBoundingClientRect(),c=(t??e.left+e.width/2)-(e.left+e.width/2),l=(n??e.top+e.height/2)-(e.top+e.height/2),u=i/r;s={x:c-(c-o.x)*u,y:l-(l-o.y)*u}}q(i,K(s,i))},[K,q]),Y=(0,h.useCallback)(()=>q(1,C),[q]);(0,h.useEffect)(()=>{let n=!1,r=null;if(j(null),N(!1),F(null),q(1,C),m>52428800){F(v(`fileManager.preview.tooLarge`,{size:a(m)}));return}let i={},o=c();return o&&(i.Authorization=`Bearer ${o}`),(async()=>{let a=await fetch(t.getLibraryFileDownloadUrl(e),{headers:i});if(!a.ok)throw Error(`HTTP ${a.status}`);let o=await a.blob();n||(r=URL.createObjectURL(o),j(r))})().catch(e=>{console.error(`[image-preview] load failed`,e),n||F(v(`fileManager.preview.error`))}),()=>{n=!0,r&&URL.revokeObjectURL(r)}},[e,m,v,q]),(0,h.useEffect)(()=>{let e=e=>{e.ctrlKey||e.metaKey||e.altKey||(e.key===`+`||e.key===`=`?(e.preventDefault(),J(y)):e.key===`-`?(e.preventDefault(),J(1/y)):e.key===`0`&&(e.preventDefault(),Y()))};return window.addEventListener(`keydown`,e),()=>window.removeEventListener(`keydown`,e)},[Y,J]),(0,h.useEffect)(()=>{let e=O.current;if(!e)return;let t=e=>{e.preventDefault();let t=Math.max(-30,Math.min(b,w(e)));J(Math.exp(-t*x),e.clientX,e.clientY)};return e.addEventListener(`wheel`,t,{passive:!1}),()=>e.removeEventListener(`wheel`,t)},[J,A]);let X=e=>{W.current.set(e.pointerId,{x:e.clientX,y:e.clientY}),G.current=null,W.current.size===1&&H.current>1&&(e.currentTarget.setPointerCapture(e.pointerId),V(!0))},Z=e=>{W.current.delete(e.pointerId),G.current=null,W.current.size===0&&V(!1)},Q=e=>{let t=W.current,n=t.get(e.pointerId);if(!n)return;if(e.pointerType===`mouse`&&e.buttons===0){Z(e);return}if(t.set(e.pointerId,{x:e.clientX,y:e.clientY}),t.size===1){if(H.current<=1)return;let t={x:U.current.x+(e.clientX-n.x),y:U.current.y+(e.clientY-n.y)};q(H.current,K(t,H.current));return}if(t.size!==2)return;let[r,i]=Array.from(t.values()),a=Math.hypot(r.x-i.x,r.y-i.y),o=G.current;G.current=a,o!=null&&o>0&&a>0&&J(a/o,(r.x+i.x)/2,(r.y+i.y)/2)},$=I>1;return(0,g.jsx)(s,{title:i,fullscreen:T,onClose:_,icon:(0,g.jsx)(r,{className:`w-5 h-5 text-bambu-green flex-shrink-0`}),actions:A?(0,g.jsxs)(g.Fragment,{children:[(0,g.jsx)(`button`,{onClick:()=>J(1/y),className:l,"aria-label":v(`fileManager.preview.zoomOut`),children:(0,g.jsx)(u,{className:`w-4 h-4`})}),(0,g.jsx)(`button`,{onClick:()=>J(y),className:l,"aria-label":v(`fileManager.preview.zoomIn`),children:(0,g.jsx)(n,{className:`w-4 h-4`})}),(0,g.jsx)(`button`,{onClick:Y,className:l,"aria-label":v(`fileManager.preview.resetZoom`),children:(0,g.jsx)(o,{className:`w-4 h-4`})})]}):void 0,children:(0,g.jsx)(`div`,{ref:O,"data-testid":`image-preview-content`,className:`relative flex-1 min-h-0 overflow-hidden bg-bambu-dark flex items-center justify-center touch-none ${E?``:`rounded-b-lg`} ${$?B?`cursor-grabbing`:`cursor-grab`:``}`,onDoubleClick:D,onPointerDown:X,onPointerMove:Q,onPointerUp:Z,onPointerCancel:Z,onPointerLeave:Z,children:P?(0,g.jsx)(`p`,{className:`text-bambu-gray text-center p-6`,children:P}):(0,g.jsxs)(g.Fragment,{children:[A&&(0,g.jsx)(`img`,{ref:k,src:A,alt:i,draggable:!1,onLoad:()=>N(!0),onError:()=>F(v(`fileManager.preview.error`)),style:{transform:`translate(${R.x}px, ${R.y}px) scale(${I})`},className:`max-w-full max-h-full object-contain select-none`}),!M&&(0,g.jsx)(`div`,{className:`absolute inset-0 flex items-center justify-center pointer-events-none`,children:(0,g.jsx)(f,{className:`w-8 h-8 text-bambu-green animate-spin`})})]})})})}export{T as ImagePreviewModal};
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+2 -2
View File
@@ -26,9 +26,9 @@
<!-- Splash screens for iOS -->
<link rel="apple-touch-startup-image" href="/img/android-chrome-512x512.png" />
<script type="module" crossorigin src="/assets/index-CceJ9Gq0.js"></script>
<script type="module" crossorigin src="/assets/index-XSkcXiLG.js"></script>
<link rel="modulepreload" crossorigin href="/assets/chunk-aKtaBQYM.js">
<link rel="stylesheet" crossorigin href="/assets/index-CvbMEZlt.css">
<link rel="stylesheet" crossorigin href="/assets/index-b50e2nk4.css">
</head>
<body>
<div id="root"></div>