diff --git a/apps/web/src/assets/i18n/en.json b/apps/web/src/assets/i18n/en.json index 3647f60e2..737cb022f 100644 --- a/apps/web/src/assets/i18n/en.json +++ b/apps/web/src/assets/i18n/en.json @@ -673,7 +673,13 @@ "SIGN_IN_HINT": "Find it in the server's IPTVNATOR_ACCESS_TOKEN setting.", "SIGN_IN_REJECTED": "Token rejected. Check it and try again.", "SIGN_IN_UNREACHABLE": "Cannot reach the server. Check the connection and try again.", - "SIGN_IN_CONTINUE": "Continue" + "SIGN_IN_CONTINUE": "Continue", + "STATUS_ONLINE": "Server connected", + "STATUS_OFFLINE": "Server unavailable", + "STATUS_UNAUTHORIZED": "Server needs an access token", + "OFFLINE_BANNER": "Server unavailable. Changes cannot be saved until it is back.", + "RETRY": "Retry", + "DELETE_ALL_WARNING": "This removes the playlists from the server for every device." }, "THEMES": { "DARK_THEME": "Dark", diff --git a/libs/workspace/shell/feature/src/lib/workspace-shell/components/workspace-shell-rail/workspace-shell-rail.component.html b/libs/workspace/shell/feature/src/lib/workspace-shell/components/workspace-shell-rail/workspace-shell-rail.component.html index 04dadb780..a27f49056 100644 --- a/libs/workspace/shell/feature/src/lib/workspace-shell/components/workspace-shell-rail/workspace-shell-rail.component.html +++ b/libs/workspace/shell/feature/src/lib/workspace-shell/components/workspace-shell-rail/workspace-shell-rail.component.html @@ -4,10 +4,7 @@ @if (primaryContextLinks().length > 0) { -
+
+ @if (serverState() !== 'inactive') { + + {{ serverIcon() }} + + + } { ?.tagName ).toBe('APP-WORKSPACE-SHELL-RAIL-LINKS'); }); + + describe('server status', () => { + const instance = { + service: 'iptvnator', + apiVersion: 1, + instanceId: 'i', + storage: 'server', + auth: 'none', + } as const; + const indicator = (): HTMLElement | null => + fixture.nativeElement.querySelector( + '[data-test-id="rail-server-status"]' + ); + + it('hides the indicator without server storage', () => { + fixture.detectChanges(); + + expect(indicator()).toBeNull(); + }); + + it('shows the connection state and links to Settings → Server', () => { + const status = TestBed.inject(ServerStorageStatusService); + status.activate(instance, () => undefined); + fixture.detectChanges(); + + expect(indicator()?.dataset['state']).toBe('online'); + expect(indicator()?.getAttribute('href')).toBe( + '/workspace/settings/server' + ); + + status.reportOffline(); + fixture.detectChanges(); + + expect(indicator()?.dataset['state']).toBe('offline'); + expect(indicator()?.textContent).toContain('cloud_off'); + expect(indicator()?.getAttribute('aria-label')).toBe( + 'SERVER_STORAGE.STATUS_OFFLINE' + ); + }); + }); }); diff --git a/libs/workspace/shell/feature/src/lib/workspace-shell/components/workspace-shell-rail/workspace-shell-rail.component.ts b/libs/workspace/shell/feature/src/lib/workspace-shell/components/workspace-shell-rail/workspace-shell-rail.component.ts index 980d29bf0..92fce1a2b 100644 --- a/libs/workspace/shell/feature/src/lib/workspace-shell/components/workspace-shell-rail/workspace-shell-rail.component.ts +++ b/libs/workspace/shell/feature/src/lib/workspace-shell/components/workspace-shell-rail/workspace-shell-rail.component.ts @@ -1,4 +1,10 @@ -import { ChangeDetectionStrategy, Component, input } from '@angular/core'; +import { + ChangeDetectionStrategy, + Component, + computed, + inject, + input, +} from '@angular/core'; import { MatIcon } from '@angular/material/icon'; import { MatTooltip } from '@angular/material/tooltip'; import { RouterLink } from '@angular/router'; @@ -7,6 +13,10 @@ import { PortalRailLink, PortalRailSection, } from '@iptvnator/portal/shared/util'; +import { + type ServerConnectionState, + ServerStorageStatusService, +} from '@iptvnator/services'; import { WorkspaceShellRailLinksComponent } from '../workspace-shell-rail-links/workspace-shell-rail-links.component'; @Component({ @@ -32,4 +42,25 @@ export class WorkspaceShellRailComponent { >(null); readonly railProviderClass = input('rail-context-region'); readonly isSettingsRoute = input(false); + + /** Self-hosted server storage: hidden while `inactive`. */ + readonly serverState = inject(ServerStorageStatusService).state; + readonly serverIcon = computed(() => SERVER_ICONS[this.serverState()]); + readonly serverLabelKey = computed( + () => SERVER_LABEL_KEYS[this.serverState()] + ); } + +const SERVER_ICONS: Record = { + inactive: 'dns', + online: 'dns', + offline: 'cloud_off', + unauthorized: 'lock', +}; + +const SERVER_LABEL_KEYS: Record = { + inactive: 'SERVER_STORAGE.STATUS_ONLINE', + online: 'SERVER_STORAGE.STATUS_ONLINE', + offline: 'SERVER_STORAGE.STATUS_OFFLINE', + unauthorized: 'SERVER_STORAGE.STATUS_UNAUTHORIZED', +}; diff --git a/libs/workspace/shell/feature/src/lib/workspace-shell/components/workspace-shell-server-banner/workspace-shell-server-banner.component.scss b/libs/workspace/shell/feature/src/lib/workspace-shell/components/workspace-shell-server-banner/workspace-shell-server-banner.component.scss new file mode 100644 index 000000000..704a21804 --- /dev/null +++ b/libs/workspace/shell/feature/src/lib/workspace-shell/components/workspace-shell-server-banner/workspace-shell-server-banner.component.scss @@ -0,0 +1,29 @@ +:host { + display: block; + min-width: 0; +} + +.server-banner { + display: flex; + align-items: center; + gap: 12px; + padding: 8px 16px; + background: var(--app-expiry-warn-surface); + border-bottom: 1px solid var(--app-separator); + color: var(--app-heading-color); + font-size: 0.88rem; + + mat-icon { + flex-shrink: 0; + color: var(--app-expiry-warn-text); + } + + .server-banner__text { + min-width: 0; + } + + button { + flex-shrink: 0; + margin-left: auto; + } +} diff --git a/libs/workspace/shell/feature/src/lib/workspace-shell/components/workspace-shell-server-banner/workspace-shell-server-banner.component.spec.ts b/libs/workspace/shell/feature/src/lib/workspace-shell/components/workspace-shell-server-banner/workspace-shell-server-banner.component.spec.ts new file mode 100644 index 000000000..206c6f273 --- /dev/null +++ b/libs/workspace/shell/feature/src/lib/workspace-shell/components/workspace-shell-server-banner/workspace-shell-server-banner.component.spec.ts @@ -0,0 +1,58 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { TranslateModule } from '@ngx-translate/core'; +import { ServerStorageStatusService } from '@iptvnator/services'; +import { WorkspaceShellServerBannerComponent } from './workspace-shell-server-banner.component'; + +const instance = { + service: 'iptvnator', + apiVersion: 1, + instanceId: 'i', + storage: 'server', + auth: 'none', +} as const; + +describe('WorkspaceShellServerBannerComponent', () => { + let fixture: ComponentFixture; + let status: ServerStorageStatusService; + const retry = jest.fn(); + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [ + WorkspaceShellServerBannerComponent, + TranslateModule.forRoot(), + ], + }).compileComponents(); + status = TestBed.inject(ServerStorageStatusService); + fixture = TestBed.createComponent(WorkspaceShellServerBannerComponent); + }); + + const banner = (): HTMLElement | null => + fixture.nativeElement.querySelector( + '[data-test-id="server-offline-banner"]' + ); + + it('renders nothing without server storage or while online', () => { + fixture.detectChanges(); + expect(banner()).toBeNull(); + + status.activate(instance, retry); + fixture.detectChanges(); + expect(banner()).toBeNull(); + }); + + it('explains the offline state and retries', () => { + status.activate(instance, retry); + status.reportOffline(); + fixture.detectChanges(); + + fixture.nativeElement + .querySelector('[data-test-id="server-offline-retry"]') + .click(); + + expect(banner()?.textContent).toContain( + 'SERVER_STORAGE.OFFLINE_BANNER' + ); + expect(retry).toHaveBeenCalled(); + }); +}); diff --git a/libs/workspace/shell/feature/src/lib/workspace-shell/components/workspace-shell-server-banner/workspace-shell-server-banner.component.ts b/libs/workspace/shell/feature/src/lib/workspace-shell/components/workspace-shell-server-banner/workspace-shell-server-banner.component.ts new file mode 100644 index 000000000..c4762f15b --- /dev/null +++ b/libs/workspace/shell/feature/src/lib/workspace-shell/components/workspace-shell-server-banner/workspace-shell-server-banner.component.ts @@ -0,0 +1,41 @@ +import { ChangeDetectionStrategy, Component, inject } from '@angular/core'; +import { MatButtonModule } from '@angular/material/button'; +import { MatIcon } from '@angular/material/icon'; +import { TranslatePipe } from '@ngx-translate/core'; +import { ServerStorageStatusService } from '@iptvnator/services'; + +/** + * Self-hosted server storage is unreachable: the library on screen is the + * last copy this browser read, and changes cannot be saved. + */ +@Component({ + selector: 'app-workspace-shell-server-banner', + imports: [MatButtonModule, MatIcon, TranslatePipe], + template: ` + @if (status.state() === 'offline') { +
+ + {{ + 'SERVER_STORAGE.OFFLINE_BANNER' | translate + }} + +
+ } + `, + styleUrl: './workspace-shell-server-banner.component.scss', + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class WorkspaceShellServerBannerComponent { + readonly status = inject(ServerStorageStatusService); +} diff --git a/libs/workspace/shell/feature/src/lib/workspace-shell/workspace-shell.component.html b/libs/workspace/shell/feature/src/lib/workspace-shell/workspace-shell.component.html index 566ecd334..99dc88e67 100644 --- a/libs/workspace/shell/feature/src/lib/workspace-shell/workspace-shell.component.html +++ b/libs/workspace/shell/feature/src/lib/workspace-shell/workspace-shell.component.html @@ -116,6 +116,9 @@ shadow without clipping; the scroller inside clips to a rectangle (see the stylesheet for why). -->
+ + diff --git a/libs/workspace/shell/feature/src/lib/workspace-shell/workspace-shell.component.scss b/libs/workspace/shell/feature/src/lib/workspace-shell/workspace-shell.component.scss index 1f7fa21a2..417305ac5 100644 --- a/libs/workspace/shell/feature/src/lib/workspace-shell/workspace-shell.component.scss +++ b/libs/workspace/shell/feature/src/lib/workspace-shell/workspace-shell.component.scss @@ -93,6 +93,10 @@ app-workspace-shell-context-sidebar { position: relative; display: grid; + // Server banner (empty unless the self-hosted server is offline), then + // the scroller. + grid-template-rows: auto minmax(0, 1fr); + grid-template-columns: minmax(0, 1fr); min-width: 0; min-height: 0; border-radius: var(--workspace-content-radius) 0 0 0; diff --git a/libs/workspace/shell/feature/src/lib/workspace-shell/workspace-shell.component.spec.ts b/libs/workspace/shell/feature/src/lib/workspace-shell/workspace-shell.component.spec.ts index cc4cc20d7..e59e755d1 100644 --- a/libs/workspace/shell/feature/src/lib/workspace-shell/workspace-shell.component.spec.ts +++ b/libs/workspace/shell/feature/src/lib/workspace-shell/workspace-shell.component.spec.ts @@ -23,6 +23,14 @@ import { WorkspaceShellComponent } from './workspace-shell.component'; import { WorkspaceShellFacade } from './services/workspace-shell.facade'; import { WorkspaceKeyboardShortcutsService } from '../workspace-keyboard-shortcuts/workspace-keyboard-shortcuts.service'; +@Component({ + selector: 'app-workspace-shell-server-banner', + template: '', + changeDetection: ChangeDetectionStrategy.Eager, + standalone: true, +}) +class MockWorkspaceShellServerBannerComponent {} + @Component({ selector: 'app-workspace-shell-rail', template: '', @@ -264,6 +272,7 @@ describe('WorkspaceShellComponent', () => { MockWorkspaceShellHeaderComponent, MockWorkspaceShellImportOverlayComponent, MockWorkspaceShellRailComponent, + MockWorkspaceShellServerBannerComponent, TrafficLightsClearanceDirective, ], providers: [ @@ -360,6 +369,7 @@ describe('WorkspaceShellComponent', () => { MockWorkspaceShellHeaderComponent, MockWorkspaceShellImportOverlayComponent, MockWorkspaceShellRailComponent, + MockWorkspaceShellServerBannerComponent, TrafficLightsClearanceDirective, ], providers: [ @@ -438,6 +448,7 @@ describe('WorkspaceShellComponent', () => { MockWorkspaceShellHeaderComponent, MockWorkspaceShellImportOverlayComponent, MockWorkspaceShellRailComponent, + MockWorkspaceShellServerBannerComponent, TrafficLightsClearanceDirective, ], providers: [ @@ -512,6 +523,7 @@ describe('WorkspaceShellComponent', () => { MockWorkspaceShellHeaderComponent, MockWorkspaceShellImportOverlayComponent, MockWorkspaceShellRailComponent, + MockWorkspaceShellServerBannerComponent, TrafficLightsClearanceDirective, ], providers: [ @@ -612,6 +624,7 @@ describe('WorkspaceShellComponent', () => { MockWorkspaceShellHeaderComponent, MockWorkspaceShellImportOverlayComponent, MockWorkspaceShellRailComponent, + MockWorkspaceShellServerBannerComponent, TrafficLightsClearanceDirective, ], providers: [ diff --git a/libs/workspace/shell/feature/src/lib/workspace-shell/workspace-shell.component.ts b/libs/workspace/shell/feature/src/lib/workspace-shell/workspace-shell.component.ts index 3a99aaab9..0860fa974 100644 --- a/libs/workspace/shell/feature/src/lib/workspace-shell/workspace-shell.component.ts +++ b/libs/workspace/shell/feature/src/lib/workspace-shell/workspace-shell.component.ts @@ -21,6 +21,7 @@ import { WorkspaceShellContextSidebarComponent } from './components/workspace-sh import { WorkspaceShellHeaderComponent } from './components/workspace-shell-header/workspace-shell-header.component'; import { WorkspaceShellImportOverlayComponent } from './components/workspace-shell-import-overlay/workspace-shell-import-overlay.component'; import { WorkspaceShellRailComponent } from './components/workspace-shell-rail/workspace-shell-rail.component'; +import { WorkspaceShellServerBannerComponent } from './components/workspace-shell-server-banner/workspace-shell-server-banner.component'; import { TrafficLightsClearanceDirective } from './traffic-lights-clearance.directive'; import { WorkspaceShellFacade } from './services/workspace-shell.facade'; import { WorkspaceShellXtreamImportService } from './services/workspace-shell-xtream-import.service'; @@ -48,6 +49,7 @@ import { LIVE_CATEGORIES_POPOVER } from '@iptvnator/portal/shared/util'; WorkspaceShellHeaderComponent, WorkspaceShellImportOverlayComponent, WorkspaceShellRailComponent, + WorkspaceShellServerBannerComponent, ], templateUrl: './workspace-shell.component.html', styleUrl: './workspace-shell.component.scss',