diff --git a/libs/workspace/dashboard/data-access/src/index.ts b/libs/workspace/dashboard/data-access/src/index.ts index cce55ccf4..968d33e39 100644 --- a/libs/workspace/dashboard/data-access/src/index.ts +++ b/libs/workspace/dashboard/data-access/src/index.ts @@ -1,3 +1 @@ -export * from './lib/dashboard-widget.model'; -export * from './lib/dashboard-layout.service'; export * from './lib/dashboard-data.service'; diff --git a/libs/workspace/dashboard/data-access/src/lib/dashboard-data.service.spec.ts b/libs/workspace/dashboard/data-access/src/lib/dashboard-data.service.spec.ts index 010da56fd..9cd36831e 100644 --- a/libs/workspace/dashboard/data-access/src/lib/dashboard-data.service.spec.ts +++ b/libs/workspace/dashboard/data-access/src/lib/dashboard-data.service.spec.ts @@ -12,7 +12,7 @@ describe('DashboardDataService', () => { let service: DashboardDataService; const playlistsLoadedSignal = signal(true); - const playlistsSignal = signal([ + const createDefaultPlaylists = (): PlaylistMeta[] => [ { _id: 'm3u-1', title: 'M3U Playlist', @@ -44,7 +44,9 @@ describe('DashboardDataService', () => { autoRefresh: false, serverUrl: 'https://example.com', }, - ]); + ]; + + const playlistsSignal = signal(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() diff --git a/libs/workspace/dashboard/data-access/src/lib/dashboard-data.service.ts b/libs/workspace/dashboard/data-access/src/lib/dashboard-data.service.ts index 91eed7f18..70c95af7e 100644 --- a/libs/workspace/dashboard/data-access/src/lib/dashboard-data.service.ts +++ b/libs/workspace/dashboard/data-access/src/lib/dashboard-data.service.ts @@ -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([]); + private readonly xtreamRecentlyAddedItemsState = signal< + DashboardRecentlyAddedItem[] + >([]); private readonly xtreamGlobalFavorites = signal( [] ); @@ -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(); + 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 { + 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 { + 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 { 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); } diff --git a/libs/workspace/dashboard/data-access/src/lib/dashboard-layout.service.spec.ts b/libs/workspace/dashboard/data-access/src/lib/dashboard-layout.service.spec.ts deleted file mode 100644 index 628b184b6..000000000 --- a/libs/workspace/dashboard/data-access/src/lib/dashboard-layout.service.spec.ts +++ /dev/null @@ -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(); - }); -}); diff --git a/libs/workspace/dashboard/data-access/src/lib/dashboard-layout.service.ts b/libs/workspace/dashboard/data-access/src/lib/dashboard-layout.service.ts deleted file mode 100644 index b90869bdd..000000000 --- a/libs/workspace/dashboard/data-access/src/lib/dashboard-layout.service.ts +++ /dev/null @@ -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(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; - 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 | 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'; - } - -} diff --git a/libs/workspace/dashboard/data-access/src/lib/dashboard-mappers.ts b/libs/workspace/dashboard/data-access/src/lib/dashboard-mappers.ts index f860c50ca..86e2ead60 100644 --- a/libs/workspace/dashboard/data-access/src/lib/dashboard-mappers.ts +++ b/libs/workspace/dashboard/data-access/src/lib/dashboard-mappers.ts @@ -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; diff --git a/libs/workspace/dashboard/data-access/src/lib/dashboard-widget.model.ts b/libs/workspace/dashboard/data-access/src/lib/dashboard-widget.model.ts deleted file mode 100644 index cc2db0160..000000000 --- a/libs/workspace/dashboard/data-access/src/lib/dashboard-widget.model.ts +++ /dev/null @@ -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, - }, -]; diff --git a/libs/workspace/dashboard/feature/src/index.ts b/libs/workspace/dashboard/feature/src/index.ts index 1826918a5..64f95abe1 100644 --- a/libs/workspace/dashboard/feature/src/index.ts +++ b/libs/workspace/dashboard/feature/src/index.ts @@ -1 +1,2 @@ -export * from './lib/workspace-dashboard.component'; +export * from './lib/rails/workspace-dashboard-rails.component'; +export * from './lib/rails/dashboard-rail.component'; diff --git a/libs/workspace/dashboard/feature/src/lib/rails/dashboard-rail.component.html b/libs/workspace/dashboard/feature/src/lib/rails/dashboard-rail.component.html new file mode 100644 index 000000000..de8e50342 --- /dev/null +++ b/libs/workspace/dashboard/feature/src/lib/rails/dashboard-rail.component.html @@ -0,0 +1,96 @@ +
+
+

+ {{ label() }} + @if ((totalCount() ?? items().length) > 0) { + + } +

+ @if (seeAllLink(); as link) { + + {{ + 'WORKSPACE.DASHBOARD.MANAGE_ALL' | translate + }} + chevron_right + + } +
+ + +
diff --git a/libs/workspace/dashboard/feature/src/lib/rails/dashboard-rail.component.scss b/libs/workspace/dashboard/feature/src/lib/rails/dashboard-rail.component.scss new file mode 100644 index 000000000..1d4c0063c --- /dev/null +++ b/libs/workspace/dashboard/feature/src/lib/rails/dashboard-rail.component.scss @@ -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; + } +} diff --git a/libs/workspace/dashboard/feature/src/lib/rails/dashboard-rail.component.ts b/libs/workspace/dashboard/feature/src/lib/rails/dashboard-rail.component.ts new file mode 100644 index 000000000..f9c37d39c --- /dev/null +++ b/libs/workspace/dashboard/feature/src/lib/rails/dashboard-rail.component.ts @@ -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; +} + +@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(); + readonly items = input.required(); + readonly seeAllLink = input(null); + readonly aspectRatio = input('2 / 3'); + readonly testId = input(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(null); + + private readonly track = viewChild.required>('track'); + + readonly canScrollLeft = signal(false); + readonly canScrollRight = signal(false); + readonly failedImages = signal>({}); + 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; + } + } +} diff --git a/libs/workspace/dashboard/feature/src/lib/rails/workspace-dashboard-rails.component.html b/libs/workspace/dashboard/feature/src/lib/rails/workspace-dashboard-rails.component.html new file mode 100644 index 000000000..4cc46d8df --- /dev/null +++ b/libs/workspace/dashboard/feature/src/lib/rails/workspace-dashboard-rails.component.html @@ -0,0 +1,130 @@ +
+ @if (!ready()) { + + + + @for (_ of skeletonRails; track $index) { + + } + } @else if (!hasPlaylists()) { + + } @else { + @if (hero(); as item) { + + @if (item.backdropUrl) { + + } + + + + @if (item.posterUrl) { + + } @else { + + {{ item.icon }} + + } + + + +

{{ item.title }}

+

{{ item.subtitle }}

+ + + play_arrow + {{ + 'WORKSPACE.DASHBOARD.CONTINUE_WATCHING' | translate + }} + +
+
+ } + + @if (recentlyWatchedCards().length > 0) { + + } + + @if (favoriteCards().length > 0) { + + } + + @if (sourceCards().length > 0) { + + } + + @if (xtreamRecentlyAddedCards().length > 0) { + + } + } +
diff --git a/libs/workspace/dashboard/feature/src/lib/rails/workspace-dashboard-rails.component.scss b/libs/workspace/dashboard/feature/src/lib/rails/workspace-dashboard-rails.component.scss new file mode 100644 index 000000000..535db41a5 --- /dev/null +++ b/libs/workspace/dashboard/feature/src/lib/rails/workspace-dashboard-rails.component.scss @@ -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; + } +} diff --git a/libs/workspace/dashboard/feature/src/lib/rails/workspace-dashboard-rails.component.ts b/libs/workspace/dashboard/feature/src/lib/rails/workspace-dashboard-rails.component.ts new file mode 100644 index 000000000..413224519 --- /dev/null +++ b/libs/workspace/dashboard/feature/src/lib/rails/workspace-dashboard-rails.component.ts @@ -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; + 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(() => { + 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(() => + this.data + .globalRecentItems() + .slice(0, RAIL_ITEM_LIMIT) + .map((item) => this.toRecentCard(item)) + ); + + readonly favoriteCards = computed(() => + this.data + .globalFavoriteItems() + .slice(0, RAIL_ITEM_LIMIT) + .map((item) => this.toFavoriteCard(item)) + ); + + readonly xtreamRecentlyAddedCards = computed(() => + this.data + .xtreamRecentlyAddedItems() + .slice(0, RAIL_ITEM_LIMIT) + .map((item) => this.toRecentlyAddedCard(item)) + ); + + readonly sourceCards = computed(() => + 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); + } +} diff --git a/libs/workspace/dashboard/feature/src/lib/workspace-dashboard.component.html b/libs/workspace/dashboard/feature/src/lib/workspace-dashboard.component.html deleted file mode 100644 index e394d3012..000000000 --- a/libs/workspace/dashboard/feature/src/lib/workspace-dashboard.component.html +++ /dev/null @@ -1,198 +0,0 @@ -
-
- @if (editMode()) { -

- {{ 'WORKSPACE.DASHBOARD.TOOLBAR_HINT_EDITING' | translate }} -

-
- - -
- } @else { -

- {{ 'WORKSPACE.DASHBOARD.TOPBAR_LABEL' | translate }} -

- - } -
- -
-
-
- @for (widget of visibleWidgets(); track widget.id) { -
- @if (editMode()) { -
- -
- - {{ - getSizeLabel(widget.size) - }} - -
- -
- } - -
- } @empty { -
-

- {{ - 'WORKSPACE.DASHBOARD.EMPTY_WIDGETS' | translate - }} -

-

- {{ - 'WORKSPACE.DASHBOARD.EMPTY_WIDGETS_HINT' - | translate - }} -

-
- } -
-
- - @if (editMode()) { - - } -
-
diff --git a/libs/workspace/dashboard/feature/src/lib/workspace-dashboard.component.scss b/libs/workspace/dashboard/feature/src/lib/workspace-dashboard.component.scss deleted file mode 100644 index c1706c76c..000000000 --- a/libs/workspace/dashboard/feature/src/lib/workspace-dashboard.component.scss +++ /dev/null @@ -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; - } -} diff --git a/libs/workspace/dashboard/feature/src/lib/workspace-dashboard.component.ts b/libs/workspace/dashboard/feature/src/lib/workspace-dashboard.component.ts deleted file mode 100644 index 80da140f8..000000000 --- a/libs/workspace/dashboard/feature/src/lib/workspace-dashboard.component.ts +++ /dev/null @@ -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): 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 = { - '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 { - return this.translate.instant(key, params); - } -} diff --git a/libs/workspace/dashboard/ui/README.md b/libs/workspace/dashboard/ui/README.md deleted file mode 100644 index 5975fa4aa..000000000 --- a/libs/workspace/dashboard/ui/README.md +++ /dev/null @@ -1,3 +0,0 @@ -# ui - -This library was generated with [Nx](https://nx.dev). diff --git a/libs/workspace/dashboard/ui/eslint.config.mjs b/libs/workspace/dashboard/ui/eslint.config.mjs deleted file mode 100644 index 51f77a345..000000000 --- a/libs/workspace/dashboard/ui/eslint.config.mjs +++ /dev/null @@ -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: {}, - }, -]; diff --git a/libs/workspace/dashboard/ui/jest.config.ts b/libs/workspace/dashboard/ui/jest.config.ts deleted file mode 100644 index d2fa01536..000000000 --- a/libs/workspace/dashboard/ui/jest.config.ts +++ /dev/null @@ -1,21 +0,0 @@ -export default { - displayName: 'workspace-dashboard-ui', - preset: '../../../../jest.preset.js', - setupFilesAfterEnv: ['/src/test-setup.ts'], - coverageDirectory: '../../../../coverage/libs/workspace/dashboard/ui', - transform: { - '^.+\\.(ts|mjs|js|html)$': [ - 'jest-preset-angular', - { - tsconfig: '/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', - ], -}; diff --git a/libs/workspace/dashboard/ui/project.json b/libs/workspace/dashboard/ui/project.json deleted file mode 100644 index 51b734732..000000000 --- a/libs/workspace/dashboard/ui/project.json +++ /dev/null @@ -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" - } - } -} diff --git a/libs/workspace/dashboard/ui/src/index.ts b/libs/workspace/dashboard/ui/src/index.ts deleted file mode 100644 index 22586f337..000000000 --- a/libs/workspace/dashboard/ui/src/index.ts +++ /dev/null @@ -1 +0,0 @@ -export * from './lib/dashboard-widget-host.component'; diff --git a/libs/workspace/dashboard/ui/src/lib/dashboard-widget-host.component.html b/libs/workspace/dashboard/ui/src/lib/dashboard-widget-host.component.html deleted file mode 100644 index f0bf57fe4..000000000 --- a/libs/workspace/dashboard/ui/src/lib/dashboard-widget-host.component.html +++ /dev/null @@ -1,25 +0,0 @@ -@switch (widget().type) { - @case ('source-stats') { - - } - @case ('continue-watching') { - - } - @case ('recently-watched') { - - } - @case ('recently-added') { - - } - @case ('global-favorites') { - - } - @default { -
-

{{ 'WORKSPACE.DASHBOARD.UNSUPPORTED_WIDGET' | translate }}

-

- {{ 'WORKSPACE.DASHBOARD.UNSUPPORTED_WIDGET_DESC' | translate }} -

-
- } -} diff --git a/libs/workspace/dashboard/ui/src/lib/dashboard-widget-host.component.scss b/libs/workspace/dashboard/ui/src/lib/dashboard-widget-host.component.scss deleted file mode 100644 index fd8d4cb8c..000000000 --- a/libs/workspace/dashboard/ui/src/lib/dashboard-widget-host.component.scss +++ /dev/null @@ -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; - } -} diff --git a/libs/workspace/dashboard/ui/src/lib/dashboard-widget-host.component.ts b/libs/workspace/dashboard/ui/src/lib/dashboard-widget-host.component.ts deleted file mode 100644 index 17c396fa4..000000000 --- a/libs/workspace/dashboard/ui/src/lib/dashboard-widget-host.component.ts +++ /dev/null @@ -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(); -} diff --git a/libs/workspace/dashboard/ui/src/lib/widgets/continue-watching-widget.component.html b/libs/workspace/dashboard/ui/src/lib/widgets/continue-watching-widget.component.html deleted file mode 100644 index aa2fdb1a8..000000000 --- a/libs/workspace/dashboard/ui/src/lib/widgets/continue-watching-widget.component.html +++ /dev/null @@ -1,103 +0,0 @@ - - @if (data.globalRecentLoading()) { -
- -
- } @else if (lastItem(); as item) { - - - - @if (hasImage(item)) { - - } @else { - - {{ typeIcon(item.type) }} - - } - - - - - - {{ typeIcon(item.type) }} - {{ typeKey(item.type) | translate }} - - {{ - item.title - }} - @if (item.playlist_name) { - {{ item.playlist_name }} - } - {{ - relativeTime(item.viewed_at) - }} - - - - - - } @else { - - } - - @if (data.quickRecent().length > 0) { -
-

- {{ 'WORKSPACE.DASHBOARD.RECENTLY_USED' | translate }} -

- @for (item of data.quickRecent(); track item._id) { - - - {{ - item.title || - item.filename || - ('WORKSPACE.DASHBOARD.UNTITLED_SOURCE' - | translate) - }} - - {{ - data.getPlaylistProvider(item) - }} - - } -
- } -
diff --git a/libs/workspace/dashboard/ui/src/lib/widgets/continue-watching-widget.component.scss b/libs/workspace/dashboard/ui/src/lib/widgets/continue-watching-widget.component.scss deleted file mode 100644 index 588624475..000000000 --- a/libs/workspace/dashboard/ui/src/lib/widgets/continue-watching-widget.component.scss +++ /dev/null @@ -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); -} diff --git a/libs/workspace/dashboard/ui/src/lib/widgets/continue-watching-widget.component.spec.ts b/libs/workspace/dashboard/ui/src/lib/widgets/continue-watching-widget.component.spec.ts deleted file mode 100644 index 0a151b59b..000000000 --- a/libs/workspace/dashboard/ui/src/lib/widgets/continue-watching-widget.component.spec.ts +++ /dev/null @@ -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; - - 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(); - }); -}); diff --git a/libs/workspace/dashboard/ui/src/lib/widgets/continue-watching-widget.component.ts b/libs/workspace/dashboard/ui/src/lib/widgets/continue-watching-widget.component.ts deleted file mode 100644 index 7c893a4ab..000000000 --- a/libs/workspace/dashboard/ui/src/lib/widgets/continue-watching-widget.component.ts +++ /dev/null @@ -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( - () => 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 | 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), - }); - } -} diff --git a/libs/workspace/dashboard/ui/src/lib/widgets/dashboard-activity-items.component.html b/libs/workspace/dashboard/ui/src/lib/widgets/dashboard-activity-items.component.html deleted file mode 100644 index 59ef86fc1..000000000 --- a/libs/workspace/dashboard/ui/src/lib/widgets/dashboard-activity-items.component.html +++ /dev/null @@ -1,93 +0,0 @@ -@if (items().length > 0) { - -} @else { -
- @if (emptyIcon()) { - {{ emptyIcon() }} - } -

{{ emptyLabel() }}

- @if (emptyHint()) { -

{{ emptyHint() }}

- } - @if (emptyActionLink() && emptyActionLabel()) { - - add - {{ emptyActionLabel() }} - - } -
-} diff --git a/libs/workspace/dashboard/ui/src/lib/widgets/dashboard-activity-items.component.scss b/libs/workspace/dashboard/ui/src/lib/widgets/dashboard-activity-items.component.scss deleted file mode 100644 index 70316176b..000000000 --- a/libs/workspace/dashboard/ui/src/lib/widgets/dashboard-activity-items.component.scss +++ /dev/null @@ -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)); - } - -} diff --git a/libs/workspace/dashboard/ui/src/lib/widgets/dashboard-activity-items.component.ts b/libs/workspace/dashboard/ui/src/lib/widgets/dashboard-activity-items.component.ts deleted file mode 100644 index 8051bc1a6..000000000 --- a/libs/workspace/dashboard/ui/src/lib/widgets/dashboard-activity-items.component.ts +++ /dev/null @@ -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; -} - -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(); - - readonly items = input.required(); - readonly emptyLabel = input(''); - readonly emptyIcon = input(); - readonly emptyHint = input(); - readonly emptyActionLabel = input(); - readonly emptyActionLink = input(); - readonly viewMode = input('list'); - readonly showRemoveAction = input(false); - readonly removeActionLabel = input(''); - - @Output() readonly itemAction = - new EventEmitter(); - - readonly failedImages = signal>({}); - - 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); - } -} diff --git a/libs/workspace/dashboard/ui/src/lib/widgets/dashboard-widget-shell.component.html b/libs/workspace/dashboard/ui/src/lib/widgets/dashboard-widget-shell.component.html deleted file mode 100644 index f8729dbdb..000000000 --- a/libs/workspace/dashboard/ui/src/lib/widgets/dashboard-widget-shell.component.html +++ /dev/null @@ -1,14 +0,0 @@ - -
-
-

{{ eyebrow() }}

-

{{ title() }}

-
-
- -
-
-
- -
-
diff --git a/libs/workspace/dashboard/ui/src/lib/widgets/dashboard-widget-shell.component.scss b/libs/workspace/dashboard/ui/src/lib/widgets/dashboard-widget-shell.component.scss deleted file mode 100644 index 3e6ad3cff..000000000 --- a/libs/workspace/dashboard/ui/src/lib/widgets/dashboard-widget-shell.component.scss +++ /dev/null @@ -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; -} diff --git a/libs/workspace/dashboard/ui/src/lib/widgets/dashboard-widget-shell.component.ts b/libs/workspace/dashboard/ui/src/lib/widgets/dashboard-widget-shell.component.ts deleted file mode 100644 index 9e3855d9f..000000000 --- a/libs/workspace/dashboard/ui/src/lib/widgets/dashboard-widget-shell.component.ts +++ /dev/null @@ -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(); - readonly title = input.required(); -} diff --git a/libs/workspace/dashboard/ui/src/lib/widgets/global-favorites-widget.component.html b/libs/workspace/dashboard/ui/src/lib/widgets/global-favorites-widget.component.html deleted file mode 100644 index a6c7dfab0..000000000 --- a/libs/workspace/dashboard/ui/src/lib/widgets/global-favorites-widget.component.html +++ /dev/null @@ -1,107 +0,0 @@ - - - - - - - - - - - - - view_list - - - grid_view - - - - - @if (data.globalFavoritesLoading()) { -
- -
- } @else { - - } -
diff --git a/libs/workspace/dashboard/ui/src/lib/widgets/global-favorites-widget.component.scss b/libs/workspace/dashboard/ui/src/lib/widgets/global-favorites-widget.component.scss deleted file mode 100644 index b1620d2c7..000000000 --- a/libs/workspace/dashboard/ui/src/lib/widgets/global-favorites-widget.component.scss +++ /dev/null @@ -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; -} diff --git a/libs/workspace/dashboard/ui/src/lib/widgets/global-favorites-widget.component.spec.ts b/libs/workspace/dashboard/ui/src/lib/widgets/global-favorites-widget.component.spec.ts deleted file mode 100644 index b71186306..000000000 --- a/libs/workspace/dashboard/ui/src/lib/widgets/global-favorites-widget.component.spec.ts +++ /dev/null @@ -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; - const globalFavoritesLoading = signal(true); - const globalFavoritesLoaded = signal(false); - const globalFavoriteItems = signal([]); - - 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(); - }); -}); diff --git a/libs/workspace/dashboard/ui/src/lib/widgets/global-favorites-widget.component.ts b/libs/workspace/dashboard/ui/src/lib/widgets/global-favorites-widget.component.ts deleted file mode 100644 index f714f647d..000000000 --- a/libs/workspace/dashboard/ui/src/lib/widgets/global-favorites-widget.component.ts +++ /dev/null @@ -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(); - readonly data = inject(DashboardDataService); - private readonly translate = inject(TranslateService); - private readonly languageTick = toSignal( - this.translate.onLangChange.pipe(startWith(null)), - { initialValue: null } - ); - readonly selectedKind = signal( - this.readStoredKind() ?? 'all' - ); - readonly viewMode = signal( - 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(() => - 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', - }); - } -} diff --git a/libs/workspace/dashboard/ui/src/lib/widgets/recent-sources-widget.component.html b/libs/workspace/dashboard/ui/src/lib/widgets/recent-sources-widget.component.html deleted file mode 100644 index 74ceca2a1..000000000 --- a/libs/workspace/dashboard/ui/src/lib/widgets/recent-sources-widget.component.html +++ /dev/null @@ -1,67 +0,0 @@ - - {{ - 'WORKSPACE.DASHBOARD.MANAGE_ALL' | translate - }} - @if (data.recentPlaylists().length > 0) { - - } @else { - - } - diff --git a/libs/workspace/dashboard/ui/src/lib/widgets/recent-sources-widget.component.scss b/libs/workspace/dashboard/ui/src/lib/widgets/recent-sources-widget.component.scss deleted file mode 100644 index f4a155e98..000000000 --- a/libs/workspace/dashboard/ui/src/lib/widgets/recent-sources-widget.component.scss +++ /dev/null @@ -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; - } -} diff --git a/libs/workspace/dashboard/ui/src/lib/widgets/recent-sources-widget.component.ts b/libs/workspace/dashboard/ui/src/lib/widgets/recent-sources-widget.component.ts deleted file mode 100644 index dce302373..000000000 --- a/libs/workspace/dashboard/ui/src/lib/widgets/recent-sources-widget.component.ts +++ /dev/null @@ -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); -} diff --git a/libs/workspace/dashboard/ui/src/lib/widgets/recently-added-widget.component.html b/libs/workspace/dashboard/ui/src/lib/widgets/recently-added-widget.component.html deleted file mode 100644 index 88561673a..000000000 --- a/libs/workspace/dashboard/ui/src/lib/widgets/recently-added-widget.component.html +++ /dev/null @@ -1,103 +0,0 @@ - - - - - - - - - - - - view_list - - - grid_view - - - - - @if (loading()) { - - } @else if (loadFailed()) { - - } @else { - - } - diff --git a/libs/workspace/dashboard/ui/src/lib/widgets/recently-added-widget.component.scss b/libs/workspace/dashboard/ui/src/lib/widgets/recently-added-widget.component.scss deleted file mode 100644 index 787b6b81a..000000000 --- a/libs/workspace/dashboard/ui/src/lib/widgets/recently-added-widget.component.scss +++ /dev/null @@ -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; -} diff --git a/libs/workspace/dashboard/ui/src/lib/widgets/recently-added-widget.component.spec.ts b/libs/workspace/dashboard/ui/src/lib/widgets/recently-added-widget.component.spec.ts deleted file mode 100644 index 4f1fdf78a..000000000 --- a/libs/workspace/dashboard/ui/src/lib/widgets/recently-added-widget.component.spec.ts +++ /dev/null @@ -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; - - 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(); - }); -}); diff --git a/libs/workspace/dashboard/ui/src/lib/widgets/recently-added-widget.component.ts b/libs/workspace/dashboard/ui/src/lib/widgets/recently-added-widget.component.ts deleted file mode 100644 index 5a4c69716..000000000 --- a/libs/workspace/dashboard/ui/src/lib/widgets/recently-added-widget.component.ts +++ /dev/null @@ -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(); - readonly data = inject(DashboardDataService); - private readonly translate = inject(TranslateService); - private readonly languageTick = toSignal( - this.translate.onLangChange.pipe(startWith(null)), - { initialValue: null } - ); - - readonly selectedKind = signal( - this.readStoredKind() ?? 'all' - ); - readonly viewMode = signal( - this.readStoredViewMode() ?? 'list' - ); - readonly items = signal([]); - readonly loading = signal(true); - readonly loadFailed = signal(false); - - readonly activityItems = computed(() => - 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 { - 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; - } -} diff --git a/libs/workspace/dashboard/ui/src/lib/widgets/recently-watched-widget.component.html b/libs/workspace/dashboard/ui/src/lib/widgets/recently-watched-widget.component.html deleted file mode 100644 index d84889824..000000000 --- a/libs/workspace/dashboard/ui/src/lib/widgets/recently-watched-widget.component.html +++ /dev/null @@ -1,108 +0,0 @@ - - - - - - - - - - - - - view_list - - - grid_view - - - - - @if (data.globalRecentLoading()) { -
- -
- } @else { - - } -
diff --git a/libs/workspace/dashboard/ui/src/lib/widgets/recently-watched-widget.component.scss b/libs/workspace/dashboard/ui/src/lib/widgets/recently-watched-widget.component.scss deleted file mode 100644 index af3db8edd..000000000 --- a/libs/workspace/dashboard/ui/src/lib/widgets/recently-watched-widget.component.scss +++ /dev/null @@ -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; -} diff --git a/libs/workspace/dashboard/ui/src/lib/widgets/recently-watched-widget.component.spec.ts b/libs/workspace/dashboard/ui/src/lib/widgets/recently-watched-widget.component.spec.ts deleted file mode 100644 index 730ed906b..000000000 --- a/libs/workspace/dashboard/ui/src/lib/widgets/recently-watched-widget.component.spec.ts +++ /dev/null @@ -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; - - 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(); - }); -}); diff --git a/libs/workspace/dashboard/ui/src/lib/widgets/recently-watched-widget.component.ts b/libs/workspace/dashboard/ui/src/lib/widgets/recently-watched-widget.component.ts deleted file mode 100644 index 82d35a02d..000000000 --- a/libs/workspace/dashboard/ui/src/lib/widgets/recently-watched-widget.component.ts +++ /dev/null @@ -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(); - readonly data = inject(DashboardDataService); - private readonly translate = inject(TranslateService); - private readonly languageTick = toSignal( - this.translate.onLangChange.pipe(startWith(null)), - { initialValue: null } - ); - readonly selectedKind = signal( - this.readStoredKind() ?? 'all' - ); - readonly viewMode = signal( - 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(() => - 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', - }); - } -} diff --git a/libs/workspace/dashboard/ui/src/lib/widgets/source-stats-widget.component.html b/libs/workspace/dashboard/ui/src/lib/widgets/source-stats-widget.component.html deleted file mode 100644 index 3d8d738dd..000000000 --- a/libs/workspace/dashboard/ui/src/lib/widgets/source-stats-widget.component.html +++ /dev/null @@ -1,53 +0,0 @@ - - @if (!data.playlistsLoaded()) { -
- -
- } @else { -
-
-
- dns -

- {{ 'WORKSPACE.DASHBOARD.TOTAL' | translate }} -

-
-

{{ data.stats().total }}

-
-
-
- tv -

- {{ 'WORKSPACE.DASHBOARD.XTREAM' | translate }} -

-
-

{{ data.stats().xtream }}

-
-
-
- router -

- {{ 'WORKSPACE.DASHBOARD.STALKER' | translate }} -

-
-

{{ data.stats().stalker }}

-
-
-
- list_alt -

- {{ 'WORKSPACE.DASHBOARD.M3U' | translate }} -

-
-

{{ data.stats().m3u }}

-
-
- } -
diff --git a/libs/workspace/dashboard/ui/src/lib/widgets/source-stats-widget.component.scss b/libs/workspace/dashboard/ui/src/lib/widgets/source-stats-widget.component.scss deleted file mode 100644 index 1479d59ab..000000000 --- a/libs/workspace/dashboard/ui/src/lib/widgets/source-stats-widget.component.scss +++ /dev/null @@ -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; -} diff --git a/libs/workspace/dashboard/ui/src/lib/widgets/source-stats-widget.component.spec.ts b/libs/workspace/dashboard/ui/src/lib/widgets/source-stats-widget.component.spec.ts deleted file mode 100644 index 21751f01d..000000000 --- a/libs/workspace/dashboard/ui/src/lib/widgets/source-stats-widget.component.spec.ts +++ /dev/null @@ -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; - - 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(); - }); -}); diff --git a/libs/workspace/dashboard/ui/src/lib/widgets/source-stats-widget.component.ts b/libs/workspace/dashboard/ui/src/lib/widgets/source-stats-widget.component.ts deleted file mode 100644 index 9fe0c62a5..000000000 --- a/libs/workspace/dashboard/ui/src/lib/widgets/source-stats-widget.component.ts +++ /dev/null @@ -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); -} diff --git a/libs/workspace/dashboard/ui/src/test-setup.ts b/libs/workspace/dashboard/ui/src/test-setup.ts deleted file mode 100644 index 0d88648e3..000000000 --- a/libs/workspace/dashboard/ui/src/test-setup.ts +++ /dev/null @@ -1,6 +0,0 @@ -import { setupZoneTestEnv } from 'jest-preset-angular/setup-env/zone'; - -setupZoneTestEnv({ - errorOnUnknownElements: true, - errorOnUnknownProperties: true, -}); diff --git a/libs/workspace/dashboard/ui/tsconfig.json b/libs/workspace/dashboard/ui/tsconfig.json deleted file mode 100644 index 7d33479d8..000000000 --- a/libs/workspace/dashboard/ui/tsconfig.json +++ /dev/null @@ -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" - } - ] -} diff --git a/libs/workspace/dashboard/ui/tsconfig.lib.json b/libs/workspace/dashboard/ui/tsconfig.lib.json deleted file mode 100644 index 7fba372dc..000000000 --- a/libs/workspace/dashboard/ui/tsconfig.lib.json +++ /dev/null @@ -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"] -} diff --git a/libs/workspace/dashboard/ui/tsconfig.spec.json b/libs/workspace/dashboard/ui/tsconfig.spec.json deleted file mode 100644 index c0bb248be..000000000 --- a/libs/workspace/dashboard/ui/tsconfig.spec.json +++ /dev/null @@ -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" - ] -} diff --git a/libs/workspace/shell/feature/src/lib/workspace-command-palette/workspace-command-palette.component.html b/libs/workspace/shell/feature/src/lib/workspace-command-palette/workspace-command-palette.component.html index 273987442..8b8a6d7c7 100644 --- a/libs/workspace/shell/feature/src/lib/workspace-command-palette/workspace-command-palette.component.html +++ b/libs/workspace/shell/feature/src/lib/workspace-command-palette/workspace-command-palette.component.html @@ -22,10 +22,10 @@
- @for (group of commandGroups(); track group.scope) { + @for (group of commandGroups(); track group.group) {

- {{ getScopeTitleKey(group.scope) | translate }} + {{ getGroupTitleKey(group.group) | translate }}

@for (command of group.items; track command.id) { }
diff --git a/libs/workspace/shell/feature/src/lib/workspace-command-palette/workspace-command-palette.component.spec.ts b/libs/workspace/shell/feature/src/lib/workspace-command-palette/workspace-command-palette.component.spec.ts index e639f7a7d..3cca4b946 100644 --- a/libs/workspace/shell/feature/src/lib/workspace-command-palette/workspace-command-palette.component.spec.ts +++ b/libs/workspace/shell/feature/src/lib/workspace-command-palette/workspace-command-palette.component.spec.ts @@ -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; 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: '', }); }); }); diff --git a/libs/workspace/shell/feature/src/lib/workspace-command-palette/workspace-command-palette.component.ts b/libs/workspace/shell/feature/src/lib/workspace-command-palette/workspace-command-palette.component.ts index 21234e462..8fa6433cc 100644 --- a/libs/workspace/shell/feature/src/lib/workspace-command-palette/workspace-command-palette.component.ts +++ b/libs/workspace/shell/feature/src/lib/workspace-command-palette/workspace-command-palette.component.ts @@ -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(() => { + readonly commandGroups = computed(() => { 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( + this.commandGroups().reduce( (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'; - } - } } diff --git a/libs/workspace/shell/feature/src/lib/workspace-shell/components/workspace-shell-header/workspace-shell-header.component.html b/libs/workspace/shell/feature/src/lib/workspace-shell/components/workspace-shell-header/workspace-shell-header.component.html index 65ac9f1cf..c07a6f8b1 100644 --- a/libs/workspace/shell/feature/src/lib/workspace-shell/components/workspace-shell-header/workspace-shell-header.component.html +++ b/libs/workspace/shell/feature/src/lib/workspace-shell/components/workspace-shell-header/workspace-shell-header.component.html @@ -75,22 +75,9 @@ sync } - - + @if (headerShortcut() || isElectron()) { + + } @if (headerShortcut(); as action) {