AnnouncementsPanel.tsx 8.1 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210
  1. import { useCallback, useEffect, useRef, useState } from 'react';
  2. import { useTranslation } from 'react-i18next';
  3. import { ChevronDown, ChevronRight, ExternalLink, Megaphone, X } from 'lucide-react';
  4. import type { Announcement, AnnouncementLevel } from '../api/client';
  5. import { announcementText, isAllowedAnnouncementLink } from '../hooks/useAnnouncements';
  6. import { formatDateOnly } from '../utils/date';
  7. const LEVEL_CHIP: Record<AnnouncementLevel, string> = {
  8. info: 'bg-sky-100 text-sky-800 dark:bg-sky-500/20 dark:text-sky-300',
  9. important: 'bg-amber-100 text-amber-800 dark:bg-amber-500/20 dark:text-amber-300',
  10. critical: 'bg-red-100 text-red-800 dark:bg-red-500/20 dark:text-red-300',
  11. };
  12. interface AnnouncementsPanelProps {
  13. open: boolean;
  14. onClose: () => void;
  15. announcements: Announcement[];
  16. markRead: (id: string) => void;
  17. // Expanded when the panel opens: the banner's "Read more" lands on its message.
  18. focusId?: string | null;
  19. }
  20. /**
  21. * Slide-over list of announcements from the Bambuddy maintainers.
  22. *
  23. * Each message is one row -- level, date, title -- and opens in place. Unread
  24. * ones carry a dot and a "New" chip until opened; opening is what marks a
  25. * message read, so the sidebar count is what is still unopened. Expired messages
  26. * the feed keeps as history sit in a collapsed "Earlier" section below; they are
  27. * never unread.
  28. */
  29. export function AnnouncementsPanel({ open, onClose, announcements, markRead, focusId }: AnnouncementsPanelProps) {
  30. const { t } = useTranslation();
  31. const [expanded, setExpanded] = useState<Set<string>>(() => new Set());
  32. const [showEarlier, setShowEarlier] = useState(false);
  33. const closeRef = useRef<HTMLButtonElement>(null);
  34. const latest = useRef({ announcements, markRead });
  35. latest.current = { announcements, markRead };
  36. const expand = useCallback((id: string) => {
  37. setExpanded((prev) => new Set(prev).add(id));
  38. const a = latest.current.announcements.find((x) => x.id === id);
  39. if (a && !a.read && !a.archived) latest.current.markRead(id);
  40. }, []);
  41. const toggle = useCallback(
  42. (id: string) => {
  43. if (expanded.has(id)) {
  44. setExpanded((prev) => {
  45. const next = new Set(prev);
  46. next.delete(id);
  47. return next;
  48. });
  49. } else {
  50. expand(id);
  51. }
  52. },
  53. [expanded, expand]
  54. );
  55. useEffect(() => {
  56. if (!open) return;
  57. // Every visit starts collapsed, apart from the message the banner pointed at.
  58. setExpanded(new Set());
  59. setShowEarlier(false);
  60. if (focusId) expand(focusId);
  61. closeRef.current?.focus();
  62. }, [open, focusId, expand]);
  63. useEffect(() => {
  64. if (!open) return;
  65. const onKey = (e: KeyboardEvent) => {
  66. if (e.key === 'Escape') onClose();
  67. };
  68. window.addEventListener('keydown', onKey);
  69. return () => window.removeEventListener('keydown', onKey);
  70. }, [open, onClose]);
  71. if (!open) return null;
  72. const current = announcements.filter((a) => !a.archived);
  73. const earlier = announcements.filter((a) => a.archived);
  74. return (
  75. <>
  76. <div className="fixed inset-0 bg-black/60 z-[60]" onClick={onClose} aria-hidden="true" />
  77. <aside
  78. role="dialog"
  79. aria-modal="true"
  80. aria-labelledby="announcements-title"
  81. className="fixed inset-y-0 right-0 z-[61] w-full max-w-md bg-bambu-dark-secondary border-l border-bambu-dark-tertiary flex flex-col shadow-2xl"
  82. >
  83. <header className="flex items-center gap-3 px-5 py-4 border-b border-bambu-dark-tertiary">
  84. <Megaphone className="w-5 h-5 text-bambu-green" />
  85. <h2 id="announcements-title" className="text-lg font-semibold text-white">
  86. {t('announcements.title')}
  87. </h2>
  88. <button
  89. ref={closeRef}
  90. onClick={onClose}
  91. className="ml-auto p-2 -mr-2 rounded-lg text-bambu-gray-light hover:text-white hover:bg-bambu-dark-tertiary transition-colors"
  92. aria-label={t('common.close')}
  93. title={t('common.close')}
  94. >
  95. <X className="w-5 h-5" />
  96. </button>
  97. </header>
  98. <div className="flex-1 overflow-y-auto">
  99. {current.length === 0 ? (
  100. <p className="p-6 text-center text-bambu-gray">{t('announcements.empty')}</p>
  101. ) : (
  102. <ul className="divide-y divide-bambu-dark-tertiary">
  103. {current.map((a) => (
  104. <AnnouncementItem key={a.id} announcement={a} expanded={expanded.has(a.id)} onToggle={toggle} />
  105. ))}
  106. </ul>
  107. )}
  108. {earlier.length > 0 && (
  109. <section className="border-t border-bambu-dark-tertiary">
  110. <button
  111. onClick={() => setShowEarlier((v) => !v)}
  112. aria-expanded={showEarlier}
  113. 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"
  114. >
  115. {showEarlier ? <ChevronDown className="w-4 h-4" /> : <ChevronRight className="w-4 h-4" />}
  116. {t('announcements.earlier', { count: earlier.length })}
  117. </button>
  118. {showEarlier && (
  119. <ul className="divide-y divide-bambu-dark-tertiary opacity-80">
  120. {earlier.map((a) => (
  121. <AnnouncementItem key={a.id} announcement={a} expanded={expanded.has(a.id)} onToggle={toggle} />
  122. ))}
  123. </ul>
  124. )}
  125. </section>
  126. )}
  127. </div>
  128. <footer className="px-5 py-3 border-t border-bambu-dark-tertiary text-xs text-bambu-gray">
  129. {t('announcements.source')}
  130. </footer>
  131. </aside>
  132. </>
  133. );
  134. }
  135. interface AnnouncementItemProps {
  136. announcement: Announcement;
  137. expanded: boolean;
  138. onToggle: (id: string) => void;
  139. }
  140. function AnnouncementItem({ announcement: a, expanded, onToggle }: AnnouncementItemProps) {
  141. const { t, i18n } = useTranslation();
  142. const text = announcementText(a, i18n.language);
  143. const link = isAllowedAnnouncementLink(a.link_url) ? a.link_url : null;
  144. const unread = !a.read && !a.archived;
  145. const bodyId = `announcement-body-${a.id}`;
  146. return (
  147. <li>
  148. <button
  149. onClick={() => onToggle(a.id)}
  150. aria-expanded={expanded}
  151. aria-controls={bodyId}
  152. className="w-full text-left px-5 py-3 flex items-start gap-3 hover:bg-bambu-dark-tertiary transition-colors"
  153. >
  154. <span className="mt-1.5 w-2 h-2 flex-shrink-0 rounded-full" aria-hidden="true">
  155. {unread && <span className="block w-2 h-2 rounded-full bg-bambu-green" />}
  156. </span>
  157. <span className="flex-1 min-w-0 space-y-1">
  158. <span className="flex items-center gap-2 flex-wrap text-xs">
  159. <span className={`px-2 py-0.5 rounded-full font-semibold ${LEVEL_CHIP[a.level]}`}>
  160. {t(`announcements.level.${a.level}`)}
  161. </span>
  162. {unread && (
  163. <span className="px-2 py-0.5 rounded-full font-semibold bg-bambu-green/20 text-bambu-green">
  164. {t('announcements.new')}
  165. </span>
  166. )}
  167. {a.published_at && <span className="ml-auto text-bambu-gray">{formatDateOnly(a.published_at)}</span>}
  168. </span>
  169. <span className={`block text-balance ${unread ? 'text-white font-semibold' : 'text-bambu-gray-light font-medium'}`}>
  170. {text.title}
  171. </span>
  172. </span>
  173. <ChevronDown
  174. className={`w-4 h-4 mt-1 flex-shrink-0 text-bambu-gray transition-transform ${expanded ? 'rotate-180' : ''}`}
  175. aria-hidden="true"
  176. />
  177. </button>
  178. {expanded && (
  179. <div id={bodyId} className="pl-10 pr-5 pb-4 space-y-2">
  180. <p className="text-sm text-bambu-gray-light whitespace-pre-line break-words">{text.body}</p>
  181. {link && (
  182. <a
  183. href={link}
  184. target="_blank"
  185. rel="noopener noreferrer"
  186. className="inline-flex items-center gap-1 text-sm font-medium text-bambu-green hover:underline"
  187. >
  188. {text.link_label || t('announcements.readMore')}
  189. <ExternalLink className="w-3.5 h-3.5" />
  190. </a>
  191. )}
  192. </div>
  193. )}
  194. </li>
  195. );
  196. }