mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-08 17:06:15 -08:00
style(season-container): enhance empty state handling and add corresponding styles
Entire-Checkpoint: c6e522b4276c
This commit is contained in:
1 parent
d6fd28450d
commit
6e6bd04eca
4 files changed
+292
-2
No files matched your search
@@ -9,7 +9,7 @@
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
@if (selectedSeason) {
|
||||
@if (selectedSeason && !showSeasonEmptyState()) {
|
||||
<div class="view-toggle">
|
||||
<mat-button-toggle-group
|
||||
[value]="viewMode()"
|
||||
@@ -32,6 +32,17 @@
|
||||
<div class="loading-container">
|
||||
<mat-spinner diameter="40"></mat-spinner>
|
||||
</div>
|
||||
} @else if (showSeriesEmptyState()) {
|
||||
<div class="empty-state-panel" role="status">
|
||||
<div class="empty-state-panel__icon" aria-hidden="true">
|
||||
<mat-icon>video_library</mat-icon>
|
||||
</div>
|
||||
<h4 class="empty-state-panel__title">No episodes available</h4>
|
||||
<p class="empty-state-panel__message">
|
||||
This series is listed by your provider, but no seasons or
|
||||
episodes are available to play.
|
||||
</p>
|
||||
</div>
|
||||
} @else if (!selectedSeason) {
|
||||
<!-- Seasons Grid -->
|
||||
<div class="seasons-grid">
|
||||
@@ -62,6 +73,17 @@
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
} @else if (showSeasonEmptyState()) {
|
||||
<div class="empty-state-panel empty-state-panel--season" role="status">
|
||||
<div class="empty-state-panel__icon" aria-hidden="true">
|
||||
<mat-icon>video_library</mat-icon>
|
||||
</div>
|
||||
<h4 class="empty-state-panel__title">This season is empty</h4>
|
||||
<p class="empty-state-panel__message">
|
||||
This season is listed by your provider, but no playable
|
||||
episodes are available.
|
||||
</p>
|
||||
</div>
|
||||
} @else {
|
||||
@if (viewMode() === 'grid') {
|
||||
<!-- Episodes Grid View -->
|
||||
|
||||
@@ -112,6 +112,64 @@
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.empty-state-panel {
|
||||
display: grid;
|
||||
justify-items: center;
|
||||
gap: 14px;
|
||||
width: min(100%, 520px);
|
||||
margin: 0 auto;
|
||||
padding: 32px 28px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||
border-radius: 16px;
|
||||
background:
|
||||
linear-gradient(180deg, rgba(255, 255, 255, 0.06), rgba(255, 255, 255, 0.03)),
|
||||
radial-gradient(circle at top, rgba(255, 255, 255, 0.06), transparent 60%);
|
||||
box-shadow:
|
||||
inset 0 1px 0 rgba(255, 255, 255, 0.04),
|
||||
0 18px 40px rgba(0, 0, 0, 0.22);
|
||||
text-align: center;
|
||||
|
||||
&__icon {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
width: 64px;
|
||||
height: 64px;
|
||||
border-radius: 50%;
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||
color: rgba(255, 255, 255, 0.52);
|
||||
|
||||
mat-icon {
|
||||
font-size: 30px;
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
}
|
||||
}
|
||||
|
||||
&__title {
|
||||
margin: 0;
|
||||
color: var(--text-primary);
|
||||
font-size: 1.125rem;
|
||||
font-weight: 600;
|
||||
letter-spacing: -0.02em;
|
||||
}
|
||||
|
||||
&__message {
|
||||
max-width: 40ch;
|
||||
margin: 0;
|
||||
color: var(--text-secondary);
|
||||
font-size: 0.9375rem;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
&--season {
|
||||
border-color: rgba(255, 255, 255, 0.1);
|
||||
background:
|
||||
linear-gradient(180deg, rgba(255, 255, 255, 0.07), rgba(255, 255, 255, 0.04)),
|
||||
radial-gradient(circle at top, rgba(255, 255, 255, 0.08), transparent 65%);
|
||||
}
|
||||
}
|
||||
|
||||
// Grid Layouts
|
||||
.seasons-grid,
|
||||
.episodes-grid {
|
||||
|
||||
@@ -0,0 +1,162 @@
|
||||
import { signal } from '@angular/core';
|
||||
import { NoopAnimationsModule } from '@angular/platform-browser/animations';
|
||||
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
||||
import { TranslateModule } from '@ngx-translate/core';
|
||||
import { XtreamSerieEpisode } from 'shared-interfaces';
|
||||
import { DownloadsService } from 'services';
|
||||
import { SeasonContainerComponent } from './season-container.component';
|
||||
|
||||
const downloadsServiceStub = {
|
||||
isAvailable: signal(false),
|
||||
downloads: () => [],
|
||||
startDownload: async () => undefined,
|
||||
isDownloaded: () => false,
|
||||
isDownloading: () => false,
|
||||
getDownloadedFilePath: () => '',
|
||||
playDownload: async () => undefined,
|
||||
};
|
||||
|
||||
function createEpisode(
|
||||
overrides: Partial<XtreamSerieEpisode> = {}
|
||||
): XtreamSerieEpisode {
|
||||
return {
|
||||
id: '101',
|
||||
episode_num: 1,
|
||||
title: 'Pilot',
|
||||
container_extension: 'mp4',
|
||||
info: {
|
||||
duration: '45 min',
|
||||
plot: 'Pilot episode',
|
||||
movie_image: 'https://example.com/poster.jpg',
|
||||
},
|
||||
custom_sid: '',
|
||||
added: '',
|
||||
season: 1,
|
||||
direct_source: '',
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe('SeasonContainerComponent', () => {
|
||||
let fixture: ComponentFixture<SeasonContainerComponent>;
|
||||
let component: SeasonContainerComponent;
|
||||
|
||||
const setRequiredInputs = (
|
||||
seasons: Record<string, XtreamSerieEpisode[]>,
|
||||
isLoading = false
|
||||
) => {
|
||||
fixture.componentRef.setInput('seasons', seasons);
|
||||
fixture.componentRef.setInput('seriesId', 1);
|
||||
fixture.componentRef.setInput('playlistId', 'playlist-1');
|
||||
fixture.componentRef.setInput('isLoading', isLoading);
|
||||
};
|
||||
|
||||
beforeEach(async () => {
|
||||
await TestBed.configureTestingModule({
|
||||
imports: [
|
||||
NoopAnimationsModule,
|
||||
SeasonContainerComponent,
|
||||
TranslateModule.forRoot(),
|
||||
],
|
||||
providers: [
|
||||
{
|
||||
provide: DownloadsService,
|
||||
useValue: downloadsServiceStub,
|
||||
},
|
||||
],
|
||||
}).compileComponents();
|
||||
|
||||
fixture = TestBed.createComponent(SeasonContainerComponent);
|
||||
component = fixture.componentInstance;
|
||||
});
|
||||
|
||||
it('renders the series-level placeholder when no seasons are available', () => {
|
||||
setRequiredInputs({});
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(
|
||||
fixture.nativeElement.querySelector('.empty-state-panel')
|
||||
).not.toBeNull();
|
||||
expect(fixture.nativeElement.textContent).toContain(
|
||||
'No episodes available'
|
||||
);
|
||||
expect(fixture.nativeElement.textContent).toContain(
|
||||
'This series is listed by your provider, but no seasons or episodes are available to play.'
|
||||
);
|
||||
expect(fixture.nativeElement.querySelector('.season-card')).toBeNull();
|
||||
});
|
||||
|
||||
it('renders the loading state instead of the placeholder while seasons are loading', () => {
|
||||
setRequiredInputs({}, true);
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(
|
||||
fixture.nativeElement.querySelector('.loading-container mat-spinner')
|
||||
).not.toBeNull();
|
||||
expect(
|
||||
fixture.nativeElement.querySelector('.empty-state-panel')
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it('renders the season-level placeholder when the selected season has no episodes', () => {
|
||||
setRequiredInputs({ '1': [] });
|
||||
fixture.detectChanges();
|
||||
|
||||
fixture.nativeElement.querySelector('.season-card').click();
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(fixture.nativeElement.textContent).toContain(
|
||||
'This season is empty'
|
||||
);
|
||||
expect(fixture.nativeElement.textContent).toContain(
|
||||
'This season is listed by your provider, but no playable episodes are available.'
|
||||
);
|
||||
expect(fixture.nativeElement.querySelector('.view-toggle')).toBeNull();
|
||||
});
|
||||
|
||||
it('renders season cards and episode cards when episodes exist', () => {
|
||||
const episode = createEpisode();
|
||||
|
||||
setRequiredInputs({
|
||||
'1': [episode],
|
||||
'2': [],
|
||||
});
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(
|
||||
fixture.nativeElement.querySelectorAll('.season-card').length
|
||||
).toBe(2);
|
||||
expect(
|
||||
fixture.nativeElement.querySelector('.empty-state-panel')
|
||||
).toBeNull();
|
||||
|
||||
fixture.nativeElement.querySelector('.season-card').click();
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(
|
||||
fixture.nativeElement.querySelectorAll('.episode-card').length
|
||||
).toBe(1);
|
||||
expect(
|
||||
fixture.nativeElement.querySelector('.empty-state-panel')
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it('clears a stale selected season when the input seasons change', () => {
|
||||
const episode = createEpisode();
|
||||
|
||||
setRequiredInputs({ '1': [episode] });
|
||||
fixture.detectChanges();
|
||||
|
||||
fixture.nativeElement.querySelector('.season-card').click();
|
||||
fixture.detectChanges();
|
||||
|
||||
fixture.componentRef.setInput('seasons', { '2': [episode] });
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(component.selectedSeason).toBeUndefined();
|
||||
expect(
|
||||
fixture.nativeElement.querySelector('.season-card__number')
|
||||
?.textContent
|
||||
).toContain('2');
|
||||
});
|
||||
});
|
||||
@@ -1,6 +1,7 @@
|
||||
import { KeyValuePipe } from '@angular/common';
|
||||
import {
|
||||
Component,
|
||||
DoCheck,
|
||||
OnInit,
|
||||
inject,
|
||||
input,
|
||||
@@ -78,9 +79,10 @@ function parseDuration(duration: string | number | undefined): number {
|
||||
WatchedBadgeComponent,
|
||||
],
|
||||
})
|
||||
export class SeasonContainerComponent implements OnInit {
|
||||
export class SeasonContainerComponent implements OnInit, DoCheck {
|
||||
private readonly downloadsService = inject(DownloadsService);
|
||||
private readonly logger = createLogger('SeasonContainer');
|
||||
private previousSeasonKeysSignature = '';
|
||||
|
||||
readonly seasons = input.required<Record<string, XtreamSerieEpisode[]>>();
|
||||
readonly seriesId = input.required<number>();
|
||||
@@ -112,6 +114,11 @@ export class SeasonContainerComponent implements OnInit {
|
||||
if (savedMode === 'grid' || savedMode === 'list') {
|
||||
this.viewMode.set(savedMode);
|
||||
}
|
||||
this.previousSeasonKeysSignature = this.getSeasonKeys().join('|');
|
||||
}
|
||||
|
||||
ngDoCheck(): void {
|
||||
this.syncSelectedSeason();
|
||||
}
|
||||
|
||||
setViewMode(mode: EpisodeViewMode) {
|
||||
@@ -123,6 +130,18 @@ export class SeasonContainerComponent implements OnInit {
|
||||
return Number(a.key) - Number(b.key);
|
||||
}
|
||||
|
||||
hasSeasons(): boolean {
|
||||
return this.getSeasonKeys().length > 0;
|
||||
}
|
||||
|
||||
showSeriesEmptyState(): boolean {
|
||||
return !this.selectedSeason && !this.hasSeasons();
|
||||
}
|
||||
|
||||
showSeasonEmptyState(): boolean {
|
||||
return Boolean(this.selectedSeason) && !this.selectedSeasonHasEpisodes();
|
||||
}
|
||||
|
||||
selectSeason(seasonKey: string) {
|
||||
this.selectedSeason = seasonKey;
|
||||
this.seasonSelected.emit(seasonKey);
|
||||
@@ -384,6 +403,35 @@ export class SeasonContainerComponent implements OnInit {
|
||||
return this.playbackPositions().get(this.getEpisodeContentId(episode));
|
||||
}
|
||||
|
||||
private selectedSeasonHasEpisodes(): boolean {
|
||||
if (!this.selectedSeason) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return (this.seasons()[this.selectedSeason]?.length ?? 0) > 0;
|
||||
}
|
||||
|
||||
private getSeasonKeys(): string[] {
|
||||
return Object.keys(this.seasons());
|
||||
}
|
||||
|
||||
private hasSeasonKey(seasonKey: string): boolean {
|
||||
return Object.prototype.hasOwnProperty.call(this.seasons(), seasonKey);
|
||||
}
|
||||
|
||||
private syncSelectedSeason(): void {
|
||||
const seasonKeysSignature = this.getSeasonKeys().join('|');
|
||||
if (seasonKeysSignature === this.previousSeasonKeysSignature) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.previousSeasonKeysSignature = seasonKeysSignature;
|
||||
|
||||
if (this.selectedSeason && !this.hasSeasonKey(this.selectedSeason)) {
|
||||
this.selectedSeason = undefined;
|
||||
}
|
||||
}
|
||||
|
||||
private hashString(str: string): number {
|
||||
let hash = 0;
|
||||
for (let index = 0; index < str.length; index++) {
|
||||
|
||||
Reference in new issue
Block a user