This commit is contained in:
Thomas Peterson 2026-01-25 14:07:17 +01:00
parent 872c004b76
commit c9196fcaf2
23 changed files with 3890 additions and 0 deletions

24
web-interface/.gitignore vendored Normal file
View File

@ -0,0 +1,24 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?

3
web-interface/.vscode/extensions.json vendored Normal file
View File

@ -0,0 +1,3 @@
{
"recommendations": ["Vue.volar"]
}

5
web-interface/README.md Normal file
View File

@ -0,0 +1,5 @@
# Vue 3 + Vite
This template should help get you started developing with Vue 3 in Vite. The template uses Vue 3 `<script setup>` SFCs, check out the [script setup docs](https://v3.vuejs.org/api/sfc-script-setup.html#sfc-script-setup) to learn more.
Learn more about IDE Support for Vue in the [Vue Docs Scaling up Guide](https://vuejs.org/guide/scaling-up/tooling.html#ide-support).

13
web-interface/index.html Normal file
View File

@ -0,0 +1,13 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>web-interface</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.js"></script>
</body>
</html>

2092
web-interface/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,27 @@
{
"name": "web-interface",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview"
},
"dependencies": {
"@vueuse/core": "^14.1.0",
"pinia": "^3.0.4",
"vue": "^3.5.24"
},
"devDependencies": {
"@tailwindcss/postcss": "^4.1.18",
"@vitejs/plugin-vue": "^6.0.1",
"autoprefixer": "^10.4.23",
"postcss": "^8.5.6",
"tailwindcss": "^4.1.18",
"vite": "npm:rolldown-vite@7.2.5"
},
"overrides": {
"vite": "npm:rolldown-vite@7.2.5"
}
}

View File

@ -0,0 +1,6 @@
export default {
plugins: {
'@tailwindcss/postcss': {},
autoprefixer: {},
},
}

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFEA83"></stop><stop offset="8.333%" stop-color="#FFDD35"></stop><stop offset="100%" stop-color="#FFA800"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

24
web-interface/src/App.vue Normal file
View File

@ -0,0 +1,24 @@
<template>
<TopBar />
<div class="workspace">
<SidebarLeft />
<CanvasArea />
<SidebarRight />
</div>
</template>
<script setup>
import { onMounted } from 'vue';
import TopBar from './components/TopBar.vue';
import SidebarLeft from './components/SidebarLeft.vue';
import SidebarRight from './components/SidebarRight.vue';
import CanvasArea from './components/CanvasArea.vue';
import { useEditorStore } from './stores/editor';
const store = useEditorStore();
onMounted(() => {
store.loadConfig();
store.loadKnxAddresses();
});
</script>

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="37.07" height="36" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 198"><path fill="#41B883" d="M204.8 0H256L128 220.8L0 0h97.92L128 51.2L157.44 0h47.36Z"></path><path fill="#41B883" d="m0 0l128 220.8L256 0h-51.2L128 132.48L50.56 0H0Z"></path><path fill="#35495E" d="M50.56 0L128 133.12L204.8 0h-47.36L128 51.2L97.92 0H50.56Z"></path></svg>

After

Width:  |  Height:  |  Size: 496 B

View File

@ -0,0 +1,165 @@
<template>
<main class="canvas-area">
<div class="canvas-shell">
<div
class="canvas"
id="canvas"
:class="{ 'grid-off': !store.showGrid }"
:style="canvasStyle"
@click.self="deselect"
>
<WidgetElement
v-for="widget in store.activeScreen?.widgets || []"
:key="widget.id"
:widget="widget"
:scale="store.canvasScale"
:selected="store.selectedWidgetId === widget.id"
@select="store.selectedWidgetId = widget.id"
@drag-start="startDrag(widget.id, $event)"
@resize-start="startResize(widget.id, $event)"
/>
</div>
</div>
</main>
</template>
<script setup>
import { computed, onMounted, onUnmounted, ref } from 'vue';
import { useEditorStore } from '../stores/editor';
import WidgetElement from './WidgetElement.vue';
import { DISPLAY_W, DISPLAY_H, WIDGET_TYPES } from '../constants';
import { clamp, minSizeFor } from '../utils';
const store = useEditorStore();
const canvasStyle = computed(() => ({
width: `${DISPLAY_W * store.canvasScale}px`,
height: `${DISPLAY_H * store.canvasScale}px`,
'--canvas-bg': store.activeScreen?.bgColor || '#1A1A2E'
}));
function deselect() {
store.selectedWidgetId = null;
}
let dragState = null;
let resizeState = null;
function startDrag(id, e) {
if (e.target.closest('.resize-handle')) return;
e.preventDefault();
store.selectedWidgetId = id;
const cx = e.touches ? e.touches[0].clientX : e.clientX;
const cy = e.touches ? e.touches[0].clientY : e.clientY;
const widget = store.activeScreen.widgets.find(w => w.id === id);
if (!widget) return;
dragState = {
id,
startX: cx,
startY: cy,
origX: widget.x,
origY: widget.y
};
resizeState = null;
document.addEventListener('mousemove', drag);
document.addEventListener('mouseup', endDrag);
document.addEventListener('touchmove', drag, { passive: false });
document.addEventListener('touchend', endDrag);
}
function drag(e) {
if (!dragState) return;
e.preventDefault();
const cx = e.touches ? e.touches[0].clientX : e.clientX;
const cy = e.touches ? e.touches[0].clientY : e.clientY;
const dx = (cx - dragState.startX) / store.canvasScale;
const dy = (cy - dragState.startY) / store.canvasScale;
const w = store.activeScreen.widgets.find(x => x.id === dragState.id);
if (w) {
w.x = Math.max(0, Math.min(DISPLAY_W - w.w, Math.round(dragState.origX + dx)));
w.y = Math.max(0, Math.min(DISPLAY_H - w.h, Math.round(dragState.origY + dy)));
}
}
function endDrag() {
dragState = null;
document.removeEventListener('mousemove', drag);
document.removeEventListener('mouseup', endDrag);
document.removeEventListener('touchmove', drag);
document.removeEventListener('touchend', endDrag);
}
function startResize(id, e) {
e.preventDefault();
store.selectedWidgetId = id;
const cx = e.touches ? e.touches[0].clientX : e.clientX;
const cy = e.touches ? e.touches[0].clientY : e.clientY;
const widget = store.activeScreen.widgets.find(w => w.id === id);
if (!widget) return;
resizeState = {
id,
startX: cx,
startY: cy,
origW: widget.w,
origH: widget.h
};
dragState = null;
document.addEventListener('mousemove', resizeDrag);
document.addEventListener('mouseup', endResize);
document.addEventListener('touchmove', resizeDrag, { passive: false });
document.addEventListener('touchend', endResize);
}
function resizeDrag(e) {
if (!resizeState) return;
e.preventDefault();
const cx = e.touches ? e.touches[0].clientX : e.clientX;
const cy = e.touches ? e.touches[0].clientY : e.clientY;
const dx = (cx - resizeState.startX) / store.canvasScale;
const dy = (cy - resizeState.startY) / store.canvasScale;
const w = store.activeScreen.widgets.find(x => x.id === resizeState.id);
if (!w) return;
const minSize = minSizeFor(w);
const maxW = DISPLAY_W - w.x;
const maxH = DISPLAY_H - w.y;
let newW = Math.round(resizeState.origW + dx);
let newH = Math.round(resizeState.origH + dy);
if (w.type === WIDGET_TYPES.LED) {
const maxSize = Math.min(maxW, maxH);
const size = clamp(Math.max(newW, newH), minSize.w, maxSize);
newW = size;
newH = size;
} else {
newW = clamp(newW, minSize.w, maxW);
newH = clamp(newH, minSize.h, maxH);
}
w.w = newW;
w.h = newH;
}
function endResize() {
resizeState = null;
document.removeEventListener('mousemove', resizeDrag);
document.removeEventListener('mouseup', endResize);
document.removeEventListener('touchmove', resizeDrag);
document.removeEventListener('touchend', endResize);
}
</script>

View File

@ -0,0 +1,43 @@
<script setup>
import { ref } from 'vue'
defineProps({
msg: String,
})
const count = ref(0)
</script>
<template>
<h1>{{ msg }}</h1>
<div class="card">
<button type="button" @click="count++">count is {{ count }}</button>
<p>
Edit
<code>components/HelloWorld.vue</code> to test HMR
</p>
</div>
<p>
Check out
<a href="https://vuejs.org/guide/quick-start.html#local" target="_blank"
>create-vue</a
>, the official Vue + Vite starter
</p>
<p>
Learn more about IDE Support for Vue in the
<a
href="https://vuejs.org/guide/scaling-up/tooling.html#ide-support"
target="_blank"
>Vue Docs Scaling up Guide</a
>.
</p>
<p class="read-the-docs">Click on the Vite and Vue logos to learn more</p>
</template>
<style scoped>
.read-the-docs {
color: #888;
}
</style>

View File

@ -0,0 +1,139 @@
<template>
<aside class="sidebar left">
<section class="panel">
<div class="panel-header">
<h3>Elemente</h3>
<span class="panel-hint">Klick zum Hinzufuegen</span>
</div>
<div class="element-grid">
<button class="element-btn" @click="store.addWidget('label')">
<span class="element-title">Label</span>
<span class="element-sub">Text</span>
</button>
<button class="element-btn" @click="store.addWidget('button')">
<span class="element-title">Button</span>
<span class="element-sub">Aktion</span>
</button>
<button class="element-btn" @click="store.addWidget('led')">
<span class="element-title">LED</span>
<span class="element-sub">Status</span>
</button>
</div>
</section>
<section class="panel">
<div class="panel-header">
<h3>Bildschirme</h3>
<button class="icon-btn" @click="store.addScreen">+</button>
</div>
<div class="screen-list">
<div
v-for="screen in store.config.screens"
:key="screen.id"
class="screen-item"
:class="{ active: screen.id === store.activeScreenId }"
@click="store.activeScreenId = screen.id; store.selectedWidgetId = null;"
>
<span class="screen-tag">{{ screen.mode === 1 ? 'Modal' : 'Fullscreen' }}</span>
<span class="screen-name">{{ screen.name }}</span>
</div>
</div>
<div class="screen-settings" v-if="store.activeScreen">
<div class="control-row">
<label>Name</label>
<input type="text" v-model="store.activeScreen.name">
</div>
<div class="control-row">
<label>Modus</label>
<select v-model.number="store.activeScreen.mode">
<option :value="0">Fullscreen</option>
<option :value="1">Modal</option>
</select>
</div>
<button class="btn ghost danger small" @click="store.deleteScreen">Screen loeschen</button>
</div>
</section>
<section class="panel">
<div class="panel-header">
<h3>Baum</h3>
<span class="panel-hint">{{ store.activeScreen?.widgets.length || 0 }}</span>
</div>
<div class="tree">
<div class="tree-root">{{ store.activeScreen?.name }}</div>
<div class="tree-list">
<div v-if="!store.activeScreen?.widgets.length" class="tree-empty">Keine Widgets</div>
<div
v-else
v-for="w in store.activeScreen.widgets"
:key="w.id"
class="tree-item"
:class="{ active: store.selectedWidgetId === w.id, hidden: !w.visible }"
@click="store.selectedWidgetId = w.id"
>
<span class="tree-tag">{{ TYPE_LABELS[typeKeyFor(w.type)] }}</span>
<span class="tree-name">{{ w.text || TYPE_LABELS[typeKeyFor(w.type)] }}</span>
<span class="tree-id">#{{ w.id }}</span>
</div>
</div>
</div>
</section>
<section class="panel">
<div class="panel-header">
<h3>Canvas</h3>
</div>
<div class="control-row">
<label>Hintergrund</label>
<input type="color" v-model="store.activeScreen.bgColor" v-if="store.activeScreen">
</div>
<div class="control-row">
<label>Zoom</label>
<input type="range" min="0.3" max="1" step="0.05" v-model.number="store.canvasScale">
</div>
<div class="control-meta">
<span>{{ Math.round(store.canvasScale * 100) }}%</span>
<span>1280x800</span>
</div>
<label class="toggle">
<input type="checkbox" v-model="store.showGrid">
<span>Grid anzeigen</span>
</label>
</section>
<section class="panel">
<div class="panel-header">
<h3>Navigation</h3>
</div>
<div class="control-row">
<label>Startscreen</label>
<select v-model.number="store.config.startScreen">
<option v-for="s in store.config.screens" :key="s.id" :value="s.id">{{ s.name }}</option>
</select>
</div>
<label class="toggle">
<input type="checkbox" v-model="store.config.standby.enabled">
<span>Standby aktiv</span>
</label>
<div class="control-row" style="margin-top:8px;">
<label>Standby</label>
<select v-model.number="store.config.standby.screen">
<option :value="-1">-- Kein --</option>
<option v-for="s in store.config.screens" :key="s.id" :value="s.id">{{ s.name }}</option>
</select>
</div>
<div class="control-row">
<label>Minuten</label>
<input type="number" min="0" v-model.number="store.config.standby.minutes">
</div>
</section>
</aside>
</template>
<script setup>
import { useEditorStore } from '../stores/editor';
import { typeKeyFor } from '../utils';
import { TYPE_LABELS } from '../constants';
const store = useEditorStore();
</script>

View File

@ -0,0 +1,152 @@
<template>
<aside class="sidebar right">
<section class="panel properties" id="properties">
<div v-if="!w" class="no-selection">
Kein Widget ausgewaehlt.<br><br>
Waehle ein Widget im Canvas oder im Baum.
</div>
<div v-else>
<!-- Layout -->
<h4>Layout</h4>
<div class="prop-row"><label>X</label><input type="number" v-model.number="w.x"></div>
<div class="prop-row"><label>Y</label><input type="number" v-model.number="w.y"></div>
<div class="prop-row"><label>Breite</label><input type="number" v-model.number="w.w"></div>
<div class="prop-row"><label>Hoehe</label><input type="number" v-model.number="w.h"></div>
<div class="prop-row"><label>Sichtbar</label><input type="checkbox" v-model="w.visible"></div>
<!-- Content -->
<template v-if="key === 'label'">
<h4>Inhalt</h4>
<div class="prop-row"><label>Quelle</label>
<select v-model.number="w.textSrc" @change="handleTextSrcChange">
<option v-for="opt in sourceOptions.label" :key="opt" :value="opt">{{ textSources[opt] }}</option>
</select>
</div>
<div v-if="w.textSrc === 0" class="prop-row">
<label>Text</label><input type="text" v-model="w.text">
</div>
<template v-else>
<div class="prop-row"><label>Format</label><input type="text" v-model="w.text"></div>
<div class="prop-row"><label>KNX Objekt</label>
<select v-model.number="w.knxAddr">
<option :value="0">-- Waehlen --</option>
<option v-for="addr in store.knxAddresses" :key="addr.index" :value="addr.index">
GO{{ addr.index }} ({{ addr.addrStr }})
</option>
</select>
</div>
</template>
</template>
<template v-if="key === 'button'">
<h4>Text</h4>
<div class="prop-row"><label>Text</label><input type="text" v-model="w.text"></div>
</template>
<template v-if="key === 'led'">
<h4>LED</h4>
<div class="prop-row"><label>Modus</label>
<select v-model.number="w.textSrc">
<option v-for="opt in sourceOptions.led" :key="opt" :value="opt">{{ textSources[opt] }}</option>
</select>
</div>
<div v-if="w.textSrc === 2" class="prop-row">
<label>KNX Objekt</label>
<select v-model.number="w.knxAddr">
<option :value="0">-- Waehlen --</option>
<option v-for="addr in store.knxAddresses" :key="addr.index" :value="addr.index">
GO{{ addr.index }} ({{ addr.addrStr }})
</option>
</select>
</div>
</template>
<!-- Typography -->
<template v-if="key === 'label' || key === 'button'">
<h4>Typo</h4>
<div class="prop-row"><label>Schriftgr.</label>
<select v-model.number="w.fontSize">
<option v-for="(size, idx) in fontSizes" :key="idx" :value="idx">{{ size }}</option>
</select>
</div>
</template>
<!-- Style -->
<template v-if="key === 'led'">
<h4>Stil</h4>
<div class="prop-row"><label>Farbe</label><input type="color" v-model="w.bgColor"></div>
<div class="prop-row"><label>Helligkeit</label><input type="number" min="0" max="255" v-model.number="w.bgOpacity"></div>
</template>
<template v-else>
<h4>Stil</h4>
<div class="prop-row"><label>Textfarbe</label><input type="color" v-model="w.textColor"></div>
<div class="prop-row"><label>Hintergrund</label><input type="color" v-model="w.bgColor"></div>
<div class="prop-row"><label>Deckkraft</label><input type="number" min="0" max="255" v-model.number="w.bgOpacity"></div>
<div class="prop-row"><label>Radius</label><input type="number" v-model.number="w.radius"></div>
</template>
<!-- Shadow/Glow -->
<h4>{{ key === 'led' ? 'Glow' : 'Schatten' }}</h4>
<div class="prop-row"><label>Aktiv</label><input type="checkbox" v-model="w.shadow.enabled"></div>
<div class="prop-row" v-if="key !== 'led'"><label>X</label><input type="number" v-model.number="w.shadow.x"></div>
<div class="prop-row" v-if="key !== 'led'"><label>Y</label><input type="number" v-model.number="w.shadow.y"></div>
<div class="prop-row"><label>Blur</label><input type="number" v-model.number="w.shadow.blur"></div>
<div class="prop-row"><label>Farbe</label><input type="color" v-model="w.shadow.color"></div>
<!-- Button Actions -->
<template v-if="key === 'button'">
<h4>Aktion</h4>
<div class="prop-row"><label>Typ</label>
<select v-model.number="w.action">
<option :value="BUTTON_ACTIONS.KNX">KNX</option>
<option :value="BUTTON_ACTIONS.JUMP">Sprung</option>
<option :value="BUTTON_ACTIONS.BACK">Zurueck</option>
</select>
</div>
<div v-if="w.action === BUTTON_ACTIONS.JUMP" class="prop-row">
<label>Ziel</label>
<select v-model.number="w.targetScreen">
<option v-for="s in store.config.screens" :key="s.id" :value="s.id">{{ s.name }}</option>
</select>
</div>
<template v-if="w.action === BUTTON_ACTIONS.KNX">
<div class="prop-row"><label>Toggle</label><input type="checkbox" v-model="w.isToggle"></div>
<div class="prop-row"><label>KNX Schreib</label>
<select v-model.number="w.knxAddrWrite">
<option :value="0">-- Keine --</option>
<option v-for="addr in writeableAddresses" :key="addr.index" :value="addr.index">
GO{{ addr.index }} ({{ addr.addrStr }})
</option>
</select>
</div>
</template>
</template>
<div class="prop-actions">
<button class="btn ghost danger" @click="store.deleteWidget">Widget loeschen</button>
</div>
</div>
</section>
</aside>
</template>
<script setup>
import { computed } from 'vue';
import { useEditorStore } from '../stores/editor';
import { typeKeyFor } from '../utils';
import { sourceOptions, textSources, fontSizes, BUTTON_ACTIONS, defaultFormats, WIDGET_TYPES } from '../constants';
const store = useEditorStore();
const w = computed(() => store.selectedWidget);
const key = computed(() => w.value ? typeKeyFor(w.value.type) : 'label');
const writeableAddresses = computed(() => store.knxAddresses.filter(a => a.write));
function handleTextSrcChange() {
if (!w.value) return;
const newSrc = w.value.textSrc;
if (w.value.type === WIDGET_TYPES.LABEL && newSrc > 0 && defaultFormats[newSrc]) {
w.value.text = defaultFormats[newSrc];
}
}
</script>

View File

@ -0,0 +1,56 @@
<template>
<header class="topbar">
<div class="brand">
<div class="brand-mark">LV</div>
<div class="brand-text">
<div class="title">GUI Designer</div>
<div class="subtitle">KNX Display</div>
</div>
</div>
<div class="top-actions">
<button class="btn ghost" @click="enableUsbMode">USB-Modus</button>
<button class="btn ghost danger" @click="handleReset">Zuruecksetzen</button>
<button class="btn primary" @click="handleSave">Speichern & Anwenden</button>
</div>
</header>
</template>
<script setup>
import { useEditorStore } from '../stores/editor';
const store = useEditorStore();
async function handleSave() {
try {
await store.saveConfig();
// alert('Gespeichert!');
// Ideally use a toast/status indicator like in the original, but alert is fine for now
} catch (e) {
alert('Fehler beim Speichern');
}
}
async function handleReset() {
if (!confirm('Wirklich auf Standardeinstellungen zuruecksetzen?')) return;
try {
await store.resetConfig();
} catch (e) {
alert('Fehler');
}
}
async function enableUsbMode() {
if (!confirm('USB-Modus aktivieren?\n\nDie SD-Karte wird als USB-Laufwerk verfuegbar.\nZum Beenden: Geraet neu starten.')) return;
try {
const resp = await fetch('/api/usb-mode', { method: 'POST' });
const data = await resp.json();
if (data.status === 'ok') {
alert('USB Mass Storage aktiviert!\n\nVerbinde das USB-Kabel mit dem PC.\nDie SD-Karte erscheint als Wechseldatentraeger.\n\nZum Beenden: Geraet neu starten.');
} else {
alert('USB-Modus fehlgeschlagen');
}
} catch (e) {
alert('Fehler beim Aktivieren');
}
}
</script>

View File

@ -0,0 +1,106 @@
<template>
<div
class="widget"
:class="{
selected: selected,
'widget-label': isLabel,
'widget-button': isButton,
'widget-led': isLed
}"
:style="computedStyle"
@mousedown="$emit('drag-start', $event)"
@touchstart="$emit('drag-start', $event)"
@click.stop="$emit('select')"
>
<div
v-if="selected"
class="resize-handle"
@mousedown.stop="$emit('resize-start', $event)"
@touchstart.stop="$emit('resize-start', $event)"
></div>
{{ widget.text }}
</div>
</template>
<script setup>
import { computed } from 'vue';
import { WIDGET_TYPES, fontSizes } from '../constants';
import { clamp, hexToRgba } from '../utils';
const props = defineProps({
widget: { type: Object, required: true },
scale: { type: Number, default: 1 },
selected: { type: Boolean, default: false }
});
defineEmits(['select', 'drag-start', 'resize-start']);
const isLabel = computed(() => props.widget.type === WIDGET_TYPES.LABEL);
const isButton = computed(() => props.widget.type === WIDGET_TYPES.BUTTON);
const isLed = computed(() => props.widget.type === WIDGET_TYPES.LED);
const computedStyle = computed(() => {
const w = props.widget;
const s = props.scale;
const style = {
left: `${w.x * s}px`,
top: `${w.y * s}px`,
width: `${w.w * s}px`,
height: `${w.h * s}px`,
fontSize: `${(fontSizes[w.fontSize] || 14) * s}px`,
color: w.textColor,
position: 'absolute',
zIndex: 1,
cursor: 'move',
userSelect: 'none',
touchAction: 'none'
};
if (isLabel.value) {
if (w.bgOpacity > 0) {
const alpha = clamp(w.bgOpacity / 255, 0, 1).toFixed(2);
style.background = hexToRgba(w.bgColor, alpha);
}
} else if (isButton.value) {
style.background = w.bgColor;
style.borderRadius = `${w.radius * s}px`;
style.display = 'flex';
style.alignItems = 'center';
style.justifyContent = 'center';
style.fontWeight = '600';
if (w.shadow && w.shadow.enabled) {
const sx = (w.shadow.x || 0) * s;
const sy = (w.shadow.y || 0) * s;
const blur = (w.shadow.blur || 0) * s;
const spread = (w.shadow.spread || 0) * s;
style.boxShadow = `${sx}px ${sy}px ${blur}px ${spread}px ${w.shadow.color}`;
}
} else if (isLed.value) {
style.borderRadius = '999px';
const brightness = clamp((w.bgOpacity ?? 255) / 255, 0, 1);
const glowColor = (w.shadow && w.shadow.color) ? w.shadow.color : w.bgColor;
const highlight = clamp(brightness + 0.25, 0, 1);
const core = clamp(brightness, 0, 1);
const edge = clamp(brightness * 0.5, 0, 1);
style.background = `radial-gradient(circle at 30% 30%, ${hexToRgba(w.bgColor, highlight)} 0%, ${hexToRgba(w.bgColor, core)} 45%, ${hexToRgba(w.bgColor, edge)} 70%, rgba(0,0,0,0.4) 100%)`;
if (w.shadow && w.shadow.enabled) {
const sx = (w.shadow.x || 0) * s;
const sy = (w.shadow.y || 0) * s;
const blur = (w.shadow.blur || 0) * s;
const spread = (w.shadow.spread || 0) * s;
const glowAlpha = clamp(0.4 + brightness * 0.6, 0, 1);
style.boxShadow = `${sx}px ${sy}px ${blur}px ${spread}px ${hexToRgba(glowColor, glowAlpha)}`;
} else {
style.boxShadow = 'inset 0 0 0 1px rgba(255,255,255,0.12)';
}
}
return style;
});
</script>

View File

@ -0,0 +1,104 @@
export const DISPLAY_W = 1280;
export const DISPLAY_H = 800;
export const MAX_SCREENS = 8;
export const WIDGET_TYPES = {
LABEL: 0,
BUTTON: 1,
LED: 2
};
export const BUTTON_ACTIONS = {
KNX: 0,
JUMP: 1,
BACK: 2
};
export const TYPE_KEYS = {
0: 'label',
1: 'button',
2: 'led'
};
export const TYPE_LABELS = {
label: 'Label',
button: 'Button',
led: 'LED'
};
export const textSources = {
0: 'Statisch',
1: 'KNX Temperatur',
2: 'KNX Schalter',
3: 'KNX Prozent',
4: 'KNX Text'
};
export const sourceOptions = {
label: [0, 1, 2, 3, 4],
button: [0],
led: [0, 2]
};
export const fontSizes = [14, 18, 22, 28, 36, 48];
export const defaultFormats = {
1: '%.1f °C',
2: '%s',
3: '%d %%',
4: '%s'
};
export const WIDGET_DEFAULTS = {
label: {
w: 160,
h: 40,
text: 'Neues Label',
textSrc: 0,
fontSize: 1,
textColor: '#FFFFFF',
bgColor: '#0E1217',
bgOpacity: 0,
radius: 6,
shadow: { enabled: false, x: 2, y: 2, blur: 8, spread: 0, color: '#000000' },
isToggle: false,
knxAddrWrite: 0,
knxAddr: 0,
action: BUTTON_ACTIONS.KNX,
targetScreen: 0
},
button: {
w: 130,
h: 52,
text: 'Button',
textSrc: 0,
fontSize: 1,
textColor: '#FFFFFF',
bgColor: '#2E7DD1',
bgOpacity: 255,
radius: 10,
shadow: { enabled: true, x: 2, y: 3, blur: 10, spread: 0, color: '#000000' },
isToggle: false,
knxAddrWrite: 0,
knxAddr: 0,
action: BUTTON_ACTIONS.KNX,
targetScreen: 0
},
led: {
w: 60,
h: 60,
text: '',
textSrc: 0,
fontSize: 1,
textColor: '#FFFFFF',
bgColor: '#F6C177',
bgOpacity: 255,
radius: 30,
shadow: { enabled: true, x: 0, y: 0, blur: 18, spread: 0, color: '#F6C177' },
isToggle: false,
knxAddrWrite: 0,
knxAddr: 0,
action: BUTTON_ACTIONS.KNX,
targetScreen: 0
}
};

10
web-interface/src/main.js Normal file
View File

@ -0,0 +1,10 @@
import { createApp } from 'vue'
import { createPinia } from 'pinia'
import './style.css'
import App from './App.vue'
const pinia = createPinia()
const app = createApp(App)
app.use(pinia)
app.mount('#app')

View File

@ -0,0 +1,215 @@
import { defineStore } from 'pinia';
import { ref, computed, reactive } from 'vue';
import { normalizeScreen, normalizeWidget } from '../utils';
import { MAX_SCREENS, WIDGET_DEFAULTS, WIDGET_TYPES } from '../constants';
export const useEditorStore = defineStore('editor', () => {
const config = reactive({
startScreen: 0,
standby: { enabled: false, screen: -1, minutes: 5 },
screens: []
});
const knxAddresses = ref([]);
const selectedWidgetId = ref(null);
const activeScreenId = ref(0);
const canvasScale = ref(0.6);
const showGrid = ref(true);
const nextScreenId = ref(0);
const nextWidgetId = ref(0);
const activeScreen = computed(() => {
return config.screens.find(s => s.id === activeScreenId.value) || config.screens[0];
});
const selectedWidget = computed(() => {
if (!activeScreen.value || selectedWidgetId.value === null) return null;
return activeScreen.value.widgets.find(w => w.id === selectedWidgetId.value);
});
async function loadKnxAddresses() {
try {
// Mock or Real API
const resp = await fetch('/api/knx/addresses');
if (resp.ok) {
knxAddresses.value = await resp.json();
} else {
knxAddresses.value = [];
}
} catch (e) {
console.error(e);
knxAddresses.value = [];
}
}
async function loadConfig() {
try {
const resp = await fetch('/api/config');
let data = {};
if (resp.ok) {
data = await resp.json();
} else {
// Fallback for dev without API
console.warn("API not available, loading defaults");
data = { screens: [] };
}
if (Array.isArray(data.screens)) {
// reset reactive object properties
Object.assign(config, data);
} else {
// Default init
config.screens = [
{
id: 0,
name: 'Screen 1',
mode: 0,
bgColor: data.bgColor || '#1A1A2E',
widgets: data.widgets || []
}
];
config.startScreen = 0;
config.standby = { enabled: false, screen: -1, minutes: 5 };
}
if (!config.standby) {
config.standby = { enabled: false, screen: -1, minutes: 5 };
}
// Recalculate IDs
nextWidgetId.value = 0;
nextScreenId.value = 0;
config.screens.forEach((screen) => {
if (typeof screen.id === 'number') {
nextScreenId.value = Math.max(nextScreenId.value, screen.id + 1);
}
});
config.screens.forEach((screen) => {
normalizeScreen(screen, nextScreenId, nextWidgetId);
// Also update max widget id
screen.widgets.forEach(w => {
nextWidgetId.value = Math.max(nextWidgetId.value, w.id + 1);
});
});
// Validate start/standby screens
if (config.standby.screen >= 255) config.standby.screen = -1;
const startExists = config.screens.find(s => s.id === config.startScreen);
if (!startExists && config.screens.length) {
config.startScreen = config.screens[0].id;
}
activeScreenId.value = config.screens.find(s => s.id === config.startScreen)?.id ?? 0;
} catch (e) {
console.error('Load config failed', e);
}
}
async function saveConfig() {
await fetch('/api/config', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(config)
});
await fetch('/api/save', { method: 'POST' });
}
async function resetConfig() {
await fetch('/api/reset', { method: 'POST' });
await loadConfig();
}
function addScreen() {
if (config.screens.length >= MAX_SCREENS) return;
const id = nextScreenId.value++;
const newScreen = {
id,
name: `Screen ${id}`,
mode: 0,
bgColor: '#1A1A2E',
widgets: []
};
config.screens.push(newScreen);
activeScreenId.value = id;
selectedWidgetId.value = null;
}
function deleteScreen() {
if (config.screens.length <= 1) return;
config.screens = config.screens.filter(s => s.id !== activeScreenId.value);
if (config.startScreen === activeScreenId.value) {
config.startScreen = config.screens[0].id;
}
if (config.standby.screen === activeScreenId.value) {
config.standby.screen = -1;
config.standby.enabled = false;
}
activeScreenId.value = config.screens[0].id;
selectedWidgetId.value = null;
}
function addWidget(typeStr) {
if (!activeScreen.value) return;
const typeValue = typeStr === 'label' ? WIDGET_TYPES.LABEL : (typeStr === 'button' ? WIDGET_TYPES.BUTTON : WIDGET_TYPES.LED);
const defaults = WIDGET_DEFAULTS[typeStr];
const w = {
id: nextWidgetId.value++,
type: typeValue,
x: 120,
y: 120,
w: defaults.w,
h: defaults.h,
visible: true,
textSrc: defaults.textSrc,
text: defaults.text,
knxAddr: defaults.knxAddr,
fontSize: defaults.fontSize,
textColor: defaults.textColor,
bgColor: defaults.bgColor,
bgOpacity: defaults.bgOpacity,
radius: defaults.radius,
shadow: { ...defaults.shadow },
isToggle: defaults.isToggle,
knxAddrWrite: defaults.knxAddrWrite,
action: defaults.action,
targetScreen: defaults.targetScreen
};
activeScreen.value.widgets.push(w);
selectedWidgetId.value = w.id;
}
function deleteWidget() {
if (!activeScreen.value || selectedWidgetId.value === null) return;
activeScreen.value.widgets = activeScreen.value.widgets.filter(w => w.id !== selectedWidgetId.value);
selectedWidgetId.value = null;
}
return {
config,
knxAddresses,
selectedWidgetId,
activeScreenId,
canvasScale,
showGrid,
activeScreen,
selectedWidget,
loadKnxAddresses,
loadConfig,
saveConfig,
resetConfig,
addScreen,
deleteScreen,
addWidget,
deleteWidget
};
});

582
web-interface/src/style.css Normal file
View File

@ -0,0 +1,582 @@
@import "tailwindcss";
:root {
--bg: #0f1419;
--bg-2: #141c24;
--panel: #18212b;
--panel-2: #1d2732;
--border: #2a3543;
--text: #e7edf3;
--muted: #9aa8b4;
--accent: #f6c177;
--accent-2: #7dd3b0;
--danger: #ff6b6b;
--canvas-bg: #1a1a2e;
--shadow: rgba(0, 0, 0, 0.35);
}
body {
font-family: "Space Grotesk", "Fira Sans", "Segoe UI", sans-serif;
background:
radial-gradient(1200px 800px at 15% 10%, #1b2530 0%, #0f1419 55%),
linear-gradient(135deg, #0f1419, #141c24);
color: var(--text);
min-height: 100vh;
/* display: flex; flex-direction: column; -- Handled by Vue App layout */
}
/* Custom scrollbar */
::-webkit-scrollbar {
width: 8px;
height: 8px;
}
::-webkit-scrollbar-track {
background: var(--bg);
}
::-webkit-scrollbar-thumb {
background: var(--border);
border-radius: 4px;
}
::-webkit-scrollbar-thumb:hover {
background: var(--muted);
}
.topbar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
padding: 14px 20px;
background: rgba(15, 20, 25, 0.85);
border-bottom: 1px solid var(--border);
backdrop-filter: blur(8px);
}
.brand {
display: flex;
align-items: center;
gap: 12px;
}
.brand-mark {
width: 36px;
height: 36px;
border-radius: 10px;
background: linear-gradient(135deg, var(--accent), #f0b365);
color: #1b1308;
display: grid;
place-items: center;
font-weight: 700;
letter-spacing: 0.04em;
box-shadow: 0 6px 16px rgba(246, 193, 119, 0.35);
}
.brand-text .title {
font-size: 18px;
font-weight: 600;
}
.brand-text .subtitle {
font-size: 12px;
color: var(--muted);
}
.top-actions {
display: flex;
align-items: center;
gap: 10px;
flex-wrap: wrap;
justify-content: flex-end;
}
.btn {
border: 1px solid transparent;
background: var(--panel-2);
color: var(--text);
padding: 8px 14px;
border-radius: 10px;
font-size: 13px;
font-weight: 600;
cursor: pointer;
transition: transform 0.15s ease, border-color 0.2s ease, background 0.2s ease;
}
.btn.small {
padding: 6px 10px;
font-size: 12px;
}
.btn.primary {
background: linear-gradient(135deg, var(--accent), #f0b365);
color: #1b1308;
box-shadow: 0 8px 18px rgba(246, 193, 119, 0.35);
}
.btn.ghost {
background: transparent;
border-color: var(--border);
}
.btn.danger {
border-color: rgba(255, 107, 107, 0.4);
color: #ffd1d1;
}
.btn:hover { transform: translateY(-1px); }
.btn:active { transform: translateY(1px); }
.icon-btn {
width: 28px;
height: 28px;
border-radius: 8px;
border: 1px solid var(--border);
background: var(--panel-2);
color: var(--text);
display: grid;
place-items: center;
cursor: pointer;
}
.workspace {
flex: 1;
min-height: 0;
display: grid;
grid-template-columns: 300px 1fr 320px;
}
.sidebar {
padding: 18px;
display: flex;
flex-direction: column;
gap: 16px;
overflow-y: auto;
height: calc(100vh - 66px); /* Adjust based on topbar height */
}
.sidebar.left {
border-right: 1px solid var(--border);
}
.sidebar.right {
border-left: 1px solid var(--border);
}
.panel {
background: var(--panel);
border: 1px solid var(--border);
border-radius: 14px;
padding: 14px;
box-shadow: 0 8px 18px var(--shadow);
}
.panel-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
margin-bottom: 12px;
}
.panel-header h3 {
font-size: 12px;
letter-spacing: 0.08em;
text-transform: uppercase;
color: var(--muted);
}
.panel-hint {
font-size: 11px;
color: var(--muted);
}
.element-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 10px;
}
.element-btn {
background: var(--panel-2);
border: 1px solid var(--border);
border-radius: 12px;
padding: 10px;
text-align: left;
cursor: pointer;
transition: transform 0.15s ease, border-color 0.2s ease;
}
.element-btn:hover {
border-color: var(--accent);
transform: translateY(-1px);
}
.element-title {
font-size: 13px;
font-weight: 600;
}
.element-sub {
font-size: 11px;
color: var(--muted);
margin-top: 2px;
display: block;
}
.screen-list {
display: flex;
flex-direction: column;
gap: 8px;
}
.screen-item {
background: var(--panel-2);
border: 1px solid var(--border);
border-radius: 10px;
padding: 8px 10px;
text-align: left;
color: var(--text);
font-size: 12px;
display: flex;
align-items: center;
gap: 8px;
cursor: pointer;
}
.screen-item.active {
border-color: var(--accent-2);
box-shadow: 0 0 0 2px rgba(125, 211, 176, 0.2);
}
.screen-tag {
font-size: 10px;
padding: 2px 6px;
border-radius: 999px;
background: rgba(125, 211, 176, 0.15);
color: var(--accent-2);
text-transform: uppercase;
letter-spacing: 0.06em;
}
.screen-name {
flex: 1;
font-size: 12px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.screen-settings {
margin-top: 12px;
display: flex;
flex-direction: column;
gap: 8px;
}
.tree {
display: flex;
flex-direction: column;
gap: 8px;
}
.tree-root {
font-size: 12px;
color: var(--muted);
margin-bottom: 4px;
}
.tree-list {
display: flex;
flex-direction: column;
gap: 6px;
}
.tree-item {
display: flex;
align-items: center;
gap: 8px;
background: var(--panel-2);
border: 1px solid var(--border);
border-radius: 10px;
padding: 8px 10px;
color: var(--text);
cursor: pointer;
transition: border-color 0.2s ease;
}
.tree-item.active {
border-color: var(--accent-2);
box-shadow: 0 0 0 2px rgba(125, 211, 176, 0.2);
}
.tree-item.hidden {
opacity: 0.5;
}
.tree-tag {
font-size: 10px;
padding: 2px 6px;
border-radius: 999px;
background: rgba(125, 211, 176, 0.15);
color: var(--accent-2);
text-transform: uppercase;
letter-spacing: 0.06em;
}
.tree-name {
flex: 1;
font-size: 12px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.tree-id {
font-size: 11px;
color: var(--muted);
}
.tree-empty {
font-size: 12px;
color: var(--muted);
padding: 6px 0;
}
.control-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
margin-bottom: 10px;
}
.control-row label {
font-size: 12px;
color: var(--muted);
}
.control-row input[type="color"] {
width: 44px;
height: 28px;
border: none;
background: transparent;
cursor: pointer;
}
.control-row input[type="range"] {
flex: 1;
}
.control-meta {
display: flex;
justify-content: space-between;
font-size: 11px;
color: var(--muted);
margin-bottom: 8px;
}
.toggle {
display: flex;
align-items: center;
gap: 8px;
font-size: 12px;
color: var(--muted);
}
.canvas-area {
padding: 20px;
overflow: auto;
display: flex;
align-items: center;
justify-content: center;
}
.canvas-shell {
padding: 18px;
border-radius: 18px;
border: 1px solid var(--border);
background: rgba(20, 28, 36, 0.7);
box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.02), 0 20px 40px rgba(0, 0, 0, 0.4);
}
.canvas {
position: relative;
background: var(--canvas-bg);
border-radius: 16px;
border: 1px solid rgba(255, 255, 255, 0.08);
overflow: hidden;
}
.canvas::before {
content: "";
position: absolute;
inset: 0;
background-image:
linear-gradient(to right, rgba(255, 255, 255, 0.06) 1px, transparent 1px),
linear-gradient(to bottom, rgba(255, 255, 255, 0.06) 1px, transparent 1px);
background-size: 32px 32px;
opacity: 0.3;
pointer-events: none;
z-index: 0;
}
.canvas.grid-off::before {
opacity: 0;
}
.widget {
/* Absolute positioning handled by component inline styles */
position: absolute;
cursor: move;
user-select: none;
touch-action: none;
z-index: 1;
}
.widget.selected {
outline: 2px solid var(--accent);
outline-offset: 2px;
}
.resize-handle {
position: absolute;
right: -6px;
bottom: -6px;
width: 14px;
height: 14px;
border-radius: 4px;
background: var(--accent);
border: 2px solid #1b1308;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.35);
cursor: se-resize;
z-index: 2;
}
.resize-handle::before {
content: "";
position: absolute;
right: 2px;
bottom: 2px;
width: 6px;
height: 6px;
border-right: 2px solid rgba(27, 19, 8, 0.65);
border-bottom: 2px solid rgba(27, 19, 8, 0.65);
border-radius: 2px;
}
.widget-label {
padding: 4px 6px;
border-radius: 6px;
display: flex;
align-items: center;
overflow: hidden;
white-space: nowrap;
}
.widget-button {
display: flex;
align-items: center;
justify-content: center;
font-weight: 600;
}
.widget-led {
border-radius: 999px;
}
.properties h4 {
font-size: 12px;
letter-spacing: 0.08em;
text-transform: uppercase;
color: var(--muted);
margin: 16px 0 10px;
}
.prop-row {
display: flex;
align-items: center;
gap: 10px;
margin-bottom: 8px;
}
.prop-row label {
width: 90px;
font-size: 12px;
color: var(--muted);
}
.prop-row input,
.prop-row select {
flex: 1;
background: var(--panel-2);
border: 1px solid var(--border);
border-radius: 8px;
padding: 6px 8px;
color: var(--text);
font-size: 12px;
}
.prop-row input[type="color"] {
padding: 2px;
height: 30px;
cursor: pointer;
}
.prop-row input[type="checkbox"] {
width: auto;
flex: none;
}
.prop-actions {
margin-top: 16px;
}
.no-selection {
color: var(--muted);
text-align: center;
padding: 20px 10px;
font-size: 13px;
}
.status {
position: fixed;
bottom: 16px;
left: 50%;
transform: translateX(-50%);
background: var(--accent);
color: #1b1308;
padding: 10px 18px;
border-radius: 10px;
font-weight: 600;
opacity: 0;
transition: opacity 0.3s;
z-index: 1000;
}
.status.show { opacity: 1; }
.status.error {
background: var(--danger);
color: #1b0f0f;
}
@media (max-width: 1100px) {
.workspace {
grid-template-columns: 1fr;
grid-template-rows: auto auto auto;
}
.sidebar.left {
order: 1;
border-right: none;
border-bottom: 1px solid var(--border);
}
.canvas-area {
order: 2;
}
.sidebar.right {
order: 3;
border-left: none;
border-top: 1px solid var(--border);
}
}

View File

@ -0,0 +1,80 @@
import { TYPE_KEYS, WIDGET_DEFAULTS } from './constants';
export function typeKeyFor(type) {
return TYPE_KEYS[type] || 'label';
}
export function clamp(value, min, max) {
if (Number.isNaN(value)) return min;
return Math.max(min, Math.min(max, value));
}
export function minSizeFor(widget) {
const key = typeKeyFor(widget.type);
if (key === 'button') return { w: 60, h: 30 };
if (key === 'led') return { w: 20, h: 20 };
return { w: 40, h: 20 };
}
export function hexToRgba(hex, alpha) {
const r = parseInt(hex.slice(1, 3), 16);
const g = parseInt(hex.slice(3, 5), 16);
const b = parseInt(hex.slice(5, 7), 16);
return `rgba(${r},${g},${b},${alpha})`;
}
export function normalizeWidget(w, nextWidgetIdRef) {
const key = typeKeyFor(w.type);
const defaults = WIDGET_DEFAULTS[key];
Object.keys(defaults).forEach((prop) => {
if (prop === 'shadow') return;
if (w[prop] === undefined || w[prop] === null) {
w[prop] = defaults[prop];
}
});
if (!w.shadow) {
w.shadow = { ...defaults.shadow };
} else {
Object.keys(defaults.shadow).forEach((prop) => {
if (w.shadow[prop] === undefined || w.shadow[prop] === null) {
w.shadow[prop] = defaults.shadow[prop];
}
});
}
if (w.visible === undefined || w.visible === null) w.visible = true;
if (w.x === undefined || w.x === null) w.x = 100;
if (w.y === undefined || w.y === null) w.y = 100;
if (w.id === undefined || w.id === null) {
if (nextWidgetIdRef) w.id = nextWidgetIdRef.value++;
}
// Action defaults
if (w.action === undefined) w.action = 0; // BUTTON_ACTIONS.KNX
if (w.targetScreen === undefined) w.targetScreen = 0;
}
export function normalizeScreen(screen, nextScreenIdRef, nextWidgetIdRef) {
if (screen.id === undefined || screen.id === null) {
if (nextScreenIdRef) screen.id = nextScreenIdRef.value++;
}
if (!screen.name) screen.name = `Screen ${screen.id}`;
if (screen.mode === undefined || screen.mode === null) screen.mode = 0;
if (!screen.bgColor) screen.bgColor = '#1A1A2E';
if (!Array.isArray(screen.widgets)) screen.widgets = [];
// Modal defaults
if (!screen.modal) {
screen.modal = { x: 0, y: 0, w: 0, h: 0, radius: 12, dim: true };
} else {
if (screen.modal.x === undefined) screen.modal.x = 0;
if (screen.modal.y === undefined) screen.modal.y = 0;
if (screen.modal.w === undefined) screen.modal.w = 0;
if (screen.modal.h === undefined) screen.modal.h = 0;
if (screen.modal.radius === undefined) screen.modal.radius = 12;
if (screen.modal.dim === undefined) screen.modal.dim = true;
}
screen.widgets.forEach(w => normalizeWidget(w, nextWidgetIdRef));
}

View File

@ -0,0 +1,27 @@
export default {
content: [
"./index.html",
"./src/**/*.{vue,js,ts,jsx,tsx}",
],
theme: {
extend: {
colors: {
'bg': 'var(--bg)',
'bg-2': 'var(--bg-2)',
'panel': 'var(--panel)',
'panel-2': 'var(--panel-2)',
'border': 'var(--border)',
'text': 'var(--text)',
'muted': 'var(--muted)',
'accent': 'var(--accent)',
'accent-2': 'var(--accent-2)',
'danger': 'var(--danger)',
'canvas-bg': 'var(--canvas-bg)',
},
fontFamily: {
sans: ['"Space Grotesk"', '"Fira Sans"', '"Segoe UI"', 'sans-serif'],
}
},
},
plugins: [],
}

View File

@ -0,0 +1,15 @@
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
// https://vite.dev/config/
export default defineConfig({
plugins: [vue()],
server: {
proxy: {
'/api': {
target: 'http://192.168.178.81',
changeOrigin: true,
}
}
}
})