mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-08 17:06:15 -08:00
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:
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']);
|
||||
}
|
||||
}
|
||||
+125
-65
@@ -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>
|
||||
}
|
||||
}
|
||||
+71
-381
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+122
-5
@@ -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();
|
||||
}
|
||||
}
|
||||
+41
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
+422
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
+259
@@ -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,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();
|
||||
|
||||
+16
-20
@@ -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()"
|
||||
/>
|
||||
}
|
||||
}
|
||||
}
|
||||
+22
@@ -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);
|
||||
|
||||
Reference in new issue
Block a user