@kernhq/module-quire 0.14.0 → 0.16.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 (74) 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 +84 -1
  19. package/dist/server/export/markdown.js.map +1 -1
  20. package/dist/server/render.d.ts +190 -1
  21. package/dist/server/render.d.ts.map +1 -1
  22. package/dist/server/render.js +413 -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/objects.d.ts +61 -0
  38. package/dist/server/services/objects.d.ts.map +1 -0
  39. package/dist/server/services/objects.js +110 -0
  40. package/dist/server/services/objects.js.map +1 -0
  41. package/dist/server/services/publications.d.ts +2 -1
  42. package/dist/server/services/publications.d.ts.map +1 -1
  43. package/dist/server/services/publications.js +43 -4
  44. package/dist/server/services/publications.js.map +1 -1
  45. package/dist/server/services/templates.d.ts +135 -0
  46. package/dist/server/services/templates.d.ts.map +1 -0
  47. package/dist/server/services/templates.js +897 -0
  48. package/dist/server/services/templates.js.map +1 -0
  49. package/dist/server/services/unfurl.d.ts +154 -0
  50. package/dist/server/services/unfurl.d.ts.map +1 -0
  51. package/dist/server/services/unfurl.js +593 -0
  52. package/dist/server/services/unfurl.js.map +1 -0
  53. package/dist/server/services/versions.d.ts +12 -0
  54. package/dist/server/services/versions.d.ts.map +1 -1
  55. package/dist/server/services/versions.js +3 -1
  56. package/dist/server/services/versions.js.map +1 -1
  57. package/migrations/0011_templates.sql +157 -0
  58. package/migrations/meta/_journal.json +7 -0
  59. package/package.json +5 -5
  60. package/src/client/components/NewSpaceDialog.svelte +77 -8
  61. package/src/client/components/PageEditor.svelte +80 -0
  62. package/src/client/components/PagePicker.svelte +264 -0
  63. package/src/client/components/SaveAsTemplateDialog.svelte +502 -0
  64. package/src/client/components/SidebarSpaces.svelte +33 -1
  65. package/src/client/components/TemplatePicker.svelte +437 -0
  66. package/src/client/i18n.ts +327 -0
  67. package/src/client/index.ts +19 -0
  68. package/src/client/mock.ts +274 -0
  69. package/src/client/pages/PageView.svelte +50 -0
  70. package/src/client/pages/SpacePage.svelte +20 -4
  71. package/src/client/query.ts +17 -0
  72. package/src/contract/models.ts +191 -0
  73. package/src/contract/permissions.ts +33 -0
  74. package/src/contract/router.ts +228 -0
@@ -0,0 +1,502 @@
1
+ <script lang="ts">
2
+ /**
3
+ * Saving what is on the screen so it can be made again.
4
+ *
5
+ * Two things here are worth knowing before changing anything.
6
+ *
7
+ * **The body is never sent.** The dialog names a *source* — this page, or this whole space — and the
8
+ * server reads the prose itself. A client that could post a document would make "save as a template"
9
+ * a way to write arbitrary text into something everybody in the space is then offered.
10
+ *
11
+ * **"Replace a template Kern ships" is a real list, not a checkbox.** The starters are constants in
12
+ * the module; a workspace edits one by writing a row that carries its key and stands in its place.
13
+ * The options come from `templates.list` — which is where the starters' translated names live — and
14
+ * a starter this workspace has already replaced is not in the list, because the second override is a
15
+ * conflict rather than a second entry.
16
+ */
17
+ import {
18
+ Button,
19
+ Dialog,
20
+ Field,
21
+ Icon,
22
+ IconButton,
23
+ Input,
24
+ SegmentedControl,
25
+ Select,
26
+ Switch,
27
+ Textarea,
28
+ } from '@kernhq/ui'
29
+ import { createQuery, useQueryClient } from '@tanstack/svelte-query'
30
+ import { untrack } from 'svelte'
31
+ import { getQuireApi } from '../api-instance.js'
32
+ import { t } from '../i18n.js'
33
+ import {
34
+ TEMPLATE_BUILT_IN_VARIABLES,
35
+ type Template,
36
+ type TemplateKind,
37
+ type TemplateStarterKey,
38
+ type TemplateVariable,
39
+ type TemplateVariableType,
40
+ } from '../index.js'
41
+ import { quireKeys } from '../query.js'
42
+
43
+ interface Props {
44
+ open: boolean
45
+ workspaceId: string
46
+ spaceId: string
47
+ spaceName: string
48
+ /** the page whose prose becomes the body, for a page template */
49
+ pageId: string
50
+ pageTitle: string
51
+ onSaved?: (template: Template) => void
52
+ }
53
+ let { open = $bindable(false), workspaceId, spaceId, spaceName, pageId, pageTitle, onSaved }: Props = $props()
54
+
55
+ const api = getQuireApi()
56
+ const client = useQueryClient()
57
+
58
+ let kind = $state<TemplateKind>('page')
59
+ let name = $state('')
60
+ let description = $state('')
61
+ let scope = $state<'space' | 'workspace'>('space')
62
+ let replaces = $state('')
63
+ let fields = $state<TemplateVariable[]>([])
64
+ let error = $state<string | null>(null)
65
+ /** Guarded in the same tick as the click; `disabled` on the button arrives a render too late. */
66
+ let busy = $state(false)
67
+
68
+ /**
69
+ * The starters still available to replace, with the names the server translated.
70
+ *
71
+ * Read from the picker's own query rather than from `TEMPLATE_STARTER_KEYS`, because a key is not a
72
+ * name: the starters' words are a table on the server, resolved per reader, and a client spelling
73
+ * them itself would be a sixth copy in one language.
74
+ */
75
+ const startersQuery = createQuery(() => ({
76
+ queryKey: quireKeys.templates(workspaceId, 'page', spaceId),
77
+ enabled: open && Boolean(workspaceId && spaceId),
78
+ queryFn: () => api.templates.list({ workspaceId, kind: 'page', spaceId }),
79
+ }))
80
+ const replaceable = $derived(
81
+ (startersQuery.data ?? []).filter((choice) => choice.builtIn && choice.id === null && choice.key),
82
+ )
83
+
84
+ /**
85
+ * Save as a new template, or take an existing one's body from this page again.
86
+ *
87
+ * The second is not a nicety. A template's prose drifts — somebody improves the meeting note they
88
+ * are looking at and wants everybody's next one to start from it — and without this the only way to
89
+ * express that is to save a second template with the same name and delete the first, which loses
90
+ * whichever of the two other people had already started using.
91
+ */
92
+ let target = $state('')
93
+ const editable = $derived((startersQuery.data ?? []).filter((choice) => choice.id))
94
+
95
+ /**
96
+ * The chosen template in full, which is where its variables come from.
97
+ *
98
+ * `templates.list` deliberately carries no body — thirty documents to draw thirty names — so
99
+ * "replace this one" needs the row itself before the form can show what it currently asks for.
100
+ */
101
+ const chosenQuery = createQuery(() => ({
102
+ queryKey: quireKeys.template(workspaceId, target),
103
+ enabled: open && Boolean(workspaceId && target),
104
+ queryFn: () => api.templates.get({ workspaceId, templateId: target }),
105
+ }))
106
+
107
+ const NAME_PATTERN = /^[a-z][a-z0-9_]*$/
108
+
109
+ /** Why this field cannot be saved, or null. Shown under the field rather than at submit time. */
110
+ function problemWith(field: TemplateVariable, at: number): string | null {
111
+ if (!field.name) return null
112
+ if (!NAME_PATTERN.test(field.name)) return t('template_field_name_invalid')
113
+ if ((TEMPLATE_BUILT_IN_VARIABLES as readonly string[]).includes(field.name))
114
+ return t('template_field_name_reserved')
115
+ if (fields.some((other, index) => index !== at && other.name === field.name))
116
+ return t('template_field_name_twice')
117
+ return null
118
+ }
119
+
120
+ const valid = $derived(
121
+ name.trim().length > 0 &&
122
+ fields.every(
123
+ (field, at) => field.name.length > 0 && field.label.length > 0 && problemWith(field, at) === null,
124
+ ),
125
+ )
126
+
127
+ function addField() {
128
+ fields = [...fields, { name: '', label: '', type: 'text', options: [], default: null, required: false }]
129
+ }
130
+
131
+ function patchField(at: number, patch: Partial<TemplateVariable>) {
132
+ fields = fields.map((field, index) => (index === at ? { ...field, ...patch } : field))
133
+ }
134
+
135
+ function removeField(at: number) {
136
+ fields = fields.filter((_, index) => index !== at)
137
+ }
138
+
139
+ function reset() {
140
+ kind = 'page'
141
+ name = ''
142
+ description = ''
143
+ scope = 'space'
144
+ replaces = ''
145
+ target = ''
146
+ fields = []
147
+ error = null
148
+ }
149
+
150
+ async function submit() {
151
+ if (!valid || busy) return
152
+ busy = true
153
+ error = null
154
+ try {
155
+ const template = target
156
+ ? await api.templates.update({
157
+ workspaceId,
158
+ templateId: target,
159
+ name: name.trim(),
160
+ description: description.trim(),
161
+ variables: fields,
162
+ /*
163
+ * Three-valued, like `publications.update`'s password: sending it re-reads the body, and
164
+ * leaving it out would change the name and quietly keep the old prose.
165
+ *
166
+ * Always this page, never the space, because the list this target came from is
167
+ * `kind: 'page'` — a space template is not offered here, and the server would resolve
168
+ * `sourceId` as a space id for one.
169
+ */
170
+ sourceId: pageId,
171
+ })
172
+ : await api.templates.createFromPage({
173
+ workspaceId,
174
+ kind,
175
+ // The page for a page template, the space for a space template — `kind` says which.
176
+ sourceId: kind === 'space' ? spaceId : pageId,
177
+ // A space template makes a space, so it cannot live inside one.
178
+ spaceId: kind === 'space' || scope === 'workspace' ? null : spaceId,
179
+ name: name.trim(),
180
+ description: description.trim(),
181
+ icon: null,
182
+ variables: fields,
183
+ // The Select's empty option means "a new template"; every other value is a starter's key.
184
+ key: (replaces || null) as TemplateStarterKey | null,
185
+ })
186
+ // Both lists: the space's picker and the workspace-wide one a "New space" picker reads.
187
+ await client.invalidateQueries({ queryKey: ['quire', 'template', workspaceId] })
188
+ open = false
189
+ reset()
190
+ onSaved?.(template)
191
+ } catch (err) {
192
+ error = err instanceof Error ? err.message : String(err)
193
+ } finally {
194
+ busy = false
195
+ }
196
+ }
197
+
198
+ const TYPES: Array<{ value: TemplateVariableType; label: string }> = $derived([
199
+ { value: 'text', label: t('template_type_text') },
200
+ { value: 'number', label: t('template_type_number') },
201
+ { value: 'date', label: t('template_type_date') },
202
+ { value: 'select', label: t('template_type_select') },
203
+ { value: 'user', label: t('template_type_user') },
204
+ ])
205
+
206
+ /**
207
+ * The page's own title is the obvious name for a template made from it, until somebody types one.
208
+ *
209
+ * `name` is read and written inside `untrack`, so this effect depends on `open` and `pageTitle` and
210
+ * on nothing it writes. Without that it is its own trigger: it re-runs on every keystroke in the
211
+ * name field, and the only reason it settles rather than looping is the `=== ''` guard — a
212
+ * termination condition that is one edit away from not being there.
213
+ */
214
+ $effect(() => {
215
+ if (!open) return
216
+ const suggestion = pageTitle
217
+ untrack(() => {
218
+ if (name === '') name = suggestion
219
+ })
220
+ })
221
+
222
+ /**
223
+ * Choosing a template to replace fills the form with what it says now.
224
+ *
225
+ * Same rule as above: the row is tracked, and everything the effect writes is read and written
226
+ * inside `untrack`, so typing in the name field does not re-run it and overwrite what was typed.
227
+ */
228
+ $effect(() => {
229
+ const chosen = chosenQuery.data
230
+ if (!chosen || chosen.id !== target) return
231
+ untrack(() => {
232
+ name = chosen.name
233
+ description = chosen.description
234
+ fields = chosen.variables.map((variable) => ({ ...variable }))
235
+ })
236
+ })
237
+ </script>
238
+
239
+ <Dialog
240
+ bind:open
241
+ size="lg"
242
+ title={t('template_save_title')}
243
+ description={t('template_save_desc')}
244
+ onOpenChange={(o: boolean) => {
245
+ if (!o) reset()
246
+ }}
247
+ >
248
+ <div class="form">
249
+ <!--
250
+ Only once this workspace has a template to update. Before that "a new template" is the only
251
+ answer, and a menu with one entry is a question nobody should have to read.
252
+ -->
253
+ {#if editable.length > 0}
254
+ <Field label={t('template_target')}>
255
+ {#snippet children(id: string)}
256
+ <Select
257
+ {id}
258
+ ariaLabel={t('template_target')}
259
+ value={target}
260
+ options={[
261
+ { value: '', label: t('template_target_new') },
262
+ ...editable.map((choice) => ({ value: choice.id as string, label: choice.name })),
263
+ ]}
264
+ onValueChange={(v: string) => {
265
+ target = v
266
+ // Back to "a new template" starts from a clean form rather than from whatever the
267
+ // template that was selected happened to say.
268
+ if (!v) {
269
+ name = pageTitle
270
+ description = ''
271
+ fields = []
272
+ }
273
+ }}
274
+ />
275
+ {/snippet}
276
+ </Field>
277
+ {/if}
278
+
279
+ <!--
280
+ What a template *is* — a page or a space — is fixed the day it is made. Changing it would
281
+ change the shape of its body, so updating an existing one does not offer the choice.
282
+ -->
283
+ {#if !target}
284
+ <Field label={t('template_source')}>
285
+ {#snippet children(_id: string)}
286
+ <SegmentedControl
287
+ label={t('template_source')}
288
+ value={kind}
289
+ items={[
290
+ { value: 'page', label: t('template_source_page'), icon: 'file-text' },
291
+ { value: 'space', label: t('template_source_space'), icon: 'scroll-text' },
292
+ ]}
293
+ onValueChange={(v: string) => (kind = v as TemplateKind)}
294
+ />
295
+ {/snippet}
296
+ </Field>
297
+ {/if}
298
+
299
+ <Field label={t('template_name')}>
300
+ {#snippet children(id: string)}
301
+ <Input {id} bind:value={name} placeholder={t('template_name_hint')} />
302
+ {/snippet}
303
+ </Field>
304
+
305
+ <Field label={t('template_desc_label')}>
306
+ {#snippet children(id: string)}
307
+ <Textarea {id} bind:value={description} rows={2} />
308
+ {/snippet}
309
+ </Field>
310
+
311
+ <!--
312
+ A space template makes a space, so "only in this space" is not a thing it can be. The control
313
+ is left out rather than shown and ignored: a disabled field somebody cannot act on is a
314
+ question they have to work out the answer to.
315
+ -->
316
+ {#if kind === 'page' && !target}
317
+ <Field label={t('template_scope')}>
318
+ {#snippet children(id: string)}
319
+ <Select
320
+ {id}
321
+ ariaLabel={t('template_scope')}
322
+ value={scope}
323
+ options={[
324
+ { value: 'space', label: t('template_scope_space', { space: spaceName }) },
325
+ { value: 'workspace', label: t('template_scope_workspace') },
326
+ ]}
327
+ onValueChange={(v: string) => (scope = v as 'space' | 'workspace')}
328
+ />
329
+ {/snippet}
330
+ </Field>
331
+
332
+ {#if replaceable.length > 0}
333
+ <Field label={t('template_replace')} hint={t('template_replace_hint')}>
334
+ {#snippet children(id: string)}
335
+ <Select
336
+ {id}
337
+ ariaLabel={t('template_replace')}
338
+ value={replaces}
339
+ options={[
340
+ { value: '', label: t('template_replace_none') },
341
+ ...replaceable.map((choice) => ({ value: choice.key as string, label: choice.name })),
342
+ ]}
343
+ onValueChange={(v: string) => (replaces = v)}
344
+ />
345
+ {/snippet}
346
+ </Field>
347
+ {/if}
348
+ {/if}
349
+
350
+ <section class="fields">
351
+ <div class="head">
352
+ <span class="title">{t('template_fields')}</span>
353
+ <Button variant="ghost" size="sm" icon="plus" onclick={addField}>{t('template_field_add')}</Button>
354
+ </div>
355
+ <p class="hint">{t('template_fields_hint')}</p>
356
+
357
+ {#each fields as field, at (at)}
358
+ <div class="field">
359
+ <div class="grid">
360
+ <Field label={t('template_field_name')} error={problemWith(field, at)}>
361
+ {#snippet children(id: string)}
362
+ <Input
363
+ {id}
364
+ value={field.name}
365
+ oninput={(e: Event) =>
366
+ patchField(at, { name: (e.currentTarget as HTMLInputElement).value.toLowerCase() })}
367
+ />
368
+ {/snippet}
369
+ </Field>
370
+ <Field label={t('template_field_label')}>
371
+ {#snippet children(id: string)}
372
+ <Input
373
+ {id}
374
+ value={field.label}
375
+ oninput={(e: Event) =>
376
+ patchField(at, { label: (e.currentTarget as HTMLInputElement).value })}
377
+ />
378
+ {/snippet}
379
+ </Field>
380
+ <Field label={t('template_field_type')}>
381
+ {#snippet children(id: string)}
382
+ <Select
383
+ {id}
384
+ ariaLabel={t('template_field_type')}
385
+ value={field.type}
386
+ options={TYPES}
387
+ onValueChange={(v: string) => patchField(at, { type: v as TemplateVariableType })}
388
+ />
389
+ {/snippet}
390
+ </Field>
391
+ </div>
392
+
393
+ {#if field.type === 'select'}
394
+ <Field label={t('template_field_options')} hint={t('template_field_options_hint')}>
395
+ {#snippet children(id: string)}
396
+ <Textarea
397
+ {id}
398
+ rows={3}
399
+ value={field.options.join('\n')}
400
+ oninput={(e: Event) =>
401
+ patchField(at, {
402
+ options: (e.currentTarget as HTMLTextAreaElement).value
403
+ .split('\n')
404
+ .map((line) => line.trim())
405
+ .filter((line) => line.length > 0),
406
+ })}
407
+ />
408
+ {/snippet}
409
+ </Field>
410
+ {/if}
411
+
412
+ <div class="foot">
413
+ <Switch
414
+ checked={field.required}
415
+ label={t('template_field_required')}
416
+ onCheckedChange={(v: boolean) => patchField(at, { required: v })}
417
+ />
418
+ <IconButton
419
+ icon="trash-2"
420
+ variant="ghost"
421
+ size={30}
422
+ label={t('template_field_remove', { label: field.label || field.name })}
423
+ onclick={() => removeField(at)}
424
+ />
425
+ </div>
426
+ </div>
427
+ {/each}
428
+ </section>
429
+
430
+ {#if error}
431
+ <p class="error" role="alert"><Icon name="triangle-alert" size={14} /> {error}</p>
432
+ {/if}
433
+ </div>
434
+
435
+ {#snippet footer()}
436
+ <Button variant="secondary" onclick={() => (open = false)}>{t('cancel')}</Button>
437
+ <Button aria-busy={busy} disabled={!valid} onclick={submit}>
438
+ {busy ? t('template_saving') : target ? t('template_update') : t('template_save')}
439
+ </Button>
440
+ {/snippet}
441
+ </Dialog>
442
+
443
+ <style>
444
+ .form {
445
+ display: flex;
446
+ flex-direction: column;
447
+ gap: 14px;
448
+ }
449
+ .fields {
450
+ display: flex;
451
+ flex-direction: column;
452
+ gap: 10px;
453
+ padding-top: 6px;
454
+ border-top: 1px solid var(--kern-border-hairline);
455
+ }
456
+ .head {
457
+ display: flex;
458
+ align-items: center;
459
+ justify-content: space-between;
460
+ gap: 12px;
461
+ }
462
+ .title {
463
+ font-size: 13px;
464
+ font-weight: 500;
465
+ color: var(--kern-ink-700);
466
+ }
467
+ .hint {
468
+ margin: 0;
469
+ font-size: 12px;
470
+ color: var(--kern-ink-450);
471
+ }
472
+ .field {
473
+ display: flex;
474
+ flex-direction: column;
475
+ gap: 10px;
476
+ padding: 12px;
477
+ border: 1px solid var(--kern-border);
478
+ border-radius: var(--kern-r-md);
479
+ background: var(--kern-surface-raised);
480
+ }
481
+ .grid {
482
+ display: grid;
483
+ /* `auto-fit` rather than three fixed columns: the dialog is narrow on a phone, and three inputs
484
+ side by side there is three inputs nobody can type in. */
485
+ grid-template-columns: repeat(auto-fit, minmax(160px, 1fr));
486
+ gap: 10px;
487
+ }
488
+ .foot {
489
+ display: flex;
490
+ align-items: center;
491
+ justify-content: space-between;
492
+ gap: 12px;
493
+ }
494
+ .error {
495
+ display: flex;
496
+ align-items: center;
497
+ gap: 6px;
498
+ margin: 0;
499
+ font-size: 13px;
500
+ color: var(--kern-danger);
501
+ }
502
+ </style>
@@ -27,6 +27,7 @@ import LabelManager from './LabelManager.svelte'
27
27
  import PageTreeRow from './PageTreeRow.svelte'
28
28
  import SidebarFavorites from './SidebarFavorites.svelte'
29
29
  import SidebarRecents from './SidebarRecents.svelte'
30
+ import TemplatePicker from './TemplatePicker.svelte'
30
31
 
31
32
  /**
32
33
  * Quire's spaces and pages, in the application sidebar (DESIGN.md §2.3).
@@ -270,7 +271,24 @@ const spaceMenu = $derived.by((): MenuItem[] => {
270
271
 
271
272
  let creating = $state(false)
272
273
 
273
- async function createPage(parentId: string | null) {
274
+ /**
275
+ * "New page" opens the picker; the picker's first row makes a blank one.
276
+ *
277
+ * The indirection is what keeps a blank page cheap. The picker holds focus on **Blank page**, so
278
+ * pressing New page and then Enter is the whole gesture — one keystroke more than before, and the
279
+ * five templates are there for the times somebody wants one.
280
+ */
281
+ let pickerOpen = $state(false)
282
+ let pickerParent = $state<string | null>(null)
283
+
284
+ function createPage(parentId: string | null) {
285
+ if (!activeSpace) return
286
+ pickerParent = parentId
287
+ pickerOpen = true
288
+ }
289
+
290
+ /** What the picker's blank row does, and what "New page" used to do on its own. */
291
+ async function createBlankPage(parentId: string | null) {
274
292
  if (!activeSpace || creating) return
275
293
  creating = true
276
294
  try {
@@ -290,6 +308,12 @@ async function createPage(parentId: string | null) {
290
308
  creating = false
291
309
  }
292
310
  }
311
+
312
+ /** A page made from a template lands in the tree exactly where a blank one would have. */
313
+ function madeFromTemplate(pageId: string | null) {
314
+ if (pickerParent) expanded = new Set(expanded).add(pickerParent)
315
+ if (pageId) openPage(pageId)
316
+ }
293
317
  </script>
294
318
 
295
319
  <div class="wrap">
@@ -507,6 +531,14 @@ async function createPage(parentId: string | null) {
507
531
  {#if activeSpace}
508
532
  <ExportDialog bind:open={exportOpen} {workspaceId} spaceId={activeSpace.id} />
509
533
  <ImportDialog bind:open={importOpen} {workspaceId} space={activeSpace} />
534
+ <TemplatePicker
535
+ bind:open={pickerOpen}
536
+ {workspaceId}
537
+ spaceId={activeSpace.id}
538
+ parentId={pickerParent}
539
+ onBlank={() => void createBlankPage(pickerParent)}
540
+ onMade={(result) => madeFromTemplate(result.pageId)}
541
+ />
510
542
  {/if}
511
543
 
512
544
  <style>