chore(ui): improve notification center accessibility

This commit is contained in:
pixelpaws
2025-11-02 20:59:00 +08:00
parent 7e40f6fcb9
commit 4e3ee843f9
15 changed files with 804 additions and 65 deletions

View File

@@ -198,6 +198,7 @@
}, },
"actions": { "actions": {
"checkUpdates": "Updates prüfen", "checkUpdates": "Updates prüfen",
"notifications": "Benachrichtigungen",
"support": "Unterstützung" "support": "Unterstützung"
} }
}, },
@@ -1066,6 +1067,11 @@
}, },
"update": { "update": {
"title": "Nach Updates suchen", "title": "Nach Updates suchen",
"notificationsTitle": "Benachrichtigungszentrum",
"tabs": {
"updates": "Aktualisierungen",
"messages": "Mitteilungen"
},
"updateAvailable": "Update verfügbar", "updateAvailable": "Update verfügbar",
"noChangelogAvailable": "Kein detailliertes Changelog verfügbar. Weitere Informationen auf GitHub.", "noChangelogAvailable": "Kein detailliertes Changelog verfügbar. Weitere Informationen auf GitHub.",
"currentVersion": "Aktuelle Version", "currentVersion": "Aktuelle Version",
@@ -1097,6 +1103,13 @@
"nightly": { "nightly": {
"warning": "Warnung: Nightly Builds können experimentelle Funktionen enthalten und könnten instabil sein.", "warning": "Warnung: Nightly Builds können experimentelle Funktionen enthalten und könnten instabil sein.",
"enable": "Nightly Updates aktivieren" "enable": "Nightly Updates aktivieren"
},
"banners": {
"recent": "Neueste Mitteilungen",
"empty": "Keine aktuellen Banner verfügbar.",
"shown": "{time} angezeigt",
"dismissed": "{time} geschlossen",
"active": "Aktiv"
} }
}, },
"support": { "support": {

View File

@@ -198,6 +198,7 @@
}, },
"actions": { "actions": {
"checkUpdates": "Check Updates", "checkUpdates": "Check Updates",
"notifications": "Notifications",
"support": "Support" "support": "Support"
} }
}, },
@@ -1065,6 +1066,11 @@
}, },
"update": { "update": {
"title": "Check for Updates", "title": "Check for Updates",
"notificationsTitle": "Notifications",
"tabs": {
"updates": "Updates",
"messages": "Messages"
},
"updateAvailable": "Update Available", "updateAvailable": "Update Available",
"noChangelogAvailable": "No detailed changelog available. Check GitHub for more information.", "noChangelogAvailable": "No detailed changelog available. Check GitHub for more information.",
"currentVersion": "Current Version", "currentVersion": "Current Version",
@@ -1096,6 +1102,13 @@
"nightly": { "nightly": {
"warning": "Warning: Nightly builds may contain experimental features and could be unstable.", "warning": "Warning: Nightly builds may contain experimental features and could be unstable.",
"enable": "Enable Nightly Updates" "enable": "Enable Nightly Updates"
},
"banners": {
"recent": "Recent messages",
"empty": "No recent banners yet.",
"shown": "Shown {time}",
"dismissed": "Dismissed {time}",
"active": "Active"
} }
}, },
"support": { "support": {

View File

@@ -198,6 +198,7 @@
}, },
"actions": { "actions": {
"checkUpdates": "Comprobar actualizaciones", "checkUpdates": "Comprobar actualizaciones",
"notifications": "Notificaciones",
"support": "Soporte" "support": "Soporte"
} }
}, },
@@ -1065,6 +1066,11 @@
}, },
"update": { "update": {
"title": "Comprobar actualizaciones", "title": "Comprobar actualizaciones",
"notificationsTitle": "Centro de notificaciones",
"tabs": {
"updates": "Actualizaciones",
"messages": "Mensajes"
},
"updateAvailable": "Actualización disponible", "updateAvailable": "Actualización disponible",
"noChangelogAvailable": "No hay registro de cambios detallado disponible. Revisa GitHub para más información.", "noChangelogAvailable": "No hay registro de cambios detallado disponible. Revisa GitHub para más información.",
"currentVersion": "Versión actual", "currentVersion": "Versión actual",
@@ -1096,6 +1102,13 @@
"nightly": { "nightly": {
"warning": "Advertencia: Las compilaciones nocturnas pueden contener características experimentales y podrían ser inestables.", "warning": "Advertencia: Las compilaciones nocturnas pueden contener características experimentales y podrían ser inestables.",
"enable": "Habilitar actualizaciones nocturnas" "enable": "Habilitar actualizaciones nocturnas"
},
"banners": {
"recent": "Notificaciones recientes",
"empty": "No hay banners recientes.",
"shown": "Mostrado {time}",
"dismissed": "Descartado {time}",
"active": "Activo"
} }
}, },
"support": { "support": {

View File

@@ -198,6 +198,7 @@
}, },
"actions": { "actions": {
"checkUpdates": "Vérifier les mises à jour", "checkUpdates": "Vérifier les mises à jour",
"notifications": "Notifications",
"support": "Support" "support": "Support"
} }
}, },
@@ -1065,6 +1066,11 @@
}, },
"update": { "update": {
"title": "Vérifier les mises à jour", "title": "Vérifier les mises à jour",
"notificationsTitle": "Notifications",
"tabs": {
"updates": "Mises à jour",
"messages": "Messages"
},
"updateAvailable": "Mise à jour disponible", "updateAvailable": "Mise à jour disponible",
"noChangelogAvailable": "Aucun journal des modifications détaillé disponible. Consultez GitHub pour plus d'informations.", "noChangelogAvailable": "Aucun journal des modifications détaillé disponible. Consultez GitHub pour plus d'informations.",
"currentVersion": "Version actuelle", "currentVersion": "Version actuelle",
@@ -1096,6 +1102,13 @@
"nightly": { "nightly": {
"warning": "Attention : Les versions nightly peuvent contenir des fonctionnalités expérimentales et être instables.", "warning": "Attention : Les versions nightly peuvent contenir des fonctionnalités expérimentales et être instables.",
"enable": "Activer les mises à jour nightly" "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": { "support": {

View File

@@ -198,6 +198,7 @@
}, },
"actions": { "actions": {
"checkUpdates": "בדוק עדכונים", "checkUpdates": "בדוק עדכונים",
"notifications": "התראות",
"support": "תמיכה" "support": "תמיכה"
} }
}, },
@@ -1065,6 +1066,11 @@
}, },
"update": { "update": {
"title": "בדוק עדכונים", "title": "בדוק עדכונים",
"notificationsTitle": "מרכז התראות",
"tabs": {
"updates": "עדכונים",
"messages": "הודעות"
},
"updateAvailable": "עדכון זמין", "updateAvailable": "עדכון זמין",
"noChangelogAvailable": "אין יומן שינויים מפורט זמין. בדוק ב-GitHub למידע נוסף.", "noChangelogAvailable": "אין יומן שינויים מפורט זמין. בדוק ב-GitHub למידע נוסף.",
"currentVersion": "גרסה נוכחית", "currentVersion": "גרסה נוכחית",
@@ -1096,6 +1102,13 @@
"nightly": { "nightly": {
"warning": "אזהרה: גרסאות ליליות עשויות להכיל תכונות ניסיוניות ועלולות להיות לא יציבות.", "warning": "אזהרה: גרסאות ליליות עשויות להכיל תכונות ניסיוניות ועלולות להיות לא יציבות.",
"enable": "הפעל עדכונים ליליים" "enable": "הפעל עדכונים ליליים"
},
"banners": {
"recent": "הודעות אחרונות",
"empty": "אין כרגע באנרים אחרונים.",
"shown": "הוצג {time}",
"dismissed": "הוסר {time}",
"active": "פעיל"
} }
}, },
"support": { "support": {

View File

@@ -198,6 +198,7 @@
}, },
"actions": { "actions": {
"checkUpdates": "更新確認", "checkUpdates": "更新確認",
"notifications": "通知",
"support": "サポート" "support": "サポート"
} }
}, },
@@ -1065,6 +1066,11 @@
}, },
"update": { "update": {
"title": "更新確認", "title": "更新確認",
"notificationsTitle": "通知センター",
"tabs": {
"updates": "更新",
"messages": "メッセージ"
},
"updateAvailable": "更新が利用可能", "updateAvailable": "更新が利用可能",
"noChangelogAvailable": "詳細な変更ログは利用できません。詳細はGitHubでご確認ください。", "noChangelogAvailable": "詳細な変更ログは利用できません。詳細はGitHubでご確認ください。",
"currentVersion": "現在のバージョン", "currentVersion": "現在のバージョン",
@@ -1096,6 +1102,13 @@
"nightly": { "nightly": {
"warning": "警告:ナイトリービルドには実験的機能が含まれており、不安定な場合があります。", "warning": "警告:ナイトリービルドには実験的機能が含まれており、不安定な場合があります。",
"enable": "ナイトリー更新を有効にする" "enable": "ナイトリー更新を有効にする"
},
"banners": {
"recent": "最近の通知",
"empty": "最近のバナーはありません。",
"shown": "{time} に表示",
"dismissed": "{time} に非表示",
"active": "アクティブ"
} }
}, },
"support": { "support": {

View File

@@ -198,6 +198,7 @@
}, },
"actions": { "actions": {
"checkUpdates": "업데이트 확인", "checkUpdates": "업데이트 확인",
"notifications": "알림",
"support": "지원" "support": "지원"
} }
}, },
@@ -1065,6 +1066,11 @@
}, },
"update": { "update": {
"title": "업데이트 확인", "title": "업데이트 확인",
"notificationsTitle": "알림 센터",
"tabs": {
"updates": "업데이트",
"messages": "메시지"
},
"updateAvailable": "업데이트 사용 가능", "updateAvailable": "업데이트 사용 가능",
"noChangelogAvailable": "상세한 변경 로그가 없습니다. 더 많은 정보는 GitHub를 확인하세요.", "noChangelogAvailable": "상세한 변경 로그가 없습니다. 더 많은 정보는 GitHub를 확인하세요.",
"currentVersion": "현재 버전", "currentVersion": "현재 버전",
@@ -1096,6 +1102,13 @@
"nightly": { "nightly": {
"warning": "경고: 나이틀리 빌드는 실험적 기능을 포함할 수 있으며 불안정할 수 있습니다.", "warning": "경고: 나이틀리 빌드는 실험적 기능을 포함할 수 있으며 불안정할 수 있습니다.",
"enable": "나이틀리 업데이트 활성화" "enable": "나이틀리 업데이트 활성화"
},
"banners": {
"recent": "최근 알림",
"empty": "최근 배너가 없습니다.",
"shown": "{time}에 표시",
"dismissed": "{time}에 닫힘",
"active": "활성"
} }
}, },
"support": { "support": {

View File

@@ -198,6 +198,7 @@
}, },
"actions": { "actions": {
"checkUpdates": "Проверить обновления", "checkUpdates": "Проверить обновления",
"notifications": "Уведомления",
"support": "Поддержка" "support": "Поддержка"
} }
}, },
@@ -1065,6 +1066,11 @@
}, },
"update": { "update": {
"title": "Проверить обновления", "title": "Проверить обновления",
"notificationsTitle": "Центр уведомлений",
"tabs": {
"updates": "Обновления",
"messages": "Сообщения"
},
"updateAvailable": "Доступно обновление", "updateAvailable": "Доступно обновление",
"noChangelogAvailable": "Подробный список изменений недоступен. Проверьте GitHub для получения дополнительной информации.", "noChangelogAvailable": "Подробный список изменений недоступен. Проверьте GitHub для получения дополнительной информации.",
"currentVersion": "Текущая версия", "currentVersion": "Текущая версия",
@@ -1096,6 +1102,13 @@
"nightly": { "nightly": {
"warning": "Предупреждение: Ночные сборки могут содержать экспериментальные функции и могут быть нестабильными.", "warning": "Предупреждение: Ночные сборки могут содержать экспериментальные функции и могут быть нестабильными.",
"enable": "Включить ночные обновления" "enable": "Включить ночные обновления"
},
"banners": {
"recent": "Недавние уведомления",
"empty": "Недавних баннеров нет.",
"shown": "Показано {time}",
"dismissed": "Закрыто {time}",
"active": "Активно"
} }
}, },
"support": { "support": {

View File

@@ -198,6 +198,7 @@
}, },
"actions": { "actions": {
"checkUpdates": "检查更新", "checkUpdates": "检查更新",
"notifications": "通知",
"support": "支持" "support": "支持"
} }
}, },
@@ -1065,6 +1066,11 @@
}, },
"update": { "update": {
"title": "检查更新", "title": "检查更新",
"notificationsTitle": "通知中心",
"tabs": {
"updates": "更新",
"messages": "消息"
},
"updateAvailable": "更新可用", "updateAvailable": "更新可用",
"noChangelogAvailable": "没有详细的更新日志可用。请查看 GitHub 以获取更多信息。", "noChangelogAvailable": "没有详细的更新日志可用。请查看 GitHub 以获取更多信息。",
"currentVersion": "当前版本", "currentVersion": "当前版本",
@@ -1096,6 +1102,13 @@
"nightly": { "nightly": {
"warning": "警告Nightly 版本可能包含实验性功能,可能不稳定。", "warning": "警告Nightly 版本可能包含实验性功能,可能不稳定。",
"enable": "启用 Nightly 更新" "enable": "启用 Nightly 更新"
},
"banners": {
"recent": "最近的通知",
"empty": "暂无最近的横幅通知。",
"shown": "{time} 显示",
"dismissed": "{time} 关闭",
"active": "仍在显示"
} }
}, },
"support": { "support": {

View File

@@ -198,6 +198,7 @@
}, },
"actions": { "actions": {
"checkUpdates": "檢查更新", "checkUpdates": "檢查更新",
"notifications": "通知",
"support": "支援" "support": "支援"
} }
}, },
@@ -1065,6 +1066,11 @@
}, },
"update": { "update": {
"title": "檢查更新", "title": "檢查更新",
"notificationsTitle": "通知中心",
"tabs": {
"updates": "更新",
"messages": "訊息"
},
"updateAvailable": "有新版本可用", "updateAvailable": "有新版本可用",
"noChangelogAvailable": "無詳細更新日誌。請至 GitHub 查看更多資訊。", "noChangelogAvailable": "無詳細更新日誌。請至 GitHub 查看更多資訊。",
"currentVersion": "目前版本", "currentVersion": "目前版本",
@@ -1096,6 +1102,13 @@
"nightly": { "nightly": {
"warning": "警告Nightly 版本可能包含實驗性功能且可能不穩定。", "warning": "警告Nightly 版本可能包含實驗性功能且可能不穩定。",
"enable": "啟用 Nightly 更新" "enable": "啟用 Nightly 更新"
},
"banners": {
"recent": "最新通知",
"empty": "目前沒有最近的橫幅通知。",
"shown": "{time} 顯示",
"dismissed": "{time} 關閉",
"active": "仍在顯示"
} }
}, },
"support": { "support": {

View File

@@ -12,6 +12,73 @@
border-bottom: 1px solid var(--lora-border); 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 { .update-icon {
font-size: 1.8em; font-size: 1.8em;
color: var(--lora-accent); color: var(--lora-accent);
@@ -165,6 +232,137 @@
justify-content: flex-start; 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 */ /* Override toggle switch styles for update preferences */
.update-preferences .toggle-switch { .update-preferences .toggle-switch {
position: relative; position: relative;

View File

@@ -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_FIRST_SEEN_AT_KEY = 'community_support_banner_first_seen_at';
const COMMUNITY_SUPPORT_SHOWN_KEY = 'community_support_banner_shown'; const COMMUNITY_SUPPORT_SHOWN_KEY = 'community_support_banner_shown';
const KO_FI_URL = 'https://ko-fi.com/pixelpawsai'; 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 * Banner Service for managing notification banners
@@ -20,6 +24,8 @@ class BannerService {
this.initialized = false; this.initialized = false;
this.communitySupportBannerTimer = null; this.communitySupportBannerTimer = null;
this.communitySupportBannerRegistered = false; this.communitySupportBannerRegistered = false;
this.recentHistory = this.loadBannerHistory();
this.bannerHistoryViewedAt = this.loadBannerHistoryViewedAt();
} }
/** /**
@@ -120,6 +126,8 @@ class BannerService {
this.updateContainerVisibility(); this.updateContainerVisibility();
}, 300); }, 300);
} }
this.markBannerDismissed(bannerId);
} }
/** /**
@@ -178,7 +186,9 @@ class BannerService {
`; `;
this.container.appendChild(bannerElement); this.container.appendChild(bannerElement);
this.recordBannerAppearance(banner);
// Call onRegister callback if provided // Call onRegister callback if provided
if (typeof banner.onRegister === 'function') { if (typeof banner.onRegister === 'function') {
banner.onRegister(bannerElement); banner.onRegister(bannerElement);
@@ -296,6 +306,95 @@ class BannerService {
this.updateContainerVisibility(); 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 // Create and export singleton instance

View File

@@ -28,6 +28,9 @@ export class UpdateService {
this.nightlyMode = getStorageItem('nightly_updates', false); this.nightlyMode = getStorageItem('nightly_updates', false);
this.currentVersionInfo = null; this.currentVersionInfo = null;
this.versionMismatch = false; this.versionMismatch = false;
this.activeNotificationTab = 'updates';
this.handleBannerHistoryUpdated = this.handleBannerHistoryUpdated.bind(this);
this.handleNotificationTabKeydown = this.handleNotificationTabKeydown.bind(this);
} }
initialize() { initialize() {
@@ -61,6 +64,10 @@ export class UpdateService {
}); });
this.updateNightlyWarning(); this.updateNightlyWarning();
} }
this.setupNotificationCenter();
window.addEventListener('lm:banner-history-updated', this.handleBannerHistoryUpdated);
this.updateTabBadges();
// Perform update check if needed // Perform update check if needed
this.checkForUpdates().then(() => { this.checkForUpdates().then(() => {
@@ -81,6 +88,272 @@ export class UpdateService {
warning.style.display = this.nightlyMode ? 'flex' : 'none'; 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 } = {}) { async checkForUpdates({ force = false } = {}) {
if (!force && !this.updateNotificationsEnabled) { if (!force && !this.updateNotificationsEnabled) {
@@ -167,20 +440,29 @@ export class UpdateService {
updateBadgeVisibility() { updateBadgeVisibility() {
const updateToggle = document.querySelector('.update-toggle'); const updateToggle = document.querySelector('.update-toggle');
const updateBadge = document.querySelector('.update-toggle .update-badge'); const updateBadge = document.querySelector('.update-toggle .update-badge');
const unreadBanners = typeof bannerService.getUnreadBannerCount === 'function'
? bannerService.getUnreadBannerCount()
: 0;
if (updateToggle) { if (updateToggle) {
updateToggle.title = this.updateNotificationsEnabled && this.updateAvailable let tooltipKey = 'header.actions.notifications';
? translate('update.updateAvailable') if (this.updateNotificationsEnabled && this.updateAvailable) {
: translate('update.title'); tooltipKey = 'update.updateAvailable';
} else if (unreadBanners > 0) {
tooltipKey = 'update.tabs.messages';
}
updateToggle.title = translate(tooltipKey);
} }
// Force updating badges visibility based on current state // 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) { if (updateBadge) {
updateBadge.classList.toggle('visible', shouldShow); updateBadge.classList.toggle('visible', shouldShow);
console.log("Update badge visibility:", shouldShow ? "visible" : "hidden");
} }
this.updateTabBadges();
} }
updateModalContent() { updateModalContent() {
@@ -190,9 +472,9 @@ export class UpdateService {
// Update title based on update availability // Update title based on update availability
const headerTitle = modal.querySelector('.update-header h2'); const headerTitle = modal.querySelector('.update-header h2');
if (headerTitle) { if (headerTitle) {
headerTitle.textContent = this.updateAvailable ? headerTitle.textContent = this.updateAvailable ?
translate('update.updateAvailable') : translate('update.updateAvailable') :
translate('update.title'); translate('update.notificationsTitle');
} }
// Always update version information, even if updateInfo is null // Always update version information, even if updateInfo is null
@@ -418,23 +700,32 @@ export class UpdateService {
toggleUpdateModal() { toggleUpdateModal() {
const updateModal = modalManager.getModal('updateModal'); const updateModal = modalManager.getModal('updateModal');
// If modal is already open, just close it // If modal is already open, just close it
if (updateModal && updateModal.isOpen) { if (updateModal && updateModal.isOpen) {
modalManager.closeModal('updateModal'); modalManager.closeModal('updateModal');
return; return;
} }
if (!Array.isArray(this.notificationTabs) || !this.notificationTabs.length) {
this.setupNotificationCenter();
}
// Update the modal content immediately with current data // Update the modal content immediately with current data
this.updateModalContent(); this.updateModalContent();
this.renderRecentBanners();
// Show the modal with current data // Show the modal with current data
modalManager.showModal('updateModal'); modalManager.showModal('updateModal');
this.switchNotificationTab(this.activeNotificationTab, { markRead: true });
// Then check for updates in the background // Then check for updates in the background
this.manualCheckForUpdates().then(() => { this.manualCheckForUpdates().then(() => {
// Update the modal content again after the check completes // Update the modal content again after the check completes
this.updateModalContent(); this.updateModalContent();
if (this.activeNotificationTab === 'banners' && this.isNotificationModalOpen()) {
this.renderRecentBanners();
}
}); });
} }

View File

@@ -69,7 +69,7 @@
<i class="fas fa-question-circle"></i> <i class="fas fa-question-circle"></i>
<span class="update-badge"></span> <span class="update-badge"></span>
</div> </div>
<div class="update-toggle" id="updateToggleBtn" title="{{ t('header.actions.checkUpdates') }}"> <div class="update-toggle" id="updateToggleBtn" title="{{ t('header.actions.notifications') }}">
<i class="fas fa-bell"></i> <i class="fas fa-bell"></i>
<span class="update-badge"></span> <span class="update-badge"></span>
</div> </div>

View File

@@ -4,61 +4,82 @@
<button class="close" onclick="modalManager.closeModal('updateModal')">&times;</button> <button class="close" onclick="modalManager.closeModal('updateModal')">&times;</button>
<div class="update-header"> <div class="update-header">
<i class="fas fa-bell update-icon"></i> <i class="fas fa-bell update-icon"></i>
<h2 data-i18n="update.title">{{ t('update.title') }}</h2> <h2 data-i18n="update.notificationsTitle">{{ t('update.notificationsTitle') }}</h2>
</div> </div>
<div class="update-content"> <div class="notification-tabs" role="tablist">
<div class="update-info"> <button type="button" class="notification-tab active" id="updatesTab" role="tab" aria-selected="true" aria-controls="updatesPanel" tabindex="0" data-notification-tab="updates">
<div class="version-info"> <span>{{ t('update.tabs.updates') }}</span>
<div class="current-version"> <span class="notification-tab-badge is-dot" id="updatesTabBadge"></span>
<span class="label">{{ t('update.currentVersion') }}:</span> </button>
<span class="version-number">v0.0.0</span> <button type="button" class="notification-tab" id="bannersTab" role="tab" aria-selected="false" aria-controls="bannersPanel" tabindex="-1" data-notification-tab="banners">
<span>{{ t('update.tabs.messages') }}</span>
<span class="notification-tab-badge" id="bannerTabBadge"></span>
</button>
</div>
<div class="notification-panels">
<div class="notification-panel active" id="updatesPanel" role="tabpanel" aria-labelledby="updatesTab" aria-hidden="false" tabindex="0" data-notification-panel="updates">
<div class="update-content">
<div class="update-info">
<div class="version-info">
<div class="current-version">
<span class="label">{{ t('update.currentVersion') }}:</span>
<span class="version-number">v0.0.0</span>
</div>
<div class="git-info" style="display:none;">{{ t('update.commit') }}: unknown</div>
<div class="new-version">
<span class="label">{{ t('update.newVersion') }}:</span>
<span class="version-number">v0.0.0</span>
</div>
</div>
<div class="update-actions">
<a href="https://github.com/willmiao/ComfyUI-Lora-Manager" target="_blank" class="update-link">
<i class="fas fa-external-link-alt"></i> {{ t('update.viewOnGitHub') }}
</a>
<button id="updateBtn" class="primary-btn disabled">
<i class="fas fa-download"></i>
<span id="updateBtnText" data-i18n="update.updateNow">{{ t('update.updateNow') }}</span>
</button>
</div>
</div> </div>
<div class="git-info" style="display:none;">{{ t('update.commit') }}: unknown</div>
<div class="new-version"> <!-- Update Progress Section -->
<span class="label">{{ t('update.newVersion') }}:</span> <div class="update-progress" id="updateProgress" style="display: none;">
<span class="version-number">v0.0.0</span> <div class="progress-info">
<div class="progress-text" id="updateProgressText" data-i18n="update.preparingUpdate">{{ t('update.preparingUpdate') }}</div>
<div class="update-progress-bar">
<div class="progress-fill" id="updateProgressFill"></div>
</div>
</div>
</div> </div>
</div>
<div class="changelog-section">
<div class="update-actions"> <h3>{{ t('update.changelog') }}</h3>
<a href="https://github.com/willmiao/ComfyUI-Lora-Manager" target="_blank" class="update-link"> <div class="changelog-content">
<i class="fas fa-external-link-alt"></i> {{ t('update.viewOnGitHub') }} <!-- Dynamic changelog content will be inserted here -->
</a> <div class="changelog-item">
<button id="updateBtn" class="primary-btn disabled"> <h4>{{ t('update.checkingUpdates') }}</h4>
<i class="fas fa-download"></i> <p>{{ t('update.checkingMessage') }}</p>
<span id="updateBtnText" data-i18n="update.updateNow">{{ t('update.updateNow') }}</span> </div>
</button> </div>
</div> </div>
</div>
<div class="update-preferences">
<!-- Update Progress Section --> <label class="toggle-switch">
<div class="update-progress" id="updateProgress" style="display: none;"> <input type="checkbox" id="updateNotifications" checked>
<div class="progress-info"> <span class="toggle-slider"></span>
<div class="progress-text" id="updateProgressText" data-i18n="update.preparingUpdate">{{ t('update.preparingUpdate') }}</div> <span class="toggle-label">{{ t('update.showNotifications') }}</span>
<div class="update-progress-bar"> </label>
<div class="progress-fill" id="updateProgressFill"></div>
</div> </div>
</div> </div>
</div> </div>
<div class="notification-panel" id="bannersPanel" role="tabpanel" aria-labelledby="bannersTab" aria-hidden="true" tabindex="-1" data-notification-panel="banners">
<div class="changelog-section"> <div class="banner-history">
<h3>{{ t('update.changelog') }}</h3> <h3>{{ t('update.banners.recent') }}</h3>
<div class="changelog-content"> <p class="banner-history-empty" id="bannerHistoryEmpty" data-i18n="update.banners.empty">{{ t('update.banners.empty') }}</p>
<!-- Dynamic changelog content will be inserted here --> <ul class="banner-history-list" id="bannerHistoryList"></ul>
<div class="changelog-item">
<h4>{{ t('update.checkingUpdates') }}</h4>
<p>{{ t('update.checkingMessage') }}</p>
</div>
</div> </div>
</div> </div>
<div class="update-preferences">
<label class="toggle-switch">
<input type="checkbox" id="updateNotifications" checked>
<span class="toggle-slider"></span>
<span class="toggle-label">{{ t('update.showNotifications') }}</span>
</label>
</div>
</div> </div>
</div> </div>
</div> </div>