From 0012fcfa7b6d295429948f2e861d34feb51d2969 Mon Sep 17 00:00:00 2001 From: 4gray Date: Tue, 10 Mar 2026 08:37:46 +0100 Subject: [PATCH] style(settings): update look&feel, add missing i18n strings --- apps/web-e2e/src/settings.e2e.ts | 11 +- apps/web/src/app/app.component.ts | 12 +- .../src/app/services/settings.service.spec.ts | 46 ++ apps/web/src/app/services/settings.service.ts | 77 +++- .../src/app/settings/settings.component.html | 113 +++-- .../src/app/settings/settings.component.scss | 424 +++++++++++------- .../app/settings/settings.component.spec.ts | 62 ++- .../src/app/settings/settings.component.ts | 70 ++- .../src/lib/settings-store.service.ts | 2 +- libs/shared/interfaces/src/lib/theme.enum.ts | 1 + .../src/lib/dashboard-data.service.spec.ts | 11 + .../src/lib/dashboard-data.service.ts | 83 +++- .../src/lib/dashboard-layout.service.ts | 8 +- .../data-access/src/lib/dashboard-mappers.ts | 20 +- .../src/lib/dashboard-navigation.utils.ts | 2 +- .../src/lib/dashboard-widget.model.ts | 16 +- .../lib/workspace-dashboard.component.html | 36 +- .../src/lib/workspace-dashboard.component.ts | 70 ++- .../continue-watching-widget.component.ts | 43 +- .../dashboard-activity-items.component.ts | 4 +- .../global-favorites-widget.component.html | 10 +- .../global-favorites-widget.component.ts | 30 +- .../recently-watched-widget.component.html | 12 +- .../recently-watched-widget.component.ts | 30 +- .../global-favorites-list.component.html | 26 +- .../global-favorites-list.component.ts | 2 + .../global-favorites-page.component.html | 17 +- .../global-favorites-page.component.ts | 2 + .../global-favorites.service.ts | 15 +- .../workspace-command-palette.component.html | 18 +- ...orkspace-command-palette.component.spec.ts | 11 + .../workspace-command-palette.component.ts | 25 +- .../workspace-context-panel.component.html | 14 +- .../workspace-context-panel.component.ts | 28 +- ...space-favorites-context-panel.component.ts | 40 +- ...pace-settings-context-panel.component.scss | 27 +- ...kspace-settings-context-panel.component.ts | 29 +- .../workspace-shell.component.html | 63 ++- .../workspace-shell.component.spec.ts | 18 +- .../workspace-shell.component.ts | 275 +++++++++--- ...space-sources-filters-panel.component.html | 8 +- ...rkspace-sources-filters-panel.component.ts | 7 +- .../workspace-sources.component.ts | 37 +- 43 files changed, 1334 insertions(+), 521 deletions(-) diff --git a/apps/web-e2e/src/settings.e2e.ts b/apps/web-e2e/src/settings.e2e.ts index 94b976843..ce673d207 100644 --- a/apps/web-e2e/src/settings.e2e.ts +++ b/apps/web-e2e/src/settings.e2e.ts @@ -43,8 +43,10 @@ test.describe('Settings', () => { test('Change app theme', async ({ page }) => { await page.getByTestId('pwa-menu').click(); await page.getByTestId('pwa-open-settings').click(); - await expect(page.locator('text="Light theme"')).toBeVisible(); - await page.getByTestId('select-theme').click(); + await expect(page.getByTestId('SYSTEM_THEME')).toHaveAttribute( + 'aria-checked', + 'true' + ); await page.getByTestId('DARK_THEME').click(); await page.getByTestId('save-settings').click(); @@ -53,7 +55,10 @@ test.describe('Settings', () => { await page.getByTestId('pwa-menu').click(); await page.getByTestId('pwa-open-settings').click(); - await expect(page.locator('text="Dark theme"')).toBeVisible(); + await expect(page.getByTestId('DARK_THEME')).toHaveAttribute( + 'aria-checked', + 'true' + ); }); test('Change app language', async ({ page }) => { diff --git a/apps/web/src/app/app.component.ts b/apps/web/src/app/app.component.ts index dfc2a5cf9..7579cb1de 100644 --- a/apps/web/src/app/app.component.ts +++ b/apps/web/src/app/app.component.ts @@ -123,18 +123,10 @@ export class AppComponent implements OnInit { } /** - * Detects if the operation system uses dark mode and changes the theme + * Applies the operating system color scheme when no explicit theme is set */ detectDarkMode(): void { - if ( - window.matchMedia && - window.matchMedia('(prefers-color-scheme: dark)').matches - ) { - this.settingsService.changeTheme(Theme.DarkTheme); - this.settingsService.setValueToLocalStorage(STORE_KEY.Settings, { - theme: Theme.DarkTheme, - }); - } + this.settingsService.changeTheme(Theme.SystemTheme); } /** diff --git a/apps/web/src/app/services/settings.service.spec.ts b/apps/web/src/app/services/settings.service.spec.ts index 1d36fbd36..336dfc3d6 100644 --- a/apps/web/src/app/services/settings.service.spec.ts +++ b/apps/web/src/app/services/settings.service.spec.ts @@ -6,7 +6,38 @@ import { STORE_KEY, Theme } from 'shared-interfaces'; import { SettingsService } from './settings.service'; describe('Service: Settings', () => { + let systemThemeListeners: Array<(event: MediaQueryListEvent) => void>; + beforeEach(() => { + systemThemeListeners = []; + Object.defineProperty(window, 'matchMedia', { + writable: true, + value: jest.fn().mockImplementation(() => ({ + matches: true, + addEventListener: jest.fn( + ( + _event: 'change', + listener: (event: MediaQueryListEvent) => void + ) => { + systemThemeListeners.push(listener); + } + ), + removeEventListener: jest.fn( + ( + _event: 'change', + listener: (event: MediaQueryListEvent) => void + ) => { + systemThemeListeners = systemThemeListeners.filter( + (registeredListener) => + registeredListener !== listener + ); + } + ), + addListener: jest.fn(), + removeListener: jest.fn(), + })), + }); + TestBed.configureTestingModule({ providers: [SettingsService], imports: [HttpClientTestingModule], @@ -74,5 +105,20 @@ describe('Service: Settings', () => { expect(spyOnAdd).toHaveBeenCalledTimes(0); } )); + + it('should follow the system theme', inject( + [SettingsService], + (service: SettingsService) => { + service.changeTheme(Theme.SystemTheme); + + expect(spyOnAdd).toHaveBeenCalledWith('dark-theme'); + + systemThemeListeners[0]?.({ + matches: false, + } as MediaQueryListEvent); + + expect(spyOnRemove).toHaveBeenCalledWith('dark-theme'); + } + )); }); }); diff --git a/apps/web/src/app/services/settings.service.ts b/apps/web/src/app/services/settings.service.ts index 2152fde50..0d17b5b7c 100644 --- a/apps/web/src/app/services/settings.service.ts +++ b/apps/web/src/app/services/settings.service.ts @@ -21,6 +21,11 @@ interface ParsedVersion { prerelease: boolean; } +type LegacyMediaQueryList = MediaQueryList & { + addListener?: (listener: (event: MediaQueryListEvent) => void) => void; + removeListener?: (listener: (event: MediaQueryListEvent) => void) => void; +}; + function parseVersion(input: string): ParsedVersion | null { const normalized = input.trim().replace(/^v/i, ''); const match = normalized.match( @@ -60,17 +65,81 @@ function compareVersions(a: ParsedVersion, b: ParsedVersion): number { export class SettingsService { private http = inject(HttpClient); private storage = inject(StorageMap); + private readonly systemThemeMediaQuery = + typeof window !== 'undefined' && 'matchMedia' in window + ? window.matchMedia('(prefers-color-scheme: dark)') + : null; + private readonly systemThemeChangeHandler = ( + event: MediaQueryListEvent + ): void => { + this.applyResolvedTheme( + event.matches ? Theme.DarkTheme : Theme.LightTheme + ); + }; + private isSystemThemeSyncActive = false; /** * Changes the visual theme of the application * @param selectedTheme theme to set */ changeTheme(selectedTheme: Theme): void { - if (selectedTheme === Theme.LightTheme) { - document.body.classList.remove('dark-theme'); - } else if (selectedTheme === Theme.DarkTheme) { - document.body.classList.add('dark-theme'); + this.stopSystemThemeSync(); + + if (selectedTheme === Theme.SystemTheme) { + this.startSystemThemeSync(); + this.applyResolvedTheme( + this.systemThemeMediaQuery?.matches + ? Theme.DarkTheme + : Theme.LightTheme + ); + return; } + + this.applyResolvedTheme(selectedTheme); + } + + private applyResolvedTheme(selectedTheme: Theme): void { + if (selectedTheme === Theme.DarkTheme) { + document.body.classList.add('dark-theme'); + return; + } + + document.body.classList.remove('dark-theme'); + } + + private startSystemThemeSync(): void { + if (!this.systemThemeMediaQuery || this.isSystemThemeSyncActive) { + return; + } + + const mediaQuery = this.systemThemeMediaQuery as LegacyMediaQueryList; + + if (mediaQuery.addEventListener) { + mediaQuery.addEventListener('change', this.systemThemeChangeHandler); + } else { + mediaQuery.addListener?.(this.systemThemeChangeHandler); + } + + this.isSystemThemeSyncActive = true; + } + + private stopSystemThemeSync(): void { + if (!this.systemThemeMediaQuery || !this.isSystemThemeSyncActive) { + return; + } + + const mediaQuery = this.systemThemeMediaQuery as LegacyMediaQueryList; + + if (mediaQuery.removeEventListener) { + mediaQuery.removeEventListener( + 'change', + this.systemThemeChangeHandler + ); + } else { + mediaQuery.removeListener?.(this.systemThemeChangeHandler); + } + + this.isSystemThemeSyncActive = false; } /** diff --git a/apps/web/src/app/settings/settings.component.html b/apps/web/src/app/settings/settings.component.html index e6d75b8cc..f657af822 100644 --- a/apps/web/src/app/settings/settings.component.html +++ b/apps/web/src/app/settings/settings.component.html @@ -13,19 +13,19 @@ class="settings-layout" novalidate > -
-
-
- {{ 'SETTINGS.HERO_EYEBROW' | translate }} + @if (!isDialog) { +
+
+

+ {{ 'SETTINGS.TITLE' | translate }} +

+

{{ 'SETTINGS.GENERAL_SUBTITLE' | translate }}

-

- {{ 'SETTINGS.HERO_TITLE' | translate }} -

-

- {{ 'SETTINGS.HERO_DESCRIPTION' | translate }} -

-
-
+ + }
{{ 'SETTINGS.LANGUAGE_DESCRIPTION' | translate }}

- + {{ 'SETTINGS.VIDEO_PLAYER_PLACEHOLDER' | translate }} @@ -75,34 +78,46 @@
-
+

{{ 'SETTINGS.THEME' | translate }}

{{ 'SETTINGS.THEME_DESCRIPTION' | translate }}

- - {{ - 'SETTINGS.VIDEO_PLAYER_PLACEHOLDER' | translate - }} - - @for ( - theme of themeEnum | keyvalue; - track theme - ) { - {{ - 'THEMES.' + theme.value | translate - }} - } - - +
+ @for ( + themeOption of themeOptions; + track themeOption.value + ) { + + } +
@@ -152,7 +167,10 @@

- + {{ 'SETTINGS.VIDEO_PLAYER_PLACEHOLDER' | translate }} @@ -182,7 +200,10 @@

- + {{ 'SETTINGS.VIDEO_PLAYER_PLACEHOLDER' | translate }} @@ -245,7 +266,10 @@

- + {{ 'SETTINGS.MPV_PLAYER_PATH' | translate }} @@ -298,7 +322,10 @@

- + {{ 'SETTINGS.VLC_PLAYER_PATH' | translate }} @@ -347,6 +374,7 @@
{{ @@ -480,7 +508,10 @@

- + {{ 'SETTINGS.PORT_RANGE_LABEL' | translate }} diff --git a/apps/web/src/app/settings/settings.component.scss b/apps/web/src/app/settings/settings.component.scss index 31ff3692a..8e52aef96 100644 --- a/apps/web/src/app/settings/settings.component.scss +++ b/apps/web/src/app/settings/settings.component.scss @@ -1,64 +1,72 @@ :host { display: block; - --settings-intro-bg: linear-gradient( - 180deg, - rgba(255, 255, 255, 0.96) 0%, - color-mix(in srgb, var(--mat-sys-surface-container-low) 88%, white) 100% - ); - --settings-intro-border: color-mix( + --settings-page-header-bg: color-mix( in srgb, - var(--mat-sys-outline-variant) 80%, - transparent + var(--mat-sys-surface-container-low) 94%, + rgba(255, 255, 255, 0.98) ); - --settings-intro-shadow: - 0 10px 24px -22px rgba(15, 23, 42, 0.26), - 0 1px 0 rgba(255, 255, 255, 0.8) inset; - --settings-group-bg: rgba(255, 255, 255, 0.72); - --settings-group-border: color-mix( - in srgb, - var(--mat-sys-outline-variant) 70%, - transparent - ); - --settings-group-shadow: 0 10px 24px -24px rgba(15, 23, 42, 0.18); - --settings-group-active-bg: color-mix( - in srgb, - var(--app-selection-color) 4%, - rgba(255, 255, 255, 0.94) - ); - --settings-group-active-shadow: - inset 0 0 0 1px var(--app-selection-border), - 0 14px 28px -26px var(--app-selection-glow); - --settings-item-bg: rgba(255, 255, 255, 0.92); - --settings-item-border: color-mix( + --settings-page-header-border: color-mix( in srgb, var(--mat-sys-outline-variant) 72%, transparent ); - --settings-item-shadow: 0 6px 20px -24px rgba(15, 23, 42, 0.18); + --settings-page-header-shadow: + 0 10px 20px -28px rgba(15, 23, 42, 0.16), + 0 1px 0 rgba(255, 255, 255, 0.68) inset; + --settings-group-bg: color-mix( + in srgb, + var(--mat-sys-surface) 96%, + rgba(255, 255, 255, 0.96) + ); + --settings-group-border: color-mix( + in srgb, + var(--mat-sys-outline-variant) 78%, + transparent + ); + --settings-group-shadow: 0 14px 28px -32px rgba(15, 23, 42, 0.14); + --settings-group-active-bg: color-mix( + in srgb, + var(--app-selection-color) 2%, + rgba(255, 255, 255, 0.98) + ); + --settings-group-active-ring: color-mix( + in srgb, + var(--app-selection-border) 68%, + var(--settings-group-border) + ); + --settings-group-active-glow: color-mix( + in srgb, + var(--app-selection-glow) 28%, + transparent + ); + --settings-group-active-shadow: + inset 0 0 0 1px var(--settings-group-active-ring), + 0 12px 24px -28px var(--settings-group-active-glow); + --settings-item-border: var(--app-separator); --settings-item-danger-bg: color-mix( in srgb, - var(--mat-sys-error) 5%, - rgba(255, 255, 255, 0.94) + var(--mat-sys-error) 4%, + rgba(255, 255, 255, 0.96) ); --settings-item-danger-border: color-mix( in srgb, - var(--mat-sys-error) 18%, + var(--mat-sys-error) 14%, transparent ); --settings-action-bar-bg: color-mix( in srgb, - var(--mat-sys-surface-container-low) 90%, - rgba(255, 255, 255, 0.86) + var(--mat-sys-surface-container-low) 94%, + rgba(255, 255, 255, 0.9) ); --settings-action-bar-border: color-mix( in srgb, - var(--mat-sys-outline-variant) 68%, + var(--mat-sys-outline-variant) 62%, transparent ); - --settings-action-bar-shadow: 0 10px 26px -24px rgba(15, 23, 42, 0.28); + --settings-action-bar-shadow: 0 10px 26px -26px rgba(15, 23, 42, 0.22); --settings-muted: color-mix( in srgb, - var(--mat-sys-on-surface) 68%, + var(--mat-sys-on-surface) 64%, transparent ); } @@ -66,55 +74,64 @@ :host-context(.dark-theme), :host-context([data-theme='dark']), :host-context(.mat-app-background.dark) { - --settings-intro-bg: color-mix( + --settings-page-header-bg: color-mix( in srgb, - var(--app-selection-surface) 72%, - rgba(10, 16, 26, 0.96) + var(--mat-sys-surface-container) 92%, + rgba(12, 16, 24, 0.94) ); - --settings-intro-border: color-mix( + --settings-page-header-border: color-mix( in srgb, - var(--app-selection-border) 55%, + var(--mat-sys-outline-variant) 38%, transparent ); - --settings-intro-shadow: + --settings-page-header-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.05), - 0 18px 28px -30px rgba(0, 0, 0, 0.45); - --settings-group-bg: rgba(255, 255, 255, 0.02); - --settings-group-border: rgba(255, 255, 255, 0.06); - --settings-group-shadow: - inset 0 1px 0 rgba(255, 255, 255, 0.04), - 0 14px 26px -26px rgba(0, 0, 0, 0.45); + 0 16px 26px -34px rgba(0, 0, 0, 0.4); + --settings-group-bg: color-mix( + in srgb, + var(--mat-sys-surface-container) 94%, + rgba(10, 14, 22, 0.82) + ); + --settings-group-border: rgba(255, 255, 255, 0.08); + --settings-group-shadow: 0 16px 28px -32px rgba(0, 0, 0, 0.42); --settings-group-active-bg: color-mix( in srgb, - var(--app-selection-surface) 82%, - rgba(255, 255, 255, 0.02) + var(--app-selection-surface) 24%, + rgba(255, 255, 255, 0.015) + ); + --settings-group-active-ring: color-mix( + in srgb, + var(--app-selection-border) 54%, + rgba(255, 255, 255, 0.08) + ); + --settings-group-active-glow: color-mix( + in srgb, + var(--app-selection-glow) 22%, + transparent ); --settings-group-active-shadow: - inset 0 0 0 1px var(--app-selection-border), - 0 16px 28px -24px var(--app-selection-glow); - --settings-item-bg: rgba(8, 12, 20, 0.3); - --settings-item-border: rgba(255, 255, 255, 0.05); - --settings-item-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.03); + inset 0 0 0 1px var(--settings-group-active-ring), + 0 10px 22px -28px var(--settings-group-active-glow); --settings-item-danger-bg: color-mix( in srgb, - var(--mat-sys-error) 10%, - rgba(24, 10, 12, 0.9) + var(--mat-sys-error) 8%, + rgba(24, 10, 12, 0.86) ); --settings-item-danger-border: color-mix( in srgb, - var(--mat-sys-error) 34%, + var(--mat-sys-error) 24%, transparent ); --settings-action-bar-bg: color-mix( in srgb, - var(--mat-sys-surface-container) 88%, + var(--mat-sys-surface-container) 92%, transparent ); - --settings-action-bar-border: var(--mat-sys-outline-variant); - --settings-action-bar-shadow: 0 8px 32px rgba(0, 0, 0, 0.2); + --settings-action-bar-border: rgba(255, 255, 255, 0.08); + --settings-action-bar-shadow: 0 8px 30px rgba(0, 0, 0, 0.18); --settings-muted: color-mix( in srgb, - var(--mat-sys-on-surface) 74%, + var(--mat-sys-on-surface) 68%, transparent ); } @@ -122,7 +139,7 @@ .settings-container { height: auto; overflow: visible; - padding: 20px 20px 32px; + padding: 18px 20px 30px; } .settings-container.dialog-mode { @@ -135,60 +152,45 @@ .settings-layout { display: flex; flex-direction: column; - gap: 24px; + gap: 16px; align-items: stretch; - max-width: 960px; + max-width: 920px; margin: 0 auto; - padding-bottom: 80px; /* space for FAB */ + padding-bottom: 72px; /* space for FAB */ } -.settings-intro { - position: relative; - overflow: hidden; - padding: 28px; - border: 1px solid var(--settings-intro-border); - border-radius: 28px; - background: var(--settings-intro-bg); - box-shadow: var(--settings-intro-shadow); +.settings-page-header { + padding: 14px 18px; + border: 1px solid var(--settings-page-header-border); + border-radius: 18px; + background: var(--settings-page-header-bg); + box-shadow: var(--settings-page-header-shadow); } -.settings-intro__content { - position: relative; - z-index: 1; - max-width: 640px; -} +.settings-page-header__text { + max-width: 56ch; -.settings-intro__eyebrow { - margin-bottom: 10px; - font-size: 0.72rem; - font-weight: 700; - letter-spacing: 0.12em; - text-transform: uppercase; - color: var(--app-selection-color); - opacity: 0.86; -} + h1 { + margin: 0; + font-size: clamp(1.35rem, 2vw, 1.75rem); + line-height: 1.08; + letter-spacing: -0.03em; + font-weight: 700; + } -.settings-intro__title { - margin: 0; - font-size: clamp(1.8rem, 3vw, 2.6rem); - line-height: 1; - letter-spacing: -0.04em; - font-weight: 800; -} - -.settings-intro__lead { - margin: 12px 0 0; - max-width: 52ch; - color: color-mix(in srgb, var(--mat-sys-on-surface) 78%, transparent); - font-size: 0.98rem; - line-height: 1.5; + p { + margin: 4px 0 0; + color: var(--settings-muted); + font-size: 0.86rem; + line-height: 1.45; + } } .settings-content { min-width: 0; display: flex; flex-direction: column; - gap: 20px; + gap: 16px; } .settings-group { @@ -196,9 +198,9 @@ background: var(--settings-group-bg); overflow: hidden; scroll-margin-top: 24px; - padding: 4px; + padding: 0; border: 1px solid var(--settings-group-border); - border-radius: 24px; + border-radius: 22px; box-shadow: var(--settings-group-shadow); transition: background 0.2s ease, @@ -208,13 +210,17 @@ .settings-group--active { background: var(--settings-group-active-bg); - border-color: var(--app-selection-border); + border-color: var(--settings-group-active-ring); box-shadow: var(--settings-group-active-shadow); .settings-group__header-icon { - background: var(--app-selection-surface-strong); + background: color-mix( + in srgb, + var(--app-selection-surface-strong) 70%, + var(--mat-sys-surface-container-high) + ); color: var(--app-selection-color); - box-shadow: inset 0 0 0 1px var(--app-selection-border); + border-color: var(--settings-group-active-ring); } .settings-group__header-text h3 { @@ -225,59 +231,66 @@ .settings-group__header { display: flex; align-items: center; - gap: 16px; - padding: 20px 18px 14px; + gap: 14px; + padding: 16px 18px 14px; .settings-group__header-icon { display: flex; align-items: center; justify-content: center; - width: 48px; - height: 48px; + width: 40px; + height: 40px; flex-shrink: 0; - border-radius: 14px; - background: var(--mat-sys-secondary-container); - color: var(--mat-sys-on-secondary-container); + border: 1px solid color-mix( + in srgb, + var(--mat-sys-outline-variant) 74%, + transparent + ); + border-radius: 12px; + background: color-mix( + in srgb, + var(--mat-sys-surface-container-high) 78%, + transparent + ); + color: var(--mat-sys-on-surface-variant); mat-icon { - font-size: 24px; - width: 24px; - height: 24px; + font-size: 20px; + width: 20px; + height: 20px; } } .settings-group__header-text { h3 { margin: 0; - font-size: 1.25rem; - font-weight: 800; + font-size: 1.08rem; + font-weight: 700; letter-spacing: -0.02em; } p { - margin: 4px 0 0; - opacity: 0.72; - font-size: 0.9rem; + margin: 2px 0 0; + color: var(--settings-muted); + font-size: 0.84rem; + line-height: 1.45; } } } .settings-group__footer { - padding: 14px 16px 12px; - margin-top: 8px; - border-top: 1px dashed var(--mat-sys-outline-variant); + padding: 12px 18px 16px; + border-top: 1px solid var(--settings-item-border); } .setting-item { display: grid; - grid-template-columns: minmax(220px, 1fr) minmax(240px, 440px); - gap: 18px; - margin: 0 10px 10px; - padding: 16px 16px; - border: 1px solid var(--settings-item-border); - border-radius: 18px; - background: var(--settings-item-bg); - box-shadow: var(--settings-item-shadow); + grid-template-columns: minmax(0, 1.15fr) minmax(220px, 340px); + gap: 14px; + margin: 0 18px; + padding: 14px 0; + border-top: 1px solid var(--settings-item-border); + background: transparent; &:last-of-type { margin-bottom: 0; @@ -291,15 +304,16 @@ .setting-item__meta { h4 { margin: 0; - font-size: 1.02rem; - font-weight: 700; + font-size: 0.95rem; + font-weight: 650; + letter-spacing: -0.01em; } p { - margin: 4px 0 0; + margin: 3px 0 0; color: var(--settings-muted); - font-size: 0.9rem; - line-height: 1.35; + font-size: 0.84rem; + line-height: 1.45; } } @@ -307,11 +321,16 @@ display: flex; justify-content: flex-end; align-items: center; - gap: 10px; + gap: 12px; + min-height: 40px; mat-form-field, .w-full { - width: min(100%, 380px); + width: min(100%, 340px); + } + + mat-form-field { + font-size: 0.9rem; } } @@ -321,12 +340,97 @@ .setting-item--full-control { grid-template-columns: 1fr; + gap: 12px; } .setting-item--full-control .setting-item__control { justify-content: stretch; } +.theme-switcher { + width: min(100%, 540px); + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 8px; +} + +.theme-switcher__option { + display: inline-flex; + align-items: center; + justify-content: center; + gap: 8px; + min-height: 54px; + padding: 12px 14px; + border: 1px solid color-mix( + in srgb, + var(--mat-sys-outline-variant) 70%, + transparent + ); + border-radius: 16px; + background: color-mix( + in srgb, + var(--mat-sys-surface-container-low) 88%, + transparent + ); + color: var(--mat-sys-on-surface-variant); + font: inherit; + font-size: 0.9rem; + font-weight: 600; + letter-spacing: -0.01em; + cursor: pointer; + transition: + border-color 0.18s ease, + background 0.18s ease, + box-shadow 0.18s ease, + color 0.18s ease; + + mat-icon { + width: 18px; + height: 18px; + font-size: 18px; + } + + &:hover { + background: color-mix( + in srgb, + var(--mat-sys-surface-container-high) 86%, + transparent + ); + color: var(--mat-sys-on-surface); + } + + &:focus-visible { + outline: none; + box-shadow: 0 0 0 3px + color-mix(in srgb, var(--app-selection-color) 18%, transparent); + } +} + +.theme-switcher__option--selected { + border-color: var(--settings-group-active-ring); + background: linear-gradient( + 135deg, + color-mix( + in srgb, + var(--app-selection-surface-strong) 72%, + var(--mat-sys-surface-container-high) + ), + color-mix( + in srgb, + var(--app-selection-surface) 72%, + var(--mat-sys-surface-container) + ) + ); + color: var(--app-selection-color); + box-shadow: + inset 0 0 0 1px color-mix( + in srgb, + var(--app-selection-border) 54%, + transparent + ), + 0 10px 18px -22px var(--settings-group-active-glow); +} + .epg-sources { width: 100%; display: flex; @@ -359,7 +463,7 @@ } .remote-control-url-item { - width: min(100%, 380px); + width: min(100%, 340px); } .url-row { @@ -402,12 +506,13 @@ } .settings-note { - margin: 0 24px 20px; - padding: 12px 14px; - border-radius: 12px; - font-size: 0.9rem; + margin: 12px 18px 18px; + padding: 10px 12px; + border-radius: 14px; + font-size: 0.84rem; background: color-mix(in srgb, var(--mat-sys-primary) 8%, transparent); - border: 1px solid var(--mat-sys-primary); + border: 1px solid color-mix(in srgb, var(--mat-sys-primary) 30%, transparent); + line-height: 1.45; a { color: inherit; @@ -416,8 +521,12 @@ } .danger-zone { + margin-top: 8px; + margin-bottom: 18px; + padding: 14px 16px; background: var(--settings-item-danger-bg); - border-color: var(--settings-item-danger-border); + border: 1px solid var(--settings-item-danger-border); + border-radius: 16px; } .action-bar { @@ -429,9 +538,9 @@ justify-content: flex-end; align-items: center; gap: 12px; - padding: 12px 20px; + padding: 10px 14px; border: 1px solid var(--settings-action-bar-border); - border-radius: 99px; /* Pill shape */ + border-radius: 18px; background: var(--settings-action-bar-bg); backdrop-filter: blur(12px); box-shadow: var(--settings-action-bar-shadow); @@ -459,14 +568,16 @@ padding: 12px; } - .settings-intro { - padding: 22px 18px; - border-radius: 24px; + .settings-page-header { + padding: 12px 14px; + border-radius: 16px; } .setting-item { grid-template-columns: 1fr; gap: 12px; + margin: 0 14px; + padding: 12px 0; } .setting-item__control { @@ -480,6 +591,11 @@ } } + .theme-switcher { + width: 100%; + grid-template-columns: 1fr; + } + .remote-control-urls, .version-block { align-items: flex-start; diff --git a/apps/web/src/app/settings/settings.component.spec.ts b/apps/web/src/app/settings/settings.component.spec.ts index e26082ceb..1bc322147 100644 --- a/apps/web/src/app/settings/settings.component.spec.ts +++ b/apps/web/src/app/settings/settings.component.spec.ts @@ -36,6 +36,7 @@ import { SETTINGS_UPDATE } from 'shared-interfaces'; import { ElectronServiceStub } from '../services/electron.service.stub'; import { SettingsStore } from '../services/settings-store.service'; import { SettingsService } from '../services/settings.service'; +import { SettingsContextService } from '@iptvnator/workspace/shell/util'; class MatSnackBarStub { open() { @@ -55,7 +56,7 @@ const DEFAULT_SETTINGS = { language: Language.ENGLISH, showCaptions: false, showExternalPlaybackBar: true, - theme: Theme.LightTheme, + theme: Theme.SystemTheme, mpvPlayerPath: '', vlcPlayerPath: '', remoteControl: false, @@ -147,6 +148,58 @@ describe('SettingsComponent', () => { expect(component).toBeTruthy(); }); + it('should render a compact page header outside dialog mode', () => { + const nativeElement = fixture.nativeElement as HTMLElement; + + expect( + nativeElement.querySelector('[data-test-id="settings-page-header"]') + ).not.toBeNull(); + expect(nativeElement.querySelector('.settings-intro')).toBeNull(); + }); + + it('should not render the page header in dialog mode', () => { + component.isDialog = true; + fixture.detectChanges(); + + const nativeElement = fixture.nativeElement as HTMLElement; + expect( + nativeElement.querySelector('[data-test-id="settings-page-header"]') + ).toBeNull(); + expect( + nativeElement.querySelector('h2[mat-dialog-title]') + ).not.toBeNull(); + }); + + it('should scroll the general navigation target to the general section', async () => { + const settingsContext = TestBed.inject(SettingsContextService); + const scrollIntoView = jest.fn(); + const originalGetElementById = document.getElementById.bind(document); + + const getElementByIdSpy = jest + .spyOn(document, 'getElementById') + .mockImplementation((id: string) => { + if (id === 'general') { + return { + scrollIntoView, + } as unknown as HTMLElement; + } + + return originalGetElementById(id); + }); + + settingsContext.navigateToSection('general'); + fixture.detectChanges(); + await fixture.whenStable(); + + expect(getElementByIdSpy).toHaveBeenCalledWith('general'); + expect(getElementByIdSpy).not.toHaveBeenCalledWith('settings-intro'); + expect(scrollIntoView).toHaveBeenCalledWith({ + behavior: 'smooth', + block: 'start', + }); + expect(settingsContext.pendingScrollTarget()).toBeNull(); + }); + describe('Get and set settings on component init', () => { const settings = { player: VideoPlayer.VideoJs, @@ -239,6 +292,13 @@ describe('SettingsComponent', () => { expect(router.navigateByUrl).toHaveBeenCalledWith('/'); }); + it('should update the selected theme and mark the form dirty', () => { + component.selectTheme(Theme.DarkTheme); + + expect(component.settingsForm.value.theme).toBe(Theme.DarkTheme); + expect(component.settingsForm.dirty).toBeTruthy(); + }); + it('should save settings on submit', async () => { const mockStore = settingsStore as unknown as MockSettingsStore; mockStore.updateSettings.mockResolvedValue(undefined); diff --git a/apps/web/src/app/settings/settings.component.ts b/apps/web/src/app/settings/settings.component.ts index dd607567e..7c3a98db1 100644 --- a/apps/web/src/app/settings/settings.component.ts +++ b/apps/web/src/app/settings/settings.component.ts @@ -63,6 +63,12 @@ interface ObservedSettingsSection { element: HTMLElement; } +interface ThemeOption { + value: Theme; + icon: string; + labelKey: string; +} + @Component({ templateUrl: './settings.component.html', styleUrls: ['./settings.component.scss'], @@ -153,8 +159,23 @@ export class SettingsComponent implements OnInit, OnDestroy { /** EPG availability flag */ epgAvailable$ = this.store.select(selectIsEpgAvailable); - /** All available visual themes */ - themeEnum = Theme; + readonly themeOptions: ThemeOption[] = [ + { + value: Theme.LightTheme, + icon: 'light_mode', + labelKey: 'THEMES.LIGHT_THEME', + }, + { + value: Theme.DarkTheme, + icon: 'dark_mode', + labelKey: 'THEMES.DARK_THEME', + }, + { + value: Theme.SystemTheme, + icon: 'desktop_windows', + labelKey: 'THEMES.SYSTEM_THEME', + }, + ]; /** Settings form object */ settingsForm = this.formBuilder.group({ @@ -164,7 +185,7 @@ export class SettingsComponent implements OnInit, OnDestroy { language: Language.ENGLISH, showCaptions: false, showExternalPlaybackBar: true, - theme: Theme.LightTheme, + theme: Theme.SystemTheme, mpvPlayerPath: '', mpvReuseInstance: false, vlcPlayerPath: '', @@ -239,10 +260,8 @@ export class SettingsComponent implements OnInit, OnDestroy { return; } - const scrollTargetId = - sectionId === 'general' ? 'settings-intro' : sectionId; document - .getElementById(scrollTargetId) + .getElementById(sectionId) ?.scrollIntoView({ behavior: 'smooth', block: 'start' }); this.settingsCtx.clearPendingScrollTarget(); }, @@ -257,26 +276,23 @@ export class SettingsComponent implements OnInit, OnDestroy { `#${activeSectionId}` ) as HTMLElement | null; - if (!activeSectionElement || activeSectionId === 'general') { + if (!activeSectionElement) { return; } const animation = activeSectionElement.animate( [ { - transform: 'translateY(0)', boxShadow: - 'inset 0 0 0 1px var(--app-selection-border), 0 12px 20px -18px var(--app-selection-glow)', + 'inset 0 0 0 1px var(--settings-group-active-ring), 0 8px 18px -24px var(--settings-group-active-glow)', }, { - transform: 'translateY(-2px)', boxShadow: - 'inset 0 0 0 1px var(--app-selection-border), 0 18px 28px -18px var(--app-selection-glow)', + 'inset 0 0 0 1px var(--settings-group-active-ring), 0 12px 22px -24px var(--settings-group-active-glow)', }, { - transform: 'translateY(0)', boxShadow: - 'inset 0 0 0 1px var(--app-selection-border), 0 12px 20px -18px var(--app-selection-glow)', + 'inset 0 0 0 1px var(--settings-group-active-ring), 0 8px 18px -24px var(--settings-group-active-glow)', }, ], { @@ -351,6 +367,16 @@ export class SettingsComponent implements OnInit, OnDestroy { } } + selectTheme(theme: Theme): void { + if (this.settingsForm.value.theme === theme) { + return; + } + + this.settingsForm.patchValue({ theme }); + this.settingsForm.get('theme')?.markAsDirty(); + this.settingsForm.markAsDirty(); + } + /** * Sets the epg urls to the form array * @param epgUrls urls of the EPG sources @@ -465,7 +491,9 @@ export class SettingsComponent implements OnInit, OnDestroy { } } this.translate.use(this.settingsForm.value.language); - this.settingsService.changeTheme(this.settingsForm.value.theme); + this.settingsService.changeTheme( + this.settingsForm.value.theme ?? Theme.SystemTheme + ); this.snackBar.open( this.translate.instant('SETTINGS.SETTINGS_SAVED'), null, @@ -624,23 +652,17 @@ export class SettingsComponent implements OnInit, OnDestroy { } const scrollRoot = this.getScrollRoot(); - const introSection = this.elementRef.nativeElement.querySelector( - '#settings-intro' - ) as HTMLElement | null; const contentSections = Array.from( this.elementRef.nativeElement.querySelectorAll( '.settings-group[id]' ) ) as HTMLElement[]; - const sections: ObservedSettingsSection[] = [ - ...(introSection - ? [{ id: 'general', element: introSection }] - : []), - ...contentSections.map((section) => ({ + const sections: ObservedSettingsSection[] = contentSections.map( + (section) => ({ id: section.id, element: section, - })), - ]; + }) + ); if (sections.length === 0) { return; diff --git a/libs/services/src/lib/settings-store.service.ts b/libs/services/src/lib/settings-store.service.ts index 3211d39b0..b2320aa59 100644 --- a/libs/services/src/lib/settings-store.service.ts +++ b/libs/services/src/lib/settings-store.service.ts @@ -23,7 +23,7 @@ const DEFAULT_SETTINGS: Settings = { language: Language.ENGLISH, showCaptions: false, showExternalPlaybackBar: true, - theme: Theme.LightTheme, + theme: Theme.SystemTheme, mpvPlayerPath: '', mpvReuseInstance: false, vlcPlayerPath: '', diff --git a/libs/shared/interfaces/src/lib/theme.enum.ts b/libs/shared/interfaces/src/lib/theme.enum.ts index 807c22f22..6b7b4f3fa 100644 --- a/libs/shared/interfaces/src/lib/theme.enum.ts +++ b/libs/shared/interfaces/src/lib/theme.enum.ts @@ -1,4 +1,5 @@ export enum Theme { DarkTheme = 'DARK_THEME', LightTheme = 'LIGHT_THEME', + SystemTheme = 'SYSTEM_THEME', } diff --git a/libs/workspace/dashboard/data-access/src/lib/dashboard-data.service.spec.ts b/libs/workspace/dashboard/data-access/src/lib/dashboard-data.service.spec.ts index 04d2f3c47..f091c8977 100644 --- a/libs/workspace/dashboard/data-access/src/lib/dashboard-data.service.spec.ts +++ b/libs/workspace/dashboard/data-access/src/lib/dashboard-data.service.spec.ts @@ -1,7 +1,9 @@ import { signal } from '@angular/core'; import { TestBed } from '@angular/core/testing'; import { Store } from '@ngrx/store'; +import { TranslateService } from '@ngx-translate/core'; import { selectAllPlaylistsMeta } from 'm3u-state'; +import { of } from 'rxjs'; import { DatabaseService } from 'services'; import { PlaylistMeta } from 'shared-interfaces'; import { DashboardDataService } from './dashboard-data.service'; @@ -47,6 +49,15 @@ describe('DashboardDataService.matchesScope', () => { DashboardDataService, { provide: Store, useValue: storeMock }, { provide: DatabaseService, useValue: dbServiceMock }, + { + provide: TranslateService, + useValue: { + instant: (key: string) => key, + onLangChange: of(null), + currentLang: 'en', + defaultLang: 'en', + }, + }, ], }); service = TestBed.inject(DashboardDataService); diff --git a/libs/workspace/dashboard/data-access/src/lib/dashboard-data.service.ts b/libs/workspace/dashboard/data-access/src/lib/dashboard-data.service.ts index e30ae81f8..837242416 100644 --- a/libs/workspace/dashboard/data-access/src/lib/dashboard-data.service.ts +++ b/libs/workspace/dashboard/data-access/src/lib/dashboard-data.service.ts @@ -1,10 +1,13 @@ import { Injectable, NgZone, computed, inject, signal } from '@angular/core'; +import { toSignal } from '@angular/core/rxjs-interop'; import { Store } from '@ngrx/store'; +import { TranslateService } from '@ngx-translate/core'; import { PlaylistActions, selectActivePlaylist, selectAllPlaylistsMeta, } from 'm3u-state'; +import { startWith } from 'rxjs'; import { DatabaseService } from 'services'; import { PlaylistMeta, @@ -16,7 +19,7 @@ import { import { buildStalkerFavoriteItems, buildStalkerRecentItems, - getTypeLabel, + getActivityTypeLabelKey, mapDbFavoriteToItem, mapDbRecentToItem, toDateTimestamp, @@ -44,6 +47,11 @@ export class DashboardDataService { private readonly store = inject(Store); private readonly dbService = inject(DatabaseService); private readonly ngZone = inject(NgZone); + private readonly translate = inject(TranslateService); + private readonly languageTick = toSignal( + this.translate.onLangChange.pipe(startWith(null)), + { initialValue: null } + ); private readonly xtreamGlobalRecentItems = signal([]); private readonly xtreamGlobalFavorites = signal( @@ -52,9 +60,13 @@ export class DashboardDataService { readonly playlists = this.store.selectSignal(selectAllPlaylistsMeta); readonly activePlaylist = this.store.selectSignal(selectActivePlaylist); - readonly stalkerGlobalRecentItems = computed(() => - buildStalkerRecentItems(this.playlists()) - ); + readonly stalkerGlobalRecentItems = computed(() => { + this.languageTick(); + return buildStalkerRecentItems( + this.playlists(), + this.translateText('WORKSPACE.DASHBOARD.STALKER_PORTAL') + ); + }); readonly globalRecentItems = computed(() => [...this.xtreamGlobalRecentItems(), ...this.stalkerGlobalRecentItems()] @@ -65,9 +77,13 @@ export class DashboardDataService { .slice(0, 200) ); - readonly stalkerGlobalFavorites = computed(() => - buildStalkerFavoriteItems(this.playlists()) - ); + readonly stalkerGlobalFavorites = computed(() => { + this.languageTick(); + return buildStalkerFavoriteItems( + this.playlists(), + this.translateText('WORKSPACE.DASHBOARD.STALKER_PORTAL') + ); + }); readonly globalFavoriteItems = computed(() => [...this.xtreamGlobalFavorites(), ...this.stalkerGlobalFavorites()] @@ -217,30 +233,35 @@ export class DashboardDataService { return ['/workspace', 'playlists', playlist._id]; } - getPlaylistProvider(playlist: PlaylistMeta): 'Xtream' | 'Stalker' | 'M3U' { + getPlaylistProvider(playlist: PlaylistMeta): string { + this.languageTick(); + if (playlist.serverUrl) { - return 'Xtream'; + return this.translateText('WORKSPACE.DASHBOARD.XTREAM'); } if (playlist.macAddress) { - return 'Stalker'; + return this.translateText('WORKSPACE.DASHBOARD.STALKER'); } - return 'M3U'; + return this.translateText('WORKSPACE.DASHBOARD.M3U'); } getRecentItemProviderLabel(item: GlobalRecentItem): string { + this.languageTick(); + if (item.source === 'stalker') { - return 'Stalker'; + return this.translateText('WORKSPACE.DASHBOARD.STALKER'); } if (item.source === 'xtream') { - return 'Xtream'; + return this.translateText('WORKSPACE.DASHBOARD.XTREAM'); } - return 'Provider'; + return this.translateText('WORKSPACE.DASHBOARD.PROVIDER'); } getRecentItemTypeLabel(item: GlobalRecentItem): string { - return getTypeLabel(item.type); + this.languageTick(); + return this.translateText(getActivityTypeLabelKey(item.type)); } getRecentItemLink(item: GlobalRecentItem): string[] { @@ -301,17 +322,20 @@ export class DashboardDataService { } getFavoriteItemProviderLabel(item: DashboardFavoriteItem): string { + this.languageTick(); + if (item.source === 'stalker') { - return 'Stalker'; + return this.translateText('WORKSPACE.DASHBOARD.STALKER'); } if (item.source === 'xtream') { - return 'Xtream'; + return this.translateText('WORKSPACE.DASHBOARD.XTREAM'); } - return 'Provider'; + return this.translateText('WORKSPACE.DASHBOARD.PROVIDER'); } getFavoriteItemTypeLabel(item: DashboardFavoriteItem): string { - return getTypeLabel(item.type); + this.languageTick(); + return this.translateText(getActivityTypeLabelKey(item.type)); } getGlobalFavoriteLink(item: DashboardFavoriteItem): string[] { @@ -367,12 +391,14 @@ export class DashboardDataService { } formatTimestamp(value?: string | number): string { + this.languageTick(); + const timestamp = toTimestamp(value); if (!timestamp) { - return 'Not yet synced'; + return this.translateText('WORKSPACE.DASHBOARD.NOT_YET_SYNCED'); } - return new Date(timestamp).toLocaleString(); + return new Date(timestamp).toLocaleString(this.getLocale()); } private normalizeScope( @@ -399,4 +425,19 @@ export class DashboardDataService { private getRecentTimestamp(item: PlaylistMeta): number { return toTimestamp(item.updateDate) || toTimestamp(item.importDate); } + + private getLocale(): string | undefined { + return ( + this.translate.currentLang || + this.translate.defaultLang || + undefined + ); + } + + private translateText( + key: string, + params?: Record + ): string { + return this.translate.instant(key, params); + } } diff --git a/libs/workspace/dashboard/data-access/src/lib/dashboard-layout.service.ts b/libs/workspace/dashboard/data-access/src/lib/dashboard-layout.service.ts index a3bfc41e4..b59f20814 100644 --- a/libs/workspace/dashboard/data-access/src/lib/dashboard-layout.service.ts +++ b/libs/workspace/dashboard/data-access/src/lib/dashboard-layout.service.ts @@ -296,12 +296,18 @@ export class DashboardLayoutService { defaultWidget: DashboardWidgetConfig, loaded: DashboardWidgetConfig ): DashboardWidgetConfig { - return { + const merged = { ...defaultWidget, ...loaded, size: this.normalizeSize(loaded.size ?? defaultWidget.size), settings: this.mergeSettings(defaultWidget, loaded), }; + + return { + ...merged, + title: defaultWidget.title, + description: defaultWidget.description, + }; } private mergeSettings( diff --git a/libs/workspace/dashboard/data-access/src/lib/dashboard-mappers.ts b/libs/workspace/dashboard/data-access/src/lib/dashboard-mappers.ts index b80328a3c..f8ef814fe 100644 --- a/libs/workspace/dashboard/data-access/src/lib/dashboard-mappers.ts +++ b/libs/workspace/dashboard/data-access/src/lib/dashboard-mappers.ts @@ -20,12 +20,10 @@ export function normalizeActivityType(value: string): PortalActivityType { return value === 'live' || value === 'series' ? value : 'movie'; } -export function getTypeLabel( - type: PortalActivityType -): 'Live' | 'Movie' | 'Series' { - if (type === 'live') return 'Live'; - if (type === 'series') return 'Series'; - return 'Movie'; +export function getActivityTypeLabelKey(type: PortalActivityType): string { + if (type === 'live') return 'WORKSPACE.DASHBOARD.TYPE_LIVE'; + if (type === 'series') return 'WORKSPACE.DASHBOARD.TYPE_SERIES'; + return 'WORKSPACE.DASHBOARD.TYPE_MOVIE'; } // ────── Xtream DB → ViewModel ────── @@ -65,7 +63,8 @@ export function mapDbRecentToItem(item: DbGlobalRecentItem): PortalRecentItem { // ────── Stalker playlist → ViewModel ────── export function buildStalkerRecentItems( - playlists: PlaylistMeta[] + playlists: PlaylistMeta[], + defaultPlaylistName: string ): PortalRecentItem[] { return playlists .filter((playlist) => Boolean(playlist.macAddress)) @@ -86,7 +85,7 @@ export function buildStalkerRecentItems( title: extractStalkerItemTitle(item), type: extractStalkerItemType(item), playlist_id: playlist._id, - playlist_name: playlist.title || 'Stalker Portal', + playlist_name: playlist.title || defaultPlaylistName, viewed_at: normalizeStalkerDate(item['added_at']), category_id: String(item['category_id'] ?? ''), xtream_id: id, @@ -102,7 +101,8 @@ export function buildStalkerRecentItems( } export function buildStalkerFavoriteItems( - playlists: PlaylistMeta[] + playlists: PlaylistMeta[], + defaultPlaylistName: string ): PortalFavoriteItem[] { return playlists .filter((playlist) => Boolean(playlist.macAddress)) @@ -120,7 +120,7 @@ export function buildStalkerFavoriteItems( title: extractStalkerItemTitle(raw), type: extractStalkerItemType(raw), playlist_id: playlist._id, - playlist_name: playlist.title || 'Stalker Portal', + playlist_name: playlist.title || defaultPlaylistName, added_at: normalizeStalkerDate(raw['added_at']), category_id: String(raw['category_id'] ?? ''), xtream_id: id, diff --git a/libs/workspace/dashboard/data-access/src/lib/dashboard-navigation.utils.ts b/libs/workspace/dashboard/data-access/src/lib/dashboard-navigation.utils.ts index c94f886e4..c57cfa843 100644 --- a/libs/workspace/dashboard/data-access/src/lib/dashboard-navigation.utils.ts +++ b/libs/workspace/dashboard/data-access/src/lib/dashboard-navigation.utils.ts @@ -154,7 +154,7 @@ export function buildStalkerStateItem( }; } - const title = fallback.title || 'Unknown'; + const title = fallback.title || ''; return { id: String(fallback.id ?? ''), title, diff --git a/libs/workspace/dashboard/data-access/src/lib/dashboard-widget.model.ts b/libs/workspace/dashboard/data-access/src/lib/dashboard-widget.model.ts index a3e5a5ad3..272b2cf8a 100644 --- a/libs/workspace/dashboard/data-access/src/lib/dashboard-widget.model.ts +++ b/libs/workspace/dashboard/data-access/src/lib/dashboard-widget.model.ts @@ -60,8 +60,8 @@ export const DEFAULT_DASHBOARD_WIDGETS: DashboardWidgetConfig[] = [ { id: 'source-stats', type: 'source-stats', - title: 'Source Statistics', - description: 'Current source mix and total connected libraries.', + title: 'WORKSPACE.DASHBOARD.SOURCE_STATS', + description: 'WORKSPACE.DASHBOARD.SOURCE_STATS_DESC', size: 'one-third', enabled: false, order: 1, @@ -69,8 +69,8 @@ export const DEFAULT_DASHBOARD_WIDGETS: DashboardWidgetConfig[] = [ { id: 'continue', type: 'continue-watching', - title: 'Continue Watching', - description: 'Jump back into the last active source.', + title: 'WORKSPACE.DASHBOARD.CONTINUE_WATCHING', + description: 'WORKSPACE.DASHBOARD.CONTINUE_WATCHING_DESC', size: 'full', enabled: true, order: 2, @@ -78,8 +78,8 @@ export const DEFAULT_DASHBOARD_WIDGETS: DashboardWidgetConfig[] = [ { id: 'recently-watched', type: 'recently-watched', - title: 'Recently Watched', - description: 'Global watch history across channels, VOD and series.', + title: 'WORKSPACE.DASHBOARD.RECENTLY_WATCHED', + description: 'WORKSPACE.DASHBOARD.RECENTLY_WATCHED_DESC', size: 'two-thirds', enabled: true, order: 4, @@ -90,8 +90,8 @@ export const DEFAULT_DASHBOARD_WIDGETS: DashboardWidgetConfig[] = [ { id: 'global-favorites', type: 'global-favorites', - title: 'Global Favorites', - description: 'Pinned items across all connected providers.', + title: 'WORKSPACE.DASHBOARD.GLOBAL_FAVORITES', + description: 'WORKSPACE.DASHBOARD.GLOBAL_FAVORITES_DESC', size: 'one-third', enabled: true, order: 5, diff --git a/libs/workspace/dashboard/feature/src/lib/workspace-dashboard.component.html b/libs/workspace/dashboard/feature/src/lib/workspace-dashboard.component.html index 91c1cda63..570ea5881 100644 --- a/libs/workspace/dashboard/feature/src/lib/workspace-dashboard.component.html +++ b/libs/workspace/dashboard/feature/src/lib/workspace-dashboard.component.html @@ -35,7 +35,9 @@ [matTooltip]=" 'WORKSPACE.DASHBOARD.TOOLBAR_CUSTOMIZE' | translate " - aria-label="Customize dashboard layout" + [attr.aria-label]=" + 'WORKSPACE.DASHBOARD.CUSTOMIZE_LAYOUT' | translate + " > tune @@ -78,7 +80,9 @@ mat-icon-button class="drag-handle" cdkDragHandle - [attr.aria-label]="'Drag ' + widget.title" + [attr.aria-label]=" + getDragWidgetLabel(widget) + " > drag_indicator @@ -89,7 +93,10 @@ class="size-stepper__btn" [disabled]="isFirstSize(widget.size)" (click)="cycleWidgetSize(widget.id, -1)" - aria-label="Make widget narrower" + [attr.aria-label]=" + 'WORKSPACE.DASHBOARD.MAKE_WIDGET_NARROWER' + | translate + " > chevron_left @@ -102,7 +109,10 @@ class="size-stepper__btn" [disabled]="isLastSize(widget.size)" (click)="cycleWidgetSize(widget.id, 1)" - aria-label="Make widget wider" + [attr.aria-label]=" + 'WORKSPACE.DASHBOARD.MAKE_WIDGET_WIDER' + | translate + " > chevron_right @@ -111,7 +121,9 @@ type="button" mat-icon-button class="widget-inline-editor__hide" - [attr.aria-label]="'Hide ' + widget.title" + [attr.aria-label]=" + getHideWidgetLabel(widget) + " (click)="hideWidget(widget.id)" > visibility_off @@ -157,8 +169,8 @@ @for (widget of allWidgets(); track widget.id) {
-

{{ widget.title }}

-

{{ widget.description }}

+

{{ getWidgetTitle(widget) }}

+

{{ getWidgetDescription(widget) }}

@@ -184,13 +196,9 @@ 'WORKSPACE.DASHBOARD.WIDGET_ADVANCED' | translate }} - - Scope ({{ - getScopeProviderSummary( - widget - ) - }}) - + {{ + getScopePanelDescription(widget) + }}

diff --git a/libs/workspace/dashboard/feature/src/lib/workspace-dashboard.component.ts b/libs/workspace/dashboard/feature/src/lib/workspace-dashboard.component.ts index 3ddccf584..379871a22 100644 --- a/libs/workspace/dashboard/feature/src/lib/workspace-dashboard.component.ts +++ b/libs/workspace/dashboard/feature/src/lib/workspace-dashboard.component.ts @@ -4,6 +4,7 @@ import { moveItemInArray, } from '@angular/cdk/drag-drop'; import { Component, computed, inject, signal } from '@angular/core'; +import { toSignal } from '@angular/core/rxjs-interop'; import { MatButtonModule } from '@angular/material/button'; import { MatCheckboxModule } from '@angular/material/checkbox'; import { MatChipsModule } from '@angular/material/chips'; @@ -12,8 +13,9 @@ import { MatIcon } from '@angular/material/icon'; import { MatSlideToggleModule } from '@angular/material/slide-toggle'; import { MatTooltip } from '@angular/material/tooltip'; import { Store } from '@ngrx/store'; -import { TranslatePipe } from '@ngx-translate/core'; +import { TranslatePipe, TranslateService } from '@ngx-translate/core'; import { selectAllPlaylistsMeta } from 'm3u-state'; +import { startWith } from 'rxjs'; import { DashboardLayoutService, DashboardWidgetConfig, @@ -42,9 +44,14 @@ import { DashboardWidgetHostComponent } from 'workspace-dashboard-ui'; }) export class WorkspaceDashboardComponent { private readonly store = inject(Store); + private readonly translate = inject(TranslateService); readonly editMode = signal(false); readonly playlists = this.store.selectSignal(selectAllPlaylistsMeta); + private readonly languageTick = toSignal( + this.translate.onLangChange.pipe(startWith(null)), + { initialValue: null } + ); readonly providerOptions: DashboardWidgetProvider[] = [ 'm3u', 'xtream', @@ -106,11 +113,13 @@ export class WorkspaceDashboardComponent { } getSizeLabel(size: DashboardWidgetSize): string { + this.languageTick(); + const labels: Record = { 'one-third': '1/3', half: '1/2', 'two-thirds': '2/3', - full: 'Full', + full: this.translateText('WORKSPACE.DASHBOARD.SIZE_FULL'), }; return labels[size]; } @@ -132,13 +141,15 @@ export class WorkspaceDashboardComponent { } getProviderLabel(provider: DashboardWidgetProvider): string { + this.languageTick(); + if (provider === 'xtream') { - return 'Xtream'; + return this.translateText('WORKSPACE.DASHBOARD.XTREAM'); } if (provider === 'stalker') { - return 'Stalker'; + return this.translateText('WORKSPACE.DASHBOARD.STALKER'); } - return 'M3U'; + return this.translateText('WORKSPACE.DASHBOARD.M3U'); } getScopeProviderCount(widget: DashboardWidgetConfig): number { @@ -147,8 +158,16 @@ export class WorkspaceDashboardComponent { } getScopeProviderSummary(widget: DashboardWidgetConfig): string { + this.languageTick(); + const count = this.getScopeProviderCount(widget); - return count === 0 ? 'All providers' : `${count} selected`; + if (count === 0) { + return this.translateText('WORKSPACE.DASHBOARD.ALL_PROVIDERS'); + } + + return this.translateText('WORKSPACE.DASHBOARD.SELECTED_COUNT', { + count, + }); } isAllProvidersScope(widget: DashboardWidgetConfig): boolean { @@ -201,15 +220,45 @@ export class WorkspaceDashboardComponent { url?: string; _id?: string; }): string { + this.languageTick(); + return ( playlist.title || playlist.filename || playlist.url || playlist._id || - 'Untitled playlist' + this.translateText('WORKSPACE.DASHBOARD.UNTITLED_SOURCE') ); } + getWidgetTitle(widget: DashboardWidgetConfig): string { + this.languageTick(); + return this.translateText(widget.title); + } + + getWidgetDescription(widget: DashboardWidgetConfig): string { + this.languageTick(); + return this.translateText(widget.description); + } + + getDragWidgetLabel(widget: DashboardWidgetConfig): string { + return this.translateText('WORKSPACE.DASHBOARD.DRAG_WIDGET', { + title: this.getWidgetTitle(widget), + }); + } + + getHideWidgetLabel(widget: DashboardWidgetConfig): string { + return this.translateText('WORKSPACE.DASHBOARD.HIDE_WIDGET', { + title: this.getWidgetTitle(widget), + }); + } + + getScopePanelDescription(widget: DashboardWidgetConfig): string { + return this.translateText('WORKSPACE.DASHBOARD.WIDGET_SCOPE_SUMMARY', { + summary: this.getScopeProviderSummary(widget), + }); + } + private getPlaylistProviderType(playlist: { serverUrl?: string; macAddress?: string; @@ -222,4 +271,11 @@ export class WorkspaceDashboardComponent { } return 'm3u'; } + + private translateText( + key: string, + params?: Record + ): string { + return this.translate.instant(key, params); + } } diff --git a/libs/workspace/dashboard/ui/src/lib/widgets/continue-watching-widget.component.ts b/libs/workspace/dashboard/ui/src/lib/widgets/continue-watching-widget.component.ts index 9356744b6..9f77c7702 100644 --- a/libs/workspace/dashboard/ui/src/lib/widgets/continue-watching-widget.component.ts +++ b/libs/workspace/dashboard/ui/src/lib/widgets/continue-watching-widget.component.ts @@ -1,8 +1,10 @@ import { Component, computed, inject } from '@angular/core'; +import { toSignal } from '@angular/core/rxjs-interop'; import { MatButtonModule } from '@angular/material/button'; import { MatIcon } from '@angular/material/icon'; import { RouterLink } from '@angular/router'; -import { TranslatePipe } from '@ngx-translate/core'; +import { TranslatePipe, TranslateService } from '@ngx-translate/core'; +import { startWith } from 'rxjs'; import { DashboardDataService, GlobalRecentItem, @@ -23,6 +25,11 @@ import { DashboardWidgetShellComponent } from './dashboard-widget-shell.componen }) export class ContinueWatchingWidgetComponent { readonly data = inject(DashboardDataService); + private readonly translate = inject(TranslateService); + private readonly languageTick = toSignal( + this.translate.onLangChange.pipe(startWith(null)), + { initialValue: null } + ); /** The most recently watched item across all sources. */ readonly lastItem = computed( @@ -50,12 +57,32 @@ export class ContinueWatchingWidgetComponent { } relativeTime(dateStr: string): string { - const diffMs = Date.now() - new Date(dateStr).getTime(); - const diffMin = Math.floor(diffMs / 60_000); - if (diffMin < 1) return 'Just now'; - if (diffMin < 60) return `${diffMin}m ago`; - const diffH = Math.floor(diffMin / 60); - if (diffH < 24) return `${diffH}h ago`; - return `${Math.floor(diffH / 24)}d ago`; + this.languageTick(); + + const date = new Date(dateStr); + if (Number.isNaN(date.getTime())) { + return this.translate.instant('WORKSPACE.DASHBOARD.RECENTLY'); + } + + const diffMinutes = Math.floor((Date.now() - date.getTime()) / 60_000); + if (diffMinutes < 1) { + return this.translate.instant('WORKSPACE.DASHBOARD.JUST_NOW'); + } + if (diffMinutes < 60) { + return this.translate.instant('WORKSPACE.DASHBOARD.MINUTES_AGO', { + count: diffMinutes, + }); + } + + const diffHours = Math.floor(diffMinutes / 60); + if (diffHours < 24) { + return this.translate.instant('WORKSPACE.DASHBOARD.HOURS_AGO', { + count: diffHours, + }); + } + + return this.translate.instant('WORKSPACE.DASHBOARD.DAYS_AGO', { + count: Math.floor(diffHours / 24), + }); } } diff --git a/libs/workspace/dashboard/ui/src/lib/widgets/dashboard-activity-items.component.ts b/libs/workspace/dashboard/ui/src/lib/widgets/dashboard-activity-items.component.ts index 4fda9ce60..8051bc1a6 100644 --- a/libs/workspace/dashboard/ui/src/lib/widgets/dashboard-activity-items.component.ts +++ b/libs/workspace/dashboard/ui/src/lib/widgets/dashboard-activity-items.component.ts @@ -25,14 +25,14 @@ export class DashboardActivityItemsComponent { @Output() readonly scrolledToEnd = new EventEmitter(); readonly items = input.required(); - readonly emptyLabel = input('No items to show.'); + readonly emptyLabel = input(''); readonly emptyIcon = input(); readonly emptyHint = input(); readonly emptyActionLabel = input(); readonly emptyActionLink = input(); readonly viewMode = input('list'); readonly showRemoveAction = input(false); - readonly removeActionLabel = input('Remove item'); + readonly removeActionLabel = input(''); @Output() readonly itemAction = new EventEmitter(); diff --git a/libs/workspace/dashboard/ui/src/lib/widgets/global-favorites-widget.component.html b/libs/workspace/dashboard/ui/src/lib/widgets/global-favorites-widget.component.html index 6ab428512..45b57a451 100644 --- a/libs/workspace/dashboard/ui/src/lib/widgets/global-favorites-widget.component.html +++ b/libs/workspace/dashboard/ui/src/lib/widgets/global-favorites-widget.component.html @@ -6,7 +6,9 @@

}
diff --git a/libs/workspace/shell/feature/src/lib/global-favorites/global-favorites-page.component.ts b/libs/workspace/shell/feature/src/lib/global-favorites/global-favorites-page.component.ts index 8ea0bc679..0823b945a 100644 --- a/libs/workspace/shell/feature/src/lib/global-favorites/global-favorites-page.component.ts +++ b/libs/workspace/shell/feature/src/lib/global-favorites/global-favorites-page.component.ts @@ -10,6 +10,7 @@ import { import { MatButtonModule } from '@angular/material/button'; import { MatIconModule } from '@angular/material/icon'; import { MatProgressSpinnerModule } from '@angular/material/progress-spinner'; +import { TranslatePipe } from '@ngx-translate/core'; import { PORTAL_PLAYER } from '@iptvnator/portal/shared/util'; import { StalkerSessionService } from '@iptvnator/portal/stalker/data-access'; import { @@ -62,6 +63,7 @@ interface StalkerCreateLinkResponse { MatIconModule, MatProgressSpinnerModule, ResizableDirective, + TranslatePipe, VjsPlayerComponent, ], }) diff --git a/libs/workspace/shell/feature/src/lib/global-favorites/global-favorites.service.ts b/libs/workspace/shell/feature/src/lib/global-favorites/global-favorites.service.ts index 30da69025..8047bb8ef 100644 --- a/libs/workspace/shell/feature/src/lib/global-favorites/global-favorites.service.ts +++ b/libs/workspace/shell/feature/src/lib/global-favorites/global-favorites.service.ts @@ -13,6 +13,7 @@ */ import { inject, Injectable } from '@angular/core'; import { Store } from '@ngrx/store'; +import { TranslateService } from '@ngx-translate/core'; import { selectAllPlaylistsMeta } from 'm3u-state'; import { firstValueFrom, map } from 'rxjs'; import { DatabaseService, PlaylistsService } from 'services'; @@ -57,6 +58,7 @@ export class GlobalFavoritesService { private readonly store = inject(Store); private readonly dbService = inject(DatabaseService); private readonly playlistsService = inject(PlaylistsService); + private readonly translate = inject(TranslateService); /** * Fetch and merge live TV favorites from all source types. @@ -272,12 +274,21 @@ export class GlobalFavoritesService { const streamId = fav.stream_id ?? fav.id; results.push({ uid: buildFavoriteUid('stalker', meta._id, streamId), - name: fav.o_name || fav.name || 'Unknown', + name: + fav.o_name || + fav.name || + this.translate.instant( + 'WORKSPACE.GLOBAL_FAVORITES.UNKNOWN_CHANNEL' + ), logo: fav.logo ?? fav.cover ?? null, sourceType: 'stalker' as const, playlistId: meta._id, playlistName: - meta.title || meta.filename || 'Stalker Portal', + meta.title || + meta.filename || + this.translate.instant( + 'WORKSPACE.DASHBOARD.STALKER_PORTAL' + ), stalkerCmd: fav.cmd, stalkerPortalUrl: playlist.portalUrl ?? playlist.url, stalkerMacAddress: playlist.macAddress, diff --git a/libs/workspace/shell/feature/src/lib/workspace-command-palette/workspace-command-palette.component.html b/libs/workspace/shell/feature/src/lib/workspace-command-palette/workspace-command-palette.component.html index 14f3ce9e2..ba95c29c0 100644 --- a/libs/workspace/shell/feature/src/lib/workspace-command-palette/workspace-command-palette.component.html +++ b/libs/workspace/shell/feature/src/lib/workspace-command-palette/workspace-command-palette.component.html @@ -1,7 +1,7 @@
-

Command palette

-

Global and contextual actions

+

{{ 'WORKSPACE.COMMAND_PALETTE.TITLE' | translate }}

+

{{ 'WORKSPACE.COMMAND_PALETTE.SUBTITLE' | translate }}

@if (commandGroups().length === 0) { -
No commands found
+
+ {{ 'WORKSPACE.COMMAND_PALETTE.EMPTY' | translate }} +
} @else { @for (group of commandGroups(); track group.scope) {
-

{{ group.title }}

+

{{ getScopeTitleKey(group.scope) | translate }}

@for (command of group.items; track command.id) { @@ -46,8 +48,10 @@
} @else if (isStalkerCategoryFailed()) { } @else { diff --git a/libs/workspace/shell/feature/src/lib/workspace-context-panel/workspace-context-panel.component.ts b/libs/workspace/shell/feature/src/lib/workspace-context-panel/workspace-context-panel.component.ts index 4013f040b..c45405134 100644 --- a/libs/workspace/shell/feature/src/lib/workspace-context-panel/workspace-context-panel.component.ts +++ b/libs/workspace/shell/feature/src/lib/workspace-context-panel/workspace-context-panel.component.ts @@ -5,6 +5,7 @@ import { MatDialog } from '@angular/material/dialog'; import { MatIcon } from '@angular/material/icon'; import { MatTooltip } from '@angular/material/tooltip'; import { Router } from '@angular/router'; +import { TranslatePipe } from '@ngx-translate/core'; import { CategoryViewComponent } from '@iptvnator/portal/shared/ui'; import { PlaylistErrorViewComponent } from '@iptvnator/portal/shared/ui'; import { StalkerStore } from '@iptvnator/portal/stalker/data-access'; @@ -30,6 +31,7 @@ interface XtreamCategoryLike { MatIconButton, MatIcon, MatTooltip, + TranslatePipe, ], templateUrl: './workspace-context-panel.component.html', styleUrl: './workspace-context-panel.component.scss', @@ -76,26 +78,34 @@ export class WorkspaceContextPanelComponent { readonly title = computed(() => { if (this.isXtreamCategories()) { - if (this.section() === 'vod') return 'Movie Categories'; - if (this.section() === 'series') return 'Series Categories'; - return 'Live Categories'; + if (this.section() === 'vod') { + return 'WORKSPACE.CONTEXT.MOVIE_CATEGORIES'; + } + if (this.section() === 'series') { + return 'WORKSPACE.CONTEXT.SERIES_CATEGORIES'; + } + return 'WORKSPACE.CONTEXT.LIVE_CATEGORIES'; } if (this.isStalkerCategories()) { - if (this.section() === 'vod') return 'Movie Categories'; - if (this.section() === 'series') return 'Series Categories'; - return 'Live Categories'; + if (this.section() === 'vod') { + return 'WORKSPACE.CONTEXT.MOVIE_CATEGORIES'; + } + if (this.section() === 'series') { + return 'WORKSPACE.CONTEXT.SERIES_CATEGORIES'; + } + return 'WORKSPACE.CONTEXT.LIVE_CATEGORIES'; } - return 'Categories'; + return 'WORKSPACE.CONTEXT.CATEGORIES'; }); readonly subtitle = computed(() => { if (this.isXtreamCategories()) { - return 'Xtream source'; + return 'WORKSPACE.CONTEXT.XTREAM_SOURCE'; } if (this.isStalkerCategories()) { - return 'Stalker portal'; + return 'WORKSPACE.CONTEXT.STALKER_PORTAL'; } return ''; }); diff --git a/libs/workspace/shell/feature/src/lib/workspace-context-panel/workspace-favorites-context-panel.component.ts b/libs/workspace/shell/feature/src/lib/workspace-context-panel/workspace-favorites-context-panel.component.ts index 79a483af4..e126e7d7e 100644 --- a/libs/workspace/shell/feature/src/lib/workspace-context-panel/workspace-favorites-context-panel.component.ts +++ b/libs/workspace/shell/feature/src/lib/workspace-context-panel/workspace-favorites-context-panel.component.ts @@ -1,4 +1,5 @@ import { Component, computed, inject } from '@angular/core'; +import { TranslatePipe } from '@ngx-translate/core'; import { CategoryViewComponent } from '@iptvnator/portal/shared/ui'; import { FavoritesContextService } from '@iptvnator/portal/shared/util'; @@ -12,15 +13,29 @@ import { FavoritesContextService } from '@iptvnator/portal/shared/util'; */ @Component({ selector: 'app-workspace-favorites-context-panel', - imports: [CategoryViewComponent], + imports: [CategoryViewComponent, TranslatePipe], template: `
-

{{ title() }}

+

+ {{ + selectedCategory()?.category_name || + ('WORKSPACE.CONTEXT.FILTER_BY_TYPE' | translate) + }} +

- @if (countBadge(); as text) { - {{ text }} + @if (selectedCount() !== null) { + + @if (selectedCount() === 1) { + {{ 'WORKSPACE.CONTEXT.ITEM_COUNT_ONE' | translate }} + } @else { + {{ + 'WORKSPACE.CONTEXT.ITEM_COUNT_OTHER' + | translate: { count: selectedCount() } + }} + } + }
@@ -38,23 +53,16 @@ import { FavoritesContextService } from '@iptvnator/portal/shared/util'; export class WorkspaceFavoritesContextPanelComponent { readonly ctx = inject(FavoritesContextService); - readonly title = computed(() => { + readonly selectedCategory = computed(() => { const categories = this.ctx.categories(); - if (!categories.length) return 'Filter by type'; - const selected = categories.find( + return categories.find( (c) => c.category_id === this.ctx.selectedCategoryId() ); - return selected?.category_name ?? 'Filter by type'; }); - readonly countBadge = computed(() => { - const categories = this.ctx.categories(); - const selected = categories.find( - (c) => c.category_id === this.ctx.selectedCategoryId() - ); - if (!selected || selected.count === undefined) return ''; - return `${selected.count} ${selected.count === 1 ? 'item' : 'items'}`; - }); + readonly selectedCount = computed( + () => this.selectedCategory()?.count ?? null + ); onCategoryClicked(item: { category_id?: string | number; diff --git a/libs/workspace/shell/feature/src/lib/workspace-context-panel/workspace-settings-context-panel.component.scss b/libs/workspace/shell/feature/src/lib/workspace-context-panel/workspace-settings-context-panel.component.scss index 4e3ad65b1..b0b80eb3c 100644 --- a/libs/workspace/shell/feature/src/lib/workspace-context-panel/workspace-settings-context-panel.component.scss +++ b/libs/workspace/shell/feature/src/lib/workspace-context-panel/workspace-settings-context-panel.component.scss @@ -15,12 +15,23 @@ color: var(--mat-sys-on-surface); } -.panel-header { - padding: 16px 16px 10px; - font-size: 11px; - font-weight: 700; - text-transform: uppercase; - letter-spacing: 0.12em; - opacity: 0.6; - flex-shrink: 0; +.settings-panel-body { + flex: 1; + min-height: 0; + padding-top: 16px; +} + +.settings-sections-list { + height: 100%; +} + +.settings-panel-footer { + flex-shrink: 0; + padding: 12px 8px 12px; + border-top: 1px solid var(--app-separator); +} + +.settings-section-item:hover:not(.active):not(.selected), +.settings-back-button:hover:not(.active):not(.selected) { + background: var(--app-selection-surface); } diff --git a/libs/workspace/shell/feature/src/lib/workspace-context-panel/workspace-settings-context-panel.component.ts b/libs/workspace/shell/feature/src/lib/workspace-context-panel/workspace-settings-context-panel.component.ts index 57010b7e9..b4d59da5b 100644 --- a/libs/workspace/shell/feature/src/lib/workspace-context-panel/workspace-settings-context-panel.component.ts +++ b/libs/workspace/shell/feature/src/lib/workspace-context-panel/workspace-settings-context-panel.component.ts @@ -10,25 +10,12 @@ import { SettingsContextService } from '@iptvnator/workspace/shell/util'; styleUrls: ['./workspace-settings-context-panel.component.scss'], template: `

{{ 'SETTINGS.TITLE' | translate }}

-