feat(dashboard): create separate workspace-dashboard lib with widgets

- Implemented `DashboardActivityItemsComponent` with styles and functionality for displaying activity items in list and grid views.
- Created `DashboardWidgetShellComponent` for consistent widget layout with header and content areas.
- Developed `GlobalFavoritesWidgetComponent` to manage and display global favorites with filtering options.
- Added `RecentSourcesWidgetComponent` to show recently accessed sources with links to manage them.
- Introduced `RecentlyWatchedWidgetComponent` to track and display recently watched content with filtering capabilities.
- Created `SourceStatsWidgetComponent` to present statistics of different source types.
- Established TypeScript configuration files for the dashboard UI library.
This commit is contained in:
4gray committed 2026-02-24 19:08:10 +01:00
1 parent 5952ce3719
commit 4211f22a69
54 files changed
+4254

No files matched your search

+188
View File
@@ -0,0 +1,188 @@
# Workspace Dashboard Plan
## Context
The workspace shell is now the primary app entrypoint. The dashboard should evolve from a placeholder into an operational home page that helps users:
1. Quickly continue playback/work.
2. Switch context across M3U, Xtream, and Stalker sources.
3. Monitor relevant content (recent items, EPG, status) in one place.
This document defines the implementation plan before Phase 1 work starts.
## Status Snapshot (February 22, 2026)
### Delivered
1. Dashboard route is active in workspace shell with persisted widget layout.
2. Widget host and configurable widget model are implemented.
3. `Customize` mode supports:
1. Enable/disable widgets
2. Reorder widgets (up/down)
3. Widget scope (provider + playlist selection)
4. Active widgets in production:
1. Continue Watching
2. Recent Sources
3. Source Statistics
4. Recently Watched (global)
5. Global Favorites
5. Recently Watched and Global Favorites support:
1. Content-kind chips (channels/vod/series)
2. List/grid toggle
3. Direct deep linking from widget item to target view/detail
6. Xtream live deep links now auto-start playback when opened from dashboard widgets.
7. Dashboard now uses a shared Material-based widget shell for consistent visual structure.
8. Customize mode now supports drag-and-drop ordering and widget size presets (`1/3`, `1/2`, `2/3`, `full`).
### Partially Delivered / Deviation From Initial Phase 1 List
1. EPG Radar was intentionally removed from the current dashboard scope and is postponed.
2. Recent Activity / Recently Added widget was intentionally removed from current scope.
### Not Started
1. Phase 4 external widgets (RSS/scores/news adapters).
### Immediate Next Widget Tasks (Recommended)
1. Expand widget settings UX (scope presets, bulk provider toggles).
## Product Direction
The dashboard should be a configurable widget system, but introduced in stages:
1. Start with useful, stable widgets and a constrained layout.
2. Add edit/customization workflows after widget value is proven.
3. Add advanced drag/resize and external integrations later.
This avoids building heavy layout mechanics before core data widgets are solid.
## UX Direction
Design style: professional operator console (dense, calm, high-signal).
Target layout:
1. Top row: continue actions, recent sources, health/status.
2. Middle row: discovery widgets (recently viewed, recently added, favorites).
3. Edit mode: add/remove/reorder widgets and configure source scope.
## Architecture
## Core Components
1. `DashboardPageComponent` (container/layout/edit mode orchestration)
2. `DashboardWidgetHostComponent` (widget factory/renderer by type)
3. `DashboardLayoutStore` (signal-based state for layout, settings, edit mode)
4. `DashboardPersistenceService` (save/load layout, version migration)
5. `DashboardDataFacade` (aggregate provider data for widgets)
## Widget Contract
```ts
type WidgetSize = 'one-third' | 'half' | 'two-thirds' | 'full';
interface WidgetScope {
providers: Array<'m3u' | 'xtream' | 'stalker'>;
playlistIds?: string[];
}
interface DashboardWidget {
id: string;
type: string;
title: string;
size: WidgetSize;
order: number;
enabled: boolean;
scope: WidgetScope;
settings: Record<string, unknown>;
}
interface DashboardLayout {
version: number;
widgets: DashboardWidget[];
}
```
## Capability Rules
Widgets must degrade gracefully per provider:
1. If a source/provider does not support required data (for example EPG), show a clear empty/unsupported state.
2. Widgets never hard fail the page; each widget owns loading/error states.
3. Scope defaults to "all supported providers" unless user config overrides it.
## Phased Delivery
## Phase 1 (Now): Production Dashboard V1
Scope:
1. Replace placeholder dashboard with real widget host + predefined layout.
2. Fixed grid slots (no free drag/resize yet).
3. Initial widgets:
1. Recent Sources
2. Continue Watching
3. Source Statistics
4. Recently Added / Recently Viewed (provider-aware)
4. Persist enabled/disabled and order (simple list reorder if needed).
Out of scope:
1. Freeform drag-and-drop grid resizing.
2. External data providers (RSS/sports/news).
3. Full widget marketplace.
Acceptance criteria:
1. Dashboard loads with useful content for at least one active source type.
2. Empty states are clear and actionable (links to Sources/settings).
3. No route regressions for existing workspace sections.
4. Layout/settings survive app restart.
## Phase 2: Edit Mode and Configuration
Scope:
1. "Customize dashboard" mode.
2. Enable/disable widgets.
3. Widget-level source scoping (providers + selected playlists).
4. Order management (move up/down or drag reorder within constrained grid).
## Phase 3: Advanced Layout (Drag/Resize)
Scope:
1. True grid layout manager with size presets and drag repositioning.
2. Collision handling and responsive breakpoint behavior.
3. Optional "reset layout" and preset templates.
## Phase 4: External Widgets
Scope:
1. Adapter interface for non-playlist data widgets (RSS, scores, news).
2. Polling/cache strategy with rate limits.
3. User opt-in and failure isolation per external source.
## Data and Performance Notes
1. Use memoized/computed selectors for widget inputs.
2. Avoid redundant provider fetches; reuse existing stores/services where possible.
3. Update widgets incrementally and isolate heavy computations in facade/store utilities.
## File/Module Placement (Proposed)
1. `libs/workspace/dashboard/feature/` (page + edit mode orchestration)
2. `libs/workspace/dashboard/ui/` (widget host + widget components)
3. `libs/workspace/dashboard/data-access/` (state model + persistence + data facade/service)
4. Route integration remains in `apps/web/src/app/app.routes.ts` and workspace shell.
## Open Questions
1. Should dashboard layout be global per app profile, or per active workspace/provider mix?
2. Which widgets are enabled by default for new users vs migrated users?
## Next Step
Start Phase 1 implementation using this document as the source of truth and track deviations explicitly in follow-up updates.
@@ -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": [],
"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,3 @@
export * from './lib/dashboard-widget.model';
export * from './lib/dashboard-layout.service';
export * from './lib/dashboard-data.service';
@@ -0,0 +1,94 @@
import { signal } from '@angular/core';
import { TestBed } from '@angular/core/testing';
import { Store } from '@ngrx/store';
import { selectAllPlaylistsMeta } from 'm3u-state';
import { DatabaseService } from 'services';
import { PlaylistMeta } from 'shared-interfaces';
import { DashboardDataService } from './dashboard-data.service';
describe('DashboardDataService.matchesScope', () => {
let service: DashboardDataService;
const playlistsSignal = signal<PlaylistMeta[]>([
{
_id: 'm3u-1',
title: 'M3U Playlist',
count: 1,
importDate: '2026-01-01T00:00:00.000Z',
autoRefresh: false,
},
{
_id: 'xtream-1',
title: 'Xtream Playlist',
count: 1,
importDate: '2026-01-01T00:00:00.000Z',
autoRefresh: false,
serverUrl: 'https://example.com',
},
]);
const storeMock = {
selectSignal: jest.fn((selector: unknown) => {
if (selector === selectAllPlaylistsMeta) {
return playlistsSignal;
}
return signal(null);
}),
};
const dbServiceMock = {
getGlobalRecentlyViewed: jest.fn(),
getGlobalFavorites: jest.fn(),
};
beforeEach(() => {
TestBed.configureTestingModule({
providers: [
DashboardDataService,
{ provide: Store, useValue: storeMock },
{ provide: DatabaseService, useValue: dbServiceMock },
],
});
service = TestBed.inject(DashboardDataService);
});
afterEach(() => {
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);
});
});
@@ -0,0 +1,764 @@
import { Injectable, computed, inject, signal } from '@angular/core';
import { Store } from '@ngrx/store';
import { selectActivePlaylist, selectAllPlaylistsMeta } from 'm3u-state';
import {
DatabaseService,
GlobalFavoriteItem as DbGlobalFavoriteItem,
GlobalRecentItem as DbGlobalRecentItem,
} from 'services';
import { PlaylistMeta } from 'shared-interfaces';
import {
DashboardWidgetProvider,
DashboardWidgetScopeSettings,
createDefaultWidgetScope,
} from './dashboard-widget.model';
type RecentActivityType = 'live' | 'movie' | 'series';
export type DashboardContentKind = 'all' | 'channels' | 'vod' | 'series';
interface GlobalRecentItem {
id: string | number;
title: string;
type: RecentActivityType;
playlist_id: string;
playlist_name?: string;
viewed_at: string;
category_id: string | number;
xtream_id: string | number;
poster_url?: string;
source?: 'xtream' | 'stalker';
stalker_item?: unknown;
}
export interface DashboardFavoriteItem {
id: string | number;
title: string;
type: RecentActivityType;
playlist_id: string;
playlist_name?: string;
added_at: string;
category_id: string | number;
xtream_id: string | number;
poster_url?: string;
source?: 'xtream' | 'stalker';
stalker_item?: unknown;
}
interface DashboardNavigationTarget {
link: string[];
state?: Record<string, unknown>;
}
@Injectable({ providedIn: 'root' })
export class DashboardDataService {
private readonly store = inject(Store);
private readonly dbService = inject(DatabaseService);
private readonly xtreamGlobalRecentItems = signal<GlobalRecentItem[]>([]);
private readonly xtreamGlobalFavorites = signal<DashboardFavoriteItem[]>(
[]
);
readonly playlists = this.store.selectSignal(selectAllPlaylistsMeta);
readonly activePlaylist = this.store.selectSignal(selectActivePlaylist);
readonly stalkerGlobalRecentItems = computed<GlobalRecentItem[]>(() =>
this.buildStalkerGlobalRecentItems(this.playlists())
);
readonly globalRecentItems = computed<GlobalRecentItem[]>(() =>
[...this.xtreamGlobalRecentItems(), ...this.stalkerGlobalRecentItems()]
.sort(
(a, b) =>
this.toDateTimestamp(b.viewed_at) -
this.toDateTimestamp(a.viewed_at)
)
.slice(0, 200)
);
readonly stalkerGlobalFavorites = computed<DashboardFavoriteItem[]>(() =>
this.buildStalkerGlobalFavorites(this.playlists())
);
readonly globalFavoriteItems = computed(() =>
[...this.xtreamGlobalFavorites(), ...this.stalkerGlobalFavorites()]
.sort(
(a, b) =>
this.toTimestamp(b.added_at) - this.toTimestamp(a.added_at)
)
.slice(0, 200)
);
constructor() {
if (window.electron) {
void this.reloadGlobalRecentItems();
void this.reloadGlobalFavorites();
}
}
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) =>
this.getRecentTimestamp(b) - this.getRecentTimestamp(a)
)
.slice(0, 12)
);
readonly quickRecent = computed(() => this.recentPlaylists().slice(0, 4));
async reloadGlobalRecentItems(): Promise<void> {
if (!window.electron) {
this.xtreamGlobalRecentItems.set([]);
return;
}
try {
const recentItems = await this.dbService.getGlobalRecentlyViewed();
const normalized = recentItems.map((item) =>
this.mapGlobalRecent(item)
);
this.xtreamGlobalRecentItems.set(normalized);
} catch {
this.xtreamGlobalRecentItems.set([]);
}
}
async reloadGlobalFavorites(): Promise<void> {
if (!window.electron) {
this.xtreamGlobalFavorites.set([]);
return;
}
try {
const favorites = await this.dbService.getGlobalFavorites();
const normalized = favorites.map((item) =>
this.mapGlobalFavorite(item)
);
this.xtreamGlobalFavorites.set(normalized);
} catch {
this.xtreamGlobalFavorites.set([]);
}
}
isTypeInKind(
type: RecentActivityType,
kind: DashboardContentKind
): boolean {
if (kind === 'all') {
return true;
}
if (kind === 'channels') {
return type === 'live';
}
if (kind === 'vod') {
return type === 'movie';
}
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'];
}
if (playlist.macAddress) {
return ['/workspace', 'stalker', playlist._id, 'vod'];
}
return ['/workspace', 'playlists', playlist._id];
}
getPlaylistProvider(playlist: PlaylistMeta): 'Xtream' | 'Stalker' | 'M3U' {
if (playlist.serverUrl) {
return 'Xtream';
}
if (playlist.macAddress) {
return 'Stalker';
}
return 'M3U';
}
getRecentItemProviderLabel(item: GlobalRecentItem): string {
if (item.source === 'stalker') {
return 'Stalker';
}
if (item.source === 'xtream') {
return 'Xtream';
}
return 'Provider';
}
getRecentItemTypeLabel(item: GlobalRecentItem): string {
return this.getTypeLabel(item.type);
}
getRecentItemLink(item: GlobalRecentItem): string[] {
return this.getRecentItemNavigation(item).link;
}
getRecentItemNavigationState(
item: GlobalRecentItem
): Record<string, unknown> | undefined {
return this.getRecentItemNavigation(item).state;
}
getFavoriteItemProviderLabel(item: DashboardFavoriteItem): string {
if (item.source === 'stalker') {
return 'Stalker';
}
if (item.source === 'xtream') {
return 'Xtream';
}
return 'Provider';
}
getFavoriteItemTypeLabel(item: DashboardFavoriteItem): string {
return this.getTypeLabel(item.type);
}
getGlobalFavoriteLink(item: DashboardFavoriteItem): string[] {
return this.getGlobalFavoriteNavigation(item).link;
}
getGlobalFavoriteNavigationState(
item: DashboardFavoriteItem
): Record<string, unknown> | undefined {
return this.getGlobalFavoriteNavigation(item).state;
}
formatTimestamp(value?: string | number): string {
const timestamp = this.toTimestamp(value);
if (!timestamp) {
return 'Not yet synced';
}
return new Date(timestamp).toLocaleString();
}
private mapGlobalFavorite(
item: DbGlobalFavoriteItem
): DashboardFavoriteItem {
return {
id: item.id,
title: item.title,
type: this.normalizeType(item.type),
playlist_id: item.playlist_id,
playlist_name: item.playlist_name,
added_at: this.normalizeDateString(item.added_at),
category_id: item.category_id,
xtream_id: item.xtream_id,
poster_url: item.poster_url,
source: 'xtream',
};
}
private mapGlobalRecent(item: DbGlobalRecentItem): GlobalRecentItem {
return {
id: item.id,
title: item.title,
type: this.normalizeType(item.type),
playlist_id: item.playlist_id,
playlist_name: item.playlist_name,
viewed_at: this.normalizeDateString(item.viewed_at),
category_id: item.category_id,
xtream_id: item.xtream_id,
poster_url: item.poster_url,
source: 'xtream',
};
}
private buildStalkerGlobalRecentItems(
playlists: PlaylistMeta[]
): GlobalRecentItem[] {
const items = playlists
.filter((playlist) => Boolean(playlist.macAddress))
.reduce<GlobalRecentItem[]>((acc, playlist) => {
const recentItems = Array.isArray(
(playlist as { recentlyViewed?: unknown[] }).recentlyViewed
)
? ((playlist as { recentlyViewed?: unknown[] })
.recentlyViewed ?? [])
: [];
const mapped = recentItems.map((rawItem, index: number) => {
const item = (rawItem ?? {}) as Record<string, unknown>;
const categoryId = String(item.category_id ?? '');
const id = String(
item.id ??
item.stream_id ??
item.series_id ??
item.movie_id ??
`${playlist._id}-${index}`
);
const title = String(
item.title ?? item.o_name ?? item.name ?? 'Unknown'
);
const posterUrl = String(
item.cover ?? item.logo ?? item.poster_url ?? ''
);
return {
id,
title,
type: this.normalizeStalkerType(item),
playlist_id: playlist._id,
playlist_name: playlist.title || 'Stalker Portal',
viewed_at: this.normalizeDateString(item.added_at),
category_id: categoryId,
xtream_id: id,
poster_url: posterUrl,
source: 'stalker' as const,
stalker_item: rawItem,
};
});
acc.push(...mapped);
return acc;
}, []);
return items.sort(
(a, b) =>
this.toDateTimestamp(b.viewed_at) -
this.toDateTimestamp(a.viewed_at)
);
}
private buildStalkerGlobalFavorites(
playlists: PlaylistMeta[]
): DashboardFavoriteItem[] {
const items = playlists
.filter((playlist) => Boolean(playlist.macAddress))
.reduce<DashboardFavoriteItem[]>((acc, playlist) => {
const favorites = Array.isArray(playlist.favorites)
? playlist.favorites
: [];
const mapped = favorites.map((item, index) => {
const categoryId = String((item as any)?.category_id ?? '');
const type = this.normalizeStalkerType(item);
const id = String(
(item as any)?.id ??
(item as any)?.stream_id ??
(item as any)?.series_id ??
(item as any)?.movie_id ??
`${playlist._id}-${index}`
);
return {
id,
title:
(item as any)?.title ??
(item as any)?.o_name ??
(item as any)?.name ??
'Unknown',
type,
playlist_id: playlist._id,
playlist_name: playlist.title || 'Stalker Portal',
added_at: this.normalizeDateString(
(item as any)?.added_at
),
category_id: categoryId,
xtream_id: id,
poster_url:
(item as any)?.cover ??
(item as any)?.logo ??
(item as any)?.poster_url ??
'',
source: 'stalker' as const,
stalker_item: item,
};
});
acc.push(...mapped);
return acc;
}, []);
return items.sort(
(a, b) =>
this.toTimestamp(b.added_at) - this.toTimestamp(a.added_at)
);
}
private normalizeStalkerType(item: unknown): RecentActivityType {
const categoryId = String(
(item as any)?.category_id ?? ''
).toLowerCase();
const streamType = String(
(item as any)?.stream_type ?? ''
).toLowerCase();
if (categoryId === 'itv' || streamType === 'live') {
return 'live';
}
if (
categoryId === 'series' ||
(item as any)?.is_series === true ||
(item as any)?.is_series === 1 ||
(item as any)?.is_series === '1'
) {
return 'series';
}
return 'movie';
}
private getRecentItemNavigation(
item: GlobalRecentItem
): DashboardNavigationTarget {
if (item.source === 'stalker') {
return {
link: ['/workspace', 'stalker', item.playlist_id, 'recent'],
state: {
openRecentItem: this.buildStalkerStateItem(
item.stalker_item,
item
),
},
};
}
return {
...this.buildXtreamNavigationTarget({
playlistId: item.playlist_id,
type: item.type,
categoryId: item.category_id,
itemId: item.xtream_id,
title: item.title,
imageUrl: item.poster_url,
}),
};
}
private getGlobalFavoriteNavigation(
item: DashboardFavoriteItem
): DashboardNavigationTarget {
if (item.source === 'stalker') {
return {
link: ['/workspace', 'stalker', item.playlist_id, 'favorites'],
state: {
openFavoriteItem: this.buildStalkerStateItem(
item.stalker_item,
item
),
},
};
}
return {
...this.buildXtreamNavigationTarget({
playlistId: item.playlist_id,
type: item.type,
categoryId: item.category_id,
itemId: item.xtream_id,
title: item.title,
imageUrl: item.poster_url,
}),
};
}
private buildXtreamNavigationTarget(params: {
playlistId: string;
type: RecentActivityType;
categoryId: string | number;
itemId: string | number;
title?: string;
imageUrl?: string;
}): DashboardNavigationTarget {
const link = this.buildXtreamItemLink(params);
const routeType = this.toXtreamRouteType(params.type);
if (routeType !== 'live') {
return { link };
}
const streamId = Number(this.toPathSegment(params.itemId));
if (!Number.isFinite(streamId) || streamId <= 0) {
return { link };
}
return {
link,
state: {
openXtreamLiveItemId: streamId,
openXtreamLiveTitle: params.title || '',
openXtreamLivePoster: params.imageUrl || '',
},
};
}
private buildXtreamItemLink(params: {
playlistId: string;
type: RecentActivityType;
categoryId: string | number;
itemId: string | number;
}): string[] {
const routeType = this.toXtreamRouteType(params.type);
const categoryId = this.toPathSegment(params.categoryId);
const itemId = this.toPathSegment(params.itemId);
if (routeType === 'live') {
return categoryId
? [
'/workspace',
'xtreams',
params.playlistId,
'live',
categoryId,
]
: ['/workspace', 'xtreams', params.playlistId, 'live'];
}
if (categoryId && itemId) {
return [
'/workspace',
'xtreams',
params.playlistId,
routeType,
categoryId,
itemId,
];
}
if (categoryId) {
return [
'/workspace',
'xtreams',
params.playlistId,
routeType,
categoryId,
];
}
return ['/workspace', 'xtreams', params.playlistId, routeType];
}
private buildStalkerStateItem(
rawItem: unknown,
fallback: {
id: string | number;
title: string;
type: RecentActivityType;
poster_url?: string;
}
) {
const normalizedCategory = this.toStalkerCategoryId(
(rawItem as any)?.category_id ?? fallback.type
);
const normalizedRaw = rawItem
? {
...(rawItem as Record<string, unknown>),
category_id: normalizedCategory,
}
: null;
if (normalizedRaw) {
return normalizedRaw;
}
const title = fallback.title || 'Unknown';
return {
id: String(fallback.id ?? ''),
title,
name: title,
o_name: title,
category_id: normalizedCategory,
cover: fallback.poster_url || '',
logo: fallback.poster_url || '',
};
}
private toXtreamRouteType(
type: RecentActivityType
): 'live' | 'vod' | 'series' {
if (type === 'movie') {
return 'vod';
}
return type;
}
private toStalkerCategoryId(value: unknown): 'vod' | 'series' | 'itv' {
const normalized = String(value ?? '').toLowerCase();
if (normalized === 'series') {
return 'series';
}
if (normalized === 'itv' || normalized === 'live') {
return 'itv';
}
return 'vod';
}
private toPathSegment(value: string | number): string {
const segment = String(value ?? '').trim();
return segment;
}
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 normalizeType(value: string): RecentActivityType {
return value === 'live' || value === 'series' ? value : 'movie';
}
private getTypeLabel(
type: RecentActivityType
): 'Live' | 'Movie' | 'Series' {
if (type === 'live') {
return 'Live';
}
if (type === 'series') {
return 'Series';
}
return 'Movie';
}
private normalizeDateString(value: unknown): string {
if (typeof value === 'number') {
if (!Number.isFinite(value)) {
return '';
}
const ms = value > 10_000_000_000 ? value : value * 1000;
return new Date(ms).toISOString();
}
if (typeof value === 'string') {
const trimmed = value.trim();
if (/^\d+$/.test(trimmed)) {
const numeric = Number(trimmed);
if (Number.isFinite(numeric)) {
const ms =
numeric > 10_000_000_000 ? numeric : numeric * 1000;
return new Date(ms).toISOString();
}
}
const parsed = Date.parse(trimmed);
if (!Number.isNaN(parsed)) {
return new Date(parsed).toISOString();
}
}
return '';
}
private getRecentTimestamp(item: PlaylistMeta): number {
return (
this.toTimestamp(item.updateDate) ||
this.toTimestamp(item.importDate)
);
}
private 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(trimmed);
return Number.isNaN(parsed) ? 0 : parsed;
}
return 0;
}
private toTimestamp(value?: string | number): number {
if (typeof value === 'number') {
return value;
}
if (typeof value === 'string') {
const parsed = Date.parse(value);
return Number.isNaN(parsed) ? 0 : parsed;
}
return 0;
}
}
@@ -0,0 +1,60 @@
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';
describe('DashboardLayoutService', () => {
let service: DashboardLayoutService;
beforeEach(() => {
localStorage.removeItem(STORAGE_KEY);
TestBed.configureTestingModule({
providers: [DashboardLayoutService],
});
service = TestBed.inject(DashboardLayoutService);
});
afterEach(() => {
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();
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(false);
});
});
@@ -0,0 +1,367 @@
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';
@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);
}
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,
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 {
...widget,
settings: widget.settings
? {
...widget.settings,
scope: widget.settings.scope
? this.normalizeScope(widget.settings.scope)
: undefined,
}
: undefined,
};
}
private mergeWidget(
defaultWidget: DashboardWidgetConfig,
loaded: DashboardWidgetConfig
): DashboardWidgetConfig {
return {
...defaultWidget,
...loaded,
size: this.normalizeSize(loaded.size ?? defaultWidget.size),
settings: this.mergeSettings(defaultWidget, loaded),
};
}
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,
};
}
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';
}
}
@@ -0,0 +1,112 @@
export type DashboardWidgetType =
| 'recent-sources'
| 'source-stats'
| 'continue-watching'
| 'recently-watched'
| '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;
type: DashboardWidgetType;
title: string;
description: string;
size: DashboardWidgetSize;
enabled: boolean;
order: number;
settings?: DashboardWidgetSettings;
}
export interface DashboardLayoutState {
version: number;
widgets: DashboardWidgetConfig[];
}
export const DASHBOARD_LAYOUT_VERSION = 5;
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[] = [
{
id: 'recent-sources',
type: 'recent-sources',
title: 'Recent Sources',
description: 'Latest added or refreshed playlists across providers.',
size: 'half',
enabled: true,
order: 0,
},
{
id: 'source-stats',
type: 'source-stats',
title: 'Source Statistics',
description: 'Current source mix and total connected libraries.',
size: 'one-third',
enabled: true,
order: 1,
},
{
id: 'continue',
type: 'continue-watching',
title: 'Continue Watching',
description: 'Jump back into the last active source.',
size: 'half',
enabled: true,
order: 2,
},
{
id: 'recently-watched',
type: 'recently-watched',
title: 'Recently Watched',
description: 'Global watch history across channels, VOD and series.',
size: 'one-third',
enabled: true,
order: 3,
settings: {
scope: createDefaultWidgetScope(),
},
},
{
id: 'global-favorites',
type: 'global-favorites',
title: 'Global Favorites',
description: 'Pinned items across all connected providers.',
size: 'two-thirds',
enabled: true,
order: 4,
settings: {
scope: createDefaultWidgetScope(),
},
},
];
@@ -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,13 @@
{
"name": "workspace-dashboard-feature",
"$schema": "../../../../node_modules/nx/schemas/project-schema.json",
"sourceRoot": "libs/workspace/dashboard/feature/src",
"prefix": "lib",
"projectType": "library",
"tags": [],
"targets": {
"lint": {
"executor": "@nx/eslint:lint"
}
}
}
@@ -0,0 +1 @@
export * from './lib/workspace-dashboard.component';
@@ -0,0 +1,188 @@
<section class="dashboard-page">
<header class="dashboard-header">
<div>
<p class="eyebrow">Workspace Dashboard</p>
<h1>Control Center</h1>
<p class="description">
Keep your daily sources in one place. In customize mode, edit directly
on the canvas, then use the sidebar for visibility and scope.
</p>
</div>
</header>
<section class="dashboard-toolbar" [class.dashboard-toolbar--editing]="editMode()">
<p class="toolbar-hint">
@if (editMode()) {
Drag cards by the handle. Resize inline. Use the sidebar for visibility and scope.
} @else {
Start customize mode to rearrange and resize widgets in place.
}
</p>
<div class="toolbar-actions">
@if (editMode()) {
<button type="button" mat-button (click)="layoutService.reset()">
Reset Layout
</button>
}
<button type="button" mat-flat-button (click)="toggleEditMode()">
<mat-icon>{{ editMode() ? 'check' : 'tune' }}</mat-icon>
{{ editMode() ? 'Done' : 'Customize' }}
</button>
</div>
</section>
<section class="dashboard-editor-shell" [class.dashboard-editor-shell--editing]="editMode()">
<section class="dashboard-canvas">
<section
class="widget-grid"
cdkDropList
[class.widget-grid--editing]="editMode()"
[cdkDropListData]="visibleWidgets()"
[cdkDropListDisabled]="!editMode()"
(cdkDropListDropped)="onWidgetDrop($event)"
>
@for (widget of visibleWidgets(); track widget.id) {
<article
class="widget-slot"
cdkDrag
[cdkDragDisabled]="!editMode()"
[class.widget-slot--one-third]="widget.size === 'one-third'"
[class.widget-slot--half]="widget.size === 'half'"
[class.widget-slot--two-thirds]="widget.size === 'two-thirds'"
[class.widget-slot--full]="widget.size === 'full'"
[class.widget-slot--editing]="editMode()"
>
@if (editMode()) {
<section class="widget-inline-editor">
<button
type="button"
mat-icon-button
class="drag-handle"
cdkDragHandle
[attr.aria-label]="'Drag ' + widget.title"
>
<mat-icon>drag_indicator</mat-icon>
</button>
<p class="widget-inline-editor__title">{{ widget.title }}</p>
<mat-button-toggle-group
class="inline-size-control"
[value]="widget.size"
(change)="setWidgetSize(widget.id, $event.value)"
aria-label="Widget width"
>
@for (size of sizeOptions; track size.value) {
<mat-button-toggle [value]="size.value">
{{ size.label }}
</mat-button-toggle>
}
</mat-button-toggle-group>
<button
type="button"
mat-icon-button
class="widget-inline-editor__hide"
[attr.aria-label]="'Hide ' + widget.title"
(click)="hideWidget(widget.id)"
>
<mat-icon>visibility_off</mat-icon>
</button>
</section>
}
<app-dashboard-widget-host [widget]="widget" />
</article>
} @empty {
<article class="empty-state">
<h3>No visible widgets</h3>
<p>Enable at least one widget from the sidebar.</p>
</article>
}
</section>
</section>
@if (editMode()) {
<aside class="widget-library">
<section class="layout-editor">
<h2>Widget Library</h2>
<p class="layout-editor__hint">
Show/hide widgets and configure provider scope without leaving the canvas.
</p>
<div class="editor-list">
@for (widget of allWidgets(); track widget.id) {
<article class="editor-item">
<div class="editor-item__meta">
<h3>{{ widget.title }}</h3>
<p>{{ widget.description }}</p>
</div>
<div class="editor-item__actions">
<p class="editor-item__action-label">Visibility</p>
<mat-slide-toggle
[checked]="widget.enabled"
(change)="layoutService.toggleWidget(widget.id)"
color="primary"
>
Visible
</mat-slide-toggle>
</div>
@if (supportsScope(widget)) {
<mat-expansion-panel class="advanced-panel">
<mat-expansion-panel-header>
<mat-panel-title>Advanced settings</mat-panel-title>
<mat-panel-description>
Scope ({{ getScopeProviderSummary(widget) }})
</mat-panel-description>
</mat-expansion-panel-header>
<section class="scope-editor">
<p class="scope-editor__title">Scope</p>
<p class="scope-editor__subtitle">
Provider filters: {{ getScopeProviderSummary(widget) }}
</p>
@if (isAllProvidersScope(widget)) {
<p class="scope-editor__subtitle">
No providers selected means all providers are included.
</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">
Playlists (leave empty to use all playlists in current provider scope)
</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">
No playlists available for selected providers.
</p>
}
</div>
</section>
</mat-expansion-panel>
}
</article>
}
</div>
</section>
</aside>
}
</section>
</section>
@@ -0,0 +1,519 @@
:host {
display: block;
min-height: 100%;
box-sizing: border-box;
padding: 20px;
--dashboard-accent: color-mix(in srgb, var(--mat-sys-primary) 84%, #1c75ff 16%);
--dashboard-frame-border: color-mix(
in srgb,
var(--mat-sys-outline-variant) 72%,
var(--dashboard-accent) 28%
);
--dashboard-frame-glow: color-mix(in srgb, var(--dashboard-accent) 34%, transparent);
--dashboard-frame-tint: color-mix(
in srgb,
var(--dashboard-accent) 12%,
var(--mat-sys-surface-container-high) 88%
);
--widget-grid-gap: 12px;
--dashboard-widget-height: 530px;
}
.dashboard-page {
max-width: 1440px;
margin: 0 auto;
display: flex;
flex-direction: column;
gap: 14px;
min-width: 0;
overflow-x: clip;
}
.dashboard-header {
border: 1px solid var(--dashboard-frame-border);
border-radius: 18px;
background:
radial-gradient(
1100px 300px at 105% -40%,
color-mix(in srgb, var(--dashboard-accent) 24%, transparent),
transparent 58%
),
linear-gradient(
165deg,
var(--mat-sys-surface-container-highest) 0%,
var(--mat-sys-surface-container-high) 55%,
var(--mat-sys-surface-container) 100%
);
padding: 20px;
box-shadow: 0 16px 30px -28px var(--dashboard-frame-glow);
}
.eyebrow {
margin: 0;
color: var(--mat-sys-on-surface-variant);
text-transform: uppercase;
letter-spacing: 0.11em;
font-size: 0.72rem;
font-weight: 700;
}
h1 {
margin: 8px 0 10px;
font-size: clamp(1.44rem, 2vw, 1.95rem);
line-height: 1.14;
}
.description {
margin: 0;
color: var(--mat-sys-on-surface-variant);
max-width: 780px;
line-height: 1.45;
}
.dashboard-toolbar {
border: 1px solid var(--dashboard-frame-border);
border-radius: 14px;
background: linear-gradient(
180deg,
var(--mat-sys-surface-container-high) 0%,
var(--mat-sys-surface-container) 100%
);
padding: 10px 12px;
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
}
.dashboard-toolbar--editing {
position: sticky;
top: 8px;
z-index: 20;
box-shadow: 0 14px 26px -24px var(--dashboard-frame-glow);
}
.toolbar-hint {
margin: 0;
color: var(--mat-sys-on-surface-variant);
font-size: 0.82rem;
}
.toolbar-actions {
display: flex;
align-items: center;
gap: 8px;
flex-shrink: 0;
}
.dashboard-editor-shell {
border-radius: 18px;
border: 1px solid color-mix(in srgb, var(--dashboard-frame-border) 72%, transparent);
background: color-mix(
in srgb,
var(--mat-sys-surface-container-high) 84%,
var(--dashboard-frame-tint) 16%
);
overflow: clip;
min-width: 0;
}
.dashboard-editor-shell--editing {
display: grid;
grid-template-columns: minmax(0, 1fr) 380px;
gap: 0;
align-items: start;
}
.dashboard-canvas {
min-width: 0;
overflow-x: clip;
}
.widget-library {
border-left: 1px solid var(--dashboard-frame-border);
background: linear-gradient(
180deg,
var(--mat-sys-surface-container-highest) 0%,
var(--mat-sys-surface-container-high) 100%
);
box-shadow: -18px 0 30px -22px var(--dashboard-frame-glow);
max-height: calc(100vh - 96px);
overflow: auto;
overflow-x: hidden;
position: sticky;
top: 76px;
min-width: 0;
}
.layout-editor {
padding: 14px;
}
.layout-editor h2 {
margin: 0;
font-size: 1rem;
}
.layout-editor__hint {
margin: 6px 0 12px;
color: var(--mat-sys-on-surface-variant);
font-size: 0.8rem;
}
.editor-list {
display: grid;
gap: 8px;
}
.editor-item {
border: 1px solid var(--dashboard-frame-border);
border-radius: 14px;
background: color-mix(
in srgb,
var(--mat-sys-surface-container-low) 84%,
var(--dashboard-frame-tint) 16%
);
padding: 10px 12px;
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 10px;
flex-wrap: wrap;
overflow: hidden;
}
.editor-item__meta h3 {
margin: 0;
font-size: 0.92rem;
}
.editor-item__meta p {
margin: 4px 0 0;
font-size: 0.79rem;
color: var(--mat-sys-on-surface-variant);
}
.editor-item__actions {
display: flex;
align-items: center;
gap: 8px;
width: 100%;
justify-content: space-between;
padding-top: 4px;
}
.editor-item__action-label {
margin: 0;
font-size: 0.74rem;
color: var(--mat-sys-on-surface-variant);
text-transform: uppercase;
letter-spacing: 0.06em;
font-weight: 600;
}
.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));
gap: var(--widget-grid-gap);
align-items: stretch;
position: relative;
padding: 12px;
min-width: 0;
overflow-x: clip;
}
.widget-grid--editing::before {
content: '';
position: absolute;
inset: 12px;
pointer-events: none;
background-image: repeating-linear-gradient(
to right,
color-mix(in srgb, var(--dashboard-frame-border) 60%, transparent) 0,
color-mix(in srgb, var(--dashboard-frame-border) 60%, transparent) 1px,
transparent 1px,
transparent calc(100% / 12)
);
opacity: 0.5;
z-index: 0;
}
.widget-slot {
min-width: 0;
min-height: 0;
border: 1px solid var(--dashboard-frame-border);
border-radius: 20px;
background: color-mix(
in srgb,
var(--mat-sys-surface-container-high) 80%,
var(--dashboard-frame-tint) 20%
);
padding: 3px;
box-sizing: border-box;
box-shadow:
inset 0 0 0 1px color-mix(in srgb, var(--dashboard-frame-border) 64%, transparent),
0 14px 24px -22px var(--dashboard-frame-glow);
overflow: hidden;
position: relative;
transition:
transform 0.2s ease,
box-shadow 0.2s ease,
border-color 0.2s ease;
z-index: 1;
height: var(--dashboard-widget-height);
}
.widget-slot:hover {
transform: translateY(-1px);
box-shadow:
inset 0 0 0 1px color-mix(in srgb, var(--dashboard-frame-border) 52%, transparent),
0 18px 28px -20px var(--dashboard-frame-glow);
}
.widget-slot--editing {
cursor: grab;
}
.widget-slot--editing:active {
cursor: grabbing;
}
.widget-slot--editing app-dashboard-widget-host {
pointer-events: none;
user-select: none;
}
.widget-inline-editor {
position: absolute;
top: 8px;
left: 8px;
right: 8px;
z-index: 4;
display: flex;
align-items: center;
gap: 8px;
border: 1px solid var(--dashboard-frame-border);
border-radius: 12px;
background: color-mix(
in srgb,
var(--mat-sys-surface-container-high) 78%,
var(--dashboard-accent) 22%
);
padding: 4px 6px;
backdrop-filter: blur(4px);
}
.widget-inline-editor__title {
margin: 0;
font-size: 0.74rem;
font-weight: 600;
max-width: 200px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.drag-handle {
color: var(--mat-sys-on-surface-variant);
}
.inline-size-control {
margin-left: auto;
border: 1px solid var(--mat-sys-outline-variant);
border-radius: 8px;
background: var(--mat-sys-surface-container);
}
.widget-inline-editor__hide {
color: var(--mat-sys-on-surface-variant);
}
.widget-slot app-dashboard-widget-host {
display: block;
height: 100%;
}
.widget-slot--one-third {
grid-column: span 4;
}
.widget-slot--half {
grid-column: span 6;
}
.widget-slot--two-thirds {
grid-column: span 8;
}
.widget-slot--full {
grid-column: span 12;
}
.empty-state {
grid-column: 1 / -1;
border: 1px dashed var(--mat-sys-outline);
border-radius: 14px;
background: var(--mat-sys-surface-container-low);
padding: 20px;
z-index: 1;
}
.empty-state h3 {
margin: 0 0 8px;
}
.empty-state p {
margin: 0;
color: var(--mat-sys-on-surface-variant);
}
:host ::ng-deep .cdk-drag-preview {
border-radius: 20px;
border: 1px solid var(--dashboard-frame-border);
box-shadow: 0 24px 40px -20px var(--dashboard-frame-glow);
background: var(--mat-sys-surface-container-high);
}
:host ::ng-deep .cdk-drop-list-dragging .widget-slot:not(.cdk-drag-placeholder) {
transition: transform 0.2s ease;
}
:host ::ng-deep .widget-slot.cdk-drag-placeholder {
border: 1px dashed var(--dashboard-frame-border);
background: color-mix(in srgb, var(--dashboard-frame-tint) 72%, transparent);
}
@media (max-width: 1180px) {
.dashboard-editor-shell--editing {
grid-template-columns: minmax(0, 1fr) 340px;
}
.widget-slot--one-third,
.widget-slot--half,
.widget-slot--two-thirds {
grid-column: span 6;
}
}
@media (max-width: 860px) {
:host {
padding: 14px;
}
.dashboard-toolbar {
flex-direction: column;
align-items: flex-start;
}
.toolbar-actions {
width: 100%;
justify-content: flex-start;
flex-wrap: wrap;
}
.dashboard-editor-shell--editing {
grid-template-columns: 1fr;
}
.widget-library {
border-left: 0;
border-top: 1px solid var(--dashboard-frame-border);
max-height: none;
position: static;
box-shadow: none;
overflow: visible;
}
.widget-inline-editor {
flex-wrap: wrap;
}
.widget-inline-editor__title {
max-width: none;
}
.inline-size-control {
margin-left: 0;
}
.widget-grid {
grid-template-columns: 1fr;
}
.widget-slot {
height: 480px;
}
.widget-slot--one-third,
.widget-slot--half,
.widget-slot--two-thirds,
.widget-slot--full {
grid-column: span 1;
}
}
@@ -0,0 +1,184 @@
import { computed, Component, inject, signal } from '@angular/core';
import { CdkDragDrop, DragDropModule, moveItemInArray } from '@angular/cdk/drag-drop';
import { MatButtonModule } from '@angular/material/button';
import { MatButtonToggleModule } from '@angular/material/button-toggle';
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 { Store } from '@ngrx/store';
import { selectAllPlaylistsMeta } from 'm3u-state';
import { DashboardLayoutService } from 'workspace-dashboard-data-access';
import { DashboardWidgetHostComponent } from 'workspace-dashboard-ui';
import {
DashboardWidgetConfig,
DashboardWidgetProvider,
DashboardWidgetSize,
createDefaultWidgetScope,
} from 'workspace-dashboard-data-access';
@Component({
selector: 'app-workspace-dashboard',
imports: [
DashboardWidgetHostComponent,
DragDropModule,
MatButtonModule,
MatButtonToggleModule,
MatCheckboxModule,
MatChipsModule,
MatExpansionModule,
MatIcon,
MatSlideToggleModule,
],
templateUrl: './workspace-dashboard.component.html',
styleUrl: './workspace-dashboard.component.scss',
})
export class WorkspaceDashboardComponent {
private readonly store = inject(Store);
readonly editMode = signal(false);
readonly playlists = this.store.selectSignal(selectAllPlaylistsMeta);
readonly providerOptions: DashboardWidgetProvider[] = [
'm3u',
'xtream',
'stalker',
];
readonly sizeOptions: Array<{ value: DashboardWidgetSize; label: string }> = [
{ value: 'one-third', label: '1/3' },
{ value: 'half', label: '1/2' },
{ value: 'two-thirds', label: '2/3' },
{ value: 'full', label: 'Full' },
];
readonly allWidgets = computed(() =>
[...this.layoutService.state().widgets].sort((a, b) => a.order - b.order)
);
readonly visibleWidgets = computed(() =>
this.allWidgets().filter((widget) => widget.enabled)
);
constructor(readonly layoutService: DashboardLayoutService) {}
toggleEditMode(): void {
this.editMode.update((value) => !value);
}
onWidgetDrop(event: CdkDragDrop<DashboardWidgetConfig[]>): void {
if (event.previousIndex === event.currentIndex) {
return;
}
const reordered = [...this.visibleWidgets()];
moveItemInArray(reordered, event.previousIndex, event.currentIndex);
this.layoutService.reorderVisibleWidgets(reordered.map((widget) => widget.id));
}
setWidgetSize(widgetId: string, size: unknown): void {
if (
size === 'one-third' ||
size === 'half' ||
size === 'two-thirds' ||
size === 'full'
) {
this.layoutService.setWidgetSize(widgetId, size);
}
}
hideWidget(widgetId: string): void {
this.layoutService.toggleWidget(widgetId);
}
supportsScope(widget: DashboardWidgetConfig): boolean {
return Boolean(widget.settings?.scope);
}
getProviderLabel(provider: DashboardWidgetProvider): string {
if (provider === 'xtream') {
return 'Xtream';
}
if (provider === 'stalker') {
return 'Stalker';
}
return 'M3U';
}
getScopeProviderCount(widget: DashboardWidgetConfig): number {
return (widget.settings?.scope ?? createDefaultWidgetScope()).providers.length;
}
getScopeProviderSummary(widget: DashboardWidgetConfig): string {
const count = this.getScopeProviderCount(widget);
return count === 0 ? 'All providers' : `${count} selected`;
}
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 {
return (
playlist.title ||
playlist.filename ||
playlist.url ||
playlist._id ||
'Untitled playlist'
);
}
private getPlaylistProviderType(playlist: {
serverUrl?: string;
macAddress?: string;
}): DashboardWidgetProvider {
if (playlist.serverUrl) {
return 'xtream';
}
if (playlist.macAddress) {
return 'stalker';
}
return 'm3u';
}
}
@@ -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"]
}
+3
View File
@@ -0,0 +1,3 @@
# ui
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: {},
},
];
+13
View File
@@ -0,0 +1,13 @@
{
"name": "workspace-dashboard-ui",
"$schema": "../../../../node_modules/nx/schemas/project-schema.json",
"sourceRoot": "libs/workspace/dashboard/ui/src",
"prefix": "lib",
"projectType": "library",
"tags": [],
"targets": {
"lint": {
"executor": "@nx/eslint:lint"
}
}
}
+1
View File
@@ -0,0 +1 @@
export * from './lib/dashboard-widget-host.component';
@@ -0,0 +1,23 @@
@switch (widget().type) {
@case ('recent-sources') {
<app-recent-sources-widget />
}
@case ('source-stats') {
<app-source-stats-widget />
}
@case ('continue-watching') {
<app-continue-watching-widget />
}
@case ('recently-watched') {
<app-recently-watched-widget [widget]="widget()" />
}
@case ('global-favorites') {
<app-global-favorites-widget [widget]="widget()" />
}
@default {
<section class="unsupported-widget">
<h3>Unsupported widget</h3>
<p>This widget type is not available in this build.</p>
</section>
}
}
@@ -0,0 +1,37 @@
:host {
display: block;
height: 100%;
}
.unsupported-widget {
border: 1px dashed var(--mat-sys-outline-variant);
border-radius: 14px;
padding: 20px;
background: linear-gradient(
160deg,
var(--mat-sys-surface-container-high) 0%,
var(--mat-sys-surface-container-low) 100%
);
height: 100%;
box-sizing: border-box;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
text-align: center;
gap: 8px;
color: var(--mat-sys-on-surface-variant);
h3 {
margin: 0;
font-size: 0.94rem;
font-weight: 600;
}
p {
margin: 0;
font-size: 0.82rem;
max-width: 260px;
line-height: 1.4;
}
}
@@ -0,0 +1,23 @@
import { Component, input } from '@angular/core';
import { DashboardWidgetConfig } from 'workspace-dashboard-data-access';
import { ContinueWatchingWidgetComponent } from './widgets/continue-watching-widget.component';
import { GlobalFavoritesWidgetComponent } from './widgets/global-favorites-widget.component';
import { RecentSourcesWidgetComponent } from './widgets/recent-sources-widget.component';
import { RecentlyWatchedWidgetComponent } from './widgets/recently-watched-widget.component';
import { SourceStatsWidgetComponent } from './widgets/source-stats-widget.component';
@Component({
selector: 'app-dashboard-widget-host',
imports: [
ContinueWatchingWidgetComponent,
GlobalFavoritesWidgetComponent,
RecentSourcesWidgetComponent,
RecentlyWatchedWidgetComponent,
SourceStatsWidgetComponent,
],
templateUrl: './dashboard-widget-host.component.html',
styleUrl: './dashboard-widget-host.component.scss',
})
export class DashboardWidgetHostComponent {
readonly widget = input.required<DashboardWidgetConfig>();
}
@@ -0,0 +1,48 @@
<app-dashboard-widget-shell eyebrow="Focus" title="Continue Watching">
@if (data.activePlaylist(); as active) {
<article class="focus-source">
<p class="focus-source__provider">
{{ data.getPlaylistProvider(active) }}
</p>
<h4
[attr.title]="active.title || active.filename || 'Untitled source'"
>
{{ active.title || active.filename || 'Untitled source' }}
</h4>
<div class="focus-source__actions">
<a mat-flat-button [routerLink]="data.getPlaylistLink(active)">
<mat-icon>play_arrow</mat-icon>
Open Source
</a>
<a mat-stroked-button routerLink="/workspace/sources">
<mat-icon>library_books</mat-icon>
Manage Sources
</a>
</div>
</article>
} @else {
<article class="focus-empty">
<p>No active source selected yet.</p>
<a mat-flat-button routerLink="/workspace/sources">
<mat-icon>add</mat-icon>
Add Source
</a>
</article>
}
@if (data.quickRecent().length > 0) {
<div class="quick-list">
<p class="quick-list__title">Recently used</p>
@for (item of data.quickRecent(); track item._id) {
<a class="quick-item" [routerLink]="data.getPlaylistLink(item)">
<span class="quick-item__title" [attr.title]="item.title || item.filename">
{{ item.title || item.filename || 'Untitled source' }}
</span>
<span class="quick-item__meta">
{{ data.getPlaylistProvider(item) }}
</span>
</a>
}
</div>
}
</app-dashboard-widget-shell>
@@ -0,0 +1,94 @@
.focus-source {
border: 1px solid var(--mat-sys-outline-variant);
background: var(--mat-sys-surface-container-low);
border-radius: 12px;
padding: 12px;
display: flex;
flex-direction: column;
gap: 10px;
}
.focus-source__provider {
margin: 0;
color: var(--mat-sys-on-surface-variant);
font-size: 0.76rem;
letter-spacing: 0.08em;
text-transform: uppercase;
font-weight: 600;
}
.focus-source h4 {
margin: 0;
font-size: 1rem;
line-height: 1.3;
}
.focus-source__actions {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.focus-empty {
border: 1px dashed var(--mat-sys-outline);
background: var(--mat-sys-surface-container-low);
border-radius: 12px;
padding: 12px;
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
}
.focus-empty p {
margin: 0;
color: var(--mat-sys-on-surface-variant);
}
.quick-list {
display: flex;
flex-direction: column;
gap: 8px;
}
.quick-list__title {
margin: 0;
font-size: 0.8rem;
color: var(--mat-sys-on-surface-variant);
letter-spacing: 0.06em;
text-transform: uppercase;
font-weight: 600;
}
.quick-item {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
border: 1px solid var(--mat-sys-outline-variant);
border-radius: 10px;
background: var(--mat-sys-surface-container-low);
padding: 8px 10px;
text-decoration: none;
color: inherit;
min-width: 0;
}
.quick-item:hover {
border-color: var(--mat-sys-outline);
background: var(--mat-sys-surface-container-high);
}
.quick-item__title {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-size: 0.88rem;
}
.quick-item__meta {
color: var(--mat-sys-on-surface-variant);
font-size: 0.74rem;
white-space: nowrap;
}
@@ -0,0 +1,16 @@
import { Component, inject } from '@angular/core';
import { MatButtonModule } from '@angular/material/button';
import { MatIcon } from '@angular/material/icon';
import { RouterLink } from '@angular/router';
import { DashboardDataService } from 'workspace-dashboard-data-access';
import { DashboardWidgetShellComponent } from './dashboard-widget-shell.component';
@Component({
selector: 'app-continue-watching-widget',
imports: [MatButtonModule, MatIcon, RouterLink, DashboardWidgetShellComponent],
templateUrl: './continue-watching-widget.component.html',
styleUrl: './continue-watching-widget.component.scss',
})
export class ContinueWatchingWidgetComponent {
readonly data = inject(DashboardDataService);
}
@@ -0,0 +1,59 @@
@if (items().length > 0) {
<div
class="activity-items"
[class.activity-items--grid]="viewMode() === 'grid'"
(scroll)="onItemsScroll($event)"
>
@for (item of items(); track item.id) {
<a
class="activity-item"
[routerLink]="item.link"
[state]="item.navigationState"
>
<span
class="activity-item__media"
[class.activity-item__media--live]="item.type === 'live'"
[class.activity-item__media--poster]="item.type !== 'live'"
[class.is-placeholder]="!hasImage(item)"
[class.is-placeholder-live]="
!hasImage(item) && item.type === 'live'
"
[class.is-placeholder-movie]="
!hasImage(item) && item.type === 'movie'
"
[class.is-placeholder-series]="
!hasImage(item) && item.type === 'series'
"
>
@if (hasImage(item)) {
<img
[src]="item.imageUrl"
[alt]="item.title"
loading="lazy"
(error)="markImageAsFailed(item.id)"
/>
} @else {
<mat-icon>{{ getFallbackIcon(item) }}</mat-icon>
}
</span>
<span class="activity-item__content">
<span
class="activity-item__title"
[attr.title]="item.title"
>
{{ item.title }}
</span>
<span
class="activity-item__subtitle"
[attr.title]="item.subtitle"
>
{{ item.subtitle }}
</span>
</span>
</a>
}
</div>
} @else {
<p class="empty">{{ emptyLabel() }}</p>
}
@@ -0,0 +1,198 @@
:host {
display: flex;
flex-direction: column;
min-height: 0;
flex: 1;
position: relative;
--activity-fade-end: var(--app-widget-bg, var(--mat-sys-surface-container));
}
:host-context(body.dark-theme) {
--activity-fade-end: var(--app-widget-bg, var(--mat-sys-surface-container));
}
:host::after {
content: '';
position: absolute;
left: 0;
right: 0;
bottom: 0;
height: 76px;
pointer-events: none;
z-index: 3;
background: linear-gradient(
180deg,
rgba(0, 0, 0, 0) 0%,
color-mix(in srgb, var(--activity-fade-end) 42%, transparent) 45%,
var(--activity-fade-end) 100%
);
}
.activity-items {
display: flex;
flex-direction: column;
gap: 8px;
flex: 1;
min-height: 0;
overflow: auto;
position: relative;
z-index: 1;
}
.activity-item {
text-decoration: none;
color: inherit;
border: 1px solid var(--mat-sys-outline-variant);
border-radius: 11px;
background: linear-gradient(
180deg,
var(--mat-sys-surface-container-low) 0%,
var(--mat-sys-surface-container) 100%
);
padding: 8px;
display: grid;
grid-template-columns: 44px minmax(0, 1fr);
align-items: center;
gap: 10px;
}
.activity-item:hover {
border-color: color-mix(in srgb, var(--mat-sys-outline) 72%, var(--mat-sys-primary) 28%);
background: linear-gradient(
180deg,
var(--mat-sys-surface-container-high) 0%,
var(--mat-sys-surface-container) 100%
);
}
.activity-item__media {
width: 44px;
height: 44px;
border-radius: 9px;
overflow: hidden;
background: var(--mat-sys-surface-container-highest);
display: inline-flex;
align-items: center;
justify-content: center;
color: var(--mat-sys-on-surface-variant);
border: 1px solid var(--mat-sys-outline-variant);
}
.activity-item__media img {
width: 100%;
height: 100%;
object-fit: cover;
display: block;
}
.activity-item__media--live img {
object-fit: contain;
padding: 4px;
box-sizing: border-box;
}
.activity-item__media mat-icon {
font-size: 1.15rem;
width: 1.15rem;
height: 1.15rem;
}
.is-placeholder-live {
background: linear-gradient(135deg, rgba(56, 189, 248, 0.15), rgba(56, 189, 248, 0.02)) !important;
color: #38bdf8 !important;
border: 1px solid rgba(56, 189, 248, 0.2) !important;
}
.is-placeholder-movie {
background: linear-gradient(135deg, rgba(167, 139, 250, 0.15), rgba(167, 139, 250, 0.02)) !important;
color: #a78bfa !important;
border: 1px solid rgba(167, 139, 250, 0.2) !important;
}
.is-placeholder-series {
background: linear-gradient(135deg, rgba(251, 146, 60, 0.15), rgba(251, 146, 60, 0.02)) !important;
color: #fb923c !important;
border: 1px solid rgba(251, 146, 60, 0.2) !important;
}
.is-placeholder mat-icon {
opacity: 0.9;
}
.activity-item__content {
min-width: 0;
display: flex;
flex-direction: column;
gap: 2px;
}
.activity-item__title {
font-size: 0.85rem;
line-height: 1.25;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.activity-item__subtitle {
font-size: 0.73rem;
color: var(--mat-sys-on-surface-variant);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.activity-items--grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(136px, 1fr));
gap: 7px;
}
.activity-items--grid .activity-item {
grid-template-columns: 1fr;
align-items: stretch;
gap: 5px;
padding: 5px;
}
.activity-items--grid .activity-item__media {
width: 100%;
height: auto;
aspect-ratio: 16 / 9;
}
.activity-items--grid .is-placeholder mat-icon {
font-size: 2.8rem !important;
width: 2.8rem !important;
height: 2.8rem !important;
}
.activity-items--grid .activity-item__title {
white-space: normal;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
line-height: 1.16;
font-size: 0.78rem;
}
.activity-items--grid .activity-item__subtitle {
white-space: normal;
display: -webkit-box;
-webkit-line-clamp: 1;
-webkit-box-orient: vertical;
font-size: 0.68rem;
}
.empty {
margin: 0;
color: var(--mat-sys-on-surface-variant);
font-size: 0.82rem;
}
@media (max-width: 920px) {
.activity-items--grid {
grid-template-columns: repeat(auto-fill, minmax(120px, 1fr));
}
}
@@ -0,0 +1,76 @@
import { Component, EventEmitter, input, Output, signal } from '@angular/core';
import { MatIcon } from '@angular/material/icon';
import { RouterLink } from '@angular/router';
export interface DashboardActivityItemViewModel {
id: string;
title: string;
subtitle: string;
type: 'live' | 'movie' | 'series';
imageUrl?: string;
link: string[];
navigationState?: Record<string, unknown>;
}
type DashboardActivityViewMode = 'list' | 'grid';
@Component({
selector: 'app-dashboard-activity-items',
imports: [MatIcon, RouterLink],
templateUrl: './dashboard-activity-items.component.html',
styleUrl: './dashboard-activity-items.component.scss',
})
export class DashboardActivityItemsComponent {
@Output() readonly scrolledToEnd = new EventEmitter<void>();
readonly items = input.required<DashboardActivityItemViewModel[]>();
readonly emptyLabel = input('No items to show.');
readonly viewMode = input<DashboardActivityViewMode>('list');
readonly failedImages = signal<Record<string, true>>({});
hasImage(item: DashboardActivityItemViewModel): boolean {
if (!item.imageUrl) {
return false;
}
return !this.failedImages()[item.id];
}
markImageAsFailed(itemId: string): void {
this.failedImages.update((state) => {
if (state[itemId]) {
return state;
}
return {
...state,
[itemId]: true,
};
});
}
getFallbackIcon(item: DashboardActivityItemViewModel): string {
if (item.type === 'live') {
return 'live_tv';
}
if (item.type === 'series') {
return 'movie_filter';
}
return 'movie';
}
onItemsScroll(event: Event): void {
const target = event.target as HTMLElement | null;
if (!target) {
return;
}
const remaining = target.scrollHeight - target.scrollTop - target.clientHeight;
if (remaining <= 120) {
this.scrolledToEnd.emit();
}
}
}
@@ -0,0 +1,14 @@
<mat-card class="widget-shell" appearance="outlined">
<header class="widget-shell__header">
<div class="widget-shell__headline">
<p class="widget-shell__eyebrow">{{ eyebrow() }}</p>
<h3 class="widget-shell__title">{{ title() }}</h3>
</div>
<div class="widget-shell__actions">
<ng-content select="[widget-actions]" />
</div>
</header>
<div class="widget-shell__content">
<ng-content />
</div>
</mat-card>
@@ -0,0 +1,80 @@
:host {
display: block;
height: 100%;
}
.widget-shell {
border-radius: 16px;
background: var(--app-widget-bg, var(--mat-sys-surface-container));
border: 1px solid var(--app-widget-border, transparent);
box-shadow: var(--app-widget-shadow, none);
display: flex;
flex-direction: column;
height: 100%;
box-sizing: border-box;
overflow: hidden;
// Subtle inset top-edge highlight — catches light like a raised surface
outline: 1px solid rgba(255, 255, 255, 0.04);
outline-offset: -1px;
}
.widget-shell__header {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 12px;
padding: 14px 16px 10px;
position: sticky;
top: 0;
z-index: 2;
background: var(--app-widget-header-bg, var(--mat-sys-surface-container-high));
border-bottom: 1px solid var(--app-widget-header-border, rgba(255, 255, 255, 0.05));
}
.widget-shell__headline {
min-width: 0;
}
.widget-shell__eyebrow {
margin: 0;
color: var(--app-eyebrow-color, var(--mat-sys-on-surface-variant));
text-transform: uppercase;
letter-spacing: 0.1em;
font-size: 0.67rem;
font-weight: 600;
}
.widget-shell__title {
margin: 4px 0 0;
font-size: 1rem;
font-weight: 600;
line-height: 1.2;
color: var(--app-heading-color, var(--mat-sys-on-surface));
letter-spacing: -0.01em;
}
.widget-shell__actions {
display: inline-flex;
align-items: center;
gap: 8px;
min-height: 28px;
}
.widget-shell__actions:empty {
display: none;
}
.widget-shell__content {
display: flex;
flex-direction: column;
gap: 12px;
min-height: 0;
flex: 1;
padding: 0 16px 0;
box-sizing: border-box;
overflow-y: auto;
overflow-x: hidden;
scrollbar-gutter: stable;
scrollbar-width: thin;
scrollbar-color: rgba(255, 255, 255, 0.08) transparent;
}
@@ -0,0 +1,13 @@
import { Component, input } from '@angular/core';
import { MatCardModule } from '@angular/material/card';
@Component({
selector: 'app-dashboard-widget-shell',
imports: [MatCardModule],
templateUrl: './dashboard-widget-shell.component.html',
styleUrl: './dashboard-widget-shell.component.scss',
})
export class DashboardWidgetShellComponent {
readonly eyebrow = input.required<string>();
readonly title = input.required<string>();
}
@@ -0,0 +1,68 @@
<app-dashboard-widget-shell eyebrow="Library" title="Global Favorites">
<ng-container widget-actions>
<button
mat-icon-button
[matMenuTriggerFor]="filterMenu"
aria-label="Filter global favorites content"
matTooltip="Filter items"
>
<mat-icon>filter_list</mat-icon>
</button>
<mat-menu #filterMenu="matMenu">
<button mat-menu-item (click)="onKindChange('all')">
<mat-icon>{{
selectedKind() === 'all' ? 'check' : ''
}}</mat-icon>
<span [class.active-filter]="selectedKind() === 'all'"
>All ({{ counts().all }})</span
>
</button>
<button mat-menu-item (click)="onKindChange('channels')">
<mat-icon>{{
selectedKind() === 'channels' ? 'check' : ''
}}</mat-icon>
<span [class.active-filter]="selectedKind() === 'channels'"
>Channels ({{ counts().channels }})</span
>
</button>
<button mat-menu-item (click)="onKindChange('vod')">
<mat-icon>{{
selectedKind() === 'vod' ? 'check' : ''
}}</mat-icon>
<span [class.active-filter]="selectedKind() === 'vod'"
>VOD ({{ counts().vod }})</span
>
</button>
<button mat-menu-item (click)="onKindChange('series')">
<mat-icon>{{
selectedKind() === 'series' ? 'check' : ''
}}</mat-icon>
<span [class.active-filter]="selectedKind() === 'series'"
>Series ({{ counts().series }})</span
>
</button>
</mat-menu>
<mat-button-toggle-group
class="view-toggle"
[value]="viewMode()"
hideSingleSelectionIndicator
aria-label="Select global favorites view mode"
(change)="onViewModeChange($event.value)"
>
<mat-button-toggle value="list" aria-label="List view">
<mat-icon>view_list</mat-icon>
</mat-button-toggle>
<mat-button-toggle value="grid" aria-label="Grid view">
<mat-icon>grid_view</mat-icon>
</mat-button-toggle>
</mat-button-toggle-group>
</ng-container>
<app-dashboard-activity-items
[items]="activityItems()"
[emptyLabel]="'No favorites found for this filter.'"
[viewMode]="viewMode()"
(scrolledToEnd)="onItemsScrolledToEnd()"
/>
</app-dashboard-widget-shell>
@@ -0,0 +1,34 @@
.view-toggle {
border-radius: 8px;
height: 32px;
align-items: center;
border: 1px solid var(--mat-sys-outline-variant);
background: var(--mat-sys-surface-container-high);
mat-icon {
font-size: 18px;
width: 18px;
height: 18px;
line-height: 18px;
}
}
::ng-deep .view-toggle {
.mat-button-toggle-label-content {
line-height: 30px !important;
padding: 0 8px !important;
}
.mat-button-toggle-checked .mat-button-toggle-button {
background-color: var(--mat-sys-secondary-container) !important;
}
.mat-button-toggle-checked mat-icon {
color: var(--mat-sys-on-secondary-container) !important;
}
}
.active-filter {
font-weight: 600;
color: var(--mat-sys-primary);
}
@@ -0,0 +1,186 @@
import { Component, computed, inject, input, signal } from '@angular/core';
import { MatButtonModule } from '@angular/material/button';
import { MatButtonToggleModule } from '@angular/material/button-toggle';
import { MatIcon } from '@angular/material/icon';
import { MatMenuModule } from '@angular/material/menu';
import {
DashboardContentKind,
DashboardDataService,
DashboardWidgetConfig,
createDefaultWidgetScope,
} from 'workspace-dashboard-data-access';
import {
DashboardActivityItemViewModel,
DashboardActivityItemsComponent,
} from './dashboard-activity-items.component';
import { DashboardWidgetShellComponent } from './dashboard-widget-shell.component';
const GLOBAL_FAVORITES_KIND_STORAGE_KEY =
'workspace-dashboard-global-favorites-kind';
const GLOBAL_FAVORITES_VIEW_MODE_STORAGE_KEY =
'workspace-dashboard-global-favorites-view-mode';
type DashboardActivityViewMode = 'list' | 'grid';
const INITIAL_PAGE_SIZE = 20;
const PAGE_SIZE = 20;
@Component({
selector: 'app-global-favorites-widget',
imports: [
MatButtonModule,
MatButtonToggleModule,
MatMenuModule,
MatIcon,
DashboardActivityItemsComponent,
DashboardWidgetShellComponent,
],
templateUrl: './global-favorites-widget.component.html',
styleUrl: './global-favorites-widget.component.scss',
})
export class GlobalFavoritesWidgetComponent {
readonly widget = input.required<DashboardWidgetConfig>();
readonly data = inject(DashboardDataService);
readonly selectedKind = signal<DashboardContentKind>(
this.readStoredKind() ?? 'all'
);
readonly viewMode = signal<DashboardActivityViewMode>(
this.readStoredViewMode() ?? 'list'
);
readonly visibleItemLimit = signal(INITIAL_PAGE_SIZE);
readonly scope = computed(
() => this.widget().settings?.scope ?? createDefaultWidgetScope()
);
readonly counts = computed(() => {
const items = this.data.globalFavoriteItems();
return {
all: items.length,
channels: items.filter((item) => item.type === 'live').length,
vod: items.filter((item) => item.type === 'movie').length,
series: items.filter((item) => item.type === 'series').length,
};
});
readonly filteredItems = computed(() =>
this.data
.globalFavoriteItems()
.filter((item) =>
this.data.isTypeInKind(item.type, this.selectedKind())
)
.filter((item) =>
this.data.matchesScope(
item.playlist_id,
item.source,
this.scope()
)
)
);
readonly activityItems = computed<DashboardActivityItemViewModel[]>(() =>
this.filteredItems()
.slice(0, this.visibleItemLimit())
.map((item) => ({
id: `${item.id}-${item.playlist_id}-${item.added_at}`,
title: item.title,
subtitle: `${this.data.getFavoriteItemProviderLabel(item)} · ${this.data.getFavoriteItemTypeLabel(item)} · ${this.formatTimestamp(item.added_at)}`,
type: item.type,
imageUrl: item.poster_url,
link: this.data.getGlobalFavoriteLink(item),
navigationState:
this.data.getGlobalFavoriteNavigationState(item),
}))
);
constructor() {
void this.data.reloadGlobalFavorites();
}
onKindChange(kind: DashboardContentKind): void {
this.setKind(kind);
}
onViewModeChange(value: unknown): void {
if (value === 'list' || value === 'grid') {
this.viewMode.set(value);
this.persistViewMode(value);
}
}
onItemsScrolledToEnd(): void {
if (this.visibleItemLimit() >= this.filteredItems().length) {
return;
}
this.visibleItemLimit.update((value) => value + PAGE_SIZE);
}
private setKind(value: unknown): void {
if (
value === 'all' ||
value === 'channels' ||
value === 'vod' ||
value === 'series'
) {
this.selectedKind.set(value);
this.persistKind(value);
this.visibleItemLimit.set(INITIAL_PAGE_SIZE);
}
}
private readStoredKind(): DashboardContentKind | null {
if (typeof localStorage === 'undefined') {
return null;
}
const value = localStorage.getItem(GLOBAL_FAVORITES_KIND_STORAGE_KEY);
if (
value === 'all' ||
value === 'channels' ||
value === 'vod' ||
value === 'series'
) {
return value;
}
return null;
}
private persistKind(kind: DashboardContentKind): void {
if (typeof localStorage === 'undefined') {
return;
}
localStorage.setItem(GLOBAL_FAVORITES_KIND_STORAGE_KEY, kind);
}
private readStoredViewMode(): DashboardActivityViewMode | null {
if (typeof localStorage === 'undefined') {
return null;
}
const value = localStorage.getItem(
GLOBAL_FAVORITES_VIEW_MODE_STORAGE_KEY
);
return value === 'grid' || value === 'list' ? value : null;
}
private persistViewMode(viewMode: DashboardActivityViewMode): void {
if (typeof localStorage === 'undefined') {
return;
}
localStorage.setItem(GLOBAL_FAVORITES_VIEW_MODE_STORAGE_KEY, viewMode);
}
private formatTimestamp(value: string): string {
const date = new Date(value);
if (Number.isNaN(date.getTime())) {
return 'Recently';
}
return date.toLocaleString([], {
month: 'short',
day: 'numeric',
hour: '2-digit',
minute: '2-digit',
});
}
}
@@ -0,0 +1,28 @@
<app-dashboard-widget-shell eyebrow="Library" title="Recent Sources">
<a widget-actions routerLink="/workspace/sources">Manage all</a>
@if (data.recentPlaylists().length > 0) {
<div class="source-list">
@for (item of data.recentPlaylists(); track item._id) {
<a class="source-row" [routerLink]="data.getPlaylistLink(item)">
<div class="source-row__head">
<span class="provider">
{{ data.getPlaylistProvider(item) }}
</span>
@if (item.count) {
<span class="count">{{ item.count }} channels</span>
}
</div>
<h4 [attr.title]="item.title || item.filename || 'Untitled source'">
{{ item.title || item.filename || 'Untitled source' }}
</h4>
<p>{{ data.formatTimestamp(item.updateDate || item.importDate) }}</p>
</a>
}
</div>
} @else {
<article class="empty-state">
<p>No sources available yet.</p>
<a routerLink="/workspace/sources">Open Sources</a>
</article>
}
</app-dashboard-widget-shell>
@@ -0,0 +1,91 @@
[widget-actions] {
color: var(--mat-sys-primary);
text-decoration: none;
font-size: 0.84rem;
font-weight: 600;
white-space: nowrap;
}
[widget-actions]:hover {
text-decoration: underline;
}
.source-list {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 10px;
}
.source-row {
border-radius: 12px;
border: 1px solid var(--mat-sys-outline-variant);
background: var(--mat-sys-surface-container-low);
padding: 10px;
text-decoration: none;
color: inherit;
min-width: 0;
}
.source-row:hover {
border-color: var(--mat-sys-outline);
background: var(--mat-sys-surface-container-high);
}
.source-row__head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
}
.provider,
.count {
font-size: 0.75rem;
color: var(--mat-sys-on-surface-variant);
}
.provider {
text-transform: uppercase;
letter-spacing: 0.06em;
font-weight: 600;
}
.source-row h4 {
margin: 8px 0 6px;
font-size: 0.95rem;
line-height: 1.25;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.source-row p {
margin: 0;
font-size: 0.77rem;
color: var(--mat-sys-on-surface-variant);
}
.empty-state {
margin: 0;
border: 1px dashed var(--mat-sys-outline);
border-radius: 12px;
background: var(--mat-sys-surface-container-low);
padding: 14px;
}
.empty-state p {
margin: 0 0 8px;
color: var(--mat-sys-on-surface-variant);
}
.empty-state a {
color: var(--mat-sys-primary);
text-decoration: none;
font-weight: 600;
}
@media (max-width: 920px) {
.source-list {
grid-template-columns: 1fr;
}
}
@@ -0,0 +1,14 @@
import { Component, inject } from '@angular/core';
import { RouterLink } from '@angular/router';
import { DashboardDataService } from 'workspace-dashboard-data-access';
import { DashboardWidgetShellComponent } from './dashboard-widget-shell.component';
@Component({
selector: 'app-recent-sources-widget',
imports: [RouterLink, DashboardWidgetShellComponent],
templateUrl: './recent-sources-widget.component.html',
styleUrl: './recent-sources-widget.component.scss',
})
export class RecentSourcesWidgetComponent {
readonly data = inject(DashboardDataService);
}
@@ -0,0 +1,68 @@
<app-dashboard-widget-shell eyebrow="Activity" title="Recently Watched">
<ng-container widget-actions>
<button
mat-icon-button
[matMenuTriggerFor]="filterMenu"
aria-label="Filter recently watched content"
matTooltip="Filter items"
>
<mat-icon>filter_list</mat-icon>
</button>
<mat-menu #filterMenu="matMenu">
<button mat-menu-item (click)="onKindChange('all')">
<mat-icon>{{
selectedKind() === 'all' ? 'check' : ''
}}</mat-icon>
<span [class.active-filter]="selectedKind() === 'all'"
>All ({{ counts().all }})</span
>
</button>
<button mat-menu-item (click)="onKindChange('channels')">
<mat-icon>{{
selectedKind() === 'channels' ? 'check' : ''
}}</mat-icon>
<span [class.active-filter]="selectedKind() === 'channels'"
>Channels ({{ counts().channels }})</span
>
</button>
<button mat-menu-item (click)="onKindChange('vod')">
<mat-icon>{{
selectedKind() === 'vod' ? 'check' : ''
}}</mat-icon>
<span [class.active-filter]="selectedKind() === 'vod'"
>VOD ({{ counts().vod }})</span
>
</button>
<button mat-menu-item (click)="onKindChange('series')">
<mat-icon>{{
selectedKind() === 'series' ? 'check' : ''
}}</mat-icon>
<span [class.active-filter]="selectedKind() === 'series'"
>Series ({{ counts().series }})</span
>
</button>
</mat-menu>
<mat-button-toggle-group
class="view-toggle"
[value]="viewMode()"
hideSingleSelectionIndicator
aria-label="Select recently watched view mode"
(change)="onViewModeChange($event.value)"
>
<mat-button-toggle value="list" aria-label="List view">
<mat-icon>view_list</mat-icon>
</mat-button-toggle>
<mat-button-toggle value="grid" aria-label="Grid view">
<mat-icon>grid_view</mat-icon>
</mat-button-toggle>
</mat-button-toggle-group>
</ng-container>
<app-dashboard-activity-items
[items]="activityItems()"
[emptyLabel]="'No items found for this filter.'"
[viewMode]="viewMode()"
(scrolledToEnd)="onItemsScrolledToEnd()"
/>
</app-dashboard-widget-shell>
@@ -0,0 +1,34 @@
.view-toggle {
border-radius: 8px;
height: 32px;
align-items: center;
border: 1px solid var(--mat-sys-outline-variant);
background: var(--mat-sys-surface-container-high);
mat-icon {
font-size: 18px;
width: 18px;
height: 18px;
line-height: 18px;
}
}
::ng-deep .view-toggle {
.mat-button-toggle-label-content {
line-height: 30px !important;
padding: 0 8px !important;
}
.mat-button-toggle-checked .mat-button-toggle-button {
background-color: var(--mat-sys-secondary-container) !important;
}
.mat-button-toggle-checked mat-icon {
color: var(--mat-sys-on-secondary-container) !important;
}
}
.active-filter {
font-weight: 600;
color: var(--mat-sys-primary);
}
@@ -0,0 +1,185 @@
import { Component, computed, inject, input, signal } from '@angular/core';
import { MatButtonModule } from '@angular/material/button';
import { MatButtonToggleModule } from '@angular/material/button-toggle';
import { MatIcon } from '@angular/material/icon';
import { MatMenuModule } from '@angular/material/menu';
import {
DashboardContentKind,
DashboardDataService,
DashboardWidgetConfig,
createDefaultWidgetScope,
} from 'workspace-dashboard-data-access';
import {
DashboardActivityItemViewModel,
DashboardActivityItemsComponent,
} from './dashboard-activity-items.component';
import { DashboardWidgetShellComponent } from './dashboard-widget-shell.component';
const RECENTLY_WATCHED_KIND_STORAGE_KEY =
'workspace-dashboard-recently-watched-kind';
const RECENTLY_WATCHED_VIEW_MODE_STORAGE_KEY =
'workspace-dashboard-recently-watched-view-mode';
type DashboardActivityViewMode = 'list' | 'grid';
const INITIAL_PAGE_SIZE = 20;
const PAGE_SIZE = 20;
@Component({
selector: 'app-recently-watched-widget',
imports: [
MatButtonModule,
MatButtonToggleModule,
MatMenuModule,
MatIcon,
DashboardActivityItemsComponent,
DashboardWidgetShellComponent,
],
templateUrl: './recently-watched-widget.component.html',
styleUrl: './recently-watched-widget.component.scss',
})
export class RecentlyWatchedWidgetComponent {
readonly widget = input.required<DashboardWidgetConfig>();
readonly data = inject(DashboardDataService);
readonly selectedKind = signal<DashboardContentKind>(
this.readStoredKind() ?? 'all'
);
readonly viewMode = signal<DashboardActivityViewMode>(
this.readStoredViewMode() ?? 'list'
);
readonly visibleItemLimit = signal(INITIAL_PAGE_SIZE);
readonly scope = computed(
() => this.widget().settings?.scope ?? createDefaultWidgetScope()
);
readonly counts = computed(() => {
const items = this.data.globalRecentItems();
return {
all: items.length,
channels: items.filter((item) => item.type === 'live').length,
vod: items.filter((item) => item.type === 'movie').length,
series: items.filter((item) => item.type === 'series').length,
};
});
readonly filteredItems = computed(() =>
this.data
.globalRecentItems()
.filter((item) =>
this.data.isTypeInKind(item.type, this.selectedKind())
)
.filter((item) =>
this.data.matchesScope(
item.playlist_id,
item.source,
this.scope()
)
)
);
readonly activityItems = computed<DashboardActivityItemViewModel[]>(() =>
this.filteredItems()
.slice(0, this.visibleItemLimit())
.map((item) => ({
id: `${item.id}-${item.playlist_id}-${item.viewed_at}`,
title: item.title,
subtitle: `${this.data.getRecentItemProviderLabel(item)} · ${this.data.getRecentItemTypeLabel(item)} · ${this.formatTimestamp(item.viewed_at)}`,
type: item.type,
imageUrl: item.poster_url,
link: this.data.getRecentItemLink(item),
navigationState: this.data.getRecentItemNavigationState(item),
}))
);
constructor() {
void this.data.reloadGlobalRecentItems();
}
onKindChange(kind: DashboardContentKind): void {
this.setKind(kind);
}
onViewModeChange(value: unknown): void {
if (value === 'list' || value === 'grid') {
this.viewMode.set(value);
this.persistViewMode(value);
}
}
onItemsScrolledToEnd(): void {
if (this.visibleItemLimit() >= this.filteredItems().length) {
return;
}
this.visibleItemLimit.update((value) => value + PAGE_SIZE);
}
private setKind(value: unknown): void {
if (
value === 'all' ||
value === 'channels' ||
value === 'vod' ||
value === 'series'
) {
this.selectedKind.set(value);
this.persistKind(value);
this.visibleItemLimit.set(INITIAL_PAGE_SIZE);
}
}
private readStoredKind(): DashboardContentKind | null {
if (typeof localStorage === 'undefined') {
return null;
}
const value = localStorage.getItem(RECENTLY_WATCHED_KIND_STORAGE_KEY);
if (
value === 'all' ||
value === 'channels' ||
value === 'vod' ||
value === 'series'
) {
return value;
}
return null;
}
private persistKind(kind: DashboardContentKind): void {
if (typeof localStorage === 'undefined') {
return;
}
localStorage.setItem(RECENTLY_WATCHED_KIND_STORAGE_KEY, kind);
}
private readStoredViewMode(): DashboardActivityViewMode | null {
if (typeof localStorage === 'undefined') {
return null;
}
const value = localStorage.getItem(
RECENTLY_WATCHED_VIEW_MODE_STORAGE_KEY
);
return value === 'grid' || value === 'list' ? value : null;
}
private persistViewMode(viewMode: DashboardActivityViewMode): void {
if (typeof localStorage === 'undefined') {
return;
}
localStorage.setItem(RECENTLY_WATCHED_VIEW_MODE_STORAGE_KEY, viewMode);
}
private formatTimestamp(value: string): string {
const date = new Date(value);
if (Number.isNaN(date.getTime())) {
return 'Recently';
}
return date.toLocaleString([], {
month: 'short',
day: 'numeric',
hour: '2-digit',
minute: '2-digit',
});
}
}
@@ -0,0 +1,20 @@
<app-dashboard-widget-shell eyebrow="Overview" title="Source Statistics">
<div class="stats-grid">
<article class="stat-tile">
<p class="label">Total</p>
<p class="value">{{ data.stats().total }}</p>
</article>
<article class="stat-tile">
<p class="label">Xtream</p>
<p class="value">{{ data.stats().xtream }}</p>
</article>
<article class="stat-tile">
<p class="label">Stalker</p>
<p class="value">{{ data.stats().stalker }}</p>
</article>
<article class="stat-tile">
<p class="label">M3U</p>
<p class="value">{{ data.stats().m3u }}</p>
</article>
</div>
</app-dashboard-widget-shell>
@@ -0,0 +1,26 @@
.stats-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 8px;
}
.stat-tile {
border-radius: 12px;
border: 1px solid var(--mat-sys-outline-variant);
background: var(--mat-sys-surface-container-low);
padding: 10px;
}
.label {
margin: 0;
color: var(--mat-sys-on-surface-variant);
font-size: 0.76rem;
text-transform: uppercase;
letter-spacing: 0.06em;
}
.value {
margin: 6px 0 0;
font-size: 1.25rem;
font-weight: 700;
}
@@ -0,0 +1,13 @@
import { Component, inject } from '@angular/core';
import { DashboardDataService } from 'workspace-dashboard-data-access';
import { DashboardWidgetShellComponent } from './dashboard-widget-shell.component';
@Component({
selector: 'app-source-stats-widget',
imports: [DashboardWidgetShellComponent],
templateUrl: './source-stats-widget.component.html',
styleUrl: './source-stats-widget.component.scss',
})
export class SourceStatsWidgetComponent {
readonly data = inject(DashboardDataService);
}
+28
View File
@@ -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"]
}