mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-10 01:56:16 -08:00
feat: add loading state and spinner to source stats widget
- Introduced a loading state in the source stats widget with a spinner. - Updated styles for the loading state in the SCSS file. - Created unit tests for the source stats widget to verify loading behavior. - Enhanced the source stats widget component to include a progress spinner. - Added a new service for managing workspace startup preferences. - Implemented logic to determine the initial workspace path based on user settings. - Updated workspace shell header to include a refresh playlist button. - Refactored workspace shell rail component to support dynamic brand link and tooltip. - Enhanced workspace shell facade to manage playlist refresh actions. - Improved loading overlay UI in the workspace shell component. Entire-Checkpoint: 271bd0162027
This commit is contained in:
1 parent
26fa697afe
commit
eaa3e0e2bd
37 files changed
+1178
-103
No files matched your search
@@ -0,0 +1,21 @@
|
||||
export default {
|
||||
displayName: 'workspace-dashboard-ui',
|
||||
preset: '../../../../jest.preset.js',
|
||||
setupFilesAfterEnv: ['<rootDir>/src/test-setup.ts'],
|
||||
coverageDirectory: '../../../../coverage/libs/workspace/dashboard/ui',
|
||||
transform: {
|
||||
'^.+\\.(ts|mjs|js|html)$': [
|
||||
'jest-preset-angular',
|
||||
{
|
||||
tsconfig: '<rootDir>/tsconfig.spec.json',
|
||||
stringifyContentPathRegex: '\\.(html|svg)$',
|
||||
},
|
||||
],
|
||||
},
|
||||
transformIgnorePatterns: ['node_modules/(?!.*\\.mjs$)'],
|
||||
snapshotSerializers: [
|
||||
'jest-preset-angular/build/serializers/no-ng-attributes',
|
||||
'jest-preset-angular/build/serializers/ng-snapshot',
|
||||
'jest-preset-angular/build/serializers/html-comment',
|
||||
],
|
||||
};
|
||||
@@ -6,6 +6,14 @@
|
||||
"projectType": "library",
|
||||
"tags": ["scope:workspace", "domain:workspace", "type:ui"],
|
||||
"targets": {
|
||||
"test": {
|
||||
"executor": "@nx/jest:jest",
|
||||
"outputs": ["{workspaceRoot}/coverage/{projectRoot}"],
|
||||
"options": {
|
||||
"jestConfig": "libs/workspace/dashboard/ui/jest.config.ts",
|
||||
"tsConfig": "libs/workspace/dashboard/ui/tsconfig.spec.json"
|
||||
}
|
||||
},
|
||||
"lint": {
|
||||
"executor": "@nx/eslint:lint"
|
||||
}
|
||||
|
||||
+10
-2
@@ -2,8 +2,16 @@
|
||||
[eyebrow]="'WORKSPACE.DASHBOARD.FOCUS' | translate"
|
||||
[title]="'WORKSPACE.DASHBOARD.CONTINUE_WATCHING' | translate"
|
||||
>
|
||||
@if (lastItem(); as item) {
|
||||
<a class="hero" [routerLink]="getLink(item)">
|
||||
@if (data.globalRecentLoading()) {
|
||||
<div
|
||||
class="continue-watching-widget__loading"
|
||||
role="status"
|
||||
[attr.aria-label]="'WORKSPACE.DASHBOARD.CONTINUE_WATCHING' | translate"
|
||||
>
|
||||
<mat-progress-spinner mode="indeterminate" diameter="36" />
|
||||
</div>
|
||||
} @else if (lastItem(); as item) {
|
||||
<a class="hero" [routerLink]="getLink(item)" [state]="getState(item)">
|
||||
<!-- Thumbnail -->
|
||||
<span class="hero__thumb" [class]="'hero__thumb--' + item.type">
|
||||
@if (hasImage(item)) {
|
||||
|
||||
@@ -13,6 +13,13 @@
|
||||
--_series-accent: oklch(68% 0.16 55);
|
||||
}
|
||||
|
||||
.continue-watching-widget__loading {
|
||||
min-height: 220px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
padding: 16px 0;
|
||||
}
|
||||
|
||||
// ── Hero card ─────────────────────────────────────────────────────────────────
|
||||
|
||||
.hero {
|
||||
|
||||
+48
@@ -0,0 +1,48 @@
|
||||
import { signal } from '@angular/core';
|
||||
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
||||
import { provideRouter } from '@angular/router';
|
||||
import { TranslateModule } from '@ngx-translate/core';
|
||||
import { DashboardDataService } from 'workspace-dashboard-data-access';
|
||||
import { ContinueWatchingWidgetComponent } from './continue-watching-widget.component';
|
||||
|
||||
describe('ContinueWatchingWidgetComponent', () => {
|
||||
let fixture: ComponentFixture<ContinueWatchingWidgetComponent>;
|
||||
|
||||
const dataServiceMock = {
|
||||
globalRecentLoading: signal(true),
|
||||
globalRecentItems: signal([]),
|
||||
quickRecent: signal([]),
|
||||
getRecentItemLink: jest.fn(() => ['/workspace', 'global-recent']),
|
||||
getRecentItemNavigationState: jest.fn(() => undefined),
|
||||
getPlaylistLink: jest.fn(() => ['/workspace', 'sources']),
|
||||
getPlaylistProvider: jest.fn(() => 'M3U'),
|
||||
};
|
||||
|
||||
beforeEach(async () => {
|
||||
dataServiceMock.globalRecentLoading.set(true);
|
||||
dataServiceMock.globalRecentItems.set([]);
|
||||
dataServiceMock.quickRecent.set([]);
|
||||
|
||||
await TestBed.configureTestingModule({
|
||||
imports: [ContinueWatchingWidgetComponent, TranslateModule.forRoot()],
|
||||
providers: [
|
||||
provideRouter([]),
|
||||
{
|
||||
provide: DashboardDataService,
|
||||
useValue: dataServiceMock,
|
||||
},
|
||||
],
|
||||
}).compileComponents();
|
||||
|
||||
fixture = TestBed.createComponent(ContinueWatchingWidgetComponent);
|
||||
});
|
||||
|
||||
it('shows a spinner while recent history is still loading', () => {
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(
|
||||
fixture.nativeElement.querySelector('mat-progress-spinner')
|
||||
).not.toBeNull();
|
||||
expect(fixture.nativeElement.querySelector('.focus-empty')).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -2,6 +2,7 @@ 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 { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
|
||||
import { RouterLink } from '@angular/router';
|
||||
import { TranslatePipe, TranslateService } from '@ngx-translate/core';
|
||||
import { startWith } from 'rxjs';
|
||||
@@ -16,6 +17,7 @@ import { DashboardWidgetShellComponent } from './dashboard-widget-shell.componen
|
||||
imports: [
|
||||
MatButtonModule,
|
||||
MatIcon,
|
||||
MatProgressSpinnerModule,
|
||||
RouterLink,
|
||||
DashboardWidgetShellComponent,
|
||||
TranslatePipe,
|
||||
@@ -44,6 +46,10 @@ export class ContinueWatchingWidgetComponent {
|
||||
return this.data.getRecentItemLink(item);
|
||||
}
|
||||
|
||||
getState(item: GlobalRecentItem): Record<string, unknown> | undefined {
|
||||
return this.data.getRecentItemNavigationState(item);
|
||||
}
|
||||
|
||||
typeIcon(type: GlobalRecentItem['type']): string {
|
||||
if (type === 'live') return 'live_tv';
|
||||
if (type === 'movie') return 'movie';
|
||||
|
||||
+24
-11
@@ -80,15 +80,28 @@
|
||||
</mat-button-toggle-group>
|
||||
</ng-container>
|
||||
|
||||
<app-dashboard-activity-items
|
||||
[items]="activityItems()"
|
||||
[emptyLabel]="'WORKSPACE.DASHBOARD.NO_FAVORITES' | translate"
|
||||
emptyIcon="favorite_border"
|
||||
[emptyHint]="'WORKSPACE.DASHBOARD.NO_FAVORITES_HINT' | translate"
|
||||
[viewMode]="viewMode()"
|
||||
[showRemoveAction]="true"
|
||||
[removeActionLabel]="'WORKSPACE.DASHBOARD.REMOVE_FAVORITE' | translate"
|
||||
(itemAction)="onRemoveItem($event)"
|
||||
(scrolledToEnd)="onItemsScrolledToEnd()"
|
||||
/>
|
||||
@if (data.globalFavoritesLoading()) {
|
||||
<div
|
||||
class="global-favorites-widget__loading"
|
||||
role="status"
|
||||
[attr.aria-label]="'WORKSPACE.DASHBOARD.GLOBAL_FAVORITES' | translate"
|
||||
>
|
||||
<mat-progress-spinner
|
||||
mode="indeterminate"
|
||||
diameter="36"
|
||||
/>
|
||||
</div>
|
||||
} @else {
|
||||
<app-dashboard-activity-items
|
||||
[items]="activityItems()"
|
||||
[emptyLabel]="'WORKSPACE.DASHBOARD.NO_FAVORITES' | translate"
|
||||
emptyIcon="favorite_border"
|
||||
[emptyHint]="'WORKSPACE.DASHBOARD.NO_FAVORITES_HINT' | translate"
|
||||
[viewMode]="viewMode()"
|
||||
[showRemoveAction]="true"
|
||||
[removeActionLabel]="'WORKSPACE.DASHBOARD.REMOVE_FAVORITE' | translate"
|
||||
(itemAction)="onRemoveItem($event)"
|
||||
(scrolledToEnd)="onItemsScrolledToEnd()"
|
||||
/>
|
||||
}
|
||||
</app-dashboard-widget-shell>
|
||||
@@ -38,3 +38,10 @@
|
||||
font-weight: 600;
|
||||
color: var(--mat-sys-primary);
|
||||
}
|
||||
|
||||
.global-favorites-widget__loading {
|
||||
min-height: 220px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
padding: 16px 0;
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
import { signal } from '@angular/core';
|
||||
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
||||
import { provideRouter } from '@angular/router';
|
||||
import { TranslateModule } from '@ngx-translate/core';
|
||||
import { DashboardDataService } from 'workspace-dashboard-data-access';
|
||||
import { GlobalFavoritesWidgetComponent } from './global-favorites-widget.component';
|
||||
|
||||
describe('GlobalFavoritesWidgetComponent', () => {
|
||||
let fixture: ComponentFixture<GlobalFavoritesWidgetComponent>;
|
||||
const globalFavoritesLoading = signal(true);
|
||||
const globalFavoritesLoaded = signal(false);
|
||||
const globalFavoriteItems = signal<any[]>([]);
|
||||
|
||||
const dataServiceMock = {
|
||||
globalFavoriteItems,
|
||||
globalFavoritesLoading,
|
||||
globalFavoritesLoaded,
|
||||
reloadGlobalFavorites: jest.fn().mockResolvedValue(undefined),
|
||||
isTypeInKind: jest.fn(
|
||||
(type: 'live' | 'movie' | 'series', kind: string) =>
|
||||
kind === 'all' ||
|
||||
(kind === 'channels' && type === 'live') ||
|
||||
(kind === 'vod' && type === 'movie') ||
|
||||
(kind === 'series' && type === 'series')
|
||||
),
|
||||
getFavoriteItemProviderLabel: jest.fn(() => 'Xtream'),
|
||||
getFavoriteItemTypeLabel: jest.fn(() => 'Live TV'),
|
||||
getGlobalFavoriteLink: jest.fn(() => ['/workspace', 'global-favorites']),
|
||||
getGlobalFavoriteNavigationState: jest.fn(() => undefined),
|
||||
removeGlobalFavorite: jest.fn(),
|
||||
};
|
||||
|
||||
beforeEach(async () => {
|
||||
globalFavoritesLoading.set(true);
|
||||
globalFavoritesLoaded.set(false);
|
||||
globalFavoriteItems.set([]);
|
||||
jest.clearAllMocks();
|
||||
|
||||
await TestBed.configureTestingModule({
|
||||
imports: [
|
||||
GlobalFavoritesWidgetComponent,
|
||||
TranslateModule.forRoot(),
|
||||
],
|
||||
providers: [
|
||||
provideRouter([]),
|
||||
{
|
||||
provide: DashboardDataService,
|
||||
useValue: dataServiceMock,
|
||||
},
|
||||
],
|
||||
}).compileComponents();
|
||||
|
||||
fixture = TestBed.createComponent(GlobalFavoritesWidgetComponent);
|
||||
fixture.componentRef.setInput('widget', {
|
||||
id: 'global-favorites',
|
||||
type: 'global-favorites',
|
||||
enabled: true,
|
||||
order: 0,
|
||||
size: 'medium',
|
||||
} as any);
|
||||
});
|
||||
|
||||
it('shows a spinner while global favorites are still loading', () => {
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(dataServiceMock.reloadGlobalFavorites).toHaveBeenCalled();
|
||||
expect(
|
||||
fixture.nativeElement.querySelector('mat-progress-spinner')
|
||||
).not.toBeNull();
|
||||
expect(
|
||||
fixture.nativeElement.querySelector('.empty-state')
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it('shows the empty state only after the initial load completes', () => {
|
||||
globalFavoritesLoading.set(false);
|
||||
globalFavoritesLoaded.set(true);
|
||||
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(
|
||||
fixture.nativeElement.querySelector('mat-progress-spinner')
|
||||
).toBeNull();
|
||||
expect(
|
||||
fixture.nativeElement.querySelector('.empty-state')
|
||||
).not.toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -1,4 +1,5 @@
|
||||
import {
|
||||
ChangeDetectionStrategy,
|
||||
Component,
|
||||
OnInit,
|
||||
computed,
|
||||
@@ -11,6 +12,7 @@ import { MatButtonModule } from '@angular/material/button';
|
||||
import { MatButtonToggleModule } from '@angular/material/button-toggle';
|
||||
import { MatIcon } from '@angular/material/icon';
|
||||
import { MatMenuModule } from '@angular/material/menu';
|
||||
import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
|
||||
import { MatTooltipModule } from '@angular/material/tooltip';
|
||||
import { TranslatePipe, TranslateService } from '@ngx-translate/core';
|
||||
import { startWith } from 'rxjs';
|
||||
@@ -35,11 +37,13 @@ const PAGE_SIZE = 20;
|
||||
|
||||
@Component({
|
||||
selector: 'app-global-favorites-widget',
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
imports: [
|
||||
MatButtonModule,
|
||||
MatButtonToggleModule,
|
||||
MatMenuModule,
|
||||
MatIcon,
|
||||
MatProgressSpinnerModule,
|
||||
MatTooltipModule,
|
||||
DashboardActivityItemsComponent,
|
||||
DashboardWidgetShellComponent,
|
||||
@@ -97,10 +101,8 @@ export class GlobalFavoritesWidgetComponent implements OnInit {
|
||||
}))
|
||||
);
|
||||
|
||||
constructor() {}
|
||||
|
||||
ngOnInit(): void {
|
||||
this.data.reloadGlobalFavorites().then(() => {
|
||||
void this.data.reloadGlobalFavorites().then(() => {
|
||||
this.visibleItemLimit.set(INITIAL_PAGE_SIZE);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -61,11 +61,12 @@
|
||||
</ng-container>
|
||||
|
||||
@if (loading()) {
|
||||
<div class="feedback-state">
|
||||
<mat-icon class="feedback-state__icon">schedule</mat-icon>
|
||||
<p class="feedback-state__title">
|
||||
{{ 'WORKSPACE.DASHBOARD.LOADING_RECENTLY_ADDED' | translate }}
|
||||
</p>
|
||||
<div
|
||||
class="feedback-state feedback-state--loading"
|
||||
role="status"
|
||||
[attr.aria-label]="'WORKSPACE.DASHBOARD.LOADING_RECENTLY_ADDED' | translate"
|
||||
>
|
||||
<mat-progress-spinner mode="indeterminate" diameter="36" />
|
||||
</div>
|
||||
} @else if (loadFailed()) {
|
||||
<div class="feedback-state">
|
||||
|
||||
@@ -59,6 +59,12 @@
|
||||
color: var(--mat-sys-on-surface-variant);
|
||||
}
|
||||
|
||||
.feedback-state--loading {
|
||||
min-height: 220px;
|
||||
border-style: solid;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.feedback-state__icon {
|
||||
font-size: 34px;
|
||||
width: 34px;
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
||||
import { provideRouter } from '@angular/router';
|
||||
import { TranslateModule } from '@ngx-translate/core';
|
||||
import { DashboardDataService } from 'workspace-dashboard-data-access';
|
||||
import { RecentlyAddedWidgetComponent } from './recently-added-widget.component';
|
||||
|
||||
describe('RecentlyAddedWidgetComponent', () => {
|
||||
let fixture: ComponentFixture<RecentlyAddedWidgetComponent>;
|
||||
|
||||
const dataServiceMock = {
|
||||
getGlobalRecentlyAddedItems: jest.fn(() => new Promise(() => {})),
|
||||
getRecentlyAddedItemProviderLabel: jest.fn(() => 'Xtream'),
|
||||
getRecentlyAddedItemTypeLabel: jest.fn(() => 'Movie'),
|
||||
getRecentlyAddedLink: jest.fn(() => ['/workspace', 'global-favorites']),
|
||||
getRecentlyAddedNavigationState: jest.fn(() => undefined),
|
||||
formatTimestamp: jest.fn(() => 'recently'),
|
||||
};
|
||||
|
||||
beforeEach(async () => {
|
||||
jest.clearAllMocks();
|
||||
|
||||
await TestBed.configureTestingModule({
|
||||
imports: [RecentlyAddedWidgetComponent, TranslateModule.forRoot()],
|
||||
providers: [
|
||||
provideRouter([]),
|
||||
{
|
||||
provide: DashboardDataService,
|
||||
useValue: dataServiceMock,
|
||||
},
|
||||
],
|
||||
}).compileComponents();
|
||||
|
||||
fixture = TestBed.createComponent(RecentlyAddedWidgetComponent);
|
||||
fixture.componentRef.setInput('widget', {
|
||||
id: 'recently-added',
|
||||
type: 'recently-added',
|
||||
enabled: true,
|
||||
order: 0,
|
||||
size: 'medium',
|
||||
} as any);
|
||||
});
|
||||
|
||||
it('shows a spinner while recently added items are loading', () => {
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(
|
||||
fixture.nativeElement.querySelector('mat-progress-spinner')
|
||||
).not.toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -11,6 +11,7 @@ import { MatButtonModule } from '@angular/material/button';
|
||||
import { MatButtonToggleModule } from '@angular/material/button-toggle';
|
||||
import { MatIcon } from '@angular/material/icon';
|
||||
import { MatMenuModule } from '@angular/material/menu';
|
||||
import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
|
||||
import { MatTooltipModule } from '@angular/material/tooltip';
|
||||
import { TranslatePipe, TranslateService } from '@ngx-translate/core';
|
||||
import { startWith } from 'rxjs';
|
||||
@@ -41,6 +42,7 @@ const DISPLAY_LIMIT = 25;
|
||||
MatButtonToggleModule,
|
||||
MatMenuModule,
|
||||
MatIcon,
|
||||
MatProgressSpinnerModule,
|
||||
MatTooltipModule,
|
||||
DashboardActivityItemsComponent,
|
||||
DashboardWidgetShellComponent,
|
||||
|
||||
+25
-15
@@ -80,19 +80,29 @@
|
||||
</mat-button-toggle-group>
|
||||
</ng-container>
|
||||
|
||||
<app-dashboard-activity-items
|
||||
[items]="activityItems()"
|
||||
[emptyLabel]="'WORKSPACE.DASHBOARD.NO_HISTORY' | translate"
|
||||
emptyIcon="history"
|
||||
[emptyHint]="'WORKSPACE.DASHBOARD.NO_HISTORY_HINT' | translate"
|
||||
[emptyActionLabel]="'WORKSPACE.DASHBOARD.ADD_SOURCE' | translate"
|
||||
[emptyActionLink]="['/workspace/sources']"
|
||||
[viewMode]="viewMode()"
|
||||
[showRemoveAction]="true"
|
||||
[removeActionLabel]="
|
||||
'WORKSPACE.DASHBOARD.REMOVE_FROM_HISTORY' | translate
|
||||
"
|
||||
(itemAction)="onRemoveItem($event)"
|
||||
(scrolledToEnd)="onItemsScrolledToEnd()"
|
||||
/>
|
||||
@if (data.globalRecentLoading()) {
|
||||
<div
|
||||
class="recently-watched-widget__loading"
|
||||
role="status"
|
||||
[attr.aria-label]="'WORKSPACE.DASHBOARD.RECENTLY_WATCHED' | translate"
|
||||
>
|
||||
<mat-progress-spinner mode="indeterminate" diameter="36" />
|
||||
</div>
|
||||
} @else {
|
||||
<app-dashboard-activity-items
|
||||
[items]="activityItems()"
|
||||
[emptyLabel]="'WORKSPACE.DASHBOARD.NO_HISTORY' | translate"
|
||||
emptyIcon="history"
|
||||
[emptyHint]="'WORKSPACE.DASHBOARD.NO_HISTORY_HINT' | translate"
|
||||
[emptyActionLabel]="'WORKSPACE.DASHBOARD.ADD_SOURCE' | translate"
|
||||
[emptyActionLink]="['/workspace/sources']"
|
||||
[viewMode]="viewMode()"
|
||||
[showRemoveAction]="true"
|
||||
[removeActionLabel]="
|
||||
'WORKSPACE.DASHBOARD.REMOVE_FROM_HISTORY' | translate
|
||||
"
|
||||
(itemAction)="onRemoveItem($event)"
|
||||
(scrolledToEnd)="onItemsScrolledToEnd()"
|
||||
/>
|
||||
}
|
||||
</app-dashboard-widget-shell>
|
||||
@@ -38,3 +38,10 @@
|
||||
font-weight: 600;
|
||||
color: var(--mat-sys-primary);
|
||||
}
|
||||
|
||||
.recently-watched-widget__loading {
|
||||
min-height: 220px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
padding: 16px 0;
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
import { signal } from '@angular/core';
|
||||
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
||||
import { provideRouter } from '@angular/router';
|
||||
import { TranslateModule } from '@ngx-translate/core';
|
||||
import { DashboardDataService } from 'workspace-dashboard-data-access';
|
||||
import { RecentlyWatchedWidgetComponent } from './recently-watched-widget.component';
|
||||
|
||||
describe('RecentlyWatchedWidgetComponent', () => {
|
||||
let fixture: ComponentFixture<RecentlyWatchedWidgetComponent>;
|
||||
|
||||
const dataServiceMock = {
|
||||
globalRecentLoading: signal(true),
|
||||
globalRecentItems: signal([]),
|
||||
reloadGlobalRecentItems: jest.fn().mockResolvedValue(undefined),
|
||||
isTypeInKind: jest.fn(() => true),
|
||||
getRecentItemProviderLabel: jest.fn(() => 'Xtream'),
|
||||
getRecentItemTypeLabel: jest.fn(() => 'Live TV'),
|
||||
getRecentItemLink: jest.fn(() => ['/workspace', 'global-recent']),
|
||||
getRecentItemNavigationState: jest.fn(() => undefined),
|
||||
removeGlobalRecentItem: jest.fn(),
|
||||
};
|
||||
|
||||
beforeEach(async () => {
|
||||
dataServiceMock.globalRecentLoading.set(true);
|
||||
dataServiceMock.globalRecentItems.set([]);
|
||||
jest.clearAllMocks();
|
||||
|
||||
await TestBed.configureTestingModule({
|
||||
imports: [RecentlyWatchedWidgetComponent, TranslateModule.forRoot()],
|
||||
providers: [
|
||||
provideRouter([]),
|
||||
{
|
||||
provide: DashboardDataService,
|
||||
useValue: dataServiceMock,
|
||||
},
|
||||
],
|
||||
}).compileComponents();
|
||||
|
||||
fixture = TestBed.createComponent(RecentlyWatchedWidgetComponent);
|
||||
fixture.componentRef.setInput('widget', {
|
||||
id: 'recently-watched',
|
||||
type: 'recently-watched',
|
||||
enabled: true,
|
||||
order: 0,
|
||||
size: 'medium',
|
||||
} as any);
|
||||
});
|
||||
|
||||
it('shows a spinner while recent history is loading', () => {
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(dataServiceMock.reloadGlobalRecentItems).toHaveBeenCalled();
|
||||
expect(
|
||||
fixture.nativeElement.querySelector('mat-progress-spinner')
|
||||
).not.toBeNull();
|
||||
expect(fixture.nativeElement.querySelector('.empty-state')).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -11,6 +11,7 @@ import { MatButtonModule } from '@angular/material/button';
|
||||
import { MatButtonToggleModule } from '@angular/material/button-toggle';
|
||||
import { MatIcon } from '@angular/material/icon';
|
||||
import { MatMenuModule } from '@angular/material/menu';
|
||||
import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
|
||||
import { MatTooltipModule } from '@angular/material/tooltip';
|
||||
import { TranslatePipe, TranslateService } from '@ngx-translate/core';
|
||||
import { startWith } from 'rxjs';
|
||||
@@ -40,6 +41,7 @@ const PAGE_SIZE = 20;
|
||||
MatButtonToggleModule,
|
||||
MatMenuModule,
|
||||
MatIcon,
|
||||
MatProgressSpinnerModule,
|
||||
MatTooltipModule,
|
||||
DashboardActivityItemsComponent,
|
||||
DashboardWidgetShellComponent,
|
||||
|
||||
@@ -2,40 +2,52 @@
|
||||
[eyebrow]="'WORKSPACE.DASHBOARD.OVERVIEW' | translate"
|
||||
[title]="'WORKSPACE.DASHBOARD.SOURCE_STATS' | translate"
|
||||
>
|
||||
<div class="stats-grid">
|
||||
<article class="stat-tile stat-tile--total">
|
||||
<div class="stat-tile__header">
|
||||
<mat-icon>dns</mat-icon>
|
||||
<p class="label">
|
||||
{{ 'WORKSPACE.DASHBOARD.TOTAL' | translate }}
|
||||
</p>
|
||||
</div>
|
||||
<p class="value">{{ data.stats().total }}</p>
|
||||
</article>
|
||||
<article class="stat-tile stat-tile--xtream">
|
||||
<div class="stat-tile__header">
|
||||
<mat-icon>tv</mat-icon>
|
||||
<p class="label">
|
||||
{{ 'WORKSPACE.DASHBOARD.XTREAM' | translate }}
|
||||
</p>
|
||||
</div>
|
||||
<p class="value">{{ data.stats().xtream }}</p>
|
||||
</article>
|
||||
<article class="stat-tile stat-tile--stalker">
|
||||
<div class="stat-tile__header">
|
||||
<mat-icon>router</mat-icon>
|
||||
<p class="label">
|
||||
{{ 'WORKSPACE.DASHBOARD.STALKER' | translate }}
|
||||
</p>
|
||||
</div>
|
||||
<p class="value">{{ data.stats().stalker }}</p>
|
||||
</article>
|
||||
<article class="stat-tile stat-tile--m3u">
|
||||
<div class="stat-tile__header">
|
||||
<mat-icon>list_alt</mat-icon>
|
||||
<p class="label">{{ 'WORKSPACE.DASHBOARD.M3U' | translate }}</p>
|
||||
</div>
|
||||
<p class="value">{{ data.stats().m3u }}</p>
|
||||
</article>
|
||||
</div>
|
||||
@if (!data.playlistsLoaded()) {
|
||||
<div
|
||||
class="source-stats-widget__loading"
|
||||
role="status"
|
||||
[attr.aria-label]="'WORKSPACE.DASHBOARD.SOURCE_STATS' | translate"
|
||||
>
|
||||
<mat-progress-spinner mode="indeterminate" diameter="36" />
|
||||
</div>
|
||||
} @else {
|
||||
<div class="stats-grid">
|
||||
<article class="stat-tile stat-tile--total">
|
||||
<div class="stat-tile__header">
|
||||
<mat-icon>dns</mat-icon>
|
||||
<p class="label">
|
||||
{{ 'WORKSPACE.DASHBOARD.TOTAL' | translate }}
|
||||
</p>
|
||||
</div>
|
||||
<p class="value">{{ data.stats().total }}</p>
|
||||
</article>
|
||||
<article class="stat-tile stat-tile--xtream">
|
||||
<div class="stat-tile__header">
|
||||
<mat-icon>tv</mat-icon>
|
||||
<p class="label">
|
||||
{{ 'WORKSPACE.DASHBOARD.XTREAM' | translate }}
|
||||
</p>
|
||||
</div>
|
||||
<p class="value">{{ data.stats().xtream }}</p>
|
||||
</article>
|
||||
<article class="stat-tile stat-tile--stalker">
|
||||
<div class="stat-tile__header">
|
||||
<mat-icon>router</mat-icon>
|
||||
<p class="label">
|
||||
{{ 'WORKSPACE.DASHBOARD.STALKER' | translate }}
|
||||
</p>
|
||||
</div>
|
||||
<p class="value">{{ data.stats().stalker }}</p>
|
||||
</article>
|
||||
<article class="stat-tile stat-tile--m3u">
|
||||
<div class="stat-tile__header">
|
||||
<mat-icon>list_alt</mat-icon>
|
||||
<p class="label">
|
||||
{{ 'WORKSPACE.DASHBOARD.M3U' | translate }}
|
||||
</p>
|
||||
</div>
|
||||
<p class="value">{{ data.stats().m3u }}</p>
|
||||
</article>
|
||||
</div>
|
||||
}
|
||||
</app-dashboard-widget-shell>
|
||||
@@ -1,3 +1,10 @@
|
||||
.source-stats-widget__loading {
|
||||
min-height: 180px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
padding: 16px 0;
|
||||
}
|
||||
|
||||
.stats-grid {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
import { signal } from '@angular/core';
|
||||
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
||||
import { TranslateModule } from '@ngx-translate/core';
|
||||
import { DashboardDataService } from 'workspace-dashboard-data-access';
|
||||
import { SourceStatsWidgetComponent } from './source-stats-widget.component';
|
||||
|
||||
describe('SourceStatsWidgetComponent', () => {
|
||||
let fixture: ComponentFixture<SourceStatsWidgetComponent>;
|
||||
|
||||
const dataServiceMock = {
|
||||
playlistsLoaded: signal(false),
|
||||
stats: signal({
|
||||
total: 0,
|
||||
xtream: 0,
|
||||
stalker: 0,
|
||||
m3u: 0,
|
||||
}),
|
||||
};
|
||||
|
||||
beforeEach(async () => {
|
||||
dataServiceMock.playlistsLoaded.set(false);
|
||||
|
||||
await TestBed.configureTestingModule({
|
||||
imports: [SourceStatsWidgetComponent, TranslateModule.forRoot()],
|
||||
providers: [
|
||||
{
|
||||
provide: DashboardDataService,
|
||||
useValue: dataServiceMock,
|
||||
},
|
||||
],
|
||||
}).compileComponents();
|
||||
|
||||
fixture = TestBed.createComponent(SourceStatsWidgetComponent);
|
||||
});
|
||||
|
||||
it('shows a spinner while playlists are still hydrating', () => {
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(
|
||||
fixture.nativeElement.querySelector('mat-progress-spinner')
|
||||
).not.toBeNull();
|
||||
expect(fixture.nativeElement.querySelector('.stats-grid')).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -1,12 +1,18 @@
|
||||
import { Component, inject } from '@angular/core';
|
||||
import { MatIcon } from '@angular/material/icon';
|
||||
import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
|
||||
import { TranslatePipe } from '@ngx-translate/core';
|
||||
import { DashboardDataService } from 'workspace-dashboard-data-access';
|
||||
import { DashboardWidgetShellComponent } from './dashboard-widget-shell.component';
|
||||
|
||||
@Component({
|
||||
selector: 'app-source-stats-widget',
|
||||
imports: [DashboardWidgetShellComponent, TranslatePipe, MatIcon],
|
||||
imports: [
|
||||
DashboardWidgetShellComponent,
|
||||
TranslatePipe,
|
||||
MatIcon,
|
||||
MatProgressSpinnerModule,
|
||||
],
|
||||
templateUrl: './source-stats-widget.component.html',
|
||||
styleUrl: './source-stats-widget.component.scss',
|
||||
})
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
import { setupZoneTestEnv } from 'jest-preset-angular/setup-env/zone';
|
||||
|
||||
setupZoneTestEnv({
|
||||
errorOnUnknownElements: true,
|
||||
errorOnUnknownProperties: true,
|
||||
});
|
||||
@@ -0,0 +1,17 @@
|
||||
{
|
||||
"extends": "./tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "../../../../dist/out-tsc",
|
||||
"module": "commonjs",
|
||||
"target": "es2016",
|
||||
"types": ["jest", "node"],
|
||||
"moduleResolution": "node10"
|
||||
},
|
||||
"files": ["src/test-setup.ts"],
|
||||
"include": [
|
||||
"jest.config.ts",
|
||||
"src/**/*.test.ts",
|
||||
"src/**/*.spec.ts",
|
||||
"src/**/*.d.ts"
|
||||
]
|
||||
}
|
||||
+17
@@ -57,6 +57,23 @@
|
||||
>
|
||||
<mat-icon>add</mat-icon>
|
||||
</button>
|
||||
@if (canRefreshPlaylist()) {
|
||||
<button
|
||||
type="button"
|
||||
class="header-shortcut"
|
||||
mat-icon-button
|
||||
[disabled]="isRefreshingPlaylist()"
|
||||
(click)="onRefreshPlaylistRequested()"
|
||||
[matTooltip]="
|
||||
'WORKSPACE.SHELL.REFRESH_PLAYLIST' | translate
|
||||
"
|
||||
[attr.aria-label]="
|
||||
'WORKSPACE.SHELL.REFRESH_PLAYLIST' | translate
|
||||
"
|
||||
>
|
||||
<mat-icon>sync</mat-icon>
|
||||
</button>
|
||||
}
|
||||
<span class="header-actions-divider" aria-hidden="true"></span>
|
||||
<button
|
||||
type="button"
|
||||
|
||||
+7
-1
@@ -36,6 +36,8 @@ export class WorkspaceShellHeaderComponent {
|
||||
readonly searchStatusLabel = input('');
|
||||
readonly isGlobalFavoritesActive = input(false);
|
||||
readonly headerShortcut = input<WorkspaceHeaderAction | null>(null);
|
||||
readonly canRefreshPlaylist = input(false);
|
||||
readonly isRefreshingPlaylist = input(false);
|
||||
readonly isElectron = input(false);
|
||||
readonly isDownloadsView = input(false);
|
||||
|
||||
@@ -45,6 +47,7 @@ export class WorkspaceShellHeaderComponent {
|
||||
readonly addPlaylistRequested = output<void>();
|
||||
readonly globalFavoritesRequested = output<void>();
|
||||
readonly headerShortcutRequested = output<void>();
|
||||
readonly refreshPlaylistRequested = output<void>();
|
||||
readonly downloadsRequested = output<void>();
|
||||
readonly playlistInfoRequested = output<void>();
|
||||
readonly accountInfoRequested = output<void>();
|
||||
@@ -83,8 +86,11 @@ export class WorkspaceShellHeaderComponent {
|
||||
this.headerShortcutRequested.emit();
|
||||
}
|
||||
|
||||
onRefreshPlaylistRequested(): void {
|
||||
this.refreshPlaylistRequested.emit();
|
||||
}
|
||||
|
||||
onDownloadsRequested(): void {
|
||||
this.downloadsRequested.emit();
|
||||
}
|
||||
|
||||
}
|
||||
+3
-3
@@ -1,10 +1,10 @@
|
||||
<aside class="app-rail" [class.is-macos]="isMacOS()">
|
||||
<a
|
||||
class="brand"
|
||||
routerLink="/workspace/dashboard"
|
||||
[matTooltip]="'WORKSPACE.SHELL.RAIL_DASHBOARD' | translate"
|
||||
[routerLink]="brandLink()"
|
||||
[matTooltip]="brandTooltipKey() | translate"
|
||||
[matTooltipPosition]="'right'"
|
||||
[attr.aria-label]="'WORKSPACE.SHELL.OPEN_DASHBOARD' | translate"
|
||||
[attr.aria-label]="brandAriaLabelKey() | translate"
|
||||
>
|
||||
<img
|
||||
src="./assets/icons/icon-tv-256.png"
|
||||
|
||||
+15
@@ -60,6 +60,11 @@ describe('WorkspaceShellRailComponent', () => {
|
||||
});
|
||||
|
||||
it('renders provider context region and active settings shortcut state', () => {
|
||||
fixture.componentRef.setInput('brandLink', '/workspace/sources');
|
||||
fixture.componentRef.setInput(
|
||||
'brandAriaLabelKey',
|
||||
'WORKSPACE.SHELL.OPEN_SOURCES'
|
||||
);
|
||||
fixture.componentRef.setInput('primaryContextLinks', [
|
||||
{
|
||||
icon: 'movie',
|
||||
@@ -81,5 +86,15 @@ describe('WorkspaceShellRailComponent', () => {
|
||||
expect(
|
||||
fixture.nativeElement.querySelector('.rail-shortcut.is-active')
|
||||
).not.toBeNull();
|
||||
expect(
|
||||
fixture.nativeElement
|
||||
.querySelector('.brand')
|
||||
?.getAttribute('href')
|
||||
).toContain('/workspace/sources');
|
||||
expect(
|
||||
fixture.nativeElement
|
||||
.querySelector('.brand')
|
||||
?.getAttribute('aria-label')
|
||||
).toBe('WORKSPACE.SHELL.OPEN_SOURCES');
|
||||
});
|
||||
});
|
||||
+3
@@ -28,6 +28,9 @@ import { WorkspaceShellRailLinksComponent } from '../workspace-shell-rail-links/
|
||||
})
|
||||
export class WorkspaceShellRailComponent {
|
||||
readonly isMacOS = input(false);
|
||||
readonly brandLink = input('/workspace/dashboard');
|
||||
readonly brandTooltipKey = input('WORKSPACE.SHELL.RAIL_DASHBOARD');
|
||||
readonly brandAriaLabelKey = input('WORKSPACE.SHELL.OPEN_DASHBOARD');
|
||||
readonly workspaceLinks = input<PortalRailLink[]>([]);
|
||||
readonly primaryContextLinks = input<PortalRailLink[]>([]);
|
||||
readonly secondaryContextLinks = input<PortalRailLink[]>([]);
|
||||
|
||||
+89
-6
@@ -5,6 +5,7 @@ import { NavigationEnd, Router } from '@angular/router';
|
||||
import { Store } from '@ngrx/store';
|
||||
import { TranslateService } from '@ngx-translate/core';
|
||||
import { of } from 'rxjs';
|
||||
import { PlaylistContextFacade } from '@iptvnator/playlist/shared/util';
|
||||
import { PORTAL_EXTERNAL_PLAYBACK } from '@iptvnator/portal/shared/util';
|
||||
import { StalkerStore } from '@iptvnator/portal/stalker/data-access';
|
||||
import { XtreamStore } from '@iptvnator/portal/xtream/data-access';
|
||||
@@ -13,13 +14,20 @@ import {
|
||||
SettingsStore,
|
||||
} from 'services';
|
||||
import { PlaylistMeta } from 'shared-interfaces';
|
||||
import { WORKSPACE_SHELL_ACTIONS } from '@iptvnator/workspace/shell/util';
|
||||
import {
|
||||
WorkspaceStartupPreferencesService,
|
||||
WORKSPACE_SHELL_ACTIONS,
|
||||
} from '@iptvnator/workspace/shell/util';
|
||||
import { WorkspaceShellFacade } from './workspace-shell.facade';
|
||||
|
||||
class MockXtreamStore {
|
||||
readonly recentItems = signal<unknown[]>([]);
|
||||
readonly searchTerm = signal('');
|
||||
readonly categorySearchTerm = signal('');
|
||||
readonly isImporting = signal(false);
|
||||
readonly activeImportSessionId = signal<string | null>(null);
|
||||
readonly currentImportPhase = signal<string | null>(null);
|
||||
readonly itemsToImport = signal(0);
|
||||
readonly getSelectedCategory = signal<{
|
||||
category_name?: string;
|
||||
name?: string;
|
||||
@@ -94,9 +102,16 @@ describe('WorkspaceShellFacade', () => {
|
||||
let activePlaylistSignal: ReturnType<typeof signal<PlaylistSignalMeta>>;
|
||||
let playlistsSignal: ReturnType<typeof signal<PlaylistSignalMeta[]>>;
|
||||
let stalkerStore: MockStalkerStore;
|
||||
let showDashboardSignal: ReturnType<typeof signal<boolean>>;
|
||||
let startupPreferences: {
|
||||
getFirstAvailableWorkspacePath: jest.Mock;
|
||||
persistLastRestorablePath: jest.Mock;
|
||||
showDashboard: jest.Mock;
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
window.electron = { platform: 'darwin' } as typeof window.electron;
|
||||
showDashboardSignal = signal(true);
|
||||
|
||||
activePlaylistSignal = signal({
|
||||
_id: 'pl-1',
|
||||
@@ -138,14 +153,17 @@ describe('WorkspaceShellFacade', () => {
|
||||
openGlobalRecent: jest.fn(),
|
||||
openAccountInfo: jest.fn(),
|
||||
};
|
||||
startupPreferences = {
|
||||
getFirstAvailableWorkspacePath: jest.fn((showDashboard: boolean) =>
|
||||
showDashboard ? '/workspace/dashboard' : '/workspace/sources'
|
||||
),
|
||||
persistLastRestorablePath: jest.fn(),
|
||||
showDashboard: jest.fn(() => showDashboardSignal()),
|
||||
};
|
||||
storeDispatch = jest.fn();
|
||||
stalkerStore = new MockStalkerStore();
|
||||
|
||||
const selectSignal = jest
|
||||
.fn()
|
||||
.mockReturnValueOnce(signal('Playlist A'))
|
||||
.mockReturnValueOnce(activePlaylistSignal)
|
||||
.mockReturnValueOnce(playlistsSignal);
|
||||
const selectSignal = jest.fn().mockReturnValue(playlistsSignal);
|
||||
|
||||
TestBed.configureTestingModule({
|
||||
providers: [
|
||||
@@ -177,6 +195,12 @@ describe('WorkspaceShellFacade', () => {
|
||||
closeSession: jest.fn(),
|
||||
},
|
||||
},
|
||||
{
|
||||
provide: PlaylistContextFacade,
|
||||
useValue: {
|
||||
activePlaylist: activePlaylistSignal,
|
||||
},
|
||||
},
|
||||
{
|
||||
provide: SettingsStore,
|
||||
useValue: {
|
||||
@@ -199,6 +223,10 @@ describe('WorkspaceShellFacade', () => {
|
||||
provide: WORKSPACE_SHELL_ACTIONS,
|
||||
useValue: workspaceActions,
|
||||
},
|
||||
{
|
||||
provide: WorkspaceStartupPreferencesService,
|
||||
useValue: startupPreferences,
|
||||
},
|
||||
{
|
||||
provide: TranslateService,
|
||||
useValue: {
|
||||
@@ -235,6 +263,42 @@ describe('WorkspaceShellFacade', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('uses a loading label for remote Xtream fetch phases', () => {
|
||||
const xtreamStore = TestBed.inject(XtreamStore) as unknown as MockXtreamStore;
|
||||
|
||||
xtreamStore.currentImportPhase.set('loading-categories');
|
||||
expect(facade.xtreamImportPhaseLabel()).toBe(
|
||||
'WORKSPACE.SHELL.XTREAM_IMPORT_LOADING'
|
||||
);
|
||||
|
||||
xtreamStore.currentImportPhase.set('loading-live');
|
||||
expect(facade.xtreamImportPhaseLabel()).toBe(
|
||||
'WORKSPACE.SHELL.XTREAM_IMPORT_LOADING'
|
||||
);
|
||||
expect(facade.xtreamImportSourceLabel()).toBe(
|
||||
'WORKSPACE.SHELL.XTREAM_IMPORT_REMOTE_BADGE'
|
||||
);
|
||||
expect(facade.xtreamImportDetailLabel()).toBe(
|
||||
'WORKSPACE.SHELL.XTREAM_IMPORT_DETAIL_REMOTE'
|
||||
);
|
||||
|
||||
xtreamStore.currentImportPhase.set('saving-categories');
|
||||
expect(facade.xtreamImportPhaseLabel()).toBe(
|
||||
'WORKSPACE.SHELL.XTREAM_IMPORT_SAVING'
|
||||
);
|
||||
|
||||
xtreamStore.currentImportPhase.set('saving-content');
|
||||
expect(facade.xtreamImportPhaseLabel()).toBe(
|
||||
'WORKSPACE.SHELL.XTREAM_IMPORT_SAVING'
|
||||
);
|
||||
expect(facade.xtreamImportSourceLabel()).toBe(
|
||||
'WORKSPACE.SHELL.XTREAM_IMPORT_LOCAL_BADGE'
|
||||
);
|
||||
expect(facade.xtreamImportDetailLabel()).toBe(
|
||||
'WORKSPACE.SHELL.XTREAM_IMPORT_DETAIL_LOCAL'
|
||||
);
|
||||
});
|
||||
|
||||
it('keeps stalker local-filter routes on the same page and syncs q', () => {
|
||||
router.navigate.mockClear();
|
||||
router.navigateByUrl.mockClear();
|
||||
@@ -326,6 +390,25 @@ describe('WorkspaceShellFacade', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('removes the dashboard rail link when dashboard is hidden', () => {
|
||||
showDashboardSignal.set(false);
|
||||
|
||||
expect(facade.workspaceLinks()).toEqual([
|
||||
{
|
||||
icon: 'library_books',
|
||||
tooltip: 'WORKSPACE.SHELL.RAIL_SOURCES',
|
||||
path: ['/workspace/sources'],
|
||||
},
|
||||
]);
|
||||
expect(facade.brandLink()).toBe('/workspace/sources');
|
||||
});
|
||||
|
||||
it('persists the last restorable route from navigation events', () => {
|
||||
expect(startupPreferences.persistLastRestorablePath).toHaveBeenCalledWith(
|
||||
'/workspace/xtreams/pl-1/vod'
|
||||
);
|
||||
});
|
||||
|
||||
it('uses the translated recent scope label on the global recent route', () => {
|
||||
facade.currentUrl.set('/workspace/global-recent?q=news');
|
||||
(facade as { syncSearchFromRoute: () => void }).syncSearchFromRoute();
|
||||
|
||||
+116
-11
@@ -13,7 +13,10 @@ import { Store } from '@ngrx/store';
|
||||
import { TranslateService } from '@ngx-translate/core';
|
||||
import { filter, firstValueFrom, startWith } from 'rxjs';
|
||||
import { PlaylistInfoComponent } from '@iptvnator/playlist/shared/ui';
|
||||
import { PlaylistContextFacade } from '@iptvnator/playlist/shared/util';
|
||||
import {
|
||||
PlaylistContextFacade,
|
||||
PlaylistRefreshActionService,
|
||||
} from '@iptvnator/playlist/shared/util';
|
||||
import {
|
||||
buildPortalRailLinks,
|
||||
PORTAL_EXTERNAL_PLAYBACK,
|
||||
@@ -29,6 +32,7 @@ import {
|
||||
WorkspacePortalContext,
|
||||
WorkspaceShellPageKind,
|
||||
WorkspaceSearchCapability,
|
||||
WorkspaceStartupPreferencesService,
|
||||
} from '@iptvnator/workspace/shell/util';
|
||||
import { PlaylistsService, SettingsStore } from 'services';
|
||||
import { PlaylistActions, selectAllPlaylistsMeta } from 'm3u-state';
|
||||
@@ -67,6 +71,8 @@ interface WorkspaceCommandDefinition {
|
||||
isEnabled: (state: WorkspaceCommandAvailability) => boolean;
|
||||
}
|
||||
|
||||
type XtreamImportPhaseTone = 'remote' | 'local' | null;
|
||||
|
||||
const SEARCH_INPUT_DEBOUNCE_MS = 350;
|
||||
const SEARCH_PLAYLIST_PLACEHOLDER =
|
||||
'WORKSPACE.SHELL.SEARCH_PLAYLIST_PLACEHOLDER';
|
||||
@@ -154,7 +160,13 @@ export class WorkspaceShellFacade {
|
||||
private readonly translate = inject(TranslateService);
|
||||
private readonly dialog = inject(MatDialog);
|
||||
private readonly playlistContext = inject(PlaylistContextFacade);
|
||||
private readonly startupPreferences = inject(
|
||||
WorkspaceStartupPreferencesService
|
||||
);
|
||||
readonly headerContext = inject(WorkspaceHeaderContextService);
|
||||
private readonly playlistRefreshAction = inject(
|
||||
PlaylistRefreshActionService
|
||||
);
|
||||
private readonly languageTick = toSignal(
|
||||
this.translate.onLangChange.pipe(startWith(null)),
|
||||
{ initialValue: null }
|
||||
@@ -197,24 +209,47 @@ export class WorkspaceShellFacade {
|
||||
readonly currentRoute = computed(() =>
|
||||
parseWorkspaceShellRoute(this.currentUrl())
|
||||
);
|
||||
readonly showDashboard = computed(() =>
|
||||
this.startupPreferences.showDashboard()
|
||||
);
|
||||
readonly brandLink = computed(() =>
|
||||
this.startupPreferences.getFirstAvailableWorkspacePath(
|
||||
this.showDashboard()
|
||||
)
|
||||
);
|
||||
readonly brandTooltipKey = computed(() =>
|
||||
this.showDashboard()
|
||||
? 'WORKSPACE.SHELL.RAIL_DASHBOARD'
|
||||
: 'WORKSPACE.SHELL.RAIL_SOURCES'
|
||||
);
|
||||
readonly brandAriaLabelKey = computed(() =>
|
||||
this.showDashboard()
|
||||
? 'WORKSPACE.SHELL.OPEN_DASHBOARD'
|
||||
: 'WORKSPACE.SHELL.OPEN_SOURCES'
|
||||
);
|
||||
readonly currentContext = computed(() => this.currentRoute().context);
|
||||
readonly currentSection = computed(() => this.currentRoute().section);
|
||||
readonly workspaceLinks = computed<PortalRailLink[]>(() => {
|
||||
this.languageTick();
|
||||
|
||||
return [
|
||||
{
|
||||
const links: PortalRailLink[] = [];
|
||||
|
||||
if (this.showDashboard()) {
|
||||
links.push({
|
||||
icon: 'dashboard',
|
||||
tooltip: this.translateText('WORKSPACE.SHELL.RAIL_DASHBOARD'),
|
||||
path: ['/workspace/dashboard'],
|
||||
exact: true,
|
||||
},
|
||||
{
|
||||
icon: 'library_books',
|
||||
tooltip: this.translateText('WORKSPACE.SHELL.RAIL_SOURCES'),
|
||||
path: ['/workspace/sources'],
|
||||
},
|
||||
];
|
||||
});
|
||||
}
|
||||
|
||||
links.push({
|
||||
icon: 'library_books',
|
||||
tooltip: this.translateText('WORKSPACE.SHELL.RAIL_SOURCES'),
|
||||
path: ['/workspace/sources'],
|
||||
});
|
||||
|
||||
return links;
|
||||
});
|
||||
readonly isDashboardRoute = computed(
|
||||
() => this.currentRoute().kind === 'dashboard'
|
||||
@@ -292,9 +327,38 @@ export class WorkspaceShellFacade {
|
||||
() =>
|
||||
this.isElectron &&
|
||||
this.xtreamStore.isImporting() &&
|
||||
this.xtreamStore.activeImportOperationIds().length > 0 &&
|
||||
Boolean(this.xtreamStore.activeImportSessionId()) &&
|
||||
!this.xtreamStore.isCancellingImport()
|
||||
);
|
||||
readonly xtreamImportTitleLabel = computed(() =>
|
||||
this.translateText('WORKSPACE.SHELL.XTREAM_IMPORT_TITLE')
|
||||
);
|
||||
readonly xtreamImportPhaseTone = computed<XtreamImportPhaseTone>(() => {
|
||||
switch (this.xtreamStore.currentImportPhase()) {
|
||||
case 'loading-categories':
|
||||
case 'loading-live':
|
||||
case 'loading-movies':
|
||||
case 'loading-series':
|
||||
return 'remote';
|
||||
case 'preparing-content':
|
||||
case 'saving-categories':
|
||||
case 'saving-content':
|
||||
case 'restoring-favorites':
|
||||
case 'restoring-recently-viewed':
|
||||
return 'local';
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
});
|
||||
readonly xtreamImportSourceLabel = computed(() => {
|
||||
this.languageTick();
|
||||
|
||||
return this.xtreamImportPhaseTone() === 'remote'
|
||||
? this.translateText('WORKSPACE.SHELL.XTREAM_IMPORT_REMOTE_BADGE')
|
||||
: this.xtreamImportPhaseTone() === 'local'
|
||||
? this.translateText('WORKSPACE.SHELL.XTREAM_IMPORT_LOCAL_BADGE')
|
||||
: '';
|
||||
});
|
||||
readonly xtreamImportPhaseLabel = computed(() => {
|
||||
this.languageTick();
|
||||
|
||||
@@ -303,6 +367,20 @@ export class WorkspaceShellFacade {
|
||||
return this.translateText(
|
||||
'WORKSPACE.SHELL.XTREAM_IMPORT_PREPARING'
|
||||
);
|
||||
case 'loading-categories':
|
||||
return this.translateText(
|
||||
'WORKSPACE.SHELL.XTREAM_IMPORT_LOADING'
|
||||
);
|
||||
case 'saving-categories':
|
||||
return this.translateText(
|
||||
'WORKSPACE.SHELL.XTREAM_IMPORT_SAVING'
|
||||
);
|
||||
case 'loading-live':
|
||||
case 'loading-movies':
|
||||
case 'loading-series':
|
||||
return this.translateText(
|
||||
'WORKSPACE.SHELL.XTREAM_IMPORT_LOADING'
|
||||
);
|
||||
case 'saving-content':
|
||||
return this.translateText(
|
||||
'WORKSPACE.SHELL.XTREAM_IMPORT_SAVING'
|
||||
@@ -319,6 +397,15 @@ export class WorkspaceShellFacade {
|
||||
return '';
|
||||
}
|
||||
});
|
||||
readonly xtreamImportDetailLabel = computed(() => {
|
||||
this.languageTick();
|
||||
|
||||
return this.xtreamImportPhaseTone() === 'remote'
|
||||
? this.translateText('WORKSPACE.SHELL.XTREAM_IMPORT_DETAIL_REMOTE')
|
||||
: this.xtreamImportPhaseTone() === 'local'
|
||||
? this.translateText('WORKSPACE.SHELL.XTREAM_IMPORT_DETAIL_LOCAL')
|
||||
: '';
|
||||
});
|
||||
readonly showXtreamImportOverlay = computed(() => {
|
||||
const context = this.currentContext();
|
||||
const section = this.currentSection();
|
||||
@@ -328,6 +415,7 @@ export class WorkspaceShellFacade {
|
||||
}
|
||||
|
||||
return (
|
||||
!this.xtreamStore.contentInitBlockReason() &&
|
||||
this.xtreamStore.isImporting() &&
|
||||
(section === 'vod' ||
|
||||
section === 'live' ||
|
||||
@@ -506,6 +594,10 @@ export class WorkspaceShellFacade {
|
||||
readonly canOpenAccountInfo = computed(() =>
|
||||
Boolean(this.activePlaylist()?.serverUrl)
|
||||
);
|
||||
readonly canRefreshPlaylist = computed(() =>
|
||||
this.playlistRefreshAction.canRefresh(this.activePlaylist())
|
||||
);
|
||||
readonly isRefreshingPlaylist = this.playlistRefreshAction.isRefreshing;
|
||||
readonly headerBulkAction = computed<WorkspaceHeaderBulkAction | null>(
|
||||
() => {
|
||||
this.languageTick();
|
||||
@@ -583,6 +675,9 @@ export class WorkspaceShellFacade {
|
||||
)
|
||||
.subscribe((event) => {
|
||||
this.currentUrl.set(event.urlAfterRedirects);
|
||||
this.startupPreferences.persistLastRestorablePath(
|
||||
event.urlAfterRedirects
|
||||
);
|
||||
this.syncSearchFromRoute();
|
||||
});
|
||||
|
||||
@@ -810,6 +905,16 @@ export class WorkspaceShellFacade {
|
||||
this.workspaceActions.openAccountInfo(data);
|
||||
}
|
||||
|
||||
refreshCurrentPlaylist(): void {
|
||||
const playlist = this.activePlaylist();
|
||||
|
||||
if (!playlist || !this.canRefreshPlaylist()) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.playlistRefreshAction.refresh(playlist);
|
||||
}
|
||||
|
||||
private getCommandPaletteItems(): WorkspaceCommandItem[] {
|
||||
const availability: WorkspaceCommandAvailability = {
|
||||
context: this.currentContext(),
|
||||
|
||||
+32
-3
@@ -2,9 +2,31 @@
|
||||
@if (facade.showXtreamImportOverlay()) {
|
||||
<div class="workspace-loading-overlay">
|
||||
<div class="workspace-loading-overlay__card">
|
||||
<h3>Loading playlist...</h3>
|
||||
<div class="workspace-loading-overlay__header">
|
||||
@if (facade.xtreamImportSourceLabel()) {
|
||||
<span
|
||||
class="workspace-loading-overlay__badge"
|
||||
[class.workspace-loading-overlay__badge--remote]="
|
||||
facade.xtreamImportPhaseTone() === 'remote'
|
||||
"
|
||||
>
|
||||
{{ facade.xtreamImportSourceLabel() }}
|
||||
</span>
|
||||
}
|
||||
<h3>{{ facade.xtreamImportTitleLabel() }}</h3>
|
||||
</div>
|
||||
@if (facade.xtreamImportPhaseLabel()) {
|
||||
<p>{{ facade.xtreamImportPhaseLabel() }}</p>
|
||||
<p
|
||||
class="workspace-loading-overlay__phase"
|
||||
aria-live="polite"
|
||||
>
|
||||
{{ facade.xtreamImportPhaseLabel() }}
|
||||
</p>
|
||||
}
|
||||
@if (facade.xtreamImportDetailLabel()) {
|
||||
<p class="workspace-loading-overlay__detail">
|
||||
{{ facade.xtreamImportDetailLabel() }}
|
||||
</p>
|
||||
}
|
||||
@if (
|
||||
facade.xtreamImportCount() !== 0 &&
|
||||
@@ -18,7 +40,7 @@
|
||||
100
|
||||
"
|
||||
/>
|
||||
<p>
|
||||
<p class="workspace-loading-overlay__progress-copy">
|
||||
{{ facade.xtreamImportCount() }} /
|
||||
{{ facade.xtreamItemsToImport() }}
|
||||
</p>
|
||||
@@ -28,6 +50,7 @@
|
||||
@if (facade.canCancelXtreamImport()) {
|
||||
<button
|
||||
mat-stroked-button
|
||||
class="workspace-loading-overlay__action"
|
||||
type="button"
|
||||
[disabled]="facade.isCancellingXtreamImport()"
|
||||
(click)="facade.cancelXtreamImport()"
|
||||
@@ -46,6 +69,9 @@
|
||||
|
||||
<app-workspace-shell-rail
|
||||
[isMacOS]="facade.isMacOS"
|
||||
[brandLink]="facade.brandLink()"
|
||||
[brandTooltipKey]="facade.brandTooltipKey()"
|
||||
[brandAriaLabelKey]="facade.brandAriaLabelKey()"
|
||||
[workspaceLinks]="facade.workspaceLinks()"
|
||||
[primaryContextLinks]="facade.primaryContextLinks()"
|
||||
[secondaryContextLinks]="facade.secondaryContextLinks()"
|
||||
@@ -68,8 +94,11 @@
|
||||
facade.isGlobalFavoritesRoute() || facade.isPortalFavoritesAllScope()
|
||||
"
|
||||
[headerShortcut]="facade.headerShortcut()"
|
||||
[canRefreshPlaylist]="facade.canRefreshPlaylist()"
|
||||
[isRefreshingPlaylist]="facade.isRefreshingPlaylist()"
|
||||
[isElectron]="facade.isElectron"
|
||||
[isDownloadsView]="facade.isDownloadsView()"
|
||||
(refreshPlaylistRequested)="facade.refreshCurrentPlaylist()"
|
||||
(searchChanged)="facade.onSearchInput($event)"
|
||||
(searchSubmitted)="facade.onSearchEnter($event)"
|
||||
(commandPaletteRequested)="facade.openCommandPalette()"
|
||||
|
||||
+105
-6
@@ -69,19 +69,118 @@ app-workspace-shell-context-sidebar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: rgba(0, 0, 0, 0.7);
|
||||
padding: 24px;
|
||||
background:
|
||||
radial-gradient(
|
||||
circle at top,
|
||||
rgba(99, 120, 255, 0.12),
|
||||
transparent 34%
|
||||
),
|
||||
rgba(8, 10, 16, 0.68);
|
||||
backdrop-filter: blur(14px);
|
||||
z-index: 1000;
|
||||
}
|
||||
|
||||
.workspace-loading-overlay__card {
|
||||
min-width: 300px;
|
||||
padding: 2rem;
|
||||
border-radius: 8px;
|
||||
background: var(--mat-sys-surface-container-high);
|
||||
width: min(440px, 100%);
|
||||
display: grid;
|
||||
gap: 14px;
|
||||
padding: 24px 24px 20px;
|
||||
border-radius: 24px;
|
||||
border: 1px solid var(--app-separator, rgba(255, 255, 255, 0.12));
|
||||
background:
|
||||
linear-gradient(
|
||||
180deg,
|
||||
rgba(255, 255, 255, 0.06),
|
||||
rgba(255, 255, 255, 0)
|
||||
),
|
||||
var(--mat-sys-surface-container-high);
|
||||
box-shadow:
|
||||
0 24px 64px rgba(0, 0, 0, 0.32),
|
||||
0 1px 0 rgba(255, 255, 255, 0.05) inset;
|
||||
text-align: center;
|
||||
animation: workspace-loading-overlay-enter 180ms ease-out;
|
||||
|
||||
h3 {
|
||||
margin-top: 0;
|
||||
margin: 0;
|
||||
font-size: 1.55rem;
|
||||
font-weight: 650;
|
||||
letter-spacing: -0.03em;
|
||||
color: var(--app-heading-color, var(--mat-sys-on-surface));
|
||||
}
|
||||
}
|
||||
|
||||
.workspace-loading-overlay__header {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
justify-items: center;
|
||||
}
|
||||
|
||||
.workspace-loading-overlay__badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 6px 12px;
|
||||
border-radius: 999px;
|
||||
border: 1px solid rgba(120, 220, 255, 0.24);
|
||||
background: rgba(120, 220, 255, 0.08);
|
||||
color: rgba(164, 233, 255, 1);
|
||||
font-size: 0.72rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.12em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.workspace-loading-overlay__badge--remote {
|
||||
border-color: rgba(105, 195, 255, 0.34);
|
||||
background: rgba(83, 158, 255, 0.12);
|
||||
color: rgba(202, 230, 255, 1);
|
||||
}
|
||||
|
||||
.workspace-loading-overlay__phase,
|
||||
.workspace-loading-overlay__detail,
|
||||
.workspace-loading-overlay__progress-copy {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.workspace-loading-overlay__phase {
|
||||
font-size: 1rem;
|
||||
font-weight: 560;
|
||||
color: var(--app-body-color, var(--mat-sys-on-surface));
|
||||
}
|
||||
|
||||
.workspace-loading-overlay__detail {
|
||||
max-width: 32ch;
|
||||
justify-self: center;
|
||||
color: var(--app-muted-color, var(--mat-sys-on-surface-variant));
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
.workspace-loading-overlay__progress-copy {
|
||||
font-variant-numeric: tabular-nums;
|
||||
color: var(--app-muted-color, var(--mat-sys-on-surface-variant));
|
||||
}
|
||||
|
||||
.workspace-loading-overlay__action {
|
||||
justify-self: center;
|
||||
min-width: 180px;
|
||||
border-radius: 999px;
|
||||
}
|
||||
|
||||
:host ::ng-deep .workspace-loading-overlay__card .mat-mdc-progress-bar {
|
||||
overflow: hidden;
|
||||
border-radius: 999px;
|
||||
}
|
||||
|
||||
@keyframes workspace-loading-overlay-enter {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(10px) scale(0.985);
|
||||
}
|
||||
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0) scale(1);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+24
@@ -23,6 +23,9 @@ import {
|
||||
})
|
||||
class MockWorkspaceShellRailComponent {
|
||||
readonly isMacOS = input(false);
|
||||
readonly brandLink = input('/workspace/dashboard');
|
||||
readonly brandTooltipKey = input('WORKSPACE.SHELL.RAIL_DASHBOARD');
|
||||
readonly brandAriaLabelKey = input('WORKSPACE.SHELL.OPEN_DASHBOARD');
|
||||
readonly workspaceLinks = input<unknown[]>([]);
|
||||
readonly primaryContextLinks = input<unknown[]>([]);
|
||||
readonly secondaryContextLinks = input<unknown[]>([]);
|
||||
@@ -85,6 +88,9 @@ class MockExternalPlaybackDockComponent {
|
||||
}
|
||||
|
||||
class MockWorkspaceShellFacade {
|
||||
readonly brandLink = signal('/workspace/dashboard');
|
||||
readonly brandTooltipKey = signal('WORKSPACE.SHELL.RAIL_DASHBOARD');
|
||||
readonly brandAriaLabelKey = signal('WORKSPACE.SHELL.OPEN_DASHBOARD');
|
||||
readonly workspaceLinks = signal([]);
|
||||
readonly primaryContextLinks = signal([]);
|
||||
readonly secondaryContextLinks = signal([]);
|
||||
@@ -115,6 +121,23 @@ class MockWorkspaceShellFacade {
|
||||
readonly showXtreamImportOverlay = signal(false);
|
||||
readonly xtreamImportCount = signal(0);
|
||||
readonly xtreamItemsToImport = signal(0);
|
||||
readonly xtreamImportTitleLabel = signal(
|
||||
'WORKSPACE.SHELL.XTREAM_IMPORT_TITLE'
|
||||
);
|
||||
readonly xtreamImportSourceLabel = signal(
|
||||
'WORKSPACE.SHELL.XTREAM_IMPORT_REMOTE_BADGE'
|
||||
);
|
||||
readonly xtreamImportPhaseLabel = signal(
|
||||
'WORKSPACE.SHELL.XTREAM_IMPORT_LOADING'
|
||||
);
|
||||
readonly xtreamImportDetailLabel = signal(
|
||||
'WORKSPACE.SHELL.XTREAM_IMPORT_DETAIL_REMOTE'
|
||||
);
|
||||
readonly xtreamImportPhaseTone = signal<'remote' | 'local' | null>(
|
||||
'remote'
|
||||
);
|
||||
readonly canCancelXtreamImport = signal(false);
|
||||
readonly isCancellingXtreamImport = signal(false);
|
||||
readonly isMacOS = true;
|
||||
readonly isElectron = true;
|
||||
|
||||
@@ -129,6 +152,7 @@ class MockWorkspaceShellFacade {
|
||||
openPlaylistInfo = jest.fn();
|
||||
openAccountInfo = jest.fn();
|
||||
closeActiveExternalSession = jest.fn();
|
||||
cancelXtreamImport = jest.fn();
|
||||
}
|
||||
|
||||
describe('WorkspaceShellComponent', () => {
|
||||
|
||||
@@ -2,3 +2,4 @@ export * from './lib/settings-context.service';
|
||||
export * from './lib/navigation/workspace-shell-route.utils';
|
||||
export * from './lib/navigation/workspace-search-capability';
|
||||
export * from './lib/workspace-shell-actions';
|
||||
export * from './lib/workspace-startup-preferences.service';
|
||||
@@ -0,0 +1,104 @@
|
||||
import { signal } from '@angular/core';
|
||||
import { TestBed } from '@angular/core/testing';
|
||||
import { of } from 'rxjs';
|
||||
import { PlaylistsService, SettingsStore } from 'services';
|
||||
import { StartupBehavior } from 'shared-interfaces';
|
||||
import { WorkspaceStartupPreferencesService } from './workspace-startup-preferences.service';
|
||||
|
||||
describe('WorkspaceStartupPreferencesService', () => {
|
||||
let service: WorkspaceStartupPreferencesService;
|
||||
let playlistsService: { getAllPlaylists: jest.Mock };
|
||||
let settingsStore: {
|
||||
loadSettings: jest.Mock;
|
||||
showDashboard: ReturnType<typeof signal<boolean>>;
|
||||
startupBehavior: ReturnType<typeof signal<StartupBehavior>>;
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
localStorage.clear();
|
||||
|
||||
playlistsService = {
|
||||
getAllPlaylists: jest.fn().mockReturnValue(
|
||||
of([{ _id: 'playlist-1' }])
|
||||
),
|
||||
};
|
||||
settingsStore = {
|
||||
loadSettings: jest.fn().mockResolvedValue(undefined),
|
||||
showDashboard: signal(true),
|
||||
startupBehavior: signal(StartupBehavior.FirstView),
|
||||
};
|
||||
|
||||
TestBed.configureTestingModule({
|
||||
providers: [
|
||||
WorkspaceStartupPreferencesService,
|
||||
{
|
||||
provide: PlaylistsService,
|
||||
useValue: playlistsService,
|
||||
},
|
||||
{
|
||||
provide: SettingsStore,
|
||||
useValue: settingsStore,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
service = TestBed.inject(WorkspaceStartupPreferencesService);
|
||||
});
|
||||
|
||||
it('resolves the first view to dashboard when dashboard is enabled', async () => {
|
||||
await expect(service.resolveInitialWorkspacePath()).resolves.toBe(
|
||||
'/workspace/dashboard'
|
||||
);
|
||||
});
|
||||
|
||||
it('resolves the first view to sources when dashboard is hidden', async () => {
|
||||
settingsStore.showDashboard.set(false);
|
||||
|
||||
await expect(service.resolveInitialWorkspacePath()).resolves.toBe(
|
||||
'/workspace/sources'
|
||||
);
|
||||
});
|
||||
|
||||
it('restores the last route when restore-last-view is enabled', async () => {
|
||||
settingsStore.startupBehavior.set(StartupBehavior.RestoreLastView);
|
||||
service.persistLastRestorablePath('/workspace/global-recent?q=matrix');
|
||||
|
||||
await expect(service.resolveInitialWorkspacePath()).resolves.toBe(
|
||||
'/workspace/global-recent'
|
||||
);
|
||||
});
|
||||
|
||||
it('falls back to sources when the stored dashboard route is hidden', async () => {
|
||||
settingsStore.showDashboard.set(false);
|
||||
settingsStore.startupBehavior.set(StartupBehavior.RestoreLastView);
|
||||
service.persistLastRestorablePath('/workspace/dashboard');
|
||||
|
||||
await expect(service.resolveInitialWorkspacePath()).resolves.toBe(
|
||||
'/workspace/sources'
|
||||
);
|
||||
});
|
||||
|
||||
it('canonicalizes detail routes to their section root', () => {
|
||||
expect(
|
||||
service.getRestorablePath(
|
||||
'/workspace/xtreams/playlist-1/vod/123/456?q=matrix'
|
||||
)
|
||||
).toBe('/workspace/xtreams/playlist-1/vod');
|
||||
});
|
||||
|
||||
it('ignores non-restorable routes', () => {
|
||||
expect(service.getRestorablePath('/workspace/settings')).toBeNull();
|
||||
expect(service.getRestorablePath('/workspace')).toBeNull();
|
||||
expect(service.getRestorablePath('/unknown')).toBeNull();
|
||||
});
|
||||
|
||||
it('falls back to the first available view when the stored playlist no longer exists', async () => {
|
||||
settingsStore.startupBehavior.set(StartupBehavior.RestoreLastView);
|
||||
playlistsService.getAllPlaylists.mockReturnValue(of([]));
|
||||
service.persistLastRestorablePath('/workspace/xtreams/missing/live');
|
||||
|
||||
await expect(service.resolveInitialWorkspacePath()).resolves.toBe(
|
||||
'/workspace/dashboard'
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,153 @@
|
||||
import { Injectable, inject } from '@angular/core';
|
||||
import { firstValueFrom } from 'rxjs';
|
||||
import { PlaylistsService, SettingsStore } from 'services';
|
||||
import {
|
||||
StartupBehavior,
|
||||
} from 'shared-interfaces';
|
||||
import { parseWorkspaceShellRoute } from './navigation/workspace-shell-route.utils';
|
||||
|
||||
const LAST_RESTORABLE_ROUTE_STORAGE_KEY = 'workspace-last-restorable-route-v1';
|
||||
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class WorkspaceStartupPreferencesService {
|
||||
private readonly settingsStore = inject(SettingsStore);
|
||||
private readonly playlistsService = inject(PlaylistsService);
|
||||
|
||||
async resolveInitialWorkspacePath(): Promise<string> {
|
||||
await this.settingsStore.loadSettings();
|
||||
|
||||
const showDashboard = this.showDashboard();
|
||||
const firstViewPath = this.getFirstAvailableWorkspacePath(showDashboard);
|
||||
|
||||
if (this.startupBehavior() !== StartupBehavior.RestoreLastView) {
|
||||
return firstViewPath;
|
||||
}
|
||||
|
||||
return (
|
||||
(await this.getValidatedLastRestorablePath(showDashboard)) ??
|
||||
firstViewPath
|
||||
);
|
||||
}
|
||||
|
||||
async resolveDashboardPath(): Promise<string> {
|
||||
await this.settingsStore.loadSettings();
|
||||
|
||||
return this.showDashboard()
|
||||
? '/workspace/dashboard'
|
||||
: '/workspace/sources';
|
||||
}
|
||||
|
||||
getFirstAvailableWorkspacePath(showDashboard = this.showDashboard()): string {
|
||||
return showDashboard ? '/workspace/dashboard' : '/workspace/sources';
|
||||
}
|
||||
|
||||
showDashboard(): boolean {
|
||||
return this.settingsStore.showDashboard?.() ?? true;
|
||||
}
|
||||
|
||||
startupBehavior(): StartupBehavior {
|
||||
return (
|
||||
this.settingsStore.startupBehavior?.() ??
|
||||
StartupBehavior.FirstView
|
||||
);
|
||||
}
|
||||
|
||||
persistLastRestorablePath(url: string): void {
|
||||
const canonicalPath = this.getRestorablePath(url);
|
||||
if (!canonicalPath) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
localStorage.setItem(
|
||||
LAST_RESTORABLE_ROUTE_STORAGE_KEY,
|
||||
canonicalPath
|
||||
);
|
||||
} catch {
|
||||
// Ignore storage write failures.
|
||||
}
|
||||
}
|
||||
|
||||
getRestorablePath(url: string): string | null {
|
||||
const [path] = url.split('?');
|
||||
if (path === '/workspace' || path === '/workspace/') {
|
||||
return null;
|
||||
}
|
||||
|
||||
const route = parseWorkspaceShellRoute(url);
|
||||
|
||||
switch (route.kind) {
|
||||
case 'dashboard':
|
||||
return '/workspace/dashboard';
|
||||
case 'downloads':
|
||||
return '/workspace/downloads';
|
||||
case 'global-favorites':
|
||||
return '/workspace/global-favorites';
|
||||
case 'global-recent':
|
||||
return '/workspace/global-recent';
|
||||
case 'sources':
|
||||
return '/workspace/sources';
|
||||
case 'portal':
|
||||
if (!route.context || !route.section) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return [
|
||||
'/workspace',
|
||||
route.context.provider,
|
||||
route.context.playlistId,
|
||||
route.section,
|
||||
].join('/');
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async getValidatedLastRestorablePath(
|
||||
showDashboard = this.showDashboard()
|
||||
): Promise<string | null> {
|
||||
const storedPath = this.readLastRestorablePath();
|
||||
if (!storedPath) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const canonicalPath = this.getRestorablePath(storedPath);
|
||||
if (!canonicalPath) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!showDashboard && canonicalPath === '/workspace/dashboard') {
|
||||
return this.getFirstAvailableWorkspacePath(false);
|
||||
}
|
||||
|
||||
const route = parseWorkspaceShellRoute(canonicalPath);
|
||||
if (route.kind !== 'portal' || !route.context) {
|
||||
return canonicalPath;
|
||||
}
|
||||
|
||||
try {
|
||||
const playlists = await firstValueFrom(
|
||||
this.playlistsService.getAllPlaylists()
|
||||
);
|
||||
|
||||
return playlists.some(
|
||||
(playlist) => playlist._id === route.context?.playlistId
|
||||
)
|
||||
? canonicalPath
|
||||
: this.getFirstAvailableWorkspacePath(showDashboard);
|
||||
} catch {
|
||||
return this.getFirstAvailableWorkspacePath(showDashboard);
|
||||
}
|
||||
}
|
||||
|
||||
private readLastRestorablePath(): string | null {
|
||||
try {
|
||||
const value = localStorage.getItem(
|
||||
LAST_RESTORABLE_ROUTE_STORAGE_KEY
|
||||
);
|
||||
return value && value.trim().length > 0 ? value : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user