Files
website/src/routes/lists/+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

221 lines
8.0 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 { onMount } from 'svelte';
let hydrated = $state(false);
onMount(() => {
hydrated = true;
});
import { NativeSelect } from '#lib/components/ui/native-select/index.js';
import * as Dialog from '#lib/components/ui/dialog/index.js';
import { enhance, type SubmitFunction } from '$app/forms';
import { resolve } from '$app/paths';
import ProgressBar from '#lib/components/ProgressBar.svelte';
import ListPictureDropzone from '#lib/components/ListPictureDropzone.svelte';
import { Button } from '#lib/components/ui/button/index.js';
import { Input } from '#lib/components/ui/input/index.js';
import type { PageProps } from './$types';
let { data, form }: PageProps = $props();
let tableFilter = $state('');
const schemas = $derived.by(() => {
const q = tableFilter.trim().toLowerCase();
// eslint-disable-next-line svelte/prefer-svelte-reactivity -- local, rebuilt on every derive
const groups = new Map<string, typeof data.tables>();
for (const t of data.tables) {
if (q && !`${t.schema}.${t.name} ${t.displayName}`.toLowerCase().includes(q)) continue;
groups.set(t.schema, [...(groups.get(t.schema) ?? []), t]);
}
// lists first, then alphabetical
return [...groups].sort(([a], [b]) =>
a === 'lists' ? -1 : b === 'lists' ? 1 : a.localeCompare(b)
);
});
const totalRows = $derived(data.tables.reduce((n, t) => n + (t.rows ?? 0), 0));
const badge = (access: string, relkind: string) =>
access === 'open'
? { text: 'editable', cls: 'border-emerald-600/40 text-emerald-500' }
: access === 'core'
? { text: 'app table', cls: 'border-amber-500/40 text-amber-500' }
: {
text: relkind === 'v' || relkind === 'm' ? 'view' : 'read-only',
cls: 'border-[var(--site-border)] text-[var(--site-muted)]'
};
let uploadError = $state('');
let uploading = $state(false);
const uploadPicture: SubmitFunction = async ({ formData, cancel }) => {
uploadError = '';
const picture = formData.get('picture');
formData.delete('picture');
if (!(picture instanceof File) || !picture.size) 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 body = new FormData();
body.set('picture', picture);
const response = await fetch(resolve('lists/api/upload'), { method: 'POST', body });
if (!response.ok) throw new Error('Could not upload the image. Please try again.');
const { key } = await response.json();
formData.set('pictureKey', key);
} catch (cause) {
uploadError = cause instanceof Error ? cause.message : 'Image upload failed.';
cancel();
} finally {
uploading = false;
}
};
</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}<Dialog.Root>
<Dialog.Trigger disabled={!hydrated} class="underline underline-offset-4"
>new list</Dialog.Trigger
>
<Dialog.Content
><Dialog.Header
><Dialog.Title>new list</Dialog.Title><Dialog.Description
>Create a collection or track progress through a 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="?/newList"
enctype="multipart/form-data"
use:enhance={uploadPicture}
class="space-y-4"
>
<label class="block"
>name<Input name="name" required maxlength={200} class="mt-2" /></label
>
<label class="block"
>type<NativeSelect
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
></NativeSelect
></label
>
<ListPictureDropzone label="List picture (optional)" />
<Button type="submit" disabled={uploading}>create list</Button>
</form>
</Dialog.Content></Dialog.Root
>{/if}
</div>
{#if form?.message}<p role="alert" class="mb-5 text-red-500">{form.message}</p>{/if}
{#if uploadError}<p role="alert" class="mb-5 text-red-500">{uploadError}</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={list.isProgress
? 'grid grid-cols-[minmax(0,1fr)_8rem_auto] items-center gap-3 py-5 hover:text-[var(--site-muted)]'
: 'flex items-center justify-between gap-3 py-5 hover:text-[var(--site-muted)]'}
>
<span class="flex min-w-0 items-center gap-3">
{#if list.pictureUrl}<img
src={list.pictureUrl}
alt=""
loading="lazy"
width="48"
height="48"
class="h-12 w-12 rounded bg-[var(--site-surface)] object-cover"
/>{/if}
<span class="truncate text-lg font-medium">{list.name}</span>
</span>
{#if list.isProgress}
<span class="block w-32"
><ProgressBar
value={list.checked}
max={list.total}
label={`Progress on ${list.name}`}
small
/></span
>
{/if}
<span class="min-w-16 whitespace-nowrap text-right text-base text-[var(--site-muted)]">
{#if list.isProgress}
{list.total ? Math.round((list.checked / list.total) * 100) : 0}%
{:else}
{list.total} {list.total === 1 ? 'item' : 'items'}
{/if}
</span>
</a>
</li>
{:else}
<li class="border-t border-[var(--site-border)] py-5 text-[var(--site-muted)]">
no lists yet
</li>
{/each}
</ul>
{#if data.isOwner && data.tables.length}
<section id="database" class="mt-16">
<div class="mb-4 flex flex-wrap items-end justify-between gap-4">
<div>
<h2 class="text-2xl font-semibold tracking-tight">database</h2>
<p class="mt-1 text-sm text-[var(--site-muted)]">
{data.tables.length} tables in {new Set(data.tables.map((t) => t.schema)).size} schemas, ~{totalRows.toLocaleString(
'en-GB'
)} rows. Only you can see this. Lists are editable; app tables are read-only until you unlock
them; a new list is a new table.
</p>
</div>
<input
type="search"
bind:value={tableFilter}
placeholder="find a table"
aria-label="Find a table"
class="w-56 rounded border border-[var(--site-border)] bg-[var(--site-bg)] px-3 py-1.5 text-sm"
/>
</div>
{#each schemas as [schema, tables] (schema)}
<details open class="border-t border-[var(--site-border)] py-3" id="schema-{schema}">
<summary class="cursor-pointer text-lg font-medium">
{schema}
<span class="ml-2 text-sm font-normal text-[var(--site-muted)]"
>{tables.length} {tables.length === 1 ? 'table' : 'tables'}</span
>
</summary>
<ul class="mt-2 grid gap-x-6 sm:grid-cols-2 lg:grid-cols-3">
{#each tables as t (t.name)}
{@const b = badge(t.access, t.relkind)}
<li>
<a
href={resolve('/lists/db/[schema]/[table]', { schema: t.schema, table: t.name })}
class="flex items-center gap-2 rounded px-2 py-1.5 hover:bg-[var(--site-hover)]"
>
<span class="min-w-0 flex-1 truncate" title={t.description ?? t.displayName}
>{t.name}</span
>
<span class={['rounded-full border px-1.5 text-[10px]', b.cls]}>{b.text}</span>
<span class="w-16 text-right text-xs text-[var(--site-muted)] tabular-nums">
{t.rows === null
? '–'
: `${t.approximate ? '~' : ''}${t.rows.toLocaleString('en-GB')}`}
</span>
</a>
</li>
{/each}
</ul>
</details>
{:else}
<p class="text-[var(--site-muted)]">no tables match</p>
{/each}
</section>
{/if}
</div>