- 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>
374 lines
17 KiB
TypeScript
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>
|
|
);
|
|
}
|