ui: add global search to shell
This commit is contained in:
parent
dd9e8da619
commit
bca53679f1
5 changed files with 204 additions and 25 deletions
|
|
@ -5,6 +5,7 @@ import { ProviderDiscoveryService, type ProviderInfo } from '../services/provide
|
|||
import { TranslationService } from '../services/translation.service';
|
||||
import { WebSocketService } from '../services/websocket.service';
|
||||
import { ProviderHostComponent } from '../components/provider-host.component';
|
||||
import { UiStateService } from '../services/ui-state.service';
|
||||
|
||||
@Component({
|
||||
selector: 'dashboard-view',
|
||||
|
|
@ -48,6 +49,10 @@ import { ProviderHostComponent } from '../components/provider-host.component';
|
|||
<span class="meta-label">API base</span>
|
||||
<strong class="mono">{{ apiBase() }}</strong>
|
||||
</div>
|
||||
<div class="meta-card">
|
||||
<span class="meta-label">Search</span>
|
||||
<strong>{{ searchQuery() || 'All providers' }}</strong>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
|
|
@ -58,7 +63,9 @@ import { ProviderHostComponent } from '../components/provider-host.component';
|
|||
<p class="eyebrow">Discovered providers</p>
|
||||
<h2>Renderable capabilities</h2>
|
||||
</div>
|
||||
<span class="pill">{{ providerCount() }} total</span>
|
||||
<span class="pill" [class.good]="searchQuery()">
|
||||
{{ filteredProviders().length }} shown / {{ providerCount() }} total
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="provider-list" *ngIf="featuredProviders().length; else emptyState">
|
||||
|
|
@ -84,8 +91,16 @@ import { ProviderHostComponent } from '../components/provider-host.component';
|
|||
|
||||
<ng-template #emptyState>
|
||||
<div class="empty-state">
|
||||
<strong>No providers discovered yet.</strong>
|
||||
<span>The shell will populate this view once the backend exposes provider metadata.</span>
|
||||
<strong>
|
||||
{{ 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>
|
||||
</ng-template>
|
||||
</article>
|
||||
|
|
@ -103,6 +118,10 @@ import { ProviderHostComponent } from '../components/provider-host.component';
|
|||
<strong>Provider discovery</strong>
|
||||
<span>Loads provider metadata and registers custom element scripts automatically.</span>
|
||||
</li>
|
||||
<li>
|
||||
<strong>Global search</strong>
|
||||
<span>Filters navigation and provider cards from a single shell-level search box.</span>
|
||||
</li>
|
||||
<li>
|
||||
<strong>Realtime status</strong>
|
||||
<span>Tracks the websocket connection used for backend events.</span>
|
||||
|
|
@ -156,6 +175,7 @@ export class DashboardComponent {
|
|||
private readonly translations = inject(TranslationService);
|
||||
private readonly websocket = inject(WebSocketService);
|
||||
private readonly destroyRef = inject(DestroyRef);
|
||||
private readonly uiState = inject(UiStateService);
|
||||
|
||||
protected readonly title = signal('Core GUI');
|
||||
protected readonly subtitle = signal('Desktop orchestration console');
|
||||
|
|
@ -166,9 +186,31 @@ export class DashboardComponent {
|
|||
protected readonly providerCount = computed(() => this.providers().length);
|
||||
protected readonly connected = this.websocket.connected;
|
||||
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[]>(() =>
|
||||
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>(() => {
|
||||
|
|
@ -178,7 +220,7 @@ export class DashboardComponent {
|
|||
}
|
||||
|
||||
return (
|
||||
this.providers().find((provider) => provider.name === selection && provider.element?.tag) ??
|
||||
this.filteredProviders().find((provider) => provider.name === selection && provider.element?.tag) ??
|
||||
this.featuredProviders()[0] ??
|
||||
null
|
||||
);
|
||||
|
|
|
|||
|
|
@ -18,18 +18,28 @@
|
|||
<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">
|
||||
<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()">
|
||||
<div class="search-shell">
|
||||
<i class="fa-light fa-xl fa-magnifying-glass pointer-events-none text-gray-400"></i>
|
||||
<input
|
||||
#searchInput
|
||||
name="search"
|
||||
[value]="searchQuery()"
|
||||
(input)="onSearchInput(searchInput.value)"
|
||||
[placeholder]="t._('app.core.ui.search')"
|
||||
aria-label="Search"
|
||||
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"
|
||||
class="text-base text-gray-900 sm:text-sm/6 dark:text-white"
|
||||
/>
|
||||
<i
|
||||
class="fa-light fa-xl fa-magnifying-glass pointer-events-none col-start-1 row-start-1 self-center text-gray-400"
|
||||
></i>
|
||||
@if (searchQuery()) {
|
||||
<button type="button" class="search-clear" (click)="clearSearch()">
|
||||
<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"
|
||||
|
|
@ -101,7 +111,7 @@
|
|||
</div>
|
||||
<nav class="flex flex-1 flex-col">
|
||||
<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>
|
||||
<a
|
||||
[routerLink]="item.href"
|
||||
|
|
@ -134,7 +144,7 @@
|
|||
</button>
|
||||
<nav class="relative mt-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>
|
||||
<a
|
||||
[routerLink]="item.href"
|
||||
|
|
|
|||
|
|
@ -1,12 +1,21 @@
|
|||
// 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 { RouterLink, RouterLinkActive, RouterOutlet } from '@angular/router';
|
||||
import { TranslationService } from '../services/translation.service';
|
||||
import { ProviderDiscoveryService } from '../services/provider-discovery.service';
|
||||
import { WebSocketService } from '../services/websocket.service';
|
||||
import { StatusBarComponent } from '../components/status-bar.component';
|
||||
import { UiStateService } from '../services/ui-state.service';
|
||||
|
||||
interface NavItem {
|
||||
name: string;
|
||||
|
|
@ -96,6 +105,62 @@ interface NavItem {
|
|||
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 a {
|
||||
transition:
|
||||
|
|
@ -121,6 +186,7 @@ export class ApplicationFrameComponent implements OnInit {
|
|||
|
||||
sidebarOpen = false;
|
||||
userMenuOpen = false;
|
||||
private readonly uiState = inject(UiStateService);
|
||||
|
||||
/** Static navigation items set by the host application. */
|
||||
@Input() staticNavigation: NavItem[] = [];
|
||||
|
|
@ -139,6 +205,17 @@ export class ApplicationFrameComponent implements OnInit {
|
|||
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[] = [];
|
||||
|
||||
constructor(
|
||||
|
|
@ -158,6 +235,31 @@ export class ApplicationFrameComponent implements OnInit {
|
|||
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 {
|
||||
this.userNavigation = [
|
||||
{
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ export { ApiConfigService } from './services/api-config.service';
|
|||
export { ProviderDiscoveryService, type ProviderInfo, type ElementSpec } from './services/provider-discovery.service';
|
||||
export { WebSocketService, type WSMessage } from './services/websocket.service';
|
||||
export { TranslationService } from './services/translation.service';
|
||||
export { UiStateService } from './services/ui-state.service';
|
||||
|
||||
// Components
|
||||
export { ProviderHostComponent } from './components/provider-host.component';
|
||||
|
|
|
|||
24
ui/src/services/ui-state.service.ts
Normal file
24
ui/src/services/ui-state.service.ts
Normal 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('');
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue