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:
4grayandClaude Opus 5.5 committed 2026-10-12 22:34:22 +02:00
1 parent 028a3db880
commit 401295c092
70 files changed
+3837 -2023

No files matched your search

@@ -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>
@@ -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;
}
@@ -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();
});
});
@@ -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();
}
}
@@ -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;
}
@@ -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)"
@@ -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;
}
@@ -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,
});
});
});
@@ -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);
}
}