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