refactor: rework dashboard view

- Removed global favorites related properties and methods from WorkspaceShellHeaderComponent.
- Updated WorkspaceShellFacade to streamline command palette commands and navigation.
- Refactored command palette to include type-aware xtream import progress labels.
- Enhanced WorkspaceShellComponent to display import progress with improved styling.
- Updated tests to reflect changes in command palette and import progress handling.
This commit is contained in:
4gray committed 2026-04-22 22:19:42 +02:00
1 parent fc3db71e85
commit 0e77f7b98a
69 files changed
+2603 -4939

No files matched your search

@@ -1,3 +1 @@
export * from './lib/dashboard-widget.model';
export * from './lib/dashboard-layout.service';
export * from './lib/dashboard-data.service';
@@ -12,7 +12,7 @@ describe('DashboardDataService', () => {
let service: DashboardDataService;
const playlistsLoadedSignal = signal(true);
const playlistsSignal = signal<PlaylistMeta[]>([
const createDefaultPlaylists = (): PlaylistMeta[] => [
{
_id: 'm3u-1',
title: 'M3U Playlist',
@@ -44,7 +44,9 @@ describe('DashboardDataService', () => {
autoRefresh: false,
serverUrl: 'https://example.com',
},
]);
];
const playlistsSignal = signal<PlaylistMeta[]>(createDefaultPlaylists());
const storeMock = {
selectSignal: jest.fn((selector: unknown) => {
@@ -116,6 +118,7 @@ describe('DashboardDataService', () => {
configurable: true,
});
playlistsLoadedSignal.set(true);
playlistsSignal.set(createDefaultPlaylists());
playlistsServiceMock.getPlaylistById.mockClear();
playlistsServiceMock.getPlaylistById.mockReturnValue(of(playlistMock));
playlistsServiceMock.setFavorites.mockClear();
@@ -164,6 +167,47 @@ describe('DashboardDataService', () => {
jest.clearAllMocks();
});
it('does not eagerly load dashboard datasets on construction', () => {
expect(dbServiceMock.getGlobalRecentlyViewed).not.toHaveBeenCalled();
expect(dbServiceMock.getAllGlobalFavorites).not.toHaveBeenCalled();
expect(dbServiceMock.getGlobalRecentlyAdded).not.toHaveBeenCalled();
expect(playlistsServiceMock.getPlaylistById).not.toHaveBeenCalled();
expect(service.dashboardReady()).toBe(false);
});
it('keeps dashboardReady false until xtream recently added finishes its first load', async () => {
expect(service.dashboardReady()).toBe(false);
await service.reloadGlobalRecentItems();
expect(service.dashboardReady()).toBe(false);
await service.reloadGlobalFavorites();
expect(service.dashboardReady()).toBe(false);
await service.reloadXtreamRecentlyAddedItems();
expect(service.dashboardReady()).toBe(true);
});
it('does not wait on xtream recently added when no xtream playlists exist', async () => {
playlistsSignal.set([
{
_id: 'm3u-1',
title: 'M3U Playlist',
count: 1,
importDate: '2026-01-01T00:00:00.000Z',
autoRefresh: false,
favorites: [],
recentlyViewed: [],
},
]);
await service.reloadGlobalRecentItems();
expect(service.dashboardReady()).toBe(false);
await service.reloadGlobalFavorites();
expect(service.dashboardReady()).toBe(true);
});
it('includes M3U favorites in global favorite items', async () => {
await service.reloadGlobalFavorites();
@@ -301,7 +345,7 @@ describe('DashboardDataService', () => {
expect(service.getGlobalFavoriteNavigationState(movieItem!)).toEqual({
openCollectionDetailItem: {
item: expect.objectContaining({
uid: 'xtream::xtream-1::5001',
uid: 'xtream::xtream-1::movie:5001',
name: 'Action Movie',
contentType: 'movie',
sourceType: 'xtream',
@@ -342,6 +386,154 @@ describe('DashboardDataService', () => {
);
});
it('prioritizes recently used Xtream sources over newer imported M3U sources', async () => {
playlistsSignal.set([
{
_id: 'm3u-fresh',
title: 'Fresh Import',
count: 1,
importDate: '2026-04-20T12:00:00.000Z',
autoRefresh: false,
favorites: [],
recentlyViewed: [],
},
{
_id: 'xtream-1',
title: 'Xtream Playlist',
count: 1,
importDate: '2026-01-01T00:00:00.000Z',
autoRefresh: false,
serverUrl: 'https://example.com',
},
]);
dbServiceMock.getGlobalRecentlyViewed.mockResolvedValue([
{
id: 91,
category_id: 18,
title: 'Recent Movie',
rating: '7.8',
viewed_at: '2026-04-21T10:00:00.000Z',
poster_url: 'https://example.com/recent-movie.png',
xtream_id: 7001,
type: 'movie',
playlist_id: 'xtream-1',
playlist_name: 'Xtream Playlist',
},
]);
await service.reloadGlobalRecentItems();
expect(service.recentPlaylists().map((playlist) => playlist._id)).toEqual(
['xtream-1', 'm3u-fresh']
);
});
it('includes M3U, Xtream, and Stalker sources when all have recent activity', async () => {
playlistsSignal.set([
{
_id: 'm3u-1',
title: 'M3U Playlist',
count: 1,
importDate: '2026-01-01T00:00:00.000Z',
autoRefresh: false,
favorites: [],
recentlyViewed: [
{
source: 'm3u',
id: 'https://example.com/stream-1.m3u8',
url: 'https://example.com/stream-1.m3u8',
title: 'Channel One',
category_id: 'live',
added_at: '2026-04-19T10:00:00.000Z',
},
],
},
{
_id: 'stalker-1',
title: 'Stalker Playlist',
count: 1,
importDate: '2026-01-01T00:00:00.000Z',
autoRefresh: false,
macAddress: '00:11:22:33:44:55',
recentlyViewed: [
{
id: 'stalker-item-1',
name: 'Stalker Movie',
category_id: 'vod',
added_at: '2026-04-20T10:00:00.000Z',
},
],
},
{
_id: 'xtream-1',
title: 'Xtream Playlist',
count: 1,
importDate: '2026-01-01T00:00:00.000Z',
autoRefresh: false,
serverUrl: 'https://example.com',
},
]);
dbServiceMock.getGlobalRecentlyViewed.mockResolvedValue([
{
id: 91,
category_id: 18,
title: 'Recent Movie',
rating: '7.8',
viewed_at: '2026-04-21T10:00:00.000Z',
poster_url: 'https://example.com/recent-movie.png',
xtream_id: 7001,
type: 'movie',
playlist_id: 'xtream-1',
playlist_name: 'Xtream Playlist',
},
]);
await service.reloadGlobalRecentItems();
expect(service.recentPlaylists().map((playlist) => playlist._id)).toEqual(
['xtream-1', 'stalker-1', 'm3u-1']
);
});
it('falls back to playlist metadata ordering when sources have no recent activity', async () => {
playlistsSignal.set([
{
_id: 'xtream-1',
title: 'Xtream Playlist',
count: 1,
importDate: '2026-01-01T00:00:00.000Z',
autoRefresh: false,
serverUrl: 'https://example.com',
},
{
_id: 'stalker-1',
title: 'Stalker Playlist',
count: 1,
importDate: '2026-03-01T00:00:00.000Z',
autoRefresh: false,
macAddress: '00:11:22:33:44:55',
recentlyViewed: [],
},
{
_id: 'm3u-1',
title: 'M3U Playlist',
count: 1,
importDate: '2026-02-01T00:00:00.000Z',
updateDate: Date.parse('2026-04-01T00:00:00.000Z'),
autoRefresh: false,
favorites: [],
recentlyViewed: [],
},
]);
dbServiceMock.getGlobalRecentlyViewed.mockResolvedValue([]);
await service.reloadGlobalRecentItems();
expect(service.recentPlaylists().map((playlist) => playlist._id)).toEqual(
['m3u-1', 'stalker-1', 'xtream-1']
);
});
it('maps recently added Xtream rows for the widget', async () => {
dbServiceMock.getGlobalRecentlyAdded.mockResolvedValue([
{
@@ -435,6 +627,7 @@ describe('DashboardDataService', () => {
rating: '7.8',
viewed_at: '2026-02-04T10:00:00.000Z',
poster_url: 'https://example.com/recent-movie.png',
backdrop_url: 'https://example.com/recent-movie-backdrop.png',
xtream_id: 7001,
type: 'movie',
playlist_id: 'xtream-1',
@@ -447,7 +640,11 @@ describe('DashboardDataService', () => {
.globalRecentItems()
.find((item) => item.source === 'xtream');
expect(movieItem).toBeDefined();
expect(movieItem).toEqual(
expect.objectContaining({
backdrop_url: 'https://example.com/recent-movie-backdrop.png',
})
);
expect(service.getRecentItemLink(movieItem!)).toEqual([
'/workspace',
'global-recent',
@@ -455,7 +652,7 @@ describe('DashboardDataService', () => {
expect(service.getRecentItemNavigationState(movieItem!)).toEqual({
openCollectionDetailItem: {
item: expect.objectContaining({
uid: 'xtream::xtream-1::7001',
uid: 'xtream::xtream-1::movie:7001',
name: 'Recent Movie',
contentType: 'movie',
sourceType: 'xtream',
@@ -465,6 +662,34 @@ describe('DashboardDataService', () => {
});
});
it('normalizes SQLite-style Xtream recent timestamps before sorting and rendering', async () => {
dbServiceMock.getGlobalRecentlyViewed.mockResolvedValue([
{
id: 91,
category_id: 18,
title: 'Recent Movie',
rating: '7.8',
viewed_at: '2026-02-04 10:00:00',
poster_url: 'https://example.com/recent-movie.png',
xtream_id: 7001,
type: 'movie',
playlist_id: 'xtream-1',
playlist_name: 'Xtream Playlist',
},
]);
await service.reloadGlobalRecentItems();
const movieItem = service
.globalRecentItems()
.find((item) => item.source === 'xtream');
expect(movieItem).toEqual(
expect.objectContaining({
viewed_at: '2026-02-04T10:00:00.000Z',
})
);
});
it('removes M3U recently viewed via PlaylistsService', async () => {
const m3uItem = service
.globalRecentItems()
@@ -67,12 +67,16 @@ export class DashboardDataService {
private readonly playlistsService = inject(PlaylistsService);
private readonly ngZone = inject(NgZone);
private readonly translate = inject(TranslateService);
private favoritesAutoRefreshEnabled = false;
private readonly languageTick = toSignal(
this.translate.onLangChange.pipe(startWith(null)),
{ initialValue: null }
);
private readonly xtreamGlobalRecentItems = signal<GlobalRecentItem[]>([]);
private readonly xtreamRecentlyAddedItemsState = signal<
DashboardRecentlyAddedItem[]
>([]);
private readonly xtreamGlobalFavorites = signal<DashboardFavoriteItem[]>(
[]
);
@@ -82,16 +86,35 @@ export class DashboardDataService {
private readonly globalRecentDbLoadedState = signal(!window.electron);
private readonly globalFavoritesLoadingState = signal(true);
private readonly globalFavoritesLoadedState = signal(false);
private readonly xtreamRecentlyAddedLoadingState = signal(true);
private readonly xtreamRecentlyAddedLoadedState = signal(false);
readonly playlists = this.store.selectSignal(selectAllPlaylistsMeta);
readonly playlistsLoaded = this.store.selectSignal(
selectPlaylistsLoadingFlag
);
readonly xtreamPlaylistCount = computed(
() => this.playlists().filter((playlist) => !!playlist.serverUrl).length
);
readonly hasXtreamPlaylists = computed(() => this.xtreamPlaylistCount() > 0);
readonly globalRecentLoading = this.globalRecentLoadingState.asReadonly();
readonly globalRecentLoaded = this.globalRecentLoadedState.asReadonly();
readonly globalFavoritesLoading =
this.globalFavoritesLoadingState.asReadonly();
readonly globalFavoritesLoaded =
this.globalFavoritesLoadedState.asReadonly();
readonly xtreamRecentlyAddedLoading =
this.xtreamRecentlyAddedLoadingState.asReadonly();
readonly xtreamRecentlyAddedLoaded =
this.xtreamRecentlyAddedLoadedState.asReadonly();
readonly xtreamRecentlyAddedItems =
this.xtreamRecentlyAddedItemsState.asReadonly();
readonly dashboardReady = computed(
() =>
this.playlistsLoaded() &&
this.globalRecentLoaded() &&
this.globalFavoritesLoaded() &&
(!this.hasXtreamPlaylists() || this.xtreamRecentlyAddedLoaded())
);
private readonly playlistFavoritesReloadKey = computed(() => {
if (!this.playlistsLoaded()) {
@@ -155,12 +178,25 @@ export class DashboardDataService {
.slice(0, 200)
);
constructor() {
void this.reloadGlobalFavorites();
private readonly recentPlaylistActivityTimestamps = computed(() => {
const timestamps = new Map<string, number>();
for (const item of this.globalRecentItems()) {
const viewedAt = toDateTimestamp(item.viewed_at);
const current = timestamps.get(item.playlist_id) ?? 0;
if (viewedAt > current) {
timestamps.set(item.playlist_id, viewedAt);
}
}
return timestamps;
});
constructor() {
effect(() => {
this.playlistFavoritesReloadKey();
if (!this.playlistsLoaded()) {
if (!this.playlistsLoaded() || !this.favoritesAutoRefreshEnabled) {
return;
}
@@ -170,11 +206,20 @@ export class DashboardDataService {
effect(() => {
this.playlistsLoaded();
this.finishInitialGlobalRecentLoadIfReady();
this.finishInitialGlobalFavoritesLoadIfReady();
});
if (window.electron) {
void this.reloadGlobalRecentItems();
}
effect(() => {
if (this.hasXtreamPlaylists()) {
return;
}
this.ngZone.run(() => {
this.xtreamRecentlyAddedItemsState.set([]);
this.xtreamRecentlyAddedLoadingState.set(false);
this.xtreamRecentlyAddedLoadedState.set(true);
});
});
}
readonly stats = computed(() => {
@@ -190,10 +235,22 @@ export class DashboardDataService {
readonly recentPlaylists = computed(() =>
[...this.playlists()]
.sort(
(a, b) =>
this.getRecentTimestamp(b) - this.getRecentTimestamp(a)
)
.sort((a, b) => {
const activityDelta =
this.getPlaylistActivityTimestamp(b) -
this.getPlaylistActivityTimestamp(a);
if (activityDelta !== 0) {
return activityDelta;
}
const metadataDelta =
this.getRecentTimestamp(b) - this.getRecentTimestamp(a);
if (metadataDelta !== 0) {
return metadataDelta;
}
return a._id.localeCompare(b._id);
})
.slice(0, 12)
);
@@ -238,6 +295,7 @@ export class DashboardDataService {
const m3uReload = this.refreshPlaylistBackedGlobalFavorites();
await Promise.all([xtreamReload, m3uReload]);
this.favoritesAutoRefreshEnabled = true;
}
async getGlobalRecentlyAddedItems(
@@ -254,6 +312,56 @@ export class DashboardDataService {
.sort((a, b) => toTimestamp(b.added_at) - toTimestamp(a.added_at));
}
async getXtreamRecentlyAddedItems(
limit = 20
): Promise<DashboardRecentlyAddedItem[]> {
if (!window.electron) {
return [];
}
const items = await this.dbService.getGlobalRecentlyAdded(
'all',
limit,
'xtream'
);
return items
.map((item) => mapDbRecentlyAddedToItem(item))
.sort((a, b) => toTimestamp(b.added_at) - toTimestamp(a.added_at));
}
async reloadXtreamRecentlyAddedItems(limit = 20): Promise<void> {
if (!this.xtreamRecentlyAddedLoaded()) {
this.xtreamRecentlyAddedLoadingState.set(true);
}
if (!window.electron) {
this.ngZone.run(() => {
this.xtreamRecentlyAddedItemsState.set([]);
this.xtreamRecentlyAddedLoadingState.set(false);
this.xtreamRecentlyAddedLoadedState.set(true);
});
return;
}
try {
const items = await this.getXtreamRecentlyAddedItems(limit);
this.ngZone.run(() =>
this.xtreamRecentlyAddedItemsState.set(items)
);
} catch (err) {
console.warn(
'[DashboardData] Failed to reload Xtream recently added items',
err
);
this.ngZone.run(() => this.xtreamRecentlyAddedItemsState.set([]));
} finally {
this.ngZone.run(() => {
this.xtreamRecentlyAddedLoadingState.set(false);
this.xtreamRecentlyAddedLoadedState.set(true);
});
}
}
private async reloadXtreamGlobalFavorites(): Promise<void> {
if (!window.electron) {
this.xtreamGlobalFavorites.set([]);
@@ -706,6 +814,10 @@ export class DashboardDataService {
: null;
}
private getPlaylistActivityTimestamp(item: PlaylistMeta): number {
return this.recentPlaylistActivityTimestamps().get(item._id) ?? 0;
}
private getRecentTimestamp(item: PlaylistMeta): number {
return toTimestamp(item.updateDate) || toTimestamp(item.importDate);
}
@@ -1,47 +0,0 @@
import { TestBed } from '@angular/core/testing';
import { DashboardLayoutService } from './dashboard-layout.service';
const STORAGE_KEY = 'workspace-dashboard-layout-v3';
describe('DashboardLayoutService', () => {
let service: DashboardLayoutService;
beforeEach(() => {
localStorage.removeItem(STORAGE_KEY);
TestBed.configureTestingModule({
providers: [DashboardLayoutService],
});
service = TestBed.inject(DashboardLayoutService);
});
afterEach(() => {
localStorage.removeItem(STORAGE_KEY);
});
it('persists layout changes to localStorage', () => {
service.toggleWidget('source-stats');
TestBed.flushEffects();
const stored = localStorage.getItem(STORAGE_KEY);
expect(stored).toBeTruthy();
const parsed = JSON.parse(stored ?? '{}') as {
widgets?: Array<{ id: string; enabled: boolean }>;
};
const sourceStats = parsed.widgets?.find((widget) => widget.id === 'source-stats');
expect(sourceStats?.enabled).toBe(true);
});
it('adds the recently added widget disabled by default during normalization', () => {
const widget = service.getWidget('recently-added');
expect(widget).toEqual(
expect.objectContaining({
type: 'recently-added',
enabled: false,
size: 'half',
})
);
expect((widget as { settings?: unknown } | undefined)?.settings).toBeUndefined();
});
});
@@ -1,288 +0,0 @@
import { effect, Injectable, signal } from '@angular/core';
import {
DASHBOARD_LAYOUT_VERSION,
DashboardLayoutState,
DashboardWidgetConfig,
DashboardWidgetSize,
DASHBOARD_WIDGET_SIZE_OPTIONS,
DEFAULT_DASHBOARD_WIDGETS,
} from './dashboard-widget.model';
const DASHBOARD_LAYOUT_STORAGE_KEY = 'workspace-dashboard-layout-v3';
@Injectable({ providedIn: 'root' })
export class DashboardLayoutService {
readonly state = signal<DashboardLayoutState>(this.loadState());
constructor() {
effect(() => {
this.persistState(this.state());
});
}
toggleWidget(widgetId: string): void {
this.updateWidget(widgetId, (widget) => ({
...widget,
enabled: !widget.enabled,
}));
}
getWidget(widgetId: string): DashboardWidgetConfig | undefined {
return this.state().widgets.find((widget) => widget.id === widgetId);
}
setWidgetSize(widgetId: string, size: DashboardWidgetSize): void {
this.updateWidget(widgetId, (widget) => ({
...widget,
size: this.normalizeSize(size),
}));
}
reorderVisibleWidgets(orderedVisibleWidgetIds: string[]): void {
const orderedIds = orderedVisibleWidgetIds.filter(Boolean);
if (orderedIds.length === 0) {
return;
}
const widgetsById = new Map(
this.state().widgets.map((widget) => [widget.id, widget])
);
const visibleWidgets = orderedIds
.map((id) => widgetsById.get(id))
.filter((widget): widget is DashboardWidgetConfig => !!widget?.enabled);
if (visibleWidgets.length !== orderedIds.length) {
return;
}
const disabledWidgets = this.getSortedWidgets(this.state().widgets).filter(
(widget) => !widget.enabled
);
const reordered = [...visibleWidgets, ...disabledWidgets].map(
(widget, order) => ({
...widget,
order,
})
);
this.state.update((state) => ({
...state,
widgets: reordered,
}));
}
moveWidgetUp(widgetId: string): void {
const sorted = this.getSortedWidgets(this.state().widgets);
const index = sorted.findIndex((widget) => widget.id === widgetId);
if (index <= 0) {
return;
}
const previous = sorted[index - 1];
const current = sorted[index];
this.swapOrder(current.id, previous.id);
}
moveWidgetDown(widgetId: string): void {
const sorted = this.getSortedWidgets(this.state().widgets);
const index = sorted.findIndex((widget) => widget.id === widgetId);
if (index === -1 || index >= sorted.length - 1) {
return;
}
const next = sorted[index + 1];
const current = sorted[index];
this.swapOrder(current.id, next.id);
}
reset(): void {
this.state.set(this.createDefaultState());
}
private swapOrder(firstId: string, secondId: string): void {
const widgetMap = new Map(
this.state().widgets.map((widget) => [widget.id, widget])
);
const first = widgetMap.get(firstId);
const second = widgetMap.get(secondId);
if (!first || !second) {
return;
}
const firstOrder = first.order;
this.state.update((state) => ({
...state,
widgets: state.widgets.map((widget) => {
if (widget.id === firstId) {
return { ...widget, order: second.order };
}
if (widget.id === secondId) {
return { ...widget, order: firstOrder };
}
return widget;
}),
}));
}
private updateWidget(
widgetId: string,
updater: (widget: DashboardWidgetConfig) => DashboardWidgetConfig
): void {
this.state.update((state) => ({
...state,
widgets: state.widgets.map((widget) =>
widget.id === widgetId ? updater(widget) : widget
),
}));
}
private loadState(): DashboardLayoutState {
if (typeof localStorage === 'undefined') {
return this.createDefaultState();
}
const raw = localStorage.getItem(DASHBOARD_LAYOUT_STORAGE_KEY);
if (!raw) {
return this.createDefaultState();
}
try {
const parsed = JSON.parse(raw) as Partial<DashboardLayoutState>;
if (!Array.isArray(parsed.widgets)) {
return this.createDefaultState();
}
if (parsed.version === DASHBOARD_LAYOUT_VERSION) {
return this.normalizeState(parsed.widgets);
}
const migrated = this.migrateState(parsed);
if (migrated) {
return migrated;
}
if (parsed.version == null) {
return this.normalizeState(parsed.widgets);
}
return this.createDefaultState();
} catch {
return this.createDefaultState();
}
}
private migrateState(
state: Partial<DashboardLayoutState>
): DashboardLayoutState | null {
if (!Array.isArray(state.widgets)) {
return null;
}
const version = Number(state.version ?? 0);
if (!Number.isFinite(version) || version <= 0) {
return null;
}
if (version < DASHBOARD_LAYOUT_VERSION) {
return this.normalizeState(state.widgets);
}
return null;
}
private normalizeState(
widgets: DashboardWidgetConfig[]
): DashboardLayoutState {
const widgetMap = new Map(widgets.map((widget) => [widget.id, widget]));
const normalized = DEFAULT_DASHBOARD_WIDGETS.map((defaultWidget) => {
const loaded = widgetMap.get(defaultWidget.id);
if (!loaded) {
return this.cloneWidget(defaultWidget);
}
return this.mergeWidget(defaultWidget, loaded);
});
return {
version: DASHBOARD_LAYOUT_VERSION,
widgets: this.getSortedWidgets(normalized).map((widget, index) => ({
...widget,
order: index,
})),
};
}
private persistState(state: DashboardLayoutState): void {
if (typeof localStorage === 'undefined') {
return;
}
localStorage.setItem(DASHBOARD_LAYOUT_STORAGE_KEY, JSON.stringify(state));
}
private createDefaultState(): DashboardLayoutState {
return {
version: DASHBOARD_LAYOUT_VERSION,
widgets: DEFAULT_DASHBOARD_WIDGETS.map((widget) =>
this.cloneWidget(widget)
),
};
}
private getSortedWidgets(
widgets: DashboardWidgetConfig[]
): DashboardWidgetConfig[] {
return [...widgets].sort((a, b) => a.order - b.order);
}
private cloneWidget(widget: DashboardWidgetConfig): DashboardWidgetConfig {
return {
id: widget.id,
type: widget.type,
title: widget.title,
description: widget.description,
size: this.normalizeSize(widget.size),
enabled: widget.enabled,
order: widget.order,
};
}
private mergeWidget(
defaultWidget: DashboardWidgetConfig,
loaded: DashboardWidgetConfig
): DashboardWidgetConfig {
return {
id: defaultWidget.id,
type: defaultWidget.type,
title: defaultWidget.title,
description: defaultWidget.description,
size: this.normalizeSize(loaded.size ?? defaultWidget.size),
enabled:
typeof loaded.enabled === 'boolean'
? loaded.enabled
: defaultWidget.enabled,
order:
typeof loaded.order === 'number'
? loaded.order
: defaultWidget.order,
};
}
private normalizeSize(size: unknown): DashboardWidgetSize {
if (size === 's') {
return 'one-third';
}
if (size === 'm') {
return 'half';
}
if (size === 'l') {
return 'full';
}
if (
typeof size === 'string' &&
DASHBOARD_WIDGET_SIZE_OPTIONS.includes(size as DashboardWidgetSize)
) {
return size as DashboardWidgetSize;
}
return 'half';
}
}
@@ -43,6 +43,7 @@ export function mapDbFavoriteToItem(
category_id: item.category_id,
xtream_id: item.xtream_id,
poster_url: item.poster_url,
backdrop_url: item.backdrop_url ?? undefined,
source: 'xtream',
};
}
@@ -58,6 +59,7 @@ export function mapDbRecentToItem(item: DbGlobalRecentItem): PortalRecentItem {
category_id: item.category_id,
xtream_id: item.xtream_id,
poster_url: item.poster_url,
backdrop_url: item.backdrop_url ?? undefined,
source: 'xtream',
};
}
@@ -171,7 +173,7 @@ export function toDateTimestamp(value: unknown): number {
}
return 0;
}
const parsed = Date.parse(trimmed);
const parsed = Date.parse(normalizeStalkerDate(trimmed) || trimmed);
return Number.isNaN(parsed) ? 0 : parsed;
}
@@ -181,7 +183,7 @@ export function toDateTimestamp(value: unknown): number {
export function toTimestamp(value?: string | number): number {
if (typeof value === 'number') return value;
if (typeof value === 'string') {
const parsed = Date.parse(value);
const parsed = Date.parse(normalizeStalkerDate(value) || value);
return Number.isNaN(parsed) ? 0 : parsed;
}
return 0;
@@ -1,79 +0,0 @@
export type DashboardWidgetType =
| 'source-stats'
| 'continue-watching'
| 'recently-watched'
| 'recently-added'
| 'global-favorites';
export type DashboardWidgetSize = 'one-third' | 'half' | 'two-thirds' | 'full';
export interface DashboardWidgetConfig {
id: string;
type: DashboardWidgetType;
title: string;
description: string;
size: DashboardWidgetSize;
enabled: boolean;
order: number;
}
export interface DashboardLayoutState {
version: number;
widgets: DashboardWidgetConfig[];
}
export const DASHBOARD_LAYOUT_VERSION = 14;
export const DASHBOARD_WIDGET_SIZE_OPTIONS: DashboardWidgetSize[] = [
'one-third',
'half',
'two-thirds',
'full',
];
export const DEFAULT_DASHBOARD_WIDGETS: DashboardWidgetConfig[] = [
{
id: 'source-stats',
type: 'source-stats',
title: 'WORKSPACE.DASHBOARD.SOURCE_STATS',
description: 'WORKSPACE.DASHBOARD.SOURCE_STATS_DESC',
size: 'one-third',
enabled: false,
order: 1,
},
{
id: 'continue',
type: 'continue-watching',
title: 'WORKSPACE.DASHBOARD.CONTINUE_WATCHING',
description: 'WORKSPACE.DASHBOARD.CONTINUE_WATCHING_DESC',
size: 'full',
enabled: true,
order: 2,
},
{
id: 'recently-watched',
type: 'recently-watched',
title: 'WORKSPACE.DASHBOARD.RECENTLY_WATCHED',
description: 'WORKSPACE.DASHBOARD.RECENTLY_WATCHED_DESC',
size: 'two-thirds',
enabled: true,
order: 4,
},
{
id: 'recently-added',
type: 'recently-added',
title: 'WORKSPACE.DASHBOARD.RECENTLY_ADDED',
description: 'WORKSPACE.DASHBOARD.RECENTLY_ADDED_DESC',
size: 'half',
enabled: false,
order: 5,
},
{
id: 'global-favorites',
type: 'global-favorites',
title: 'WORKSPACE.DASHBOARD.GLOBAL_FAVORITES',
description: 'WORKSPACE.DASHBOARD.GLOBAL_FAVORITES_DESC',
size: 'one-third',
enabled: true,
order: 6,
},
];
@@ -1 +1,2 @@
export * from './lib/workspace-dashboard.component';
export * from './lib/rails/workspace-dashboard-rails.component';
export * from './lib/rails/dashboard-rail.component';
@@ -0,0 +1,96 @@
<section class="rail" [attr.data-test-id]="testId()">
<header class="rail__header">
<h2 class="rail__title">
<span class="rail__label">{{ label() }}</span>
@if ((totalCount() ?? items().length) > 0) {
<span class="rail__count" aria-hidden="true">{{
totalCount() ?? items().length
}}</span>
}
</h2>
@if (seeAllLink(); as link) {
<a
class="rail__see-all"
[routerLink]="link"
[attr.data-test-id]="
testId() ? testId() + '-manage-all' : null
"
>
<span class="rail__see-all-label">{{
'WORKSPACE.DASHBOARD.MANAGE_ALL' | translate
}}</span>
<mat-icon>chevron_right</mat-icon>
</a>
}
</header>
<div class="rail__viewport">
<button
type="button"
class="rail__chev rail__chev--left"
[class.rail__chev--visible]="canScrollLeft()"
(click)="scrollBy(-1)"
aria-label="Scroll left"
tabindex="-1"
>
<mat-icon>chevron_left</mat-icon>
</button>
<div
#track
class="rail__track"
(scroll)="onScroll()"
role="list"
>
@for (card of items(); track card.id) {
<a
class="rail__card"
role="listitem"
[routerLink]="card.link"
[state]="card.state"
[attr.title]="card.title"
[attr.data-test-id]="testId() ? testId() + '-card' : null"
>
<div
class="rail__art"
[style.aspect-ratio]="aspectRatio()"
>
@if (card.imageUrl && !failedImages()[card.id]) {
<img
[src]="card.imageUrl"
[alt]="card.title"
loading="lazy"
decoding="async"
(error)="markFailed(card.id)"
/>
} @else {
<div class="rail__art-fallback">
<mat-icon>{{ card.icon }}</mat-icon>
</div>
}
</div>
<div class="rail__meta">
<span class="rail__card-title">{{ card.title }}</span>
@if (card.subtitle) {
<span class="rail__card-subtitle">{{
card.subtitle
}}</span>
}
</div>
</a>
}
</div>
<button
type="button"
class="rail__chev rail__chev--right"
[class.rail__chev--visible]="canScrollRight()"
(click)="scrollBy(1)"
aria-label="Scroll right"
tabindex="-1"
>
<mat-icon>chevron_right</mat-icon>
</button>
</div>
</section>
@@ -0,0 +1,361 @@
:host {
display: block;
--rail-card-width: 172px;
--rail-gap: 14px;
--rail-radius: 12px;
}
.rail {
display: flex;
flex-direction: column;
gap: 12px;
}
.rail__header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 0 2px;
// Tighter height cap — keeps rail chapter markers compact
min-height: 24px;
}
.rail__title {
margin: 0;
display: inline-flex;
align-items: baseline;
gap: 8px;
font-size: 1.125rem; // 18px — matches Netflix rail-title scale
font-weight: 600;
line-height: 1.2;
letter-spacing: -0.012em;
color: var(--app-heading-color, #1a1d23);
}
// Muted count badge, tabular numerals for alignment, sits just after label.
.rail__count {
font-size: 0.78rem;
font-weight: 500;
line-height: 1;
color: var(--app-muted-color, #8891a4);
font-variant-numeric: tabular-nums;
// Tiny capsule to read as a "count chip", not a number-in-text
padding: 2px 8px;
border-radius: 999px;
background: color-mix(
in srgb,
var(--app-muted-color, #8891a4) 18%,
transparent
);
letter-spacing: 0;
}
.rail__see-all {
display: inline-flex;
align-items: center;
gap: 2px;
padding: 4px 8px 4px 10px;
border-radius: 999px;
font-size: 0.78rem;
font-weight: 500;
line-height: 1;
letter-spacing: 0.005em;
color: var(--app-muted-color, #8891a4);
text-decoration: none;
transition:
color 0.15s ease,
background-color 0.15s ease;
mat-icon {
font-size: 16px;
width: 16px;
height: 16px;
transition: transform 0.15s ease;
}
&:hover {
color: var(--app-selection-color, #2f7bff);
background: color-mix(
in srgb,
var(--app-selection-color, #2f7bff) 12%,
transparent
);
mat-icon {
transform: translateX(2px);
}
}
&:focus-visible {
outline: 2px solid var(--app-selection-color, #2f7bff);
outline-offset: 2px;
}
}
.rail__viewport {
position: relative;
min-width: 0;
// Soft edge gradients hint at horizontal scroll without any interaction.
// Anchored to the viewport so they appear/disappear in sync with the
// chevrons' `.rail__chev--visible` state.
&::before,
&::after {
content: '';
position: absolute;
top: 0;
bottom: 0;
width: 32px;
pointer-events: none;
z-index: 1;
opacity: 0;
transition: opacity 0.2s ease;
}
&::before {
left: 0;
background: linear-gradient(
to right,
var(--app-content-bg, #161a22) 0%,
transparent 100%
);
}
&::after {
right: 0;
background: linear-gradient(
to left,
var(--app-content-bg, #161a22) 0%,
transparent 100%
);
}
// Show the correct edge gradient when scroll is possible in that direction
&:has(.rail__chev--left.rail__chev--visible)::before {
opacity: 0.9;
}
&:has(.rail__chev--right.rail__chev--visible)::after {
opacity: 0.9;
}
&:hover .rail__chev--visible {
opacity: 1;
transform: translateY(-50%) scale(1);
}
}
.rail__track {
display: flex;
align-items: flex-start;
gap: var(--rail-gap);
overflow-x: auto;
overflow-y: hidden;
scroll-snap-type: x mandatory;
scroll-padding-inline-start: 4px;
scrollbar-width: none;
padding: 4px;
margin: -4px;
&::-webkit-scrollbar {
display: none;
}
}
.rail__card {
// Pin the card size so long titles never expand the flex item.
// `min-width: 0` is needed because flex items default to `min-width: auto`
// (their intrinsic content size), which would otherwise override the
// flex-basis when the title wraps.
flex: 0 0 var(--rail-card-width);
width: var(--rail-card-width);
max-width: var(--rail-card-width);
min-width: 0;
scroll-snap-align: start;
display: flex;
flex-direction: column;
gap: 8px;
text-decoration: none;
color: inherit;
border-radius: var(--rail-radius);
transition:
transform 0.2s ease,
filter 0.2s ease;
&:hover {
transform: translateY(-2px);
.rail__art {
box-shadow:
0 12px 22px -12px rgba(0, 0, 0, 0.45),
0 0 0 1px var(--app-selection-border, rgba(47, 123, 255, 0.4));
}
.rail__card-title {
color: var(--app-selection-color, #2f7bff);
}
}
}
.rail__art {
position: relative;
overflow: hidden;
border-radius: var(--rail-radius);
background: var(--app-widget-bg, #1e2330);
border: 1px solid var(--app-widget-header-border, rgba(255, 255, 255, 0.05));
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.15);
transition: box-shadow 0.2s ease;
img {
width: 100%;
height: 100%;
object-fit: cover;
display: block;
}
}
.rail__art-fallback {
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
background:
radial-gradient(
circle at 30% 20%,
var(--app-selection-surface, rgba(120, 173, 255, 0.18)) 0%,
transparent 55%
),
var(--app-widget-bg, #1e2330);
mat-icon {
font-size: 44px;
width: 44px;
height: 44px;
color: var(--app-selection-color, #78adff);
opacity: 0.55;
}
}
.rail__meta {
display: flex;
flex-direction: column;
gap: 3px;
padding: 0 2px;
min-width: 0;
}
.rail__card-title {
font-size: 0.85rem;
font-weight: 600;
line-height: 1.3;
color: var(--app-heading-color, #1a1d23);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
letter-spacing: -0.005em;
transition: color 0.15s ease;
}
.rail__card-subtitle {
font-size: 0.72rem;
font-weight: 500;
line-height: 1.4;
color: var(--app-muted-color, #8891a4);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
letter-spacing: 0.005em;
}
// Circular chevron — streaming-app pattern (Netflix/Apple TV+). High-contrast
// dark glass works against any card color, including the muted fallback tiles
// which previously camouflaged the buttons.
.rail__chev {
position: absolute;
top: 50%;
transform: translateY(-50%) scale(0.96);
display: flex;
align-items: center;
justify-content: center;
width: 44px;
height: 44px;
padding: 0;
border: 1px solid rgba(255, 255, 255, 0.14);
border-radius: 50%;
background: rgba(0, 0, 0, 0.72);
backdrop-filter: blur(12px);
-webkit-backdrop-filter: blur(12px);
color: #fff;
cursor: pointer;
opacity: 0;
pointer-events: none;
transition:
opacity 0.18s ease,
background-color 0.18s ease,
transform 0.18s ease,
box-shadow 0.18s ease;
z-index: 2;
box-shadow:
0 4px 14px -4px rgba(0, 0, 0, 0.55),
0 0 0 0 rgba(255, 255, 255, 0); // slot for hover ring
mat-icon {
font-size: 24px;
width: 24px;
height: 24px;
line-height: 24px;
// Keep icon crisp over bright content
filter: drop-shadow(0 1px 2px rgba(0, 0, 0, 0.5));
}
// Peek at rest when scroll is possible — hints the affordance exists
// without requiring the user to hover first.
&--visible {
opacity: 0.5;
pointer-events: auto;
}
&:hover {
background: rgba(0, 0, 0, 0.88);
transform: translateY(-50%) scale(1.08);
box-shadow:
0 8px 22px -6px rgba(0, 0, 0, 0.65),
0 0 0 4px rgba(255, 255, 255, 0.06);
}
&:active {
transform: translateY(-50%) scale(0.96);
}
&:focus-visible {
outline: 2px solid var(--app-selection-color, #78adff);
outline-offset: 3px;
}
&--left {
left: 6px;
}
&--right {
right: 6px;
}
}
@media (hover: none) {
.rail__chev {
display: none;
}
.rail__viewport::before,
.rail__viewport::after {
display: none;
}
}
@media (max-width: 720px) {
:host {
--rail-card-width: 140px;
}
}
@@ -0,0 +1,126 @@
import {
AfterViewInit,
ChangeDetectionStrategy,
Component,
ElementRef,
OnDestroy,
effect,
signal,
viewChild,
input,
} from '@angular/core';
import { MatButtonModule } from '@angular/material/button';
import { MatIcon } from '@angular/material/icon';
import { RouterLink } from '@angular/router';
import { TranslatePipe } from '@ngx-translate/core';
export interface DashboardRailCard {
id: string;
title: string;
subtitle?: string;
imageUrl?: string;
icon: string;
link: string[];
state?: Record<string, unknown>;
}
@Component({
selector: 'lib-dashboard-rail',
imports: [MatButtonModule, MatIcon, RouterLink, TranslatePipe],
templateUrl: './dashboard-rail.component.html',
styleUrl: './dashboard-rail.component.scss',
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class DashboardRailComponent implements AfterViewInit, OnDestroy {
readonly label = input.required<string>();
readonly items = input.required<DashboardRailCard[]>();
readonly seeAllLink = input<string[] | null>(null);
readonly aspectRatio = input<string>('2 / 3');
readonly testId = input<string | null>(null);
/**
* True total in the underlying dataset. Shown as a count badge next to
* the rail label. Falls back to `items().length` when not supplied.
*/
readonly totalCount = input<number | null>(null);
private readonly track = viewChild.required<ElementRef<HTMLDivElement>>('track');
readonly canScrollLeft = signal(false);
readonly canScrollRight = signal(false);
readonly failedImages = signal<Record<string, true>>({});
private readonly viewReady = signal(false);
private resizeObserver?: ResizeObserver;
private resetFrameId: number | null = null;
private settleFrameId: number | null = null;
constructor() {
effect(() => {
this.items();
if (!this.viewReady()) return;
this.scheduleResetToStart();
});
}
ngAfterViewInit(): void {
this.viewReady.set(true);
this.updateScrollState();
this.resizeObserver = new ResizeObserver(() => this.updateScrollState());
this.resizeObserver.observe(this.track().nativeElement);
}
ngOnDestroy(): void {
this.resizeObserver?.disconnect();
this.cancelPendingReset();
}
onScroll(): void {
this.updateScrollState();
}
scrollBy(direction: 1 | -1): void {
const el = this.track().nativeElement;
el.scrollBy({
left: direction * el.clientWidth * 0.85,
behavior: 'smooth',
});
}
markFailed(id: string): void {
this.failedImages.update((state) =>
state[id] ? state : { ...state, [id]: true }
);
}
private updateScrollState(): void {
const el = this.track().nativeElement;
this.canScrollLeft.set(el.scrollLeft > 4);
this.canScrollRight.set(
el.scrollLeft + el.clientWidth < el.scrollWidth - 4
);
}
private scheduleResetToStart(): void {
this.cancelPendingReset();
this.resetFrameId = requestAnimationFrame(() => {
this.resetFrameId = null;
this.settleFrameId = requestAnimationFrame(() => {
this.settleFrameId = null;
const el = this.track().nativeElement;
el.scrollTo({ left: 0, behavior: 'auto' });
this.updateScrollState();
});
});
}
private cancelPendingReset(): void {
if (this.resetFrameId !== null) {
cancelAnimationFrame(this.resetFrameId);
this.resetFrameId = null;
}
if (this.settleFrameId !== null) {
cancelAnimationFrame(this.settleFrameId);
this.settleFrameId = null;
}
}
}
@@ -0,0 +1,130 @@
<section class="rails-page" data-test-id="dashboard-page">
@if (!ready()) {
<!-- Precise ghosts that mirror the real hero + rails so the layout
doesn't reflow when data arrives. -->
<section
class="rails-page__skeleton-hero"
aria-hidden="true"
aria-busy="true"
>
<div class="rails-page__skeleton-hero-poster"></div>
<div class="rails-page__skeleton-hero-body">
<div class="rails-page__skeleton-hero-title"></div>
<div
class="rails-page__skeleton-hero-title rails-page__skeleton-hero-title--short"
></div>
<div class="rails-page__skeleton-hero-subtitle"></div>
<div class="rails-page__skeleton-hero-cta"></div>
</div>
</section>
@for (_ of skeletonRails; track $index) {
<section
class="rails-page__skeleton-rail"
aria-hidden="true"
aria-busy="true"
>
<div class="rails-page__skeleton-header"></div>
<div class="rails-page__skeleton-track">
@for (slot of skeletonSlots; track slot) {
<div class="rails-page__skeleton-card"></div>
}
</div>
</section>
}
} @else if (!hasPlaylists()) {
<app-empty-state
[type]="'welcome'"
(addPlaylistClicked)="onAddPlaylist()"
/>
} @else {
@if (hero(); as item) {
<a
class="hero"
data-test-id="dashboard-hero"
[class.hero--with-image]="!!item.posterUrl || !!item.backdropUrl"
[class.hero--has-backdrop]="item.hasBackdrop"
[routerLink]="item.link"
[state]="item.state ?? {}"
>
@if (item.backdropUrl) {
<span
class="hero__backdrop"
aria-hidden="true"
[style.backgroundImage]="'url(' + item.backdropUrl + ')'"
></span>
}
<span class="hero__vignette" aria-hidden="true"></span>
<span class="hero__poster">
@if (item.posterUrl) {
<img
[src]="item.posterUrl"
[alt]="item.title"
loading="eager"
decoding="async"
/>
} @else {
<span class="hero__poster-fallback">
<mat-icon>{{ item.icon }}</mat-icon>
</span>
}
</span>
<span class="hero__body">
<h1 class="hero__title">{{ item.title }}</h1>
<p class="hero__subtitle">{{ item.subtitle }}</p>
<span class="hero__cta">
<mat-icon>play_arrow</mat-icon>
<span>{{
'WORKSPACE.DASHBOARD.CONTINUE_WATCHING' | translate
}}</span>
</span>
</span>
</a>
}
@if (recentlyWatchedCards().length > 0) {
<lib-dashboard-rail
[label]="'WORKSPACE.DASHBOARD.RECENTLY_WATCHED' | translate"
[items]="recentlyWatchedCards()"
[totalCount]="data.globalRecentItems().length"
[seeAllLink]="['/workspace/global-recent']"
[testId]="'dashboard-recently-watched-rail'"
/>
}
@if (favoriteCards().length > 0) {
<lib-dashboard-rail
[label]="'WORKSPACE.DASHBOARD.GLOBAL_FAVORITES' | translate"
[items]="favoriteCards()"
[totalCount]="data.globalFavoriteItems().length"
[seeAllLink]="['/workspace/global-favorites']"
[testId]="'dashboard-global-favorites-rail'"
/>
}
@if (sourceCards().length > 0) {
<lib-dashboard-rail
[label]="'WORKSPACE.DASHBOARD.RECENT_SOURCES' | translate"
[items]="sourceCards()"
[totalCount]="data.playlists().length"
[seeAllLink]="['/workspace/sources']"
aspectRatio="16 / 9"
[testId]="'dashboard-recent-sources-rail'"
/>
}
@if (xtreamRecentlyAddedCards().length > 0) {
<lib-dashboard-rail
[label]="
'WORKSPACE.DASHBOARD.XTREAM_RECENTLY_ADDED' | translate
"
[items]="xtreamRecentlyAddedCards()"
[totalCount]="data.xtreamRecentlyAddedItems().length"
[testId]="'dashboard-xtream-recently-added-rail'"
/>
}
}
</section>
@@ -0,0 +1,427 @@
:host {
display: block;
padding: 24px 28px 40px;
box-sizing: border-box;
min-height: 100%;
}
.rails-page {
max-width: 1440px;
margin: 0 auto;
display: flex;
flex-direction: column;
gap: 32px;
min-width: 0;
}
// ── Hero ────────────────────────────────────────────────────────────────────
// Netflix/Apple TV-style hero: blurred poster backdrop + vignette + sharp 2:3
// poster card + text block. Mirrors content-hero fallback (no dedicated
// backdrop asset available, so we reuse the poster blurred + scaled).
.hero {
position: relative;
display: grid;
grid-template-columns: minmax(0, var(--hero-poster-width)) minmax(0, 1fr);
gap: 24px;
padding: 20px;
text-decoration: none;
color: inherit;
border-radius: 18px;
overflow: hidden;
isolation: isolate;
background: var(--app-widget-header-bg, #232840);
border: 1px solid var(--app-widget-header-border, rgba(255, 255, 255, 0.08));
box-shadow:
0 1px 2px rgba(0, 0, 0, 0.2),
0 12px 32px -18px var(--app-selection-glow, rgba(120, 173, 255, 0.4));
transition:
transform 0.2s ease,
box-shadow 0.2s ease;
--hero-poster-width: 160px;
&:hover {
transform: translateY(-2px);
box-shadow:
0 1px 2px rgba(0, 0, 0, 0.2),
0 20px 40px -20px var(--app-selection-glow, rgba(120, 173, 255, 0.55));
}
// No-image fallback keeps the accent gradient so the hero is never flat.
&:not(.hero--with-image) {
background:
var(
--app-accent-gradient,
linear-gradient(
135deg,
rgba(99, 102, 241, 0.12) 0%,
rgba(168, 85, 247, 0.07) 100%
)
),
var(--app-widget-header-bg, #232840);
}
}
// Layer 0: backdrop. Two modes:
// (1) Real backdrop (`.hero--has-backdrop`): sharp wide image with no blur.
// (2) Blurred-poster fallback: heavy blur + scale so the poster's colours
// wash across the full panel without the portrait edges showing.
.hero__backdrop {
position: absolute;
inset: 0;
background-size: cover;
background-position: center 25%;
background-repeat: no-repeat;
z-index: 0;
pointer-events: none;
// Fallback: blurred poster.
filter: blur(40px) saturate(1.5);
transform: scale(1.5);
opacity: 0.9;
}
.hero--has-backdrop .hero__backdrop {
filter: none;
transform: none;
opacity: 1;
}
// Layer 1: vignette. Scrim behind the poster card (contrast) and on the
// right edge (text legibility). Middle stays mostly transparent so the
// backdrop colours show through. Darkens further when a sharp backdrop is
// present, since real photography has more focal variation than a blur wash.
.hero__vignette {
position: absolute;
inset: 0;
z-index: 1;
pointer-events: none;
background:
linear-gradient(
90deg,
color-mix(
in srgb,
var(--app-widget-header-bg, #232840) 55%,
transparent
)
0%,
color-mix(
in srgb,
var(--app-widget-header-bg, #232840) 10%,
transparent
)
45%,
color-mix(
in srgb,
var(--app-widget-header-bg, #232840) 65%,
transparent
)
100%
),
linear-gradient(
to bottom,
transparent 55%,
color-mix(
in srgb,
var(--app-widget-header-bg, #232840) 35%,
transparent
)
100%
);
}
.hero--has-backdrop .hero__vignette {
background:
linear-gradient(
90deg,
rgba(0, 0, 0, 0.55) 0%,
rgba(0, 0, 0, 0.2) 35%,
rgba(0, 0, 0, 0.78) 100%
),
linear-gradient(
to bottom,
rgba(0, 0, 0, 0) 40%,
rgba(0, 0, 0, 0.45) 100%
);
}
// Layer 2: sharp poster in native 2:3 aspect (no more 16:9 crop).
.hero__poster {
position: relative;
z-index: 2;
width: var(--hero-poster-width);
aspect-ratio: 2 / 3;
border-radius: 12px;
overflow: hidden;
background: var(--app-widget-bg, #1e2330);
border: 1px solid var(--app-widget-header-border, rgba(255, 255, 255, 0.1));
box-shadow: 0 8px 24px -10px rgba(0, 0, 0, 0.55);
align-self: center;
img {
width: 100%;
height: 100%;
object-fit: cover;
display: block;
}
}
.hero__poster-fallback {
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
background:
radial-gradient(
circle at 50% 40%,
var(--app-selection-surface, rgba(120, 173, 255, 0.2)) 0%,
transparent 60%
);
mat-icon {
font-size: 56px;
width: 56px;
height: 56px;
color: var(--app-selection-color, #78adff);
opacity: 0.75;
}
}
.hero__body {
position: relative;
z-index: 2;
display: flex;
flex-direction: column;
justify-content: center;
gap: 6px;
min-width: 0;
}
.hero__title {
margin: 6px 0 4px;
font-size: 1.75rem;
font-weight: 600;
line-height: 1.15;
// Display-weight tracking: looser than -0.02em which crushes glyphs at
// this size (~28px). Matches Apple TV+ "Up Next" card scale.
letter-spacing: -0.015em;
color: var(--app-heading-color, #d8dce8);
overflow: hidden;
text-overflow: ellipsis;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
.hero--with-image & {
color: #fff;
text-shadow: 0 2px 12px rgba(0, 0, 0, 0.55);
}
}
.hero__subtitle {
margin: 0;
font-size: 0.88rem;
font-weight: 500;
line-height: 1.45;
letter-spacing: 0.002em;
color: var(--app-body-color, #8b93a8);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
.hero--with-image & {
color: rgba(255, 255, 255, 0.82);
text-shadow: 0 1px 6px rgba(0, 0, 0, 0.45);
}
}
.hero__cta {
margin-top: 16px;
align-self: flex-start;
display: inline-flex;
align-items: center;
gap: 6px;
padding: 10px 20px 10px 14px;
border-radius: 999px;
background: var(--app-selection-color, #78adff);
color: var(--app-selection-on-color, #071225);
font-size: 0.875rem;
font-weight: 600;
line-height: 1;
letter-spacing: 0.005em;
// Explicit min-height anchors the text baseline regardless of font-smoothing
min-height: 36px;
box-sizing: border-box;
box-shadow: 0 4px 12px -4px var(--app-selection-glow, rgba(120, 173, 255, 0.5));
mat-icon {
font-size: 20px;
width: 20px;
height: 20px;
line-height: 20px;
}
}
// ── Loading skeleton ────────────────────────────────────────────────────────
// Geometry mirrors the real hero + rails so the layout doesn't shift when
// data arrives. All shimmer targets share the `%skeleton-shimmer` placeholder
// defined below.
%skeleton-shimmer {
background: linear-gradient(
90deg,
var(--app-widget-bg, #1e2330) 0%,
var(--app-widget-header-bg, #232840) 50%,
var(--app-widget-bg, #1e2330) 100%
);
background-size: 200% 100%;
animation: dashboard-skeleton-shimmer 1.6s linear infinite;
border: 1px solid var(--app-widget-header-border, rgba(255, 255, 255, 0.05));
}
@keyframes dashboard-skeleton-shimmer {
from {
background-position: 200% 0;
}
to {
background-position: -200% 0;
}
}
// ── Hero ghost ──────────────────────────────────────────────────────────────
.rails-page__skeleton-hero {
position: relative;
display: grid;
grid-template-columns: minmax(0, 160px) minmax(0, 1fr);
gap: 24px;
padding: 20px;
border-radius: 18px;
background: var(--app-widget-header-bg, #232840);
border: 1px solid var(--app-widget-header-border, rgba(255, 255, 255, 0.08));
}
.rails-page__skeleton-hero-poster {
@extend %skeleton-shimmer;
width: 160px;
aspect-ratio: 2 / 3;
border-radius: 12px;
align-self: center;
}
.rails-page__skeleton-hero-body {
display: flex;
flex-direction: column;
justify-content: center;
gap: 10px;
min-width: 0;
}
.rails-page__skeleton-hero-title {
@extend %skeleton-shimmer;
width: 60%;
height: 28px;
border-radius: 6px;
}
.rails-page__skeleton-hero-title--short {
width: 35%;
}
.rails-page__skeleton-hero-subtitle {
@extend %skeleton-shimmer;
width: 45%;
height: 14px;
border-radius: 4px;
margin-top: 2px;
}
.rails-page__skeleton-hero-cta {
@extend %skeleton-shimmer;
width: 160px;
height: 36px;
border-radius: 999px;
margin-top: 14px;
}
// ── Rail ghost ──────────────────────────────────────────────────────────────
.rails-page__skeleton-rail {
display: flex;
flex-direction: column;
gap: 12px;
padding: 12px 0 0;
}
.rails-page__skeleton-header {
@extend %skeleton-shimmer;
width: 180px;
height: 22px;
border-radius: 6px;
}
.rails-page__skeleton-track {
display: flex;
gap: 14px;
padding: 4px 0 16px;
overflow: hidden;
}
.rails-page__skeleton-card {
@extend %skeleton-shimmer;
// Matches the real .rail__card width (--rail-card-width: 172px) and
// .rail__art aspect-ratio 2/3, so when cards arrive the geometry is
// identical and there's no layout shift.
flex: 0 0 172px;
width: 172px;
aspect-ratio: 2 / 3;
border-radius: 12px;
}
@media (prefers-reduced-motion: reduce) {
.rails-page__skeleton-hero-poster,
.rails-page__skeleton-hero-title,
.rails-page__skeleton-hero-subtitle,
.rails-page__skeleton-hero-cta,
.rails-page__skeleton-header,
.rails-page__skeleton-card {
animation: none;
background-position: 0 0;
}
}
// ── Responsive ──────────────────────────────────────────────────────────────
@media (max-width: 720px) {
:host {
padding: 16px 16px 32px;
}
.rails-page {
gap: 24px;
}
.hero {
--hero-poster-width: 120px;
grid-template-columns: var(--hero-poster-width) minmax(0, 1fr);
gap: 14px;
padding: 14px;
}
// Skeleton hero mirrors real hero at this breakpoint.
.rails-page__skeleton-hero {
grid-template-columns: 120px minmax(0, 1fr);
gap: 14px;
padding: 14px;
}
.rails-page__skeleton-hero-poster {
width: 120px;
}
.rails-page__skeleton-card {
flex: 0 0 140px;
width: 140px;
}
.hero__title {
font-size: 1.3rem;
}
}
@@ -0,0 +1,210 @@
import {
ChangeDetectionStrategy,
Component,
computed,
effect,
inject,
} from '@angular/core';
import { MatButtonModule } from '@angular/material/button';
import { MatIcon } from '@angular/material/icon';
import { RouterLink } from '@angular/router';
import { TranslatePipe, TranslateService } from '@ngx-translate/core';
import { EmptyStateComponent } from '@iptvnator/playlist/shared/ui';
import { WORKSPACE_SHELL_ACTIONS } from '@iptvnator/workspace/shell/util';
import {
DashboardDataService,
DashboardFavoriteItem,
DashboardRecentlyAddedItem,
GlobalRecentItem,
} from 'workspace-dashboard-data-access';
import {
DashboardRailCard,
DashboardRailComponent,
} from './dashboard-rail.component';
// Cap dashboard rails at 20 items. Users get ~3× what's visible at once,
// the DOM stays cheap, and the "Manage all" link is one click away for the
// full list. Matches the single-rail density of Netflix / Apple TV+.
const RAIL_ITEM_LIMIT = 20;
// Six placeholder slots per skeleton rail — fills a typical viewport without
// taking the whole page. Mirrors the recently-added skeleton density.
const SKELETON_CARDS_PER_RAIL = [1, 2, 3, 4, 5, 6] as const;
const SKELETON_RAILS = [1, 2, 3] as const;
interface DashboardHeroModel {
readonly backdropUrl?: string;
readonly hasBackdrop: boolean;
readonly icon: string;
readonly link: string[];
readonly posterUrl?: string;
readonly state?: Record<string, unknown>;
readonly subtitle: string;
readonly title: string;
}
@Component({
selector: 'lib-workspace-dashboard-rails',
imports: [
DashboardRailComponent,
EmptyStateComponent,
MatButtonModule,
MatIcon,
RouterLink,
TranslatePipe,
],
templateUrl: './workspace-dashboard-rails.component.html',
styleUrl: './workspace-dashboard-rails.component.scss',
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class WorkspaceDashboardRailsComponent {
readonly data = inject(DashboardDataService);
private readonly translate = inject(TranslateService);
private readonly shellActions = inject(WORKSPACE_SHELL_ACTIONS);
readonly hasPlaylists = computed(() => this.data.playlists().length > 0);
readonly ready = this.data.dashboardReady;
readonly xtreamPlaylistCount = this.data.xtreamPlaylistCount;
readonly skeletonSlots = SKELETON_CARDS_PER_RAIL;
readonly skeletonRails = SKELETON_RAILS;
readonly hero = computed<DashboardHeroModel | null>(() => {
const item = this.data.globalRecentItems()[0] ?? null;
if (!item) {
return null;
}
return {
backdropUrl: item.backdrop_url || item.poster_url,
hasBackdrop: Boolean(item.backdrop_url),
icon: this.typeIcon(item.type),
link: this.data.getRecentItemLink(item),
posterUrl: item.poster_url,
state: this.data.getRecentItemNavigationState(item),
subtitle: this.buildHeroSubtitle(item),
title: item.title,
};
});
readonly recentlyWatchedCards = computed<DashboardRailCard[]>(() =>
this.data
.globalRecentItems()
.slice(0, RAIL_ITEM_LIMIT)
.map((item) => this.toRecentCard(item))
);
readonly favoriteCards = computed<DashboardRailCard[]>(() =>
this.data
.globalFavoriteItems()
.slice(0, RAIL_ITEM_LIMIT)
.map((item) => this.toFavoriteCard(item))
);
readonly xtreamRecentlyAddedCards = computed<DashboardRailCard[]>(() =>
this.data
.xtreamRecentlyAddedItems()
.slice(0, RAIL_ITEM_LIMIT)
.map((item) => this.toRecentlyAddedCard(item))
);
readonly sourceCards = computed<DashboardRailCard[]>(() =>
this.data.recentPlaylists().map((playlist) => ({
id: playlist._id,
title:
playlist.title ||
playlist.filename ||
this.t('WORKSPACE.DASHBOARD.UNTITLED_SOURCE'),
subtitle: this.data.getPlaylistProvider(playlist),
icon: playlist.serverUrl
? 'cloud'
: playlist.macAddress
? 'cast'
: 'folder_open',
link: this.data.getPlaylistLink(playlist),
}))
);
constructor() {
// Re-entering the dashboard should pick up any DB-backed recent/favorite
// changes made while viewing details, including newly backfilled
// backdrops that do not change recency ordering.
void this.data.reloadGlobalRecentItems();
void this.data.reloadGlobalFavorites();
// Refresh when Xtream playlist count changes so a newly added provider
// populates the rail without a manual dashboard reload.
effect(() => {
if (this.xtreamPlaylistCount() === 0) {
return;
}
void this.data.reloadXtreamRecentlyAddedItems(RAIL_ITEM_LIMIT);
});
}
onAddPlaylist(): void {
this.shellActions.openAddPlaylistDialog();
}
private buildHeroSubtitle(item: GlobalRecentItem): string {
const parts = [
item.playlist_name,
this.data.getRecentItemProviderLabel(item),
this.data.getRecentItemTypeLabel(item),
].filter((value): value is string => Boolean(value));
return parts.join(' · ');
}
private toRecentCard(item: GlobalRecentItem): DashboardRailCard {
return {
id: `recent-${item.id}-${item.playlist_id}-${item.viewed_at}`,
title: item.title,
subtitle: `${this.data.getRecentItemProviderLabel(item)} · ${this.data.getRecentItemTypeLabel(item)}`,
imageUrl: item.poster_url,
icon: this.typeIcon(item.type),
link: this.data.getRecentItemLink(item),
state: this.data.getRecentItemNavigationState(item),
};
}
private toFavoriteCard(item: DashboardFavoriteItem): DashboardRailCard {
return {
id: `fav-${item.id}-${item.playlist_id}-${item.added_at}`,
title: item.title,
subtitle: `${this.data.getFavoriteItemProviderLabel(item)} · ${this.data.getFavoriteItemTypeLabel(item)}`,
imageUrl: item.poster_url,
icon: this.typeIcon(item.type),
link: this.data.getGlobalFavoriteLink(item),
state: this.data.getGlobalFavoriteNavigationState(item),
};
}
private toRecentlyAddedCard(
item: DashboardRecentlyAddedItem
): DashboardRailCard {
const typeLabel = this.data.getRecentlyAddedItemTypeLabel(item);
const subtitleParts = [item.playlist_name, typeLabel].filter(
(value): value is string => Boolean(value)
);
return {
id: `added-${item.id}-${item.playlist_id}-${item.added_at}`,
title: item.title,
subtitle: subtitleParts.join(' · '),
imageUrl: item.poster_url,
icon: this.typeIcon(item.type),
link: this.data.getRecentlyAddedLink(item),
state: this.data.getRecentlyAddedNavigationState(item),
};
}
private typeIcon(type: 'live' | 'movie' | 'series'): string {
if (type === 'live') return 'live_tv';
if (type === 'movie') return 'movie';
return 'video_library';
}
private t(key: string): string {
return this.translate.instant(key);
}
}
@@ -1,198 +0,0 @@
<section class="dashboard-page">
<section
class="dashboard-topbar"
[class.dashboard-topbar--editing]="editMode()"
>
@if (editMode()) {
<p class="topbar-edit-hint">
{{ 'WORKSPACE.DASHBOARD.TOOLBAR_HINT_EDITING' | translate }}
</p>
<div class="topbar-edit-actions">
<button
type="button"
mat-button
(click)="layoutService.reset()"
>
{{ 'WORKSPACE.DASHBOARD.TOOLBAR_RESET' | translate }}
</button>
<button
type="button"
mat-stroked-button
(click)="toggleEditMode()"
>
<mat-icon>check</mat-icon>
{{ 'WORKSPACE.DASHBOARD.TOOLBAR_DONE' | translate }}
</button>
</div>
} @else {
<p class="topbar-label">
{{ 'WORKSPACE.DASHBOARD.TOPBAR_LABEL' | translate }}
</p>
<button
type="button"
mat-icon-button
(click)="toggleEditMode()"
[matTooltip]="
'WORKSPACE.DASHBOARD.TOOLBAR_CUSTOMIZE' | translate
"
[attr.aria-label]="
'WORKSPACE.DASHBOARD.CUSTOMIZE_LAYOUT' | translate
"
>
<mat-icon>tune</mat-icon>
</button>
}
</section>
<section
class="dashboard-editor-shell"
[class.dashboard-editor-shell--editing]="editMode()"
>
<section class="dashboard-canvas">
<section
class="widget-grid"
cdkDropList
[class.widget-grid--editing]="editMode()"
[cdkDropListData]="visibleWidgets()"
[cdkDropListDisabled]="!editMode()"
(cdkDropListDropped)="onWidgetDrop($event)"
>
@for (widget of visibleWidgets(); track widget.id) {
<article
class="widget-slot"
cdkDrag
[attr.data-widget-type]="widget.type"
[cdkDragDisabled]="!editMode()"
[class.widget-slot--one-third]="
widget.size === 'one-third'
"
[class.widget-slot--half]="widget.size === 'half'"
[class.widget-slot--two-thirds]="
widget.size === 'two-thirds'
"
[class.widget-slot--full]="widget.size === 'full'"
[class.widget-slot--editing]="editMode()"
>
@if (editMode()) {
<section class="widget-inline-editor">
<button
type="button"
mat-icon-button
class="drag-handle"
cdkDragHandle
[attr.aria-label]="
getDragWidgetLabel(widget)
"
>
<mat-icon>drag_indicator</mat-icon>
</button>
<div class="size-stepper">
<button
type="button"
mat-icon-button
class="size-stepper__btn"
[disabled]="isFirstSize(widget.size)"
(click)="cycleWidgetSize(widget.id, -1)"
[attr.aria-label]="
'WORKSPACE.DASHBOARD.MAKE_WIDGET_NARROWER'
| translate
"
>
<mat-icon>chevron_left</mat-icon>
</button>
<span class="size-stepper__label">{{
getSizeLabel(widget.size)
}}</span>
<button
type="button"
mat-icon-button
class="size-stepper__btn"
[disabled]="isLastSize(widget.size)"
(click)="cycleWidgetSize(widget.id, 1)"
[attr.aria-label]="
'WORKSPACE.DASHBOARD.MAKE_WIDGET_WIDER'
| translate
"
>
<mat-icon>chevron_right</mat-icon>
</button>
</div>
<button
type="button"
mat-icon-button
class="widget-inline-editor__hide"
[attr.aria-label]="
getHideWidgetLabel(widget)
"
(click)="hideWidget(widget.id)"
>
<mat-icon>visibility_off</mat-icon>
</button>
</section>
}
<app-dashboard-widget-host [widget]="widget" />
</article>
} @empty {
<article class="empty-state">
<h3>
{{
'WORKSPACE.DASHBOARD.EMPTY_WIDGETS' | translate
}}
</h3>
<p>
{{
'WORKSPACE.DASHBOARD.EMPTY_WIDGETS_HINT'
| translate
}}
</p>
</article>
}
</section>
</section>
@if (editMode()) {
<aside class="widget-library">
<section class="layout-editor">
<h2>
{{
'WORKSPACE.DASHBOARD.WIDGET_LIBRARY_TITLE'
| translate
}}
</h2>
<p class="layout-editor__hint">
{{
'WORKSPACE.DASHBOARD.WIDGET_LIBRARY_HINT'
| translate
}}
</p>
<div class="editor-list">
@for (widget of allWidgets(); track widget.id) {
<article class="editor-item">
<div class="editor-item__meta">
<h3>{{ getWidgetTitle(widget) }}</h3>
<p>{{ getWidgetDescription(widget) }}</p>
</div>
<div class="editor-item__actions">
<p class="editor-item__action-label">
{{
'WORKSPACE.DASHBOARD.WIDGET_VISIBILITY'
| translate
}}
</p>
<mat-slide-toggle
[checked]="widget.enabled"
(change)="
layoutService.toggleWidget(
widget.id
)
"
></mat-slide-toggle>
</div>
</article>
}
</div>
</section>
</aside>
}
</section>
</section>
@@ -1,471 +0,0 @@
:host {
display: block;
min-height: 100%;
box-sizing: border-box;
padding: 20px;
--dashboard-accent: color-mix(in srgb, var(--mat-sys-primary) 84%, #1c75ff 16%);
--dashboard-frame-border: color-mix(
in srgb,
var(--mat-sys-outline-variant) 72%,
var(--dashboard-accent) 28%
);
--dashboard-frame-glow: color-mix(in srgb, var(--dashboard-accent) 34%, transparent);
--dashboard-frame-tint: color-mix(
in srgb,
var(--dashboard-accent) 12%,
var(--mat-sys-surface-container-high) 88%
);
--widget-grid-gap: 12px;
--dashboard-widget-height: 400px;
}
.dashboard-page {
max-width: 1440px;
margin: 0 auto;
display: flex;
flex-direction: column;
gap: 6px;
min-width: 0;
overflow-x: clip;
}
// ─── Dashboard Topbar ────────────────────────────────────────────────────────
.dashboard-topbar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
height: 44px;
padding: 0 4px 0 10px;
border-radius: 12px;
}
.dashboard-topbar--editing {
position: sticky;
top: 8px;
z-index: 20;
padding: 0 8px 0 12px;
border: 1px solid var(--dashboard-frame-border);
background: linear-gradient(
180deg,
var(--mat-sys-surface-container-highest) 0%,
var(--mat-sys-surface-container-high) 100%
);
box-shadow: 0 8px 24px -16px var(--dashboard-frame-glow);
}
.topbar-label {
margin: 0;
font-size: 0.72rem;
font-weight: 600;
color: var(--mat-sys-on-surface-variant);
text-transform: uppercase;
letter-spacing: 0.08em;
opacity: 0.6;
}
.topbar-edit-hint {
margin: 0;
font-size: 0.82rem;
color: var(--mat-sys-on-surface-variant);
flex: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.topbar-edit-actions {
display: flex;
align-items: center;
gap: 6px;
flex-shrink: 0;
}
.dashboard-editor-shell {
border-radius: 18px;
border: 1px solid color-mix(in srgb, var(--dashboard-frame-border) 72%, transparent);
background: color-mix(
in srgb,
var(--mat-sys-surface-container-high) 84%,
var(--dashboard-frame-tint) 16%
);
overflow: clip;
min-width: 0;
}
.dashboard-editor-shell--editing {
display: grid;
grid-template-columns: minmax(0, 1fr) 380px;
gap: 0;
align-items: start;
}
.dashboard-canvas {
min-width: 0;
overflow-x: clip;
}
.widget-library {
border-left: 1px solid var(--dashboard-frame-border);
background: linear-gradient(
180deg,
var(--mat-sys-surface-container-highest) 0%,
var(--mat-sys-surface-container-high) 100%
);
box-shadow: -18px 0 30px -22px var(--dashboard-frame-glow);
max-height: calc(100vh - 96px);
overflow: auto;
overflow-x: hidden;
position: sticky;
top: 76px;
min-width: 0;
}
.layout-editor {
padding: 14px 14px 24px;
}
.layout-editor h2 {
margin: 0 0 4px;
font-size: 0.72rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.1em;
color: var(--mat-sys-on-surface-variant);
}
.layout-editor__hint {
margin: 6px 0 12px;
color: var(--mat-sys-on-surface-variant);
font-size: 0.8rem;
}
.editor-list {
display: grid;
gap: 8px;
}
.editor-item {
border: 1px solid var(--dashboard-frame-border);
border-radius: 14px;
background: color-mix(
in srgb,
var(--mat-sys-surface-container-low) 84%,
var(--dashboard-frame-tint) 16%
);
padding: 10px 12px;
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 10px;
flex-wrap: wrap;
overflow: hidden;
}
.editor-item__meta h3 {
margin: 0;
font-size: 0.875rem;
font-weight: 600;
color: var(--mat-sys-on-surface);
}
.editor-item__meta p {
margin: 4px 0 0;
font-size: 0.79rem;
color: var(--mat-sys-on-surface-variant);
}
.editor-item__actions {
display: flex;
align-items: center;
gap: 8px;
width: 100%;
justify-content: space-between;
padding-top: 6px;
border-top: 1px solid color-mix(in srgb, var(--dashboard-frame-border) 50%, transparent);
margin-top: 6px;
}
.editor-item__action-label {
margin: 0;
font-size: 0.74rem;
color: var(--mat-sys-on-surface-variant);
text-transform: uppercase;
letter-spacing: 0.06em;
font-weight: 600;
}
.widget-grid {
display: grid;
grid-template-columns: repeat(12, minmax(0, 1fr));
gap: var(--widget-grid-gap);
align-items: stretch;
position: relative;
//padding: 12px;
min-width: 0;
overflow-x: clip;
}
.widget-grid--editing::before {
content: '';
position: absolute;
inset: 12px;
pointer-events: none;
background-image: repeating-linear-gradient(
to right,
color-mix(in srgb, var(--dashboard-frame-border) 60%, transparent) 0,
color-mix(in srgb, var(--dashboard-frame-border) 60%, transparent) 1px,
transparent 1px,
transparent calc(100% / 12)
);
opacity: 0.5;
z-index: 0;
}
.widget-slot {
min-width: 0;
min-height: 0;
border: 1px solid var(--dashboard-frame-border);
border-radius: 20px;
background: color-mix(
in srgb,
var(--mat-sys-surface-container-high) 80%,
var(--dashboard-frame-tint) 20%
);
padding: 3px;
box-sizing: border-box;
box-shadow:
inset 0 0 0 1px color-mix(in srgb, var(--dashboard-frame-border) 64%, transparent),
0 14px 24px -22px var(--dashboard-frame-glow);
overflow: hidden;
position: relative;
transition:
transform 0.2s ease,
box-shadow 0.2s ease,
border-color 0.2s ease;
contain: layout style;
z-index: 1;
height: var(--dashboard-widget-height);
// Suppress the inner mat-card outlined border — slot provides the only visible border
--mat-card-outline-width: 0;
}
.widget-slot:hover {
//transform: translateY(-1px);
box-shadow:
inset 0 0 0 1px color-mix(in srgb, var(--dashboard-frame-border) 52%, transparent),
0 18px 28px -20px var(--dashboard-frame-glow);
}
// Content-light widgets: collapse to their natural height instead of the fixed row height
.widget-slot[data-widget-type='source-stats'],
.widget-slot[data-widget-type='continue-watching'] {
height: auto;
align-self: start;
}
.widget-slot--editing {
cursor: grab;
// Push mat-card content below the floating inline editor bar
padding-top: 52px;
}
.widget-slot--editing:active {
cursor: grabbing;
}
.widget-slot--editing app-dashboard-widget-host {
pointer-events: none;
user-select: none;
}
.widget-inline-editor {
position: absolute;
top: 8px;
left: 8px;
right: 8px;
z-index: 4;
display: flex;
align-items: center;
gap: 8px;
border: 1px solid var(--dashboard-frame-border);
border-radius: 12px;
background: color-mix(
in srgb,
var(--mat-sys-surface-container-high) 92%,
var(--dashboard-accent) 8%
);
padding: 4px 6px;
}
.drag-handle {
color: var(--mat-sys-on-surface-variant);
flex-shrink: 0;
}
// ── Size stepper ─────────────────────────────────────────────────────────────
.size-stepper {
display: flex;
align-items: center;
gap: 0;
margin-left: auto;
background: var(--mat-sys-surface-container);
border: 1px solid var(--mat-sys-outline-variant);
border-radius: 8px;
overflow: hidden;
flex-shrink: 0;
}
.size-stepper__btn {
width: 32px !important;
height: 32px !important;
border-radius: 0 !important;
color: var(--mat-sys-on-surface-variant);
mat-icon {
font-size: 18px;
width: 18px;
height: 18px;
}
&[disabled] {
opacity: 0.3;
}
}
.size-stepper__label {
font-size: 0.75rem;
font-weight: 600;
color: var(--mat-sys-on-surface);
min-width: 26px;
text-align: center;
letter-spacing: 0.02em;
border-left: 1px solid var(--mat-sys-outline-variant);
border-right: 1px solid var(--mat-sys-outline-variant);
padding: 0 2px;
line-height: 32px;
height: 32px;
display: inline-block;
}
.widget-inline-editor__hide {
color: var(--mat-sys-on-surface-variant);
flex-shrink: 0;
}
.widget-slot app-dashboard-widget-host {
display: block;
height: 100%;
}
.widget-slot--one-third {
grid-column: span 4;
}
.widget-slot--half {
grid-column: span 6;
}
.widget-slot--two-thirds {
grid-column: span 8;
}
.widget-slot--full {
grid-column: span 12;
}
.empty-state {
grid-column: 1 / -1;
border: 1px dashed var(--mat-sys-outline);
border-radius: 14px;
background: var(--mat-sys-surface-container-low);
padding: 20px;
z-index: 1;
}
.empty-state h3 {
margin: 0 0 8px;
}
.empty-state p {
margin: 0;
color: var(--mat-sys-on-surface-variant);
}
:host ::ng-deep .cdk-drag-preview {
border-radius: 20px;
border: 1px solid var(--dashboard-frame-border);
box-shadow: 0 24px 40px -20px var(--dashboard-frame-glow);
background: var(--mat-sys-surface-container-high);
}
:host ::ng-deep .cdk-drop-list-dragging .widget-slot:not(.cdk-drag-placeholder) {
transition: transform 0.2s ease;
}
:host ::ng-deep .widget-slot.cdk-drag-placeholder {
border: 1px dashed var(--dashboard-frame-border);
background: color-mix(in srgb, var(--dashboard-frame-tint) 72%, transparent);
}
@media (max-width: 1180px) {
.dashboard-editor-shell--editing {
grid-template-columns: minmax(0, 1fr) 340px;
}
.widget-slot--one-third,
.widget-slot--half,
.widget-slot--two-thirds {
grid-column: span 6;
}
}
@media (max-width: 860px) {
:host {
padding: 14px;
}
.dashboard-topbar--editing {
flex-wrap: wrap;
height: auto;
padding: 8px 8px;
}
.dashboard-editor-shell--editing {
grid-template-columns: 1fr;
}
.widget-library {
border-left: 0;
border-top: 1px solid var(--dashboard-frame-border);
max-height: none;
position: static;
box-shadow: none;
overflow: visible;
}
.widget-inline-editor {
gap: 4px;
}
.size-stepper__label {
display: none;
}
.widget-grid {
grid-template-columns: 1fr;
}
.widget-slot {
height: 480px;
}
.widget-slot--one-third,
.widget-slot--half,
.widget-slot--two-thirds,
.widget-slot--full {
grid-column: span 1;
}
}
@@ -1,150 +0,0 @@
import {
CdkDragDrop,
DragDropModule,
moveItemInArray,
} from '@angular/cdk/drag-drop';
import { Component, computed, inject, signal } from '@angular/core';
import { toSignal } from '@angular/core/rxjs-interop';
import { MatButtonModule } from '@angular/material/button';
import { MatIcon } from '@angular/material/icon';
import { MatSlideToggleModule } from '@angular/material/slide-toggle';
import { MatTooltip } from '@angular/material/tooltip';
import { TranslatePipe, TranslateService } from '@ngx-translate/core';
import { startWith } from 'rxjs';
import {
DashboardLayoutService,
DashboardWidgetConfig,
DashboardWidgetSize,
} from 'workspace-dashboard-data-access';
import { DashboardWidgetHostComponent } from 'workspace-dashboard-ui';
@Component({
selector: 'app-workspace-dashboard',
imports: [
DashboardWidgetHostComponent,
DragDropModule,
MatButtonModule,
MatIcon,
MatSlideToggleModule,
MatTooltip,
TranslatePipe,
],
templateUrl: './workspace-dashboard.component.html',
styleUrl: './workspace-dashboard.component.scss',
})
export class WorkspaceDashboardComponent {
private readonly translate = inject(TranslateService);
readonly editMode = signal(false);
private readonly languageTick = toSignal(
this.translate.onLangChange.pipe(startWith(null)),
{ initialValue: null }
);
private readonly sizeOrder: DashboardWidgetSize[] = [
'one-third',
'half',
'two-thirds',
'full',
];
readonly allWidgets = computed(() =>
[...this.layoutService.state().widgets].sort(
(a, b) => a.order - b.order
)
);
readonly visibleWidgets = computed(() =>
this.allWidgets().filter((widget) => widget.enabled)
);
constructor(readonly layoutService: DashboardLayoutService) {
// Data reload is handled reactively by DashboardDataService
// via Router NavigationEnd events
}
toggleEditMode(): void {
this.editMode.update((value) => !value);
}
onWidgetDrop(event: CdkDragDrop<DashboardWidgetConfig[]>): void {
if (event.previousIndex === event.currentIndex) {
return;
}
const reordered = [...this.visibleWidgets()];
moveItemInArray(reordered, event.previousIndex, event.currentIndex);
this.layoutService.reorderVisibleWidgets(
reordered.map((widget) => widget.id)
);
}
setWidgetSize(widgetId: string, size: unknown): void {
if (
size === 'one-third' ||
size === 'half' ||
size === 'two-thirds' ||
size === 'full'
) {
this.layoutService.setWidgetSize(widgetId, size);
}
}
cycleWidgetSize(widgetId: string, dir: 1 | -1): void {
const widget = this.allWidgets().find((w) => w.id === widgetId);
if (!widget) return;
const idx = this.sizeOrder.indexOf(widget.size);
const next = this.sizeOrder[idx + dir];
if (next) this.layoutService.setWidgetSize(widgetId, next);
}
getSizeLabel(size: DashboardWidgetSize): string {
this.languageTick();
const labels: Record<DashboardWidgetSize, string> = {
'one-third': '1/3',
half: '1/2',
'two-thirds': '2/3',
full: this.translateText('WORKSPACE.DASHBOARD.SIZE_FULL'),
};
return labels[size];
}
isFirstSize(size: DashboardWidgetSize): boolean {
return this.sizeOrder.indexOf(size) === 0;
}
isLastSize(size: DashboardWidgetSize): boolean {
return this.sizeOrder.indexOf(size) === this.sizeOrder.length - 1;
}
hideWidget(widgetId: string): void {
this.layoutService.toggleWidget(widgetId);
}
getWidgetTitle(widget: DashboardWidgetConfig): string {
this.languageTick();
return this.translateText(widget.title);
}
getWidgetDescription(widget: DashboardWidgetConfig): string {
this.languageTick();
return this.translateText(widget.description);
}
getDragWidgetLabel(widget: DashboardWidgetConfig): string {
return this.translateText('WORKSPACE.DASHBOARD.DRAG_WIDGET', {
title: this.getWidgetTitle(widget),
});
}
getHideWidgetLabel(widget: DashboardWidgetConfig): string {
return this.translateText('WORKSPACE.DASHBOARD.HIDE_WIDGET', {
title: this.getWidgetTitle(widget),
});
}
private translateText(
key: string,
params?: Record<string, string | number>
): string {
return this.translate.instant(key, params);
}
}
-3
View File
@@ -1,3 +0,0 @@
# ui
This library was generated with [Nx](https://nx.dev).
@@ -1,34 +0,0 @@
import nx from '@nx/eslint-plugin';
import baseConfig from '../../../../eslint.config.mjs';
export default [
...baseConfig,
...nx.configs['flat/angular'],
...nx.configs['flat/angular-template'],
{
files: ['**/*.ts'],
rules: {
'@angular-eslint/directive-selector': [
'error',
{
type: 'attribute',
prefix: 'lib',
style: 'camelCase',
},
],
'@angular-eslint/component-selector': [
'error',
{
type: 'element',
prefix: 'lib',
style: 'kebab-case',
},
],
},
},
{
files: ['**/*.html'],
// Override or add rules here
rules: {},
},
];
@@ -1,21 +0,0 @@
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',
],
};
-21
View File
@@ -1,21 +0,0 @@
{
"name": "workspace-dashboard-ui",
"$schema": "../../../../node_modules/nx/schemas/project-schema.json",
"sourceRoot": "libs/workspace/dashboard/ui/src",
"prefix": "lib",
"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"
}
}
}
-1
View File
@@ -1 +0,0 @@
export * from './lib/dashboard-widget-host.component';
@@ -1,25 +0,0 @@
@switch (widget().type) {
@case ('source-stats') {
<app-source-stats-widget />
}
@case ('continue-watching') {
<app-continue-watching-widget />
}
@case ('recently-watched') {
<app-recently-watched-widget [widget]="widget()" />
}
@case ('recently-added') {
<app-recently-added-widget [widget]="widget()" />
}
@case ('global-favorites') {
<app-global-favorites-widget [widget]="widget()" />
}
@default {
<section class="unsupported-widget">
<h3>{{ 'WORKSPACE.DASHBOARD.UNSUPPORTED_WIDGET' | translate }}</h3>
<p>
{{ 'WORKSPACE.DASHBOARD.UNSUPPORTED_WIDGET_DESC' | translate }}
</p>
</section>
}
}
@@ -1,37 +0,0 @@
:host {
display: block;
height: 100%;
}
.unsupported-widget {
border: 1px dashed var(--mat-sys-outline-variant);
border-radius: 14px;
padding: 20px;
background: linear-gradient(
160deg,
var(--mat-sys-surface-container-high) 0%,
var(--mat-sys-surface-container-low) 100%
);
height: 100%;
box-sizing: border-box;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
text-align: center;
gap: 8px;
color: var(--mat-sys-on-surface-variant);
h3 {
margin: 0;
font-size: 0.94rem;
font-weight: 600;
}
p {
margin: 0;
font-size: 0.82rem;
max-width: 260px;
line-height: 1.4;
}
}
@@ -1,25 +0,0 @@
import { Component, input } from '@angular/core';
import { TranslatePipe } from '@ngx-translate/core';
import { DashboardWidgetConfig } from 'workspace-dashboard-data-access';
import { ContinueWatchingWidgetComponent } from './widgets/continue-watching-widget.component';
import { GlobalFavoritesWidgetComponent } from './widgets/global-favorites-widget.component';
import { RecentlyAddedWidgetComponent } from './widgets/recently-added-widget.component';
import { RecentlyWatchedWidgetComponent } from './widgets/recently-watched-widget.component';
import { SourceStatsWidgetComponent } from './widgets/source-stats-widget.component';
@Component({
selector: 'app-dashboard-widget-host',
imports: [
ContinueWatchingWidgetComponent,
GlobalFavoritesWidgetComponent,
RecentlyAddedWidgetComponent,
RecentlyWatchedWidgetComponent,
SourceStatsWidgetComponent,
TranslatePipe,
],
templateUrl: './dashboard-widget-host.component.html',
styleUrl: './dashboard-widget-host.component.scss',
})
export class DashboardWidgetHostComponent {
readonly widget = input.required<DashboardWidgetConfig>();
}
@@ -1,103 +0,0 @@
<app-dashboard-widget-shell
[eyebrow]="'WORKSPACE.DASHBOARD.FOCUS' | translate"
[title]="'WORKSPACE.DASHBOARD.CONTINUE_WATCHING' | translate"
>
@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)) {
<img
[src]="item.poster_url"
[alt]="item.title"
loading="lazy"
/>
} @else {
<span class="hero__thumb-placeholder">
<mat-icon>{{ typeIcon(item.type) }}</mat-icon>
</span>
}
</span>
<!-- Meta -->
<span class="hero__body">
<span
class="hero__type-badge hero__type-badge--{{ item.type }}"
>
<mat-icon>{{ typeIcon(item.type) }}</mat-icon>
{{ typeKey(item.type) | translate }}
</span>
<span class="hero__title" [attr.title]="item.title">{{
item.title
}}</span>
@if (item.playlist_name) {
<span class="hero__playlist">{{ item.playlist_name }}</span>
}
<span class="hero__time">{{
relativeTime(item.viewed_at)
}}</span>
</span>
<!-- Play CTA -->
<span class="hero__play" aria-hidden="true">
<mat-icon>play_circle</mat-icon>
</span>
</a>
} @else {
<article class="focus-empty">
<div class="focus-empty__content">
<mat-icon class="focus-empty__icon"
>play_circle_outline</mat-icon
>
<div class="focus-empty__text">
<p class="focus-empty__title">
{{ 'WORKSPACE.DASHBOARD.NO_HISTORY_CW' | translate }}
</p>
<p class="focus-empty__hint">
{{
'WORKSPACE.DASHBOARD.NO_HISTORY_CW_HINT' | translate
}}
</p>
</div>
</div>
<a mat-stroked-button routerLink="/workspace/sources">
<mat-icon>add</mat-icon>
{{ 'WORKSPACE.DASHBOARD.ADD_SOURCE' | translate }}
</a>
</article>
}
@if (data.quickRecent().length > 0) {
<div class="quick-list">
<p class="quick-list__label">
{{ 'WORKSPACE.DASHBOARD.RECENTLY_USED' | translate }}
</p>
@for (item of data.quickRecent(); track item._id) {
<a class="quick-item" [routerLink]="data.getPlaylistLink(item)">
<span
class="quick-item__title"
[attr.title]="item.title || item.filename"
>
{{
item.title ||
item.filename ||
('WORKSPACE.DASHBOARD.UNTITLED_SOURCE'
| translate)
}}
</span>
<span class="quick-item__meta">{{
data.getPlaylistProvider(item)
}}</span>
</a>
}
</div>
}
</app-dashboard-widget-shell>
@@ -1,315 +0,0 @@
:host {
// Deep-resume accent — vivid green
--app-eyebrow-color: oklch(60% 0.18 155);
--app-widget-border: color-mix(
in srgb,
oklch(60% 0.18 155) 22%,
var(--mat-sys-outline-variant) 78%
);
// Per-type accent vars (used by hero thumb + badge)
--_live-accent: oklch(58% 0.21 25);
--_movie-accent: oklch(60% 0.17 295);
--_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 {
display: flex;
align-items: center;
gap: 14px;
padding: 12px;
border-radius: 14px;
border: 1px solid var(--mat-sys-outline-variant);
background: var(--mat-sys-surface-container-low);
text-decoration: none;
color: inherit;
cursor: pointer;
transition:
background-color 0.18s ease,
color 0.18s ease;
min-width: 0;
&:hover {
background: var(--app-selection-surface);
.hero__play mat-icon {
color: var(--app-selection-color);
transform: scale(1.1);
}
}
&:focus-visible {
outline: 2px solid var(--mat-sys-primary);
outline-offset: 2px;
}
}
// ── Thumbnail ─────────────────────────────────────────────────────────────────
.hero__thumb {
flex-shrink: 0;
border-radius: 10px;
overflow: hidden;
border: 1px solid var(--mat-sys-outline-variant);
display: flex;
align-items: center;
justify-content: center;
background: var(--mat-sys-surface-container-highest);
}
// Live: square logo
.hero__thumb--live {
width: 72px;
height: 72px;
}
// Movie: portrait poster 2:3
.hero__thumb--movie {
width: 56px;
height: 84px;
border-radius: 10px;
}
// Series: use poster dimensions like movies to avoid cropped covers.
.hero__thumb--series {
width: 56px;
height: 84px;
border-radius: 10px;
}
.hero__thumb img {
width: 100%;
height: 100%;
object-fit: cover;
display: block;
}
.hero__thumb--live img {
object-fit: contain;
padding: 10px;
box-sizing: border-box;
}
.hero__thumb-placeholder {
display: flex;
align-items: center;
justify-content: center;
width: 100%;
height: 100%;
mat-icon {
font-size: 24px;
width: 24px;
height: 24px;
}
}
.hero__thumb--live .hero__thumb-placeholder {
background: linear-gradient(135deg,
color-mix(in srgb, var(--_live-accent) 14%, transparent),
color-mix(in srgb, var(--_live-accent) 2%, transparent));
color: var(--_live-accent);
}
.hero__thumb--movie .hero__thumb-placeholder {
background: linear-gradient(135deg,
color-mix(in srgb, var(--_movie-accent) 14%, transparent),
color-mix(in srgb, var(--_movie-accent) 2%, transparent));
color: var(--_movie-accent);
}
.hero__thumb--series .hero__thumb-placeholder {
background: linear-gradient(135deg,
color-mix(in srgb, var(--_series-accent) 14%, transparent),
color-mix(in srgb, var(--_series-accent) 2%, transparent));
color: var(--_series-accent);
}
// ── Body ─────────────────────────────────────────────────────────────────────
.hero__body {
display: flex;
flex-direction: column;
gap: 3px;
min-width: 0;
flex: 1;
}
.hero__type-badge {
display: inline-flex;
align-items: center;
gap: 4px;
font-size: 0.68rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.08em;
width: fit-content;
mat-icon {
font-size: 14px;
width: 14px;
height: 14px;
}
}
.hero__type-badge--live { color: var(--_live-accent); }
.hero__type-badge--movie { color: var(--_movie-accent); }
.hero__type-badge--series { color: var(--_series-accent); }
.hero__title {
font-size: 0.9rem;
font-weight: 500;
color: var(--mat-sys-on-surface);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
line-height: 1.3;
}
.hero__playlist {
font-size: 0.73rem;
color: var(--mat-sys-on-surface-variant);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.hero__time {
font-size: 0.68rem;
color: color-mix(in srgb, var(--mat-sys-on-surface-variant) 60%, transparent);
}
// ── Play icon ─────────────────────────────────────────────────────────────────
.hero__play {
flex-shrink: 0;
display: flex;
align-items: center;
mat-icon {
font-size: 32px;
width: 32px;
height: 32px;
color: color-mix(in srgb, var(--mat-sys-on-surface) 30%, transparent);
transition: color 0.15s ease, transform 0.15s ease;
}
}
// ── Empty state ───────────────────────────────────────────────────────────────
.focus-empty {
border: 1px dashed color-mix(in srgb, var(--app-eyebrow-color) 30%, var(--mat-sys-outline-variant) 70%);
background: radial-gradient(
ellipse at 0% 50%,
color-mix(in srgb, var(--app-eyebrow-color) 6%, transparent),
transparent 70%
);
border-radius: 14px;
padding: 16px 20px;
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
flex-wrap: wrap;
}
.focus-empty__content {
display: flex;
align-items: center;
gap: 16px;
}
.focus-empty__icon {
font-size: 28px;
width: 28px;
height: 28px;
color: var(--app-eyebrow-color);
opacity: 0.8;
background: color-mix(in srgb, var(--app-eyebrow-color) 12%, transparent);
padding: 12px;
border-radius: 50%;
box-sizing: content-box;
flex-shrink: 0;
}
.focus-empty__text {
display: flex;
flex-direction: column;
gap: 4px;
}
.focus-empty__title {
margin: 0;
font-weight: 500;
font-size: 0.9rem;
color: var(--mat-sys-on-surface);
}
.focus-empty__hint {
margin: 0;
font-size: 0.75rem;
color: var(--mat-sys-on-surface-variant);
max-width: 260px;
line-height: 1.4;
}
// ── Quick playlist list ───────────────────────────────────────────────────────
.quick-list {
display: flex;
flex-direction: column;
gap: 6px;
}
.quick-list__label {
margin: 0;
font-size: 0.73rem;
color: var(--mat-sys-on-surface-variant);
letter-spacing: 0.06em;
text-transform: uppercase;
font-weight: 600;
}
.quick-item {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
border: 1px solid var(--mat-sys-outline-variant);
border-radius: 10px;
background: var(--mat-sys-surface-container-low);
padding: 7px 10px;
text-decoration: none;
color: inherit;
min-width: 0;
transition:
background-color 0.18s ease,
color 0.18s ease;
&:hover {
background: var(--app-selection-surface);
}
}
.quick-item__title {
min-width: 0;
font-size: 0.82rem;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.quick-item__meta {
flex-shrink: 0;
font-size: 0.72rem;
color: var(--mat-sys-on-surface-variant);
}
@@ -1,48 +0,0 @@
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();
});
});
@@ -1,94 +0,0 @@
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';
import {
DashboardDataService,
GlobalRecentItem,
} from 'workspace-dashboard-data-access';
import { DashboardWidgetShellComponent } from './dashboard-widget-shell.component';
@Component({
selector: 'app-continue-watching-widget',
imports: [
MatButtonModule,
MatIcon,
MatProgressSpinnerModule,
RouterLink,
DashboardWidgetShellComponent,
TranslatePipe,
],
templateUrl: './continue-watching-widget.component.html',
styleUrl: './continue-watching-widget.component.scss',
})
export class ContinueWatchingWidgetComponent {
readonly data = inject(DashboardDataService);
private readonly translate = inject(TranslateService);
private readonly languageTick = toSignal(
this.translate.onLangChange.pipe(startWith(null)),
{ initialValue: null }
);
/** The most recently watched item across all sources. */
readonly lastItem = computed<GlobalRecentItem | null>(
() => this.data.globalRecentItems()[0] ?? null
);
hasImage(item: GlobalRecentItem): boolean {
return !!item.poster_url;
}
getLink(item: GlobalRecentItem): string[] {
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';
return 'video_library';
}
typeKey(type: GlobalRecentItem['type']): string {
if (type === 'live') return 'WORKSPACE.DASHBOARD.TYPE_LIVE';
if (type === 'movie') return 'WORKSPACE.DASHBOARD.TYPE_MOVIE';
return 'WORKSPACE.DASHBOARD.TYPE_SERIES';
}
relativeTime(dateStr: string): string {
this.languageTick();
const date = new Date(dateStr);
if (Number.isNaN(date.getTime())) {
return this.translate.instant('WORKSPACE.DASHBOARD.RECENTLY');
}
const diffMinutes = Math.floor((Date.now() - date.getTime()) / 60_000);
if (diffMinutes < 1) {
return this.translate.instant('WORKSPACE.DASHBOARD.JUST_NOW');
}
if (diffMinutes < 60) {
return this.translate.instant('WORKSPACE.DASHBOARD.MINUTES_AGO', {
count: diffMinutes,
});
}
const diffHours = Math.floor(diffMinutes / 60);
if (diffHours < 24) {
return this.translate.instant('WORKSPACE.DASHBOARD.HOURS_AGO', {
count: diffHours,
});
}
return this.translate.instant('WORKSPACE.DASHBOARD.DAYS_AGO', {
count: Math.floor(diffHours / 24),
});
}
}
@@ -1,93 +0,0 @@
@if (items().length > 0) {
<div
class="activity-items"
[class.activity-items--grid]="viewMode() === 'grid'"
(scroll)="onItemsScroll($event)"
>
@for (item of items(); track item.id) {
<a
class="activity-item"
[routerLink]="item.link"
[state]="item.navigationState"
>
<span
class="activity-item__media"
[class.activity-item__media--live]="item.type === 'live'"
[class.activity-item__media--movie]="item.type === 'movie'"
[class.activity-item__media--series]="
item.type === 'series'
"
[class.activity-item__media--poster]="item.type !== 'live'"
[class.is-placeholder]="!hasImage(item)"
[class.is-placeholder-live]="
!hasImage(item) && item.type === 'live'
"
[class.is-placeholder-movie]="
!hasImage(item) && item.type === 'movie'
"
[class.is-placeholder-series]="
!hasImage(item) && item.type === 'series'
"
>
@if (hasImage(item)) {
<img
[src]="item.imageUrl"
[alt]="item.title"
loading="lazy"
(error)="markImageAsFailed(item.id)"
/>
} @else {
<mat-icon>{{ getFallbackIcon(item) }}</mat-icon>
}
</span>
<span class="activity-item__content">
<span
class="activity-item__title"
[attr.title]="item.title"
>
{{ item.title }}
</span>
<span
class="activity-item__subtitle"
[attr.title]="item.subtitle"
>
{{ item.subtitle }}
</span>
</span>
@if (showRemoveAction()) {
<button
type="button"
class="activity-item__action-btn"
(click)="onActionClick($event, item)"
[attr.aria-label]="removeActionLabel()"
[title]="removeActionLabel()"
>
<mat-icon>close</mat-icon>
</button>
}
</a>
}
</div>
} @else {
<div class="empty-state" [class.empty-state--grid]="viewMode() === 'grid'">
@if (emptyIcon()) {
<mat-icon class="empty-state__icon">{{ emptyIcon() }}</mat-icon>
}
<p class="empty-state__title">{{ emptyLabel() }}</p>
@if (emptyHint()) {
<p class="empty-state__hint">{{ emptyHint() }}</p>
}
@if (emptyActionLink() && emptyActionLabel()) {
<a
mat-stroked-button
class="empty-state__action"
[routerLink]="emptyActionLink()"
>
<mat-icon>add</mat-icon>
{{ emptyActionLabel() }}
</a>
}
</div>
}
@@ -1,341 +0,0 @@
:host {
display: flex;
flex-direction: column;
min-height: 0;
flex: 1;
position: relative;
--activity-fade-end: var(--app-widget-bg, var(--mat-sys-surface-container));
}
:host-context(body.dark-theme) {
--activity-fade-end: var(--app-widget-bg, var(--mat-sys-surface-container));
}
:host::after {
content: '';
position: absolute;
left: 0;
right: 0;
bottom: 0;
height: 76px;
pointer-events: none;
z-index: 3;
background: linear-gradient(
180deg,
rgba(0, 0, 0, 0) 0%,
color-mix(in srgb, var(--activity-fade-end) 42%, transparent) 45%,
var(--activity-fade-end) 100%
);
}
.activity-items {
display: flex;
flex-direction: column;
gap: 8px;
flex: 1;
min-height: 0;
overflow: auto;
position: relative;
z-index: 1;
}
.activity-item {
text-decoration: none;
color: inherit;
border: 1px solid var(--mat-sys-outline-variant);
border-radius: 11px;
background: linear-gradient(
180deg,
var(--mat-sys-surface-container-low) 0%,
var(--mat-sys-surface-container) 100%
);
padding: 8px;
display: grid;
grid-template-columns: auto minmax(0, 1fr) auto;
align-items: center;
gap: 10px;
transition:
background-color 0.18s ease,
color 0.18s ease;
}
.activity-item__action-btn {
display: flex;
align-items: center;
justify-content: center;
width: 28px;
height: 28px;
border-radius: 50%;
border: none;
background: transparent;
color: var(--mat-sys-on-surface-variant);
cursor: pointer;
opacity: 0;
transition: opacity 0.2s ease, background-color 0.2s ease, color 0.2s ease;
mat-icon {
font-size: 18px;
width: 18px;
height: 18px;
}
}
.activity-item:hover .activity-item__action-btn {
opacity: 0.6;
}
.activity-item__action-btn:hover {
opacity: 1 !important;
background: var(--mat-sys-surface-container-highest);
color: var(--mat-sys-on-surface);
}
.activity-item:hover {
background: var(--app-selection-surface);
}
.activity-item__media {
// Default: square for channels/live
width: 56px;
height: 56px;
flex-shrink: 0;
border-radius: 12px;
overflow: hidden;
background: var(--mat-sys-surface-container-highest);
display: inline-flex;
align-items: center;
justify-content: center;
color: var(--mat-sys-on-surface-variant);
border: 1px solid var(--mat-sys-outline-variant);
}
// Movie: portrait poster 2:3
.activity-item__media--movie {
width: 42px;
height: 63px;
border-radius: 9px;
}
// Series: use the same poster ratio as movies to avoid cover cropping.
.activity-item__media--series {
width: 42px;
height: 63px;
border-radius: 9px;
}
.activity-item__media img {
width: 100%;
height: 100%;
object-fit: cover;
display: block;
}
.activity-item__media--live img {
object-fit: contain;
padding: 8px;
box-sizing: border-box;
}
.activity-item__media mat-icon {
font-size: 1.15rem;
width: 1.15rem;
height: 1.15rem;
}
// Placeholder tints use oklch() matching the stat-tile accent approach
.is-placeholder-live {
--_accent: oklch(66% 0.17 210); // sky-blue: live TV
background: linear-gradient(135deg,
color-mix(in srgb, var(--_accent) 14%, transparent),
color-mix(in srgb, var(--_accent) 2%, transparent)
) !important;
color: var(--_accent) !important;
border-color: color-mix(in srgb, var(--_accent) 22%, var(--mat-sys-outline-variant) 78%) !important;
}
.is-placeholder-movie {
--_accent: oklch(60% 0.17 295); // violet: movies
background: linear-gradient(135deg,
color-mix(in srgb, var(--_accent) 14%, transparent),
color-mix(in srgb, var(--_accent) 2%, transparent)
) !important;
color: var(--_accent) !important;
border-color: color-mix(in srgb, var(--_accent) 22%, var(--mat-sys-outline-variant) 78%) !important;
}
.is-placeholder-series {
--_accent: oklch(68% 0.16 55); // amber: series
background: linear-gradient(135deg,
color-mix(in srgb, var(--_accent) 14%, transparent),
color-mix(in srgb, var(--_accent) 2%, transparent)
) !important;
color: var(--_accent) !important;
border-color: color-mix(in srgb, var(--_accent) 22%, var(--mat-sys-outline-variant) 78%) !important;
}
.is-placeholder mat-icon {
opacity: 0.9;
}
.activity-item__content {
min-width: 0;
display: flex;
flex-direction: column;
gap: 2px;
}
.activity-item__title {
font-size: 0.85rem;
line-height: 1.25;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.activity-item__subtitle {
font-size: 0.73rem;
color: var(--mat-sys-on-surface-variant);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.activity-items--grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(136px, 1fr));
gap: 7px;
}
.activity-items--grid .activity-item {
grid-template-columns: 1fr;
align-items: stretch;
gap: 5px;
padding: 5px;
position: relative;
}
// In grid mode: float the remove button over the top-right corner
.activity-items--grid .activity-item__action-btn {
position: absolute;
top: 4px;
right: 4px;
width: 24px;
height: 24px;
background: color-mix(in srgb, var(--mat-sys-surface-container-highest) 96%, transparent);
opacity: 0;
mat-icon {
font-size: 15px;
width: 15px;
height: 15px;
}
}
.activity-items--grid .activity-item:hover .activity-item__action-btn {
opacity: 0.75;
}
.activity-items--grid .activity-item__media {
width: 100%;
height: auto;
}
.activity-items--grid .activity-item__media--movie,
.activity-items--grid .activity-item__media--series {
aspect-ratio: 2 / 3;
border-radius: 10px;
}
.activity-items--grid .activity-item__media--live {
aspect-ratio: 1 / 1;
border-radius: 12px;
}
.activity-items--grid .activity-item__media--live img {
padding: 12px;
}
.activity-items--grid .is-placeholder mat-icon {
font-size: 2.8rem !important;
width: 2.8rem !important;
height: 2.8rem !important;
}
.activity-items--grid .activity-item__title {
white-space: normal;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
line-height: 1.16;
font-size: 0.78rem;
}
.activity-items--grid .activity-item__subtitle {
white-space: normal;
display: -webkit-box;
-webkit-line-clamp: 1;
-webkit-box-orient: vertical;
font-size: 0.68rem;
}
.empty-state {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 32px 16px;
text-align: center;
border: 1px dashed var(--mat-sys-outline-variant);
border-radius: 12px;
margin: 8px 0;
color: var(--mat-sys-on-surface-variant);
flex: 1;
background: radial-gradient(
ellipse at 50% 0%,
color-mix(in srgb, var(--mat-sys-primary) 4%, transparent),
transparent 70%
);
&.empty-state--grid {
min-height: 180px;
}
}
.empty-state__icon {
font-size: 40px;
width: 40px;
height: 40px;
margin-bottom: 4px;
color: var(--mat-sys-primary);
opacity: 0.5;
background: color-mix(in srgb, var(--mat-sys-primary) 8%, transparent);
padding: 14px;
border-radius: 50%;
box-sizing: content-box;
}
.empty-state__title {
margin: 8px 0 0;
font-size: 0.875rem;
font-weight: 500;
color: var(--mat-sys-on-surface);
}
.empty-state__hint {
margin: 6px 0 0;
font-size: 0.78rem;
color: var(--mat-sys-on-surface-variant);
max-width: 280px;
line-height: 1.45;
}
.empty-state__action {
margin-top: 16px;
}
@media (max-width: 920px) {
.activity-items--grid {
grid-template-columns: repeat(auto-fill, minmax(120px, 1fr));
}
}
@@ -1,93 +0,0 @@
import { Component, EventEmitter, input, Output, signal } from '@angular/core';
import { MatButton } from '@angular/material/button';
import { MatIcon } from '@angular/material/icon';
import { RouterLink } from '@angular/router';
export interface DashboardActivityItemViewModel {
id: string;
title: string;
subtitle: string;
type: 'live' | 'movie' | 'series';
imageUrl?: string;
link: string[];
navigationState?: Record<string, unknown>;
}
type DashboardActivityViewMode = 'list' | 'grid';
@Component({
selector: 'app-dashboard-activity-items',
imports: [MatButton, MatIcon, RouterLink],
templateUrl: './dashboard-activity-items.component.html',
styleUrl: './dashboard-activity-items.component.scss',
})
export class DashboardActivityItemsComponent {
@Output() readonly scrolledToEnd = new EventEmitter<void>();
readonly items = input.required<DashboardActivityItemViewModel[]>();
readonly emptyLabel = input('');
readonly emptyIcon = input<string>();
readonly emptyHint = input<string>();
readonly emptyActionLabel = input<string>();
readonly emptyActionLink = input<string[]>();
readonly viewMode = input<DashboardActivityViewMode>('list');
readonly showRemoveAction = input(false);
readonly removeActionLabel = input('');
@Output() readonly itemAction =
new EventEmitter<DashboardActivityItemViewModel>();
readonly failedImages = signal<Record<string, true>>({});
hasImage(item: DashboardActivityItemViewModel): boolean {
if (!item.imageUrl) {
return false;
}
return !this.failedImages()[item.id];
}
markImageAsFailed(itemId: string): void {
this.failedImages.update((state) => {
if (state[itemId]) {
return state;
}
return {
...state,
[itemId]: true,
};
});
}
getFallbackIcon(item: DashboardActivityItemViewModel): string {
if (item.type === 'live') {
return 'live_tv';
}
if (item.type === 'series') {
return 'movie_filter';
}
return 'movie';
}
onItemsScroll(event: Event): void {
const target = event.target as HTMLElement | null;
if (!target) {
return;
}
const remaining =
target.scrollHeight - target.scrollTop - target.clientHeight;
if (remaining <= 120) {
this.scrolledToEnd.emit();
}
}
onActionClick(event: Event, item: DashboardActivityItemViewModel): void {
event.preventDefault();
event.stopPropagation();
this.itemAction.emit(item);
}
}
@@ -1,14 +0,0 @@
<mat-card class="widget-shell" appearance="outlined">
<header class="widget-shell__header">
<div class="widget-shell__headline">
<p class="widget-shell__eyebrow">{{ eyebrow() }}</p>
<h3 class="widget-shell__title">{{ title() }}</h3>
</div>
<div class="widget-shell__actions">
<ng-content select="[widget-actions]" />
</div>
</header>
<div class="widget-shell__content">
<ng-content />
</div>
</mat-card>
@@ -1,79 +0,0 @@
:host {
display: block;
height: 100%;
}
.widget-shell {
border-radius: 16px;
background: var(--app-widget-bg, var(--mat-sys-surface-container));
border: 1px solid var(--app-widget-border, transparent);
box-shadow: var(--app-widget-shadow, none);
display: flex;
flex-direction: column;
height: 100%;
box-sizing: border-box;
overflow: hidden;
// Subtle inset top-edge highlight — catches light like a raised surface
outline: 1px solid rgba(255, 255, 255, 0.04);
outline-offset: -1px;
}
.widget-shell__header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 14px 16px 10px;
flex-shrink: 0;
background: var(--app-widget-header-bg, var(--mat-sys-surface-container-high));
border-bottom: 1px solid var(--app-widget-header-border, rgba(255, 255, 255, 0.05));
}
.widget-shell__headline {
min-width: 0;
align-self: flex-start;
}
.widget-shell__eyebrow {
margin: 0;
color: var(--app-eyebrow-color, var(--mat-sys-on-surface-variant));
text-transform: uppercase;
letter-spacing: 0.1em;
font-size: 0.72rem;
font-weight: 600;
}
.widget-shell__title {
margin: 4px 0 0;
font-size: 1rem;
font-weight: 600;
line-height: 1.2;
color: var(--app-heading-color, var(--mat-sys-on-surface));
letter-spacing: -0.01em;
}
.widget-shell__actions {
display: inline-flex;
align-items: center;
gap: 8px;
min-height: 28px;
}
.widget-shell__actions:empty {
display: none;
}
.widget-shell__content {
display: flex;
flex-direction: column;
gap: 12px;
min-height: 0;
flex: 1;
padding: 5px 16px 12px;
box-sizing: border-box;
overflow-y: auto;
overflow-x: hidden;
scrollbar-gutter: stable;
scrollbar-width: thin;
scrollbar-color: color-mix(in srgb, var(--mat-sys-on-surface) 12%, transparent) transparent;
}
@@ -1,13 +0,0 @@
import { Component, input } from '@angular/core';
import { MatCardModule } from '@angular/material/card';
@Component({
selector: 'app-dashboard-widget-shell',
imports: [MatCardModule],
templateUrl: './dashboard-widget-shell.component.html',
styleUrl: './dashboard-widget-shell.component.scss',
})
export class DashboardWidgetShellComponent {
readonly eyebrow = input.required<string>();
readonly title = input.required<string>();
}
@@ -1,107 +0,0 @@
<app-dashboard-widget-shell
[eyebrow]="'WORKSPACE.DASHBOARD.LIBRARY' | translate"
[title]="'WORKSPACE.DASHBOARD.GLOBAL_FAVORITES' | translate"
>
<ng-container widget-actions>
<button
mat-icon-button
[matMenuTriggerFor]="filterMenu"
[attr.aria-label]="
'WORKSPACE.DASHBOARD.FILTER_GLOBAL_FAVORITES' | translate
"
[matTooltip]="'WORKSPACE.DASHBOARD.FILTER_ITEMS' | translate"
>
<mat-icon>filter_list</mat-icon>
</button>
<mat-menu #filterMenu="matMenu">
<button mat-menu-item (click)="onKindChange('all')">
<mat-icon>{{
selectedKind() === 'all' ? 'check' : ''
}}</mat-icon>
<span [class.active-filter]="selectedKind() === 'all'"
>{{ 'WORKSPACE.DASHBOARD.ALL' | translate }} ({{
counts().all
}})</span
>
</button>
<button mat-menu-item (click)="onKindChange('channels')">
<mat-icon>{{
selectedKind() === 'channels' ? 'check' : ''
}}</mat-icon>
<span [class.active-filter]="selectedKind() === 'channels'"
>{{ 'WORKSPACE.DASHBOARD.CHANNELS' | translate }} ({{
counts().channels
}})</span
>
</button>
<button mat-menu-item (click)="onKindChange('vod')">
<mat-icon>{{
selectedKind() === 'vod' ? 'check' : ''
}}</mat-icon>
<span [class.active-filter]="selectedKind() === 'vod'"
>{{ 'WORKSPACE.DASHBOARD.VOD' | translate }} ({{
counts().vod
}})</span
>
</button>
<button mat-menu-item (click)="onKindChange('series')">
<mat-icon>{{
selectedKind() === 'series' ? 'check' : ''
}}</mat-icon>
<span [class.active-filter]="selectedKind() === 'series'"
>{{ 'WORKSPACE.DASHBOARD.SERIES' | translate }} ({{
counts().series
}})</span
>
</button>
</mat-menu>
<mat-button-toggle-group
class="view-toggle"
[value]="viewMode()"
hideSingleSelectionIndicator
[attr.aria-label]="
'WORKSPACE.DASHBOARD.GLOBAL_FAVORITES_VIEW_MODE' | translate
"
(change)="onViewModeChange($event.value)"
>
<mat-button-toggle
value="list"
[aria-label]="'WORKSPACE.DASHBOARD.LIST_VIEW' | translate"
>
<mat-icon>view_list</mat-icon>
</mat-button-toggle>
<mat-button-toggle
value="grid"
[aria-label]="'WORKSPACE.DASHBOARD.GRID_VIEW' | translate"
>
<mat-icon>grid_view</mat-icon>
</mat-button-toggle>
</mat-button-toggle-group>
</ng-container>
@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>
@@ -1,47 +0,0 @@
:host {
// Favorites / library tint — amber-gold
--app-eyebrow-color: oklch(60% 0.16 55);
--app-widget-border: color-mix(in srgb, oklch(60% 0.16 55) 22%, var(--mat-sys-outline-variant) 78%);
}
.view-toggle {
border-radius: 8px;
height: 32px;
align-items: center;
border: 1px solid var(--mat-sys-outline-variant);
background: var(--mat-sys-surface-container-high);
mat-icon {
font-size: 18px;
width: 18px;
height: 18px;
line-height: 18px;
}
}
::ng-deep .view-toggle {
.mat-button-toggle-label-content {
line-height: 30px !important;
padding: 0 8px !important;
}
.mat-button-toggle-checked .mat-button-toggle-button {
background-color: var(--mat-sys-secondary-container) !important;
}
.mat-button-toggle-checked mat-icon {
color: var(--mat-sys-on-secondary-container) !important;
}
}
.active-filter {
font-weight: 600;
color: var(--mat-sys-primary);
}
.global-favorites-widget__loading {
min-height: 220px;
display: grid;
place-items: center;
padding: 16px 0;
}
@@ -1,88 +0,0 @@
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,225 +0,0 @@
import {
ChangeDetectionStrategy,
Component,
OnInit,
computed,
inject,
input,
signal,
} from '@angular/core';
import { toSignal } from '@angular/core/rxjs-interop';
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 { formatWithIntl } from '@iptvnator/pipes';
import { TranslatePipe, TranslateService } from '@ngx-translate/core';
import { startWith } from 'rxjs';
import {
DashboardContentKind,
DashboardDataService,
DashboardWidgetConfig,
} from 'workspace-dashboard-data-access';
import {
DashboardActivityItemViewModel,
DashboardActivityItemsComponent,
} from './dashboard-activity-items.component';
import { DashboardWidgetShellComponent } from './dashboard-widget-shell.component';
const GLOBAL_FAVORITES_KIND_STORAGE_KEY =
'workspace-dashboard-global-favorites-kind';
const GLOBAL_FAVORITES_VIEW_MODE_STORAGE_KEY =
'workspace-dashboard-global-favorites-view-mode';
type DashboardActivityViewMode = 'list' | 'grid';
const INITIAL_PAGE_SIZE = 20;
const PAGE_SIZE = 20;
@Component({
selector: 'app-global-favorites-widget',
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [
MatButtonModule,
MatButtonToggleModule,
MatMenuModule,
MatIcon,
MatProgressSpinnerModule,
MatTooltipModule,
DashboardActivityItemsComponent,
DashboardWidgetShellComponent,
TranslatePipe,
],
templateUrl: './global-favorites-widget.component.html',
styleUrl: './global-favorites-widget.component.scss',
})
export class GlobalFavoritesWidgetComponent implements OnInit {
readonly widget = input.required<DashboardWidgetConfig>();
readonly data = inject(DashboardDataService);
private readonly translate = inject(TranslateService);
private readonly languageTick = toSignal(
this.translate.onLangChange.pipe(startWith(null)),
{ initialValue: null }
);
readonly selectedKind = signal<DashboardContentKind>(
this.readStoredKind() ?? 'all'
);
readonly viewMode = signal<DashboardActivityViewMode>(
this.readStoredViewMode() ?? 'list'
);
readonly visibleItemLimit = signal(INITIAL_PAGE_SIZE);
readonly counts = computed(() => {
const items = this.data.globalFavoriteItems();
return {
all: items.length,
channels: items.filter((item) => item.type === 'live').length,
vod: items.filter((item) => item.type === 'movie').length,
series: items.filter((item) => item.type === 'series').length,
};
});
readonly filteredItems = computed(() =>
this.data
.globalFavoriteItems()
.filter((item) =>
this.data.isTypeInKind(item.type, this.selectedKind())
)
);
readonly activityItems = computed<DashboardActivityItemViewModel[]>(() =>
this.filteredItems()
.slice(0, this.visibleItemLimit())
.map((item) => ({
id: `${item.id}-${item.playlist_id}-${item.added_at}`,
title: item.title,
subtitle: `${this.data.getFavoriteItemProviderLabel(item)} · ${this.data.getFavoriteItemTypeLabel(item)} · ${this.formatTimestamp(item.added_at)}`,
type: item.type,
imageUrl: item.poster_url,
link: this.data.getGlobalFavoriteLink(item),
navigationState:
this.data.getGlobalFavoriteNavigationState(item),
}))
);
ngOnInit(): void {
void this.data.reloadGlobalFavorites().then(() => {
this.visibleItemLimit.set(INITIAL_PAGE_SIZE);
});
}
onKindChange(kind: DashboardContentKind): void {
this.setKind(kind);
}
onViewModeChange(value: unknown): void {
if (value === 'list' || value === 'grid') {
this.viewMode.set(value);
this.persistViewMode(value);
}
}
onItemsScrolledToEnd(): void {
if (this.visibleItemLimit() >= this.filteredItems().length) {
return;
}
this.visibleItemLimit.update((value) => value + PAGE_SIZE);
}
onRemoveItem(item: DashboardActivityItemViewModel): void {
const globalItem = this.data
.globalFavoriteItems()
.find((i) => `${i.id}-${i.playlist_id}-${i.added_at}` === item.id);
if (globalItem) {
void this.data.removeGlobalFavorite(globalItem);
} else {
// Fallback strategy
const fallbackItem = this.data
.globalFavoriteItems()
.find(
(i) =>
item.link.includes(String(i.xtream_id)) ||
item.link.includes(i.playlist_id)
);
if (fallbackItem) {
void this.data.removeGlobalFavorite(fallbackItem);
}
}
}
private setKind(value: unknown): void {
if (
value === 'all' ||
value === 'channels' ||
value === 'vod' ||
value === 'series'
) {
this.selectedKind.set(value);
this.persistKind(value);
this.visibleItemLimit.set(INITIAL_PAGE_SIZE);
}
}
private readStoredKind(): DashboardContentKind | null {
if (typeof localStorage === 'undefined') {
return null;
}
const value = localStorage.getItem(GLOBAL_FAVORITES_KIND_STORAGE_KEY);
if (
value === 'all' ||
value === 'channels' ||
value === 'vod' ||
value === 'series'
) {
return value;
}
return null;
}
private persistKind(kind: DashboardContentKind): void {
if (typeof localStorage === 'undefined') {
return;
}
localStorage.setItem(GLOBAL_FAVORITES_KIND_STORAGE_KEY, kind);
}
private readStoredViewMode(): DashboardActivityViewMode | null {
if (typeof localStorage === 'undefined') {
return null;
}
const value = localStorage.getItem(
GLOBAL_FAVORITES_VIEW_MODE_STORAGE_KEY
);
return value === 'grid' || value === 'list' ? value : null;
}
private persistViewMode(viewMode: DashboardActivityViewMode): void {
if (typeof localStorage === 'undefined') {
return;
}
localStorage.setItem(GLOBAL_FAVORITES_VIEW_MODE_STORAGE_KEY, viewMode);
}
private formatTimestamp(value: string): string {
this.languageTick();
const date = new Date(value);
if (Number.isNaN(date.getTime())) {
return this.translate.instant('WORKSPACE.DASHBOARD.RECENTLY');
}
return formatWithIntl(date, {
locale: this.translate.currentLang || this.translate.defaultLang,
month: 'short',
day: 'numeric',
hour: '2-digit',
minute: '2-digit',
});
}
}
@@ -1,67 +0,0 @@
<app-dashboard-widget-shell
[eyebrow]="'WORKSPACE.DASHBOARD.LIBRARY' | translate"
[title]="'WORKSPACE.DASHBOARD.RECENT_SOURCES' | translate"
>
<a widget-actions routerLink="/workspace/sources">{{
'WORKSPACE.DASHBOARD.MANAGE_ALL' | translate
}}</a>
@if (data.recentPlaylists().length > 0) {
<div class="source-list">
@for (item of data.recentPlaylists(); track item._id) {
<a class="source-row" [routerLink]="data.getPlaylistLink(item)">
<div class="source-row__head">
<span
class="provider-pill"
[class]="
'provider-pill--' +
data.getPlaylistProvider(item).toLowerCase()
"
>{{ data.getPlaylistProvider(item) }}</span
>
@if (item.count) {
<span class="count-chip">{{
item.count | number
}}</span>
}
</div>
<h4
[attr.title]="
item.title ||
item.filename ||
('WORKSPACE.DASHBOARD.UNTITLED_SOURCE' | translate)
"
>
{{
item.title ||
item.filename ||
('WORKSPACE.DASHBOARD.UNTITLED_SOURCE'
| translate)
}}
</h4>
<p>
{{
data.formatTimestamp(
item.updateDate || item.importDate
)
}}
</p>
</a>
}
</div>
} @else {
<article class="empty-state">
<mat-icon class="empty-state__icon">library_add</mat-icon>
<div class="empty-content">
<p class="empty-title">
{{ 'WORKSPACE.DASHBOARD.NO_SOURCES' | translate }}
</p>
<p class="empty-hint">
{{ 'WORKSPACE.DASHBOARD.NO_SOURCES_HINT' | translate }}
</p>
</div>
<a mat-stroked-button routerLink="/workspace/sources">{{
'WORKSPACE.DASHBOARD.OPEN_SOURCES' | translate
}}</a>
</article>
}
</app-dashboard-widget-shell>
@@ -1,162 +0,0 @@
:host {
--_xtream-accent: oklch(55% 0.13 185);
--_stalker-accent: oklch(68% 0.16 55);
--_m3u-accent: oklch(55% 0.18 295);
}
[widget-actions] {
color: var(--mat-sys-primary);
text-decoration: none;
font-size: 0.84rem;
font-weight: 600;
white-space: nowrap;
}
[widget-actions]:hover {
text-decoration: underline;
}
// ── Provider pill ─────────────────────────────────────────────────────────────
.provider-pill {
display: inline-flex;
align-items: center;
font-size: 0.67rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.09em;
padding: 1px 7px;
border-radius: 20px;
border: 1px solid currentColor;
// Default (fallback)
color: var(--mat-sys-on-surface-variant);
background: color-mix(in srgb, currentColor 8%, transparent);
}
.provider-pill--xtream {
color: var(--_xtream-accent);
border-color: color-mix(in srgb, var(--_xtream-accent) 40%, transparent);
background: color-mix(in srgb, var(--_xtream-accent) 10%, transparent);
}
.provider-pill--stalker {
color: var(--_stalker-accent);
border-color: color-mix(in srgb, var(--_stalker-accent) 40%, transparent);
background: color-mix(in srgb, var(--_stalker-accent) 10%, transparent);
}
.provider-pill--m3u {
color: var(--_m3u-accent);
border-color: color-mix(in srgb, var(--_m3u-accent) 40%, transparent);
background: color-mix(in srgb, var(--_m3u-accent) 10%, transparent);
}
// ── Channel count chip ────────────────────────────────────────────────────────
.count-chip {
font-size: 0.68rem;
font-weight: 500;
color: var(--mat-sys-on-surface-variant);
background: var(--mat-sys-surface-container-high);
border: 1px solid var(--mat-sys-outline-variant);
padding: 0 6px;
border-radius: 10px;
line-height: 18px;
white-space: nowrap;
}
.source-list {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 10px;
}
.source-row {
border-radius: 12px;
border: 1px solid var(--mat-sys-outline-variant);
background: var(--mat-sys-surface-container-low);
padding: 10px;
text-decoration: none;
color: inherit;
min-width: 0;
transition: transform 0.2s ease, border-color 0.2s ease, background-color 0.2s ease, box-shadow 0.2s ease;
}
.source-row:hover {
transform: translateY(-2px);
border-color: color-mix(in srgb, var(--mat-sys-outline) 60%, var(--mat-sys-primary) 40%);
background: var(--mat-sys-surface-container-high);
box-shadow: 0 4px 12px -4px rgba(0, 0, 0, 0.15);
}
.source-row__head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
}
.source-row h4 {
margin: 8px 0 6px;
font-size: 0.95rem;
line-height: 1.25;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.source-row p {
margin: 0;
font-size: 0.77rem;
color: var(--mat-sys-on-surface-variant);
}
.empty-state {
margin: 0;
border: 1px dashed var(--mat-sys-outline);
border-radius: 12px;
background: var(--mat-sys-surface-container-low);
padding: 24px;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
text-align: center;
gap: 16px;
font-size: 0.82rem;
}
.empty-state__icon {
font-size: 32px;
width: 32px;
height: 32px;
color: var(--mat-sys-outline);
opacity: 0.7;
}
.empty-content {
display: flex;
flex-direction: column;
gap: 4px;
}
.empty-title {
margin: 0;
font-size: 0.875rem;
font-weight: 500;
color: var(--mat-sys-on-surface);
}
.empty-hint {
margin: 0;
font-size: 0.75rem;
color: var(--mat-sys-on-surface-variant);
max-width: 240px;
line-height: 1.4;
}
@media (max-width: 920px) {
.source-list {
grid-template-columns: 1fr;
}
}
@@ -1,25 +0,0 @@
import { DecimalPipe } from '@angular/common';
import { Component, inject } from '@angular/core';
import { MatButtonModule } from '@angular/material/button';
import { MatIcon } from '@angular/material/icon';
import { RouterLink } from '@angular/router';
import { TranslatePipe } from '@ngx-translate/core';
import { DashboardDataService } from 'workspace-dashboard-data-access';
import { DashboardWidgetShellComponent } from './dashboard-widget-shell.component';
@Component({
selector: 'app-recent-sources-widget',
imports: [
DecimalPipe,
MatButtonModule,
MatIcon,
RouterLink,
DashboardWidgetShellComponent,
TranslatePipe,
],
templateUrl: './recent-sources-widget.component.html',
styleUrl: './recent-sources-widget.component.scss',
})
export class RecentSourcesWidgetComponent {
readonly data = inject(DashboardDataService);
}
@@ -1,103 +0,0 @@
<app-dashboard-widget-shell
[eyebrow]="'WORKSPACE.DASHBOARD.LIBRARY' | translate"
[title]="'WORKSPACE.DASHBOARD.RECENTLY_ADDED' | translate"
>
<ng-container widget-actions>
<button
mat-icon-button
[matMenuTriggerFor]="filterMenu"
[attr.aria-label]="
'WORKSPACE.DASHBOARD.FILTER_RECENTLY_ADDED' | translate
"
[matTooltip]="'WORKSPACE.DASHBOARD.FILTER_ITEMS' | translate"
>
<mat-icon>filter_list</mat-icon>
</button>
<mat-menu #filterMenu="matMenu">
<button mat-menu-item (click)="onKindChange('all')">
<mat-icon>{{ selectedKind() === 'all' ? 'check' : '' }}</mat-icon>
<span [class.active-filter]="selectedKind() === 'all'">
{{ 'WORKSPACE.DASHBOARD.ALL' | translate }}
</span>
</button>
<button mat-menu-item (click)="onKindChange('vod')">
<mat-icon>{{ selectedKind() === 'vod' ? 'check' : '' }}</mat-icon>
<span [class.active-filter]="selectedKind() === 'vod'">
{{ 'WORKSPACE.DASHBOARD.VOD' | translate }}
</span>
</button>
<button mat-menu-item (click)="onKindChange('series')">
<mat-icon>{{
selectedKind() === 'series' ? 'check' : ''
}}</mat-icon>
<span [class.active-filter]="selectedKind() === 'series'">
{{ 'WORKSPACE.DASHBOARD.SERIES' | translate }}
</span>
</button>
</mat-menu>
<mat-button-toggle-group
class="view-toggle"
[value]="viewMode()"
hideSingleSelectionIndicator
[attr.aria-label]="
'WORKSPACE.DASHBOARD.RECENTLY_ADDED_VIEW_MODE' | translate
"
(change)="onViewModeChange($event.value)"
>
<mat-button-toggle
value="list"
[aria-label]="'WORKSPACE.DASHBOARD.LIST_VIEW' | translate"
>
<mat-icon>view_list</mat-icon>
</mat-button-toggle>
<mat-button-toggle
value="grid"
[aria-label]="'WORKSPACE.DASHBOARD.GRID_VIEW' | translate"
>
<mat-icon>grid_view</mat-icon>
</mat-button-toggle>
</mat-button-toggle-group>
</ng-container>
@if (loading()) {
<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">
<mat-icon class="feedback-state__icon">error_outline</mat-icon>
<p class="feedback-state__title">
{{
'WORKSPACE.DASHBOARD.RECENTLY_ADDED_LOAD_ERROR'
| translate
}}
</p>
<p class="feedback-state__hint">
{{
'WORKSPACE.DASHBOARD.RECENTLY_ADDED_LOAD_ERROR_HINT'
| translate
}}
</p>
<button mat-stroked-button type="button" (click)="reloadItems()">
{{ 'DOWNLOADS.RETRY' | translate }}
</button>
</div>
} @else {
<app-dashboard-activity-items
[items]="activityItems()"
[emptyLabel]="'WORKSPACE.DASHBOARD.NO_RECENTLY_ADDED' | translate"
emptyIcon="movie_filter"
[emptyHint]="
'WORKSPACE.DASHBOARD.NO_RECENTLY_ADDED_HINT' | translate
"
[emptyActionLabel]="'WORKSPACE.DASHBOARD.ADD_SOURCE' | translate"
[emptyActionLink]="['/workspace/sources']"
[viewMode]="viewMode()"
/>
}
</app-dashboard-widget-shell>
@@ -1,88 +0,0 @@
:host {
// Recently added / discovery tint — fresh emerald
--app-eyebrow-color: oklch(68% 0.17 155);
--app-widget-border: color-mix(in srgb, oklch(68% 0.17 155) 22%, var(--mat-sys-outline-variant) 78%);
}
.view-toggle {
border-radius: 8px;
height: 32px;
align-items: center;
border: 1px solid var(--mat-sys-outline-variant);
background: var(--mat-sys-surface-container-high);
mat-icon {
font-size: 18px;
width: 18px;
height: 18px;
line-height: 18px;
}
}
::ng-deep .view-toggle {
.mat-button-toggle-label-content {
line-height: 30px !important;
padding: 0 8px !important;
}
.mat-button-toggle-checked .mat-button-toggle-button {
background-color: var(--mat-sys-secondary-container) !important;
}
.mat-button-toggle-checked mat-icon {
color: var(--mat-sys-on-secondary-container) !important;
}
}
.active-filter {
font-weight: 600;
color: var(--mat-sys-primary);
}
.feedback-state {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
text-align: center;
padding: 24px 16px;
gap: 8px;
border: 1px dashed var(--mat-sys-outline-variant);
border-radius: 12px;
margin: 8px 0;
background: radial-gradient(
ellipse at 50% 0%,
color-mix(in srgb, var(--mat-sys-primary) 6%, transparent),
transparent 72%
);
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;
height: 34px;
color: var(--mat-sys-primary);
opacity: 0.6;
}
.feedback-state__title {
margin: 0;
font-size: 0.88rem;
font-weight: 500;
color: var(--mat-sys-on-surface);
}
.feedback-state__hint {
margin: 0;
max-width: 280px;
font-size: 0.78rem;
line-height: 1.45;
}
@@ -1,50 +0,0 @@
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();
});
});
@@ -1,174 +0,0 @@
import {
Component,
OnInit,
computed,
inject,
input,
signal,
} from '@angular/core';
import { toSignal } from '@angular/core/rxjs-interop';
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';
import {
DashboardDataService,
DashboardRecentlyAddedFilterKind,
DashboardRecentlyAddedItem,
DashboardWidgetConfig,
} from 'workspace-dashboard-data-access';
import {
DashboardActivityItemViewModel,
DashboardActivityItemsComponent,
} from './dashboard-activity-items.component';
import { DashboardWidgetShellComponent } from './dashboard-widget-shell.component';
const RECENTLY_ADDED_KIND_STORAGE_KEY =
'workspace-dashboard-recently-added-kind';
const RECENTLY_ADDED_VIEW_MODE_STORAGE_KEY =
'workspace-dashboard-recently-added-view-mode';
type DashboardActivityViewMode = 'list' | 'grid';
const QUERY_LIMIT = 200;
const DISPLAY_LIMIT = 25;
@Component({
selector: 'app-recently-added-widget',
imports: [
MatButtonModule,
MatButtonToggleModule,
MatMenuModule,
MatIcon,
MatProgressSpinnerModule,
MatTooltipModule,
DashboardActivityItemsComponent,
DashboardWidgetShellComponent,
TranslatePipe,
],
templateUrl: './recently-added-widget.component.html',
styleUrl: './recently-added-widget.component.scss',
})
export class RecentlyAddedWidgetComponent implements OnInit {
readonly widget = input.required<DashboardWidgetConfig>();
readonly data = inject(DashboardDataService);
private readonly translate = inject(TranslateService);
private readonly languageTick = toSignal(
this.translate.onLangChange.pipe(startWith(null)),
{ initialValue: null }
);
readonly selectedKind = signal<DashboardRecentlyAddedFilterKind>(
this.readStoredKind() ?? 'all'
);
readonly viewMode = signal<DashboardActivityViewMode>(
this.readStoredViewMode() ?? 'list'
);
readonly items = signal<DashboardRecentlyAddedItem[]>([]);
readonly loading = signal(true);
readonly loadFailed = signal(false);
readonly activityItems = computed<DashboardActivityItemViewModel[]>(() =>
this.items()
.slice(0, DISPLAY_LIMIT)
.map((item) => ({
id: `${item.id}-${item.playlist_id}-${item.added_at}`,
title: item.title,
subtitle: `${this.data.getRecentlyAddedItemProviderLabel(item)} · ${this.data.getRecentlyAddedItemTypeLabel(item)} · ${this.formatTimestamp(item.added_at)}`,
type: item.type,
imageUrl: item.poster_url,
link: this.data.getRecentlyAddedLink(item),
navigationState:
this.data.getRecentlyAddedNavigationState(item),
}))
);
ngOnInit(): void {
void this.reloadItems();
}
onKindChange(kind: DashboardRecentlyAddedFilterKind): void {
this.selectedKind.set(kind);
this.persistKind(kind);
void this.reloadItems();
}
onViewModeChange(value: unknown): void {
if (value === 'grid' || value === 'list') {
this.viewMode.set(value);
this.persistViewMode(value);
}
}
async reloadItems(): Promise<void> {
this.loading.set(true);
this.loadFailed.set(false);
try {
const items = await this.data.getGlobalRecentlyAddedItems(
this.selectedKind(),
QUERY_LIMIT
);
this.items.set(items);
} catch (error) {
console.warn(
'[RecentlyAddedWidget] Failed to load recently added items',
error
);
this.items.set([]);
this.loadFailed.set(true);
} finally {
this.loading.set(false);
}
}
private readStoredKind(): DashboardRecentlyAddedFilterKind | null {
if (typeof localStorage === 'undefined') {
return null;
}
const value = localStorage.getItem(RECENTLY_ADDED_KIND_STORAGE_KEY);
return value === 'vod' || value === 'series' || value === 'all'
? value
: null;
}
private persistKind(kind: DashboardRecentlyAddedFilterKind): void {
if (typeof localStorage === 'undefined') {
return;
}
localStorage.setItem(RECENTLY_ADDED_KIND_STORAGE_KEY, kind);
}
private readStoredViewMode(): DashboardActivityViewMode | null {
if (typeof localStorage === 'undefined') {
return null;
}
const value = localStorage.getItem(
RECENTLY_ADDED_VIEW_MODE_STORAGE_KEY
);
return value === 'grid' || value === 'list' ? value : null;
}
private persistViewMode(viewMode: DashboardActivityViewMode): void {
if (typeof localStorage === 'undefined') {
return;
}
localStorage.setItem(RECENTLY_ADDED_VIEW_MODE_STORAGE_KEY, viewMode);
}
private formatTimestamp(value?: string | number): string {
this.languageTick();
const timestamp = this.data.formatTimestamp(value);
return timestamp ===
this.translate.instant('WORKSPACE.DASHBOARD.NOT_YET_SYNCED')
? this.translate.instant('WORKSPACE.DASHBOARD.RECENTLY')
: timestamp;
}
}
@@ -1,108 +0,0 @@
<app-dashboard-widget-shell
[eyebrow]="'WORKSPACE.DASHBOARD.ACTIVITY' | translate"
[title]="'WORKSPACE.DASHBOARD.RECENTLY_WATCHED' | translate"
>
<ng-container widget-actions>
<button
mat-icon-button
[matMenuTriggerFor]="filterMenu"
[attr.aria-label]="
'WORKSPACE.DASHBOARD.FILTER_RECENTLY_WATCHED' | translate
"
[matTooltip]="'WORKSPACE.DASHBOARD.FILTER_ITEMS' | translate"
>
<mat-icon>filter_list</mat-icon>
</button>
<mat-menu #filterMenu="matMenu">
<button mat-menu-item (click)="onKindChange('all')">
<mat-icon>{{
selectedKind() === 'all' ? 'check' : ''
}}</mat-icon>
<span [class.active-filter]="selectedKind() === 'all'"
>{{ 'WORKSPACE.DASHBOARD.ALL' | translate }} ({{
counts().all
}})</span
>
</button>
<button mat-menu-item (click)="onKindChange('channels')">
<mat-icon>{{
selectedKind() === 'channels' ? 'check' : ''
}}</mat-icon>
<span [class.active-filter]="selectedKind() === 'channels'"
>{{ 'WORKSPACE.DASHBOARD.CHANNELS' | translate }} ({{
counts().channels
}})</span
>
</button>
<button mat-menu-item (click)="onKindChange('vod')">
<mat-icon>{{
selectedKind() === 'vod' ? 'check' : ''
}}</mat-icon>
<span [class.active-filter]="selectedKind() === 'vod'"
>{{ 'WORKSPACE.DASHBOARD.VOD' | translate }} ({{
counts().vod
}})</span
>
</button>
<button mat-menu-item (click)="onKindChange('series')">
<mat-icon>{{
selectedKind() === 'series' ? 'check' : ''
}}</mat-icon>
<span [class.active-filter]="selectedKind() === 'series'"
>{{ 'WORKSPACE.DASHBOARD.SERIES' | translate }} ({{
counts().series
}})</span
>
</button>
</mat-menu>
<mat-button-toggle-group
class="view-toggle"
[value]="viewMode()"
hideSingleSelectionIndicator
[attr.aria-label]="
'WORKSPACE.DASHBOARD.RECENTLY_WATCHED_VIEW_MODE' | translate
"
(change)="onViewModeChange($event.value)"
>
<mat-button-toggle
value="list"
[aria-label]="'WORKSPACE.DASHBOARD.LIST_VIEW' | translate"
>
<mat-icon>view_list</mat-icon>
</mat-button-toggle>
<mat-button-toggle
value="grid"
[aria-label]="'WORKSPACE.DASHBOARD.GRID_VIEW' | translate"
>
<mat-icon>grid_view</mat-icon>
</mat-button-toggle>
</mat-button-toggle-group>
</ng-container>
@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>
@@ -1,47 +0,0 @@
:host {
// Activity / history tint — blue-teal
--app-eyebrow-color: oklch(54% 0.13 225);
--app-widget-border: color-mix(in srgb, oklch(54% 0.13 225) 22%, var(--mat-sys-outline-variant) 78%);
}
.view-toggle {
border-radius: 8px;
height: 32px;
align-items: center;
border: 1px solid var(--mat-sys-outline-variant);
background: var(--mat-sys-surface-container-high);
mat-icon {
font-size: 18px;
width: 18px;
height: 18px;
line-height: 18px;
}
}
::ng-deep .view-toggle {
.mat-button-toggle-label-content {
line-height: 30px !important;
padding: 0 8px !important;
}
.mat-button-toggle-checked .mat-button-toggle-button {
background-color: var(--mat-sys-secondary-container) !important;
}
.mat-button-toggle-checked mat-icon {
color: var(--mat-sys-on-secondary-container) !important;
}
}
.active-filter {
font-weight: 600;
color: var(--mat-sys-primary);
}
.recently-watched-widget__loading {
min-height: 220px;
display: grid;
place-items: center;
padding: 16px 0;
}
@@ -1,58 +0,0 @@
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();
});
});
@@ -1,224 +0,0 @@
import {
Component,
OnInit,
computed,
inject,
input,
signal,
} from '@angular/core';
import { toSignal } from '@angular/core/rxjs-interop';
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 { formatWithIntl } from '@iptvnator/pipes';
import { TranslatePipe, TranslateService } from '@ngx-translate/core';
import { startWith } from 'rxjs';
import {
DashboardContentKind,
DashboardDataService,
DashboardWidgetConfig,
} from 'workspace-dashboard-data-access';
import {
DashboardActivityItemViewModel,
DashboardActivityItemsComponent,
} from './dashboard-activity-items.component';
import { DashboardWidgetShellComponent } from './dashboard-widget-shell.component';
const RECENTLY_WATCHED_KIND_STORAGE_KEY =
'workspace-dashboard-recently-watched-kind';
const RECENTLY_WATCHED_VIEW_MODE_STORAGE_KEY =
'workspace-dashboard-recently-watched-view-mode';
type DashboardActivityViewMode = 'list' | 'grid';
const INITIAL_PAGE_SIZE = 20;
const PAGE_SIZE = 20;
@Component({
selector: 'app-recently-watched-widget',
imports: [
MatButtonModule,
MatButtonToggleModule,
MatMenuModule,
MatIcon,
MatProgressSpinnerModule,
MatTooltipModule,
DashboardActivityItemsComponent,
DashboardWidgetShellComponent,
TranslatePipe,
],
templateUrl: './recently-watched-widget.component.html',
styleUrl: './recently-watched-widget.component.scss',
})
export class RecentlyWatchedWidgetComponent implements OnInit {
readonly widget = input.required<DashboardWidgetConfig>();
readonly data = inject(DashboardDataService);
private readonly translate = inject(TranslateService);
private readonly languageTick = toSignal(
this.translate.onLangChange.pipe(startWith(null)),
{ initialValue: null }
);
readonly selectedKind = signal<DashboardContentKind>(
this.readStoredKind() ?? 'all'
);
readonly viewMode = signal<DashboardActivityViewMode>(
this.readStoredViewMode() ?? 'list'
);
readonly visibleItemLimit = signal(INITIAL_PAGE_SIZE);
readonly counts = computed(() => {
const items = this.data.globalRecentItems();
return {
all: items.length,
channels: items.filter((item) => item.type === 'live').length,
vod: items.filter((item) => item.type === 'movie').length,
series: items.filter((item) => item.type === 'series').length,
};
});
readonly filteredItems = computed(() =>
this.data
.globalRecentItems()
.filter((item) =>
this.data.isTypeInKind(item.type, this.selectedKind())
)
);
readonly activityItems = computed<DashboardActivityItemViewModel[]>(() =>
this.filteredItems()
.slice(0, this.visibleItemLimit())
.map((item) => ({
id: `${item.id}-${item.playlist_id}-${item.viewed_at}`,
title: item.title,
subtitle: `${this.data.getRecentItemProviderLabel(item)} · ${this.data.getRecentItemTypeLabel(item)} · ${this.formatTimestamp(item.viewed_at)}`,
type: item.type,
imageUrl: item.poster_url,
link: this.data.getRecentItemLink(item),
navigationState: this.data.getRecentItemNavigationState(item),
}))
);
constructor() {}
ngOnInit(): void {
this.data.reloadGlobalRecentItems().then(() => {
this.visibleItemLimit.set(INITIAL_PAGE_SIZE);
});
}
onKindChange(kind: DashboardContentKind): void {
this.setKind(kind);
}
onViewModeChange(value: unknown): void {
if (value === 'list' || value === 'grid') {
this.viewMode.set(value);
this.persistViewMode(value);
}
}
onItemsScrolledToEnd(): void {
if (this.visibleItemLimit() >= this.filteredItems().length) {
return;
}
this.visibleItemLimit.update((value) => value + PAGE_SIZE);
}
onRemoveItem(item: DashboardActivityItemViewModel): void {
const globalItem = this.data
.globalRecentItems()
.find((i) => `${i.id}-${i.playlist_id}-${i.viewed_at}` === item.id);
if (globalItem) {
void this.data.removeGlobalRecentItem(globalItem);
} else {
// Fallback strategy if custom ID mapping changed
const fallbackItem = this.data
.globalRecentItems()
.find(
(i) =>
item.link.includes(String(i.xtream_id)) ||
item.link.includes(i.playlist_id)
);
if (fallbackItem) {
void this.data.removeGlobalRecentItem(fallbackItem);
}
}
}
private setKind(value: unknown): void {
if (
value === 'all' ||
value === 'channels' ||
value === 'vod' ||
value === 'series'
) {
this.selectedKind.set(value);
this.persistKind(value);
this.visibleItemLimit.set(INITIAL_PAGE_SIZE);
}
}
private readStoredKind(): DashboardContentKind | null {
if (typeof localStorage === 'undefined') {
return null;
}
const value = localStorage.getItem(RECENTLY_WATCHED_KIND_STORAGE_KEY);
if (
value === 'all' ||
value === 'channels' ||
value === 'vod' ||
value === 'series'
) {
return value;
}
return null;
}
private persistKind(kind: DashboardContentKind): void {
if (typeof localStorage === 'undefined') {
return;
}
localStorage.setItem(RECENTLY_WATCHED_KIND_STORAGE_KEY, kind);
}
private readStoredViewMode(): DashboardActivityViewMode | null {
if (typeof localStorage === 'undefined') {
return null;
}
const value = localStorage.getItem(
RECENTLY_WATCHED_VIEW_MODE_STORAGE_KEY
);
return value === 'grid' || value === 'list' ? value : null;
}
private persistViewMode(viewMode: DashboardActivityViewMode): void {
if (typeof localStorage === 'undefined') {
return;
}
localStorage.setItem(RECENTLY_WATCHED_VIEW_MODE_STORAGE_KEY, viewMode);
}
private formatTimestamp(value: string): string {
this.languageTick();
const date = new Date(value);
if (Number.isNaN(date.getTime())) {
return this.translate.instant('WORKSPACE.DASHBOARD.RECENTLY');
}
return formatWithIntl(date, {
locale: this.translate.currentLang || this.translate.defaultLang,
month: 'short',
day: 'numeric',
hour: '2-digit',
minute: '2-digit',
});
}
}
@@ -1,53 +0,0 @@
<app-dashboard-widget-shell
[eyebrow]="'WORKSPACE.DASHBOARD.OVERVIEW' | translate"
[title]="'WORKSPACE.DASHBOARD.SOURCE_STATS' | translate"
>
@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,98 +0,0 @@
.source-stats-widget__loading {
min-height: 180px;
display: grid;
place-items: center;
padding: 16px 0;
}
.stats-grid {
display: flex;
flex-wrap: wrap;
gap: 12px;
}
.stat-tile {
flex: 1 1 120px;
border-radius: 12px;
border: 1px solid var(--mat-sys-outline-variant);
background: var(--mat-sys-surface-container-low);
padding: 12px 14px;
display: flex;
flex-direction: column;
gap: 8px;
min-width: 0;
&--total {
background: linear-gradient(135deg, var(--mat-sys-primary-container) 0%, transparent 100%);
border-color: var(--mat-sys-primary);
.stat-tile__header mat-icon,
.label {
color: var(--mat-sys-on-primary-container);
}
.value {
color: var(--mat-sys-on-primary-container);
}
}
// Xtream: teal accent
&--xtream {
--stat-accent: oklch(55% 0.13 185);
border-color: color-mix(in srgb, var(--stat-accent) 35%, var(--mat-sys-outline-variant) 65%);
background: color-mix(in srgb, var(--stat-accent) 7%, var(--mat-sys-surface-container-low) 93%);
.stat-tile__header mat-icon { color: var(--stat-accent); }
}
// Stalker: amber accent
&--stalker {
--stat-accent: oklch(68% 0.16 55);
border-color: color-mix(in srgb, var(--stat-accent) 35%, var(--mat-sys-outline-variant) 65%);
background: color-mix(in srgb, var(--stat-accent) 7%, var(--mat-sys-surface-container-low) 93%);
.stat-tile__header mat-icon { color: var(--stat-accent); }
}
// M3U: violet accent
&--m3u {
--stat-accent: oklch(55% 0.18 295);
border-color: color-mix(in srgb, var(--stat-accent) 35%, var(--mat-sys-outline-variant) 65%);
background: color-mix(in srgb, var(--stat-accent) 7%, var(--mat-sys-surface-container-low) 93%);
.stat-tile__header mat-icon { color: var(--stat-accent); }
}
}
.stat-tile__header {
display: flex;
align-items: center;
gap: 6px;
mat-icon {
font-size: 16px;
width: 16px;
height: 16px;
color: var(--mat-sys-on-surface-variant);
}
}
.label {
margin: 0;
color: var(--mat-sys-on-surface-variant);
font-size: 0.72rem;
text-transform: uppercase;
letter-spacing: 0.08em;
font-weight: 600;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.value {
margin: 0;
font-size: 1.5rem;
font-weight: 700;
color: var(--mat-sys-on-surface);
line-height: 1;
}
@@ -1,44 +0,0 @@
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,21 +0,0 @@
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,
MatProgressSpinnerModule,
],
templateUrl: './source-stats-widget.component.html',
styleUrl: './source-stats-widget.component.scss',
})
export class SourceStatsWidgetComponent {
readonly data = inject(DashboardDataService);
}
@@ -1,6 +0,0 @@
import { setupZoneTestEnv } from 'jest-preset-angular/setup-env/zone';
setupZoneTestEnv({
errorOnUnknownElements: true,
errorOnUnknownProperties: true,
});
-28
View File
@@ -1,28 +0,0 @@
{
"extends": "../../../../tsconfig.base.json",
"compilerOptions": {
"isolatedModules": true,
"target": "es2022",
"moduleResolution": "bundler",
"strict": true,
"noImplicitOverride": true,
"noPropertyAccessFromIndexSignature": true,
"noImplicitReturns": true,
"noFallthroughCasesInSwitch": true,
"emitDecoratorMetadata": false,
"module": "preserve"
},
"angularCompilerOptions": {
"enableI18nLegacyMessageIdFormat": false,
"strictInjectionParameters": true,
"strictInputAccessModifiers": true,
"strictTemplates": true
},
"files": [],
"include": [],
"references": [
{
"path": "./tsconfig.lib.json"
}
]
}
@@ -1,12 +0,0 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
"outDir": "../../../../dist/out-tsc",
"declaration": true,
"declarationMap": true,
"inlineSources": true,
"types": []
},
"include": ["src/**/*.ts"],
"exclude": ["src/**/*.spec.ts", "src/**/*.test.ts"]
}
@@ -1,17 +0,0 @@
{
"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"
]
}
@@ -22,10 +22,10 @@
<div class="palette-divider"></div>
<div class="palette-results">
@for (group of commandGroups(); track group.scope) {
@for (group of commandGroups(); track group.group) {
<section class="palette-group">
<h3 class="palette-group__title">
{{ getScopeTitleKey(group.scope) | translate }}
{{ getGroupTitleKey(group.group) | translate }}
</h3>
@for (command of group.items; track command.id) {
<button
@@ -37,16 +37,16 @@
(click)="onCommandClick(command)"
>
<span class="palette-command__icon">
<mat-icon>{{
getCommandIcon(command)
}}</mat-icon>
<mat-icon>{{ command.icon }}</mat-icon>
</span>
<span class="palette-command__label">{{
command.label
}}</span>
<span class="palette-command__desc">{{
command.description
}}</span>
@if (command.description) {
<span class="palette-command__desc">{{
command.description
}}</span>
}
</button>
}
</section>
@@ -2,17 +2,69 @@ import { ComponentFixture, TestBed } from '@angular/core/testing';
import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog';
import { TranslateService } from '@ngx-translate/core';
import { of } from 'rxjs';
import { WorkspaceResolvedCommandItem } from '@iptvnator/portal/shared/util';
import { WorkspaceCommandPaletteComponent } from './workspace-command-palette.component';
describe('WorkspaceCommandPaletteComponent', () => {
let fixture: ComponentFixture<WorkspaceCommandPaletteComponent>;
let component: WorkspaceCommandPaletteComponent;
let dialogRef: { close: jest.Mock };
let commands: WorkspaceResolvedCommandItem[];
beforeEach(async () => {
dialogRef = {
close: jest.fn(),
};
commands = [
{
id: 'global-search',
label: 'Search all Xtream playlists',
description: 'Open global search overlay',
group: 'global',
icon: 'search',
keywords: ['xtream', 'global'],
priority: 100,
visible: true,
enabled: false,
run: () => undefined,
},
{
id: 'playlist-search',
label: 'Search this playlist',
description: 'Open playlist search route',
group: 'playlist',
icon: 'playlist_play',
keywords: ['playlist'],
priority: 10,
visible: true,
enabled: true,
run: () => undefined,
},
{
id: 'multi-epg',
label: 'Open Multi-EPG',
description: '',
group: 'view',
icon: 'view_list',
keywords: ['epg', 'guide'],
priority: 0,
visible: true,
enabled: true,
run: () => undefined,
},
{
id: 'hidden-command',
label: 'Hidden command',
description: 'Should not appear',
group: 'global',
icon: 'visibility_off',
keywords: [],
priority: 200,
visible: false,
enabled: true,
run: () => undefined,
},
];
await TestBed.configureTestingModule({
imports: [WorkspaceCommandPaletteComponent],
@@ -25,22 +77,7 @@ describe('WorkspaceCommandPaletteComponent', () => {
provide: MAT_DIALOG_DATA,
useValue: {
query: 'search',
commands: [
{
id: 'global-search',
label: 'Search all Xtream playlists',
description: 'Open global search overlay',
scope: 'global',
enabled: true,
},
{
id: 'playlist-search',
label: 'Search this playlist',
description: 'Open playlist search route',
scope: 'playlist',
enabled: true,
},
],
commands,
},
},
{
@@ -69,13 +106,60 @@ describe('WorkspaceCommandPaletteComponent', () => {
expect(component.query()).toBe('search');
});
it('selects the first enabled command by default', () => {
expect(component.selectedIndex()).toBe(0);
expect(component.flatCommands()[0].id).toBe('playlist-search');
});
it('hides non-visible commands and omits empty groups', () => {
expect(component.flatCommands().map((command) => command.id)).toEqual([
'playlist-search',
'global-search',
]);
expect(component.commandGroups().map((group) => group.group)).toEqual([
'playlist',
'global',
]);
});
it('filters commands by keywords', () => {
component.query.set('guide');
expect(component.flatCommands().map((command) => command.id)).toEqual([
'multi-epg',
]);
});
it('skips disabled commands during keyboard navigation', () => {
component.query.set('');
fixture.detectChanges();
expect(component.flatCommands().map((command) => command.id)).toEqual([
'multi-epg',
'playlist-search',
'global-search',
]);
expect(component.selectedIndex()).toBe(0);
component.onInputKeydown(
new KeyboardEvent('keydown', { key: 'ArrowDown' })
);
expect(component.selectedIndex()).toBe(1);
component.onInputKeydown(
new KeyboardEvent('keydown', { key: 'ArrowDown' })
);
expect(component.selectedIndex()).toBe(0);
});
it('closes with selected command and query on click', () => {
component.query.set('');
const command = component.flatCommands()[0];
component.onCommandClick(command);
expect(dialogRef.close).toHaveBeenCalledWith({
commandId: 'global-search',
query: 'search',
commandId: 'multi-epg',
query: '',
});
});
});
@@ -15,47 +15,20 @@ import {
} from '@angular/material/dialog';
import { MatIconModule } from '@angular/material/icon';
import { TranslatePipe } from '@ngx-translate/core';
export type WorkspaceCommandScope = 'global' | 'playlist' | 'section';
export type WorkspaceCommandId =
| 'global-search'
| 'playlist-search'
| 'open-global-favorites'
| 'open-downloads'
| 'open-global-recent'
| 'open-settings'
| 'open-sources'
| 'open-dashboard'
| 'add-playlist'
| 'refresh-playlist'
| 'playlist-info'
| 'account-info'
| 'go-to-vod'
| 'go-to-live'
| 'go-to-series';
export interface WorkspaceCommandItem {
id: WorkspaceCommandId;
label: string;
description: string;
scope: WorkspaceCommandScope;
enabled: boolean;
}
export interface WorkspaceCommandSelection {
commandId: WorkspaceCommandId;
query: string;
}
import {
WorkspaceCommandGroup,
WorkspaceCommandSelection,
WorkspaceResolvedCommandItem,
} from '@iptvnator/portal/shared/util';
interface WorkspaceCommandPaletteData {
commands: WorkspaceCommandItem[];
commands: WorkspaceResolvedCommandItem[];
query?: string;
}
interface WorkspaceCommandGroup {
scope: WorkspaceCommandScope;
items: WorkspaceCommandItem[];
interface WorkspaceCommandGroupSection {
group: WorkspaceCommandGroup;
items: WorkspaceResolvedCommandItem[];
}
@Component({
@@ -80,50 +53,72 @@ export class WorkspaceCommandPaletteComponent implements AfterViewInit {
readonly query = signal(this.data?.query ?? '');
readonly selectedIndex = signal(0);
readonly visibleCommands = computed(() =>
this.data.commands.filter((command) => command.visible)
);
readonly filteredCommands = computed(() => {
const term = this.query().trim().toLowerCase();
const commands = this.visibleCommands();
if (!term) {
return this.data.commands;
return commands;
}
return this.data.commands.filter((command) => {
const haystack =
`${command.label} ${command.description}`.toLowerCase();
return commands.filter((command) => {
const haystack = [
command.label,
command.description,
...(command.keywords ?? []),
]
.join(' ')
.toLowerCase();
return haystack.includes(term);
});
});
readonly commandGroups = computed<WorkspaceCommandGroup[]>(() => {
readonly commandGroups = computed<WorkspaceCommandGroupSection[]>(() => {
const commands = this.filteredCommands();
const grouped: WorkspaceCommandGroup[] = [];
const sections: WorkspaceCommandGroupSection[] = [];
const buildSection = (
group: WorkspaceCommandGroup
): WorkspaceCommandGroupSection | null => {
const items = commands.filter((command) => command.group === group);
const buildGroup = (
scope: WorkspaceCommandScope
): WorkspaceCommandGroup | null => {
const items = commands.filter((command) => command.scope === scope);
if (items.length === 0) {
return null;
}
return { scope, items };
return { group, items };
};
const groups = [
buildGroup('global'),
buildGroup('playlist'),
buildGroup('section'),
].filter((group): group is WorkspaceCommandGroup => group !== null);
buildSection('view'),
buildSection('playlist'),
buildSection('global'),
].filter(
(group): group is WorkspaceCommandGroupSection => group !== null
);
grouped.push(...groups);
return grouped;
sections.push(...groups);
return sections;
});
readonly flatCommands = computed(() =>
this.commandGroups().reduce<WorkspaceCommandItem[]>(
this.commandGroups().reduce<WorkspaceResolvedCommandItem[]>(
(items, group) => items.concat(group.items),
[]
)
);
readonly selectableIndices = computed(() =>
this.flatCommands()
.map((command, index) => (command.enabled ? index : -1))
.filter((index) => index >= 0)
);
constructor() {
effect(() => {
const items = this.flatCommands();
@@ -132,9 +127,15 @@ export class WorkspaceCommandPaletteComponent implements AfterViewInit {
return;
}
const selectableIndices = this.selectableIndices();
if (selectableIndices.length === 0) {
this.selectedIndex.set(-1);
return;
}
const currentIndex = this.selectedIndex();
if (currentIndex < 0 || currentIndex >= items.length) {
this.selectedIndex.set(0);
if (!selectableIndices.includes(currentIndex)) {
this.selectedIndex.set(selectableIndices[0]);
}
});
}
@@ -176,7 +177,11 @@ export class WorkspaceCommandPaletteComponent implements AfterViewInit {
}
}
onCommandHover(command: WorkspaceCommandItem): void {
onCommandHover(command: WorkspaceResolvedCommandItem): void {
if (!command.enabled) {
return;
}
const index = this.flatCommands().findIndex(
(item) => item.id === command.id
);
@@ -185,7 +190,7 @@ export class WorkspaceCommandPaletteComponent implements AfterViewInit {
}
}
onCommandClick(command: WorkspaceCommandItem): void {
onCommandClick(command: WorkspaceResolvedCommandItem): void {
if (!command.enabled) {
return;
}
@@ -196,23 +201,39 @@ export class WorkspaceCommandPaletteComponent implements AfterViewInit {
});
}
isCommandSelected(command: WorkspaceCommandItem): boolean {
isCommandSelected(command: WorkspaceResolvedCommandItem): boolean {
const index = this.flatCommands().findIndex(
(item) => item.id === command.id
);
return index >= 0 && this.selectedIndex() === index;
}
getGroupTitleKey(group: WorkspaceCommandGroup): string {
if (group === 'view') {
return 'WORKSPACE.COMMAND_PALETTE.GROUP_VIEW';
}
if (group === 'playlist') {
return 'WORKSPACE.COMMAND_PALETTE.GROUP_PLAYLIST';
}
return 'WORKSPACE.COMMAND_PALETTE.GROUP_GLOBAL';
}
private moveSelection(direction: 1 | -1): void {
const items = this.flatCommands();
if (items.length === 0) {
const selectableIndices = this.selectableIndices();
if (selectableIndices.length === 0) {
return;
}
const currentIndex = this.selectedIndex();
const safeIndex = currentIndex < 0 ? 0 : currentIndex;
const nextIndex = (safeIndex + direction + items.length) % items.length;
this.selectedIndex.set(nextIndex);
const currentPosition = selectableIndices.indexOf(this.selectedIndex());
const nextPosition =
currentPosition === -1
? direction === 1
? 0
: selectableIndices.length - 1
: (currentPosition + direction + selectableIndices.length) %
selectableIndices.length;
this.selectedIndex.set(selectableIndices[nextPosition]);
}
private selectCurrent(): void {
@@ -228,51 +249,4 @@ export class WorkspaceCommandPaletteComponent implements AfterViewInit {
this.onCommandClick(items[index]);
}
getScopeTitleKey(scope: WorkspaceCommandScope): string {
if (scope === 'global') {
return 'WORKSPACE.COMMAND_PALETTE.GROUP_GLOBAL';
}
if (scope === 'playlist') {
return 'WORKSPACE.COMMAND_PALETTE.GROUP_PLAYLIST';
}
return 'WORKSPACE.COMMAND_PALETTE.GROUP_SECTION';
}
getCommandIcon(command: WorkspaceCommandItem): string {
switch (command.id) {
case 'global-search':
return 'search';
case 'playlist-search':
return 'playlist_play';
case 'open-global-favorites':
return 'star';
case 'open-downloads':
return 'download';
case 'open-global-recent':
return 'history';
case 'open-settings':
return 'settings';
case 'open-sources':
return 'library_books';
case 'open-dashboard':
return 'dashboard';
case 'add-playlist':
return 'add_circle_outline';
case 'refresh-playlist':
return 'sync';
case 'playlist-info':
return 'info';
case 'account-info':
return 'account_circle';
case 'go-to-vod':
return 'movie';
case 'go-to-live':
return 'live_tv';
case 'go-to-series':
return 'video_library';
default:
return 'bolt';
}
}
}
@@ -75,22 +75,9 @@
<mat-icon>sync</mat-icon>
</button>
}
<span class="header-actions-divider" aria-hidden="true"></span>
<button
type="button"
class="header-shortcut"
mat-icon-button
[class.is-active]="isGlobalFavoritesActive()"
(click)="onGlobalFavoritesRequested()"
[matTooltip]="
'WORKSPACE.SHELL.OPEN_GLOBAL_FAVORITES' | translate
"
[attr.aria-label]="
'WORKSPACE.SHELL.OPEN_GLOBAL_FAVORITES' | translate
"
>
<mat-icon>star</mat-icon>
</button>
@if (headerShortcut() || isElectron()) {
<span class="header-actions-divider" aria-hidden="true"></span>
}
@if (headerShortcut(); as action) {
<button
type="button"
@@ -23,8 +23,10 @@ class MockPlaylistSwitcherComponent {
readonly subtitle = input('');
readonly showPlaylistInfo = input(false);
readonly showAccountInfo = input(false);
readonly showAddPlaylist = input(false);
readonly playlistInfoRequested = output<void>();
readonly accountInfoRequested = output<void>();
readonly addPlaylistRequested = output<void>();
}
describe('WorkspaceShellHeaderComponent', () => {
@@ -88,19 +90,40 @@ describe('WorkspaceShellHeaderComponent', () => {
expect(emitted).toEqual(['matrix']);
});
it('emits header bulk action requests when the action button is clicked', () => {
it('emits add playlist requests when the toolbar add button is clicked', () => {
const requested = jest.fn();
component.headerBulkActionRequested.subscribe(requested);
fixture.componentRef.setInput('headerBulkAction', {
icon: 'delete_sweep',
tooltip: 'clear',
ariaLabel: 'clear recent',
disabled: false,
component.addPlaylistRequested.subscribe(requested);
const button: HTMLButtonElement = fixture.nativeElement.querySelector(
'button[aria-label="WORKSPACE.SHELL.ADD_PLAYLIST"]'
);
button.click();
expect(requested).toHaveBeenCalledTimes(1);
});
it('does not render the removed global favorites shortcut', () => {
const button: HTMLButtonElement | null =
fixture.nativeElement.querySelector(
'button[aria-label="WORKSPACE.SHELL.OPEN_GLOBAL_FAVORITES"]'
);
expect(button).toBeNull();
});
it('emits contextual header shortcut requests when configured', () => {
const requested = jest.fn();
component.headerShortcutRequested.subscribe(requested);
fixture.componentRef.setInput('headerShortcut', {
icon: 'tune',
tooltipKey: 'shortcut.tooltip',
ariaLabelKey: 'shortcut.aria',
run: () => undefined,
});
fixture.detectChanges();
const button: HTMLButtonElement = fixture.nativeElement.querySelector(
'button[aria-label="clear recent"]'
'button[aria-label="shortcut.aria"]'
);
button.click();
@@ -39,7 +39,6 @@ export class WorkspaceShellHeaderComponent {
readonly searchPlaceholder = input('');
readonly searchScopeLabel = input('');
readonly searchStatusLabel = input('');
readonly isGlobalFavoritesActive = input(false);
readonly headerShortcut = input<WorkspaceHeaderAction | null>(null);
readonly canRefreshPlaylist = input(false);
readonly isRefreshingPlaylist = input(false);
@@ -51,7 +50,6 @@ export class WorkspaceShellHeaderComponent {
readonly searchSubmitted = output<string>();
readonly commandPaletteRequested = output<void>();
readonly addPlaylistRequested = output<void>();
readonly globalFavoritesRequested = output<void>();
readonly headerShortcutRequested = output<void>();
readonly refreshPlaylistRequested = output<void>();
readonly downloadsRequested = output<void>();
@@ -84,10 +82,6 @@ export class WorkspaceShellHeaderComponent {
this.commandPaletteRequested.emit();
}
onGlobalFavoritesRequested(): void {
this.globalFavoritesRequested.emit();
}
onHeaderShortcutRequested(): void {
this.headerShortcutRequested.emit();
}
@@ -6,7 +6,11 @@ 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 {
PORTAL_EXTERNAL_PLAYBACK,
WorkspaceHeaderContextService,
WorkspaceViewCommandService,
} from '@iptvnator/portal/shared/util';
import { StalkerStore } from '@iptvnator/portal/stalker/data-access';
import { XtreamStore } from '@iptvnator/portal/xtream/data-access';
import {
@@ -25,8 +29,15 @@ class MockXtreamStore {
readonly searchTerm = signal('');
readonly categorySearchTerm = signal('');
readonly isImporting = signal(false);
readonly isCancellingImport = signal(false);
readonly activeImportSessionId = signal<string | null>(null);
readonly currentImportPhase = signal<string | null>(null);
readonly activeImportContentType = signal<'live' | 'vod' | 'series' | null>(
null
);
readonly activeImportCurrentCount = signal(0);
readonly activeImportTotalCount = signal(0);
readonly getImportCount = signal(0);
readonly itemsToImport = signal(0);
readonly getSelectedCategory = signal<{
category_name?: string;
@@ -99,7 +110,9 @@ describe('WorkspaceShellFacade', () => {
openAccountInfo: jest.Mock;
};
let storeDispatch: jest.Mock;
let activePlaylistSignal: ReturnType<typeof signal<PlaylistSignalMeta>>;
let activePlaylistSignal: ReturnType<
typeof signal<PlaylistSignalMeta | null>
>;
let playlistsSignal: ReturnType<typeof signal<PlaylistSignalMeta[]>>;
let stalkerStore: MockStalkerStore;
let showDashboardSignal: ReturnType<typeof signal<boolean>>;
@@ -230,7 +243,19 @@ describe('WorkspaceShellFacade', () => {
{
provide: TranslateService,
useValue: {
instant: (key: string) => key,
instant: (
key: string,
params?: Record<string, string | number>
) => {
if (
key === 'WORKSPACE.SHELL.XTREAM_IMPORT_PROGRESS' &&
params
) {
return `${params.type} imported: ${params.current} / ${params.total}`;
}
return key;
},
get: (key: string) => of(key),
stream: (key: string) => of(key),
onLangChange: of(null),
@@ -299,6 +324,21 @@ describe('WorkspaceShellFacade', () => {
);
});
it('builds a type-aware xtream import progress label', () => {
const xtreamStore = TestBed.inject(XtreamStore) as unknown as MockXtreamStore;
xtreamStore.activeImportContentType.set('vod');
xtreamStore.activeImportCurrentCount.set(20);
xtreamStore.activeImportTotalCount.set(12323);
expect(facade.xtreamImportTypeLabel()).toBe(
'WORKSPACE.SHELL.RAIL_MOVIES'
);
expect(facade.xtreamImportProgressLabel()).toBe(
'WORKSPACE.SHELL.RAIL_MOVIES imported: 20 / 12,323'
);
});
it('keeps stalker local-filter routes on the same page and syncs q', () => {
router.navigate.mockClear();
router.navigateByUrl.mockClear();
@@ -329,18 +369,15 @@ describe('WorkspaceShellFacade', () => {
});
});
it('prefers provider-scoped global favorites when rail context exists', () => {
it('navigates to the global favorites route', () => {
router.navigate.mockClear();
facade.currentUrl.set('/workspace/xtreams/pl-1/vod');
facade.navigateToGlobalFavorites();
expect(router.navigate).toHaveBeenCalledWith(
['/workspace', 'xtreams', 'pl-1', 'favorites'],
{
queryParams: { scope: 'all' },
}
);
expect(router.navigate).toHaveBeenCalledWith([
'/workspace/global-favorites',
]);
});
it('clears stalker recent items and refreshes the route', async () => {
@@ -410,6 +447,18 @@ describe('WorkspaceShellFacade', () => {
tooltip: 'WORKSPACE.SHELL.RAIL_SOURCES',
path: ['/workspace/sources'],
},
{
icon: 'favorite',
tooltip: 'WORKSPACE.SHELL.RAIL_GLOBAL_FAVORITES',
path: ['/workspace/global-favorites'],
exact: true,
},
{
icon: 'history',
tooltip: 'WORKSPACE.SHELL.RAIL_GLOBAL_RECENT',
path: ['/workspace/global-recent'],
exact: true,
},
]);
expect(facade.brandLink()).toBe('/workspace/sources');
});
@@ -426,4 +475,99 @@ describe('WorkspaceShellFacade', () => {
expect(facade.searchScopeLabel()).toBe('PORTALS.RECENTLY_VIEWED');
});
it('shows only actionable global commands on an empty dashboard', () => {
activePlaylistSignal.set(null);
playlistsSignal.set([]);
facade.currentUrl.set('/workspace/dashboard');
const commands = facade.commandPaletteCommands();
expect(commands.map((command) => command.id)).toEqual([
'open-global-favorites',
'open-global-recent',
'open-downloads',
'open-settings',
'open-sources',
'add-playlist',
]);
expect(commands.every((command) => command.group === 'global')).toBe(
true
);
expect(commands.every((command) => command.enabled)).toBe(true);
});
it('includes M3U navigation, playlist actions, and Multi-EPG on playlist routes', () => {
const headerContext = TestBed.inject(WorkspaceHeaderContextService);
activePlaylistSignal.set({
_id: 'pl-m3u',
title: 'Playlist M3U',
count: 10,
importDate: '2026-04-22T10:00:00.000Z',
autoRefresh: false,
});
facade.currentUrl.set('/workspace/playlists/pl-m3u/groups');
headerContext.setAction({
id: 'm3u-multi-epg',
icon: 'view_list',
tooltipKey: 'TOP_MENU.OPEN_MULTI_EPG',
ariaLabelKey: 'TOP_MENU.OPEN_MULTI_EPG',
palette: {
labelKey: 'TOP_MENU.OPEN_MULTI_EPG',
descriptionKey:
'WORKSPACE.SHELL.COMMANDS.OPEN_MULTI_EPG_DESCRIPTION',
keywords: ['epg', 'guide', 'schedule'],
priority: 10,
},
run: jest.fn(),
});
const commands = facade.commandPaletteCommands();
expect(commands.map((command) => command.id)).toEqual(
expect.arrayContaining([
'm3u-multi-epg',
'go-to-all',
'go-to-favorites',
'go-to-recent',
'playlist-info',
])
);
expect(
commands.find((command) => command.id === 'm3u-multi-epg')?.group
).toBe('view');
expect(commands.some((command) => command.id === 'account-info')).toBe(
false
);
});
it('places registered current-view commands ahead of globals on global favorites routes', () => {
const viewCommands = TestBed.inject(WorkspaceViewCommandService);
const clearCurrent = jest.fn();
const unregister = viewCommands.registerCommand({
id: 'clear-current-favorites',
group: 'view',
icon: 'delete_sweep',
labelKey: 'WORKSPACE.SHELL.CLEAR_FAVORITES_TYPE',
labelParams: () => ({ type: 'Live TV' }),
descriptionKey:
'WORKSPACE.SHELL.COMMANDS.CLEAR_CURRENT_VIEW_DESCRIPTION',
descriptionParams: () => ({ type: 'Live TV' }),
priority: 10,
run: clearCurrent,
});
facade.currentUrl.set('/workspace/global-favorites');
const commands = facade.commandPaletteCommands();
expect(commands[0]?.id).toBe('clear-current-favorites');
expect(commands[0]?.group).toBe('view');
expect(commands.some((command) => command.id === 'playlist-search')).toBe(
false
);
unregister();
});
});
@@ -22,8 +22,12 @@ import {
PORTAL_EXTERNAL_PLAYBACK,
PortalRailLink,
PortalRailSection,
WorkspaceCommandContribution,
WorkspaceCommandSelection,
WorkspaceHeaderAction,
WorkspaceHeaderContextService,
WorkspaceResolvedCommandItem,
WorkspaceViewCommandService,
} from '@iptvnator/portal/shared/util';
import { XtreamStore } from '@iptvnator/portal/xtream/data-access';
import { StalkerStore } from '@iptvnator/portal/stalker/data-access';
@@ -41,13 +45,7 @@ import {
WorkspaceAccountInfoData,
WORKSPACE_SHELL_ACTIONS,
} from '@iptvnator/workspace/shell/util';
import {
WorkspaceCommandId,
WorkspaceCommandItem,
WorkspaceCommandPaletteComponent,
WorkspaceCommandScope,
WorkspaceCommandSelection,
} from '../../workspace-command-palette/workspace-command-palette.component';
import { WorkspaceCommandPaletteComponent } from '../../workspace-command-palette/workspace-command-palette.component';
export interface WorkspaceHeaderBulkAction {
icon: string;
@@ -56,25 +54,6 @@ export interface WorkspaceHeaderBulkAction {
disabled: boolean;
}
interface WorkspaceCommandAvailability {
context: WorkspacePortalContext | null;
dashboardXtreamContext: WorkspacePortalContext | null;
hasXtreamPlaylists: boolean;
isElectron: boolean;
canRefreshPlaylist: boolean;
hasActivePlaylist: boolean;
isXtreamPlaylist: boolean;
isPortalPlaylist: boolean;
}
interface WorkspaceCommandDefinition {
id: WorkspaceCommandId;
labelKey: string;
descriptionKey: string;
scope: WorkspaceCommandScope;
isEnabled: (state: WorkspaceCommandAvailability) => boolean;
}
type XtreamImportPhaseTone = 'remote' | 'local' | null;
const SEARCH_INPUT_DEBOUNCE_MS = 350;
@@ -104,123 +83,6 @@ const RAIL_TOOLTIP_KEYS: Readonly<Partial<Record<PortalRailSection, string>>> =
groups: 'WORKSPACE.SHELL.RAIL_GROUPS',
};
const WORKSPACE_COMMAND_DEFINITIONS: readonly WorkspaceCommandDefinition[] = [
{
id: 'global-search',
labelKey: 'WORKSPACE.SHELL.COMMANDS.GLOBAL_SEARCH_LABEL',
descriptionKey: 'WORKSPACE.SHELL.COMMANDS.GLOBAL_SEARCH_DESCRIPTION',
scope: 'global',
isEnabled: (state) => state.hasXtreamPlaylists,
},
{
id: 'playlist-search',
labelKey: 'WORKSPACE.SHELL.COMMANDS.PLAYLIST_SEARCH_LABEL',
descriptionKey: 'WORKSPACE.SHELL.COMMANDS.PLAYLIST_SEARCH_DESCRIPTION',
scope: 'playlist',
isEnabled: (state) =>
Boolean(
state.dashboardXtreamContext ||
(state.context &&
(state.context.provider === 'xtreams' ||
state.context.provider === 'stalker'))
),
},
{
id: 'open-global-favorites',
labelKey: 'WORKSPACE.SHELL.COMMANDS.OPEN_GLOBAL_FAVORITES_LABEL',
descriptionKey:
'WORKSPACE.SHELL.COMMANDS.OPEN_GLOBAL_FAVORITES_DESCRIPTION',
scope: 'global',
isEnabled: () => true,
},
{
id: 'open-downloads',
labelKey: 'WORKSPACE.SHELL.COMMANDS.OPEN_DOWNLOADS_LABEL',
descriptionKey: 'WORKSPACE.SHELL.COMMANDS.OPEN_DOWNLOADS_DESCRIPTION',
scope: 'global',
isEnabled: (state) => state.isElectron,
},
{
id: 'open-global-recent',
labelKey: 'WORKSPACE.SHELL.COMMANDS.OPEN_GLOBAL_RECENT_LABEL',
descriptionKey:
'WORKSPACE.SHELL.COMMANDS.OPEN_GLOBAL_RECENT_DESCRIPTION',
scope: 'global',
isEnabled: () => true,
},
{
id: 'open-settings',
labelKey: 'WORKSPACE.SHELL.COMMANDS.OPEN_SETTINGS_LABEL',
descriptionKey: 'WORKSPACE.SHELL.COMMANDS.OPEN_SETTINGS_DESCRIPTION',
scope: 'global',
isEnabled: () => true,
},
{
id: 'open-sources',
labelKey: 'WORKSPACE.SHELL.COMMANDS.OPEN_SOURCES_LABEL',
descriptionKey: 'WORKSPACE.SHELL.COMMANDS.OPEN_SOURCES_DESCRIPTION',
scope: 'global',
isEnabled: () => true,
},
{
id: 'open-dashboard',
labelKey: 'WORKSPACE.SHELL.COMMANDS.OPEN_DASHBOARD_LABEL',
descriptionKey: 'WORKSPACE.SHELL.COMMANDS.OPEN_DASHBOARD_DESCRIPTION',
scope: 'global',
isEnabled: () => true,
},
{
id: 'add-playlist',
labelKey: 'WORKSPACE.SHELL.COMMANDS.ADD_PLAYLIST_LABEL',
descriptionKey: 'WORKSPACE.SHELL.COMMANDS.ADD_PLAYLIST_DESCRIPTION',
scope: 'global',
isEnabled: () => true,
},
{
id: 'refresh-playlist',
labelKey: 'WORKSPACE.SHELL.COMMANDS.REFRESH_PLAYLIST_LABEL',
descriptionKey:
'WORKSPACE.SHELL.COMMANDS.REFRESH_PLAYLIST_DESCRIPTION',
scope: 'playlist',
isEnabled: (state) => state.canRefreshPlaylist,
},
{
id: 'playlist-info',
labelKey: 'WORKSPACE.SHELL.COMMANDS.PLAYLIST_INFO_LABEL',
descriptionKey: 'WORKSPACE.SHELL.COMMANDS.PLAYLIST_INFO_DESCRIPTION',
scope: 'playlist',
isEnabled: (state) => state.hasActivePlaylist,
},
{
id: 'account-info',
labelKey: 'WORKSPACE.SHELL.COMMANDS.ACCOUNT_INFO_LABEL',
descriptionKey: 'WORKSPACE.SHELL.COMMANDS.ACCOUNT_INFO_DESCRIPTION',
scope: 'playlist',
isEnabled: (state) => state.isXtreamPlaylist,
},
{
id: 'go-to-vod',
labelKey: 'WORKSPACE.SHELL.COMMANDS.GO_TO_VOD_LABEL',
descriptionKey: 'WORKSPACE.SHELL.COMMANDS.GO_TO_VOD_DESCRIPTION',
scope: 'section',
isEnabled: (state) => state.isPortalPlaylist,
},
{
id: 'go-to-live',
labelKey: 'WORKSPACE.SHELL.COMMANDS.GO_TO_LIVE_LABEL',
descriptionKey: 'WORKSPACE.SHELL.COMMANDS.GO_TO_LIVE_DESCRIPTION',
scope: 'section',
isEnabled: (state) => state.isPortalPlaylist,
},
{
id: 'go-to-series',
labelKey: 'WORKSPACE.SHELL.COMMANDS.GO_TO_SERIES_LABEL',
descriptionKey: 'WORKSPACE.SHELL.COMMANDS.GO_TO_SERIES_DESCRIPTION',
scope: 'section',
isEnabled: (state) => state.isPortalPlaylist,
},
];
@Injectable()
export class WorkspaceShellFacade {
private readonly router = inject(Router);
@@ -239,6 +101,7 @@ export class WorkspaceShellFacade {
WorkspaceStartupPreferencesService
);
readonly headerContext = inject(WorkspaceHeaderContextService);
private readonly viewCommands = inject(WorkspaceViewCommandService);
private readonly playlistRefreshAction = inject(
PlaylistRefreshActionService
);
@@ -308,6 +171,9 @@ export class WorkspaceShellFacade {
);
readonly currentContext = computed(() => this.currentRoute().context);
readonly currentSection = computed(() => this.currentRoute().section);
readonly commandPaletteCommands = computed<WorkspaceResolvedCommandItem[]>(
() => this.getCommandPaletteItems()
);
readonly workspaceLinks = computed<PortalRailLink[]>(() => {
this.languageTick();
@@ -328,6 +194,22 @@ export class WorkspaceShellFacade {
path: ['/workspace/sources'],
});
links.push({
icon: 'favorite',
tooltip: this.translateText(
'WORKSPACE.SHELL.RAIL_GLOBAL_FAVORITES'
),
path: ['/workspace/global-favorites'],
exact: true,
});
links.push({
icon: 'history',
tooltip: this.translateText('WORKSPACE.SHELL.RAIL_GLOBAL_RECENT'),
path: ['/workspace/global-recent'],
exact: true,
});
return links;
});
readonly isDashboardRoute = computed(
@@ -339,12 +221,6 @@ export class WorkspaceShellFacade {
readonly isSettingsRoute = computed(
() => this.currentRoute().kind === 'settings'
);
readonly isGlobalFavoritesRoute = computed(
() => this.currentRoute().kind === 'global-favorites'
);
readonly isPortalFavoritesAllScope = computed(
() => this.currentRoute().isPortalFavoritesAllScope
);
readonly isGlobalDownloadsRoute = computed(
() => this.currentRoute().kind === 'downloads'
);
@@ -400,6 +276,9 @@ export class WorkspaceShellFacade {
);
readonly xtreamImportCount = this.xtreamStore.getImportCount;
readonly xtreamItemsToImport = this.xtreamStore.itemsToImport;
readonly xtreamActiveImportCount = this.xtreamStore.activeImportCurrentCount;
readonly xtreamActiveItemsToImport =
this.xtreamStore.activeImportTotalCount;
readonly xtreamImportPhase = this.xtreamStore.currentImportPhase;
readonly isCancellingXtreamImport = this.xtreamStore.isCancellingImport;
readonly canCancelXtreamImport = computed(
@@ -412,6 +291,34 @@ export class WorkspaceShellFacade {
readonly xtreamImportTitleLabel = computed(() =>
this.translateText('WORKSPACE.SHELL.XTREAM_IMPORT_TITLE')
);
readonly xtreamImportTypeLabel = computed(() => {
this.languageTick();
switch (this.xtreamStore.activeImportContentType()) {
case 'live':
return this.translateText('WORKSPACE.SHELL.RAIL_LIVE');
case 'vod':
return this.translateText('WORKSPACE.SHELL.RAIL_MOVIES');
case 'series':
return this.translateText('WORKSPACE.SHELL.RAIL_SERIES');
default:
return '';
}
});
readonly xtreamImportProgressLabel = computed(() => {
const type = this.xtreamImportTypeLabel();
const total = this.xtreamActiveItemsToImport();
if (!type || total === 0) {
return '';
}
return this.translateText('WORKSPACE.SHELL.XTREAM_IMPORT_PROGRESS', {
type,
current: this.formatLocalizedNumber(this.xtreamActiveImportCount()),
total: this.formatLocalizedNumber(total),
});
});
readonly xtreamImportPhaseTone = computed<XtreamImportPhaseTone>(() => {
switch (this.xtreamStore.currentImportPhase()) {
case 'loading-categories':
@@ -845,9 +752,10 @@ export class WorkspaceShellFacade {
}
openCommandPalette(): void {
const commands = this.commandPaletteCommands();
const dialogRef = this.dialog.open<
WorkspaceCommandPaletteComponent,
{ commands: WorkspaceCommandItem[]; query: string },
{ commands: WorkspaceResolvedCommandItem[]; query: string },
WorkspaceCommandSelection | undefined
>(WorkspaceCommandPaletteComponent, {
width: 'min(760px, 92vw)',
@@ -855,7 +763,7 @@ export class WorkspaceShellFacade {
panelClass: 'workspace-command-palette-overlay',
autoFocus: false,
data: {
commands: this.getCommandPaletteItems(),
commands,
query: this.searchQuery(),
},
});
@@ -868,7 +776,13 @@ export class WorkspaceShellFacade {
return;
}
this.runCommandPaletteAction(selection);
const command = commands.find(
(item) =>
item.id === selection.commandId &&
item.visible &&
item.enabled
);
command?.run({ query: selection.query.trim() });
});
}
@@ -920,23 +834,6 @@ export class WorkspaceShellFacade {
}
navigateToGlobalFavorites(): void {
const context = this.railContext();
if (
context &&
(context.provider === 'xtreams' || context.provider === 'stalker')
) {
void this.router.navigate(
[
'/workspace',
context.provider,
context.playlistId,
'favorites',
],
{ queryParams: { scope: 'all' } }
);
return;
}
void this.router.navigate(['/workspace/global-favorites']);
}
@@ -994,129 +891,390 @@ export class WorkspaceShellFacade {
this.playlistRefreshAction.refresh(playlist);
}
private getCommandPaletteItems(): WorkspaceCommandItem[] {
private getCommandPaletteItems(): WorkspaceResolvedCommandItem[] {
this.languageTick();
return [
...this.getViewCommandDefinitions(),
...this.getPlaylistCommandDefinitions(),
...this.getGlobalCommandDefinitions(),
...this.viewCommands.commands(),
]
.map((command) => this.resolveCommand(command))
.filter((command) => command.visible)
.sort((left, right) => this.comparePaletteCommands(left, right));
}
private getViewCommandDefinitions(): WorkspaceCommandContribution[] {
const context = this.currentContext();
const provider = context?.provider;
const availability: WorkspaceCommandAvailability = {
context,
dashboardXtreamContext: this.dashboardXtreamContext(),
hasXtreamPlaylists: this.playlists().some(
(playlist) => !!playlist.serverUrl
),
isElectron: this.isElectron,
canRefreshPlaylist: this.canRefreshPlaylist(),
hasActivePlaylist: !!this.activePlaylist(),
isXtreamPlaylist: provider === 'xtreams',
isPortalPlaylist:
provider === 'xtreams' || provider === 'stalker',
const section = this.currentSection();
if (!context || !section) {
return [];
}
if (context.provider === 'playlists') {
return this.getM3uNavigationCommands(context, section);
}
if (context.provider === 'xtreams' || context.provider === 'stalker') {
return this.getPortalNavigationCommands(context, section);
}
return [];
}
private getPlaylistCommandDefinitions(): WorkspaceCommandContribution[] {
const route = this.currentRoute();
const context = route.context;
if (
!context ||
(context.provider !== 'xtreams' &&
context.provider !== 'stalker' &&
context.provider !== 'playlists')
) {
return [];
}
const hasActivePlaylist = !!this.activePlaylist();
const canOpenPlaylistSearch =
context.provider === 'xtreams' || context.provider === 'stalker';
return [
{
id: 'playlist-search',
group: 'playlist',
icon: 'playlist_play',
labelKey: 'WORKSPACE.SHELL.COMMANDS.PLAYLIST_SEARCH_LABEL',
descriptionKey:
'WORKSPACE.SHELL.COMMANDS.PLAYLIST_SEARCH_DESCRIPTION',
priority: 10,
visible: canOpenPlaylistSearch,
run: ({ query }) => this.openPlaylistSearchFromPalette(query),
},
{
id: 'refresh-playlist',
group: 'playlist',
icon: 'sync',
labelKey: 'WORKSPACE.SHELL.COMMANDS.REFRESH_PLAYLIST_LABEL',
descriptionKey:
'WORKSPACE.SHELL.COMMANDS.REFRESH_PLAYLIST_DESCRIPTION',
priority: 20,
visible: this.canRefreshPlaylist(),
run: () => this.refreshCurrentPlaylist(),
},
{
id: 'playlist-info',
group: 'playlist',
icon: 'info',
labelKey: 'WORKSPACE.SHELL.COMMANDS.PLAYLIST_INFO_LABEL',
descriptionKey:
'WORKSPACE.SHELL.COMMANDS.PLAYLIST_INFO_DESCRIPTION',
priority: 30,
visible: hasActivePlaylist,
run: () => this.openPlaylistInfo(),
},
{
id: 'account-info',
group: 'playlist',
icon: 'account_circle',
labelKey: 'WORKSPACE.SHELL.COMMANDS.ACCOUNT_INFO_LABEL',
descriptionKey:
'WORKSPACE.SHELL.COMMANDS.ACCOUNT_INFO_DESCRIPTION',
priority: 40,
visible: context.provider === 'xtreams',
run: () => this.openAccountInfo(),
},
];
}
private getGlobalCommandDefinitions(): WorkspaceCommandContribution[] {
const route = this.currentRoute();
const hasXtreamPlaylists = this.playlists().some(
(playlist) => !!playlist.serverUrl
);
return [
{
id: 'global-search',
group: 'global',
icon: 'search',
labelKey: 'WORKSPACE.SHELL.COMMANDS.GLOBAL_SEARCH_LABEL',
descriptionKey:
'WORKSPACE.SHELL.COMMANDS.GLOBAL_SEARCH_DESCRIPTION',
priority: 10,
visible: hasXtreamPlaylists,
keywords: ['xtream'],
run: ({ query }) => this.openGlobalSearch(query),
},
{
id: 'open-global-favorites',
group: 'global',
icon: 'star',
labelKey:
'WORKSPACE.SHELL.COMMANDS.OPEN_GLOBAL_FAVORITES_LABEL',
descriptionKey:
'WORKSPACE.SHELL.COMMANDS.OPEN_GLOBAL_FAVORITES_DESCRIPTION',
priority: 20,
visible: route.kind !== 'global-favorites',
run: () => this.navigateToGlobalFavorites(),
},
{
id: 'open-global-recent',
group: 'global',
icon: 'history',
labelKey: 'WORKSPACE.SHELL.COMMANDS.OPEN_GLOBAL_RECENT_LABEL',
descriptionKey:
'WORKSPACE.SHELL.COMMANDS.OPEN_GLOBAL_RECENT_DESCRIPTION',
priority: 30,
visible: route.kind !== 'global-recent',
run: () => this.openGlobalRecent(),
},
{
id: 'open-downloads',
group: 'global',
icon: 'download',
labelKey: 'WORKSPACE.SHELL.COMMANDS.OPEN_DOWNLOADS_LABEL',
descriptionKey:
'WORKSPACE.SHELL.COMMANDS.OPEN_DOWNLOADS_DESCRIPTION',
priority: 40,
visible: this.isElectron && route.kind !== 'downloads',
run: () => this.openDownloadsShortcut(),
},
{
id: 'open-settings',
group: 'global',
icon: 'settings',
labelKey: 'WORKSPACE.SHELL.COMMANDS.OPEN_SETTINGS_LABEL',
descriptionKey:
'WORKSPACE.SHELL.COMMANDS.OPEN_SETTINGS_DESCRIPTION',
priority: 50,
visible: route.kind !== 'settings',
run: () => {
void this.router.navigate(['/workspace', 'settings']);
},
},
{
id: 'open-sources',
group: 'global',
icon: 'library_books',
labelKey: 'WORKSPACE.SHELL.COMMANDS.OPEN_SOURCES_LABEL',
descriptionKey:
'WORKSPACE.SHELL.COMMANDS.OPEN_SOURCES_DESCRIPTION',
priority: 60,
visible: route.kind !== 'sources',
run: () => {
void this.router.navigate(['/workspace', 'sources']);
},
},
{
id: 'open-dashboard',
group: 'global',
icon: 'dashboard',
labelKey: 'WORKSPACE.SHELL.COMMANDS.OPEN_DASHBOARD_LABEL',
descriptionKey:
'WORKSPACE.SHELL.COMMANDS.OPEN_DASHBOARD_DESCRIPTION',
priority: 70,
visible: this.showDashboard() && route.kind !== 'dashboard',
run: () => {
void this.router.navigate(['/workspace', 'dashboard']);
},
},
{
id: 'add-playlist',
group: 'global',
icon: 'add_circle_outline',
labelKey: 'WORKSPACE.SHELL.COMMANDS.ADD_PLAYLIST_LABEL',
descriptionKey:
'WORKSPACE.SHELL.COMMANDS.ADD_PLAYLIST_DESCRIPTION',
priority: 80,
run: () => this.openAddPlaylistDialog(),
},
];
}
private getPortalNavigationCommands(
context: WorkspacePortalContext,
section: PortalRailSection
): WorkspaceCommandContribution[] {
const liveSection = context.provider === 'stalker' ? 'itv' : 'live';
return [
this.createNavigationCommand({
id: 'go-to-vod',
context,
targetSection: 'vod',
currentSection: section,
icon: 'movie',
labelKey: 'WORKSPACE.SHELL.RAIL_MOVIES',
priority: 100,
}),
this.createNavigationCommand({
id: 'go-to-live',
context,
targetSection: liveSection,
currentSection: section,
icon: 'live_tv',
labelKey: 'WORKSPACE.SHELL.RAIL_LIVE',
priority: 110,
}),
this.createNavigationCommand({
id: 'go-to-series',
context,
targetSection: 'series',
currentSection: section,
icon: 'video_library',
labelKey: 'WORKSPACE.SHELL.RAIL_SERIES',
priority: 120,
}),
].filter(
(command): command is WorkspaceCommandContribution => command !== null
);
}
private getM3uNavigationCommands(
context: WorkspacePortalContext,
section: PortalRailSection
): WorkspaceCommandContribution[] {
return [
this.createNavigationCommand({
id: 'go-to-all',
context,
targetSection: 'all',
currentSection: section,
icon: 'format_list_bulleted',
labelKey: 'WORKSPACE.SHELL.RAIL_ALL_CHANNELS',
priority: 100,
}),
this.createNavigationCommand({
id: 'go-to-groups',
context,
targetSection: 'groups',
currentSection: section,
icon: 'folder_open',
labelKey: 'WORKSPACE.SHELL.RAIL_GROUPS',
priority: 110,
}),
this.createNavigationCommand({
id: 'go-to-favorites',
context,
targetSection: 'favorites',
currentSection: section,
icon: 'star',
labelKey: 'WORKSPACE.SHELL.RAIL_FAVORITES',
priority: 120,
}),
this.createNavigationCommand({
id: 'go-to-recent',
context,
targetSection: 'recent',
currentSection: section,
icon: 'history',
labelKey: 'WORKSPACE.SHELL.RAIL_RECENT',
priority: 130,
}),
].filter(
(command): command is WorkspaceCommandContribution => command !== null
);
}
private createNavigationCommand(config: {
id: string;
context: WorkspacePortalContext;
targetSection: string;
currentSection: PortalRailSection;
icon: string;
labelKey: string;
priority: number;
}): WorkspaceCommandContribution | null {
if (config.currentSection === config.targetSection) {
return null;
}
return {
id: config.id,
group: 'view',
icon: config.icon,
labelKey: config.labelKey,
descriptionKey: 'WORKSPACE.SHELL.COMMANDS.OPEN_VIEW_DESCRIPTION',
descriptionParams: () => ({
view: this.translateText(config.labelKey),
}),
priority: config.priority,
run: () => {
void this.router.navigate([
'/workspace',
config.context.provider,
config.context.playlistId,
config.targetSection,
]);
},
};
return WORKSPACE_COMMAND_DEFINITIONS.map((definition) => ({
id: definition.id,
label: this.translateText(definition.labelKey),
description: this.translateText(definition.descriptionKey),
scope: definition.scope,
enabled: definition.isEnabled(availability),
}));
}
private runCommandPaletteAction(
selection: WorkspaceCommandSelection
): void {
const query = selection.query.trim();
private resolveCommand(
command: WorkspaceCommandContribution
): WorkspaceResolvedCommandItem {
const labelParams = this.resolveCommandValue(command.labelParams);
const descriptionParams = this.resolveCommandValue(
command.descriptionParams
);
if (selection.commandId === 'global-search') {
this.openGlobalSearch(query);
return;
}
if (selection.commandId === 'playlist-search') {
this.openPlaylistSearchFromPalette(query);
return;
}
if (selection.commandId === 'open-global-favorites') {
this.navigateToGlobalFavorites();
return;
}
if (selection.commandId === 'open-downloads') {
this.openDownloadsShortcut();
return;
}
if (selection.commandId === 'open-global-recent') {
this.openGlobalRecent();
return;
}
if (selection.commandId === 'open-settings') {
void this.router.navigate(['/workspace', 'settings']);
return;
}
if (selection.commandId === 'open-sources') {
void this.router.navigate(['/workspace', 'sources']);
return;
}
if (selection.commandId === 'open-dashboard') {
void this.router.navigate(['/workspace', 'dashboard']);
return;
}
if (selection.commandId === 'add-playlist') {
this.openAddPlaylistDialog();
return;
}
if (selection.commandId === 'refresh-playlist') {
this.refreshCurrentPlaylist();
return;
}
if (selection.commandId === 'playlist-info') {
this.openPlaylistInfo();
return;
}
if (selection.commandId === 'account-info') {
this.openAccountInfo();
return;
}
this.navigateToPortalSection(selection.commandId);
return {
id: command.id,
group: command.group,
icon: command.icon,
label: this.translateText(command.labelKey, labelParams),
description: command.descriptionKey
? this.translateText(command.descriptionKey, descriptionParams)
: '',
keywords: this.resolveCommandValue(command.keywords) ?? [],
priority: command.priority ?? 100,
visible: this.resolveCommandValue(command.visible) ?? true,
enabled: this.resolveCommandValue(command.enabled) ?? true,
run: command.run,
};
}
private navigateToPortalSection(commandId: WorkspaceCommandId): void {
const context = this.currentContext();
if (!context) {
return;
private comparePaletteCommands(
left: WorkspaceResolvedCommandItem,
right: WorkspaceResolvedCommandItem
): number {
const groupOrder =
this.getCommandGroupOrder(left.group) -
this.getCommandGroupOrder(right.group);
if (groupOrder !== 0) {
return groupOrder;
}
const isStalker = context.provider === 'stalker';
let section: string;
if (left.priority !== right.priority) {
return left.priority - right.priority;
}
switch (commandId) {
case 'go-to-vod':
section = 'vod';
break;
case 'go-to-live':
section = isStalker ? 'itv' : 'live';
break;
case 'go-to-series':
section = 'series';
break;
return left.label.localeCompare(right.label);
}
private getCommandGroupOrder(group: WorkspaceResolvedCommandItem['group']) {
switch (group) {
case 'view':
return 0;
case 'playlist':
return 1;
default:
return;
return 2;
}
}
private resolveCommandValue<T>(
value: T | (() => T | undefined) | undefined
): T | undefined {
if (typeof value === 'function') {
return (value as () => T | undefined)();
}
void this.router.navigate([
'/workspace',
context.provider,
context.playlistId,
section,
]);
return value;
}
private openPlaylistSearchFromPalette(query: string): void {
@@ -1385,6 +1543,12 @@ export class WorkspaceShellFacade {
return this.translateText(this.getRailTooltipKey('playlists', section));
}
private formatLocalizedNumber(value: number): string {
const locale =
this.translate.currentLang || this.translate.defaultLang || 'en';
return new Intl.NumberFormat(locale).format(value);
}
private translateText(
key: string,
params?: Record<string, string | number>
@@ -29,24 +29,25 @@
</p>
}
@if (
facade.xtreamImportCount() !== 0 &&
facade.xtreamItemsToImport() !== 0
facade.xtreamActiveImportCount() !== 0 &&
facade.xtreamActiveItemsToImport() !== 0
) {
<mat-progress-bar
mode="determinate"
[value]="
(facade.xtreamImportCount() /
facade.xtreamItemsToImport()) *
(facade.xtreamActiveImportCount() /
facade.xtreamActiveItemsToImport()) *
100
"
/>
<p class="workspace-loading-overlay__progress-copy">
{{ facade.xtreamImportCount() }} /
{{ facade.xtreamItemsToImport() }}
</p>
} @else {
<mat-progress-bar mode="indeterminate" />
}
@if (facade.xtreamImportProgressLabel()) {
<p class="workspace-loading-overlay__progress-copy">
{{ facade.xtreamImportProgressLabel() }}
</p>
}
@if (facade.canCancelXtreamImport()) {
<button
mat-stroked-button
@@ -90,9 +91,6 @@
[searchPlaceholder]="facade.searchPlaceholder()"
[searchScopeLabel]="facade.searchScopeLabel()"
[searchStatusLabel]="facade.searchStatusLabel()"
[isGlobalFavoritesActive]="
facade.isGlobalFavoritesRoute() || facade.isPortalFavoritesAllScope()
"
[headerShortcut]="facade.headerShortcut()"
[canRefreshPlaylist]="facade.canRefreshPlaylist()"
[isRefreshingPlaylist]="facade.isRefreshingPlaylist()"
@@ -104,7 +102,6 @@
(searchSubmitted)="facade.onSearchEnter($event)"
(commandPaletteRequested)="facade.openCommandPalette()"
(addPlaylistRequested)="facade.openAddPlaylistDialog()"
(globalFavoritesRequested)="facade.navigateToGlobalFavorites()"
(headerShortcutRequested)="facade.runHeaderShortcut()"
(downloadsRequested)="facade.openDownloadsShortcut()"
(playlistInfoRequested)="facade.openPlaylistInfo()"
@@ -157,8 +157,12 @@ app-workspace-shell-context-sidebar {
}
.workspace-loading-overlay__progress-copy {
max-width: 30ch;
justify-self: center;
line-height: 1.45;
font-variant-numeric: tabular-nums;
color: var(--app-muted-color, var(--mat-sys-on-surface-variant));
text-wrap: balance;
}
.workspace-loading-overlay__action {
@@ -5,6 +5,7 @@ import {
signal,
} from '@angular/core';
import { TestBed } from '@angular/core/testing';
import { MatProgressBarModule } from '@angular/material/progress-bar';
import { RouterOutlet, provideRouter } from '@angular/router';
import {
WorkspacePortalContext,
@@ -49,17 +50,19 @@ class MockWorkspaceShellHeaderComponent {
readonly searchPlaceholder = input('');
readonly searchScopeLabel = input('');
readonly searchStatusLabel = input('');
readonly isGlobalFavoritesActive = input(false);
readonly headerShortcut = input<unknown>(null);
readonly canRefreshPlaylist = input(false);
readonly isRefreshingPlaylist = input(false);
readonly isElectron = input(false);
readonly isDownloadsView = input(false);
readonly hasActiveDownloads = input(false);
readonly headerBulkAction = input<WorkspaceHeaderBulkAction | null>(null);
readonly searchChanged = output<string>();
readonly searchSubmitted = output<string>();
readonly commandPaletteRequested = output<void>();
readonly addPlaylistRequested = output<void>();
readonly globalFavoritesRequested = output<void>();
readonly headerShortcutRequested = output<void>();
readonly refreshPlaylistRequested = output<void>();
readonly downloadsRequested = output<void>();
readonly headerBulkActionRequested = output<void>();
readonly playlistInfoRequested = output<void>();
@@ -108,10 +111,11 @@ class MockWorkspaceShellFacade {
);
readonly searchScopeLabel = signal('Movies / All Items');
readonly searchStatusLabel = signal('');
readonly isGlobalFavoritesRoute = signal(false);
readonly isPortalFavoritesAllScope = signal(false);
readonly headerShortcut = signal(null);
readonly canRefreshPlaylist = signal(false);
readonly isRefreshingPlaylist = signal(false);
readonly isDownloadsView = signal(false);
readonly hasActiveDownloads = signal(false);
readonly headerBulkAction = signal<WorkspaceHeaderBulkAction | null>(null);
readonly showContextPanel = signal(true);
readonly contextPanel = signal<WorkspaceShellContextPanel>('settings');
@@ -121,6 +125,8 @@ class MockWorkspaceShellFacade {
readonly showXtreamImportOverlay = signal(false);
readonly xtreamImportCount = signal(0);
readonly xtreamItemsToImport = signal(0);
readonly xtreamActiveImportCount = signal(0);
readonly xtreamActiveItemsToImport = signal(0);
readonly xtreamImportTitleLabel = signal(
'WORKSPACE.SHELL.XTREAM_IMPORT_TITLE'
);
@@ -133,6 +139,7 @@ class MockWorkspaceShellFacade {
readonly xtreamImportDetailLabel = signal(
'WORKSPACE.SHELL.XTREAM_IMPORT_DETAIL_REMOTE'
);
readonly xtreamImportProgressLabel = signal('');
readonly xtreamImportPhaseTone = signal<'remote' | 'local' | null>(
'remote'
);
@@ -145,8 +152,8 @@ class MockWorkspaceShellFacade {
onSearchEnter = jest.fn();
openCommandPalette = jest.fn();
openAddPlaylistDialog = jest.fn();
navigateToGlobalFavorites = jest.fn();
runHeaderShortcut = jest.fn();
refreshCurrentPlaylist = jest.fn();
openDownloadsShortcut = jest.fn();
runHeaderBulkAction = jest.fn();
openPlaylistInfo = jest.fn();
@@ -166,6 +173,7 @@ describe('WorkspaceShellComponent', () => {
.overrideComponent(WorkspaceShellComponent, {
set: {
imports: [
MatProgressBarModule,
RouterOutlet,
MockExternalPlaybackDockComponent,
MockWorkspaceShellContextSidebarComponent,
@@ -201,4 +209,46 @@ describe('WorkspaceShellComponent', () => {
fixture.nativeElement.querySelector('app-external-playback-dock')
).not.toBeNull();
});
it('renders type-aware xtream import progress copy in the overlay', async () => {
const facade = new MockWorkspaceShellFacade();
await TestBed.configureTestingModule({
imports: [WorkspaceShellComponent],
providers: [provideRouter([])],
})
.overrideComponent(WorkspaceShellComponent, {
set: {
imports: [
MatProgressBarModule,
RouterOutlet,
MockExternalPlaybackDockComponent,
MockWorkspaceShellContextSidebarComponent,
MockWorkspaceShellHeaderComponent,
MockWorkspaceShellRailComponent,
],
providers: [
{
provide: WorkspaceShellFacade,
useValue: facade,
},
],
},
})
.compileComponents();
facade.showXtreamImportOverlay.set(true);
facade.xtreamActiveImportCount.set(20);
facade.xtreamActiveItemsToImport.set(12323);
facade.xtreamImportProgressLabel.set('Movies imported: 20 / 12,323');
const fixture = TestBed.createComponent(WorkspaceShellComponent);
fixture.detectChanges();
expect(
fixture.nativeElement.querySelector(
'.workspace-loading-overlay__progress-copy'
)?.textContent
).toContain('Movies imported: 20 / 12,323');
});
});