refactor: rework dashboard view

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

No files matched your search

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