diff --git a/ui/src/app/core/settings.service.ts b/ui/src/app/core/settings.service.ts index fe7604edd9..54780708af 100644 --- a/ui/src/app/core/settings.service.ts +++ b/ui/src/app/core/settings.service.ts @@ -39,8 +39,8 @@ export class SettingsService { public keepOrphans: boolean public wallpaper: string public serverTimeOffset = 0 - public rtl = false // set true if current translation is RLT - public browserLang: string // set by the browser language + public rtl = false + public browserLang: string public onSettingsLoaded = this.settingsLoadedSubject.pipe(first()) public settingsLoaded = false public readonly themeList = [ @@ -104,24 +104,28 @@ export class SettingsService { } } + private getIframeOrigin(iframe: HTMLIFrameElement): string { + try { + const src = iframe.getAttribute('src') || '' + const url = new URL(src, window.location.href) + return url.origin + } catch { + return '' + } + } + public setTheme(theme: string) { - // Default theme is deep-purple if (!theme || !this.themeList.includes(theme)) { theme = this.defaultTheme - - // Save the new property to the config file firstValueFrom(this.$api.put('/config-editor/ui', { key: 'theme', value: theme })) .catch(error => console.error('Error saving setTheme:', error)) } - // Grab the body element const bodySelector = window.document.querySelector('body') - // Remove all existing theme classes bodySelector.classList.remove(`config-ui-x-${this.theme}`) bodySelector.classList.remove(`config-ui-x-dark-mode-${this.theme}`) - // Set the new theme this.theme = theme if (this.actualLightingMode === 'dark') { bodySelector.classList.add(`config-ui-x-dark-mode-${this.theme}`) @@ -135,50 +139,59 @@ export class SettingsService { } } - // Update same-origin iframes - const iframes = window.document.querySelectorAll('iframe') + const iframes = Array.from(window.document.querySelectorAll('iframe')) as HTMLIFrameElement[] iframes.forEach((iframe, index) => { - try { - const iframeDoc = iframe.contentDocument - if (iframeDoc) { - const iframeBody = iframeDoc.body + const iframeOrigin = this.getIframeOrigin(iframe) + const sameOrigin = !!iframeOrigin && iframeOrigin === window.location.origin - if (this.actualLightingMode === 'dark') { - if (iframeBody.classList.contains(`config-ui-x-${this.theme}`)) { - iframeBody.classList.remove(`config-ui-x-${this.theme}`) - } - if (!iframeBody.classList.contains(`config-ui-x-dark-mode-${this.theme}`)) { - iframeBody.classList.add(`config-ui-x-dark-mode-${this.theme}`) - } - if (!iframeBody.classList.contains('dark-mode')) { - iframeBody.classList.add('dark-mode') - } + if (sameOrigin) { + try { + const iframeDoc = iframe.contentDocument + const iframeBody = iframeDoc?.body - iframeBody.style.backgroundColor = '#242424 !important' - iframeBody.style.color = '#ffffff !important' - } else { - if (!iframeBody.classList.contains(`config-ui-x-${this.theme}`)) { - iframeBody.classList.add(`config-ui-x-${this.theme}`) - } - if (iframeBody.classList.contains(`config-ui-x-dark-mode-${this.theme}`)) { - iframeBody.classList.remove(`config-ui-x-dark-mode-${this.theme}`) - } - if (iframeBody.classList.contains('dark-mode')) { - iframeBody.classList.remove('dark-mode') - } + if (iframeBody) { + if (this.actualLightingMode === 'dark') { + if (iframeBody.classList.contains(`config-ui-x-${this.theme}`)) { + iframeBody.classList.remove(`config-ui-x-${this.theme}`) + } + if (!iframeBody.classList.contains(`config-ui-x-dark-mode-${this.theme}`)) { + iframeBody.classList.add(`config-ui-x-dark-mode-${this.theme}`) + } + if (!iframeBody.classList.contains('dark-mode')) { + iframeBody.classList.add('dark-mode') + } + + iframeBody.style.backgroundColor = '#242424 !important' + iframeBody.style.color = '#ffffff !important' + } else { + if (!iframeBody.classList.contains(`config-ui-x-${this.theme}`)) { + iframeBody.classList.add(`config-ui-x-${this.theme}`) + } + if (iframeBody.classList.contains(`config-ui-x-dark-mode-${this.theme}`)) { + iframeBody.classList.remove(`config-ui-x-dark-mode-${this.theme}`) + } + if (iframeBody.classList.contains('dark-mode')) { + iframeBody.classList.remove('dark-mode') + } - iframeBody.style.backgroundColor = '#ffffff !important' - iframeBody.style.color = '#000000 !important' + iframeBody.style.backgroundColor = '#ffffff !important' + iframeBody.style.color = '#000000 !important' + } } + } catch (e) { + console.warn(`Iframe ${index}: Same-origin access failed`, { error: e, src: iframe.src }) + } + } - // Notify iframe Angular app + try { + if (iframe.contentWindow) { iframe.contentWindow.postMessage( { type: 'theme-update', isDark: this.actualLightingMode === 'dark', theme }, - window.location.origin, + iframeOrigin || '*', ) } } catch (e) { - console.warn(`Iframe ${index}: Access denied (cross-origin?)`, { error: e, src: iframe.src }) + console.warn(`Iframe ${index}: postMessage failed`, { error: e, src: iframe.src }) } }) } @@ -205,7 +218,6 @@ export class SettingsService { } public setEnvItem(key: string, value: any) { - // If the key contains a dot, we assume it's a nested property if (key.includes('.')) { const keys = key.split('.') let current = this.env @@ -226,11 +238,6 @@ export class SettingsService { } } - /** - * Check to make sure the server time is roughly the same as the client time. - * A warning is shown if the time difference is >= 4 hours. - * @param timestamp - */ private checkServerTime(timestamp: string) { const serverTime = dayjs(timestamp) const diff = serverTime.diff(dayjs(), 'hour') @@ -269,11 +276,6 @@ export class SettingsService { } } - /** - * Check if a specific feature is enabled based on feature flags - * @param featureKey The feature flag key to check - * @returns true if the feature is enabled, false otherwise - */ public isFeatureEnabled(featureKey: string): boolean { return this.env.featureFlags?.[featureKey] ?? false } diff --git a/ui/src/app/modules/settings/backup/backup.component.html b/ui/src/app/modules/settings/backup/backup.component.html index c7d78d7cb2..c5630df987 100644 --- a/ui/src/app/modules/settings/backup/backup.component.html +++ b/ui/src/app/modules/settings/backup/backup.component.html @@ -10,24 +10,31 @@ [disabled]="clicked || deleting" > + diff --git a/ui/src/app/modules/settings/settings.component.html b/ui/src/app/modules/settings/settings.component.html index 71db5f844e..f19830e237 100644 --- a/ui/src/app/modules/settings/settings.component.html +++ b/ui/src/app/modules/settings/settings.component.html @@ -8,6 +8,8 @@

{{ 'menu.settings.title' | translate }}

class="btn btn-elegant my-0 me-0" (click)="toggleSearch()" [attr.aria-label]="'form.search' | translate" + [attr.aria-expanded]="showSearchBar" + aria-controls="settings-search-region" > @@ -15,7 +17,7 @@

{{ 'menu.settings.title' | translate }}

@if (showSearchBar) { -
+
} @if (!isItemHidden('setting-backup')) {
  • - {{ 'backup.title_backup' | translate }} +
  • } @if (!isItemHidden('setting-restore')) {
  • - {{ 'config.restore.title' | translate }} +
  • } @if (!isItemHidden('setting-users')) {
  • - {{ 'menu.tooltip_user_accounts' | translate }} + { - void this.$router.navigate(['/restart']) - }) - } + if (this.restartToastIsShown) { + return } + this.restartToastIsShown = true + const msg = this.$translate.instant('settings.changes.saved') + const html = ` +
    +

    ${msg}

    +
    + ` + const ref = this.$toastr.info(html, '', { + timeOut: 0, + disableTimeOut: true, + enableHtml: true, + tapToDismiss: false, + closeButton: true, + positionClass: 'toast-bottom-right', + toastClass: 'ngx-toastr hb-restart-toast-toast', + }) + this.$settings.restartToastRef = ref + ref.onShown.subscribe(() => { + const container = document.getElementById('toast-container') + if (!container) { + return + } + const toastEl + = container.querySelector(`#toast-${ref.toastId}`) + || container + toastEl.setAttribute('role', 'alert') + toastEl.setAttribute('aria-live', 'assertive') + toastEl.setAttribute('aria-atomic', 'true') + const body = toastEl.querySelector('.toast-message') + const closeBtn = toastEl.querySelector('.toast-close-button') + if (!body || !closeBtn) { + return + } + closeBtn.setAttribute('type', 'button') + closeBtn.setAttribute('aria-label', 'Close') + const msgEl = body.querySelector('.hb-restart-toast-msg') + if (!msgEl) { + return + } + const restartBtn = document.createElement('button') + restartBtn.type = 'button' + restartBtn.className = closeBtn.className + restartBtn.textContent = 'Restart Homebridge' + // Remove the aria-label - textContent already provides the accessible name + const activate = (ev: Event) => { + ev.preventDefault() + ev.stopPropagation() + void this.$router.navigate(['/restart']) + this.$toastr.clear(ref.toastId) + } + restartBtn.addEventListener('click', activate) + restartBtn.addEventListener('keydown', (ev: KeyboardEvent) => { + if (ev.key === 'Enter' || ev.key === ' ') { + activate(ev) + } + }) + while (body.firstChild) { + body.removeChild(body.firstChild) + } + body.appendChild(msgEl) + body.appendChild(restartBtn) + }) + ref.onHidden.subscribe(() => { + this.restartToastIsShown = false + }) } } diff --git a/ui/src/app/shared/layout/sidebar/sidebar.component.html b/ui/src/app/shared/layout/sidebar/sidebar.component.html index 6262bb9a99..aa2a0eddf6 100644 --- a/ui/src/app/shared/layout/sidebar/sidebar.component.html +++ b/ui/src/app/shared/layout/sidebar/sidebar.component.html @@ -1,216 +1,543 @@ -