diff --git a/locales/de.json b/locales/de.json index 624a74b3..1d2d9f64 100644 --- a/locales/de.json +++ b/locales/de.json @@ -198,6 +198,7 @@ }, "actions": { "checkUpdates": "Updates prüfen", + "notifications": "Benachrichtigungen", "support": "Unterstützung" } }, @@ -1066,6 +1067,11 @@ }, "update": { "title": "Nach Updates suchen", + "notificationsTitle": "Benachrichtigungszentrum", + "tabs": { + "updates": "Aktualisierungen", + "messages": "Mitteilungen" + }, "updateAvailable": "Update verfügbar", "noChangelogAvailable": "Kein detailliertes Changelog verfügbar. Weitere Informationen auf GitHub.", "currentVersion": "Aktuelle Version", @@ -1097,6 +1103,13 @@ "nightly": { "warning": "Warnung: Nightly Builds können experimentelle Funktionen enthalten und könnten instabil sein.", "enable": "Nightly Updates aktivieren" + }, + "banners": { + "recent": "Neueste Mitteilungen", + "empty": "Keine aktuellen Banner verfügbar.", + "shown": "{time} angezeigt", + "dismissed": "{time} geschlossen", + "active": "Aktiv" } }, "support": { diff --git a/locales/en.json b/locales/en.json index f83b3f6d..826e5055 100644 --- a/locales/en.json +++ b/locales/en.json @@ -198,6 +198,7 @@ }, "actions": { "checkUpdates": "Check Updates", + "notifications": "Notifications", "support": "Support" } }, @@ -1065,6 +1066,11 @@ }, "update": { "title": "Check for Updates", + "notificationsTitle": "Notifications", + "tabs": { + "updates": "Updates", + "messages": "Messages" + }, "updateAvailable": "Update Available", "noChangelogAvailable": "No detailed changelog available. Check GitHub for more information.", "currentVersion": "Current Version", @@ -1096,6 +1102,13 @@ "nightly": { "warning": "Warning: Nightly builds may contain experimental features and could be unstable.", "enable": "Enable Nightly Updates" + }, + "banners": { + "recent": "Recent messages", + "empty": "No recent banners yet.", + "shown": "Shown {time}", + "dismissed": "Dismissed {time}", + "active": "Active" } }, "support": { diff --git a/locales/es.json b/locales/es.json index e5c6e058..82b5d629 100644 --- a/locales/es.json +++ b/locales/es.json @@ -198,6 +198,7 @@ }, "actions": { "checkUpdates": "Comprobar actualizaciones", + "notifications": "Notificaciones", "support": "Soporte" } }, @@ -1065,6 +1066,11 @@ }, "update": { "title": "Comprobar actualizaciones", + "notificationsTitle": "Centro de notificaciones", + "tabs": { + "updates": "Actualizaciones", + "messages": "Mensajes" + }, "updateAvailable": "Actualización disponible", "noChangelogAvailable": "No hay registro de cambios detallado disponible. Revisa GitHub para más información.", "currentVersion": "Versión actual", @@ -1096,6 +1102,13 @@ "nightly": { "warning": "Advertencia: Las compilaciones nocturnas pueden contener características experimentales y podrían ser inestables.", "enable": "Habilitar actualizaciones nocturnas" + }, + "banners": { + "recent": "Notificaciones recientes", + "empty": "No hay banners recientes.", + "shown": "Mostrado {time}", + "dismissed": "Descartado {time}", + "active": "Activo" } }, "support": { diff --git a/locales/fr.json b/locales/fr.json index 17480348..11d2d649 100644 --- a/locales/fr.json +++ b/locales/fr.json @@ -198,6 +198,7 @@ }, "actions": { "checkUpdates": "Vérifier les mises à jour", + "notifications": "Notifications", "support": "Support" } }, @@ -1065,6 +1066,11 @@ }, "update": { "title": "Vérifier les mises à jour", + "notificationsTitle": "Notifications", + "tabs": { + "updates": "Mises à jour", + "messages": "Messages" + }, "updateAvailable": "Mise à jour disponible", "noChangelogAvailable": "Aucun journal des modifications détaillé disponible. Consultez GitHub pour plus d'informations.", "currentVersion": "Version actuelle", @@ -1096,6 +1102,13 @@ "nightly": { "warning": "Attention : Les versions nightly peuvent contenir des fonctionnalités expérimentales et être instables.", "enable": "Activer les mises à jour nightly" + }, + "banners": { + "recent": "Messages récents", + "empty": "Aucune bannière récente.", + "shown": "Affiché {time}", + "dismissed": "Ignoré {time}", + "active": "Actif" } }, "support": { diff --git a/locales/he.json b/locales/he.json index 13e15085..be80306b 100644 --- a/locales/he.json +++ b/locales/he.json @@ -198,6 +198,7 @@ }, "actions": { "checkUpdates": "בדוק עדכונים", + "notifications": "התראות", "support": "תמיכה" } }, @@ -1065,6 +1066,11 @@ }, "update": { "title": "בדוק עדכונים", + "notificationsTitle": "מרכז התראות", + "tabs": { + "updates": "עדכונים", + "messages": "הודעות" + }, "updateAvailable": "עדכון זמין", "noChangelogAvailable": "אין יומן שינויים מפורט זמין. בדוק ב-GitHub למידע נוסף.", "currentVersion": "גרסה נוכחית", @@ -1096,6 +1102,13 @@ "nightly": { "warning": "אזהרה: גרסאות ליליות עשויות להכיל תכונות ניסיוניות ועלולות להיות לא יציבות.", "enable": "הפעל עדכונים ליליים" + }, + "banners": { + "recent": "הודעות אחרונות", + "empty": "אין כרגע באנרים אחרונים.", + "shown": "הוצג {time}", + "dismissed": "הוסר {time}", + "active": "פעיל" } }, "support": { diff --git a/locales/ja.json b/locales/ja.json index 6d1121ed..08388178 100644 --- a/locales/ja.json +++ b/locales/ja.json @@ -198,6 +198,7 @@ }, "actions": { "checkUpdates": "更新確認", + "notifications": "通知", "support": "サポート" } }, @@ -1065,6 +1066,11 @@ }, "update": { "title": "更新確認", + "notificationsTitle": "通知センター", + "tabs": { + "updates": "更新", + "messages": "メッセージ" + }, "updateAvailable": "更新が利用可能", "noChangelogAvailable": "詳細な変更ログは利用できません。詳細はGitHubでご確認ください。", "currentVersion": "現在のバージョン", @@ -1096,6 +1102,13 @@ "nightly": { "warning": "警告:ナイトリービルドには実験的機能が含まれており、不安定な場合があります。", "enable": "ナイトリー更新を有効にする" + }, + "banners": { + "recent": "最近の通知", + "empty": "最近のバナーはありません。", + "shown": "{time} に表示", + "dismissed": "{time} に非表示", + "active": "アクティブ" } }, "support": { diff --git a/locales/ko.json b/locales/ko.json index 7b7b67a8..ab170ce4 100644 --- a/locales/ko.json +++ b/locales/ko.json @@ -198,6 +198,7 @@ }, "actions": { "checkUpdates": "업데이트 확인", + "notifications": "알림", "support": "지원" } }, @@ -1065,6 +1066,11 @@ }, "update": { "title": "업데이트 확인", + "notificationsTitle": "알림 센터", + "tabs": { + "updates": "업데이트", + "messages": "메시지" + }, "updateAvailable": "업데이트 사용 가능", "noChangelogAvailable": "상세한 변경 로그가 없습니다. 더 많은 정보는 GitHub를 확인하세요.", "currentVersion": "현재 버전", @@ -1096,6 +1102,13 @@ "nightly": { "warning": "경고: 나이틀리 빌드는 실험적 기능을 포함할 수 있으며 불안정할 수 있습니다.", "enable": "나이틀리 업데이트 활성화" + }, + "banners": { + "recent": "최근 알림", + "empty": "최근 배너가 없습니다.", + "shown": "{time}에 표시", + "dismissed": "{time}에 닫힘", + "active": "활성" } }, "support": { diff --git a/locales/ru.json b/locales/ru.json index 172f701d..19ff8ead 100644 --- a/locales/ru.json +++ b/locales/ru.json @@ -198,6 +198,7 @@ }, "actions": { "checkUpdates": "Проверить обновления", + "notifications": "Уведомления", "support": "Поддержка" } }, @@ -1065,6 +1066,11 @@ }, "update": { "title": "Проверить обновления", + "notificationsTitle": "Центр уведомлений", + "tabs": { + "updates": "Обновления", + "messages": "Сообщения" + }, "updateAvailable": "Доступно обновление", "noChangelogAvailable": "Подробный список изменений недоступен. Проверьте GitHub для получения дополнительной информации.", "currentVersion": "Текущая версия", @@ -1096,6 +1102,13 @@ "nightly": { "warning": "Предупреждение: Ночные сборки могут содержать экспериментальные функции и могут быть нестабильными.", "enable": "Включить ночные обновления" + }, + "banners": { + "recent": "Недавние уведомления", + "empty": "Недавних баннеров нет.", + "shown": "Показано {time}", + "dismissed": "Закрыто {time}", + "active": "Активно" } }, "support": { diff --git a/locales/zh-CN.json b/locales/zh-CN.json index 7a5f3a5c..eb51f513 100644 --- a/locales/zh-CN.json +++ b/locales/zh-CN.json @@ -198,6 +198,7 @@ }, "actions": { "checkUpdates": "检查更新", + "notifications": "通知", "support": "支持" } }, @@ -1065,6 +1066,11 @@ }, "update": { "title": "检查更新", + "notificationsTitle": "通知中心", + "tabs": { + "updates": "更新", + "messages": "消息" + }, "updateAvailable": "更新可用", "noChangelogAvailable": "没有详细的更新日志可用。请查看 GitHub 以获取更多信息。", "currentVersion": "当前版本", @@ -1096,6 +1102,13 @@ "nightly": { "warning": "警告:Nightly 版本可能包含实验性功能,可能不稳定。", "enable": "启用 Nightly 更新" + }, + "banners": { + "recent": "最近的通知", + "empty": "暂无最近的横幅通知。", + "shown": "{time} 显示", + "dismissed": "{time} 关闭", + "active": "仍在显示" } }, "support": { diff --git a/locales/zh-TW.json b/locales/zh-TW.json index a56363ec..9919efff 100644 --- a/locales/zh-TW.json +++ b/locales/zh-TW.json @@ -198,6 +198,7 @@ }, "actions": { "checkUpdates": "檢查更新", + "notifications": "通知", "support": "支援" } }, @@ -1065,6 +1066,11 @@ }, "update": { "title": "檢查更新", + "notificationsTitle": "通知中心", + "tabs": { + "updates": "更新", + "messages": "訊息" + }, "updateAvailable": "有新版本可用", "noChangelogAvailable": "無詳細更新日誌。請至 GitHub 查看更多資訊。", "currentVersion": "目前版本", @@ -1096,6 +1102,13 @@ "nightly": { "warning": "警告:Nightly 版本可能包含實驗性功能且可能不穩定。", "enable": "啟用 Nightly 更新" + }, + "banners": { + "recent": "最新通知", + "empty": "目前沒有最近的橫幅通知。", + "shown": "{time} 顯示", + "dismissed": "{time} 關閉", + "active": "仍在顯示" } }, "support": { diff --git a/static/css/components/update-modal.css b/static/css/components/update-modal.css index 853ea3e8..a64237ba 100644 --- a/static/css/components/update-modal.css +++ b/static/css/components/update-modal.css @@ -12,6 +12,73 @@ border-bottom: 1px solid var(--lora-border); } +.notification-tabs { + display: flex; + gap: var(--space-2); + margin-bottom: var(--space-3); +} + +.notification-tab { + display: inline-flex; + align-items: center; + gap: var(--space-1); + padding: 0.5rem 0.75rem; + background: var(--lora-surface); + border: 1px solid var(--lora-border); + border-radius: var(--border-radius-sm); + color: var(--text-color); + cursor: pointer; + transition: background 0.2s ease, border-color 0.2s ease; + font-weight: 500; +} + +.notification-tab:hover, +.notification-tab.active { + background: var(--lora-accent-light, rgba(0, 148, 255, 0.12)); + border-color: var(--lora-accent); + color: var(--lora-accent-text, var(--text-color)); +} + +.notification-tab-badge { + display: none; + min-width: 1.25rem; + height: 1.25rem; + padding: 0 0.4rem; + border-radius: 999px; + background: var(--lora-accent); + color: #fff; + font-size: 0.75rem; + font-weight: 600; + align-items: center; + justify-content: center; +} + +.notification-tab-badge.is-dot { + min-width: 0.5rem; + width: 0.5rem; + height: 0.5rem; + padding: 0; + border-radius: 50%; +} + +.notification-tab-badge.visible { + display: inline-flex; +} + +.notification-panels { + display: flex; + flex-direction: column; + gap: var(--space-3); +} + +.notification-panel { + display: none; +} + +.notification-panel.active { + display: block; +} + .update-icon { font-size: 1.8em; color: var(--lora-accent); @@ -165,6 +232,137 @@ justify-content: flex-start; } +.banner-history { + display: flex; + flex-direction: column; + gap: var(--space-2); +} + +.banner-history h3 { + margin: 0; + font-size: 1.05rem; + color: var(--lora-accent); +} + +.banner-history-empty { + margin: 0; + padding: var(--space-3); + background: var(--lora-surface); + border: 1px dashed var(--lora-border); + border-radius: var(--border-radius-sm); + text-align: center; + color: var(--text-muted, rgba(0, 0, 0, 0.6)); +} + +.banner-history-list { + list-style: none; + margin: 0; + padding: 0; + display: flex; + flex-direction: column; + gap: var(--space-2); +} + +.banner-history-item { + border: 1px solid var(--lora-border); + border-radius: var(--border-radius-sm); + padding: var(--space-2); + background: var(--card-bg, #fff); + display: flex; + flex-direction: column; + gap: var(--space-1); +} + +[data-theme="dark"] .banner-history-item { + background: rgba(255, 255, 255, 0.03); +} + +.banner-history-title { + margin: 0; + font-size: 1rem; +} + +.banner-history-description { + margin: 0; + color: var(--text-color); + opacity: 0.85; +} + +.banner-history-meta { + display: flex; + gap: var(--space-2); + font-size: 0.85rem; + color: var(--text-muted, rgba(0, 0, 0, 0.6)); + flex-wrap: wrap; +} + +.banner-history-time { + display: inline-flex; + align-items: center; +} + +.banner-history-status { + display: inline-flex; + align-items: center; + gap: 0.35rem; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.05em; +} + +.banner-history-status.active { + color: var(--lora-success); +} + +.banner-history-status.dismissed { + color: var(--lora-error); +} + +.banner-history-actions { + display: flex; + flex-wrap: wrap; + gap: var(--space-2); + margin-top: var(--space-1); +} + +.banner-history-action { + display: inline-flex; + align-items: center; + gap: 0.35rem; + padding: 0.35rem 0.65rem; + border-radius: var(--border-radius-sm); + border: 1px solid var(--lora-border); + text-decoration: none; + font-size: 0.85rem; + transition: background 0.2s ease, color 0.2s ease, border-color 0.2s ease; +} + +.banner-history-action i { + font-size: 0.9rem; +} + +.banner-history-action.banner-history-action-primary { + background: var(--lora-accent); + border-color: var(--lora-accent); + color: #fff; +} + +.banner-history-action.banner-history-action-secondary { + background: var(--lora-surface); + color: var(--text-color); +} + +.banner-history-action.banner-history-action-tertiary { + background: transparent; + border-style: dashed; +} + +.banner-history-action:hover { + background: var(--lora-accent-light, rgba(0, 148, 255, 0.12)); + border-color: var(--lora-accent); + color: var(--lora-accent-text, var(--text-color)); +} + /* Override toggle switch styles for update preferences */ .update-preferences .toggle-switch { position: relative; diff --git a/static/js/managers/BannerService.js b/static/js/managers/BannerService.js index 01d82d1f..52694e54 100644 --- a/static/js/managers/BannerService.js +++ b/static/js/managers/BannerService.js @@ -9,6 +9,10 @@ const COMMUNITY_SUPPORT_BANNER_DELAY_MS = 5 * 24 * 60 * 60 * 1000; // 5 days const COMMUNITY_SUPPORT_FIRST_SEEN_AT_KEY = 'community_support_banner_first_seen_at'; const COMMUNITY_SUPPORT_SHOWN_KEY = 'community_support_banner_shown'; const KO_FI_URL = 'https://ko-fi.com/pixelpawsai'; +const BANNER_HISTORY_KEY = 'banner_history'; +const BANNER_HISTORY_VIEWED_AT_KEY = 'banner_history_viewed_at'; +const BANNER_HISTORY_LIMIT = 20; +const HISTORY_EXCLUDED_IDS = new Set(['version-mismatch']); /** * Banner Service for managing notification banners @@ -20,6 +24,8 @@ class BannerService { this.initialized = false; this.communitySupportBannerTimer = null; this.communitySupportBannerRegistered = false; + this.recentHistory = this.loadBannerHistory(); + this.bannerHistoryViewedAt = this.loadBannerHistoryViewedAt(); } /** @@ -120,6 +126,8 @@ class BannerService { this.updateContainerVisibility(); }, 300); } + + this.markBannerDismissed(bannerId); } /** @@ -178,7 +186,9 @@ class BannerService { `; this.container.appendChild(bannerElement); - + + this.recordBannerAppearance(banner); + // Call onRegister callback if provided if (typeof banner.onRegister === 'function') { banner.onRegister(bannerElement); @@ -296,6 +306,95 @@ class BannerService { this.updateContainerVisibility(); } + + loadBannerHistory() { + const stored = getStorageItem(BANNER_HISTORY_KEY, []); + if (!Array.isArray(stored)) { + return []; + } + + return stored.slice(0, BANNER_HISTORY_LIMIT).map(entry => ({ + ...entry, + timestamp: typeof entry.timestamp === 'number' ? entry.timestamp : Date.now(), + dismissedAt: typeof entry.dismissedAt === 'number' ? entry.dismissedAt : null, + actions: Array.isArray(entry.actions) ? entry.actions : [] + })); + } + + loadBannerHistoryViewedAt() { + const stored = getStorageItem(BANNER_HISTORY_VIEWED_AT_KEY, 0); + return typeof stored === 'number' ? stored : 0; + } + + saveBannerHistory() { + setStorageItem(BANNER_HISTORY_KEY, this.recentHistory.slice(0, BANNER_HISTORY_LIMIT)); + } + + notifyBannerHistoryUpdated() { + window.dispatchEvent(new CustomEvent('lm:banner-history-updated')); + } + + recordBannerAppearance(banner) { + if (!banner?.id || HISTORY_EXCLUDED_IDS.has(banner.id)) { + return; + } + + const sanitizedActions = Array.isArray(banner.actions) + ? banner.actions.map(action => ({ + text: action.text, + icon: action.icon, + url: action.url || null, + type: action.type || 'secondary' + })) + : []; + + const entry = { + id: banner.id, + title: banner.title, + content: banner.content, + actions: sanitizedActions, + timestamp: Date.now(), + dismissedAt: null + }; + + this.recentHistory.unshift(entry); + if (this.recentHistory.length > BANNER_HISTORY_LIMIT) { + this.recentHistory.length = BANNER_HISTORY_LIMIT; + } + + this.saveBannerHistory(); + this.notifyBannerHistoryUpdated(); + } + + markBannerDismissed(bannerId) { + if (!bannerId || HISTORY_EXCLUDED_IDS.has(bannerId)) { + return; + } + + for (const entry of this.recentHistory) { + if (entry.id === bannerId && !entry.dismissedAt) { + entry.dismissedAt = Date.now(); + break; + } + } + + this.saveBannerHistory(); + this.notifyBannerHistoryUpdated(); + } + + getRecentBanners() { + return this.recentHistory.slice(); + } + + getUnreadBannerCount() { + return this.recentHistory.filter(entry => entry.timestamp > this.bannerHistoryViewedAt).length; + } + + markBannerHistoryViewed() { + this.bannerHistoryViewedAt = Date.now(); + setStorageItem(BANNER_HISTORY_VIEWED_AT_KEY, this.bannerHistoryViewedAt); + this.notifyBannerHistoryUpdated(); + } } // Create and export singleton instance diff --git a/static/js/managers/UpdateService.js b/static/js/managers/UpdateService.js index 56a83ccf..d93784fa 100644 --- a/static/js/managers/UpdateService.js +++ b/static/js/managers/UpdateService.js @@ -28,6 +28,9 @@ export class UpdateService { this.nightlyMode = getStorageItem('nightly_updates', false); this.currentVersionInfo = null; this.versionMismatch = false; + this.activeNotificationTab = 'updates'; + this.handleBannerHistoryUpdated = this.handleBannerHistoryUpdated.bind(this); + this.handleNotificationTabKeydown = this.handleNotificationTabKeydown.bind(this); } initialize() { @@ -61,6 +64,10 @@ export class UpdateService { }); this.updateNightlyWarning(); } + + this.setupNotificationCenter(); + window.addEventListener('lm:banner-history-updated', this.handleBannerHistoryUpdated); + this.updateTabBadges(); // Perform update check if needed this.checkForUpdates().then(() => { @@ -81,6 +88,272 @@ export class UpdateService { warning.style.display = this.nightlyMode ? 'flex' : 'none'; } } + + setupNotificationCenter() { + const modal = document.getElementById('updateModal'); + if (!modal) { + this.notificationTabs = []; + this.notificationPanels = []; + return; + } + + this.notificationTabs = Array.from(modal.querySelectorAll('[data-notification-tab]')); + this.notificationPanels = Array.from(modal.querySelectorAll('[data-notification-panel]')); + + this.notificationTabs.forEach(tab => { + tab.addEventListener('click', () => { + const tabName = tab.getAttribute('data-notification-tab'); + this.switchNotificationTab(tabName, { markRead: true }); + }); + tab.addEventListener('keydown', this.handleNotificationTabKeydown); + }); + + this.renderRecentBanners(); + this.switchNotificationTab(this.activeNotificationTab); + } + + switchNotificationTab(tabName, { markRead = false } = {}) { + if (!tabName) return; + + this.activeNotificationTab = tabName; + + if (Array.isArray(this.notificationTabs)) { + this.notificationTabs.forEach(tab => { + const isActive = tab.getAttribute('data-notification-tab') === tabName; + tab.classList.toggle('active', isActive); + tab.setAttribute('aria-selected', isActive ? 'true' : 'false'); + tab.setAttribute('tabindex', isActive ? '0' : '-1'); + }); + } + + if (Array.isArray(this.notificationPanels)) { + this.notificationPanels.forEach(panel => { + const isActive = panel.getAttribute('data-notification-panel') === tabName; + panel.classList.toggle('active', isActive); + panel.setAttribute('aria-hidden', isActive ? 'false' : 'true'); + panel.setAttribute('tabindex', isActive ? '0' : '-1'); + }); + } + + if (tabName === 'banners') { + this.renderRecentBanners(); + if (markRead && typeof bannerService.markBannerHistoryViewed === 'function') { + bannerService.markBannerHistoryViewed(); + } + } + + this.updateTabBadges(); + } + + handleNotificationTabKeydown(event) { + if (!Array.isArray(this.notificationTabs) || this.notificationTabs.length === 0) { + return; + } + + const { key } = event; + const supportedKeys = ['ArrowLeft', 'ArrowRight', 'ArrowUp', 'ArrowDown', 'Home', 'End']; + + if (!supportedKeys.includes(key)) { + return; + } + + event.preventDefault(); + + const currentIndex = this.notificationTabs.indexOf(event.currentTarget); + if (currentIndex === -1) { + return; + } + + let targetIndex = currentIndex; + + if (key === 'ArrowLeft' || key === 'ArrowUp') { + targetIndex = (currentIndex - 1 + this.notificationTabs.length) % this.notificationTabs.length; + } else if (key === 'ArrowRight' || key === 'ArrowDown') { + targetIndex = (currentIndex + 1) % this.notificationTabs.length; + } else if (key === 'Home') { + targetIndex = 0; + } else if (key === 'End') { + targetIndex = this.notificationTabs.length - 1; + } + + const nextTab = this.notificationTabs[targetIndex]; + if (!nextTab) { + return; + } + + const tabName = nextTab.getAttribute('data-notification-tab'); + nextTab.focus(); + this.switchNotificationTab(tabName, { markRead: true }); + } + + isNotificationModalOpen() { + const updateModal = modalManager.getModal('updateModal'); + return !!(updateModal && updateModal.isOpen); + } + + handleBannerHistoryUpdated() { + this.updateBadgeVisibility(); + + if (this.isNotificationModalOpen() && this.activeNotificationTab === 'banners') { + this.renderRecentBanners(); + } + } + + updateTabBadges() { + const updatesBadge = document.getElementById('updatesTabBadge'); + const bannerBadge = document.getElementById('bannerTabBadge'); + const hasUpdate = this.updateNotificationsEnabled && this.updateAvailable; + const unreadBanners = typeof bannerService.getUnreadBannerCount === 'function' + ? bannerService.getUnreadBannerCount() + : 0; + + if (updatesBadge) { + updatesBadge.classList.toggle('visible', hasUpdate); + updatesBadge.classList.toggle('is-dot', hasUpdate); + updatesBadge.textContent = ''; + } + + if (bannerBadge) { + if (unreadBanners > 0) { + bannerBadge.textContent = unreadBanners > 9 ? '9+' : unreadBanners.toString(); + } else { + bannerBadge.textContent = ''; + } + bannerBadge.classList.toggle('visible', unreadBanners > 0); + bannerBadge.classList.remove('is-dot'); + } + } + + renderRecentBanners() { + const list = document.getElementById('bannerHistoryList'); + const emptyState = document.getElementById('bannerHistoryEmpty'); + + if (!list || !emptyState) return; + + const banners = typeof bannerService.getRecentBanners === 'function' + ? bannerService.getRecentBanners() + : []; + + list.innerHTML = ''; + + if (!banners.length) { + emptyState.style.display = 'block'; + return; + } + + emptyState.style.display = 'none'; + + banners.forEach(banner => { + const item = document.createElement('li'); + item.className = 'banner-history-item'; + + const title = document.createElement('h4'); + title.className = 'banner-history-title'; + title.textContent = banner.title || translate('update.banners.recent', {}, 'Recent banners'); + item.appendChild(title); + + if (banner.content) { + const description = document.createElement('p'); + description.className = 'banner-history-description'; + description.textContent = banner.content; + item.appendChild(description); + } + + const meta = document.createElement('div'); + meta.className = 'banner-history-meta'; + + const status = document.createElement('span'); + status.className = 'banner-history-status'; + if (banner.dismissedAt) { + status.classList.add('dismissed'); + const dismissedRelative = this.formatRelativeTime(banner.dismissedAt); + status.textContent = translate('update.banners.dismissed', { + time: dismissedRelative + }, `Dismissed ${dismissedRelative}`); + } else { + status.classList.add('active'); + status.textContent = translate('update.banners.active', {}, 'Active'); + } + meta.appendChild(status); + + const shownRelative = this.formatRelativeTime(banner.timestamp); + const timestamp = document.createElement('span'); + timestamp.className = 'banner-history-time'; + timestamp.textContent = translate('update.banners.shown', { + time: shownRelative + }, `Shown ${shownRelative}`); + meta.appendChild(timestamp); + + item.appendChild(meta); + + if (Array.isArray(banner.actions) && banner.actions.length > 0) { + const actionsContainer = document.createElement('div'); + actionsContainer.className = 'banner-history-actions'; + + banner.actions.forEach(action => { + if (!action?.url) { + return; + } + + const link = document.createElement('a'); + link.className = `banner-history-action banner-history-action-${action.type || 'secondary'}`; + link.href = action.url; + link.target = '_blank'; + link.rel = 'noopener noreferrer'; + link.textContent = action.text || action.url; + + if (action.icon) { + const icon = document.createElement('i'); + icon.className = action.icon; + link.prepend(icon); + } + + actionsContainer.appendChild(link); + }); + + if (actionsContainer.children.length > 0) { + item.appendChild(actionsContainer); + } + } + + list.appendChild(item); + }); + } + + formatRelativeTime(timestamp) { + if (!timestamp) { + return ''; + } + + const locale = window?.i18n?.getCurrentLocale?.() || navigator.language || 'en'; + + try { + const formatter = new Intl.RelativeTimeFormat(locale, { numeric: 'auto' }); + const divisions = [ + { amount: 60, unit: 'second' }, + { amount: 60, unit: 'minute' }, + { amount: 24, unit: 'hour' }, + { amount: 7, unit: 'day' }, + { amount: 4.34524, unit: 'week' }, + { amount: 12, unit: 'month' }, + { amount: Infinity, unit: 'year' } + ]; + + let duration = (timestamp - Date.now()) / 1000; + + for (const division of divisions) { + if (Math.abs(duration) < division.amount) { + return formatter.format(Math.round(duration), division.unit); + } + duration /= division.amount; + } + + return formatter.format(Math.round(duration), 'year'); + } catch (error) { + console.warn('RelativeTimeFormat not available, falling back to locale string.', error); + return new Date(timestamp).toLocaleString(locale); + } + } async checkForUpdates({ force = false } = {}) { if (!force && !this.updateNotificationsEnabled) { @@ -167,20 +440,29 @@ export class UpdateService { updateBadgeVisibility() { const updateToggle = document.querySelector('.update-toggle'); const updateBadge = document.querySelector('.update-toggle .update-badge'); - + const unreadBanners = typeof bannerService.getUnreadBannerCount === 'function' + ? bannerService.getUnreadBannerCount() + : 0; + if (updateToggle) { - updateToggle.title = this.updateNotificationsEnabled && this.updateAvailable - ? translate('update.updateAvailable') - : translate('update.title'); + let tooltipKey = 'header.actions.notifications'; + if (this.updateNotificationsEnabled && this.updateAvailable) { + tooltipKey = 'update.updateAvailable'; + } else if (unreadBanners > 0) { + tooltipKey = 'update.tabs.messages'; + } + updateToggle.title = translate(tooltipKey); } - + // Force updating badges visibility based on current state - const shouldShow = this.updateNotificationsEnabled && this.updateAvailable; - + const shouldShowUpdate = this.updateNotificationsEnabled && this.updateAvailable; + const shouldShow = shouldShowUpdate || unreadBanners > 0; + if (updateBadge) { updateBadge.classList.toggle('visible', shouldShow); - console.log("Update badge visibility:", shouldShow ? "visible" : "hidden"); } + + this.updateTabBadges(); } updateModalContent() { @@ -190,9 +472,9 @@ export class UpdateService { // Update title based on update availability const headerTitle = modal.querySelector('.update-header h2'); if (headerTitle) { - headerTitle.textContent = this.updateAvailable ? - translate('update.updateAvailable') : - translate('update.title'); + headerTitle.textContent = this.updateAvailable ? + translate('update.updateAvailable') : + translate('update.notificationsTitle'); } // Always update version information, even if updateInfo is null @@ -418,23 +700,32 @@ export class UpdateService { toggleUpdateModal() { const updateModal = modalManager.getModal('updateModal'); - + // If modal is already open, just close it if (updateModal && updateModal.isOpen) { modalManager.closeModal('updateModal'); return; } - + + if (!Array.isArray(this.notificationTabs) || !this.notificationTabs.length) { + this.setupNotificationCenter(); + } + // Update the modal content immediately with current data this.updateModalContent(); - + this.renderRecentBanners(); + // Show the modal with current data modalManager.showModal('updateModal'); - + this.switchNotificationTab(this.activeNotificationTab, { markRead: true }); + // Then check for updates in the background this.manualCheckForUpdates().then(() => { // Update the modal content again after the check completes this.updateModalContent(); + if (this.activeNotificationTab === 'banners' && this.isNotificationModalOpen()) { + this.renderRecentBanners(); + } }); } diff --git a/templates/components/header.html b/templates/components/header.html index ae739982..ac0d8ef7 100644 --- a/templates/components/header.html +++ b/templates/components/header.html @@ -69,7 +69,7 @@ -