data browser wip

This commit is contained in:
2026-10-24 02:42:31 +00:00
parent c8205e474e
commit 941a9f16e1
22 changed files with 2141 additions and 6 deletions

View File

@@ -0,0 +1,10 @@
import { requireOwner } from '#lib/server/owner.js';
import type { LayoutServerLoad } from './$types';
// Owner only: everyone else gets a plain 404. Endpoints below check again themselves,
// and hooks.server.ts refuses every non-GET request under /lists/db for non-owners.
export const load: LayoutServerLoad = (event) => {
requireOwner(event);
event.setHeaders({ 'cache-control': 'private, no-store' });
return {};
};

View File

@@ -0,0 +1,9 @@
import { requireOwner } from '#lib/server/owner.js';
import { redirect } from '@sveltejs/kit';
import type { PageServerLoad } from './$types';
// The database overview lives on /lists itself (shown to the owner only).
export const load: PageServerLoad = (event) => {
requireOwner(event);
redirect(307, '/lists#database');
};

View File

@@ -0,0 +1,19 @@
import { readRows, DataError } from '#lib/server/data-editor.js';
import { ownerTable, rowQuery } from '#lib/server/data-route.js';
import { error } from '@sveltejs/kit';
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);
return { table, query, ...result, queryError: null as string | null };
} catch (e) {
if (!(e instanceof DataError)) throw e;
if (e.status === 404) error(404, 'Not Found');
const result = await readRows(table, { ...query, filters: [], q: '', sort: undefined });
return { table, query, ...result, queryError: e.message };
}
};

View File

@@ -0,0 +1,585 @@
<script lang="ts">
import { goto, invalidateAll } from '$app/navigation';
import { page } from '$app/state';
import { resolve } from '$app/paths';
import * as Dialog from '#lib/components/ui/dialog/index.js';
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 type { PageProps } from './$types';
let { data }: PageProps = $props();
const t = $derived(data.table);
const cols = $derived(t.columns as Col[]);
const base = $derived(`/lists/db/${t.schema}/${t.name}`);
const unlockKey = $derived(`data-unlock:${t.schema}.${t.name}`);
// ----- lock state -----
let unlockedTables = $state<Record<string, boolean>>({});
const unlocked = $derived(Boolean(unlockedTables[unlockKey]));
const editable = $derived(t.access === 'open' || (t.access === 'core' && unlocked));
let unlockOpen = $state(false);
function restoreUnlock(node: HTMLElement) {
const key = node.dataset.key!;
if (sessionStorage.getItem(key) === '1') unlockedTables[key] = true;
}
function setUnlocked(on: boolean) {
unlockedTables[unlockKey] = on;
if (on) sessionStorage.setItem(unlockKey, '1');
else sessionStorage.removeItem(unlockKey);
}
// ----- query state (lives in the URL) -----
const params = $derived(page.url.searchParams);
const pageNo = $derived(Math.max(1, Number(params.get('page') ?? 1) || 1));
const limit = $derived(data.query.limit ?? 50);
const pages = $derived(data.total === null ? null : Math.max(1, Math.ceil(data.total / limit)));
let search = $state('');
function syncSearch(node: HTMLInputElement) {
search = params.get('q') ?? '';
node.value = search;
}
function navigate(change: Record<string, string | string[] | null>, keepPage = false) {
const url = new URL(page.url);
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 });
}
let searchTimer: ReturnType<typeof setTimeout> | undefined;
function onSearch(value: string) {
search = value;
clearTimeout(searchTimer);
searchTimer = setTimeout(() => navigate({ q: value }), 250);
}
function sortBy(c: Col) {
const sort = params.get('sort');
const dir = params.get('dir');
if (sort !== c.name) navigate({ sort: c.name, dir: 'asc' });
else if (dir !== 'desc') navigate({ sort: c.name, dir: 'desc' });
else navigate({ sort: null, dir: null });
}
const filters = $derived(params.getAll('f'));
let fCol = $state('');
let fOp = $state('contains');
let fVal = $state('');
const OPS: [string, string][] = [
['contains', 'contains'],
['eq', '='],
['ne', '≠'],
['gt', '>'],
['gte', '≥'],
['lt', '<'],
['lte', '≤'],
['null', 'is null'],
['notnull', 'is not null']
];
function addFilter(e: SubmitEvent) {
e.preventDefault();
const col = fCol || cols[0]?.name;
if (!col) return;
const f = fOp === 'null' || fOp === 'notnull' ? `${col}:${fOp}` : `${col}:${fOp}:${fVal}`;
navigate({ f: [...filters, f] });
fVal = '';
}
function filterLabel(f: string) {
const [c, op, ...rest] = f.split(':');
const sym = OPS.find(([k]) => k === op)?.[1] ?? op;
return `${c} ${sym} ${rest.join(':')}`.trim();
}
// ----- rows, selection, editing -----
type Row = Record<string, unknown>;
const rows = $derived(data.rows as Row[]);
const pkOf = (r: Row) => Object.fromEntries(t.primaryKey.map((k) => [k, r[k]]));
const keyOf = (r: Row) => JSON.stringify(t.primaryKey.map((k) => r[k]));
let selected = $state<Record<string, boolean>>({});
const selectedRows = $derived(rows.filter((r) => selected[keyOf(r)]));
let active = $state({ r: 0, c: 0 });
let editing = $state<{ r: number; c: number } | null>(null);
let draft = $state<string | null>(null);
let saving = $state(false);
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) {
const res = await fetch(`${base}/${path}`, {
method,
headers: {
'content-type': type ?? 'application/json',
...(unlocked ? { 'x-data-unlock': '1' } : {})
},
body: raw ?? (body === undefined ? undefined : JSON.stringify(body))
});
const json = await res.json().catch(() => ({}));
if (!res.ok) throw new Error(json.message ?? `Request failed (${res.status})`);
return json;
}
function flash(kind: 'error' | 'ok', text: string) {
message = { kind, text };
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)) };
queueMicrotask(() =>
grid?.querySelector<HTMLElement>(`[data-cell="${active.r}:${active.c}"]`)?.focus()
);
}
function startEdit(r: number, c: number) {
const col = cols[c];
if (!editable || !t.canUpdate || col.readOnly) return;
editing = { r, c };
draft = toEditor(col, rows[r][col.name]);
}
async function saveEdit(move?: 'next' | 'prev') {
if (!editing || saving) return;
const { r, c } = editing;
const col = cols[c];
const before = toEditor(col, rows[r][col.name]);
if (draft === before) {
editing = null;
if (move) {
focusCell(r, c + (move === 'next' ? 1 : -1));
if (move) startEdit(active.r, active.c);
} else focusCell(r, c);
return;
}
saving = true;
try {
await api('rows', 'PATCH', { pk: pkOf(rows[r]), values: { [col.name]: fromEditor(col, draft) } });
editing = null;
await invalidateAll();
flash('ok', 'Saved');
focusCell(r, move ? c + (move === 'next' ? 1 : -1) : c);
if (move) startEdit(active.r, active.c);
} catch (e) {
flash('error', (e as Error).message);
} finally {
saving = false;
}
}
function cancelEdit() {
if (!editing) return;
const { r, c } = editing;
editing = null;
focusCell(r, c);
}
function cellKey(e: KeyboardEvent, r: number, c: number) {
if (editing) return;
const moves: Record<string, [number, number]> = {
ArrowUp: [-1, 0],
ArrowDown: [1, 0],
ArrowLeft: [0, -1],
ArrowRight: [0, 1]
};
if (moves[e.key]) {
e.preventDefault();
focusCell(r + moves[e.key][0], c + moves[e.key][1]);
} else if (e.key === 'Home') {
e.preventDefault();
focusCell(r, 0);
} else if (e.key === 'End') {
e.preventDefault();
focusCell(r, cols.length - 1);
} else if (e.key === 'Enter' || e.key === 'F2') {
e.preventDefault();
startEdit(r, c);
} else if (e.key === ' ' && t.primaryKey.length) {
e.preventDefault();
const k = keyOf(rows[r]);
selected[k] = !selected[k];
}
}
// ----- delete -----
let deleteTargets = $state<Row[]>([]);
let deleteOpen = $state(false);
function askDelete(targets: Row[]) {
deleteTargets = targets;
deleteOpen = true;
}
async function confirmDelete() {
try {
const { deleted } = await api('rows', 'DELETE', { pks: deleteTargets.map(pkOf) });
selected = {};
deleteOpen = false;
await invalidateAll();
flash('ok', `Deleted ${deleted} row${deleted === 1 ? '' : 's'}`);
} catch (e) {
deleteOpen = false;
flash('error', (e as Error).message);
}
}
// ----- new row -----
let newOpen = $state(false);
let newValues = $state<Record<string, string | null>>({});
let newError = $state('');
const insertable = $derived(cols.filter((c) => !c.readOnly));
function openNew() {
newValues = Object.fromEntries(insertable.map((c) => [c.name, null]));
newError = '';
newOpen = true;
}
async function saveNew(e?: SubmitEvent) {
e?.preventDefault();
const values: Record<string, string | null> = {};
for (const c of insertable) {
const v = newValues[c.name];
if (v !== null && v !== undefined) values[c.name] = fromEditor(c, v);
}
try {
await api('rows', 'POST', { values });
newOpen = false;
await invalidateAll();
flash('ok', 'Row added');
} catch (err) {
newError = (err as Error).message;
}
}
// ----- add column (lists only) -----
let colOpen = $state(false);
let colName = $state('');
let colLabel = $state('');
let colType = $state('text');
let colOptions = $state('');
let colError = $state('');
async function addColumn(e: SubmitEvent) {
e.preventDefault();
try {
await api('columns', 'POST', {
name: colName,
label: colLabel,
uiType: colType,
options: colOptions.split(',').map((s) => s.trim()).filter(Boolean)
});
colOpen = false;
colName = colLabel = colOptions = '';
await invalidateAll();
flash('ok', 'Column added');
} catch (err) {
colError = (err as Error).message;
}
}
// ----- CSV -----
const exportHref = $derived(`${base}/export${page.url.search}`);
let importFile = $state<File | null>(null);
let importOpen = $state(false);
let fileInput = $state<HTMLInputElement>();
function pickImport(e: Event) {
const f = (e.currentTarget as HTMLInputElement).files?.[0];
if (!f) return;
importFile = f;
importOpen = true;
}
async function confirmImport() {
if (!importFile) return;
try {
const { inserted } = await api('import', 'POST', undefined, await importFile.text(), 'text/csv');
flash('ok', `Imported ${inserted} row${inserted === 1 ? '' : 's'}`);
await invalidateAll();
} catch (e) {
flash('error', (e as Error).message);
} finally {
importOpen = false;
importFile = null;
if (fileInput) fileInput.value = '';
}
}
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 (e.key === '/') {
e.preventDefault();
searchBox?.focus();
} else if (e.key === 'n' && editable && t.canInsert) {
e.preventDefault();
openNew();
}
}
const accessBadge = $derived(
t.access === 'open'
? { 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 · 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)]' }
);
</script>
<svelte:window onkeydown={globalKey} />
<svelte:head><title>{t.schema}.{t.name} · data</title></svelte:head>
<div class="mx-auto max-w-[96rem] px-4 py-8 sm:px-6" data-key={unlockKey} {@attach restoreUnlock}>
<nav class="mb-2 text-sm text-[var(--site-muted)]">
<a href={resolve('/lists')} class="hover:underline">lists</a> /
<a href="/lists#schema-{t.schema}" class="hover:underline">{t.schema}</a> /
<span class="text-[var(--site-text)]">{t.name}</span>
</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="ml-auto text-sm text-[var(--site-muted)]">
{#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">
{#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>
<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>
{/if}
</div>
{/if}
<div class="mb-3 flex flex-wrap items-center gap-2">
<input
bind:this={searchBox}
{@attach syncSearch}
type="search"
placeholder="search all columns ( / )"
class="w-64 rounded border border-[var(--site-border)] bg-[var(--site-bg)] px-3 py-1.5 text-sm"
oninput={(e) => onSearch(e.currentTarget.value)}
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">
{#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">
{#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" />
{/if}
<Button size="sm" variant="outline" type="submit">filter</Button>
</form>
<div class="ml-auto flex flex-wrap items-center gap-2">
{#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} />
{/if}
{#if t.isList && t.access === 'open'}
<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>
{#if editable && t.canDelete && selectedRows.length}
<Button size="sm" variant="destructive" onclick={() => askDelete(selectedRows)}>delete {selectedRows.length}</Button>
{/if}
</div>
</div>
{#if filters.length}
<div class="mb-3 flex flex-wrap gap-2">
{#each filters as f, i (f + i)}
<button
class="rounded-full border border-[var(--site-border)] px-2.5 py-0.5 text-xs hover:bg-[var(--site-hover)]"
onclick={() => navigate({ f: filters.filter((_, j) => j !== i) })}
title="Remove filter">{filterLabel(f)} ✕</button
>
{/each}
</div>
{/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>
{/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}>
<thead class="sticky top-0 z-10 bg-[var(--site-surface)]">
<tr>
{#if t.primaryKey.length}
<th class="w-8 border-b border-[var(--site-border)] px-2">
<input
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])) : {})}
/>
</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}
</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' : ''}
</div>
</th>
{/each}
{#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)]']}>
{#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)]} />
</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>
</td>
{:else}
<td
data-cell="{r}:{ci}"
tabindex={active.r === r && active.c === ci ? 0 : -1}
class={[
'max-w-96 cursor-default truncate border-b border-[var(--site-border)] px-2 py-1.5 focus:outline-2 focus:-outline-offset-2 focus:outline-[var(--site-text)]',
(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)}
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>
{: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>
{: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>
</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>
{/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>
<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>
<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 })}>
{#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>
</div>
</div>
<AlertDialog.Root bind:open={unlockOpen}>
<AlertDialog.Content>
<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.
</AlertDialog.Description>
</AlertDialog.Header>
<AlertDialog.Footer>
<AlertDialog.Cancel>cancel</AlertDialog.Cancel>
<AlertDialog.Action onclick={() => { setUnlocked(true); unlockOpen = false; }}>unlock editing</AlertDialog.Action>
</AlertDialog.Footer>
</AlertDialog.Content>
</AlertDialog.Root>
<AlertDialog.Root bind:open={deleteOpen}>
<AlertDialog.Content>
<AlertDialog.Header>
<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).
</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.Footer>
</AlertDialog.Content>
</AlertDialog.Root>
<AlertDialog.Root bind:open={importOpen}>
<AlertDialog.Content>
<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.
</AlertDialog.Description>
</AlertDialog.Header>
<AlertDialog.Footer>
<AlertDialog.Cancel onclick={() => { importFile = null; if (fileInput) fileInput.value = ''; }}>cancel</AlertDialog.Cancel>
<AlertDialog.Action onclick={confirmImport}>import</AlertDialog.Action>
</AlertDialog.Footer>
</AlertDialog.Content>
</AlertDialog.Root>
<Dialog.Root bind:open={newOpen}>
<Dialog.Content class="max-h-[90vh] overflow-y-auto sm:max-w-xl">
<Dialog.Header><Dialog.Title>new row in {t.schema}.{t.name}</Dialog.Title></Dialog.Header>
{#if newError}<p role="alert" class="text-sm text-red-500">{newError}</p>{/if}
<form class="space-y-3" onsubmit={saveNew}>
{#each insertable as c, i (c.name)}
<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>
<FieldInput column={c} bind:value={newValues[c.name]} lookupUrl={`${base}/lookup`} autofocus={i === 0} />
</label>
{/each}
<Button type="submit">add row</Button>
</form>
</Dialog.Content>
</Dialog.Root>
<Dialog.Root bind:open={colOpen}>
<Dialog.Content>
<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>
<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}
</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>
{/if}
<Button type="submit">add column</Button>
</form>
</Dialog.Content>
</Dialog.Root>

View File

@@ -0,0 +1,25 @@
import { json } from '@sveltejs/kit';
import { addListColumn } from '#lib/server/data-editor.js';
import { failure, noStore, ownerTable } from '#lib/server/data-route.js';
import type { RequestHandler } from './$types';
/** Add a column to a runtime list (schema `lists` only, via meta.add_column()). */
export const POST: RequestHandler = async (event) => {
const { table, actor } = await ownerTable(event);
const b = (await event.request.json().catch(() => ({}))) as Record<string, unknown>;
try {
await addListColumn(
table,
{
name: String(b.name ?? '').trim(),
uiType: String(b.uiType ?? ''),
label: typeof b.label === 'string' ? b.label : undefined,
options: Array.isArray(b.options) ? b.options.map(String) : undefined
},
actor
);
return json({ ok: true }, { status: 201, headers: noStore });
} catch (e) {
return failure(e);
}
};

View File

@@ -0,0 +1,23 @@
import { exportRows } from '#lib/server/data-editor.js';
import { toCsv } from '#lib/server/data-policy.js';
import { failure, noStore, ownerTable, rowQuery } from '#lib/server/data-route.js';
import type { RequestHandler } from './$types';
/** CSV of every row matching the current search/filters/sort (header = column names). */
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 stamp = new Date().toISOString().slice(0, 10);
return new Response(csv, {
headers: {
...noStore,
'content-type': 'text/csv; charset=utf-8',
'content-disposition': `attachment; filename="${table.schema}.${table.name}-${stamp}.csv"`
}
});
} catch (e) {
return failure(e);
}
};

View File

@@ -0,0 +1,26 @@
import { json } from '@sveltejs/kit';
import { DataError, importRows } from '#lib/server/data-editor.js';
import { parseCsv } from '#lib/server/data-policy.js';
import { failure, noStore, ownerTable, unlocked } from '#lib/server/data-route.js';
import type { RequestHandler } from './$types';
/** Append rows from a CSV body (text/csv, at most 10 MB). All rows or none. */
export const POST: RequestHandler = async (event) => {
const { table, actor } = await ownerTable(event);
try {
const length = Number(event.request.headers.get('content-length') ?? 0);
if (length > 10 * 1024 * 1024) throw new DataError(413, 'CSV files are limited to 10 MB.');
const text = await event.request.text();
if (text.length > 10 * 1024 * 1024) throw new DataError(413, 'CSV files are limited to 10 MB.');
let rows: string[][];
try {
rows = parseCsv(text);
} catch (e) {
throw new DataError(400, (e as Error).message);
}
const inserted = await importRows(table, rows, unlocked(event.request), actor);
return json({ inserted }, { headers: noStore });
} catch (e) {
return failure(e);
}
};

View File

@@ -0,0 +1,13 @@
import { json } from '@sveltejs/kit';
import { fkOptions } from '#lib/server/data-editor.js';
import { noStore, ownerTable } from '#lib/server/data-route.js';
import type { RequestHandler } from './$types';
/** Options for a foreign-key column: rows of the referenced table as {value, label}. */
export const GET: RequestHandler = async (event) => {
const { table } = await ownerTable(event);
const column = table.columns.find((c) => c.name === event.url.searchParams.get('column'));
if (!column?.fk) return json({ options: [] }, { headers: noStore });
const q = (event.url.searchParams.get('q') ?? '').slice(0, 100);
return json({ options: await fkOptions(column, { q }) }, { headers: noStore });
};

View File

@@ -0,0 +1,56 @@
import { json } from '@sveltejs/kit';
import { deleteRows, insertRow, readRows, updateRow } from '#lib/server/data-editor.js';
import { failure, noStore, ownerTable, rowQuery, unlocked } from '#lib/server/data-route.js';
import type { RequestHandler } from './$types';
const isObject = (v: unknown): v is Record<string, unknown> =>
typeof v === 'object' && v !== null && !Array.isArray(v);
async function body(request: Request): Promise<Record<string, unknown>> {
const b = await request.json().catch(() => null);
return isObject(b) ? b : {};
}
export const GET: RequestHandler = async (event) => {
const { table } = await ownerTable(event);
try {
return json(await readRows(table, rowQuery(event.url)), { headers: noStore });
} catch (e) {
return failure(e);
}
};
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);
return json({ row }, { status: 201, headers: noStore });
} catch (e) {
return failure(e);
}
};
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 });
const row = await updateRow(table, b.pk, b.values, unlocked(event.request), actor);
return json({ row }, { headers: noStore });
} catch (e) {
return failure(e);
}
};
export const DELETE: RequestHandler = async (event) => {
const { table, actor } = await ownerTable(event);
const b = await body(event.request);
try {
const pks = Array.isArray(b.pks) ? b.pks.filter(isObject) : [];
const deleted = await deleteRows(table, pks, unlocked(event.request), actor);
return json({ deleted }, { headers: noStore });
} catch (e) {
return failure(e);
}
};