@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
|
@@ -0,0 +1,378 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import {
|
|
3
|
+
Avatar,
|
|
4
|
+
Button,
|
|
5
|
+
coreApi,
|
|
6
|
+
EmptyState,
|
|
7
|
+
formatCount,
|
|
8
|
+
formatDateTime,
|
|
9
|
+
Icon,
|
|
10
|
+
IconButton,
|
|
11
|
+
keys,
|
|
12
|
+
navigation,
|
|
13
|
+
Page,
|
|
14
|
+
PageHeader,
|
|
15
|
+
relativeTime,
|
|
16
|
+
Skeleton,
|
|
17
|
+
session,
|
|
18
|
+
Table,
|
|
19
|
+
TableCell,
|
|
20
|
+
TableHeader,
|
|
21
|
+
TableRow,
|
|
22
|
+
toast,
|
|
23
|
+
} from '@kernhq/ui'
|
|
24
|
+
import { createQuery, useQueryClient } from '@tanstack/svelte-query'
|
|
25
|
+
import type { Page as QuirePage } from '../../contract/index.js'
|
|
26
|
+
import { getQuireApi } from '../api-instance.js'
|
|
27
|
+
import ConfirmDialog from '../components/ConfirmDialog.svelte'
|
|
28
|
+
import { type CoreApi, toPerson } from '../core-api.js'
|
|
29
|
+
import { t } from '../i18n.js'
|
|
30
|
+
import { canQuire } from '../permissions.js'
|
|
31
|
+
import { quireKeys } from '../query.js'
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* The way back.
|
|
35
|
+
*
|
|
36
|
+
* "Move to trash" took a page and every page under it, with no confirmation and nowhere to look
|
|
37
|
+
* afterwards — deleting "Working here" silently took "Your first week" and "Time off" with it, and
|
|
38
|
+
* the only trace was that they had stopped being in the sidebar. `pages.trash` has always listed
|
|
39
|
+
* what was taken; nothing drew it. This is that screen.
|
|
40
|
+
*
|
|
41
|
+
* **Rows are subtrees, not pages.** The listing is flat — trashing a parent marks each descendant —
|
|
42
|
+
* so drawing it row-for-row would show three entries for one act and offer to restore each of them
|
|
43
|
+
* separately, which is exactly the confusion that made the loss invisible in the first place. A row
|
|
44
|
+
* is a page whose parent is *not* also in the trash, and it says how many pages went with it. That
|
|
45
|
+
* matches what restore and purge actually do: both act on the subtree.
|
|
46
|
+
*
|
|
47
|
+
* **When and by whom** is as honest as the data allows. `deleted_at` is a column; there is no
|
|
48
|
+
* `deleted_by`, so the person shown is the page's last editor and the column says so rather than
|
|
49
|
+
* implying they were the one who deleted it.
|
|
50
|
+
*/
|
|
51
|
+
interface Props {
|
|
52
|
+
params?: Record<string, string>
|
|
53
|
+
spaceKey?: string
|
|
54
|
+
}
|
|
55
|
+
const { params, spaceKey: spaceKeyProp }: Props = $props()
|
|
56
|
+
const spaceKey = $derived(spaceKeyProp ?? params?.space ?? '')
|
|
57
|
+
|
|
58
|
+
const api = getQuireApi()
|
|
59
|
+
const core = coreApi<CoreApi>()
|
|
60
|
+
const client = useQueryClient()
|
|
61
|
+
|
|
62
|
+
const workspaceSlug = $derived(navigation.workspaceSlug)
|
|
63
|
+
const workspace = $derived(session.workspaces.find((w) => w.slug === workspaceSlug))
|
|
64
|
+
const workspaceId = $derived(workspace?.id ?? '')
|
|
65
|
+
|
|
66
|
+
const spacesQuery = createQuery(() => ({
|
|
67
|
+
queryKey: quireKeys.spaces(workspaceId),
|
|
68
|
+
enabled: Boolean(workspaceId),
|
|
69
|
+
queryFn: () => api.spaces.list({ workspaceId, includeArchived: false }),
|
|
70
|
+
}))
|
|
71
|
+
const space = $derived((spacesQuery.data ?? []).find((s) => s.key === spaceKey) ?? null)
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* How many pages of the listing to walk.
|
|
75
|
+
*
|
|
76
|
+
* The trash is cursor-paginated and a subtree can straddle a page boundary, so grouping has to be
|
|
77
|
+
* done over the whole listing rather than over one batch — a batch that happens to hold a child
|
|
78
|
+
* and not its parent would draw the child as a subtree of its own. So the query walks the cursor
|
|
79
|
+
* itself. The cap is in the query key: when it is reached, **Show more** raises it and the query
|
|
80
|
+
* re-runs, rather than the screen quietly pretending there was nothing else.
|
|
81
|
+
*/
|
|
82
|
+
const BATCH = 200
|
|
83
|
+
let rounds = $state(3)
|
|
84
|
+
|
|
85
|
+
const query = createQuery(() => ({
|
|
86
|
+
queryKey: [...quireKeys.trash(workspaceId, space?.id ?? ''), rounds],
|
|
87
|
+
enabled: Boolean(workspaceId && space),
|
|
88
|
+
queryFn: async () => {
|
|
89
|
+
const items: QuirePage[] = []
|
|
90
|
+
let cursor: string | undefined
|
|
91
|
+
let capped = false
|
|
92
|
+
for (let round = 0; round < rounds; round++) {
|
|
93
|
+
const batch = await api.pages.trash({
|
|
94
|
+
workspaceId,
|
|
95
|
+
spaceId: space?.id ?? '',
|
|
96
|
+
limit: BATCH,
|
|
97
|
+
...(cursor ? { cursor } : {}),
|
|
98
|
+
})
|
|
99
|
+
items.push(...batch.items)
|
|
100
|
+
cursor = batch.nextCursor ?? undefined
|
|
101
|
+
if (!cursor) break
|
|
102
|
+
capped = round === rounds - 1
|
|
103
|
+
}
|
|
104
|
+
return { items, capped }
|
|
105
|
+
},
|
|
106
|
+
}))
|
|
107
|
+
|
|
108
|
+
const members = createQuery(() => ({
|
|
109
|
+
queryKey: keys.members(workspaceId),
|
|
110
|
+
enabled: Boolean(workspaceId),
|
|
111
|
+
queryFn: () => core.workspaces.members.list({ workspaceId, limit: 200 }),
|
|
112
|
+
}))
|
|
113
|
+
const people = $derived(new Map((members.data?.items ?? []).map(toPerson).map((p) => [p.id, p])))
|
|
114
|
+
|
|
115
|
+
const items = $derived(query.data?.items ?? [])
|
|
116
|
+
|
|
117
|
+
/** Every page in the trash, so "is my parent here too" is a lookup rather than a scan. */
|
|
118
|
+
const trashed = $derived(new Set(items.map((p) => p.id)))
|
|
119
|
+
|
|
120
|
+
interface Group {
|
|
121
|
+
page: QuirePage
|
|
122
|
+
/** how many pages go with it, itself included — the number restore and purge both act on */
|
|
123
|
+
size: number
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
const groups = $derived.by((): Group[] => {
|
|
127
|
+
const children = new Map<string, QuirePage[]>()
|
|
128
|
+
for (const p of items) {
|
|
129
|
+
if (!p.parentId) continue
|
|
130
|
+
children.set(p.parentId, [...(children.get(p.parentId) ?? []), p])
|
|
131
|
+
}
|
|
132
|
+
const sizeOf = (page: QuirePage): number => {
|
|
133
|
+
let total = 1
|
|
134
|
+
let guard = 0
|
|
135
|
+
const stack = [page.id]
|
|
136
|
+
while (stack.length > 0 && guard++ < 5000) {
|
|
137
|
+
const id = stack.pop() as string
|
|
138
|
+
for (const child of children.get(id) ?? []) {
|
|
139
|
+
total++
|
|
140
|
+
stack.push(child.id)
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
return total
|
|
144
|
+
}
|
|
145
|
+
return items
|
|
146
|
+
.filter((p) => !p.parentId || !trashed.has(p.parentId))
|
|
147
|
+
.sort((a, b) => ((a.deletedAt ?? '') < (b.deletedAt ?? '') ? 1 : -1))
|
|
148
|
+
.map((page) => ({ page, size: sizeOf(page) }))
|
|
149
|
+
})
|
|
150
|
+
|
|
151
|
+
const titleOf = (page: QuirePage) => page.title.trim() || t('untitled')
|
|
152
|
+
|
|
153
|
+
const iconFor = (page: QuirePage) =>
|
|
154
|
+
page.kind === 'live' ? 'square-pen' : page.kind === 'database' ? 'database' : 'file-text'
|
|
155
|
+
|
|
156
|
+
let busy = $state(false)
|
|
157
|
+
let purging = $state<Group | null>(null)
|
|
158
|
+
const purgeOpen = $derived(purging !== null)
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* The favourites and recents lists move with the page.
|
|
162
|
+
*
|
|
163
|
+
* Both are composed by joining to `pages`, so restoring a page puts it back into them and purging
|
|
164
|
+
* one takes it out — and neither would notice on its own: they live under their own query prefix,
|
|
165
|
+
* and a page change invalidates the `page` one.
|
|
166
|
+
*/
|
|
167
|
+
const refresh = async () => {
|
|
168
|
+
await client.invalidateQueries({ queryKey: quireKeys.trash(workspaceId, space?.id ?? '') })
|
|
169
|
+
await client.invalidateQueries({ queryKey: quireKeys.tree(workspaceId, space?.id ?? '') })
|
|
170
|
+
await client.invalidateQueries({ queryKey: quireKeys.favorites(workspaceId) })
|
|
171
|
+
await client.invalidateQueries({ queryKey: quireKeys.recents(workspaceId) })
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
async function restore(group: Group) {
|
|
175
|
+
if (busy) return
|
|
176
|
+
busy = true
|
|
177
|
+
try {
|
|
178
|
+
await api.pages.restore({ workspaceId, pageId: group.page.id })
|
|
179
|
+
await refresh()
|
|
180
|
+
toast.success(t('trash_restore_done', { title: titleOf(group.page) }))
|
|
181
|
+
} finally {
|
|
182
|
+
busy = false
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
async function purge(group: Group) {
|
|
187
|
+
const answer = await api.pages.purge({ workspaceId, pageId: group.page.id })
|
|
188
|
+
purging = null
|
|
189
|
+
await refresh()
|
|
190
|
+
toast.success(t('trash_purge_done', { count: answer.count }))
|
|
191
|
+
}
|
|
192
|
+
</script>
|
|
193
|
+
|
|
194
|
+
<PageHeader
|
|
195
|
+
crumbs={[
|
|
196
|
+
{ label: workspace?.name ?? '' },
|
|
197
|
+
{ label: t('title'), href: `/${workspaceSlug}/quire` },
|
|
198
|
+
...(space
|
|
199
|
+
? [{ label: space.name, href: `/${workspaceSlug}/quire/${encodeURIComponent(space.key)}` }]
|
|
200
|
+
: []),
|
|
201
|
+
{ label: t('trash') },
|
|
202
|
+
]}
|
|
203
|
+
title={t('trash')}
|
|
204
|
+
subtitle={t('trash_subtitle')}
|
|
205
|
+
/>
|
|
206
|
+
|
|
207
|
+
<Page>
|
|
208
|
+
{#if spacesQuery.isLoading || (query.isLoading && Boolean(space))}
|
|
209
|
+
<div class="rows">
|
|
210
|
+
{#each [1, 2, 3] as n (n)}<Skeleton height="48px" />{/each}
|
|
211
|
+
</div>
|
|
212
|
+
{:else if !space}
|
|
213
|
+
<EmptyState icon="scroll-text" title={t('space_missing')} description={t('space_missing_desc')} />
|
|
214
|
+
{:else if query.isError}
|
|
215
|
+
<EmptyState icon="triangle-alert" title={t('trash_error')} description={t('page_error_desc')}>
|
|
216
|
+
{#snippet actions()}
|
|
217
|
+
<Button variant="secondary" onclick={() => void query.refetch()}>{t('retry')}</Button>
|
|
218
|
+
{/snippet}
|
|
219
|
+
</EmptyState>
|
|
220
|
+
{:else if groups.length === 0}
|
|
221
|
+
<EmptyState icon="trash-2" title={t('trash_empty')} description={t('trash_empty_desc')} />
|
|
222
|
+
{:else}
|
|
223
|
+
<!--
|
|
224
|
+
The two text columns have a floor, and that is what makes the scroll box below do anything.
|
|
225
|
+
|
|
226
|
+
`minmax(0, …)` lets a column shrink to *nothing* rather than overflow, so at 390px the page
|
|
227
|
+
column collapsed to zero and the row drew the icon and "with 1 page inside it" and none of
|
|
228
|
+
the page's actual name — while the scroll box reported nothing to scroll, because nothing had
|
|
229
|
+
overflowed. Measured at 390px in en, fa and ar alike: the name was 0 wide in all three.
|
|
230
|
+
A minimum turns it back into an overflow, which is a thing a finger can move.
|
|
231
|
+
|
|
232
|
+
260px rather than something rounder, because the page cell holds three things: the icon, the
|
|
233
|
+
title, and "with 1 page inside it" — which is `flex: none` and around 106px, so a floor that
|
|
234
|
+
only clears the fixed pair leaves the title 33px again.
|
|
235
|
+
-->
|
|
236
|
+
<Table
|
|
237
|
+
columns="minmax(260px, 2fr) 110px minmax(90px, 1fr) 92px"
|
|
238
|
+
ariaLabel={t('trash')}
|
|
239
|
+
class="trash-table"
|
|
240
|
+
>
|
|
241
|
+
<TableHeader>
|
|
242
|
+
<TableCell header>{t('trash_col_page')}</TableCell>
|
|
243
|
+
<TableCell header>{t('trash_col_deleted')}</TableCell>
|
|
244
|
+
<TableCell header>{t('trash_col_editor')}</TableCell>
|
|
245
|
+
<TableCell header end>{formatCount(groups.length)}</TableCell>
|
|
246
|
+
</TableHeader>
|
|
247
|
+
|
|
248
|
+
{#each groups as group (group.page.id)}
|
|
249
|
+
{@const person = group.page.updatedBy ? people.get(group.page.updatedBy) : undefined}
|
|
250
|
+
<TableRow>
|
|
251
|
+
<TableCell>
|
|
252
|
+
<span class="ic"><Icon name={iconFor(group.page)} size={15} strokeWidth={1.6} /></span>
|
|
253
|
+
<span class="name">{titleOf(group.page)}</span>
|
|
254
|
+
{#if group.size > 1}
|
|
255
|
+
<span class="inside">{t('trash_inside', { count: group.size - 1 })}</span>
|
|
256
|
+
{/if}
|
|
257
|
+
</TableCell>
|
|
258
|
+
|
|
259
|
+
<TableCell>
|
|
260
|
+
<!--
|
|
261
|
+
A relative time with the exact one on hover: "3d" is what this column is read for, and
|
|
262
|
+
the full date is what somebody deciding whether to purge actually needs.
|
|
263
|
+
|
|
264
|
+
Through `formatDateTime`, not the raw column. `deletedAt` is an ISO 8601 UTC string,
|
|
265
|
+
so the tooltip read `2026-08-25T19:45:47.634Z` in every language — Latin digits and a
|
|
266
|
+
machine's calendar hanging off a cell that had just said پریروز. It is the same defect
|
|
267
|
+
the date column had, one layer up: the one untranslated thing on the page.
|
|
268
|
+
-->
|
|
269
|
+
<span class="when" title={group.page.deletedAt ? formatDateTime(group.page.deletedAt) : ''}>
|
|
270
|
+
{group.page.deletedAt ? relativeTime(group.page.deletedAt) : ''}
|
|
271
|
+
</span>
|
|
272
|
+
</TableCell>
|
|
273
|
+
|
|
274
|
+
<TableCell>
|
|
275
|
+
{#if person}
|
|
276
|
+
<Avatar id={person.id} name={person.name} src={person.avatarUrl ?? undefined} size={20} />
|
|
277
|
+
<span class="who">{person.name}</span>
|
|
278
|
+
{:else}
|
|
279
|
+
<span class="who">{t('comment_someone')}</span>
|
|
280
|
+
{/if}
|
|
281
|
+
</TableCell>
|
|
282
|
+
|
|
283
|
+
<TableCell end>
|
|
284
|
+
<IconButton
|
|
285
|
+
icon="rotate-ccw"
|
|
286
|
+
size={26}
|
|
287
|
+
variant="ghost"
|
|
288
|
+
label={t('restore')}
|
|
289
|
+
aria-busy={busy}
|
|
290
|
+
onclick={() => void restore(group)}
|
|
291
|
+
/>
|
|
292
|
+
{#if canQuire('pageDelete')}
|
|
293
|
+
<IconButton
|
|
294
|
+
icon="trash-2"
|
|
295
|
+
size={26}
|
|
296
|
+
variant="ghost"
|
|
297
|
+
label={t('trash_purge_title', { title: titleOf(group.page) })}
|
|
298
|
+
onclick={() => (purging = group)}
|
|
299
|
+
/>
|
|
300
|
+
{/if}
|
|
301
|
+
</TableCell>
|
|
302
|
+
</TableRow>
|
|
303
|
+
{/each}
|
|
304
|
+
</Table>
|
|
305
|
+
|
|
306
|
+
{#if query.data?.capped}
|
|
307
|
+
<div class="more">
|
|
308
|
+
<Button variant="secondary" size="sm" onclick={() => (rounds += 3)}>{t('trash_more')}</Button>
|
|
309
|
+
</div>
|
|
310
|
+
{/if}
|
|
311
|
+
{/if}
|
|
312
|
+
</Page>
|
|
313
|
+
|
|
314
|
+
<ConfirmDialog
|
|
315
|
+
open={purgeOpen}
|
|
316
|
+
title={t('trash_purge_title', { title: purging ? titleOf(purging.page) : '' })}
|
|
317
|
+
body={t('trash_purge_body', { count: purging?.size ?? 1 })}
|
|
318
|
+
confirmLabel={t('trash_purge')}
|
|
319
|
+
danger
|
|
320
|
+
onCancel={() => (purging = null)}
|
|
321
|
+
onConfirm={async () => {
|
|
322
|
+
if (purging) await purge(purging)
|
|
323
|
+
}}
|
|
324
|
+
/>
|
|
325
|
+
|
|
326
|
+
<style>
|
|
327
|
+
.rows {
|
|
328
|
+
display: flex;
|
|
329
|
+
flex-direction: column;
|
|
330
|
+
gap: 6px;
|
|
331
|
+
}
|
|
332
|
+
.ic {
|
|
333
|
+
display: inline-flex;
|
|
334
|
+
color: var(--kern-ink-400);
|
|
335
|
+
flex: none;
|
|
336
|
+
}
|
|
337
|
+
.name {
|
|
338
|
+
color: var(--kern-ink-900);
|
|
339
|
+
overflow: hidden;
|
|
340
|
+
text-overflow: ellipsis;
|
|
341
|
+
white-space: nowrap;
|
|
342
|
+
}
|
|
343
|
+
/*
|
|
344
|
+
* "with 2 pages inside it" is the whole point of the row, so it is a colour rather than a fade —
|
|
345
|
+
* `opacity` here would put the one fact a person came to read below the contrast floor.
|
|
346
|
+
*/
|
|
347
|
+
.inside {
|
|
348
|
+
flex: none;
|
|
349
|
+
font-size: 12px;
|
|
350
|
+
color: var(--kern-ink-450);
|
|
351
|
+
white-space: nowrap;
|
|
352
|
+
}
|
|
353
|
+
.when {
|
|
354
|
+
font-family: var(--kern-font-mono);
|
|
355
|
+
font-size: 12px;
|
|
356
|
+
color: var(--kern-ink-500);
|
|
357
|
+
letter-spacing: -0.01em;
|
|
358
|
+
}
|
|
359
|
+
.who {
|
|
360
|
+
overflow: hidden;
|
|
361
|
+
text-overflow: ellipsis;
|
|
362
|
+
white-space: nowrap;
|
|
363
|
+
color: var(--kern-ink-600);
|
|
364
|
+
}
|
|
365
|
+
.more {
|
|
366
|
+
display: flex;
|
|
367
|
+
justify-content: center;
|
|
368
|
+
padding-block-start: 14px;
|
|
369
|
+
}
|
|
370
|
+
/*
|
|
371
|
+
* The table is the only thing on this screen wide enough to overflow a narrow window, so it
|
|
372
|
+
* scrolls inside itself rather than taking the page sideways with it — which is how a Persian
|
|
373
|
+
* layout ends up with a horizontal scrollbar under everything.
|
|
374
|
+
*/
|
|
375
|
+
:global(.trash-table) {
|
|
376
|
+
overflow-x: auto;
|
|
377
|
+
}
|
|
378
|
+
</style>
|
package/src/client/query.ts
CHANGED
|
@@ -14,6 +14,30 @@ export const quireKeys = {
|
|
|
14
14
|
page: (workspaceId: string, pageId: string) => ['quire', 'page', workspaceId, pageId] as const,
|
|
15
15
|
trash: (workspaceId: string, spaceId: string) => ['quire', 'page', workspaceId, 'trash', spaceId] as const,
|
|
16
16
|
|
|
17
|
+
/**
|
|
18
|
+
* A space's vocabulary, and what one page wears out of it.
|
|
19
|
+
*
|
|
20
|
+
* `label` is the entity the server announces when one is created, renamed or removed, so a
|
|
21
|
+
* rename reaches every chip drawing that label without anybody wiring an invalidation. Putting
|
|
22
|
+
* labels *on* a page announces `page` instead — that is a change to the page — so
|
|
23
|
+
* `pages.setLabels` invalidates this key itself.
|
|
24
|
+
*/
|
|
25
|
+
labels: (workspaceId: string, spaceId: string) => ['quire', 'label', workspaceId, spaceId] as const,
|
|
26
|
+
pageLabels: (workspaceId: string, pageId: string) =>
|
|
27
|
+
['quire', 'label', workspaceId, 'page', pageId] as const,
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* The three personal lists.
|
|
31
|
+
*
|
|
32
|
+
* Nothing on the server announces a change for these, and nothing should: one person starring a
|
|
33
|
+
* page is not news to anybody else's open tab, and a `change` is broadcast to the whole
|
|
34
|
+
* workspace. Their entities are therefore cache names rather than realtime names — the mutations
|
|
35
|
+
* that write them answer with the whole list, so a screen redraws from the reply.
|
|
36
|
+
*/
|
|
37
|
+
favorites: (workspaceId: string) => ['quire', 'favorite', workspaceId] as const,
|
|
38
|
+
recents: (workspaceId: string) => ['quire', 'recent', workspaceId] as const,
|
|
39
|
+
watchers: (workspaceId: string, pageId: string) => ['quire', 'watcher', workspaceId, pageId] as const,
|
|
40
|
+
|
|
17
41
|
/** the schema — properties and views — which every open tab of a database is drawing */
|
|
18
42
|
database: (workspaceId: string, databaseId: string) =>
|
|
19
43
|
['quire', 'database', workspaceId, databaseId] as const,
|
package/src/contract/models.ts
CHANGED
|
@@ -161,4 +161,87 @@ export const CommentThread = z.object({
|
|
|
161
161
|
})
|
|
162
162
|
export type CommentThread = z.infer<typeof CommentThread>
|
|
163
163
|
|
|
164
|
+
/**
|
|
165
|
+
* The colours a label may wear, closed on purpose.
|
|
166
|
+
*
|
|
167
|
+
* `SelectOption.colour` next door is free text, and the comment on `client/database/colours.ts`
|
|
168
|
+
* explains what that costs: one typo renders a chip with no background, and — worse — a colour pair
|
|
169
|
+
* nobody has measured for contrast. These are exactly the keys of `TONES`, so every one of them is a
|
|
170
|
+
* pair the design tokens already tuned for light and dark. A label is picked from a menu rather than
|
|
171
|
+
* typed, so there is no reason to leave the door open here.
|
|
172
|
+
*/
|
|
173
|
+
export const LabelColour = z.enum([
|
|
174
|
+
'grey',
|
|
175
|
+
'slate',
|
|
176
|
+
'accent',
|
|
177
|
+
'success',
|
|
178
|
+
'warning',
|
|
179
|
+
'danger',
|
|
180
|
+
'info',
|
|
181
|
+
'purple',
|
|
182
|
+
])
|
|
183
|
+
export type LabelColour = z.infer<typeof LabelColour>
|
|
184
|
+
|
|
185
|
+
/**
|
|
186
|
+
* A word a space puts on pages, so they can be gathered by something other than where they sit.
|
|
187
|
+
*
|
|
188
|
+
* Scoped to a space, not to the workspace: two teams both wanting "Draft" should not have to agree
|
|
189
|
+
* on what it means, and a label list spanning every space is a list nobody can read. Names are
|
|
190
|
+
* unique per space case-insensitively — "Draft" and "draft" in one picker are broken data.
|
|
191
|
+
*/
|
|
192
|
+
export const Label = z.object({
|
|
193
|
+
id: Id,
|
|
194
|
+
workspaceId: WorkspaceId,
|
|
195
|
+
spaceId: Id,
|
|
196
|
+
name: z.string().min(1).max(60),
|
|
197
|
+
colour: LabelColour,
|
|
198
|
+
createdAt: Timestamp,
|
|
199
|
+
})
|
|
200
|
+
export type Label = z.infer<typeof Label>
|
|
201
|
+
|
|
202
|
+
/**
|
|
203
|
+
* A page somebody put in their own sidebar, and where they put it.
|
|
204
|
+
*
|
|
205
|
+
* `position` is a fractional index, not an integer, for the same reason `Page.position` is: dragging
|
|
206
|
+
* one favourite between two others must not renumber the rest, or two reorders at once write
|
|
207
|
+
* different numbers for the same rows.
|
|
208
|
+
*/
|
|
209
|
+
export const Favorite = z.object({
|
|
210
|
+
workspaceId: WorkspaceId,
|
|
211
|
+
userId: UserId,
|
|
212
|
+
pageId: Id,
|
|
213
|
+
position: z.string().min(1).max(256),
|
|
214
|
+
createdAt: Timestamp,
|
|
215
|
+
})
|
|
216
|
+
export type Favorite = z.infer<typeof Favorite>
|
|
217
|
+
|
|
218
|
+
/**
|
|
219
|
+
* The last time somebody opened a page.
|
|
220
|
+
*
|
|
221
|
+
* One row per person per page, bumped in place — not a visit log. A log grows without bound to
|
|
222
|
+
* answer a question that only ever wants the most recent handful, and needs pruning nobody runs.
|
|
223
|
+
*/
|
|
224
|
+
export const RecentView = z.object({
|
|
225
|
+
workspaceId: WorkspaceId,
|
|
226
|
+
userId: UserId,
|
|
227
|
+
pageId: Id,
|
|
228
|
+
viewedAt: Timestamp,
|
|
229
|
+
})
|
|
230
|
+
export type RecentView = z.infer<typeof RecentView>
|
|
231
|
+
|
|
232
|
+
/**
|
|
233
|
+
* Somebody who asked to hear about a page.
|
|
234
|
+
*
|
|
235
|
+
* Deliberately not the same thing as a favourite: "I want this to hand" and "tell me when this
|
|
236
|
+
* changes" are different requests, and collapsing them gives you either a sidebar full of pages
|
|
237
|
+
* somebody only wanted news about or a notification for every shortcut they made.
|
|
238
|
+
*/
|
|
239
|
+
export const Watcher = z.object({
|
|
240
|
+
workspaceId: WorkspaceId,
|
|
241
|
+
userId: UserId,
|
|
242
|
+
pageId: Id,
|
|
243
|
+
createdAt: Timestamp,
|
|
244
|
+
})
|
|
245
|
+
export type Watcher = z.infer<typeof Watcher>
|
|
246
|
+
|
|
164
247
|
export const Ok = z.object({ ok: z.literal(true) })
|
|
@@ -124,6 +124,42 @@ export const quireProcedureAuthz: Record<string, ProcedureAuthz> = {
|
|
|
124
124
|
'pages.trashPage': { check: 'page', permission: 'quire.page.edit' },
|
|
125
125
|
'pages.restore': { check: 'page', permission: 'quire.page.edit' },
|
|
126
126
|
'pages.purge': { check: 'page', permission: 'quire.page.delete' },
|
|
127
|
+
'pages.setLabels': { check: 'page', permission: 'quire.page.edit' },
|
|
128
|
+
|
|
129
|
+
// A label is the space's vocabulary, not one page's content: renaming "Draft" changes what it
|
|
130
|
+
// means everywhere it is worn, so writing one is `space.manage` while reading is `space.view`.
|
|
131
|
+
'labels.list': { check: 'space', permission: 'quire.space.view' },
|
|
132
|
+
'labels.forPage': { check: 'page', permission: 'quire.page.view' },
|
|
133
|
+
'labels.create': { check: 'space', permission: 'quire.space.manage' },
|
|
134
|
+
'labels.update': { check: 'space', permission: 'quire.space.manage' },
|
|
135
|
+
'labels.remove': { check: 'space', permission: 'quire.space.manage' },
|
|
136
|
+
|
|
137
|
+
/*
|
|
138
|
+
* Favourites, watches and recent views are one person's own, and that is a *filter inside the
|
|
139
|
+
* query*, not a permission — RLS fences the workspace, which is the tenant boundary rather than a
|
|
140
|
+
* privacy one, so `user_id` in each query is what keeps a sidebar personal.
|
|
141
|
+
*
|
|
142
|
+
* What the permission still decides is which pages may enter that list at all. Anything naming a
|
|
143
|
+
* page checks the page: you have to be able to read a page to bookmark it, to watch it, or to
|
|
144
|
+
* record having opened it — otherwise a page a space has closed to you is one you can still put
|
|
145
|
+
* in your own sidebar and be told about.
|
|
146
|
+
*
|
|
147
|
+
* The three that name no page are `workspace`, honestly: "my whole list" has no narrower scope to
|
|
148
|
+
* resolve. Each still drops the entries whose pages the caller may no longer read, and taking
|
|
149
|
+
* your own bookmark back is deliberately not gated on the page — a shortcut you can no longer
|
|
150
|
+
* open is exactly the one you want to be rid of, and needing read access to delete it would
|
|
151
|
+
* strand it there for good.
|
|
152
|
+
*/
|
|
153
|
+
'favorites.list': { check: 'workspace', permission: 'quire.page.view' },
|
|
154
|
+
'favorites.add': { check: 'page', permission: 'quire.page.view' },
|
|
155
|
+
'favorites.remove': { check: 'workspace', permission: 'quire.page.view' },
|
|
156
|
+
'favorites.reorder': { check: 'workspace', permission: 'quire.page.view' },
|
|
157
|
+
|
|
158
|
+
'watchers.get': { check: 'page', permission: 'quire.page.view' },
|
|
159
|
+
'watchers.set': { check: 'page', permission: 'quire.page.view' },
|
|
160
|
+
|
|
161
|
+
'recents.list': { check: 'workspace', permission: 'quire.page.view' },
|
|
162
|
+
'recents.record': { check: 'page', permission: 'quire.page.view' },
|
|
127
163
|
|
|
128
164
|
'versions.list': { check: 'page', permission: 'quire.page.view' },
|
|
129
165
|
'versions.get': { check: 'page', permission: 'quire.page.view' },
|