@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,158 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import { DropdownMenu, IconButton, type MenuItem } from '@kernhq/ui'
|
|
3
|
+
import { createQuery, useQueryClient } from '@tanstack/svelte-query'
|
|
4
|
+
import { untrack } from 'svelte'
|
|
5
|
+
import { getQuireApi } from '../api-instance.js'
|
|
6
|
+
import { t } from '../i18n.js'
|
|
7
|
+
import { quireKeys } from '../query.js'
|
|
8
|
+
import LabelChip from './LabelChip.svelte'
|
|
9
|
+
import LabelManager from './LabelManager.svelte'
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* The labels a page wears, and the menu that changes them.
|
|
13
|
+
*
|
|
14
|
+
* `pages.setLabels` replaces the whole set rather than adding one, so this holds the ticked set
|
|
15
|
+
* locally and sends all of it on every toggle. That is what makes unticking expressible at all —
|
|
16
|
+
* an additive procedure would need a second one to pair with it, and the pair would then have to
|
|
17
|
+
* agree about a page two people are editing.
|
|
18
|
+
*
|
|
19
|
+
* The local set is also what makes the menu feel like a menu. A checkbox that only ticks once the
|
|
20
|
+
* server has answered reads as broken at any latency worth having, so the tick lands immediately
|
|
21
|
+
* and the request follows; a failure resyncs from the server rather than leaving a lie on screen.
|
|
22
|
+
*
|
|
23
|
+
* The seeding effect reads `selected` and `inflight` through `untrack` because it writes the first
|
|
24
|
+
* and is gated on the second — tracked, the write would make the effect its own trigger and every
|
|
25
|
+
* tick would be undone by the stale answer still in the cache.
|
|
26
|
+
*/
|
|
27
|
+
interface Props {
|
|
28
|
+
workspaceId: string
|
|
29
|
+
spaceId: string
|
|
30
|
+
pageId: string
|
|
31
|
+
canEdit: boolean
|
|
32
|
+
canManage: boolean
|
|
33
|
+
}
|
|
34
|
+
const { workspaceId, spaceId, pageId, canEdit, canManage }: Props = $props()
|
|
35
|
+
|
|
36
|
+
const api = getQuireApi()
|
|
37
|
+
const client = useQueryClient()
|
|
38
|
+
|
|
39
|
+
const onPage = createQuery(() => ({
|
|
40
|
+
queryKey: quireKeys.pageLabels(workspaceId, pageId),
|
|
41
|
+
enabled: Boolean(workspaceId && pageId),
|
|
42
|
+
queryFn: () => api.labels.forPage({ workspaceId, pageId }),
|
|
43
|
+
}))
|
|
44
|
+
|
|
45
|
+
/** The whole vocabulary, so the menu can offer what is *not* on the page as well as what is. */
|
|
46
|
+
const inSpace = createQuery(() => ({
|
|
47
|
+
queryKey: quireKeys.labels(workspaceId, spaceId),
|
|
48
|
+
enabled: Boolean(workspaceId && spaceId),
|
|
49
|
+
queryFn: () => api.labels.list({ workspaceId, spaceId }),
|
|
50
|
+
}))
|
|
51
|
+
|
|
52
|
+
let selected = $state(new Set<string>())
|
|
53
|
+
let inflight = $state(0)
|
|
54
|
+
let managerOpen = $state(false)
|
|
55
|
+
|
|
56
|
+
$effect(() => {
|
|
57
|
+
const data = onPage.data
|
|
58
|
+
if (!data) return
|
|
59
|
+
if (untrack(() => inflight) > 0) return
|
|
60
|
+
const next = new Set(data.map((l) => l.id))
|
|
61
|
+
const current = untrack(() => selected)
|
|
62
|
+
if (next.size !== current.size || [...next].some((id) => !current.has(id))) selected = next
|
|
63
|
+
})
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Drawn from the space's list rather than from the page's own answer, so a rename shows up on the
|
|
67
|
+
* chip without this page being refetched — and so a tick that has not reached the server yet is
|
|
68
|
+
* already a chip. The page's answer stands in until the list has loaded.
|
|
69
|
+
*/
|
|
70
|
+
const chips = $derived(
|
|
71
|
+
inSpace.data
|
|
72
|
+
? inSpace.data.filter((l) => selected.has(l.id))
|
|
73
|
+
: (onPage.data ?? []).filter((l) => selected.has(l.id)),
|
|
74
|
+
)
|
|
75
|
+
|
|
76
|
+
async function toggle(labelId: string, on: boolean) {
|
|
77
|
+
const next = new Set(selected)
|
|
78
|
+
if (on) next.add(labelId)
|
|
79
|
+
else next.delete(labelId)
|
|
80
|
+
selected = next
|
|
81
|
+
inflight++
|
|
82
|
+
try {
|
|
83
|
+
await api.pages.setLabels({ workspaceId, pageId, labelIds: [...next] })
|
|
84
|
+
} catch {
|
|
85
|
+
// The optimistic tick was a guess; the server's answer is the fact.
|
|
86
|
+
await onPage.refetch()
|
|
87
|
+
} finally {
|
|
88
|
+
inflight--
|
|
89
|
+
/*
|
|
90
|
+
* The whole `label` prefix, not just this page's key. The sidebar's label filter holds a
|
|
91
|
+
* page-to-labels map for the space, and labelling a page here is precisely what makes it
|
|
92
|
+
* stale — the server announces `page updated` for this, which lands on the `page` prefix and
|
|
93
|
+
* never reaches it.
|
|
94
|
+
*/
|
|
95
|
+
if (inflight === 0) await client.invalidateQueries({ queryKey: ['quire', 'label', workspaceId] })
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
const items = $derived.by((): MenuItem[] => {
|
|
100
|
+
const all = inSpace.data ?? []
|
|
101
|
+
const rows: MenuItem[] = [{ type: 'label', label: t('labels_edit') }]
|
|
102
|
+
if (all.length === 0) rows.push({ id: 'none', label: t('labels_empty'), disabled: true })
|
|
103
|
+
for (const label of all)
|
|
104
|
+
rows.push({
|
|
105
|
+
type: 'checkbox',
|
|
106
|
+
id: label.id,
|
|
107
|
+
label: label.name,
|
|
108
|
+
checked: selected.has(label.id),
|
|
109
|
+
onCheckedChange: (on: boolean) => void toggle(label.id, on),
|
|
110
|
+
})
|
|
111
|
+
if (canManage) {
|
|
112
|
+
rows.push({ type: 'separator' })
|
|
113
|
+
rows.push({
|
|
114
|
+
id: 'manage',
|
|
115
|
+
label: t('labels_manage'),
|
|
116
|
+
icon: 'settings',
|
|
117
|
+
onSelect: () => (managerOpen = true),
|
|
118
|
+
})
|
|
119
|
+
}
|
|
120
|
+
return rows
|
|
121
|
+
})
|
|
122
|
+
</script>
|
|
123
|
+
|
|
124
|
+
{#if chips.length > 0 || canEdit}
|
|
125
|
+
<div class="labels">
|
|
126
|
+
{#each chips as label (label.id)}
|
|
127
|
+
<LabelChip {label} />
|
|
128
|
+
{/each}
|
|
129
|
+
|
|
130
|
+
{#if canEdit}
|
|
131
|
+
<DropdownMenu {items} align="start">
|
|
132
|
+
{#snippet trigger(props: Record<string, unknown>)}
|
|
133
|
+
<IconButton
|
|
134
|
+
{...props}
|
|
135
|
+
icon="tag"
|
|
136
|
+
size={26}
|
|
137
|
+
variant="ghost"
|
|
138
|
+
label={chips.length === 0 ? t('label_none_on_page') : t('labels_edit')}
|
|
139
|
+
/>
|
|
140
|
+
{/snippet}
|
|
141
|
+
</DropdownMenu>
|
|
142
|
+
{/if}
|
|
143
|
+
</div>
|
|
144
|
+
{/if}
|
|
145
|
+
|
|
146
|
+
{#if canManage}
|
|
147
|
+
<LabelManager bind:open={managerOpen} {workspaceId} {spaceId} />
|
|
148
|
+
{/if}
|
|
149
|
+
|
|
150
|
+
<style>
|
|
151
|
+
.labels {
|
|
152
|
+
display: flex;
|
|
153
|
+
align-items: center;
|
|
154
|
+
gap: 6px;
|
|
155
|
+
flex-wrap: wrap;
|
|
156
|
+
margin-block-start: 12px;
|
|
157
|
+
}
|
|
158
|
+
</style>
|
|
@@ -0,0 +1,262 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import {
|
|
3
|
+
DropdownMenu,
|
|
4
|
+
formatCount,
|
|
5
|
+
IconButton,
|
|
6
|
+
type MenuItem,
|
|
7
|
+
navigation,
|
|
8
|
+
SectionLabel,
|
|
9
|
+
SidebarItem,
|
|
10
|
+
} from '@kernhq/ui'
|
|
11
|
+
import { createQuery, useQueryClient } from '@tanstack/svelte-query'
|
|
12
|
+
import { untrack } from 'svelte'
|
|
13
|
+
import { dndzone, SHADOW_ITEM_MARKER_PROPERTY_NAME } from 'svelte-dnd-action'
|
|
14
|
+
import type { FavoriteEntry, Space } from '../../contract/index.js'
|
|
15
|
+
import { getQuireApi } from '../api-instance.js'
|
|
16
|
+
import { t } from '../i18n.js'
|
|
17
|
+
import { quireKeys } from '../query.js'
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* One person's own shortcuts, at the top of the sidebar where a table of contents is not.
|
|
21
|
+
*
|
|
22
|
+
* The list is the *workspace's*, not the space's — a favourite is "I want this to hand", and
|
|
23
|
+
* losing it because you switched space would defeat the point. Each entry carries the little of
|
|
24
|
+
* its page a row needs to draw itself, so the group is one request rather than one per row.
|
|
25
|
+
*
|
|
26
|
+
* Reordering is a drag and a menu, and the menu is not a fallback: `svelte-dnd-action` cannot be
|
|
27
|
+
* reached from a keyboard, so without **Move up** and **Move down** half the people using this
|
|
28
|
+
* could not reorder anything. Both go through `favorites.reorder`, which mints a fractional index
|
|
29
|
+
* server-side and answers with the whole list — so the reply *is* the new order and nothing has to
|
|
30
|
+
* refetch to find out what happened.
|
|
31
|
+
*
|
|
32
|
+
* Three traps in `svelte-dnd-action`, all of which have shipped in this project before: it tracks
|
|
33
|
+
* items by an `id` property and nothing else (a favourite is keyed by `pageId`, so the rows carry
|
|
34
|
+
* an `id` alias); the list must be `$state.raw`, because a deep-reactive proxy hands the library a
|
|
35
|
+
* different object on every read and it reads that as an endless stream of changes; and the
|
|
36
|
+
* seeding effect reads its `dragging` guard through `untrack`, or clearing the flag at the end of
|
|
37
|
+
* a drop re-runs it against the old query data and undoes the move on screen.
|
|
38
|
+
*/
|
|
39
|
+
interface Props {
|
|
40
|
+
workspaceId: string
|
|
41
|
+
workspaceSlug: string
|
|
42
|
+
/** the spaces this person can see, for turning a favourite's `spaceId` into a URL */
|
|
43
|
+
spaces: readonly Space[]
|
|
44
|
+
activePageId: string | null
|
|
45
|
+
}
|
|
46
|
+
const { workspaceId, workspaceSlug, spaces, activePageId }: Props = $props()
|
|
47
|
+
|
|
48
|
+
const api = getQuireApi()
|
|
49
|
+
const client = useQueryClient()
|
|
50
|
+
|
|
51
|
+
const query = createQuery(() => ({
|
|
52
|
+
queryKey: quireKeys.favorites(workspaceId),
|
|
53
|
+
enabled: Boolean(workspaceId),
|
|
54
|
+
queryFn: () => api.favorites.list({ workspaceId }),
|
|
55
|
+
}))
|
|
56
|
+
|
|
57
|
+
type Entry = FavoriteEntry & { id: string; [SHADOW_ITEM_MARKER_PROPERTY_NAME]?: boolean }
|
|
58
|
+
|
|
59
|
+
const keyOf = $derived(new Map(spaces.map((s) => [s.id, s.key])))
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* A favourite whose space this person cannot see is not drawn.
|
|
63
|
+
*
|
|
64
|
+
* The row would have nowhere to go: a page's URL is `/quire/<space key>/<page id>`, and the key
|
|
65
|
+
* comes from the space list. The server already drops the entries whose *pages* are unreadable, so
|
|
66
|
+
* what is left here is the narrower case of a space that has since been archived.
|
|
67
|
+
*/
|
|
68
|
+
const rows = $derived(
|
|
69
|
+
(query.data ?? []).filter((f) => keyOf.has(f.spaceId)).map((f): Entry => ({ ...f, id: f.pageId })),
|
|
70
|
+
)
|
|
71
|
+
|
|
72
|
+
let ordered = $state.raw<Entry[]>([])
|
|
73
|
+
let dragging = $state(false)
|
|
74
|
+
let busy = $state(false)
|
|
75
|
+
|
|
76
|
+
$effect(() => {
|
|
77
|
+
const next = rows
|
|
78
|
+
if (untrack(() => dragging)) return
|
|
79
|
+
ordered = next
|
|
80
|
+
})
|
|
81
|
+
|
|
82
|
+
const hrefFor = (entry: Entry) =>
|
|
83
|
+
`/${workspaceSlug}/quire/${encodeURIComponent(keyOf.get(entry.spaceId) ?? '')}/${encodeURIComponent(entry.pageId)}`
|
|
84
|
+
|
|
85
|
+
const iconFor = (entry: Entry) =>
|
|
86
|
+
entry.kind === 'live' ? 'square-pen' : entry.kind === 'database' ? 'database' : 'file-text'
|
|
87
|
+
|
|
88
|
+
const titleOf = (entry: Entry) => entry.title.trim() || t('untitled')
|
|
89
|
+
|
|
90
|
+
const isShadow = (entry: Entry) => Boolean(entry[SHADOW_ITEM_MARKER_PROPERTY_NAME])
|
|
91
|
+
|
|
92
|
+
/** `afterId` is the favourite to land behind; null means first. */
|
|
93
|
+
async function move(pageId: string, afterId: string | null) {
|
|
94
|
+
if (busy) return
|
|
95
|
+
busy = true
|
|
96
|
+
try {
|
|
97
|
+
const list = await api.favorites.reorder({ workspaceId, pageId, afterId })
|
|
98
|
+
client.setQueryData(quireKeys.favorites(workspaceId), list)
|
|
99
|
+
} finally {
|
|
100
|
+
busy = false
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
async function remove(pageId: string) {
|
|
105
|
+
if (busy) return
|
|
106
|
+
busy = true
|
|
107
|
+
try {
|
|
108
|
+
const list = await api.favorites.remove({ workspaceId, pageId })
|
|
109
|
+
client.setQueryData(quireKeys.favorites(workspaceId), list)
|
|
110
|
+
} finally {
|
|
111
|
+
busy = false
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function consider(event: CustomEvent<{ items: Entry[] }>) {
|
|
116
|
+
dragging = true
|
|
117
|
+
ordered = event.detail.items
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function finalize(event: CustomEvent<{ items: Entry[] }>) {
|
|
121
|
+
const items = event.detail.items
|
|
122
|
+
ordered = items
|
|
123
|
+
const before = new Map(untrack(() => rows).map((r, i) => [r.id, i]))
|
|
124
|
+
const moved = items.find((item, index) => before.get(item.id) !== index)
|
|
125
|
+
dragging = false
|
|
126
|
+
if (!moved) return
|
|
127
|
+
const at = items.indexOf(moved)
|
|
128
|
+
void move(moved.pageId, at > 0 ? (items[at - 1]?.pageId ?? null) : null)
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
function menuFor(entry: Entry, index: number): MenuItem[] {
|
|
132
|
+
const title = titleOf(entry)
|
|
133
|
+
return [
|
|
134
|
+
{
|
|
135
|
+
id: 'up',
|
|
136
|
+
label: t('favorite_move_up', { title }),
|
|
137
|
+
icon: 'chevron-up',
|
|
138
|
+
disabled: index === 0,
|
|
139
|
+
onSelect: () => void move(entry.pageId, index > 1 ? (ordered[index - 2]?.pageId ?? null) : null),
|
|
140
|
+
},
|
|
141
|
+
{
|
|
142
|
+
id: 'down',
|
|
143
|
+
label: t('favorite_move_down', { title }),
|
|
144
|
+
icon: 'chevron-down',
|
|
145
|
+
disabled: index >= ordered.length - 1,
|
|
146
|
+
onSelect: () => void move(entry.pageId, ordered[index + 1]?.pageId ?? null),
|
|
147
|
+
},
|
|
148
|
+
{ type: 'separator' },
|
|
149
|
+
{
|
|
150
|
+
id: 'remove',
|
|
151
|
+
label: t('favorite_remove'),
|
|
152
|
+
icon: 'star',
|
|
153
|
+
danger: true,
|
|
154
|
+
onSelect: () => void remove(entry.pageId),
|
|
155
|
+
},
|
|
156
|
+
]
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
function open(entry: Entry) {
|
|
160
|
+
void navigation.go(hrefFor(entry))
|
|
161
|
+
}
|
|
162
|
+
</script>
|
|
163
|
+
|
|
164
|
+
{#if ordered.length > 0}
|
|
165
|
+
<div class="group">
|
|
166
|
+
<SectionLabel label={t('favorites')} count={formatCount(ordered.length)} />
|
|
167
|
+
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
|
168
|
+
<div
|
|
169
|
+
class="stack"
|
|
170
|
+
use:dndzone={{
|
|
171
|
+
items: ordered,
|
|
172
|
+
flipDurationMs: 140,
|
|
173
|
+
type: 'quire-favorites',
|
|
174
|
+
dragDisabled: ordered.length < 2,
|
|
175
|
+
dropTargetStyle: {},
|
|
176
|
+
}}
|
|
177
|
+
onconsider={consider}
|
|
178
|
+
onfinalize={finalize}
|
|
179
|
+
>
|
|
180
|
+
{#each ordered as entry, index (entry.id)}
|
|
181
|
+
<div class="row" class:shadow={isShadow(entry)}>
|
|
182
|
+
<SidebarItem
|
|
183
|
+
label={titleOf(entry)}
|
|
184
|
+
icon={iconFor(entry)}
|
|
185
|
+
active={activePageId === entry.pageId}
|
|
186
|
+
onclick={() => open(entry)}
|
|
187
|
+
>
|
|
188
|
+
{#snippet trailing()}
|
|
189
|
+
<!--
|
|
190
|
+
Room for the menu, which is a sibling laid over the row rather than a child of it:
|
|
191
|
+
`SidebarItem` is a `<button>`, and a button inside a button is invalid markup whose
|
|
192
|
+
accessible name becomes the two labels run together.
|
|
193
|
+
-->
|
|
194
|
+
<span class="well"></span>
|
|
195
|
+
{/snippet}
|
|
196
|
+
</SidebarItem>
|
|
197
|
+
|
|
198
|
+
<span class="actions">
|
|
199
|
+
<DropdownMenu items={menuFor(entry, index)}>
|
|
200
|
+
{#snippet trigger(props: Record<string, unknown>)}
|
|
201
|
+
<IconButton
|
|
202
|
+
{...props}
|
|
203
|
+
icon="ellipsis"
|
|
204
|
+
size={22}
|
|
205
|
+
variant="ghost"
|
|
206
|
+
label={t('favorite_actions', { title: titleOf(entry) })}
|
|
207
|
+
/>
|
|
208
|
+
{/snippet}
|
|
209
|
+
</DropdownMenu>
|
|
210
|
+
</span>
|
|
211
|
+
</div>
|
|
212
|
+
{/each}
|
|
213
|
+
</div>
|
|
214
|
+
</div>
|
|
215
|
+
{/if}
|
|
216
|
+
|
|
217
|
+
<style>
|
|
218
|
+
.group {
|
|
219
|
+
padding-block-end: 6px;
|
|
220
|
+
}
|
|
221
|
+
.stack {
|
|
222
|
+
display: flex;
|
|
223
|
+
flex-direction: column;
|
|
224
|
+
gap: 1px;
|
|
225
|
+
}
|
|
226
|
+
.row {
|
|
227
|
+
position: relative;
|
|
228
|
+
}
|
|
229
|
+
/*
|
|
230
|
+
* The placeholder the library leaves where the row came from. Drawn as an outline rather than
|
|
231
|
+
* faded, because fading it would mute its text against the pane — `opacity` on a row is how
|
|
232
|
+
* "muted" becomes "unreadable".
|
|
233
|
+
*/
|
|
234
|
+
.row.shadow :global(.ksi) {
|
|
235
|
+
visibility: hidden;
|
|
236
|
+
}
|
|
237
|
+
.row.shadow {
|
|
238
|
+
border: 1px dashed var(--kern-border-strong);
|
|
239
|
+
border-radius: var(--kern-r-xl);
|
|
240
|
+
}
|
|
241
|
+
.well {
|
|
242
|
+
width: 22px;
|
|
243
|
+
flex: none;
|
|
244
|
+
}
|
|
245
|
+
/*
|
|
246
|
+
* `opacity: 0` rather than `display: none` keeps the control focusable, and `:focus-within` on the
|
|
247
|
+
* row is what makes it visible once the keyboard arrives — the same shape as the page tree's add
|
|
248
|
+
* button, and the reason **Move up** is reachable at all.
|
|
249
|
+
*/
|
|
250
|
+
.actions {
|
|
251
|
+
position: absolute;
|
|
252
|
+
inset-block-start: 50%;
|
|
253
|
+
inset-inline-end: 8px;
|
|
254
|
+
transform: translateY(-50%);
|
|
255
|
+
display: inline-flex;
|
|
256
|
+
opacity: 0;
|
|
257
|
+
}
|
|
258
|
+
.row:hover .actions,
|
|
259
|
+
.row:focus-within .actions {
|
|
260
|
+
opacity: 1;
|
|
261
|
+
}
|
|
262
|
+
</style>
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import { navigation, relativeTime, SectionLabel, SidebarItem } from '@kernhq/ui'
|
|
3
|
+
import { createQuery } from '@tanstack/svelte-query'
|
|
4
|
+
import type { RecentEntry, Space } from '../../contract/index.js'
|
|
5
|
+
import { getQuireApi } from '../api-instance.js'
|
|
6
|
+
import { t } from '../i18n.js'
|
|
7
|
+
import { quireKeys } from '../query.js'
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Where this person has just been, newest first.
|
|
11
|
+
*
|
|
12
|
+
* Below the tree rather than above it: the tree is the space's table of contents and the thing
|
|
13
|
+
* somebody came to the sidebar for, and a list that reorders itself every time you open a page is
|
|
14
|
+
* a poor thing to put in front of it. Eight rows, because this answers "take me back" and not
|
|
15
|
+
* "what have I read" — the row is a shortcut, and a long list of them stops being one.
|
|
16
|
+
*
|
|
17
|
+
* `recents.record` is called by the page screen on open; nothing here writes. The list is one
|
|
18
|
+
* person's own — the server filters it by the caller, which is the only thing keeping it personal,
|
|
19
|
+
* since row-level security fences the workspace rather than the reader.
|
|
20
|
+
*/
|
|
21
|
+
interface Props {
|
|
22
|
+
workspaceId: string
|
|
23
|
+
workspaceSlug: string
|
|
24
|
+
spaces: readonly Space[]
|
|
25
|
+
activePageId: string | null
|
|
26
|
+
}
|
|
27
|
+
const { workspaceId, workspaceSlug, spaces, activePageId }: Props = $props()
|
|
28
|
+
|
|
29
|
+
const api = getQuireApi()
|
|
30
|
+
|
|
31
|
+
const query = createQuery(() => ({
|
|
32
|
+
queryKey: quireKeys.recents(workspaceId),
|
|
33
|
+
enabled: Boolean(workspaceId),
|
|
34
|
+
queryFn: () => api.recents.list({ workspaceId, limit: 8 }),
|
|
35
|
+
}))
|
|
36
|
+
|
|
37
|
+
const keyOf = $derived(new Map(spaces.map((s) => [s.id, s.key])))
|
|
38
|
+
|
|
39
|
+
/** Same as the favourites group: a row whose space has no key has nowhere to navigate to. */
|
|
40
|
+
const rows = $derived((query.data ?? []).filter((r) => keyOf.has(r.spaceId)))
|
|
41
|
+
|
|
42
|
+
const titleOf = (entry: RecentEntry) => entry.title.trim() || t('untitled')
|
|
43
|
+
|
|
44
|
+
const iconFor = (entry: RecentEntry) =>
|
|
45
|
+
entry.kind === 'live' ? 'square-pen' : entry.kind === 'database' ? 'database' : 'file-text'
|
|
46
|
+
|
|
47
|
+
function open(entry: RecentEntry) {
|
|
48
|
+
void navigation.go(
|
|
49
|
+
`/${workspaceSlug}/quire/${encodeURIComponent(keyOf.get(entry.spaceId) ?? '')}/${encodeURIComponent(entry.pageId)}`,
|
|
50
|
+
)
|
|
51
|
+
}
|
|
52
|
+
</script>
|
|
53
|
+
|
|
54
|
+
{#if rows.length > 0}
|
|
55
|
+
<div class="group">
|
|
56
|
+
<SectionLabel label={t('recent')} />
|
|
57
|
+
<div class="stack">
|
|
58
|
+
{#each rows as entry (entry.pageId)}
|
|
59
|
+
<SidebarItem
|
|
60
|
+
label={titleOf(entry)}
|
|
61
|
+
icon={iconFor(entry)}
|
|
62
|
+
active={activePageId === entry.pageId}
|
|
63
|
+
onclick={() => open(entry)}
|
|
64
|
+
>
|
|
65
|
+
{#snippet trailing()}
|
|
66
|
+
<!--
|
|
67
|
+
A relative time, not a date: this list is only ever read as "how long ago", and
|
|
68
|
+
`relativeTime` renders it in the interface language rather than as a raw number.
|
|
69
|
+
-->
|
|
70
|
+
<span class="when">{relativeTime(entry.viewedAt)}</span>
|
|
71
|
+
{/snippet}
|
|
72
|
+
</SidebarItem>
|
|
73
|
+
{/each}
|
|
74
|
+
</div>
|
|
75
|
+
</div>
|
|
76
|
+
{/if}
|
|
77
|
+
|
|
78
|
+
<style>
|
|
79
|
+
.group {
|
|
80
|
+
padding-block-start: 10px;
|
|
81
|
+
}
|
|
82
|
+
.stack {
|
|
83
|
+
display: flex;
|
|
84
|
+
flex-direction: column;
|
|
85
|
+
gap: 1px;
|
|
86
|
+
}
|
|
87
|
+
/*
|
|
88
|
+
* Muted with a colour, never with `opacity` — and never smaller than 11.5px, which is where this
|
|
89
|
+
* pane's ink stops being legible against its own background.
|
|
90
|
+
*/
|
|
91
|
+
.when {
|
|
92
|
+
flex: none;
|
|
93
|
+
font-family: var(--kern-font-mono);
|
|
94
|
+
font-size: 11.5px;
|
|
95
|
+
color: var(--kern-ink-350);
|
|
96
|
+
letter-spacing: -0.01em;
|
|
97
|
+
}
|
|
98
|
+
</style>
|