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