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:
4gray committed 2025-12-06 21:44:25 +01:00
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);
}
}