@kernhq/module-quire 0.8.0 → 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 (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,162 @@
1
+ <script lang="ts">
2
+ import { DropdownMenu, Icon, type MenuItem } from '@kernhq/ui'
3
+ import type { Property } from '../../contract/index.js'
4
+ import { t } from '../i18n.js'
5
+ import { descriptorFor } from './property-types.js'
6
+
7
+ /**
8
+ * A column header, and everything that can be done to it.
9
+ *
10
+ * This menu is also where the **keyboard route** for the two pointer gestures lives: dragging the
11
+ * grabber resizes and there is no drag on the header row at all, so "move earlier", "move later",
12
+ * "widen" and "narrow" are not conveniences — they are the only way to do those things without a
13
+ * mouse, which makes them part of the specification rather than an extra.
14
+ */
15
+ interface Props {
16
+ property: Property
17
+ canEdit: boolean
18
+ first: boolean
19
+ last: boolean
20
+ sortDirection: 'asc' | 'desc' | null
21
+ onEdit: () => void
22
+ onHide: () => void
23
+ onMove: (direction: -1 | 1) => void
24
+ onResize: (delta: number) => void
25
+ onSort: (direction: 'asc' | 'desc' | null) => void
26
+ onFilter: () => void
27
+ onDelete: () => void
28
+ }
29
+ const {
30
+ property,
31
+ canEdit,
32
+ first,
33
+ last,
34
+ sortDirection,
35
+ onEdit,
36
+ onHide,
37
+ onMove,
38
+ onResize,
39
+ onSort,
40
+ onFilter,
41
+ onDelete,
42
+ }: Props = $props()
43
+
44
+ const descriptor = $derived(descriptorFor(property.type))
45
+
46
+ const items = $derived<MenuItem[]>([
47
+ { type: 'label', label: property.name },
48
+ {
49
+ id: 'sort-asc',
50
+ label: t('db_sort_asc'),
51
+ icon: 'arrow-up',
52
+ disabled: sortDirection === 'asc',
53
+ onSelect: () => onSort('asc'),
54
+ },
55
+ {
56
+ id: 'sort-desc',
57
+ label: t('db_sort_desc'),
58
+ icon: 'chevron-down',
59
+ disabled: sortDirection === 'desc',
60
+ onSelect: () => onSort('desc'),
61
+ },
62
+ ...(sortDirection
63
+ ? [{ id: 'sort-clear', label: t('db_sort_remove'), icon: 'x', onSelect: () => onSort(null) }]
64
+ : []),
65
+ { type: 'separator' },
66
+ { id: 'filter', label: t('db_filter_by'), icon: 'filter', onSelect: onFilter },
67
+ { type: 'separator' },
68
+ {
69
+ id: 'move-earlier',
70
+ label: t('db_move_earlier'),
71
+ icon: 'chevron-left',
72
+ disabled: first || !canEdit,
73
+ onSelect: () => onMove(-1),
74
+ },
75
+ {
76
+ id: 'move-later',
77
+ label: t('db_move_later'),
78
+ icon: 'chevron-right',
79
+ disabled: last || !canEdit,
80
+ onSelect: () => onMove(1),
81
+ },
82
+ { id: 'widen', label: t('db_widen'), icon: 'maximize-2', onSelect: () => onResize(40) },
83
+ { id: 'narrow', label: t('db_narrow'), icon: 'minimize-2', onSelect: () => onResize(-40) },
84
+ { type: 'separator' },
85
+ {
86
+ id: 'edit',
87
+ label: t('db_edit_property'),
88
+ icon: 'pencil',
89
+ disabled: !canEdit,
90
+ onSelect: onEdit,
91
+ },
92
+ {
93
+ id: 'hide',
94
+ label: t('db_hide_property'),
95
+ icon: 'eye-off',
96
+ disabled: !canEdit,
97
+ onSelect: onHide,
98
+ },
99
+ {
100
+ id: 'delete',
101
+ label: t('db_delete_property'),
102
+ icon: 'trash-2',
103
+ danger: true,
104
+ disabled: !canEdit,
105
+ onSelect: onDelete,
106
+ },
107
+ ])
108
+ </script>
109
+
110
+ <DropdownMenu {items} align="start">
111
+ {#snippet trigger(props: Record<string, unknown>)}
112
+ <button {...props} type="button" class="head" title={property.name}>
113
+ <Icon name={descriptor.icon} size={12} strokeWidth={1.7} />
114
+ <span class="nm">{property.name}</span>
115
+ {#if sortDirection}
116
+ <Icon name={sortDirection === 'asc' ? 'arrow-up' : 'chevron-down'} size={11} strokeWidth={2} />
117
+ {/if}
118
+ </button>
119
+ {/snippet}
120
+ </DropdownMenu>
121
+
122
+ <style>
123
+ /*
124
+ * The header cell is the trigger, so the whole 34px strip is the hit target rather than a 12px
125
+ * chevron beside a label.
126
+ */
127
+ .head {
128
+ display: inline-flex;
129
+ align-items: center;
130
+ gap: 6px;
131
+ min-width: 0;
132
+ width: 100%;
133
+ height: 26px;
134
+ padding: 0 6px;
135
+ margin-inline-start: -6px;
136
+ border: 0;
137
+ border-radius: var(--kern-r-sm);
138
+ background: none;
139
+ color: var(--kern-ink-280);
140
+ font: inherit;
141
+ font-size: 11px;
142
+ font-weight: 600;
143
+ letter-spacing: 0.06em;
144
+ text-transform: uppercase;
145
+ text-align: start;
146
+ }
147
+ /* Uppercase tracking breaks connected Arabic and Persian letters. */
148
+ :global([dir='rtl']) .head {
149
+ letter-spacing: 0;
150
+ text-transform: none;
151
+ }
152
+ .head:hover {
153
+ background: var(--kern-surface-hover);
154
+ color: var(--kern-ink-900);
155
+ }
156
+ .nm {
157
+ min-width: 0;
158
+ overflow: hidden;
159
+ text-overflow: ellipsis;
160
+ white-space: nowrap;
161
+ }
162
+ </style>
@@ -0,0 +1,157 @@
1
+ <script lang="ts">
2
+ import { Button, RightPanel, relativeTime } from '@kernhq/ui'
3
+ import type { Database, Property, Row } from '../../contract/index.js'
4
+ import type { Person } from '../core-api.js'
5
+ import { t } from '../i18n.js'
6
+ import Cell from './cells/Cell.svelte'
7
+ import { descriptorFor } from './property-types.js'
8
+ import { orderedProperties } from './view-config.js'
9
+
10
+ /**
11
+ * One row, with every column — hidden ones included.
12
+ *
13
+ * This is where a row is inspected, and it has to be: `Page` carries no field saying "this page is
14
+ * a row of that database", deliberately, so opening the row's own page shows its prose and could
15
+ * not show its properties without inventing a second meaning for `pages.database_id`. The panel
16
+ * links out to the page instead.
17
+ */
18
+ interface Props {
19
+ database: Database
20
+ row: Row
21
+ people: Person[]
22
+ workspaceId: string
23
+ canEdit: boolean
24
+ onClose: () => void
25
+ onChange: (property: Property, value: unknown) => void
26
+ onTitleChange: (title: string) => void
27
+ onOpenPage: () => void
28
+ }
29
+ const { database, row, people, workspaceId, canEdit, onClose, onChange, onTitleChange, onOpenPage }: Props =
30
+ $props()
31
+
32
+ const properties = $derived(orderedProperties(database))
33
+ </script>
34
+
35
+ <RightPanel title={t('db_row_panel')} width={420} onClose={onClose}>
36
+ <div class="body">
37
+ {#if canEdit}
38
+ <input
39
+ class="title"
40
+ value={row.title}
41
+ aria-label={t('db_title_column')}
42
+ placeholder={t('untitled')}
43
+ onblur={(e) => onTitleChange(e.currentTarget.value)}
44
+ onkeydown={(e) => {
45
+ if (e.key === 'Enter') e.currentTarget.blur()
46
+ }}
47
+ />
48
+ {:else}
49
+ <h2 class="title as-heading">{row.title.trim() || t('untitled')}</h2>
50
+ {/if}
51
+
52
+ <p class="stamp">{t('edited_ago', { when: relativeTime(row.updatedAt) })}</p>
53
+
54
+ {#if properties.length === 0}
55
+ <p class="hint">{t('db_no_columns')}</p>
56
+ {:else}
57
+ <dl class="fields">
58
+ {#each properties as property (property.id)}
59
+ <div class="field">
60
+ <dt title={property.hidden ? t('db_hidden_here') : undefined}>
61
+ {property.name}
62
+ {#if property.hidden}<span class="tag">{t('db_hidden')}</span>{/if}
63
+ </dt>
64
+ <dd>
65
+ <Cell
66
+ {property}
67
+ {row}
68
+ {people}
69
+ {workspaceId}
70
+ canEdit={canEdit && !descriptorFor(property.type).readOnly}
71
+ onchange={(value) => onChange(property, value)}
72
+ />
73
+ </dd>
74
+ </div>
75
+ {/each}
76
+ </dl>
77
+ {/if}
78
+ </div>
79
+
80
+ {#snippet footer()}
81
+ <Button variant="secondary" onclick={onOpenPage}>{t('db_open_as_page')}</Button>
82
+ {/snippet}
83
+ </RightPanel>
84
+
85
+ <style>
86
+ .body {
87
+ padding: 18px;
88
+ }
89
+ .title {
90
+ width: 100%;
91
+ padding: 2px 6px;
92
+ margin-inline-start: -6px;
93
+ border: 1px solid transparent;
94
+ border-radius: var(--kern-r-sm);
95
+ background: none;
96
+ color: var(--kern-ink-900);
97
+ font: inherit;
98
+ font-size: 18px;
99
+ font-weight: 600;
100
+ letter-spacing: -0.02em;
101
+ }
102
+ .title:hover {
103
+ background: var(--kern-surface-active);
104
+ }
105
+ .title:focus {
106
+ border-color: var(--kern-border);
107
+ background: var(--kern-surface-raised);
108
+ outline: none;
109
+ }
110
+ .title.as-heading {
111
+ margin: 0;
112
+ }
113
+ .stamp {
114
+ margin: 6px 0 16px;
115
+ font-size: 12px;
116
+ color: var(--kern-ink-450);
117
+ }
118
+ .fields {
119
+ margin: 0;
120
+ padding: 0;
121
+ display: flex;
122
+ flex-direction: column;
123
+ gap: 10px;
124
+ }
125
+ .field {
126
+ display: grid;
127
+ grid-template-columns: 130px minmax(0, 1fr);
128
+ align-items: center;
129
+ gap: 10px;
130
+ }
131
+ .field dt {
132
+ display: flex;
133
+ align-items: center;
134
+ gap: 5px;
135
+ font-size: 12.5px;
136
+ color: var(--kern-ink-450);
137
+ overflow: hidden;
138
+ }
139
+ .field dd {
140
+ margin: 0;
141
+ min-width: 0;
142
+ font-size: 13px;
143
+ color: var(--kern-ink-700);
144
+ }
145
+ .tag {
146
+ padding: 0 5px;
147
+ border-radius: var(--kern-r-sm);
148
+ background: var(--kern-surface-chip);
149
+ font-size: 10.5px;
150
+ color: var(--kern-ink-550);
151
+ }
152
+ .hint {
153
+ margin: 0;
154
+ font-size: 12.5px;
155
+ color: var(--kern-ink-450);
156
+ }
157
+ </style>
@@ -0,0 +1,199 @@
1
+ <script lang="ts">
2
+ import { Button, Icon, Popover, ToolbarButton } from '@kernhq/ui'
3
+ import type { Database, Sort, ViewConfig } from '../../contract/index.js'
4
+ import { t } from '../i18n.js'
5
+ import { orderedProperties } from './view-config.js'
6
+
7
+ /**
8
+ * The view's sorting, in order.
9
+ *
10
+ * Order matters and is the whole reason this is a list rather than a single choice, so moving a
11
+ * rule up and down is a control rather than something you get by deleting and re-adding.
12
+ */
13
+ interface Props {
14
+ database: Database
15
+ config: ViewConfig
16
+ canEdit: boolean
17
+ open: boolean
18
+ onOpenChange: (open: boolean) => void
19
+ onchange: (patch: Partial<ViewConfig>) => void
20
+ }
21
+ const { database, config, canEdit, open, onOpenChange, onchange }: Props = $props()
22
+
23
+ const properties = $derived(orderedProperties(database))
24
+ const sorts = $derived(config.sorts)
25
+
26
+ const setSorts = (next: Sort[]) => onchange({ sorts: next })
27
+ const patchAt = (index: number, patch: Partial<Sort>) =>
28
+ setSorts(sorts.map((s, i) => (i === index ? { ...s, ...patch } : s)))
29
+
30
+ function addSort() {
31
+ const used = new Set(sorts.map((s) => s.propertyKey))
32
+ const next = properties.find((p) => !used.has(p.key)) ?? properties[0]
33
+ if (!next) return
34
+ setSorts([...sorts, { propertyKey: next.key, direction: 'asc' }])
35
+ }
36
+
37
+ function move(index: number, delta: -1 | 1) {
38
+ const to = index + delta
39
+ if (to < 0 || to >= sorts.length) return
40
+ const next = [...sorts]
41
+ const [moved] = next.splice(index, 1)
42
+ if (moved) next.splice(to, 0, moved)
43
+ setSorts(next)
44
+ }
45
+ </script>
46
+
47
+ <Popover {open} {onOpenChange} align="start" width="380px">
48
+ {#snippet trigger(props: Record<string, unknown>)}
49
+ <ToolbarButton
50
+ {...props}
51
+ icon="chevrons-up-down"
52
+ active={sorts.length > 0}
53
+ onClear={sorts.length > 0 && canEdit ? () => setSorts([]) : undefined}
54
+ >
55
+ {sorts.length > 0 ? t('db_sorts', { n: sorts.length }) : t('db_sort')}
56
+ </ToolbarButton>
57
+ {/snippet}
58
+
59
+ <div class="panel">
60
+ {#if sorts.length === 0}
61
+ <p class="empty">{t('db_sort_none_desc')}</p>
62
+ {:else}
63
+ <ul class="rows">
64
+ {#each sorts as sort, index (index)}
65
+ <li class="line">
66
+ <select
67
+ class="control grow"
68
+ value={sort.propertyKey}
69
+ disabled={!canEdit}
70
+ aria-label={t('db_sort_property')}
71
+ onchange={(e) => patchAt(index, { propertyKey: e.currentTarget.value })}
72
+ >
73
+ {#each properties as option (option.key)}
74
+ <option value={option.key}>{option.name}</option>
75
+ {/each}
76
+ </select>
77
+ <select
78
+ class="control"
79
+ value={sort.direction}
80
+ disabled={!canEdit}
81
+ aria-label={t('db_sort_direction')}
82
+ onchange={(e) => patchAt(index, { direction: e.currentTarget.value as 'asc' | 'desc' })}
83
+ >
84
+ <option value="asc">{t('db_asc')}</option>
85
+ <option value="desc">{t('db_desc')}</option>
86
+ </select>
87
+ <button
88
+ type="button"
89
+ class="icon"
90
+ disabled={!canEdit || index === 0}
91
+ aria-label={t('db_move_up')}
92
+ onclick={() => move(index, -1)}
93
+ >
94
+ <Icon name="chevron-up" size={13} strokeWidth={1.9} />
95
+ </button>
96
+ <button
97
+ type="button"
98
+ class="icon"
99
+ disabled={!canEdit || index === sorts.length - 1}
100
+ aria-label={t('db_move_down')}
101
+ onclick={() => move(index, 1)}
102
+ >
103
+ <Icon name="chevron-down" size={13} strokeWidth={1.9} />
104
+ </button>
105
+ <button
106
+ type="button"
107
+ class="icon"
108
+ disabled={!canEdit}
109
+ aria-label={t('db_sort_remove')}
110
+ onclick={() => setSorts(sorts.filter((_, i) => i !== index))}
111
+ >
112
+ <Icon name="x" size={13} strokeWidth={1.9} />
113
+ </button>
114
+ </li>
115
+ {/each}
116
+ </ul>
117
+ {/if}
118
+
119
+ <div class="foot">
120
+ <Button size="sm" variant="secondary" disabled={!canEdit || properties.length === 0} onclick={addSort}>
121
+ {t('db_sort_add')}
122
+ </Button>
123
+ {#if sorts.length > 0}
124
+ <Button size="sm" variant="secondary" disabled={!canEdit} onclick={() => setSorts([])}>
125
+ {t('db_sort_clear')}
126
+ </Button>
127
+ {/if}
128
+ </div>
129
+ </div>
130
+ </Popover>
131
+
132
+ <style>
133
+ .panel {
134
+ padding: 12px;
135
+ display: flex;
136
+ flex-direction: column;
137
+ gap: 10px;
138
+ }
139
+ .empty {
140
+ margin: 0;
141
+ font-size: 12.5px;
142
+ color: var(--kern-ink-450);
143
+ }
144
+ .rows {
145
+ list-style: none;
146
+ margin: 0;
147
+ padding: 0;
148
+ display: flex;
149
+ flex-direction: column;
150
+ gap: 6px;
151
+ }
152
+ .line {
153
+ display: flex;
154
+ align-items: center;
155
+ gap: 4px;
156
+ }
157
+ .control {
158
+ min-width: 0;
159
+ height: 30px;
160
+ padding: 0 8px;
161
+ border: 1px solid var(--kern-border);
162
+ border-radius: var(--kern-r-md);
163
+ background: var(--kern-surface-raised);
164
+ color: var(--kern-ink-700);
165
+ font: inherit;
166
+ font-size: 12.5px;
167
+ }
168
+ .control:focus {
169
+ outline: none;
170
+ border-color: var(--kern-accent);
171
+ box-shadow: 0 0 0 3px var(--kern-ring);
172
+ }
173
+ .grow {
174
+ flex: 1;
175
+ }
176
+ .icon {
177
+ flex: none;
178
+ display: inline-grid;
179
+ place-items: center;
180
+ width: 28px;
181
+ height: 28px;
182
+ border: 0;
183
+ border-radius: var(--kern-r-sm);
184
+ background: none;
185
+ color: var(--kern-ink-450);
186
+ }
187
+ .icon:hover:not(:disabled) {
188
+ background: var(--kern-surface-hover);
189
+ color: var(--kern-ink-900);
190
+ }
191
+ .icon:disabled {
192
+ color: var(--kern-ink-300);
193
+ cursor: default;
194
+ }
195
+ .foot {
196
+ display: flex;
197
+ gap: 8px;
198
+ }
199
+ </style>