Files
website/src/routes/lists/[slug]/+page.svelte
Stan Runge 9d08e6af23
All checks were successful
Deploy production / deploy (push) Successful in 2m33s
Lists: owner-only database browser/editor for all tables (meta.edit_log audit)
2026-10-24 02:52:18 +00:00

402 lines
14 KiB
Svelte
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<script lang="ts">
import { Input } from '#lib/components/ui/input/index.js';
import { Textarea } from '#lib/components/ui/textarea/index.js';
import { Checkbox } from '#lib/components/ui/checkbox/index.js';
import * as Collapsible from '#lib/components/ui/collapsible/index.js';
import * as AlertDialog from '#lib/components/ui/alert-dialog/index.js';
import * as Dialog from '#lib/components/ui/dialog/index.js';
import { Button } from '#lib/components/ui/button/index.js';
let addOpen = $state(false);
import { resolve } from '$app/paths';
import ArrowLeft from 'lucide-svelte/icons/arrow-left';
import Check from 'lucide-svelte/icons/check';
import { enhance, type SubmitFunction } from '$app/forms';
import ListPictureDropzone from '#lib/components/ListPictureDropzone.svelte';
import ProgressBar from '#lib/components/ProgressBar.svelte';
import ListExtraFields from '#lib/components/ListExtraFields.svelte';
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);
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;
addOpen = false;
}
};
uploadError = '';
const picture = formData.get('picture');
formData.delete('picture');
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();
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;
}
return finish;
};
</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="inline-flex items-center gap-1 text-base text-[var(--site-muted)] hover:text-[var(--site-text)]"
><ArrowLeft size={16} aria-hidden="true" /> lists</a
>
<div class="flex flex-wrap items-end justify-between gap-4">
<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.isOwner}<a
href={resolve('/lists/db/[schema]/[table]', {
schema: 'lists',
table: data.list.tableName
})}
class="text-base underline underline-offset-4">table view</a
>{/if}
{#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}
<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>
{/if}
{#if data.isAdmin && managing}
<Collapsible.Root class="border-b border-[var(--site-border)] pb-5">
<Collapsible.Trigger class="cursor-pointer text-base text-[var(--site-muted)]"
>list settings</Collapsible.Trigger
>
<Collapsible.Content class="mt-5 space-y-5">
<form
method="POST"
action="?/updateList"
enctype="multipart/form-data"
use:enhance={uploadPicture}
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
>
<p class="self-end text-sm text-[var(--site-muted)]">
{data.list.isProgress ? 'Progress' : 'Normal'} list (type is fixed once created)
</p>
<div class="sm:col-span-2">
<ListPictureDropzone label="List picture (optional)" />
{#if data.list.pictureUrl}<label class="mt-2 block text-sm"
><Checkbox name="removePicture" value="on" /> Remove current picture</label
>{/if}
</div>
<Button type="submit" disabled={uploading} class="rounded border p-2 disabled:opacity-50"
>Save list</Button
>
</form>
<AlertDialog.Root>
<AlertDialog.Trigger class="font-medium text-destructive"
>Delete this list</AlertDialog.Trigger
><AlertDialog.Content
><AlertDialog.Header
><AlertDialog.Title>Delete this list?</AlertDialog.Title><AlertDialog.Description
>Deleting this list also removes its items and saved progress.</AlertDialog.Description
></AlertDialog.Header
>
<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
>
<AlertDialog.Footer
><AlertDialog.Cancel>Keep list</AlertDialog.Cancel><Button
type="submit"
variant="destructive">Delete list</Button
></AlertDialog.Footer
>
</form>
</AlertDialog.Content></AlertDialog.Root
>
</Collapsible.Content>
</Collapsible.Root>
{/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 visibleItems 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}
{#if data.list.columns.length}<p class="mt-1 text-sm text-[var(--site-muted)]">
{#each data.list.columns.filter((c) => item.extras[c.name] !== null && item.extras[c.name] !== '') as column, i (column.name)}{#if i}
·
{/if}{column.label}: {#if column.uiType === 'url'}<a
class="underline"
href={String(item.extras[column.name])}
rel="noopener noreferrer">link</a
>{:else if column.uiType === 'bool'}{item.extras[column.name]
? 'yes'
: 'no'}{:else if column.uiType === 'cents'}{(
Number(item.extras[column.name]) / 100
).toFixed(2)}{:else}{item.extras[column.name]}{/if}{/each}
</p>{/if}
</div>
{#if data.list.isProgress && data.isAdmin}
<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
type="submit"
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}`}
>
{#if completed.has(item.id)}<Check size={16} aria-hidden="true" />{/if}
</Button>
</form>
{:else if data.list.isProgress && completed.has(item.id)}
<Check size={16} aria-label="Done" class="shrink-0" />
{/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>
</li>
{:else}<li class="py-5 text-[var(--site-muted)]">
{search ? 'No matching items' : 'no items yet'}
</li>{/each}
</ul>
<Dialog.Root
open={!!(data.isAdmin && managing && editingItem)}
onOpenChange={(open) => {
if (!open) editingItem = null;
}}
>
<Dialog.Content class="max-h-[90dvh] overflow-y-auto sm:max-w-xl">
<Dialog.Header
><Dialog.Title>Edit item</Dialog.Title><Dialog.Description
>Update the name, picture and description.</Dialog.Description
></Dialog.Header
>
{#if form?.message}<p role="alert" class="text-destructive">{form.message}</p>{/if}
{#if uploadError}<p role="alert" class="text-destructive">{uploadError}</p>{/if}
{#if editingItem}
<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"
><Checkbox name="removePicture" value="on" /> Remove current picture</label
>{/if}
<label
>Description (optional)<Textarea
name="description"
maxlength={5000}
rows={3}
class="block w-full rounded border p-2"
value={editingItem.description ?? ''}
/></label
>
<ListExtraFields columns={data.list.columns} values={editingItem.extras} />
<Button
type="submit"
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;
addOpen = false;
}
}}
class="mt-5 border-t border-[var(--site-border)] pt-4"
>
<input type="hidden" name="itemId" value={editingItem.id} />
<Button
type="submit"
class="text-sm text-red-700 underline"
aria-label={`Remove ${editingItem.name}`}>Remove item</Button
>
</form>
{/if}</Dialog.Content
></Dialog.Root
>
{#if data.isAdmin && managing}
<Dialog.Root bind:open={addOpen}
><Dialog.Trigger class="underline underline-offset-4">add item</Dialog.Trigger><Dialog.Content
><Dialog.Header
><Dialog.Title>Add item</Dialog.Title><Dialog.Description
>Add a new entry to this list.</Dialog.Description
></Dialog.Header
>
{#if form?.message}<p role="alert" class="text-destructive">{form.message}</p>{/if}
{#if uploadError}<p role="alert" class="text-destructive">{uploadError}</p>{/if}
<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"
/></label
>
<ListExtraFields columns={data.list.columns} />
<Button
type="submit"
disabled={uploading}
class="justify-self-start rounded bg-gray-900 px-4 py-2 text-white disabled:opacity-50"
>Add item</Button
>
</form>
</Dialog.Content></Dialog.Root
>
<noscript><p>Enable JavaScript to upload item pictures.</p></noscript>
{/if}
</section>