@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.
Files changed (110) hide show
  1. package/LICENSE +661 -0
  2. package/biome.json +62 -0
  3. package/dist/assets/index-C9Y5-AKj.js +33 -0
  4. package/dist/assets/index-uVdiUjty.css +1 -0
  5. package/dist/index.html +20 -0
  6. package/index.html +19 -0
  7. package/package.json +43 -0
  8. package/src/AdminRoot.svelte +98 -0
  9. package/src/app.css +211 -0
  10. package/src/lib/Slot.svelte +65 -0
  11. package/src/lib/api/auth.ts +26 -0
  12. package/src/lib/api/client.ts +73 -0
  13. package/src/lib/api/files.ts +56 -0
  14. package/src/lib/api/globals.ts +13 -0
  15. package/src/lib/api/records.ts +102 -0
  16. package/src/lib/api/schema.ts +7 -0
  17. package/src/lib/api/versions.ts +40 -0
  18. package/src/lib/components/AdminHeader.svelte +107 -0
  19. package/src/lib/components/AdminSidebar.svelte +262 -0
  20. package/src/lib/components/DeleteDialog.svelte +58 -0
  21. package/src/lib/components/DocumentEditLayout.svelte +263 -0
  22. package/src/lib/components/DynamicForm.svelte +74 -0
  23. package/src/lib/components/KpiCard.svelte +75 -0
  24. package/src/lib/components/MediaLibrary.svelte +311 -0
  25. package/src/lib/components/Pagination.svelte +78 -0
  26. package/src/lib/components/RangeFilter.svelte +41 -0
  27. package/src/lib/components/RecordGrid.svelte +123 -0
  28. package/src/lib/components/RecordTable.svelte +156 -0
  29. package/src/lib/components/cells/CheckboxCell.svelte +10 -0
  30. package/src/lib/components/cells/ColorCell.svelte +15 -0
  31. package/src/lib/components/cells/DateCell.svelte +8 -0
  32. package/src/lib/components/cells/RelationshipCell.svelte +20 -0
  33. package/src/lib/components/cells/RichTextCell.svelte +21 -0
  34. package/src/lib/components/cells/SelectCell.svelte +26 -0
  35. package/src/lib/components/cells/TextCell.svelte +8 -0
  36. package/src/lib/components/cells/UploadCell.svelte +34 -0
  37. package/src/lib/components/cells/index.ts +28 -0
  38. package/src/lib/components/charts/TimeSeriesChart.svelte +184 -0
  39. package/src/lib/components/doc/ApiView.svelte +181 -0
  40. package/src/lib/components/doc/Autosave.svelte +102 -0
  41. package/src/lib/components/doc/DocHeader.svelte +86 -0
  42. package/src/lib/components/doc/DocMetaStrip.svelte +103 -0
  43. package/src/lib/components/doc/DocTabBar.svelte +26 -0
  44. package/src/lib/components/doc/HeaderModeSwitch.svelte +32 -0
  45. package/src/lib/components/doc/PublishButton.svelte +114 -0
  46. package/src/lib/components/doc/ScheduleModal.svelte +110 -0
  47. package/src/lib/components/doc/VersionHistory.svelte +20 -0
  48. package/src/lib/components/fields/ArrayFieldEditor.svelte +62 -0
  49. package/src/lib/components/fields/BlockCard.svelte +63 -0
  50. package/src/lib/components/fields/BlocksFieldEditor.svelte +83 -0
  51. package/src/lib/components/fields/CheckboxField.svelte +27 -0
  52. package/src/lib/components/fields/ColorField.svelte +46 -0
  53. package/src/lib/components/fields/DateField.svelte +52 -0
  54. package/src/lib/components/fields/EmailField.svelte +30 -0
  55. package/src/lib/components/fields/FileField.svelte +280 -0
  56. package/src/lib/components/fields/JsonField.svelte +145 -0
  57. package/src/lib/components/fields/NumberField.svelte +44 -0
  58. package/src/lib/components/fields/PasswordField.svelte +38 -0
  59. package/src/lib/components/fields/RelationshipField.svelte +271 -0
  60. package/src/lib/components/fields/RichTextField.svelte +139 -0
  61. package/src/lib/components/fields/SelectField.svelte +33 -0
  62. package/src/lib/components/fields/SlugField.svelte +70 -0
  63. package/src/lib/components/fields/TabsField.svelte +56 -0
  64. package/src/lib/components/fields/TagsField.svelte +85 -0
  65. package/src/lib/components/fields/TextField.svelte +36 -0
  66. package/src/lib/components/fields/TextareaField.svelte +32 -0
  67. package/src/lib/components/fields/UploadField.svelte +166 -0
  68. package/src/lib/components/fields/UploadFieldDispatch.svelte +21 -0
  69. package/src/lib/components/fields/UploadGalleryField.svelte +166 -0
  70. package/src/lib/components/fields/index.ts +22 -0
  71. package/src/lib/components/fields/registry.ts +58 -0
  72. package/src/lib/components/lexical/CustomHTMLComponent.svelte +52 -0
  73. package/src/lib/components/lexical/CustomHTMLNode.ts +94 -0
  74. package/src/lib/components/lexical/PullQuoteComponent.svelte +73 -0
  75. package/src/lib/components/lexical/PullQuoteNode.ts +112 -0
  76. package/src/lib/components/lexical/lexical-helpers.ts +24 -0
  77. package/src/lib/components/lexical/nodes.ts +8 -0
  78. package/src/lib/components/lexical/toolbar/EditorToolbar.svelte +159 -0
  79. package/src/lib/components/lexical/toolbar/InsertBlockDropdown.svelte +278 -0
  80. package/src/lib/components/versions/CompareSelector.svelte +31 -0
  81. package/src/lib/components/versions/FieldDiff.svelte +141 -0
  82. package/src/lib/components/versions/RestoreModal.svelte +67 -0
  83. package/src/lib/components/versions/StatusPill.svelte +21 -0
  84. package/src/lib/context.svelte.ts +156 -0
  85. package/src/lib/router/index.svelte.ts +282 -0
  86. package/src/lib/router/matcher.ts +52 -0
  87. package/src/lib/stores/branding.svelte.ts +74 -0
  88. package/src/lib/stores/schema.svelte.ts +17 -0
  89. package/src/lib/types/schema.ts +126 -0
  90. package/src/lib/utils/cn.ts +6 -0
  91. package/src/lib/utils/diff.ts +112 -0
  92. package/src/lib/utils/dirty.svelte.ts +50 -0
  93. package/src/lib/utils/format.ts +28 -0
  94. package/src/lib/utils/json-highlight.ts +34 -0
  95. package/src/lib/utils/slug.ts +8 -0
  96. package/src/main.ts +32 -0
  97. package/src/views/AdminLayout.svelte +73 -0
  98. package/src/views/AdsAnalyticsView.svelte +152 -0
  99. package/src/views/CollectionEditView.svelte +117 -0
  100. package/src/views/CollectionListView.svelte +347 -0
  101. package/src/views/CollectionNewView.svelte +68 -0
  102. package/src/views/CustomPageView.svelte +59 -0
  103. package/src/views/DashboardView.svelte +370 -0
  104. package/src/views/GlobalEditView.svelte +100 -0
  105. package/src/views/LoginView.svelte +231 -0
  106. package/src/views/NotFoundView.svelte +9 -0
  107. package/src/views/VersionDetailView.svelte +307 -0
  108. package/src/views/VersionsListView.svelte +201 -0
  109. package/tsconfig.json +25 -0
  110. 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}