mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-12 03:56:15 -08:00
feat(ui): add recent-playlists empty state component
Add a new EmptyState component and template for the recent-playlists
widget, providing two visual states: "welcome" and "no-results".
- Create empty-state.component.html with:
- a welcome illustration (monitor + WiFi) and content for first-time
users, including translated title/description, add-playlist action
button and features list.
- a no-results illustration (magnifying glass + box) and compact
content for empty search/results.
- conditional rendering based on type() to switch states.
- Start empty-state.component.ts with type definition and imports for
Angular Material and translation; wire up AddPlaylistMenu types.
This implements the UI for when there are no recent playlists and
provides the primary CTA to add the first playlist, improving onboarding
and empty-state UX.
This commit is contained in:
1 parent
892857f000
commit
578a9567d1
3 files changed
+597
No files matched your search
@@ -0,0 +1,87 @@
|
||||
@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"/>
|
||||
|
||||
<!-- Monitor base -->
|
||||
<rect x="134" y="387" width="244" height="18" rx="4" class="monitor-base"/>
|
||||
|
||||
<!-- 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"
|
||||
[matMenuTriggerFor]="addPlaylistMenuComponent().menu()"
|
||||
>
|
||||
<mat-icon>add_circle</mat-icon>
|
||||
{{ 'HOME.PLAYLISTS.ADD_YOUR_FIRST' | translate }}
|
||||
</button>
|
||||
<app-add-playlist-menu
|
||||
(playlistTypeSelected)="onAddPlaylist($event)"
|
||||
/>
|
||||
</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>
|
||||
</div>
|
||||
</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"/>
|
||||
|
||||
<!-- 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>
|
||||
|
||||
<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>
|
||||
}
|
||||
@@ -0,0 +1,486 @@
|
||||
:host {
|
||||
display: block;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 60px 24px;
|
||||
text-align: center;
|
||||
animation: fadeIn 0.6s ease-out;
|
||||
|
||||
.content {
|
||||
max-width: 480px;
|
||||
animation: slideUp 0.8s ease-out;
|
||||
}
|
||||
|
||||
.title {
|
||||
font-family: Roboto, 'Helvetica Neue', sans-serif;
|
||||
font-weight: 500;
|
||||
margin: 0 0 12px 0;
|
||||
background: linear-gradient(135deg, #60a5fa 0%, #3b82f6 50%, #2563eb 100%);
|
||||
-webkit-background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
background-clip: text;
|
||||
|
||||
&.small {
|
||||
font-size: 1.5rem;
|
||||
}
|
||||
}
|
||||
|
||||
.description {
|
||||
font-family: Roboto, 'Helvetica Neue', sans-serif;
|
||||
font-size: 0.95rem;
|
||||
line-height: 1.6;
|
||||
margin: 0 0 32px 0;
|
||||
opacity: 0.7;
|
||||
}
|
||||
}
|
||||
|
||||
.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;
|
||||
|
||||
.illustration-container {
|
||||
margin-bottom: 24px;
|
||||
opacity: 0.5;
|
||||
|
||||
&.small {
|
||||
animation: gentleFloat 1s ease-out;
|
||||
}
|
||||
}
|
||||
|
||||
.illustration {
|
||||
width: 120px;
|
||||
height: 120px;
|
||||
|
||||
.search-circle {
|
||||
stroke: currentColor;
|
||||
opacity: 0.4;
|
||||
}
|
||||
|
||||
.search-handle {
|
||||
stroke: currentColor;
|
||||
opacity: 0.4;
|
||||
}
|
||||
|
||||
.empty-box {
|
||||
stroke: currentColor;
|
||||
}
|
||||
|
||||
.empty-line {
|
||||
stroke: currentColor;
|
||||
}
|
||||
}
|
||||
|
||||
.description {
|
||||
margin-bottom: 0;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes fadeIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes slideUp {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(20px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
@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;
|
||||
transform: scale(1);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes gridMove {
|
||||
0% {
|
||||
transform: translate(0, 0) rotate(0deg);
|
||||
}
|
||||
100% {
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import { Component, input, output, viewChild } from '@angular/core';
|
||||
import { MatButtonModule } from '@angular/material/button';
|
||||
import { MatIcon } from '@angular/material/icon';
|
||||
import { MatMenuModule } from '@angular/material/menu';
|
||||
import { TranslatePipe } from '@ngx-translate/core';
|
||||
import { AddPlaylistMenuComponent, PlaylistType } from '../../add-playlist-menu/add-playlist-menu.component';
|
||||
|
||||
export type EmptyStateType = 'welcome' | 'no-results';
|
||||
|
||||
@Component({
|
||||
selector: 'app-empty-state',
|
||||
templateUrl: './empty-state.component.html',
|
||||
styleUrls: ['./empty-state.component.scss'],
|
||||
imports: [AddPlaylistMenuComponent, MatButtonModule, MatIcon, MatMenuModule, TranslatePipe],
|
||||
})
|
||||
export class EmptyStateComponent {
|
||||
readonly addPlaylistMenuComponent = viewChild.required(AddPlaylistMenuComponent);
|
||||
readonly type = input.required<EmptyStateType>();
|
||||
readonly addPlaylistClicked = output<PlaylistType>();
|
||||
|
||||
onAddPlaylist(playlistType: PlaylistType): void {
|
||||
this.addPlaylistClicked.emit(playlistType);
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user