feat(oxibadge): add onboarding flow with i18n and reader preferences
Check, format and test / build (push) Successful in 9m59s
Check, format and test / build (push) Successful in 9m59s
Adds the first real screen: a setup form for the device's host/port, tested against the real device (3 retries) and persisted to preferences.toml before showing a placeholder "done" screen. - Rust: test_connection/save_preferences/load_preferences commands; preferences stored as [[reader]] in TOML so more readers can be added later without a format change. - Svelte: split the monolithic page into reusable components (Screen, Column, Field, Button, ErrorBanner, Hint) under src/lib/components, sharing design tokens from app.css. - Automatic it/en i18n via svelte-i18n, locale detected from navigator.language. Assisted-by: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,51 @@
|
||||
@import '@fontsource/geist/latin-400.css';
|
||||
@import '@fontsource/geist/latin-500.css';
|
||||
@import '@fontsource/geist/latin-600.css';
|
||||
@import '@fontsource/bricolage-grotesque/latin-700.css';
|
||||
@import '@fontsource/geist-mono/latin-400.css';
|
||||
|
||||
:root {
|
||||
--ox-bg: #121212;
|
||||
--ox-text: #f2f2f2;
|
||||
--ox-text-strong: #ffffff;
|
||||
--ox-text-muted: #a3a3a3;
|
||||
--ox-text-subtle: #8a8a8a;
|
||||
--ox-label: #9a9a9a;
|
||||
--ox-placeholder: #5e5e5e;
|
||||
--ox-border: #333333;
|
||||
--ox-border-hover: #4a4a4a;
|
||||
|
||||
--ox-accent: #f74c00;
|
||||
--ox-accent-soft: #ff7a3d;
|
||||
--ox-accent-ring: rgba(247, 76, 0, 0.16);
|
||||
--ox-on-accent: #1a0700;
|
||||
|
||||
--ox-error-bg: #261316;
|
||||
--ox-error-border: #5a2229;
|
||||
--ox-error-text: #f5b5b9;
|
||||
--ox-error-title: #ffd6d8;
|
||||
--ox-error-icon: #ff6b74;
|
||||
|
||||
--ox-radius: 12px;
|
||||
--ox-control-h: 52px;
|
||||
|
||||
--ox-font-display: 'Bricolage Grotesque', 'Geist', system-ui, sans-serif;
|
||||
--ox-font-body: 'Geist', system-ui, sans-serif;
|
||||
--ox-font-mono: 'Geist Mono', ui-monospace, monospace;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
background: var(--ox-bg);
|
||||
color: var(--ox-text);
|
||||
font-family: var(--ox-font-body);
|
||||
}
|
||||
|
||||
svg {
|
||||
fill: none;
|
||||
stroke: currentColor;
|
||||
stroke-width: 1.8;
|
||||
stroke-linecap: round;
|
||||
stroke-linejoin: round;
|
||||
flex: none;
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
<script lang="ts">
|
||||
let {
|
||||
label,
|
||||
type = 'button',
|
||||
faded = false,
|
||||
busy = false,
|
||||
disabled = false,
|
||||
onclick
|
||||
}: {
|
||||
label: string;
|
||||
type?: 'button' | 'submit';
|
||||
faded?: boolean;
|
||||
busy?: boolean;
|
||||
disabled?: boolean;
|
||||
onclick?: () => void;
|
||||
} = $props();
|
||||
</script>
|
||||
|
||||
<button {type} class="btn" class:faded class:busy {disabled} {onclick}>
|
||||
<span>{label}</span>
|
||||
{#if busy}
|
||||
<span class="spinner"></span>
|
||||
{:else}
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" stroke-width="2" aria-hidden="true"
|
||||
><path d="M5 12h14M13 6l6 6-6 6" /></svg
|
||||
>
|
||||
{/if}
|
||||
</button>
|
||||
|
||||
<style>
|
||||
.btn {
|
||||
all: unset;
|
||||
box-sizing: border-box;
|
||||
height: var(--ox-control-h);
|
||||
width: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 10px;
|
||||
border-radius: var(--ox-radius);
|
||||
background: var(--ox-accent);
|
||||
color: var(--ox-on-accent);
|
||||
font-family: var(--ox-font-body);
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition:
|
||||
filter 0.15s,
|
||||
transform 0.1s,
|
||||
opacity 0.2s;
|
||||
}
|
||||
|
||||
.btn:not(:disabled):hover {
|
||||
filter: brightness(1.08);
|
||||
}
|
||||
|
||||
.btn:not(:disabled):active {
|
||||
transform: translateY(1px);
|
||||
}
|
||||
|
||||
.btn:focus-visible {
|
||||
outline: 2px solid #ffffff;
|
||||
outline-offset: 3px;
|
||||
}
|
||||
|
||||
.btn:disabled {
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.btn.faded {
|
||||
opacity: 0.35;
|
||||
}
|
||||
|
||||
.btn.busy {
|
||||
opacity: 0.7;
|
||||
cursor: progress;
|
||||
}
|
||||
|
||||
.spinner {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
border: 2px solid rgba(26, 7, 0, 0.35);
|
||||
border-top-color: var(--ox-on-accent);
|
||||
border-radius: 50%;
|
||||
animation: spin 0.7s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,19 @@
|
||||
<script lang="ts">
|
||||
import type { Snippet } from 'svelte';
|
||||
|
||||
let { children }: { children: Snippet } = $props();
|
||||
</script>
|
||||
|
||||
<div class="column">
|
||||
{@render children()}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.column {
|
||||
width: 100%;
|
||||
max-width: 400px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 36px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,46 @@
|
||||
<script lang="ts">
|
||||
let { title, message }: { title: string; message: string } = $props();
|
||||
</script>
|
||||
|
||||
<div class="banner" role="alert">
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" stroke-width="2" aria-hidden="true">
|
||||
<path
|
||||
d="M10.3 3.9L2.4 17.5A2 2 0 0 0 4.1 20.5h15.8a2 2 0 0 0 1.7-3L13.7 3.9a2 2 0 0 0-3.4 0z"
|
||||
/><path d="M12 9.5v4M12 17h.01" />
|
||||
</svg>
|
||||
<div class="banner-text">
|
||||
<strong>{title}</strong>
|
||||
<span>{message}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.banner {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 12px;
|
||||
padding: 14px 16px;
|
||||
border-radius: var(--ox-radius);
|
||||
font-size: 14px;
|
||||
line-height: 1.45;
|
||||
background: var(--ox-error-bg);
|
||||
border: 1px solid var(--ox-error-border);
|
||||
color: var(--ox-error-text);
|
||||
}
|
||||
|
||||
.banner svg {
|
||||
color: var(--ox-error-icon);
|
||||
margin-top: 1px;
|
||||
}
|
||||
|
||||
.banner-text {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 3px;
|
||||
}
|
||||
|
||||
.banner-text strong {
|
||||
font-weight: 600;
|
||||
color: var(--ox-error-title);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,96 @@
|
||||
<script lang="ts">
|
||||
import type { Snippet } from 'svelte';
|
||||
|
||||
let {
|
||||
id,
|
||||
label,
|
||||
value = $bindable(),
|
||||
type = 'text',
|
||||
placeholder = '',
|
||||
oninput,
|
||||
icon
|
||||
}: {
|
||||
id: string;
|
||||
label: string;
|
||||
value: string | number;
|
||||
type?: string;
|
||||
placeholder?: string;
|
||||
oninput?: () => void;
|
||||
icon?: Snippet;
|
||||
} = $props();
|
||||
</script>
|
||||
|
||||
<div class="field">
|
||||
<label for={id}>{label}</label>
|
||||
{#if icon}{@render icon()}{/if}
|
||||
<input {id} {type} {placeholder} bind:value {oninput} autocomplete="off" spellcheck="false" />
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.field {
|
||||
position: relative;
|
||||
height: var(--ox-control-h);
|
||||
box-sizing: border-box;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 0 16px;
|
||||
background: var(--ox-bg);
|
||||
border: 1px solid var(--ox-border);
|
||||
border-radius: var(--ox-radius);
|
||||
transition:
|
||||
border-color 0.15s,
|
||||
box-shadow 0.15s;
|
||||
}
|
||||
|
||||
.field:hover {
|
||||
border-color: var(--ox-border-hover);
|
||||
}
|
||||
|
||||
.field:focus-within {
|
||||
border-color: var(--ox-accent);
|
||||
box-shadow: 0 0 0 4px var(--ox-accent-ring);
|
||||
}
|
||||
|
||||
.field label {
|
||||
position: absolute;
|
||||
top: -9px;
|
||||
left: 12px;
|
||||
padding: 0 6px;
|
||||
background: var(--ox-bg);
|
||||
font-size: 12px;
|
||||
line-height: 16px;
|
||||
font-weight: 500;
|
||||
color: var(--ox-label);
|
||||
transition: color 0.15s;
|
||||
}
|
||||
|
||||
.field:focus-within label {
|
||||
color: var(--ox-accent-soft);
|
||||
}
|
||||
|
||||
.field :global(svg) {
|
||||
color: #6e6e6e;
|
||||
}
|
||||
|
||||
.field input {
|
||||
all: unset;
|
||||
box-sizing: border-box;
|
||||
min-width: 0;
|
||||
width: 100%;
|
||||
font-family: var(--ox-font-mono);
|
||||
font-size: 16px;
|
||||
color: var(--ox-text);
|
||||
caret-color: var(--ox-accent);
|
||||
}
|
||||
|
||||
.field input::placeholder {
|
||||
color: var(--ox-placeholder);
|
||||
}
|
||||
|
||||
.field input[type='number']::-webkit-outer-spin-button,
|
||||
.field input[type='number']::-webkit-inner-spin-button {
|
||||
-webkit-appearance: none;
|
||||
margin: 0;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,23 @@
|
||||
<script lang="ts">
|
||||
let { text }: { text: string } = $props();
|
||||
</script>
|
||||
|
||||
<p class="hint">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" aria-hidden="true">
|
||||
<circle cx="12" cy="12" r="9" /><path d="M12 11v5M12 8h.01" />
|
||||
</svg>
|
||||
<span>{text}</span>
|
||||
</p>
|
||||
|
||||
<style>
|
||||
.hint {
|
||||
margin: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
font-size: 13px;
|
||||
color: var(--ox-text-subtle);
|
||||
text-align: center;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,137 @@
|
||||
<script lang="ts">
|
||||
import { invoke } from '@tauri-apps/api/core';
|
||||
import { _ } from 'svelte-i18n';
|
||||
import Column from './Column.svelte';
|
||||
import Field from './Field.svelte';
|
||||
import Button from './Button.svelte';
|
||||
import ErrorBanner from './ErrorBanner.svelte';
|
||||
import Hint from './Hint.svelte';
|
||||
|
||||
let { onconnected }: { onconnected: (cfg: { host: string; port: number }) => void } = $props();
|
||||
|
||||
type Phase = 'idle' | 'testing' | 'err';
|
||||
|
||||
let host = $state('');
|
||||
let port = $state(5005);
|
||||
let phase = $state<Phase>('idle');
|
||||
let errorCode = $state('');
|
||||
|
||||
const trimmedHost = $derived(host.trim());
|
||||
const empty = $derived(trimmedHost === '');
|
||||
const disabled = $derived(empty || phase === 'testing');
|
||||
const btnLabel = $derived(
|
||||
phase === 'testing'
|
||||
? $_('onboarding.button.testing')
|
||||
: phase === 'err'
|
||||
? $_('onboarding.button.retry')
|
||||
: $_('onboarding.button.idle')
|
||||
);
|
||||
|
||||
function resetPhase() {
|
||||
phase = 'idle';
|
||||
errorCode = '';
|
||||
}
|
||||
|
||||
async function testAndSave() {
|
||||
if (disabled) return;
|
||||
phase = 'testing';
|
||||
errorCode = '';
|
||||
try {
|
||||
await invoke('test_connection', { host: trimmedHost, port });
|
||||
await invoke('save_preferences', { host: trimmedHost, port });
|
||||
onconnected({ host: trimmedHost, port });
|
||||
} catch (err) {
|
||||
errorCode = String(err);
|
||||
phase = 'err';
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<Column>
|
||||
<header class="intro">
|
||||
<h1>{$_('onboarding.title')}</h1>
|
||||
<p>{$_('onboarding.subtitle')}</p>
|
||||
</header>
|
||||
|
||||
<form
|
||||
class="form"
|
||||
onsubmit={(event) => {
|
||||
event.preventDefault();
|
||||
testAndSave();
|
||||
}}
|
||||
>
|
||||
<div class="row">
|
||||
<Field
|
||||
id="ox-host"
|
||||
label={$_('onboarding.host_label')}
|
||||
bind:value={host}
|
||||
placeholder="192.168.1.100"
|
||||
oninput={resetPhase}
|
||||
>
|
||||
{#snippet icon()}
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" aria-hidden="true">
|
||||
<rect x="3" y="4" width="18" height="12" rx="2" /><path d="M8 20h8M12 16v4" />
|
||||
</svg>
|
||||
{/snippet}
|
||||
</Field>
|
||||
|
||||
<Field
|
||||
id="ox-port"
|
||||
label={$_('onboarding.port_label')}
|
||||
bind:value={port}
|
||||
type="number"
|
||||
placeholder="5005"
|
||||
oninput={resetPhase}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Button type="submit" label={btnLabel} faded={empty} busy={phase === 'testing'} {disabled} />
|
||||
|
||||
{#if phase === 'err'}
|
||||
<ErrorBanner title={$_('onboarding.error_title')} message={$_(`onboarding.errors.${errorCode}`)} />
|
||||
{/if}
|
||||
|
||||
<Hint text={$_('onboarding.hint')} />
|
||||
</form>
|
||||
</Column>
|
||||
|
||||
<style>
|
||||
.intro {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
h1 {
|
||||
margin: 0;
|
||||
font-family: var(--ox-font-display);
|
||||
font-weight: 700;
|
||||
font-size: 48px;
|
||||
line-height: 1.05;
|
||||
letter-spacing: -0.03em;
|
||||
color: var(--ox-text-strong);
|
||||
}
|
||||
|
||||
.intro p {
|
||||
margin: 0;
|
||||
max-width: 340px;
|
||||
font-size: 15px;
|
||||
line-height: 1.5;
|
||||
color: var(--ox-text-muted);
|
||||
}
|
||||
|
||||
.form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.row {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) 108px;
|
||||
gap: 12px;
|
||||
align-items: start;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,20 @@
|
||||
<script lang="ts">
|
||||
import type { Snippet } from 'svelte';
|
||||
|
||||
let { children }: { children: Snippet } = $props();
|
||||
</script>
|
||||
|
||||
<main class="screen">
|
||||
{@render children()}
|
||||
</main>
|
||||
|
||||
<style>
|
||||
.screen {
|
||||
min-height: 100vh;
|
||||
box-sizing: border-box;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 48px 24px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,25 @@
|
||||
{
|
||||
"loading": "Loading...",
|
||||
"onboarding": {
|
||||
"title": "Welcome!",
|
||||
"subtitle": "Connect Oxibadge to your time clock by entering its IP address and port.",
|
||||
"host_label": "IP address",
|
||||
"port_label": "Port",
|
||||
"hint": "You can find the address in the time clock's network settings.",
|
||||
"button": {
|
||||
"idle": "Test connection",
|
||||
"testing": "Connecting...",
|
||||
"retry": "Retry"
|
||||
},
|
||||
"error_title": "Could not reach the time clock",
|
||||
"errors": {
|
||||
"connection-refused": "Connection refused",
|
||||
"timed-out": "Connection timed out",
|
||||
"dns-failure": "Invalid or unreachable address",
|
||||
"unknown": "Connection error"
|
||||
}
|
||||
},
|
||||
"done": {
|
||||
"message": "Nothing to do here."
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
{
|
||||
"loading": "Caricamento...",
|
||||
"onboarding": {
|
||||
"title": "Benvenuto!",
|
||||
"subtitle": "Collega Oxibadge al tuo timbratore inserendo il suo indirizzo IP e la porta.",
|
||||
"host_label": "Indirizzo IP",
|
||||
"port_label": "Porta",
|
||||
"hint": "Trovi l'indirizzo nelle impostazioni di rete del timbratore.",
|
||||
"button": {
|
||||
"idle": "Testa connessione",
|
||||
"testing": "Connessione in corso...",
|
||||
"retry": "Riprova"
|
||||
},
|
||||
"error_title": "Impossibile raggiungere il timbratore",
|
||||
"errors": {
|
||||
"connection-refused": "Connessione rifiutata",
|
||||
"timed-out": "Connessione scaduta",
|
||||
"dns-failure": "Indirizzo non valido o non raggiungibile",
|
||||
"unknown": "Errore di connessione"
|
||||
}
|
||||
},
|
||||
"done": {
|
||||
"message": "Non c'è niente da fare."
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
<script lang="ts">
|
||||
import '../app.css';
|
||||
import { waitLocale } from 'svelte-i18n';
|
||||
import type { Snippet } from 'svelte';
|
||||
|
||||
let { children }: { children: Snippet } = $props();
|
||||
|
||||
let ready = $state(false);
|
||||
waitLocale().then(() => {
|
||||
ready = true;
|
||||
});
|
||||
</script>
|
||||
|
||||
{#if ready}
|
||||
{@render children()}
|
||||
{/if}
|
||||
@@ -1,5 +1,15 @@
|
||||
import { getLocaleFromNavigator, init, register } from 'svelte-i18n';
|
||||
|
||||
// Tauri doesn't have a Node.js server to do proper SSR
|
||||
// so we use adapter-static with a fallback to index.html to put the site in SPA mode
|
||||
// See: https://svelte.dev/docs/kit/single-page-apps
|
||||
// See: https://v2.tauri.app/start/frontend/sveltekit/ for more info
|
||||
export const ssr = false;
|
||||
|
||||
register('it', () => import('$lib/locales/it.json'));
|
||||
register('en', () => import('$lib/locales/en.json'));
|
||||
|
||||
init({
|
||||
fallbackLocale: 'it',
|
||||
initialLocale: getLocaleFromNavigator()
|
||||
});
|
||||
|
||||
@@ -1,33 +1,26 @@
|
||||
<main>
|
||||
<img src="/logo.svg" alt="Oxibadge logo" class="logo" />
|
||||
<h1>Hello, world!</h1>
|
||||
</main>
|
||||
<script lang="ts">
|
||||
import { invoke } from '@tauri-apps/api/core';
|
||||
import { onMount } from 'svelte';
|
||||
import { _ } from 'svelte-i18n';
|
||||
import Screen from '$lib/components/Screen.svelte';
|
||||
import Onboarding from '$lib/components/Onboarding.svelte';
|
||||
|
||||
<style>
|
||||
:global(body) {
|
||||
margin: 0;
|
||||
color: #f6f6f6;
|
||||
background-color: #2f2f2f;
|
||||
font-family: Inter, Avenir, Helvetica, Arial, sans-serif;
|
||||
}
|
||||
type Step = 'loading' | 'setup' | 'done';
|
||||
|
||||
main {
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 1.5rem;
|
||||
}
|
||||
let step = $state<Step>('loading');
|
||||
|
||||
.logo {
|
||||
width: 8rem;
|
||||
height: 8rem;
|
||||
}
|
||||
onMount(async () => {
|
||||
const preferences = await invoke<{ host: string; port: number } | null>('load_preferences');
|
||||
step = preferences ? 'done' : 'setup';
|
||||
});
|
||||
</script>
|
||||
|
||||
h1 {
|
||||
margin: 0;
|
||||
font-size: 2rem;
|
||||
font-weight: 500;
|
||||
}
|
||||
</style>
|
||||
<Screen>
|
||||
{#if step === 'loading'}
|
||||
<p>{$_('loading')}</p>
|
||||
{:else if step === 'setup'}
|
||||
<Onboarding onconnected={() => (step = 'done')} />
|
||||
{:else}
|
||||
<p>{$_('done.message')}</p>
|
||||
{/if}
|
||||
</Screen>
|
||||
|
||||
Reference in New Issue
Block a user