@kernhq/module-quire 0.11.1 → 0.13.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 (52) hide show
  1. package/dist/contract/models.d.ts +46 -0
  2. package/dist/contract/models.d.ts.map +1 -1
  3. package/dist/contract/models.js +73 -0
  4. package/dist/contract/models.js.map +1 -1
  5. package/dist/contract/permissions.d.ts +17 -1
  6. package/dist/contract/permissions.d.ts.map +1 -1
  7. package/dist/contract/permissions.js +35 -0
  8. package/dist/contract/permissions.js.map +1 -1
  9. package/dist/contract/router.d.ts +756 -0
  10. package/dist/contract/router.d.ts.map +1 -1
  11. package/dist/contract/router.js +346 -2
  12. package/dist/contract/router.js.map +1 -1
  13. package/dist/server/_impl.d.ts +826 -0
  14. package/dist/server/_impl.d.ts.map +1 -1
  15. package/dist/server/_impl.js +336 -2
  16. package/dist/server/_impl.js.map +1 -1
  17. package/dist/server/schema.d.ts +318 -1
  18. package/dist/server/schema.d.ts.map +1 -1
  19. package/dist/server/schema.js +86 -0
  20. package/dist/server/schema.js.map +1 -1
  21. package/dist/server/services/access.d.ts +1 -0
  22. package/dist/server/services/access.d.ts.map +1 -1
  23. package/dist/server/services/index.d.ts +3 -0
  24. package/dist/server/services/index.d.ts.map +1 -1
  25. package/dist/server/services/index.js +5 -1
  26. package/dist/server/services/index.js.map +1 -1
  27. package/dist/server/services/pages.d.ts.map +1 -1
  28. package/dist/server/services/pages.js +6 -0
  29. package/dist/server/services/pages.js.map +1 -1
  30. package/dist/server/services/publications.d.ts +191 -0
  31. package/dist/server/services/publications.d.ts.map +1 -0
  32. package/dist/server/services/publications.js +772 -0
  33. package/dist/server/services/publications.js.map +1 -0
  34. package/dist/server/services/versions.d.ts +26 -1
  35. package/dist/server/services/versions.d.ts.map +1 -1
  36. package/dist/server/services/versions.js +44 -15
  37. package/dist/server/services/versions.js.map +1 -1
  38. package/migrations/0008_publications.sql +140 -0
  39. package/migrations/0009_public_asset_references.sql +35 -0
  40. package/migrations/meta/_journal.json +14 -0
  41. package/package.json +1 -1
  42. package/src/client/components/PublishDialog.svelte +920 -0
  43. package/src/client/components/SidebarRecents.svelte +17 -1
  44. package/src/client/i18n.ts +448 -0
  45. package/src/client/index.ts +21 -0
  46. package/src/client/mock.ts +436 -2
  47. package/src/client/pages/PageView.svelte +139 -0
  48. package/src/client/public-url.ts +76 -0
  49. package/src/client/query.ts +16 -0
  50. package/src/contract/models.ts +77 -0
  51. package/src/contract/permissions.ts +54 -1
  52. package/src/contract/router.ts +380 -1
@@ -0,0 +1,920 @@
1
+ <script lang="ts">
2
+ import { Button, Dialog, Field, Icon, Input, relativeTime, Select, Switch, Textarea, toast } from '@kernhq/ui'
3
+ import { createQuery, useQueryClient } from '@tanstack/svelte-query'
4
+ import { untrack } from 'svelte'
5
+ import { getQuireApi } from '../api-instance.js'
6
+ import { t } from '../i18n.js'
7
+ import type { Page, PageNode, Publication } from '../index.js'
8
+ import { publicSiteUrl } from '../public-url.js'
9
+ import { quireKeys } from '../query.js'
10
+
11
+ /**
12
+ * Putting a page on the internet, and taking it off again.
13
+ *
14
+ * **This is the one screen in Quire where a misunderstanding is a data leak**, and three things
15
+ * follow from that rather than from taste:
16
+ *
17
+ * 1. **The consequence is stated above the control that causes it**, in a block nobody has to open,
18
+ * in the words somebody would use afterwards: anyone with the link, without signing in, this
19
+ * page *and everything under it*. A checkbox called "Public" with a tooltip is the shape of this
20
+ * screen that leaks a handbook.
21
+ * 2. **What a stranger sees is measured, not claimed.** After publishing, the dialog calls the
22
+ * signed-out `public.site` — the same procedure the internet calls — and reports the number of
23
+ * pages that came back. That is the only line here that cannot be wrong: a root page nobody ever
24
+ * published produces a live URL and an empty site, which looks exactly like success from the
25
+ * inside and is a broken link to everybody else.
26
+ * 3. **The per-page list says why a page is not on the site, not only whether it was opted out.**
27
+ * A switch on its own answers "did somebody exclude this", and the interesting question is "is
28
+ * this readable by strangers" — which a never-published page, an archived one and a child of an
29
+ * opted-out parent all answer differently and for different reasons.
30
+ *
31
+ * What this dialog deliberately does *not* do is decide who may publish. The menu entry that opens
32
+ * it is gated on `quire.page.publish` for the rail's benefit, and every procedure it calls asks the
33
+ * same permission about this page on the server. A person who reaches it another way gets refused
34
+ * there, which is the only refusal that counts.
35
+ */
36
+ interface Props {
37
+ open?: boolean
38
+ workspaceId: string
39
+ spaceId: string
40
+ /** the page the site would be rooted at — its own published version is the front page */
41
+ page: Pick<Page, 'id' | 'title' | 'publishedVersionId'>
42
+ }
43
+ let { open = $bindable(false), workspaceId, spaceId, page }: Props = $props()
44
+
45
+ const api = getQuireApi()
46
+ const client = useQueryClient()
47
+
48
+ // ------------------------------------------------------------------------------------------------
49
+ // What exists
50
+ // ------------------------------------------------------------------------------------------------
51
+
52
+ /**
53
+ * Space-scoped, because that is the only listing the server offers — a publication has no
54
+ * "for this page" read, and asking for one page's would be a second procedure saying the same
55
+ * thing. The list is small (one row per published site in the space) and it is already the key a
56
+ * `publication` announcement invalidates, so another tab publishing something redraws this one.
57
+ */
58
+ const publicationsQuery = createQuery(() => ({
59
+ queryKey: quireKeys.publications(workspaceId, spaceId),
60
+ enabled: open && Boolean(workspaceId && spaceId),
61
+ queryFn: () => api.publications.list({ workspaceId, spaceId }),
62
+ }))
63
+ const publication = $derived((publicationsQuery.data ?? []).find((row) => row.rootPageId === page.id) ?? null)
64
+ /**
65
+ * Nothing is drawn until the list has arrived.
66
+ *
67
+ * "No publication yet" and "not asked yet" are the same value here, and rendering the first while
68
+ * the second is true opens a published page on the *unpublished* screen — a "Publish to the web"
69
+ * button for a site that already exists, which invites somebody to make a second one. It is a
70
+ * flicker on a fast connection and a wrong screen on a slow one.
71
+ */
72
+ const loading = $derived(publicationsQuery.isPending)
73
+
74
+ /**
75
+ * The same key the sidebar holds, on purpose.
76
+ *
77
+ * Opening this dialog costs no request in the common case — the tree for the space somebody is
78
+ * reading is already in the cache — and, more usefully, toggling a page here refreshes the sidebar
79
+ * for free. `includeArchived: false` has to match the sidebar's call exactly or the two queries are
80
+ * different queries wearing the same key.
81
+ */
82
+ const treeQuery = createQuery(() => ({
83
+ queryKey: quireKeys.tree(workspaceId, spaceId),
84
+ enabled: open && Boolean(workspaceId && spaceId),
85
+ queryFn: () => api.pages.tree({ workspaceId, spaceId, includeArchived: false }),
86
+ }))
87
+
88
+ /**
89
+ * The signed-out read of the site, made from inside the app.
90
+ *
91
+ * `public.site` replaces the principal before it does anything, so this call is answered exactly as
92
+ * it would be for a stranger — which is what makes the sentence it feeds honest rather than
93
+ * reassuring. `retry: false` because the failure *is* the answer: an address that 404s should say
94
+ * so at once, not after three attempts.
95
+ */
96
+ const siteQuery = createQuery(() => ({
97
+ queryKey: quireKeys.site(workspaceId, publication?.slug ?? ''),
98
+ enabled: open && Boolean(workspaceId && publication),
99
+ retry: false,
100
+ queryFn: () => api.public.site({ workspaceId, slug: publication?.slug ?? '', token: null }),
101
+ }))
102
+
103
+ // ------------------------------------------------------------------------------------------------
104
+ // The form
105
+ // ------------------------------------------------------------------------------------------------
106
+
107
+ const SLUG_PATTERN = /^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$/
108
+
109
+ const slugify = (value: string) =>
110
+ value
111
+ .toLowerCase()
112
+ .replace(/[^a-z0-9]+/g, '-')
113
+ .replace(/^-+|-+$/g, '')
114
+ .slice(0, 64)
115
+ .replace(/-+$/g, '')
116
+
117
+ let slug = $state('')
118
+ let includeDescendants = $state(true)
119
+ let indexable = $state(true)
120
+ let theme = $state<Publication['theme']>('auto')
121
+ let seoTitle = $state('')
122
+ let seoDescription = $state('')
123
+ let ogImageUrl = $state('')
124
+ let expiresOn = $state('')
125
+ /** empty means "leave the password alone" — never "there is no password". See `passwordPatch`. */
126
+ let password = $state('')
127
+ let dropPassword = $state(false)
128
+ let showMore = $state(false)
129
+ let error = $state<string | null>(null)
130
+ let confirmingUnpublish = $state(false)
131
+
132
+ /**
133
+ * Which row the form was filled from, so it is filled once rather than on every render.
134
+ *
135
+ * An `$effect` that reads the fields it also writes is its own trigger, so `formFor` is read
136
+ * through `untrack` — the effect depends on `open` and on the publication, and on nothing it sets.
137
+ * Resetting it when the dialog closes is what makes reopening show what is stored rather than a
138
+ * half-finished edit somebody abandoned.
139
+ */
140
+ let formFor = $state<string | null>(null)
141
+
142
+ $effect(() => {
143
+ const row = open ? publication : null
144
+ const key = open ? (row?.id ?? 'new') : null
145
+ if (untrack(() => formFor) === key) return
146
+ formFor = key
147
+ error = null
148
+ password = ''
149
+ dropPassword = false
150
+ confirmingUnpublish = false
151
+ if (key === null) return
152
+ slug = row ? row.slug : (slugify(page.title) || 'page').slice(0, 64)
153
+ includeDescendants = row ? row.includeDescendants : true
154
+ indexable = row ? row.indexable : true
155
+ theme = row ? row.theme : 'auto'
156
+ seoTitle = row?.seoTitle ?? ''
157
+ seoDescription = row?.seoDescription ?? ''
158
+ ogImageUrl = row?.ogImageUrl ?? ''
159
+ expiresOn = row?.expiresAt ? row.expiresAt.slice(0, 10) : ''
160
+ showMore = false
161
+ })
162
+
163
+ const slugValid = $derived(slug.length >= 2 && slug.length <= 64 && SLUG_PATTERN.test(slug))
164
+
165
+ /**
166
+ * A site that exists and serves nothing, which is a third state and used to be told as the first.
167
+ *
168
+ * The publication row and the root page's published version are independent: making the one does
169
+ * not publish the other, so "there is a publication" is not "this page is public". Every sentence
170
+ * in this dialog that used to branch on `publication` alone said the wrong thing for as long as
171
+ * somebody had made a site out of a page they had not published.
172
+ */
173
+ const emptySite = $derived(publication !== null && page.publishedVersionId === null)
174
+
175
+ /**
176
+ * The address that is **actually serving**, not the one in the box.
177
+ *
178
+ * Once a site exists the two can differ for as long as somebody is halfway through renaming it, and
179
+ * the copy button is right there: a link box that followed the field would hand out a dead URL to
180
+ * anybody who typed a character and then copied. Before there is a publication there is nothing to
181
+ * be wrong about, so it follows the field — that is the whole point of showing it then.
182
+ */
183
+ const url = $derived(
184
+ publicSiteUrl({ workspaceId, slug: publication?.slug ?? (slugValid ? slug : 'your-page') }),
185
+ )
186
+ /** The address it becomes on save — shown as a sentence, so there are never two links on screen. */
187
+ const pendingUrl = $derived(
188
+ publication && slugValid && slug !== publication.slug ? publicSiteUrl({ workspaceId, slug }) : null,
189
+ )
190
+
191
+ /**
192
+ * `password` is three-valued at the contract and has to stay three-valued here.
193
+ *
194
+ * A string sets one, `null` removes one, and leaving the key out changes nothing. Collapsing that
195
+ * into "whatever is in the box" is how a rename quietly takes the door off a handbook: the field is
196
+ * empty on every visit, because a password is never read back.
197
+ */
198
+ const passwordPatch = $derived<{ password?: string | null }>(
199
+ dropPassword ? { password: null } : password.length > 0 ? { password } : {},
200
+ )
201
+
202
+ const settings = $derived({
203
+ slug,
204
+ includeDescendants,
205
+ indexable,
206
+ theme,
207
+ seoTitle: seoTitle.trim(),
208
+ seoDescription: seoDescription.trim(),
209
+ ogImageUrl: ogImageUrl.trim() || null,
210
+ expiresAt: expiresOn ? new Date(`${expiresOn}T23:59:59`).toISOString() : null,
211
+ })
212
+
213
+ const dirty = $derived.by(() => {
214
+ const row = publication
215
+ if (!row) return false
216
+ if (dropPassword || password.length > 0) return true
217
+ return (
218
+ settings.slug !== row.slug ||
219
+ settings.includeDescendants !== row.includeDescendants ||
220
+ settings.indexable !== row.indexable ||
221
+ settings.theme !== row.theme ||
222
+ settings.seoTitle !== row.seoTitle ||
223
+ settings.seoDescription !== row.seoDescription ||
224
+ settings.ogImageUrl !== row.ogImageUrl ||
225
+ (settings.expiresAt ?? '').slice(0, 10) !== (row.expiresAt ?? '').slice(0, 10)
226
+ )
227
+ })
228
+
229
+ // ------------------------------------------------------------------------------------------------
230
+ // The pages underneath
231
+ // ------------------------------------------------------------------------------------------------
232
+
233
+ interface Descendant {
234
+ node: PageNode
235
+ depth: number
236
+ /** an ancestor between this page and the root is opted out, so nothing here is reachable */
237
+ blocked: boolean
238
+ }
239
+
240
+ const descendants = $derived.by((): Descendant[] => {
241
+ const childrenOf = new Map<string, PageNode[]>()
242
+ for (const node of treeQuery.data ?? []) {
243
+ if (!node.parentId) continue
244
+ childrenOf.set(node.parentId, [...(childrenOf.get(node.parentId) ?? []), node])
245
+ }
246
+ const out: Descendant[] = []
247
+ const walk = (parentId: string, depth: number, blocked: boolean) => {
248
+ for (const node of childrenOf.get(parentId) ?? []) {
249
+ out.push({ node, depth, blocked })
250
+ walk(node.id, depth + 1, blocked || node.excludedFromPublic)
251
+ }
252
+ }
253
+ walk(page.id, 0, false)
254
+ return out
255
+ })
256
+
257
+ /**
258
+ * Why this page is not on the site — the first reason that applies, in the order somebody can act
259
+ * on them. `null` means it is public, which is the only state that needs no explanation.
260
+ */
261
+ function reasonFor(row: Descendant): string | null {
262
+ if (row.node.excludedFromPublic) return t('share_child_private')
263
+ if (row.node.archivedAt) return t('share_child_archived')
264
+ if (!row.node.hasPublishedVersion) return t('share_child_draft')
265
+ if (row.blocked) return t('share_child_blocked')
266
+ return null
267
+ }
268
+
269
+ // ------------------------------------------------------------------------------------------------
270
+ // Doing it
271
+ // ------------------------------------------------------------------------------------------------
272
+
273
+ /* Set in the same tick as the click. `isPending` arrives a render late, so two quick clicks on
274
+ "Publish" are one render apart and both get through — which here means two sites. */
275
+ let busy = false
276
+ let rowsBusy = $state<string[]>([])
277
+
278
+ /**
279
+ * Where focus goes when the confirmation arms and disarms.
280
+ *
281
+ * Pressing "Unpublish" destroys the button being pressed and draws two others in its place, so the
282
+ * browser has nothing to keep focus on and the dialog's own trap hands it to the **Close** button —
283
+ * eight Tab stops away from the confirmation, and one Enter away from dismissing the whole dialog.
284
+ * A keyboard user pressing Enter twice, which is what confirming feels like, closes the dialog and
285
+ * leaves the site published with nothing said. Same family as disabling a focused control, and it
286
+ * has the same fix: put focus somewhere deliberate rather than letting it fall.
287
+ *
288
+ * It lands on **Cancel**, not on the confirming button: a repeated Enter must not be able to take a
289
+ * site down, and Cancel is one Tab from the answer either way. Disarming sends it back to the
290
+ * control that armed it, so the round trip leaves the keyboard where it started.
291
+ */
292
+ let confirmRef = $state<HTMLElement | null>(null)
293
+ let unpublishRef = $state<HTMLElement | null>(null)
294
+ /* A plain `let`, like `busy`: it records what the last run saw and must not be a dependency of the
295
+ effect that writes it. Equal values mean this run is a re-render rather than a change, and the
296
+ first run is always equal — so opening the dialog never steals focus from the address field. */
297
+ let confirmWas = false
298
+ $effect(() => {
299
+ const asking = confirmingUnpublish
300
+ if (confirmWas === asking) return
301
+ confirmWas = asking
302
+ ;(asking ? confirmRef : unpublishRef)?.focus()
303
+ })
304
+
305
+ const message = (err: unknown) => (err instanceof Error ? err.message : String(err))
306
+
307
+ async function refresh() {
308
+ await client.invalidateQueries({ queryKey: quireKeys.publications(workspaceId, spaceId) })
309
+ await client.invalidateQueries({ queryKey: quireKeys.tree(workspaceId, spaceId) })
310
+ }
311
+
312
+ async function publish() {
313
+ if (busy || !slugValid) return
314
+ busy = true
315
+ error = null
316
+ try {
317
+ await api.publications.create({
318
+ workspaceId,
319
+ rootPageId: page.id,
320
+ ...settings,
321
+ ...passwordPatch,
322
+ })
323
+ password = ''
324
+ dropPassword = false
325
+ await refresh()
326
+ toast.success(
327
+ page.publishedVersionId === null ? t('share_published_empty_toast') : t('share_published_toast'),
328
+ )
329
+ } catch (err) {
330
+ error = message(err)
331
+ } finally {
332
+ busy = false
333
+ }
334
+ }
335
+
336
+ async function save() {
337
+ const row = publication
338
+ if (busy || !row || !slugValid) return
339
+ busy = true
340
+ error = null
341
+ try {
342
+ await api.publications.update({
343
+ workspaceId,
344
+ publicationId: row.id,
345
+ ...settings,
346
+ ...passwordPatch,
347
+ })
348
+ password = ''
349
+ dropPassword = false
350
+ await refresh()
351
+ toast.success(t('share_saved'))
352
+ } catch (err) {
353
+ error = message(err)
354
+ } finally {
355
+ busy = false
356
+ }
357
+ }
358
+
359
+ async function unpublish() {
360
+ const row = publication
361
+ if (busy || !row) return
362
+ busy = true
363
+ error = null
364
+ try {
365
+ await api.publications.remove({ workspaceId, publicationId: row.id })
366
+ confirmingUnpublish = false
367
+ await refresh()
368
+ toast.success(t('share_unpublish_done'))
369
+ } catch (err) {
370
+ error = message(err)
371
+ } finally {
372
+ busy = false
373
+ }
374
+ }
375
+
376
+ /**
377
+ * Guarded rather than disabled: disabling the switch somebody just pressed blurs it, and the
378
+ * browser hands that focus to `<body>` — so a keyboard user toggling three pages in a row loses
379
+ * their place after the first one.
380
+ */
381
+ async function setExcluded(node: PageNode, excluded: boolean) {
382
+ if (rowsBusy.includes(node.id)) return
383
+ rowsBusy = [...rowsBusy, node.id]
384
+ try {
385
+ await api.publications.optOut({ workspaceId, pageId: node.id, excluded })
386
+ await client.invalidateQueries({ queryKey: quireKeys.tree(workspaceId, spaceId) })
387
+ await client.invalidateQueries({ queryKey: quireKeys.site(workspaceId, publication?.slug ?? '') })
388
+ } catch (err) {
389
+ error = message(err)
390
+ } finally {
391
+ rowsBusy = rowsBusy.filter((id) => id !== node.id)
392
+ }
393
+ }
394
+
395
+ async function copyLink() {
396
+ try {
397
+ await navigator.clipboard.writeText(url)
398
+ toast.success(t('share_copied'))
399
+ } catch {
400
+ // A denied clipboard is not worth an error toast: the link is on screen to select.
401
+ toast.info(t('share_copy_manually'))
402
+ }
403
+ }
404
+ </script>
405
+
406
+ <Dialog bind:open title={t('share_title')} size="lg">
407
+ <div class="sheet">
408
+ {#if loading}
409
+ <p class="note">{t('loading')}</p>
410
+ {:else if publicationsQuery.isError}
411
+ <p class="error" role="alert">{t('page_error_desc')}</p>
412
+ {:else}
413
+ <!--
414
+ The consequence, above the control that causes it, whether or not it has already happened.
415
+ It is not a warning that appears when something is wrong — it is a description of what the
416
+ button does, and it stays true after the button has been pressed.
417
+ -->
418
+ <section class="tell" class:live={publication !== null && !emptySite}>
419
+ <Icon name={publication && !emptySite ? 'globe' : 'triangle-alert'} size={17} />
420
+ <div class="tell-body">
421
+ <p class="tell-title">
422
+ {#if emptySite}{t('share_published_empty')}
423
+ {:else if publication}{t('share_published')}
424
+ {:else}{t('share_warn_title')}{/if}
425
+ </p>
426
+ <p>{t('share_warn_body')}</p>
427
+ <p>{t('share_warn_version')}</p>
428
+ <p>{t('share_warn_unpublished')}</p>
429
+ {#if indexable}<p>{t('share_warn_search')}</p>{/if}
430
+ </div>
431
+ </section>
432
+
433
+ <!--
434
+ Not gated on there being no publication yet, which is what made this dialog say three things
435
+ at once and mean two of them. Publishing a page that has never been published put "This page
436
+ is public" at the top and a success toast over it, while the measured line forty pixels below
437
+ read "A signed-out visitor cannot open this address yet" — the loudest affordances asserting
438
+ what only the quietest one had checked. The site being made does not make the page published,
439
+ so the sentence that says so belongs on screen for exactly as long as it is true.
440
+ -->
441
+ {#if page.publishedVersionId === null}
442
+ <p class="caution" role="status">{t('share_needs_publish')}</p>
443
+ {/if}
444
+
445
+ <!-- The address, and the link it makes, side by side so one is visibly the other. -->
446
+ <Field
447
+ label={t('share_address')}
448
+ hint={t('share_address_hint')}
449
+ error={slugValid ? null : t('share_address_invalid')}
450
+ >
451
+ {#snippet children(id: string)}
452
+ <Input
453
+ {id}
454
+ mono
455
+ value={slug}
456
+ oninput={(e: Event) => (slug = slugify((e.currentTarget as HTMLInputElement).value))}
457
+ />
458
+ {/snippet}
459
+ </Field>
460
+
461
+ <!--
462
+ A heading rather than nothing. Read linearly — which is how a screen reader meets it — an
463
+ unlabelled `<code>` between a form field and two buttons is a string of punctuation with no
464
+ say in what it is. `aria-label` would have been the wrong fix: on an element with text content
465
+ it *replaces* the text, so the one thing worth hearing would be the one thing hidden.
466
+ -->
467
+ <p class="link-label">{t('share_link')}</p>
468
+ <div class="link">
469
+ <code class="link-url">{url}</code>
470
+ <Button size="sm" variant="secondary" icon="copy" onclick={() => void copyLink()}>
471
+ {t('share_copy')}
472
+ </Button>
473
+ {#if publication}
474
+ <Button
475
+ size="sm"
476
+ variant="ghost"
477
+ icon="external-link"
478
+ href={url}
479
+ target="_blank"
480
+ rel="noreferrer noopener"
481
+ >
482
+ {t('share_open')}
483
+ </Button>
484
+ {/if}
485
+ </div>
486
+
487
+ {#if pendingUrl}
488
+ <p class="note" role="status">
489
+ {t('share_address_pending')}
490
+ <code class="inline-url">{pendingUrl}</code>
491
+ </p>
492
+ {/if}
493
+
494
+ <!--
495
+ Measured, not claimed. Everything else on this screen is what we intend to be true; this line
496
+ is the only one that went and asked.
497
+ -->
498
+ {#if publication}
499
+ <p class="check" role="status">
500
+ {#if siteQuery.isPending}
501
+ {t('share_check_running')}
502
+ {:else if siteQuery.isError}
503
+ <Icon name="triangle-alert" size={13} />{t('share_check_failed')}
504
+ {:else if siteQuery.data?.locked}
505
+ <Icon name="lock" size={13} />{t('share_check_locked')}
506
+ {:else}
507
+ <Icon name="check" size={13} />{t('share_check_ok', {
508
+ count: siteQuery.data?.site?.nav.length ?? 0,
509
+ })}
510
+ {/if}
511
+ </p>
512
+ <p class="when">{t('share_published_when', { when: relativeTime(publication.createdAt) })}</p>
513
+ {/if}
514
+
515
+ <div class="switches">
516
+ <Switch
517
+ checked={includeDescendants}
518
+ label={t('share_include')}
519
+ description={t('share_include_desc')}
520
+ onCheckedChange={(next: boolean) => (includeDescendants = next)}
521
+ />
522
+ {#if includeDescendants && descendants.length > 0}
523
+ <p class="note">{t('share_count', { count: descendants.length })}</p>
524
+ {/if}
525
+ </div>
526
+
527
+ <button type="button" class="more" onclick={() => (showMore = !showMore)}>
528
+ <Icon name={showMore ? 'chevron-up' : 'chevron-down'} size={14} />
529
+ {showMore ? t('share_less') : t('share_more')}
530
+ </button>
531
+
532
+ {#if showMore}
533
+ <div class="form">
534
+ <Switch
535
+ checked={indexable}
536
+ label={t('share_indexable')}
537
+ description={t('share_indexable_desc')}
538
+ onCheckedChange={(next: boolean) => (indexable = next)}
539
+ />
540
+
541
+ <Field
542
+ label={t('share_password')}
543
+ hint={publication?.hasPassword ? t('share_password_keep') : t('share_password_hint')}
544
+ >
545
+ {#snippet children(id: string)}
546
+ <Input
547
+ {id}
548
+ type="password"
549
+ autocomplete="new-password"
550
+ bind:value={password}
551
+ placeholder={publication?.hasPassword ? t('share_password_on') : ''}
552
+ />
553
+ {/snippet}
554
+ </Field>
555
+ {#if publication?.hasPassword}
556
+ <Switch
557
+ checked={dropPassword}
558
+ label={t('share_password_remove')}
559
+ onCheckedChange={(next: boolean) => (dropPassword = next)}
560
+ />
561
+ {/if}
562
+
563
+ <Field label={t('share_expires')} hint={t('share_expires_hint')}>
564
+ {#snippet children(id: string)}
565
+ <Input {id} type="date" bind:value={expiresOn} />
566
+ {/snippet}
567
+ </Field>
568
+
569
+ <Field label={t('share_theme')}>
570
+ {#snippet children(id: string)}
571
+ <Select
572
+ {id}
573
+ ariaLabel={t('share_theme')}
574
+ value={theme}
575
+ options={[
576
+ { value: 'auto', label: t('share_theme_auto') },
577
+ { value: 'light', label: t('share_theme_light') },
578
+ { value: 'dark', label: t('share_theme_dark') },
579
+ ]}
580
+ onValueChange={(v: string) => (theme = v as Publication['theme'])}
581
+ />
582
+ {/snippet}
583
+ </Field>
584
+
585
+ <Field label={t('share_seo_title')} hint={t('share_seo_title_hint')}>
586
+ {#snippet children(id: string)}
587
+ <Input {id} bind:value={seoTitle} />
588
+ {/snippet}
589
+ </Field>
590
+
591
+ <Field label={t('share_seo_description')}>
592
+ {#snippet children(id: string)}
593
+ <Textarea {id} bind:value={seoDescription} rows={2} />
594
+ {/snippet}
595
+ </Field>
596
+
597
+ <Field label={t('share_og_image')} hint={t('share_og_image_hint')}>
598
+ {#snippet children(id: string)}
599
+ <Input {id} bind:value={ogImageUrl} placeholder="https://" />
600
+ {/snippet}
601
+ </Field>
602
+ </div>
603
+ {/if}
604
+
605
+ <!--
606
+ Only once the site exists. Before that the switches would write a flag against pages nobody is
607
+ publishing, which is a real setting with no visible effect — and this list is the one place
608
+ somebody looks to find out what is on the internet.
609
+ -->
610
+ {#if publication}
611
+ <section class="children">
612
+ <h3>{t('share_children')}</h3>
613
+ {#if !includeDescendants}
614
+ <p class="note">{t('share_children_off')}</p>
615
+ {:else if treeQuery.isError}
616
+ <!-- Never "nothing is nested under this page" because the tree failed to load: on this
617
+ screen an empty list reads as a promise that nothing else is public. -->
618
+ <p class="error" role="alert">{t('tree_error')}</p>
619
+ {:else if treeQuery.isPending}
620
+ <p class="note">{t('loading')}</p>
621
+ {:else if descendants.length === 0}
622
+ <p class="note">{t('share_children_none')}</p>
623
+ {:else}
624
+ <p class="note">{t('share_children_desc')}</p>
625
+ <ul>
626
+ {#each descendants as row (row.node.id)}
627
+ {@const reason = reasonFor(row)}
628
+ <!--
629
+ The reason is a sibling of the switch, not its `description`.
630
+
631
+ `Switch` puts a description inside its own `<label>` and names the control from the
632
+ title alone, so a description is drawn on screen and reachable by nobody using a
633
+ screen reader — and the reason is the whole difference between a switch that is on
634
+ because the page is public and one that is on while the page is archived. As a
635
+ paragraph in the list item it is read like any other content. No `ariaLabel` either:
636
+ the visible title *is* the accessible name, which is the right pattern, and the prop
637
+ would have been silently ignored beside a `label`.
638
+ -->
639
+ <li style={`--depth:${Math.min(row.depth, 6)}`}>
640
+ <Switch
641
+ class="row-switch"
642
+ checked={!row.node.excludedFromPublic}
643
+ label={row.node.title.trim() || t('untitled')}
644
+ onCheckedChange={(next: boolean) => void setExcluded(row.node, !next)}
645
+ />
646
+ {#if reason}<p class="reason">{reason}</p>{/if}
647
+ </li>
648
+ {/each}
649
+ </ul>
650
+ {/if}
651
+ </section>
652
+ {/if}
653
+
654
+ {#if error}<p class="error" role="alert">{error}</p>{/if}
655
+ {/if}
656
+ </div>
657
+
658
+ {#snippet footer()}
659
+ <div class="foot">
660
+ {#if loading || publicationsQuery.isError}
661
+ <span class="spacer"></span>
662
+ <Button variant="secondary" onclick={() => (open = false)}>{t('cancel')}</Button>
663
+ {:else if publication}
664
+ {#if confirmingUnpublish}
665
+ <!-- `alert`, because the question is drawn where nobody is looking: it appears at the
666
+ foot of a long dialog, and a reader who is not watching that corner is given no other
667
+ sign that pressing the button asked something rather than doing it. -->
668
+ <p class="ask" role="alert">{t('share_unpublish_ask')}</p>
669
+ <Button
670
+ bind:ref={confirmRef}
671
+ variant="secondary"
672
+ size="sm"
673
+ onclick={() => (confirmingUnpublish = false)}
674
+ >
675
+ {t('cancel')}
676
+ </Button>
677
+ <Button variant="danger" size="sm" onclick={() => void unpublish()}>
678
+ {t('share_unpublish')}
679
+ </Button>
680
+ {:else}
681
+ <Button
682
+ bind:ref={unpublishRef}
683
+ variant="danger"
684
+ size="sm"
685
+ onclick={() => (confirmingUnpublish = true)}
686
+ >
687
+ {t('share_unpublish')}
688
+ </Button>
689
+ <span class="spacer"></span>
690
+ {#if dirty}
691
+ <Button disabled={!slugValid} onclick={() => void save()}>{t('share_save')}</Button>
692
+ {:else}
693
+ <Button variant="secondary" onclick={() => (open = false)}>{t('share_done')}</Button>
694
+ {/if}
695
+ {/if}
696
+ {:else}
697
+ <span class="spacer"></span>
698
+ <Button variant="secondary" onclick={() => (open = false)}>{t('cancel')}</Button>
699
+ <!--
700
+ `disabled` for an address that cannot be a URL, and a plain flag inside `publish()` for the
701
+ second click. They are different problems: the first is a statement about the form that the
702
+ field beside it already explains, and the second is a race a disabled attribute loses,
703
+ because it reaches the button one render after the click that should have been stopped.
704
+ -->
705
+ <Button disabled={!slugValid} onclick={() => void publish()}>{t('share_publish')}</Button>
706
+ {/if}
707
+ </div>
708
+ {/snippet}
709
+ </Dialog>
710
+
711
+ <style>
712
+ .sheet {
713
+ display: flex;
714
+ flex-direction: column;
715
+ gap: 16px;
716
+ }
717
+
718
+ /*
719
+ * The sentence that matters. Warning-tinted before it is true and accent-tinted after, because
720
+ * afterwards it is not a warning — it is the state of the page, and a permanent amber banner is a
721
+ * thing people stop seeing.
722
+ */
723
+ .tell {
724
+ display: flex;
725
+ gap: 10px;
726
+ padding: 12px 13px;
727
+ border-radius: var(--kern-r-lg);
728
+ background: var(--kern-warning-tint);
729
+ color: var(--kern-ink-700);
730
+ }
731
+ .tell.live {
732
+ background: var(--kern-accent-tint);
733
+ }
734
+ .tell-body {
735
+ display: flex;
736
+ flex-direction: column;
737
+ gap: 6px;
738
+ min-width: 0;
739
+ }
740
+ .tell p {
741
+ margin: 0;
742
+ font-size: 13px;
743
+ line-height: 1.55;
744
+ text-wrap: pretty;
745
+ }
746
+ .tell-title {
747
+ font-weight: 600;
748
+ font-size: 13.5px;
749
+ color: var(--kern-ink-900);
750
+ }
751
+ .caution {
752
+ margin: 0;
753
+ padding: 10px 12px;
754
+ border-radius: var(--kern-r-lg);
755
+ background: var(--kern-info-tint);
756
+ color: var(--kern-ink-700);
757
+ font-size: 13px;
758
+ line-height: 1.55;
759
+ }
760
+
761
+ .link-label {
762
+ margin: 0 0 -10px;
763
+ font-size: 12.5px;
764
+ font-weight: 500;
765
+ color: var(--kern-ink-600);
766
+ }
767
+ .link {
768
+ display: flex;
769
+ align-items: center;
770
+ gap: 8px;
771
+ flex-wrap: wrap;
772
+ padding: 9px 11px;
773
+ border: 1px solid var(--kern-border);
774
+ border-radius: var(--kern-r-lg);
775
+ background: var(--kern-surface-input);
776
+ }
777
+ .link-url {
778
+ flex: 1;
779
+ min-width: 0;
780
+ font-family: var(--kern-font-mono);
781
+ font-size: 12.5px;
782
+ color: var(--kern-ink-800);
783
+ overflow-x: auto;
784
+ white-space: nowrap;
785
+ /* the address is Latin whichever way the page runs, and it is read left to right */
786
+ direction: ltr;
787
+ unicode-bidi: isolate;
788
+ text-align: start;
789
+ }
790
+
791
+ .check,
792
+ .when {
793
+ display: flex;
794
+ align-items: center;
795
+ gap: 6px;
796
+ margin: -8px 0 0;
797
+ font-size: 12.5px;
798
+ line-height: 1.5;
799
+ /* muted with a colour: `opacity` fades this against the dialog until nobody can read it */
800
+ color: var(--kern-ink-450);
801
+ }
802
+ .when {
803
+ margin-block-start: -12px;
804
+ }
805
+
806
+ .switches {
807
+ display: flex;
808
+ flex-direction: column;
809
+ gap: 6px;
810
+ }
811
+ .note {
812
+ margin: 0;
813
+ font-size: 12.5px;
814
+ line-height: 1.5;
815
+ color: var(--kern-ink-450);
816
+ text-wrap: pretty;
817
+ }
818
+ /*
819
+ * A URL is Latin and reads left to right whichever way the sentence around it runs. Without its own
820
+ * direction the bidi algorithm lays it out against the paragraph's, and a Persian note about
821
+ * `https://…/p/ws/handbook` comes apart at the slashes.
822
+ */
823
+ .inline-url {
824
+ display: inline-block;
825
+ direction: ltr;
826
+ unicode-bidi: isolate;
827
+ font-family: var(--kern-font-mono);
828
+ font-size: 12px;
829
+ color: var(--kern-ink-700);
830
+ overflow-wrap: anywhere;
831
+ }
832
+
833
+ .more {
834
+ align-self: flex-start;
835
+ display: inline-flex;
836
+ align-items: center;
837
+ gap: 6px;
838
+ min-height: 32px;
839
+ padding: 0 8px;
840
+ margin-inline-start: -8px;
841
+ border: 0;
842
+ border-radius: var(--kern-r-md);
843
+ background: none;
844
+ color: var(--kern-ink-600);
845
+ font: inherit;
846
+ font-size: 13px;
847
+ cursor: pointer;
848
+ }
849
+ .more:hover {
850
+ background: var(--kern-surface-hover);
851
+ color: var(--kern-ink-900);
852
+ }
853
+
854
+ .form {
855
+ display: flex;
856
+ flex-direction: column;
857
+ gap: 14px;
858
+ padding: 14px;
859
+ border: 1px solid var(--kern-border);
860
+ border-radius: var(--kern-r-lg);
861
+ background: var(--kern-surface-raised);
862
+ }
863
+
864
+ .children h3 {
865
+ margin: 0 0 4px;
866
+ font-size: 13.5px;
867
+ font-weight: 600;
868
+ color: var(--kern-ink-900);
869
+ }
870
+ .children ul {
871
+ list-style: none;
872
+ margin: 10px 0 0;
873
+ padding: 0;
874
+ display: flex;
875
+ flex-direction: column;
876
+ }
877
+ .children li {
878
+ /* logical, so a Persian tree indents from the right like the sidebar does */
879
+ padding-inline-start: calc(var(--depth) * 16px);
880
+ padding-block: 5px;
881
+ border-block-end: 1px solid var(--kern-border-hairline);
882
+ }
883
+ .children li:last-child {
884
+ border-block-end: 0;
885
+ }
886
+ .children :global(.row-switch) {
887
+ width: 100%;
888
+ }
889
+ .reason {
890
+ margin: 0 0 2px;
891
+ font-size: 12px;
892
+ line-height: 1.45;
893
+ color: var(--kern-ink-450);
894
+ }
895
+
896
+ .error {
897
+ margin: 0;
898
+ font-size: 13px;
899
+ color: var(--kern-danger);
900
+ }
901
+
902
+ .foot {
903
+ display: flex;
904
+ align-items: center;
905
+ gap: 8px;
906
+ flex-wrap: wrap;
907
+ width: 100%;
908
+ }
909
+ .spacer {
910
+ flex: 1;
911
+ }
912
+ .ask {
913
+ flex: 1 1 220px;
914
+ margin: 0;
915
+ font-size: 12.5px;
916
+ line-height: 1.5;
917
+ color: var(--kern-ink-700);
918
+ text-wrap: pretty;
919
+ }
920
+ </style>