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 { 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
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
|
|
|
||||||
|
|
@ -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 = [
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -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';
|
||||||
|
|
|
||||||
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