فهرست منبع

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.
maziggy 1 روز پیش
والد
کامیت
20df83589c
34فایلهای تغییر یافته به همراه374 افزوده شده و 105 حذف شده
  1. 32 13
      backend/app/services/announcements.py
  2. 34 2
      backend/tests/unit/test_announcements_service.py
  3. 87 8
      frontend/src/__tests__/components/Announcements.test.tsx
  4. 1 1
      frontend/src/__tests__/hooks/useAnnouncements.test.ts
  5. 30 0
      frontend/src/__tests__/hooks/useWebSocket.test.ts
  6. 2 0
      frontend/src/api/client.ts
  7. 3 2
      frontend/src/components/AnnouncementBanner.tsx
  8. 129 48
      frontend/src/components/AnnouncementsPanel.tsx
  9. 29 25
      frontend/src/components/Layout.tsx
  10. 2 1
      frontend/src/hooks/useAnnouncements.ts
  11. 6 0
      frontend/src/hooks/useWebSocket.ts
  12. 1 0
      frontend/src/i18n/locales/de.ts
  13. 1 0
      frontend/src/i18n/locales/en.ts
  14. 1 0
      frontend/src/i18n/locales/es.ts
  15. 1 0
      frontend/src/i18n/locales/fr.ts
  16. 1 0
      frontend/src/i18n/locales/it.ts
  17. 1 0
      frontend/src/i18n/locales/ja.ts
  18. 1 0
      frontend/src/i18n/locales/ko.ts
  19. 1 0
      frontend/src/i18n/locales/nl.ts
  20. 1 0
      frontend/src/i18n/locales/pt-BR.ts
  21. 1 0
      frontend/src/i18n/locales/ru.ts
  22. 1 0
      frontend/src/i18n/locales/sv.ts
  23. 1 0
      frontend/src/i18n/locales/tr.ts
  24. 1 0
      frontend/src/i18n/locales/uk.ts
  25. 1 0
      frontend/src/i18n/locales/zh-CN.ts
  26. 1 0
      frontend/src/i18n/locales/zh-TW.ts
  27. 0 0
      static/assets/ImagePreviewModal-gsnEQ0Oa.js
  28. 0 1
      static/assets/PdfPreviewModal-VeiGrj3M.js
  29. 0 0
      static/assets/SpreadsheetPreviewModal-Ws1DaBxm.js
  30. 0 1
      static/assets/index-CvbMEZlt.css
  31. 1 1
      static/assets/index-XSkcXiLG.js
  32. 1 0
      static/assets/index-b50e2nk4.css
  33. 0 0
      static/assets/pdf-vgFNYDS8.js
  34. 2 2
      static/index.html

+ 32 - 13
backend/app/services/announcements.py

@@ -251,13 +251,21 @@ def parse_entry(raw: object) -> Entry | None:
         return None
         return None
     link = raw.get("link_url")
     link = raw.get("link_url")
     link = link if isinstance(link, str) and link_allowed(link) else None
     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(
     return Entry(
         public_id=public_id,
         public_id=public_id,
         level=level,
         level=level,
         texts=texts,
         texts=texts,
         link_url=link,
         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):
     if not await is_enabled(db):
         return None
         return None
+    seen_before = int(await _get(db, SERIAL_KEY) or 0)
     try:
     try:
         content = await _download()
         content = await _download()
         payload = verify_feed(content)
         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 _set(db, LAST_FETCH_KEY, datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ"))
     await db.commit()
     await db.commit()
     logger.info("Announcements feed #%s: %d for this install", payload["serial"], count)
     logger.info("Announcements feed #%s: %d for this install", payload["serial"], count)
+    if payload["serial"] > seen_before:
+        await _tell_open_pages()
     return count
     return count
 
 
 
 
-# ---- read state ----------------------------------------------------------------------
+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)
 
 
 
 
-def _visible_filter(now: datetime):
-    return (Announcement.expires_at.is_(None)) | (Announcement.expires_at > now)
+# ---- read state ----------------------------------------------------------------------
 
 
 
 
 async def list_for(db: AsyncSession, user_id: int | None) -> list[dict]:
 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)
     now = datetime.now(timezone.utc).replace(tzinfo=None)
     rows = (
     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()
         .scalars()
         .all()
         .all()
     )
     )
@@ -400,6 +418,7 @@ async def list_for(db: AsyncSession, user_id: int | None) -> list[dict]:
                 "link_url": a.link_url,
                 "link_url": a.link_url,
                 "published_at": a.published_at.isoformat() + "Z" if a.published_at else None,
                 "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,
                 "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,
                 "read": a.id in read_ids,
             }
             }
         )
         )

+ 34 - 2
backend/tests/unit/test_announcements_service.py

@@ -244,13 +244,30 @@ class TestApply:
 
 
 class TestReadState:
 class TestReadState:
     @pytest.mark.asyncio
     @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")
         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")
         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"))
         p = payload(entry("gone", expires_at=past), entry("live", expires_at=future), entry("forever"))
         await svc.apply_payload(db_session, p, STABLE_DOCKER)
         await svc.apply_payload(db_session, p, STABLE_DOCKER)
         await db_session.commit()
         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
     @pytest.mark.asyncio
     async def test_reading_twice_with_auth_off_records_once(self, db_session):
     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 svc.refresh(db_session) == 1
         assert await _stored(db_session) == ["a1"]
         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
     @pytest.mark.asyncio
     async def test_a_bad_feed_keeps_the_last_good_list(self, db_session):
     async def test_a_bad_feed_keeps_the_last_good_list(self, db_session):
         with (
         with (

+ 87 - 8
frontend/src/__tests__/components/Announcements.test.tsx

@@ -21,27 +21,64 @@ function announcement(overrides: Partial<Announcement> = {}): Announcement {
     link_url: null,
     link_url: null,
     published_at: '2026-10-01T12:00:00Z',
     published_at: '2026-10-01T12:00:00Z',
     expires_at: null,
     expires_at: null,
+    archived: false,
     read: false,
     read: false,
     ...overrides,
     ...overrides,
   };
   };
 }
 }
 
 
 describe('AnnouncementsPanel', () => {
 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();
     const markRead = vi.fn();
     render(
     render(
       <AnnouncementsPanel
       <AnnouncementsPanel
         open
         open
         onClose={() => {}}
         onClose={() => {}}
         markRead={markRead}
         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');
     const items = screen.getAllByRole('listitem');
     expect(within(items[0]).getByText('New')).toBeInTheDocument();
     expect(within(items[0]).getByText('New')).toBeInTheDocument();
     expect(within(items[1]).queryByText('New')).not.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', () => {
   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)>' } } })]}
         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(screen.getByText('<img src=x onerror=alert(1)>')).toBeInTheDocument();
     expect(document.querySelector('img[src="x"]')).toBeNull();
     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/ });
     const link = screen.getByRole('link', { name: /Details/ });
     expect(link).toHaveAttribute('href', 'https://wiki.bambuddy.cool/x/');
     expect(link).toHaveAttribute('href', 'https://wiki.bambuddy.cool/x/');
     expect(link).toHaveAttribute('rel', 'noopener noreferrer');
     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', () => {
 describe('AnnouncementBanner', () => {
   it('shows the first item, offers the rest, and Got it marks it read', () => {
   it('shows the first item, offers the rest, and Got it marks it read', () => {
     const markRead = vi.fn();
     const markRead = vi.fn();
@@ -99,7 +162,7 @@ describe('AnnouncementBanner', () => {
     );
     );
     expect(screen.getByRole('alert')).toHaveTextContent('Update now');
     expect(screen.getByRole('alert')).toHaveTextContent('Update now');
     fireEvent.click(screen.getByRole('button', { name: 'Read more (+1)' }));
     fireEvent.click(screen.getByRole('button', { name: 'Read more (+1)' }));
-    expect(onOpen).toHaveBeenCalled();
+    expect(onOpen).toHaveBeenCalledWith('crit');
     fireEvent.click(screen.getByRole('button', { name: 'Got it' }));
     fireEvent.click(screen.getByRole('button', { name: 'Got it' }));
     expect(markRead).toHaveBeenCalledWith('crit');
     expect(markRead).toHaveBeenCalledWith('crit');
   });
   });
@@ -127,6 +190,18 @@ describe('Layout with announcements', () => {
     expect(screen.queryByText('Announcements')).not.toBeInTheDocument();
     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 () => {
   it('shows the entry with an unread count, the banner for important, and opens the list', async () => {
     let reads: string[] = [];
     let reads: string[] = [];
     server.use(
     server.use(
@@ -143,7 +218,7 @@ describe('Layout with announcements', () => {
     );
     );
     render(<Layout />);
     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();
     expect(within(entry).getByText('2')).toBeInTheDocument();
     // Only the important one earns a banner.
     // Only the important one earns a banner.
     expect(screen.getByRole('status')).toHaveTextContent('Breaking change in 2.0');
     expect(screen.getByRole('status')).toHaveTextContent('Breaking change in 2.0');
@@ -152,8 +227,12 @@ describe('Layout with announcements', () => {
     fireEvent.click(entry);
     fireEvent.click(entry);
     const dialog = await screen.findByRole('dialog');
     const dialog = await screen.findByRole('dialog');
     expect(within(dialog).getByText('Testers wanted')).toBeInTheDocument();
     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());
     await waitFor(() => expect(screen.queryByRole('status')).not.toBeInTheDocument());
+    expect(within(dialog).getAllByText('New')).toHaveLength(1);
   });
   });
 });
 });

+ 1 - 1
frontend/src/__tests__/hooks/useAnnouncements.test.ts

@@ -3,7 +3,7 @@ import { announcementText, isAllowedAnnouncementLink } from '../../hooks/useAnno
 import type { Announcement } from '../../api/client';
 import type { Announcement } from '../../api/client';
 
 
 const a = (texts: Announcement['texts']): Announcement => ({
 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', () => {
 describe('announcementText', () => {

+ 30 - 0
frontend/src/__tests__/hooks/useWebSocket.test.ts

@@ -438,6 +438,36 @@ describe('useWebSocket hook', () => {
       vi.unstubAllGlobals();
       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 () => {
     it('invalidates inventory queries on inventory_changed message', async () => {
       vi.useFakeTimers();
       vi.useFakeTimers();
       const { useWebSocket } = await import('../../hooks/useWebSocket');
       const { useWebSocket } = await import('../../hooks/useWebSocket');

+ 2 - 0
frontend/src/api/client.ts

@@ -9216,6 +9216,8 @@ export interface Announcement {
   link_url: string | null;
   link_url: string | null;
   published_at: string | null;
   published_at: string | null;
   expires_at: string | null;
   expires_at: string | null;
+  // Past its expiry: kept as history, listed under "Earlier", never unread.
+  archived: boolean;
   read: boolean;
   read: boolean;
 }
 }
 
 

+ 3 - 2
frontend/src/components/AnnouncementBanner.tsx

@@ -6,7 +6,8 @@ import { announcementText } from '../hooks/useAnnouncements';
 interface AnnouncementBannerProps {
 interface AnnouncementBannerProps {
   // Unread important/critical announcements, most severe first.
   // Unread important/critical announcements, most severe first.
   items: Announcement[];
   items: Announcement[];
-  onOpen: () => void;
+  // Opens the panel with this message expanded.
+  onOpen: (id: string) => void;
   markRead: (id: string) => void;
   markRead: (id: string) => void;
 }
 }
 
 
@@ -40,7 +41,7 @@ export function AnnouncementBanner({ items, onOpen, markRead }: AnnouncementBann
           {text.title}
           {text.title}
         </span>
         </span>
         <button
         <button
-          onClick={onOpen}
+          onClick={() => onOpen(first.id)}
           className={`font-medium underline ${
           className={`font-medium underline ${
             critical
             critical
               ? 'text-red-700 dark:text-red-400 hover:text-red-900 dark:hover:text-red-300'
               ? 'text-red-700 dark:text-red-400 hover:text-red-900 dark:hover:text-red-300'

+ 129 - 48
frontend/src/components/AnnouncementsPanel.tsx

@@ -1,6 +1,6 @@
-import { useEffect, useRef, useState } from 'react';
+import { useCallback, useEffect, useRef, useState } from 'react';
 import { useTranslation } from 'react-i18next';
 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 type { Announcement, AnnouncementLevel } from '../api/client';
 import { announcementText, isAllowedAnnouncementLink } from '../hooks/useAnnouncements';
 import { announcementText, isAllowedAnnouncementLink } from '../hooks/useAnnouncements';
 import { formatDateOnly } from '../utils/date';
 import { formatDateOnly } from '../utils/date';
@@ -16,30 +16,56 @@ interface AnnouncementsPanelProps {
   onClose: () => void;
   onClose: () => void;
   announcements: Announcement[];
   announcements: Announcement[];
   markRead: (id: string) => void;
   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.
  * 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);
   const closeRef = useRef<HTMLButtonElement>(null);
-  // The list at the moment of opening; later refetches don't re-mark anything.
   const latest = useRef({ announcements, markRead });
   const latest = useRef({ announcements, markRead });
   latest.current = { 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(() => {
   useEffect(() => {
     if (!open) return;
     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();
     closeRef.current?.focus();
-  }, [open]);
+  }, [open, focusId, expand]);
 
 
   useEffect(() => {
   useEffect(() => {
     if (!open) return;
     if (!open) return;
@@ -52,6 +78,9 @@ export function AnnouncementsPanel({ open, onClose, announcements, markRead }: A
 
 
   if (!open) return null;
   if (!open) return null;
 
 
+  const current = announcements.filter((a) => !a.archived);
+  const earlier = announcements.filter((a) => a.archived);
+
   return (
   return (
     <>
     <>
       <div className="fixed inset-0 bg-black/60 z-[60]" onClick={onClose} aria-hidden="true" />
       <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>
         </header>
 
 
         <div className="flex-1 overflow-y-auto">
         <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>
             <p className="p-6 text-center text-bambu-gray">{t('announcements.empty')}</p>
           ) : (
           ) : (
             <ul className="divide-y divide-bambu-dark-tertiary">
             <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>
             </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>
         </div>
 
 
         <footer className="px-5 py-3 border-t border-bambu-dark-tertiary text-xs text-bambu-gray">
         <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
frontend/src/components/Layout.tsx

@@ -157,8 +157,10 @@ export function Layout() {
   const { announcements, unread: unreadAnnouncements, bannerItems, markRead: markAnnouncementRead } =
   const { announcements, unread: unreadAnnouncements, bannerItems, markRead: markAnnouncementRead } =
     useAnnouncements();
     useAnnouncements();
   const [announcementsOpen, setAnnouncementsOpen] = useState(false);
   const [announcementsOpen, setAnnouncementsOpen] = useState(false);
-  const openAnnouncements = useCallback(() => {
+  const [announcementFocus, setAnnouncementFocus] = useState<string | null>(null);
+  const openAnnouncements = useCallback((focusId?: string) => {
     setMobileDrawerOpen(false);
     setMobileDrawerOpen(false);
+    setAnnouncementFocus(focusId ?? null);
     setAnnouncementsOpen(true);
     setAnnouncementsOpen(true);
   }, []);
   }, []);
   const closeAnnouncements = useCallback(() => setAnnouncementsOpen(false), []);
   const closeAnnouncements = useCallback(() => setAnnouncementsOpen(false), []);
@@ -746,29 +748,11 @@ export function Layout() {
         {/* Footer */}
         {/* Footer */}
         <div className="flex-shrink-0 p-2 border-t border-bambu-dark-tertiary">
         <div className="flex-shrink-0 p-2 border-t border-bambu-dark-tertiary">
           {isSidebarCompact || sidebarExpanded ? (
           {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 && (
                 {hasSwitchbarPlugs && (
                   <div className="relative">
                   <div className="relative">
                     <button
                     <button
@@ -785,6 +769,25 @@ export function Layout() {
                     )}
                     )}
                   </div>
                   </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') ? (
                 {hasPermission('system:read') ? (
                   <NavLink
                   <NavLink
                     to="/system"
                     to="/system"
@@ -892,7 +895,7 @@ export function Layout() {
               )}
               )}
               {announcements.length > 0 && (
               {announcements.length > 0 && (
                 <button
                 <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"
                   className="relative p-2 rounded-lg hover:bg-bambu-dark-tertiary transition-colors text-bambu-gray-light hover:text-white"
                   title={t('announcements.title')}
                   title={t('announcements.title')}
                   aria-label={
                   aria-label={
@@ -1065,6 +1068,7 @@ export function Layout() {
         onClose={closeAnnouncements}
         onClose={closeAnnouncements}
         announcements={announcements}
         announcements={announcements}
         markRead={markAnnouncementRead}
         markRead={markAnnouncementRead}
+        focusId={announcementFocus}
       />
       />
 
 
       <UnknownSpoolModal
       <UnknownSpoolModal

+ 2 - 1
frontend/src/hooks/useAnnouncements.ts

@@ -69,7 +69,8 @@ export function useAnnouncements() {
   const { mutate } = markReadMutation;
   const { mutate } = markReadMutation;
   const markRead = useCallback((id: string) => mutate(id), [mutate]);
   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,
   // What earns a banner: important or critical, not yet read. Most severe first,
   // then newest (the list already comes newest first).
   // then newest (the list already comes newest first).

+ 6 - 0
frontend/src/hooks/useWebSocket.ts

@@ -403,6 +403,12 @@ export function useWebSocket() {
         }));
         }));
         break;
         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':
       case 'inventory_changed':
         // Spool created/updated/deleted/archived/restored - refresh inventory across all tabs
         // Spool created/updated/deleted/archived/restored - refresh inventory across all tabs
         debouncedInvalidate('inventory-spools');
         debouncedInvalidate('inventory-spools');

+ 1 - 0
frontend/src/i18n/locales/de.ts

@@ -37,6 +37,7 @@ export default {
     title: 'Ankündigungen',
     title: 'Ankündigungen',
     unread: 'Ungelesene Ankündigungen: {{count}}',
     unread: 'Ungelesene Ankündigungen: {{count}}',
     empty: 'Derzeit keine Ankündigungen.',
     empty: 'Derzeit keine Ankündigungen.',
+    earlier: 'Frühere ({{count}})',
     new: 'Neu',
     new: 'Neu',
     readMore: 'Mehr erfahren',
     readMore: 'Mehr erfahren',
     readMoreCount: 'Mehr erfahren (+{{count}})',
     readMoreCount: 'Mehr erfahren (+{{count}})',

+ 1 - 0
frontend/src/i18n/locales/en.ts

@@ -37,6 +37,7 @@ export default {
     title: 'Announcements',
     title: 'Announcements',
     unread: 'Unread announcements: {{count}}',
     unread: 'Unread announcements: {{count}}',
     empty: 'No announcements right now.',
     empty: 'No announcements right now.',
+    earlier: 'Earlier ({{count}})',
     new: 'New',
     new: 'New',
     readMore: 'Read more',
     readMore: 'Read more',
     readMoreCount: 'Read more (+{{count}})',
     readMoreCount: 'Read more (+{{count}})',

+ 1 - 0
frontend/src/i18n/locales/es.ts

@@ -37,6 +37,7 @@ export default {
     title: 'Anuncios',
     title: 'Anuncios',
     unread: 'Anuncios sin leer: {{count}}',
     unread: 'Anuncios sin leer: {{count}}',
     empty: 'No hay anuncios por ahora.',
     empty: 'No hay anuncios por ahora.',
+    earlier: 'Anteriores ({{count}})',
     new: 'Nuevo',
     new: 'Nuevo',
     readMore: 'Leer más',
     readMore: 'Leer más',
     readMoreCount: 'Leer más (+{{count}})',
     readMoreCount: 'Leer más (+{{count}})',

+ 1 - 0
frontend/src/i18n/locales/fr.ts

@@ -37,6 +37,7 @@ export default {
     title: 'Annonces',
     title: 'Annonces',
     unread: 'Annonces non lues : {{count}}',
     unread: 'Annonces non lues : {{count}}',
     empty: 'Aucune annonce pour le moment.',
     empty: 'Aucune annonce pour le moment.',
+    earlier: 'Précédentes ({{count}})',
     new: 'Nouveau',
     new: 'Nouveau',
     readMore: 'En savoir plus',
     readMore: 'En savoir plus',
     readMoreCount: 'En savoir plus (+{{count}})',
     readMoreCount: 'En savoir plus (+{{count}})',

+ 1 - 0
frontend/src/i18n/locales/it.ts

@@ -37,6 +37,7 @@ export default {
     title: 'Annunci',
     title: 'Annunci',
     unread: 'Annunci non letti: {{count}}',
     unread: 'Annunci non letti: {{count}}',
     empty: 'Nessun annuncio al momento.',
     empty: 'Nessun annuncio al momento.',
+    earlier: 'Precedenti ({{count}})',
     new: 'Nuovo',
     new: 'Nuovo',
     readMore: 'Scopri di più',
     readMore: 'Scopri di più',
     readMoreCount: 'Scopri di più (+{{count}})',
     readMoreCount: 'Scopri di più (+{{count}})',

+ 1 - 0
frontend/src/i18n/locales/ja.ts

@@ -37,6 +37,7 @@ export default {
     title: 'お知らせ',
     title: 'お知らせ',
     unread: '未読のお知らせ: {{count}}',
     unread: '未読のお知らせ: {{count}}',
     empty: '現在お知らせはありません。',
     empty: '現在お知らせはありません。',
+    earlier: '過去のお知らせ ({{count}})',
     new: '新着',
     new: '新着',
     readMore: '詳細を見る',
     readMore: '詳細を見る',
     readMoreCount: '詳細を見る (+{{count}})',
     readMoreCount: '詳細を見る (+{{count}})',

+ 1 - 0
frontend/src/i18n/locales/ko.ts

@@ -34,6 +34,7 @@ export default {
     title: '공지사항',
     title: '공지사항',
     unread: '읽지 않은 공지사항: {{count}}',
     unread: '읽지 않은 공지사항: {{count}}',
     empty: '현재 공지사항이 없습니다.',
     empty: '현재 공지사항이 없습니다.',
+    earlier: '이전 공지 ({{count}})',
     new: '새 소식',
     new: '새 소식',
     readMore: '자세히 보기',
     readMore: '자세히 보기',
     readMoreCount: '자세히 보기 (+{{count}})',
     readMoreCount: '자세히 보기 (+{{count}})',

+ 1 - 0
frontend/src/i18n/locales/nl.ts

@@ -37,6 +37,7 @@ export default {
     title: 'Aankondigingen',
     title: 'Aankondigingen',
     unread: 'Ongelezen aankondigingen: {{count}}',
     unread: 'Ongelezen aankondigingen: {{count}}',
     empty: 'Op dit moment geen aankondigingen.',
     empty: 'Op dit moment geen aankondigingen.',
+    earlier: 'Eerder ({{count}})',
     new: 'Nieuw',
     new: 'Nieuw',
     readMore: 'Meer lezen',
     readMore: 'Meer lezen',
     readMoreCount: 'Meer lezen (+{{count}})',
     readMoreCount: 'Meer lezen (+{{count}})',

+ 1 - 0
frontend/src/i18n/locales/pt-BR.ts

@@ -37,6 +37,7 @@ export default {
     title: 'Anúncios',
     title: 'Anúncios',
     unread: 'Anúncios não lidos: {{count}}',
     unread: 'Anúncios não lidos: {{count}}',
     empty: 'Nenhum anúncio no momento.',
     empty: 'Nenhum anúncio no momento.',
+    earlier: 'Anteriores ({{count}})',
     new: 'Novo',
     new: 'Novo',
     readMore: 'Saiba mais',
     readMore: 'Saiba mais',
     readMoreCount: 'Saiba mais (+{{count}})',
     readMoreCount: 'Saiba mais (+{{count}})',

+ 1 - 0
frontend/src/i18n/locales/ru.ts

@@ -34,6 +34,7 @@ export default {
     title: 'Объявления',
     title: 'Объявления',
     unread: 'Непрочитанные объявления: {{count}}',
     unread: 'Непрочитанные объявления: {{count}}',
     empty: 'Сейчас объявлений нет.',
     empty: 'Сейчас объявлений нет.',
+    earlier: 'Ранее ({{count}})',
     new: 'Новое',
     new: 'Новое',
     readMore: 'Подробнее',
     readMore: 'Подробнее',
     readMoreCount: 'Подробнее (+{{count}})',
     readMoreCount: 'Подробнее (+{{count}})',

+ 1 - 0
frontend/src/i18n/locales/sv.ts

@@ -37,6 +37,7 @@ export default {
     title: 'Meddelanden',
     title: 'Meddelanden',
     unread: 'Olästa meddelanden: {{count}}',
     unread: 'Olästa meddelanden: {{count}}',
     empty: 'Inga meddelanden just nu.',
     empty: 'Inga meddelanden just nu.',
+    earlier: 'Tidigare ({{count}})',
     new: 'Ny',
     new: 'Ny',
     readMore: 'Läs mer',
     readMore: 'Läs mer',
     readMoreCount: 'Läs mer (+{{count}})',
     readMoreCount: 'Läs mer (+{{count}})',

+ 1 - 0
frontend/src/i18n/locales/tr.ts

@@ -37,6 +37,7 @@ export default {
     title: 'Duyurular',
     title: 'Duyurular',
     unread: 'Okunmamış duyurular: {{count}}',
     unread: 'Okunmamış duyurular: {{count}}',
     empty: 'Şu anda duyuru yok.',
     empty: 'Şu anda duyuru yok.',
+    earlier: 'Önceki ({{count}})',
     new: 'Yeni',
     new: 'Yeni',
     readMore: 'Devamını oku',
     readMore: 'Devamını oku',
     readMoreCount: 'Devamını oku (+{{count}})',
     readMoreCount: 'Devamını oku (+{{count}})',

+ 1 - 0
frontend/src/i18n/locales/uk.ts

@@ -37,6 +37,7 @@ export default {
     title: 'Оголошення',
     title: 'Оголошення',
     unread: 'Непрочитані оголошення: {{count}}',
     unread: 'Непрочитані оголошення: {{count}}',
     empty: 'Наразі оголошень немає.',
     empty: 'Наразі оголошень немає.',
+    earlier: 'Раніше ({{count}})',
     new: 'Нове',
     new: 'Нове',
     readMore: 'Докладніше',
     readMore: 'Докладніше',
     readMoreCount: 'Докладніше (+{{count}})',
     readMoreCount: 'Докладніше (+{{count}})',

+ 1 - 0
frontend/src/i18n/locales/zh-CN.ts

@@ -37,6 +37,7 @@ export default {
     title: '公告',
     title: '公告',
     unread: '未读公告:{{count}}',
     unread: '未读公告:{{count}}',
     empty: '暂无公告。',
     empty: '暂无公告。',
+    earlier: '更早 ({{count}})',
     new: '新',
     new: '新',
     readMore: '了解更多',
     readMore: '了解更多',
     readMoreCount: '了解更多 (+{{count}})',
     readMoreCount: '了解更多 (+{{count}})',

+ 1 - 0
frontend/src/i18n/locales/zh-TW.ts

@@ -37,6 +37,7 @@ export default {
     title: '公告',
     title: '公告',
     unread: '未讀公告:{{count}}',
     unread: '未讀公告:{{count}}',
     empty: '目前沒有公告。',
     empty: '目前沒有公告。',
+    earlier: '更早 ({{count}})',
     new: '新',
     new: '新',
     readMore: '了解更多',
     readMore: '了解更多',
     readMoreCount: '了解更多 (+{{count}})',
     readMoreCount: '了解更多 (+{{count}})',

تفاوت فایلی نمایش داده نمی شود زیرا این فایل بسیار بزرگ است
+ 0 - 0
static/assets/ImagePreviewModal-gsnEQ0Oa.js


تفاوت فایلی نمایش داده نمی شود زیرا این فایل بسیار بزرگ است
+ 0 - 1
static/assets/PdfPreviewModal-VeiGrj3M.js


تفاوت فایلی نمایش داده نمی شود زیرا این فایل بسیار بزرگ است
+ 0 - 0
static/assets/SpreadsheetPreviewModal-Ws1DaBxm.js


تفاوت فایلی نمایش داده نمی شود زیرا این فایل بسیار بزرگ است
+ 0 - 1
static/assets/index-CvbMEZlt.css


تفاوت فایلی نمایش داده نمی شود زیرا این فایل بسیار بزرگ است
+ 1 - 1
static/assets/index-XSkcXiLG.js


تفاوت فایلی نمایش داده نمی شود زیرا این فایل بسیار بزرگ است
+ 1 - 0
static/assets/index-b50e2nk4.css


تفاوت فایلی نمایش داده نمی شود زیرا این فایل بسیار بزرگ است
+ 0 - 0
static/assets/pdf-vgFNYDS8.js


+ 2 - 2
static/index.html

@@ -26,9 +26,9 @@
 
 
     <!-- Splash screens for iOS -->
     <!-- Splash screens for iOS -->
     <link rel="apple-touch-startup-image" href="/img/android-chrome-512x512.png" />
     <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="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>
   </head>
   <body>
   <body>
     <div id="root"></div>
     <div id="root"></div>

برخی فایل ها در این مقایسه diff نمایش داده نمی شوند زیرا تعداد فایل ها بسیار زیاد است