mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-12 20:16:15 -08:00
refactor: rework dashboard view
- Removed global favorites related properties and methods from WorkspaceShellHeaderComponent. - Updated WorkspaceShellFacade to streamline command palette commands and navigation. - Refactored command palette to include type-aware xtream import progress labels. - Enhanced WorkspaceShellComponent to display import progress with improved styling. - Updated tests to reflect changes in command palette and import progress handling.
This commit is contained in:
1 parent
fc3db71e85
commit
0e77f7b98a
69 files changed
+2603
-4939
No files matched your search
@@ -1,3 +1 @@
|
||||
export * from './lib/dashboard-widget.model';
|
||||
export * from './lib/dashboard-layout.service';
|
||||
export * from './lib/dashboard-data.service';
|
||||
@@ -12,7 +12,7 @@ describe('DashboardDataService', () => {
|
||||
let service: DashboardDataService;
|
||||
const playlistsLoadedSignal = signal(true);
|
||||
|
||||
const playlistsSignal = signal<PlaylistMeta[]>([
|
||||
const createDefaultPlaylists = (): PlaylistMeta[] => [
|
||||
{
|
||||
_id: 'm3u-1',
|
||||
title: 'M3U Playlist',
|
||||
@@ -44,7 +44,9 @@ describe('DashboardDataService', () => {
|
||||
autoRefresh: false,
|
||||
serverUrl: 'https://example.com',
|
||||
},
|
||||
]);
|
||||
];
|
||||
|
||||
const playlistsSignal = signal<PlaylistMeta[]>(createDefaultPlaylists());
|
||||
|
||||
const storeMock = {
|
||||
selectSignal: jest.fn((selector: unknown) => {
|
||||
@@ -116,6 +118,7 @@ describe('DashboardDataService', () => {
|
||||
configurable: true,
|
||||
});
|
||||
playlistsLoadedSignal.set(true);
|
||||
playlistsSignal.set(createDefaultPlaylists());
|
||||
playlistsServiceMock.getPlaylistById.mockClear();
|
||||
playlistsServiceMock.getPlaylistById.mockReturnValue(of(playlistMock));
|
||||
playlistsServiceMock.setFavorites.mockClear();
|
||||
@@ -164,6 +167,47 @@ describe('DashboardDataService', () => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
it('does not eagerly load dashboard datasets on construction', () => {
|
||||
expect(dbServiceMock.getGlobalRecentlyViewed).not.toHaveBeenCalled();
|
||||
expect(dbServiceMock.getAllGlobalFavorites).not.toHaveBeenCalled();
|
||||
expect(dbServiceMock.getGlobalRecentlyAdded).not.toHaveBeenCalled();
|
||||
expect(playlistsServiceMock.getPlaylistById).not.toHaveBeenCalled();
|
||||
expect(service.dashboardReady()).toBe(false);
|
||||
});
|
||||
|
||||
it('keeps dashboardReady false until xtream recently added finishes its first load', async () => {
|
||||
expect(service.dashboardReady()).toBe(false);
|
||||
|
||||
await service.reloadGlobalRecentItems();
|
||||
expect(service.dashboardReady()).toBe(false);
|
||||
|
||||
await service.reloadGlobalFavorites();
|
||||
expect(service.dashboardReady()).toBe(false);
|
||||
|
||||
await service.reloadXtreamRecentlyAddedItems();
|
||||
expect(service.dashboardReady()).toBe(true);
|
||||
});
|
||||
|
||||
it('does not wait on xtream recently added when no xtream playlists exist', async () => {
|
||||
playlistsSignal.set([
|
||||
{
|
||||
_id: 'm3u-1',
|
||||
title: 'M3U Playlist',
|
||||
count: 1,
|
||||
importDate: '2026-01-01T00:00:00.000Z',
|
||||
autoRefresh: false,
|
||||
favorites: [],
|
||||
recentlyViewed: [],
|
||||
},
|
||||
]);
|
||||
|
||||
await service.reloadGlobalRecentItems();
|
||||
expect(service.dashboardReady()).toBe(false);
|
||||
|
||||
await service.reloadGlobalFavorites();
|
||||
expect(service.dashboardReady()).toBe(true);
|
||||
});
|
||||
|
||||
it('includes M3U favorites in global favorite items', async () => {
|
||||
await service.reloadGlobalFavorites();
|
||||
|
||||
@@ -301,7 +345,7 @@ describe('DashboardDataService', () => {
|
||||
expect(service.getGlobalFavoriteNavigationState(movieItem!)).toEqual({
|
||||
openCollectionDetailItem: {
|
||||
item: expect.objectContaining({
|
||||
uid: 'xtream::xtream-1::5001',
|
||||
uid: 'xtream::xtream-1::movie:5001',
|
||||
name: 'Action Movie',
|
||||
contentType: 'movie',
|
||||
sourceType: 'xtream',
|
||||
@@ -342,6 +386,154 @@ describe('DashboardDataService', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('prioritizes recently used Xtream sources over newer imported M3U sources', async () => {
|
||||
playlistsSignal.set([
|
||||
{
|
||||
_id: 'm3u-fresh',
|
||||
title: 'Fresh Import',
|
||||
count: 1,
|
||||
importDate: '2026-04-20T12:00:00.000Z',
|
||||
autoRefresh: false,
|
||||
favorites: [],
|
||||
recentlyViewed: [],
|
||||
},
|
||||
{
|
||||
_id: 'xtream-1',
|
||||
title: 'Xtream Playlist',
|
||||
count: 1,
|
||||
importDate: '2026-01-01T00:00:00.000Z',
|
||||
autoRefresh: false,
|
||||
serverUrl: 'https://example.com',
|
||||
},
|
||||
]);
|
||||
dbServiceMock.getGlobalRecentlyViewed.mockResolvedValue([
|
||||
{
|
||||
id: 91,
|
||||
category_id: 18,
|
||||
title: 'Recent Movie',
|
||||
rating: '7.8',
|
||||
viewed_at: '2026-04-21T10:00:00.000Z',
|
||||
poster_url: 'https://example.com/recent-movie.png',
|
||||
xtream_id: 7001,
|
||||
type: 'movie',
|
||||
playlist_id: 'xtream-1',
|
||||
playlist_name: 'Xtream Playlist',
|
||||
},
|
||||
]);
|
||||
|
||||
await service.reloadGlobalRecentItems();
|
||||
|
||||
expect(service.recentPlaylists().map((playlist) => playlist._id)).toEqual(
|
||||
['xtream-1', 'm3u-fresh']
|
||||
);
|
||||
});
|
||||
|
||||
it('includes M3U, Xtream, and Stalker sources when all have recent activity', async () => {
|
||||
playlistsSignal.set([
|
||||
{
|
||||
_id: 'm3u-1',
|
||||
title: 'M3U Playlist',
|
||||
count: 1,
|
||||
importDate: '2026-01-01T00:00:00.000Z',
|
||||
autoRefresh: false,
|
||||
favorites: [],
|
||||
recentlyViewed: [
|
||||
{
|
||||
source: 'm3u',
|
||||
id: 'https://example.com/stream-1.m3u8',
|
||||
url: 'https://example.com/stream-1.m3u8',
|
||||
title: 'Channel One',
|
||||
category_id: 'live',
|
||||
added_at: '2026-04-19T10:00:00.000Z',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
_id: 'stalker-1',
|
||||
title: 'Stalker Playlist',
|
||||
count: 1,
|
||||
importDate: '2026-01-01T00:00:00.000Z',
|
||||
autoRefresh: false,
|
||||
macAddress: '00:11:22:33:44:55',
|
||||
recentlyViewed: [
|
||||
{
|
||||
id: 'stalker-item-1',
|
||||
name: 'Stalker Movie',
|
||||
category_id: 'vod',
|
||||
added_at: '2026-04-20T10:00:00.000Z',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
_id: 'xtream-1',
|
||||
title: 'Xtream Playlist',
|
||||
count: 1,
|
||||
importDate: '2026-01-01T00:00:00.000Z',
|
||||
autoRefresh: false,
|
||||
serverUrl: 'https://example.com',
|
||||
},
|
||||
]);
|
||||
dbServiceMock.getGlobalRecentlyViewed.mockResolvedValue([
|
||||
{
|
||||
id: 91,
|
||||
category_id: 18,
|
||||
title: 'Recent Movie',
|
||||
rating: '7.8',
|
||||
viewed_at: '2026-04-21T10:00:00.000Z',
|
||||
poster_url: 'https://example.com/recent-movie.png',
|
||||
xtream_id: 7001,
|
||||
type: 'movie',
|
||||
playlist_id: 'xtream-1',
|
||||
playlist_name: 'Xtream Playlist',
|
||||
},
|
||||
]);
|
||||
|
||||
await service.reloadGlobalRecentItems();
|
||||
|
||||
expect(service.recentPlaylists().map((playlist) => playlist._id)).toEqual(
|
||||
['xtream-1', 'stalker-1', 'm3u-1']
|
||||
);
|
||||
});
|
||||
|
||||
it('falls back to playlist metadata ordering when sources have no recent activity', async () => {
|
||||
playlistsSignal.set([
|
||||
{
|
||||
_id: 'xtream-1',
|
||||
title: 'Xtream Playlist',
|
||||
count: 1,
|
||||
importDate: '2026-01-01T00:00:00.000Z',
|
||||
autoRefresh: false,
|
||||
serverUrl: 'https://example.com',
|
||||
},
|
||||
{
|
||||
_id: 'stalker-1',
|
||||
title: 'Stalker Playlist',
|
||||
count: 1,
|
||||
importDate: '2026-03-01T00:00:00.000Z',
|
||||
autoRefresh: false,
|
||||
macAddress: '00:11:22:33:44:55',
|
||||
recentlyViewed: [],
|
||||
},
|
||||
{
|
||||
_id: 'm3u-1',
|
||||
title: 'M3U Playlist',
|
||||
count: 1,
|
||||
importDate: '2026-02-01T00:00:00.000Z',
|
||||
updateDate: Date.parse('2026-04-01T00:00:00.000Z'),
|
||||
autoRefresh: false,
|
||||
favorites: [],
|
||||
recentlyViewed: [],
|
||||
},
|
||||
]);
|
||||
dbServiceMock.getGlobalRecentlyViewed.mockResolvedValue([]);
|
||||
|
||||
await service.reloadGlobalRecentItems();
|
||||
|
||||
expect(service.recentPlaylists().map((playlist) => playlist._id)).toEqual(
|
||||
['m3u-1', 'stalker-1', 'xtream-1']
|
||||
);
|
||||
});
|
||||
|
||||
it('maps recently added Xtream rows for the widget', async () => {
|
||||
dbServiceMock.getGlobalRecentlyAdded.mockResolvedValue([
|
||||
{
|
||||
@@ -435,6 +627,7 @@ describe('DashboardDataService', () => {
|
||||
rating: '7.8',
|
||||
viewed_at: '2026-02-04T10:00:00.000Z',
|
||||
poster_url: 'https://example.com/recent-movie.png',
|
||||
backdrop_url: 'https://example.com/recent-movie-backdrop.png',
|
||||
xtream_id: 7001,
|
||||
type: 'movie',
|
||||
playlist_id: 'xtream-1',
|
||||
@@ -447,7 +640,11 @@ describe('DashboardDataService', () => {
|
||||
.globalRecentItems()
|
||||
.find((item) => item.source === 'xtream');
|
||||
|
||||
expect(movieItem).toBeDefined();
|
||||
expect(movieItem).toEqual(
|
||||
expect.objectContaining({
|
||||
backdrop_url: 'https://example.com/recent-movie-backdrop.png',
|
||||
})
|
||||
);
|
||||
expect(service.getRecentItemLink(movieItem!)).toEqual([
|
||||
'/workspace',
|
||||
'global-recent',
|
||||
@@ -455,7 +652,7 @@ describe('DashboardDataService', () => {
|
||||
expect(service.getRecentItemNavigationState(movieItem!)).toEqual({
|
||||
openCollectionDetailItem: {
|
||||
item: expect.objectContaining({
|
||||
uid: 'xtream::xtream-1::7001',
|
||||
uid: 'xtream::xtream-1::movie:7001',
|
||||
name: 'Recent Movie',
|
||||
contentType: 'movie',
|
||||
sourceType: 'xtream',
|
||||
@@ -465,6 +662,34 @@ describe('DashboardDataService', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('normalizes SQLite-style Xtream recent timestamps before sorting and rendering', async () => {
|
||||
dbServiceMock.getGlobalRecentlyViewed.mockResolvedValue([
|
||||
{
|
||||
id: 91,
|
||||
category_id: 18,
|
||||
title: 'Recent Movie',
|
||||
rating: '7.8',
|
||||
viewed_at: '2026-02-04 10:00:00',
|
||||
poster_url: 'https://example.com/recent-movie.png',
|
||||
xtream_id: 7001,
|
||||
type: 'movie',
|
||||
playlist_id: 'xtream-1',
|
||||
playlist_name: 'Xtream Playlist',
|
||||
},
|
||||
]);
|
||||
|
||||
await service.reloadGlobalRecentItems();
|
||||
const movieItem = service
|
||||
.globalRecentItems()
|
||||
.find((item) => item.source === 'xtream');
|
||||
|
||||
expect(movieItem).toEqual(
|
||||
expect.objectContaining({
|
||||
viewed_at: '2026-02-04T10:00:00.000Z',
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('removes M3U recently viewed via PlaylistsService', async () => {
|
||||
const m3uItem = service
|
||||
.globalRecentItems()
|
||||
|
||||
@@ -67,12 +67,16 @@ export class DashboardDataService {
|
||||
private readonly playlistsService = inject(PlaylistsService);
|
||||
private readonly ngZone = inject(NgZone);
|
||||
private readonly translate = inject(TranslateService);
|
||||
private favoritesAutoRefreshEnabled = false;
|
||||
private readonly languageTick = toSignal(
|
||||
this.translate.onLangChange.pipe(startWith(null)),
|
||||
{ initialValue: null }
|
||||
);
|
||||
|
||||
private readonly xtreamGlobalRecentItems = signal<GlobalRecentItem[]>([]);
|
||||
private readonly xtreamRecentlyAddedItemsState = signal<
|
||||
DashboardRecentlyAddedItem[]
|
||||
>([]);
|
||||
private readonly xtreamGlobalFavorites = signal<DashboardFavoriteItem[]>(
|
||||
[]
|
||||
);
|
||||
@@ -82,16 +86,35 @@ export class DashboardDataService {
|
||||
private readonly globalRecentDbLoadedState = signal(!window.electron);
|
||||
private readonly globalFavoritesLoadingState = signal(true);
|
||||
private readonly globalFavoritesLoadedState = signal(false);
|
||||
private readonly xtreamRecentlyAddedLoadingState = signal(true);
|
||||
private readonly xtreamRecentlyAddedLoadedState = signal(false);
|
||||
readonly playlists = this.store.selectSignal(selectAllPlaylistsMeta);
|
||||
readonly playlistsLoaded = this.store.selectSignal(
|
||||
selectPlaylistsLoadingFlag
|
||||
);
|
||||
readonly xtreamPlaylistCount = computed(
|
||||
() => this.playlists().filter((playlist) => !!playlist.serverUrl).length
|
||||
);
|
||||
readonly hasXtreamPlaylists = computed(() => this.xtreamPlaylistCount() > 0);
|
||||
readonly globalRecentLoading = this.globalRecentLoadingState.asReadonly();
|
||||
readonly globalRecentLoaded = this.globalRecentLoadedState.asReadonly();
|
||||
readonly globalFavoritesLoading =
|
||||
this.globalFavoritesLoadingState.asReadonly();
|
||||
readonly globalFavoritesLoaded =
|
||||
this.globalFavoritesLoadedState.asReadonly();
|
||||
readonly xtreamRecentlyAddedLoading =
|
||||
this.xtreamRecentlyAddedLoadingState.asReadonly();
|
||||
readonly xtreamRecentlyAddedLoaded =
|
||||
this.xtreamRecentlyAddedLoadedState.asReadonly();
|
||||
readonly xtreamRecentlyAddedItems =
|
||||
this.xtreamRecentlyAddedItemsState.asReadonly();
|
||||
readonly dashboardReady = computed(
|
||||
() =>
|
||||
this.playlistsLoaded() &&
|
||||
this.globalRecentLoaded() &&
|
||||
this.globalFavoritesLoaded() &&
|
||||
(!this.hasXtreamPlaylists() || this.xtreamRecentlyAddedLoaded())
|
||||
);
|
||||
|
||||
private readonly playlistFavoritesReloadKey = computed(() => {
|
||||
if (!this.playlistsLoaded()) {
|
||||
@@ -155,12 +178,25 @@ export class DashboardDataService {
|
||||
.slice(0, 200)
|
||||
);
|
||||
|
||||
constructor() {
|
||||
void this.reloadGlobalFavorites();
|
||||
private readonly recentPlaylistActivityTimestamps = computed(() => {
|
||||
const timestamps = new Map<string, number>();
|
||||
|
||||
for (const item of this.globalRecentItems()) {
|
||||
const viewedAt = toDateTimestamp(item.viewed_at);
|
||||
const current = timestamps.get(item.playlist_id) ?? 0;
|
||||
|
||||
if (viewedAt > current) {
|
||||
timestamps.set(item.playlist_id, viewedAt);
|
||||
}
|
||||
}
|
||||
|
||||
return timestamps;
|
||||
});
|
||||
|
||||
constructor() {
|
||||
effect(() => {
|
||||
this.playlistFavoritesReloadKey();
|
||||
if (!this.playlistsLoaded()) {
|
||||
if (!this.playlistsLoaded() || !this.favoritesAutoRefreshEnabled) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -170,11 +206,20 @@ export class DashboardDataService {
|
||||
effect(() => {
|
||||
this.playlistsLoaded();
|
||||
this.finishInitialGlobalRecentLoadIfReady();
|
||||
this.finishInitialGlobalFavoritesLoadIfReady();
|
||||
});
|
||||
|
||||
if (window.electron) {
|
||||
void this.reloadGlobalRecentItems();
|
||||
}
|
||||
effect(() => {
|
||||
if (this.hasXtreamPlaylists()) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.ngZone.run(() => {
|
||||
this.xtreamRecentlyAddedItemsState.set([]);
|
||||
this.xtreamRecentlyAddedLoadingState.set(false);
|
||||
this.xtreamRecentlyAddedLoadedState.set(true);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
readonly stats = computed(() => {
|
||||
@@ -190,10 +235,22 @@ export class DashboardDataService {
|
||||
|
||||
readonly recentPlaylists = computed(() =>
|
||||
[...this.playlists()]
|
||||
.sort(
|
||||
(a, b) =>
|
||||
this.getRecentTimestamp(b) - this.getRecentTimestamp(a)
|
||||
)
|
||||
.sort((a, b) => {
|
||||
const activityDelta =
|
||||
this.getPlaylistActivityTimestamp(b) -
|
||||
this.getPlaylistActivityTimestamp(a);
|
||||
if (activityDelta !== 0) {
|
||||
return activityDelta;
|
||||
}
|
||||
|
||||
const metadataDelta =
|
||||
this.getRecentTimestamp(b) - this.getRecentTimestamp(a);
|
||||
if (metadataDelta !== 0) {
|
||||
return metadataDelta;
|
||||
}
|
||||
|
||||
return a._id.localeCompare(b._id);
|
||||
})
|
||||
.slice(0, 12)
|
||||
);
|
||||
|
||||
@@ -238,6 +295,7 @@ export class DashboardDataService {
|
||||
const m3uReload = this.refreshPlaylistBackedGlobalFavorites();
|
||||
|
||||
await Promise.all([xtreamReload, m3uReload]);
|
||||
this.favoritesAutoRefreshEnabled = true;
|
||||
}
|
||||
|
||||
async getGlobalRecentlyAddedItems(
|
||||
@@ -254,6 +312,56 @@ export class DashboardDataService {
|
||||
.sort((a, b) => toTimestamp(b.added_at) - toTimestamp(a.added_at));
|
||||
}
|
||||
|
||||
async getXtreamRecentlyAddedItems(
|
||||
limit = 20
|
||||
): Promise<DashboardRecentlyAddedItem[]> {
|
||||
if (!window.electron) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const items = await this.dbService.getGlobalRecentlyAdded(
|
||||
'all',
|
||||
limit,
|
||||
'xtream'
|
||||
);
|
||||
return items
|
||||
.map((item) => mapDbRecentlyAddedToItem(item))
|
||||
.sort((a, b) => toTimestamp(b.added_at) - toTimestamp(a.added_at));
|
||||
}
|
||||
|
||||
async reloadXtreamRecentlyAddedItems(limit = 20): Promise<void> {
|
||||
if (!this.xtreamRecentlyAddedLoaded()) {
|
||||
this.xtreamRecentlyAddedLoadingState.set(true);
|
||||
}
|
||||
|
||||
if (!window.electron) {
|
||||
this.ngZone.run(() => {
|
||||
this.xtreamRecentlyAddedItemsState.set([]);
|
||||
this.xtreamRecentlyAddedLoadingState.set(false);
|
||||
this.xtreamRecentlyAddedLoadedState.set(true);
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const items = await this.getXtreamRecentlyAddedItems(limit);
|
||||
this.ngZone.run(() =>
|
||||
this.xtreamRecentlyAddedItemsState.set(items)
|
||||
);
|
||||
} catch (err) {
|
||||
console.warn(
|
||||
'[DashboardData] Failed to reload Xtream recently added items',
|
||||
err
|
||||
);
|
||||
this.ngZone.run(() => this.xtreamRecentlyAddedItemsState.set([]));
|
||||
} finally {
|
||||
this.ngZone.run(() => {
|
||||
this.xtreamRecentlyAddedLoadingState.set(false);
|
||||
this.xtreamRecentlyAddedLoadedState.set(true);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private async reloadXtreamGlobalFavorites(): Promise<void> {
|
||||
if (!window.electron) {
|
||||
this.xtreamGlobalFavorites.set([]);
|
||||
@@ -706,6 +814,10 @@ export class DashboardDataService {
|
||||
: null;
|
||||
}
|
||||
|
||||
private getPlaylistActivityTimestamp(item: PlaylistMeta): number {
|
||||
return this.recentPlaylistActivityTimestamps().get(item._id) ?? 0;
|
||||
}
|
||||
|
||||
private getRecentTimestamp(item: PlaylistMeta): number {
|
||||
return toTimestamp(item.updateDate) || toTimestamp(item.importDate);
|
||||
}
|
||||
|
||||
@@ -1,47 +0,0 @@
|
||||
import { TestBed } from '@angular/core/testing';
|
||||
import { DashboardLayoutService } from './dashboard-layout.service';
|
||||
|
||||
const STORAGE_KEY = 'workspace-dashboard-layout-v3';
|
||||
|
||||
describe('DashboardLayoutService', () => {
|
||||
let service: DashboardLayoutService;
|
||||
|
||||
beforeEach(() => {
|
||||
localStorage.removeItem(STORAGE_KEY);
|
||||
TestBed.configureTestingModule({
|
||||
providers: [DashboardLayoutService],
|
||||
});
|
||||
service = TestBed.inject(DashboardLayoutService);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
localStorage.removeItem(STORAGE_KEY);
|
||||
});
|
||||
|
||||
it('persists layout changes to localStorage', () => {
|
||||
service.toggleWidget('source-stats');
|
||||
TestBed.flushEffects();
|
||||
|
||||
const stored = localStorage.getItem(STORAGE_KEY);
|
||||
expect(stored).toBeTruthy();
|
||||
|
||||
const parsed = JSON.parse(stored ?? '{}') as {
|
||||
widgets?: Array<{ id: string; enabled: boolean }>;
|
||||
};
|
||||
const sourceStats = parsed.widgets?.find((widget) => widget.id === 'source-stats');
|
||||
expect(sourceStats?.enabled).toBe(true);
|
||||
});
|
||||
|
||||
it('adds the recently added widget disabled by default during normalization', () => {
|
||||
const widget = service.getWidget('recently-added');
|
||||
|
||||
expect(widget).toEqual(
|
||||
expect.objectContaining({
|
||||
type: 'recently-added',
|
||||
enabled: false,
|
||||
size: 'half',
|
||||
})
|
||||
);
|
||||
expect((widget as { settings?: unknown } | undefined)?.settings).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -1,288 +0,0 @@
|
||||
import { effect, Injectable, signal } from '@angular/core';
|
||||
import {
|
||||
DASHBOARD_LAYOUT_VERSION,
|
||||
DashboardLayoutState,
|
||||
DashboardWidgetConfig,
|
||||
DashboardWidgetSize,
|
||||
DASHBOARD_WIDGET_SIZE_OPTIONS,
|
||||
DEFAULT_DASHBOARD_WIDGETS,
|
||||
} from './dashboard-widget.model';
|
||||
|
||||
const DASHBOARD_LAYOUT_STORAGE_KEY = 'workspace-dashboard-layout-v3';
|
||||
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class DashboardLayoutService {
|
||||
readonly state = signal<DashboardLayoutState>(this.loadState());
|
||||
|
||||
constructor() {
|
||||
effect(() => {
|
||||
this.persistState(this.state());
|
||||
});
|
||||
}
|
||||
|
||||
toggleWidget(widgetId: string): void {
|
||||
this.updateWidget(widgetId, (widget) => ({
|
||||
...widget,
|
||||
enabled: !widget.enabled,
|
||||
}));
|
||||
}
|
||||
|
||||
getWidget(widgetId: string): DashboardWidgetConfig | undefined {
|
||||
return this.state().widgets.find((widget) => widget.id === widgetId);
|
||||
}
|
||||
|
||||
setWidgetSize(widgetId: string, size: DashboardWidgetSize): void {
|
||||
this.updateWidget(widgetId, (widget) => ({
|
||||
...widget,
|
||||
size: this.normalizeSize(size),
|
||||
}));
|
||||
}
|
||||
|
||||
reorderVisibleWidgets(orderedVisibleWidgetIds: string[]): void {
|
||||
const orderedIds = orderedVisibleWidgetIds.filter(Boolean);
|
||||
if (orderedIds.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const widgetsById = new Map(
|
||||
this.state().widgets.map((widget) => [widget.id, widget])
|
||||
);
|
||||
const visibleWidgets = orderedIds
|
||||
.map((id) => widgetsById.get(id))
|
||||
.filter((widget): widget is DashboardWidgetConfig => !!widget?.enabled);
|
||||
if (visibleWidgets.length !== orderedIds.length) {
|
||||
return;
|
||||
}
|
||||
|
||||
const disabledWidgets = this.getSortedWidgets(this.state().widgets).filter(
|
||||
(widget) => !widget.enabled
|
||||
);
|
||||
|
||||
const reordered = [...visibleWidgets, ...disabledWidgets].map(
|
||||
(widget, order) => ({
|
||||
...widget,
|
||||
order,
|
||||
})
|
||||
);
|
||||
|
||||
this.state.update((state) => ({
|
||||
...state,
|
||||
widgets: reordered,
|
||||
}));
|
||||
}
|
||||
|
||||
moveWidgetUp(widgetId: string): void {
|
||||
const sorted = this.getSortedWidgets(this.state().widgets);
|
||||
const index = sorted.findIndex((widget) => widget.id === widgetId);
|
||||
if (index <= 0) {
|
||||
return;
|
||||
}
|
||||
const previous = sorted[index - 1];
|
||||
const current = sorted[index];
|
||||
this.swapOrder(current.id, previous.id);
|
||||
}
|
||||
|
||||
moveWidgetDown(widgetId: string): void {
|
||||
const sorted = this.getSortedWidgets(this.state().widgets);
|
||||
const index = sorted.findIndex((widget) => widget.id === widgetId);
|
||||
if (index === -1 || index >= sorted.length - 1) {
|
||||
return;
|
||||
}
|
||||
const next = sorted[index + 1];
|
||||
const current = sorted[index];
|
||||
this.swapOrder(current.id, next.id);
|
||||
}
|
||||
|
||||
reset(): void {
|
||||
this.state.set(this.createDefaultState());
|
||||
}
|
||||
|
||||
private swapOrder(firstId: string, secondId: string): void {
|
||||
const widgetMap = new Map(
|
||||
this.state().widgets.map((widget) => [widget.id, widget])
|
||||
);
|
||||
const first = widgetMap.get(firstId);
|
||||
const second = widgetMap.get(secondId);
|
||||
if (!first || !second) {
|
||||
return;
|
||||
}
|
||||
|
||||
const firstOrder = first.order;
|
||||
this.state.update((state) => ({
|
||||
...state,
|
||||
widgets: state.widgets.map((widget) => {
|
||||
if (widget.id === firstId) {
|
||||
return { ...widget, order: second.order };
|
||||
}
|
||||
if (widget.id === secondId) {
|
||||
return { ...widget, order: firstOrder };
|
||||
}
|
||||
return widget;
|
||||
}),
|
||||
}));
|
||||
}
|
||||
|
||||
private updateWidget(
|
||||
widgetId: string,
|
||||
updater: (widget: DashboardWidgetConfig) => DashboardWidgetConfig
|
||||
): void {
|
||||
this.state.update((state) => ({
|
||||
...state,
|
||||
widgets: state.widgets.map((widget) =>
|
||||
widget.id === widgetId ? updater(widget) : widget
|
||||
),
|
||||
}));
|
||||
}
|
||||
|
||||
private loadState(): DashboardLayoutState {
|
||||
if (typeof localStorage === 'undefined') {
|
||||
return this.createDefaultState();
|
||||
}
|
||||
|
||||
const raw = localStorage.getItem(DASHBOARD_LAYOUT_STORAGE_KEY);
|
||||
if (!raw) {
|
||||
return this.createDefaultState();
|
||||
}
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(raw) as Partial<DashboardLayoutState>;
|
||||
if (!Array.isArray(parsed.widgets)) {
|
||||
return this.createDefaultState();
|
||||
}
|
||||
|
||||
if (parsed.version === DASHBOARD_LAYOUT_VERSION) {
|
||||
return this.normalizeState(parsed.widgets);
|
||||
}
|
||||
|
||||
const migrated = this.migrateState(parsed);
|
||||
if (migrated) {
|
||||
return migrated;
|
||||
}
|
||||
|
||||
if (parsed.version == null) {
|
||||
return this.normalizeState(parsed.widgets);
|
||||
}
|
||||
|
||||
return this.createDefaultState();
|
||||
} catch {
|
||||
return this.createDefaultState();
|
||||
}
|
||||
}
|
||||
|
||||
private migrateState(
|
||||
state: Partial<DashboardLayoutState>
|
||||
): DashboardLayoutState | null {
|
||||
if (!Array.isArray(state.widgets)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const version = Number(state.version ?? 0);
|
||||
if (!Number.isFinite(version) || version <= 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (version < DASHBOARD_LAYOUT_VERSION) {
|
||||
return this.normalizeState(state.widgets);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private normalizeState(
|
||||
widgets: DashboardWidgetConfig[]
|
||||
): DashboardLayoutState {
|
||||
const widgetMap = new Map(widgets.map((widget) => [widget.id, widget]));
|
||||
const normalized = DEFAULT_DASHBOARD_WIDGETS.map((defaultWidget) => {
|
||||
const loaded = widgetMap.get(defaultWidget.id);
|
||||
if (!loaded) {
|
||||
return this.cloneWidget(defaultWidget);
|
||||
}
|
||||
|
||||
return this.mergeWidget(defaultWidget, loaded);
|
||||
});
|
||||
|
||||
return {
|
||||
version: DASHBOARD_LAYOUT_VERSION,
|
||||
widgets: this.getSortedWidgets(normalized).map((widget, index) => ({
|
||||
...widget,
|
||||
order: index,
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
private persistState(state: DashboardLayoutState): void {
|
||||
if (typeof localStorage === 'undefined') {
|
||||
return;
|
||||
}
|
||||
|
||||
localStorage.setItem(DASHBOARD_LAYOUT_STORAGE_KEY, JSON.stringify(state));
|
||||
}
|
||||
|
||||
private createDefaultState(): DashboardLayoutState {
|
||||
return {
|
||||
version: DASHBOARD_LAYOUT_VERSION,
|
||||
widgets: DEFAULT_DASHBOARD_WIDGETS.map((widget) =>
|
||||
this.cloneWidget(widget)
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
private getSortedWidgets(
|
||||
widgets: DashboardWidgetConfig[]
|
||||
): DashboardWidgetConfig[] {
|
||||
return [...widgets].sort((a, b) => a.order - b.order);
|
||||
}
|
||||
|
||||
private cloneWidget(widget: DashboardWidgetConfig): DashboardWidgetConfig {
|
||||
return {
|
||||
id: widget.id,
|
||||
type: widget.type,
|
||||
title: widget.title,
|
||||
description: widget.description,
|
||||
size: this.normalizeSize(widget.size),
|
||||
enabled: widget.enabled,
|
||||
order: widget.order,
|
||||
};
|
||||
}
|
||||
|
||||
private mergeWidget(
|
||||
defaultWidget: DashboardWidgetConfig,
|
||||
loaded: DashboardWidgetConfig
|
||||
): DashboardWidgetConfig {
|
||||
return {
|
||||
id: defaultWidget.id,
|
||||
type: defaultWidget.type,
|
||||
title: defaultWidget.title,
|
||||
description: defaultWidget.description,
|
||||
size: this.normalizeSize(loaded.size ?? defaultWidget.size),
|
||||
enabled:
|
||||
typeof loaded.enabled === 'boolean'
|
||||
? loaded.enabled
|
||||
: defaultWidget.enabled,
|
||||
order:
|
||||
typeof loaded.order === 'number'
|
||||
? loaded.order
|
||||
: defaultWidget.order,
|
||||
};
|
||||
}
|
||||
|
||||
private normalizeSize(size: unknown): DashboardWidgetSize {
|
||||
if (size === 's') {
|
||||
return 'one-third';
|
||||
}
|
||||
if (size === 'm') {
|
||||
return 'half';
|
||||
}
|
||||
if (size === 'l') {
|
||||
return 'full';
|
||||
}
|
||||
if (
|
||||
typeof size === 'string' &&
|
||||
DASHBOARD_WIDGET_SIZE_OPTIONS.includes(size as DashboardWidgetSize)
|
||||
) {
|
||||
return size as DashboardWidgetSize;
|
||||
}
|
||||
return 'half';
|
||||
}
|
||||
|
||||
}
|
||||
@@ -43,6 +43,7 @@ export function mapDbFavoriteToItem(
|
||||
category_id: item.category_id,
|
||||
xtream_id: item.xtream_id,
|
||||
poster_url: item.poster_url,
|
||||
backdrop_url: item.backdrop_url ?? undefined,
|
||||
source: 'xtream',
|
||||
};
|
||||
}
|
||||
@@ -58,6 +59,7 @@ export function mapDbRecentToItem(item: DbGlobalRecentItem): PortalRecentItem {
|
||||
category_id: item.category_id,
|
||||
xtream_id: item.xtream_id,
|
||||
poster_url: item.poster_url,
|
||||
backdrop_url: item.backdrop_url ?? undefined,
|
||||
source: 'xtream',
|
||||
};
|
||||
}
|
||||
@@ -171,7 +173,7 @@ export function toDateTimestamp(value: unknown): number {
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
const parsed = Date.parse(trimmed);
|
||||
const parsed = Date.parse(normalizeStalkerDate(trimmed) || trimmed);
|
||||
return Number.isNaN(parsed) ? 0 : parsed;
|
||||
}
|
||||
|
||||
@@ -181,7 +183,7 @@ export function toDateTimestamp(value: unknown): number {
|
||||
export function toTimestamp(value?: string | number): number {
|
||||
if (typeof value === 'number') return value;
|
||||
if (typeof value === 'string') {
|
||||
const parsed = Date.parse(value);
|
||||
const parsed = Date.parse(normalizeStalkerDate(value) || value);
|
||||
return Number.isNaN(parsed) ? 0 : parsed;
|
||||
}
|
||||
return 0;
|
||||
|
||||
@@ -1,79 +0,0 @@
|
||||
export type DashboardWidgetType =
|
||||
| 'source-stats'
|
||||
| 'continue-watching'
|
||||
| 'recently-watched'
|
||||
| 'recently-added'
|
||||
| 'global-favorites';
|
||||
|
||||
export type DashboardWidgetSize = 'one-third' | 'half' | 'two-thirds' | 'full';
|
||||
|
||||
export interface DashboardWidgetConfig {
|
||||
id: string;
|
||||
type: DashboardWidgetType;
|
||||
title: string;
|
||||
description: string;
|
||||
size: DashboardWidgetSize;
|
||||
enabled: boolean;
|
||||
order: number;
|
||||
}
|
||||
|
||||
export interface DashboardLayoutState {
|
||||
version: number;
|
||||
widgets: DashboardWidgetConfig[];
|
||||
}
|
||||
|
||||
export const DASHBOARD_LAYOUT_VERSION = 14;
|
||||
export const DASHBOARD_WIDGET_SIZE_OPTIONS: DashboardWidgetSize[] = [
|
||||
'one-third',
|
||||
'half',
|
||||
'two-thirds',
|
||||
'full',
|
||||
];
|
||||
|
||||
export const DEFAULT_DASHBOARD_WIDGETS: DashboardWidgetConfig[] = [
|
||||
{
|
||||
id: 'source-stats',
|
||||
type: 'source-stats',
|
||||
title: 'WORKSPACE.DASHBOARD.SOURCE_STATS',
|
||||
description: 'WORKSPACE.DASHBOARD.SOURCE_STATS_DESC',
|
||||
size: 'one-third',
|
||||
enabled: false,
|
||||
order: 1,
|
||||
},
|
||||
{
|
||||
id: 'continue',
|
||||
type: 'continue-watching',
|
||||
title: 'WORKSPACE.DASHBOARD.CONTINUE_WATCHING',
|
||||
description: 'WORKSPACE.DASHBOARD.CONTINUE_WATCHING_DESC',
|
||||
size: 'full',
|
||||
enabled: true,
|
||||
order: 2,
|
||||
},
|
||||
{
|
||||
id: 'recently-watched',
|
||||
type: 'recently-watched',
|
||||
title: 'WORKSPACE.DASHBOARD.RECENTLY_WATCHED',
|
||||
description: 'WORKSPACE.DASHBOARD.RECENTLY_WATCHED_DESC',
|
||||
size: 'two-thirds',
|
||||
enabled: true,
|
||||
order: 4,
|
||||
},
|
||||
{
|
||||
id: 'recently-added',
|
||||
type: 'recently-added',
|
||||
title: 'WORKSPACE.DASHBOARD.RECENTLY_ADDED',
|
||||
description: 'WORKSPACE.DASHBOARD.RECENTLY_ADDED_DESC',
|
||||
size: 'half',
|
||||
enabled: false,
|
||||
order: 5,
|
||||
},
|
||||
{
|
||||
id: 'global-favorites',
|
||||
type: 'global-favorites',
|
||||
title: 'WORKSPACE.DASHBOARD.GLOBAL_FAVORITES',
|
||||
description: 'WORKSPACE.DASHBOARD.GLOBAL_FAVORITES_DESC',
|
||||
size: 'one-third',
|
||||
enabled: true,
|
||||
order: 6,
|
||||
},
|
||||
];
|
||||
Reference in new issue
Block a user