[refactor]

This commit is contained in:
Piotr Domański 2026-04-16 22:11:34 +02:00
parent f4cf2b89e0
commit eb54fa59d9
17 changed files with 315 additions and 316 deletions

View file

@ -5,6 +5,26 @@ import { network } from '$lib/offline/network.svelte';
const BASE = '/api';
export class ApiError extends Error {
status: number;
detail: unknown;
constructor(message: string, status: number, detail: unknown) {
super(message);
this.name = 'ApiError';
this.status = status;
this.detail = detail;
}
}
export function extractErrorMessage(err: unknown, fallback = 'Something went wrong'): string {
if (err instanceof ApiError) {
const d = err.detail as Record<string, unknown> | null;
if (typeof d?.detail === 'string') return d.detail;
}
if (err instanceof Error) return err.message;
return fallback;
}
let isRefreshing = false;
let refreshPromise: Promise<string | null> | null = null;
@ -121,7 +141,7 @@ export async function apiPost<T>(path: string, body: unknown): Promise<T> {
return {} as T;
}
const err = await res.json().catch(() => ({}));
throw Object.assign(new Error(`POST ${path} failed: ${res.status}`), { status: res.status, detail: err });
throw new ApiError(`POST ${path} failed: ${res.status}`, res.status, err);
}
return res.json();
}

View file

@ -5,6 +5,8 @@
import { updateUserSettings } from "$lib/api/settings";
import { useQueryClient } from "@tanstack/svelte-query";
import Sheet from "$lib/components/ui/Sheet.svelte";
import Button from "$lib/components/ui/Button.svelte";
import Input from "$lib/components/ui/Input.svelte";
interface Props {
diary: Diary;
@ -192,16 +194,8 @@
well?
</p>
<div class="flex gap-3">
<button
onclick={() => (syncSettingsOpen = false)}
class="flex-1 bg-zinc-800 hover:bg-zinc-700 rounded-xl py-3 text-sm font-medium transition-colors"
>No</button
>
<button
onclick={handleSyncToSettings}
class="flex-1 bg-green-600 hover:bg-green-500 rounded-xl py-3 font-semibold transition-colors"
>Yes</button
>
<Button variant="secondary" onclick={() => (syncSettingsOpen = false)}>No</Button>
<Button variant="primary-flex" onclick={handleSyncToSettings}>Yes</Button>
</div>
</Sheet>
@ -220,16 +214,17 @@
>
{/if}
</div>
<input
<Input
variant="sheet"
size="sm"
type="number"
min="0"
step="1"
value={form.calories_goal ?? diary.calories_goal}
oninput={(e) => {
const v = e.currentTarget.value;
const v = (e.currentTarget as HTMLInputElement).value;
form.calories_goal = v === "" ? null : Number(v);
}}
class="w-full bg-zinc-800 border border-zinc-700 rounded-xl px-4 py-2.5 text-zinc-100 focus:outline-none focus:border-green-500 transition-colors"
/>
<p class="text-xs text-zinc-600 mt-1">
Clear to auto-calculate from macro goals
@ -242,22 +237,19 @@
>{field.label}
<span class="text-zinc-600">({field.unit})</span></label
>
<input
<Input
variant="sheet"
size="sm"
type="number"
min="0"
step="1"
bind:value={form[field.key]}
class="w-full bg-zinc-800 border border-zinc-700 rounded-xl px-4 py-2.5 text-zinc-100 focus:outline-none focus:border-green-500 transition-colors"
/>
</div>
{/each}
<button
type="submit"
disabled={saving}
class="w-full bg-green-600 hover:bg-green-500 disabled:opacity-50 rounded-xl py-3 font-semibold transition-colors"
>
<Button type="submit" disabled={saving}>
{saving ? "Saving…" : "Save"}
</button>
</Button>
</form>
</Sheet>

View file

@ -7,6 +7,8 @@
import { deleteEntry } from "$lib/api/entries";
import { goto } from "$app/navigation";
import Sheet from "$lib/components/ui/Sheet.svelte";
import Button from "$lib/components/ui/Button.svelte";
import Input from "$lib/components/ui/Input.svelte";
interface Props {
meal: Meal;
@ -92,6 +94,8 @@
>
<button
onclick={() => (collapsed = !collapsed)}
aria-expanded={!collapsed}
aria-label="{meal.name}, {collapsed ? 'expand' : 'collapse'}"
class="flex-1 flex items-center gap-2 text-left min-w-0"
>
<svg
@ -253,22 +257,18 @@
<form onsubmit={handleSavePreset} class="space-y-4">
<div>
<label class="block text-sm text-zinc-400 mb-1.5">Preset name</label>
<input
bind:this={presetNameInput}
<Input
bind:el={presetNameInput}
variant="sheet"
type="text"
bind:value={presetName}
required
autofocus
class="w-full bg-zinc-800 border border-zinc-700 rounded-xl px-4 py-3 text-zinc-100 focus:outline-none focus:border-green-500 transition-colors"
/>
</div>
<button
type="submit"
disabled={saving || !presetName.trim()}
class="w-full bg-green-600 hover:bg-green-500 disabled:opacity-50 rounded-xl py-3 font-semibold transition-colors"
>
<Button type="submit" disabled={saving || !presetName.trim()}>
{saving ? "Saving…" : "Save preset"}
</button>
</Button>
</form>
</Sheet>
@ -278,20 +278,16 @@
title="Rename meal"
>
<form onsubmit={handleRename} class="space-y-4">
<input
bind:this={renameNameInput}
<Input
bind:el={renameNameInput}
variant="sheet"
type="text"
bind:value={renameName}
required
autofocus
class="w-full bg-zinc-800 border border-zinc-700 rounded-xl px-4 py-3 text-zinc-100 focus:outline-none focus:border-green-500 transition-colors"
/>
<button
type="submit"
disabled={renaming || !renameName.trim()}
class="w-full bg-green-600 hover:bg-green-500 disabled:opacity-50 rounded-xl py-3 font-semibold transition-colors"
>
<Button type="submit" disabled={renaming || !renameName.trim()}>
{renaming ? "Saving…" : "Save"}
</button>
</Button>
</form>
</Sheet>

View file

@ -0,0 +1,25 @@
<script lang="ts">
import type { HTMLButtonAttributes } from "svelte/elements";
import type { Snippet } from "svelte";
interface Props extends HTMLButtonAttributes {
variant?: "primary" | "secondary" | "primary-flex";
children?: Snippet;
}
let { variant = "primary", class: className = "", children, ...rest }: Props =
$props();
const variantClasses: Record<string, string> = {
primary:
"w-full bg-green-600 hover:bg-green-500 disabled:opacity-50 rounded-xl py-3 font-semibold transition-colors",
secondary:
"flex-1 bg-zinc-800 hover:bg-zinc-700 rounded-xl py-3 text-sm font-medium transition-colors",
"primary-flex":
"flex-1 bg-green-600 hover:bg-green-500 rounded-xl py-3 font-semibold transition-colors",
};
</script>
<button class="{variantClasses[variant]} {className}" {...rest}>
{@render children?.()}
</button>

View file

@ -0,0 +1,69 @@
<script lang="ts">
interface Props {
value: number;
min?: number;
max?: number;
step?: number;
inputEl?: HTMLInputElement | null;
onEnter?: () => void;
size?: "sm" | "lg";
}
let {
value = $bindable(100),
min = 1,
max,
step = 10,
inputEl = $bindable(null),
onEnter,
size = "sm",
}: Props = $props();
const btnClass =
size === "lg"
? "w-12 h-12 rounded-xl bg-zinc-900 hover:bg-zinc-800 transition-colors text-xl font-medium flex items-center justify-center"
: "w-11 h-11 rounded-xl bg-zinc-800 hover:bg-zinc-700 transition-colors text-lg font-medium flex items-center justify-center";
const inputClass =
size === "lg"
? "flex-1 bg-zinc-900 border border-zinc-700 rounded-xl px-4 py-3 text-center text-2xl font-semibold focus:outline-none focus:border-green-500 transition-colors"
: "flex-1 bg-zinc-800 border border-zinc-700 rounded-xl px-4 py-2.5 text-center text-xl font-semibold focus:outline-none focus:border-green-500 transition-colors";
</script>
<div class="flex items-center gap-3">
<button
type="button"
aria-label="Decrease"
onpointerdown={(e) => e.preventDefault()}
onclick={() => {
value = Math.max(min, value - step);
}}
class={btnClass}
></button>
<input
bind:this={inputEl}
type="number"
bind:value
{min}
{max}
aria-valuemin={min}
aria-valuenow={value}
aria-valuemax={max}
inputmode="decimal"
autofocus
onfocus={(e) => e.currentTarget.select()}
onkeydown={(e) => {
if (e.key === "Enter") onEnter?.();
}}
class={inputClass}
/>
<button
type="button"
aria-label="Increase"
onpointerdown={(e) => e.preventDefault()}
onclick={() => {
value = value + step;
}}
class={btnClass}
>+</button>
</div>

View file

@ -0,0 +1,30 @@
<script lang="ts">
import type { HTMLInputAttributes } from "svelte/elements";
interface Props extends Omit<HTMLInputAttributes, "value" | "size"> {
variant?: "default" | "sheet";
size?: "md" | "sm";
value?: string | number | null;
el?: HTMLInputElement | null;
}
let {
variant = "default",
size = "md",
value = $bindable<string | number | null>(),
el = $bindable(null),
class: className = "",
...rest
}: Props = $props();
</script>
<input
bind:this={el}
bind:value
class="w-full {variant === 'sheet'
? 'bg-zinc-800'
: 'bg-zinc-900'} border border-zinc-700 rounded-xl px-4 {size === 'sm'
? 'py-2.5'
: 'py-3'} text-zinc-100 focus:outline-none focus:border-green-500 transition-colors {className}"
{...rest}
/>

View file

@ -44,9 +44,9 @@
<svelte:window onkeydown={handleKey} />
{#if open}
<!-- Backdrop -->
<!-- svelte-ignore a11y_click_events_have_key_events a11y_no_static_element_interactions -->
<!-- Backdrop: purely visual overlay; keyboard (Escape) handled on svelte:window -->
<div
role="presentation"
class="fixed inset-0 z-40 bg-black/60 backdrop-blur-sm"
onclick={handleBackdrop}
></div>

View file

@ -16,6 +16,7 @@ let dbInstance: IDBPDatabase | null = null;
export async function getDb() {
if (dbInstance) return dbInstance;
try {
dbInstance = await openDB(DB_NAME, DB_VERSION, {
upgrade(db, oldVersion) {
if (oldVersion < 1) {
@ -30,6 +31,10 @@ export async function getDb() {
}
});
return dbInstance;
} catch (e) {
dbInstance = null;
throw e;
}
}
// ── Diary cache ──────────────────────────────────────────────────────────────

View file

@ -28,6 +28,7 @@ export async function syncOfflineQueue(): Promise<number> {
synced++;
} else {
// Non-retryable failure (e.g. 400 validation error) — drop it to unblock the queue
console.warn(`[sync] dropping failed mutation ${mutation.method} ${mutation.url}: ${res.status}`);
await dequeueMutation(mutation.id!);
}
} catch {

View file

@ -5,11 +5,15 @@
import { listProducts, getProductByBarcode } from "$lib/api/products";
import { cacheProducts, searchCachedProducts } from "$lib/offline/db";
import { offlineAddEntry } from "$lib/offline/mutations";
import { ApiError, extractErrorMessage } from "$lib/api/client";
import { network } from "$lib/offline/network.svelte";
import type { Product } from "$lib/types/api";
import TopBar from "$lib/components/ui/TopBar.svelte";
import Sheet from "$lib/components/ui/Sheet.svelte";
import BarcodeScanner from "$lib/components/ui/BarcodeScanner.svelte";
import Button from "$lib/components/ui/Button.svelte";
import Input from "$lib/components/ui/Input.svelte";
import GramsStepper from "$lib/components/ui/GramsStepper.svelte";
import { kcal, g } from "$lib/utils/format";
import { today } from "$lib/utils/date";
@ -36,11 +40,6 @@
let gramsInput = $state<HTMLInputElement | null>(null);
$effect(() => {
if (!selectedProduct) setTimeout(() => searchInput?.focus(), 50);
else setTimeout(() => gramsInput?.focus(), 50);
});
function handleSearch(value: string) {
q = value;
clearTimeout(debounceTimer);
@ -64,7 +63,7 @@
return searchCachedProducts(debouncedQ);
}
},
staleTime: 0,
staleTime: 30_000,
}));
function selectProduct(product: Product) {
@ -80,8 +79,8 @@
try {
const product = await getProductByBarcode(barcode);
selectProduct(product);
} catch (err: any) {
if (err?.status === 404) {
} catch (err: unknown) {
if (err instanceof ApiError && err.status === 404) {
goto(`/products/new?barcode=${encodeURIComponent(barcode)}`);
} else {
scanError = "Could not look up barcode. Try searching manually.";
@ -98,8 +97,8 @@
try {
await offlineAddEntry(queryClient, date, mealId, selectedProduct, grams);
goto(`/diary/${date}`);
} catch (e: any) {
error = e.message ?? "Failed to add entry";
} catch (e: unknown) {
error = extractErrorMessage(e, "Failed to add entry");
submitting = false;
}
}
@ -115,6 +114,11 @@
}
: null,
);
$effect(() => {
if (!selectedProduct) setTimeout(() => searchInput?.focus(), 50);
else setTimeout(() => gramsInput?.focus(), 50);
});
</script>
{#if scannerOpen}
@ -144,20 +148,21 @@
d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"
/>
</svg>
<input
bind:this={searchInput}
<Input
bind:el={searchInput}
autofocus
type="search"
size="sm"
placeholder="Search foods…"
value={q}
oninput={(e) => handleSearch(e.currentTarget.value)}
oninput={(e) => handleSearch((e.currentTarget as HTMLInputElement).value)}
onkeydown={(e) => {
if (e.key === "Enter") {
const first = productsQuery.data?.[0];
if (first) selectProduct(first);
}
}}
class="w-full bg-zinc-900 border border-zinc-700 rounded-xl pl-9 pr-4 py-2.5 text-sm text-zinc-100 placeholder-zinc-500 focus:outline-none focus:border-green-500 transition-colors"
class="text-sm placeholder-zinc-500 pl-9"
/>
</div>
@ -295,54 +300,24 @@
{/if}
<label class="block text-sm text-zinc-400 mb-2">Grams</label>
<div class="flex items-center gap-3 mb-4">
<button
onpointerdown={(e) => e.preventDefault()}
onclick={() => {
grams = Math.max(1, grams - 10);
}}
class="w-11 h-11 rounded-xl bg-zinc-800 hover:bg-zinc-700 transition-colors text-lg font-medium flex items-center justify-center"
></button
>
<input
bind:this={gramsInput}
type="number"
bind:value={grams}
min="1"
max="5000"
inputmode="decimal"
autofocus
onfocus={(e) => e.currentTarget.select()}
onkeydown={(e) => {
if (e.key === "Enter") handleAddEntry();
}}
class="flex-1 bg-zinc-800 border border-zinc-700 rounded-xl px-4 py-2.5 text-center text-xl font-semibold focus:outline-none focus:border-green-500 transition-colors"
/>
<button
onpointerdown={(e) => e.preventDefault()}
onclick={() => {
grams = grams + 10;
}}
class="w-11 h-11 rounded-xl bg-zinc-800 hover:bg-zinc-700 transition-colors text-lg font-medium flex items-center justify-center"
>+</button
>
<div class="mb-4">
<GramsStepper bind:value={grams} bind:inputEl={gramsInput} onEnter={handleAddEntry} />
</div>
{#if error}
<p class="text-red-400 text-sm mb-3">{error}</p>
{/if}
<button
<Button
onclick={handleAddEntry}
disabled={submitting || grams < 1}
class="w-full bg-green-600 hover:bg-green-500 disabled:opacity-50 rounded-xl py-3 font-semibold transition-colors"
>
{submitting
? "Adding…"
: network.online
? "Add to meal"
: "Add to meal (offline)"}
</button>
</Button>
{/if}
</Sheet>
</div>

View file

@ -8,6 +8,8 @@
import { listPresets } from "$lib/api/presets";
import type { Preset } from "$lib/types/api";
import TopBar from "$lib/components/ui/TopBar.svelte";
import Button from "$lib/components/ui/Button.svelte";
import Input from "$lib/components/ui/Input.svelte";
import { kcal, g } from "$lib/utils/format";
import { today } from "$lib/utils/date";
@ -45,6 +47,12 @@
queryFn: () => listPresets(30),
}));
const filteredPresets = $derived(
(presetsQuery.data ?? []).filter(
(p) => !presetDebounced || p.name.toLowerCase().includes(presetDebounced.toLowerCase()),
),
);
async function handleCreateNew(e: SubmitEvent) {
e.preventDefault();
submitting = true;
@ -99,39 +107,36 @@
<label for="meal-name" class="block text-sm text-zinc-400 mb-2"
>Meal name <span class="text-zinc-600">(optional)</span></label
>
<input
bind:this={mealNameInput}
<Input
bind:el={mealNameInput}
id="meal-name"
type="text"
bind:value={mealName}
placeholder="e.g. Breakfast, Lunch…"
autofocus
class="w-full bg-zinc-900 border border-zinc-700 rounded-xl px-4 py-3 text-zinc-100 placeholder-zinc-600 focus:outline-none focus:border-green-500 transition-colors"
class="placeholder-zinc-600"
/>
</div>
<button
type="submit"
disabled={submitting}
class="w-full bg-green-600 hover:bg-green-500 disabled:opacity-50 rounded-xl py-3 font-semibold transition-colors"
>
<Button type="submit" disabled={submitting}>
{submitting
? "Creating…"
: network.online
? "Create meal"
: "Create meal (offline)"}
</button>
</Button>
</form>
{:else}
<!-- Preset search -->
<div class="px-4 py-3 border-b border-zinc-800">
<input
bind:this={presetQInput}
<Input
bind:el={presetQInput}
type="search"
size="sm"
placeholder="Search presets…"
value={presetQ}
oninput={(e) => handlePresetSearch(e.currentTarget.value)}
oninput={(e) => handlePresetSearch((e.currentTarget as HTMLInputElement).value)}
autofocus
class="w-full bg-zinc-900 border border-zinc-700 rounded-xl px-4 py-2.5 text-sm text-zinc-100 placeholder-zinc-500 focus:outline-none focus:border-green-500 transition-colors"
class="text-sm placeholder-zinc-500"
/>
</div>
@ -145,18 +150,14 @@
<div class="h-16 bg-zinc-900 rounded-xl animate-pulse"></div>
{/each}
</div>
{:else if (presetsQuery.data ?? []).filter((p) => !presetDebounced || p.name
.toLowerCase()
.includes(presetDebounced.toLowerCase())).length === 0}
{:else if filteredPresets.length === 0}
<div class="text-center text-zinc-500 mt-16 px-6">
<p>No presets yet</p>
<p class="text-sm mt-1">Save a meal as preset from the diary view</p>
</div>
{:else}
<ul class="divide-y divide-zinc-800/50">
{#each (presetsQuery.data ?? []).filter((p) => !presetDebounced || p.name
.toLowerCase()
.includes(presetDebounced.toLowerCase())) as preset (preset.id)}
{#each filteredPresets as preset (preset.id)}
<li>
<button
onclick={() => handleFromPreset(preset)}

View file

@ -6,6 +6,8 @@
import { offlineEditEntry } from "$lib/offline/mutations";
import { network } from "$lib/offline/network.svelte";
import TopBar from "$lib/components/ui/TopBar.svelte";
import Button from "$lib/components/ui/Button.svelte";
import GramsStepper from "$lib/components/ui/GramsStepper.svelte";
import { today } from "$lib/utils/date";
const date = $derived(
@ -133,53 +135,27 @@
<!-- Grams input -->
<div>
<label class="block text-sm text-zinc-400 mb-2">Grams</label>
<div class="flex items-center gap-3">
<button
onpointerdown={(e) => e.preventDefault()}
onclick={() => {
grams = Math.max(1, grams - 10);
}}
class="w-12 h-12 rounded-xl bg-zinc-900 hover:bg-zinc-800 transition-colors text-xl font-medium flex items-center justify-center"
></button
>
<input
bind:this={gramsInput}
type="number"
<GramsStepper
bind:value={grams}
min="1"
max="5000"
inputmode="decimal"
autofocus
onfocus={(e) => e.currentTarget.select()}
onkeydown={(e) => {
if (e.key === "Enter") handleSave();
}}
class="flex-1 bg-zinc-900 border border-zinc-700 rounded-xl px-4 py-3 text-center text-2xl font-semibold focus:outline-none focus:border-green-500 transition-colors"
bind:inputEl={gramsInput}
max={5000}
size="lg"
onEnter={handleSave}
/>
<button
onpointerdown={(e) => e.preventDefault()}
onclick={() => {
grams = grams + 10;
}}
class="w-12 h-12 rounded-xl bg-zinc-900 hover:bg-zinc-800 transition-colors text-xl font-medium flex items-center justify-center"
>+</button
>
</div>
</div>
</main>
<div class="px-4 pb-[calc(5rem+var(--safe-bottom))] lg:pb-6">
<button
<Button
onclick={handleSave}
disabled={saving || grams < 1 || grams === entry.grams}
class="w-full bg-green-600 hover:bg-green-500 disabled:opacity-40 rounded-xl py-3.5 font-semibold transition-colors"
>
{saving
? "Saving…"
: network.online
? "Save changes"
: "Save changes (offline)"}
</button>
</Button>
</div>
{/if}
</div>

View file

@ -15,6 +15,9 @@
import { kcal, g } from "$lib/utils/format";
import TopBar from "$lib/components/ui/TopBar.svelte";
import Sheet from "$lib/components/ui/Sheet.svelte";
import Button from "$lib/components/ui/Button.svelte";
import Input from "$lib/components/ui/Input.svelte";
import GramsStepper from "$lib/components/ui/GramsStepper.svelte";
import type { Preset, PresetEntry, Product } from "$lib/types/api";
const queryClient = useQueryClient();
@ -248,12 +251,13 @@
d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"
/>
</svg>
<input
<Input
type="search"
size="sm"
placeholder="Search presets…"
value={q}
oninput={(e) => handleSearch(e.currentTarget.value)}
class="w-full bg-zinc-900 border border-zinc-700 rounded-xl pl-9 pr-4 py-2.5 text-sm text-zinc-100 placeholder-zinc-500 focus:outline-none focus:border-green-500 transition-colors"
oninput={(e) => handleSearch((e.currentTarget as HTMLInputElement).value)}
class="text-sm placeholder-zinc-500 pl-9"
/>
</div>
</div>
@ -511,21 +515,17 @@
title="Create new preset"
>
<form onsubmit={handleCreate} class="space-y-4">
<input
bind:this={creatingNameInput}
<Input
bind:el={creatingNameInput}
variant="sheet"
type="text"
bind:value={creatingName}
required
autofocus
class="w-full bg-zinc-800 border border-zinc-700 rounded-xl px-4 py-3 text-zinc-100 focus:outline-none focus:border-green-500 transition-colors"
/>
<button
type="submit"
disabled={creatingSaving || !creatingName.trim()}
class="w-full bg-green-600 hover:bg-green-500 disabled:opacity-50 rounded-xl py-3 font-semibold transition-colors"
>
<Button type="submit" disabled={creatingSaving || !creatingName.trim()}>
{creatingSaving ? "Saving…" : "Save"}
</button>
</Button>
</form>
</Sheet>
@ -536,21 +536,17 @@
title="Rename preset"
>
<form onsubmit={handleRename} class="space-y-4">
<input
bind:this={renameNameInput}
<Input
bind:el={renameNameInput}
variant="sheet"
type="text"
bind:value={renameName}
required
autofocus
class="w-full bg-zinc-800 border border-zinc-700 rounded-xl px-4 py-3 text-zinc-100 focus:outline-none focus:border-green-500 transition-colors"
/>
<button
type="submit"
disabled={renaming || !renameName.trim()}
class="w-full bg-green-600 hover:bg-green-500 disabled:opacity-50 rounded-xl py-3 font-semibold transition-colors"
>
<Button type="submit" disabled={renaming || !renameName.trim()}>
{renaming ? "Saving…" : "Save"}
</button>
</Button>
</form>
</Sheet>
@ -565,44 +561,11 @@
<p class="text-sm text-zinc-400">{editingEntry.entry.product.name}</p>
<div>
<label class="block text-sm text-zinc-400 mb-2">Grams</label>
<div class="flex items-center gap-3">
<button
type="button"
onpointerdown={(e) => e.preventDefault()}
onclick={() => {
editGrams = Math.max(1, editGrams - 10);
}}
class="w-11 h-11 rounded-xl bg-zinc-800 hover:bg-zinc-700 transition-colors text-lg font-medium flex items-center justify-center"
></button
>
<input
bind:this={editGramsInput}
type="number"
bind:value={editGrams}
min="1"
autofocus
inputmode="decimal"
onfocus={(e) => e.currentTarget.select()}
class="flex-1 bg-zinc-800 border border-zinc-700 rounded-xl px-4 py-2.5 text-center text-xl font-semibold focus:outline-none focus:border-green-500 transition-colors"
/>
<button
type="button"
onpointerdown={(e) => e.preventDefault()}
onclick={() => {
editGrams = editGrams + 10;
}}
class="w-11 h-11 rounded-xl bg-zinc-800 hover:bg-zinc-700 transition-colors text-lg font-medium flex items-center justify-center"
>+</button
>
<GramsStepper bind:value={editGrams} bind:inputEl={editGramsInput} />
</div>
</div>
<button
type="submit"
disabled={editSaving || editGrams < 1}
class="w-full bg-green-600 hover:bg-green-500 disabled:opacity-50 rounded-xl py-3 font-semibold transition-colors"
>
<Button type="submit" disabled={editSaving || editGrams < 1}>
{editSaving ? "Saving…" : "Save"}
</button>
</Button>
</form>
{/if}
</Sheet>
@ -620,14 +583,16 @@
{#if selectedProduct === null}
<!-- Product search -->
<div class="space-y-3">
<input
bind:this={productQInput}
<Input
bind:el={productQInput}
variant="sheet"
type="search"
size="sm"
placeholder="Search foods…"
value={productQ}
oninput={(e) => handleProductSearch(e.currentTarget.value)}
oninput={(e) => handleProductSearch((e.currentTarget as HTMLInputElement).value)}
autofocus
class="w-full bg-zinc-800 border border-zinc-700 rounded-xl px-4 py-2.5 text-sm text-zinc-100 placeholder-zinc-500 focus:outline-none focus:border-green-500 transition-colors"
class="text-sm placeholder-zinc-500"
/>
{#if productsQuery.isPending && productDebouncedQ}
<p class="text-sm text-zinc-500 text-center py-4">Searching…</p>
@ -673,52 +638,15 @@
</div>
<div>
<label class="block text-sm text-zinc-400 mb-2">Grams</label>
<div class="flex items-center gap-3">
<button
type="button"
onpointerdown={(e) => e.preventDefault()}
onclick={() => {
addGrams = Math.max(1, addGrams - 10);
}}
class="w-11 h-11 rounded-xl bg-zinc-800 hover:bg-zinc-700 transition-colors text-lg font-medium flex items-center justify-center"
></button
>
<input
bind:this={addGramsInput}
type="number"
bind:value={addGrams}
min="1"
autofocus
inputmode="decimal"
onfocus={(e) => e.currentTarget.select()}
class="flex-1 bg-zinc-800 border border-zinc-700 rounded-xl px-4 py-2.5 text-center text-xl font-semibold focus:outline-none focus:border-green-500 transition-colors"
/>
<button
type="button"
onpointerdown={(e) => e.preventDefault()}
onclick={() => {
addGrams = addGrams + 10;
}}
class="w-11 h-11 rounded-xl bg-zinc-800 hover:bg-zinc-700 transition-colors text-lg font-medium flex items-center justify-center"
>+</button
>
</div>
<GramsStepper bind:value={addGrams} bind:inputEl={addGramsInput} />
</div>
<div class="flex gap-2">
<button
type="button"
onclick={() => (selectedProduct = null)}
class="flex-1 bg-zinc-800 hover:bg-zinc-700 rounded-xl py-3 text-sm font-medium transition-colors"
>
<Button variant="secondary" type="button" onclick={() => (selectedProduct = null)}>
Back
</button>
<button
type="submit"
disabled={addSaving || addGrams < 1}
class="flex-1 bg-green-600 hover:bg-green-500 disabled:opacity-50 rounded-xl py-3 font-semibold transition-colors"
>
</Button>
<Button variant="primary-flex" type="submit" disabled={addSaving || addGrams < 1}>
{addSaving ? "Adding…" : "Add"}
</button>
</Button>
</div>
</form>
{/if}

View file

@ -2,6 +2,8 @@
import { page } from "$app/state";
import { createProduct } from "$lib/api/products";
import TopBar from "$lib/components/ui/TopBar.svelte";
import Button from "$lib/components/ui/Button.svelte";
import Input from "$lib/components/ui/Input.svelte";
let name = $state(page.url.searchParams.get("name") ?? "");
let barcode = $state(page.url.searchParams.get("barcode") ?? "");
@ -52,15 +54,15 @@
<div>
<label for="name" class="block text-sm text-zinc-400 mb-2">Name</label>
<input
bind:this={nameInput}
<Input
bind:el={nameInput}
id="name"
type="text"
bind:value={name}
placeholder="e.g. Chicken breast"
required
autofocus
class="w-full bg-zinc-900 border border-zinc-700 rounded-xl px-4 py-3 text-zinc-100 placeholder-zinc-600 focus:outline-none focus:border-green-500 transition-colors"
class="placeholder-zinc-600"
/>
</div>
@ -69,7 +71,7 @@
<label for="protein" class="block text-sm text-zinc-400 mb-2"
>Protein (g)</label
>
<input
<Input
id="protein"
type="number"
bind:value={protein}
@ -77,14 +79,13 @@
step="0.1"
inputmode="decimal"
required
class="w-full bg-zinc-900 border border-zinc-700 rounded-xl px-4 py-3 text-zinc-100 focus:outline-none focus:border-green-500 transition-colors"
/>
</div>
<div>
<label for="carb" class="block text-sm text-zinc-400 mb-2"
>Carbs (g)</label
>
<input
<Input
id="carb"
type="number"
bind:value={carb}
@ -92,14 +93,13 @@
step="0.1"
inputmode="decimal"
required
class="w-full bg-zinc-900 border border-zinc-700 rounded-xl px-4 py-3 text-zinc-100 focus:outline-none focus:border-green-500 transition-colors"
/>
</div>
<div>
<label for="fat" class="block text-sm text-zinc-400 mb-2"
>Fat (g)</label
>
<input
<Input
id="fat"
type="number"
bind:value={fat}
@ -107,14 +107,13 @@
step="0.1"
inputmode="decimal"
required
class="w-full bg-zinc-900 border border-zinc-700 rounded-xl px-4 py-3 text-zinc-100 focus:outline-none focus:border-green-500 transition-colors"
/>
</div>
<div>
<label for="fiber" class="block text-sm text-zinc-400 mb-2"
>Fiber (g)</label
>
<input
<Input
id="fiber"
type="number"
bind:value={fiber}
@ -122,7 +121,6 @@
step="0.1"
inputmode="decimal"
required
class="w-full bg-zinc-900 border border-zinc-700 rounded-xl px-4 py-3 text-zinc-100 focus:outline-none focus:border-green-500 transition-colors"
/>
</div>
</div>
@ -138,13 +136,9 @@
<p class="text-red-400 text-sm">{error}</p>
{/if}
<button
type="submit"
disabled={submitting || !name.trim()}
class="w-full bg-green-600 hover:bg-green-500 disabled:opacity-50 rounded-xl py-3 font-semibold transition-colors"
>
<Button type="submit" disabled={submitting || !name.trim()}>
{submitting ? "Saving…" : "Save product"}
</button>
</Button>
</form>
</main>
</div>

View file

@ -4,6 +4,7 @@
import { updateDiary } from "$lib/api/diary";
import TopBar from "$lib/components/ui/TopBar.svelte";
import Sheet from "$lib/components/ui/Sheet.svelte";
import Button from "$lib/components/ui/Button.svelte";
import { today } from "$lib/utils/date";
const queryClient = useQueryClient();
@ -101,7 +102,7 @@
>
<div class="flex-1">
<div class="flex items-center gap-2">
<p class="text-sm font-medium">Calories</p>
<p id="label-calories" class="text-sm font-medium">Calories</p>
{#if form.calories_goal === null}
<span
class="text-xs font-medium text-zinc-500 bg-zinc-800 border border-zinc-700 px-1.5 py-0.5 rounded-full"
@ -114,6 +115,8 @@
</p>
</div>
<input
id="setting-calories"
aria-labelledby="label-calories"
type="number"
min="0"
step="1"
@ -133,10 +136,12 @@
class="bg-zinc-900 rounded-xl px-4 py-3 flex items-center gap-4"
>
<div class="flex-1">
<p class="text-sm font-medium">{field.label}</p>
<p id="label-{field.key}" class="text-sm font-medium">{field.label}</p>
<p class="text-xs text-zinc-500">{field.unit} per day</p>
</div>
<input
id="setting-{field.key}"
aria-labelledby="label-{field.key}"
type="number"
min="0"
step="1"
@ -152,14 +157,13 @@
<p class="text-red-400 text-sm">{error}</p>
{/if}
<button
<Button
type="submit"
disabled={saving}
class="w-full bg-green-600 hover:bg-green-500 disabled:opacity-50 rounded-xl py-3 font-semibold transition-colors
{saved ? 'bg-zinc-700 hover:bg-zinc-700' : ''}"
class={saved ? "bg-zinc-700 hover:bg-zinc-700" : ""}
>
{saving ? "Saving…" : saved ? "Saved!" : "Save"}
</button>
</Button>
</form>
{/if}
</main>
@ -174,15 +178,7 @@
Override today's diary macro goals with these values as well?
</p>
<div class="flex gap-3">
<button
onclick={() => (syncDiaryOpen = false)}
class="flex-1 bg-zinc-800 hover:bg-zinc-700 rounded-xl py-3 text-sm font-medium transition-colors"
>No</button
>
<button
onclick={handleSyncToDiary}
class="flex-1 bg-green-600 hover:bg-green-500 rounded-xl py-3 font-semibold transition-colors"
>Yes</button
>
<Button variant="secondary" onclick={() => (syncDiaryOpen = false)}>No</Button>
<Button variant="primary-flex" onclick={handleSyncToDiary}>Yes</Button>
</div>
</Sheet>

View file

@ -4,6 +4,8 @@
import { auth } from "$lib/auth/store.svelte";
import { onMount } from "svelte";
import { today } from "$lib/utils/date";
import Button from "$lib/components/ui/Button.svelte";
import Input from "$lib/components/ui/Input.svelte";
let username = $state("");
let password = $state("");
@ -39,13 +41,12 @@
for="username"
class="block text-sm font-medium text-zinc-400 mb-1">Username</label
>
<input
<Input
id="username"
type="text"
bind:value={username}
autocomplete="username"
required
class="w-full bg-zinc-900 border border-zinc-700 rounded-xl px-4 py-3 text-zinc-100 focus:outline-none focus:border-green-500 transition-colors"
/>
</div>
@ -54,13 +55,12 @@
for="password"
class="block text-sm font-medium text-zinc-400 mb-1">Password</label
>
<input
<Input
id="password"
type="password"
bind:value={password}
autocomplete="current-password"
required
class="w-full bg-zinc-900 border border-zinc-700 rounded-xl px-4 py-3 text-zinc-100 focus:outline-none focus:border-green-500 transition-colors"
/>
</div>
@ -68,13 +68,9 @@
<p class="text-red-400 text-sm">{error}</p>
{/if}
<button
type="submit"
disabled={loading}
class="w-full bg-green-600 hover:bg-green-500 disabled:opacity-50 rounded-xl py-3 font-semibold transition-colors"
>
<Button type="submit" disabled={loading}>
{loading ? "Signing in…" : "Sign in"}
</button>
</Button>
</form>
<p class="text-center mt-6 text-zinc-500 text-sm">

View file

@ -3,6 +3,9 @@
import { goto } from "$app/navigation";
import { onMount } from "svelte";
import { PUBLIC_TURNSTILE_SITE_KEY } from "$env/static/public";
import { extractErrorMessage } from "$lib/api/client";
import Button from "$lib/components/ui/Button.svelte";
import Input from "$lib/components/ui/Input.svelte";
let username = $state("");
let password = $state("");
@ -52,8 +55,7 @@
await register(username, password, captchaToken);
goto(`/settings`);
} catch (err: unknown) {
const e2 = err as { detail?: { detail?: string } };
error = e2.detail?.detail ?? "Registration failed";
error = extractErrorMessage(err, "Registration failed");
} finally {
loading = false;
}
@ -70,13 +72,12 @@
for="username"
class="block text-sm font-medium text-zinc-400 mb-1">Username</label
>
<input
<Input
id="username"
type="text"
bind:value={username}
autocomplete="username"
required
class="w-full bg-zinc-900 border border-zinc-700 rounded-xl px-4 py-3 text-zinc-100 focus:outline-none focus:border-green-500 transition-colors"
/>
</div>
@ -85,13 +86,12 @@
for="password"
class="block text-sm font-medium text-zinc-400 mb-1">Password</label
>
<input
<Input
id="password"
type="password"
bind:value={password}
autocomplete="new-password"
required
class="w-full bg-zinc-900 border border-zinc-700 rounded-xl px-4 py-3 text-zinc-100 focus:outline-none focus:border-green-500 transition-colors"
/>
</div>
@ -101,13 +101,12 @@
class="block text-sm font-medium text-zinc-400 mb-1"
>Confirm password</label
>
<input
<Input
id="confirm"
type="password"
bind:value={confirm}
autocomplete="new-password"
required
class="w-full bg-zinc-900 border border-zinc-700 rounded-xl px-4 py-3 text-zinc-100 focus:outline-none focus:border-green-500 transition-colors"
/>
</div>
@ -117,13 +116,9 @@
<p class="text-red-400 text-sm">{error}</p>
{/if}
<button
type="submit"
disabled={loading || !captchaToken}
class="w-full bg-green-600 hover:bg-green-500 disabled:opacity-50 rounded-xl py-3 font-semibold transition-colors"
>
<Button type="submit" disabled={loading || !captchaToken}>
{loading ? "Creating account…" : "Create account"}
</button>
</Button>
</form>
<p class="text-center mt-6 text-zinc-500 text-sm">