@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.
- package/README.md +93 -55
- package/dist/{server → client}/formula.d.ts +6 -0
- package/dist/client/formula.d.ts.map +1 -0
- package/dist/{server → client}/formula.js +6 -0
- package/dist/client/formula.js.map +1 -0
- package/dist/contract/properties.d.ts +26 -0
- package/dist/contract/properties.d.ts.map +1 -1
- package/dist/contract/properties.js +24 -0
- package/dist/contract/properties.js.map +1 -1
- package/dist/contract/router.d.ts +334 -0
- package/dist/contract/router.d.ts.map +1 -1
- package/dist/contract/router.js +43 -1
- package/dist/contract/router.js.map +1 -1
- package/dist/server/_impl.d.ts +341 -0
- package/dist/server/_impl.d.ts.map +1 -1
- package/dist/server/_impl.js +79 -10
- package/dist/server/_impl.js.map +1 -1
- package/dist/server/services/databases.d.ts +73 -1
- package/dist/server/services/databases.d.ts.map +1 -1
- package/dist/server/services/databases.js +167 -10
- package/dist/server/services/databases.js.map +1 -1
- package/dist/server/services/pages.d.ts.map +1 -1
- package/dist/server/services/pages.js +11 -1
- package/dist/server/services/pages.js.map +1 -1
- package/dist/server/services/query.d.ts.map +1 -1
- package/dist/server/services/query.js +93 -37
- package/dist/server/services/query.js.map +1 -1
- package/migrations/0006_rank_collation.sql +14 -0
- package/migrations/meta/_journal.json +7 -0
- package/package.json +3 -2
- package/src/client/components/PageTreeRow.svelte +1 -1
- package/src/client/core-api.ts +38 -0
- package/src/client/database/BoardView.svelte +354 -0
- package/src/client/database/CalendarView.svelte +346 -0
- package/src/client/database/DatabaseView.svelte +785 -0
- package/src/client/database/FilterMenu.svelte +281 -0
- package/src/client/database/FilterValue.svelte +176 -0
- package/src/client/database/GalleryView.svelte +177 -0
- package/src/client/database/ListView.svelte +108 -0
- package/src/client/database/OptionChip.svelte +38 -0
- package/src/client/database/PropertyDialog.svelte +460 -0
- package/src/client/database/PropertyMenu.svelte +162 -0
- package/src/client/database/RowPanel.svelte +157 -0
- package/src/client/database/SortMenu.svelte +199 -0
- package/src/client/database/TableView.svelte +388 -0
- package/src/client/database/ViewDialog.svelte +229 -0
- package/src/client/database/cells/Cell.svelte +103 -0
- package/src/client/database/cells/CheckboxCell.svelte +33 -0
- package/src/client/database/cells/ComputedCell.svelte +100 -0
- package/src/client/database/cells/DateCell.svelte +97 -0
- package/src/client/database/cells/LinkCell.svelte +143 -0
- package/src/client/database/cells/NumberCell.svelte +131 -0
- package/src/client/database/cells/PersonCell.svelte +116 -0
- package/src/client/database/cells/RelationCell.svelte +222 -0
- package/src/client/database/cells/SelectCell.svelte +133 -0
- package/src/client/database/cells/TextCell.svelte +85 -0
- package/src/client/database/colours.ts +27 -0
- package/src/client/database/property-types.test.ts +64 -0
- package/src/client/database/property-types.ts +294 -0
- package/src/client/database/view-config.test.ts +148 -0
- package/src/client/database/view-config.ts +102 -0
- package/src/client/formula.test.ts +139 -0
- package/src/client/formula.ts +430 -0
- package/src/client/i18n.ts +1176 -0
- package/src/client/index.ts +31 -0
- package/src/client/mock.ts +511 -1
- package/src/client/pages/PageView.svelte +47 -17
- package/src/client/pages/SpacePage.svelte +8 -2
- package/src/client/query.ts +17 -4
- package/src/contract/properties.ts +28 -0
- package/src/contract/router.ts +46 -0
- package/dist/server/formula.d.ts.map +0 -1
- 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>
|