Check, format and test / build (push) Successful in 11m10s
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>
385 lines
9.7 KiB
Svelte
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>
|