@@ -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>
|
||||
+9
-15
@@ -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>
|
||||
+16
-8
@@ -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">
|
||||
@@ -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,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);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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)
|
||||
});
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user