From c253db4cef8a3da9332e186833659a78e3de5d1d Mon Sep 17 00:00:00 2001 From: 4gray Date: Sun, 8 Feb 2026 19:18:05 +0100 Subject: [PATCH] feat: enhance search functionality with improved filtering and UI updates --- .../src/app/events/database/content.events.ts | 44 +++--- .../search-form/search-form.component.html | 14 +- .../search-form/search-form.component.ts | 7 + .../search-layout.component.html | 15 +- .../search-layout.component.scss | 7 +- .../search-result-item.component.ts | 139 ------------------ .../search-results.component.html | 99 ++++++++----- .../search-results.component.scss | 27 ++++ .../search-results.component.ts | 35 ++++- .../stores/features/with-search.feature.ts | 8 + 10 files changed, 192 insertions(+), 203 deletions(-) delete mode 100644 apps/web/src/app/shared/components/search-result-item/search-result-item.component.ts diff --git a/apps/electron-backend/src/app/events/database/content.events.ts b/apps/electron-backend/src/app/events/database/content.events.ts index 571b4392f..78687e51c 100644 --- a/apps/electron-backend/src/app/events/database/content.events.ts +++ b/apps/electron-backend/src/app/events/database/content.events.ts @@ -243,12 +243,12 @@ ipcMain.handle( async (event, playlistId: string, searchTerm: string, types: string[]) => { try { const db = await getDatabase(); - // Note: SQLite's LOWER() only handles ASCII characters, not Unicode/Cyrillic. - // We use a two-step approach: - // 1. SQL filters by playlist and type - // 2. JavaScript filters by title using proper Unicode toLowerCase() const searchTermLower = searchTerm.toLowerCase(); + const escapedTerm = escapeLikePattern(searchTerm); + const likePattern = `%${escapedTerm}%`; + // Pre-filter with SQL LIKE (case-insensitive for ASCII, handled in native C code). + // This avoids loading the entire content table into JS memory. const candidates = await db .select({ id: schema.content.id, @@ -271,11 +271,14 @@ ipcMain.handle( inArray( schema.content.type, types as Array<'live' | 'movie' | 'series'> - ) + ), + sql`${schema.content.title} LIKE ${likePattern}` ) - ); + ) + .limit(200); - // Filter in JavaScript for proper Unicode case-insensitive search + // Post-filter for proper Unicode case-insensitive matching + // (SQLite LIKE is only case-insensitive for ASCII) const result = candidates .filter(item => item.title?.toLowerCase().includes(searchTermLower)) .slice(0, 50); @@ -296,12 +299,14 @@ ipcMain.handle( async (event, searchTerm: string, types: string[]) => { try { const db = await getDatabase(); - // Note: SQLite's LOWER() only handles ASCII characters, not Unicode/Cyrillic. - // We use a two-step approach: - // 1. SQL filters by type only - // 2. JavaScript filters by title using proper Unicode toLowerCase() const searchTermLower = searchTerm.toLowerCase(); + const escapedTerm = escapeLikePattern(searchTerm); + const likePattern = `%${escapedTerm}%`; + // Pre-filter with SQL LIKE (case-insensitive for ASCII, handled in native C code). + // This avoids loading the entire content table into JS memory. + // Previously this query loaded ALL content rows (potentially 100k+), + // created JS objects for each, then filtered in JS — blocking the main process. const candidates = await db .select({ id: schema.content.id, @@ -325,16 +330,21 @@ ipcMain.handle( eq(schema.categories.playlistId, schema.playlists.id) ) .where( - inArray( - schema.content.type, - types as Array<'live' | 'movie' | 'series'> + and( + inArray( + schema.content.type, + types as Array<'live' | 'movie' | 'series'> + ), + sql`${schema.content.title} LIKE ${likePattern}` ) - ); + ) + .orderBy(schema.content.title) + .limit(200); - // Filter in JavaScript for proper Unicode case-insensitive search + // Post-filter for proper Unicode case-insensitive matching + // (SQLite LIKE is only case-insensitive for ASCII) const result = candidates .filter(item => item.title?.toLowerCase().includes(searchTermLower)) - .sort((a, b) => a.title.localeCompare(b.title)) .slice(0, 50); return result; diff --git a/apps/web/src/app/shared/components/search-form/search-form.component.html b/apps/web/src/app/shared/components/search-form/search-form.component.html index 87706979c..950999011 100644 --- a/apps/web/src/app/shared/components/search-form/search-form.component.html +++ b/apps/web/src/app/shared/components/search-form/search-form.component.html @@ -1,5 +1,9 @@
- + {{ placeholder }} - search + @if (searchTerm()) { + + } @else { + search + } @if (filterConfig.length > 0) { diff --git a/apps/web/src/app/shared/components/search-form/search-form.component.ts b/apps/web/src/app/shared/components/search-form/search-form.component.ts index dfca06ee7..6be4c3e4e 100644 --- a/apps/web/src/app/shared/components/search-form/search-form.component.ts +++ b/apps/web/src/app/shared/components/search-form/search-form.component.ts @@ -9,6 +9,7 @@ import { } from '@angular/core'; import { FormsModule } from '@angular/forms'; import { MatCheckboxModule } from '@angular/material/checkbox'; +import { MatButtonModule } from '@angular/material/button'; import { MatFormFieldModule } from '@angular/material/form-field'; import { MatIconModule } from '@angular/material/icon'; import { MatInputModule } from '@angular/material/input'; @@ -33,6 +34,7 @@ export interface SearchFilter { MatInputModule, MatIconModule, MatCheckboxModule, + MatButtonModule, TranslatePipe, ], templateUrl: './search-form.component.html', @@ -76,6 +78,11 @@ export class SearchFormComponent { }, 500); } + clearSearch() { + this.searchInput.nativeElement.value = ''; + this.searchTerm.set(''); + } + focusSearchInput() { this.searchInput.nativeElement.focus(); } diff --git a/apps/web/src/app/shared/components/search-layout/search-layout.component.html b/apps/web/src/app/shared/components/search-layout/search-layout.component.html index 9a7d8dd54..5552bd103 100644 --- a/apps/web/src/app/shared/components/search-layout/search-layout.component.html +++ b/apps/web/src/app/shared/components/search-layout/search-layout.component.html @@ -17,7 +17,10 @@
@if (showResultsCount() && resultsCount() > 0) {
- Found {{ resultsCount() }} results + {{ + 'PORTALS.SEARCH_VIEW.RESULTS_FOUND' + | translate: { count: resultsCount() } + }}
} @if (showCloseButton()) { @@ -36,6 +39,9 @@ (searchTermChange)="onSearchTermChange($event)" />
+ + +
@@ -52,7 +58,12 @@

{{ 'PORTALS.EMPTY_LIST_VIEW.TITLE' | translate }}

{{ 'PORTALS.EMPTY_LIST_VIEW.NO_SEARCH_RESULTS' | translate }}

-

No results found for "{{ searchTerm() }}"

+

+ {{ + 'PORTALS.SEARCH_VIEW.NO_RESULTS_FOR' + | translate: { term: searchTerm() } + }} +

} @else if (showInitialState) { diff --git a/apps/web/src/app/shared/components/search-layout/search-layout.component.scss b/apps/web/src/app/shared/components/search-layout/search-layout.component.scss index 21062809e..98c6901cf 100644 --- a/apps/web/src/app/shared/components/search-layout/search-layout.component.scss +++ b/apps/web/src/app/shared/components/search-layout/search-layout.component.scss @@ -20,7 +20,7 @@ flex-direction: column; flex: 1; overflow-y: auto; - padding: 0 20px 20px 20px; + padding: 0 20px 0 20px; } .header { @@ -29,7 +29,7 @@ z-index: 100; background: var(--background, var(--mat-sidenav-content-background-color, #fff)); padding: 20px 0 10px 0; - margin-bottom: 20px; + margin-bottom: 2px; box-shadow: 0 4px 12px rgba(0, 0, 0, 0.02); display: flex; @@ -154,6 +154,9 @@ // Global search overlay context :host-context(.global-search-overlay) { + // Prevent Electron's app-region: drag from bleeding through the dialog overlay + app-region: no-drag; + .search-page { background: var(--background); height: 100vh; diff --git a/apps/web/src/app/shared/components/search-result-item/search-result-item.component.ts b/apps/web/src/app/shared/components/search-result-item/search-result-item.component.ts deleted file mode 100644 index 9a914738c..000000000 --- a/apps/web/src/app/shared/components/search-result-item/search-result-item.component.ts +++ /dev/null @@ -1,139 +0,0 @@ -import { Component, EventEmitter, Input, Output } from '@angular/core'; -import { MatCardModule } from '@angular/material/card'; -import { MatIconModule } from '@angular/material/icon'; - -@Component({ - selector: 'app-search-result-item', - imports: [MatCardModule, MatIconModule], - template: ` - - @if (posterUrl) { - - } @else { -
- movie -
- } -

{{ title }}

- @if (description) { -

{{ description }}

- } - @if (showPlaylistInfo && playlistName) { -
- playlist_play - {{ playlistName }} -
- } -
- `, - styles: [ - ` - :host { - display: block; - height: 100%; - } - - mat-card { - cursor: pointer; - height: 100%; - display: flex; - flex-direction: column; - position: relative; - overflow: hidden; - background: transparent !important; - box-shadow: none !important; - border: none !important; - padding: 0 !important; - } - - .poster { - width: 100%; - aspect-ratio: 2/3; - object-fit: cover; - display: block; - border-radius: 8px; - } - - .poster-placeholder { - width: 100%; - aspect-ratio: 2/3; - display: flex; - align-items: center; - justify-content: center; - background: rgba(0, 0, 0, 0.05); - border-radius: 8px; - - mat-icon { - font-size: 48px; - width: 48px; - height: 48px; - opacity: 0.3; - } - } - - h4 { - margin: 0; - padding: 10px 0 2px 0; - font-size: 0.9rem; - font-weight: 500; - line-height: 1.3em; - height: 2.6em; /* Fixed height for 2 lines */ - color: var(--text-color, #fff); - - display: -webkit-box; - -webkit-line-clamp: 2; - -webkit-box-orient: vertical; - overflow: hidden; - text-overflow: ellipsis; - } - - p { - margin: 0; - padding: 0 0 10px 0; - font-size: 0.75rem; - line-height: 1.2em; - height: 1.2em; /* Fixed height for 1 line */ - color: var(--text-color-secondary, rgba(255, 255, 255, 0.7)); - opacity: 0.9; /* Improved readability */ - - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; - } - - .playlist-badge { - display: flex; - align-items: center; - gap: 4px; - margin-top: auto; - padding: 0 0 10px 0; - font-size: 11px; - opacity: 0.8; - color: var(--text-color-secondary, inherit); - - mat-icon { - font-size: 14px; - width: 14px; - height: 14px; - } - } - `, - ], -}) -export class SearchResultItemComponent { - @Input() title = ''; - @Input() description = ''; - @Input() posterUrl = ''; - @Input() type = ''; - @Input() playlistName = ''; - @Input() showPlaylistInfo = false; - - @Output() itemClick = new EventEmitter(); -} \ No newline at end of file diff --git a/apps/web/src/app/xtream-tauri/search-results/search-results.component.html b/apps/web/src/app/xtream-tauri/search-results/search-results.component.html index 396682bc1..bd548cef8 100644 --- a/apps/web/src/app/xtream-tauri/search-results/search-results.component.html +++ b/apps/web/src/app/xtream-tauri/search-results/search-results.component.html @@ -3,11 +3,11 @@ [resultsCount]="xtreamStore.searchResults().length" [isLoading]="xtreamStore.isSearching()" [showCloseButton]="isGlobalSearch" - [showResultsCount]="!isGlobalSearch" + [showResultsCount]="!isGlobalSearch || !groupByPlaylist()" (searchTermChange)="updateSearchTerm($event)" (closeClick)="onCloseDialog()" > - + @for (filter of filterConfig; track filter.key) { + + @if (isGlobalSearch) { +
+
+ info_outline + {{ 'PORTALS.SEARCH_VIEW.XTREAM_ONLY_NOTE' | translate }} +
+ + {{ 'PORTALS.SEARCH_VIEW.GROUP_BY_PLAYLIST' | translate }} + +
+ } +
+ @if (isGlobalSearch) { -
-

- Found {{ xtreamStore.searchResults().length }} items across - {{ (getGroupedResults() | keyvalue).length }} playlists -

- @for ( - group of getGroupedResults() | keyvalue; - track group.key - ) { -
-

- playlist_play - {{ group.key }} - ({{ $any(group).value.length }} items) -

-
- @for (item of $any(group).value; track item.id) { - - } + @if (groupByPlaylist()) { + +
+

+ Found {{ xtreamStore.searchResults().length }} items across + {{ (groupedResults() | keyvalue).length }} playlists +

+ @for ( + group of groupedResults() | keyvalue; + track group.key + ) { +
+

+ playlist_play + {{ group.key }} + ({{ $any(group).value.length }} items) +

+
+ @for (item of $any(group).value; track item.id) { + + } +
-
- } -
+ } +
+ } @else { + +
+ @for (item of xtreamStore.searchResults(); track item.id) { + + } +
+ } } @else {
diff --git a/apps/web/src/app/xtream-tauri/search-results/search-results.component.scss b/apps/web/src/app/xtream-tauri/search-results/search-results.component.scss index d9aa5794e..3cfe3a269 100644 --- a/apps/web/src/app/xtream-tauri/search-results/search-results.component.scss +++ b/apps/web/src/app/xtream-tauri/search-results/search-results.component.scss @@ -55,6 +55,33 @@ } } +// Subheader row for global search (note + grouping toggle) +.global-search-subheader { + display: flex; + align-items: center; + justify-content: space-between; + gap: 16px; + + .global-search-note { + display: flex; + align-items: center; + gap: 6px; + font-size: 0.8rem; + opacity: 0.55; + + mat-icon { + font-size: 15px; + width: 15px; + height: 15px; + flex-shrink: 0; + } + } + + ::ng-deep mat-checkbox { + flex-shrink: 0; + } +} + // Playlist grouping styles for global search .playlist-group { margin-bottom: 2rem; diff --git a/apps/web/src/app/xtream-tauri/search-results/search-results.component.ts b/apps/web/src/app/xtream-tauri/search-results/search-results.component.ts index 2cf9b967a..c1387c6cb 100644 --- a/apps/web/src/app/xtream-tauri/search-results/search-results.component.ts +++ b/apps/web/src/app/xtream-tauri/search-results/search-results.component.ts @@ -2,10 +2,12 @@ import { KeyValuePipe } from '@angular/common'; import { AfterViewInit, Component, + computed, effect, inject, Inject, Optional, + signal, viewChild, } from '@angular/core'; import { FormsModule } from '@angular/forms'; @@ -24,7 +26,6 @@ import { XtreamContentItem } from '../data-sources/xtream-data-source.interface' import { ContentType } from '../xtream-state'; import { ContentCardComponent } from '../../shared/components/content-card/content-card.component'; import { SearchLayoutComponent } from '../../shared/components/search-layout/search-layout.component'; -import { SearchResultItemComponent } from '../../shared/components/search-result-item/search-result-item.component'; import { XtreamStore } from '../stores/xtream.store'; import { SearchFilters } from '../stores/features/with-search.feature'; @@ -42,7 +43,6 @@ interface SearchResultsData { MatDialogModule, MatIcon, SearchLayoutComponent, - SearchResultItemComponent, TranslatePipe, ], providers: [], @@ -62,8 +62,15 @@ export class SearchResultsComponent implements AfterViewInit { /** Search filters from store */ readonly filters = this.xtreamStore.searchFilters; + private static readonly GROUP_BY_STORAGE_KEY = 'global-search-group-by-playlist'; + isGlobalSearch = false; + /** Whether to group global search results by playlist */ + readonly groupByPlaylist = signal( + localStorage.getItem(SearchResultsComponent.GROUP_BY_STORAGE_KEY) !== 'false' + ); + readonly filterConfig = [ { key: 'live' as keyof SearchFilters, @@ -82,16 +89,24 @@ export class SearchResultsComponent implements AfterViewInit { }, ]; + /** Grouped results computed once per result change (avoids recalculating on every CD cycle) */ + readonly groupedResults = computed(() => { + const results = this.xtreamStore.searchResults(); + if (!this.isGlobalSearch) return { default: results }; + return groupBy(results, 'playlist_name'); + }); + constructor( @Optional() @Inject(MAT_DIALOG_DATA) data: SearchResultsData, @Optional() public dialogRef: MatDialogRef ) { this.isGlobalSearch = data?.isGlobalSearch || false; - effect(() => { + effect((onCleanup) => { const term = this.searchTerm(); if (term.length >= 3) { - this.executeSearch(); + const timeout = setTimeout(() => this.executeSearch(), 300); + onCleanup(() => clearTimeout(timeout)); } else if (term.length === 0) { this.clearResultsOnly(); } @@ -146,6 +161,7 @@ export class SearchResultsComponent implements AfterViewInit { } async searchGlobal(term: string, types: string[]) { + this.xtreamStore.setIsSearching(true); try { const results = await this.databaseService.globalSearchContent( term, @@ -153,6 +169,8 @@ export class SearchResultsComponent implements AfterViewInit { ); if (results && Array.isArray(results)) { this.xtreamStore.setGlobalSearchResults(results); + } else { + this.xtreamStore.setIsSearching(false); } } catch (error) { console.error('Error in global search:', error); @@ -188,9 +206,12 @@ export class SearchResultsComponent implements AfterViewInit { this.dialogRef?.close(); } + toggleGroupByPlaylist(value: boolean) { + this.groupByPlaylist.set(value); + localStorage.setItem(SearchResultsComponent.GROUP_BY_STORAGE_KEY, String(value)); + } + getGroupedResults() { - const results = this.xtreamStore.searchResults(); - if (!this.isGlobalSearch) return { default: results }; - return groupBy(results, 'playlist_name'); + return this.groupedResults(); } } diff --git a/apps/web/src/app/xtream-tauri/stores/features/with-search.feature.ts b/apps/web/src/app/xtream-tauri/stores/features/with-search.feature.ts index f7469da03..aa11df16e 100644 --- a/apps/web/src/app/xtream-tauri/stores/features/with-search.feature.ts +++ b/apps/web/src/app/xtream-tauri/stores/features/with-search.feature.ts @@ -113,9 +113,17 @@ export function withSearch() { patchState(store, { searchResults: results as any, globalSearchResults: results, + isSearching: false, }); }, + /** + * Set the searching state + */ + setIsSearching(value: boolean): void { + patchState(store, { isSearching: value }); + }, + /** * Set the search term */