@kernhq/module-quire 0.6.0 → 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/dist/server/formula.d.ts +13 -3
- package/dist/server/formula.d.ts.map +1 -1
- package/dist/server/formula.js +4 -4
- package/dist/server/formula.js.map +1 -1
- 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
|
@@ -0,0 +1,406 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import {
|
|
3
|
+
Avatar,
|
|
4
|
+
Button,
|
|
5
|
+
type CollabPeer,
|
|
6
|
+
DropdownMenu,
|
|
7
|
+
EmptyState,
|
|
8
|
+
Icon,
|
|
9
|
+
IconButton,
|
|
10
|
+
navigation,
|
|
11
|
+
Page,
|
|
12
|
+
relativeTime,
|
|
13
|
+
Skeleton,
|
|
14
|
+
session,
|
|
15
|
+
} from '@kernhq/ui'
|
|
16
|
+
import { createQuery, useQueryClient } from '@tanstack/svelte-query'
|
|
17
|
+
import { getQuireApi } from '../api-instance.js'
|
|
18
|
+
import CommentsPanel from '../components/CommentsPanel.svelte'
|
|
19
|
+
import PageEditor from '../components/PageEditor.svelte'
|
|
20
|
+
import VersionHistory from '../components/VersionHistory.svelte'
|
|
21
|
+
import { t } from '../i18n.js'
|
|
22
|
+
import { canQuire } from '../permissions.js'
|
|
23
|
+
import { quireKeys } from '../query.js'
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* One page (DESIGN.md §3.6): 780px measure, a 30px title, a byline under a hairline.
|
|
27
|
+
*
|
|
28
|
+
* The body is not here yet. The collaborative editor is the next slice, and until it exists this
|
|
29
|
+
* says so rather than drawing an empty box that looks broken — a surface that pretends to be
|
|
30
|
+
* editable and silently drops what you type is worse than one that admits it is not finished.
|
|
31
|
+
*/
|
|
32
|
+
interface Props {
|
|
33
|
+
spaceKey: string
|
|
34
|
+
pageId: string
|
|
35
|
+
}
|
|
36
|
+
const { spaceKey, pageId }: Props = $props()
|
|
37
|
+
|
|
38
|
+
const api = getQuireApi()
|
|
39
|
+
const client = useQueryClient()
|
|
40
|
+
|
|
41
|
+
const workspaceSlug = $derived(navigation.workspaceSlug)
|
|
42
|
+
const workspace = $derived(session.workspaces.find((w) => w.slug === workspaceSlug))
|
|
43
|
+
const workspaceId = $derived(workspace?.id ?? '')
|
|
44
|
+
|
|
45
|
+
const query = createQuery(() => ({
|
|
46
|
+
queryKey: quireKeys.page(workspaceId, pageId),
|
|
47
|
+
enabled: Boolean(workspaceId && pageId),
|
|
48
|
+
queryFn: () => api.pages.get({ workspaceId, pageId }),
|
|
49
|
+
}))
|
|
50
|
+
const doc = $derived(query.data ?? null)
|
|
51
|
+
|
|
52
|
+
const editable = $derived(canQuire('pageEdit'))
|
|
53
|
+
let title = $state('')
|
|
54
|
+
let dirty = $state(false)
|
|
55
|
+
let titleEl = $state<HTMLInputElement | null>(null)
|
|
56
|
+
let peers = $state<CollabPeer[]>([])
|
|
57
|
+
let historyOpen = $state(false)
|
|
58
|
+
let busy = $state(false)
|
|
59
|
+
let activeComment = $state<string | null>(null)
|
|
60
|
+
let pendingComment = $state<{ anchor: { from: string; to: string }; quotedText: string } | null>(null)
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Anchors for the editor to highlight, from the threads the panel has loaded.
|
|
64
|
+
*
|
|
65
|
+
* The panel owns the query; this reads the same cache rather than asking again, so the margin and
|
|
66
|
+
* the highlights can never disagree about which threads exist.
|
|
67
|
+
*/
|
|
68
|
+
const threads = createQuery(() => ({
|
|
69
|
+
queryKey: [...quireKeys.page(workspaceId, pageId), 'comments'],
|
|
70
|
+
enabled: Boolean(workspaceId && pageId),
|
|
71
|
+
queryFn: () => api.comments.list({ workspaceId, pageId, includeResolved: false }),
|
|
72
|
+
}))
|
|
73
|
+
|
|
74
|
+
const commentRanges = $derived(
|
|
75
|
+
(threads.data ?? [])
|
|
76
|
+
.filter((t) => t.root.anchor)
|
|
77
|
+
.map((t) => ({ id: t.id, from: t.root.anchor!.from, to: t.root.anchor!.to })),
|
|
78
|
+
)
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Threads whose text is gone.
|
|
82
|
+
*
|
|
83
|
+
* A relative position resolves to nothing once the content it pointed at is deleted, which is the
|
|
84
|
+
* whole reason for using one. The panel says so rather than the thread quietly vanishing — that is
|
|
85
|
+
* exactly when somebody's question matters most.
|
|
86
|
+
*/
|
|
87
|
+
let orphaned = $state(new Set<string>())
|
|
88
|
+
|
|
89
|
+
/** The margin appears when there is something in it, or when somebody is about to put something there. */
|
|
90
|
+
const showComments = $derived(pendingComment !== null || (threads.data ?? []).length > 0)
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* A page created from the sidebar arrives with no title, and the only thing anybody wants to do next
|
|
94
|
+
* is name it. Without this the page is called "Untitled" and you have to go and find the field.
|
|
95
|
+
* Guarded on the title being empty so opening an existing page never steals the caret.
|
|
96
|
+
*/
|
|
97
|
+
$effect(() => {
|
|
98
|
+
const el = titleEl
|
|
99
|
+
if (el && doc && doc.title === '' && !dirty) el.focus()
|
|
100
|
+
})
|
|
101
|
+
|
|
102
|
+
/** Reset the field when a different page loads, but never over something being typed. */
|
|
103
|
+
$effect(() => {
|
|
104
|
+
const loaded = doc
|
|
105
|
+
if (!loaded) return
|
|
106
|
+
if (!dirty) title = loaded.title
|
|
107
|
+
})
|
|
108
|
+
$effect(() => {
|
|
109
|
+
void pageId
|
|
110
|
+
dirty = false
|
|
111
|
+
})
|
|
112
|
+
|
|
113
|
+
async function saveTitle() {
|
|
114
|
+
if (!doc || !dirty) return
|
|
115
|
+
const next = title.trim()
|
|
116
|
+
if (next === doc.title) {
|
|
117
|
+
dirty = false
|
|
118
|
+
return
|
|
119
|
+
}
|
|
120
|
+
await api.pages.update({ workspaceId, pageId, title: next })
|
|
121
|
+
dirty = false
|
|
122
|
+
await client.invalidateQueries({ queryKey: quireKeys.page(workspaceId, pageId) })
|
|
123
|
+
await client.invalidateQueries({ queryKey: quireKeys.tree(workspaceId, doc.spaceId) })
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
async function archive(archived: boolean) {
|
|
127
|
+
if (!doc) return
|
|
128
|
+
await api.pages.archive({ workspaceId, pageId, archived })
|
|
129
|
+
await client.invalidateQueries({ queryKey: quireKeys.page(workspaceId, pageId) })
|
|
130
|
+
await client.invalidateQueries({ queryKey: quireKeys.tree(workspaceId, doc.spaceId) })
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* A page has a published face and a draft; a live doc has neither. Everything below is therefore
|
|
135
|
+
* only offered for a `page` — a live doc with a "publish" button would be a control that does
|
|
136
|
+
* nothing, which is worse than an absent one.
|
|
137
|
+
*/
|
|
138
|
+
async function publish() {
|
|
139
|
+
if (!doc || busy) return
|
|
140
|
+
busy = true
|
|
141
|
+
try {
|
|
142
|
+
await api.publishing.publish({ workspaceId, pageId, label: null })
|
|
143
|
+
await client.invalidateQueries({ queryKey: quireKeys.page(workspaceId, pageId) })
|
|
144
|
+
} finally {
|
|
145
|
+
busy = false
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
async function revert() {
|
|
150
|
+
if (!doc || busy) return
|
|
151
|
+
busy = true
|
|
152
|
+
try {
|
|
153
|
+
await api.publishing.revert({ workspaceId, pageId })
|
|
154
|
+
await client.invalidateQueries({ queryKey: quireKeys.page(workspaceId, pageId) })
|
|
155
|
+
} finally {
|
|
156
|
+
busy = false
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
async function trash() {
|
|
161
|
+
if (!doc) return
|
|
162
|
+
await api.pages.trashPage({ workspaceId, pageId })
|
|
163
|
+
await client.invalidateQueries({ queryKey: quireKeys.tree(workspaceId, doc.spaceId) })
|
|
164
|
+
void navigation.go(`/${workspaceSlug}/quire/${encodeURIComponent(spaceKey)}`)
|
|
165
|
+
}
|
|
166
|
+
</script>
|
|
167
|
+
|
|
168
|
+
<div class="with-margin" class:open={showComments}>
|
|
169
|
+
<Page padding="docs" maxWidth="780px">
|
|
170
|
+
{#if query.isLoading}
|
|
171
|
+
<Skeleton height="36px" />
|
|
172
|
+
<div class="gap"></div>
|
|
173
|
+
<Skeleton height="18px" />
|
|
174
|
+
{:else if query.isError}
|
|
175
|
+
<EmptyState icon="triangle-alert" title={t('page_error')} description={t('page_error_desc')}>
|
|
176
|
+
{#snippet actions()}
|
|
177
|
+
<Button variant="secondary" onclick={() => void query.refetch()}>{t('common.retry')}</Button>
|
|
178
|
+
{/snippet}
|
|
179
|
+
</EmptyState>
|
|
180
|
+
{:else if !doc}
|
|
181
|
+
<EmptyState icon="circle-help" title={t('page_missing')} description={t('page_missing_desc')} />
|
|
182
|
+
{:else}
|
|
183
|
+
<div class="head">
|
|
184
|
+
{#if editable}
|
|
185
|
+
<input
|
|
186
|
+
bind:this={titleEl}
|
|
187
|
+
class="title"
|
|
188
|
+
value={title}
|
|
189
|
+
placeholder={t('untitled')}
|
|
190
|
+
aria-label={t('page_title')}
|
|
191
|
+
oninput={(e) => {
|
|
192
|
+
title = (e.currentTarget as HTMLInputElement).value
|
|
193
|
+
dirty = true
|
|
194
|
+
}}
|
|
195
|
+
onblur={saveTitle}
|
|
196
|
+
onkeydown={(e) => {
|
|
197
|
+
if (e.key === 'Enter') {
|
|
198
|
+
e.preventDefault()
|
|
199
|
+
;(e.currentTarget as HTMLInputElement).blur()
|
|
200
|
+
}
|
|
201
|
+
}}
|
|
202
|
+
/>
|
|
203
|
+
{:else}
|
|
204
|
+
<h1 class="title">{doc.title.trim() || t('untitled')}</h1>
|
|
205
|
+
{/if}
|
|
206
|
+
|
|
207
|
+
<DropdownMenu
|
|
208
|
+
items={[
|
|
209
|
+
{
|
|
210
|
+
id: 'comments',
|
|
211
|
+
label: t('comments'),
|
|
212
|
+
icon: 'message-circle',
|
|
213
|
+
onSelect: () => {
|
|
214
|
+
// Nothing selected, so this is a remark about the page rather than a piece of it.
|
|
215
|
+
pendingComment = { anchor: { from: '', to: '' }, quotedText: '' }
|
|
216
|
+
},
|
|
217
|
+
},
|
|
218
|
+
{
|
|
219
|
+
id: 'history',
|
|
220
|
+
label: t('history'),
|
|
221
|
+
icon: 'rotate-ccw',
|
|
222
|
+
onSelect: () => (historyOpen = true),
|
|
223
|
+
},
|
|
224
|
+
...(doc.kind === 'page' && canQuire('pageEdit')
|
|
225
|
+
? [
|
|
226
|
+
{
|
|
227
|
+
id: 'publish',
|
|
228
|
+
label: t('publish'),
|
|
229
|
+
icon: 'circle-check',
|
|
230
|
+
disabled: busy,
|
|
231
|
+
onSelect: () => void publish(),
|
|
232
|
+
},
|
|
233
|
+
]
|
|
234
|
+
: []),
|
|
235
|
+
{
|
|
236
|
+
id: 'archive',
|
|
237
|
+
label: doc.archivedAt ? t('unarchive') : t('archive'),
|
|
238
|
+
icon: 'archive',
|
|
239
|
+
disabled: !editable,
|
|
240
|
+
onSelect: () => void archive(!doc.archivedAt),
|
|
241
|
+
},
|
|
242
|
+
{
|
|
243
|
+
id: 'trash',
|
|
244
|
+
label: t('move_to_trash'),
|
|
245
|
+
icon: 'trash-2',
|
|
246
|
+
danger: true,
|
|
247
|
+
disabled: !editable,
|
|
248
|
+
onSelect: () => void trash(),
|
|
249
|
+
},
|
|
250
|
+
]}
|
|
251
|
+
>
|
|
252
|
+
{#snippet trigger(props: Record<string, unknown>)}
|
|
253
|
+
<IconButton icon="ellipsis" label={t('page_actions')} variant="ghost" {...props} />
|
|
254
|
+
{/snippet}
|
|
255
|
+
</DropdownMenu>
|
|
256
|
+
</div>
|
|
257
|
+
|
|
258
|
+
<div class="byline">
|
|
259
|
+
<Avatar id={doc.updatedBy} size={24} />
|
|
260
|
+
<span>{t('edited_ago', { when: relativeTime(doc.updatedAt) })}</span>
|
|
261
|
+
{#if doc.kind === 'live'}
|
|
262
|
+
<span class="chip"><Icon name="square-pen" size={12} /> {t('kind_live')}</span>
|
|
263
|
+
{/if}
|
|
264
|
+
{#if doc.archivedAt}
|
|
265
|
+
<span class="chip"><Icon name="archive" size={12} /> {t('archived')}</span>
|
|
266
|
+
{/if}
|
|
267
|
+
{#if peers.length > 0}
|
|
268
|
+
<span class="chip">{t('people_here', { count: peers.length })}</span>
|
|
269
|
+
{/if}
|
|
270
|
+
</div>
|
|
271
|
+
|
|
272
|
+
{#if doc.kind === 'page' && doc.hasUnpublishedChanges}
|
|
273
|
+
<div class="banner" role="status">
|
|
274
|
+
<Icon name="circle-alert" size={15} />
|
|
275
|
+
<span>{t('unpublished')}</span>
|
|
276
|
+
<span class="spacer"></span>
|
|
277
|
+
<Button size="sm" variant="secondary" disabled={busy} onclick={revert}>{t('revert')}</Button>
|
|
278
|
+
{#if canQuire('pageEdit')}
|
|
279
|
+
<Button size="sm" disabled={busy} onclick={publish}>{t('publish')}</Button>
|
|
280
|
+
{/if}
|
|
281
|
+
</div>
|
|
282
|
+
{/if}
|
|
283
|
+
|
|
284
|
+
<div class="body">
|
|
285
|
+
<PageEditor
|
|
286
|
+
{doc}
|
|
287
|
+
onpeers={(p) => (peers = p)}
|
|
288
|
+
{commentRanges}
|
|
289
|
+
{activeComment}
|
|
290
|
+
onCommentClick={(id) => (activeComment = id)}
|
|
291
|
+
oncomment={(anchor, quotedText) => {
|
|
292
|
+
pendingComment = { anchor, quotedText }
|
|
293
|
+
activeComment = null
|
|
294
|
+
}}
|
|
295
|
+
/>
|
|
296
|
+
</div>
|
|
297
|
+
{/if}
|
|
298
|
+
</Page>
|
|
299
|
+
|
|
300
|
+
{#if doc && showComments}
|
|
301
|
+
<CommentsPanel
|
|
302
|
+
{workspaceId}
|
|
303
|
+
{pageId}
|
|
304
|
+
activeId={activeComment}
|
|
305
|
+
{orphaned}
|
|
306
|
+
onFocus={(id) => (activeComment = id)}
|
|
307
|
+
pending={pendingComment}
|
|
308
|
+
onPendingHandled={() => (pendingComment = null)}
|
|
309
|
+
/>
|
|
310
|
+
{/if}
|
|
311
|
+
</div>
|
|
312
|
+
|
|
313
|
+
{#if doc}
|
|
314
|
+
<VersionHistory
|
|
315
|
+
bind:open={historyOpen}
|
|
316
|
+
{workspaceId}
|
|
317
|
+
{pageId}
|
|
318
|
+
publishedVersionId={doc.publishedVersionId}
|
|
319
|
+
/>
|
|
320
|
+
{/if}
|
|
321
|
+
|
|
322
|
+
<style>
|
|
323
|
+
.gap {
|
|
324
|
+
height: 14px;
|
|
325
|
+
}
|
|
326
|
+
.head {
|
|
327
|
+
display: flex;
|
|
328
|
+
align-items: flex-start;
|
|
329
|
+
gap: 10px;
|
|
330
|
+
}
|
|
331
|
+
.title {
|
|
332
|
+
flex: 1;
|
|
333
|
+
min-width: 0;
|
|
334
|
+
font-size: 30px;
|
|
335
|
+
font-weight: 600;
|
|
336
|
+
letter-spacing: -0.03em;
|
|
337
|
+
line-height: 1.2;
|
|
338
|
+
color: var(--kern-ink-900);
|
|
339
|
+
margin: 0;
|
|
340
|
+
border: 0;
|
|
341
|
+
background: none;
|
|
342
|
+
padding: 0;
|
|
343
|
+
font-family: inherit;
|
|
344
|
+
}
|
|
345
|
+
.title:focus {
|
|
346
|
+
outline: none;
|
|
347
|
+
}
|
|
348
|
+
.byline {
|
|
349
|
+
display: flex;
|
|
350
|
+
align-items: center;
|
|
351
|
+
gap: 9px;
|
|
352
|
+
margin-block-start: 14px;
|
|
353
|
+
padding-block-end: 20px;
|
|
354
|
+
border-block-end: 1px solid var(--kern-border);
|
|
355
|
+
font-size: 13px;
|
|
356
|
+
color: var(--kern-ink-400);
|
|
357
|
+
}
|
|
358
|
+
.chip {
|
|
359
|
+
display: inline-flex;
|
|
360
|
+
align-items: center;
|
|
361
|
+
gap: 4px;
|
|
362
|
+
}
|
|
363
|
+
.body {
|
|
364
|
+
margin-block-start: 22px;
|
|
365
|
+
}
|
|
366
|
+
/*
|
|
367
|
+
* The margin is a column beside the page rather than an overlay: a comment is about a specific
|
|
368
|
+
* sentence, and an overlay covers the sentence you are reading it against.
|
|
369
|
+
*/
|
|
370
|
+
.with-margin {
|
|
371
|
+
display: flex;
|
|
372
|
+
flex: 1;
|
|
373
|
+
min-height: 0;
|
|
374
|
+
min-width: 0;
|
|
375
|
+
}
|
|
376
|
+
.with-margin.open :global(> .kpage) {
|
|
377
|
+
min-width: 0;
|
|
378
|
+
}
|
|
379
|
+
.with-margin.open > :global(aside) {
|
|
380
|
+
width: 320px;
|
|
381
|
+
flex: none;
|
|
382
|
+
}
|
|
383
|
+
@media (max-width: 900px) {
|
|
384
|
+
.with-margin.open > :global(aside) {
|
|
385
|
+
width: 260px;
|
|
386
|
+
}
|
|
387
|
+
}
|
|
388
|
+
/*
|
|
389
|
+
* Above the body rather than beside the title: it is about what a reader currently sees, which is
|
|
390
|
+
* a statement about the text underneath it.
|
|
391
|
+
*/
|
|
392
|
+
.banner {
|
|
393
|
+
display: flex;
|
|
394
|
+
align-items: center;
|
|
395
|
+
gap: 10px;
|
|
396
|
+
margin-block-start: 18px;
|
|
397
|
+
padding: 10px 12px;
|
|
398
|
+
border-radius: var(--kern-r-lg);
|
|
399
|
+
background: var(--kern-warning-tint);
|
|
400
|
+
color: var(--kern-ink-700);
|
|
401
|
+
font-size: 13px;
|
|
402
|
+
}
|
|
403
|
+
.spacer {
|
|
404
|
+
flex: 1;
|
|
405
|
+
}
|
|
406
|
+
</style>
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import { Button, EmptyState, navigation, Skeleton, 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 { canQuire } from '../permissions.js'
|
|
7
|
+
import { quireKeys } from '../query.js'
|
|
8
|
+
import PageView from './PageView.svelte'
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* A space with no page chosen.
|
|
12
|
+
*
|
|
13
|
+
* If the space has a home page, that is what opening it means; otherwise this is the first thing
|
|
14
|
+
* somebody sees, so it has to offer the one action that gets them out of it.
|
|
15
|
+
*/
|
|
16
|
+
interface Props {
|
|
17
|
+
spaceKey: string
|
|
18
|
+
}
|
|
19
|
+
const { spaceKey }: Props = $props()
|
|
20
|
+
|
|
21
|
+
const api = getQuireApi()
|
|
22
|
+
const workspaceSlug = $derived(navigation.workspaceSlug)
|
|
23
|
+
const workspaceId = $derived(session.workspaces.find((w) => w.slug === workspaceSlug)?.id ?? '')
|
|
24
|
+
|
|
25
|
+
const spacesQuery = createQuery(() => ({
|
|
26
|
+
queryKey: quireKeys.spaces(workspaceId),
|
|
27
|
+
enabled: Boolean(workspaceId),
|
|
28
|
+
queryFn: () => api.spaces.list({ workspaceId, includeArchived: false }),
|
|
29
|
+
}))
|
|
30
|
+
const space = $derived((spacesQuery.data ?? []).find((s) => s.key === spaceKey) ?? null)
|
|
31
|
+
|
|
32
|
+
let creating = $state(false)
|
|
33
|
+
async function createFirst() {
|
|
34
|
+
if (!space || creating) return
|
|
35
|
+
creating = true
|
|
36
|
+
try {
|
|
37
|
+
const created = await api.pages.create({
|
|
38
|
+
workspaceId,
|
|
39
|
+
spaceId: space.id,
|
|
40
|
+
parentId: null,
|
|
41
|
+
title: '',
|
|
42
|
+
kind: 'page',
|
|
43
|
+
icon: null,
|
|
44
|
+
afterId: null,
|
|
45
|
+
})
|
|
46
|
+
void navigation.go(
|
|
47
|
+
`/${workspaceSlug}/quire/${encodeURIComponent(spaceKey)}/${encodeURIComponent(created.id)}`,
|
|
48
|
+
)
|
|
49
|
+
} finally {
|
|
50
|
+
creating = false
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
</script>
|
|
54
|
+
|
|
55
|
+
{#if spacesQuery.isLoading}
|
|
56
|
+
<div class="pad"><Skeleton height="36px" /></div>
|
|
57
|
+
{:else if !space}
|
|
58
|
+
<div class="pad">
|
|
59
|
+
<EmptyState icon="scroll-text" title={t('space_missing')} description={t('space_missing_desc')} />
|
|
60
|
+
</div>
|
|
61
|
+
{:else if space.homepageId}
|
|
62
|
+
<PageView {spaceKey} pageId={space.homepageId} />
|
|
63
|
+
{:else}
|
|
64
|
+
<div class="pad">
|
|
65
|
+
<EmptyState icon="file-text" title={t('space_empty')} description={t('space_empty_desc')}>
|
|
66
|
+
{#snippet actions()}
|
|
67
|
+
{#if canQuire('pageCreate')}
|
|
68
|
+
<Button disabled={creating} onclick={createFirst}>{t('new_page')}</Button>
|
|
69
|
+
{/if}
|
|
70
|
+
{/snippet}
|
|
71
|
+
</EmptyState>
|
|
72
|
+
</div>
|
|
73
|
+
{/if}
|
|
74
|
+
|
|
75
|
+
<style>
|
|
76
|
+
.pad {
|
|
77
|
+
padding: 28px 32px 48px;
|
|
78
|
+
}
|
|
79
|
+
</style>
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import { Button, Card, EmptyState, Icon, navigation, Page, PageHeader, Skeleton, session } from '@kernhq/ui'
|
|
3
|
+
import { createQuery } from '@tanstack/svelte-query'
|
|
4
|
+
import { getQuireApi } from '../api-instance.js'
|
|
5
|
+
import NewSpaceDialog from '../components/NewSpaceDialog.svelte'
|
|
6
|
+
import { t } from '../i18n.js'
|
|
7
|
+
import { canQuire } from '../permissions.js'
|
|
8
|
+
import { quireKeys } from '../query.js'
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* The spaces in this workspace.
|
|
12
|
+
*
|
|
13
|
+
* Quire's front door is a list of spaces rather than a page, because a space is the unit people
|
|
14
|
+
* think in — "the handbook", "engineering" — and the tree in the sidebar only makes sense once one
|
|
15
|
+
* is chosen. A space with a home page opens straight to it.
|
|
16
|
+
*/
|
|
17
|
+
const api = getQuireApi()
|
|
18
|
+
|
|
19
|
+
const workspaceSlug = $derived(navigation.workspaceSlug)
|
|
20
|
+
const workspace = $derived(session.workspaces.find((w) => w.slug === workspaceSlug))
|
|
21
|
+
const workspaceId = $derived(workspace?.id ?? '')
|
|
22
|
+
|
|
23
|
+
const spacesQuery = createQuery(() => ({
|
|
24
|
+
queryKey: quireKeys.spaces(workspaceId),
|
|
25
|
+
enabled: Boolean(workspaceId),
|
|
26
|
+
queryFn: () => api.spaces.list({ workspaceId, includeArchived: false }),
|
|
27
|
+
}))
|
|
28
|
+
const spaceList = $derived(spacesQuery.data ?? [])
|
|
29
|
+
|
|
30
|
+
let newOpen = $state(navigation.search.new === '1')
|
|
31
|
+
|
|
32
|
+
function open(key: string) {
|
|
33
|
+
void navigation.go(`/${workspaceSlug}/quire/${encodeURIComponent(key)}`)
|
|
34
|
+
}
|
|
35
|
+
</script>
|
|
36
|
+
|
|
37
|
+
<PageHeader
|
|
38
|
+
crumbs={[{ label: workspace?.name ?? '' }, { label: t('title') }]}
|
|
39
|
+
title={t('title')}
|
|
40
|
+
subtitle={t('subtitle')}
|
|
41
|
+
>
|
|
42
|
+
{#snippet actions()}
|
|
43
|
+
{#if canQuire('spaceManage')}
|
|
44
|
+
<Button size="sm" onclick={() => (newOpen = true)}>{t('new_space')}</Button>
|
|
45
|
+
{/if}
|
|
46
|
+
{/snippet}
|
|
47
|
+
</PageHeader>
|
|
48
|
+
|
|
49
|
+
<Page>
|
|
50
|
+
{#if spacesQuery.isLoading}
|
|
51
|
+
<div class="grid">
|
|
52
|
+
{#each [1, 2, 3] as n (n)}<Skeleton height="112px" />{/each}
|
|
53
|
+
</div>
|
|
54
|
+
{:else if spacesQuery.isError}
|
|
55
|
+
<EmptyState icon="triangle-alert" title={t('spaces_error')} description={t('spaces_error_desc')}>
|
|
56
|
+
{#snippet actions()}
|
|
57
|
+
<Button variant="secondary" onclick={() => void spacesQuery.refetch()}>{t('common.retry')}</Button>
|
|
58
|
+
{/snippet}
|
|
59
|
+
</EmptyState>
|
|
60
|
+
{:else if spaceList.length === 0}
|
|
61
|
+
<EmptyState icon="scroll-text" title={t('no_spaces')} description={t('no_spaces_desc')}>
|
|
62
|
+
{#snippet actions()}
|
|
63
|
+
{#if canQuire('spaceManage')}
|
|
64
|
+
<Button icon="plus" onclick={() => (newOpen = true)}>{t('new_space')}</Button>
|
|
65
|
+
{/if}
|
|
66
|
+
{/snippet}
|
|
67
|
+
</EmptyState>
|
|
68
|
+
{:else}
|
|
69
|
+
<div class="grid">
|
|
70
|
+
{#each spaceList as space (space.id)}
|
|
71
|
+
<button class="space" type="button" onclick={() => open(space.key)}>
|
|
72
|
+
<Card>
|
|
73
|
+
<div class="head">
|
|
74
|
+
<span class="ic"><Icon name={space.icon || 'scroll-text'} size={18} /></span>
|
|
75
|
+
<span class="name">{space.name}</span>
|
|
76
|
+
</div>
|
|
77
|
+
{#if space.description}
|
|
78
|
+
<p class="desc">{space.description}</p>
|
|
79
|
+
{/if}
|
|
80
|
+
<p class="meta">
|
|
81
|
+
{space.visibility === 'open'
|
|
82
|
+
? t('visibility_open')
|
|
83
|
+
: space.visibility === 'restricted'
|
|
84
|
+
? t('visibility_restricted')
|
|
85
|
+
: t('visibility_private')}
|
|
86
|
+
</p>
|
|
87
|
+
</Card>
|
|
88
|
+
</button>
|
|
89
|
+
{/each}
|
|
90
|
+
</div>
|
|
91
|
+
{/if}
|
|
92
|
+
</Page>
|
|
93
|
+
|
|
94
|
+
<NewSpaceDialog bind:open={newOpen} {workspaceId} onCreated={(space) => open(space.key)} />
|
|
95
|
+
|
|
96
|
+
<style>
|
|
97
|
+
.grid {
|
|
98
|
+
display: grid;
|
|
99
|
+
/*
|
|
100
|
+
* `auto-fit`, not `auto-fill`. `auto-fill` keeps the empty tracks, so two spaces in a wide window
|
|
101
|
+
* sit at their minimum width with the rest of the row blank; `auto-fit` collapses them and the
|
|
102
|
+
* cards share the space. Every other card grid in the app already does this.
|
|
103
|
+
*/
|
|
104
|
+
grid-template-columns: repeat(auto-fit, minmax(260px, 1fr));
|
|
105
|
+
gap: 12px;
|
|
106
|
+
}
|
|
107
|
+
.space {
|
|
108
|
+
border: 0;
|
|
109
|
+
background: none;
|
|
110
|
+
padding: 0;
|
|
111
|
+
text-align: start;
|
|
112
|
+
cursor: pointer;
|
|
113
|
+
font: inherit;
|
|
114
|
+
}
|
|
115
|
+
.head {
|
|
116
|
+
display: flex;
|
|
117
|
+
align-items: center;
|
|
118
|
+
gap: 10px;
|
|
119
|
+
}
|
|
120
|
+
.ic {
|
|
121
|
+
display: inline-flex;
|
|
122
|
+
color: var(--kern-ink-400);
|
|
123
|
+
}
|
|
124
|
+
.name {
|
|
125
|
+
font-size: 15px;
|
|
126
|
+
font-weight: 600;
|
|
127
|
+
color: var(--kern-ink-900);
|
|
128
|
+
letter-spacing: -0.01em;
|
|
129
|
+
}
|
|
130
|
+
.desc {
|
|
131
|
+
margin: 8px 0 0;
|
|
132
|
+
font-size: 13.5px;
|
|
133
|
+
line-height: 1.5;
|
|
134
|
+
color: var(--kern-ink-650);
|
|
135
|
+
}
|
|
136
|
+
.meta {
|
|
137
|
+
margin: 10px 0 0;
|
|
138
|
+
font-size: 12.5px;
|
|
139
|
+
color: var(--kern-ink-400);
|
|
140
|
+
}
|
|
141
|
+
</style>
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { session } from '@kernhq/ui'
|
|
2
|
+
import { quirePermissions } from '../contract/permissions.js'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* What quire lets somebody do.
|
|
6
|
+
*
|
|
7
|
+
* Derived from the contract rather than re-typed, and `key()` throws at import when a name does not
|
|
8
|
+
* exist. There were two hand-written copies of this before — one in the package and one in the app —
|
|
9
|
+
* and they had drifted: the package's was missing `page.comment` and `page.publish` entirely, so any
|
|
10
|
+
* screen gating on them through that copy silently had no key at all. Nothing reported it, because
|
|
11
|
+
* a wrong permission string is a perfectly valid string.
|
|
12
|
+
*
|
|
13
|
+
* Every key is declared at **space** scope on the server, so a person may be able to edit one space
|
|
14
|
+
* and only read another. `session.can` answers for the workspace, which is the right answer for
|
|
15
|
+
* "should this appear in the rail at all" and the wrong one for "may I edit this page" — the page
|
|
16
|
+
* asks the server for that, and the server is what refuses.
|
|
17
|
+
*/
|
|
18
|
+
const key = (suffix: string) => {
|
|
19
|
+
const found = quirePermissions.find((p) => p.key === `quire.${suffix}`)
|
|
20
|
+
if (!found) throw new Error(`quire: no permission declared for quire.${suffix}`)
|
|
21
|
+
return found.key
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export const QUIRE_PERMISSIONS = {
|
|
25
|
+
spaceView: key('space.view'),
|
|
26
|
+
spaceManage: key('space.manage'),
|
|
27
|
+
pageView: key('page.view'),
|
|
28
|
+
pageCreate: key('page.create'),
|
|
29
|
+
pageComment: key('page.comment'),
|
|
30
|
+
pageEdit: key('page.edit'),
|
|
31
|
+
pagePublish: key('page.publish'),
|
|
32
|
+
pageDelete: key('page.delete'),
|
|
33
|
+
} as const
|
|
34
|
+
|
|
35
|
+
export type QuirePermission = keyof typeof QUIRE_PERMISSIONS
|
|
36
|
+
|
|
37
|
+
export function canQuire(permission: QuirePermission): boolean {
|
|
38
|
+
return session.can(QUIRE_PERMISSIONS[permission])
|
|
39
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Query keys, shaped `[module, entity, …scope]` so a realtime `change` message invalidates exactly
|
|
3
|
+
* the queries it touches — `lib/realtime.svelte.ts` compares the `[module, entity]` prefix.
|
|
4
|
+
*
|
|
5
|
+
* The entity names have to match what the server sends in `kernel.realtime.change`: `space` and
|
|
6
|
+
* `page`. A key that spells one differently is a screen that never refreshes and nobody notices
|
|
7
|
+
* until somebody else edits something.
|
|
8
|
+
*/
|
|
9
|
+
export const quireKeys = {
|
|
10
|
+
spaces: (workspaceId: string) => ['quire', 'space', workspaceId] as const,
|
|
11
|
+
space: (workspaceId: string, spaceId: string) => ['quire', 'space', workspaceId, spaceId] as const,
|
|
12
|
+
tree: (workspaceId: string, spaceId: string) => ['quire', 'page', workspaceId, 'tree', spaceId] as const,
|
|
13
|
+
page: (workspaceId: string, pageId: string) => ['quire', 'page', workspaceId, pageId] as const,
|
|
14
|
+
trash: (workspaceId: string, spaceId: string) => ['quire', 'page', workspaceId, 'trash', spaceId] as const,
|
|
15
|
+
}
|