@kernhq/module-quire 0.8.0 → 0.9.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (73) hide show
  1. package/README.md +93 -55
  2. package/dist/{server → client}/formula.d.ts +6 -0
  3. package/dist/client/formula.d.ts.map +1 -0
  4. package/dist/{server → client}/formula.js +6 -0
  5. package/dist/client/formula.js.map +1 -0
  6. package/dist/contract/properties.d.ts +26 -0
  7. package/dist/contract/properties.d.ts.map +1 -1
  8. package/dist/contract/properties.js +24 -0
  9. package/dist/contract/properties.js.map +1 -1
  10. package/dist/contract/router.d.ts +334 -0
  11. package/dist/contract/router.d.ts.map +1 -1
  12. package/dist/contract/router.js +43 -1
  13. package/dist/contract/router.js.map +1 -1
  14. package/dist/server/_impl.d.ts +341 -0
  15. package/dist/server/_impl.d.ts.map +1 -1
  16. package/dist/server/_impl.js +79 -10
  17. package/dist/server/_impl.js.map +1 -1
  18. package/dist/server/services/databases.d.ts +73 -1
  19. package/dist/server/services/databases.d.ts.map +1 -1
  20. package/dist/server/services/databases.js +167 -10
  21. package/dist/server/services/databases.js.map +1 -1
  22. package/dist/server/services/pages.d.ts.map +1 -1
  23. package/dist/server/services/pages.js +11 -1
  24. package/dist/server/services/pages.js.map +1 -1
  25. package/dist/server/services/query.d.ts.map +1 -1
  26. package/dist/server/services/query.js +93 -37
  27. package/dist/server/services/query.js.map +1 -1
  28. package/migrations/0006_rank_collation.sql +14 -0
  29. package/migrations/meta/_journal.json +7 -0
  30. package/package.json +3 -2
  31. package/src/client/components/PageTreeRow.svelte +1 -1
  32. package/src/client/core-api.ts +38 -0
  33. package/src/client/database/BoardView.svelte +354 -0
  34. package/src/client/database/CalendarView.svelte +346 -0
  35. package/src/client/database/DatabaseView.svelte +785 -0
  36. package/src/client/database/FilterMenu.svelte +281 -0
  37. package/src/client/database/FilterValue.svelte +176 -0
  38. package/src/client/database/GalleryView.svelte +177 -0
  39. package/src/client/database/ListView.svelte +108 -0
  40. package/src/client/database/OptionChip.svelte +38 -0
  41. package/src/client/database/PropertyDialog.svelte +460 -0
  42. package/src/client/database/PropertyMenu.svelte +162 -0
  43. package/src/client/database/RowPanel.svelte +157 -0
  44. package/src/client/database/SortMenu.svelte +199 -0
  45. package/src/client/database/TableView.svelte +388 -0
  46. package/src/client/database/ViewDialog.svelte +229 -0
  47. package/src/client/database/cells/Cell.svelte +103 -0
  48. package/src/client/database/cells/CheckboxCell.svelte +33 -0
  49. package/src/client/database/cells/ComputedCell.svelte +100 -0
  50. package/src/client/database/cells/DateCell.svelte +97 -0
  51. package/src/client/database/cells/LinkCell.svelte +143 -0
  52. package/src/client/database/cells/NumberCell.svelte +131 -0
  53. package/src/client/database/cells/PersonCell.svelte +116 -0
  54. package/src/client/database/cells/RelationCell.svelte +222 -0
  55. package/src/client/database/cells/SelectCell.svelte +133 -0
  56. package/src/client/database/cells/TextCell.svelte +85 -0
  57. package/src/client/database/colours.ts +27 -0
  58. package/src/client/database/property-types.test.ts +64 -0
  59. package/src/client/database/property-types.ts +294 -0
  60. package/src/client/database/view-config.test.ts +148 -0
  61. package/src/client/database/view-config.ts +102 -0
  62. package/src/client/formula.test.ts +139 -0
  63. package/src/client/formula.ts +430 -0
  64. package/src/client/i18n.ts +1176 -0
  65. package/src/client/index.ts +31 -0
  66. package/src/client/mock.ts +511 -1
  67. package/src/client/pages/PageView.svelte +47 -17
  68. package/src/client/pages/SpacePage.svelte +8 -2
  69. package/src/client/query.ts +17 -4
  70. package/src/contract/properties.ts +28 -0
  71. package/src/contract/router.ts +46 -0
  72. package/dist/server/formula.d.ts.map +0 -1
  73. package/dist/server/formula.js.map +0 -1
@@ -0,0 +1,388 @@
1
+ <script lang="ts">
2
+ import {
3
+ DropdownMenu,
4
+ Icon,
5
+ IconButton,
6
+ type MenuItem,
7
+ Table,
8
+ TableCell,
9
+ TableHeader,
10
+ TableRow,
11
+ } from '@kernhq/ui'
12
+ import type { Database, Property, Row, View, ViewConfig } from '../../contract/index.js'
13
+ import type { Person } from '../core-api.js'
14
+ import { t } from '../i18n.js'
15
+ import Cell from './cells/Cell.svelte'
16
+ import PropertyMenu from './PropertyMenu.svelte'
17
+ import {
18
+ clampWidth,
19
+ columnTemplate,
20
+ DEFAULT_COLUMN_WIDTH,
21
+ tableMinWidth,
22
+ visiblePropertiesOf,
23
+ } from './view-config.js'
24
+
25
+ /**
26
+ * The table.
27
+ *
28
+ * Three things about it are deliberate and easy to undo by accident.
29
+ *
30
+ * `TableRow` is given neither `href` nor `onclick`: it becomes an `<a>` with the first and a
31
+ * `<button>` with the second, and a button cannot contain the inline editors — so "open" lives in
32
+ * the hover action group instead, where it is also reachable by keyboard.
33
+ *
34
+ * The whole table scrolls inside its **own** `overflow-x: auto` wrapper. `Page` sets
35
+ * `overflow-x: hidden`, so without the wrapper a wide table is clipped rather than scrolled, and
36
+ * the columns past the edge are unreachable.
37
+ *
38
+ * Resizing is a pointer gesture with no keyboard equivalent of its own, so widen and narrow live in
39
+ * the column menu. Reordering has **no** drag at all — the menu's move-earlier and move-later are
40
+ * the whole interaction, which is one fewer thing to get wrong and loses nothing.
41
+ */
42
+ interface Props {
43
+ database: Database
44
+ view: View | null
45
+ rows: Row[]
46
+ people: Person[]
47
+ workspaceId: string
48
+ canEdit: boolean
49
+ canCreate: boolean
50
+ sortDirectionOf: (key: string) => 'asc' | 'desc' | null
51
+ onCellChange: (row: Row, property: Property, value: unknown) => void
52
+ onTitleChange: (row: Row, title: string) => void
53
+ onOpenRow: (row: Row) => void
54
+ onOpenPage: (row: Row) => void
55
+ onDuplicateRow: (row: Row) => void
56
+ onDeleteRow: (row: Row) => void
57
+ onAddRow: () => void
58
+ onAddProperty: () => void
59
+ onEditProperty: (property: Property) => void
60
+ onMoveProperty: (property: Property, direction: -1 | 1) => void
61
+ onHideProperty: (property: Property) => void
62
+ onDeleteProperty: (property: Property) => void
63
+ onSortBy: (property: Property, direction: 'asc' | 'desc' | null) => void
64
+ onFilterBy: (property: Property) => void
65
+ onConfigChange: (patch: Partial<ViewConfig>) => void
66
+ }
67
+ const {
68
+ database,
69
+ view,
70
+ rows,
71
+ people,
72
+ workspaceId,
73
+ canEdit,
74
+ canCreate,
75
+ sortDirectionOf,
76
+ onCellChange,
77
+ onTitleChange,
78
+ onOpenRow,
79
+ onOpenPage,
80
+ onDuplicateRow,
81
+ onDeleteRow,
82
+ onAddRow,
83
+ onAddProperty,
84
+ onEditProperty,
85
+ onMoveProperty,
86
+ onHideProperty,
87
+ onDeleteProperty,
88
+ onSortBy,
89
+ onFilterBy,
90
+ onConfigChange,
91
+ }: Props = $props()
92
+
93
+ const columns = $derived(visiblePropertiesOf(database, view))
94
+ const widths = $derived(view?.config.columnWidths ?? {})
95
+ const template = $derived(columnTemplate(columns, widths))
96
+ const minWidth = $derived(tableMinWidth(columns, widths))
97
+
98
+ /** The pointer drag in progress, if any. Committed once, on release, not on every move. */
99
+ let dragging = $state<{ key: string; startX: number; startWidth: number } | null>(null)
100
+ let previewWidth = $state<Record<string, number>>({})
101
+
102
+ const widthOf = (key: string) => previewWidth[key] ?? widths[key] ?? DEFAULT_COLUMN_WIDTH
103
+
104
+ const liveTemplate = $derived(dragging ? columnTemplate(columns, { ...widths, ...previewWidth }) : template)
105
+
106
+ function startResize(event: PointerEvent, property: Property) {
107
+ if (!canEdit) return
108
+ const target = event.currentTarget as HTMLElement
109
+ target.setPointerCapture(event.pointerId)
110
+ dragging = { key: property.key, startX: event.clientX, startWidth: widthOf(property.key) }
111
+ }
112
+
113
+ function moveResize(event: PointerEvent) {
114
+ if (!dragging) return
115
+ // In RTL the pointer moves the other way, and a column that shrinks when you drag it outwards is
116
+ // the sort of thing that reads as broken rather than as mirrored.
117
+ const rtl = document.documentElement.dir === 'rtl'
118
+ const delta = (event.clientX - dragging.startX) * (rtl ? -1 : 1)
119
+ previewWidth = { ...previewWidth, [dragging.key]: clampWidth(dragging.startWidth + delta) }
120
+ }
121
+
122
+ function endResize() {
123
+ if (!dragging) return
124
+ const key = dragging.key
125
+ const next = previewWidth[key]
126
+ dragging = null
127
+ previewWidth = {}
128
+ if (next !== undefined && next !== (widths[key] ?? DEFAULT_COLUMN_WIDTH))
129
+ onConfigChange({ columnWidths: { ...widths, [key]: next } })
130
+ }
131
+
132
+ /** The keyboard route for the drag: widen and narrow by a fixed step from the column menu. */
133
+ function resizeBy(property: Property, delta: number) {
134
+ const next = clampWidth(widthOf(property.key) + delta)
135
+ onConfigChange({ columnWidths: { ...widths, [property.key]: next } })
136
+ }
137
+
138
+ const rowMenu = (row: Row): MenuItem[] => [
139
+ { id: 'open', label: t('db_open_row'), icon: 'maximize-2', onSelect: () => onOpenRow(row) },
140
+ { id: 'page', label: t('db_open_as_page'), icon: 'file-text', onSelect: () => onOpenPage(row) },
141
+ {
142
+ id: 'duplicate',
143
+ label: t('db_duplicate_row'),
144
+ icon: 'copy',
145
+ disabled: !canCreate,
146
+ onSelect: () => onDuplicateRow(row),
147
+ },
148
+ { type: 'separator' },
149
+ {
150
+ id: 'delete',
151
+ label: t('db_delete_row'),
152
+ icon: 'trash-2',
153
+ danger: true,
154
+ disabled: !canEdit,
155
+ onSelect: () => onDeleteRow(row),
156
+ },
157
+ ]
158
+ </script>
159
+
160
+ <div class="scroll">
161
+ <div class="rail" data-testid="database-table" style:min-width="{minWidth}px">
162
+ <Table columns={liveTemplate} dense>
163
+ <TableHeader>
164
+ <TableCell header>
165
+ <span class="title-head">{t('db_title_column')}</span>
166
+ </TableCell>
167
+ {#each columns as property, index (property.id)}
168
+ <TableCell header class="col-head">
169
+ <PropertyMenu
170
+ {property}
171
+ {canEdit}
172
+ first={index === 0}
173
+ last={index === columns.length - 1}
174
+ sortDirection={sortDirectionOf(property.key)}
175
+ onEdit={() => onEditProperty(property)}
176
+ onHide={() => onHideProperty(property)}
177
+ onMove={(direction) => onMoveProperty(property, direction)}
178
+ onResize={(delta) => resizeBy(property, delta)}
179
+ onSort={(direction) => onSortBy(property, direction)}
180
+ onFilter={() => onFilterBy(property)}
181
+ onDelete={() => onDeleteProperty(property)}
182
+ />
183
+ {#if canEdit}
184
+ <!--
185
+ A 6px grabber with a 24px hit area behind it: under 24 it fails the target-size rule
186
+ and it sits right beside the next header, so the spacing exception does not apply.
187
+ -->
188
+ <span
189
+ class="grip"
190
+ role="separator"
191
+ aria-label={t('db_resize', { name: property.name })}
192
+ aria-orientation="vertical"
193
+ onpointerdown={(e) => startResize(e, property)}
194
+ onpointermove={moveResize}
195
+ onpointerup={endResize}
196
+ onpointercancel={endResize}
197
+ ></span>
198
+ {/if}
199
+ </TableCell>
200
+ {/each}
201
+ <TableCell header end>
202
+ {#if canEdit}
203
+ <IconButton icon="plus" label={t('db_add_property')} size={26} variant="ghost" onclick={onAddProperty} />
204
+ {/if}
205
+ </TableCell>
206
+ </TableHeader>
207
+
208
+ {#each rows as row (row.id)}
209
+ <TableRow data-testid="database-row" data-row-id={row.id}>
210
+ <TableCell class="title-cell">
211
+ {#if canEdit}
212
+ <input
213
+ class="title-input"
214
+ value={row.title}
215
+ aria-label={t('db_title_column')}
216
+ placeholder={t('untitled')}
217
+ onblur={(e) => onTitleChange(row, e.currentTarget.value)}
218
+ onkeydown={(e) => {
219
+ if (e.key === 'Enter') e.currentTarget.blur()
220
+ if (e.key === 'Escape') {
221
+ e.currentTarget.value = row.title
222
+ e.currentTarget.blur()
223
+ }
224
+ }}
225
+ />
226
+ {:else}
227
+ <span class="title-static">{row.title.trim() || t('untitled')}</span>
228
+ {/if}
229
+ </TableCell>
230
+
231
+ {#each columns as property (property.id)}
232
+ <TableCell>
233
+ <Cell
234
+ {property}
235
+ {row}
236
+ {people}
237
+ {workspaceId}
238
+ canEdit={canEdit}
239
+ onchange={(value) => onCellChange(row, property, value)}
240
+ />
241
+ </TableCell>
242
+ {/each}
243
+
244
+ <TableCell end class="acts">
245
+ <span class="actions">
246
+ <IconButton
247
+ icon="maximize-2"
248
+ label={t('db_open_row_named', { title: row.title.trim() || t('untitled') })}
249
+ size={26}
250
+ variant="ghost"
251
+ onclick={() => onOpenRow(row)}
252
+ />
253
+ <DropdownMenu items={rowMenu(row)}>
254
+ {#snippet trigger(props: Record<string, unknown>)}
255
+ <IconButton
256
+ {...props}
257
+ icon="ellipsis"
258
+ label={t('db_row_actions', { title: row.title.trim() || t('untitled') })}
259
+ size={26}
260
+ variant="ghost"
261
+ />
262
+ {/snippet}
263
+ </DropdownMenu>
264
+ </span>
265
+ </TableCell>
266
+ </TableRow>
267
+ {/each}
268
+
269
+ {#if canCreate}
270
+ <button type="button" class="new-row" onclick={onAddRow}>
271
+ <Icon name="plus" size={13} strokeWidth={1.9} />
272
+ <span>{t('db_new_row')}</span>
273
+ </button>
274
+ {/if}
275
+ </Table>
276
+ </div>
277
+ </div>
278
+
279
+ <style>
280
+ /*
281
+ * `Page` is `overflow-x: hidden`, so the table has to own its own horizontal scroll or the columns
282
+ * past the viewport are simply unreachable.
283
+ */
284
+ .scroll {
285
+ overflow-x: auto;
286
+ overflow-y: visible;
287
+ padding-block-end: 40px;
288
+ }
289
+ .rail {
290
+ min-width: 100%;
291
+ }
292
+ .title-head {
293
+ font-size: inherit;
294
+ letter-spacing: inherit;
295
+ }
296
+ :global([dir='rtl']) .title-head {
297
+ letter-spacing: 0;
298
+ text-transform: none;
299
+ }
300
+ /* `.ktd` is `overflow: hidden`; the header cell needs the grabber to sit on its trailing edge. */
301
+ :global(.ktd.col-head) {
302
+ position: relative;
303
+ overflow: visible;
304
+ }
305
+ .grip {
306
+ position: absolute;
307
+ inset-block: 4px;
308
+ inset-inline-end: -9px;
309
+ width: 6px;
310
+ border-radius: 3px;
311
+ cursor: col-resize;
312
+ touch-action: none;
313
+ }
314
+ .grip::after {
315
+ content: '';
316
+ position: absolute;
317
+ inset-block: -4px;
318
+ inset-inline: -9px;
319
+ }
320
+ .grip:hover,
321
+ .grip:active {
322
+ background: var(--kern-accent);
323
+ }
324
+ .title-input {
325
+ width: 100%;
326
+ min-width: 0;
327
+ min-height: 26px;
328
+ padding: 2px 6px;
329
+ margin-inline-start: -6px;
330
+ border: 1px solid transparent;
331
+ border-radius: var(--kern-r-sm);
332
+ background: none;
333
+ color: var(--kern-ink-900);
334
+ font: inherit;
335
+ font-size: 13px;
336
+ font-weight: 500;
337
+ }
338
+ .title-input:hover {
339
+ background: var(--kern-surface-active);
340
+ }
341
+ .title-input:focus {
342
+ border-color: var(--kern-border);
343
+ background: var(--kern-surface-raised);
344
+ outline: none;
345
+ }
346
+ .title-input::placeholder {
347
+ color: var(--kern-ink-350);
348
+ }
349
+ .title-static {
350
+ overflow: hidden;
351
+ text-overflow: ellipsis;
352
+ white-space: nowrap;
353
+ color: var(--kern-ink-900);
354
+ font-weight: 500;
355
+ }
356
+ /*
357
+ * Hidden until the row is hovered or something inside it has focus — `focus-within` is what keeps
358
+ * the actions reachable by keyboard, which `display: none` until hover would not.
359
+ */
360
+ .actions {
361
+ display: inline-flex;
362
+ gap: 2px;
363
+ opacity: 0;
364
+ }
365
+ :global(.ktr:hover) .actions,
366
+ :global(.ktr:focus-within) .actions {
367
+ opacity: 1;
368
+ }
369
+ .new-row {
370
+ display: flex;
371
+ align-items: center;
372
+ gap: 7px;
373
+ width: 100%;
374
+ min-height: 36px;
375
+ padding: 0 12px;
376
+ border: 0;
377
+ border-bottom: 1px solid var(--kern-border-hairline);
378
+ background: none;
379
+ color: var(--kern-ink-450);
380
+ font: inherit;
381
+ font-size: 13px;
382
+ text-align: start;
383
+ }
384
+ .new-row:hover {
385
+ background: var(--kern-surface-raised);
386
+ color: var(--kern-ink-900);
387
+ }
388
+ </style>
@@ -0,0 +1,229 @@
1
+ <script lang="ts">
2
+ import { Button, Dialog, Field, Input, Select } from '@kernhq/ui'
3
+ import type { Database, View, ViewConfig, ViewKind } from '../../contract/index.js'
4
+ import { t } from '../i18n.js'
5
+ import { descriptorFor, VIEW_KINDS } from './property-types.js'
6
+ import { mergeConfig, orderedProperties } from './view-config.js'
7
+
8
+ /**
9
+ * Add a view, or change one.
10
+ *
11
+ * Only the settings the chosen kind uses are shown: a table has no group-by, a board has no date
12
+ * column, and a gallery's cover picker is disabled **with the reason** rather than hidden — the
13
+ * setting exists, it is files that are not built.
14
+ *
15
+ * The config sent is always the merged whole, because `updateView` replaces it.
16
+ */
17
+ interface Props {
18
+ open: boolean
19
+ database: Database
20
+ /** null when adding */
21
+ view: View | null
22
+ busy: boolean
23
+ onClose: () => void
24
+ onSubmit: (input: { name: string; kind: ViewKind; config: ViewConfig }) => void
25
+ }
26
+ const { open, database, view, busy, onClose, onSubmit }: Props = $props()
27
+
28
+ const BLANK: ViewConfig = {
29
+ filters: [],
30
+ filterMode: 'and',
31
+ sorts: [],
32
+ groupBy: null,
33
+ dateProperty: null,
34
+ visibleProperties: null,
35
+ columnWidths: {},
36
+ cardSize: 'medium',
37
+ coverProperty: null,
38
+ }
39
+
40
+ let name = $state('')
41
+ let kind = $state<ViewKind>('table')
42
+ let config = $state<ViewConfig>(BLANK)
43
+ let submitting = $state(false)
44
+
45
+ $effect(() => {
46
+ if (!open) return
47
+ name = view?.name ?? ''
48
+ kind = view && VIEW_KINDS.some((v) => v.kind === view.kind) ? view.kind : 'table'
49
+ config = view ? mergeConfig(BLANK, view.config) : BLANK
50
+ submitting = false
51
+ })
52
+
53
+ const properties = $derived(orderedProperties(database))
54
+ const groupable = $derived(properties.filter((p) => descriptorFor(p.type).canGroup))
55
+ const datable = $derived(properties.filter((p) => descriptorFor(p.type).canDate))
56
+
57
+ const patch = (next: Partial<ViewConfig>) => {
58
+ config = mergeConfig(config, next)
59
+ }
60
+
61
+ const visible = $derived(config.visibleProperties ?? properties.filter((p) => !p.hidden).map((p) => p.key))
62
+
63
+ const toggleVisible = (key: string, on: boolean) =>
64
+ patch({
65
+ visibleProperties: on ? [...visible.filter((k) => k !== key), key] : visible.filter((k) => k !== key),
66
+ })
67
+
68
+ const valid = $derived(name.trim().length > 0)
69
+
70
+ function submit() {
71
+ if (submitting || !valid) return
72
+ submitting = true
73
+ onSubmit({ name: name.trim(), kind, config: $state.snapshot(config) })
74
+ }
75
+
76
+ $effect(() => {
77
+ if (!busy) submitting = false
78
+ })
79
+ </script>
80
+
81
+ <Dialog {open} title={view ? t('db_edit_view') : t('db_add_view')} size="md" onOpenChange={(o) => !o && onClose()}>
82
+ <div class="form">
83
+ <Field label={t('db_view_name')}>
84
+ {#snippet children(id: string)}
85
+ <Input {id} bind:value={name} placeholder={t('db_view_name')} />
86
+ {/snippet}
87
+ </Field>
88
+
89
+ <Field label={t('db_view_kind')}>
90
+ {#snippet children(id: string)}
91
+ <Select
92
+ {id}
93
+ value={kind}
94
+ options={VIEW_KINDS.map((v) => ({ value: v.kind, label: t(`db_kind_${v.kind}`), icon: v.icon }))}
95
+ onValueChange={(next) => (kind = next as ViewKind)}
96
+ />
97
+ {/snippet}
98
+ </Field>
99
+
100
+ {#if kind === 'board'}
101
+ <Field label={t('db_group_by')} hint={groupable.length === 0 ? t('db_group_needs_column') : undefined}>
102
+ {#snippet children(id: string)}
103
+ <Select
104
+ {id}
105
+ value={config.groupBy ?? ''}
106
+ placeholder={t('db_group_none')}
107
+ disabled={groupable.length === 0}
108
+ options={groupable.map((p) => ({ value: p.key, label: p.name, icon: descriptorFor(p.type).icon }))}
109
+ onValueChange={(next) => patch({ groupBy: next || null })}
110
+ />
111
+ {/snippet}
112
+ </Field>
113
+ {/if}
114
+
115
+ {#if kind === 'calendar'}
116
+ <Field label={t('db_date_property')} hint={datable.length === 0 ? t('db_date_needs_column') : undefined}>
117
+ {#snippet children(id: string)}
118
+ <Select
119
+ {id}
120
+ value={config.dateProperty ?? ''}
121
+ placeholder={t('db_date_choose')}
122
+ disabled={datable.length === 0}
123
+ options={datable.map((p) => ({ value: p.key, label: p.name, icon: descriptorFor(p.type).icon }))}
124
+ onValueChange={(next) => patch({ dateProperty: next || null })}
125
+ />
126
+ {/snippet}
127
+ </Field>
128
+ {/if}
129
+
130
+ {#if kind === 'gallery'}
131
+ <Field label={t('db_cover_property')} hint={t('db_cover_unsupported')}>
132
+ {#snippet children(id: string)}
133
+ <Select {id} value="" disabled placeholder={t('db_cover_none')} options={[]} />
134
+ {/snippet}
135
+ </Field>
136
+ <Field label={t('db_card_size')}>
137
+ {#snippet children(id: string)}
138
+ <Select
139
+ {id}
140
+ value={config.cardSize}
141
+ options={(['small', 'medium', 'large'] as const).map((s) => ({
142
+ value: s,
143
+ label: t(`db_size_${s}`),
144
+ }))}
145
+ onValueChange={(next) => patch({ cardSize: next as 'small' | 'medium' | 'large' })}
146
+ />
147
+ {/snippet}
148
+ </Field>
149
+ {/if}
150
+
151
+ <fieldset class="group">
152
+ <legend>{t('db_visible_properties')}</legend>
153
+ {#if properties.length === 0}
154
+ <p class="hint">{t('db_no_columns')}</p>
155
+ {:else}
156
+ <ul class="cols">
157
+ {#each properties as property (property.id)}
158
+ <li>
159
+ <label>
160
+ <input
161
+ type="checkbox"
162
+ checked={visible.includes(property.key)}
163
+ onchange={(e) => toggleVisible(property.key, e.currentTarget.checked)}
164
+ />
165
+ <span>{property.name}</span>
166
+ </label>
167
+ </li>
168
+ {/each}
169
+ </ul>
170
+ {/if}
171
+ </fieldset>
172
+ </div>
173
+
174
+ {#snippet footer()}
175
+ <Button variant="secondary" onclick={onClose}>{t('common.cancel')}</Button>
176
+ <Button disabled={!valid || busy || submitting} onclick={submit}>
177
+ {view ? t('common.save') : t('common.create')}
178
+ </Button>
179
+ {/snippet}
180
+ </Dialog>
181
+
182
+ <style>
183
+ .form {
184
+ display: flex;
185
+ flex-direction: column;
186
+ gap: 16px;
187
+ }
188
+ .group {
189
+ border: 1px solid var(--kern-border);
190
+ border-radius: var(--kern-r-lg);
191
+ padding: 12px 14px 14px;
192
+ margin: 0;
193
+ }
194
+ legend {
195
+ padding-inline: 4px;
196
+ font-size: 12px;
197
+ font-weight: 600;
198
+ color: var(--kern-ink-550);
199
+ }
200
+ .cols {
201
+ list-style: none;
202
+ margin: 0;
203
+ padding: 0;
204
+ display: grid;
205
+ grid-template-columns: repeat(auto-fill, minmax(160px, 1fr));
206
+ gap: 2px;
207
+ max-height: 200px;
208
+ overflow-y: auto;
209
+ }
210
+ .cols label {
211
+ display: flex;
212
+ align-items: center;
213
+ gap: 7px;
214
+ min-height: 28px;
215
+ padding: 2px 4px;
216
+ border-radius: var(--kern-r-sm);
217
+ font-size: 13px;
218
+ color: var(--kern-ink-700);
219
+ cursor: pointer;
220
+ }
221
+ .cols label:hover {
222
+ background: var(--kern-surface-hover);
223
+ }
224
+ .hint {
225
+ margin: 0;
226
+ font-size: 12.5px;
227
+ color: var(--kern-ink-450);
228
+ }
229
+ </style>