feat(web): pop-out modal forms for setup/subscriptions/roles
Add a reusable ui/Modal (Radix Dialog + terminal chrome) and move the add/edit forms in the Devices setup, Subscriptions and Roles screens into it, leaving each list in the page behind the modal. The Devices wizard's per- category device form is also fully translated (setup.* i18n keys). Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
This commit is contained in:
+214
-240
@@ -1,4 +1,5 @@
|
||||
import { useState, useEffect, useCallback } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
assignDevice,
|
||||
editDevice,
|
||||
@@ -19,6 +20,7 @@ import {
|
||||
type RelaySpec,
|
||||
type TestResult,
|
||||
} from "./api.js";
|
||||
import { Modal } from "./ui/Modal.js";
|
||||
|
||||
// First-run setup wizard. The pool-of-spaces model: a parking lot is one pool with
|
||||
// a flexible set of entry/exit points — NO lane. The admin adds CONTROLLERS (each
|
||||
@@ -27,25 +29,30 @@ import {
|
||||
// Direction is a property of the relay, inherited by bound devices. The data model
|
||||
// is multi-instance — one `devices` row per instance. See entry-exit-points.md.
|
||||
|
||||
const CONTROLLER: { key: DeviceCategory; title: string; noun: string } = {
|
||||
// Categories carry i18n KEYS (resolved at render via t()), not literal copy.
|
||||
// `titleKey` is the section heading; `nounKey` resolves to the singular noun used in
|
||||
// the add/edit buttons, modal titles and confirm prompts.
|
||||
const CONTROLLER: { key: DeviceCategory; titleKey: string; nounKey: string } = {
|
||||
key: "access",
|
||||
title: "Controllers (barriers + entry button)",
|
||||
noun: "controller",
|
||||
titleKey: "setup.catControllers",
|
||||
nounKey: "setup.nounController",
|
||||
};
|
||||
// Categories that BIND to a controller relay (direction inherited from the relay).
|
||||
const BOUND: { key: DeviceCategory; title: string; noun: string }[] = [
|
||||
{ key: "reader", title: "Readers (QR / RFID)", noun: "reader" },
|
||||
{ key: "camera", title: "Cameras (snapshot + plate)", noun: "camera" },
|
||||
{ key: "printer", title: "Printers (tickets / vouchers)", noun: "printer" },
|
||||
const BOUND: { key: DeviceCategory; titleKey: string; nounKey: string }[] = [
|
||||
{ key: "reader", titleKey: "setup.catReaders", nounKey: "setup.nounReader" },
|
||||
{ key: "camera", titleKey: "setup.catCameras", nounKey: "setup.nounCamera" },
|
||||
{ key: "printer", titleKey: "setup.catPrinters", nounKey: "setup.nounPrinter" },
|
||||
];
|
||||
|
||||
const DIRECTION_LABELS: Record<Direction, string> = {
|
||||
entry: "Entry",
|
||||
exit: "Exit",
|
||||
both: "Both (entry + exit)",
|
||||
// Translated direction label (relay direction / inherited binding).
|
||||
const DIRECTION_KEYS: Record<Direction, string> = {
|
||||
entry: "setup.dirEntry",
|
||||
exit: "setup.dirExit",
|
||||
both: "setup.dirBoth",
|
||||
};
|
||||
|
||||
export function SetupWizard() {
|
||||
const { t } = useTranslation();
|
||||
const [catalog, setCatalog] = useState<Catalog | null>(null);
|
||||
const [assignments, setAssignments] = useState<Assignment[] | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
@@ -61,25 +68,21 @@ export function SetupWizard() {
|
||||
reloadState();
|
||||
}, [reloadState]);
|
||||
|
||||
if (error) return <p style={{ color: "crimson" }}>Failed to load setup: {error}</p>;
|
||||
if (!catalog || !assignments) return <p>Loading device catalog…</p>;
|
||||
if (error) return <p className="px-4 py-6 text-term-red">{t("setup.failedToLoad", { error })}</p>;
|
||||
if (!catalog || !assignments) return <p className="px-4 py-6 text-term-muted">{t("setup.loadingCatalog")}</p>;
|
||||
|
||||
// Controllers are needed before binding readers/cameras (they pick a controller relay).
|
||||
const controllers = assignments.filter((a) => a.category === "access");
|
||||
|
||||
return (
|
||||
<section>
|
||||
<h2>First-run setup</h2>
|
||||
<p style={{ color: "#666", fontSize: "0.9em" }}>
|
||||
Add your barrier controllers first — set which relay is entry/exit and which
|
||||
terminal the entry button is wired to. Then add readers, cameras and printers
|
||||
and point each at the barrier it serves.
|
||||
</p>
|
||||
<section className="mx-auto max-w-3xl px-4 py-6">
|
||||
<h2 className="mb-1 text-h4 font-semibold text-term-text">{t("setup.title")}</h2>
|
||||
<p className="hint mb-4 max-w-prose">{t("setup.intro")}</p>
|
||||
|
||||
<CategorySection
|
||||
category={CONTROLLER.key}
|
||||
title={CONTROLLER.title}
|
||||
noun={CONTROLLER.noun}
|
||||
title={t(CONTROLLER.titleKey)}
|
||||
noun={t(CONTROLLER.nounKey)}
|
||||
entries={catalog[CONTROLLER.key]}
|
||||
discoverableIds={catalog.discoverable}
|
||||
pushCapableIds={catalog.pushCapable}
|
||||
@@ -88,12 +91,12 @@ export function SetupWizard() {
|
||||
onChanged={reloadState}
|
||||
/>
|
||||
|
||||
{BOUND.map(({ key, title, noun }) => (
|
||||
{BOUND.map(({ key, titleKey, nounKey }) => (
|
||||
<CategorySection
|
||||
key={key}
|
||||
category={key}
|
||||
title={title}
|
||||
noun={noun}
|
||||
title={t(titleKey)}
|
||||
noun={t(nounKey)}
|
||||
entries={catalog[key]}
|
||||
discoverableIds={catalog.discoverable}
|
||||
pushCapableIds={catalog.pushCapable}
|
||||
@@ -127,101 +130,84 @@ function CategorySection({
|
||||
assignments: Assignment[];
|
||||
onChanged: () => Promise<void> | void;
|
||||
}) {
|
||||
const [adding, setAdding] = useState(false);
|
||||
const [editingId, setEditingId] = useState<string | null>(null);
|
||||
const { t } = useTranslation();
|
||||
// The form is popped out in a Modal. `formFor` selects what it edits:
|
||||
// - "new" → the add form
|
||||
// - an Assignment → edit that device in place
|
||||
// - null → closed.
|
||||
const [formFor, setFormFor] = useState<Assignment | "new" | null>(null);
|
||||
const [warnings, setWarnings] = useState<string[]>([]);
|
||||
const editing = editingId ? assignments.find((a) => a.id === editingId) : undefined;
|
||||
// Show the add form for an empty category or an explicit "+ Add", but not while
|
||||
// editing an existing row (that row renders its own inline form).
|
||||
const showForm = !editing && (adding || assignments.length === 0);
|
||||
|
||||
// Binding categories need a controller to point at first.
|
||||
const isBound = category !== "access";
|
||||
const blockedNoController = isBound && controllers.length === 0;
|
||||
const editing = formFor && formFor !== "new" ? formFor : undefined;
|
||||
|
||||
return (
|
||||
<fieldset style={{ marginTop: "1rem" }}>
|
||||
<legend>{title}</legend>
|
||||
<fieldset className="card mt-4 p-4">
|
||||
<legend className="px-1 text-h6 font-semibold uppercase tracking-wider text-term-text">{title}</legend>
|
||||
|
||||
{warnings.length > 0 && (
|
||||
<div
|
||||
style={{
|
||||
margin: "0 0 0.75rem",
|
||||
padding: "0.5rem 0.75rem",
|
||||
background: "#fef3c7",
|
||||
border: "1px solid #f59e0b",
|
||||
borderRadius: 6,
|
||||
}}
|
||||
>
|
||||
<strong style={{ color: "#92400e" }}>⚠ Saved, but action needed:</strong>
|
||||
<ul style={{ margin: "0.25rem 0 0", paddingLeft: "1.25rem", color: "#92400e" }}>
|
||||
<div className="mb-3 rounded-term border border-term-amber/60 bg-term-amber/10 px-3 py-2">
|
||||
<strong className="text-[12px] text-term-amber">{t("setup.warnTitle")}</strong>
|
||||
<ul className="mt-1 list-disc pl-5 text-[12px] text-term-amber">
|
||||
{warnings.map((w, i) => (
|
||||
<li key={i}>{w}</li>
|
||||
))}
|
||||
</ul>
|
||||
<button type="button" onClick={() => setWarnings([])} style={{ marginTop: "0.5rem" }}>
|
||||
Dismiss
|
||||
<button type="button" className="btn btn-sm mt-2" onClick={() => setWarnings([])}>
|
||||
{t("setup.dismiss")}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{assignments.length > 0 && (
|
||||
<ul style={{ listStyle: "none", padding: 0, margin: "0 0 0.75rem" }}>
|
||||
{assignments.map((a) =>
|
||||
editingId === a.id ? (
|
||||
<li key={a.id} style={{ listStyle: "none", padding: 0 }}>
|
||||
<DeviceForm
|
||||
category={category}
|
||||
entries={entries}
|
||||
discoverableIds={discoverableIds}
|
||||
pushCapableIds={pushCapableIds}
|
||||
controllers={controllers}
|
||||
editing={a}
|
||||
onSaved={async (w) => {
|
||||
setWarnings(w);
|
||||
await onChanged();
|
||||
setEditingId(null);
|
||||
}}
|
||||
onCancel={() => setEditingId(null)}
|
||||
/>
|
||||
</li>
|
||||
) : (
|
||||
<AssignmentRow
|
||||
key={a.id}
|
||||
assignment={a}
|
||||
controllers={controllers}
|
||||
onChanged={onChanged}
|
||||
onEdit={() => {
|
||||
setAdding(false);
|
||||
setEditingId(a.id);
|
||||
}}
|
||||
/>
|
||||
),
|
||||
)}
|
||||
<ul className="mb-3 list-none p-0">
|
||||
{assignments.map((a) => (
|
||||
<AssignmentRow
|
||||
key={a.id}
|
||||
assignment={a}
|
||||
controllers={controllers}
|
||||
onChanged={onChanged}
|
||||
onEdit={() => setFormFor(a)}
|
||||
/>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
|
||||
{blockedNoController ? (
|
||||
<p style={{ color: "#b45309", margin: 0 }}>Add a controller first — a {noun} points at one of its relays.</p>
|
||||
) : editing ? null : showForm ? (
|
||||
<DeviceForm
|
||||
category={category}
|
||||
entries={entries}
|
||||
discoverableIds={discoverableIds}
|
||||
pushCapableIds={pushCapableIds}
|
||||
controllers={controllers}
|
||||
onSaved={async (w) => {
|
||||
setWarnings(w);
|
||||
await onChanged();
|
||||
setAdding(false);
|
||||
}}
|
||||
onCancel={assignments.length > 0 ? () => setAdding(false) : undefined}
|
||||
/>
|
||||
<p className="m-0 text-[12px] text-term-amber">{t("setup.needControllerFirst", { noun })}</p>
|
||||
) : (
|
||||
<button type="button" onClick={() => setAdding(true)}>
|
||||
+ Add another {noun}
|
||||
<button type="button" className="btn btn-sm" onClick={() => setFormFor("new")}>
|
||||
{assignments.length === 0 ? t("setup.add", { noun }) : t("setup.addAnother", { noun })}
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Add/edit form — popped out. One modal per category; the device list stays
|
||||
in the page behind it. */}
|
||||
<Modal
|
||||
open={formFor != null}
|
||||
onClose={() => setFormFor(null)}
|
||||
title={editing ? t("setup.editTitle", { noun }) : t("setup.addTitle", { noun })}
|
||||
width="max-w-2xl"
|
||||
>
|
||||
{formFor != null && (
|
||||
<DeviceForm
|
||||
category={category}
|
||||
entries={entries}
|
||||
discoverableIds={discoverableIds}
|
||||
pushCapableIds={pushCapableIds}
|
||||
controllers={controllers}
|
||||
editing={editing}
|
||||
onSaved={async (w) => {
|
||||
setWarnings(w);
|
||||
await onChanged();
|
||||
setFormFor(null);
|
||||
}}
|
||||
onCancel={() => setFormFor(null)}
|
||||
/>
|
||||
)}
|
||||
</Modal>
|
||||
</fieldset>
|
||||
);
|
||||
}
|
||||
@@ -237,6 +223,7 @@ function AssignmentRow({
|
||||
onChanged: () => Promise<void> | void;
|
||||
onEdit: () => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const [removing, setRemoving] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
@@ -244,7 +231,7 @@ function AssignmentRow({
|
||||
const host = typeof cfg.host === "string" ? cfg.host : null;
|
||||
|
||||
async function remove() {
|
||||
if (!confirm(`Remove this ${assignment.driverId} device?`)) return;
|
||||
if (!confirm(t("setup.confirmRemove", { driver: assignment.driverId }))) return;
|
||||
setRemoving(true);
|
||||
setError(null);
|
||||
try {
|
||||
@@ -257,26 +244,18 @@ function AssignmentRow({
|
||||
}
|
||||
|
||||
return (
|
||||
<li
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: "0.5rem",
|
||||
padding: "0.4rem 0.5rem",
|
||||
borderBottom: "1px solid #eee",
|
||||
}}
|
||||
>
|
||||
<strong>{assignment.driverId}</strong>
|
||||
{host && <span style={{ color: "#666" }}>{host}</span>}
|
||||
<li className="flex items-center gap-2 border-b border-term-border/60 px-1 py-2 text-[12px]">
|
||||
<strong className="text-term-text">{assignment.driverId}</strong>
|
||||
{host && <span className="tabular-nums text-term-muted">{host}</span>}
|
||||
<DeviceSummary assignment={assignment} controllers={controllers} />
|
||||
{!assignment.enabled && <span style={{ color: "#b45309" }}>(disabled)</span>}
|
||||
<span style={{ flex: 1 }} />
|
||||
{error && <span style={{ color: "crimson" }}>{error}</span>}
|
||||
<button type="button" onClick={onEdit} disabled={removing}>
|
||||
Edit
|
||||
{!assignment.enabled && <span className="text-term-amber">{t("setup.disabled")}</span>}
|
||||
<span className="flex-1" />
|
||||
{error && <span className="text-term-red">{error}</span>}
|
||||
<button type="button" className="btn btn-ghost btn-sm" onClick={onEdit} disabled={removing}>
|
||||
{t("setup.edit")}
|
||||
</button>
|
||||
<button type="button" onClick={remove} disabled={removing}>
|
||||
{removing ? "Removing…" : "Remove"}
|
||||
<button type="button" className="btn btn-danger btn-sm" onClick={remove} disabled={removing}>
|
||||
{removing ? t("setup.removing") : t("setup.remove")}
|
||||
</button>
|
||||
</li>
|
||||
);
|
||||
@@ -284,12 +263,13 @@ function AssignmentRow({
|
||||
|
||||
/** Inline summary of an assignment's direction/binding for the list. */
|
||||
function DeviceSummary({ assignment, controllers }: { assignment: Assignment; controllers: Assignment[] }) {
|
||||
const { t } = useTranslation();
|
||||
const cfg = assignment.config as Record<string, unknown>;
|
||||
if (assignment.category === "access") {
|
||||
const relays = Array.isArray(cfg.relays) ? (cfg.relays as RelaySpec[]) : [];
|
||||
if (relays.length === 0) return <em style={{ color: "#b45309" }}>no relays set</em>;
|
||||
if (relays.length === 0) return <em className="text-term-amber">{t("setup.noRelaysSet")}</em>;
|
||||
return (
|
||||
<span style={{ display: "flex", gap: "0.35rem" }}>
|
||||
<span className="flex gap-1.5">
|
||||
{relays.map((r) => (
|
||||
<DirectionBadge key={r.relay} direction={r.direction} label={`R${r.relay}${r.button ? `·btn${r.button}` : ""}`} />
|
||||
))}
|
||||
@@ -299,7 +279,7 @@ function DeviceSummary({ assignment, controllers }: { assignment: Assignment; co
|
||||
// Bound device: show controller + relay it points at, with inherited direction.
|
||||
const controllerId = typeof cfg.controllerId === "string" ? cfg.controllerId : null;
|
||||
const relay = typeof cfg.relay === "number" ? cfg.relay : null;
|
||||
if (!controllerId || relay == null) return <em style={{ color: "#b45309" }}>unbound</em>;
|
||||
if (!controllerId || relay == null) return <em className="text-term-amber">{t("setup.unbound")}</em>;
|
||||
const controller = controllers.find((c) => c.id === controllerId);
|
||||
const spec = controller
|
||||
? (((controller.config as Record<string, unknown>).relays as RelaySpec[]) ?? []).find((r) => r.relay === relay)
|
||||
@@ -333,6 +313,7 @@ function DeviceForm({
|
||||
onSaved: (warnings: string[]) => Promise<void> | void;
|
||||
onCancel?: () => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
// On edit the driver is fixed (you can't change what KIND of device a slot is —
|
||||
// that's a remove + re-add); pre-select it and lock the picker.
|
||||
const editCfg = editing?.config as Record<string, unknown> | undefined;
|
||||
@@ -500,15 +481,15 @@ function DeviceForm({
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{ padding: "0.5rem", background: "#fafafa", borderRadius: 6 }}>
|
||||
<div>
|
||||
{entries.length === 0 ? (
|
||||
<em>No drivers registered.</em>
|
||||
<em className="text-term-muted">{t("setup.noDrivers")}</em>
|
||||
) : (
|
||||
// Driver is locked when editing — changing the kind of device is a
|
||||
// remove + re-add, not an in-place edit.
|
||||
<select value={selectedId} onChange={(e) => selectDriver(e.target.value)} disabled={!!editing}>
|
||||
<select className="select w-auto min-w-64" value={selectedId} onChange={(e) => selectDriver(e.target.value)} disabled={!!editing}>
|
||||
<option value="" disabled>
|
||||
Choose a device…
|
||||
{t("setup.chooseDevice")}
|
||||
</option>
|
||||
{entries.map((e) => (
|
||||
<option key={e.id} value={e.id}>
|
||||
@@ -519,26 +500,26 @@ function DeviceForm({
|
||||
)}
|
||||
|
||||
{selected && (
|
||||
<div style={{ marginTop: "0.5rem" }}>
|
||||
<p style={{ margin: "0.25rem 0", color: "#555" }}>{selected.description}</p>
|
||||
<div className="mt-3">
|
||||
<p className="mb-2 text-[12px] text-term-muted">{selected.description}</p>
|
||||
|
||||
{canDiscover && (
|
||||
<div style={{ margin: "0.5rem 0", padding: "0.5rem", background: "#f3f4f6", borderRadius: 6 }}>
|
||||
<button type="button" onClick={scan} disabled={scanning}>
|
||||
{scanning ? "Scanning…" : "Scan for controllers"}
|
||||
<div className="my-2 rounded-term border border-term-border bg-term-bg p-2">
|
||||
<button type="button" className="btn btn-sm" onClick={scan} disabled={scanning}>
|
||||
{scanning ? t("setup.scanning") : t("setup.scan")}
|
||||
</button>
|
||||
{scanError && <span style={{ marginLeft: 8, color: "crimson" }}>{scanError}</span>}
|
||||
{found && found.length === 0 && <p style={{ margin: "0.5rem 0 0" }}>No controllers found on the LAN.</p>}
|
||||
{scanError && <span className="ml-2 text-[12px] text-term-red">{scanError}</span>}
|
||||
{found && found.length === 0 && <p className="mt-2 text-[12px] text-term-muted">{t("setup.noControllersFound")}</p>}
|
||||
{found && found.length > 0 && (
|
||||
<ul style={{ margin: "0.5rem 0 0", paddingLeft: "1rem" }}>
|
||||
<ul className="mt-2 list-none p-0">
|
||||
{found.map((d) => (
|
||||
<li key={d.id} style={{ margin: "0.25rem 0" }}>
|
||||
<button type="button" onClick={() => applyDiscovered(d)}>
|
||||
Use
|
||||
</button>{" "}
|
||||
<strong>{d.label}</strong>{" "}
|
||||
<li key={d.id} className="my-1 flex items-center gap-2 text-[12px]">
|
||||
<button type="button" className="btn btn-sm" onClick={() => applyDiscovered(d)}>
|
||||
{t("setup.use")}
|
||||
</button>
|
||||
<strong className="text-term-text">{d.label}</strong>
|
||||
<HealthBadge status={d.health.status} />
|
||||
{d.info?.firmware && <span style={{ color: "#666" }}> · fw {d.info.firmware}</span>}
|
||||
{d.info?.firmware && <span className="text-term-muted"> · fw {d.info.firmware}</span>}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
@@ -547,38 +528,40 @@ function DeviceForm({
|
||||
)}
|
||||
|
||||
{selected.configFields.map((f) => (
|
||||
<div key={f.key} style={{ margin: "0.25rem 0" }}>
|
||||
<label>
|
||||
<div key={f.key} className="field my-2 max-w-sm">
|
||||
<label className="label">
|
||||
{f.label}
|
||||
{f.required ? " *" : ""}{" "}
|
||||
{f.type === "select" ? (
|
||||
<select
|
||||
value={String(config[f.key] ?? (f.default as string | number | undefined) ?? "")}
|
||||
onChange={(e) => {
|
||||
const v = e.target.value;
|
||||
setConfig((c) => ({ ...c, [f.key]: v }));
|
||||
resetStatus();
|
||||
}}
|
||||
>
|
||||
{f.options?.map((o) => (
|
||||
<option key={o.value} value={o.value}>
|
||||
{o.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
) : (
|
||||
<input
|
||||
type={f.type === "secret" ? "password" : f.type === "number" || f.type === "port" ? "number" : "text"}
|
||||
value={config[f.key] ?? (f.default as string | number | undefined) ?? ""}
|
||||
placeholder={f.help}
|
||||
onChange={(e) => {
|
||||
const v = e.target.value;
|
||||
setConfig((c) => ({ ...c, [f.key]: v }));
|
||||
resetStatus();
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{f.required ? " *" : ""}
|
||||
</label>
|
||||
{f.type === "select" ? (
|
||||
<select
|
||||
className="select"
|
||||
value={String(config[f.key] ?? (f.default as string | number | undefined) ?? "")}
|
||||
onChange={(e) => {
|
||||
const v = e.target.value;
|
||||
setConfig((c) => ({ ...c, [f.key]: v }));
|
||||
resetStatus();
|
||||
}}
|
||||
>
|
||||
{f.options?.map((o) => (
|
||||
<option key={o.value} value={o.value}>
|
||||
{o.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
) : (
|
||||
<input
|
||||
className="input"
|
||||
type={f.type === "secret" ? "password" : f.type === "number" || f.type === "port" ? "number" : "text"}
|
||||
value={config[f.key] ?? (f.default as string | number | undefined) ?? ""}
|
||||
placeholder={f.help}
|
||||
onChange={(e) => {
|
||||
const v = e.target.value;
|
||||
setConfig((c) => ({ ...c, [f.key]: v }));
|
||||
resetStatus();
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
|
||||
@@ -600,34 +583,34 @@ function DeviceForm({
|
||||
)}
|
||||
|
||||
{/* Test (no save/no device change) then Save (configures + persists). */}
|
||||
<div style={{ marginTop: "0.75rem", display: "flex", gap: "0.5rem", alignItems: "center" }}>
|
||||
<button type="button" onClick={test} disabled={testing}>
|
||||
{testing ? "Testing…" : "Test connection"}
|
||||
<div className="mt-3 flex items-center gap-2">
|
||||
<button type="button" className="btn btn-sm" onClick={test} disabled={testing}>
|
||||
{testing ? t("setup.testing") : t("setup.test")}
|
||||
</button>
|
||||
<button type="button" onClick={save} disabled={saving}>
|
||||
{saving ? "Saving…" : editing ? "Save changes" : "Save & configure"}
|
||||
<button type="button" className="btn btn-primary btn-sm" onClick={save} disabled={saving}>
|
||||
{saving ? t("setup.saving") : editing ? t("setup.saveChanges") : t("setup.saveConfigure")}
|
||||
</button>
|
||||
{onCancel && (
|
||||
<button type="button" onClick={onCancel} disabled={saving}>
|
||||
Cancel
|
||||
<button type="button" className="btn btn-ghost btn-sm" onClick={onCancel} disabled={saving}>
|
||||
{t("setup.cancel")}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{testError && <p style={{ color: "crimson", margin: "0.5rem 0 0" }}>Test failed: {testError}</p>}
|
||||
{testError && <p className="mt-2 text-[12px] text-term-red">{t("setup.testFailed", { error: testError })}</p>}
|
||||
{tested && (
|
||||
<div style={{ margin: "0.5rem 0 0" }}>
|
||||
<div>
|
||||
Device: <HealthBadge status={tested.health.status} />
|
||||
{tested.health.detail && <span style={{ color: "#666" }}> — {tested.health.detail}</span>}
|
||||
<div className="mt-2 text-[12px]">
|
||||
<div className="text-term-text">
|
||||
{t("setup.deviceLabel")} <HealthBadge status={tested.health.status} />
|
||||
{tested.health.detail && <span className="text-term-muted"> — {tested.health.detail}</span>}
|
||||
</div>
|
||||
{tested.preconditions.ok ? (
|
||||
<div style={{ color: "#16a34a" }}>● preconditions OK</div>
|
||||
<div className="text-term-green">{t("setup.preconditionsOk")}</div>
|
||||
) : (
|
||||
tested.preconditions.issues.map((i) => (
|
||||
<div key={i.key} style={{ color: "#d97706" }}>
|
||||
<div key={i.key} className="text-term-amber">
|
||||
⚠ {i.message}
|
||||
{i.fixable && <span style={{ color: "#666" }}> (auto-fixed on save)</span>}
|
||||
{i.fixable && <span className="text-term-muted"> {t("setup.autoFixedOnSave")}</span>}
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
@@ -635,33 +618,29 @@ function DeviceForm({
|
||||
)}
|
||||
|
||||
{backendIps && backendIps.length > 0 && (
|
||||
<div style={{ margin: "0.5rem 0 0" }}>
|
||||
<label>
|
||||
Backend push IP{" "}
|
||||
<select value={backendIp} onChange={(e) => setBackendIp(e.target.value)}>
|
||||
<div className="mt-3">
|
||||
<div className="field max-w-md">
|
||||
<label className="label">{t("setup.backendPushIp")}</label>
|
||||
<select className="select" value={backendIp} onChange={(e) => setBackendIp(e.target.value)}>
|
||||
{!backendIps.some((c) => c.onDeviceSubnet) && (
|
||||
<option value="" disabled>
|
||||
Choose an address…
|
||||
{t("setup.chooseAddress")}
|
||||
</option>
|
||||
)}
|
||||
{backendIps.map((c) => (
|
||||
<option key={c.ip} value={c.ip}>
|
||||
{c.ip} ({c.iface}){c.onDeviceSubnet ? " — on device subnet" : ""}
|
||||
{c.ip} ({c.iface}){c.onDeviceSubnet ? ` ${t("setup.onDeviceSubnet")}` : ""}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
{!backendIps.some((c) => c.onDeviceSubnet) && (
|
||||
<span style={{ marginLeft: 8, color: "#d97706" }}>
|
||||
⚠ no NIC on the device's subnet — the device may not reach the backend
|
||||
</span>
|
||||
<span className="text-[12px] text-term-amber">{t("setup.noNicOnSubnet")}</span>
|
||||
)}
|
||||
<p style={{ margin: "0.25rem 0 0", color: "#666", fontSize: "0.85em" }}>
|
||||
The address this device will POST input events to.
|
||||
</p>
|
||||
<p className="hint mt-1">{t("setup.backendIpHint")}</p>
|
||||
</div>
|
||||
)}
|
||||
{saveError && <p style={{ color: "crimson", margin: "0.5rem 0 0" }}>Save failed: {saveError}</p>}
|
||||
{saveError && <p className="mt-2 text-[12px] text-term-red">{t("setup.saveFailed", { error: saveError })}</p>}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -671,6 +650,7 @@ function DeviceForm({
|
||||
/** Controller relay map editor: each row = a relay + its direction + (optional)
|
||||
* the input terminal its entry button is wired to. */
|
||||
function RelayEditor({ relays, onChange }: { relays: RelaySpec[]; onChange: (r: RelaySpec[]) => void }) {
|
||||
const { t } = useTranslation();
|
||||
function update(i: number, patch: Partial<RelaySpec>) {
|
||||
onChange(relays.map((r, idx) => (idx === i ? { ...r, ...patch } : r)));
|
||||
}
|
||||
@@ -683,53 +663,50 @@ function RelayEditor({ relays, onChange }: { relays: RelaySpec[]; onChange: (r:
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{ margin: "0.5rem 0", padding: "0.5rem", background: "#f3f4f6", borderRadius: 6 }}>
|
||||
<strong style={{ fontSize: "0.9em" }}>Relays on this controller</strong>
|
||||
<p style={{ margin: "0.15rem 0 0.5rem", color: "#666", fontSize: "0.8em" }}>
|
||||
Each relay opens one barrier. Set its direction; for transient entry, set which input
|
||||
terminal the entry button is wired to.
|
||||
</p>
|
||||
<div className="my-2 rounded-term border border-term-border bg-term-bg p-2">
|
||||
<strong className="text-[12px] uppercase tracking-wider text-term-text">{t("setup.relaysTitle")}</strong>
|
||||
<p className="hint mt-0.5 mb-2">{t("setup.relaysHint")}</p>
|
||||
{relays.map((r, i) => (
|
||||
<div key={i} style={{ display: "flex", gap: "0.5rem", alignItems: "center", margin: "0.25rem 0" }}>
|
||||
<label>
|
||||
Relay{" "}
|
||||
<div key={i} className="my-1 flex flex-wrap items-center gap-2">
|
||||
<label className="inline-flex items-center gap-1.5 text-[12px] text-term-muted">
|
||||
{t("setup.relay")}
|
||||
<input
|
||||
type="number"
|
||||
min={1}
|
||||
value={r.relay}
|
||||
style={{ width: "3.5rem" }}
|
||||
className="input input-sm w-16"
|
||||
onChange={(e) => update(i, { relay: Number(e.target.value) })}
|
||||
/>
|
||||
</label>
|
||||
<select value={r.direction} onChange={(e) => update(i, { direction: e.target.value as Direction })}>
|
||||
<select className="select input-sm w-auto" value={r.direction} onChange={(e) => update(i, { direction: e.target.value as Direction })}>
|
||||
{(["entry", "exit", "both"] as Direction[]).map((d) => (
|
||||
<option key={d} value={d}>
|
||||
{DIRECTION_LABELS[d]}
|
||||
{t(DIRECTION_KEYS[d])}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
{(r.direction === "entry" || r.direction === "both") && (
|
||||
<label>
|
||||
Entry button on terminal{" "}
|
||||
<label className="inline-flex items-center gap-1.5 text-[12px] text-term-muted">
|
||||
{t("setup.entryButtonTerminal")}
|
||||
<input
|
||||
type="number"
|
||||
min={1}
|
||||
value={r.button ?? ""}
|
||||
placeholder="—"
|
||||
style={{ width: "3.5rem" }}
|
||||
className="input input-sm w-16"
|
||||
onChange={(e) => update(i, { button: e.target.value === "" ? undefined : Number(e.target.value) })}
|
||||
/>
|
||||
</label>
|
||||
)}
|
||||
{relays.length > 1 && (
|
||||
<button type="button" onClick={() => remove(i)}>
|
||||
<button type="button" className="btn btn-ghost btn-sm" onClick={() => remove(i)}>
|
||||
✕
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
<button type="button" onClick={add} style={{ marginTop: "0.25rem" }}>
|
||||
+ Add relay
|
||||
<button type="button" className="btn btn-sm mt-1" onClick={add}>
|
||||
{t("setup.addRelay")}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
@@ -750,6 +727,7 @@ function BindingPicker({
|
||||
onControllerChange: (id: string) => void;
|
||||
onRelayChange: (relay: number) => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const controller = controllers.find((c) => c.id === controllerId);
|
||||
const relays: RelaySpec[] = controller
|
||||
? (((controller.config as Record<string, unknown>).relays as RelaySpec[]) ?? [])
|
||||
@@ -757,14 +735,14 @@ function BindingPicker({
|
||||
const chosen = relays.find((r) => r.relay === relay);
|
||||
|
||||
return (
|
||||
<div style={{ margin: "0.5rem 0", padding: "0.5rem", background: "#f3f4f6", borderRadius: 6 }}>
|
||||
<strong style={{ fontSize: "0.9em" }}>Which barrier does this device serve?</strong>
|
||||
<div style={{ display: "flex", gap: "0.5rem", alignItems: "center", marginTop: "0.35rem", flexWrap: "wrap" }}>
|
||||
<label>
|
||||
Controller{" "}
|
||||
<select value={controllerId} onChange={(e) => onControllerChange(e.target.value)}>
|
||||
<div className="my-2 rounded-term border border-term-border bg-term-bg p-2">
|
||||
<strong className="text-[12px] uppercase tracking-wider text-term-text">{t("setup.whichBarrier")}</strong>
|
||||
<div className="mt-1.5 flex flex-wrap items-center gap-2">
|
||||
<label className="inline-flex items-center gap-1.5 text-[12px] text-term-muted">
|
||||
{t("setup.controller")}
|
||||
<select className="select input-sm w-auto" value={controllerId} onChange={(e) => onControllerChange(e.target.value)}>
|
||||
<option value="" disabled>
|
||||
Choose…
|
||||
{t("setup.choose")}
|
||||
</option>
|
||||
{controllers.map((c) => {
|
||||
const host = (c.config as Record<string, unknown>).host;
|
||||
@@ -777,53 +755,49 @@ function BindingPicker({
|
||||
})}
|
||||
</select>
|
||||
</label>
|
||||
<label>
|
||||
Relay{" "}
|
||||
<label className="inline-flex items-center gap-1.5 text-[12px] text-term-muted">
|
||||
{t("setup.relay")}
|
||||
<select
|
||||
className="select input-sm w-auto"
|
||||
value={relay === "" ? "" : String(relay)}
|
||||
disabled={!controller}
|
||||
onChange={(e) => onRelayChange(Number(e.target.value))}
|
||||
>
|
||||
<option value="" disabled>
|
||||
Choose…
|
||||
{t("setup.choose")}
|
||||
</option>
|
||||
{relays.map((r) => (
|
||||
<option key={r.relay} value={r.relay}>
|
||||
Relay {r.relay} ({DIRECTION_LABELS[r.direction]})
|
||||
{t("setup.relayLabel", { relay: r.relay, direction: t(DIRECTION_KEYS[r.direction]) })}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
{chosen && <DirectionBadge direction={chosen.direction} label={`inherits ${chosen.direction}`} />}
|
||||
{chosen && <DirectionBadge direction={chosen.direction} label={t("setup.inherits", { direction: t(DIRECTION_KEYS[chosen.direction]) })} />}
|
||||
</div>
|
||||
{controller && relays.length === 0 && (
|
||||
<p style={{ margin: "0.35rem 0 0", color: "#b45309", fontSize: "0.85em" }}>
|
||||
This controller has no relays configured.
|
||||
</p>
|
||||
<p className="mt-1.5 text-[12px] text-term-amber">{t("setup.noRelaysConfigured")}</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function DirectionBadge({ direction, label }: { direction: Direction; label?: string }) {
|
||||
const color = direction === "entry" ? "#15803d" : direction === "exit" ? "#b45309" : "#6b7280";
|
||||
// entry=green, exit=amber, both=muted — aligned to the terminal accent palette.
|
||||
const cls =
|
||||
direction === "entry"
|
||||
? "border-term-green text-term-green"
|
||||
: direction === "exit"
|
||||
? "border-term-amber text-term-amber"
|
||||
: "border-term-muted text-term-muted";
|
||||
return (
|
||||
<span
|
||||
style={{
|
||||
color,
|
||||
border: `1px solid ${color}`,
|
||||
borderRadius: 4,
|
||||
padding: "0 0.35rem",
|
||||
fontSize: "0.75em",
|
||||
fontWeight: 600,
|
||||
}}
|
||||
>
|
||||
<span className={`rounded-term border px-1.5 text-[10px] font-semibold uppercase tracking-wider ${cls}`}>
|
||||
{label ?? direction}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function HealthBadge({ status }: { status: string }) {
|
||||
const color = status === "ready" ? "#16a34a" : status === "degraded" ? "#d97706" : "#dc2626";
|
||||
return <span style={{ color, fontWeight: 600 }}>● {status}</span>;
|
||||
const cls = status === "ready" ? "text-term-green" : status === "degraded" ? "text-term-amber" : "text-term-red";
|
||||
return <span className={`font-semibold ${cls}`}>● {status}</span>;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user