mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-15 05:36:17 -08:00
feat(sources): rebuild the Sources page on the shared source row
- Side list (UI-kit side list): title with the playlist count and an Add button, a Type group, and on desktop a Status group (Any / Responding / Not responding). The two filters combine and persist; each group counts within the other's choice. The chosen row uses one selected-row token (soft tint, outlined in light theme). - Page: a 28px title with the visible count instead of the bordered header bar; Clean up and the sort menu are secondary buttons. Clean up shows how many sources do not respond. Sort adds Last updated, Most channels and (desktop) Not responding first, and keeps Name Z-A and Custom order. - List: app-source-row with hover actions (Refresh, Edit, ⋯); a failing row keeps Retry in view. "No matches" offers Reset filters. Dragging in custom order no longer reuses positions of rows a filter hides. - Delete with Undo: no confirmation; the row leaves at once and the delete runs when the toast closes (its close button commits at once). Deleting the active source switches to the next row. A large Xtream delete shows its row as "Removing…" with progress and Cancel, as before. - The list's refresh flows moved into SourceListRefreshService; E2E and performance drivers follow the new markup and delete flow. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
028a3db880
commit
401295c092
70 files changed
+3837
-2023
No files matched your search
+79
-25
@@ -1,38 +1,92 @@
|
||||
<div class="context-column">
|
||||
<header class="context-header">
|
||||
<div class="context-header__top">
|
||||
<h2>{{ 'WORKSPACE.SOURCES.TITLE' | translate }}</h2>
|
||||
<div class="side-list">
|
||||
<header class="side-list__header">
|
||||
<div class="side-list__heading">
|
||||
<h2 class="side-list__title">
|
||||
{{ 'WORKSPACE.SOURCES.TITLE' | translate }}
|
||||
</h2>
|
||||
<p class="side-list__subtitle">
|
||||
@if (total() === 1) {
|
||||
{{ 'WORKSPACE.SOURCES.PLAYLIST_COUNT_ONE' | translate }}
|
||||
} @else {
|
||||
{{
|
||||
'WORKSPACE.SOURCES.PLAYLIST_COUNT_OTHER'
|
||||
| translate: { count: total() }
|
||||
}}
|
||||
}
|
||||
</p>
|
||||
</div>
|
||||
<p>{{ 'WORKSPACE.SOURCES.SUBTITLE' | translate }}</p>
|
||||
<button
|
||||
mat-icon-button
|
||||
type="button"
|
||||
class="side-list__action"
|
||||
data-test-id="sources-add-playlist"
|
||||
(click)="addPlaylist()"
|
||||
[matTooltip]="'WORKSPACE.SHELL.ADD_PLAYLIST' | translate"
|
||||
[attr.aria-label]="'WORKSPACE.SHELL.ADD_PLAYLIST' | translate"
|
||||
>
|
||||
<mat-icon>add</mat-icon>
|
||||
</button>
|
||||
</header>
|
||||
|
||||
<div class="context-divider"></div>
|
||||
|
||||
<section class="panel-section">
|
||||
<div class="nav-list option-list">
|
||||
<h3 class="panel-section-title">
|
||||
<div class="side-list__groups">
|
||||
<section
|
||||
class="side-list__group"
|
||||
role="group"
|
||||
aria-labelledby="sources-type-label"
|
||||
>
|
||||
<h3 id="sources-type-label" class="side-list__label">
|
||||
{{ 'WORKSPACE.SOURCES.TYPE' | translate }}
|
||||
</h3>
|
||||
@for (option of typeOptions; track option.id) {
|
||||
<button
|
||||
type="button"
|
||||
class="nav-item category-item option-row"
|
||||
[class.selected]="isTypeActive(option.id)"
|
||||
class="side-list__row option-row"
|
||||
[attr.data-filter]="option.id"
|
||||
[attr.aria-pressed]="filter().type === option.id"
|
||||
(click)="selectType(option.id)"
|
||||
>
|
||||
<mat-icon>{{ option.icon }}</mat-icon>
|
||||
<span class="nav-item-label option-label">
|
||||
{{
|
||||
option.translationKey
|
||||
? (option.translationKey | translate)
|
||||
: option.label
|
||||
}}
|
||||
</span>
|
||||
<div class="nav-item-meta option-meta">
|
||||
{{ getTypeCount(option.id) }}
|
||||
</div>
|
||||
<mat-icon aria-hidden="true">{{ option.icon }}</mat-icon>
|
||||
<span class="side-list__row-label option-label">{{
|
||||
option.labelKey | translate
|
||||
}}</span>
|
||||
<span class="side-list__row-count">{{
|
||||
typeCounts()[option.id]
|
||||
}}</span>
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
</section>
|
||||
</section>
|
||||
|
||||
@if (showStatus) {
|
||||
<section
|
||||
class="side-list__group"
|
||||
role="group"
|
||||
aria-labelledby="sources-status-label"
|
||||
>
|
||||
<h3 id="sources-status-label" class="side-list__label">
|
||||
{{ 'WORKSPACE.SOURCES.STATUS' | translate }}
|
||||
</h3>
|
||||
@for (option of statusOptions; track option.id) {
|
||||
<button
|
||||
type="button"
|
||||
class="side-list__row option-row"
|
||||
[attr.data-filter]="'status-' + option.id"
|
||||
[attr.aria-pressed]="filter().status === option.id"
|
||||
(click)="selectStatus(option.id)"
|
||||
>
|
||||
<span
|
||||
class="side-list__dot"
|
||||
[attr.data-state]="option.id"
|
||||
aria-hidden="true"
|
||||
></span>
|
||||
<span class="side-list__row-label option-label">{{
|
||||
option.labelKey | translate
|
||||
}}</span>
|
||||
<span class="side-list__row-count">{{
|
||||
statusCounts()[option.id]
|
||||
}}</span>
|
||||
</button>
|
||||
}
|
||||
</section>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
+53
-67
@@ -1,4 +1,5 @@
|
||||
@use '../../nav-list.scss';
|
||||
@use '../../../../../../ui/styles/app-controls' as controls;
|
||||
@use '../../../../../../ui/styles/side-list';
|
||||
|
||||
:host {
|
||||
display: flex;
|
||||
@@ -7,7 +8,7 @@
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.context-column {
|
||||
.side-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
@@ -15,88 +16,73 @@
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
// ─── Header ───────────────────────────────────────────────────────────────────
|
||||
.context-header {
|
||||
padding: 4px 6px 10px;
|
||||
.side-list__header {
|
||||
@include side-list.header;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.context-header__top {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
|
||||
h2 {
|
||||
margin: 0;
|
||||
font-size: 0.94rem;
|
||||
font-weight: 600;
|
||||
letter-spacing: -0.01em;
|
||||
color: var(--mat-sys-on-surface);
|
||||
}
|
||||
.side-list__heading {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.context-header p {
|
||||
margin: 4px 0 0;
|
||||
font-size: 0.78rem;
|
||||
color: var(--mat-sys-on-surface-variant);
|
||||
.side-list__title {
|
||||
@include side-list.title;
|
||||
}
|
||||
|
||||
// ─── Divider ──────────────────────────────────────────────────────────────────
|
||||
.context-divider {
|
||||
height: 1px;
|
||||
background: var(--mat-sys-outline-variant);
|
||||
margin: 0 0 6px;
|
||||
opacity: 0.6;
|
||||
flex-shrink: 0;
|
||||
.side-list__subtitle {
|
||||
@include side-list.subtitle;
|
||||
}
|
||||
|
||||
.panel-section {
|
||||
.side-list__action {
|
||||
@include controls.ghost-icon-button(32px, 18px);
|
||||
flex: none;
|
||||
}
|
||||
|
||||
.side-list__groups {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 14px;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.option-list {
|
||||
overflow-y: auto;
|
||||
min-height: 0;
|
||||
scrollbar-width: thin;
|
||||
}
|
||||
|
||||
.option-row {
|
||||
border-radius: 12px;
|
||||
transition:
|
||||
box-shadow 0.18s ease,
|
||||
transform 0.18s ease;
|
||||
.side-list__group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1px;
|
||||
}
|
||||
|
||||
.option-label,
|
||||
.option-meta,
|
||||
mat-icon {
|
||||
transition: color 0.18s ease, opacity 0.18s ease;
|
||||
.side-list__label {
|
||||
@include side-list.label;
|
||||
}
|
||||
|
||||
.side-list__row {
|
||||
@include side-list.row;
|
||||
}
|
||||
|
||||
.side-list__row-label {
|
||||
@include side-list.row-label;
|
||||
}
|
||||
|
||||
.side-list__row-count {
|
||||
@include side-list.row-count;
|
||||
}
|
||||
|
||||
// The status rows lead with a dot in the colour the source rows use.
|
||||
.side-list__dot {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
margin: 0 4px;
|
||||
border-radius: 50%;
|
||||
background: var(--app-muted-color);
|
||||
|
||||
&[data-state='ok'] {
|
||||
background: var(--app-success-color);
|
||||
}
|
||||
|
||||
&.selected {
|
||||
box-shadow: 0 10px 20px -18px var(--app-selection-glow);
|
||||
transform: translateY(-1px);
|
||||
|
||||
.option-label,
|
||||
.option-meta,
|
||||
mat-icon {
|
||||
color: var(--app-selection-color) !important;
|
||||
}
|
||||
|
||||
.option-meta {
|
||||
opacity: 0.95;
|
||||
}
|
||||
&[data-state='bad'] {
|
||||
background: var(--app-live-color);
|
||||
}
|
||||
}
|
||||
|
||||
.panel-section-title {
|
||||
margin: 0;
|
||||
padding: 10px 10px 6px;
|
||||
font-size: 0.72rem;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.08em;
|
||||
color: var(--mat-sys-on-surface-variant);
|
||||
opacity: 0.85;
|
||||
}
|
||||
+149
@@ -0,0 +1,149 @@
|
||||
import { signal } from '@angular/core';
|
||||
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
||||
import { provideMockStore } from '@ngrx/store/testing';
|
||||
import { TranslateModule } from '@ngx-translate/core';
|
||||
import { selectAllPlaylistsMeta } from '@iptvnator/m3u-state';
|
||||
import { SourceStatusService } from '@iptvnator/portal/shared/data-access';
|
||||
import { SourceRemovalService } from '@iptvnator/playlist/shared/ui';
|
||||
import { SourcesViewStateService } from '@iptvnator/playlist/shared/util';
|
||||
import { PlaylistMeta } from '@iptvnator/shared/interfaces';
|
||||
import { WORKSPACE_SHELL_ACTIONS } from '@iptvnator/workspace/shell/util';
|
||||
import { WorkspaceSourcesFiltersPanelComponent } from './workspace-sources-filters-panel.component';
|
||||
|
||||
const PLAYLISTS = [
|
||||
{ _id: 'm3u', title: 'a', url: 'https://a.example/list.m3u' },
|
||||
{ _id: 'file', title: 'b' },
|
||||
{ _id: 'xtream', title: 'c', serverUrl: 'http://x.example' },
|
||||
{ _id: 'stalker', title: 'd', macAddress: 'm', portalUrl: 'http://p' },
|
||||
] as PlaylistMeta[];
|
||||
const BUCKETS: Record<string, string> = {
|
||||
m3u: 'ok',
|
||||
xtream: 'bad',
|
||||
stalker: 'ok',
|
||||
};
|
||||
|
||||
describe('WorkspaceSourcesFiltersPanelComponent', () => {
|
||||
let fixture: ComponentFixture<WorkspaceSourcesFiltersPanelComponent>;
|
||||
let supportsStatusFilter: boolean;
|
||||
let openAddPlaylistDialog: jest.Mock;
|
||||
|
||||
const render = () => {
|
||||
fixture = TestBed.createComponent(
|
||||
WorkspaceSourcesFiltersPanelComponent
|
||||
);
|
||||
fixture.detectChanges();
|
||||
return fixture.nativeElement as HTMLElement;
|
||||
};
|
||||
const rows = (element: HTMLElement) =>
|
||||
Array.from(
|
||||
element.querySelectorAll<HTMLButtonElement>('.side-list__row')
|
||||
);
|
||||
const counts = (element: HTMLElement) =>
|
||||
rows(element).map(
|
||||
(row) =>
|
||||
`${row.dataset['filter']}:${row.querySelector('.side-list__row-count')?.textContent?.trim()}`
|
||||
);
|
||||
|
||||
beforeEach(() => {
|
||||
localStorage.clear();
|
||||
supportsStatusFilter = true;
|
||||
openAddPlaylistDialog = jest.fn();
|
||||
TestBed.configureTestingModule({
|
||||
imports: [
|
||||
WorkspaceSourcesFiltersPanelComponent,
|
||||
TranslateModule.forRoot(),
|
||||
],
|
||||
providers: [
|
||||
provideMockStore({
|
||||
selectors: [
|
||||
{ selector: selectAllPlaylistsMeta, value: PLAYLISTS },
|
||||
],
|
||||
}),
|
||||
{
|
||||
provide: SourceStatusService,
|
||||
useValue: {
|
||||
get supportsStatusFilter() {
|
||||
return supportsStatusFilter;
|
||||
},
|
||||
statusOf: (p: PlaylistMeta) =>
|
||||
BUCKETS[p._id]
|
||||
? {
|
||||
reachability:
|
||||
BUCKETS[p._id] === 'ok'
|
||||
? 'ok'
|
||||
: 'bad',
|
||||
}
|
||||
: null,
|
||||
},
|
||||
},
|
||||
{
|
||||
provide: SourceRemovalService,
|
||||
useValue: { pendingIds: signal(new Set<string>()) },
|
||||
},
|
||||
{
|
||||
provide: WORKSPACE_SHELL_ACTIONS,
|
||||
useValue: { openAddPlaylistDialog },
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it('counts each group within the other group’s choice', () => {
|
||||
const element = render();
|
||||
expect(counts(element)).toEqual([
|
||||
'all:4',
|
||||
'm3u:2',
|
||||
'xtream:1',
|
||||
'stalker:1',
|
||||
'status-all:4',
|
||||
'status-ok:2',
|
||||
'status-bad:1',
|
||||
]);
|
||||
|
||||
rows(element)[6].click();
|
||||
fixture.detectChanges();
|
||||
expect(counts(element).slice(0, 4)).toEqual([
|
||||
'all:1',
|
||||
'm3u:0',
|
||||
'xtream:1',
|
||||
'stalker:0',
|
||||
]);
|
||||
expect(TestBed.inject(SourcesViewStateService).filter()).toEqual({
|
||||
type: 'all',
|
||||
status: 'bad',
|
||||
});
|
||||
});
|
||||
|
||||
it('marks the chosen row of each group as pressed', () => {
|
||||
const element = render();
|
||||
rows(element)[2].click();
|
||||
fixture.detectChanges();
|
||||
expect(
|
||||
rows(element).map((row) => row.getAttribute('aria-pressed'))
|
||||
).toEqual([
|
||||
'false',
|
||||
'false',
|
||||
'true',
|
||||
'false',
|
||||
'true',
|
||||
'false',
|
||||
'false',
|
||||
]);
|
||||
});
|
||||
|
||||
it('has no status group where source health is unknown', () => {
|
||||
supportsStatusFilter = false;
|
||||
const element = render();
|
||||
expect(rows(element)).toHaveLength(4);
|
||||
});
|
||||
|
||||
it('adds a playlist from the header', () => {
|
||||
const element = render();
|
||||
element
|
||||
.querySelector<HTMLButtonElement>(
|
||||
'[data-test-id="sources-add-playlist"]'
|
||||
)
|
||||
?.click();
|
||||
expect(openAddPlaylistDialog).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
+96
-69
@@ -4,112 +4,139 @@ import {
|
||||
computed,
|
||||
inject,
|
||||
} from '@angular/core';
|
||||
import { MatIconButton } from '@angular/material/button';
|
||||
import { MatIcon } from '@angular/material/icon';
|
||||
import { MatListModule } from '@angular/material/list';
|
||||
import { MatTooltip } from '@angular/material/tooltip';
|
||||
import { Store } from '@ngrx/store';
|
||||
import {
|
||||
FilterActions,
|
||||
selectActiveTypeFilters,
|
||||
selectAllPlaylistsMeta,
|
||||
} from '@iptvnator/m3u-state';
|
||||
import { TranslatePipe } from '@ngx-translate/core';
|
||||
import { WorkspaceShellContextDrawerService } from '@iptvnator/workspace/shell/util';
|
||||
import { SOURCE_TYPE_ICONS } from '@iptvnator/shared/interfaces';
|
||||
import { selectAllPlaylistsMeta } from '@iptvnator/m3u-state';
|
||||
import { SourceStatusService } from '@iptvnator/portal/shared/data-access';
|
||||
import { SourceRemovalService } from '@iptvnator/playlist/shared/ui';
|
||||
import {
|
||||
countSourcesByStatus,
|
||||
countSourcesByType,
|
||||
SourceBucketOf,
|
||||
SourcesStatusFilter,
|
||||
SourcesTypeFilter,
|
||||
SourcesViewStateService,
|
||||
} from '@iptvnator/playlist/shared/util';
|
||||
import {
|
||||
SOURCE_TYPE_ICONS,
|
||||
sourceStatusBucket,
|
||||
} from '@iptvnator/shared/interfaces';
|
||||
import {
|
||||
WORKSPACE_SHELL_ACTIONS,
|
||||
WorkspaceShellContextDrawerService,
|
||||
} from '@iptvnator/workspace/shell/util';
|
||||
|
||||
type PlaylistFilterId = 'all' | 'm3u' | 'xtream' | 'stalker';
|
||||
|
||||
interface PlaylistFilterOption {
|
||||
id: PlaylistFilterId;
|
||||
interface TypeOption {
|
||||
id: SourcesTypeFilter;
|
||||
icon: string;
|
||||
label?: string;
|
||||
translationKey?: string;
|
||||
labelKey: string;
|
||||
}
|
||||
|
||||
const ALL_FILTERS = ['m3u', 'xtream', 'stalker'];
|
||||
interface StatusOption {
|
||||
id: SourcesStatusFilter;
|
||||
labelKey: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* The Sources side list: Type and, on desktop where source health is known,
|
||||
* Status. Both are single-choice and combine; each group counts within the
|
||||
* other group's choice.
|
||||
*/
|
||||
@Component({
|
||||
selector: 'app-workspace-sources-filters-panel',
|
||||
imports: [MatIcon, MatListModule, TranslatePipe],
|
||||
imports: [MatIcon, MatIconButton, MatTooltip, TranslatePipe],
|
||||
templateUrl: './workspace-sources-filters-panel.component.html',
|
||||
styleUrl: './workspace-sources-filters-panel.component.scss',
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
})
|
||||
export class WorkspaceSourcesFiltersPanelComponent {
|
||||
private readonly store = inject(Store);
|
||||
// Root-provided; optional keeps standalone unit tests light. Picking a type
|
||||
// filter is a single-select choice that never navigates, so the phone
|
||||
// drawer closes here to reveal the filtered list.
|
||||
private readonly contextDrawer = inject(WorkspaceShellContextDrawerService, {
|
||||
private readonly statusService = inject(SourceStatusService);
|
||||
private readonly removal = inject(SourceRemovalService);
|
||||
private readonly viewState = inject(SourcesViewStateService);
|
||||
private readonly workspaceActions = inject(WORKSPACE_SHELL_ACTIONS, {
|
||||
optional: true,
|
||||
});
|
||||
|
||||
private readonly activeTypeFilters = this.store.selectSignal(
|
||||
selectActiveTypeFilters
|
||||
);
|
||||
private readonly playlists = this.store.selectSignal(selectAllPlaylistsMeta);
|
||||
|
||||
readonly typeOptions: PlaylistFilterOption[] = [
|
||||
// Root-provided; optional keeps standalone unit tests light. Picking a
|
||||
// filter never navigates, so the phone drawer closes to reveal the list.
|
||||
private readonly contextDrawer = inject(
|
||||
WorkspaceShellContextDrawerService,
|
||||
{
|
||||
id: 'all',
|
||||
icon: 'layers',
|
||||
translationKey: 'WORKSPACE.SOURCES.ALL',
|
||||
},
|
||||
optional: true,
|
||||
}
|
||||
);
|
||||
|
||||
readonly typeOptions: readonly TypeOption[] = [
|
||||
{ id: 'all', icon: 'layers', labelKey: 'WORKSPACE.SOURCES.ALL' },
|
||||
{
|
||||
id: 'm3u',
|
||||
icon: SOURCE_TYPE_ICONS.m3u,
|
||||
translationKey: 'HOME.PLAYLIST_TYPES.M3U',
|
||||
labelKey: 'HOME.PLAYLIST_TYPES.M3U',
|
||||
},
|
||||
{
|
||||
id: 'xtream',
|
||||
icon: SOURCE_TYPE_ICONS.xtream,
|
||||
translationKey: 'HOME.PLAYLIST_TYPES.XTREAM',
|
||||
labelKey: 'HOME.PLAYLIST_TYPES.XTREAM',
|
||||
},
|
||||
{
|
||||
id: 'stalker',
|
||||
icon: SOURCE_TYPE_ICONS.stalker,
|
||||
translationKey: 'HOME.PLAYLIST_TYPES.STALKER',
|
||||
labelKey: 'HOME.PLAYLIST_TYPES.STALKER',
|
||||
},
|
||||
];
|
||||
readonly statusOptions: readonly StatusOption[] = [
|
||||
{ id: 'all', labelKey: 'WORKSPACE.SOURCES.STATUS_ANY' },
|
||||
{ id: 'ok', labelKey: 'WORKSPACE.SOURCES.STATUS_OK' },
|
||||
{ id: 'bad', labelKey: 'WORKSPACE.SOURCES.STATUS_BAD' },
|
||||
];
|
||||
|
||||
readonly typeCounts = computed(() => {
|
||||
const items = this.playlists();
|
||||
return {
|
||||
all: items.length,
|
||||
m3u: items.filter((item) => !item.serverUrl && !item.macAddress)
|
||||
.length,
|
||||
xtream: items.filter((item) => !!item.serverUrl).length,
|
||||
stalker: items.filter((item) => !!item.macAddress).length,
|
||||
};
|
||||
});
|
||||
readonly showStatus = this.statusService.supportsStatusFilter;
|
||||
readonly filter = this.viewState.filter;
|
||||
|
||||
isTypeActive(filterId: PlaylistFilterId): boolean {
|
||||
const selected = this.activeTypeFilters();
|
||||
if (filterId === 'all') {
|
||||
return (
|
||||
selected.length === ALL_FILTERS.length &&
|
||||
ALL_FILTERS.every((id) => selected.includes(id))
|
||||
);
|
||||
}
|
||||
|
||||
return selected.length === 1 && selected[0] === filterId;
|
||||
}
|
||||
|
||||
selectType(filterId: PlaylistFilterId): void {
|
||||
const selectedFilters =
|
||||
filterId === 'all' ? ALL_FILTERS : [filterId];
|
||||
this.store.dispatch(
|
||||
FilterActions.setSelectedFilters({
|
||||
selectedFilters,
|
||||
})
|
||||
private readonly allPlaylists = this.store.selectSignal(
|
||||
selectAllPlaylistsMeta
|
||||
);
|
||||
private readonly playlists = computed(() => {
|
||||
const pending = this.removal.pendingIds();
|
||||
return this.allPlaylists().filter(
|
||||
(playlist) => !pending.has(playlist._id)
|
||||
);
|
||||
});
|
||||
private readonly bucketOf: SourceBucketOf = (playlist) => {
|
||||
const status = this.statusService.statusOf(playlist);
|
||||
return status ? sourceStatusBucket(status.reachability) : null;
|
||||
};
|
||||
|
||||
readonly total = computed(() => this.playlists().length);
|
||||
readonly typeCounts = computed(() =>
|
||||
countSourcesByType(
|
||||
this.playlists(),
|
||||
this.showStatus ? this.filter().status : 'all',
|
||||
this.bucketOf
|
||||
)
|
||||
);
|
||||
readonly statusCounts = computed(() =>
|
||||
countSourcesByStatus(
|
||||
this.playlists(),
|
||||
this.filter().type,
|
||||
this.bucketOf
|
||||
)
|
||||
);
|
||||
|
||||
selectType(type: SourcesTypeFilter): void {
|
||||
this.viewState.setType(type);
|
||||
this.contextDrawer?.close();
|
||||
}
|
||||
|
||||
getTypeCount(filterId: PlaylistFilterId): number {
|
||||
const counts = this.typeCounts();
|
||||
if (filterId === 'all') {
|
||||
return counts.all;
|
||||
}
|
||||
return counts[filterId];
|
||||
selectStatus(status: SourcesStatusFilter): void {
|
||||
this.viewState.setStatus(status);
|
||||
this.contextDrawer?.close();
|
||||
}
|
||||
|
||||
addPlaylist(): void {
|
||||
this.workspaceActions?.openAddPlaylistDialog();
|
||||
}
|
||||
}
|
||||
+53
@@ -0,0 +1,53 @@
|
||||
import { SortBy, SortOptions, SortOrder } from '@iptvnator/services';
|
||||
|
||||
export interface SourceSortOption {
|
||||
by: SortBy;
|
||||
order: SortOrder;
|
||||
translationKey: string;
|
||||
}
|
||||
|
||||
/** The Sources sort menu, in menu order. Custom order allows dragging. */
|
||||
export const SOURCE_SORT_OPTIONS: readonly SourceSortOption[] = [
|
||||
{
|
||||
by: SortBy.DATE_ADDED,
|
||||
order: SortOrder.DESC,
|
||||
translationKey: 'HOME.SORT_OPTIONS.NEWEST',
|
||||
},
|
||||
{
|
||||
by: SortBy.DATE_ADDED,
|
||||
order: SortOrder.ASC,
|
||||
translationKey: 'HOME.SORT_OPTIONS.OLDEST',
|
||||
},
|
||||
{
|
||||
by: SortBy.UPDATED,
|
||||
order: SortOrder.DESC,
|
||||
translationKey: 'HOME.SORT_OPTIONS.UPDATED',
|
||||
},
|
||||
{
|
||||
by: SortBy.NAME,
|
||||
order: SortOrder.ASC,
|
||||
translationKey: 'HOME.SORT_OPTIONS.NAME_ASC',
|
||||
},
|
||||
{
|
||||
by: SortBy.NAME,
|
||||
order: SortOrder.DESC,
|
||||
translationKey: 'HOME.SORT_OPTIONS.NAME_DESC',
|
||||
},
|
||||
{
|
||||
by: SortBy.CHANNELS,
|
||||
order: SortOrder.DESC,
|
||||
translationKey: 'HOME.SORT_OPTIONS.CHANNELS',
|
||||
},
|
||||
{
|
||||
by: SortBy.CUSTOM,
|
||||
order: SortOrder.ASC,
|
||||
translationKey: 'HOME.SORT_OPTIONS.CUSTOM_ORDER',
|
||||
},
|
||||
];
|
||||
|
||||
export function isSortOptionActive(
|
||||
option: SourceSortOption,
|
||||
current: SortOptions
|
||||
): boolean {
|
||||
return option.by === current.by && option.order === current.order;
|
||||
}
|
||||
+83
-40
@@ -1,55 +1,98 @@
|
||||
<section
|
||||
class="sources-page"
|
||||
[class.sources-page--empty]="visibleSourcesCount() === 0"
|
||||
class="sources-page app-scrollbar"
|
||||
[class.sources-page--empty]="!hasPlaylists()"
|
||||
>
|
||||
@if (visibleSourcesCount() > 0 || canCleanSources()) {
|
||||
@if (hasPlaylists()) {
|
||||
<header class="sources-header">
|
||||
<div class="sources-header__meta">
|
||||
<div class="sources-header__title">
|
||||
<h2 class="sources-title">{{ title() }}</h2>
|
||||
<span class="sources-subtitle">{{ subtitle() }}</span>
|
||||
<span class="sources-count" data-test-id="sources-count">{{
|
||||
visibleCount()
|
||||
}}</span>
|
||||
</div>
|
||||
@if (canCleanSources()) {
|
||||
<button
|
||||
mat-stroked-button
|
||||
type="button"
|
||||
class="cleanup-trigger"
|
||||
[attr.aria-label]="'SOURCE_CLEANUP.TITLE' | translate"
|
||||
(click)="openCleanup()"
|
||||
>
|
||||
<mat-icon>cleaning_services</mat-icon>
|
||||
<span>{{ 'SOURCE_CLEANUP.TITLE' | translate }}</span>
|
||||
</button>
|
||||
}
|
||||
<button
|
||||
type="button"
|
||||
mat-stroked-button
|
||||
class="sort-trigger"
|
||||
[matMenuTriggerFor]="sortMenu"
|
||||
>
|
||||
<mat-icon>sort</mat-icon>
|
||||
<span class="sort-trigger__label">{{ activeSortLabel() }}</span>
|
||||
<mat-icon class="sort-trigger__caret">arrow_drop_down</mat-icon>
|
||||
</button>
|
||||
<mat-menu #sortMenu="matMenu" xPosition="before">
|
||||
@for (option of sortOptions; track option.translationKey) {
|
||||
<div class="sources-header__actions">
|
||||
@if (canCleanSources()) {
|
||||
<button
|
||||
mat-menu-item
|
||||
mat-flat-button
|
||||
type="button"
|
||||
[appMenuItemRadio]="isSortActive(option)"
|
||||
(click)="setSortOption(option)"
|
||||
class="sources-action cleanup-trigger"
|
||||
[attr.aria-label]="'SOURCE_CLEANUP.TITLE' | translate"
|
||||
(click)="openCleanup()"
|
||||
>
|
||||
<mat-icon appMenuItemRadioCheck>check</mat-icon>
|
||||
<mat-icon>{{ option.icon }}</mat-icon>
|
||||
<span>{{ option.translationKey | translate }}</span>
|
||||
<mat-icon>cleaning_services</mat-icon>
|
||||
<span>{{ 'SOURCE_CLEANUP.TITLE' | translate }}</span>
|
||||
@if (failingCount() > 0) {
|
||||
<span class="sources-action__count">{{
|
||||
failingCount()
|
||||
}}</span>
|
||||
}
|
||||
</button>
|
||||
}
|
||||
</mat-menu>
|
||||
<button
|
||||
mat-flat-button
|
||||
type="button"
|
||||
class="sources-action sort-trigger"
|
||||
[matMenuTriggerFor]="sortMenu"
|
||||
[attr.aria-label]="
|
||||
('WORKSPACE.REFINE_SORT_SECTION' | translate) +
|
||||
': ' +
|
||||
activeSortLabel()
|
||||
"
|
||||
>
|
||||
<mat-icon>sort</mat-icon>
|
||||
<span class="sort-trigger__label">{{
|
||||
activeSortLabel()
|
||||
}}</span>
|
||||
<mat-icon class="sort-trigger__chevron" iconPositionEnd
|
||||
>expand_more</mat-icon
|
||||
>
|
||||
</button>
|
||||
<mat-menu
|
||||
#sortMenu="matMenu"
|
||||
class="app-menu"
|
||||
xPosition="before"
|
||||
>
|
||||
<div role="group">
|
||||
@for (option of sortOptions; track $index) {
|
||||
<button
|
||||
mat-menu-item
|
||||
type="button"
|
||||
[appMenuItemRadio]="isSortActive(option)"
|
||||
(click)="setSortOption(option)"
|
||||
>
|
||||
<mat-icon appMenuItemRadioCheck>check</mat-icon>
|
||||
<span>{{
|
||||
option.translationKey | translate
|
||||
}}</span>
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
@if (showFailingFirst) {
|
||||
<mat-divider role="separator" />
|
||||
<button
|
||||
mat-menu-item
|
||||
type="button"
|
||||
role="menuitemcheckbox"
|
||||
class="sort-failing-first"
|
||||
[attr.aria-checked]="failingFirst()"
|
||||
(click)="toggleFailingFirst()"
|
||||
>
|
||||
<mat-icon
|
||||
[class.sort-failing-first__check--off]="
|
||||
!failingFirst()
|
||||
"
|
||||
>check</mat-icon
|
||||
>
|
||||
<span>{{
|
||||
'HOME.SORT_OPTIONS.FAILING_FIRST' | translate
|
||||
}}</span>
|
||||
</button>
|
||||
}
|
||||
</mat-menu>
|
||||
</div>
|
||||
</header>
|
||||
}
|
||||
<div
|
||||
class="sources-content"
|
||||
[class.app-scrollbar]="visibleSourcesCount() > 0"
|
||||
>
|
||||
<div class="sources-content">
|
||||
<app-recent-playlists
|
||||
[searchQueryInput]="searchQuery()"
|
||||
(addPlaylistClicked)="onAddPlaylist($event)"
|
||||
|
||||
+78
-59
@@ -1,4 +1,4 @@
|
||||
@use '../../../../../../ui/styles/panel-header' as panel;
|
||||
@use '../../../../../../ui/styles/app-controls' as controls;
|
||||
|
||||
:host {
|
||||
display: block;
|
||||
@@ -6,103 +6,122 @@
|
||||
min-height: 100%;
|
||||
}
|
||||
|
||||
// The page scrolls as one: the header is not sticky.
|
||||
.sources-page {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
min-height: 100%;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.sources-header {
|
||||
@include panel.standard-panel-header($sticky: true);
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 24px 32px 14px;
|
||||
}
|
||||
|
||||
.sources-header__meta {
|
||||
@include panel.standard-panel-meta();
|
||||
.sources-header__title {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 10px;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.sources-title {
|
||||
@include panel.standard-panel-title();
|
||||
margin: 0;
|
||||
font-size: 28px;
|
||||
font-weight: 700;
|
||||
letter-spacing: -0.4px;
|
||||
line-height: 1.2;
|
||||
color: var(--app-heading-color);
|
||||
// A long translated title wraps rather than cuts off (content headings
|
||||
// never ellipsize).
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.sources-subtitle {
|
||||
@include panel.standard-panel-subtitle();
|
||||
.sources-count {
|
||||
font-size: 13px;
|
||||
font-variant-numeric: tabular-nums;
|
||||
color: var(--app-muted-color);
|
||||
}
|
||||
|
||||
.sort-trigger {
|
||||
flex-shrink: 0;
|
||||
display: inline-flex;
|
||||
.sources-header__actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.sources-action {
|
||||
@include controls.secondary-button(md);
|
||||
app-region: no-drag;
|
||||
}
|
||||
|
||||
.sort-trigger__label {
|
||||
font-size: 0.85rem;
|
||||
font-weight: 500;
|
||||
}
|
||||
.sources-action__count {
|
||||
margin-inline-start: 8px;
|
||||
padding: 1px 7px;
|
||||
border-radius: 999px;
|
||||
background: var(--app-danger-surface);
|
||||
color: var(--app-danger-text);
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.sort-trigger__caret {
|
||||
font-size: 18px;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
margin-left: -2px;
|
||||
}
|
||||
.sort-trigger__chevron {
|
||||
@include controls.trigger-chevron;
|
||||
}
|
||||
|
||||
.sources-content {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow-y: auto;
|
||||
padding: 16px 20px 20px;
|
||||
flex-direction: column;
|
||||
padding: 0 20px 32px;
|
||||
}
|
||||
|
||||
.sources-content app-recent-playlists {
|
||||
display: block;
|
||||
height: 100%;
|
||||
.sources-page--empty .sources-content {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.sources-page--empty {
|
||||
.sources-content {
|
||||
padding: 0;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.sources-content app-recent-playlists {
|
||||
flex: 1;
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
@media (max-width: 1100px) {
|
||||
.sources-header {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
padding: 10px 16px;
|
||||
min-height: unset;
|
||||
}
|
||||
|
||||
.sources-header__meta {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 4px;
|
||||
.sources-action {
|
||||
@include controls.secondary-button-height(36px);
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.sources-header {
|
||||
padding: 16px 16px 10px;
|
||||
}
|
||||
|
||||
.sources-title {
|
||||
font-size: 22px;
|
||||
}
|
||||
|
||||
.sources-content {
|
||||
padding: 12px 16px 16px;
|
||||
padding: 0 8px 24px;
|
||||
}
|
||||
}
|
||||
|
||||
.cleanup-trigger {
|
||||
flex-shrink: 0;
|
||||
app-region: no-drag;
|
||||
// The unchecked failing-first item keeps its check slot, so its label lines
|
||||
// up with the radio items above. Menu items come from this template, so the
|
||||
// component's scoping reaches them inside the overlay.
|
||||
.sort-failing-first__check--off {
|
||||
visibility: hidden;
|
||||
}
|
||||
@media (max-width: 1100px) {
|
||||
.cleanup-trigger span {
|
||||
display: none;
|
||||
}
|
||||
|
||||
// UI-kit menu panel. The panel renders in the overlay container, so the
|
||||
// rule is global; it ships with the components that use `class="app-menu"`
|
||||
// rather than in the app's initial stylesheet.
|
||||
::ng-deep .mat-mdc-menu-panel.app-menu {
|
||||
@include controls.menu-panel;
|
||||
}
|
||||
+91
-45
@@ -12,17 +12,24 @@ import {
|
||||
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
||||
import { NoopAnimationsModule } from '@angular/platform-browser/animations';
|
||||
import { MatButtonModule } from '@angular/material/button';
|
||||
import { MatDividerModule } from '@angular/material/divider';
|
||||
import { MatIconModule } from '@angular/material/icon';
|
||||
import { MatMenuModule } from '@angular/material/menu';
|
||||
import { ActivatedRoute, convertToParamMap } from '@angular/router';
|
||||
import { provideMockStore } from '@ngrx/store/testing';
|
||||
import { TranslatePipe, TranslateService } from '@ngx-translate/core';
|
||||
import { of, Subject } from 'rxjs';
|
||||
import { selectAllPlaylistsMeta } from '@iptvnator/m3u-state';
|
||||
import { SourceStatusService } from '@iptvnator/portal/shared/data-access';
|
||||
import { SourceRemovalService } from '@iptvnator/playlist/shared/ui';
|
||||
import { SourcesViewStateService } from '@iptvnator/playlist/shared/util';
|
||||
import {
|
||||
selectActiveTypeFilters,
|
||||
selectAllPlaylistsMeta,
|
||||
} from '@iptvnator/m3u-state';
|
||||
import { SortBy, SortOrder, SortService } from '@iptvnator/services';
|
||||
RuntimeCapabilitiesService,
|
||||
SortBy,
|
||||
SortOrder,
|
||||
SortService,
|
||||
} from '@iptvnator/services';
|
||||
import { PlaylistMeta } from '@iptvnator/shared/interfaces';
|
||||
import {
|
||||
MenuItemRadioCheckDirective,
|
||||
MenuItemRadioDirective,
|
||||
@@ -41,10 +48,24 @@ class MockRecentPlaylistsComponent {
|
||||
readonly addPlaylistClicked = output<void>();
|
||||
}
|
||||
|
||||
const PLAYLISTS = [
|
||||
{ _id: 'playlist-1', title: 'Playlist 1', url: 'https://a.example/1.m3u' },
|
||||
{
|
||||
_id: 'playlist-2',
|
||||
title: 'Playlist 2',
|
||||
serverUrl: 'http://x.example',
|
||||
username: 'u',
|
||||
password: 'p',
|
||||
},
|
||||
] as PlaylistMeta[];
|
||||
|
||||
describe('WorkspaceSourcesComponent', () => {
|
||||
let fixture: ComponentFixture<WorkspaceSourcesComponent>;
|
||||
let setSortOptions: jest.Mock;
|
||||
|
||||
beforeEach(async () => {
|
||||
localStorage.clear();
|
||||
setSortOptions = jest.fn();
|
||||
await TestBed.configureTestingModule({
|
||||
imports: [WorkspaceSourcesComponent, NoopAnimationsModule],
|
||||
providers: [
|
||||
@@ -62,21 +83,29 @@ describe('WorkspaceSourcesComponent', () => {
|
||||
},
|
||||
provideMockStore({
|
||||
selectors: [
|
||||
{
|
||||
selector: selectActiveTypeFilters,
|
||||
value: ['m3u', 'xtream', 'stalker'],
|
||||
},
|
||||
{
|
||||
selector: selectAllPlaylistsMeta,
|
||||
value: [
|
||||
{
|
||||
_id: 'playlist-1',
|
||||
title: 'Playlist 1',
|
||||
},
|
||||
],
|
||||
},
|
||||
{ selector: selectAllPlaylistsMeta, value: PLAYLISTS },
|
||||
],
|
||||
}),
|
||||
{
|
||||
provide: RuntimeCapabilitiesService,
|
||||
useValue: { supportsSourceHealth: true },
|
||||
},
|
||||
{
|
||||
provide: SourceStatusService,
|
||||
useValue: {
|
||||
supportsStatusFilter: true,
|
||||
statusOf: (p: PlaylistMeta) => ({
|
||||
reachability: p._id === 'playlist-2' ? 'bad' : 'ok',
|
||||
}),
|
||||
},
|
||||
},
|
||||
{
|
||||
provide: SourceRemovalService,
|
||||
useValue: {
|
||||
pendingIds: signal(new Set<string>()),
|
||||
isPending: () => false,
|
||||
},
|
||||
},
|
||||
{
|
||||
provide: ActivatedRoute,
|
||||
useValue: {
|
||||
@@ -91,7 +120,8 @@ describe('WorkspaceSourcesComponent', () => {
|
||||
by: SortBy.DATE_ADDED,
|
||||
order: SortOrder.DESC,
|
||||
}),
|
||||
setSortOptions: jest.fn(),
|
||||
setSortOptions: (options: unknown) =>
|
||||
setSortOptions(options),
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -140,6 +170,7 @@ describe('WorkspaceSourcesComponent', () => {
|
||||
set: {
|
||||
imports: [
|
||||
MatButtonModule,
|
||||
MatDividerModule,
|
||||
MatIconModule,
|
||||
MatMenuModule,
|
||||
MenuItemRadioCheckDirective,
|
||||
@@ -199,35 +230,45 @@ describe('WorkspaceSourcesComponent', () => {
|
||||
expect(context.protected('other')).toBe(false);
|
||||
});
|
||||
|
||||
it('renders the shared panel header structure without paragraph subtitle margins', async () => {
|
||||
it('puts a 28px title and the visible count on the page', async () => {
|
||||
fixture.detectChanges();
|
||||
await fixture.whenStable();
|
||||
|
||||
const header: HTMLElement =
|
||||
fixture.nativeElement.querySelector('.sources-header');
|
||||
const meta: HTMLElement = fixture.nativeElement.querySelector(
|
||||
'.sources-header__meta'
|
||||
const element = fixture.nativeElement as HTMLElement;
|
||||
expect(
|
||||
element.querySelector('.sources-title')?.textContent?.trim()
|
||||
).toBe('All Playlists');
|
||||
expect(
|
||||
element.querySelector('.sources-count')?.textContent?.trim()
|
||||
).toBe('2');
|
||||
// The page scrolls as one; the header is not a separate bar.
|
||||
expect(element.querySelector('.sources-page')?.classList).toContain(
|
||||
'app-scrollbar'
|
||||
);
|
||||
const title: HTMLElement =
|
||||
fixture.nativeElement.querySelector('.sources-title');
|
||||
const subtitle: HTMLElement =
|
||||
fixture.nativeElement.querySelector('.sources-subtitle');
|
||||
|
||||
expect(header).not.toBeNull();
|
||||
expect(meta).not.toBeNull();
|
||||
expect(title.textContent?.trim()).toBe('All Playlists');
|
||||
expect(subtitle.textContent?.trim()).toBe('1 playlist');
|
||||
expect(subtitle.tagName).toBe('SPAN');
|
||||
});
|
||||
|
||||
it('uses the shared always-visible scrollbar utility for the playlist list', async () => {
|
||||
it('titles the page by the status filter and counts what it shows', async () => {
|
||||
TestBed.inject(SourcesViewStateService).setStatus('bad');
|
||||
fixture.detectChanges();
|
||||
await fixture.whenStable();
|
||||
|
||||
const content: HTMLElement =
|
||||
fixture.nativeElement.querySelector('.sources-content');
|
||||
const element = fixture.nativeElement as HTMLElement;
|
||||
expect(
|
||||
element.querySelector('.sources-title')?.textContent?.trim()
|
||||
).toBe('WORKSPACE.SOURCES.STATUS_BAD');
|
||||
expect(
|
||||
element.querySelector('.sources-count')?.textContent?.trim()
|
||||
).toBe('1');
|
||||
});
|
||||
|
||||
expect(content.classList.contains('app-scrollbar')).toBe(true);
|
||||
it('counts sources that do not respond on Clean up', async () => {
|
||||
fixture.detectChanges();
|
||||
await fixture.whenStable();
|
||||
|
||||
const pill = (fixture.nativeElement as HTMLElement).querySelector(
|
||||
'.cleanup-trigger .sources-action__count'
|
||||
);
|
||||
expect(pill?.textContent?.trim()).toBe('1');
|
||||
});
|
||||
|
||||
it('offers the source sorts as radio rows with the active one checked', async () => {
|
||||
@@ -246,20 +287,25 @@ describe('WorkspaceSourcesComponent', () => {
|
||||
'.cdk-overlay-container [mat-menu-item]'
|
||||
)
|
||||
);
|
||||
expect(rows.map((row) => row.getAttribute('role'))).toEqual(
|
||||
Array(5).fill('menuitemradio')
|
||||
);
|
||||
expect(rows.map((row) => row.getAttribute('role'))).toEqual([
|
||||
...Array(7).fill('menuitemradio'),
|
||||
'menuitemcheckbox',
|
||||
]);
|
||||
expect(rows.map((row) => row.getAttribute('aria-checked'))).toEqual([
|
||||
'true',
|
||||
'false',
|
||||
'false',
|
||||
'false',
|
||||
'false',
|
||||
...Array(7).fill('false'),
|
||||
]);
|
||||
for (const row of rows) {
|
||||
for (const row of rows.slice(0, 7)) {
|
||||
expect(row.firstElementChild?.classList).toContain(
|
||||
'app-menu-item-radio-check'
|
||||
);
|
||||
}
|
||||
|
||||
rows[7].click();
|
||||
expect(setSortOptions).toHaveBeenCalledWith({
|
||||
by: SortBy.DATE_ADDED,
|
||||
order: SortOrder.DESC,
|
||||
failingFirst: true,
|
||||
});
|
||||
});
|
||||
});
|
||||
+150
-194
@@ -1,65 +1,81 @@
|
||||
import { WorkspaceShellXtreamImportService } from '../workspace-shell/services/workspace-shell-xtream-import.service';
|
||||
import { Injector, viewChild } from '@angular/core';
|
||||
import { MatDialog } from '@angular/material/dialog';
|
||||
import {
|
||||
SourceCleanupDialogComponent,
|
||||
ChangeDetectionStrategy,
|
||||
Component,
|
||||
computed,
|
||||
inject,
|
||||
Injector,
|
||||
viewChild,
|
||||
} from '@angular/core';
|
||||
import { toSignal } from '@angular/core/rxjs-interop';
|
||||
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 { ActivatedRoute } from '@angular/router';
|
||||
import { Store } from '@ngrx/store';
|
||||
import { TranslatePipe, TranslateService } from '@ngx-translate/core';
|
||||
import { map } from 'rxjs';
|
||||
import { PlaylistActions, selectAllPlaylistsMeta } from '@iptvnator/m3u-state';
|
||||
import { SourceStatusService } from '@iptvnator/portal/shared/data-access';
|
||||
import {
|
||||
isLiveExternalPlayerSession,
|
||||
PORTAL_EXTERNAL_PLAYBACK,
|
||||
} from '@iptvnator/portal/shared/util';
|
||||
import {
|
||||
PlaylistRefreshActionService,
|
||||
RecentPlaylistsComponent,
|
||||
SourceCleanupDialogComponent,
|
||||
SourceRemovalService,
|
||||
} from '@iptvnator/playlist/shared/ui';
|
||||
import {
|
||||
RuntimeCapabilitiesService,
|
||||
SourceActivityService,
|
||||
filterSources,
|
||||
SourceBucketOf,
|
||||
SourcesViewStateService,
|
||||
} from '@iptvnator/playlist/shared/util';
|
||||
import {
|
||||
injectTranslationTick,
|
||||
RuntimeCapabilitiesService,
|
||||
SortService,
|
||||
SourceActivityService,
|
||||
} from '@iptvnator/services';
|
||||
import {
|
||||
PORTAL_EXTERNAL_PLAYBACK,
|
||||
isLiveExternalPlayerSession,
|
||||
} from '@iptvnator/portal/shared/util';
|
||||
import { PlaylistActions } from '@iptvnator/m3u-state';
|
||||
PlaylistUpdateState,
|
||||
sourceHealthType,
|
||||
sourceStatusBucket,
|
||||
} from '@iptvnator/shared/interfaces';
|
||||
import {
|
||||
MenuItemRadioCheckDirective,
|
||||
MenuItemRadioDirective,
|
||||
} from '@iptvnator/ui/components';
|
||||
import {
|
||||
foldSearchText,
|
||||
sourceHealthType,
|
||||
PlaylistUpdateState,
|
||||
} from '@iptvnator/shared/interfaces';
|
||||
import {
|
||||
Component,
|
||||
computed,
|
||||
inject,
|
||||
ChangeDetectionStrategy,
|
||||
} from '@angular/core';
|
||||
import { toSignal } from '@angular/core/rxjs-interop';
|
||||
import { MatButtonModule } from '@angular/material/button';
|
||||
import { MatIconModule } from '@angular/material/icon';
|
||||
import { MatMenuModule } from '@angular/material/menu';
|
||||
import { ActivatedRoute } from '@angular/router';
|
||||
import { Store } from '@ngrx/store';
|
||||
import { RecentPlaylistsComponent } from '@iptvnator/playlist/shared/ui';
|
||||
import { TranslatePipe, TranslateService } from '@ngx-translate/core';
|
||||
import {
|
||||
selectActiveTypeFilters,
|
||||
selectAllPlaylistsMeta,
|
||||
} from '@iptvnator/m3u-state';
|
||||
import { map } from 'rxjs';
|
||||
import { SortBy, SortOrder, SortService } from '@iptvnator/services';
|
||||
import {
|
||||
WORKSPACE_SHELL_ACTIONS,
|
||||
WorkspacePlaylistType,
|
||||
} from '@iptvnator/workspace/shell/util';
|
||||
import {
|
||||
isSortOptionActive,
|
||||
SOURCE_SORT_OPTIONS,
|
||||
SourceSortOption,
|
||||
} from './workspace-sources-sort-options';
|
||||
|
||||
interface SortOption {
|
||||
by: SortBy;
|
||||
order: SortOrder;
|
||||
icon: string;
|
||||
translationKey: string;
|
||||
}
|
||||
const TYPE_TITLE_KEYS = {
|
||||
all: 'WORKSPACE.SOURCES.ALL_PLAYLISTS',
|
||||
m3u: 'WORKSPACE.SOURCES.M3U_PLAYLISTS',
|
||||
xtream: 'WORKSPACE.SOURCES.XTREAM_PLAYLISTS',
|
||||
stalker: 'WORKSPACE.SOURCES.STALKER_PLAYLISTS',
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* The Sources page: a 28px title with the visible count, Clean up (desktop,
|
||||
* with a count of sources that do not respond) and the sort menu, above the
|
||||
* list. The side list's filters live in `SourcesViewStateService`.
|
||||
*/
|
||||
@Component({
|
||||
selector: 'app-workspace-sources',
|
||||
imports: [
|
||||
MatButtonModule,
|
||||
MatDividerModule,
|
||||
MatIconModule,
|
||||
MatMenuModule,
|
||||
MenuItemRadioCheckDirective,
|
||||
@@ -68,12 +84,20 @@ interface SortOption {
|
||||
TranslatePipe,
|
||||
],
|
||||
templateUrl: './workspace-sources.component.html',
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
styleUrl: './workspace-sources.component.scss',
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
})
|
||||
export class WorkspaceSourcesComponent {
|
||||
private readonly injector = inject(Injector);
|
||||
private readonly route = inject(ActivatedRoute);
|
||||
private readonly store = inject(Store);
|
||||
private readonly translate = inject(TranslateService);
|
||||
private readonly runtime = inject(RuntimeCapabilitiesService);
|
||||
private readonly sortService = inject(SortService);
|
||||
private readonly statusService = inject(SourceStatusService);
|
||||
private readonly removal = inject(SourceRemovalService);
|
||||
private readonly viewState = inject(SourcesViewStateService);
|
||||
private readonly workspaceActions = inject(WORKSPACE_SHELL_ACTIONS);
|
||||
private readonly playback = inject(PORTAL_EXTERNAL_PLAYBACK, {
|
||||
optional: true,
|
||||
});
|
||||
@@ -81,11 +105,95 @@ export class WorkspaceSourcesComponent {
|
||||
optional: true,
|
||||
});
|
||||
private readonly sourceList = viewChild(RecentPlaylistsComponent);
|
||||
private readonly languageTick = injectTranslationTick();
|
||||
|
||||
readonly sortOptions = SOURCE_SORT_OPTIONS;
|
||||
readonly showFailingFirst = this.statusService.supportsStatusFilter;
|
||||
private readonly currentSort = toSignal(this.sortService.getSortOptions(), {
|
||||
requireSync: true,
|
||||
});
|
||||
readonly searchQuery = toSignal(
|
||||
this.route.queryParamMap.pipe(map((params) => params.get('q') ?? '')),
|
||||
{ initialValue: '' }
|
||||
);
|
||||
|
||||
private readonly allPlaylists = this.store.selectSignal(
|
||||
selectAllPlaylistsMeta
|
||||
);
|
||||
private readonly playlists = computed(() => {
|
||||
const pending = this.removal.pendingIds();
|
||||
return this.allPlaylists().filter((p) => !pending.has(p._id));
|
||||
});
|
||||
private readonly bucketOf: SourceBucketOf = (playlist) => {
|
||||
const status = this.statusService.statusOf(playlist);
|
||||
return status ? sourceStatusBucket(status.reachability) : null;
|
||||
};
|
||||
|
||||
readonly hasPlaylists = computed(() => this.playlists().length > 0);
|
||||
readonly visibleCount = computed(() => {
|
||||
const filter = this.viewState.filter();
|
||||
return filterSources(
|
||||
this.playlists(),
|
||||
this.showFailingFirst ? filter : { ...filter, status: 'all' },
|
||||
this.searchQuery(),
|
||||
this.bucketOf
|
||||
).length;
|
||||
});
|
||||
readonly title = computed(() => {
|
||||
this.languageTick();
|
||||
const filter = this.viewState.filter();
|
||||
const key =
|
||||
this.showFailingFirst && filter.status === 'bad'
|
||||
? 'WORKSPACE.SOURCES.STATUS_BAD'
|
||||
: TYPE_TITLE_KEYS[filter.type];
|
||||
return this.translate.instant(key);
|
||||
});
|
||||
|
||||
readonly canCleanSources = computed(
|
||||
() =>
|
||||
this.runtime.supportsSourceHealth &&
|
||||
this.playlists().some((p) => sourceHealthType(p))
|
||||
);
|
||||
/** Sources last seen not responding, across every filter. */
|
||||
readonly failingCount = computed(
|
||||
() => this.playlists().filter((p) => this.bucketOf(p) === 'bad').length
|
||||
);
|
||||
|
||||
readonly activeSortLabel = computed(() => {
|
||||
this.languageTick();
|
||||
const current = this.currentSort();
|
||||
const match = this.sortOptions.find((option) =>
|
||||
isSortOptionActive(option, current)
|
||||
);
|
||||
return this.translate.instant(
|
||||
match?.translationKey ?? 'HOME.SORT_OPTIONS.NEWEST'
|
||||
);
|
||||
});
|
||||
readonly failingFirst = computed(() => !!this.currentSort().failingFirst);
|
||||
|
||||
isSortActive(option: SourceSortOption): boolean {
|
||||
return isSortOptionActive(option, this.currentSort());
|
||||
}
|
||||
|
||||
setSortOption(option: SourceSortOption): void {
|
||||
this.sortService.setSortOptions({
|
||||
...this.currentSort(),
|
||||
by: option.by,
|
||||
order: option.order,
|
||||
});
|
||||
}
|
||||
|
||||
toggleFailingFirst(): void {
|
||||
this.sortService.setSortOptions({
|
||||
...this.currentSort(),
|
||||
failingFirst: !this.failingFirst(),
|
||||
});
|
||||
}
|
||||
|
||||
onAddPlaylist(type?: WorkspacePlaylistType): void {
|
||||
this.workspaceActions.openAddPlaylistDialog(type);
|
||||
}
|
||||
|
||||
openCleanup(): void {
|
||||
const dialogs = this.injector.get(MatDialog);
|
||||
const refresh = this.injector.get(PlaylistRefreshActionService);
|
||||
@@ -108,7 +216,7 @@ export class WorkspaceSourcesComponent {
|
||||
activity.isBusy(id) ||
|
||||
refresh.isSourceBusy(id) ||
|
||||
!!this.imports?.isSourceBusy(id) ||
|
||||
!!this.sourceList()?.isDeletePending(id) ||
|
||||
this.removal.isPending(id) ||
|
||||
!!this.sourceList()?.isRefreshPending(id) ||
|
||||
this.playlists().find((p) => p._id === id)?.updateState ===
|
||||
PlaylistUpdateState.IN_PROGRESS,
|
||||
@@ -121,156 +229,4 @@ export class WorkspaceSourcesComponent {
|
||||
},
|
||||
});
|
||||
}
|
||||
private readonly route = inject(ActivatedRoute);
|
||||
private readonly store = inject(Store);
|
||||
private readonly workspaceActions = inject(WORKSPACE_SHELL_ACTIONS);
|
||||
private readonly translate = inject(TranslateService);
|
||||
private readonly sortService = inject(SortService);
|
||||
|
||||
private readonly activeTypeFilters = this.store.selectSignal(
|
||||
selectActiveTypeFilters
|
||||
);
|
||||
private readonly playlists = this.store.selectSignal(
|
||||
selectAllPlaylistsMeta
|
||||
);
|
||||
private readonly languageTick = injectTranslationTick();
|
||||
|
||||
readonly sortOptions: SortOption[] = [
|
||||
{
|
||||
by: SortBy.DATE_ADDED,
|
||||
order: SortOrder.DESC,
|
||||
icon: 'schedule',
|
||||
translationKey: 'HOME.SORT_OPTIONS.NEWEST',
|
||||
},
|
||||
{
|
||||
by: SortBy.DATE_ADDED,
|
||||
order: SortOrder.ASC,
|
||||
icon: 'history',
|
||||
translationKey: 'HOME.SORT_OPTIONS.OLDEST',
|
||||
},
|
||||
{
|
||||
by: SortBy.NAME,
|
||||
order: SortOrder.ASC,
|
||||
icon: 'sort_by_alpha',
|
||||
translationKey: 'HOME.SORT_OPTIONS.NAME_ASC',
|
||||
},
|
||||
{
|
||||
by: SortBy.NAME,
|
||||
order: SortOrder.DESC,
|
||||
icon: 'sort_by_alpha',
|
||||
translationKey: 'HOME.SORT_OPTIONS.NAME_DESC',
|
||||
},
|
||||
{
|
||||
by: SortBy.CUSTOM,
|
||||
order: SortOrder.ASC,
|
||||
icon: 'drag_indicator',
|
||||
translationKey: 'HOME.SORT_OPTIONS.CUSTOM_ORDER',
|
||||
},
|
||||
];
|
||||
|
||||
private readonly currentSortOptions = toSignal(
|
||||
this.sortService.getSortOptions(),
|
||||
{ requireSync: true }
|
||||
);
|
||||
|
||||
readonly searchQuery = toSignal(
|
||||
this.route.queryParamMap.pipe(map((params) => params.get('q') ?? '')),
|
||||
{ initialValue: '' }
|
||||
);
|
||||
readonly title = computed(() => {
|
||||
this.languageTick();
|
||||
|
||||
const filters = this.activeTypeFilters();
|
||||
|
||||
if (filters.length === 1) {
|
||||
if (filters[0] === 'm3u') {
|
||||
return this.translateText('WORKSPACE.SOURCES.M3U_PLAYLISTS');
|
||||
}
|
||||
if (filters[0] === 'xtream') {
|
||||
return this.translateText('WORKSPACE.SOURCES.XTREAM_PLAYLISTS');
|
||||
}
|
||||
if (filters[0] === 'stalker') {
|
||||
return this.translateText(
|
||||
'WORKSPACE.SOURCES.STALKER_PLAYLISTS'
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return this.translateText('WORKSPACE.SOURCES.ALL_PLAYLISTS');
|
||||
});
|
||||
|
||||
readonly visibleSourcesCount = computed(() => {
|
||||
const query = foldSearchText(this.searchQuery().trim());
|
||||
const filters = this.activeTypeFilters();
|
||||
const allPlaylists = this.playlists();
|
||||
|
||||
return allPlaylists
|
||||
.filter((item) => {
|
||||
const isStalkerFilter =
|
||||
!!item.macAddress && filters.includes('stalker');
|
||||
const isXtreamFilter =
|
||||
!!item.username &&
|
||||
!!item.password &&
|
||||
!!item.serverUrl &&
|
||||
filters.includes('xtream');
|
||||
const isM3uFilter =
|
||||
!item.username &&
|
||||
!item.password &&
|
||||
!item.serverUrl &&
|
||||
!item.macAddress &&
|
||||
filters.includes('m3u');
|
||||
|
||||
return isStalkerFilter || isXtreamFilter || isM3uFilter;
|
||||
})
|
||||
.filter((item) => foldSearchText(item.title || '').includes(query))
|
||||
.length;
|
||||
});
|
||||
|
||||
readonly subtitle = computed(() => {
|
||||
this.languageTick();
|
||||
|
||||
const count = this.visibleSourcesCount();
|
||||
if (count === 1) {
|
||||
return this.translateText('WORKSPACE.SOURCES.PLAYLIST_COUNT_ONE');
|
||||
}
|
||||
|
||||
return this.translateText('WORKSPACE.SOURCES.PLAYLIST_COUNT_OTHER', {
|
||||
count,
|
||||
});
|
||||
});
|
||||
|
||||
readonly activeSortLabel = computed(() => {
|
||||
this.languageTick();
|
||||
const current = this.currentSortOptions();
|
||||
const match = this.sortOptions.find(
|
||||
(option) =>
|
||||
option.by === current.by && option.order === current.order
|
||||
);
|
||||
return this.translateText(
|
||||
match?.translationKey ?? 'HOME.SORT_OPTIONS.NEWEST'
|
||||
);
|
||||
});
|
||||
|
||||
onAddPlaylist(type?: WorkspacePlaylistType): void {
|
||||
this.workspaceActions.openAddPlaylistDialog(type);
|
||||
}
|
||||
|
||||
isSortActive(option: SortOption): boolean {
|
||||
const current = this.currentSortOptions();
|
||||
return current.by === option.by && current.order === option.order;
|
||||
}
|
||||
|
||||
setSortOption(option: SortOption): void {
|
||||
this.sortService.setSortOptions({
|
||||
by: option.by,
|
||||
order: option.order,
|
||||
});
|
||||
}
|
||||
|
||||
private translateText(
|
||||
key: string,
|
||||
params?: Record<string, string | number>
|
||||
): string {
|
||||
return this.translate.instant(key, params);
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user