mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-08 17:06:15 -08:00
feat: enhance search functionality with improved filtering and UI updates
This commit is contained in:
1 parent
f5edba7d4c
commit
c253db4cef
10 files changed
+192
-203
No files matched your search
@@ -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;
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
<div class="search-container">
|
||||
<mat-form-field appearance="outline" class="search-field">
|
||||
<mat-form-field
|
||||
appearance="outline"
|
||||
class="search-field"
|
||||
subscriptSizing="dynamic"
|
||||
>
|
||||
<mat-label>{{ placeholder }}</mat-label>
|
||||
<input
|
||||
#searchInput
|
||||
@@ -8,7 +12,13 @@
|
||||
[ngModel]="searchTerm()"
|
||||
(ngModelChange)="onSearchTermChange()"
|
||||
/>
|
||||
<mat-icon matSuffix>search</mat-icon>
|
||||
@if (searchTerm()) {
|
||||
<button mat-icon-button matSuffix (click)="clearSearch()">
|
||||
<mat-icon>close</mat-icon>
|
||||
</button>
|
||||
} @else {
|
||||
<mat-icon matSuffix>search</mat-icon>
|
||||
}
|
||||
</mat-form-field>
|
||||
|
||||
@if (filterConfig.length > 0) {
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -17,7 +17,10 @@
|
||||
<div class="header-right">
|
||||
@if (showResultsCount() && resultsCount() > 0) {
|
||||
<div class="results-count">
|
||||
Found {{ resultsCount() }} results
|
||||
{{
|
||||
'PORTALS.SEARCH_VIEW.RESULTS_FOUND'
|
||||
| translate: { count: resultsCount() }
|
||||
}}
|
||||
</div>
|
||||
}
|
||||
@if (showCloseButton()) {
|
||||
@@ -36,6 +39,9 @@
|
||||
(searchTermChange)="onSearchTermChange($event)"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Subheader slot (info notes, secondary controls) -->
|
||||
<ng-content select="[subheader]" />
|
||||
</div>
|
||||
|
||||
<div class="results-container">
|
||||
@@ -52,7 +58,12 @@
|
||||
<div class="no-items-content">
|
||||
<h3>{{ 'PORTALS.EMPTY_LIST_VIEW.TITLE' | translate }}</h3>
|
||||
<p>{{ 'PORTALS.EMPTY_LIST_VIEW.NO_SEARCH_RESULTS' | translate }}</p>
|
||||
<p class="search-term-note">No results found for "{{ searchTerm() }}"</p>
|
||||
<p class="search-term-note">
|
||||
{{
|
||||
'PORTALS.SEARCH_VIEW.NO_RESULTS_FOR'
|
||||
| translate: { term: searchTerm() }
|
||||
}}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
} @else if (showInitialState) {
|
||||
|
||||
@@ -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;
|
||||
|
||||
-139
@@ -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: `
|
||||
<mat-card (click)="itemClick.emit()">
|
||||
@if (posterUrl) {
|
||||
<img
|
||||
[src]="posterUrl"
|
||||
[alt]="title"
|
||||
(error)="
|
||||
$event.target.src = './assets/images/default-poster.png'
|
||||
"
|
||||
class="poster"
|
||||
/>
|
||||
} @else {
|
||||
<div class="poster-placeholder">
|
||||
<mat-icon>movie</mat-icon>
|
||||
</div>
|
||||
}
|
||||
<h4>{{ title }}</h4>
|
||||
@if (description) {
|
||||
<p>{{ description }}</p>
|
||||
}
|
||||
@if (showPlaylistInfo && playlistName) {
|
||||
<div class="playlist-badge">
|
||||
<mat-icon>playlist_play</mat-icon>
|
||||
<span>{{ playlistName }}</span>
|
||||
</div>
|
||||
}
|
||||
</mat-card>
|
||||
`,
|
||||
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<void>();
|
||||
}
|
||||
@@ -3,11 +3,11 @@
|
||||
[resultsCount]="xtreamStore.searchResults().length"
|
||||
[isLoading]="xtreamStore.isSearching()"
|
||||
[showCloseButton]="isGlobalSearch"
|
||||
[showResultsCount]="!isGlobalSearch"
|
||||
[showResultsCount]="!isGlobalSearch || !groupByPlaylist()"
|
||||
(searchTermChange)="updateSearchTerm($event)"
|
||||
(closeClick)="onCloseDialog()"
|
||||
>
|
||||
<!-- Filters -->
|
||||
<!-- Type filters -->
|
||||
<ng-container filters>
|
||||
@for (filter of filterConfig; track filter.key) {
|
||||
<mat-checkbox
|
||||
@@ -19,43 +19,74 @@
|
||||
}
|
||||
</ng-container>
|
||||
|
||||
<ng-container subheader>
|
||||
@if (isGlobalSearch) {
|
||||
<div class="global-search-subheader">
|
||||
<div class="global-search-note">
|
||||
<mat-icon>info_outline</mat-icon>
|
||||
<span>{{ 'PORTALS.SEARCH_VIEW.XTREAM_ONLY_NOTE' | translate }}</span>
|
||||
</div>
|
||||
<mat-checkbox
|
||||
[ngModel]="groupByPlaylist()"
|
||||
(ngModelChange)="toggleGroupByPlaylist($event)"
|
||||
>
|
||||
{{ 'PORTALS.SEARCH_VIEW.GROUP_BY_PLAYLIST' | translate }}
|
||||
</mat-checkbox>
|
||||
</div>
|
||||
}
|
||||
</ng-container>
|
||||
|
||||
<!-- Results -->
|
||||
<ng-container results>
|
||||
@if (isGlobalSearch) {
|
||||
<!-- Global Search Results -->
|
||||
<div class="playlist-section">
|
||||
<h2 class="section-title">
|
||||
Found {{ xtreamStore.searchResults().length }} items across
|
||||
{{ (getGroupedResults() | keyvalue).length }} playlists
|
||||
</h2>
|
||||
@for (
|
||||
group of getGroupedResults() | keyvalue;
|
||||
track group.key
|
||||
) {
|
||||
<div class="playlist-group">
|
||||
<h3 class="playlist-title">
|
||||
<mat-icon>playlist_play</mat-icon>
|
||||
{{ group.key }}
|
||||
<span class="item-count"
|
||||
>({{ $any(group).value.length }} items)</span
|
||||
>
|
||||
</h3>
|
||||
<div class="results-grid">
|
||||
@for (item of $any(group).value; track item.id) {
|
||||
<app-search-result-item
|
||||
[title]="item.title"
|
||||
[description]="item.description"
|
||||
[posterUrl]="item.poster_url"
|
||||
[type]="item.type"
|
||||
[playlistName]="item.playlist_name"
|
||||
[showPlaylistInfo]="isGlobalSearch"
|
||||
(itemClick)="selectItem(item)"
|
||||
/>
|
||||
}
|
||||
@if (groupByPlaylist()) {
|
||||
<!-- Grouped by playlist -->
|
||||
<div class="playlist-section">
|
||||
<h2 class="section-title">
|
||||
Found {{ xtreamStore.searchResults().length }} items across
|
||||
{{ (groupedResults() | keyvalue).length }} playlists
|
||||
</h2>
|
||||
@for (
|
||||
group of groupedResults() | keyvalue;
|
||||
track group.key
|
||||
) {
|
||||
<div class="playlist-group">
|
||||
<h3 class="playlist-title">
|
||||
<mat-icon>playlist_play</mat-icon>
|
||||
{{ group.key }}
|
||||
<span class="item-count"
|
||||
>({{ $any(group).value.length }} items)</span
|
||||
>
|
||||
</h3>
|
||||
<div class="results-grid">
|
||||
@for (item of $any(group).value; track item.id) {
|
||||
<app-content-card
|
||||
[posterUrl]="item.poster_url"
|
||||
[title]="item.title"
|
||||
[type]="item.type"
|
||||
[showPlaceholder]="true"
|
||||
(cardClick)="selectItem(item)"
|
||||
/>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
} @else {
|
||||
<!-- Flat list -->
|
||||
<div class="results-grid">
|
||||
@for (item of xtreamStore.searchResults(); track item.id) {
|
||||
<app-content-card
|
||||
[posterUrl]="item.poster_url"
|
||||
[title]="item.title"
|
||||
[type]="item.type"
|
||||
[showPlaceholder]="true"
|
||||
(cardClick)="selectItem(item)"
|
||||
/>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
} @else {
|
||||
<!-- Playlist-level Search Results -->
|
||||
<div class="results-grid">
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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<SearchResultsComponent>
|
||||
) {
|
||||
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();
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
*/
|
||||
|
||||
Reference in new issue
Block a user