mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-10 10:06:15 -08:00
refactor(portals): more structural changes in monorepo for xtream and stalker
feat(xtream): implement category management dialog and main container with sorting and searching features style(stalker, xtream): add styles for main container, shell, and category management dialog fix(xtream): update routing and state management for playlist and content sections chore: update tsconfig paths for stalker and xtream features
This commit is contained in:
1 parent
b5bd1bd92a
commit
9834c9fc0d
35 files changed
+274
-251
No files matched your search
@@ -28,6 +28,7 @@ import { AppConfig } from '../environments/environment';
|
||||
import { routes } from './app.routes';
|
||||
import { ElectronService } from './services/electron.service';
|
||||
import { PlayerService } from './services/player.service';
|
||||
import { providePortalNavigationActions } from './services/portal-navigation-actions.service';
|
||||
import { PwaService } from './services/pwa.service';
|
||||
|
||||
// AoT requires an exported function for factories
|
||||
@@ -91,6 +92,7 @@ export const appConfig: ApplicationConfig = {
|
||||
provide: PORTAL_PLAYER,
|
||||
useExisting: PlayerService,
|
||||
},
|
||||
...providePortalNavigationActions(),
|
||||
...provideXtreamDataSource(),
|
||||
],
|
||||
};
|
||||
@@ -1,79 +1 @@
|
||||
@use '@angular/material' as mat;
|
||||
|
||||
:host {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
overflow: hidden;
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.sidebar-header {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
app-region: drag;
|
||||
|
||||
&.sidebar-header--workspace {
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
button {
|
||||
@include mat.form-field-density(-5);
|
||||
app-region: no-drag;
|
||||
}
|
||||
}
|
||||
|
||||
.main-content {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 0; // Important for proper flex behavior
|
||||
}
|
||||
|
||||
.content-header {
|
||||
padding: 16px;
|
||||
border-bottom: 1px solid rgba(0, 0, 0, 0.12);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
app-region: drag;
|
||||
|
||||
h2 {
|
||||
margin: 0;
|
||||
font-size: 1.2rem;
|
||||
font-weight: 500;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
button {
|
||||
app-region: no-drag;
|
||||
}
|
||||
}
|
||||
|
||||
.header-left {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.header-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
app-region: no-drag;
|
||||
}
|
||||
|
||||
.category-search {
|
||||
width: 240px;
|
||||
}
|
||||
|
||||
.scrollable-content {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
|
||||
&.no-header {
|
||||
// When header is hidden, content starts from top
|
||||
margin-top: 0;
|
||||
}
|
||||
}
|
||||
@forward '../../../../../libs/portal/shared/ui/src/lib/styles/portal-main-container';
|
||||
@@ -0,0 +1,59 @@
|
||||
import { inject, Injectable, Provider } from '@angular/core';
|
||||
import { MatDialog } from '@angular/material/dialog';
|
||||
import { PlaylistInfoComponent } from 'components';
|
||||
import {
|
||||
PORTAL_NAVIGATION_ACTIONS,
|
||||
PortalNavigationActions,
|
||||
} from '@iptvnator/portal/shared/util';
|
||||
import { XtreamStore } from '@iptvnator/portal/xtream/data-access';
|
||||
import { Playlist } from 'shared-interfaces';
|
||||
import { SettingsComponent } from '../settings/settings.component';
|
||||
import { AccountInfoComponent } from '../xtream-electron/account-info/account-info.component';
|
||||
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class AppPortalNavigationActionsService
|
||||
implements PortalNavigationActions
|
||||
{
|
||||
private readonly dialog = inject(MatDialog);
|
||||
private readonly xtreamStore = inject(XtreamStore);
|
||||
|
||||
openAccountInfo(): void {
|
||||
this.dialog.open(AccountInfoComponent, {
|
||||
width: '80%',
|
||||
maxWidth: '1200px',
|
||||
maxHeight: '90vh',
|
||||
data: {
|
||||
vodStreamsCount: this.xtreamStore.vodStreams().length,
|
||||
liveStreamsCount: this.xtreamStore.liveStreams().length,
|
||||
seriesCount: this.xtreamStore.serialStreams().length,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
openPlaylistInfo(playlist: Playlist | null | undefined): void {
|
||||
this.dialog.open(PlaylistInfoComponent, {
|
||||
data: playlist ?? null,
|
||||
});
|
||||
}
|
||||
|
||||
openSettings(): void {
|
||||
this.dialog.open(SettingsComponent, {
|
||||
width: '1200px',
|
||||
maxWidth: '96vw',
|
||||
maxHeight: '92vh',
|
||||
data: {
|
||||
isDialog: true,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export function providePortalNavigationActions(): Provider[] {
|
||||
return [
|
||||
AppPortalNavigationActionsService,
|
||||
{
|
||||
provide: PORTAL_NAVIGATION_ACTIONS,
|
||||
useExisting: AppPortalNavigationActionsService,
|
||||
},
|
||||
];
|
||||
}
|
||||
@@ -4,14 +4,6 @@ import { provideStalkerCatalogFacade } from './stalker-catalog-facade.service';
|
||||
// Temporary app-side loader map while Stalker route screens remain in apps/web.
|
||||
export const stalkerFeatureRouteOptions: StalkerFeatureRouteOptions = {
|
||||
catalogProviders: provideStalkerCatalogFacade(),
|
||||
loadShellComponent: () =>
|
||||
import('./stalker-shell/stalker-shell.component').then(
|
||||
(c) => c.StalkerShellComponent
|
||||
),
|
||||
loadMainContainerComponent: () =>
|
||||
import('./stalker-main-container.component').then(
|
||||
(c) => c.StalkerMainContainerComponent
|
||||
),
|
||||
loadCategoryContentViewComponent: () =>
|
||||
import(
|
||||
'../portal-shared/category-content-view/category-content-view.component'
|
||||
|
||||
@@ -11,7 +11,7 @@ import { StalkerStore } from '@iptvnator/portal/stalker/data-access';
|
||||
import {
|
||||
CategoryManagementDialogComponent,
|
||||
CategoryManagementDialogData,
|
||||
} from '../xtream-electron/category-management-dialog/category-management-dialog.component';
|
||||
} from '@iptvnator/portal/xtream/feature';
|
||||
import { XtreamStore } from '@iptvnator/portal/xtream/data-access';
|
||||
|
||||
type WorkspaceProvider = 'xtreams' | 'stalker' | 'playlists';
|
||||
|
||||
@@ -37,7 +37,7 @@ import {
|
||||
import {
|
||||
CategoryManagementDialogComponent,
|
||||
CategoryManagementDialogData,
|
||||
} from '../category-management-dialog/category-management-dialog.component';
|
||||
} from '@iptvnator/portal/xtream/feature';
|
||||
import { PortalChannelsListComponent } from '../portal-channels-list/portal-channels-list.component';
|
||||
import { FavoritesService } from '@iptvnator/portal/xtream/data-access';
|
||||
import { XtreamStore } from '@iptvnator/portal/xtream/data-access';
|
||||
|
||||
@@ -5,18 +5,10 @@ import { provideXtreamCatalogFacade } from './xtream-catalog-facade.service';
|
||||
// out of apps/web into the Xtream feature library.
|
||||
export const xtreamFeatureRouteOptions: XtreamFeatureRouteOptions = {
|
||||
catalogProviders: provideXtreamCatalogFacade(),
|
||||
loadShellComponent: () =>
|
||||
import('./xtream-shell/xtream-shell.component').then(
|
||||
(c) => c.XtreamShellComponent
|
||||
),
|
||||
loadLiveStreamLayoutComponent: () =>
|
||||
import('./live-stream-layout/live-stream-layout.component').then(
|
||||
(c) => c.LiveStreamLayoutComponent
|
||||
),
|
||||
loadMainContainerComponent: () =>
|
||||
import('./xtream-main-container.component').then(
|
||||
(c) => c.XtreamMainContainerComponent
|
||||
),
|
||||
loadCategoryContentViewComponent: () =>
|
||||
import(
|
||||
'../portal-shared/category-content-view/category-content-view.component'
|
||||
|
||||
@@ -9,4 +9,5 @@ export * from './lib/components/portal-empty-state/portal-empty-state.component'
|
||||
export * from './lib/components/search-form/search-form.component';
|
||||
export * from './lib/components/search-layout/search-layout.component';
|
||||
export * from './lib/components/watched-badge/watched-badge.component';
|
||||
export * from './lib/navigation/navigation.component';
|
||||
export * from './lib/navigation/portal-rail-links.component';
|
||||
+1
-6
@@ -1,13 +1,12 @@
|
||||
<div
|
||||
class="portal-status-container"
|
||||
[class]="getStatusColor()"
|
||||
[matTooltip]="xtreamStore.portalStatus()"
|
||||
[matTooltip]="portalStatus() ?? ''"
|
||||
matTooltipPosition="right"
|
||||
>
|
||||
<mat-icon class="status-watermark">{{ getStatusIcon() }}</mat-icon>
|
||||
</div>
|
||||
<div class="nav-section">
|
||||
<!-- <div class="nav-group-label">{{ 'PORTALS.SIDEBAR.MAIN' | translate }}</div> -->
|
||||
<mat-nav-list>
|
||||
<a
|
||||
mat-list-item
|
||||
@@ -23,7 +22,6 @@
|
||||
</div>
|
||||
<mat-divider />
|
||||
<div class="nav-section">
|
||||
<!-- <div class="nav-group-label">{{ 'PORTALS.SIDEBAR.MAIN' | translate }}</div> -->
|
||||
<app-portal-rail-links
|
||||
variant="list"
|
||||
activeClass="active"
|
||||
@@ -34,9 +32,6 @@
|
||||
<mat-divider />
|
||||
|
||||
<div class="nav-section">
|
||||
<!-- <div class="nav-group-label">
|
||||
{{ 'PORTALS.SIDEBAR.LIBRARY' | translate }}
|
||||
</div> -->
|
||||
<app-portal-rail-links
|
||||
variant="list"
|
||||
activeClass="active"
|
||||
File renamed without changes.
+21
-38
@@ -1,23 +1,24 @@
|
||||
import { Component, computed, inject, input } from '@angular/core';
|
||||
import { MatDialog } from '@angular/material/dialog';
|
||||
import { MatDividerModule } from '@angular/material/divider';
|
||||
import { MatIcon } from '@angular/material/icon';
|
||||
import { MatListModule } from '@angular/material/list';
|
||||
import { MatTooltip } from '@angular/material/tooltip';
|
||||
import { ActivatedRoute, RouterLink, RouterLinkActive } from '@angular/router';
|
||||
import { Store } from '@ngrx/store';
|
||||
import { TranslatePipe } from '@ngx-translate/core';
|
||||
import { PlaylistInfoComponent } from 'components';
|
||||
import {
|
||||
buildPortalRailLinks,
|
||||
PORTAL_NAVIGATION_ACTIONS,
|
||||
PortalRailSection,
|
||||
} from '@iptvnator/portal/shared/util';
|
||||
import { selectPlaylistById } from 'm3u-state';
|
||||
import { Playlist } from 'shared-interfaces';
|
||||
import { SettingsComponent } from '../../settings/settings.component';
|
||||
import { AccountInfoComponent } from '../../xtream-electron/account-info/account-info.component';
|
||||
import { XtreamStore } from '@iptvnator/portal/xtream/data-access';
|
||||
import { buildPortalRailLinks } from '@iptvnator/portal/shared/util';
|
||||
import { PortalRailLinksComponent } from '@iptvnator/portal/shared/ui';
|
||||
import { PortalRailLinksComponent } from './portal-rail-links.component';
|
||||
|
||||
@Component({
|
||||
selector: 'app-navigation',
|
||||
imports: [
|
||||
MatDividerModule,
|
||||
MatIcon,
|
||||
MatListModule,
|
||||
MatTooltip,
|
||||
@@ -31,27 +32,27 @@ import { PortalRailLinksComponent } from '@iptvnator/portal/shared/ui';
|
||||
})
|
||||
export class NavigationComponent {
|
||||
private readonly activatedRoute = inject(ActivatedRoute);
|
||||
private readonly dialog = inject(MatDialog);
|
||||
private readonly navigationActions = inject(PORTAL_NAVIGATION_ACTIONS);
|
||||
private readonly store = inject(Store);
|
||||
readonly xtreamStore = inject(XtreamStore);
|
||||
|
||||
readonly portalStatus = input<'active' | 'inactive' | 'expired'>();
|
||||
readonly selectedContentType = input<string>();
|
||||
readonly portalStatus = input<
|
||||
'active' | 'inactive' | 'expired' | 'unavailable'
|
||||
>();
|
||||
readonly selectedContentType = input<PortalRailSection | undefined>();
|
||||
|
||||
readonly currentPlaylist = this.store.selectSignal(
|
||||
selectPlaylistById(this.activatedRoute.snapshot.params.id)
|
||||
);
|
||||
|
||||
readonly isStalkerPlaylist = computed(
|
||||
() => !!(this.currentPlaylist() as Playlist)?.macAddress
|
||||
() => !!(this.currentPlaylist() as Playlist | undefined)?.macAddress
|
||||
);
|
||||
|
||||
/** Check if running in Electron (downloads only available in desktop) */
|
||||
readonly isElectron = !!window.electron;
|
||||
|
||||
readonly railLinks = computed(() => {
|
||||
const playlistId =
|
||||
(this.currentPlaylist() as Playlist | null)?._id ??
|
||||
(this.currentPlaylist() as Playlist | undefined)?._id ??
|
||||
this.activatedRoute.snapshot.params['id'];
|
||||
if (!playlistId) {
|
||||
return { primary: [], secondary: [] };
|
||||
@@ -97,33 +98,15 @@ export class NavigationComponent {
|
||||
}
|
||||
}
|
||||
|
||||
openAccountInfo() {
|
||||
this.dialog.open(AccountInfoComponent, {
|
||||
width: '80%',
|
||||
maxWidth: '1200px',
|
||||
maxHeight: '90vh',
|
||||
data: {
|
||||
vodStreamsCount: this.xtreamStore.vodStreams().length,
|
||||
liveStreamsCount: this.xtreamStore.liveStreams().length,
|
||||
seriesCount: this.xtreamStore.serialStreams().length,
|
||||
},
|
||||
});
|
||||
openAccountInfo(): void {
|
||||
this.navigationActions.openAccountInfo();
|
||||
}
|
||||
|
||||
openSettings() {
|
||||
this.dialog.open(SettingsComponent, {
|
||||
width: '1200px',
|
||||
maxWidth: '96vw',
|
||||
maxHeight: '92vh',
|
||||
data: {
|
||||
isDialog: true,
|
||||
},
|
||||
});
|
||||
openSettings(): void {
|
||||
this.navigationActions.openSettings();
|
||||
}
|
||||
|
||||
openPlaylistInfo() {
|
||||
this.dialog.open(PlaylistInfoComponent, {
|
||||
data: this.currentPlaylist(),
|
||||
});
|
||||
openPlaylistInfo(): void {
|
||||
this.navigationActions.openPlaylistInfo(this.currentPlaylist());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
@use '@angular/material' as mat;
|
||||
|
||||
:host {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
overflow: hidden;
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.sidebar-header {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
app-region: drag;
|
||||
|
||||
&.sidebar-header--workspace {
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
button {
|
||||
@include mat.form-field-density(-5);
|
||||
app-region: no-drag;
|
||||
}
|
||||
}
|
||||
|
||||
.main-content {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.content-header {
|
||||
padding: 16px;
|
||||
border-bottom: 1px solid rgba(0, 0, 0, 0.12);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
app-region: drag;
|
||||
|
||||
h2 {
|
||||
margin: 0;
|
||||
font-size: 1.2rem;
|
||||
font-weight: 500;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
button {
|
||||
app-region: no-drag;
|
||||
}
|
||||
}
|
||||
|
||||
.header-left {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.header-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
app-region: no-drag;
|
||||
}
|
||||
|
||||
.category-search {
|
||||
width: 240px;
|
||||
}
|
||||
|
||||
.scrollable-content {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
|
||||
&.no-header {
|
||||
margin-top: 0;
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,7 @@ export * from './lib/favorites-context.service';
|
||||
export * from './lib/logger';
|
||||
export * from './lib/portal-catalog-facade';
|
||||
export * from './lib/portal-player';
|
||||
export * from './lib/portal-navigation-actions';
|
||||
export * from './lib/portal-collection-context';
|
||||
export * from './lib/portal-collection-items';
|
||||
export * from './lib/navigation/portal-rail-links';
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
import { InjectionToken } from '@angular/core';
|
||||
import { Playlist } from 'shared-interfaces';
|
||||
|
||||
export interface PortalNavigationActions {
|
||||
openAccountInfo(): void;
|
||||
openPlaylistInfo(playlist: Playlist | null | undefined): void;
|
||||
openSettings(): void;
|
||||
}
|
||||
|
||||
export const PORTAL_NAVIGATION_ACTIONS =
|
||||
new InjectionToken<PortalNavigationActions>('PORTAL_NAVIGATION_ACTIONS');
|
||||
@@ -1 +1,3 @@
|
||||
export * from './lib/stalker-main-container.component';
|
||||
export * from './lib/stalker-feature.routes';
|
||||
export * from './lib/stalker-shell/stalker-shell.component';
|
||||
@@ -5,8 +5,6 @@ type ComponentLoader = NonNullable<Route['loadComponent']>;
|
||||
|
||||
export interface StalkerFeatureRouteOptions {
|
||||
readonly catalogProviders?: Provider[];
|
||||
readonly loadShellComponent: ComponentLoader;
|
||||
readonly loadMainContainerComponent: ComponentLoader;
|
||||
readonly loadCategoryContentViewComponent: ComponentLoader;
|
||||
readonly loadLiveStreamLayoutComponent: ComponentLoader;
|
||||
readonly loadFavoritesComponent: ComponentLoader;
|
||||
@@ -19,13 +17,23 @@ const loadDownloadsComponent: ComponentLoader = () =>
|
||||
(c) => c.DownloadsComponent
|
||||
);
|
||||
|
||||
const loadStalkerShellComponent: ComponentLoader = () =>
|
||||
import('./stalker-shell/stalker-shell.component').then(
|
||||
(c) => c.StalkerShellComponent
|
||||
);
|
||||
|
||||
const loadStalkerMainContainerComponent: ComponentLoader = () =>
|
||||
import('./stalker-main-container.component').then(
|
||||
(c) => c.StalkerMainContainerComponent
|
||||
);
|
||||
|
||||
export function createStalkerRoutes(
|
||||
options: StalkerFeatureRouteOptions
|
||||
): Route[] {
|
||||
return [
|
||||
{
|
||||
path: 'stalker/:id',
|
||||
loadComponent: options.loadShellComponent,
|
||||
loadComponent: loadStalkerShellComponent,
|
||||
children: [
|
||||
{
|
||||
path: '',
|
||||
@@ -35,7 +43,7 @@ export function createStalkerRoutes(
|
||||
{
|
||||
path: 'vod',
|
||||
providers: options.catalogProviders ?? [],
|
||||
loadComponent: options.loadMainContainerComponent,
|
||||
loadComponent: loadStalkerMainContainerComponent,
|
||||
children: [
|
||||
{
|
||||
path: '',
|
||||
@@ -69,7 +77,7 @@ export function createStalkerRoutes(
|
||||
{
|
||||
path: 'series',
|
||||
providers: options.catalogProviders ?? [],
|
||||
loadComponent: options.loadMainContainerComponent,
|
||||
loadComponent: loadStalkerMainContainerComponent,
|
||||
children: [
|
||||
{
|
||||
path: '',
|
||||
|
||||
File renamed without changes.
File renamed without changes.
+6
-13
@@ -5,8 +5,10 @@ import { MatPaginatorModule } from '@angular/material/paginator';
|
||||
import { ActivatedRoute, Router, RouterOutlet } from '@angular/router';
|
||||
import { TranslatePipe, TranslateService } from '@ngx-translate/core';
|
||||
import { PlaylistSwitcherComponent, ResizableDirective } from 'components';
|
||||
import { CategoryViewComponent } from '@iptvnator/portal/shared/ui';
|
||||
import { PlaylistErrorViewComponent } from '@iptvnator/portal/shared/ui';
|
||||
import {
|
||||
CategoryViewComponent,
|
||||
PlaylistErrorViewComponent,
|
||||
} from '@iptvnator/portal/shared/ui';
|
||||
import { isWorkspaceLayoutRoute } from '@iptvnator/portal/shared/util';
|
||||
import { StalkerStore } from '@iptvnator/portal/stalker/data-access';
|
||||
|
||||
@@ -15,8 +17,8 @@ import { StalkerStore } from '@iptvnator/portal/stalker/data-access';
|
||||
templateUrl: './stalker-main-container.component.html',
|
||||
styleUrls: [
|
||||
'./stalker-main-container.component.scss',
|
||||
'../portal-shared/portal-main-container.scss',
|
||||
'../shared/styles/portal-sidebar.scss',
|
||||
'../../../../shared/ui/src/lib/styles/portal-main-container.scss',
|
||||
'../../../../shared/ui/src/lib/styles/portal-sidebar.scss',
|
||||
],
|
||||
imports: [
|
||||
CategoryViewComponent,
|
||||
@@ -45,13 +47,9 @@ export class StalkerMainContainerComponent {
|
||||
|
||||
readonly selectedCategoryTitle = this.stalkerStore.getSelectedCategoryName;
|
||||
readonly currentPlaylist = this.stalkerStore.currentPlaylist;
|
||||
|
||||
/** categories */
|
||||
readonly categories = this.stalkerStore.getCategoryResource;
|
||||
readonly isCategoryLoading = this.stalkerStore.isCategoryResourceLoading;
|
||||
readonly isCategoryFailed = this.stalkerStore.isCategoryResourceFailed;
|
||||
|
||||
/** content items */
|
||||
readonly contentItems = this.stalkerStore.getPaginatedContent;
|
||||
readonly isContentLoading = this.stalkerStore.isPaginatedContentLoading;
|
||||
readonly skeletonRows = Array.from({ length: 12 }, (_, index) => index);
|
||||
@@ -60,7 +58,6 @@ export class StalkerMainContainerComponent {
|
||||
];
|
||||
|
||||
constructor() {
|
||||
// reset category title after changing content type
|
||||
effect(() => {
|
||||
this.stalkerStore.selectedContentType();
|
||||
});
|
||||
@@ -104,18 +101,14 @@ export class StalkerMainContainerComponent {
|
||||
this.stalkerStore.getSelectedCategoryName();
|
||||
let categoryName = selectedCategoryName;
|
||||
|
||||
// For implicit "All" view, show a type-specific title so switching
|
||||
// between VOD/Series updates header text immediately.
|
||||
if (selectedCategoryId === '*') {
|
||||
categoryName = `${selectedTypeLabel} - ${this.translateService.instant(
|
||||
'PORTALS.ALL_CATEGORIES'
|
||||
)}`;
|
||||
} else if (!categoryName) {
|
||||
// Fallback while categories are loading or when no category is selected.
|
||||
categoryName = selectedTypeLabel;
|
||||
}
|
||||
|
||||
// Show page number when viewing category content (not detail view)
|
||||
if (
|
||||
!this.stalkerStore.selectedItem() &&
|
||||
!this.isContentLoading() &&
|
||||
+1
-1
@@ -2,7 +2,7 @@
|
||||
@if (!isWorkspaceLayout) {
|
||||
<app-navigation
|
||||
class="navigation-sidebar"
|
||||
[selectedContentType]="stalkerStore.selectedContentType()"
|
||||
[selectedContentType]="selectedSection()"
|
||||
/>
|
||||
}
|
||||
<router-outlet />
|
||||
File renamed without changes.
+19
-22
@@ -1,4 +1,4 @@
|
||||
import { Component, effect, inject, OnDestroy } from '@angular/core';
|
||||
import { Component, computed, effect, inject, OnDestroy, signal } from '@angular/core';
|
||||
import { takeUntilDestroyed, toSignal } from '@angular/core/rxjs-interop';
|
||||
import {
|
||||
ActivatedRoute,
|
||||
@@ -9,42 +9,43 @@ import {
|
||||
import { Store } from '@ngrx/store';
|
||||
import { PlaylistActions, selectPlaylistById } from 'm3u-state';
|
||||
import { map, switchMap } from 'rxjs';
|
||||
import { PortalRailSection } from '@iptvnator/portal/shared/util';
|
||||
import {
|
||||
isWorkspaceLayoutRoute,
|
||||
PortalRailSection,
|
||||
resolveCurrentPortalSection,
|
||||
} from '@iptvnator/portal/shared/util';
|
||||
import { createLogger } from '@iptvnator/portal/shared/util';
|
||||
import { NavigationComponent } from '../../portal-shared/navigation/navigation.component';
|
||||
import { XtreamStore } from '@iptvnator/portal/xtream/data-access';
|
||||
import { NavigationComponent } from '@iptvnator/portal/shared/ui';
|
||||
import { StalkerStore } from '@iptvnator/portal/stalker/data-access';
|
||||
|
||||
type StalkerContentType = 'vod' | 'series' | 'itv';
|
||||
|
||||
@Component({
|
||||
selector: 'app-stalker-shell',
|
||||
templateUrl: './stalker-shell.component.html',
|
||||
styleUrls: ['./stalker-shell.component.scss'],
|
||||
imports: [NavigationComponent, RouterOutlet],
|
||||
providers: [XtreamStore],
|
||||
})
|
||||
export class StalkerShellComponent implements OnDestroy {
|
||||
private readonly logger = createLogger('StalkerShell');
|
||||
private readonly route = inject(ActivatedRoute);
|
||||
private readonly router = inject(Router);
|
||||
readonly stalkerStore = inject(StalkerStore);
|
||||
private readonly store = inject(Store);
|
||||
readonly isWorkspaceLayout = isWorkspaceLayoutRoute(this.route);
|
||||
|
||||
/** Current playlist derived from route params */
|
||||
readonly currentPlaylist = toSignal(
|
||||
this.route.params.pipe(
|
||||
map((params) => params['id']),
|
||||
switchMap((id) => this.store.select(selectPlaylistById(id)))
|
||||
)
|
||||
);
|
||||
private currentSection: PortalRailSection | null = null;
|
||||
readonly selectedSection = computed<PortalRailSection | undefined>(() => {
|
||||
const section = this.currentSection();
|
||||
return section ?? undefined;
|
||||
});
|
||||
|
||||
private readonly currentSection = signal<PortalRailSection | null>(null);
|
||||
|
||||
constructor() {
|
||||
// Subscribe to route params to handle switching between playlists
|
||||
this.route.params.pipe(takeUntilDestroyed()).subscribe((params) => {
|
||||
const playlistId = params['id'];
|
||||
|
||||
@@ -55,16 +56,15 @@ export class StalkerShellComponent implements OnDestroy {
|
||||
PlaylistActions.setCurrentPlaylistId({ playlistId })
|
||||
);
|
||||
|
||||
// Reset store state when switching playlists
|
||||
this.stalkerStore.resetCategories();
|
||||
this.stalkerStore.setSelectedCategory(null);
|
||||
|
||||
const childRoute = this.route.snapshot.firstChild;
|
||||
const path = childRoute?.url?.[0]?.path;
|
||||
const validTypes = ['vod', 'series', 'itv'];
|
||||
const initialType = validTypes.includes(path)
|
||||
? (path as any)
|
||||
: 'vod';
|
||||
const initialType: StalkerContentType =
|
||||
path === 'itv' || path === 'series' || path === 'vod'
|
||||
? path
|
||||
: 'vod';
|
||||
this.stalkerStore.setSelectedContentType(initialType);
|
||||
});
|
||||
|
||||
@@ -83,7 +83,7 @@ export class StalkerShellComponent implements OnDestroy {
|
||||
});
|
||||
}
|
||||
|
||||
ngOnDestroy() {
|
||||
ngOnDestroy(): void {
|
||||
this.stalkerStore.resetCategories();
|
||||
}
|
||||
|
||||
@@ -94,17 +94,14 @@ export class StalkerShellComponent implements OnDestroy {
|
||||
'stalker'
|
||||
);
|
||||
|
||||
if (!section || section === this.currentSection) {
|
||||
if (!section || section === this.currentSection()) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.currentSection = section;
|
||||
this.currentSection.set(section);
|
||||
|
||||
if (section === 'vod' || section === 'series' || section === 'itv') {
|
||||
this.stalkerStore.setSelectedContentType(section as any);
|
||||
return;
|
||||
this.stalkerStore.setSelectedContentType(section);
|
||||
}
|
||||
|
||||
this.stalkerStore.setSelectedContentType(null as any);
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"extends": "../../../../../tsconfig.base.json",
|
||||
"extends": "../../../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"isolatedModules": true,
|
||||
"target": "es2022",
|
||||
|
||||
@@ -1 +1,5 @@
|
||||
export * from './lib/category-management-dialog/category-management-dialog.component';
|
||||
export * from './lib/loading-overlay.component';
|
||||
export * from './lib/xtream-main-container.component';
|
||||
export * from './lib/xtream-feature.routes';
|
||||
export * from './lib/xtream-shell/xtream-shell.component';
|
||||
File renamed without changes.
File renamed without changes.
+2
-12
@@ -108,7 +108,6 @@ export class CategoryManagementDialogComponent implements OnInit {
|
||||
}
|
||||
|
||||
getItemCount(category: CategoryWithSelection): number {
|
||||
// content.category_id references categories.id (internal DB id), not xtream_id
|
||||
const categoryId = Number(category.id);
|
||||
return this.data.itemCounts.get(categoryId) ?? 0;
|
||||
}
|
||||
@@ -156,18 +155,9 @@ export class CategoryManagementDialogComponent implements OnInit {
|
||||
this.isSaving.set(true);
|
||||
try {
|
||||
const categories = this.categories();
|
||||
const toHide = categories.filter((c) => !c.selected).map((c) => c.id);
|
||||
const toShow = categories.filter((c) => c.selected).map((c) => c.id);
|
||||
|
||||
// Get IDs of categories to hide (unselected)
|
||||
const toHide = categories
|
||||
.filter((c) => !c.selected)
|
||||
.map((c) => c.id);
|
||||
|
||||
// Get IDs of categories to show (selected)
|
||||
const toShow = categories
|
||||
.filter((c) => c.selected)
|
||||
.map((c) => c.id);
|
||||
|
||||
// Update visibility in database
|
||||
if (toHide.length > 0) {
|
||||
await this.dbService.updateCategoryVisibility(toHide, true);
|
||||
}
|
||||
File renamed without changes.
@@ -5,9 +5,7 @@ type ComponentLoader = NonNullable<Route['loadComponent']>;
|
||||
|
||||
export interface XtreamFeatureRouteOptions {
|
||||
readonly catalogProviders?: Provider[];
|
||||
readonly loadShellComponent: ComponentLoader;
|
||||
readonly loadLiveStreamLayoutComponent: ComponentLoader;
|
||||
readonly loadMainContainerComponent: ComponentLoader;
|
||||
readonly loadCategoryContentViewComponent: ComponentLoader;
|
||||
readonly loadVodDetailsRouteComponent: ComponentLoader;
|
||||
readonly loadSerialDetailsComponent: ComponentLoader;
|
||||
@@ -22,13 +20,23 @@ const loadDownloadsComponent: ComponentLoader = () =>
|
||||
(c) => c.DownloadsComponent
|
||||
);
|
||||
|
||||
const loadXtreamShellComponent: ComponentLoader = () =>
|
||||
import('./xtream-shell/xtream-shell.component').then(
|
||||
(c) => c.XtreamShellComponent
|
||||
);
|
||||
|
||||
const loadXtreamMainContainerComponent: ComponentLoader = () =>
|
||||
import('./xtream-main-container.component').then(
|
||||
(c) => c.XtreamMainContainerComponent
|
||||
);
|
||||
|
||||
export function createXtreamRoutes(
|
||||
options: XtreamFeatureRouteOptions
|
||||
): Route[] {
|
||||
return [
|
||||
{
|
||||
path: 'xtreams/:id',
|
||||
loadComponent: options.loadShellComponent,
|
||||
loadComponent: loadXtreamShellComponent,
|
||||
children: [
|
||||
{
|
||||
path: '',
|
||||
@@ -49,7 +57,7 @@ export function createXtreamRoutes(
|
||||
{
|
||||
path: 'vod',
|
||||
providers: options.catalogProviders ?? [],
|
||||
loadComponent: options.loadMainContainerComponent,
|
||||
loadComponent: loadXtreamMainContainerComponent,
|
||||
children: [
|
||||
{
|
||||
path: '',
|
||||
@@ -71,7 +79,7 @@ export function createXtreamRoutes(
|
||||
{
|
||||
path: 'series',
|
||||
providers: options.catalogProviders ?? [],
|
||||
loadComponent: options.loadMainContainerComponent,
|
||||
loadComponent: loadXtreamMainContainerComponent,
|
||||
children: [
|
||||
{
|
||||
path: '',
|
||||
|
||||
-1
@@ -127,5 +127,4 @@
|
||||
>
|
||||
<router-outlet />
|
||||
</div>
|
||||
<!-- <app-mpv-player-bar /> -->
|
||||
</div>
|
||||
+24
-17
@@ -13,21 +13,29 @@ import { PlaylistSwitcherComponent, ResizableDirective } from 'components';
|
||||
import { XtreamCategory } from 'shared-interfaces';
|
||||
import { CategoryViewComponent } from '@iptvnator/portal/shared/ui';
|
||||
import { isWorkspaceLayoutRoute } from '@iptvnator/portal/shared/util';
|
||||
import {
|
||||
XtreamCategorySortMode,
|
||||
XtreamStore,
|
||||
} from '@iptvnator/portal/xtream/data-access';
|
||||
import {
|
||||
CategoryManagementDialogComponent,
|
||||
CategoryManagementDialogData,
|
||||
} from './category-management-dialog/category-management-dialog.component';
|
||||
import { XtreamStore } from '@iptvnator/portal/xtream/data-access';
|
||||
import { XtreamCategorySortMode } from '@iptvnator/portal/xtream/data-access';
|
||||
|
||||
const XTREAM_CATEGORY_SORT_STORAGE_KEY = 'xtream-category-sort-mode';
|
||||
|
||||
interface XtreamCategoryLike {
|
||||
readonly id?: number | string;
|
||||
readonly category_id?: number | string;
|
||||
readonly name?: string;
|
||||
}
|
||||
|
||||
@Component({
|
||||
selector: 'app-xtream-main-container',
|
||||
templateUrl: './xtream-main-container.component.html',
|
||||
styleUrls: [
|
||||
'../portal-shared/portal-main-container.scss',
|
||||
'../shared/styles/portal-sidebar.scss',
|
||||
'../../../../shared/ui/src/lib/styles/portal-main-container.scss',
|
||||
'../../../../shared/ui/src/lib/styles/portal-sidebar.scss',
|
||||
],
|
||||
imports: [
|
||||
CategoryViewComponent,
|
||||
@@ -72,7 +80,9 @@ export class XtreamMainContainerComponent implements OnInit {
|
||||
});
|
||||
|
||||
ngOnInit(): void {
|
||||
const savedSortMode = localStorage.getItem(XTREAM_CATEGORY_SORT_STORAGE_KEY);
|
||||
const savedSortMode = localStorage.getItem(
|
||||
XTREAM_CATEGORY_SORT_STORAGE_KEY
|
||||
);
|
||||
if (
|
||||
savedSortMode === 'date-desc' ||
|
||||
savedSortMode === 'date-asc' ||
|
||||
@@ -83,16 +93,16 @@ export class XtreamMainContainerComponent implements OnInit {
|
||||
}
|
||||
|
||||
const { categoryId } = this.route.snapshot.params;
|
||||
if (categoryId)
|
||||
if (categoryId) {
|
||||
this.xtreamStore.setSelectedCategory(Number(categoryId));
|
||||
}
|
||||
}
|
||||
|
||||
categoryClicked(category: XtreamCategory) {
|
||||
const categoryId = (category as any).category_id ?? category.id;
|
||||
const categoryData = category as XtreamCategoryLike;
|
||||
const categoryId = categoryData.category_id ?? categoryData.id;
|
||||
|
||||
// Clear any selected item when switching categories
|
||||
this.xtreamStore.setSelectedItem(null);
|
||||
|
||||
this.xtreamStore.setSelectedCategory(Number(categoryId));
|
||||
|
||||
this.router.navigate([categoryId], {
|
||||
@@ -103,11 +113,11 @@ export class XtreamMainContainerComponent implements OnInit {
|
||||
getContentLabel(): string {
|
||||
const selectedCategoryId = this.xtreamStore.selectedCategoryId();
|
||||
|
||||
// When no category is selected, show "Recently Added"
|
||||
if (selectedCategoryId === null || selectedCategoryId === undefined) {
|
||||
const recentlyAddedLabel = this.translateService.instant('PORTALS.SIDEBAR.RECENTLY_ADDED');
|
||||
const recentlyAddedLabel = this.translateService.instant(
|
||||
'PORTALS.SIDEBAR.RECENTLY_ADDED'
|
||||
);
|
||||
|
||||
// Show page number when viewing recently added (not detail view)
|
||||
if (
|
||||
!this.xtreamStore.selectedItem() &&
|
||||
this.xtreamStore.getTotalPages() > 1
|
||||
@@ -123,15 +133,14 @@ export class XtreamMainContainerComponent implements OnInit {
|
||||
|
||||
const selectedCategory = this.xtreamStore.getSelectedCategory();
|
||||
const categoryName = selectedCategory
|
||||
? (selectedCategory as any).name
|
||||
? (selectedCategory as XtreamCategoryLike).name
|
||||
: 'Category Content';
|
||||
|
||||
// Show page number when viewing category content (not detail view)
|
||||
if (
|
||||
!this.xtreamStore.selectedItem() &&
|
||||
this.xtreamStore.getTotalPages() > 1
|
||||
) {
|
||||
const currentPage = this.xtreamStore.page() + 1; // +1 because page is 0-indexed
|
||||
const currentPage = this.xtreamStore.page() + 1;
|
||||
const totalPages = this.xtreamStore.getTotalPages();
|
||||
const pageLabel = this.translateService.instant('PORTALS.PAGE');
|
||||
return `${categoryName} (${pageLabel} ${currentPage}/${totalPages})`;
|
||||
@@ -147,7 +156,6 @@ export class XtreamMainContainerComponent implements OnInit {
|
||||
}
|
||||
|
||||
openCategoryManagement(): void {
|
||||
// The playlist id is in the parent route (xtreams/:id)
|
||||
const playlistId = this.route.parent?.snapshot.params['id'];
|
||||
const contentType = this.xtreamStore.selectedContentType();
|
||||
|
||||
@@ -167,7 +175,6 @@ export class XtreamMainContainerComponent implements OnInit {
|
||||
|
||||
dialogRef.afterClosed().subscribe((result) => {
|
||||
if (result) {
|
||||
// Reload categories from database to reflect visibility changes
|
||||
this.xtreamStore.reloadCategories();
|
||||
}
|
||||
});
|
||||
+2
-2
@@ -9,8 +9,8 @@
|
||||
@if (!isWorkspaceLayout) {
|
||||
<app-navigation
|
||||
class="navigation-sidebar"
|
||||
[portalStatus]="$any(portalStatus())"
|
||||
[selectedContentType]="xtreamStore.selectedContentType()"
|
||||
[portalStatus]="portalStatus()"
|
||||
[selectedContentType]="selectedSection()"
|
||||
/>
|
||||
}
|
||||
<router-outlet />
|
||||
+1
-2
@@ -9,10 +9,9 @@
|
||||
display: flex;
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
min-height: 0; // Important for proper flex behavior
|
||||
min-height: 0;
|
||||
|
||||
> router-outlet {
|
||||
|
||||
min-width: 0;
|
||||
}
|
||||
}
|
||||
+8
-28
@@ -7,27 +7,21 @@ import {
|
||||
RouterOutlet,
|
||||
} from '@angular/router';
|
||||
import { Store } from '@ngrx/store';
|
||||
import { TranslateModule } from '@ngx-translate/core';
|
||||
import { PlaylistActions } from 'm3u-state';
|
||||
import { filter } from 'rxjs';
|
||||
import { PortalRailSection } from '@iptvnator/portal/shared/util';
|
||||
import {
|
||||
isWorkspaceLayoutRoute,
|
||||
PortalRailSection,
|
||||
resolveCurrentPortalSection,
|
||||
} from '@iptvnator/portal/shared/util';
|
||||
import { LoadingOverlayComponent } from '../loading-overlay/loading-overlay.component';
|
||||
import { NavigationComponent } from '../../portal-shared/navigation/navigation.component';
|
||||
import { NavigationComponent } from '@iptvnator/portal/shared/ui';
|
||||
import { XtreamStore } from '@iptvnator/portal/xtream/data-access';
|
||||
import { LoadingOverlayComponent } from '../loading-overlay.component';
|
||||
|
||||
@Component({
|
||||
templateUrl: './xtream-shell.component.html',
|
||||
styleUrls: ['./xtream-shell.component.scss'],
|
||||
imports: [
|
||||
LoadingOverlayComponent,
|
||||
NavigationComponent,
|
||||
RouterOutlet,
|
||||
TranslateModule,
|
||||
],
|
||||
imports: [LoadingOverlayComponent, NavigationComponent, RouterOutlet],
|
||||
})
|
||||
export class XtreamShellComponent {
|
||||
private readonly route = inject(ActivatedRoute);
|
||||
@@ -44,31 +38,24 @@ export class XtreamShellComponent {
|
||||
const section = this.currentSection();
|
||||
return this.isImporting() && this.isContentSection(section);
|
||||
});
|
||||
readonly selectedSection = computed<PortalRailSection | undefined>(() => {
|
||||
const section = this.currentSection();
|
||||
return section ?? undefined;
|
||||
});
|
||||
|
||||
private currentPlaylistId: string | null = null;
|
||||
private readonly currentSection = signal<PortalRailSection | null>(null);
|
||||
|
||||
constructor() {
|
||||
// Subscribe to route params to handle switching between playlists
|
||||
this.route.params
|
||||
.pipe(takeUntilDestroyed())
|
||||
.subscribe(async (params) => {
|
||||
const newPlaylistId = params['id'];
|
||||
|
||||
// Skip if this component instance already processed this ID
|
||||
if (this.currentPlaylistId === newPlaylistId) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Always reset the store when this is a fresh shell instance
|
||||
// (currentPlaylistId is null = newly created component). This
|
||||
// handles the refresh-from-sources flow where the DB content was
|
||||
// cleared but the root-scoped store still holds stale
|
||||
// isContentInitialized: true from the previous session.
|
||||
//
|
||||
// When navigating between sub-routes within the same shell
|
||||
// (live → vod → series), currentPlaylistId is already set so
|
||||
// reset is skipped and in-memory content is preserved.
|
||||
const isFreshInstance = this.currentPlaylistId === null;
|
||||
if (
|
||||
isFreshInstance ||
|
||||
@@ -85,8 +72,6 @@ export class XtreamShellComponent {
|
||||
})
|
||||
);
|
||||
|
||||
// Must await fetchXtreamPlaylist before checking status
|
||||
// because checkPortalStatus needs currentPlaylist to be set
|
||||
await this.xtreamStore.fetchXtreamPlaylist();
|
||||
await this.xtreamStore.checkPortalStatus();
|
||||
});
|
||||
@@ -114,8 +99,6 @@ export class XtreamShellComponent {
|
||||
return;
|
||||
}
|
||||
|
||||
// Only initialize content when playlist is loaded AND matches the current playlistId
|
||||
// This prevents stale data from a previous session from being used
|
||||
if (playlist !== null && playlist.id === playlistId) {
|
||||
void this.xtreamStore.initializeContent();
|
||||
}
|
||||
@@ -137,10 +120,7 @@ export class XtreamShellComponent {
|
||||
|
||||
if (section === 'vod' || section === 'live' || section === 'series') {
|
||||
this.xtreamStore.setSelectedContentType(section);
|
||||
return;
|
||||
}
|
||||
|
||||
this.xtreamStore.setSelectedContentType(undefined);
|
||||
}
|
||||
|
||||
private isContentSection(section: PortalRailSection | null): boolean {
|
||||
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"extends": "../../../../../tsconfig.base.json",
|
||||
"extends": "../../../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"isolatedModules": true,
|
||||
"target": "es2022",
|
||||
|
||||
Reference in new issue
Block a user