@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,702 @@
1
+ <script lang="ts">
2
+ import {
3
+ Button,
4
+ Dialog,
5
+ formatBytes,
6
+ Icon,
7
+ navigation,
8
+ ProgressBar,
9
+ relativeTime,
10
+ toast,
11
+ uid,
12
+ uploadFile,
13
+ } from '@kernhq/ui'
14
+ import { createQuery, useQueryClient } from '@tanstack/svelte-query'
15
+ import { tick, untrack } from 'svelte'
16
+ import type { ImportJob, ImportSource } from '../../contract/index.js'
17
+ import { getQuireApi } from '../api-instance.js'
18
+ import { t } from '../i18n.js'
19
+ import { quireKeys } from '../query.js'
20
+ import {
21
+ IMPORT_SOURCES,
22
+ isRunning,
23
+ messageOf,
24
+ sourceDescription,
25
+ sourceLabel,
26
+ stateIcon,
27
+ stateLabel,
28
+ } from '../transfers.js'
29
+ import ImportReport from './ImportReport.svelte'
30
+
31
+ /**
32
+ * Bringing a Notion, Confluence or Markdown export into one space.
33
+ *
34
+ * **This is the one thing in Quire that writes hundreds of pages in a single act**, and the whole
35
+ * shape of the dialog follows from that rather than from the upload being fiddly:
36
+ *
37
+ * 1. **The consequence is stated above the button that causes it, and it names the space.** Not in
38
+ * a tooltip and not after the fact: "adds every page in this archive to *Handbook*", "nothing
39
+ * already in Handbook is changed", "there is no single way back". The permission is declared
40
+ * `dangerous` on the server for the same reason, and a dangerous permission whose screen says
41
+ * nothing is a permission flag doing no work.
42
+ * 2. **The report is the outcome, not the counts.** A real export has files that will not map, so
43
+ * the job answers with one row per file and the dialog draws them. An import that finished with
44
+ * forty silent drops looks identical to one that finished cleanly, right up until somebody goes
45
+ * looking for a page.
46
+ * 3. **An import is a server job**, so this attaches to a running one on open rather than
47
+ * remembering anything locally — reload mid-import, reopen, and the progress is still here. The
48
+ * same shape as `ExportDialog`, for the same reason.
49
+ *
50
+ * The upload is the shell's one uploader (`uploadFile` in `@kernhq/ui`): ask core for a ticket, PUT
51
+ * the bytes straight to storage, tell core the file is ready. Skipping the third step leaves the
52
+ * file `pending`, and `imports.start` refuses a file that is not `ready` — which is the check
53
+ * catching a mistake this dialog cannot make, rather than a reason to do the upload by hand.
54
+ */
55
+ interface Props {
56
+ open?: boolean
57
+ workspaceId: string
58
+ /** the space being written into. An import always targets exactly one, and it is named on screen. */
59
+ space: { id: string; key: string; name: string }
60
+ }
61
+ let { open = $bindable(false), workspaceId, space }: Props = $props()
62
+
63
+ const api = getQuireApi()
64
+ const client = useQueryClient()
65
+ const workspaceSlug = $derived(navigation.workspaceSlug)
66
+
67
+ /** Unique per instance: the sidebar's dialog and any other can be mounted at the same time. */
68
+ const group = uid('quire-import')
69
+
70
+ /**
71
+ * Mirrors `MAX_ARCHIVE_BYTES` in `services/import.ts`, and is checked here **as well as** there.
72
+ *
73
+ * Not instead of. The server's copy is the one that matters and the one that cannot be bypassed;
74
+ * this one exists so that a 400 MB archive is refused before somebody watches it upload for six
75
+ * minutes. A limit checked only on the client is a lie; a limit checked only on the server is six
76
+ * wasted minutes and a failure with no obvious cause.
77
+ */
78
+ const MAX_BYTES = 256 * 1024 * 1024
79
+
80
+ // ------------------------------------------------------------------------------------------------
81
+ // What is already happening
82
+ // ------------------------------------------------------------------------------------------------
83
+
84
+ const listQuery = createQuery(() => ({
85
+ queryKey: quireKeys.imports(workspaceId),
86
+ enabled: open && Boolean(workspaceId),
87
+ queryFn: () => api.imports.list({ workspaceId, limit: 20 }),
88
+ }))
89
+
90
+ let watching = $state<string | null>(null)
91
+ let source = $state<ImportSource>('notion')
92
+ let file = $state<File | null>(null)
93
+ let error = $state<string | null>(null)
94
+ /**
95
+ * Two variables rather than one, and the difference is a bug that was written here first.
96
+ *
97
+ * `sending` is whether an upload is happening; `uploaded` is how far along it is, and it is **null
98
+ * whenever the browser is not reporting progress** — a same-origin PUT of a small file often reports
99
+ * nothing at all. Deriving "is it sending" from "do we have a ratio" made the progress region vanish
100
+ * mid-upload on exactly those transfers, which reads as a dialog that gave up.
101
+ */
102
+ let sending = $state(false)
103
+ let uploaded = $state<number | null>(null)
104
+ let dragging = $state(false)
105
+
106
+ /** Which opening this form belongs to; `attached` records what that opening already looked for. */
107
+ let session = $state<string | null>(null)
108
+ let attached = false
109
+
110
+ $effect(() => {
111
+ const key = open ? space.id : null
112
+ if (untrack(() => session) !== key) {
113
+ session = key
114
+ attached = false
115
+ error = null
116
+ watching = null
117
+ file = null
118
+ sending = false
119
+ uploaded = null
120
+ source = 'notion'
121
+ }
122
+ if (key === null || attached) return
123
+ const rows = listQuery.data
124
+ if (!rows) return
125
+ attached = true
126
+ // Newest first from the server, so this is the most recent import into this space — running or
127
+ // finished. Coming back to a report is the other half of "a job outlives the tab".
128
+ const mine = rows.find((row) => row.targetId === space.id)
129
+ if (!mine) return
130
+ watching = mine.id
131
+ source = mine.source
132
+ })
133
+
134
+ /**
135
+ * Polled while running, and invalidated by realtime beside it.
136
+ *
137
+ * The server announces a `change` on the `import` entity as the job moves, and this key sits under
138
+ * that entity's prefix — so on a healthy socket the report appears the moment it is written. The
139
+ * timer is what makes it appear anyway in `dev:mock` or behind a proxy that eats websockets.
140
+ */
141
+ const jobQuery = createQuery(() => ({
142
+ queryKey: quireKeys.importJob(workspaceId, watching ?? ''),
143
+ enabled: open && Boolean(workspaceId && watching),
144
+ queryFn: () => api.imports.get({ workspaceId, jobId: watching as string }),
145
+ refetchInterval: (query: { state: { data?: ImportJob } }) =>
146
+ query.state.data && isRunning(query.state.data.state) ? 1500 : false,
147
+ }))
148
+ const job = $derived((jobQuery.data ?? null) as ImportJob | null)
149
+
150
+ /**
151
+ * A finished import has put pages into the space, and every tree, list and trash view of it is now
152
+ * wrong. Done once, when the job first reports `done`, rather than on every poll.
153
+ */
154
+ let refreshedFor: string | null = null
155
+ $effect(() => {
156
+ const finished = job && job.state === 'done' ? job.id : null
157
+ if (!finished || refreshedFor === finished) return
158
+ refreshedFor = finished
159
+ void client.invalidateQueries({ queryKey: quireKeys.tree(workspaceId, space.id) })
160
+ void client.invalidateQueries({ queryKey: quireKeys.spaces(workspaceId) })
161
+ })
162
+
163
+ // ------------------------------------------------------------------------------------------------
164
+ // Choosing a file
165
+ // ------------------------------------------------------------------------------------------------
166
+
167
+ const looksLikeZip = (chosen: File) =>
168
+ /\.zip$/i.test(chosen.name) ||
169
+ chosen.type === 'application/zip' ||
170
+ chosen.type === 'application/x-zip-compressed'
171
+
172
+ function choose(chosen: File | null | undefined) {
173
+ if (!chosen) return
174
+ error = null
175
+ if (!looksLikeZip(chosen)) {
176
+ file = null
177
+ error = t('import_wrong_type')
178
+ return
179
+ }
180
+ if (chosen.size > MAX_BYTES) {
181
+ file = null
182
+ error = t('import_too_big', { size: formatBytes(chosen.size), max: formatBytes(MAX_BYTES) })
183
+ return
184
+ }
185
+ file = chosen
186
+ }
187
+
188
+ function onDrop(event: DragEvent) {
189
+ event.preventDefault()
190
+ dragging = false
191
+ choose(event.dataTransfer?.files?.[0])
192
+ }
193
+
194
+ // ------------------------------------------------------------------------------------------------
195
+ // Doing it
196
+ // ------------------------------------------------------------------------------------------------
197
+
198
+ /* Set in the same tick as the click. `isPending` reaches the button one render later, so a
199
+ double-click is two uploads and two imports — and an import run twice is every page twice. */
200
+ let busy = false
201
+
202
+ /**
203
+ * Where focus goes when the dialog swaps one half of itself for the other.
204
+ *
205
+ * Both transitions destroy the button that caused them, and the browser hands the focus of a removed
206
+ * element to `<body>` and leaves it there — so a keyboard user who presses **Import** has to tab in
207
+ * from the top of the page to reach the dialog they are standing in. Forward, focus lands on the
208
+ * status region rather than a button, so a repeated Enter cannot press anything; back, it lands on
209
+ * the first source option. Same fix `ExportDialog` and `PublishDialog` carry, for the same reason.
210
+ */
211
+ let jobRegion = $state<HTMLElement | null>(null)
212
+ let sourceFieldset = $state<HTMLElement | null>(null)
213
+
214
+ async function start() {
215
+ const chosen = file
216
+ if (busy || !chosen) return
217
+ busy = true
218
+ error = null
219
+ sending = true
220
+ uploaded = null
221
+ try {
222
+ const object = await uploadFile({
223
+ workspaceId,
224
+ file: chosen,
225
+ name: chosen.name,
226
+ mimeType: 'application/zip',
227
+ attachedTo: { module: 'quire', type: 'import', id: space.id },
228
+ // `ratio` is null whenever the browser is not reporting progress, and then no bar is drawn:
229
+ // a bar pinned at 0% for the whole upload is a worse answer than the sentence beside it.
230
+ onProgress: ({ ratio }) => {
231
+ uploaded = ratio
232
+ },
233
+ })
234
+ const created = await api.imports.start({
235
+ workspaceId,
236
+ spaceId: space.id,
237
+ source,
238
+ fileId: object.id,
239
+ })
240
+ /*
241
+ * The row `start` answered with **is** the answer `get` would give, so it is put in the cache
242
+ * rather than waited for. Without this there is a round trip in which the job exists on the
243
+ * server and the dialog is still showing the form with a live **Import** button on it — and an
244
+ * import run twice is every page in the archive written twice. `busy` does not cover it: it is
245
+ * cleared as soon as this function returns.
246
+ */
247
+ watching = created.id
248
+ client.setQueryData(quireKeys.importJob(workspaceId, created.id), created)
249
+ await tick()
250
+ jobRegion?.focus()
251
+ await listQuery.refetch()
252
+ } catch (err) {
253
+ error = messageOf(err)
254
+ } finally {
255
+ sending = false
256
+ uploaded = null
257
+ busy = false
258
+ }
259
+ }
260
+
261
+ async function copy(text: string) {
262
+ try {
263
+ await navigator.clipboard.writeText(text)
264
+ toast.success(t('transfer_copied'))
265
+ } catch {
266
+ toast.info(t('share_copy_manually'))
267
+ }
268
+ }
269
+
270
+ /** Back to the form. The archive is not kept — a second import needs a second, deliberate choice. */
271
+ async function again() {
272
+ watching = null
273
+ file = null
274
+ error = null
275
+ await tick()
276
+ sourceFieldset?.querySelector('input')?.focus()
277
+ }
278
+
279
+ const fileFieldId = $derived(`${group}-file`)
280
+ const spaceHref = $derived(`/${workspaceSlug}/quire/${encodeURIComponent(space.key)}`)
281
+ const pageHref = (pageId: string) =>
282
+ `/${workspaceSlug}/quire/${encodeURIComponent(space.key)}/${encodeURIComponent(pageId)}`
283
+ </script>
284
+
285
+ <Dialog bind:open title={t('import_title')} size="lg">
286
+ {#if job}
287
+ <!-- `tabindex="-1"` so it can be given focus deliberately when the form is replaced by it; it is
288
+ never in the tab order itself. -->
289
+ <section
290
+ class="job"
291
+ role="status"
292
+ tabindex="-1"
293
+ bind:this={jobRegion}
294
+ aria-busy={isRunning(job.state)}
295
+ >
296
+ <p class="job-state">
297
+ <span class="job-icon" class:spin={isRunning(job.state)} class:bad={job.state === 'failed'}>
298
+ <Icon name={stateIcon(job.state)} size={17} />
299
+ </span>
300
+ {stateLabel(job.state)}
301
+ </p>
302
+ <p class="job-what">
303
+ {t('import_of', { source: sourceLabel(job.source), name: space.name })}
304
+ </p>
305
+
306
+ {#if isRunning(job.state)}
307
+ <!--
308
+ No bar. An import reads the whole archive before it writes anything, so there is no total
309
+ to measure against until the moment it is finished — and a bar with nothing behind it is
310
+ worse than a sentence, because a bar is a promise about how long this will take.
311
+ -->
312
+ <p class="note">{t('import_reading')}</p>
313
+ {/if}
314
+
315
+ {#if job.state === 'failed'}
316
+ <!--
317
+ Said first and said plainly. An import is written in one transaction, so a failure leaves
318
+ the space exactly as it was — and the thing somebody wants to know before anything else is
319
+ whether they now have half an archive in their handbook.
320
+ -->
321
+ <p class="warn">{t('import_failed_nothing')}</p>
322
+ {#if job.error}
323
+ <!-- `dir="auto"` for the same reason as the report's reasons: the server writes this in
324
+ English, and an English sentence inheriting an RTL direction breaks at its punctuation. -->
325
+ <div class="fail">
326
+ <p class="fail-text" dir="auto">{job.error}</p>
327
+ <Button size="xs" variant="ghost" icon="copy" onclick={() => void copy(job.error ?? '')}>
328
+ {t('transfer_copy')}
329
+ </Button>
330
+ </div>
331
+ {/if}
332
+ {/if}
333
+
334
+ {#if job.state === 'done' || job.report.length > 0}
335
+ <ImportReport report={job.report} counts={job.counts} link={pageHref} />
336
+ {/if}
337
+
338
+ <p class="when">{t('import_started', { when: relativeTime(job.createdAt) })}</p>
339
+ </section>
340
+ {:else}
341
+ <fieldset class="choices" bind:this={sourceFieldset}>
342
+ <legend>{t('import_source')}</legend>
343
+ {#each IMPORT_SOURCES as option (option)}
344
+ <label class="choice" class:on={source === option}>
345
+ <input
346
+ type="radio"
347
+ name={`${group}-source`}
348
+ value={option}
349
+ checked={source === option}
350
+ onchange={() => (source = option)}
351
+ />
352
+ <span class="choice-body">
353
+ <span class="choice-title">{sourceLabel(option)}</span>
354
+ <span class="choice-desc">{sourceDescription(option)}</span>
355
+ </span>
356
+ </label>
357
+ {/each}
358
+ </fieldset>
359
+
360
+ <!--
361
+ The input stays in the tree and stays focusable — it is the control, and the label is only its
362
+ visible surface. Hiding it with `display: none` would take the whole thing away from a keyboard
363
+ and leave a label pointing at nothing.
364
+ -->
365
+ <div class="drop" class:on={dragging}>
366
+ <input
367
+ id={fileFieldId}
368
+ class="file"
369
+ type="file"
370
+ accept=".zip,application/zip"
371
+ onchange={(event: Event) => choose((event.currentTarget as HTMLInputElement).files?.[0])}
372
+ />
373
+ <label
374
+ for={fileFieldId}
375
+ ondragover={(event: DragEvent) => {
376
+ event.preventDefault()
377
+ dragging = true
378
+ }}
379
+ ondragleave={() => (dragging = false)}
380
+ ondrop={onDrop}
381
+ >
382
+ <Icon name="upload" size={20} />
383
+ <span class="drop-title">{file ? file.name : t('import_choose')}</span>
384
+ <span class="drop-hint">
385
+ {file ? formatBytes(file.size) : t('import_file_hint', { max: formatBytes(MAX_BYTES) })}
386
+ </span>
387
+ </label>
388
+ </div>
389
+
390
+ <!--
391
+ What is about to happen, above the button that does it, in the words somebody would use
392
+ afterwards — and naming the space every time, because the sidebar's idea of "the space you are
393
+ in" is not necessarily the one somebody opened this from.
394
+ -->
395
+ <section class="tell">
396
+ <Icon name="triangle-alert" size={17} />
397
+ <div class="tell-body">
398
+ <p class="tell-title">{t('import_consequence_title', { name: space.name })}</p>
399
+ <p>{t('import_consequence_adds', { name: space.name })}</p>
400
+ <p>{t('import_consequence_keeps', { name: space.name })}</p>
401
+ <p>{t('import_consequence_undo')}</p>
402
+ <p>{t('import_consequence_report')}</p>
403
+ </div>
404
+ </section>
405
+
406
+ {#if sending}
407
+ <div class="upload" role="status">
408
+ <p class="note">{t('import_uploading')}</p>
409
+ {#if uploaded !== null}
410
+ <ProgressBar value={uploaded * 100} label={t('import_uploading')} />
411
+ {/if}
412
+ </div>
413
+ {/if}
414
+ {/if}
415
+
416
+ {#if error}<p class="error" role="alert">{error}</p>{/if}
417
+
418
+ {#snippet footer()}
419
+ <div class="foot">
420
+ {#if job}
421
+ <Button variant="ghost" size="sm" onclick={again}>{t('import_another')}</Button>
422
+ <span class="spacer"></span>
423
+ <Button variant="secondary" onclick={() => (open = false)}>{t('transfer_close')}</Button>
424
+ {#if job.state === 'done' && job.counts.done > 0}
425
+ <Button href={spaceHref} onclick={() => (open = false)}>{t('import_open_space')}</Button>
426
+ {/if}
427
+ {:else}
428
+ <span class="spacer"></span>
429
+ <Button variant="secondary" onclick={() => (open = false)}>{t('cancel')}</Button>
430
+ <!--
431
+ `disabled` only for "there is no file", which is a statement about the form the drop zone
432
+ already makes. The second click is stopped by the flag inside `start()` instead, and there
433
+ is deliberately no `loading` here: `Button`'s loading state *disables* the button, and this
434
+ is the button somebody has their finger and their focus on — disabling it blurs it and the
435
+ browser hands that focus to `<body>`, so the keyboard user who pressed Import loses their
436
+ place on the page. The progress region above says the same thing without moving anything.
437
+ -->
438
+ <Button icon="upload" disabled={!file} onclick={() => void start()}>
439
+ {t('import_start', { name: space.name })}
440
+ </Button>
441
+ {/if}
442
+ </div>
443
+ {/snippet}
444
+ </Dialog>
445
+
446
+ <style>
447
+ .choices {
448
+ display: flex;
449
+ flex-direction: column;
450
+ gap: 6px;
451
+ margin: 0 0 16px;
452
+ padding: 0;
453
+ border: 0;
454
+ }
455
+ .choices legend {
456
+ padding: 0 0 8px;
457
+ font-size: 12.5px;
458
+ font-weight: 500;
459
+ color: var(--kern-ink-600);
460
+ }
461
+ .choice {
462
+ display: flex;
463
+ align-items: start;
464
+ gap: 10px;
465
+ padding: 10px 12px;
466
+ border: 1px solid var(--kern-border);
467
+ border-radius: var(--kern-r-lg);
468
+ cursor: pointer;
469
+ }
470
+ .choice:hover {
471
+ border-color: var(--kern-border-hover);
472
+ background: var(--kern-surface-hover);
473
+ }
474
+ .choice.on {
475
+ border-color: var(--kern-accent);
476
+ background: var(--kern-accent-tint);
477
+ }
478
+ .choice:focus-within {
479
+ outline: 2px solid var(--kern-ring);
480
+ outline-offset: 1px;
481
+ }
482
+ .choice input {
483
+ margin: 2px 0 0;
484
+ accent-color: var(--kern-accent);
485
+ width: 15px;
486
+ height: 15px;
487
+ flex: none;
488
+ }
489
+ .choice-body {
490
+ display: flex;
491
+ flex-direction: column;
492
+ gap: 2px;
493
+ min-width: 0;
494
+ }
495
+ .choice-title {
496
+ font-size: 13.5px;
497
+ font-weight: 500;
498
+ color: var(--kern-ink-900);
499
+ }
500
+ .choice-desc {
501
+ font-size: 12.5px;
502
+ line-height: 1.5;
503
+ color: var(--kern-ink-600);
504
+ text-wrap: pretty;
505
+ }
506
+
507
+ .drop {
508
+ position: relative;
509
+ margin-block-end: 16px;
510
+ }
511
+ /*
512
+ * Off screen but still in the accessibility tree and still focusable — `display: none` or
513
+ * `visibility: hidden` would take the only control here away from a keyboard entirely.
514
+ */
515
+ .file {
516
+ position: absolute;
517
+ width: 1px;
518
+ height: 1px;
519
+ padding: 0;
520
+ margin: 0;
521
+ overflow: hidden;
522
+ clip-path: inset(50%);
523
+ white-space: nowrap;
524
+ }
525
+ /*
526
+ * `--kern-border-hover`, not `--kern-border-strong`, and the difference is only visible in dark.
527
+ *
528
+ * Measured: in the dark theme `--kern-surface-raised` resolves to the *same* colour as the dialog it
529
+ * sits on, so the dashed line is the only thing that says "a target", and `--kern-border-strong`
530
+ * against that ground is 1.34:1 — a line nobody can see. `--kern-border-hover` is 2.2:1, which is a
531
+ * visible dashed box in both themes. The zone stays identifiable without it either way: its icon and
532
+ * its two lines of text are at 5.45:1 and the input inside it draws a real focus ring.
533
+ */
534
+ .drop label {
535
+ display: flex;
536
+ flex-direction: column;
537
+ align-items: center;
538
+ gap: 4px;
539
+ padding: 20px 16px;
540
+ border: 1px dashed var(--kern-border-hover);
541
+ border-radius: var(--kern-r-lg);
542
+ background: var(--kern-surface-input);
543
+ color: var(--kern-ink-500);
544
+ cursor: pointer;
545
+ text-align: center;
546
+ }
547
+ .drop label:hover,
548
+ .drop.on label {
549
+ border-color: var(--kern-accent);
550
+ background: var(--kern-accent-tint);
551
+ }
552
+ /* The input carries the focus and the label is what can be seen, so the ring is drawn out here. */
553
+ .drop:focus-within label {
554
+ outline: 2px solid var(--kern-ring);
555
+ outline-offset: 2px;
556
+ }
557
+ .drop-title {
558
+ font-size: 13.5px;
559
+ font-weight: 500;
560
+ color: var(--kern-ink-900);
561
+ overflow-wrap: anywhere;
562
+ }
563
+ .drop-hint {
564
+ font-size: 12.5px;
565
+ color: var(--kern-ink-550);
566
+ }
567
+
568
+ .tell {
569
+ display: flex;
570
+ gap: 10px;
571
+ padding: 12px 13px;
572
+ border-radius: var(--kern-r-lg);
573
+ background: var(--kern-warning-tint);
574
+ color: var(--kern-ink-700);
575
+ }
576
+ .tell-body {
577
+ display: flex;
578
+ flex-direction: column;
579
+ gap: 6px;
580
+ min-width: 0;
581
+ }
582
+ .tell p {
583
+ margin: 0;
584
+ font-size: 13px;
585
+ line-height: 1.55;
586
+ text-wrap: pretty;
587
+ }
588
+ .tell-title {
589
+ font-weight: 600;
590
+ font-size: 13.5px;
591
+ color: var(--kern-ink-900);
592
+ }
593
+
594
+ .upload {
595
+ display: flex;
596
+ flex-direction: column;
597
+ gap: 6px;
598
+ margin-block-start: 14px;
599
+ }
600
+
601
+ .job {
602
+ display: flex;
603
+ flex-direction: column;
604
+ gap: 12px;
605
+ }
606
+ .job-state {
607
+ display: flex;
608
+ align-items: center;
609
+ gap: 8px;
610
+ margin: 0;
611
+ font-size: 15px;
612
+ font-weight: 600;
613
+ color: var(--kern-ink-900);
614
+ }
615
+ .job-icon {
616
+ display: inline-flex;
617
+ color: var(--kern-ink-500);
618
+ }
619
+ .job-icon.bad {
620
+ color: var(--kern-danger);
621
+ }
622
+ .job-icon.spin {
623
+ animation: quire-spin 900ms linear infinite;
624
+ }
625
+ @media (prefers-reduced-motion: reduce) {
626
+ .job-icon.spin {
627
+ animation: none;
628
+ }
629
+ }
630
+ @keyframes quire-spin {
631
+ to {
632
+ transform: rotate(360deg);
633
+ }
634
+ }
635
+ .job-what {
636
+ margin: -8px 0 0;
637
+ font-size: 13px;
638
+ color: var(--kern-ink-700);
639
+ text-wrap: pretty;
640
+ }
641
+
642
+ .note {
643
+ margin: 0;
644
+ font-size: 12.5px;
645
+ line-height: 1.55;
646
+ /* muted with a colour: `opacity` fades this against the dialog until nobody can read it */
647
+ color: var(--kern-ink-550);
648
+ text-wrap: pretty;
649
+ }
650
+ .warn {
651
+ margin: 0;
652
+ padding: 9px 11px;
653
+ border-radius: var(--kern-r-lg);
654
+ background: var(--kern-warning-tint);
655
+ color: var(--kern-ink-800);
656
+ font-size: 12.5px;
657
+ line-height: 1.55;
658
+ text-wrap: pretty;
659
+ }
660
+ .when {
661
+ margin: 0;
662
+ font-size: 12px;
663
+ color: var(--kern-ink-450);
664
+ }
665
+
666
+ .fail {
667
+ display: flex;
668
+ align-items: start;
669
+ gap: 8px;
670
+ padding: 10px 11px;
671
+ border-radius: var(--kern-r-lg);
672
+ background: var(--kern-danger-tint);
673
+ }
674
+ .fail-text {
675
+ flex: 1;
676
+ min-width: 0;
677
+ margin: 0;
678
+ font-family: var(--kern-font-mono);
679
+ font-size: 12px;
680
+ line-height: 1.5;
681
+ color: var(--kern-ink-800);
682
+ overflow-wrap: anywhere;
683
+ user-select: text;
684
+ }
685
+
686
+ .error {
687
+ margin: 12px 0 0;
688
+ font-size: 13px;
689
+ color: var(--kern-danger);
690
+ }
691
+
692
+ .foot {
693
+ display: flex;
694
+ align-items: center;
695
+ gap: 8px;
696
+ flex-wrap: wrap;
697
+ width: 100%;
698
+ }
699
+ .spacer {
700
+ flex: 1;
701
+ }
702
+ </style>