Files
r701/oxibadge/src/lib/components/settings/ReaderCard.svelte
T
nicolabelluti da5daf5bca
Check, format and test / build (push) Successful in 11m10s
fix(oxibadge): accept hostnames and FQDNs for the time clock address
The "Edit address" field in Settings only accepted an IPv4 address
(IPV4.test), rejecting a bare hostname (e.g. "timbratore") or FQDN
(e.g. "timbratore.lan") even though the underlying connection (Rust's
ToSocketAddrs) already resolves those fine. Onboarding had no such
restriction. Replaced the IPv4-only check with isValidHost (IPv4 OR a
standard DNS hostname), dropped the numeric-only keyboard hint on that
field, and reworded the labels/placeholder error to not imply IP-only.

Also makes "Edit address" test the connection before accepting it,
instead of saving unconditionally: ActionButton gained a `disabled`
prop so the confirm/cancel buttons can be locked (with a spinner on
confirm) while the test is in flight, and the edit form now only
closes on a successful test, staying open with the raw error shown
otherwise.

Assisted-by: Claude Sonnet 5 <noreply@anthropic.com>
2026-10-09 14:05:12 +02:00

385 lines
9.7 KiB
Svelte

<script lang="ts">
import { invoke } from '@tauri-apps/api/core';
import { onMount } from 'svelte';
import { _ } from 'svelte-i18n';
import { error, setError, clearError } from '$lib/error';
import type { Settings } from '$lib/settings';
import { PRIMARY_READER_INDEX, type EmployeeCache, type EmployeeNames } from '$lib/employees';
import Field from '../Field.svelte';
import ErrorBanner from '../ErrorBanner.svelte';
import ActionButton from '../ActionButton.svelte';
import PillButton from '../PillButton.svelte';
import EmployeesDrawer from './EmployeesDrawer.svelte';
import { isValidHost } from './logic';
let { host, port, onsave }: { host: string; port: number; onsave: (host: string, port: number) => void } =
$props();
let editing = $state(false);
let draftHost = $state('');
let draftPort = $state(0);
let ipError = $state(false);
let confirming = $state(false);
let status = $state<'' | 'testing' | 'ok'>('');
let fullEmployeeCache = $state<EmployeeCache>({});
let employeeCache = $state<EmployeeNames>({});
let employeesOpen = $state(false);
let employeeQuery = $state('');
onMount(async () => {
fullEmployeeCache = await invoke<EmployeeCache>('load_employee_cache');
employeeCache = fullEmployeeCache[PRIMARY_READER_INDEX] ?? {};
});
const employeesList = $derived(
Object.entries(employeeCache)
.map(([id, name]) => ({ id, name }))
.sort((a, b) => a.name.localeCompare(b.name))
);
const employeeCount = $derived(employeesList.length);
const visibleEmployees = $derived(
employeesList.filter(
(e) =>
!employeeQuery.trim() ||
e.name.toLowerCase().includes(employeeQuery.trim().toLowerCase()) ||
e.id.includes(employeeQuery.trim())
)
);
async function persistEmployees(next: EmployeeNames) {
employeeCache = next;
const nextFullCache = { ...fullEmployeeCache, [PRIMARY_READER_INDEX]: next };
fullEmployeeCache = nextFullCache;
await invoke('save_employee_cache', { cache: nextFullCache });
}
function renameEmployee(id: string, name: string) {
persistEmployees({ ...employeeCache, [id]: name });
}
function removeEmployee(id: string) {
const next = { ...employeeCache };
delete next[id];
persistEmployees(next);
}
function startEdit() {
draftHost = host;
draftPort = port;
ipError = false;
clearError();
editing = true;
}
function cancelEdit() {
editing = false;
ipError = false;
}
async function confirmEdit() {
const v = draftHost.trim();
if (!isValidHost(v)) {
ipError = true;
return;
}
const nextPort = draftPort || 5005;
confirming = true;
clearError();
try {
await invoke('test_connection', { host: v, port: nextPort });
const settings = await invoke<Settings>('load_settings');
await invoke('save_settings', { settings: { ...settings, readers: [{ host: v, port: nextPort }] } });
editing = false;
status = 'ok';
onsave(v, nextPort);
} catch (err) {
status = '';
setError(err);
} finally {
confirming = false;
}
}
async function test() {
status = 'testing';
clearError();
try {
await invoke('test_connection', { host, port });
status = 'ok';
} catch (err) {
status = '';
setError(err);
}
}
function onPortInput(e: Event & { currentTarget: HTMLInputElement }) {
const input = e.currentTarget;
const next = input.value.replace(/\D/g, '').slice(0, 5);
if (input.value !== next) input.value = next;
draftPort = Number(next);
}
function onKey(e: KeyboardEvent) {
if (e.key === 'Escape' && editing && !confirming) cancelEdit();
}
</script>
<svelte:window onkeydown={onKey} />
<div class="card" class:active={editing}>
{#if !editing}
<div class="dev-row">
<div class="dev-icon">
<svg width="22" height="22" viewBox="0 0 24 24" aria-hidden="true" style="stroke-width: 1.8"
><rect x="5" y="3" width="14" height="18" rx="2.5" /><rect x="8" y="6" width="8" height="5" rx="1" /><path
d="M9 15h.01M12 15h.01M15 15h.01M9 18h6"
/></svg
>
</div>
<div class="dev-text">
<span class="dev-label">{$_('settings.device_label')}</span>
<span class="dev-endpoint mono">{host}:{port}</span>
</div>
<span class="dev-status">
{#if status === 'testing'}
<svg class="spin" width="14" height="14" viewBox="0 0 24 24" aria-hidden="true" style="stroke-width: 2.4"
><path d="M12 3a9 9 0 1 0 9 9" /></svg
>
{$_('settings.status_testing')}
{:else if status === 'ok'}
<span class="dot ok" aria-hidden="true"></span>
{$_('settings.status_ok')}
{:else}
<span class="dot" aria-hidden="true"></span>
{$_('settings.status_idle')}
{/if}
</span>
</div>
<div class="dev-actions">
<PillButton onclick={startEdit}>
{#snippet icon()}
<svg width="15" height="15" viewBox="0 0 24 24" aria-hidden="true"
><path d="M4 20h4L19 9a2.8 2.8 0 0 0-4-4L4 16v4z" /><path d="M13.5 6.5l4 4" /></svg
>
{/snippet}
{$_('settings.edit_address')}
</PillButton>
<PillButton onclick={test} disabled={status === 'testing'}>
{#snippet icon()}
<svg width="15" height="15" viewBox="0 0 24 24" aria-hidden="true"
><path d="M5 12.5a10 10 0 0 1 14 0M8.5 16a5 5 0 0 1 7 0M12 19.5h.01" /></svg
>
{/snippet}
{$_('settings.test_connection')}
</PillButton>
<PillButton onclick={() => (employeesOpen = true)}>
{#snippet icon()}
<svg width="15" height="15" viewBox="0 0 24 24" aria-hidden="true"
><circle cx="9" cy="8" r="3.5" /><path d="M2.5 20c.8-3.3 3.3-5 6.5-5s5.7 1.7 6.5 5" /><path
d="M16 4.5a3.5 3.5 0 0 1 0 7M18 15.2c1.8.7 3 2.3 3.5 4.8"
/></svg
>
{/snippet}
{$_('settings.manage_employees')}
<span class="count mono">{employeeCount}</span>
</PillButton>
</div>
{:else}
<form
class="dev-edit"
onsubmit={(e) => {
e.preventDefault();
confirmEdit();
}}
>
<div class="grid-ip">
<div class="stack">
<Field
id="dev-ip"
label={$_('settings.ip_label')}
bind:value={draftHost}
placeholder="192.168.1.100"
err={ipError}
oninput={() => (ipError = false)}
/>
{#if ipError}<span class="field-error">{$_('settings.ip_invalid')}</span>{/if}
</div>
<Field
id="dev-port"
label={$_('settings.port_label')}
value={draftPort}
maxlength={5}
inputmode="numeric"
placeholder="5005"
oninput={onPortInput}
/>
</div>
<div class="row-end">
<ActionButton disabled={confirming} onclick={cancelEdit}>{$_('settings.cancel')}</ActionButton>
<ActionButton primary disabled={confirming} onclick={confirmEdit}>
{#snippet icon()}
{#if confirming}
<svg class="spin" width="16" height="16" viewBox="0 0 24 24" aria-hidden="true" style="stroke-width: 2.4"
><path d="M12 3a9 9 0 1 0 9 9" /></svg
>
{/if}
{/snippet}
{confirming ? $_('settings.status_testing') : $_('settings.confirm')}
</ActionButton>
</div>
</form>
{/if}
</div>
{#if $error}
<ErrorBanner title={$_('errors.title')} message={$error} />
{/if}
{#if employeesOpen}
<EmployeesDrawer
employees={visibleEmployees}
total={employeeCount}
bind:query={employeeQuery}
onclose={() => (employeesOpen = false)}
onrename={renameEmployee}
ondelete={removeEmployee}
/>
{/if}
<style>
.spin {
animation: spin 0.9s linear infinite;
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
.card {
border: 1px solid #262626;
border-radius: 14px;
background: #161616;
}
.card.active {
border-color: #3a3a3a;
}
.dev-row {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 14px;
padding: 16px 16px 16px 18px;
}
.dev-icon {
width: 44px;
height: 44px;
flex: none;
border-radius: 11px;
background: #1f1f1f;
color: #bdbdbd;
display: flex;
align-items: center;
justify-content: center;
}
.dev-text {
flex: 1;
min-width: 160px;
display: flex;
flex-direction: column;
gap: 3px;
}
.dev-label {
font-size: 12.5px;
color: #8a8a8a;
}
.dev-endpoint {
font-size: 16px;
color: #f2f2f2;
}
.dev-status {
display: inline-flex;
align-items: center;
gap: 6px;
font-size: 13px;
color: #6e6e6e;
}
.dot {
width: 8px;
height: 8px;
border-radius: 50%;
background: currentColor;
}
.dot.ok {
background: #4cc38a;
}
.dev-status:has(.ok) {
color: #4cc38a;
}
.dev-actions {
display: flex;
flex-wrap: wrap;
gap: 8px;
padding: 12px 16px 14px 18px;
border-top: 1px solid #222222;
}
.count {
margin-left: 6px;
font-size: 12.5px;
color: #6e6e6e;
}
.dev-edit {
display: flex;
flex-direction: column;
gap: 18px;
padding: 22px 16px 16px;
}
.grid-ip {
display: grid;
grid-template-columns: minmax(0, 1fr) 120px;
gap: 12px;
align-items: start;
}
.stack {
display: flex;
flex-direction: column;
gap: 8px;
}
.row-end {
display: flex;
justify-content: flex-end;
gap: 10px;
}
.field-error {
padding-left: 4px;
font-size: 12.5px;
color: #ff8a8f;
}
@media (max-width: 560px) {
.grid-ip {
grid-template-columns: minmax(0, 1fr);
}
}
</style>