mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-14 13:16:16 -08:00
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:
1 parent
07b942a832
commit
fa79c3eb6d
12 files changed
+282
-7
No files matched your search
@@ -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",
|
||||
|
||||
+15
-4
@@ -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"
|
||||
|
||||
+37
-1
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
+41
@@ -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'
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
+32
-1
@@ -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',
|
||||
};
|
||||
+29
@@ -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;
|
||||
}
|
||||
}
|
||||
+58
@@ -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();
|
||||
});
|
||||
});
|
||||
+41
@@ -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;
|
||||
|
||||
+13
@@ -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',
|
||||
|
||||
Reference in new issue
Block a user