feat(oxibadge): add settings screen for shifts, rounding and language
Check, format and test / build (push) Successful in 10m22s
Check, format and test / build (push) Successful in 10m22s
Add a settings screen (gear button on the month-confirmation screen) to configure the expected shift times, the rounding step, the interface language, and the time clock address — replacing the old onboarding-only connection flow. Settings persist to the same preferences.toml as the reader address, under a single load_settings/ save_settings command pair. The review screen's "fix shifts" action now redistributes stacked punches by nearest configured shift time instead of blind position, and a stacked cell manually cleared down to one entry no longer leaves a dangling empty input. Assisted-by: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,332 @@
|
||||
<script lang="ts">
|
||||
import { invoke } from '@tauri-apps/api/core';
|
||||
import { _ } from 'svelte-i18n';
|
||||
import { error, setError, clearError } from '$lib/error';
|
||||
import type { Settings } from '$lib/settings';
|
||||
import Field from '../Field.svelte';
|
||||
import ErrorBanner from '../ErrorBanner.svelte';
|
||||
import ActionButton from '../review/ActionButton.svelte';
|
||||
import { IPV4 } 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 status = $state<'' | 'testing' | 'ok'>('');
|
||||
|
||||
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 (!IPV4.test(v)) {
|
||||
ipError = true;
|
||||
return;
|
||||
}
|
||||
const nextPort = draftPort || 5005;
|
||||
editing = false;
|
||||
status = '';
|
||||
clearError();
|
||||
try {
|
||||
const settings = await invoke<Settings>('load_settings');
|
||||
await invoke('save_settings', { settings: { ...settings, reader: { host: v, port: nextPort } } });
|
||||
onsave(v, nextPort);
|
||||
} catch (err) {
|
||||
setError(err);
|
||||
}
|
||||
}
|
||||
|
||||
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) 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">
|
||||
<button type="button" class="pill" onclick={startEdit}>
|
||||
<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
|
||||
>
|
||||
<span>{$_('settings.edit_address')}</span>
|
||||
</button>
|
||||
<button type="button" class="pill" onclick={test} disabled={status === 'testing'}>
|
||||
<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
|
||||
>
|
||||
<span>{$_('settings.test_connection')}</span>
|
||||
</button>
|
||||
</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}
|
||||
inputmode="decimal"
|
||||
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 onclick={cancelEdit}>{$_('settings.cancel')}</ActionButton>
|
||||
<ActionButton primary onclick={confirmEdit}>{$_('settings.confirm')}</ActionButton>
|
||||
</div>
|
||||
</form>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if $error}
|
||||
<ErrorBanner title={$_('errors.title')} message={$error} />
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
.mono {
|
||||
font-family: var(--ox-font-mono);
|
||||
}
|
||||
|
||||
.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;
|
||||
}
|
||||
|
||||
.pill {
|
||||
all: unset;
|
||||
box-sizing: border-box;
|
||||
height: 36px;
|
||||
padding: 0 12px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 7px;
|
||||
border-radius: 9px;
|
||||
border: 1px solid #333333;
|
||||
color: #c9c9c9;
|
||||
font-size: 13.5px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition:
|
||||
background 0.15s,
|
||||
color 0.15s,
|
||||
border-color 0.15s;
|
||||
}
|
||||
|
||||
.pill:hover:not(:disabled) {
|
||||
background: #1e1e1e;
|
||||
color: #ffffff;
|
||||
border-color: #4a4a4a;
|
||||
}
|
||||
|
||||
.pill:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: progress;
|
||||
}
|
||||
|
||||
.pill:focus-visible {
|
||||
outline: 2px solid var(--ox-accent);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.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>
|
||||
Reference in New Issue
Block a user