From 4211f22a692eea6c6b12733f50f18ab6a076620c Mon Sep 17 00:00:00 2001 From: 4gray Date: Tue, 24 Feb 2026 19:08:10 +0100 Subject: [PATCH] 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. --- docs/architecture/workspace-dashboard.md | 188 +++++ .../workspace/dashboard/data-access/README.md | 3 + .../dashboard/data-access/eslint.config.mjs | 34 + .../dashboard/data-access/jest.config.ts | 22 + .../dashboard/data-access/project.json | 21 + .../dashboard/data-access/src/index.ts | 3 + .../src/lib/dashboard-data.service.spec.ts | 94 +++ .../src/lib/dashboard-data.service.ts | 764 ++++++++++++++++++ .../src/lib/dashboard-layout.service.spec.ts | 60 ++ .../src/lib/dashboard-layout.service.ts | 367 +++++++++ .../src/lib/dashboard-widget.model.ts | 112 +++ .../dashboard/data-access/src/test-setup.ts | 6 + .../dashboard/data-access/tsconfig.json | 31 + .../dashboard/data-access/tsconfig.lib.json | 12 + .../dashboard/data-access/tsconfig.spec.json | 16 + libs/workspace/dashboard/feature/README.md | 3 + .../dashboard/feature/eslint.config.mjs | 34 + libs/workspace/dashboard/feature/project.json | 13 + libs/workspace/dashboard/feature/src/index.ts | 1 + .../lib/workspace-dashboard.component.html | 188 +++++ .../lib/workspace-dashboard.component.scss | 519 ++++++++++++ .../src/lib/workspace-dashboard.component.ts | 184 +++++ .../workspace/dashboard/feature/tsconfig.json | 28 + .../dashboard/feature/tsconfig.lib.json | 12 + libs/workspace/dashboard/ui/README.md | 3 + libs/workspace/dashboard/ui/eslint.config.mjs | 34 + libs/workspace/dashboard/ui/project.json | 13 + libs/workspace/dashboard/ui/src/index.ts | 1 + .../lib/dashboard-widget-host.component.html | 23 + .../lib/dashboard-widget-host.component.scss | 37 + .../lib/dashboard-widget-host.component.ts | 23 + .../continue-watching-widget.component.html | 48 ++ .../continue-watching-widget.component.scss | 94 +++ .../continue-watching-widget.component.ts | 16 + .../dashboard-activity-items.component.html | 59 ++ .../dashboard-activity-items.component.scss | 198 +++++ .../dashboard-activity-items.component.ts | 76 ++ .../dashboard-widget-shell.component.html | 14 + .../dashboard-widget-shell.component.scss | 80 ++ .../dashboard-widget-shell.component.ts | 13 + .../global-favorites-widget.component.html | 68 ++ .../global-favorites-widget.component.scss | 34 + .../global-favorites-widget.component.ts | 186 +++++ .../recent-sources-widget.component.html | 28 + .../recent-sources-widget.component.scss | 91 +++ .../recent-sources-widget.component.ts | 14 + .../recently-watched-widget.component.html | 68 ++ .../recently-watched-widget.component.scss | 34 + .../recently-watched-widget.component.ts | 185 +++++ .../source-stats-widget.component.html | 20 + .../source-stats-widget.component.scss | 26 + .../widgets/source-stats-widget.component.ts | 13 + libs/workspace/dashboard/ui/tsconfig.json | 28 + libs/workspace/dashboard/ui/tsconfig.lib.json | 12 + 54 files changed, 4254 insertions(+) create mode 100644 docs/architecture/workspace-dashboard.md create mode 100644 libs/workspace/dashboard/data-access/README.md create mode 100644 libs/workspace/dashboard/data-access/eslint.config.mjs create mode 100644 libs/workspace/dashboard/data-access/jest.config.ts create mode 100644 libs/workspace/dashboard/data-access/project.json create mode 100644 libs/workspace/dashboard/data-access/src/index.ts create mode 100644 libs/workspace/dashboard/data-access/src/lib/dashboard-data.service.spec.ts create mode 100644 libs/workspace/dashboard/data-access/src/lib/dashboard-data.service.ts create mode 100644 libs/workspace/dashboard/data-access/src/lib/dashboard-layout.service.spec.ts create mode 100644 libs/workspace/dashboard/data-access/src/lib/dashboard-layout.service.ts create mode 100644 libs/workspace/dashboard/data-access/src/lib/dashboard-widget.model.ts create mode 100644 libs/workspace/dashboard/data-access/src/test-setup.ts create mode 100644 libs/workspace/dashboard/data-access/tsconfig.json create mode 100644 libs/workspace/dashboard/data-access/tsconfig.lib.json create mode 100644 libs/workspace/dashboard/data-access/tsconfig.spec.json create mode 100644 libs/workspace/dashboard/feature/README.md create mode 100644 libs/workspace/dashboard/feature/eslint.config.mjs create mode 100644 libs/workspace/dashboard/feature/project.json create mode 100644 libs/workspace/dashboard/feature/src/index.ts create mode 100644 libs/workspace/dashboard/feature/src/lib/workspace-dashboard.component.html create mode 100644 libs/workspace/dashboard/feature/src/lib/workspace-dashboard.component.scss create mode 100644 libs/workspace/dashboard/feature/src/lib/workspace-dashboard.component.ts create mode 100644 libs/workspace/dashboard/feature/tsconfig.json create mode 100644 libs/workspace/dashboard/feature/tsconfig.lib.json create mode 100644 libs/workspace/dashboard/ui/README.md create mode 100644 libs/workspace/dashboard/ui/eslint.config.mjs create mode 100644 libs/workspace/dashboard/ui/project.json create mode 100644 libs/workspace/dashboard/ui/src/index.ts create mode 100644 libs/workspace/dashboard/ui/src/lib/dashboard-widget-host.component.html create mode 100644 libs/workspace/dashboard/ui/src/lib/dashboard-widget-host.component.scss create mode 100644 libs/workspace/dashboard/ui/src/lib/dashboard-widget-host.component.ts create mode 100644 libs/workspace/dashboard/ui/src/lib/widgets/continue-watching-widget.component.html create mode 100644 libs/workspace/dashboard/ui/src/lib/widgets/continue-watching-widget.component.scss create mode 100644 libs/workspace/dashboard/ui/src/lib/widgets/continue-watching-widget.component.ts create mode 100644 libs/workspace/dashboard/ui/src/lib/widgets/dashboard-activity-items.component.html create mode 100644 libs/workspace/dashboard/ui/src/lib/widgets/dashboard-activity-items.component.scss create mode 100644 libs/workspace/dashboard/ui/src/lib/widgets/dashboard-activity-items.component.ts create mode 100644 libs/workspace/dashboard/ui/src/lib/widgets/dashboard-widget-shell.component.html create mode 100644 libs/workspace/dashboard/ui/src/lib/widgets/dashboard-widget-shell.component.scss create mode 100644 libs/workspace/dashboard/ui/src/lib/widgets/dashboard-widget-shell.component.ts create mode 100644 libs/workspace/dashboard/ui/src/lib/widgets/global-favorites-widget.component.html create mode 100644 libs/workspace/dashboard/ui/src/lib/widgets/global-favorites-widget.component.scss create mode 100644 libs/workspace/dashboard/ui/src/lib/widgets/global-favorites-widget.component.ts create mode 100644 libs/workspace/dashboard/ui/src/lib/widgets/recent-sources-widget.component.html create mode 100644 libs/workspace/dashboard/ui/src/lib/widgets/recent-sources-widget.component.scss create mode 100644 libs/workspace/dashboard/ui/src/lib/widgets/recent-sources-widget.component.ts create mode 100644 libs/workspace/dashboard/ui/src/lib/widgets/recently-watched-widget.component.html create mode 100644 libs/workspace/dashboard/ui/src/lib/widgets/recently-watched-widget.component.scss create mode 100644 libs/workspace/dashboard/ui/src/lib/widgets/recently-watched-widget.component.ts create mode 100644 libs/workspace/dashboard/ui/src/lib/widgets/source-stats-widget.component.html create mode 100644 libs/workspace/dashboard/ui/src/lib/widgets/source-stats-widget.component.scss create mode 100644 libs/workspace/dashboard/ui/src/lib/widgets/source-stats-widget.component.ts create mode 100644 libs/workspace/dashboard/ui/tsconfig.json create mode 100644 libs/workspace/dashboard/ui/tsconfig.lib.json diff --git a/docs/architecture/workspace-dashboard.md b/docs/architecture/workspace-dashboard.md new file mode 100644 index 000000000..7dd98c6dc --- /dev/null +++ b/docs/architecture/workspace-dashboard.md @@ -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; +} + +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. diff --git a/libs/workspace/dashboard/data-access/README.md b/libs/workspace/dashboard/data-access/README.md new file mode 100644 index 000000000..33ce105cc --- /dev/null +++ b/libs/workspace/dashboard/data-access/README.md @@ -0,0 +1,3 @@ +# data-access + +This library was generated with [Nx](https://nx.dev). diff --git a/libs/workspace/dashboard/data-access/eslint.config.mjs b/libs/workspace/dashboard/data-access/eslint.config.mjs new file mode 100644 index 000000000..51f77a345 --- /dev/null +++ b/libs/workspace/dashboard/data-access/eslint.config.mjs @@ -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: {}, + }, +]; diff --git a/libs/workspace/dashboard/data-access/jest.config.ts b/libs/workspace/dashboard/data-access/jest.config.ts new file mode 100644 index 000000000..58d893656 --- /dev/null +++ b/libs/workspace/dashboard/data-access/jest.config.ts @@ -0,0 +1,22 @@ +export default { + displayName: 'workspace-dashboard-data-access', + preset: '../../../../jest.preset.js', + setupFilesAfterEnv: ['/src/test-setup.ts'], + coverageDirectory: '../../../../coverage/libs/workspace/dashboard/data-access', + transform: { + '^.+\\.(ts|mjs|js|html)$': [ + 'jest-preset-angular', + { + tsconfig: '/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'], +}; diff --git a/libs/workspace/dashboard/data-access/project.json b/libs/workspace/dashboard/data-access/project.json new file mode 100644 index 000000000..550cc44cd --- /dev/null +++ b/libs/workspace/dashboard/data-access/project.json @@ -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" + } + } +} diff --git a/libs/workspace/dashboard/data-access/src/index.ts b/libs/workspace/dashboard/data-access/src/index.ts new file mode 100644 index 000000000..cce55ccf4 --- /dev/null +++ b/libs/workspace/dashboard/data-access/src/index.ts @@ -0,0 +1,3 @@ +export * from './lib/dashboard-widget.model'; +export * from './lib/dashboard-layout.service'; +export * from './lib/dashboard-data.service'; diff --git a/libs/workspace/dashboard/data-access/src/lib/dashboard-data.service.spec.ts b/libs/workspace/dashboard/data-access/src/lib/dashboard-data.service.spec.ts new file mode 100644 index 000000000..04d2f3c47 --- /dev/null +++ b/libs/workspace/dashboard/data-access/src/lib/dashboard-data.service.spec.ts @@ -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([ + { + _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); + }); +}); diff --git a/libs/workspace/dashboard/data-access/src/lib/dashboard-data.service.ts b/libs/workspace/dashboard/data-access/src/lib/dashboard-data.service.ts new file mode 100644 index 000000000..677707bcd --- /dev/null +++ b/libs/workspace/dashboard/data-access/src/lib/dashboard-data.service.ts @@ -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; +} + +@Injectable({ providedIn: 'root' }) +export class DashboardDataService { + private readonly store = inject(Store); + private readonly dbService = inject(DatabaseService); + + private readonly xtreamGlobalRecentItems = signal([]); + private readonly xtreamGlobalFavorites = signal( + [] + ); + readonly playlists = this.store.selectSignal(selectAllPlaylistsMeta); + readonly activePlaylist = this.store.selectSignal(selectActivePlaylist); + + readonly stalkerGlobalRecentItems = computed(() => + this.buildStalkerGlobalRecentItems(this.playlists()) + ); + + readonly globalRecentItems = computed(() => + [...this.xtreamGlobalRecentItems(), ...this.stalkerGlobalRecentItems()] + .sort( + (a, b) => + this.toDateTimestamp(b.viewed_at) - + this.toDateTimestamp(a.viewed_at) + ) + .slice(0, 200) + ); + + readonly stalkerGlobalFavorites = computed(() => + 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 { + 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 { + 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 | 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 | 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((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; + 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((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), + 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; + } +} diff --git a/libs/workspace/dashboard/data-access/src/lib/dashboard-layout.service.spec.ts b/libs/workspace/dashboard/data-access/src/lib/dashboard-layout.service.spec.ts new file mode 100644 index 000000000..b84e09396 --- /dev/null +++ b/libs/workspace/dashboard/data-access/src/lib/dashboard-layout.service.spec.ts @@ -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); + }); +}); diff --git a/libs/workspace/dashboard/data-access/src/lib/dashboard-layout.service.ts b/libs/workspace/dashboard/data-access/src/lib/dashboard-layout.service.ts new file mode 100644 index 000000000..a3bfc41e4 --- /dev/null +++ b/libs/workspace/dashboard/data-access/src/lib/dashboard-layout.service.ts @@ -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(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; + 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 | 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'; + } + +} diff --git a/libs/workspace/dashboard/data-access/src/lib/dashboard-widget.model.ts b/libs/workspace/dashboard/data-access/src/lib/dashboard-widget.model.ts new file mode 100644 index 000000000..ca4db4cde --- /dev/null +++ b/libs/workspace/dashboard/data-access/src/lib/dashboard-widget.model.ts @@ -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(), + }, + }, +]; diff --git a/libs/workspace/dashboard/data-access/src/test-setup.ts b/libs/workspace/dashboard/data-access/src/test-setup.ts new file mode 100644 index 000000000..0d88648e3 --- /dev/null +++ b/libs/workspace/dashboard/data-access/src/test-setup.ts @@ -0,0 +1,6 @@ +import { setupZoneTestEnv } from 'jest-preset-angular/setup-env/zone'; + +setupZoneTestEnv({ + errorOnUnknownElements: true, + errorOnUnknownProperties: true, +}); diff --git a/libs/workspace/dashboard/data-access/tsconfig.json b/libs/workspace/dashboard/data-access/tsconfig.json new file mode 100644 index 000000000..dd63ae991 --- /dev/null +++ b/libs/workspace/dashboard/data-access/tsconfig.json @@ -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" + } + ] +} diff --git a/libs/workspace/dashboard/data-access/tsconfig.lib.json b/libs/workspace/dashboard/data-access/tsconfig.lib.json new file mode 100644 index 000000000..7fba372dc --- /dev/null +++ b/libs/workspace/dashboard/data-access/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"] +} diff --git a/libs/workspace/dashboard/data-access/tsconfig.spec.json b/libs/workspace/dashboard/data-access/tsconfig.spec.json new file mode 100644 index 000000000..e7d59a0a6 --- /dev/null +++ b/libs/workspace/dashboard/data-access/tsconfig.spec.json @@ -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" + ] +} diff --git a/libs/workspace/dashboard/feature/README.md b/libs/workspace/dashboard/feature/README.md new file mode 100644 index 000000000..1ebf51e65 --- /dev/null +++ b/libs/workspace/dashboard/feature/README.md @@ -0,0 +1,3 @@ +# feature + +This library was generated with [Nx](https://nx.dev). diff --git a/libs/workspace/dashboard/feature/eslint.config.mjs b/libs/workspace/dashboard/feature/eslint.config.mjs new file mode 100644 index 000000000..51f77a345 --- /dev/null +++ b/libs/workspace/dashboard/feature/eslint.config.mjs @@ -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: {}, + }, +]; diff --git a/libs/workspace/dashboard/feature/project.json b/libs/workspace/dashboard/feature/project.json new file mode 100644 index 000000000..a31f59117 --- /dev/null +++ b/libs/workspace/dashboard/feature/project.json @@ -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" + } + } +} diff --git a/libs/workspace/dashboard/feature/src/index.ts b/libs/workspace/dashboard/feature/src/index.ts new file mode 100644 index 000000000..1826918a5 --- /dev/null +++ b/libs/workspace/dashboard/feature/src/index.ts @@ -0,0 +1 @@ +export * from './lib/workspace-dashboard.component'; diff --git a/libs/workspace/dashboard/feature/src/lib/workspace-dashboard.component.html b/libs/workspace/dashboard/feature/src/lib/workspace-dashboard.component.html new file mode 100644 index 000000000..69ae22158 --- /dev/null +++ b/libs/workspace/dashboard/feature/src/lib/workspace-dashboard.component.html @@ -0,0 +1,188 @@ +
+
+
+

Workspace Dashboard

+

Control Center

+

+ Keep your daily sources in one place. In customize mode, edit directly + on the canvas, then use the sidebar for visibility and scope. +

+
+
+ +
+

+ @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. + } +

+
+ @if (editMode()) { + + } + +
+
+ +
+
+
+ @for (widget of visibleWidgets(); track widget.id) { +
+ @if (editMode()) { +
+ +

{{ widget.title }}

+ + @for (size of sizeOptions; track size.value) { + + {{ size.label }} + + } + + +
+ } + +
+ } @empty { +
+

No visible widgets

+

Enable at least one widget from the sidebar.

+
+ } +
+
+ + @if (editMode()) { + + } +
+
diff --git a/libs/workspace/dashboard/feature/src/lib/workspace-dashboard.component.scss b/libs/workspace/dashboard/feature/src/lib/workspace-dashboard.component.scss new file mode 100644 index 000000000..e4f71dc78 --- /dev/null +++ b/libs/workspace/dashboard/feature/src/lib/workspace-dashboard.component.scss @@ -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; + } +} diff --git a/libs/workspace/dashboard/feature/src/lib/workspace-dashboard.component.ts b/libs/workspace/dashboard/feature/src/lib/workspace-dashboard.component.ts new file mode 100644 index 000000000..b8b6c1e30 --- /dev/null +++ b/libs/workspace/dashboard/feature/src/lib/workspace-dashboard.component.ts @@ -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): 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'; + } +} diff --git a/libs/workspace/dashboard/feature/tsconfig.json b/libs/workspace/dashboard/feature/tsconfig.json new file mode 100644 index 000000000..7d33479d8 --- /dev/null +++ b/libs/workspace/dashboard/feature/tsconfig.json @@ -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" + } + ] +} diff --git a/libs/workspace/dashboard/feature/tsconfig.lib.json b/libs/workspace/dashboard/feature/tsconfig.lib.json new file mode 100644 index 000000000..7fba372dc --- /dev/null +++ b/libs/workspace/dashboard/feature/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"] +} diff --git a/libs/workspace/dashboard/ui/README.md b/libs/workspace/dashboard/ui/README.md new file mode 100644 index 000000000..5975fa4aa --- /dev/null +++ b/libs/workspace/dashboard/ui/README.md @@ -0,0 +1,3 @@ +# ui + +This library was generated with [Nx](https://nx.dev). diff --git a/libs/workspace/dashboard/ui/eslint.config.mjs b/libs/workspace/dashboard/ui/eslint.config.mjs new file mode 100644 index 000000000..51f77a345 --- /dev/null +++ b/libs/workspace/dashboard/ui/eslint.config.mjs @@ -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: {}, + }, +]; diff --git a/libs/workspace/dashboard/ui/project.json b/libs/workspace/dashboard/ui/project.json new file mode 100644 index 000000000..6964ed157 --- /dev/null +++ b/libs/workspace/dashboard/ui/project.json @@ -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" + } + } +} diff --git a/libs/workspace/dashboard/ui/src/index.ts b/libs/workspace/dashboard/ui/src/index.ts new file mode 100644 index 000000000..22586f337 --- /dev/null +++ b/libs/workspace/dashboard/ui/src/index.ts @@ -0,0 +1 @@ +export * from './lib/dashboard-widget-host.component'; diff --git a/libs/workspace/dashboard/ui/src/lib/dashboard-widget-host.component.html b/libs/workspace/dashboard/ui/src/lib/dashboard-widget-host.component.html new file mode 100644 index 000000000..afa84de4f --- /dev/null +++ b/libs/workspace/dashboard/ui/src/lib/dashboard-widget-host.component.html @@ -0,0 +1,23 @@ +@switch (widget().type) { + @case ('recent-sources') { + + } + @case ('source-stats') { + + } + @case ('continue-watching') { + + } + @case ('recently-watched') { + + } + @case ('global-favorites') { + + } + @default { +
+

Unsupported widget

+

This widget type is not available in this build.

+
+ } +} diff --git a/libs/workspace/dashboard/ui/src/lib/dashboard-widget-host.component.scss b/libs/workspace/dashboard/ui/src/lib/dashboard-widget-host.component.scss new file mode 100644 index 000000000..fd8d4cb8c --- /dev/null +++ b/libs/workspace/dashboard/ui/src/lib/dashboard-widget-host.component.scss @@ -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; + } +} diff --git a/libs/workspace/dashboard/ui/src/lib/dashboard-widget-host.component.ts b/libs/workspace/dashboard/ui/src/lib/dashboard-widget-host.component.ts new file mode 100644 index 000000000..031b35184 --- /dev/null +++ b/libs/workspace/dashboard/ui/src/lib/dashboard-widget-host.component.ts @@ -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(); +} diff --git a/libs/workspace/dashboard/ui/src/lib/widgets/continue-watching-widget.component.html b/libs/workspace/dashboard/ui/src/lib/widgets/continue-watching-widget.component.html new file mode 100644 index 000000000..efcc2f4d2 --- /dev/null +++ b/libs/workspace/dashboard/ui/src/lib/widgets/continue-watching-widget.component.html @@ -0,0 +1,48 @@ + + @if (data.activePlaylist(); as active) { + + } @else { + + } + + @if (data.quickRecent().length > 0) { +
+

Recently used

+ @for (item of data.quickRecent(); track item._id) { + + + {{ item.title || item.filename || 'Untitled source' }} + + + {{ data.getPlaylistProvider(item) }} + + + } +
+ } +
diff --git a/libs/workspace/dashboard/ui/src/lib/widgets/continue-watching-widget.component.scss b/libs/workspace/dashboard/ui/src/lib/widgets/continue-watching-widget.component.scss new file mode 100644 index 000000000..e7bab9aaf --- /dev/null +++ b/libs/workspace/dashboard/ui/src/lib/widgets/continue-watching-widget.component.scss @@ -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; +} diff --git a/libs/workspace/dashboard/ui/src/lib/widgets/continue-watching-widget.component.ts b/libs/workspace/dashboard/ui/src/lib/widgets/continue-watching-widget.component.ts new file mode 100644 index 000000000..656cb690e --- /dev/null +++ b/libs/workspace/dashboard/ui/src/lib/widgets/continue-watching-widget.component.ts @@ -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); +} diff --git a/libs/workspace/dashboard/ui/src/lib/widgets/dashboard-activity-items.component.html b/libs/workspace/dashboard/ui/src/lib/widgets/dashboard-activity-items.component.html new file mode 100644 index 000000000..8aed4a658 --- /dev/null +++ b/libs/workspace/dashboard/ui/src/lib/widgets/dashboard-activity-items.component.html @@ -0,0 +1,59 @@ +@if (items().length > 0) { + +} @else { +

{{ emptyLabel() }}

+} diff --git a/libs/workspace/dashboard/ui/src/lib/widgets/dashboard-activity-items.component.scss b/libs/workspace/dashboard/ui/src/lib/widgets/dashboard-activity-items.component.scss new file mode 100644 index 000000000..b3c75a8c9 --- /dev/null +++ b/libs/workspace/dashboard/ui/src/lib/widgets/dashboard-activity-items.component.scss @@ -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)); + } + +} diff --git a/libs/workspace/dashboard/ui/src/lib/widgets/dashboard-activity-items.component.ts b/libs/workspace/dashboard/ui/src/lib/widgets/dashboard-activity-items.component.ts new file mode 100644 index 000000000..c60bf71e6 --- /dev/null +++ b/libs/workspace/dashboard/ui/src/lib/widgets/dashboard-activity-items.component.ts @@ -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; +} + +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(); + + readonly items = input.required(); + readonly emptyLabel = input('No items to show.'); + readonly viewMode = input('list'); + + readonly failedImages = signal>({}); + + 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(); + } + } +} diff --git a/libs/workspace/dashboard/ui/src/lib/widgets/dashboard-widget-shell.component.html b/libs/workspace/dashboard/ui/src/lib/widgets/dashboard-widget-shell.component.html new file mode 100644 index 000000000..f8729dbdb --- /dev/null +++ b/libs/workspace/dashboard/ui/src/lib/widgets/dashboard-widget-shell.component.html @@ -0,0 +1,14 @@ + +
+
+

{{ eyebrow() }}

+

{{ title() }}

+
+
+ +
+
+
+ +
+
diff --git a/libs/workspace/dashboard/ui/src/lib/widgets/dashboard-widget-shell.component.scss b/libs/workspace/dashboard/ui/src/lib/widgets/dashboard-widget-shell.component.scss new file mode 100644 index 000000000..eb9f6e55c --- /dev/null +++ b/libs/workspace/dashboard/ui/src/lib/widgets/dashboard-widget-shell.component.scss @@ -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; +} diff --git a/libs/workspace/dashboard/ui/src/lib/widgets/dashboard-widget-shell.component.ts b/libs/workspace/dashboard/ui/src/lib/widgets/dashboard-widget-shell.component.ts new file mode 100644 index 000000000..9e3855d9f --- /dev/null +++ b/libs/workspace/dashboard/ui/src/lib/widgets/dashboard-widget-shell.component.ts @@ -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(); + readonly title = input.required(); +} diff --git a/libs/workspace/dashboard/ui/src/lib/widgets/global-favorites-widget.component.html b/libs/workspace/dashboard/ui/src/lib/widgets/global-favorites-widget.component.html new file mode 100644 index 000000000..26d07d867 --- /dev/null +++ b/libs/workspace/dashboard/ui/src/lib/widgets/global-favorites-widget.component.html @@ -0,0 +1,68 @@ + + + + + + + + + + + + + view_list + + + grid_view + + + + + + diff --git a/libs/workspace/dashboard/ui/src/lib/widgets/global-favorites-widget.component.scss b/libs/workspace/dashboard/ui/src/lib/widgets/global-favorites-widget.component.scss new file mode 100644 index 000000000..7e22e455f --- /dev/null +++ b/libs/workspace/dashboard/ui/src/lib/widgets/global-favorites-widget.component.scss @@ -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); +} diff --git a/libs/workspace/dashboard/ui/src/lib/widgets/global-favorites-widget.component.ts b/libs/workspace/dashboard/ui/src/lib/widgets/global-favorites-widget.component.ts new file mode 100644 index 000000000..bfa1de360 --- /dev/null +++ b/libs/workspace/dashboard/ui/src/lib/widgets/global-favorites-widget.component.ts @@ -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(); + readonly data = inject(DashboardDataService); + readonly selectedKind = signal( + this.readStoredKind() ?? 'all' + ); + readonly viewMode = signal( + 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(() => + 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', + }); + } +} diff --git a/libs/workspace/dashboard/ui/src/lib/widgets/recent-sources-widget.component.html b/libs/workspace/dashboard/ui/src/lib/widgets/recent-sources-widget.component.html new file mode 100644 index 000000000..71607f26d --- /dev/null +++ b/libs/workspace/dashboard/ui/src/lib/widgets/recent-sources-widget.component.html @@ -0,0 +1,28 @@ + + Manage all + @if (data.recentPlaylists().length > 0) { + + } @else { + + } + diff --git a/libs/workspace/dashboard/ui/src/lib/widgets/recent-sources-widget.component.scss b/libs/workspace/dashboard/ui/src/lib/widgets/recent-sources-widget.component.scss new file mode 100644 index 000000000..d5109e956 --- /dev/null +++ b/libs/workspace/dashboard/ui/src/lib/widgets/recent-sources-widget.component.scss @@ -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; + } +} diff --git a/libs/workspace/dashboard/ui/src/lib/widgets/recent-sources-widget.component.ts b/libs/workspace/dashboard/ui/src/lib/widgets/recent-sources-widget.component.ts new file mode 100644 index 000000000..6212a185a --- /dev/null +++ b/libs/workspace/dashboard/ui/src/lib/widgets/recent-sources-widget.component.ts @@ -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); +} diff --git a/libs/workspace/dashboard/ui/src/lib/widgets/recently-watched-widget.component.html b/libs/workspace/dashboard/ui/src/lib/widgets/recently-watched-widget.component.html new file mode 100644 index 000000000..a55d803b1 --- /dev/null +++ b/libs/workspace/dashboard/ui/src/lib/widgets/recently-watched-widget.component.html @@ -0,0 +1,68 @@ + + + + + + + + + + + + + view_list + + + grid_view + + + + + + diff --git a/libs/workspace/dashboard/ui/src/lib/widgets/recently-watched-widget.component.scss b/libs/workspace/dashboard/ui/src/lib/widgets/recently-watched-widget.component.scss new file mode 100644 index 000000000..7e22e455f --- /dev/null +++ b/libs/workspace/dashboard/ui/src/lib/widgets/recently-watched-widget.component.scss @@ -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); +} diff --git a/libs/workspace/dashboard/ui/src/lib/widgets/recently-watched-widget.component.ts b/libs/workspace/dashboard/ui/src/lib/widgets/recently-watched-widget.component.ts new file mode 100644 index 000000000..5507ad49c --- /dev/null +++ b/libs/workspace/dashboard/ui/src/lib/widgets/recently-watched-widget.component.ts @@ -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(); + readonly data = inject(DashboardDataService); + readonly selectedKind = signal( + this.readStoredKind() ?? 'all' + ); + readonly viewMode = signal( + 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(() => + 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', + }); + } +} diff --git a/libs/workspace/dashboard/ui/src/lib/widgets/source-stats-widget.component.html b/libs/workspace/dashboard/ui/src/lib/widgets/source-stats-widget.component.html new file mode 100644 index 000000000..3231b81d7 --- /dev/null +++ b/libs/workspace/dashboard/ui/src/lib/widgets/source-stats-widget.component.html @@ -0,0 +1,20 @@ + +
+
+

Total

+

{{ data.stats().total }}

+
+
+

Xtream

+

{{ data.stats().xtream }}

+
+
+

Stalker

+

{{ data.stats().stalker }}

+
+
+

M3U

+

{{ data.stats().m3u }}

+
+
+
diff --git a/libs/workspace/dashboard/ui/src/lib/widgets/source-stats-widget.component.scss b/libs/workspace/dashboard/ui/src/lib/widgets/source-stats-widget.component.scss new file mode 100644 index 000000000..2cd569edf --- /dev/null +++ b/libs/workspace/dashboard/ui/src/lib/widgets/source-stats-widget.component.scss @@ -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; +} diff --git a/libs/workspace/dashboard/ui/src/lib/widgets/source-stats-widget.component.ts b/libs/workspace/dashboard/ui/src/lib/widgets/source-stats-widget.component.ts new file mode 100644 index 000000000..b4b2f9508 --- /dev/null +++ b/libs/workspace/dashboard/ui/src/lib/widgets/source-stats-widget.component.ts @@ -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); +} diff --git a/libs/workspace/dashboard/ui/tsconfig.json b/libs/workspace/dashboard/ui/tsconfig.json new file mode 100644 index 000000000..7d33479d8 --- /dev/null +++ b/libs/workspace/dashboard/ui/tsconfig.json @@ -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" + } + ] +} diff --git a/libs/workspace/dashboard/ui/tsconfig.lib.json b/libs/workspace/dashboard/ui/tsconfig.lib.json new file mode 100644 index 000000000..7fba372dc --- /dev/null +++ b/libs/workspace/dashboard/ui/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"] +}