update frontend
This commit is contained in:
228
frontend/src/components/ConfigForm.tsx
Normal file
228
frontend/src/components/ConfigForm.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
178
frontend/src/components/DeviceCard.tsx
Normal file
178
frontend/src/components/DeviceCard.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
168
frontend/src/components/DevicesSection.tsx
Normal file
168
frontend/src/components/DevicesSection.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
29
frontend/src/components/Hero.tsx
Normal file
29
frontend/src/components/Hero.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
63
frontend/src/components/PotentialDeviceCard.tsx
Normal file
63
frontend/src/components/PotentialDeviceCard.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
47
frontend/src/components/PotentialDevicesSection.tsx
Normal file
47
frontend/src/components/PotentialDevicesSection.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
123
frontend/src/components/SensorCard.tsx
Normal file
123
frontend/src/components/SensorCard.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
71
frontend/src/components/SensorsSection.tsx
Normal file
71
frontend/src/components/SensorsSection.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
18
frontend/src/components/StatsBar.tsx
Normal file
18
frontend/src/components/StatsBar.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
9
frontend/src/components/index.ts
Normal file
9
frontend/src/components/index.ts
Normal 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";
|
||||
Reference in New Issue
Block a user