@kernhq/module-quire 0.6.1 → 0.7.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kernhq/module-quire",
3
- "version": "0.6.1",
3
+ "version": "0.7.0",
4
4
  "description": "Kern Quire: collaborative documents, spaces and page trees",
5
5
  "homepage": "https://kernaio.com",
6
6
  "license": "AGPL-3.0-only",
@@ -52,26 +52,38 @@
52
52
  "zod": "^4.1.0"
53
53
  },
54
54
  "peerDependencies": {
55
- "svelte": "^5.0.0"
55
+ "@kernhq/ui": "^0.7.0",
56
+ "@tanstack/svelte-query": "^6.1.0",
57
+ "svelte": "^5.46.0"
56
58
  },
57
59
  "peerDependenciesMeta": {
60
+ "@kernhq/ui": {
61
+ "optional": true
62
+ },
63
+ "@tanstack/svelte-query": {
64
+ "optional": true
65
+ },
58
66
  "svelte": {
59
67
  "optional": true
60
68
  }
61
69
  },
62
70
  "devDependencies": {
63
71
  "@kernhq/tsconfig": "^0.1.0",
72
+ "@kernhq/ui": "^0.7.0",
73
+ "@tanstack/svelte-query": "^6.1.0",
64
74
  "@types/node": "^24.0.0",
65
75
  "@types/pg": "^8.15.0",
66
76
  "drizzle-kit": "^0.31.0",
67
77
  "pg": "^8.16.0",
78
+ "svelte": "^5.46.0",
79
+ "svelte-check": "^4.0.0",
68
80
  "typescript": "~5.9.3",
69
81
  "vitest": "^4.0.0"
70
82
  },
71
83
  "scripts": {
72
84
  "build": "tsc -p tsconfig.json",
73
85
  "dev": "tsc -p tsconfig.json --watch --preserveWatchOutput",
74
- "typecheck": "tsc -p tsconfig.json --noEmit && tsc -p tsconfig.client.json --noEmit",
86
+ "typecheck": "tsc -p tsconfig.json --noEmit && svelte-check --tsconfig ./tsconfig.client.json --threshold error",
75
87
  "test": "vitest run",
76
88
  "db:generate": "drizzle-kit generate"
77
89
  }
@@ -0,0 +1,29 @@
1
+ import { getHost } from '@kernhq/ui'
2
+ import { createQuireClient, type QuireApi } from './index.js'
3
+ import { createMockQuireApi } from './mock.js'
4
+
5
+ /**
6
+ * This module's API client.
7
+ *
8
+ * An empty base URL keeps requests same-origin, so the dev proxy and the reverse proxy both work
9
+ * without CORS. `PUBLIC_API_MOCK=1` swaps in the in-memory implementation, which satisfies the same
10
+ * contract types — so no screen has a second code path for demos and end-to-end tests.
11
+ */
12
+ export type { QuireApi }
13
+
14
+ let cached: QuireApi | null = null
15
+
16
+ export function getQuireApi(): QuireApi {
17
+ if (cached) return cached
18
+ cached = getHost().isMock
19
+ ? (createMockQuireApi() as unknown as QuireApi)
20
+ : createQuireClient({
21
+ baseUrl: getHost().apiBaseUrl,
22
+ })
23
+ return cached
24
+ }
25
+
26
+ /** Test seam. */
27
+ export function __setQuireApi(api: QuireApi | null) {
28
+ cached = api
29
+ }
@@ -0,0 +1,305 @@
1
+ <script lang="ts">
2
+ import { Avatar, Button, EmptyState, Icon, IconButton, relativeTime, Skeleton, session } from '@kernhq/ui'
3
+ import { RichTextEditor } from '@kernhq/ui/editor'
4
+ import { createQuery, useQueryClient } from '@tanstack/svelte-query'
5
+ import { getQuireApi } from '../api-instance.js'
6
+ import { t } from '../i18n.js'
7
+ import type { CommentThread } from '../index.js'
8
+ import { canQuire } from '../permissions.js'
9
+ import { quireKeys } from '../query.js'
10
+
11
+ /**
12
+ * The margin.
13
+ *
14
+ * Threads rather than a flat list, because a remark and its answers are one conversation — and
15
+ * because resolving is a property of the conversation, not of the last thing said in it.
16
+ *
17
+ * A thread whose anchored text has been deleted still appears, showing what it was about. Hiding it
18
+ * would quietly discard somebody's question the moment the sentence it referred to was rewritten,
19
+ * which is exactly when the question matters most.
20
+ */
21
+ interface Props {
22
+ workspaceId: string
23
+ pageId: string
24
+ /** the thread the editor has highlighted, if any */
25
+ activeId: string | null
26
+ /** anchors whose text no longer resolves, so the panel can say so */
27
+ orphaned: Set<string>
28
+ onFocus?: (id: string | null) => void
29
+ /** a selection waiting for its first remark */
30
+ pending: { anchor: { from: string; to: string }; quotedText: string } | null
31
+ onPendingHandled?: () => void
32
+ }
33
+ const { workspaceId, pageId, activeId, orphaned, onFocus, pending, onPendingHandled }: Props = $props()
34
+
35
+ const api = getQuireApi()
36
+ const client = useQueryClient()
37
+
38
+ const query = createQuery(() => ({
39
+ queryKey: [...quireKeys.page(workspaceId, pageId), 'comments'],
40
+ enabled: Boolean(workspaceId && pageId),
41
+ queryFn: () => api.comments.list({ workspaceId, pageId, includeResolved: false }),
42
+ }))
43
+ const threads = $derived(query.data ?? [])
44
+
45
+ let draft = $state<unknown>(undefined)
46
+ let replyTo = $state<string | null>(null)
47
+ let replyDraft = $state<unknown>(undefined)
48
+ let busy = $state(false)
49
+ let error = $state<string | null>(null)
50
+
51
+ const empty = (doc: unknown) => {
52
+ const text = JSON.stringify(doc ?? {})
53
+ return !text.includes('"text"')
54
+ }
55
+
56
+ async function submit(parentId: string | null, body: unknown) {
57
+ if (busy || empty(body)) return
58
+ busy = true
59
+ error = null
60
+ try {
61
+ await api.comments.create({
62
+ workspaceId,
63
+ pageId,
64
+ body: $state.snapshot(body) as Record<string, unknown>,
65
+ // An empty pair is how the page-level composer says "about the page, not a piece of it".
66
+ anchor: parentId || !pending?.anchor.from ? null : pending.anchor,
67
+ quotedText: parentId ? '' : (pending?.quotedText ?? ''),
68
+ parentId,
69
+ })
70
+ await query.refetch()
71
+ if (parentId) {
72
+ replyDraft = undefined
73
+ replyTo = null
74
+ } else {
75
+ draft = undefined
76
+ onPendingHandled?.()
77
+ }
78
+ } catch (err) {
79
+ error = err instanceof Error ? err.message : String(err)
80
+ } finally {
81
+ busy = false
82
+ }
83
+ }
84
+
85
+ async function resolve(thread: CommentThread) {
86
+ busy = true
87
+ try {
88
+ await api.comments.resolve({ workspaceId, commentId: thread.id, resolved: true })
89
+ await query.refetch()
90
+ await client.invalidateQueries({ queryKey: quireKeys.page(workspaceId, pageId) })
91
+ } finally {
92
+ busy = false
93
+ }
94
+ }
95
+
96
+ async function remove(commentId: string) {
97
+ busy = true
98
+ try {
99
+ await api.comments.remove({ workspaceId, commentId })
100
+ await query.refetch()
101
+ } finally {
102
+ busy = false
103
+ }
104
+ }
105
+ </script>
106
+
107
+ <aside class="panel" aria-label={t('comments')}>
108
+ <h2 class="heading">{t('comments')}</h2>
109
+
110
+ {#if pending}
111
+ <div class="composer new">
112
+ {#if pending.quotedText}
113
+ <p class="quoted">“{pending.quotedText}”</p>
114
+ {/if}
115
+ <RichTextEditor bind:value={draft} placeholder={t('comment_placeholder')} minRows={2} />
116
+ <div class="actions">
117
+ <Button size="sm" variant="secondary" onclick={() => onPendingHandled?.()}>{t('common.cancel')}</Button>
118
+ <Button size="sm" disabled={busy || empty(draft)} onclick={() => submit(null, draft)}>
119
+ {t('comment_post')}
120
+ </Button>
121
+ </div>
122
+ </div>
123
+ {/if}
124
+
125
+ {#if error}<p class="error" role="alert">{error}</p>{/if}
126
+
127
+ {#if query.isLoading}
128
+ <Skeleton height="72px" />
129
+ {:else if threads.length === 0 && !pending}
130
+ <EmptyState
131
+ bare
132
+ compact
133
+ icon="message-circle"
134
+ title={t('comments_empty')}
135
+ description={t('comments_empty_desc')}
136
+ />
137
+ {:else}
138
+ {#each threads as thread (thread.id)}
139
+ <div
140
+ class="thread"
141
+ class:active={thread.id === activeId}
142
+ role="button"
143
+ tabindex="0"
144
+ onclick={() => onFocus?.(thread.id)}
145
+ onkeydown={(e) => {
146
+ if (e.key === 'Enter' || e.key === ' ') {
147
+ e.preventDefault()
148
+ onFocus?.(thread.id)
149
+ }
150
+ }}
151
+ >
152
+ {#if thread.root.quotedText}
153
+ <p class="quoted" class:orphan={orphaned.has(thread.id)}>“{thread.root.quotedText}”</p>
154
+ {#if orphaned.has(thread.id)}
155
+ <p class="orphan-note"><Icon name="circle-alert" size={12} /> {t('comment_orphaned')}</p>
156
+ {/if}
157
+ {/if}
158
+
159
+ {#each [thread.root, ...thread.replies] as comment (comment.id)}
160
+ <div class="comment">
161
+ <Avatar id={comment.authorId} size={22} />
162
+ <div class="bubble">
163
+ <div class="who">
164
+ <span class="time">{relativeTime(comment.createdAt)}</span>
165
+ {#if comment.editedAt}<span class="edited">{t('comment_edited')}</span>{/if}
166
+ {#if comment.authorId === session.user?.id}
167
+ <span class="spacer"></span>
168
+ <IconButton
169
+ icon="trash-2"
170
+ size={22}
171
+ variant="ghost"
172
+ label={t('common.delete')}
173
+ onclick={() => remove(comment.id)}
174
+ />
175
+ {/if}
176
+ </div>
177
+ <p class="text">{comment.bodyText}</p>
178
+ </div>
179
+ </div>
180
+ {/each}
181
+
182
+ <div class="thread-actions">
183
+ {#if replyTo === thread.id}
184
+ <RichTextEditor bind:value={replyDraft} placeholder={t('comment_reply')} minRows={1} />
185
+ <div class="actions">
186
+ <Button size="sm" variant="secondary" onclick={() => (replyTo = null)}>{t('common.cancel')}</Button>
187
+ <Button
188
+ size="sm"
189
+ disabled={busy || empty(replyDraft)}
190
+ onclick={() => submit(thread.root.id, replyDraft)}
191
+ >
192
+ {t('comment_post')}
193
+ </Button>
194
+ </div>
195
+ {:else if canQuire('pageComment')}
196
+ <Button size="sm" variant="ghost" onclick={() => (replyTo = thread.id)}>
197
+ {t('comment_reply')}
198
+ </Button>
199
+ <Button size="sm" variant="ghost" disabled={busy} onclick={() => resolve(thread)}>
200
+ {t('comment_resolve')}
201
+ </Button>
202
+ {/if}
203
+ </div>
204
+ </div>
205
+ {/each}
206
+ {/if}
207
+ </aside>
208
+
209
+ <style>
210
+ .panel {
211
+ display: flex;
212
+ flex-direction: column;
213
+ gap: 12px;
214
+ padding: 20px 18px;
215
+ border-inline-start: 1px solid var(--kern-border);
216
+ background: var(--kern-surface);
217
+ overflow-y: auto;
218
+ min-height: 0;
219
+ }
220
+ .heading {
221
+ margin: 0;
222
+ font-size: 12.5px;
223
+ font-weight: 600;
224
+ letter-spacing: 0.08em;
225
+ text-transform: uppercase;
226
+ color: var(--kern-ink-400);
227
+ }
228
+ .thread {
229
+ border: 1px solid var(--kern-border);
230
+ border-radius: var(--kern-r-card);
231
+ background: var(--kern-surface-raised);
232
+ padding: 12px;
233
+ cursor: pointer;
234
+ }
235
+ .thread.active {
236
+ border-color: var(--kern-accent);
237
+ }
238
+ .quoted {
239
+ margin: 0 0 10px;
240
+ padding-inline-start: 8px;
241
+ border-inline-start: 2px solid var(--kern-warning);
242
+ font-size: 12.5px;
243
+ line-height: 1.45;
244
+ color: var(--kern-ink-400);
245
+ }
246
+ .quoted.orphan {
247
+ border-inline-start-color: var(--kern-ink-350);
248
+ text-decoration: line-through;
249
+ }
250
+ .orphan-note {
251
+ display: flex;
252
+ align-items: center;
253
+ gap: 5px;
254
+ margin: -6px 0 10px;
255
+ font-size: 12px;
256
+ color: var(--kern-ink-400);
257
+ }
258
+ .comment {
259
+ display: flex;
260
+ gap: 8px;
261
+ margin-block-end: 10px;
262
+ }
263
+ .bubble {
264
+ flex: 1;
265
+ min-width: 0;
266
+ }
267
+ .who {
268
+ display: flex;
269
+ align-items: center;
270
+ gap: 6px;
271
+ font-size: 12px;
272
+ color: var(--kern-ink-400);
273
+ }
274
+ .spacer {
275
+ flex: 1;
276
+ }
277
+ .text {
278
+ margin: 2px 0 0;
279
+ font-size: 13.5px;
280
+ line-height: 1.5;
281
+ color: var(--kern-ink-700);
282
+ white-space: pre-wrap;
283
+ }
284
+ .composer.new {
285
+ border: 1px solid var(--kern-accent);
286
+ border-radius: var(--kern-r-card);
287
+ padding: 12px;
288
+ background: var(--kern-surface-raised);
289
+ }
290
+ .actions {
291
+ display: flex;
292
+ justify-content: flex-end;
293
+ gap: 6px;
294
+ margin-block-start: 8px;
295
+ }
296
+ .thread-actions {
297
+ display: flex;
298
+ gap: 4px;
299
+ }
300
+ .error {
301
+ margin: 0;
302
+ font-size: 13px;
303
+ color: var(--kern-danger);
304
+ }
305
+ </style>
@@ -0,0 +1,141 @@
1
+ <script lang="ts">
2
+ import { Button, Dialog, Field, Input, Select, Textarea } from '@kernhq/ui'
3
+ import { useQueryClient } from '@tanstack/svelte-query'
4
+ import { getQuireApi } from '../api-instance.js'
5
+ import { t } from '../i18n.js'
6
+ import type { Space } from '../index.js'
7
+ import { quireKeys } from '../query.js'
8
+
9
+ interface Props {
10
+ open: boolean
11
+ workspaceId: string
12
+ onCreated?: (space: Space) => void
13
+ }
14
+ let { open = $bindable(false), workspaceId, onCreated }: Props = $props()
15
+
16
+ const api = getQuireApi()
17
+ const client = useQueryClient()
18
+
19
+ let name = $state('')
20
+ let key = $state('')
21
+ let description = $state('')
22
+ let visibility = $state<Space['visibility']>('open')
23
+ let saving = $state(false)
24
+ let error = $state<string | null>(null)
25
+
26
+ /**
27
+ * The key is derived from the name until somebody types one, and then left alone. Overwriting a key
28
+ * a person has edited — because they went back and fixed a typo in the name — is the kind of thing
29
+ * that only shows up after the space exists and the URL is wrong.
30
+ */
31
+ let keyTouched = $state(false)
32
+ const slugify = (v: string) =>
33
+ v
34
+ .toLowerCase()
35
+ .replace(/[^a-z0-9]+/g, '-')
36
+ .replace(/^-+|-+$/g, '')
37
+ .slice(0, 48)
38
+
39
+ $effect(() => {
40
+ if (!keyTouched) key = slugify(name)
41
+ })
42
+
43
+ const valid = $derived(name.trim().length > 0 && key.length >= 2)
44
+
45
+ function reset() {
46
+ name = ''
47
+ key = ''
48
+ description = ''
49
+ visibility = 'open'
50
+ keyTouched = false
51
+ error = null
52
+ }
53
+
54
+ async function submit() {
55
+ if (!valid || saving) return
56
+ saving = true
57
+ error = null
58
+ try {
59
+ const space = await api.spaces.create({
60
+ workspaceId,
61
+ key,
62
+ name: name.trim(),
63
+ description: description.trim(),
64
+ icon: null,
65
+ visibility,
66
+ })
67
+ await client.invalidateQueries({ queryKey: quireKeys.spaces(workspaceId) })
68
+ open = false
69
+ reset()
70
+ onCreated?.(space)
71
+ } catch (err) {
72
+ error = err instanceof Error ? err.message : String(err)
73
+ } finally {
74
+ saving = false
75
+ }
76
+ }
77
+ </script>
78
+
79
+ <Dialog bind:open title={t('new_space')} description={t('new_space_desc')}>
80
+ <div class="form">
81
+ <Field label={t('space_name')}>
82
+ {#snippet children(id: string)}
83
+ <Input {id} bind:value={name} placeholder={t('space_name_hint')} />
84
+ {/snippet}
85
+ </Field>
86
+
87
+ <Field label={t('space_key')} hint={t('space_key_hint')}>
88
+ {#snippet children(id: string)}
89
+ <Input
90
+ {id}
91
+ value={key}
92
+ oninput={(e: Event) => {
93
+ keyTouched = true
94
+ key = slugify((e.currentTarget as HTMLInputElement).value)
95
+ }}
96
+ />
97
+ {/snippet}
98
+ </Field>
99
+
100
+ <Field label={t('space_description')}>
101
+ {#snippet children(id: string)}
102
+ <Textarea {id} bind:value={description} rows={2} />
103
+ {/snippet}
104
+ </Field>
105
+
106
+ <Field label={t('space_visibility')}>
107
+ {#snippet children(id: string)}
108
+ <Select
109
+ {id}
110
+ value={visibility}
111
+ options={[
112
+ { value: 'open', label: t('visibility_open') },
113
+ { value: 'restricted', label: t('visibility_restricted') },
114
+ { value: 'private', label: t('visibility_private') },
115
+ ]}
116
+ onValueChange={(v: string) => (visibility = v as Space['visibility'])}
117
+ />
118
+ {/snippet}
119
+ </Field>
120
+
121
+ {#if error}<p class="error">{error}</p>{/if}
122
+ </div>
123
+
124
+ {#snippet footer()}
125
+ <Button variant="secondary" onclick={() => (open = false)}>{t('common.cancel')}</Button>
126
+ <Button disabled={!valid || saving} onclick={submit}>{t('common.create')}</Button>
127
+ {/snippet}
128
+ </Dialog>
129
+
130
+ <style>
131
+ .form {
132
+ display: flex;
133
+ flex-direction: column;
134
+ gap: 14px;
135
+ }
136
+ .error {
137
+ margin: 0;
138
+ font-size: 13px;
139
+ color: var(--kern-danger);
140
+ }
141
+ </style>
@@ -0,0 +1,83 @@
1
+ <script lang="ts">
2
+ import {
3
+ CollaborativeEditor,
4
+ type CollabPeer,
5
+ type CollabStatus,
6
+ type CommentRange,
7
+ EmptyState,
8
+ getHost,
9
+ session,
10
+ } from '@kernhq/ui'
11
+ import { t } from '../i18n.js'
12
+ import { type Page, pageDocumentName } from '../index.js'
13
+
14
+ /**
15
+ * The body of a page, synchronised through the collab service.
16
+ *
17
+ * The document name is built with `formatCollabDocument` rather than assembled here: the gateway
18
+ * parses it with the matching function from the same package, and a name it cannot parse is a
19
+ * rejected connection with no useful error.
20
+ */
21
+ interface Props {
22
+ doc: Page
23
+ onpeers?: (peers: CollabPeer[]) => void
24
+ onstatus?: (status: CollabStatus) => void
25
+ commentRanges?: CommentRange[]
26
+ activeComment?: string | null
27
+ onCommentClick?: (id: string) => void
28
+ oncomment?: (anchor: { from: string; to: string }, quotedText: string) => void
29
+ }
30
+ const {
31
+ doc,
32
+ onpeers,
33
+ onstatus,
34
+ commentRanges = [],
35
+ activeComment = null,
36
+ onCommentClick,
37
+ oncomment,
38
+ }: Props = $props()
39
+
40
+ const name = $derived(pageDocumentName(doc))
41
+
42
+ /**
43
+ * Same-origin by default, so the dev proxy and the reverse proxy both work without configuration.
44
+ * The shell owns the endpoint: same origin under `/collab` in every ordinary deployment, and an
45
+ * explicit one for an instance that puts the collab service somewhere else.
46
+ */
47
+ const url = $derived(
48
+ getHost().collabUrl ??
49
+ (typeof location === 'undefined' ? '' : `${location.origin.replace(/^http/, 'ws')}/collab`),
50
+ )
51
+
52
+ const user = $derived({
53
+ id: session.user?.id ?? '',
54
+ name: session.user?.name ?? '',
55
+ avatarUrl: session.user?.avatarUrl ?? null,
56
+ })
57
+ </script>
58
+
59
+ {#if getHost().isMock}
60
+ <!--
61
+ There is no collab service behind `dev:mock`, and an editor that silently fails to sync is worse
62
+ than one that says so — this is the environment used for demos, where "it looked like it saved"
63
+ is exactly the wrong impression to leave.
64
+ -->
65
+ <EmptyState icon="wifi-off" title={t('editor_mock')} description={t('editor_mock_desc')} />
66
+ {:else if !user.id}
67
+ <EmptyState icon="triangle-alert" title={t('editor_no_session')} description={t('editor_no_session_desc')} />
68
+ {:else}
69
+ {#key name}
70
+ <CollaborativeEditor
71
+ {url}
72
+ {name}
73
+ {user}
74
+ placeholder={t('editor_placeholder')}
75
+ {onpeers}
76
+ {onstatus}
77
+ {commentRanges}
78
+ {activeComment}
79
+ {onCommentClick}
80
+ {oncomment}
81
+ />
82
+ {/key}
83
+ {/if}
@@ -0,0 +1,44 @@
1
+ <script lang="ts">
2
+ import { Icon, navigation, session } from '@kernhq/ui'
3
+ import { createQuery } from '@tanstack/svelte-query'
4
+ import { getQuireApi } from '../api-instance.js'
5
+ import { t } from '../i18n.js'
6
+ import { quireKeys } from '../query.js'
7
+
8
+ /**
9
+ * How a Quire page reads when another module links to one — a mention in an issue, a search hit, a
10
+ * notification. Inline presenters are how a module renders its own objects somewhere it does not own.
11
+ */
12
+ interface Props {
13
+ id: string
14
+ }
15
+ const { id }: Props = $props()
16
+
17
+ const api = getQuireApi()
18
+ const workspaceSlug = $derived(navigation.workspaceSlug)
19
+ const workspaceId = $derived(session.workspaces.find((w) => w.slug === workspaceSlug)?.id ?? '')
20
+
21
+ const query = createQuery(() => ({
22
+ queryKey: quireKeys.page(workspaceId, id),
23
+ enabled: Boolean(workspaceId && id),
24
+ queryFn: () => api.pages.get({ workspaceId, pageId: id }),
25
+ }))
26
+ const title = $derived(query.data?.title?.trim() || t('untitled'))
27
+ </script>
28
+
29
+ <span class="inline">
30
+ <Icon name="file-text" size={14} />
31
+ <span class="t">{query.isLoading ? '…' : title}</span>
32
+ </span>
33
+
34
+ <style>
35
+ .inline {
36
+ display: inline-flex;
37
+ align-items: center;
38
+ gap: 5px;
39
+ color: var(--kern-ink-700);
40
+ }
41
+ .t {
42
+ font-weight: 500;
43
+ }
44
+ </style>