@kernhq/module-quire 0.7.4 → 0.9.0

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 (88) 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/document.d.ts +13 -0
  19. package/dist/server/document.d.ts.map +1 -0
  20. package/dist/server/document.js +58 -0
  21. package/dist/server/document.js.map +1 -0
  22. package/dist/server/index.d.ts.map +1 -1
  23. package/dist/server/index.js +26 -1
  24. package/dist/server/index.js.map +1 -1
  25. package/dist/server/render.d.ts +53 -0
  26. package/dist/server/render.d.ts.map +1 -0
  27. package/dist/server/render.js +358 -0
  28. package/dist/server/render.js.map +1 -0
  29. package/dist/server/services/databases.d.ts +73 -1
  30. package/dist/server/services/databases.d.ts.map +1 -1
  31. package/dist/server/services/databases.js +167 -10
  32. package/dist/server/services/databases.js.map +1 -1
  33. package/dist/server/services/pages.d.ts.map +1 -1
  34. package/dist/server/services/pages.js +11 -1
  35. package/dist/server/services/pages.js.map +1 -1
  36. package/dist/server/services/query.d.ts.map +1 -1
  37. package/dist/server/services/query.js +93 -37
  38. package/dist/server/services/query.js.map +1 -1
  39. package/dist/server/services/versions.d.ts.map +1 -1
  40. package/dist/server/services/versions.js +13 -1
  41. package/dist/server/services/versions.js.map +1 -1
  42. package/migrations/0006_rank_collation.sql +14 -0
  43. package/migrations/meta/_journal.json +7 -0
  44. package/package.json +12 -9
  45. package/src/client/components/PageEditor.svelte +7 -0
  46. package/src/client/components/PageTreeRow.svelte +1 -1
  47. package/src/client/core-api.ts +38 -0
  48. package/src/client/database/BoardView.svelte +354 -0
  49. package/src/client/database/CalendarView.svelte +346 -0
  50. package/src/client/database/DatabaseView.svelte +785 -0
  51. package/src/client/database/FilterMenu.svelte +281 -0
  52. package/src/client/database/FilterValue.svelte +176 -0
  53. package/src/client/database/GalleryView.svelte +177 -0
  54. package/src/client/database/ListView.svelte +108 -0
  55. package/src/client/database/OptionChip.svelte +38 -0
  56. package/src/client/database/PropertyDialog.svelte +460 -0
  57. package/src/client/database/PropertyMenu.svelte +162 -0
  58. package/src/client/database/RowPanel.svelte +157 -0
  59. package/src/client/database/SortMenu.svelte +199 -0
  60. package/src/client/database/TableView.svelte +388 -0
  61. package/src/client/database/ViewDialog.svelte +229 -0
  62. package/src/client/database/cells/Cell.svelte +103 -0
  63. package/src/client/database/cells/CheckboxCell.svelte +33 -0
  64. package/src/client/database/cells/ComputedCell.svelte +100 -0
  65. package/src/client/database/cells/DateCell.svelte +97 -0
  66. package/src/client/database/cells/LinkCell.svelte +143 -0
  67. package/src/client/database/cells/NumberCell.svelte +131 -0
  68. package/src/client/database/cells/PersonCell.svelte +116 -0
  69. package/src/client/database/cells/RelationCell.svelte +222 -0
  70. package/src/client/database/cells/SelectCell.svelte +133 -0
  71. package/src/client/database/cells/TextCell.svelte +85 -0
  72. package/src/client/database/colours.ts +27 -0
  73. package/src/client/database/property-types.test.ts +64 -0
  74. package/src/client/database/property-types.ts +294 -0
  75. package/src/client/database/view-config.test.ts +148 -0
  76. package/src/client/database/view-config.ts +102 -0
  77. package/src/client/formula.test.ts +139 -0
  78. package/src/client/formula.ts +430 -0
  79. package/src/client/i18n.ts +1176 -0
  80. package/src/client/index.ts +31 -0
  81. package/src/client/mock.ts +511 -1
  82. package/src/client/pages/PageView.svelte +47 -17
  83. package/src/client/pages/SpacePage.svelte +8 -2
  84. package/src/client/query.ts +17 -4
  85. package/src/contract/properties.ts +28 -0
  86. package/src/contract/router.ts +46 -0
  87. package/dist/server/formula.d.ts.map +0 -1
  88. package/dist/server/formula.js.map +0 -1
@@ -0,0 +1,785 @@
1
+ <script lang="ts">
2
+ import {
3
+ Button,
4
+ coreApi,
5
+ Dialog,
6
+ DropdownMenu,
7
+ EmptyState,
8
+ Icon,
9
+ IconButton,
10
+ keys,
11
+ type MenuItem,
12
+ navigation,
13
+ Skeleton,
14
+ Tabs,
15
+ Toolbar,
16
+ ToolbarButton,
17
+ toast,
18
+ } from '@kernhq/ui'
19
+ import { createInfiniteQuery, createQuery, useQueryClient } from '@tanstack/svelte-query'
20
+ import type {
21
+ Property,
22
+ PropertyConfig,
23
+ PropertyType,
24
+ Row,
25
+ View,
26
+ ViewConfig,
27
+ ViewKind,
28
+ } from '../../contract/index.js'
29
+ import { getQuireApi } from '../api-instance.js'
30
+ import { type CoreApi, toPerson } from '../core-api.js'
31
+ import { t } from '../i18n.js'
32
+ import { canQuire } from '../permissions.js'
33
+ import { quireKeys } from '../query.js'
34
+ import BoardView from './BoardView.svelte'
35
+ import CalendarView from './CalendarView.svelte'
36
+ import FilterMenu from './FilterMenu.svelte'
37
+ import GalleryView from './GalleryView.svelte'
38
+ import ListView from './ListView.svelte'
39
+ import PropertyDialog from './PropertyDialog.svelte'
40
+ import { descriptorFor, viewIcon } from './property-types.js'
41
+ import RowPanel from './RowPanel.svelte'
42
+ import SortMenu from './SortMenu.svelte'
43
+ import TableView from './TableView.svelte'
44
+ import ViewDialog from './ViewDialog.svelte'
45
+ import { EMPTY_GROUP, groupValue, mergeConfig, orderedProperties } from './view-config.js'
46
+
47
+ /**
48
+ * A database page: the view tabs, the 52px toolbar, and whichever view is chosen.
49
+ *
50
+ * Everything that writes lives here rather than in the views, so there is one place that knows a
51
+ * `ViewConfig` is replaced wholesale and one place that reports a failure. The views take data and
52
+ * callbacks and hold no queries of their own.
53
+ *
54
+ * The table pages lazily; the other four ask for one large page, because a board cannot show a
55
+ * "load more" button at the bottom of a lane without lying about what the lane contains. That cap
56
+ * is stated on screen rather than left as a silently short list.
57
+ */
58
+ interface Props {
59
+ workspaceId: string
60
+ spaceKey: string
61
+ pageId: string
62
+ spaceId: string
63
+ }
64
+ const { workspaceId, spaceKey, pageId, spaceId }: Props = $props()
65
+
66
+ const api = getQuireApi()
67
+ const core = coreApi<CoreApi>()
68
+ const client = useQueryClient()
69
+
70
+ const PAGE_SIZE = 50
71
+ /** What a non-paging view will show before it says it has stopped. */
72
+ const CAP = 500
73
+
74
+ const canEdit = $derived(canQuire('pageEdit'))
75
+ const canCreate = $derived(canQuire('pageCreate'))
76
+
77
+ // ---- data ---------------------------------------------------------------------------------
78
+
79
+ const forPage = createQuery(() => ({
80
+ queryKey: quireKeys.databaseForPage(workspaceId, pageId),
81
+ enabled: Boolean(workspaceId && pageId),
82
+ queryFn: () => api.databases.forPage({ workspaceId, pageId }),
83
+ }))
84
+
85
+ const databaseId = $derived(forPage.data?.id ?? '')
86
+
87
+ const databaseQuery = createQuery(() => ({
88
+ queryKey: quireKeys.database(workspaceId, databaseId),
89
+ enabled: Boolean(workspaceId && databaseId),
90
+ queryFn: () => api.databases.get({ workspaceId, databaseId }),
91
+ }))
92
+
93
+ const database = $derived(databaseQuery.data ?? forPage.data ?? null)
94
+ const views = $derived(database?.views ?? [])
95
+
96
+ let chosenViewId = $state<string | null>(null)
97
+ const view = $derived<View | null>(
98
+ views.find((v) => v.id === chosenViewId) ?? views.find((v) => v.isDefault) ?? views[0] ?? null,
99
+ )
100
+
101
+ /** `nextCursor` is an offset the server minted; the client never builds one. */
102
+ type RowPage = { items: Row[]; nextCursor: string | null }
103
+
104
+ const rowsQuery = createInfiniteQuery(() => ({
105
+ queryKey: quireKeys.rows(workspaceId, databaseId, view?.id ?? null),
106
+ enabled: Boolean(workspaceId && databaseId && view),
107
+ initialPageParam: null as string | null,
108
+ queryFn: ({ pageParam }: { pageParam: string | null }): Promise<RowPage> =>
109
+ api.databases.rows({
110
+ workspaceId,
111
+ databaseId,
112
+ viewId: view?.id ?? null,
113
+ limit: PAGE_SIZE,
114
+ cursor: pageParam ?? undefined,
115
+ }),
116
+ getNextPageParam: (last: RowPage) => last.nextCursor,
117
+ }))
118
+
119
+ const rows = $derived<Row[]>(((rowsQuery.data?.pages ?? []) as RowPage[]).flatMap((p) => p.items))
120
+
121
+ const membersQuery = createQuery(() => ({
122
+ queryKey: keys.members(workspaceId),
123
+ enabled: Boolean(workspaceId),
124
+ queryFn: () => core.workspaces.members.list({ workspaceId, limit: 200 }),
125
+ }))
126
+ const people = $derived((membersQuery.data?.items ?? []).map(toPerson))
127
+
128
+ /**
129
+ * A board, gallery, list or calendar wants the whole set at once, so it keeps asking until it has
130
+ * it — up to the cap, which is then said out loud.
131
+ */
132
+ const paging = $derived(view?.kind === 'table')
133
+ $effect(() => {
134
+ if (paging || !rowsQuery.hasNextPage || rowsQuery.isFetchingNextPage) return
135
+ if (rows.length >= CAP) return
136
+ void rowsQuery.fetchNextPage()
137
+ })
138
+ const capped = $derived(!paging && rows.length >= CAP && Boolean(rowsQuery.hasNextPage))
139
+
140
+ // ---- state --------------------------------------------------------------------------------
141
+
142
+ let busy = $state(false)
143
+ let filterOpen = $state(false)
144
+ let sortOpen = $state(false)
145
+ let propertyDialog = $state<{ property: Property | null } | null>(null)
146
+ let viewDialog = $state<{ view: View | null } | null>(null)
147
+ let confirming = $state<
148
+ { kind: 'property'; property: Property } | { kind: 'view'; view: View } | { kind: 'row'; row: Row } | null
149
+ >(null)
150
+ let inspecting = $state<string | null>(null)
151
+
152
+ const inspected = $derived(inspecting ? (rows.find((r) => r.id === inspecting) ?? null) : null)
153
+ $effect(() => {
154
+ // The row was deleted, or a filter now excludes it; the panel must not linger over nothing.
155
+ if (inspecting && rows.length > 0 && !rows.some((r) => r.id === inspecting)) inspecting = null
156
+ })
157
+
158
+ const properties = $derived(database ? orderedProperties(database) : [])
159
+ const relations = $derived(properties.filter((p) => p.type === 'relation'))
160
+ const groupProperty = $derived(
161
+ view?.config.groupBy ? (properties.find((p) => p.key === view.config.groupBy) ?? null) : null,
162
+ )
163
+
164
+ // ---- writing ------------------------------------------------------------------------------
165
+
166
+ const refreshSchema = async () => {
167
+ await client.invalidateQueries({ queryKey: quireKeys.databaseForPage(workspaceId, pageId) })
168
+ await client.invalidateQueries({ queryKey: quireKeys.database(workspaceId, databaseId) })
169
+ }
170
+ const refreshRows = () => client.invalidateQueries({ queryKey: ['quire', 'row', workspaceId, databaseId] })
171
+
172
+ /**
173
+ * One place that reports a failure, and one flag that stops a second click.
174
+ *
175
+ * `disabled={busy}` alone does not: the attribute reaches the button on the next render and two
176
+ * quick clicks are one render apart, so the guard is read here in the same tick as the click.
177
+ */
178
+ async function act(work: () => Promise<unknown>, after: () => Promise<unknown>) {
179
+ if (busy) return
180
+ busy = true
181
+ try {
182
+ await work()
183
+ await after()
184
+ } catch (err) {
185
+ toast.error(err instanceof Error && err.message ? err.message : t('common.error'))
186
+ } finally {
187
+ busy = false
188
+ }
189
+ }
190
+
191
+ const patchView = (patch: Partial<ViewConfig>) => {
192
+ if (!view) return
193
+ // `updateView` replaces `config` wholesale, so a partial write deletes the rest of the view.
194
+ const config = mergeConfig(view.config, patch)
195
+ void act(() => api.databases.updateView({ workspaceId, viewId: view.id, config }), refreshSchema)
196
+ }
197
+
198
+ const writeCell = (row: Row, property: Property, value: unknown) =>
199
+ act(
200
+ () => api.databases.updateRow({ workspaceId, rowId: row.id, props: { [property.key]: value } }),
201
+ refreshRows,
202
+ )
203
+
204
+ const writeTitle = (row: Row, title: string) => {
205
+ if (title === row.title) return Promise.resolve()
206
+ return act(
207
+ () => api.databases.updateRow({ workspaceId, rowId: row.id, title }),
208
+ async () => {
209
+ await refreshRows()
210
+ await client.invalidateQueries({ queryKey: quireKeys.tree(workspaceId, spaceId) })
211
+ },
212
+ )
213
+ }
214
+
215
+ const addRow = (seed: Record<string, unknown> = {}) =>
216
+ act(
217
+ () => api.databases.addRow({ workspaceId, databaseId, title: '', props: seed }),
218
+ async () => {
219
+ await refreshRows()
220
+ await client.invalidateQueries({ queryKey: quireKeys.tree(workspaceId, spaceId) })
221
+ },
222
+ )
223
+
224
+ const duplicateRow = (row: Row) =>
225
+ act(
226
+ () =>
227
+ api.databases.addRow({
228
+ workspaceId,
229
+ databaseId,
230
+ title: row.title,
231
+ props: $state.snapshot(row.props),
232
+ }),
233
+ refreshRows,
234
+ )
235
+
236
+ const openPage = (row: Row) =>
237
+ navigation.go(
238
+ `/${navigation.workspaceSlug}/quire/${encodeURIComponent(spaceKey)}/${encodeURIComponent(row.id)}`,
239
+ )
240
+
241
+ const moveOnBoard = (row: Row, laneId: string) => {
242
+ if (!groupProperty) return Promise.resolve()
243
+ const value = groupValue(laneId)
244
+ return act(
245
+ () =>
246
+ api.databases.updateRow({
247
+ workspaceId,
248
+ rowId: row.id,
249
+ props: {
250
+ [groupProperty.key]: groupProperty.type === 'checkbox' ? value === 'true' : value,
251
+ },
252
+ }),
253
+ refreshRows,
254
+ )
255
+ }
256
+
257
+ const setDate = (row: Row, iso: string | null) => {
258
+ const key = view?.config.dateProperty
259
+ if (!key) return Promise.resolve()
260
+ return act(
261
+ () => api.databases.updateRow({ workspaceId, rowId: row.id, props: { [key]: iso } }),
262
+ refreshRows,
263
+ )
264
+ }
265
+
266
+ const submitProperty = (input: { name: string; type: PropertyType; config: PropertyConfig }) => {
267
+ const editing = propertyDialog?.property ?? null
268
+ return act(
269
+ () =>
270
+ editing
271
+ ? api.databases.updateProperty({ workspaceId, propertyId: editing.id, ...input })
272
+ : api.databases.addProperty({ workspaceId, databaseId, ...input }),
273
+ async () => {
274
+ propertyDialog = null
275
+ await refreshSchema()
276
+ await refreshRows()
277
+ },
278
+ )
279
+ }
280
+
281
+ const moveProperty = (property: Property, direction: -1 | 1) => {
282
+ const order = properties.filter((p) => !p.hidden)
283
+ const at = order.findIndex((p) => p.id === property.id)
284
+ const to = at + direction
285
+ if (at < 0 || to < 0 || to >= order.length) return Promise.resolve()
286
+ // Landing "before" the previous column means following the one before *that*, or nothing.
287
+ const afterId = direction === 1 ? (order[to]?.id ?? null) : (order[to - 1]?.id ?? null)
288
+ return act(
289
+ () => api.databases.moveProperty({ workspaceId, propertyId: property.id, afterId }),
290
+ refreshSchema,
291
+ )
292
+ }
293
+
294
+ const hideProperty = (property: Property) =>
295
+ act(
296
+ () => api.databases.updateProperty({ workspaceId, propertyId: property.id, hidden: !property.hidden }),
297
+ refreshSchema,
298
+ )
299
+
300
+ const sortBy = (property: Property, direction: 'asc' | 'desc' | null) => {
301
+ if (!view) return
302
+ const rest = view.config.sorts.filter((s) => s.propertyKey !== property.key)
303
+ patchView({ sorts: direction ? [{ propertyKey: property.key, direction }, ...rest] : rest })
304
+ }
305
+
306
+ const sortDirectionOf = (key: string) =>
307
+ view?.config.sorts.find((s) => s.propertyKey === key)?.direction ?? null
308
+
309
+ function filterBy(property: Property) {
310
+ if (!view) return
311
+ const already = view.config.filters.some((f) => f.propertyKey === property.key)
312
+ if (!already) {
313
+ const operator = descriptorFor(property.type).operators[0] ?? 'equals'
314
+ patchView({ filters: [...view.config.filters, { propertyKey: property.key, operator, value: null }] })
315
+ }
316
+ filterOpen = true
317
+ }
318
+
319
+ const submitView = (input: { name: string; kind: ViewKind; config: ViewConfig }) => {
320
+ const editing = viewDialog?.view ?? null
321
+ return act(
322
+ async () => {
323
+ if (editing) return api.databases.updateView({ workspaceId, viewId: editing.id, ...input })
324
+ const created = await api.databases.addView({ workspaceId, databaseId, ...input })
325
+ chosenViewId = created.id
326
+ return created
327
+ },
328
+ async () => {
329
+ viewDialog = null
330
+ await refreshSchema()
331
+ },
332
+ )
333
+ }
334
+
335
+ function confirmed() {
336
+ const target = confirming
337
+ if (!target) return
338
+ if (target.kind === 'property')
339
+ void act(
340
+ () => api.databases.removeProperty({ workspaceId, propertyId: target.property.id }),
341
+ async () => {
342
+ confirming = null
343
+ await refreshSchema()
344
+ await refreshRows()
345
+ },
346
+ )
347
+ else if (target.kind === 'view')
348
+ void act(
349
+ () => api.databases.removeView({ workspaceId, viewId: target.view.id }),
350
+ async () => {
351
+ confirming = null
352
+ if (chosenViewId === target.view.id) chosenViewId = null
353
+ await refreshSchema()
354
+ },
355
+ )
356
+ else
357
+ void act(
358
+ () => api.pages.trashPage({ workspaceId, pageId: target.row.id }),
359
+ async () => {
360
+ confirming = null
361
+ if (inspecting === target.row.id) inspecting = null
362
+ await refreshRows()
363
+ await client.invalidateQueries({ queryKey: quireKeys.tree(workspaceId, spaceId) })
364
+ },
365
+ )
366
+ }
367
+
368
+ const confirmTitle = $derived(
369
+ confirming?.kind === 'property'
370
+ ? t('db_delete_property_confirm', { name: confirming.property.name })
371
+ : confirming?.kind === 'view'
372
+ ? t('db_delete_view_confirm', { name: confirming.view.name })
373
+ : confirming?.kind === 'row'
374
+ ? t('db_delete_row_confirm', { title: confirming.row.title.trim() || t('untitled') })
375
+ : '',
376
+ )
377
+ const confirmBody = $derived(
378
+ confirming?.kind === 'property'
379
+ ? t('db_delete_property_desc')
380
+ : confirming?.kind === 'view'
381
+ ? t('db_delete_view_desc')
382
+ : confirming?.kind === 'row'
383
+ ? t('db_delete_row_desc')
384
+ : '',
385
+ )
386
+
387
+ // ---- toolbar menus -------------------------------------------------------------------------
388
+
389
+ const groupItems = $derived<MenuItem[]>([
390
+ {
391
+ type: 'radio',
392
+ value: view?.config.groupBy ?? '',
393
+ options: [
394
+ { value: '', label: t('db_group_none') },
395
+ ...properties
396
+ .filter((p) => descriptorFor(p.type).canGroup)
397
+ .map((p) => ({ value: p.key, label: p.name, icon: descriptorFor(p.type).icon })),
398
+ ],
399
+ onValueChange: (next: string) => patchView({ groupBy: next || null }),
400
+ },
401
+ ])
402
+
403
+ const columnItems = $derived<MenuItem[]>(
404
+ properties.length === 0
405
+ ? [{ type: 'label', label: t('db_no_columns') }]
406
+ : properties.map((property) => ({
407
+ type: 'checkbox' as const,
408
+ id: property.id,
409
+ label: property.name,
410
+ icon: descriptorFor(property.type).icon,
411
+ checked: !property.hidden,
412
+ disabled: !canEdit,
413
+ onCheckedChange: () => void hideProperty(property),
414
+ })),
415
+ )
416
+
417
+ const viewItems = $derived<MenuItem[]>([
418
+ {
419
+ id: 'edit',
420
+ label: t('db_edit_view'),
421
+ icon: 'sliders-vertical',
422
+ disabled: !canEdit || !view,
423
+ onSelect: () => {
424
+ if (view) viewDialog = { view }
425
+ },
426
+ },
427
+ {
428
+ id: 'add',
429
+ label: t('db_add_view'),
430
+ icon: 'plus',
431
+ disabled: !canEdit,
432
+ onSelect: () => (viewDialog = { view: null }),
433
+ },
434
+ { type: 'separator' },
435
+ {
436
+ id: 'delete',
437
+ label: t('db_delete_view'),
438
+ icon: 'trash-2',
439
+ danger: true,
440
+ disabled: !canEdit || !view || views.length <= 1,
441
+ hint: views.length <= 1 ? t('db_view_last') : undefined,
442
+ onSelect: () => {
443
+ if (view) confirming = { kind: 'view', view }
444
+ },
445
+ },
446
+ ])
447
+
448
+ const tabs = $derived(views.map((v) => ({ value: v.id, label: v.name, icon: viewIcon(v.kind) })))
449
+
450
+ const loading = $derived(forPage.isLoading || (Boolean(databaseId) && databaseQuery.isLoading))
451
+ const failed = $derived(forPage.isError || databaseQuery.isError || rowsQuery.isError)
452
+ </script>
453
+
454
+ {#if loading}
455
+ <div class="pad">
456
+ <Skeleton height="34px" />
457
+ <div class="gap"></div>
458
+ <Skeleton height="18px" lines={8} />
459
+ </div>
460
+ {:else if failed}
461
+ <div class="pad">
462
+ <EmptyState icon="triangle-alert" title={t('db_error')} description={t('db_error_desc')}>
463
+ {#snippet actions()}
464
+ <Button
465
+ variant="secondary"
466
+ onclick={() => {
467
+ void forPage.refetch()
468
+ void databaseQuery.refetch()
469
+ void rowsQuery.refetch()
470
+ }}
471
+ >
472
+ {t('common.retry')}
473
+ </Button>
474
+ {/snippet}
475
+ </EmptyState>
476
+ </div>
477
+ {:else if !database}
478
+ <div class="pad">
479
+ <EmptyState icon="database" title={t('db_missing')} description={t('db_missing_desc')} />
480
+ </div>
481
+ {:else}
482
+ <div class="head">
483
+ {#if tabs.length > 0}
484
+ <Tabs
485
+ items={tabs}
486
+ value={view?.id ?? tabs[0]?.value ?? ''}
487
+ variant="underline"
488
+ label={t('db_views')}
489
+ onValueChange={(next) => (chosenViewId = next)}
490
+ />
491
+ {/if}
492
+ <span class="sp"></span>
493
+ <DropdownMenu items={viewItems}>
494
+ {#snippet trigger(props: Record<string, unknown>)}
495
+ <IconButton {...props} icon="ellipsis" label={t('db_view_actions')} size={28} variant="ghost" />
496
+ {/snippet}
497
+ </DropdownMenu>
498
+ </div>
499
+
500
+ <Toolbar>
501
+ <FilterMenu
502
+ {database}
503
+ config={view?.config ?? { filters: [], filterMode: 'and', sorts: [], groupBy: null, dateProperty: null, visibleProperties: null, columnWidths: {}, cardSize: 'medium', coverProperty: null }}
504
+ {workspaceId}
505
+ {people}
506
+ {canEdit}
507
+ open={filterOpen}
508
+ onOpenChange={(o) => (filterOpen = o)}
509
+ onchange={patchView}
510
+ />
511
+ <SortMenu
512
+ {database}
513
+ config={view?.config ?? { filters: [], filterMode: 'and', sorts: [], groupBy: null, dateProperty: null, visibleProperties: null, columnWidths: {}, cardSize: 'medium', coverProperty: null }}
514
+ {canEdit}
515
+ open={sortOpen}
516
+ onOpenChange={(o) => (sortOpen = o)}
517
+ onchange={patchView}
518
+ />
519
+ {#if view?.kind === 'board'}
520
+ <DropdownMenu items={groupItems} align="start">
521
+ {#snippet trigger(props: Record<string, unknown>)}
522
+ <ToolbarButton {...props} prefix={t('db_by')} active={Boolean(view?.config.groupBy)}>
523
+ {groupProperty?.name ?? t('db_group_none')}
524
+ </ToolbarButton>
525
+ {/snippet}
526
+ </DropdownMenu>
527
+ {/if}
528
+ <DropdownMenu items={columnItems} align="start">
529
+ {#snippet trigger(props: Record<string, unknown>)}
530
+ <ToolbarButton {...props} icon="columns-3">{t('db_properties')}</ToolbarButton>
531
+ {/snippet}
532
+ </DropdownMenu>
533
+
534
+ {#snippet end()}
535
+ <span class="count">{t('db_rows', { n: rows.length })}</span>
536
+ {#if canCreate}
537
+ <Button size="sm" disabled={busy} onclick={() => void addRow()}>{t('db_new_row')}</Button>
538
+ {/if}
539
+ {/snippet}
540
+ </Toolbar>
541
+
542
+ <div class="body">
543
+ {#if rowsQuery.isLoading}
544
+ <div class="pad"><Skeleton height="40px" lines={6} /></div>
545
+ {:else if rows.length === 0}
546
+ <div class="pad">
547
+ <EmptyState icon="database" title={t('db_empty')} description={t('db_empty_desc')}>
548
+ {#snippet actions()}
549
+ {#if canCreate}
550
+ <Button disabled={busy} onclick={() => void addRow()}>{t('db_new_row')}</Button>
551
+ {/if}
552
+ {/snippet}
553
+ </EmptyState>
554
+ </div>
555
+ {:else if !view || view.kind === 'table' || view.kind === 'timeline'}
556
+ {#if view?.kind === 'timeline'}
557
+ <p class="notice" role="status">
558
+ <Icon name="circle-alert" size={14} strokeWidth={1.7} />
559
+ {t('db_timeline_unbuilt')}
560
+ </p>
561
+ {/if}
562
+ <TableView
563
+ {database}
564
+ {view}
565
+ {rows}
566
+ {people}
567
+ {workspaceId}
568
+ {canEdit}
569
+ {canCreate}
570
+ {sortDirectionOf}
571
+ onCellChange={(row, property, value) => void writeCell(row, property, value)}
572
+ onTitleChange={(row, title) => void writeTitle(row, title)}
573
+ onOpenRow={(row) => (inspecting = row.id)}
574
+ onOpenPage={(row) => void openPage(row)}
575
+ onDuplicateRow={(row) => void duplicateRow(row)}
576
+ onDeleteRow={(row) => (confirming = { kind: 'row', row })}
577
+ onAddRow={() => void addRow()}
578
+ onAddProperty={() => (propertyDialog = { property: null })}
579
+ onEditProperty={(property) => (propertyDialog = { property })}
580
+ onMoveProperty={(property, direction) => void moveProperty(property, direction)}
581
+ onHideProperty={(property) => void hideProperty(property)}
582
+ onDeleteProperty={(property) => (confirming = { kind: 'property', property })}
583
+ onSortBy={sortBy}
584
+ onFilterBy={filterBy}
585
+ onConfigChange={patchView}
586
+ />
587
+ {#if rowsQuery.hasNextPage}
588
+ <div class="more">
589
+ <Button
590
+ variant="secondary"
591
+ disabled={rowsQuery.isFetchingNextPage}
592
+ onclick={() => void rowsQuery.fetchNextPage()}
593
+ >
594
+ {rowsQuery.isFetchingNextPage ? t('common.loading') : t('db_load_more')}
595
+ </Button>
596
+ </div>
597
+ {/if}
598
+ {:else if view.kind === 'board'}
599
+ <BoardView
600
+ {database}
601
+ {view}
602
+ {rows}
603
+ {people}
604
+ {workspaceId}
605
+ {canEdit}
606
+ {canCreate}
607
+ onMove={(row, laneId) => void moveOnBoard(row, laneId)}
608
+ onOpenRow={(row) => (inspecting = row.id)}
609
+ onAddRow={(laneId) =>
610
+ void addRow(
611
+ groupProperty && laneId !== EMPTY_GROUP
612
+ ? { [groupProperty.key]: groupProperty.type === 'checkbox' ? laneId === 'true' : laneId }
613
+ : {},
614
+ )}
615
+ onConfigure={() => {
616
+ if (view) viewDialog = { view }
617
+ }}
618
+ />
619
+ {:else if view.kind === 'gallery'}
620
+ <GalleryView
621
+ {database}
622
+ {view}
623
+ {rows}
624
+ {people}
625
+ {workspaceId}
626
+ {canEdit}
627
+ onOpenRow={(row) => (inspecting = row.id)}
628
+ onOpenPage={(row) => void openPage(row)}
629
+ onDeleteRow={(row) => (confirming = { kind: 'row', row })}
630
+ />
631
+ {:else if view.kind === 'list'}
632
+ <ListView
633
+ {database}
634
+ {view}
635
+ {rows}
636
+ {people}
637
+ {workspaceId}
638
+ {canEdit}
639
+ onOpenRow={(row) => (inspecting = row.id)}
640
+ onOpenPage={(row) => void openPage(row)}
641
+ onDeleteRow={(row) => (confirming = { kind: 'row', row })}
642
+ />
643
+ {:else}
644
+ <CalendarView
645
+ {database}
646
+ {view}
647
+ {rows}
648
+ {canEdit}
649
+ onOpenRow={(row) => (inspecting = row.id)}
650
+ onSetDate={(row, iso) => void setDate(row, iso)}
651
+ onConfigure={() => {
652
+ if (view) viewDialog = { view }
653
+ }}
654
+ />
655
+ {/if}
656
+
657
+ {#if capped}
658
+ <p class="notice" role="status">
659
+ <Icon name="circle-alert" size={14} strokeWidth={1.7} />
660
+ {t('db_capped', { n: CAP })}
661
+ </p>
662
+ {/if}
663
+ </div>
664
+ {/if}
665
+
666
+ {#if database && inspected}
667
+ <RowPanel
668
+ {database}
669
+ row={inspected}
670
+ {people}
671
+ {workspaceId}
672
+ {canEdit}
673
+ onClose={() => (inspecting = null)}
674
+ onChange={(property, value) => void writeCell(inspected, property, value)}
675
+ onTitleChange={(title) => void writeTitle(inspected, title)}
676
+ onOpenPage={() => void openPage(inspected)}
677
+ />
678
+ {/if}
679
+
680
+ {#if database}
681
+ <PropertyDialog
682
+ open={propertyDialog !== null}
683
+ {workspaceId}
684
+ {spaceId}
685
+ {databaseId}
686
+ property={propertyDialog?.property ?? null}
687
+ {relations}
688
+ {busy}
689
+ onClose={() => (propertyDialog = null)}
690
+ onSubmit={(input) => void submitProperty(input)}
691
+ />
692
+ <ViewDialog
693
+ open={viewDialog !== null}
694
+ {database}
695
+ view={viewDialog?.view ?? null}
696
+ {busy}
697
+ onClose={() => (viewDialog = null)}
698
+ onSubmit={(input) => void submitView(input)}
699
+ />
700
+ {/if}
701
+
702
+ <Dialog
703
+ open={confirming !== null}
704
+ title={confirmTitle}
705
+ size="sm"
706
+ onOpenChange={(o) => !o && (confirming = null)}
707
+ >
708
+ <p class="confirm">{confirmBody}</p>
709
+ {#snippet footer()}
710
+ <Button variant="secondary" onclick={() => (confirming = null)}>{t('common.cancel')}</Button>
711
+ <Button variant="danger" disabled={busy} onclick={confirmed}>{t('common.delete')}</Button>
712
+ {/snippet}
713
+ </Dialog>
714
+
715
+ <style>
716
+ .head {
717
+ display: flex;
718
+ align-items: stretch;
719
+ gap: 10px;
720
+ height: 44px;
721
+ padding: 0 28px;
722
+ border-block-end: 1px solid var(--kern-border);
723
+ background: var(--kern-surface);
724
+ flex: none;
725
+ }
726
+ .head .sp {
727
+ flex: 1;
728
+ }
729
+ .head :global(.ktabs) {
730
+ display: flex;
731
+ align-items: stretch;
732
+ }
733
+ /*
734
+ * No scroller of its own: this renders inside `Page`, which already scrolls. Two nested scrollers
735
+ * means a wheel that stops at an invisible boundary halfway down the screen.
736
+ */
737
+ .body {
738
+ min-height: 0;
739
+ }
740
+ .pad {
741
+ padding: 24px 28px 40px;
742
+ }
743
+ .gap {
744
+ height: 14px;
745
+ }
746
+ .count {
747
+ font-family: var(--kern-font-mono);
748
+ font-size: 11.5px;
749
+ color: var(--kern-ink-450);
750
+ white-space: nowrap;
751
+ }
752
+ .more {
753
+ display: flex;
754
+ justify-content: center;
755
+ padding: 4px 0 32px;
756
+ }
757
+ .notice {
758
+ display: flex;
759
+ align-items: center;
760
+ gap: 8px;
761
+ margin: 0 28px 28px;
762
+ padding: 9px 12px;
763
+ border-radius: var(--kern-r-lg);
764
+ background: var(--kern-warning-tint);
765
+ color: var(--kern-warning);
766
+ font-size: 12.5px;
767
+ }
768
+ .confirm {
769
+ margin: 0;
770
+ font-size: 13.5px;
771
+ line-height: 1.55;
772
+ color: var(--kern-ink-600);
773
+ }
774
+ @media (max-width: 768px) {
775
+ .head {
776
+ padding: 0 16px;
777
+ }
778
+ .pad {
779
+ padding: 16px;
780
+ }
781
+ .notice {
782
+ margin-inline: 16px;
783
+ }
784
+ }
785
+ </style>