feat(ui): add app shell framework with provider discovery
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:
parent
de35a7ab16
commit
0dcc42c7fb
18 changed files with 2824 additions and 33 deletions
|
|
@ -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
1422
ui/package-lock.json
generated
File diff suppressed because it is too large
Load diff
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "display",
|
||||
"version": "0.0.0",
|
||||
"name": "@core/gui-ui",
|
||||
"version": "0.1.0",
|
||||
"scripts": {
|
||||
"ng": "ng",
|
||||
"start": "ng serve",
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
96
ui/src/components/provider-host.component.ts
Normal file
96
ui/src/components/provider-host.component.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
132
ui/src/components/provider-nav.component.ts
Normal file
132
ui/src/components/provider-nav.component.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
122
ui/src/components/status-bar.component.ts
Normal file
122
ui/src/components/status-bar.component.ts
Normal 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());
|
||||
}
|
||||
}
|
||||
179
ui/src/frame/application-frame.component.html
Normal file
179
ui/src/frame/application-frame.component.html
Normal 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>
|
||||
137
ui/src/frame/application-frame.component.ts
Normal file
137
ui/src/frame/application-frame.component.ts
Normal 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();
|
||||
}
|
||||
}
|
||||
388
ui/src/frame/system-tray-frame.component.ts
Normal file
388
ui/src/frame/system-tray-frame.component.ts
Normal 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
16
ui/src/index.ts
Normal 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';
|
||||
|
|
@ -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));
|
||||
|
|
|
|||
29
ui/src/services/api-config.service.ts
Normal file
29
ui/src/services/api-config.service.ts
Normal 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}`;
|
||||
}
|
||||
}
|
||||
91
ui/src/services/provider-discovery.service.ts
Normal file
91
ui/src/services/provider-discovery.service.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
66
ui/src/services/translation.service.ts
Normal file
66
ui/src/services/translation.service.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
126
ui/src/services/websocket.service.ts
Normal file
126
ui/src/services/websocket.service.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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": {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue