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,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>