@kernhq/module-quire 0.10.9 → 0.11.1
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 +87 -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.map +1 -1
- package/dist/contract/permissions.js +32 -0
- package/dist/contract/permissions.js.map +1 -1
- package/dist/contract/router.d.ts +645 -0
- package/dist/contract/router.d.ts.map +1 -1
- package/dist/contract/router.js +137 -2
- package/dist/contract/router.js.map +1 -1
- package/dist/server/_impl.d.ts +877 -0
- package/dist/server/_impl.d.ts.map +1 -1
- package/dist/server/_impl.js +127 -1
- package/dist/server/_impl.js.map +1 -1
- package/dist/server/schema.d.ts +482 -1
- package/dist/server/schema.d.ts.map +1 -1
- package/dist/server/schema.js +115 -1
- package/dist/server/schema.js.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 +3 -0
- package/dist/server/services/index.js.map +1 -1
- package/dist/server/services/organisation.d.ts +117 -0
- package/dist/server/services/organisation.d.ts.map +1 -0
- package/dist/server/services/organisation.js +319 -0
- package/dist/server/services/organisation.js.map +1 -0
- package/dist/server/services/pages.d.ts +16 -1
- package/dist/server/services/pages.d.ts.map +1 -1
- package/dist/server/services/pages.js +62 -3
- package/dist/server/services/pages.js.map +1 -1
- package/migrations/0007_organisation.sql +114 -0
- package/migrations/meta/0007_snapshot.json +1588 -0
- package/migrations/meta/_journal.json +7 -0
- package/package.json +1 -1
- package/src/client/components/ConfirmDialog.svelte +123 -0
- package/src/client/components/FavoriteStar.svelte +81 -0
- package/src/client/components/LabelChip.svelte +46 -0
- package/src/client/components/LabelManager.svelte +336 -0
- package/src/client/components/PageLabels.svelte +158 -0
- package/src/client/components/SidebarFavorites.svelte +262 -0
- package/src/client/components/SidebarRecents.svelte +98 -0
- package/src/client/components/SidebarSpaces.svelte +266 -1
- package/src/client/i18n.ts +387 -0
- package/src/client/index.ts +8 -0
- package/src/client/mock.ts +306 -0
- package/src/client/module.ts +18 -0
- package/src/client/pages/PageView.svelte +284 -9
- package/src/client/pages/TrashPage.svelte +378 -0
- package/src/client/query.ts +24 -0
- package/src/contract/models.ts +83 -0
- package/src/contract/permissions.ts +36 -0
- package/src/contract/router.ts +153 -1
|
@@ -14,11 +14,16 @@ import {
|
|
|
14
14
|
relativeTime,
|
|
15
15
|
Skeleton,
|
|
16
16
|
session,
|
|
17
|
+
toast,
|
|
17
18
|
} from '@kernhq/ui'
|
|
18
19
|
import { createQuery, useQueryClient } from '@tanstack/svelte-query'
|
|
20
|
+
import { untrack } from 'svelte'
|
|
19
21
|
import { getQuireApi } from '../api-instance.js'
|
|
20
22
|
import CommentsPanel from '../components/CommentsPanel.svelte'
|
|
23
|
+
import ConfirmDialog from '../components/ConfirmDialog.svelte'
|
|
24
|
+
import FavoriteStar from '../components/FavoriteStar.svelte'
|
|
21
25
|
import PageEditor from '../components/PageEditor.svelte'
|
|
26
|
+
import PageLabels from '../components/PageLabels.svelte'
|
|
22
27
|
import VersionHistory from '../components/VersionHistory.svelte'
|
|
23
28
|
import { type CoreApi, toPerson } from '../core-api.js'
|
|
24
29
|
import DatabaseView from '../database/DatabaseView.svelte'
|
|
@@ -144,19 +149,69 @@ $effect(() => {
|
|
|
144
149
|
dirty = false
|
|
145
150
|
})
|
|
146
151
|
|
|
152
|
+
/**
|
|
153
|
+
* The title saves as you type, not only when you leave the field.
|
|
154
|
+
*
|
|
155
|
+
* It used to save on `blur` alone, and nothing else — so typing a name and then going somewhere
|
|
156
|
+
* without blurring first (a keyboard shortcut, ⌘K, closing the tab, any programmatic navigation)
|
|
157
|
+
* threw the name away and left the page called "Untitled" for ever. Measured against the live
|
|
158
|
+
* stack: fifteen seconds after typing, `mod_quire.pages.title` was still `''`; it only ever became
|
|
159
|
+
* the typed value on blur.
|
|
160
|
+
*
|
|
161
|
+
* A page's *body* has never had this problem, because it is a Y.Doc the collab service persists on
|
|
162
|
+
* its own schedule. The title is not — it is a plain column behind `pages.update` — so the schedule
|
|
163
|
+
* has to be written here. `docs/adr/0006` says the title should live in the Y.Doc beside the body
|
|
164
|
+
* for exactly this reason, and because two people renaming at once currently clobber each other;
|
|
165
|
+
* that is a larger change and this is not a substitute for it.
|
|
166
|
+
*/
|
|
167
|
+
const TITLE_SAVE_AFTER_MS = 700
|
|
168
|
+
let titleTimer: ReturnType<typeof setTimeout> | null = null
|
|
169
|
+
/* Set in the same tick as the call, because `isPending` arrives a render late and two saves would
|
|
170
|
+
race to write the same column in an order neither of them chose. */
|
|
171
|
+
let savingTitle = false
|
|
172
|
+
|
|
173
|
+
function queueTitleSave() {
|
|
174
|
+
if (titleTimer) clearTimeout(titleTimer)
|
|
175
|
+
titleTimer = setTimeout(() => {
|
|
176
|
+
titleTimer = null
|
|
177
|
+
void saveTitle()
|
|
178
|
+
}, TITLE_SAVE_AFTER_MS)
|
|
179
|
+
}
|
|
180
|
+
|
|
147
181
|
async function saveTitle() {
|
|
148
|
-
if (
|
|
182
|
+
if (titleTimer) {
|
|
183
|
+
clearTimeout(titleTimer)
|
|
184
|
+
titleTimer = null
|
|
185
|
+
}
|
|
186
|
+
if (!doc || !dirty || savingTitle) return
|
|
149
187
|
const next = title.trim()
|
|
150
188
|
if (next === doc.title) {
|
|
151
189
|
dirty = false
|
|
152
190
|
return
|
|
153
191
|
}
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
192
|
+
savingTitle = true
|
|
193
|
+
try {
|
|
194
|
+
await api.pages.update({ workspaceId, pageId, title: next })
|
|
195
|
+
dirty = false
|
|
196
|
+
await client.invalidateQueries({ queryKey: quireKeys.page(workspaceId, pageId) })
|
|
197
|
+
await client.invalidateQueries({ queryKey: quireKeys.tree(workspaceId, doc.spaceId) })
|
|
198
|
+
} finally {
|
|
199
|
+
savingTitle = false
|
|
200
|
+
}
|
|
158
201
|
}
|
|
159
202
|
|
|
203
|
+
/* Leaving the page mid-word must not lose the word. */
|
|
204
|
+
$effect(() => {
|
|
205
|
+
void pageId
|
|
206
|
+
return () => {
|
|
207
|
+
if (titleTimer) {
|
|
208
|
+
clearTimeout(titleTimer)
|
|
209
|
+
titleTimer = null
|
|
210
|
+
}
|
|
211
|
+
if (untrack(() => dirty)) void saveTitle()
|
|
212
|
+
}
|
|
213
|
+
})
|
|
214
|
+
|
|
160
215
|
async function archive(archived: boolean) {
|
|
161
216
|
if (!doc) return
|
|
162
217
|
await api.pages.archive({ workspaceId, pageId, archived })
|
|
@@ -191,12 +246,175 @@ async function revert() {
|
|
|
191
246
|
}
|
|
192
247
|
}
|
|
193
248
|
|
|
249
|
+
// -----------------------------------------------------------------------------------------------
|
|
250
|
+
// Watching, recording, and the way this page is deleted
|
|
251
|
+
// -----------------------------------------------------------------------------------------------
|
|
252
|
+
|
|
253
|
+
/**
|
|
254
|
+
* Whether you are watching, and how many others are.
|
|
255
|
+
*
|
|
256
|
+
* One request, because the control has to draw both — its own pressed state and the number beside
|
|
257
|
+
* it — and asking twice within a keystroke of each other is two requests for one button. The reply
|
|
258
|
+
* to `set` is the same shape, so it goes straight into the cache and nothing refetches.
|
|
259
|
+
*/
|
|
260
|
+
const watchQuery = createQuery(() => ({
|
|
261
|
+
queryKey: quireKeys.watchers(workspaceId, pageId),
|
|
262
|
+
enabled: Boolean(workspaceId && pageId),
|
|
263
|
+
queryFn: () => api.watchers.get({ workspaceId, pageId }),
|
|
264
|
+
}))
|
|
265
|
+
const watching = $derived(watchQuery.data?.watching ?? false)
|
|
266
|
+
const watcherCount = $derived(watchQuery.data?.watchers.length ?? 0)
|
|
267
|
+
let watchBusy = $state(false)
|
|
268
|
+
|
|
269
|
+
async function toggleWatch() {
|
|
270
|
+
if (watchBusy) return
|
|
271
|
+
watchBusy = true
|
|
272
|
+
try {
|
|
273
|
+
const next = await api.watchers.set({ workspaceId, pageId, watching: !watching })
|
|
274
|
+
client.setQueryData(quireKeys.watchers(workspaceId, pageId), next)
|
|
275
|
+
toast.success(next.watching ? t('watch_on') : t('watch_off'))
|
|
276
|
+
} finally {
|
|
277
|
+
watchBusy = false
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
/**
|
|
282
|
+
* Opening a page is what puts it in "Recent".
|
|
283
|
+
*
|
|
284
|
+
* A bump, not a log: one row per person per page, so the table is bounded by pages times people
|
|
285
|
+
* rather than growing for ever to answer a question that only ever wants the most recent handful.
|
|
286
|
+
* Failure is swallowed on purpose — a page you cannot record having read is still a page you are
|
|
287
|
+
* reading, and an error toast about a sidebar list would be noise over the thing you came for.
|
|
288
|
+
*/
|
|
289
|
+
$effect(() => {
|
|
290
|
+
const ws = workspaceId
|
|
291
|
+
const id = pageId
|
|
292
|
+
if (!ws || !id) return
|
|
293
|
+
void api.recents
|
|
294
|
+
.record({ workspaceId: ws, pageId: id })
|
|
295
|
+
.then(() => client.invalidateQueries({ queryKey: quireKeys.recents(ws) }))
|
|
296
|
+
.catch(() => {})
|
|
297
|
+
})
|
|
298
|
+
|
|
299
|
+
/**
|
|
300
|
+
* How many pages "Move to trash" is about to take.
|
|
301
|
+
*
|
|
302
|
+
* It takes the whole subtree, and it used to fire with no confirmation and no way back: deleting
|
|
303
|
+
* "Working here" silently took "Your first week" and "Time off" with it. So the count is worked out
|
|
304
|
+
* *before* the dialog says anything, from the space's tree — loaded only when the dialog opens,
|
|
305
|
+
* because a page nobody is deleting should not pay for a second copy of the tree.
|
|
306
|
+
*
|
|
307
|
+
* `includeArchived: true`, under a key of its own: the sidebar holds the same call with archived
|
|
308
|
+
* pages left out, and reusing that key would either hand this the wrong list or replace the
|
|
309
|
+
* sidebar's. Archived descendants go to the trash like any other, so a count that skipped them
|
|
310
|
+
* would be the same lie in a smaller size.
|
|
311
|
+
*
|
|
312
|
+
* A database page's rows are not counted. They are pages, and `trashPage` takes them — but they are
|
|
313
|
+
* rows to the person reading, and "and 340 pages inside it" for a table of 340 rows would read as a
|
|
314
|
+
* different disaster from the one about to happen. The toast afterwards reports the number the
|
|
315
|
+
* server actually took.
|
|
316
|
+
*/
|
|
317
|
+
let trashConfirm = $state(false)
|
|
318
|
+
|
|
319
|
+
const subtreeQuery = createQuery(() => ({
|
|
320
|
+
queryKey: [...quireKeys.tree(workspaceId, doc?.spaceId ?? ''), 'with-archived'],
|
|
321
|
+
enabled: trashConfirm && Boolean(workspaceId && doc?.spaceId),
|
|
322
|
+
queryFn: () => api.pages.tree({ workspaceId, spaceId: doc?.spaceId ?? '', includeArchived: true }),
|
|
323
|
+
}))
|
|
324
|
+
|
|
325
|
+
/**
|
|
326
|
+
* `isFetching`, not just `data`, because a cached tree is not a current one.
|
|
327
|
+
*
|
|
328
|
+
* TanStack hands a query its cached value the instant it is enabled and refetches behind it, so
|
|
329
|
+
* `data === undefined` only catches the *first* open. Every later one renders whatever the last
|
|
330
|
+
* fetch left, and the last fetch is routinely wrong: `refreshAfterMoving` runs while the dialog is
|
|
331
|
+
* still open, so it reloads the tree with the subtree already in the trash and caches a tree
|
|
332
|
+
* without it. Trash a page, press **Undo**, reach for **Move to trash** again, and the dialog said
|
|
333
|
+
* "It goes to the trash, and you can put it back from there" — the singular sentence, for a page
|
|
334
|
+
* that takes two others with it. Measured; the whole point of this dialog is that number, so a
|
|
335
|
+
* stale one is worse than none.
|
|
336
|
+
*/
|
|
337
|
+
const trashCount = $derived.by((): number | null => {
|
|
338
|
+
const nodes = subtreeQuery.data
|
|
339
|
+
if (!nodes || subtreeQuery.isFetching) return null
|
|
340
|
+
const children = new Map<string, string[]>()
|
|
341
|
+
for (const node of nodes)
|
|
342
|
+
if (node.parentId) children.set(node.parentId, [...(children.get(node.parentId) ?? []), node.id])
|
|
343
|
+
let total = 1
|
|
344
|
+
let guard = 0
|
|
345
|
+
const stack = [pageId]
|
|
346
|
+
while (stack.length > 0 && guard++ < 5000) {
|
|
347
|
+
const id = stack.pop() as string
|
|
348
|
+
for (const child of children.get(id) ?? []) {
|
|
349
|
+
total++
|
|
350
|
+
stack.push(child)
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
return total
|
|
354
|
+
})
|
|
355
|
+
|
|
356
|
+
/**
|
|
357
|
+
* Move it, then offer to take it back.
|
|
358
|
+
*
|
|
359
|
+
* The undo is the point. A confirmation stops the deletion you did not mean to start; it does
|
|
360
|
+
* nothing for the one you meant and regretted, and `pages.restore` puts the whole subtree back —
|
|
361
|
+
* so the toast carries the action rather than leaving the trash screen as the only way home. It
|
|
362
|
+
* outlives this component: the shell owns the toaster, so navigating away does not cancel it.
|
|
363
|
+
*/
|
|
364
|
+
/**
|
|
365
|
+
* Everything a page leaving or rejoining the space changes.
|
|
366
|
+
*
|
|
367
|
+
* The favourites and recents lists are the ones easy to forget, and forgetting them is visible:
|
|
368
|
+
* both are composed by joining to `pages`, so a trashed page silently drops out of them — and a
|
|
369
|
+
* sidebar still offering a shortcut to a page that is in the trash is exactly the kind of thing
|
|
370
|
+
* that makes somebody distrust the sidebar. Nothing else will do it either, because a `page`
|
|
371
|
+
* change invalidates the `page` prefix and these two live under their own.
|
|
372
|
+
*/
|
|
373
|
+
async function refreshAfterMoving(spaceId: string) {
|
|
374
|
+
await client.invalidateQueries({ queryKey: quireKeys.tree(workspaceId, spaceId) })
|
|
375
|
+
await client.invalidateQueries({ queryKey: quireKeys.trash(workspaceId, spaceId) })
|
|
376
|
+
await client.invalidateQueries({ queryKey: quireKeys.favorites(workspaceId) })
|
|
377
|
+
await client.invalidateQueries({ queryKey: quireKeys.recents(workspaceId) })
|
|
378
|
+
}
|
|
379
|
+
|
|
194
380
|
async function trash() {
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
381
|
+
const page = doc
|
|
382
|
+
if (!page) return
|
|
383
|
+
const spaceId = page.spaceId
|
|
384
|
+
const title = page.title.trim() || t('untitled')
|
|
385
|
+
const answer = await api.pages.trashPage({ workspaceId, pageId })
|
|
386
|
+
await refreshAfterMoving(spaceId)
|
|
387
|
+
toast(t('trash_moved', { count: answer.count }), {
|
|
388
|
+
// Long enough to read the sentence, notice the number and decide — the default 2.2s is a
|
|
389
|
+
// confirmation, and this is an offer.
|
|
390
|
+
duration: 9000,
|
|
391
|
+
action: {
|
|
392
|
+
label: t('undo'),
|
|
393
|
+
onClick: () => void undoTrash(workspaceId, pageId, spaceId, title),
|
|
394
|
+
},
|
|
395
|
+
})
|
|
198
396
|
void navigation.go(`/${workspaceSlug}/quire/${encodeURIComponent(spaceKey)}`)
|
|
199
397
|
}
|
|
398
|
+
|
|
399
|
+
/**
|
|
400
|
+
* `workspace` is passed in rather than read from the closure.
|
|
401
|
+
*
|
|
402
|
+
* This runs from a toast that outlives the component — the screen has already navigated away by
|
|
403
|
+
* the time anybody presses **Undo** — so every value it needs is a plain argument. Reaching for
|
|
404
|
+
* `workspaceId` here would be reading a `$derived` belonging to a component that no longer exists.
|
|
405
|
+
*/
|
|
406
|
+
async function undoTrash(workspace: string, id: string, spaceId: string, title: string) {
|
|
407
|
+
try {
|
|
408
|
+
await api.pages.restore({ workspaceId: workspace, pageId: id })
|
|
409
|
+
await client.invalidateQueries({ queryKey: quireKeys.tree(workspace, spaceId) })
|
|
410
|
+
await client.invalidateQueries({ queryKey: quireKeys.trash(workspace, spaceId) })
|
|
411
|
+
await client.invalidateQueries({ queryKey: quireKeys.favorites(workspace) })
|
|
412
|
+
await client.invalidateQueries({ queryKey: quireKeys.recents(workspace) })
|
|
413
|
+
toast.success(t('trash_restore_done', { title }))
|
|
414
|
+
} catch {
|
|
415
|
+
toast.error(t('trash_undo_failed'))
|
|
416
|
+
}
|
|
417
|
+
}
|
|
200
418
|
</script>
|
|
201
419
|
|
|
202
420
|
<div class="with-margin" class:open={showComments}>
|
|
@@ -235,6 +453,7 @@ async function trash() {
|
|
|
235
453
|
oninput={(e) => {
|
|
236
454
|
title = (e.currentTarget as HTMLInputElement).value
|
|
237
455
|
dirty = true
|
|
456
|
+
queueTitleSave()
|
|
238
457
|
}}
|
|
239
458
|
onblur={saveTitle}
|
|
240
459
|
onkeydown={(e) => {
|
|
@@ -249,6 +468,13 @@ async function trash() {
|
|
|
249
468
|
{/if}
|
|
250
469
|
</h1>
|
|
251
470
|
|
|
471
|
+
<!--
|
|
472
|
+
The star sits beside the title rather than in the menu: "keep this to hand" is a thing
|
|
473
|
+
people do while reading, and a two-state control buried behind an ellipsis cannot show its
|
|
474
|
+
state at all.
|
|
475
|
+
-->
|
|
476
|
+
<FavoriteStar {workspaceId} pageId={doc.id} />
|
|
477
|
+
|
|
252
478
|
<DropdownMenu
|
|
253
479
|
items={[
|
|
254
480
|
{
|
|
@@ -277,6 +503,13 @@ async function trash() {
|
|
|
277
503
|
},
|
|
278
504
|
]
|
|
279
505
|
: []),
|
|
506
|
+
{
|
|
507
|
+
id: 'watch',
|
|
508
|
+
label: watching ? t('watch_stop') : t('watch'),
|
|
509
|
+
icon: watching ? 'bell-off' : 'bell',
|
|
510
|
+
hint: watcherCount > 0 ? t('watchers', { count: watcherCount }) : undefined,
|
|
511
|
+
onSelect: () => void toggleWatch(),
|
|
512
|
+
},
|
|
280
513
|
{
|
|
281
514
|
id: 'archive',
|
|
282
515
|
label: doc.archivedAt ? t('unarchive') : t('archive'),
|
|
@@ -284,13 +517,25 @@ async function trash() {
|
|
|
284
517
|
disabled: !editable,
|
|
285
518
|
onSelect: () => void archive(!doc.archivedAt),
|
|
286
519
|
},
|
|
520
|
+
{ type: 'separator' },
|
|
287
521
|
{
|
|
288
522
|
id: 'trash',
|
|
289
523
|
label: t('move_to_trash'),
|
|
290
524
|
icon: 'trash-2',
|
|
291
525
|
danger: true,
|
|
292
526
|
disabled: !editable,
|
|
293
|
-
|
|
527
|
+
// Asks first, and says how many pages it is about to take with it.
|
|
528
|
+
onSelect: () => (trashConfirm = true),
|
|
529
|
+
},
|
|
530
|
+
{
|
|
531
|
+
id: 'open-trash',
|
|
532
|
+
label: t('trash_open'),
|
|
533
|
+
icon: 'rotate-ccw',
|
|
534
|
+
disabled: !editable,
|
|
535
|
+
onSelect: () =>
|
|
536
|
+
void navigation.go(
|
|
537
|
+
`/${workspaceSlug}/quire/${encodeURIComponent(spaceKey)}/trash`,
|
|
538
|
+
),
|
|
294
539
|
},
|
|
295
540
|
]}
|
|
296
541
|
>
|
|
@@ -327,8 +572,23 @@ async function trash() {
|
|
|
327
572
|
{#if peers.length > 0}
|
|
328
573
|
<span class="chip">{t('people_here', { count: peers.length })}</span>
|
|
329
574
|
{/if}
|
|
575
|
+
{#if watching}
|
|
576
|
+
<span class="chip"><Icon name="bell" size={12} /> {t('watchers', { count: watcherCount })}</span>
|
|
577
|
+
{/if}
|
|
330
578
|
</div>
|
|
331
579
|
|
|
580
|
+
<!--
|
|
581
|
+
Under the byline, above the prose: a label is about the page as a whole, so it belongs with
|
|
582
|
+
the things that say what this page *is* rather than inside what it says.
|
|
583
|
+
-->
|
|
584
|
+
<PageLabels
|
|
585
|
+
{workspaceId}
|
|
586
|
+
spaceId={doc.spaceId}
|
|
587
|
+
pageId={doc.id}
|
|
588
|
+
canEdit={editable}
|
|
589
|
+
canManage={canQuire('spaceManage')}
|
|
590
|
+
/>
|
|
591
|
+
|
|
332
592
|
{#if doc.kind === 'page' && doc.hasUnpublishedChanges}
|
|
333
593
|
<div class="banner" role="status">
|
|
334
594
|
<Icon name="circle-alert" size={15} />
|
|
@@ -381,6 +641,21 @@ async function trash() {
|
|
|
381
641
|
{pageId}
|
|
382
642
|
publishedVersionId={doc.publishedVersionId}
|
|
383
643
|
/>
|
|
644
|
+
|
|
645
|
+
<!--
|
|
646
|
+
The body says nothing about numbers until it knows them. Naming a count before the tree has
|
|
647
|
+
loaded would be the same silent lie in a smaller size — "it goes to the trash" for a page that
|
|
648
|
+
is about to take two others with it.
|
|
649
|
+
-->
|
|
650
|
+
<ConfirmDialog
|
|
651
|
+
bind:open={trashConfirm}
|
|
652
|
+
title={t('trash_confirm_title', { title: doc.title.trim() || t('untitled') })}
|
|
653
|
+
body={trashCount === null ? t('loading') : t('trash_confirm_body', { count: trashCount })}
|
|
654
|
+
confirmLabel={t('move_to_trash')}
|
|
655
|
+
danger
|
|
656
|
+
pending={trashCount === null}
|
|
657
|
+
onConfirm={trash}
|
|
658
|
+
/>
|
|
384
659
|
{/if}
|
|
385
660
|
|
|
386
661
|
<style>
|