feat(workspace): server status in the rail and an offline banner

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
4grayandClaude Opus 5.5 committed 2026-10-12 00:16:55 +02:00
1 parent 07b942a832
commit fa79c3eb6d
12 files changed
+282 -7

No files matched your search

+7 -1
View File
@@ -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",
@@ -4,10 +4,7 @@
<app-workspace-shell-rail-links [links]="workspaceLinks()" />
@if (primaryContextLinks().length > 0) {
<div
class="rail-context-region"
[class]="railProviderClass()"
>
<div class="rail-context-region" [class]="railProviderClass()">
<app-workspace-shell-rail-links
activeClass="is-active"
[selectedSection]="selectedSection()"
@@ -26,6 +23,20 @@
}
<div class="rail-footer">
@if (serverState() !== 'inactive') {
<a
class="rail-shortcut nav-item rail-server"
routerLink="/workspace/settings/server"
[attr.data-state]="serverState()"
[matTooltip]="serverLabelKey() | translate"
[matTooltipPosition]="'right'"
[attr.aria-label]="serverLabelKey() | translate"
data-test-id="rail-server-status"
>
<mat-icon>{{ serverIcon() }}</mat-icon>
<span class="rail-server__dot" aria-hidden="true"></span>
</a>
}
<a
class="rail-shortcut nav-item"
routerLink="/workspace/settings"
@@ -19,7 +19,11 @@
overflow-y: auto;
overflow-x: hidden;
scrollbar-width: none;
--rail-link-color: color-mix(in srgb, var(--app-on-surface) 65%, transparent);
--rail-link-color: color-mix(
in srgb,
var(--app-on-surface) 65%,
transparent
);
--rail-link-hover-color: var(--app-on-surface);
--rail-link-hover-bg: var(--app-hover-overlay);
--rail-link-active-bg: var(--app-selection-surface);
@@ -287,3 +291,35 @@
);
}
}
// Server storage indicator (self-hosted PWA only): the dot carries the
// connection state, so the icon can stay quiet while online.
.rail-footer .rail-server {
.rail-server__dot {
position: absolute;
right: 9px;
bottom: 9px;
width: 7px;
height: 7px;
border-radius: 50%;
border: 2px solid
var(--app-rail-bg, var(--mat-sys-surface-container-low));
background: var(--app-selection-color);
}
&[data-state='offline'] {
color: var(--app-expiry-warn-text);
.rail-server__dot {
background: var(--app-expiry-warn-text);
}
}
&[data-state='unauthorized'] {
color: var(--app-expiry-expired-text);
.rail-server__dot {
background: var(--app-expiry-expired-text);
}
}
}
@@ -7,6 +7,7 @@ import { TranslateService } from '@ngx-translate/core';
import { of } from 'rxjs';
import { RouterLink } from '@angular/router';
import { TranslatePipe } from '@ngx-translate/core';
import { ServerStorageStatusService } from '@iptvnator/services';
import { WorkspaceShellRailComponent } from './workspace-shell-rail.component';
@Component({
@@ -93,4 +94,44 @@ describe('WorkspaceShellRailComponent', () => {
?.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'
);
});
});
});
@@ -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<ServerConnectionState, string> = {
inactive: 'dns',
online: 'dns',
offline: 'cloud_off',
unauthorized: 'lock',
};
const SERVER_LABEL_KEYS: Record<ServerConnectionState, string> = {
inactive: 'SERVER_STORAGE.STATUS_ONLINE',
online: 'SERVER_STORAGE.STATUS_ONLINE',
offline: 'SERVER_STORAGE.STATUS_OFFLINE',
unauthorized: 'SERVER_STORAGE.STATUS_UNAUTHORIZED',
};
@@ -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;
}
}
@@ -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<WorkspaceShellServerBannerComponent>;
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();
});
});
@@ -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') {
<div
class="server-banner"
role="status"
data-test-id="server-offline-banner"
>
<mat-icon aria-hidden="true">cloud_off</mat-icon>
<span class="server-banner__text">{{
'SERVER_STORAGE.OFFLINE_BANNER' | translate
}}</span>
<button
mat-stroked-button
type="button"
(click)="status.retry()"
data-test-id="server-offline-retry"
>
{{ 'SERVER_STORAGE.RETRY' | translate }}
</button>
</div>
}
`,
styleUrl: './workspace-shell-server-banner.component.scss',
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class WorkspaceShellServerBannerComponent {
readonly status = inject(ServerStorageStatusService);
}
@@ -116,6 +116,9 @@
shadow without clipping; the scroller inside clips to a
rectangle (see the stylesheet for why). -->
<div class="workspace-content-frame">
<!-- Self-hosted server storage offline: a row of its own above
the scroller, so full-height pages keep their height. -->
<app-workspace-shell-server-banner />
<!-- tabindex="-1": programmatic focus target for the drawer-close
fallback when the toggle is gone (navigated to a route without
a context panel); not in the Tab order. -->
@@ -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;
@@ -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: [
@@ -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',