feat(editor): F4.5b the device sheet for routers and home routers
Measured first: SheetRun now types into FILIUS' router and home router
panels, records five forwarding tables (holeTabelle) and steps in the
panel's table. Java turns a card address that passes the pattern but not
IP.ipCheck (".0.0.1") into null and the router panel throws; an empty
gateway is marked red yet taken; a refused table cell is stored as null;
a home router's DHCP client leaves its DHCP server running. None of the
nulls is adopted. The core's routeTableRows matches all five tables.
- doc: card addresses (the first card mirrored in the device's settings),
router gateway, RIP, manual routes (a new row once all four cells pass,
a cell, removal); the sheet reads cards with what their cable joins and
the whole table
- editor: Java's tabs (general, each card titled by its address, the
forwarding table greyed out under RIP; general, LAN, WAN on a home
router); the open card's cable drawn active in Java's green
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -78,6 +78,8 @@ export function Editor({ doc }: { readonly doc: Y.Doc }) {
|
||||
const [menu, setMenu] = useState<{ readonly target: Selection; readonly at: Point } | null>(null);
|
||||
const [routerAt, setRouterAt] = useState<Point | null>(null);
|
||||
const [message, setMessage] = useState<string | null>(null);
|
||||
// The cable of the card whose tab is open in the sheet (Java's Kabel.setAktiv).
|
||||
const [highlightedLink, setHighlightedLink] = useState<string | null>(null);
|
||||
|
||||
// A message goes after a while.
|
||||
useEffect(() => {
|
||||
@@ -203,6 +205,7 @@ export function Editor({ doc }: { readonly doc: Y.Doc }) {
|
||||
onMoveEnd={() => manager?.stopCapturing()}
|
||||
onCableStart={setCableFrom}
|
||||
onCable={onCable}
|
||||
highlightedLink={highlightedLink}
|
||||
onMenu={(target, at) => {
|
||||
setSelection(target);
|
||||
setMenu({ target, at });
|
||||
@@ -247,7 +250,16 @@ export function Editor({ doc }: { readonly doc: Y.Doc }) {
|
||||
</div>
|
||||
{/* Java shows the settings of the device that is chosen; closing them lets go of it, as a click beside it does. */}
|
||||
{selected === undefined ? null : (
|
||||
<DeviceSheet doc={doc} id={selected.id} label={selected.label} origin={origin} onChanged={() => manager?.stopCapturing()} onMessage={setMessage} onClose={() => setSelection(null)} />
|
||||
<DeviceSheet
|
||||
doc={doc}
|
||||
id={selected.id}
|
||||
label={selected.label}
|
||||
origin={origin}
|
||||
onChanged={() => manager?.stopCapturing()}
|
||||
onMessage={setMessage}
|
||||
onHighlight={setHighlightedLink}
|
||||
onClose={() => setSelection(null)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -54,6 +54,8 @@ export interface CanvasProps {
|
||||
readonly onCableStart?: (from: { readonly id: string; readonly side: Side } | null) => void;
|
||||
readonly onCable?: (from: { readonly id: string; readonly side: Side }, to: { readonly id: string; readonly side: Side }) => void;
|
||||
readonly onMenu?: (target: Selection, at: Point) => void;
|
||||
/** A cable drawn active, as Java draws the cable of the card whose tab is open (Kabel.setAktiv). */
|
||||
readonly highlightedLink?: string | null;
|
||||
}
|
||||
|
||||
/** The label's width in Java's font, measured once per text. */
|
||||
@@ -74,8 +76,7 @@ function useTextWidth(): (text: string) => number {
|
||||
);
|
||||
}
|
||||
|
||||
const rgba = (color: DocumentationView['color']) =>
|
||||
`rgba(${Math.round(color.red * 255)}, ${Math.round(color.green * 255)}, ${Math.round(color.blue * 255)}, ${color.alpha})`;
|
||||
const rgba = (color: DocumentationView['color']) => `rgba(${Math.round(color.red * 255)}, ${Math.round(color.green * 255)}, ${Math.round(color.blue * 255)}, ${color.alpha})`;
|
||||
|
||||
function Documentation({ item }: { readonly item: DocumentationView }) {
|
||||
if (item.kind === 'rectangle') {
|
||||
@@ -140,7 +141,7 @@ type Gesture =
|
||||
| { readonly kind: 'link'; readonly id: string; readonly start: Point; moved: boolean; longPressed: boolean };
|
||||
|
||||
export const Canvas = forwardRef<CanvasHandle, CanvasProps>(function Canvas(
|
||||
{ view, tool = 'select', selection = null, cableFrom = null, onSelect, onMoveDevice, onMoveEnd, onCableStart, onCable, onMenu },
|
||||
{ view, tool = 'select', selection = null, cableFrom = null, onSelect, onMoveDevice, onMoveEnd, onCableStart, onCable, onMenu, highlightedLink = null },
|
||||
handle,
|
||||
) {
|
||||
const t = useTranslate();
|
||||
@@ -420,14 +421,16 @@ export const Canvas = forwardRef<CanvasHandle, CanvasProps>(function Canvas(
|
||||
if (source === undefined || target === undefined) return null;
|
||||
const d = cablePath(source.center, target.center);
|
||||
const selected = selection?.kind === 'link' && selection.id === link.id;
|
||||
const active = highlightedLink === link.id;
|
||||
return (
|
||||
<g key={link.id}>
|
||||
<path
|
||||
data-link={link.id}
|
||||
data-selected={selected ? 'true' : undefined}
|
||||
data-active={active ? 'true' : undefined}
|
||||
d={d}
|
||||
strokeWidth={selected ? 4 : 2}
|
||||
stroke={selected ? 'var(--accent)' : link.wireless ? 'var(--canvas-cable-wireless)' : 'var(--canvas-cable)'}
|
||||
stroke={selected ? 'var(--accent)' : active ? 'var(--canvas-cable-active)' : link.wireless ? 'var(--canvas-cable-wireless)' : 'var(--canvas-cable)'}
|
||||
strokeDasharray={link.wireless ? '10' : undefined}
|
||||
/>
|
||||
{/* A wide, invisible line to tap: 2 px is no target for a finger. */}
|
||||
|
||||
@@ -30,19 +30,28 @@
|
||||
"menu.delete": "Löschen",
|
||||
"menu.removeAllCables": "Alle Verbindungen entfernen",
|
||||
"menu.removeCable": "Verbindung entfernen",
|
||||
"route.destinationNetwork": "Ziel",
|
||||
"route.gateway": "Nächstes Gateway",
|
||||
"route.interfaceIPAddress": "Über Schnittstelle",
|
||||
"route.subnetMask": "Netzmaske",
|
||||
"router.cancel": "Abbrechen",
|
||||
"router.prompt": "Wählen Sie die Anzahl der Schnittstellen des Vermittlungsrechners aus!",
|
||||
"router.title": "Anzahl Schnittstellen",
|
||||
"sheet.cable": "Kabelgebunden (LAN)",
|
||||
"sheet.cablePlugged": "Das Kabel ist noch eingesteckt.",
|
||||
"sheet.cards": "Netzwerkkarten",
|
||||
"sheet.chooseNetwork": "Bitte auswählen",
|
||||
"sheet.close": "Schließen",
|
||||
"sheet.cloud": "als Wolke darstellen",
|
||||
"sheet.connectedTo": "Verbunden mit {device}",
|
||||
"sheet.connection": "Anschluss",
|
||||
"sheet.defaultGateway": "Gateway",
|
||||
"sheet.deleteEntry": "Eintrag löschen",
|
||||
"sheet.dhcpClient": "DHCP zur Konfiguration verwenden",
|
||||
"sheet.dnsServer": "Domain Name Server",
|
||||
"sheet.empty": "(leer)",
|
||||
"sheet.forwardingTable": "Weiterleitungstabelle",
|
||||
"sheet.general": "Allgemein",
|
||||
"sheet.invalidValue": "Dieser Wert ist ungültig.",
|
||||
"sheet.ipAddress": "IP-Adresse",
|
||||
"sheet.ipAsName": "IP-Adresse als Name verwenden",
|
||||
@@ -51,9 +60,15 @@
|
||||
"sheet.macAsName": "MAC-Adresse als Name verwenden",
|
||||
"sheet.name": "Name",
|
||||
"sheet.network": "Drahtloses Netzwerk (SSID)",
|
||||
"sheet.newEntry": "Neuer Eintrag",
|
||||
"sheet.newEntryHint": "Die Zeile wird übernommen, sobald alle vier Felder gültig sind.",
|
||||
"sheet.nic": "Netzwerkkarte {number}",
|
||||
"sheet.notConnected": "Keine Verbindung",
|
||||
"sheet.notTaken": "Nicht übernommen – es gilt weiterhin {value}.",
|
||||
"sheet.port": "Port",
|
||||
"sheet.retention": "Gültigkeitsdauer SAT-Einträge (in Sek.)",
|
||||
"sheet.rip": "Automatisches Routing",
|
||||
"sheet.showAllEntries": "Alle Einträge anzeigen",
|
||||
"sheet.subnetMask": "Netzmaske",
|
||||
"sheet.switchSsid": "Name WLAN (SSID)",
|
||||
"sheet.title": "Einstellungen",
|
||||
|
||||
@@ -30,19 +30,28 @@
|
||||
"menu.delete": "Delete",
|
||||
"menu.removeAllCables": "Remove all cables",
|
||||
"menu.removeCable": "Remove cable",
|
||||
"route.destinationNetwork": "Destination",
|
||||
"route.gateway": "Next gateway",
|
||||
"route.interfaceIPAddress": "NIC",
|
||||
"route.subnetMask": "Netmask",
|
||||
"router.cancel": "Cancel",
|
||||
"router.prompt": "Choose number of NIC of the router!",
|
||||
"router.title": "Number of NIC",
|
||||
"sheet.cable": "Cable (LAN)",
|
||||
"sheet.cablePlugged": "The cable is still plugged in.",
|
||||
"sheet.cards": "Network cards",
|
||||
"sheet.chooseNetwork": "Please select",
|
||||
"sheet.close": "Close",
|
||||
"sheet.cloud": "Display as Cloud",
|
||||
"sheet.connectedTo": "Connected to {device}",
|
||||
"sheet.connection": "Connection",
|
||||
"sheet.defaultGateway": "Gateway",
|
||||
"sheet.deleteEntry": "Delete entry",
|
||||
"sheet.dhcpClient": "Use DHCP for configuration",
|
||||
"sheet.dnsServer": "Domain Name Server",
|
||||
"sheet.empty": "(empty)",
|
||||
"sheet.forwardingTable": "Forwarding table",
|
||||
"sheet.general": "General",
|
||||
"sheet.invalidValue": "This value is invalid.",
|
||||
"sheet.ipAddress": "IP Address",
|
||||
"sheet.ipAsName": "Use IP address as name",
|
||||
@@ -51,9 +60,15 @@
|
||||
"sheet.macAsName": "Use MAC address as name",
|
||||
"sheet.name": "Name",
|
||||
"sheet.network": "Wireless Network (SSID)",
|
||||
"sheet.newEntry": "New entry",
|
||||
"sheet.newEntryHint": "The row is taken once all four fields are valid.",
|
||||
"sheet.nic": "NIC {number}",
|
||||
"sheet.notConnected": "No Connection",
|
||||
"sheet.notTaken": "Not taken – still {value}.",
|
||||
"sheet.port": "Port",
|
||||
"sheet.retention": "Retention Time SAT Entries (Seconds)",
|
||||
"sheet.rip": "Automatic Routing",
|
||||
"sheet.showAllEntries": "Show all entries",
|
||||
"sheet.subnetMask": "Netmask",
|
||||
"sheet.switchSsid": "Wi-Fi Name (SSID)",
|
||||
"sheet.title": "Settings",
|
||||
|
||||
@@ -1,16 +1,14 @@
|
||||
// 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).
|
||||
// JSwitchKonfiguration, JModemKonfiguration; a router's and a home router's
|
||||
// in RouterSheets.tsx) 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.
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import type * as Y from 'yjs';
|
||||
import {
|
||||
deviceSheet,
|
||||
renameDevice,
|
||||
setDhcpClient,
|
||||
setHostAddress,
|
||||
setHostLabels,
|
||||
@@ -30,6 +28,8 @@ import {
|
||||
import { iconOf } from '../canvas/layout.js';
|
||||
import { ICON_URLS } from '../canvas/icons.js';
|
||||
import { useTranslate } from '../i18n/i18n.js';
|
||||
import { NameField, type Change } from './NameField.js';
|
||||
import { HomeRouterFields, RouterFields } from './RouterSheets.js';
|
||||
import { SheetCheck, SheetField } from './SheetField.js';
|
||||
import './sheet.css';
|
||||
|
||||
@@ -55,17 +55,11 @@ export interface DeviceSheetProps {
|
||||
readonly onChanged: () => void;
|
||||
readonly onMessage: (message: string) => void;
|
||||
readonly onClose: () => void;
|
||||
/** The cable to draw active (a router's card whose tab is open), or none. */
|
||||
readonly onHighlight: (link: string | null) => 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) {
|
||||
export function DeviceSheet({ doc, id, label, origin, onChanged, onMessage, onClose, onHighlight }: DeviceSheetProps) {
|
||||
const t = useTranslate();
|
||||
const sheet = useDeviceSheet(doc, id);
|
||||
if (sheet === undefined) return null;
|
||||
@@ -97,6 +91,10 @@ export function DeviceSheet({ doc, id, label, origin, onChanged, onMessage, onCl
|
||||
<SwitchFields sheet={sheet} change={change} />
|
||||
) : sheet.kind === 'remoteLink' ? (
|
||||
<ModemFields sheet={sheet} change={change} />
|
||||
) : sheet.kind === 'router' ? (
|
||||
<RouterFields sheet={sheet} change={change} onHighlight={onHighlight} />
|
||||
) : sheet.kind === 'gateway' ? (
|
||||
<HomeRouterFields sheet={sheet} change={change} onHighlight={onHighlight} />
|
||||
) : (
|
||||
<NameField name={sheet.name} change={change} />
|
||||
)}
|
||||
@@ -105,20 +103,6 @@ export function DeviceSheet({ doc, id, label, origin, onChanged, onMessage, onCl
|
||||
);
|
||||
}
|
||||
|
||||
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 =
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
// What every part of the device sheet shares: the change of one device by
|
||||
// one person, and the device's name.
|
||||
|
||||
import type * as Y from 'yjs';
|
||||
import { renameDevice } from '@mdg-filius/doc';
|
||||
import { useTranslate } from '../i18n/i18n.js';
|
||||
import { SheetField } from './SheetField.js';
|
||||
|
||||
export 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 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 }))}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,266 @@
|
||||
// The sheets of a router and a home router, after Java's panels
|
||||
// (JVermittlungsrechnerKonfiguration, JGatewayConfiguration): tabs for the
|
||||
// general settings, each card and — on a router — the forwarding table
|
||||
// (JWeiterleitungsTabelle), greyed out while automatic routing (RIP) runs.
|
||||
// The card whose tab is open has its cable drawn active, as Java draws it.
|
||||
// The firewall, the port forwarding, the DHCP server and the change of the
|
||||
// cards follow with their dialogs (F4.5c).
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import {
|
||||
addRoute,
|
||||
checkField,
|
||||
ROUTE_COLUMNS,
|
||||
removeRoute,
|
||||
setDhcpClient,
|
||||
setInterfaceAddress,
|
||||
setRip,
|
||||
setRouteCell,
|
||||
setRouterGateway,
|
||||
type CheckedField,
|
||||
type HomeRouterSheet,
|
||||
type InterfaceSheet,
|
||||
type RouteColumn,
|
||||
type RouterSheet,
|
||||
} from '@mdg-filius/doc';
|
||||
import { useTranslate } from '../i18n/i18n.js';
|
||||
import { NameField, type Change } from './NameField.js';
|
||||
import { SheetCheck, SheetField } from './SheetField.js';
|
||||
|
||||
interface Tab {
|
||||
readonly key: string;
|
||||
readonly label: string;
|
||||
readonly disabled?: boolean;
|
||||
}
|
||||
|
||||
function SheetTabs({ tabs, selected, onSelect }: { readonly tabs: readonly Tab[]; readonly selected: string; readonly onSelect: (key: string) => void }) {
|
||||
return (
|
||||
<div role="tablist" className="sheet-tabs">
|
||||
{tabs.map((tab) => (
|
||||
<button key={tab.key} type="button" role="tab" data-tab={tab.key} aria-selected={tab.key === selected} disabled={tab.disabled === true} onClick={() => onSelect(tab.key)}>
|
||||
{tab.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** Reports the cable of the card whose tab is open, and none once the sheet goes. */
|
||||
function useHighlight(link: string | null, onHighlight: (link: string | null) => void): void {
|
||||
useEffect(() => {
|
||||
onHighlight(link);
|
||||
}, [link, onHighlight]);
|
||||
useEffect(() => () => onHighlight(null), [onHighlight]);
|
||||
}
|
||||
|
||||
/** A card: what its cable joins, its address and mask, its MAC address. */
|
||||
function InterfaceFields({ card, change, closed = false }: { readonly card: InterfaceSheet; readonly change: Change; readonly closed?: boolean }) {
|
||||
const t = useTranslate();
|
||||
const set = (field: 'ipAddress' | 'subnetMask') => (text: string) => change.run(() => setInterfaceAddress(change.doc, change.id, card.portId, field, text, change.origin));
|
||||
return (
|
||||
<>
|
||||
<p className="sheet-connected" data-connected={card.connected === null ? 'false' : 'true'}>
|
||||
{card.connected === null ? t('sheet.notConnected') : t('sheet.connectedTo', { device: card.connected.device })}
|
||||
</p>
|
||||
<SheetField name="ipAddress" label={t('sheet.ipAddress')} value={card.ipAddress} rule="ipAddress" inputMode="decimal" disabled={closed} onCommit={set('ipAddress')} />
|
||||
<SheetField name="subnetMask" label={t('sheet.subnetMask')} value={card.subnetMask} rule="subnetMask" inputMode="decimal" disabled={closed} onCommit={set('subnetMask')} />
|
||||
<SheetField name="macAddress" label={t('sheet.macAddress')} value={card.macAddress} rule="name" readOnly onCommit={() => ({ invalid: true })} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function GatewayField({ value, change }: { readonly value: string; readonly change: Change }) {
|
||||
const t = useTranslate();
|
||||
return (
|
||||
<SheetField
|
||||
name="gateway"
|
||||
label={t('sheet.defaultGateway')}
|
||||
value={value}
|
||||
rule="ipAddressOrEmpty"
|
||||
inputMode="decimal"
|
||||
onCommit={(text) => change.run(() => setRouterGateway(change.doc, change.id, text, change.origin))}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export function RouterFields({ sheet, change, onHighlight }: { readonly sheet: RouterSheet; readonly change: Change; readonly onHighlight: (link: string | null) => void }) {
|
||||
const t = useTranslate();
|
||||
const [tab, setTab] = useState('general');
|
||||
// Java greys the table out while RIP runs; an open table goes back to the general tab.
|
||||
const shown = tab === 'table' && sheet.rip ? 'general' : tab;
|
||||
const card = shown.startsWith('card-') ? sheet.interfaces[Number(shown.slice(5))] : undefined;
|
||||
useHighlight(card?.connected?.link ?? null, onHighlight);
|
||||
const tabs: Tab[] = [
|
||||
{ key: 'general', label: t('sheet.general') },
|
||||
// Java names a card's tab by its address (updateAttribute), "Netzwerkkarte n" without one.
|
||||
...sheet.interfaces.map((each, index) => ({ key: `card-${index}`, label: each.ipAddress === '' ? t('sheet.nic', { number: index + 1 }) : each.ipAddress })),
|
||||
{ key: 'table', label: t('sheet.forwardingTable'), disabled: sheet.rip },
|
||||
];
|
||||
return (
|
||||
<>
|
||||
<SheetTabs tabs={tabs} selected={shown} onSelect={setTab} />
|
||||
{/* A panel per tab: a text typed on one card is not offered to the next. */}
|
||||
<div role="tabpanel" key={shown}>
|
||||
{shown === 'general' ? (
|
||||
<>
|
||||
<NameField name={sheet.name} change={change} />
|
||||
<GatewayField value={sheet.gateway} change={change} />
|
||||
<SheetCheck name="rip" label={t('sheet.rip')} checked={sheet.rip} onChange={(on) => change.run(() => setRip(change.doc, change.id, on, change.origin))} />
|
||||
<SheetCheck name="ipForwarding" label={t('sheet.ipForwarding')} checked={sheet.ipForwarding} disabled onChange={() => undefined} />
|
||||
</>
|
||||
) : card !== undefined ? (
|
||||
<InterfaceFields card={card} change={change} />
|
||||
) : (
|
||||
<RouteTable sheet={sheet} change={change} />
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export function HomeRouterFields({ sheet, change, onHighlight }: { readonly sheet: HomeRouterSheet; readonly change: Change; readonly onHighlight: (link: string | null) => void }) {
|
||||
const t = useTranslate();
|
||||
const [tab, setTab] = useState('general');
|
||||
const card = tab === 'lan' ? sheet.lan : tab === 'wan' ? sheet.wan : undefined;
|
||||
useHighlight(card?.connected?.link ?? null, onHighlight);
|
||||
// Java's order: general, LAN, WAN; a card's tab says which and its address.
|
||||
const tabs: Tab[] = [
|
||||
{ key: 'general', label: t('sheet.general') },
|
||||
{ key: 'lan', label: `LAN: ${sheet.lan.ipAddress}` },
|
||||
{ key: 'wan', label: `WAN: ${sheet.wan.ipAddress}` },
|
||||
];
|
||||
return (
|
||||
<>
|
||||
<SheetTabs tabs={tabs} selected={tab} onSelect={setTab} />
|
||||
<div role="tabpanel" key={tab}>
|
||||
{tab === 'general' ? (
|
||||
<>
|
||||
<NameField name={sheet.name} change={change} />
|
||||
<GatewayField value={sheet.gateway} change={change} />
|
||||
<SheetCheck name="ipForwarding" label={t('sheet.ipForwarding')} checked={sheet.ipForwarding} disabled onChange={() => undefined} />
|
||||
</>
|
||||
) : tab === 'lan' ? (
|
||||
<InterfaceFields card={sheet.lan} change={change} />
|
||||
) : (
|
||||
<>
|
||||
{/* DHCP configures the WAN card; its address and mask are closed meanwhile, as in Java. */}
|
||||
<InterfaceFields card={sheet.wan} change={change} closed={sheet.dhcpClient} />
|
||||
<SheetCheck name="dhcpClient" label={t('sheet.dhcpClient')} checked={sheet.dhcpClient} onChange={(on) => change.run(() => setDhcpClient(change.doc, change.id, on, change.origin))} />
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
const routeRule = (column: RouteColumn) => (column === 'subnetMask' ? 'subnetMask' : 'ipAddress');
|
||||
const EMPTY_ROW: Readonly<Record<RouteColumn, string>> = { destinationNetwork: '', subnetMask: '', gateway: '', interfaceIPAddress: '' };
|
||||
|
||||
/**
|
||||
* The forwarding table: all rows or the manual ones only (Java's "show all
|
||||
* entries", on at first), a manual row chosen to edit its cells or remove
|
||||
* it, one new row at a time, taken once all four cells pass (as Java keeps
|
||||
* its new row until Weiterleitungstabelle.validate takes it).
|
||||
*/
|
||||
function RouteTable({ sheet, change }: { readonly sheet: RouterSheet; readonly change: Change }) {
|
||||
const t = useTranslate();
|
||||
const [showAll, setShowAll] = useState(true);
|
||||
const [chosen, setChosen] = useState<number | 'new' | null>(null);
|
||||
const [draft, setDraft] = useState<Record<RouteColumn, string>>(EMPTY_ROW);
|
||||
let manual = -1;
|
||||
const rows = sheet.table.map((row) => ({ row, manual: row.isEditable ? ++manual : null })).filter((each) => showAll || each.manual !== null);
|
||||
const editing = typeof chosen === 'number' ? sheet.routes[chosen] : undefined;
|
||||
|
||||
const draftCell =
|
||||
(column: RouteColumn) =>
|
||||
(text: string): CheckedField => {
|
||||
const checked = checkField(routeRule(column), text);
|
||||
if ('invalid' in checked) return checked;
|
||||
const next = { ...draft, [column]: checked.value };
|
||||
setDraft(next);
|
||||
if (ROUTE_COLUMNS.every((each) => 'value' in checkField(routeRule(each), next[each]))) {
|
||||
const added = change.run(() => addRoute(change.doc, change.id, next, change.origin));
|
||||
if ('route' in added) {
|
||||
setDraft(EMPTY_ROW);
|
||||
setChosen(sheet.routes.length);
|
||||
}
|
||||
}
|
||||
return checked;
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<SheetCheck name="showAll" label={t('sheet.showAllEntries')} checked={showAll} onChange={setShowAll} />
|
||||
<div className="route-table-frame">
|
||||
<table className="route-table">
|
||||
<thead>
|
||||
<tr>
|
||||
{ROUTE_COLUMNS.map((column) => (
|
||||
<th key={column} scope="col">
|
||||
{t(`route.${column}`)}
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{rows.map(({ row, manual: index }, place) => (
|
||||
<tr key={place} data-manual={index === null ? undefined : String(index)} aria-selected={index !== null && chosen === index} onClick={index === null ? undefined : () => setChosen(index)}>
|
||||
{[row.destinationNetwork, row.subnetMask, row.nextHop, row.interfaceIPAddress].map((cell, column) => (
|
||||
// A manual cell the rule refuses (from a file) is marked, not mended.
|
||||
<td key={column} data-invalid={index !== null && 'invalid' in checkField(routeRule(ROUTE_COLUMNS[column]!), cell) ? 'true' : undefined}>
|
||||
{cell}
|
||||
</td>
|
||||
))}
|
||||
</tr>
|
||||
))}
|
||||
{chosen === 'new' ? (
|
||||
<tr aria-selected data-new>
|
||||
{ROUTE_COLUMNS.map((column) => (
|
||||
<td key={column}>{draft[column]}</td>
|
||||
))}
|
||||
</tr>
|
||||
) : null}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div className="sheet-buttons">
|
||||
<button type="button" onClick={() => setChosen('new')}>
|
||||
{t('sheet.newEntry')}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
disabled={chosen === null}
|
||||
onClick={() => {
|
||||
if (chosen === 'new') setDraft(EMPTY_ROW);
|
||||
else if (chosen !== null) change.run(() => removeRoute(change.doc, change.id, chosen, change.origin));
|
||||
setChosen(null);
|
||||
}}
|
||||
>
|
||||
{t('sheet.deleteEntry')}
|
||||
</button>
|
||||
</div>
|
||||
{chosen === 'new' ? (
|
||||
<fieldset className="sheet-group" key="new">
|
||||
<p className="sheet-note">{t('sheet.newEntryHint')}</p>
|
||||
{ROUTE_COLUMNS.map((column) => (
|
||||
<SheetField key={column} name={`route-${column}`} label={t(`route.${column}`)} value={draft[column]} rule={routeRule(column)} inputMode="decimal" unfilled onCommit={draftCell(column)} />
|
||||
))}
|
||||
</fieldset>
|
||||
) : editing !== undefined ? (
|
||||
<fieldset className="sheet-group" key={`row-${String(chosen)}`}>
|
||||
{ROUTE_COLUMNS.map((column) => (
|
||||
<SheetField
|
||||
key={column}
|
||||
name={`route-${column}`}
|
||||
label={t(`route.${column}`)}
|
||||
value={editing[column]}
|
||||
rule={routeRule(column)}
|
||||
inputMode="decimal"
|
||||
onCommit={(text) => change.run(() => setRouteCell(change.doc, change.id, chosen as number, column, text, change.origin))}
|
||||
/>
|
||||
))}
|
||||
</fieldset>
|
||||
) : null}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -22,16 +22,18 @@ export interface SheetFieldProps {
|
||||
/** A name for the browser tests and the label's input mode. */
|
||||
readonly name: string;
|
||||
readonly inputMode?: 'text' | 'decimal' | 'numeric';
|
||||
/** A field still to be filled (a new row's cell): empty and untouched, it is not marked. */
|
||||
readonly unfilled?: boolean;
|
||||
}
|
||||
|
||||
export function SheetField({ label, value, rule, onCommit, disabled = false, readOnly = false, name, inputMode = 'text' }: SheetFieldProps) {
|
||||
export function SheetField({ label, value, rule, onCommit, disabled = false, readOnly = false, name, inputMode = 'text', unfilled = false }: SheetFieldProps) {
|
||||
const t = useTranslate();
|
||||
const id = useId();
|
||||
// What is typed and not yet taken; null shows the device's value.
|
||||
const [draft, setDraft] = useState<string | null>(null);
|
||||
const [refused, setRefused] = useState(false);
|
||||
const shown = draft ?? value;
|
||||
const marked = !readOnly && 'invalid' in checkField(rule, shown);
|
||||
const marked = !readOnly && !(unfilled && draft === null && shown === '') && 'invalid' in checkField(rule, shown);
|
||||
|
||||
const commit = () => {
|
||||
if (draft === null || readOnly) return;
|
||||
|
||||
@@ -148,3 +148,101 @@
|
||||
padding: 0;
|
||||
border: none;
|
||||
}
|
||||
|
||||
/* Java's tabs (JTabbedPane), as chips that wrap in a narrow sheet. */
|
||||
.sheet-tabs {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
margin: 4px 0 8px;
|
||||
}
|
||||
|
||||
.sheet-tabs button,
|
||||
.sheet-buttons button {
|
||||
min-height: 44px;
|
||||
padding: 0 12px;
|
||||
border: 1px solid var(--border-default);
|
||||
border-radius: 8px;
|
||||
background: var(--surface-raised);
|
||||
color: var(--text-primary);
|
||||
font: inherit;
|
||||
font-size: 14px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.sheet-tabs button[aria-selected='true'] {
|
||||
border-color: var(--accent);
|
||||
background: var(--accent-soft);
|
||||
color: var(--accent-on-soft);
|
||||
}
|
||||
|
||||
.sheet-tabs button:disabled,
|
||||
.sheet-buttons button:disabled {
|
||||
color: var(--text-muted);
|
||||
background: var(--surface-sunken);
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.sheet-buttons {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
margin: 8px 0;
|
||||
}
|
||||
|
||||
.sheet-connected,
|
||||
.sheet-note {
|
||||
margin: 8px 0;
|
||||
font-size: 14px;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.route-table-frame {
|
||||
overflow-x: auto;
|
||||
border: 1px solid var(--border-default);
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.route-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: 12px;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.route-table th,
|
||||
.route-table td {
|
||||
padding: 6px;
|
||||
text-align: left;
|
||||
white-space: nowrap;
|
||||
border-bottom: 1px solid var(--border-subtle);
|
||||
}
|
||||
|
||||
.route-table th {
|
||||
font-weight: 600;
|
||||
color: var(--text-secondary);
|
||||
background: var(--surface-sunken);
|
||||
}
|
||||
|
||||
/* Java greys the rows it makes and lets the manual ones be edited. */
|
||||
.route-table tbody tr:not([data-manual]):not([data-new]) {
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.route-table tbody tr[data-manual] {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.route-table tbody tr[aria-selected='true'] {
|
||||
background: var(--surface-selected);
|
||||
}
|
||||
|
||||
.route-table td[data-invalid='true'] {
|
||||
color: var(--danger);
|
||||
}
|
||||
|
||||
/* A manual row is a finger's target. */
|
||||
.route-table tbody tr[data-manual] td {
|
||||
padding-top: 13px;
|
||||
padding-bottom: 13px;
|
||||
}
|
||||
|
||||
@@ -30,6 +30,8 @@
|
||||
--canvas-bg: #ffffff;
|
||||
--canvas-cable: rgb(64 64 64);
|
||||
--canvas-cable-wireless: rgb(192 192 192);
|
||||
/* Java's active cable (JCablePanel.farbeBlinken): a frame on its way, or the card whose tab is open. */
|
||||
--canvas-cable-active: rgb(0 255 64);
|
||||
--canvas-label: #000000;
|
||||
}
|
||||
|
||||
@@ -60,6 +62,7 @@
|
||||
--canvas-bg: #e9ecf1;
|
||||
--canvas-cable: rgb(64 64 64);
|
||||
--canvas-cable-wireless: rgb(150 150 150);
|
||||
--canvas-cable-active: rgb(0 200 50);
|
||||
--canvas-label: #000000;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user