AnnouncementsPanel.tsx 5.3 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129
  1. import { useEffect, useRef, useState } from 'react';
  2. import { useTranslation } from 'react-i18next';
  3. import { 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. }
  18. /**
  19. * Slide-over list of announcements from the Bambuddy maintainers.
  20. *
  21. * Opening it marks everything in it read -- the dot and the banner go -- but the
  22. * ones that were unread keep a "New" chip until it closes, so the reader can
  23. * still tell what they came for.
  24. */
  25. export function AnnouncementsPanel({ open, onClose, announcements, markRead }: AnnouncementsPanelProps) {
  26. const { t, i18n } = useTranslation();
  27. const [newIds, setNewIds] = useState<Set<string>>(() => new Set());
  28. const closeRef = useRef<HTMLButtonElement>(null);
  29. // The list at the moment of opening; later refetches don't re-mark anything.
  30. const latest = useRef({ announcements, markRead });
  31. latest.current = { announcements, markRead };
  32. useEffect(() => {
  33. if (!open) return;
  34. const unreadIds = latest.current.announcements.filter((a) => !a.read).map((a) => a.id);
  35. setNewIds(new Set(unreadIds));
  36. unreadIds.forEach((id) => latest.current.markRead(id));
  37. closeRef.current?.focus();
  38. }, [open]);
  39. useEffect(() => {
  40. if (!open) return;
  41. const onKey = (e: KeyboardEvent) => {
  42. if (e.key === 'Escape') onClose();
  43. };
  44. window.addEventListener('keydown', onKey);
  45. return () => window.removeEventListener('keydown', onKey);
  46. }, [open, onClose]);
  47. if (!open) return null;
  48. return (
  49. <>
  50. <div className="fixed inset-0 bg-black/60 z-[60]" onClick={onClose} aria-hidden="true" />
  51. <aside
  52. role="dialog"
  53. aria-modal="true"
  54. aria-labelledby="announcements-title"
  55. 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"
  56. >
  57. <header className="flex items-center gap-3 px-5 py-4 border-b border-bambu-dark-tertiary">
  58. <Megaphone className="w-5 h-5 text-bambu-green" />
  59. <h2 id="announcements-title" className="text-lg font-semibold text-white">
  60. {t('announcements.title')}
  61. </h2>
  62. <button
  63. ref={closeRef}
  64. onClick={onClose}
  65. className="ml-auto p-2 -mr-2 rounded-lg text-bambu-gray-light hover:text-white hover:bg-bambu-dark-tertiary transition-colors"
  66. aria-label={t('common.close')}
  67. title={t('common.close')}
  68. >
  69. <X className="w-5 h-5" />
  70. </button>
  71. </header>
  72. <div className="flex-1 overflow-y-auto">
  73. {announcements.length === 0 ? (
  74. <p className="p-6 text-center text-bambu-gray">{t('announcements.empty')}</p>
  75. ) : (
  76. <ul className="divide-y divide-bambu-dark-tertiary">
  77. {announcements.map((a) => {
  78. const text = announcementText(a, i18n.language);
  79. const link = isAllowedAnnouncementLink(a.link_url) ? a.link_url : null;
  80. return (
  81. <li key={a.id} className="px-5 py-4 space-y-2">
  82. <div className="flex items-center gap-2 flex-wrap text-xs">
  83. <span className={`px-2 py-0.5 rounded-full font-semibold ${LEVEL_CHIP[a.level]}`}>
  84. {t(`announcements.level.${a.level}`)}
  85. </span>
  86. {newIds.has(a.id) && (
  87. <span className="px-2 py-0.5 rounded-full font-semibold bg-bambu-green/20 text-bambu-green">
  88. {t('announcements.new')}
  89. </span>
  90. )}
  91. {a.published_at && (
  92. <span className="ml-auto text-bambu-gray">{formatDateOnly(a.published_at)}</span>
  93. )}
  94. </div>
  95. <h3 className="text-white font-semibold text-balance">{text.title}</h3>
  96. <p className="text-sm text-bambu-gray-light whitespace-pre-line break-words">{text.body}</p>
  97. {link && (
  98. <a
  99. href={link}
  100. target="_blank"
  101. rel="noopener noreferrer"
  102. className="inline-flex items-center gap-1 text-sm font-medium text-bambu-green hover:underline"
  103. >
  104. {text.link_label || t('announcements.readMore')}
  105. <ExternalLink className="w-3.5 h-3.5" />
  106. </a>
  107. )}
  108. </li>
  109. );
  110. })}
  111. </ul>
  112. )}
  113. </div>
  114. <footer className="px-5 py-3 border-t border-bambu-dark-tertiary text-xs text-bambu-gray">
  115. {t('announcements.source')}
  116. </footer>
  117. </aside>
  118. </>
  119. );
  120. }