mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-08 17:06:15 -08:00
feat: keep filter search phrase and results when navigating back from details view
This commit is contained in:
1 parent
b101dfaf91
commit
139944281c
4 files changed
+182
-113
No files matched your search
@@ -7,8 +7,8 @@
|
||||
<div class="type-filters">
|
||||
@for (filter of filterConfig; track filter.key) {
|
||||
<mat-checkbox
|
||||
[(ngModel)]="filters[filter.key]"
|
||||
(ngModelChange)="executeSearch()"
|
||||
[ngModel]="filters()[filter.key]"
|
||||
(ngModelChange)="updateFilter(filter.key, $event)"
|
||||
>
|
||||
{{ filter.translationKey | translate }}
|
||||
</mat-checkbox>
|
||||
@@ -32,9 +32,8 @@
|
||||
|
||||
<div class="search-input-container">
|
||||
<app-search-form
|
||||
[(searchTerm)]="searchTerm"
|
||||
[filters]="filters"
|
||||
[filterConfig]="[]"
|
||||
[searchTerm]="searchTerm()"
|
||||
(searchTermChange)="updateSearchTerm($event)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -76,8 +75,8 @@
|
||||
<!-- Playlist-level Search Results -->
|
||||
<div class="results-grid">
|
||||
@for (item of xtreamStore.searchResults(); track item.id) {
|
||||
<mat-card (click)="selectItem(item)">
|
||||
<mat-card-content>
|
||||
<div class="result-card" (click)="selectItem(item)">
|
||||
<div class="thumbnail-wrapper">
|
||||
@if (item.poster_url) {
|
||||
<img
|
||||
[src]="item.poster_url"
|
||||
@@ -88,28 +87,19 @@
|
||||
"
|
||||
class="poster"
|
||||
/>
|
||||
} @if (!item.poster_url) {
|
||||
} @else {
|
||||
<div class="poster-placeholder">
|
||||
<mat-icon>movie</mat-icon>
|
||||
<mat-icon>{{ item.type === 'live' ? 'live_tv' : item.type === 'series' ? 'tv' : 'movie' }}</mat-icon>
|
||||
</div>
|
||||
}
|
||||
<h3>{{ item.title }}</h3>
|
||||
<p>{{ item.description }}</p>
|
||||
<div class="type-badge" [class]="item.type">
|
||||
<span class="type-badge" [class]="item.type">
|
||||
{{ item.type }}
|
||||
</div>
|
||||
@if (item.playlist_id && isGlobalSearch) {
|
||||
<div class="playlist-badge">
|
||||
{{ item.playlist_name }}
|
||||
</div>
|
||||
} @if (isGlobalSearch && item.playlist_name) {
|
||||
<div class="playlist-badge">
|
||||
<mat-icon>playlist_play</mat-icon>
|
||||
<span>{{ item.playlist_name }}</span>
|
||||
</div>
|
||||
}
|
||||
</mat-card-content>
|
||||
</mat-card>
|
||||
</span>
|
||||
</div>
|
||||
<div class="card-info">
|
||||
<h3>{{ item.title }}</h3>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
} } @else if (searchTerm() && searchTerm().length >= 3) {
|
||||
|
||||
@@ -63,12 +63,8 @@
|
||||
|
||||
.results-count {
|
||||
font-size: 0.85rem;
|
||||
color: var(--text-color-secondary, rgba(0,0,0,0.7));
|
||||
background: rgba(0, 0, 0, 0.06);
|
||||
padding: 6px 12px;
|
||||
border-radius: 16px;
|
||||
white-space: nowrap;
|
||||
font-weight: 500;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
@@ -85,81 +81,85 @@
|
||||
|
||||
.results-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(
|
||||
auto-fill,
|
||||
minmax(200px, 1fr)
|
||||
);
|
||||
gap: 1rem;
|
||||
grid-template-columns: repeat(auto-fill, minmax(140px, 1fr));
|
||||
gap: 20px;
|
||||
padding-bottom: 1rem;
|
||||
|
||||
mat-card {
|
||||
.result-card {
|
||||
cursor: pointer;
|
||||
transition: transform 0.2s, box-shadow 0.2s;
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
transition: transform 0.2s ease, box-shadow 0.2s ease;
|
||||
|
||||
&:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 4px 12px rgba(0,0,0,0.1);
|
||||
transform: translateY(-3px);
|
||||
box-shadow: 0 6px 16px rgba(0, 0, 0, 0.12);
|
||||
}
|
||||
|
||||
.poster,
|
||||
.poster-placeholder {
|
||||
border-radius: 8px;
|
||||
width: 100%;
|
||||
aspect-ratio: 2/3;
|
||||
object-fit: cover;
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
.poster-placeholder {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: rgba(0,0,0,0.05);
|
||||
|
||||
mat-icon {
|
||||
font-size: 48px;
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
opacity: 0.3;
|
||||
}
|
||||
}
|
||||
h3 {
|
||||
margin: 0.5rem 0;
|
||||
font-size: 1rem;
|
||||
font-weight: 500;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
p {
|
||||
font-size: 0.85rem;
|
||||
color: var(--text-color-secondary, rgba(0, 0, 0, 0.6));
|
||||
margin: 0 0 0.75rem 0;
|
||||
line-height: 1.4;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
}
|
||||
.type-badge {
|
||||
display: inline-block;
|
||||
padding: 0.25rem 0.5rem;
|
||||
border-radius: 4px;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
.thumbnail-wrapper {
|
||||
position: relative;
|
||||
|
||||
&.live {
|
||||
background: rgba(229, 57, 53, 0.1);
|
||||
color: #e53935;
|
||||
.poster,
|
||||
.poster-placeholder {
|
||||
width: 100%;
|
||||
aspect-ratio: 2/3;
|
||||
object-fit: cover;
|
||||
display: block;
|
||||
}
|
||||
&.movie {
|
||||
background: rgba(30, 136, 229, 0.1);
|
||||
color: #1e88e5;
|
||||
|
||||
.poster-placeholder {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: rgba(128, 128, 128, 0.1);
|
||||
|
||||
mat-icon {
|
||||
font-size: 36px;
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
opacity: 0.25;
|
||||
}
|
||||
}
|
||||
&.series {
|
||||
background: rgba(67, 160, 71, 0.1);
|
||||
color: #43a047;
|
||||
|
||||
.type-badge {
|
||||
position: absolute;
|
||||
top: 6px;
|
||||
left: 6px;
|
||||
padding: 3px 6px;
|
||||
border-radius: 4px;
|
||||
font-size: 9px;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
backdrop-filter: blur(8px);
|
||||
-webkit-backdrop-filter: blur(8px);
|
||||
background: rgba(0, 0, 0, 0.6);
|
||||
color: #fff;
|
||||
|
||||
&.live {
|
||||
color: #ef5350;
|
||||
}
|
||||
&.movie {
|
||||
color: #42a5f5;
|
||||
}
|
||||
&.series {
|
||||
color: #66bb6a;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.card-info {
|
||||
padding: 8px 10px;
|
||||
|
||||
h3 {
|
||||
margin: 0;
|
||||
font-size: 0.8rem;
|
||||
font-weight: 500;
|
||||
line-height: 1.3;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,8 +6,7 @@ import {
|
||||
inject,
|
||||
Inject,
|
||||
Optional,
|
||||
signal,
|
||||
ViewChild,
|
||||
viewChild,
|
||||
} from '@angular/core';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import { MatIconButton } from '@angular/material/button';
|
||||
@@ -30,6 +29,7 @@ import { ContentType } from '../xtream-state';
|
||||
import { SearchFormComponent } from '../../shared/components/search-form/search-form.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';
|
||||
|
||||
interface SearchResultsData {
|
||||
isGlobalSearch: boolean;
|
||||
@@ -56,32 +56,33 @@ interface SearchResultsData {
|
||||
styleUrls: ['./search-results.component.scss'],
|
||||
})
|
||||
export class SearchResultsComponent implements AfterViewInit {
|
||||
@ViewChild(SearchFormComponent) searchFormComponent!: SearchFormComponent;
|
||||
readonly searchFormComponent = viewChild(SearchFormComponent);
|
||||
readonly xtreamStore = inject(XtreamStore);
|
||||
readonly router = inject(Router);
|
||||
readonly activatedRoute = inject(ActivatedRoute);
|
||||
readonly databaseService = inject(DatabaseService);
|
||||
searchTerm = signal('');
|
||||
filters = {
|
||||
live: true,
|
||||
movie: true,
|
||||
series: true,
|
||||
};
|
||||
|
||||
/** Search term from store */
|
||||
readonly searchTerm = this.xtreamStore.searchTerm;
|
||||
|
||||
/** Search filters from store */
|
||||
readonly filters = this.xtreamStore.searchFilters;
|
||||
|
||||
isGlobalSearch = false;
|
||||
|
||||
readonly filterConfig = [
|
||||
{
|
||||
key: 'live',
|
||||
key: 'live' as keyof SearchFilters,
|
||||
label: 'Live TV',
|
||||
translationKey: 'PORTALS.SIDEBAR.LIVE_TV',
|
||||
},
|
||||
{
|
||||
key: 'movie',
|
||||
key: 'movie' as keyof SearchFilters,
|
||||
label: 'Movies',
|
||||
translationKey: 'PORTALS.SIDEBAR.MOVIES',
|
||||
},
|
||||
{
|
||||
key: 'series',
|
||||
key: 'series' as keyof SearchFilters,
|
||||
label: 'Series',
|
||||
translationKey: 'PORTALS.SIDEBAR.SERIES',
|
||||
},
|
||||
@@ -94,11 +95,12 @@ export class SearchResultsComponent implements AfterViewInit {
|
||||
this.isGlobalSearch = data?.isGlobalSearch || false;
|
||||
|
||||
effect(() => {
|
||||
this.searchTerm();
|
||||
if (this.searchTerm().length >= 3) {
|
||||
const term = this.searchTerm();
|
||||
if (term.length >= 3) {
|
||||
this.executeSearch();
|
||||
} else {
|
||||
this.xtreamStore.resetSearchResults();
|
||||
} else if (term.length === 0) {
|
||||
// Only clear results when search term is completely empty
|
||||
this.clearResultsOnly();
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -106,12 +108,13 @@ export class SearchResultsComponent implements AfterViewInit {
|
||||
ngAfterViewInit() {
|
||||
this.xtreamStore.setSelectedContentType(undefined);
|
||||
setTimeout(() => {
|
||||
this.searchFormComponent?.focusSearchInput();
|
||||
this.searchFormComponent()?.focusSearchInput();
|
||||
});
|
||||
}
|
||||
|
||||
executeSearch() {
|
||||
const types = Object.entries(this.filters)
|
||||
const filters = this.filters();
|
||||
const types = Object.entries(filters)
|
||||
.filter(([_, enabled]) => enabled)
|
||||
.map(([type]) => type);
|
||||
|
||||
@@ -125,6 +128,31 @@ export class SearchResultsComponent implements AfterViewInit {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update search term in the store
|
||||
*/
|
||||
updateSearchTerm(term: string) {
|
||||
this.xtreamStore.setSearchTerm(term);
|
||||
}
|
||||
|
||||
/**
|
||||
* Update a single filter in the store
|
||||
*/
|
||||
updateFilter(key: keyof SearchFilters, value: boolean) {
|
||||
this.xtreamStore.updateSearchFilter(key, value);
|
||||
// Re-execute search when filter changes
|
||||
if (this.searchTerm().length >= 3) {
|
||||
this.executeSearch();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear only the results, not the search term/filters
|
||||
*/
|
||||
private clearResultsOnly() {
|
||||
this.xtreamStore.setGlobalSearchResults([]);
|
||||
}
|
||||
|
||||
async searchGlobal(term: string, types: string[]) {
|
||||
try {
|
||||
const results = await this.databaseService.globalSearchContent(
|
||||
@@ -153,7 +181,7 @@ export class SearchResultsComponent implements AfterViewInit {
|
||||
]);
|
||||
} else {
|
||||
const type = (item.type === 'movie' ? 'vod' : item.type) as ContentType;
|
||||
this.xtreamStore.resetSearchResults();
|
||||
// Don't reset search results - preserve them for back navigation
|
||||
this.xtreamStore.setSelectedContentType(type);
|
||||
|
||||
this.router.navigate(
|
||||
|
||||
@@ -11,19 +11,41 @@ import {
|
||||
XtreamContentItem,
|
||||
} from '../../data-sources/xtream-data-source.interface';
|
||||
|
||||
/**
|
||||
* Search filters configuration
|
||||
*/
|
||||
export interface SearchFilters {
|
||||
live: boolean;
|
||||
movie: boolean;
|
||||
series: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Search state for managing search results
|
||||
*/
|
||||
export interface SearchState {
|
||||
searchTerm: string;
|
||||
searchFilters: SearchFilters;
|
||||
searchResults: XtreamContentItem[];
|
||||
globalSearchResults: GlobalSearchResult[];
|
||||
isSearching: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Initial search filters
|
||||
*/
|
||||
const initialSearchFilters: SearchFilters = {
|
||||
live: true,
|
||||
movie: true,
|
||||
series: true,
|
||||
};
|
||||
|
||||
/**
|
||||
* Initial search state
|
||||
*/
|
||||
const initialSearchState: SearchState = {
|
||||
searchTerm: '',
|
||||
searchFilters: initialSearchFilters,
|
||||
searchResults: [],
|
||||
globalSearchResults: [],
|
||||
isSearching: false,
|
||||
@@ -94,6 +116,35 @@ export function withSearch() {
|
||||
});
|
||||
},
|
||||
|
||||
/**
|
||||
* Set the search term
|
||||
*/
|
||||
setSearchTerm(term: string): void {
|
||||
patchState(store, { searchTerm: term });
|
||||
},
|
||||
|
||||
/**
|
||||
* Set search filters
|
||||
*/
|
||||
setSearchFilters(filters: SearchFilters): void {
|
||||
patchState(store, { searchFilters: filters });
|
||||
},
|
||||
|
||||
/**
|
||||
* Update a single filter
|
||||
*/
|
||||
updateSearchFilter(
|
||||
key: keyof SearchFilters,
|
||||
value: boolean
|
||||
): void {
|
||||
patchState(store, {
|
||||
searchFilters: {
|
||||
...store.searchFilters(),
|
||||
[key]: value,
|
||||
},
|
||||
});
|
||||
},
|
||||
|
||||
/**
|
||||
* Clear search results
|
||||
*/
|
||||
|
||||
Reference in new issue
Block a user