feat(workspace): enhance dashboard and sources components with playlist management and UI improvements

Entire-Checkpoint: c6e522b4276c
This commit is contained in:
4gray committed 2026-04-25 22:11:31 +02:00
1 parent caed2ce396
commit 92f21bd237
14 files changed
+209 -115

No files matched your search

@@ -1,4 +1,8 @@
<section class="rails-page" data-test-id="dashboard-page">
<section
class="rails-page"
[class.rails-page--empty]="ready() && !hasPlaylists()"
data-test-id="dashboard-page"
>
@if (!ready()) {
<!-- Precise ghosts that mirror the real hero + rails so the layout
doesn't reflow when data arrives. -->
@@ -34,8 +38,9 @@
}
} @else if (!hasPlaylists()) {
<app-empty-state
[type]="'welcome'"
(addPlaylistClicked)="onAddPlaylist()"
[type]="'welcome-dashboard'"
[showElectronOnlyValueProps]="isElectron"
(addPlaylistClicked)="onAddPlaylist($event)"
/>
} @else {
@if (hero(); as item) {
@@ -5,6 +5,14 @@
min-height: 100%;
}
:host(.rails-page-host--empty) {
padding: 0;
height: 100%;
min-height: 100%;
display: flex;
overflow: hidden;
}
.rails-page {
max-width: 1440px;
margin: 0 auto;
@@ -14,6 +22,16 @@
min-width: 0;
}
.rails-page--empty {
flex: 1;
max-width: none;
margin: 0;
gap: 0;
min-height: 0;
height: 100%;
overflow: hidden;
}
// ── Hero ────────────────────────────────────────────────────────────────────
// Netflix/Apple TV-style hero: blurred poster backdrop + vignette + sharp 2:3
// poster card + text block. Mirrors content-hero fallback (no dedicated
@@ -18,7 +18,10 @@ import {
PlaylistInfoComponent,
} from '@iptvnator/playlist/shared/ui';
import { PlaylistRefreshActionService } from '@iptvnator/playlist/shared/util';
import { WORKSPACE_SHELL_ACTIONS } from '@iptvnator/workspace/shell/util';
import {
WORKSPACE_SHELL_ACTIONS,
WorkspacePlaylistType,
} from '@iptvnator/workspace/shell/util';
import { DialogService } from 'components';
import { PlaylistActions } from 'm3u-state';
import { DatabaseService } from 'services';
@@ -214,6 +217,9 @@ function isXtreamAccountPlaylist(
templateUrl: './workspace-dashboard-rails.component.html',
styleUrl: './workspace-dashboard-rails.component.scss',
changeDetection: ChangeDetectionStrategy.OnPush,
host: {
'[class.rails-page-host--empty]': 'ready() && !hasPlaylists()',
},
})
export class WorkspaceDashboardRailsComponent {
readonly data = inject(DashboardDataService);
@@ -231,6 +237,7 @@ export class WorkspaceDashboardRailsComponent {
readonly hasPlaylists = computed(() => this.data.playlists().length > 0);
readonly ready = this.data.dashboardReady;
readonly xtreamPlaylistCount = this.data.xtreamPlaylistCount;
readonly isElectron = !!window.electron;
readonly skeletonSlots = SKELETON_CARDS_PER_RAIL;
readonly skeletonRails = SKELETON_RAILS;
@@ -321,8 +328,8 @@ export class WorkspaceDashboardRailsComponent {
});
}
onAddPlaylist(): void {
this.shellActions.openAddPlaylistDialog();
onAddPlaylist(type?: WorkspacePlaylistType): void {
this.shellActions.openAddPlaylistDialog(type);
}
markHeroImageFailed(url: string): void {
@@ -1,4 +1,4 @@
@if (variant() === 'sources') {
@if (variant() === 'sources' && hasPlaylists()) {
<aside
class="context-panel context-panel--sources"
appResizable
@@ -31,4 +31,5 @@ export class WorkspaceShellContextSidebarComponent {
readonly variant = input.required<WorkspaceShellContextPanel>();
readonly context = input<WorkspacePortalContext | null>(null);
readonly section = input<PortalRailSection | null>(null);
readonly hasPlaylists = input(true);
}
@@ -6,49 +6,54 @@
[showPlaylistInfo]="canOpenPlaylistInfo()"
[showAccountInfo]="canOpenAccountInfo()"
[showAddPlaylist]="true"
[canRefreshActivePlaylist]="canRefreshPlaylist()"
[isRefreshingActivePlaylist]="isRefreshingPlaylist()"
(playlistInfoRequested)="onPlaylistInfoRequested()"
(accountInfoRequested)="onAccountInfoRequested()"
(addPlaylistRequested)="onAddPlaylistRequested()"
(refreshPlaylistRequested)="onRefreshPlaylistRequested()"
/>
<label class="search-field">
<mat-icon>search</mat-icon>
@if (searchScopeLabel()) {
<span class="search-chip search-chip--scope">
{{ searchScopeLabel() }}
</span>
}
<input
type="search"
spellcheck="false"
[value]="searchQuery()"
(input)="onSearchInput($event)"
(keydown.enter)="onSearchEnter($event)"
[disabled]="!canUseSearch()"
[placeholder]="searchPlaceholder() | translate"
[attr.aria-label]="'WORKSPACE.SHELL.SEARCH_LABEL' | translate"
/>
@if (searchStatusLabel()) {
<span class="search-chip search-chip--status">
{{ searchStatusLabel() }}
</span>
}
<button
type="button"
class="command-trigger"
(click)="onCommandPaletteRequested()"
[matTooltip]="
'WORKSPACE.SHELL.COMMAND_PALETTE_SHORTCUT' | translate
"
[attr.aria-label]="
'WORKSPACE.SHELL.OPEN_COMMAND_PALETTE' | translate
"
>
{{ commandShortcutLabel }}
</button>
</label>
@if (!hasNoPlaylists()) {
<label class="search-field">
<mat-icon>search</mat-icon>
@if (searchScopeLabel()) {
<span class="search-chip search-chip--scope">
{{ searchScopeLabel() }}
</span>
}
<input
type="search"
spellcheck="false"
[value]="searchQuery()"
(input)="onSearchInput($event)"
(keydown.enter)="onSearchEnter($event)"
[disabled]="!canUseSearch()"
[placeholder]="searchPlaceholder() | translate"
[attr.aria-label]="'WORKSPACE.SHELL.SEARCH_LABEL' | translate"
/>
@if (searchStatusLabel()) {
<span class="search-chip search-chip--status">
{{ searchStatusLabel() }}
</span>
}
<button
type="button"
class="command-trigger"
(click)="onCommandPaletteRequested()"
[matTooltip]="
'WORKSPACE.SHELL.COMMAND_PALETTE_SHORTCUT' | translate
"
[attr.aria-label]="
'WORKSPACE.SHELL.OPEN_COMMAND_PALETTE' | translate
"
>
{{ commandShortcutLabel }}
</button>
</label>
}
<div class="header-actions">
<div class="header-actions header-actions--leading">
<button
type="button"
mat-icon-button
@@ -58,26 +63,6 @@
>
<mat-icon>add</mat-icon>
</button>
@if (canRefreshPlaylist()) {
<button
type="button"
class="header-shortcut"
mat-icon-button
[disabled]="isRefreshingPlaylist()"
(click)="onRefreshPlaylistRequested()"
[matTooltip]="
'WORKSPACE.SHELL.REFRESH_PLAYLIST' | translate
"
[attr.aria-label]="
'WORKSPACE.SHELL.REFRESH_PLAYLIST' | translate
"
>
<mat-icon>sync</mat-icon>
</button>
}
@if (headerShortcut() || isElectron()) {
<span class="header-actions-divider" aria-hidden="true"></span>
}
@if (headerShortcut(); as action) {
<button
type="button"
@@ -90,7 +75,10 @@
<mat-icon>{{ action.icon }}</mat-icon>
</button>
}
@if (isElectron()) {
</div>
@if (isElectron()) {
<div class="header-actions header-actions--trailing">
<span class="download-btn-wrap">
@if (hasActiveDownloads()) {
<span class="download-activity-bar" aria-hidden="true">
@@ -114,6 +102,6 @@
<mat-icon>download</mat-icon>
</button>
</span>
}
</div>
</div>
}
</header>
@@ -165,7 +165,7 @@
.header-actions {
display: flex;
align-items: center;
gap: 8px;
gap: 4px;
app-region: no-drag;
button,
@@ -195,6 +195,17 @@
}
}
.header-actions--leading {
margin-left: -4px;
}
.header-actions--trailing {
margin-left: auto;
padding-left: 8px;
border-left: 1px solid var(--mat-sys-outline-variant);
app-region: no-drag;
}
// ─── Download Activity Indicator ─────────────────────────────────────────────
.download-btn-wrap {
position: relative;
@@ -243,15 +254,6 @@
}
}
.header-actions-divider {
width: 1px;
height: 20px;
background: var(--mat-sys-outline-variant);
margin: 0 4px;
flex-shrink: 0;
opacity: 0.6;
}
@media (max-width: 960px) {
.search-field {
min-width: 120px;
@@ -43,6 +43,7 @@ export class WorkspaceShellHeaderComponent {
readonly canRefreshPlaylist = input(false);
readonly isRefreshingPlaylist = input(false);
readonly isElectron = input(false);
readonly hasNoPlaylists = input(false);
readonly isDownloadsView = input(false);
readonly hasActiveDownloads = input(false);
@@ -142,6 +142,7 @@ export class WorkspaceShellFacade {
private readonly playlists = this.store.selectSignal(
selectAllPlaylistsMeta
);
readonly hasNoPlaylists = computed(() => this.playlists().length === 0);
readonly searchQuery = signal('');
readonly appliedSearchQuery = signal('');
@@ -1091,6 +1092,41 @@ export class WorkspaceShellFacade {
priority: 80,
run: () => this.openAddPlaylistDialog(),
},
{
id: 'add-playlist-m3u',
group: 'global',
icon: 'folder_open',
labelKey: 'WORKSPACE.SHELL.COMMANDS.ADD_PLAYLIST_M3U_LABEL',
descriptionKey:
'WORKSPACE.SHELL.COMMANDS.ADD_PLAYLIST_M3U_DESCRIPTION',
keywords: ['m3u', 'm3u8', 'file', 'url', 'add', 'import'],
priority: 79,
run: () => this.workspaceActions.openAddPlaylistDialog('url'),
},
{
id: 'add-playlist-xtream',
group: 'global',
icon: 'cloud',
labelKey: 'WORKSPACE.SHELL.COMMANDS.ADD_PLAYLIST_XTREAM_LABEL',
descriptionKey:
'WORKSPACE.SHELL.COMMANDS.ADD_PLAYLIST_XTREAM_DESCRIPTION',
keywords: ['xtream', 'codes', 'iptv', 'add', 'import'],
priority: 78,
run: () =>
this.workspaceActions.openAddPlaylistDialog('xtream'),
},
{
id: 'add-playlist-stalker',
group: 'global',
icon: 'cast',
labelKey: 'WORKSPACE.SHELL.COMMANDS.ADD_PLAYLIST_STALKER_LABEL',
descriptionKey:
'WORKSPACE.SHELL.COMMANDS.ADD_PLAYLIST_STALKER_DESCRIPTION',
keywords: ['stalker', 'portal', 'mac', 'ministra', 'add'],
priority: 77,
run: () =>
this.workspaceActions.openAddPlaylistDialog('stalker'),
},
];
}
@@ -1,4 +1,9 @@
<div class="workspace-shell">
<div
class="workspace-shell"
appPlaylistDropZone
#dropZone="playlistDropZone"
>
<app-playlist-drop-overlay [state]="dropZone.overlayState()" />
@if (facade.showXtreamImportOverlay()) {
<div class="workspace-loading-overlay">
<div class="workspace-loading-overlay__card">
@@ -95,6 +100,7 @@
[canRefreshPlaylist]="facade.canRefreshPlaylist()"
[isRefreshingPlaylist]="facade.isRefreshingPlaylist()"
[isElectron]="facade.isElectron"
[hasNoPlaylists]="facade.hasNoPlaylists()"
[isDownloadsView]="facade.isDownloadsView()"
[hasActiveDownloads]="facade.hasActiveDownloads()"
(refreshPlaylistRequested)="facade.refreshCurrentPlaylist()"
@@ -114,6 +120,7 @@
[variant]="facade.contextPanel()"
[context]="facade.currentContext()"
[section]="facade.currentSection()"
[hasPlaylists]="!facade.hasNoPlaylists()"
/>
}
@@ -7,6 +7,10 @@ import { MatProgressBarModule } from '@angular/material/progress-bar';
import { RouterOutlet } from '@angular/router';
import { TranslatePipe } from '@ngx-translate/core';
import { ExternalPlaybackDockComponent } from 'components';
import {
PlaylistDropOverlayComponent,
PlaylistDropZoneDirective,
} from '../playlist-drop-overlay';
import { WorkspaceShellContextSidebarComponent } from './components/workspace-shell-context-sidebar/workspace-shell-context-sidebar.component';
import { WorkspaceShellHeaderComponent } from './components/workspace-shell-header/workspace-shell-header.component';
import { WorkspaceShellRailComponent } from './components/workspace-shell-rail/workspace-shell-rail.component';
@@ -18,6 +22,8 @@ import { WorkspaceShellFacade } from './services/workspace-shell.facade';
ExternalPlaybackDockComponent,
MatButtonModule,
MatProgressBarModule,
PlaylistDropOverlayComponent,
PlaylistDropZoneDirective,
RouterOutlet,
TranslatePipe,
WorkspaceShellContextSidebarComponent,
@@ -1,40 +1,45 @@
<section class="sources-page">
<header class="sources-header">
<div class="sources-header__text">
<h2 class="sources-title">{{ title() }}</h2>
<p class="sources-subtitle">{{ subtitle() }}</p>
</div>
<button
type="button"
mat-stroked-button
class="sort-trigger"
[matMenuTriggerFor]="sortMenu"
>
<mat-icon>sort</mat-icon>
<span class="sort-trigger__label">{{ activeSortLabel() }}</span>
<mat-icon class="sort-trigger__caret">arrow_drop_down</mat-icon>
</button>
<mat-menu #sortMenu="matMenu" xPosition="before">
@for (option of sortOptions; track option.translationKey) {
<button
mat-menu-item
type="button"
[class.active-option]="isSortActive(option)"
(click)="setSortOption(option)"
>
<mat-icon>{{ option.icon }}</mat-icon>
<span>{{ option.translationKey | translate }}</span>
@if (isSortActive(option)) {
<mat-icon class="sort-menu__check">check</mat-icon>
}
</button>
}
</mat-menu>
</header>
<section
class="sources-page"
[class.sources-page--empty]="visibleSourcesCount() === 0"
>
@if (visibleSourcesCount() > 0) {
<header class="sources-header">
<div class="sources-header__text">
<h2 class="sources-title">{{ title() }}</h2>
<p class="sources-subtitle">{{ subtitle() }}</p>
</div>
<button
type="button"
mat-stroked-button
class="sort-trigger"
[matMenuTriggerFor]="sortMenu"
>
<mat-icon>sort</mat-icon>
<span class="sort-trigger__label">{{ activeSortLabel() }}</span>
<mat-icon class="sort-trigger__caret">arrow_drop_down</mat-icon>
</button>
<mat-menu #sortMenu="matMenu" xPosition="before">
@for (option of sortOptions; track option.translationKey) {
<button
mat-menu-item
type="button"
[class.active-option]="isSortActive(option)"
(click)="setSortOption(option)"
>
<mat-icon>{{ option.icon }}</mat-icon>
<span>{{ option.translationKey | translate }}</span>
@if (isSortActive(option)) {
<mat-icon class="sort-menu__check">check</mat-icon>
}
</button>
}
</mat-menu>
</header>
}
<div class="sources-content">
<app-recent-playlists
[searchQueryInput]="searchQuery()"
(addPlaylistClicked)="onAddPlaylist()"
(addPlaylistClicked)="onAddPlaylist($event)"
/>
</div>
</section>
@@ -72,3 +72,18 @@
display: block;
height: 100%;
}
.sources-page--empty {
.sources-content {
padding: 0;
overflow: hidden;
display: flex;
}
.sources-content app-recent-playlists {
flex: 1;
height: 100%;
min-height: 0;
display: flex;
}
}
@@ -10,7 +10,10 @@ import { TranslatePipe, TranslateService } from '@ngx-translate/core';
import { selectActiveTypeFilters, selectAllPlaylistsMeta } from 'm3u-state';
import { map, startWith } from 'rxjs';
import { SortBy, SortOrder, SortService } from 'services';
import { WORKSPACE_SHELL_ACTIONS } from '@iptvnator/workspace/shell/util';
import {
WORKSPACE_SHELL_ACTIONS,
WorkspacePlaylistType,
} from '@iptvnator/workspace/shell/util';
interface SortOption {
by: SortBy;
@@ -165,8 +168,8 @@ export class WorkspaceSourcesComponent {
);
});
onAddPlaylist(): void {
this.workspaceActions.openAddPlaylistDialog();
onAddPlaylist(type?: WorkspacePlaylistType): void {
this.workspaceActions.openAddPlaylistDialog(type);
}
isSortActive(option: SortOption): boolean {