gui/ui/src/frame/application-frame.component.ts
Virgil bca53679f1
Some checks are pending
Security Scan / security (push) Waiting to run
Test / test (push) Waiting to run
ui: add global search to shell
2026-04-02 20:16:54 +00:00

273 lines
7.3 KiB
TypeScript

// SPDX-Licence-Identifier: EUPL-1.2
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;
href: string;
icon: string;
}
/**
* ApplicationFrameComponent is the HLCRF (Header, Left nav, Content, Right, Footer)
* shell for all Core Wails applications. It provides:
*
* - Dynamic sidebar navigation populated from ProviderDiscoveryService
* - Content area rendered via router-outlet for child routes
* - Footer status bar with time, version, and provider status
* - Mobile-responsive sidebar with expand/collapse
* - Dark mode support
*
* Ported from core-gui/cmd/lthn-desktop/frontend/src/frame/application.frame.ts
* with navigation made dynamic via provider discovery.
*/
@Component({
selector: 'application-frame',
standalone: true,
schemas: [CUSTOM_ELEMENTS_SCHEMA],
imports: [CommonModule, RouterOutlet, RouterLink, RouterLinkActive, StatusBarComponent],
templateUrl: './application-frame.component.html',
styles: [
`
.application-frame {
min-height: 100vh;
position: relative;
}
.frame-main {
min-height: calc(100vh - 6.5rem);
position: relative;
z-index: 0;
}
.application-frame .frame-header {
backdrop-filter: blur(18px);
background: linear-gradient(180deg, rgba(8, 12, 22, 0.94), rgba(8, 12, 22, 0.82));
border-bottom-color: rgba(255, 255, 255, 0.06);
box-shadow: 0 12px 40px rgba(0, 0, 0, 0.18);
}
.application-frame .frame-nav {
position: relative;
z-index: 30;
}
.application-frame .frame-nav .lg\\:fixed {
background: linear-gradient(180deg, rgba(5, 9, 18, 0.96), rgba(7, 12, 22, 0.84));
backdrop-filter: blur(18px);
border-right: 1px solid rgba(255, 255, 255, 0.06);
box-shadow: 12px 0 40px rgba(0, 0, 0, 0.16);
}
.application-frame .frame-nav a {
transition:
transform 140ms ease,
background 140ms ease,
color 140ms ease;
}
.application-frame .frame-nav a:hover {
transform: translateX(1px);
}
.application-frame .frame-main {
background:
radial-gradient(circle at 0% 0%, rgba(20, 184, 166, 0.08), transparent 22%),
linear-gradient(180deg, rgba(255, 255, 255, 0.01), transparent 24%);
}
.application-frame .frame-main .px-0 {
padding-left: clamp(1rem, 2vw, 1.5rem);
padding-right: clamp(1rem, 2vw, 1.5rem);
}
.application-frame .frame-main router-outlet {
display: block;
}
.application-frame .frame-header input {
color: var(--text);
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:
transform 140ms ease,
color 140ms ease,
background 140ms ease;
}
.application-frame .frame-header button:hover,
.application-frame .frame-header a:hover {
transform: translateY(-1px);
}
.application-frame .frame-header .fa-bell,
.application-frame .frame-header .fa-bars {
color: var(--accent-strong);
}
`,
],
})
export class ApplicationFrameComponent implements OnInit {
@Input() version = 'v0.1.0';
sidebarOpen = false;
userMenuOpen = false;
private readonly uiState = inject(UiStateService);
/** Static navigation items set by the host application. */
@Input() staticNavigation: NavItem[] = [];
/** Combined navigation: static + dynamic from providers. */
readonly navigation = computed<NavItem[]>(() => {
const dynamicItems = this.providerService
.providers()
.filter((p) => p.element)
.map((p) => ({
name: p.name,
href: `/provider/${encodeURIComponent(p.name.toLowerCase())}`,
icon: 'fa-regular fa-puzzle-piece fa-2xl shrink-0',
}));
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(
public t: TranslationService,
private providerService: ProviderDiscoveryService,
private wsService: WebSocketService,
) {}
async ngOnInit(): Promise<void> {
await this.t.onReady();
this.initUserNavigation();
// Discover providers and build navigation
await this.providerService.discover();
// Connect WebSocket for real-time updates
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 = [
{
name: this.t._('menu.settings'),
href: '/settings',
icon: 'fa-regular fa-gear',
},
];
}
}