mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-08 17:06: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,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) : '-';
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
+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