@ekrist1/vulse 0.1.6-alpha.3 → 0.1.7-alpha.4

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 (264) hide show
  1. package/dist/cli/migrate.d.ts.map +1 -1
  2. package/dist/cli/migrate.js +3 -4
  3. package/dist/cli/setup.d.ts.map +1 -1
  4. package/dist/cli/setup.js +11 -10
  5. package/dist/core/blueprints/compile.d.ts +1 -1
  6. package/dist/core/blueprints/compile.d.ts.map +1 -1
  7. package/dist/core/blueprints/compile.js +3 -3
  8. package/dist/core/blueprints/mutations.js +2 -2
  9. package/dist/core/forms/rate-limit.d.ts +1 -1
  10. package/dist/core/forms/rate-limit.d.ts.map +1 -1
  11. package/dist/core/forms/rate-limit.js +3 -3
  12. package/dist/core/forms/unique.d.ts +1 -1
  13. package/dist/core/forms/unique.d.ts.map +1 -1
  14. package/dist/core/forms/unique.js +4 -4
  15. package/dist/core/globals/compile.d.ts +1 -1
  16. package/dist/core/globals/compile.d.ts.map +1 -1
  17. package/dist/core/globals/compile.js +2 -2
  18. package/dist/core/globals/definition.d.ts +1 -1
  19. package/dist/core/globals/definition.d.ts.map +1 -1
  20. package/dist/core/globals/definition.js +3 -3
  21. package/dist/core/repos/globals.js +3 -3
  22. package/dist/core/sha256.d.ts +3 -0
  23. package/dist/core/sha256.d.ts.map +1 -0
  24. package/dist/core/sha256.js +9 -0
  25. package/dist/integration/index.js +1 -1
  26. package/dist/integration/install-hook.d.ts.map +1 -1
  27. package/dist/integration/install-hook.js +6 -4
  28. package/dist/integration/wrangler-config.d.ts +12 -0
  29. package/dist/integration/wrangler-config.d.ts.map +1 -0
  30. package/dist/integration/wrangler-config.js +97 -0
  31. package/dist/integration/wrangler-patch.d.ts +1 -0
  32. package/dist/integration/wrangler-patch.d.ts.map +1 -1
  33. package/dist/integration/wrangler-patch.js +2 -1
  34. package/dist/server/routes/form-submit.js +1 -1
  35. package/dist/version.d.ts +1 -1
  36. package/dist/version.js +1 -1
  37. package/package.json +11 -3
  38. package/src/admin/assets/logo-mark.svg +5 -0
  39. package/src/admin/client/active-locale.ts +17 -0
  40. package/src/admin/client/api.ts +21 -0
  41. package/src/admin/client/form-from-zod.ts +7 -0
  42. package/src/admin/client/live-preview-enabled.ts +5 -0
  43. package/src/admin/components/AdminShell.astro +45 -0
  44. package/src/admin/components/AuthSettings.vue +60 -0
  45. package/src/admin/components/BlockEditor.vue +53 -0
  46. package/src/admin/components/BlueprintEditor.vue +1783 -0
  47. package/src/admin/components/CollectionKindIcon.vue +26 -0
  48. package/src/admin/components/CollectionTree.vue +220 -0
  49. package/src/admin/components/EntryEditorWithPreview.vue +130 -0
  50. package/src/admin/components/EntryForm.vue +411 -0
  51. package/src/admin/components/EntryList.vue +121 -0
  52. package/src/admin/components/EntryStatusBadge.vue +24 -0
  53. package/src/admin/components/FormEditor.vue +233 -0
  54. package/src/admin/components/FormList.vue +54 -0
  55. package/src/admin/components/GlobalSetEditor.vue +272 -0
  56. package/src/admin/components/GlobalSetList.vue +55 -0
  57. package/src/admin/components/LivePreviewPanel.vue +171 -0
  58. package/src/admin/components/LoginForm.vue +53 -0
  59. package/src/admin/components/MediaLibrary.vue +106 -0
  60. package/src/admin/components/MediaPicker.vue +49 -0
  61. package/src/admin/components/RevisionDiff.vue +11 -0
  62. package/src/admin/components/RevisionList.vue +134 -0
  63. package/src/admin/components/SeoFields.vue +113 -0
  64. package/src/admin/components/SetEditor.vue +137 -0
  65. package/src/admin/components/SetList.vue +32 -0
  66. package/src/admin/components/SettingsForm.vue +189 -0
  67. package/src/admin/components/SideNav.vue +152 -0
  68. package/src/admin/components/SubmissionDetail.vue +45 -0
  69. package/src/admin/components/SubmissionList.vue +89 -0
  70. package/src/admin/components/ToastHost.vue +33 -0
  71. package/src/admin/components/TreeRow.vue +163 -0
  72. package/src/admin/components/UserEditor.vue +186 -0
  73. package/src/admin/components/UserList.vue +46 -0
  74. package/src/admin/components/blocks/BlockItem.vue +32 -0
  75. package/src/admin/components/blocks/BlockToolbar.vue +12 -0
  76. package/src/admin/components/blocks/edit/CodeEdit.vue +18 -0
  77. package/src/admin/components/blocks/edit/EmbedEdit.vue +14 -0
  78. package/src/admin/components/blocks/edit/HeadingEdit.vue +19 -0
  79. package/src/admin/components/blocks/edit/ImageEdit.vue +40 -0
  80. package/src/admin/components/blocks/edit/ListEdit.vue +36 -0
  81. package/src/admin/components/blocks/edit/ParagraphEdit.vue +14 -0
  82. package/src/admin/components/blocks/edit/QuoteEdit.vue +18 -0
  83. package/src/admin/components/fields/BlocksField.vue +123 -0
  84. package/src/admin/components/fields/BlocksSetsPicker.vue +59 -0
  85. package/src/admin/components/fields/BoolField.vue +10 -0
  86. package/src/admin/components/fields/DateField.vue +22 -0
  87. package/src/admin/components/fields/EntriesField.vue +153 -0
  88. package/src/admin/components/fields/EntryField.vue +138 -0
  89. package/src/admin/components/fields/EnumField.vue +81 -0
  90. package/src/admin/components/fields/FieldRenderer.vue +87 -0
  91. package/src/admin/components/fields/GridField.vue +173 -0
  92. package/src/admin/components/fields/LinkField.vue +219 -0
  93. package/src/admin/components/fields/MediaField.vue +69 -0
  94. package/src/admin/components/fields/NumberField.vue +12 -0
  95. package/src/admin/components/fields/ObjectField.vue +18 -0
  96. package/src/admin/components/fields/RefField.vue +170 -0
  97. package/src/admin/components/fields/RepeaterField.vue +27 -0
  98. package/src/admin/components/fields/ReplicatorField.vue +121 -0
  99. package/src/admin/components/fields/TextField.vue +11 -0
  100. package/src/admin/components/fields/TextareaField.vue +11 -0
  101. package/src/admin/components/fields/VulseAccordionGroupNodeView.vue +82 -0
  102. package/src/admin/components/fields/VulseAccordionNodeView.vue +128 -0
  103. package/src/admin/components/fields/VulseCalloutNodeView.vue +81 -0
  104. package/src/admin/components/fields/VulseIframeNodeView.vue +112 -0
  105. package/src/admin/components/fields/VulseSetNodeView.vue +68 -0
  106. package/src/admin/components/fields/VulseVideoNodeView.vue +104 -0
  107. package/src/admin/components/fields/blocks-editor-extensions.ts +26 -0
  108. package/src/admin/components/fields/emoji-extension.ts +54 -0
  109. package/src/admin/components/fields/link-extension.ts +48 -0
  110. package/src/admin/components/fields/set-node-utils.ts +115 -0
  111. package/src/admin/components/fields/url-utils.ts +85 -0
  112. package/src/admin/components/fields/vulse-accordion-extension.ts +64 -0
  113. package/src/admin/components/fields/vulse-accordion-group-extension.ts +49 -0
  114. package/src/admin/components/fields/vulse-callout-extension.ts +53 -0
  115. package/src/admin/components/fields/vulse-iframe-extension.ts +96 -0
  116. package/src/admin/components/fields/vulse-set-extension.ts +66 -0
  117. package/src/admin/components/fields/vulse-video-extension.ts +65 -0
  118. package/src/admin/composables/toast.ts +35 -0
  119. package/src/admin/composables/useEntrySearch.ts +112 -0
  120. package/src/admin/composables/useSets.ts +31 -0
  121. package/src/admin/pages/collections/[name]/[id]/revisions.astro +27 -0
  122. package/src/admin/pages/collections/[name]/[id].astro +90 -0
  123. package/src/admin/pages/collections/[name]/index.astro +31 -0
  124. package/src/admin/pages/collections/[name]/new.astro +38 -0
  125. package/src/admin/pages/forms/[handle]/submissions/[id].astro +9 -0
  126. package/src/admin/pages/forms/[handle]/submissions/index.astro +9 -0
  127. package/src/admin/pages/forms/[handle].astro +9 -0
  128. package/src/admin/pages/forms/index.astro +7 -0
  129. package/src/admin/pages/forms/new.astro +7 -0
  130. package/src/admin/pages/index.astro +36 -0
  131. package/src/admin/pages/login.astro +14 -0
  132. package/src/admin/pages/media.astro +8 -0
  133. package/src/admin/pages/schema/[handle].astro +10 -0
  134. package/src/admin/pages/schema/new.astro +9 -0
  135. package/src/admin/pages/settings/auth.astro +9 -0
  136. package/src/admin/pages/settings/globals/[handle].astro +9 -0
  137. package/src/admin/pages/settings/globals/index.astro +7 -0
  138. package/src/admin/pages/settings/globals/new.astro +7 -0
  139. package/src/admin/pages/settings/index.astro +8 -0
  140. package/src/admin/pages/settings/sets/[handle].astro +9 -0
  141. package/src/admin/pages/settings/sets/index.astro +7 -0
  142. package/src/admin/pages/settings/sets/new.astro +7 -0
  143. package/src/admin/pages/users/[id].astro +10 -0
  144. package/src/admin/pages/users/index.astro +8 -0
  145. package/src/admin/styles/admin.css +166 -0
  146. package/src/core/access.ts +9 -0
  147. package/src/core/blocks/schema.ts +66 -0
  148. package/src/core/blueprints/code-to-definition.ts +156 -0
  149. package/src/core/blueprints/compile.ts +176 -0
  150. package/src/core/blueprints/define.ts +12 -0
  151. package/src/core/blueprints/definition.ts +185 -0
  152. package/src/core/blueprints/load.ts +144 -0
  153. package/src/core/blueprints/mutations.ts +236 -0
  154. package/src/core/blueprints/preview-path.ts +33 -0
  155. package/src/core/blueprints/reflect-fields.ts +305 -0
  156. package/src/core/blueprints/registry.ts +14 -0
  157. package/src/core/blueprints/seed.ts +20 -0
  158. package/src/core/blueprints/select-helpers.ts +30 -0
  159. package/src/core/blueprints/seo.ts +180 -0
  160. package/src/core/blueprints/types.ts +59 -0
  161. package/src/core/blueprints/zod-helpers.ts +86 -0
  162. package/src/core/db.ts +11 -0
  163. package/src/core/errors.ts +34 -0
  164. package/src/core/forms/compile.ts +84 -0
  165. package/src/core/forms/definition.ts +102 -0
  166. package/src/core/forms/rate-limit.ts +52 -0
  167. package/src/core/forms/unique.ts +38 -0
  168. package/src/core/globals/compile.ts +35 -0
  169. package/src/core/globals/definition.ts +27 -0
  170. package/src/core/locales.ts +45 -0
  171. package/src/core/migrations.ts +48 -0
  172. package/src/core/parse-content.ts +85 -0
  173. package/src/core/plugins/definition.ts +150 -0
  174. package/src/core/preview-content.ts +21 -0
  175. package/src/core/repos/entries.ts +504 -0
  176. package/src/core/repos/forms.ts +270 -0
  177. package/src/core/repos/globals.ts +179 -0
  178. package/src/core/repos/media.ts +106 -0
  179. package/src/core/repos/preview-sessions.ts +108 -0
  180. package/src/core/repos/revisions.ts +60 -0
  181. package/src/core/repos/settings.ts +23 -0
  182. package/src/core/schema.ts +244 -0
  183. package/src/core/sets/compile.ts +12 -0
  184. package/src/core/sets/definition.ts +10 -0
  185. package/src/core/sets/service.ts +82 -0
  186. package/src/core/sets/validate-tree.ts +57 -0
  187. package/src/core/sha256.ts +10 -0
  188. package/src/core/slug.ts +30 -0
  189. package/src/scaffold/collection-write.ts +83 -0
  190. package/src/scaffold/collection.ts +277 -0
  191. package/src/server/assets/live-preview-bridge.content.ts +2 -0
  192. package/src/server/assets/live-preview-bridge.js +535 -0
  193. package/src/server/better-auth.ts +82 -0
  194. package/src/server/cf-images.ts +34 -0
  195. package/src/server/cron.ts +37 -0
  196. package/src/server/email.ts +17 -0
  197. package/src/server/endpoints/api-auth.ts +10 -0
  198. package/src/server/endpoints/api-vulse-blueprints.ts +23 -0
  199. package/src/server/endpoints/api-vulse-entries-locales.ts +12 -0
  200. package/src/server/endpoints/api-vulse-entries-move.ts +7 -0
  201. package/src/server/endpoints/api-vulse-entries-publish.ts +7 -0
  202. package/src/server/endpoints/api-vulse-entries-tree.ts +7 -0
  203. package/src/server/endpoints/api-vulse-entries.ts +23 -0
  204. package/src/server/endpoints/api-vulse-form-handle.ts +30 -0
  205. package/src/server/endpoints/api-vulse-form-public.ts +7 -0
  206. package/src/server/endpoints/api-vulse-form-submit.ts +7 -0
  207. package/src/server/endpoints/api-vulse-form-upload.ts +7 -0
  208. package/src/server/endpoints/api-vulse-forms.ts +12 -0
  209. package/src/server/endpoints/api-vulse-globals-handle.ts +20 -0
  210. package/src/server/endpoints/api-vulse-globals-public-handle.ts +7 -0
  211. package/src/server/endpoints/api-vulse-globals-public.ts +7 -0
  212. package/src/server/endpoints/api-vulse-globals-value.ts +8 -0
  213. package/src/server/endpoints/api-vulse-globals.ts +12 -0
  214. package/src/server/endpoints/api-vulse-media-file.ts +7 -0
  215. package/src/server/endpoints/api-vulse-media-id.ts +12 -0
  216. package/src/server/endpoints/api-vulse-media.ts +12 -0
  217. package/src/server/endpoints/api-vulse-preview-bridge.ts +11 -0
  218. package/src/server/endpoints/api-vulse-preview-sessions-id.ts +10 -0
  219. package/src/server/endpoints/api-vulse-preview-sessions.ts +7 -0
  220. package/src/server/endpoints/api-vulse-preview-start.ts +7 -0
  221. package/src/server/endpoints/api-vulse-preview-stop.ts +7 -0
  222. package/src/server/endpoints/api-vulse-revisions-restore.ts +7 -0
  223. package/src/server/endpoints/api-vulse-revisions.ts +7 -0
  224. package/src/server/endpoints/api-vulse-search.ts +7 -0
  225. package/src/server/endpoints/api-vulse-sets.ts +23 -0
  226. package/src/server/endpoints/api-vulse-settings.ts +12 -0
  227. package/src/server/endpoints/api-vulse-users-id.ts +12 -0
  228. package/src/server/endpoints/api-vulse-users-reset-password.ts +7 -0
  229. package/src/server/endpoints/api-vulse-users-role.ts +9 -0
  230. package/src/server/endpoints/api-vulse-users.ts +7 -0
  231. package/src/server/endpoints/with-runtime.ts +11 -0
  232. package/src/server/env.ts +23 -0
  233. package/src/server/envelope.ts +21 -0
  234. package/src/server/forms/email.ts +11 -0
  235. package/src/server/forms/process-submission.ts +95 -0
  236. package/src/server/forms/queue.ts +25 -0
  237. package/src/server/forms/templates.ts +24 -0
  238. package/src/server/forms/webhook.ts +19 -0
  239. package/src/server/handler.ts +66 -0
  240. package/src/server/image-probe.ts +35 -0
  241. package/src/server/loader.ts +54 -0
  242. package/src/server/plugins.ts +214 -0
  243. package/src/server/preview.ts +25 -0
  244. package/src/server/r2.ts +13 -0
  245. package/src/server/routes/blueprints.ts +62 -0
  246. package/src/server/routes/entries.ts +255 -0
  247. package/src/server/routes/form-submit.ts +168 -0
  248. package/src/server/routes/form-upload.ts +100 -0
  249. package/src/server/routes/forms.ts +88 -0
  250. package/src/server/routes/globals-public.ts +30 -0
  251. package/src/server/routes/globals.ts +93 -0
  252. package/src/server/routes/media.ts +145 -0
  253. package/src/server/routes/preview-sessions.ts +76 -0
  254. package/src/server/routes/preview.ts +36 -0
  255. package/src/server/routes/revisions.ts +29 -0
  256. package/src/server/routes/search.ts +31 -0
  257. package/src/server/routes/sets.ts +40 -0
  258. package/src/server/routes/settings.ts +24 -0
  259. package/src/server/routes/users.ts +127 -0
  260. package/src/server/runtime.ts +99 -0
  261. package/src/server/sdk/collections.ts +98 -0
  262. package/src/server/sdk/index.ts +25 -0
  263. package/src/server/sdk/media.ts +11 -0
  264. package/src/server/sdk/search.ts +90 -0
@@ -0,0 +1,171 @@
1
+ <script setup lang="ts">
2
+ import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'
3
+ import { adminApi, AdminApiError } from '../client/api.js'
4
+
5
+ const props = defineProps<{
6
+ collection: string
7
+ entryId?: string
8
+ previewPath: string
9
+ slug: string
10
+ content: Record<string, unknown>
11
+ entryLocale?: string
12
+ }>()
13
+
14
+ const iframeRef = ref<HTMLIFrameElement | null>(null)
15
+ const iframeSrc = ref('')
16
+ const sessionId = ref<string | null>(null)
17
+ const lastSlug = ref(props.slug)
18
+ const error = ref<string | null>(null)
19
+ const starting = ref(false)
20
+
21
+ let updateTimer: ReturnType<typeof setTimeout> | null = null
22
+
23
+ function buildPreviewUrl(slug: string): string {
24
+ const path = props.previewPath.replace('{slug}', encodeURIComponent(slug))
25
+ const url = new URL(path, window.location.origin)
26
+ if (sessionId.value) url.searchParams.set('vulse_live_preview', sessionId.value)
27
+ return url.toString()
28
+ }
29
+
30
+ function postPreviewUpdated() {
31
+ if (!sessionId.value) return
32
+ iframeRef.value?.contentWindow?.postMessage(
33
+ { name: 'vulse.preview.updated', token: sessionId.value },
34
+ window.location.origin,
35
+ )
36
+ }
37
+
38
+ async function startSession() {
39
+ starting.value = true
40
+ error.value = null
41
+ try {
42
+ const created = await adminApi.post<{ id: string; previewUrl: string }>(
43
+ '/api/vulse/preview/sessions',
44
+ {
45
+ collection: props.collection,
46
+ entryId: props.entryId ?? null,
47
+ slug: props.slug,
48
+ content: props.content,
49
+ ...(props.entryLocale ? { locale: props.entryLocale } : {}),
50
+ },
51
+ )
52
+ sessionId.value = created.id
53
+ iframeSrc.value = created.previewUrl
54
+ lastSlug.value = props.slug
55
+ await updateSession()
56
+ } catch (e) {
57
+ if (e instanceof AdminApiError) error.value = e.message
58
+ else error.value = 'Failed to start live preview'
59
+ } finally {
60
+ starting.value = false
61
+ }
62
+ }
63
+
64
+ async function updateSession() {
65
+ if (!sessionId.value) return
66
+ try {
67
+ await adminApi.put(`/api/vulse/preview/sessions/${sessionId.value}`, {
68
+ content: props.content,
69
+ slug: props.slug,
70
+ ...(props.entryLocale ? { locale: props.entryLocale } : {}),
71
+ })
72
+ if (props.slug !== lastSlug.value) {
73
+ iframeSrc.value = buildPreviewUrl(props.slug)
74
+ lastSlug.value = props.slug
75
+ }
76
+ postPreviewUpdated()
77
+ error.value = null
78
+ } catch (e) {
79
+ if (e instanceof AdminApiError && (e.status === 403 || e.status === 404)) {
80
+ sessionId.value = null
81
+ iframeSrc.value = ''
82
+ await startSession()
83
+ return
84
+ }
85
+ if (e instanceof AdminApiError) error.value = e.message
86
+ else error.value = 'Failed to update live preview'
87
+ }
88
+ }
89
+
90
+ function scheduleUpdate() {
91
+ if (!sessionId.value) return
92
+ if (updateTimer) clearTimeout(updateTimer)
93
+ updateTimer = setTimeout(() => {
94
+ void updateSession()
95
+ }, 100)
96
+ }
97
+
98
+ const showIframe = computed(() => !!iframeSrc.value && !starting.value && !error.value)
99
+
100
+ watch(
101
+ () => ({ slug: props.slug, content: props.content }),
102
+ () => scheduleUpdate(),
103
+ { deep: true },
104
+ )
105
+
106
+ onMounted(async () => {
107
+ await startSession()
108
+ })
109
+
110
+ onBeforeUnmount(() => {
111
+ if (updateTimer) clearTimeout(updateTimer)
112
+ const id = sessionId.value
113
+ if (!id) return
114
+ void adminApi.delete(`/api/vulse/preview/sessions/${id}`).catch(() => {})
115
+ })
116
+ </script>
117
+
118
+ <template>
119
+ <aside class="flex min-h-[520px] flex-col rounded border border-zinc-200 bg-white">
120
+ <header class="flex items-center justify-between gap-3 border-b border-zinc-200 px-4 py-3">
121
+ <h2 class="text-sm font-semibold text-zinc-900">Live preview</h2>
122
+ <div class="flex items-center gap-3">
123
+ <button
124
+ v-if="error"
125
+ type="button"
126
+ class="text-sm text-zinc-600 underline hover:text-zinc-900"
127
+ @click="startSession"
128
+ >
129
+ Retry
130
+ </button>
131
+ <a
132
+ v-if="iframeSrc"
133
+ :href="iframeSrc"
134
+ target="_blank"
135
+ rel="noreferrer"
136
+ class="text-sm text-zinc-600 underline hover:text-zinc-900"
137
+ >
138
+ Open in tab
139
+ </a>
140
+ </div>
141
+ </header>
142
+
143
+ <div class="flex flex-1 flex-col">
144
+ <div
145
+ v-if="error"
146
+ class="flex min-h-[480px] flex-1 flex-col items-center justify-center gap-3 bg-red-50 px-6 text-sm text-red-700"
147
+ >
148
+ <p>{{ error }}</p>
149
+ <button
150
+ type="button"
151
+ class="rounded border border-red-300 bg-white px-3 py-1.5 text-sm hover:bg-red-50"
152
+ @click="startSession"
153
+ >
154
+ Restart preview
155
+ </button>
156
+ </div>
157
+ <div
158
+ v-else-if="starting || !iframeSrc"
159
+ class="flex min-h-[480px] flex-1 items-center justify-center bg-zinc-50 px-6 text-sm text-zinc-500"
160
+ >
161
+ Starting preview session…
162
+ </div>
163
+ <iframe
164
+ v-else-if="showIframe"
165
+ ref="iframeRef"
166
+ :src="iframeSrc"
167
+ class="min-h-[480px] w-full flex-1 border-0 bg-white"
168
+ />
169
+ </div>
170
+ </aside>
171
+ </template>
@@ -0,0 +1,53 @@
1
+ <script setup lang="ts">
2
+ import { ref } from 'vue'
3
+ const props = defineProps<{ next: string }>()
4
+ const email = ref('')
5
+ const password = ref('')
6
+ const error = ref<string | null>(null)
7
+ const loading = ref(false)
8
+
9
+ function safeNext(raw: string): string {
10
+ // Only allow same-origin path redirects; reject protocol-relative ("//evil")
11
+ // and absolute URLs.
12
+ if (typeof raw !== 'string' || !raw.startsWith('/') || raw.startsWith('//')) return '/admin'
13
+ return raw
14
+ }
15
+
16
+ async function submit(e: Event) {
17
+ e.preventDefault()
18
+ loading.value = true
19
+ error.value = null
20
+ const res = await fetch('/api/auth/sign-in/email', {
21
+ method: 'POST',
22
+ headers: {
23
+ 'content-type': 'application/json',
24
+ Origin: window.location.origin,
25
+ },
26
+ body: JSON.stringify({ email: email.value, password: password.value }),
27
+ })
28
+ loading.value = false
29
+ if (!res.ok) { error.value = 'Invalid email or password'; return }
30
+ window.location.href = safeNext(props.next)
31
+ }
32
+ </script>
33
+
34
+ <template>
35
+ <form @submit="submit" class="w-80 p-6 rounded-xl shadow-sm border bg-white space-y-4">
36
+ <h1 class="text-2xl font-semibold">Sign in</h1>
37
+ <label class="block">
38
+ <span class="text-sm text-zinc-600">Email</span>
39
+ <input v-model="email" type="email" required class="mt-1 w-full rounded border px-3 py-2" />
40
+ </label>
41
+ <label class="block">
42
+ <span class="text-sm text-zinc-600">Password</span>
43
+ <input v-model="password" type="password" required class="mt-1 w-full rounded border px-3 py-2" />
44
+ </label>
45
+ <p v-if="error" class="text-sm text-red-600">{{ error }}</p>
46
+ <button :disabled="loading" class="w-full rounded bg-brand py-2 text-white font-medium disabled:opacity-50">
47
+ {{ loading ? 'Signing in…' : 'Sign in' }}
48
+ </button>
49
+ <p class="text-center text-sm text-zinc-500">
50
+ <a href="/forgot-password?next=/admin" class="text-brand hover:underline">Forgot password?</a>
51
+ </p>
52
+ </form>
53
+ </template>
@@ -0,0 +1,106 @@
1
+ <script setup lang="ts">
2
+ import { ref, onMounted } from 'vue'
3
+ import { adminApi } from '../client/api.js'
4
+
5
+ interface MediaItem {
6
+ id: string
7
+ r2Key: string
8
+ mime: string
9
+ alt: string | null
10
+ width: number | null
11
+ height: number | null
12
+ deliveryUrl: string | null
13
+ previewUrl: string
14
+ }
15
+
16
+ const items = ref<MediaItem[]>([])
17
+ const uploading = ref(false)
18
+ const error = ref<string | null>(null)
19
+
20
+ function previewSrc(item: MediaItem): string {
21
+ return item.deliveryUrl ?? item.previewUrl
22
+ }
23
+
24
+ async function load() {
25
+ error.value = null
26
+ try {
27
+ items.value = await adminApi.get<MediaItem[]>('/api/vulse/media')
28
+ } catch (e) {
29
+ error.value = e instanceof Error ? e.message : 'Failed to load media'
30
+ }
31
+ }
32
+
33
+ async function onFiles(files: FileList | null) {
34
+ if (!files?.length) return
35
+ uploading.value = true
36
+ error.value = null
37
+ try {
38
+ for (const f of Array.from(files)) {
39
+ const form = new FormData()
40
+ form.append('file', f)
41
+ const res = await fetch('/api/vulse/media', { method: 'POST', body: form, credentials: 'same-origin' })
42
+ const body = await res.json() as { ok: boolean; error?: { message: string } }
43
+ if (!body.ok) throw new Error(body.error?.message ?? 'Upload failed')
44
+ }
45
+ await load()
46
+ } catch (e) {
47
+ error.value = e instanceof Error ? e.message : 'Upload failed'
48
+ } finally {
49
+ uploading.value = false
50
+ }
51
+ }
52
+
53
+ async function softDelete(id: string) {
54
+ if (!confirm('Delete this asset?')) return
55
+ await adminApi.delete(`/api/vulse/media/${id}`)
56
+ await load()
57
+ }
58
+
59
+ async function setAlt(id: string, alt: string) {
60
+ await adminApi.patch(`/api/vulse/media/${id}`, { alt })
61
+ }
62
+
63
+ onMounted(load)
64
+ </script>
65
+
66
+ <template>
67
+ <div>
68
+ <div class="mb-4 flex items-center gap-3">
69
+ <label class="vulse-button-primary cursor-pointer rounded px-4 py-2 text-sm font-medium">
70
+ Upload
71
+ <input
72
+ type="file"
73
+ multiple
74
+ accept="image/*"
75
+ class="hidden"
76
+ @change="onFiles(($event.target as HTMLInputElement).files)"
77
+ />
78
+ </label>
79
+ <span v-if="uploading" class="text-sm text-zinc-500">Uploading…</span>
80
+ </div>
81
+
82
+ <p v-if="error" class="mb-4 rounded bg-red-50 px-3 py-2 text-sm text-red-700">{{ error }}</p>
83
+
84
+ <div
85
+ v-if="items.length === 0 && !uploading"
86
+ class="rounded border border-dashed border-zinc-300 bg-white p-8 text-center text-sm text-zinc-500"
87
+ >
88
+ No assets yet. Upload images to get started.
89
+ </div>
90
+
91
+ <div v-else class="grid grid-cols-2 gap-3 sm:grid-cols-3 lg:grid-cols-4">
92
+ <div v-for="m in items" :key="m.id" class="space-y-2 rounded border border-zinc-200 bg-white p-2">
93
+ <img :src="previewSrc(m)" :alt="m.alt ?? ''" class="aspect-square w-full rounded object-cover" />
94
+ <input
95
+ :value="m.alt ?? ''"
96
+ placeholder="Alt text"
97
+ class="vulse-input text-xs"
98
+ @change="setAlt(m.id, ($event.target as HTMLInputElement).value)"
99
+ />
100
+ <button type="button" class="text-xs text-red-600 hover:underline" @click="softDelete(m.id)">
101
+ Delete
102
+ </button>
103
+ </div>
104
+ </div>
105
+ </div>
106
+ </template>
@@ -0,0 +1,49 @@
1
+ <script setup lang="ts">
2
+ import { ref, onMounted } from 'vue'
3
+ import { adminApi } from '../client/api.js'
4
+
5
+ const emit = defineEmits<{ (e: 'pick', id: string): void; (e: 'close'): void }>()
6
+
7
+ interface MediaItem {
8
+ id: string
9
+ alt: string | null
10
+ deliveryUrl: string | null
11
+ previewUrl: string
12
+ }
13
+
14
+ const items = ref<MediaItem[]>([])
15
+
16
+ function previewSrc(item: MediaItem): string {
17
+ return item.deliveryUrl ?? item.previewUrl
18
+ }
19
+
20
+ onMounted(async () => {
21
+ items.value = await adminApi.get<MediaItem[]>('/api/vulse/media')
22
+ })
23
+ </script>
24
+
25
+ <template>
26
+ <div class="fixed inset-0 z-50 grid place-items-center bg-black/40" @click.self="$emit('close')">
27
+ <div class="max-h-[80vh] w-[720px] overflow-auto rounded-xl bg-white p-4">
28
+ <div class="mb-3 flex items-center justify-between">
29
+ <h2 class="font-semibold">Pick a media item</h2>
30
+ <button type="button" class="text-zinc-500 hover:text-zinc-800" @click="$emit('close')">×</button>
31
+ </div>
32
+ <div v-if="items.length === 0" class="py-8 text-center text-sm text-zinc-500">
33
+ No media yet. Upload assets from the Media page first.
34
+ </div>
35
+ <div v-else class="grid grid-cols-4 gap-3">
36
+ <button
37
+ v-for="m in items"
38
+ :key="m.id"
39
+ type="button"
40
+ class="rounded border p-2 hover:ring-2 hover:ring-[var(--vulse-color-accent)]"
41
+ @click="$emit('pick', m.id)"
42
+ >
43
+ <img :src="previewSrc(m)" :alt="m.alt ?? ''" class="aspect-square w-full rounded object-cover" />
44
+ <div v-if="m.alt" class="mt-1 truncate text-xs">{{ m.alt }}</div>
45
+ </button>
46
+ </div>
47
+ </div>
48
+ </div>
49
+ </template>
@@ -0,0 +1,11 @@
1
+ <script setup lang="ts">
2
+ import { computed } from 'vue'
3
+ import { diffJson } from 'diff'
4
+
5
+ const props = defineProps<{ from: unknown; to: unknown }>()
6
+ const parts = computed(() => diffJson(props.from as object, props.to as object))
7
+ </script>
8
+ <template>
9
+ <pre class="bg-zinc-50 border rounded p-3 text-xs overflow-auto whitespace-pre-wrap"><span v-for="(p, i) in parts" :key="i"
10
+ :class="p.added ? 'bg-green-100 text-green-900' : p.removed ? 'bg-red-100 text-red-900' : ''">{{ p.value }}</span></pre>
11
+ </template>
@@ -0,0 +1,134 @@
1
+ <script setup lang="ts">
2
+ import { ref, onMounted, computed } from 'vue'
3
+ import { adminApi, AdminApiError } from '../client/api'
4
+ import { resolveActiveLocale } from '../client/active-locale.js'
5
+ import { useToast } from '../composables/toast.js'
6
+ import RevisionDiff from './RevisionDiff.vue'
7
+
8
+ interface RevisionRow {
9
+ id: string
10
+ version: number
11
+ authorId: string | null
12
+ createdAt: string
13
+ changeSummary: string | null
14
+ content: unknown
15
+ }
16
+
17
+ const props = defineProps<{
18
+ collection: string
19
+ entryId: string
20
+ /** Active locale from the server. Avoid prop name `locale` (Astro/HTML coercion). */
21
+ entryLocale?: string
22
+ supportedLocales?: string[]
23
+ defaultLocale?: string
24
+ }>()
25
+
26
+ const toast = useToast()
27
+ const revisions = ref<RevisionRow[]>([])
28
+ const selected = ref<number | null>(null)
29
+
30
+ const activeLocale = computed(() =>
31
+ resolveActiveLocale(props.supportedLocales, props.entryLocale, props.defaultLocale),
32
+ )
33
+
34
+ function versionOf(r: RevisionRow): number {
35
+ return Number(r.version)
36
+ }
37
+
38
+ const localeQuery = () => `?locale=${encodeURIComponent(activeLocale.value)}`
39
+
40
+ const latestVersion = computed(() =>
41
+ revisions.value.reduce((max, r) => Math.max(max, versionOf(r)), 0),
42
+ )
43
+
44
+ const selectedRevision = computed(() =>
45
+ selected.value === null
46
+ ? null
47
+ : revisions.value.find((r) => versionOf(r) === selected.value) ?? null,
48
+ )
49
+
50
+ const selectedContent = computed(() => selectedRevision.value?.content ?? null)
51
+
52
+ const isCurrentVersion = computed(() =>
53
+ selected.value !== null && selected.value === latestVersion.value,
54
+ )
55
+
56
+ const canRestore = computed(() =>
57
+ selected.value !== null && revisions.value.length > 1 && !isCurrentVersion.value,
58
+ )
59
+
60
+ const newerContent = computed(() => {
61
+ if (selected.value === null) return null
62
+ const newer = revisions.value
63
+ .filter((r) => versionOf(r) > selected.value!)
64
+ .sort((a, b) => versionOf(a) - versionOf(b))[0]
65
+ return newer?.content ?? null
66
+ })
67
+
68
+ async function load() {
69
+ revisions.value = await adminApi.get<RevisionRow[]>(
70
+ `/api/vulse/entries/${props.collection}/${props.entryId}/revisions${localeQuery()}`,
71
+ )
72
+ if (revisions.value.length && selected.value === null) {
73
+ selected.value = versionOf(revisions.value[0]!)
74
+ }
75
+ }
76
+
77
+ function inspect(v: number) {
78
+ selected.value = Number(v)
79
+ }
80
+
81
+ async function restore(v: number) {
82
+ if (!confirm(`Restore version ${v}? A new revision will be written on top — no history is lost.`)) return
83
+ try {
84
+ await adminApi.post(
85
+ `/api/vulse/entries/${props.collection}/${props.entryId}/revisions/${v}/restore${localeQuery()}`,
86
+ {},
87
+ )
88
+ window.location.href = `/admin/collections/${props.collection}/${props.entryId}?locale=${encodeURIComponent(activeLocale.value)}`
89
+ } catch (err) {
90
+ toast.error(err instanceof AdminApiError ? err.message : 'Failed to restore revision')
91
+ }
92
+ }
93
+
94
+ onMounted(load)
95
+ </script>
96
+
97
+ <template>
98
+ <div class="grid grid-cols-[260px_1fr] gap-6">
99
+ <ul class="border rounded bg-white divide-y text-sm min-h-[120px]">
100
+ <li v-if="!revisions.length" class="p-3 text-zinc-500">No revisions yet.</li>
101
+ <li v-for="r in revisions" :key="r.id"
102
+ @click="inspect(r.version)"
103
+ :class="selected === versionOf(r) && 'bg-zinc-100'"
104
+ class="p-3 cursor-pointer hover:bg-zinc-50">
105
+ <div class="flex items-center justify-between gap-2">
106
+ <div class="font-medium">v{{ r.version }}</div>
107
+ <span v-if="versionOf(r) === latestVersion" class="text-xs text-zinc-500">Current</span>
108
+ </div>
109
+ <div class="text-xs text-zinc-500">{{ new Date(r.createdAt).toLocaleString() }}</div>
110
+ <div v-if="r.changeSummary" class="text-xs text-zinc-600 mt-1">{{ r.changeSummary }}</div>
111
+ </li>
112
+ </ul>
113
+ <div v-if="selected !== null" class="space-y-3">
114
+ <div class="flex items-center gap-3">
115
+ <h2 class="text-lg font-semibold">Version {{ selected }}</h2>
116
+ <button
117
+ v-if="canRestore"
118
+ type="button"
119
+ @click="restore(selected!)"
120
+ class="vulse-button-primary rounded px-3 py-1 text-sm font-medium">
121
+ Restore
122
+ </button>
123
+ </div>
124
+ <p v-if="isCurrentVersion && revisions.length > 1" class="text-sm text-zinc-600">
125
+ This is the current version. Select an older version to restore it.
126
+ </p>
127
+ <p v-else-if="revisions.length <= 1" class="text-sm text-zinc-600">
128
+ Only one version exists.
129
+ </p>
130
+ <RevisionDiff v-if="newerContent !== null && selectedContent !== null" :from="selectedContent" :to="newerContent" />
131
+ <pre v-else-if="selectedContent !== null" class="bg-zinc-900 text-zinc-100 rounded p-4 overflow-auto text-xs">{{ JSON.stringify(selectedContent, null, 2) }}</pre>
132
+ </div>
133
+ </div>
134
+ </template>
@@ -0,0 +1,113 @@
1
+ <script setup lang="ts">
2
+ import { computed, ref } from 'vue'
3
+ import type { FieldDescriptor } from '../client/form-from-zod.js'
4
+ import type { SeoContent, SeoFieldMapping } from '../../core/blueprints/seo.js'
5
+ import {
6
+ resolveEffectiveSeo,
7
+ resolvedSeoSummary,
8
+ } from '../../core/blueprints/seo.js'
9
+ import TextField from './fields/TextField.vue'
10
+ import TextareaField from './fields/TextareaField.vue'
11
+ import MediaField from './fields/MediaField.vue'
12
+
13
+ const props = defineProps<{
14
+ modelValue: SeoContent | undefined
15
+ content: Record<string, unknown>
16
+ fields: FieldDescriptor[]
17
+ titleField?: string
18
+ seoMapping?: SeoFieldMapping
19
+ fieldLabels?: Record<string, string>
20
+ }>()
21
+
22
+ const emit = defineEmits<{
23
+ (e: 'update:modelValue', v: SeoContent): void
24
+ }>()
25
+
26
+ const expanded = ref(false)
27
+
28
+ const seo = computed(() => props.modelValue ?? {})
29
+
30
+ const resolved = computed(() =>
31
+ resolveEffectiveSeo(
32
+ props.content,
33
+ seo.value,
34
+ props.fields,
35
+ props.titleField ?? 'title',
36
+ props.seoMapping,
37
+ ),
38
+ )
39
+
40
+ const summary = computed(() => resolvedSeoSummary(resolved.value))
41
+
42
+ function fieldLabel(path: string | undefined): string {
43
+ if (!path) return 'field'
44
+ return props.fieldLabels?.[path] ?? path
45
+ }
46
+
47
+ function sourceHint(key: 'metaTitle' | 'metaDescription' | 'ogImage'): string | null {
48
+ const field = resolved.value[key]
49
+ if (field.overridden || !field.sourceField) return null
50
+ return `Defaults to ${fieldLabel(field.sourceField)}`
51
+ }
52
+
53
+ function update<K extends keyof SeoContent>(key: K, value: SeoContent[K]) {
54
+ emit('update:modelValue', { ...seo.value, [key]: value })
55
+ }
56
+ </script>
57
+
58
+ <template>
59
+ <details
60
+ class="rounded border border-zinc-200 bg-zinc-50 text-sm"
61
+ :open="expanded"
62
+ @toggle="expanded = ($event.target as HTMLDetailsElement).open"
63
+ >
64
+ <summary class="cursor-pointer select-none px-3 py-2.5 text-zinc-600">
65
+ <span class="font-medium">SEO</span>
66
+ <span class="ml-2 text-zinc-500">{{ summary }}</span>
67
+ </summary>
68
+ <div class="space-y-4 border-t border-zinc-200 px-3 py-3">
69
+ <p class="text-xs text-zinc-500">
70
+ Leave fields empty to use mapped content fields. Values you enter here override those defaults.
71
+ </p>
72
+ <div>
73
+ <TextField
74
+ label="Meta title"
75
+ :model-value="seo.metaTitle ?? ''"
76
+ @update:modelValue="update('metaTitle', $event || undefined)"
77
+ />
78
+ <p v-if="sourceHint('metaTitle')" class="mt-1 text-xs text-zinc-400">
79
+ {{ sourceHint('metaTitle') }}
80
+ <span v-if="resolved.metaTitle.value && !resolved.metaTitle.overridden" class="text-zinc-500">
81
+ — currently “{{ resolved.metaTitle.value }}”
82
+ </span>
83
+ </p>
84
+ </div>
85
+ <div>
86
+ <TextareaField
87
+ label="Meta description"
88
+ :model-value="seo.metaDescription ?? ''"
89
+ @update:modelValue="update('metaDescription', $event || undefined)"
90
+ />
91
+ <p v-if="sourceHint('metaDescription')" class="mt-1 text-xs text-zinc-400">
92
+ {{ sourceHint('metaDescription') }}
93
+ <span v-if="resolved.metaDescription.value && !resolved.metaDescription.overridden" class="text-zinc-500">
94
+ — currently “{{ resolved.metaDescription.value }}”
95
+ </span>
96
+ </p>
97
+ </div>
98
+ <div>
99
+ <MediaField
100
+ label="OG image"
101
+ :model-value="seo.ogImage ?? resolved.ogImage.value"
102
+ @update:modelValue="update('ogImage', $event ?? undefined)"
103
+ />
104
+ <p v-if="sourceHint('ogImage')" class="mt-1 text-xs text-zinc-400">
105
+ {{ sourceHint('ogImage') }}
106
+ <span v-if="resolved.ogImage.value && !resolved.ogImage.overridden" class="text-zinc-500">
107
+ — using {{ fieldLabel(resolved.ogImage.sourceField) }}
108
+ </span>
109
+ </p>
110
+ </div>
111
+ </div>
112
+ </details>
113
+ </template>