feat(gui): wire shell routes and provider previews
Some checks are pending
Security Scan / security (push) Waiting to run
Test / test (push) Waiting to run

This commit is contained in:
Virgil 2026-04-02 19:41:35 +00:00
parent fdff5435c2
commit f2eb9f03c4
12 changed files with 549 additions and 236 deletions

View file

@ -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 {

View file

@ -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
View 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 },
],
},
];

View file

@ -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 {}

View 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;
}
}

View 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();
}
}

View file

@ -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();
}
}
}

View file

@ -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,
}));

View file

@ -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;

View file

@ -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>

View file

@ -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();
}
}

View file

@ -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) {