Lists: owner-only database browser/editor for all tables (meta.edit_log audit)
All checks were successful
Deploy production / deploy (push) Successful in 2m33s

This commit is contained in:
2026-10-24 02:52:17 +00:00
parent 941a9f16e1
commit 9d08e6af23
14 changed files with 15698 additions and 123 deletions

View File

@@ -5,7 +5,6 @@ import type { PageServerLoad } from './$types';
export const load: PageServerLoad = async (event) => {
const { table } = await ownerTable(event);
event.setHeaders({ 'cache-control': 'private, no-store' });
const query = rowQuery(event.url);
try {
const result = await readRows(table, query);

View File

@@ -6,7 +6,13 @@
import * as AlertDialog from '#lib/components/ui/alert-dialog/index.js';
import { Button } from '#lib/components/ui/button/index.js';
import FieldInput from '#lib/components/data/FieldInput.svelte';
import { display, fromEditor, shortType, toEditor, type Col } from '#lib/components/data/data-format.js';
import {
display,
fromEditor,
shortType,
toEditor,
type Col
} from '#lib/components/data/data-format.js';
import type { PageProps } from './$types';
let { data }: PageProps = $props();
@@ -42,14 +48,14 @@
node.value = search;
}
function navigate(change: Record<string, string | string[] | null>, keepPage = false) {
const url = new URL(page.url);
const url = new URL(page.url.href);
for (const [k, v] of Object.entries(change)) {
url.searchParams.delete(k);
if (Array.isArray(v)) for (const x of v) url.searchParams.append(k, x);
else if (v !== null && v !== '') url.searchParams.set(k, v);
}
if (!keepPage) url.searchParams.delete('page');
goto(url, { keepFocus: true, noScroll: true, replaceState: true });
goto(url, { reset: false, replace: true });
}
let searchTimer: ReturnType<typeof setTimeout> | undefined;
function onSearch(value: string) {
@@ -107,7 +113,13 @@
let message = $state<{ kind: 'error' | 'ok'; text: string } | null>(null);
let grid = $state<HTMLTableElement>();
async function api(path: string, method: string, body?: unknown, raw?: BodyInit, type?: string) {
async function api(
path: string,
method: string,
body?: unknown,
raw?: string | Blob,
type?: string
) {
const res = await fetch(`${base}/${path}`, {
method,
headers: {
@@ -125,7 +137,10 @@
if (kind === 'ok') setTimeout(() => message?.text === text && (message = null), 2500);
}
function focusCell(r: number, c: number) {
active = { r: Math.max(0, Math.min(rows.length - 1, r)), c: Math.max(0, Math.min(cols.length - 1, c)) };
active = {
r: Math.max(0, Math.min(rows.length - 1, r)),
c: Math.max(0, Math.min(cols.length - 1, c))
};
queueMicrotask(() =>
grid?.querySelector<HTMLElement>(`[data-cell="${active.r}:${active.c}"]`)?.focus()
);
@@ -151,7 +166,10 @@
}
saving = true;
try {
await api('rows', 'PATCH', { pk: pkOf(rows[r]), values: { [col.name]: fromEditor(col, draft) } });
await api('rows', 'PATCH', {
pk: pkOf(rows[r]),
values: { [col.name]: fromEditor(col, draft) }
});
editing = null;
await invalidateAll();
flash('ok', 'Saved');
@@ -257,7 +275,10 @@
name: colName,
label: colLabel,
uiType: colType,
options: colOptions.split(',').map((s) => s.trim()).filter(Boolean)
options: colOptions
.split(',')
.map((s) => s.trim())
.filter(Boolean)
});
colOpen = false;
colName = colLabel = colOptions = '';
@@ -282,7 +303,13 @@
async function confirmImport() {
if (!importFile) return;
try {
const { inserted } = await api('import', 'POST', undefined, await importFile.text(), 'text/csv');
const { inserted } = await api(
'import',
'POST',
undefined,
await importFile.text(),
'text/csv'
);
flash('ok', `Imported ${inserted} row${inserted === 1 ? '' : 's'}`);
await invalidateAll();
} catch (e) {
@@ -297,7 +324,12 @@
let searchBox = $state<HTMLInputElement>();
function globalKey(e: KeyboardEvent) {
const el = e.target as HTMLElement;
if (el.closest('input, textarea, select, [contenteditable], [role="dialog"], [role="alertdialog"]')) return;
if (
el.closest(
'input, textarea, select, [contenteditable], [role="dialog"], [role="alertdialog"]'
)
)
return;
if (e.key === '/') {
e.preventDefault();
searchBox?.focus();
@@ -311,9 +343,15 @@
? { text: 'list · editable', cls: 'border-emerald-600/40 text-emerald-500' }
: t.access === 'core'
? unlocked
? { text: 'app table · editing unlocked', cls: 'border-amber-500/60 bg-amber-500/10 text-amber-500' }
? {
text: 'app table · editing unlocked',
cls: 'border-amber-500/60 bg-amber-500/10 text-amber-500'
}
: { text: 'app table · read-only', cls: 'border-amber-500/40 text-amber-500' }
: { text: t.relkind === 'v' || t.relkind === 'm' ? 'view · read-only' : 'read-only', cls: 'border-[var(--site-border)] text-[var(--site-muted)]' }
: {
text: t.relkind === 'v' || t.relkind === 'm' ? 'view · read-only' : 'read-only',
cls: 'border-[var(--site-border)] text-[var(--site-muted)]'
}
);
</script>
@@ -328,22 +366,35 @@
</nav>
<div class="mb-4 flex flex-wrap items-center gap-3">
<h1 class="text-3xl font-semibold tracking-tight">{t.displayName}</h1>
<span class={['rounded-full border px-2.5 py-0.5 text-xs', accessBadge.cls]}>{accessBadge.text}</span>
{#if t.listSlug}<a class="text-sm underline underline-offset-4" href={resolve('/lists/[slug]', { slug: t.listSlug })}>list view</a>{/if}
<span class={['rounded-full border px-2.5 py-0.5 text-xs', accessBadge.cls]}
>{accessBadge.text}</span
>
{#if t.listSlug}<a
class="text-sm underline underline-offset-4"
href={resolve('/lists/[slug]', { slug: t.listSlug })}>list view</a
>{/if}
<span class="ml-auto text-sm text-[var(--site-muted)]">
{#if data.total !== null}{data.approximate ? '~' : ''}{data.total.toLocaleString('en-GB')} rows{:else}rows: ?{/if}
{#if data.total !== null}{data.approximate ? '~' : ''}{data.total.toLocaleString('en-GB')} rows{:else}rows:
?{/if}
</span>
</div>
{#if t.description}<p class="mb-4 text-sm text-[var(--site-muted)]">{t.description}</p>{/if}
{#if t.access === 'core'}
<div class="mb-4 flex flex-wrap items-center gap-3 rounded border border-amber-500/40 bg-amber-500/5 px-3 py-2 text-sm">
<div
class="mb-4 flex flex-wrap items-center gap-3 rounded border border-amber-500/40 bg-amber-500/5 px-3 py-2 text-sm"
>
{#if unlocked}
<span>Editing is unlocked for this table (this browser tab). Changes go straight into data the {t.schema} app uses, and are logged.</span>
<span
>Editing is unlocked for this table (this browser tab). Changes go straight into data the {t.schema}
app uses, and are logged.</span
>
<Button size="sm" variant="outline" onclick={() => setUnlocked(false)}>lock again</Button>
{:else}
<span>This table belongs to the {t.schema} app, so it is read-only by default.</span>
<Button size="sm" variant="outline" onclick={() => (unlockOpen = true)}>unlock editing…</Button>
<Button size="sm" variant="outline" onclick={() => (unlockOpen = true)}
>unlock editing…</Button
>
{/if}
</div>
{/if}
@@ -359,14 +410,27 @@
aria-label="Search rows"
/>
<form class="flex flex-wrap items-center gap-1" onsubmit={addFilter}>
<select bind:value={fCol} class="rounded border border-[var(--site-border)] bg-[var(--site-bg)] px-2 py-1.5 text-sm" aria-label="Filter column">
<select
bind:value={fCol}
class="rounded border border-[var(--site-border)] bg-[var(--site-bg)] px-2 py-1.5 text-sm"
aria-label="Filter column"
>
{#each cols as c (c.name)}<option value={c.name}>{c.name}</option>{/each}
</select>
<select bind:value={fOp} class="rounded border border-[var(--site-border)] bg-[var(--site-bg)] px-2 py-1.5 text-sm" aria-label="Filter operator">
<select
bind:value={fOp}
class="rounded border border-[var(--site-border)] bg-[var(--site-bg)] px-2 py-1.5 text-sm"
aria-label="Filter operator"
>
{#each OPS as [k, label] (k)}<option value={k}>{label}</option>{/each}
</select>
{#if fOp !== 'null' && fOp !== 'notnull'}
<input bind:value={fVal} class="w-36 rounded border border-[var(--site-border)] bg-[var(--site-bg)] px-2 py-1.5 text-sm" placeholder="value" aria-label="Filter value" />
<input
bind:value={fVal}
class="w-36 rounded border border-[var(--site-border)] bg-[var(--site-bg)] px-2 py-1.5 text-sm"
placeholder="value"
aria-label="Filter value"
/>
{/if}
<Button size="sm" variant="outline" type="submit">filter</Button>
</form>
@@ -374,14 +438,34 @@
{#if editable && t.canInsert}
<Button size="sm" onclick={openNew} title="New row (n)">+ row</Button>
<Button size="sm" variant="outline" onclick={() => fileInput?.click()}>import CSV</Button>
<input bind:this={fileInput} type="file" accept=".csv,text/csv" class="hidden" onchange={pickImport} />
<input
bind:this={fileInput}
type="file"
accept=".csv,text/csv"
class="hidden"
onchange={pickImport}
/>
{/if}
{#if t.isList && t.access === 'open'}
<Button size="sm" variant="outline" onclick={() => { colError = ''; colOpen = true; }}>+ column</Button>
<Button
size="sm"
variant="outline"
onclick={() => {
colError = '';
colOpen = true;
}}>+ column</Button
>
{/if}
<a class="rounded border border-[var(--site-border)] px-3 py-1 text-sm hover:bg-[var(--site-hover)]" href={exportHref} data-sveltekit-reload download>export CSV</a>
<a
class="rounded border border-[var(--site-border)] px-3 py-1 text-sm hover:bg-[var(--site-hover)]"
href={exportHref}
data-sveltekit-reload
download>export CSV</a
>
{#if editable && t.canDelete && selectedRows.length}
<Button size="sm" variant="destructive" onclick={() => askDelete(selectedRows)}>delete {selectedRows.length}</Button>
<Button size="sm" variant="destructive" onclick={() => askDelete(selectedRows)}
>delete {selectedRows.length}</Button
>
{/if}
</div>
</div>
@@ -399,11 +483,24 @@
{/if}
{#if data.queryError}<p role="alert" class="mb-3 text-sm text-red-500">{data.queryError}</p>{/if}
{#if message}
<p role={message.kind === 'error' ? 'alert' : 'status'} class={['mb-3 text-sm', message.kind === 'error' ? 'text-red-500' : 'text-emerald-500']}>{message.text}</p>
<p
role={message.kind === 'error' ? 'alert' : 'status'}
class={['mb-3 text-sm', message.kind === 'error' ? 'text-red-500' : 'text-emerald-500']}
>
{message.text}
</p>
{/if}
<div class="overflow-auto rounded border border-[var(--site-border)]" style="max-height: calc(100vh - 16rem)">
<table bind:this={grid} class="w-full border-collapse text-sm" role="grid" aria-rowcount={data.total ?? -1}>
<div
class="overflow-auto rounded border border-[var(--site-border)]"
style="max-height: calc(100vh - 16rem)"
>
<table
bind:this={grid}
class="w-full border-collapse text-sm"
role="grid"
aria-rowcount={data.total ?? -1}
>
<thead class="sticky top-0 z-10 bg-[var(--site-surface)]">
<tr>
{#if t.primaryKey.length}
@@ -412,38 +509,73 @@
type="checkbox"
aria-label="Select all rows on this page"
checked={rows.length > 0 && selectedRows.length === rows.length}
onchange={(e) => (selected = e.currentTarget.checked ? Object.fromEntries(rows.map((r) => [keyOf(r), true])) : {})}
onchange={(e) =>
(selected = e.currentTarget.checked
? Object.fromEntries(rows.map((r) => [keyOf(r), true]))
: {})}
/>
</th>
{/if}
{#each cols as c (c.name)}
{@const sorted = params.get('sort') === c.name}
<th class="border-b border-[var(--site-border)] px-2 py-1.5 text-left align-bottom font-medium whitespace-nowrap">
<button class="text-left hover:underline" onclick={() => sortBy(c)} title={c.help ?? `Sort by ${c.name}`}>
{c.name}{#if c.pk}<span class="ml-1 text-[10px] text-amber-500">PK</span>{/if}{#if sorted}<span aria-hidden="true">{params.get('dir') === 'desc' ? ' ↓' : ' ↑'}</span>{/if}
<th
class="border-b border-[var(--site-border)] px-2 py-1.5 text-left align-bottom font-medium whitespace-nowrap"
>
<button
class="text-left hover:underline"
onclick={() => sortBy(c)}
title={c.help ?? `Sort by ${c.name}`}
>
{c.name}{#if c.pk}<span class="ml-1 text-[10px] text-amber-500">PK</span
>{/if}{#if sorted}<span aria-hidden="true"
>{params.get('dir') === 'desc' ? ' ↓' : ' ↑'}</span
>{/if}
</button>
<div class="text-[11px] font-normal text-[var(--site-muted)]">
{shortType(c.type)}{c.nullable ? '' : ' · req'}{c.fk ? ` → ${c.fk.table}` : ''}{c.readOnly ? ' · auto' : ''}
{shortType(c.type)}{c.nullable ? '' : ' · req'}{c.fk
? ` → ${c.fk.table}`
: ''}{c.readOnly ? ' · auto' : ''}
</div>
</th>
{/each}
{#if editable && t.canDelete}<th class="w-8 border-b border-[var(--site-border)]"></th>{/if}
{#if editable && t.canDelete}<th class="w-8 border-b border-[var(--site-border)]"
></th>{/if}
</tr>
</thead>
<tbody>
{#each rows as row, r (keyOf(row) + r)}
<tr class={['hover:bg-[var(--site-hover)]', selected[keyOf(row)] && 'bg-[var(--site-hover)]']}>
<tr
class={[
'hover:bg-[var(--site-hover)]',
selected[keyOf(row)] && 'bg-[var(--site-hover)]'
]}
>
{#if t.primaryKey.length}
<td class="border-b border-[var(--site-border)] px-2">
<input type="checkbox" aria-label="Select row" bind:checked={selected[keyOf(row)]} />
<input
type="checkbox"
aria-label="Select row"
bind:checked={selected[keyOf(row)]}
/>
</td>
{/if}
{#each cols as c, ci (c.name)}
{@const v = row[c.name]}
{#if editing && editing.r === r && editing.c === ci}
<td class="min-w-48 border-b border-[var(--site-border)] p-1 align-top">
<FieldInput column={c} bind:value={draft} lookupUrl={`${base}/lookup`} autofocus compact onsave={saveEdit} oncancel={cancelEdit} />
<div class="mt-1 text-[11px] text-[var(--site-muted)]">Enter{c.kind === 'json' || c.uiType === 'longtext' ? ' + Ctrl' : ''} save · Esc cancel · Tab next</div>
<FieldInput
column={c}
bind:value={draft}
lookupUrl={`${base}/lookup`}
autofocus
compact
onsave={saveEdit}
oncancel={cancelEdit}
/>
<div class="mt-1 text-[11px] text-[var(--site-muted)]">
Enter{c.kind === 'json' || c.uiType === 'longtext' ? ' + Ctrl' : ''} save · Esc cancel
· Tab next
</div>
</td>
{:else}
<td
@@ -454,43 +586,81 @@
(c.kind === 'number' || c.uiType === 'cents') && 'text-right tabular-nums',
editable && !c.readOnly && t.canUpdate && 'cursor-text'
]}
title={v === null ? 'null' : typeof v === 'object' ? JSON.stringify(v) : String(v)}
title={v === null
? 'null'
: typeof v === 'object'
? JSON.stringify(v)
: String(v)}
onclick={() => (active = { r, c: ci })}
ondblclick={() => startEdit(r, ci)}
onkeydown={(e) => cellKey(e, r, ci)}
>
{#if v === null}<span class="text-xs text-[var(--site-muted)] opacity-60">null</span>
{:else if c.fk && data.fkLabels[c.name]?.[String(v)]}<span title={String(v)}>{data.fkLabels[c.name][String(v)]}</span>
{#if v === null}<span class="text-xs text-[var(--site-muted)] opacity-60"
>null</span
>
{:else if c.fk && data.fkLabels[c.name]?.[String(v)]}<span title={String(v)}
>{data.fkLabels[c.name][String(v)]}</span
>
{:else if (c.uiType === 'url' || c.uiType === 'image') && typeof v === 'string' && /^https?:\/\//.test(v)}
<a href={v} target="_blank" rel="noopener noreferrer" class="underline underline-offset-2">{display(c, v)}</a>
<a
href={v}
target="_blank"
rel="noopener noreferrer"
class="underline underline-offset-2">{display(c, v)}</a
>
{:else}{display(c, v)}{/if}
</td>
{/if}
{/each}
{#if editable && t.canDelete}
<td class="border-b border-[var(--site-border)] px-1 text-center">
<button class="rounded px-1.5 text-[var(--site-muted)] hover:bg-red-500/10 hover:text-red-500" title="Delete row" aria-label="Delete row" onclick={() => askDelete([row])}>✕</button>
<button
class="rounded px-1.5 text-[var(--site-muted)] hover:bg-red-500/10 hover:text-red-500"
title="Delete row"
aria-label="Delete row"
onclick={() => askDelete([row])}>✕</button
>
</td>
{/if}
</tr>
{:else}
<tr><td colspan={cols.length + 2} class="px-3 py-8 text-center text-[var(--site-muted)]">no rows</td></tr>
<tr
><td colspan={cols.length + 2} class="px-3 py-8 text-center text-[var(--site-muted)]"
>no rows</td
></tr
>
{/each}
</tbody>
</table>
</div>
<div class="mt-3 flex flex-wrap items-center gap-3 text-sm">
<Button size="sm" variant="outline" disabled={pageNo <= 1} onclick={() => navigate({ page: String(pageNo - 1) }, true)}>← prev</Button>
<Button
size="sm"
variant="outline"
disabled={pageNo <= 1}
onclick={() => navigate({ page: String(pageNo - 1) }, true)}>← prev</Button
>
<span>page {pageNo}{pages ? ` of ${pages.toLocaleString('en-GB')}` : ''}</span>
<Button size="sm" variant="outline" disabled={pages !== null ? pageNo >= pages : rows.length < limit} onclick={() => navigate({ page: String(pageNo + 1) }, true)}>next →</Button>
<Button
size="sm"
variant="outline"
disabled={pages !== null ? pageNo >= pages : rows.length < limit}
onclick={() => navigate({ page: String(pageNo + 1) }, true)}>next →</Button
>
<label class="ml-2 flex items-center gap-1 text-[var(--site-muted)]">
rows per page
<select class="rounded border border-[var(--site-border)] bg-[var(--site-bg)] px-1 py-0.5" value={String(limit)} onchange={(e) => navigate({ limit: e.currentTarget.value })}>
<select
class="rounded border border-[var(--site-border)] bg-[var(--site-bg)] px-1 py-0.5"
value={String(limit)}
onchange={(e) => navigate({ limit: e.currentTarget.value })}
>
{#each [25, 50, 100, 250, 500] as n (n)}<option value={String(n)}>{n}</option>{/each}
</select>
</label>
<span class="ml-auto text-xs text-[var(--site-muted)]">↑↓←→ move · Enter edit · Space select · / search · n new row</span>
<span class="ml-auto text-xs text-[var(--site-muted)]"
>↑↓←→ move · Enter edit · Space select · / search · n new row</span
>
</div>
</div>
@@ -499,12 +669,19 @@
<AlertDialog.Header>
<AlertDialog.Title>Unlock editing {t.schema}.{t.name}?</AlertDialog.Title>
<AlertDialog.Description>
This is an app table, not a list. Edits change what the {t.schema} pages show and could break them. Every change is written to the edit log. Editing stays unlocked for this table until you lock it again or close the tab.
This is an app table, not a list. Edits change what the {t.schema} pages show and could break
them. Every change is written to the edit log. Editing stays unlocked for this table until you
lock it again or close the tab.
</AlertDialog.Description>
</AlertDialog.Header>
<AlertDialog.Footer>
<AlertDialog.Cancel>cancel</AlertDialog.Cancel>
<AlertDialog.Action onclick={() => { setUnlocked(true); unlockOpen = false; }}>unlock editing</AlertDialog.Action>
<AlertDialog.Action
onclick={() => {
setUnlocked(true);
unlockOpen = false;
}}>unlock editing</AlertDialog.Action
>
</AlertDialog.Footer>
</AlertDialog.Content>
</AlertDialog.Root>
@@ -512,14 +689,21 @@
<AlertDialog.Root bind:open={deleteOpen}>
<AlertDialog.Content>
<AlertDialog.Header>
<AlertDialog.Title>Delete {deleteTargets.length} row{deleteTargets.length === 1 ? '' : 's'}?</AlertDialog.Title>
<AlertDialog.Title
>Delete {deleteTargets.length} row{deleteTargets.length === 1
? ''
: 's'}?</AlertDialog.Title
>
<AlertDialog.Description>
From {t.schema}.{t.name}. This can't be undone here (the old values are kept in the edit log).
From {t.schema}.{t.name}. This can't be undone here (the old values are kept in the edit
log).
</AlertDialog.Description>
</AlertDialog.Header>
<AlertDialog.Footer>
<AlertDialog.Cancel>cancel</AlertDialog.Cancel>
<AlertDialog.Action class="bg-red-600 text-white hover:bg-red-700" onclick={confirmDelete}>delete</AlertDialog.Action>
<AlertDialog.Action class="bg-red-600 text-white hover:bg-red-700" onclick={confirmDelete}
>delete</AlertDialog.Action
>
</AlertDialog.Footer>
</AlertDialog.Content>
</AlertDialog.Root>
@@ -529,11 +713,17 @@
<AlertDialog.Header>
<AlertDialog.Title>Import {importFile?.name}?</AlertDialog.Title>
<AlertDialog.Description>
Appends every row to {t.schema}.{t.name}. The first line must hold column names; empty cells use the column default. If any row fails, nothing is imported.
Appends every row to {t.schema}.{t.name}. The first line must hold column names; empty cells
use the column default. If any row fails, nothing is imported.
</AlertDialog.Description>
</AlertDialog.Header>
<AlertDialog.Footer>
<AlertDialog.Cancel onclick={() => { importFile = null; if (fileInput) fileInput.value = ''; }}>cancel</AlertDialog.Cancel>
<AlertDialog.Cancel
onclick={() => {
importFile = null;
if (fileInput) fileInput.value = '';
}}>cancel</AlertDialog.Cancel
>
<AlertDialog.Action onclick={confirmImport}>import</AlertDialog.Action>
</AlertDialog.Footer>
</AlertDialog.Content>
@@ -548,9 +738,18 @@
<label class="block text-sm">
<span class="mb-1 flex items-baseline gap-2">
<span class="font-medium">{c.name}</span>
<span class="text-xs text-[var(--site-muted)]">{shortType(c.type)}{!c.nullable && !c.hasDefault ? ' · required' : ''}{c.fk ? ` → ${c.fk.table}` : ''}</span>
<span class="text-xs text-[var(--site-muted)]"
>{shortType(c.type)}{!c.nullable && !c.hasDefault ? ' · required' : ''}{c.fk
? ` → ${c.fk.table}`
: ''}</span
>
</span>
<FieldInput column={c} bind:value={newValues[c.name]} lookupUrl={`${base}/lookup`} autofocus={i === 0} />
<FieldInput
column={c}
bind:value={newValues[c.name]}
lookupUrl={`${base}/lookup`}
autofocus={i === 0}
/>
</label>
{/each}
<Button type="submit">add row</Button>
@@ -563,20 +762,42 @@
<Dialog.Header><Dialog.Title>new column</Dialog.Title></Dialog.Header>
{#if colError}<p role="alert" class="text-sm text-red-500">{colError}</p>{/if}
<form class="space-y-3" onsubmit={addColumn}>
<label class="block text-sm">name (lowercase, a–z, 0–9, _)
<input bind:value={colName} required pattern="[a-z][a-z0-9_]*" maxlength="63" class="mt-1 w-full rounded border border-[var(--site-border)] bg-[var(--site-bg)] px-2 py-1.5" />
<label class="block text-sm"
>name (lowercase, a–z, 0–9, _)
<input
bind:value={colName}
required
pattern="[a-z][a-z0-9_]*"
maxlength="63"
class="mt-1 w-full rounded border border-[var(--site-border)] bg-[var(--site-bg)] px-2 py-1.5"
/>
</label>
<label class="block text-sm">label (optional)
<input bind:value={colLabel} maxlength="100" class="mt-1 w-full rounded border border-[var(--site-border)] bg-[var(--site-bg)] px-2 py-1.5" />
<label class="block text-sm"
>label (optional)
<input
bind:value={colLabel}
maxlength="100"
class="mt-1 w-full rounded border border-[var(--site-border)] bg-[var(--site-bg)] px-2 py-1.5"
/>
</label>
<label class="block text-sm">type
<select bind:value={colType} class="mt-1 w-full rounded border border-[var(--site-border)] bg-[var(--site-bg)] px-2 py-1.5">
{#each ['text', 'longtext', 'number', 'cents', 'bool', 'date', 'datetime', 'url', 'image', 'select', 'multiselect', 'json'] as ty (ty)}<option value={ty}>{ty}</option>{/each}
<label class="block text-sm"
>type
<select
bind:value={colType}
class="mt-1 w-full rounded border border-[var(--site-border)] bg-[var(--site-bg)] px-2 py-1.5"
>
{#each ['text', 'longtext', 'number', 'cents', 'bool', 'date', 'datetime', 'url', 'image', 'select', 'multiselect', 'json'] as ty (ty)}<option
value={ty}>{ty}</option
>{/each}
</select>
</label>
{#if colType === 'select' || colType === 'multiselect'}
<label class="block text-sm">options (comma separated)
<input bind:value={colOptions} class="mt-1 w-full rounded border border-[var(--site-border)] bg-[var(--site-bg)] px-2 py-1.5" />
<label class="block text-sm"
>options (comma separated)
<input
bind:value={colOptions}
class="mt-1 w-full rounded border border-[var(--site-border)] bg-[var(--site-bg)] px-2 py-1.5"
/>
</label>
{/if}
<Button type="submit">add column</Button>

View File

@@ -8,7 +8,10 @@ export const GET: RequestHandler = async (event) => {
const { table } = await ownerTable(event);
try {
const rows = await exportRows(table, { ...rowQuery(event.url), limit: undefined, offset: 0 });
const csv = toCsv(table.columns.map((c) => c.name), rows);
const csv = toCsv(
table.columns.map((c) => c.name),
rows
);
const stamp = new Date().toISOString().slice(0, 10);
return new Response(csv, {
headers: {

View File

@@ -24,7 +24,12 @@ export const POST: RequestHandler = async (event) => {
const { table, actor } = await ownerTable(event);
const b = await body(event.request);
try {
const row = await insertRow(table, isObject(b.values) ? b.values : {}, unlocked(event.request), actor);
const row = await insertRow(
table,
isObject(b.values) ? b.values : {},
unlocked(event.request),
actor
);
return json({ row }, { status: 201, headers: noStore });
} catch (e) {
return failure(e);
@@ -35,7 +40,8 @@ export const PATCH: RequestHandler = async (event) => {
const { table, actor } = await ownerTable(event);
const b = await body(event.request);
try {
if (!isObject(b.pk) || !isObject(b.values)) return json({ message: 'Bad request.' }, { status: 400 });
if (!isObject(b.pk) || !isObject(b.values))
return json({ message: 'Bad request.' }, { status: 400 });
const row = await updateRow(table, b.pk, b.values, unlocked(event.request), actor);
return json({ row }, { headers: noStore });
} catch (e) {