website in svelte
Signed-off-by: Peter Siegmund <developer@mars3142.org>
This commit is contained in:
@@ -0,0 +1,15 @@
|
||||
<component name="ProjectRunConfigurationManager">
|
||||
<configuration default="false" name="Debug dev" type="JavascriptDebugType" uri="http://localhost:5173">
|
||||
<method v="2">
|
||||
<option name="NpmBeforeRunTask" enabled="true">
|
||||
<package-json value="$PROJECT_DIR$/package.json" />
|
||||
<command value="run" />
|
||||
<scripts>
|
||||
<script value="dev" />
|
||||
</scripts>
|
||||
<node-interpreter value="project" />
|
||||
<envs />
|
||||
</option>
|
||||
</method>
|
||||
</configuration>
|
||||
</component>
|
||||
@@ -1,13 +1,14 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="de">
|
||||
<html lang="en">
|
||||
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
|
||||
<meta name="theme-color" content="#1a1a2e">
|
||||
<meta name="apple-mobile-web-app-capable" content="yes">
|
||||
<meta name="mobile-web-app-capable" content="yes">
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
|
||||
<link rel="icon" type="image/svg+xml" href="favicon.svg">
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg">
|
||||
<title>System Control</title>
|
||||
</head>
|
||||
|
||||
|
||||
Generated
+1744
-186
File diff suppressed because it is too large
Load Diff
@@ -3,18 +3,23 @@
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"build": "vite build --emptyOutDir",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@sveltejs/vite-plugin-svelte": "^6.1.1",
|
||||
"svelte": "^5.38.1",
|
||||
"autoprefixer": "^10.4.16",
|
||||
"postcss": "^8.4.31",
|
||||
"svelte": "^5.53.5",
|
||||
"tailwindcss": "^3.3.5",
|
||||
"vite": "^7.1.2",
|
||||
"vite-plugin-compression": "^0.5.1"
|
||||
},
|
||||
"dependencies": {
|
||||
"@fontsource/atkinson-hyperlegible": "^5.2.6",
|
||||
"@picocss/pico": "^2.1.1",
|
||||
"gsap": "^3.13.0"
|
||||
"@tailwindcss/vite": "^4.1.18",
|
||||
"gsap": "^3.13.0",
|
||||
"svelte-spa-router": "^4.0.2"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
export default {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
}
|
||||
@@ -1,27 +0,0 @@
|
||||
<script lang="ts">
|
||||
import Header from "./compoents/Header.svelte";
|
||||
import Index from "./Index.svelte";
|
||||
import Captive from "./Captive.svelte";
|
||||
import { onMount } from "svelte";
|
||||
import { writable } from "svelte/store";
|
||||
|
||||
const isCaptive = writable(false);
|
||||
|
||||
function checkHash() {
|
||||
isCaptive.set(window.location.hash === "#/captive");
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
checkHash();
|
||||
window.addEventListener("hashchange", checkHash);
|
||||
return () => window.removeEventListener("hashchange", checkHash);
|
||||
});
|
||||
</script>
|
||||
|
||||
<Header />
|
||||
|
||||
{#if $isCaptive}
|
||||
<Captive />
|
||||
{:else}
|
||||
<Index />
|
||||
{/if}
|
||||
@@ -1,5 +0,0 @@
|
||||
<script lang="ts">
|
||||
import { t } from "./i18n/store";
|
||||
</script>
|
||||
|
||||
<h1>{$t("welcome")}</h1>
|
||||
@@ -1,70 +1,69 @@
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
:root {
|
||||
--bg-color: #1a1a2e;
|
||||
--card-bg: #16213e;
|
||||
--accent: #0f3460;
|
||||
--text: #eaeaea;
|
||||
--text-muted: #a0a0a0;
|
||||
--success: #00d26a;
|
||||
--error: #ff6b6b;
|
||||
--border: #2a2a4a;
|
||||
--input-bg: #1a1a2e;
|
||||
--shadow: rgba(0, 0, 0, 0.3);
|
||||
--primary: #c41e3a;
|
||||
/* Default to Light Theme */
|
||||
--bg-color: #faf8f5;
|
||||
--card-bg: #ffffff;
|
||||
--accent: #fef2f2;
|
||||
--text: #1a1a2e;
|
||||
--text-muted: #6b7280;
|
||||
--success: #c41e3a;
|
||||
--error: #dc2626;
|
||||
--border: #e5d9d0;
|
||||
--input-bg: #ffffff;
|
||||
--shadow: rgba(196, 30, 58, 0.1);
|
||||
--primary: #c41e3a;
|
||||
}
|
||||
|
||||
[data-theme="light"] {
|
||||
--bg-color: #faf8f5;
|
||||
--card-bg: #ffffff;
|
||||
--accent: #fef2f2;
|
||||
--text: #1a1a2e;
|
||||
--text-muted: #6b7280;
|
||||
--success: #c41e3a;
|
||||
--error: #dc2626;
|
||||
--border: #e5d9d0;
|
||||
--input-bg: #ffffff;
|
||||
--shadow: rgba(196, 30, 58, 0.1);
|
||||
--primary: #c41e3a;
|
||||
}
|
||||
|
||||
[data-theme="light"] .header h1 {
|
||||
color: var(--primary);
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:root {
|
||||
/* Dark Theme Overrides */
|
||||
--bg-color: #1a1a2e;
|
||||
--card-bg: #16213e;
|
||||
--accent: #0f3460;
|
||||
--text: #eaeaea;
|
||||
--text-muted: #a0a0a0;
|
||||
--success: #00d26a;
|
||||
--error: #ff6b6b;
|
||||
--border: #2a2a4a;
|
||||
--input-bg: #1a1a2e;
|
||||
--shadow: rgba(0, 0, 0, 0.3);
|
||||
--primary: #c41e3a;
|
||||
}
|
||||
}
|
||||
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||
background: var(--bg-color);
|
||||
color: var(--text);
|
||||
min-height: 100vh;
|
||||
transition: background 0.3s, color 0.3s;
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-size: 1.5rem;
|
||||
color: var(--text);
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||
background: var(--bg-color);
|
||||
color: var(--text);
|
||||
min-height: 100vh;
|
||||
transition: background 0.3s, color 0.3s;
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
#app {
|
||||
max-width: 900px;
|
||||
margin: 0 auto;
|
||||
max-width: 900px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
@media (max-width: 600px) {
|
||||
body {
|
||||
padding: 6px;
|
||||
}
|
||||
body {
|
||||
padding: 6px;
|
||||
}
|
||||
}
|
||||
|
||||
@supports (padding: max(0px)) {
|
||||
body {
|
||||
padding-left: max(12px, env(safe-area-inset-left));
|
||||
padding-right: max(12px, env(safe-area-inset-right));
|
||||
padding-bottom: max(12px, env(safe-area-inset-bottom));
|
||||
}
|
||||
body {
|
||||
padding-left: max(12px, env(safe-area-inset-left));
|
||||
padding-right: max(12px, env(safe-area-inset-right));
|
||||
padding-bottom: max(12px, env(safe-area-inset-bottom));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
<script lang="ts">
|
||||
import Header from "./components/header.svelte";
|
||||
import Footer from "./components/footer.svelte";
|
||||
import Index from "./index.svelte";
|
||||
import Captive from "./captive.svelte";
|
||||
import Router, { location } from "svelte-spa-router";
|
||||
import { lang } from "./i18n/store";
|
||||
|
||||
const routes = {
|
||||
"/control": Index,
|
||||
"/captive": Captive,
|
||||
// Fallback route
|
||||
"*": Index,
|
||||
};
|
||||
|
||||
$: document.documentElement.lang = $lang;
|
||||
</script>
|
||||
|
||||
{#if $location !== "/captive"}
|
||||
<Header />
|
||||
{/if}
|
||||
|
||||
<main class="container mx-auto px-4 py-8">
|
||||
<Router {routes} />
|
||||
</main>
|
||||
|
||||
{#if $location !== "/captive"}
|
||||
<Footer />
|
||||
{/if}
|
||||
@@ -1,5 +1,5 @@
|
||||
<script lang="ts">
|
||||
import { t } from "./i18n/store";
|
||||
import { t } from "./i18n/store";
|
||||
</script>
|
||||
|
||||
<h1>{$t("welcome")} - Captive Portal</h1>
|
||||
@@ -1,133 +0,0 @@
|
||||
<script lang="ts">
|
||||
import Toggle from "./Toggle.svelte";
|
||||
import {toggleTheme} from "../theme";
|
||||
import {onMount} from "svelte";
|
||||
import {writable} from "svelte/store";
|
||||
import {lang, t} from "../i18n/store";
|
||||
|
||||
const theme = writable<"dark" | "light">("dark");
|
||||
|
||||
function applyInitialTheme() {
|
||||
const userTheme = localStorage.getItem("theme");
|
||||
if (userTheme) {
|
||||
document.documentElement.setAttribute("data-theme", userTheme);
|
||||
} else if (window.matchMedia("(prefers-color-scheme: light)").matches) {
|
||||
document.documentElement.setAttribute("data-theme", "light");
|
||||
} else {
|
||||
document.documentElement.setAttribute("data-theme", "dark");
|
||||
}
|
||||
}
|
||||
|
||||
function updateThemeFromDom() {
|
||||
const t = document.documentElement.getAttribute("data-theme");
|
||||
theme.set(t === "light" ? "light" : "dark");
|
||||
}
|
||||
|
||||
function handleThemeToggle() {
|
||||
toggleTheme();
|
||||
updateThemeFromDom();
|
||||
}
|
||||
|
||||
let themeIcon = $state("🌙");
|
||||
let themeLabel = $state("Dark");
|
||||
let currentLangCode = $state($lang);
|
||||
let currentLang = $state("Deutsch");
|
||||
let currentFlag = $state("🇩🇪");
|
||||
|
||||
$effect(() => {
|
||||
theme.subscribe(($theme) => {
|
||||
themeIcon = $theme === "light" ? "☀️" : "🌙";
|
||||
themeLabel = $theme === "light" ? "Light" : "Dark";
|
||||
});
|
||||
lang.subscribe(($lang) => {
|
||||
currentLangCode = $lang;
|
||||
currentLang = $lang === "de" ? "Deutsch" : "English";
|
||||
currentFlag = $lang === "de" ? "🇩🇪" : "🇬🇧";
|
||||
});
|
||||
});
|
||||
|
||||
function handleLangChange(newLang: "de" | "en") {
|
||||
lang.set(newLang);
|
||||
|
||||
localStorage.setItem("lang", newLang);
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
applyInitialTheme();
|
||||
updateThemeFromDom();
|
||||
window.addEventListener("storage", updateThemeFromDom);
|
||||
|
||||
// Listener für OS-Theme-Änderung
|
||||
const mql = window.matchMedia("(prefers-color-scheme: light)");
|
||||
const osThemeListener = () => {
|
||||
// Nur reagieren, wenn kein User-Theme gesetzt ist
|
||||
if (!localStorage.getItem("theme")) {
|
||||
applyInitialTheme();
|
||||
updateThemeFromDom();
|
||||
}
|
||||
};
|
||||
mql.addEventListener("change", osThemeListener);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener("storage", updateThemeFromDom);
|
||||
mql.removeEventListener("change", osThemeListener);
|
||||
};
|
||||
});
|
||||
</script>
|
||||
|
||||
<div class="header">
|
||||
<div class="header-controls">
|
||||
<Toggle
|
||||
label={currentLang}
|
||||
icon={currentFlag}
|
||||
ariaLabel="Sprache wechseln"
|
||||
onClick={() => {
|
||||
const newLang = currentLangCode === "de" ? "en" : "de";
|
||||
handleLangChange(newLang);
|
||||
}}
|
||||
/>
|
||||
|
||||
<Toggle
|
||||
label={themeLabel}
|
||||
icon={themeIcon}
|
||||
ariaLabel="Theme wechseln"
|
||||
onClick={handleThemeToggle}
|
||||
/>
|
||||
</div>
|
||||
<h1>🚂 System Control</h1>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 20px;
|
||||
flex-wrap: wrap;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.header-controls {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
@media (max-width: 600px) {
|
||||
.header {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.header h1 {
|
||||
order: 1;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 380px) {
|
||||
.header h1 {
|
||||
font-size: 1.2rem;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -1,37 +0,0 @@
|
||||
<script lang="ts">
|
||||
const { label, icon, ariaLabel, onClick } = $props<{
|
||||
label: string;
|
||||
icon: string;
|
||||
ariaLabel: string;
|
||||
onClick?: () => void;
|
||||
}>();
|
||||
</script>
|
||||
|
||||
<button class="toggle" aria-label={ariaLabel} onclick={onClick}>
|
||||
<span class="icon" id="icon">{icon}</span>
|
||||
<span class="label" id="label">{label}</span>
|
||||
</button>
|
||||
|
||||
<style>
|
||||
.toggle {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
background: var(--card-bg);
|
||||
padding: 8px 12px;
|
||||
border-radius: 20px;
|
||||
border: 1px solid var(--border);
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
font-size: 0.85rem;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.toggle:hover {
|
||||
border-color: var(--success);
|
||||
}
|
||||
|
||||
.icon {
|
||||
font-size: 1.2rem;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,17 @@
|
||||
<script lang="ts">
|
||||
const { label, icon, ariaLabel, onClick } = $props<{
|
||||
label: string;
|
||||
icon: string;
|
||||
ariaLabel: string;
|
||||
onClick?: () => void;
|
||||
}>();
|
||||
</script>
|
||||
|
||||
<button
|
||||
aria-label={ariaLabel}
|
||||
class="flex items-center justify-center cursor-pointer p-2 gap-2 bg-card rounded-lg text-text-muted text-sm transition-all border-solid border-border border-x border-y hover:border-success min-w-28"
|
||||
onclick={onClick}
|
||||
>
|
||||
<span class="text-xl" id="icon">{icon}</span>
|
||||
<span class="label" id="label">{label}</span>
|
||||
</button>
|
||||
@@ -0,0 +1,86 @@
|
||||
<script lang="ts">
|
||||
import { slide } from "svelte/transition";
|
||||
|
||||
let { options = [], value = $bindable(), id = "", onchange }: { options?: { value: string; label: string }[], value: string, id?: string, onchange?: (e: CustomEvent<string>) => void } = $props();
|
||||
|
||||
let isDropdownOpen = $state(false);
|
||||
let dropdownRef: HTMLDivElement;
|
||||
|
||||
function toggleDropdown() {
|
||||
isDropdownOpen = !isDropdownOpen;
|
||||
}
|
||||
|
||||
function selectOption(selectedValue: string) {
|
||||
value = selectedValue;
|
||||
isDropdownOpen = false;
|
||||
if (onchange) {
|
||||
onchange(new CustomEvent("change", { detail: selectedValue }));
|
||||
}
|
||||
}
|
||||
|
||||
function handleClickOutside(event: MouseEvent) {
|
||||
if (dropdownRef && !dropdownRef.contains(event.target as Node)) {
|
||||
isDropdownOpen = false;
|
||||
}
|
||||
}
|
||||
|
||||
// Re-evaluate label if value changes from outside
|
||||
let selectedLabel = $derived(options.find((o) => o.value === value)?.label || value);
|
||||
</script>
|
||||
|
||||
<svelte:window onclick={handleClickOutside} />
|
||||
|
||||
<div class="relative w-full" bind:this={dropdownRef}>
|
||||
<button
|
||||
type="button"
|
||||
{id}
|
||||
class="w-full flex items-center justify-between p-4 rounded-md border bg-background text-foreground transition-all {isDropdownOpen
|
||||
? 'bg-primary/10 border-primary ring-destructive/20'
|
||||
: 'border-border hover:bg-accent'}"
|
||||
onclick={toggleDropdown}
|
||||
aria-haspopup="listbox"
|
||||
aria-expanded={isDropdownOpen}
|
||||
>
|
||||
<span class="font-medium">{selectedLabel}</span>
|
||||
<svg
|
||||
class="w-5 h-5 opacity-70 transition-transform duration-200"
|
||||
class:rotate-180={isDropdownOpen}
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M19 9l-7 7-7-7"
|
||||
></path>
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
{#if isDropdownOpen}
|
||||
<ul
|
||||
transition:slide={{ duration: 150 }}
|
||||
class="absolute z-20 w-full mt-2 bg-popover text-popover-foreground bg-card border border-border rounded-md shadow-md overflow-hidden"
|
||||
role="listbox"
|
||||
>
|
||||
{#each options as option}
|
||||
<li>
|
||||
<button
|
||||
type="button"
|
||||
class="w-full text-left px-4 py-3 text-sm transition-colors hover:bg-accent hover:text-accent-foreground {option.value ===
|
||||
value
|
||||
? 'bg-primary/10 text-primary font-medium'
|
||||
: ''}"
|
||||
role="option"
|
||||
aria-selected={option.value === value}
|
||||
onclick={() => selectOption(option.value)}
|
||||
>
|
||||
{option.label}
|
||||
</button>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
{/if}
|
||||
</div>
|
||||
@@ -0,0 +1,7 @@
|
||||
<div class="flex justify-center mb-6">
|
||||
<div
|
||||
class="inline-flex bg-card rounded-lg p-1 border border-border shadow-sm"
|
||||
>
|
||||
<slot />
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,14 @@
|
||||
<script lang="ts">
|
||||
export let active: boolean = false;
|
||||
export let label: string = "";
|
||||
export let onClick: () => void;
|
||||
</script>
|
||||
|
||||
<button
|
||||
class="px-6 py-2 rounded-md text-sm font-medium transition-colors {active
|
||||
? 'bg-primary text-white shadow-sm'
|
||||
: 'text-muted-foreground hover:text-foreground'}"
|
||||
on:click={onClick}
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
@@ -0,0 +1,42 @@
|
||||
<script lang="ts">
|
||||
let {
|
||||
checked = $bindable(false),
|
||||
disabled = false,
|
||||
label,
|
||||
onchange,
|
||||
} = $props<{
|
||||
checked?: boolean;
|
||||
disabled?: boolean;
|
||||
label?: string;
|
||||
onchange?: (checked: boolean) => void;
|
||||
}>();
|
||||
|
||||
function toggle() {
|
||||
if (!disabled) {
|
||||
checked = !checked;
|
||||
onchange?.(checked);
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="flex items-center justify-between">
|
||||
{#if label}
|
||||
<span class="font-medium">{label}</span>
|
||||
{/if}
|
||||
<!-- svelte-ignore a11y_click_events_have_key_events -->
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||
<div
|
||||
class="relative w-11 h-6 rounded-full cursor-pointer transition-colors duration-200 ease-in-out {checked
|
||||
? 'bg-primary'
|
||||
: 'bg-gray-200 dark:bg-gray-700'} {disabled
|
||||
? 'opacity-50 cursor-not-allowed'
|
||||
: ''}"
|
||||
onclick={toggle}
|
||||
>
|
||||
<div
|
||||
class="absolute top-[2px] left-[2px] bg-white border-gray-300 border rounded-full h-5 w-5 transition-transform duration-200 ease-in-out {checked
|
||||
? 'translate-x-full border-white'
|
||||
: 'translate-x-0'}"
|
||||
></div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,60 @@
|
||||
<script lang="ts">
|
||||
import { controlStore, type ControlState } from "../../stores/controlStore";
|
||||
import LightControl from "./lightControl.svelte";
|
||||
import ModeControl from "./modeControl.svelte";
|
||||
import StatusDisplay from "./statusDisplay.svelte";
|
||||
|
||||
let state = $state<ControlState>({
|
||||
on: false,
|
||||
mode: 'day',
|
||||
schema: 'schema_01.csv',
|
||||
color: { r: 0, g: 0, b: 0 },
|
||||
clock: '00:00'
|
||||
});
|
||||
$effect(() => {
|
||||
return controlStore.subscribe((value) => {
|
||||
if (value) state = value;
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
function setMode(mode: string) {
|
||||
controlStore.setState({ mode });
|
||||
}
|
||||
|
||||
function handleSchemaChange(schema: string) {
|
||||
controlStore.setState({ 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} />
|
||||
|
||||
<ModeControl
|
||||
bind:activeSchema={state.schema}
|
||||
bind:mode={state.mode}
|
||||
onchangeSchema={(e) => handleSchemaChange(e.detail)}
|
||||
onchangeMode={(e) => setMode(e.detail)}
|
||||
/>
|
||||
|
||||
<StatusDisplay
|
||||
clock={state.clock}
|
||||
color={colorToCss(state.color)}
|
||||
mode={state.mode}
|
||||
/>
|
||||
</div>
|
||||
@@ -0,0 +1,38 @@
|
||||
<script lang="ts">
|
||||
import { t } from "../../i18n/store";
|
||||
import Toggle from "../common/toggle.svelte";
|
||||
|
||||
export let lightOn = false;
|
||||
export let thunderOn = false;
|
||||
|
||||
function toggleLight(checked: boolean) {
|
||||
lightOn = checked;
|
||||
// TODO: Send command to backend
|
||||
}
|
||||
|
||||
function toggleThunder(checked: boolean) {
|
||||
thunderOn = checked;
|
||||
// TODO: Send command to backend
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="bg-card p-6 rounded-lg border border-border shadow-sm">
|
||||
<h2 class="text-lg font-semibold mb-4 flex items-center gap-2">
|
||||
💡 {$t("control.light.title")}
|
||||
</h2>
|
||||
|
||||
<div class="flex flex-col gap-4">
|
||||
<Toggle
|
||||
bind:checked={lightOn}
|
||||
label={$t("control.light.light")}
|
||||
onchange={toggleLight}
|
||||
/>
|
||||
<div class="hidden">
|
||||
<Toggle
|
||||
bind:checked={thunderOn}
|
||||
label={$t("control.light.thunder")}
|
||||
onchange={toggleThunder}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,18 @@
|
||||
<script lang="ts">
|
||||
let {
|
||||
active = false,
|
||||
icon,
|
||||
label,
|
||||
onClick
|
||||
}: { active?: boolean; icon: string; label: string; onClick: () => void } = $props();
|
||||
</script>
|
||||
|
||||
<button
|
||||
class="flex-1 flex flex-col items-center justify-center p-4 rounded-md border transition-all {active
|
||||
? 'bg-primary text-primary-foreground border-primary shadow-md'
|
||||
: 'bg-background text-foreground border-border hover:bg-accent hover:text-accent-foreground'}"
|
||||
onclick={onClick}
|
||||
>
|
||||
<span class="text-2xl mb-1">{icon}</span>
|
||||
<span class="text-sm font-medium">{label}</span>
|
||||
</button>
|
||||
@@ -0,0 +1,55 @@
|
||||
<script lang="ts">
|
||||
import { t } from '../../i18n/store';
|
||||
import ModeButton from './modeButton.svelte';
|
||||
import DropDown from '../common/dropDown.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>
|
||||
|
||||
<div class="bg-card p-6 rounded-lg border border-border shadow-sm">
|
||||
<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}
|
||||
</div>
|
||||
@@ -0,0 +1,50 @@
|
||||
<script lang="ts">
|
||||
import { t } from "../../i18n/store";
|
||||
|
||||
export let mode = "simulation";
|
||||
export let color = "#000000";
|
||||
export let clock: string | null = "12:34 Uhr";
|
||||
</script>
|
||||
|
||||
<div class="bg-card p-6 rounded-lg border border-border shadow-sm">
|
||||
<h2 class="text-lg font-semibold mb-4 flex items-center gap-2">
|
||||
📊 {$t("control.status.title")}
|
||||
</h2>
|
||||
|
||||
<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">
|
||||
{$t("control.status.mode")}
|
||||
</div>
|
||||
<div class="font-medium flex items-center gap-2">
|
||||
{#if mode === "day"}☀️ {$t("mode.day")}
|
||||
{:else if mode === "night"}🌙 {$t("mode.night")}
|
||||
{:else}🔄 {$t("mode.simulation")}{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex-1 p-3 bg-background rounded-md border border-border">
|
||||
<div class="text-xs text-muted-foreground mb-1">
|
||||
{$t("control.status.color")}
|
||||
</div>
|
||||
<div class="h-6 w-full rounded" style="background: {color};"></div>
|
||||
</div>
|
||||
|
||||
{#if mode === "simulation"}
|
||||
<div
|
||||
class="flex-1 p-3 bg-background rounded-md border border-border"
|
||||
>
|
||||
<div class="text-xs text-muted-foreground mb-1">
|
||||
{$t("control.status.clock")}
|
||||
</div>
|
||||
<div class="font-medium">
|
||||
{#if clock}
|
||||
{clock}
|
||||
{:else}
|
||||
{$t("control.status.stopped")}
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,40 @@
|
||||
<script>
|
||||
function toRoman(num) {
|
||||
const lookup = {
|
||||
M: 1000,
|
||||
CM: 900,
|
||||
D: 500,
|
||||
CD: 400,
|
||||
C: 100,
|
||||
XC: 90,
|
||||
L: 50,
|
||||
XL: 40,
|
||||
X: 10,
|
||||
IX: 9,
|
||||
V: 5,
|
||||
IV: 4,
|
||||
I: 1,
|
||||
};
|
||||
let roman = "";
|
||||
for (let i in lookup) {
|
||||
while (num >= lookup[i]) {
|
||||
roman += i;
|
||||
num -= lookup[i];
|
||||
}
|
||||
}
|
||||
return roman;
|
||||
}
|
||||
|
||||
const year = new Date().getFullYear();
|
||||
const romanYear = toRoman(year);
|
||||
</script>
|
||||
|
||||
<footer class="px-0 py-4">
|
||||
<hr class="border-0 mb-4 border-t-2 border-solid" />
|
||||
<div class="flex justify-center items-center relative">
|
||||
<p class="m-0">© {romanYear} by mars3142</p>
|
||||
<p class="absolute right-0 m-0 text-sm text-gray-500">
|
||||
v{__APP_VERSION__} ({__COMMIT_HASH__})
|
||||
</p>
|
||||
</div>
|
||||
</footer>
|
||||
@@ -0,0 +1,39 @@
|
||||
<script lang="ts">
|
||||
import Button from "./common/button.svelte";
|
||||
import { lang, t } from "../i18n/store";
|
||||
|
||||
let currentLangCode = $state($lang);
|
||||
let currentLang = $state("English");
|
||||
let currentFlag = $state("🇬🇧");
|
||||
|
||||
$effect(() => {
|
||||
lang.subscribe(($lang) => {
|
||||
currentLangCode = $lang;
|
||||
currentLang = $t("common.language");
|
||||
currentFlag = $t("common.flag");
|
||||
});
|
||||
});
|
||||
|
||||
function handleLangChange(newLang: "de" | "en") {
|
||||
lang.set(newLang);
|
||||
|
||||
localStorage.setItem("lang", newLang);
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="flex flex-wrap justify-between items-center mb-5 gap-2">
|
||||
<div>
|
||||
<Button
|
||||
ariaLabel="Sprache wechseln"
|
||||
icon={currentFlag}
|
||||
label={currentLang}
|
||||
onClick={() => {
|
||||
const newLang = currentLangCode === "de" ? "en" : "de";
|
||||
handleLangChange(newLang);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<h1 class="font-bold order-first text-2xl text-primary dark:text-text">
|
||||
🚂 System Control
|
||||
</h1>
|
||||
</div>
|
||||
@@ -1,6 +1,253 @@
|
||||
{
|
||||
"hello": "Hallo Welt",
|
||||
"welcome": "Willkommen",
|
||||
"language": "Sprache",
|
||||
"save": "Speichern"
|
||||
"common": {
|
||||
"language": "Deutsch",
|
||||
"flag": "🇩🇪",
|
||||
"save": "Speichern",
|
||||
"on": "AN",
|
||||
"off": "AUS",
|
||||
"loading": "Wird geladen..."
|
||||
},
|
||||
"page": {
|
||||
"title": "System Control"
|
||||
},
|
||||
"tab": {
|
||||
"control": {
|
||||
"title": "🎛️ Bedienung"
|
||||
},
|
||||
"config": {
|
||||
"title": "⚙️ Konfiguration",
|
||||
"subtab": {
|
||||
"wifi": "📶 WLAN",
|
||||
"light": "💡 Lichtsteuerung",
|
||||
"devices": "🔗 Geräte",
|
||||
"scenes": "🎬 Szenen"
|
||||
}
|
||||
}
|
||||
},
|
||||
"wled": {
|
||||
"config": {
|
||||
"title": "LED Konfiguration",
|
||||
"desc": "Konfiguriere die LED-Segmente und Anzahl LEDs pro Segment"
|
||||
},
|
||||
"segments": {
|
||||
"title": "Segmente",
|
||||
"empty": "Keine Segmente konfiguriert",
|
||||
"empty.hint": "Klicke auf \"Segment hinzufügen\" um ein Segment zu erstellen"
|
||||
},
|
||||
"segment": {
|
||||
"add": "➕ Segment hinzufügen",
|
||||
"name": "Segment {num}",
|
||||
"leds": "Anzahl LEDs",
|
||||
"start": "Start-LED",
|
||||
"remove": "Entfernen"
|
||||
},
|
||||
"saved": "LED-Konfiguration gespeichert!",
|
||||
"error": {
|
||||
"save": "Fehler beim Speichern der LED-Konfiguration"
|
||||
},
|
||||
"loaded": "LED-Konfiguration geladen"
|
||||
},
|
||||
"control": {
|
||||
"light": {
|
||||
"title": "Lichtsteuerung",
|
||||
"light": "Licht",
|
||||
"thunder": "Gewitter"
|
||||
},
|
||||
"mode": {
|
||||
"title": "Betriebsmodus"
|
||||
},
|
||||
"schema": {
|
||||
"active": "Aktives Schema"
|
||||
},
|
||||
"status": {
|
||||
"title": "Aktueller Status",
|
||||
"mode": "Modus",
|
||||
"schema": "Schema",
|
||||
"color": "Aktuelle Farbe",
|
||||
"clock": "Uhrzeit",
|
||||
"stopped": "Angehalten"
|
||||
}
|
||||
},
|
||||
"mode": {
|
||||
"day": "Tag",
|
||||
"night": "Nacht",
|
||||
"simulation": "Simulation"
|
||||
},
|
||||
"schema": {
|
||||
"name": {
|
||||
"1": "Schema 1 (Standard)",
|
||||
"2": "Schema 2 (Warm)",
|
||||
"3": "Schema 3 (Natur)"
|
||||
},
|
||||
"editor": {
|
||||
"title": "Licht-Schema Editor"
|
||||
},
|
||||
"file": "Schema-Datei",
|
||||
"loading": "Schema wird geladen...",
|
||||
"header": {
|
||||
"time": "Zeit",
|
||||
"color": "Farbe"
|
||||
},
|
||||
"loaded": "{file} erfolgreich geladen",
|
||||
"saved": "{file} erfolgreich gespeichert!",
|
||||
"demo": "Demo-Daten geladen (Server nicht erreichbar)"
|
||||
},
|
||||
"scenes": {
|
||||
"title": "Szenen",
|
||||
"empty": "Keine Szenen definiert",
|
||||
"empty.hint": "Erstelle Szenen unter Konfiguration",
|
||||
"manage": {
|
||||
"title": "Szenen verwalten",
|
||||
"desc": "Erstelle und bearbeite Szenen für schnellen Zugriff"
|
||||
},
|
||||
"config": {
|
||||
"empty": "Keine Szenen erstellt",
|
||||
"empty.hint": "Klicke auf \"Neue Szene\" um eine Szene zu erstellen"
|
||||
},
|
||||
"activated": "\"{name}\" aktiviert",
|
||||
"created": "Szene erstellt",
|
||||
"updated": "Szene aktualisiert",
|
||||
"deleted": "\"{name}\" gelöscht",
|
||||
"confirm": {
|
||||
"delete": "\"{name}\" wirklich löschen?"
|
||||
},
|
||||
"error": {
|
||||
"name": "Bitte Namen eingeben"
|
||||
}
|
||||
},
|
||||
"devices": {
|
||||
"external": "Externe Geräte",
|
||||
"control": {
|
||||
"empty": "Keine Geräte hinzugefügt",
|
||||
"empty.hint": "Füge Geräte unter Konfiguration hinzu"
|
||||
},
|
||||
"new": {
|
||||
"title": "Neue Geräte",
|
||||
"desc": "Unprovisionierte Matter-Geräte in der Nähe"
|
||||
},
|
||||
"searching": "Suche nach Geräten...",
|
||||
"unpaired": {
|
||||
"empty": "Keine neuen Geräte gefunden",
|
||||
"empty.hint": "Drücke \"Geräte suchen\" um nach Matter-Geräten zu suchen"
|
||||
},
|
||||
"paired": {
|
||||
"title": "Zugeordnete Geräte",
|
||||
"desc": "Bereits hinzugefügte externe Geräte",
|
||||
"empty": "Keine Geräte hinzugefügt"
|
||||
},
|
||||
"none": {
|
||||
"available": "Keine Geräte verfügbar"
|
||||
},
|
||||
"found": "{count} Gerät(e) gefunden",
|
||||
"added": "\"{name}\" erfolgreich hinzugefügt",
|
||||
"removed": "\"{name}\" entfernt",
|
||||
"name": {
|
||||
"updated": "Name aktualisiert"
|
||||
},
|
||||
"confirm": {
|
||||
"remove": "\"{name}\" wirklich entfernen?"
|
||||
}
|
||||
},
|
||||
"wifi": {
|
||||
"config": {
|
||||
"title": "WLAN Konfiguration"
|
||||
},
|
||||
"ssid": "WLAN Name (SSID)",
|
||||
"ssid.placeholder": "Netzwerkname eingeben",
|
||||
"password": "WLAN Passwort",
|
||||
"password.short": "Passwort",
|
||||
"password.placeholder": "Passwort eingeben",
|
||||
"available": "Verfügbare Netzwerke",
|
||||
"scan": {
|
||||
"hint": "Nach Netzwerken suchen...",
|
||||
"error": "Fehler beim WLAN-Scan",
|
||||
"failed": "Netzwerksuche fehlgeschlagen"
|
||||
},
|
||||
"status": {
|
||||
"title": "Verbindungsstatus",
|
||||
"status": "Status:",
|
||||
"ip": "IP-Adresse:",
|
||||
"signal": "Signal:"
|
||||
},
|
||||
"connected": "✅ Verbunden",
|
||||
"disconnected": "❌ Nicht verbunden",
|
||||
"unavailable": "⚠️ Status nicht verfügbar",
|
||||
"searching": "Suche läuft...",
|
||||
"saved": "WLAN-Konfiguration gespeichert! Gerät verbindet sich...",
|
||||
"error": {
|
||||
"ssid": "Bitte WLAN-Name eingeben",
|
||||
"save": "Fehler beim Speichern"
|
||||
},
|
||||
"networks": {
|
||||
"found": "{count} Netzwerk(e) gefunden",
|
||||
"notfound": "Keine Netzwerke gefunden."
|
||||
},
|
||||
"signal": "Signal",
|
||||
"secure": "Gesichert",
|
||||
"open": "Offen"
|
||||
},
|
||||
"modal": {
|
||||
"color": {
|
||||
"title": "Farbe wählen"
|
||||
},
|
||||
"scene": {
|
||||
"new": "Neue Szene erstellen",
|
||||
"edit": "Szene bearbeiten"
|
||||
}
|
||||
},
|
||||
"scene": {
|
||||
"name": "Name",
|
||||
"name.placeholder": "z.B. Abendstimmung",
|
||||
"icon": "Icon auswählen",
|
||||
"actions": "Aktionen",
|
||||
"action": {
|
||||
"light": "Licht Ein/Aus",
|
||||
"mode": "Modus setzen",
|
||||
"schema": "Schema wählen"
|
||||
},
|
||||
"light": {
|
||||
"on": "Einschalten",
|
||||
"off": "Ausschalten"
|
||||
}
|
||||
},
|
||||
"btn": {
|
||||
"scan": "🔍 Suchen",
|
||||
"save": "💾 Speichern",
|
||||
"load": "🔄 Laden",
|
||||
"cancel": "Abbrechen",
|
||||
"apply": "Übernehmen",
|
||||
"new": {
|
||||
"scene": "➕ Neue Szene"
|
||||
},
|
||||
"scan.devices": "🔍 Geräte suchen",
|
||||
"add": "Hinzufügen",
|
||||
"remove": "Entfernen",
|
||||
"edit": "Bearbeiten",
|
||||
"delete": "Löschen"
|
||||
},
|
||||
"captive": {
|
||||
"title": "System Control - WLAN Setup",
|
||||
"subtitle": "WLAN-Einrichtung",
|
||||
"scan": "📡 Netzwerke suchen",
|
||||
"scanning": "Suche nach Netzwerken...",
|
||||
"or": {
|
||||
"manual": "oder manuell eingeben"
|
||||
},
|
||||
"password": {
|
||||
"placeholder": "WLAN-Passwort"
|
||||
},
|
||||
"connect": "💾 Verbinden",
|
||||
"note": {
|
||||
"title": "Hinweis:",
|
||||
"text": "Nach dem Speichern verbindet sich das Gerät mit dem gewählten Netzwerk. Diese Seite wird dann nicht mehr erreichbar sein. Verbinden Sie sich mit Ihrem normalen WLAN, um auf das Gerät zuzugreifen."
|
||||
},
|
||||
"connecting": "Verbindung wird hergestellt... {seconds}s",
|
||||
"done": "Gerät sollte jetzt verbunden sein. Sie können diese Seite schließen."
|
||||
},
|
||||
"loading": "Laden...",
|
||||
"error": "Fehler",
|
||||
"success": "Erfolg",
|
||||
"clock": {
|
||||
"suffix": "Uhr"
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,253 @@
|
||||
{
|
||||
"hello": "Hello World",
|
||||
"welcome": "Welcome",
|
||||
"language": "Language",
|
||||
"save": "Save"
|
||||
"common": {
|
||||
"language": "English",
|
||||
"flag": "🇬🇧",
|
||||
"save": "Save",
|
||||
"on": "ON",
|
||||
"off": "OFF",
|
||||
"loading": "Loading..."
|
||||
},
|
||||
"page": {
|
||||
"title": "System Control"
|
||||
},
|
||||
"tab": {
|
||||
"control": {
|
||||
"title": "🎛️ Control"
|
||||
},
|
||||
"config": {
|
||||
"title": "⚙️ Settings",
|
||||
"subtab": {
|
||||
"wifi": "📶 WiFi",
|
||||
"light": "💡 Light Control",
|
||||
"devices": "🔗 Devices",
|
||||
"scenes": "🎬 Scenes"
|
||||
}
|
||||
}
|
||||
},
|
||||
"wled": {
|
||||
"config": {
|
||||
"title": "LED Configuration",
|
||||
"desc": "Configure LED segments and number of LEDs per segment"
|
||||
},
|
||||
"segments": {
|
||||
"title": "Segments",
|
||||
"empty": "No segments configured",
|
||||
"empty.hint": "Click \"Add Segment\" to create a segment"
|
||||
},
|
||||
"segment": {
|
||||
"add": "➕ Add Segment",
|
||||
"name": "Segment {num}",
|
||||
"leds": "Number of LEDs",
|
||||
"start": "Start LED",
|
||||
"remove": "Remove"
|
||||
},
|
||||
"saved": "LED configuration saved!",
|
||||
"error": {
|
||||
"save": "Error saving LED configuration"
|
||||
},
|
||||
"loaded": "LED configuration loaded"
|
||||
},
|
||||
"control": {
|
||||
"light": {
|
||||
"title": "Light Control",
|
||||
"light": "Light",
|
||||
"thunder": "Thunder"
|
||||
},
|
||||
"mode": {
|
||||
"title": "Operating Mode"
|
||||
},
|
||||
"schema": {
|
||||
"active": "Active Schema"
|
||||
},
|
||||
"status": {
|
||||
"title": "Current Status",
|
||||
"mode": "Mode",
|
||||
"schema": "Schema",
|
||||
"color": "Current Color",
|
||||
"clock": "Time",
|
||||
"stopped": "Stopped"
|
||||
}
|
||||
},
|
||||
"mode": {
|
||||
"day": "Day",
|
||||
"night": "Night",
|
||||
"simulation": "Simulation"
|
||||
},
|
||||
"schema": {
|
||||
"name": {
|
||||
"1": "Schema 1 (Standard)",
|
||||
"2": "Schema 2 (Warm)",
|
||||
"3": "Schema 3 (Natural)"
|
||||
},
|
||||
"editor": {
|
||||
"title": "Light Schema Editor"
|
||||
},
|
||||
"file": "Schema File",
|
||||
"loading": "Loading schema...",
|
||||
"header": {
|
||||
"time": "Time",
|
||||
"color": "Color"
|
||||
},
|
||||
"loaded": "{file} loaded successfully",
|
||||
"saved": "{file} saved successfully!",
|
||||
"demo": "Demo data loaded (server unreachable)"
|
||||
},
|
||||
"scenes": {
|
||||
"title": "Scenes",
|
||||
"empty": "No scenes defined",
|
||||
"empty.hint": "Create scenes in settings",
|
||||
"manage": {
|
||||
"title": "Manage Scenes",
|
||||
"desc": "Create and edit scenes for quick access"
|
||||
},
|
||||
"config": {
|
||||
"empty": "No scenes created",
|
||||
"empty.hint": "Click \"New Scene\" to create a scene"
|
||||
},
|
||||
"activated": "\"{name}\" activated",
|
||||
"created": "Scene created",
|
||||
"updated": "Scene updated",
|
||||
"deleted": "\"{name}\" deleted",
|
||||
"confirm": {
|
||||
"delete": "Really delete \"{name}\"?"
|
||||
},
|
||||
"error": {
|
||||
"name": "Please enter a name"
|
||||
}
|
||||
},
|
||||
"devices": {
|
||||
"external": "External Devices",
|
||||
"control": {
|
||||
"empty": "No devices added",
|
||||
"empty.hint": "Add devices in settings"
|
||||
},
|
||||
"new": {
|
||||
"title": "New Devices",
|
||||
"desc": "Unprovisioned Matter devices nearby"
|
||||
},
|
||||
"searching": "Searching for devices...",
|
||||
"unpaired": {
|
||||
"empty": "No new devices found",
|
||||
"empty.hint": "Press \"Scan devices\" to search for Matter devices"
|
||||
},
|
||||
"paired": {
|
||||
"title": "Paired Devices",
|
||||
"desc": "Already added external devices",
|
||||
"empty": "No devices added"
|
||||
},
|
||||
"none": {
|
||||
"available": "No devices available"
|
||||
},
|
||||
"found": "{count} device(s) found",
|
||||
"added": "\"{name}\" added successfully",
|
||||
"removed": "\"{name}\" removed",
|
||||
"name": {
|
||||
"updated": "Name updated"
|
||||
},
|
||||
"confirm": {
|
||||
"remove": "Really remove \"{name}\"?"
|
||||
}
|
||||
},
|
||||
"wifi": {
|
||||
"config": {
|
||||
"title": "WiFi Configuration"
|
||||
},
|
||||
"ssid": "WiFi Name (SSID)",
|
||||
"ssid.placeholder": "Enter network name",
|
||||
"password": "WiFi Password",
|
||||
"password.short": "Password",
|
||||
"password.placeholder": "Enter password",
|
||||
"available": "Available Networks",
|
||||
"scan": {
|
||||
"hint": "Search for networks...",
|
||||
"error": "Scan error",
|
||||
"failed": "Network scan failed"
|
||||
},
|
||||
"status": {
|
||||
"title": "Connection Status",
|
||||
"status": "Status:",
|
||||
"ip": "IP Address:",
|
||||
"signal": "Signal:"
|
||||
},
|
||||
"connected": "✅ Connected",
|
||||
"disconnected": "❌ Not connected",
|
||||
"unavailable": "⚠️ Status unavailable",
|
||||
"searching": "Searching...",
|
||||
"saved": "WiFi configuration saved! Device connecting...",
|
||||
"error": {
|
||||
"ssid": "Please enter WiFi name",
|
||||
"save": "Error saving"
|
||||
},
|
||||
"networks": {
|
||||
"found": "{count} network(s) found",
|
||||
"notfound": "No networks found."
|
||||
},
|
||||
"signal": "Signal",
|
||||
"secure": "Secured",
|
||||
"open": "Open"
|
||||
},
|
||||
"modal": {
|
||||
"color": {
|
||||
"title": "Choose Color"
|
||||
},
|
||||
"scene": {
|
||||
"new": "Create New Scene",
|
||||
"edit": "Edit Scene"
|
||||
}
|
||||
},
|
||||
"scene": {
|
||||
"name": "Name",
|
||||
"name.placeholder": "e.g. Evening Mood",
|
||||
"icon": "Choose Icon",
|
||||
"actions": "Actions",
|
||||
"action": {
|
||||
"light": "Light On/Off",
|
||||
"mode": "Set Mode",
|
||||
"schema": "Choose Schema"
|
||||
},
|
||||
"light": {
|
||||
"on": "Turn On",
|
||||
"off": "Turn Off"
|
||||
}
|
||||
},
|
||||
"btn": {
|
||||
"scan": "🔍 Scan",
|
||||
"save": "💾 Save",
|
||||
"load": "🔄 Load",
|
||||
"cancel": "Cancel",
|
||||
"apply": "Apply",
|
||||
"new": {
|
||||
"scene": "➕ New Scene"
|
||||
},
|
||||
"scan.devices": "🔍 Scan Devices",
|
||||
"add": "Add",
|
||||
"remove": "Remove",
|
||||
"edit": "Edit",
|
||||
"delete": "Delete"
|
||||
},
|
||||
"captive": {
|
||||
"title": "System Control - WiFi Setup",
|
||||
"subtitle": "WiFi Setup",
|
||||
"scan": "📡 Scan Networks",
|
||||
"scanning": "Scanning for networks...",
|
||||
"or": {
|
||||
"manual": "or enter manually"
|
||||
},
|
||||
"password": {
|
||||
"placeholder": "WiFi password"
|
||||
},
|
||||
"connect": "💾 Connect",
|
||||
"note": {
|
||||
"title": "Note:",
|
||||
"text": "After saving, the device will connect to the selected network. This page will no longer be accessible. Connect to your regular WiFi to access the device."
|
||||
},
|
||||
"connecting": "Connecting... {seconds}s",
|
||||
"done": "Device should now be connected. You can close this page."
|
||||
},
|
||||
"loading": "Loading...",
|
||||
"error": "Error",
|
||||
"success": "Success",
|
||||
"clock": {
|
||||
"suffix": "o'clock"
|
||||
}
|
||||
}
|
||||
@@ -6,7 +6,7 @@ export const translations = { de, en };
|
||||
export type Lang = keyof typeof translations;
|
||||
|
||||
export function getInitialLang(): Lang {
|
||||
const navLang = navigator.language.slice(0, 2);
|
||||
if (navLang in translations) return navLang as Lang;
|
||||
return 'en';
|
||||
const navLang = navigator.language.slice(0, 2);
|
||||
if (navLang in translations) return navLang as Lang;
|
||||
return 'en';
|
||||
}
|
||||
|
||||
@@ -1,16 +1,23 @@
|
||||
import { writable, derived } from 'svelte/store';
|
||||
import { translations, getInitialLang, type Lang } from './index';
|
||||
import { derived, writable } from 'svelte/store';
|
||||
import { getInitialLang, type Lang, translations } from './index';
|
||||
|
||||
function getLang(): Lang {
|
||||
const stored = localStorage.getItem('lang');
|
||||
if (stored && stored in translations) return stored as Lang;
|
||||
return getInitialLang();
|
||||
const stored = localStorage.getItem('lang');
|
||||
if (stored && stored in translations) return stored as Lang;
|
||||
return getInitialLang();
|
||||
}
|
||||
|
||||
export const lang = writable<Lang>(getLang());
|
||||
|
||||
function getNestedTranslation(obj: any, path: string): string {
|
||||
return path.split('.').reduce((prev, curr) => {
|
||||
return prev ? prev[curr] : null;
|
||||
}, obj);
|
||||
}
|
||||
|
||||
export const t = derived(lang, $lang => {
|
||||
return (key: string) => {
|
||||
return translations[$lang][key] || key;
|
||||
};
|
||||
return (key: string) => {
|
||||
const translation = getNestedTranslation(translations[$lang], key);
|
||||
return translation || '[' + key + ']';
|
||||
};
|
||||
});
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from "svelte";
|
||||
import { t } from "./i18n/store";
|
||||
import ControlTab from "./components/control/controlTab.svelte";
|
||||
import ConfigTab from "./components/config/configTab.svelte";
|
||||
import TabButton from "./components/common/tabButton.svelte";
|
||||
import TabBar from "./components/common/tabBar.svelte";
|
||||
import { controlStore } from "./stores/controlStore";
|
||||
|
||||
let activeTab = "control";
|
||||
|
||||
onMount(() => {
|
||||
controlStore.fetchState();
|
||||
const handleHashChange = () => {
|
||||
const hash = window.location.hash.slice(1);
|
||||
if (hash === "config") {
|
||||
activeTab = "config";
|
||||
} else {
|
||||
activeTab = "control";
|
||||
}
|
||||
};
|
||||
|
||||
handleHashChange();
|
||||
window.addEventListener("hashchange", handleHashChange);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener("hashchange", handleHashChange);
|
||||
};
|
||||
});
|
||||
|
||||
function setTab(tab: string) {
|
||||
activeTab = tab;
|
||||
window.location.hash = tab;
|
||||
}
|
||||
</script>
|
||||
|
||||
<TabBar>
|
||||
<TabButton
|
||||
active={activeTab === "control"}
|
||||
label={$t("tab.control.title")}
|
||||
onClick={() => setTab("control")}
|
||||
/>
|
||||
<TabButton
|
||||
active={activeTab === "config"}
|
||||
label={$t("tab.config.title")}
|
||||
onClick={() => setTab("config")}
|
||||
/>
|
||||
</TabBar>
|
||||
|
||||
<div class="tab-content">
|
||||
{#if activeTab === "control"}
|
||||
<ControlTab />
|
||||
{:else if activeTab === "config"}
|
||||
<ConfigTab />
|
||||
{/if}
|
||||
</div>
|
||||
@@ -1,9 +1,9 @@
|
||||
import { mount } from 'svelte'
|
||||
import App from './App.svelte'
|
||||
import './app.css'
|
||||
import { mount } from 'svelte';
|
||||
import App from './app.svelte';
|
||||
import './app.css';
|
||||
|
||||
const app = mount(App, {
|
||||
target: document.getElementById('app'),
|
||||
})
|
||||
target: document.getElementById('app')
|
||||
});
|
||||
|
||||
export default app
|
||||
export default app;
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
import { writable } from 'svelte/store';
|
||||
|
||||
// Types for state and REST/WebSocket messages
|
||||
export interface ControlState {
|
||||
on: boolean;
|
||||
mode: string;
|
||||
schema?: string;
|
||||
color?: { r: number; g: number; b: number };
|
||||
clock?: string;
|
||||
|
||||
[key: string]: any;
|
||||
}
|
||||
|
||||
const createControlStore = () => {
|
||||
const store = writable<ControlState>({
|
||||
on: false,
|
||||
mode: 'day',
|
||||
schema: 'schema_01.csv',
|
||||
color: { r: 0, g: 0, b: 0 },
|
||||
clock: '00:00'
|
||||
});
|
||||
const { subscribe, set } = store;
|
||||
|
||||
// Centralized host and URL configuration
|
||||
const host = import.meta.env.DEV
|
||||
? 'system-control.local'
|
||||
: typeof window !== 'undefined'
|
||||
? window.location.host
|
||||
: '';
|
||||
const baseUrl = import.meta.env.DEV ? `http://${host}` : '';
|
||||
|
||||
let ws: WebSocket | null = null;
|
||||
let wsReconnectTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
async function fetchState() {
|
||||
const res = await fetch(`${baseUrl}/api/light/status`);
|
||||
if (!res.ok) throw new Error('Failed to fetch state');
|
||||
const data = await res.json();
|
||||
set(data);
|
||||
}
|
||||
|
||||
async function setState(partial: Partial<ControlState>) {
|
||||
const res = await fetch(`${baseUrl}/api/status`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(partial)
|
||||
});
|
||||
if (!res.ok) throw new Error('Failed to update state');
|
||||
const data = await res.json();
|
||||
set(data);
|
||||
}
|
||||
|
||||
function connectWebSocket() {
|
||||
if (typeof window === 'undefined') return;
|
||||
|
||||
const wsProtocol =
|
||||
window.location.protocol === 'https:' && !import.meta.env.DEV ? 'wss:' : 'ws:';
|
||||
const wsUrl = `${wsProtocol}//${host}/ws`;
|
||||
|
||||
ws = new WebSocket(wsUrl);
|
||||
|
||||
ws.onopen = () => {
|
||||
if (wsReconnectTimer) clearTimeout(wsReconnectTimer);
|
||||
ws?.send(JSON.stringify({ type: 'getStatus' }));
|
||||
};
|
||||
ws.onmessage = (event) => {
|
||||
try {
|
||||
const data = JSON.parse(event.data);
|
||||
if (data.type === 'status') set(data);
|
||||
} catch (e) {
|
||||
// ignore
|
||||
}
|
||||
};
|
||||
ws.onclose = () => {
|
||||
ws = null;
|
||||
wsReconnectTimer = setTimeout(connectWebSocket, 3000);
|
||||
};
|
||||
ws.onerror = () => {
|
||||
ws?.close();
|
||||
};
|
||||
}
|
||||
|
||||
if (typeof window !== 'undefined') connectWebSocket();
|
||||
|
||||
return {
|
||||
...store,
|
||||
fetchState,
|
||||
setState
|
||||
};
|
||||
};
|
||||
|
||||
export const controlStore = createControlStore();
|
||||
@@ -1,18 +0,0 @@
|
||||
// src/theme.ts
|
||||
export function setTheme(theme: 'light' | 'dark') {
|
||||
document.documentElement.setAttribute('data-theme', theme);
|
||||
localStorage.setItem('theme', theme);
|
||||
|
||||
const icon = document.getElementById('theme-icon');
|
||||
const label = document.getElementById('theme-label');
|
||||
const metaTheme = document.querySelector('meta[name="theme-color"]') as HTMLMetaElement | null;
|
||||
|
||||
if (icon) icon.textContent = theme === 'light' ? '☀️' : '🌙';
|
||||
if (label) label.textContent = theme === 'light' ? 'Light' : 'Dark';
|
||||
if (metaTheme) metaTheme.content = theme === 'light' ? '#f0f2f5' : '#1a1a2e';
|
||||
}
|
||||
|
||||
export function toggleTheme() {
|
||||
const current = document.documentElement.getAttribute('data-theme') as 'light' | 'dark' | null || 'dark';
|
||||
setTheme(current === 'dark' ? 'light' : 'dark');
|
||||
}
|
||||
Vendored
+3
@@ -1,2 +1,5 @@
|
||||
/// <reference types="svelte" />
|
||||
/// <reference types="vite/client" />
|
||||
|
||||
declare const __COMMIT_HASH__: string;
|
||||
declare const __APP_VERSION__: string;
|
||||
|
||||
@@ -1,17 +1,5 @@
|
||||
import { vitePreprocess } from '@sveltejs/vite-plugin-svelte'
|
||||
import { vitePreprocess } from '@sveltejs/vite-plugin-svelte';
|
||||
|
||||
/** @type {import("@sveltejs/vite-plugin-svelte").SvelteConfig} */
|
||||
export default {
|
||||
// Consult https://svelte.dev/docs#compile-time-svelte-preprocess
|
||||
// for more information about preprocessors
|
||||
preprocess: vitePreprocess(),
|
||||
compilerOptions: {
|
||||
runes: true,
|
||||
},
|
||||
vitePlugin: {
|
||||
inspector: {
|
||||
showToggleButton: 'always',
|
||||
toggleButtonPos: 'bottom-right'
|
||||
}
|
||||
}
|
||||
}
|
||||
preprocess: vitePreprocess()
|
||||
};
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
/** @type {import('tailwindcss').Config} */
|
||||
export default {
|
||||
content: ['./src/**/*.{html,js,svelte,ts}'],
|
||||
darkMode: 'media',
|
||||
theme: {
|
||||
extend: {
|
||||
colors: {
|
||||
bg: 'var(--bg-color)',
|
||||
card: 'var(--card-bg)',
|
||||
accent: 'var(--accent)',
|
||||
text: 'var(--text)',
|
||||
'text-muted': 'var(--text-muted)',
|
||||
success: 'var(--success)',
|
||||
error: 'var(--error)',
|
||||
border: 'var(--border)',
|
||||
input: 'var(--input-bg)',
|
||||
primary: 'var(--primary)',
|
||||
},
|
||||
boxShadow: {
|
||||
custom: 'var(--shadow)',
|
||||
}
|
||||
},
|
||||
},
|
||||
plugins: [],
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"moduleResolution": "bundler",
|
||||
"target": "ESNext",
|
||||
"module": "ESNext",
|
||||
"lib": [
|
||||
"ES2015",
|
||||
"DOM"
|
||||
],
|
||||
"verbatimModuleSyntax": true,
|
||||
"isolatedModules": true,
|
||||
"resolveJsonModule": true,
|
||||
"sourceMap": true,
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true,
|
||||
"allowJs": true,
|
||||
"checkJs": true
|
||||
},
|
||||
"include": [
|
||||
"src/**/*.d.ts",
|
||||
"src/**/*.js",
|
||||
"src/**/*.svelte",
|
||||
"src/**/*.ts"
|
||||
]
|
||||
}
|
||||
@@ -1,14 +1,48 @@
|
||||
import { defineConfig } from 'vite'
|
||||
import { svelte } from '@sveltejs/vite-plugin-svelte'
|
||||
/// <reference types="vitest/config" />
|
||||
import { defineConfig } from 'vite';
|
||||
import { svelte } from '@sveltejs/vite-plugin-svelte';
|
||||
import viteCompression from 'vite-plugin-compression';
|
||||
import { execSync } from 'child_process';
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
|
||||
const commitHash = execSync('git rev-parse --short HEAD').toString().trim();
|
||||
const version = fs.readFileSync(path.resolve(__dirname, '../version.txt'), 'utf-8').trim();
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [
|
||||
svelte(),
|
||||
viteCompression()
|
||||
],
|
||||
build: {
|
||||
outDir: '../storage/website',
|
||||
assetsDir: '',
|
||||
},
|
||||
})
|
||||
define: {
|
||||
__COMMIT_HASH__: JSON.stringify(commitHash),
|
||||
__APP_VERSION__: JSON.stringify(version),
|
||||
},
|
||||
plugins: [
|
||||
svelte({
|
||||
configFile: 'svelte.config.js'
|
||||
}),
|
||||
viteCompression({
|
||||
algorithm: 'gzip',
|
||||
ext: '.gz',
|
||||
deleteOriginFile: true,
|
||||
}),
|
||||
],
|
||||
build: {
|
||||
outDir: '../storage/website',
|
||||
assetsDir: '',
|
||||
sourcemap: true,
|
||||
rollupOptions: {
|
||||
output: {
|
||||
manualChunks(id) {
|
||||
if (id.includes('node_modules')) {
|
||||
return 'vendor';
|
||||
}
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
server: {
|
||||
port: 5173,
|
||||
strictPort: true,
|
||||
sourcemapIgnoreList(sourcePath) {
|
||||
return sourcePath.includes('node_modules');
|
||||
}
|
||||
}
|
||||
});
|
||||
Reference in New Issue
Block a user