@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,502 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
/**
|
|
3
|
+
* Saving what is on the screen so it can be made again.
|
|
4
|
+
*
|
|
5
|
+
* Two things here are worth knowing before changing anything.
|
|
6
|
+
*
|
|
7
|
+
* **The body is never sent.** The dialog names a *source* — this page, or this whole space — and the
|
|
8
|
+
* server reads the prose itself. A client that could post a document would make "save as a template"
|
|
9
|
+
* a way to write arbitrary text into something everybody in the space is then offered.
|
|
10
|
+
*
|
|
11
|
+
* **"Replace a template Kern ships" is a real list, not a checkbox.** The starters are constants in
|
|
12
|
+
* the module; a workspace edits one by writing a row that carries its key and stands in its place.
|
|
13
|
+
* The options come from `templates.list` — which is where the starters' translated names live — and
|
|
14
|
+
* a starter this workspace has already replaced is not in the list, because the second override is a
|
|
15
|
+
* conflict rather than a second entry.
|
|
16
|
+
*/
|
|
17
|
+
import {
|
|
18
|
+
Button,
|
|
19
|
+
Dialog,
|
|
20
|
+
Field,
|
|
21
|
+
Icon,
|
|
22
|
+
IconButton,
|
|
23
|
+
Input,
|
|
24
|
+
SegmentedControl,
|
|
25
|
+
Select,
|
|
26
|
+
Switch,
|
|
27
|
+
Textarea,
|
|
28
|
+
} from '@kernhq/ui'
|
|
29
|
+
import { createQuery, useQueryClient } from '@tanstack/svelte-query'
|
|
30
|
+
import { untrack } from 'svelte'
|
|
31
|
+
import { getQuireApi } from '../api-instance.js'
|
|
32
|
+
import { t } from '../i18n.js'
|
|
33
|
+
import {
|
|
34
|
+
TEMPLATE_BUILT_IN_VARIABLES,
|
|
35
|
+
type Template,
|
|
36
|
+
type TemplateKind,
|
|
37
|
+
type TemplateStarterKey,
|
|
38
|
+
type TemplateVariable,
|
|
39
|
+
type TemplateVariableType,
|
|
40
|
+
} from '../index.js'
|
|
41
|
+
import { quireKeys } from '../query.js'
|
|
42
|
+
|
|
43
|
+
interface Props {
|
|
44
|
+
open: boolean
|
|
45
|
+
workspaceId: string
|
|
46
|
+
spaceId: string
|
|
47
|
+
spaceName: string
|
|
48
|
+
/** the page whose prose becomes the body, for a page template */
|
|
49
|
+
pageId: string
|
|
50
|
+
pageTitle: string
|
|
51
|
+
onSaved?: (template: Template) => void
|
|
52
|
+
}
|
|
53
|
+
let { open = $bindable(false), workspaceId, spaceId, spaceName, pageId, pageTitle, onSaved }: Props = $props()
|
|
54
|
+
|
|
55
|
+
const api = getQuireApi()
|
|
56
|
+
const client = useQueryClient()
|
|
57
|
+
|
|
58
|
+
let kind = $state<TemplateKind>('page')
|
|
59
|
+
let name = $state('')
|
|
60
|
+
let description = $state('')
|
|
61
|
+
let scope = $state<'space' | 'workspace'>('space')
|
|
62
|
+
let replaces = $state('')
|
|
63
|
+
let fields = $state<TemplateVariable[]>([])
|
|
64
|
+
let error = $state<string | null>(null)
|
|
65
|
+
/** Guarded in the same tick as the click; `disabled` on the button arrives a render too late. */
|
|
66
|
+
let busy = $state(false)
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* The starters still available to replace, with the names the server translated.
|
|
70
|
+
*
|
|
71
|
+
* Read from the picker's own query rather than from `TEMPLATE_STARTER_KEYS`, because a key is not a
|
|
72
|
+
* name: the starters' words are a table on the server, resolved per reader, and a client spelling
|
|
73
|
+
* them itself would be a sixth copy in one language.
|
|
74
|
+
*/
|
|
75
|
+
const startersQuery = createQuery(() => ({
|
|
76
|
+
queryKey: quireKeys.templates(workspaceId, 'page', spaceId),
|
|
77
|
+
enabled: open && Boolean(workspaceId && spaceId),
|
|
78
|
+
queryFn: () => api.templates.list({ workspaceId, kind: 'page', spaceId }),
|
|
79
|
+
}))
|
|
80
|
+
const replaceable = $derived(
|
|
81
|
+
(startersQuery.data ?? []).filter((choice) => choice.builtIn && choice.id === null && choice.key),
|
|
82
|
+
)
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Save as a new template, or take an existing one's body from this page again.
|
|
86
|
+
*
|
|
87
|
+
* The second is not a nicety. A template's prose drifts — somebody improves the meeting note they
|
|
88
|
+
* are looking at and wants everybody's next one to start from it — and without this the only way to
|
|
89
|
+
* express that is to save a second template with the same name and delete the first, which loses
|
|
90
|
+
* whichever of the two other people had already started using.
|
|
91
|
+
*/
|
|
92
|
+
let target = $state('')
|
|
93
|
+
const editable = $derived((startersQuery.data ?? []).filter((choice) => choice.id))
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* The chosen template in full, which is where its variables come from.
|
|
97
|
+
*
|
|
98
|
+
* `templates.list` deliberately carries no body — thirty documents to draw thirty names — so
|
|
99
|
+
* "replace this one" needs the row itself before the form can show what it currently asks for.
|
|
100
|
+
*/
|
|
101
|
+
const chosenQuery = createQuery(() => ({
|
|
102
|
+
queryKey: quireKeys.template(workspaceId, target),
|
|
103
|
+
enabled: open && Boolean(workspaceId && target),
|
|
104
|
+
queryFn: () => api.templates.get({ workspaceId, templateId: target }),
|
|
105
|
+
}))
|
|
106
|
+
|
|
107
|
+
const NAME_PATTERN = /^[a-z][a-z0-9_]*$/
|
|
108
|
+
|
|
109
|
+
/** Why this field cannot be saved, or null. Shown under the field rather than at submit time. */
|
|
110
|
+
function problemWith(field: TemplateVariable, at: number): string | null {
|
|
111
|
+
if (!field.name) return null
|
|
112
|
+
if (!NAME_PATTERN.test(field.name)) return t('template_field_name_invalid')
|
|
113
|
+
if ((TEMPLATE_BUILT_IN_VARIABLES as readonly string[]).includes(field.name))
|
|
114
|
+
return t('template_field_name_reserved')
|
|
115
|
+
if (fields.some((other, index) => index !== at && other.name === field.name))
|
|
116
|
+
return t('template_field_name_twice')
|
|
117
|
+
return null
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
const valid = $derived(
|
|
121
|
+
name.trim().length > 0 &&
|
|
122
|
+
fields.every(
|
|
123
|
+
(field, at) => field.name.length > 0 && field.label.length > 0 && problemWith(field, at) === null,
|
|
124
|
+
),
|
|
125
|
+
)
|
|
126
|
+
|
|
127
|
+
function addField() {
|
|
128
|
+
fields = [...fields, { name: '', label: '', type: 'text', options: [], default: null, required: false }]
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
function patchField(at: number, patch: Partial<TemplateVariable>) {
|
|
132
|
+
fields = fields.map((field, index) => (index === at ? { ...field, ...patch } : field))
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
function removeField(at: number) {
|
|
136
|
+
fields = fields.filter((_, index) => index !== at)
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
function reset() {
|
|
140
|
+
kind = 'page'
|
|
141
|
+
name = ''
|
|
142
|
+
description = ''
|
|
143
|
+
scope = 'space'
|
|
144
|
+
replaces = ''
|
|
145
|
+
target = ''
|
|
146
|
+
fields = []
|
|
147
|
+
error = null
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
async function submit() {
|
|
151
|
+
if (!valid || busy) return
|
|
152
|
+
busy = true
|
|
153
|
+
error = null
|
|
154
|
+
try {
|
|
155
|
+
const template = target
|
|
156
|
+
? await api.templates.update({
|
|
157
|
+
workspaceId,
|
|
158
|
+
templateId: target,
|
|
159
|
+
name: name.trim(),
|
|
160
|
+
description: description.trim(),
|
|
161
|
+
variables: fields,
|
|
162
|
+
/*
|
|
163
|
+
* Three-valued, like `publications.update`'s password: sending it re-reads the body, and
|
|
164
|
+
* leaving it out would change the name and quietly keep the old prose.
|
|
165
|
+
*
|
|
166
|
+
* Always this page, never the space, because the list this target came from is
|
|
167
|
+
* `kind: 'page'` — a space template is not offered here, and the server would resolve
|
|
168
|
+
* `sourceId` as a space id for one.
|
|
169
|
+
*/
|
|
170
|
+
sourceId: pageId,
|
|
171
|
+
})
|
|
172
|
+
: await api.templates.createFromPage({
|
|
173
|
+
workspaceId,
|
|
174
|
+
kind,
|
|
175
|
+
// The page for a page template, the space for a space template — `kind` says which.
|
|
176
|
+
sourceId: kind === 'space' ? spaceId : pageId,
|
|
177
|
+
// A space template makes a space, so it cannot live inside one.
|
|
178
|
+
spaceId: kind === 'space' || scope === 'workspace' ? null : spaceId,
|
|
179
|
+
name: name.trim(),
|
|
180
|
+
description: description.trim(),
|
|
181
|
+
icon: null,
|
|
182
|
+
variables: fields,
|
|
183
|
+
// The Select's empty option means "a new template"; every other value is a starter's key.
|
|
184
|
+
key: (replaces || null) as TemplateStarterKey | null,
|
|
185
|
+
})
|
|
186
|
+
// Both lists: the space's picker and the workspace-wide one a "New space" picker reads.
|
|
187
|
+
await client.invalidateQueries({ queryKey: ['quire', 'template', workspaceId] })
|
|
188
|
+
open = false
|
|
189
|
+
reset()
|
|
190
|
+
onSaved?.(template)
|
|
191
|
+
} catch (err) {
|
|
192
|
+
error = err instanceof Error ? err.message : String(err)
|
|
193
|
+
} finally {
|
|
194
|
+
busy = false
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
const TYPES: Array<{ value: TemplateVariableType; label: string }> = $derived([
|
|
199
|
+
{ value: 'text', label: t('template_type_text') },
|
|
200
|
+
{ value: 'number', label: t('template_type_number') },
|
|
201
|
+
{ value: 'date', label: t('template_type_date') },
|
|
202
|
+
{ value: 'select', label: t('template_type_select') },
|
|
203
|
+
{ value: 'user', label: t('template_type_user') },
|
|
204
|
+
])
|
|
205
|
+
|
|
206
|
+
/**
|
|
207
|
+
* The page's own title is the obvious name for a template made from it, until somebody types one.
|
|
208
|
+
*
|
|
209
|
+
* `name` is read and written inside `untrack`, so this effect depends on `open` and `pageTitle` and
|
|
210
|
+
* on nothing it writes. Without that it is its own trigger: it re-runs on every keystroke in the
|
|
211
|
+
* name field, and the only reason it settles rather than looping is the `=== ''` guard — a
|
|
212
|
+
* termination condition that is one edit away from not being there.
|
|
213
|
+
*/
|
|
214
|
+
$effect(() => {
|
|
215
|
+
if (!open) return
|
|
216
|
+
const suggestion = pageTitle
|
|
217
|
+
untrack(() => {
|
|
218
|
+
if (name === '') name = suggestion
|
|
219
|
+
})
|
|
220
|
+
})
|
|
221
|
+
|
|
222
|
+
/**
|
|
223
|
+
* Choosing a template to replace fills the form with what it says now.
|
|
224
|
+
*
|
|
225
|
+
* Same rule as above: the row is tracked, and everything the effect writes is read and written
|
|
226
|
+
* inside `untrack`, so typing in the name field does not re-run it and overwrite what was typed.
|
|
227
|
+
*/
|
|
228
|
+
$effect(() => {
|
|
229
|
+
const chosen = chosenQuery.data
|
|
230
|
+
if (!chosen || chosen.id !== target) return
|
|
231
|
+
untrack(() => {
|
|
232
|
+
name = chosen.name
|
|
233
|
+
description = chosen.description
|
|
234
|
+
fields = chosen.variables.map((variable) => ({ ...variable }))
|
|
235
|
+
})
|
|
236
|
+
})
|
|
237
|
+
</script>
|
|
238
|
+
|
|
239
|
+
<Dialog
|
|
240
|
+
bind:open
|
|
241
|
+
size="lg"
|
|
242
|
+
title={t('template_save_title')}
|
|
243
|
+
description={t('template_save_desc')}
|
|
244
|
+
onOpenChange={(o: boolean) => {
|
|
245
|
+
if (!o) reset()
|
|
246
|
+
}}
|
|
247
|
+
>
|
|
248
|
+
<div class="form">
|
|
249
|
+
<!--
|
|
250
|
+
Only once this workspace has a template to update. Before that "a new template" is the only
|
|
251
|
+
answer, and a menu with one entry is a question nobody should have to read.
|
|
252
|
+
-->
|
|
253
|
+
{#if editable.length > 0}
|
|
254
|
+
<Field label={t('template_target')}>
|
|
255
|
+
{#snippet children(id: string)}
|
|
256
|
+
<Select
|
|
257
|
+
{id}
|
|
258
|
+
ariaLabel={t('template_target')}
|
|
259
|
+
value={target}
|
|
260
|
+
options={[
|
|
261
|
+
{ value: '', label: t('template_target_new') },
|
|
262
|
+
...editable.map((choice) => ({ value: choice.id as string, label: choice.name })),
|
|
263
|
+
]}
|
|
264
|
+
onValueChange={(v: string) => {
|
|
265
|
+
target = v
|
|
266
|
+
// Back to "a new template" starts from a clean form rather than from whatever the
|
|
267
|
+
// template that was selected happened to say.
|
|
268
|
+
if (!v) {
|
|
269
|
+
name = pageTitle
|
|
270
|
+
description = ''
|
|
271
|
+
fields = []
|
|
272
|
+
}
|
|
273
|
+
}}
|
|
274
|
+
/>
|
|
275
|
+
{/snippet}
|
|
276
|
+
</Field>
|
|
277
|
+
{/if}
|
|
278
|
+
|
|
279
|
+
<!--
|
|
280
|
+
What a template *is* — a page or a space — is fixed the day it is made. Changing it would
|
|
281
|
+
change the shape of its body, so updating an existing one does not offer the choice.
|
|
282
|
+
-->
|
|
283
|
+
{#if !target}
|
|
284
|
+
<Field label={t('template_source')}>
|
|
285
|
+
{#snippet children(_id: string)}
|
|
286
|
+
<SegmentedControl
|
|
287
|
+
label={t('template_source')}
|
|
288
|
+
value={kind}
|
|
289
|
+
items={[
|
|
290
|
+
{ value: 'page', label: t('template_source_page'), icon: 'file-text' },
|
|
291
|
+
{ value: 'space', label: t('template_source_space'), icon: 'scroll-text' },
|
|
292
|
+
]}
|
|
293
|
+
onValueChange={(v: string) => (kind = v as TemplateKind)}
|
|
294
|
+
/>
|
|
295
|
+
{/snippet}
|
|
296
|
+
</Field>
|
|
297
|
+
{/if}
|
|
298
|
+
|
|
299
|
+
<Field label={t('template_name')}>
|
|
300
|
+
{#snippet children(id: string)}
|
|
301
|
+
<Input {id} bind:value={name} placeholder={t('template_name_hint')} />
|
|
302
|
+
{/snippet}
|
|
303
|
+
</Field>
|
|
304
|
+
|
|
305
|
+
<Field label={t('template_desc_label')}>
|
|
306
|
+
{#snippet children(id: string)}
|
|
307
|
+
<Textarea {id} bind:value={description} rows={2} />
|
|
308
|
+
{/snippet}
|
|
309
|
+
</Field>
|
|
310
|
+
|
|
311
|
+
<!--
|
|
312
|
+
A space template makes a space, so "only in this space" is not a thing it can be. The control
|
|
313
|
+
is left out rather than shown and ignored: a disabled field somebody cannot act on is a
|
|
314
|
+
question they have to work out the answer to.
|
|
315
|
+
-->
|
|
316
|
+
{#if kind === 'page' && !target}
|
|
317
|
+
<Field label={t('template_scope')}>
|
|
318
|
+
{#snippet children(id: string)}
|
|
319
|
+
<Select
|
|
320
|
+
{id}
|
|
321
|
+
ariaLabel={t('template_scope')}
|
|
322
|
+
value={scope}
|
|
323
|
+
options={[
|
|
324
|
+
{ value: 'space', label: t('template_scope_space', { space: spaceName }) },
|
|
325
|
+
{ value: 'workspace', label: t('template_scope_workspace') },
|
|
326
|
+
]}
|
|
327
|
+
onValueChange={(v: string) => (scope = v as 'space' | 'workspace')}
|
|
328
|
+
/>
|
|
329
|
+
{/snippet}
|
|
330
|
+
</Field>
|
|
331
|
+
|
|
332
|
+
{#if replaceable.length > 0}
|
|
333
|
+
<Field label={t('template_replace')} hint={t('template_replace_hint')}>
|
|
334
|
+
{#snippet children(id: string)}
|
|
335
|
+
<Select
|
|
336
|
+
{id}
|
|
337
|
+
ariaLabel={t('template_replace')}
|
|
338
|
+
value={replaces}
|
|
339
|
+
options={[
|
|
340
|
+
{ value: '', label: t('template_replace_none') },
|
|
341
|
+
...replaceable.map((choice) => ({ value: choice.key as string, label: choice.name })),
|
|
342
|
+
]}
|
|
343
|
+
onValueChange={(v: string) => (replaces = v)}
|
|
344
|
+
/>
|
|
345
|
+
{/snippet}
|
|
346
|
+
</Field>
|
|
347
|
+
{/if}
|
|
348
|
+
{/if}
|
|
349
|
+
|
|
350
|
+
<section class="fields">
|
|
351
|
+
<div class="head">
|
|
352
|
+
<span class="title">{t('template_fields')}</span>
|
|
353
|
+
<Button variant="ghost" size="sm" icon="plus" onclick={addField}>{t('template_field_add')}</Button>
|
|
354
|
+
</div>
|
|
355
|
+
<p class="hint">{t('template_fields_hint')}</p>
|
|
356
|
+
|
|
357
|
+
{#each fields as field, at (at)}
|
|
358
|
+
<div class="field">
|
|
359
|
+
<div class="grid">
|
|
360
|
+
<Field label={t('template_field_name')} error={problemWith(field, at)}>
|
|
361
|
+
{#snippet children(id: string)}
|
|
362
|
+
<Input
|
|
363
|
+
{id}
|
|
364
|
+
value={field.name}
|
|
365
|
+
oninput={(e: Event) =>
|
|
366
|
+
patchField(at, { name: (e.currentTarget as HTMLInputElement).value.toLowerCase() })}
|
|
367
|
+
/>
|
|
368
|
+
{/snippet}
|
|
369
|
+
</Field>
|
|
370
|
+
<Field label={t('template_field_label')}>
|
|
371
|
+
{#snippet children(id: string)}
|
|
372
|
+
<Input
|
|
373
|
+
{id}
|
|
374
|
+
value={field.label}
|
|
375
|
+
oninput={(e: Event) =>
|
|
376
|
+
patchField(at, { label: (e.currentTarget as HTMLInputElement).value })}
|
|
377
|
+
/>
|
|
378
|
+
{/snippet}
|
|
379
|
+
</Field>
|
|
380
|
+
<Field label={t('template_field_type')}>
|
|
381
|
+
{#snippet children(id: string)}
|
|
382
|
+
<Select
|
|
383
|
+
{id}
|
|
384
|
+
ariaLabel={t('template_field_type')}
|
|
385
|
+
value={field.type}
|
|
386
|
+
options={TYPES}
|
|
387
|
+
onValueChange={(v: string) => patchField(at, { type: v as TemplateVariableType })}
|
|
388
|
+
/>
|
|
389
|
+
{/snippet}
|
|
390
|
+
</Field>
|
|
391
|
+
</div>
|
|
392
|
+
|
|
393
|
+
{#if field.type === 'select'}
|
|
394
|
+
<Field label={t('template_field_options')} hint={t('template_field_options_hint')}>
|
|
395
|
+
{#snippet children(id: string)}
|
|
396
|
+
<Textarea
|
|
397
|
+
{id}
|
|
398
|
+
rows={3}
|
|
399
|
+
value={field.options.join('\n')}
|
|
400
|
+
oninput={(e: Event) =>
|
|
401
|
+
patchField(at, {
|
|
402
|
+
options: (e.currentTarget as HTMLTextAreaElement).value
|
|
403
|
+
.split('\n')
|
|
404
|
+
.map((line) => line.trim())
|
|
405
|
+
.filter((line) => line.length > 0),
|
|
406
|
+
})}
|
|
407
|
+
/>
|
|
408
|
+
{/snippet}
|
|
409
|
+
</Field>
|
|
410
|
+
{/if}
|
|
411
|
+
|
|
412
|
+
<div class="foot">
|
|
413
|
+
<Switch
|
|
414
|
+
checked={field.required}
|
|
415
|
+
label={t('template_field_required')}
|
|
416
|
+
onCheckedChange={(v: boolean) => patchField(at, { required: v })}
|
|
417
|
+
/>
|
|
418
|
+
<IconButton
|
|
419
|
+
icon="trash-2"
|
|
420
|
+
variant="ghost"
|
|
421
|
+
size={30}
|
|
422
|
+
label={t('template_field_remove', { label: field.label || field.name })}
|
|
423
|
+
onclick={() => removeField(at)}
|
|
424
|
+
/>
|
|
425
|
+
</div>
|
|
426
|
+
</div>
|
|
427
|
+
{/each}
|
|
428
|
+
</section>
|
|
429
|
+
|
|
430
|
+
{#if error}
|
|
431
|
+
<p class="error" role="alert"><Icon name="triangle-alert" size={14} /> {error}</p>
|
|
432
|
+
{/if}
|
|
433
|
+
</div>
|
|
434
|
+
|
|
435
|
+
{#snippet footer()}
|
|
436
|
+
<Button variant="secondary" onclick={() => (open = false)}>{t('cancel')}</Button>
|
|
437
|
+
<Button aria-busy={busy} disabled={!valid} onclick={submit}>
|
|
438
|
+
{busy ? t('template_saving') : target ? t('template_update') : t('template_save')}
|
|
439
|
+
</Button>
|
|
440
|
+
{/snippet}
|
|
441
|
+
</Dialog>
|
|
442
|
+
|
|
443
|
+
<style>
|
|
444
|
+
.form {
|
|
445
|
+
display: flex;
|
|
446
|
+
flex-direction: column;
|
|
447
|
+
gap: 14px;
|
|
448
|
+
}
|
|
449
|
+
.fields {
|
|
450
|
+
display: flex;
|
|
451
|
+
flex-direction: column;
|
|
452
|
+
gap: 10px;
|
|
453
|
+
padding-top: 6px;
|
|
454
|
+
border-top: 1px solid var(--kern-border-hairline);
|
|
455
|
+
}
|
|
456
|
+
.head {
|
|
457
|
+
display: flex;
|
|
458
|
+
align-items: center;
|
|
459
|
+
justify-content: space-between;
|
|
460
|
+
gap: 12px;
|
|
461
|
+
}
|
|
462
|
+
.title {
|
|
463
|
+
font-size: 13px;
|
|
464
|
+
font-weight: 500;
|
|
465
|
+
color: var(--kern-ink-700);
|
|
466
|
+
}
|
|
467
|
+
.hint {
|
|
468
|
+
margin: 0;
|
|
469
|
+
font-size: 12px;
|
|
470
|
+
color: var(--kern-ink-450);
|
|
471
|
+
}
|
|
472
|
+
.field {
|
|
473
|
+
display: flex;
|
|
474
|
+
flex-direction: column;
|
|
475
|
+
gap: 10px;
|
|
476
|
+
padding: 12px;
|
|
477
|
+
border: 1px solid var(--kern-border);
|
|
478
|
+
border-radius: var(--kern-r-md);
|
|
479
|
+
background: var(--kern-surface-raised);
|
|
480
|
+
}
|
|
481
|
+
.grid {
|
|
482
|
+
display: grid;
|
|
483
|
+
/* `auto-fit` rather than three fixed columns: the dialog is narrow on a phone, and three inputs
|
|
484
|
+
side by side there is three inputs nobody can type in. */
|
|
485
|
+
grid-template-columns: repeat(auto-fit, minmax(160px, 1fr));
|
|
486
|
+
gap: 10px;
|
|
487
|
+
}
|
|
488
|
+
.foot {
|
|
489
|
+
display: flex;
|
|
490
|
+
align-items: center;
|
|
491
|
+
justify-content: space-between;
|
|
492
|
+
gap: 12px;
|
|
493
|
+
}
|
|
494
|
+
.error {
|
|
495
|
+
display: flex;
|
|
496
|
+
align-items: center;
|
|
497
|
+
gap: 6px;
|
|
498
|
+
margin: 0;
|
|
499
|
+
font-size: 13px;
|
|
500
|
+
color: var(--kern-danger);
|
|
501
|
+
}
|
|
502
|
+
</style>
|
|
@@ -21,10 +21,13 @@ 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'
|
|
27
29
|
import SidebarRecents from './SidebarRecents.svelte'
|
|
30
|
+
import TemplatePicker from './TemplatePicker.svelte'
|
|
28
31
|
|
|
29
32
|
/**
|
|
30
33
|
* Quire's spaces and pages, in the application sidebar (DESIGN.md §2.3).
|
|
@@ -217,9 +220,75 @@ function openTrash() {
|
|
|
217
220
|
void navigation.go(`/${workspaceSlug}/quire/${encodeURIComponent(activeSpace.key)}/trash`)
|
|
218
221
|
}
|
|
219
222
|
|
|
223
|
+
// ---------------------------------------------------------------------------------------------
|
|
224
|
+
// The space menu: getting a whole space out, and getting somebody else's wiki in
|
|
225
|
+
// ---------------------------------------------------------------------------------------------
|
|
226
|
+
|
|
227
|
+
/**
|
|
228
|
+
* Both of these are about the *space* rather than about a page, and neither had a home before.
|
|
229
|
+
*
|
|
230
|
+
* The sidebar is where a space is chosen, so it is where a space's own actions belong — the page
|
|
231
|
+
* menu is one page's business, and the spaces index is a list of cards. The menu is built per
|
|
232
|
+
* permission and is not rendered at all when it would be empty: an ellipsis that opens onto nothing
|
|
233
|
+
* teaches people that the ellipsis is not worth pressing.
|
|
234
|
+
*
|
|
235
|
+
* Neither dialog is mounted until it has been asked for. They both make requests on open, and an
|
|
236
|
+
* always-mounted pair would run them on every Quire screen for everybody.
|
|
237
|
+
*/
|
|
238
|
+
let exportOpen = $state(false)
|
|
239
|
+
let importOpen = $state(false)
|
|
240
|
+
|
|
241
|
+
const canExport = $derived(canQuire('pageExport'))
|
|
242
|
+
const canImport = $derived(canQuire('pageImport'))
|
|
243
|
+
|
|
244
|
+
const spaceMenu = $derived.by((): MenuItem[] => {
|
|
245
|
+
const items: MenuItem[] = []
|
|
246
|
+
if (canExport)
|
|
247
|
+
items.push({
|
|
248
|
+
id: 'export-space',
|
|
249
|
+
label: t('export_space_menu'),
|
|
250
|
+
icon: 'download',
|
|
251
|
+
onSelect: () => (exportOpen = true),
|
|
252
|
+
})
|
|
253
|
+
if (canImport)
|
|
254
|
+
items.push({
|
|
255
|
+
id: 'import-space',
|
|
256
|
+
label: t('import_menu'),
|
|
257
|
+
icon: 'upload',
|
|
258
|
+
onSelect: () => (importOpen = true),
|
|
259
|
+
})
|
|
260
|
+
if (items.length > 0) {
|
|
261
|
+
items.push({ type: 'separator' })
|
|
262
|
+
items.push({
|
|
263
|
+
id: 'transfers',
|
|
264
|
+
label: t('transfers'),
|
|
265
|
+
icon: 'package',
|
|
266
|
+
onSelect: () => void navigation.go(`/${workspaceSlug}/quire/transfers`),
|
|
267
|
+
})
|
|
268
|
+
}
|
|
269
|
+
return items
|
|
270
|
+
})
|
|
271
|
+
|
|
220
272
|
let creating = $state(false)
|
|
221
273
|
|
|
222
|
-
|
|
274
|
+
/**
|
|
275
|
+
* "New page" opens the picker; the picker's first row makes a blank one.
|
|
276
|
+
*
|
|
277
|
+
* The indirection is what keeps a blank page cheap. The picker holds focus on **Blank page**, so
|
|
278
|
+
* pressing New page and then Enter is the whole gesture — one keystroke more than before, and the
|
|
279
|
+
* five templates are there for the times somebody wants one.
|
|
280
|
+
*/
|
|
281
|
+
let pickerOpen = $state(false)
|
|
282
|
+
let pickerParent = $state<string | null>(null)
|
|
283
|
+
|
|
284
|
+
function createPage(parentId: string | null) {
|
|
285
|
+
if (!activeSpace) return
|
|
286
|
+
pickerParent = parentId
|
|
287
|
+
pickerOpen = true
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
/** What the picker's blank row does, and what "New page" used to do on its own. */
|
|
291
|
+
async function createBlankPage(parentId: string | null) {
|
|
223
292
|
if (!activeSpace || creating) return
|
|
224
293
|
creating = true
|
|
225
294
|
try {
|
|
@@ -239,6 +308,12 @@ async function createPage(parentId: string | null) {
|
|
|
239
308
|
creating = false
|
|
240
309
|
}
|
|
241
310
|
}
|
|
311
|
+
|
|
312
|
+
/** A page made from a template lands in the tree exactly where a blank one would have. */
|
|
313
|
+
function madeFromTemplate(pageId: string | null) {
|
|
314
|
+
if (pickerParent) expanded = new Set(expanded).add(pickerParent)
|
|
315
|
+
if (pageId) openPage(pageId)
|
|
316
|
+
}
|
|
242
317
|
</script>
|
|
243
318
|
|
|
244
319
|
<div class="wrap">
|
|
@@ -273,6 +348,23 @@ async function createPage(parentId: string | null) {
|
|
|
273
348
|
{/snippet}
|
|
274
349
|
</DropdownMenu>
|
|
275
350
|
{/if}
|
|
351
|
+
<!--
|
|
352
|
+
The space's own actions, beside the box that searches it. Only drawn when there is a space to
|
|
353
|
+
act on and at least one action the person may take.
|
|
354
|
+
-->
|
|
355
|
+
{#if activeSpace && spaceMenu.length > 0}
|
|
356
|
+
<DropdownMenu items={spaceMenu}>
|
|
357
|
+
{#snippet trigger(props: Record<string, unknown>)}
|
|
358
|
+
<IconButton
|
|
359
|
+
{...props}
|
|
360
|
+
icon="ellipsis"
|
|
361
|
+
size={30}
|
|
362
|
+
variant="ghost"
|
|
363
|
+
label={t('space_actions')}
|
|
364
|
+
/>
|
|
365
|
+
{/snippet}
|
|
366
|
+
</DropdownMenu>
|
|
367
|
+
{/if}
|
|
276
368
|
</div>
|
|
277
369
|
|
|
278
370
|
<div class="scroll">
|
|
@@ -430,6 +522,25 @@ async function createPage(parentId: string | null) {
|
|
|
430
522
|
<LabelManager bind:open={labelsOpen} {workspaceId} spaceId={activeSpace.id} />
|
|
431
523
|
{/if}
|
|
432
524
|
|
|
525
|
+
<!--
|
|
526
|
+
Mounted with the sidebar rather than behind `{#if open}`, and it costs nothing: every query in both
|
|
527
|
+
dialogs is `enabled: open`, so an unopened dialog makes no request. Rendering them conditionally
|
|
528
|
+
would also cut the closing animation off — the component would leave the tree on the same tick the
|
|
529
|
+
dialog decided to close — and it is what makes `bind:open` from a menu item work at all.
|
|
530
|
+
-->
|
|
531
|
+
{#if activeSpace}
|
|
532
|
+
<ExportDialog bind:open={exportOpen} {workspaceId} spaceId={activeSpace.id} />
|
|
533
|
+
<ImportDialog bind:open={importOpen} {workspaceId} space={activeSpace} />
|
|
534
|
+
<TemplatePicker
|
|
535
|
+
bind:open={pickerOpen}
|
|
536
|
+
{workspaceId}
|
|
537
|
+
spaceId={activeSpace.id}
|
|
538
|
+
parentId={pickerParent}
|
|
539
|
+
onBlank={() => void createBlankPage(pickerParent)}
|
|
540
|
+
onMade={(result) => madeFromTemplate(result.pageId)}
|
|
541
|
+
/>
|
|
542
|
+
{/if}
|
|
543
|
+
|
|
433
544
|
<style>
|
|
434
545
|
.wrap {
|
|
435
546
|
display: flex;
|