feat(dashboard): implement context menu with actions for recent sources

This commit is contained in:
4gray committed 2026-04-24 14:27:04 +02:00
1 parent 02c37e9bda
commit efa0385fc8
15 files changed
+608 -71

No files matched your search

@@ -0,0 +1,95 @@
import { signal } from '@angular/core';
import type { WritableSignal } from '@angular/core';
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { MAT_DIALOG_DATA } from '@angular/material/dialog';
import { NoopAnimationsModule } from '@angular/platform-browser/animations';
import { TranslateModule } from '@ngx-translate/core';
import {
XtreamApiService,
XtreamStore,
} from '@iptvnator/portal/xtream/data-access';
import { AccountInfoComponent } from './account-info.component';
describe('AccountInfoComponent', () => {
let fixture: ComponentFixture<AccountInfoComponent>;
let component: AccountInfoComponent;
let xtreamApiService: {
getAccountInfo: jest.Mock;
};
let currentPlaylist: WritableSignal<null>;
beforeEach(async () => {
xtreamApiService = {
getAccountInfo: jest.fn().mockResolvedValue({
user_info: {
active_cons: '0',
allowed_output_formats: [],
max_connections: '0',
status: 'Active',
username: 'dialog-user',
},
server_info: {
server_protocol: 'http',
url: 'dialog.example.test',
},
}),
};
currentPlaylist = signal(null);
await TestBed.configureTestingModule({
imports: [
AccountInfoComponent,
NoopAnimationsModule,
TranslateModule.forRoot(),
],
providers: [
{
provide: MAT_DIALOG_DATA,
useValue: {
playlist: {
id: 'dialog-playlist',
title: 'Dialog Xtream',
serverUrl: 'https://dialog.example.test',
username: 'dialog-user',
password: 'dialog-secret',
},
},
},
{
provide: XtreamApiService,
useValue: xtreamApiService,
},
{
provide: XtreamStore,
useValue: {
currentPlaylist,
},
},
],
}).compileComponents();
fixture = TestBed.createComponent(AccountInfoComponent);
component = fixture.componentInstance;
fixture.detectChanges();
await fixture.whenStable();
});
it('loads account info from dialog-supplied playlist credentials', () => {
expect(xtreamApiService.getAccountInfo).toHaveBeenCalledWith({
serverUrl: 'https://dialog.example.test',
username: 'dialog-user',
password: 'dialog-secret',
});
expect(component.loadState()).toBe('ready');
expect(component.playlistLabel()).toBe('Dialog Xtream');
});
it('shows unknown content counts when dashboard does not supply them', () => {
expect(component.heroStats().map((stat) => stat.value)).toEqual([
'0/0',
'-',
'-',
'-',
]);
});
});
@@ -15,6 +15,7 @@ import {
XtreamStore,
} from '@iptvnator/portal/xtream/data-access';
import { createLogger } from '@iptvnator/portal/shared/util';
import type { XtreamAccountInfoDialogData } from 'shared-interfaces';
type AccountLoadState = 'loading' | 'ready' | 'error';
@@ -46,23 +47,21 @@ interface AccountPort {
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class AccountInfoComponent {
readonly data = inject<{
vodStreamsCount: number;
liveStreamsCount: number;
seriesCount: number;
}>(MAT_DIALOG_DATA);
readonly data =
inject<XtreamAccountInfoDialogData | null>(MAT_DIALOG_DATA, {
optional: true,
}) ?? {};
private readonly xtreamApiService = inject(XtreamApiService);
private readonly xtreamStore = inject(XtreamStore);
private readonly logger = createLogger('XtreamAccountInfo');
readonly currentPlaylist = this.xtreamStore.currentPlaylist;
readonly currentPlaylist = computed(
() => this.data.playlist ?? this.xtreamStore.currentPlaylist()
);
readonly loadState = signal<AccountLoadState>('loading');
readonly accountInfo = signal<XtreamAccountInfo | null>(null);
readonly skeletonStats = [1, 2, 3, 4];
readonly skeletonPanels = [1, 2];
readonly vodStreamsCount = this.data.vodStreamsCount;
readonly liveStreamsCount = this.data.liveStreamsCount;
readonly seriesCount = this.data.seriesCount;
readonly isActive = computed(
() => this.accountInfo()?.user_info?.status === 'Active'
@@ -76,6 +75,7 @@ export class AccountInfoComponent {
return (
playlist?.title ||
playlist?.name ||
info?.server_info?.url ||
info?.user_info?.username ||
'-'
@@ -103,7 +103,8 @@ export class AccountInfoComponent {
);
});
readonly activeConnectionsLabel = computed(
() => `${this.activeConnections()}/${Math.max(this.maxConnections(), 0)}`
() =>
`${this.activeConnections()}/${Math.max(this.maxConnections(), 0)}`
);
readonly formattedExpDate = computed(() =>
this.formatUnixDate(this.accountInfo()?.user_info?.exp_date)
@@ -142,19 +143,19 @@ export class AccountInfoComponent {
{
icon: 'live_tv',
labelKey: 'XTREAM.ACCOUNT_INFO.LIVE_TV',
value: String(this.liveStreamsCount),
value: this.formatOptionalCount(this.data.liveStreamsCount),
meter: null,
},
{
icon: 'movie',
labelKey: 'XTREAM.ACCOUNT_INFO.MOVIES',
value: String(this.vodStreamsCount),
value: this.formatOptionalCount(this.data.vodStreamsCount),
meter: null,
},
{
icon: 'tv',
labelKey: 'XTREAM.ACCOUNT_INFO.TV_SERIES',
value: String(this.seriesCount),
value: this.formatOptionalCount(this.data.seriesCount),
meter: null,
},
]);
@@ -219,7 +220,7 @@ export class AccountInfoComponent {
async reload(): Promise<void> {
const playlist = this.currentPlaylist();
if (!playlist) {
if (!playlist?.serverUrl || !playlist.username || !playlist.password) {
this.loadState.set('error');
this.accountInfo.set(null);
return;
@@ -257,4 +258,8 @@ export class AccountInfoComponent {
const parsed = Number.parseInt(value ?? '', 10);
return Number.isFinite(parsed) ? parsed : 0;
}
private formatOptionalCount(value?: number): string {
return Number.isFinite(value) ? String(value) : '-';
}
}
+4 -3
View File
@@ -1,7 +1,7 @@
export * from './lib/channel.interface';
export * from './lib/channel.model';
export * from './lib/epg-channel-with-programs.interface';
export * from './lib/epg-channel-metadata.model';
export * from './lib/epg-channel-with-programs.interface';
export * from './lib/epg-channel.model';
export * from './lib/epg-item.interface';
export * from './lib/epg-program.model';
@@ -14,9 +14,9 @@ export * from './lib/parsed-playlist.interface';
export * from './lib/playback-position.interface';
export * from './lib/playlist-backup.interface';
export * from './lib/playlist-meta.type';
export * from './lib/playlist-refresh.interface';
export * from './lib/playlist-recently-viewed.interface';
export * from './lib/playlist-recently-viewed.utils';
export * from './lib/playlist-refresh.interface';
export * from './lib/playlist.interface';
export * from './lib/portal-activity-item.interface';
export * from './lib/portal-debug.interface';
@@ -26,12 +26,13 @@ export * from './lib/stalker-portal-actions.enum';
export * from './lib/store-keys.enum';
export * from './lib/stream-format.enum';
export * from './lib/theme.enum';
export * from './lib/xtream-restore-state.util';
export * from './lib/xtream-account-info-dialog-data.interface';
export * from './lib/xtream-category.interface';
export * from './lib/xtream-code-actions';
export * from './lib/xtream-item.interface';
export * from './lib/xtream-live-stream.interface';
export * from './lib/xtream-response.interface';
export * from './lib/xtream-restore-state.util';
export * from './lib/xtream-serie-details.interface';
export * from './lib/xtream-serie-item.interface';
export * from './lib/xtream-vod-details.interface';
@@ -0,0 +1,15 @@
export interface XtreamAccountInfoDialogPlaylist {
id: string;
name?: string;
title?: string;
serverUrl: string;
username: string;
password: string;
}
export interface XtreamAccountInfoDialogData {
vodStreamsCount?: number;
liveStreamsCount?: number;
seriesCount?: number;
playlist?: XtreamAccountInfoDialogPlaylist;
}
@@ -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);
@@ -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)"
/>
}
@@ -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']);
});
});
@@ -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;