@kernhq/module-quire 0.14.0 → 0.16.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/contract/models.d.ts +209 -0
- package/dist/contract/models.d.ts.map +1 -1
- package/dist/contract/models.js +151 -0
- package/dist/contract/models.js.map +1 -1
- package/dist/contract/permissions.d.ts.map +1 -1
- package/dist/contract/permissions.js +32 -0
- package/dist/contract/permissions.js.map +1 -1
- package/dist/contract/properties.d.ts +3 -3
- package/dist/contract/router.d.ts +509 -8
- package/dist/contract/router.d.ts.map +1 -1
- package/dist/contract/router.js +211 -1
- package/dist/contract/router.js.map +1 -1
- package/dist/server/_impl.d.ts +573 -1269
- package/dist/server/_impl.d.ts.map +1 -1
- package/dist/server/_impl.js +180 -0
- package/dist/server/_impl.js.map +1 -1
- package/dist/server/export/markdown.d.ts.map +1 -1
- package/dist/server/export/markdown.js +84 -1
- package/dist/server/export/markdown.js.map +1 -1
- package/dist/server/render.d.ts +190 -1
- package/dist/server/render.d.ts.map +1 -1
- package/dist/server/render.js +413 -0
- package/dist/server/render.js.map +1 -1
- package/dist/server/schema.d.ts +260 -1
- package/dist/server/schema.d.ts.map +1 -1
- package/dist/server/schema.js +108 -1
- package/dist/server/schema.js.map +1 -1
- package/dist/server/services/databases.d.ts +5 -5
- package/dist/server/services/index.d.ts +6 -0
- package/dist/server/services/index.d.ts.map +1 -1
- package/dist/server/services/index.js +27 -3
- package/dist/server/services/index.js.map +1 -1
- package/dist/server/services/macros.d.ts +83 -0
- package/dist/server/services/macros.d.ts.map +1 -0
- package/dist/server/services/macros.js +488 -0
- package/dist/server/services/macros.js.map +1 -0
- package/dist/server/services/objects.d.ts +61 -0
- package/dist/server/services/objects.d.ts.map +1 -0
- package/dist/server/services/objects.js +110 -0
- package/dist/server/services/objects.js.map +1 -0
- package/dist/server/services/publications.d.ts +2 -1
- package/dist/server/services/publications.d.ts.map +1 -1
- package/dist/server/services/publications.js +43 -4
- package/dist/server/services/publications.js.map +1 -1
- package/dist/server/services/templates.d.ts +135 -0
- package/dist/server/services/templates.d.ts.map +1 -0
- package/dist/server/services/templates.js +897 -0
- package/dist/server/services/templates.js.map +1 -0
- package/dist/server/services/unfurl.d.ts +154 -0
- package/dist/server/services/unfurl.d.ts.map +1 -0
- package/dist/server/services/unfurl.js +593 -0
- package/dist/server/services/unfurl.js.map +1 -0
- package/dist/server/services/versions.d.ts +12 -0
- package/dist/server/services/versions.d.ts.map +1 -1
- package/dist/server/services/versions.js +3 -1
- package/dist/server/services/versions.js.map +1 -1
- package/migrations/0011_templates.sql +157 -0
- package/migrations/meta/_journal.json +7 -0
- package/package.json +5 -5
- package/src/client/components/NewSpaceDialog.svelte +77 -8
- package/src/client/components/PageEditor.svelte +80 -0
- package/src/client/components/PagePicker.svelte +264 -0
- package/src/client/components/SaveAsTemplateDialog.svelte +502 -0
- package/src/client/components/SidebarSpaces.svelte +33 -1
- package/src/client/components/TemplatePicker.svelte +437 -0
- package/src/client/i18n.ts +327 -0
- package/src/client/index.ts +19 -0
- package/src/client/mock.ts +274 -0
- package/src/client/pages/PageView.svelte +50 -0
- package/src/client/pages/SpacePage.svelte +20 -4
- package/src/client/query.ts +17 -0
- package/src/contract/models.ts +191 -0
- package/src/contract/permissions.ts +33 -0
- package/src/contract/router.ts +228 -0
|
@@ -0,0 +1,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>
|
|
@@ -27,6 +27,7 @@ import LabelManager from './LabelManager.svelte'
|
|
|
27
27
|
import PageTreeRow from './PageTreeRow.svelte'
|
|
28
28
|
import SidebarFavorites from './SidebarFavorites.svelte'
|
|
29
29
|
import SidebarRecents from './SidebarRecents.svelte'
|
|
30
|
+
import TemplatePicker from './TemplatePicker.svelte'
|
|
30
31
|
|
|
31
32
|
/**
|
|
32
33
|
* Quire's spaces and pages, in the application sidebar (DESIGN.md §2.3).
|
|
@@ -270,7 +271,24 @@ const spaceMenu = $derived.by((): MenuItem[] => {
|
|
|
270
271
|
|
|
271
272
|
let creating = $state(false)
|
|
272
273
|
|
|
273
|
-
|
|
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) {
|
|
274
292
|
if (!activeSpace || creating) return
|
|
275
293
|
creating = true
|
|
276
294
|
try {
|
|
@@ -290,6 +308,12 @@ async function createPage(parentId: string | null) {
|
|
|
290
308
|
creating = false
|
|
291
309
|
}
|
|
292
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
|
+
}
|
|
293
317
|
</script>
|
|
294
318
|
|
|
295
319
|
<div class="wrap">
|
|
@@ -507,6 +531,14 @@ async function createPage(parentId: string | null) {
|
|
|
507
531
|
{#if activeSpace}
|
|
508
532
|
<ExportDialog bind:open={exportOpen} {workspaceId} spaceId={activeSpace.id} />
|
|
509
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
|
+
/>
|
|
510
542
|
{/if}
|
|
511
543
|
|
|
512
544
|
<style>
|