mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-10 18:36:15 -08:00
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:
1 parent
aa5e4097f1
commit
8874e4a17e
80 files changed
+2396
-4316
No files matched your search
-14
@@ -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">
|
||||
|
||||
+3
-44
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
+4
@@ -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()"
|
||||
|
||||
+15
-1
@@ -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(
|
||||
|
||||
+14
-3
@@ -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>
|
||||
+22
-1
@@ -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));
|
||||
|
||||
+92
-79
@@ -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>
|
||||
+29
-1
@@ -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();
|
||||
|
||||
+12
-2
@@ -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>
|
||||
+17
-1
@@ -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();
|
||||
|
||||
|
||||
Reference in new issue
Block a user