feat(ui): add empty-state and expose addPlaylist event

Introduce explicit empty-state handling in RecentPlaylists:
- Replace previous direct rendering of an empty mat-list item with
  app-empty-state for two cases: a global "welcome" state when no
  playlists exist at all, and a "no-results" state when filters/search
  return nothing. This clarifies UX and provides a CTA for adding
  playlists in the welcome state.
- Change playlist data binding from playlists$ to playlistsData$ and
  adapt loops and accessors to data.playlists / data.totalCount.
- Keep existing global favorites item and playlist-item rendering.

Wire up addPlaylist event and components:
- Add addPlaylistClicked output to RecentPlaylists component and
  forward the event from the home component so the parent can open
  the add-playlist flow.
- Import EmptyStateComponent and PlaylistType types used by the new
  output.

Minor cleanup and imports:
- Reformat some imports and add tap to RxJS imports.
- Adjust template structure and placeholders for deferred loading.
This commit is contained in:
4gray committed 2025-12-06 21:44:25 +01:00
1 parent b82d47f6b6
commit aa2d5b9508
6 files changed
+68 -82

No files matched your search

@@ -6,4 +6,5 @@
<app-recent-playlists
class="recent-playlists"
[searchQueryInput]="searchQuery()"
(addPlaylistClicked)="onAddPlaylist($event)"
/>
+16 -2
View File
@@ -1,6 +1,8 @@
import { Component, signal } from '@angular/core';
import { Component, inject, signal } from '@angular/core';
import { MatDialog } from '@angular/material/dialog';
import { TranslatePipe } from '@ngx-translate/core';
import { RecentPlaylistsComponent } from 'components';
import { PlaylistType, RecentPlaylistsComponent } from 'components';
import { AddPlaylistDialogComponent } from '../shared/components/add-playlist/add-playlist-dialog.component';
import { HeaderComponent } from '../shared/components/header/header.component';
@Component({
@@ -10,9 +12,21 @@ import { HeaderComponent } from '../shared/components/header/header.component';
imports: [HeaderComponent, RecentPlaylistsComponent, TranslatePipe],
})
export class HomeComponent {
private readonly dialog = inject(MatDialog);
searchQuery = signal<string>('');
onSearchQueryChange(query: string): void {
this.searchQuery.set(query);
}
onAddPlaylist(playlistType: PlaylistType): void {
this.dialog.open<AddPlaylistDialogComponent, { type: PlaylistType }>(
AddPlaylistDialogComponent,
{
width: '600px',
data: { type: playlistType },
}
);
}
}
@@ -28,12 +28,15 @@
<button
mat-icon-button
class="add-playlist-btn"
[matMenuTriggerFor]="addPlaylistMenu"
[matMenuTriggerFor]="addPlaylistMenuComponent().menu()"
[matTooltip]="'HOME.URL_UPLOAD.ADD_PLAYLIST' | translate"
data-test-id="add-playlist"
>
<mat-icon>add</mat-icon>
</button>
<app-add-playlist-menu
(playlistTypeSelected)="opedAddPlaylistDialog($event)"
/>
<!-- Sort button -->
<button
mat-icon-button
@@ -194,53 +197,6 @@
}
</section>
</mat-menu>
<mat-menu #addPlaylistMenu="matMenu">
<button
mat-menu-item
[attr.aria-label]="'HOME.TABS.FILE_UPLOAD' | translate"
(click)="opedAddPlaylistDialog('file')"
>
<mat-icon>attach_file</mat-icon>
<span>{{ 'HOME.TABS.FILE_UPLOAD' | translate }}</span>
</button>
<button
mat-menu-item
[attr.aria-label]="'HOME.TABS.URL_UPLOAD' | translate"
(click)="opedAddPlaylistDialog('url')"
>
<mat-icon>link</mat-icon>
<span>{{ 'HOME.TABS.URL_UPLOAD' | translate }}</span>
</button>
<button
mat-menu-item
[attr.aria-label]="'HOME.TABS.TEXT_IMPORT' | translate"
(click)="opedAddPlaylistDialog('text')"
>
<mat-icon>text_format</mat-icon>
<span>{{ 'HOME.TABS.TEXT_IMPORT' | translate }}</span>
</button>
<mat-divider></mat-divider>
<button
mat-menu-item
[attr.aria-label]="'HOME.TABS.XTREME_IMPORT' | translate"
(click)="opedAddPlaylistDialog('xtream')"
>
<mat-icon>video_library</mat-icon>
<span>{{ 'HOME.TABS.XTREME_IMPORT' | translate }}</span>
</button>
<button
mat-menu-item
[attr.aria-label]="
'HOME.TABS.STALKER_PORTAL_IMPORT' | translate
"
(click)="opedAddPlaylistDialog('stalker')"
>
<mat-icon>dashboard</mat-icon>
<span>{{
'HOME.TABS.STALKER_PORTAL_IMPORT' | translate
}}</span>
</button>
</mat-menu>
</div>
}
</div>
@@ -1,4 +1,4 @@
import { Component, HostBinding, OnInit, effect, inject, input, output } from '@angular/core';
import { Component, HostBinding, OnInit, effect, inject, input, output, viewChild } from '@angular/core';
import { FormsModule, ReactiveFormsModule } from '@angular/forms';
import { MatButtonModule } from '@angular/material/button';
import { MatCheckboxModule } from '@angular/material/checkbox';
@@ -13,18 +13,17 @@ import { TranslateModule } from '@ngx-translate/core';
import { DataService, SortBy, SortOrder, SortService } from 'services';
//import { shell } from 'electron';
import { selectActiveTypeFilters, setSelectedFilters } from 'm3u-state';
import { AddPlaylistMenuComponent, PlaylistType } from 'components';
import { HomeComponent } from '../../../home/home.component';
import { AboutDialogComponent } from '../about-dialog/about-dialog.component';
import {
AddPlaylistDialogComponent,
PlaylistType,
} from '../add-playlist/add-playlist-dialog.component';
import { AddPlaylistDialogComponent } from '../add-playlist/add-playlist-dialog.component';
@Component({
selector: 'app-header',
templateUrl: './header.component.html',
styleUrls: ['./header.component.scss'],
imports: [
AddPlaylistMenuComponent,
FormsModule,
MatButtonModule,
MatCheckboxModule,
@@ -40,6 +39,7 @@ export class HeaderComponent implements OnInit {
@HostBinding('class.home-header') get isHomeHeader() {
return this.isHome;
}
private activatedRoute = inject(ActivatedRoute);
private dialog = inject(MatDialog);
private dataService = inject(DataService);
@@ -47,6 +47,7 @@ export class HeaderComponent implements OnInit {
private store = inject(Store);
private sortService = inject(SortService);
readonly addPlaylistMenuComponent = viewChild.required(AddPlaylistMenuComponent);
readonly isDesktop = !!window.electron;
readonly title = input.required<string>();
readonly subtitle = input.required<string>();
@@ -1,24 +1,28 @@
@if (allPlaylistsLoaded()) {
@defer {
@if (playlists$ | async; as playlists) {
<mat-nav-list class="playlists-list">
@if (playlists.length === 0) {
<mat-list-item>
<mat-icon matListItemIcon>search</mat-icon>
<div matListItemTitle>
{{ 'HOME.PLAYLISTS.NO_PLAYLISTS' | translate }}
</div>
<div matListItemLine class="meta">
{{ 'HOME.PLAYLISTS.ADD_FIRST' | translate }}
</div>
</mat-list-item>
@if (playlistsData$ | async; as data) {
@if (data.playlists.length === 0) {
@if (data.totalCount === 0) {
<!-- Welcome state: no playlists at all -->
<app-empty-state
[type]="'welcome'"
(addPlaylistClicked)="onAddPlaylist($event)"
/>
} @else {
<!-- No results state: search/filter returned nothing -->
<app-empty-state [type]="'no-results'" />
}
} @else {
<mat-nav-list class="playlists-list">
<mat-list-item (click)="navigateToGlobalFavorites()">
<mat-icon class="favorites-icon" matListItemIcon
>star</mat-icon
>
<div matListItemTitle>
{{ 'HOME.PLAYLISTS.GLOBAL_FAVORITES' | translate }}
{{
'HOME.PLAYLISTS.GLOBAL_FAVORITES'
| translate
}}
</div>
<div matListItemLine class="meta">
{{
@@ -27,7 +31,11 @@
}}
</div>
</mat-list-item>
@for (item of playlists; track item._id; let last = $last) {
@for (
item of data.playlists;
track item._id;
let last = $last
) {
<app-playlist-item
[item]="item"
[showActions]="!sidebarMode()"
@@ -37,8 +45,8 @@
(removeClicked)="removeClicked($event)"
/>
}
}
</mat-nav-list>
</mat-nav-list>
}
}
} @placeholder {
<div id="skeleton-container">
@@ -1,12 +1,6 @@
import { CdkDragDrop, moveItemInArray } from '@angular/cdk/drag-drop';
import { AsyncPipe } from '@angular/common';
import {
Component,
effect,
inject,
input,
output,
} from '@angular/core';
import { Component, effect, inject, input, output } from '@angular/core';
import { MatDialog } from '@angular/material/dialog';
import { MatIcon } from '@angular/material/icon';
import { MatInputModule } from '@angular/material/input';
@@ -22,7 +16,7 @@ import {
selectPlaylistsLoadingFlag,
} from 'm3u-state';
import { NgxSkeletonLoaderComponent } from 'ngx-skeleton-loader';
import { BehaviorSubject, combineLatest, map } from 'rxjs';
import { BehaviorSubject, combineLatest, map, tap } from 'rxjs';
import { DatabaseService, DataService, SortService } from 'services';
import {
GLOBAL_FAVORITES_PLAYLIST_ID,
@@ -30,6 +24,8 @@ import {
PlaylistMeta,
} from 'shared-interfaces';
import { DialogService } from '../confirm-dialog/dialog.service';
import { PlaylistType } from '../add-playlist-menu/add-playlist-menu.component';
import { EmptyStateComponent } from './empty-state/empty-state.component';
import { PlaylistInfoComponent } from './playlist-info/playlist-info.component';
import { PlaylistItemComponent } from './playlist-item/playlist-item.component';
@@ -39,6 +35,7 @@ import { PlaylistItemComponent } from './playlist-item/playlist-item.component';
styleUrls: ['./recent-playlists.component.scss'],
imports: [
AsyncPipe,
EmptyStateComponent,
MatIcon,
MatInputModule,
MatListModule,
@@ -61,6 +58,7 @@ export class RecentPlaylistsComponent {
readonly sidebarMode = input(false);
readonly searchQueryInput = input<string>('');
readonly playlistClicked = output<string>();
readonly addPlaylistClicked = output<PlaylistType>();
readonly allPlaylistsLoaded = this.store.selectSignal(
selectPlaylistsLoadingFlag
@@ -77,7 +75,7 @@ export class RecentPlaylistsComponent {
});
}
readonly playlists$ = combineLatest([
readonly playlistsData$ = combineLatest([
this.store.select(selectAllPlaylistsMeta),
this.searchQuery,
this.store.select(selectActiveTypeFilters),
@@ -111,10 +109,15 @@ export class RecentPlaylistsComponent {
);
// Apply sorting using the SortService
return this.sortService.sortPlaylists(
const sortedPlaylists = this.sortService.sortPlaylists(
filteredPlaylists,
sortOptions
);
return {
playlists: sortedPlaylists,
totalCount: playlists.length
};
})
);
@@ -149,6 +152,10 @@ export class RecentPlaylistsComponent {
this.playlistClicked.emit(GLOBAL_FAVORITES_PLAYLIST_ID);
}
onAddPlaylist(playlistType: PlaylistType) {
this.addPlaylistClicked.emit(playlistType);
}
getPlaylist(playlistMeta: PlaylistMeta): void {
if (playlistMeta.serverUrl) {
this.router.navigate(['xtreams', playlistMeta._id]);
@@ -281,5 +288,4 @@ export class RecentPlaylistsComponent {
},
});
}
}