refactor(workspace-shell): push xtream import overlay and bulk action into child components

Continues the workspace-shell facade decomposition by relocating two
clusters from the parent template into the components that own them.

Xtream import overlay:
- New WorkspaceShellImportOverlayComponent injects
  WorkspaceShellXtreamImportService directly. The 70-line inline
  overlay block in workspace-shell.component.html collapses to a
  single <app-workspace-shell-import-overlay /> element, gated by
  facade.showXtreamImportOverlay().
- Overlay-specific styles move from workspace-shell.component.scss
  into the child component (~125 lines relocated).
- Facade drops 14 import re-export lines plus the cancelXtreamImport
  delegation. The two facade tests that exercised label generation
  now resolve the service via TestBed.inject and assert against it
  directly.

Header bulk action:
- WorkspaceShellHeaderComponent gains headerBulkAction input and
  headerBulkActionRequested output, plus a button that renders the
  action when present (next to the existing headerShortcut button).
- The parent template wires facade.headerBulkAction() →
  facade.runHeaderBulkAction(). The facade keeps the cross-cutting
  computed and the multi-store dispatch method since both span
  multiple stores.

Verification:
- workspace-shell.facade.spec.ts: 20/20 passing (two assertions
  retargeted at WorkspaceShellXtreamImportService).
- Full feature lib: 53/60 passing — same 7 pre-existing failures.
- pnpm nx build web --configuration=production: success.
- pnpm nx lint workspace-shell-feature: clean.
- pnpm nx run web-e2e:e2e-ci--src/basic.e2e.ts: 3/3 across browsers.
- pnpm nx run web-e2e:e2e-ci--src/settings.e2e.ts: 12/12 across
  browsers.

Facade: 917 → 897 lines. The bulk-action button now actually renders
in the header — closing a latent gap where the computed existed on
the facade but had no template binding.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Entire-Checkpoint: c2cea9c530e6
This commit is contained in:
4grayandClaude Opus 4.7 committed 2026-05-01 19:12:19 +02:00
1 parent 0ad54e7939
commit 63f5173139
10 files changed
+252 -230

No files matched your search

@@ -75,6 +75,19 @@
<mat-icon>{{ action.icon }}</mat-icon>
</button>
}
@if (headerBulkAction(); as bulk) {
<button
type="button"
class="header-shortcut header-bulk-action"
mat-icon-button
[disabled]="bulk.disabled"
(click)="onHeaderBulkActionRequested()"
[matTooltip]="bulk.tooltip"
[attr.aria-label]="bulk.ariaLabel"
>
<mat-icon>{{ bulk.icon }}</mat-icon>
</button>
}
</div>
@if (isElectron()) {
@@ -10,6 +10,7 @@ import { MatTooltip } from '@angular/material/tooltip';
import { TranslatePipe } from '@ngx-translate/core';
import { PlaylistSwitcherComponent } from '@iptvnator/playlist/shared/ui';
import { WorkspaceHeaderAction } from '@iptvnator/portal/shared/util';
import { WorkspaceHeaderBulkAction } from '../../services/helpers/workspace-shell-constants';
@Component({
selector: 'app-workspace-shell-header',
@@ -40,6 +41,7 @@ export class WorkspaceShellHeaderComponent {
readonly searchScopeLabel = input('');
readonly searchStatusLabel = input('');
readonly headerShortcut = input<WorkspaceHeaderAction | null>(null);
readonly headerBulkAction = input<WorkspaceHeaderBulkAction | null>(null);
readonly canRefreshPlaylist = input(false);
readonly isRefreshingPlaylist = input(false);
readonly isElectron = input(false);
@@ -52,6 +54,7 @@ export class WorkspaceShellHeaderComponent {
readonly commandPaletteRequested = output<void>();
readonly addPlaylistRequested = output<void>();
readonly headerShortcutRequested = output<void>();
readonly headerBulkActionRequested = output<void>();
readonly refreshPlaylistRequested = output<void>();
readonly downloadsRequested = output<void>();
readonly playlistInfoRequested = output<void>();
@@ -87,6 +90,10 @@ export class WorkspaceShellHeaderComponent {
this.headerShortcutRequested.emit();
}
onHeaderBulkActionRequested(): void {
this.headerBulkActionRequested.emit();
}
onRefreshPlaylistRequested(): void {
this.refreshPlaylistRequested.emit();
}
@@ -0,0 +1,66 @@
<div class="workspace-loading-overlay">
<div class="workspace-loading-overlay__card">
<div class="workspace-loading-overlay__header">
@if (xtreamImport.xtreamImportSourceLabel()) {
<span
class="workspace-loading-overlay__badge"
[class.workspace-loading-overlay__badge--remote]="
xtreamImport.xtreamImportPhaseTone() === 'remote'
"
>
{{ xtreamImport.xtreamImportSourceLabel() }}
</span>
}
<h3>{{ xtreamImport.xtreamImportTitleLabel() }}</h3>
</div>
@if (xtreamImport.xtreamImportPhaseLabel()) {
<p
class="workspace-loading-overlay__phase"
aria-live="polite"
>
{{ xtreamImport.xtreamImportPhaseLabel() }}
</p>
}
@if (xtreamImport.xtreamImportDetailLabel()) {
<p class="workspace-loading-overlay__detail">
{{ xtreamImport.xtreamImportDetailLabel() }}
</p>
}
@if (
xtreamImport.xtreamActiveImportCount() !== 0 &&
xtreamImport.xtreamActiveItemsToImport() !== 0
) {
<mat-progress-bar
mode="determinate"
[value]="
(xtreamImport.xtreamActiveImportCount() /
xtreamImport.xtreamActiveItemsToImport()) *
100
"
/>
} @else {
<mat-progress-bar mode="indeterminate" />
}
@if (xtreamImport.xtreamImportProgressLabel()) {
<p class="workspace-loading-overlay__progress-copy">
{{ xtreamImport.xtreamImportProgressLabel() }}
</p>
}
@if (xtreamImport.canCancelXtreamImport()) {
<button
mat-stroked-button
class="workspace-loading-overlay__action"
type="button"
[disabled]="xtreamImport.isCancellingXtreamImport()"
(click)="onCancel()"
>
{{
(xtreamImport.isCancellingXtreamImport()
? 'WORKSPACE.SHELL.XTREAM_IMPORT_CANCELLING'
: 'WORKSPACE.SHELL.XTREAM_IMPORT_CANCEL'
) | translate
}}
</button>
}
</div>
</div>
@@ -0,0 +1,128 @@
:host {
display: contents;
}
.workspace-loading-overlay {
position: fixed;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
padding: 24px;
background:
radial-gradient(
circle at top,
rgba(99, 120, 255, 0.12),
transparent 34%
),
rgba(8, 10, 16, 0.68);
backdrop-filter: blur(14px);
z-index: 1000;
}
.workspace-loading-overlay__card {
width: min(440px, 100%);
display: grid;
gap: 14px;
padding: 24px 24px 20px;
border-radius: 24px;
border: 1px solid var(--app-separator, rgba(255, 255, 255, 0.12));
background:
linear-gradient(
180deg,
rgba(255, 255, 255, 0.06),
rgba(255, 255, 255, 0)
),
var(--mat-sys-surface-container-high);
box-shadow:
0 24px 64px rgba(0, 0, 0, 0.32),
0 1px 0 rgba(255, 255, 255, 0.05) inset;
text-align: center;
animation: workspace-loading-overlay-enter 180ms ease-out;
h3 {
margin: 0;
font-size: 1.55rem;
font-weight: 650;
letter-spacing: -0.03em;
color: var(--app-heading-color, var(--mat-sys-on-surface));
}
}
.workspace-loading-overlay__header {
display: grid;
gap: 10px;
justify-items: center;
}
.workspace-loading-overlay__badge {
display: inline-flex;
align-items: center;
justify-content: center;
padding: 6px 12px;
border-radius: 999px;
border: 1px solid rgba(120, 220, 255, 0.24);
background: rgba(120, 220, 255, 0.08);
color: rgba(164, 233, 255, 1);
font-size: 0.72rem;
font-weight: 700;
letter-spacing: 0.12em;
text-transform: uppercase;
}
.workspace-loading-overlay__badge--remote {
border-color: rgba(105, 195, 255, 0.34);
background: rgba(83, 158, 255, 0.12);
color: rgba(202, 230, 255, 1);
}
.workspace-loading-overlay__phase,
.workspace-loading-overlay__detail,
.workspace-loading-overlay__progress-copy {
margin: 0;
}
.workspace-loading-overlay__phase {
font-size: 1rem;
font-weight: 560;
color: var(--app-body-color, var(--mat-sys-on-surface));
}
.workspace-loading-overlay__detail {
max-width: 32ch;
justify-self: center;
color: var(--app-muted-color, var(--mat-sys-on-surface-variant));
line-height: 1.45;
}
.workspace-loading-overlay__progress-copy {
max-width: 30ch;
justify-self: center;
line-height: 1.45;
font-variant-numeric: tabular-nums;
color: var(--app-muted-color, var(--mat-sys-on-surface-variant));
text-wrap: balance;
}
.workspace-loading-overlay__action {
justify-self: center;
min-width: 180px;
border-radius: 999px;
}
:host ::ng-deep .workspace-loading-overlay__card .mat-mdc-progress-bar {
overflow: hidden;
border-radius: 999px;
}
@keyframes workspace-loading-overlay-enter {
from {
opacity: 0;
transform: translateY(10px) scale(0.985);
}
to {
opacity: 1;
transform: translateY(0) scale(1);
}
}
@@ -0,0 +1,20 @@
import { ChangeDetectionStrategy, Component, inject } from '@angular/core';
import { MatButtonModule } from '@angular/material/button';
import { MatProgressBarModule } from '@angular/material/progress-bar';
import { TranslatePipe } from '@ngx-translate/core';
import { WorkspaceShellXtreamImportService } from '../../services/workspace-shell-xtream-import.service';
@Component({
selector: 'app-workspace-shell-import-overlay',
imports: [MatButtonModule, MatProgressBarModule, TranslatePipe],
templateUrl: './workspace-shell-import-overlay.component.html',
styleUrl: './workspace-shell-import-overlay.component.scss',
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class WorkspaceShellImportOverlayComponent {
protected readonly xtreamImport = inject(WorkspaceShellXtreamImportService);
onCancel(): void {
this.xtreamImport.cancelXtreamImport();
}
}
@@ -314,51 +314,53 @@ describe('WorkspaceShellFacade', () => {
it('uses a loading label for remote Xtream fetch phases', () => {
const xtreamStore = TestBed.inject(XtreamStore) as unknown as MockXtreamStore;
const xtreamImport = TestBed.inject(WorkspaceShellXtreamImportService);
xtreamStore.currentImportPhase.set('loading-categories');
expect(facade.xtreamImportPhaseLabel()).toBe(
expect(xtreamImport.xtreamImportPhaseLabel()).toBe(
'WORKSPACE.SHELL.XTREAM_IMPORT_LOADING'
);
xtreamStore.currentImportPhase.set('loading-live');
expect(facade.xtreamImportPhaseLabel()).toBe(
expect(xtreamImport.xtreamImportPhaseLabel()).toBe(
'WORKSPACE.SHELL.XTREAM_IMPORT_LOADING'
);
expect(facade.xtreamImportSourceLabel()).toBe(
expect(xtreamImport.xtreamImportSourceLabel()).toBe(
'WORKSPACE.SHELL.XTREAM_IMPORT_REMOTE_BADGE'
);
expect(facade.xtreamImportDetailLabel()).toBe(
expect(xtreamImport.xtreamImportDetailLabel()).toBe(
'WORKSPACE.SHELL.XTREAM_IMPORT_DETAIL_REMOTE'
);
xtreamStore.currentImportPhase.set('saving-categories');
expect(facade.xtreamImportPhaseLabel()).toBe(
expect(xtreamImport.xtreamImportPhaseLabel()).toBe(
'WORKSPACE.SHELL.XTREAM_IMPORT_SAVING'
);
xtreamStore.currentImportPhase.set('saving-content');
expect(facade.xtreamImportPhaseLabel()).toBe(
expect(xtreamImport.xtreamImportPhaseLabel()).toBe(
'WORKSPACE.SHELL.XTREAM_IMPORT_SAVING'
);
expect(facade.xtreamImportSourceLabel()).toBe(
expect(xtreamImport.xtreamImportSourceLabel()).toBe(
'WORKSPACE.SHELL.XTREAM_IMPORT_LOCAL_BADGE'
);
expect(facade.xtreamImportDetailLabel()).toBe(
expect(xtreamImport.xtreamImportDetailLabel()).toBe(
'WORKSPACE.SHELL.XTREAM_IMPORT_DETAIL_LOCAL'
);
});
it('builds a type-aware xtream import progress label', () => {
const xtreamStore = TestBed.inject(XtreamStore) as unknown as MockXtreamStore;
const xtreamImport = TestBed.inject(WorkspaceShellXtreamImportService);
xtreamStore.activeImportContentType.set('vod');
xtreamStore.activeImportCurrentCount.set(20);
xtreamStore.activeImportTotalCount.set(12323);
expect(facade.xtreamImportTypeLabel()).toBe(
expect(xtreamImport.xtreamImportTypeLabel()).toBe(
'WORKSPACE.SHELL.RAIL_MOVIES'
);
expect(facade.xtreamImportProgressLabel()).toBe(
expect(xtreamImport.xtreamImportProgressLabel()).toBe(
'WORKSPACE.SHELL.RAIL_MOVIES imported: 20 / 12,323'
);
});
@@ -268,22 +268,6 @@ export class WorkspaceShellFacade {
() => this.currentRoute().contextPanel !== 'none'
);
private readonly xtreamImport = inject(WorkspaceShellXtreamImportService);
readonly xtreamImportCount = this.xtreamImport.xtreamImportCount;
readonly xtreamItemsToImport = this.xtreamImport.xtreamItemsToImport;
readonly xtreamActiveImportCount = this.xtreamImport.xtreamActiveImportCount;
readonly xtreamActiveItemsToImport =
this.xtreamImport.xtreamActiveItemsToImport;
readonly xtreamImportPhase = this.xtreamImport.xtreamImportPhase;
readonly isCancellingXtreamImport = this.xtreamImport.isCancellingXtreamImport;
readonly canCancelXtreamImport = this.xtreamImport.canCancelXtreamImport;
readonly xtreamImportTitleLabel = this.xtreamImport.xtreamImportTitleLabel;
readonly xtreamImportTypeLabel = this.xtreamImport.xtreamImportTypeLabel;
readonly xtreamImportProgressLabel =
this.xtreamImport.xtreamImportProgressLabel;
readonly xtreamImportPhaseTone = this.xtreamImport.xtreamImportPhaseTone;
readonly xtreamImportSourceLabel = this.xtreamImport.xtreamImportSourceLabel;
readonly xtreamImportPhaseLabel = this.xtreamImport.xtreamImportPhaseLabel;
readonly xtreamImportDetailLabel = this.xtreamImport.xtreamImportDetailLabel;
readonly showXtreamImportOverlay = computed(() => {
const context = this.currentContext();
const section = this.currentSection();
@@ -754,10 +738,6 @@ export class WorkspaceShellFacade {
});
}
cancelXtreamImport(): void {
this.xtreamImport.cancelXtreamImport();
}
openAccountInfo(): void {
if (!this.canOpenAccountInfo()) {
return;
@@ -5,72 +5,7 @@
>
<app-playlist-drop-overlay [state]="dropZone.overlayState()" />
@if (facade.showXtreamImportOverlay()) {
<div class="workspace-loading-overlay">
<div class="workspace-loading-overlay__card">
<div class="workspace-loading-overlay__header">
@if (facade.xtreamImportSourceLabel()) {
<span
class="workspace-loading-overlay__badge"
[class.workspace-loading-overlay__badge--remote]="
facade.xtreamImportPhaseTone() === 'remote'
"
>
{{ facade.xtreamImportSourceLabel() }}
</span>
}
<h3>{{ facade.xtreamImportTitleLabel() }}</h3>
</div>
@if (facade.xtreamImportPhaseLabel()) {
<p
class="workspace-loading-overlay__phase"
aria-live="polite"
>
{{ facade.xtreamImportPhaseLabel() }}
</p>
}
@if (facade.xtreamImportDetailLabel()) {
<p class="workspace-loading-overlay__detail">
{{ facade.xtreamImportDetailLabel() }}
</p>
}
@if (
facade.xtreamActiveImportCount() !== 0 &&
facade.xtreamActiveItemsToImport() !== 0
) {
<mat-progress-bar
mode="determinate"
[value]="
(facade.xtreamActiveImportCount() /
facade.xtreamActiveItemsToImport()) *
100
"
/>
} @else {
<mat-progress-bar mode="indeterminate" />
}
@if (facade.xtreamImportProgressLabel()) {
<p class="workspace-loading-overlay__progress-copy">
{{ facade.xtreamImportProgressLabel() }}
</p>
}
@if (facade.canCancelXtreamImport()) {
<button
mat-stroked-button
class="workspace-loading-overlay__action"
type="button"
[disabled]="facade.isCancellingXtreamImport()"
(click)="facade.cancelXtreamImport()"
>
{{
(facade.isCancellingXtreamImport()
? 'WORKSPACE.SHELL.XTREAM_IMPORT_CANCELLING'
: 'WORKSPACE.SHELL.XTREAM_IMPORT_CANCEL'
) | translate
}}
</button>
}
</div>
</div>
<app-workspace-shell-import-overlay />
}
<app-workspace-shell-rail
@@ -97,6 +32,7 @@
[searchScopeLabel]="facade.searchScopeLabel()"
[searchStatusLabel]="facade.searchStatusLabel()"
[headerShortcut]="facade.headerShortcut()"
[headerBulkAction]="facade.headerBulkAction()"
[canRefreshPlaylist]="facade.canRefreshPlaylist()"
[isRefreshingPlaylist]="facade.isRefreshingPlaylist()"
[isElectron]="facade.isElectron"
@@ -109,6 +45,7 @@
(commandPaletteRequested)="facade.openCommandPalette()"
(addPlaylistRequested)="facade.openAddPlaylistDialog()"
(headerShortcutRequested)="facade.runHeaderShortcut()"
(headerBulkActionRequested)="facade.runHeaderBulkAction()"
(downloadsRequested)="facade.openDownloadsShortcut()"
(playlistInfoRequested)="facade.openPlaylistInfo()"
(accountInfoRequested)="facade.openAccountInfo()"
@@ -65,130 +65,6 @@ app-workspace-shell-context-sidebar {
box-shadow: -4px 8px 20px var(--app-content-shadow, rgba(0, 0, 0, 0.18));
}
.workspace-loading-overlay {
position: fixed;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
padding: 24px;
background:
radial-gradient(
circle at top,
rgba(99, 120, 255, 0.12),
transparent 34%
),
rgba(8, 10, 16, 0.68);
backdrop-filter: blur(14px);
z-index: 1000;
}
.workspace-loading-overlay__card {
width: min(440px, 100%);
display: grid;
gap: 14px;
padding: 24px 24px 20px;
border-radius: 24px;
border: 1px solid var(--app-separator, rgba(255, 255, 255, 0.12));
background:
linear-gradient(
180deg,
rgba(255, 255, 255, 0.06),
rgba(255, 255, 255, 0)
),
var(--mat-sys-surface-container-high);
box-shadow:
0 24px 64px rgba(0, 0, 0, 0.32),
0 1px 0 rgba(255, 255, 255, 0.05) inset;
text-align: center;
animation: workspace-loading-overlay-enter 180ms ease-out;
h3 {
margin: 0;
font-size: 1.55rem;
font-weight: 650;
letter-spacing: -0.03em;
color: var(--app-heading-color, var(--mat-sys-on-surface));
}
}
.workspace-loading-overlay__header {
display: grid;
gap: 10px;
justify-items: center;
}
.workspace-loading-overlay__badge {
display: inline-flex;
align-items: center;
justify-content: center;
padding: 6px 12px;
border-radius: 999px;
border: 1px solid rgba(120, 220, 255, 0.24);
background: rgba(120, 220, 255, 0.08);
color: rgba(164, 233, 255, 1);
font-size: 0.72rem;
font-weight: 700;
letter-spacing: 0.12em;
text-transform: uppercase;
}
.workspace-loading-overlay__badge--remote {
border-color: rgba(105, 195, 255, 0.34);
background: rgba(83, 158, 255, 0.12);
color: rgba(202, 230, 255, 1);
}
.workspace-loading-overlay__phase,
.workspace-loading-overlay__detail,
.workspace-loading-overlay__progress-copy {
margin: 0;
}
.workspace-loading-overlay__phase {
font-size: 1rem;
font-weight: 560;
color: var(--app-body-color, var(--mat-sys-on-surface));
}
.workspace-loading-overlay__detail {
max-width: 32ch;
justify-self: center;
color: var(--app-muted-color, var(--mat-sys-on-surface-variant));
line-height: 1.45;
}
.workspace-loading-overlay__progress-copy {
max-width: 30ch;
justify-self: center;
line-height: 1.45;
font-variant-numeric: tabular-nums;
color: var(--app-muted-color, var(--mat-sys-on-surface-variant));
text-wrap: balance;
}
.workspace-loading-overlay__action {
justify-self: center;
min-width: 180px;
border-radius: 999px;
}
:host ::ng-deep .workspace-loading-overlay__card .mat-mdc-progress-bar {
overflow: hidden;
border-radius: 999px;
}
@keyframes workspace-loading-overlay-enter {
from {
opacity: 0;
transform: translateY(10px) scale(0.985);
}
to {
opacity: 1;
transform: translateY(0) scale(1);
}
}
@media (max-width: 780px) {
.workspace-shell {
@@ -1,11 +1,5 @@
import {
Component,
inject,
} from '@angular/core';
import { MatButtonModule } from '@angular/material/button';
import { MatProgressBarModule } from '@angular/material/progress-bar';
import { Component, inject } from '@angular/core';
import { RouterOutlet } from '@angular/router';
import { TranslatePipe } from '@ngx-translate/core';
import { ExternalPlaybackDockComponent } from 'components';
import {
PlaylistDropOverlayComponent,
@@ -13,6 +7,7 @@ import {
} 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 { WorkspaceShellImportOverlayComponent } from './components/workspace-shell-import-overlay/workspace-shell-import-overlay.component';
import { WorkspaceShellRailComponent } from './components/workspace-shell-rail/workspace-shell-rail.component';
import { WorkspaceShellFacade } from './services/workspace-shell.facade';
import { WorkspaceShellXtreamImportService } from './services/workspace-shell-xtream-import.service';
@@ -22,14 +17,12 @@ import { WorkspaceShellCommandPaletteService } from './services/workspace-shell-
selector: 'app-workspace-shell',
imports: [
ExternalPlaybackDockComponent,
MatButtonModule,
MatProgressBarModule,
PlaylistDropOverlayComponent,
PlaylistDropZoneDirective,
RouterOutlet,
TranslatePipe,
WorkspaceShellContextSidebarComponent,
WorkspaceShellHeaderComponent,
WorkspaceShellImportOverlayComponent,
WorkspaceShellRailComponent,
],
templateUrl: './workspace-shell.component.html',