ui: add global search to shell
Some checks are pending
Security Scan / security (push) Waiting to run
Test / test (push) Waiting to run

This commit is contained in:
Virgil 2026-04-02 20:16:54 +00:00
parent dd9e8da619
commit bca53679f1
5 changed files with 204 additions and 25 deletions

View file

@ -5,6 +5,7 @@ import { ProviderDiscoveryService, type ProviderInfo } from '../services/provide
import { TranslationService } from '../services/translation.service'; import { TranslationService } from '../services/translation.service';
import { WebSocketService } from '../services/websocket.service'; import { WebSocketService } from '../services/websocket.service';
import { ProviderHostComponent } from '../components/provider-host.component'; import { ProviderHostComponent } from '../components/provider-host.component';
import { UiStateService } from '../services/ui-state.service';
@Component({ @Component({
selector: 'dashboard-view', selector: 'dashboard-view',
@ -48,6 +49,10 @@ import { ProviderHostComponent } from '../components/provider-host.component';
<span class="meta-label">API base</span> <span class="meta-label">API base</span>
<strong class="mono">{{ apiBase() }}</strong> <strong class="mono">{{ apiBase() }}</strong>
</div> </div>
<div class="meta-card">
<span class="meta-label">Search</span>
<strong>{{ searchQuery() || 'All providers' }}</strong>
</div>
</div> </div>
</section> </section>
@ -58,7 +63,9 @@ import { ProviderHostComponent } from '../components/provider-host.component';
<p class="eyebrow">Discovered providers</p> <p class="eyebrow">Discovered providers</p>
<h2>Renderable capabilities</h2> <h2>Renderable capabilities</h2>
</div> </div>
<span class="pill">{{ providerCount() }} total</span> <span class="pill" [class.good]="searchQuery()">
{{ filteredProviders().length }} shown / {{ providerCount() }} total
</span>
</div> </div>
<div class="provider-list" *ngIf="featuredProviders().length; else emptyState"> <div class="provider-list" *ngIf="featuredProviders().length; else emptyState">
@ -84,8 +91,16 @@ import { ProviderHostComponent } from '../components/provider-host.component';
<ng-template #emptyState> <ng-template #emptyState>
<div class="empty-state"> <div class="empty-state">
<strong>No providers discovered yet.</strong> <strong>
<span>The shell will populate this view once the backend exposes provider metadata.</span> {{ searchQuery() ? 'No providers match the current search.' : 'No providers discovered yet.' }}
</strong>
<span>
{{
searchQuery()
? 'Clear the search box to restore the full provider list.'
: 'The shell will populate this view once the backend exposes provider metadata.'
}}
</span>
</div> </div>
</ng-template> </ng-template>
</article> </article>
@ -103,6 +118,10 @@ import { ProviderHostComponent } from '../components/provider-host.component';
<strong>Provider discovery</strong> <strong>Provider discovery</strong>
<span>Loads provider metadata and registers custom element scripts automatically.</span> <span>Loads provider metadata and registers custom element scripts automatically.</span>
</li> </li>
<li>
<strong>Global search</strong>
<span>Filters navigation and provider cards from a single shell-level search box.</span>
</li>
<li> <li>
<strong>Realtime status</strong> <strong>Realtime status</strong>
<span>Tracks the websocket connection used for backend events.</span> <span>Tracks the websocket connection used for backend events.</span>
@ -156,6 +175,7 @@ export class DashboardComponent {
private readonly translations = inject(TranslationService); private readonly translations = inject(TranslationService);
private readonly websocket = inject(WebSocketService); private readonly websocket = inject(WebSocketService);
private readonly destroyRef = inject(DestroyRef); private readonly destroyRef = inject(DestroyRef);
private readonly uiState = inject(UiStateService);
protected readonly title = signal('Core GUI'); protected readonly title = signal('Core GUI');
protected readonly subtitle = signal('Desktop orchestration console'); protected readonly subtitle = signal('Desktop orchestration console');
@ -166,9 +186,31 @@ export class DashboardComponent {
protected readonly providerCount = computed(() => this.providers().length); protected readonly providerCount = computed(() => this.providers().length);
protected readonly connected = this.websocket.connected; protected readonly connected = this.websocket.connected;
protected readonly apiBase = computed(() => this.apiConfig.effectiveBaseUrl); protected readonly apiBase = computed(() => this.apiConfig.effectiveBaseUrl);
protected readonly searchQuery = this.uiState.searchQuery;
protected readonly filteredProviders = computed<ProviderInfo[]>(() => {
const query = this.searchQuery().trim().toLowerCase();
const providers = this.providers();
if (!query) {
return providers;
}
return providers.filter((provider) => {
const haystack = [
provider.name,
provider.basePath,
provider.status ?? '',
provider.element?.tag ?? '',
provider.element?.source ?? '',
]
.join(' ')
.toLowerCase();
return haystack.includes(query);
});
});
protected readonly featuredProviders = computed<ProviderInfo[]>(() => protected readonly featuredProviders = computed<ProviderInfo[]>(() =>
this.providers().filter((provider) => provider.element?.tag).slice(0, 6), this.filteredProviders().filter((provider) => provider.element?.tag).slice(0, 6),
); );
protected readonly selectedRenderableProvider = computed<ProviderInfo | null>(() => { protected readonly selectedRenderableProvider = computed<ProviderInfo | null>(() => {
@ -178,7 +220,7 @@ export class DashboardComponent {
} }
return ( return (
this.providers().find((provider) => provider.name === selection && provider.element?.tag) ?? this.filteredProviders().find((provider) => provider.name === selection && provider.element?.tag) ??
this.featuredProviders()[0] ?? this.featuredProviders()[0] ??
null null
); );

View file

@ -17,23 +17,33 @@
<!-- Separator --> <!-- Separator -->
<div aria-hidden="true" class="h-6 w-px bg-gray-900/10 lg:hidden dark:bg-white/10"></div> <div aria-hidden="true" class="h-6 w-px bg-gray-900/10 lg:hidden dark:bg-white/10"></div>
<div class="flex flex-1 gap-x-4 self-stretch lg:gap-x-6"> <div class="flex flex-1 gap-x-4 self-stretch lg:gap-x-6">
<form action="#" method="GET" class="grid flex-1 grid-cols-1"> <form action="#" method="GET" class="grid flex-1 grid-cols-1" (submit)="$event.preventDefault()">
<input <div class="search-shell">
name="search" <i class="fa-light fa-xl fa-magnifying-glass pointer-events-none text-gray-400"></i>
[placeholder]="t._('app.core.ui.search')" <input
aria-label="Search" #searchInput
class="col-start-1 row-start-1 block size-full bg-white pl-8 text-base text-gray-900 outline-hidden placeholder:text-gray-400 sm:text-sm/6 dark:bg-gray-900 dark:text-white dark:placeholder:text-gray-500" name="search"
/> [value]="searchQuery()"
<i (input)="onSearchInput(searchInput.value)"
class="fa-light fa-xl fa-magnifying-glass pointer-events-none col-start-1 row-start-1 self-center text-gray-400" [placeholder]="t._('app.core.ui.search')"
></i> aria-label="Search"
</form> class="text-base text-gray-900 sm:text-sm/6 dark:text-white"
<div class="flex items-center gap-x-4 lg:gap-x-6"> />
<button @if (searchQuery()) {
type="button" <button type="button" class="search-clear" (click)="clearSearch()">
class="-m-2.5 p-2.5 text-gray-400 hover:text-gray-500 dark:hover:text-white" <span class="sr-only">Clear search</span>
> <i class="fa-regular fa-xmark"></i>
</button>
}
</div>
</form>
<div class="flex items-center gap-x-4 lg:gap-x-6">
<span class="search-count">{{ visibleNavigation().length }} visible</span>
<button
type="button"
class="-m-2.5 p-2.5 text-gray-400 hover:text-gray-500 dark:hover:text-white"
>
<span class="sr-only">View notifications</span> <span class="sr-only">View notifications</span>
<i class="fa-light fa-bell fa-xl"></i> <i class="fa-light fa-bell fa-xl"></i>
</button> </button>
@ -101,7 +111,7 @@
</div> </div>
<nav class="flex flex-1 flex-col"> <nav class="flex flex-1 flex-col">
<ul role="list" class="-mx-2 flex-1 space-y-1"> <ul role="list" class="-mx-2 flex-1 space-y-1">
@for (item of navigation(); track item.name) { @for (item of visibleNavigation(); track item.name) {
<li> <li>
<a <a
[routerLink]="item.href" [routerLink]="item.href"
@ -134,7 +144,7 @@
</button> </button>
<nav class="relative mt-1"> <nav class="relative mt-1">
<ul role="list" class="flex flex-col items-center space-y-1"> <ul role="list" class="flex flex-col items-center space-y-1">
@for (item of navigation(); track item.name) { @for (item of visibleNavigation(); track item.name) {
<li> <li>
<a <a
[routerLink]="item.href" [routerLink]="item.href"

View file

@ -1,12 +1,21 @@
// SPDX-Licence-Identifier: EUPL-1.2 // SPDX-Licence-Identifier: EUPL-1.2
import { Component, CUSTOM_ELEMENTS_SCHEMA, Input, OnInit, computed } from '@angular/core'; import {
Component,
CUSTOM_ELEMENTS_SCHEMA,
HostListener,
Input,
OnInit,
computed,
inject,
} from '@angular/core';
import { CommonModule } from '@angular/common'; import { CommonModule } from '@angular/common';
import { RouterLink, RouterLinkActive, RouterOutlet } from '@angular/router'; import { RouterLink, RouterLinkActive, RouterOutlet } from '@angular/router';
import { TranslationService } from '../services/translation.service'; import { TranslationService } from '../services/translation.service';
import { ProviderDiscoveryService } from '../services/provider-discovery.service'; import { ProviderDiscoveryService } from '../services/provider-discovery.service';
import { WebSocketService } from '../services/websocket.service'; import { WebSocketService } from '../services/websocket.service';
import { StatusBarComponent } from '../components/status-bar.component'; import { StatusBarComponent } from '../components/status-bar.component';
import { UiStateService } from '../services/ui-state.service';
interface NavItem { interface NavItem {
name: string; name: string;
@ -96,6 +105,62 @@ interface NavItem {
caret-color: var(--accent-strong); caret-color: var(--accent-strong);
} }
.application-frame .search-shell {
display: flex;
align-items: center;
gap: 0.75rem;
min-height: 2.75rem;
padding: 0 0.875rem;
border-radius: 999px;
border: 1px solid rgba(255, 255, 255, 0.08);
background: rgba(255, 255, 255, 0.03);
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.03);
}
.application-frame .search-shell:focus-within {
border-color: rgba(103, 232, 249, 0.3);
background: rgba(255, 255, 255, 0.05);
}
.application-frame .search-shell input {
min-width: 0;
flex: 1;
border: 0;
background: transparent;
outline: none;
}
.application-frame .search-shell input::placeholder {
color: rgba(170, 182, 205, 0.72);
}
.application-frame .search-clear {
display: inline-flex;
align-items: center;
justify-content: center;
border: 0;
background: transparent;
color: var(--muted);
cursor: pointer;
padding: 0;
}
.application-frame .search-clear:hover {
color: var(--text);
}
.application-frame .search-count {
display: inline-flex;
align-items: center;
min-height: 2.25rem;
padding: 0 0.8rem;
border-radius: 999px;
border: 1px solid rgba(255, 255, 255, 0.08);
background: rgba(255, 255, 255, 0.03);
color: var(--muted);
white-space: nowrap;
}
.application-frame .frame-header button, .application-frame .frame-header button,
.application-frame .frame-header a { .application-frame .frame-header a {
transition: transition:
@ -121,6 +186,7 @@ export class ApplicationFrameComponent implements OnInit {
sidebarOpen = false; sidebarOpen = false;
userMenuOpen = false; userMenuOpen = false;
private readonly uiState = inject(UiStateService);
/** Static navigation items set by the host application. */ /** Static navigation items set by the host application. */
@Input() staticNavigation: NavItem[] = []; @Input() staticNavigation: NavItem[] = [];
@ -139,6 +205,17 @@ export class ApplicationFrameComponent implements OnInit {
return [...this.staticNavigation, ...dynamicItems]; return [...this.staticNavigation, ...dynamicItems];
}); });
readonly searchQuery = this.uiState.searchQuery;
readonly visibleNavigation = computed(() => {
const query = this.searchQuery().toLowerCase();
const items = this.navigation();
if (!query) {
return items;
}
return items.filter((item) => `${item.name} ${item.href}`.toLowerCase().includes(query));
});
userNavigation: NavItem[] = []; userNavigation: NavItem[] = [];
constructor( constructor(
@ -158,6 +235,31 @@ export class ApplicationFrameComponent implements OnInit {
this.wsService.connect(); this.wsService.connect();
} }
@HostListener('document:keydown.escape')
onEscape(): void {
if (this.sidebarOpen) {
this.sidebarOpen = false;
return;
}
if (this.userMenuOpen) {
this.userMenuOpen = false;
return;
}
if (this.searchQuery()) {
this.clearSearch();
}
}
onSearchInput(value: string): void {
this.uiState.setSearchQuery(value);
}
clearSearch(): void {
this.uiState.clearSearchQuery();
}
private initUserNavigation(): void { private initUserNavigation(): void {
this.userNavigation = [ this.userNavigation = [
{ {

View file

@ -9,6 +9,7 @@ export { ApiConfigService } from './services/api-config.service';
export { ProviderDiscoveryService, type ProviderInfo, type ElementSpec } from './services/provider-discovery.service'; export { ProviderDiscoveryService, type ProviderInfo, type ElementSpec } from './services/provider-discovery.service';
export { WebSocketService, type WSMessage } from './services/websocket.service'; export { WebSocketService, type WSMessage } from './services/websocket.service';
export { TranslationService } from './services/translation.service'; export { TranslationService } from './services/translation.service';
export { UiStateService } from './services/ui-state.service';
// Components // Components
export { ProviderHostComponent } from './components/provider-host.component'; export { ProviderHostComponent } from './components/provider-host.component';

View file

@ -0,0 +1,24 @@
// SPDX-Licence-Identifier: EUPL-1.2
import { Injectable, computed, signal } from '@angular/core';
/**
* UiStateService stores shell-wide client state shared across the frame and
* dashboard. Keeping it in one place avoids threading search state through
* unrelated routes.
*/
@Injectable({ providedIn: 'root' })
export class UiStateService {
private readonly _searchQuery = signal('');
readonly searchQuery = this._searchQuery.asReadonly();
readonly hasSearch = computed(() => this.searchQuery().length > 0);
setSearchQuery(value: string): void {
this._searchQuery.set(value.trim());
}
clearSearchQuery(): void {
this._searchQuery.set('');
}
}