mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-10 18:36:15 -08:00
feat: enhance recently added component with skeleton loading states and layout improvements
This commit is contained in:
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);
|
||||
|
||||
|
||||
Reference in new issue
Block a user