mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-10 18:36:15 -08:00
feat(dashboard): implement context menu with actions for recent sources
This commit is contained in:
1 parent
02c37e9bda
commit
efa0385fc8
15 files changed
+608
-71
No files matched your search
@@ -0,0 +1,22 @@
|
||||
export default {
|
||||
displayName: 'workspace-dashboard-feature',
|
||||
preset: '../../../../jest.preset.js',
|
||||
setupFilesAfterEnv: ['<rootDir>/src/test-setup.ts'],
|
||||
coverageDirectory: '../../../../coverage/libs/workspace/dashboard/feature',
|
||||
transform: {
|
||||
'^.+\\.(ts|mjs|js|html)$': [
|
||||
'jest-preset-angular',
|
||||
{
|
||||
tsconfig: '<rootDir>/tsconfig.spec.json',
|
||||
stringifyContentPathRegex: '\\.(html|svg)$',
|
||||
},
|
||||
],
|
||||
},
|
||||
transformIgnorePatterns: ['node_modules/(?!.*\\.mjs$)'],
|
||||
snapshotSerializers: [
|
||||
'jest-preset-angular/build/serializers/no-ng-attributes',
|
||||
'jest-preset-angular/build/serializers/ng-snapshot',
|
||||
'jest-preset-angular/build/serializers/html-comment',
|
||||
],
|
||||
moduleFileExtensions: ['ts', 'js', 'html'],
|
||||
};
|
||||
@@ -6,6 +6,14 @@
|
||||
"projectType": "library",
|
||||
"tags": ["scope:workspace", "domain:workspace", "type:feature"],
|
||||
"targets": {
|
||||
"test": {
|
||||
"executor": "@nx/jest:jest",
|
||||
"outputs": ["{workspaceRoot}/coverage/{projectRoot}"],
|
||||
"options": {
|
||||
"jestConfig": "libs/workspace/dashboard/feature/jest.config.ts",
|
||||
"tsConfig": "libs/workspace/dashboard/feature/tsconfig.spec.json"
|
||||
}
|
||||
},
|
||||
"lint": {
|
||||
"executor": "@nx/eslint:lint"
|
||||
}
|
||||
|
||||
@@ -12,9 +12,7 @@
|
||||
<a
|
||||
class="rail__see-all"
|
||||
[routerLink]="link"
|
||||
[attr.data-test-id]="
|
||||
testId() ? testId() + '-manage-all' : null
|
||||
"
|
||||
[attr.data-test-id]="testId() ? testId() + '-manage-all' : null"
|
||||
>
|
||||
<span class="rail__see-all-label">{{
|
||||
'WORKSPACE.DASHBOARD.MANAGE_ALL' | translate
|
||||
@@ -36,52 +34,94 @@
|
||||
<mat-icon>chevron_left</mat-icon>
|
||||
</button>
|
||||
|
||||
<div
|
||||
#track
|
||||
class="rail__track"
|
||||
(scroll)="onScroll()"
|
||||
role="list"
|
||||
>
|
||||
<div #track class="rail__track" (scroll)="onScroll()" role="list">
|
||||
@for (card of items(); track card.id) {
|
||||
<a
|
||||
<div
|
||||
class="rail__card"
|
||||
role="listitem"
|
||||
[routerLink]="card.link"
|
||||
[state]="card.state"
|
||||
[attr.title]="card.title"
|
||||
[attr.data-test-id]="testId() ? testId() + '-card' : null"
|
||||
>
|
||||
<div
|
||||
class="rail__art"
|
||||
[style.aspect-ratio]="aspectRatio()"
|
||||
<a
|
||||
class="rail__card-link"
|
||||
[routerLink]="card.link"
|
||||
[state]="card.state"
|
||||
>
|
||||
@if (card.imageUrl && !failedImages()[card.id]) {
|
||||
<img
|
||||
[src]="card.imageUrl"
|
||||
[alt]="card.title"
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
(error)="markFailed(card.id)"
|
||||
/>
|
||||
} @else {
|
||||
<div class="rail__art-fallback">
|
||||
<mat-icon>{{ card.icon }}</mat-icon>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
<div
|
||||
class="rail__art"
|
||||
[style.aspect-ratio]="aspectRatio()"
|
||||
>
|
||||
@if (card.imageUrl && !failedImages()[card.id]) {
|
||||
<img
|
||||
[src]="card.imageUrl"
|
||||
[alt]="card.title"
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
(error)="markFailed(card.id)"
|
||||
/>
|
||||
} @else {
|
||||
<div class="rail__art-fallback">
|
||||
<mat-icon>{{ card.icon }}</mat-icon>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
<div class="rail__meta">
|
||||
<span class="rail__card-title">{{ card.title }}</span>
|
||||
@if (card.subtitle) {
|
||||
<span class="rail__card-subtitle">{{
|
||||
card.subtitle
|
||||
<div class="rail__meta">
|
||||
<span class="rail__card-title">{{
|
||||
card.title
|
||||
}}</span>
|
||||
}
|
||||
</div>
|
||||
</a>
|
||||
@if (card.subtitle) {
|
||||
<span class="rail__card-subtitle">{{
|
||||
card.subtitle
|
||||
}}</span>
|
||||
}
|
||||
</div>
|
||||
</a>
|
||||
|
||||
@if (card.actions && card.actions.length > 0) {
|
||||
<button
|
||||
type="button"
|
||||
mat-icon-button
|
||||
class="rail__action-trigger"
|
||||
[matMenuTriggerFor]="cardActionMenu"
|
||||
[matMenuTriggerData]="{ card: card }"
|
||||
[attr.aria-label]="'SHOW_MORE' | translate"
|
||||
[attr.data-test-id]="
|
||||
testId() ? testId() + '-card-actions' : null
|
||||
"
|
||||
(click)="stopActionEvent($event)"
|
||||
>
|
||||
<mat-icon>more_vert</mat-icon>
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
<mat-menu
|
||||
#cardActionMenu="matMenu"
|
||||
panelClass="dashboard-rail-action-menu"
|
||||
xPosition="before"
|
||||
>
|
||||
<ng-template matMenuContent let-card="card">
|
||||
@for (action of card.actions ?? []; track action.id) {
|
||||
<button
|
||||
mat-menu-item
|
||||
type="button"
|
||||
[disabled]="action.disabled"
|
||||
[class.rail__menu-action--danger]="action.destructive"
|
||||
[class.rail__menu-action--separated]="
|
||||
action.separatorBefore
|
||||
"
|
||||
(click)="selectAction(card, action, $event)"
|
||||
>
|
||||
<mat-icon>{{ action.icon }}</mat-icon>
|
||||
<span>{{ action.labelKey | translate }}</span>
|
||||
</button>
|
||||
}
|
||||
</ng-template>
|
||||
</mat-menu>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
class="rail__chev rail__chev--right"
|
||||
|
||||
@@ -173,10 +173,9 @@
|
||||
max-width: var(--rail-card-width);
|
||||
min-width: 0;
|
||||
scroll-snap-align: start;
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
text-decoration: none;
|
||||
color: inherit;
|
||||
border-radius: var(--rail-radius);
|
||||
transition:
|
||||
@@ -198,6 +197,21 @@
|
||||
}
|
||||
}
|
||||
|
||||
.rail__card-link {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
min-width: 0;
|
||||
color: inherit;
|
||||
text-decoration: none;
|
||||
border-radius: inherit;
|
||||
|
||||
&:focus-visible {
|
||||
outline: 2px solid var(--app-selection-color, #2f7bff);
|
||||
outline-offset: 3px;
|
||||
}
|
||||
}
|
||||
|
||||
.rail__art {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
@@ -238,6 +252,52 @@
|
||||
}
|
||||
}
|
||||
|
||||
.rail__action-trigger {
|
||||
position: absolute;
|
||||
top: 8px;
|
||||
right: 8px;
|
||||
z-index: 3;
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
padding: 3px;
|
||||
border-radius: 50%;
|
||||
color: #fff;
|
||||
background: transparent;
|
||||
border: 0;
|
||||
box-shadow: none;
|
||||
opacity: 0.92;
|
||||
transition:
|
||||
opacity 0.15s ease,
|
||||
transform 0.15s ease;
|
||||
--mdc-icon-button-state-layer-size: 30px;
|
||||
|
||||
mat-icon {
|
||||
font-size: 18px;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
line-height: 18px;
|
||||
}
|
||||
|
||||
&:hover,
|
||||
&:focus-visible {
|
||||
opacity: 1;
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
}
|
||||
|
||||
.rail__menu-action--separated {
|
||||
border-top: 1px solid var(--app-separator);
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.rail__menu-action--danger {
|
||||
color: var(--mat-sys-error);
|
||||
|
||||
mat-icon {
|
||||
color: var(--mat-sys-error);
|
||||
}
|
||||
}
|
||||
|
||||
.rail__meta {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
@@ -5,15 +5,26 @@ import {
|
||||
ElementRef,
|
||||
OnDestroy,
|
||||
effect,
|
||||
input,
|
||||
output,
|
||||
signal,
|
||||
viewChild,
|
||||
input,
|
||||
} from '@angular/core';
|
||||
import { MatButtonModule } from '@angular/material/button';
|
||||
import { MatIcon } from '@angular/material/icon';
|
||||
import { MatMenuModule } from '@angular/material/menu';
|
||||
import { RouterLink } from '@angular/router';
|
||||
import { TranslatePipe } from '@ngx-translate/core';
|
||||
|
||||
export interface DashboardRailAction {
|
||||
id: string;
|
||||
labelKey: string;
|
||||
icon: string;
|
||||
destructive?: boolean;
|
||||
disabled?: boolean;
|
||||
separatorBefore?: boolean;
|
||||
}
|
||||
|
||||
export interface DashboardRailCard {
|
||||
id: string;
|
||||
title: string;
|
||||
@@ -22,11 +33,23 @@ export interface DashboardRailCard {
|
||||
icon: string;
|
||||
link: string[];
|
||||
state?: Record<string, unknown>;
|
||||
actions?: DashboardRailAction[];
|
||||
}
|
||||
|
||||
export interface DashboardRailActionSelection {
|
||||
action: DashboardRailAction;
|
||||
card: DashboardRailCard;
|
||||
}
|
||||
|
||||
@Component({
|
||||
selector: 'lib-dashboard-rail',
|
||||
imports: [MatButtonModule, MatIcon, RouterLink, TranslatePipe],
|
||||
imports: [
|
||||
MatButtonModule,
|
||||
MatIcon,
|
||||
MatMenuModule,
|
||||
RouterLink,
|
||||
TranslatePipe,
|
||||
],
|
||||
templateUrl: './dashboard-rail.component.html',
|
||||
styleUrl: './dashboard-rail.component.scss',
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
@@ -37,13 +60,15 @@ export class DashboardRailComponent implements AfterViewInit, OnDestroy {
|
||||
readonly seeAllLink = input<string[] | null>(null);
|
||||
readonly aspectRatio = input<string>('2 / 3');
|
||||
readonly testId = input<string | null>(null);
|
||||
readonly actionSelected = output<DashboardRailActionSelection>();
|
||||
/**
|
||||
* True total in the underlying dataset. Shown as a count badge next to
|
||||
* the rail label. Falls back to `items().length` when not supplied.
|
||||
*/
|
||||
readonly totalCount = input<number | null>(null);
|
||||
|
||||
private readonly track = viewChild.required<ElementRef<HTMLDivElement>>('track');
|
||||
private readonly track =
|
||||
viewChild.required<ElementRef<HTMLDivElement>>('track');
|
||||
|
||||
readonly canScrollLeft = signal(false);
|
||||
readonly canScrollRight = signal(false);
|
||||
@@ -65,7 +90,9 @@ export class DashboardRailComponent implements AfterViewInit, OnDestroy {
|
||||
ngAfterViewInit(): void {
|
||||
this.viewReady.set(true);
|
||||
this.updateScrollState();
|
||||
this.resizeObserver = new ResizeObserver(() => this.updateScrollState());
|
||||
this.resizeObserver = new ResizeObserver(() =>
|
||||
this.updateScrollState()
|
||||
);
|
||||
this.resizeObserver.observe(this.track().nativeElement);
|
||||
}
|
||||
|
||||
@@ -92,6 +119,19 @@ export class DashboardRailComponent implements AfterViewInit, OnDestroy {
|
||||
);
|
||||
}
|
||||
|
||||
stopActionEvent(event: Event): void {
|
||||
event.stopPropagation();
|
||||
}
|
||||
|
||||
selectAction(
|
||||
card: DashboardRailCard,
|
||||
action: DashboardRailAction,
|
||||
event: Event
|
||||
): void {
|
||||
event.stopPropagation();
|
||||
this.actionSelected.emit({ card, action });
|
||||
}
|
||||
|
||||
private updateScrollState(): void {
|
||||
const el = this.track().nativeElement;
|
||||
this.canScrollLeft.set(el.scrollLeft > 4);
|
||||
|
||||
+7
-2
@@ -42,7 +42,9 @@
|
||||
<a
|
||||
class="hero"
|
||||
data-test-id="dashboard-hero"
|
||||
[class.hero--with-image]="!!item.posterUrl || !!item.backdropUrl"
|
||||
[class.hero--with-image]="
|
||||
!!item.posterUrl || !!item.backdropUrl
|
||||
"
|
||||
[class.hero--has-backdrop]="item.hasBackdrop"
|
||||
[routerLink]="item.link"
|
||||
[state]="item.state ?? {}"
|
||||
@@ -51,7 +53,9 @@
|
||||
<span
|
||||
class="hero__backdrop"
|
||||
aria-hidden="true"
|
||||
[style.backgroundImage]="'url(' + item.backdropUrl + ')'"
|
||||
[style.backgroundImage]="
|
||||
'url(' + item.backdropUrl + ')'
|
||||
"
|
||||
></span>
|
||||
}
|
||||
<span class="hero__vignette" aria-hidden="true"></span>
|
||||
@@ -113,6 +117,7 @@
|
||||
[seeAllLink]="['/workspace/sources']"
|
||||
aspectRatio="16 / 9"
|
||||
[testId]="'dashboard-recent-sources-rail'"
|
||||
(actionSelected)="onSourceActionSelected($event)"
|
||||
/>
|
||||
}
|
||||
|
||||
|
||||
+55
@@ -0,0 +1,55 @@
|
||||
import type { PlaylistMeta } from 'shared-interfaces';
|
||||
import { buildDashboardSourceActions } from './workspace-dashboard-rails.component';
|
||||
|
||||
describe('buildDashboardSourceActions', () => {
|
||||
const basePlaylist = {
|
||||
_id: 'playlist-1',
|
||||
title: 'Playlist',
|
||||
importDate: '2026-04-24T08:00:00.000Z',
|
||||
autoRefresh: false,
|
||||
} as PlaylistMeta;
|
||||
|
||||
const actionIds = (playlist: PlaylistMeta, canRefresh: boolean) =>
|
||||
buildDashboardSourceActions(playlist, canRefresh).map(
|
||||
(action) => action.id
|
||||
);
|
||||
|
||||
it('exposes refresh, info, and remove for refreshable M3U sources', () => {
|
||||
const playlist = {
|
||||
...basePlaylist,
|
||||
url: 'https://example.com/playlist.m3u',
|
||||
} as PlaylistMeta;
|
||||
|
||||
expect(actionIds(playlist, true)).toEqual([
|
||||
'refresh',
|
||||
'playlist-info',
|
||||
'remove',
|
||||
]);
|
||||
});
|
||||
|
||||
it('exposes refresh, info, account, and remove for refreshable Xtream sources', () => {
|
||||
const playlist = {
|
||||
...basePlaylist,
|
||||
serverUrl: 'https://provider.example.test',
|
||||
username: 'demo',
|
||||
password: 'secret',
|
||||
} as PlaylistMeta;
|
||||
|
||||
expect(actionIds(playlist, true)).toEqual([
|
||||
'refresh',
|
||||
'playlist-info',
|
||||
'account-info',
|
||||
'remove',
|
||||
]);
|
||||
});
|
||||
|
||||
it('exposes info and remove for Stalker sources', () => {
|
||||
const playlist = {
|
||||
...basePlaylist,
|
||||
macAddress: '00:1A:79:00:00:01',
|
||||
portalUrl: 'https://stalker.example.test',
|
||||
} as PlaylistMeta;
|
||||
|
||||
expect(actionIds(playlist, false)).toEqual(['playlist-info', 'remove']);
|
||||
});
|
||||
});
|
||||
+170
-3
@@ -6,21 +6,34 @@ import {
|
||||
inject,
|
||||
} from '@angular/core';
|
||||
import { MatButtonModule } from '@angular/material/button';
|
||||
import { MatDialog } from '@angular/material/dialog';
|
||||
import { MatIcon } from '@angular/material/icon';
|
||||
import { MatSnackBar } from '@angular/material/snack-bar';
|
||||
import { RouterLink } from '@angular/router';
|
||||
import { Store } from '@ngrx/store';
|
||||
import { TranslatePipe, TranslateService } from '@ngx-translate/core';
|
||||
import { EmptyStateComponent } from '@iptvnator/playlist/shared/ui';
|
||||
import {
|
||||
EmptyStateComponent,
|
||||
PlaylistInfoComponent,
|
||||
} from '@iptvnator/playlist/shared/ui';
|
||||
import { PlaylistRefreshActionService } from '@iptvnator/playlist/shared/util';
|
||||
import { WORKSPACE_SHELL_ACTIONS } from '@iptvnator/workspace/shell/util';
|
||||
import { DialogService } from 'components';
|
||||
import { PlaylistActions } from 'm3u-state';
|
||||
import { DatabaseService } from 'services';
|
||||
import {
|
||||
DashboardDataService,
|
||||
DashboardFavoriteItem,
|
||||
DashboardRecentlyAddedItem,
|
||||
GlobalRecentItem,
|
||||
} from 'workspace-dashboard-data-access';
|
||||
import {
|
||||
import { DashboardRailComponent } from './dashboard-rail.component';
|
||||
import type {
|
||||
DashboardRailAction,
|
||||
DashboardRailCard,
|
||||
DashboardRailComponent,
|
||||
DashboardRailActionSelection,
|
||||
} from './dashboard-rail.component';
|
||||
import type { PlaylistMeta } from 'shared-interfaces';
|
||||
|
||||
// Cap dashboard rails at 20 items. Users get ~3× what's visible at once,
|
||||
// the DOM stays cheap, and the "Manage all" link is one click away for the
|
||||
@@ -43,6 +56,65 @@ interface DashboardHeroModel {
|
||||
readonly title: string;
|
||||
}
|
||||
|
||||
export type DashboardSourceActionId =
|
||||
| 'refresh'
|
||||
| 'playlist-info'
|
||||
| 'account-info'
|
||||
| 'remove';
|
||||
|
||||
export function buildDashboardSourceActions(
|
||||
playlist: PlaylistMeta,
|
||||
canRefresh: boolean
|
||||
): DashboardRailAction[] {
|
||||
const actions: DashboardRailAction[] = [];
|
||||
|
||||
if (canRefresh) {
|
||||
actions.push({
|
||||
id: 'refresh',
|
||||
icon: 'sync',
|
||||
labelKey: playlist.serverUrl
|
||||
? 'HOME.PLAYLISTS.REFRESH_XTREAM'
|
||||
: 'HOME.PLAYLISTS.REFRESH',
|
||||
});
|
||||
}
|
||||
|
||||
actions.push({
|
||||
id: 'playlist-info',
|
||||
icon: 'edit',
|
||||
labelKey: 'HOME.PLAYLISTS.SHOW_DETAILS',
|
||||
});
|
||||
|
||||
if (isXtreamAccountPlaylist(playlist)) {
|
||||
actions.push({
|
||||
id: 'account-info',
|
||||
icon: 'person',
|
||||
labelKey: 'WORKSPACE.SHELL.ACCOUNT_INFO',
|
||||
});
|
||||
}
|
||||
|
||||
actions.push({
|
||||
id: 'remove',
|
||||
icon: 'delete',
|
||||
labelKey: 'HOME.PLAYLISTS.REMOVE_DIALOG.TITLE',
|
||||
destructive: true,
|
||||
separatorBefore: true,
|
||||
});
|
||||
|
||||
return actions;
|
||||
}
|
||||
|
||||
function isXtreamAccountPlaylist(
|
||||
playlist: PlaylistMeta
|
||||
): playlist is PlaylistMeta & {
|
||||
serverUrl: string;
|
||||
username: string;
|
||||
password: string;
|
||||
} {
|
||||
return Boolean(
|
||||
playlist.serverUrl && playlist.username && playlist.password
|
||||
);
|
||||
}
|
||||
|
||||
@Component({
|
||||
selector: 'lib-workspace-dashboard-rails',
|
||||
imports: [
|
||||
@@ -59,6 +131,14 @@ interface DashboardHeroModel {
|
||||
})
|
||||
export class WorkspaceDashboardRailsComponent {
|
||||
readonly data = inject(DashboardDataService);
|
||||
private readonly databaseService = inject(DatabaseService);
|
||||
private readonly dialog = inject(MatDialog);
|
||||
private readonly dialogService = inject(DialogService);
|
||||
private readonly playlistRefreshAction = inject(
|
||||
PlaylistRefreshActionService
|
||||
);
|
||||
private readonly snackBar = inject(MatSnackBar);
|
||||
private readonly store = inject(Store);
|
||||
private readonly translate = inject(TranslateService);
|
||||
private readonly shellActions = inject(WORKSPACE_SHELL_ACTIONS);
|
||||
|
||||
@@ -122,6 +202,10 @@ export class WorkspaceDashboardRailsComponent {
|
||||
? 'cast'
|
||||
: 'folder_open',
|
||||
link: this.data.getPlaylistLink(playlist),
|
||||
actions: buildDashboardSourceActions(
|
||||
playlist,
|
||||
this.playlistRefreshAction.canRefresh(playlist)
|
||||
),
|
||||
}))
|
||||
);
|
||||
|
||||
@@ -147,6 +231,31 @@ export class WorkspaceDashboardRailsComponent {
|
||||
this.shellActions.openAddPlaylistDialog();
|
||||
}
|
||||
|
||||
onSourceActionSelected(selection: DashboardRailActionSelection): void {
|
||||
const playlist = this.data
|
||||
.playlists()
|
||||
.find((item) => item._id === selection.card.id);
|
||||
|
||||
if (!playlist) {
|
||||
return;
|
||||
}
|
||||
|
||||
switch (selection.action.id as DashboardSourceActionId) {
|
||||
case 'refresh':
|
||||
this.playlistRefreshAction.refresh(playlist);
|
||||
break;
|
||||
case 'playlist-info':
|
||||
this.dialog.open(PlaylistInfoComponent, { data: playlist });
|
||||
break;
|
||||
case 'account-info':
|
||||
this.openXtreamAccountInfo(playlist);
|
||||
break;
|
||||
case 'remove':
|
||||
this.confirmRemovePlaylist(playlist);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
private buildHeroSubtitle(item: GlobalRecentItem): string {
|
||||
const parts = [
|
||||
item.playlist_name,
|
||||
@@ -204,6 +313,64 @@ export class WorkspaceDashboardRailsComponent {
|
||||
return 'video_library';
|
||||
}
|
||||
|
||||
private openXtreamAccountInfo(playlist: PlaylistMeta): void {
|
||||
if (!isXtreamAccountPlaylist(playlist)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const title =
|
||||
playlist.title ||
|
||||
playlist.filename ||
|
||||
this.t('WORKSPACE.DASHBOARD.UNTITLED_SOURCE');
|
||||
|
||||
this.shellActions.openAccountInfo({
|
||||
playlist: {
|
||||
id: playlist._id,
|
||||
name: title,
|
||||
title,
|
||||
serverUrl: playlist.serverUrl,
|
||||
username: playlist.username,
|
||||
password: playlist.password,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
private confirmRemovePlaylist(playlist: PlaylistMeta): void {
|
||||
this.dialogService.openConfirmDialog({
|
||||
title: this.translate.instant('HOME.PLAYLISTS.REMOVE_DIALOG.TITLE'),
|
||||
message: this.translate.instant(
|
||||
'HOME.PLAYLISTS.REMOVE_DIALOG.MESSAGE'
|
||||
),
|
||||
onConfirm: () => {
|
||||
void this.removePlaylist(playlist);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
private async removePlaylist(playlist: PlaylistMeta): Promise<void> {
|
||||
const operationId = playlist.serverUrl
|
||||
? this.databaseService.createOperationId('playlist-delete')
|
||||
: undefined;
|
||||
|
||||
const deleted = await this.databaseService.deletePlaylist(
|
||||
playlist._id,
|
||||
operationId ? { operationId } : undefined
|
||||
);
|
||||
|
||||
if (!deleted) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.store.dispatch(
|
||||
PlaylistActions.removePlaylist({ playlistId: playlist._id })
|
||||
);
|
||||
this.snackBar.open(
|
||||
this.translate.instant('HOME.PLAYLISTS.REMOVE_DIALOG.SUCCESS'),
|
||||
undefined,
|
||||
{ duration: 2000 }
|
||||
);
|
||||
}
|
||||
|
||||
private t(key: string): string {
|
||||
return this.translate.instant(key);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
import { setupZoneTestEnv } from 'jest-preset-angular/setup-env/zone';
|
||||
|
||||
setupZoneTestEnv({
|
||||
errorOnUnknownElements: true,
|
||||
errorOnUnknownProperties: true,
|
||||
});
|
||||
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"extends": "./tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "../../../../dist/out-tsc",
|
||||
"module": "commonjs",
|
||||
"moduleResolution": "node10",
|
||||
"types": ["jest", "node"]
|
||||
},
|
||||
"include": [
|
||||
"jest.config.ts",
|
||||
"src/test-setup.ts",
|
||||
"src/**/*.test.ts",
|
||||
"src/**/*.spec.ts",
|
||||
"src/**/*.d.ts"
|
||||
]
|
||||
}
|
||||
@@ -1,14 +1,16 @@
|
||||
import { InjectionToken } from '@angular/core';
|
||||
import type { XtreamAccountInfoDialogData } from 'shared-interfaces';
|
||||
|
||||
export type WorkspacePlaylistType = 'xtream' | 'url' | 'text' | 'file' | 'stalker';
|
||||
export type WorkspacePlaylistType =
|
||||
| 'xtream'
|
||||
| 'url'
|
||||
| 'text'
|
||||
| 'file'
|
||||
| 'stalker';
|
||||
export type PlaylistCategory = 'm3u' | 'xtream' | 'stalker';
|
||||
export type M3uSubType = 'url' | 'file' | 'text';
|
||||
|
||||
export interface WorkspaceAccountInfoData {
|
||||
vodStreamsCount: number;
|
||||
liveStreamsCount: number;
|
||||
seriesCount: number;
|
||||
}
|
||||
export type WorkspaceAccountInfoData = XtreamAccountInfoDialogData;
|
||||
|
||||
export interface WorkspaceShellActions {
|
||||
openAddPlaylistDialog(type?: WorkspacePlaylistType): void;
|
||||
|
||||
Reference in new issue
Block a user