feat: enhance recently added component with skeleton loading states and layout improvements

This commit is contained in:
4gray committed 2026-04-22 22:23:12 +02:00
1 parent 92cbd44e89
commit 199ff91fcc
3 files changed
+222 -101

No files matched your search

@@ -1,13 +1,37 @@
<div class="recently-added-page">
<h2>{{ 'PORTALS.SIDEBAR.RECENTLY_ADDED' | translate }}</h2>
<h2 class="recently-added-page__title">
{{ 'PORTALS.SIDEBAR.RECENTLY_ADDED' | translate }}
</h2>
@if (recentlyAddedVod().length > 0) {
<div class="section" [class.section--active]="isSectionActive('vod')">
<h3>{{ 'PORTALS.SIDEBAR.MOVIES' | translate }}</h3>
<div class="carousel">
@if (isLoading()) {
<!-- Three skeleton rails cover the typical shape of this view. -->
@for (_ of [1, 2, 3]; track $index) {
<section class="recently-added-page__skeleton-rail">
<div
class="recently-added-page__skeleton-header"
aria-hidden="true"
></div>
<div
class="recently-added-page__skeleton-track"
aria-hidden="true"
>
@for (slot of skeletonSlots; track slot) {
<div class="recently-added-page__skeleton-card"></div>
}
</div>
</section>
}
} @else {
@if (recentlyAddedVod().length > 0) {
<app-content-rail-shell
class="recently-added-page__rail"
[label]="'PORTALS.SIDEBAR.MOVIES' | translate"
[totalCount]="recentlyAddedVod().length"
[seeAllLink]="vodSeeAllLink()"
[seeAllLabel]="vodSeeAllLabel()"
>
@for (item of recentlyAddedVod(); track item.xtream_id) {
<app-content-card
class="carousel-item"
[title]="item.title || item.name"
[posterUrl]="item.poster_url || item.stream_icon"
[type]="'movie'"
@@ -15,20 +39,19 @@
(cardClick)="openItem(item, 'vod')"
/>
}
</div>
</div>
}
</app-content-rail-shell>
}
@if (recentlyAddedSeries().length > 0) {
<div
class="section"
[class.section--active]="isSectionActive('series')"
>
<h3>{{ 'PORTALS.SIDEBAR.SERIES' | translate }}</h3>
<div class="carousel">
@if (recentlyAddedSeries().length > 0) {
<app-content-rail-shell
class="recently-added-page__rail"
[label]="'PORTALS.SIDEBAR.SERIES' | translate"
[totalCount]="recentlyAddedSeries().length"
[seeAllLink]="seriesSeeAllLink()"
[seeAllLabel]="seriesSeeAllLabel()"
>
@for (item of recentlyAddedSeries(); track item.xtream_id) {
<app-content-card
class="carousel-item"
[title]="item.title || item.name"
[posterUrl]="item.poster_url || item.cover"
[type]="'series'"
@@ -36,17 +59,19 @@
(cardClick)="openItem(item, 'series')"
/>
}
</div>
</div>
}
</app-content-rail-shell>
}
@if (recentlyAddedLive().length > 0) {
<div class="section" [class.section--active]="isSectionActive('live')">
<h3>{{ 'PORTALS.SIDEBAR.LIVE_TV' | translate }}</h3>
<div class="carousel">
@if (recentlyAddedLive().length > 0) {
<app-content-rail-shell
class="recently-added-page__rail"
[label]="'PORTALS.SIDEBAR.LIVE_TV' | translate"
[totalCount]="recentlyAddedLive().length"
[seeAllLink]="liveSeeAllLink()"
[seeAllLabel]="liveSeeAllLabel()"
>
@for (item of recentlyAddedLive(); track item.xtream_id) {
<app-content-card
class="carousel-item"
[title]="item.title || item.name"
[posterUrl]="item.poster_url || item.stream_icon"
[type]="'live'"
@@ -54,7 +79,7 @@
(cardClick)="openItem(item, 'live')"
/>
}
</div>
</div>
</app-content-rail-shell>
}
}
</div>
@@ -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;
}
}
@@ -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<T extends RecentlyAddedItem>(
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);