refactor: implement search layout component with dynamic results and filters

This commit is contained in:
4gray committed 2026-02-05 23:08:22 +01:00
1 parent e8632e7bee
commit ed9e85f5e8
10 files changed
+557 -400

No files matched your search

@@ -0,0 +1,71 @@
<div class="search-page">
<!-- Inline details slot (for stalker) - shown above everything when active -->
<ng-content select="[details]" />
<div class="search-content">
<div class="header sticky-header">
<div class="header-top">
<div class="header-left">
<h2>{{ title() | translate }}</h2>
<!-- Filters slot -->
<div class="type-filters">
<ng-content select="[filters]" />
</div>
</div>
<div class="header-right">
@if (showResultsCount() && resultsCount() > 0) {
<div class="results-count">
Found {{ resultsCount() }} results
</div>
}
@if (showCloseButton()) {
<button mat-icon-button (click)="onCloseClick()">
<mat-icon>close</mat-icon>
</button>
}
<!-- Extra header actions slot -->
<ng-content select="[header-actions]" />
</div>
</div>
<div class="search-input-container">
<app-search-form
[searchTerm]="searchTerm()"
(searchTermChange)="onSearchTermChange($event)"
/>
</div>
</div>
<div class="results-container">
@if (isLoading()) {
<div class="loading-state">
<mat-spinner diameter="48" />
</div>
} @else if (showResults) {
<!-- Results slot -->
<ng-content select="[results]" />
} @else if (showNoResults) {
<div class="no-items">
<mat-icon>search_off</mat-icon>
<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>
</div>
</div>
} @else if (showInitialState) {
<div class="no-items initial-state">
<mat-icon>search</mat-icon>
<div class="no-items-content">
<h3>{{ 'PORTALS.SEARCH_VIEW.INITIAL_TITLE' | translate }}</h3>
<p>{{ 'PORTALS.SEARCH_VIEW.INITIAL_DESCRIPTION' | translate }}</p>
</div>
</div>
}
<!-- Custom empty state slot (optional override) -->
<ng-content select="[empty]" />
</div>
</div>
</div>
@@ -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);
}
}
@@ -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<string>('PORTALS.SIDEBAR.SEARCH');
/** Current search term */
readonly searchTerm = input<string>('');
/** Number of results found */
readonly resultsCount = input<number>(0);
/** Whether search is in progress */
readonly isLoading = input<boolean>(false);
/** Whether to show the close button (for dialog mode) */
readonly showCloseButton = input<boolean>(false);
/** Whether to show results count */
readonly showResultsCount = input<boolean>(true);
/** Minimum characters required for search */
readonly minSearchLength = input<number>(3);
/** Emitted when search term changes */
readonly searchTermChange = output<string>();
/** Emitted when close button is clicked */
readonly closeClick = output<void>();
/** 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();
}
}
@@ -1,76 +1,65 @@
@if (itemDetails) {
@switch (selectedFilterType()) {
@case ('vod') {
@if (itemDetails.series?.length > 0) {
<!-- VOD item with embedded series (vclub or Ministra) -->
<app-stalker-series-view
[vodWithSeries]="itemDetails"
(backClicked)="onVodBack()"
/>
} @else if (itemDetails.is_series === true) {
<!-- VOD item that is actually a series (Ministra plugin is_series=1) -->
<app-stalker-series-view (backClicked)="onVodBack()" />
} @else if (vodDetailsItem) {
<!-- Regular VOD item -->
<app-vod-details
[item]="vodDetailsItem"
(playClicked)="onVodPlay($event)"
(favoriteToggled)="onVodFavoriteToggled($event)"
(backClicked)="onVodBack()"
/>
}
}
@case ('series') {
<app-stalker-series-view (backClicked)="itemDetails = null" />
}
}
}
<div class="search-page">
@if (!itemDetails) {
<div class="header">
<h2>{{ 'PORTALS.SIDEBAR.SEARCH' | translate }}</h2>
</div>
<app-search-form
[singleSelection]="true"
[(searchTerm)]="searchTerm"
[filters]="filters"
[filterConfig]="filterConfig"
(filtersChange)="onFiltersChange($any($event))"
/>
<div class="results-container">
@if (searchResultsResource.isLoading()) {
<mat-spinner />
} @else {
<div class="results-grid">
@for (
item of searchResultsResource.value();
track item.id
) {
<app-search-result-item
[title]="item.o_name || item.name"
[posterUrl]="item.screenshot_uri"
[type]="item.type"
(itemClick)="selectItem(item)"
/>
}
</div>
@if (searchResultsResource.value().length === 0) {
<div class="no-results">
<app-playlist-error-view
[title]="
'PORTALS.EMPTY_LIST_VIEW.TITLE' | translate
"
[description]="
'PORTALS.EMPTY_LIST_VIEW.NO_SEARCH_RESULTS'
| translate
"
[showActionButtons]="false"
[viewType]="'NO_SEARCH_RESULTS'"
/>
</div>
@if (showingDetails) {
<!-- Inline details view -->
<div class="details-view">
@switch (selectedFilterType()) {
@case ('vod') {
@if (itemDetails.series?.length > 0) {
<!-- VOD item with embedded series (vclub or Ministra) -->
<app-stalker-series-view
[vodWithSeries]="itemDetails"
(backClicked)="onVodBack()"
/>
} @else if (itemDetails.is_series === true) {
<!-- VOD item that is actually a series (Ministra plugin is_series=1) -->
<app-stalker-series-view (backClicked)="onVodBack()" />
} @else if (vodDetailsItem) {
<!-- Regular VOD item -->
<app-vod-details
[item]="vodDetailsItem"
(playClicked)="onVodPlay($event)"
(favoriteToggled)="onVodFavoriteToggled($event)"
(backClicked)="onVodBack()"
/>
}
}
</div>
}
</div>
@case ('series') {
<app-stalker-series-view (backClicked)="itemDetails = null" />
}
}
</div>
} @else {
<app-search-layout
[searchTerm]="searchTerm()"
[resultsCount]="resultsCount"
[isLoading]="searchResultsResource.isLoading()"
[showResultsCount]="true"
(searchTermChange)="updateSearchTerm($event)"
>
<!-- Filters (single selection mode for stalker) -->
<ng-container filters>
@for (filter of filterConfig; track filter.key) {
<mat-checkbox
[ngModel]="filters()[filter.key]"
(ngModelChange)="updateFilter(filter.key, $event)"
>
{{ filter.translationKey | translate }}
</mat-checkbox>
}
</ng-container>
<!-- Results -->
<ng-container results>
<div class="results-grid">
@for (item of searchResultsResource.value(); track item.id) {
<app-content-card
[title]="item.o_name || item.name"
[posterUrl]="item.screenshot_uri"
[type]="selectedFilterType() === 'vod' ? 'movie' : 'series'"
[showPlaceholder]="true"
(cardClick)="selectItem(item)"
/>
}
</div>
</ng-container>
</app-search-layout>
}
@@ -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;
}
@@ -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<string, boolean> = {};
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}`;
@@ -37,14 +37,16 @@ export class NavigationComponent {
readonly selectedContentType = input<string>();
readonly categoryClick = output<string>();
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<NavigationItem[]>();
@@ -1,106 +1,74 @@
<div class="search-page">
<div class="header sticky-header">
<div class="header-top">
<div class="header-left">
<h2>{{ 'PORTALS.SIDEBAR.SEARCH' | translate }}</h2>
<div class="type-filters">
@for (filter of filterConfig; track filter.key) {
<mat-checkbox
[ngModel]="filters()[filter.key]"
(ngModelChange)="updateFilter(filter.key, $event)"
>
{{ filter.translationKey | translate }}
</mat-checkbox>
}
</div>
</div>
<app-search-layout
[searchTerm]="searchTerm()"
[resultsCount]="xtreamStore.searchResults().length"
[isLoading]="xtreamStore.isSearching()"
[showCloseButton]="isGlobalSearch"
[showResultsCount]="!isGlobalSearch"
(searchTermChange)="updateSearchTerm($event)"
(closeClick)="onCloseDialog()"
>
<!-- Filters -->
<ng-container filters>
@for (filter of filterConfig; track filter.key) {
<mat-checkbox
[ngModel]="filters()[filter.key]"
(ngModelChange)="updateFilter(filter.key, $event)"
>
{{ filter.translationKey | translate }}
</mat-checkbox>
}
</ng-container>
<div class="header-right">
@if (!isGlobalSearch && xtreamStore.searchResults().length > 0) {
<div class="results-count">
Found {{ xtreamStore.searchResults().length }} results
<!-- 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)"
/>
}
</div>
</div>
}
@if (isGlobalSearch) {
<button mat-icon-button (click)="dialogRef.close()">
<mat-icon>close</mat-icon>
</button>
</div>
} @else {
<!-- Playlist-level Search Results -->
<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>
</div>
<div class="search-input-container">
<app-search-form
[searchTerm]="searchTerm()"
(searchTermChange)="updateSearchTerm($event)"
/>
</div>
</div>
<div class="results-container">
@if (xtreamStore.searchResults().length > 0) { @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)"
/>
}
</div>
</div>
}
</div>
} @else {
<!-- Playlist-level Search Results -->
<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 if (searchTerm() && searchTerm().length >= 3) {
<div class="no-items">
<mat-icon>search_off</mat-icon>
<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>
</div>
</div>
} @else {
<div class="no-items initial-state">
<mat-icon>search</mat-icon>
<div class="no-items-content">
<h3>{{ 'PORTALS.SEARCH_VIEW.INITIAL_TITLE' | translate }}</h3>
<p>{{ 'PORTALS.SEARCH_VIEW.INITIAL_DESCRIPTION' | translate }}</p>
</div>
</div>
}
</div>
</div>
</ng-container>
</app-search-layout>
@@ -8,150 +8,21 @@
width: 100%;
}
.search-page {
padding: 0 20px 20px 20px;
}
.header {
position: sticky;
top: 0;
z-index: 100;
// Use theme background variable or fallback
background: var(--background, var(--mat-sidenav-content-background-color, #fff));
padding: 20px 0 10px 0;
margin-bottom: 20px;
// Subtle shadow or border for separation
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;
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;
.results-grid {
@include grid.content-grid(140px, 20px);
padding-bottom: 1rem;
}
}
.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 styling
.results-grid {
@include grid.content-grid(140px, 20px);
padding-bottom: 1rem;
}
// Global search overlay styles
:host-context(.global-search-overlay) {
.search-page {
background: var(--background);
height: 100vh;
}
/* Ensure sticky header background matches overlay background */
.header {
background: var(--background);
}
.results-grid {
mat-card {
position: relative;
.poster,
.poster-placeholder {
margin-top: 24px; // Add space for the playlist badge
margin-top: 24px;
}
.playlist-badge {
@@ -184,6 +55,7 @@
}
}
// Playlist grouping styles for global search
.playlist-group {
margin-bottom: 2rem;
@@ -226,7 +98,7 @@
.playlist-badge {
display: none;
}
.poster,
.poster-placeholder {
margin-top: 0;
@@ -243,4 +115,4 @@
color: var(--text-color-secondary);
padding: 0 8px;
}
}
}
@@ -9,16 +9,13 @@ import {
viewChild,
} from '@angular/core';
import { FormsModule } from '@angular/forms';
import { MatIconButton } from '@angular/material/button';
import { MatCheckboxModule } from '@angular/material/checkbox';
import {
MAT_DIALOG_DATA,
MatDialogModule,
MatDialogRef,
} from '@angular/material/dialog';
import { MatFormFieldModule } from '@angular/material/form-field';
import { MatIcon } from '@angular/material/icon';
import { MatInputModule } from '@angular/material/input';
import { ActivatedRoute, Router } from '@angular/router';
import { TranslatePipe } from '@ngx-translate/core';
import groupBy from 'lodash/groupBy';
@@ -26,7 +23,7 @@ import { DatabaseService } from 'services';
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 { SearchFormComponent } from '../../shared/components/search-form/search-form.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';
@@ -43,11 +40,8 @@ interface SearchResultsData {
KeyValuePipe,
MatCheckboxModule,
MatDialogModule,
MatFormFieldModule,
MatIcon,
MatIconButton,
MatInputModule,
SearchFormComponent,
SearchLayoutComponent,
SearchResultItemComponent,
TranslatePipe,
],
@@ -56,7 +50,7 @@ interface SearchResultsData {
styleUrls: ['./search-results.component.scss'],
})
export class SearchResultsComponent implements AfterViewInit {
readonly searchFormComponent = viewChild(SearchFormComponent);
readonly searchLayoutComponent = viewChild(SearchLayoutComponent);
readonly xtreamStore = inject(XtreamStore);
readonly router = inject(Router);
readonly activatedRoute = inject(ActivatedRoute);
@@ -99,7 +93,6 @@ export class SearchResultsComponent implements AfterViewInit {
if (term.length >= 3) {
this.executeSearch();
} else if (term.length === 0) {
// Only clear results when search term is completely empty
this.clearResultsOnly();
}
});
@@ -108,7 +101,7 @@ export class SearchResultsComponent implements AfterViewInit {
ngAfterViewInit() {
this.xtreamStore.setSelectedContentType(undefined);
setTimeout(() => {
this.searchFormComponent()?.focusSearchInput();
this.searchLayoutComponent()?.focusSearchInput();
});
}
@@ -140,7 +133,6 @@ export class SearchResultsComponent implements AfterViewInit {
*/
updateFilter(key: keyof SearchFilters, value: boolean) {
this.xtreamStore.updateSearchFilter(key, value);
// Re-execute search when filter changes
if (this.searchTerm().length >= 3) {
this.executeSearch();
}
@@ -181,7 +173,6 @@ export class SearchResultsComponent implements AfterViewInit {
]);
} else {
const type = (item.type === 'movie' ? 'vod' : item.type) as ContentType;
// Don't reset search results - preserve them for back navigation
this.xtreamStore.setSelectedContentType(type);
this.router.navigate(
@@ -192,6 +183,11 @@ export class SearchResultsComponent implements AfterViewInit {
);
}
}
onCloseDialog() {
this.dialogRef?.close();
}
getGroupedResults() {
const results = this.xtreamStore.searchResults();
if (!this.isGlobalSearch) return { default: results };