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>
169 lines
4.8 KiB
TypeScript
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>
|
|
);
|
|
}
|