@kernhq/module-quire 0.10.9 → 0.11.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 +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 +227 -4
- 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
package/src/client/mock.ts
CHANGED
|
@@ -5,18 +5,24 @@ import type {
|
|
|
5
5
|
Database,
|
|
6
6
|
DatabaseRef,
|
|
7
7
|
Row as DatabaseRow,
|
|
8
|
+
FavoriteEntry,
|
|
9
|
+
Label,
|
|
10
|
+
LabelColour,
|
|
8
11
|
Page,
|
|
9
12
|
PageNode,
|
|
10
13
|
PageVersion,
|
|
11
14
|
Property,
|
|
12
15
|
PropertyConfig,
|
|
13
16
|
PropertyType,
|
|
17
|
+
RecentEntry,
|
|
14
18
|
RowRef,
|
|
15
19
|
Space,
|
|
16
20
|
View,
|
|
17
21
|
ViewConfig,
|
|
18
22
|
ViewKind,
|
|
23
|
+
WatchState,
|
|
19
24
|
} from '../contract/index.js'
|
|
25
|
+
import { rankBetween, rankSequence } from './rank.js'
|
|
20
26
|
|
|
21
27
|
/**
|
|
22
28
|
* The in-memory quire API.
|
|
@@ -150,6 +156,16 @@ export function createMockQuireApi() {
|
|
|
150
156
|
page(103, uid(1), 'Your first week', 'ba', 102),
|
|
151
157
|
page(104, uid(1), 'Time off', 'bb', 102),
|
|
152
158
|
page(105, uid(1), 'Expenses', 'c', null, { kind: 'live' }),
|
|
159
|
+
/*
|
|
160
|
+
* A subtree in the trash, because that is the case the trash screen exists for.
|
|
161
|
+
*
|
|
162
|
+
* Deleting a page takes everything under it, and the flat listing the server answers with has
|
|
163
|
+
* one row per page — so a demo whose trash holds a single orphan never exercises the grouping,
|
|
164
|
+
* which is the whole difference between "one page was deleted" and "three were". These two are
|
|
165
|
+
* a parent and its child, deleted together.
|
|
166
|
+
*/
|
|
167
|
+
page(106, uid(1), 'Old expenses policy', 'ca', null, { deletedAt: iso(1728e5) }),
|
|
168
|
+
page(107, uid(1), 'Receipts', 'caa', 106, { deletedAt: iso(1728e5) }),
|
|
153
169
|
page(110, uid(1), 'Onboarding tasks', 'd', null, { kind: 'database' }),
|
|
154
170
|
page(201, uid(2), 'Architecture', 'a'),
|
|
155
171
|
page(202, uid(2), 'Runbooks', 'b'),
|
|
@@ -391,6 +407,70 @@ export function createMockQuireApi() {
|
|
|
391
407
|
comment(161, uid(101), uid(160), uid(160), ME, 'Good point — I will link to it from here.', 36e5),
|
|
392
408
|
]
|
|
393
409
|
|
|
410
|
+
/**
|
|
411
|
+
* How a space is organised, and what one person has made of it.
|
|
412
|
+
*
|
|
413
|
+
* Seeded rather than left empty for the same reason the versions and comments above are: a screen
|
|
414
|
+
* with no data in the demo is a screen the end-to-end sweep cannot see, so an empty favourites
|
|
415
|
+
* group and an empty label picker would ship without anything ever rendering the populated case.
|
|
416
|
+
*
|
|
417
|
+
* Two spaces on purpose. Labels belong to a space — two teams both wanting "Draft" should not
|
|
418
|
+
* have to agree on what it means — and a seed with one space's vocabulary would let a bug that
|
|
419
|
+
* leaks labels across the boundary pass unnoticed.
|
|
420
|
+
*/
|
|
421
|
+
const label = (n: number, spaceId: string, name: string, colour: LabelColour, msAgo: number): Label => ({
|
|
422
|
+
id: uid(n),
|
|
423
|
+
workspaceId: '' as Label['workspaceId'],
|
|
424
|
+
spaceId,
|
|
425
|
+
name,
|
|
426
|
+
colour,
|
|
427
|
+
createdAt: iso(msAgo),
|
|
428
|
+
})
|
|
429
|
+
|
|
430
|
+
const labels: Label[] = [
|
|
431
|
+
label(300, uid(1), 'Draft', 'warning', 8e7),
|
|
432
|
+
label(301, uid(1), 'Needs review', 'info', 79e6),
|
|
433
|
+
label(302, uid(1), 'Reference', 'purple', 78e6),
|
|
434
|
+
label(303, uid(2), 'ADR', 'slate', 77e6),
|
|
435
|
+
]
|
|
436
|
+
|
|
437
|
+
/** page id → label ids. A page wears a set, and `pages.setLabels` replaces the whole of it. */
|
|
438
|
+
const pageLabels = new Map<string, string[]>([
|
|
439
|
+
[uid(101), [uid(302)]],
|
|
440
|
+
[uid(102), [uid(300), uid(301)]],
|
|
441
|
+
[uid(103), [uid(300)]],
|
|
442
|
+
[uid(201), [uid(303)]],
|
|
443
|
+
])
|
|
444
|
+
|
|
445
|
+
/**
|
|
446
|
+
* One person's shortcuts, in the order they arranged them.
|
|
447
|
+
*
|
|
448
|
+
* Ranks come from the real `rankBetween` rather than from the plain sortable strings the rest of
|
|
449
|
+
* this file uses. Reordering favourites is a drag, so the mock is the only thing the interaction
|
|
450
|
+
* is ever tested against — and a second implementation of fractional indexing is a second place
|
|
451
|
+
* for it to be wrong. Three of them, across two spaces, because a favourite is a workspace list
|
|
452
|
+
* and a demo confined to one space would never show that.
|
|
453
|
+
*/
|
|
454
|
+
const favouriteSeed = rankSequence(3)
|
|
455
|
+
const favorites: { pageId: string; position: string; createdAt: string }[] = [
|
|
456
|
+
{ pageId: uid(101), position: favouriteSeed[0] as string, createdAt: iso(72e5) },
|
|
457
|
+
{ pageId: uid(105), position: favouriteSeed[1] as string, createdAt: iso(54e5) },
|
|
458
|
+
{ pageId: uid(202), position: favouriteSeed[2] as string, createdAt: iso(36e5) },
|
|
459
|
+
]
|
|
460
|
+
|
|
461
|
+
/** page id → the people watching it. Deliberately not the same list as the favourites above. */
|
|
462
|
+
const watchers = new Map<string, string[]>([
|
|
463
|
+
[uid(101), [ME, COLLEAGUE]],
|
|
464
|
+
[uid(102), [COLLEAGUE]],
|
|
465
|
+
])
|
|
466
|
+
|
|
467
|
+
/** One row per page, bumped in place — never a visit log. */
|
|
468
|
+
const recents: { pageId: string; viewedAt: string }[] = [
|
|
469
|
+
{ pageId: uid(102), viewedAt: iso(6e5) },
|
|
470
|
+
{ pageId: uid(110), viewedAt: iso(18e5) },
|
|
471
|
+
{ pageId: uid(201), viewedAt: iso(9e6) },
|
|
472
|
+
]
|
|
473
|
+
|
|
394
474
|
let seq = 900
|
|
395
475
|
const nextId = () => uid(++seq)
|
|
396
476
|
const strip = ({ _order, ...p }: Row): Page => p
|
|
@@ -472,6 +552,73 @@ export function createMockQuireApi() {
|
|
|
472
552
|
throw notFound('View')
|
|
473
553
|
}
|
|
474
554
|
|
|
555
|
+
const theLabel = (id: string): Label => {
|
|
556
|
+
const found = labels.find((l) => l.id === id)
|
|
557
|
+
if (!found) throw notFound('Label')
|
|
558
|
+
return found
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
/**
|
|
562
|
+
* The page fields a shortcut row draws — or nothing, when the page has been trashed or purged.
|
|
563
|
+
*
|
|
564
|
+
* The server composes these with a join to `pages`, which is why a favourite whose page is gone
|
|
565
|
+
* simply stops being drawn: the row survives (nothing cascades from a purge), and every read
|
|
566
|
+
* joins. Reproducing that here is the difference between a demo where trashing a favourited page
|
|
567
|
+
* quietly removes it from the sidebar and one where the sidebar keeps a shortcut to nothing.
|
|
568
|
+
*/
|
|
569
|
+
const pageBitsOf = (pageId: string) => {
|
|
570
|
+
const row = pages.find((p) => p.id === pageId && !p.deletedAt)
|
|
571
|
+
return row ? { spaceId: row.spaceId, title: row.title, icon: row.icon, kind: row.kind } : null
|
|
572
|
+
}
|
|
573
|
+
|
|
574
|
+
const byPosition = (a: { position: string }, b: { position: string }) =>
|
|
575
|
+
a.position < b.position ? -1 : a.position > b.position ? 1 : 0
|
|
576
|
+
|
|
577
|
+
const favoriteList = (): FavoriteEntry[] =>
|
|
578
|
+
[...favorites].sort(byPosition).flatMap((f) => {
|
|
579
|
+
const bits = pageBitsOf(f.pageId)
|
|
580
|
+
if (!bits) return []
|
|
581
|
+
return [
|
|
582
|
+
{
|
|
583
|
+
workspaceId: '' as FavoriteEntry['workspaceId'],
|
|
584
|
+
userId: ME as FavoriteEntry['userId'],
|
|
585
|
+
pageId: f.pageId,
|
|
586
|
+
position: f.position,
|
|
587
|
+
createdAt: f.createdAt,
|
|
588
|
+
...bits,
|
|
589
|
+
},
|
|
590
|
+
]
|
|
591
|
+
})
|
|
592
|
+
|
|
593
|
+
const recentList = (limit: number): RecentEntry[] =>
|
|
594
|
+
[...recents]
|
|
595
|
+
.sort((a, b) => (a.viewedAt < b.viewedAt ? 1 : -1))
|
|
596
|
+
.flatMap((r) => {
|
|
597
|
+
const bits = pageBitsOf(r.pageId)
|
|
598
|
+
if (!bits) return []
|
|
599
|
+
return [
|
|
600
|
+
{
|
|
601
|
+
workspaceId: '' as RecentEntry['workspaceId'],
|
|
602
|
+
userId: ME as RecentEntry['userId'],
|
|
603
|
+
pageId: r.pageId,
|
|
604
|
+
viewedAt: r.viewedAt,
|
|
605
|
+
...bits,
|
|
606
|
+
},
|
|
607
|
+
]
|
|
608
|
+
})
|
|
609
|
+
.slice(0, limit)
|
|
610
|
+
|
|
611
|
+
const watchStateOf = (pageId: string): WatchState => {
|
|
612
|
+
const list = watchers.get(pageId) ?? []
|
|
613
|
+
return { watching: list.includes(ME), watchers: list as WatchState['watchers'] }
|
|
614
|
+
}
|
|
615
|
+
|
|
616
|
+
const labelsOn = (pageId: string): Label[] =>
|
|
617
|
+
(pageLabels.get(pageId) ?? []).flatMap((id) => {
|
|
618
|
+
const found = labels.find((l) => l.id === id)
|
|
619
|
+
return found ? [found] : []
|
|
620
|
+
})
|
|
621
|
+
|
|
475
622
|
/**
|
|
476
623
|
* A copy on the way out, because a real API answers with fresh JSON every time.
|
|
477
624
|
*
|
|
@@ -750,8 +897,167 @@ export function createMockQuireApi() {
|
|
|
750
897
|
purge: async ({ pageId }: { pageId: string }) => {
|
|
751
898
|
const ids = new Set(subtree(pageId).map((r) => r.id))
|
|
752
899
|
for (let i = pages.length - 1; i >= 0; i--) if (ids.has(pages[i]!.id)) pages.splice(i, 1)
|
|
900
|
+
/*
|
|
901
|
+
* The favourites, watches and labels of a purged page are deliberately left behind, because
|
|
902
|
+
* that is what the server does: there is no foreign key, so the rows survive and every read
|
|
903
|
+
* joins to `pages` — which is why they are invisible rather than broken. Cleaning them up
|
|
904
|
+
* here would hide the one thing worth noticing about that decision.
|
|
905
|
+
*/
|
|
753
906
|
return { ok: true as const, count: ids.size }
|
|
754
907
|
},
|
|
908
|
+
|
|
909
|
+
setLabels: async ({ pageId, labelIds }: { pageId: string; labelIds: string[] }) => {
|
|
910
|
+
const row = found(pageId)
|
|
911
|
+
for (const id of labelIds) {
|
|
912
|
+
const label = theLabel(id)
|
|
913
|
+
// A label belongs to a space; putting another space's on a page would leak its vocabulary.
|
|
914
|
+
if (label.spaceId !== row.spaceId)
|
|
915
|
+
throw Object.assign(new Error('Every label has to be one this space declares'), {
|
|
916
|
+
code: 'BAD_REQUEST',
|
|
917
|
+
})
|
|
918
|
+
}
|
|
919
|
+
pageLabels.set(pageId, [...labelIds])
|
|
920
|
+
return labelsOn(pageId).map((l) => ({ ...l }))
|
|
921
|
+
},
|
|
922
|
+
},
|
|
923
|
+
|
|
924
|
+
/**
|
|
925
|
+
* The space's vocabulary. Names clash case-insensitively, as they do in the database — "Draft"
|
|
926
|
+
* beside "draft" in one picker is broken data rather than two labels — and the capitalisation
|
|
927
|
+
* somebody typed is what is kept.
|
|
928
|
+
*/
|
|
929
|
+
labels: {
|
|
930
|
+
list: async ({ spaceId }: { spaceId: string }) =>
|
|
931
|
+
labels.filter((l) => l.spaceId === spaceId).map((l) => ({ ...l })),
|
|
932
|
+
|
|
933
|
+
forPage: async ({ pageId }: { pageId: string }) => labelsOn(pageId).map((l) => ({ ...l })),
|
|
934
|
+
|
|
935
|
+
create: async (input: { spaceId: string; name: string; colour?: LabelColour }) => {
|
|
936
|
+
const name = input.name.trim()
|
|
937
|
+
if (!name) throw Object.assign(new Error('A label needs a name'), { code: 'BAD_REQUEST' })
|
|
938
|
+
if (labels.some((l) => l.spaceId === input.spaceId && l.name.toLowerCase() === name.toLowerCase()))
|
|
939
|
+
throw Object.assign(new Error(`This space already has a label called "${name}"`), {
|
|
940
|
+
code: 'CONFLICT',
|
|
941
|
+
})
|
|
942
|
+
const made: Label = {
|
|
943
|
+
id: nextId(),
|
|
944
|
+
workspaceId: '' as Label['workspaceId'],
|
|
945
|
+
spaceId: input.spaceId,
|
|
946
|
+
name,
|
|
947
|
+
colour: input.colour ?? 'grey',
|
|
948
|
+
createdAt: new Date().toISOString(),
|
|
949
|
+
}
|
|
950
|
+
labels.push(made)
|
|
951
|
+
return { ...made }
|
|
952
|
+
},
|
|
953
|
+
|
|
954
|
+
update: async (input: { labelId: string; name?: string; colour?: LabelColour }) => {
|
|
955
|
+
const label = theLabel(input.labelId)
|
|
956
|
+
if (input.name !== undefined) {
|
|
957
|
+
const name = input.name.trim()
|
|
958
|
+
if (!name) throw Object.assign(new Error('A label needs a name'), { code: 'BAD_REQUEST' })
|
|
959
|
+
if (
|
|
960
|
+
labels.some(
|
|
961
|
+
(l) =>
|
|
962
|
+
l.id !== label.id &&
|
|
963
|
+
l.spaceId === label.spaceId &&
|
|
964
|
+
l.name.toLowerCase() === name.toLowerCase(),
|
|
965
|
+
)
|
|
966
|
+
)
|
|
967
|
+
throw Object.assign(new Error(`This space already has a label called "${name}"`), {
|
|
968
|
+
code: 'CONFLICT',
|
|
969
|
+
})
|
|
970
|
+
label.name = name
|
|
971
|
+
}
|
|
972
|
+
if (input.colour !== undefined) label.colour = input.colour
|
|
973
|
+
return { ...label }
|
|
974
|
+
},
|
|
975
|
+
|
|
976
|
+
remove: async ({ labelId }: { labelId: string }) => {
|
|
977
|
+
const label = theLabel(labelId)
|
|
978
|
+
labels.splice(labels.indexOf(label), 1)
|
|
979
|
+
// Off every page that wore it — a label nothing can name is not one anybody can remove.
|
|
980
|
+
for (const [pageId, ids] of pageLabels)
|
|
981
|
+
if (ids.includes(labelId))
|
|
982
|
+
pageLabels.set(
|
|
983
|
+
pageId,
|
|
984
|
+
ids.filter((id) => id !== labelId),
|
|
985
|
+
)
|
|
986
|
+
return { ok: true as const }
|
|
987
|
+
},
|
|
988
|
+
},
|
|
989
|
+
|
|
990
|
+
/**
|
|
991
|
+
* One person's own shortcuts. Every mutation answers with the whole ordered list, because a
|
|
992
|
+
* fractional-index reorder is only meaningful as an ordering — and because it saves the sidebar
|
|
993
|
+
* a refetch to redraw itself.
|
|
994
|
+
*/
|
|
995
|
+
favorites: {
|
|
996
|
+
list: async () => favoriteList(),
|
|
997
|
+
|
|
998
|
+
add: async ({ pageId }: { pageId: string }) => {
|
|
999
|
+
found(pageId)
|
|
1000
|
+
// Starring the same page twice is the same star, not an error.
|
|
1001
|
+
if (!favorites.some((f) => f.pageId === pageId)) {
|
|
1002
|
+
const last = [...favorites].sort(byPosition).at(-1)?.position ?? null
|
|
1003
|
+
favorites.push({
|
|
1004
|
+
pageId,
|
|
1005
|
+
position: rankBetween(last, null),
|
|
1006
|
+
createdAt: new Date().toISOString(),
|
|
1007
|
+
})
|
|
1008
|
+
}
|
|
1009
|
+
return favoriteList()
|
|
1010
|
+
},
|
|
1011
|
+
|
|
1012
|
+
remove: async ({ pageId }: { pageId: string }) => {
|
|
1013
|
+
const at = favorites.findIndex((f) => f.pageId === pageId)
|
|
1014
|
+
if (at >= 0) favorites.splice(at, 1)
|
|
1015
|
+
return favoriteList()
|
|
1016
|
+
},
|
|
1017
|
+
|
|
1018
|
+
reorder: async ({ pageId, afterId = null }: { pageId: string; afterId?: string | null }) => {
|
|
1019
|
+
const moving = favorites.find((f) => f.pageId === pageId)
|
|
1020
|
+
if (!moving) throw notFound('Favourite')
|
|
1021
|
+
const rest = [...favorites].filter((f) => f.pageId !== pageId).sort(byPosition)
|
|
1022
|
+
const at = afterId ? rest.findIndex((f) => f.pageId === afterId) : -1
|
|
1023
|
+
if (afterId && at < 0)
|
|
1024
|
+
throw Object.assign(new Error('afterId is not one of your favourites'), {
|
|
1025
|
+
code: 'BAD_REQUEST',
|
|
1026
|
+
})
|
|
1027
|
+
moving.position = rankBetween(
|
|
1028
|
+
at >= 0 ? (rest[at]?.position ?? null) : null,
|
|
1029
|
+
rest[at + 1]?.position ?? null,
|
|
1030
|
+
)
|
|
1031
|
+
return favoriteList()
|
|
1032
|
+
},
|
|
1033
|
+
},
|
|
1034
|
+
|
|
1035
|
+
watchers: {
|
|
1036
|
+
get: async ({ pageId }: { pageId: string }) => {
|
|
1037
|
+
found(pageId)
|
|
1038
|
+
return watchStateOf(pageId)
|
|
1039
|
+
},
|
|
1040
|
+
|
|
1041
|
+
set: async ({ pageId, watching = true }: { pageId: string; watching?: boolean }) => {
|
|
1042
|
+
found(pageId)
|
|
1043
|
+
const list = watchers.get(pageId) ?? []
|
|
1044
|
+
const next = watching ? (list.includes(ME) ? list : [...list, ME]) : list.filter((id) => id !== ME)
|
|
1045
|
+
watchers.set(pageId, next)
|
|
1046
|
+
return watchStateOf(pageId)
|
|
1047
|
+
},
|
|
1048
|
+
},
|
|
1049
|
+
|
|
1050
|
+
recents: {
|
|
1051
|
+
list: async ({ limit = 10 }: { limit?: number } = {}) => recentList(limit),
|
|
1052
|
+
|
|
1053
|
+
record: async ({ pageId }: { pageId: string }) => {
|
|
1054
|
+
found(pageId)
|
|
1055
|
+
const existing = recents.find((r) => r.pageId === pageId)
|
|
1056
|
+
// Bumped in place, never appended: this is one row per page, not a visit log.
|
|
1057
|
+
if (existing) existing.viewedAt = new Date().toISOString()
|
|
1058
|
+
else recents.push({ pageId, viewedAt: new Date().toISOString() })
|
|
1059
|
+
return { ok: true as const }
|
|
1060
|
+
},
|
|
755
1061
|
},
|
|
756
1062
|
|
|
757
1063
|
versions: {
|
package/src/client/module.ts
CHANGED
|
@@ -49,6 +49,24 @@ export const quireClientModule = defineClientModule({
|
|
|
49
49
|
component: () => import('./pages/SpacePage.svelte'),
|
|
50
50
|
permission: QUIRE_PERMISSIONS.spaceView,
|
|
51
51
|
},
|
|
52
|
+
/**
|
|
53
|
+
* Declared before `/quire/:space/:page` for a reader's benefit only — the shell resolves by
|
|
54
|
+
* specificity, not by order, so the two literal segments here beat the one in the page route
|
|
55
|
+
* whatever sequence they are written in. A page whose id happened to be "trash" could not
|
|
56
|
+
* shadow this, and a space could not shadow the spaces index either.
|
|
57
|
+
*
|
|
58
|
+
* `page.edit`, matching what `pages.trash` asks on the server: the trash is where an edit is
|
|
59
|
+
* undone, so anybody who may edit a page in this space may see what has been taken out of it.
|
|
60
|
+
* Purging is `page.delete` and is gated on the row.
|
|
61
|
+
*/
|
|
62
|
+
{
|
|
63
|
+
path: '/quire/:space/trash',
|
|
64
|
+
component: () => import('./pages/TrashPage.svelte'),
|
|
65
|
+
get title() {
|
|
66
|
+
return t('trash')
|
|
67
|
+
},
|
|
68
|
+
permission: QUIRE_PERMISSIONS.pageEdit,
|
|
69
|
+
},
|
|
52
70
|
{
|
|
53
71
|
path: '/quire/:space/:page',
|
|
54
72
|
component: () => import('./pages/PageView.svelte'),
|
|
@@ -14,11 +14,15 @@ 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'
|
|
19
20
|
import { getQuireApi } from '../api-instance.js'
|
|
20
21
|
import CommentsPanel from '../components/CommentsPanel.svelte'
|
|
22
|
+
import ConfirmDialog from '../components/ConfirmDialog.svelte'
|
|
23
|
+
import FavoriteStar from '../components/FavoriteStar.svelte'
|
|
21
24
|
import PageEditor from '../components/PageEditor.svelte'
|
|
25
|
+
import PageLabels from '../components/PageLabels.svelte'
|
|
22
26
|
import VersionHistory from '../components/VersionHistory.svelte'
|
|
23
27
|
import { type CoreApi, toPerson } from '../core-api.js'
|
|
24
28
|
import DatabaseView from '../database/DatabaseView.svelte'
|
|
@@ -191,12 +195,175 @@ async function revert() {
|
|
|
191
195
|
}
|
|
192
196
|
}
|
|
193
197
|
|
|
198
|
+
// -----------------------------------------------------------------------------------------------
|
|
199
|
+
// Watching, recording, and the way this page is deleted
|
|
200
|
+
// -----------------------------------------------------------------------------------------------
|
|
201
|
+
|
|
202
|
+
/**
|
|
203
|
+
* Whether you are watching, and how many others are.
|
|
204
|
+
*
|
|
205
|
+
* One request, because the control has to draw both — its own pressed state and the number beside
|
|
206
|
+
* it — and asking twice within a keystroke of each other is two requests for one button. The reply
|
|
207
|
+
* to `set` is the same shape, so it goes straight into the cache and nothing refetches.
|
|
208
|
+
*/
|
|
209
|
+
const watchQuery = createQuery(() => ({
|
|
210
|
+
queryKey: quireKeys.watchers(workspaceId, pageId),
|
|
211
|
+
enabled: Boolean(workspaceId && pageId),
|
|
212
|
+
queryFn: () => api.watchers.get({ workspaceId, pageId }),
|
|
213
|
+
}))
|
|
214
|
+
const watching = $derived(watchQuery.data?.watching ?? false)
|
|
215
|
+
const watcherCount = $derived(watchQuery.data?.watchers.length ?? 0)
|
|
216
|
+
let watchBusy = $state(false)
|
|
217
|
+
|
|
218
|
+
async function toggleWatch() {
|
|
219
|
+
if (watchBusy) return
|
|
220
|
+
watchBusy = true
|
|
221
|
+
try {
|
|
222
|
+
const next = await api.watchers.set({ workspaceId, pageId, watching: !watching })
|
|
223
|
+
client.setQueryData(quireKeys.watchers(workspaceId, pageId), next)
|
|
224
|
+
toast.success(next.watching ? t('watch_on') : t('watch_off'))
|
|
225
|
+
} finally {
|
|
226
|
+
watchBusy = false
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
/**
|
|
231
|
+
* Opening a page is what puts it in "Recent".
|
|
232
|
+
*
|
|
233
|
+
* A bump, not a log: one row per person per page, so the table is bounded by pages times people
|
|
234
|
+
* rather than growing for ever to answer a question that only ever wants the most recent handful.
|
|
235
|
+
* Failure is swallowed on purpose — a page you cannot record having read is still a page you are
|
|
236
|
+
* reading, and an error toast about a sidebar list would be noise over the thing you came for.
|
|
237
|
+
*/
|
|
238
|
+
$effect(() => {
|
|
239
|
+
const ws = workspaceId
|
|
240
|
+
const id = pageId
|
|
241
|
+
if (!ws || !id) return
|
|
242
|
+
void api.recents
|
|
243
|
+
.record({ workspaceId: ws, pageId: id })
|
|
244
|
+
.then(() => client.invalidateQueries({ queryKey: quireKeys.recents(ws) }))
|
|
245
|
+
.catch(() => {})
|
|
246
|
+
})
|
|
247
|
+
|
|
248
|
+
/**
|
|
249
|
+
* How many pages "Move to trash" is about to take.
|
|
250
|
+
*
|
|
251
|
+
* It takes the whole subtree, and it used to fire with no confirmation and no way back: deleting
|
|
252
|
+
* "Working here" silently took "Your first week" and "Time off" with it. So the count is worked out
|
|
253
|
+
* *before* the dialog says anything, from the space's tree — loaded only when the dialog opens,
|
|
254
|
+
* because a page nobody is deleting should not pay for a second copy of the tree.
|
|
255
|
+
*
|
|
256
|
+
* `includeArchived: true`, under a key of its own: the sidebar holds the same call with archived
|
|
257
|
+
* pages left out, and reusing that key would either hand this the wrong list or replace the
|
|
258
|
+
* sidebar's. Archived descendants go to the trash like any other, so a count that skipped them
|
|
259
|
+
* would be the same lie in a smaller size.
|
|
260
|
+
*
|
|
261
|
+
* A database page's rows are not counted. They are pages, and `trashPage` takes them — but they are
|
|
262
|
+
* rows to the person reading, and "and 340 pages inside it" for a table of 340 rows would read as a
|
|
263
|
+
* different disaster from the one about to happen. The toast afterwards reports the number the
|
|
264
|
+
* server actually took.
|
|
265
|
+
*/
|
|
266
|
+
let trashConfirm = $state(false)
|
|
267
|
+
|
|
268
|
+
const subtreeQuery = createQuery(() => ({
|
|
269
|
+
queryKey: [...quireKeys.tree(workspaceId, doc?.spaceId ?? ''), 'with-archived'],
|
|
270
|
+
enabled: trashConfirm && Boolean(workspaceId && doc?.spaceId),
|
|
271
|
+
queryFn: () => api.pages.tree({ workspaceId, spaceId: doc?.spaceId ?? '', includeArchived: true }),
|
|
272
|
+
}))
|
|
273
|
+
|
|
274
|
+
/**
|
|
275
|
+
* `isFetching`, not just `data`, because a cached tree is not a current one.
|
|
276
|
+
*
|
|
277
|
+
* TanStack hands a query its cached value the instant it is enabled and refetches behind it, so
|
|
278
|
+
* `data === undefined` only catches the *first* open. Every later one renders whatever the last
|
|
279
|
+
* fetch left, and the last fetch is routinely wrong: `refreshAfterMoving` runs while the dialog is
|
|
280
|
+
* still open, so it reloads the tree with the subtree already in the trash and caches a tree
|
|
281
|
+
* without it. Trash a page, press **Undo**, reach for **Move to trash** again, and the dialog said
|
|
282
|
+
* "It goes to the trash, and you can put it back from there" — the singular sentence, for a page
|
|
283
|
+
* that takes two others with it. Measured; the whole point of this dialog is that number, so a
|
|
284
|
+
* stale one is worse than none.
|
|
285
|
+
*/
|
|
286
|
+
const trashCount = $derived.by((): number | null => {
|
|
287
|
+
const nodes = subtreeQuery.data
|
|
288
|
+
if (!nodes || subtreeQuery.isFetching) return null
|
|
289
|
+
const children = new Map<string, string[]>()
|
|
290
|
+
for (const node of nodes)
|
|
291
|
+
if (node.parentId) children.set(node.parentId, [...(children.get(node.parentId) ?? []), node.id])
|
|
292
|
+
let total = 1
|
|
293
|
+
let guard = 0
|
|
294
|
+
const stack = [pageId]
|
|
295
|
+
while (stack.length > 0 && guard++ < 5000) {
|
|
296
|
+
const id = stack.pop() as string
|
|
297
|
+
for (const child of children.get(id) ?? []) {
|
|
298
|
+
total++
|
|
299
|
+
stack.push(child)
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
return total
|
|
303
|
+
})
|
|
304
|
+
|
|
305
|
+
/**
|
|
306
|
+
* Move it, then offer to take it back.
|
|
307
|
+
*
|
|
308
|
+
* The undo is the point. A confirmation stops the deletion you did not mean to start; it does
|
|
309
|
+
* nothing for the one you meant and regretted, and `pages.restore` puts the whole subtree back —
|
|
310
|
+
* so the toast carries the action rather than leaving the trash screen as the only way home. It
|
|
311
|
+
* outlives this component: the shell owns the toaster, so navigating away does not cancel it.
|
|
312
|
+
*/
|
|
313
|
+
/**
|
|
314
|
+
* Everything a page leaving or rejoining the space changes.
|
|
315
|
+
*
|
|
316
|
+
* The favourites and recents lists are the ones easy to forget, and forgetting them is visible:
|
|
317
|
+
* both are composed by joining to `pages`, so a trashed page silently drops out of them — and a
|
|
318
|
+
* sidebar still offering a shortcut to a page that is in the trash is exactly the kind of thing
|
|
319
|
+
* that makes somebody distrust the sidebar. Nothing else will do it either, because a `page`
|
|
320
|
+
* change invalidates the `page` prefix and these two live under their own.
|
|
321
|
+
*/
|
|
322
|
+
async function refreshAfterMoving(spaceId: string) {
|
|
323
|
+
await client.invalidateQueries({ queryKey: quireKeys.tree(workspaceId, spaceId) })
|
|
324
|
+
await client.invalidateQueries({ queryKey: quireKeys.trash(workspaceId, spaceId) })
|
|
325
|
+
await client.invalidateQueries({ queryKey: quireKeys.favorites(workspaceId) })
|
|
326
|
+
await client.invalidateQueries({ queryKey: quireKeys.recents(workspaceId) })
|
|
327
|
+
}
|
|
328
|
+
|
|
194
329
|
async function trash() {
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
330
|
+
const page = doc
|
|
331
|
+
if (!page) return
|
|
332
|
+
const spaceId = page.spaceId
|
|
333
|
+
const title = page.title.trim() || t('untitled')
|
|
334
|
+
const answer = await api.pages.trashPage({ workspaceId, pageId })
|
|
335
|
+
await refreshAfterMoving(spaceId)
|
|
336
|
+
toast(t('trash_moved', { count: answer.count }), {
|
|
337
|
+
// Long enough to read the sentence, notice the number and decide — the default 2.2s is a
|
|
338
|
+
// confirmation, and this is an offer.
|
|
339
|
+
duration: 9000,
|
|
340
|
+
action: {
|
|
341
|
+
label: t('undo'),
|
|
342
|
+
onClick: () => void undoTrash(workspaceId, pageId, spaceId, title),
|
|
343
|
+
},
|
|
344
|
+
})
|
|
198
345
|
void navigation.go(`/${workspaceSlug}/quire/${encodeURIComponent(spaceKey)}`)
|
|
199
346
|
}
|
|
347
|
+
|
|
348
|
+
/**
|
|
349
|
+
* `workspace` is passed in rather than read from the closure.
|
|
350
|
+
*
|
|
351
|
+
* This runs from a toast that outlives the component — the screen has already navigated away by
|
|
352
|
+
* the time anybody presses **Undo** — so every value it needs is a plain argument. Reaching for
|
|
353
|
+
* `workspaceId` here would be reading a `$derived` belonging to a component that no longer exists.
|
|
354
|
+
*/
|
|
355
|
+
async function undoTrash(workspace: string, id: string, spaceId: string, title: string) {
|
|
356
|
+
try {
|
|
357
|
+
await api.pages.restore({ workspaceId: workspace, pageId: id })
|
|
358
|
+
await client.invalidateQueries({ queryKey: quireKeys.tree(workspace, spaceId) })
|
|
359
|
+
await client.invalidateQueries({ queryKey: quireKeys.trash(workspace, spaceId) })
|
|
360
|
+
await client.invalidateQueries({ queryKey: quireKeys.favorites(workspace) })
|
|
361
|
+
await client.invalidateQueries({ queryKey: quireKeys.recents(workspace) })
|
|
362
|
+
toast.success(t('trash_restore_done', { title }))
|
|
363
|
+
} catch {
|
|
364
|
+
toast.error(t('trash_undo_failed'))
|
|
365
|
+
}
|
|
366
|
+
}
|
|
200
367
|
</script>
|
|
201
368
|
|
|
202
369
|
<div class="with-margin" class:open={showComments}>
|
|
@@ -249,6 +416,13 @@ async function trash() {
|
|
|
249
416
|
{/if}
|
|
250
417
|
</h1>
|
|
251
418
|
|
|
419
|
+
<!--
|
|
420
|
+
The star sits beside the title rather than in the menu: "keep this to hand" is a thing
|
|
421
|
+
people do while reading, and a two-state control buried behind an ellipsis cannot show its
|
|
422
|
+
state at all.
|
|
423
|
+
-->
|
|
424
|
+
<FavoriteStar {workspaceId} pageId={doc.id} />
|
|
425
|
+
|
|
252
426
|
<DropdownMenu
|
|
253
427
|
items={[
|
|
254
428
|
{
|
|
@@ -277,6 +451,13 @@ async function trash() {
|
|
|
277
451
|
},
|
|
278
452
|
]
|
|
279
453
|
: []),
|
|
454
|
+
{
|
|
455
|
+
id: 'watch',
|
|
456
|
+
label: watching ? t('watch_stop') : t('watch'),
|
|
457
|
+
icon: watching ? 'bell-off' : 'bell',
|
|
458
|
+
hint: watcherCount > 0 ? t('watchers', { count: watcherCount }) : undefined,
|
|
459
|
+
onSelect: () => void toggleWatch(),
|
|
460
|
+
},
|
|
280
461
|
{
|
|
281
462
|
id: 'archive',
|
|
282
463
|
label: doc.archivedAt ? t('unarchive') : t('archive'),
|
|
@@ -284,13 +465,25 @@ async function trash() {
|
|
|
284
465
|
disabled: !editable,
|
|
285
466
|
onSelect: () => void archive(!doc.archivedAt),
|
|
286
467
|
},
|
|
468
|
+
{ type: 'separator' },
|
|
287
469
|
{
|
|
288
470
|
id: 'trash',
|
|
289
471
|
label: t('move_to_trash'),
|
|
290
472
|
icon: 'trash-2',
|
|
291
473
|
danger: true,
|
|
292
474
|
disabled: !editable,
|
|
293
|
-
|
|
475
|
+
// Asks first, and says how many pages it is about to take with it.
|
|
476
|
+
onSelect: () => (trashConfirm = true),
|
|
477
|
+
},
|
|
478
|
+
{
|
|
479
|
+
id: 'open-trash',
|
|
480
|
+
label: t('trash_open'),
|
|
481
|
+
icon: 'rotate-ccw',
|
|
482
|
+
disabled: !editable,
|
|
483
|
+
onSelect: () =>
|
|
484
|
+
void navigation.go(
|
|
485
|
+
`/${workspaceSlug}/quire/${encodeURIComponent(spaceKey)}/trash`,
|
|
486
|
+
),
|
|
294
487
|
},
|
|
295
488
|
]}
|
|
296
489
|
>
|
|
@@ -327,8 +520,23 @@ async function trash() {
|
|
|
327
520
|
{#if peers.length > 0}
|
|
328
521
|
<span class="chip">{t('people_here', { count: peers.length })}</span>
|
|
329
522
|
{/if}
|
|
523
|
+
{#if watching}
|
|
524
|
+
<span class="chip"><Icon name="bell" size={12} /> {t('watchers', { count: watcherCount })}</span>
|
|
525
|
+
{/if}
|
|
330
526
|
</div>
|
|
331
527
|
|
|
528
|
+
<!--
|
|
529
|
+
Under the byline, above the prose: a label is about the page as a whole, so it belongs with
|
|
530
|
+
the things that say what this page *is* rather than inside what it says.
|
|
531
|
+
-->
|
|
532
|
+
<PageLabels
|
|
533
|
+
{workspaceId}
|
|
534
|
+
spaceId={doc.spaceId}
|
|
535
|
+
pageId={doc.id}
|
|
536
|
+
canEdit={editable}
|
|
537
|
+
canManage={canQuire('spaceManage')}
|
|
538
|
+
/>
|
|
539
|
+
|
|
332
540
|
{#if doc.kind === 'page' && doc.hasUnpublishedChanges}
|
|
333
541
|
<div class="banner" role="status">
|
|
334
542
|
<Icon name="circle-alert" size={15} />
|
|
@@ -381,6 +589,21 @@ async function trash() {
|
|
|
381
589
|
{pageId}
|
|
382
590
|
publishedVersionId={doc.publishedVersionId}
|
|
383
591
|
/>
|
|
592
|
+
|
|
593
|
+
<!--
|
|
594
|
+
The body says nothing about numbers until it knows them. Naming a count before the tree has
|
|
595
|
+
loaded would be the same silent lie in a smaller size — "it goes to the trash" for a page that
|
|
596
|
+
is about to take two others with it.
|
|
597
|
+
-->
|
|
598
|
+
<ConfirmDialog
|
|
599
|
+
bind:open={trashConfirm}
|
|
600
|
+
title={t('trash_confirm_title', { title: doc.title.trim() || t('untitled') })}
|
|
601
|
+
body={trashCount === null ? t('loading') : t('trash_confirm_body', { count: trashCount })}
|
|
602
|
+
confirmLabel={t('move_to_trash')}
|
|
603
|
+
danger
|
|
604
|
+
pending={trashCount === null}
|
|
605
|
+
onConfirm={trash}
|
|
606
|
+
/>
|
|
384
607
|
{/if}
|
|
385
608
|
|
|
386
609
|
<style>
|