From 599b8fc49389b309b804163cb10a833559a98e04 Mon Sep 17 00:00:00 2001 From: Peter Siegmund Date: Sat, 21 Mar 2026 18:55:11 +0100 Subject: [PATCH] latest website Signed-off-by: Peter Siegmund --- firmware/website/AGENTS.md | 67 ------------------- .../website/src/components/common/card.svelte | 13 +++- .../src/components/configTab/configTab.svelte | 10 +++ .../configTab/ledConfiguration.svelte | 14 ++++ .../configTab/schemaConfiguration.svelte | 7 ++ .../src/components/control/controlTab.svelte | 57 ---------------- .../src/components/control/modeControl.svelte | 61 ----------------- .../components/controlTab/controlTab.svelte | 13 ++++ .../lightControl.svelte | 24 +++---- .../{control => controlTab}/modeButton.svelte | 0 .../components/controlTab/modeControl.svelte | 52 ++++++++++++++ .../statusDisplay.svelte | 24 ++++--- firmware/website/src/routes/index.svelte | 6 +- .../website/src/stores/configSchemaStore.ts | 0 .../website/src/stores/configSegmentStore.ts | 59 ++++++++++++++++ firmware/website/src/stores/controlStore.ts | 28 +------- firmware/website/src/utils/apiClient.ts | 40 +++++++++++ firmware/website/src/utils/logger.ts | 2 - 18 files changed, 236 insertions(+), 241 deletions(-) delete mode 100644 firmware/website/AGENTS.md create mode 100644 firmware/website/src/components/configTab/configTab.svelte create mode 100644 firmware/website/src/components/configTab/ledConfiguration.svelte create mode 100644 firmware/website/src/components/configTab/schemaConfiguration.svelte delete mode 100644 firmware/website/src/components/control/controlTab.svelte delete mode 100644 firmware/website/src/components/control/modeControl.svelte create mode 100644 firmware/website/src/components/controlTab/controlTab.svelte rename firmware/website/src/components/{control => controlTab}/lightControl.svelte (62%) rename firmware/website/src/components/{control => controlTab}/modeButton.svelte (100%) create mode 100644 firmware/website/src/components/controlTab/modeControl.svelte rename firmware/website/src/components/{control => controlTab}/statusDisplay.svelte (69%) create mode 100644 firmware/website/src/stores/configSchemaStore.ts create mode 100644 firmware/website/src/stores/configSegmentStore.ts create mode 100644 firmware/website/src/utils/apiClient.ts diff --git a/firmware/website/AGENTS.md b/firmware/website/AGENTS.md deleted file mode 100644 index 9b32779..0000000 --- a/firmware/website/AGENTS.md +++ /dev/null @@ -1,67 +0,0 @@ -# AGENTS.md - -## Project Overview -This is a Svelte-based web frontend for a model railway system control, using Vite for build tooling and SPA routing via `svelte-spa-router`. The app is structured around two main tabs: Control and Config, with a captive portal route for special cases. - -## Architecture & Data Flow -- **Entry Point:** `src/main.js` mounts the root Svelte component (`app.svelte`) to the DOM. -- **Routing:** Defined in `app.svelte` using `svelte-spa-router`. Routes are: - - `/` → redirects to `/control` - - `/control` → ControlTab - - `/config` → ConfigTab - - `/captive` → Captive Portal -- **State Management:** - - Centralized via Svelte stores, especially `controlStore` in `src/stores/controlStore.ts`. - - `controlStore` manages system state, communicates with backend via REST and WebSocket, and exposes methods for light, mode, and schema changes. - - WebSocket auto-reconnects and is only active when there are subscribers. -- **Internationalization:** - - Language state managed in `src/i18n/store.ts`. - - Translations loaded from `src/i18n/de.json` and `src/i18n/en.json`. - - Language switching persists in `localStorage`. - -## Developer Workflows -- **Build:** `npm run build` (outputs to `../storage/website`) -- **Dev Server:** `npm run dev` (port 5173, strict) -- **Preview:** `npm run preview` -- **Testing:** `npm run test` (uses Vitest, tests in `src/stores/controlStore.test.ts`) -- **Versioning:** - - App version and commit hash injected via `vite.config.js` (from `../version.txt` and `git rev-parse`). - - Displayed in footer. - -## Project-Specific Patterns -- **Tab Navigation:** - - Implemented via `TabBar` and `TabButton` components. - - Active tab is derived from route. -- **Control Logic:** - - All control actions (light, mode, schema) use latest-only sender pattern to avoid race conditions. - - REST endpoints: `/api/light/status`, `/api/light/power`, `/api/light/mode`, `/api/light/schema`. - - WebSocket endpoint: `/ws`. -- **Styling:** - - TailwindCSS and PicoCSS used for styling. - - Custom themes and responsive layouts. -- **Component Structure:** - - Common UI elements in `src/components/common/`. - - Control-specific UI in `src/components/control/`. - - Config-specific UI in `src/components/config/`. - -## Integration Points -- **Backend:** - - Communicates via REST and WebSocket (see endpoints above). - - Host resolution adapts for dev/prod. -- **External Libraries:** - - GSAP for animations. - - TailwindCSS, PicoCSS, Fontsource for UI. - -## Examples -- To add a new control action, extend `controlStore` and update relevant Svelte components. -- To add a new language, add a JSON file in `src/i18n/` and update `translations` in `index.ts`. -- To debug state, use browser devtools and subscribe to Svelte stores. - -## Key Files -- `src/app.svelte` (routing, layout) -- `src/stores/controlStore.ts` (state, backend communication) -- `src/i18n/store.ts` (language state) -- `vite.config.js` (build, versioning) -- `src/components/` (UI) - -_Last updated: 2026-03-17_ diff --git a/firmware/website/src/components/common/card.svelte b/firmware/website/src/components/common/card.svelte index 44ccf30..e24ae7d 100644 --- a/firmware/website/src/components/common/card.svelte +++ b/firmware/website/src/components/common/card.svelte @@ -1,3 +1,14 @@ + +
- + {#if title} +

+ + {$t(title)} +

+ {/if} +
diff --git a/firmware/website/src/components/configTab/configTab.svelte b/firmware/website/src/components/configTab/configTab.svelte new file mode 100644 index 0000000..a53a008 --- /dev/null +++ b/firmware/website/src/components/configTab/configTab.svelte @@ -0,0 +1,10 @@ + + +
+ + + +
\ No newline at end of file diff --git a/firmware/website/src/components/configTab/ledConfiguration.svelte b/firmware/website/src/components/configTab/ledConfiguration.svelte new file mode 100644 index 0000000..ee134f6 --- /dev/null +++ b/firmware/website/src/components/configTab/ledConfiguration.svelte @@ -0,0 +1,14 @@ + + + +

{$t("wled.config.desc")}

+ +
+
Segmente
+ +
+
diff --git a/firmware/website/src/components/configTab/schemaConfiguration.svelte b/firmware/website/src/components/configTab/schemaConfiguration.svelte new file mode 100644 index 0000000..1db8528 --- /dev/null +++ b/firmware/website/src/components/configTab/schemaConfiguration.svelte @@ -0,0 +1,7 @@ + + + + + diff --git a/firmware/website/src/components/control/controlTab.svelte b/firmware/website/src/components/control/controlTab.svelte deleted file mode 100644 index 3c5a3f8..0000000 --- a/firmware/website/src/components/control/controlTab.svelte +++ /dev/null @@ -1,57 +0,0 @@ - - -
- setLight(e.detail)} /> - - setSchema(e.detail)} - onchangeMode={(e) => setMode(e.detail)} - /> - - -
\ No newline at end of file diff --git a/firmware/website/src/components/control/modeControl.svelte b/firmware/website/src/components/control/modeControl.svelte deleted file mode 100644 index 8068dc6..0000000 --- a/firmware/website/src/components/control/modeControl.svelte +++ /dev/null @@ -1,61 +0,0 @@ - - - -

- 🔄 {$t("control.mode.title")} -

- -
- setMode('day')} /> - setMode('night')} /> - setMode('simulation')} /> -
- -{#if mode === 'simulation'} -
- - - -
-{/if} -
diff --git a/firmware/website/src/components/controlTab/controlTab.svelte b/firmware/website/src/components/controlTab/controlTab.svelte new file mode 100644 index 0000000..fb87acd --- /dev/null +++ b/firmware/website/src/components/controlTab/controlTab.svelte @@ -0,0 +1,13 @@ + + +
+ + + + + +
\ No newline at end of file diff --git a/firmware/website/src/components/control/lightControl.svelte b/firmware/website/src/components/controlTab/lightControl.svelte similarity index 62% rename from firmware/website/src/components/control/lightControl.svelte rename to firmware/website/src/components/controlTab/lightControl.svelte index c4ea6a8..2efe0c9 100644 --- a/firmware/website/src/components/control/lightControl.svelte +++ b/firmware/website/src/components/controlTab/lightControl.svelte @@ -2,32 +2,26 @@ import { t } from '../../i18n/store'; import Card from '../common/card.svelte'; import Toggle from '../common/toggle.svelte'; + import { controlStore } from '../../stores/controlStore'; - let { - lightOn = $bindable(false), - thunderOn = $bindable(false), - onchange, - }: { - lightOn?: boolean; - thunderOn?: boolean; - onchange: (e: CustomEvent) => void; - } = $props(); + let lightOn = false; + controlStore.subscribe((state) => { + if (state) lightOn = state.on; + }); function toggleLight(checked: boolean) { - onchange(new CustomEvent('changeLight', { detail: checked })); + controlStore.setLight({ on: checked }); } + let thunderOn = false; + function toggleThunder(checked: boolean) { thunderOn = checked; // TODO: Send command to backend } - -

- 💡 {$t("control.light.title")} -

- +
+ import { t } from '../../i18n/store'; + import ModeButton from './modeButton.svelte'; + import DropDown from '../common/dropDown.svelte'; + import Card from '../common/card.svelte'; + import { controlStore } from '../../stores/controlStore'; + + let mode = 'simulation'; + let activeSchema = 'schema_01.csv'; + let schemas = [ + { value: 'schema_01.csv', label: $t('schema.name.1') }, + { value: 'schema_02.csv', label: $t('schema.name.2') }, + { value: 'schema_03.csv', label: $t('schema.name.3') } + ]; + + controlStore.subscribe((state) => { + if (state) { + mode = state.mode; + activeSchema = state.schema ?? 'schema_01.csv'; + } + }); + + function setMode(newMode: string) { + controlStore.setMode({ mode: newMode }); + } + + function handleSchemaChange(event: CustomEvent) { + controlStore.setSchema({ schema: event.detail }); + } + + + +
+ setMode('day')} /> + setMode('night')} /> + setMode('simulation')} /> +
+ + {#if mode === 'simulation'} +
+ + + +
+ {/if} +
diff --git a/firmware/website/src/components/control/statusDisplay.svelte b/firmware/website/src/components/controlTab/statusDisplay.svelte similarity index 69% rename from firmware/website/src/components/control/statusDisplay.svelte rename to firmware/website/src/components/controlTab/statusDisplay.svelte index 1eef102..69276b2 100644 --- a/firmware/website/src/components/control/statusDisplay.svelte +++ b/firmware/website/src/components/controlTab/statusDisplay.svelte @@ -1,17 +1,25 @@ - -

- 📊 {$t("control.status.title")} -

- +
diff --git a/firmware/website/src/routes/index.svelte b/firmware/website/src/routes/index.svelte index 331439d..aabfd6a 100644 --- a/firmware/website/src/routes/index.svelte +++ b/firmware/website/src/routes/index.svelte @@ -2,11 +2,11 @@ import { onMount } from "svelte"; import { location, replace } from 'svelte-spa-router'; import { t } from "../i18n/store"; - import ControlTab from "../components/control/controlTab.svelte"; - import ConfigTab from "../components/config/configTab.svelte"; + import { controlStore } from "../stores/controlStore"; + import ControlTab from "../components/controlTab/controlTab.svelte"; + import ConfigTab from "../components/configTab/configTab.svelte"; import TabButton from "../components/common/tabButton.svelte"; import TabBar from "../components/common/tabBar.svelte"; - import { controlStore } from "../stores/controlStore"; type Tab = "control" | "config"; diff --git a/firmware/website/src/stores/configSchemaStore.ts b/firmware/website/src/stores/configSchemaStore.ts new file mode 100644 index 0000000..e69de29 diff --git a/firmware/website/src/stores/configSegmentStore.ts b/firmware/website/src/stores/configSegmentStore.ts new file mode 100644 index 0000000..bdc9d19 --- /dev/null +++ b/firmware/website/src/stores/configSegmentStore.ts @@ -0,0 +1,59 @@ +import { writable } from 'svelte/store'; +import { requestJson } from '../utils/apiClient'; + +export interface Segment { + id: string; // Client-generated ID (index fallback, API does not provide IDs) + name: string; + start: number; + leds: number; +} + +// Exact API response type +interface ApiSegmentResponse { + segments: { + name: string; + start: number; + leds: number; + }[]; +} + +function createSegmentStore() { + const { subscribe, set } = writable([]); + + async function fetchSegments() { + const response = await requestJson('/api/wled/config'); + // Use API response directly + const segments: Segment[] = response.segments.map((seg, index) => ({ + id: index.toString(), + name: seg.name, + start: seg.start, + leds: seg.leds + })); + set(segments); + } + + async function updateSegments(segments: Segment[]) { + // Send all segments as array in API format + const apiPayload = { + segments: segments.map(seg => ({ + name: seg.name, + start: seg.start, + leds: seg.leds + })) + }; + await requestJson(`/api/wled/config`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(apiPayload) + }); + await fetchSegments(); // Refresh store after update + } + + return { + subscribe, + fetchSegments, + updateSegments + }; +} + +export const segmentStore = createSegmentStore(); diff --git a/firmware/website/src/stores/controlStore.ts b/firmware/website/src/stores/controlStore.ts index 365bed9..ec25337 100644 --- a/firmware/website/src/stores/controlStore.ts +++ b/firmware/website/src/stores/controlStore.ts @@ -1,6 +1,7 @@ import { writable } from 'svelte/store'; import { createLogger } from '../utils/logger'; import { createLatestOnlySender } from './utils'; +import { requestJson, resolveHost } from '../utils/apiClient'; // Types for state and REST/WebSocket messages export interface ControlState { @@ -37,17 +38,6 @@ const WS_RECONNECT_DELAY_MS = 3000; const isBrowser = typeof window !== 'undefined'; -const resolveHost = () => { - if (import.meta.env.DEV) return 'system-control.local'; - return isBrowser ? window.location.host : ''; -}; - -const buildBaseUrl = (host: string) => { - if (!host) return ''; - const protocol = isBrowser ? window.location.protocol : import.meta.env.DEV ? 'http:' : 'https:'; - return `${protocol}//${host}`; -}; - const buildWebSocketUrl = (host: string) => { if (!isBrowser) return ''; const wsProtocol = @@ -77,7 +67,6 @@ const createControlStore = () => { type StoreInvalidate = Parameters[1]; const host = resolveHost(); - const baseUrl = buildBaseUrl(host); const wsUrl = buildWebSocketUrl(host); let ws: WebSocket | null = null; @@ -104,21 +93,6 @@ const createControlStore = () => { }, WS_RECONNECT_DELAY_MS); }; - async function requestJson(path: string, init?: RequestInit): Promise { - log.debug('HTTP request', { path, method: init?.method ?? 'GET' }); - const res = await fetch(`${baseUrl}${path}`, init); - if (!res.ok) { - log.warn('HTTP request failed', { - path, - status: res.status, - statusText: res.statusText - }); - throw new Error(`Request failed: ${res.status} ${res.statusText}`); - } - log.debug('HTTP request succeeded', { path, status: res.status }); - return (await res.json()) as T; - } - async function fetchState() { const data = await requestJson>(STATUS_ENDPOINT); applyState(data); diff --git a/firmware/website/src/utils/apiClient.ts b/firmware/website/src/utils/apiClient.ts new file mode 100644 index 0000000..b82fd04 --- /dev/null +++ b/firmware/website/src/utils/apiClient.ts @@ -0,0 +1,40 @@ +import { createLogger } from './logger'; + +const isBrowser = typeof window !== 'undefined'; + +export const resolveHost = () => { + if (import.meta.env.DEV) return 'system-control.local'; + return isBrowser ? window.location.host : ''; +}; + +export const buildBaseUrl = (host: string) => { + if (!host) return ''; + const protocol = isBrowser ? window.location.protocol : import.meta.env.DEV ? 'http:' : 'https:'; + return `${protocol}//${host}`; +}; + +const host = resolveHost(); +export const baseUrl = buildBaseUrl(host); + +const log = createLogger('apiClient'); + +/** + * Führt einen fetch-Request durch, prüft auf Fehler und parst die JSON-Antwort. + */ +export async function requestJson(path: string, init?: RequestInit): Promise { + log.debug('HTTP request', { path, method: init?.method ?? 'GET' }); + + const res = await fetch(`${baseUrl}${path}`, init); + + if (!res.ok) { + log.warn('HTTP request failed', { + path, + status: res.status, + statusText: res.statusText + }); + throw new Error(`Request failed: ${res.status} ${res.statusText}`); + } + + log.debug('HTTP request succeeded', { path, status: res.status }); + return (await res.json()) as T; +} diff --git a/firmware/website/src/utils/logger.ts b/firmware/website/src/utils/logger.ts index 0458faf..fb1b3cc 100644 --- a/firmware/website/src/utils/logger.ts +++ b/firmware/website/src/utils/logger.ts @@ -28,5 +28,3 @@ export const createLogger = (scope: string): Record<'debug' | 'info' | 'warn' | warn: (message, meta) => emit(console.warn, scope, message, meta), error: (message, meta) => emit(console.error, scope, message, meta) }); - -