feat(editor): F4.5a the device sheet for PCs, notebooks, switches and modems

Measured first: the new oracle SheetRun types 77 texts into FILIUS' own
settings panels and records what was marked red, what the device took and
what the field showed afterwards. Java's two judgements disagree (red text
taken, black text refused, any int as a modem port, a refused text dropped
without a word); here one rule decides both (packages/doc fields.ts), a
refused text stays marked in the field, and only what Java's model accepts
reaches the document.

- doc: settings.ts (name, label policy, addresses, IP forwarding, DHCP
  stopping the own DHCP server, LAN/WLAN with the wireless connection,
  switch SSID dropping its wireless hosts, retention time, cloud, modem
  port as its pair), sheet.ts (what the sheet shows)
- fls: modemPair/modemPort in one place
- editor: DeviceSheet beside the surface or below it on a phone, opened
  with the selection; every change one undo step
- not adopted from Java: the name overwritten by the MAC address, the
  access point search depending on unrelated devices

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-03 11:22:28 +00:00
co-authored by Claude Opus 5.5
parent 806df6baca
commit 9b7ec2fc72
25 changed files with 2667 additions and 73 deletions
+229
View File
@@ -0,0 +1,229 @@
// The device sheet (plan §7.1): the settings of the selected device, the
// fields of FILIUS' settings panel of its kind (JHostKonfiguration,
// JSwitchKonfiguration, JModemKonfiguration) in the panels' order. Java
// shows the panel when a device is chosen; so does the sheet, beside the
// surface or below it on a phone. Each change is one step of the person's
// undo. A router's and a home router's sheet name the device only, until
// their own panels follow (F4.5b).
import { useEffect, useState } from 'react';
import type * as Y from 'yjs';
import {
deviceSheet,
renameDevice,
setDhcpClient,
setHostAddress,
setHostLabels,
setHostWireless,
setIpForwarding,
setModemPort,
setSwitchCloud,
setSwitchRetention,
setSwitchSsid,
type CheckedField,
type DeviceSheet as Sheet,
type HostSheet,
type ModemSheet,
type SwitchSheet,
type WirelessError,
} from '@mdg-filius/doc';
import { iconOf } from '../canvas/layout.js';
import { ICON_URLS } from '../canvas/icons.js';
import { useTranslate } from '../i18n/i18n.js';
import { SheetCheck, SheetField } from './SheetField.js';
import './sheet.css';
/** The device's sheet, read anew after every change of the document. */
function useDeviceSheet(doc: Y.Doc, id: string): Sheet | undefined {
const [sheet, setSheet] = useState(() => deviceSheet(doc, id));
useEffect(() => {
setSheet(deviceSheet(doc, id));
const update = () => setSheet(deviceSheet(doc, id));
doc.on('update', update);
return () => doc.off('update', update);
}, [doc, id]);
return sheet;
}
export interface DeviceSheetProps {
readonly doc: Y.Doc;
readonly id: string;
/** What the surface writes under the icon (a host's address or MAC address, when it shows them). */
readonly label: string;
readonly origin: object;
/** After each change: ends the undo step. */
readonly onChanged: () => void;
readonly onMessage: (message: string) => void;
readonly onClose: () => void;
}
interface Change {
readonly doc: Y.Doc;
readonly id: string;
readonly origin: object;
/** Runs a change and ends the undo step. */
readonly run: <T>(change: () => T) => T;
}
export function DeviceSheet({ doc, id, label, origin, onChanged, onMessage, onClose }: DeviceSheetProps) {
const t = useTranslate();
const sheet = useDeviceSheet(doc, id);
if (sheet === undefined) return null;
const change: Change = {
doc,
id,
origin,
run: (each) => {
const result = each();
onChanged();
return result;
},
};
const title = t(`device.${sheet.kind}`);
return (
<aside className="device-sheet" aria-label={t('sheet.title')} data-device={id}>
<header>
<img src={ICON_URLS[iconOf(sheet.kind, sheet.kind === 'networkSwitch' && sheet.cloud)]} alt="" />
<h2>{title}</h2>
<button type="button" className="sheet-close" onClick={onClose} aria-label={t('sheet.close')} title={t('sheet.close')}>
×
</button>
</header>
{/* A field per device: a text typed for one device is not offered to the next. */}
<div key={id}>
{sheet.kind === 'pc' || sheet.kind === 'notebook' ? (
<HostFields sheet={sheet} label={label} change={change} onMessage={onMessage} />
) : sheet.kind === 'networkSwitch' ? (
<SwitchFields sheet={sheet} change={change} />
) : sheet.kind === 'remoteLink' ? (
<ModemFields sheet={sheet} change={change} />
) : (
<NameField name={sheet.name} change={change} />
)}
</div>
</aside>
);
}
function NameField({ name, change, shown, disabled = false }: { readonly name: string; readonly change: Change; readonly shown?: string; readonly disabled?: boolean }) {
const t = useTranslate();
return (
<SheetField
name="name"
label={t('sheet.name')}
value={shown ?? name}
rule="name"
disabled={disabled}
onCommit={(text) => change.run(() => (renameDevice(change.doc, change.id, text, change.origin), { value: text }))}
/>
);
}
function HostFields({ sheet, label, change, onMessage }: { readonly sheet: HostSheet; readonly label: string; readonly change: Change; readonly onMessage: (message: string) => void }) {
const t = useTranslate();
const address =
(field: 'ipAddress' | 'subnetMask' | 'defaultGateway' | 'dnsServer') =>
(text: string): CheckedField =>
change.run(() => setHostAddress(change.doc, change.id, field, text, change.origin));
// Java's list offers the switches' networks; an SSID none of them has shows as "please choose" (JHostKonfiguration.updateSsid).
const chosen = sheet.networks.includes(sheet.ssid) ? sheet.ssid : '';
const wireless = (on: boolean, ssid: string) => {
const result = change.run(() => setHostWireless(change.doc, change.id, on, ssid, crypto.randomUUID(), change.origin));
if ('error' in result) onMessage(wirelessMessage(t, result.error));
};
const labelled = sheet.labels.ipAddress || sheet.labels.macAddress;
return (
<>
{/* Java's name field shows what the surface writes, and is closed while that is the address. */}
<NameField name={sheet.name} shown={labelled ? label : sheet.name} disabled={labelled} change={change} />
<SheetField name="macAddress" label={t('sheet.macAddress')} value={sheet.macAddress} rule="name" readOnly onCommit={() => ({ invalid: true })} />
<SheetField name="ipAddress" label={t('sheet.ipAddress')} value={sheet.ipAddress} rule="ipAddress" inputMode="decimal" disabled={sheet.dhcpClient} onCommit={address('ipAddress')} />
<SheetField name="subnetMask" label={t('sheet.subnetMask')} value={sheet.subnetMask} rule="subnetMask" inputMode="decimal" disabled={sheet.dhcpClient} onCommit={address('subnetMask')} />
<SheetField
name="defaultGateway"
label={t('sheet.defaultGateway')}
value={sheet.defaultGateway}
rule="ipAddressOrEmpty"
inputMode="decimal"
disabled={sheet.dhcpClient}
onCommit={address('defaultGateway')}
/>
<SheetField name="dnsServer" label={t('sheet.dnsServer')} value={sheet.dnsServer} rule="ipAddressOrEmpty" inputMode="decimal" disabled={sheet.dhcpClient} onCommit={address('dnsServer')} />
<fieldset className="sheet-group" role="radiogroup" aria-label={t('sheet.connection')}>
<label className="sheet-check">
<input type="radio" name="connection" value="cable" checked={!sheet.wireless} onChange={() => wireless(false, chosen)} />
{t('sheet.cable')}
</label>
<label className={sheet.wirelessPossible ? 'sheet-check' : 'sheet-check disabled'}>
<input type="radio" name="connection" value="wireless" checked={sheet.wireless} disabled={!sheet.wirelessPossible} onChange={() => wireless(true, chosen)} />
{t('sheet.wireless')}
</label>
</fieldset>
<label className="sheet-field">
<span>{t('sheet.network')}</span>
<select name="ssid" value={chosen} disabled={!sheet.wireless} onChange={(event) => wireless(sheet.wireless, event.target.value)}>
<option value="">{t('sheet.chooseNetwork')}</option>
{sheet.networks.map((network) => (
<option key={network} value={network}>
{network}
</option>
))}
</select>
</label>
<SheetCheck
name="ipAsName"
label={t('sheet.ipAsName')}
checked={sheet.labels.ipAddress}
onChange={(on) => change.run(() => setHostLabels(change.doc, change.id, { ...sheet.labels, ipAddress: on }, change.origin))}
/>
<SheetCheck
name="macAsName"
label={t('sheet.macAsName')}
checked={sheet.labels.macAddress}
onChange={(on) => change.run(() => setHostLabels(change.doc, change.id, { ...sheet.labels, macAddress: on }, change.origin))}
/>
<SheetCheck name="ipForwarding" label={t('sheet.ipForwarding')} checked={sheet.ipForwarding} onChange={(on) => change.run(() => setIpForwarding(change.doc, change.id, on, change.origin))} />
<SheetCheck name="dhcpClient" label={t('sheet.dhcpClient')} checked={sheet.dhcpClient} onChange={(on) => change.run(() => setDhcpClient(change.doc, change.id, on, change.origin))} />
</>
);
}
function wirelessMessage(t: ReturnType<typeof useTranslate>, error: WirelessError): string {
return error === 'cablePlugged' ? t('sheet.cablePlugged') : t(`cable.${error}`);
}
function SwitchFields({ sheet, change }: { readonly sheet: SwitchSheet; readonly change: Change }) {
const t = useTranslate();
return (
<>
<NameField name={sheet.name} change={change} />
<SheetField name="ssid" label={t('sheet.switchSsid')} value={sheet.ssid} rule="ssid" onCommit={(text) => change.run(() => setSwitchSsid(change.doc, change.id, text, change.origin))} />
<SheetCheck name="cloud" label={t('sheet.cloud')} checked={sheet.cloud} onChange={(on) => change.run(() => setSwitchCloud(change.doc, change.id, on, change.origin))} />
<SheetField
name="retention"
label={t('sheet.retention')}
value={String(sheet.retentionSeconds)}
rule="seconds"
inputMode="numeric"
onCommit={(text) => change.run(() => setSwitchRetention(change.doc, change.id, text, change.origin))}
/>
</>
);
}
function ModemFields({ sheet, change }: { readonly sheet: ModemSheet; readonly change: Change }) {
const t = useTranslate();
return (
<>
<NameField name={sheet.name} change={change} />
<SheetField
name="port"
label={t('sheet.port')}
value={String(sheet.port)}
rule="port"
inputMode="numeric"
onCommit={(text) => change.run(() => setModemPort(change.doc, change.id, text, change.origin))}
/>
</>
);
}