app: lists on meta tables, schema-qualified SQL, typed columns
- lists read/write real lists.* tables via meta.tables/meta.columns and the SECURITY DEFINER meta functions; typed extra columns in the item forms - old numeric list URLs 308-redirect to the slug - include_recurring is boolean, osu accuracy/pp/rate numeric - raw SQL qualified with the new schemas
This commit is contained in:
@@ -1,35 +1,11 @@
|
||||
import { db } from '#lib/server/db/index.js';
|
||||
import { listItemCompletions, lists } from '#lib/server/db/schema/index.js';
|
||||
import { requireAdmin } from '#lib/server/authorization.js';
|
||||
import { uniqueListSlug } from '#lib/server/slugify.js';
|
||||
import { listImageUrl } from '#lib/server/r2.js';
|
||||
import { eq } from 'drizzle-orm';
|
||||
import { createList, listSummaries } from '#lib/server/lists.js';
|
||||
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 load: PageServerLoad = async () => ({ lists: await listSummaries() });
|
||||
|
||||
export const actions: Actions = {
|
||||
newList: async (event) => {
|
||||
@@ -47,8 +23,9 @@ export const actions: Actions = {
|
||||
return fail(400, { message: 'Enable JavaScript to upload an image.' });
|
||||
const pictureUrl = pictureKey ? listImageUrl(pictureKey) : null;
|
||||
if (pictureKey && !pictureUrl) return fail(400, { message: 'Invalid uploaded picture.' });
|
||||
const slug = await uniqueListSlug(name);
|
||||
await db.insert(lists).values({ name, slug, isProgress: type === 'progress', pictureUrl });
|
||||
// meta.tables.slug allows at most 63 characters
|
||||
const slug = (await uniqueListSlug(name.slice(0, 60))).slice(0, 63).replace(/-+$/, '');
|
||||
await createList({ slug, name, isProgress: type === 'progress', pictureUrl });
|
||||
redirect(303, `/lists/${slug}`);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -123,7 +123,7 @@
|
||||
>
|
||||
{/if}
|
||||
<span class="min-w-16 whitespace-nowrap text-right text-base text-[var(--site-muted)]">
|
||||
{#if list.isProgress && data.user}
|
||||
{#if list.isProgress}
|
||||
{list.total ? Math.round((list.checked / list.total) * 100) : 0}%
|
||||
{:else}
|
||||
{list.total} {list.total === 1 ? 'item' : 'items'}
|
||||
|
||||
@@ -1,15 +1,28 @@
|
||||
import { db } from '#lib/server/db/index.js';
|
||||
import { ORIGIN } from '$app/env/private';
|
||||
import { lists, listItems, listItemCompletions } from '#lib/server/db/schema/index.js';
|
||||
import { requireAdmin, requireUser } from '#lib/server/authorization.js';
|
||||
import { metaTables } from '#lib/server/db/schema/index.js';
|
||||
import { requireAdmin } from '#lib/server/authorization.js';
|
||||
import { deleteObject, listImageKey, listImageUrl } from '#lib/server/r2.js';
|
||||
import { validSlug } from '#lib/server/slugify.js';
|
||||
import {
|
||||
archiveList,
|
||||
deleteItem,
|
||||
extraFields,
|
||||
findList,
|
||||
getItem,
|
||||
getList,
|
||||
insertItem,
|
||||
listColumns,
|
||||
setDone,
|
||||
updateItem,
|
||||
type ListColumn
|
||||
} from '#lib/server/lists.js';
|
||||
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 } });
|
||||
const list = await findList(slug);
|
||||
if (!list) error(404, 'List not found');
|
||||
return list;
|
||||
}
|
||||
@@ -24,7 +37,7 @@ async function removeStoredPicture(url: string | null) {
|
||||
console.error('Could not remove list picture from R2', cause);
|
||||
}
|
||||
}
|
||||
function itemFields(form: FormData) {
|
||||
function itemFields(form: FormData, columns: ListColumn[]) {
|
||||
const name = String(form.get('name') ?? '').trim();
|
||||
const description = String(form.get('description') ?? '').trim();
|
||||
const pictureKey = String(form.get('pictureKey') ?? '').trim();
|
||||
@@ -37,54 +50,51 @@ function itemFields(form: FormData) {
|
||||
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.' };
|
||||
const extras = extraFields(form, columns);
|
||||
if ('error' in extras) return { error: extras.error };
|
||||
return {
|
||||
values: {
|
||||
name,
|
||||
description: description || null,
|
||||
extras: extras.values,
|
||||
...(pictureKey ? { pictureUrl } : removePicture ? { pictureUrl: null } : {})
|
||||
}
|
||||
};
|
||||
}
|
||||
export const load: PageServerLoad = async ({ params, locals }) => {
|
||||
function itemId(form: FormData) {
|
||||
const id = Number(form.get('itemId'));
|
||||
return Number.isSafeInteger(id) && id > 0 ? id : null;
|
||||
}
|
||||
|
||||
export const load: PageServerLoad = async ({ params }) => {
|
||||
if (/^[1-9]\d*$/.test(params.slug)) {
|
||||
// Old numeric list URLs: list ids were preserved as meta.tables.sort_order.
|
||||
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 [old] = await db
|
||||
.select({ slug: metaTables.slug })
|
||||
.from(metaTables)
|
||||
.where(and(eq(metaTables.kind, 'list'), eq(metaTables.sortOrder, oldId)));
|
||||
if (old) redirect(308, `/lists/${old.slug}`);
|
||||
}
|
||||
}
|
||||
const list = await db.query.lists.findFirst({
|
||||
where: { slug: params.slug },
|
||||
with: { items: { orderBy: { id: 'asc' } } }
|
||||
});
|
||||
const list = await getList(params.slug);
|
||||
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 };
|
||||
const completedIds = list.isProgress
|
||||
? list.items.filter((item) => item.doneAt).map((item) => item.id)
|
||||
: [];
|
||||
return { list, completedIds, checked: completedIds.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)
|
||||
if (confirmation !== list.displayName)
|
||||
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) => item.pictureUrl), list.pictureUrl].map(removeStoredPicture)
|
||||
);
|
||||
// Archived, not dropped: the table and its pictures stay restorable for 7 days.
|
||||
await archiveList(list.slug);
|
||||
redirect(303, '/lists');
|
||||
},
|
||||
updateList: async (event) => {
|
||||
@@ -94,15 +104,12 @@ export const actions: Actions = {
|
||||
const slug = String(form.get('slug') ?? '')
|
||||
.trim()
|
||||
.toLowerCase();
|
||||
const type = String(form.get('type') ?? '');
|
||||
const pictureKey = String(form.get('pictureKey') ?? '').trim();
|
||||
const removePicture = form.get('removePicture') === 'on';
|
||||
const picture = form.get('picture');
|
||||
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))
|
||||
if (!validSlug(slug) || slug.length > 63)
|
||||
return fail(400, { message: 'Use a lowercase slug with letters, numbers, and hyphens.' });
|
||||
if (picture instanceof File && picture.size > 0)
|
||||
return fail(400, { message: 'Enable JavaScript to upload an image.' });
|
||||
@@ -112,20 +119,19 @@ export const actions: Actions = {
|
||||
if (pictureKey && !pictureUrl) return fail(400, { message: 'Invalid uploaded picture.' });
|
||||
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)));
|
||||
.select({ id: metaTables.id })
|
||||
.from(metaTables)
|
||||
.where(and(eq(metaTables.slug, slug), ne(metaTables.id, current.id)));
|
||||
if (duplicate.length) return fail(409, { message: 'That list slug is already in use.' });
|
||||
const [list] = await db
|
||||
.update(lists)
|
||||
.update(metaTables)
|
||||
.set({
|
||||
name,
|
||||
displayName: name,
|
||||
slug,
|
||||
isProgress: type === 'progress',
|
||||
...(pictureKey ? { pictureUrl } : removePicture ? { pictureUrl: null } : {})
|
||||
})
|
||||
.where(eq(lists.id, current.id))
|
||||
.returning();
|
||||
.where(eq(metaTables.id, current.id))
|
||||
.returning({ pictureUrl: metaTables.pictureUrl });
|
||||
if (!list) error(404, 'List not found');
|
||||
if (list.pictureUrl !== current.pictureUrl) await removeStoredPicture(current.pictureUrl);
|
||||
if (slug !== event.params.slug) redirect(303, `/lists/${slug}`);
|
||||
@@ -133,66 +139,48 @@ export const actions: Actions = {
|
||||
},
|
||||
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 });
|
||||
const fields = itemFields(await event.request.formData(), await listColumns(list));
|
||||
if (fields.error) return fail(400, { message: fields.error });
|
||||
await insertItem(list, fields.values!);
|
||||
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 id = itemId(form);
|
||||
if (!id) return fail(400, { message: 'Invalid item.' });
|
||||
const list = await currentList(event.params.slug);
|
||||
const existing = await db.query.listItems.findFirst({ where: { id, listId: list.id } });
|
||||
const fields = itemFields(form, await listColumns(list));
|
||||
if (fields.error) return fail(400, { message: fields.error });
|
||||
const existing = await getItem(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 (!(await updateItem(list, id, fields.values!))) 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 id = itemId(await event.request.formData());
|
||||
if (!id) 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 });
|
||||
const item = await deleteItem(list, id);
|
||||
if (!item) error(404, 'Item not found');
|
||||
await removeStoredPicture(item.pictureUrl);
|
||||
await removeStoredPicture(item.picture_url);
|
||||
return { saved: true };
|
||||
},
|
||||
setCompletion: async (event) => {
|
||||
const user = requireUser(event);
|
||||
// One owner: `done` lives on the item, so only the admin can tick items off.
|
||||
requireAdmin(event);
|
||||
const list = await currentList(event.params.slug);
|
||||
if (!list.isProgress) return fail(400, { message: 'This is not a progress list.' });
|
||||
if (!list.hasDone) return fail(400, { message: 'This is not a progress list.' });
|
||||
const form = await event.request.formData();
|
||||
const id = Number(form.get('itemId'));
|
||||
const id = itemId(form);
|
||||
const completed = form.get('completed');
|
||||
if (!Number.isSafeInteger(id) || id <= 0 || (completed !== 'yes' && completed !== 'no'))
|
||||
if (!id || (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)));
|
||||
}
|
||||
if (!(await setDone(list, id, completed === 'yes'))) error(404, 'Item not found');
|
||||
return { saved: true };
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
<script lang="ts">
|
||||
import { NativeSelect } from '#lib/components/ui/native-select/index.js';
|
||||
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';
|
||||
@@ -14,6 +13,7 @@
|
||||
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));
|
||||
@@ -109,23 +109,16 @@
|
||||
{#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.
|
||||
<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>
|
||||
{/if}
|
||||
<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">
|
||||
@@ -159,12 +152,9 @@
|
||||
class="block w-full rounded border p-2"
|
||||
/></label
|
||||
>
|
||||
<label
|
||||
>Type<NativeSelect 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>
|
||||
</NativeSelect></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"
|
||||
@@ -238,8 +228,21 @@
|
||||
>
|
||||
{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.user}
|
||||
{#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'} />
|
||||
@@ -251,6 +254,8 @@
|
||||
{#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"
|
||||
@@ -309,6 +314,7 @@
|
||||
value={editingItem.description ?? ''}
|
||||
/></label
|
||||
>
|
||||
<ListExtraFields columns={data.list.columns} values={editingItem.extras} />
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={uploading}
|
||||
@@ -373,6 +379,7 @@
|
||||
class="block w-full rounded border p-2"
|
||||
/></label
|
||||
>
|
||||
<ListExtraFields columns={data.list.columns} />
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={uploading}
|
||||
|
||||
Reference in New Issue
Block a user