@kernhq/module-quire 0.14.0 → 0.15.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (66) hide show
  1. package/dist/contract/models.d.ts +209 -0
  2. package/dist/contract/models.d.ts.map +1 -1
  3. package/dist/contract/models.js +151 -0
  4. package/dist/contract/models.js.map +1 -1
  5. package/dist/contract/permissions.d.ts.map +1 -1
  6. package/dist/contract/permissions.js +32 -0
  7. package/dist/contract/permissions.js.map +1 -1
  8. package/dist/contract/properties.d.ts +3 -3
  9. package/dist/contract/router.d.ts +509 -8
  10. package/dist/contract/router.d.ts.map +1 -1
  11. package/dist/contract/router.js +211 -1
  12. package/dist/contract/router.js.map +1 -1
  13. package/dist/server/_impl.d.ts +573 -1269
  14. package/dist/server/_impl.d.ts.map +1 -1
  15. package/dist/server/_impl.js +180 -0
  16. package/dist/server/_impl.js.map +1 -1
  17. package/dist/server/export/markdown.d.ts.map +1 -1
  18. package/dist/server/export/markdown.js +40 -1
  19. package/dist/server/export/markdown.js.map +1 -1
  20. package/dist/server/render.d.ts +158 -0
  21. package/dist/server/render.d.ts.map +1 -1
  22. package/dist/server/render.js +240 -0
  23. package/dist/server/render.js.map +1 -1
  24. package/dist/server/schema.d.ts +260 -1
  25. package/dist/server/schema.d.ts.map +1 -1
  26. package/dist/server/schema.js +108 -1
  27. package/dist/server/schema.js.map +1 -1
  28. package/dist/server/services/databases.d.ts +5 -5
  29. package/dist/server/services/index.d.ts +6 -0
  30. package/dist/server/services/index.d.ts.map +1 -1
  31. package/dist/server/services/index.js +27 -3
  32. package/dist/server/services/index.js.map +1 -1
  33. package/dist/server/services/macros.d.ts +83 -0
  34. package/dist/server/services/macros.d.ts.map +1 -0
  35. package/dist/server/services/macros.js +488 -0
  36. package/dist/server/services/macros.js.map +1 -0
  37. package/dist/server/services/publications.d.ts +2 -1
  38. package/dist/server/services/publications.d.ts.map +1 -1
  39. package/dist/server/services/publications.js +43 -4
  40. package/dist/server/services/publications.js.map +1 -1
  41. package/dist/server/services/templates.d.ts +135 -0
  42. package/dist/server/services/templates.d.ts.map +1 -0
  43. package/dist/server/services/templates.js +897 -0
  44. package/dist/server/services/templates.js.map +1 -0
  45. package/dist/server/services/versions.d.ts +12 -0
  46. package/dist/server/services/versions.d.ts.map +1 -1
  47. package/dist/server/services/versions.js +3 -1
  48. package/dist/server/services/versions.js.map +1 -1
  49. package/migrations/0011_templates.sql +157 -0
  50. package/migrations/meta/_journal.json +7 -0
  51. package/package.json +5 -5
  52. package/src/client/components/NewSpaceDialog.svelte +77 -8
  53. package/src/client/components/PageEditor.svelte +80 -0
  54. package/src/client/components/PagePicker.svelte +264 -0
  55. package/src/client/components/SaveAsTemplateDialog.svelte +502 -0
  56. package/src/client/components/SidebarSpaces.svelte +33 -1
  57. package/src/client/components/TemplatePicker.svelte +437 -0
  58. package/src/client/i18n.ts +327 -0
  59. package/src/client/index.ts +19 -0
  60. package/src/client/mock.ts +274 -0
  61. package/src/client/pages/PageView.svelte +50 -0
  62. package/src/client/pages/SpacePage.svelte +20 -4
  63. package/src/client/query.ts +17 -0
  64. package/src/contract/models.ts +191 -0
  65. package/src/contract/permissions.ts +33 -0
  66. package/src/contract/router.ts +228 -0
@@ -0,0 +1,437 @@
1
+ <script lang="ts">
2
+ /**
3
+ * What comes up when somebody presses "New page".
4
+ *
5
+ * **A blank page has to stay the fastest thing to make**, and that is the constraint the whole
6
+ * component is arranged around. Putting a picker in front of "New page" is exactly the kind of
7
+ * change that makes a product feel slower — every page anybody ever writes now costs a dialog — so
8
+ * blank is the *first* row, it holds focus the moment the dialog opens, and Enter makes it. One
9
+ * keystroke, and Escape is the other one that also costs nothing.
10
+ *
11
+ * Two steps, and the second only appears when it has something to ask. A template with no variables
12
+ * makes its page on the first press; a template with variables asks for them, because a form that
13
+ * appears for every template would be the same tax the blank row exists to avoid.
14
+ */
15
+ import {
16
+ Button,
17
+ Dialog,
18
+ EmptyState,
19
+ Field,
20
+ formatDate,
21
+ Icon,
22
+ IconButton,
23
+ Input,
24
+ Select,
25
+ Skeleton,
26
+ } from '@kernhq/ui'
27
+ import { createQuery, useQueryClient } from '@tanstack/svelte-query'
28
+ import { getQuireApi } from '../api-instance.js'
29
+ import { t } from '../i18n.js'
30
+ import type { TemplateChoice, TemplateResult, TemplateStarterKey } from '../index.js'
31
+ import { canQuire } from '../permissions.js'
32
+ import { quireKeys } from '../query.js'
33
+ import ConfirmDialog from './ConfirmDialog.svelte'
34
+
35
+ interface Props {
36
+ open: boolean
37
+ workspaceId: string
38
+ spaceId: string
39
+ /** where the new page hangs — null makes it a top-level page of the space */
40
+ parentId?: string | null
41
+ /** the sibling it lands behind; null puts it first */
42
+ afterId?: string | null
43
+ /** a page was made from a template */
44
+ onMade?: (result: TemplateResult) => void
45
+ /** the blank row, which does not go through `instantiate` at all */
46
+ onBlank?: () => void
47
+ }
48
+ let {
49
+ open = $bindable(false),
50
+ workspaceId,
51
+ spaceId,
52
+ parentId = null,
53
+ afterId = null,
54
+ onMade,
55
+ onBlank,
56
+ }: Props = $props()
57
+
58
+ const api = getQuireApi()
59
+ const client = useQueryClient()
60
+
61
+ const query = createQuery(() => ({
62
+ queryKey: quireKeys.templates(workspaceId, 'page', spaceId),
63
+ enabled: open && Boolean(workspaceId && spaceId),
64
+ queryFn: () => api.templates.list({ workspaceId, kind: 'page', spaceId }),
65
+ }))
66
+ const choices = $derived(query.data ?? [])
67
+
68
+ /** The template being filled in, or null while the list is showing. */
69
+ let asking = $state<TemplateChoice | null>(null)
70
+ let answers = $state<Record<string, string>>({})
71
+ let error = $state<string | null>(null)
72
+ /**
73
+ * A plain flag rather than `mutation.isPending`.
74
+ *
75
+ * `disabled={pending}` reaches the button on the next render, and two quick clicks are one render
76
+ * apart — so a double-click on a template would make two pages, both of which somebody then has to
77
+ * find and delete.
78
+ */
79
+ let busy = $state(false)
80
+ /**
81
+ * Which row is being made, so `aria-busy` lands on that one rather than on all of them.
82
+ *
83
+ * `aria-busy` on every row would tell a screen-reader user that the whole list is working, which is
84
+ * both untrue and unhelpful — the one thing worth saying is that the row they just pressed is.
85
+ */
86
+ let pending = $state<string | null>(null)
87
+
88
+ /** The blank row, so the dialog can hand it focus rather than the browser guessing. */
89
+ let blankRow = $state<HTMLButtonElement | null>(null)
90
+
91
+ /** A starter is addressed by key and a saved template by id, so a row's identity is one or other. */
92
+ const idOf = (choice: TemplateChoice) => choice.id ?? (choice.key as string)
93
+
94
+ /**
95
+ * The row a confirmation is open about.
96
+ *
97
+ * Deleting a template and resetting an overridden starter are **the same act** — the row goes — so
98
+ * they are one code path wearing whichever word is true. Confirmed rather than done on the press,
99
+ * because it is not undoable and because the thing people most need told is what it does *not* do:
100
+ * the pages already made from it stay exactly where they are.
101
+ */
102
+ let removing = $state<TemplateChoice | null>(null)
103
+
104
+ async function removeTemplate(choice: TemplateChoice) {
105
+ if (!choice.id) return
106
+ try {
107
+ await api.templates.remove({ workspaceId, templateId: choice.id })
108
+ await client.invalidateQueries({ queryKey: ['quire', 'template', workspaceId] })
109
+ } catch (err) {
110
+ error = err instanceof Error ? err.message : String(err)
111
+ }
112
+ }
113
+
114
+ function reset() {
115
+ asking = null
116
+ answers = {}
117
+ error = null
118
+ }
119
+
120
+ function choose(choice: TemplateChoice) {
121
+ error = null
122
+ if (choice.variables.length === 0) {
123
+ void make(choice)
124
+ return
125
+ }
126
+ answers = Object.fromEntries(choice.variables.map((v) => [v.name, v.default ?? '']))
127
+ asking = choice
128
+ }
129
+
130
+ const missing = $derived(
131
+ (asking?.variables ?? []).filter((v) => v.required && !(answers[v.name] ?? '').trim()),
132
+ )
133
+
134
+ async function make(choice: TemplateChoice) {
135
+ if (busy) return
136
+ busy = true
137
+ pending = idOf(choice)
138
+ error = null
139
+ try {
140
+ const result = await api.templates.instantiate({
141
+ workspaceId,
142
+ templateId: choice.id,
143
+ // A shipped starter has no row, so its key is what addresses it. Exactly one of the two.
144
+ starterKey: choice.id === null ? (choice.key as TemplateStarterKey) : null,
145
+ spaceId,
146
+ parentId,
147
+ afterId,
148
+ title: '',
149
+ values: answers,
150
+ })
151
+ await client.invalidateQueries({ queryKey: quireKeys.tree(workspaceId, spaceId) })
152
+ open = false
153
+ reset()
154
+ onMade?.(result)
155
+ } catch (err) {
156
+ error = err instanceof Error ? err.message : String(err)
157
+ } finally {
158
+ busy = false
159
+ pending = null
160
+ }
161
+ }
162
+
163
+ function blank() {
164
+ if (busy) return
165
+ open = false
166
+ reset()
167
+ onBlank?.()
168
+ }
169
+ </script>
170
+
171
+ <Dialog
172
+ bind:open
173
+ size="lg"
174
+ title={asking ? t('template_fill_title') : t('template_pick_title')}
175
+ description={asking ? t('template_fill_desc') : t('template_pick_desc')}
176
+ initialFocus={() => blankRow}
177
+ onOpenChange={(o: boolean) => {
178
+ if (!o) reset()
179
+ }}
180
+ >
181
+ {#if asking}
182
+ <div class="form">
183
+ {#each asking.variables as variable (variable.name)}
184
+ <Field
185
+ label={variable.label}
186
+ required={variable.required}
187
+ error={variable.required && !(answers[variable.name] ?? '').trim()
188
+ ? t('template_required_missing')
189
+ : null}
190
+ >
191
+ {#snippet children(id: string)}
192
+ {#if variable.type === 'select'}
193
+ <Select
194
+ {id}
195
+ ariaLabel={variable.label}
196
+ placeholder={t('template_choose_option')}
197
+ value={answers[variable.name] ?? ''}
198
+ options={variable.options.map((option) => ({ value: option, label: option }))}
199
+ onValueChange={(v: string) => (answers = { ...answers, [variable.name]: v })}
200
+ />
201
+ {:else}
202
+ <!--
203
+ The type picks the control and nothing else: every answer is substituted as text, so
204
+ a date field is a date input whose value is a string like any other.
205
+ -->
206
+ <Input
207
+ {id}
208
+ type={variable.type === 'number' ? 'number' : variable.type === 'date' ? 'date' : 'text'}
209
+ value={answers[variable.name] ?? ''}
210
+ oninput={(e: Event) =>
211
+ (answers = {
212
+ ...answers,
213
+ [variable.name]: (e.currentTarget as HTMLInputElement).value,
214
+ })}
215
+ />
216
+ {/if}
217
+ {/snippet}
218
+ </Field>
219
+ {/each}
220
+ {#if error}<p class="error" role="alert">{error}</p>{/if}
221
+ </div>
222
+ {:else if query.isLoading}
223
+ <div class="list">
224
+ {#each [1, 2, 3, 4] as n (n)}
225
+ <Skeleton height="56px" />
226
+ {/each}
227
+ </div>
228
+ {:else if query.isError}
229
+ <EmptyState icon="triangle-alert" title={t('template_error')} description={t('template_error_desc')} />
230
+ {:else}
231
+ <div class="list">
232
+ <!--
233
+ First, focused, and visually the same weight as the rest: the point is that pressing Enter
234
+ the instant the dialog appears is still the fastest way to a page.
235
+ -->
236
+ <button class="row blank" type="button" bind:this={blankRow} onclick={blank}>
237
+ <span class="mark"><Icon name="file-text" size={18} /></span>
238
+ <span class="what">
239
+ <span class="name">{t('template_blank')}</span>
240
+ <span class="desc">{t('template_blank_desc')}</span>
241
+ </span>
242
+ </button>
243
+
244
+ <!--
245
+ A row is a `<div>` wrapping two controls rather than one big `<button>`, because a button
246
+ inside a button is not a thing a browser will render — the pick and the delete are siblings.
247
+ -->
248
+ {#each choices as choice (idOf(choice))}
249
+ <div class="row">
250
+ <button
251
+ class="pick"
252
+ type="button"
253
+ aria-busy={pending === idOf(choice)}
254
+ onclick={() => choose(choice)}
255
+ >
256
+ <span class="mark"><Icon name={choice.icon ?? 'file-text'} size={18} /></span>
257
+ <span class="what">
258
+ <span class="name">
259
+ {choice.name}
260
+ <!--
261
+ A starter this workspace has edited is a different thing from the shipped one, and
262
+ the only way to tell from the outside is that it has a row. Saying so is what makes
263
+ "delete it and the shipped one comes back" make sense.
264
+ -->
265
+ {#if choice.builtIn && choice.id}<span class="tag">{t('template_customised')}</span>{/if}
266
+ </span>
267
+ <span class="desc">{choice.description}</span>
268
+ </span>
269
+ {#if choice.updatedAt}
270
+ <span class="when">{formatDate(choice.updatedAt)}</span>
271
+ {/if}
272
+ </button>
273
+
274
+ <!--
275
+ The only way out of a template somebody no longer wants, and the only way back to a
276
+ shipped one that was replaced — the two are the same act on the row, so they are one
277
+ control wearing the right word. A shipped starter has no row and therefore no control.
278
+
279
+ `space.manage`, matching `templates.remove`: a template is the space's furniture.
280
+ -->
281
+ {#if choice.id && canQuire('spaceManage')}
282
+ <IconButton
283
+ icon={choice.builtIn ? 'rotate-ccw' : 'trash-2'}
284
+ variant="ghost"
285
+ size={30}
286
+ label={choice.builtIn
287
+ ? t('template_reset', { name: choice.name })
288
+ : t('template_delete', { name: choice.name })}
289
+ onclick={() => (removing = choice)}
290
+ />
291
+ {/if}
292
+ </div>
293
+ {/each}
294
+ {#if error}<p class="error" role="alert">{error}</p>{/if}
295
+ </div>
296
+ {/if}
297
+
298
+ {#snippet footer()}
299
+ {#if asking}
300
+ <Button variant="secondary" onclick={reset}>{t('template_back')}</Button>
301
+ <Button
302
+ aria-busy={busy}
303
+ disabled={missing.length > 0}
304
+ onclick={() => asking && make(asking)}
305
+ >
306
+ {t('template_create')}
307
+ </Button>
308
+ {:else}
309
+ <Button variant="secondary" onclick={() => (open = false)}>{t('cancel')}</Button>
310
+ {/if}
311
+ {/snippet}
312
+ </Dialog>
313
+
314
+ {#if removing}
315
+ <ConfirmDialog
316
+ open={true}
317
+ danger={!removing.builtIn}
318
+ title={removing.builtIn ? t('template_reset_title') : t('template_delete_title')}
319
+ body={t('template_delete_body', { name: removing.name })}
320
+ confirmLabel={removing.builtIn ? t('template_reset_confirm') : t('template_delete_confirm')}
321
+ onConfirm={async () => {
322
+ if (removing) await removeTemplate(removing)
323
+ removing = null
324
+ }}
325
+ onCancel={() => (removing = null)}
326
+ />
327
+ {/if}
328
+
329
+ <style>
330
+ .list {
331
+ display: flex;
332
+ flex-direction: column;
333
+ gap: 6px;
334
+ }
335
+ .form {
336
+ display: flex;
337
+ flex-direction: column;
338
+ gap: 14px;
339
+ }
340
+ /*
341
+ * `.row` is the frame — a `<div>` around a saved template, and the `<button>` itself for the blank
342
+ * one, which has nothing beside it. `.pick` is the part somebody presses in both cases.
343
+ */
344
+ .row {
345
+ display: flex;
346
+ align-items: center;
347
+ gap: 4px;
348
+ width: 100%;
349
+ /* A row people press: 56px tall, so it is a comfortable target on a phone as well as a pointer. */
350
+ min-height: 56px;
351
+ padding: 6px 8px;
352
+ border: 1px solid var(--kern-border);
353
+ border-radius: var(--kern-r-md);
354
+ background: var(--kern-surface);
355
+ color: var(--kern-ink-800);
356
+ /* Logical, so the icon leads the row in Persian and Arabic rather than trailing it. */
357
+ text-align: start;
358
+ }
359
+ .row:hover {
360
+ background: var(--kern-surface-hover);
361
+ border-color: var(--kern-border-hover);
362
+ }
363
+ .pick {
364
+ display: flex;
365
+ align-items: center;
366
+ gap: 12px;
367
+ flex: 1;
368
+ min-width: 0;
369
+ min-height: 44px;
370
+ padding: 4px;
371
+ border: 0;
372
+ border-radius: var(--kern-r-sm);
373
+ background: none;
374
+ color: inherit;
375
+ text-align: start;
376
+ cursor: pointer;
377
+ }
378
+ .row:focus-visible,
379
+ .pick:focus-visible {
380
+ outline: 2px solid var(--kern-ring);
381
+ outline-offset: 2px;
382
+ }
383
+ /* The blank row is the frame and the control at once, so it carries both sets of rules. */
384
+ .blank {
385
+ gap: 12px;
386
+ padding: 10px 12px;
387
+ border-color: var(--kern-border-strong);
388
+ cursor: pointer;
389
+ }
390
+ .mark {
391
+ display: grid;
392
+ place-items: center;
393
+ flex: none;
394
+ width: 34px;
395
+ height: 34px;
396
+ border-radius: var(--kern-r-sm);
397
+ background: var(--kern-surface-chip);
398
+ color: var(--kern-ink-600);
399
+ }
400
+ .what {
401
+ display: flex;
402
+ flex-direction: column;
403
+ gap: 2px;
404
+ min-width: 0;
405
+ }
406
+ .name {
407
+ display: flex;
408
+ align-items: center;
409
+ gap: 8px;
410
+ font-size: 14px;
411
+ font-weight: 500;
412
+ }
413
+ .desc {
414
+ /* Muted with a colour, never with opacity — opacity fades the text against the page. */
415
+ font-size: 12px;
416
+ color: var(--kern-ink-450);
417
+ }
418
+ .tag {
419
+ padding: 1px 6px;
420
+ border-radius: var(--kern-r-full);
421
+ background: var(--kern-accent-tint);
422
+ color: var(--kern-accent-text);
423
+ font-size: 11px;
424
+ font-weight: 500;
425
+ }
426
+ .when {
427
+ margin-inline-start: auto;
428
+ flex: none;
429
+ font-size: 11px;
430
+ color: var(--kern-ink-450);
431
+ }
432
+ .error {
433
+ margin: 0;
434
+ font-size: 13px;
435
+ color: var(--kern-danger);
436
+ }
437
+ </style>