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

- Expired messages stay readable under a collapsed "Earlier" section, as
  long as the feed keeps them (12 months, at most 50). They never count as
  unread or raise a banner; withdrawn ones are gone everywhere.
- Each message is one row (level, date, title) that opens in place.
  Unread ones carry a dot and a New chip, and opening one is what marks
  it read; the banner's Read more opens the panel on its message.
- A fetch that brings a newer feed broadcasts an empty
  announcements_changed event, so open pages show the new dot and banner
  without a reload.
- The sidebar entry is a megaphone icon with an unread badge, in the
  footer row left of System. Footer icons are 32px with no gap so seven
  fit an expanded sidebar; with authentication on, logout used to wrap
  onto a line of its own.
This commit is contained in:
maziggy
2026-10-01 11:37:06 +02:00
parent 5cd1e211a5
commit 3fae2b402b
35 changed files with 517 additions and 247 deletions
+1 -1
View File
@@ -5,7 +5,7 @@ All notable changes to Bambuddy will be documented in this file.
## [1.2.6b1] - Unreleased
### Added
- **Announcements from the Bambuddy maintainers, inside Bambuddy** — Security fixes, breaking changes, new releases and calls for testers now reach the people running Bambuddy where they already look, instead of only on GitHub and Discord. While there is one, an **Announcements** entry sits at the bottom of the sidebar above the System icon, with a count of unread messages; it opens a list, and **important** and **critical** messages also show a banner until dismissed. Read state is kept per user on the server. Bambuddy fetches one file, `feed.json` from the public `maziggy/bambuddy-notifications` repo on GitHub, at startup and every 6 hours: no Bambuddy server is contacted and nothing about the install is sent, and whether a message applies (version range, beta channel, install type) is decided locally. The file is signed with Ed25519 against a key built into Bambuddy, so a copy of the repo or anyone in between can't make it show a message, and an older file is refused so a withdrawn message can't be brought back. Messages are plain text, and links go only to github.com and bambuddy.cool. Shown to administrators; **Settings → General → Updates** can show them to every user, or turn them off entirely, in which case nothing is fetched.
- **Announcements from the Bambuddy maintainers, inside Bambuddy** — Security fixes, breaking changes, new releases and calls for testers now reach the people running Bambuddy where they already look, instead of only on GitHub and Discord. While there is one, an **Announcements** entry sits at the bottom of the sidebar above the System icon, with a count of unread messages; it opens a list, and **important** and **critical** messages also show a banner until dismissed. Messages past their expiry stay readable under **Earlier** for a year (up to 50); withdrawn ones disappear everywhere. Read state is kept per user on the server. Bambuddy fetches one file, `feed.json` from the public `maziggy/bambuddy-notifications` repo on GitHub, at startup and every 6 hours: no Bambuddy server is contacted and nothing about the install is sent, and whether a message applies (version range, beta channel, install type) is decided locally. The file is signed with Ed25519 against a key built into Bambuddy, so a copy of the repo or anyone in between can't make it show a message, and an older file is refused so a withdrawn message can't be brought back. Messages are plain text, and links go only to github.com and bambuddy.cool. Shown to administrators; **Settings → General → Updates** can show them to every user, or turn them off entirely, in which case nothing is fetched.
- **Camera snapshots reach more notifications and more providers, with an Attach Photo switch per provider (#3089, requested and contributed by @bbbenji in #3199)** — Plate Not Empty now carries a photo of the plate, taken before the chamber light is switched back off, and AI Failure Detection carries the frame the model flagged. Home Assistant (with a notify service set), Bark and Slack-format webhooks get photos too: they fetch the image themselves, so Bambuddy saves it under a random name and sends a link that opens only that one photo and stops working after 3 days. This needs **External URL** set in **Settings → Network**. Each provider has an **Attach Photo** toggle, on by default so nothing changes for existing setups, and **Test** sends a sample image when it is on. The print emails sent to the user who queued a job can show the finish photo inline when the template contains `{finish_photo_url}`. The template editor shows which events can carry a photo, and lists the variables for AI Failure Detection, Plate Not Empty, Plate Clear Required and First Layer Complete. The chamber light switched on for the plate check is now always switched back off, even when the check fails.
- **Combine several STLs, or several copies of one, onto one plate (#2999, requested by @Markus98, contributed by @adman234 in #3162)** — The slicer sidecar slices one model at a time, so putting separate STLs on one plate needed a desktop slicer to build the 3MF first. Select one or more STLs in the File Manager and click **Combine to 3MF**, set how many copies of each you want, and Bambuddy saves a new 3MF with every object on one plate, with a preview image. Tick **Open the slicer when done** to go straight to the Slice dialog with auto-arrange already on. The source STLs are left untouched. A plate holds at most 100 objects, and the selected STLs can be at most 300 MB and 5 million triangles in total; each STL is stored once however many copies you place.
- **The API-key printer status carries layers, the job id, HMS faults and the serial (#2919, requested by @simplytoast1)** — `GET /api/v1/webhook/printer/{id}/status`, the compact status route polled by add-ons such as notify-bambuddy for iOS Live Activities, returned only state, progress and time remaining. It now also returns `layer_num` and `total_layers`, the printer's `subtask_id` for the running job (a new value means a new print, even between two polls; `null` when Bambu gives the job no id), the live `hms_errors` in the same shape as the printer status route (so a filament runout reads differently from a manual pause), and the printer's `serial_number`. `remaining_time` stays in minutes for existing clients; the new `remaining_seconds` gives the same estimate in seconds, the unit notifications use. Nothing existing changed, and the route still needs **Read Status** and honours a key's printer limits.
+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>