feat(groups-view): add search functionality to filter grouped channels

feat(recent-view): implement search term filtering for recent items
fix(dashboard-data): update return type for navigation state methods
chore(jest): add jest configuration for workspace shell feature
refactor(global-favorites): migrate global favorites list to shared UI
refactor(global-favorites): remove global favorites page component and its styles
refactor(global-favorites): update unified favorite channel interface to use shared util
test(workspace-shell): setup test environment for workspace shell feature
Entire-Checkpoint: 0e4488af6c76
This commit is contained in:
4gray committed 2026-03-25 19:16:03 +01:00
1 parent aa5e4097f1
commit 8874e4a17e
80 files changed
+2396 -4316

No files matched your search

@@ -1,17 +1,3 @@
<div class="search-panel">
<mat-form-field class="search-bar">
<input
matInput
class="full-width"
type="search"
spellcheck="false"
autocomplete="off"
#search
[placeholder]="'CHANNELS.SEARCH_CHANNEL' | translate"
(input)="onSearchInput($any($event.target).value)"
/>
</mat-form-field>
</div>
<div id="all-channels" class="channel-list">
@if (enrichedChannels().length === 0 && searchTerm()) {
<div class="no-results">
@@ -4,17 +4,10 @@ import {
ChangeDetectionStrategy,
Component,
computed,
ElementRef,
HostListener,
input,
OnDestroy,
output,
signal,
viewChild,
} from '@angular/core';
import { MatFormFieldModule } from '@angular/material/form-field';
import { MatIconModule } from '@angular/material/icon';
import { MatInputModule } from '@angular/material/input';
import { TranslatePipe } from '@ngx-translate/core';
import { Channel, EpgProgram } from 'shared-interfaces';
import { ChannelListItemComponent } from '../channel-list-item/channel-list-item.component';
@@ -32,16 +25,15 @@ export interface EnrichedChannel extends Channel {
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [
ChannelListItemComponent,
MatFormFieldModule,
MatIconModule,
MatInputModule,
ScrollingModule,
TranslatePipe,
],
})
export class AllChannelsViewComponent implements OnDestroy {
export class AllChannelsViewComponent {
/** All channels (will be filtered by search) */
readonly channels = input.required<Channel[]>();
readonly searchTerm = input('');
/** EPG map for channel enrichment */
readonly channelEpgMap = input.required<Map<string, EpgProgram | null>>();
@@ -70,28 +62,11 @@ export class AllChannelsViewComponent implements OnDestroy {
event: MouseEvent;
}>();
/** Search term signal for debounced filtering */
readonly searchTerm = signal('');
/** Debounce timeout for search */
private searchDebounceTimeout?: number;
/** Search field element */
readonly searchElement = viewChild<ElementRef<HTMLInputElement>>('search');
/** Register ctrl+f as keyboard hotkey to focus the search input field */
@HostListener('document:keypress', ['$event'])
handleKeyboardEvent(event: KeyboardEvent): void {
if (event.key === 'f' && event.ctrlKey) {
this.searchElement()?.nativeElement.focus();
}
}
/**
* Computed signal for filtered and enriched channels.
*/
readonly enrichedChannels = computed(() => {
const term = this.searchTerm().toLowerCase();
const term = this.searchTerm().trim().toLowerCase();
const channels = this.channels();
const epgMap = this.channelEpgMap();
// Read progressTick to create dependency for progress refresh
@@ -118,16 +93,6 @@ export class AllChannelsViewComponent implements OnDestroy {
});
});
/**
* Handles debounced search input
*/
onSearchInput(value: string): void {
clearTimeout(this.searchDebounceTimeout);
this.searchDebounceTimeout = window.setTimeout(() => {
this.searchTerm.set(value);
}, 300);
}
/**
* Calculates progress percentage for an EPG program
*/
@@ -171,10 +136,4 @@ export class AllChannelsViewComponent implements OnDestroy {
onFavoriteToggle(channel: Channel, event: MouseEvent): void {
this.favoriteToggled.emit({ channel, event });
}
ngOnDestroy(): void {
if (this.searchDebounceTimeout) {
clearTimeout(this.searchDebounceTimeout);
}
}
}
@@ -20,6 +20,7 @@
@case ('all') {
<app-all-channels-view
[channels]="displayedChannels()"
[searchTerm]="workspaceSearchTerm()"
[channelEpgMap]="channelEpgMap()"
[progressTick]="progressTick()"
[shouldShowEpg]="shouldShowEpg()"
@@ -33,6 +34,7 @@
@case ('groups') {
<app-groups-view
[groupedChannels]="groupedChannels()"
[searchTerm]="workspaceSearchTerm()"
[channelEpgMap]="channelEpgMap()"
[progressTick]="progressTick()"
[shouldShowEpg]="shouldShowEpg()"
@@ -47,6 +49,7 @@
@if (favorites) {
<app-favorites-view
[favorites]="favorites"
[searchTerm]="workspaceSearchTerm()"
[channelEpgMap]="channelEpgMap()"
[progressTick]="progressTick()"
[shouldShowEpg]="shouldShowEpg()"
@@ -60,6 +63,7 @@
@case ('recent') {
<app-recent-view
[recentItems]="recentChannelItems()"
[searchTerm]="workspaceSearchTerm()"
[channelEpgMap]="channelEpgMap()"
[progressTick]="progressTick()"
[shouldShowEpg]="shouldShowEpg()"
@@ -13,11 +13,15 @@ import {
import { toSignal } from '@angular/core/rxjs-interop';
import { MatButtonModule } from '@angular/material/button';
import { MatIconModule } from '@angular/material/icon';
import { NavigationEnd, Router } from '@angular/router';
import { ActivatedRoute, NavigationEnd, Router } from '@angular/router';
import { Store } from '@ngrx/store';
import { StorageMap } from '@ngx-pwa/local-storage';
import { TranslatePipe } from '@ngx-translate/core';
import { EpgService } from '@iptvnator/epg/data-access';
import {
isWorkspaceLayoutRoute,
queryParamSignal,
} from '@iptvnator/portal/shared/util';
import {
ChannelActions,
FavoritesActions,
@@ -86,6 +90,7 @@ export class ChannelListContainerComponent implements OnInit, OnDestroy {
private readonly storage = inject(StorageMap);
private readonly store = inject(Store);
private readonly router = inject(Router);
private readonly route = inject(ActivatedRoute);
/** Map of channel ID to current EPG program */
readonly channelEpgMap = signal(new Map<string, EpgProgram | null>());
@@ -108,6 +113,15 @@ export class ChannelListContainerComponent implements OnInit, OnDestroy {
/** Active view (all, groups, favorites, recent) */
readonly activeView = input<string>('all');
readonly recentItems = input<PlaylistRecentlyViewedItem[]>([]);
readonly isWorkspaceLayout = isWorkspaceLayoutRoute(this.route);
private readonly routeSearchTerm = queryParamSignal(
this.route,
'q',
(value) => (value ?? '').trim().toLowerCase()
);
readonly workspaceSearchTerm = computed(() =>
this.isWorkspaceLayout ? this.routeSearchTerm() : ''
);
readonly currentUrl = toSignal(
this.router.events.pipe(
@@ -1,6 +1,7 @@
<div
class="channel-list"
cdkDropList
[cdkDropListDisabled]="hasSearchTerm()"
(cdkDropListDropped)="onDrop($event)"
id="favorites-list"
>
@@ -17,7 +18,7 @@
'. ' +
(channel?.name || 'CHANNELS.UNNAMED_CHANNEL' | translate)
"
[isDraggable]="true"
[isDraggable]="!hasSearchTerm()"
[logo]="channel?.tvg?.logo"
[showEpg]="shouldShowEpg()"
[epgProgram]="channel.epgProgram"
@@ -31,10 +32,20 @@
}
} @else {
<div class="empty-favorites">
<strong>{{ 'CHANNELS.NO_FAVORITES' | translate }}</strong>
<strong>
@if (hasSearchTerm()) {
{{ 'CHANNELS.NO_SEARCH_RESULTS' | translate }}
} @else {
{{ 'CHANNELS.NO_FAVORITES' | translate }}
}
</strong>
</div>
<div class="empty-favorites-hint">
{{ 'CHANNELS.USE_STAR_TO_FAVORITE' | translate }}
@if (hasSearchTerm()) {
{{ 'CHANNELS.TRY_DIFFERENT_SEARCH' | translate }}
} @else {
{{ 'CHANNELS.USE_STAR_TO_FAVORITE' | translate }}
}
</div>
}
</div>
@@ -25,6 +25,7 @@ import { ChannelListItemComponent } from '../channel-list-item/channel-list-item
export class FavoritesViewComponent {
/** Favorite channels */
readonly favorites = input.required<Channel[]>();
readonly searchTerm = input('');
/** EPG map for channel enrichment */
readonly channelEpgMap = input.required<Map<string, EpgProgram | null>>();
@@ -50,11 +51,27 @@ export class FavoritesViewComponent {
/** Emits when favorites order changes via drag-drop */
readonly favoritesReordered = output<string[]>();
readonly hasSearchTerm = computed(() => this.searchTerm().trim().length > 0);
readonly filteredFavorites = computed(() => {
const favorites = this.favorites();
const term = this.searchTerm().trim().toLowerCase();
if (!term) {
return favorites;
}
return favorites.filter((channel) =>
`${channel.name ?? ''} ${channel.group?.title ?? ''}`
.toLowerCase()
.includes(term)
);
});
/**
* Computed signal for enriched favorites with EPG data
*/
readonly enrichedFavorites = computed(() => {
const favorites = this.favorites();
const favorites = this.filteredFavorites();
const epgMap = this.channelEpgMap();
// Read progressTick to trigger recalculation
this.progressTick();
@@ -103,6 +120,10 @@ export class FavoritesViewComponent {
}
onDrop(event: CdkDragDrop<Channel[]>): void {
if (this.hasSearchTerm()) {
return;
}
const favorites = [...this.favorites()];
moveItemInArray(favorites, event.previousIndex, event.currentIndex);
this.favoritesReordered.emit(favorites.map((item) => item.url));
@@ -1,84 +1,97 @@
<div id="groups-list" class="channel-list">
<mat-accordion multi>
@for (
groups of groupedChannels() | keyvalue: groupsComparator;
track $index
) {
@if (groups.value.length > 0) {
<mat-expansion-panel
(afterExpand)="
onGroupPanelOpened(groups.key, groups.value.length)
"
>
<mat-expansion-panel-header>
{{
groups.key || ('CHANNELS.UNGROUPED' | translate)
| titlecase
}}
({{ groups.value.length }})
</mat-expansion-panel-header>
@if (searchTerm() && (filteredGroupedChannels() | keyvalue).length === 0) {
<div class="no-results">
<mat-icon class="no-results-icon">search_off</mat-icon>
<div class="no-results-title">
{{ 'CHANNELS.NO_SEARCH_RESULTS' | translate }}
</div>
<div class="no-results-hint">
{{ 'CHANNELS.TRY_DIFFERENT_SEARCH' | translate }}
</div>
</div>
} @else {
<mat-accordion multi>
@for (
groups of filteredGroupedChannels() | keyvalue: groupsComparator;
track $index
) {
@if (groups.value.length > 0) {
<mat-expansion-panel
(afterExpand)="
onGroupPanelOpened(groups.key, groups.value.length)
"
>
<mat-expansion-panel-header>
{{
groups.key || ('CHANNELS.UNGROUPED' | translate)
| titlecase
}}
({{ groups.value.length }})
</mat-expansion-panel-header>
<ng-template matExpansionPanelContent>
@let enrichedGroupChannels =
getEnrichedGroupChannels(groups.value, groups.key);
@let limit = getGroupLimit(groups.key);
<div class="group-channels">
@for (
channel of enrichedGroupChannels.slice(
0,
limit
);
track trackByFn($index, channel);
let i = $index
) {
<app-channel-list-item
[name]="
i +
1 +
'. ' +
(channel?.name ||
'CHANNELS.UNNAMED_CHANNEL'
| translate)
"
[logo]="channel?.tvg?.logo"
[showEpg]="shouldShowEpg()"
[epgProgram]="channel.epgProgram"
[progressPercentage]="
channel.progressPercentage
"
(clicked)="onChannelClick(channel)"
[selected]="
activeChannelUrl() === channel?.url
"
[showFavoriteButton]="true"
[isFavorite]="
favoriteIds().has(channel?.url)
"
(favoriteToggled)="
onFavoriteToggle(channel, $event)
"
></app-channel-list-item>
}
<!-- Infinite scroll sentinel - triggers loading more when visible -->
@if (groups.value.length > limit) {
<div
class="scroll-sentinel"
[attr.data-sentinel-group]="groups.key"
>
<div class="loading-more">
<mat-icon class="loading-more-spinner"
>sync</mat-icon
>
<span>{{
'CHANNELS.LOADING_MORE' | translate
}}</span>
<ng-template matExpansionPanelContent>
@let enrichedGroupChannels =
getEnrichedGroupChannels(groups.value, groups.key);
@let limit = getGroupLimit(groups.key);
<div class="group-channels">
@for (
channel of enrichedGroupChannels.slice(
0,
limit
);
track trackByFn($index, channel);
let i = $index
) {
<app-channel-list-item
[name]="
i +
1 +
'. ' +
(channel?.name ||
'CHANNELS.UNNAMED_CHANNEL'
| translate)
"
[logo]="channel?.tvg?.logo"
[showEpg]="shouldShowEpg()"
[epgProgram]="channel.epgProgram"
[progressPercentage]="
channel.progressPercentage
"
(clicked)="onChannelClick(channel)"
[selected]="
activeChannelUrl() === channel?.url
"
[showFavoriteButton]="true"
[isFavorite]="
favoriteIds().has(channel?.url)
"
(favoriteToggled)="
onFavoriteToggle(channel, $event)
"
></app-channel-list-item>
}
@if (groups.value.length > limit) {
<div
class="scroll-sentinel"
[attr.data-sentinel-group]="groups.key"
>
<div class="loading-more">
<mat-icon
class="loading-more-spinner"
>sync</mat-icon
>
<span>{{
'CHANNELS.LOADING_MORE'
| translate
}}</span>
</div>
</div>
</div>
}
</div>
</ng-template>
</mat-expansion-panel>
}
</div>
</ng-template>
</mat-expansion-panel>
}
}
}
</mat-accordion>
</mat-accordion>
}
</div>
@@ -39,6 +39,7 @@ export class GroupsViewComponent implements AfterViewInit, OnDestroy {
/** Grouped channels object */
readonly groupedChannels = input.required<{ [key: string]: Channel[] }>();
readonly searchTerm = input('');
/** EPG map for channel enrichment */
readonly channelEpgMap = input.required<Map<string, EpgProgram | null>>();
@@ -179,8 +180,35 @@ export class GroupsViewComponent implements AfterViewInit, OnDestroy {
/**
* Computed signal that memoizes enriched channels for all groups
*/
private readonly enrichedGroupChannelsMap = computed(() => {
readonly filteredGroupedChannels = computed(() => {
const grouped = this.groupedChannels();
const term = this.searchTerm().trim().toLowerCase();
if (!term) {
return grouped;
}
return Object.entries(grouped).reduce<Record<string, Channel[]>>(
(acc, [groupKey, channels]) => {
const groupMatches = groupKey.toLowerCase().includes(term);
const matchingChannels = groupMatches
? channels
: channels.filter((channel) =>
channel.name?.toLowerCase().includes(term)
);
if (matchingChannels.length > 0) {
acc[groupKey] = matchingChannels;
}
return acc;
},
{}
);
});
private readonly enrichedGroupChannelsMap = computed(() => {
const grouped = this.filteredGroupedChannels();
const epgMap = this.channelEpgMap();
// Read progressTick to create dependency for progress refresh
this.progressTick();
@@ -28,10 +28,20 @@
}
} @else {
<div class="empty-recent">
<strong>{{ 'WORKSPACE.DASHBOARD.NO_HISTORY' | translate }}</strong>
<strong>
@if (searchTerm()) {
{{ 'CHANNELS.NO_SEARCH_RESULTS' | translate }}
} @else {
{{ 'WORKSPACE.DASHBOARD.NO_HISTORY' | translate }}
}
</strong>
</div>
<div class="empty-recent-hint">
{{ 'WORKSPACE.DASHBOARD.NO_HISTORY_HINT' | translate }}
@if (searchTerm()) {
{{ 'CHANNELS.TRY_DIFFERENT_SEARCH' | translate }}
} @else {
{{ 'WORKSPACE.DASHBOARD.NO_HISTORY_HINT' | translate }}
}
</div>
}
</div>
@@ -23,6 +23,7 @@ export interface RecentViewItem {
})
export class RecentViewComponent {
readonly recentItems = input.required<RecentViewItem[]>();
readonly searchTerm = input('');
readonly channelEpgMap = input.required<Map<string, EpgProgram | null>>();
readonly progressTick = input.required<number>();
readonly shouldShowEpg = input.required<boolean>();
@@ -31,8 +32,23 @@ export class RecentViewComponent {
readonly channelSelected = output<Channel>();
readonly removeRecent = output<string>();
readonly enrichedRecentItems = computed(() => {
readonly filteredRecentItems = computed(() => {
const recentItems = this.recentItems();
const term = this.searchTerm().trim().toLowerCase();
if (!term) {
return recentItems;
}
return recentItems.filter(({ channel }) =>
`${channel.name ?? ''} ${channel.group?.title ?? ''}`
.toLowerCase()
.includes(term)
);
});
readonly enrichedRecentItems = computed(() => {
const recentItems = this.filteredRecentItems();
const epgMap = this.channelEpgMap();
this.progressTick();