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 new file mode 100644 index 000000000..9a7d8dd54 --- /dev/null +++ b/apps/web/src/app/shared/components/search-layout/search-layout.component.html @@ -0,0 +1,71 @@ +
+ + + +
+ + +
+ @if (isLoading()) { +
+ +
+ } @else if (showResults) { + + + } @else if (showNoResults) { +
+ search_off +
+

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

+

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

+

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

+
+
+ } @else if (showInitialState) { +
+ search +
+

{{ 'PORTALS.SEARCH_VIEW.INITIAL_TITLE' | translate }}

+

{{ 'PORTALS.SEARCH_VIEW.INITIAL_DESCRIPTION' | translate }}

+
+
+ } + + +
+
+
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 new file mode 100644 index 000000000..21062809e --- /dev/null +++ b/apps/web/src/app/shared/components/search-layout/search-layout.component.scss @@ -0,0 +1,165 @@ +@use '../../styles/content-grid' as grid; + +:host { + display: flex; + flex-direction: column; + overflow-y: auto; + height: 100%; + width: 100%; +} + +.search-page { + display: flex; + flex-direction: column; + height: 100%; + width: 100%; +} + +.search-content { + display: flex; + flex-direction: column; + flex: 1; + overflow-y: auto; + padding: 0 20px 20px 20px; +} + +.header { + position: sticky; + top: 0; + z-index: 100; + background: var(--background, var(--mat-sidenav-content-background-color, #fff)); + padding: 20px 0 10px 0; + margin-bottom: 20px; + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.02); + + display: flex; + flex-direction: column; + align-items: stretch; + gap: 16px; + + .header-top { + display: flex; + justify-content: space-between; + align-items: center; + gap: 20px; + } + + .header-left { + display: flex; + align-items: center; + gap: 32px; + flex: 1; + flex-wrap: wrap; + + h2 { + margin: 0; + white-space: nowrap; + } + + .type-filters { + display: flex; + gap: 20px; + align-items: center; + + ::ng-deep mat-checkbox { + margin: 0; + } + } + } + + .header-right { + display: flex; + align-items: center; + gap: 16px; + + .results-count { + font-size: 0.85rem; + padding: 6px 12px; + white-space: nowrap; + display: flex; + align-items: center; + } + } + + .search-input-container { + margin-bottom: 0; + } +} + +.results-container { + flex: 1; + overflow-y: auto; +} + +.loading-state { + display: flex; + justify-content: center; + align-items: center; + padding: 4rem 2rem; +} + +.no-items { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + padding: 4rem 2rem; + text-align: center; + + mat-icon { + font-size: 64px; + width: 64px; + height: 64px; + margin-bottom: 1.5rem; + opacity: 0.5; + } + + .no-items-content { + h3 { + margin: 0 0 0.5rem; + font-size: 1.5rem; + font-weight: 500; + color: var(--text-color); + } + + p { + font-size: 1.1rem; + margin: 0; + opacity: 0.8; + } + + .search-term-note { + margin-top: 1rem; + font-size: 0.9rem; + font-style: italic; + opacity: 0.6; + } + } + + &.initial-state { + opacity: 0.8; + + mat-icon { + color: var(--primary-color); + opacity: 0.6; + } + } +} + +// Results grid mixin for consumers +::ng-deep .results-grid { + @include grid.content-grid(140px, 20px); + padding-bottom: 1rem; +} + +// Global search overlay context +:host-context(.global-search-overlay) { + .search-page { + background: var(--background); + height: 100vh; + } + + .header { + background: var(--background); + } +} diff --git a/apps/web/src/app/shared/components/search-layout/search-layout.component.ts b/apps/web/src/app/shared/components/search-layout/search-layout.component.ts new file mode 100644 index 000000000..e2e2d419e --- /dev/null +++ b/apps/web/src/app/shared/components/search-layout/search-layout.component.ts @@ -0,0 +1,94 @@ +import { + ChangeDetectionStrategy, + Component, + ElementRef, + input, + output, + viewChild, +} from '@angular/core'; +import { MatIconButton } from '@angular/material/button'; +import { MatIcon } from '@angular/material/icon'; +import { MatProgressSpinner } from '@angular/material/progress-spinner'; +import { TranslatePipe } from '@ngx-translate/core'; +import { SearchFormComponent } from '../search-form/search-form.component'; + +@Component({ + selector: 'app-search-layout', + standalone: true, + imports: [ + MatIcon, + MatIconButton, + MatProgressSpinner, + SearchFormComponent, + TranslatePipe, + ], + templateUrl: './search-layout.component.html', + styleUrl: './search-layout.component.scss', + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class SearchLayoutComponent { + private readonly searchFormComponent = viewChild(SearchFormComponent); + + /** Page title translation key */ + readonly title = input('PORTALS.SIDEBAR.SEARCH'); + + /** Current search term */ + readonly searchTerm = input(''); + + /** Number of results found */ + readonly resultsCount = input(0); + + /** Whether search is in progress */ + readonly isLoading = input(false); + + /** Whether to show the close button (for dialog mode) */ + readonly showCloseButton = input(false); + + /** Whether to show results count */ + readonly showResultsCount = input(true); + + /** Minimum characters required for search */ + readonly minSearchLength = input(3); + + /** Emitted when search term changes */ + readonly searchTermChange = output(); + + /** Emitted when close button is clicked */ + readonly closeClick = output(); + + /** Focus the search input */ + focusSearchInput(): void { + this.searchFormComponent()?.focusSearchInput(); + } + + onSearchTermChange(term: string): void { + this.searchTermChange.emit(term); + } + + onCloseClick(): void { + this.closeClick.emit(); + } + + /** Check if we should show the "no results" state */ + get showNoResults(): boolean { + return ( + this.searchTerm().length >= this.minSearchLength() && + this.resultsCount() === 0 && + !this.isLoading() + ); + } + + /** Check if we should show the initial state */ + get showInitialState(): boolean { + return ( + this.searchTerm().length < this.minSearchLength() && + this.resultsCount() === 0 && + !this.isLoading() + ); + } + + /** Check if we should show results */ + get showResults(): boolean { + return this.resultsCount() > 0 && !this.isLoading(); + } +} diff --git a/apps/web/src/app/stalker/stalker-search/stalker-search.component.html b/apps/web/src/app/stalker/stalker-search/stalker-search.component.html index f1de8f542..b34713c25 100644 --- a/apps/web/src/app/stalker/stalker-search/stalker-search.component.html +++ b/apps/web/src/app/stalker/stalker-search/stalker-search.component.html @@ -1,76 +1,65 @@ -@if (itemDetails) { - @switch (selectedFilterType()) { - @case ('vod') { - @if (itemDetails.series?.length > 0) { - - - } @else if (itemDetails.is_series === true) { - - - } @else if (vodDetailsItem) { - - - } - } - @case ('series') { - - } - } -} - -
- @if (!itemDetails) { -
-

{{ 'PORTALS.SIDEBAR.SEARCH' | translate }}

-
- -
- @if (searchResultsResource.isLoading()) { - - } @else { -
- @for ( - item of searchResultsResource.value(); - track item.id - ) { - - } -
- @if (searchResultsResource.value().length === 0) { -
- -
+@if (showingDetails) { + +
+ @switch (selectedFilterType()) { + @case ('vod') { + @if (itemDetails.series?.length > 0) { + + + } @else if (itemDetails.is_series === true) { + + + } @else if (vodDetailsItem) { + + } } -
- } -
+ @case ('series') { + + } + } +
+} @else { + + + + @for (filter of filterConfig; track filter.key) { + + {{ filter.translationKey | translate }} + + } + + + + +
+ @for (item of searchResultsResource.value(); track item.id) { + + } +
+
+
+} diff --git a/apps/web/src/app/stalker/stalker-search/stalker-search.component.scss b/apps/web/src/app/stalker/stalker-search/stalker-search.component.scss new file mode 100644 index 000000000..7c42e9336 --- /dev/null +++ b/apps/web/src/app/stalker/stalker-search/stalker-search.component.scss @@ -0,0 +1,20 @@ +@use '../../shared/styles/content-grid' as grid; + +:host { + display: flex; + flex-direction: column; + overflow-y: auto; + height: 100%; + width: 100%; +} + +.details-view { + height: 100%; + width: 100%; + overflow-y: auto; +} + +.results-grid { + @include grid.content-grid(140px, 20px); + padding-bottom: 1rem; +} diff --git a/apps/web/src/app/stalker/stalker-search/stalker-search.component.ts b/apps/web/src/app/stalker/stalker-search/stalker-search.component.ts index 00aa1d97e..a1371e517 100644 --- a/apps/web/src/app/stalker/stalker-search/stalker-search.component.ts +++ b/apps/web/src/app/stalker/stalker-search/stalker-search.component.ts @@ -1,6 +1,6 @@ import { Component, inject, resource, signal } from '@angular/core'; -import { MatCardModule } from '@angular/material/card'; -import { MatProgressSpinner } from '@angular/material/progress-spinner'; +import { FormsModule } from '@angular/forms'; +import { MatCheckboxModule } from '@angular/material/checkbox'; import { ActivatedRoute } from '@angular/router'; import { Store } from '@ngrx/store'; import { TranslatePipe } from '@ngx-translate/core'; @@ -10,47 +10,36 @@ import { Playlist, STALKER_REQUEST, StalkerPortalActions, - VodDetailsItem, StalkerVodDetails, + VodDetailsItem, createStalkerVodItem, } from 'shared-interfaces'; -import { SearchFormComponent } from '../../shared/components/search-form/search-form.component'; -import { SearchResultItemComponent } from '../../shared/components/search-result-item/search-result-item.component'; -import { PlaylistErrorViewComponent } from '../../xtream-tauri/playlist-error-view/playlist-error-view.component'; +import { ContentCardComponent } from '../../shared/components/content-card/content-card.component'; +import { SearchLayoutComponent } from '../../shared/components/search-layout/search-layout.component'; import { VodDetailsComponent } from '../../xtream/vod-details/vod-details.component'; import { StalkerContentTypes } from '../stalker-content-types'; import { StalkerSeriesViewComponent } from '../stalker-series-view/stalker-series-view.component'; import { StalkerStore } from '../stalker.store'; +interface StalkerFilter { + key: string; + label: string; + translationKey: string; +} + @Component({ selector: 'app-stalker-search', imports: [ - MatCardModule, - MatProgressSpinner, - PlaylistErrorViewComponent, - SearchFormComponent, - TranslatePipe, - SearchResultItemComponent, + ContentCardComponent, + FormsModule, + MatCheckboxModule, + SearchLayoutComponent, StalkerSeriesViewComponent, + TranslatePipe, VodDetailsComponent, ], templateUrl: './stalker-search.component.html', - styles: ` - :host { - width: 100%; - } - - .search-page { - padding: 20px; - } - - .results-grid { - display: grid; - grid-template-columns: repeat(auto-fill, minmax(150px, 1fr)); - gap: 1rem; - padding-bottom: 1rem; - } - `, + styleUrl: './stalker-search.component.scss', }) export class StalkerSearchComponent { private readonly activatedRoute = inject(ActivatedRoute); @@ -59,12 +48,12 @@ export class StalkerSearchComponent { private readonly stalkerSession = inject(StalkerSessionService); private readonly store = inject(Store); - readonly filters = { + readonly filters = signal({ series: false, vod: true, - }; + }); - readonly filterConfig = [ + readonly filterConfig: StalkerFilter[] = [ { key: 'vod', label: 'Movies', @@ -102,7 +91,6 @@ export class StalkerSearchComponent { if (!playlist) return []; const { portalUrl, macAddress } = playlist; - // Get token if it's a full stalker portal let token: string | undefined; let serialNumber: string | undefined; if ((playlist as Playlist).isFullStalkerPortal) { @@ -134,7 +122,6 @@ export class StalkerSearchComponent { } ); if (response) { - // Process items to convert relative URLs to absolute const items = response.js?.data || []; return items.map((item: any) => this.processItemUrls(item, portalUrl) @@ -147,32 +134,51 @@ export class StalkerSearchComponent { }, }); + /** Check if showing item details */ + get showingDetails(): boolean { + return this.itemDetails !== null; + } + + /** Get results count for layout */ + get resultsCount(): number { + return this.searchResultsResource.value()?.length ?? 0; + } + createLinkToPlayVodItv(cmd?: string, title?: string, thumbnail?: string) { this.stalkerStore.createLinkToPlayVod(cmd, title, thumbnail); } - selectItem(item: any) { - // Check if item has embedded series data (vclub mode or Ministra with episodes) - const hasEmbeddedSeries = item.series?.length > 0; + updateSearchTerm(term: string) { + this.searchTerm.set(term); + } - // Detect if this VOD item is a series that needs API fetch (Ministra is_series flag WITHOUT embedded series) - // Only set is_series when we need to fetch seasons from API + updateFilter(key: string, value: boolean) { + if (value) { + // Single selection mode - set clicked filter, disable others + this.selectedFilterType.set(key); + this.filters.update((f) => { + const newFilters: Record = {}; + Object.keys(f).forEach((k) => { + newFilters[k] = k === key; + }); + return newFilters as typeof f; + }); + } + } + + selectItem(item: any) { + const hasEmbeddedSeries = item.series?.length > 0; const needsSeriesFetch = this.selectedFilterType() === 'vod' && !hasEmbeddedSeries && (item.is_series === '1' || item.is_series === 1); - // Structure item exactly like category-content-view does this.itemDetails = { id: item.id, cmd: item.cmd, - // For VOD items with embedded series array (Stalker vclub or Ministra with episode numbers) series: item.series, - // Preserve has_files for cmd transformation during playback has_files: item.has_files, - // Flag for VOD items that need to fetch seasons from API (Ministra plugin without embedded series) is_series: needsSeriesFetch ? true : undefined, - // Store video_id for season fetching if available video_id: item.video_id, info: { movie_image: item.screenshot_uri, @@ -188,17 +194,11 @@ export class StalkerSearchComponent { }, }; - // Debug logging - console.log('[StalkerSearch] selectItem - hasEmbeddedSeries:', hasEmbeddedSeries); - console.log('[StalkerSearch] selectItem - needsSeriesFetch:', needsSeriesFetch); - console.log('[StalkerSearch] selectItem - itemDetails:', this.itemDetails); - this.stalkerStore.setSelectedItem(this.itemDetails); switch (this.selectedFilterType()) { case 'vod': this.stalkerStore.setSelectedContentType('vod'); - // Only create VodDetailsItem for regular VODs (not series of any kind) if (!hasEmbeddedSeries && !needsSeriesFetch) { const playlist = this.currentPlaylist(); this.vodDetailsItem = createStalkerVodItem( @@ -217,7 +217,6 @@ export class StalkerSearchComponent { } } - /** Handle play from vod-details component */ onVodPlay(item: VodDetailsItem): void { if (item.type === 'stalker') { this.createLinkToPlayVodItv( @@ -228,8 +227,10 @@ export class StalkerSearchComponent { } } - /** Handle favorite toggle from vod-details component */ - onVodFavoriteToggled(event: { item: VodDetailsItem; isFavorite: boolean }): void { + onVodFavoriteToggled(event: { + item: VodDetailsItem; + isFavorite: boolean; + }): void { if (event.item.type === 'stalker') { if (event.isFavorite) { this.addToFavorites({ @@ -245,37 +246,22 @@ export class StalkerSearchComponent { } } - /** Handle back from vod-details component */ onVodBack(): void { this.itemDetails = null; this.vodDetailsItem = null; } - onFiltersChange(event: { vod: boolean; live: boolean; series: boolean }) { - const selectedFilter = Object.keys(event).find((key) => event[key]); - if (selectedFilter) { - this.selectedFilterType.set(selectedFilter); - } - } - addToFavorites(item: any) { - console.debug('Add to favorites', item); this.stalkerStore.addToFavorites(item); } removeFromFavorites(favoriteId: string) { - console.debug('Remove from favorites', favoriteId); this.stalkerStore.removeFromFavorites(favoriteId); } - /** - * Convert relative URLs to absolute URLs using the portal base URL. - * Ministra portals often return relative paths for screenshot_uri. - */ private processItemUrls(item: any, portalUrl: string): any { const processed = { ...item }; - // Convert screenshot_uri to absolute URL if (processed.screenshot_uri) { processed.screenshot_uri = this.makeAbsoluteUrl( portalUrl, @@ -286,22 +272,16 @@ export class StalkerSearchComponent { return processed; } - /** - * Convert relative URL to absolute using portal base URL - */ private makeAbsoluteUrl(baseUrl: string, relativePath: string): string { if (!relativePath) return ''; - // Already absolute URL if ( relativePath.startsWith('http://') || relativePath.startsWith('https://') ) { return relativePath; } - // Parse the base URL to get origin try { const url = new URL(baseUrl); - // Ensure the relative path starts with / const path = relativePath.startsWith('/') ? relativePath : `/${relativePath}`; diff --git a/apps/web/src/app/xtream-tauri/navigation/navigation.component.ts b/apps/web/src/app/xtream-tauri/navigation/navigation.component.ts index 43047b86e..f4d265d91 100644 --- a/apps/web/src/app/xtream-tauri/navigation/navigation.component.ts +++ b/apps/web/src/app/xtream-tauri/navigation/navigation.component.ts @@ -37,14 +37,16 @@ export class NavigationComponent { readonly selectedContentType = input(); readonly categoryClick = output(); - readonly pageClicked = output<'search' | 'recent' | 'favorites' | 'recently-added'>(); + readonly pageClicked = output< + 'search' | 'recent' | 'favorites' | 'recently-added' + >(); readonly currentPlaylist = this.store.selectSignal( selectPlaylistById(this.activatedRoute.snapshot.params.id) ); readonly isStalkerPlaylist = computed( - () => (this.currentPlaylist() as Playlist).macAddress + () => (this.currentPlaylist() as Playlist)?.macAddress ); readonly navigationItems = input(); 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 bdec05359..396682bc1 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 @@ -1,106 +1,74 @@ -
-