refactor: replace tabs by mat-menu

This commit is contained in:
4gray committed 2023-11-26 18:32:16 +01:00
1 parent 623874984d
commit 1c7d14d216
20 files changed
+484 -535

No files matched your search

+1 -80
View File
@@ -1,89 +1,10 @@
<div class="grid-container">
<!-- Application header-->
<app-header
[title]="'HOME.TITLE' | translate"
[subtitle]="'HOME.SUBTITLE' | translate"
></app-header>
<!-- Tab-based layout -->
<mat-tab-group
[dynamicHeight]="true"
color="primary"
backgroundColor="primary"
mat-stretch-tabs
>
<mat-tab>
<ng-template mat-tab-label>
<mat-icon class="tab-icon">list</mat-icon>
<div class="tab-title">
{{ 'HOME.TABS.RECENTLY_ADDED' | translate }}
</div>
</ng-template>
<ng-template matTabContent>
<app-recent-playlists class="recent-playlists">
</app-recent-playlists>
</ng-template>
</mat-tab>
<mat-tab>
<ng-template mat-tab-label>
<mat-icon class="tab-icon">attach_file</mat-icon>
<div class="tab-title">
{{ 'HOME.TABS.FILE_UPLOAD' | translate }}
</div>
</ng-template>
<ng-template matTabContent>
<app-file-upload
(fileRejected)="rejectFile($event)"
(fileSelected)="handlePlaylist($event)"
/>
</ng-template>
</mat-tab>
<mat-tab>
<ng-template mat-tab-label>
<mat-icon class="tab-icon">link</mat-icon>
<div class="tab-title">
{{ 'HOME.TABS.URL_UPLOAD' | translate }}
</div>
</ng-template>
<ng-template matTabContent>
<app-url-upload (urlAdded)="sendPlaylistsUrl($event)" />
</ng-template>
</mat-tab>
<mat-tab>
<ng-template mat-tab-label>
<mat-icon class="tab-icon">text_format</mat-icon>
<div class="tab-title">
{{ 'HOME.TABS.TEXT_IMPORT' | translate }}
</div>
</ng-template>
<ng-template matTabContent>
<app-text-import (textAdded)="uploadAsText($event)" />
</ng-template>
</mat-tab>
<mat-tab>
<ng-template mat-tab-label>
<mat-icon class="tab-icon">video_library</mat-icon>
<div class="tab-title">
{{ 'HOME.TABS.XTREME_IMPORT' | translate }}
</div>
</ng-template>
<ng-template matTabContent>
<app-xtream-code-import />
</ng-template>
</mat-tab>
<mat-tab>
<ng-template mat-tab-label>
<mat-icon class="tab-icon">dashboard</mat-icon>
<div class="tab-title">
{{ 'HOME.TABS.STALKER_PORTAL_IMPORT' | translate }}
</div>
</ng-template>
<ng-template matTabContent>
<app-stalker-portal-import />
</ng-template>
</mat-tab>
</mat-tab-group>
<app-recent-playlists class="recent-playlists" />
<!-- Loading spinner -->
<mat-progress-bar *ngIf="isLoading" mode="indeterminate"></mat-progress-bar>
</div>
+9 -57
View File
@@ -3,39 +3,30 @@ import { MatIconModule } from '@angular/material/icon';
import { MatProgressBarModule } from '@angular/material/progress-bar';
import { MatSnackBar, MatSnackBarModule } from '@angular/material/snack-bar';
import { MatTabsModule } from '@angular/material/tabs';
import { Router } from '@angular/router';
import { RouterTestingModule } from '@angular/router/testing';
import { Actions } from '@ngrx/effects';
import { provideMockActions } from '@ngrx/effects/testing';
import { MockStore, provideMockStore } from '@ngrx/store/testing';
import { TranslatePipe, TranslateService } from '@ngx-translate/core';
import {
TranslateModule,
TranslatePipe,
TranslateService,
} from '@ngx-translate/core';
import { MockComponent, MockModule, MockPipe, MockProvider } from 'ng-mocks';
import { NgxIndexedDBModule, NgxIndexedDBService } from 'ngx-indexed-db';
import { Observable, of } from 'rxjs';
import { Observable } from 'rxjs';
import { FileUploadComponent } from '../home/file-upload/file-upload.component';
import { RecentPlaylistsComponent } from '../home/recent-playlists/recent-playlists.component';
import { UrlUploadComponent } from '../home/url-upload/url-upload.component';
import { DataService } from '../services/data.service';
import { ElectronServiceStub } from '../services/electron.service.stub';
import { HeaderComponent } from '../shared/components/header/header.component';
import { PLAYLIST_PARSE_BY_URL } from './../../../shared/ipc-commands';
import { HomeComponent } from './home.component';
class MatSnackBarStub {
open(): void {}
}
class NgxIndexedDBServiceStub {
getAll() {
return of([] as any);
}
}
describe('HomeComponent', () => {
let component: HomeComponent;
let fixture: ComponentFixture<HomeComponent>;
let electronService: DataService;
let router: Router;
let mockStore: MockStore;
const actions$ = new Observable<Actions>();
@@ -56,15 +47,13 @@ describe('HomeComponent', () => {
MockModule(MatSnackBarModule),
MockModule(RouterTestingModule),
MockModule(NgxIndexedDBModule),
MockModule(TranslateModule),
],
providers: [
{ provide: MatSnackBar, useClass: MatSnackBarStub },
MockProvider(MatSnackBar),
{ provide: DataService, useClass: ElectronServiceStub },
MockProvider(TranslateService),
{
provide: NgxIndexedDBService,
useClass: NgxIndexedDBServiceStub,
},
MockProvider(NgxIndexedDBService),
provideMockStore(),
provideMockActions(actions$),
],
@@ -79,7 +68,6 @@ describe('HomeComponent', () => {
mockStore = TestBed.inject(MockStore);
mockStore.setState({});
router = TestBed.inject(Router);
TestBed.inject(NgxIndexedDBService);
fixture.detectChanges();
});
@@ -88,36 +76,6 @@ describe('HomeComponent', () => {
expect(component).toBeTruthy();
});
it('should send an event to the main process to get a playlist by URL', () => {
const playlistTitle = 'playlist.m3u';
const playlistUrl = 'http://test.com/' + playlistTitle;
jest.spyOn(electronService, 'sendIpcEvent');
component.sendPlaylistsUrl(playlistUrl);
expect(electronService.sendIpcEvent).toHaveBeenCalledWith(
PLAYLIST_PARSE_BY_URL,
{
title: playlistTitle,
url: playlistUrl,
}
);
});
it('should send an event to the main process to parse a playlist', () => {
jest.spyOn(mockStore, 'dispatch');
const title = 'my-list.m3u';
const path = '/home/user/iptv/' + title;
const playlistContent = 'test';
const file: File = {
path,
name: title,
} as unknown as File;
const uploadEvent: Event = {
target: { result: playlistContent },
} as unknown as Event;
component.handlePlaylist({ file, uploadEvent });
expect(mockStore.dispatch).toHaveBeenCalledTimes(1);
});
it('should set IPC event listeners', () => {
jest.spyOn(electronService, 'listenOn');
component.setRendererListeners();
@@ -126,12 +84,6 @@ describe('HomeComponent', () => {
);
});
it('should send notification on file reject', () => {
jest.spyOn(component, 'showNotification');
component.rejectFile('wrong-file.txt');
expect(component.showNotification).toHaveBeenCalledTimes(1);
});
it('should remove all ipc listeners on destroy', () => {
jest.spyOn(electronService, 'removeAllListeners');
component.ngOnDestroy();
+7 -70
View File
@@ -1,16 +1,10 @@
import { Component, NgZone } from '@angular/core';
import { MatSnackBar } from '@angular/material/snack-bar';
import { Store } from '@ngrx/store';
import { TranslateService } from '@ngx-translate/core';
import {
ERROR,
PLAYLIST_PARSE_BY_URL,
PLAYLIST_PARSE_RESPONSE,
} from '../../../shared/ipc-commands';
import { ERROR, PLAYLIST_PARSE_RESPONSE } from '../../../shared/ipc-commands';
import { Playlist } from '../../../shared/playlist.interface';
import { getFilenameFromUrl } from '../../../shared/playlist.utils';
import { DataService } from '../services/data.service';
import { addPlaylist, parsePlaylist } from '../state/actions';
import { addPlaylist } from '../state/actions';
@Component({
selector: 'app-home',
@@ -35,7 +29,10 @@ export class HomeComponent {
},
{
id: ERROR,
execute: () => (this.isLoading = false),
execute: (error: { message: string; status: string }) => {
//this.isLoading = false;
this.showNotification('Error2: ' + error.message);
},
},
];
@@ -45,8 +42,7 @@ export class HomeComponent {
private dataService: DataService,
private ngZone: NgZone,
private snackBar: MatSnackBar,
private readonly store: Store,
private translateService: TranslateService
private readonly store: Store
) {
this.setRendererListeners();
}
@@ -72,65 +68,6 @@ export class HomeComponent {
});
}
/**
* Shows the filename of rejected file
* @param filename name of the uploaded file
*/
rejectFile(filename: string): void {
this.showNotification(
this.translateService.instant('HOME.FILE_UPLOAD.REJECTED', {
filename,
})
);
this.isLoading = false;
}
/**
* Parse and store uploaded playlist
* @param payload
*/
handlePlaylist(payload: { uploadEvent: Event; file: File }): void {
this.isLoading = true;
const playlist = (payload.uploadEvent.target as FileReader)
.result as string;
this.store.dispatch(
parsePlaylist({
uploadType: 'FILE',
playlist,
title: payload.file.name,
path: payload.file.path,
})
);
}
/**
* Sends url of the playlist to the renderer process
* @param playlistUrl url of the added playlist
*/
sendPlaylistsUrl(playlistUrl: string): void {
this.isLoading = true;
this.dataService.sendIpcEvent(PLAYLIST_PARSE_BY_URL, {
title: getFilenameFromUrl(playlistUrl),
url: playlistUrl,
});
}
/**
* Sends IPC event to the renderer process to parse playlist
* @param text playlist as string
*/
uploadAsText(playlist: string): void {
this.isLoading = true;
this.store.dispatch(
parsePlaylist({
uploadType: 'TEXT',
playlist,
title: 'Imported as text',
})
);
}
/**
* Shows snack bar notification with a given message
* @param message message to show
+2 -9
View File
@@ -1,20 +1,13 @@
import { CommonModule } from '@angular/common';
import { NgModule } from '@angular/core';
import { HeaderComponent } from '../shared/components/header/header.component';
import { SharedModule } from '../shared/shared.module';
import { DragDropFileUploadDirective } from './file-upload/drag-drop-file-upload.directive';
import { HomeComponent } from './home.component';
import { HomeRoutingModule } from './home.routing';
import { StalkerPortalImportComponent } from './stalker-portal-import/stalker-portal-import.component';
import { XtreamCodeImportComponent } from './xtream-code-import/xtream-code-import.component';
@NgModule({
imports: [
CommonModule,
HomeRoutingModule,
SharedModule,
XtreamCodeImportComponent,
StalkerPortalImportComponent,
],
imports: [CommonModule, HeaderComponent, HomeRoutingModule, SharedModule],
declarations: [DragDropFileUploadDirective, HomeComponent],
})
export class HomeModule {}
@@ -9,10 +9,11 @@ import {
} from '@angular/forms';
import { MAT_DIALOG_DATA, MatDialogModule } from '@angular/material/dialog';
import { MatFormFieldModule } from '@angular/material/form-field';
import { MatSnackBar, MatSnackBarModule } from '@angular/material/snack-bar';
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 { TranslateModule, TranslateService } from '@ngx-translate/core';
import { MockModule, MockPipe, MockProvider } from 'ng-mocks';
import { NgxIndexedDBService } from 'ngx-indexed-db';
import { Observable } from 'rxjs';
@@ -27,29 +28,30 @@ describe('PlaylistInfoComponent', () => {
let mockStore: MockStore;
const actions$ = new Observable<Actions>();
beforeEach(
waitForAsync(() => {
TestBed.configureTestingModule({
imports: [
FormsModule,
MockModule(MatDialogModule),
MockModule(MatCheckboxModule),
MockModule(MatFormFieldModule),
MockModule(TranslateModule),
ReactiveFormsModule,
],
declarations: [PlaylistInfoComponent, MockPipe(DatePipe)],
providers: [
{ provide: MAT_DIALOG_DATA, useValue: {} },
{ provide: DataService, useClass: ElectronServiceStub },
UntypedFormBuilder,
provideMockStore(),
provideMockActions(actions$),
MockProvider(NgxIndexedDBService),
],
}).compileComponents();
})
);
beforeEach(waitForAsync(() => {
TestBed.configureTestingModule({
imports: [
FormsModule,
MockModule(MatDialogModule),
MockModule(MatCheckboxModule),
MockModule(MatFormFieldModule),
MockModule(TranslateModule),
MockModule(MatSnackBarModule),
ReactiveFormsModule,
],
declarations: [PlaylistInfoComponent, MockPipe(DatePipe)],
providers: [
MockProvider(MatSnackBar),
{ provide: MAT_DIALOG_DATA, useValue: {} },
{ provide: DataService, useClass: ElectronServiceStub },
UntypedFormBuilder,
provideMockStore(),
provideMockActions(actions$),
MockProvider(NgxIndexedDBService),
MockProvider(TranslateService),
],
}).compileComponents();
}));
beforeEach(() => {
fixture = TestBed.createComponent(PlaylistInfoComponent);
@@ -56,8 +56,12 @@
>
</ng-container>
</ng-container>
<ng-template #xtreamPlaylist
>Xtream/Stalker Playlist
<ng-template #xtreamPlaylist>
@if (item?.macAddress) {
Stalker Portal
} @else {
Xtream Code Playlist
}
<ng-container *ngIf="item.importDate?.length > 3">
| {{ 'HOME.PLAYLISTS.ADDED' | translate }}:
{{ item.importDate | date }}
@@ -1,4 +1,4 @@
<ng-container *ngIf="allPlaylistsLoaded$ | async; else skeletonView">
@if (allPlaylistsLoaded()) {
<mat-form-field
class="full-width"
subscriptSizing="dynamic"
@@ -11,98 +11,105 @@
[placeholder]="'HOME.PLAYLISTS.SEARCH_PLAYLISTS' | translate"
(input)="onSearchQueryUpdate(searchQuery.value)"
/></mat-form-field>
<mat-nav-list
cdkDropList
(cdkDropListDropped)="drop($event, playlists)"
*ngIf="playlists$ | async as playlists"
class="playlists-list"
>
<mat-list-item *ngIf="playlists.length === 0; else playlistsTemplate">
<mat-icon class="favorites-icon" matListItemAvatar>search</mat-icon>
<div matListItemTitle>
{{ 'HOME.PLAYLISTS.NO_PLAYLISTS' | translate }}
</div>
<div matListItemLine class="meta">
{{ 'HOME.PLAYLISTS.ADD_FIRST' | translate }}
</div>
</mat-list-item>
<ng-template #playlistsTemplate>
<mat-list-item (click)="getGlobalFavorites()">
@defer {
<mat-nav-list
cdkDropList
(cdkDropListDropped)="drop($event, playlists)"
*ngIf="playlists$ | async as playlists"
class="playlists-list"
>
<mat-list-item
*ngIf="playlists.length === 0; else playlistsTemplate"
>
<mat-icon class="favorites-icon" matListItemAvatar
>star</mat-icon
>search</mat-icon
>
<div matListItemTitle>
{{ 'HOME.PLAYLISTS.GLOBAL_FAVORITES' | translate }}
{{ 'HOME.PLAYLISTS.NO_PLAYLISTS' | translate }}
</div>
<div matListItemLine class="meta">
{{
'HOME.PLAYLISTS.GLOBAL_FAVORITES_DESCRIPTION'
| translate
}}
{{ 'HOME.PLAYLISTS.ADD_FIRST' | translate }}
</div>
</mat-list-item>
<mat-divider></mat-divider>
<app-playlist-item
*ngFor="let item of playlists; last as last; trackBy: trackByFn"
[item]="item"
[showActions]="!sidebarMode"
(editPlaylistClicked)="openInfoDialog($event)"
(playlistClicked)="getPlaylist(item)"
(refreshClicked)="refreshPlaylist($event)"
(removeClicked)="removeClicked($event)"
></app-playlist-item>
<ng-template #playlistsTemplate>
<mat-list-item (click)="getGlobalFavorites()">
<mat-icon class="favorites-icon" matListItemAvatar
>star</mat-icon
>
<div matListItemTitle>
{{ 'HOME.PLAYLISTS.GLOBAL_FAVORITES' | translate }}
</div>
<div matListItemLine class="meta">
{{
'HOME.PLAYLISTS.GLOBAL_FAVORITES_DESCRIPTION'
| translate
}}
</div>
</mat-list-item>
<mat-divider></mat-divider>
<div *ngIf="isMigrationPossible" id="migration-container">
<p>{{ migrationMessage }}</p>
<button
mat-button
(click)="migratePlaylists()"
class="migration-btn"
color="accent"
>
<mat-icon>playlist_add</mat-icon>
Migrate playlists</button
>&nbsp;
<button
mat-button
(click)="deleteMigratedPlaylists()"
class="migration-btn"
color="accent"
>
<mat-icon>delete</mat-icon>
{{ 'SETTINGS.REMOVE_DIALOG.TITLE' | translate }}
</button>
</div>
</ng-template>
</mat-nav-list>
</ng-container>
<app-playlist-item
*ngFor="
let item of playlists;
last as last;
trackBy: trackByFn
"
[item]="item"
[showActions]="!sidebarMode"
(editPlaylistClicked)="openInfoDialog($event)"
(playlistClicked)="getPlaylist(item)"
(refreshClicked)="refreshPlaylist($event)"
(removeClicked)="removeClicked($event)"
></app-playlist-item>
<ng-template #skeletonView>
<div id="skeleton-container">
<div class="skeleton-item" *ngFor="let a of [].constructor(10)">
<ngx-skeleton-loader
count="1"
appearance="circle"
></ngx-skeleton-loader>
<div class="text">
<div *ngIf="isMigrationPossible" id="migration-container">
<p>{{ migrationMessage }}</p>
<button
mat-button
(click)="migratePlaylists()"
class="migration-btn"
color="accent"
>
<mat-icon>playlist_add</mat-icon>
Migrate playlists</button
>&nbsp;
<button
mat-button
(click)="deleteMigratedPlaylists()"
class="migration-btn"
color="accent"
>
<mat-icon>delete</mat-icon>
{{ 'SETTINGS.REMOVE_DIALOG.TITLE' | translate }}
</button>
</div>
</ng-template>
</mat-nav-list>
} @placeholder {
<div id="skeleton-container">
<div class="skeleton-item" *ngFor="let a of [].constructor(10)">
<ngx-skeleton-loader
count="1"
[theme]="{ width: '30%', height: '20px' }"
></ngx-skeleton-loader>
<ngx-skeleton-loader
count="1"
[theme]="{ width: '50%', height: '10px' }"
></ngx-skeleton-loader>
</div>
<div class="actions">
<ngx-skeleton-loader
count="2"
appearance="circle"
></ngx-skeleton-loader>
<div class="text">
<ngx-skeleton-loader
count="1"
[theme]="{ width: '30%', height: '20px' }"
></ngx-skeleton-loader>
<ngx-skeleton-loader
count="1"
[theme]="{ width: '50%', height: '10px' }"
></ngx-skeleton-loader>
</div>
<div class="actions">
<ngx-skeleton-loader
count="2"
appearance="circle"
></ngx-skeleton-loader>
</div>
</div>
</div>
</div>
</ng-template>
}
}
@@ -58,7 +58,7 @@ export class RecentPlaylistsComponent implements OnDestroy {
)
);
allPlaylistsLoaded$ = this.store.select(selectPlaylistsLoadingFlag);
allPlaylistsLoaded = this.store.selectSignal(selectPlaylistsLoadingFlag);
@Input() sidebarMode = false;
@@ -1,7 +1,7 @@
import { ComponentFixture, TestBed, waitForAsync } from '@angular/core/testing';
import { FormsModule, ReactiveFormsModule } from '@angular/forms';
import { MatInputModule } from '@angular/material/input';
import { TranslatePipe } from '@ngx-translate/core';
import { TranslateModule, TranslatePipe } from '@ngx-translate/core';
import { MockModule, MockPipe } from 'ng-mocks';
import { TextImportComponent } from './text-import.component';
@@ -10,18 +10,17 @@ describe('TextImportComponent', () => {
let component: TextImportComponent;
let fixture: ComponentFixture<TextImportComponent>;
beforeEach(
waitForAsync(() => {
TestBed.configureTestingModule({
declarations: [TextImportComponent, MockPipe(TranslatePipe)],
imports: [
MockModule(MatInputModule),
MockModule(FormsModule),
MockModule(ReactiveFormsModule),
],
}).compileComponents();
})
);
beforeEach(waitForAsync(() => {
TestBed.configureTestingModule({
declarations: [TextImportComponent, MockPipe(TranslatePipe)],
imports: [
MockModule(MatInputModule),
MockModule(FormsModule),
MockModule(ReactiveFormsModule),
MockModule(TranslateModule),
],
}).compileComponents();
}));
beforeEach(() => {
fixture = TestBed.createComponent(TextImportComponent);
@@ -4,7 +4,7 @@ import { MatCardModule } from '@angular/material/card';
import { MatInputModule } from '@angular/material/input';
import { By } from '@angular/platform-browser';
import { NoopAnimationsModule } from '@angular/platform-browser/animations';
import { TranslatePipe } from '@ngx-translate/core';
import { TranslateModule, TranslatePipe } from '@ngx-translate/core';
import { MockModule, MockPipe } from 'ng-mocks';
import { DataService } from '../../services/data.service';
import { ElectronServiceStub } from '../../services/electron.service.stub';
@@ -14,26 +14,25 @@ describe('UrlUploadComponent', () => {
let component: UrlUploadComponent;
let fixture: ComponentFixture<UrlUploadComponent>;
beforeEach(
waitForAsync(() => {
TestBed.configureTestingModule({
declarations: [UrlUploadComponent, MockPipe(TranslatePipe)],
imports: [
MockModule(MatInputModule),
MockModule(MatCardModule),
FormsModule,
ReactiveFormsModule,
NoopAnimationsModule,
],
providers: [
{
provide: DataService,
useClass: ElectronServiceStub,
},
],
}).compileComponents();
})
);
beforeEach(waitForAsync(() => {
TestBed.configureTestingModule({
declarations: [UrlUploadComponent, MockPipe(TranslatePipe)],
imports: [
MockModule(MatInputModule),
MockModule(MatCardModule),
MockModule(TranslateModule),
MockModule(FormsModule),
MockModule(ReactiveFormsModule),
NoopAnimationsModule,
],
providers: [
{
provide: DataService,
useClass: ElectronServiceStub,
},
],
}).compileComponents();
}));
beforeEach(() => {
fixture = TestBed.createComponent(UrlUploadComponent);
@@ -45,31 +44,27 @@ describe('UrlUploadComponent', () => {
expect(component).toBeTruthy();
});
it(
'submit form with playlist url',
waitForAsync(() => {
jest.spyOn(component.urlAdded, 'emit');
const TEST_URL = 'http://example.org/playlist.m3u';
const submitButton =
fixture.debugElement.nativeElement.querySelector('button');
it('submit form with playlist url', waitForAsync(() => {
jest.spyOn(component.urlAdded, 'emit');
const TEST_URL = 'http://example.org/playlist.m3u';
const submitButton =
fixture.debugElement.nativeElement.querySelector('button');
// test input field validation
expect(submitButton.disabled).toBeTruthy();
component.form.setValue({ playlistUrl: 'wrong url here' });
fixture.detectChanges();
expect(submitButton.disabled).toBeTruthy();
component.form.setValue({ playlistUrl: TEST_URL + '8' });
fixture.detectChanges();
expect(submitButton.disabled).toBeFalsy();
component.form.setValue({ playlistUrl: TEST_URL });
fixture.detectChanges();
expect(submitButton.disabled).toBeFalsy();
// test input field validation
expect(submitButton.disabled).toBeTruthy();
component.form.setValue({ playlistUrl: 'wrong url here' });
fixture.detectChanges();
expect(submitButton.disabled).toBeTruthy();
component.form.setValue({ playlistUrl: TEST_URL + '8' });
fixture.detectChanges();
expect(submitButton.disabled).toBeFalsy();
component.form.setValue({ playlistUrl: TEST_URL });
fixture.detectChanges();
expect(submitButton.disabled).toBeFalsy();
const form = fixture.debugElement.query(By.css('form'));
form.triggerEventHandler('ngSubmit', null);
const form = fixture.debugElement.query(By.css('form'));
form.triggerEventHandler('ngSubmit', null);
// eslint-disable-next-line @typescript-eslint/unbound-method
expect(component.urlAdded.emit).toHaveBeenCalledWith(TEST_URL);
})
);
expect(component.urlAdded.emit).toHaveBeenCalledWith(TEST_URL);
}));
});
@@ -1,108 +1,27 @@
<mat-tab-group color="primary" backgroundColor="primary">
<mat-tab>
<ng-template mat-tab-label>
<mat-icon class="tab-icon">list</mat-icon>
<div class="tab-title">
{{ 'CHANNELS.ALL_CHANNELS' | translate }}
@if (_channelList) {
<mat-tab-group color="primary" backgroundColor="primary">
<mat-tab>
<ng-template mat-tab-label>
<mat-icon class="tab-icon">list</mat-icon>
<div class="tab-title">
{{ 'CHANNELS.ALL_CHANNELS' | translate }}
</div>
</ng-template>
<div class="search-panel">
<input
class="full-width search-bar"
type="search"
#search
[placeholder]="'CHANNELS.SEARCH_CHANNEL' | translate"
[(ngModel)]="searchTerm.name"
/>
</div>
</ng-template>
<div class="search-panel">
<input
class="full-width search-bar"
type="search"
#search
[placeholder]="'CHANNELS.SEARCH_CHANNEL' | translate"
[(ngModel)]="searchTerm.name"
/>
</div>
<mat-divider></mat-divider>
<mat-nav-list>
<app-channel-list-item
*ngFor="
let channel of _channelList
| filterBy: searchTerm.name : 'name';
index as i;
trackBy: trackByFn
"
[name]="
i +
1 +
'. ' +
(channel?.name || 'CHANNELS.UNNAMED_CHANNEL' | translate)
"
[logo]="channel?.tvg?.logo"
(clicked)="selectChannel(channel)"
[selected]="selected?.id === channel?.id"
></app-channel-list-item>
</mat-nav-list>
</mat-tab>
<mat-tab>
<ng-template mat-tab-label>
<mat-icon class="tab-icon">category</mat-icon>
<div class="tab-title">
{{ 'CHANNELS.GROUPS' | translate }}
</div>
</ng-template>
<mat-nav-list id="groups-list">
<mat-accordion multi>
<ng-container *ngFor="let groups of groupedChannels | keyvalue">
<mat-expansion-panel>
<mat-expansion-panel-header
*ngIf="groups.value.length > 0"
>
{{
groups.key || ('CHANNELS.UNGROUPED' | translate)
| titlecase
}}
({{ groups.value.length }})
</mat-expansion-panel-header>
<ng-template matExpansionPanelContent>
<ng-container
*ngFor="
let channel of groups.value;
index as i;
trackBy: trackByFn
"
>
<app-channel-list-item
[name]="
i +
1 +
'. ' +
(channel?.name ||
'CHANNELS.UNNAMED_CHANNEL'
| translate)
"
[logo]="channel?.tvg?.logo"
(clicked)="selectChannel(channel)"
[selected]="selected?.id === channel.id"
></app-channel-list-item>
</ng-container>
</ng-template>
</mat-expansion-panel>
</ng-container>
</mat-accordion>
</mat-nav-list>
</mat-tab>
<mat-tab *ngIf="(playlistId$ | async) !== 'GLOBAL_FAVORITES'">
<ng-template mat-tab-label>
<mat-icon class="tab-icon">star</mat-icon>
<div class="tab-title">
{{ 'CHANNELS.FAVORITES' | translate }}
</div>
</ng-template>
<mat-nav-list
cdkDropList
*ngIf="favorites$ | async as favorites"
(cdkDropListDropped)="drop($event, favorites)"
id="favorites-list"
>
<ng-container *ngIf="favorites.length > 0; else noFavorites">
<mat-divider></mat-divider>
<mat-nav-list>
<app-channel-list-item
*ngFor="
let channel of favorites;
let channel of _channelList
| filterBy: searchTerm.name : 'name';
index as i;
trackBy: trackByFn
"
@@ -113,26 +32,117 @@
(channel?.name || 'CHANNELS.UNNAMED_CHANNEL'
| translate)
"
[isDraggable]="true"
[logo]="channel?.tvg?.logo"
(clicked)="selectChannel(channel)"
[selected]="selected?.id === channel?.id"
[showFavoriteButton]="true"
(favoriteToggled)="toggleFavoriteChannel(channel, $event)"
></app-channel-list-item>
</ng-container>
<ng-template #noFavorites>
<mat-list-item
><strong>{{
'CHANNELS.NO_FAVORITES' | translate
}}</strong></mat-list-item
>
<mat-list-item>
{{
'CHANNELS.USE_STAR_TO_FAVORITE' | translate
}}</mat-list-item
>
</mat-nav-list>
</mat-tab>
<mat-tab>
<ng-template mat-tab-label>
<mat-icon class="tab-icon">category</mat-icon>
<div class="tab-title">
{{ 'CHANNELS.GROUPS' | translate }}
</div>
</ng-template>
</mat-nav-list>
</mat-tab>
</mat-tab-group>
<mat-nav-list id="groups-list">
<mat-accordion multi>
<ng-container
*ngFor="let groups of groupedChannels | keyvalue"
>
<mat-expansion-panel>
<mat-expansion-panel-header
*ngIf="groups.value.length > 0"
>
{{
groups.key ||
('CHANNELS.UNGROUPED' | translate)
| titlecase
}}
({{ groups.value.length }})
</mat-expansion-panel-header>
<ng-template matExpansionPanelContent>
<ng-container
*ngFor="
let channel of groups.value;
index as i;
trackBy: trackByFn
"
>
<app-channel-list-item
[name]="
i +
1 +
'. ' +
(channel?.name ||
'CHANNELS.UNNAMED_CHANNEL'
| translate)
"
[logo]="channel?.tvg?.logo"
(clicked)="selectChannel(channel)"
[selected]="selected?.id === channel.id"
></app-channel-list-item>
</ng-container>
</ng-template>
</mat-expansion-panel>
</ng-container>
</mat-accordion>
</mat-nav-list>
</mat-tab>
<mat-tab *ngIf="(playlistId$ | async) !== 'GLOBAL_FAVORITES'">
<ng-template mat-tab-label>
<mat-icon class="tab-icon">star</mat-icon>
<div class="tab-title">
{{ 'CHANNELS.FAVORITES' | translate }}
</div>
</ng-template>
<mat-nav-list
cdkDropList
*ngIf="favorites$ | async as favorites"
(cdkDropListDropped)="drop($event, favorites)"
id="favorites-list"
>
<ng-container *ngIf="favorites.length > 0; else noFavorites">
<app-channel-list-item
*ngFor="
let channel of favorites;
index as i;
trackBy: trackByFn
"
[name]="
i +
1 +
'. ' +
(channel?.name || 'CHANNELS.UNNAMED_CHANNEL'
| translate)
"
[isDraggable]="true"
[logo]="channel?.tvg?.logo"
(clicked)="selectChannel(channel)"
[selected]="selected?.id === channel?.id"
[showFavoriteButton]="true"
(favoriteToggled)="
toggleFavoriteChannel(channel, $event)
"
></app-channel-list-item>
</ng-container>
<ng-template #noFavorites>
<mat-list-item
><strong>{{
'CHANNELS.NO_FAVORITES' | translate
}}</strong></mat-list-item
>
<mat-list-item>
{{
'CHANNELS.USE_STAR_TO_FAVORITE' | translate
}}</mat-list-item
>
</ng-template>
</mat-nav-list>
</mat-tab>
</mat-tab-group>
} @else {
No channels there
}
@@ -70,17 +70,18 @@ export class ChannelListContainerComponent {
);
/** List with favorites */
favorites$ = this.store
.select(selectFavorites)
.pipe(
map((favoriteChannelIds) =>
favorites$ = this.store.select(selectFavorites).pipe(
map(
(
favoriteChannelIds // TODO: move to selector
) =>
favoriteChannelIds.map((favoriteChannelId) =>
this.channelList.find(
(channel) => channel.id === favoriteChannelId
)
)
)
);
)
);
constructor(
private readonly store: Store,
@@ -129,4 +130,8 @@ export class ChannelListContainerComponent {
})
);
}
ngOnDestroy() {
this.store.dispatch(PlaylistActions.setChannels({ channels: [] }));
}
}
@@ -17,7 +17,7 @@
<div class="channels-count">
{{
sidebarView === 'CHANNELS'
? channels.length +
? channels?.length +
' ' +
('HOME.PLAYLISTS.CHANNELS' | translate)
: ('HOME.PLAYLISTS.MY_PLAYLISTS_SUBTITLE' | translate)
@@ -38,16 +38,13 @@
</div>
</div>
<mat-divider></mat-divider>
<app-channel-list-container
*ngIf="sidebarView === 'CHANNELS'; else playlistsTemplate"
[channelList]="channels"
>
</app-channel-list-container>
<ng-template #playlistsTemplate>
@if (sidebarView === 'CHANNELS') {
<app-channel-list-container [channelList]="channels" />
} @else {
<app-recent-playlists
class="recent-playlists"
[sidebarMode]="true"
[class.electron]="dataService.isElectron"
(playlistClicked)="selectPlaylist()"
></app-recent-playlists>
</ng-template>
/>
}
@@ -1,16 +1,13 @@
<mat-drawer-container class="main-container">
<!-- sidebar content -->
<mat-drawer #drawer mode="side" opened disableClose>
<app-sidebar
*ngIf="channels$ | async as channels; else noChannels"
[channels]="channels"
/>
<ng-template #noChannels
>No channels <br />
<button mat-button color="secondary" (click)="navigateHome()">
Go back
</button></ng-template
>
@defer {
<app-sidebar [channels]="channels$ | async" />
} @loading {
<p>Loading...</p>
} @placeholder {
<p>Loading...</p>
}
</mat-drawer>
<mat-drawer-content>
<ng-container *ngIf="activeChannel$ | async as activeChannel">
+25 -3
View File
@@ -4,12 +4,21 @@ import {
FormArray,
FormBuilder,
FormControl,
FormsModule,
ReactiveFormsModule,
Validators,
} from '@angular/forms';
import { MatButtonModule } from '@angular/material/button';
import { MatCheckboxModule } from '@angular/material/checkbox';
import { MatDividerModule } from '@angular/material/divider';
import { MatIconModule } from '@angular/material/icon';
import { MatInputModule } from '@angular/material/input';
import { MatSelectModule } from '@angular/material/select';
import { MatSnackBar } from '@angular/material/snack-bar';
import { MatTooltipModule } from '@angular/material/tooltip';
import { Router } from '@angular/router';
import { Store } from '@ngrx/store';
import { TranslateService } from '@ngx-translate/core';
import { TranslateModule, TranslateService } from '@ngx-translate/core';
import { take } from 'rxjs';
import * as semver from 'semver';
import {
@@ -22,8 +31,8 @@ import { DataService } from '../services/data.service';
import { DialogService } from '../services/dialog.service';
import { EpgService } from '../services/epg.service';
import { PlaylistsService } from '../services/playlists.service';
import { HeaderComponent } from '../shared/components/header/header.component';
import { STORE_KEY } from '../shared/enums/store-keys.enum';
import { SharedModule } from '../shared/shared.module';
import * as PlaylistActions from '../state/actions';
import { selectIsEpgAvailable } from '../state/selectors';
import { SettingsService } from './../services/settings.service';
@@ -35,7 +44,20 @@ import { Theme } from './theme.enum';
templateUrl: './settings.component.html',
styleUrls: ['./settings.component.scss'],
standalone: true,
imports: [CommonModule, SharedModule],
imports: [
CommonModule,
FormsModule,
HeaderComponent,
MatButtonModule,
MatCheckboxModule,
MatDividerModule,
MatIconModule,
MatInputModule,
MatSelectModule,
MatTooltipModule,
ReactiveFormsModule,
TranslateModule,
],
})
export class SettingsComponent implements OnInit {
/** List with available languages as enum */
@@ -6,14 +6,82 @@
<div class="subtitle">{{ subtitle }}</div>
</div>
<div class="menu">
<button
mat-icon-button
class="add-playlist-btn"
[matMenuTriggerFor]="addPlaylistMenu"
[matTooltip]="'HOME.URL_UPLOAD.ADD_PLAYLIST' | translate"
data-test-id="add-playlist"
>
<mat-icon>add</mat-icon>
</button>
<!-- <button
mat-icon-button
[matTooltip]="'HOME.FILTER_BY_NAME' | translate"
data-test-id="filter-playlist-by-name"
>
<mat-icon>search</mat-icon>
</button>
<button
mat-icon-button
[matTooltip]="'HOME.FILTER_BY_TYPE' | translate"
data-test-id="filter-playlist-by-type"
>
<mat-icon>filter_list</mat-icon>
</button> -->
<button
*ngIf="isElectron; else pwaMenu"
mat-icon-button
(click)="openSettings()"
[matTooltip]="'MENU.SETTINGS' | translate"
data-test-id="open-settings"
>
<mat-icon>settings</mat-icon>
</button>
<mat-menu #addPlaylistMenu="matMenu">
<button
mat-menu-item
[attr.aria-label]="'HOME.TABS.FILE_UPLOAD' | translate"
(click)="opedAddPlaylistDialog('file')"
>
<mat-icon>attach_file</mat-icon>
<span>{{ 'HOME.TABS.FILE_UPLOAD' | translate }}</span>
</button>
<button
mat-menu-item
[attr.aria-label]="'HOME.TABS.URL_UPLOAD' | translate"
(click)="opedAddPlaylistDialog('url')"
>
<mat-icon>link</mat-icon>
<span>{{ 'HOME.TABS.URL_UPLOAD' | translate }}</span>
</button>
<button
mat-menu-item
[attr.aria-label]="'HOME.TABS.TEXT_IMPORT' | translate"
(click)="opedAddPlaylistDialog('text')"
>
<mat-icon>text_format</mat-icon>
<span>{{ 'HOME.TABS.TEXT_IMPORT' | translate }}</span>
</button>
<mat-divider></mat-divider>
<button
mat-menu-item
[attr.aria-label]="'HOME.TABS.XTREME_IMPORT' | translate"
(click)="opedAddPlaylistDialog('xtream')"
>
<mat-icon>video_library</mat-icon>
<span>{{ 'HOME.TABS.XTREME_IMPORT' | translate }}</span>
</button>
<button
mat-menu-item
[attr.aria-label]="'HOME.TABS.STALKER_PORTAL_IMPORT' | translate"
(click)="opedAddPlaylistDialog('stalker')"
>
<mat-icon>dashboard</mat-icon>
<span>{{ 'HOME.TABS.STALKER_PORTAL_IMPORT' | translate }}</span>
</button>
</mat-menu>
</div>
<ng-template #pwaMenu>
@@ -32,7 +32,15 @@
.menu {
place-content: center;
align-self: center;
min-width: 50px;
max-width: 50px;
display: flex;
}
.add-playlist-btn {
background-color: #3f51b5;
}
@media (max-width: 500px) {
.main {
display: none;
}
}
@@ -1,15 +1,40 @@
import { AsyncPipe, NgFor, NgIf } from '@angular/common';
import { Component, Input, OnInit } from '@angular/core';
import { MatButtonModule } from '@angular/material/button';
import { MatDialog } from '@angular/material/dialog';
import { MatDividerModule } from '@angular/material/divider';
import { MatIconModule } from '@angular/material/icon';
import { MatMenuModule } from '@angular/material/menu';
import { MatTooltipModule } from '@angular/material/tooltip';
import { ActivatedRoute, Router } from '@angular/router';
import { TranslateModule } from '@ngx-translate/core';
import { NgxWhatsNewModule } from 'ngx-whats-new';
import { HomeComponent } from '../../../home/home.component';
import { DataService } from '../../../services/data.service';
import { WhatsNewService } from '../../../services/whats-new.service';
import { AboutDialogComponent } from '../about-dialog/about-dialog.component';
import {
AddPlaylistDialogComponent,
PlaylistType,
} from '../add-playlist/add-playlist-dialog.component';
@Component({
standalone: true,
selector: 'app-header',
templateUrl: './header.component.html',
styleUrls: ['./header.component.scss'],
imports: [
AsyncPipe,
NgIf,
NgFor,
MatButtonModule,
MatIconModule,
MatMenuModule,
MatDividerModule,
MatTooltipModule,
NgxWhatsNewModule,
TranslateModule,
],
})
export class HeaderComponent implements OnInit {
/** Title of the header */
@@ -82,4 +107,14 @@ export class HeaderComponent implements OnInit {
data: this.dataService.getAppVersion(),
});
}
opedAddPlaylistDialog(type: PlaylistType) {
this.dialog.open<AddPlaylistDialogComponent, { type: PlaylistType }>(
AddPlaylistDialogComponent,
{
width: '600px',
data: { type },
}
);
}
}
-3
View File
@@ -8,13 +8,11 @@ import { NgxWhatsNewModule } from 'ngx-whats-new';
import { PlaylistItemComponent } from '../home/recent-playlists/playlist-item/playlist-item.component';
import { RecentPlaylistsComponent } from '../home/recent-playlists/recent-playlists.component';
import { MaterialModule } from '../material.module';
import { HeaderComponent } from './components/';
import { FilterPipe } from './pipes/filter.pipe';
import { MomentDatePipe } from './pipes/moment-date.pipe';
@NgModule({
declarations: [
HeaderComponent,
MomentDatePipe,
RecentPlaylistsComponent,
PlaylistItemComponent,
@@ -37,7 +35,6 @@ import { MomentDatePipe } from './pipes/moment-date.pipe';
DragDropModule,
FilterPipe,
FormsModule,
HeaderComponent,
MaterialModule,
NgxWhatsNewModule,
MomentDatePipe,
+1 -1
View File
@@ -87,7 +87,7 @@ textarea,
}
.recent-playlists {
height: calc(100vh - 152px - 28px) !important;
height: calc(100vh - 106px - 28px) !important;
}
#groups-list,