diff --git a/.github/workflows/e2e-tests.yaml b/.github/workflows/e2e-tests.yaml index 6bc49797e..bf1909ba3 100644 --- a/.github/workflows/e2e-tests.yaml +++ b/.github/workflows/e2e-tests.yaml @@ -34,7 +34,7 @@ jobs: cache: 'pnpm' - name: Install Dependencies - run: pnpm install + run: pnpm install --no-frozen-lockfile - name: Build Backend run: npx nx build electron-backend diff --git a/apps/web/src/app/shared/components/content-card/content-card.component.html b/apps/web/src/app/shared/components/content-card/content-card.component.html new file mode 100644 index 000000000..b8d3fa2b2 --- /dev/null +++ b/apps/web/src/app/shared/components/content-card/content-card.component.html @@ -0,0 +1,48 @@ +
+
+ @if (showRemoveButton()) { + + } + + @if (posterUrl()) { + + } @else if (showPlaceholder()) { +
+ {{ getPlaceholderIcon() }} +
+ } @else { + + } + + @if (type()) { + + {{ type() }} + + } +
+ +
+

{{ title() }}

+ @if (date()) { +

+ {{ date() | date: 'd MMM, HH:mm' }} +

+ } +
+
diff --git a/apps/web/src/app/shared/components/content-card/content-card.component.scss b/apps/web/src/app/shared/components/content-card/content-card.component.scss new file mode 100644 index 000000000..6cc36dea1 --- /dev/null +++ b/apps/web/src/app/shared/components/content-card/content-card.component.scss @@ -0,0 +1,21 @@ +@use '../../styles/content-grid' as grid; + +.content-card { + @include grid.content-card; + + .thumbnail-wrapper { + @include grid.thumbnail-wrapper; + + .remove-button { + @include grid.remove-button; + } + + .type-badge { + @include grid.type-badge; + } + } + + .card-info { + @include grid.card-info; + } +} diff --git a/apps/web/src/app/shared/components/content-card/content-card.component.ts b/apps/web/src/app/shared/components/content-card/content-card.component.ts new file mode 100644 index 000000000..85f4f8217 --- /dev/null +++ b/apps/web/src/app/shared/components/content-card/content-card.component.ts @@ -0,0 +1,73 @@ +import { DatePipe } from '@angular/common'; +import { + ChangeDetectionStrategy, + Component, + input, + output, +} from '@angular/core'; +import { MatIconButton } from '@angular/material/button'; +import { MatIcon } from '@angular/material/icon'; +import { MatTooltip } from '@angular/material/tooltip'; + +@Component({ + selector: 'app-content-card', + standalone: true, + imports: [DatePipe, MatIcon, MatIconButton, MatTooltip], + templateUrl: './content-card.component.html', + styleUrl: './content-card.component.scss', + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class ContentCardComponent { + /** URL of the poster image */ + readonly posterUrl = input(); + + /** Title to display */ + readonly title = input.required(); + + /** Content type (live, movie, series) */ + readonly type = input(); + + /** Optional date to display (Date, string, or timestamp number) */ + readonly date = input(); + + /** Whether to show the remove button */ + readonly showRemoveButton = input(false); + + /** Tooltip text for the remove button */ + readonly removeTooltip = input('Remove'); + + /** Whether to show placeholder when no poster */ + readonly showPlaceholder = input(true); + + /** Emitted when the card is clicked */ + readonly cardClick = output(); + + /** Emitted when the remove button is clicked */ + readonly remove = output(); + + /** Get the icon for the placeholder based on type */ + getPlaceholderIcon(): string { + switch (this.type()) { + case 'live': + return 'live_tv'; + case 'series': + return 'tv'; + default: + return 'movie'; + } + } + + onCardClick(): void { + this.cardClick.emit(); + } + + onRemoveClick(event: Event): void { + event.stopPropagation(); + this.remove.emit(); + } + + onImageError(event: Event): void { + (event.target as HTMLImageElement).src = + './assets/images/default-poster.png'; + } +} diff --git a/apps/web/src/app/shared/components/favorites-layout/favorites-layout.component.html b/apps/web/src/app/shared/components/favorites-layout/favorites-layout.component.html index fab280587..bd1762abe 100644 --- a/apps/web/src/app/shared/components/favorites-layout/favorites-layout.component.html +++ b/apps/web/src/app/shared/components/favorites-layout/favorites-layout.component.html @@ -38,46 +38,16 @@ @if (favorites().length) {
@for (item of favorites(); track $index) { - - - -

- {{ item?.title || item?.details?.info?.name }} -

-

- Added: - {{ item?.added_at | date: 'd MMMM, y, HH:MM' }} -

-
- -
+ }
} @else { diff --git a/apps/web/src/app/shared/components/favorites-layout/favorites-layout.component.scss b/apps/web/src/app/shared/components/favorites-layout/favorites-layout.component.scss index 790580d65..c9fc7c64a 100644 --- a/apps/web/src/app/shared/components/favorites-layout/favorites-layout.component.scss +++ b/apps/web/src/app/shared/components/favorites-layout/favorites-layout.component.scss @@ -1,4 +1,5 @@ @use '@angular/material' as mat; +@use '../../../shared/styles/content-grid' as grid; :host { display: flex; @@ -47,66 +48,13 @@ } .items-grid { - display: grid; - grid-template-columns: repeat(auto-fill, minmax(160px, 1fr)); - gap: 16px; + @include grid.content-grid(140px, 20px); padding: 16px; width: 100%; box-sizing: border-box; margin: 0; } -.item-card { - position: relative; - cursor: pointer; - transition: transform 0.2s; - height: 100%; - display: flex; - flex-direction: column; - - &:hover { - transform: scale(1.02); - } -} - -.item-image { - width: 100%; - aspect-ratio: 2/3; - object-fit: cover; -} - -.favorite-button { - position: absolute; - top: 8px; - right: 8px; - z-index: 1; -} - -mat-card-content { - padding: 12px; - flex-grow: 1; - - h3 { - margin: 0; - font-weight: normal; - font-size: 14px; - line-height: 1.2; - max-height: 2.4em; - overflow: hidden; - text-overflow: ellipsis; - display: -webkit-box; - -webkit-line-clamp: 2; - -webkit-box-orient: vertical; - white-space: normal; - } -} - -.added-date { - font-size: 12px; - color: rgba(255, 255, 255, 0.7); - margin: 4px 0 0 0; -} - .no-items { display: flex; flex-direction: column; diff --git a/apps/web/src/app/shared/components/favorites-layout/favorites-layout.component.ts b/apps/web/src/app/shared/components/favorites-layout/favorites-layout.component.ts index 07b31e81d..4750186c0 100644 --- a/apps/web/src/app/shared/components/favorites-layout/favorites-layout.component.ts +++ b/apps/web/src/app/shared/components/favorites-layout/favorites-layout.component.ts @@ -1,12 +1,9 @@ -import { DatePipe } from '@angular/common'; import { Component, input, output } from '@angular/core'; -import { MatIconButton } from '@angular/material/button'; -import { MatCardModule } from '@angular/material/card'; import { MatIcon } from '@angular/material/icon'; -import { MatTooltip } from '@angular/material/tooltip'; import { TranslatePipe } from '@ngx-translate/core'; import { PlaylistSwitcherComponent, ResizableDirective } from 'components'; import { CategoryViewComponent } from '../../../xtream-tauri/category-view/category-view.component'; +import { ContentCardComponent } from '../content-card/content-card.component'; @Component({ selector: 'app-favorites-layout', @@ -17,11 +14,8 @@ import { CategoryViewComponent } from '../../../xtream-tauri/category-view/categ ], imports: [ CategoryViewComponent, - DatePipe, - MatCardModule, + ContentCardComponent, MatIcon, - MatIconButton, - MatTooltip, PlaylistSwitcherComponent, ResizableDirective, TranslatePipe, diff --git a/apps/web/src/app/shared/components/index.ts b/apps/web/src/app/shared/components/index.ts index abd4bb244..e5775eb61 100644 --- a/apps/web/src/app/shared/components/index.ts +++ b/apps/web/src/app/shared/components/index.ts @@ -1 +1,2 @@ +export * from './content-card/content-card.component'; export * from './header/header.component'; 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/shared/styles/_content-grid.scss b/apps/web/src/app/shared/styles/_content-grid.scss new file mode 100644 index 000000000..0b94e1e3c --- /dev/null +++ b/apps/web/src/app/shared/styles/_content-grid.scss @@ -0,0 +1,118 @@ +// Shared content grid and card styles +// Usage: @use 'path/to/_content-grid.scss' as grid; +// @include grid.content-grid; +// @include grid.content-card; + +@mixin content-grid($min-width: 140px, $gap: 20px) { + display: grid; + grid-template-columns: repeat(auto-fill, minmax($min-width, 1fr)); + gap: $gap; +} + +@mixin content-card { + cursor: pointer; + border-radius: 8px; + overflow: hidden; + transition: transform 0.2s ease, box-shadow 0.2s ease; + + &:hover { + transform: translateY(-3px); + box-shadow: 0 6px 16px rgba(0, 0, 0, 0.12); + } +} + +@mixin thumbnail-wrapper { + position: relative; + + .poster { + width: 100%; + aspect-ratio: 2/3; + object-fit: cover; + display: block; + } + + .poster-placeholder { + width: 100%; + aspect-ratio: 2/3; + 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; + } + } +} + +@mixin 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; + } +} + +@mixin remove-button { + position: absolute; + top: 4px; + right: 4px; + z-index: 1; + background: rgba(0, 0, 0, 0.5); + width: 28px; + height: 28px; + padding: 0; + + &:hover { + background: rgba(0, 0, 0, 0.8); + } + + mat-icon { + color: white; + font-size: 18px; + width: 18px; + height: 18px; + } +} + +@mixin 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; + } + + .date-info { + margin: 4px 0 0; + font-size: 0.7rem; + opacity: 0.6; + } +} diff --git a/apps/web/src/app/stalker/recently-viewed/recently-viewed.component.ts b/apps/web/src/app/stalker/recently-viewed/recently-viewed.component.ts index 290c02be3..5e21ebe57 100644 --- a/apps/web/src/app/stalker/recently-viewed/recently-viewed.component.ts +++ b/apps/web/src/app/stalker/recently-viewed/recently-viewed.component.ts @@ -47,13 +47,16 @@ export class RecentlyViewedComponent { readonly currentPlaylist = this.stalkerStore.currentPlaylist; readonly allFavorites = rxResource({ - params: () => ({ - refreshTimestamp: this.refreshTimestamp(), - }), - stream: () => - this.playlistService.getPortalRecentlyViewed( - this.stalkerStore.currentPlaylist()?._id - ), + params: () => { + const portalId = this.stalkerStore.currentPlaylist()?._id; + if (!portalId) return undefined; + return { + portalId, + refreshTimestamp: this.refreshTimestamp(), + }; + }, + stream: ({ params }) => + this.playlistService.getPortalRecentlyViewed(params.portalId), }); readonly categories = computed(() => [ diff --git a/apps/web/src/app/stalker/stalker-favorites/stalker-favorites.component.ts b/apps/web/src/app/stalker/stalker-favorites/stalker-favorites.component.ts index 95f7b3980..e9d3b8037 100644 --- a/apps/web/src/app/stalker/stalker-favorites/stalker-favorites.component.ts +++ b/apps/web/src/app/stalker/stalker-favorites/stalker-favorites.component.ts @@ -47,13 +47,16 @@ export class StalkerFavoritesComponent { readonly currentPlaylist = this.stalkerStore.currentPlaylist; readonly allFavorites = rxResource({ - params: () => ({ - refreshTimestamp: this.refreshTimestamp(), - }), - stream: () => - this.playlistService.getPortalFavorites( - this.stalkerStore.currentPlaylist()?._id - ), + params: () => { + const portalId = this.stalkerStore.currentPlaylist()?._id; + if (!portalId) return undefined; + return { + portalId, + refreshTimestamp: this.refreshTimestamp(), + }; + }, + stream: ({ params }) => + this.playlistService.getPortalFavorites(params.portalId), }); readonly categories = computed(() => [ 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/downloads/downloads.component.html b/apps/web/src/app/xtream-tauri/downloads/downloads.component.html index dbd0919a0..85bfbfd33 100644 --- a/apps/web/src/app/xtream-tauri/downloads/downloads.component.html +++ b/apps/web/src/app/xtream-tauri/downloads/downloads.component.html @@ -1,9 +1,6 @@
-

{{ 'DOWNLOADS.TITLE' | translate }}

@if (activeCount() > 0) { {{ activeCount() }} @@ -25,7 +22,9 @@
folder - {{ downloadFolder() || ('DOWNLOADS.NO_FOLDER' | translate) }} + {{ + downloadFolder() || ('DOWNLOADS.NO_FOLDER' | translate) + }} @@ -81,7 +80,11 @@ {{ getStatusIcon(item.status) }} - {{ 'DOWNLOADS.STATUS.' + item.status.toUpperCase() | translate }} + {{ + 'DOWNLOADS.STATUS.' + + item.status.toUpperCase() + | translate + }} @if ( diff --git a/apps/web/src/app/xtream-tauri/downloads/downloads.component.ts b/apps/web/src/app/xtream-tauri/downloads/downloads.component.ts index eb282a930..67a73da3b 100644 --- a/apps/web/src/app/xtream-tauri/downloads/downloads.component.ts +++ b/apps/web/src/app/xtream-tauri/downloads/downloads.component.ts @@ -9,7 +9,7 @@ import { MatButtonModule } from '@angular/material/button'; import { MatIcon } from '@angular/material/icon'; import { MatProgressBarModule } from '@angular/material/progress-bar'; import { MatTooltip } from '@angular/material/tooltip'; -import { ActivatedRoute, Router } from '@angular/router'; +import { ActivatedRoute } from '@angular/router'; import { TranslatePipe } from '@ngx-translate/core'; import { DownloadItem, @@ -31,7 +31,6 @@ import { }) export class DownloadsComponent implements OnInit { private readonly route = inject(ActivatedRoute); - private readonly router = inject(Router); readonly downloadsService = inject(DownloadsService); readonly downloads = this.downloadsService.downloads; @@ -135,10 +134,6 @@ export class DownloadsComponent implements OnInit { await this.downloadsService.clearCompleted(playlistId); } - goBack() { - this.router.navigate(['..'], { relativeTo: this.route }); - } - formatEpisodeLabel(item: DownloadItem): string { if (item.contentType !== 'episode') return ''; const s = item.seasonNumber?.toString().padStart(2, '0') || '00'; diff --git a/apps/web/src/app/xtream-tauri/live-stream-layout/live-stream-layout.component.ts b/apps/web/src/app/xtream-tauri/live-stream-layout/live-stream-layout.component.ts index c3c9346d6..fb5bf435d 100644 --- a/apps/web/src/app/xtream-tauri/live-stream-layout/live-stream-layout.component.ts +++ b/apps/web/src/app/xtream-tauri/live-stream-layout/live-stream-layout.component.ts @@ -1,9 +1,13 @@ import { + afterNextRender, ChangeDetectionStrategy, Component, computed, + ElementRef, inject, + Injector, OnInit, + viewChild, } from '@angular/core'; import { FormsModule } from '@angular/forms'; import { MatIconButton } from '@angular/material/button'; @@ -23,7 +27,9 @@ import { CategoryManagementDialogComponent, CategoryManagementDialogData, } from '../category-management-dialog/category-management-dialog.component'; -import { CategoryViewComponent } from '../category-view/category-view.component'; +import { + CategoryViewComponent, +} from '../category-view/category-view.component'; import { PortalChannelsListComponent } from '../portal-channels-list/portal-channels-list.component'; import { FavoritesService } from '../services/favorites.service'; import { XtreamStore } from '../stores/xtream.store'; @@ -80,6 +86,12 @@ export class LiveStreamLayoutComponent implements OnInit { }; }); + private readonly injector = inject(Injector); + private readonly categoryView = viewChild(CategoryViewComponent, { + read: ElementRef, + }); + private categoryScrollTop = 0; + readonly player = this.settingsStore.player; streamUrl: string; favorites = new Map(); @@ -116,12 +128,25 @@ export class LiveStreamLayoutComponent implements OnInit { } selectCategory(category: XtreamCategory) { + const el = this.categoryView()?.nativeElement; + if (el) { + this.categoryScrollTop = el.scrollTop; + } const categoryId = (category as any).category_id ?? category.id; this.xtreamStore.setSelectedCategory(categoryId); } backToCategories() { this.xtreamStore.setSelectedCategory(null); + afterNextRender( + () => { + const el = this.categoryView()?.nativeElement; + if (el) { + el.scrollTop = this.categoryScrollTop; + } + }, + { injector: this.injector } + ); } openCategoryManagement(): void { 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/recently-added/recently-added.component.html b/apps/web/src/app/xtream-tauri/recently-added/recently-added.component.html index 906c9983c..b5db4263c 100644 --- a/apps/web/src/app/xtream-tauri/recently-added/recently-added.component.html +++ b/apps/web/src/app/xtream-tauri/recently-added/recently-added.component.html @@ -6,13 +6,13 @@

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

@@ -24,13 +24,13 @@

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

@@ -42,13 +42,13 @@

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

diff --git a/apps/web/src/app/xtream-tauri/recently-added/recently-added.component.ts b/apps/web/src/app/xtream-tauri/recently-added/recently-added.component.ts index da5c30ae4..b1a076dd7 100644 --- a/apps/web/src/app/xtream-tauri/recently-added/recently-added.component.ts +++ b/apps/web/src/app/xtream-tauri/recently-added/recently-added.component.ts @@ -1,8 +1,7 @@ -import { DatePipe } from '@angular/common'; import { Component, computed, inject } from '@angular/core'; import { ActivatedRoute, Router } from '@angular/router'; import { TranslatePipe } from '@ngx-translate/core'; -import { SearchResultItemComponent } from '../../shared/components/search-result-item/search-result-item.component'; +import { ContentCardComponent } from '../../shared/components/content-card/content-card.component'; import { XtreamStore } from '../stores/xtream.store'; import { ContentType } from '../xtream-state'; @@ -10,7 +9,7 @@ import { ContentType } from '../xtream-state'; selector: 'app-recently-added', templateUrl: './recently-added.component.html', styleUrls: ['./recently-added.component.scss'], - imports: [DatePipe, SearchResultItemComponent, TranslatePipe], + imports: [ContentCardComponent, TranslatePipe], }) export class RecentlyAddedComponent { private readonly xtreamStore = inject(XtreamStore); diff --git a/apps/web/src/app/xtream-tauri/recently-viewed/recently-viewed.component.html b/apps/web/src/app/xtream-tauri/recently-viewed/recently-viewed.component.html index f5b5b6dcd..2e4b318ee 100644 --- a/apps/web/src/app/xtream-tauri/recently-viewed/recently-viewed.component.html +++ b/apps/web/src/app/xtream-tauri/recently-viewed/recently-viewed.component.html @@ -32,36 +32,16 @@
@for (item of $any(group).value; track item.id) { - - - -
-

{{ item.title }}

-

- {{ item.type | titlecase }} -

-

- {{ item.viewed_at | date : 'd MMMM, y, HH:mm' }} -

-
-
}
@@ -70,32 +50,16 @@ } @else {
@for (item of recentItems(); track item.id) { - - - -
-

{{ item.title }}

-

- {{ item.type | titlecase }} -

-

- {{ item.viewed_at | date : 'd MMMM, y, hh:mm' }} -

-
-
}
} } @else { diff --git a/apps/web/src/app/xtream-tauri/recently-viewed/recently-viewed.component.scss b/apps/web/src/app/xtream-tauri/recently-viewed/recently-viewed.component.scss index 20eac9cf4..b11640007 100644 --- a/apps/web/src/app/xtream-tauri/recently-viewed/recently-viewed.component.scss +++ b/apps/web/src/app/xtream-tauri/recently-viewed/recently-viewed.component.scss @@ -1,3 +1,5 @@ +@use '../../shared/styles/content-grid' as grid; + :host { display: flex; flex-direction: column; @@ -24,61 +26,7 @@ } .items-grid { - display: grid; - grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); - gap: 20px; -} - -.item-card { - cursor: pointer; - transition: transform 0.2s; - position: relative; - - &:hover { - transform: scale(1.02); - } - - .remove-button { - position: absolute; - top: 5px; - right: 5px; - z-index: 1; - background: rgba(0, 0, 0, 0.5); - - &:hover { - background: rgba(0, 0, 0, 0.7); - } - - mat-icon { - color: white; - } - } -} - -.poster { - width: 100%; - height: 280px; - object-fit: cover; -} - -.item-info { - padding: 16px; - - h3 { - margin: 0; - font-size: 16px; - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; - } - - p { - margin: 8px 0; - } - - .viewed-at { - font-size: 12px; - } + @include grid.content-grid(140px, 20px); } .no-items { @@ -93,26 +41,6 @@ } } -.type-badge { - display: inline-block; - padding: 0.25rem 0.5rem; - border-radius: 4px; - font-size: 0.8rem; - text-transform: uppercase; - &.live { - background: #e53935; - color: white; - } - &.movie { - background: #1e88e5; - color: white; - } - &.series { - background: #43a047; - color: white; - } -} - .playlist-section { .section-title { margin: 0 0 1.5rem; @@ -160,4 +88,4 @@ background: var(--surface); } } -} \ No newline at end of file +} diff --git a/apps/web/src/app/xtream-tauri/recently-viewed/recently-viewed.component.ts b/apps/web/src/app/xtream-tauri/recently-viewed/recently-viewed.component.ts index 3a336e64d..8bdbc4fc0 100644 --- a/apps/web/src/app/xtream-tauri/recently-viewed/recently-viewed.component.ts +++ b/apps/web/src/app/xtream-tauri/recently-viewed/recently-viewed.component.ts @@ -1,24 +1,21 @@ -import { DatePipe, KeyValuePipe, TitleCasePipe } from '@angular/common'; +import { KeyValuePipe } from '@angular/common'; import { Component, computed, inject, Optional } from '@angular/core'; import { MatButton, MatIconButton } from '@angular/material/button'; -import { MatCard } from '@angular/material/card'; import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog'; import { MatIcon } from '@angular/material/icon'; import { ActivatedRoute, Router } from '@angular/router'; import groupBy from 'lodash/groupBy'; +import { ContentCardComponent } from '../../shared/components/content-card/content-card.component'; import { XtreamStore } from '../stores/xtream.store'; @Component({ selector: 'app-recently-viewed', imports: [ - DatePipe, + ContentCardComponent, KeyValuePipe, MatButton, - MatCard, MatIcon, MatIconButton, - MatIconButton, - TitleCasePipe, ], providers: [], templateUrl: './recently-viewed.component.html', @@ -90,8 +87,7 @@ export class RecentlyViewedComponent { } } - removeItem(event: Event, itemId: number) { - event.stopPropagation(); + onRemoveItem(itemId: number) { this.xtreamStore.removeRecentItem({ itemId, playlistId: this.currentPlaylist().id, 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..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,134 +1,74 @@ -
- - -
- @if (xtreamStore.searchResults().length > 0) { @if (isGlobalSearch) { - -
-

- Found {{ xtreamStore.searchResults().length }} items across - {{ (getGroupedResults() | keyvalue).length }} playlists -

- @for ( group of getGroupedResults() | keyvalue; track group.key ) { -
-

- playlist_play - {{ group.key }} - ({{ $any(group).value.length }} items) -

-
- @for ( item of $any(group).value; track item.id ) { - - } -
-
- } -
- } @else { - -
- @for (item of xtreamStore.searchResults(); track item.id) { - - - @if (item.poster_url) { - - } @if (!item.poster_url) { -
- 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 }} -
- } -
-
- } -
- } } @else if (searchTerm() && searchTerm().length >= 3) { -
- search_off -
-

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

-

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

-

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

-
-
- } @else { -
- search -
-

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

-

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

-
-
+ + + + @for (filter of filterConfig; track filter.key) { + + {{ filter.translationKey | translate }} + } -
-
+ + + + + @if (isGlobalSearch) { + +
+

+ Found {{ xtreamStore.searchResults().length }} items across + {{ (getGroupedResults() | keyvalue).length }} playlists +

+ @for ( + group of getGroupedResults() | keyvalue; + track group.key + ) { +
+

+ playlist_play + {{ group.key }} + ({{ $any(group).value.length }} items) +

+
+ @for (item of $any(group).value; track item.id) { + + } +
+
+ } +
+ } @else { + +
+ @for (item of xtreamStore.searchResults(); track item.id) { + + } +
+ } +
+ 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..d9aa5794e 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 @@ -1,3 +1,5 @@ +@use '../../shared/styles/content-grid' as grid; + :host { display: flex; flex-direction: column; @@ -6,231 +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; - 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; - } - } - - .search-input-container { - margin-bottom: 0; - } -} - -.results-container { - flex: 1; - overflow-y: auto; - - .results-grid { - display: grid; - grid-template-columns: repeat( - auto-fill, - minmax(200px, 1fr) - ); - gap: 1rem; - padding-bottom: 1rem; - - mat-card { - cursor: pointer; - transition: transform 0.2s, box-shadow 0.2s; - - &:hover { - transform: translateY(-2px); - box-shadow: 0 4px 12px rgba(0,0,0,0.1); - } - - .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; - - &.live { - background: rgba(229, 57, 53, 0.1); - color: #e53935; - } - &.movie { - background: rgba(30, 136, 229, 0.1); - color: #1e88e5; - } - &.series { - background: rgba(67, 160, 71, 0.1); - color: #43a047; - } - } - } - } -} - -.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 { @@ -263,6 +55,7 @@ } } +// Playlist grouping styles for global search .playlist-group { margin-bottom: 2rem; @@ -305,7 +98,7 @@ .playlist-badge { display: none; } - + .poster, .poster-placeholder { margin-top: 0; @@ -322,4 +115,4 @@ color: var(--text-color-secondary); padding: 0 8px; } -} \ No newline at end of file +} 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..2cf9b967a 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,30 +6,27 @@ import { inject, Inject, Optional, - signal, - ViewChild, + viewChild, } from '@angular/core'; import { FormsModule } from '@angular/forms'; -import { MatIconButton } from '@angular/material/button'; -import { MatCardModule } from '@angular/material/card'; 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'; import { DatabaseService } from 'services'; import { XtreamContentItem } from '../data-sources/xtream-data-source.interface'; import { ContentType } from '../xtream-state'; -import { SearchFormComponent } from '../../shared/components/search-form/search-form.component'; +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'; interface SearchResultsData { isGlobalSearch: boolean; @@ -38,16 +35,13 @@ interface SearchResultsData { @Component({ selector: 'app-search-results', imports: [ + ContentCardComponent, FormsModule, KeyValuePipe, - MatCardModule, MatCheckboxModule, MatDialogModule, - MatFormFieldModule, MatIcon, - MatIconButton, - MatInputModule, - SearchFormComponent, + SearchLayoutComponent, SearchResultItemComponent, TranslatePipe, ], @@ -56,32 +50,33 @@ interface SearchResultsData { styleUrls: ['./search-results.component.scss'], }) export class SearchResultsComponent implements AfterViewInit { - @ViewChild(SearchFormComponent) searchFormComponent!: SearchFormComponent; + readonly searchLayoutComponent = viewChild(SearchLayoutComponent); 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 +89,11 @@ 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) { + this.clearResultsOnly(); } }); } @@ -106,12 +101,13 @@ export class SearchResultsComponent implements AfterViewInit { ngAfterViewInit() { this.xtreamStore.setSelectedContentType(undefined); setTimeout(() => { - this.searchFormComponent?.focusSearchInput(); + this.searchLayoutComponent()?.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 +121,30 @@ 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); + 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 +173,6 @@ export class SearchResultsComponent implements AfterViewInit { ]); } else { const type = (item.type === 'movie' ? 'vod' : item.type) as ContentType; - this.xtreamStore.resetSearchResults(); this.xtreamStore.setSelectedContentType(type); this.router.navigate( @@ -164,6 +183,11 @@ export class SearchResultsComponent implements AfterViewInit { ); } } + + onCloseDialog() { + this.dialogRef?.close(); + } + getGroupedResults() { const results = this.xtreamStore.searchResults(); if (!this.isGlobalSearch) return { default: results }; 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 */