diff --git a/apps/web/src/app/workspace/workspace-command-palette.component.html b/apps/web/src/app/workspace/workspace-command-palette.component.html new file mode 100644 index 000000000..14f3ce9e2 --- /dev/null +++ b/apps/web/src/app/workspace/workspace-command-palette.component.html @@ -0,0 +1,52 @@ +
+
+

Command palette

+

Global and contextual actions

+
+ + + +
+ @if (commandGroups().length === 0) { +
No commands found
+ } @else { + @for (group of commandGroups(); track group.scope) { +
+

{{ group.title }}

+
+ @for (command of group.items; track command.id) { + + } +
+
+ } + } +
+
diff --git a/apps/web/src/app/workspace/workspace-command-palette.component.scss b/apps/web/src/app/workspace/workspace-command-palette.component.scss new file mode 100644 index 000000000..a60ae5d24 --- /dev/null +++ b/apps/web/src/app/workspace/workspace-command-palette.component.scss @@ -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; +} diff --git a/apps/web/src/app/workspace/workspace-command-palette.component.spec.ts b/apps/web/src/app/workspace/workspace-command-palette.component.spec.ts new file mode 100644 index 000000000..14a95abf2 --- /dev/null +++ b/apps/web/src/app/workspace/workspace-command-palette.component.spec.ts @@ -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; + 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', + }); + }); +}); diff --git a/apps/web/src/app/workspace/workspace-command-palette.component.ts b/apps/web/src/app/workspace/workspace-command-palette.component.ts new file mode 100644 index 000000000..8d83180ab --- /dev/null +++ b/apps/web/src/app/workspace/workspace-command-palette.component.ts @@ -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(MAT_DIALOG_DATA); + + private readonly queryInputRef = + viewChild>('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(() => { + 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( + (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]); + } +} diff --git a/apps/web/src/app/workspace/workspace-context-panel.component.html b/apps/web/src/app/workspace/workspace-context-panel.component.html index c6c4d139f..338491b9c 100644 --- a/apps/web/src/app/workspace/workspace-context-panel.component.html +++ b/apps/web/src/app/workspace/workspace-context-panel.component.html @@ -2,16 +2,6 @@

{{ title() }}

- @if (isXtreamCategories()) { - - }