style(empty-state): enhance empty state component with new types and responsive design

- Expanded EmptyStateType to include 'welcome-dashboard', 'welcome-sources', 'no-data'.
- Introduced FeatureCard and SourceCard interfaces for better structure.
- Added FEATURE_CARDS and SOURCE_CARDS constants for feature and source card configurations.
- Updated empty-state.component.ts to handle new types and actions.
- Created responsive styles in empty-state.responsive.scss for better mobile support.
- Added theme styles in empty-state.themes.scss for light mode.
- Developed specific styles for welcome dashboard and sources in empty-state.welcome-dashboard.scss and empty-state.welcome-sources.scss.
- Updated recent-playlists.component.html to utilize new empty state types.
- Modified recent-playlists.component.ts to support new playlist actions.
- Introduced PlaylistFileImportService for handling playlist file imports.
- Updated downloads.component.html and downloads.component.ts to integrate new empty state handling.
- Enhanced unified-collection-page.component.html and unified-collection-page.component.ts to utilize empty state for no results.

Entire-Checkpoint: c6e522b4276c
This commit is contained in:
4gray committed 2026-04-25 22:18:13 +02:00
1 parent 74c8adc12e
commit b1d519183f
17 files changed
+1308 -502

No files matched your search

@@ -1,4 +1,14 @@
import { ChangeDetectionStrategy, Component, computed, inject, input } from '@angular/core';
import {
ChangeDetectionStrategy,
Component,
computed,
inject,
input,
} from '@angular/core';
import { Router } from '@angular/router';
import { Store } from '@ngrx/store';
import { selectAllPlaylistsMeta } from 'm3u-state';
import { EmptyStateComponent } from '@iptvnator/playlist/shared/ui';
import { PlaylistContextFacade } from '@iptvnator/playlist/shared/util';
import {
UnifiedCollectionDetailDirective,
@@ -7,8 +17,26 @@ import {
import { CollectionScope, PortalProvider } from '@iptvnator/portal/shared/util';
import { StalkerCollectionDetailComponent } from '@iptvnator/portal/stalker/feature';
import { XtreamCollectionDetailComponent } from '@iptvnator/portal/xtream/feature';
import { WORKSPACE_SHELL_ACTIONS } from '@iptvnator/workspace/shell/util';
type UnifiedPortalType = 'm3u' | 'xtream' | 'stalker';
type CollectionMode = 'favorites' | 'recent';
const EMPTY_STATE_BY_MODE: Record<
CollectionMode,
{ icon: string; titleKey: string; descriptionKey: string }
> = {
favorites: {
icon: 'favorite_border',
titleKey: 'WORKSPACE.GLOBAL_FAVORITES.NO_PLAYLISTS_TITLE',
descriptionKey: 'WORKSPACE.GLOBAL_FAVORITES.NO_PLAYLISTS_BODY',
},
recent: {
icon: 'history_toggle_off',
titleKey: 'WORKSPACE.GLOBAL_RECENT.NO_PLAYLISTS_TITLE',
descriptionKey: 'WORKSPACE.GLOBAL_RECENT.NO_PLAYLISTS_BODY',
},
};
function providerToPortalType(provider: PortalProvider): UnifiedPortalType {
if (provider === 'playlists') return 'm3u';
@@ -19,38 +47,60 @@ function providerToPortalType(provider: PortalProvider): UnifiedPortalType {
@Component({
selector: 'app-global-collection-route',
imports: [
EmptyStateComponent,
StalkerCollectionDetailComponent,
UnifiedCollectionDetailDirective,
UnifiedCollectionPageComponent,
XtreamCollectionDetailComponent,
],
template: `
<app-unified-collection-page
[mode]="mode()"
[playlistId]="activePlaylistId() ?? undefined"
[portalType]="activePortalType() ?? undefined"
[defaultScope]="effectiveDefaultScope()"
>
<ng-template unifiedCollectionDetail let-item let-close="close">
@if (item.sourceType === 'xtream') {
<app-xtream-collection-detail [item]="item" />
} @else if (item.sourceType === 'stalker') {
<app-stalker-collection-detail
[item]="item"
(closeRequested)="close()"
/>
}
</ng-template>
</app-unified-collection-page>
@if (hasNoPlaylists()) {
<app-empty-state
type="no-data"
[icon]="emptyState().icon"
[titleKey]="emptyState().titleKey"
[descriptionKey]="emptyState().descriptionKey"
primaryActionLabelKey="HOME.PLAYLISTS.ADD_YOUR_FIRST"
secondaryActionLabelKey="WORKSPACE.SHELL.GO_TO_DASHBOARD"
(primaryActionClicked)="addPlaylist()"
(secondaryActionClicked)="goToDashboard()"
/>
} @else {
<app-unified-collection-page
[mode]="mode()"
[playlistId]="activePlaylistId() ?? undefined"
[portalType]="activePortalType() ?? undefined"
[defaultScope]="effectiveDefaultScope()"
>
<ng-template unifiedCollectionDetail let-item let-close="close">
@if (item.sourceType === 'xtream') {
<app-xtream-collection-detail [item]="item" />
} @else if (item.sourceType === 'stalker') {
<app-stalker-collection-detail
[item]="item"
(closeRequested)="close()"
/>
}
</ng-template>
</app-unified-collection-page>
}
`,
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class GlobalCollectionRouteComponent {
private readonly playlistContext = inject(PlaylistContextFacade);
private readonly store = inject(Store);
private readonly shellActions = inject(WORKSPACE_SHELL_ACTIONS);
private readonly router = inject(Router);
readonly mode = input<'favorites' | 'recent'>('favorites');
readonly mode = input<CollectionMode>('favorites');
readonly defaultScope = input<CollectionScope | undefined>(undefined);
private readonly playlists = this.store.selectSignal(selectAllPlaylistsMeta);
readonly hasNoPlaylists = computed(() => this.playlists().length === 0);
readonly emptyState = computed(() => EMPTY_STATE_BY_MODE[this.mode()]);
readonly activePlaylistId = computed(
() => this.playlistContext.activePlaylist()?._id ?? null
);
@@ -61,4 +111,12 @@ export class GlobalCollectionRouteComponent {
readonly effectiveDefaultScope = computed<CollectionScope | undefined>(() =>
this.activePlaylistId() ? 'playlist' : (this.defaultScope() ?? 'all')
);
addPlaylist(): void {
this.shellActions.openAddPlaylistDialog();
}
goToDashboard(): void {
void this.router.navigate(['/workspace', 'dashboard']);
}
}
@@ -1,43 +1,28 @@
@if (type() === 'welcome') {
<div class="empty-state welcome-state">
<div class="illustration-container">
<!-- Monitor with WiFi signal -->
<svg class="illustration" viewBox="0 0 512 512" xmlns="http://www.w3.org/2000/svg">
<!-- Background rounded rectangle -->
<rect x="40" y="40" width="432" height="432" rx="80" class="icon-background"/>
@switch (type()) {
@case ('welcome-dashboard') {
<div class="empty-state welcome-dashboard">
<div class="welcome-dashboard__hero">
<div class="welcome-dashboard__illustration-container">
<svg class="welcome-dashboard__illustration" viewBox="0 0 512 512" xmlns="http://www.w3.org/2000/svg">
<rect x="40" y="40" width="432" height="432" rx="80" class="icon-background"/>
<rect x="134" y="387" width="244" height="18" rx="4" class="monitor-base"/>
<rect x="238" y="347" width="36" height="40" rx="4" class="monitor-stand"/>
<rect x="88" y="102" width="336" height="245" rx="12" stroke="currentColor" stroke-width="12" fill="none" class="screen-border"/>
<circle cx="256" cy="280" r="16" class="wifi-dot"/>
<path d="M 220 250 Q 256 220, 292 250" stroke="currentColor" stroke-width="14" fill="none" stroke-linecap="round" class="wifi-arc arc-1"/>
<path d="M 200 230 Q 256 185, 312 230" stroke="currentColor" stroke-width="14" fill="none" stroke-linecap="round" class="wifi-arc arc-2"/>
<path d="M 180 210 Q 256 150, 332 210" stroke="currentColor" stroke-width="14" fill="none" stroke-linecap="round" class="wifi-arc arc-3"/>
</svg>
</div>
<!-- Monitor base -->
<rect x="134" y="387" width="244" height="18" rx="4" class="monitor-base"/>
<h2 class="welcome-dashboard__title">{{ 'HOME.PLAYLISTS.WELCOME_TITLE' | translate }}</h2>
<p class="welcome-dashboard__tagline">{{ 'HOME.PLAYLISTS.DASHBOARD_WELCOME_TAGLINE' | translate }}</p>
<p class="welcome-dashboard__description">{{ 'HOME.PLAYLISTS.DASHBOARD_WELCOME_DESCRIPTION' | translate }}</p>
<!-- Monitor stand -->
<rect x="238" y="347" width="36" height="40" rx="4" class="monitor-stand"/>
<!-- Monitor screen border -->
<rect x="88" y="102" width="336" height="245" rx="12" stroke="currentColor" stroke-width="12" fill="none" class="screen-border"/>
<!-- WiFi symbol -->
<!-- Bottom dot -->
<circle cx="256" cy="280" r="16" class="wifi-dot"/>
<!-- First arc (smallest) -->
<path d="M 220 250 Q 256 220, 292 250" stroke="currentColor" stroke-width="14" fill="none" stroke-linecap="round" class="wifi-arc arc-1"/>
<!-- Second arc (medium) -->
<path d="M 200 230 Q 256 185, 312 230" stroke="currentColor" stroke-width="14" fill="none" stroke-linecap="round" class="wifi-arc arc-2"/>
<!-- Third arc (largest) -->
<path d="M 180 210 Q 256 150, 332 210" stroke="currentColor" stroke-width="14" fill="none" stroke-linecap="round" class="wifi-arc arc-3"/>
</svg>
</div>
<div class="content">
<h2 class="title">{{ 'HOME.PLAYLISTS.WELCOME_TITLE' | translate }}</h2>
<p class="description">{{ 'HOME.PLAYLISTS.WELCOME_DESCRIPTION' | translate }}</p>
<div class="actions">
<button
mat-raised-button
class="primary-button"
type="button"
class="welcome-dashboard__cta"
(click)="onAddPlaylist()"
>
<mat-icon>add_circle</mat-icon>
@@ -45,40 +30,115 @@
</button>
</div>
<div class="features">
<div class="feature-item">
<mat-icon class="feature-icon">folder_open</mat-icon>
<span>{{ 'HOME.PLAYLISTS.FEATURE_M3U' | translate }}</span>
</div>
<div class="feature-item">
<mat-icon class="feature-icon">cloud</mat-icon>
<span>{{ 'HOME.PLAYLISTS.FEATURE_XTREAM' | translate }}</span>
</div>
<div class="feature-item">
<mat-icon class="feature-icon">cast</mat-icon>
<span>{{ 'HOME.PLAYLISTS.FEATURE_STALKER' | translate }}</span>
<ul class="welcome-dashboard__cards" role="list">
@for (card of featureCards; track card.titleKey) {
@if (isFeatureCardVisible(card)) {
<li class="feature-card">
<div class="feature-card__icon-wrap">
<mat-icon class="feature-card__icon">{{ card.icon }}</mat-icon>
</div>
<h3 class="feature-card__title">{{ card.titleKey | translate }}</h3>
<p class="feature-card__desc">{{ card.descKey | translate }}</p>
</li>
}
}
</ul>
</div>
}
@case ('welcome-sources') {
<div class="empty-state welcome-sources">
<header class="welcome-sources__header">
<div class="welcome-sources__icon-wrap">
<mat-icon class="welcome-sources__icon">playlist_add</mat-icon>
</div>
<h2 class="welcome-sources__title">{{ 'HOME.PLAYLISTS.SOURCES_WELCOME_TITLE' | translate }}</h2>
<p class="welcome-sources__body">{{ 'HOME.PLAYLISTS.SOURCES_WELCOME_BODY' | translate }}</p>
</header>
<div class="welcome-sources__grid">
@for (card of sourceCards; track card.type) {
<article class="source-card">
<div class="source-card__head">
<mat-icon class="source-card__icon">{{ card.icon }}</mat-icon>
<h3 class="source-card__name">{{ card.nameKey | translate }}</h3>
</div>
<p class="source-card__needs">{{ card.needsKey | translate }}</p>
<ul class="source-card__chips" role="list">
@for (chip of card.contentKeys; track chip) {
<li class="source-card__chip">{{ chip | translate }}</li>
}
</ul>
<button
mat-raised-button
type="button"
class="source-card__cta"
(click)="onAddPlaylist(card.type)"
>
<mat-icon>add</mat-icon>
{{ card.addLabelKey | translate }}
</button>
</article>
}
</div>
</div>
</div>
} @else {
<div class="empty-state no-results-state">
<div class="illustration-container small">
<!-- Search/Empty themed illustration -->
<svg class="illustration" viewBox="0 0 160 160" fill="none" xmlns="http://www.w3.org/2000/svg">
<!-- Magnifying glass -->
<circle cx="65" cy="65" r="35" class="search-circle" stroke-width="4"/>
<line x1="90" y1="90" x2="115" y2="115" class="search-handle" stroke-width="4" stroke-linecap="round"/>
}
@case ('no-results') {
<div class="empty-state no-results-state">
<div class="illustration-container small">
<svg class="illustration" viewBox="0 0 160 160" fill="none" xmlns="http://www.w3.org/2000/svg">
<circle cx="65" cy="65" r="35" class="search-circle" stroke-width="4"/>
<line x1="90" y1="90" x2="115" y2="115" class="search-handle" stroke-width="4" stroke-linecap="round"/>
<rect x="45" y="50" width="40" height="30" rx="3" class="empty-box" stroke-width="2" opacity="0.4"/>
<line x1="55" y1="65" x2="75" y2="65" class="empty-line" stroke-width="2" opacity="0.3"/>
</svg>
</div>
<!-- Empty box -->
<rect x="45" y="50" width="40" height="30" rx="3" class="empty-box" stroke-width="2" opacity="0.4"/>
<line x1="55" y1="65" x2="75" y2="65" class="empty-line" stroke-width="2" opacity="0.3"/>
</svg>
<div class="content">
<h3 class="title small">{{ 'HOME.PLAYLISTS.NO_RESULTS_TITLE' | translate }}</h3>
<p class="description">{{ 'HOME.PLAYLISTS.NO_RESULTS_DESCRIPTION' | translate }}</p>
</div>
</div>
}
@case ('no-data') {
<div class="empty-state no-data-state">
<div class="empty-icon-container">
<mat-icon class="empty-icon">{{ icon() }}</mat-icon>
</div>
<div class="content">
<h3 class="title small">{{ 'HOME.PLAYLISTS.NO_RESULTS_TITLE' | translate }}</h3>
<p class="description">{{ 'HOME.PLAYLISTS.NO_RESULTS_DESCRIPTION' | translate }}</p>
<div class="content">
@if (titleKey()) {
<h3 class="title small">{{ titleKey() | translate }}</h3>
}
@if (descriptionKey()) {
<p class="description">{{ descriptionKey() | translate }}</p>
}
@if (primaryActionLabelKey() || secondaryActionLabelKey()) {
<div class="actions">
@if (primaryActionLabelKey(); as primaryLabel) {
<button
mat-raised-button
type="button"
class="primary-button"
(click)="onPrimaryAction()"
>
<mat-icon>add_circle</mat-icon>
{{ primaryLabel | translate }}
</button>
}
@if (secondaryActionLabelKey(); as secondaryLabel) {
<button
mat-button
type="button"
class="secondary-button"
(click)="onSecondaryAction()"
>
{{ secondaryLabel | translate }}
</button>
}
</div>
}
</div>
</div>
</div>
}
}
@@ -1,6 +1,7 @@
:host {
display: block;
width: 100%;
height: 100%;
}
.empty-state {
@@ -8,7 +9,6 @@
flex-direction: column;
align-items: center;
justify-content: center;
padding: 60px 24px;
text-align: center;
animation: fadeIn 0.6s ease-out;
@@ -40,169 +40,6 @@
}
}
.welcome-state {
position: relative;
overflow: hidden;
&::before {
content: '';
position: absolute;
top: -50%;
left: -50%;
width: 200%;
height: 200%;
background-image:
repeating-linear-gradient(
0deg,
transparent,
transparent 39px,
rgba(59, 130, 246, 0.03) 39px,
rgba(59, 130, 246, 0.03) 41px
),
repeating-linear-gradient(
90deg,
transparent,
transparent 39px,
rgba(59, 130, 246, 0.03) 39px,
rgba(59, 130, 246, 0.03) 41px
),
repeating-linear-gradient(
45deg,
transparent,
transparent 79px,
rgba(59, 130, 246, 0.015) 79px,
rgba(59, 130, 246, 0.015) 81px
);
mask-image: linear-gradient(to bottom, rgba(0, 0, 0, 1) 0%, rgba(0, 0, 0, 0.7) 40%, rgba(0, 0, 0, 0.3) 70%, transparent 100%);
-webkit-mask-image: linear-gradient(to bottom, rgba(0, 0, 0, 1) 0%, rgba(0, 0, 0, 0.7) 40%, rgba(0, 0, 0, 0.3) 70%, transparent 100%);
animation: gridMove 60s linear infinite;
pointer-events: none;
z-index: 0;
}
> * {
position: relative;
z-index: 1;
}
.illustration-container {
margin-bottom: 32px;
position: relative;
}
.illustration {
width: 200px;
height: 200px;
filter: drop-shadow(0 10px 30px rgba(59, 130, 246, 0.2));
color: #60a5fa;
.icon-background {
fill: #3b82f6;
opacity: 0.15;
}
.monitor-base,
.monitor-stand {
fill: #3b82f6;
}
.screen-border {
stroke: #3b82f6;
}
.wifi-dot {
fill: #60a5fa;
}
.wifi-arc {
stroke: #60a5fa;
opacity: 0.7;
&.arc-1 {
opacity: 0.9;
}
&.arc-2 {
opacity: 0.7;
}
&.arc-3 {
opacity: 0.5;
}
}
}
.title {
font-size: 2rem;
letter-spacing: -0.02em;
}
.actions {
margin-bottom: 40px;
.primary-button {
background: linear-gradient(135deg, #3b82f6 0%, #2563eb 100%);
color: white;
font-family: Roboto, 'Helvetica Neue', sans-serif;
font-weight: 500;
font-size: 1rem;
padding: 12px 32px;
border-radius: 12px;
box-shadow: 0 4px 16px rgba(59, 130, 246, 0.3);
transition: all 0.3s ease;
text-transform: none;
letter-spacing: 0.01em;
mat-icon {
margin-right: 8px;
}
&:hover {
transform: translateY(-2px);
box-shadow: 0 6px 24px rgba(59, 130, 246, 0.4);
}
&:active {
transform: translateY(0);
}
}
}
.features {
display: flex;
gap: 24px;
justify-content: center;
flex-wrap: wrap;
opacity: 0.6;
.feature-item {
display: flex;
align-items: center;
gap: 8px;
font-family: Roboto, 'Helvetica Neue', sans-serif;
font-size: 0.875rem;
padding: 8px 16px;
background: rgba(59, 130, 246, 0.08);
border-radius: 8px;
border: 1px solid rgba(59, 130, 246, 0.15);
transition: all 0.3s ease;
.feature-icon {
font-size: 18px;
width: 18px;
height: 18px;
color: #60a5fa;
}
&:hover {
background: rgba(59, 130, 246, 0.12);
border-color: rgba(59, 130, 246, 0.25);
opacity: 1;
}
}
}
}
.no-results-state {
padding: 40px 24px;
@@ -219,20 +56,13 @@
width: 120px;
height: 120px;
.search-circle {
stroke: currentColor;
opacity: 0.4;
}
.search-circle,
.search-handle {
stroke: currentColor;
opacity: 0.4;
}
.empty-box {
stroke: currentColor;
}
.empty-box,
.empty-line {
stroke: currentColor;
}
@@ -244,13 +74,74 @@
}
}
.no-data-state {
padding: 56px 24px;
.empty-icon-container {
margin-bottom: 20px;
animation: gentleFloat 0.6s ease-out;
}
.empty-icon {
font-size: 72px;
width: 72px;
height: 72px;
color: #60a5fa;
opacity: 0.85;
}
.content {
max-width: 460px;
}
.title {
font-size: 1.5rem;
margin-bottom: 8px;
}
.description {
margin-bottom: 24px;
}
.actions {
display: flex;
gap: 12px;
justify-content: center;
flex-wrap: wrap;
.primary-button {
background: linear-gradient(135deg, #3b82f6 0%, #2563eb 100%);
color: white;
font-weight: 500;
padding: 10px 24px;
border-radius: 10px;
text-transform: none;
letter-spacing: 0.01em;
box-shadow: 0 4px 14px rgba(59, 130, 246, 0.25);
transition: all 0.25s ease;
mat-icon {
margin-right: 6px;
}
&:hover {
transform: translateY(-1px);
box-shadow: 0 6px 18px rgba(59, 130, 246, 0.35);
}
}
.secondary-button {
color: #60a5fa;
font-weight: 500;
padding: 10px 18px;
text-transform: none;
}
}
}
@keyframes fadeIn {
from {
opacity: 0;
}
to {
opacity: 1;
}
from { opacity: 0; }
to { opacity: 1; }
}
@keyframes slideUp {
@@ -264,24 +155,13 @@
}
}
@keyframes floatIn {
from {
opacity: 0;
transform: translateY(-10px) scale(0.95);
}
to {
opacity: 1;
transform: translateY(0) scale(1);
}
}
@keyframes gentleFloat {
from {
opacity: 0;
transform: scale(0.9);
}
to {
opacity: 0.5;
opacity: 0.85;
transform: scale(1);
}
}
@@ -294,193 +174,3 @@
transform: translate(40px, 40px) rotate(0.5deg);
}
}
@media (max-width: 480px) {
.welcome-state {
padding: 40px 20px;
.illustration {
width: 160px;
height: 160px;
}
.title {
font-size: 1.5rem;
}
.features {
flex-direction: column;
gap: 12px;
align-items: stretch;
}
}
.no-results-state {
padding: 30px 20px;
.illustration {
width: 100px;
height: 100px;
}
}
}
@media (max-height: 700px) {
.welcome-state {
padding: 30px 24px;
.illustration-container { margin-bottom: 20px; }
.illustration { width: 150px; height: 150px; }
.title { font-size: 1.5rem; margin-bottom: 8px; }
.description { font-size: 0.875rem; margin-bottom: 20px; }
.actions { margin-bottom: 24px; }
.features { gap: 16px; }
.feature-item { padding: 6px 12px; font-size: 0.8125rem; }
}
.no-results-state {
padding: 24px 20px;
.illustration-container { margin-bottom: 16px; }
}
}
@media (max-height: 550px) {
.welcome-state {
padding: 20px 24px;
.illustration-container { margin-bottom: 16px; }
.illustration { width: 120px; height: 120px; }
.title { font-size: 1.25rem; margin-bottom: 6px; }
.description { font-size: 0.8125rem; margin-bottom: 16px; }
.actions { margin-bottom: 16px; }
.primary-button { padding: 10px 24px; font-size: 0.9rem; }
.features { gap: 12px; }
.feature-item { padding: 4px 10px; font-size: 0.75rem; }
.feature-icon { font-size: 16px; width: 16px; height: 16px; }
}
.no-results-state {
padding: 16px 20px;
.illustration-container { margin-bottom: 12px; }
.illustration { width: 80px; height: 80px; }
.title { font-size: 1.125rem; }
.description { font-size: 0.8125rem; }
}
}
@media (max-width: 480px) and (max-height: 700px) {
.welcome-state {
padding: 20px 16px;
.illustration {
width: 120px;
height: 120px;
}
.title {
font-size: 1.25rem;
}
}
}
@media (prefers-color-scheme: light) {
.empty-state {
.title {
background: linear-gradient(135deg, #2563eb 0%, #1d4ed8 50%, #1e40af 100%);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
}
.description {
opacity: 0.8;
color: #64748b;
}
}
.welcome-state {
// Light theme grid
&::before {
background-image:
repeating-linear-gradient(
0deg,
transparent,
transparent 39px,
rgba(37, 99, 235, 0.04) 39px,
rgba(37, 99, 235, 0.04) 41px
),
repeating-linear-gradient(
90deg,
transparent,
transparent 39px,
rgba(37, 99, 235, 0.04) 39px,
rgba(37, 99, 235, 0.04) 41px
),
repeating-linear-gradient(
45deg,
transparent,
transparent 79px,
rgba(37, 99, 235, 0.02) 79px,
rgba(37, 99, 235, 0.02) 81px
);
mask-image: linear-gradient(to bottom, rgba(0, 0, 0, 1) 0%, rgba(0, 0, 0, 0.7) 40%, rgba(0, 0, 0, 0.3) 70%, transparent 100%);
-webkit-mask-image: linear-gradient(to bottom, rgba(0, 0, 0, 1) 0%, rgba(0, 0, 0, 0.7) 40%, rgba(0, 0, 0, 0.3) 70%, transparent 100%);
}
.illustration {
filter: drop-shadow(0 10px 30px rgba(37, 99, 235, 0.15));
color: #3b82f6;
.icon-background {
fill: #2563eb;
opacity: 0.12;
}
.monitor-base,
.monitor-stand {
fill: #2563eb;
}
.screen-border {
stroke: #2563eb;
}
.wifi-dot {
fill: #3b82f6;
}
.wifi-arc {
stroke: #3b82f6;
}
}
.features {
.feature-item {
background: rgba(37, 99, 235, 0.06);
border-color: rgba(37, 99, 235, 0.12);
.feature-icon {
color: #2563eb;
}
&:hover {
background: rgba(37, 99, 235, 0.1);
border-color: rgba(37, 99, 235, 0.2);
}
}
}
}
.no-results-state {
.illustration {
opacity: 0.3;
.search-circle,
.search-handle,
.empty-box,
.empty-line {
stroke: #64748b;
}
}
}
}
@@ -2,20 +2,137 @@ import { Component, input, output } from '@angular/core';
import { MatButtonModule } from '@angular/material/button';
import { MatIcon } from '@angular/material/icon';
import { TranslatePipe } from '@ngx-translate/core';
import type { WorkspacePlaylistType } from '@iptvnator/workspace/shell/util';
export type EmptyStateType = 'welcome' | 'no-results';
export type EmptyStateType =
| 'welcome-dashboard'
| 'welcome-sources'
| 'no-results'
| 'no-data';
interface FeatureCard {
icon: string;
titleKey: string;
descKey: string;
electronOnly?: boolean;
}
interface SourceCard {
type: WorkspacePlaylistType;
icon: string;
nameKey: string;
needsKey: string;
addLabelKey: string;
contentKeys: readonly string[];
}
const FEATURE_CARDS: readonly FeatureCard[] = [
{
icon: 'live_tv',
titleKey: 'HOME.PLAYLISTS.FEATURE_LIVE_TITLE',
descKey: 'HOME.PLAYLISTS.FEATURE_LIVE_DESC',
},
{
icon: 'movie',
titleKey: 'HOME.PLAYLISTS.FEATURE_VOD_TITLE',
descKey: 'HOME.PLAYLISTS.FEATURE_VOD_DESC',
},
{
icon: 'event_note',
titleKey: 'HOME.PLAYLISTS.FEATURE_EPG_TITLE',
descKey: 'HOME.PLAYLISTS.FEATURE_EPG_DESC',
},
{
icon: 'download_for_offline',
titleKey: 'HOME.PLAYLISTS.FEATURE_OFFLINE_TITLE',
descKey: 'HOME.PLAYLISTS.FEATURE_OFFLINE_DESC',
electronOnly: true,
},
];
const SOURCE_CARDS: readonly SourceCard[] = [
{
type: 'url',
icon: 'folder_open',
nameKey: 'HOME.PLAYLISTS.FEATURE_M3U',
needsKey: 'HOME.PLAYLISTS.SOURCE_M3U_NEEDS',
addLabelKey: 'HOME.PLAYLISTS.SOURCE_M3U_ADD',
contentKeys: [
'HOME.PLAYLISTS.CONTENT_LIVE',
'HOME.PLAYLISTS.CONTENT_EPG',
],
},
{
type: 'xtream',
icon: 'cloud',
nameKey: 'HOME.PLAYLISTS.FEATURE_XTREAM',
needsKey: 'HOME.PLAYLISTS.SOURCE_XTREAM_NEEDS',
addLabelKey: 'HOME.PLAYLISTS.SOURCE_XTREAM_ADD',
contentKeys: [
'HOME.PLAYLISTS.CONTENT_LIVE',
'HOME.PLAYLISTS.CONTENT_VOD',
'HOME.PLAYLISTS.CONTENT_SERIES',
'HOME.PLAYLISTS.CONTENT_EPG',
],
},
{
type: 'stalker',
icon: 'cast',
nameKey: 'HOME.PLAYLISTS.FEATURE_STALKER',
needsKey: 'HOME.PLAYLISTS.SOURCE_STALKER_NEEDS',
addLabelKey: 'HOME.PLAYLISTS.SOURCE_STALKER_ADD',
contentKeys: [
'HOME.PLAYLISTS.CONTENT_LIVE',
'HOME.PLAYLISTS.CONTENT_VOD',
'HOME.PLAYLISTS.CONTENT_SERIES',
'HOME.PLAYLISTS.CONTENT_EPG',
],
},
];
@Component({
selector: 'app-empty-state',
templateUrl: './empty-state.component.html',
styleUrls: ['./empty-state.component.scss'],
styleUrls: [
'./empty-state.component.scss',
'./empty-state.welcome-dashboard.scss',
'./empty-state.welcome-sources.scss',
'./empty-state.responsive.scss',
'./empty-state.themes.scss',
],
imports: [MatButtonModule, MatIcon, TranslatePipe],
})
export class EmptyStateComponent {
readonly type = input.required<EmptyStateType>();
readonly addPlaylistClicked = output<void>();
onAddPlaylist(): void {
this.addPlaylistClicked.emit();
readonly icon = input<string>('inbox');
readonly titleKey = input<string>('');
readonly descriptionKey = input<string>('');
readonly primaryActionLabelKey = input<string | null>(null);
readonly secondaryActionLabelKey = input<string | null>(null);
readonly showElectronOnlyValueProps = input<boolean>(false);
readonly addPlaylistClicked = output<WorkspacePlaylistType | undefined>();
readonly primaryActionClicked = output<void>();
readonly secondaryActionClicked = output<void>();
readonly featureCards = FEATURE_CARDS;
readonly sourceCards = SOURCE_CARDS;
onAddPlaylist(type?: WorkspacePlaylistType): void {
this.addPlaylistClicked.emit(type);
}
onPrimaryAction(): void {
this.primaryActionClicked.emit();
}
onSecondaryAction(): void {
this.secondaryActionClicked.emit();
}
isFeatureCardVisible(card: FeatureCard): boolean {
return !card.electronOnly || this.showElectronOnlyValueProps();
}
}
@@ -0,0 +1,41 @@
@media (max-width: 480px) {
.no-results-state {
padding: 30px 20px;
.illustration {
width: 100px;
height: 100px;
}
}
.no-data-state {
padding: 40px 20px;
.actions {
flex-direction: column;
gap: 8px;
}
}
}
@media (max-height: 700px) {
.no-results-state {
padding: 24px 20px;
.illustration-container { margin-bottom: 16px; }
}
.no-data-state {
padding: 32px 24px;
.empty-icon { font-size: 56px; width: 56px; height: 56px; }
}
}
@media (max-height: 550px) {
.no-results-state {
padding: 16px 20px;
.illustration-container { margin-bottom: 12px; }
.illustration { width: 80px; height: 80px; }
.title { font-size: 1.125rem; }
.description { font-size: 0.8125rem; }
}
}
@@ -0,0 +1,38 @@
@media (prefers-color-scheme: light) {
.empty-state {
.title {
background: linear-gradient(135deg, #2563eb 0%, #1d4ed8 50%, #1e40af 100%);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
}
.description {
opacity: 0.8;
color: #64748b;
}
}
.no-results-state {
.illustration {
opacity: 0.3;
.search-circle,
.search-handle,
.empty-box,
.empty-line {
stroke: #64748b;
}
}
}
.no-data-state {
.empty-icon {
color: #2563eb;
}
.secondary-button {
color: #2563eb;
}
}
}
@@ -0,0 +1,422 @@
.welcome-dashboard {
position: relative;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
height: 100%;
width: 100%;
box-sizing: border-box;
padding: clamp(16px, 3vh, 36px) clamp(12px, 3vw, 32px);
overflow: hidden;
text-align: center;
gap: clamp(16px, 3vh, 32px);
animation: fadeIn 0.5s ease-out;
min-width: 0;
min-height: 0;
&::before {
content: '';
position: absolute;
inset: -50%;
background-image:
repeating-linear-gradient(
0deg,
transparent,
transparent 39px,
rgba(59, 130, 246, 0.03) 39px,
rgba(59, 130, 246, 0.03) 41px
),
repeating-linear-gradient(
90deg,
transparent,
transparent 39px,
rgba(59, 130, 246, 0.03) 39px,
rgba(59, 130, 246, 0.03) 41px
),
repeating-linear-gradient(
45deg,
transparent,
transparent 79px,
rgba(59, 130, 246, 0.015) 79px,
rgba(59, 130, 246, 0.015) 81px
);
mask-image: linear-gradient(
to bottom,
rgba(0, 0, 0, 1) 0%,
rgba(0, 0, 0, 0.7) 40%,
rgba(0, 0, 0, 0.3) 70%,
transparent 100%
);
-webkit-mask-image: linear-gradient(
to bottom,
rgba(0, 0, 0, 1) 0%,
rgba(0, 0, 0, 0.7) 40%,
rgba(0, 0, 0, 0.3) 70%,
transparent 100%
);
animation: gridMove 60s linear infinite;
pointer-events: none;
z-index: 0;
}
> * {
position: relative;
z-index: 1;
}
.welcome-dashboard__hero {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 10px;
width: 100%;
max-width: 640px;
min-width: 0;
animation: slideUp 0.6s ease-out;
}
.welcome-dashboard__illustration-container {
position: relative;
margin-bottom: 4px;
}
.welcome-dashboard__illustration {
width: clamp(96px, 16vh, 160px);
height: clamp(96px, 16vh, 160px);
filter: drop-shadow(0 10px 30px rgba(59, 130, 246, 0.2));
color: #60a5fa;
display: block;
.icon-background {
fill: #3b82f6;
opacity: 0.15;
}
.monitor-base,
.monitor-stand {
fill: #3b82f6;
}
.screen-border {
stroke: #3b82f6;
}
.wifi-dot {
fill: #60a5fa;
}
.wifi-arc {
stroke: #60a5fa;
opacity: 0.7;
&.arc-1 { opacity: 0.9; }
&.arc-2 { opacity: 0.7; }
&.arc-3 { opacity: 0.5; }
}
}
.welcome-dashboard__title {
font-family: Roboto, 'Helvetica Neue', sans-serif;
font-weight: 600;
font-size: clamp(1.4rem, 3.4vh, 2.1rem);
margin: 0;
background: linear-gradient(135deg, #60a5fa 0%, #3b82f6 50%, #2563eb 100%);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
letter-spacing: -0.02em;
}
.welcome-dashboard__tagline {
font-family: Roboto, 'Helvetica Neue', sans-serif;
font-size: clamp(0.95rem, 1.9vh, 1.05rem);
font-weight: 500;
opacity: 0.85;
margin: 0;
line-height: 1.45;
}
.welcome-dashboard__description {
font-family: Roboto, 'Helvetica Neue', sans-serif;
font-size: clamp(0.8125rem, 1.7vh, 0.9375rem);
opacity: 0.65;
margin: 0;
line-height: 1.5;
max-width: 480px;
}
.welcome-dashboard__cta {
margin-top: 8px;
background: linear-gradient(135deg, #3b82f6 0%, #2563eb 100%);
color: white;
font-weight: 500;
font-size: clamp(0.875rem, 1.8vh, 1rem);
padding: 10px 28px;
border-radius: 12px;
box-shadow: 0 4px 16px rgba(59, 130, 246, 0.3);
transition:
transform 0.2s ease,
box-shadow 0.2s ease;
text-transform: none;
letter-spacing: 0.01em;
mat-icon {
margin-right: 8px;
}
&:hover {
transform: translateY(-2px);
box-shadow: 0 6px 24px rgba(59, 130, 246, 0.4);
}
&:active {
transform: translateY(0);
}
}
.welcome-dashboard__cards {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(min(140px, 100%), 1fr));
gap: clamp(10px, 1.6vw, 18px);
width: 100%;
max-width: 820px;
margin: 0 auto;
padding: 8px 4px 4px;
list-style: none;
justify-items: stretch;
align-items: stretch;
animation: slideUp 0.7s ease-out 0.1s backwards;
min-width: 0;
}
.feature-card {
display: flex;
flex-direction: column;
align-items: center;
text-align: center;
gap: 8px;
padding: clamp(12px, 2vh, 18px) clamp(10px, 1.5vw, 14px);
background: linear-gradient(
135deg,
rgba(96, 165, 250, 0.08),
rgba(37, 99, 235, 0.04)
);
border: 1px solid rgba(96, 165, 250, 0.18);
border-radius: 14px;
backdrop-filter: blur(8px);
transform: rotate(-2deg);
transform-origin: center;
transition:
transform 0.25s ease,
box-shadow 0.25s ease,
border-color 0.25s ease,
background 0.25s ease;
cursor: default;
min-width: 0;
&:nth-child(2) { transform: rotate(1.5deg); }
&:nth-child(3) { transform: rotate(-1deg); }
&:nth-child(4) { transform: rotate(2deg); }
&:hover {
transform: rotate(0deg) translateY(-4px) scale(1.04);
box-shadow: 0 10px 24px rgba(59, 130, 246, 0.22);
border-color: rgba(96, 165, 250, 0.4);
background: linear-gradient(
135deg,
rgba(96, 165, 250, 0.16),
rgba(37, 99, 235, 0.1)
);
.feature-card__icon-wrap {
transform: scale(1.08);
box-shadow: 0 6px 18px rgba(59, 130, 246, 0.4);
}
}
}
.feature-card__icon-wrap {
display: flex;
align-items: center;
justify-content: center;
width: 40px;
height: 40px;
border-radius: 11px;
background: linear-gradient(135deg, #3b82f6, #2563eb);
box-shadow: 0 4px 12px rgba(59, 130, 246, 0.3);
transition:
transform 0.25s ease,
box-shadow 0.25s ease;
flex-shrink: 0;
}
.feature-card__icon {
font-size: 22px;
width: 22px;
height: 22px;
color: white;
}
.feature-card__title {
font-family: Roboto, 'Helvetica Neue', sans-serif;
font-size: 0.9rem;
font-weight: 600;
margin: 0;
line-height: 1.25;
word-break: break-word;
}
.feature-card__desc {
font-family: Roboto, 'Helvetica Neue', sans-serif;
font-size: 0.78125rem;
opacity: 0.7;
margin: 0;
line-height: 1.35;
word-break: break-word;
}
}
@media (max-width: 640px) {
.welcome-dashboard {
gap: 16px;
.welcome-dashboard__cards {
grid-template-columns: repeat(2, minmax(0, 1fr));
max-width: 420px;
}
}
}
@media (max-width: 380px) {
.welcome-dashboard .welcome-dashboard__cards {
grid-template-columns: 1fr;
}
}
@media (max-height: 620px) {
.welcome-dashboard {
gap: 12px;
.welcome-dashboard__description {
display: none;
}
.welcome-dashboard__hero {
gap: 6px;
}
}
}
@media (prefers-reduced-motion: reduce) {
.welcome-dashboard {
animation: none;
.welcome-dashboard__hero,
.welcome-dashboard__cards {
animation: none;
}
.feature-card,
.feature-card:nth-child(n) {
transform: none;
&:hover {
transform: translateY(-2px);
}
}
.welcome-dashboard__cta:hover {
transform: none;
}
}
}
@media (prefers-color-scheme: light) {
.welcome-dashboard {
&::before {
background-image:
repeating-linear-gradient(
0deg,
transparent,
transparent 39px,
rgba(37, 99, 235, 0.04) 39px,
rgba(37, 99, 235, 0.04) 41px
),
repeating-linear-gradient(
90deg,
transparent,
transparent 39px,
rgba(37, 99, 235, 0.04) 39px,
rgba(37, 99, 235, 0.04) 41px
),
repeating-linear-gradient(
45deg,
transparent,
transparent 79px,
rgba(37, 99, 235, 0.02) 79px,
rgba(37, 99, 235, 0.02) 81px
);
}
.welcome-dashboard__title {
background: linear-gradient(135deg, #2563eb 0%, #1d4ed8 50%, #1e40af 100%);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
}
.welcome-dashboard__tagline {
color: #1e3a8a;
opacity: 0.85;
}
.welcome-dashboard__description {
color: #64748b;
opacity: 0.85;
}
.welcome-dashboard__illustration {
filter: drop-shadow(0 10px 30px rgba(37, 99, 235, 0.15));
color: #3b82f6;
.icon-background {
fill: #2563eb;
opacity: 0.12;
}
.monitor-base,
.monitor-stand {
fill: #2563eb;
}
.screen-border {
stroke: #2563eb;
}
.wifi-dot {
fill: #3b82f6;
}
.wifi-arc {
stroke: #3b82f6;
}
}
.feature-card {
background: linear-gradient(135deg, rgba(37, 99, 235, 0.06), rgba(29, 78, 216, 0.03));
border-color: rgba(37, 99, 235, 0.15);
&:hover {
background: linear-gradient(135deg, rgba(37, 99, 235, 0.12), rgba(29, 78, 216, 0.06));
border-color: rgba(37, 99, 235, 0.3);
}
}
.feature-card__desc {
color: #475569;
opacity: 0.85;
}
}
}
@@ -0,0 +1,259 @@
.welcome-sources {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
height: 100%;
width: 100%;
box-sizing: border-box;
padding: clamp(16px, 3vh, 36px) clamp(12px, 3vw, 32px);
overflow: hidden;
gap: clamp(16px, 3vh, 28px);
animation: fadeIn 0.5s ease-out;
min-width: 0;
min-height: 0;
.welcome-sources__header {
display: flex;
flex-direction: column;
align-items: center;
text-align: center;
gap: 8px;
max-width: 540px;
width: 100%;
animation: slideUp 0.6s ease-out;
}
.welcome-sources__icon-wrap {
display: flex;
align-items: center;
justify-content: center;
margin-bottom: 12px;
animation: gentleFloat 0.6s ease-out;
}
.welcome-sources__icon {
font-size: 72px;
width: 72px;
height: 72px;
color: #60a5fa;
opacity: 0.85;
}
.welcome-sources__title {
font-family: Roboto, 'Helvetica Neue', sans-serif;
font-weight: 500;
font-size: 1.5rem;
margin: 0 0 8px 0;
background: linear-gradient(135deg, #60a5fa 0%, #3b82f6 50%, #2563eb 100%);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
}
.welcome-sources__body {
font-family: Roboto, 'Helvetica Neue', sans-serif;
font-size: 0.95rem;
line-height: 1.6;
opacity: 0.7;
margin: 0;
}
.welcome-sources__grid {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: clamp(10px, 1.6vw, 18px);
width: 100%;
max-width: 980px;
margin: 0 auto;
animation: slideUp 0.7s ease-out 0.1s backwards;
min-width: 0;
}
.source-card {
display: flex;
flex-direction: column;
gap: 10px;
padding: clamp(14px, 2.2vh, 22px);
background: linear-gradient(
145deg,
rgba(96, 165, 250, 0.08),
rgba(37, 99, 235, 0.04)
);
border: 1px solid rgba(96, 165, 250, 0.2);
border-radius: 16px;
transition:
transform 0.2s ease,
box-shadow 0.2s ease,
border-color 0.2s ease;
min-width: 0;
&:hover {
transform: translateY(-3px);
box-shadow: 0 12px 28px rgba(59, 130, 246, 0.22);
border-color: rgba(96, 165, 250, 0.4);
}
}
.source-card__head {
display: flex;
align-items: center;
gap: 10px;
min-width: 0;
}
.source-card__icon {
font-size: 28px;
width: 28px;
height: 28px;
color: #60a5fa;
flex-shrink: 0;
}
.source-card__name {
font-family: Roboto, 'Helvetica Neue', sans-serif;
font-size: 1.05rem;
font-weight: 600;
margin: 0;
line-height: 1.2;
word-break: break-word;
}
.source-card__needs {
font-family: Roboto, 'Helvetica Neue', sans-serif;
font-size: 0.8125rem;
opacity: 0.75;
margin: 0;
line-height: 1.45;
min-height: 2.5em;
word-break: break-word;
}
.source-card__chips {
display: flex;
flex-wrap: wrap;
gap: 6px;
margin: 0;
padding: 0;
list-style: none;
}
.source-card__chip {
font-family: Roboto, 'Helvetica Neue', sans-serif;
font-size: 0.7rem;
font-weight: 500;
padding: 3px 9px;
border-radius: 999px;
background: rgba(96, 165, 250, 0.14);
color: #93c5fd;
border: 1px solid rgba(96, 165, 250, 0.22);
text-transform: uppercase;
letter-spacing: 0.04em;
}
.source-card__cta {
margin-top: auto;
align-self: stretch;
background: linear-gradient(135deg, #3b82f6 0%, #2563eb 100%);
color: white;
font-weight: 500;
font-size: 0.875rem;
padding: 8px 18px;
border-radius: 10px;
box-shadow: 0 4px 12px rgba(59, 130, 246, 0.25);
transition:
transform 0.2s ease,
box-shadow 0.2s ease;
text-transform: none;
mat-icon {
margin-right: 6px;
font-size: 18px;
width: 18px;
height: 18px;
}
&:hover {
transform: translateY(-1px);
box-shadow: 0 6px 18px rgba(59, 130, 246, 0.35);
}
}
}
@media (max-width: 760px) {
.welcome-sources .welcome-sources__grid {
grid-template-columns: 1fr;
max-width: 480px;
}
}
@media (max-height: 620px) {
.welcome-sources {
gap: 14px;
.source-card__needs {
min-height: 0;
}
}
}
@media (prefers-reduced-motion: reduce) {
.welcome-sources {
animation: none;
.welcome-sources__header,
.welcome-sources__grid {
animation: none;
}
.source-card:hover,
.source-card__cta:hover {
transform: none;
}
}
}
@media (prefers-color-scheme: light) {
.welcome-sources {
.welcome-sources__icon {
color: #2563eb;
}
.welcome-sources__title {
background: linear-gradient(135deg, #2563eb 0%, #1d4ed8 50%, #1e40af 100%);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
}
.welcome-sources__body {
color: #64748b;
opacity: 0.85;
}
.source-card {
background: linear-gradient(145deg, rgba(37, 99, 235, 0.06), rgba(29, 78, 216, 0.03));
border-color: rgba(37, 99, 235, 0.15);
&:hover {
background: linear-gradient(145deg, rgba(37, 99, 235, 0.1), rgba(29, 78, 216, 0.05));
border-color: rgba(37, 99, 235, 0.3);
}
}
.source-card__icon {
color: #2563eb;
}
.source-card__needs {
color: #475569;
opacity: 0.9;
}
.source-card__chip {
background: rgba(37, 99, 235, 0.08);
color: #1d4ed8;
border-color: rgba(37, 99, 235, 0.18);
}
}
}
@@ -3,8 +3,9 @@
@if (data.totalCount === 0) {
<!-- Welcome state: no playlists at all -->
<app-empty-state
[type]="'welcome'"
(addPlaylistClicked)="onAddPlaylist()"
[type]="'welcome-sources'"
[showElectronOnlyValueProps]="isElectron"
(addPlaylistClicked)="onAddPlaylist($event)"
/>
} @else {
<!-- No results state: search/filter returned nothing -->
@@ -2,6 +2,17 @@
display: flex;
flex-direction: column;
align-items: center;
width: 100%;
min-width: 0;
min-height: 0;
}
:host app-empty-state {
display: flex;
flex: 1;
width: 100%;
height: 100%;
min-height: 0;
}
.playlists-list {
@@ -19,6 +19,7 @@ import { Router } from '@angular/router';
import { Store } from '@ngrx/store';
import { TranslateService } from '@ngx-translate/core';
import { PlaylistContextFacade } from '@iptvnator/playlist/shared/util';
import type { WorkspacePlaylistType } from '@iptvnator/workspace/shell/util';
import {
PlaylistActions,
selectActiveTypeFilters,
@@ -88,7 +89,9 @@ export class RecentPlaylistsComponent {
readonly sidebarMode = input(false);
readonly searchQueryInput = input<string>('');
readonly playlistClicked = output<string>();
readonly addPlaylistClicked = output<void>();
readonly addPlaylistClicked = output<WorkspacePlaylistType | undefined>();
readonly isElectron = !!window.electron;
readonly allPlaylistsLoaded = this.store.selectSignal(
selectPlaylistsLoadingFlag
@@ -198,8 +201,8 @@ export class RecentPlaylistsComponent {
);
}
onAddPlaylist() {
this.addPlaylistClicked.emit();
onAddPlaylist(type?: WorkspacePlaylistType) {
this.addPlaylistClicked.emit(type);
}
getPlaylist(playlistMeta: PlaylistMeta): void {
+1
View File
@@ -1,3 +1,4 @@
export * from './lib/playlist-player-actions';
export * from './lib/playlist-context.facade';
export * from './lib/playlist-file-import.service';
export * from './lib/playlist-refresh-action.service';
@@ -0,0 +1,56 @@
import { Injectable, inject } from '@angular/core';
import { Store } from '@ngrx/store';
import { PlaylistActions } from 'm3u-state';
const M3U_EXTENSIONS = ['.m3u', '.m3u8'];
export type PlaylistFileImportResult =
| { ok: true; title: string }
| { ok: false; reason: 'unsupported' | 'empty' | 'read-error' };
@Injectable({ providedIn: 'root' })
export class PlaylistFileImportService {
private readonly store = inject(Store);
isSupportedFile(file: File): boolean {
const lower = file.name.toLowerCase();
return M3U_EXTENSIONS.some((ext) => lower.endsWith(ext));
}
async importFile(file: File): Promise<PlaylistFileImportResult> {
if (!this.isSupportedFile(file)) {
return { ok: false, reason: 'unsupported' };
}
let playlist: string;
try {
playlist = await file.text();
} catch {
return { ok: false, reason: 'read-error' };
}
if (!playlist.trim()) {
return { ok: false, reason: 'empty' };
}
const title = this.normalizeTitle(file.name);
this.store.dispatch(
PlaylistActions.parsePlaylist({
uploadType: 'FILE',
playlist,
title,
path: (file as File & { path?: string }).path,
})
);
return { ok: true, title };
}
private normalizeTitle(filename: string): string {
const trimmed = filename.trim();
if (!trimmed) {
return filename;
}
return trimmed.replace(/\.(m3u8?|pls|txt)$/i, '') || trimmed;
}
}
@@ -54,17 +54,31 @@
<p>{{ 'DOWNLOADS.NOT_AVAILABLE' | translate }}</p>
<span>{{ 'DOWNLOADS.DESKTOP_ONLY' | translate }}</span>
</div>
} @else if (hasNoPlaylists()) {
<app-empty-state
type="no-data"
icon="download_for_offline"
titleKey="DOWNLOADS.NO_PLAYLISTS_TITLE"
descriptionKey="DOWNLOADS.NO_PLAYLISTS_BODY"
primaryActionLabelKey="HOME.PLAYLISTS.ADD_YOUR_FIRST"
secondaryActionLabelKey="WORKSPACE.SHELL.GO_TO_SOURCES"
(primaryActionClicked)="addPlaylist()"
(secondaryActionClicked)="goToSources()"
/>
} @else if (!hasDownloads()) {
<div class="downloads__empty">
<mat-icon>download</mat-icon>
<p>{{ 'DOWNLOADS.EMPTY' | translate }}</p>
<span>{{ 'DOWNLOADS.EMPTY_HINT' | translate }}</span>
</div>
<app-empty-state
type="no-data"
icon="download_for_offline"
titleKey="DOWNLOADS.EMPTY"
descriptionKey="DOWNLOADS.NO_ITEMS_BODY"
secondaryActionLabelKey="WORKSPACE.SHELL.GO_TO_DASHBOARD"
(secondaryActionClicked)="goToDashboard()"
/>
} @else if (filteredDownloads().length === 0) {
<div class="downloads__empty">
<mat-icon>search_off</mat-icon>
<p>No downloads match the current filter</p>
<span>Try a different search term</span>
<p>{{ 'DOWNLOADS.NO_FILTER_MATCH' | translate }}</p>
<span>{{ 'DOWNLOADS.NO_FILTER_MATCH_HINT' | translate }}</span>
</div>
} @else {
<div class="downloads__list-wrapper">
@@ -22,8 +22,10 @@ import {
DownloadsService,
PlaylistsService,
} from 'services';
import { EmptyStateComponent } from '@iptvnator/playlist/shared/ui';
import { queryParamSignal } from '@iptvnator/portal/shared/util';
import { createPortalCollectionContext } from '@iptvnator/portal/shared/util';
import { WORKSPACE_SHELL_ACTIONS } from '@iptvnator/workspace/shell/util';
import {
buildStandardCollectionCategories,
filterCollectionBucket,
@@ -48,6 +50,7 @@ const DOWNLOAD_COLLECTION_LABELS = {
],
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [
EmptyStateComponent,
MatButtonModule,
MatIcon,
MatProgressBarModule,
@@ -64,6 +67,7 @@ export class DownloadsComponent {
private readonly dialogService = inject(DialogService);
private readonly translate = inject(TranslateService);
private readonly snackBar = inject(MatSnackBar);
private readonly shellActions = inject(WORKSPACE_SHELL_ACTIONS);
readonly downloadsService = inject(DownloadsService);
readonly downloads = this.downloadsService.downloads;
@@ -81,6 +85,19 @@ export class DownloadsComponent {
readonly playlists = toSignal(this.playlistsService.getAllPlaylists(), {
initialValue: [] as Playlist[],
});
readonly hasNoPlaylists = computed(() => this.playlists().length === 0);
addPlaylist(): void {
this.shellActions.openAddPlaylistDialog();
}
goToSources(): void {
void this.router.navigate(['/workspace', 'sources']);
}
goToDashboard(): void {
void this.router.navigate(['/workspace', 'dashboard']);
}
readonly scopedDownloads = computed(() => {
const playlistId = this.playlistId();
@@ -207,26 +207,22 @@
}
@if (!hasLive() && !hasMovies() && !hasSeries()) {
<div class="empty-state">
<mat-icon class="empty-icon">{{
workspaceSearchTerm()
? 'search_off'
: mode() === 'favorites'
? 'favorite_border'
: 'history'
}}</mat-icon>
<p class="empty-title">
@if (workspaceSearchTerm()) {
@if (workspaceSearchTerm()) {
<div class="empty-state">
<mat-icon class="empty-icon">search_off</mat-icon>
<p class="empty-title">
{{ 'WORKSPACE.GLOBAL_FAVORITES.NO_RESULTS' | translate }}
} @else {
{{
(mode() === 'favorites'
? 'PORTALS.NO_FAVORITES'
: 'PORTALS.NO_RECENT_ITEMS'
) | translate
}}
}
</p>
</div>
</p>
</div>
} @else {
<app-empty-state
type="no-data"
[icon]="emptyStateIcon()"
[titleKey]="emptyStateTitleKey()"
[descriptionKey]="emptyStateBodyKey()"
secondaryActionLabelKey="WORKSPACE.SHELL.GO_TO_DASHBOARD"
(secondaryActionClicked)="goToDashboard()"
/>
}
}
}
@@ -52,6 +52,7 @@ import {
WorkspaceViewCommandService,
} from '@iptvnator/portal/shared/util';
import { selectAllPlaylistsMeta, selectPlaylistsLoadingFlag } from 'm3u-state';
import { EmptyStateComponent } from '@iptvnator/playlist/shared/ui';
import { UnifiedLiveTabComponent } from './unified-live-tab.component';
import { UnifiedGridTabComponent } from './unified-grid-tab.component';
import {
@@ -65,6 +66,7 @@ import {
styleUrl: './unified-collection-page.component.scss',
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [
EmptyStateComponent,
NgTemplateOutlet,
MatButtonToggleModule,
MatIconButton,
@@ -423,6 +425,26 @@ export class UnifiedCollectionPageComponent implements AfterContentInit {
this.selectedContentType.set(value);
}
readonly emptyStateIcon = computed(() =>
this.mode() === 'favorites' ? 'favorite_border' : 'history_toggle_off'
);
readonly emptyStateTitleKey = computed(() =>
this.mode() === 'favorites'
? 'WORKSPACE.GLOBAL_FAVORITES.NO_ITEMS_TITLE'
: 'WORKSPACE.GLOBAL_RECENT.NO_ITEMS_TITLE'
);
readonly emptyStateBodyKey = computed(() =>
this.mode() === 'favorites'
? 'WORKSPACE.GLOBAL_FAVORITES.NO_ITEMS_BODY'
: 'WORKSPACE.GLOBAL_RECENT.NO_ITEMS_BODY'
);
goToDashboard(): void {
void this.router.navigate(['/workspace', 'dashboard']);
}
setFavSortMode(mode: FavoritesChannelSortMode): void {
this.favSortMode.set(mode);
persistFavoritesChannelSortMode(mode);