latest website

Signed-off-by: Peter Siegmund <developer@mars3142.org>
This commit is contained in:
2026-03-21 18:55:11 +01:00
parent 0cac0709a9
commit 599b8fc493
18 changed files with 236 additions and 241 deletions
-67
View File
@@ -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_
@@ -1,3 +1,14 @@
<script lang="ts">
export let title: string | undefined;
import { t } from '../../i18n/store';
</script>
<div class="bg-card p-6 rounded-lg border border-border shadow-sm">
<slot />
{#if title}
<h2 class="text-lg font-semibold mb-4 flex items-center gap-2">
<span class="inline-block w-1 h-5 bg-success rounded"></span>
{$t(title)}
</h2>
{/if}
<slot />
</div>
@@ -0,0 +1,10 @@
<script lang="ts">
import LedConfiguration from "./ledConfiguration.svelte";
import SchemaConfiguration from "./schemaConfiguration.svelte";
</script>
<div class="space-y-6">
<LedConfiguration />
<SchemaConfiguration />
</div>
@@ -0,0 +1,14 @@
<script lang="ts">
import Card from '../common/card.svelte';
import Button from '../common/button.svelte';
import { t } from '../../i18n/store';
</script>
<Card title="wled.config.title">
<p class="text-sm -mt-3 mb-4 text-text-muted">{$t("wled.config.desc")}</p>
<div class="flex justify-center items-center">
<div class="flex-1">Segmente</div>
<Button label={$t("wled.segment.add")} ariaLabel={$t("wled.segment.add")}></Button>
</div>
</Card>
@@ -0,0 +1,7 @@
<script lang="ts">
import Card from '../common/card.svelte';
</script>
<Card title="schema.editor.title">
</Card>
@@ -1,57 +0,0 @@
<script lang="ts">
import { type ControlState, controlStore, createDefaultControlState } from '../../stores/controlStore';
import LightControl from './lightControl.svelte';
import ModeControl from './modeControl.svelte';
import StatusDisplay from './statusDisplay.svelte';
let state = $state<ControlState>(createDefaultControlState());
$effect(() => {
return controlStore.subscribe((value) => {
if (value) state = value;
});
});
function setLight(on: boolean) {
controlStore.setLight({ on });
}
function setMode(mode: string) {
controlStore.setMode({ mode });
}
function setSchema(schema: string) {
controlStore.setSchema({ schema });
}
// Hilfsfunktion für CSS-Farbe
function colorToCss(color: any): string {
if (!color) return '#000';
if (typeof color === 'string') return color;
if (
typeof color === 'object' &&
color.r !== undefined &&
color.g !== undefined &&
color.b !== undefined
) {
return `rgb(${color.r},${color.g},${color.b})`;
}
return '#000';
}
</script>
<div class="space-y-6">
<LightControl lightOn={state.on} onchange={(e) => setLight(e.detail)} />
<ModeControl
bind:activeSchema={state.schema}
bind:mode={state.mode}
onchangeSchema={(e) => setSchema(e.detail)}
onchangeMode={(e) => setMode(e.detail)}
/>
<StatusDisplay
clock={state.clock}
color={colorToCss(state.color)}
mode={state.mode}
/>
</div>
@@ -1,61 +0,0 @@
<script lang="ts">
import { t } from '../../i18n/store';
import ModeButton from './modeButton.svelte';
import DropDown from '../common/dropDown.svelte';
import Card from '../common/card.svelte';
let { mode = $bindable('simulation'), activeSchema = $bindable('schema_01.csv'), onchangeMode, onchangeSchema }: {
mode?: string,
activeSchema?: string,
onchangeMode?: (e: CustomEvent<string>) => void,
onchangeSchema?: (e: CustomEvent<string>) => void
} = $props();
let schemas = $derived([
{ 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') }
]);
function setMode(newMode: string) {
mode = newMode;
if (onchangeMode) {
onchangeMode(new CustomEvent('changeMode', { detail: mode }));
}
}
function handleSchemaChange(event: CustomEvent<string>) {
// When the user selects a new schema from the DropDown, we update our local state.
// If the update came from the WS, activeSchema would be updated directly by the parent.
activeSchema = event.detail;
if (onchangeSchema) {
onchangeSchema(new CustomEvent('changeSchema', { detail: activeSchema }));
}
}
</script>
<Card>
<h2 class="text-lg font-semibold mb-4 flex items-center gap-2">
🔄 {$t("control.mode.title")}
</h2>
<div class="flex gap-2 mb-6">
<ModeButton active={mode === 'day'} icon="☀️" label={$t('mode.day')} onClick={() => setMode('day')} />
<ModeButton active={mode === 'night'} icon="🌙" label={$t('mode.night')} onClick={() => setMode('night')} />
<ModeButton active={mode === 'simulation'} icon="🔄" label={$t('mode.simulation')}
onClick={() => setMode('simulation')} />
</div>
{#if mode === 'simulation'}
<div class="p-4 bg-background rounded-md border border-border">
<label for="active-schema" class="block text-sm font-medium mb-2">{$t("control.schema.active")}</label>
<DropDown
id="active-schema"
options={schemas}
bind:value={activeSchema}
onchange={handleSchemaChange}
/>
</div>
{/if}
</Card>
@@ -0,0 +1,13 @@
<script lang="ts">
import LightControl from './lightControl.svelte';
import ModeControl from './modeControl.svelte';
import StatusDisplay from './statusDisplay.svelte';
</script>
<div class="space-y-6">
<LightControl />
<ModeControl />
<StatusDisplay />
</div>
@@ -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<boolean>) => 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
}
</script>
<Card>
<h2 class="text-lg font-semibold mb-4 flex items-center gap-2">
💡 {$t("control.light.title")}
</h2>
<Card title="control.light.title">
<div class="flex flex-col gap-4">
<Toggle
bind:checked={lightOn}
@@ -0,0 +1,52 @@
<script lang="ts">
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<string>) {
controlStore.setSchema({ schema: event.detail });
}
</script>
<Card title="control.mode.title">
<div class="flex gap-2 mb-6">
<ModeButton active={mode === 'day'} icon="☀️" label={$t('mode.day')} onClick={() => setMode('day')} />
<ModeButton active={mode === 'night'} icon="🌙" label={$t('mode.night')} onClick={() => setMode('night')} />
<ModeButton active={mode === 'simulation'} icon="🔄" label={$t('mode.simulation')}
onClick={() => setMode('simulation')} />
</div>
{#if mode === 'simulation'}
<div class="p-4 bg-background rounded-md border border-border">
<label for="active-schema" class="block text-sm font-medium mb-2">{$t("control.schema.active")}</label>
<DropDown
id="active-schema"
options={schemas}
bind:value={activeSchema}
onchange={handleSchemaChange}
/>
</div>
{/if}
</Card>
@@ -1,17 +1,25 @@
<script lang="ts">
import { t } from '../../i18n/store';
import Card from '../common/card.svelte';
import { controlStore } from '../../stores/controlStore';
export let mode = 'simulation';
export let color = '#000000';
export let clock: string | null = '12:34 Uhr';
let mode = 'simulation';
let color = '#000000';
let clock: string | null = '12:34 Uhr';
controlStore.subscribe((state) => {
if (state) {
mode = state.mode;
clock = state.clock ?? null;
if (typeof state.color === 'string') {
color = state.color;
} else if (typeof state.color === 'object' && state.color) {
color = `rgb(${state.color.r},${state.color.g},${state.color.b})`;
}
}
});
</script>
<Card>
<h2 class="text-lg font-semibold mb-4 flex items-center gap-2">
📊 {$t("control.status.title")}
</h2>
<Card title="control.status.title">
<div class="flex flex-col sm:flex-row gap-4">
<div class="flex-1 p-3 bg-background rounded-md border border-border">
<div class="text-xs text-muted-foreground mb-1">
+3 -3
View File
@@ -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";
@@ -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<Segment[]>([]);
async function fetchSegments() {
const response = await requestJson<ApiSegmentResponse>('/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();
+1 -27
View File
@@ -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<StoreSubscribe>[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<T>(path: string, init?: RequestInit): Promise<T> {
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<Partial<ControlState>>(STATUS_ENDPOINT);
applyState(data);
+40
View File
@@ -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<T>(path: string, init?: RequestInit): Promise<T> {
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;
}
-2
View File
@@ -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)
});