feat: add workspace command palette component with tests

- Implemented WorkspaceCommandPaletteComponent for executing commands.
- Added unit tests for command palette functionality.
- Updated WorkspaceShellComponent to integrate command palette and keyboard shortcuts.
- Refactored workspace context panel and shell components for improved UI and functionality.
- Enhanced tooltips and accessibility for various UI elements.
- Removed unused category management functionality from context panel.
This commit is contained in:
4gray committed 2026-03-05 23:34:54 +01:00
1 parent 698668f3e3
commit 7f4c8ae9ad
12 files changed
+1109 -216

No files matched your search

@@ -0,0 +1,52 @@
<div class="palette-shell">
<header class="palette-header">
<h2>Command palette</h2>
<p>Global and contextual actions</p>
</header>
<label class="palette-search">
<mat-icon>search</mat-icon>
<input
#queryInput
type="search"
spellcheck="false"
[value]="query()"
(input)="onQueryInput($event)"
(keydown)="onInputKeydown($event)"
placeholder="Type to filter commands"
aria-label="Filter commands"
/>
<span class="palette-search__hint">Ctrl/Cmd+K</span>
</label>
<div class="palette-results">
@if (commandGroups().length === 0) {
<div class="palette-empty">No commands found</div>
} @else {
@for (group of commandGroups(); track group.scope) {
<section class="palette-group">
<h3>{{ group.title }}</h3>
<div class="palette-group__items">
@for (command of group.items; track command.id) {
<button
type="button"
class="palette-command"
[class.is-selected]="isCommandSelected(command)"
[disabled]="!command.enabled"
(mouseenter)="onCommandHover(command)"
(click)="onCommandClick(command)"
>
<span class="palette-command__label">{{
command.label
}}</span>
<span class="palette-command__description">{{
command.description
}}</span>
</button>
}
</div>
</section>
}
}
</div>
</div>
@@ -0,0 +1,160 @@
:host {
display: block;
width: 100%;
max-height: 80vh;
app-region: no-drag;
}
.palette-shell {
padding: 14px;
background: var(--app-header-bg, var(--mat-sys-surface-container-low));
color: var(--mat-sys-on-surface);
border: 1px solid var(--mat-sys-outline-variant);
border-radius: 14px;
box-shadow:
0 24px 48px -28px rgba(0, 0, 0, 0.5),
0 10px 24px -18px rgba(0, 0, 0, 0.35);
}
.palette-header {
display: grid;
gap: 2px;
margin-bottom: 10px;
h2 {
margin: 0;
font-size: 1rem;
letter-spacing: -0.01em;
font-weight: 640;
}
p {
margin: 0;
font-size: 0.78rem;
color: var(--mat-sys-on-surface-variant);
}
}
.palette-search {
display: flex;
align-items: center;
gap: 8px;
border-radius: 10px;
border: 1px solid var(--mat-sys-outline-variant);
background: var(--mat-sys-surface-container);
min-height: 38px;
padding: 0 10px;
&:focus-within {
border-color: var(--mat-sys-primary);
}
mat-icon {
font-size: 18px;
width: 18px;
height: 18px;
color: var(--mat-sys-on-surface-variant);
flex-shrink: 0;
}
input {
border: none;
outline: none;
background: transparent;
color: inherit;
font: inherit;
font-size: 0.9rem;
width: 100%;
&::placeholder {
color: var(--mat-sys-on-surface-variant);
opacity: 0.7;
}
}
}
.palette-search__hint {
font-size: 0.72rem;
color: var(--mat-sys-on-surface-variant);
border: 1px solid var(--mat-sys-outline-variant);
border-radius: 6px;
padding: 2px 6px;
line-height: 1;
flex-shrink: 0;
}
.palette-results {
max-height: min(56vh, 520px);
overflow: auto;
margin-top: 12px;
display: grid;
gap: 10px;
padding-right: 2px;
}
.palette-group {
display: grid;
gap: 6px;
h3 {
margin: 0;
font-size: 0.74rem;
color: var(--mat-sys-on-surface-variant);
text-transform: uppercase;
letter-spacing: 0.05em;
font-weight: 620;
}
}
.palette-group__items {
display: grid;
gap: 4px;
}
.palette-command {
display: grid;
gap: 1px;
width: 100%;
text-align: left;
border: 1px solid transparent;
border-radius: 10px;
background: transparent;
color: inherit;
padding: 8px 10px;
cursor: pointer;
&:hover:not(:disabled),
&.is-selected:not(:disabled) {
background: linear-gradient(
145deg,
var(--app-selection-surface-strong),
var(--app-selection-surface)
);
border-color: var(--app-selection-border);
}
&:disabled {
cursor: not-allowed;
opacity: 0.45;
}
}
.palette-command__label {
font-size: 0.9rem;
font-weight: 560;
letter-spacing: -0.006em;
}
.palette-command__description {
font-size: 0.76rem;
color: var(--mat-sys-on-surface-variant);
}
.palette-empty {
border: 1px dashed var(--mat-sys-outline-variant);
border-radius: 10px;
padding: 18px;
text-align: center;
color: var(--mat-sys-on-surface-variant);
font-size: 0.88rem;
}
@@ -0,0 +1,66 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog';
import { WorkspaceCommandPaletteComponent } from './workspace-command-palette.component';
describe('WorkspaceCommandPaletteComponent', () => {
let fixture: ComponentFixture<WorkspaceCommandPaletteComponent>;
let component: WorkspaceCommandPaletteComponent;
let dialogRef: { close: jest.Mock };
beforeEach(async () => {
dialogRef = {
close: jest.fn(),
};
await TestBed.configureTestingModule({
imports: [WorkspaceCommandPaletteComponent],
providers: [
{
provide: MatDialogRef,
useValue: dialogRef,
},
{
provide: MAT_DIALOG_DATA,
useValue: {
query: 'recent',
commands: [
{
id: 'global-search',
label: 'Search all Xtream playlists',
description: 'Open global search overlay',
scope: 'global',
enabled: true,
},
{
id: 'playlist-search',
label: 'Search this playlist',
description: 'Open playlist search route',
scope: 'playlist',
enabled: true,
},
],
},
},
],
}).compileComponents();
fixture = TestBed.createComponent(WorkspaceCommandPaletteComponent);
component = fixture.componentInstance;
fixture.detectChanges();
});
it('creates and applies initial query', () => {
expect(component).toBeTruthy();
expect(component.query()).toBe('recent');
});
it('closes with selected command and query on click', () => {
const command = component.flatCommands()[0];
component.onCommandClick(command);
expect(dialogRef.close).toHaveBeenCalledWith({
commandId: 'global-search',
query: 'recent',
});
});
});
@@ -0,0 +1,222 @@
import {
AfterViewInit,
Component,
ElementRef,
computed,
effect,
inject,
signal,
viewChild,
} from '@angular/core';
import {
MAT_DIALOG_DATA,
MatDialogModule,
MatDialogRef,
} from '@angular/material/dialog';
import { MatIconModule } from '@angular/material/icon';
export type WorkspaceCommandScope = 'global' | 'playlist' | 'section';
export type WorkspaceCommandId =
| 'global-search'
| 'playlist-search'
| 'open-global-favorites'
| 'open-downloads'
| 'open-global-recent';
export interface WorkspaceCommandItem {
id: WorkspaceCommandId;
label: string;
description: string;
scope: WorkspaceCommandScope;
enabled: boolean;
}
export interface WorkspaceCommandSelection {
commandId: WorkspaceCommandId;
query: string;
}
interface WorkspaceCommandPaletteData {
commands: WorkspaceCommandItem[];
query?: string;
}
interface WorkspaceCommandGroup {
scope: WorkspaceCommandScope;
title: string;
items: WorkspaceCommandItem[];
}
@Component({
selector: 'app-workspace-command-palette',
imports: [MatDialogModule, MatIconModule],
templateUrl: './workspace-command-palette.component.html',
styleUrl: './workspace-command-palette.component.scss',
})
export class WorkspaceCommandPaletteComponent implements AfterViewInit {
private readonly dialogRef = inject(
MatDialogRef<
WorkspaceCommandPaletteComponent,
WorkspaceCommandSelection | undefined
>
);
private readonly data =
inject<WorkspaceCommandPaletteData>(MAT_DIALOG_DATA);
private readonly queryInputRef =
viewChild<ElementRef<HTMLInputElement>>('queryInput');
readonly query = signal(this.data?.query ?? '');
readonly selectedIndex = signal(0);
readonly filteredCommands = computed(() => {
const term = this.query().trim().toLowerCase();
if (!term) {
return this.data.commands;
}
return this.data.commands.filter((command) => {
const haystack =
`${command.label} ${command.description}`.toLowerCase();
return haystack.includes(term);
});
});
readonly commandGroups = computed<WorkspaceCommandGroup[]>(() => {
const commands = this.filteredCommands();
const grouped: WorkspaceCommandGroup[] = [];
const buildGroup = (
scope: WorkspaceCommandScope,
title: string
): WorkspaceCommandGroup | null => {
const items = commands.filter((command) => command.scope === scope);
if (items.length === 0) {
return null;
}
return { scope, title, items };
};
const groups = [
buildGroup('global', 'Global actions'),
buildGroup('playlist', 'This playlist'),
buildGroup('section', 'This section'),
].filter((group): group is WorkspaceCommandGroup => group !== null);
grouped.push(...groups);
return grouped;
});
readonly flatCommands = computed(() =>
this.commandGroups().reduce<WorkspaceCommandItem[]>(
(items, group) => items.concat(group.items),
[]
)
);
constructor() {
effect(() => {
const items = this.flatCommands();
if (items.length === 0) {
this.selectedIndex.set(-1);
return;
}
const currentIndex = this.selectedIndex();
if (currentIndex < 0 || currentIndex >= items.length) {
this.selectedIndex.set(0);
}
});
}
ngAfterViewInit(): void {
queueMicrotask(() => {
this.queryInputRef()?.nativeElement.focus();
this.queryInputRef()?.nativeElement.select();
});
}
onQueryInput(event: Event): void {
const target = event.target as HTMLInputElement | null;
this.query.set(target?.value ?? '');
}
onInputKeydown(event: KeyboardEvent): void {
if (event.key === 'ArrowDown') {
event.preventDefault();
this.moveSelection(1);
return;
}
if (event.key === 'ArrowUp') {
event.preventDefault();
this.moveSelection(-1);
return;
}
if (event.key === 'Enter') {
event.preventDefault();
this.selectCurrent();
return;
}
if (event.key === 'Escape') {
event.preventDefault();
this.dialogRef.close();
}
}
onCommandHover(command: WorkspaceCommandItem): void {
const index = this.flatCommands().findIndex(
(item) => item.id === command.id
);
if (index >= 0) {
this.selectedIndex.set(index);
}
}
onCommandClick(command: WorkspaceCommandItem): void {
if (!command.enabled) {
return;
}
this.dialogRef.close({
commandId: command.id,
query: this.query().trim(),
});
}
isCommandSelected(command: WorkspaceCommandItem): boolean {
const index = this.flatCommands().findIndex(
(item) => item.id === command.id
);
return index >= 0 && this.selectedIndex() === index;
}
private moveSelection(direction: 1 | -1): void {
const items = this.flatCommands();
if (items.length === 0) {
return;
}
const currentIndex = this.selectedIndex();
const safeIndex = currentIndex < 0 ? 0 : currentIndex;
const nextIndex = (safeIndex + direction + items.length) % items.length;
this.selectedIndex.set(nextIndex);
}
private selectCurrent(): void {
const items = this.flatCommands();
if (items.length === 0) {
return;
}
const index = this.selectedIndex();
if (index < 0 || index >= items.length) {
return;
}
this.onCommandClick(items[index]);
}
}
@@ -2,16 +2,6 @@
<header class="context-header">
<div class="context-header__top">
<h2>{{ title() }}</h2>
@if (isXtreamCategories()) {
<button
mat-icon-button
[matTooltip]="'Manage categories'"
aria-label="Manage categories"
(click)="openXtreamCategoryManagement()"
>
<mat-icon>tune</mat-icon>
</button>
}
</div>
<!-- @if (subtitle(); as text) {
<span class="context-header__badge">{{ text }}</span>
@@ -23,7 +23,7 @@
.context-header__top {
display: flex;
align-items: center;
justify-content: space-between;
justify-content: flex-start;
gap: 8px;
h2 {
@@ -33,29 +33,6 @@
letter-spacing: -0.01em;
color: var(--mat-sys-on-surface);
}
button[mat-icon-button] {
width: 32px;
height: 32px;
padding: 0;
line-height: 32px;
border-radius: 8px;
color: var(--mat-sys-on-surface-variant);
transition:
background 0.15s ease,
color 0.15s ease;
&:hover {
background: var(--mat-sys-surface-container);
color: var(--mat-sys-on-surface);
}
mat-icon {
font-size: 18px;
width: 18px;
height: 18px;
}
}
}
.context-header__badge {
@@ -1,14 +1,6 @@
import { Component, computed, inject, input } from '@angular/core';
import { MatDialog } from '@angular/material/dialog';
import { MatIconButton } from '@angular/material/button';
import { MatIcon } from '@angular/material/icon';
import { MatTooltip } from '@angular/material/tooltip';
import { Router } from '@angular/router';
import { PlaylistErrorViewComponent } from '../xtream-electron/playlist-error-view/playlist-error-view.component';
import {
CategoryManagementDialogComponent,
CategoryManagementDialogData,
} from '../xtream-electron/category-management-dialog/category-management-dialog.component';
import { CategoryViewComponent } from '../xtream-electron/category-view/category-view.component';
import { XtreamStore } from '../xtream-electron/stores/xtream.store';
import { StalkerStore } from '../stalker/stalker.store';
@@ -27,19 +19,12 @@ interface XtreamCategoryLike {
@Component({
selector: 'app-workspace-context-panel',
imports: [
CategoryViewComponent,
MatIcon,
MatIconButton,
MatTooltip,
PlaylistErrorViewComponent,
],
imports: [CategoryViewComponent, PlaylistErrorViewComponent],
templateUrl: './workspace-context-panel.component.html',
styleUrl: './workspace-context-panel.component.scss',
})
export class WorkspaceContextPanelComponent {
private readonly router = inject(Router);
private readonly dialog = inject(MatDialog);
private readonly xtreamStore = inject(XtreamStore);
private readonly stalkerStore = inject(StalkerStore);
@@ -162,39 +147,4 @@ export class WorkspaceContextPanelComponent {
categoryId,
]);
}
openXtreamCategoryManagement(): void {
const context = this.context();
const section = this.section();
if (!this.isXtreamCategories()) {
return;
}
const contentType =
section === 'series'
? 'series'
: section === 'live'
? 'live'
: 'vod';
const dialogRef = this.dialog.open<
CategoryManagementDialogComponent,
CategoryManagementDialogData,
boolean
>(CategoryManagementDialogComponent, {
data: {
playlistId: context.playlistId,
contentType,
itemCounts: this.xtreamCategoryItemCounts(),
},
width: '500px',
maxHeight: '80vh',
});
dialogRef.afterClosed().subscribe((result) => {
if (result) {
this.xtreamStore.reloadCategories();
}
});
}
}
@@ -22,7 +22,7 @@ import { SettingsContextService } from './settings-context.service';
</button>
<p class="panel-header" style="padding-top: 8px;">
App preferences
{{ 'SETTINGS.APP_PREFERENCES' | translate }}
</p>
<div class="nav-list">
@for (section of ctx.sections(); track section.id) {
@@ -30,7 +30,7 @@ import { SettingsContextService } from './settings-context.service';
type="button"
class="nav-item"
[class.active]="ctx.activeSection() === section.id"
(click)="ctx.setActiveSection(section.id)"
(click)="ctx.navigateToSection(section.id)"
>
<mat-icon>{{ section.icon }}</mat-icon>
<span>{{ section.label | translate }}</span>
@@ -3,7 +3,7 @@
<a
class="brand"
routerLink="/workspace/dashboard"
matTooltip="Dashboard"
matTooltip="Dashboard (all playlists)"
[matTooltipPosition]="'right'"
aria-label="Open dashboard"
>
@@ -41,7 +41,7 @@
class="rail-shortcut"
routerLink="/workspace/settings"
[class.is-active]="isSettingsRoute()"
matTooltip="Settings"
matTooltip="Settings (global)"
[matTooltipPosition]="'right'"
aria-label="Open settings"
>
@@ -69,6 +69,16 @@
[placeholder]="searchPlaceholder()"
aria-label="Search"
/>
<button
type="button"
class="command-trigger"
mat-icon-button
(click)="openCommandPalette()"
matTooltip="Command palette (Ctrl/Cmd+K)"
aria-label="Open command palette"
>
<mat-icon>keyboard_command_key</mat-icon>
</button>
</label>
<div class="header-actions">
@@ -76,7 +86,7 @@
type="button"
mat-icon-button
[matMenuTriggerFor]="addPlaylistMenu().menu()"
[matTooltip]="'HOME.URL_UPLOAD.ADD_PLAYLIST' | translate"
matTooltip="Add playlist (all playlists)"
aria-label="Add playlist"
>
<mat-icon>add</mat-icon>
@@ -84,28 +94,16 @@
<app-add-playlist-menu
(playlistTypeSelected)="openAddPlaylistDialog($event)"
/>
@if (canOpenPlaylistInfo()) {
<button
type="button"
mat-icon-button
(click)="openPlaylistInfo()"
[matTooltip]="'PORTALS.SIDEBAR.PLAYLIST_INFO' | translate"
aria-label="Open playlist info"
>
<mat-icon>playlist_play</mat-icon>
</button>
}
@if (canOpenAccountInfo()) {
<button
type="button"
mat-icon-button
(click)="openAccountInfo()"
[matTooltip]="'PORTALS.SIDEBAR.ACCOUNT_INFO' | translate"
aria-label="Open account info"
>
<mat-icon>person</mat-icon>
</button>
}
<a
class="header-shortcut"
mat-icon-button
routerLink="/workspace/global-favorites"
[class.is-active]="isGlobalFavoritesRoute()"
matTooltip="Global favorites (all playlists)"
aria-label="Open global favorites"
>
<mat-icon>star</mat-icon>
</a>
@if (isElectron) {
<button
type="button"
@@ -113,91 +111,115 @@
mat-icon-button
[class.is-active]="isDownloadsView()"
(click)="openDownloadsShortcut()"
[matTooltip]="'DOWNLOADS.TITLE' | translate"
matTooltip="Downloads (all playlists)"
aria-label="Open downloads"
>
<mat-icon>download</mat-icon>
</button>
}
@if (headerBulkAction(); as action) {
<button
type="button"
mat-icon-button
(click)="runHeaderBulkAction()"
[matTooltip]="action.tooltip"
[disabled]="action.disabled"
[attr.aria-label]="action.ariaLabel"
>
<mat-icon>{{ action.icon }}</mat-icon>
</button>
}
<a
class="header-shortcut"
<button
type="button"
mat-icon-button
routerLink="/workspace/global-favorites"
[class.is-active]="isGlobalFavoritesRoute()"
[matTooltip]="'HOME.PLAYLISTS.GLOBAL_FAVORITES' | translate"
aria-label="Open global favorites"
[matMenuTriggerFor]="contextActionsMenu"
matTooltip="Context actions (this section)"
aria-label="Open context actions"
>
<mat-icon>star</mat-icon>
</a>
@if (canSort()) {
<button
type="button"
mat-icon-button
[matMenuTriggerFor]="sortMenu"
[matTooltip]="
('WORKSPACE.SORT_LABEL' | translate) +
contentSortLabel()
"
aria-label="Sorting options"
>
<mat-icon>sort</mat-icon>
</button>
<mat-menu #sortMenu="matMenu">
<button
mat-menu-item
(click)="setContentSortMode('date-desc')"
>
<span>{{
'WORKSPACE.SORT_DATE_DESC' | translate
}}</span>
@if (contentSortMode() === 'date-desc') {
<mat-icon>check</mat-icon>
}
</button>
<button
mat-menu-item
(click)="setContentSortMode('date-asc')"
>
<span>{{ 'WORKSPACE.SORT_DATE_ASC' | translate }}</span>
@if (contentSortMode() === 'date-asc') {
<mat-icon>check</mat-icon>
}
</button>
<button
mat-menu-item
(click)="setContentSortMode('name-asc')"
>
<span>{{ 'WORKSPACE.SORT_NAME_ASC' | translate }}</span>
@if (contentSortMode() === 'name-asc') {
<mat-icon>check</mat-icon>
}
</button>
<button
mat-menu-item
(click)="setContentSortMode('name-desc')"
>
<span>{{
'WORKSPACE.SORT_NAME_DESC' | translate
}}</span>
@if (contentSortMode() === 'name-desc') {
<mat-icon>check</mat-icon>
}
</button>
</mat-menu>
}
<mat-icon>more_horiz</mat-icon>
</button>
</div>
<mat-menu #contextActionsMenu="matMenu">
@if (!hasContextActions()) {
<button mat-menu-item disabled>
<span>No actions for this section</span>
</button>
} @else {
@if (contextActionGroups().hasPlaylistActions) {
@if (canOpenPlaylistInfo()) {
<button mat-menu-item (click)="openPlaylistInfo()">
<mat-icon>playlist_play</mat-icon>
<span>Playlist info (this playlist)</span>
</button>
}
@if (canOpenAccountInfo()) {
<button mat-menu-item (click)="openAccountInfo()">
<mat-icon>person</mat-icon>
<span>Account info (this playlist)</span>
</button>
}
}
@if (
contextActionGroups().hasPlaylistActions &&
(contextActionGroups().hasSectionActions ||
contextActionGroups().hasCleanupActions)
) {
<mat-divider />
}
@if (contextActionGroups().hasSectionActions) {
@if (canManageCategories()) {
<button mat-menu-item (click)="openManageCategories()">
<mat-icon>tune</mat-icon>
<span>Manage categories (this section)</span>
</button>
}
@if (canSort()) {
<button mat-menu-item [matMenuTriggerFor]="sortMenu">
<mat-icon>sort</mat-icon>
<span>Sort content (this section)</span>
</button>
}
}
@if (
contextActionGroups().hasCleanupActions &&
(contextActionGroups().hasPlaylistActions ||
contextActionGroups().hasSectionActions)
) {
<mat-divider />
}
@if (headerBulkAction(); as action) {
<button
mat-menu-item
(click)="runHeaderBulkAction()"
[disabled]="action.disabled"
[attr.aria-label]="action.ariaLabel"
>
<mat-icon>{{ action.icon }}</mat-icon>
<span>{{ action.tooltip }}</span>
</button>
}
}
</mat-menu>
<mat-menu #sortMenu="matMenu">
<button mat-menu-item (click)="setContentSortMode('date-desc')">
<span>{{ 'WORKSPACE.SORT_DATE_DESC' | translate }}</span>
@if (contentSortMode() === 'date-desc') {
<mat-icon>check</mat-icon>
}
</button>
<button mat-menu-item (click)="setContentSortMode('date-asc')">
<span>{{ 'WORKSPACE.SORT_DATE_ASC' | translate }}</span>
@if (contentSortMode() === 'date-asc') {
<mat-icon>check</mat-icon>
}
</button>
<button mat-menu-item (click)="setContentSortMode('name-asc')">
<span>{{ 'WORKSPACE.SORT_NAME_ASC' | translate }}</span>
@if (contentSortMode() === 'name-asc') {
<mat-icon>check</mat-icon>
}
</button>
<button mat-menu-item (click)="setContentSortMode('name-desc')">
<span>{{ 'WORKSPACE.SORT_NAME_DESC' | translate }}</span>
@if (contentSortMode() === 'name-desc') {
<mat-icon>check</mat-icon>
}
</button>
</mat-menu>
</header>
<div class="workspace-body" [class.single-pane]="!showContextPanel()">
@@ -27,7 +27,8 @@
align-items: center;
gap: 2px;
padding: 10px 8px;
border-right: 1px solid var(--app-rail-border, var(--mat-sys-outline-variant));
border-right: 1px solid
var(--app-rail-border, var(--mat-sys-outline-variant));
background: var(--app-rail-bg, var(--mat-sys-surface-container-low));
app-region: drag;
overflow-y: auto;
@@ -225,6 +226,27 @@
opacity: 0.7;
}
}
.command-trigger {
width: 28px;
height: 28px;
padding: 0;
margin-right: -4px;
color: var(--mat-sys-on-surface-variant);
border-radius: 8px;
flex-shrink: 0;
&:hover {
background: var(--mat-sys-surface-container-high);
color: var(--mat-sys-on-surface);
}
mat-icon {
font-size: 16px;
width: 16px;
height: 16px;
}
}
}
// ─── Header Actions ───────────────────────────────────────────────────────────
@@ -396,6 +418,13 @@
box-shadow: -4px 8px 20px rgba(0, 0, 0, 0.18);
}
// ─── Dialog Surfaces ──────────────────────────────────────────────────────────
:host ::ng-deep .workspace-command-palette-overlay .mat-mdc-dialog-surface {
background: transparent;
box-shadow: none;
overflow: visible;
}
// ─── Responsive ───────────────────────────────────────────────────────────────
@media (max-width: 780px) {
.workspace-shell {
@@ -0,0 +1,142 @@
import { signal } from '@angular/core';
import { TestBed } from '@angular/core/testing';
import { MatDialog } from '@angular/material/dialog';
import { NavigationEnd, Router } from '@angular/router';
import { Store } from '@ngrx/store';
import { of } from 'rxjs';
import { PlaylistsService } from 'services';
import { DownloadsService } from '../services/downloads.service';
import { XtreamStore } from '../xtream-electron/stores/xtream.store';
import { FavoritesContextService } from './favorites-context.service';
import { SettingsContextService } from './settings-context.service';
import { WorkspaceShellComponent } from './workspace-shell.component';
class MockXtreamStore {
readonly contentSortMode = signal<
'date-desc' | 'date-asc' | 'name-asc' | 'name-desc'
>('date-desc');
readonly getCategoryItemCounts = signal(new Map<number, number>());
readonly recentItems = signal<any[]>([]);
setContentSortMode = jest.fn();
setSearchTerm = jest.fn();
setCategorySearchTerm = jest.fn();
reloadCategories = jest.fn();
}
describe('WorkspaceShellComponent action matrix', () => {
beforeEach(() => {
(window as any).electron = { platform: 'darwin' };
const selectSignal = jest
.fn()
.mockReturnValueOnce(signal('Playlist A'))
.mockReturnValueOnce(
signal({
_id: 'pl-1',
serverUrl: 'http://example.com',
title: 'Playlist A',
})
)
.mockReturnValueOnce(
signal([
{ _id: 'pl-1', serverUrl: 'http://example.com' },
{ _id: 'pl-2', serverUrl: 'http://example.com' },
])
);
TestBed.configureTestingModule({
providers: [
{
provide: Router,
useValue: {
url: '/workspace/xtreams/pl-1/vod',
events: of(
new NavigationEnd(
1,
'/workspace/xtreams/pl-1/vod',
'/workspace/xtreams/pl-1/vod'
)
),
navigate: jest.fn(),
navigateByUrl: jest.fn(),
parseUrl: jest
.fn()
.mockReturnValue({ queryParams: {} }),
createUrlTree: jest.fn(),
isActive: jest.fn(),
},
},
{
provide: Store,
useValue: {
selectSignal,
},
},
{
provide: XtreamStore,
useClass: MockXtreamStore,
},
{
provide: DownloadsService,
useValue: {
downloads: signal([]),
clearCompleted: jest.fn().mockResolvedValue(undefined),
loadDownloads: jest.fn().mockResolvedValue(undefined),
},
},
{
provide: PlaylistsService,
useValue: {
clearPortalRecentlyViewed: jest
.fn()
.mockReturnValue(of(undefined)),
},
},
{
provide: MatDialog,
useValue: {
open: jest.fn().mockReturnValue({
afterClosed: () => of(undefined),
}),
},
},
{
provide: FavoritesContextService,
useValue: {},
},
{
provide: SettingsContextService,
useValue: {},
},
],
});
});
it('shows manage categories action for xtream category sections', () => {
const component = TestBed.runInInjectionContext(
() => new WorkspaceShellComponent()
);
component.currentUrl.set('/workspace/xtreams/pl-1/vod');
expect(component.canManageCategories()).toBe(true);
expect(component.contextActionGroups().hasSectionActions).toBe(true);
});
it('shows cleanup action for recent sections', () => {
const component = TestBed.runInInjectionContext(
() => new WorkspaceShellComponent()
);
component.currentUrl.set('/workspace/stalker/pl-1/recent');
expect(component.headerBulkAction()).toEqual(
expect.objectContaining({
icon: 'delete_sweep',
tooltip: 'Clear recently viewed',
})
);
expect(component.hasContextActions()).toBe(true);
});
});
@@ -9,6 +9,7 @@ import {
} from '@angular/core';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { MatIconButton } from '@angular/material/button';
import { MatDividerModule } from '@angular/material/divider';
import { MatDialog } from '@angular/material/dialog';
import { MatIcon } from '@angular/material/icon';
import { MatMenuModule } from '@angular/material/menu';
@@ -43,10 +44,21 @@ import {
} from '../shared/navigation/portal-rail-links';
import { PortalRailLinksComponent } from '../shared/navigation/portal-rail-links.component';
import { AccountInfoComponent } from '../xtream-electron/account-info/account-info.component';
import {
CategoryManagementDialogComponent,
CategoryManagementDialogData,
} from '../xtream-electron/category-management-dialog/category-management-dialog.component';
import { GlobalRecentlyViewedComponent } from '../xtream-electron/recently-viewed/global-recently-viewed.component';
import { GlobalSearchResultsComponent } from '../xtream-electron/search-results/global-search-results.component';
import { XtreamCategorySortMode } from '../xtream-electron/stores/features/with-selection.feature';
import { XtreamStore } from '../xtream-electron/stores/xtream.store';
import { FavoritesContextService } from './favorites-context.service';
import { SettingsContextService } from './settings-context.service';
import {
WorkspaceCommandItem,
WorkspaceCommandPaletteComponent,
WorkspaceCommandSelection,
} from './workspace-command-palette.component';
import { WorkspaceContextPanelComponent } from './workspace-context-panel.component';
import { WorkspaceFavoritesContextPanelComponent } from './workspace-favorites-context-panel.component';
import { WorkspaceSettingsContextPanelComponent } from './workspace-settings-context-panel.component';
@@ -64,6 +76,12 @@ interface WorkspaceHeaderBulkAction {
disabled: boolean;
}
interface WorkspaceContextActionGroup {
hasPlaylistActions: boolean;
hasSectionActions: boolean;
hasCleanupActions: boolean;
}
const XTREAM_CATEGORY_SORT_STORAGE_KEY = 'xtream-category-sort-mode';
const SEARCH_INPUT_DEBOUNCE_MS = 350;
@@ -72,6 +90,7 @@ const SEARCH_INPUT_DEBOUNCE_MS = 350;
imports: [
MatIcon,
MatIconButton,
MatDividerModule,
MatMenuModule,
MatTooltip,
AddPlaylistMenuComponent,
@@ -102,6 +121,16 @@ export class WorkspaceShellComponent {
private searchDebounceTimeoutId: ReturnType<typeof setTimeout> | null =
null;
private readonly onDocumentKeydown = (event: KeyboardEvent): void => {
if (!(event.ctrlKey || event.metaKey)) {
return;
}
if (event.key.toLowerCase() === 'k') {
event.preventDefault();
this.openCommandPalette();
}
};
readonly addPlaylistMenu = viewChild.required(AddPlaylistMenuComponent);
@@ -120,13 +149,13 @@ export class WorkspaceShellComponent {
readonly workspaceLinks: PortalRailLink[] = [
{
icon: 'dashboard',
tooltip: 'Dashboard',
tooltip: 'Dashboard (all playlists)',
path: ['/workspace/dashboard'],
exact: true,
},
{
icon: 'library_books',
tooltip: 'Sources',
tooltip: 'Sources (all playlists)',
path: ['/workspace/sources'],
},
];
@@ -255,7 +284,7 @@ export class WorkspaceShellComponent {
});
readonly searchPlaceholder = computed(() => {
if (this.isSourcesRoute()) {
return 'Search sources...';
return 'Search sources (all playlists)...';
}
const context = this.currentContext();
@@ -263,46 +292,47 @@ export class WorkspaceShellComponent {
if (!context) {
if (this.dashboardXtreamContext()) {
return 'Search in active playlist...';
return 'Search in this playlist...';
}
return 'Search channels, movies, series...';
return 'Search in this playlist...';
}
if (context.provider === 'xtreams') {
if (section === 'search') {
return 'Search in playlist content...';
return 'Search in this playlist...';
}
if (section === 'vod' || section === 'series') {
return 'Search in current category...';
if (
section === 'vod' ||
section === 'series' ||
section === 'live'
) {
return 'Search in this section...';
}
if (section === 'favorites') {
return 'Filter favorites...';
return 'Filter this section...';
}
if (section === 'recent') {
return 'Filter recently viewed...';
return 'Filter this section...';
}
if (section === 'recently-added') {
return 'Filter recently added...';
return 'Filter this section...';
}
if (section === 'downloads') {
return 'Filter downloads...';
return 'Filter this section...';
}
}
if (context.provider === 'stalker') {
if (section === 'search') {
return 'Search movies and series in this portal...';
return 'Search in this playlist...';
}
if (section === 'favorites') {
return 'Filter favorites...';
if (section === 'favorites' || section === 'recent') {
return 'Filter this section...';
}
if (section === 'recent') {
return 'Filter recently viewed...';
}
return 'Search this portal...';
return 'Search in this section...';
}
return 'Search is not available for this section';
return 'Search in this playlist...';
});
readonly canSort = computed(() => {
const context = this.currentContext();
@@ -350,6 +380,14 @@ export class WorkspaceShellComponent {
readonly canOpenAccountInfo = computed(() =>
Boolean(this.activePlaylist()?.serverUrl)
);
readonly canManageCategories = computed(() => {
const context = this.currentContext();
const section = this.currentSection();
return (
context?.provider === 'xtreams' &&
(section === 'vod' || section === 'series' || section === 'live')
);
});
readonly headerBulkAction = computed<WorkspaceHeaderBulkAction | null>(
() => {
const context = this.currentContext();
@@ -375,8 +413,12 @@ export class WorkspaceShellComponent {
);
return {
icon: 'delete_sweep',
tooltip: 'Clear completed downloads',
ariaLabel: 'Clear completed downloads',
tooltip: isGlobalDownloads
? 'Clear completed downloads (all playlists)'
: 'Clear completed downloads (this playlist)',
ariaLabel: isGlobalDownloads
? 'Clear completed downloads for all playlists'
: 'Clear completed downloads for this playlist',
disabled: !hasClearable,
};
}
@@ -388,8 +430,8 @@ export class WorkspaceShellComponent {
if (context.provider === 'xtreams' && section === 'recent') {
return {
icon: 'delete_sweep',
tooltip: 'Clear recently viewed',
ariaLabel: 'Clear recently viewed',
tooltip: 'Clear recently viewed (this section)',
ariaLabel: 'Clear recently viewed for this section',
disabled: this.xtreamStore.recentItems().length === 0,
};
}
@@ -397,8 +439,8 @@ export class WorkspaceShellComponent {
if (context.provider === 'stalker' && section === 'recent') {
return {
icon: 'delete_sweep',
tooltip: 'Clear recently viewed',
ariaLabel: 'Clear recently viewed',
tooltip: 'Clear recently viewed (this section)',
ariaLabel: 'Clear recently viewed for this section',
disabled: false,
};
}
@@ -406,6 +448,25 @@ export class WorkspaceShellComponent {
return null;
}
);
readonly contextActionGroups = computed<WorkspaceContextActionGroup>(() => {
const hasPlaylistActions =
this.canOpenPlaylistInfo() || this.canOpenAccountInfo();
const hasSectionActions = this.canManageCategories() || this.canSort();
const hasCleanupActions = Boolean(this.headerBulkAction());
return {
hasPlaylistActions,
hasSectionActions,
hasCleanupActions,
};
});
readonly hasContextActions = computed(() => {
const groups = this.contextActionGroups();
return (
groups.hasPlaylistActions ||
groups.hasSectionActions ||
groups.hasCleanupActions
);
});
constructor() {
this.destroyRef.onDestroy(() => {
@@ -413,8 +474,12 @@ export class WorkspaceShellComponent {
clearTimeout(this.searchDebounceTimeoutId);
this.searchDebounceTimeoutId = null;
}
document.removeEventListener('keydown', this.onDocumentKeydown);
});
document.addEventListener('keydown', this.onDocumentKeydown);
const savedSortMode = localStorage.getItem(
XTREAM_CATEGORY_SORT_STORAGE_KEY
);
@@ -553,6 +618,33 @@ export class WorkspaceShellComponent {
);
}
openCommandPalette(): void {
const dialogRef = this.dialog.open<
WorkspaceCommandPaletteComponent,
{ commands: WorkspaceCommandItem[]; query: string },
WorkspaceCommandSelection | undefined
>(WorkspaceCommandPaletteComponent, {
width: 'min(760px, 92vw)',
maxWidth: '92vw',
panelClass: 'workspace-command-palette-overlay',
autoFocus: false,
data: {
commands: this.getCommandPaletteItems(),
query: this.searchQuery(),
},
});
dialogRef
.afterClosed()
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe((selection) => {
if (!selection) {
return;
}
this.runCommandPaletteAction(selection);
});
}
async runHeaderBulkAction(): Promise<void> {
const context = this.currentContext();
const section = this.currentSection();
@@ -590,10 +682,77 @@ export class WorkspaceShellComponent {
}
}
openManageCategories(): void {
const context = this.currentContext();
const section = this.currentSection();
if (
!context ||
context.provider !== 'xtreams' ||
(section !== 'vod' && section !== 'series' && section !== 'live')
) {
return;
}
const contentType =
section === 'series'
? 'series'
: section === 'live'
? 'live'
: 'vod';
const dialogRef = this.dialog.open<
CategoryManagementDialogComponent,
CategoryManagementDialogData,
boolean
>(CategoryManagementDialogComponent, {
data: {
playlistId: context.playlistId,
contentType,
itemCounts: this.xtreamStore.getCategoryItemCounts(),
},
width: '500px',
maxHeight: '80vh',
});
dialogRef
.afterClosed()
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe((result) => {
if (result) {
this.xtreamStore.reloadCategories();
}
});
}
openDownloadsShortcut(): void {
this.router.navigate(['/workspace/downloads']);
}
openGlobalSearch(initialQuery = ''): void {
this.dialog.open(GlobalSearchResultsComponent, {
width: '100%',
height: '100%',
maxWidth: '100%',
panelClass: 'global-search-overlay',
data: {
isGlobalSearch: true,
initialQuery,
},
});
}
openGlobalRecent(): void {
this.dialog.open(GlobalRecentlyViewedComponent, {
width: '100%',
height: '100%',
maxWidth: '100%',
panelClass: 'global-search-overlay',
data: { isGlobal: true },
hasBackdrop: true,
disableClose: false,
});
}
openPlaylistInfo(): void {
const playlist = this.activePlaylist();
if (!playlist) {
@@ -627,6 +786,130 @@ export class WorkspaceShellComponent {
localStorage.setItem(XTREAM_CATEGORY_SORT_STORAGE_KEY, mode);
}
private getCommandPaletteItems(): WorkspaceCommandItem[] {
const context = this.currentContext();
const dashboardXtream = this.dashboardXtreamContext();
const hasXtreamPlaylists = this.playlists().some(
(playlist) => !!playlist.serverUrl
);
return [
{
id: 'global-search',
label: 'Search all Xtream playlists',
description: 'Open global search overlay',
scope: 'global',
enabled: hasXtreamPlaylists,
},
{
id: 'playlist-search',
label: 'Search this playlist',
description: 'Open playlist search route',
scope: 'playlist',
enabled: Boolean(
dashboardXtream ||
(context &&
(context.provider === 'xtreams' ||
context.provider === 'stalker'))
),
},
{
id: 'open-global-favorites',
label: 'Open global favorites',
description: 'Navigate to aggregated favorites',
scope: 'global',
enabled: true,
},
{
id: 'open-downloads',
label: 'Open downloads',
description: 'Navigate to downloads view',
scope: 'global',
enabled: this.isElectron,
},
{
id: 'open-global-recent',
label: 'Open recently viewed',
description: 'Open global recently viewed overlay',
scope: 'global',
enabled: true,
},
];
}
private runCommandPaletteAction(
selection: WorkspaceCommandSelection
): void {
const query = selection.query.trim();
if (selection.commandId === 'global-search') {
this.openGlobalSearch(query);
return;
}
if (selection.commandId === 'playlist-search') {
this.openPlaylistSearchFromPalette(query);
return;
}
if (selection.commandId === 'open-global-favorites') {
this.router.navigate(['/workspace/global-favorites']);
return;
}
if (selection.commandId === 'open-downloads') {
this.openDownloadsShortcut();
return;
}
if (selection.commandId === 'open-global-recent') {
this.openGlobalRecent();
}
}
private openPlaylistSearchFromPalette(query: string): void {
const context = this.currentContext();
const dashboardXtream = this.dashboardXtreamContext();
const effectiveContext = dashboardXtream ?? context;
if (!effectiveContext) {
return;
}
this.searchQuery.set(query);
this.appliedSearchQuery.set(query);
if (effectiveContext.provider === 'xtreams') {
this.xtreamStore.setSearchTerm(query);
this.router.navigate(
[
'/workspace',
'xtreams',
effectiveContext.playlistId,
'search',
],
{
queryParams: query ? { q: query } : {},
}
);
return;
}
if (effectiveContext.provider === 'stalker') {
this.router.navigate(
[
'/workspace',
'stalker',
effectiveContext.playlistId,
'search',
],
{
queryParams: query ? { q: query } : {},
}
);
}
}
private parseWorkspaceContext(url: string): WorkspaceContext | null {
const match = url.match(
/^\/workspace\/(xtreams|stalker|playlists)\/([^\/\?]+)/