mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-08 17:06:15 -08:00
feat: remove home component, and update workspace UI and routing
This commit is contained in:
1 parent
20485972c3
commit
129848c4d4
21 files changed
+362
-287
No files matched your search
@@ -72,6 +72,14 @@ export const routes: Routes = [
|
||||
(c) => c.VideoPlayerComponent
|
||||
),
|
||||
},
|
||||
{
|
||||
path: 'downloads',
|
||||
data: { layout: 'workspace' },
|
||||
loadComponent: () =>
|
||||
import('./xtream-electron/downloads/downloads.component').then(
|
||||
(c) => c.DownloadsComponent
|
||||
),
|
||||
},
|
||||
...withWorkspaceLayout(xtreamRoutes),
|
||||
...withWorkspaceLayout(stalkerRoutes),
|
||||
{
|
||||
@@ -84,51 +92,11 @@ export const routes: Routes = [
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
path: 'home',
|
||||
loadComponent: () =>
|
||||
import('./home/home.component').then((c) => c.HomeComponent),
|
||||
},
|
||||
{
|
||||
path: 'playlists',
|
||||
loadComponent: () =>
|
||||
import('./home/video-player/video-player.component').then(
|
||||
(c) => c.VideoPlayerComponent
|
||||
),
|
||||
},
|
||||
{
|
||||
path: 'iptv',
|
||||
loadComponent: () =>
|
||||
import('./home/video-player/video-player.component').then(
|
||||
(c) => c.VideoPlayerComponent
|
||||
),
|
||||
},
|
||||
{
|
||||
path: 'playlists/:id',
|
||||
pathMatch: 'full',
|
||||
redirectTo: 'playlists/:id/all',
|
||||
},
|
||||
{
|
||||
path: 'playlists/:id/:view',
|
||||
loadComponent: () =>
|
||||
import('./home/video-player/video-player.component').then(
|
||||
(c) => c.VideoPlayerComponent
|
||||
),
|
||||
},
|
||||
{
|
||||
path: 'settings',
|
||||
redirectTo: '/workspace/settings',
|
||||
pathMatch: 'full',
|
||||
},
|
||||
...xtreamRoutes,
|
||||
{
|
||||
path: 'portals/:id',
|
||||
loadComponent: () =>
|
||||
import('./stalker/stalker-main-container.component').then(
|
||||
(c) => c.StalkerMainContainerComponent
|
||||
),
|
||||
},
|
||||
...stalkerRoutes,
|
||||
{
|
||||
path: '**',
|
||||
redirectTo: '',
|
||||
|
||||
@@ -1,12 +0,0 @@
|
||||
<app-header
|
||||
[title]="'HOME.TITLE' | translate"
|
||||
[subtitle]="'HOME.SUBTITLE' | translate"
|
||||
(searchQuery)="onSearchQueryChange($event)"
|
||||
(globalSearchClicked)="openGlobalSearch()"
|
||||
(globalRecentClicked)="openGlobalRecent()"
|
||||
/>
|
||||
<app-recent-playlists
|
||||
class="recent-playlists"
|
||||
[searchQueryInput]="searchQuery()"
|
||||
(addPlaylistClicked)="onAddPlaylist($event)"
|
||||
/>
|
||||
@@ -1,4 +0,0 @@
|
||||
.recent-playlists {
|
||||
height: calc(100vh - 71px);
|
||||
overflow: auto;
|
||||
}
|
||||
@@ -1,77 +0,0 @@
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
||||
import { MatDialogModule } from '@angular/material/dialog';
|
||||
import { MatProgressBarModule } from '@angular/material/progress-bar';
|
||||
import { MatSnackBar, MatSnackBarModule } from '@angular/material/snack-bar';
|
||||
import { ActivatedRoute, RouterModule } from '@angular/router';
|
||||
import { Actions } from '@ngrx/effects';
|
||||
import { provideMockActions } from '@ngrx/effects/testing';
|
||||
import { MockStore, provideMockStore } from '@ngrx/store/testing';
|
||||
import { TranslateModule } from '@ngx-translate/core';
|
||||
import { MockComponent, MockModule, MockProvider } from 'ng-mocks';
|
||||
import { Observable } from 'rxjs';
|
||||
import { DataService } from '../../../../../libs/services/src/lib/data.service';
|
||||
import { RecentPlaylistsComponent } from '../../../../../libs/ui/components/src/lib/recent-playlists/recent-playlists.component';
|
||||
import { ElectronServiceStub } from '../services/electron.service.stub';
|
||||
import { HomeComponent } from './home.component';
|
||||
|
||||
describe('HomeComponent', () => {
|
||||
let component: HomeComponent;
|
||||
let fixture: ComponentFixture<HomeComponent>;
|
||||
let electronService: DataService;
|
||||
let mockStore: MockStore;
|
||||
const actions$ = new Observable<Actions>();
|
||||
|
||||
beforeEach(() => {
|
||||
TestBed.configureTestingModule({
|
||||
imports: [
|
||||
HomeComponent,
|
||||
CommonModule,
|
||||
MockComponent(RecentPlaylistsComponent),
|
||||
MockModule(MatProgressBarModule),
|
||||
MockModule(MatSnackBarModule),
|
||||
TranslateModule.forRoot(),
|
||||
MockModule(RouterModule),
|
||||
MockModule(MatDialogModule),
|
||||
],
|
||||
providers: [
|
||||
MockProvider(ActivatedRoute, {
|
||||
snapshot: { component: '' } as any,
|
||||
}),
|
||||
MockProvider(MatSnackBar),
|
||||
{ provide: DataService, useClass: ElectronServiceStub },
|
||||
provideMockStore(),
|
||||
provideMockActions(actions$),
|
||||
],
|
||||
}).compileComponents();
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
fixture = TestBed.createComponent(HomeComponent);
|
||||
component = fixture.componentInstance;
|
||||
electronService = TestBed.inject(DataService);
|
||||
|
||||
mockStore = TestBed.inject(MockStore);
|
||||
mockStore.setState({});
|
||||
});
|
||||
|
||||
it('should create', () => {
|
||||
expect(component).toBeTruthy();
|
||||
});
|
||||
|
||||
it('should set IPC event listeners', () => {
|
||||
jest.spyOn(electronService, 'listenOn');
|
||||
component.setRendererListeners();
|
||||
expect(electronService.listenOn).toHaveBeenCalledTimes(
|
||||
component.commandsList.length
|
||||
);
|
||||
});
|
||||
|
||||
it('should remove all ipc listeners on destroy', () => {
|
||||
jest.spyOn(electronService, 'removeAllListeners');
|
||||
component.ngOnDestroy();
|
||||
expect(electronService.removeAllListeners).toHaveBeenCalledTimes(
|
||||
component.commandsList.length
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -1,56 +0,0 @@
|
||||
import { Component, inject, signal } from '@angular/core';
|
||||
import { MatDialog } from '@angular/material/dialog';
|
||||
import { TranslatePipe } from '@ngx-translate/core';
|
||||
import { PlaylistType, RecentPlaylistsComponent } from 'components';
|
||||
import { AddPlaylistDialogComponent } from '../shared/components/add-playlist/add-playlist-dialog.component';
|
||||
import { HeaderComponent } from '../shared/components/header/header.component';
|
||||
import { GlobalRecentlyViewedComponent } from '../xtream-electron/recently-viewed/global-recently-viewed.component';
|
||||
import { GlobalSearchResultsComponent } from '../xtream-electron/search-results/global-search-results.component';
|
||||
|
||||
@Component({
|
||||
selector: 'app-home',
|
||||
templateUrl: './home.component.html',
|
||||
styleUrls: ['./home.component.scss'],
|
||||
imports: [HeaderComponent, RecentPlaylistsComponent, TranslatePipe],
|
||||
})
|
||||
export class HomeComponent {
|
||||
private readonly dialog = inject(MatDialog);
|
||||
|
||||
searchQuery = signal<string>('');
|
||||
|
||||
onSearchQueryChange(query: string): void {
|
||||
this.searchQuery.set(query);
|
||||
}
|
||||
|
||||
onAddPlaylist(playlistType: PlaylistType): void {
|
||||
this.dialog.open<AddPlaylistDialogComponent, { type: PlaylistType }>(
|
||||
AddPlaylistDialogComponent,
|
||||
{
|
||||
width: '600px',
|
||||
data: { type: playlistType },
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
openGlobalSearch(): void {
|
||||
this.dialog.open(GlobalSearchResultsComponent, {
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
maxWidth: '100%',
|
||||
panelClass: 'global-search-overlay',
|
||||
data: { isGlobalSearch: true },
|
||||
});
|
||||
}
|
||||
|
||||
openGlobalRecent(): void {
|
||||
this.dialog.open(GlobalRecentlyViewedComponent, {
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
maxWidth: '100%',
|
||||
panelClass: 'global-search-overlay',
|
||||
data: { isGlobal: true },
|
||||
hasBackdrop: true,
|
||||
disableClose: false,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -48,7 +48,6 @@ import {
|
||||
VideoPlayer,
|
||||
} from 'shared-interfaces';
|
||||
import { SettingsStore } from '../services/settings-store.service';
|
||||
import { HeaderComponent } from '../shared/components/header/header.component';
|
||||
import { SettingsContextService } from '../workspace/settings-context.service';
|
||||
import { SettingsService } from './../services/settings.service';
|
||||
|
||||
@@ -65,7 +64,6 @@ interface SettingsSection {
|
||||
imports: [
|
||||
CommonModule,
|
||||
FormsModule,
|
||||
HeaderComponent,
|
||||
MatButtonModule,
|
||||
MatCheckboxModule,
|
||||
MatDividerModule,
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import {
|
||||
Component,
|
||||
HostBinding,
|
||||
OnInit,
|
||||
inject,
|
||||
input,
|
||||
@@ -20,7 +19,6 @@ import { TranslateModule } from '@ngx-translate/core';
|
||||
import { DataService } from 'services';
|
||||
//import { shell } from 'electron';
|
||||
import { AddPlaylistMenuComponent, PlaylistType } from 'components';
|
||||
import { HomeComponent } from '../../../home/home.component';
|
||||
import { AboutDialogComponent } from '../about-dialog/about-dialog.component';
|
||||
import { AddPlaylistDialogComponent } from '../add-playlist/add-playlist-dialog.component';
|
||||
import { FilterSortMenuComponent } from '../filter-sort-menu/filter-sort-menu.component';
|
||||
@@ -44,10 +42,6 @@ import { FilterSortMenuComponent } from '../filter-sort-menu/filter-sort-menu.co
|
||||
],
|
||||
})
|
||||
export class HeaderComponent implements OnInit {
|
||||
@HostBinding('class.home-header') get isHomeHeader() {
|
||||
return this.isHome;
|
||||
}
|
||||
|
||||
private activatedRoute = inject(ActivatedRoute);
|
||||
private dialog = inject(MatDialog);
|
||||
private dataService = inject(DataService);
|
||||
@@ -66,11 +60,10 @@ export class HeaderComponent implements OnInit {
|
||||
readonly globalSearchClicked = output<void>();
|
||||
readonly globalRecentClicked = output<void>();
|
||||
|
||||
isHome = true;
|
||||
readonly isHome = false;
|
||||
|
||||
ngOnInit() {
|
||||
this.isHome =
|
||||
this.activatedRoute.snapshot.component.name === HomeComponent.name;
|
||||
// Initialization if needed
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
@import '../../nav-list.scss';
|
||||
@use '../../nav-list.scss';
|
||||
|
||||
:host {
|
||||
display: flex;
|
||||
|
||||
@@ -106,7 +106,7 @@
|
||||
<mat-icon>person</mat-icon>
|
||||
</button>
|
||||
}
|
||||
@if (downloadsContext()) {
|
||||
@if (isElectron) {
|
||||
<button
|
||||
type="button"
|
||||
class="header-shortcut"
|
||||
|
||||
@@ -17,7 +17,6 @@ import {
|
||||
NavigationEnd,
|
||||
Router,
|
||||
RouterLink,
|
||||
RouterLinkActive,
|
||||
RouterOutlet,
|
||||
} from '@angular/router';
|
||||
import { Store } from '@ngrx/store';
|
||||
@@ -79,7 +78,6 @@ const SEARCH_INPUT_DEBOUNCE_MS = 350;
|
||||
PlaylistSwitcherComponent,
|
||||
TranslatePipe,
|
||||
RouterLink,
|
||||
RouterLinkActive,
|
||||
RouterOutlet,
|
||||
WorkspaceContextPanelComponent,
|
||||
WorkspaceFavoritesContextPanelComponent,
|
||||
@@ -180,6 +178,9 @@ export class WorkspaceShellComponent {
|
||||
this.currentUrl()
|
||||
)
|
||||
);
|
||||
readonly isGlobalDownloadsRoute = computed(() =>
|
||||
/^\/workspace\/downloads(?:\/)?(?:\?.*)?$/.test(this.currentUrl())
|
||||
);
|
||||
readonly dashboardXtreamContext = computed<WorkspaceContext | null>(() => {
|
||||
if (!this.isDashboardRoute()) {
|
||||
return null;
|
||||
@@ -338,23 +339,10 @@ export class WorkspaceShellComponent {
|
||||
workspace: true,
|
||||
}).secondary.filter((link) => link.section !== 'downloads');
|
||||
});
|
||||
readonly downloadsContext = computed<WorkspaceContext | null>(() => {
|
||||
if (!this.isElectron) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const context = this.currentContext() ?? this.railContext();
|
||||
if (
|
||||
!context ||
|
||||
(context.provider !== 'xtreams' && context.provider !== 'stalker')
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return context;
|
||||
});
|
||||
readonly isDownloadsView = computed(
|
||||
() => this.currentSection() === 'downloads'
|
||||
() =>
|
||||
this.currentSection() === 'downloads' ||
|
||||
this.isGlobalDownloadsRoute()
|
||||
);
|
||||
readonly canOpenPlaylistInfo = computed(() =>
|
||||
Boolean(this.activePlaylist())
|
||||
@@ -366,20 +354,21 @@ export class WorkspaceShellComponent {
|
||||
() => {
|
||||
const context = this.currentContext();
|
||||
const section = this.currentSection();
|
||||
if (!context || !section) {
|
||||
return null;
|
||||
}
|
||||
const isGlobalDownloads = this.isGlobalDownloadsRoute();
|
||||
|
||||
if (
|
||||
(context.provider === 'xtreams' ||
|
||||
context.provider === 'stalker') &&
|
||||
section === 'downloads'
|
||||
isGlobalDownloads ||
|
||||
(context &&
|
||||
(context.provider === 'xtreams' ||
|
||||
context.provider === 'stalker') &&
|
||||
section === 'downloads')
|
||||
) {
|
||||
const playlistId = context?.playlistId;
|
||||
const hasClearable = this.downloadsService
|
||||
.downloads()
|
||||
.some(
|
||||
(item) =>
|
||||
item.playlistId === context.playlistId &&
|
||||
(!playlistId || item.playlistId === playlistId) &&
|
||||
(item.status === 'completed' ||
|
||||
item.status === 'failed' ||
|
||||
item.status === 'canceled')
|
||||
@@ -392,6 +381,10 @@ export class WorkspaceShellComponent {
|
||||
};
|
||||
}
|
||||
|
||||
if (!context || !section) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (context.provider === 'xtreams' && section === 'recent') {
|
||||
return {
|
||||
icon: 'delete_sweep',
|
||||
@@ -563,17 +556,22 @@ export class WorkspaceShellComponent {
|
||||
async runHeaderBulkAction(): Promise<void> {
|
||||
const context = this.currentContext();
|
||||
const section = this.currentSection();
|
||||
if (!context || !section) {
|
||||
const isGlobalDownloads = this.isGlobalDownloadsRoute();
|
||||
|
||||
if (
|
||||
isGlobalDownloads ||
|
||||
(context &&
|
||||
(context.provider === 'xtreams' ||
|
||||
context.provider === 'stalker') &&
|
||||
section === 'downloads')
|
||||
) {
|
||||
const playlistId = context?.playlistId;
|
||||
await this.downloadsService.clearCompleted(playlistId);
|
||||
await this.downloadsService.loadDownloads(playlistId);
|
||||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
(context.provider === 'xtreams' ||
|
||||
context.provider === 'stalker') &&
|
||||
section === 'downloads'
|
||||
) {
|
||||
await this.downloadsService.clearCompleted(context.playlistId);
|
||||
await this.downloadsService.loadDownloads(context.playlistId);
|
||||
if (!context || !section) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -593,17 +591,7 @@ export class WorkspaceShellComponent {
|
||||
}
|
||||
|
||||
openDownloadsShortcut(): void {
|
||||
const context = this.downloadsContext();
|
||||
if (!context) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.router.navigate([
|
||||
'/workspace',
|
||||
context.provider,
|
||||
context.playlistId,
|
||||
'downloads',
|
||||
]);
|
||||
this.router.navigate(['/workspace/downloads']);
|
||||
}
|
||||
|
||||
openPlaylistInfo(): void {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
@import '../../nav-list.scss';
|
||||
@use '../../nav-list.scss';
|
||||
|
||||
:host {
|
||||
display: flex;
|
||||
|
||||
@@ -1,14 +1,11 @@
|
||||
import { map } from 'rxjs';
|
||||
import { toSignal } from '@angular/core/rxjs-interop';
|
||||
import { Component, computed, inject } from '@angular/core';
|
||||
import { ActivatedRoute } from '@angular/router';
|
||||
import { toSignal } from '@angular/core/rxjs-interop';
|
||||
import { MatDialog } from '@angular/material/dialog';
|
||||
import { ActivatedRoute } from '@angular/router';
|
||||
import { Store } from '@ngrx/store';
|
||||
import {
|
||||
selectActiveTypeFilters,
|
||||
selectAllPlaylistsMeta,
|
||||
} from 'm3u-state';
|
||||
import { PlaylistType, RecentPlaylistsComponent } from 'components';
|
||||
import { selectActiveTypeFilters, selectAllPlaylistsMeta } from 'm3u-state';
|
||||
import { map } from 'rxjs';
|
||||
import { AddPlaylistDialogComponent } from '../shared/components/add-playlist/add-playlist-dialog.component';
|
||||
|
||||
@Component({
|
||||
@@ -25,7 +22,9 @@ export class WorkspaceSourcesComponent {
|
||||
private readonly activeTypeFilters = this.store.selectSignal(
|
||||
selectActiveTypeFilters
|
||||
);
|
||||
private readonly playlists = this.store.selectSignal(selectAllPlaylistsMeta);
|
||||
private readonly playlists = this.store.selectSignal(
|
||||
selectAllPlaylistsMeta
|
||||
);
|
||||
|
||||
readonly searchQuery = toSignal(
|
||||
this.route.queryParamMap.pipe(map((params) => params.get('q') ?? '')),
|
||||
@@ -72,7 +71,8 @@ export class WorkspaceSourcesComponent {
|
||||
|
||||
return isStalkerFilter || isXtreamFilter || isM3uFilter;
|
||||
})
|
||||
.filter((item) => item.title.toLowerCase().includes(query)).length;
|
||||
.filter((item) => (item.title || '').toLowerCase().includes(query))
|
||||
.length;
|
||||
});
|
||||
|
||||
readonly subtitle = computed(() => {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
@import '../../../nav-list.scss';
|
||||
@use '../../../nav-list.scss';
|
||||
|
||||
:host {
|
||||
height: 100%;
|
||||
|
||||
@@ -511,6 +511,7 @@
|
||||
"OPEN_SOURCE": "Open Source",
|
||||
"MANAGE_SOURCES": "Manage Sources",
|
||||
"NO_ACTIVE_SOURCE": "No active source selected yet.",
|
||||
"NO_ACTIVE_SOURCE_HINT": "Select a playlist from the sidebar to continue where you left off.",
|
||||
"ADD_SOURCE": "Add Source",
|
||||
"RECENTLY_USED": "Recently used",
|
||||
"GLOBAL_FAVORITES": "Global Favorites",
|
||||
@@ -523,13 +524,16 @@
|
||||
"LIST_VIEW": "List view",
|
||||
"GRID_VIEW": "Grid view",
|
||||
"NO_FAVORITES": "No favorites found for this filter.",
|
||||
"NO_FAVORITES_HINT": "You haven't added any channels, movies, or series to your global favorites yet. Use the star icon to favorite items.",
|
||||
"RECENT_SOURCES": "Recent Sources",
|
||||
"MANAGE_ALL": "Manage all",
|
||||
"NO_SOURCES": "No sources available yet.",
|
||||
"NO_SOURCES_HINT": "Your recently used or added playlists will show up here for quick access.",
|
||||
"OPEN_SOURCES": "Open Sources",
|
||||
"RECENTLY_WATCHED": "Recently Watched",
|
||||
"ACTIVITY": "Activity",
|
||||
"NO_HISTORY": "No items found for this filter.",
|
||||
"NO_HISTORY_HINT": "Your watching history will appear here once you start playing content from your playlists.",
|
||||
"SOURCE_STATS": "Source Statistics",
|
||||
"OVERVIEW": "Overview",
|
||||
"TOTAL": "Total",
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
@use '../../../libs/ui/components/src/lib/resizable/resizable.scss';
|
||||
|
||||
// ─── Tailwind CSS ─────────────────────────────────────────────────────────────
|
||||
// Preflight is disabled in tailwind.config.js to avoid conflicts with
|
||||
// Angular Material. Only components + utilities are loaded.
|
||||
@@ -7,7 +9,6 @@
|
||||
|
||||
// ─── Third-party ─────────────────────────────────────────────────────────────
|
||||
@import '@yangkghjh/videojs-aspect-ratio-panel/dist/videojs-aspect-ratio-panel.css';
|
||||
@import '../../../libs/ui/components/src/lib/resizable/resizable.scss';
|
||||
|
||||
// ─── Google Fonts ─────────────────────────────────────────────────────────────
|
||||
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap');
|
||||
|
||||
@@ -0,0 +1,253 @@
|
||||
# Workspace UI Refactor Summary
|
||||
|
||||
Date: 2026-02-22
|
||||
|
||||
## 1) What Was Refactored
|
||||
|
||||
The app moved from a setup-first flow (playlist management first, then provider-specific UI) to a **workspace shell** flow with a persistent app frame:
|
||||
|
||||
1. Fixed left navigation rail (global + context links).
|
||||
2. Persistent top header (playlist switcher, search, actions, favorites shortcut, sorting where relevant).
|
||||
3. Secondary left panel for:
|
||||
1. Sources filters on `/workspace/sources`.
|
||||
2. Provider category context for Xtream/Stalker content sections.
|
||||
4. Unified workspace routing with dashboard and sources as first-class views.
|
||||
|
||||
Core entrypoint:
|
||||
|
||||
1. `apps/web/src/app/workspace/workspace-shell.component.ts`
|
||||
2. `apps/web/src/app/workspace/workspace-shell.component.html`
|
||||
|
||||
Shared rail link model:
|
||||
|
||||
1. `apps/web/src/app/shared/navigation/portal-rail-links.ts`
|
||||
2. `apps/web/src/app/shared/navigation/portal-rail-links.component.ts`
|
||||
|
||||
## 2) New Dashboard (Current State)
|
||||
|
||||
The dashboard is now a real widget host with persisted layout and customization.
|
||||
|
||||
Key parts:
|
||||
|
||||
1. Widget model and defaults:
|
||||
1. `libs/workspace/dashboard/data-access/src/lib/dashboard-widget.model.ts`
|
||||
2. Persisted layout store with migration support:
|
||||
1. `libs/workspace/dashboard/data-access/src/lib/dashboard-layout.service.ts`
|
||||
3. Widget host:
|
||||
1. `libs/workspace/dashboard/ui/src/lib/dashboard-widget-host.component.ts`
|
||||
2. `libs/workspace/dashboard/ui/src/lib/dashboard-widget-host.component.html`
|
||||
4. Dashboard page (view + customize mode):
|
||||
1. `libs/workspace/dashboard/feature/src/lib/workspace-dashboard.component.ts`
|
||||
2. `libs/workspace/dashboard/feature/src/lib/workspace-dashboard.component.html`
|
||||
|
||||
Active widget set:
|
||||
|
||||
1. Recent Sources
|
||||
2. Source Statistics
|
||||
3. Continue Watching
|
||||
4. Recently Watched (global, scope-aware)
|
||||
5. Global Favorites (global, scope-aware)
|
||||
|
||||
Notes:
|
||||
|
||||
1. Widget scope settings (provider + playlist filtering) are implemented for scoped widgets.
|
||||
|
||||
## 2.1) Dashboard Idea and Widget Implementation
|
||||
|
||||
### Product idea
|
||||
|
||||
The dashboard is designed as a content-first operational home, not a source setup page.
|
||||
|
||||
Goals:
|
||||
|
||||
1. Show high-value content immediately after app start.
|
||||
2. Reduce context switching between Xtream, Stalker, and M3U flows.
|
||||
3. Keep widgets independent so one data failure does not break the page.
|
||||
4. Allow gradual customization without introducing heavy layout complexity too early.
|
||||
|
||||
Design principles:
|
||||
|
||||
1. Widgets are about user tasks (continue watching, favorites, recent activity), not provider internals.
|
||||
2. Each widget must have explicit loading/empty/error states.
|
||||
3. Widget settings should be persistent and reversible.
|
||||
4. Navigation from widget cards should deep-link directly to the right route and content context.
|
||||
|
||||
### Implementation shape
|
||||
|
||||
Core implementation files:
|
||||
|
||||
1. Dashboard page and orchestration:
|
||||
1. `libs/workspace/dashboard/feature/src/lib/workspace-dashboard.component.ts`
|
||||
2. `libs/workspace/dashboard/feature/src/lib/workspace-dashboard.component.html`
|
||||
2. Widget metadata contract:
|
||||
1. `libs/workspace/dashboard/data-access/src/lib/dashboard-widget.model.ts`
|
||||
3. Layout + settings persistence:
|
||||
1. `libs/workspace/dashboard/data-access/src/lib/dashboard-layout.service.ts`
|
||||
4. Widget rendering host:
|
||||
1. `libs/workspace/dashboard/ui/src/lib/dashboard-widget-host.component.ts`
|
||||
5. Data aggregation and widget inputs:
|
||||
1. `libs/workspace/dashboard/data-access/src/lib/dashboard-data.service.ts`
|
||||
6. Shared activity item renderer (used by multiple widgets):
|
||||
1. `libs/workspace/dashboard/ui/src/lib/widgets/dashboard-activity-items.component.ts`
|
||||
|
||||
### How widget rendering works
|
||||
|
||||
1. `workspace-dashboard` reads layout state (widget list, order, enabled state, settings).
|
||||
2. For each enabled widget, `dashboard-widget-host` maps `widget.type` to a concrete widget component.
|
||||
3. The host passes widget config (`scope`, `settings`, mode flags) into the widget.
|
||||
4. Widget requests data through `dashboard-data.service` and/or existing provider services/stores.
|
||||
5. Widget emits navigation actions, which are translated into direct route deep-links.
|
||||
|
||||
### How persistence works
|
||||
|
||||
Layout is stored as a versioned dashboard configuration:
|
||||
|
||||
1. Widget identity and type
|
||||
2. Visibility (`enabled`)
|
||||
3. Order
|
||||
4. Scope (`providers`, optional `playlistIds`)
|
||||
5. Widget-specific `settings`
|
||||
|
||||
On load:
|
||||
|
||||
1. Stored layout is validated against current widget registry/defaults.
|
||||
2. Missing or removed widgets are migrated safely.
|
||||
3. Invalid settings fall back to defaults, preventing dashboard hard-fail.
|
||||
|
||||
### Widget UX behavior implemented
|
||||
|
||||
1. `Recently Watched` and `Global Favorites`:
|
||||
1. Content-kind chips (channels/vod/series)
|
||||
2. List/grid toggle
|
||||
3. Shared item styles
|
||||
4. Deep-link navigation into details/views
|
||||
2. `Continue Watching`:
|
||||
1. Resume-oriented content block
|
||||
3. `Recent Sources`:
|
||||
1. Source activity with truncation-safe titles
|
||||
4. `Source Statistics`:
|
||||
1. Aggregated per-provider/source counters
|
||||
5. `Recent Activity` was intentionally removed from scope.
|
||||
|
||||
### How to add a new widget (current workflow)
|
||||
|
||||
1. Add a new `type` in `dashboard-widget.model.ts`.
|
||||
2. Implement widget component under `libs/workspace/dashboard/ui/src/lib/widgets/`.
|
||||
3. Register it in `dashboard-widget-host.component.ts` type mapping.
|
||||
4. Add default config in layout defaults/migration path (`dashboard-layout.service.ts`).
|
||||
5. Extend `dashboard-data.service.ts` or reuse existing feature services for data.
|
||||
6. Add explicit loading/empty/error UI states and deep-link behavior.
|
||||
|
||||
### Current boundaries
|
||||
|
||||
1. Advanced drag/resize grid and collision engine is not implemented yet.
|
||||
2. External adapters (RSS/sports/news) are intentionally deferred.
|
||||
|
||||
## 3) Route Generalization (Current Routing Topology)
|
||||
|
||||
Primary app routing now starts in workspace:
|
||||
|
||||
1. `/` -> `/workspace`
|
||||
2. `/workspace/dashboard`
|
||||
3. `/workspace/sources`
|
||||
4. `/workspace/playlists/:id` (M3U player in workspace frame)
|
||||
5. `/workspace/xtreams/:id/...` (workspace layout applied)
|
||||
6. `/workspace/stalker/:id/...` (workspace layout applied)
|
||||
|
||||
Defined in:
|
||||
|
||||
1. `apps/web/src/app/app.routes.ts`
|
||||
|
||||
Mechanism:
|
||||
|
||||
1. Existing provider route trees (`xtreamRoutes`, `stalkerRoutes`) are wrapped by `withWorkspaceLayout(...)` and receive `data.layout = 'workspace'`.
|
||||
2. Provider shells/components use `isWorkspaceLayout` to hide legacy local sidebars/header pieces.
|
||||
|
||||
## 4) Generalized UX Behaviors Added
|
||||
|
||||
1. Playlist switcher integrated into persistent header and used as cross-context navigator:
|
||||
1. `libs/ui/components/src/lib/playlist-switcher/playlist-switcher.component.ts`
|
||||
2. Header search unified, with debounced apply for route/store updates:
|
||||
1. `apps/web/src/app/workspace/workspace-shell.component.ts`
|
||||
3. Sources filtering moved to dedicated panel:
|
||||
1. `apps/web/src/app/workspace/workspace-sources-filters-panel.component.ts`
|
||||
4. Context categories moved to shared workspace panel:
|
||||
1. `apps/web/src/app/workspace/workspace-context-panel.component.ts`
|
||||
5. Dashboard activity items deep-link directly to content context/details; Xtream live entries auto-play on open.
|
||||
|
||||
## 5) Legacy/Old Parts Still Present
|
||||
|
||||
### A) Legacy routes kept for compatibility
|
||||
|
||||
In `apps/web/src/app/app.routes.ts`:
|
||||
|
||||
1. `/home`
|
||||
2. `/playlists`
|
||||
3. `/iptv`
|
||||
4. `/playlists/:id`
|
||||
5. Non-workspace provider routes:
|
||||
1. `/xtreams/:id/...`
|
||||
2. `/stalker/:id/...`
|
||||
6. Alias `/portals/:id` -> `StalkerMainContainerComponent`
|
||||
|
||||
### B) Legacy Xtream module tree kept as fallback
|
||||
|
||||
1. `apps/web/src/app/xtream/*`
|
||||
|
||||
This is still used by `legacyXtreamRouteFallback` when `!window.electron`.
|
||||
|
||||
### C) Dual-layout branching across provider components
|
||||
|
||||
Many provider components still have workspace/non-workspace conditional branches via `isWorkspaceLayout` (for sidebar/header/body behavior). This is intentional compatibility, but increases maintenance.
|
||||
|
||||
Examples:
|
||||
|
||||
1. `apps/web/src/app/xtream-electron/xtream-shell.component.html`
|
||||
2. `apps/web/src/app/stalker/stalker-shell.component.html`
|
||||
3. `apps/web/src/app/xtream-electron/xtream-main-container.component.html`
|
||||
4. `apps/web/src/app/stalker/stalker-main-container.component.html`
|
||||
5. `apps/web/src/app/home/video-player/video-player.component.html`
|
||||
|
||||
### D) Unused placeholder artifact
|
||||
|
||||
1. `apps/web/src/app/workspace/dashboard-placeholder.component.ts`
|
||||
2. `apps/web/src/app/workspace/dashboard-placeholder.component.html`
|
||||
3. `apps/web/src/app/workspace/dashboard-placeholder.component.scss`
|
||||
|
||||
These files are no longer routed.
|
||||
|
||||
## 6) Potential Removals (With Preconditions)
|
||||
|
||||
### Low risk (can remove soon)
|
||||
|
||||
1. Remove unused dashboard placeholder files (`dashboard-placeholder.*`).
|
||||
|
||||
### Medium risk (requires product decision)
|
||||
|
||||
1. Remove `/portals/:id` alias route if no external/deeplink dependency remains.
|
||||
2. Remove legacy entry routes (`/home`, `/iptv`, `/playlists`, `/playlists/:id`) if workspace-only navigation is desired.
|
||||
|
||||
### High risk (requires platform/support policy decision)
|
||||
|
||||
1. Remove legacy fallback module `apps/web/src/app/xtream/*` and `legacyXtreamRouteFallback` only if non-Electron fallback is officially dropped.
|
||||
2. Remove non-workspace provider routes (`/xtreams/:id/...`, `/stalker/:id/...`) only after verifying all deeplinks/bookmarks/integration paths have migrated to `/workspace/...`.
|
||||
|
||||
## 7) Recommended Cleanup Sequence
|
||||
|
||||
1. Remove unused placeholder files.
|
||||
2. Decide whether non-workspace URLs must remain public and supported.
|
||||
3. If workspace-only is approved:
|
||||
1. Deprecate legacy routes with redirects.
|
||||
2. Remove non-workspace route trees.
|
||||
3. Remove `isWorkspaceLayout` branches and simplify provider layouts.
|
||||
4. If non-Electron fallback is not required:
|
||||
1. Remove `legacyXtreamRouteFallback`.
|
||||
2. Remove `apps/web/src/app/xtream/*`.
|
||||
|
||||
## 8) Current Plan Items Still Open
|
||||
|
||||
From current plan scope, remaining work is primarily:
|
||||
|
||||
1. Widget settings UX improvements (scope presets, bulk provider toggles).
|
||||
2. Phase 3 dashboard advanced layout system (drag/resize/collision handling).
|
||||
3. Phase 4 external widget framework (RSS/scores/news adapters + reliability controls).
|
||||
@@ -289,11 +289,20 @@ export class PlaylistEffects {
|
||||
}),
|
||||
map((playlist: Playlist) => {
|
||||
if (playlist.serverUrl && !this.dataService.isElectron) {
|
||||
this.router.navigate(['/xtreams/', playlist._id]);
|
||||
this.router.navigate([
|
||||
'/workspace/xtreams/',
|
||||
playlist._id,
|
||||
]);
|
||||
} else if (playlist.macAddress) {
|
||||
this.router.navigate(['stalker', playlist._id]);
|
||||
this.router.navigate([
|
||||
'/workspace/stalker/',
|
||||
playlist._id,
|
||||
]);
|
||||
} else {
|
||||
this.router.navigate(['/playlists/', playlist._id]);
|
||||
this.router.navigate([
|
||||
'/workspace/playlists/',
|
||||
playlist._id,
|
||||
]);
|
||||
}
|
||||
return playlist;
|
||||
}),
|
||||
@@ -308,7 +317,10 @@ export class PlaylistEffects {
|
||||
password: playlist.password || '',
|
||||
type: 'xtream',
|
||||
});
|
||||
this.router.navigate(['/xtreams/', playlist._id]);
|
||||
this.router.navigate([
|
||||
'/workspace/xtreams/',
|
||||
playlist._id,
|
||||
]);
|
||||
} else if (
|
||||
playlist.macAddress &&
|
||||
this.dataService.isElectron
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 6px 10px;
|
||||
padding: 2px 4px;
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
|
||||
+21
-19
@@ -6,14 +6,14 @@
|
||||
{{ 'HOME.PLAYLISTS.INFO_DIALOG.PLAYLIST_DETAILS' | translate }}
|
||||
</h2>
|
||||
<mat-dialog-content class="mat-typography">
|
||||
<mat-form-field class="full-width">
|
||||
<mat-form-field class="w-full">
|
||||
<mat-label>{{
|
||||
'HOME.PLAYLISTS.INFO_DIALOG.TITLE' | translate
|
||||
}}</mat-label>
|
||||
<input formControlName="title" matInput />
|
||||
</mat-form-field>
|
||||
@if (playlist.portalUrl) {
|
||||
<mat-form-field class="full-width">
|
||||
<mat-form-field class="w-full">
|
||||
<mat-label>{{
|
||||
'HOME.PLAYLISTS.INFO_DIALOG.SERVER_URL' | translate
|
||||
}}</mat-label>
|
||||
@@ -21,7 +21,7 @@
|
||||
</mat-form-field>
|
||||
}
|
||||
@if (playlist.macAddress) {
|
||||
<mat-form-field class="full-width">
|
||||
<mat-form-field class="w-full">
|
||||
<mat-label>{{
|
||||
'HOME.PLAYLISTS.INFO_DIALOG.MAC_ADDRESS' | translate
|
||||
}}</mat-label>
|
||||
@@ -29,31 +29,31 @@
|
||||
</mat-form-field>
|
||||
}
|
||||
@if (playlist.portalUrl) {
|
||||
<mat-form-field class="full-width">
|
||||
<mat-form-field class="w-full">
|
||||
<mat-label>{{
|
||||
'HOME.STALKER_PORTAL.SERIAL_NUMBER' | translate
|
||||
}}</mat-label>
|
||||
<input matInput formControlName="stalkerSerialNumber" />
|
||||
</mat-form-field>
|
||||
<mat-form-field class="full-width">
|
||||
<mat-form-field class="w-full">
|
||||
<mat-label>{{
|
||||
'HOME.STALKER_PORTAL.DEVICE_ID_1' | translate
|
||||
}}</mat-label>
|
||||
<input matInput formControlName="stalkerDeviceId1" />
|
||||
</mat-form-field>
|
||||
<mat-form-field class="full-width">
|
||||
<mat-form-field class="w-full">
|
||||
<mat-label>{{
|
||||
'HOME.STALKER_PORTAL.DEVICE_ID_2' | translate
|
||||
}}</mat-label>
|
||||
<input matInput formControlName="stalkerDeviceId2" />
|
||||
</mat-form-field>
|
||||
<mat-form-field class="full-width">
|
||||
<mat-form-field class="w-full">
|
||||
<mat-label>{{
|
||||
'HOME.STALKER_PORTAL.SIGNATURE_1' | translate
|
||||
}}</mat-label>
|
||||
<input matInput formControlName="stalkerSignature1" />
|
||||
</mat-form-field>
|
||||
<mat-form-field class="full-width">
|
||||
<mat-form-field class="w-full">
|
||||
<mat-label>{{
|
||||
'HOME.STALKER_PORTAL.SIGNATURE_2' | translate
|
||||
}}</mat-label>
|
||||
@@ -61,7 +61,7 @@
|
||||
</mat-form-field>
|
||||
}
|
||||
@if (playlist.serverUrl) {
|
||||
<mat-form-field class="full-width">
|
||||
<mat-form-field class="w-full">
|
||||
<mat-label>{{
|
||||
'HOME.PLAYLISTS.INFO_DIALOG.SERVER_URL' | translate
|
||||
}}</mat-label>
|
||||
@@ -69,7 +69,7 @@
|
||||
</mat-form-field>
|
||||
}
|
||||
@if (playlist.username) {
|
||||
<mat-form-field class="full-width">
|
||||
<mat-form-field class="w-full">
|
||||
<mat-label>{{
|
||||
'HOME.PLAYLISTS.INFO_DIALOG.USERNAME' | translate
|
||||
}}</mat-label>
|
||||
@@ -77,7 +77,7 @@
|
||||
</mat-form-field>
|
||||
}
|
||||
@if (playlist.password) {
|
||||
<mat-form-field class="full-width">
|
||||
<mat-form-field class="w-full">
|
||||
<mat-label>{{
|
||||
'HOME.PLAYLISTS.INFO_DIALOG.PASSWORD' | translate
|
||||
}}</mat-label>
|
||||
@@ -85,7 +85,7 @@
|
||||
</mat-form-field>
|
||||
}
|
||||
@if (playlist.url) {
|
||||
<mat-form-field class="full-width">
|
||||
<mat-form-field class="w-full">
|
||||
<mat-label>{{
|
||||
'HOME.PLAYLISTS.INFO_DIALOG.FROM_URL' | translate
|
||||
}}</mat-label>
|
||||
@@ -95,20 +95,22 @@
|
||||
matSuffix
|
||||
type="button"
|
||||
(click)="copyUrl()"
|
||||
[matTooltip]="'HOME.PLAYLISTS.INFO_DIALOG.COPY_URL' | translate"
|
||||
[matTooltip]="
|
||||
'HOME.PLAYLISTS.INFO_DIALOG.COPY_URL' | translate
|
||||
"
|
||||
>
|
||||
<mat-icon>content_copy</mat-icon>
|
||||
</button>
|
||||
</mat-form-field>
|
||||
}
|
||||
@if (playlist.filename) {
|
||||
<mat-form-field class="full-width">
|
||||
<mat-form-field class="w-full">
|
||||
<mat-label>{{
|
||||
'HOME.PLAYLISTS.INFO_DIALOG.ORIGINAL_FILENAME' | translate
|
||||
}}</mat-label>
|
||||
<input matInput formControlName="filename" />
|
||||
</mat-form-field>
|
||||
<mat-form-field class="full-width">
|
||||
<mat-form-field class="w-full">
|
||||
<mat-label>{{
|
||||
'HOME.PLAYLISTS.INFO_DIALOG.IMPORT_DATE' | translate
|
||||
}}</mat-label>
|
||||
@@ -116,7 +118,7 @@
|
||||
</mat-form-field>
|
||||
}
|
||||
@if (playlist.count) {
|
||||
<mat-form-field class="full-width">
|
||||
<mat-form-field class="w-full">
|
||||
<mat-label>{{
|
||||
'HOME.PLAYLISTS.INFO_DIALOG.CHANNELS' | translate
|
||||
}}</mat-label>
|
||||
@@ -124,7 +126,7 @@
|
||||
</mat-form-field>
|
||||
}
|
||||
@if (isDesktop) {
|
||||
<mat-form-field class="full-width mb-10">
|
||||
<mat-form-field class="w-full mb-10">
|
||||
<mat-label>{{
|
||||
'HOME.PLAYLISTS.INFO_DIALOG.USER_AGENT' | translate
|
||||
}}</mat-label>
|
||||
@@ -135,7 +137,7 @@
|
||||
</mat-form-field>
|
||||
}
|
||||
@if (playlist.filePath) {
|
||||
<mat-form-field class="full-width">
|
||||
<mat-form-field class="w-full">
|
||||
<mat-label>
|
||||
{{ 'HOME.PLAYLISTS.INFO_DIALOG.FILE_PATH' | translate }}
|
||||
</mat-label>
|
||||
@@ -151,7 +153,7 @@
|
||||
</mat-form-field>
|
||||
}
|
||||
@if (isDesktop && (playlist.url || playlist.filePath)) {
|
||||
<mat-checkbox class="full-width" formControlName="autoRefresh">
|
||||
<mat-checkbox class="w-full" formControlName="autoRefresh">
|
||||
{{ 'HOME.PLAYLISTS.INFO_DIALOG.AUTO_UPDATE' | translate }}
|
||||
</mat-checkbox>
|
||||
<p>
|
||||
|
||||
@@ -112,7 +112,9 @@ export class RecentPlaylistsComponent {
|
||||
);
|
||||
})
|
||||
.filter((item) =>
|
||||
item.title.toLowerCase().includes(searchQuery.toLowerCase())
|
||||
(item.title || '')
|
||||
.toLowerCase()
|
||||
.includes(searchQuery.toLowerCase())
|
||||
);
|
||||
|
||||
// Apply sorting using the SortService
|
||||
@@ -160,11 +162,11 @@ export class RecentPlaylistsComponent {
|
||||
|
||||
getPlaylist(playlistMeta: PlaylistMeta): void {
|
||||
if (playlistMeta.serverUrl) {
|
||||
this.router.navigate(['xtreams', playlistMeta._id]);
|
||||
this.router.navigate(['/workspace', 'xtreams', playlistMeta._id]);
|
||||
} else if (playlistMeta.macAddress) {
|
||||
this.router.navigate(['stalker', playlistMeta._id]);
|
||||
this.router.navigate(['/workspace', 'stalker', playlistMeta._id]);
|
||||
} else {
|
||||
this.router.navigate(['playlists', playlistMeta._id]);
|
||||
this.router.navigate(['/workspace', 'playlists', playlistMeta._id]);
|
||||
this.playlistClicked.emit(playlistMeta._id);
|
||||
}
|
||||
}
|
||||
@@ -280,7 +282,7 @@ export class RecentPlaylistsComponent {
|
||||
);
|
||||
|
||||
// Navigate to the playlist to trigger re-import
|
||||
this.router.navigate(['xtreams', item._id]);
|
||||
this.router.navigate(['/workspace', 'xtreams', item._id]);
|
||||
} catch (error) {
|
||||
console.error('Error refreshing Xtream playlist:', error);
|
||||
this.snackBar.open(
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
import { signal } from '@angular/core';
|
||||
const sig = signal<any>(undefined);
|
||||
console.log(!!sig());
|
||||
Reference in new issue
Block a user