@kernhq/module-quire 0.13.1 → 0.14.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 +185 -0
- package/dist/contract/models.d.ts.map +1 -1
- package/dist/contract/models.js +133 -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 +82 -0
- package/dist/contract/permissions.js.map +1 -1
- package/dist/contract/properties.d.ts +4 -4
- package/dist/contract/router.d.ts +535 -8
- package/dist/contract/router.d.ts.map +1 -1
- package/dist/contract/router.js +133 -1
- package/dist/contract/router.js.map +1 -1
- package/dist/server/_impl.d.ts +519 -8
- package/dist/server/_impl.d.ts.map +1 -1
- package/dist/server/_impl.js +128 -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 +312 -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/schema.d.ts +445 -1
- package/dist/server/schema.d.ts.map +1 -1
- package/dist/server/schema.js +146 -0
- package/dist/server/schema.js.map +1 -1
- package/dist/server/services/databases.d.ts +5 -5
- 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 +26 -1
- package/dist/server/services/index.d.ts.map +1 -1
- package/dist/server/services/index.js +60 -1
- package/dist/server/services/index.js.map +1 -1
- package/dist/server/services/versions.d.ts +1 -1
- package/migrations/0010_transfers.sql +154 -0
- package/migrations/meta/_journal.json +7 -0
- package/package.json +1 -1
- 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/SidebarSpaces.svelte +79 -0
- package/src/client/i18n.ts +614 -0
- package/src/client/index.ts +32 -0
- package/src/client/mock.ts +318 -0
- package/src/client/module.ts +36 -0
- package/src/client/pages/PageView.svelte +36 -0
- package/src/client/pages/TransfersPage.svelte +570 -0
- package/src/client/permissions.ts +11 -0
- package/src/client/query.ts +23 -0
- package/src/client/transfers.ts +142 -0
- package/src/contract/models.ts +152 -0
- package/src/contract/permissions.ts +84 -0
- package/src/contract/router.ts +147 -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>
|
|
@@ -21,6 +21,8 @@ import { t } from '../i18n.js'
|
|
|
21
21
|
import { buildPageTree, type PageTreeNode } from '../index.js'
|
|
22
22
|
import { canQuire } from '../permissions.js'
|
|
23
23
|
import { quireKeys } from '../query.js'
|
|
24
|
+
import ExportDialog from './ExportDialog.svelte'
|
|
25
|
+
import ImportDialog from './ImportDialog.svelte'
|
|
24
26
|
import LabelManager from './LabelManager.svelte'
|
|
25
27
|
import PageTreeRow from './PageTreeRow.svelte'
|
|
26
28
|
import SidebarFavorites from './SidebarFavorites.svelte'
|
|
@@ -217,6 +219,55 @@ function openTrash() {
|
|
|
217
219
|
void navigation.go(`/${workspaceSlug}/quire/${encodeURIComponent(activeSpace.key)}/trash`)
|
|
218
220
|
}
|
|
219
221
|
|
|
222
|
+
// ---------------------------------------------------------------------------------------------
|
|
223
|
+
// The space menu: getting a whole space out, and getting somebody else's wiki in
|
|
224
|
+
// ---------------------------------------------------------------------------------------------
|
|
225
|
+
|
|
226
|
+
/**
|
|
227
|
+
* Both of these are about the *space* rather than about a page, and neither had a home before.
|
|
228
|
+
*
|
|
229
|
+
* The sidebar is where a space is chosen, so it is where a space's own actions belong — the page
|
|
230
|
+
* menu is one page's business, and the spaces index is a list of cards. The menu is built per
|
|
231
|
+
* permission and is not rendered at all when it would be empty: an ellipsis that opens onto nothing
|
|
232
|
+
* teaches people that the ellipsis is not worth pressing.
|
|
233
|
+
*
|
|
234
|
+
* Neither dialog is mounted until it has been asked for. They both make requests on open, and an
|
|
235
|
+
* always-mounted pair would run them on every Quire screen for everybody.
|
|
236
|
+
*/
|
|
237
|
+
let exportOpen = $state(false)
|
|
238
|
+
let importOpen = $state(false)
|
|
239
|
+
|
|
240
|
+
const canExport = $derived(canQuire('pageExport'))
|
|
241
|
+
const canImport = $derived(canQuire('pageImport'))
|
|
242
|
+
|
|
243
|
+
const spaceMenu = $derived.by((): MenuItem[] => {
|
|
244
|
+
const items: MenuItem[] = []
|
|
245
|
+
if (canExport)
|
|
246
|
+
items.push({
|
|
247
|
+
id: 'export-space',
|
|
248
|
+
label: t('export_space_menu'),
|
|
249
|
+
icon: 'download',
|
|
250
|
+
onSelect: () => (exportOpen = true),
|
|
251
|
+
})
|
|
252
|
+
if (canImport)
|
|
253
|
+
items.push({
|
|
254
|
+
id: 'import-space',
|
|
255
|
+
label: t('import_menu'),
|
|
256
|
+
icon: 'upload',
|
|
257
|
+
onSelect: () => (importOpen = true),
|
|
258
|
+
})
|
|
259
|
+
if (items.length > 0) {
|
|
260
|
+
items.push({ type: 'separator' })
|
|
261
|
+
items.push({
|
|
262
|
+
id: 'transfers',
|
|
263
|
+
label: t('transfers'),
|
|
264
|
+
icon: 'package',
|
|
265
|
+
onSelect: () => void navigation.go(`/${workspaceSlug}/quire/transfers`),
|
|
266
|
+
})
|
|
267
|
+
}
|
|
268
|
+
return items
|
|
269
|
+
})
|
|
270
|
+
|
|
220
271
|
let creating = $state(false)
|
|
221
272
|
|
|
222
273
|
async function createPage(parentId: string | null) {
|
|
@@ -273,6 +324,23 @@ async function createPage(parentId: string | null) {
|
|
|
273
324
|
{/snippet}
|
|
274
325
|
</DropdownMenu>
|
|
275
326
|
{/if}
|
|
327
|
+
<!--
|
|
328
|
+
The space's own actions, beside the box that searches it. Only drawn when there is a space to
|
|
329
|
+
act on and at least one action the person may take.
|
|
330
|
+
-->
|
|
331
|
+
{#if activeSpace && spaceMenu.length > 0}
|
|
332
|
+
<DropdownMenu items={spaceMenu}>
|
|
333
|
+
{#snippet trigger(props: Record<string, unknown>)}
|
|
334
|
+
<IconButton
|
|
335
|
+
{...props}
|
|
336
|
+
icon="ellipsis"
|
|
337
|
+
size={30}
|
|
338
|
+
variant="ghost"
|
|
339
|
+
label={t('space_actions')}
|
|
340
|
+
/>
|
|
341
|
+
{/snippet}
|
|
342
|
+
</DropdownMenu>
|
|
343
|
+
{/if}
|
|
276
344
|
</div>
|
|
277
345
|
|
|
278
346
|
<div class="scroll">
|
|
@@ -430,6 +498,17 @@ async function createPage(parentId: string | null) {
|
|
|
430
498
|
<LabelManager bind:open={labelsOpen} {workspaceId} spaceId={activeSpace.id} />
|
|
431
499
|
{/if}
|
|
432
500
|
|
|
501
|
+
<!--
|
|
502
|
+
Mounted with the sidebar rather than behind `{#if open}`, and it costs nothing: every query in both
|
|
503
|
+
dialogs is `enabled: open`, so an unopened dialog makes no request. Rendering them conditionally
|
|
504
|
+
would also cut the closing animation off — the component would leave the tree on the same tick the
|
|
505
|
+
dialog decided to close — and it is what makes `bind:open` from a menu item work at all.
|
|
506
|
+
-->
|
|
507
|
+
{#if activeSpace}
|
|
508
|
+
<ExportDialog bind:open={exportOpen} {workspaceId} spaceId={activeSpace.id} />
|
|
509
|
+
<ImportDialog bind:open={importOpen} {workspaceId} space={activeSpace} />
|
|
510
|
+
{/if}
|
|
511
|
+
|
|
433
512
|
<style>
|
|
434
513
|
.wrap {
|
|
435
514
|
display: flex;
|