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 (isLoading()) {
+
+ @for (_ of [1, 2, 3]; track $index) {
+
+
+
+ @for (slot of skeletonSlots; track slot) {
+
+ }
+
+
+ }
+ } @else {
+ @if (recentlyAddedVod().length > 0) {
+
@for (item of recentlyAddedVod(); track item.xtream_id) {
}
-
-
- }
+
+ }
- @if (recentlyAddedSeries().length > 0) {
-
-
{{ 'PORTALS.SIDEBAR.SERIES' | translate }}
-
+ @if (recentlyAddedSeries().length > 0) {
+
@for (item of recentlyAddedSeries(); track item.xtream_id) {
}
-
-
- }
+
+ }
- @if (recentlyAddedLive().length > 0) {
-
-
{{ 'PORTALS.SIDEBAR.LIVE_TV' | translate }}
-
+ @if (recentlyAddedLive().length > 0) {
+
@for (item of recentlyAddedLive(); track item.xtream_id) {
}
-
-
+
+ }
}
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