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:
4gray committed 2026-04-04 10:10:52 +02:00
1 parent 26fa697afe
commit eaa3e0e2bd
37 files changed
+1178 -103

No files matched your search

+1
View File
@@ -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;
}
}
}