Christian Mueller 77c9a77645 feat: add WT32-SC01 LCD support + dynamic display resolution
- Add WT32-SC01 V1 (ESP32-WROVER, 480x320 LCD) as second board option
- Split ESP32 code into epaper/ and lcd/ subdirectories
- LCD version: LovyanGFX, color display, touch-to-refresh, no deep sleep
- Add display_type, width, height to displays table (migration 003)
- Frontend: display type selector with predefined resolutions
- Preview and layout editor now use dynamic resolution from display

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-07 21:12:08 +02:00

374 lines
17 KiB
TypeScript

import { useEffect, useState } from 'react';
import type { FormEvent } from 'react';
import { api } from '../api/client';
import EpaperPreview from '../components/EpaperPreview';
interface Layout {
id: number;
name: string;
type: string;
width: number;
height: number;
}
interface LayoutElement {
id: number;
layout_id: number;
type: string;
x: number;
y: number;
width: number | null;
height: number | null;
font_size: number;
font_weight: string;
text_align: string;
style: string;
static_text: string | null;
datapoint_id: number | null;
icon: string | null;
sort_order: number;
}
interface LayoutWithElements extends Layout {
elements: LayoutElement[];
}
interface Datapoint {
id: number;
name: string;
label: string | null;
value: string | null;
unit: string | null;
}
const ICONS = ['', 'thermometer', 'droplet', 'wind', 'pressure', 'sun', 'bolt', 'gauge', 'wifi', 'battery', 'check', 'warning', 'clock'];
export default function Layouts() {
const [layouts, setLayouts] = useState<Layout[]>([]);
const [selected, setSelected] = useState<LayoutWithElements | null>(null);
const [datapoints, setDatapoints] = useState<Datapoint[]>([]);
const [newName, setNewName] = useState('');
const [error, setError] = useState('');
async function loadLayouts() {
try {
setLayouts(await api.get<Layout[]>('/api/layouts'));
} catch (e) { setError((e as Error).message); }
}
async function loadDatapoints() {
try {
setDatapoints(await api.get<Datapoint[]>('/api/datapoints'));
} catch (_) {}
}
async function selectLayout(id: number) {
try {
setSelected(await api.get<LayoutWithElements>(`/api/layouts/${id}`));
} catch (e) { setError((e as Error).message); }
}
useEffect(() => { loadLayouts(); loadDatapoints(); }, []);
async function handleAddLayout(e: FormEvent) {
e.preventDefault();
if (!newName.trim()) return;
try {
const res = await api.post<{ id: number }>('/api/layouts', { name: newName.trim() });
setNewName('');
loadLayouts();
selectLayout(res.id);
} catch (e) { setError((e as Error).message); }
}
async function handleDeleteLayout(id: number) {
if (!confirm('Layout wirklich loeschen?')) return;
try {
await api.del(`/api/layouts/${id}`);
if (selected?.id === id) setSelected(null);
loadLayouts();
} catch (e) { setError((e as Error).message); }
}
async function handleAddElement(type: string) {
if (!selected) return;
const sortOrder = selected.elements.length;
const defaults: Record<string, any> = {
value: { type: 'value', x: 20, y: 70 + sortOrder * 100, width: 370, height: 90, font_size: 32, font_weight: 'bold', style: 'card', sort_order: sortOrder },
label: { type: 'label', x: 400, y: 15, font_size: 28, font_weight: 'bold', text_align: 'center', static_text: 'Titel', sort_order: sortOrder },
line: { type: 'line', x: 10, y: 55, width: 780, height: 2, sort_order: sortOrder },
rect: { type: 'rect', x: 10, y: 10, width: 100, height: 50, sort_order: sortOrder },
};
try {
await api.post(`/api/layouts/${selected.id}/elements`, defaults[type] || defaults.value);
selectLayout(selected.id);
} catch (e) { setError((e as Error).message); }
}
async function updateElement(elId: number, field: string, value: any) {
if (!selected) return;
try {
await api.put(`/api/layouts/${selected.id}/elements/${elId}`, { [field]: value });
selectLayout(selected.id);
} catch (e) { setError((e as Error).message); }
}
async function updateDatapointLabel(dpId: number, label: string) {
try {
await api.put(`/api/datapoints/${dpId}`, { label });
loadDatapoints();
if (selected) selectLayout(selected.id);
} catch (e) { setError((e as Error).message); }
}
async function updateDatapointUnit(dpId: number, unit: string) {
try {
await api.put(`/api/datapoints/${dpId}`, { unit });
loadDatapoints();
if (selected) selectLayout(selected.id);
} catch (e) { setError((e as Error).message); }
}
async function handleDeleteElement(elId: number) {
if (!selected) return;
try {
await api.del(`/api/layouts/${selected.id}/elements/${elId}`);
selectLayout(selected.id);
} catch (e) { setError((e as Error).message); }
}
function handleExport() {
if (!selected) return;
window.open(`/api/layouts/${selected.id}/export`, '_blank');
}
async function handleImport() {
const input = document.createElement('input');
input.type = 'file';
input.accept = '.json';
input.onchange = async (e) => {
const file = (e.target as HTMLInputElement).files?.[0];
if (!file) return;
try {
const text = await file.text();
const data = JSON.parse(text);
const result = await api.post<any>('/api/layouts/import', data);
loadLayouts();
selectLayout(result.id);
} catch (e) {
setError((e as Error).message);
}
};
input.click();
}
function NumInput({ value, onChange, label, w }: { value: number; onChange: (v: number) => void; label: string; w?: string }) {
return (
<div className={w || 'w-16'}>
<div className="text-[10px] text-gray-400">{label}</div>
<input type="number" className="w-full border border-gray-300 rounded px-1.5 py-1 text-xs"
defaultValue={value} onBlur={(e) => { const v = parseInt(e.target.value); if (!isNaN(v) && v !== value) onChange(v); }} />
</div>
);
}
return (
<div>
<h2 className="text-xl font-semibold text-gray-800 mb-6">Layouts</h2>
{error && <div className="mb-4 p-3 bg-red-50 border border-red-200 rounded text-red-700 text-sm">{error}<button className="ml-2 text-red-400" onClick={() => setError('')}>x</button></div>}
<div className="flex gap-6">
{/* Left: layout list */}
<div className="w-56 shrink-0">
<div className="bg-white rounded-lg border border-gray-200 overflow-hidden mb-3">
{layouts.length === 0 && <p className="px-4 py-4 text-sm text-gray-400">Keine Layouts</p>}
{layouts.map((l) => (
<div key={l.id} onClick={() => selectLayout(l.id)}
className={`flex items-center justify-between px-3 py-2.5 cursor-pointer text-sm border-b border-gray-100 last:border-0 ${selected?.id === l.id ? 'bg-sky-50 text-sky-700 font-medium' : 'hover:bg-gray-50'}`}>
<span>{l.name}</span>
<button onClick={(e) => { e.stopPropagation(); handleDeleteLayout(l.id); }} className="text-red-400 hover:text-red-600 text-xs ml-2">X</button>
</div>
))}
</div>
<form onSubmit={handleAddLayout} className="flex gap-2 mb-2">
<input placeholder="Neues Layout..." className="flex-1 border border-gray-300 rounded px-3 py-1.5 text-sm" value={newName} onChange={(e) => setNewName(e.target.value)} />
<button type="submit" className="bg-sky-500 hover:bg-sky-600 text-white text-sm px-3 py-1.5 rounded">+</button>
</form>
<button onClick={handleImport} className="w-full text-xs text-gray-500 hover:text-sky-600 border border-dashed border-gray-300 rounded py-1.5 hover:border-sky-400">
Layout importieren (.json)
</button>
</div>
{/* Right: element editor */}
{selected && (
<div className="flex-1 min-w-0">
<div className="flex items-center justify-between mb-4">
<h3 className="font-medium text-gray-700">
<span className="text-sky-600">{selected.name}</span>
<span className="text-xs text-gray-400 ml-2">({selected.width}x{selected.height})</span>
</h3>
<div className="flex gap-2">
<button onClick={() => handleAddElement('value')} className="bg-green-500 hover:bg-green-600 text-white text-xs px-3 py-1.5 rounded">+ Wert</button>
<button onClick={() => handleAddElement('label')} className="bg-blue-500 hover:bg-blue-600 text-white text-xs px-3 py-1.5 rounded">+ Label</button>
<button onClick={() => handleAddElement('line')} className="bg-gray-500 hover:bg-gray-600 text-white text-xs px-3 py-1.5 rounded">+ Linie</button>
<button onClick={handleExport} className="border border-gray-300 text-gray-600 hover:bg-gray-100 text-xs px-3 py-1.5 rounded">Exportieren</button>
</div>
</div>
{/* Element list — editable */}
<div className="space-y-2 mb-6">
{selected.elements.length === 0 && (
<div className="bg-white rounded-lg border border-gray-200 p-6 text-center text-gray-400 text-sm">
Noch keine Elemente. Klicke oben auf "+ Wert" oder "+ Label" um ein Element hinzuzufuegen.
</div>
)}
{selected.elements.map((el) => {
const dp = datapoints.find((d) => d.id === el.datapoint_id);
return (
<div key={el.id} className="bg-white rounded-lg border border-gray-200 p-3">
<div className="flex items-center gap-2 mb-2">
<span className={`text-xs font-bold px-2 py-0.5 rounded ${
el.type === 'value' ? 'bg-green-100 text-green-700' :
el.type === 'label' ? 'bg-blue-100 text-blue-700' :
el.type === 'line' ? 'bg-gray-100 text-gray-600' :
'bg-yellow-100 text-yellow-700'
}`}>{el.type.toUpperCase()}</span>
{el.type === 'value' && dp && (
<span className="text-xs text-gray-500">{dp.label || dp.name} = <strong>{dp.value ?? '—'}</strong> {dp.unit || ''}</span>
)}
{el.type === 'label' && (
<span className="text-xs text-gray-500 italic">"{el.static_text}"</span>
)}
<div className="flex-1" />
<button onClick={() => handleDeleteElement(el.id)} className="text-red-400 hover:text-red-600 text-xs">Loeschen</button>
</div>
<div className="flex flex-wrap gap-2 items-end">
{/* Position */}
<NumInput label="X" value={el.x} onChange={(v) => updateElement(el.id, 'x', v)} />
<NumInput label="Y" value={el.y} onChange={(v) => updateElement(el.id, 'y', v)} />
{/* Size (for value, line, rect) */}
{(el.type === 'value' || el.type === 'line' || el.type === 'rect') && (
<>
<NumInput label="Breite" value={el.width ?? 370} onChange={(v) => updateElement(el.id, 'width', v)} />
<NumInput label="Hoehe" value={el.height ?? 90} onChange={(v) => updateElement(el.id, 'height', v)} />
</>
)}
{/* Font size (for value, label) */}
{(el.type === 'value' || el.type === 'label') && (
<NumInput label="Schrift" value={el.font_size} onChange={(v) => updateElement(el.id, 'font_size', v)} />
)}
{/* Font weight */}
{(el.type === 'value' || el.type === 'label') && (
<div className="w-20">
<div className="text-[10px] text-gray-400">Gewicht</div>
<select className="w-full border border-gray-300 rounded px-1.5 py-1 text-xs"
value={el.font_weight} onChange={(e) => updateElement(el.id, 'font_weight', e.target.value)}>
<option value="normal">normal</option>
<option value="bold">bold</option>
</select>
</div>
)}
{/* Text align (for label) */}
{el.type === 'label' && (
<div className="w-20">
<div className="text-[10px] text-gray-400">Ausrichtung</div>
<select className="w-full border border-gray-300 rounded px-1.5 py-1 text-xs"
value={el.text_align} onChange={(e) => updateElement(el.id, 'text_align', e.target.value)}>
<option value="left">links</option>
<option value="center">mitte</option>
<option value="right">rechts</option>
</select>
</div>
)}
{/* Style (for value) */}
{el.type === 'value' && (
<div className="w-20">
<div className="text-[10px] text-gray-400">Stil</div>
<select className="w-full border border-gray-300 rounded px-1.5 py-1 text-xs"
value={el.style} onChange={(e) => updateElement(el.id, 'style', e.target.value)}>
<option value="plain">plain</option>
<option value="card">card</option>
</select>
</div>
)}
{/* Icon (for value) */}
{el.type === 'value' && (
<div className="w-28">
<div className="text-[10px] text-gray-400">Icon</div>
<select className="w-full border border-gray-300 rounded px-1.5 py-1 text-xs"
value={el.icon || ''} onChange={(e) => updateElement(el.id, 'icon', e.target.value || null)}>
{ICONS.map((i) => <option key={i} value={i}>{i || '— kein Icon —'}</option>)}
</select>
</div>
)}
{/* Datapoint (for value) */}
{el.type === 'value' && (
<div className="w-48">
<div className="text-[10px] text-gray-400">Datenpunkt</div>
<select className="w-full border border-gray-300 rounded px-1.5 py-1 text-xs"
value={el.datapoint_id || ''} onChange={(e) => updateElement(el.id, 'datapoint_id', e.target.value ? parseInt(e.target.value) : null)}>
<option value=""> keiner </option>
{datapoints.map((d) => (
<option key={d.id} value={d.id}>{d.label || d.name}{d.value ? ` (${d.value}${d.unit ? ' ' + d.unit : ''})` : ''}</option>
))}
</select>
</div>
)}
{/* Editable datapoint label + unit (for value with assigned datapoint) */}
{el.type === 'value' && dp && (
<>
<div className="w-40">
<div className="text-[10px] text-gray-400">Anzeigename</div>
<input className="w-full border border-gray-300 rounded px-1.5 py-1 text-xs"
defaultValue={dp.label || dp.name}
onBlur={(e) => { if (e.target.value !== (dp.label || dp.name)) updateDatapointLabel(dp.id, e.target.value); }}
placeholder="Anzeigename" />
</div>
<div className="w-16">
<div className="text-[10px] text-gray-400">Einheit</div>
<input className="w-full border border-gray-300 rounded px-1.5 py-1 text-xs"
defaultValue={dp.unit || ''}
onBlur={(e) => { if (e.target.value !== (dp.unit || '')) updateDatapointUnit(dp.id, e.target.value); }}
placeholder="z.B. W" />
</div>
</>
)}
{/* Static text (for label) */}
{el.type === 'label' && (
<div className="flex-1 min-w-48">
<div className="text-[10px] text-gray-400">Text</div>
<input className="w-full border border-gray-300 rounded px-1.5 py-1 text-xs"
defaultValue={el.static_text || ''} onBlur={(e) => updateElement(el.id, 'static_text', e.target.value)} />
</div>
)}
</div>
</div>
);
})}
</div>
{/* E-paper preview */}
<h4 className="text-sm font-medium text-gray-600 mb-2">Vorschau ({selected.width}x{selected.height})</h4>
<EpaperPreview
layoutId={selected.id}
elements={selected.elements}
layoutWidth={selected.width}
layoutHeight={selected.height}
/>
</div>
)}
</div>
</div>
);
}