mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-14 05:06:16 -08:00
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:
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)\/([^\/\?]+)/
|
||||
|
||||
Reference in new issue
Block a user