@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.
- package/dist/contract/models.d.ts +394 -0
- package/dist/contract/models.d.ts.map +1 -1
- package/dist/contract/models.js +284 -0
- package/dist/contract/models.js.map +1 -1
- package/dist/contract/permissions.d.ts +14 -0
- package/dist/contract/permissions.d.ts.map +1 -1
- package/dist/contract/permissions.js +114 -0
- package/dist/contract/permissions.js.map +1 -1
- package/dist/contract/properties.d.ts +7 -7
- package/dist/contract/router.d.ts +1124 -96
- package/dist/contract/router.d.ts.map +1 -1
- package/dist/contract/router.js +343 -1
- package/dist/contract/router.js.map +1 -1
- package/dist/server/_impl.d.ts +983 -1168
- package/dist/server/_impl.d.ts.map +1 -1
- package/dist/server/_impl.js +308 -0
- package/dist/server/_impl.js.map +1 -1
- package/dist/server/export/html.d.ts +67 -0
- package/dist/server/export/html.d.ts.map +1 -0
- package/dist/server/export/html.js +206 -0
- package/dist/server/export/html.js.map +1 -0
- package/dist/server/export/markdown.d.ts +51 -0
- package/dist/server/export/markdown.d.ts.map +1 -0
- package/dist/server/export/markdown.js +351 -0
- package/dist/server/export/markdown.js.map +1 -0
- package/dist/server/export/pdf.d.ts +20 -0
- package/dist/server/export/pdf.d.ts.map +1 -0
- package/dist/server/export/pdf.js +91 -0
- package/dist/server/export/pdf.js.map +1 -0
- package/dist/server/export/zip.d.ts +31 -0
- package/dist/server/export/zip.d.ts.map +1 -0
- package/dist/server/export/zip.js +158 -0
- package/dist/server/export/zip.js.map +1 -0
- package/dist/server/import/csv.d.ts +77 -0
- package/dist/server/import/csv.d.ts.map +1 -0
- package/dist/server/import/csv.js +263 -0
- package/dist/server/import/csv.js.map +1 -0
- package/dist/server/import/html.d.ts +52 -0
- package/dist/server/import/html.d.ts.map +1 -0
- package/dist/server/import/html.js +472 -0
- package/dist/server/import/html.js.map +1 -0
- package/dist/server/import/markdown.d.ts +63 -0
- package/dist/server/import/markdown.d.ts.map +1 -0
- package/dist/server/import/markdown.js +692 -0
- package/dist/server/import/markdown.js.map +1 -0
- package/dist/server/import/plan.d.ts +70 -0
- package/dist/server/import/plan.d.ts.map +1 -0
- package/dist/server/import/plan.js +761 -0
- package/dist/server/import/plan.js.map +1 -0
- package/dist/server/import/ydoc.d.ts +35 -0
- package/dist/server/import/ydoc.d.ts.map +1 -0
- package/dist/server/import/ydoc.js +91 -0
- package/dist/server/import/ydoc.js.map +1 -0
- package/dist/server/import/zip.d.ts +63 -0
- package/dist/server/import/zip.d.ts.map +1 -0
- package/dist/server/import/zip.js +308 -0
- package/dist/server/import/zip.js.map +1 -0
- package/dist/server/index.d.ts.map +1 -1
- package/dist/server/index.js +3 -1
- package/dist/server/index.js.map +1 -1
- package/dist/server/render.d.ts +158 -0
- package/dist/server/render.d.ts.map +1 -1
- package/dist/server/render.js +240 -0
- package/dist/server/render.js.map +1 -1
- package/dist/server/schema.d.ts +704 -1
- package/dist/server/schema.d.ts.map +1 -1
- package/dist/server/schema.js +254 -1
- package/dist/server/schema.js.map +1 -1
- package/dist/server/services/databases.d.ts +10 -10
- package/dist/server/services/export.d.ts +176 -0
- package/dist/server/services/export.d.ts.map +1 -0
- package/dist/server/services/export.js +822 -0
- package/dist/server/services/export.js.map +1 -0
- package/dist/server/services/import.d.ts +109 -0
- package/dist/server/services/import.d.ts.map +1 -0
- package/dist/server/services/import.js +570 -0
- package/dist/server/services/import.js.map +1 -0
- package/dist/server/services/index.d.ts +32 -1
- package/dist/server/services/index.d.ts.map +1 -1
- package/dist/server/services/index.js +87 -4
- 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/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/versions.d.ts +13 -1
- 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/0010_transfers.sql +154 -0
- package/migrations/0011_templates.sql +157 -0
- package/migrations/meta/_journal.json +14 -0
- package/package.json +5 -5
- package/src/client/components/ExportDialog.svelte +685 -0
- package/src/client/components/ImportDialog.svelte +702 -0
- package/src/client/components/ImportReport.svelte +310 -0
- 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 +112 -1
- package/src/client/components/TemplatePicker.svelte +437 -0
- package/src/client/i18n.ts +941 -0
- package/src/client/index.ts +51 -0
- package/src/client/mock.ts +592 -0
- package/src/client/module.ts +36 -0
- package/src/client/pages/PageView.svelte +86 -0
- package/src/client/pages/SpacePage.svelte +20 -4
- package/src/client/pages/TransfersPage.svelte +570 -0
- package/src/client/permissions.ts +11 -0
- package/src/client/query.ts +40 -0
- package/src/client/transfers.ts +142 -0
- package/src/contract/models.ts +343 -0
- package/src/contract/permissions.ts +117 -0
- 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
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
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
|
-
|
|
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}
|