@@ -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 : tru e, replaceState : true });
goto ( url , { reset : fals e, 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 >