mirror of
https://github.com/maziggy/bambuddy.git
synced 2026-10-20 17:50:12 +02:00
Announcements: history, collapsed list, instant refresh, footer icon
- Expired messages stay readable under a collapsed "Earlier" section, as
long as the feed keeps them (12 months, at most 50). They never count as
unread or raise a banner; withdrawn ones are gone everywhere.
- Each message is one row (level, date, title) that opens in place.
Unread ones carry a dot and a New chip, and opening one is what marks
it read; the banner's Read more opens the panel on its message.
- A fetch that brings a newer feed broadcasts an empty
announcements_changed event, so open pages show the new dot and banner
without a reload.
- The sidebar entry is a megaphone icon with an unread badge, in the
footer row left of System. Footer icons are 32px with no gap so seven
fit an expanded sidebar; with authentication on, logout used to wrap
onto a line of its own.
This commit is contained in:
@@ -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');
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { ExternalLink, Megaphone, X } from 'lucide-react';
|
||||
import { ChevronDown, ChevronRight, ExternalLink, Megaphone, X } from 'lucide-react';
|
||||
import type { Announcement, AnnouncementLevel } from '../api/client';
|
||||
import { announcementText, isAllowedAnnouncementLink } from '../hooks/useAnnouncements';
|
||||
import { formatDateOnly } from '../utils/date';
|
||||
@@ -16,30 +16,56 @@ interface AnnouncementsPanelProps {
|
||||
onClose: () => void;
|
||||
announcements: Announcement[];
|
||||
markRead: (id: string) => void;
|
||||
// Expanded when the panel opens: the banner's "Read more" lands on its message.
|
||||
focusId?: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Slide-over list of announcements from the Bambuddy maintainers.
|
||||
*
|
||||
* Opening it marks everything in it read -- the dot and the banner go -- but the
|
||||
* ones that were unread keep a "New" chip until it closes, so the reader can
|
||||
* still tell what they came for.
|
||||
* Each message is one row -- level, date, title -- and opens in place. Unread
|
||||
* ones carry a dot and a "New" chip until opened; opening is what marks a
|
||||
* message read, so the sidebar count is what is still unopened. Expired messages
|
||||
* the feed keeps as history sit in a collapsed "Earlier" section below; they are
|
||||
* never unread.
|
||||
*/
|
||||
export function AnnouncementsPanel({ open, onClose, announcements, markRead }: AnnouncementsPanelProps) {
|
||||
const { t, i18n } = useTranslation();
|
||||
const [newIds, setNewIds] = useState<Set<string>>(() => new Set());
|
||||
export function AnnouncementsPanel({ open, onClose, announcements, markRead, focusId }: AnnouncementsPanelProps) {
|
||||
const { t } = useTranslation();
|
||||
const [expanded, setExpanded] = useState<Set<string>>(() => new Set());
|
||||
const [showEarlier, setShowEarlier] = useState(false);
|
||||
const closeRef = useRef<HTMLButtonElement>(null);
|
||||
// The list at the moment of opening; later refetches don't re-mark anything.
|
||||
const latest = useRef({ announcements, markRead });
|
||||
latest.current = { announcements, markRead };
|
||||
|
||||
const expand = useCallback((id: string) => {
|
||||
setExpanded((prev) => new Set(prev).add(id));
|
||||
const a = latest.current.announcements.find((x) => x.id === id);
|
||||
if (a && !a.read && !a.archived) latest.current.markRead(id);
|
||||
}, []);
|
||||
|
||||
const toggle = useCallback(
|
||||
(id: string) => {
|
||||
if (expanded.has(id)) {
|
||||
setExpanded((prev) => {
|
||||
const next = new Set(prev);
|
||||
next.delete(id);
|
||||
return next;
|
||||
});
|
||||
} else {
|
||||
expand(id);
|
||||
}
|
||||
},
|
||||
[expanded, expand]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const unreadIds = latest.current.announcements.filter((a) => !a.read).map((a) => a.id);
|
||||
setNewIds(new Set(unreadIds));
|
||||
unreadIds.forEach((id) => latest.current.markRead(id));
|
||||
// Every visit starts collapsed, apart from the message the banner pointed at.
|
||||
setExpanded(new Set());
|
||||
setShowEarlier(false);
|
||||
if (focusId) expand(focusId);
|
||||
closeRef.current?.focus();
|
||||
}, [open]);
|
||||
}, [open, focusId, expand]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
@@ -52,6 +78,9 @@ export function AnnouncementsPanel({ open, onClose, announcements, markRead }: A
|
||||
|
||||
if (!open) return null;
|
||||
|
||||
const current = announcements.filter((a) => !a.archived);
|
||||
const earlier = announcements.filter((a) => a.archived);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="fixed inset-0 bg-black/60 z-[60]" onClick={onClose} aria-hidden="true" />
|
||||
@@ -78,46 +107,34 @@ export function AnnouncementsPanel({ open, onClose, announcements, markRead }: A
|
||||
</header>
|
||||
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
{announcements.length === 0 ? (
|
||||
{current.length === 0 ? (
|
||||
<p className="p-6 text-center text-bambu-gray">{t('announcements.empty')}</p>
|
||||
) : (
|
||||
<ul className="divide-y divide-bambu-dark-tertiary">
|
||||
{announcements.map((a) => {
|
||||
const text = announcementText(a, i18n.language);
|
||||
const link = isAllowedAnnouncementLink(a.link_url) ? a.link_url : null;
|
||||
return (
|
||||
<li key={a.id} className="px-5 py-4 space-y-2">
|
||||
<div className="flex items-center gap-2 flex-wrap text-xs">
|
||||
<span className={`px-2 py-0.5 rounded-full font-semibold ${LEVEL_CHIP[a.level]}`}>
|
||||
{t(`announcements.level.${a.level}`)}
|
||||
</span>
|
||||
{newIds.has(a.id) && (
|
||||
<span className="px-2 py-0.5 rounded-full font-semibold bg-bambu-green/20 text-bambu-green">
|
||||
{t('announcements.new')}
|
||||
</span>
|
||||
)}
|
||||
{a.published_at && (
|
||||
<span className="ml-auto text-bambu-gray">{formatDateOnly(a.published_at)}</span>
|
||||
)}
|
||||
</div>
|
||||
<h3 className="text-white font-semibold text-balance">{text.title}</h3>
|
||||
<p className="text-sm text-bambu-gray-light whitespace-pre-line break-words">{text.body}</p>
|
||||
{link && (
|
||||
<a
|
||||
href={link}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="inline-flex items-center gap-1 text-sm font-medium text-bambu-green hover:underline"
|
||||
>
|
||||
{text.link_label || t('announcements.readMore')}
|
||||
<ExternalLink className="w-3.5 h-3.5" />
|
||||
</a>
|
||||
)}
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
{current.map((a) => (
|
||||
<AnnouncementItem key={a.id} announcement={a} expanded={expanded.has(a.id)} onToggle={toggle} />
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
{earlier.length > 0 && (
|
||||
<section className="border-t border-bambu-dark-tertiary">
|
||||
<button
|
||||
onClick={() => setShowEarlier((v) => !v)}
|
||||
aria-expanded={showEarlier}
|
||||
className="w-full flex items-center gap-2 px-5 py-3 text-sm font-medium text-bambu-gray-light hover:text-white hover:bg-bambu-dark-tertiary transition-colors"
|
||||
>
|
||||
{showEarlier ? <ChevronDown className="w-4 h-4" /> : <ChevronRight className="w-4 h-4" />}
|
||||
{t('announcements.earlier', { count: earlier.length })}
|
||||
</button>
|
||||
{showEarlier && (
|
||||
<ul className="divide-y divide-bambu-dark-tertiary opacity-80">
|
||||
{earlier.map((a) => (
|
||||
<AnnouncementItem key={a.id} announcement={a} expanded={expanded.has(a.id)} onToggle={toggle} />
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</section>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<footer className="px-5 py-3 border-t border-bambu-dark-tertiary text-xs text-bambu-gray">
|
||||
@@ -127,3 +144,67 @@ export function AnnouncementsPanel({ open, onClose, announcements, markRead }: A
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
interface AnnouncementItemProps {
|
||||
announcement: Announcement;
|
||||
expanded: boolean;
|
||||
onToggle: (id: string) => void;
|
||||
}
|
||||
|
||||
function AnnouncementItem({ announcement: a, expanded, onToggle }: AnnouncementItemProps) {
|
||||
const { t, i18n } = useTranslation();
|
||||
const text = announcementText(a, i18n.language);
|
||||
const link = isAllowedAnnouncementLink(a.link_url) ? a.link_url : null;
|
||||
const unread = !a.read && !a.archived;
|
||||
const bodyId = `announcement-body-${a.id}`;
|
||||
return (
|
||||
<li>
|
||||
<button
|
||||
onClick={() => onToggle(a.id)}
|
||||
aria-expanded={expanded}
|
||||
aria-controls={bodyId}
|
||||
className="w-full text-left px-5 py-3 flex items-start gap-3 hover:bg-bambu-dark-tertiary transition-colors"
|
||||
>
|
||||
<span className="mt-1.5 w-2 h-2 flex-shrink-0 rounded-full" aria-hidden="true">
|
||||
{unread && <span className="block w-2 h-2 rounded-full bg-bambu-green" />}
|
||||
</span>
|
||||
<span className="flex-1 min-w-0 space-y-1">
|
||||
<span className="flex items-center gap-2 flex-wrap text-xs">
|
||||
<span className={`px-2 py-0.5 rounded-full font-semibold ${LEVEL_CHIP[a.level]}`}>
|
||||
{t(`announcements.level.${a.level}`)}
|
||||
</span>
|
||||
{unread && (
|
||||
<span className="px-2 py-0.5 rounded-full font-semibold bg-bambu-green/20 text-bambu-green">
|
||||
{t('announcements.new')}
|
||||
</span>
|
||||
)}
|
||||
{a.published_at && <span className="ml-auto text-bambu-gray">{formatDateOnly(a.published_at)}</span>}
|
||||
</span>
|
||||
<span className={`block text-balance ${unread ? 'text-white font-semibold' : 'text-bambu-gray-light font-medium'}`}>
|
||||
{text.title}
|
||||
</span>
|
||||
</span>
|
||||
<ChevronDown
|
||||
className={`w-4 h-4 mt-1 flex-shrink-0 text-bambu-gray transition-transform ${expanded ? 'rotate-180' : ''}`}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</button>
|
||||
{expanded && (
|
||||
<div id={bodyId} className="pl-10 pr-5 pb-4 space-y-2">
|
||||
<p className="text-sm text-bambu-gray-light whitespace-pre-line break-words">{text.body}</p>
|
||||
{link && (
|
||||
<a
|
||||
href={link}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="inline-flex items-center gap-1 text-sm font-medium text-bambu-green hover:underline"
|
||||
>
|
||||
{text.link_label || t('announcements.readMore')}
|
||||
<ExternalLink className="w-3.5 h-3.5" />
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</li>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -157,8 +157,10 @@ export function Layout() {
|
||||
const { announcements, unread: unreadAnnouncements, bannerItems, markRead: markAnnouncementRead } =
|
||||
useAnnouncements();
|
||||
const [announcementsOpen, setAnnouncementsOpen] = useState(false);
|
||||
const openAnnouncements = useCallback(() => {
|
||||
const [announcementFocus, setAnnouncementFocus] = useState<string | null>(null);
|
||||
const openAnnouncements = useCallback((focusId?: string) => {
|
||||
setMobileDrawerOpen(false);
|
||||
setAnnouncementFocus(focusId ?? null);
|
||||
setAnnouncementsOpen(true);
|
||||
}, []);
|
||||
const closeAnnouncements = useCallback(() => setAnnouncementsOpen(false), []);
|
||||
@@ -746,29 +748,11 @@ export function Layout() {
|
||||
{/* Footer */}
|
||||
<div className="flex-shrink-0 p-2 border-t border-bambu-dark-tertiary">
|
||||
{isSidebarCompact || sidebarExpanded ? (
|
||||
<div className="flex flex-col gap-2 px-2">
|
||||
{/* Announcements: a row of its own above the icons, so it isn't one
|
||||
more glyph in a row people have stopped reading. Only while
|
||||
there is something to show. */}
|
||||
{announcements.length > 0 && (
|
||||
<button
|
||||
onClick={openAnnouncements}
|
||||
className="flex items-center gap-2 px-2 py-1.5 rounded-lg hover:bg-bambu-dark-tertiary transition-colors text-bambu-gray-light hover:text-white text-sm"
|
||||
>
|
||||
<Megaphone className="w-5 h-5 flex-shrink-0" />
|
||||
<span>{t('announcements.title')}</span>
|
||||
{unreadAnnouncements.length > 0 && (
|
||||
<span
|
||||
className="ml-auto min-w-[20px] h-5 px-1.5 flex items-center justify-center text-[11px] font-bold rounded-full bg-bambu-green text-white"
|
||||
aria-label={t('announcements.unread', { count: unreadAnnouncements.length })}
|
||||
>
|
||||
{unreadAnnouncements.length}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
{/* Top row: icons */}
|
||||
<div className="flex items-center justify-center gap-1 flex-wrap">
|
||||
<div className="flex flex-col gap-2">
|
||||
{/* Top row: icons. 32px each with no gap so seven fit the 239px
|
||||
of an expanded sidebar -- announcements, System, GitHub,
|
||||
shortcuts, theme, password, logout -- without wrapping. */}
|
||||
<div className="flex items-center justify-center flex-wrap [&>a]:p-1.5 [&>button]:p-1.5 [&>span]:p-1.5 [&>div>button]:p-1.5">
|
||||
{hasSwitchbarPlugs && (
|
||||
<div className="relative">
|
||||
<button
|
||||
@@ -785,6 +769,25 @@ export function Layout() {
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{announcements.length > 0 && (
|
||||
<button
|
||||
onClick={() => openAnnouncements()}
|
||||
className="relative p-2 rounded-lg hover:bg-bambu-dark-tertiary transition-colors text-bambu-gray-light hover:text-white"
|
||||
title={t('announcements.title')}
|
||||
aria-label={
|
||||
unreadAnnouncements.length > 0
|
||||
? t('announcements.unread', { count: unreadAnnouncements.length })
|
||||
: t('announcements.title')
|
||||
}
|
||||
>
|
||||
<Megaphone className="w-5 h-5" />
|
||||
{unreadAnnouncements.length > 0 && (
|
||||
<span className="absolute -top-0.5 -right-0.5 min-w-[16px] h-4 px-1 flex items-center justify-center text-[10px] font-bold rounded-full bg-bambu-green text-white">
|
||||
{unreadAnnouncements.length}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
{hasPermission('system:read') ? (
|
||||
<NavLink
|
||||
to="/system"
|
||||
@@ -892,7 +895,7 @@ export function Layout() {
|
||||
)}
|
||||
{announcements.length > 0 && (
|
||||
<button
|
||||
onClick={openAnnouncements}
|
||||
onClick={() => openAnnouncements()}
|
||||
className="relative p-2 rounded-lg hover:bg-bambu-dark-tertiary transition-colors text-bambu-gray-light hover:text-white"
|
||||
title={t('announcements.title')}
|
||||
aria-label={
|
||||
@@ -1065,6 +1068,7 @@ export function Layout() {
|
||||
onClose={closeAnnouncements}
|
||||
announcements={announcements}
|
||||
markRead={markAnnouncementRead}
|
||||
focusId={announcementFocus}
|
||||
/>
|
||||
|
||||
<UnknownSpoolModal
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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}})',
|
||||
|
||||
@@ -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}})',
|
||||
|
||||
@@ -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}})',
|
||||
|
||||
@@ -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}})',
|
||||
|
||||
@@ -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}})',
|
||||
|
||||
@@ -37,6 +37,7 @@ export default {
|
||||
title: 'お知らせ',
|
||||
unread: '未読のお知らせ: {{count}}',
|
||||
empty: '現在お知らせはありません。',
|
||||
earlier: '過去のお知らせ ({{count}})',
|
||||
new: '新着',
|
||||
readMore: '詳細を見る',
|
||||
readMoreCount: '詳細を見る (+{{count}})',
|
||||
|
||||
@@ -34,6 +34,7 @@ export default {
|
||||
title: '공지사항',
|
||||
unread: '읽지 않은 공지사항: {{count}}',
|
||||
empty: '현재 공지사항이 없습니다.',
|
||||
earlier: '이전 공지 ({{count}})',
|
||||
new: '새 소식',
|
||||
readMore: '자세히 보기',
|
||||
readMoreCount: '자세히 보기 (+{{count}})',
|
||||
|
||||
@@ -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}})',
|
||||
|
||||
@@ -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}})',
|
||||
|
||||
@@ -34,6 +34,7 @@ export default {
|
||||
title: 'Объявления',
|
||||
unread: 'Непрочитанные объявления: {{count}}',
|
||||
empty: 'Сейчас объявлений нет.',
|
||||
earlier: 'Ранее ({{count}})',
|
||||
new: 'Новое',
|
||||
readMore: 'Подробнее',
|
||||
readMoreCount: 'Подробнее (+{{count}})',
|
||||
|
||||
@@ -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}})',
|
||||
|
||||
@@ -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}})',
|
||||
|
||||
@@ -37,6 +37,7 @@ export default {
|
||||
title: 'Оголошення',
|
||||
unread: 'Непрочитані оголошення: {{count}}',
|
||||
empty: 'Наразі оголошень немає.',
|
||||
earlier: 'Раніше ({{count}})',
|
||||
new: 'Нове',
|
||||
readMore: 'Докладніше',
|
||||
readMoreCount: 'Докладніше (+{{count}})',
|
||||
|
||||
@@ -37,6 +37,7 @@ export default {
|
||||
title: '公告',
|
||||
unread: '未读公告:{{count}}',
|
||||
empty: '暂无公告。',
|
||||
earlier: '更早 ({{count}})',
|
||||
new: '新',
|
||||
readMore: '了解更多',
|
||||
readMoreCount: '了解更多 (+{{count}})',
|
||||
|
||||
@@ -37,6 +37,7 @@ export default {
|
||||
title: '公告',
|
||||
unread: '未讀公告:{{count}}',
|
||||
empty: '目前沒有公告。',
|
||||
earlier: '更早 ({{count}})',
|
||||
new: '新',
|
||||
readMore: '了解更多',
|
||||
readMoreCount: '了解更多 (+{{count}})',
|
||||
|
||||
+1
-1
@@ -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};
|
||||
+2
-2
File diff suppressed because one or more lines are too long
+1
-1
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
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user