mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-08 17:06:15 -08:00
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:
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
+130
@@ -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>
|
||||
+427
@@ -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;
|
||||
}
|
||||
}
|
||||
+210
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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',
|
||||
],
|
||||
};
|
||||
@@ -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 +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);
|
||||
}
|
||||
-48
@@ -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,
|
||||
});
|
||||
@@ -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"
|
||||
]
|
||||
}
|
||||
+8
-8
@@ -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>
|
||||
|
||||
+102
-18
@@ -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: '',
|
||||
});
|
||||
});
|
||||
});
|
||||
+86
-112
@@ -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';
|
||||
}
|
||||
}
|
||||
}
|
||||
+3
-16
@@ -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"
|
||||
|
||||
+31
-8
@@ -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();
|
||||
|
||||
|
||||
-6
@@ -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();
|
||||
}
|
||||
|
||||
+154
-10
@@ -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();
|
||||
});
|
||||
});
|
||||
+443
-279
@@ -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>
|
||||
|
||||
+9
-12
@@ -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 {
|
||||
|
||||
+55
-5
@@ -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');
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user