diff --git a/locales/de.json b/locales/de.json index 5dcfa609..7ad1b9c3 100644 --- a/locales/de.json +++ b/locales/de.json @@ -2741,6 +2741,11 @@ "content": "Scannen und verwalten Sie VAE-, Upscaler-, Text-Encoder-, CLIP-Vision- und ControlNet-Dateien und laden Sie sie von CivitAI herunter, alles auf einer eigenen Seite.", "enable": "Weitere Modelle aktivieren", "openSettings": "Einstellungen öffnen" + }, + "pager": { + "previous": "Vorherige Mitteilung", + "next": "Nächste Mitteilung", + "position": "Mitteilung {current} von {total}" } } } diff --git a/locales/en.json b/locales/en.json index 668fe316..ae4d2988 100644 --- a/locales/en.json +++ b/locales/en.json @@ -2741,6 +2741,11 @@ "content": "Scan and manage VAE, upscaler, text encoder, CLIP vision and ControlNet files — and download them from CivitAI — from one dedicated page.", "enable": "Enable Other Models", "openSettings": "Open Settings" + }, + "pager": { + "previous": "Previous message", + "next": "Next message", + "position": "Message {current} of {total}" } } } diff --git a/locales/es.json b/locales/es.json index 209fcd90..9e206a73 100644 --- a/locales/es.json +++ b/locales/es.json @@ -2741,6 +2741,11 @@ "content": "Escanea y gestiona archivos VAE, Upscaler, Text Encoder, CLIP Vision y ControlNet, y descárgalos desde CivitAI, todo desde una página dedicada.", "enable": "Activar otros modelos", "openSettings": "Abrir configuración" + }, + "pager": { + "previous": "Notificación anterior", + "next": "Notificación siguiente", + "position": "Notificación {current} de {total}" } } } diff --git a/locales/fr.json b/locales/fr.json index 1b826ecf..ea2713c1 100644 --- a/locales/fr.json +++ b/locales/fr.json @@ -2741,6 +2741,11 @@ "content": "Analysez et gérez les fichiers VAE, Upscaler, Text Encoder, CLIP Vision et ControlNet, et téléchargez-les depuis CivitAI, le tout depuis une page dédiée.", "enable": "Activer les autres modèles", "openSettings": "Ouvrir les paramètres" + }, + "pager": { + "previous": "Message précédent", + "next": "Message suivant", + "position": "Message {current} sur {total}" } } } diff --git a/locales/he.json b/locales/he.json index 907ab6be..03e9fa97 100644 --- a/locales/he.json +++ b/locales/he.json @@ -2741,6 +2741,11 @@ "content": "סרוק ונהל קבצי VAE, Upscaler, Text Encoder, CLIP Vision ו-ControlNet, והורד אותם מ-CivitAI — מהעמוד הייעודי.", "enable": "הפעל מודלים אחרים", "openSettings": "פתח הגדרות" + }, + "pager": { + "previous": "הודעה קודמת", + "next": "הודעה הבאה", + "position": "הודעה {current} מתוך {total}" } } } diff --git a/locales/ja.json b/locales/ja.json index 38d5a5e8..dbb657b4 100644 --- a/locales/ja.json +++ b/locales/ja.json @@ -2741,6 +2741,11 @@ "content": "専用ページで VAE、Upscaler、Text Encoder、CLIP Vision、ControlNet の各ファイルをスキャン・管理し、CivitAI からダウンロードできます。", "enable": "その他のモデルを有効にする", "openSettings": "設定を開く" + }, + "pager": { + "previous": "前の通知", + "next": "次の通知", + "position": "{total} 件中 {current} 件目の通知" } } } diff --git a/locales/ko.json b/locales/ko.json index 671eaba4..54c6c109 100644 --- a/locales/ko.json +++ b/locales/ko.json @@ -2741,6 +2741,11 @@ "content": "전용 페이지에서 VAE, Upscaler, Text Encoder, CLIP Vision, ControlNet 파일을 스캔 및 관리하고 CivitAI에서 다운로드할 수 있습니다.", "enable": "기타 모델 활성화", "openSettings": "설정 열기" + }, + "pager": { + "previous": "이전 알림", + "next": "다음 알림", + "position": "전체 {total}개 중 {current}번째 알림" } } } diff --git a/locales/ru.json b/locales/ru.json index c55911e2..280ce0e7 100644 --- a/locales/ru.json +++ b/locales/ru.json @@ -2741,6 +2741,11 @@ "content": "Сканирование и управление файлами VAE, Upscaler, Text Encoder, CLIP Vision и ControlNet, а также загрузка их с CivitAI — всё на одной отдельной странице.", "enable": "Включить другие модели", "openSettings": "Открыть настройки" + }, + "pager": { + "previous": "Предыдущее уведомление", + "next": "Следующее уведомление", + "position": "Уведомление {current} из {total}" } } } diff --git a/locales/zh-CN.json b/locales/zh-CN.json index 895c77de..58818f24 100644 --- a/locales/zh-CN.json +++ b/locales/zh-CN.json @@ -2741,6 +2741,11 @@ "content": "在一个专属页面中扫描和管理 VAE、Upscaler、Text Encoder、CLIP Vision 和 ControlNet 文件,并从 CivitAI 下载。", "enable": "启用其他模型", "openSettings": "打开设置" + }, + "pager": { + "previous": "上一条通知", + "next": "下一条通知", + "position": "第 {current} 条通知,共 {total} 条" } } } diff --git a/locales/zh-TW.json b/locales/zh-TW.json index 631e43f9..19e48ce2 100644 --- a/locales/zh-TW.json +++ b/locales/zh-TW.json @@ -2741,6 +2741,11 @@ "content": "在專屬頁面中掃描和管理 VAE、Upscaler、Text Encoder、CLIP Vision 和 ControlNet 檔案,並從 CivitAI 下載。", "enable": "啟用其他模型", "openSettings": "開啟設定" + }, + "pager": { + "previous": "上一則通知", + "next": "下一則通知", + "position": "第 {current} 則通知,共 {total} 則" } } } diff --git a/static/css/components/banner.css b/static/css/components/banner.css index 757926bb..298465bf 100644 --- a/static/css/components/banner.css +++ b/static/css/components/banner.css @@ -118,6 +118,44 @@ transform: translateY(-1px); } +/* Banner Pager (cycles through multiple active banners) */ +.banner-pager { + display: flex; + align-items: center; + gap: 2px; + flex-shrink: 0; + margin-left: var(--space-2); +} + +.banner-pager-btn { + width: 24px; + height: 24px; + border: none; + background: transparent; + color: var(--text-muted); + cursor: pointer; + border-radius: 50%; + display: flex; + align-items: center; + justify-content: center; + transition: var(--transition-base); + font-size: 0.75em; + padding: 0; +} + +.banner-pager-btn:hover { + background: oklch(var(--lora-accent) / 0.1); + color: var(--lora-accent); +} + +.banner-pager-indicator { + font-size: 0.8em; + color: var(--text-muted); + min-width: 2.8em; + text-align: center; + font-variant-numeric: tabular-nums; +} + /* Dismiss Button */ .banner-dismiss { position: absolute; @@ -183,6 +221,10 @@ flex-wrap: wrap; justify-content: flex-start; } + + .banner-pager { + margin-left: 0; + } .banner-action { flex: 1; diff --git a/static/js/managers/BannerService.js b/static/js/managers/BannerService.js index 746ccdce..a29ea40f 100644 --- a/static/js/managers/BannerService.js +++ b/static/js/managers/BannerService.js @@ -31,6 +31,9 @@ class BannerService { this.banners = new Map(); this.container = null; this.initialized = false; + // Only one banner is rendered at a time; this index selects which of + // the active (non-dismissed) banners is currently displayed. + this.currentBannerIndex = 0; this.recentHistory = this.loadBannerHistory(); this.bannerHistoryViewedAt = this.loadBannerHistoryViewedAt(); @@ -121,12 +124,22 @@ class BannerService { */ registerBanner(id, bannerConfig) { this.banners.set(id, bannerConfig); - - // If already initialized, render the banner immediately - if (this.initialized && !this.isBannerDismissed(id) && this.container) { - this.renderBanner(bannerConfig); - this.updateContainerVisibility(); + + if (!this.initialized || !this.container || this.isBannerDismissed(id)) { + return; } + + // Preempt the currently displayed banner only when the new one has a + // strictly higher priority (i.e. sorts earlier). + const activeBanners = this.getSortedActiveBanners(); + const displayedId = this.container.querySelector('.banner-item') + ?.getAttribute('data-banner-id'); + const newIndex = activeBanners.findIndex(banner => banner.id === id); + const displayedIndex = activeBanners.findIndex(banner => banner.id === displayedId); + if (displayedIndex === -1 || (newIndex !== -1 && newIndex < displayedIndex)) { + this.currentBannerIndex = Math.max(newIndex, 0); + } + this.renderCurrentBanner(); } /** @@ -164,11 +177,10 @@ class BannerService { if (banner && typeof banner.onRemove === 'function') { banner.onRemove(bannerElement); } - + bannerElement.style.animation = 'banner-slide-up 0.3s ease-in-out forwards'; setTimeout(() => { - bannerElement.remove(); - this.updateContainerVisibility(); + this.renderCurrentBanner(); }, 300); } @@ -193,28 +205,87 @@ class BannerService { } } + /** + * Get active (non-dismissed) banners sorted by priority, highest first + * @returns {Object[]} + */ + getSortedActiveBanners() { + return Array.from(this.banners.values()) + .filter(banner => !this.isBannerDismissed(banner.id)) + .sort((a, b) => (b.priority || 0) - (a.priority || 0)); + } + /** * Show all active (non-dismissed) banners */ async showActiveBanners() { if (!this.container) return; - const activeBanners = Array.from(this.banners.values()) - .filter(banner => !this.isBannerDismissed(banner.id)) - .sort((a, b) => (b.priority || 0) - (a.priority || 0)); - - activeBanners.forEach(banner => { - this.renderBanner(banner); - }); - - this.updateContainerVisibility(); + this.currentBannerIndex = 0; + this.renderCurrentBanner(); } /** - * Render a banner to the DOM - * @param {Object} banner - Banner configuration + * Render the currently selected banner into the container. Only one + * banner is visible at a time; a pager lets the user cycle through the + * remaining active banners. */ - renderBanner(banner) { + renderCurrentBanner() { + if (!this.container) return; + + const activeBanners = this.getSortedActiveBanners(); + + this.container.innerHTML = ''; + + if (activeBanners.length === 0) { + this.currentBannerIndex = 0; + this.updateContainerVisibility(); + return; + } + + if (this.currentBannerIndex >= activeBanners.length) { + this.currentBannerIndex = activeBanners.length - 1; + } + if (this.currentBannerIndex < 0) { + this.currentBannerIndex = 0; + } + + // Record every active banner once so dismissed/cycled-away banners + // remain reachable through the notification center history. + activeBanners.forEach(banner => this.recordBannerAppearance(banner)); + + const banner = activeBanners[this.currentBannerIndex]; + const bannerElement = this.buildBannerElement(banner, activeBanners.length); + this.container.appendChild(bannerElement); + + this.updateContainerVisibility(); + + // Call onRegister callback if provided + if (typeof banner.onRegister === 'function') { + banner.onRegister(bannerElement); + } + } + + /** + * Advance the displayed banner by offset, wrapping around + * @param {number} offset - +1 for next, -1 for previous + */ + showAdjacentBanner(offset) { + const activeBanners = this.getSortedActiveBanners(); + if (activeBanners.length < 2) return; + + this.currentBannerIndex = + (this.currentBannerIndex + offset + activeBanners.length) % activeBanners.length; + this.renderCurrentBanner(); + } + + /** + * Build a banner DOM element + * @param {Object} banner - Banner configuration + * @param {number} totalCount - Total number of active banners + * @returns {HTMLElement} + */ + buildBannerElement(banner, totalCount) { const bannerElement = document.createElement('div'); bannerElement.className = 'banner-item'; bannerElement.setAttribute('data-banner-id', banner.id); @@ -230,11 +301,34 @@ class BannerService { `; }).join('') : ''; - const dismissButtonHtml = banner.dismissible ? + const dismissButtonHtml = banner.dismissible ? `` : ''; + let pagerHtml = ''; + if (totalCount > 1) { + const previousLabel = translate('banners.pager.previous', {}, 'Previous message'); + const nextLabel = translate('banners.pager.next', {}, 'Next message'); + const positionLabel = translate('banners.pager.position', { + current: this.currentBannerIndex + 1, + total: totalCount + }, `Message ${this.currentBannerIndex + 1} of ${totalCount}`); + + pagerHtml = ` +
`; + } + bannerElement.innerHTML = `