@kernhq/module-quire 0.6.1 → 0.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +15 -3
- package/src/client/api-instance.ts +29 -0
- package/src/client/components/CommentsPanel.svelte +305 -0
- package/src/client/components/NewSpaceDialog.svelte +141 -0
- package/src/client/components/PageEditor.svelte +83 -0
- package/src/client/components/PageInline.svelte +44 -0
- package/src/client/components/PageTreeRow.svelte +147 -0
- package/src/client/components/SidebarSpaces.svelte +248 -0
- package/src/client/components/VersionHistory.svelte +159 -0
- package/src/client/i18n.ts +443 -0
- package/src/client/index.ts +4 -10
- package/src/client/mock.ts +287 -0
- package/src/client/module.ts +98 -0
- package/src/client/pages/PageView.svelte +406 -0
- package/src/client/pages/SpacePage.svelte +79 -0
- package/src/client/pages/SpacesPage.svelte +141 -0
- package/src/client/permissions.ts +39 -0
- package/src/client/query.ts +15 -0
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import { Icon, IconButton, SidebarItem } from '@kernhq/ui'
|
|
3
|
+
import { t } from '../i18n.js'
|
|
4
|
+
import type { PageTreeNode } from '../index.js'
|
|
5
|
+
import PageTreeRow from './PageTreeRow.svelte'
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* One row of the page tree, and its children.
|
|
9
|
+
*
|
|
10
|
+
* Recursive rather than flattened: the disclosure state belongs to the row that owns it, and a
|
|
11
|
+
* flattened list has to carry a depth on every node just to indent it. `SidebarItem` draws the row
|
|
12
|
+
* so indentation, the active state and RTL come from the design system rather than from here —
|
|
13
|
+
* `indent` caps at 2 there, so deeper levels stop stepping in rather than marching off the edge of a
|
|
14
|
+
* 268px column.
|
|
15
|
+
*/
|
|
16
|
+
interface Props {
|
|
17
|
+
node: PageTreeNode
|
|
18
|
+
depth: number
|
|
19
|
+
activeId: string | null
|
|
20
|
+
expanded: Set<string>
|
|
21
|
+
onToggle: (id: string) => void
|
|
22
|
+
onOpen: (id: string) => void
|
|
23
|
+
onCreateChild: (id: string) => void
|
|
24
|
+
canCreate: boolean
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const { node, depth, activeId, expanded, onToggle, onOpen, onCreateChild, canCreate }: Props = $props()
|
|
28
|
+
|
|
29
|
+
const isOpen = $derived(expanded.has(node.id))
|
|
30
|
+
const title = $derived(node.title.trim() || t('untitled'))
|
|
31
|
+
const indent = $derived(Math.min(depth, 2) as 0 | 1 | 2)
|
|
32
|
+
</script>
|
|
33
|
+
|
|
34
|
+
<div class="row">
|
|
35
|
+
<SidebarItem
|
|
36
|
+
label={title}
|
|
37
|
+
icon={node.kind === 'live' ? 'square-pen' : 'file-text'}
|
|
38
|
+
active={activeId === node.id}
|
|
39
|
+
{indent}
|
|
40
|
+
onclick={() => onOpen(node.id)}
|
|
41
|
+
>
|
|
42
|
+
{#snippet trailing()}
|
|
43
|
+
{#if node.archivedAt}
|
|
44
|
+
<span class="flag" title={t('archived')}><Icon name="archive" size={12} /></span>
|
|
45
|
+
{/if}
|
|
46
|
+
{#if canCreate}
|
|
47
|
+
<span class="add">
|
|
48
|
+
<IconButton
|
|
49
|
+
icon="plus"
|
|
50
|
+
size={22}
|
|
51
|
+
variant="ghost"
|
|
52
|
+
label={t('new_child_page')}
|
|
53
|
+
onclick={(e: MouseEvent) => {
|
|
54
|
+
e.stopPropagation()
|
|
55
|
+
onCreateChild(node.id)
|
|
56
|
+
}}
|
|
57
|
+
/>
|
|
58
|
+
</span>
|
|
59
|
+
{/if}
|
|
60
|
+
{/snippet}
|
|
61
|
+
</SidebarItem>
|
|
62
|
+
|
|
63
|
+
{#if node.hasChildren}
|
|
64
|
+
<button
|
|
65
|
+
class="twisty"
|
|
66
|
+
type="button"
|
|
67
|
+
style:inset-inline-start="{indent * 22 - 4}px"
|
|
68
|
+
aria-label={isOpen ? t('collapse') : t('expand')}
|
|
69
|
+
aria-expanded={isOpen}
|
|
70
|
+
onclick={() => onToggle(node.id)}
|
|
71
|
+
>
|
|
72
|
+
<span class:open={isOpen}><Icon name="chevron-right" size={11} strokeWidth={2} /></span>
|
|
73
|
+
</button>
|
|
74
|
+
{/if}
|
|
75
|
+
</div>
|
|
76
|
+
|
|
77
|
+
{#if isOpen}
|
|
78
|
+
{#each node.children as child (child.id)}
|
|
79
|
+
<PageTreeRow
|
|
80
|
+
node={child}
|
|
81
|
+
depth={depth + 1}
|
|
82
|
+
{activeId}
|
|
83
|
+
{expanded}
|
|
84
|
+
{onToggle}
|
|
85
|
+
{onOpen}
|
|
86
|
+
{onCreateChild}
|
|
87
|
+
{canCreate}
|
|
88
|
+
/>
|
|
89
|
+
{/each}
|
|
90
|
+
{/if}
|
|
91
|
+
|
|
92
|
+
<style>
|
|
93
|
+
.row {
|
|
94
|
+
position: relative;
|
|
95
|
+
}
|
|
96
|
+
/*
|
|
97
|
+
* The twisty sits over the item's icon well rather than inside the row, so expanding a page is not
|
|
98
|
+
* the same click target as opening it — a tree where one is inside the other makes it impossible to
|
|
99
|
+
* expand without navigating.
|
|
100
|
+
*/
|
|
101
|
+
.twisty {
|
|
102
|
+
position: absolute;
|
|
103
|
+
inset-block-start: 50%;
|
|
104
|
+
transform: translateY(-50%);
|
|
105
|
+
display: inline-flex;
|
|
106
|
+
align-items: center;
|
|
107
|
+
justify-content: center;
|
|
108
|
+
width: 16px;
|
|
109
|
+
height: 16px;
|
|
110
|
+
border: 0;
|
|
111
|
+
background: none;
|
|
112
|
+
padding: 0;
|
|
113
|
+
color: var(--kern-ink-350);
|
|
114
|
+
cursor: pointer;
|
|
115
|
+
border-radius: var(--kern-r-xs);
|
|
116
|
+
}
|
|
117
|
+
.twisty:hover {
|
|
118
|
+
color: var(--kern-ink-900);
|
|
119
|
+
}
|
|
120
|
+
.twisty span {
|
|
121
|
+
display: inline-flex;
|
|
122
|
+
transition: transform var(--kern-dur-fast) var(--kern-ease-out);
|
|
123
|
+
}
|
|
124
|
+
.twisty span.open {
|
|
125
|
+
transform: rotate(90deg);
|
|
126
|
+
}
|
|
127
|
+
/* The chevron points along the reading direction, so it mirrors with the document. */
|
|
128
|
+
:global([dir='rtl']) .twisty span {
|
|
129
|
+
transform: scaleX(-1);
|
|
130
|
+
}
|
|
131
|
+
:global([dir='rtl']) .twisty span.open {
|
|
132
|
+
transform: rotate(-90deg);
|
|
133
|
+
}
|
|
134
|
+
.flag {
|
|
135
|
+
display: inline-flex;
|
|
136
|
+
color: var(--kern-ink-400);
|
|
137
|
+
flex: none;
|
|
138
|
+
}
|
|
139
|
+
.add {
|
|
140
|
+
opacity: 0;
|
|
141
|
+
flex: none;
|
|
142
|
+
}
|
|
143
|
+
.row:hover .add,
|
|
144
|
+
.row:focus-within .add {
|
|
145
|
+
opacity: 1;
|
|
146
|
+
}
|
|
147
|
+
</style>
|
|
@@ -0,0 +1,248 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import {
|
|
3
|
+
Button,
|
|
4
|
+
EmptyState,
|
|
5
|
+
navigation,
|
|
6
|
+
SearchBox,
|
|
7
|
+
SectionLabel,
|
|
8
|
+
Select,
|
|
9
|
+
Skeleton,
|
|
10
|
+
session,
|
|
11
|
+
} from '@kernhq/ui'
|
|
12
|
+
import { createQuery, useQueryClient } from '@tanstack/svelte-query'
|
|
13
|
+
import { getQuireApi } from '../api-instance.js'
|
|
14
|
+
import { t } from '../i18n.js'
|
|
15
|
+
import { buildPageTree, type PageTreeNode } from '../index.js'
|
|
16
|
+
import { canQuire } from '../permissions.js'
|
|
17
|
+
import { quireKeys } from '../query.js'
|
|
18
|
+
import PageTreeRow from './PageTreeRow.svelte'
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Quire's spaces and pages, in the application sidebar (DESIGN.md §2.3).
|
|
22
|
+
*
|
|
23
|
+
* The sidebar belongs to whichever module you are in — that is why a wiki gets a space switcher, a
|
|
24
|
+
* "Search this space" box and its page tree here rather than a third column. The tree is the table
|
|
25
|
+
* of contents, so it shows every level at once and comes from one request per space.
|
|
26
|
+
*/
|
|
27
|
+
const api = getQuireApi()
|
|
28
|
+
const client = useQueryClient()
|
|
29
|
+
|
|
30
|
+
const workspaceSlug = $derived(navigation.workspaceSlug)
|
|
31
|
+
const workspace = $derived(session.workspaces.find((w) => w.slug === workspaceSlug))
|
|
32
|
+
const workspaceId = $derived(workspace?.id ?? '')
|
|
33
|
+
|
|
34
|
+
const spaceKeyInUrl = $derived(navigation.params.space ?? null)
|
|
35
|
+
const activePageId = $derived(navigation.params.page ?? null)
|
|
36
|
+
|
|
37
|
+
const spacesQuery = createQuery(() => ({
|
|
38
|
+
queryKey: quireKeys.spaces(workspaceId),
|
|
39
|
+
enabled: Boolean(workspaceId),
|
|
40
|
+
queryFn: () => api.spaces.list({ workspaceId, includeArchived: false }),
|
|
41
|
+
}))
|
|
42
|
+
|
|
43
|
+
const spaceList = $derived(spacesQuery.data ?? [])
|
|
44
|
+
const activeSpace = $derived(spaceList.find((space) => space.key === spaceKeyInUrl) ?? spaceList[0] ?? null)
|
|
45
|
+
|
|
46
|
+
const treeQuery = createQuery(() => ({
|
|
47
|
+
queryKey: quireKeys.tree(workspaceId, activeSpace?.id ?? ''),
|
|
48
|
+
enabled: Boolean(workspaceId && activeSpace),
|
|
49
|
+
queryFn: () => api.pages.tree({ workspaceId, spaceId: activeSpace?.id ?? '', includeArchived: false }),
|
|
50
|
+
}))
|
|
51
|
+
|
|
52
|
+
let search = $state('')
|
|
53
|
+
let expanded = $state(new Set<string>())
|
|
54
|
+
|
|
55
|
+
const nodes = $derived(treeQuery.data ?? [])
|
|
56
|
+
const roots = $derived(buildPageTree(nodes))
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Searching replaces the tree in the same scroll area rather than appearing under it, so the results
|
|
60
|
+
* are where the list was and never below the fold. It filters what is already loaded — the whole
|
|
61
|
+
* space is in memory — so it costs no request and answers as you type.
|
|
62
|
+
*/
|
|
63
|
+
const query = $derived(search.trim().toLowerCase())
|
|
64
|
+
const matches = $derived(
|
|
65
|
+
query ? nodes.filter((n) => (n.title || t('untitled')).toLowerCase().includes(query)) : [],
|
|
66
|
+
)
|
|
67
|
+
|
|
68
|
+
/** A page opened from a link may be nested; its ancestors have to be open for it to be visible. */
|
|
69
|
+
$effect(() => {
|
|
70
|
+
if (!activePageId || nodes.length === 0) return
|
|
71
|
+
const byId = new Map(nodes.map((n) => [n.id, n]))
|
|
72
|
+
const next = new Set(expanded)
|
|
73
|
+
let cursor = byId.get(activePageId)?.parentId ?? null
|
|
74
|
+
let guard = 0
|
|
75
|
+
while (cursor && guard++ < 100) {
|
|
76
|
+
next.add(cursor)
|
|
77
|
+
cursor = byId.get(cursor)?.parentId ?? null
|
|
78
|
+
}
|
|
79
|
+
if (next.size !== expanded.size) expanded = next
|
|
80
|
+
})
|
|
81
|
+
|
|
82
|
+
function toggle(id: string) {
|
|
83
|
+
const next = new Set(expanded)
|
|
84
|
+
if (!next.delete(id)) next.add(id)
|
|
85
|
+
expanded = next
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function openPage(id: string) {
|
|
89
|
+
if (!activeSpace) return
|
|
90
|
+
void navigation.go(
|
|
91
|
+
`/${workspaceSlug}/quire/${encodeURIComponent(activeSpace.key)}/${encodeURIComponent(id)}`,
|
|
92
|
+
)
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function switchSpace(key: string) {
|
|
96
|
+
void navigation.go(`/${workspaceSlug}/quire/${encodeURIComponent(key)}`)
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
let creating = $state(false)
|
|
100
|
+
|
|
101
|
+
async function createPage(parentId: string | null) {
|
|
102
|
+
if (!activeSpace || creating) return
|
|
103
|
+
creating = true
|
|
104
|
+
try {
|
|
105
|
+
const created = await api.pages.create({
|
|
106
|
+
workspaceId,
|
|
107
|
+
spaceId: activeSpace.id,
|
|
108
|
+
parentId,
|
|
109
|
+
title: '',
|
|
110
|
+
kind: 'page',
|
|
111
|
+
icon: null,
|
|
112
|
+
afterId: null,
|
|
113
|
+
})
|
|
114
|
+
await client.invalidateQueries({ queryKey: quireKeys.tree(workspaceId, activeSpace.id) })
|
|
115
|
+
if (parentId) expanded = new Set(expanded).add(parentId)
|
|
116
|
+
openPage(created.id)
|
|
117
|
+
} finally {
|
|
118
|
+
creating = false
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
</script>
|
|
122
|
+
|
|
123
|
+
<div class="wrap">
|
|
124
|
+
{#if spaceList.length > 1}
|
|
125
|
+
<div class="switcher">
|
|
126
|
+
<Select
|
|
127
|
+
value={activeSpace?.key ?? ''}
|
|
128
|
+
options={spaceList.map((space) => ({ value: space.key, label: space.name }))}
|
|
129
|
+
onValueChange={(v: string) => switchSpace(v)}
|
|
130
|
+
/>
|
|
131
|
+
</div>
|
|
132
|
+
{/if}
|
|
133
|
+
|
|
134
|
+
<div class="strip">
|
|
135
|
+
<SearchBox bind:value={search} placeholder={t('search_space')} />
|
|
136
|
+
</div>
|
|
137
|
+
|
|
138
|
+
<div class="scroll">
|
|
139
|
+
{#if spacesQuery.isLoading || treeQuery.isLoading}
|
|
140
|
+
<div class="loading">
|
|
141
|
+
{#each [1, 2, 3, 4, 5] as n (n)}
|
|
142
|
+
<Skeleton height="34px" />
|
|
143
|
+
{/each}
|
|
144
|
+
</div>
|
|
145
|
+
{:else if spacesQuery.isError || treeQuery.isError}
|
|
146
|
+
<EmptyState
|
|
147
|
+
icon="triangle-alert"
|
|
148
|
+
title={t('tree_error')}
|
|
149
|
+
description={t('tree_error_desc')}
|
|
150
|
+
>
|
|
151
|
+
{#snippet actions()}
|
|
152
|
+
<Button
|
|
153
|
+
variant="secondary"
|
|
154
|
+
size="sm"
|
|
155
|
+
onclick={() => {
|
|
156
|
+
void spacesQuery.refetch()
|
|
157
|
+
void treeQuery.refetch()
|
|
158
|
+
}}
|
|
159
|
+
>
|
|
160
|
+
{t('common.retry')}
|
|
161
|
+
</Button>
|
|
162
|
+
{/snippet}
|
|
163
|
+
</EmptyState>
|
|
164
|
+
{:else if spaceList.length === 0}
|
|
165
|
+
<EmptyState icon="scroll-text" title={t('no_spaces')} description={t('no_spaces_desc')} />
|
|
166
|
+
{:else if query}
|
|
167
|
+
<SectionLabel label={t('search_results')} />
|
|
168
|
+
{#if matches.length === 0}
|
|
169
|
+
<p class="none">{t('search_none')}</p>
|
|
170
|
+
{:else}
|
|
171
|
+
{#each matches as node (node.id)}
|
|
172
|
+
<PageTreeRow
|
|
173
|
+
node={{ ...node, children: [] } as PageTreeNode}
|
|
174
|
+
depth={0}
|
|
175
|
+
activeId={activePageId}
|
|
176
|
+
expanded={new Set()}
|
|
177
|
+
onToggle={() => {}}
|
|
178
|
+
onOpen={openPage}
|
|
179
|
+
onCreateChild={createPage}
|
|
180
|
+
canCreate={false}
|
|
181
|
+
/>
|
|
182
|
+
{/each}
|
|
183
|
+
{/if}
|
|
184
|
+
{:else}
|
|
185
|
+
<SectionLabel label={activeSpace?.name ?? t('nav')} />
|
|
186
|
+
{#if roots.length === 0}
|
|
187
|
+
<p class="none">{t('space_empty')}</p>
|
|
188
|
+
{:else}
|
|
189
|
+
{#each roots as node (node.id)}
|
|
190
|
+
<PageTreeRow
|
|
191
|
+
{node}
|
|
192
|
+
depth={0}
|
|
193
|
+
activeId={activePageId}
|
|
194
|
+
{expanded}
|
|
195
|
+
onToggle={toggle}
|
|
196
|
+
onOpen={openPage}
|
|
197
|
+
onCreateChild={createPage}
|
|
198
|
+
canCreate={canQuire('pageCreate')}
|
|
199
|
+
/>
|
|
200
|
+
{/each}
|
|
201
|
+
{/if}
|
|
202
|
+
|
|
203
|
+
{#if canQuire('pageCreate') && activeSpace}
|
|
204
|
+
<div class="new">
|
|
205
|
+
<Button variant="ghost" size="sm" icon="plus" disabled={creating} onclick={() => createPage(null)}>
|
|
206
|
+
{t('new_page')}
|
|
207
|
+
</Button>
|
|
208
|
+
</div>
|
|
209
|
+
{/if}
|
|
210
|
+
{/if}
|
|
211
|
+
</div>
|
|
212
|
+
</div>
|
|
213
|
+
|
|
214
|
+
<style>
|
|
215
|
+
.wrap {
|
|
216
|
+
display: flex;
|
|
217
|
+
flex-direction: column;
|
|
218
|
+
min-height: 0;
|
|
219
|
+
flex: 1;
|
|
220
|
+
}
|
|
221
|
+
.switcher {
|
|
222
|
+
padding: 10px 12px 0;
|
|
223
|
+
}
|
|
224
|
+
.strip {
|
|
225
|
+
padding: 12px 12px 4px;
|
|
226
|
+
}
|
|
227
|
+
.scroll {
|
|
228
|
+
flex: 1;
|
|
229
|
+
overflow-y: auto;
|
|
230
|
+
padding: 0 12px 14px;
|
|
231
|
+
min-height: 0;
|
|
232
|
+
}
|
|
233
|
+
.loading {
|
|
234
|
+
display: flex;
|
|
235
|
+
flex-direction: column;
|
|
236
|
+
gap: 6px;
|
|
237
|
+
padding-block-start: 8px;
|
|
238
|
+
}
|
|
239
|
+
.none {
|
|
240
|
+
padding: 10px;
|
|
241
|
+
font-size: 13px;
|
|
242
|
+
color: var(--kern-ink-400);
|
|
243
|
+
margin: 0;
|
|
244
|
+
}
|
|
245
|
+
.new {
|
|
246
|
+
padding-block-start: 6px;
|
|
247
|
+
}
|
|
248
|
+
</style>
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import { Avatar, Badge, Button, EmptyState, ListRow, relativeTime, Sheet, Skeleton } from '@kernhq/ui'
|
|
3
|
+
import { createQuery, useQueryClient } from '@tanstack/svelte-query'
|
|
4
|
+
import { getQuireApi } from '../api-instance.js'
|
|
5
|
+
import { t } from '../i18n.js'
|
|
6
|
+
import type { PageVersion } from '../index.js'
|
|
7
|
+
import { canQuire } from '../permissions.js'
|
|
8
|
+
import { quireKeys } from '../query.js'
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* What a page used to say, and how to put it back.
|
|
12
|
+
*
|
|
13
|
+
* Restoring is offered without a confirmation on purpose: it captures the state it replaces first,
|
|
14
|
+
* so it is undoable by restoring the version it just made. A confirmation dialog on a reversible
|
|
15
|
+
* action trains people to click through the ones that are not.
|
|
16
|
+
*/
|
|
17
|
+
interface Props {
|
|
18
|
+
open: boolean
|
|
19
|
+
workspaceId: string
|
|
20
|
+
pageId: string
|
|
21
|
+
publishedVersionId: string | null
|
|
22
|
+
}
|
|
23
|
+
let { open = $bindable(false), workspaceId, pageId, publishedVersionId }: Props = $props()
|
|
24
|
+
|
|
25
|
+
const api = getQuireApi()
|
|
26
|
+
const client = useQueryClient()
|
|
27
|
+
|
|
28
|
+
const query = createQuery(() => ({
|
|
29
|
+
queryKey: [...quireKeys.page(workspaceId, pageId), 'versions'],
|
|
30
|
+
enabled: open && Boolean(workspaceId && pageId),
|
|
31
|
+
queryFn: () => api.versions.list({ workspaceId, pageId, limit: 50 }),
|
|
32
|
+
}))
|
|
33
|
+
|
|
34
|
+
const versions = $derived(query.data?.items ?? [])
|
|
35
|
+
|
|
36
|
+
let restoring = $state<string | null>(null)
|
|
37
|
+
let error = $state<string | null>(null)
|
|
38
|
+
|
|
39
|
+
async function restore(version: PageVersion) {
|
|
40
|
+
if (restoring) return
|
|
41
|
+
restoring = version.id
|
|
42
|
+
error = null
|
|
43
|
+
try {
|
|
44
|
+
await api.versions.restore({ workspaceId, versionId: version.id })
|
|
45
|
+
await client.invalidateQueries({ queryKey: quireKeys.page(workspaceId, pageId) })
|
|
46
|
+
await query.refetch()
|
|
47
|
+
} catch (err) {
|
|
48
|
+
error = err instanceof Error ? err.message : String(err)
|
|
49
|
+
} finally {
|
|
50
|
+
restoring = null
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const kindLabel = (v: PageVersion) =>
|
|
55
|
+
v.kind === 'publish'
|
|
56
|
+
? t('version_published')
|
|
57
|
+
: v.kind === 'restore'
|
|
58
|
+
? t('version_restored')
|
|
59
|
+
: v.kind === 'import'
|
|
60
|
+
? t('version_imported')
|
|
61
|
+
: t('version_auto')
|
|
62
|
+
</script>
|
|
63
|
+
|
|
64
|
+
<Sheet bind:open title={t('history')} width={420}>
|
|
65
|
+
{#if query.isLoading}
|
|
66
|
+
<div class="rows">
|
|
67
|
+
{#each [1, 2, 3, 4] as n (n)}<Skeleton height="56px" />{/each}
|
|
68
|
+
</div>
|
|
69
|
+
{:else if query.isError}
|
|
70
|
+
<EmptyState icon="triangle-alert" title={t('history_error')} description={t('common.retry')}>
|
|
71
|
+
{#snippet actions()}
|
|
72
|
+
<Button variant="secondary" onclick={() => void query.refetch()}>{t('common.retry')}</Button>
|
|
73
|
+
{/snippet}
|
|
74
|
+
</EmptyState>
|
|
75
|
+
{:else if versions.length === 0}
|
|
76
|
+
<EmptyState icon="scroll-text" title={t('history_empty')} description={t('history_empty_desc')} />
|
|
77
|
+
{:else}
|
|
78
|
+
{#if error}<p class="error" role="alert">{error}</p>{/if}
|
|
79
|
+
<div class="rows">
|
|
80
|
+
{#each versions as version (version.id)}
|
|
81
|
+
<ListRow>
|
|
82
|
+
<div class="row">
|
|
83
|
+
<Avatar id={version.authorId} size={24} />
|
|
84
|
+
<div class="meta">
|
|
85
|
+
<div class="line">
|
|
86
|
+
<span class="when">{relativeTime(version.createdAt)}</span>
|
|
87
|
+
{#if version.id === publishedVersionId}
|
|
88
|
+
<Badge tone="active">{t('version_live')}</Badge>
|
|
89
|
+
{:else}
|
|
90
|
+
<span class="kind">{version.label || kindLabel(version)}</span>
|
|
91
|
+
{/if}
|
|
92
|
+
</div>
|
|
93
|
+
{#if version.preview}
|
|
94
|
+
<p class="preview">{version.preview}</p>
|
|
95
|
+
{/if}
|
|
96
|
+
</div>
|
|
97
|
+
{#if canQuire('pageEdit') && version.id !== publishedVersionId}
|
|
98
|
+
<Button
|
|
99
|
+
size="sm"
|
|
100
|
+
variant="secondary"
|
|
101
|
+
disabled={restoring !== null}
|
|
102
|
+
onclick={() => restore(version)}
|
|
103
|
+
>
|
|
104
|
+
{restoring === version.id ? t('restoring') : t('restore')}
|
|
105
|
+
</Button>
|
|
106
|
+
{/if}
|
|
107
|
+
</div>
|
|
108
|
+
</ListRow>
|
|
109
|
+
{/each}
|
|
110
|
+
</div>
|
|
111
|
+
{/if}
|
|
112
|
+
</Sheet>
|
|
113
|
+
|
|
114
|
+
<style>
|
|
115
|
+
.rows {
|
|
116
|
+
display: flex;
|
|
117
|
+
flex-direction: column;
|
|
118
|
+
gap: 4px;
|
|
119
|
+
}
|
|
120
|
+
.row {
|
|
121
|
+
display: flex;
|
|
122
|
+
align-items: flex-start;
|
|
123
|
+
gap: 10px;
|
|
124
|
+
width: 100%;
|
|
125
|
+
}
|
|
126
|
+
.meta {
|
|
127
|
+
flex: 1;
|
|
128
|
+
min-width: 0;
|
|
129
|
+
}
|
|
130
|
+
.line {
|
|
131
|
+
display: flex;
|
|
132
|
+
align-items: center;
|
|
133
|
+
gap: 8px;
|
|
134
|
+
}
|
|
135
|
+
.when {
|
|
136
|
+
font-size: 13.5px;
|
|
137
|
+
font-weight: 500;
|
|
138
|
+
color: var(--kern-ink-900);
|
|
139
|
+
}
|
|
140
|
+
.kind {
|
|
141
|
+
font-size: 12.5px;
|
|
142
|
+
color: var(--kern-ink-400);
|
|
143
|
+
}
|
|
144
|
+
.preview {
|
|
145
|
+
margin: 3px 0 0;
|
|
146
|
+
font-size: 12.5px;
|
|
147
|
+
line-height: 1.45;
|
|
148
|
+
color: var(--kern-ink-400);
|
|
149
|
+
overflow: hidden;
|
|
150
|
+
display: -webkit-box;
|
|
151
|
+
-webkit-line-clamp: 2;
|
|
152
|
+
-webkit-box-orient: vertical;
|
|
153
|
+
}
|
|
154
|
+
.error {
|
|
155
|
+
margin: 0 0 10px;
|
|
156
|
+
font-size: 13px;
|
|
157
|
+
color: var(--kern-danger);
|
|
158
|
+
}
|
|
159
|
+
</style>
|