@kernhq/module-quire 0.10.8 → 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.
Files changed (53) hide show
  1. package/dist/contract/models.d.ts +87 -0
  2. package/dist/contract/models.d.ts.map +1 -1
  3. package/dist/contract/models.js +73 -0
  4. package/dist/contract/models.js.map +1 -1
  5. package/dist/contract/permissions.d.ts.map +1 -1
  6. package/dist/contract/permissions.js +32 -0
  7. package/dist/contract/permissions.js.map +1 -1
  8. package/dist/contract/router.d.ts +645 -0
  9. package/dist/contract/router.d.ts.map +1 -1
  10. package/dist/contract/router.js +137 -2
  11. package/dist/contract/router.js.map +1 -1
  12. package/dist/server/_impl.d.ts +877 -0
  13. package/dist/server/_impl.d.ts.map +1 -1
  14. package/dist/server/_impl.js +127 -1
  15. package/dist/server/_impl.js.map +1 -1
  16. package/dist/server/schema.d.ts +482 -1
  17. package/dist/server/schema.d.ts.map +1 -1
  18. package/dist/server/schema.js +115 -1
  19. package/dist/server/schema.js.map +1 -1
  20. package/dist/server/services/index.d.ts +3 -0
  21. package/dist/server/services/index.d.ts.map +1 -1
  22. package/dist/server/services/index.js +3 -0
  23. package/dist/server/services/index.js.map +1 -1
  24. package/dist/server/services/organisation.d.ts +117 -0
  25. package/dist/server/services/organisation.d.ts.map +1 -0
  26. package/dist/server/services/organisation.js +319 -0
  27. package/dist/server/services/organisation.js.map +1 -0
  28. package/dist/server/services/pages.d.ts +16 -1
  29. package/dist/server/services/pages.d.ts.map +1 -1
  30. package/dist/server/services/pages.js +62 -3
  31. package/dist/server/services/pages.js.map +1 -1
  32. package/migrations/0007_organisation.sql +114 -0
  33. package/migrations/meta/0007_snapshot.json +1588 -0
  34. package/migrations/meta/_journal.json +7 -0
  35. package/package.json +1 -1
  36. package/src/client/components/ConfirmDialog.svelte +123 -0
  37. package/src/client/components/FavoriteStar.svelte +81 -0
  38. package/src/client/components/LabelChip.svelte +46 -0
  39. package/src/client/components/LabelManager.svelte +336 -0
  40. package/src/client/components/PageLabels.svelte +158 -0
  41. package/src/client/components/SidebarFavorites.svelte +262 -0
  42. package/src/client/components/SidebarRecents.svelte +98 -0
  43. package/src/client/components/SidebarSpaces.svelte +266 -1
  44. package/src/client/i18n.ts +387 -0
  45. package/src/client/index.ts +8 -0
  46. package/src/client/mock.ts +306 -0
  47. package/src/client/module.ts +18 -0
  48. package/src/client/pages/PageView.svelte +235 -5
  49. package/src/client/pages/TrashPage.svelte +378 -0
  50. package/src/client/query.ts +24 -0
  51. package/src/contract/models.ts +83 -0
  52. package/src/contract/permissions.ts +36 -0
  53. package/src/contract/router.ts +153 -1
@@ -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: {
@@ -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
- if (!doc) return
196
- await api.pages.trashPage({ workspaceId, pageId })
197
- await client.invalidateQueries({ queryKey: quireKeys.tree(workspaceId, doc.spaceId) })
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
- onSelect: () => void trash(),
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>
@@ -412,8 +635,15 @@ async function trash() {
412
635
  background: none;
413
636
  padding: 0;
414
637
  }
415
- .title-field:focus {
638
+ /*
639
+ * The title is the page's heading, not a form field, so it carries no ring — the caret says where
640
+ * you are. `outline: none` is not enough on its own: the global `:focus-visible` rule draws a
641
+ * `box-shadow`, which drew a box around the title on every click.
642
+ */
643
+ .title-field:focus,
644
+ .title-field:focus-visible {
416
645
  outline: none;
646
+ box-shadow: none;
417
647
  }
418
648
  .byline {
419
649
  display: flex;