Proyecto App Linux
This commit is contained in:
@@ -0,0 +1,3 @@
|
||||
# data-access
|
||||
|
||||
This library was generated with [Nx](https://nx.dev).
|
||||
@@ -0,0 +1,34 @@
|
||||
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: {},
|
||||
},
|
||||
];
|
||||
@@ -0,0 +1,22 @@
|
||||
export default {
|
||||
displayName: 'workspace-dashboard-data-access',
|
||||
preset: '../../../../jest.preset.js',
|
||||
setupFilesAfterEnv: ['<rootDir>/src/test-setup.ts'],
|
||||
coverageDirectory: '../../../../coverage/libs/workspace/dashboard/data-access',
|
||||
transform: {
|
||||
'^.+\\.(ts|mjs|js|html)$': [
|
||||
'jest-preset-angular',
|
||||
{
|
||||
tsconfig: '<rootDir>/tsconfig.spec.json',
|
||||
stringifyContentPathRegex: '\\.(html|svg)$',
|
||||
},
|
||||
],
|
||||
},
|
||||
transformIgnorePatterns: ['node_modules/(?!.*\\.mjs$)'],
|
||||
snapshotSerializers: [
|
||||
'jest-preset-angular/build/serializers/no-ng-attributes',
|
||||
'jest-preset-angular/build/serializers/ng-snapshot',
|
||||
'jest-preset-angular/build/serializers/html-comment',
|
||||
],
|
||||
moduleFileExtensions: ['ts', 'js', 'html'],
|
||||
};
|
||||
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"name": "workspace-dashboard-data-access",
|
||||
"$schema": "../../../../node_modules/nx/schemas/project-schema.json",
|
||||
"sourceRoot": "libs/workspace/dashboard/data-access/src",
|
||||
"prefix": "lib",
|
||||
"projectType": "library",
|
||||
"tags": ["scope:workspace", "domain:workspace", "type:data-access"],
|
||||
"targets": {
|
||||
"test": {
|
||||
"executor": "@nx/jest:jest",
|
||||
"outputs": ["{workspaceRoot}/coverage/{projectRoot}"],
|
||||
"options": {
|
||||
"jestConfig": "libs/workspace/dashboard/data-access/jest.config.ts",
|
||||
"tsConfig": "libs/workspace/dashboard/data-access/tsconfig.spec.json"
|
||||
}
|
||||
},
|
||||
"lint": {
|
||||
"executor": "@nx/eslint:lint"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export * from './lib/dashboard-data.service';
|
||||
@@ -0,0 +1,706 @@
|
||||
import { signal } from '@angular/core';
|
||||
import { TestBed } from '@angular/core/testing';
|
||||
import { Store } from '@ngrx/store';
|
||||
import { TranslateService } from '@ngx-translate/core';
|
||||
import { selectAllPlaylistsMeta, selectPlaylistsLoadingFlag } from 'm3u-state';
|
||||
import { of } from 'rxjs';
|
||||
import { DatabaseService, PlaylistsService } from 'services';
|
||||
import { Playlist, PlaylistMeta } from 'shared-interfaces';
|
||||
import { DashboardDataService } from './dashboard-data.service';
|
||||
|
||||
describe('DashboardDataService', () => {
|
||||
let service: DashboardDataService;
|
||||
const playlistsLoadedSignal = signal(true);
|
||||
|
||||
const createDefaultPlaylists = (): PlaylistMeta[] => [
|
||||
{
|
||||
_id: 'm3u-1',
|
||||
title: 'M3U Playlist',
|
||||
count: 1,
|
||||
importDate: '2026-01-01T00:00:00.000Z',
|
||||
autoRefresh: false,
|
||||
favorites: ['channel-1', 'https://example.com/stream-2.m3u8'],
|
||||
recentlyViewed: [
|
||||
{
|
||||
source: 'm3u',
|
||||
id: 'https://example.com/stream-1.m3u8',
|
||||
url: 'https://example.com/stream-1.m3u8',
|
||||
title: 'Channel One',
|
||||
channel_id: 'channel-1',
|
||||
poster_url: 'https://example.com/logo-1.png',
|
||||
tvg_id: 'tvg-1',
|
||||
tvg_name: 'Channel One',
|
||||
group_title: 'News',
|
||||
category_id: 'live',
|
||||
added_at: '2026-02-01T12:00:00.000Z',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
_id: 'xtream-1',
|
||||
title: 'Xtream Playlist',
|
||||
count: 1,
|
||||
importDate: '2026-01-01T00:00:00.000Z',
|
||||
autoRefresh: false,
|
||||
serverUrl: 'https://example.com',
|
||||
},
|
||||
];
|
||||
|
||||
const playlistsSignal = signal<PlaylistMeta[]>(createDefaultPlaylists());
|
||||
|
||||
const storeMock = {
|
||||
selectSignal: jest.fn((selector: unknown) => {
|
||||
if (selector === selectAllPlaylistsMeta) {
|
||||
return playlistsSignal;
|
||||
}
|
||||
if (selector === selectPlaylistsLoadingFlag) {
|
||||
return playlistsLoadedSignal;
|
||||
}
|
||||
return signal(null);
|
||||
}),
|
||||
dispatch: jest.fn(),
|
||||
};
|
||||
|
||||
const dbServiceMock = {
|
||||
getGlobalRecentlyAdded: jest.fn().mockResolvedValue([]),
|
||||
getGlobalRecentlyViewed: jest.fn().mockResolvedValue([]),
|
||||
getAllGlobalFavorites: jest.fn().mockResolvedValue([]),
|
||||
removeFromFavorites: jest.fn().mockResolvedValue(undefined),
|
||||
removeRecentItem: jest.fn().mockResolvedValue(undefined),
|
||||
};
|
||||
const playlistMock: Playlist = {
|
||||
_id: 'm3u-1',
|
||||
title: 'M3U Playlist',
|
||||
count: 2,
|
||||
importDate: '2026-01-01T00:00:00.000Z',
|
||||
lastUsage: '2026-01-01T00:00:00.000Z',
|
||||
autoRefresh: false,
|
||||
favorites: ['channel-1', 'https://example.com/stream-2.m3u8'],
|
||||
playlist: {
|
||||
items: [
|
||||
{
|
||||
id: 'channel-1',
|
||||
name: 'Channel One',
|
||||
url: 'https://example.com/stream-1.m3u8',
|
||||
tvg: {
|
||||
logo: 'https://example.com/logo-1.png',
|
||||
id: 'tvg-1',
|
||||
name: 'Channel One',
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'channel-2',
|
||||
name: 'Channel Two',
|
||||
url: 'https://example.com/stream-2.m3u8',
|
||||
tvg: {
|
||||
logo: 'https://example.com/logo-2.png',
|
||||
id: 'tvg-2',
|
||||
name: 'Channel Two',
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
} as Playlist;
|
||||
const playlistsServiceMock = {
|
||||
getPlaylistById: jest.fn().mockReturnValue(of(playlistMock)),
|
||||
setFavorites: jest.fn().mockReturnValue(of(undefined)),
|
||||
removeFromM3uRecentlyViewed: jest.fn().mockReturnValue(
|
||||
of({
|
||||
...playlistMock,
|
||||
recentlyViewed: [],
|
||||
})
|
||||
),
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
Object.defineProperty(window, 'electron', {
|
||||
value: {} as Window['electron'],
|
||||
configurable: true,
|
||||
});
|
||||
playlistsLoadedSignal.set(true);
|
||||
playlistsSignal.set(createDefaultPlaylists());
|
||||
playlistsServiceMock.getPlaylistById.mockClear();
|
||||
playlistsServiceMock.getPlaylistById.mockReturnValue(of(playlistMock));
|
||||
playlistsServiceMock.setFavorites.mockClear();
|
||||
playlistsServiceMock.setFavorites.mockReturnValue(of(undefined));
|
||||
playlistsServiceMock.removeFromM3uRecentlyViewed.mockClear();
|
||||
playlistsServiceMock.removeFromM3uRecentlyViewed.mockReturnValue(
|
||||
of({
|
||||
...playlistMock,
|
||||
recentlyViewed: [],
|
||||
})
|
||||
);
|
||||
dbServiceMock.getAllGlobalFavorites.mockClear();
|
||||
dbServiceMock.getAllGlobalFavorites.mockResolvedValue([]);
|
||||
dbServiceMock.getGlobalRecentlyAdded.mockClear();
|
||||
dbServiceMock.getGlobalRecentlyAdded.mockResolvedValue([]);
|
||||
dbServiceMock.getGlobalRecentlyViewed.mockClear();
|
||||
dbServiceMock.getGlobalRecentlyViewed.mockResolvedValue([]);
|
||||
dbServiceMock.removeFromFavorites.mockClear();
|
||||
dbServiceMock.removeRecentItem.mockClear();
|
||||
storeMock.dispatch.mockClear();
|
||||
|
||||
TestBed.configureTestingModule({
|
||||
providers: [
|
||||
DashboardDataService,
|
||||
{ provide: Store, useValue: storeMock },
|
||||
{ provide: DatabaseService, useValue: dbServiceMock },
|
||||
{
|
||||
provide: PlaylistsService,
|
||||
useValue: playlistsServiceMock,
|
||||
},
|
||||
{
|
||||
provide: TranslateService,
|
||||
useValue: {
|
||||
instant: (key: string) => key,
|
||||
onLangChange: of(null),
|
||||
currentLang: 'en',
|
||||
defaultLang: 'en',
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
service = TestBed.inject(DashboardDataService);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
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();
|
||||
|
||||
expect(service.globalFavoriteItems()).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
id: 'channel-1',
|
||||
title: 'Channel One',
|
||||
type: 'live',
|
||||
playlist_id: 'm3u-1',
|
||||
source: 'm3u',
|
||||
poster_url: 'https://example.com/logo-1.png',
|
||||
}),
|
||||
expect.objectContaining({
|
||||
id: 'https://example.com/stream-2.m3u8',
|
||||
title: 'Channel Two',
|
||||
type: 'live',
|
||||
playlist_id: 'm3u-1',
|
||||
source: 'm3u',
|
||||
poster_url: 'https://example.com/logo-2.png',
|
||||
}),
|
||||
])
|
||||
);
|
||||
});
|
||||
|
||||
it('includes Xtream movies and series in global favorite items', async () => {
|
||||
dbServiceMock.getAllGlobalFavorites.mockResolvedValue([
|
||||
{
|
||||
id: 51,
|
||||
category_id: 12,
|
||||
title: 'Action Movie',
|
||||
rating: '8.0',
|
||||
added_at: '2026-02-02T10:00:00.000Z',
|
||||
poster_url: 'https://example.com/movie.png',
|
||||
xtream_id: 5001,
|
||||
type: 'movie',
|
||||
playlist_id: 'xtream-1',
|
||||
playlist_name: 'Xtream Playlist',
|
||||
},
|
||||
{
|
||||
id: 52,
|
||||
category_id: 13,
|
||||
title: 'Sci-Fi Series',
|
||||
rating: '8.7',
|
||||
added_at: '2026-02-03T10:00:00.000Z',
|
||||
poster_url: 'https://example.com/series.png',
|
||||
xtream_id: 5002,
|
||||
type: 'series',
|
||||
playlist_id: 'xtream-1',
|
||||
playlist_name: 'Xtream Playlist',
|
||||
},
|
||||
]);
|
||||
|
||||
await service.reloadGlobalFavorites();
|
||||
|
||||
expect(service.globalFavoriteItems()).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
id: 51,
|
||||
title: 'Action Movie',
|
||||
type: 'movie',
|
||||
source: 'xtream',
|
||||
}),
|
||||
expect.objectContaining({
|
||||
id: 52,
|
||||
title: 'Sci-Fi Series',
|
||||
type: 'series',
|
||||
source: 'xtream',
|
||||
}),
|
||||
])
|
||||
);
|
||||
expect(
|
||||
service
|
||||
.globalFavoriteItems()
|
||||
.filter((item) => item.type === 'movie')
|
||||
).toHaveLength(1);
|
||||
expect(
|
||||
service
|
||||
.globalFavoriteItems()
|
||||
.filter((item) => item.type === 'series')
|
||||
).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('builds the M3U favorites route', async () => {
|
||||
await service.reloadGlobalFavorites();
|
||||
const m3uItem = service
|
||||
.globalFavoriteItems()
|
||||
.find((item) => item.source === 'm3u');
|
||||
|
||||
expect(m3uItem).toBeDefined();
|
||||
expect(service.getGlobalFavoriteLink(m3uItem!)).toEqual([
|
||||
'/workspace',
|
||||
'playlists',
|
||||
'm3u-1',
|
||||
'favorites',
|
||||
]);
|
||||
expect(service.getGlobalFavoriteNavigationState(m3uItem!)).toEqual({
|
||||
openLiveCollectionItem: {
|
||||
contentType: 'live',
|
||||
sourceType: 'm3u',
|
||||
playlistId: 'm3u-1',
|
||||
itemId: 'channel-1',
|
||||
title: 'Channel One',
|
||||
imageUrl: 'https://example.com/logo-1.png',
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('routes Xtream non-live favorites through the global favorites page with inline detail state', async () => {
|
||||
dbServiceMock.getAllGlobalFavorites.mockResolvedValue([
|
||||
{
|
||||
id: 51,
|
||||
category_id: 12,
|
||||
title: 'Action Movie',
|
||||
rating: '8.0',
|
||||
added_at: '2026-02-02T10:00:00.000Z',
|
||||
poster_url: 'https://example.com/movie.png',
|
||||
xtream_id: 5001,
|
||||
type: 'movie',
|
||||
playlist_id: 'xtream-1',
|
||||
playlist_name: 'Xtream Playlist',
|
||||
},
|
||||
]);
|
||||
|
||||
await service.reloadGlobalFavorites();
|
||||
const movieItem = service
|
||||
.globalFavoriteItems()
|
||||
.find((item) => item.type === 'movie');
|
||||
|
||||
expect(movieItem).toBeDefined();
|
||||
expect(service.getGlobalFavoriteLink(movieItem!)).toEqual([
|
||||
'/workspace',
|
||||
'global-favorites',
|
||||
]);
|
||||
expect(service.getGlobalFavoriteNavigationState(movieItem!)).toEqual({
|
||||
openCollectionDetailItem: {
|
||||
item: expect.objectContaining({
|
||||
uid: 'xtream::xtream-1::movie:5001',
|
||||
name: 'Action Movie',
|
||||
contentType: 'movie',
|
||||
sourceType: 'xtream',
|
||||
playlistId: 'xtream-1',
|
||||
}),
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('removes M3U favorites via PlaylistsService', async () => {
|
||||
await service.reloadGlobalFavorites();
|
||||
const m3uItem = service
|
||||
.globalFavoriteItems()
|
||||
.find((item) => item.id === 'channel-1');
|
||||
|
||||
expect(m3uItem).toBeDefined();
|
||||
|
||||
await service.removeGlobalFavorite(m3uItem!);
|
||||
|
||||
expect(playlistsServiceMock.setFavorites).toHaveBeenCalledWith(
|
||||
'm3u-1',
|
||||
['https://example.com/stream-2.m3u8']
|
||||
);
|
||||
});
|
||||
|
||||
it('includes M3U recently viewed items in global recent items', () => {
|
||||
expect(service.globalRecentItems()).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
id: 'https://example.com/stream-1.m3u8',
|
||||
title: 'Channel One',
|
||||
type: 'live',
|
||||
playlist_id: 'm3u-1',
|
||||
source: 'm3u',
|
||||
xtream_id: 'https://example.com/stream-1.m3u8',
|
||||
}),
|
||||
])
|
||||
);
|
||||
});
|
||||
|
||||
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([
|
||||
{
|
||||
id: 11,
|
||||
category_id: 77,
|
||||
title: 'Fresh Movie',
|
||||
rating: '8.1',
|
||||
added: '1774298340',
|
||||
added_at: '1774298340',
|
||||
poster_url: 'https://example.com/poster.png',
|
||||
xtream_id: 501,
|
||||
type: 'movie',
|
||||
playlist_id: 'xtream-1',
|
||||
playlist_name: 'Xtream Playlist',
|
||||
},
|
||||
]);
|
||||
|
||||
await expect(
|
||||
service.getGlobalRecentlyAddedItems('all', 25)
|
||||
).resolves.toEqual([
|
||||
expect.objectContaining({
|
||||
id: 11,
|
||||
title: 'Fresh Movie',
|
||||
type: 'movie',
|
||||
playlist_id: 'xtream-1',
|
||||
source: 'xtream',
|
||||
added_at: expect.any(String),
|
||||
}),
|
||||
]);
|
||||
expect(dbServiceMock.getGlobalRecentlyAdded).toHaveBeenCalledWith(
|
||||
'all',
|
||||
25
|
||||
);
|
||||
});
|
||||
|
||||
it('builds the Xtream recently added route', () => {
|
||||
const item = {
|
||||
id: 11,
|
||||
title: 'Fresh Series',
|
||||
type: 'series',
|
||||
playlist_id: 'xtream-1',
|
||||
playlist_name: 'Xtream Playlist',
|
||||
added_at: '2026-03-27T14:39:00.000Z',
|
||||
category_id: 77,
|
||||
xtream_id: 501,
|
||||
poster_url: 'https://example.com/poster.png',
|
||||
source: 'xtream',
|
||||
} as const;
|
||||
|
||||
expect(service.getRecentlyAddedLink(item)).toEqual([
|
||||
'/workspace',
|
||||
'xtreams',
|
||||
'xtream-1',
|
||||
'series',
|
||||
'77',
|
||||
'501',
|
||||
]);
|
||||
expect(service.getRecentlyAddedNavigationState(item)).toBeUndefined();
|
||||
});
|
||||
|
||||
it('builds the M3U recent route with collection auto-open state', () => {
|
||||
const m3uItem = service
|
||||
.globalRecentItems()
|
||||
.find((item) => item.source === 'm3u');
|
||||
|
||||
expect(m3uItem).toBeDefined();
|
||||
expect(service.getRecentItemLink(m3uItem!)).toEqual([
|
||||
'/workspace',
|
||||
'playlists',
|
||||
'm3u-1',
|
||||
'recent',
|
||||
]);
|
||||
expect(service.getRecentItemNavigationState(m3uItem!)).toEqual({
|
||||
openLiveCollectionItem: {
|
||||
contentType: 'live',
|
||||
sourceType: 'm3u',
|
||||
playlistId: 'm3u-1',
|
||||
itemId: 'https://example.com/stream-1.m3u8',
|
||||
title: 'Channel One',
|
||||
imageUrl: 'https://example.com/logo-1.png',
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('routes Xtream non-live recents through the global recent page with inline detail state', async () => {
|
||||
dbServiceMock.getGlobalRecentlyViewed.mockResolvedValue([
|
||||
{
|
||||
id: 91,
|
||||
category_id: 18,
|
||||
title: 'Recent Movie',
|
||||
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',
|
||||
playlist_name: 'Xtream Playlist',
|
||||
},
|
||||
]);
|
||||
|
||||
await service.reloadGlobalRecentItems();
|
||||
const movieItem = service
|
||||
.globalRecentItems()
|
||||
.find((item) => item.source === 'xtream');
|
||||
|
||||
expect(movieItem).toEqual(
|
||||
expect.objectContaining({
|
||||
backdrop_url: 'https://example.com/recent-movie-backdrop.png',
|
||||
})
|
||||
);
|
||||
expect(service.getRecentItemLink(movieItem!)).toEqual([
|
||||
'/workspace',
|
||||
'global-recent',
|
||||
]);
|
||||
expect(service.getRecentItemNavigationState(movieItem!)).toEqual({
|
||||
openCollectionDetailItem: {
|
||||
item: expect.objectContaining({
|
||||
uid: 'xtream::xtream-1::movie:7001',
|
||||
name: 'Recent Movie',
|
||||
contentType: 'movie',
|
||||
sourceType: 'xtream',
|
||||
playlistId: 'xtream-1',
|
||||
}),
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
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()
|
||||
.find((item) => item.source === 'm3u');
|
||||
|
||||
expect(m3uItem).toBeDefined();
|
||||
|
||||
await service.removeGlobalRecentItem(m3uItem!);
|
||||
|
||||
expect(
|
||||
playlistsServiceMock.removeFromM3uRecentlyViewed
|
||||
).toHaveBeenCalledWith('m3u-1', 'https://example.com/stream-1.m3u8');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,839 @@
|
||||
import {
|
||||
Injectable,
|
||||
NgZone,
|
||||
computed,
|
||||
effect,
|
||||
inject,
|
||||
signal,
|
||||
} from '@angular/core';
|
||||
import { toSignal } from '@angular/core/rxjs-interop';
|
||||
import { Store } from '@ngrx/store';
|
||||
import { TranslateService } from '@ngx-translate/core';
|
||||
import {
|
||||
PlaylistActions,
|
||||
selectAllPlaylistsMeta,
|
||||
selectPlaylistsLoadingFlag,
|
||||
} from 'm3u-state';
|
||||
import { firstValueFrom, startWith } from 'rxjs';
|
||||
import {
|
||||
DatabaseService,
|
||||
GlobalRecentlyAddedKind,
|
||||
PlaylistsService,
|
||||
} from 'services';
|
||||
import {
|
||||
buildPlaylistRecentItems,
|
||||
Channel,
|
||||
Playlist,
|
||||
PortalAddedItem,
|
||||
PortalActivityItem,
|
||||
PlaylistMeta,
|
||||
PortalActivityType,
|
||||
PortalFavoriteItem,
|
||||
PortalRecentItem,
|
||||
stalkerItemMatchesId,
|
||||
} from 'shared-interfaces';
|
||||
import {
|
||||
buildStalkerFavoriteItems,
|
||||
getActivityTypeLabelKey,
|
||||
mapDbFavoriteToItem,
|
||||
mapDbRecentlyAddedToItem,
|
||||
mapDbRecentToItem,
|
||||
toDateTimestamp,
|
||||
toTimestamp,
|
||||
} from './dashboard-mappers';
|
||||
import {
|
||||
buildStalkerDetailNavigationTarget,
|
||||
buildStalkerStateItem,
|
||||
buildXtreamNavigationTarget,
|
||||
getGlobalFavoriteNavigation,
|
||||
getRecentItemNavigation,
|
||||
WorkspaceNavigationTarget,
|
||||
} from '@iptvnator/portal/shared/util';
|
||||
|
||||
export type DashboardContentKind = 'all' | 'channels' | 'vod' | 'series';
|
||||
|
||||
/** @deprecated Use {@link PortalRecentItem} from `shared-interfaces` instead. */
|
||||
export type GlobalRecentItem = PortalRecentItem;
|
||||
/** @deprecated Use {@link PortalFavoriteItem} from `shared-interfaces` instead. */
|
||||
export type DashboardFavoriteItem = PortalFavoriteItem;
|
||||
/** @deprecated Use {@link PortalAddedItem} from `shared-interfaces` instead. */
|
||||
export type DashboardRecentlyAddedItem = PortalAddedItem;
|
||||
export type DashboardRecentlyAddedFilterKind = GlobalRecentlyAddedKind;
|
||||
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class DashboardDataService {
|
||||
private readonly store = inject(Store);
|
||||
private readonly dbService = inject(DatabaseService);
|
||||
private readonly playlistsService = inject(PlaylistsService);
|
||||
private readonly ngZone = inject(NgZone);
|
||||
private readonly translate = inject(TranslateService);
|
||||
private favoritesAutoRefreshEnabled = false;
|
||||
private readonly languageTick = toSignal(
|
||||
this.translate.onLangChange.pipe(startWith(null)),
|
||||
{ initialValue: null }
|
||||
);
|
||||
|
||||
private readonly xtreamGlobalRecentItems = signal<GlobalRecentItem[]>([]);
|
||||
private readonly xtreamRecentlyAddedItemsState = signal<
|
||||
DashboardRecentlyAddedItem[]
|
||||
>([]);
|
||||
private readonly xtreamGlobalFavorites = signal<DashboardFavoriteItem[]>(
|
||||
[]
|
||||
);
|
||||
private readonly m3uGlobalFavorites = signal<DashboardFavoriteItem[]>([]);
|
||||
private readonly globalRecentLoadingState = signal(true);
|
||||
private readonly globalRecentLoadedState = signal(false);
|
||||
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()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return this.playlists()
|
||||
.map((playlist) =>
|
||||
[
|
||||
playlist._id,
|
||||
playlist.serverUrl
|
||||
? 'xtream'
|
||||
: playlist.macAddress
|
||||
? 'stalker'
|
||||
: 'm3u',
|
||||
JSON.stringify(playlist.favorites ?? []),
|
||||
].join('::')
|
||||
)
|
||||
.join('|');
|
||||
});
|
||||
|
||||
readonly playlistBackedGlobalRecentItems = computed<GlobalRecentItem[]>(
|
||||
() => {
|
||||
this.languageTick();
|
||||
return buildPlaylistRecentItems(this.playlists(), {
|
||||
stalker: this.translateText(
|
||||
'WORKSPACE.DASHBOARD.STALKER_PORTAL'
|
||||
),
|
||||
m3u: this.translateText('WORKSPACE.DASHBOARD.M3U'),
|
||||
});
|
||||
}
|
||||
);
|
||||
|
||||
readonly globalRecentItems = computed<GlobalRecentItem[]>(() =>
|
||||
[
|
||||
...this.xtreamGlobalRecentItems(),
|
||||
...this.playlistBackedGlobalRecentItems(),
|
||||
]
|
||||
.sort(
|
||||
(a, b) =>
|
||||
toDateTimestamp(b.viewed_at) - toDateTimestamp(a.viewed_at)
|
||||
)
|
||||
.slice(0, 200)
|
||||
);
|
||||
|
||||
readonly stalkerGlobalFavorites = computed<DashboardFavoriteItem[]>(() => {
|
||||
this.languageTick();
|
||||
return buildStalkerFavoriteItems(
|
||||
this.playlists(),
|
||||
this.translateText('WORKSPACE.DASHBOARD.STALKER_PORTAL')
|
||||
);
|
||||
});
|
||||
|
||||
readonly globalFavoriteItems = computed(() =>
|
||||
[
|
||||
...this.xtreamGlobalFavorites(),
|
||||
...this.m3uGlobalFavorites(),
|
||||
...this.stalkerGlobalFavorites(),
|
||||
]
|
||||
.sort((a, b) => toTimestamp(b.added_at) - toTimestamp(a.added_at))
|
||||
.slice(0, 200)
|
||||
);
|
||||
|
||||
private readonly recentPlaylistActivityTimestamps = computed(() => {
|
||||
const timestamps = new Map<string, number>();
|
||||
|
||||
for (const item of this.globalRecentItems()) {
|
||||
const viewedAt = toDateTimestamp(item.viewed_at);
|
||||
const current = timestamps.get(item.playlist_id) ?? 0;
|
||||
|
||||
if (viewedAt > current) {
|
||||
timestamps.set(item.playlist_id, viewedAt);
|
||||
}
|
||||
}
|
||||
|
||||
return timestamps;
|
||||
});
|
||||
|
||||
constructor() {
|
||||
effect(() => {
|
||||
this.playlistFavoritesReloadKey();
|
||||
if (!this.playlistsLoaded() || !this.favoritesAutoRefreshEnabled) {
|
||||
return;
|
||||
}
|
||||
|
||||
void this.refreshPlaylistBackedGlobalFavorites();
|
||||
});
|
||||
|
||||
effect(() => {
|
||||
this.playlistsLoaded();
|
||||
this.finishInitialGlobalRecentLoadIfReady();
|
||||
this.finishInitialGlobalFavoritesLoadIfReady();
|
||||
});
|
||||
|
||||
effect(() => {
|
||||
if (this.hasXtreamPlaylists()) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.ngZone.run(() => {
|
||||
this.xtreamRecentlyAddedItemsState.set([]);
|
||||
this.xtreamRecentlyAddedLoadingState.set(false);
|
||||
this.xtreamRecentlyAddedLoadedState.set(true);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
readonly stats = computed(() => {
|
||||
const items = this.playlists();
|
||||
return {
|
||||
total: items.length,
|
||||
xtream: items.filter((item) => !!item.serverUrl).length,
|
||||
stalker: items.filter((item) => !!item.macAddress).length,
|
||||
m3u: items.filter((item) => !item.serverUrl && !item.macAddress)
|
||||
.length,
|
||||
};
|
||||
});
|
||||
|
||||
readonly recentPlaylists = computed(() =>
|
||||
[...this.playlists()]
|
||||
.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)
|
||||
);
|
||||
|
||||
readonly quickRecent = computed(() => this.recentPlaylists().slice(0, 4));
|
||||
|
||||
async reloadGlobalRecentItems(): Promise<void> {
|
||||
if (!this.globalRecentLoaded()) {
|
||||
this.globalRecentLoadingState.set(true);
|
||||
}
|
||||
|
||||
if (!window.electron) {
|
||||
this.xtreamGlobalRecentItems.set([]);
|
||||
this.globalRecentDbLoadedState.set(true);
|
||||
this.finishInitialGlobalRecentLoadIfReady();
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const recentItems = await this.dbService.getGlobalRecentlyViewed();
|
||||
const normalized = recentItems.map((item) =>
|
||||
mapDbRecentToItem(item)
|
||||
);
|
||||
this.ngZone.run(() => this.xtreamGlobalRecentItems.set(normalized));
|
||||
} catch (err) {
|
||||
console.warn(
|
||||
'[DashboardData] Failed to reload global recent items',
|
||||
err
|
||||
);
|
||||
this.ngZone.run(() => this.xtreamGlobalRecentItems.set([]));
|
||||
} finally {
|
||||
this.globalRecentDbLoadedState.set(true);
|
||||
this.finishInitialGlobalRecentLoadIfReady();
|
||||
}
|
||||
}
|
||||
|
||||
async reloadGlobalFavorites(): Promise<void> {
|
||||
if (!this.globalFavoritesLoaded()) {
|
||||
this.globalFavoritesLoadingState.set(true);
|
||||
}
|
||||
|
||||
const xtreamReload = this.reloadXtreamGlobalFavorites();
|
||||
const m3uReload = this.refreshPlaylistBackedGlobalFavorites();
|
||||
|
||||
await Promise.all([xtreamReload, m3uReload]);
|
||||
this.favoritesAutoRefreshEnabled = true;
|
||||
}
|
||||
|
||||
async getGlobalRecentlyAddedItems(
|
||||
kind: DashboardRecentlyAddedFilterKind,
|
||||
limit = 200
|
||||
): Promise<DashboardRecentlyAddedItem[]> {
|
||||
if (!window.electron) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const items = await this.dbService.getGlobalRecentlyAdded(kind, limit);
|
||||
return items
|
||||
.map((item) => mapDbRecentlyAddedToItem(item))
|
||||
.sort((a, b) => toTimestamp(b.added_at) - toTimestamp(a.added_at));
|
||||
}
|
||||
|
||||
async getXtreamRecentlyAddedItems(
|
||||
limit = 20
|
||||
): Promise<DashboardRecentlyAddedItem[]> {
|
||||
if (!window.electron) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const items = await this.dbService.getGlobalRecentlyAdded(
|
||||
'all',
|
||||
limit,
|
||||
'xtream'
|
||||
);
|
||||
return items
|
||||
.map((item) => mapDbRecentlyAddedToItem(item))
|
||||
.sort((a, b) => toTimestamp(b.added_at) - toTimestamp(a.added_at));
|
||||
}
|
||||
|
||||
async reloadXtreamRecentlyAddedItems(limit = 20): Promise<void> {
|
||||
if (!this.xtreamRecentlyAddedLoaded()) {
|
||||
this.xtreamRecentlyAddedLoadingState.set(true);
|
||||
}
|
||||
|
||||
if (!window.electron) {
|
||||
this.ngZone.run(() => {
|
||||
this.xtreamRecentlyAddedItemsState.set([]);
|
||||
this.xtreamRecentlyAddedLoadingState.set(false);
|
||||
this.xtreamRecentlyAddedLoadedState.set(true);
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const items = await this.getXtreamRecentlyAddedItems(limit);
|
||||
this.ngZone.run(() =>
|
||||
this.xtreamRecentlyAddedItemsState.set(items)
|
||||
);
|
||||
} catch (err) {
|
||||
console.warn(
|
||||
'[DashboardData] Failed to reload Xtream recently added items',
|
||||
err
|
||||
);
|
||||
this.ngZone.run(() => this.xtreamRecentlyAddedItemsState.set([]));
|
||||
} finally {
|
||||
this.ngZone.run(() => {
|
||||
this.xtreamRecentlyAddedLoadingState.set(false);
|
||||
this.xtreamRecentlyAddedLoadedState.set(true);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private async reloadXtreamGlobalFavorites(): Promise<void> {
|
||||
if (!window.electron) {
|
||||
this.xtreamGlobalFavorites.set([]);
|
||||
this.finishInitialGlobalFavoritesLoadIfReady();
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const favorites = await this.dbService.getAllGlobalFavorites();
|
||||
const normalized = favorites.map((item) =>
|
||||
mapDbFavoriteToItem(item)
|
||||
);
|
||||
this.ngZone.run(() => this.xtreamGlobalFavorites.set(normalized));
|
||||
} catch (err) {
|
||||
console.warn(
|
||||
'[DashboardData] Failed to reload global favorites',
|
||||
err
|
||||
);
|
||||
this.ngZone.run(() => this.xtreamGlobalFavorites.set([]));
|
||||
} finally {
|
||||
this.finishInitialGlobalFavoritesLoadIfReady();
|
||||
}
|
||||
}
|
||||
|
||||
private async refreshPlaylistBackedGlobalFavorites(): Promise<void> {
|
||||
await this.reloadM3uGlobalFavorites();
|
||||
this.finishInitialGlobalFavoritesLoadIfReady();
|
||||
}
|
||||
|
||||
private async reloadM3uGlobalFavorites(): Promise<void> {
|
||||
const m3uPlaylists = this.playlists().filter(
|
||||
(playlist) =>
|
||||
!playlist.serverUrl &&
|
||||
!playlist.macAddress &&
|
||||
Array.isArray(playlist.favorites) &&
|
||||
playlist.favorites.some(
|
||||
(favorite): favorite is string =>
|
||||
typeof favorite === 'string' &&
|
||||
favorite.trim().length > 0
|
||||
)
|
||||
);
|
||||
|
||||
if (m3uPlaylists.length === 0) {
|
||||
this.ngZone.run(() => this.m3uGlobalFavorites.set([]));
|
||||
return;
|
||||
}
|
||||
|
||||
const favoriteItems = await Promise.all(
|
||||
m3uPlaylists.map((playlist) =>
|
||||
this.loadM3uPlaylistFavorites(playlist).catch(
|
||||
(): DashboardFavoriteItem[] => []
|
||||
)
|
||||
)
|
||||
);
|
||||
const flattenedFavorites = favoriteItems.reduce(
|
||||
(acc, items) => [...acc, ...items],
|
||||
[] as DashboardFavoriteItem[]
|
||||
);
|
||||
|
||||
this.ngZone.run(() => this.m3uGlobalFavorites.set(flattenedFavorites));
|
||||
}
|
||||
|
||||
private finishInitialGlobalFavoritesLoadIfReady(): void {
|
||||
if (!this.playlistsLoaded()) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.ngZone.run(() => {
|
||||
this.globalFavoritesLoadedState.set(true);
|
||||
this.globalFavoritesLoadingState.set(false);
|
||||
});
|
||||
}
|
||||
|
||||
private finishInitialGlobalRecentLoadIfReady(): void {
|
||||
if (!this.playlistsLoaded() || !this.globalRecentDbLoadedState()) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.ngZone.run(() => {
|
||||
this.globalRecentLoadedState.set(true);
|
||||
this.globalRecentLoadingState.set(false);
|
||||
});
|
||||
}
|
||||
|
||||
isTypeInKind(
|
||||
type: PortalActivityType,
|
||||
kind: DashboardContentKind
|
||||
): boolean {
|
||||
if (kind === 'all') {
|
||||
return true;
|
||||
}
|
||||
if (kind === 'channels') {
|
||||
return type === 'live';
|
||||
}
|
||||
if (kind === 'vod') {
|
||||
return type === 'movie';
|
||||
}
|
||||
return type === 'series';
|
||||
}
|
||||
|
||||
getPlaylistLink(playlist: PlaylistMeta): string[] {
|
||||
if (playlist.serverUrl) {
|
||||
return ['/workspace', 'xtreams', playlist._id, 'vod'];
|
||||
}
|
||||
|
||||
if (playlist.macAddress) {
|
||||
return ['/workspace', 'stalker', playlist._id, 'vod'];
|
||||
}
|
||||
|
||||
return ['/workspace', 'playlists', playlist._id];
|
||||
}
|
||||
|
||||
getPlaylistProvider(playlist: PlaylistMeta): string {
|
||||
this.languageTick();
|
||||
|
||||
if (playlist.serverUrl) {
|
||||
return this.translateText('WORKSPACE.DASHBOARD.XTREAM');
|
||||
}
|
||||
|
||||
if (playlist.macAddress) {
|
||||
return this.translateText('WORKSPACE.DASHBOARD.STALKER');
|
||||
}
|
||||
|
||||
return this.translateText('WORKSPACE.DASHBOARD.M3U');
|
||||
}
|
||||
|
||||
getRecentItemProviderLabel(item: GlobalRecentItem): string {
|
||||
this.languageTick();
|
||||
|
||||
if (item.source === 'stalker') {
|
||||
return this.translateText('WORKSPACE.DASHBOARD.STALKER');
|
||||
}
|
||||
if (item.source === 'xtream') {
|
||||
return this.translateText('WORKSPACE.DASHBOARD.XTREAM');
|
||||
}
|
||||
if (item.source === 'm3u') {
|
||||
return this.translateText('WORKSPACE.DASHBOARD.M3U');
|
||||
}
|
||||
return this.translateText('WORKSPACE.DASHBOARD.PROVIDER');
|
||||
}
|
||||
|
||||
getRecentItemTypeLabel(item: GlobalRecentItem): string {
|
||||
this.languageTick();
|
||||
return this.translateText(getActivityTypeLabelKey(item.type));
|
||||
}
|
||||
|
||||
getRecentItemLink(item: GlobalRecentItem): string[] {
|
||||
return getRecentItemNavigation(item).link;
|
||||
}
|
||||
|
||||
getRecentItemNavigationState(
|
||||
item: GlobalRecentItem
|
||||
): WorkspaceNavigationTarget['state'] {
|
||||
return getRecentItemNavigation(item).state;
|
||||
}
|
||||
|
||||
async removeGlobalRecentItem(item: GlobalRecentItem): Promise<void> {
|
||||
if (item.source === 'xtream') {
|
||||
await this.dbService.removeRecentItem(
|
||||
item.id as number,
|
||||
item.playlist_id
|
||||
);
|
||||
await this.reloadGlobalRecentItems();
|
||||
return;
|
||||
}
|
||||
|
||||
if (item.source === 'stalker') {
|
||||
const updatedPlaylist = await firstValueFrom(
|
||||
this.playlistsService.removeFromPortalRecentlyViewed(
|
||||
item.playlist_id,
|
||||
item.id
|
||||
)
|
||||
);
|
||||
|
||||
this.store.dispatch(
|
||||
PlaylistActions.updatePlaylistMeta({
|
||||
playlist: {
|
||||
_id: item.playlist_id,
|
||||
recentlyViewed: updatedPlaylist?.recentlyViewed ?? [],
|
||||
} as unknown as PlaylistMeta,
|
||||
}) as any
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (item.source === 'm3u') {
|
||||
const updatedPlaylist = await firstValueFrom(
|
||||
this.playlistsService.removeFromM3uRecentlyViewed(
|
||||
item.playlist_id,
|
||||
String(item.xtream_id ?? item.id)
|
||||
)
|
||||
);
|
||||
|
||||
this.store.dispatch(
|
||||
PlaylistActions.updatePlaylistMeta({
|
||||
playlist: {
|
||||
_id: item.playlist_id,
|
||||
recentlyViewed: updatedPlaylist?.recentlyViewed ?? [],
|
||||
} as unknown as PlaylistMeta,
|
||||
}) as any
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
getFavoriteItemProviderLabel(item: DashboardFavoriteItem): string {
|
||||
return this.getActivityItemProviderLabel(item);
|
||||
}
|
||||
|
||||
getRecentlyAddedItemProviderLabel(
|
||||
item: DashboardRecentlyAddedItem
|
||||
): string {
|
||||
return this.getActivityItemProviderLabel(item);
|
||||
}
|
||||
|
||||
private getActivityItemProviderLabel(
|
||||
item: Pick<PortalActivityItem, 'source'>
|
||||
): string {
|
||||
this.languageTick();
|
||||
|
||||
if (item.source === 'stalker') {
|
||||
return this.translateText('WORKSPACE.DASHBOARD.STALKER');
|
||||
}
|
||||
if (item.source === 'xtream') {
|
||||
return this.translateText('WORKSPACE.DASHBOARD.XTREAM');
|
||||
}
|
||||
if (item.source === 'm3u') {
|
||||
return this.translateText('WORKSPACE.DASHBOARD.M3U');
|
||||
}
|
||||
return this.translateText('WORKSPACE.DASHBOARD.PROVIDER');
|
||||
}
|
||||
|
||||
getFavoriteItemTypeLabel(item: DashboardFavoriteItem): string {
|
||||
return this.getActivityItemTypeLabel(item);
|
||||
}
|
||||
|
||||
getRecentlyAddedItemTypeLabel(item: DashboardRecentlyAddedItem): string {
|
||||
return this.getActivityItemTypeLabel(item);
|
||||
}
|
||||
|
||||
private getActivityItemTypeLabel(
|
||||
item: Pick<PortalActivityItem, 'type'>
|
||||
): string {
|
||||
this.languageTick();
|
||||
return this.translateText(getActivityTypeLabelKey(item.type));
|
||||
}
|
||||
|
||||
getGlobalFavoriteLink(item: DashboardFavoriteItem): string[] {
|
||||
return getGlobalFavoriteNavigation(item).link;
|
||||
}
|
||||
|
||||
getGlobalFavoriteNavigationState(
|
||||
item: DashboardFavoriteItem
|
||||
): WorkspaceNavigationTarget['state'] {
|
||||
return getGlobalFavoriteNavigation(item).state;
|
||||
}
|
||||
|
||||
getRecentlyAddedLink(item: DashboardRecentlyAddedItem): string[] {
|
||||
if (item.source === 'stalker' && item.type !== 'live') {
|
||||
return buildStalkerDetailNavigationTarget({
|
||||
playlistId: item.playlist_id,
|
||||
type: item.type,
|
||||
categoryId: item.category_id,
|
||||
item: buildStalkerStateItem(item.stalker_item, {
|
||||
id: item.id,
|
||||
title: item.title,
|
||||
type: item.type,
|
||||
category_id: item.category_id,
|
||||
poster_url: item.poster_url,
|
||||
}),
|
||||
}).link;
|
||||
}
|
||||
|
||||
return buildXtreamNavigationTarget({
|
||||
playlistId: item.playlist_id,
|
||||
type: item.type,
|
||||
categoryId: item.category_id,
|
||||
itemId: item.xtream_id,
|
||||
title: item.title,
|
||||
imageUrl: item.poster_url,
|
||||
}).link;
|
||||
}
|
||||
|
||||
getRecentlyAddedNavigationState(
|
||||
item: DashboardRecentlyAddedItem
|
||||
): WorkspaceNavigationTarget['state'] {
|
||||
if (item.source === 'stalker' && item.type !== 'live') {
|
||||
return buildStalkerDetailNavigationTarget({
|
||||
playlistId: item.playlist_id,
|
||||
type: item.type,
|
||||
categoryId: item.category_id,
|
||||
item: buildStalkerStateItem(item.stalker_item, {
|
||||
id: item.id,
|
||||
title: item.title,
|
||||
type: item.type,
|
||||
category_id: item.category_id,
|
||||
poster_url: item.poster_url,
|
||||
}),
|
||||
}).state;
|
||||
}
|
||||
|
||||
return buildXtreamNavigationTarget({
|
||||
playlistId: item.playlist_id,
|
||||
type: item.type,
|
||||
categoryId: item.category_id,
|
||||
itemId: item.xtream_id,
|
||||
title: item.title,
|
||||
imageUrl: item.poster_url,
|
||||
}).state;
|
||||
}
|
||||
|
||||
async removeGlobalFavorite(item: DashboardFavoriteItem): Promise<void> {
|
||||
if (item.source === 'xtream') {
|
||||
await this.dbService.removeFromFavorites(
|
||||
item.id as number,
|
||||
item.playlist_id
|
||||
);
|
||||
await this.reloadGlobalFavorites();
|
||||
return;
|
||||
}
|
||||
|
||||
if (item.source === 'stalker') {
|
||||
const playlist = this.playlists().find(
|
||||
(p) => p._id === item.playlist_id
|
||||
);
|
||||
if (!playlist) return;
|
||||
|
||||
const currentFavorites = Array.isArray(playlist.favorites)
|
||||
? [...playlist.favorites]
|
||||
: [];
|
||||
const itemMatchStr = String(item.id);
|
||||
|
||||
const filteredFavorites = currentFavorites.filter(
|
||||
(raw, index) =>
|
||||
!stalkerItemMatchesId(
|
||||
raw,
|
||||
itemMatchStr,
|
||||
playlist._id,
|
||||
index
|
||||
)
|
||||
);
|
||||
|
||||
this.store.dispatch(
|
||||
PlaylistActions.updatePlaylistMeta({
|
||||
playlist: {
|
||||
_id: item.playlist_id,
|
||||
favorites: filteredFavorites,
|
||||
} as unknown as PlaylistMeta,
|
||||
}) as any
|
||||
);
|
||||
}
|
||||
|
||||
if (item.source === 'm3u') {
|
||||
const playlist = await firstValueFrom(
|
||||
this.playlistsService.getPlaylistById(item.playlist_id)
|
||||
);
|
||||
const currentFavorites = Array.isArray(playlist?.favorites)
|
||||
? playlist.favorites.filter(
|
||||
(favorite): favorite is string =>
|
||||
typeof favorite === 'string'
|
||||
)
|
||||
: [];
|
||||
const filteredFavorites = currentFavorites.filter(
|
||||
(favorite) => favorite !== String(item.id)
|
||||
);
|
||||
|
||||
await firstValueFrom(
|
||||
this.playlistsService.setFavorites(
|
||||
item.playlist_id,
|
||||
filteredFavorites
|
||||
)
|
||||
);
|
||||
await this.reloadGlobalFavorites();
|
||||
}
|
||||
}
|
||||
|
||||
formatTimestamp(value?: string | number): string {
|
||||
this.languageTick();
|
||||
|
||||
const timestamp = toTimestamp(value);
|
||||
if (!timestamp) {
|
||||
return this.translateText('WORKSPACE.DASHBOARD.NOT_YET_SYNCED');
|
||||
}
|
||||
|
||||
return new Date(timestamp).toLocaleString(this.getLocale());
|
||||
}
|
||||
|
||||
private async loadM3uPlaylistFavorites(
|
||||
playlistMeta: PlaylistMeta
|
||||
): Promise<DashboardFavoriteItem[]> {
|
||||
const playlist = (await firstValueFrom(
|
||||
this.playlistsService.getPlaylistById(playlistMeta._id)
|
||||
)) as Playlist & {
|
||||
playlist?: {
|
||||
items?: Channel[];
|
||||
};
|
||||
};
|
||||
const favorites = Array.isArray(playlist?.favorites)
|
||||
? playlist.favorites.filter(
|
||||
(favorite): favorite is string =>
|
||||
typeof favorite === 'string' && favorite.trim().length > 0
|
||||
)
|
||||
: [];
|
||||
|
||||
if (favorites.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const items: Channel[] = Array.isArray(playlist?.playlist?.items)
|
||||
? (playlist.playlist.items as Channel[])
|
||||
: [];
|
||||
const fallbackTimestamp =
|
||||
this.getM3uFavoriteTimestamp(playlistMeta) ??
|
||||
new Date(0).toISOString();
|
||||
|
||||
return items.reduce<DashboardFavoriteItem[]>((acc, channel) => {
|
||||
const channelId = String(channel.id ?? '').trim();
|
||||
const channelUrl = String(channel.url ?? '').trim();
|
||||
const matchedFavoriteId = favorites.find(
|
||||
(favorite) => favorite === channelId || favorite === channelUrl
|
||||
);
|
||||
|
||||
if (!matchedFavoriteId) {
|
||||
return acc;
|
||||
}
|
||||
|
||||
acc.push({
|
||||
id: matchedFavoriteId,
|
||||
title: channel.name?.trim() || channel.tvg?.name || channelId,
|
||||
type: 'live',
|
||||
playlist_id: playlistMeta._id,
|
||||
playlist_name:
|
||||
playlistMeta.title || playlistMeta.filename || 'M3U',
|
||||
added_at: fallbackTimestamp,
|
||||
category_id: 'live',
|
||||
xtream_id: matchedFavoriteId,
|
||||
poster_url: channel.tvg?.logo || undefined,
|
||||
source: 'm3u',
|
||||
});
|
||||
return acc;
|
||||
}, []);
|
||||
}
|
||||
|
||||
private getM3uFavoriteTimestamp(playlist: PlaylistMeta): string | null {
|
||||
if (playlist.updateDate) {
|
||||
return new Date(playlist.updateDate).toISOString();
|
||||
}
|
||||
|
||||
return typeof playlist.importDate === 'string'
|
||||
? playlist.importDate
|
||||
: 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);
|
||||
}
|
||||
|
||||
private getLocale(): string | undefined {
|
||||
return (
|
||||
this.translate.currentLang ||
|
||||
this.translate.defaultLang ||
|
||||
undefined
|
||||
);
|
||||
}
|
||||
|
||||
private translateText(
|
||||
key: string,
|
||||
params?: Record<string, string | number>
|
||||
): string {
|
||||
return this.translate.instant(key, params);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,190 @@
|
||||
import {
|
||||
GlobalFavoriteItem as DbGlobalFavoriteItem,
|
||||
GlobalRecentlyAddedItem as DbGlobalRecentlyAddedItem,
|
||||
GlobalRecentItem as DbGlobalRecentItem,
|
||||
} from 'services';
|
||||
import {
|
||||
PortalAddedItem,
|
||||
PlaylistMeta,
|
||||
PortalActivityType,
|
||||
PortalFavoriteItem,
|
||||
PortalRecentItem,
|
||||
extractStalkerItemId,
|
||||
extractStalkerItemPoster,
|
||||
extractStalkerItemTitle,
|
||||
extractStalkerItemType,
|
||||
normalizeStalkerDate,
|
||||
} from 'shared-interfaces';
|
||||
|
||||
// ────── Type / label helpers ──────
|
||||
|
||||
export function normalizeActivityType(value: string): PortalActivityType {
|
||||
return value === 'live' || value === 'series' ? value : 'movie';
|
||||
}
|
||||
|
||||
export function getActivityTypeLabelKey(type: PortalActivityType): string {
|
||||
if (type === 'live') return 'WORKSPACE.DASHBOARD.TYPE_LIVE';
|
||||
if (type === 'series') return 'WORKSPACE.DASHBOARD.TYPE_SERIES';
|
||||
return 'WORKSPACE.DASHBOARD.TYPE_MOVIE';
|
||||
}
|
||||
|
||||
// ────── Xtream DB → ViewModel ──────
|
||||
|
||||
export function mapDbFavoriteToItem(
|
||||
item: DbGlobalFavoriteItem
|
||||
): PortalFavoriteItem {
|
||||
return {
|
||||
id: item.id,
|
||||
title: item.title,
|
||||
type: normalizeActivityType(item.type),
|
||||
playlist_id: item.playlist_id,
|
||||
playlist_name: item.playlist_name,
|
||||
added_at: normalizeStalkerDate(item.added_at),
|
||||
category_id: item.category_id,
|
||||
xtream_id: item.xtream_id,
|
||||
poster_url: item.poster_url,
|
||||
backdrop_url: item.backdrop_url ?? undefined,
|
||||
source: 'xtream',
|
||||
};
|
||||
}
|
||||
|
||||
export function mapDbRecentToItem(item: DbGlobalRecentItem): PortalRecentItem {
|
||||
return {
|
||||
id: item.id,
|
||||
title: item.title,
|
||||
type: normalizeActivityType(item.type),
|
||||
playlist_id: item.playlist_id,
|
||||
playlist_name: item.playlist_name,
|
||||
viewed_at: normalizeStalkerDate(item.viewed_at),
|
||||
category_id: item.category_id,
|
||||
xtream_id: item.xtream_id,
|
||||
poster_url: item.poster_url,
|
||||
backdrop_url: item.backdrop_url ?? undefined,
|
||||
source: 'xtream',
|
||||
};
|
||||
}
|
||||
|
||||
export function mapDbRecentlyAddedToItem(
|
||||
item: DbGlobalRecentlyAddedItem
|
||||
): PortalAddedItem {
|
||||
return {
|
||||
id: item.id,
|
||||
title: item.title,
|
||||
type: normalizeActivityType(item.type),
|
||||
playlist_id: item.playlist_id,
|
||||
playlist_name: item.playlist_name,
|
||||
added_at: normalizeStalkerDate(item.added_at),
|
||||
category_id: item.category_id,
|
||||
xtream_id: item.xtream_id,
|
||||
poster_url: item.poster_url,
|
||||
source: 'xtream',
|
||||
};
|
||||
}
|
||||
|
||||
// ────── Stalker playlist → ViewModel ──────
|
||||
|
||||
export function buildStalkerRecentItems(
|
||||
playlists: PlaylistMeta[],
|
||||
defaultPlaylistName: string
|
||||
): PortalRecentItem[] {
|
||||
return playlists
|
||||
.filter((playlist) => Boolean(playlist.macAddress))
|
||||
.reduce<PortalRecentItem[]>((acc, playlist) => {
|
||||
const recentItems = Array.isArray(
|
||||
(playlist as { recentlyViewed?: unknown[] }).recentlyViewed
|
||||
)
|
||||
? ((playlist as { recentlyViewed?: unknown[] })
|
||||
.recentlyViewed ?? [])
|
||||
: [];
|
||||
|
||||
const mapped = recentItems.map((rawItem, index) => {
|
||||
const item = (rawItem ?? {}) as Record<string, unknown>;
|
||||
const id = extractStalkerItemId(item, playlist._id, index);
|
||||
|
||||
return {
|
||||
id,
|
||||
title: extractStalkerItemTitle(item),
|
||||
type: extractStalkerItemType(item),
|
||||
playlist_id: playlist._id,
|
||||
playlist_name: playlist.title || defaultPlaylistName,
|
||||
viewed_at: normalizeStalkerDate(item['added_at']),
|
||||
category_id: String(item['category_id'] ?? ''),
|
||||
xtream_id: id,
|
||||
poster_url: extractStalkerItemPoster(item),
|
||||
source: 'stalker' as const,
|
||||
stalker_item: rawItem,
|
||||
} as PortalRecentItem;
|
||||
});
|
||||
|
||||
acc.push(...mapped);
|
||||
return acc;
|
||||
}, []);
|
||||
}
|
||||
|
||||
export function buildStalkerFavoriteItems(
|
||||
playlists: PlaylistMeta[],
|
||||
defaultPlaylistName: string
|
||||
): PortalFavoriteItem[] {
|
||||
return playlists
|
||||
.filter((playlist) => Boolean(playlist.macAddress))
|
||||
.reduce<PortalFavoriteItem[]>((acc, playlist) => {
|
||||
const favorites = Array.isArray(playlist.favorites)
|
||||
? playlist.favorites
|
||||
: [];
|
||||
|
||||
const mapped = favorites.map((item, index) => {
|
||||
const raw = (item ?? {}) as Record<string, unknown>;
|
||||
const id = extractStalkerItemId(raw, playlist._id, index);
|
||||
|
||||
return {
|
||||
id,
|
||||
title: extractStalkerItemTitle(raw),
|
||||
type: extractStalkerItemType(raw),
|
||||
playlist_id: playlist._id,
|
||||
playlist_name: playlist.title || defaultPlaylistName,
|
||||
added_at: normalizeStalkerDate(raw['added_at']),
|
||||
category_id: String(raw['category_id'] ?? ''),
|
||||
xtream_id: id,
|
||||
poster_url: extractStalkerItemPoster(raw),
|
||||
source: 'stalker' as const,
|
||||
stalker_item: item,
|
||||
} as PortalFavoriteItem;
|
||||
});
|
||||
|
||||
acc.push(...mapped);
|
||||
return acc;
|
||||
}, []);
|
||||
}
|
||||
|
||||
// ────── Timestamp helpers ──────
|
||||
|
||||
export function toDateTimestamp(value: unknown): number {
|
||||
if (typeof value === 'number') {
|
||||
if (!Number.isFinite(value) || value <= 0) return 0;
|
||||
return value > 10_000_000_000 ? value : value * 1000;
|
||||
}
|
||||
|
||||
if (typeof value === 'string') {
|
||||
const trimmed = value.trim();
|
||||
if (/^\d+$/.test(trimmed)) {
|
||||
const numeric = Number(trimmed);
|
||||
if (Number.isFinite(numeric) && numeric > 0) {
|
||||
return numeric > 10_000_000_000 ? numeric : numeric * 1000;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
const parsed = Date.parse(normalizeStalkerDate(trimmed) || trimmed);
|
||||
return Number.isNaN(parsed) ? 0 : parsed;
|
||||
}
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
export function toTimestamp(value?: string | number): number {
|
||||
if (typeof value === 'number') return value;
|
||||
if (typeof value === 'string') {
|
||||
const parsed = Date.parse(normalizeStalkerDate(value) || value);
|
||||
return Number.isNaN(parsed) ? 0 : parsed;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
import { setupZoneTestEnv } from 'jest-preset-angular/setup-env/zone';
|
||||
|
||||
setupZoneTestEnv({
|
||||
errorOnUnknownElements: true,
|
||||
errorOnUnknownProperties: true,
|
||||
});
|
||||
@@ -0,0 +1,31 @@
|
||||
{
|
||||
"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"
|
||||
},
|
||||
{
|
||||
"path": "./tsconfig.spec.json"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"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"]
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"extends": "./tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "../../../../dist/out-tsc",
|
||||
"module": "commonjs",
|
||||
"moduleResolution": "node10",
|
||||
"types": ["jest", "node"]
|
||||
},
|
||||
"include": [
|
||||
"jest.config.ts",
|
||||
"src/test-setup.ts",
|
||||
"src/**/*.test.ts",
|
||||
"src/**/*.spec.ts",
|
||||
"src/**/*.d.ts"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
# feature
|
||||
|
||||
This library was generated with [Nx](https://nx.dev).
|
||||
@@ -0,0 +1,34 @@
|
||||
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: {},
|
||||
},
|
||||
];
|
||||
@@ -0,0 +1,22 @@
|
||||
export default {
|
||||
displayName: 'workspace-dashboard-feature',
|
||||
preset: '../../../../jest.preset.js',
|
||||
setupFilesAfterEnv: ['<rootDir>/src/test-setup.ts'],
|
||||
coverageDirectory: '../../../../coverage/libs/workspace/dashboard/feature',
|
||||
transform: {
|
||||
'^.+\\.(ts|mjs|js|html)$': [
|
||||
'jest-preset-angular',
|
||||
{
|
||||
tsconfig: '<rootDir>/tsconfig.spec.json',
|
||||
stringifyContentPathRegex: '\\.(html|svg)$',
|
||||
},
|
||||
],
|
||||
},
|
||||
transformIgnorePatterns: ['node_modules/(?!.*\\.mjs$)'],
|
||||
snapshotSerializers: [
|
||||
'jest-preset-angular/build/serializers/no-ng-attributes',
|
||||
'jest-preset-angular/build/serializers/ng-snapshot',
|
||||
'jest-preset-angular/build/serializers/html-comment',
|
||||
],
|
||||
moduleFileExtensions: ['ts', 'js', 'html'],
|
||||
};
|
||||
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"name": "workspace-dashboard-feature",
|
||||
"$schema": "../../../../node_modules/nx/schemas/project-schema.json",
|
||||
"sourceRoot": "libs/workspace/dashboard/feature/src",
|
||||
"prefix": "lib",
|
||||
"projectType": "library",
|
||||
"tags": ["scope:workspace", "domain:workspace", "type:feature"],
|
||||
"targets": {
|
||||
"test": {
|
||||
"executor": "@nx/jest:jest",
|
||||
"outputs": ["{workspaceRoot}/coverage/{projectRoot}"],
|
||||
"options": {
|
||||
"jestConfig": "libs/workspace/dashboard/feature/jest.config.ts",
|
||||
"tsConfig": "libs/workspace/dashboard/feature/tsconfig.spec.json"
|
||||
}
|
||||
},
|
||||
"lint": {
|
||||
"executor": "@nx/eslint:lint"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
export * from './lib/rails/workspace-dashboard-rails.component';
|
||||
export * from './lib/rails/dashboard-rail.component';
|
||||
@@ -0,0 +1,136 @@
|
||||
<section class="rail" [attr.data-test-id]="testId()">
|
||||
<header class="rail__header">
|
||||
<h2 class="rail__title">
|
||||
<span class="rail__label">{{ label() }}</span>
|
||||
@if ((totalCount() ?? items().length) > 0) {
|
||||
<span class="rail__count" aria-hidden="true">{{
|
||||
totalCount() ?? items().length
|
||||
}}</span>
|
||||
}
|
||||
</h2>
|
||||
@if (seeAllLink(); as link) {
|
||||
<a
|
||||
class="rail__see-all"
|
||||
[routerLink]="link"
|
||||
[attr.data-test-id]="testId() ? testId() + '-manage-all' : null"
|
||||
>
|
||||
<span class="rail__see-all-label">{{
|
||||
'WORKSPACE.DASHBOARD.MANAGE_ALL' | translate
|
||||
}}</span>
|
||||
<mat-icon>chevron_right</mat-icon>
|
||||
</a>
|
||||
}
|
||||
</header>
|
||||
|
||||
<div class="rail__viewport">
|
||||
<button
|
||||
type="button"
|
||||
class="rail__chev rail__chev--left"
|
||||
[class.rail__chev--visible]="canScrollLeft()"
|
||||
(click)="scrollBy(-1)"
|
||||
aria-label="Scroll left"
|
||||
tabindex="-1"
|
||||
>
|
||||
<mat-icon>chevron_left</mat-icon>
|
||||
</button>
|
||||
|
||||
<div #track class="rail__track" (scroll)="onScroll()" role="list">
|
||||
@for (card of items(); track card.id) {
|
||||
<div
|
||||
class="rail__card"
|
||||
role="listitem"
|
||||
[attr.title]="card.title"
|
||||
[attr.data-test-id]="testId() ? testId() + '-card' : null"
|
||||
>
|
||||
<a
|
||||
class="rail__card-link"
|
||||
[routerLink]="card.link"
|
||||
[state]="card.state"
|
||||
>
|
||||
<div
|
||||
class="rail__art"
|
||||
[style.aspect-ratio]="aspectRatio()"
|
||||
>
|
||||
@if (card.imageUrl && !failedImages()[card.id]) {
|
||||
<img
|
||||
[src]="card.imageUrl"
|
||||
[alt]="card.title"
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
(error)="markFailed(card.id)"
|
||||
/>
|
||||
} @else {
|
||||
<div class="rail__art-fallback">
|
||||
<mat-icon>{{ card.icon }}</mat-icon>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
<div class="rail__meta">
|
||||
<span class="rail__card-title">{{
|
||||
card.title
|
||||
}}</span>
|
||||
@if (card.subtitle) {
|
||||
<span class="rail__card-subtitle">{{
|
||||
card.subtitle
|
||||
}}</span>
|
||||
}
|
||||
</div>
|
||||
</a>
|
||||
|
||||
@if (card.actions && card.actions.length > 0) {
|
||||
<button
|
||||
type="button"
|
||||
mat-icon-button
|
||||
class="rail__action-trigger"
|
||||
[matMenuTriggerFor]="cardActionMenu"
|
||||
[matMenuTriggerData]="{ card: card }"
|
||||
[attr.aria-label]="'SHOW_MORE' | translate"
|
||||
[attr.data-test-id]="
|
||||
testId() ? testId() + '-card-actions' : null
|
||||
"
|
||||
(click)="stopActionEvent($event)"
|
||||
>
|
||||
<mat-icon>more_vert</mat-icon>
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
<mat-menu
|
||||
#cardActionMenu="matMenu"
|
||||
panelClass="dashboard-rail-action-menu"
|
||||
xPosition="before"
|
||||
>
|
||||
<ng-template matMenuContent let-card="card">
|
||||
@for (action of card.actions ?? []; track action.id) {
|
||||
<button
|
||||
mat-menu-item
|
||||
type="button"
|
||||
[disabled]="action.disabled"
|
||||
[class.rail__menu-action--danger]="action.destructive"
|
||||
[class.rail__menu-action--separated]="
|
||||
action.separatorBefore
|
||||
"
|
||||
(click)="selectAction(card, action, $event)"
|
||||
>
|
||||
<mat-icon>{{ action.icon }}</mat-icon>
|
||||
<span>{{ action.labelKey | translate }}</span>
|
||||
</button>
|
||||
}
|
||||
</ng-template>
|
||||
</mat-menu>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
class="rail__chev rail__chev--right"
|
||||
[class.rail__chev--visible]="canScrollRight()"
|
||||
(click)="scrollBy(1)"
|
||||
aria-label="Scroll right"
|
||||
tabindex="-1"
|
||||
>
|
||||
<mat-icon>chevron_right</mat-icon>
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
@@ -0,0 +1,421 @@
|
||||
: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;
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
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__card-link {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
min-width: 0;
|
||||
color: inherit;
|
||||
text-decoration: none;
|
||||
border-radius: inherit;
|
||||
|
||||
&:focus-visible {
|
||||
outline: 2px solid var(--app-selection-color, #2f7bff);
|
||||
outline-offset: 3px;
|
||||
}
|
||||
}
|
||||
|
||||
.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__action-trigger {
|
||||
position: absolute;
|
||||
top: 8px;
|
||||
right: 8px;
|
||||
z-index: 3;
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
padding: 3px;
|
||||
border-radius: 50%;
|
||||
color: #fff;
|
||||
background: transparent;
|
||||
border: 0;
|
||||
box-shadow: none;
|
||||
opacity: 0.92;
|
||||
transition:
|
||||
opacity 0.15s ease,
|
||||
transform 0.15s ease;
|
||||
--mdc-icon-button-state-layer-size: 30px;
|
||||
|
||||
mat-icon {
|
||||
font-size: 18px;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
line-height: 18px;
|
||||
}
|
||||
|
||||
&:hover,
|
||||
&:focus-visible {
|
||||
opacity: 1;
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
}
|
||||
|
||||
.rail__menu-action--separated {
|
||||
border-top: 1px solid var(--app-separator);
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.rail__menu-action--danger {
|
||||
color: var(--mat-sys-error);
|
||||
|
||||
mat-icon {
|
||||
color: var(--mat-sys-error);
|
||||
}
|
||||
}
|
||||
|
||||
.rail__meta {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 3px;
|
||||
padding: 0 2px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.rail__card-title {
|
||||
font-size: 0.85rem;
|
||||
font-weight: 600;
|
||||
line-height: 1.3;
|
||||
color: var(--app-heading-color, #1a1d23);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
letter-spacing: -0.005em;
|
||||
transition: color 0.15s ease;
|
||||
}
|
||||
|
||||
.rail__card-subtitle {
|
||||
font-size: 0.72rem;
|
||||
font-weight: 500;
|
||||
line-height: 1.4;
|
||||
color: var(--app-muted-color, #8891a4);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
letter-spacing: 0.005em;
|
||||
}
|
||||
|
||||
// Circular chevron — streaming-app pattern (Netflix/Apple TV+). High-contrast
|
||||
// dark glass works against any card color, including the muted fallback tiles
|
||||
// which previously camouflaged the buttons.
|
||||
.rail__chev {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
transform: translateY(-50%) scale(0.96);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
padding: 0;
|
||||
border: 1px solid rgba(255, 255, 255, 0.14);
|
||||
border-radius: 50%;
|
||||
background: rgba(0, 0, 0, 0.72);
|
||||
backdrop-filter: blur(12px);
|
||||
-webkit-backdrop-filter: blur(12px);
|
||||
color: #fff;
|
||||
cursor: pointer;
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
transition:
|
||||
opacity 0.18s ease,
|
||||
background-color 0.18s ease,
|
||||
transform 0.18s ease,
|
||||
box-shadow 0.18s ease;
|
||||
z-index: 2;
|
||||
box-shadow:
|
||||
0 4px 14px -4px rgba(0, 0, 0, 0.55),
|
||||
0 0 0 0 rgba(255, 255, 255, 0); // slot for hover ring
|
||||
|
||||
mat-icon {
|
||||
font-size: 24px;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
line-height: 24px;
|
||||
// Keep icon crisp over bright content
|
||||
filter: drop-shadow(0 1px 2px rgba(0, 0, 0, 0.5));
|
||||
}
|
||||
|
||||
// Peek at rest when scroll is possible — hints the affordance exists
|
||||
// without requiring the user to hover first.
|
||||
&--visible {
|
||||
opacity: 0.5;
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
background: rgba(0, 0, 0, 0.88);
|
||||
transform: translateY(-50%) scale(1.08);
|
||||
box-shadow:
|
||||
0 8px 22px -6px rgba(0, 0, 0, 0.65),
|
||||
0 0 0 4px rgba(255, 255, 255, 0.06);
|
||||
}
|
||||
|
||||
&:active {
|
||||
transform: translateY(-50%) scale(0.96);
|
||||
}
|
||||
|
||||
&:focus-visible {
|
||||
outline: 2px solid var(--app-selection-color, #78adff);
|
||||
outline-offset: 3px;
|
||||
}
|
||||
|
||||
&--left {
|
||||
left: 6px;
|
||||
}
|
||||
|
||||
&--right {
|
||||
right: 6px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (hover: none) {
|
||||
.rail__chev {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.rail__viewport::before,
|
||||
.rail__viewport::after {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 720px) {
|
||||
:host {
|
||||
--rail-card-width: 140px;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,166 @@
|
||||
import {
|
||||
AfterViewInit,
|
||||
ChangeDetectionStrategy,
|
||||
Component,
|
||||
ElementRef,
|
||||
OnDestroy,
|
||||
effect,
|
||||
input,
|
||||
output,
|
||||
signal,
|
||||
viewChild,
|
||||
} from '@angular/core';
|
||||
import { MatButtonModule } from '@angular/material/button';
|
||||
import { MatIcon } from '@angular/material/icon';
|
||||
import { MatMenuModule } from '@angular/material/menu';
|
||||
import { RouterLink } from '@angular/router';
|
||||
import { TranslatePipe } from '@ngx-translate/core';
|
||||
|
||||
export interface DashboardRailAction {
|
||||
id: string;
|
||||
labelKey: string;
|
||||
icon: string;
|
||||
destructive?: boolean;
|
||||
disabled?: boolean;
|
||||
separatorBefore?: boolean;
|
||||
}
|
||||
|
||||
export interface DashboardRailCard {
|
||||
id: string;
|
||||
title: string;
|
||||
subtitle?: string;
|
||||
imageUrl?: string;
|
||||
icon: string;
|
||||
link: string[];
|
||||
state?: Record<string, unknown>;
|
||||
actions?: DashboardRailAction[];
|
||||
}
|
||||
|
||||
export interface DashboardRailActionSelection {
|
||||
action: DashboardRailAction;
|
||||
card: DashboardRailCard;
|
||||
}
|
||||
|
||||
@Component({
|
||||
selector: 'lib-dashboard-rail',
|
||||
imports: [
|
||||
MatButtonModule,
|
||||
MatIcon,
|
||||
MatMenuModule,
|
||||
RouterLink,
|
||||
TranslatePipe,
|
||||
],
|
||||
templateUrl: './dashboard-rail.component.html',
|
||||
styleUrl: './dashboard-rail.component.scss',
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
})
|
||||
export class DashboardRailComponent implements AfterViewInit, OnDestroy {
|
||||
readonly label = input.required<string>();
|
||||
readonly items = input.required<DashboardRailCard[]>();
|
||||
readonly seeAllLink = input<string[] | null>(null);
|
||||
readonly aspectRatio = input<string>('2 / 3');
|
||||
readonly testId = input<string | null>(null);
|
||||
readonly actionSelected = output<DashboardRailActionSelection>();
|
||||
/**
|
||||
* True total in the underlying dataset. Shown as a count badge next to
|
||||
* the rail label. Falls back to `items().length` when not supplied.
|
||||
*/
|
||||
readonly totalCount = input<number | null>(null);
|
||||
|
||||
private readonly track =
|
||||
viewChild.required<ElementRef<HTMLDivElement>>('track');
|
||||
|
||||
readonly canScrollLeft = signal(false);
|
||||
readonly canScrollRight = signal(false);
|
||||
readonly failedImages = signal<Record<string, true>>({});
|
||||
private readonly viewReady = signal(false);
|
||||
|
||||
private resizeObserver?: ResizeObserver;
|
||||
private resetFrameId: number | null = null;
|
||||
private settleFrameId: number | null = null;
|
||||
|
||||
constructor() {
|
||||
effect(() => {
|
||||
this.items();
|
||||
if (!this.viewReady()) return;
|
||||
this.scheduleResetToStart();
|
||||
});
|
||||
}
|
||||
|
||||
ngAfterViewInit(): void {
|
||||
this.viewReady.set(true);
|
||||
this.updateScrollState();
|
||||
this.resizeObserver = new ResizeObserver(() =>
|
||||
this.updateScrollState()
|
||||
);
|
||||
this.resizeObserver.observe(this.track().nativeElement);
|
||||
}
|
||||
|
||||
ngOnDestroy(): void {
|
||||
this.resizeObserver?.disconnect();
|
||||
this.cancelPendingReset();
|
||||
}
|
||||
|
||||
onScroll(): void {
|
||||
this.updateScrollState();
|
||||
}
|
||||
|
||||
scrollBy(direction: 1 | -1): void {
|
||||
const el = this.track().nativeElement;
|
||||
el.scrollBy({
|
||||
left: direction * el.clientWidth * 0.85,
|
||||
behavior: 'smooth',
|
||||
});
|
||||
}
|
||||
|
||||
markFailed(id: string): void {
|
||||
this.failedImages.update((state) =>
|
||||
state[id] ? state : { ...state, [id]: true }
|
||||
);
|
||||
}
|
||||
|
||||
stopActionEvent(event: Event): void {
|
||||
event.stopPropagation();
|
||||
}
|
||||
|
||||
selectAction(
|
||||
card: DashboardRailCard,
|
||||
action: DashboardRailAction,
|
||||
event: Event
|
||||
): void {
|
||||
event.stopPropagation();
|
||||
this.actionSelected.emit({ card, action });
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
+160
@@ -0,0 +1,160 @@
|
||||
<section
|
||||
class="rails-page"
|
||||
[class.rails-page--empty]="ready() && !hasPlaylists()"
|
||||
data-test-id="dashboard-page"
|
||||
>
|
||||
@if (!ready()) {
|
||||
<!-- Precise ghosts that mirror the real hero + rails so the layout
|
||||
doesn't reflow when data arrives. -->
|
||||
<section
|
||||
class="rails-page__skeleton-hero"
|
||||
aria-hidden="true"
|
||||
aria-busy="true"
|
||||
>
|
||||
<div class="rails-page__skeleton-hero-poster"></div>
|
||||
<div class="rails-page__skeleton-hero-body">
|
||||
<div class="rails-page__skeleton-hero-title"></div>
|
||||
<div
|
||||
class="rails-page__skeleton-hero-title rails-page__skeleton-hero-title--short"
|
||||
></div>
|
||||
<div class="rails-page__skeleton-hero-subtitle"></div>
|
||||
<div class="rails-page__skeleton-hero-cta"></div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@for (_ of skeletonRails; track $index) {
|
||||
<section
|
||||
class="rails-page__skeleton-rail"
|
||||
aria-hidden="true"
|
||||
aria-busy="true"
|
||||
>
|
||||
<div class="rails-page__skeleton-header"></div>
|
||||
<div class="rails-page__skeleton-track">
|
||||
@for (slot of skeletonSlots; track slot) {
|
||||
<div class="rails-page__skeleton-card"></div>
|
||||
}
|
||||
</div>
|
||||
</section>
|
||||
}
|
||||
} @else if (!hasPlaylists()) {
|
||||
<app-empty-state
|
||||
[type]="'welcome-dashboard'"
|
||||
[showElectronOnlyValueProps]="isElectron"
|
||||
(addPlaylistClicked)="onAddPlaylist($event)"
|
||||
/>
|
||||
} @else {
|
||||
@if (hero(); as item) {
|
||||
<a
|
||||
class="hero"
|
||||
data-test-id="dashboard-hero"
|
||||
[class.hero--with-image]="
|
||||
!!item.posterUrl || !!item.backdropUrl
|
||||
"
|
||||
[class.hero--has-backdrop]="item.hasBackdrop"
|
||||
[class.hero--has-default-backdrop]="
|
||||
item.backdropSource === 'fallback'
|
||||
"
|
||||
[routerLink]="item.link"
|
||||
[state]="item.state ?? {}"
|
||||
>
|
||||
<span
|
||||
class="hero__backdrop"
|
||||
aria-hidden="true"
|
||||
[style.backgroundImage]="
|
||||
item.backdropUrl
|
||||
? 'url(' + item.backdropUrl + ')'
|
||||
: item.fallbackBackdropBackground
|
||||
"
|
||||
></span>
|
||||
@if (item.backdropUrl; as backdropUrl) {
|
||||
<img
|
||||
class="hero__image-probe"
|
||||
[src]="backdropUrl"
|
||||
alt=""
|
||||
aria-hidden="true"
|
||||
loading="eager"
|
||||
decoding="async"
|
||||
(error)="markHeroImageFailed(backdropUrl)"
|
||||
/>
|
||||
}
|
||||
<span class="hero__vignette" aria-hidden="true"></span>
|
||||
|
||||
<span class="hero__poster">
|
||||
@if (item.posterUrl; as posterUrl) {
|
||||
<img
|
||||
[src]="posterUrl"
|
||||
[alt]="item.title"
|
||||
loading="eager"
|
||||
decoding="async"
|
||||
(error)="markHeroImageFailed(posterUrl)"
|
||||
/>
|
||||
} @else {
|
||||
<span
|
||||
class="hero__poster-fallback"
|
||||
[style.backgroundImage]="
|
||||
item.fallbackPosterBackground
|
||||
"
|
||||
>
|
||||
<mat-icon>{{ item.icon }}</mat-icon>
|
||||
</span>
|
||||
}
|
||||
</span>
|
||||
|
||||
<span class="hero__body">
|
||||
<h1 class="hero__title">{{ item.title }}</h1>
|
||||
<p class="hero__subtitle">{{ item.subtitle }}</p>
|
||||
|
||||
<span class="hero__cta">
|
||||
<mat-icon>play_arrow</mat-icon>
|
||||
<span>{{
|
||||
'WORKSPACE.DASHBOARD.CONTINUE_WATCHING' | translate
|
||||
}}</span>
|
||||
</span>
|
||||
</span>
|
||||
</a>
|
||||
}
|
||||
|
||||
@if (recentlyWatchedCards().length > 0) {
|
||||
<lib-dashboard-rail
|
||||
[label]="'WORKSPACE.DASHBOARD.RECENTLY_WATCHED' | translate"
|
||||
[items]="recentlyWatchedCards()"
|
||||
[totalCount]="data.globalRecentItems().length"
|
||||
[seeAllLink]="['/workspace/global-recent']"
|
||||
[testId]="'dashboard-recently-watched-rail'"
|
||||
/>
|
||||
}
|
||||
|
||||
@if (favoriteCards().length > 0) {
|
||||
<lib-dashboard-rail
|
||||
[label]="'WORKSPACE.DASHBOARD.GLOBAL_FAVORITES' | translate"
|
||||
[items]="favoriteCards()"
|
||||
[totalCount]="data.globalFavoriteItems().length"
|
||||
[seeAllLink]="['/workspace/global-favorites']"
|
||||
[testId]="'dashboard-global-favorites-rail'"
|
||||
/>
|
||||
}
|
||||
|
||||
@if (sourceCards().length > 0) {
|
||||
<lib-dashboard-rail
|
||||
[label]="'WORKSPACE.DASHBOARD.RECENT_SOURCES' | translate"
|
||||
[items]="sourceCards()"
|
||||
[totalCount]="data.playlists().length"
|
||||
[seeAllLink]="['/workspace/sources']"
|
||||
aspectRatio="16 / 9"
|
||||
[testId]="'dashboard-recent-sources-rail'"
|
||||
(actionSelected)="onSourceActionSelected($event)"
|
||||
/>
|
||||
}
|
||||
|
||||
@if (xtreamRecentlyAddedCards().length > 0) {
|
||||
<lib-dashboard-rail
|
||||
[label]="
|
||||
'WORKSPACE.DASHBOARD.XTREAM_RECENTLY_ADDED' | translate
|
||||
"
|
||||
[items]="xtreamRecentlyAddedCards()"
|
||||
[totalCount]="data.xtreamRecentlyAddedItems().length"
|
||||
[testId]="'dashboard-xtream-recently-added-rail'"
|
||||
/>
|
||||
}
|
||||
}
|
||||
</section>
|
||||
+463
@@ -0,0 +1,463 @@
|
||||
:host {
|
||||
display: block;
|
||||
padding: 24px 28px 40px;
|
||||
box-sizing: border-box;
|
||||
min-height: 100%;
|
||||
}
|
||||
|
||||
:host(.rails-page-host--empty) {
|
||||
padding: 0;
|
||||
height: 100%;
|
||||
min-height: 100%;
|
||||
display: flex;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.rails-page {
|
||||
max-width: 1440px;
|
||||
margin: 0 auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 32px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.rails-page--empty {
|
||||
flex: 1;
|
||||
max-width: none;
|
||||
margin: 0;
|
||||
gap: 0;
|
||||
min-height: 0;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
// ── 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;
|
||||
}
|
||||
|
||||
.hero--has-default-backdrop .hero__backdrop {
|
||||
filter: none;
|
||||
transform: none;
|
||||
opacity: 0.95;
|
||||
}
|
||||
|
||||
.hero__image-probe {
|
||||
position: absolute;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
clip-path: inset(50%);
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
// 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%
|
||||
);
|
||||
background-size: cover;
|
||||
background-position: center;
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
+104
@@ -0,0 +1,104 @@
|
||||
import type { PlaylistMeta } from 'shared-interfaces';
|
||||
import {
|
||||
buildDashboardSourceActions,
|
||||
resolveDashboardHeroArtwork,
|
||||
} from './workspace-dashboard-rails.component';
|
||||
|
||||
describe('buildDashboardSourceActions', () => {
|
||||
const basePlaylist = {
|
||||
_id: 'playlist-1',
|
||||
title: 'Playlist',
|
||||
importDate: '2026-04-24T08:00:00.000Z',
|
||||
autoRefresh: false,
|
||||
} as PlaylistMeta;
|
||||
|
||||
const actionIds = (playlist: PlaylistMeta, canRefresh: boolean) =>
|
||||
buildDashboardSourceActions(playlist, canRefresh).map(
|
||||
(action) => action.id
|
||||
);
|
||||
|
||||
it('exposes refresh, info, and remove for refreshable M3U sources', () => {
|
||||
const playlist = {
|
||||
...basePlaylist,
|
||||
url: 'https://example.com/playlist.m3u',
|
||||
} as PlaylistMeta;
|
||||
|
||||
expect(actionIds(playlist, true)).toEqual([
|
||||
'refresh',
|
||||
'playlist-info',
|
||||
'remove',
|
||||
]);
|
||||
});
|
||||
|
||||
it('exposes refresh, info, account, and remove for refreshable Xtream sources', () => {
|
||||
const playlist = {
|
||||
...basePlaylist,
|
||||
serverUrl: 'https://provider.example.test',
|
||||
username: 'demo',
|
||||
password: 'secret',
|
||||
} as PlaylistMeta;
|
||||
|
||||
expect(actionIds(playlist, true)).toEqual([
|
||||
'refresh',
|
||||
'playlist-info',
|
||||
'account-info',
|
||||
'remove',
|
||||
]);
|
||||
});
|
||||
|
||||
it('exposes info and remove for Stalker sources', () => {
|
||||
const playlist = {
|
||||
...basePlaylist,
|
||||
macAddress: '00:1A:79:00:00:01',
|
||||
portalUrl: 'https://stalker.example.test',
|
||||
} as PlaylistMeta;
|
||||
|
||||
expect(actionIds(playlist, false)).toEqual(['playlist-info', 'remove']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('resolveDashboardHeroArtwork', () => {
|
||||
const item = {
|
||||
title: 'Broken Hero',
|
||||
posterUrl: 'https://images.example.test/poster.jpg',
|
||||
backdropUrl: 'https://images.example.test/backdrop.jpg',
|
||||
};
|
||||
|
||||
it('uses the explicit backdrop when both hero images are available', () => {
|
||||
expect(resolveDashboardHeroArtwork(item, {})).toMatchObject({
|
||||
backdropUrl: item.backdropUrl,
|
||||
backdropSource: 'backdrop',
|
||||
hasBackdrop: true,
|
||||
posterUrl: item.posterUrl,
|
||||
});
|
||||
});
|
||||
|
||||
it('falls back from a failed backdrop to the poster cover', () => {
|
||||
expect(
|
||||
resolveDashboardHeroArtwork(item, {
|
||||
[item.backdropUrl]: true,
|
||||
})
|
||||
).toMatchObject({
|
||||
backdropUrl: item.posterUrl,
|
||||
backdropSource: 'poster',
|
||||
hasBackdrop: false,
|
||||
posterUrl: item.posterUrl,
|
||||
});
|
||||
});
|
||||
|
||||
it('uses generated fallback artwork when backdrop and poster both fail', () => {
|
||||
const artwork = resolveDashboardHeroArtwork(item, {
|
||||
[item.backdropUrl]: true,
|
||||
[item.posterUrl]: true,
|
||||
});
|
||||
|
||||
expect(artwork).toMatchObject({
|
||||
backdropSource: 'fallback',
|
||||
hasBackdrop: false,
|
||||
posterUrl: undefined,
|
||||
backdropUrl: undefined,
|
||||
});
|
||||
expect(artwork.fallbackBackdropBackground).toContain('linear-gradient');
|
||||
expect(artwork.fallbackPosterBackground).toContain('linear-gradient');
|
||||
});
|
||||
});
|
||||
+484
@@ -0,0 +1,484 @@
|
||||
import {
|
||||
ChangeDetectionStrategy,
|
||||
Component,
|
||||
computed,
|
||||
effect,
|
||||
inject,
|
||||
signal,
|
||||
} from '@angular/core';
|
||||
import { MatButtonModule } from '@angular/material/button';
|
||||
import { MatDialog } from '@angular/material/dialog';
|
||||
import { MatIcon } from '@angular/material/icon';
|
||||
import { MatSnackBar } from '@angular/material/snack-bar';
|
||||
import { RouterLink } from '@angular/router';
|
||||
import { Store } from '@ngrx/store';
|
||||
import { TranslatePipe, TranslateService } from '@ngx-translate/core';
|
||||
import {
|
||||
EmptyStateComponent,
|
||||
PlaylistInfoComponent,
|
||||
} from '@iptvnator/playlist/shared/ui';
|
||||
import { PlaylistRefreshActionService } from '@iptvnator/playlist/shared/util';
|
||||
import {
|
||||
WORKSPACE_SHELL_ACTIONS,
|
||||
WorkspacePlaylistType,
|
||||
} from '@iptvnator/workspace/shell/util';
|
||||
import { DialogService } from 'components';
|
||||
import { PlaylistActions } from 'm3u-state';
|
||||
import { DatabaseService } from 'services';
|
||||
import {
|
||||
DashboardDataService,
|
||||
DashboardFavoriteItem,
|
||||
DashboardRecentlyAddedItem,
|
||||
GlobalRecentItem,
|
||||
} from 'workspace-dashboard-data-access';
|
||||
import { DashboardRailComponent } from './dashboard-rail.component';
|
||||
import type {
|
||||
DashboardRailAction,
|
||||
DashboardRailCard,
|
||||
DashboardRailActionSelection,
|
||||
} from './dashboard-rail.component';
|
||||
import type { PlaylistMeta } from 'shared-interfaces';
|
||||
|
||||
// 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 backdropSource: DashboardHeroBackdropSource;
|
||||
readonly fallbackBackdropBackground: string;
|
||||
readonly fallbackPosterBackground: string;
|
||||
readonly hasBackdrop: boolean;
|
||||
readonly icon: string;
|
||||
readonly link: string[];
|
||||
readonly posterUrl?: string;
|
||||
readonly state?: Record<string, unknown>;
|
||||
readonly subtitle: string;
|
||||
readonly title: string;
|
||||
}
|
||||
|
||||
export type DashboardHeroBackdropSource = 'backdrop' | 'poster' | 'fallback';
|
||||
|
||||
export interface DashboardHeroArtworkInput {
|
||||
readonly backdropUrl?: string | null;
|
||||
readonly posterUrl?: string | null;
|
||||
readonly title: string;
|
||||
}
|
||||
|
||||
export interface DashboardHeroArtwork {
|
||||
readonly backdropUrl?: string;
|
||||
readonly backdropSource: DashboardHeroBackdropSource;
|
||||
readonly fallbackBackdropBackground: string;
|
||||
readonly fallbackPosterBackground: string;
|
||||
readonly hasBackdrop: boolean;
|
||||
readonly posterUrl?: string;
|
||||
}
|
||||
|
||||
export function resolveDashboardHeroArtwork(
|
||||
item: DashboardHeroArtworkInput,
|
||||
failedImages: Record<string, true>
|
||||
): DashboardHeroArtwork {
|
||||
const posterUrl =
|
||||
item.posterUrl && !failedImages[item.posterUrl]
|
||||
? item.posterUrl
|
||||
: undefined;
|
||||
const explicitBackdropUrl =
|
||||
item.backdropUrl && !failedImages[item.backdropUrl]
|
||||
? item.backdropUrl
|
||||
: undefined;
|
||||
const backdropUrl = explicitBackdropUrl ?? posterUrl;
|
||||
const backdropSource: DashboardHeroBackdropSource = explicitBackdropUrl
|
||||
? 'backdrop'
|
||||
: posterUrl
|
||||
? 'poster'
|
||||
: 'fallback';
|
||||
|
||||
return {
|
||||
backdropUrl,
|
||||
backdropSource,
|
||||
fallbackBackdropBackground: buildFallbackBackground(
|
||||
item.title,
|
||||
50,
|
||||
15,
|
||||
80,
|
||||
5,
|
||||
60
|
||||
),
|
||||
fallbackPosterBackground: buildFallbackBackground(
|
||||
item.title,
|
||||
40,
|
||||
25,
|
||||
50,
|
||||
15,
|
||||
40
|
||||
),
|
||||
hasBackdrop: backdropSource === 'backdrop',
|
||||
posterUrl,
|
||||
};
|
||||
}
|
||||
|
||||
function buildFallbackBackground(
|
||||
title: string,
|
||||
saturationA: number,
|
||||
lightnessA: number,
|
||||
saturationB: number,
|
||||
lightnessB: number,
|
||||
hueOffset: number
|
||||
): string {
|
||||
const hue = calculateHue(title || 'placeholder');
|
||||
const h2 = (hue + hueOffset) % 360;
|
||||
return `linear-gradient(135deg, hsl(${hue}, ${saturationA}%, ${lightnessA}%) 0%, hsl(${h2}, ${saturationB}%, ${lightnessB}%) 100%)`;
|
||||
}
|
||||
|
||||
function calculateHue(text: string): number {
|
||||
let hash = 0;
|
||||
for (let i = 0; i < text.length; i++) {
|
||||
hash = text.charCodeAt(i) + ((hash << 5) - hash);
|
||||
hash = hash & hash;
|
||||
}
|
||||
return Math.abs(hash) % 360;
|
||||
}
|
||||
|
||||
export type DashboardSourceActionId =
|
||||
| 'refresh'
|
||||
| 'playlist-info'
|
||||
| 'account-info'
|
||||
| 'remove';
|
||||
|
||||
export function buildDashboardSourceActions(
|
||||
playlist: PlaylistMeta,
|
||||
canRefresh: boolean
|
||||
): DashboardRailAction[] {
|
||||
const actions: DashboardRailAction[] = [];
|
||||
|
||||
if (canRefresh) {
|
||||
actions.push({
|
||||
id: 'refresh',
|
||||
icon: 'sync',
|
||||
labelKey: playlist.serverUrl
|
||||
? 'HOME.PLAYLISTS.REFRESH_XTREAM'
|
||||
: 'HOME.PLAYLISTS.REFRESH',
|
||||
});
|
||||
}
|
||||
|
||||
actions.push({
|
||||
id: 'playlist-info',
|
||||
icon: 'edit',
|
||||
labelKey: 'HOME.PLAYLISTS.SHOW_DETAILS',
|
||||
});
|
||||
|
||||
if (isXtreamAccountPlaylist(playlist)) {
|
||||
actions.push({
|
||||
id: 'account-info',
|
||||
icon: 'person',
|
||||
labelKey: 'WORKSPACE.SHELL.ACCOUNT_INFO',
|
||||
});
|
||||
}
|
||||
|
||||
actions.push({
|
||||
id: 'remove',
|
||||
icon: 'delete',
|
||||
labelKey: 'HOME.PLAYLISTS.REMOVE_DIALOG.TITLE',
|
||||
destructive: true,
|
||||
separatorBefore: true,
|
||||
});
|
||||
|
||||
return actions;
|
||||
}
|
||||
|
||||
function isXtreamAccountPlaylist(
|
||||
playlist: PlaylistMeta
|
||||
): playlist is PlaylistMeta & {
|
||||
serverUrl: string;
|
||||
username: string;
|
||||
password: string;
|
||||
} {
|
||||
return Boolean(
|
||||
playlist.serverUrl && playlist.username && playlist.password
|
||||
);
|
||||
}
|
||||
|
||||
@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,
|
||||
host: {
|
||||
'[class.rails-page-host--empty]': 'ready() && !hasPlaylists()',
|
||||
},
|
||||
})
|
||||
export class WorkspaceDashboardRailsComponent {
|
||||
readonly data = inject(DashboardDataService);
|
||||
private readonly databaseService = inject(DatabaseService);
|
||||
private readonly dialog = inject(MatDialog);
|
||||
private readonly dialogService = inject(DialogService);
|
||||
private readonly playlistRefreshAction = inject(
|
||||
PlaylistRefreshActionService
|
||||
);
|
||||
private readonly snackBar = inject(MatSnackBar);
|
||||
private readonly store = inject(Store);
|
||||
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 isElectron = !!window.electron;
|
||||
|
||||
readonly skeletonSlots = SKELETON_CARDS_PER_RAIL;
|
||||
readonly skeletonRails = SKELETON_RAILS;
|
||||
readonly failedHeroImages = signal<Record<string, true>>({});
|
||||
|
||||
readonly hero = computed<DashboardHeroModel | null>(() => {
|
||||
const item = this.data.globalRecentItems()[0] ?? null;
|
||||
if (!item) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const artwork = resolveDashboardHeroArtwork(
|
||||
{
|
||||
backdropUrl: item.backdrop_url,
|
||||
posterUrl: item.poster_url,
|
||||
title: item.title,
|
||||
},
|
||||
this.failedHeroImages()
|
||||
);
|
||||
|
||||
return {
|
||||
...artwork,
|
||||
icon: this.typeIcon(item.type),
|
||||
link: this.data.getRecentItemLink(item),
|
||||
state: this.data.getRecentItemNavigationState(item),
|
||||
subtitle: this.buildHeroSubtitle(item),
|
||||
title: item.title,
|
||||
};
|
||||
});
|
||||
|
||||
readonly recentlyWatchedCards = computed<DashboardRailCard[]>(() =>
|
||||
this.data
|
||||
.globalRecentItems()
|
||||
.slice(0, RAIL_ITEM_LIMIT)
|
||||
.map((item) => this.toRecentCard(item))
|
||||
);
|
||||
|
||||
readonly favoriteCards = computed<DashboardRailCard[]>(() =>
|
||||
this.data
|
||||
.globalFavoriteItems()
|
||||
.slice(0, RAIL_ITEM_LIMIT)
|
||||
.map((item) => this.toFavoriteCard(item))
|
||||
);
|
||||
|
||||
readonly xtreamRecentlyAddedCards = computed<DashboardRailCard[]>(() =>
|
||||
this.data
|
||||
.xtreamRecentlyAddedItems()
|
||||
.slice(0, RAIL_ITEM_LIMIT)
|
||||
.map((item) => this.toRecentlyAddedCard(item))
|
||||
);
|
||||
|
||||
readonly sourceCards = computed<DashboardRailCard[]>(() =>
|
||||
this.data.recentPlaylists().map((playlist) => ({
|
||||
id: playlist._id,
|
||||
title:
|
||||
playlist.title ||
|
||||
playlist.filename ||
|
||||
this.t('WORKSPACE.DASHBOARD.UNTITLED_SOURCE'),
|
||||
subtitle: this.data.getPlaylistProvider(playlist),
|
||||
icon: playlist.serverUrl
|
||||
? 'cloud'
|
||||
: playlist.macAddress
|
||||
? 'cast'
|
||||
: 'folder_open',
|
||||
link: this.data.getPlaylistLink(playlist),
|
||||
actions: buildDashboardSourceActions(
|
||||
playlist,
|
||||
this.playlistRefreshAction.canRefresh(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(type?: WorkspacePlaylistType): void {
|
||||
this.shellActions.openAddPlaylistDialog(type);
|
||||
}
|
||||
|
||||
markHeroImageFailed(url: string): void {
|
||||
this.failedHeroImages.update((state) =>
|
||||
state[url] ? state : { ...state, [url]: true }
|
||||
);
|
||||
}
|
||||
|
||||
onSourceActionSelected(selection: DashboardRailActionSelection): void {
|
||||
const playlist = this.data
|
||||
.playlists()
|
||||
.find((item) => item._id === selection.card.id);
|
||||
|
||||
if (!playlist) {
|
||||
return;
|
||||
}
|
||||
|
||||
switch (selection.action.id as DashboardSourceActionId) {
|
||||
case 'refresh':
|
||||
this.playlistRefreshAction.refresh(playlist);
|
||||
break;
|
||||
case 'playlist-info':
|
||||
this.dialog.open(PlaylistInfoComponent, { data: playlist });
|
||||
break;
|
||||
case 'account-info':
|
||||
this.openXtreamAccountInfo(playlist);
|
||||
break;
|
||||
case 'remove':
|
||||
this.confirmRemovePlaylist(playlist);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
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 openXtreamAccountInfo(playlist: PlaylistMeta): void {
|
||||
if (!isXtreamAccountPlaylist(playlist)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const title =
|
||||
playlist.title ||
|
||||
playlist.filename ||
|
||||
this.t('WORKSPACE.DASHBOARD.UNTITLED_SOURCE');
|
||||
|
||||
this.shellActions.openAccountInfo({
|
||||
playlist: {
|
||||
id: playlist._id,
|
||||
name: title,
|
||||
title,
|
||||
serverUrl: playlist.serverUrl,
|
||||
username: playlist.username,
|
||||
password: playlist.password,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
private confirmRemovePlaylist(playlist: PlaylistMeta): void {
|
||||
this.dialogService.openConfirmDialog({
|
||||
title: this.translate.instant('HOME.PLAYLISTS.REMOVE_DIALOG.TITLE'),
|
||||
message: this.translate.instant(
|
||||
'HOME.PLAYLISTS.REMOVE_DIALOG.MESSAGE'
|
||||
),
|
||||
onConfirm: () => {
|
||||
void this.removePlaylist(playlist);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
private async removePlaylist(playlist: PlaylistMeta): Promise<void> {
|
||||
const operationId = playlist.serverUrl
|
||||
? this.databaseService.createOperationId('playlist-delete')
|
||||
: undefined;
|
||||
|
||||
const deleted = await this.databaseService.deletePlaylist(
|
||||
playlist._id,
|
||||
operationId ? { operationId } : undefined
|
||||
);
|
||||
|
||||
if (!deleted) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.store.dispatch(
|
||||
PlaylistActions.removePlaylist({ playlistId: playlist._id })
|
||||
);
|
||||
this.snackBar.open(
|
||||
this.translate.instant('HOME.PLAYLISTS.REMOVE_DIALOG.SUCCESS'),
|
||||
undefined,
|
||||
{ duration: 2000 }
|
||||
);
|
||||
}
|
||||
|
||||
private t(key: string): string {
|
||||
return this.translate.instant(key);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
import { setupZoneTestEnv } from 'jest-preset-angular/setup-env/zone';
|
||||
|
||||
setupZoneTestEnv({
|
||||
errorOnUnknownElements: true,
|
||||
errorOnUnknownProperties: true,
|
||||
});
|
||||
@@ -0,0 +1,28 @@
|
||||
{
|
||||
"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"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"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"]
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"extends": "./tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "../../../../dist/out-tsc",
|
||||
"module": "commonjs",
|
||||
"moduleResolution": "node10",
|
||||
"types": ["jest", "node"]
|
||||
},
|
||||
"include": [
|
||||
"jest.config.ts",
|
||||
"src/test-setup.ts",
|
||||
"src/**/*.test.ts",
|
||||
"src/**/*.spec.ts",
|
||||
"src/**/*.d.ts"
|
||||
]
|
||||
}
|
||||
Reference in New Issue
Block a user