feat: layout export/import as JSON files

Export saves layout + elements with datapoint names for re-linking.
Import creates new layout and re-links datapoints by name.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Christian Mueller 2026-04-07 08:42:41 +02:00
parent 07bb4acb57
commit 52dddfd74f
3 changed files with 142 additions and 1 deletions

View File

@ -137,6 +137,31 @@ export default function Layouts() {
} catch (e) { setError((e as Error).message); } } 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 }) { function NumInput({ value, onChange, label, w }: { value: number; onChange: (v: number) => void; label: string; w?: string }) {
return ( return (
<div className={w || 'w-16'}> <div className={w || 'w-16'}>
@ -165,10 +190,13 @@ export default function Layouts() {
</div> </div>
))} ))}
</div> </div>
<form onSubmit={handleAddLayout} className="flex gap-2"> <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)} /> <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> <button type="submit" className="bg-sky-500 hover:bg-sky-600 text-white text-sm px-3 py-1.5 rounded">+</button>
</form> </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> </div>
{/* Right: element editor */} {/* Right: element editor */}
@ -183,6 +211,7 @@ export default function Layouts() {
<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('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('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={() => 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>
</div> </div>

View File

@ -64,6 +64,113 @@ router.delete('/:id', (req, res): void => {
} }
}); });
// ---- Export / Import ----
// GET /api/layouts/:id/export — export layout with elements as JSON file
router.get('/:id/export', (req, res): void => {
try {
const layout = getLayoutById(parseInt(req.params.id));
if (!layout) { res.status(404).json({ error: 'Layout not found' }); return; }
const exportData = {
_format: 'epaper-layout-v1',
name: layout.name,
type: layout.type,
width: layout.width,
height: layout.height,
grid_cols: layout.grid_cols,
grid_rows: layout.grid_rows,
elements: layout.elements.map((el: any) => ({
type: el.type,
x: el.x,
y: el.y,
width: el.width,
height: el.height,
font_size: el.font_size,
font_weight: el.font_weight,
text_align: el.text_align,
icon: el.icon,
style: el.style,
static_text: el.static_text,
grid_col: el.grid_col,
grid_row: el.grid_row,
sort_order: el.sort_order,
// Store datapoint name for re-linking on import
datapoint_name: el.datapoint_id
? (require('../../modules/datapoints/datapoint-manager').getDatapointById(el.datapoint_id)?.name || null)
: null,
})),
};
const filename = `layout-${layout.name.replace(/[^a-zA-Z0-9_-]/g, '_')}.json`;
res.setHeader('Content-Disposition', `attachment; filename="${filename}"`);
res.setHeader('Content-Type', 'application/json');
res.json(exportData);
} catch (err) {
res.status(500).json({ error: (err as Error).message });
}
});
// POST /api/layouts/import — import layout from JSON
router.post('/import', (req, res): void => {
try {
const data = req.body;
if (!data._format || !data._format.startsWith('epaper-layout')) {
res.status(400).json({ error: 'Ungueltiges Layout-Format. Erwartet: epaper-layout-v1' });
return;
}
if (!data.name || !data.elements) {
res.status(400).json({ error: 'name und elements sind erforderlich' });
return;
}
// Create layout
const layout = createLayout({
name: data.name + ' (Import)',
type: data.type || 'free',
width: data.width,
height: data.height,
grid_cols: data.grid_cols,
grid_rows: data.grid_rows,
});
const { getDatapointByName } = require('../../modules/datapoints/datapoint-manager');
// Add elements
for (const el of data.elements) {
// Try to re-link datapoint by name
let datapointId = null;
if (el.datapoint_name) {
const dp = getDatapointByName ? getDatapointByName(el.datapoint_name) : null;
if (dp) datapointId = dp.id;
}
addElement(layout.id, {
type: el.type || 'label',
x: el.x || 0,
y: el.y || 0,
width: el.width || null,
height: el.height || null,
font_size: el.font_size || 24,
font_weight: el.font_weight || 'normal',
text_align: el.text_align || 'left',
icon: el.icon || null,
style: el.style || 'plain',
static_text: el.static_text || null,
grid_col: el.grid_col || null,
grid_row: el.grid_row || null,
sort_order: el.sort_order || 0,
datapoint_id: datapointId,
});
}
const result = getLayoutById(layout.id);
res.status(201).json(result);
} catch (err) {
res.status(500).json({ error: (err as Error).message });
}
});
// ---- Elements ---- // ---- Elements ----
// POST /api/layouts/:layoutId/elements // POST /api/layouts/:layoutId/elements

View File

@ -118,6 +118,11 @@ export function createDatapointFromPg(
return db.prepare('SELECT * FROM datapoints WHERE id = ?').get(id) as Datapoint; return db.prepare('SELECT * FROM datapoints WHERE id = ?').get(id) as Datapoint;
} }
export function getDatapointByName(name: string): Datapoint | undefined {
const db = getDb();
return db.prepare('SELECT * FROM datapoints WHERE name = ?').get(name) as Datapoint | undefined;
}
export function deleteDatapoint(id: number): boolean { export function deleteDatapoint(id: number): boolean {
const db = getDb(); const db = getDb();
const result = db.prepare('DELETE FROM datapoints WHERE id = ?').run(id); const result = db.prepare('DELETE FROM datapoints WHERE id = ?').run(id);