@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.
- package/dist/contract/models.d.ts +209 -0
- package/dist/contract/models.d.ts.map +1 -1
- package/dist/contract/models.js +151 -0
- package/dist/contract/models.js.map +1 -1
- package/dist/contract/permissions.d.ts.map +1 -1
- package/dist/contract/permissions.js +32 -0
- package/dist/contract/permissions.js.map +1 -1
- package/dist/contract/properties.d.ts +3 -3
- package/dist/contract/router.d.ts +509 -8
- package/dist/contract/router.d.ts.map +1 -1
- package/dist/contract/router.js +211 -1
- package/dist/contract/router.js.map +1 -1
- package/dist/server/_impl.d.ts +573 -1269
- package/dist/server/_impl.d.ts.map +1 -1
- package/dist/server/_impl.js +180 -0
- package/dist/server/_impl.js.map +1 -1
- package/dist/server/export/markdown.d.ts.map +1 -1
- package/dist/server/export/markdown.js +84 -1
- package/dist/server/export/markdown.js.map +1 -1
- package/dist/server/render.d.ts +190 -1
- package/dist/server/render.d.ts.map +1 -1
- package/dist/server/render.js +413 -0
- package/dist/server/render.js.map +1 -1
- package/dist/server/schema.d.ts +260 -1
- package/dist/server/schema.d.ts.map +1 -1
- package/dist/server/schema.js +108 -1
- package/dist/server/schema.js.map +1 -1
- package/dist/server/services/databases.d.ts +5 -5
- package/dist/server/services/index.d.ts +6 -0
- package/dist/server/services/index.d.ts.map +1 -1
- package/dist/server/services/index.js +27 -3
- package/dist/server/services/index.js.map +1 -1
- package/dist/server/services/macros.d.ts +83 -0
- package/dist/server/services/macros.d.ts.map +1 -0
- package/dist/server/services/macros.js +488 -0
- package/dist/server/services/macros.js.map +1 -0
- package/dist/server/services/objects.d.ts +61 -0
- package/dist/server/services/objects.d.ts.map +1 -0
- package/dist/server/services/objects.js +110 -0
- package/dist/server/services/objects.js.map +1 -0
- package/dist/server/services/publications.d.ts +2 -1
- package/dist/server/services/publications.d.ts.map +1 -1
- package/dist/server/services/publications.js +43 -4
- package/dist/server/services/publications.js.map +1 -1
- package/dist/server/services/templates.d.ts +135 -0
- package/dist/server/services/templates.d.ts.map +1 -0
- package/dist/server/services/templates.js +897 -0
- package/dist/server/services/templates.js.map +1 -0
- package/dist/server/services/unfurl.d.ts +154 -0
- package/dist/server/services/unfurl.d.ts.map +1 -0
- package/dist/server/services/unfurl.js +593 -0
- package/dist/server/services/unfurl.js.map +1 -0
- package/dist/server/services/versions.d.ts +12 -0
- package/dist/server/services/versions.d.ts.map +1 -1
- package/dist/server/services/versions.js +3 -1
- package/dist/server/services/versions.js.map +1 -1
- package/migrations/0011_templates.sql +157 -0
- package/migrations/meta/_journal.json +7 -0
- package/package.json +5 -5
- package/src/client/components/NewSpaceDialog.svelte +77 -8
- package/src/client/components/PageEditor.svelte +80 -0
- package/src/client/components/PagePicker.svelte +264 -0
- package/src/client/components/SaveAsTemplateDialog.svelte +502 -0
- package/src/client/components/SidebarSpaces.svelte +33 -1
- package/src/client/components/TemplatePicker.svelte +437 -0
- package/src/client/i18n.ts +327 -0
- package/src/client/index.ts +19 -0
- package/src/client/mock.ts +274 -0
- package/src/client/pages/PageView.svelte +50 -0
- package/src/client/pages/SpacePage.svelte +20 -4
- package/src/client/query.ts +17 -0
- package/src/contract/models.ts +191 -0
- package/src/contract/permissions.ts +33 -0
- 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>
|