diff --git a/libs/portal/xtream/feature/src/lib/recently-added/recently-added.component.html b/libs/portal/xtream/feature/src/lib/recently-added/recently-added.component.html index ff480f9c6..5acf14702 100644 --- a/libs/portal/xtream/feature/src/lib/recently-added/recently-added.component.html +++ b/libs/portal/xtream/feature/src/lib/recently-added/recently-added.component.html @@ -1,13 +1,37 @@
-

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

+

+ {{ 'PORTALS.SIDEBAR.RECENTLY_ADDED' | translate }} +

- @if (recentlyAddedVod().length > 0) { -
-

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

- -
- } + + } - @if (recentlyAddedSeries().length > 0) { -
-

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

- -
- } + + } - @if (recentlyAddedLive().length > 0) { -
-

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

- -
+ + } }
diff --git a/libs/portal/xtream/feature/src/lib/recently-added/recently-added.component.scss b/libs/portal/xtream/feature/src/lib/recently-added/recently-added.component.scss index 47dd77cde..4788b767f 100644 --- a/libs/portal/xtream/feature/src/lib/recently-added/recently-added.component.scss +++ b/libs/portal/xtream/feature/src/lib/recently-added/recently-added.component.scss @@ -5,75 +5,102 @@ } .recently-added-page { - padding: 20px; -} - -h2 { - margin: 0 0 24px; - font-size: 1.2rem; - font-weight: 650; - letter-spacing: -0.02em; -} - -.section { - padding: 12px 14px 0; - border: 1px solid transparent; - border-radius: 16px; - margin-bottom: 32px; - transition: - background 0.2s ease, - border-color 0.2s ease, - box-shadow 0.2s ease; - - h3 { - display: flex; - align-items: center; - gap: 8px; - margin-bottom: 16px; - font-size: 1rem; - font-weight: 600; - letter-spacing: -0.01em; - opacity: 0.8; - } -} - -.section.section--active { - border-color: var(--app-selection-border); - background: linear-gradient( - 145deg, - var(--app-selection-surface), - color-mix(in srgb, var(--app-selection-color) 6%, transparent) - ); - box-shadow: - inset 0 0 0 1px var(--app-selection-border), - 0 14px 24px -22px var(--app-selection-glow); - - h3 { - color: var(--app-selection-color); - opacity: 1; - - &::after { - content: ''; - width: 7px; - height: 7px; - border-radius: 999px; - background: var(--app-selection-color); - box-shadow: 0 0 10px var(--app-selection-glow); - } - } -} - -.carousel { + padding: 24px 28px 40px; + max-width: 1440px; + margin: 0 auto; + display: flex; + flex-direction: column; + gap: 28px; + min-width: 0; + box-sizing: border-box; +} + +.recently-added-page__title { + margin: 0; + font-size: 1.5rem; + font-weight: 650; + line-height: 1.2; + letter-spacing: -0.02em; + color: var(--app-heading-color, #d8dce8); +} + +// Projected card sizing lives on the caller — the rail shell can't reach +// projected children through emulated encapsulation. +.recently-added-page__rail app-content-card { + flex: 0 0 160px; + width: 160px; + scroll-snap-align: start; +} + +// ── Skeleton rails ───────────────────────────────────────────────────────── +// Shown while the xtream store is still fetching categories/content and +// before any section has resolved data. Matches the rail shell's header + +// track geometry so the layout doesn't shift when real content arrives. +.recently-added-page__skeleton-rail { + display: flex; + flex-direction: column; + gap: 12px; + padding: 12px 0 0; +} + +.recently-added-page__skeleton-header { + width: 180px; + height: 22px; + border-radius: 6px; +} + +.recently-added-page__skeleton-track { display: flex; - overflow-x: auto; gap: 16px; - padding-bottom: 16px; + padding: 4px 0 16px; + overflow: hidden; +} - scrollbar-width: thin; +.recently-added-page__skeleton-card { + flex: 0 0 160px; + width: 160px; + aspect-ratio: 2 / 3; + border-radius: 12px; +} - .carousel-item { - min-width: 160px; - width: 160px; - flex-shrink: 0; +.recently-added-page__skeleton-header, +.recently-added-page__skeleton-card { + background: linear-gradient( + 90deg, + var(--app-widget-bg, #1e2330) 0%, + var(--app-widget-header-bg, #232840) 50%, + var(--app-widget-bg, #1e2330) 100% + ); + background-size: 200% 100%; + animation: recently-added-shimmer 1.6s linear infinite; + border: 1px solid var(--app-widget-header-border, rgba(255, 255, 255, 0.05)); +} + +@keyframes recently-added-shimmer { + from { + background-position: 200% 0; + } + to { + background-position: -200% 0; + } +} + +@media (prefers-reduced-motion: reduce) { + .recently-added-page__skeleton-header, + .recently-added-page__skeleton-card { + animation: none; + background-position: 0 0; + } +} + +@media (max-width: 720px) { + .recently-added-page { + padding: 16px 16px 32px; + gap: 20px; + } + + .recently-added-page__skeleton-card { + flex: 0 0 136px; + width: 136px; } } diff --git a/libs/portal/xtream/feature/src/lib/recently-added/recently-added.component.ts b/libs/portal/xtream/feature/src/lib/recently-added/recently-added.component.ts index 4716a5ae1..d742b6666 100644 --- a/libs/portal/xtream/feature/src/lib/recently-added/recently-added.component.ts +++ b/libs/portal/xtream/feature/src/lib/recently-added/recently-added.component.ts @@ -1,7 +1,12 @@ import { Component, computed, inject } from '@angular/core'; +import { toSignal } from '@angular/core/rxjs-interop'; import { ActivatedRoute, Router } from '@angular/router'; -import { TranslatePipe } from '@ngx-translate/core'; -import { ContentCardComponent } from '@iptvnator/portal/shared/ui'; +import { TranslatePipe, TranslateService } from '@ngx-translate/core'; +import { startWith } from 'rxjs'; +import { + ContentCardComponent, + ContentRailShellComponent, +} from '@iptvnator/portal/shared/ui'; import { XtreamStore } from '@iptvnator/portal/xtream/data-access'; import { ContentType } from '@iptvnator/portal/xtream/data-access'; @@ -20,16 +25,26 @@ interface RecentlyAddedItem { readonly xtream_id?: number; } +// Three placeholder slots per skeleton rail — enough to suggest a horizontal +// scroll without taking the whole viewport. +const SKELETON_CARDS_PER_RAIL = [1, 2, 3, 4, 5, 6] as const; + @Component({ selector: 'app-recently-added', templateUrl: './recently-added.component.html', styleUrls: ['./recently-added.component.scss'], - imports: [ContentCardComponent, TranslatePipe], + imports: [ContentCardComponent, ContentRailShellComponent, TranslatePipe], }) export class RecentlyAddedComponent { private readonly xtreamStore = inject(XtreamStore); private readonly router = inject(Router); private readonly activatedRoute = inject(ActivatedRoute); + private readonly translate = inject(TranslateService); + // Re-compute translated labels when the active language changes. + private readonly languageTick = toSignal( + this.translate.onLangChange.pipe(startWith(null)), + { initialValue: null } + ); readonly recentlyAddedLive = computed(() => this.getRecentlyAdded( @@ -47,7 +62,65 @@ export class RecentlyAddedComponent { true ) ); - readonly selectedContentType = this.xtreamStore.selectedContentType; + + readonly skeletonSlots = SKELETON_CARDS_PER_RAIL; + + readonly totalItems = computed( + () => + this.recentlyAddedVod().length + + this.recentlyAddedSeries().length + + this.recentlyAddedLive().length + ); + + /** + * Show skeleton rails when the store is still fetching AND we have + * nothing resolved yet. Once a single section has data we drop the + * skeletons so partial-load states aren't obscured. + */ + readonly isLoading = computed(() => { + const store = this.xtreamStore; + const hasAnyData = this.totalItems() > 0; + if (hasAnyData) return false; + return ( + store.isLoadingContent() || + store.isLoadingCategories() || + store.isImporting() + ); + }); + + // Absolute rail "browse all" routes reuse the active playlist id. The + // store owns the current playlist, so navigation stays correct even if + // the user switches playlists before the rails finish loading. + private readonly playlistId = computed( + () => this.xtreamStore.currentPlaylist()?.id ?? null + ); + + readonly vodSeeAllLink = computed(() => this.buildSectionLink('vod')); + readonly seriesSeeAllLink = computed(() => + this.buildSectionLink('series') + ); + readonly liveSeeAllLink = computed(() => this.buildSectionLink('live')); + + readonly vodSeeAllLabel = computed(() => + this.translateWithTick('PORTALS.BROWSE_ALL_MOVIES') + ); + readonly seriesSeeAllLabel = computed(() => + this.translateWithTick('PORTALS.BROWSE_ALL_SERIES') + ); + readonly liveSeeAllLabel = computed(() => + this.translateWithTick('PORTALS.BROWSE_ALL_LIVE') + ); + + private buildSectionLink(section: 'vod' | 'series' | 'live'): string[] | null { + const id = this.playlistId(); + if (!id) return null; + return ['/workspace', 'xtreams', id, section]; + } + + private translateWithTick(key: string): string { + this.languageTick(); + return this.translate.instant(key); + } private getRecentlyAdded( items: T[], @@ -69,10 +142,6 @@ export class RecentlyAddedComponent { return parseInt(timestamp) * 1000; } - isSectionActive(type: ContentType): boolean { - return this.selectedContentType() === type; - } - openItem(item: RecentlyAddedItem, type: ContentType) { this.xtreamStore.setSelectedContentType(type);