mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-13 20:56:19 -08:00
feat: implement recently added widget with data mapping and default settings
This commit is contained in:
1 parent
43426114d6
commit
36c3d26559
9 files changed
+190
-594
No files matched your search
@@ -56,6 +56,7 @@ describe('DashboardDataService', () => {
|
||||
};
|
||||
|
||||
const dbServiceMock = {
|
||||
getGlobalRecentlyAdded: jest.fn().mockResolvedValue([]),
|
||||
getGlobalRecentlyViewed: jest.fn().mockResolvedValue([]),
|
||||
getGlobalFavorites: jest.fn().mockResolvedValue([]),
|
||||
removeFromFavorites: jest.fn().mockResolvedValue(undefined),
|
||||
@@ -108,6 +109,10 @@ describe('DashboardDataService', () => {
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
Object.defineProperty(window, 'electron', {
|
||||
value: {} as Window['electron'],
|
||||
configurable: true,
|
||||
});
|
||||
playlistsServiceMock.getPlaylistById.mockClear();
|
||||
playlistsServiceMock.getPlaylistById.mockReturnValue(of(playlistMock));
|
||||
playlistsServiceMock.setFavorites.mockClear();
|
||||
@@ -121,6 +126,8 @@ describe('DashboardDataService', () => {
|
||||
);
|
||||
dbServiceMock.getGlobalFavorites.mockClear();
|
||||
dbServiceMock.getGlobalFavorites.mockResolvedValue([]);
|
||||
dbServiceMock.getGlobalRecentlyAdded.mockClear();
|
||||
dbServiceMock.getGlobalRecentlyAdded.mockResolvedValue([]);
|
||||
dbServiceMock.getGlobalRecentlyViewed.mockClear();
|
||||
dbServiceMock.getGlobalRecentlyViewed.mockResolvedValue([]);
|
||||
dbServiceMock.removeFromFavorites.mockClear();
|
||||
@@ -154,42 +161,6 @@ describe('DashboardDataService', () => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
it('treats an empty provider scope as all providers', () => {
|
||||
const result = service.matchesScope('xtream-1', 'xtream', {
|
||||
providers: [],
|
||||
playlistIds: [],
|
||||
});
|
||||
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
|
||||
it('filters out items when provider is excluded', () => {
|
||||
const result = service.matchesScope('xtream-1', 'xtream', {
|
||||
providers: ['stalker'],
|
||||
playlistIds: [],
|
||||
});
|
||||
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
|
||||
it('filters out items when playlist is not selected', () => {
|
||||
const result = service.matchesScope('xtream-1', 'xtream', {
|
||||
providers: ['xtream'],
|
||||
playlistIds: ['m3u-1'],
|
||||
});
|
||||
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
|
||||
it('uses playlist metadata as provider fallback when source is missing', () => {
|
||||
const result = service.matchesScope('xtream-1', undefined, {
|
||||
providers: ['xtream'],
|
||||
playlistIds: [],
|
||||
});
|
||||
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
|
||||
it('includes M3U favorites in global favorite items', async () => {
|
||||
await service.reloadGlobalFavorites();
|
||||
|
||||
@@ -246,15 +217,6 @@ describe('DashboardDataService', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('matches M3U provider scope', () => {
|
||||
const result = service.matchesScope('m3u-1', 'm3u', {
|
||||
providers: ['m3u'],
|
||||
playlistIds: [],
|
||||
});
|
||||
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
|
||||
it('includes M3U recently viewed items in global recent items', () => {
|
||||
expect(service.globalRecentItems()).toEqual(
|
||||
expect.arrayContaining([
|
||||
@@ -270,6 +232,66 @@ describe('DashboardDataService', () => {
|
||||
);
|
||||
});
|
||||
|
||||
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 navigation state', () => {
|
||||
const m3uItem = service
|
||||
.globalRecentItems()
|
||||
|
||||
@@ -8,11 +8,17 @@ import {
|
||||
selectAllPlaylistsMeta,
|
||||
} from 'm3u-state';
|
||||
import { firstValueFrom, startWith } from 'rxjs';
|
||||
import { DatabaseService, PlaylistsService } from 'services';
|
||||
import {
|
||||
DatabaseService,
|
||||
GlobalRecentlyAddedKind,
|
||||
PlaylistsService,
|
||||
} from 'services';
|
||||
import {
|
||||
buildPlaylistRecentItems,
|
||||
Channel,
|
||||
Playlist,
|
||||
PortalAddedItem,
|
||||
PortalActivityItem,
|
||||
PlaylistMeta,
|
||||
PortalActivityType,
|
||||
PortalFavoriteItem,
|
||||
@@ -23,6 +29,7 @@ import {
|
||||
buildStalkerFavoriteItems,
|
||||
getActivityTypeLabelKey,
|
||||
mapDbFavoriteToItem,
|
||||
mapDbRecentlyAddedToItem,
|
||||
mapDbRecentToItem,
|
||||
toDateTimestamp,
|
||||
toTimestamp,
|
||||
@@ -32,11 +39,6 @@ import {
|
||||
getRecentItemNavigation,
|
||||
WorkspaceNavigationTarget,
|
||||
} from '@iptvnator/portal/shared/util';
|
||||
import {
|
||||
DashboardWidgetProvider,
|
||||
DashboardWidgetScopeSettings,
|
||||
createDefaultWidgetScope,
|
||||
} from './dashboard-widget.model';
|
||||
|
||||
export type DashboardContentKind = 'all' | 'channels' | 'vod' | 'series';
|
||||
|
||||
@@ -44,6 +46,9 @@ export type DashboardContentKind = 'all' | 'channels' | 'vod' | 'series';
|
||||
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 {
|
||||
@@ -67,7 +72,7 @@ export class DashboardDataService {
|
||||
|
||||
readonly playlistBackedGlobalRecentItems = computed<GlobalRecentItem[]>(
|
||||
() => {
|
||||
this.languageTick();
|
||||
this.languageTick();
|
||||
return buildPlaylistRecentItems(this.playlists(), {
|
||||
stalker: this.translateText(
|
||||
'WORKSPACE.DASHBOARD.STALKER_PORTAL'
|
||||
@@ -164,6 +169,20 @@ export class DashboardDataService {
|
||||
await Promise.all([xtreamReload, m3uReload]);
|
||||
}
|
||||
|
||||
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));
|
||||
}
|
||||
|
||||
private async reloadXtreamGlobalFavorites(): Promise<void> {
|
||||
if (!window.electron) {
|
||||
this.xtreamGlobalFavorites.set([]);
|
||||
@@ -233,49 +252,6 @@ export class DashboardDataService {
|
||||
return type === 'series';
|
||||
}
|
||||
|
||||
matchesScope(
|
||||
playlistId: string,
|
||||
source: 'xtream' | 'stalker' | 'm3u' | undefined,
|
||||
scope?: DashboardWidgetScopeSettings
|
||||
): boolean {
|
||||
const normalizedScope = this.normalizeScope(scope);
|
||||
const provider = this.getProviderFromSource(
|
||||
source,
|
||||
this.getPlaylistProviderType(playlistId)
|
||||
);
|
||||
const providerAllowed =
|
||||
normalizedScope.providers.length === 0 ||
|
||||
normalizedScope.providers.includes(provider);
|
||||
if (!providerAllowed) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (normalizedScope.playlistIds.length === 0) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return normalizedScope.playlistIds.includes(playlistId);
|
||||
}
|
||||
|
||||
getPlaylistProviderType(playlistId: string): DashboardWidgetProvider {
|
||||
const playlist = this.playlists().find(
|
||||
(item) => item._id === playlistId
|
||||
);
|
||||
if (!playlist) {
|
||||
return 'm3u';
|
||||
}
|
||||
|
||||
if (playlist.serverUrl) {
|
||||
return 'xtream';
|
||||
}
|
||||
|
||||
if (playlist.macAddress) {
|
||||
return 'stalker';
|
||||
}
|
||||
|
||||
return 'm3u';
|
||||
}
|
||||
|
||||
getPlaylistLink(playlist: PlaylistMeta): string[] {
|
||||
if (playlist.serverUrl) {
|
||||
return ['/workspace', 'xtreams', playlist._id, 'vod'];
|
||||
@@ -381,6 +357,18 @@ export class DashboardDataService {
|
||||
}
|
||||
|
||||
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') {
|
||||
@@ -396,6 +384,18 @@ export class DashboardDataService {
|
||||
}
|
||||
|
||||
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));
|
||||
}
|
||||
@@ -410,6 +410,16 @@ export class DashboardDataService {
|
||||
return getGlobalFavoriteNavigation(item).state;
|
||||
}
|
||||
|
||||
getRecentlyAddedLink(item: DashboardRecentlyAddedItem): string[] {
|
||||
return getGlobalFavoriteNavigation(item).link;
|
||||
}
|
||||
|
||||
getRecentlyAddedNavigationState(
|
||||
item: DashboardRecentlyAddedItem
|
||||
): WorkspaceNavigationTarget['state'] {
|
||||
return getGlobalFavoriteNavigation(item).state;
|
||||
}
|
||||
|
||||
async removeGlobalFavorite(item: DashboardFavoriteItem): Promise<void> {
|
||||
if (item.source === 'xtream') {
|
||||
await this.dbService.removeFromFavorites(
|
||||
@@ -486,27 +496,6 @@ export class DashboardDataService {
|
||||
return new Date(timestamp).toLocaleString(this.getLocale());
|
||||
}
|
||||
|
||||
private normalizeScope(
|
||||
scope?: DashboardWidgetScopeSettings
|
||||
): DashboardWidgetScopeSettings {
|
||||
const fallback = createDefaultWidgetScope();
|
||||
return {
|
||||
providers: [...(scope?.providers ?? fallback.providers)],
|
||||
playlistIds: [...(scope?.playlistIds ?? fallback.playlistIds)],
|
||||
};
|
||||
}
|
||||
|
||||
private getProviderFromSource(
|
||||
source: 'xtream' | 'stalker' | 'm3u' | undefined,
|
||||
fallback: DashboardWidgetProvider
|
||||
): DashboardWidgetProvider {
|
||||
if (source === 'xtream' || source === 'stalker' || source === 'm3u') {
|
||||
return source;
|
||||
}
|
||||
|
||||
return fallback;
|
||||
}
|
||||
|
||||
private async loadM3uPlaylistFavorites(
|
||||
playlistMeta: PlaylistMeta
|
||||
): Promise<DashboardFavoriteItem[]> {
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { TestBed } from '@angular/core/testing';
|
||||
import { DashboardWidgetScopeSettings } from './dashboard-widget.model';
|
||||
import { DashboardLayoutService } from './dashboard-layout.service';
|
||||
|
||||
const STORAGE_KEY = 'workspace-dashboard-layout-v3';
|
||||
@@ -19,31 +18,6 @@ describe('DashboardLayoutService', () => {
|
||||
localStorage.removeItem(STORAGE_KEY);
|
||||
});
|
||||
|
||||
it('normalizes scope providers and playlist ids when setting scope', () => {
|
||||
service.setWidgetScope('recently-watched', {
|
||||
providers: ['xtream', 'xtream', 'invalid'],
|
||||
playlistIds: ['playlist-a', '', 'playlist-a'],
|
||||
} as unknown as DashboardWidgetScopeSettings);
|
||||
|
||||
const widget = service.getWidget('recently-watched');
|
||||
expect(widget?.settings?.scope).toEqual({
|
||||
providers: ['xtream'],
|
||||
playlistIds: ['playlist-a'],
|
||||
});
|
||||
});
|
||||
|
||||
it('allows an empty provider list to represent all providers', () => {
|
||||
service.setWidgetScope('recently-watched', {
|
||||
providers: ['m3u'],
|
||||
playlistIds: [],
|
||||
});
|
||||
service.toggleWidgetScopeProvider('recently-watched', 'm3u');
|
||||
|
||||
const providers =
|
||||
service.getWidget('recently-watched')?.settings?.scope?.providers ?? [];
|
||||
expect(providers).toEqual([]);
|
||||
});
|
||||
|
||||
it('persists layout changes to localStorage', () => {
|
||||
service.toggleWidget('source-stats');
|
||||
TestBed.flushEffects();
|
||||
@@ -55,6 +29,19 @@ describe('DashboardLayoutService', () => {
|
||||
widgets?: Array<{ id: string; enabled: boolean }>;
|
||||
};
|
||||
const sourceStats = parsed.widgets?.find((widget) => widget.id === 'source-stats');
|
||||
expect(sourceStats?.enabled).toBe(false);
|
||||
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,15 +1,11 @@
|
||||
import { effect, Injectable, signal } from '@angular/core';
|
||||
import {
|
||||
ALL_DASHBOARD_WIDGET_PROVIDERS,
|
||||
DASHBOARD_LAYOUT_VERSION,
|
||||
DashboardLayoutState,
|
||||
DashboardWidgetConfig,
|
||||
DashboardWidgetProvider,
|
||||
DashboardWidgetScopeSettings,
|
||||
DashboardWidgetSize,
|
||||
DASHBOARD_WIDGET_SIZE_OPTIONS,
|
||||
DEFAULT_DASHBOARD_WIDGETS,
|
||||
createDefaultWidgetScope,
|
||||
} from './dashboard-widget.model';
|
||||
|
||||
const DASHBOARD_LAYOUT_STORAGE_KEY = 'workspace-dashboard-layout-v3';
|
||||
@@ -35,47 +31,6 @@ export class DashboardLayoutService {
|
||||
return this.state().widgets.find((widget) => widget.id === widgetId);
|
||||
}
|
||||
|
||||
setWidgetScope(widgetId: string, scope: DashboardWidgetScopeSettings): void {
|
||||
this.updateWidget(widgetId, (widget) => ({
|
||||
...widget,
|
||||
settings: {
|
||||
...widget.settings,
|
||||
scope: this.normalizeScope(scope),
|
||||
},
|
||||
}));
|
||||
}
|
||||
|
||||
toggleWidgetScopeProvider(
|
||||
widgetId: string,
|
||||
provider: DashboardWidgetProvider
|
||||
): void {
|
||||
const widget = this.getWidget(widgetId);
|
||||
const currentScope = this.normalizeScope(widget?.settings?.scope);
|
||||
const hasProvider = currentScope.providers.includes(provider);
|
||||
const providers = hasProvider
|
||||
? currentScope.providers.filter((item) => item !== provider)
|
||||
: [...currentScope.providers, provider];
|
||||
|
||||
this.setWidgetScope(widgetId, {
|
||||
...currentScope,
|
||||
providers,
|
||||
});
|
||||
}
|
||||
|
||||
toggleWidgetScopePlaylist(widgetId: string, playlistId: string): void {
|
||||
const widget = this.getWidget(widgetId);
|
||||
const currentScope = this.normalizeScope(widget?.settings?.scope);
|
||||
const hasPlaylist = currentScope.playlistIds.includes(playlistId);
|
||||
const playlistIds = hasPlaylist
|
||||
? currentScope.playlistIds.filter((id) => id !== playlistId)
|
||||
: [...currentScope.playlistIds, playlistId];
|
||||
|
||||
this.setWidgetScope(widgetId, {
|
||||
...currentScope,
|
||||
playlistIds,
|
||||
});
|
||||
}
|
||||
|
||||
setWidgetSize(widgetId: string, size: DashboardWidgetSize): void {
|
||||
this.updateWidget(widgetId, (widget) => ({
|
||||
...widget,
|
||||
@@ -280,15 +235,13 @@ export class DashboardLayoutService {
|
||||
|
||||
private cloneWidget(widget: DashboardWidgetConfig): DashboardWidgetConfig {
|
||||
return {
|
||||
...widget,
|
||||
settings: widget.settings
|
||||
? {
|
||||
...widget.settings,
|
||||
scope: widget.settings.scope
|
||||
? this.normalizeScope(widget.settings.scope)
|
||||
: undefined,
|
||||
}
|
||||
: undefined,
|
||||
id: widget.id,
|
||||
type: widget.type,
|
||||
title: widget.title,
|
||||
description: widget.description,
|
||||
size: this.normalizeSize(widget.size),
|
||||
enabled: widget.enabled,
|
||||
order: widget.order,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -296,58 +249,20 @@ export class DashboardLayoutService {
|
||||
defaultWidget: DashboardWidgetConfig,
|
||||
loaded: DashboardWidgetConfig
|
||||
): DashboardWidgetConfig {
|
||||
const merged = {
|
||||
...defaultWidget,
|
||||
...loaded,
|
||||
size: this.normalizeSize(loaded.size ?? defaultWidget.size),
|
||||
settings: this.mergeSettings(defaultWidget, loaded),
|
||||
};
|
||||
|
||||
return {
|
||||
...merged,
|
||||
id: defaultWidget.id,
|
||||
type: defaultWidget.type,
|
||||
title: defaultWidget.title,
|
||||
description: defaultWidget.description,
|
||||
};
|
||||
}
|
||||
|
||||
private mergeSettings(
|
||||
defaultWidget: DashboardWidgetConfig,
|
||||
loaded: DashboardWidgetConfig
|
||||
) {
|
||||
if (!defaultWidget.settings && !loaded.settings) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return {
|
||||
...defaultWidget.settings,
|
||||
...loaded.settings,
|
||||
scope:
|
||||
loaded.settings?.scope || defaultWidget.settings?.scope
|
||||
? this.normalizeScope(
|
||||
loaded.settings?.scope ?? defaultWidget.settings?.scope
|
||||
)
|
||||
: undefined,
|
||||
};
|
||||
}
|
||||
|
||||
private normalizeScope(
|
||||
scope?: DashboardWidgetScopeSettings
|
||||
): DashboardWidgetScopeSettings {
|
||||
const defaultScope = createDefaultWidgetScope();
|
||||
const rawProviders = Array.isArray(scope?.providers)
|
||||
? scope.providers
|
||||
: defaultScope.providers;
|
||||
const providers = [...new Set(rawProviders)].filter(
|
||||
(provider): provider is DashboardWidgetProvider =>
|
||||
ALL_DASHBOARD_WIDGET_PROVIDERS.includes(
|
||||
provider as DashboardWidgetProvider
|
||||
)
|
||||
);
|
||||
const playlistIds = [...new Set(scope?.playlistIds ?? [])].filter(Boolean);
|
||||
|
||||
return {
|
||||
providers,
|
||||
playlistIds,
|
||||
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,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import {
|
||||
GlobalFavoriteItem as DbGlobalFavoriteItem,
|
||||
GlobalRecentlyAddedItem as DbGlobalRecentlyAddedItem,
|
||||
GlobalRecentItem as DbGlobalRecentItem,
|
||||
} from 'services';
|
||||
import {
|
||||
PortalAddedItem,
|
||||
PlaylistMeta,
|
||||
PortalActivityType,
|
||||
PortalFavoriteItem,
|
||||
@@ -60,6 +62,23 @@ export function mapDbRecentToItem(item: DbGlobalRecentItem): PortalRecentItem {
|
||||
};
|
||||
}
|
||||
|
||||
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(
|
||||
|
||||
@@ -2,19 +2,10 @@ export type DashboardWidgetType =
|
||||
| 'source-stats'
|
||||
| 'continue-watching'
|
||||
| 'recently-watched'
|
||||
| 'recently-added'
|
||||
| 'global-favorites';
|
||||
|
||||
export type DashboardWidgetSize = 'one-third' | 'half' | 'two-thirds' | 'full';
|
||||
export type DashboardWidgetProvider = 'm3u' | 'xtream' | 'stalker';
|
||||
|
||||
export interface DashboardWidgetScopeSettings {
|
||||
providers: DashboardWidgetProvider[];
|
||||
playlistIds: string[];
|
||||
}
|
||||
|
||||
export interface DashboardWidgetSettings {
|
||||
scope?: DashboardWidgetScopeSettings;
|
||||
}
|
||||
|
||||
export interface DashboardWidgetConfig {
|
||||
id: string;
|
||||
@@ -24,7 +15,6 @@ export interface DashboardWidgetConfig {
|
||||
size: DashboardWidgetSize;
|
||||
enabled: boolean;
|
||||
order: number;
|
||||
settings?: DashboardWidgetSettings;
|
||||
}
|
||||
|
||||
export interface DashboardLayoutState {
|
||||
@@ -32,29 +22,13 @@ export interface DashboardLayoutState {
|
||||
widgets: DashboardWidgetConfig[];
|
||||
}
|
||||
|
||||
export const DASHBOARD_LAYOUT_VERSION = 12;
|
||||
export const DASHBOARD_LAYOUT_VERSION = 14;
|
||||
export const DASHBOARD_WIDGET_SIZE_OPTIONS: DashboardWidgetSize[] = [
|
||||
'one-third',
|
||||
'half',
|
||||
'two-thirds',
|
||||
'full',
|
||||
];
|
||||
export const ALL_DASHBOARD_WIDGET_PROVIDERS: DashboardWidgetProvider[] = [
|
||||
'm3u',
|
||||
'xtream',
|
||||
'stalker',
|
||||
];
|
||||
export const DEFAULT_WIDGET_SCOPE: DashboardWidgetScopeSettings = {
|
||||
providers: [...ALL_DASHBOARD_WIDGET_PROVIDERS],
|
||||
playlistIds: [],
|
||||
};
|
||||
|
||||
export function createDefaultWidgetScope(): DashboardWidgetScopeSettings {
|
||||
return {
|
||||
providers: [...ALL_DASHBOARD_WIDGET_PROVIDERS],
|
||||
playlistIds: [],
|
||||
};
|
||||
}
|
||||
|
||||
export const DEFAULT_DASHBOARD_WIDGETS: DashboardWidgetConfig[] = [
|
||||
{
|
||||
@@ -83,9 +57,15 @@ export const DEFAULT_DASHBOARD_WIDGETS: DashboardWidgetConfig[] = [
|
||||
size: 'two-thirds',
|
||||
enabled: true,
|
||||
order: 4,
|
||||
settings: {
|
||||
scope: createDefaultWidgetScope(),
|
||||
},
|
||||
},
|
||||
{
|
||||
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',
|
||||
@@ -94,9 +74,6 @@ export const DEFAULT_DASHBOARD_WIDGETS: DashboardWidgetConfig[] = [
|
||||
description: 'WORKSPACE.DASHBOARD.GLOBAL_FAVORITES_DESC',
|
||||
size: 'one-third',
|
||||
enabled: true,
|
||||
order: 5,
|
||||
settings: {
|
||||
scope: createDefaultWidgetScope(),
|
||||
},
|
||||
order: 6,
|
||||
},
|
||||
];
|
||||
@@ -188,122 +188,6 @@
|
||||
"
|
||||
></mat-slide-toggle>
|
||||
</div>
|
||||
|
||||
@if (supportsScope(widget)) {
|
||||
<mat-expansion-panel class="advanced-panel">
|
||||
<mat-expansion-panel-header>
|
||||
<mat-panel-title>{{
|
||||
'WORKSPACE.DASHBOARD.WIDGET_ADVANCED'
|
||||
| translate
|
||||
}}</mat-panel-title>
|
||||
<mat-panel-description>{{
|
||||
getScopePanelDescription(widget)
|
||||
}}</mat-panel-description>
|
||||
</mat-expansion-panel-header>
|
||||
<section class="scope-editor">
|
||||
<p class="scope-editor__title">
|
||||
{{
|
||||
'WORKSPACE.DASHBOARD.WIDGET_SCOPE'
|
||||
| translate
|
||||
}}
|
||||
</p>
|
||||
<p class="scope-editor__subtitle">
|
||||
{{
|
||||
'WORKSPACE.DASHBOARD.WIDGET_SCOPE_PROVIDERS'
|
||||
| translate
|
||||
}}
|
||||
{{
|
||||
getScopeProviderSummary(
|
||||
widget
|
||||
)
|
||||
}}
|
||||
</p>
|
||||
@if (isAllProvidersScope(widget)) {
|
||||
<p
|
||||
class="scope-editor__subtitle"
|
||||
>
|
||||
{{
|
||||
'WORKSPACE.DASHBOARD.WIDGET_SCOPE_ALL_HINT'
|
||||
| translate
|
||||
}}
|
||||
</p>
|
||||
}
|
||||
<mat-chip-listbox
|
||||
class="scope-providers"
|
||||
multiple
|
||||
>
|
||||
@for (
|
||||
provider of providerOptions;
|
||||
track provider
|
||||
) {
|
||||
<mat-chip-option
|
||||
[selected]="
|
||||
isProviderSelected(
|
||||
widget,
|
||||
provider
|
||||
)
|
||||
"
|
||||
(click)="
|
||||
toggleScopeProvider(
|
||||
widget.id,
|
||||
provider
|
||||
)
|
||||
"
|
||||
>
|
||||
{{
|
||||
getProviderLabel(
|
||||
provider
|
||||
)
|
||||
}}
|
||||
</mat-chip-option>
|
||||
}
|
||||
</mat-chip-listbox>
|
||||
|
||||
<p class="scope-editor__subtitle">
|
||||
{{
|
||||
'WORKSPACE.DASHBOARD.WIDGET_SCOPE_PLAYLISTS'
|
||||
| translate
|
||||
}}
|
||||
</p>
|
||||
<div class="scope-playlists">
|
||||
@for (
|
||||
playlist of getScopePlaylists(
|
||||
widget
|
||||
);
|
||||
track playlist._id
|
||||
) {
|
||||
<mat-checkbox
|
||||
[checked]="
|
||||
isPlaylistSelected(
|
||||
widget,
|
||||
playlist._id
|
||||
)
|
||||
"
|
||||
(change)="
|
||||
toggleScopePlaylist(
|
||||
widget.id,
|
||||
playlist._id
|
||||
)
|
||||
"
|
||||
>
|
||||
{{
|
||||
getPlaylistTitle(
|
||||
playlist
|
||||
)
|
||||
}}
|
||||
</mat-checkbox>
|
||||
} @empty {
|
||||
<p class="scope-empty">
|
||||
{{
|
||||
'WORKSPACE.DASHBOARD.WIDGET_SCOPE_EMPTY'
|
||||
| translate
|
||||
}}
|
||||
</p>
|
||||
}
|
||||
</div>
|
||||
</section>
|
||||
</mat-expansion-panel>
|
||||
}
|
||||
</article>
|
||||
}
|
||||
</div>
|
||||
|
||||
@@ -197,72 +197,6 @@
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.advanced-panel {
|
||||
width: 100%;
|
||||
border: 1px solid var(--mat-sys-outline-variant);
|
||||
background: color-mix(
|
||||
in srgb,
|
||||
var(--mat-sys-surface-container-low) 82%,
|
||||
var(--dashboard-frame-tint) 18%
|
||||
);
|
||||
}
|
||||
|
||||
.advanced-panel :is(.mat-expansion-panel-header, .mat-expansion-panel-content) {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.scope-editor {
|
||||
width: 100%;
|
||||
border-top: 1px solid var(--mat-sys-outline-variant);
|
||||
padding-top: 10px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.scope-editor__title {
|
||||
margin: 0;
|
||||
font-size: 0.8rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.scope-editor__subtitle {
|
||||
margin: 0;
|
||||
font-size: 0.74rem;
|
||||
color: var(--mat-sys-on-surface-variant);
|
||||
}
|
||||
|
||||
.scope-providers {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.scope-playlists {
|
||||
max-height: 200px;
|
||||
overflow: auto;
|
||||
overflow-x: hidden;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.scope-empty {
|
||||
margin: 0;
|
||||
font-size: 0.76rem;
|
||||
color: var(--mat-sys-on-surface-variant);
|
||||
}
|
||||
|
||||
:host ::ng-deep .scope-playlists .mdc-form-field {
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
:host ::ng-deep .scope-playlists .mdc-form-field > label {
|
||||
display: inline-block;
|
||||
max-width: 260px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.widget-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(12, minmax(0, 1fr));
|
||||
|
||||
@@ -6,22 +6,15 @@ import {
|
||||
import { Component, computed, inject, signal } from '@angular/core';
|
||||
import { toSignal } from '@angular/core/rxjs-interop';
|
||||
import { MatButtonModule } from '@angular/material/button';
|
||||
import { MatCheckboxModule } from '@angular/material/checkbox';
|
||||
import { MatChipsModule } from '@angular/material/chips';
|
||||
import { MatExpansionModule } from '@angular/material/expansion';
|
||||
import { MatIcon } from '@angular/material/icon';
|
||||
import { MatSlideToggleModule } from '@angular/material/slide-toggle';
|
||||
import { MatTooltip } from '@angular/material/tooltip';
|
||||
import { Store } from '@ngrx/store';
|
||||
import { TranslatePipe, TranslateService } from '@ngx-translate/core';
|
||||
import { selectAllPlaylistsMeta } from 'm3u-state';
|
||||
import { startWith } from 'rxjs';
|
||||
import {
|
||||
DashboardLayoutService,
|
||||
DashboardWidgetConfig,
|
||||
DashboardWidgetProvider,
|
||||
DashboardWidgetSize,
|
||||
createDefaultWidgetScope,
|
||||
} from 'workspace-dashboard-data-access';
|
||||
import { DashboardWidgetHostComponent } from 'workspace-dashboard-ui';
|
||||
|
||||
@@ -31,9 +24,6 @@ import { DashboardWidgetHostComponent } from 'workspace-dashboard-ui';
|
||||
DashboardWidgetHostComponent,
|
||||
DragDropModule,
|
||||
MatButtonModule,
|
||||
MatCheckboxModule,
|
||||
MatChipsModule,
|
||||
MatExpansionModule,
|
||||
MatIcon,
|
||||
MatSlideToggleModule,
|
||||
MatTooltip,
|
||||
@@ -43,20 +33,13 @@ import { DashboardWidgetHostComponent } from 'workspace-dashboard-ui';
|
||||
styleUrl: './workspace-dashboard.component.scss',
|
||||
})
|
||||
export class WorkspaceDashboardComponent {
|
||||
private readonly store = inject(Store);
|
||||
private readonly translate = inject(TranslateService);
|
||||
|
||||
readonly editMode = signal(false);
|
||||
readonly playlists = this.store.selectSignal(selectAllPlaylistsMeta);
|
||||
private readonly languageTick = toSignal(
|
||||
this.translate.onLangChange.pipe(startWith(null)),
|
||||
{ initialValue: null }
|
||||
);
|
||||
readonly providerOptions: DashboardWidgetProvider[] = [
|
||||
'm3u',
|
||||
'xtream',
|
||||
'stalker',
|
||||
];
|
||||
private readonly sizeOrder: DashboardWidgetSize[] = [
|
||||
'one-third',
|
||||
'half',
|
||||
@@ -136,101 +119,6 @@ export class WorkspaceDashboardComponent {
|
||||
this.layoutService.toggleWidget(widgetId);
|
||||
}
|
||||
|
||||
supportsScope(widget: DashboardWidgetConfig): boolean {
|
||||
return Boolean(widget.settings?.scope);
|
||||
}
|
||||
|
||||
getProviderLabel(provider: DashboardWidgetProvider): string {
|
||||
this.languageTick();
|
||||
|
||||
if (provider === 'xtream') {
|
||||
return this.translateText('WORKSPACE.DASHBOARD.XTREAM');
|
||||
}
|
||||
if (provider === 'stalker') {
|
||||
return this.translateText('WORKSPACE.DASHBOARD.STALKER');
|
||||
}
|
||||
return this.translateText('WORKSPACE.DASHBOARD.M3U');
|
||||
}
|
||||
|
||||
getScopeProviderCount(widget: DashboardWidgetConfig): number {
|
||||
return (widget.settings?.scope ?? createDefaultWidgetScope()).providers
|
||||
.length;
|
||||
}
|
||||
|
||||
getScopeProviderSummary(widget: DashboardWidgetConfig): string {
|
||||
this.languageTick();
|
||||
|
||||
const count = this.getScopeProviderCount(widget);
|
||||
if (count === 0) {
|
||||
return this.translateText('WORKSPACE.DASHBOARD.ALL_PROVIDERS');
|
||||
}
|
||||
|
||||
return this.translateText('WORKSPACE.DASHBOARD.SELECTED_COUNT', {
|
||||
count,
|
||||
});
|
||||
}
|
||||
|
||||
isAllProvidersScope(widget: DashboardWidgetConfig): boolean {
|
||||
return this.getScopeProviderCount(widget) === 0;
|
||||
}
|
||||
|
||||
isProviderSelected(
|
||||
widget: DashboardWidgetConfig,
|
||||
provider: DashboardWidgetProvider
|
||||
): boolean {
|
||||
return (
|
||||
widget.settings?.scope ?? createDefaultWidgetScope()
|
||||
).providers.includes(provider);
|
||||
}
|
||||
|
||||
getScopePlaylists(widget: DashboardWidgetConfig) {
|
||||
const scope = widget.settings?.scope ?? createDefaultWidgetScope();
|
||||
const scopedProviders =
|
||||
scope.providers.length === 0
|
||||
? this.providerOptions
|
||||
: scope.providers;
|
||||
return this.playlists().filter((playlist) =>
|
||||
scopedProviders.includes(this.getPlaylistProviderType(playlist))
|
||||
);
|
||||
}
|
||||
|
||||
isPlaylistSelected(
|
||||
widget: DashboardWidgetConfig,
|
||||
playlistId: string
|
||||
): boolean {
|
||||
return (
|
||||
widget.settings?.scope ?? createDefaultWidgetScope()
|
||||
).playlistIds.includes(playlistId);
|
||||
}
|
||||
|
||||
toggleScopeProvider(
|
||||
widgetId: string,
|
||||
provider: DashboardWidgetProvider
|
||||
): void {
|
||||
this.layoutService.toggleWidgetScopeProvider(widgetId, provider);
|
||||
}
|
||||
|
||||
toggleScopePlaylist(widgetId: string, playlistId: string): void {
|
||||
this.layoutService.toggleWidgetScopePlaylist(widgetId, playlistId);
|
||||
}
|
||||
|
||||
getPlaylistTitle(playlist: {
|
||||
title?: string;
|
||||
filename?: string;
|
||||
url?: string;
|
||||
_id?: string;
|
||||
}): string {
|
||||
this.languageTick();
|
||||
|
||||
return (
|
||||
playlist.title ||
|
||||
playlist.filename ||
|
||||
playlist.url ||
|
||||
playlist._id ||
|
||||
this.translateText('WORKSPACE.DASHBOARD.UNTITLED_SOURCE')
|
||||
);
|
||||
}
|
||||
|
||||
getWidgetTitle(widget: DashboardWidgetConfig): string {
|
||||
this.languageTick();
|
||||
return this.translateText(widget.title);
|
||||
@@ -253,25 +141,6 @@ export class WorkspaceDashboardComponent {
|
||||
});
|
||||
}
|
||||
|
||||
getScopePanelDescription(widget: DashboardWidgetConfig): string {
|
||||
return this.translateText('WORKSPACE.DASHBOARD.WIDGET_SCOPE_SUMMARY', {
|
||||
summary: this.getScopeProviderSummary(widget),
|
||||
});
|
||||
}
|
||||
|
||||
private getPlaylistProviderType(playlist: {
|
||||
serverUrl?: string;
|
||||
macAddress?: string;
|
||||
}): DashboardWidgetProvider {
|
||||
if (playlist.serverUrl) {
|
||||
return 'xtream';
|
||||
}
|
||||
if (playlist.macAddress) {
|
||||
return 'stalker';
|
||||
}
|
||||
return 'm3u';
|
||||
}
|
||||
|
||||
private translateText(
|
||||
key: string,
|
||||
params?: Record<string, string | number>
|
||||
|
||||
Reference in new issue
Block a user