add icons to lists and solve money tracker bugs
All checks were successful
Test and deploy staging / staging (push) Successful in 2m30s

This commit is contained in:
2026-09-24 19:24:09 +00:00
parent a10585a4d2
commit c757d0882a
18 changed files with 1358 additions and 731 deletions

View File

@@ -2,6 +2,7 @@ import { db } from '$lib/server/db';
import { listItemCompletions, lists } from '$lib/server/db/schema';
import { requireAdmin } from '$lib/server/authorization';
import { uniqueListSlug } from '$lib/server/slugify';
import { listImageUrl } from '$lib/server/r2';
import { eq } from 'drizzle-orm';
import { fail, redirect } from '@sveltejs/kit';
import type { Actions, PageServerLoad } from './$types';
@@ -36,12 +37,18 @@ export const actions: Actions = {
const form = await event.request.formData();
const name = String(form.get('name') ?? '').trim();
const type = String(form.get('type') ?? '');
const pictureKey = String(form.get('pictureKey') ?? '').trim();
const picture = form.get('picture');
if (!name || name.length > 200)
return fail(400, { message: 'Enter a list name of 1–200 characters.' });
if (type !== 'normal' && type !== 'progress')
return fail(400, { message: 'Choose a list type.' });
if (picture instanceof File && picture.size > 0)
return fail(400, { message: 'Enable JavaScript to upload an image.' });
const pictureUrl = pictureKey ? listImageUrl(pictureKey) : null;
if (pictureKey && !pictureUrl) return fail(400, { message: 'Invalid uploaded picture.' });
const slug = await uniqueListSlug(name);
await db.insert(lists).values({ name, slug, isProgress: type === 'progress' });
await db.insert(lists).values({ name, slug, isProgress: type === 'progress', pictureUrl });
redirect(303, `/lists/${slug}`);
}
};

View File

@@ -2,8 +2,42 @@
import { enhance } from '$app/forms';
import { resolve } from '$app/paths';
import ProgressBar from '$lib/components/ProgressBar.svelte';
import ListPictureDropzone from '$lib/components/ListPictureDropzone.svelte';
import type { SubmitFunction } from '@sveltejs/kit';
import type { PageProps } from './$types';
let { data, form }: PageProps = $props();
let uploadError = $state('');
let uploading = $state(false);
const uploadPicture: SubmitFunction = async ({ formData, cancel }) => {
uploadError = '';
const picture = formData.get('picture');
formData.delete('picture');
if (!(picture instanceof File) || !picture.size) return;
if (!['image/jpeg', 'image/png', 'image/webp'].includes(picture.type)) {
uploadError = 'Choose a JPEG, PNG, or WebP image.';
cancel();
return;
}
if (picture.size > 5 * 1024 * 1024) {
uploadError = 'Images must be 5 MB or smaller.';
cancel();
return;
}
uploading = true;
try {
const body = new FormData();
body.set('picture', picture);
const response = await fetch(resolve('/lists/api/upload'), { method: 'POST', body });
if (!response.ok) throw new Error('Could not upload the image. Please try again.');
const { key } = await response.json();
formData.set('pictureKey', key);
} catch (cause) {
uploadError = cause instanceof Error ? cause.message : 'Image upload failed.';
cancel();
} finally {
uploading = false;
}
};
</script>
<div class="mx-auto max-w-6xl px-4 py-12 sm:px-6 sm:py-16">
@@ -14,7 +48,8 @@
<form
method="POST"
action="?/newList"
use:enhance
enctype="multipart/form-data"
use:enhance={uploadPicture}
class="absolute top-full right-0 z-10 mt-3 w-80 max-w-[calc(100vw-2rem)] space-y-3 rounded-lg border border-[var(--site-border)] bg-[var(--site-surface)] p-4 shadow-lg"
>
<label class="block"
@@ -33,7 +68,9 @@
></select
></label
>
<ListPictureDropzone label="List picture (optional)" />
<button
disabled={uploading}
class="rounded bg-[var(--site-text)] px-4 py-2 font-semibold text-[var(--site-bg)]"
>create list</button
>
@@ -41,6 +78,7 @@
</details>{/if}
</div>
{#if form?.message}<p role="alert" class="mb-5 text-red-500">{form.message}</p>{/if}
{#if uploadError}<p role="alert" class="mb-5 text-red-500">{uploadError}</p>{/if}
<ul>
{#each data.lists as list (list.id)}
<li class="border-t border-[var(--site-border)]">
@@ -49,7 +87,17 @@
class="block py-5 hover:text-[var(--site-muted)]"
>
<span class="flex flex-wrap items-center justify-between gap-x-6 gap-y-1">
<span class="text-lg font-medium">{list.name}</span>
<span class="flex items-center gap-3">
{#if list.pictureUrl}<img
src={list.pictureUrl}
alt=""
loading="lazy"
width="48"
height="48"
class="h-12 w-12 rounded bg-[var(--site-surface)] object-cover"
/>{/if}
<span class="text-lg font-medium">{list.name}</span>
</span>
<span class="text-base text-[var(--site-muted)]">
{#if list.isProgress && data.user}
{list.total ? Math.round((list.checked / list.total) * 100) : 0}% · {list.checked}/{list.total}

View File

@@ -1,4 +1,5 @@
import { db } from '$lib/server/db';
import { env } from '$env/dynamic/private';
import { lists, listItems, listItemCompletions } from '$lib/server/db/schema';
import { requireAdmin, requireUser } from '$lib/server/authorization';
import { deleteObject, listImageKey, listImageUrl } from '$lib/server/r2';
@@ -13,6 +14,8 @@ async function currentList(slug: string) {
return list;
}
async function removeStoredPicture(url: string | null) {
// Development and staging may reference pictures copied from production.
if (env.ORIGIN !== 'https://stanrunge.dev') return;
const key = url ? listImageKey(url) : null;
if (!key) return;
try {
@@ -79,7 +82,9 @@ export const actions: Actions = {
.from(listItems)
.where(eq(listItems.listId, list.id));
await db.delete(lists).where(eq(lists.id, list.id));
await Promise.all(pictures.map((item) => removeStoredPicture(item.pictureUrl)));
await Promise.all(
[...pictures.map((item) => item.pictureUrl), list.pictureUrl].map(removeStoredPicture)
);
redirect(303, '/lists');
},
updateList: async (event) => {
@@ -90,12 +95,21 @@ export const actions: Actions = {
.trim()
.toLowerCase();
const type = String(form.get('type') ?? '');
const pictureKey = String(form.get('pictureKey') ?? '').trim();
const removePicture = form.get('removePicture') === 'on';
const picture = form.get('picture');
if (!name || name.length > 200)
return fail(400, { message: 'Enter a name of 1–200 characters' });
if (type !== 'normal' && type !== 'progress')
return fail(400, { message: 'Choose a list type.' });
if (!validSlug(slug))
return fail(400, { message: 'Use a lowercase slug with letters, numbers, and hyphens.' });
if (picture instanceof File && picture.size > 0)
return fail(400, { message: 'Enable JavaScript to upload an image.' });
if (pictureKey && removePicture)
return fail(400, { message: 'Choose a new picture or remove the old one.' });
const pictureUrl = pictureKey ? listImageUrl(pictureKey) : null;
if (pictureKey && !pictureUrl) return fail(400, { message: 'Invalid uploaded picture.' });
const current = await currentList(event.params.slug);
const duplicate = await db
.select({ id: lists.id })
@@ -104,10 +118,16 @@ export const actions: Actions = {
if (duplicate.length) return fail(409, { message: 'That list slug is already in use.' });
const [list] = await db
.update(lists)
.set({ name, slug, isProgress: type === 'progress' })
.set({
name,
slug,
isProgress: type === 'progress',
...(pictureKey ? { pictureUrl } : removePicture ? { pictureUrl: null } : {})
})
.where(eq(lists.id, current.id))
.returning();
if (!list) error(404, 'List not found');
if (list.pictureUrl !== current.pictureUrl) await removeStoredPicture(current.pictureUrl);
if (slug !== event.params.slug) redirect(303, `/lists/${slug}`);
return { saved: true };
},

View File

@@ -12,11 +12,28 @@
let uploadError = $state('');
let uploading = $state(false);
let managing = $state(false);
let search = $state('');
let editingItem = $state<(typeof data.list.items)[number] | null>(null);
let visibleItems = $derived(
data.list.items.filter((item) =>
`${item.name} ${item.description ?? ''}`.toLowerCase().includes(search.trim().toLowerCase())
)
);
const uploadPicture: SubmitFunction = async ({ formData, cancel }) => {
const finish = async ({
result,
update
}: {
result: { type: string };
update: () => Promise<void>;
}) => {
await update();
if (result.type === 'success') editingItem = null;
};
uploadError = '';
const picture = formData.get('picture');
formData.delete('picture');
if (!(picture instanceof File) || picture.size === 0) return;
if (!(picture instanceof File) || picture.size === 0) return finish;
if (!['image/jpeg', 'image/png', 'image/webp'].includes(picture.type)) {
uploadError = 'Choose a JPEG, PNG, or WebP image.';
cancel();
@@ -44,9 +61,16 @@
} finally {
uploading = false;
}
return finish;
};
</script>
<svelte:window
onkeydown={(event) => {
if (event.key === 'Escape') editingItem = null;
}}
/>
<section class="mx-auto max-w-4xl space-y-6 px-4 py-10 sm:px-6 sm:py-14">
<a
href={resolve('/lists')}
@@ -54,12 +78,21 @@
><ArrowLeft size={16} aria-hidden="true" /> lists</a
>
<div class="flex flex-wrap items-end justify-between gap-4">
<div>
<h1 class="text-4xl font-semibold tracking-tight sm:text-5xl">{data.list.name}</h1>
<p class="mt-2 text-base text-[var(--site-muted)]">
{data.total}
{data.total === 1 ? 'item' : 'items'}
</p>
<div class="flex items-center gap-4">
{#if data.list.pictureUrl}<img
src={data.list.pictureUrl}
alt=""
width="96"
height="96"
class="h-20 w-20 rounded-lg bg-[var(--site-surface)] object-cover sm:h-24 sm:w-24"
/>{/if}
<div>
<h1 class="text-4xl font-semibold tracking-tight sm:text-5xl">{data.list.name}</h1>
<p class="mt-2 text-base text-[var(--site-muted)]">
{data.total}
{data.total === 1 ? 'item' : 'items'}
</p>
</div>
</div>
{#if data.isAdmin}<button
type="button"
@@ -96,7 +129,8 @@
<form
method="POST"
action="?/updateList"
use:enhance
enctype="multipart/form-data"
use:enhance={uploadPicture}
class="grid gap-3 sm:grid-cols-2 sm:items-end"
>
<label
@@ -124,7 +158,15 @@
<option value="progress" selected={data.list.isProgress}>Progress</option>
</select></label
>
<button class="rounded border p-2">Save list</button>
<div class="sm:col-span-2">
<ListPictureDropzone label="List picture (optional)" />
{#if data.list.pictureUrl}<label class="mt-2 block text-sm"
><input type="checkbox" name="removePicture" /> Remove current picture</label
>{/if}
</div>
<button disabled={uploading} class="rounded border p-2 disabled:opacity-50"
>Save list</button
>
</form>
<details class="border-t border-[var(--site-border)] pt-4">
<summary class="cursor-pointer font-medium text-red-700">Delete this list</summary>
@@ -148,8 +190,17 @@
</div>
</details>
{/if}
<label class="block">
<span class="sr-only">Search list items</span>
<input
type="search"
bind:value={search}
placeholder="Search list items"
class="w-full rounded border border-[var(--site-border)] bg-[var(--site-bg)] px-3 py-2"
/>
</label>
<ul class="divide-y divide-[var(--site-border)] border-t border-[var(--site-border)]">
{#each data.list.items as item (item.id)}
{#each visibleItems as item (item.id)}
<li class="py-4">
<div class="flex items-start gap-4">
{#if item.pictureUrl}<img
@@ -180,56 +231,85 @@
</button>
</form>
{/if}
{#if data.isAdmin && managing}<button
type="button"
onclick={() => (editingItem = item)}
class="shrink-0 rounded border border-[var(--site-border)] px-3 py-1 text-sm hover:bg-[var(--site-surface)]"
aria-label={`Edit ${item.name}`}>Edit</button
>{/if}
</div>
{#if data.isAdmin && managing}
<details class="mt-3 border-t border-[var(--site-border)] pt-3">
<summary class="cursor-pointer text-base underline">edit item</summary>
<form
method="POST"
action="?/updateItem"
enctype="multipart/form-data"
use:enhance={uploadPicture}
class="mt-3 grid gap-3"
>
<input type="hidden" name="itemId" value={item.id} />
<label
>Name<input
name="name"
value={item.name}
required
maxlength="500"
class="block w-full rounded border p-2"
/></label
>
<ListPictureDropzone label="Replace picture (optional)" />
{#if item.pictureUrl}<label class="text-sm"
><input type="checkbox" name="removePicture" /> Remove current picture</label
>{/if}
<label
>Description (optional)<textarea
name="description"
maxlength="5000"
rows="3"
class="block w-full rounded border p-2">{item.description ?? ''}</textarea
></label
>
<button
disabled={uploading}
class="justify-self-start rounded bg-gray-900 px-4 py-2 text-white disabled:opacity-50"
>Save item</button
>
</form>
<form method="POST" action="?/removeItem" use:enhance class="mt-3">
<input type="hidden" name="itemId" value={item.id} />
<button class="text-sm text-red-700 underline" aria-label={`Remove ${item.name}`}
>Remove item</button
>
</form>
</details>
{/if}
</li>
{:else}<li class="py-5 text-[var(--site-muted)]">no items yet</li>{/each}
{:else}<li class="py-5 text-[var(--site-muted)]">
{search ? 'No matching items' : 'no items yet'}
</li>{/each}
</ul>
{#if data.isAdmin && managing && editingItem}
<div class="fixed inset-0 z-40 bg-black/60"></div>
<div class="fixed inset-0 z-50 overflow-y-auto p-4 sm:p-8">
<div
role="dialog"
aria-modal="true"
aria-label={`Edit ${editingItem.name}`}
class="mx-auto max-w-xl rounded-lg bg-[var(--site-bg)] p-5 shadow-xl"
>
<div class="mb-4 flex items-center justify-between gap-4">
<h2 class="text-xl font-semibold">Edit item</h2>
<button type="button" onclick={() => (editingItem = null)} class="underline">Close</button
>
</div>
<form
method="POST"
action="?/updateItem"
enctype="multipart/form-data"
use:enhance={uploadPicture}
class="grid gap-3"
>
<input type="hidden" name="itemId" value={editingItem.id} />
<label
>Name<input
name="name"
value={editingItem.name}
required
maxlength="500"
class="block w-full rounded border p-2"
/></label
>
<ListPictureDropzone label="Replace picture (optional)" />
{#if editingItem.pictureUrl}<label class="text-sm"
><input type="checkbox" name="removePicture" /> Remove current picture</label
>{/if}
<label
>Description (optional)<textarea
name="description"
maxlength="5000"
rows="3"
class="block w-full rounded border p-2">{editingItem.description ?? ''}</textarea
></label
>
<button
disabled={uploading}
class="justify-self-start rounded bg-gray-900 px-4 py-2 text-white disabled:opacity-50"
>Save item</button
>
</form>
<form
method="POST"
action="?/removeItem"
use:enhance={() =>
async ({ result, update }) => {
await update();
if (result.type === 'success') editingItem = null;
}}
class="mt-5 border-t border-[var(--site-border)] pt-4"
>
<input type="hidden" name="itemId" value={editingItem.id} />
<button class="text-sm text-red-700 underline" aria-label={`Remove ${editingItem.name}`}
>Remove item</button
>
</form>
</div>
</div>
{/if}
{#if data.isAdmin && managing}
<details class="border-t border-[var(--site-border)] pt-5">
<summary class="cursor-pointer font-medium">add item</summary>