feat(ui): add app shell framework with provider discovery
Some checks failed
Security Scan / security (push) Failing after 9s
Test / test (push) Failing after 1m28s

Port the HLCRF application frame from lthn-desktop into core/gui/ui/ as a
reusable Angular framework. Adds:

- ApplicationFrameComponent: header, collapsible sidebar, content area, footer
- SystemTrayFrameComponent: 380x480 frameless panel with provider status cards
- ProviderDiscoveryService: fetches GET /api/v1/providers, loads custom elements
- ProviderHostComponent: renders any custom element by tag via Renderer2
- ProviderNavComponent: dynamic sidebar navigation from provider discovery
- StatusBarComponent: footer with time, version, provider count, WS status
- WebSocketService: persistent connection with auto-reconnect
- ApiConfigService: configurable API base URL
- TranslationService: key-value i18n with API fallback

Navigation is dynamic (populated from providers), sidebar shows icons-only
in collapsed mode with expand on click, dark mode supported throughout.

Co-Authored-By: Virgil <virgil@lethean.io>
This commit is contained in:
Snider 2026-03-14 12:41:33 +00:00
parent de35a7ab16
commit 0dcc42c7fb
18 changed files with 2824 additions and 33 deletions

View file

@ -3,22 +3,16 @@
"version": 1,
"newProjectRoot": "projects",
"projects": {
"display": {
"gui-ui": {
"projectType": "application",
"schematics": {
"@schematics/angular:component": {
"standalone": false
},
"@schematics/angular:directive": {
"standalone": false
},
"@schematics/angular:pipe": {
"standalone": false
"standalone": true
}
},
"root": "",
"sourceRoot": "src",
"prefix": "app",
"prefix": "core",
"architect": {
"build": {
"builder": "@angular/build:application",
@ -64,10 +58,10 @@
"builder": "@angular/build:dev-server",
"configurations": {
"production": {
"buildTarget": "display:build:production"
"buildTarget": "gui-ui:build:production"
},
"development": {
"buildTarget": "display:build:development"
"buildTarget": "gui-ui:build:development"
}
},
"defaultConfiguration": "development"

1422
ui/package-lock.json generated

File diff suppressed because it is too large Load diff

View file

@ -1,6 +1,6 @@
{
"name": "display",
"version": "0.0.0",
"name": "@core/gui-ui",
"version": "0.1.0",
"scripts": {
"ng": "ng",
"start": "ng serve",

View file

@ -1,3 +1,5 @@
// SPDX-Licence-Identifier: EUPL-1.2
import { DoBootstrap, Injector, NgModule, provideBrowserGlobalErrorListeners } from '@angular/core';
import { BrowserModule } from '@angular/platform-browser';
import { createCustomElement } from '@angular/elements';
@ -5,13 +7,8 @@ import { createCustomElement } from '@angular/elements';
import { App } from './app';
@NgModule({
imports: [
BrowserModule,
App
],
providers: [
provideBrowserGlobalErrorListeners()
]
imports: [BrowserModule, App],
providers: [provideBrowserGlobalErrorListeners()],
})
export class AppModule implements DoBootstrap {
constructor(private injector: Injector) {

View file

@ -0,0 +1,96 @@
// SPDX-Licence-Identifier: EUPL-1.2
import {
Component,
CUSTOM_ELEMENTS_SCHEMA,
ElementRef,
Input,
OnChanges,
OnInit,
Renderer2,
ViewChild,
} from '@angular/core';
import { ActivatedRoute } from '@angular/router';
import { ApiConfigService } from '../services/api-config.service';
import { ProviderDiscoveryService } from '../services/provider-discovery.service';
/**
* ProviderHostComponent renders any custom element by tag name using
* Angular's Renderer2 for safe DOM manipulation. It reads the :provider
* route parameter to look up the element tag from the discovery service.
*/
@Component({
selector: 'provider-host',
standalone: true,
schemas: [CUSTOM_ELEMENTS_SCHEMA],
template: '<div #container class="provider-host"></div>',
styles: [
`
:host {
display: block;
width: 100%;
height: 100%;
}
.provider-host {
width: 100%;
height: 100%;
}
`,
],
})
export class ProviderHostComponent implements OnInit, OnChanges {
/** The custom element tag to render. Can be set via input or route param. */
@Input() tag = '';
/** API URL attribute passed to the custom element. */
@Input() apiUrl = '';
@ViewChild('container', { static: true }) container!: ElementRef;
constructor(
private renderer: Renderer2,
private route: ActivatedRoute,
private apiConfig: ApiConfigService,
private providerService: ProviderDiscoveryService,
) {}
ngOnInit(): void {
this.route.params.subscribe((params) => {
const providerName = params['provider'];
if (providerName) {
const provider = this.providerService
.providers()
.find((p) => p.name.toLowerCase() === providerName.toLowerCase());
if (provider?.element?.tag) {
this.tag = provider.element.tag;
this.renderElement();
}
}
});
}
ngOnChanges(): void {
this.renderElement();
}
private renderElement(): void {
if (!this.tag || !this.container) {
return;
}
const native = this.container.nativeElement;
// Clear previous element safely
while (native.firstChild) {
this.renderer.removeChild(native, native.firstChild);
}
// Create and append the custom element
const el = this.renderer.createElement(this.tag);
const url = this.apiUrl || this.apiConfig.baseUrl;
if (url) {
this.renderer.setAttribute(el, 'api-url', url);
}
this.renderer.appendChild(native, el);
}
}

View file

@ -0,0 +1,132 @@
// SPDX-Licence-Identifier: EUPL-1.2
import { Component, computed, Input, signal } from '@angular/core';
import { RouterLink, RouterLinkActive } from '@angular/router';
import { ProviderDiscoveryService, ProviderInfo } from '../services/provider-discovery.service';
export interface NavItem {
name: string;
href: string;
icon: string;
element?: { tag: string; source: string };
}
/**
* ProviderNavComponent renders the sidebar navigation built dynamically
* from the provider discovery service. Shows icon-only in collapsed mode,
* expands on click.
*/
@Component({
selector: 'provider-nav',
standalone: true,
imports: [RouterLink, RouterLinkActive],
template: `
<nav class="provider-nav">
<ul role="list" class="nav-list">
@for (item of navItems(); track item.name) {
<li>
<a
[routerLink]="item.href"
routerLinkActive="active"
[routerLinkActiveOptions]="{ exact: true }"
class="nav-item"
[title]="item.name"
>
<i [class]="item.icon"></i>
@if (expanded()) {
<span class="nav-label">{{ item.name }}</span>
}
</a>
</li>
}
</ul>
</nav>
`,
styles: [
`
.provider-nav {
display: flex;
flex-direction: column;
flex: 1;
}
.nav-list {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
align-items: center;
gap: 0.25rem;
}
.nav-list li {
width: 100%;
}
.nav-item {
display: flex;
justify-content: center;
align-items: center;
gap: 0.75rem;
padding: 1rem;
border-radius: 0.375rem;
color: #9ca3af;
text-decoration: none;
font-size: 0.875rem;
font-weight: 600;
line-height: 1.5;
transition: background 0.15s, color 0.15s;
height: 4rem;
}
.nav-item:hover {
background: rgba(255, 255, 255, 0.05);
color: #ffffff;
}
.nav-item.active {
background: rgba(255, 255, 255, 0.05);
color: #ffffff;
}
.nav-item i {
flex-shrink: 0;
}
.nav-label {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
`,
],
})
export class ProviderNavComponent {
/** Additional static navigation items to prepend. */
@Input() staticItems: NavItem[] = [];
readonly expanded = signal(false);
constructor(private providerService: ProviderDiscoveryService) {}
/** Dynamic navigation built from discovered providers and static items. */
readonly navItems = computed<NavItem[]>(() => {
const dynamicItems = this.providerService
.providers()
.filter((p: ProviderInfo) => p.element)
.map((p: ProviderInfo) => ({
name: p.name,
href: p.name.toLowerCase(),
icon: 'fa-regular fa-puzzle-piece fa-2xl',
element: p.element,
}));
return [...this.staticItems, ...dynamicItems];
});
/** Toggle sidebar expansion. */
toggle(): void {
this.expanded.update((v) => !v);
}
}

View file

@ -0,0 +1,122 @@
// SPDX-Licence-Identifier: EUPL-1.2
import { Component, Input, OnDestroy, OnInit, signal } from '@angular/core';
import { ProviderDiscoveryService } from '../services/provider-discovery.service';
import { WebSocketService } from '../services/websocket.service';
/**
* StatusBarComponent renders the footer bar showing time, version,
* provider count, and connection status.
*/
@Component({
selector: 'status-bar',
standalone: true,
template: `
<footer class="status-bar">
<div class="status-left">
<span class="status-item version">{{ version }}</span>
<span class="status-item providers">
<i class="fa-regular fa-puzzle-piece"></i>
{{ providerCount() }} providers
</span>
</div>
<div class="status-right">
<span class="status-item connection" [class.connected]="wsConnected()">
<span class="status-dot"></span>
{{ wsConnected() ? 'Connected' : 'Disconnected' }}
</span>
<span class="status-item time">{{ time() }}</span>
</div>
</footer>
`,
styles: [
`
.status-bar {
position: fixed;
inset-inline: 0;
bottom: 0;
z-index: 40;
height: 2.5rem;
border-top: 1px solid rgb(229 231 235);
background: #ffffff;
display: flex;
align-items: center;
justify-content: space-between;
padding-inline: 1rem;
}
:host-context(.dark) .status-bar {
border-color: rgba(255, 255, 255, 0.1);
background: rgb(17 24 39);
}
.status-left,
.status-right {
display: flex;
align-items: center;
gap: 1rem;
}
.status-item {
font-size: 0.875rem;
color: rgb(107 114 128);
}
:host-context(.dark) .status-item {
color: rgb(156 163 175);
}
.status-item i {
margin-right: 0.25rem;
}
.status-dot {
display: inline-block;
width: 6px;
height: 6px;
border-radius: 50%;
background: rgb(107 114 128);
margin-right: 0.375rem;
}
.connection.connected .status-dot {
background: rgb(34 197 94);
box-shadow: 0 0 4px rgb(34 197 94);
}
.time {
font-family: 'JetBrains Mono', 'Fira Code', monospace;
}
`,
],
})
export class StatusBarComponent implements OnInit, OnDestroy {
@Input() version = 'v0.1.0';
@Input() sidebarWidth = '5rem';
readonly time = signal('');
private intervalId: ReturnType<typeof setInterval> | undefined;
constructor(
private providerService: ProviderDiscoveryService,
private wsService: WebSocketService,
) {}
readonly providerCount = () => this.providerService.providers().length;
readonly wsConnected = () => this.wsService.connected();
ngOnInit(): void {
this.updateTime();
this.intervalId = setInterval(() => this.updateTime(), 1000);
}
ngOnDestroy(): void {
if (this.intervalId) {
clearInterval(this.intervalId);
}
}
private updateTime(): void {
this.time.set(new Date().toLocaleTimeString());
}
}

View file

@ -0,0 +1,179 @@
<!-- SPDX-Licence-Identifier: EUPL-1.2 -->
<!-- HLCRF Application Frame: Header, Left Nav, Content, Right (unused), Footer -->
<div class="application-frame">
<header
class="frame-header sticky top-0 z-40 flex h-16 shrink-0 items-center gap-x-4 border-b border-gray-200 bg-white px-4 shadow-xs sm:gap-x-6 sm:px-6 lg:px-8 dark:border-white/10 dark:bg-gray-900 dark:shadow-none"
>
<button
type="button"
(click)="sidebarOpen = true"
class="-m-2.5 p-2.5 text-gray-700 dark:text-gray-400"
>
<span class="sr-only">Open sidebar</span>
<i class="fa-regular fa-bars fa-2xl"></i>
</button>
<!-- Separator -->
<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">
<input
name="search"
[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"
/>
<i
class="fa-light fa-xl fa-magnifying-glass pointer-events-none col-start-1 row-start-1 self-center text-gray-400"
></i>
</form>
<div class="flex items-center gap-x-4 lg:gap-x-6">
<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>
<i class="fa-light fa-bell fa-xl"></i>
</button>
<!-- Separator -->
<div
aria-hidden="true"
class="hidden lg:block lg:h-6 lg:w-px bg-gray-900/10 dark:lg:bg-white/10"
></div>
<!-- User menu -->
<div class="relative">
<button (click)="userMenuOpen = !userMenuOpen" class="relative flex items-center">
<span class="absolute -inset-1.5"></span>
<span class="sr-only">Open user menu</span>
<span class="hidden lg:flex lg:items-center">
<span
aria-hidden="true"
class="ml-4 w-32 text-sm/6 font-semibold text-gray-900 dark:text-white"
>
Core IDE
</span>
<i class="ml-2 fa-regular fa-chevron-down text-sm/6 text-gray-400"></i>
</span>
</button>
@if (userMenuOpen) {
<div
class="absolute right-0 z-50 mt-2.5 w-48 origin-top-right rounded-md bg-white py-2 shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none dark:bg-gray-800 dark:ring-white/10"
>
@for (item of userNavigation; track item.name) {
<a
[routerLink]="item.href"
(click)="userMenuOpen = false"
class="flex items-center gap-x-3 px-3 py-1 text-sm/6 text-gray-900 focus:bg-gray-50 focus:outline-hidden dark:text-white dark:focus:bg-white/5 h-8"
>
<i [class]="item.icon"></i>
{{ item.name }}
</a>
}
</div>
}
</div>
</div>
</div>
</header>
<nav class="frame-nav">
<!-- Mobile sidebar overlay -->
@if (sidebarOpen) {
<div class="relative z-50" role="dialog" aria-modal="true">
<div class="fixed inset-0 bg-gray-900/80"></div>
<div class="fixed inset-0 flex">
<div class="relative mr-16 flex w-full max-w-xs flex-1">
<div class="absolute top-0 left-full flex w-16 justify-center pt-5">
<button type="button" (click)="sidebarOpen = false" class="-m-2.5 p-2.5">
<span class="sr-only">Close sidebar</span>
<i class="fa-regular fa-xmark fa-2xl text-white"></i>
</button>
</div>
<div
class="flex grow flex-col gap-y-5 overflow-y-auto bg-gray-900 px-6 pb-2 ring-1 ring-white/10"
>
<div class="flex h-16 shrink-0 items-center">
<span class="text-lg font-semibold text-white">Core IDE</span>
</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) {
<li>
<a
[routerLink]="item.href"
routerLinkActive="bg-gray-800 text-white"
[routerLinkActiveOptions]="{ exact: true }"
(click)="sidebarOpen = false"
class="text-gray-400 hover:bg-gray-800 hover:text-white group flex justify-center items-center gap-x-3 rounded-md p-4 text-sm/6 font-semibold"
>
<i [class]="item.icon"></i>
{{ item.name }}
</a>
</li>
}
</ul>
</nav>
</div>
</div>
</div>
</div>
} @else {
<!-- Desktop sidebar — icons only, expand on click -->
<div
class="hidden lg:fixed lg:inset-y-0 lg:left-0 lg:z-50 lg:block lg:w-20 lg:overflow-y-auto lg:bg-gray-900 lg:pb-4 dark:before:pointer-events-none dark:before:absolute dark:before:inset-0 dark:before:border-r dark:before:border-white/10 dark:before:bg-black/10"
>
<button
(click)="sidebarOpen = true"
class="relative flex h-16 w-full shrink-0 items-center justify-center cursor-pointer bg-transparent border-none"
>
<i class="fa-regular fa-cube fa-2xl text-indigo-400"></i>
</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) {
<li>
<a
[routerLink]="item.href"
routerLinkActive="bg-white/5 text-white"
[routerLinkActiveOptions]="{ exact: true }"
class="text-gray-400 hover:bg-white/5 hover:text-white group flex justify-center items-center rounded-md p-4 text-sm/6 font-semibold h-16"
>
<i [class]="item.icon"></i>
<span class="sr-only">{{ item.name }}</span>
</a>
</li>
}
</ul>
</nav>
</div>
}
</nav>
<main class="frame-main">
<div class="lg:pl-20 pb-10">
<div class="px-0 py-0">
<router-outlet></router-outlet>
</div>
</div>
</main>
<footer
class="fixed inset-x-0 bottom-0 z-40 h-10 border-t border-gray-200 bg-white dark:border-white/10 dark:bg-gray-900 lg:left-20"
>
<div class="flex h-full items-center justify-between px-4 sm:px-6 lg:px-8">
<span class="text-sm text-gray-500 dark:text-gray-400">
{{ version }}
<i class="fa-regular fa-puzzle-piece ml-2"></i>
{{ providerCount() }}
</span>
<span class="text-sm text-gray-500 dark:text-gray-400">
<span class="connection-dot" [class.connected]="wsConnected()"></span>
{{ time }}
</span>
</div>
</footer>
</div>

View file

@ -0,0 +1,137 @@
// SPDX-Licence-Identifier: EUPL-1.2
import { Component, CUSTOM_ELEMENTS_SCHEMA, Input, OnDestroy, OnInit } 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';
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 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],
templateUrl: './application-frame.component.html',
styles: [
`
.application-frame {
min-height: 100vh;
}
.frame-main {
min-height: calc(100vh - 6.5rem);
}
.connection-dot {
display: inline-block;
width: 6px;
height: 6px;
border-radius: 50%;
background: rgb(107 114 128);
margin-right: 0.375rem;
vertical-align: middle;
}
.connection-dot.connected {
background: rgb(34 197 94);
box-shadow: 0 0 4px rgb(34 197 94);
}
`,
],
})
export class ApplicationFrameComponent implements OnInit, OnDestroy {
@Input() version = 'v0.1.0';
sidebarOpen = false;
userMenuOpen = false;
time = '';
private intervalId: ReturnType<typeof setInterval> | undefined;
/** Static navigation items set by the host application. */
@Input() staticNavigation: NavItem[] = [];
/** Combined navigation: static + dynamic from providers. */
navigation: NavItem[] = [];
userNavigation: NavItem[] = [];
constructor(
public t: TranslationService,
private providerService: ProviderDiscoveryService,
private wsService: WebSocketService,
) {}
/** Provider count from discovery service. */
readonly providerCount = () => this.providerService.providers().length;
/** WebSocket connection status. */
readonly wsConnected = () => this.wsService.connected();
async ngOnInit(): Promise<void> {
this.updateTime();
this.intervalId = setInterval(() => this.updateTime(), 1000);
await this.t.onReady();
this.initUserNavigation();
// Discover providers and build navigation
await this.providerService.discover();
this.buildNavigation();
// Connect WebSocket for real-time updates
this.wsService.connect();
}
ngOnDestroy(): void {
if (this.intervalId) {
clearInterval(this.intervalId);
}
}
private initUserNavigation(): void {
this.userNavigation = [
{
name: this.t._('menu.settings'),
href: 'settings',
icon: 'fa-regular fa-gear',
},
];
}
private buildNavigation(): void {
const dynamicItems = this.providerService
.providers()
.filter((p) => p.element)
.map((p) => ({
name: p.name,
href: p.name.toLowerCase(),
icon: 'fa-regular fa-puzzle-piece fa-2xl shrink-0',
}));
this.navigation = [...this.staticNavigation, ...dynamicItems];
}
private updateTime(): void {
this.time = new Date().toLocaleTimeString();
}
}

View file

@ -0,0 +1,388 @@
// SPDX-Licence-Identifier: EUPL-1.2
import { Component, OnDestroy, OnInit, signal } from '@angular/core';
import { CommonModule } from '@angular/common';
import { ProviderDiscoveryService, ProviderInfo } from '../services/provider-discovery.service';
import { WebSocketService } from '../services/websocket.service';
/**
* SystemTrayFrameComponent is a 380x480 frameless panel showing:
* - Provider status cards from the discovery service
* - Brain connection status
* - MCP server status
* - Quick actions
*
* Ported from core-gui/cmd/lthn-desktop/frontend/src/frame/system-tray.frame.ts
* with dynamic provider status cards.
*/
@Component({
selector: 'system-tray-frame',
standalone: true,
imports: [CommonModule],
template: `
<div class="tray-container">
<!-- Header -->
<div class="tray-header">
<div class="tray-logo">
<i class="fa-regular fa-cube logo-icon"></i>
<span>Core IDE</span>
</div>
<div class="tray-controls">
<button class="control-btn" (click)="settingsMenuOpen = !settingsMenuOpen" title="Settings">
<i class="fa-regular fa-gear"></i>
</button>
</div>
</div>
<!-- Settings dropdown -->
@if (settingsMenuOpen) {
<div class="settings-menu">
@for (item of settingsNavigation; track item.name) {
<button class="settings-item" (click)="settingsMenuOpen = false">
{{ item.name }}
</button>
}
</div>
}
<!-- Connection Status -->
<div class="status-section">
<div class="status-row">
<span class="status-label">Connection</span>
<span class="status-value" [class.active]="wsConnected()">
{{ wsConnected() ? 'Connected' : 'Disconnected' }}
</span>
</div>
<div class="status-row">
<span class="status-label">Providers</span>
<span class="status-value">{{ providers().length }}</span>
</div>
<div class="status-row">
<span class="status-label">Time</span>
<span class="status-value mono">{{ time() }}</span>
</div>
</div>
<!-- Provider Cards -->
<div class="providers-section">
<div class="section-header">Providers</div>
<div class="providers-list">
@for (provider of providers(); track provider.name) {
<div class="provider-card">
<div class="provider-icon">
<i class="fa-regular fa-puzzle-piece"></i>
</div>
<div class="provider-info">
<span class="provider-name">{{ provider.name }}</span>
<span class="provider-path">{{ provider.basePath }}</span>
</div>
<div class="provider-status">
<span class="status-indicator active"></span>
</div>
</div>
} @empty {
<div class="no-providers">No providers registered</div>
}
</div>
</div>
<!-- Footer -->
<div class="tray-footer">
<div class="connection-status" [class.connected]="wsConnected()">
<div class="footer-dot"></div>
<span>{{ wsConnected() ? 'Services Running' : 'Ready' }}</span>
</div>
</div>
</div>
`,
styles: [
`
:host {
display: block;
width: 100%;
height: 100%;
overflow: hidden;
}
.tray-container {
display: flex;
flex-direction: column;
height: 100%;
background: rgb(17 24 39);
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
color: rgb(156 163 175);
border-radius: 0.375rem;
}
.tray-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0.75rem 1rem;
background: rgb(31 41 55);
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
}
.tray-logo {
display: flex;
align-items: center;
gap: 0.5rem;
font-size: 0.9375rem;
font-weight: 600;
color: #ffffff;
}
.logo-icon {
color: rgb(129 140 248);
}
.tray-controls {
display: flex;
gap: 0.5rem;
}
.control-btn {
display: flex;
align-items: center;
justify-content: center;
width: 32px;
height: 32px;
background: transparent;
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 6px;
color: rgb(156 163 175);
cursor: pointer;
transition: all 0.15s ease;
}
.control-btn:hover {
background: rgba(255, 255, 255, 0.05);
border-color: rgba(255, 255, 255, 0.2);
color: #ffffff;
}
.settings-menu {
background: rgb(31 41 55);
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
padding: 0.5rem;
}
.settings-item {
display: block;
width: 100%;
text-align: left;
padding: 0.5rem 0.75rem;
background: transparent;
border: none;
border-radius: 0.25rem;
color: rgb(156 163 175);
font-size: 0.875rem;
cursor: pointer;
}
.settings-item:hover {
background: rgba(255, 255, 255, 0.05);
color: #ffffff;
}
.status-section {
display: flex;
flex-direction: column;
gap: 0.5rem;
padding: 0.875rem 1rem;
background: rgb(31 41 55);
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
}
.status-row {
display: flex;
align-items: center;
justify-content: space-between;
}
.status-label {
font-size: 0.8125rem;
color: rgb(107 114 128);
}
.status-value {
font-size: 0.875rem;
font-weight: 600;
color: #ffffff;
}
.status-value.active {
color: rgb(34 197 94);
}
.status-value.mono {
font-family: 'JetBrains Mono', 'Fira Code', monospace;
font-size: 0.8125rem;
}
.providers-section {
display: flex;
flex-direction: column;
flex: 1;
min-height: 0;
}
.section-header {
padding: 0.625rem 1rem;
font-size: 0.6875rem;
font-weight: 600;
color: rgb(107 114 128);
text-transform: uppercase;
letter-spacing: 0.05em;
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
}
.providers-list {
flex: 1;
overflow-y: auto;
padding: 0.5rem;
}
.provider-card {
display: flex;
align-items: center;
gap: 0.75rem;
padding: 0.625rem 0.75rem;
border-radius: 6px;
transition: background 0.15s ease;
}
.provider-card:hover {
background: rgba(255, 255, 255, 0.05);
}
.provider-icon {
display: flex;
align-items: center;
justify-content: center;
width: 32px;
height: 32px;
background: rgba(129, 140, 248, 0.15);
border-radius: 6px;
color: rgb(129 140 248);
}
.provider-info {
display: flex;
flex-direction: column;
flex: 1;
min-width: 0;
}
.provider-name {
font-size: 0.8125rem;
font-weight: 500;
color: #ffffff;
}
.provider-path {
font-size: 0.6875rem;
color: rgb(107 114 128);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.provider-status {
display: flex;
align-items: center;
}
.status-indicator {
width: 8px;
height: 8px;
border-radius: 50%;
background: rgb(107 114 128);
}
.status-indicator.active {
background: rgb(34 197 94);
box-shadow: 0 0 4px rgb(34 197 94);
}
.no-providers {
display: flex;
align-items: center;
justify-content: center;
padding: 2rem;
color: rgb(107 114 128);
font-size: 0.8125rem;
}
.tray-footer {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0.625rem 1rem;
background: rgb(31 41 55);
border-top: 1px solid rgba(255, 255, 255, 0.1);
}
.connection-status {
display: flex;
align-items: center;
gap: 0.5rem;
font-size: 0.75rem;
color: rgb(107 114 128);
}
.footer-dot {
width: 6px;
height: 6px;
border-radius: 50%;
background: rgb(107 114 128);
}
.connection-status.connected .footer-dot {
background: rgb(34 197 94);
box-shadow: 0 0 4px rgb(34 197 94);
}
.connection-status.connected {
color: rgb(34 197 94);
}
`,
],
})
export class SystemTrayFrameComponent implements OnInit, OnDestroy {
settingsMenuOpen = false;
readonly time = signal('');
private intervalId: ReturnType<typeof setInterval> | undefined;
settingsNavigation = [
{ name: 'Settings', href: '#' },
{ name: 'About', href: '#' },
{ name: 'Check for Updates...', href: '#' },
];
constructor(
private providerService: ProviderDiscoveryService,
private wsService: WebSocketService,
) {}
readonly providers = () => this.providerService.providers();
readonly wsConnected = () => this.wsService.connected();
async ngOnInit(): Promise<void> {
this.updateTime();
this.intervalId = setInterval(() => this.updateTime(), 1000);
// Discover providers for status cards
await this.providerService.discover();
}
ngOnDestroy(): void {
if (this.intervalId) {
clearInterval(this.intervalId);
}
}
private updateTime(): void {
this.time.set(new Date().toLocaleTimeString());
}
}

16
ui/src/index.ts Normal file
View file

@ -0,0 +1,16 @@
// SPDX-Licence-Identifier: EUPL-1.2
// Frame components
export { ApplicationFrameComponent } from './frame/application-frame.component';
export { SystemTrayFrameComponent } from './frame/system-tray-frame.component';
// Services
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';
// Components
export { ProviderHostComponent } from './components/provider-host.component';
export { ProviderNavComponent, type NavItem } from './components/provider-nav.component';
export { StatusBarComponent } from './components/status-bar.component';

View file

@ -1,7 +1,10 @@
// SPDX-Licence-Identifier: EUPL-1.2
import { platformBrowser } from '@angular/platform-browser';
import { AppModule } from './app/app-module';
platformBrowser().bootstrapModule(AppModule, {
ngZoneEventCoalescing: true,
})
.catch(err => console.error(err));
platformBrowser()
.bootstrapModule(AppModule, {
ngZoneEventCoalescing: true,
})
.catch((err) => console.error(err));

View file

@ -0,0 +1,29 @@
// SPDX-Licence-Identifier: EUPL-1.2
import { Injectable } from '@angular/core';
/**
* ApiConfigService provides a configurable base URL for all API calls.
* Defaults to the current origin (Wails embedded) but can be overridden
* for development or remote connections.
*/
@Injectable({ providedIn: 'root' })
export class ApiConfigService {
private _baseUrl = '';
/** The API base URL without a trailing slash. */
get baseUrl(): string {
return this._baseUrl;
}
/** Override the base URL. Strips trailing slash if present. */
set baseUrl(url: string) {
this._baseUrl = url.replace(/\/+$/, '');
}
/** Build a full URL for the given path. */
url(path: string): string {
const cleanPath = path.startsWith('/') ? path : `/${path}`;
return `${this._baseUrl}${cleanPath}`;
}
}

View file

@ -0,0 +1,91 @@
// SPDX-Licence-Identifier: EUPL-1.2
import { Injectable, signal } from '@angular/core';
import { ApiConfigService } from './api-config.service';
/**
* Describes the element specification for a renderable provider.
*/
export interface ElementSpec {
tag: string;
source: string;
}
/**
* Describes a provider as returned by GET /api/v1/providers.
*/
export interface ProviderInfo {
name: string;
basePath: string;
status?: string;
element?: ElementSpec;
channels?: string[];
}
/**
* ProviderDiscoveryService fetches the list of registered providers from
* the API server and dynamically loads custom element scripts for any
* Renderable providers.
*/
@Injectable({ providedIn: 'root' })
export class ProviderDiscoveryService {
private readonly _providers = signal<ProviderInfo[]>([]);
readonly providers = this._providers.asReadonly();
private discovered = false;
constructor(private apiConfig: ApiConfigService) {}
/** Fetch providers from the API and load custom element scripts. */
async discover(): Promise<void> {
if (this.discovered) {
return;
}
try {
const res = await fetch(this.apiConfig.url('/api/v1/providers'));
if (!res.ok) {
console.warn('ProviderDiscoveryService: failed to fetch providers:', res.statusText);
return;
}
const data = await res.json();
const providers: ProviderInfo[] = data.providers ?? [];
this._providers.set(providers);
this.discovered = true;
// Load custom elements for Renderable providers
for (const p of providers) {
if (p.element?.tag && p.element?.source) {
await this.loadElement(p.element.tag, p.element.source);
}
}
} catch (err) {
console.warn('ProviderDiscoveryService: discovery failed:', err);
}
}
/** Refresh the provider list (force re-discovery). */
async refresh(): Promise<void> {
this.discovered = false;
await this.discover();
}
/** Dynamically load a custom element script if not already registered. */
private async loadElement(tag: string, source: string): Promise<void> {
if (customElements.get(tag)) {
return;
}
const script = document.createElement('script');
script.type = 'module';
script.src = source.startsWith('http') ? source : this.apiConfig.url(source);
document.head.appendChild(script);
try {
await customElements.whenDefined(tag);
} catch (err) {
console.warn(`ProviderDiscoveryService: failed to load element <${tag}>:`, err);
}
}
}

View file

@ -0,0 +1,66 @@
// SPDX-Licence-Identifier: EUPL-1.2
import { Injectable } from '@angular/core';
import { ApiConfigService } from './api-config.service';
/**
* TranslationService provides a simple key-value translation lookup.
* In production mode it fetches translations from the API; in development
* it falls back to returning the key as-is.
*/
@Injectable({ providedIn: 'root' })
export class TranslationService {
private translations = new Map<string, string>();
private loaded = false;
private loadingPromise: Promise<void>;
constructor(private apiConfig: ApiConfigService) {
this.loadingPromise = this.loadTranslations('en');
}
/** Reload translations for a given language. */
reload(lang: string): Promise<void> {
this.loaded = false;
this.loadingPromise = this.loadTranslations(lang);
return this.loadingPromise;
}
/** Translate a key. Returns the key itself if no translation is found. */
translate(key: string): string {
if (!this.loaded) {
return key;
}
return this.translations.get(key) ?? key;
}
/** Shorthand for translate(). */
_ = (key: string): string => this.translate(key);
/** Wait for the initial translation load to complete. */
onReady(): Promise<void> {
return this.loadingPromise;
}
private async loadTranslations(lang: string): Promise<void> {
try {
const res = await fetch(this.apiConfig.url(`/api/v1/i18n/${lang}`));
if (!res.ok) {
// API not available — translations will fall through to keys
this.loaded = true;
return;
}
const messages: Record<string, string> = await res.json();
this.translations.clear();
for (const key in messages) {
if (Object.prototype.hasOwnProperty.call(messages, key)) {
this.translations.set(key, messages[key]);
}
}
this.loaded = true;
} catch {
// Silently fall through — key passthrough is acceptable
this.loaded = true;
}
}
}

View file

@ -0,0 +1,126 @@
// SPDX-Licence-Identifier: EUPL-1.2
import { Injectable, OnDestroy, signal } from '@angular/core';
import { ApiConfigService } from './api-config.service';
export interface WSMessage {
channel: string;
data: unknown;
}
/**
* WebSocketService manages a persistent WebSocket connection with automatic
* reconnection. Follows the same pattern used by Mining's websocket.service.ts.
*/
@Injectable({ providedIn: 'root' })
export class WebSocketService implements OnDestroy {
private ws: WebSocket | null = null;
private reconnectTimer: ReturnType<typeof setTimeout> | null = null;
private listeners = new Map<string, Set<(data: unknown) => void>>();
private reconnectDelay = 1000;
private maxReconnectDelay = 30000;
private shouldReconnect = true;
readonly connected = signal(false);
constructor(private apiConfig: ApiConfigService) {}
/** Open the WebSocket connection. */
connect(path = '/ws'): void {
if (this.ws) {
return;
}
this.shouldReconnect = true;
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
const base = this.apiConfig.baseUrl || window.location.origin;
const wsBase = base.replace(/^http/, 'ws');
const url = `${wsBase.length > 0 ? wsBase : `${protocol}//${window.location.host}`}${path}`;
this.ws = new WebSocket(url);
this.ws.onopen = () => {
this.connected.set(true);
this.reconnectDelay = 1000;
};
this.ws.onclose = () => {
this.connected.set(false);
this.ws = null;
this.scheduleReconnect(path);
};
this.ws.onerror = () => {
this.ws?.close();
};
this.ws.onmessage = (event) => {
try {
const msg: WSMessage = JSON.parse(event.data as string);
this.dispatch(msg.channel, msg.data);
} catch {
// Silently ignore malformed messages
}
};
}
/** Subscribe to a channel. Returns an unsubscribe function. */
on(channel: string, callback: (data: unknown) => void): () => void {
if (!this.listeners.has(channel)) {
this.listeners.set(channel, new Set());
}
this.listeners.get(channel)!.add(callback);
return () => {
const set = this.listeners.get(channel);
if (set) {
set.delete(callback);
if (set.size === 0) {
this.listeners.delete(channel);
}
}
};
}
/** Send a message over the WebSocket. */
send(channel: string, data: unknown): void {
if (this.ws?.readyState === WebSocket.OPEN) {
this.ws.send(JSON.stringify({ channel, data }));
}
}
/** Disconnect and stop reconnecting. */
disconnect(): void {
this.shouldReconnect = false;
if (this.reconnectTimer) {
clearTimeout(this.reconnectTimer);
this.reconnectTimer = null;
}
this.ws?.close();
this.ws = null;
}
ngOnDestroy(): void {
this.disconnect();
}
private dispatch(channel: string, data: unknown): void {
// Exact match
this.listeners.get(channel)?.forEach((cb) => cb(data));
// Wildcard match
this.listeners.get('*')?.forEach((cb) => cb({ channel, data }));
}
private scheduleReconnect(path: string): void {
if (!this.shouldReconnect) {
return;
}
this.reconnectTimer = setTimeout(() => {
this.reconnectTimer = null;
this.connect(path);
}, this.reconnectDelay);
this.reconnectDelay = Math.min(this.reconnectDelay * 2, this.maxReconnectDelay);
}
}

View file

@ -1,5 +1,3 @@
/* To learn more about Typescript configuration file: https://www.typescriptlang.org/docs/handbook/tsconfig-json.html. */
/* To learn more about Angular compiler options: https://angular.dev/reference/configs/angular-compiler-options. */
{
"extends": "./tsconfig.json",
"compilerOptions": {

View file

@ -1,5 +1,3 @@
/* To learn more about Typescript configuration file: https://www.typescriptlang.org/docs/handbook/tsconfig-json.html. */
/* To learn more about Angular compiler options: https://angular.dev/reference/configs/angular-compiler-options. */
{
"compileOnSave": false,
"compilerOptions": {
@ -13,7 +11,12 @@
"experimentalDecorators": true,
"importHelpers": true,
"target": "ES2022",
"module": "preserve"
"module": "preserve",
"baseUrl": "./",
"paths": {
"@core/gui-ui": ["src/index.ts"],
"@core/gui-ui/*": ["src/*"]
}
},
"angularCompilerOptions": {
"enableI18nLegacyMessageIdFormat": false,