All checks were successful
Deploy production / deploy (push) Successful in 2m33s
402 lines
14 KiB
Svelte
402 lines
14 KiB
Svelte
<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>
|