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:
parent
b1f3d0b523
commit
0196891ef8
@ -20,10 +20,13 @@ interface LayoutElement {
|
||||
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 {
|
||||
@ -34,50 +37,48 @@ interface Datapoint {
|
||||
id: number;
|
||||
name: string;
|
||||
label: string | null;
|
||||
value: string | null;
|
||||
unit: string | null;
|
||||
}
|
||||
|
||||
const ELEMENT_TYPES = ['label', 'value', 'line', 'rect', 'static'];
|
||||
const emptyEl = { type: 'value', x: '0', y: '0', font_size: '24', style: 'plain', datapoint_id: '', icon: '', static_text: '' };
|
||||
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 [elForm, setElForm] = useState(emptyEl);
|
||||
const [error, setError] = useState('');
|
||||
|
||||
async function loadLayouts() {
|
||||
try {
|
||||
setLayouts(await api.get<Layout[]>('/api/layouts'));
|
||||
} catch (e) {
|
||||
setError((e as Error).message);
|
||||
}
|
||||
} 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);
|
||||
}
|
||||
} catch (e) { setError((e as Error).message); }
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
loadLayouts();
|
||||
api.get<Datapoint[]>('/api/datapoints').then(setDatapoints).catch(() => {});
|
||||
}, []);
|
||||
useEffect(() => { loadLayouts(); loadDatapoints(); }, []);
|
||||
|
||||
async function handleAddLayout(e: FormEvent) {
|
||||
e.preventDefault();
|
||||
if (!newName.trim()) return;
|
||||
try {
|
||||
await api.post('/api/layouts', { name: newName.trim() });
|
||||
const res = await api.post<{ id: number }>('/api/layouts', { name: newName.trim() });
|
||||
setNewName('');
|
||||
loadLayouts();
|
||||
} catch (e) {
|
||||
setError((e as Error).message);
|
||||
}
|
||||
selectLayout(res.id);
|
||||
} catch (e) { setError((e as Error).message); }
|
||||
}
|
||||
|
||||
async function handleDeleteLayout(id: number) {
|
||||
@ -86,30 +87,30 @@ export default function Layouts() {
|
||||
await api.del(`/api/layouts/${id}`);
|
||||
if (selected?.id === id) setSelected(null);
|
||||
loadLayouts();
|
||||
} catch (e) {
|
||||
setError((e as Error).message);
|
||||
}
|
||||
} catch (e) { setError((e as Error).message); }
|
||||
}
|
||||
|
||||
async function handleAddElement(e: FormEvent) {
|
||||
e.preventDefault();
|
||||
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.post(`/api/layouts/${selected.id}/elements`, {
|
||||
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);
|
||||
await api.put(`/api/layouts/elements/${elId}`, { [field]: value });
|
||||
selectLayout(selected.id);
|
||||
} catch (e) {
|
||||
setError((e as Error).message);
|
||||
}
|
||||
} catch (e) { setError((e as Error).message); }
|
||||
}
|
||||
|
||||
async function handleDeleteElement(elId: number) {
|
||||
@ -117,47 +118,39 @@ export default function Layouts() {
|
||||
try {
|
||||
await api.del(`/api/layouts/${selected.id}/elements/${elId}`);
|
||||
selectLayout(selected.id);
|
||||
} catch (e) {
|
||||
setError((e as Error).message);
|
||||
}
|
||||
} catch (e) { 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 (
|
||||
<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}</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">
|
||||
{/* 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">
|
||||
{layouts.length === 0 && (
|
||||
<p className="px-4 py-4 text-sm text-gray-400">Keine Layouts vorhanden</p>
|
||||
)}
|
||||
{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'}`}
|
||||
>
|
||||
<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>
|
||||
<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">
|
||||
<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)}
|
||||
/>
|
||||
<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>
|
||||
</div>
|
||||
@ -165,100 +158,142 @@ export default function Layouts() {
|
||||
{/* Right: element editor */}
|
||||
{selected && (
|
||||
<div className="flex-1 min-w-0">
|
||||
<h3 className="font-medium text-gray-700 mb-4">
|
||||
Elemente in <span className="text-sky-600">{selected.name}</span>{' '}
|
||||
<span className="text-xs text-gray-400">({selected.width}x{selected.height})</span>
|
||||
</h3>
|
||||
|
||||
{/* Element list */}
|
||||
<div className="bg-white rounded-lg border border-gray-200 overflow-hidden mb-4">
|
||||
<table className="w-full text-sm">
|
||||
<thead className="bg-gray-50">
|
||||
<tr>
|
||||
<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 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>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Add element form */}
|
||||
<div className="bg-white rounded-lg border border-gray-200 p-4 mb-4">
|
||||
<h4 className="text-sm font-medium text-gray-700 mb-3">Element hinzufuegen</h4>
|
||||
<form onSubmit={handleAddElement} className="grid grid-cols-3 gap-3">
|
||||
<div>
|
||||
<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>
|
||||
{/* 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>
|
||||
<div>
|
||||
<label className="block text-xs text-gray-600 mb-1">X</label>
|
||||
<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 })} />
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs text-gray-600 mb-1">Y</label>
|
||||
<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 })} />
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs text-gray-600 mb-1">Schriftgroesse</label>
|
||||
<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 })} />
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs text-gray-600 mb-1">Stil</label>
|
||||
<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>
|
||||
<option value="card">card</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs text-gray-600 mb-1">Datenpunkt</label>
|
||||
<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>
|
||||
{datapoints.map((dp) => (
|
||||
<option key={dp.id} value={dp.id}>{dp.label || dp.name}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<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" />
|
||||
</div>
|
||||
<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>
|
||||
<div className="col-span-3">
|
||||
<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>
|
||||
</div>
|
||||
</form>
|
||||
)}
|
||||
{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>
|
||||
)}
|
||||
|
||||
{/* 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 (800x480)</h4>
|
||||
<EpaperPreview
|
||||
layoutId={selected.id}
|
||||
elements={selected.elements}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user