Lists: owner-only database browser/editor for all tables (meta.edit_log audit)
All checks were successful
Deploy production / deploy (push) Successful in 2m33s
All checks were successful
Deploy production / deploy (push) Successful in 2m33s
This commit is contained in:
@@ -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);
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user