@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 +15 -3
- package/src/client/api-instance.ts +29 -0
- package/src/client/components/CommentsPanel.svelte +305 -0
- package/src/client/components/NewSpaceDialog.svelte +141 -0
- package/src/client/components/PageEditor.svelte +83 -0
- package/src/client/components/PageInline.svelte +44 -0
- package/src/client/components/PageTreeRow.svelte +147 -0
- package/src/client/components/SidebarSpaces.svelte +248 -0
- package/src/client/components/VersionHistory.svelte +159 -0
- package/src/client/i18n.ts +443 -0
- package/src/client/index.ts +4 -10
- package/src/client/mock.ts +287 -0
- package/src/client/module.ts +98 -0
- package/src/client/pages/PageView.svelte +406 -0
- package/src/client/pages/SpacePage.svelte +79 -0
- package/src/client/pages/SpacesPage.svelte +141 -0
- package/src/client/permissions.ts +39 -0
- package/src/client/query.ts +15 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@kernhq/module-quire",
|
|
3
|
-
"version": "0.
|
|
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
|
-
"
|
|
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 &&
|
|
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>
|