Fixes
This commit is contained in:
parent
872c004b76
commit
c9196fcaf2
24
web-interface/.gitignore
vendored
Normal file
24
web-interface/.gitignore
vendored
Normal 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
3
web-interface/.vscode/extensions.json
vendored
Normal file
@ -0,0 +1,3 @@
|
||||
{
|
||||
"recommendations": ["Vue.volar"]
|
||||
}
|
||||
5
web-interface/README.md
Normal file
5
web-interface/README.md
Normal 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
13
web-interface/index.html
Normal 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
2092
web-interface/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
27
web-interface/package.json
Normal file
27
web-interface/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
6
web-interface/postcss.config.js
Normal file
6
web-interface/postcss.config.js
Normal file
@ -0,0 +1,6 @@
|
||||
export default {
|
||||
plugins: {
|
||||
'@tailwindcss/postcss': {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
}
|
||||
1
web-interface/public/vite.svg
Normal file
1
web-interface/public/vite.svg
Normal 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
24
web-interface/src/App.vue
Normal 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>
|
||||
1
web-interface/src/assets/vue.svg
Normal file
1
web-interface/src/assets/vue.svg
Normal 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 |
165
web-interface/src/components/CanvasArea.vue
Normal file
165
web-interface/src/components/CanvasArea.vue
Normal 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>
|
||||
43
web-interface/src/components/HelloWorld.vue
Normal file
43
web-interface/src/components/HelloWorld.vue
Normal 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>
|
||||
139
web-interface/src/components/SidebarLeft.vue
Normal file
139
web-interface/src/components/SidebarLeft.vue
Normal 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>
|
||||
152
web-interface/src/components/SidebarRight.vue
Normal file
152
web-interface/src/components/SidebarRight.vue
Normal 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>
|
||||
56
web-interface/src/components/TopBar.vue
Normal file
56
web-interface/src/components/TopBar.vue
Normal 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>
|
||||
106
web-interface/src/components/WidgetElement.vue
Normal file
106
web-interface/src/components/WidgetElement.vue
Normal 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>
|
||||
104
web-interface/src/constants.js
Normal file
104
web-interface/src/constants.js
Normal 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
10
web-interface/src/main.js
Normal 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')
|
||||
215
web-interface/src/stores/editor.js
Normal file
215
web-interface/src/stores/editor.js
Normal 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
582
web-interface/src/style.css
Normal 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);
|
||||
}
|
||||
}
|
||||
80
web-interface/src/utils.js
Normal file
80
web-interface/src/utils.js
Normal 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));
|
||||
}
|
||||
27
web-interface/tailwind.config.js
Normal file
27
web-interface/tailwind.config.js
Normal 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: [],
|
||||
}
|
||||
15
web-interface/vite.config.js
Normal file
15
web-interface/vite.config.js
Normal 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,
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
Loading…
Reference in New Issue
Block a user