mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-08 17:06:15 -08:00
feat(m3u): add inline search functionality for group filtering and enhance group management UI
Entire-Checkpoint: 21b9b9f90f23
This commit is contained in:
1 parent
02f22dbfaf
commit
e2d1bb5400
7 files changed
+253
-17
No files matched your search
@@ -9,6 +9,7 @@
|
||||
"MANAGE_GROUPS_HINT": "Verwenden Sie \"Gruppen verwalten\", um Gruppen wieder einzublenden.",
|
||||
"NO_FAVORITES": "Keine Favoriten",
|
||||
"NO_VISIBLE_GROUPS": "Keine sichtbaren Gruppen",
|
||||
"SEARCH_GROUPS": "Gruppen suchen",
|
||||
"REMOVE_FAVORITE": "Aus Favoritenliste entfernen",
|
||||
"SEARCH_CHANNEL": "Nach Sender suchen",
|
||||
"UNGROUPED": "Ohne Gruppe",
|
||||
|
||||
@@ -275,6 +275,7 @@
|
||||
"MANAGE_GROUPS": "Manage groups",
|
||||
"MANAGE_GROUPS_HINT": "Use manage groups to show groups again.",
|
||||
"NO_VISIBLE_GROUPS": "No visible groups",
|
||||
"SEARCH_GROUPS": "Search groups",
|
||||
"UNGROUPED": "Ungrouped",
|
||||
"UNNAMED_CHANNEL": "Unnamed Channel",
|
||||
"FAVORITES": "Favorites",
|
||||
|
||||
@@ -190,6 +190,9 @@ channel-list-container/
|
||||
|
||||
- `GroupsViewComponent` owns the M3U-only "Manage groups" action and dialog in
|
||||
`libs/ui/components/src/lib/channel-list-container/groups-view/`.
|
||||
- The groups rail header also owns an inline search toggle that filters the
|
||||
currently visible groups without mutating the workspace-level route search
|
||||
term used by the broader channel views.
|
||||
- The dialog operates on the full grouped dataset, while the left rail and
|
||||
channel pane render only groups whose titles are not listed in
|
||||
`hiddenGroupTitles`.
|
||||
|
||||
+67
-14
@@ -39,17 +39,68 @@
|
||||
<h2 class="groups-nav-title">
|
||||
{{ 'CHANNELS.GROUPS' | translate }}
|
||||
</h2>
|
||||
<button
|
||||
mat-icon-button
|
||||
[matTooltip]="'CHANNELS.MANAGE_GROUPS' | translate"
|
||||
[attr.aria-label]="
|
||||
'CHANNELS.MANAGE_GROUPS' | translate
|
||||
"
|
||||
(click)="openGroupManagement()"
|
||||
>
|
||||
<mat-icon>tune</mat-icon>
|
||||
</button>
|
||||
<div class="groups-nav-actions">
|
||||
<button
|
||||
type="button"
|
||||
mat-icon-button
|
||||
class="groups-nav-action groups-nav-action--search"
|
||||
[matTooltip]="'CHANNELS.SEARCH_GROUPS' | translate"
|
||||
[attr.aria-pressed]="
|
||||
isGroupSearchOpen() ? 'true' : 'false'
|
||||
"
|
||||
[attr.aria-label]="
|
||||
'CHANNELS.SEARCH_GROUPS' | translate
|
||||
"
|
||||
(click)="toggleGroupSearch()"
|
||||
>
|
||||
<mat-icon>search</mat-icon>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
mat-icon-button
|
||||
class="groups-nav-action groups-nav-action--manage"
|
||||
[matTooltip]="'CHANNELS.MANAGE_GROUPS' | translate"
|
||||
[attr.aria-label]="
|
||||
'CHANNELS.MANAGE_GROUPS' | translate
|
||||
"
|
||||
(click)="openGroupManagement()"
|
||||
>
|
||||
<mat-icon>tune</mat-icon>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if (isGroupSearchOpen()) {
|
||||
<label class="groups-nav-search">
|
||||
<mat-icon class="groups-nav-search__icon">
|
||||
search
|
||||
</mat-icon>
|
||||
<input
|
||||
#groupSearchInput
|
||||
type="search"
|
||||
spellcheck="false"
|
||||
[value]="localGroupSearchTerm()"
|
||||
[placeholder]="
|
||||
'CHANNELS.SEARCH_GROUPS' | translate
|
||||
"
|
||||
(input)="
|
||||
updateGroupSearchTerm(
|
||||
$any($event.target).value
|
||||
)
|
||||
"
|
||||
(keydown.escape)="closeGroupSearch()"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
mat-icon-button
|
||||
class="groups-nav-search__close"
|
||||
[attr.aria-label]="'CLOSE' | translate"
|
||||
(click)="closeGroupSearch()"
|
||||
>
|
||||
<mat-icon>close</mat-icon>
|
||||
</button>
|
||||
</label>
|
||||
}
|
||||
</header>
|
||||
|
||||
<div class="groups-nav-list nav-list">
|
||||
@@ -87,7 +138,7 @@
|
||||
} @else {
|
||||
<div class="groups-nav-empty">
|
||||
{{
|
||||
(searchTerm()
|
||||
(hasSearchQuery()
|
||||
? 'CHANNELS.NO_SEARCH_RESULTS'
|
||||
: 'CHANNELS.NO_VISIBLE_GROUPS'
|
||||
) | translate
|
||||
@@ -201,11 +252,13 @@
|
||||
|
||||
<div class="groups-content-empty-state">
|
||||
<mat-icon class="empty-state-icon">
|
||||
{{ searchTerm() ? 'search_off' : 'visibility_off' }}
|
||||
{{
|
||||
hasSearchQuery() ? 'search_off' : 'visibility_off'
|
||||
}}
|
||||
</mat-icon>
|
||||
<div class="empty-state-title">
|
||||
{{
|
||||
(searchTerm()
|
||||
(hasSearchQuery()
|
||||
? 'CHANNELS.NO_SEARCH_RESULTS'
|
||||
: 'CHANNELS.NO_VISIBLE_GROUPS'
|
||||
) | translate
|
||||
@@ -213,7 +266,7 @@
|
||||
</div>
|
||||
<div class="empty-state-hint">
|
||||
{{
|
||||
(searchTerm()
|
||||
(hasSearchQuery()
|
||||
? 'CHANNELS.TRY_DIFFERENT_SEARCH'
|
||||
: 'CHANNELS.MANAGE_GROUPS_HINT'
|
||||
) | translate
|
||||
|
||||
+93
@@ -44,6 +44,9 @@
|
||||
.groups-nav-header {
|
||||
padding: 4px 6px 12px;
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.groups-nav-header__content {
|
||||
@@ -61,6 +64,96 @@
|
||||
color: var(--mat-sys-on-surface);
|
||||
}
|
||||
|
||||
.groups-nav-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.groups-nav-action,
|
||||
.groups-nav-search {
|
||||
@include mat.form-field-density(-5);
|
||||
}
|
||||
|
||||
.groups-nav-action {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
min-width: 28px;
|
||||
padding: 0;
|
||||
line-height: 28px;
|
||||
|
||||
mat-icon {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
font-size: 18px;
|
||||
}
|
||||
}
|
||||
|
||||
.groups-nav-search {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 0 10px;
|
||||
min-height: 36px;
|
||||
border-radius: 10px;
|
||||
border: 1px solid var(--app-search-border, var(--mat-sys-outline-variant));
|
||||
background: var(--app-search-bg, var(--mat-sys-surface-container));
|
||||
cursor: text;
|
||||
transition:
|
||||
border-color 0.15s ease,
|
||||
box-shadow 0.15s ease;
|
||||
|
||||
&:focus-within {
|
||||
border-color: var(--mat-sys-primary);
|
||||
box-shadow: 0 0 0 3px
|
||||
color-mix(in srgb, var(--mat-sys-primary) 12%, transparent);
|
||||
}
|
||||
}
|
||||
|
||||
.groups-nav-search__icon {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
font-size: 18px;
|
||||
color: var(--mat-sys-on-surface-variant);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.groups-nav-search input {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
width: 100%;
|
||||
border: none;
|
||||
outline: none;
|
||||
background: transparent;
|
||||
color: var(--mat-sys-on-surface);
|
||||
font: inherit;
|
||||
font-size: 0.84rem;
|
||||
|
||||
&::placeholder {
|
||||
color: var(--mat-sys-on-surface-variant);
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
&::-webkit-search-cancel-button {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
.groups-nav-search__close {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
min-width: 24px;
|
||||
line-height: 24px;
|
||||
flex-shrink: 0;
|
||||
padding: 0;
|
||||
|
||||
mat-icon {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
font-size: 16px;
|
||||
}
|
||||
}
|
||||
|
||||
.groups-content-header {
|
||||
@include panel.standard-panel-header();
|
||||
border-top-left-radius: inherit;
|
||||
|
||||
+39
-2
@@ -402,6 +402,43 @@ describe('GroupsViewComponent', () => {
|
||||
]);
|
||||
});
|
||||
|
||||
it('toggles the inline group search from the header action and filters the visible groups', () => {
|
||||
const searchButton = fixture.nativeElement.querySelector(
|
||||
'.groups-nav-action--search'
|
||||
) as HTMLButtonElement;
|
||||
|
||||
searchButton.click();
|
||||
fixture.detectChanges();
|
||||
|
||||
const searchInput = fixture.nativeElement.querySelector(
|
||||
'.groups-nav-search input'
|
||||
) as HTMLInputElement | null;
|
||||
|
||||
expect(searchInput).not.toBeNull();
|
||||
|
||||
searchInput!.value = 'spo';
|
||||
searchInput!.dispatchEvent(new Event('input'));
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(component.filteredGroups().map((group) => group.key)).toEqual([
|
||||
'Sports',
|
||||
]);
|
||||
expect(component.selectedGroupKey()).toBe('Sports');
|
||||
|
||||
searchButton.click();
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(
|
||||
fixture.nativeElement.querySelector('.groups-nav-search input')
|
||||
).toBeNull();
|
||||
expect(component.filteredGroups().map((group) => group.key)).toEqual([
|
||||
'Movies',
|
||||
'News',
|
||||
'Series',
|
||||
'Sports',
|
||||
]);
|
||||
});
|
||||
|
||||
it('emits channel and favorite events from the selected group pane', () => {
|
||||
const channelSelected = jest.fn();
|
||||
const favoriteToggled = jest.fn();
|
||||
@@ -497,7 +534,7 @@ describe('GroupsViewComponent', () => {
|
||||
'.groups-content-empty-state'
|
||||
) as HTMLElement | null;
|
||||
const manageButton = fixture.nativeElement.querySelector(
|
||||
'.groups-nav-header button'
|
||||
'.groups-nav-action--manage'
|
||||
) as HTMLButtonElement | null;
|
||||
|
||||
expect(layout).not.toBeNull();
|
||||
@@ -531,7 +568,7 @@ describe('GroupsViewComponent', () => {
|
||||
'.groups-content-empty-state'
|
||||
) as HTMLElement | null;
|
||||
const manageButton = fixture.nativeElement.querySelector(
|
||||
'.groups-nav-header button'
|
||||
'.groups-nav-action--manage'
|
||||
) as HTMLButtonElement | null;
|
||||
|
||||
expect(layout).not.toBeNull();
|
||||
|
||||
+49
-1
@@ -74,6 +74,8 @@ export class GroupsViewComponent {
|
||||
|
||||
readonly contextMenuTrigger =
|
||||
viewChild.required<MatMenuTrigger>('contextMenuTrigger');
|
||||
readonly groupSearchInput =
|
||||
viewChild<ElementRef<HTMLInputElement>>('groupSearchInput');
|
||||
|
||||
/** Grouped channels object */
|
||||
readonly groupedChannels = input.required<{ [key: string]: Channel[] }>();
|
||||
@@ -114,6 +116,8 @@ export class GroupsViewComponent {
|
||||
readonly sidebarWidthRequestEnded = output<number>();
|
||||
readonly hiddenGroupTitlesChanged = output<string[]>();
|
||||
|
||||
readonly isGroupSearchOpen = signal(false);
|
||||
readonly localGroupSearchTerm = signal('');
|
||||
readonly selectedGroupKey = signal<string | null>(null);
|
||||
readonly groupChannelSortMode = signal<PortalChannelSortMode>(
|
||||
restorePortalChannelSortMode(GROUP_CHANNEL_SORT_STORAGE_KEY)
|
||||
@@ -121,6 +125,11 @@ export class GroupsViewComponent {
|
||||
readonly groupChannelSortLabel = computed(() =>
|
||||
getPortalChannelSortModeLabel(this.groupChannelSortMode())
|
||||
);
|
||||
readonly hasSearchQuery = computed(
|
||||
() =>
|
||||
this.searchTerm().trim().length > 0 ||
|
||||
this.localGroupSearchTerm().trim().length > 0
|
||||
);
|
||||
readonly itemSize = computed(() => (this.shouldShowEpg() ? 68 : 48));
|
||||
readonly contextMenuChannel = signal<Channel | null>(null);
|
||||
readonly contextMenuPosition = signal({
|
||||
@@ -212,6 +221,16 @@ export class GroupsViewComponent {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
effect(() => {
|
||||
if (!this.isGroupSearchOpen()) {
|
||||
return;
|
||||
}
|
||||
|
||||
queueMicrotask(() => {
|
||||
this.groupSearchInput()?.nativeElement.focus();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
readonly allGroups = computed<GroupView[]>(() => {
|
||||
@@ -234,7 +253,7 @@ export class GroupsViewComponent {
|
||||
);
|
||||
});
|
||||
|
||||
readonly filteredGroups = computed<FilteredGroupView[]>(() => {
|
||||
readonly workspaceFilteredGroups = computed<FilteredGroupView[]>(() => {
|
||||
const term = this.searchTerm().trim().toLowerCase();
|
||||
const groups = this.visibleGroups();
|
||||
|
||||
@@ -270,6 +289,17 @@ export class GroupsViewComponent {
|
||||
}, []);
|
||||
});
|
||||
|
||||
readonly filteredGroups = computed<FilteredGroupView[]>(() => {
|
||||
const term = this.localGroupSearchTerm().trim().toLowerCase();
|
||||
const groups = this.workspaceFilteredGroups();
|
||||
|
||||
if (!term) {
|
||||
return groups;
|
||||
}
|
||||
|
||||
return groups.filter((group) => group.key.toLowerCase().includes(term));
|
||||
});
|
||||
|
||||
readonly hasAnyGroups = computed(() => this.allGroups().length > 0);
|
||||
|
||||
readonly selectedGroup = computed(() => {
|
||||
@@ -326,6 +356,24 @@ export class GroupsViewComponent {
|
||||
this.selectedGroupKey.set(groupKey);
|
||||
}
|
||||
|
||||
closeGroupSearch(): void {
|
||||
this.localGroupSearchTerm.set('');
|
||||
this.isGroupSearchOpen.set(false);
|
||||
}
|
||||
|
||||
toggleGroupSearch(): void {
|
||||
if (this.isGroupSearchOpen()) {
|
||||
this.closeGroupSearch();
|
||||
return;
|
||||
}
|
||||
|
||||
this.isGroupSearchOpen.set(true);
|
||||
}
|
||||
|
||||
updateGroupSearchTerm(value: string): void {
|
||||
this.localGroupSearchTerm.set(value);
|
||||
}
|
||||
|
||||
openGroupManagement(): void {
|
||||
const groups = this.allGroups().map<GroupManagementDialogGroup>(
|
||||
({ key, count }) => ({
|
||||
|
||||
Reference in new issue
Block a user