mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-08 17:06:15 -08:00
feat(workspace): enhance dashboard and sources components with playlist management and UI improvements
Entire-Checkpoint: c6e522b4276c
This commit is contained in:
1 parent
caed2ce396
commit
92f21bd237
14 files changed
+209
-115
No files matched your search
+8
-3
@@ -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) {
|
||||
|
||||
+18
@@ -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
|
||||
|
||||
+10
-3
@@ -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
-1
@@ -1,4 +1,4 @@
|
||||
@if (variant() === 'sources') {
|
||||
@if (variant() === 'sources' && hasPlaylists()) {
|
||||
<aside
|
||||
class="context-panel context-panel--sources"
|
||||
appResizable
|
||||
|
||||
+1
@@ -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);
|
||||
}
|
||||
+48
-60
@@ -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>
|
||||
+12
-10
@@ -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;
|
||||
|
||||
+1
@@ -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);
|
||||
|
||||
|
||||
+36
@@ -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,
|
||||
|
||||
+39
-34
@@ -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>
|
||||
+15
@@ -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;
|
||||
}
|
||||
}
|
||||
+6
-3
@@ -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 {
|
||||
|
||||
Reference in new issue
Block a user