mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-14 05:06:16 -08:00
feat(search): add workspace global search with M3U support
This commit is contained in:
1 parent
ffc07146fe
commit
12d931f4cc
63 files changed
+2359
-374
No files matched your search
+1
@@ -29,6 +29,7 @@
|
||||
</span>
|
||||
}
|
||||
<input
|
||||
#searchInput
|
||||
type="search"
|
||||
spellcheck="false"
|
||||
[value]="searchQuery()"
|
||||
|
||||
+13
@@ -93,6 +93,19 @@ describe('WorkspaceShellHeaderComponent', () => {
|
||||
expect(emitted).toEqual(['matrix']);
|
||||
});
|
||||
|
||||
it('focuses and selects the search input on request', () => {
|
||||
const input: HTMLInputElement =
|
||||
fixture.nativeElement.querySelector('input[type="search"]');
|
||||
input.value = 'matrix';
|
||||
input.blur();
|
||||
|
||||
component.focusSearchInput({ select: true });
|
||||
|
||||
expect(document.activeElement).toBe(input);
|
||||
expect(input.selectionStart).toBe(0);
|
||||
expect(input.selectionEnd).toBe('matrix'.length);
|
||||
});
|
||||
|
||||
it('emits add playlist requests when the toolbar add button is clicked', () => {
|
||||
const requested = jest.fn();
|
||||
component.addPlaylistRequested.subscribe(requested);
|
||||
|
||||
+22
@@ -1,8 +1,10 @@
|
||||
import {
|
||||
ChangeDetectionStrategy,
|
||||
Component,
|
||||
ElementRef,
|
||||
input,
|
||||
output,
|
||||
viewChild,
|
||||
} from '@angular/core';
|
||||
import { MatIconButton } from '@angular/material/button';
|
||||
import { MatIcon } from '@angular/material/icon';
|
||||
@@ -26,6 +28,9 @@ import { WorkspaceHeaderBulkAction } from '../../services/helpers/workspace-shel
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
})
|
||||
export class WorkspaceShellHeaderComponent {
|
||||
private readonly searchInput =
|
||||
viewChild<ElementRef<HTMLInputElement>>('searchInput');
|
||||
|
||||
readonly isMac =
|
||||
typeof navigator !== 'undefined' &&
|
||||
/Mac|iPhone|iPad|iPod/i.test(navigator.platform);
|
||||
@@ -70,6 +75,23 @@ export class WorkspaceShellHeaderComponent {
|
||||
readonly playlistInfoRequested = output<void>();
|
||||
readonly accountInfoRequested = output<void>();
|
||||
|
||||
focusSearchInput(options: { select?: boolean } = {}): void {
|
||||
const inputElement = this.searchInput()?.nativeElement;
|
||||
if (!inputElement || inputElement.disabled) {
|
||||
return;
|
||||
}
|
||||
|
||||
inputElement.focus();
|
||||
if (options.select) {
|
||||
inputElement.select();
|
||||
}
|
||||
}
|
||||
|
||||
containsSearchInput(target: EventTarget | null): boolean {
|
||||
const inputElement = this.searchInput()?.nativeElement;
|
||||
return !!inputElement && target === inputElement;
|
||||
}
|
||||
|
||||
onSearchInput(event: Event): void {
|
||||
const target = event.target as HTMLInputElement | null;
|
||||
this.searchChanged.emit(target?.value ?? '');
|
||||
|
||||
+4
-4
@@ -27,7 +27,7 @@ export interface CommandBuilderContext {
|
||||
context: WorkspacePortalContext | null;
|
||||
section: PortalRailSection | null;
|
||||
hasActivePlaylist: boolean;
|
||||
hasXtreamPlaylists: boolean;
|
||||
hasSearchablePlaylists: boolean;
|
||||
canRefreshPlaylist: boolean;
|
||||
supportsDownloads: boolean;
|
||||
showDashboard: boolean;
|
||||
@@ -141,7 +141,7 @@ export function getGlobalCommandDefinitions(
|
||||
): WorkspaceCommandContribution[] {
|
||||
const {
|
||||
route,
|
||||
hasXtreamPlaylists,
|
||||
hasSearchablePlaylists,
|
||||
supportsDownloads,
|
||||
showDashboard,
|
||||
actions,
|
||||
@@ -156,8 +156,8 @@ export function getGlobalCommandDefinitions(
|
||||
descriptionKey:
|
||||
'WORKSPACE.SHELL.COMMANDS.GLOBAL_SEARCH_DESCRIPTION',
|
||||
priority: 10,
|
||||
visible: hasXtreamPlaylists,
|
||||
keywords: ['xtream'],
|
||||
visible: hasSearchablePlaylists && route.kind !== 'global-search',
|
||||
keywords: ['xtream', 'm3u', 'live'],
|
||||
run: ({ query }) => actions.openGlobalSearch(query),
|
||||
},
|
||||
{
|
||||
|
||||
+4
@@ -130,6 +130,10 @@ export function resolveSearchScopeLabel(ctx: SearchScopeContext): string {
|
||||
return translate('PORTALS.RECENTLY_VIEWED');
|
||||
}
|
||||
|
||||
if (kind === 'global-search') {
|
||||
return translate('WORKSPACE.SHELL.RAIL_GLOBAL_SEARCH');
|
||||
}
|
||||
|
||||
if (kind === 'downloads') {
|
||||
return translate('WORKSPACE.SHELL.RAIL_DOWNLOADS');
|
||||
}
|
||||
|
||||
+8
@@ -84,6 +84,13 @@ export class WorkspaceShellRouteStateService {
|
||||
path: ['/workspace/sources'],
|
||||
});
|
||||
|
||||
links.push({
|
||||
icon: 'search',
|
||||
tooltip: this.translateText('WORKSPACE.SHELL.RAIL_GLOBAL_SEARCH'),
|
||||
path: ['/workspace/search'],
|
||||
exact: true,
|
||||
});
|
||||
|
||||
links.push({
|
||||
icon: 'favorite',
|
||||
tooltip: this.translateText('HOME.PLAYLISTS.GLOBAL_FAVORITES'),
|
||||
@@ -125,6 +132,7 @@ export class WorkspaceShellRouteStateService {
|
||||
currentRoute.kind !== 'settings' &&
|
||||
currentRoute.kind !== 'global-favorites' &&
|
||||
currentRoute.kind !== 'global-recent' &&
|
||||
currentRoute.kind !== 'global-search' &&
|
||||
currentRoute.kind !== 'downloads'
|
||||
) {
|
||||
return null;
|
||||
|
||||
+30
@@ -82,6 +82,36 @@ export class WorkspaceShellSearchService {
|
||||
};
|
||||
}
|
||||
|
||||
if (route.kind === 'global-search') {
|
||||
const hasSearchablePlaylists = this.routeState.playlists().some(
|
||||
(playlist) =>
|
||||
!!playlist.serverUrl ||
|
||||
(!playlist.macAddress &&
|
||||
(!!playlist.filePath ||
|
||||
!!playlist.url ||
|
||||
!playlist.serverUrl))
|
||||
);
|
||||
|
||||
return {
|
||||
enabled: hasSearchablePlaylists,
|
||||
behavior: hasSearchablePlaylists
|
||||
? 'remote-search'
|
||||
: 'disabled',
|
||||
context: null,
|
||||
section: null,
|
||||
searchMode: hasSearchablePlaylists
|
||||
? 'remote-search'
|
||||
: 'none',
|
||||
placeholderKey: 'WORKSPACE.SHELL.SEARCH_GLOBAL_PLACEHOLDER',
|
||||
scopeLabel: this.translateText(
|
||||
'WORKSPACE.SHELL.RAIL_GLOBAL_SEARCH'
|
||||
),
|
||||
statusLabel: '',
|
||||
minLength: hasSearchablePlaylists ? 2 : 0,
|
||||
advancedRouteTarget: null,
|
||||
};
|
||||
}
|
||||
|
||||
if (route.searchMode === 'none') {
|
||||
return {
|
||||
enabled: false,
|
||||
|
||||
+30
@@ -653,6 +653,12 @@ describe('WorkspaceShellFacade', () => {
|
||||
tooltip: 'WORKSPACE.SHELL.RAIL_SOURCES',
|
||||
path: ['/workspace/sources'],
|
||||
},
|
||||
{
|
||||
icon: 'search',
|
||||
tooltip: 'WORKSPACE.SHELL.RAIL_GLOBAL_SEARCH',
|
||||
path: ['/workspace/search'],
|
||||
exact: true,
|
||||
},
|
||||
{
|
||||
icon: 'favorite',
|
||||
tooltip: 'HOME.PLAYLISTS.GLOBAL_FAVORITES',
|
||||
@@ -706,6 +712,30 @@ describe('WorkspaceShellFacade', () => {
|
||||
expect(commands.every((command) => command.enabled)).toBe(true);
|
||||
});
|
||||
|
||||
it('shows the global search command for M3U-only sources', () => {
|
||||
activePlaylistSignal.set({
|
||||
_id: 'pl-m3u',
|
||||
title: 'Playlist M3U',
|
||||
count: 10,
|
||||
importDate: '2026-04-22T10:00:00.000Z',
|
||||
autoRefresh: false,
|
||||
});
|
||||
playlistsSignal.set([
|
||||
{
|
||||
_id: 'pl-m3u',
|
||||
title: 'Playlist M3U',
|
||||
count: 10,
|
||||
importDate: '2026-04-22T10:00:00.000Z',
|
||||
autoRefresh: false,
|
||||
},
|
||||
]);
|
||||
facade.currentUrl.set('/workspace/dashboard');
|
||||
|
||||
expect(facade.commandPaletteCommands().map((command) => command.id)).toContain(
|
||||
'global-search'
|
||||
);
|
||||
});
|
||||
|
||||
it('hides the downloads command when downloads are unsupported', () => {
|
||||
runtime.supportsDownloads = false;
|
||||
activePlaylistSignal.set(null);
|
||||
|
||||
+7
-2
@@ -219,8 +219,13 @@ export class WorkspaceShellFacade {
|
||||
context: this.currentContext(),
|
||||
section: this.currentSection(),
|
||||
hasActivePlaylist: !!this.activePlaylist(),
|
||||
hasXtreamPlaylists: this.playlists().some(
|
||||
(playlist) => !!playlist.serverUrl
|
||||
hasSearchablePlaylists: this.playlists().some(
|
||||
(playlist) =>
|
||||
!!playlist.serverUrl ||
|
||||
(!playlist.macAddress &&
|
||||
(!!playlist.filePath ||
|
||||
!!playlist.url ||
|
||||
!playlist.serverUrl))
|
||||
),
|
||||
canRefreshPlaylist: this.canRefreshPlaylist(),
|
||||
supportsDownloads: this.supportsDownloads,
|
||||
|
||||
@@ -22,6 +22,7 @@
|
||||
/>
|
||||
|
||||
<app-workspace-shell-header
|
||||
#workspaceHeader
|
||||
[playlistTitle]="facade.playlistTitle()"
|
||||
[playlistSubtitle]="facade.playlistSubtitle()"
|
||||
[canOpenPlaylistInfo]="facade.canOpenPlaylistInfo()"
|
||||
|
||||
+59
@@ -72,6 +72,9 @@ class MockWorkspaceShellHeaderComponent {
|
||||
readonly headerBulkActionRequested = output<void>();
|
||||
readonly playlistInfoRequested = output<void>();
|
||||
readonly accountInfoRequested = output<void>();
|
||||
|
||||
focusSearchInput = jest.fn();
|
||||
containsSearchInput = jest.fn(() => false);
|
||||
}
|
||||
|
||||
@Component({
|
||||
@@ -187,6 +190,7 @@ class MockWorkspaceShellFacade {
|
||||
onSearchInput = jest.fn();
|
||||
onSearchEnter = jest.fn();
|
||||
openCommandPalette = jest.fn();
|
||||
openGlobalSearch = jest.fn();
|
||||
openAddPlaylistDialog = jest.fn();
|
||||
runHeaderShortcut = jest.fn();
|
||||
refreshCurrentPlaylist = jest.fn();
|
||||
@@ -350,4 +354,59 @@ describe('WorkspaceShellComponent', () => {
|
||||
|
||||
expect(shortcutsService.openShortcutsDialog).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('opens the routed global search and focuses header search on Ctrl/Cmd+F', async () => {
|
||||
jest.useFakeTimers();
|
||||
const facade = new MockWorkspaceShellFacade();
|
||||
|
||||
await TestBed.configureTestingModule({
|
||||
imports: [WorkspaceShellComponent],
|
||||
providers: [provideRouter([])],
|
||||
})
|
||||
.overrideComponent(WorkspaceShellComponent, {
|
||||
set: {
|
||||
imports: [
|
||||
RouterOutlet,
|
||||
MockExternalPlaybackDockComponent,
|
||||
MockPlaylistDropOverlayComponent,
|
||||
MockPlaylistDropZoneDirective,
|
||||
MockWorkspaceShellContextSidebarComponent,
|
||||
MockWorkspaceShellHeaderComponent,
|
||||
MockWorkspaceShellImportOverlayComponent,
|
||||
MockWorkspaceShellRailComponent,
|
||||
],
|
||||
providers: [
|
||||
{
|
||||
provide: WorkspaceShellFacade,
|
||||
useValue: facade,
|
||||
},
|
||||
{
|
||||
provide: WorkspaceKeyboardShortcutsService,
|
||||
useClass: MockWorkspaceKeyboardShortcutsService,
|
||||
},
|
||||
],
|
||||
},
|
||||
})
|
||||
.compileComponents();
|
||||
|
||||
const fixture = TestBed.createComponent(WorkspaceShellComponent);
|
||||
fixture.detectChanges();
|
||||
const header = fixture.debugElement.query(
|
||||
By.directive(MockWorkspaceShellHeaderComponent)
|
||||
).componentInstance as MockWorkspaceShellHeaderComponent;
|
||||
const event = new KeyboardEvent('keydown', {
|
||||
key: 'f',
|
||||
metaKey: true,
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
});
|
||||
|
||||
document.dispatchEvent(event);
|
||||
jest.runOnlyPendingTimers();
|
||||
|
||||
expect(event.defaultPrevented).toBe(true);
|
||||
expect(facade.openGlobalSearch).toHaveBeenCalledWith('');
|
||||
expect(header.focusSearchInput).toHaveBeenCalledWith({ select: true });
|
||||
jest.useRealTimers();
|
||||
});
|
||||
});
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Component, inject } from '@angular/core';
|
||||
import { Component, HostListener, inject, viewChild } from '@angular/core';
|
||||
import { RouterOutlet } from '@angular/router';
|
||||
import { ExternalPlaybackDockComponent } from '@iptvnator/ui/components';
|
||||
import {
|
||||
@@ -46,4 +46,52 @@ import { WorkspaceKeyboardShortcutsService } from '../workspace-keyboard-shortcu
|
||||
export class WorkspaceShellComponent {
|
||||
readonly facade = inject(WorkspaceShellFacade);
|
||||
readonly keyboardShortcuts = inject(WorkspaceKeyboardShortcutsService);
|
||||
private readonly header = viewChild<WorkspaceShellHeaderShortcutTarget>(
|
||||
'workspaceHeader'
|
||||
);
|
||||
|
||||
@HostListener('document:keydown', ['$event'])
|
||||
onDocumentKeydown(event: KeyboardEvent): void {
|
||||
if (
|
||||
event.defaultPrevented ||
|
||||
!this.facade.isElectron ||
|
||||
!isFindShortcut(event)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
const header = this.header();
|
||||
const target = event.target;
|
||||
if (isEditableTarget(target) && !header?.containsSearchInput(target)) {
|
||||
return;
|
||||
}
|
||||
|
||||
event.preventDefault();
|
||||
this.facade.openGlobalSearch(this.facade.searchQuery());
|
||||
setTimeout(() => header?.focusSearchInput({ select: true }));
|
||||
}
|
||||
}
|
||||
|
||||
function isFindShortcut(event: KeyboardEvent): boolean {
|
||||
return (event.ctrlKey || event.metaKey) && event.key.toLowerCase() === 'f';
|
||||
}
|
||||
|
||||
function isEditableTarget(target: EventTarget | null): boolean {
|
||||
if (!(target instanceof HTMLElement)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (target.isContentEditable) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const tagName = target.tagName.toLowerCase();
|
||||
return (
|
||||
tagName === 'input' || tagName === 'textarea' || tagName === 'select'
|
||||
);
|
||||
}
|
||||
|
||||
interface WorkspaceShellHeaderShortcutTarget {
|
||||
containsSearchInput(target: EventTarget | null): boolean;
|
||||
focusSearchInput(options?: { select?: boolean }): void;
|
||||
}
|
||||
@@ -42,6 +42,15 @@ describe('workspace-shell-route.utils', () => {
|
||||
usesQuerySearch: true,
|
||||
})
|
||||
);
|
||||
expect(parseWorkspaceShellRoute('/workspace/search?q=matrix')).toEqual(
|
||||
expect.objectContaining({
|
||||
kind: 'global-search',
|
||||
context: null,
|
||||
contextPanel: 'none',
|
||||
searchMode: 'remote-search',
|
||||
usesQuerySearch: true,
|
||||
})
|
||||
);
|
||||
expect(parseWorkspaceShellRoute('/workspace/downloads')).toEqual(
|
||||
expect.objectContaining({
|
||||
kind: 'downloads',
|
||||
|
||||
@@ -8,6 +8,7 @@ export type WorkspaceShellPageKind =
|
||||
| 'downloads'
|
||||
| 'global-favorites'
|
||||
| 'global-recent'
|
||||
| 'global-search'
|
||||
| 'portal'
|
||||
| 'settings'
|
||||
| 'sources'
|
||||
@@ -194,6 +195,10 @@ function resolveRouteSearchMode(
|
||||
return 'local-filter';
|
||||
}
|
||||
|
||||
if (kind === 'global-search') {
|
||||
return 'remote-search';
|
||||
}
|
||||
|
||||
if (kind === 'portal') {
|
||||
return resolvePortalSearchMode(context, section);
|
||||
}
|
||||
@@ -288,9 +293,11 @@ export function parseWorkspaceShellRoute(url: string): WorkspaceShellRoute {
|
||||
? 'global-favorites'
|
||||
: page === 'global-recent'
|
||||
? 'global-recent'
|
||||
: page === 'downloads'
|
||||
? 'downloads'
|
||||
: 'unknown';
|
||||
: page === 'search'
|
||||
? 'global-search'
|
||||
: page === 'downloads'
|
||||
? 'downloads'
|
||||
: 'unknown';
|
||||
const searchMode = resolveRouteSearchMode(kind, null, null);
|
||||
|
||||
return {
|
||||
|
||||
Reference in new issue
Block a user