@kernhq/module-quire 0.7.4 → 0.9.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- 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/document.d.ts +13 -0
- package/dist/server/document.d.ts.map +1 -0
- package/dist/server/document.js +58 -0
- package/dist/server/document.js.map +1 -0
- package/dist/server/index.d.ts.map +1 -1
- package/dist/server/index.js +26 -1
- package/dist/server/index.js.map +1 -1
- package/dist/server/render.d.ts +53 -0
- package/dist/server/render.d.ts.map +1 -0
- package/dist/server/render.js +358 -0
- package/dist/server/render.js.map +1 -0
- 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/dist/server/services/versions.d.ts.map +1 -1
- package/dist/server/services/versions.js +13 -1
- package/dist/server/services/versions.js.map +1 -1
- package/migrations/0006_rank_collation.sql +14 -0
- package/migrations/meta/_journal.json +7 -0
- package/package.json +12 -9
- package/src/client/components/PageEditor.svelte +7 -0
- 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,354 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import { DropdownMenu, EmptyState, Icon, IconButton, type MenuItem } from '@kernhq/ui'
|
|
3
|
+
import { untrack } from 'svelte'
|
|
4
|
+
import { dndzone, SHADOW_ITEM_MARKER_PROPERTY_NAME } from 'svelte-dnd-action'
|
|
5
|
+
import type { Database, Property, Row, View } from '../../contract/index.js'
|
|
6
|
+
import type { Person } from '../core-api.js'
|
|
7
|
+
import { t } from '../i18n.js'
|
|
8
|
+
import Cell from './cells/Cell.svelte'
|
|
9
|
+
import OptionChip from './OptionChip.svelte'
|
|
10
|
+
import { descriptorFor } from './property-types.js'
|
|
11
|
+
import { EMPTY_GROUP, groupsOf, type Lane, visiblePropertiesOf } from './view-config.js'
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* The board (DESIGN.md §3.3), grouped by a select, status or checkbox column.
|
|
15
|
+
*
|
|
16
|
+
* Three traps in `svelte-dnd-action`, all of which have already shipped once in this project:
|
|
17
|
+
*
|
|
18
|
+
* - it tracks items by an `id` property and nothing else, so the lanes hold `Row`s and never
|
|
19
|
+
* wrappers;
|
|
20
|
+
* - the lanes must be `$state.raw` — a deep-reactive proxy hands the library a different object on
|
|
21
|
+
* every read, which it reads as an endless stream of changes;
|
|
22
|
+
* - the seeding effect reads its `dragging` guard through `untrack`. Clearing the flag at the end of
|
|
23
|
+
* a drop otherwise re-runs the effect against the *old* query data and undoes the move on screen
|
|
24
|
+
* while the mutation is still in flight.
|
|
25
|
+
*
|
|
26
|
+
* Every card carries a **Move to** menu. The drag is unreachable by keyboard, so without it half
|
|
27
|
+
* the people using this board could not move a card at all.
|
|
28
|
+
*/
|
|
29
|
+
interface Props {
|
|
30
|
+
database: Database
|
|
31
|
+
view: View | null
|
|
32
|
+
rows: Row[]
|
|
33
|
+
people: Person[]
|
|
34
|
+
workspaceId: string
|
|
35
|
+
canEdit: boolean
|
|
36
|
+
canCreate: boolean
|
|
37
|
+
onMove: (row: Row, laneId: string) => void
|
|
38
|
+
onOpenRow: (row: Row) => void
|
|
39
|
+
onAddRow: (laneId: string) => void
|
|
40
|
+
onConfigure: () => void
|
|
41
|
+
}
|
|
42
|
+
const {
|
|
43
|
+
database,
|
|
44
|
+
view,
|
|
45
|
+
rows,
|
|
46
|
+
people,
|
|
47
|
+
workspaceId,
|
|
48
|
+
canEdit,
|
|
49
|
+
canCreate,
|
|
50
|
+
onMove,
|
|
51
|
+
onOpenRow,
|
|
52
|
+
onAddRow,
|
|
53
|
+
onConfigure,
|
|
54
|
+
}: Props = $props()
|
|
55
|
+
|
|
56
|
+
const FLIP = 160
|
|
57
|
+
|
|
58
|
+
const groupKey = $derived(view?.config.groupBy ?? null)
|
|
59
|
+
const groupProperty = $derived(
|
|
60
|
+
groupKey ? (database.properties.find((p) => p.key === groupKey) ?? null) : null,
|
|
61
|
+
)
|
|
62
|
+
const groupable = $derived(groupProperty !== null && descriptorFor(groupProperty.type).canGroup)
|
|
63
|
+
|
|
64
|
+
/** Three properties on a card, not thirty: the card is a summary, the panel is the record. */
|
|
65
|
+
const cardProperties = $derived(
|
|
66
|
+
visiblePropertiesOf(database, view)
|
|
67
|
+
.filter((p) => p.key !== groupKey)
|
|
68
|
+
.slice(0, 3),
|
|
69
|
+
)
|
|
70
|
+
|
|
71
|
+
let lanes = $state.raw<Lane[]>([])
|
|
72
|
+
let dragging = $state(false)
|
|
73
|
+
|
|
74
|
+
const replaceLane = (index: number, items: Row[]) => {
|
|
75
|
+
lanes = lanes.map((lane, i) => (i === index ? { ...lane, rows: items } : lane))
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
$effect(() => {
|
|
79
|
+
const next = groupable ? groupsOf(groupProperty, rows) : []
|
|
80
|
+
if (untrack(() => dragging)) return
|
|
81
|
+
lanes = next
|
|
82
|
+
})
|
|
83
|
+
|
|
84
|
+
const isShadow = (row: Row) =>
|
|
85
|
+
(row as unknown as Record<string, unknown>)[SHADOW_ITEM_MARKER_PROPERTY_NAME] === true
|
|
86
|
+
|
|
87
|
+
function consider(index: number, event: CustomEvent<{ items: Row[] }>) {
|
|
88
|
+
dragging = true
|
|
89
|
+
replaceLane(index, event.detail.items)
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function finalize(index: number, event: CustomEvent<{ items: Row[]; info: { id: string } }>) {
|
|
93
|
+
dragging = false
|
|
94
|
+
const lane = lanes[index]
|
|
95
|
+
if (!lane) return
|
|
96
|
+
replaceLane(index, event.detail.items)
|
|
97
|
+
const moved = event.detail.items.find((r) => r.id === event.detail.info.id)
|
|
98
|
+
// Both the source and the target lane fire; only the target holds the card.
|
|
99
|
+
if (!moved) return
|
|
100
|
+
const already = groupProperty ? moved.props[groupProperty.key] : null
|
|
101
|
+
const current = Array.isArray(already) ? (already[0] ?? null) : (already ?? null)
|
|
102
|
+
// A drop inside the same lane re-seeds and does nothing: rows carry no order of their own, so
|
|
103
|
+
// there is nothing to persist and pretending otherwise would snap back a moment later anyway.
|
|
104
|
+
if ((current ?? EMPTY_GROUP) === lane.id) return
|
|
105
|
+
onMove(moved, lane.id)
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
const laneName = (lane: Lane) => lane.option?.label ?? t('db_board_uncategorised')
|
|
109
|
+
|
|
110
|
+
const cardMenu = (row: Row, from: Lane): MenuItem[] => [
|
|
111
|
+
{ id: 'open', label: t('db_open_row'), icon: 'maximize-2', onSelect: () => onOpenRow(row) },
|
|
112
|
+
{ type: 'separator' },
|
|
113
|
+
{ type: 'label', label: t('db_move_to') },
|
|
114
|
+
...lanes
|
|
115
|
+
.filter((lane) => lane.id !== from.id)
|
|
116
|
+
.map((lane) => ({
|
|
117
|
+
id: `move-${lane.id}`,
|
|
118
|
+
label: laneName(lane),
|
|
119
|
+
icon: 'arrow-right',
|
|
120
|
+
disabled: !canEdit,
|
|
121
|
+
onSelect: () => onMove(row, lane.id),
|
|
122
|
+
})),
|
|
123
|
+
]
|
|
124
|
+
|
|
125
|
+
const laneRule = (lane: Lane) =>
|
|
126
|
+
`linear-gradient(to var(--kern-quire-lane-dir, right), ${
|
|
127
|
+
lane.option ? 'var(--kern-accent)' : 'var(--kern-ink-300)'
|
|
128
|
+
} 0 34px, var(--kern-border) 34px)`
|
|
129
|
+
</script>
|
|
130
|
+
|
|
131
|
+
{#if !groupable}
|
|
132
|
+
<div class="pad">
|
|
133
|
+
<EmptyState
|
|
134
|
+
icon="kanban"
|
|
135
|
+
title={t('db_board_needs_group')}
|
|
136
|
+
description={t('db_board_needs_group_desc')}
|
|
137
|
+
>
|
|
138
|
+
{#snippet actions()}
|
|
139
|
+
{#if canEdit}
|
|
140
|
+
<button type="button" class="link" onclick={onConfigure}>{t('db_edit_view')}</button>
|
|
141
|
+
{/if}
|
|
142
|
+
{/snippet}
|
|
143
|
+
</EmptyState>
|
|
144
|
+
</div>
|
|
145
|
+
{:else}
|
|
146
|
+
<div class="board">
|
|
147
|
+
{#each lanes as lane, index (lane.id)}
|
|
148
|
+
<section class="col">
|
|
149
|
+
<header class="head">
|
|
150
|
+
{#if lane.option}
|
|
151
|
+
<OptionChip option={lane.option} compact />
|
|
152
|
+
{:else}
|
|
153
|
+
<span class="none">{t('db_board_uncategorised')}</span>
|
|
154
|
+
{/if}
|
|
155
|
+
<span class="count">{lane.rows.length}</span>
|
|
156
|
+
<span class="sp"></span>
|
|
157
|
+
{#if canCreate}
|
|
158
|
+
<IconButton
|
|
159
|
+
icon="plus"
|
|
160
|
+
label={t('db_new_row_in', { lane: laneName(lane) })}
|
|
161
|
+
size={26}
|
|
162
|
+
variant="ghost"
|
|
163
|
+
onclick={() => onAddRow(lane.id)}
|
|
164
|
+
/>
|
|
165
|
+
{/if}
|
|
166
|
+
</header>
|
|
167
|
+
<div class="rule" style:background={laneRule(lane)}></div>
|
|
168
|
+
|
|
169
|
+
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
|
170
|
+
<div
|
|
171
|
+
class="stack"
|
|
172
|
+
use:dndzone={{
|
|
173
|
+
items: lane.rows,
|
|
174
|
+
flipDurationMs: FLIP,
|
|
175
|
+
type: 'quire-rows',
|
|
176
|
+
dragDisabled: !canEdit,
|
|
177
|
+
dropTargetStyle: {},
|
|
178
|
+
}}
|
|
179
|
+
onconsider={(e) => consider(index, e)}
|
|
180
|
+
onfinalize={(e) => finalize(index, e)}
|
|
181
|
+
>
|
|
182
|
+
{#each lane.rows as row (row.id)}
|
|
183
|
+
<article class="card" class:shadow={isShadow(row)}>
|
|
184
|
+
<div class="card-top">
|
|
185
|
+
<h3 class="card-title">{row.title.trim() || t('untitled')}</h3>
|
|
186
|
+
<DropdownMenu items={cardMenu(row, lane)}>
|
|
187
|
+
{#snippet trigger(props: Record<string, unknown>)}
|
|
188
|
+
<IconButton
|
|
189
|
+
{...props}
|
|
190
|
+
icon="ellipsis"
|
|
191
|
+
label={t('db_row_actions', { title: row.title.trim() || t('untitled') })}
|
|
192
|
+
size={26}
|
|
193
|
+
variant="ghost"
|
|
194
|
+
/>
|
|
195
|
+
{/snippet}
|
|
196
|
+
</DropdownMenu>
|
|
197
|
+
</div>
|
|
198
|
+
{#if cardProperties.length > 0}
|
|
199
|
+
<dl class="fields">
|
|
200
|
+
{#each cardProperties as property (property.id)}
|
|
201
|
+
<div class="field">
|
|
202
|
+
<dt>{property.name}</dt>
|
|
203
|
+
<dd>
|
|
204
|
+
<Cell
|
|
205
|
+
{property}
|
|
206
|
+
{row}
|
|
207
|
+
{people}
|
|
208
|
+
{workspaceId}
|
|
209
|
+
canEdit={false}
|
|
210
|
+
onchange={() => {}}
|
|
211
|
+
/>
|
|
212
|
+
</dd>
|
|
213
|
+
</div>
|
|
214
|
+
{/each}
|
|
215
|
+
</dl>
|
|
216
|
+
{/if}
|
|
217
|
+
</article>
|
|
218
|
+
{/each}
|
|
219
|
+
{#if lane.rows.length === 0}
|
|
220
|
+
<p class="lane-empty">{t('db_lane_empty')}</p>
|
|
221
|
+
{/if}
|
|
222
|
+
</div>
|
|
223
|
+
</section>
|
|
224
|
+
{/each}
|
|
225
|
+
</div>
|
|
226
|
+
{/if}
|
|
227
|
+
|
|
228
|
+
<style>
|
|
229
|
+
.board {
|
|
230
|
+
display: flex;
|
|
231
|
+
align-items: flex-start;
|
|
232
|
+
gap: 20px;
|
|
233
|
+
padding: 22px 28px 30px;
|
|
234
|
+
overflow-x: auto;
|
|
235
|
+
}
|
|
236
|
+
:global([dir='rtl']) .board {
|
|
237
|
+
--kern-quire-lane-dir: left;
|
|
238
|
+
}
|
|
239
|
+
.col {
|
|
240
|
+
width: var(--kern-board-col-w);
|
|
241
|
+
flex: none;
|
|
242
|
+
}
|
|
243
|
+
.head {
|
|
244
|
+
display: flex;
|
|
245
|
+
align-items: center;
|
|
246
|
+
gap: 9px;
|
|
247
|
+
height: 30px;
|
|
248
|
+
}
|
|
249
|
+
.none {
|
|
250
|
+
font-size: 13px;
|
|
251
|
+
color: var(--kern-ink-450);
|
|
252
|
+
}
|
|
253
|
+
.count {
|
|
254
|
+
font-family: var(--kern-font-mono);
|
|
255
|
+
font-size: 11.5px;
|
|
256
|
+
color: var(--kern-ink-450);
|
|
257
|
+
}
|
|
258
|
+
.sp {
|
|
259
|
+
flex: 1;
|
|
260
|
+
}
|
|
261
|
+
.rule {
|
|
262
|
+
height: 2px;
|
|
263
|
+
border-radius: 1px;
|
|
264
|
+
}
|
|
265
|
+
.stack {
|
|
266
|
+
display: flex;
|
|
267
|
+
flex-direction: column;
|
|
268
|
+
gap: 10px;
|
|
269
|
+
margin-block-start: 14px;
|
|
270
|
+
min-height: 80px;
|
|
271
|
+
padding-block-end: 4px;
|
|
272
|
+
}
|
|
273
|
+
.card {
|
|
274
|
+
border: 1px solid var(--kern-border);
|
|
275
|
+
border-radius: var(--kern-r-xl);
|
|
276
|
+
background: var(--kern-surface-raised);
|
|
277
|
+
padding: 11px 8px 12px 13px;
|
|
278
|
+
}
|
|
279
|
+
.card:hover {
|
|
280
|
+
border-color: var(--kern-border-hover);
|
|
281
|
+
}
|
|
282
|
+
.card.shadow {
|
|
283
|
+
border-style: dashed;
|
|
284
|
+
background: var(--kern-surface-hover);
|
|
285
|
+
}
|
|
286
|
+
.card-top {
|
|
287
|
+
display: flex;
|
|
288
|
+
align-items: flex-start;
|
|
289
|
+
gap: 6px;
|
|
290
|
+
}
|
|
291
|
+
.card-title {
|
|
292
|
+
flex: 1;
|
|
293
|
+
min-width: 0;
|
|
294
|
+
margin: 0;
|
|
295
|
+
font-size: 14px;
|
|
296
|
+
font-weight: 500;
|
|
297
|
+
line-height: 1.42;
|
|
298
|
+
letter-spacing: -0.005em;
|
|
299
|
+
color: var(--kern-ink-900);
|
|
300
|
+
}
|
|
301
|
+
.fields {
|
|
302
|
+
margin: 10px 5px 0 0;
|
|
303
|
+
padding: 0;
|
|
304
|
+
display: flex;
|
|
305
|
+
flex-direction: column;
|
|
306
|
+
gap: 4px;
|
|
307
|
+
}
|
|
308
|
+
.field {
|
|
309
|
+
display: grid;
|
|
310
|
+
grid-template-columns: 84px minmax(0, 1fr);
|
|
311
|
+
align-items: center;
|
|
312
|
+
gap: 8px;
|
|
313
|
+
}
|
|
314
|
+
.field dt {
|
|
315
|
+
font-size: 11.5px;
|
|
316
|
+
color: var(--kern-ink-450);
|
|
317
|
+
overflow: hidden;
|
|
318
|
+
text-overflow: ellipsis;
|
|
319
|
+
white-space: nowrap;
|
|
320
|
+
}
|
|
321
|
+
.field dd {
|
|
322
|
+
margin: 0;
|
|
323
|
+
min-width: 0;
|
|
324
|
+
font-size: 12.5px;
|
|
325
|
+
color: var(--kern-ink-600);
|
|
326
|
+
overflow: hidden;
|
|
327
|
+
}
|
|
328
|
+
.lane-empty {
|
|
329
|
+
margin: 0;
|
|
330
|
+
padding: 14px 10px;
|
|
331
|
+
border: 1px dashed var(--kern-border);
|
|
332
|
+
border-radius: var(--kern-r-lg);
|
|
333
|
+
text-align: center;
|
|
334
|
+
font-size: 12.5px;
|
|
335
|
+
color: var(--kern-ink-450);
|
|
336
|
+
}
|
|
337
|
+
.pad {
|
|
338
|
+
padding: 28px;
|
|
339
|
+
}
|
|
340
|
+
.link {
|
|
341
|
+
border: 0;
|
|
342
|
+
background: none;
|
|
343
|
+
color: var(--kern-accent-text);
|
|
344
|
+
font: inherit;
|
|
345
|
+
font-size: 13px;
|
|
346
|
+
text-decoration: underline;
|
|
347
|
+
}
|
|
348
|
+
@media (max-width: 768px) {
|
|
349
|
+
.board {
|
|
350
|
+
padding: 16px;
|
|
351
|
+
gap: 14px;
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
</style>
|
|
@@ -0,0 +1,346 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import { Button, DropdownMenu, EmptyState, IconButton, type MenuItem, messageLocale } from '@kernhq/ui'
|
|
3
|
+
import type { Database, Row, View } from '../../contract/index.js'
|
|
4
|
+
import { t } from '../i18n.js'
|
|
5
|
+
import { descriptorFor } from './property-types.js'
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* A month of rows, plotted on the view's date column.
|
|
9
|
+
*
|
|
10
|
+
* The month is component state rather than a URL parameter: which month somebody is looking at is
|
|
11
|
+
* not what the page is *about*, and putting it in the address makes every scroll through the year a
|
|
12
|
+
* history entry to walk back through.
|
|
13
|
+
*
|
|
14
|
+
* A day cell is a drop target, and every card carries **Move to a date** so the drag is never the
|
|
15
|
+
* only route — the same rule the board follows.
|
|
16
|
+
*/
|
|
17
|
+
interface Props {
|
|
18
|
+
database: Database
|
|
19
|
+
view: View | null
|
|
20
|
+
rows: Row[]
|
|
21
|
+
canEdit: boolean
|
|
22
|
+
onOpenRow: (row: Row) => void
|
|
23
|
+
onSetDate: (row: Row, iso: string | null) => void
|
|
24
|
+
onConfigure: () => void
|
|
25
|
+
}
|
|
26
|
+
const { database, view, rows, canEdit, onOpenRow, onSetDate, onConfigure }: Props = $props()
|
|
27
|
+
|
|
28
|
+
const dateKey = $derived(view?.config.dateProperty ?? null)
|
|
29
|
+
const dateProperty = $derived(dateKey ? (database.properties.find((p) => p.key === dateKey) ?? null) : null)
|
|
30
|
+
const plottable = $derived(dateProperty !== null && descriptorFor(dateProperty.type).canDate)
|
|
31
|
+
|
|
32
|
+
const today = new Date()
|
|
33
|
+
let cursor = $state(new Date(today.getFullYear(), today.getMonth(), 1))
|
|
34
|
+
|
|
35
|
+
const monthLabel = $derived(
|
|
36
|
+
new Intl.DateTimeFormat(messageLocale(), { month: 'long', year: 'numeric' }).format(cursor),
|
|
37
|
+
)
|
|
38
|
+
|
|
39
|
+
/** Weekday names for the header, starting on the locale's first day. */
|
|
40
|
+
const weekdays = $derived.by(() => {
|
|
41
|
+
const format = new Intl.DateTimeFormat(messageLocale(), { weekday: 'short' })
|
|
42
|
+
// 2024-01-07 was a Sunday; the grid starts on Monday, which is what a working week means here.
|
|
43
|
+
return Array.from({ length: 7 }, (_, i) => format.format(new Date(Date.UTC(2024, 0, 8 + i))))
|
|
44
|
+
})
|
|
45
|
+
|
|
46
|
+
const dayKey = (at: Date) =>
|
|
47
|
+
`${at.getFullYear()}-${String(at.getMonth() + 1).padStart(2, '0')}-${String(at.getDate()).padStart(2, '0')}`
|
|
48
|
+
|
|
49
|
+
/** Six weeks from the Monday on or before the first of the month — a month never needs more. */
|
|
50
|
+
const days = $derived.by(() => {
|
|
51
|
+
const first = new Date(cursor.getFullYear(), cursor.getMonth(), 1)
|
|
52
|
+
const offset = (first.getDay() + 6) % 7
|
|
53
|
+
const start = new Date(first.getFullYear(), first.getMonth(), 1 - offset)
|
|
54
|
+
return Array.from(
|
|
55
|
+
{ length: 42 },
|
|
56
|
+
(_, i) => new Date(start.getFullYear(), start.getMonth(), start.getDate() + i),
|
|
57
|
+
)
|
|
58
|
+
})
|
|
59
|
+
|
|
60
|
+
const byDay = $derived.by(() => {
|
|
61
|
+
const map = new Map<string, Row[]>()
|
|
62
|
+
if (!dateProperty) return map
|
|
63
|
+
for (const row of rows) {
|
|
64
|
+
const raw = row.props[dateProperty.key]
|
|
65
|
+
if (typeof raw !== 'string' || raw === '') continue
|
|
66
|
+
const at = new Date(raw)
|
|
67
|
+
if (Number.isNaN(at.getTime())) continue
|
|
68
|
+
const key = dayKey(at)
|
|
69
|
+
map.set(key, [...(map.get(key) ?? []), row])
|
|
70
|
+
}
|
|
71
|
+
return map
|
|
72
|
+
})
|
|
73
|
+
|
|
74
|
+
const undated = $derived(
|
|
75
|
+
dateProperty
|
|
76
|
+
? rows.filter((row) => {
|
|
77
|
+
const raw = row.props[dateProperty.key]
|
|
78
|
+
return typeof raw !== 'string' || raw === '' || Number.isNaN(new Date(raw).getTime())
|
|
79
|
+
})
|
|
80
|
+
: [],
|
|
81
|
+
)
|
|
82
|
+
|
|
83
|
+
const shift = (months: number) => {
|
|
84
|
+
cursor = new Date(cursor.getFullYear(), cursor.getMonth() + months, 1)
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
let dragged = $state<string | null>(null)
|
|
88
|
+
|
|
89
|
+
function dropOn(at: Date) {
|
|
90
|
+
const row = rows.find((r) => r.id === dragged)
|
|
91
|
+
dragged = null
|
|
92
|
+
if (!row) return
|
|
93
|
+
onSetDate(row, new Date(at.getFullYear(), at.getMonth(), at.getDate(), 9).toISOString())
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
const cardMenu = (row: Row): MenuItem[] => [
|
|
97
|
+
{ id: 'open', label: t('db_open_row'), icon: 'maximize-2', onSelect: () => onOpenRow(row) },
|
|
98
|
+
{ type: 'separator' },
|
|
99
|
+
{ type: 'label', label: t('db_move_to_date') },
|
|
100
|
+
...[0, 1, 7].map((offset) => {
|
|
101
|
+
const at = new Date(today.getFullYear(), today.getMonth(), today.getDate() + offset, 9)
|
|
102
|
+
return {
|
|
103
|
+
id: `date-${offset}`,
|
|
104
|
+
label:
|
|
105
|
+
offset === 0 ? t('db_date_today') : offset === 1 ? t('db_date_tomorrow') : t('db_date_next_week'),
|
|
106
|
+
icon: 'calendar',
|
|
107
|
+
disabled: !canEdit,
|
|
108
|
+
onSelect: () => onSetDate(row, at.toISOString()),
|
|
109
|
+
}
|
|
110
|
+
}),
|
|
111
|
+
{
|
|
112
|
+
id: 'clear-date',
|
|
113
|
+
label: t('db_date_clear'),
|
|
114
|
+
icon: 'x',
|
|
115
|
+
disabled: !canEdit,
|
|
116
|
+
onSelect: () => onSetDate(row, null),
|
|
117
|
+
},
|
|
118
|
+
]
|
|
119
|
+
</script>
|
|
120
|
+
|
|
121
|
+
{#if !plottable}
|
|
122
|
+
<div class="pad">
|
|
123
|
+
<EmptyState
|
|
124
|
+
icon="calendar-days"
|
|
125
|
+
title={t('db_calendar_needs_date')}
|
|
126
|
+
description={t('db_calendar_needs_date_desc')}
|
|
127
|
+
>
|
|
128
|
+
{#snippet actions()}
|
|
129
|
+
{#if canEdit}
|
|
130
|
+
<Button variant="secondary" onclick={onConfigure}>{t('db_edit_view')}</Button>
|
|
131
|
+
{/if}
|
|
132
|
+
{/snippet}
|
|
133
|
+
</EmptyState>
|
|
134
|
+
</div>
|
|
135
|
+
{:else}
|
|
136
|
+
<div class="cal">
|
|
137
|
+
<div class="bar">
|
|
138
|
+
<IconButton icon="chevron-left" label={t('db_prev_month')} size={28} variant="ghost" onclick={() => shift(-1)} />
|
|
139
|
+
<h2 class="month">{monthLabel}</h2>
|
|
140
|
+
<IconButton icon="chevron-right" label={t('db_next_month')} size={28} variant="ghost" onclick={() => shift(1)} />
|
|
141
|
+
<Button
|
|
142
|
+
size="sm"
|
|
143
|
+
variant="secondary"
|
|
144
|
+
onclick={() => (cursor = new Date(today.getFullYear(), today.getMonth(), 1))}
|
|
145
|
+
>
|
|
146
|
+
{t('db_this_month')}
|
|
147
|
+
</Button>
|
|
148
|
+
</div>
|
|
149
|
+
|
|
150
|
+
<!--
|
|
151
|
+
A plain layout, not `role="grid"`. A real grid promises arrow-key navigation between cells,
|
|
152
|
+
and promising that without implementing it is worse for a screen-reader user than not claiming
|
|
153
|
+
it at all — the keyboard route to every action here is the card's own menu.
|
|
154
|
+
-->
|
|
155
|
+
<div class="grid" aria-label={monthLabel}>
|
|
156
|
+
{#each weekdays as weekday (weekday)}
|
|
157
|
+
<div class="dow">{weekday}</div>
|
|
158
|
+
{/each}
|
|
159
|
+
{#each days as day (day.toISOString())}
|
|
160
|
+
{@const key = dayKey(day)}
|
|
161
|
+
{@const inMonth = day.getMonth() === cursor.getMonth()}
|
|
162
|
+
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
|
163
|
+
<div
|
|
164
|
+
class="day"
|
|
165
|
+
class:out={!inMonth}
|
|
166
|
+
class:today={key === dayKey(today)}
|
|
167
|
+
ondragover={(e) => canEdit && e.preventDefault()}
|
|
168
|
+
ondrop={(e) => {
|
|
169
|
+
e.preventDefault()
|
|
170
|
+
if (canEdit) dropOn(day)
|
|
171
|
+
}}
|
|
172
|
+
>
|
|
173
|
+
<span class="num">{day.getDate()}</span>
|
|
174
|
+
{#each byDay.get(key) ?? [] as row (row.id)}
|
|
175
|
+
<div
|
|
176
|
+
class="event"
|
|
177
|
+
draggable={canEdit}
|
|
178
|
+
ondragstart={() => (dragged = row.id)}
|
|
179
|
+
ondragend={() => (dragged = null)}
|
|
180
|
+
>
|
|
181
|
+
<span class="ev-title">{row.title.trim() || t('untitled')}</span>
|
|
182
|
+
<DropdownMenu items={cardMenu(row)}>
|
|
183
|
+
{#snippet trigger(props: Record<string, unknown>)}
|
|
184
|
+
<IconButton
|
|
185
|
+
{...props}
|
|
186
|
+
icon="ellipsis"
|
|
187
|
+
label={t('db_row_actions', { title: row.title.trim() || t('untitled') })}
|
|
188
|
+
size={26}
|
|
189
|
+
variant="ghost"
|
|
190
|
+
/>
|
|
191
|
+
{/snippet}
|
|
192
|
+
</DropdownMenu>
|
|
193
|
+
</div>
|
|
194
|
+
{/each}
|
|
195
|
+
</div>
|
|
196
|
+
{/each}
|
|
197
|
+
</div>
|
|
198
|
+
|
|
199
|
+
{#if undated.length > 0}
|
|
200
|
+
<section class="undated">
|
|
201
|
+
<h3>{t('db_no_date_rows', { n: undated.length })}</h3>
|
|
202
|
+
<ul>
|
|
203
|
+
{#each undated as row (row.id)}
|
|
204
|
+
<li>
|
|
205
|
+
<span class="ev-title">{row.title.trim() || t('untitled')}</span>
|
|
206
|
+
<DropdownMenu items={cardMenu(row)}>
|
|
207
|
+
{#snippet trigger(props: Record<string, unknown>)}
|
|
208
|
+
<IconButton
|
|
209
|
+
{...props}
|
|
210
|
+
icon="ellipsis"
|
|
211
|
+
label={t('db_row_actions', { title: row.title.trim() || t('untitled') })}
|
|
212
|
+
size={26}
|
|
213
|
+
variant="ghost"
|
|
214
|
+
/>
|
|
215
|
+
{/snippet}
|
|
216
|
+
</DropdownMenu>
|
|
217
|
+
</li>
|
|
218
|
+
{/each}
|
|
219
|
+
</ul>
|
|
220
|
+
</section>
|
|
221
|
+
{/if}
|
|
222
|
+
</div>
|
|
223
|
+
{/if}
|
|
224
|
+
|
|
225
|
+
<style>
|
|
226
|
+
.cal {
|
|
227
|
+
padding: 18px 24px 40px;
|
|
228
|
+
}
|
|
229
|
+
.bar {
|
|
230
|
+
display: flex;
|
|
231
|
+
align-items: center;
|
|
232
|
+
gap: 8px;
|
|
233
|
+
margin-block-end: 14px;
|
|
234
|
+
}
|
|
235
|
+
.month {
|
|
236
|
+
margin: 0;
|
|
237
|
+
min-width: 180px;
|
|
238
|
+
font-size: 15px;
|
|
239
|
+
font-weight: 600;
|
|
240
|
+
color: var(--kern-ink-900);
|
|
241
|
+
}
|
|
242
|
+
.grid {
|
|
243
|
+
display: grid;
|
|
244
|
+
grid-template-columns: repeat(7, minmax(0, 1fr));
|
|
245
|
+
border: 1px solid var(--kern-border);
|
|
246
|
+
border-radius: var(--kern-r-lg);
|
|
247
|
+
overflow: hidden;
|
|
248
|
+
}
|
|
249
|
+
.dow {
|
|
250
|
+
padding: 7px 8px;
|
|
251
|
+
background: var(--kern-surface-header);
|
|
252
|
+
border-block-end: 1px solid var(--kern-border);
|
|
253
|
+
font-size: 11px;
|
|
254
|
+
font-weight: 600;
|
|
255
|
+
letter-spacing: 0.06em;
|
|
256
|
+
text-transform: uppercase;
|
|
257
|
+
color: var(--kern-ink-450);
|
|
258
|
+
}
|
|
259
|
+
:global([dir='rtl']) .dow {
|
|
260
|
+
letter-spacing: 0;
|
|
261
|
+
text-transform: none;
|
|
262
|
+
}
|
|
263
|
+
.day {
|
|
264
|
+
min-height: 96px;
|
|
265
|
+
padding: 5px 6px 8px;
|
|
266
|
+
border-block-end: 1px solid var(--kern-border-hairline);
|
|
267
|
+
border-inline-end: 1px solid var(--kern-border-hairline);
|
|
268
|
+
display: flex;
|
|
269
|
+
flex-direction: column;
|
|
270
|
+
gap: 4px;
|
|
271
|
+
}
|
|
272
|
+
/* Muted with a colour, never with opacity — opacity fades the text against the page. */
|
|
273
|
+
.day.out .num {
|
|
274
|
+
color: var(--kern-ink-350);
|
|
275
|
+
}
|
|
276
|
+
.day.out {
|
|
277
|
+
background: var(--kern-surface-header);
|
|
278
|
+
}
|
|
279
|
+
.day.today .num {
|
|
280
|
+
background: var(--kern-ink-900);
|
|
281
|
+
color: var(--kern-ink-inverse);
|
|
282
|
+
}
|
|
283
|
+
.num {
|
|
284
|
+
align-self: flex-start;
|
|
285
|
+
min-width: 20px;
|
|
286
|
+
height: 20px;
|
|
287
|
+
padding: 0 5px;
|
|
288
|
+
border-radius: var(--kern-r-full);
|
|
289
|
+
display: inline-grid;
|
|
290
|
+
place-items: center;
|
|
291
|
+
font-size: 11.5px;
|
|
292
|
+
color: var(--kern-ink-550);
|
|
293
|
+
}
|
|
294
|
+
.event {
|
|
295
|
+
display: flex;
|
|
296
|
+
align-items: center;
|
|
297
|
+
gap: 2px;
|
|
298
|
+
min-height: 28px;
|
|
299
|
+
padding-inline: 7px 1px;
|
|
300
|
+
padding-block: 1px;
|
|
301
|
+
border-radius: var(--kern-r-md);
|
|
302
|
+
background: var(--kern-accent-tint);
|
|
303
|
+
color: var(--kern-accent-deep);
|
|
304
|
+
font-size: 12px;
|
|
305
|
+
cursor: grab;
|
|
306
|
+
}
|
|
307
|
+
.ev-title {
|
|
308
|
+
flex: 1;
|
|
309
|
+
min-width: 0;
|
|
310
|
+
overflow: hidden;
|
|
311
|
+
text-overflow: ellipsis;
|
|
312
|
+
white-space: nowrap;
|
|
313
|
+
}
|
|
314
|
+
.undated {
|
|
315
|
+
margin-block-start: 18px;
|
|
316
|
+
}
|
|
317
|
+
.undated h3 {
|
|
318
|
+
margin: 0 0 8px;
|
|
319
|
+
font-size: 12px;
|
|
320
|
+
font-weight: 600;
|
|
321
|
+
color: var(--kern-ink-450);
|
|
322
|
+
}
|
|
323
|
+
.undated ul {
|
|
324
|
+
list-style: none;
|
|
325
|
+
margin: 0;
|
|
326
|
+
padding: 0;
|
|
327
|
+
display: flex;
|
|
328
|
+
flex-wrap: wrap;
|
|
329
|
+
gap: 6px;
|
|
330
|
+
}
|
|
331
|
+
.undated li {
|
|
332
|
+
display: flex;
|
|
333
|
+
align-items: center;
|
|
334
|
+
gap: 2px;
|
|
335
|
+
max-width: 260px;
|
|
336
|
+
padding-inline: 9px 2px;
|
|
337
|
+
padding-block: 2px;
|
|
338
|
+
border-radius: var(--kern-r-md);
|
|
339
|
+
background: var(--kern-surface-chip);
|
|
340
|
+
color: var(--kern-ink-550);
|
|
341
|
+
font-size: 12.5px;
|
|
342
|
+
}
|
|
343
|
+
.pad {
|
|
344
|
+
padding: 28px;
|
|
345
|
+
}
|
|
346
|
+
</style>
|