Spaces:
Running
Running
assafvayner HF Staff
fix(ui): split editor usable without JS, untrack prop snapshots, clean orphan photo on conflict
3b74a95 | <script lang="ts"> | |
| import { enhance } from '$app/forms'; | |
| import { downscaleImage } from '$lib/client/downscale'; | |
| import type { ExpensePhoto, PublicUser } from '$lib/domain/types'; | |
| import type { ExpenseFormValues, FieldErrors } from '$lib/forms/types'; | |
| import { untrack } from 'svelte'; | |
| import FormError from './FormError.svelte'; | |
| import SplitEditor from './SplitEditor.svelte'; | |
| let { | |
| members, | |
| currency, | |
| values, | |
| errors = {}, | |
| existingPhoto, | |
| version, | |
| submitLabel | |
| }: { | |
| members: PublicUser[]; | |
| currency: string; | |
| values: ExpenseFormValues; | |
| errors?: FieldErrors; | |
| existingPhoto?: ExpensePhoto; | |
| version?: number; | |
| submitLabel: string; | |
| } = $props(); | |
| // one-time snapshot of props at mount; not meant to react to later prop changes | |
| let amount = $state(untrack(() => values.amount)); | |
| let submitting = $state(false); | |
| </script> | |
| <form | |
| method="POST" | |
| enctype="multipart/form-data" | |
| class="space-y-4" | |
| use:enhance={async ({ formData }) => { | |
| submitting = true; | |
| let reachedReturn = false; | |
| try { | |
| const photo = formData.get('photo'); | |
| if (photo instanceof File && photo.size > 0 && photo.type.startsWith('image/')) { | |
| try { | |
| const small = await downscaleImage(photo); | |
| if (small) formData.set('photo', small, 'photo.jpg'); | |
| } catch { | |
| /* keep original file */ | |
| } | |
| } | |
| reachedReturn = true; | |
| } finally { | |
| if (!reachedReturn) submitting = false; | |
| } | |
| return async ({ update }) => { | |
| submitting = false; | |
| await update(); | |
| }; | |
| }} | |
| > | |
| {#if version !== undefined} | |
| <input type="hidden" name="version" value={version} /> | |
| {/if} | |
| <div> | |
| <label for="description">Description</label> | |
| <input | |
| id="description" | |
| name="description" | |
| required | |
| maxlength="120" | |
| value={values.description} | |
| placeholder="Dinner, groceries, tickets…" | |
| /> | |
| <FormError message={errors.description} /> | |
| </div> | |
| <div class="grid grid-cols-2 gap-3"> | |
| <div> | |
| <label for="amount">Amount ({currency})</label> | |
| <input id="amount" name="amount" inputmode="decimal" placeholder="0.00" required bind:value={amount} /> | |
| <FormError message={errors.amount} /> | |
| </div> | |
| <div> | |
| <label for="date">Date</label> | |
| <input id="date" name="date" type="date" required value={values.date} /> | |
| <FormError message={errors.date} /> | |
| </div> | |
| </div> | |
| <div> | |
| <label for="paidBy">Paid by</label> | |
| <select id="paidBy" name="paidBy" value={values.paidBy}> | |
| <option value="">Choose…</option> | |
| {#each members as m (m.id)} | |
| <option value={m.id}>{m.displayName}</option> | |
| {/each} | |
| </select> | |
| <FormError message={errors.paidBy} /> | |
| </div> | |
| <SplitEditor {members} {currency} {values} {amount} error={errors.participants ?? errors.shares} /> | |
| <div> | |
| <label for="notes">Notes (optional)</label> | |
| <textarea id="notes" name="notes" rows="2" maxlength="2000" value={values.notes}></textarea> | |
| </div> | |
| <div> | |
| <label for="photo">Receipt photo (optional)</label> | |
| {#if existingPhoto} | |
| <img src="/{existingPhoto.key}" alt="Current receipt" class="mb-2 max-h-40 rounded-lg" /> | |
| <label class="mb-2 flex items-center gap-2 font-normal" | |
| ><input type="checkbox" name="removePhoto" class="h-5 w-5" /> Remove current photo</label | |
| > | |
| {/if} | |
| <input id="photo" name="photo" type="file" accept="image/*" /> | |
| <FormError message={errors.photo} /> | |
| </div> | |
| <FormError message={errors.form} /> | |
| <button | |
| type="submit" | |
| disabled={submitting} | |
| class="w-full rounded-lg bg-green-700 py-3 font-semibold text-white disabled:opacity-60" | |
| > | |
| {submitting ? 'Saving…' : submitLabel} | |
| </button> | |
| </form> | |