diff --git a/frontend/src/main.ts b/frontend/src/main.ts index 2e242f1f..acda1c86 100644 --- a/frontend/src/main.ts +++ b/frontend/src/main.ts @@ -10,12 +10,33 @@ import './styles/base.css'; import './components/AppShell.js'; import './components/StateCard.js'; import './pages/Dashboard.js'; +import './pages/States.js'; +import './pages/Services.js'; +import './pages/Settings.js'; + +// Tiny router: the AppShell dispatches `hc-navigate` on every nav +// click. We swap whichever page element is sitting in its +// based on the new active id. Default page on first paint = dashboard. +const NAV_TO_TAG: Record = { + dashboard: 'hc-dashboard', + states: 'hc-states', + services: 'hc-services', + settings: 'hc-settings', +}; + +function mountPage(shell: Element, tag: string): void { + // Remove any existing page (everything that isn't itself the shell). + Array.from(shell.children).forEach((c) => c.remove()); + shell.appendChild(document.createElement(tag)); +} -// Mount the Dashboard inside the AppShell's slot so the empty `
` -// layout actually shows something on first paint. window.addEventListener('DOMContentLoaded', () => { const shell = document.querySelector('hc-app-shell'); - if (shell && !shell.querySelector('hc-dashboard')) { - shell.appendChild(document.createElement('hc-dashboard')); - } + if (!shell) return; + mountPage(shell, 'hc-dashboard'); + shell.addEventListener('hc-navigate', (ev) => { + const id = (ev as CustomEvent<{ id: string }>).detail?.id; + const tag = id ? NAV_TO_TAG[id] : undefined; + if (tag) mountPage(shell, tag); + }); }); diff --git a/frontend/src/pages/Services.ts b/frontend/src/pages/Services.ts new file mode 100644 index 00000000..045e6649 --- /dev/null +++ b/frontend/src/pages/Services.ts @@ -0,0 +1,86 @@ +/** + * Services page — lists every registered service grouped by domain. + * Reads from `/api/services` (HA-wire-compat). + */ + +import { LitElement, html, css } from 'lit'; +import { customElement, state } from 'lit/decorators.js'; + +import { HomecoreClient } from '../api/client.js'; +import type { ServiceDomainView } from '../api/types.js'; + +function resolveToken(): string { + if (typeof localStorage !== 'undefined') { + const stored = localStorage.getItem('homecore.token'); + if (stored) return stored; + } + const qs = new URL(window.location.href).searchParams.get('token'); + return qs ?? 'dev-token'; +} + +@customElement('hc-services') +export class ServicesPage extends LitElement { + static styles = css` + :host { display: block; padding: 24px; color: var(--hc-text, #e6eaee); font-family: var(--hc-font-sans, 'Outfit', system-ui, sans-serif); } + h1 { font-size: 18px; font-weight: 600; margin: 0 0 16px 0; } + .domain { background: hsl(220 20% 10%); border: 1px solid var(--hc-border, #2a323e); border-radius: 8px; margin-bottom: 12px; padding: 14px 16px; } + .domain h2 { font-size: 14px; font-weight: 600; margin: 0 0 8px 0; color: var(--hc-primary, #19d4e5); font-family: var(--hc-font-mono, 'JetBrains Mono', monospace); } + ul { list-style: none; padding: 0; margin: 0; display: flex; flex-wrap: wrap; gap: 6px; } + li { background: hsl(220 25% 14%); padding: 4px 10px; border-radius: 4px; font-family: var(--hc-font-mono, 'JetBrains Mono', monospace); font-size: 12px; color: var(--hc-text-muted, #7b899d); } + .empty { padding: 24px; border: 1px dashed var(--hc-border, #2a323e); border-radius: 8px; text-align: center; color: var(--hc-text-muted, #7b899d); } + .err { padding: 16px; border: 1px dashed #b35a5a; border-radius: 8px; color: #f0c0c0; font-size: 13px; } + `; + + @state() private domains: ServiceDomainView[] = []; + @state() private error: string | null = null; + @state() private loading = true; + + private client = new HomecoreClient({ token: resolveToken() }); + + connectedCallback(): void { + super.connectedCallback(); + void this.refresh(); + } + + private async refresh(): Promise { + try { + const r = await fetch('/api/services', { headers: { 'Authorization': `Bearer ${resolveToken()}` } }); + if (!r.ok) throw new Error(`/api/services -> HTTP ${r.status}`); + this.domains = await r.json(); + this.error = null; + } catch (e) { + this.error = e instanceof Error ? e.message : String(e); + } finally { + this.loading = false; + } + void this.client; // suppress unused warning while keeping the import shape consistent + } + + render() { + if (this.error) return html`
backend unreachable — ${this.error}
`; + if (this.loading) return html`
loading services…
`; + if (this.domains.length === 0) { + return html` +

Services (0 domains)

+
+ No services registered. Services are registered by plugins + (Wasmtime or InProcess) or by integrations that call + services::register() on boot. +
+ `; + } + return html` +

Services (${this.domains.length} domain${this.domains.length === 1 ? '' : 's'})

+ ${this.domains.map(d => html` +
+

${d.domain}

+
    + ${Object.keys(d.services).map(name => html`
  • ${name}
  • `)} +
+
+ `)} + `; + } +} + +declare global { interface HTMLElementTagNameMap { 'hc-services': ServicesPage; } } diff --git a/frontend/src/pages/Settings.ts b/frontend/src/pages/Settings.ts new file mode 100644 index 00000000..7d56a45d --- /dev/null +++ b/frontend/src/pages/Settings.ts @@ -0,0 +1,94 @@ +/** + * Settings page — backend config + bearer-token editor (localStorage). + */ + +import { LitElement, html, css } from 'lit'; +import { customElement, state } from 'lit/decorators.js'; + +import { HomecoreClient } from '../api/client.js'; +import type { ApiConfig } from '../api/types.js'; + +function resolveToken(): string { + if (typeof localStorage !== 'undefined') { + const stored = localStorage.getItem('homecore.token'); + if (stored) return stored; + } + const qs = new URL(window.location.href).searchParams.get('token'); + return qs ?? 'dev-token'; +} + +@customElement('hc-settings') +export class SettingsPage extends LitElement { + static styles = css` + :host { display: block; padding: 24px; color: var(--hc-text, #e6eaee); font-family: var(--hc-font-sans, 'Outfit', system-ui, sans-serif); } + h1 { font-size: 18px; font-weight: 600; margin: 0 0 16px 0; } + section { background: hsl(220 20% 10%); border: 1px solid var(--hc-border, #2a323e); border-radius: 8px; padding: 16px; margin-bottom: 16px; } + h2 { font-size: 14px; font-weight: 600; margin: 0 0 12px 0; color: var(--hc-primary, #19d4e5); } + dl { display: grid; grid-template-columns: max-content 1fr; gap: 6px 18px; margin: 0; font-size: 13px; font-family: var(--hc-font-mono, 'JetBrains Mono', monospace); } + dt { color: var(--hc-text-muted, #7b899d); } + dd { margin: 0; } + label { display: block; margin-bottom: 6px; font-size: 13px; color: var(--hc-text-muted, #7b899d); } + input { width: 100%; box-sizing: border-box; padding: 8px 12px; background: hsl(220 25% 14%); border: 1px solid var(--hc-border, #2a323e); border-radius: 6px; color: var(--hc-text, #e6eaee); font-family: var(--hc-font-mono, 'JetBrains Mono', monospace); font-size: 13px; } + button { margin-top: 10px; padding: 8px 16px; background: var(--hc-primary, #19d4e5); color: var(--hc-primary-fg, #0b0e13); border: none; border-radius: 6px; font-weight: 600; font-size: 13px; cursor: pointer; font-family: var(--hc-font-sans, 'Outfit', system-ui, sans-serif); } + button:hover { background: hsl(185 80% 55%); } + .toast { font-size: 12px; color: var(--hc-primary, #19d4e5); margin-top: 8px; } + .err { padding: 16px; border: 1px dashed #b35a5a; border-radius: 8px; color: #f0c0c0; font-size: 13px; } + `; + + @state() private config: ApiConfig | null = null; + @state() private error: string | null = null; + @state() private token = resolveToken(); + @state() private savedAt = 0; + + private client = new HomecoreClient({ token: resolveToken() }); + + connectedCallback(): void { + super.connectedCallback(); + void this.refresh(); + } + + private async refresh(): Promise { + try { + this.config = await this.client.getConfig(); + this.error = null; + } catch (e) { + this.error = e instanceof Error ? e.message : String(e); + } + } + + private saveToken() { + localStorage.setItem('homecore.token', this.token); + this.savedAt = Date.now(); + this.client = new HomecoreClient({ token: this.token }); + void this.refresh(); + } + + render() { + return html` +

Settings

+
+

backend

+ ${this.error + ? html`
unreachable — ${this.error}
` + : this.config + ? html`
+
location
${this.config.location_name}
+
version
${this.config.version}
+
state
${this.config.state}
+
components
${this.config.components.join(', ')}
+
` + : html`loading…`} +
+
+

auth — bearer token

+ + (this.token = (e.target as HTMLInputElement).value)} /> + + ${this.savedAt > 0 ? html`
saved at ${new Date(this.savedAt).toLocaleTimeString()}
` : ''} +
+ `; + } +} + +declare global { interface HTMLElementTagNameMap { 'hc-settings': SettingsPage; } } diff --git a/frontend/src/pages/States.ts b/frontend/src/pages/States.ts new file mode 100644 index 00000000..8efdca01 --- /dev/null +++ b/frontend/src/pages/States.ts @@ -0,0 +1,85 @@ +/** + * States page — full table view of every entity in the state machine. + * Mirrors Home Assistant's `/developer-tools/state` view (read-only). + */ + +import { LitElement, html, css } from 'lit'; +import { customElement, state } from 'lit/decorators.js'; + +import { HomecoreClient } from '../api/client.js'; +import type { StateView } from '../api/types.js'; + +function resolveToken(): string { + if (typeof localStorage !== 'undefined') { + const stored = localStorage.getItem('homecore.token'); + if (stored) return stored; + } + const qs = new URL(window.location.href).searchParams.get('token'); + return qs ?? 'dev-token'; +} + +@customElement('hc-states') +export class StatesPage extends LitElement { + static styles = css` + :host { display: block; padding: 24px; color: var(--hc-text, #e6eaee); font-family: var(--hc-font-sans, 'Outfit', system-ui, sans-serif); } + h1 { font-size: 18px; font-weight: 600; margin: 0 0 16px 0; } + table { width: 100%; border-collapse: collapse; font-size: 13px; } + th { text-align: left; padding: 10px 12px; border-bottom: 1px solid var(--hc-border, #2a323e); color: var(--hc-text-muted, #7b899d); font-weight: 500; } + td { padding: 10px 12px; border-bottom: 1px solid hsl(220 15% 14%); font-family: var(--hc-font-mono, 'JetBrains Mono', monospace); } + td.attrs { color: var(--hc-text-muted, #7b899d); font-size: 12px; max-width: 380px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } + tr:hover td { background: hsl(220 20% 10%); } + .state { color: var(--hc-primary, #19d4e5); } + .err { padding: 16px; border: 1px dashed #b35a5a; border-radius: 8px; color: #f0c0c0; font-family: var(--hc-font-mono, 'JetBrains Mono', monospace); font-size: 13px; } + `; + + @state() private states: StateView[] = []; + @state() private error: string | null = null; + @state() private loading = true; + + private client = new HomecoreClient({ token: resolveToken() }); + private timer?: number; + + connectedCallback(): void { + super.connectedCallback(); + void this.refresh(); + this.timer = window.setInterval(() => void this.refresh(), 5000); + } + disconnectedCallback(): void { + if (this.timer !== undefined) window.clearInterval(this.timer); + super.disconnectedCallback(); + } + + private async refresh(): Promise { + try { + this.states = await this.client.getStates(); + this.error = null; + } catch (e) { + this.error = e instanceof Error ? e.message : String(e); + } finally { + this.loading = false; + } + } + + render() { + if (this.error) return html`
backend unreachable — ${this.error}
`; + if (this.loading) return html`
loading…
`; + return html` +

States (${this.states.length})

+ + + + ${this.states.map(s => html` + + + + + + + `)} + +
entity_idstatelast_changedattributes
${s.entity_id}${s.state}${s.last_changed.replace('T', ' ').replace(/\..*$/, '')}${JSON.stringify(s.attributes)}
+ `; + } +} + +declare global { interface HTMLElementTagNameMap { 'hc-states': StatesPage; } }