feat(gui): wire shell routes and provider previews
This commit is contained in:
parent
fdff5435c2
commit
f2eb9f03c4
12 changed files with 549 additions and 236 deletions
|
|
@ -3,11 +3,13 @@
|
|||
import { DoBootstrap, Injector, NgModule, provideBrowserGlobalErrorListeners } from '@angular/core';
|
||||
import { BrowserModule } from '@angular/platform-browser';
|
||||
import { createCustomElement } from '@angular/elements';
|
||||
import { RouterModule } from '@angular/router';
|
||||
|
||||
import { App } from './app';
|
||||
import { routes } from './app.routes';
|
||||
|
||||
@NgModule({
|
||||
imports: [BrowserModule, App],
|
||||
imports: [BrowserModule, App, RouterModule.forRoot(routes)],
|
||||
providers: [provideBrowserGlobalErrorListeners()],
|
||||
})
|
||||
export class AppModule implements DoBootstrap {
|
||||
|
|
|
|||
|
|
@ -1,99 +0,0 @@
|
|||
<main class="display-shell">
|
||||
<section class="hero">
|
||||
<div class="hero-copy">
|
||||
<p class="eyebrow">Core GUI</p>
|
||||
<h1>{{ title() }}</h1>
|
||||
<p class="subtitle">{{ subtitle() }}</p>
|
||||
<p class="body">
|
||||
A compact operator surface for desktop workflows, provider discovery, and realtime
|
||||
backend status.
|
||||
</p>
|
||||
|
||||
<div class="hero-actions">
|
||||
<button type="button" class="primary-action" (click)="refreshProviders()">
|
||||
Refresh providers
|
||||
</button>
|
||||
<a class="secondary-action" [href]="apiBase()" target="_blank" rel="noreferrer">
|
||||
Open API endpoint
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="hero-meta">
|
||||
<div class="meta-card">
|
||||
<span class="meta-label">Connection</span>
|
||||
<strong [class.good]="connected()">{{ connected() ? 'Live' : 'Reconnecting' }}</strong>
|
||||
</div>
|
||||
<div class="meta-card">
|
||||
<span class="meta-label">Providers</span>
|
||||
<strong>{{ providerCount() }}</strong>
|
||||
</div>
|
||||
<div class="meta-card">
|
||||
<span class="meta-label">Local time</span>
|
||||
<strong>{{ clock() | date: 'mediumTime' }}</strong>
|
||||
</div>
|
||||
<div class="meta-card">
|
||||
<span class="meta-label">API base</span>
|
||||
<strong class="mono">{{ apiBase() }}</strong>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="content-grid">
|
||||
<article class="feature-panel">
|
||||
<div class="panel-heading">
|
||||
<div>
|
||||
<p class="eyebrow">Discovered providers</p>
|
||||
<h2>Renderable capabilities</h2>
|
||||
</div>
|
||||
<span class="pill">{{ providerCount() }} total</span>
|
||||
</div>
|
||||
|
||||
<div class="provider-list" *ngIf="featuredProviders().length; else emptyState">
|
||||
<div class="provider-row" *ngFor="let provider of featuredProviders(); trackBy: trackByProvider">
|
||||
<div class="provider-icon">
|
||||
<span>{{ provider.name.slice(0, 1).toUpperCase() }}</span>
|
||||
</div>
|
||||
<div class="provider-copy">
|
||||
<strong>{{ provider.name }}</strong>
|
||||
<span>{{ provider.basePath }}</span>
|
||||
<small *ngIf="provider.element?.tag" class="mono">
|
||||
{{ provider.element?.tag }} · {{ provider.element?.source }}
|
||||
</small>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ng-template #emptyState>
|
||||
<div class="empty-state">
|
||||
<strong>No providers discovered yet.</strong>
|
||||
<span>The shell will populate this view once the backend exposes provider metadata.</span>
|
||||
</div>
|
||||
</ng-template>
|
||||
</article>
|
||||
|
||||
<article class="feature-panel accent">
|
||||
<div class="panel-heading">
|
||||
<div>
|
||||
<p class="eyebrow">Live wiring</p>
|
||||
<h2>What this shell keeps online</h2>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ul class="feature-list">
|
||||
<li>
|
||||
<strong>Provider discovery</strong>
|
||||
<span>Loads provider metadata and registers custom element scripts automatically.</span>
|
||||
</li>
|
||||
<li>
|
||||
<strong>Realtime status</strong>
|
||||
<span>Tracks the websocket connection used for backend events.</span>
|
||||
</li>
|
||||
<li>
|
||||
<strong>Desktop bridge</strong>
|
||||
<span>Renders in the Wails webview and stays responsive to the local runtime.</span>
|
||||
</li>
|
||||
</ul>
|
||||
</article>
|
||||
</section>
|
||||
</main>
|
||||
17
ui/src/app/app.routes.ts
Normal file
17
ui/src/app/app.routes.ts
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
import { Routes } from '@angular/router';
|
||||
import { ApplicationFrameComponent } from '../frame/application-frame.component';
|
||||
import { DashboardComponent } from './dashboard.component';
|
||||
import { ProviderHostComponent } from '../components/provider-host.component';
|
||||
import { SettingsComponent } from './settings.component';
|
||||
|
||||
export const routes: Routes = [
|
||||
{
|
||||
path: '',
|
||||
component: ApplicationFrameComponent,
|
||||
children: [
|
||||
{ path: '', component: DashboardComponent },
|
||||
{ path: 'provider/:provider', component: ProviderHostComponent },
|
||||
{ path: 'settings', component: SettingsComponent },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
|
@ -1,64 +1,10 @@
|
|||
import { CommonModule } from '@angular/common';
|
||||
import { Component, DestroyRef, OnDestroy, computed, effect, inject, signal } from '@angular/core';
|
||||
import { ApiConfigService } from '../services/api-config.service';
|
||||
import { ProviderDiscoveryService, type ProviderInfo } from '../services/provider-discovery.service';
|
||||
import { TranslationService } from '../services/translation.service';
|
||||
import { WebSocketService } from '../services/websocket.service';
|
||||
import { Component } from '@angular/core';
|
||||
import { RouterOutlet } from '@angular/router';
|
||||
|
||||
@Component({
|
||||
selector: 'core-display',
|
||||
imports: [CommonModule],
|
||||
templateUrl: './app.html',
|
||||
imports: [RouterOutlet],
|
||||
template: '<router-outlet></router-outlet>',
|
||||
standalone: true,
|
||||
})
|
||||
export class App implements OnDestroy {
|
||||
private readonly discovery = inject(ProviderDiscoveryService);
|
||||
private readonly apiConfig = inject(ApiConfigService);
|
||||
private readonly translations = inject(TranslationService);
|
||||
private readonly websocket = inject(WebSocketService);
|
||||
private readonly destroyRef = inject(DestroyRef);
|
||||
|
||||
protected readonly title = signal('Core GUI');
|
||||
protected readonly subtitle = signal('Desktop orchestration console');
|
||||
protected readonly clock = signal(new Date());
|
||||
|
||||
protected readonly providers = this.discovery.providers;
|
||||
protected readonly providerCount = computed(() => this.providers().length);
|
||||
protected readonly connected = this.websocket.connected;
|
||||
protected readonly apiBase = computed(() => this.apiConfig.baseUrl || window.location.origin);
|
||||
|
||||
protected readonly featuredProviders = computed<ProviderInfo[]>(() =>
|
||||
this.providers().filter((provider) => provider.element?.tag).slice(0, 6),
|
||||
);
|
||||
|
||||
constructor() {
|
||||
const tick = setInterval(() => this.clock.set(new Date()), 1000);
|
||||
this.destroyRef.onDestroy(() => clearInterval(tick));
|
||||
|
||||
effect(() => {
|
||||
if (this.connected()) {
|
||||
document.documentElement.setAttribute('data-connected', 'true');
|
||||
} else {
|
||||
document.documentElement.removeAttribute('data-connected');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async ngOnInit(): Promise<void> {
|
||||
await this.translations.onReady();
|
||||
await this.discovery.discover();
|
||||
this.websocket.connect();
|
||||
}
|
||||
|
||||
ngOnDestroy(): void {
|
||||
this.websocket.disconnect();
|
||||
}
|
||||
|
||||
refreshProviders(): Promise<void> {
|
||||
return this.discovery.refresh();
|
||||
}
|
||||
|
||||
trackByProvider(_: number, provider: ProviderInfo): string {
|
||||
return provider.name;
|
||||
}
|
||||
}
|
||||
export class App {}
|
||||
|
|
|
|||
235
ui/src/app/dashboard.component.ts
Normal file
235
ui/src/app/dashboard.component.ts
Normal file
|
|
@ -0,0 +1,235 @@
|
|||
import { CommonModule } from '@angular/common';
|
||||
import { Component, DestroyRef, computed, effect, inject, signal } from '@angular/core';
|
||||
import { ApiConfigService } from '../services/api-config.service';
|
||||
import { ProviderDiscoveryService, type ProviderInfo } from '../services/provider-discovery.service';
|
||||
import { TranslationService } from '../services/translation.service';
|
||||
import { WebSocketService } from '../services/websocket.service';
|
||||
import { ProviderHostComponent } from '../components/provider-host.component';
|
||||
|
||||
@Component({
|
||||
selector: 'dashboard-view',
|
||||
imports: [CommonModule, ProviderHostComponent],
|
||||
template: `
|
||||
<main class="display-shell">
|
||||
<section class="hero">
|
||||
<div class="hero-copy">
|
||||
<p class="eyebrow">Core GUI</p>
|
||||
<h1>{{ title() }}</h1>
|
||||
<p class="subtitle">{{ subtitle() }}</p>
|
||||
<p class="body">
|
||||
A compact operator surface for desktop workflows, provider discovery, and realtime
|
||||
backend status.
|
||||
</p>
|
||||
|
||||
<div class="hero-actions">
|
||||
<button type="button" class="primary-action" (click)="refreshProviders()">
|
||||
Refresh providers
|
||||
</button>
|
||||
<a class="secondary-action" [href]="apiBase()" target="_blank" rel="noreferrer">
|
||||
Open API endpoint
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="hero-meta">
|
||||
<div class="meta-card">
|
||||
<span class="meta-label">Connection</span>
|
||||
<strong [class.good]="connected()">{{ connected() ? 'Live' : 'Reconnecting' }}</strong>
|
||||
</div>
|
||||
<div class="meta-card">
|
||||
<span class="meta-label">Providers</span>
|
||||
<strong>{{ providerCount() }}</strong>
|
||||
</div>
|
||||
<div class="meta-card">
|
||||
<span class="meta-label">Local time</span>
|
||||
<strong>{{ clock() | date: 'mediumTime' }}</strong>
|
||||
</div>
|
||||
<div class="meta-card">
|
||||
<span class="meta-label">API base</span>
|
||||
<strong class="mono">{{ apiBase() }}</strong>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="content-grid">
|
||||
<article class="feature-panel">
|
||||
<div class="panel-heading">
|
||||
<div>
|
||||
<p class="eyebrow">Discovered providers</p>
|
||||
<h2>Renderable capabilities</h2>
|
||||
</div>
|
||||
<span class="pill">{{ providerCount() }} total</span>
|
||||
</div>
|
||||
|
||||
<div class="provider-list" *ngIf="featuredProviders().length; else emptyState">
|
||||
<button
|
||||
type="button"
|
||||
class="provider-row"
|
||||
*ngFor="let provider of featuredProviders(); trackBy: trackByProvider"
|
||||
[class.selected]="selectedProviderName() === provider.name"
|
||||
(click)="selectProvider(provider)"
|
||||
>
|
||||
<div class="provider-icon">
|
||||
<span>{{ provider.name.slice(0, 1).toUpperCase() }}</span>
|
||||
</div>
|
||||
<div class="provider-copy">
|
||||
<strong>{{ provider.name }}</strong>
|
||||
<span>{{ provider.basePath }}</span>
|
||||
<small *ngIf="provider.element?.tag" class="mono">
|
||||
{{ provider.element?.tag }} · {{ provider.element?.source }}
|
||||
</small>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<ng-template #emptyState>
|
||||
<div class="empty-state">
|
||||
<strong>No providers discovered yet.</strong>
|
||||
<span>The shell will populate this view once the backend exposes provider metadata.</span>
|
||||
</div>
|
||||
</ng-template>
|
||||
</article>
|
||||
|
||||
<article class="feature-panel accent">
|
||||
<div class="panel-heading">
|
||||
<div>
|
||||
<p class="eyebrow">Live wiring</p>
|
||||
<h2>What this shell keeps online</h2>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ul class="feature-list">
|
||||
<li>
|
||||
<strong>Provider discovery</strong>
|
||||
<span>Loads provider metadata and registers custom element scripts automatically.</span>
|
||||
</li>
|
||||
<li>
|
||||
<strong>Realtime status</strong>
|
||||
<span>Tracks the websocket connection used for backend events.</span>
|
||||
</li>
|
||||
<li>
|
||||
<strong>Desktop bridge</strong>
|
||||
<span>Renders in the Wails webview and stays responsive to the local runtime.</span>
|
||||
</li>
|
||||
</ul>
|
||||
</article>
|
||||
|
||||
<article class="feature-panel preview-panel">
|
||||
<div class="panel-heading">
|
||||
<div>
|
||||
<p class="eyebrow">Provider preview</p>
|
||||
<h2>{{ selectedProviderTitle() }}</h2>
|
||||
</div>
|
||||
<span class="pill" [class.good]="hasRenderableSelection()">
|
||||
{{ hasRenderableSelection() ? 'Renderable' : 'Select one' }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
@if (selectedRenderableProvider(); as provider) {
|
||||
<div class="preview-meta">
|
||||
<span class="preview-field">
|
||||
<label>Base path</label>
|
||||
<strong>{{ provider.basePath }}</strong>
|
||||
</span>
|
||||
<span class="preview-field">
|
||||
<label>Tag</label>
|
||||
<strong class="mono">{{ provider.element?.tag }}</strong>
|
||||
</span>
|
||||
</div>
|
||||
<div class="preview-host">
|
||||
<provider-host [tag]="provider.element?.tag || ''" [apiUrl]="apiBase()"></provider-host>
|
||||
</div>
|
||||
} @else {
|
||||
<div class="empty-state">
|
||||
<strong>No renderable provider selected.</strong>
|
||||
<span>Pick a provider with a custom element to load its live preview here.</span>
|
||||
</div>
|
||||
}
|
||||
</article>
|
||||
</section>
|
||||
</main>
|
||||
`,
|
||||
})
|
||||
export class DashboardComponent {
|
||||
private readonly discovery = inject(ProviderDiscoveryService);
|
||||
private readonly apiConfig = inject(ApiConfigService);
|
||||
private readonly translations = inject(TranslationService);
|
||||
private readonly websocket = inject(WebSocketService);
|
||||
private readonly destroyRef = inject(DestroyRef);
|
||||
|
||||
protected readonly title = signal('Core GUI');
|
||||
protected readonly subtitle = signal('Desktop orchestration console');
|
||||
protected readonly clock = signal(new Date());
|
||||
protected readonly selectedProviderName = signal('');
|
||||
|
||||
protected readonly providers = this.discovery.providers;
|
||||
protected readonly providerCount = computed(() => this.providers().length);
|
||||
protected readonly connected = this.websocket.connected;
|
||||
protected readonly apiBase = computed(() => this.apiConfig.baseUrl || window.location.origin);
|
||||
|
||||
protected readonly featuredProviders = computed<ProviderInfo[]>(() =>
|
||||
this.providers().filter((provider) => provider.element?.tag).slice(0, 6),
|
||||
);
|
||||
|
||||
protected readonly selectedRenderableProvider = computed<ProviderInfo | null>(() => {
|
||||
const selection = this.selectedProviderName();
|
||||
if (!selection) {
|
||||
return this.featuredProviders()[0] ?? null;
|
||||
}
|
||||
|
||||
return (
|
||||
this.providers().find((provider) => provider.name === selection && provider.element?.tag) ??
|
||||
this.featuredProviders()[0] ??
|
||||
null
|
||||
);
|
||||
});
|
||||
|
||||
protected readonly selectedProviderTitle = computed(() => {
|
||||
const provider = this.selectedRenderableProvider();
|
||||
return provider?.name ?? 'Preview';
|
||||
});
|
||||
|
||||
constructor() {
|
||||
const tick = setInterval(() => this.clock.set(new Date()), 1000);
|
||||
this.destroyRef.onDestroy(() => clearInterval(tick));
|
||||
|
||||
effect(() => {
|
||||
if (this.connected()) {
|
||||
document.documentElement.setAttribute('data-connected', 'true');
|
||||
} else {
|
||||
document.documentElement.removeAttribute('data-connected');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async ngOnInit(): Promise<void> {
|
||||
await this.translations.onReady();
|
||||
await this.discovery.discover();
|
||||
this.websocket.connect();
|
||||
}
|
||||
|
||||
ngOnDestroy(): void {
|
||||
this.websocket.disconnect();
|
||||
}
|
||||
|
||||
async refreshProviders(): Promise<void> {
|
||||
await this.discovery.refresh();
|
||||
if (!this.selectedRenderableProvider()) {
|
||||
this.selectedProviderName.set('');
|
||||
}
|
||||
}
|
||||
|
||||
selectProvider(provider: ProviderInfo): void {
|
||||
if (provider.element?.tag) {
|
||||
this.selectedProviderName.set(provider.name);
|
||||
}
|
||||
}
|
||||
|
||||
hasRenderableSelection(): boolean {
|
||||
return !!this.selectedRenderableProvider();
|
||||
}
|
||||
|
||||
trackByProvider(_: number, provider: ProviderInfo): string {
|
||||
return provider.name;
|
||||
}
|
||||
}
|
||||
92
ui/src/app/settings.component.ts
Normal file
92
ui/src/app/settings.component.ts
Normal file
|
|
@ -0,0 +1,92 @@
|
|||
import { CommonModule } from '@angular/common';
|
||||
import { Component, inject } from '@angular/core';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import { ApiConfigService } from '../services/api-config.service';
|
||||
import { ProviderDiscoveryService } from '../services/provider-discovery.service';
|
||||
import { WebSocketService } from '../services/websocket.service';
|
||||
|
||||
@Component({
|
||||
selector: 'settings-view',
|
||||
imports: [CommonModule, FormsModule],
|
||||
template: `
|
||||
<main class="display-shell">
|
||||
<section class="hero settings-hero">
|
||||
<div class="hero-copy">
|
||||
<p class="eyebrow">Settings</p>
|
||||
<h1>Connection surface</h1>
|
||||
<p class="subtitle">Adjust the API endpoint the shell uses for discovery and previews.</p>
|
||||
<p class="body">
|
||||
The frontend can target the embedded Wails origin or a remote Core API during
|
||||
development. Changes apply immediately to future discovery and provider-host requests.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="hero-meta">
|
||||
<div class="meta-card">
|
||||
<span class="meta-label">Providers</span>
|
||||
<strong>{{ providerCount() }}</strong>
|
||||
</div>
|
||||
<div class="meta-card">
|
||||
<span class="meta-label">Connection</span>
|
||||
<strong [class.good]="connected()">{{ connected() ? 'Live' : 'Reconnecting' }}</strong>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="content-grid single-column">
|
||||
<article class="feature-panel">
|
||||
<div class="panel-heading">
|
||||
<div>
|
||||
<p class="eyebrow">API</p>
|
||||
<h2>Base URL</h2>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="settings-form">
|
||||
<label class="settings-field">
|
||||
<span>API base URL</span>
|
||||
<input
|
||||
type="url"
|
||||
[(ngModel)]="draftBaseUrl"
|
||||
placeholder="http://127.0.0.1:8080"
|
||||
autocomplete="off"
|
||||
spellcheck="false"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<div class="settings-actions">
|
||||
<button type="button" class="primary-action" (click)="applyBaseUrl()">
|
||||
Apply
|
||||
</button>
|
||||
<button type="button" class="secondary-action" (click)="resetBaseUrl()">
|
||||
Use local origin
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
</section>
|
||||
</main>
|
||||
`,
|
||||
})
|
||||
export class SettingsComponent {
|
||||
private readonly apiConfig = inject(ApiConfigService);
|
||||
private readonly discovery = inject(ProviderDiscoveryService);
|
||||
private readonly websocket = inject(WebSocketService);
|
||||
|
||||
draftBaseUrl = this.apiConfig.baseUrl;
|
||||
|
||||
readonly providerCount = () => this.discovery.providers().length;
|
||||
readonly connected = () => this.websocket.connected();
|
||||
|
||||
applyBaseUrl(): void {
|
||||
this.apiConfig.baseUrl = this.draftBaseUrl.trim();
|
||||
this.discovery.refresh();
|
||||
this.websocket.disconnect();
|
||||
this.websocket.connect();
|
||||
}
|
||||
|
||||
resetBaseUrl(): void {
|
||||
this.draftBaseUrl = '';
|
||||
this.applyBaseUrl();
|
||||
}
|
||||
}
|
||||
|
|
@ -4,13 +4,16 @@ import {
|
|||
Component,
|
||||
CUSTOM_ELEMENTS_SCHEMA,
|
||||
ElementRef,
|
||||
DestroyRef,
|
||||
Input,
|
||||
OnChanges,
|
||||
OnInit,
|
||||
inject,
|
||||
Renderer2,
|
||||
ViewChild,
|
||||
} from '@angular/core';
|
||||
import { ActivatedRoute } from '@angular/router';
|
||||
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
|
||||
import { ApiConfigService } from '../services/api-config.service';
|
||||
import { ProviderDiscoveryService } from '../services/provider-discovery.service';
|
||||
|
||||
|
|
@ -35,6 +38,16 @@ import { ProviderDiscoveryService } from '../services/provider-discovery.service
|
|||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.provider-host-empty {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
min-height: 100%;
|
||||
padding: 1.5rem;
|
||||
color: rgb(156 163 175);
|
||||
background: rgba(255, 255, 255, 0.02);
|
||||
text-align: center;
|
||||
}
|
||||
`,
|
||||
],
|
||||
})
|
||||
|
|
@ -46,6 +59,7 @@ export class ProviderHostComponent implements OnInit, OnChanges {
|
|||
@Input() apiUrl = '';
|
||||
|
||||
@ViewChild('container', { static: true }) container!: ElementRef;
|
||||
private readonly destroyRef = inject(DestroyRef);
|
||||
|
||||
constructor(
|
||||
private renderer: Renderer2,
|
||||
|
|
@ -55,8 +69,8 @@ export class ProviderHostComponent implements OnInit, OnChanges {
|
|||
) {}
|
||||
|
||||
ngOnInit(): void {
|
||||
this.route.params.subscribe((params) => {
|
||||
const providerName = params['provider'];
|
||||
this.route.params.pipe(takeUntilDestroyed(this.destroyRef)).subscribe((params) => {
|
||||
const providerName = this.normalizeProviderName(params['provider']);
|
||||
if (providerName) {
|
||||
const provider = this.providerService
|
||||
.providers()
|
||||
|
|
@ -64,8 +78,12 @@ export class ProviderHostComponent implements OnInit, OnChanges {
|
|||
if (provider?.element?.tag) {
|
||||
this.tag = provider.element.tag;
|
||||
this.renderElement();
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
this.tag = '';
|
||||
this.renderEmptyState();
|
||||
});
|
||||
}
|
||||
|
||||
|
|
@ -74,7 +92,12 @@ export class ProviderHostComponent implements OnInit, OnChanges {
|
|||
}
|
||||
|
||||
private renderElement(): void {
|
||||
if (!this.tag || !this.container) {
|
||||
if (!this.container) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!this.tag) {
|
||||
this.renderEmptyState();
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
@ -93,4 +116,35 @@ export class ProviderHostComponent implements OnInit, OnChanges {
|
|||
}
|
||||
this.renderer.appendChild(native, el);
|
||||
}
|
||||
|
||||
private renderEmptyState(): void {
|
||||
if (!this.container) {
|
||||
return;
|
||||
}
|
||||
|
||||
const native = this.container.nativeElement;
|
||||
while (native.firstChild) {
|
||||
this.renderer.removeChild(native, native.firstChild);
|
||||
}
|
||||
|
||||
const empty = this.renderer.createElement('div');
|
||||
this.renderer.addClass(empty, 'provider-host-empty');
|
||||
this.renderer.appendChild(
|
||||
empty,
|
||||
this.renderer.createText('Select a renderable provider to preview its custom element.'),
|
||||
);
|
||||
this.renderer.appendChild(native, empty);
|
||||
}
|
||||
|
||||
private normalizeProviderName(value: unknown): string {
|
||||
if (typeof value !== 'string') {
|
||||
return '';
|
||||
}
|
||||
|
||||
try {
|
||||
return decodeURIComponent(value).trim();
|
||||
} catch {
|
||||
return value.trim();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -117,7 +117,7 @@ export class ProviderNavComponent {
|
|||
.filter((p: ProviderInfo) => p.element)
|
||||
.map((p: ProviderInfo) => ({
|
||||
name: p.name,
|
||||
href: p.name.toLowerCase(),
|
||||
href: `/provider/${encodeURIComponent(p.name.toLowerCase())}`,
|
||||
icon: 'fa-regular fa-puzzle-piece fa-2xl',
|
||||
element: p.element,
|
||||
}));
|
||||
|
|
|
|||
|
|
@ -12,7 +12,7 @@ import { WebSocketService } from '../services/websocket.service';
|
|||
selector: 'status-bar',
|
||||
standalone: true,
|
||||
template: `
|
||||
<footer class="status-bar">
|
||||
<footer class="status-bar" [style.--sidebar-width]="sidebarWidth">
|
||||
<div class="status-left">
|
||||
<span class="status-item version">{{ version }}</span>
|
||||
<span class="status-item providers">
|
||||
|
|
@ -33,7 +33,8 @@ import { WebSocketService } from '../services/websocket.service';
|
|||
`
|
||||
.status-bar {
|
||||
position: fixed;
|
||||
inset-inline: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
bottom: 0;
|
||||
z-index: 40;
|
||||
height: 2.5rem;
|
||||
|
|
@ -50,6 +51,13 @@ import { WebSocketService } from '../services/websocket.service';
|
|||
background: rgb(17 24 39);
|
||||
}
|
||||
|
||||
@media (min-width: 1024px) {
|
||||
.status-bar {
|
||||
left: var(--sidebar-width, 0);
|
||||
width: calc(100% - var(--sidebar-width, 0));
|
||||
}
|
||||
}
|
||||
|
||||
.status-left,
|
||||
.status-right {
|
||||
display: flex;
|
||||
|
|
|
|||
|
|
@ -101,7 +101,7 @@
|
|||
</div>
|
||||
<nav class="flex flex-1 flex-col">
|
||||
<ul role="list" class="-mx-2 flex-1 space-y-1">
|
||||
@for (item of navigation; track item.name) {
|
||||
@for (item of navigation(); track item.name) {
|
||||
<li>
|
||||
<a
|
||||
[routerLink]="item.href"
|
||||
|
|
@ -134,7 +134,7 @@
|
|||
</button>
|
||||
<nav class="relative mt-1">
|
||||
<ul role="list" class="flex flex-col items-center space-y-1">
|
||||
@for (item of navigation; track item.name) {
|
||||
@for (item of navigation(); track item.name) {
|
||||
<li>
|
||||
<a
|
||||
[routerLink]="item.href"
|
||||
|
|
@ -161,19 +161,5 @@
|
|||
</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>
|
||||
<status-bar [version]="version" sidebarWidth="5rem"></status-bar>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,11 +1,12 @@
|
|||
// SPDX-Licence-Identifier: EUPL-1.2
|
||||
|
||||
import { Component, CUSTOM_ELEMENTS_SCHEMA, Input, OnDestroy, OnInit } from '@angular/core';
|
||||
import { Component, CUSTOM_ELEMENTS_SCHEMA, Input, OnInit, computed } 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';
|
||||
|
||||
interface NavItem {
|
||||
name: string;
|
||||
|
|
@ -19,7 +20,7 @@ interface NavItem {
|
|||
*
|
||||
* - Dynamic sidebar navigation populated from ProviderDiscoveryService
|
||||
* - Content area rendered via router-outlet for child routes
|
||||
* - Footer with time, version, and provider status
|
||||
* - Footer status bar with time, version, and provider status
|
||||
* - Mobile-responsive sidebar with expand/collapse
|
||||
* - Dark mode support
|
||||
*
|
||||
|
|
@ -30,7 +31,7 @@ interface NavItem {
|
|||
selector: 'application-frame',
|
||||
standalone: true,
|
||||
schemas: [CUSTOM_ELEMENTS_SCHEMA],
|
||||
imports: [CommonModule, RouterOutlet, RouterLink, RouterLinkActive],
|
||||
imports: [CommonModule, RouterOutlet, RouterLink, RouterLinkActive, StatusBarComponent],
|
||||
templateUrl: './application-frame.component.html',
|
||||
styles: [
|
||||
`
|
||||
|
|
@ -42,36 +43,31 @@ interface NavItem {
|
|||
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 {
|
||||
export class ApplicationFrameComponent implements OnInit {
|
||||
@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[] = [];
|
||||
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];
|
||||
});
|
||||
|
||||
userNavigation: NavItem[] = [];
|
||||
|
||||
|
|
@ -81,57 +77,25 @@ export class ApplicationFrameComponent implements OnInit, OnDestroy {
|
|||
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',
|
||||
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();
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -276,6 +276,12 @@ core-display .pill {
|
|||
font-size: 0.78rem;
|
||||
}
|
||||
|
||||
core-display .pill.good {
|
||||
color: var(--good);
|
||||
border-color: rgba(74, 222, 128, 0.24);
|
||||
background: rgba(74, 222, 128, 0.08);
|
||||
}
|
||||
|
||||
core-display .provider-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
|
@ -283,6 +289,10 @@ core-display .provider-list {
|
|||
}
|
||||
|
||||
core-display .provider-row {
|
||||
width: 100%;
|
||||
text-align: left;
|
||||
font: inherit;
|
||||
cursor: pointer;
|
||||
display: grid;
|
||||
grid-template-columns: auto minmax(0, 1fr);
|
||||
gap: 0.875rem;
|
||||
|
|
@ -291,6 +301,21 @@ core-display .provider-row {
|
|||
border-radius: var(--radius-sm);
|
||||
background: rgba(255, 255, 255, 0.03);
|
||||
border: 1px solid rgba(255, 255, 255, 0.05);
|
||||
transition:
|
||||
transform 140ms ease,
|
||||
border-color 140ms ease,
|
||||
background 140ms ease;
|
||||
}
|
||||
|
||||
core-display .provider-row:hover {
|
||||
transform: translateY(-1px);
|
||||
border-color: rgba(139, 92, 246, 0.3);
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
}
|
||||
|
||||
core-display .provider-row.selected {
|
||||
border-color: rgba(139, 92, 246, 0.52);
|
||||
background: rgba(139, 92, 246, 0.11);
|
||||
}
|
||||
|
||||
core-display .provider-icon {
|
||||
|
|
@ -363,11 +388,94 @@ core-display .feature-list span {
|
|||
line-height: 1.55;
|
||||
}
|
||||
|
||||
core-display .preview-panel {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
|
||||
core-display .preview-meta {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 0.85rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
core-display .preview-field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.35rem;
|
||||
padding: 0.9rem 1rem;
|
||||
border-radius: var(--radius-sm);
|
||||
background: rgba(255, 255, 255, 0.04);
|
||||
border: 1px solid rgba(255, 255, 255, 0.05);
|
||||
}
|
||||
|
||||
core-display .preview-field label {
|
||||
font-size: 0.72rem;
|
||||
letter-spacing: 0.16em;
|
||||
text-transform: uppercase;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
core-display .preview-host {
|
||||
min-height: 19rem;
|
||||
border-radius: var(--radius-sm);
|
||||
overflow: hidden;
|
||||
border: 1px solid rgba(255, 255, 255, 0.05);
|
||||
background: rgba(5, 8, 22, 0.45);
|
||||
}
|
||||
|
||||
core-display .single-column {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
core-display .settings-hero {
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
core-display .settings-form {
|
||||
display: grid;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
core-display .settings-field {
|
||||
display: grid;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
core-display .settings-field span {
|
||||
color: var(--muted);
|
||||
font-size: 0.88rem;
|
||||
}
|
||||
|
||||
core-display .settings-field input {
|
||||
min-height: 2.75rem;
|
||||
padding: 0.75rem 0.95rem;
|
||||
border-radius: 0.9rem;
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
background: rgba(255, 255, 255, 0.04);
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
core-display .settings-field input:focus {
|
||||
outline: 2px solid rgba(139, 92, 246, 0.55);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
core-display .settings-actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
@media (max-width: 920px) {
|
||||
core-display .hero,
|
||||
core-display .content-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
core-display .preview-meta {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue