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

@@ -21,7 +21,9 @@
const kind = $derived(column.kind);
const long = $derived(
kind === 'json' || column.uiType === 'longtext' || (kind === 'text' && (value?.length ?? 0) > 80)
kind === 'json' ||
column.uiType === 'longtext' ||
(kind === 'text' && (value?.length ?? 0) > 80)
);
const listId = `fk-${Math.random().toString(36).slice(2)}`;
let options = $state.raw<{ value: string; label: string }[]>([]);
@@ -64,7 +66,11 @@
const base =
'w-full rounded border border-[var(--site-border)] bg-[var(--site-bg)] px-2 py-1 text-sm focus:outline-2 focus:outline-[var(--site-text)]';
const placeholder = $derived(
value === null ? (column.hasDefault ? `default${column.defaultExpr ? `: ${column.defaultExpr}` : ''}` : 'null') : ''
value === null
? column.hasDefault
? `default${column.defaultExpr ? `: ${column.defaultExpr}` : ''}`
: 'null'
: ''
);
</script>
@@ -78,7 +84,9 @@
{@attach focusOnMount}
aria-label={column.label}
>
{#if column.nullable || column.hasDefault}<option value="">{column.hasDefault ? '(default)' : 'null'}</option>{/if}
{#if column.nullable || column.hasDefault}<option value=""
>{column.hasDefault ? '(default)' : 'null'}</option
>{/if}
<option value="true">true</option>
<option value="false">false</option>
</select>
@@ -91,7 +99,9 @@
{@attach focusOnMount}
aria-label={column.label}
>
{#if column.nullable || column.hasDefault}<option value="">{column.hasDefault ? '(default)' : 'null'}</option>{/if}
{#if column.nullable || column.hasDefault}<option value=""
>{column.hasDefault ? '(default)' : 'null'}</option
>{/if}
{#each column.enumValues as v (v)}<option value={v}>{v}</option>{/each}
</select>
{:else if long}
@@ -103,8 +113,7 @@
oninput={(e) => (value = e.currentTarget.value)}
onkeydown={keydown}
{@attach focusOnMount}
aria-label={column.label}
></textarea>
aria-label={column.label}></textarea>
{:else}
<input
class={base}

View File

@@ -117,15 +117,20 @@ function summarise(r: CatalogRow): Omit<TableSummary, 'rows' | 'approximate'> {
};
}
const rel = (schema: string, name: string) => sql`${sql.identifier(schema)}.${sql.identifier(name)}`;
const rel = (schema: string, name: string) =>
sql`${sql.identifier(schema)}.${sql.identifier(name)}`;
/** Every table/view the app role can read, minus hidden ones, with row counts. */
export async function listTables(): Promise<TableSummary[]> {
const rows = (await db.execute<CatalogRow>(catalogQuery(sql`true`))) as unknown as CatalogRow[];
const visible = rows.map((r) => ({ r, s: summarise(r) })).filter(({ s }) => s.access !== 'hidden');
const visible = rows
.map((r) => ({ r, s: summarise(r) }))
.filter(({ s }) => s.access !== 'hidden');
// exact counts for real tables up to MAX_EXACT rows; estimates for huge ones; views are
// counted only when opened (some aggregate big tables).
const exact = visible.filter(({ r }) => (r.relkind === 'r' || r.relkind === 'p') && r.estimate < MAX_EXACT);
const exact = visible.filter(
({ r }) => (r.relkind === 'r' || r.relkind === 'p') && r.estimate < MAX_EXACT
);
const counts = new Map<string, number>();
if (exact.length) {
const res = await db.transaction(async (tx) => {
@@ -133,7 +138,8 @@ export async function listTables(): Promise<TableSummary[]> {
return tx.execute<{ k: string; n: number }>(
sql.join(
exact.map(
({ r }) => sql`SELECT ${`${r.schema}.${r.name}`}::text AS k, count(*)::int AS n FROM ${rel(r.schema, r.name)}`
({ r }) =>
sql`SELECT ${`${r.schema}.${r.name}`}::text AS k, count(*)::int AS n FROM ${rel(r.schema, r.name)}`
),
sql` UNION ALL `
)
@@ -233,7 +239,8 @@ export async function getTable(schema: string, name: string): Promise<TableInfo
.sort((a, b) => a.pk_pos! - b.pk_pos!)
.map((c) => c.name);
// a key column that is hidden or has an odd type makes rows unaddressable: read-only
const keyUsable = primaryKey.length > 0 && primaryKey.every((k) => columns.some((c) => c.name === k));
const keyUsable =
primaryKey.length > 0 && primaryKey.every((k) => columns.some((c) => c.name === k));
return {
...s,
access: s.access !== 'locked' && !keyUsable ? 'locked' : s.access,
@@ -275,7 +282,9 @@ function whereClause(t: TableInfo, q: RowQuery): SQL {
const search = q.q?.trim();
if (search) {
const texts = t.columns.map((c) => sql`t.${sql.identifier(c.name)}::text`);
parts.push(sql`concat_ws(' ', ${sql.join(texts, sql`, `)}) ILIKE ${`%${search.replace(/[\\%_]/g, '\\$&')}%`}`);
parts.push(
sql`concat_ws(' ', ${sql.join(texts, sql`, `)}) ILIKE ${`%${search.replace(/[\\%_]/g, '\\$&')}%`}`
);
}
for (const f of q.filters ?? []) {
const { c, ref } = colRef(t, f.column);
@@ -302,7 +311,10 @@ function orderClause(t: TableInfo, q: RowQuery): SQL {
keys.push(sql`${ref} ${sql.raw(q.dir === 'desc' ? 'DESC NULLS LAST' : 'ASC NULLS FIRST')}`);
}
for (const k of t.primaryKey.length ? t.primaryKey : [])
if (k !== q.sort) keys.push(sql`t.${sql.identifier(k)} ${sql.raw(q.dir === 'desc' && !q.sort ? 'DESC' : 'ASC')}`);
if (k !== q.sort)
keys.push(
sql`t.${sql.identifier(k)} ${sql.raw(q.dir === 'desc' && !q.sort ? 'DESC' : 'ASC')}`
);
return keys.length ? sql`ORDER BY ${sql.join(keys, sql`, `)}` : sql``;
}
@@ -367,10 +379,13 @@ export async function readRows(t: TableInfo, q: RowQuery): Promise<RowsResult> {
const fkLabels: RowsResult['fkLabels'] = {};
for (const c of t.columns) {
if (!c.fk) continue;
const values = [...new Set(visible.map((r) => r[c.name]).filter((v) => v !== null))].map(String);
const values = [...new Set(visible.map((r) => r[c.name]).filter((v) => v !== null))].map(
String
);
if (!values.length) continue;
const labels = await fkOptions(c, { values }, tx).catch(() => []);
if (labels.length) fkLabels[c.name] = Object.fromEntries(labels.map((o) => [o.value, o.label]));
if (labels.length)
fkLabels[c.name] = Object.fromEntries(labels.map((o) => [o.value, o.label]));
}
return { rows: visible, total, approximate, fkLabels };
});
@@ -391,14 +406,19 @@ export async function fkOptions(
const key = target.columns.find((x) => x.name === c.fk!.column);
if (!key) return [];
const labelCol =
['name', 'display_name', 'title', 'label', 'slug', 'email', 'code'].map((n) =>
target.columns.find((x) => x.name === n)
).find(Boolean) ?? key;
['name', 'display_name', 'title', 'label', 'slug', 'email', 'code']
.map((n) => target.columns.find((x) => x.name === n))
.find(Boolean) ?? key;
const k = sql`t.${sql.identifier(key.name)}`;
const l = sql`t.${sql.identifier(labelCol.name)}`;
const conds: SQL[] = [];
if (opts.values?.length)
conds.push(sql`${k}::text IN (${sql.join(opts.values.slice(0, 500).map((v) => sql`${v}`), sql`, `)})`);
conds.push(
sql`${k}::text IN (${sql.join(
opts.values.slice(0, 500).map((v) => sql`${v}`),
sql`, `
)})`
);
if (opts.q?.trim()) {
const like = `%${opts.q.trim().replace(/[\\%_]/g, '\\$&')}%`;
conds.push(sql`(${l}::text ILIKE ${like} OR ${k}::text ILIKE ${like})`);
@@ -427,7 +447,7 @@ export async function exportRows(t: TableInfo, q: RowQuery, cap = 200_000) {
: sql`t.${sql.identifier(c.name)}::text`
);
const rows = (await tx.execute<{ v: (string | null)[] }>(
sql`SELECT ARRAY[${sql.join(cols, sql`, `)}]::text[] AS v FROM ${rel(t.schema, t.name)} t ${whereClause(t, q)} ${orderClause(t, q)} LIMIT ${cap}`
sql`SELECT to_jsonb(ARRAY[${sql.join(cols, sql`, `)}]::text[]) AS v FROM ${rel(t.schema, t.name)} t ${whereClause(t, q)} ${orderClause(t, q)} LIMIT ${cap}`
)) as unknown as { v: (string | null)[] }[];
return rows.map((r) => r.v);
});
@@ -465,7 +485,12 @@ function assignments(t: TableInfo, values: Record<string, unknown>) {
if (!v.ok) throw new DataError(400, v.error);
if (v.value === null && !c.nullable && !(c.hasDefault && raw === undefined))
throw new DataError(400, `${c.label} is required.`);
if (c.kind === 'enum' && v.value !== null && c.enumValues.length && !c.enumValues.includes(v.value))
if (
c.kind === 'enum' &&
v.value !== null &&
c.enumValues.length &&
!c.enumValues.includes(v.value)
)
throw new DataError(400, `${c.label} must be one of ${c.enumValues.join(', ')}.`);
out.push({ c, v: v.value });
}
@@ -479,7 +504,11 @@ function pkWhere(t: TableInfo, pk: Pk) {
const c = t.columns.find((x) => x.name === k)!;
const raw = pk[k];
if (raw === undefined || raw === null) throw new DataError(400, `Missing key ${k}.`);
const v = coerce(c.kind, typeof raw === 'object' ? JSON.stringify(raw) : String(raw), c.label);
const v = coerce(
c.kind,
typeof raw === 'object' ? JSON.stringify(raw) : String(raw),
c.label
);
if (!v.ok || v.value === null) throw new DataError(400, `Invalid key ${k}.`);
return sql`t.${sql.identifier(k)} = ${cast(v.value, c)}`;
}),
@@ -537,15 +566,26 @@ async function write<T>(fn: (tx: Tx) => Promise<T>): Promise<T> {
const one = <T>(rows: unknown) => (rows as T[])[0];
export async function insertRow(t: TableInfo, values: Record<string, unknown>, unlocked: boolean, actor: Actor) {
export async function insertRow(
t: TableInfo,
values: Record<string, unknown>,
unlocked: boolean,
actor: Actor
) {
assertWritable(t, unlocked, 'insert');
const set = assignments(t, values);
return write(async (tx) => {
const row = one<{ r: Record<string, unknown> }>(
await tx.execute(
set.length
? sql`INSERT INTO ${rel(t.schema, t.name)} AS t (${sql.join(set.map((s) => sql.identifier(s.c.name)), sql`, `)})
VALUES (${sql.join(set.map((s) => cast(s.v, s.c)), sql`, `)}) RETURNING ${rowJson(t)} AS r`
? sql`INSERT INTO ${rel(t.schema, t.name)} AS t (${sql.join(
set.map((s) => sql.identifier(s.c.name)),
sql`, `
)})
VALUES (${sql.join(
set.map((s) => cast(s.v, s.c)),
sql`, `
)}) RETURNING ${rowJson(t)} AS r`
: sql`INSERT INTO ${rel(t.schema, t.name)} AS t DEFAULT VALUES RETURNING ${rowJson(t)} AS r`
)
).r;
@@ -568,16 +608,29 @@ export async function updateRow(
const where = pkWhere(t, pk);
return write(async (tx) => {
const before = one<{ r: Record<string, unknown> }>(
await tx.execute(sql`SELECT ${rowJson(t)} AS r FROM ${rel(t.schema, t.name)} t WHERE ${where} FOR UPDATE`)
await tx.execute(
sql`SELECT ${rowJson(t)} AS r FROM ${rel(t.schema, t.name)} t WHERE ${where} FOR UPDATE`
)
)?.r;
if (!before) throw new DataError(404, 'That row no longer exists.');
const after = one<{ r: Record<string, unknown> }>(
await tx.execute(sql`UPDATE ${rel(t.schema, t.name)} AS t
SET ${sql.join(set.map((s) => sql`${sql.identifier(s.c.name)} = ${cast(s.v, s.c)}`), sql`, `)}
SET ${sql.join(
set.map((s) => sql`${sql.identifier(s.c.name)} = ${cast(s.v, s.c)}`),
sql`, `
)}
WHERE ${where} RETURNING ${rowJson(t)} AS r`)
).r;
const changed = Object.fromEntries(set.map((s) => [s.c.name, before[s.c.name]]));
await audit(tx, actor, t, 'update', pk, changed, Object.fromEntries(set.map((s) => [s.c.name, after[s.c.name]])));
await audit(
tx,
actor,
t,
'update',
pk,
changed,
Object.fromEntries(set.map((s) => [s.c.name, after[s.c.name]]))
);
return after;
});
}
@@ -590,7 +643,9 @@ export async function deleteRows(t: TableInfo, pks: Pk[], unlocked: boolean, act
let n = 0;
for (let i = 0; i < wheres.length; i++) {
const before = one<{ r: Record<string, unknown> }>(
await tx.execute(sql`DELETE FROM ${rel(t.schema, t.name)} AS t WHERE ${wheres[i]} RETURNING ${rowJson(t)} AS r`)
await tx.execute(
sql`DELETE FROM ${rel(t.schema, t.name)} AS t WHERE ${wheres[i]} RETURNING ${rowJson(t)} AS r`
)
)?.r;
if (!before) continue;
n++;
@@ -614,7 +669,8 @@ export async function importRows(
const names = header.map((h) => h.trim());
const unknown = names.filter((n) => n && !t.columns.some((c) => c.name === n));
if (unknown.length) throw new DataError(400, `Unknown column(s): ${unknown.join(', ')}.`);
if (new Set(names).size !== names.length) throw new DataError(400, 'Duplicate column in the header.');
if (new Set(names).size !== names.length)
throw new DataError(400, 'Duplicate column in the header.');
const prepared = body.map((cells, i) => {
if (cells.length !== names.length)
throw new DataError(400, `Row ${i + 2} has ${cells.length} cells, expected ${names.length}.`);
@@ -637,8 +693,14 @@ export async function importRows(
for (const set of prepared)
await tx.execute(
set.length
? sql`INSERT INTO ${rel(t.schema, t.name)} (${sql.join(set.map((s) => sql.identifier(s.c.name)), sql`, `)})
VALUES (${sql.join(set.map((s) => cast(s.v, s.c)), sql`, `)})`
? sql`INSERT INTO ${rel(t.schema, t.name)} (${sql.join(
set.map((s) => sql.identifier(s.c.name)),
sql`, `
)})
VALUES (${sql.join(
set.map((s) => cast(s.v, s.c)),
sql`, `
)})`
: sql`INSERT INTO ${rel(t.schema, t.name)} DEFAULT VALUES`
);
await audit(tx, actor, t, 'import', null, null, { rows: prepared.length, columns: names });
@@ -646,7 +708,20 @@ export async function importRows(
});
}
export const LIST_UI_TYPES = ['text', 'longtext', 'url', 'image', 'number', 'cents', 'date', 'datetime', 'bool', 'select', 'multiselect', 'json'] as const;
export const LIST_UI_TYPES = [
'text',
'longtext',
'url',
'image',
'number',
'cents',
'date',
'datetime',
'bool',
'select',
'multiselect',
'json'
] as const;
/** Add a typed column to a runtime list (lists.* only) through meta.add_column(). */
export async function addListColumn(
@@ -657,15 +732,23 @@ export async function addListColumn(
if (!t.isList || !t.listSlug || t.access !== 'open')
throw new DataError(403, 'Columns can only be added to lists.');
if (!/^[a-z][a-z0-9_]{0,62}$/.test(input.name))
throw new DataError(400, 'Column names use lowercase letters, digits and _ and start with a letter.');
throw new DataError(
400,
'Column names use lowercase letters, digits and _ and start with a letter.'
);
if (!(LIST_UI_TYPES as readonly string[]).includes(input.uiType))
throw new DataError(400, 'Unknown column type.');
if (t.columns.some((c) => c.name === input.name)) throw new DataError(409, 'That column already exists.');
if (t.columns.some((c) => c.name === input.name))
throw new DataError(409, 'That column already exists.');
const col: Record<string, unknown> = { name: input.name, ui_type: input.uiType };
if (input.label?.trim()) col.label = input.label.trim().slice(0, 100);
if (input.uiType === 'select' || input.uiType === 'multiselect') {
const values = (input.options ?? []).map((o) => o.trim()).filter(Boolean).slice(0, 100);
if (input.uiType === 'select' && !values.length) throw new DataError(400, 'Give the select at least one option.');
const values = (input.options ?? [])
.map((o) => o.trim())
.filter(Boolean)
.slice(0, 100);
if (input.uiType === 'select' && !values.length)
throw new DataError(400, 'Give the select at least one option.');
col.options = { values };
}
await write(async (tx) => {

View File

@@ -60,7 +60,13 @@ describe('tableAccess', () => {
describe('columns and identifiers', () => {
it('hides credential-like columns only', () => {
for (const c of ['password', 'access_token', 'refresh_token_expires_at', 'api_key', 'client_secret'])
for (const c of [
'password',
'access_token',
'refresh_token_expires_at',
'api_key',
'client_secret'
])
expect(hiddenColumn(c)).toBe(true);
for (const c of ['key', 'session_id', 'iban', 'author', 'market_hash_name', 'r2_key'])
expect(hiddenColumn(c)).toBe(false);
@@ -107,20 +113,34 @@ describe('coerce', () => {
describe('filters and csv', () => {
it('parses filters', () => {
expect(parseFilter('name:contains:foo:bar')).toEqual({ column: 'name', op: 'contains', value: 'foo:bar' });
expect(parseFilter('name:contains:foo:bar')).toEqual({
column: 'name',
op: 'contains',
value: 'foo:bar'
});
expect(parseFilter('done_at:null')).toEqual({ column: 'done_at', op: 'null', value: '' });
expect(parseFilter('x;y:eq:1')).toBeNull();
expect(parseFilter('name:like:1')).toBeNull();
});
it('round-trips CSV', () => {
const csv = toCsv(['a', 'b'], [['x, y', 'he said "hi"'], [null, { k: 1 }], ['line\nbreak', 3]]);
const csv = toCsv(
['a', 'b'],
[
['x, y', 'he said "hi"'],
[null, { k: 1 }],
['line\nbreak', 3]
]
);
expect(parseCsv(csv)).toEqual([
['a', 'b'],
['x, y', 'he said "hi"'],
['', '{"k":1}'],
['line\nbreak', '3']
]);
expect(parseCsv('\ufeffa,b\r\n1,2')).toEqual([['a', 'b'], ['1', '2']]);
expect(parseCsv('\ufeffa,b\r\n1,2')).toEqual([
['a', 'b'],
['1', '2']
]);
expect(() => parseCsv('a\n"open')).toThrow();
});
});

View File

@@ -53,7 +53,9 @@ export function writeAllowed(access: Access, unlocked: boolean): boolean {
/** Identifiers coming from the URL/body are looked up in the catalog; this is a cheap
* pre-filter so obviously bad input never reaches a query at all. */
export function plausibleIdent(s: unknown): s is string {
return typeof s === 'string' && s.length > 0 && s.length <= 63 && /^[A-Za-z_][A-Za-z0-9_$]*$/.test(s);
return (
typeof s === 'string' && s.length > 0 && s.length <= 63 && /^[A-Za-z_][A-Za-z0-9_$]*$/.test(s)
);
}
/** format_type() output that is safe to splice into a CAST (it comes from pg_catalog). */
@@ -61,7 +63,18 @@ export function safeTypeName(type: string): boolean {
return /^[A-Za-z0-9_ ."(),[\]]{1,120}$/.test(type) && !type.includes('--') && !type.includes(';');
}
export type Kind = 'text' | 'number' | 'bool' | 'date' | 'datetime' | 'time' | 'json' | 'enum' | 'array' | 'uuid' | 'other';
export type Kind =
| 'text'
| 'number'
| 'bool'
| 'date'
| 'datetime'
| 'time'
| 'json'
| 'enum'
| 'array'
| 'uuid'
| 'other';
export function typeKind(type: string, isEnum: boolean): Kind {
if (type.endsWith('[]')) return 'array';
@@ -106,7 +119,10 @@ export function coerce(kind: Kind, raw: unknown, label: string): Coerced {
try {
items = JSON.parse(s);
} catch {
items = s.split(',').map((x) => x.trim()).filter(Boolean);
items = s
.split(',')
.map((x) => x.trim())
.filter(Boolean);
}
}
if (!Array.isArray(items)) return { ok: false, error: `${label} must be a list.` };
@@ -138,7 +154,8 @@ export function coerce(kind: Kind, raw: unknown, label: string): Coerced {
return { ok: false, error: `${label} must be true or false.` };
}
case 'date':
if (!/^\d{4}-\d{2}-\d{2}/.test(t)) return { ok: false, error: `${label} must be a date (YYYY-MM-DD).` };
if (!/^\d{4}-\d{2}-\d{2}/.test(t))
return { ok: false, error: `${label} must be a date (YYYY-MM-DD).` };
return { ok: true, value: t };
case 'datetime':
if (Number.isNaN(Date.parse(t)) && !/^\d{4}-\d{2}-\d{2}/.test(t))
@@ -154,7 +171,17 @@ export function coerce(kind: Kind, raw: unknown, label: string): Coerced {
}
/** Filter operators accepted by the grid (?f=<column>:<op>:<value>). */
export const FILTER_OPS = ['contains', 'eq', 'ne', 'gt', 'gte', 'lt', 'lte', 'null', 'notnull'] as const;
export const FILTER_OPS = [
'contains',
'eq',
'ne',
'gt',
'gte',
'lt',
'lte',
'null',
'notnull'
] as const;
export type FilterOp = (typeof FILTER_OPS)[number];
export type Filter = { column: string; op: FilterOp; value: string };

View File

@@ -44,4 +44,7 @@ export function failure(e: unknown) {
throw e;
}
export const noStore = { 'cache-control': 'private, no-store', 'x-robots-tag': 'noindex, nofollow' };
export const noStore = {
'cache-control': 'private, no-store',
'x-robots-tag': 'noindex, nofollow'
};

View File

@@ -1,4 +1,5 @@
import {
bigint,
boolean,
check,
index,
@@ -88,5 +89,29 @@ export const metaColumns = metaSchema.table(
]
);
/** Audit trail of every write made through the database browser (/lists/db). */
export const metaEditLog = metaSchema.table(
'edit_log',
{
id: bigint('id', { mode: 'number' }).primaryKey().generatedAlwaysAsIdentity(),
at: ts('at').notNull().defaultNow(),
userId: text('user_id'),
userEmail: text('user_email'),
schemaName: text('schema_name').notNull(),
tableName: text('table_name').notNull(),
action: text('action').notNull(),
pk: jsonb('pk'),
before: jsonb('before'),
after: jsonb('after')
},
(t) => [
index('edit_log_table_at_index').on(t.schemaName, t.tableName, t.at),
check(
'edit_log_action_check',
sql`action IN ('insert','update','delete','import','add_column')`
)
]
);
export type MetaTable = typeof metaTables.$inferSelect;
export type MetaColumn = typeof metaColumns.$inferSelect;

View File

@@ -17,13 +17,16 @@
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)));
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) =>
@@ -31,7 +34,10 @@
? { 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)]' };
: {
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 }) => {
@@ -162,7 +168,10 @@
<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.
{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
@@ -177,7 +186,9 @@
<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>
<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)}
@@ -187,10 +198,14 @@
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="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')}`}
{t.rows === null
? '–'
: `${t.approximate ? '~' : ''}${t.rows.toLocaleString('en-GB')}`}
</span>
</a>
</li>

View File

@@ -100,7 +100,10 @@
</div>
</div>
{#if data.isOwner}<a
href={resolve('/lists/db/[schema]/[table]', { schema: 'lists', table: data.list.tableName })}
href={resolve('/lists/db/[schema]/[table]', {
schema: 'lists',
table: data.list.tableName
})}
class="text-base underline underline-offset-4">table view</a
>{/if}
{#if data.isAdmin}<Button

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) {