@kernhq/module-quire 0.13.1 → 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 (123) hide show
  1. package/dist/contract/models.d.ts +394 -0
  2. package/dist/contract/models.d.ts.map +1 -1
  3. package/dist/contract/models.js +284 -0
  4. package/dist/contract/models.js.map +1 -1
  5. package/dist/contract/permissions.d.ts +14 -0
  6. package/dist/contract/permissions.d.ts.map +1 -1
  7. package/dist/contract/permissions.js +114 -0
  8. package/dist/contract/permissions.js.map +1 -1
  9. package/dist/contract/properties.d.ts +7 -7
  10. package/dist/contract/router.d.ts +1124 -96
  11. package/dist/contract/router.d.ts.map +1 -1
  12. package/dist/contract/router.js +343 -1
  13. package/dist/contract/router.js.map +1 -1
  14. package/dist/server/_impl.d.ts +983 -1168
  15. package/dist/server/_impl.d.ts.map +1 -1
  16. package/dist/server/_impl.js +308 -0
  17. package/dist/server/_impl.js.map +1 -1
  18. package/dist/server/export/html.d.ts +67 -0
  19. package/dist/server/export/html.d.ts.map +1 -0
  20. package/dist/server/export/html.js +206 -0
  21. package/dist/server/export/html.js.map +1 -0
  22. package/dist/server/export/markdown.d.ts +51 -0
  23. package/dist/server/export/markdown.d.ts.map +1 -0
  24. package/dist/server/export/markdown.js +351 -0
  25. package/dist/server/export/markdown.js.map +1 -0
  26. package/dist/server/export/pdf.d.ts +20 -0
  27. package/dist/server/export/pdf.d.ts.map +1 -0
  28. package/dist/server/export/pdf.js +91 -0
  29. package/dist/server/export/pdf.js.map +1 -0
  30. package/dist/server/export/zip.d.ts +31 -0
  31. package/dist/server/export/zip.d.ts.map +1 -0
  32. package/dist/server/export/zip.js +158 -0
  33. package/dist/server/export/zip.js.map +1 -0
  34. package/dist/server/import/csv.d.ts +77 -0
  35. package/dist/server/import/csv.d.ts.map +1 -0
  36. package/dist/server/import/csv.js +263 -0
  37. package/dist/server/import/csv.js.map +1 -0
  38. package/dist/server/import/html.d.ts +52 -0
  39. package/dist/server/import/html.d.ts.map +1 -0
  40. package/dist/server/import/html.js +472 -0
  41. package/dist/server/import/html.js.map +1 -0
  42. package/dist/server/import/markdown.d.ts +63 -0
  43. package/dist/server/import/markdown.d.ts.map +1 -0
  44. package/dist/server/import/markdown.js +692 -0
  45. package/dist/server/import/markdown.js.map +1 -0
  46. package/dist/server/import/plan.d.ts +70 -0
  47. package/dist/server/import/plan.d.ts.map +1 -0
  48. package/dist/server/import/plan.js +761 -0
  49. package/dist/server/import/plan.js.map +1 -0
  50. package/dist/server/import/ydoc.d.ts +35 -0
  51. package/dist/server/import/ydoc.d.ts.map +1 -0
  52. package/dist/server/import/ydoc.js +91 -0
  53. package/dist/server/import/ydoc.js.map +1 -0
  54. package/dist/server/import/zip.d.ts +63 -0
  55. package/dist/server/import/zip.d.ts.map +1 -0
  56. package/dist/server/import/zip.js +308 -0
  57. package/dist/server/import/zip.js.map +1 -0
  58. package/dist/server/index.d.ts.map +1 -1
  59. package/dist/server/index.js +3 -1
  60. package/dist/server/index.js.map +1 -1
  61. package/dist/server/render.d.ts +158 -0
  62. package/dist/server/render.d.ts.map +1 -1
  63. package/dist/server/render.js +240 -0
  64. package/dist/server/render.js.map +1 -1
  65. package/dist/server/schema.d.ts +704 -1
  66. package/dist/server/schema.d.ts.map +1 -1
  67. package/dist/server/schema.js +254 -1
  68. package/dist/server/schema.js.map +1 -1
  69. package/dist/server/services/databases.d.ts +10 -10
  70. package/dist/server/services/export.d.ts +176 -0
  71. package/dist/server/services/export.d.ts.map +1 -0
  72. package/dist/server/services/export.js +822 -0
  73. package/dist/server/services/export.js.map +1 -0
  74. package/dist/server/services/import.d.ts +109 -0
  75. package/dist/server/services/import.d.ts.map +1 -0
  76. package/dist/server/services/import.js +570 -0
  77. package/dist/server/services/import.js.map +1 -0
  78. package/dist/server/services/index.d.ts +32 -1
  79. package/dist/server/services/index.d.ts.map +1 -1
  80. package/dist/server/services/index.js +87 -4
  81. package/dist/server/services/index.js.map +1 -1
  82. package/dist/server/services/macros.d.ts +83 -0
  83. package/dist/server/services/macros.d.ts.map +1 -0
  84. package/dist/server/services/macros.js +488 -0
  85. package/dist/server/services/macros.js.map +1 -0
  86. package/dist/server/services/publications.d.ts +2 -1
  87. package/dist/server/services/publications.d.ts.map +1 -1
  88. package/dist/server/services/publications.js +43 -4
  89. package/dist/server/services/publications.js.map +1 -1
  90. package/dist/server/services/templates.d.ts +135 -0
  91. package/dist/server/services/templates.d.ts.map +1 -0
  92. package/dist/server/services/templates.js +897 -0
  93. package/dist/server/services/templates.js.map +1 -0
  94. package/dist/server/services/versions.d.ts +13 -1
  95. package/dist/server/services/versions.d.ts.map +1 -1
  96. package/dist/server/services/versions.js +3 -1
  97. package/dist/server/services/versions.js.map +1 -1
  98. package/migrations/0010_transfers.sql +154 -0
  99. package/migrations/0011_templates.sql +157 -0
  100. package/migrations/meta/_journal.json +14 -0
  101. package/package.json +5 -5
  102. package/src/client/components/ExportDialog.svelte +685 -0
  103. package/src/client/components/ImportDialog.svelte +702 -0
  104. package/src/client/components/ImportReport.svelte +310 -0
  105. package/src/client/components/NewSpaceDialog.svelte +77 -8
  106. package/src/client/components/PageEditor.svelte +80 -0
  107. package/src/client/components/PagePicker.svelte +264 -0
  108. package/src/client/components/SaveAsTemplateDialog.svelte +502 -0
  109. package/src/client/components/SidebarSpaces.svelte +112 -1
  110. package/src/client/components/TemplatePicker.svelte +437 -0
  111. package/src/client/i18n.ts +941 -0
  112. package/src/client/index.ts +51 -0
  113. package/src/client/mock.ts +592 -0
  114. package/src/client/module.ts +36 -0
  115. package/src/client/pages/PageView.svelte +86 -0
  116. package/src/client/pages/SpacePage.svelte +20 -4
  117. package/src/client/pages/TransfersPage.svelte +570 -0
  118. package/src/client/permissions.ts +11 -0
  119. package/src/client/query.ts +40 -0
  120. package/src/client/transfers.ts +142 -0
  121. package/src/contract/models.ts +343 -0
  122. package/src/contract/permissions.ts +117 -0
  123. package/src/contract/router.ts +375 -0
@@ -0,0 +1,310 @@
1
+ <script lang="ts">
2
+ import { Button, formatCount, Icon, toast } from '@kernhq/ui'
3
+ import type { ImportOutcome, ImportReportEntry, TransferCounts } from '../../contract/index.js'
4
+ import { t } from '../i18n.js'
5
+
6
+ /**
7
+ * What happened to every file in an archive.
8
+ *
9
+ * **The report is the screen.** An import that silently drops forty pages is worse than one that
10
+ * refuses, so the server writes one row per file — imported, skipped, or failed and why — and this
11
+ * is the thing that makes those rows worth writing. Three decisions in it are worth knowing:
12
+ *
13
+ * 1. **`skipped` and `failed` are drawn differently and are never added together.** A picture Quire
14
+ * deliberately cannot carry is skipped; a `.md` that should have become a page and did not is
15
+ * failed. Collapsing them into "not imported" turns a broken import into a tidy-looking one,
16
+ * which is the exact failure mode the report exists to prevent.
17
+ * 2. **A failure is copyable, one row at a time and all at once.** The person who can act on
18
+ * "its checksum does not match" is usually not the person reading it, and re-typing forty paths
19
+ * into a message is how a report gets ignored.
20
+ * 3. **The list is filtered, not truncated.** A real Notion export is thousands of files, and the
21
+ * question is almost always "what did not come in" — so the filter is the first control, and the
22
+ * counts above it are buttons rather than decoration. Rendering is capped for the browser's sake
23
+ * and says so; the cap never hides a row from *Copy every failure*, which reads the whole report.
24
+ */
25
+ interface Props {
26
+ report: readonly ImportReportEntry[]
27
+ counts: TransferCounts
28
+ /** where an imported page can be opened, when the caller knows — omit and the rows are read-only */
29
+ link?: ((pageId: string) => string) | null
30
+ }
31
+ const { report, counts, link = null }: Props = $props()
32
+
33
+ type Filter = ImportOutcome | 'all'
34
+ let filter = $state<Filter>('all')
35
+
36
+ /** Rendered at once. A thousand list items is a browser hanging, and nobody scrolls a thousand. */
37
+ const PAGE = 300
38
+ let shown = $state(PAGE)
39
+
40
+ /** Back to the top of the list whenever the question changes — a cap carried over reads as a bug. */
41
+ function choose(next: Filter) {
42
+ filter = next
43
+ shown = PAGE
44
+ }
45
+
46
+ const rows = $derived(filter === 'all' ? report : report.filter((row) => row.outcome === filter))
47
+ const visible = $derived(rows.slice(0, shown))
48
+ const failures = $derived(report.filter((row) => row.outcome === 'failed'))
49
+
50
+ const outcomeLabel = (outcome: ImportOutcome) =>
51
+ outcome === 'imported'
52
+ ? t('import_outcome_imported')
53
+ : outcome === 'skipped'
54
+ ? t('import_outcome_skipped')
55
+ : t('import_outcome_failed')
56
+
57
+ const outcomeIcon = (outcome: ImportOutcome) =>
58
+ outcome === 'imported' ? 'check' : outcome === 'skipped' ? 'minus' : 'triangle-alert'
59
+
60
+ /** One row as a line somebody can paste into a message: the path, the outcome, and the reason. */
61
+ const asText = (row: ImportReportEntry) =>
62
+ [row.path, outcomeLabel(row.outcome), row.reason ?? ''].filter(Boolean).join(' — ')
63
+
64
+ async function copy(text: string) {
65
+ try {
66
+ await navigator.clipboard.writeText(text)
67
+ toast.success(t('transfer_copied'))
68
+ } catch {
69
+ // A denied clipboard is not worth an error toast: the text is on screen to select.
70
+ toast.info(t('share_copy_manually'))
71
+ }
72
+ }
73
+
74
+ const tabs = $derived<{ value: Filter; label: string; count: number; tone: string }[]>([
75
+ { value: 'all', label: t('import_outcome_all'), count: counts.total, tone: 'all' },
76
+ { value: 'imported', label: t('import_outcome_imported'), count: counts.done, tone: 'ok' },
77
+ { value: 'skipped', label: t('import_outcome_skipped'), count: counts.skipped, tone: 'skip' },
78
+ { value: 'failed', label: t('import_outcome_failed'), count: counts.failed, tone: 'bad' },
79
+ ])
80
+ </script>
81
+
82
+ <div class="report">
83
+ <!--
84
+ The counts are the filter. Two controls saying the same four numbers is one of them going stale,
85
+ and "3 failed" is a thing people reach for with a pointer the moment they read it.
86
+ `aria-pressed` rather than a radio group: these are toggles over a list that is already on
87
+ screen, not a choice that has to be submitted.
88
+ -->
89
+ <div class="tabs">
90
+ {#each tabs as tab (tab.value)}
91
+ <button
92
+ type="button"
93
+ class="tab {tab.tone}"
94
+ class:on={filter === tab.value}
95
+ aria-pressed={filter === tab.value}
96
+ onclick={() => choose(tab.value)}
97
+ >
98
+ <!--
99
+ Through `formatCount`, not interpolated. Every other number on this screen reaches the page
100
+ through `t()`, which puts it through `Intl.NumberFormat` — these four were the only ones
101
+ written straight into the template, and on a Persian screen they were the only Latin digits
102
+ on it, sitting in the largest type. The cap is raised because a Notion export really is
103
+ thousands of files and "99+" is not an answer here.
104
+ -->
105
+ <span class="tab-n">{formatCount(tab.count, 999_999)}</span>
106
+ <span class="tab-l">{tab.label}</span>
107
+ </button>
108
+ {/each}
109
+ </div>
110
+
111
+ {#if failures.length > 0}
112
+ <div class="bulk">
113
+ <Button
114
+ size="xs"
115
+ variant="secondary"
116
+ icon="copy"
117
+ onclick={() => void copy(failures.map(asText).join('\n'))}
118
+ >
119
+ {t('import_copy_failures', { count: failures.length })}
120
+ </Button>
121
+ </div>
122
+ {/if}
123
+
124
+ {#if rows.length === 0}
125
+ <p class="none">{t('import_report_none')}</p>
126
+ {:else}
127
+ <ul class="rows">
128
+ {#each visible as row, index (`${row.path}:${index}`)}
129
+ <li class="row {row.outcome}">
130
+ <span class="mark" aria-hidden="true"><Icon name={outcomeIcon(row.outcome)} size={13} /></span>
131
+ <div class="body">
132
+ <!--
133
+ A path is a filename with slashes in it, and it is Latin whichever way the page runs.
134
+ Without its own direction the bidi algorithm lays it out against the paragraph's and a
135
+ Persian report comes apart at every slash.
136
+ -->
137
+ <p class="path">{row.path}</p>
138
+ <!--
139
+ `dir="auto"` because a reason is **not a translated string**. `ImportReportEntry.reason`
140
+ is free text the server writes — a checksum that does not match, the column types a CSV
141
+ was read as — and it is English whatever the interface language is. Left to inherit, an
142
+ English sentence inside an RTL paragraph is laid out right to left and comes apart at
143
+ its punctuation: measured in Persian, `…came from “Tasks 71cc9e10.csv”` rendered with
144
+ both quotation marks on the same side of the filename. `dir="auto"` takes the direction
145
+ from the first strong character, so it is right for this sentence today and stays right
146
+ on the day these reasons are translated.
147
+ -->
148
+ {#if row.reason}<p class="reason" dir="auto">{row.reason}</p>{/if}
149
+ </div>
150
+ <!--
151
+ The outcome as a word, not only as the icon's colour. Green and amber are the same
152
+ greyish smudge to a good number of readers, and to every screen reader.
153
+ -->
154
+ <span class="outcome">{outcomeLabel(row.outcome)}</span>
155
+ {#if row.outcome === 'imported' && row.pageId && link}
156
+ <Button size="xs" variant="ghost" href={link(row.pageId)}>{t('import_open_page')}</Button>
157
+ {:else}
158
+ <Button size="xs" variant="ghost" icon="copy" onclick={() => void copy(asText(row))}>
159
+ {t('transfer_copy')}
160
+ </Button>
161
+ {/if}
162
+ </li>
163
+ {/each}
164
+ </ul>
165
+
166
+ {#if rows.length > visible.length}
167
+ <div class="more">
168
+ <p class="none">{t('import_report_capped', { shown: visible.length, total: rows.length })}</p>
169
+ <Button size="sm" variant="secondary" onclick={() => (shown += PAGE)}>
170
+ {t('import_report_more')}
171
+ </Button>
172
+ </div>
173
+ {/if}
174
+ {/if}
175
+ </div>
176
+
177
+ <style>
178
+ .report {
179
+ display: flex;
180
+ flex-direction: column;
181
+ gap: 12px;
182
+ min-width: 0;
183
+ }
184
+
185
+ .tabs {
186
+ display: flex;
187
+ flex-wrap: wrap;
188
+ gap: 8px;
189
+ }
190
+ .tab {
191
+ display: flex;
192
+ flex-direction: column;
193
+ gap: 1px;
194
+ /* 44px tall and 84px wide: a count is the smallest label on this screen and the easiest to miss */
195
+ min-width: 84px;
196
+ min-height: 46px;
197
+ padding: 6px 12px;
198
+ border: 1px solid var(--kern-border);
199
+ border-radius: var(--kern-r-lg);
200
+ background: var(--kern-surface);
201
+ color: var(--kern-ink-700);
202
+ font: inherit;
203
+ text-align: start;
204
+ cursor: pointer;
205
+ }
206
+ .tab:hover {
207
+ border-color: var(--kern-border-hover);
208
+ background: var(--kern-surface-hover);
209
+ }
210
+ .tab.on {
211
+ border-color: var(--kern-accent);
212
+ background: var(--kern-accent-tint);
213
+ }
214
+ .tab-n {
215
+ font-size: 17px;
216
+ font-weight: 600;
217
+ font-variant-numeric: tabular-nums;
218
+ color: var(--kern-ink-900);
219
+ }
220
+ .tab.ok .tab-n {
221
+ color: var(--kern-success-ink);
222
+ }
223
+ .tab.bad .tab-n {
224
+ color: var(--kern-danger);
225
+ }
226
+ .tab-l {
227
+ font-size: 12px;
228
+ color: var(--kern-ink-600);
229
+ }
230
+
231
+ .bulk {
232
+ display: flex;
233
+ }
234
+
235
+ .rows {
236
+ list-style: none;
237
+ margin: 0;
238
+ padding: 0;
239
+ display: flex;
240
+ flex-direction: column;
241
+ }
242
+ .row {
243
+ display: flex;
244
+ align-items: start;
245
+ gap: 10px;
246
+ padding-block: 8px;
247
+ border-block-end: 1px solid var(--kern-border-hairline);
248
+ }
249
+ .row:last-child {
250
+ border-block-end: 0;
251
+ }
252
+ .mark {
253
+ display: inline-flex;
254
+ margin-block-start: 1px;
255
+ color: var(--kern-ink-450);
256
+ }
257
+ .row.imported .mark {
258
+ color: var(--kern-success-ink);
259
+ }
260
+ .row.failed .mark {
261
+ color: var(--kern-danger);
262
+ }
263
+ .body {
264
+ flex: 1;
265
+ min-width: 0;
266
+ }
267
+ .path {
268
+ margin: 0;
269
+ font-family: var(--kern-font-mono);
270
+ font-size: 12px;
271
+ line-height: 1.45;
272
+ color: var(--kern-ink-800);
273
+ /* a path reads left to right in every language; isolated so it cannot reorder a Persian row */
274
+ direction: ltr;
275
+ unicode-bidi: isolate;
276
+ text-align: start;
277
+ overflow-wrap: anywhere;
278
+ }
279
+ .reason {
280
+ margin: 3px 0 0;
281
+ font-size: 12.5px;
282
+ line-height: 1.5;
283
+ color: var(--kern-ink-650);
284
+ text-wrap: pretty;
285
+ }
286
+ .row.failed .reason {
287
+ color: var(--kern-danger);
288
+ }
289
+ .outcome {
290
+ flex: none;
291
+ align-self: center;
292
+ font-size: 12px;
293
+ color: var(--kern-ink-550);
294
+ }
295
+
296
+ .none {
297
+ margin: 0;
298
+ font-size: 12.5px;
299
+ line-height: 1.5;
300
+ color: var(--kern-ink-550);
301
+ text-wrap: pretty;
302
+ }
303
+ .more {
304
+ display: flex;
305
+ align-items: center;
306
+ gap: 10px;
307
+ flex-wrap: wrap;
308
+ padding-block-start: 4px;
309
+ }
310
+ </style>
@@ -1,6 +1,6 @@
1
1
  <script lang="ts">
2
2
  import { Button, Dialog, Field, Input, Select, Textarea } from '@kernhq/ui'
3
- import { useQueryClient } from '@tanstack/svelte-query'
3
+ import { createQuery, useQueryClient } from '@tanstack/svelte-query'
4
4
  import { getQuireApi } from '../api-instance.js'
5
5
  import { t } from '../i18n.js'
6
6
  import type { Space } from '../index.js'
@@ -23,6 +23,25 @@ let visibility = $state<Space['visibility']>('open')
23
23
  let saving = $state(false)
24
24
  let error = $state<string | null>(null)
25
25
 
26
+ /**
27
+ * A whole space somebody saved, so a team can be given the shape as well as the name.
28
+ *
29
+ * There is no shipped space template and there should not be: the five starters Kern ships are
30
+ * pages, because a page template is generic in a way a space's organisation never is. So this
31
+ * control appears only once somebody in the workspace has saved one — an empty picker is a question
32
+ * with one answer.
33
+ *
34
+ * `spaceId: null` is the workspace-wide question, which is the only one that can be asked before
35
+ * there is a space.
36
+ */
37
+ let fromTemplate = $state('')
38
+ const templatesQuery = createQuery(() => ({
39
+ queryKey: quireKeys.templates(workspaceId, 'space', null),
40
+ enabled: open && Boolean(workspaceId),
41
+ queryFn: () => api.templates.list({ workspaceId, kind: 'space', spaceId: null }),
42
+ }))
43
+ const spaceTemplates = $derived((templatesQuery.data ?? []).filter((choice) => choice.id))
44
+
26
45
  /**
27
46
  * The key is derived from the name until somebody types one, and then left alone. Overwriting a key
28
47
  * a person has edited — because they went back and fixed a typo in the name — is the kind of thing
@@ -47,16 +66,22 @@ function reset() {
47
66
  key = ''
48
67
  description = ''
49
68
  visibility = 'open'
69
+ fromTemplate = ''
50
70
  keyTouched = false
51
71
  error = null
52
72
  }
53
73
 
54
- async function submit() {
55
- if (!valid || saving) return
56
- saving = true
57
- error = null
58
- try {
59
- const space = await api.spaces.create({
74
+ /**
75
+ * Make the space, from a template or from nothing.
76
+ *
77
+ * `templates.instantiate` makes the space itself — it has to, because it also writes the tree in the
78
+ * same transaction — so the two fields it has no opinion about are applied afterwards rather than
79
+ * dropped. A dialog that asks for a description and then quietly ignores it is worse than one that
80
+ * never asked.
81
+ */
82
+ async function make(): Promise<Space> {
83
+ if (!fromTemplate)
84
+ return api.spaces.create({
60
85
  workspaceId,
61
86
  key,
62
87
  name: name.trim(),
@@ -64,6 +89,29 @@ async function submit() {
64
89
  icon: null,
65
90
  visibility,
66
91
  })
92
+
93
+ const made = await api.templates.instantiate({
94
+ workspaceId,
95
+ templateId: fromTemplate,
96
+ key,
97
+ name: name.trim(),
98
+ })
99
+ if (description.trim() || visibility !== 'open')
100
+ return api.spaces.update({
101
+ workspaceId,
102
+ spaceId: made.spaceId,
103
+ description: description.trim(),
104
+ visibility,
105
+ })
106
+ return api.spaces.get({ workspaceId, spaceId: made.spaceId })
107
+ }
108
+
109
+ async function submit() {
110
+ if (!valid || saving) return
111
+ saving = true
112
+ error = null
113
+ try {
114
+ const space = await make()
67
115
  await client.invalidateQueries({ queryKey: quireKeys.spaces(workspaceId) })
68
116
  open = false
69
117
  reset()
@@ -119,7 +167,28 @@ async function submit() {
119
167
  {/snippet}
120
168
  </Field>
121
169
 
122
- {#if error}<p class="error">{error}</p>{/if}
170
+ <!--
171
+ Only once there is something to choose between. With no space templates saved this is a menu
172
+ whose single entry is "an empty space", which teaches somebody that the control does nothing.
173
+ -->
174
+ {#if spaceTemplates.length > 0}
175
+ <Field label={t('template_new_space_from')}>
176
+ {#snippet children(id: string)}
177
+ <Select
178
+ {id}
179
+ ariaLabel={t('template_new_space_from')}
180
+ value={fromTemplate}
181
+ options={[
182
+ { value: '', label: t('template_new_space_blank') },
183
+ ...spaceTemplates.map((choice) => ({ value: choice.id as string, label: choice.name })),
184
+ ]}
185
+ onValueChange={(v: string) => (fromTemplate = v)}
186
+ />
187
+ {/snippet}
188
+ </Field>
189
+ {/if}
190
+
191
+ {#if error}<p class="error" role="alert">{error}</p>{/if}
123
192
  </div>
124
193
 
125
194
  {#snippet footer()}
@@ -8,8 +8,12 @@ import {
8
8
  getHost,
9
9
  session,
10
10
  } from '@kernhq/ui'
11
+ import { createQuery } from '@tanstack/svelte-query'
12
+ import { getQuireApi } from '../api-instance.js'
11
13
  import { t } from '../i18n.js'
12
14
  import { type Page, pageDocumentName } from '../index.js'
15
+ import { quireKeys } from '../query.js'
16
+ import PagePicker from './PagePicker.svelte'
13
17
 
14
18
  /**
15
19
  * The body of a page, synchronised through the collab service.
@@ -54,6 +58,68 @@ const user = $derived({
54
58
  name: session.user?.name ?? '',
55
59
  avatarUrl: session.user?.avatarUrl ?? null,
56
60
  })
61
+
62
+ /* ---------------------------------------------------------------------------------------------- */
63
+ /* The two macros that name another page */
64
+ /* ---------------------------------------------------------------------------------------------- */
65
+
66
+ /**
67
+ * Include page and include-an-excerpt need a page id, and `@kernhq/ui` cannot search for one.
68
+ *
69
+ * The `/` menu hides both entries unless the host passes `pickPage`, for the same reason it hides
70
+ * Image without `pickImage`: a macro with no page draws an empty frame for every reader, so an entry
71
+ * whose only possible outcome is an empty frame is worse than no entry. Without this wiring the two
72
+ * macros were in the schema, in the renderer and in the resolver, and unreachable from the product.
73
+ *
74
+ * The menu awaits a promise, so dismissing the dialog has to resolve it — see `PagePicker`.
75
+ */
76
+ let pickerOpen = $state(false)
77
+ let pending: ((picked: { pageId: string } | null) => void) | null = null
78
+
79
+ function pickPage(): Promise<{ pageId: string } | null> {
80
+ return new Promise((resolve) => {
81
+ // A picker already waiting answers "nothing" rather than being left holding an open promise.
82
+ pending?.(null)
83
+ pending = resolve
84
+ pickerOpen = true
85
+ })
86
+ }
87
+
88
+ function picked(page: { id: string; title: string } | null) {
89
+ const resolve = pending
90
+ pending = null
91
+ pickerOpen = false
92
+ if (page) titlesPicked.set(page.id, page.title)
93
+ resolve?.(page ? { pageId: page.id } : null)
94
+ }
95
+
96
+ /**
97
+ * The title a macro's card shows, resolved live and never stored.
98
+ *
99
+ * A title written into the document outlives the permission that allowed it: it would travel into
100
+ * every export and every published copy and be drawn by a renderer that never asked anybody. So the
101
+ * document holds the id, and this names it for the writer's own card, from what this session
102
+ * already has — the space's tree, which the sidebar has usually loaded under this very key, plus
103
+ * whatever was chosen in the picker (which is how a page in another space gets a name here).
104
+ *
105
+ * A page it cannot name is drawn as "Another page" rather than as "No page chosen": the card says
106
+ * what it knows and does not invent the rest.
107
+ */
108
+ const api = getQuireApi()
109
+ const treeQuery = createQuery(() => ({
110
+ queryKey: quireKeys.tree(doc.workspaceId, doc.spaceId),
111
+ enabled: Boolean(doc.workspaceId && doc.spaceId),
112
+ queryFn: () =>
113
+ api.pages.tree({ workspaceId: doc.workspaceId, spaceId: doc.spaceId, includeArchived: false }),
114
+ }))
115
+ const titlesInSpace = $derived(new Map((treeQuery.data ?? []).map((node) => [node.id, node.title])))
116
+ const titlesPicked = new Map<string, string>()
117
+
118
+ function macroPageLabel(pageId: string): string | null {
119
+ const title = titlesInSpace.get(pageId) ?? titlesPicked.get(pageId) ?? null
120
+ if (title === null) return null
121
+ return title.trim() || t('untitled')
122
+ }
57
123
  </script>
58
124
 
59
125
  {#if getHost().isMock}
@@ -92,6 +158,20 @@ const user = $derived({
92
158
  {activeComment}
93
159
  {onCommentClick}
94
160
  {oncomment}
161
+ {pickPage}
162
+ {macroPageLabel}
95
163
  />
96
164
  {/key}
165
+
166
+ <!--
167
+ Outside the `{#key name}` block: re-keying tears the editor down and rebuilds it, and a picker
168
+ inside would be destroyed mid-choice with the `/` menu still awaiting its promise.
169
+ -->
170
+ <PagePicker
171
+ bind:open={pickerOpen}
172
+ workspaceId={doc.workspaceId}
173
+ spaceId={doc.spaceId}
174
+ excludeId={doc.id}
175
+ onPick={picked}
176
+ />
97
177
  {/if}