@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.
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 +227 -4
  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
@@ -1,22 +1,30 @@
1
1
  <script lang="ts">
2
2
  import {
3
3
  Button,
4
+ DropdownMenu,
4
5
  EmptyState,
6
+ IconButton,
7
+ type MenuItem,
5
8
  navigation,
6
9
  SearchBox,
7
10
  SectionLabel,
8
11
  Select,
12
+ SidebarItem,
9
13
  Skeleton,
10
14
  session,
11
15
  } from '@kernhq/ui'
12
16
  import { createQuery, useQueryClient } from '@tanstack/svelte-query'
13
17
  import { untrack } from 'svelte'
18
+ import type { Label } from '../../contract/index.js'
14
19
  import { getQuireApi } from '../api-instance.js'
15
20
  import { t } from '../i18n.js'
16
21
  import { buildPageTree, type PageTreeNode } from '../index.js'
17
22
  import { canQuire } from '../permissions.js'
18
23
  import { quireKeys } from '../query.js'
24
+ import LabelManager from './LabelManager.svelte'
19
25
  import PageTreeRow from './PageTreeRow.svelte'
26
+ import SidebarFavorites from './SidebarFavorites.svelte'
27
+ import SidebarRecents from './SidebarRecents.svelte'
20
28
 
21
29
  /**
22
30
  * Quire's spaces and pages, in the application sidebar (DESIGN.md §2.3).
@@ -41,7 +49,7 @@ interface Props {
41
49
  pathname: string
42
50
  segment: string
43
51
  }
44
- const { workspaceId, workspaceSlug }: Props = $props()
52
+ const { workspaceId, workspaceSlug, pathname }: Props = $props()
45
53
 
46
54
  const spaceKeyInUrl = $derived(navigation.params.space ?? null)
47
55
  const activePageId = $derived(navigation.params.page ?? null)
@@ -121,6 +129,94 @@ function switchSpace(key: string) {
121
129
  void navigation.go(`/${workspaceSlug}/quire/${encodeURIComponent(key)}`)
122
130
  }
123
131
 
132
+ // ---------------------------------------------------------------------------------------------
133
+ // Labels: the space's vocabulary, and the filter built on it
134
+ // ---------------------------------------------------------------------------------------------
135
+
136
+ let labelFilter = $state<string | null>(null)
137
+ let labelsOpen = $state(false)
138
+
139
+ const labelsQuery = createQuery(() => ({
140
+ queryKey: quireKeys.labels(workspaceId, activeSpace?.id ?? ''),
141
+ enabled: Boolean(workspaceId && activeSpace),
142
+ queryFn: () => api.labels.list({ workspaceId, spaceId: activeSpace?.id ?? '' }),
143
+ }))
144
+ const labelList = $derived(labelsQuery.data ?? [])
145
+ const activeLabel = $derived(labelList.find((l) => l.id === labelFilter) ?? null)
146
+
147
+ /** Switching space takes its vocabulary with it, so a filter from the last one must not survive. */
148
+ $effect(() => {
149
+ void activeSpace?.id
150
+ labelFilter = null
151
+ })
152
+
153
+ /**
154
+ * Which pages wear which label — one request per page in the space.
155
+ *
156
+ * That is the honest cost and it is worth naming: the contract has `labels.forPage` and nothing
157
+ * that answers "which pages wear this label", so a filter can only be built by asking about each
158
+ * page. It is therefore gated behind actually choosing a filter rather than run on load, and
159
+ * bounded — a space past `MAX_LABEL_SCAN` pages is not scanned at all, and the filter says nothing
160
+ * matched rather than firing a thousand requests. The fix is a listing on the server; until it
161
+ * exists this is the shape that works without one.
162
+ */
163
+ const MAX_LABEL_SCAN = 400
164
+ const CONCURRENCY = 8
165
+
166
+ const labelMapQuery = createQuery(() => ({
167
+ queryKey: ['quire', 'label', workspaceId, 'tree', activeSpace?.id ?? '', nodes.length] as const,
168
+ enabled: Boolean(workspaceId && activeSpace && labelFilter && nodes.length > 0),
169
+ staleTime: 30_000,
170
+ queryFn: async () => {
171
+ const wanted = nodes.slice(0, MAX_LABEL_SCAN).map((n) => n.id)
172
+ const map: Record<string, string[]> = {}
173
+ for (let from = 0; from < wanted.length; from += CONCURRENCY) {
174
+ const slice = wanted.slice(from, from + CONCURRENCY)
175
+ const answers = await Promise.all(slice.map((pageId) => api.labels.forPage({ workspaceId, pageId })))
176
+ slice.forEach((pageId, at) => {
177
+ map[pageId] = (answers[at] ?? []).map((l: Label) => l.id)
178
+ })
179
+ }
180
+ return map
181
+ },
182
+ }))
183
+
184
+ const labelled = $derived.by(() => {
185
+ const wanted = labelFilter
186
+ const map = labelMapQuery.data
187
+ if (!wanted || !map) return []
188
+ return nodes.filter((n) => (map[n.id] ?? []).includes(wanted))
189
+ })
190
+
191
+ const filterMenu = $derived.by((): MenuItem[] => [
192
+ { type: 'label', label: t('label_filter') },
193
+ {
194
+ type: 'radio',
195
+ value: labelFilter ?? '',
196
+ options: [
197
+ { value: '', label: t('label_filter_all') },
198
+ ...labelList.map((l) => ({ value: l.id, label: l.name })),
199
+ ],
200
+ onValueChange: (value: string) => {
201
+ labelFilter = value === '' ? null : value
202
+ },
203
+ },
204
+ ])
205
+
206
+ /**
207
+ * `pathname` is a prop, not `navigation.pathname`.
208
+ *
209
+ * The shell fills that singleton from an `$effect`, which runs after the first render — the same
210
+ * reason `workspaceId` is a prop here. Reading it instead would leave the trash entry unhighlighted
211
+ * on the paint that matters.
212
+ */
213
+ const onTrash = $derived(pathname.endsWith('/trash'))
214
+
215
+ function openTrash() {
216
+ if (!activeSpace) return
217
+ void navigation.go(`/${workspaceSlug}/quire/${encodeURIComponent(activeSpace.key)}/trash`)
218
+ }
219
+
124
220
  let creating = $state(false)
125
221
 
126
222
  async function createPage(parentId: string | null) {
@@ -163,9 +259,30 @@ async function createPage(parentId: string | null) {
163
259
 
164
260
  <div class="strip">
165
261
  <SearchBox bind:value={search} placeholder={t('search_space')} />
262
+ {#if labelList.length > 0}
263
+ <DropdownMenu items={filterMenu}>
264
+ {#snippet trigger(props: Record<string, unknown>)}
265
+ <IconButton
266
+ {...props}
267
+ icon="filter"
268
+ size={30}
269
+ variant="ghost"
270
+ active={labelFilter !== null}
271
+ label={t('label_filter')}
272
+ />
273
+ {/snippet}
274
+ </DropdownMenu>
275
+ {/if}
166
276
  </div>
167
277
 
168
278
  <div class="scroll">
279
+ <SidebarFavorites
280
+ {workspaceId}
281
+ {workspaceSlug}
282
+ spaces={spaceList}
283
+ activePageId={activePageId}
284
+ />
285
+
169
286
  {#if spacesQuery.isLoading || treeQuery.isLoading}
170
287
  <div class="loading">
171
288
  {#each [1, 2, 3, 4, 5] as n (n)}
@@ -193,6 +310,46 @@ async function createPage(parentId: string | null) {
193
310
  </EmptyState>
194
311
  {:else if spaceList.length === 0}
195
312
  <EmptyState icon="scroll-text" title={t('no_spaces')} description={t('no_spaces_desc')} />
313
+ {:else if labelFilter}
314
+ <!--
315
+ A label filter replaces the tree in the same scroll area, exactly as search does: what a
316
+ label gathers is a set of pages rather than a shape, and drawing it as a tree with the
317
+ unmatched branches still in it would answer a different question. The banner names the
318
+ filter and carries the way out of it — a filtered sidebar with no visible reason for being
319
+ short is how somebody concludes their pages are gone.
320
+ -->
321
+ <div class="banner">
322
+ <span class="banner-text">{t('label_filter_active', { name: activeLabel?.name ?? '' })}</span>
323
+ <IconButton
324
+ icon="x"
325
+ size={22}
326
+ variant="ghost"
327
+ label={t('label_filter_clear')}
328
+ onclick={() => (labelFilter = null)}
329
+ />
330
+ </div>
331
+ {#if labelMapQuery.isLoading}
332
+ <div class="loading">
333
+ {#each [1, 2, 3] as n (n)}
334
+ <Skeleton height="34px" />
335
+ {/each}
336
+ </div>
337
+ {:else if labelled.length === 0}
338
+ <p class="none">{t('label_filter_none')}</p>
339
+ {:else}
340
+ {#each labelled as node (node.id)}
341
+ <PageTreeRow
342
+ node={{ ...node, children: [] } as PageTreeNode}
343
+ depth={0}
344
+ activeId={activePageId}
345
+ expanded={new Set()}
346
+ onToggle={() => {}}
347
+ onOpen={openPage}
348
+ onCreateChild={createPage}
349
+ canCreate={false}
350
+ />
351
+ {/each}
352
+ {/if}
196
353
  {:else if query}
197
354
  <SectionLabel label={t('search_results')} />
198
355
  {#if matches.length === 0}
@@ -237,10 +394,42 @@ async function createPage(parentId: string | null) {
237
394
  </Button>
238
395
  </div>
239
396
  {/if}
397
+
398
+ <SidebarRecents
399
+ {workspaceId}
400
+ {workspaceSlug}
401
+ spaces={spaceList}
402
+ activePageId={activePageId}
403
+ />
240
404
  {/if}
241
405
  </div>
406
+
407
+ <!--
408
+ Outside the scroll area, so the way back from a deletion is where it can always be found rather
409
+ than wherever a long tree happens to end. This is the only entrance to the trash: without it
410
+ `pages.trash` was a procedure with no screen, and "Move to trash" was a one-way door.
411
+ -->
412
+ {#if activeSpace}
413
+ <div class="foot">
414
+ {#if canQuire('pageEdit')}
415
+ <SidebarItem
416
+ label={t('trash')}
417
+ icon="trash-2"
418
+ active={onTrash}
419
+ onclick={openTrash}
420
+ />
421
+ {/if}
422
+ {#if canQuire('spaceManage')}
423
+ <SidebarItem label={t('labels')} icon="tag" onclick={() => (labelsOpen = true)} />
424
+ {/if}
425
+ </div>
426
+ {/if}
242
427
  </div>
243
428
 
429
+ {#if activeSpace && canQuire('spaceManage')}
430
+ <LabelManager bind:open={labelsOpen} {workspaceId} spaceId={activeSpace.id} />
431
+ {/if}
432
+
244
433
  <style>
245
434
  .wrap {
246
435
  display: flex;
@@ -252,8 +441,21 @@ async function createPage(parentId: string | null) {
252
441
  padding: 10px 12px 0;
253
442
  }
254
443
  .strip {
444
+ display: flex;
445
+ align-items: center;
446
+ gap: 6px;
255
447
  padding: 12px 12px 4px;
256
448
  }
449
+ /*
450
+ * The search box takes the room and the filter is a fixed 30px beside it. Selected as the first
451
+ * child rather than by `SearchBox`'s own class — that class belongs to `@kernhq/ui` and a module
452
+ * reaching for it is a rule that stops working silently the next time the design system renames
453
+ * something.
454
+ */
455
+ .strip > :global(:first-child) {
456
+ flex: 1;
457
+ min-width: 0;
458
+ }
257
459
  .scroll {
258
460
  flex: 1;
259
461
  overflow-y: auto;
@@ -275,4 +477,67 @@ async function createPage(parentId: string | null) {
275
477
  .new {
276
478
  padding-block-start: 6px;
277
479
  }
480
+ /*
481
+ * Pinned to the bottom of the pane, and sticky rather than flex-pinned.
482
+ *
483
+ * The scroller is the shell's own `nav`, not this component — `.scroll` below never actually
484
+ * overflows, because nothing above gives `.wrap` a height to work against. So a footer laid out as
485
+ * the last flex child sits after the tree and scrolls away with it, which for a space of any size
486
+ * puts the only way back from a deletion below the fold. `position: sticky` inside somebody else's
487
+ * scrollport is what works from in here, and it needs an opaque background of its own:
488
+ * `--kern-shell` is the pane's colour, so the rows underneath do not read through it.
489
+ */
490
+ .foot {
491
+ position: sticky;
492
+ inset-block-end: 0;
493
+ z-index: 1;
494
+ flex: none;
495
+ display: flex;
496
+ flex-direction: column;
497
+ gap: 1px;
498
+ padding: 8px 12px 12px;
499
+ background: var(--kern-shell);
500
+ border-block-start: 1px solid var(--kern-border);
501
+ }
502
+ /*
503
+ * A sticky element stops at the scrollport's *padding* edge, and the pane has 14px of it — so rows
504
+ * scrolling underneath reappeared in the strip below this footer, which in dark mode is a bright
505
+ * cream sliver of whichever row happens to be active. Measured, not guessed. The background is bled
506
+ * downwards rather than the offset being negative, so the exact padding does not have to be known
507
+ * here — and 24px covers it with room to spare.
508
+ */
509
+ .foot::after {
510
+ content: '';
511
+ position: absolute;
512
+ inset-block-start: 100%;
513
+ inset-inline: 0;
514
+ height: 24px;
515
+ background: var(--kern-shell);
516
+ }
517
+ .banner {
518
+ display: flex;
519
+ align-items: center;
520
+ gap: 6px;
521
+ margin-block: 6px 4px;
522
+ /*
523
+ * Logical, not `padding: 6px 6px 6px 10px`. The extra room belongs to the sentence and the
524
+ * tighter side to the clear button beside it, and written physically that pairing survives only
525
+ * in English: under `dir="rtl"` the 10px stayed on the physical left, which is where the button
526
+ * is, so Persian and Arabic read the banner with the padding swapped.
527
+ */
528
+ padding-block: 6px;
529
+ padding-inline: 10px 6px;
530
+ border-radius: var(--kern-r-lg);
531
+ background: var(--kern-accent-tint);
532
+ }
533
+ .banner-text {
534
+ flex: 1;
535
+ min-width: 0;
536
+ font-size: 12.5px;
537
+ line-height: 1.4;
538
+ color: var(--kern-accent-deep);
539
+ overflow: hidden;
540
+ text-overflow: ellipsis;
541
+ white-space: nowrap;
542
+ }
278
543
  </style>