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 8aa520953..498bdb4d1 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 @@ -7,8 +7,8 @@
@for (filter of filterConfig; track filter.key) { {{ filter.translationKey | translate }} @@ -32,9 +32,8 @@
@@ -76,8 +75,8 @@
@for (item of xtreamStore.searchResults(); track item.id) { - - +
+
@if (item.poster_url) { - } @if (!item.poster_url) { + } @else {
- movie + {{ item.type === 'live' ? 'live_tv' : item.type === 'series' ? 'tv' : 'movie' }}
} -

{{ item.title }}

-

{{ item.description }}

-
+ {{ item.type }} -
- @if (item.playlist_id && isGlobalSearch) { -
- {{ item.playlist_name }} -
- } @if (isGlobalSearch && item.playlist_name) { -
- playlist_play - {{ item.playlist_name }} -
- } - - + +
+
+

{{ item.title }}

+
+
}
} } @else if (searchTerm() && searchTerm().length >= 3) { 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 532921c11..823db5e3d 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 @@ -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; } } } 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 b24d965e8..4f020a352 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 @@ -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( 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 fcc009566..f7469da03 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 @@ -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 */