This commit is contained in:
47
src/routes/lists/+page.server.ts
Normal file
47
src/routes/lists/+page.server.ts
Normal 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}`);
|
||||
}
|
||||
};
|
||||
78
src/routes/lists/+page.svelte
Normal file
78
src/routes/lists/+page.svelte
Normal 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>
|
||||
178
src/routes/lists/[slug]/+page.server.ts
Normal file
178
src/routes/lists/[slug]/+page.server.ts
Normal 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 };
|
||||
}
|
||||
};
|
||||
265
src/routes/lists/[slug]/+page.svelte
Normal file
265
src/routes/lists/[slug]/+page.svelte
Normal 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>
|
||||
24
src/routes/lists/api/upload/+server.ts
Normal file
24
src/routes/lists/api/upload/+server.ts
Normal 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.');
|
||||
}
|
||||
};
|
||||
Reference in New Issue
Block a user