update frontend

This commit is contained in:
Mats Tornberg
2025-11-24 09:10:03 +01:00
parent 187c49ef00
commit cfc9310fe6
16 changed files with 1625 additions and 892 deletions

View File

@@ -0,0 +1,228 @@
import type { ConfigDeviceForm, ParameterSpec } from "../types";
import { PROTOCOL_SPECS } from "../constants/protocols";
interface ConfigFormProps {
deviceId?: number;
configForm: ConfigDeviceForm;
configFeedback: { type: "success" | "error"; message: string } | null;
isConfigMutating: boolean;
hasExistingConfig: boolean;
currentProtocolSpec: (typeof PROTOCOL_SPECS)[number] | undefined;
availableParameterSpecs: ParameterSpec[];
onUpdateField: (field: "name" | "protocol" | "model", value: string) => void;
onUpdateParameterRow: (
index: number,
field: "key" | "value",
value: string
) => void;
onAddParameterRow: () => void;
onRemoveParameterRow: (index: number) => void;
onSave: (deviceId?: number) => void;
onReset: (deviceId?: number) => void;
onCancel: () => void;
onDelete?: (deviceId: number) => void;
}
const isEmpty = (value: string) =>
value.trim() === "" || value === "null" || value === "0";
export const ConfigForm = ({
deviceId,
configForm,
configFeedback,
isConfigMutating,
hasExistingConfig,
currentProtocolSpec,
availableParameterSpecs,
onUpdateField,
onUpdateParameterRow,
onAddParameterRow,
onRemoveParameterRow,
onSave,
onReset,
onCancel,
onDelete,
}: ConfigFormProps) => {
const idSuffix = deviceId ? `-${deviceId}` : "-new";
return (
<div className="config-form">
<div className="config-form-header">
<h3>{deviceId ? `Configure Device #${deviceId}` : "Add New Device"}</h3>
{hasExistingConfig && deviceId && onDelete && (
<button
type="button"
className="danger"
onClick={() => onDelete(deviceId)}
disabled={isConfigMutating}
>
Delete
</button>
)}
</div>
<label className="input-label" htmlFor={`config-name${idSuffix}`}>
Name
</label>
<input
id={`config-name${idSuffix}`}
value={configForm.name}
onChange={(event) => onUpdateField("name", event.target.value)}
placeholder="Living room lamp"
/>
<label className="input-label" htmlFor={`config-protocol${idSuffix}`}>
Protocol
</label>
<select
id={`config-protocol${idSuffix}`}
value={configForm.protocol}
onChange={(event) => onUpdateField("protocol", event.target.value)}
>
<option value="">Select protocol...</option>
{PROTOCOL_SPECS.map((spec) => (
<option key={spec.name} value={spec.name}>
{spec.name}
</option>
))}
</select>
<label className="input-label" htmlFor={`config-model${idSuffix}`}>
Model {currentProtocolSpec?.models ? "" : "(optional)"}
</label>
{currentProtocolSpec?.models ? (
<select
id={`config-model${idSuffix}`}
value={configForm.model}
onChange={(event) => onUpdateField("model", event.target.value)}
>
<option value="">Select model...</option>
{currentProtocolSpec.models.map((model) => (
<option key={model.name} value={model.name}>
{model.name}
</option>
))}
</select>
) : (
<input
id={`config-model${idSuffix}`}
value={configForm.model}
onChange={(event) => onUpdateField("model", event.target.value)}
placeholder="Optional model name"
disabled={!configForm.protocol}
/>
)}
<div className="parameter-header">
<h4>Parameters</h4>
<button
type="button"
className="outline"
onClick={onAddParameterRow}
disabled={isConfigMutating}
>
Add parameter
</button>
</div>
<div className="parameter-rows">
{configForm.parameters.map((parameter, index) => {
const paramSpec = availableParameterSpecs.find(
(spec) => spec.name === parameter.key
);
console.log(
"parameter:",
parameter,
"paramSpec:",
paramSpec,
!isEmpty(parameter.value)
);
if (!paramSpec && isEmpty(parameter.value)) {
return null;
}
return (
<div className="parameter-row" key={`parameter-${index}`}>
{availableParameterSpecs.length > 0 ? (
<select
value={parameter.key}
onChange={(event) =>
onUpdateParameterRow(index, "key", event.target.value)
}
disabled={!configForm.protocol}
>
<option value="">Select parameter...</option>
{availableParameterSpecs.map((spec) => (
<option key={spec.name} value={spec.name}>
{spec.name}
</option>
))}
</select>
) : (
<input
value={parameter.key}
placeholder="Parameter key"
onChange={(event) =>
onUpdateParameterRow(index, "key", event.target.value)
}
disabled={!configForm.protocol}
/>
)}
<input
value={parameter.value}
placeholder={paramSpec ? paramSpec.description : "Value"}
onChange={(event) =>
onUpdateParameterRow(index, "value", event.target.value)
}
disabled={!parameter.key}
/>
<button
type="button"
className="icon-btn subtle"
onClick={() => onRemoveParameterRow(index)}
>
</button>
</div>
);
})}
</div>
{configFeedback && (
<div className={`config-feedback ${configFeedback.type}`}>
{configFeedback.message}
</div>
)}
<div className="actions config-form-actions">
<button
type="button"
className="primary"
onClick={() => onSave(deviceId)}
disabled={isConfigMutating}
>
{hasExistingConfig
? "Save changes"
: deviceId
? "Create config"
: "Create device"}
</button>
<button
type="button"
className="ghost"
onClick={() => onReset(deviceId)}
disabled={isConfigMutating}
>
Reset
</button>
<button
type="button"
className="ghost"
onClick={onCancel}
disabled={isConfigMutating}
>
Cancel
</button>
</div>
</div>
);
};

View File

@@ -0,0 +1,178 @@
import type { CSSProperties } from "react";
import type {
Device,
ConfigDevice,
ConfigDeviceForm,
ParameterSpec,
} from "../types";
import { PROTOCOL_SPECS } from "../constants/protocols";
import { ConfigForm } from "./ConfigForm";
interface DeviceCardProps {
device: Device;
configDevice: ConfigDevice | undefined;
mode: "rename" | "config" | null;
newName: string;
configForm: ConfigDeviceForm;
configFeedback: { type: "success" | "error"; message: string } | null;
emoji: string;
label: string;
gradient: string;
isControlMutating: boolean;
isConfigMutating: boolean;
currentProtocolSpec: (typeof PROTOCOL_SPECS)[number] | undefined;
availableParameterSpecs: ParameterSpec[];
onSetNewName: (name: string) => void;
onRenameDevice: (id: number) => void;
onCancelMode: (id: number) => void;
onStartConfigMode: (id: number) => void;
onTurnOn: (id: number) => void;
onTurnOff: (id: number) => void;
onLearn: (id: number) => void;
onUpdateConfigField: (
field: "name" | "protocol" | "model",
value: string
) => void;
onUpdateParameterRow: (
index: number,
field: "key" | "value",
value: string
) => void;
onAddParameterRow: () => void;
onRemoveParameterRow: (index: number) => void;
onSaveConfig: (deviceId?: number) => void;
onResetConfig: (deviceId?: number) => void;
onDeleteConfig: (deviceId: number) => void;
}
export const DeviceCard = ({
device,
configDevice,
mode,
newName,
configForm,
configFeedback,
emoji,
label,
gradient,
isControlMutating,
isConfigMutating,
currentProtocolSpec,
availableParameterSpecs,
onSetNewName,
onRenameDevice,
onCancelMode,
onStartConfigMode,
onTurnOn,
onTurnOff,
onLearn,
onUpdateConfigField,
onUpdateParameterRow,
onAddParameterRow,
onRemoveParameterRow,
onSaveConfig,
onResetConfig,
onDeleteConfig,
}: DeviceCardProps) => {
const cardStyle = {
"--device-accent": gradient,
} as CSSProperties;
const hexId = device.id.toString(16).toUpperCase();
return (
<div key={device.id} className="card device-card" style={cardStyle}>
{mode === "rename" ? (
<div className="edit-form">
<input
value={newName}
onChange={(e) => onSetNewName(e.target.value)}
placeholder="Enter new name"
autoFocus
/>
<div className="actions">
<button
className="primary"
onClick={() => onRenameDevice(device.id)}
>
Save
</button>
<button onClick={() => onCancelMode(device.id)}>Cancel</button>
</div>
</div>
) : mode === "config" ? (
<ConfigForm
deviceId={device.id}
configForm={configForm}
configFeedback={configFeedback}
isConfigMutating={isConfigMutating}
hasExistingConfig={!!configDevice}
currentProtocolSpec={currentProtocolSpec}
availableParameterSpecs={availableParameterSpecs}
onUpdateField={onUpdateConfigField}
onUpdateParameterRow={onUpdateParameterRow}
onAddParameterRow={onAddParameterRow}
onRemoveParameterRow={onRemoveParameterRow}
onSave={onSaveConfig}
onReset={onResetConfig}
onCancel={() => onCancelMode(device.id)}
onDelete={onDeleteConfig}
/>
) : (
<div className="device-card-main">
<div className="device-card-accent" aria-hidden="true" />
<div className="device-header">
<div className="device-overview">
<div className="device-avatar" style={{ background: gradient }}>
<span>{emoji}</span>
</div>
<div>
<h3 className="device-name">{device.name}</h3>
<div className="device-meta-row">
<span className="badge badge-id">#{device.id}</span>
<span className="device-chip">{label}</span>
<span className="device-meta-inline">0x{hexId}</span>
{configDevice && (
<span className="badge badge-protocol">
{configDevice.protocol}
</span>
)}
</div>
</div>
</div>
<button
className="icon-btn subtle"
title={configDevice ? "Edit config" : "Add config"}
onClick={() => onStartConfigMode(device.id)}
>
</button>
</div>
<div className="actions device-actions">
<button
className="primary"
onClick={() => onTurnOn(device.id)}
disabled={isControlMutating}
>
Turn on
</button>
<button
className="danger"
onClick={() => onTurnOff(device.id)}
disabled={isControlMutating}
>
Turn off
</button>
<button
className="outline"
onClick={() => onLearn(device.id)}
disabled={isControlMutating}
>
Learn
</button>
</div>
</div>
)}
</div>
);
};

View File

@@ -0,0 +1,168 @@
import type {
Device,
ConfigDevice,
ConfigDeviceForm,
ParameterSpec,
} from "../types";
import { DeviceCard } from "./DeviceCard";
import { ConfigForm } from "./ConfigForm";
import { getDeviceVisuals } from "../utils/device";
import { PROTOCOL_SPECS } from "../constants/protocols";
interface DevicesSectionProps {
devices: Device[];
configDevices: ConfigDevice[];
isLoading: boolean;
deviceMode: Record<number, "rename" | "config" | null>;
newName: string;
configForm: ConfigDeviceForm;
configFeedback: { type: "success" | "error"; message: string } | null;
showAddDevice: boolean;
isControlMutating: boolean;
isConfigMutating: boolean;
currentProtocolSpec: (typeof PROTOCOL_SPECS)[number] | undefined;
availableParameterSpecs: ParameterSpec[];
onSetNewName: (name: string) => void;
onRenameDevice: (id: number) => void;
onCancelMode: (id: number) => void;
onStartConfigMode: (id: number) => void;
onTurnOn: (id: number) => void;
onTurnOff: (id: number) => void;
onLearn: (id: number) => void;
onUpdateConfigField: (
field: "name" | "protocol" | "model",
value: string
) => void;
onUpdateParameterRow: (
index: number,
field: "key" | "value",
value: string
) => void;
onAddParameterRow: () => void;
onRemoveParameterRow: (index: number) => void;
onSaveConfig: (deviceId?: number) => void;
onResetConfig: (deviceId?: number) => void;
onDeleteConfig: (deviceId: number) => void;
onStartAddDevice: () => void;
onCancelAddDevice: () => void;
}
export const DevicesSection = ({
devices,
configDevices,
isLoading,
deviceMode,
newName,
configForm,
configFeedback,
showAddDevice,
isControlMutating,
isConfigMutating,
currentProtocolSpec,
availableParameterSpecs,
onSetNewName,
onRenameDevice,
onCancelMode,
onStartConfigMode,
onTurnOn,
onTurnOff,
onLearn,
onUpdateConfigField,
onUpdateParameterRow,
onAddParameterRow,
onRemoveParameterRow,
onSaveConfig,
onResetConfig,
onDeleteConfig,
onStartAddDevice,
onCancelAddDevice,
}: DevicesSectionProps) => {
return (
<section className="panel">
<div className="panel-head">
<div>
<p className="eyebrow">Lighting & switches</p>
<h2>Devices</h2>
</div>
<span className="pill">{devices.length} active</span>
</div>
{isLoading ? (
<div className="empty-state shimmer">Loading devices</div>
) : (
<>
<div className="grid">
{devices.map((device) => {
const mode = deviceMode[device.id];
const configDevice = configDevices.find(
(d) => d.id === device.id
);
const { emoji, label, gradient } = getDeviceVisuals(device);
return (
<DeviceCard
key={device.id}
device={device}
configDevice={configDevice}
mode={mode}
newName={newName}
configForm={configForm}
configFeedback={configFeedback}
emoji={emoji}
label={label}
gradient={gradient}
isControlMutating={isControlMutating}
isConfigMutating={isConfigMutating}
currentProtocolSpec={currentProtocolSpec}
availableParameterSpecs={availableParameterSpecs}
onSetNewName={onSetNewName}
onRenameDevice={onRenameDevice}
onCancelMode={onCancelMode}
onStartConfigMode={onStartConfigMode}
onTurnOn={onTurnOn}
onTurnOff={onTurnOff}
onLearn={onLearn}
onUpdateConfigField={onUpdateConfigField}
onUpdateParameterRow={onUpdateParameterRow}
onAddParameterRow={onAddParameterRow}
onRemoveParameterRow={onRemoveParameterRow}
onSaveConfig={onSaveConfig}
onResetConfig={onResetConfig}
onDeleteConfig={onDeleteConfig}
/>
);
})}
</div>
{showAddDevice && (
<div className="card device-card add-device-card">
<ConfigForm
configForm={configForm}
configFeedback={configFeedback}
isConfigMutating={isConfigMutating}
hasExistingConfig={false}
currentProtocolSpec={currentProtocolSpec}
availableParameterSpecs={availableParameterSpecs}
onUpdateField={onUpdateConfigField}
onUpdateParameterRow={onUpdateParameterRow}
onAddParameterRow={onAddParameterRow}
onRemoveParameterRow={onRemoveParameterRow}
onSave={onSaveConfig}
onReset={onResetConfig}
onCancel={onCancelAddDevice}
/>
</div>
)}
{!showAddDevice && (
<button
className="add-device-btn"
onClick={onStartAddDevice}
disabled={isConfigMutating}
>
<span className="add-device-icon"></span>
<span>Add new device</span>
</button>
)}
</>
)}
</section>
);
};

View File

@@ -0,0 +1,29 @@
interface HeroProps {
latestSensorUpdate: string;
isRefreshing: boolean;
onRefresh: () => void;
}
export const Hero = ({
latestSensorUpdate,
isRefreshing,
onRefresh,
}: HeroProps) => {
return (
<header className="hero">
<div>
<p className="eyebrow">Telldus</p>
<h1>Ambient Control Center</h1>
<p className="hero-subtitle">
Real-time overview of devices, sensors, and incoming signals. Last
update {latestSensorUpdate}.
</p>
</div>
<div className="hero-actions">
<button className="ghost" onClick={onRefresh} disabled={isRefreshing}>
{isRefreshing ? "Refreshing…" : "Refresh data"}
</button>
</div>
</header>
);
};

View File

@@ -0,0 +1,63 @@
import type { PotentialDevice } from "../types";
interface PotentialDeviceCardProps {
deviceId: string;
group: PotentialDevice[];
isExpanded: boolean;
onToggleExpand: (deviceId: string) => void;
}
export const PotentialDeviceCard = ({
deviceId,
group,
isExpanded,
onToggleExpand,
}: PotentialDeviceCardProps) => {
const latest = group.reduce((prev, current) =>
prev.last_seen > current.last_seen ? prev : current
);
return (
<div key={deviceId} className="card">
<div className="card-header">
<div>
<h3 className="device-name">
<span className="emoji"></span> {latest.class}
</h3>
<div className="device-meta">
<span className="badge badge-protocol">{latest.protocol}</span>{" "}
{latest.model}
</div>
</div>
</div>
<div className="card-content">
<div className="device-meta">ID: {deviceId}</div>
<div className="device-meta">
Last seen: {new Date(latest.last_seen * 1000).toLocaleString()}
</div>
<div className="device-meta">Events: {group.length}</div>
{isExpanded && (
<div className="events-list">
<h4>History</h4>
{group
.sort((a, b) => b.last_seen - a.last_seen)
.map((event, idx) => (
<div key={idx} className="event-item">
<div className="event-time">
{new Date(event.last_seen * 1000).toLocaleTimeString()}
</div>
<div className="event-data">{event.last_data}</div>
</div>
))}
</div>
)}
</div>
<div className="actions">
<button onClick={() => onToggleExpand(deviceId)}>
{isExpanded ? "Collapse" : "Expand"}
</button>
</div>
</div>
);
};

View File

@@ -0,0 +1,47 @@
import type { PotentialDevice } from "../types";
import { PotentialDeviceCard } from "./PotentialDeviceCard";
interface PotentialDevicesSectionProps {
groupedPotentialDevices: Record<string, PotentialDevice[]>;
isLoading: boolean;
expandedPotential: string | null;
onToggleExpand: (deviceId: string) => void;
}
export const PotentialDevicesSection = ({
groupedPotentialDevices,
isLoading,
expandedPotential,
onToggleExpand,
}: PotentialDevicesSectionProps) => {
return (
<section className="panel">
<div className="panel-head">
<div>
<p className="eyebrow">Discovery stream</p>
<h2>Potential Devices</h2>
</div>
<span className="pill">
{Object.keys(groupedPotentialDevices).length} signals
</span>
</div>
{isLoading ? (
<div className="empty-state shimmer">Listening for signals</div>
) : Object.keys(groupedPotentialDevices).length === 0 ? (
<div className="empty-state">No potential devices detected</div>
) : (
<div className="grid">
{Object.entries(groupedPotentialDevices).map(([deviceId, group]) => (
<PotentialDeviceCard
key={deviceId}
deviceId={deviceId}
group={group}
isExpanded={expandedPotential === deviceId}
onToggleExpand={onToggleExpand}
/>
))}
</div>
)}
</section>
);
};

View File

@@ -0,0 +1,123 @@
import type { Sensor } from "../types";
interface SensorCardProps {
sensor: Sensor;
isEditing: boolean;
newName: string;
isControlMutating: boolean;
onSetNewName: (name: string) => void;
onStartEdit: (sensorId: number, name: string) => void;
onSaveRename: (sensorId: number) => void;
onCancelEdit: () => void;
onHide: (sensorId: number) => void;
onUnhide: (sensorId: number) => void;
}
export const SensorCard = ({
sensor,
isEditing,
newName,
isControlMutating,
onSetNewName,
onStartEdit,
onSaveRename,
onCancelEdit,
onHide,
onUnhide,
}: SensorCardProps) => {
return (
<div
key={sensor.sensor_id}
className={`card sensor ${sensor.hidden ? "muted" : ""}`}
>
{isEditing ? (
<div className="edit-form">
<input
value={newName}
onChange={(e) => onSetNewName(e.target.value)}
placeholder="Enter new name"
autoFocus
/>
<div className="actions">
<button
className="primary"
onClick={() => onSaveRename(sensor.sensor_id)}
>
Save
</button>
<button onClick={onCancelEdit}>Cancel</button>
</div>
</div>
) : (
<>
<div className="card-header">
<div>
<h3 className="device-name">
<span className="emoji">📡</span> {sensor.name}
</h3>
<div className="device-meta">
<span className="badge badge-protocol">{sensor.protocol}</span>{" "}
{sensor.model}
</div>
</div>
</div>
<div className="card-content">
<div className="sensor-values">
{sensor.last_temperature && (
<div className="sensor-value">
<span className="sensor-icon">🌡</span>
<div>
<p className="sensor-label">Temperature</p>
<span>{sensor.last_temperature}°C</span>
</div>
</div>
)}
{sensor.last_humidity && (
<div className="sensor-value">
<span className="sensor-icon">💧</span>
<div>
<p className="sensor-label">Humidity</p>
<span>{sensor.last_humidity}%</span>
</div>
</div>
)}
</div>
{sensor.last_timestamp && (
<div className="device-meta" style={{ marginTop: "10px" }}>
Updated:{" "}
{new Date(sensor.last_timestamp * 1000).toLocaleString()}
</div>
)}
</div>
<div className="actions">
<button
className="icon-btn"
title="Rename"
onClick={() => onStartEdit(sensor.sensor_id, sensor.name)}
>
</button>
{sensor.hidden ? (
<button
className="success"
onClick={() => onUnhide(sensor.sensor_id)}
disabled={isControlMutating}
>
Unhide
</button>
) : (
<button
onClick={() => onHide(sensor.sensor_id)}
disabled={isControlMutating}
>
Hide
</button>
)}
</div>
</>
)}
</div>
);
};

View File

@@ -0,0 +1,71 @@
import type { Sensor } from "../types";
import { SensorCard } from "./SensorCard";
interface SensorsSectionProps {
sensors: Sensor[];
isLoading: boolean;
editingSensor: number | null;
newName: string;
isControlMutating: boolean;
visibleSensors: number;
hiddenSensors: number;
onSetNewName: (name: string) => void;
onStartEdit: (sensorId: number, name: string) => void;
onSaveRename: (sensorId: number) => void;
onCancelEdit: () => void;
onHide: (sensorId: number) => void;
onUnhide: (sensorId: number) => void;
}
export const SensorsSection = ({
sensors,
isLoading,
editingSensor,
newName,
isControlMutating,
visibleSensors,
hiddenSensors,
onSetNewName,
onStartEdit,
onSaveRename,
onCancelEdit,
onHide,
onUnhide,
}: SensorsSectionProps) => {
return (
<section className="panel">
<div className="panel-head">
<div>
<p className="eyebrow">Climate & environment</p>
<h2>Sensors</h2>
</div>
<span className="pill">
{visibleSensors} visible · {hiddenSensors} hidden
</span>
</div>
{isLoading ? (
<div className="empty-state shimmer">Loading sensors</div>
) : sensors.length === 0 ? (
<div className="empty-state">No sensors found</div>
) : (
<div className="grid">
{sensors.map((sensor) => (
<SensorCard
key={sensor.sensor_id}
sensor={sensor}
isEditing={editingSensor === sensor.sensor_id}
newName={newName}
isControlMutating={isControlMutating}
onSetNewName={onSetNewName}
onStartEdit={onStartEdit}
onSaveRename={onSaveRename}
onCancelEdit={onCancelEdit}
onHide={onHide}
onUnhide={onUnhide}
/>
))}
</div>
)}
</section>
);
};

View File

@@ -0,0 +1,18 @@
import type { DashboardStat } from "../types";
interface StatsBarProps {
stats: DashboardStat[];
}
export const StatsBar = ({ stats }: StatsBarProps) => {
return (
<section className="stats-bar">
{stats.map((stat) => (
<article key={stat.label} className={`stat-card ${stat.accent}`}>
<span className="stat-value">{stat.value}</span>
<span className="stat-label">{stat.label}</span>
</article>
))}
</section>
);
};

View File

@@ -0,0 +1,9 @@
export { Hero } from "./Hero";
export { StatsBar } from "./StatsBar";
export { DeviceCard } from "./DeviceCard";
export { SensorCard } from "./SensorCard";
export { PotentialDeviceCard } from "./PotentialDeviceCard";
export { ConfigForm } from "./ConfigForm";
export { DevicesSection } from "./DevicesSection";
export { SensorsSection } from "./SensorsSection";
export { PotentialDevicesSection } from "./PotentialDevicesSection";