@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
@@ -0,0 +1,158 @@
1
+ <script lang="ts">
2
+ import { DropdownMenu, IconButton, type MenuItem } from '@kernhq/ui'
3
+ import { createQuery, useQueryClient } from '@tanstack/svelte-query'
4
+ import { untrack } from 'svelte'
5
+ import { getQuireApi } from '../api-instance.js'
6
+ import { t } from '../i18n.js'
7
+ import { quireKeys } from '../query.js'
8
+ import LabelChip from './LabelChip.svelte'
9
+ import LabelManager from './LabelManager.svelte'
10
+
11
+ /**
12
+ * The labels a page wears, and the menu that changes them.
13
+ *
14
+ * `pages.setLabels` replaces the whole set rather than adding one, so this holds the ticked set
15
+ * locally and sends all of it on every toggle. That is what makes unticking expressible at all —
16
+ * an additive procedure would need a second one to pair with it, and the pair would then have to
17
+ * agree about a page two people are editing.
18
+ *
19
+ * The local set is also what makes the menu feel like a menu. A checkbox that only ticks once the
20
+ * server has answered reads as broken at any latency worth having, so the tick lands immediately
21
+ * and the request follows; a failure resyncs from the server rather than leaving a lie on screen.
22
+ *
23
+ * The seeding effect reads `selected` and `inflight` through `untrack` because it writes the first
24
+ * and is gated on the second — tracked, the write would make the effect its own trigger and every
25
+ * tick would be undone by the stale answer still in the cache.
26
+ */
27
+ interface Props {
28
+ workspaceId: string
29
+ spaceId: string
30
+ pageId: string
31
+ canEdit: boolean
32
+ canManage: boolean
33
+ }
34
+ const { workspaceId, spaceId, pageId, canEdit, canManage }: Props = $props()
35
+
36
+ const api = getQuireApi()
37
+ const client = useQueryClient()
38
+
39
+ const onPage = createQuery(() => ({
40
+ queryKey: quireKeys.pageLabels(workspaceId, pageId),
41
+ enabled: Boolean(workspaceId && pageId),
42
+ queryFn: () => api.labels.forPage({ workspaceId, pageId }),
43
+ }))
44
+
45
+ /** The whole vocabulary, so the menu can offer what is *not* on the page as well as what is. */
46
+ const inSpace = createQuery(() => ({
47
+ queryKey: quireKeys.labels(workspaceId, spaceId),
48
+ enabled: Boolean(workspaceId && spaceId),
49
+ queryFn: () => api.labels.list({ workspaceId, spaceId }),
50
+ }))
51
+
52
+ let selected = $state(new Set<string>())
53
+ let inflight = $state(0)
54
+ let managerOpen = $state(false)
55
+
56
+ $effect(() => {
57
+ const data = onPage.data
58
+ if (!data) return
59
+ if (untrack(() => inflight) > 0) return
60
+ const next = new Set(data.map((l) => l.id))
61
+ const current = untrack(() => selected)
62
+ if (next.size !== current.size || [...next].some((id) => !current.has(id))) selected = next
63
+ })
64
+
65
+ /**
66
+ * Drawn from the space's list rather than from the page's own answer, so a rename shows up on the
67
+ * chip without this page being refetched — and so a tick that has not reached the server yet is
68
+ * already a chip. The page's answer stands in until the list has loaded.
69
+ */
70
+ const chips = $derived(
71
+ inSpace.data
72
+ ? inSpace.data.filter((l) => selected.has(l.id))
73
+ : (onPage.data ?? []).filter((l) => selected.has(l.id)),
74
+ )
75
+
76
+ async function toggle(labelId: string, on: boolean) {
77
+ const next = new Set(selected)
78
+ if (on) next.add(labelId)
79
+ else next.delete(labelId)
80
+ selected = next
81
+ inflight++
82
+ try {
83
+ await api.pages.setLabels({ workspaceId, pageId, labelIds: [...next] })
84
+ } catch {
85
+ // The optimistic tick was a guess; the server's answer is the fact.
86
+ await onPage.refetch()
87
+ } finally {
88
+ inflight--
89
+ /*
90
+ * The whole `label` prefix, not just this page's key. The sidebar's label filter holds a
91
+ * page-to-labels map for the space, and labelling a page here is precisely what makes it
92
+ * stale — the server announces `page updated` for this, which lands on the `page` prefix and
93
+ * never reaches it.
94
+ */
95
+ if (inflight === 0) await client.invalidateQueries({ queryKey: ['quire', 'label', workspaceId] })
96
+ }
97
+ }
98
+
99
+ const items = $derived.by((): MenuItem[] => {
100
+ const all = inSpace.data ?? []
101
+ const rows: MenuItem[] = [{ type: 'label', label: t('labels_edit') }]
102
+ if (all.length === 0) rows.push({ id: 'none', label: t('labels_empty'), disabled: true })
103
+ for (const label of all)
104
+ rows.push({
105
+ type: 'checkbox',
106
+ id: label.id,
107
+ label: label.name,
108
+ checked: selected.has(label.id),
109
+ onCheckedChange: (on: boolean) => void toggle(label.id, on),
110
+ })
111
+ if (canManage) {
112
+ rows.push({ type: 'separator' })
113
+ rows.push({
114
+ id: 'manage',
115
+ label: t('labels_manage'),
116
+ icon: 'settings',
117
+ onSelect: () => (managerOpen = true),
118
+ })
119
+ }
120
+ return rows
121
+ })
122
+ </script>
123
+
124
+ {#if chips.length > 0 || canEdit}
125
+ <div class="labels">
126
+ {#each chips as label (label.id)}
127
+ <LabelChip {label} />
128
+ {/each}
129
+
130
+ {#if canEdit}
131
+ <DropdownMenu {items} align="start">
132
+ {#snippet trigger(props: Record<string, unknown>)}
133
+ <IconButton
134
+ {...props}
135
+ icon="tag"
136
+ size={26}
137
+ variant="ghost"
138
+ label={chips.length === 0 ? t('label_none_on_page') : t('labels_edit')}
139
+ />
140
+ {/snippet}
141
+ </DropdownMenu>
142
+ {/if}
143
+ </div>
144
+ {/if}
145
+
146
+ {#if canManage}
147
+ <LabelManager bind:open={managerOpen} {workspaceId} {spaceId} />
148
+ {/if}
149
+
150
+ <style>
151
+ .labels {
152
+ display: flex;
153
+ align-items: center;
154
+ gap: 6px;
155
+ flex-wrap: wrap;
156
+ margin-block-start: 12px;
157
+ }
158
+ </style>
@@ -0,0 +1,262 @@
1
+ <script lang="ts">
2
+ import {
3
+ DropdownMenu,
4
+ formatCount,
5
+ IconButton,
6
+ type MenuItem,
7
+ navigation,
8
+ SectionLabel,
9
+ SidebarItem,
10
+ } from '@kernhq/ui'
11
+ import { createQuery, useQueryClient } from '@tanstack/svelte-query'
12
+ import { untrack } from 'svelte'
13
+ import { dndzone, SHADOW_ITEM_MARKER_PROPERTY_NAME } from 'svelte-dnd-action'
14
+ import type { FavoriteEntry, Space } from '../../contract/index.js'
15
+ import { getQuireApi } from '../api-instance.js'
16
+ import { t } from '../i18n.js'
17
+ import { quireKeys } from '../query.js'
18
+
19
+ /**
20
+ * One person's own shortcuts, at the top of the sidebar where a table of contents is not.
21
+ *
22
+ * The list is the *workspace's*, not the space's — a favourite is "I want this to hand", and
23
+ * losing it because you switched space would defeat the point. Each entry carries the little of
24
+ * its page a row needs to draw itself, so the group is one request rather than one per row.
25
+ *
26
+ * Reordering is a drag and a menu, and the menu is not a fallback: `svelte-dnd-action` cannot be
27
+ * reached from a keyboard, so without **Move up** and **Move down** half the people using this
28
+ * could not reorder anything. Both go through `favorites.reorder`, which mints a fractional index
29
+ * server-side and answers with the whole list — so the reply *is* the new order and nothing has to
30
+ * refetch to find out what happened.
31
+ *
32
+ * Three traps in `svelte-dnd-action`, all of which have shipped in this project before: it tracks
33
+ * items by an `id` property and nothing else (a favourite is keyed by `pageId`, so the rows carry
34
+ * an `id` alias); the list must be `$state.raw`, because a deep-reactive proxy hands the library a
35
+ * different object on every read and it reads that as an endless stream of changes; and the
36
+ * seeding effect reads its `dragging` guard through `untrack`, or clearing the flag at the end of
37
+ * a drop re-runs it against the old query data and undoes the move on screen.
38
+ */
39
+ interface Props {
40
+ workspaceId: string
41
+ workspaceSlug: string
42
+ /** the spaces this person can see, for turning a favourite's `spaceId` into a URL */
43
+ spaces: readonly Space[]
44
+ activePageId: string | null
45
+ }
46
+ const { workspaceId, workspaceSlug, spaces, activePageId }: Props = $props()
47
+
48
+ const api = getQuireApi()
49
+ const client = useQueryClient()
50
+
51
+ const query = createQuery(() => ({
52
+ queryKey: quireKeys.favorites(workspaceId),
53
+ enabled: Boolean(workspaceId),
54
+ queryFn: () => api.favorites.list({ workspaceId }),
55
+ }))
56
+
57
+ type Entry = FavoriteEntry & { id: string; [SHADOW_ITEM_MARKER_PROPERTY_NAME]?: boolean }
58
+
59
+ const keyOf = $derived(new Map(spaces.map((s) => [s.id, s.key])))
60
+
61
+ /**
62
+ * A favourite whose space this person cannot see is not drawn.
63
+ *
64
+ * The row would have nowhere to go: a page's URL is `/quire/<space key>/<page id>`, and the key
65
+ * comes from the space list. The server already drops the entries whose *pages* are unreadable, so
66
+ * what is left here is the narrower case of a space that has since been archived.
67
+ */
68
+ const rows = $derived(
69
+ (query.data ?? []).filter((f) => keyOf.has(f.spaceId)).map((f): Entry => ({ ...f, id: f.pageId })),
70
+ )
71
+
72
+ let ordered = $state.raw<Entry[]>([])
73
+ let dragging = $state(false)
74
+ let busy = $state(false)
75
+
76
+ $effect(() => {
77
+ const next = rows
78
+ if (untrack(() => dragging)) return
79
+ ordered = next
80
+ })
81
+
82
+ const hrefFor = (entry: Entry) =>
83
+ `/${workspaceSlug}/quire/${encodeURIComponent(keyOf.get(entry.spaceId) ?? '')}/${encodeURIComponent(entry.pageId)}`
84
+
85
+ const iconFor = (entry: Entry) =>
86
+ entry.kind === 'live' ? 'square-pen' : entry.kind === 'database' ? 'database' : 'file-text'
87
+
88
+ const titleOf = (entry: Entry) => entry.title.trim() || t('untitled')
89
+
90
+ const isShadow = (entry: Entry) => Boolean(entry[SHADOW_ITEM_MARKER_PROPERTY_NAME])
91
+
92
+ /** `afterId` is the favourite to land behind; null means first. */
93
+ async function move(pageId: string, afterId: string | null) {
94
+ if (busy) return
95
+ busy = true
96
+ try {
97
+ const list = await api.favorites.reorder({ workspaceId, pageId, afterId })
98
+ client.setQueryData(quireKeys.favorites(workspaceId), list)
99
+ } finally {
100
+ busy = false
101
+ }
102
+ }
103
+
104
+ async function remove(pageId: string) {
105
+ if (busy) return
106
+ busy = true
107
+ try {
108
+ const list = await api.favorites.remove({ workspaceId, pageId })
109
+ client.setQueryData(quireKeys.favorites(workspaceId), list)
110
+ } finally {
111
+ busy = false
112
+ }
113
+ }
114
+
115
+ function consider(event: CustomEvent<{ items: Entry[] }>) {
116
+ dragging = true
117
+ ordered = event.detail.items
118
+ }
119
+
120
+ function finalize(event: CustomEvent<{ items: Entry[] }>) {
121
+ const items = event.detail.items
122
+ ordered = items
123
+ const before = new Map(untrack(() => rows).map((r, i) => [r.id, i]))
124
+ const moved = items.find((item, index) => before.get(item.id) !== index)
125
+ dragging = false
126
+ if (!moved) return
127
+ const at = items.indexOf(moved)
128
+ void move(moved.pageId, at > 0 ? (items[at - 1]?.pageId ?? null) : null)
129
+ }
130
+
131
+ function menuFor(entry: Entry, index: number): MenuItem[] {
132
+ const title = titleOf(entry)
133
+ return [
134
+ {
135
+ id: 'up',
136
+ label: t('favorite_move_up', { title }),
137
+ icon: 'chevron-up',
138
+ disabled: index === 0,
139
+ onSelect: () => void move(entry.pageId, index > 1 ? (ordered[index - 2]?.pageId ?? null) : null),
140
+ },
141
+ {
142
+ id: 'down',
143
+ label: t('favorite_move_down', { title }),
144
+ icon: 'chevron-down',
145
+ disabled: index >= ordered.length - 1,
146
+ onSelect: () => void move(entry.pageId, ordered[index + 1]?.pageId ?? null),
147
+ },
148
+ { type: 'separator' },
149
+ {
150
+ id: 'remove',
151
+ label: t('favorite_remove'),
152
+ icon: 'star',
153
+ danger: true,
154
+ onSelect: () => void remove(entry.pageId),
155
+ },
156
+ ]
157
+ }
158
+
159
+ function open(entry: Entry) {
160
+ void navigation.go(hrefFor(entry))
161
+ }
162
+ </script>
163
+
164
+ {#if ordered.length > 0}
165
+ <div class="group">
166
+ <SectionLabel label={t('favorites')} count={formatCount(ordered.length)} />
167
+ <!-- svelte-ignore a11y_no_static_element_interactions -->
168
+ <div
169
+ class="stack"
170
+ use:dndzone={{
171
+ items: ordered,
172
+ flipDurationMs: 140,
173
+ type: 'quire-favorites',
174
+ dragDisabled: ordered.length < 2,
175
+ dropTargetStyle: {},
176
+ }}
177
+ onconsider={consider}
178
+ onfinalize={finalize}
179
+ >
180
+ {#each ordered as entry, index (entry.id)}
181
+ <div class="row" class:shadow={isShadow(entry)}>
182
+ <SidebarItem
183
+ label={titleOf(entry)}
184
+ icon={iconFor(entry)}
185
+ active={activePageId === entry.pageId}
186
+ onclick={() => open(entry)}
187
+ >
188
+ {#snippet trailing()}
189
+ <!--
190
+ Room for the menu, which is a sibling laid over the row rather than a child of it:
191
+ `SidebarItem` is a `<button>`, and a button inside a button is invalid markup whose
192
+ accessible name becomes the two labels run together.
193
+ -->
194
+ <span class="well"></span>
195
+ {/snippet}
196
+ </SidebarItem>
197
+
198
+ <span class="actions">
199
+ <DropdownMenu items={menuFor(entry, index)}>
200
+ {#snippet trigger(props: Record<string, unknown>)}
201
+ <IconButton
202
+ {...props}
203
+ icon="ellipsis"
204
+ size={22}
205
+ variant="ghost"
206
+ label={t('favorite_actions', { title: titleOf(entry) })}
207
+ />
208
+ {/snippet}
209
+ </DropdownMenu>
210
+ </span>
211
+ </div>
212
+ {/each}
213
+ </div>
214
+ </div>
215
+ {/if}
216
+
217
+ <style>
218
+ .group {
219
+ padding-block-end: 6px;
220
+ }
221
+ .stack {
222
+ display: flex;
223
+ flex-direction: column;
224
+ gap: 1px;
225
+ }
226
+ .row {
227
+ position: relative;
228
+ }
229
+ /*
230
+ * The placeholder the library leaves where the row came from. Drawn as an outline rather than
231
+ * faded, because fading it would mute its text against the pane — `opacity` on a row is how
232
+ * "muted" becomes "unreadable".
233
+ */
234
+ .row.shadow :global(.ksi) {
235
+ visibility: hidden;
236
+ }
237
+ .row.shadow {
238
+ border: 1px dashed var(--kern-border-strong);
239
+ border-radius: var(--kern-r-xl);
240
+ }
241
+ .well {
242
+ width: 22px;
243
+ flex: none;
244
+ }
245
+ /*
246
+ * `opacity: 0` rather than `display: none` keeps the control focusable, and `:focus-within` on the
247
+ * row is what makes it visible once the keyboard arrives — the same shape as the page tree's add
248
+ * button, and the reason **Move up** is reachable at all.
249
+ */
250
+ .actions {
251
+ position: absolute;
252
+ inset-block-start: 50%;
253
+ inset-inline-end: 8px;
254
+ transform: translateY(-50%);
255
+ display: inline-flex;
256
+ opacity: 0;
257
+ }
258
+ .row:hover .actions,
259
+ .row:focus-within .actions {
260
+ opacity: 1;
261
+ }
262
+ </style>
@@ -0,0 +1,98 @@
1
+ <script lang="ts">
2
+ import { navigation, relativeTime, SectionLabel, SidebarItem } from '@kernhq/ui'
3
+ import { createQuery } from '@tanstack/svelte-query'
4
+ import type { RecentEntry, Space } from '../../contract/index.js'
5
+ import { getQuireApi } from '../api-instance.js'
6
+ import { t } from '../i18n.js'
7
+ import { quireKeys } from '../query.js'
8
+
9
+ /**
10
+ * Where this person has just been, newest first.
11
+ *
12
+ * Below the tree rather than above it: the tree is the space's table of contents and the thing
13
+ * somebody came to the sidebar for, and a list that reorders itself every time you open a page is
14
+ * a poor thing to put in front of it. Eight rows, because this answers "take me back" and not
15
+ * "what have I read" — the row is a shortcut, and a long list of them stops being one.
16
+ *
17
+ * `recents.record` is called by the page screen on open; nothing here writes. The list is one
18
+ * person's own — the server filters it by the caller, which is the only thing keeping it personal,
19
+ * since row-level security fences the workspace rather than the reader.
20
+ */
21
+ interface Props {
22
+ workspaceId: string
23
+ workspaceSlug: string
24
+ spaces: readonly Space[]
25
+ activePageId: string | null
26
+ }
27
+ const { workspaceId, workspaceSlug, spaces, activePageId }: Props = $props()
28
+
29
+ const api = getQuireApi()
30
+
31
+ const query = createQuery(() => ({
32
+ queryKey: quireKeys.recents(workspaceId),
33
+ enabled: Boolean(workspaceId),
34
+ queryFn: () => api.recents.list({ workspaceId, limit: 8 }),
35
+ }))
36
+
37
+ const keyOf = $derived(new Map(spaces.map((s) => [s.id, s.key])))
38
+
39
+ /** Same as the favourites group: a row whose space has no key has nowhere to navigate to. */
40
+ const rows = $derived((query.data ?? []).filter((r) => keyOf.has(r.spaceId)))
41
+
42
+ const titleOf = (entry: RecentEntry) => entry.title.trim() || t('untitled')
43
+
44
+ const iconFor = (entry: RecentEntry) =>
45
+ entry.kind === 'live' ? 'square-pen' : entry.kind === 'database' ? 'database' : 'file-text'
46
+
47
+ function open(entry: RecentEntry) {
48
+ void navigation.go(
49
+ `/${workspaceSlug}/quire/${encodeURIComponent(keyOf.get(entry.spaceId) ?? '')}/${encodeURIComponent(entry.pageId)}`,
50
+ )
51
+ }
52
+ </script>
53
+
54
+ {#if rows.length > 0}
55
+ <div class="group">
56
+ <SectionLabel label={t('recent')} />
57
+ <div class="stack">
58
+ {#each rows as entry (entry.pageId)}
59
+ <SidebarItem
60
+ label={titleOf(entry)}
61
+ icon={iconFor(entry)}
62
+ active={activePageId === entry.pageId}
63
+ onclick={() => open(entry)}
64
+ >
65
+ {#snippet trailing()}
66
+ <!--
67
+ A relative time, not a date: this list is only ever read as "how long ago", and
68
+ `relativeTime` renders it in the interface language rather than as a raw number.
69
+ -->
70
+ <span class="when">{relativeTime(entry.viewedAt)}</span>
71
+ {/snippet}
72
+ </SidebarItem>
73
+ {/each}
74
+ </div>
75
+ </div>
76
+ {/if}
77
+
78
+ <style>
79
+ .group {
80
+ padding-block-start: 10px;
81
+ }
82
+ .stack {
83
+ display: flex;
84
+ flex-direction: column;
85
+ gap: 1px;
86
+ }
87
+ /*
88
+ * Muted with a colour, never with `opacity` — and never smaller than 11.5px, which is where this
89
+ * pane's ink stops being legible against its own background.
90
+ */
91
+ .when {
92
+ flex: none;
93
+ font-family: var(--kern-font-mono);
94
+ font-size: 11.5px;
95
+ color: var(--kern-ink-350);
96
+ letter-spacing: -0.01em;
97
+ }
98
+ </style>