beeeeg update
Some checks failed
Test and deploy staging / staging (push) Has been cancelled

This commit is contained in:
2026-09-23 04:15:10 +00:00
parent 487eff5bb2
commit d0c7b6548b
135 changed files with 9531 additions and 1777 deletions

View File

@@ -0,0 +1,47 @@
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 { eq } from 'drizzle-orm';
import { fail, redirect } from '@sveltejs/kit';
import type { Actions, PageServerLoad } from './$types';
export const load: PageServerLoad = async ({ locals }) => {
const rows = await db.query.lists.findMany({
with: { items: { columns: { id: true } } },
orderBy: { id: 'desc' }
});
const completed = new Set(
locals.user
? (
await db
.select({ itemId: listItemCompletions.itemId })
.from(listItemCompletions)
.where(eq(listItemCompletions.userId, locals.user.id))
).map((row) => row.itemId)
: []
);
return {
lists: rows.map(({ items, ...list }) => ({
...list,
total: items.length,
checked: list.isProgress ? items.filter((item) => completed.has(item.id)).length : 0
}))
};
};
export const actions: Actions = {
newList: async (event) => {
requireAdmin(event);
const form = await event.request.formData();
const name = String(form.get('name') ?? '').trim();
const type = String(form.get('type') ?? '');
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.' });
const slug = await uniqueListSlug(name);
await db.insert(lists).values({ name, slug, isProgress: type === 'progress' });
redirect(303, `/lists/${slug}`);
}
};

View File

@@ -0,0 +1,78 @@
<script lang="ts">
import { enhance } from '$app/forms';
import { resolve } from '$app/paths';
import ProgressBar from '$lib/components/ProgressBar.svelte';
import type { PageProps } from './$types';
let { data, form }: PageProps = $props();
</script>
<div class="mx-auto max-w-6xl px-4 py-12 sm:px-6 sm:py-16">
<div class="mb-10 flex flex-wrap items-center justify-between gap-4">
<h1 class="text-4xl font-semibold tracking-tight sm:text-5xl">lists</h1>
{#if data.isAdmin}<details class="relative">
<summary class="cursor-pointer text-base underline underline-offset-4">new list</summary>
<form
method="POST"
action="?/newList"
use:enhance
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"
>name<input
name="name"
required
maxlength="200"
class="mt-2 w-full rounded border border-[var(--site-border)] bg-[var(--site-bg)] px-3 py-2"
/></label
>
<label class="block"
>type<select
name="type"
class="mt-2 w-full rounded border border-[var(--site-border)] bg-[var(--site-bg)] px-3 py-2"
><option value="normal">normal</option><option value="progress">progress</option
></select
></label
>
<button
class="rounded bg-[var(--site-text)] px-4 py-2 font-semibold text-[var(--site-bg)]"
>create list</button
>
</form>
</details>{/if}
</div>
{#if form?.message}<p role="alert" class="mb-5 text-red-500">{form.message}</p>{/if}
<ul>
{#each data.lists as list (list.id)}
<li class="border-t border-[var(--site-border)]">
<a
href={resolve('/lists/[slug]', { slug: list.slug })}
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="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}
{:else}
{list.total}
{list.total === 1 ? 'item' : 'items'}{list.isProgress ? ' · progress' : ''}
{/if}
</span>
</span>
{#if list.isProgress}<span class="mt-3 block"
><ProgressBar
value={list.checked}
max={list.total}
label={`Progress on ${list.name}`}
small
/></span
>{/if}
</a>
</li>
{:else}
<li class="border-t border-[var(--site-border)] py-5 text-[var(--site-muted)]">
no lists yet
</li>
{/each}
</ul>
</div>

View File

@@ -0,0 +1,178 @@
import { db } from '$lib/server/db';
import { lists, listItems, listItemCompletions } from '$lib/server/db/schema';
import { requireAdmin, requireUser } from '$lib/server/authorization';
import { deleteObject, listImageKey, listImageUrl } from '$lib/server/r2';
import { validSlug } from '$lib/server/slugify';
import { and, eq, ne } from 'drizzle-orm';
import { error, fail, redirect } from '@sveltejs/kit';
import type { Actions, PageServerLoad } from './$types';
async function currentList(slug: string) {
const list = await db.query.lists.findFirst({ where: { slug } });
if (!list) error(404, 'List not found');
return list;
}
async function removeStoredPicture(url: string | null) {
const key = url ? listImageKey(url) : null;
if (!key) return;
try {
await deleteObject(key);
} catch (cause) {
console.error('Could not remove list picture from R2', cause);
}
}
function itemFields(form: FormData) {
const name = String(form.get('name') ?? '').trim();
const description = String(form.get('description') ?? '').trim();
const pictureKey = String(form.get('pictureKey') ?? '').trim();
const removePicture = form.get('removePicture') === 'on';
const file = form.get('picture');
if (!name || name.length > 500) return { error: 'Enter an item name of 1–500 characters.' };
if (description.length > 5000) return { error: 'Description must be 5,000 characters or less.' };
if (file instanceof File && file.size > 0)
return { error: 'Enable JavaScript to upload an image.' };
if (pictureKey && removePicture) return { error: 'Choose a new picture or remove the old one.' };
const pictureUrl = pictureKey ? listImageUrl(pictureKey) : null;
if (pictureKey && !pictureUrl) return { error: 'Invalid uploaded picture.' };
return {
values: {
name,
description: description || null,
...(pictureKey ? { pictureUrl } : removePicture ? { pictureUrl: null } : {})
}
};
}
export const load: PageServerLoad = async ({ params, locals }) => {
if (/^[1-9]\d*$/.test(params.slug)) {
const oldId = Number(params.slug);
if (Number.isSafeInteger(oldId)) {
const oldList = await db.query.lists.findFirst({ where: { id: oldId } });
if (oldList) redirect(308, `/lists/${oldList.slug}`);
}
}
const list = await db.query.lists.findFirst({
where: { slug: params.slug },
with: { items: { orderBy: { id: 'asc' } } }
});
if (!list) error(404, 'List not found');
const completedIds =
locals.user && list.isProgress
? (
await db
.select({ itemId: listItemCompletions.itemId })
.from(listItemCompletions)
.where(eq(listItemCompletions.userId, locals.user.id))
).map((row) => row.itemId)
: [];
const inList = completedIds.filter((id) => list.items.some((item) => item.id === id));
return { list, completedIds: inList, checked: inList.length, total: list.items.length };
};
export const actions: Actions = {
deleteList: async (event) => {
requireAdmin(event);
const list = await currentList(event.params.slug);
const confirmation = String((await event.request.formData()).get('confirmName') ?? '');
if (confirmation !== list.name)
return fail(400, { message: 'Type the exact list name to confirm deletion.' });
const pictures = await db
.select({ pictureUrl: listItems.pictureUrl })
.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)));
redirect(303, '/lists');
},
updateList: async (event) => {
requireAdmin(event);
const form = await event.request.formData();
const name = String(form.get('name') ?? '').trim();
const slug = String(form.get('slug') ?? '')
.trim()
.toLowerCase();
const type = String(form.get('type') ?? '');
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.' });
const current = await currentList(event.params.slug);
const duplicate = await db
.select({ id: lists.id })
.from(lists)
.where(and(eq(lists.slug, slug), ne(lists.id, current.id)));
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' })
.where(eq(lists.id, current.id))
.returning();
if (!list) error(404, 'List not found');
if (slug !== event.params.slug) redirect(303, `/lists/${slug}`);
return { saved: true };
},
addItem: async (event) => {
requireAdmin(event);
const fields = itemFields(await event.request.formData());
if (fields.error) return fail(400, { message: fields.error });
const list = await currentList(event.params.slug);
await db.insert(listItems).values({ ...fields.values!, listId: list.id });
return { saved: true };
},
updateItem: async (event) => {
requireAdmin(event);
const form = await event.request.formData();
const id = Number(form.get('itemId'));
if (!Number.isSafeInteger(id) || id <= 0) return fail(400, { message: 'Invalid item.' });
const fields = itemFields(form);
if (fields.error) return fail(400, { message: fields.error });
const list = await currentList(event.params.slug);
const existing = await db.query.listItems.findFirst({ where: { id, listId: list.id } });
if (!existing) error(404, 'Item not found');
const [item] = await db
.update(listItems)
.set(fields.values!)
.where(and(eq(listItems.id, id), eq(listItems.listId, list.id)))
.returning({ id: listItems.id });
if (!item) error(404, 'Item not found');
if ('pictureUrl' in fields.values! && fields.values!.pictureUrl !== existing.pictureUrl)
await removeStoredPicture(existing.pictureUrl);
return { saved: true };
},
removeItem: async (event) => {
requireAdmin(event);
const id = Number((await event.request.formData()).get('itemId'));
if (!Number.isSafeInteger(id) || id <= 0) return fail(400, { message: 'Invalid item' });
const list = await currentList(event.params.slug);
const [item] = await db
.delete(listItems)
.where(and(eq(listItems.id, id), eq(listItems.listId, list.id)))
.returning({ pictureUrl: listItems.pictureUrl });
if (!item) error(404, 'Item not found');
await removeStoredPicture(item.pictureUrl);
return { saved: true };
},
setCompletion: async (event) => {
const user = requireUser(event);
const list = await currentList(event.params.slug);
if (!list.isProgress) return fail(400, { message: 'This is not a progress list.' });
const form = await event.request.formData();
const id = Number(form.get('itemId'));
const completed = form.get('completed');
if (!Number.isSafeInteger(id) || id <= 0 || (completed !== 'yes' && completed !== 'no'))
return fail(400, { message: 'Invalid progress change.' });
const item = await db.query.listItems.findFirst({ where: { id, listId: list.id } });
if (!item) error(404, 'Item not found');
if (completed === 'yes') {
await db
.insert(listItemCompletions)
.values({ itemId: id, userId: user.id })
.onConflictDoNothing();
} else {
await db
.delete(listItemCompletions)
.where(and(eq(listItemCompletions.itemId, id), eq(listItemCompletions.userId, user.id)));
}
return { saved: true };
}
};

View File

@@ -0,0 +1,265 @@
<script lang="ts">
import { resolve } from '$app/paths';
import { enhance } from '$app/forms';
import ListPictureDropzone from '$lib/components/ListPictureDropzone.svelte';
import ProgressBar from '$lib/components/ProgressBar.svelte';
import type { SubmitFunction } from '@sveltejs/kit';
import type { PageProps } from './$types';
let { data, form }: PageProps = $props();
let completed = $derived(new Set(data.completedIds));
let uploadError = $state('');
let uploading = $state(false);
let managing = $state(false);
const uploadPicture: SubmitFunction = async ({ formData, cancel }) => {
uploadError = '';
const picture = formData.get('picture');
formData.delete('picture');
if (!(picture instanceof File) || picture.size === 0) 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 uploadForm = new FormData();
uploadForm.set('picture', picture);
const uploaded = await fetch(resolve('/lists/api/upload'), {
method: 'POST',
body: uploadForm
});
if (!uploaded.ok) throw new Error('Could not upload the image. Please try again.');
const { key } = await uploaded.json();
formData.set('pictureKey', key);
} catch (cause) {
uploadError = cause instanceof Error ? cause.message : 'Image upload failed.';
cancel();
} finally {
uploading = false;
}
};
</script>
<section class="mx-auto max-w-4xl space-y-6 px-4 py-10 sm:px-6 sm:py-14">
<a
href={resolve('/lists')}
class="text-base text-[var(--site-muted)] hover:text-[var(--site-text)]">← 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>
{#if data.isAdmin}<button
type="button"
onclick={() => (managing = !managing)}
aria-pressed={managing}
class="text-base underline underline-offset-4">{managing ? 'done' : 'manage list'}</button
>{/if}
</div>
{#if form?.message}<p role="alert" class="text-red-700">{form.message}</p>{/if}
{#if uploadError}<p role="alert" class="text-red-700">{uploadError}</p>{/if}
{#if data.list.isProgress}
{#if data.user}
<div class="border-y border-[var(--site-border)] py-4">
<p class="font-medium">
{data.checked} of {data.total} checked · {data.total
? Math.round((data.checked / data.total) * 100)
: 0}%
</p>
<div class="mt-2">
<ProgressBar value={data.checked} max={data.total} label="List progress" small />
</div>
</div>
{:else}
<p>
<a class="underline" href={resolve(`/login?returnTo=/lists/${data.list.slug}`)}>Log in</a> to
save your progress.
</p>
{/if}
{/if}
{#if data.isAdmin && managing}
<details class="border-b border-[var(--site-border)] pb-5">
<summary class="cursor-pointer text-base text-[var(--site-muted)]">list settings</summary>
<div class="mt-5 space-y-5">
<form
method="POST"
action="?/updateList"
use:enhance
class="grid gap-3 sm:grid-cols-2 sm:items-end"
>
<label
>List name<input
name="name"
value={data.list.name}
required
maxlength="200"
class="block w-full rounded border p-2"
/></label
>
<label
>Slug<input
name="slug"
value={data.list.slug}
required
maxlength="120"
pattern="[a-z0-9]+(-[a-z0-9]+)*"
class="block w-full rounded border p-2"
/></label
>
<label
>Type<select name="type" class="block w-full rounded border p-2">
<option value="normal" selected={!data.list.isProgress}>Normal</option>
<option value="progress" selected={data.list.isProgress}>Progress</option>
</select></label
>
<button class="rounded border p-2">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>
<form method="POST" action="?/deleteList" class="mt-3 space-y-3">
<p class="text-sm">
This deletes the list, its items, and everyone’s saved progress. Type <strong
>{data.list.name}</strong
> to confirm.
</p>
<label class="block"
>List name<input
name="confirmName"
required
autocomplete="off"
class="mt-1 block w-full rounded border p-2"
/></label
>
<button class="rounded bg-red-700 px-4 py-2 text-white">Delete list</button>
</form>
</details>
</div>
</details>
{/if}
<ul class="divide-y divide-[var(--site-border)] border-t border-[var(--site-border)]">
{#each data.list.items as item (item.id)}
<li class="py-4">
<div class="flex items-start gap-4">
{#if item.pictureUrl}<img
src={item.pictureUrl}
alt={item.name}
loading="lazy"
width="64"
height="64"
class="h-16 w-16 shrink-0 rounded bg-[var(--site-surface)] object-cover"
/>{/if}
<div class="min-w-0 flex-1">
<p class:line-through={completed.has(item.id)} class="font-medium">{item.name}</p>
{#if item.description}<p
class="mt-1 whitespace-pre-wrap text-base text-[var(--site-muted)]"
>
{item.description}
</p>{/if}
</div>
{#if data.list.isProgress && data.user}
<form method="POST" action="?/setCompletion" use:enhance class="shrink-0">
<input type="hidden" name="itemId" value={item.id} />
<input type="hidden" name="completed" value={completed.has(item.id) ? 'no' : 'yes'} />
<button
class="flex h-8 w-8 items-center justify-center rounded-full border border-[var(--site-border)] text-base"
aria-label={`${completed.has(item.id) ? 'Uncheck' : 'Check'} ${item.name}`}
>
{completed.has(item.id) ? '✓' : ''}
</button>
</form>
{/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}
</ul>
{#if data.isAdmin && managing}
<details class="border-t border-[var(--site-border)] pt-5">
<summary class="cursor-pointer font-medium">add item</summary>
<form
method="POST"
action="?/addItem"
enctype="multipart/form-data"
use:enhance={uploadPicture}
class="mt-5 grid gap-3"
>
<label
>Name<input
name="name"
required
maxlength="500"
class="block w-full rounded border p-2"
/></label
>
<ListPictureDropzone />
<label
>Description (optional)<textarea
name="description"
maxlength="5000"
rows="3"
class="block w-full rounded border p-2"></textarea></label
>
<button
disabled={uploading}
class="justify-self-start rounded bg-gray-900 px-4 py-2 text-white disabled:opacity-50"
>Add item</button
>
</form>
</details>
<noscript><p>Enable JavaScript to upload item pictures.</p></noscript>
{/if}
</section>

View File

@@ -0,0 +1,24 @@
import { json, error } from '@sveltejs/kit';
import { requireAdmin } from '$lib/server/authorization';
import { uploadImage } from '$lib/server/r2';
import type { RequestHandler } from './$types';
export const POST: RequestHandler = async (event) => {
requireAdmin(event);
const form = await event.request.formData().catch(() => null);
const picture = form?.get('picture');
if (
!(picture instanceof File) ||
!['image/jpeg', 'image/png', 'image/webp'].includes(picture.type)
) {
error(400, 'Choose a JPEG, PNG, or WebP image.');
}
if (!picture.size || picture.size > 5 * 1024 * 1024)
error(400, 'Images must be 5 MB or smaller.');
try {
return json(await uploadImage(picture, 'lists'));
} catch (cause) {
console.error('List image upload failed', cause);
error(503, 'Image storage is unavailable.');
}
};