@quoin-cms/admin 0.1.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/LICENSE +661 -0
- package/biome.json +62 -0
- package/dist/assets/index-C9Y5-AKj.js +33 -0
- package/dist/assets/index-uVdiUjty.css +1 -0
- package/dist/index.html +20 -0
- package/index.html +19 -0
- package/package.json +43 -0
- package/src/AdminRoot.svelte +98 -0
- package/src/app.css +211 -0
- package/src/lib/Slot.svelte +65 -0
- package/src/lib/api/auth.ts +26 -0
- package/src/lib/api/client.ts +73 -0
- package/src/lib/api/files.ts +56 -0
- package/src/lib/api/globals.ts +13 -0
- package/src/lib/api/records.ts +102 -0
- package/src/lib/api/schema.ts +7 -0
- package/src/lib/api/versions.ts +40 -0
- package/src/lib/components/AdminHeader.svelte +107 -0
- package/src/lib/components/AdminSidebar.svelte +262 -0
- package/src/lib/components/DeleteDialog.svelte +58 -0
- package/src/lib/components/DocumentEditLayout.svelte +263 -0
- package/src/lib/components/DynamicForm.svelte +74 -0
- package/src/lib/components/KpiCard.svelte +75 -0
- package/src/lib/components/MediaLibrary.svelte +311 -0
- package/src/lib/components/Pagination.svelte +78 -0
- package/src/lib/components/RangeFilter.svelte +41 -0
- package/src/lib/components/RecordGrid.svelte +123 -0
- package/src/lib/components/RecordTable.svelte +156 -0
- package/src/lib/components/cells/CheckboxCell.svelte +10 -0
- package/src/lib/components/cells/ColorCell.svelte +15 -0
- package/src/lib/components/cells/DateCell.svelte +8 -0
- package/src/lib/components/cells/RelationshipCell.svelte +20 -0
- package/src/lib/components/cells/RichTextCell.svelte +21 -0
- package/src/lib/components/cells/SelectCell.svelte +26 -0
- package/src/lib/components/cells/TextCell.svelte +8 -0
- package/src/lib/components/cells/UploadCell.svelte +34 -0
- package/src/lib/components/cells/index.ts +28 -0
- package/src/lib/components/charts/TimeSeriesChart.svelte +184 -0
- package/src/lib/components/doc/ApiView.svelte +181 -0
- package/src/lib/components/doc/Autosave.svelte +102 -0
- package/src/lib/components/doc/DocHeader.svelte +86 -0
- package/src/lib/components/doc/DocMetaStrip.svelte +103 -0
- package/src/lib/components/doc/DocTabBar.svelte +26 -0
- package/src/lib/components/doc/HeaderModeSwitch.svelte +32 -0
- package/src/lib/components/doc/PublishButton.svelte +114 -0
- package/src/lib/components/doc/ScheduleModal.svelte +110 -0
- package/src/lib/components/doc/VersionHistory.svelte +20 -0
- package/src/lib/components/fields/ArrayFieldEditor.svelte +62 -0
- package/src/lib/components/fields/BlockCard.svelte +63 -0
- package/src/lib/components/fields/BlocksFieldEditor.svelte +83 -0
- package/src/lib/components/fields/CheckboxField.svelte +27 -0
- package/src/lib/components/fields/ColorField.svelte +46 -0
- package/src/lib/components/fields/DateField.svelte +52 -0
- package/src/lib/components/fields/EmailField.svelte +30 -0
- package/src/lib/components/fields/FileField.svelte +280 -0
- package/src/lib/components/fields/JsonField.svelte +145 -0
- package/src/lib/components/fields/NumberField.svelte +44 -0
- package/src/lib/components/fields/PasswordField.svelte +38 -0
- package/src/lib/components/fields/RelationshipField.svelte +271 -0
- package/src/lib/components/fields/RichTextField.svelte +139 -0
- package/src/lib/components/fields/SelectField.svelte +33 -0
- package/src/lib/components/fields/SlugField.svelte +70 -0
- package/src/lib/components/fields/TabsField.svelte +56 -0
- package/src/lib/components/fields/TagsField.svelte +85 -0
- package/src/lib/components/fields/TextField.svelte +36 -0
- package/src/lib/components/fields/TextareaField.svelte +32 -0
- package/src/lib/components/fields/UploadField.svelte +166 -0
- package/src/lib/components/fields/UploadFieldDispatch.svelte +21 -0
- package/src/lib/components/fields/UploadGalleryField.svelte +166 -0
- package/src/lib/components/fields/index.ts +22 -0
- package/src/lib/components/fields/registry.ts +58 -0
- package/src/lib/components/lexical/CustomHTMLComponent.svelte +52 -0
- package/src/lib/components/lexical/CustomHTMLNode.ts +94 -0
- package/src/lib/components/lexical/PullQuoteComponent.svelte +73 -0
- package/src/lib/components/lexical/PullQuoteNode.ts +112 -0
- package/src/lib/components/lexical/lexical-helpers.ts +24 -0
- package/src/lib/components/lexical/nodes.ts +8 -0
- package/src/lib/components/lexical/toolbar/EditorToolbar.svelte +159 -0
- package/src/lib/components/lexical/toolbar/InsertBlockDropdown.svelte +278 -0
- package/src/lib/components/versions/CompareSelector.svelte +31 -0
- package/src/lib/components/versions/FieldDiff.svelte +141 -0
- package/src/lib/components/versions/RestoreModal.svelte +67 -0
- package/src/lib/components/versions/StatusPill.svelte +21 -0
- package/src/lib/context.svelte.ts +156 -0
- package/src/lib/router/index.svelte.ts +282 -0
- package/src/lib/router/matcher.ts +52 -0
- package/src/lib/stores/branding.svelte.ts +74 -0
- package/src/lib/stores/schema.svelte.ts +17 -0
- package/src/lib/types/schema.ts +126 -0
- package/src/lib/utils/cn.ts +6 -0
- package/src/lib/utils/diff.ts +112 -0
- package/src/lib/utils/dirty.svelte.ts +50 -0
- package/src/lib/utils/format.ts +28 -0
- package/src/lib/utils/json-highlight.ts +34 -0
- package/src/lib/utils/slug.ts +8 -0
- package/src/main.ts +32 -0
- package/src/views/AdminLayout.svelte +73 -0
- package/src/views/AdsAnalyticsView.svelte +152 -0
- package/src/views/CollectionEditView.svelte +117 -0
- package/src/views/CollectionListView.svelte +347 -0
- package/src/views/CollectionNewView.svelte +68 -0
- package/src/views/CustomPageView.svelte +59 -0
- package/src/views/DashboardView.svelte +370 -0
- package/src/views/GlobalEditView.svelte +100 -0
- package/src/views/LoginView.svelte +231 -0
- package/src/views/NotFoundView.svelte +9 -0
- package/src/views/VersionDetailView.svelte +307 -0
- package/src/views/VersionsListView.svelte +201 -0
- package/tsconfig.json +25 -0
- package/vite.config.ts +80 -0
|
@@ -0,0 +1,231 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import { goto } from '$lib/router/index.svelte.js'
|
|
3
|
+
import { resolve } from '$lib/router/index.svelte.js'
|
|
4
|
+
import { login, register, getMe, getSetupStatus } from '$lib/api/auth.js'
|
|
5
|
+
import { toast } from 'svelte-sonner'
|
|
6
|
+
import { onMount } from 'svelte'
|
|
7
|
+
import { BookOpen } from 'lucide-svelte'
|
|
8
|
+
import { branding, loadBranding } from '$lib/stores/branding.svelte.js'
|
|
9
|
+
import Slot from '$lib/Slot.svelte'
|
|
10
|
+
|
|
11
|
+
let email = $state('')
|
|
12
|
+
let password = $state('')
|
|
13
|
+
let confirmPassword = $state('')
|
|
14
|
+
let isSetup = $state(false)
|
|
15
|
+
let isLoading = $state(false)
|
|
16
|
+
let isCheckingAuth = $state(true)
|
|
17
|
+
|
|
18
|
+
onMount(async () => {
|
|
19
|
+
loadBranding()
|
|
20
|
+
const result = await getMe()
|
|
21
|
+
if (result.ok) {
|
|
22
|
+
await goto(resolve('/'))
|
|
23
|
+
return
|
|
24
|
+
}
|
|
25
|
+
const status = await getSetupStatus()
|
|
26
|
+
if (status.ok && status.data.needsSetup) {
|
|
27
|
+
isSetup = true
|
|
28
|
+
}
|
|
29
|
+
isCheckingAuth = false
|
|
30
|
+
})
|
|
31
|
+
|
|
32
|
+
async function handleLogin() {
|
|
33
|
+
if (!email || !password) {
|
|
34
|
+
toast.error('Please enter email and password')
|
|
35
|
+
return
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
isLoading = true
|
|
39
|
+
const result = await login(email, password)
|
|
40
|
+
isLoading = false
|
|
41
|
+
|
|
42
|
+
if (result.ok) {
|
|
43
|
+
await goto(resolve('/'))
|
|
44
|
+
} else {
|
|
45
|
+
if (result.status === 401) {
|
|
46
|
+
toast.error('Invalid credentials')
|
|
47
|
+
} else {
|
|
48
|
+
toast.error(result.error)
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
async function handleSetup() {
|
|
54
|
+
if (!email || !password) {
|
|
55
|
+
toast.error('Please enter email and password')
|
|
56
|
+
return
|
|
57
|
+
}
|
|
58
|
+
if (password !== confirmPassword) {
|
|
59
|
+
toast.error('Passwords do not match')
|
|
60
|
+
return
|
|
61
|
+
}
|
|
62
|
+
if (password.length < 6) {
|
|
63
|
+
toast.error('Password must be at least 6 characters')
|
|
64
|
+
return
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
isLoading = true
|
|
68
|
+
const result = await register(email, password)
|
|
69
|
+
isLoading = false
|
|
70
|
+
|
|
71
|
+
if (result.ok) {
|
|
72
|
+
toast.success('Admin account created! Logging in...')
|
|
73
|
+
const loginResult = await login(email, password)
|
|
74
|
+
if (loginResult.ok) {
|
|
75
|
+
await goto(resolve('/'))
|
|
76
|
+
} else {
|
|
77
|
+
toast.error('Account created but login failed. Please try logging in.')
|
|
78
|
+
isSetup = false
|
|
79
|
+
}
|
|
80
|
+
} else {
|
|
81
|
+
toast.error(result.error)
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function toggleMode() {
|
|
86
|
+
isSetup = !isSetup
|
|
87
|
+
confirmPassword = ''
|
|
88
|
+
}
|
|
89
|
+
</script>
|
|
90
|
+
|
|
91
|
+
{#if isCheckingAuth}
|
|
92
|
+
<div class="flex h-screen items-center justify-center bg-background">
|
|
93
|
+
<div class="flex h-10 w-10 items-center justify-center rounded-xl bg-primary/10">
|
|
94
|
+
<BookOpen class="h-5 w-5 text-primary animate-spin-slow" />
|
|
95
|
+
</div>
|
|
96
|
+
</div>
|
|
97
|
+
{:else}
|
|
98
|
+
<div class="flex min-h-screen">
|
|
99
|
+
<!-- Left panel — decorative -->
|
|
100
|
+
<div class="relative hidden w-1/2 overflow-hidden bg-sidebar-background lg:flex lg:flex-col lg:items-center lg:justify-center">
|
|
101
|
+
<!-- Subtle pattern overlay -->
|
|
102
|
+
<div class="absolute inset-0 opacity-[0.03]" style="background-image: radial-gradient(circle at 1px 1px, white 1px, transparent 0); background-size: 32px 32px;"></div>
|
|
103
|
+
|
|
104
|
+
<div class="relative z-10 max-w-md px-12 text-center">
|
|
105
|
+
<div class="mb-8 flex justify-center">
|
|
106
|
+
<Slot name="branding.logo" siteName={branding.siteName} logoUrl={branding.logoUrl}>
|
|
107
|
+
{#snippet children()}
|
|
108
|
+
{#if branding.logoUrl}
|
|
109
|
+
<img src={branding.logoUrl} alt={branding.siteName} class="h-20 w-auto max-w-[280px] object-contain" />
|
|
110
|
+
{:else}
|
|
111
|
+
<div class="flex h-16 w-16 items-center justify-center rounded-2xl bg-sidebar-accent">
|
|
112
|
+
<BookOpen class="h-8 w-8 text-sidebar-accent-foreground" />
|
|
113
|
+
</div>
|
|
114
|
+
{/if}
|
|
115
|
+
{/snippet}
|
|
116
|
+
</Slot>
|
|
117
|
+
</div>
|
|
118
|
+
{#if !branding.logoUrl}
|
|
119
|
+
<h1 class="text-3xl font-semibold tracking-tight text-white" style="font-family: var(--font-display);">
|
|
120
|
+
{branding.siteName}
|
|
121
|
+
</h1>
|
|
122
|
+
{/if}
|
|
123
|
+
<!-- Consumer-overridable tagline. Library default: site name + "Admin". -->
|
|
124
|
+
<Slot name="login.tagline">
|
|
125
|
+
{#snippet children()}
|
|
126
|
+
<p class="mt-3 text-base leading-relaxed text-sidebar-foreground/70" style="font-family: var(--font-display); font-style: italic;">
|
|
127
|
+
{branding.siteName} Admin
|
|
128
|
+
</p>
|
|
129
|
+
{/snippet}
|
|
130
|
+
</Slot>
|
|
131
|
+
<div class="mx-auto mt-8 h-px w-16 bg-sidebar-accent/40"></div>
|
|
132
|
+
</div>
|
|
133
|
+
</div>
|
|
134
|
+
|
|
135
|
+
<!-- Right panel — form -->
|
|
136
|
+
<div class="flex flex-1 items-center justify-center bg-background p-6">
|
|
137
|
+
<div class="w-full max-w-sm">
|
|
138
|
+
<!-- Mobile logo -->
|
|
139
|
+
<div class="mb-8 flex flex-col items-center lg:hidden">
|
|
140
|
+
{#if branding.logoUrl}
|
|
141
|
+
<img src={branding.logoUrl} alt={branding.siteName} class="h-14 w-auto max-w-[220px] object-contain" />
|
|
142
|
+
{:else}
|
|
143
|
+
<div class="mb-3 flex h-12 w-12 items-center justify-center rounded-xl bg-primary/10">
|
|
144
|
+
<BookOpen class="h-6 w-6 text-primary" />
|
|
145
|
+
</div>
|
|
146
|
+
<h1 class="text-xl font-semibold" style="font-family: var(--font-display);">{branding.siteName}</h1>
|
|
147
|
+
{/if}
|
|
148
|
+
</div>
|
|
149
|
+
|
|
150
|
+
<div class="mb-8">
|
|
151
|
+
<h2 class="text-xl font-semibold tracking-tight" style="font-family: var(--font-display);">
|
|
152
|
+
{isSetup ? 'Create your account' : 'Sign in'}
|
|
153
|
+
</h2>
|
|
154
|
+
<p class="mt-1.5 text-sm text-muted-foreground">
|
|
155
|
+
{isSetup ? 'Set up the first admin account to get started.' : 'Enter your credentials to continue.'}
|
|
156
|
+
</p>
|
|
157
|
+
</div>
|
|
158
|
+
|
|
159
|
+
<form
|
|
160
|
+
onsubmit={(e) => {
|
|
161
|
+
e.preventDefault();
|
|
162
|
+
isSetup ? handleSetup() : handleLogin();
|
|
163
|
+
}}
|
|
164
|
+
class="space-y-5"
|
|
165
|
+
>
|
|
166
|
+
<div>
|
|
167
|
+
<label for="email" class="mb-2 block text-[13px] font-medium text-foreground">
|
|
168
|
+
Email
|
|
169
|
+
</label>
|
|
170
|
+
<input
|
|
171
|
+
id="email"
|
|
172
|
+
type="email"
|
|
173
|
+
bind:value={email}
|
|
174
|
+
class="h-11 w-full rounded-lg border border-border bg-card px-3.5 text-sm shadow-sm transition-all placeholder:text-muted-foreground/50 focus:border-primary/40 focus:outline-none focus:ring-2 focus:ring-primary/10"
|
|
175
|
+
placeholder="you@example.com"
|
|
176
|
+
autocomplete="email"
|
|
177
|
+
/>
|
|
178
|
+
</div>
|
|
179
|
+
|
|
180
|
+
<div>
|
|
181
|
+
<label for="password" class="mb-2 block text-[13px] font-medium text-foreground">
|
|
182
|
+
Password
|
|
183
|
+
</label>
|
|
184
|
+
<input
|
|
185
|
+
id="password"
|
|
186
|
+
type="password"
|
|
187
|
+
bind:value={password}
|
|
188
|
+
class="h-11 w-full rounded-lg border border-border bg-card px-3.5 text-sm shadow-sm transition-all placeholder:text-muted-foreground/50 focus:border-primary/40 focus:outline-none focus:ring-2 focus:ring-primary/10"
|
|
189
|
+
placeholder="Enter password"
|
|
190
|
+
autocomplete={isSetup ? 'new-password' : 'current-password'}
|
|
191
|
+
/>
|
|
192
|
+
</div>
|
|
193
|
+
|
|
194
|
+
{#if isSetup}
|
|
195
|
+
<div>
|
|
196
|
+
<label for="confirmPassword" class="mb-2 block text-[13px] font-medium text-foreground">
|
|
197
|
+
Confirm Password
|
|
198
|
+
</label>
|
|
199
|
+
<input
|
|
200
|
+
id="confirmPassword"
|
|
201
|
+
type="password"
|
|
202
|
+
bind:value={confirmPassword}
|
|
203
|
+
class="h-11 w-full rounded-lg border border-border bg-card px-3.5 text-sm shadow-sm transition-all placeholder:text-muted-foreground/50 focus:border-primary/40 focus:outline-none focus:ring-2 focus:ring-primary/10"
|
|
204
|
+
placeholder="Confirm password"
|
|
205
|
+
autocomplete="new-password"
|
|
206
|
+
/>
|
|
207
|
+
</div>
|
|
208
|
+
{/if}
|
|
209
|
+
|
|
210
|
+
<button
|
|
211
|
+
type="submit"
|
|
212
|
+
disabled={isLoading}
|
|
213
|
+
class="h-11 w-full rounded-lg bg-primary text-sm font-medium text-primary-foreground shadow-sm transition-all hover:bg-primary/90 hover:shadow-md active:scale-[0.99] disabled:opacity-50 disabled:shadow-none"
|
|
214
|
+
>
|
|
215
|
+
{isLoading ? 'Please wait...' : isSetup ? 'Create Account' : 'Sign In'}
|
|
216
|
+
</button>
|
|
217
|
+
</form>
|
|
218
|
+
|
|
219
|
+
<div class="mt-6 text-center">
|
|
220
|
+
<button
|
|
221
|
+
type="button"
|
|
222
|
+
onclick={toggleMode}
|
|
223
|
+
class="text-[13px] text-muted-foreground transition-colors hover:text-primary"
|
|
224
|
+
>
|
|
225
|
+
{isSetup ? 'Already have an account? Sign in' : 'First time? Create admin account'}
|
|
226
|
+
</button>
|
|
227
|
+
</div>
|
|
228
|
+
</div>
|
|
229
|
+
</div>
|
|
230
|
+
</div>
|
|
231
|
+
{/if}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import { resolve } from '$lib/router/index.svelte.js'
|
|
3
|
+
</script>
|
|
4
|
+
|
|
5
|
+
<div class="flex h-[60vh] flex-col items-center justify-center gap-2 text-center">
|
|
6
|
+
<h1 class="text-2xl font-semibold">Page not found</h1>
|
|
7
|
+
<p class="text-muted-foreground">The admin URL you followed doesn't match any known route.</p>
|
|
8
|
+
<a class="text-primary underline mt-2" href={resolve('/')}>Back to dashboard</a>
|
|
9
|
+
</div>
|
|
@@ -0,0 +1,307 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import { page } from '$lib/router/index.svelte.js'
|
|
3
|
+
import { resolve } from '$lib/router/index.svelte.js'
|
|
4
|
+
import { goto } from '$lib/router/index.svelte.js'
|
|
5
|
+
import { schema, getCollectionByKey } from '$lib/stores/schema.svelte.js'
|
|
6
|
+
import { listVersions, getVersion, restoreVersion } from '$lib/api/versions.js'
|
|
7
|
+
import type { Version } from '$lib/api/versions.js'
|
|
8
|
+
import FieldDiff from '$lib/components/versions/FieldDiff.svelte'
|
|
9
|
+
import StatusPill from '$lib/components/versions/StatusPill.svelte'
|
|
10
|
+
import CompareSelector from '$lib/components/versions/CompareSelector.svelte'
|
|
11
|
+
import type { VersionOption } from '$lib/components/versions/CompareSelector.svelte'
|
|
12
|
+
import RestoreModal from '$lib/components/versions/RestoreModal.svelte'
|
|
13
|
+
import { toast } from 'svelte-sonner'
|
|
14
|
+
import { onMount } from 'svelte'
|
|
15
|
+
|
|
16
|
+
let collectionKey = $derived(page.params.collection ?? '')
|
|
17
|
+
let recordId = $derived(page.params.id ?? '')
|
|
18
|
+
let versionId = $derived(page.params.vid ?? '')
|
|
19
|
+
let collection = $derived(getCollectionByKey(schema.collections, collectionKey))
|
|
20
|
+
|
|
21
|
+
let allVersions = $state<Version[]>([])
|
|
22
|
+
let currentVersion = $state<Version | null>(null)
|
|
23
|
+
let compareVersion = $state<Version | null>(null)
|
|
24
|
+
let isLoading = $state(true)
|
|
25
|
+
let modifiedOnly = $state(true)
|
|
26
|
+
|
|
27
|
+
let restoreModalOpen = $state(false)
|
|
28
|
+
let isRestoring = $state(false)
|
|
29
|
+
|
|
30
|
+
let hasDrafts = $derived(collection?.versions?.drafts != null)
|
|
31
|
+
|
|
32
|
+
let currentData = $derived(parseData(currentVersion))
|
|
33
|
+
let compareData = $derived(parseData(compareVersion))
|
|
34
|
+
|
|
35
|
+
const systemFields = ['id', 'createdAt', 'updatedAt', '_versionId', '_versionCreatedAt']
|
|
36
|
+
|
|
37
|
+
function flattenFields(flds: any[]): any[] {
|
|
38
|
+
const out: any[] = []
|
|
39
|
+
for (const f of flds) {
|
|
40
|
+
if (f.type === 'tabs') {
|
|
41
|
+
for (const t of (f.tabs ?? [])) out.push(...flattenFields(t.fields ?? []))
|
|
42
|
+
continue
|
|
43
|
+
}
|
|
44
|
+
out.push(f)
|
|
45
|
+
}
|
|
46
|
+
return out
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
let fields = $derived.by(() => {
|
|
50
|
+
if (!collection) return []
|
|
51
|
+
return flattenFields(collection.fields).filter(
|
|
52
|
+
(f: any) => !f.hidden && !systemFields.includes(f.name)
|
|
53
|
+
)
|
|
54
|
+
})
|
|
55
|
+
|
|
56
|
+
let visibleFields = $derived.by(() => {
|
|
57
|
+
if (!modifiedOnly) return fields
|
|
58
|
+
return fields.filter((f: any) => {
|
|
59
|
+
const oldVal = compareData[f.name]
|
|
60
|
+
const newVal = currentData[f.name]
|
|
61
|
+
return JSON.stringify(oldVal) !== JSON.stringify(newVal)
|
|
62
|
+
})
|
|
63
|
+
})
|
|
64
|
+
|
|
65
|
+
let latestPublishedId = $derived.by(() => {
|
|
66
|
+
const v = allVersions.find((v) => v._status === 'published' && v.latest)
|
|
67
|
+
return v?.id ?? allVersions.find((v) => v._status === 'published')?.id ?? ''
|
|
68
|
+
})
|
|
69
|
+
|
|
70
|
+
let latestDraftId = $derived.by(() => {
|
|
71
|
+
const v = allVersions.find((v) => v._status === 'draft' && v.latest)
|
|
72
|
+
return v?.id ?? ''
|
|
73
|
+
})
|
|
74
|
+
|
|
75
|
+
let compareOptions = $derived.by((): VersionOption[] => {
|
|
76
|
+
const firstPublished = allVersions.find((v) => v._status === 'published')
|
|
77
|
+
let defaultMarked = false
|
|
78
|
+
return allVersions
|
|
79
|
+
.filter((v) => v.id !== versionId)
|
|
80
|
+
.map((v) => {
|
|
81
|
+
let label: string
|
|
82
|
+
if (v.latest && v._status === 'draft') {
|
|
83
|
+
label = 'Current Draft'
|
|
84
|
+
} else if (v._status === 'published' && v.id === firstPublished?.id) {
|
|
85
|
+
label = 'Currently Published'
|
|
86
|
+
} else if (v._status === 'published') {
|
|
87
|
+
label = 'Previously Published'
|
|
88
|
+
} else if (!defaultMarked) {
|
|
89
|
+
label = 'Previous Version'
|
|
90
|
+
defaultMarked = true
|
|
91
|
+
} else {
|
|
92
|
+
label = 'Version'
|
|
93
|
+
}
|
|
94
|
+
return {
|
|
95
|
+
id: v.id,
|
|
96
|
+
label,
|
|
97
|
+
status: v._status,
|
|
98
|
+
isLatest: v.id === latestPublishedId || v.id === latestDraftId,
|
|
99
|
+
date: v.createdAt,
|
|
100
|
+
}
|
|
101
|
+
})
|
|
102
|
+
})
|
|
103
|
+
|
|
104
|
+
function parseData(v: Version | null): Record<string, any> {
|
|
105
|
+
if (!v) return {}
|
|
106
|
+
const raw = v.version
|
|
107
|
+
if (!raw) return {}
|
|
108
|
+
if (typeof raw === 'string') {
|
|
109
|
+
try { return JSON.parse(raw) } catch { return {} }
|
|
110
|
+
}
|
|
111
|
+
if (typeof raw === 'object') return raw
|
|
112
|
+
return {}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function formatDate(iso: string): string {
|
|
116
|
+
if (!iso) return ''
|
|
117
|
+
return new Date(iso).toLocaleString()
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function timeAgo(iso: string): string {
|
|
121
|
+
if (!iso) return ''
|
|
122
|
+
const diff = Date.now() - new Date(iso).getTime()
|
|
123
|
+
const seconds = Math.floor(diff / 1000)
|
|
124
|
+
if (seconds < 60) return `${seconds}s ago`
|
|
125
|
+
const minutes = Math.floor(seconds / 60)
|
|
126
|
+
if (minutes < 60) return `${minutes}m ago`
|
|
127
|
+
const hours = Math.floor(minutes / 60)
|
|
128
|
+
if (hours < 24) return `${hours}h ago`
|
|
129
|
+
const days = Math.floor(hours / 24)
|
|
130
|
+
return `${days}d ago`
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
async function loadCompare(id: string) {
|
|
134
|
+
const result = await getVersion(collectionKey, recordId, id)
|
|
135
|
+
if (result.ok) {
|
|
136
|
+
compareVersion = result.data
|
|
137
|
+
} else {
|
|
138
|
+
toast.error(result.error)
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
function handleCompareChange(id: string) {
|
|
143
|
+
loadCompare(id)
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
onMount(async () => {
|
|
147
|
+
const [listResult, versionResult] = await Promise.all([
|
|
148
|
+
listVersions(collectionKey, recordId, { page: 1, limit: 50 }),
|
|
149
|
+
getVersion(collectionKey, recordId, versionId),
|
|
150
|
+
])
|
|
151
|
+
|
|
152
|
+
if (!listResult.ok) {
|
|
153
|
+
isLoading = false
|
|
154
|
+
toast.error(listResult.error)
|
|
155
|
+
return
|
|
156
|
+
}
|
|
157
|
+
if (!versionResult.ok) {
|
|
158
|
+
isLoading = false
|
|
159
|
+
toast.error(versionResult.error)
|
|
160
|
+
return
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
allVersions = listResult.data.versions ?? []
|
|
164
|
+
currentVersion = versionResult.data
|
|
165
|
+
|
|
166
|
+
// Find next older version for default compare
|
|
167
|
+
const idx = allVersions.findIndex((v) => v.id === versionId)
|
|
168
|
+
const defaultCompare = idx >= 0 && idx < allVersions.length - 1
|
|
169
|
+
? allVersions[idx + 1]
|
|
170
|
+
: allVersions.find((v) => v.id !== versionId)
|
|
171
|
+
|
|
172
|
+
if (defaultCompare) {
|
|
173
|
+
await loadCompare(defaultCompare.id)
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
isLoading = false
|
|
177
|
+
})
|
|
178
|
+
|
|
179
|
+
async function handleRestore(asDraft: boolean) {
|
|
180
|
+
isRestoring = true
|
|
181
|
+
const result = await restoreVersion(collectionKey, recordId, versionId, asDraft)
|
|
182
|
+
isRestoring = false
|
|
183
|
+
restoreModalOpen = false
|
|
184
|
+
if (result.ok) {
|
|
185
|
+
toast.success('Version restored')
|
|
186
|
+
await goto(resolve(`/${collectionKey}/${recordId}`))
|
|
187
|
+
} else {
|
|
188
|
+
toast.error(result.error)
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
</script>
|
|
192
|
+
|
|
193
|
+
{#if !collection}
|
|
194
|
+
<div class="flex h-full items-center justify-center">
|
|
195
|
+
<p class="text-muted-foreground">Collection not found</p>
|
|
196
|
+
</div>
|
|
197
|
+
{:else if isLoading}
|
|
198
|
+
<div class="flex h-full items-center justify-center">
|
|
199
|
+
<div class="flex flex-col items-center gap-3">
|
|
200
|
+
<div class="h-6 w-6 rounded-full border-2 border-primary/20 border-t-primary animate-spin-slow"></div>
|
|
201
|
+
<p class="text-sm text-muted-foreground">Loading...</p>
|
|
202
|
+
</div>
|
|
203
|
+
</div>
|
|
204
|
+
{:else if !currentVersion}
|
|
205
|
+
<div class="flex h-full items-center justify-center">
|
|
206
|
+
<p class="text-muted-foreground">Version not found</p>
|
|
207
|
+
</div>
|
|
208
|
+
{:else}
|
|
209
|
+
<div class="space-y-5">
|
|
210
|
+
<!-- Breadcrumb -->
|
|
211
|
+
<nav class="flex items-center gap-1.5 text-sm text-muted-foreground">
|
|
212
|
+
<a href={resolve(`/${collectionKey}`)} class="hover:text-foreground">{collection.labelPlural || collection.label}</a>
|
|
213
|
+
<span>/</span>
|
|
214
|
+
<a href={resolve(`/${collectionKey}/${recordId}`)} class="hover:text-foreground">{recordId}</a>
|
|
215
|
+
<span>/</span>
|
|
216
|
+
<a href={resolve(`/${collectionKey}/${recordId}/versions`)} class="hover:text-foreground">Versions</a>
|
|
217
|
+
<span>/</span>
|
|
218
|
+
<span class="text-foreground font-medium">{formatDate(currentVersion.createdAt)}</span>
|
|
219
|
+
</nav>
|
|
220
|
+
|
|
221
|
+
<!-- Header -->
|
|
222
|
+
<div class="flex items-center justify-between">
|
|
223
|
+
<h1 class="text-xl font-semibold text-foreground">Compare Versions</h1>
|
|
224
|
+
<label class="flex items-center gap-2 text-sm text-muted-foreground">
|
|
225
|
+
<input type="checkbox" bind:checked={modifiedOnly} class="rounded" />
|
|
226
|
+
Modified fields only
|
|
227
|
+
</label>
|
|
228
|
+
</div>
|
|
229
|
+
|
|
230
|
+
<!-- Comparison Control Bar -->
|
|
231
|
+
<div class="grid grid-cols-[1fr_auto_1fr] gap-0 rounded-lg border border-border/80 bg-card shadow-sm">
|
|
232
|
+
<!-- Left: comparing against -->
|
|
233
|
+
<div class="p-4 space-y-2">
|
|
234
|
+
<span class="text-[10px] font-semibold uppercase tracking-wide text-muted-foreground">Comparing Against</span>
|
|
235
|
+
{#if compareVersion}
|
|
236
|
+
<div class="flex flex-wrap items-center gap-2">
|
|
237
|
+
<CompareSelector
|
|
238
|
+
options={compareOptions}
|
|
239
|
+
selected={compareVersion.id}
|
|
240
|
+
onChange={handleCompareChange}
|
|
241
|
+
/>
|
|
242
|
+
<StatusPill
|
|
243
|
+
status={compareVersion._status ?? ''}
|
|
244
|
+
isLatest={compareVersion.id === latestPublishedId || compareVersion.id === latestDraftId}
|
|
245
|
+
/>
|
|
246
|
+
</div>
|
|
247
|
+
<p class="text-xs text-muted-foreground">{timeAgo(compareVersion.createdAt)}</p>
|
|
248
|
+
{:else}
|
|
249
|
+
<p class="text-xs text-muted-foreground">No other version to compare</p>
|
|
250
|
+
{/if}
|
|
251
|
+
</div>
|
|
252
|
+
|
|
253
|
+
<!-- Divider -->
|
|
254
|
+
<div class="w-px self-stretch bg-border/60"></div>
|
|
255
|
+
|
|
256
|
+
<!-- Right: currently viewing -->
|
|
257
|
+
<div class="p-4 space-y-2">
|
|
258
|
+
<span class="text-[10px] font-semibold uppercase tracking-wide text-muted-foreground">Currently Viewing</span>
|
|
259
|
+
<div class="flex flex-wrap items-center gap-2">
|
|
260
|
+
<span class="text-sm font-medium text-foreground">{formatDate(currentVersion.createdAt)}</span>
|
|
261
|
+
<StatusPill
|
|
262
|
+
status={currentVersion._status ?? ''}
|
|
263
|
+
isLatest={currentVersion.id === latestPublishedId || currentVersion.id === latestDraftId}
|
|
264
|
+
/>
|
|
265
|
+
</div>
|
|
266
|
+
<div class="flex items-center gap-3">
|
|
267
|
+
<p class="text-xs text-muted-foreground">{timeAgo(currentVersion.createdAt)}</p>
|
|
268
|
+
<button
|
|
269
|
+
type="button"
|
|
270
|
+
class="text-xs font-medium text-primary hover:underline"
|
|
271
|
+
onclick={() => (restoreModalOpen = true)}
|
|
272
|
+
>
|
|
273
|
+
Restore this version
|
|
274
|
+
</button>
|
|
275
|
+
</div>
|
|
276
|
+
</div>
|
|
277
|
+
</div>
|
|
278
|
+
|
|
279
|
+
<!-- Field Diffs -->
|
|
280
|
+
{#if visibleFields.length === 0}
|
|
281
|
+
<div class="flex flex-col items-center justify-center py-12">
|
|
282
|
+
<p class="text-sm text-muted-foreground">
|
|
283
|
+
{modifiedOnly ? 'No modified fields' : 'No fields to display'}
|
|
284
|
+
</p>
|
|
285
|
+
</div>
|
|
286
|
+
{:else}
|
|
287
|
+
<div class="overflow-hidden rounded-lg border border-border/80 bg-card shadow-sm">
|
|
288
|
+
{#each visibleFields as field (field.name)}
|
|
289
|
+
<FieldDiff
|
|
290
|
+
fieldName={field.name}
|
|
291
|
+
fieldLabel={field.label}
|
|
292
|
+
fieldType={field.type}
|
|
293
|
+
oldValue={compareData[field.name]}
|
|
294
|
+
newValue={currentData[field.name]}
|
|
295
|
+
/>
|
|
296
|
+
{/each}
|
|
297
|
+
</div>
|
|
298
|
+
{/if}
|
|
299
|
+
</div>
|
|
300
|
+
|
|
301
|
+
<RestoreModal
|
|
302
|
+
bind:open={restoreModalOpen}
|
|
303
|
+
{hasDrafts}
|
|
304
|
+
{isRestoring}
|
|
305
|
+
onRestore={handleRestore}
|
|
306
|
+
/>
|
|
307
|
+
{/if}
|