assafvayner HF Staff commited on
Commit
4ae345a
·
1 Parent(s): 650ab40

feat(ui): add expense form with live split editor and photo upload

Browse files
src/lib/client/downscale.ts ADDED
@@ -0,0 +1,25 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * Downscales an image file in the browser to at most `maxEdge` px on its longest side
3
+ * and re-encodes it as JPEG. Returns null when no processing is needed or possible.
4
+ */
5
+ export async function downscaleImage(file: File, maxEdge = 1600, quality = 0.85): Promise<Blob | null> {
6
+ if (typeof createImageBitmap === 'undefined' || typeof document === 'undefined') return null;
7
+ try {
8
+ const bitmap = await createImageBitmap(file);
9
+ const scale = Math.min(1, maxEdge / Math.max(bitmap.width, bitmap.height));
10
+ if (scale === 1 && file.type === 'image/jpeg') {
11
+ bitmap.close();
12
+ return null;
13
+ }
14
+ const canvas = document.createElement('canvas');
15
+ canvas.width = Math.max(1, Math.round(bitmap.width * scale));
16
+ canvas.height = Math.max(1, Math.round(bitmap.height * scale));
17
+ const ctx = canvas.getContext('2d');
18
+ if (!ctx) return null;
19
+ ctx.drawImage(bitmap, 0, 0, canvas.width, canvas.height);
20
+ bitmap.close();
21
+ return await new Promise<Blob | null>((resolve) => canvas.toBlob(resolve, 'image/jpeg', quality));
22
+ } catch {
23
+ return null;
24
+ }
25
+ }
src/lib/components/ExpenseForm.svelte ADDED
@@ -0,0 +1,115 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <script lang="ts">
2
+ import { enhance } from '$app/forms';
3
+ import { downscaleImage } from '$lib/client/downscale';
4
+ import type { ExpensePhoto, PublicUser } from '$lib/domain/types';
5
+ import type { ExpenseFormValues, FieldErrors } from '$lib/forms/types';
6
+ import FormError from './FormError.svelte';
7
+ import SplitEditor from './SplitEditor.svelte';
8
+
9
+ let {
10
+ members,
11
+ currency,
12
+ values,
13
+ errors = {},
14
+ existingPhoto,
15
+ version,
16
+ submitLabel
17
+ }: {
18
+ members: PublicUser[];
19
+ currency: string;
20
+ values: ExpenseFormValues;
21
+ errors?: FieldErrors;
22
+ existingPhoto?: ExpensePhoto;
23
+ version?: number;
24
+ submitLabel: string;
25
+ } = $props();
26
+
27
+ let amount = $state(values.amount);
28
+ let submitting = $state(false);
29
+ </script>
30
+
31
+ <form
32
+ method="POST"
33
+ enctype="multipart/form-data"
34
+ class="space-y-4"
35
+ use:enhance={async ({ formData }) => {
36
+ submitting = true;
37
+ const photo = formData.get('photo');
38
+ if (photo instanceof File && photo.size > 0 && photo.type.startsWith('image/')) {
39
+ const small = await downscaleImage(photo);
40
+ if (small) formData.set('photo', small, 'photo.jpg');
41
+ }
42
+ return async ({ update }) => {
43
+ submitting = false;
44
+ await update();
45
+ };
46
+ }}
47
+ >
48
+ {#if version !== undefined}
49
+ <input type="hidden" name="version" value={version} />
50
+ {/if}
51
+
52
+ <div>
53
+ <label for="description">Description</label>
54
+ <input
55
+ id="description"
56
+ name="description"
57
+ required
58
+ maxlength="120"
59
+ value={values.description}
60
+ placeholder="Dinner, groceries, tickets…"
61
+ />
62
+ <FormError message={errors.description} />
63
+ </div>
64
+
65
+ <div class="grid grid-cols-2 gap-3">
66
+ <div>
67
+ <label for="amount">Amount ({currency})</label>
68
+ <input id="amount" name="amount" inputmode="decimal" placeholder="0.00" required bind:value={amount} />
69
+ <FormError message={errors.amount} />
70
+ </div>
71
+ <div>
72
+ <label for="date">Date</label>
73
+ <input id="date" name="date" type="date" required value={values.date} />
74
+ <FormError message={errors.date} />
75
+ </div>
76
+ </div>
77
+
78
+ <div>
79
+ <label for="paidBy">Paid by</label>
80
+ <select id="paidBy" name="paidBy" value={values.paidBy}>
81
+ {#each members as m (m.id)}
82
+ <option value={m.id}>{m.displayName}</option>
83
+ {/each}
84
+ </select>
85
+ <FormError message={errors.paidBy} />
86
+ </div>
87
+
88
+ <SplitEditor {members} {currency} {values} {amount} error={errors.participants ?? errors.shares} />
89
+
90
+ <div>
91
+ <label for="notes">Notes (optional)</label>
92
+ <textarea id="notes" name="notes" rows="2" maxlength="2000" value={values.notes}></textarea>
93
+ </div>
94
+
95
+ <div>
96
+ <label for="photo">Receipt photo (optional)</label>
97
+ {#if existingPhoto}
98
+ <img src="/{existingPhoto.key}" alt="Current receipt" class="mb-2 max-h-40 rounded-lg" />
99
+ <label class="mb-2 flex items-center gap-2 font-normal"
100
+ ><input type="checkbox" name="removePhoto" class="h-5 w-5" /> Remove current photo</label
101
+ >
102
+ {/if}
103
+ <input id="photo" name="photo" type="file" accept="image/*" />
104
+ <FormError message={errors.photo} />
105
+ </div>
106
+
107
+ <FormError message={errors.form} />
108
+ <button
109
+ type="submit"
110
+ disabled={submitting}
111
+ class="w-full rounded-lg bg-green-700 py-3 font-semibold text-white disabled:opacity-60"
112
+ >
113
+ {submitting ? 'Saving…' : submitLabel}
114
+ </button>
115
+ </form>
src/lib/components/SplitEditor.svelte ADDED
@@ -0,0 +1,131 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <script lang="ts">
2
+ import { formatCents, parseAmountToCents, parsePercentToBp } from '$lib/domain/money';
3
+ import { allocate } from '$lib/domain/split';
4
+ import type { PublicUser, SplitMode } from '$lib/domain/types';
5
+ import type { ExpenseFormValues } from '$lib/forms/types';
6
+ import FormError from './FormError.svelte';
7
+
8
+ let {
9
+ members,
10
+ currency,
11
+ values,
12
+ amount,
13
+ error
14
+ }: {
15
+ members: PublicUser[];
16
+ currency: string;
17
+ values: ExpenseFormValues;
18
+ amount: string;
19
+ error?: string;
20
+ } = $props();
21
+
22
+ const modes: { value: SplitMode; label: string }[] = [
23
+ { value: 'equal', label: 'Equally' },
24
+ { value: 'percent', label: 'By %' },
25
+ { value: 'exact', label: 'Exact' }
26
+ ];
27
+
28
+ let mode = $state<SplitMode>(values.mode);
29
+ let participants = $state<string[]>([...values.participants]);
30
+ let pct = $state<Record<string, string>>({ ...values.pct });
31
+ let exact = $state<Record<string, string>>({ ...values.exact });
32
+
33
+ const amountCents = $derived(parseAmountToCents(amount) ?? 0);
34
+ const selected = $derived(members.filter((m) => participants.includes(m.id)));
35
+ const equalShares = $derived<Record<string, number>>(
36
+ amountCents > 0 && selected.length > 0
37
+ ? allocate(
38
+ amountCents,
39
+ selected.map((m) => ({ id: m.id, weight: 1 }))
40
+ )
41
+ : {}
42
+ );
43
+ const pctTotalBp = $derived(
44
+ selected.reduce((acc, m) => acc + (parsePercentToBp(pct[m.id] ?? '') ?? 0), 0)
45
+ );
46
+ const exactTotal = $derived(
47
+ selected.reduce((acc, m) => acc + (parseAmountToCents(exact[m.id] ?? '') ?? 0), 0)
48
+ );
49
+ </script>
50
+
51
+ <fieldset class="space-y-3">
52
+ <legend class="text-sm font-medium text-gray-700">Split</legend>
53
+
54
+ <div class="grid grid-cols-3 gap-1 rounded-lg bg-gray-100 p-1">
55
+ {#each modes as m (m.value)}
56
+ <label
57
+ class="cursor-pointer rounded-md py-2 text-center text-sm {mode === m.value
58
+ ? 'bg-white font-semibold shadow'
59
+ : 'text-gray-600'}"
60
+ >
61
+ <input type="radio" name="mode" value={m.value} bind:group={mode} class="sr-only" />
62
+ {m.label}
63
+ </label>
64
+ {/each}
65
+ </div>
66
+
67
+ <ul class="divide-y divide-gray-200 rounded-lg border border-gray-200 bg-white">
68
+ {#each members as m (m.id)}
69
+ {@const on = participants.includes(m.id)}
70
+ <li class="flex items-center gap-3 px-3 py-2">
71
+ <input
72
+ type="checkbox"
73
+ name="participants"
74
+ value={m.id}
75
+ bind:group={participants}
76
+ id="p_{m.id}"
77
+ class="h-5 w-5 shrink-0"
78
+ />
79
+ <label for="p_{m.id}" class="flex-1 font-normal {on ? '' : 'text-gray-400'}"
80
+ >{m.displayName}</label
81
+ >
82
+ {#if mode === 'equal'}
83
+ <span class="text-sm text-gray-500 tabular-nums"
84
+ >{on && amountCents > 0 ? formatCents(equalShares[m.id] ?? 0, currency) : ''}</span
85
+ >
86
+ {:else if mode === 'percent'}
87
+ <div class="flex w-28 items-center gap-1">
88
+ <input
89
+ name="pct_{m.id}"
90
+ inputmode="decimal"
91
+ placeholder="0"
92
+ bind:value={pct[m.id]}
93
+ disabled={!on}
94
+ class="text-right"
95
+ aria-label="Percent for {m.displayName}"
96
+ />
97
+ <span class="text-sm text-gray-500">%</span>
98
+ </div>
99
+ {:else}
100
+ <input
101
+ name="exact_{m.id}"
102
+ inputmode="decimal"
103
+ placeholder="0.00"
104
+ bind:value={exact[m.id]}
105
+ disabled={!on}
106
+ class="w-28 text-right"
107
+ aria-label="Amount for {m.displayName}"
108
+ />
109
+ {/if}
110
+ </li>
111
+ {/each}
112
+ </ul>
113
+
114
+ {#if mode === 'percent'}
115
+ <p class="text-sm tabular-nums {pctTotalBp === 10000 ? 'text-green-700' : 'text-amber-700'}">
116
+ Total {(pctTotalBp / 100).toFixed(2)}% · {((10000 - pctTotalBp) / 100).toFixed(2)}% remaining
117
+ </p>
118
+ {:else if mode === 'exact'}
119
+ <p
120
+ class="text-sm tabular-nums {exactTotal === amountCents && amountCents > 0
121
+ ? 'text-green-700'
122
+ : 'text-amber-700'}"
123
+ >
124
+ Assigned {formatCents(exactTotal, currency)} · {formatCents(
125
+ amountCents - exactTotal,
126
+ currency
127
+ )} remaining
128
+ </p>
129
+ {/if}
130
+ <FormError message={error} />
131
+ </fieldset>
src/lib/server/photos.ts CHANGED
@@ -1,3 +1,7 @@
 
 
 
 
1
  export const MAX_PHOTO_BYTES = 5 * 1024 * 1024;
2
  export type ImageType = 'image/jpeg' | 'image/png' | 'image/webp';
3
 
@@ -78,3 +82,23 @@ function u24le(b: Uint8Array, i: number): number {
78
  function check(width: number, height: number): { width: number; height: number } | null {
79
  return width > 0 && height > 0 && width <= MAX_IMAGE_DIMENSION && height <= MAX_IMAGE_DIMENSION ? { width, height } : null;
80
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { ulid } from '../domain/ulid';
2
+ import type { ExpensePhoto } from '../domain/types';
3
+ import type { BucketStore } from './storage/store';
4
+
5
  export const MAX_PHOTO_BYTES = 5 * 1024 * 1024;
6
  export type ImageType = 'image/jpeg' | 'image/png' | 'image/webp';
7
 
 
82
  function check(width: number, height: number): { width: number; height: number } | null {
83
  return width > 0 && height > 0 && width <= MAX_IMAGE_DIMENSION && height <= MAX_IMAGE_DIMENSION ? { width, height } : null;
84
  }
85
+
86
+ /** Returns the uploaded file for `name`, or null when the field is absent/empty. */
87
+ export function uploadedFile(form: FormData, name: string): File | null {
88
+ const f = form.get(name);
89
+ return f instanceof File && f.size > 0 ? f : null;
90
+ }
91
+
92
+ export type StoredPhoto = { ok: true; photo: ExpensePhoto } | { ok: false; error: string };
93
+
94
+ /** Validates and stores an uploaded image under photos/<expenseId>/<ulid>.<ext>. */
95
+ export async function storeUploadedPhoto(store: BucketStore, expenseId: string, file: File): Promise<StoredPhoto> {
96
+ if (file.size > MAX_PHOTO_BYTES) return { ok: false, error: 'Photo must be 5 MB or smaller' };
97
+ const bytes = new Uint8Array(await file.arrayBuffer());
98
+ const type = sniffImageType(bytes);
99
+ if (!type) return { ok: false, error: 'Photo must be a JPEG, PNG or WebP image' };
100
+ const key = photoKey(expenseId, ulid(), type);
101
+ await store.put(key, bytes, type);
102
+ const dims = imageDimensions(bytes, type);
103
+ return { ok: true, photo: { key, contentType: type, ...(dims ?? {}) } };
104
+ }
src/routes/expenses/new/+page.server.ts ADDED
@@ -0,0 +1,40 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { toPublicUser } from '$lib/domain/types';
2
+ import { ulid } from '$lib/domain/ulid';
3
+ import { buildExpenseBody, emptyExpenseForm, readExpenseForm } from '$lib/server/forms/expense-form';
4
+ import { today } from '$lib/server/forms/form-utils';
5
+ import { storeUploadedPhoto, uploadedFile } from '$lib/server/photos';
6
+ import { fail, redirect } from '@sveltejs/kit';
7
+ import type { Actions, PageServerLoad } from './$types';
8
+
9
+ export const load: PageServerLoad = ({ locals }) => {
10
+ const members = locals.app.users.activeMembers().map(toPublicUser);
11
+ return {
12
+ members,
13
+ values: emptyExpenseForm(
14
+ locals.user!.id,
15
+ members.map((m) => m.id),
16
+ today()
17
+ )
18
+ };
19
+ };
20
+
21
+ export const actions: Actions = {
22
+ default: async ({ request, locals }) => {
23
+ const { users, ledger, store, config } = locals.app;
24
+ const active = users.activeMembers().map((u) => u.id);
25
+ const form = await request.formData();
26
+ const values = readExpenseForm(form, active);
27
+ const built = buildExpenseBody(values, { activeMemberIds: active, currency: config.currency });
28
+ if (!built.ok) return fail(400, { values, errors: built.errors });
29
+
30
+ const expenseId = `e_${ulid()}`;
31
+ const file = uploadedFile(form, 'photo');
32
+ if (file) {
33
+ const saved = await storeUploadedPhoto(store, expenseId, file);
34
+ if (!saved.ok) return fail(400, { values, errors: { photo: saved.error } });
35
+ built.body.photo = saved.photo;
36
+ }
37
+ await ledger.createExpense(locals.user!.id, built.body, expenseId);
38
+ redirect(303, `/expenses/${expenseId}`);
39
+ }
40
+ };
src/routes/expenses/new/+page.svelte ADDED
@@ -0,0 +1,24 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <script lang="ts">
2
+ import ExpenseForm from '$lib/components/ExpenseForm.svelte';
3
+ import type { PageProps } from './$types';
4
+
5
+ let { data, form }: PageProps = $props();
6
+ </script>
7
+
8
+ <main class="p-4">
9
+ <header class="mb-4 flex items-center gap-3">
10
+ <a href="/" class="text-gray-500" aria-label="Back">←</a>
11
+ <h1 class="text-2xl font-bold">Add expense</h1>
12
+ </header>
13
+ {#if data.members.length === 0}
14
+ <p class="text-gray-600">No members yet. Ask the admin to add users first.</p>
15
+ {:else}
16
+ <ExpenseForm
17
+ members={data.members}
18
+ currency={data.currency}
19
+ values={form?.values ?? data.values}
20
+ errors={form?.errors ?? {}}
21
+ submitLabel="Save expense"
22
+ />
23
+ {/if}
24
+ </main>