From 139944281c807dce83e0fef01fcf9d4297128b9b Mon Sep 17 00:00:00 2001 From: 4gray Date: Wed, 4 Feb 2026 00:40:04 +0100 Subject: [PATCH 01/10] feat: keep filter search phrase and results when navigating back from details view --- .../search-results.component.html | 40 ++--- .../search-results.component.scss | 138 +++++++++--------- .../search-results.component.ts | 66 ++++++--- .../stores/features/with-search.feature.ts | 51 +++++++ 4 files changed, 182 insertions(+), 113 deletions(-) diff --git a/apps/web/src/app/xtream-tauri/search-results/search-results.component.html b/apps/web/src/app/xtream-tauri/search-results/search-results.component.html index 8aa520953..498bdb4d1 100644 --- a/apps/web/src/app/xtream-tauri/search-results/search-results.component.html +++ b/apps/web/src/app/xtream-tauri/search-results/search-results.component.html @@ -7,8 +7,8 @@
@for (filter of filterConfig; track filter.key) { {{ filter.translationKey | translate }} @@ -32,9 +32,8 @@
@@ -76,8 +75,8 @@
@for (item of xtreamStore.searchResults(); track item.id) { - - +
+
@if (item.poster_url) { - } @if (!item.poster_url) { + } @else {
- movie + {{ item.type === 'live' ? 'live_tv' : item.type === 'series' ? 'tv' : 'movie' }}
} -

{{ item.title }}

-

{{ item.description }}

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

{{ item.title }}

+
+
}
} } @else if (searchTerm() && searchTerm().length >= 3) { diff --git a/apps/web/src/app/xtream-tauri/search-results/search-results.component.scss b/apps/web/src/app/xtream-tauri/search-results/search-results.component.scss index 532921c11..823db5e3d 100644 --- a/apps/web/src/app/xtream-tauri/search-results/search-results.component.scss +++ b/apps/web/src/app/xtream-tauri/search-results/search-results.component.scss @@ -63,12 +63,8 @@ .results-count { font-size: 0.85rem; - color: var(--text-color-secondary, rgba(0,0,0,0.7)); - background: rgba(0, 0, 0, 0.06); padding: 6px 12px; - border-radius: 16px; white-space: nowrap; - font-weight: 500; display: flex; align-items: center; } @@ -85,81 +81,85 @@ .results-grid { display: grid; - grid-template-columns: repeat( - auto-fill, - minmax(200px, 1fr) - ); - gap: 1rem; + grid-template-columns: repeat(auto-fill, minmax(140px, 1fr)); + gap: 20px; padding-bottom: 1rem; - mat-card { + .result-card { cursor: pointer; - transition: transform 0.2s, box-shadow 0.2s; + border-radius: 8px; + overflow: hidden; + transition: transform 0.2s ease, box-shadow 0.2s ease; &:hover { - transform: translateY(-2px); - box-shadow: 0 4px 12px rgba(0,0,0,0.1); + transform: translateY(-3px); + box-shadow: 0 6px 16px rgba(0, 0, 0, 0.12); } - .poster, - .poster-placeholder { - border-radius: 8px; - width: 100%; - aspect-ratio: 2/3; - object-fit: cover; - margin-bottom: 0.75rem; - } - .poster-placeholder { - display: flex; - align-items: center; - justify-content: center; - background: rgba(0,0,0,0.05); - - mat-icon { - font-size: 48px; - width: 48px; - height: 48px; - opacity: 0.3; - } - } - h3 { - margin: 0.5rem 0; - font-size: 1rem; - font-weight: 500; - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; - } - p { - font-size: 0.85rem; - color: var(--text-color-secondary, rgba(0, 0, 0, 0.6)); - margin: 0 0 0.75rem 0; - line-height: 1.4; - display: -webkit-box; - -webkit-line-clamp: 2; - -webkit-box-orient: vertical; - overflow: hidden; - } - .type-badge { - display: inline-block; - padding: 0.25rem 0.5rem; - border-radius: 4px; - font-size: 0.75rem; - font-weight: 600; - text-transform: uppercase; - letter-spacing: 0.5px; + .thumbnail-wrapper { + position: relative; - &.live { - background: rgba(229, 57, 53, 0.1); - color: #e53935; + .poster, + .poster-placeholder { + width: 100%; + aspect-ratio: 2/3; + object-fit: cover; + display: block; } - &.movie { - background: rgba(30, 136, 229, 0.1); - color: #1e88e5; + + .poster-placeholder { + display: flex; + align-items: center; + justify-content: center; + background: rgba(128, 128, 128, 0.1); + + mat-icon { + font-size: 36px; + width: 36px; + height: 36px; + opacity: 0.25; + } } - &.series { - background: rgba(67, 160, 71, 0.1); - color: #43a047; + + .type-badge { + position: absolute; + top: 6px; + left: 6px; + padding: 3px 6px; + border-radius: 4px; + font-size: 9px; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.5px; + backdrop-filter: blur(8px); + -webkit-backdrop-filter: blur(8px); + background: rgba(0, 0, 0, 0.6); + color: #fff; + + &.live { + color: #ef5350; + } + &.movie { + color: #42a5f5; + } + &.series { + color: #66bb6a; + } + } + } + + .card-info { + padding: 8px 10px; + + h3 { + margin: 0; + font-size: 0.8rem; + font-weight: 500; + line-height: 1.3; + display: -webkit-box; + -webkit-line-clamp: 2; + -webkit-box-orient: vertical; + overflow: hidden; } } } diff --git a/apps/web/src/app/xtream-tauri/search-results/search-results.component.ts b/apps/web/src/app/xtream-tauri/search-results/search-results.component.ts index b24d965e8..4f020a352 100644 --- a/apps/web/src/app/xtream-tauri/search-results/search-results.component.ts +++ b/apps/web/src/app/xtream-tauri/search-results/search-results.component.ts @@ -6,8 +6,7 @@ import { inject, Inject, Optional, - signal, - ViewChild, + viewChild, } from '@angular/core'; import { FormsModule } from '@angular/forms'; import { MatIconButton } from '@angular/material/button'; @@ -30,6 +29,7 @@ import { ContentType } from '../xtream-state'; import { SearchFormComponent } from '../../shared/components/search-form/search-form.component'; import { SearchResultItemComponent } from '../../shared/components/search-result-item/search-result-item.component'; import { XtreamStore } from '../stores/xtream.store'; +import { SearchFilters } from '../stores/features/with-search.feature'; interface SearchResultsData { isGlobalSearch: boolean; @@ -56,32 +56,33 @@ interface SearchResultsData { styleUrls: ['./search-results.component.scss'], }) export class SearchResultsComponent implements AfterViewInit { - @ViewChild(SearchFormComponent) searchFormComponent!: SearchFormComponent; + readonly searchFormComponent = viewChild(SearchFormComponent); readonly xtreamStore = inject(XtreamStore); readonly router = inject(Router); readonly activatedRoute = inject(ActivatedRoute); readonly databaseService = inject(DatabaseService); - searchTerm = signal(''); - filters = { - live: true, - movie: true, - series: true, - }; + + /** Search term from store */ + readonly searchTerm = this.xtreamStore.searchTerm; + + /** Search filters from store */ + readonly filters = this.xtreamStore.searchFilters; + isGlobalSearch = false; readonly filterConfig = [ { - key: 'live', + key: 'live' as keyof SearchFilters, label: 'Live TV', translationKey: 'PORTALS.SIDEBAR.LIVE_TV', }, { - key: 'movie', + key: 'movie' as keyof SearchFilters, label: 'Movies', translationKey: 'PORTALS.SIDEBAR.MOVIES', }, { - key: 'series', + key: 'series' as keyof SearchFilters, label: 'Series', translationKey: 'PORTALS.SIDEBAR.SERIES', }, @@ -94,11 +95,12 @@ export class SearchResultsComponent implements AfterViewInit { this.isGlobalSearch = data?.isGlobalSearch || false; effect(() => { - this.searchTerm(); - if (this.searchTerm().length >= 3) { + const term = this.searchTerm(); + if (term.length >= 3) { this.executeSearch(); - } else { - this.xtreamStore.resetSearchResults(); + } else if (term.length === 0) { + // Only clear results when search term is completely empty + this.clearResultsOnly(); } }); } @@ -106,12 +108,13 @@ export class SearchResultsComponent implements AfterViewInit { ngAfterViewInit() { this.xtreamStore.setSelectedContentType(undefined); setTimeout(() => { - this.searchFormComponent?.focusSearchInput(); + this.searchFormComponent()?.focusSearchInput(); }); } executeSearch() { - const types = Object.entries(this.filters) + const filters = this.filters(); + const types = Object.entries(filters) .filter(([_, enabled]) => enabled) .map(([type]) => type); @@ -125,6 +128,31 @@ export class SearchResultsComponent implements AfterViewInit { } } + /** + * Update search term in the store + */ + updateSearchTerm(term: string) { + this.xtreamStore.setSearchTerm(term); + } + + /** + * Update a single filter in the store + */ + updateFilter(key: keyof SearchFilters, value: boolean) { + this.xtreamStore.updateSearchFilter(key, value); + // Re-execute search when filter changes + if (this.searchTerm().length >= 3) { + this.executeSearch(); + } + } + + /** + * Clear only the results, not the search term/filters + */ + private clearResultsOnly() { + this.xtreamStore.setGlobalSearchResults([]); + } + async searchGlobal(term: string, types: string[]) { try { const results = await this.databaseService.globalSearchContent( @@ -153,7 +181,7 @@ export class SearchResultsComponent implements AfterViewInit { ]); } else { const type = (item.type === 'movie' ? 'vod' : item.type) as ContentType; - this.xtreamStore.resetSearchResults(); + // Don't reset search results - preserve them for back navigation this.xtreamStore.setSelectedContentType(type); this.router.navigate( diff --git a/apps/web/src/app/xtream-tauri/stores/features/with-search.feature.ts b/apps/web/src/app/xtream-tauri/stores/features/with-search.feature.ts index fcc009566..f7469da03 100644 --- a/apps/web/src/app/xtream-tauri/stores/features/with-search.feature.ts +++ b/apps/web/src/app/xtream-tauri/stores/features/with-search.feature.ts @@ -11,19 +11,41 @@ import { XtreamContentItem, } from '../../data-sources/xtream-data-source.interface'; +/** + * Search filters configuration + */ +export interface SearchFilters { + live: boolean; + movie: boolean; + series: boolean; +} + /** * Search state for managing search results */ export interface SearchState { + searchTerm: string; + searchFilters: SearchFilters; searchResults: XtreamContentItem[]; globalSearchResults: GlobalSearchResult[]; isSearching: boolean; } +/** + * Initial search filters + */ +const initialSearchFilters: SearchFilters = { + live: true, + movie: true, + series: true, +}; + /** * Initial search state */ const initialSearchState: SearchState = { + searchTerm: '', + searchFilters: initialSearchFilters, searchResults: [], globalSearchResults: [], isSearching: false, @@ -94,6 +116,35 @@ export function withSearch() { }); }, + /** + * Set the search term + */ + setSearchTerm(term: string): void { + patchState(store, { searchTerm: term }); + }, + + /** + * Set search filters + */ + setSearchFilters(filters: SearchFilters): void { + patchState(store, { searchFilters: filters }); + }, + + /** + * Update a single filter + */ + updateSearchFilter( + key: keyof SearchFilters, + value: boolean + ): void { + patchState(store, { + searchFilters: { + ...store.searchFilters(), + [key]: value, + }, + }); + }, + /** * Clear search results */ From d64283e33d009864ea63033a0506ae1e247bde97 Mon Sep 17 00:00:00 2001 From: 4gray Date: Wed, 4 Feb 2026 00:41:03 +0100 Subject: [PATCH 02/10] style: enhance favorites and recently viewed UI with improved layout and functionality --- .../favorites-layout.component.html | 68 ++++----- .../favorites-layout.component.scss | 121 ++++++++++------ .../recently-viewed.component.html | 96 +++++++------ .../recently-viewed.component.scss | 134 ++++++++++-------- .../recently-viewed.component.ts | 8 +- 5 files changed, 243 insertions(+), 184 deletions(-) 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..74ebad294 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,40 @@ @if (favorites().length) {
@for (item of favorites(); track $index) { - - +
+ + - -

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

+ " + (error)=" + $event.target.src = + './assets/images/default-poster.png' + " + [alt]="item.title" + class="poster" + /> + + {{ item.type }} + +
+
+

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

- Added: - {{ item?.added_at | date: 'd MMMM, y, HH:MM' }} + {{ item?.added_at | date: 'd MMM, 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..32ecc6b24 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 @@ -48,8 +48,8 @@ .items-grid { display: grid; - grid-template-columns: repeat(auto-fill, minmax(160px, 1fr)); - gap: 16px; + grid-template-columns: repeat(auto-fill, minmax(140px, 1fr)); + gap: 20px; padding: 16px; width: 100%; box-sizing: border-box; @@ -57,54 +57,95 @@ } .item-card { - position: relative; cursor: pointer; - transition: transform 0.2s; - height: 100%; - display: flex; - flex-direction: column; + border-radius: 8px; + overflow: hidden; + transition: transform 0.2s ease, box-shadow 0.2s ease; &:hover { - transform: scale(1.02); + transform: translateY(-3px); + box-shadow: 0 6px 16px rgba(0, 0, 0, 0.12); } -} -.item-image { - width: 100%; - aspect-ratio: 2/3; - object-fit: cover; -} + .thumbnail-wrapper { + position: relative; -.favorite-button { - position: absolute; - top: 8px; - right: 8px; - z-index: 1; -} + .poster { + width: 100%; + aspect-ratio: 2/3; + object-fit: cover; + display: block; + } -mat-card-content { - padding: 12px; - flex-grow: 1; + .remove-button { + position: absolute; + top: 4px; + right: 4px; + z-index: 1; + background: rgba(0, 0, 0, 0.5); + width: 28px; + height: 28px; + padding: 0; - 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; + &:hover { + background: rgba(0, 0, 0, 0.8); + } + + mat-icon { + color: white; + font-size: 18px; + width: 18px; + height: 18px; + } + } + + .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; + } + } } -} -.added-date { - font-size: 12px; - color: rgba(255, 255, 255, 0.7); - margin: 4px 0 0 0; + .item-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; + } + + .added-date { + margin: 4px 0 0; + font-size: 0.7rem; + opacity: 0.6; + } + } } .no-items { 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..6e3da045b 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,39 @@
@for (item of $any(group).value; track item.id) { - - - +
+ + + " + (error)=" + $event.target.src = + './assets/images/default-poster.png' + " + [alt]="item.title" + class="poster" + /> + + {{ item.type }} + +

{{ item.title }}

-

- {{ item.type | titlecase }} -

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

-
+
} @@ -70,32 +73,35 @@ } @else {
@for (item of recentItems(); track item.id) { - - - +
+
+ + + + {{ item.type }} + +

{{ item.title }}

-

- {{ item.type | titlecase }} -

- {{ item.viewed_at | date : 'd MMMM, y, hh:mm' }} + {{ item.viewed_at | date : 'd MMM, 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..b2a151909 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 @@ -25,59 +25,99 @@ .items-grid { display: grid; - grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); + grid-template-columns: repeat(auto-fill, minmax(140px, 1fr)); gap: 20px; } .item-card { cursor: pointer; - transition: transform 0.2s; - position: relative; + border-radius: 8px; + overflow: hidden; + transition: transform 0.2s ease, box-shadow 0.2s ease; &:hover { - transform: scale(1.02); + transform: translateY(-3px); + box-shadow: 0 6px 16px rgba(0, 0, 0, 0.12); } - .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); + .thumbnail-wrapper { + position: relative; + + .poster { + width: 100%; + aspect-ratio: 2/3; + object-fit: cover; + display: block; } - mat-icon { - color: white; + .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; + } + } + + .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; + } } } -} -.poster { - width: 100%; - height: 280px; - object-fit: cover; -} + .item-info { + padding: 8px 10px; -.item-info { - padding: 16px; + 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; + } - h3 { - margin: 0; - font-size: 16px; - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; - } - - p { - margin: 8px 0; - } - - .viewed-at { - font-size: 12px; + .viewed-at { + margin: 4px 0 0; + font-size: 0.7rem; + opacity: 0.6; + } } } @@ -93,26 +133,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; 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..5a8bc51f6 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,9 +1,9 @@ -import { DatePipe, KeyValuePipe, TitleCasePipe } from '@angular/common'; +import { DatePipe, 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 { MatTooltip } from '@angular/material/tooltip'; import { ActivatedRoute, Router } from '@angular/router'; import groupBy from 'lodash/groupBy'; import { XtreamStore } from '../stores/xtream.store'; @@ -14,11 +14,9 @@ import { XtreamStore } from '../stores/xtream.store'; DatePipe, KeyValuePipe, MatButton, - MatCard, MatIcon, MatIconButton, - MatIconButton, - TitleCasePipe, + MatTooltip, ], providers: [], templateUrl: './recently-viewed.component.html', From e6923d3fff43be2eea0a01235c8533c164aa208e Mon Sep 17 00:00:00 2001 From: 4gray Date: Wed, 4 Feb 2026 22:12:51 +0100 Subject: [PATCH 03/10] feat: implement content card component with dynamic features and styles --- .../content-card/content-card.component.html | 48 +++++++ .../content-card/content-card.component.scss | 21 ++++ .../content-card/content-card.component.ts | 73 +++++++++++ .../src/app/shared/styles/_content-grid.scss | 118 ++++++++++++++++++ 4 files changed, 260 insertions(+) create mode 100644 apps/web/src/app/shared/components/content-card/content-card.component.html create mode 100644 apps/web/src/app/shared/components/content-card/content-card.component.scss create mode 100644 apps/web/src/app/shared/components/content-card/content-card.component.ts create mode 100644 apps/web/src/app/shared/styles/_content-grid.scss 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..9ee4049ed --- /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 */ + 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/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; + } +} From 2f1e455330fdbd9ca1b4ba79a540931a815691ff Mon Sep 17 00:00:00 2001 From: 4gray Date: Wed, 4 Feb 2026 22:13:23 +0100 Subject: [PATCH 04/10] refactor: refactor favorites and recently viewed components to use content card --- .../favorites-layout.component.html | 44 ++------ .../favorites-layout.component.scss | 97 +---------------- .../favorites-layout.component.ts | 10 +- apps/web/src/app/shared/components/index.ts | 1 + .../recently-viewed.component.html | 82 ++++---------- .../recently-viewed.component.scss | 100 +----------------- .../recently-viewed.component.ts | 10 +- .../search-results.component.html | 32 ++---- .../search-results.component.scss | 85 +-------------- .../search-results.component.ts | 4 +- 10 files changed, 55 insertions(+), 410 deletions(-) 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 74ebad294..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,40 +38,16 @@ @if (favorites().length) {
@for (item of favorites(); track $index) { -
-
- - - - {{ item.type }} - -
-
-

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

-

- {{ item?.added_at | date: 'd MMM, 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 32ecc6b24..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,107 +48,13 @@ } .items-grid { - display: grid; - grid-template-columns: repeat(auto-fill, minmax(140px, 1fr)); - gap: 20px; + @include grid.content-grid(140px, 20px); padding: 16px; width: 100%; box-sizing: border-box; margin: 0; } -.item-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); - } - - .thumbnail-wrapper { - position: relative; - - .poster { - width: 100%; - aspect-ratio: 2/3; - object-fit: cover; - display: block; - } - - .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; - } - } - - .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; - } - } - } - - .item-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; - } - - .added-date { - margin: 4px 0 0; - font-size: 0.7rem; - opacity: 0.6; - } - } -} - .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/xtream-tauri/recently-viewed/recently-viewed.component.html b/apps/web/src/app/xtream-tauri/recently-viewed/recently-viewed.component.html index 6e3da045b..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,39 +32,16 @@
@for (item of $any(group).value; track item.id) { -
-
- - - - {{ item.type }} - -
-
-

{{ item.title }}

-

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

-
-
+ }
@@ -73,35 +50,16 @@ } @else {
@for (item of recentItems(); track item.id) { -
-
- - - - {{ item.type }} - -
-
-

{{ item.title }}

-

- {{ item.viewed_at | date : 'd MMM, 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 b2a151909..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,101 +26,7 @@ } .items-grid { - display: grid; - grid-template-columns: repeat(auto-fill, minmax(140px, 1fr)); - gap: 20px; -} - -.item-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); - } - - .thumbnail-wrapper { - position: relative; - - .poster { - width: 100%; - aspect-ratio: 2/3; - object-fit: cover; - display: block; - } - - .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; - } - } - - .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; - } - } - } - - .item-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; - } - - .viewed-at { - margin: 4px 0 0; - font-size: 0.7rem; - opacity: 0.6; - } - } + @include grid.content-grid(140px, 20px); } .no-items { @@ -180,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 5a8bc51f6..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,22 +1,21 @@ -import { DatePipe, KeyValuePipe } from '@angular/common'; +import { KeyValuePipe } from '@angular/common'; import { Component, computed, inject, Optional } from '@angular/core'; import { MatButton, MatIconButton } from '@angular/material/button'; import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog'; import { MatIcon } from '@angular/material/icon'; -import { MatTooltip } from '@angular/material/tooltip'; 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, MatIcon, MatIconButton, - MatTooltip, ], providers: [], templateUrl: './recently-viewed.component.html', @@ -88,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 498bdb4d1..bdec05359 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 @@ -75,31 +75,13 @@
@for (item of xtreamStore.searchResults(); track item.id) { -
-
- @if (item.poster_url) { - - } @else { -
- {{ item.type === 'live' ? 'live_tv' : item.type === 'series' ? 'tv' : 'movie' }} -
- } - - {{ item.type }} - -
-
-

{{ item.title }}

-
-
+ }
} } @else if (searchTerm() && searchTerm().length >= 3) { diff --git a/apps/web/src/app/xtream-tauri/search-results/search-results.component.scss b/apps/web/src/app/xtream-tauri/search-results/search-results.component.scss index 823db5e3d..538c1ed92 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; @@ -80,89 +82,8 @@ overflow-y: auto; .results-grid { - display: grid; - grid-template-columns: repeat(auto-fill, minmax(140px, 1fr)); - gap: 20px; + @include grid.content-grid(140px, 20px); padding-bottom: 1rem; - - .result-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); - } - - .thumbnail-wrapper { - position: relative; - - .poster, - .poster-placeholder { - width: 100%; - aspect-ratio: 2/3; - object-fit: cover; - display: block; - } - - .poster-placeholder { - display: flex; - align-items: center; - justify-content: center; - background: rgba(128, 128, 128, 0.1); - - mat-icon { - font-size: 36px; - width: 36px; - height: 36px; - opacity: 0.25; - } - } - - .type-badge { - position: absolute; - top: 6px; - left: 6px; - padding: 3px 6px; - border-radius: 4px; - font-size: 9px; - font-weight: 700; - text-transform: uppercase; - letter-spacing: 0.5px; - backdrop-filter: blur(8px); - -webkit-backdrop-filter: blur(8px); - background: rgba(0, 0, 0, 0.6); - color: #fff; - - &.live { - color: #ef5350; - } - &.movie { - color: #42a5f5; - } - &.series { - color: #66bb6a; - } - } - } - - .card-info { - padding: 8px 10px; - - h3 { - margin: 0; - font-size: 0.8rem; - font-weight: 500; - line-height: 1.3; - display: -webkit-box; - -webkit-line-clamp: 2; - -webkit-box-orient: vertical; - overflow: hidden; - } - } - } } } diff --git a/apps/web/src/app/xtream-tauri/search-results/search-results.component.ts b/apps/web/src/app/xtream-tauri/search-results/search-results.component.ts index 4f020a352..e60ab18dd 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 @@ -10,7 +10,6 @@ import { } 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, @@ -26,6 +25,7 @@ import groupBy from 'lodash/groupBy'; 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 { SearchResultItemComponent } from '../../shared/components/search-result-item/search-result-item.component'; import { XtreamStore } from '../stores/xtream.store'; @@ -38,9 +38,9 @@ interface SearchResultsData { @Component({ selector: 'app-search-results', imports: [ + ContentCardComponent, FormsModule, KeyValuePipe, - MatCardModule, MatCheckboxModule, MatDialogModule, MatFormFieldModule, From 9a04c42e7963e759062640bf11b229bd8da9cc03 Mon Sep 17 00:00:00 2001 From: 4gray Date: Wed, 4 Feb 2026 22:52:52 +0100 Subject: [PATCH 05/10] refactor: remove unused back navigation and clean up downloads component --- .../xtream-tauri/downloads/downloads.component.html | 13 ++++++++----- .../xtream-tauri/downloads/downloads.component.ts | 7 +------ 2 files changed, 9 insertions(+), 11 deletions(-) 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'; From 2691e3f36e8c01f94e101bfffaa1f231b42e1135 Mon Sep 17 00:00:00 2001 From: 4gray Date: Wed, 4 Feb 2026 22:55:02 +0100 Subject: [PATCH 06/10] refactor: update navigation and recently added components for improved UI and functionality --- .../content-card/content-card.component.ts | 4 ++-- .../recently-added.component.html | 18 +++++++++--------- .../recently-added/recently-added.component.ts | 5 ++--- 3 files changed, 13 insertions(+), 14 deletions(-) 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 index 9ee4049ed..85f4f8217 100644 --- 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 @@ -27,8 +27,8 @@ export class ContentCardComponent { /** Content type (live, movie, series) */ readonly type = input(); - /** Optional date to display */ - readonly date = input(); + /** Optional date to display (Date, string, or timestamp number) */ + readonly date = input(); /** Whether to show the remove button */ readonly showRemoveButton = input(false); 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); From e8632e7bee342d307deb3635a815ab1061586dfb Mon Sep 17 00:00:00 2001 From: 4gray Date: Thu, 5 Feb 2026 23:07:46 +0100 Subject: [PATCH 07/10] feat: implement category scroll restoration in live stream layout --- .../live-stream-layout.component.ts | 27 ++++++++++++++++++- 1 file changed, 26 insertions(+), 1 deletion(-) 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 { From ed9e85f5e8f1fc1a6ba98de73f1611283d62f80f Mon Sep 17 00:00:00 2001 From: 4gray Date: Thu, 5 Feb 2026 23:08:22 +0100 Subject: [PATCH 08/10] refactor: implement search layout component with dynamic results and filters --- .../search-layout.component.html | 71 ++++++++ .../search-layout.component.scss | 165 +++++++++++++++++ .../search-layout/search-layout.component.ts | 94 ++++++++++ .../stalker-search.component.html | 137 +++++++------- .../stalker-search.component.scss | 20 +++ .../stalker-search.component.ts | 126 ++++++------- .../navigation/navigation.component.ts | 6 +- .../search-results.component.html | 168 +++++++----------- .../search-results.component.scss | 148 ++------------- .../search-results.component.ts | 22 +-- 10 files changed, 557 insertions(+), 400 deletions(-) create mode 100644 apps/web/src/app/shared/components/search-layout/search-layout.component.html create mode 100644 apps/web/src/app/shared/components/search-layout/search-layout.component.scss create mode 100644 apps/web/src/app/shared/components/search-layout/search-layout.component.ts create mode 100644 apps/web/src/app/stalker/stalker-search/stalker-search.component.scss 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 @@ -
-