mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-08 17:06:15 -08:00
feat: implement loading state and skeleton UI for channel list
Entire-Checkpoint: e6dee91f3aee
This commit is contained in:
1 parent
70b7f570ef
commit
0661bf08b5
4 files changed
+259
-5
No files matched your search
+19
-1
@@ -1,5 +1,23 @@
|
||||
<div class="channels-list">
|
||||
@if (filteredChannels().length > 0) {
|
||||
@if (isSelectedTypeContentLoading()) {
|
||||
<div class="channels-loading-state" aria-live="polite">
|
||||
<div class="channels-loading-state__copy">
|
||||
<div class="channels-loading-state__title">
|
||||
{{ 'CHANNELS.LOADING' | translate }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="channels-loading-state__list" aria-hidden="true">
|
||||
@for (row of loadingRows; track row) {
|
||||
<div class="channel-skeleton">
|
||||
<span class="channel-skeleton__logo"></span>
|
||||
<span class="channel-skeleton__title"></span>
|
||||
<span class="channel-skeleton__meta"></span>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
} @else if (filteredChannels().length > 0) {
|
||||
<cdk-virtual-scroll-viewport
|
||||
[itemSize]="68"
|
||||
class="scroll-viewport-portals"
|
||||
|
||||
+102
@@ -142,6 +142,102 @@
|
||||
}
|
||||
}
|
||||
|
||||
.channels-loading-state {
|
||||
display: grid;
|
||||
gap: 14px;
|
||||
height: 100%;
|
||||
padding: 16px 12px 18px;
|
||||
}
|
||||
|
||||
.channels-loading-state__copy {
|
||||
padding: 0 4px;
|
||||
}
|
||||
|
||||
.channels-loading-state__title {
|
||||
font-size: 0.86rem;
|
||||
font-weight: 600;
|
||||
letter-spacing: -0.01em;
|
||||
color: var(--mat-sys-on-surface);
|
||||
}
|
||||
|
||||
.channels-loading-state__list {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.channel-skeleton {
|
||||
display: grid;
|
||||
grid-template-columns: 44px minmax(0, 1fr);
|
||||
grid-template-rows: 14px 10px;
|
||||
align-items: center;
|
||||
gap: 8px 12px;
|
||||
min-height: 68px;
|
||||
padding: 10px 12px;
|
||||
border-radius: 14px;
|
||||
background: color-mix(
|
||||
in srgb,
|
||||
var(--app-widget-header-bg, var(--mat-sys-surface-container-high)) 88%,
|
||||
var(--app-widget-bg, var(--mat-sys-surface-container)) 12%
|
||||
);
|
||||
border: 1px solid
|
||||
color-mix(
|
||||
in srgb,
|
||||
var(--app-widget-header-border, var(--mat-sys-outline-variant)) 96%,
|
||||
transparent
|
||||
);
|
||||
}
|
||||
|
||||
.channel-skeleton__logo,
|
||||
.channel-skeleton__title,
|
||||
.channel-skeleton__meta {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
background: color-mix(
|
||||
in srgb,
|
||||
var(--app-widget-header-bg, var(--mat-sys-surface-container-high)) 52%,
|
||||
var(--app-body-color, var(--mat-sys-on-surface-variant)) 48%
|
||||
);
|
||||
|
||||
&::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: linear-gradient(
|
||||
100deg,
|
||||
transparent 8%,
|
||||
color-mix(
|
||||
in srgb,
|
||||
var(--app-heading-color, var(--mat-sys-on-surface)) 18%,
|
||||
transparent
|
||||
)
|
||||
44%,
|
||||
transparent 74%
|
||||
);
|
||||
transform: translateX(-120%);
|
||||
animation: channel-skeleton-shimmer 1.15s ease-in-out infinite;
|
||||
}
|
||||
}
|
||||
|
||||
.channel-skeleton__logo {
|
||||
grid-row: 1 / span 2;
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
border-radius: 12px;
|
||||
}
|
||||
|
||||
.channel-skeleton__title {
|
||||
height: 11px;
|
||||
width: min(68%, 180px);
|
||||
border-radius: 999px;
|
||||
}
|
||||
|
||||
.channel-skeleton__meta {
|
||||
height: 9px;
|
||||
width: min(42%, 120px);
|
||||
border-radius: 999px;
|
||||
}
|
||||
|
||||
@container (max-width: 350px) {
|
||||
.scroll-viewport-portals {
|
||||
padding-inline: 6px;
|
||||
@@ -159,3 +255,9 @@
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes channel-skeleton-shimmer {
|
||||
100% {
|
||||
transform: translateX(120%);
|
||||
}
|
||||
}
|
||||
+129
@@ -0,0 +1,129 @@
|
||||
import { signal } from '@angular/core';
|
||||
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
||||
import { NoopAnimationsModule } from '@angular/platform-browser/animations';
|
||||
import { ActivatedRoute } from '@angular/router';
|
||||
import { TranslateService } from '@ngx-translate/core';
|
||||
import { of, Subject } from 'rxjs';
|
||||
import {
|
||||
EpgQueueService,
|
||||
FavoritesService,
|
||||
XtreamStore,
|
||||
} from '@iptvnator/portal/xtream/data-access';
|
||||
import { PortalChannelsListComponent } from './portal-channels-list.component';
|
||||
|
||||
describe('PortalChannelsListComponent', () => {
|
||||
let fixture: ComponentFixture<PortalChannelsListComponent>;
|
||||
const selectedChannels = signal<unknown[]>([]);
|
||||
const selectedItem = signal<unknown>(null);
|
||||
const epgItems = signal<unknown[]>([]);
|
||||
const selectedTypeContentLoading = signal(true);
|
||||
const selectedContentType = signal('live');
|
||||
const currentPlaylist = signal<unknown>(null);
|
||||
const selectedCategoryId = signal<number | null>(1);
|
||||
const storeSignals = {
|
||||
selectItemsFromSelectedCategory: selectedChannels,
|
||||
selectedItem,
|
||||
epgItems,
|
||||
selectedTypeContentLoading,
|
||||
selectedContentType,
|
||||
currentPlaylist,
|
||||
selectedCategoryId,
|
||||
setSelectedCategory: jest.fn(),
|
||||
};
|
||||
const epgResults$ = new Subject<{ streamId: number; items: unknown[] }>();
|
||||
|
||||
beforeEach(async () => {
|
||||
storeSignals.setSelectedCategory.mockClear();
|
||||
selectedChannels.set([]);
|
||||
selectedItem.set(null);
|
||||
epgItems.set([]);
|
||||
selectedTypeContentLoading.set(true);
|
||||
selectedContentType.set('live');
|
||||
currentPlaylist.set(null);
|
||||
selectedCategoryId.set(1);
|
||||
|
||||
await TestBed.configureTestingModule({
|
||||
imports: [PortalChannelsListComponent, NoopAnimationsModule],
|
||||
providers: [
|
||||
{
|
||||
provide: TranslateService,
|
||||
useValue: {
|
||||
instant: (key: string) =>
|
||||
key === 'CHANNELS.LOADING'
|
||||
? 'Loading channels...'
|
||||
: key,
|
||||
get: (key: string) =>
|
||||
of(
|
||||
key === 'CHANNELS.LOADING'
|
||||
? 'Loading channels...'
|
||||
: key
|
||||
),
|
||||
stream: (key: string) =>
|
||||
of(
|
||||
key === 'CHANNELS.LOADING'
|
||||
? 'Loading channels...'
|
||||
: key
|
||||
),
|
||||
onLangChange: of(null),
|
||||
onTranslationChange: of(null),
|
||||
onDefaultLangChange: of(null),
|
||||
currentLang: 'en',
|
||||
defaultLang: 'en',
|
||||
},
|
||||
},
|
||||
{
|
||||
provide: XtreamStore,
|
||||
useValue: storeSignals,
|
||||
},
|
||||
{
|
||||
provide: FavoritesService,
|
||||
useValue: {
|
||||
getFavorites: jest.fn().mockReturnValue(of([])),
|
||||
},
|
||||
},
|
||||
{
|
||||
provide: EpgQueueService,
|
||||
useValue: {
|
||||
epgResult$: epgResults$,
|
||||
getCached: jest.fn().mockReturnValue(null),
|
||||
enqueue: jest.fn(),
|
||||
},
|
||||
},
|
||||
{
|
||||
provide: ActivatedRoute,
|
||||
useValue: {
|
||||
snapshot: {
|
||||
params: {},
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
}).compileComponents();
|
||||
|
||||
fixture = TestBed.createComponent(PortalChannelsListComponent);
|
||||
});
|
||||
|
||||
it('renders a loading placeholder instead of the empty state while xtream live content is still loading', () => {
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(
|
||||
fixture.nativeElement.querySelector('.channels-loading-state')
|
||||
).not.toBeNull();
|
||||
expect(
|
||||
fixture.nativeElement.querySelector('.empty-search-state')
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it('renders the empty state once loading has finished and the selected category has no channels', () => {
|
||||
selectedTypeContentLoading.set(false);
|
||||
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(
|
||||
fixture.nativeElement.querySelector('.channels-loading-state')
|
||||
).toBeNull();
|
||||
expect(
|
||||
fixture.nativeElement.querySelector('.empty-search-state')
|
||||
).not.toBeNull();
|
||||
});
|
||||
});
|
||||
+9
-4
@@ -69,6 +69,9 @@ export class PortalChannelsListComponent implements AfterViewInit, OnDestroy {
|
||||
private readonly favoritesService = inject(FavoritesService);
|
||||
private readonly epgQueueService = inject(EpgQueueService);
|
||||
private readonly route = inject(ActivatedRoute);
|
||||
readonly isSelectedTypeContentLoading =
|
||||
this.xtreamStore.selectedTypeContentLoading;
|
||||
readonly loadingRows = Array.from({ length: 9 }, (_, index) => index);
|
||||
readonly channels = computed(() => {
|
||||
const override = this.channelsOverride();
|
||||
if (Array.isArray(override)) {
|
||||
@@ -261,11 +264,13 @@ export class PortalChannelsListComponent implements AfterViewInit, OnDestroy {
|
||||
|
||||
toggleFavorite(event: Event, item: XtreamChannelListItem) {
|
||||
event.stopPropagation();
|
||||
const playlistId = this.xtreamStore.currentPlaylist()?.id;
|
||||
if (!playlistId) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.xtreamStore
|
||||
.toggleFavorite(
|
||||
item.xtream_id,
|
||||
this.xtreamStore.currentPlaylist().id
|
||||
)
|
||||
.toggleFavorite(item.xtream_id, playlistId)
|
||||
.then((result: boolean) => {
|
||||
if (result) {
|
||||
this.favorites.set(item.xtream_id, true);
|
||||
|
||||
Reference in new issue
Block a user