knxdisplay/sdcard_content/webseite/index.html
2026-01-22 21:32:21 +01:00

636 lines
25 KiB
HTML

<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>GUI Editor - KNX Display</title>
<style>
* { box-sizing: border-box; margin: 0; padding: 0; }
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
background: #1e1e2e;
color: #cdd6f4;
height: 100vh;
display: flex;
flex-direction: column;
overflow: hidden;
}
/* Header */
.header {
background: #181825;
padding: 10px 20px;
display: flex;
align-items: center;
gap: 15px;
border-bottom: 1px solid #313244;
}
.header h1 { font-size: 18px; color: #89b4fa; }
.header-buttons { display: flex; gap: 10px; margin-left: auto; }
/* Main Layout */
.main {
display: flex;
flex: 1;
overflow: hidden;
}
/* Sidebar - Widget Types */
.sidebar {
width: 200px;
background: #181825;
border-right: 1px solid #313244;
padding: 15px;
overflow-y: auto;
}
.sidebar h3 {
font-size: 12px;
text-transform: uppercase;
color: #6c7086;
margin-bottom: 10px;
}
.widget-type {
background: #313244;
padding: 12px;
border-radius: 8px;
margin-bottom: 8px;
cursor: pointer;
transition: all 0.2s;
text-align: center;
}
.widget-type:hover { background: #45475a; }
.widget-type span { display: block; font-size: 12px; color: #a6adc8; }
/* Canvas Area */
.canvas-container {
flex: 1;
padding: 20px;
overflow: auto;
display: flex;
align-items: center;
justify-content: center;
}
.canvas {
position: relative;
background: #1a1a2e;
border: 2px solid #313244;
border-radius: 8px;
overflow: hidden;
}
/* Widgets on Canvas */
.widget {
position: absolute;
cursor: move;
user-select: none;
touch-action: none;
}
.widget.selected {
outline: 2px solid #89b4fa;
outline-offset: 2px;
}
.widget-label {
padding: 4px 6px;
color: white;
display: flex;
align-items: center;
overflow: hidden;
white-space: nowrap;
}
.widget-button {
color: white;
display: flex;
align-items: center;
justify-content: center;
overflow: hidden;
}
/* Properties Panel */
.properties {
width: 280px;
background: #181825;
border-left: 1px solid #313244;
padding: 15px;
overflow-y: auto;
}
.properties h3 {
font-size: 12px;
text-transform: uppercase;
color: #6c7086;
margin: 15px 0 10px;
}
.properties h3:first-child { margin-top: 0; }
.prop-row {
display: flex;
align-items: center;
margin-bottom: 8px;
}
.prop-row label {
width: 80px;
font-size: 12px;
color: #a6adc8;
}
.prop-row input, .prop-row select {
flex: 1;
background: #313244;
border: 1px solid #45475a;
border-radius: 4px;
padding: 6px 8px;
color: #cdd6f4;
font-size: 12px;
}
.prop-row input[type="color"] {
padding: 2px;
height: 30px;
cursor: pointer;
}
.prop-row input[type="checkbox"] {
width: auto;
flex: none;
}
.prop-row input:focus, .prop-row select:focus {
outline: none;
border-color: #89b4fa;
}
/* Buttons */
button {
background: #89b4fa;
color: #1e1e2e;
border: none;
padding: 8px 16px;
border-radius: 6px;
font-size: 13px;
font-weight: 600;
cursor: pointer;
transition: all 0.2s;
}
button:hover { background: #b4befe; }
button.danger { background: #f38ba8; }
button.danger:hover { background: #eba0ac; }
button.secondary {
background: #45475a;
color: #cdd6f4;
}
button.secondary:hover { background: #585b70; }
/* Status */
.status {
position: fixed;
bottom: 20px;
left: 50%;
transform: translateX(-50%);
background: #a6e3a1;
color: #1e1e2e;
padding: 10px 20px;
border-radius: 8px;
font-weight: 600;
opacity: 0;
transition: opacity 0.3s;
z-index: 1000;
}
.status.show { opacity: 1; }
.status.error { background: #f38ba8; }
/* No selection */
.no-selection {
color: #6c7086;
text-align: center;
padding: 20px;
}
</style>
</head>
<body>
<div class="header">
<h1>GUI Editor</h1>
<span style="color:#6c7086">KNX Display Konfiguration</span>
<div class="header-buttons">
<button class="secondary" onclick="resetConfig()">Zuruecksetzen</button>
<button onclick="saveConfig()">Speichern & Anwenden</button>
</div>
</div>
<div class="main">
<div class="sidebar">
<h3>Widgets</h3>
<div class="widget-type" onclick="addWidget('label')">
<strong>Label</strong>
<span>Text / KNX-Wert</span>
</div>
<div class="widget-type" onclick="addWidget('button')">
<strong>Button</strong>
<span>KNX Schalten</span>
</div>
<h3 style="margin-top:20px">Bildschirm</h3>
<div class="prop-row">
<label>Hintergrund</label>
<input type="color" id="bgColor" value="#1a1a2e" onchange="updateBgColor()">
</div>
</div>
<div class="canvas-container">
<div class="canvas" id="canvas" style="width:640px;height:400px">
<!-- Widgets appear here -->
</div>
</div>
<div class="properties" id="properties">
<div class="no-selection">
Kein Widget ausgewaehlt.<br><br>
Klicken Sie auf ein Widget oder fuegen Sie ein neues hinzu.
</div>
</div>
</div>
<div class="status" id="status"></div>
<script>
const SCALE = 0.5; // Canvas scale (1280x800 -> 640x400)
const DISPLAY_W = 1280;
const DISPLAY_H = 800;
let config = { bgColor: '#1A1A2E', widgets: [] };
let selectedWidget = null;
let dragState = null;
let nextId = 0;
let knxAddresses = []; // KNX group objects
const textSources = {
0: 'Statisch',
1: 'KNX Temperatur',
2: 'KNX Schalter',
3: 'KNX Prozent',
4: 'KNX Text'
};
// Font sizes matching LVGL (montserrat fonts)
const fontSizes = [14, 18, 22, 28, 36, 48];
// Default format strings for KNX data types
const defaultFormats = {
0: '', // Static - user defines
1: '%.1f °C', // Temperature
2: '%s', // Switch (EIN/AUS handled in code)
3: '%d %%', // Percent
4: '%s' // Text
};
// Preview values for KNX data types
const previewValues = {
0: null, // Static - use actual text
1: '21.5 °C', // Temperature
2: 'EIN', // Switch
3: '75 %', // Percent
4: 'Text' // Text
};
// Get preview text for widget
function getPreviewText(w) {
if (w.textSrc === 0) return w.text; // Static
return previewValues[w.textSrc] || w.text;
}
// Convert hex color to rgba
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})`;
}
async function loadKnxAddresses() {
try {
const resp = await fetch('/api/knx/addresses');
knxAddresses = await resp.json();
console.log('KNX addresses loaded:', knxAddresses.length);
} catch (e) {
console.error('Failed to load KNX addresses', e);
knxAddresses = [];
}
}
async function loadConfig() {
try {
await loadKnxAddresses(); // Load KNX addresses first
const resp = await fetch('/api/config');
config = await resp.json();
nextId = Math.max(0, ...config.widgets.map(w => w.id)) + 1;
document.getElementById('bgColor').value = config.bgColor;
renderCanvas();
} catch (e) {
showStatus('Fehler beim Laden', true);
}
}
function renderCanvas() {
const canvas = document.getElementById('canvas');
canvas.style.background = config.bgColor;
canvas.innerHTML = '';
config.widgets.forEach(w => {
if (!w.visible) return;
const el = document.createElement('div');
el.className = 'widget' + (selectedWidget === w.id ? ' selected' : '');
el.dataset.id = w.id;
el.style.left = (w.x * SCALE) + 'px';
el.style.top = (w.y * SCALE) + 'px';
el.style.width = (w.w * SCALE) + 'px';
el.style.height = (w.h * SCALE) + 'px';
// Apply scaled font size and color
const fontSize = fontSizes[w.fontSize] || 14;
el.style.fontSize = (fontSize * SCALE) + 'px';
el.style.color = w.textColor;
// Get display text (preview for KNX values)
const displayText = getPreviewText(w);
if (w.type === 0) { // Label
el.className += ' widget-label';
if (w.bgOpacity > 0) {
const alpha = (w.bgOpacity / 255).toFixed(2);
el.style.background = hexToRgba(w.bgColor, alpha);
}
el.textContent = displayText;
} else { // Button
el.className += ' widget-button';
el.style.background = w.bgColor;
el.style.borderRadius = (w.radius * SCALE) + 'px';
if (w.shadow && w.shadow.enabled) {
el.style.boxShadow = `${w.shadow.x * SCALE}px ${w.shadow.y * SCALE}px ${w.shadow.blur * SCALE}px ${w.shadow.color}`;
}
el.textContent = displayText;
}
el.addEventListener('mousedown', startDrag);
el.addEventListener('touchstart', startDrag, {passive: false});
el.addEventListener('click', (e) => { e.stopPropagation(); selectWidget(w.id); });
canvas.appendChild(el);
});
}
function selectWidget(id) {
selectedWidget = id;
renderCanvas();
renderProperties();
}
function renderProperties() {
const panel = document.getElementById('properties');
const w = config.widgets.find(x => x.id === selectedWidget);
if (!w) {
panel.innerHTML = '<div class="no-selection">Kein Widget ausgewaehlt.</div>';
return;
}
panel.innerHTML = `
<h3>Position & Groesse</h3>
<div class="prop-row"><label>X</label><input type="number" id="px" value="${w.x}" onchange="updateProp('x')"></div>
<div class="prop-row"><label>Y</label><input type="number" id="py" value="${w.y}" onchange="updateProp('y')"></div>
<div class="prop-row"><label>Breite</label><input type="number" id="pw" value="${w.w}" onchange="updateProp('w')"></div>
<div class="prop-row"><label>Hoehe</label><input type="number" id="ph" value="${w.h}" onchange="updateProp('h')"></div>
<div class="prop-row"><label>Sichtbar</label><input type="checkbox" id="pvis" ${w.visible?'checked':''} onchange="updateProp('visible')"></div>
<h3>Text</h3>
<div class="prop-row"><label>Quelle</label>
<select id="ptextsrc" onchange="updateProp('textSrc')">
${Object.entries(textSources).map(([k,v]) => `<option value="${k}" ${w.textSrc==k?'selected':''}>${v}</option>`).join('')}
</select>
</div>
${w.textSrc === 0 ? `
<div class="prop-row"><label>Text</label><input type="text" id="ptext" value="${w.text}" onchange="updateProp('text')"></div>
` : `
<div class="prop-row"><label>KNX Objekt</label>
<select id="pknx" onchange="updateProp('knxAddr')">
<option value="0" ${w.knxAddr==0?'selected':''}>-- Waehlen --</option>
${knxAddresses.map(a => `<option value="${a.index}" ${w.knxAddr==a.index?'selected':''}>GO${a.index} (${a.addrStr})</option>`).join('')}
</select>
</div>
<div class="prop-row"><label>Vorschau</label><span style="color:#a6adc8">${previewValues[w.textSrc] || '...'}</span></div>
<input type="hidden" id="ptext" value="${w.text}">
`}
<div class="prop-row"><label>Schriftgr.</label>
<select id="pfont" onchange="updateProp('fontSize')">
<option value="0" ${w.fontSize==0?'selected':''}>14</option>
<option value="1" ${w.fontSize==1?'selected':''}>18</option>
<option value="2" ${w.fontSize==2?'selected':''}>22</option>
<option value="3" ${w.fontSize==3?'selected':''}>28</option>
<option value="4" ${w.fontSize==4?'selected':''}>36</option>
<option value="5" ${w.fontSize==5?'selected':''}>48</option>
</select>
</div>
<h3>Farben</h3>
<div class="prop-row"><label>Textfarbe</label><input type="color" id="ptc" value="${w.textColor}" onchange="updateProp('textColor')"></div>
<div class="prop-row"><label>Hintergrund</label><input type="color" id="pbc" value="${w.bgColor}" onchange="updateProp('bgColor')"></div>
<div class="prop-row"><label>HG Deckkraft</label><input type="number" id="pbo" min="0" max="255" value="${w.bgOpacity}" onchange="updateProp('bgOpacity')"></div>
<div class="prop-row"><label>Radius</label><input type="number" id="prad" value="${w.radius}" onchange="updateProp('radius')"></div>
<h3>Schatten</h3>
<div class="prop-row"><label>Aktiv</label><input type="checkbox" id="pse" ${w.shadow?.enabled?'checked':''} onchange="updateShadow('enabled')"></div>
<div class="prop-row"><label>X</label><input type="number" id="psx" value="${w.shadow?.x||0}" onchange="updateShadow('x')"></div>
<div class="prop-row"><label>Y</label><input type="number" id="psy" value="${w.shadow?.y||0}" onchange="updateShadow('y')"></div>
<div class="prop-row"><label>Blur</label><input type="number" id="psb" value="${w.shadow?.blur||0}" onchange="updateShadow('blur')"></div>
<div class="prop-row"><label>Farbe</label><input type="color" id="psc" value="${w.shadow?.color||'#000000'}" onchange="updateShadow('color')"></div>
${w.type === 1 ? `
<h3>Button</h3>
<div class="prop-row"><label>Toggle</label><input type="checkbox" id="ptog" ${w.isToggle?'checked':''} onchange="updateProp('isToggle')"></div>
<div class="prop-row"><label>KNX Schreib</label>
<select id="pknxw" onchange="updateProp('knxAddrWrite')">
<option value="0" ${w.knxAddrWrite==0?'selected':''}>-- Keine --</option>
${knxAddresses.filter(a => a.write).map(a => `<option value="${a.index}" ${w.knxAddrWrite==a.index?'selected':''}>GO${a.index} (${a.addrStr})</option>`).join('')}
</select>
</div>
` : ''}
<div style="margin-top:20px">
<button class="danger" onclick="deleteWidget()">Widget loeschen</button>
</div>
`;
}
function updateProp(prop) {
const w = config.widgets.find(x => x.id === selectedWidget);
if (!w) return;
if (prop === 'x') w.x = parseInt(document.getElementById('px').value);
else if (prop === 'y') w.y = parseInt(document.getElementById('py').value);
else if (prop === 'w') w.w = parseInt(document.getElementById('pw').value);
else if (prop === 'h') w.h = parseInt(document.getElementById('ph').value);
else if (prop === 'visible') w.visible = document.getElementById('pvis').checked;
else if (prop === 'textSrc') {
const newSrc = parseInt(document.getElementById('ptextsrc').value);
w.textSrc = newSrc;
// Auto-fill format string when switching to KNX source
if (newSrc > 0 && defaultFormats[newSrc]) {
w.text = defaultFormats[newSrc];
// Update text field in UI
const textEl = document.getElementById('ptext');
if (textEl) textEl.value = w.text;
}
}
else if (prop === 'text') w.text = document.getElementById('ptext').value;
else if (prop === 'knxAddr') w.knxAddr = parseInt(document.getElementById('pknx').value);
else if (prop === 'fontSize') w.fontSize = parseInt(document.getElementById('pfont').value);
else if (prop === 'textColor') w.textColor = document.getElementById('ptc').value;
else if (prop === 'bgColor') w.bgColor = document.getElementById('pbc').value;
else if (prop === 'bgOpacity') w.bgOpacity = parseInt(document.getElementById('pbo').value);
else if (prop === 'radius') w.radius = parseInt(document.getElementById('prad').value);
else if (prop === 'isToggle') w.isToggle = document.getElementById('ptog').checked;
else if (prop === 'knxAddrWrite') w.knxAddrWrite = parseInt(document.getElementById('pknxw').value);
renderCanvas();
}
function updateShadow(prop) {
const w = config.widgets.find(x => x.id === selectedWidget);
if (!w) return;
if (!w.shadow) w.shadow = {enabled:false,x:2,y:2,blur:8,spread:0,color:'#000000'};
if (prop === 'enabled') w.shadow.enabled = document.getElementById('pse').checked;
else if (prop === 'x') w.shadow.x = parseInt(document.getElementById('psx').value);
else if (prop === 'y') w.shadow.y = parseInt(document.getElementById('psy').value);
else if (prop === 'blur') w.shadow.blur = parseInt(document.getElementById('psb').value);
else if (prop === 'color') w.shadow.color = document.getElementById('psc').value;
renderCanvas();
}
function updateBgColor() {
config.bgColor = document.getElementById('bgColor').value;
renderCanvas();
}
function addWidget(type) {
const w = {
id: nextId++,
type: type === 'label' ? 0 : 1,
x: 100, y: 100,
w: type === 'label' ? 150 : 120,
h: type === 'label' ? 40 : 50,
visible: true,
textSrc: 0,
text: type === 'label' ? 'Neues Label' : 'Button',
knxAddr: 0,
fontSize: 1,
textColor: '#FFFFFF',
bgColor: type === 'label' ? '#000000' : '#2196F3',
bgOpacity: type === 'label' ? 0 : 255,
radius: type === 'label' ? 0 : 8,
shadow: {enabled: type === 'button', x:2, y:2, blur:8, spread:0, color:'#000000'},
isToggle: false,
knxAddrWrite: 0
};
config.widgets.push(w);
selectWidget(w.id);
}
function deleteWidget() {
config.widgets = config.widgets.filter(w => w.id !== selectedWidget);
selectedWidget = null;
renderCanvas();
renderProperties();
}
function startDrag(e) {
e.preventDefault();
const el = e.target.closest('.widget');
if (!el) return;
const id = parseInt(el.dataset.id);
selectWidget(id);
const rect = document.getElementById('canvas').getBoundingClientRect();
const cx = e.touches ? e.touches[0].clientX : e.clientX;
const cy = e.touches ? e.touches[0].clientY : e.clientY;
dragState = {
id: id,
startX: cx,
startY: cy,
origX: config.widgets.find(w => w.id === id).x,
origY: config.widgets.find(w => w.id === id).y
};
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) / SCALE;
const dy = (cy - dragState.startY) / SCALE;
const w = config.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)));
renderCanvas();
// Update properties panel
const px = document.getElementById('px');
const py = document.getElementById('py');
if (px) px.value = w.x;
if (py) py.value = w.y;
}
}
function endDrag() {
dragState = null;
document.removeEventListener('mousemove', drag);
document.removeEventListener('mouseup', endDrag);
document.removeEventListener('touchmove', drag);
document.removeEventListener('touchend', endDrag);
}
async function saveConfig() {
try {
// First update config
await fetch('/api/config', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify(config)
});
// Then save and apply
await fetch('/api/save', {method: 'POST'});
showStatus('Gespeichert!');
} catch (e) {
showStatus('Fehler beim Speichern', true);
}
}
async function resetConfig() {
if (!confirm('Wirklich auf Standardeinstellungen zuruecksetzen?')) return;
try {
await fetch('/api/reset', {method: 'POST'});
await loadConfig();
showStatus('Zurueckgesetzt!');
} catch (e) {
showStatus('Fehler', true);
}
}
function showStatus(msg, isError = false) {
const el = document.getElementById('status');
el.textContent = msg;
el.className = 'status show' + (isError ? ' error' : '');
setTimeout(() => el.className = 'status', 2000);
}
// Deselect when clicking canvas background
document.getElementById('canvas').addEventListener('click', (e) => {
if (e.target.id === 'canvas') {
selectedWidget = null;
renderCanvas();
renderProperties();
}
});
// Load on start
loadConfig();
</script>
</body>
</html>