mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-15 21:56:18 -08:00
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:
1 parent
5952ce3719
commit
4211f22a69
54 files changed
+4254
No files matched your search
@@ -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"]
|
||||
}
|
||||
@@ -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: {},
|
||||
},
|
||||
];
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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"]
|
||||
}
|
||||
Reference in new issue
Block a user