@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,281 @@
1
+ <script lang="ts">
2
+ import { Button, Icon, Popover, ToolbarButton } from '@kernhq/ui'
3
+ import type { Database, Filter, FilterOperator, ViewConfig } from '../../contract/index.js'
4
+ import type { Person } from '../core-api.js'
5
+ import { t } from '../i18n.js'
6
+ import FilterValue from './FilterValue.svelte'
7
+ import { operatorsFor, VALUELESS_OPERATORS } from './property-types.js'
8
+ import { orderedProperties } from './view-config.js'
9
+
10
+ /**
11
+ * The view's filters.
12
+ *
13
+ * A column only offers the operators it can actually answer — the contract declares fourteen and no
14
+ * type accepts all of them, so listing every one produces a filter that silently matches nothing.
15
+ * `property-types.ts` is the single place that decides which.
16
+ *
17
+ * Every write sends the **whole** `ViewConfig`, because `updateView` replaces the column.
18
+ */
19
+ interface Props {
20
+ database: Database
21
+ config: ViewConfig
22
+ workspaceId: string
23
+ people: Person[]
24
+ canEdit: boolean
25
+ /** the toolbar can ask for the popover to open on a particular column */
26
+ open: boolean
27
+ onOpenChange: (open: boolean) => void
28
+ onchange: (patch: Partial<ViewConfig>) => void
29
+ }
30
+ const { database, config, workspaceId, people, canEdit, open, onOpenChange, onchange }: Props = $props()
31
+
32
+ const properties = $derived(orderedProperties(database))
33
+ const filters = $derived(config.filters)
34
+
35
+ const propertyFor = (key: string) => properties.find((p) => p.key === key) ?? null
36
+
37
+ const setFilters = (next: Filter[]) => onchange({ filters: next })
38
+
39
+ const patchAt = (index: number, patch: Partial<Filter>) =>
40
+ setFilters(filters.map((f, i) => (i === index ? { ...f, ...patch } : f)))
41
+
42
+ function addFilter() {
43
+ const first = properties[0]
44
+ if (!first) return
45
+ const operator = operatorsFor(first.type)[0] ?? 'equals'
46
+ setFilters([...filters, { propertyKey: first.key, operator, value: null }])
47
+ }
48
+
49
+ /**
50
+ * Changing the column has to re-pick the operator: a checkbox cannot answer `starts_with`, and
51
+ * leaving the old one behind is a filter that returns nothing for no visible reason.
52
+ */
53
+ function changeProperty(index: number, key: string) {
54
+ const property = propertyFor(key)
55
+ if (!property) return
56
+ const current = filters[index]?.operator
57
+ const allowed = operatorsFor(property.type)
58
+ patchAt(index, {
59
+ propertyKey: key,
60
+ operator: current && allowed.includes(current) ? current : (allowed[0] ?? 'equals'),
61
+ value: null,
62
+ })
63
+ }
64
+ </script>
65
+
66
+ <Popover {open} {onOpenChange} align="start" width="420px">
67
+ {#snippet trigger(props: Record<string, unknown>)}
68
+ <ToolbarButton
69
+ {...props}
70
+ icon="filter"
71
+ active={filters.length > 0}
72
+ onClear={filters.length > 0 && canEdit ? () => setFilters([]) : undefined}
73
+ >
74
+ {filters.length > 0 ? t('db_filters', { n: filters.length }) : t('db_filter')}
75
+ </ToolbarButton>
76
+ {/snippet}
77
+
78
+ <div class="panel">
79
+ {#if filters.length === 0}
80
+ <p class="empty">{t('db_filter_none_desc')}</p>
81
+ {:else}
82
+ <div class="mode" role="group" aria-label={t('db_filter_mode')}>
83
+ <button
84
+ type="button"
85
+ class:on={config.filterMode === 'and'}
86
+ disabled={!canEdit}
87
+ onclick={() => onchange({ filterMode: 'and' })}
88
+ >
89
+ {t('db_filter_mode_and')}
90
+ </button>
91
+ <button
92
+ type="button"
93
+ class:on={config.filterMode === 'or'}
94
+ disabled={!canEdit}
95
+ onclick={() => onchange({ filterMode: 'or' })}
96
+ >
97
+ {t('db_filter_mode_or')}
98
+ </button>
99
+ </div>
100
+
101
+ <ul class="rows">
102
+ {#each filters as filter, index (index)}
103
+ {@const property = propertyFor(filter.propertyKey)}
104
+ <li class="row">
105
+ <div class="line">
106
+ <select
107
+ class="control grow"
108
+ value={filter.propertyKey}
109
+ disabled={!canEdit}
110
+ aria-label={t('db_filter_property')}
111
+ onchange={(e) => changeProperty(index, e.currentTarget.value)}
112
+ >
113
+ {#each properties as option (option.key)}
114
+ <option value={option.key}>{option.name}</option>
115
+ {/each}
116
+ </select>
117
+ <select
118
+ class="control grow"
119
+ value={filter.operator}
120
+ disabled={!canEdit || !property}
121
+ aria-label={t('db_filter_operator')}
122
+ onchange={(e) =>
123
+ patchAt(index, {
124
+ operator: e.currentTarget.value as FilterOperator,
125
+ ...(VALUELESS_OPERATORS.includes(e.currentTarget.value as FilterOperator)
126
+ ? { value: null }
127
+ : {}),
128
+ })}
129
+ >
130
+ {#each property ? operatorsFor(property.type) : [] as op (op)}
131
+ <option value={op}>{t(`db_op_${op}`)}</option>
132
+ {/each}
133
+ </select>
134
+ <button
135
+ type="button"
136
+ class="remove"
137
+ disabled={!canEdit}
138
+ aria-label={t('db_filter_remove')}
139
+ onclick={() => setFilters(filters.filter((_, i) => i !== index))}
140
+ >
141
+ <Icon name="x" size={13} strokeWidth={1.9} />
142
+ </button>
143
+ </div>
144
+ {#if property}
145
+ <div class="value">
146
+ <FilterValue
147
+ {property}
148
+ operator={filter.operator}
149
+ value={filter.value}
150
+ {workspaceId}
151
+ {people}
152
+ onchange={(next) => patchAt(index, { value: next })}
153
+ />
154
+ </div>
155
+ {:else}
156
+ <p class="warn">{t('db_filter_unknown_property')}</p>
157
+ {/if}
158
+ </li>
159
+ {/each}
160
+ </ul>
161
+ {/if}
162
+
163
+ <div class="foot">
164
+ <Button size="sm" variant="secondary" disabled={!canEdit || properties.length === 0} onclick={addFilter}>
165
+ {t('db_filter_add')}
166
+ </Button>
167
+ {#if filters.length > 0}
168
+ <Button size="sm" variant="secondary" disabled={!canEdit} onclick={() => setFilters([])}>
169
+ {t('db_filter_clear')}
170
+ </Button>
171
+ {/if}
172
+ </div>
173
+ </div>
174
+ </Popover>
175
+
176
+ <style>
177
+ .panel {
178
+ padding: 12px;
179
+ display: flex;
180
+ flex-direction: column;
181
+ gap: 10px;
182
+ }
183
+ .empty {
184
+ margin: 0;
185
+ font-size: 12.5px;
186
+ color: var(--kern-ink-450);
187
+ }
188
+ .mode {
189
+ display: inline-flex;
190
+ gap: 2px;
191
+ padding: 2px;
192
+ border-radius: var(--kern-r-md);
193
+ background: var(--kern-surface-hover);
194
+ align-self: flex-start;
195
+ }
196
+ .mode button {
197
+ height: 26px;
198
+ padding: 0 10px;
199
+ border: 0;
200
+ border-radius: var(--kern-r-sm);
201
+ background: none;
202
+ color: var(--kern-ink-450);
203
+ font: inherit;
204
+ font-size: 12px;
205
+ }
206
+ .mode button.on {
207
+ background: var(--kern-surface-raised);
208
+ color: var(--kern-ink-900);
209
+ font-weight: 600;
210
+ }
211
+ .rows {
212
+ list-style: none;
213
+ margin: 0;
214
+ padding: 0;
215
+ display: flex;
216
+ flex-direction: column;
217
+ gap: 10px;
218
+ }
219
+ .row {
220
+ display: flex;
221
+ flex-direction: column;
222
+ gap: 6px;
223
+ padding-block-end: 10px;
224
+ border-block-end: 1px solid var(--kern-border-hairline);
225
+ }
226
+ .rows li:last-child {
227
+ border-block-end: 0;
228
+ padding-block-end: 0;
229
+ }
230
+ .line {
231
+ display: flex;
232
+ align-items: center;
233
+ gap: 6px;
234
+ }
235
+ .control {
236
+ min-width: 0;
237
+ height: 30px;
238
+ padding: 0 8px;
239
+ border: 1px solid var(--kern-border);
240
+ border-radius: var(--kern-r-md);
241
+ background: var(--kern-surface-raised);
242
+ color: var(--kern-ink-700);
243
+ font: inherit;
244
+ font-size: 12.5px;
245
+ }
246
+ .control:focus {
247
+ outline: none;
248
+ border-color: var(--kern-accent);
249
+ box-shadow: 0 0 0 3px var(--kern-ring);
250
+ }
251
+ .grow {
252
+ flex: 1;
253
+ }
254
+ .remove {
255
+ flex: none;
256
+ display: inline-grid;
257
+ place-items: center;
258
+ width: 28px;
259
+ height: 28px;
260
+ border: 0;
261
+ border-radius: var(--kern-r-sm);
262
+ background: none;
263
+ color: var(--kern-ink-450);
264
+ }
265
+ .remove:hover:not(:disabled) {
266
+ background: var(--kern-surface-hover);
267
+ color: var(--kern-ink-900);
268
+ }
269
+ .value {
270
+ padding-inline-start: 2px;
271
+ }
272
+ .warn {
273
+ margin: 0;
274
+ font-size: 12px;
275
+ color: var(--kern-warning);
276
+ }
277
+ .foot {
278
+ display: flex;
279
+ gap: 8px;
280
+ }
281
+ </style>
@@ -0,0 +1,176 @@
1
+ <script lang="ts">
2
+ import { createQuery } from '@tanstack/svelte-query'
3
+ import type { FilterOperator, Property } from '../../contract/index.js'
4
+ import { getQuireApi } from '../api-instance.js'
5
+ import type { Person } from '../core-api.js'
6
+ import { t } from '../i18n.js'
7
+ import { quireKeys } from '../query.js'
8
+ import { descriptorFor, VALUELESS_OPERATORS } from './property-types.js'
9
+
10
+ /**
11
+ * The value half of one filter.
12
+ *
13
+ * Which control appears is decided by the property's editor family and by the operator: `is_empty`
14
+ * takes no value at all, so nothing is drawn rather than a disabled box that looks like it wants
15
+ * one. `is_any_of` takes several, so the closed-set types offer checkboxes rather than a field
16
+ * somebody has to type comma-separated ids into.
17
+ */
18
+ interface Props {
19
+ property: Property
20
+ operator: FilterOperator
21
+ value: unknown
22
+ workspaceId: string
23
+ people: Person[]
24
+ onchange: (value: unknown) => void
25
+ }
26
+ const { property, operator, value, workspaceId, people, onchange }: Props = $props()
27
+
28
+ const api = getQuireApi()
29
+ const editor = $derived(descriptorFor(property.type).editor)
30
+ const many = $derived(operator === 'is_any_of' || operator === 'is_none_of')
31
+
32
+ const asList = $derived(
33
+ value == null ? [] : (Array.isArray(value) ? value : [value]).map(String).filter((v) => v !== ''),
34
+ )
35
+ const asText = $derived(value == null ? '' : Array.isArray(value) ? '' : String(value))
36
+
37
+ const toggle = (id: string, on: boolean) => {
38
+ const next = on ? [...asList.filter((v) => v !== id), id] : asList.filter((v) => v !== id)
39
+ onchange(many ? next : (next.at(-1) ?? null))
40
+ }
41
+
42
+ const choices = $derived(
43
+ (property.config.options ?? []).map((option) => ({ id: option.id, label: option.label })),
44
+ )
45
+
46
+ /** A relation filter compares page ids, so the picker has to name them. */
47
+ const linked = createQuery(() => ({
48
+ queryKey: quireKeys.lookup(workspaceId, property.config.relationDatabaseId ?? '', ''),
49
+ enabled: editor === 'relation' && Boolean(property.config.relationDatabaseId),
50
+ queryFn: () =>
51
+ api.databases.lookup({
52
+ workspaceId,
53
+ databaseId: property.config.relationDatabaseId!,
54
+ query: '',
55
+ ids: [],
56
+ limit: 50,
57
+ }),
58
+ }))
59
+
60
+ const rows = $derived.by(() => {
61
+ if (editor === 'select') return choices
62
+ if (editor === 'person') return people.map((p) => ({ id: p.id, label: p.name }))
63
+ if (editor === 'relation')
64
+ return (linked.data ?? []).map((r) => ({ id: r.id, label: r.title.trim() || t('untitled') }))
65
+ return []
66
+ })
67
+ </script>
68
+
69
+ {#if VALUELESS_OPERATORS.includes(operator)}
70
+ <!-- nothing to compare against -->
71
+ {:else if editor === 'checkbox'}
72
+ <select
73
+ class="control"
74
+ value={value === true ? 'true' : 'false'}
75
+ aria-label={t('db_filter_value', { name: property.name })}
76
+ onchange={(e) => onchange(e.currentTarget.value === 'true')}
77
+ >
78
+ <option value="true">{t('db_checked')}</option>
79
+ <option value="false">{t('db_unchecked')}</option>
80
+ </select>
81
+ {:else if editor === 'select' || editor === 'person' || editor === 'relation'}
82
+ {#if rows.length === 0}
83
+ <p class="hint">{t('db_filter_no_choices')}</p>
84
+ {:else}
85
+ <ul class="choices" aria-label={t('db_filter_value', { name: property.name })}>
86
+ {#each rows as choice (choice.id)}
87
+ <li>
88
+ <label>
89
+ <input
90
+ type="checkbox"
91
+ checked={asList.includes(choice.id)}
92
+ onchange={(e) => toggle(choice.id, e.currentTarget.checked)}
93
+ />
94
+ <span>{choice.label}</span>
95
+ </label>
96
+ </li>
97
+ {/each}
98
+ </ul>
99
+ {/if}
100
+ {:else if editor === 'date' || property.type === 'created_time' || property.type === 'edited_time'}
101
+ <input
102
+ class="control"
103
+ type="date"
104
+ value={asText ? asText.slice(0, 10) : ''}
105
+ aria-label={t('db_filter_value', { name: property.name })}
106
+ onchange={(e) =>
107
+ onchange(e.currentTarget.value ? new Date(e.currentTarget.value).toISOString() : null)}
108
+ />
109
+ {:else if editor === 'number' || editor === 'computed'}
110
+ <input
111
+ class="control"
112
+ type="number"
113
+ inputmode="decimal"
114
+ value={asText}
115
+ aria-label={t('db_filter_value', { name: property.name })}
116
+ onchange={(e) => onchange(e.currentTarget.value === '' ? null : Number(e.currentTarget.value))}
117
+ />
118
+ {:else}
119
+ <input
120
+ class="control"
121
+ type="text"
122
+ value={asText}
123
+ placeholder={t('db_filter_value_placeholder')}
124
+ aria-label={t('db_filter_value', { name: property.name })}
125
+ onchange={(e) => onchange(e.currentTarget.value === '' ? null : e.currentTarget.value)}
126
+ />
127
+ {/if}
128
+
129
+ <style>
130
+ .control {
131
+ width: 100%;
132
+ min-width: 0;
133
+ height: 30px;
134
+ padding: 0 8px;
135
+ border: 1px solid var(--kern-border);
136
+ border-radius: var(--kern-r-md);
137
+ background: var(--kern-surface-raised);
138
+ color: var(--kern-ink-700);
139
+ font: inherit;
140
+ font-size: 12.5px;
141
+ }
142
+ .control:focus {
143
+ outline: none;
144
+ border-color: var(--kern-accent);
145
+ box-shadow: 0 0 0 3px var(--kern-ring);
146
+ }
147
+ .choices {
148
+ list-style: none;
149
+ margin: 0;
150
+ padding: 0;
151
+ display: flex;
152
+ flex-direction: column;
153
+ gap: 2px;
154
+ max-height: 160px;
155
+ overflow-y: auto;
156
+ }
157
+ .choices label {
158
+ display: flex;
159
+ align-items: center;
160
+ gap: 7px;
161
+ min-height: 26px;
162
+ padding: 2px 4px;
163
+ border-radius: var(--kern-r-sm);
164
+ font-size: 12.5px;
165
+ color: var(--kern-ink-700);
166
+ cursor: pointer;
167
+ }
168
+ .choices label:hover {
169
+ background: var(--kern-surface-hover);
170
+ }
171
+ .hint {
172
+ margin: 0;
173
+ font-size: 12px;
174
+ color: var(--kern-ink-450);
175
+ }
176
+ </style>
@@ -0,0 +1,177 @@
1
+ <script lang="ts">
2
+ import { DropdownMenu, Icon, IconButton, type MenuItem } from '@kernhq/ui'
3
+ import type { Database, Row, View } 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 { visiblePropertiesOf } from './view-config.js'
8
+
9
+ /**
10
+ * Cards of a title and the columns the view shows.
11
+ *
12
+ * No picture: a cover would come from a `files` property, and Quire has no file handling at all —
13
+ * so the card leads with an icon band rather than pretending an image is missing. The view editor
14
+ * says the same thing where somebody would go looking for the setting.
15
+ */
16
+ interface Props {
17
+ database: Database
18
+ view: View | null
19
+ rows: Row[]
20
+ people: Person[]
21
+ workspaceId: string
22
+ canEdit: boolean
23
+ onOpenRow: (row: Row) => void
24
+ onOpenPage: (row: Row) => void
25
+ onDeleteRow: (row: Row) => void
26
+ }
27
+ const { database, view, rows, people, workspaceId, canEdit, onOpenRow, onOpenPage, onDeleteRow }: Props =
28
+ $props()
29
+
30
+ const size = $derived(view?.config.cardSize ?? 'medium')
31
+ const columns = $derived(visiblePropertiesOf(database, view).slice(0, size === 'small' ? 2 : 4))
32
+
33
+ const menu = (row: Row): MenuItem[] => [
34
+ { id: 'open', label: t('db_open_row'), icon: 'maximize-2', onSelect: () => onOpenRow(row) },
35
+ { id: 'page', label: t('db_open_as_page'), icon: 'file-text', onSelect: () => onOpenPage(row) },
36
+ { type: 'separator' },
37
+ {
38
+ id: 'delete',
39
+ label: t('db_delete_row'),
40
+ icon: 'trash-2',
41
+ danger: true,
42
+ disabled: !canEdit,
43
+ onSelect: () => onDeleteRow(row),
44
+ },
45
+ ]
46
+ </script>
47
+
48
+ <ul class="gallery" class:small={size === 'small'} class:large={size === 'large'}>
49
+ {#each rows as row (row.id)}
50
+ <li>
51
+ <article class="card">
52
+ <div class="band" aria-hidden="true"><Icon name="file-text" size={18} strokeWidth={1.5} /></div>
53
+ <div class="body">
54
+ <div class="top">
55
+ <h3 class="title">{row.title.trim() || t('untitled')}</h3>
56
+ <DropdownMenu items={menu(row)}>
57
+ {#snippet trigger(props: Record<string, unknown>)}
58
+ <IconButton
59
+ {...props}
60
+ icon="ellipsis"
61
+ label={t('db_row_actions', { title: row.title.trim() || t('untitled') })}
62
+ size={26}
63
+ variant="ghost"
64
+ />
65
+ {/snippet}
66
+ </DropdownMenu>
67
+ </div>
68
+ {#if columns.length > 0}
69
+ <dl class="fields">
70
+ {#each columns as property (property.id)}
71
+ <div class="field">
72
+ <dt>{property.name}</dt>
73
+ <dd>
74
+ <Cell {property} {row} {people} {workspaceId} canEdit={false} onchange={() => {}} />
75
+ </dd>
76
+ </div>
77
+ {/each}
78
+ </dl>
79
+ {/if}
80
+ </div>
81
+ </article>
82
+ </li>
83
+ {/each}
84
+ </ul>
85
+
86
+ <style>
87
+ .gallery {
88
+ list-style: none;
89
+ margin: 0;
90
+ padding: 22px 28px 40px;
91
+ display: grid;
92
+ grid-template-columns: repeat(auto-fill, minmax(260px, 1fr));
93
+ /* the cell stretches, not the card inside it, or one long title makes its neighbour short */
94
+ grid-auto-rows: 1fr;
95
+ gap: 14px;
96
+ }
97
+ .gallery.small {
98
+ grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
99
+ }
100
+ .gallery.large {
101
+ grid-template-columns: repeat(auto-fill, minmax(340px, 1fr));
102
+ }
103
+ .gallery > li {
104
+ display: grid;
105
+ }
106
+ .card {
107
+ display: flex;
108
+ flex-direction: column;
109
+ border: 1px solid var(--kern-border);
110
+ border-radius: var(--kern-r-xl);
111
+ background: var(--kern-surface-raised);
112
+ overflow: hidden;
113
+ }
114
+ .card:hover {
115
+ border-color: var(--kern-border-hover);
116
+ }
117
+ .band {
118
+ height: 54px;
119
+ display: grid;
120
+ place-items: center;
121
+ background: var(--kern-surface-hover);
122
+ color: var(--kern-ink-350);
123
+ }
124
+ .body {
125
+ flex: 1;
126
+ padding: 11px 8px 13px 13px;
127
+ display: flex;
128
+ flex-direction: column;
129
+ gap: 9px;
130
+ }
131
+ .top {
132
+ display: flex;
133
+ align-items: flex-start;
134
+ gap: 6px;
135
+ }
136
+ .title {
137
+ flex: 1;
138
+ min-width: 0;
139
+ margin: 0;
140
+ font-size: 14px;
141
+ font-weight: 500;
142
+ line-height: 1.4;
143
+ color: var(--kern-ink-900);
144
+ }
145
+ .fields {
146
+ margin: 0 5px 0 0;
147
+ padding: 0;
148
+ display: flex;
149
+ flex-direction: column;
150
+ gap: 4px;
151
+ }
152
+ .field {
153
+ display: grid;
154
+ grid-template-columns: 84px minmax(0, 1fr);
155
+ align-items: center;
156
+ gap: 8px;
157
+ }
158
+ .field dt {
159
+ font-size: 11.5px;
160
+ color: var(--kern-ink-450);
161
+ overflow: hidden;
162
+ text-overflow: ellipsis;
163
+ white-space: nowrap;
164
+ }
165
+ .field dd {
166
+ margin: 0;
167
+ min-width: 0;
168
+ font-size: 12.5px;
169
+ color: var(--kern-ink-600);
170
+ overflow: hidden;
171
+ }
172
+ @media (max-width: 768px) {
173
+ .gallery {
174
+ padding: 16px;
175
+ }
176
+ }
177
+ </style>