@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,264 @@
1
+ <script lang="ts">
2
+ import { Dialog, EmptyState, Icon, Input, Select, Skeleton } from '@kernhq/ui'
3
+ import { createQuery } from '@tanstack/svelte-query'
4
+ import { getQuireApi } from '../api-instance.js'
5
+ import { t } from '../i18n.js'
6
+ import type { PageNode } from '../index.js'
7
+ import { quireKeys } from '../query.js'
8
+
9
+ /**
10
+ * Which page a macro points at.
11
+ *
12
+ * Two of the eight macros — include page, and include another page's excerpt — name a page, and
13
+ * `@kernhq/ui` cannot search for one: it is a design system, it has no API client, and a macro
14
+ * whose `pageId` is null draws an empty frame for every reader. So the `/` menu offers those two
15
+ * entries **only** where the host supplies a picker, exactly as it does for Image. This is that
16
+ * picker, and without it those two macros are in the schema and unreachable — which is the state
17
+ * they were in.
18
+ *
19
+ * Three decisions worth stating, because each of them is a thing that would otherwise be wrong:
20
+ *
21
+ * - **Only what a macro can actually draw is offered.** The resolver's `drawable` filter takes
22
+ * ordinary pages and nothing else — no databases, no live docs, nothing archived or in the trash
23
+ * — so offering one of those here would let somebody choose a page that renders as an empty frame
24
+ * for ever, with nothing anywhere saying why. The row that is not there is the honest answer.
25
+ * - **A page can be picked out of any space, so the space is a control rather than an assumption.**
26
+ * An include that crosses spaces is the normal case for a handbook quoting a policy, and the
27
+ * permission question is asked at render time against whoever is reading — never here. This
28
+ * dialog offers what *this* writer may see, which is what `spaces.list` and `pages.tree` already
29
+ * return; it decides nothing about anybody else.
30
+ * - **Enter picks the first row.** Type three letters and press Enter, which is how everybody uses
31
+ * a picker; the mouse and Tab both still work. Escape leaves without choosing, and dismissing has
32
+ * to resolve the promise the `/` menu is waiting on — an unresolved one leaves the editor with a
33
+ * menu entry that appears to do nothing.
34
+ */
35
+ interface Props {
36
+ open: boolean
37
+ workspaceId: string
38
+ /** the space the writer is in, which is where the picker starts */
39
+ spaceId: string
40
+ /** the page holding the macro — a page cannot usefully include itself */
41
+ excludeId?: string | null
42
+ /** the choice, or null when the dialog was dismissed. The title is for the editor's card only. */
43
+ onPick: (page: { id: string; title: string } | null) => void
44
+ }
45
+ let { open = $bindable(false), workspaceId, spaceId, excludeId = null, onPick }: Props = $props()
46
+
47
+ const api = getQuireApi()
48
+
49
+ let search = $state('')
50
+ let chosenSpace = $state(spaceId)
51
+ /**
52
+ * Whether this opening has already answered.
53
+ *
54
+ * Not `$state`: nothing draws it, and it exists so that choosing a row does not also fire the
55
+ * dismissal path when `open` goes false. A promise resolved twice is not an error anybody sees —
56
+ * the second answer is dropped silently — which is exactly why it is worth being explicit.
57
+ */
58
+ let settled = false
59
+
60
+ $effect(() => {
61
+ if (!open) return
62
+ settled = false
63
+ search = ''
64
+ chosenSpace = spaceId
65
+ })
66
+
67
+ const spacesQuery = createQuery(() => ({
68
+ queryKey: quireKeys.spaces(workspaceId),
69
+ enabled: Boolean(workspaceId) && open,
70
+ queryFn: () => api.spaces.list({ workspaceId, includeArchived: false }),
71
+ }))
72
+ const spaceList = $derived(spacesQuery.data ?? [])
73
+
74
+ const treeQuery = createQuery(() => ({
75
+ queryKey: quireKeys.tree(workspaceId, chosenSpace),
76
+ enabled: Boolean(workspaceId && chosenSpace) && open,
77
+ queryFn: () => api.pages.tree({ workspaceId, spaceId: chosenSpace, includeArchived: false }),
78
+ }))
79
+
80
+ interface Row {
81
+ id: string
82
+ title: string
83
+ /** the pages above it, so two "Overview" rows can be told apart */
84
+ path: string
85
+ }
86
+
87
+ const nodes = $derived(treeQuery.data ?? [])
88
+
89
+ /**
90
+ * The tree, flattened to rows with a readable path.
91
+ *
92
+ * `pages.tree` answers with a flat, position-ordered list and a `parentId` on each row, so the path
93
+ * is a walk up through a map rather than a second request. It is bounded: a cycle would be a bug in
94
+ * a move, and a picker is not the place to hang on one.
95
+ */
96
+ const rows = $derived.by((): Row[] => {
97
+ const byId = new Map<string, PageNode>(nodes.map((node) => [node.id, node]))
98
+ const out: Row[] = []
99
+ for (const node of nodes) {
100
+ if (node.kind !== 'page' || node.archivedAt || node.id === excludeId) continue
101
+ const names: string[] = []
102
+ let parent = node.parentId ? byId.get(node.parentId) : undefined
103
+ for (let i = 0; parent && i < 8; i++) {
104
+ names.unshift(parent.title.trim() || t('untitled'))
105
+ parent = parent.parentId ? byId.get(parent.parentId) : undefined
106
+ }
107
+ out.push({ id: node.id, title: node.title.trim() || t('untitled'), path: names.join(' / ') })
108
+ }
109
+ return out
110
+ })
111
+
112
+ /** At most this many rows are drawn. Somebody past forty matches should type another letter. */
113
+ const MAX_ROWS = 40
114
+
115
+ const matches = $derived.by((): Row[] => {
116
+ const needle = search.trim().toLowerCase()
117
+ const hits = needle
118
+ ? rows.filter(
119
+ (row) => row.title.toLowerCase().includes(needle) || row.path.toLowerCase().includes(needle),
120
+ )
121
+ : rows
122
+ return hits.slice(0, MAX_ROWS)
123
+ })
124
+
125
+ function choose(row: Row) {
126
+ if (settled) return
127
+ settled = true
128
+ open = false
129
+ onPick({ id: row.id, title: row.title })
130
+ }
131
+
132
+ function dismissed() {
133
+ if (settled) return
134
+ settled = true
135
+ onPick(null)
136
+ }
137
+
138
+ function onSearchKey(event: KeyboardEvent) {
139
+ if (event.key !== 'Enter') return
140
+ const first = matches[0]
141
+ if (!first) return
142
+ event.preventDefault()
143
+ choose(first)
144
+ }
145
+ </script>
146
+
147
+ <Dialog
148
+ bind:open
149
+ title={t('macro_pick_page')}
150
+ description={t('macro_pick_page_desc')}
151
+ size="md"
152
+ onOpenChange={(next) => {
153
+ if (!next) dismissed()
154
+ }}
155
+ >
156
+ <div class="picker">
157
+ <div class="controls">
158
+ <Input
159
+ bind:value={search}
160
+ icon="search"
161
+ placeholder={t('macro_pick_search')}
162
+ aria-label={t('macro_pick_search')}
163
+ onkeydown={onSearchKey}
164
+ />
165
+ {#if spaceList.length > 1}
166
+ <Select
167
+ ariaLabel={t('macro_pick_space')}
168
+ value={chosenSpace}
169
+ options={spaceList.map((space) => ({ value: space.id, label: space.name }))}
170
+ onValueChange={(value: string) => (chosenSpace = value)}
171
+ />
172
+ {/if}
173
+ </div>
174
+
175
+ {#if treeQuery.isPending}
176
+ <div class="loading">
177
+ {#each [0, 1, 2, 3] as row (row)}<Skeleton height="34px" />{/each}
178
+ </div>
179
+ {:else if matches.length === 0}
180
+ <EmptyState icon="search" title={t('macro_pick_none')} description={t('macro_pick_none_desc')} />
181
+ {:else}
182
+ <!--
183
+ A list of buttons rather than a listbox: each row does something the moment it is activated,
184
+ which is what a button is. Tab reaches every row, Enter and Space activate it, and the
185
+ search field's Enter takes the first — so the whole dialog is usable without a pointer.
186
+ -->
187
+ <ul class="rows">
188
+ {#each matches as row (row.id)}
189
+ <li>
190
+ <button type="button" class="row" onclick={() => choose(row)}>
191
+ <Icon name="file-text" size={14} />
192
+ <span class="title">{row.title}</span>
193
+ {#if row.path}<span class="path">{row.path}</span>{/if}
194
+ </button>
195
+ </li>
196
+ {/each}
197
+ </ul>
198
+ {/if}
199
+ </div>
200
+ </Dialog>
201
+
202
+ <style>
203
+ .picker {
204
+ display: flex;
205
+ flex-direction: column;
206
+ gap: 10px;
207
+ /* The dialog grows with its content up to here, then the list scrolls rather than the page. */
208
+ max-height: 60vh;
209
+ }
210
+ .controls {
211
+ display: flex;
212
+ gap: 8px;
213
+ }
214
+ .loading {
215
+ display: flex;
216
+ flex-direction: column;
217
+ gap: 6px;
218
+ }
219
+ .rows {
220
+ margin: 0;
221
+ padding: 0;
222
+ list-style: none;
223
+ overflow-y: auto;
224
+ }
225
+ .row {
226
+ display: flex;
227
+ align-items: center;
228
+ gap: 8px;
229
+ width: 100%;
230
+ /* Logical, so the row reads from the right in Persian and Arabic without a second rule. */
231
+ padding: 7px 10px;
232
+ border: 0;
233
+ border-radius: var(--kern-r-md);
234
+ background: transparent;
235
+ color: var(--kern-ink-800);
236
+ font: inherit;
237
+ font-size: 13.5px;
238
+ text-align: start;
239
+ cursor: pointer;
240
+ }
241
+ .row:hover,
242
+ .row:focus-visible {
243
+ background: var(--kern-surface-hover);
244
+ }
245
+ .title {
246
+ flex: 0 1 auto;
247
+ overflow: hidden;
248
+ text-overflow: ellipsis;
249
+ white-space: nowrap;
250
+ }
251
+ /*
252
+ * The path is secondary and is muted with a colour, never with `opacity` — fading it against the
253
+ * page is how a line meant to read as quieter ends up unreadable, whatever its token says.
254
+ */
255
+ .path {
256
+ flex: 1 1 auto;
257
+ min-width: 0;
258
+ overflow: hidden;
259
+ text-overflow: ellipsis;
260
+ white-space: nowrap;
261
+ color: var(--kern-ink-450);
262
+ font-size: 12.5px;
263
+ }
264
+ </style>