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