@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.
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 +284 -9
  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
@@ -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 (!doc || !dirty) return
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
- await api.pages.update({ workspaceId, pageId, title: next })
155
- dirty = false
156
- await client.invalidateQueries({ queryKey: quireKeys.page(workspaceId, pageId) })
157
- await client.invalidateQueries({ queryKey: quireKeys.tree(workspaceId, doc.spaceId) })
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
- if (!doc) return
196
- await api.pages.trashPage({ workspaceId, pageId })
197
- await client.invalidateQueries({ queryKey: quireKeys.tree(workspaceId, doc.spaceId) })
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
- onSelect: () => void trash(),
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>