@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,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>
|