import { useCallback, useEffect, useRef, useState } from 'react'; import { useTranslation } from 'react-i18next'; 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'; const LEVEL_CHIP: Record = { info: 'bg-sky-100 text-sky-800 dark:bg-sky-500/20 dark:text-sky-300', important: 'bg-amber-100 text-amber-800 dark:bg-amber-500/20 dark:text-amber-300', critical: 'bg-red-100 text-red-800 dark:bg-red-500/20 dark:text-red-300', }; interface AnnouncementsPanelProps { open: boolean; 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. * * 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, focusId }: AnnouncementsPanelProps) { const { t } = useTranslation(); const [expanded, setExpanded] = useState>(() => new Set()); const [showEarlier, setShowEarlier] = useState(false); const closeRef = useRef(null); 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; // 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, focusId, expand]); useEffect(() => { if (!open) return; const onKey = (e: KeyboardEvent) => { if (e.key === 'Escape') onClose(); }; window.addEventListener('keydown', onKey); return () => window.removeEventListener('keydown', onKey); }, [open, onClose]); if (!open) return null; const current = announcements.filter((a) => !a.archived); const earlier = announcements.filter((a) => a.archived); return ( <>