feat: make layout elements fully editable inline

Each element now has editable fields for position, size, font,
style, icon, datapoint, and text directly in the element card.
No more delete-and-recreate workflow.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Christian Mueller 2026-04-07 03:48:23 +02:00
parent b1f3d0b523
commit 0196891ef8

View File

@ -20,10 +20,13 @@ interface LayoutElement {
width: number | null; width: number | null;
height: number | null; height: number | null;
font_size: number; font_size: number;
font_weight: string;
text_align: string;
style: string; style: string;
static_text: string | null; static_text: string | null;
datapoint_id: number | null; datapoint_id: number | null;
icon: string | null; icon: string | null;
sort_order: number;
} }
interface LayoutWithElements extends Layout { interface LayoutWithElements extends Layout {
@ -34,50 +37,48 @@ interface Datapoint {
id: number; id: number;
name: string; name: string;
label: string | null; label: string | null;
value: string | null;
unit: string | null;
} }
const ELEMENT_TYPES = ['label', 'value', 'line', 'rect', 'static']; const ICONS = ['', 'thermometer', 'droplet', 'wind', 'pressure', 'sun', 'bolt', 'gauge', 'wifi', 'battery', 'check', 'warning', 'clock'];
const emptyEl = { type: 'value', x: '0', y: '0', font_size: '24', style: 'plain', datapoint_id: '', icon: '', static_text: '' };
export default function Layouts() { export default function Layouts() {
const [layouts, setLayouts] = useState<Layout[]>([]); const [layouts, setLayouts] = useState<Layout[]>([]);
const [selected, setSelected] = useState<LayoutWithElements | null>(null); const [selected, setSelected] = useState<LayoutWithElements | null>(null);
const [datapoints, setDatapoints] = useState<Datapoint[]>([]); const [datapoints, setDatapoints] = useState<Datapoint[]>([]);
const [newName, setNewName] = useState(''); const [newName, setNewName] = useState('');
const [elForm, setElForm] = useState(emptyEl);
const [error, setError] = useState(''); const [error, setError] = useState('');
async function loadLayouts() { async function loadLayouts() {
try { try {
setLayouts(await api.get<Layout[]>('/api/layouts')); setLayouts(await api.get<Layout[]>('/api/layouts'));
} catch (e) { } catch (e) { setError((e as Error).message); }
setError((e as Error).message); }
}
async function loadDatapoints() {
try {
setDatapoints(await api.get<Datapoint[]>('/api/datapoints'));
} catch (_) {}
} }
async function selectLayout(id: number) { async function selectLayout(id: number) {
try { try {
setSelected(await api.get<LayoutWithElements>(`/api/layouts/${id}`)); setSelected(await api.get<LayoutWithElements>(`/api/layouts/${id}`));
} catch (e) { } catch (e) { setError((e as Error).message); }
setError((e as Error).message);
}
} }
useEffect(() => { useEffect(() => { loadLayouts(); loadDatapoints(); }, []);
loadLayouts();
api.get<Datapoint[]>('/api/datapoints').then(setDatapoints).catch(() => {});
}, []);
async function handleAddLayout(e: FormEvent) { async function handleAddLayout(e: FormEvent) {
e.preventDefault(); e.preventDefault();
if (!newName.trim()) return; if (!newName.trim()) return;
try { try {
await api.post('/api/layouts', { name: newName.trim() }); const res = await api.post<{ id: number }>('/api/layouts', { name: newName.trim() });
setNewName(''); setNewName('');
loadLayouts(); loadLayouts();
} catch (e) { selectLayout(res.id);
setError((e as Error).message); } catch (e) { setError((e as Error).message); }
}
} }
async function handleDeleteLayout(id: number) { async function handleDeleteLayout(id: number) {
@ -86,30 +87,30 @@ export default function Layouts() {
await api.del(`/api/layouts/${id}`); await api.del(`/api/layouts/${id}`);
if (selected?.id === id) setSelected(null); if (selected?.id === id) setSelected(null);
loadLayouts(); loadLayouts();
} catch (e) { } catch (e) { setError((e as Error).message); }
setError((e as Error).message);
}
} }
async function handleAddElement(e: FormEvent) { async function handleAddElement(type: string) {
e.preventDefault(); 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; if (!selected) return;
try { try {
await api.post(`/api/layouts/${selected.id}/elements`, { await api.put(`/api/layouts/elements/${elId}`, { [field]: value });
type: elForm.type,
x: parseInt(elForm.x),
y: parseInt(elForm.y),
font_size: parseInt(elForm.font_size),
style: elForm.style,
datapoint_id: elForm.datapoint_id ? parseInt(elForm.datapoint_id) : null,
icon: elForm.icon || null,
static_text: elForm.static_text || null,
});
setElForm(emptyEl);
selectLayout(selected.id); selectLayout(selected.id);
} catch (e) { } catch (e) { setError((e as Error).message); }
setError((e as Error).message);
}
} }
async function handleDeleteElement(elId: number) { async function handleDeleteElement(elId: number) {
@ -117,47 +118,39 @@ export default function Layouts() {
try { try {
await api.del(`/api/layouts/${selected.id}/elements/${elId}`); await api.del(`/api/layouts/${selected.id}/elements/${elId}`);
selectLayout(selected.id); selectLayout(selected.id);
} catch (e) { } catch (e) { setError((e as Error).message); }
setError((e as Error).message); }
}
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 ( return (
<div> <div>
<h2 className="text-xl font-semibold text-gray-800 mb-6">Layouts</h2> <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}</div>} {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"> <div className="flex gap-6">
{/* Left: layout list */} {/* Left: layout list */}
<div className="w-64 shrink-0"> <div className="w-56 shrink-0">
<div className="bg-white rounded-lg border border-gray-200 overflow-hidden mb-3"> <div className="bg-white rounded-lg border border-gray-200 overflow-hidden mb-3">
{layouts.length === 0 && ( {layouts.length === 0 && <p className="px-4 py-4 text-sm text-gray-400">Keine Layouts</p>}
<p className="px-4 py-4 text-sm text-gray-400">Keine Layouts vorhanden</p>
)}
{layouts.map((l) => ( {layouts.map((l) => (
<div <div key={l.id} onClick={() => selectLayout(l.id)}
key={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'}`}>
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> <span>{l.name}</span>
<button <button onClick={(e) => { e.stopPropagation(); handleDeleteLayout(l.id); }} className="text-red-400 hover:text-red-600 text-xs ml-2">X</button>
onClick={(e) => { e.stopPropagation(); handleDeleteLayout(l.id); }}
className="text-red-400 hover:text-red-600 text-xs ml-2"
>
X
</button>
</div> </div>
))} ))}
</div> </div>
<form onSubmit={handleAddLayout} className="flex gap-2"> <form onSubmit={handleAddLayout} className="flex gap-2">
<input <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)} />
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> <button type="submit" className="bg-sky-500 hover:bg-sky-600 text-white text-sm px-3 py-1.5 rounded">+</button>
</form> </form>
</div> </div>
@ -165,100 +158,142 @@ export default function Layouts() {
{/* Right: element editor */} {/* Right: element editor */}
{selected && ( {selected && (
<div className="flex-1 min-w-0"> <div className="flex-1 min-w-0">
<h3 className="font-medium text-gray-700 mb-4"> <div className="flex items-center justify-between mb-4">
Elemente in <span className="text-sky-600">{selected.name}</span>{' '} <h3 className="font-medium text-gray-700">
<span className="text-xs text-gray-400">({selected.width}x{selected.height})</span> <span className="text-sky-600">{selected.name}</span>
</h3> <span className="text-xs text-gray-400 ml-2">({selected.width}x{selected.height})</span>
</h3>
{/* Element list */} <div className="flex gap-2">
<div className="bg-white rounded-lg border border-gray-200 overflow-hidden mb-4"> <button onClick={() => handleAddElement('value')} className="bg-green-500 hover:bg-green-600 text-white text-xs px-3 py-1.5 rounded">+ Wert</button>
<table className="w-full text-sm"> <button onClick={() => handleAddElement('label')} className="bg-blue-500 hover:bg-blue-600 text-white text-xs px-3 py-1.5 rounded">+ Label</button>
<thead className="bg-gray-50"> <button onClick={() => handleAddElement('line')} className="bg-gray-500 hover:bg-gray-600 text-white text-xs px-3 py-1.5 rounded">+ Linie</button>
<tr> </div>
<th className="px-3 py-2 text-left text-xs font-medium text-gray-500">Typ</th>
<th className="px-3 py-2 text-left text-xs font-medium text-gray-500">X / Y</th>
<th className="px-3 py-2 text-left text-xs font-medium text-gray-500">Schriftgroesse</th>
<th className="px-3 py-2 text-left text-xs font-medium text-gray-500">Datenpunkt</th>
<th className="px-3 py-2 text-left text-xs font-medium text-gray-500">Statischer Text</th>
<th className="px-3 py-2 text-left text-xs font-medium text-gray-500">Aktion</th>
</tr>
</thead>
<tbody className="divide-y divide-gray-100">
{selected.elements.length === 0 && (
<tr><td colSpan={6} className="px-3 py-4 text-center text-gray-400 text-xs">Noch keine Elemente</td></tr>
)}
{selected.elements.map((el) => {
const dp = datapoints.find((d) => d.id === el.datapoint_id);
return (
<tr key={el.id} className="hover:bg-gray-50">
<td className="px-3 py-2 text-xs font-mono">{el.type}</td>
<td className="px-3 py-2 text-xs text-gray-500">{el.x} / {el.y}</td>
<td className="px-3 py-2 text-xs text-gray-500">{el.font_size}</td>
<td className="px-3 py-2 text-xs text-gray-600">{dp ? (dp.label || dp.name) : '-'}</td>
<td className="px-3 py-2 text-xs text-gray-500 max-w-xs truncate">{el.static_text ?? '-'}</td>
<td className="px-3 py-2">
<button onClick={() => handleDeleteElement(el.id)} className="text-red-400 hover:text-red-600 text-xs">Loeschen</button>
</td>
</tr>
);
})}
</tbody>
</table>
</div> </div>
{/* Add element form */} {/* Element list — editable */}
<div className="bg-white rounded-lg border border-gray-200 p-4 mb-4"> <div className="space-y-2 mb-6">
<h4 className="text-sm font-medium text-gray-700 mb-3">Element hinzufuegen</h4> {selected.elements.length === 0 && (
<form onSubmit={handleAddElement} className="grid grid-cols-3 gap-3"> <div className="bg-white rounded-lg border border-gray-200 p-6 text-center text-gray-400 text-sm">
<div> Noch keine Elemente. Klicke oben auf "+ Wert" oder "+ Label" um ein Element hinzuzufuegen.
<label className="block text-xs text-gray-600 mb-1">Typ</label>
<select className="w-full border border-gray-300 rounded px-2 py-1.5 text-sm" value={elForm.type} onChange={(e) => setElForm({ ...elForm, type: e.target.value })}>
{ELEMENT_TYPES.map((t) => <option key={t} value={t}>{t}</option>)}
</select>
</div> </div>
<div> )}
<label className="block text-xs text-gray-600 mb-1">X</label> {selected.elements.map((el) => {
<input type="number" className="w-full border border-gray-300 rounded px-2 py-1.5 text-sm" value={elForm.x} onChange={(e) => setElForm({ ...elForm, x: e.target.value })} /> const dp = datapoints.find((d) => d.id === el.datapoint_id);
</div> return (
<div> <div key={el.id} className="bg-white rounded-lg border border-gray-200 p-3">
<label className="block text-xs text-gray-600 mb-1">Y</label> <div className="flex items-center gap-2 mb-2">
<input type="number" className="w-full border border-gray-300 rounded px-2 py-1.5 text-sm" value={elForm.y} onChange={(e) => setElForm({ ...elForm, y: e.target.value })} /> <span className={`text-xs font-bold px-2 py-0.5 rounded ${
</div> el.type === 'value' ? 'bg-green-100 text-green-700' :
<div> el.type === 'label' ? 'bg-blue-100 text-blue-700' :
<label className="block text-xs text-gray-600 mb-1">Schriftgroesse</label> el.type === 'line' ? 'bg-gray-100 text-gray-600' :
<input type="number" className="w-full border border-gray-300 rounded px-2 py-1.5 text-sm" value={elForm.font_size} onChange={(e) => setElForm({ ...elForm, font_size: e.target.value })} /> 'bg-yellow-100 text-yellow-700'
</div> }`}>{el.type.toUpperCase()}</span>
<div> {el.type === 'value' && dp && (
<label className="block text-xs text-gray-600 mb-1">Stil</label> <span className="text-xs text-gray-500">{dp.label || dp.name} = <strong>{dp.value ?? '—'}</strong> {dp.unit || ''}</span>
<select className="w-full border border-gray-300 rounded px-2 py-1.5 text-sm" value={elForm.style} onChange={(e) => setElForm({ ...elForm, style: e.target.value })}> )}
<option value="plain">plain</option> {el.type === 'label' && (
<option value="card">card</option> <span className="text-xs text-gray-500 italic">"{el.static_text}"</span>
</select> )}
</div> <div className="flex-1" />
<div> <button onClick={() => handleDeleteElement(el.id)} className="text-red-400 hover:text-red-600 text-xs">Loeschen</button>
<label className="block text-xs text-gray-600 mb-1">Datenpunkt</label> </div>
<select className="w-full border border-gray-300 rounded px-2 py-1.5 text-sm" value={elForm.datapoint_id} onChange={(e) => setElForm({ ...elForm, datapoint_id: e.target.value })}>
<option value="">-- keiner --</option> <div className="flex flex-wrap gap-2 items-end">
{datapoints.map((dp) => ( {/* Position */}
<option key={dp.id} value={dp.id}>{dp.label || dp.name}</option> <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)} />
</select>
</div> {/* Size (for value, line, rect) */}
<div> {(el.type === 'value' || el.type === 'line' || el.type === 'rect') && (
<label className="block text-xs text-gray-600 mb-1">Icon</label> <>
<input className="w-full border border-gray-300 rounded px-2 py-1.5 text-sm" value={elForm.icon} onChange={(e) => setElForm({ ...elForm, icon: e.target.value })} placeholder="z.B. thermometer" /> <NumInput label="Breite" value={el.width ?? 370} onChange={(v) => updateElement(el.id, 'width', v)} />
</div> <NumInput label="Hoehe" value={el.height ?? 90} onChange={(v) => updateElement(el.id, 'height', v)} />
<div className="col-span-2"> </>
<label className="block text-xs text-gray-600 mb-1">Statischer Text</label> )}
<input className="w-full border border-gray-300 rounded px-2 py-1.5 text-sm" value={elForm.static_text} onChange={(e) => setElForm({ ...elForm, static_text: e.target.value })} placeholder="Fester Text" />
</div> {/* Font size (for value, label) */}
<div className="col-span-3"> {(el.type === 'value' || el.type === 'label') && (
<button type="submit" className="bg-sky-500 hover:bg-sky-600 text-white text-sm px-4 py-2 rounded transition-colors">Element hinzufuegen</button> <NumInput label="Schrift" value={el.font_size} onChange={(v) => updateElement(el.id, 'font_size', v)} />
</div> )}
</form>
{/* 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>
)}
{/* 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> </div>
{/* E-paper preview */} {/* E-paper preview */}
<h4 className="text-sm font-medium text-gray-600 mb-2">Vorschau (800x480)</h4>
<EpaperPreview <EpaperPreview
layoutId={selected.id} layoutId={selected.id}
elements={selected.elements} elements={selected.elements}