From b1d519183fe11e4647f3ed936616930c35f24890 Mon Sep 17 00:00:00 2001 From: 4gray Date: Sat, 25 Apr 2026 22:18:13 +0200 Subject: [PATCH] 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 --- .../app/global-collection-route.component.ts | 96 +++- .../empty-state/empty-state.component.html | 190 +++++--- .../empty-state/empty-state.component.scss | 452 +++--------------- .../empty-state/empty-state.component.ts | 127 ++++- .../empty-state/empty-state.responsive.scss | 41 ++ .../empty-state/empty-state.themes.scss | 38 ++ .../empty-state.welcome-dashboard.scss | 422 ++++++++++++++++ .../empty-state.welcome-sources.scss | 259 ++++++++++ .../recent-playlists.component.html | 5 +- .../recent-playlists.component.scss | 11 + .../recent-playlists.component.ts | 9 +- libs/playlist/shared/util/src/index.ts | 1 + .../src/lib/playlist-file-import.service.ts | 56 +++ .../feature/src/lib/downloads.component.html | 28 +- .../feature/src/lib/downloads.component.ts | 17 + .../unified-collection-page.component.html | 36 +- .../unified-collection-page.component.ts | 22 + 17 files changed, 1308 insertions(+), 502 deletions(-) create mode 100644 libs/playlist/shared/ui/src/lib/recent-playlists/empty-state/empty-state.responsive.scss create mode 100644 libs/playlist/shared/ui/src/lib/recent-playlists/empty-state/empty-state.themes.scss create mode 100644 libs/playlist/shared/ui/src/lib/recent-playlists/empty-state/empty-state.welcome-dashboard.scss create mode 100644 libs/playlist/shared/ui/src/lib/recent-playlists/empty-state/empty-state.welcome-sources.scss create mode 100644 libs/playlist/shared/util/src/lib/playlist-file-import.service.ts diff --git a/apps/web/src/app/global-collection-route.component.ts b/apps/web/src/app/global-collection-route.component.ts index 6799a625e..f2a7f5e31 100644 --- a/apps/web/src/app/global-collection-route.component.ts +++ b/apps/web/src/app/global-collection-route.component.ts @@ -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: ` - - - @if (item.sourceType === 'xtream') { - - } @else if (item.sourceType === 'stalker') { - - } - - + @if (hasNoPlaylists()) { + + } @else { + + + @if (item.sourceType === 'xtream') { + + } @else if (item.sourceType === 'stalker') { + + } + + + } `, 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('favorites'); readonly defaultScope = input(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(() => this.activePlaylistId() ? 'playlist' : (this.defaultScope() ?? 'all') ); + + addPlaylist(): void { + this.shellActions.openAddPlaylistDialog(); + } + + goToDashboard(): void { + void this.router.navigate(['/workspace', 'dashboard']); + } } diff --git a/libs/playlist/shared/ui/src/lib/recent-playlists/empty-state/empty-state.component.html b/libs/playlist/shared/ui/src/lib/recent-playlists/empty-state/empty-state.component.html index f1b95d0f4..97227f172 100644 --- a/libs/playlist/shared/ui/src/lib/recent-playlists/empty-state/empty-state.component.html +++ b/libs/playlist/shared/ui/src/lib/recent-playlists/empty-state/empty-state.component.html @@ -1,43 +1,28 @@ -@if (type() === 'welcome') { -
-
- - - - +@switch (type()) { + @case ('welcome-dashboard') { +
+
+
+ + + + + + + + + + +
- - +

{{ 'HOME.PLAYLISTS.WELCOME_TITLE' | translate }}

+

{{ 'HOME.PLAYLISTS.DASHBOARD_WELCOME_TAGLINE' | translate }}

+

{{ 'HOME.PLAYLISTS.DASHBOARD_WELCOME_DESCRIPTION' | translate }}

- - - - - - - - - - - - - - - - - - - -
- -
-

{{ 'HOME.PLAYLISTS.WELCOME_TITLE' | translate }}

-

{{ 'HOME.PLAYLISTS.WELCOME_DESCRIPTION' | translate }}

- -
-
-
- folder_open - {{ 'HOME.PLAYLISTS.FEATURE_M3U' | translate }} -
-
- cloud - {{ 'HOME.PLAYLISTS.FEATURE_XTREAM' | translate }} -
-
- cast - {{ 'HOME.PLAYLISTS.FEATURE_STALKER' | translate }} +
    + @for (card of featureCards; track card.titleKey) { + @if (isFeatureCardVisible(card)) { +
  • +
    + {{ card.icon }} +
    +

    {{ card.titleKey | translate }}

    +

    {{ card.descKey | translate }}

    +
  • + } + } +
+
+ } + @case ('welcome-sources') { +
+
+
+ playlist_add
+

{{ 'HOME.PLAYLISTS.SOURCES_WELCOME_TITLE' | translate }}

+

{{ 'HOME.PLAYLISTS.SOURCES_WELCOME_BODY' | translate }}

+
+ +
+ @for (card of sourceCards; track card.type) { +
+
+ {{ card.icon }} +

{{ card.nameKey | translate }}

+
+

{{ card.needsKey | translate }}

+
    + @for (chip of card.contentKeys; track chip) { +
  • {{ chip | translate }}
  • + } +
+ +
+ }
-
-} @else { -
-
- - - - - + } + @case ('no-results') { +
+
+ + + + + + +
- - - - +
+

{{ 'HOME.PLAYLISTS.NO_RESULTS_TITLE' | translate }}

+

{{ 'HOME.PLAYLISTS.NO_RESULTS_DESCRIPTION' | translate }}

+
+ } + @case ('no-data') { +
+
+ {{ icon() }} +
-
-

{{ 'HOME.PLAYLISTS.NO_RESULTS_TITLE' | translate }}

-

{{ 'HOME.PLAYLISTS.NO_RESULTS_DESCRIPTION' | translate }}

+
+ @if (titleKey()) { +

{{ titleKey() | translate }}

+ } + @if (descriptionKey()) { +

{{ descriptionKey() | translate }}

+ } + + @if (primaryActionLabelKey() || secondaryActionLabelKey()) { +
+ @if (primaryActionLabelKey(); as primaryLabel) { + + } + @if (secondaryActionLabelKey(); as secondaryLabel) { + + } +
+ } +
-
+ } } diff --git a/libs/playlist/shared/ui/src/lib/recent-playlists/empty-state/empty-state.component.scss b/libs/playlist/shared/ui/src/lib/recent-playlists/empty-state/empty-state.component.scss index 6db964ec0..9b6b3487d 100644 --- a/libs/playlist/shared/ui/src/lib/recent-playlists/empty-state/empty-state.component.scss +++ b/libs/playlist/shared/ui/src/lib/recent-playlists/empty-state/empty-state.component.scss @@ -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; - } - } - } -} diff --git a/libs/playlist/shared/ui/src/lib/recent-playlists/empty-state/empty-state.component.ts b/libs/playlist/shared/ui/src/lib/recent-playlists/empty-state/empty-state.component.ts index 631a683ad..d4c3f9704 100644 --- a/libs/playlist/shared/ui/src/lib/recent-playlists/empty-state/empty-state.component.ts +++ b/libs/playlist/shared/ui/src/lib/recent-playlists/empty-state/empty-state.component.ts @@ -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(); - readonly addPlaylistClicked = output(); - onAddPlaylist(): void { - this.addPlaylistClicked.emit(); + readonly icon = input('inbox'); + readonly titleKey = input(''); + readonly descriptionKey = input(''); + readonly primaryActionLabelKey = input(null); + readonly secondaryActionLabelKey = input(null); + + readonly showElectronOnlyValueProps = input(false); + + readonly addPlaylistClicked = output(); + readonly primaryActionClicked = output(); + readonly secondaryActionClicked = output(); + + 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(); } } diff --git a/libs/playlist/shared/ui/src/lib/recent-playlists/empty-state/empty-state.responsive.scss b/libs/playlist/shared/ui/src/lib/recent-playlists/empty-state/empty-state.responsive.scss new file mode 100644 index 000000000..ba080ca32 --- /dev/null +++ b/libs/playlist/shared/ui/src/lib/recent-playlists/empty-state/empty-state.responsive.scss @@ -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; } + } +} diff --git a/libs/playlist/shared/ui/src/lib/recent-playlists/empty-state/empty-state.themes.scss b/libs/playlist/shared/ui/src/lib/recent-playlists/empty-state/empty-state.themes.scss new file mode 100644 index 000000000..47590851a --- /dev/null +++ b/libs/playlist/shared/ui/src/lib/recent-playlists/empty-state/empty-state.themes.scss @@ -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; + } + } +} diff --git a/libs/playlist/shared/ui/src/lib/recent-playlists/empty-state/empty-state.welcome-dashboard.scss b/libs/playlist/shared/ui/src/lib/recent-playlists/empty-state/empty-state.welcome-dashboard.scss new file mode 100644 index 000000000..154d51bba --- /dev/null +++ b/libs/playlist/shared/ui/src/lib/recent-playlists/empty-state/empty-state.welcome-dashboard.scss @@ -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; + } + } +} diff --git a/libs/playlist/shared/ui/src/lib/recent-playlists/empty-state/empty-state.welcome-sources.scss b/libs/playlist/shared/ui/src/lib/recent-playlists/empty-state/empty-state.welcome-sources.scss new file mode 100644 index 000000000..41e01a628 --- /dev/null +++ b/libs/playlist/shared/ui/src/lib/recent-playlists/empty-state/empty-state.welcome-sources.scss @@ -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); + } + } +} diff --git a/libs/playlist/shared/ui/src/lib/recent-playlists/recent-playlists.component.html b/libs/playlist/shared/ui/src/lib/recent-playlists/recent-playlists.component.html index 028424235..b795a9e09 100644 --- a/libs/playlist/shared/ui/src/lib/recent-playlists/recent-playlists.component.html +++ b/libs/playlist/shared/ui/src/lib/recent-playlists/recent-playlists.component.html @@ -3,8 +3,9 @@ @if (data.totalCount === 0) { } @else { diff --git a/libs/playlist/shared/ui/src/lib/recent-playlists/recent-playlists.component.scss b/libs/playlist/shared/ui/src/lib/recent-playlists/recent-playlists.component.scss index a55b6f99b..ccb5152f8 100644 --- a/libs/playlist/shared/ui/src/lib/recent-playlists/recent-playlists.component.scss +++ b/libs/playlist/shared/ui/src/lib/recent-playlists/recent-playlists.component.scss @@ -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 { diff --git a/libs/playlist/shared/ui/src/lib/recent-playlists/recent-playlists.component.ts b/libs/playlist/shared/ui/src/lib/recent-playlists/recent-playlists.component.ts index a85aab62a..45205bbbd 100644 --- a/libs/playlist/shared/ui/src/lib/recent-playlists/recent-playlists.component.ts +++ b/libs/playlist/shared/ui/src/lib/recent-playlists/recent-playlists.component.ts @@ -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(''); readonly playlistClicked = output(); - readonly addPlaylistClicked = output(); + readonly addPlaylistClicked = output(); + + 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 { diff --git a/libs/playlist/shared/util/src/index.ts b/libs/playlist/shared/util/src/index.ts index 3cc1fb482..c52303d9a 100644 --- a/libs/playlist/shared/util/src/index.ts +++ b/libs/playlist/shared/util/src/index.ts @@ -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'; diff --git a/libs/playlist/shared/util/src/lib/playlist-file-import.service.ts b/libs/playlist/shared/util/src/lib/playlist-file-import.service.ts new file mode 100644 index 000000000..46e07ebff --- /dev/null +++ b/libs/playlist/shared/util/src/lib/playlist-file-import.service.ts @@ -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 { + 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; + } +} diff --git a/libs/portal/downloads/feature/src/lib/downloads.component.html b/libs/portal/downloads/feature/src/lib/downloads.component.html index 55f459e4e..61876a00c 100644 --- a/libs/portal/downloads/feature/src/lib/downloads.component.html +++ b/libs/portal/downloads/feature/src/lib/downloads.component.html @@ -54,17 +54,31 @@

{{ 'DOWNLOADS.NOT_AVAILABLE' | translate }}

{{ 'DOWNLOADS.DESKTOP_ONLY' | translate }}
+ } @else if (hasNoPlaylists()) { + } @else if (!hasDownloads()) { -
- download -

{{ 'DOWNLOADS.EMPTY' | translate }}

- {{ 'DOWNLOADS.EMPTY_HINT' | translate }} -
+ } @else if (filteredDownloads().length === 0) {
search_off -

No downloads match the current filter

- Try a different search term +

{{ 'DOWNLOADS.NO_FILTER_MATCH' | translate }}

+ {{ 'DOWNLOADS.NO_FILTER_MATCH_HINT' | translate }}
} @else {
diff --git a/libs/portal/downloads/feature/src/lib/downloads.component.ts b/libs/portal/downloads/feature/src/lib/downloads.component.ts index 84179a700..a9dafaa0f 100644 --- a/libs/portal/downloads/feature/src/lib/downloads.component.ts +++ b/libs/portal/downloads/feature/src/lib/downloads.component.ts @@ -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(); diff --git a/libs/portal/shared/ui/src/lib/components/unified-collection/unified-collection-page.component.html b/libs/portal/shared/ui/src/lib/components/unified-collection/unified-collection-page.component.html index b87ddad80..8e8909d89 100644 --- a/libs/portal/shared/ui/src/lib/components/unified-collection/unified-collection-page.component.html +++ b/libs/portal/shared/ui/src/lib/components/unified-collection/unified-collection-page.component.html @@ -207,26 +207,22 @@ } @if (!hasLive() && !hasMovies() && !hasSeries()) { -
- {{ - workspaceSearchTerm() - ? 'search_off' - : mode() === 'favorites' - ? 'favorite_border' - : 'history' - }} -

- @if (workspaceSearchTerm()) { + @if (workspaceSearchTerm()) { +

+ search_off +

{{ 'WORKSPACE.GLOBAL_FAVORITES.NO_RESULTS' | translate }} - } @else { - {{ - (mode() === 'favorites' - ? 'PORTALS.NO_FAVORITES' - : 'PORTALS.NO_RECENT_ITEMS' - ) | translate - }} - } -

-
+

+
+ } @else { + + } } } diff --git a/libs/portal/shared/ui/src/lib/components/unified-collection/unified-collection-page.component.ts b/libs/portal/shared/ui/src/lib/components/unified-collection/unified-collection-page.component.ts index 0a38fe431..26defbd65 100644 --- a/libs/portal/shared/ui/src/lib/components/unified-collection/unified-collection-page.component.ts +++ b/libs/portal/shared/ui/src/lib/components/unified-collection/unified-collection-page.component.ts @@ -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);