Christian Mueller a50ef96eb7 feat(frontend): Task 15 - all React pages and components
Implements the full SPA UI:
- App.tsx with BrowserRouter, ProtectedRoute, and all page routes
- Sidebar with NavLink active state and logout
- Login page with JWT auth flow
- Dashboard with parallel data fetching and status tiles
- MqttBrokers: CRUD with test-connection button and Topics link
- MqttTopics: discovery, filter, json_path inline edit, ignore
- PgSources: CRUD form with all required fields
- Datapoints: table with inline label/unit editing via onBlur
- Displays: layout dropdown, refresh interval, last-seen timestamp
- Layouts: left panel list + right panel element editor
- EpaperPreview: scaled e-paper simulation with live data support
- StatusBadge: color-coded status chip

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-06 13:06:40 +02:00

169 lines
4.8 KiB
TypeScript

import { useEffect, useState } from 'react';
import { api } from '../api/client';
interface LayoutElement {
id: number;
type: string;
x: number;
y: number;
width: number | null;
height: number | null;
font_size: number;
style: string;
static_text: string | null;
datapoint_id: number | null;
}
interface DisplayJson {
display_id: number;
layout: {
id: number;
width: number;
height: number;
};
elements: Array<{
type: string;
x: number;
y: number;
width?: number;
height?: number;
font_size: number;
style: string;
static_text?: string;
label?: string;
value?: string;
unit?: string;
}>;
}
interface Display {
id: number;
name: string;
layout_id: number | null;
}
interface EpaperPreviewProps {
layoutId: number;
elements: LayoutElement[];
layoutWidth?: number;
layoutHeight?: number;
}
const SCALE = 0.5;
export default function EpaperPreview({ layoutId, elements, layoutWidth = 800, layoutHeight = 480 }: EpaperPreviewProps) {
const [displayJson, setDisplayJson] = useState<DisplayJson | null>(null);
useEffect(() => {
// Find a display using this layout and fetch its JSON
api.get<Display[]>('/api/displays')
.then((displays) => {
const d = displays.find((disp) => disp.layout_id === layoutId);
if (!d) return;
return api.get<DisplayJson>(`/api/display/${d.id}`).then(setDisplayJson).catch(() => {});
})
.catch(() => {});
}, [layoutId]);
const w = layoutWidth * SCALE;
const h = layoutHeight * SCALE;
const renderEl = (el: typeof elements[0], _idx?: number) => {
const left = el.x * SCALE;
const top = el.y * SCALE;
const fs = Math.max(8, el.font_size * SCALE);
if (el.type === 'line') {
return (
<div
key={el.id}
style={{ position: 'absolute', left, top, width: (el.width ?? 100) * SCALE, height: 1, backgroundColor: '#000' }}
/>
);
}
if (el.type === 'rect') {
return (
<div
key={el.id}
style={{
position: 'absolute', left, top,
width: (el.width ?? 80) * SCALE,
height: (el.height ?? 40) * SCALE,
border: '1px solid #000',
}}
/>
);
}
// label or value elements
const text = el.static_text ?? (el.type === 'label' ? `[DP ${el.datapoint_id ?? '?'}]` : `[Wert]`);
const isCard = el.style === 'card';
return (
<div
key={el.id}
style={{
position: 'absolute', left, top,
fontSize: fs,
fontFamily: 'monospace',
color: '#000',
background: isCard ? '#e8e8e8' : 'transparent',
border: isCard ? '1px solid #aaa' : 'none',
padding: isCard ? '2px 4px' : 0,
whiteSpace: 'nowrap',
lineHeight: 1.2,
}}
>
{text}
</div>
);
};
// Use live display JSON if available, else render from elements prop
const liveEls = displayJson?.elements;
return (
<div className="mt-4">
<p className="text-xs text-gray-500 mb-2">
E-Paper Vorschau ({layoutWidth}x{layoutHeight}px){liveEls ? ' — Live-Daten' : ' — Design-Modus'}
</p>
<div
style={{
position: 'relative',
width: w,
height: h,
backgroundColor: '#f5f0e8',
border: '2px solid #1a1a1a',
borderRadius: 4,
overflow: 'hidden',
boxShadow: '2px 2px 8px rgba(0,0,0,0.2)',
}}
>
{liveEls
? liveEls.map((el, idx) => {
const left = el.x * SCALE;
const top = el.y * SCALE;
const fs = Math.max(8, el.font_size * SCALE);
if (el.type === 'line') {
return <div key={idx} style={{ position: 'absolute', left, top, width: (el.width ?? 100) * SCALE, height: 1, backgroundColor: '#000' }} />;
}
if (el.type === 'rect') {
return <div key={idx} style={{ position: 'absolute', left, top, width: (el.width ?? 80) * SCALE, height: (el.height ?? 40) * SCALE, border: '1px solid #000' }} />;
}
const text = el.static_text ?? (el.type === 'value' ? `${el.value ?? '?'} ${el.unit ?? ''}` : (el.label ?? ''));
const isCard = el.style === 'card';
return (
<div key={idx} style={{ position: 'absolute', left, top, fontSize: fs, fontFamily: 'monospace', color: '#000', background: isCard ? '#e8e8e8' : 'transparent', border: isCard ? '1px solid #aaa' : 'none', padding: isCard ? '2px 4px' : 0, whiteSpace: 'nowrap', lineHeight: 1.2 }}>
{text}
</div>
);
})
: elements.map((el) => renderEl(el, el.id))}
</div>
</div>
);
}