mirror of
https://github.com/willmiao/ComfyUI-Lora-Manager.git
synced 2026-09-20 18:51:26 -03:00
feat(banners): rotate active banners one at a time with a pager
Stacking every active banner vertically ate header height when several were active at once. Only the highest-priority banner renders now; a ‹ 1/N › pager cycles through the rest, and all active banners are still recorded in the notification-center history so cycled-away ones stay reachable. Newly registered banners preempt the displayed one only when they outrank it. Also fix the startup flow: the restart-required banner (now priority 80) outranks the model-folders setup warning (60), and the setup banner is retired once a non-empty folder path is saved. New banners.pager.* keys translated in all 9 locales.
This commit is contained in:
@@ -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.",
|
"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",
|
"enable": "Weitere Modelle aktivieren",
|
||||||
"openSettings": "Einstellungen öffnen"
|
"openSettings": "Einstellungen öffnen"
|
||||||
|
},
|
||||||
|
"pager": {
|
||||||
|
"previous": "Vorherige Mitteilung",
|
||||||
|
"next": "Nächste Mitteilung",
|
||||||
|
"position": "Mitteilung {current} von {total}"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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.",
|
"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",
|
"enable": "Enable Other Models",
|
||||||
"openSettings": "Open Settings"
|
"openSettings": "Open Settings"
|
||||||
|
},
|
||||||
|
"pager": {
|
||||||
|
"previous": "Previous message",
|
||||||
|
"next": "Next message",
|
||||||
|
"position": "Message {current} of {total}"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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.",
|
"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",
|
"enable": "Activar otros modelos",
|
||||||
"openSettings": "Abrir configuración"
|
"openSettings": "Abrir configuración"
|
||||||
|
},
|
||||||
|
"pager": {
|
||||||
|
"previous": "Notificación anterior",
|
||||||
|
"next": "Notificación siguiente",
|
||||||
|
"position": "Notificación {current} de {total}"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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.",
|
"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",
|
"enable": "Activer les autres modèles",
|
||||||
"openSettings": "Ouvrir les paramètres"
|
"openSettings": "Ouvrir les paramètres"
|
||||||
|
},
|
||||||
|
"pager": {
|
||||||
|
"previous": "Message précédent",
|
||||||
|
"next": "Message suivant",
|
||||||
|
"position": "Message {current} sur {total}"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2741,6 +2741,11 @@
|
|||||||
"content": "סרוק ונהל קבצי VAE, Upscaler, Text Encoder, CLIP Vision ו-ControlNet, והורד אותם מ-CivitAI — מהעמוד הייעודי.",
|
"content": "סרוק ונהל קבצי VAE, Upscaler, Text Encoder, CLIP Vision ו-ControlNet, והורד אותם מ-CivitAI — מהעמוד הייעודי.",
|
||||||
"enable": "הפעל מודלים אחרים",
|
"enable": "הפעל מודלים אחרים",
|
||||||
"openSettings": "פתח הגדרות"
|
"openSettings": "פתח הגדרות"
|
||||||
|
},
|
||||||
|
"pager": {
|
||||||
|
"previous": "הודעה קודמת",
|
||||||
|
"next": "הודעה הבאה",
|
||||||
|
"position": "הודעה {current} מתוך {total}"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2741,6 +2741,11 @@
|
|||||||
"content": "専用ページで VAE、Upscaler、Text Encoder、CLIP Vision、ControlNet の各ファイルをスキャン・管理し、CivitAI からダウンロードできます。",
|
"content": "専用ページで VAE、Upscaler、Text Encoder、CLIP Vision、ControlNet の各ファイルをスキャン・管理し、CivitAI からダウンロードできます。",
|
||||||
"enable": "その他のモデルを有効にする",
|
"enable": "その他のモデルを有効にする",
|
||||||
"openSettings": "設定を開く"
|
"openSettings": "設定を開く"
|
||||||
|
},
|
||||||
|
"pager": {
|
||||||
|
"previous": "前の通知",
|
||||||
|
"next": "次の通知",
|
||||||
|
"position": "{total} 件中 {current} 件目の通知"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2741,6 +2741,11 @@
|
|||||||
"content": "전용 페이지에서 VAE, Upscaler, Text Encoder, CLIP Vision, ControlNet 파일을 스캔 및 관리하고 CivitAI에서 다운로드할 수 있습니다.",
|
"content": "전용 페이지에서 VAE, Upscaler, Text Encoder, CLIP Vision, ControlNet 파일을 스캔 및 관리하고 CivitAI에서 다운로드할 수 있습니다.",
|
||||||
"enable": "기타 모델 활성화",
|
"enable": "기타 모델 활성화",
|
||||||
"openSettings": "설정 열기"
|
"openSettings": "설정 열기"
|
||||||
|
},
|
||||||
|
"pager": {
|
||||||
|
"previous": "이전 알림",
|
||||||
|
"next": "다음 알림",
|
||||||
|
"position": "전체 {total}개 중 {current}번째 알림"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2741,6 +2741,11 @@
|
|||||||
"content": "Сканирование и управление файлами VAE, Upscaler, Text Encoder, CLIP Vision и ControlNet, а также загрузка их с CivitAI — всё на одной отдельной странице.",
|
"content": "Сканирование и управление файлами VAE, Upscaler, Text Encoder, CLIP Vision и ControlNet, а также загрузка их с CivitAI — всё на одной отдельной странице.",
|
||||||
"enable": "Включить другие модели",
|
"enable": "Включить другие модели",
|
||||||
"openSettings": "Открыть настройки"
|
"openSettings": "Открыть настройки"
|
||||||
|
},
|
||||||
|
"pager": {
|
||||||
|
"previous": "Предыдущее уведомление",
|
||||||
|
"next": "Следующее уведомление",
|
||||||
|
"position": "Уведомление {current} из {total}"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2741,6 +2741,11 @@
|
|||||||
"content": "在一个专属页面中扫描和管理 VAE、Upscaler、Text Encoder、CLIP Vision 和 ControlNet 文件,并从 CivitAI 下载。",
|
"content": "在一个专属页面中扫描和管理 VAE、Upscaler、Text Encoder、CLIP Vision 和 ControlNet 文件,并从 CivitAI 下载。",
|
||||||
"enable": "启用其他模型",
|
"enable": "启用其他模型",
|
||||||
"openSettings": "打开设置"
|
"openSettings": "打开设置"
|
||||||
|
},
|
||||||
|
"pager": {
|
||||||
|
"previous": "上一条通知",
|
||||||
|
"next": "下一条通知",
|
||||||
|
"position": "第 {current} 条通知,共 {total} 条"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2741,6 +2741,11 @@
|
|||||||
"content": "在專屬頁面中掃描和管理 VAE、Upscaler、Text Encoder、CLIP Vision 和 ControlNet 檔案,並從 CivitAI 下載。",
|
"content": "在專屬頁面中掃描和管理 VAE、Upscaler、Text Encoder、CLIP Vision 和 ControlNet 檔案,並從 CivitAI 下載。",
|
||||||
"enable": "啟用其他模型",
|
"enable": "啟用其他模型",
|
||||||
"openSettings": "開啟設定"
|
"openSettings": "開啟設定"
|
||||||
|
},
|
||||||
|
"pager": {
|
||||||
|
"previous": "上一則通知",
|
||||||
|
"next": "下一則通知",
|
||||||
|
"position": "第 {current} 則通知,共 {total} 則"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -118,6 +118,44 @@
|
|||||||
transform: translateY(-1px);
|
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 */
|
/* Dismiss Button */
|
||||||
.banner-dismiss {
|
.banner-dismiss {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
@@ -183,6 +221,10 @@
|
|||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
justify-content: flex-start;
|
justify-content: flex-start;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.banner-pager {
|
||||||
|
margin-left: 0;
|
||||||
|
}
|
||||||
|
|
||||||
.banner-action {
|
.banner-action {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
|
|||||||
@@ -31,6 +31,9 @@ class BannerService {
|
|||||||
this.banners = new Map();
|
this.banners = new Map();
|
||||||
this.container = null;
|
this.container = null;
|
||||||
this.initialized = false;
|
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.recentHistory = this.loadBannerHistory();
|
||||||
this.bannerHistoryViewedAt = this.loadBannerHistoryViewedAt();
|
this.bannerHistoryViewedAt = this.loadBannerHistoryViewedAt();
|
||||||
|
|
||||||
@@ -121,12 +124,22 @@ class BannerService {
|
|||||||
*/
|
*/
|
||||||
registerBanner(id, bannerConfig) {
|
registerBanner(id, bannerConfig) {
|
||||||
this.banners.set(id, bannerConfig);
|
this.banners.set(id, bannerConfig);
|
||||||
|
|
||||||
// If already initialized, render the banner immediately
|
if (!this.initialized || !this.container || this.isBannerDismissed(id)) {
|
||||||
if (this.initialized && !this.isBannerDismissed(id) && this.container) {
|
return;
|
||||||
this.renderBanner(bannerConfig);
|
|
||||||
this.updateContainerVisibility();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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') {
|
if (banner && typeof banner.onRemove === 'function') {
|
||||||
banner.onRemove(bannerElement);
|
banner.onRemove(bannerElement);
|
||||||
}
|
}
|
||||||
|
|
||||||
bannerElement.style.animation = 'banner-slide-up 0.3s ease-in-out forwards';
|
bannerElement.style.animation = 'banner-slide-up 0.3s ease-in-out forwards';
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
bannerElement.remove();
|
this.renderCurrentBanner();
|
||||||
this.updateContainerVisibility();
|
|
||||||
}, 300);
|
}, 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
|
* Show all active (non-dismissed) banners
|
||||||
*/
|
*/
|
||||||
async showActiveBanners() {
|
async showActiveBanners() {
|
||||||
if (!this.container) return;
|
if (!this.container) return;
|
||||||
|
|
||||||
const activeBanners = Array.from(this.banners.values())
|
this.currentBannerIndex = 0;
|
||||||
.filter(banner => !this.isBannerDismissed(banner.id))
|
this.renderCurrentBanner();
|
||||||
.sort((a, b) => (b.priority || 0) - (a.priority || 0));
|
|
||||||
|
|
||||||
activeBanners.forEach(banner => {
|
|
||||||
this.renderBanner(banner);
|
|
||||||
});
|
|
||||||
|
|
||||||
this.updateContainerVisibility();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Render a banner to the DOM
|
* Render the currently selected banner into the container. Only one
|
||||||
* @param {Object} banner - Banner configuration
|
* 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');
|
const bannerElement = document.createElement('div');
|
||||||
bannerElement.className = 'banner-item';
|
bannerElement.className = 'banner-item';
|
||||||
bannerElement.setAttribute('data-banner-id', banner.id);
|
bannerElement.setAttribute('data-banner-id', banner.id);
|
||||||
@@ -230,11 +301,34 @@ class BannerService {
|
|||||||
</a>`;
|
</a>`;
|
||||||
}).join('') : '';
|
}).join('') : '';
|
||||||
|
|
||||||
const dismissButtonHtml = banner.dismissible ?
|
const dismissButtonHtml = banner.dismissible ?
|
||||||
`<button class="banner-dismiss" onclick="bannerService.dismissBanner('${banner.id}').catch(console.error)" title="Dismiss">
|
`<button class="banner-dismiss" onclick="bannerService.dismissBanner('${banner.id}').catch(console.error)" title="Dismiss">
|
||||||
<i class="fas fa-times"></i>
|
<i class="fas fa-times"></i>
|
||||||
</button>` : '';
|
</button>` : '';
|
||||||
|
|
||||||
|
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 = `
|
||||||
|
<div class="banner-pager">
|
||||||
|
<button type="button" class="banner-pager-btn" data-pager="prev"
|
||||||
|
aria-label="${previousLabel}" title="${previousLabel}">
|
||||||
|
<i class="fas fa-chevron-left"></i>
|
||||||
|
</button>
|
||||||
|
<span class="banner-pager-indicator" aria-label="${positionLabel}">${this.currentBannerIndex + 1} / ${totalCount}</span>
|
||||||
|
<button type="button" class="banner-pager-btn" data-pager="next"
|
||||||
|
aria-label="${nextLabel}" title="${nextLabel}">
|
||||||
|
<i class="fas fa-chevron-right"></i>
|
||||||
|
</button>
|
||||||
|
</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
bannerElement.innerHTML = `
|
bannerElement.innerHTML = `
|
||||||
<div class="banner-content">
|
<div class="banner-content">
|
||||||
<div class="banner-text">
|
<div class="banner-text">
|
||||||
@@ -244,18 +338,19 @@ class BannerService {
|
|||||||
<div class="banner-actions">
|
<div class="banner-actions">
|
||||||
${actionsHtml}
|
${actionsHtml}
|
||||||
</div>
|
</div>
|
||||||
|
${pagerHtml}
|
||||||
</div>
|
</div>
|
||||||
${dismissButtonHtml}
|
${dismissButtonHtml}
|
||||||
`;
|
`;
|
||||||
|
|
||||||
this.container.appendChild(bannerElement);
|
bannerElement.querySelectorAll('.banner-pager-btn').forEach(button => {
|
||||||
|
button.addEventListener('click', (event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
this.showAdjacentBanner(button.getAttribute('data-pager') === 'next' ? 1 : -1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
this.recordBannerAppearance(banner);
|
return bannerElement;
|
||||||
|
|
||||||
// Call onRegister callback if provided
|
|
||||||
if (typeof banner.onRegister === 'function') {
|
|
||||||
banner.onRegister(bannerElement);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -458,17 +553,18 @@ class BannerService {
|
|||||||
* @param {string} bannerId - Banner ID to remove
|
* @param {string} bannerId - Banner ID to remove
|
||||||
*/
|
*/
|
||||||
removeBannerElement(bannerId) {
|
removeBannerElement(bannerId) {
|
||||||
|
// Also remove from banners map
|
||||||
|
this.banners.delete(bannerId);
|
||||||
|
|
||||||
const bannerElement = document.querySelector(`[data-banner-id="${bannerId}"]`);
|
const bannerElement = document.querySelector(`[data-banner-id="${bannerId}"]`);
|
||||||
if (bannerElement) {
|
if (bannerElement) {
|
||||||
bannerElement.style.animation = 'banner-slide-up 0.3s ease-in-out forwards';
|
bannerElement.style.animation = 'banner-slide-up 0.3s ease-in-out forwards';
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
bannerElement.remove();
|
this.renderCurrentBanner();
|
||||||
this.updateContainerVisibility();
|
|
||||||
}, 300);
|
}, 300);
|
||||||
|
} else {
|
||||||
|
this.renderCurrentBanner();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Also remove from banners map
|
|
||||||
this.banners.delete(bannerId);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
prepareCommunitySupportBanner() {
|
prepareCommunitySupportBanner() {
|
||||||
|
|||||||
@@ -2405,6 +2405,17 @@ export class SettingsManager {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
await this.saveSetting('folder_paths', folderPaths);
|
await this.saveSetting('folder_paths', folderPaths);
|
||||||
|
|
||||||
|
// The "model folders need setup" startup banner is obsolete once
|
||||||
|
// at least one folder path is configured.
|
||||||
|
const hasAnyPath = Object.values(folderPaths).some(value => {
|
||||||
|
const list = Array.isArray(value) ? value : [value];
|
||||||
|
return list.some(path => typeof path === 'string' && path.trim());
|
||||||
|
});
|
||||||
|
if (hasAnyPath) {
|
||||||
|
bannerService.removeBannerElement('startup-missing-model-paths');
|
||||||
|
}
|
||||||
|
|
||||||
this._markModelPathsDirty();
|
this._markModelPathsDirty();
|
||||||
showToast('settings.modelPaths.saveSuccessRestart', {}, 'success');
|
showToast('settings.modelPaths.saveSuccessRestart', {}, 'success');
|
||||||
|
|
||||||
@@ -2451,7 +2462,9 @@ export class SettingsManager {
|
|||||||
title: translate('settings.modelPaths.pendingRestartBannerTitle', {}, 'Restart required to apply path changes'),
|
title: translate('settings.modelPaths.pendingRestartBannerTitle', {}, 'Restart required to apply path changes'),
|
||||||
content: translate('settings.modelPaths.pendingRestartBannerMessage', {}, 'Model library paths were updated. Restart the LoRA Manager server to scan the new folders.'),
|
content: translate('settings.modelPaths.pendingRestartBannerMessage', {}, 'Model library paths were updated. Restart the LoRA Manager server to scan the new folders.'),
|
||||||
dismissible: true,
|
dismissible: true,
|
||||||
priority: 60,
|
// Above startup warnings (60), below startup errors (90): a pending
|
||||||
|
// restart is the most actionable state and should preempt prompts.
|
||||||
|
priority: 80,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -43,6 +43,7 @@ describe('BannerService', () => {
|
|||||||
// Reset banner service state
|
// Reset banner service state
|
||||||
bannerService.banners.clear();
|
bannerService.banners.clear();
|
||||||
bannerService.initialized = false;
|
bannerService.initialized = false;
|
||||||
|
bannerService.currentBannerIndex = 0;
|
||||||
bannerService.recentHistory = []; // Clear history for each test
|
bannerService.recentHistory = []; // Clear history for each test
|
||||||
|
|
||||||
// Clear DOM
|
// Clear DOM
|
||||||
@@ -331,6 +332,116 @@ describe('BannerService', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('Banner Rotation', () => {
|
||||||
|
const registerTestBanner = (id, priority) => {
|
||||||
|
bannerService.registerBanner(id, {
|
||||||
|
id,
|
||||||
|
title: `Banner ${id}`,
|
||||||
|
content: `Content ${id}`,
|
||||||
|
dismissible: true,
|
||||||
|
priority
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const displayedBannerId = () =>
|
||||||
|
document.querySelector('#banner-container .banner-item')
|
||||||
|
?.getAttribute('data-banner-id');
|
||||||
|
|
||||||
|
let dismissedStore;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
dismissedStore = [];
|
||||||
|
storageHelpers.getStorageItem.mockImplementation((key, defaultValue) => {
|
||||||
|
if (key === 'dismissed_banners') {
|
||||||
|
return dismissedStore;
|
||||||
|
}
|
||||||
|
return defaultValue;
|
||||||
|
});
|
||||||
|
storageHelpers.setStorageItem.mockImplementation((key, value) => {
|
||||||
|
if (key === 'dismissed_banners') {
|
||||||
|
dismissedStore = value;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
bannerService.container = document.getElementById('banner-container');
|
||||||
|
bannerService.initialized = true;
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders only the highest priority banner when multiple are active', () => {
|
||||||
|
registerTestBanner('low', 1);
|
||||||
|
registerTestBanner('high', 10);
|
||||||
|
|
||||||
|
const rendered = document.querySelectorAll('#banner-container .banner-item');
|
||||||
|
expect(rendered).toHaveLength(1);
|
||||||
|
expect(displayedBannerId()).toBe('high');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows a pager with position indicator when multiple banners are active', () => {
|
||||||
|
registerTestBanner('a', 1);
|
||||||
|
registerTestBanner('b', 2);
|
||||||
|
|
||||||
|
const pager = document.querySelector('.banner-pager');
|
||||||
|
expect(pager).not.toBeNull();
|
||||||
|
expect(pager.querySelector('.banner-pager-indicator').textContent.trim())
|
||||||
|
.toBe('1 / 2');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not show a pager for a single banner', () => {
|
||||||
|
registerTestBanner('only', 1);
|
||||||
|
|
||||||
|
expect(document.querySelector('.banner-pager')).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('cycles to the next banner and wraps around', () => {
|
||||||
|
registerTestBanner('a', 1);
|
||||||
|
registerTestBanner('b', 2);
|
||||||
|
|
||||||
|
document.querySelector('[data-pager="next"]')
|
||||||
|
.dispatchEvent(new MouseEvent('click', { bubbles: true }));
|
||||||
|
expect(displayedBannerId()).toBe('a');
|
||||||
|
expect(document.querySelector('.banner-pager-indicator').textContent.trim())
|
||||||
|
.toBe('2 / 2');
|
||||||
|
|
||||||
|
document.querySelector('[data-pager="next"]')
|
||||||
|
.dispatchEvent(new MouseEvent('click', { bubbles: true }));
|
||||||
|
expect(displayedBannerId()).toBe('b');
|
||||||
|
expect(document.querySelector('.banner-pager-indicator').textContent.trim())
|
||||||
|
.toBe('1 / 2');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('cycles backwards with the previous button', () => {
|
||||||
|
registerTestBanner('a', 1);
|
||||||
|
registerTestBanner('b', 2);
|
||||||
|
|
||||||
|
document.querySelector('[data-pager="prev"]')
|
||||||
|
.dispatchEvent(new MouseEvent('click', { bubbles: true }));
|
||||||
|
expect(displayedBannerId()).toBe('a');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows the next banner after the displayed one is dismissed', async () => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
try {
|
||||||
|
registerTestBanner('a', 1);
|
||||||
|
registerTestBanner('b', 2);
|
||||||
|
expect(displayedBannerId()).toBe('b');
|
||||||
|
|
||||||
|
await bannerService.dismissBanner('b');
|
||||||
|
vi.advanceTimersByTime(300);
|
||||||
|
|
||||||
|
expect(displayedBannerId()).toBe('a');
|
||||||
|
} finally {
|
||||||
|
vi.useRealTimers();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('records all active banners in history, not just the displayed one', () => {
|
||||||
|
registerTestBanner('a', 1);
|
||||||
|
registerTestBanner('b', 2);
|
||||||
|
|
||||||
|
const historyIds = bannerService.recentHistory.map(entry => entry.id);
|
||||||
|
expect(historyIds).toEqual(expect.arrayContaining(['a', 'b']));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe('Banner History', () => {
|
describe('Banner History', () => {
|
||||||
const testBanner = {
|
const testBanner = {
|
||||||
id: 'test-banner',
|
id: 'test-banner',
|
||||||
|
|||||||
@@ -415,4 +415,75 @@ describe('SettingsManager Model Paths section', () => {
|
|||||||
.filter((id) => id.startsWith('model-paths-restart-'));
|
.filter((id) => id.startsWith('model-paths-restart-'));
|
||||||
expect(restartBanners).toHaveLength(1);
|
expect(restartBanners).toHaveLength(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('gives the restart banner a higher priority than startup warnings', async () => {
|
||||||
|
buildModalDom();
|
||||||
|
setStandaloneSettings();
|
||||||
|
|
||||||
|
const manager = createManager();
|
||||||
|
manager.saveSetting = vi.fn().mockResolvedValue();
|
||||||
|
manager.setupModelPathsSection();
|
||||||
|
manager.loadModelPaths();
|
||||||
|
|
||||||
|
manager.addModelFolderPathRow('loras');
|
||||||
|
document.querySelector('#modelFolderPaths-loras .extra-folder-path-input').value = '/data/loras';
|
||||||
|
await manager.updateModelFolderPaths('loras');
|
||||||
|
|
||||||
|
const restartBanner = Array.from(bannerService.banners.values())
|
||||||
|
.find((banner) => banner.id.startsWith('model-paths-restart-'));
|
||||||
|
// Startup warnings map to 60; the restart cue must outrank them so it
|
||||||
|
// preempts the "model folders need setup" prompt in the banner pager.
|
||||||
|
expect(restartBanner.priority).toBeGreaterThan(60);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('removes the "model folders need setup" startup banner once a path is saved', async () => {
|
||||||
|
buildModalDom();
|
||||||
|
setStandaloneSettings();
|
||||||
|
|
||||||
|
bannerService.registerBanner('startup-missing-model-paths', {
|
||||||
|
id: 'startup-missing-model-paths',
|
||||||
|
title: 'Model folders need setup',
|
||||||
|
content: 'stub',
|
||||||
|
dismissible: false,
|
||||||
|
priority: 60,
|
||||||
|
});
|
||||||
|
|
||||||
|
const manager = createManager();
|
||||||
|
manager.saveSetting = vi.fn().mockResolvedValue();
|
||||||
|
manager.setupModelPathsSection();
|
||||||
|
manager.loadModelPaths();
|
||||||
|
|
||||||
|
manager.addModelFolderPathRow('loras');
|
||||||
|
document.querySelector('#modelFolderPaths-loras .extra-folder-path-input').value = '/data/loras';
|
||||||
|
await manager.updateModelFolderPaths('loras');
|
||||||
|
|
||||||
|
expect(bannerService.banners.has('startup-missing-model-paths')).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps the setup banner when the saved paths are all empty', async () => {
|
||||||
|
buildModalDom();
|
||||||
|
setStandaloneSettings({ folder_paths: { loras: ['/data/loras'] } });
|
||||||
|
|
||||||
|
const manager = createManager();
|
||||||
|
manager.saveSetting = vi.fn().mockResolvedValue();
|
||||||
|
manager.setupModelPathsSection();
|
||||||
|
manager.loadModelPaths();
|
||||||
|
|
||||||
|
bannerService.registerBanner('startup-missing-model-paths', {
|
||||||
|
id: 'startup-missing-model-paths',
|
||||||
|
title: 'Model folders need setup',
|
||||||
|
content: 'stub',
|
||||||
|
dismissible: false,
|
||||||
|
priority: 60,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Clear every row and save: an all-empty path set must not retire the
|
||||||
|
// setup prompt.
|
||||||
|
document.querySelectorAll('#modelFolderPaths-loras .extra-folder-path-input')
|
||||||
|
.forEach((input) => { input.value = ''; });
|
||||||
|
await manager.updateModelFolderPaths('loras');
|
||||||
|
|
||||||
|
expect(manager.saveSetting).toHaveBeenCalled();
|
||||||
|
expect(bannerService.banners.has('startup-missing-model-paths')).toBe(true);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user