@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,233 @@
1
+ <script setup lang="ts">
2
+ import { computed, onMounted, reactive, ref, watch } from 'vue'
3
+ import { adminApi, AdminApiError } from '../client/api.js'
4
+ import type { FormDefinition, FormFieldDefinition } from '../../core/forms/definition.js'
5
+
6
+ interface FormRow {
7
+ handle: string
8
+ label: string
9
+ definition: FormDefinition
10
+ enabled: boolean
11
+ }
12
+
13
+ const props = defineProps<{ handle: string | null }>()
14
+
15
+ const tab = ref<'fields' | 'settings' | 'emails' | 'embed'>('fields')
16
+ const handle = ref('')
17
+ const label = ref('')
18
+ const fields = reactive<FormFieldDefinition[]>([])
19
+ const settings = reactive({
20
+ enabled: true,
21
+ successMessage: 'Thank you!',
22
+ redirectTo: '',
23
+ honeypotField: '_hp',
24
+ notifyEmails: [] as string[],
25
+ confirmationEmail: {
26
+ enabled: false,
27
+ toField: 'email',
28
+ subject: 'Thanks for your submission',
29
+ bodyTemplate: 'We received your message.',
30
+ },
31
+ })
32
+ const saving = ref(false)
33
+ const error = ref<string | null>(null)
34
+ const handleLocked = ref(false)
35
+
36
+ const isCreate = computed(() => props.handle === null)
37
+
38
+ const embedSnippet = computed(() => `<FormRenderer form="${handle.value || 'my-form'}">
39
+ <!-- your field markup -->
40
+ </FormRenderer>`)
41
+
42
+ function slugify(input: string): string {
43
+ return input.toLowerCase().normalize('NFKD').replace(/[\u0300-\u036f]/g, '')
44
+ .replace(/[^a-z0-9_-]+/g, '-').replace(/^-+|-+$/g, '').replace(/^[^a-z]+/, '')
45
+ }
46
+
47
+ watch(label, (v) => {
48
+ if (isCreate.value && !handleLocked.value) handle.value = slugify(v)
49
+ })
50
+
51
+ function onHandleInput(e: Event) {
52
+ handleLocked.value = true
53
+ handle.value = (e.target as HTMLInputElement).value
54
+ }
55
+
56
+ async function load() {
57
+ if (props.handle === null) {
58
+ handle.value = ''
59
+ label.value = ''
60
+ fields.splice(0)
61
+ handleLocked.value = false
62
+ return
63
+ }
64
+ const row = await adminApi.get<FormRow>(`/api/vulse/forms/${props.handle}`)
65
+ handle.value = row.handle
66
+ label.value = row.label
67
+ handleLocked.value = true
68
+ fields.splice(0, fields.length, ...row.definition.fields)
69
+ Object.assign(settings, {
70
+ enabled: row.enabled,
71
+ successMessage: row.definition.settings.successMessage ?? 'Thank you!',
72
+ redirectTo: row.definition.settings.redirectTo ?? '',
73
+ honeypotField: row.definition.settings.honeypotField ?? '_hp',
74
+ notifyEmails: row.definition.settings.notifyEmails ?? [],
75
+ confirmationEmail: row.definition.settings.confirmationEmail ?? settings.confirmationEmail,
76
+ })
77
+ }
78
+
79
+ function formatApiError(e: unknown): string {
80
+ if (!(e instanceof AdminApiError)) return e instanceof Error ? e.message : 'Save failed'
81
+ const issues = (e.details as { issues?: Array<{ path?: (string | number)[]; message?: string }> } | undefined)?.issues
82
+ if (issues?.length) {
83
+ return issues.map((issue) => {
84
+ const path = issue.path?.length ? issue.path.join('.') : 'form'
85
+ return `${path}: ${issue.message ?? 'invalid'}`
86
+ }).join('; ')
87
+ }
88
+ return e.message
89
+ }
90
+
91
+ onMounted(load)
92
+ watch(() => props.handle, load)
93
+
94
+ function addField() {
95
+ fields.push({ name: '', ui: { kind: 'text' }, optional: false })
96
+ }
97
+
98
+ function buildDefinition(): FormDefinition {
99
+ return {
100
+ handle: handle.value,
101
+ label: label.value,
102
+ fields: [...fields],
103
+ settings: {
104
+ enabled: settings.enabled,
105
+ successMessage: settings.successMessage,
106
+ redirectTo: settings.redirectTo || undefined,
107
+ honeypotField: settings.honeypotField,
108
+ notifyEmails: settings.notifyEmails.filter(Boolean),
109
+ confirmationEmail: settings.confirmationEmail.enabled ? settings.confirmationEmail : undefined,
110
+ },
111
+ actions: [],
112
+ }
113
+ }
114
+
115
+ async function save() {
116
+ error.value = null
117
+ for (const field of fields) {
118
+ if (!field.name.trim()) {
119
+ error.value = 'Each field must have a name.'
120
+ return
121
+ }
122
+ }
123
+ saving.value = true
124
+ try {
125
+ const body = buildDefinition()
126
+ if (isCreate.value) {
127
+ await adminApi.post('/api/vulse/forms', body)
128
+ window.location.href = `/admin/forms/${handle.value}`
129
+ } else {
130
+ await adminApi.put(`/api/vulse/forms/${props.handle}`, body)
131
+ }
132
+ } catch (e) {
133
+ error.value = formatApiError(e)
134
+ } finally {
135
+ saving.value = false
136
+ }
137
+ }
138
+
139
+ async function destroy() {
140
+ if (!props.handle || !confirm(`Delete form "${props.handle}"?`)) return
141
+ await adminApi.delete(`/api/vulse/forms/${props.handle}`)
142
+ window.location.href = '/admin/forms'
143
+ }
144
+ </script>
145
+
146
+ <template>
147
+ <div>
148
+ <div class="mb-4 flex items-center justify-between">
149
+ <h1 class="text-2xl font-semibold">{{ isCreate ? 'New form' : label }}</h1>
150
+ <a v-if="!isCreate" :href="`/admin/forms/${handle}/submissions`" class="text-sm text-zinc-600 hover:underline">View submissions</a>
151
+ </div>
152
+
153
+ <div class="mb-4 flex gap-2 border-b border-zinc-200">
154
+ <button v-for="t in ['fields', 'settings', 'emails', 'embed'] as const" :key="t" type="button"
155
+ class="px-3 py-2 text-sm capitalize" :class="tab === t && 'border-b-2 border-zinc-900 font-medium'" @click="tab = t">
156
+ {{ t }}
157
+ </button>
158
+ </div>
159
+
160
+ <div class="max-w-3xl space-y-4">
161
+ <div class="space-y-3 rounded-xl border border-zinc-200 bg-white p-4">
162
+ <label class="block">
163
+ <span class="text-sm font-medium text-zinc-700">Label</span>
164
+ <input v-model="label" class="mt-1 w-full rounded-lg border border-zinc-300 px-3 py-2 text-sm" />
165
+ </label>
166
+ <label class="block">
167
+ <span class="text-sm font-medium text-zinc-700">Handle</span>
168
+ <input :value="handle" :disabled="!isCreate" class="mt-1 w-full rounded-lg border border-zinc-300 px-3 py-2 text-sm disabled:bg-zinc-100" @input="onHandleInput" />
169
+ </label>
170
+ </div>
171
+
172
+ <div v-show="tab === 'fields'" class="rounded-xl border border-zinc-200 bg-white p-4">
173
+ <div class="mb-3 flex items-center justify-between">
174
+ <h2 class="text-sm font-semibold text-zinc-700">Fields</h2>
175
+ <button type="button" class="rounded-lg border border-zinc-300 px-2 py-1 text-xs" @click="addField">+ Add field</button>
176
+ </div>
177
+ <div v-for="(f, i) in fields" :key="i" class="mb-3 rounded-lg border border-zinc-100 p-3">
178
+ <div class="flex flex-wrap items-center gap-2">
179
+ <input v-model="f.name" placeholder="name" class="flex-1 rounded border border-zinc-300 px-2 py-1 text-sm" />
180
+ <input v-model="f.label" placeholder="label" class="flex-1 rounded border border-zinc-300 px-2 py-1 text-sm" />
181
+ <select v-model="f.ui.kind" class="rounded border border-zinc-300 px-2 py-1 text-sm">
182
+ <option value="text">text</option>
183
+ <option value="textarea">textarea</option>
184
+ <option value="email">email</option>
185
+ <option value="number">number</option>
186
+ <option value="select">select</option>
187
+ <option value="checkbox">checkbox</option>
188
+ <option value="radio">radio</option>
189
+ <option value="date">date</option>
190
+ <option value="file">file</option>
191
+ <option value="hidden">hidden</option>
192
+ <option value="honeypot">honeypot</option>
193
+ <option value="submit">submit</option>
194
+ </select>
195
+ <label class="flex items-center gap-1 text-xs"><input v-model="f.optional" type="checkbox" /> optional</label>
196
+ <label v-if="f.validation" class="flex items-center gap-1 text-xs"><input v-model="f.validation.unique" type="checkbox" /> unique</label>
197
+ <button type="button" class="text-xs text-red-600" @click="fields.splice(i, 1)">Remove</button>
198
+ </div>
199
+ </div>
200
+ </div>
201
+
202
+ <div v-show="tab === 'settings'" class="space-y-3 rounded-xl border border-zinc-200 bg-white p-4">
203
+ <label class="flex items-center gap-2 text-sm"><input v-model="settings.enabled" type="checkbox" /> Enabled</label>
204
+ <label class="block text-sm">Success message<input v-model="settings.successMessage" class="mt-1 w-full rounded border border-zinc-300 px-2 py-1" /></label>
205
+ <label class="block text-sm">Redirect URL (optional)<input v-model="settings.redirectTo" class="mt-1 w-full rounded border border-zinc-300 px-2 py-1" /></label>
206
+ <label class="block text-sm">Honeypot field<input v-model="settings.honeypotField" class="mt-1 w-full rounded border border-zinc-300 px-2 py-1" /></label>
207
+ </div>
208
+
209
+ <div v-show="tab === 'emails'" class="space-y-3 rounded-xl border border-zinc-200 bg-white p-4">
210
+ <label class="block text-sm">Notify emails (comma-separated)
211
+ <input :value="settings.notifyEmails.join(', ')" class="mt-1 w-full rounded border border-zinc-300 px-2 py-1"
212
+ @input="settings.notifyEmails = ($event.target as HTMLInputElement).value.split(',').map((s) => s.trim()).filter(Boolean)" />
213
+ </label>
214
+ <label class="flex items-center gap-2 text-sm"><input v-model="settings.confirmationEmail.enabled" type="checkbox" /> Send confirmation email</label>
215
+ <template v-if="settings.confirmationEmail.enabled">
216
+ <label class="block text-sm">To field<input v-model="settings.confirmationEmail.toField" class="mt-1 w-full rounded border border-zinc-300 px-2 py-1" /></label>
217
+ <label class="block text-sm">Subject<input v-model="settings.confirmationEmail.subject" class="mt-1 w-full rounded border border-zinc-300 px-2 py-1" /></label>
218
+ <label class="block text-sm">Body<textarea v-model="settings.confirmationEmail.bodyTemplate" class="mt-1 w-full rounded border border-zinc-300 px-2 py-1" rows="4" /></label>
219
+ </template>
220
+ </div>
221
+
222
+ <div v-show="tab === 'embed'" class="rounded-xl border border-zinc-200 bg-white p-4">
223
+ <pre class="overflow-x-auto rounded bg-zinc-50 p-3 text-xs">{{ embedSnippet }}</pre>
224
+ </div>
225
+
226
+ <div v-if="error" class="rounded-lg bg-red-50 px-3 py-2 text-sm text-red-700">{{ error }}</div>
227
+ <div class="flex items-center gap-2">
228
+ <button type="button" class="rounded-lg bg-zinc-900 px-4 py-2 text-sm text-white disabled:opacity-50" :disabled="saving" @click="save">{{ saving ? 'Saving…' : 'Save' }}</button>
229
+ <button v-if="!isCreate" type="button" class="rounded-lg border border-red-200 px-4 py-2 text-sm text-red-700" @click="destroy">Delete</button>
230
+ </div>
231
+ </div>
232
+ </div>
233
+ </template>
@@ -0,0 +1,54 @@
1
+ <script setup lang="ts">
2
+ import { onMounted, ref } from 'vue'
3
+ import { adminApi } from '../client/api.js'
4
+
5
+ interface FormRow {
6
+ handle: string
7
+ label: string
8
+ enabled: boolean
9
+ submissionCount?: number
10
+ }
11
+
12
+ const forms = ref<FormRow[]>([])
13
+ const loading = ref(true)
14
+
15
+ onMounted(async () => {
16
+ forms.value = await adminApi.get<FormRow[]>('/api/vulse/forms')
17
+ loading.value = false
18
+ })
19
+ </script>
20
+
21
+ <template>
22
+ <div>
23
+ <div class="mb-6 flex items-center justify-between">
24
+ <h1 class="text-2xl font-semibold">Forms</h1>
25
+ <a href="/admin/forms/new" class="rounded-lg bg-zinc-900 px-3 py-2 text-sm text-white">New form</a>
26
+ </div>
27
+ <p v-if="loading" class="text-sm text-zinc-500">Loading…</p>
28
+ <table v-else class="w-full text-sm">
29
+ <thead>
30
+ <tr class="border-b border-zinc-200 text-left text-zinc-500">
31
+ <th class="py-2 pr-4">Label</th>
32
+ <th class="py-2 pr-4">Handle</th>
33
+ <th class="py-2 pr-4">Enabled</th>
34
+ <th class="py-2 pr-4">Submissions</th>
35
+ <th class="py-2"></th>
36
+ </tr>
37
+ </thead>
38
+ <tbody>
39
+ <tr v-for="f in forms" :key="f.handle" class="border-b border-zinc-100">
40
+ <td class="py-2 pr-4">{{ f.label }}</td>
41
+ <td class="py-2 pr-4 font-mono text-xs">{{ f.handle }}</td>
42
+ <td class="py-2 pr-4">{{ f.enabled ? 'Yes' : 'No' }}</td>
43
+ <td class="py-2 pr-4">{{ f.submissionCount ?? 0 }}</td>
44
+ <td class="py-2 text-right">
45
+ <a :href="`/admin/forms/${f.handle}`" class="text-zinc-700 hover:underline">Edit</a>
46
+ ·
47
+ <a :href="`/admin/forms/${f.handle}/submissions`" class="text-zinc-700 hover:underline">Submissions</a>
48
+ </td>
49
+ </tr>
50
+ </tbody>
51
+ </table>
52
+ <p v-if="!loading && forms.length === 0" class="mt-4 text-sm text-zinc-500">No forms yet.</p>
53
+ </div>
54
+ </template>
@@ -0,0 +1,272 @@
1
+ <script setup lang="ts">
2
+ import { computed, onMounted, reactive, ref, watch } from 'vue'
3
+ import { adminApi, AdminApiError } from '../client/api.js'
4
+ import { fieldDescriptorsFromDefinitions } from '../client/form-from-zod.js'
5
+ import type { FieldDescriptor } from '../client/form-from-zod.js'
6
+ import type { FieldDefinition } from '../../core/blueprints/definition.js'
7
+ import FieldRenderer from './fields/FieldRenderer.vue'
8
+
9
+ const props = defineProps<{ handle: string | null }>()
10
+
11
+ const handle = ref('')
12
+ const label = ref('')
13
+ const fields = reactive<FieldDefinition[]>([])
14
+ const content = reactive<Record<string, unknown>>({})
15
+ const fieldErrors = reactive<Record<string, string>>({})
16
+ const savingDefinition = ref(false)
17
+ const savingValue = ref(false)
18
+ const loading = ref(false)
19
+ const error = ref<string | null>(null)
20
+ const handleLocked = ref(false)
21
+
22
+ const isCreate = computed(() => props.handle === null)
23
+ const fieldDescriptors = computed<FieldDescriptor[]>(() => fieldDescriptorsFromDefinitions(fields))
24
+ const canEditValue = computed(() => !isCreate.value && fields.length > 0)
25
+
26
+ function slugify(input: string): string {
27
+ return input.toLowerCase().normalize('NFKD').replace(/[\u0300-\u036f]/g, '')
28
+ .replace(/[^a-z0-9_-]+/g, '-').replace(/^-+|-+$/g, '').replace(/^[^a-z]+/, '')
29
+ }
30
+
31
+ function defaultFor(kind: string): unknown {
32
+ if (kind === 'boolean') return false
33
+ if (kind === 'blocks') return { type: 'doc', content: [{ type: 'paragraph' }] }
34
+ if (kind === 'date') {
35
+ const d = new Date()
36
+ const pad = (n: number) => String(n).padStart(2, '0')
37
+ return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}`
38
+ }
39
+ return ''
40
+ }
41
+
42
+ function ensureContentFields() {
43
+ for (const field of fields) {
44
+ if (!(field.name in content)) content[field.name] = field.default ?? defaultFor(field.ui.kind)
45
+ }
46
+ }
47
+
48
+ watch(label, (v) => {
49
+ if (isCreate.value && !handleLocked.value) handle.value = slugify(v)
50
+ })
51
+
52
+ function onHandleInput(e: Event) {
53
+ handleLocked.value = true
54
+ handle.value = (e.target as HTMLInputElement).value
55
+ }
56
+
57
+ async function load() {
58
+ for (const key of Object.keys(content)) delete content[key]
59
+ for (const key of Object.keys(fieldErrors)) delete fieldErrors[key]
60
+ error.value = null
61
+
62
+ if (props.handle === null) {
63
+ handle.value = ''
64
+ label.value = ''
65
+ fields.splice(0)
66
+ handleLocked.value = false
67
+ return
68
+ }
69
+
70
+ loading.value = true
71
+ try {
72
+ const result = await adminApi.get<{
73
+ set: { handle: string; label: string; fields: FieldDefinition[] }
74
+ value: { content: Record<string, unknown> } | null
75
+ }>(`/api/vulse/globals/${props.handle}`)
76
+ handle.value = result.set.handle
77
+ label.value = result.set.label
78
+ fields.splice(0, fields.length, ...result.set.fields)
79
+ handleLocked.value = true
80
+ ensureContentFields()
81
+ Object.assign(content, result.value?.content ?? {})
82
+ } finally {
83
+ loading.value = false
84
+ }
85
+ }
86
+
87
+ onMounted(load)
88
+ watch(() => props.handle, load)
89
+
90
+ function addField() {
91
+ fields.push({ name: '', ui: { kind: 'text' }, optional: false })
92
+ }
93
+
94
+ function removeField(index: number) {
95
+ const [field] = fields.splice(index, 1)
96
+ if (field?.name) delete content[field.name]
97
+ }
98
+
99
+ function updateFieldName(index: number, event: Event) {
100
+ const field = fields[index]
101
+ if (!field) return
102
+ const oldName = field.name
103
+ const newName = (event.target as HTMLInputElement).value
104
+ field.name = newName
105
+ if (oldName && oldName in content && !(newName in content)) {
106
+ content[newName] = content[oldName]
107
+ delete content[oldName]
108
+ }
109
+ }
110
+
111
+ function formatApiError(e: unknown): string {
112
+ if (!(e instanceof AdminApiError)) return e instanceof Error ? e.message : 'Save failed'
113
+ const issues = (e.details as { issues?: Array<{ path?: (string | number)[]; message?: string }> } | undefined)?.issues
114
+ if (issues?.length) {
115
+ return issues.map((issue) => {
116
+ const path = issue.path?.length ? issue.path.join('.') : 'form'
117
+ return `${path}: ${issue.message ?? 'invalid'}`
118
+ }).join('; ')
119
+ }
120
+ return e.message
121
+ }
122
+
123
+ function applyValueErrors(e: AdminApiError) {
124
+ for (const key of Object.keys(fieldErrors)) delete fieldErrors[key]
125
+ const issues = (e.details as { issues?: Array<{ path?: (string | number)[]; message?: string }> } | undefined)?.issues
126
+ if (issues?.length) {
127
+ for (const issue of issues) {
128
+ const field = String(issue.path?.[0] ?? '')
129
+ if (field) fieldErrors[field] = issue.message ?? 'Invalid'
130
+ }
131
+ return
132
+ }
133
+ error.value = e.message
134
+ }
135
+
136
+ async function saveDefinition() {
137
+ error.value = null
138
+ for (const field of fields) {
139
+ if (!field.name.trim()) {
140
+ error.value = 'Each field must have a name.'
141
+ return
142
+ }
143
+ }
144
+ savingDefinition.value = true
145
+ try {
146
+ const body = { handle: handle.value, label: label.value, fields: [...fields] }
147
+ if (isCreate.value) {
148
+ await adminApi.post('/api/vulse/globals', body)
149
+ window.location.href = `/admin/settings/globals/${handle.value}`
150
+ } else {
151
+ await adminApi.put(`/api/vulse/globals/${props.handle}`, body)
152
+ ensureContentFields()
153
+ }
154
+ } catch (e) {
155
+ error.value = formatApiError(e)
156
+ } finally {
157
+ savingDefinition.value = false
158
+ }
159
+ }
160
+
161
+ async function saveValue() {
162
+ if (isCreate.value) return
163
+ error.value = null
164
+ for (const key of Object.keys(fieldErrors)) delete fieldErrors[key]
165
+ savingValue.value = true
166
+ try {
167
+ await adminApi.put(`/api/vulse/globals/${props.handle}/value`, { ...content })
168
+ } catch (e) {
169
+ if (e instanceof AdminApiError) applyValueErrors(e)
170
+ else error.value = 'Save failed'
171
+ } finally {
172
+ savingValue.value = false
173
+ }
174
+ }
175
+
176
+ async function destroy() {
177
+ if (!props.handle || !confirm(`Delete global set "${props.handle}"?`)) return
178
+ await adminApi.delete(`/api/vulse/globals/${props.handle}`)
179
+ window.location.href = '/admin/settings/globals'
180
+ }
181
+ </script>
182
+
183
+ <template>
184
+ <div>
185
+ <div class="mb-6 flex items-center justify-between">
186
+ <div>
187
+ <h1 class="text-2xl font-semibold">{{ isCreate ? 'New global set' : label }}</h1>
188
+ <p class="mt-1 text-sm text-zinc-500">Globals are site-wide content available to the frontend on every page.</p>
189
+ </div>
190
+ <button v-if="!isCreate" type="button" class="rounded-lg border border-red-200 px-4 py-2 text-sm text-red-700" @click="destroy">Delete</button>
191
+ </div>
192
+
193
+ <p v-if="loading" class="text-sm text-zinc-500">Loading…</p>
194
+ <div v-else class="grid max-w-5xl gap-6 lg:grid-cols-2">
195
+ <section class="space-y-4 rounded-xl border border-zinc-200 bg-white p-4">
196
+ <div>
197
+ <h2 class="text-sm font-semibold text-zinc-700">Definition</h2>
198
+ <p class="mt-1 text-xs text-zinc-500">Define the fields editors can fill in for this global set.</p>
199
+ </div>
200
+
201
+ <label class="block">
202
+ <span class="text-sm font-medium text-zinc-700">Label</span>
203
+ <input v-model="label" class="mt-1 w-full rounded-lg border border-zinc-300 px-3 py-2 text-sm" />
204
+ </label>
205
+ <label class="block">
206
+ <span class="text-sm font-medium text-zinc-700">Handle</span>
207
+ <input :value="handle" :disabled="!isCreate" class="mt-1 w-full rounded-lg border border-zinc-300 px-3 py-2 text-sm disabled:bg-zinc-100" @input="onHandleInput" />
208
+ <span class="mt-1 block text-xs text-zinc-500">
209
+ <template v-if="isCreate">
210
+ Stable identifier exposed by the public globals API (<code>/api/vulse/public/globals/{{ handle || 'handle' }}</code>) and used by any frontend code that reads this global.
211
+ </template>
212
+ <template v-else>
213
+ Locked — changing it would break the public API path and any frontend code that loads this global by handle. Create a new global to rename.
214
+ </template>
215
+ </span>
216
+ </label>
217
+
218
+ <div>
219
+ <div class="mb-3 flex items-center justify-between">
220
+ <h3 class="text-sm font-semibold text-zinc-700">Fields</h3>
221
+ <button type="button" class="rounded-lg border border-zinc-300 px-2 py-1 text-xs" @click="addField">+ Add field</button>
222
+ </div>
223
+ <div v-for="(f, i) in fields" :key="i" class="mb-3 rounded-lg border border-zinc-100 p-3">
224
+ <div class="flex flex-wrap items-center gap-2">
225
+ <input :value="f.name" placeholder="name" class="flex-1 rounded border border-zinc-300 px-2 py-1 text-sm" @input="updateFieldName(i, $event)" />
226
+ <input v-model="f.label" placeholder="label" class="flex-1 rounded border border-zinc-300 px-2 py-1 text-sm" />
227
+ <select v-model="f.ui.kind" class="rounded border border-zinc-300 px-2 py-1 text-sm">
228
+ <option value="text">text</option>
229
+ <option value="textarea">textarea</option>
230
+ <option value="blocks">blocks</option>
231
+ <option value="date">date</option>
232
+ <option value="boolean">boolean</option>
233
+ <option value="select">select</option>
234
+ <option value="asset">asset</option>
235
+ </select>
236
+ <label class="flex items-center gap-1 text-xs"><input v-model="f.optional" type="checkbox" /> optional</label>
237
+ <button type="button" class="text-xs text-red-600" @click="removeField(i)">Remove</button>
238
+ </div>
239
+ </div>
240
+ </div>
241
+
242
+ <div v-if="error" class="rounded-lg bg-red-50 px-3 py-2 text-sm text-red-700">{{ error }}</div>
243
+ <button type="button" class="vulse-button-primary rounded-lg px-4 py-2 text-sm font-medium disabled:opacity-50" :disabled="savingDefinition" @click="saveDefinition">
244
+ {{ savingDefinition ? 'Saving…' : (isCreate ? 'Create set' : 'Save definition') }}
245
+ </button>
246
+ </section>
247
+
248
+ <section class="space-y-4 rounded-xl border border-zinc-200 bg-white p-4">
249
+ <div>
250
+ <h2 class="text-sm font-semibold text-zinc-700">Content</h2>
251
+ <p class="mt-1 text-xs text-zinc-500">Values exposed via the public globals API.</p>
252
+ </div>
253
+
254
+ <p v-if="isCreate" class="text-sm text-zinc-500">Save the definition first to edit content.</p>
255
+ <p v-else-if="fields.length === 0" class="text-sm text-zinc-500">Add fields to the definition to edit content.</p>
256
+ <template v-else>
257
+ <FieldRenderer
258
+ v-for="fd in fieldDescriptors"
259
+ :key="fd.path"
260
+ :field="fd"
261
+ :model-value="content[fd.path]"
262
+ :field-errors="fieldErrors"
263
+ @update:modelValue="content[fd.path] = $event"
264
+ />
265
+ <button type="button" class="rounded-lg bg-zinc-900 px-4 py-2 text-sm text-white disabled:opacity-50" :disabled="savingValue || !canEditValue" @click="saveValue">
266
+ {{ savingValue ? 'Saving…' : 'Save content' }}
267
+ </button>
268
+ </template>
269
+ </section>
270
+ </div>
271
+ </div>
272
+ </template>
@@ -0,0 +1,55 @@
1
+ <script setup lang="ts">
2
+ import { onMounted, ref } from 'vue'
3
+ import { adminApi } from '../client/api.js'
4
+
5
+ interface GlobalSetListItem {
6
+ handle: string
7
+ label: string
8
+ fieldCount: number
9
+ }
10
+
11
+ const sets = ref<GlobalSetListItem[]>([])
12
+ const loading = ref(true)
13
+
14
+ onMounted(async () => {
15
+ sets.value = await adminApi.get<GlobalSetListItem[]>('/api/vulse/globals')
16
+ loading.value = false
17
+ })
18
+
19
+ async function destroy(handle: string) {
20
+ if (!confirm(`Delete global set "${handle}"?`)) return
21
+ await adminApi.delete(`/api/vulse/globals/${handle}`)
22
+ sets.value = sets.value.filter((s) => s.handle !== handle)
23
+ }
24
+ </script>
25
+
26
+ <template>
27
+ <div>
28
+ <div class="mb-6 flex items-center justify-between">
29
+ <div>
30
+ <h1 class="text-2xl font-semibold tracking-tight">Globals</h1>
31
+ <p class="mt-1 text-sm text-zinc-500">Site-wide content available on every page.</p>
32
+ </div>
33
+ <a href="/admin/settings/globals/new" class="vulse-button-primary rounded-lg px-4 py-2 text-sm font-medium">+ New global set</a>
34
+ </div>
35
+
36
+ <p v-if="loading" class="text-sm text-zinc-500">Loading…</p>
37
+ <div v-else class="rounded-xl border border-zinc-200 bg-white">
38
+ <div
39
+ v-for="s in sets"
40
+ :key="s.handle"
41
+ class="flex items-center justify-between border-b border-zinc-100 px-4 py-3 text-sm last:border-0"
42
+ >
43
+ <div>
44
+ <div class="font-medium">{{ s.label }}</div>
45
+ <div class="font-mono text-xs text-zinc-500">{{ s.handle }} · {{ s.fieldCount }} field{{ s.fieldCount === 1 ? '' : 's' }}</div>
46
+ </div>
47
+ <div class="flex items-center gap-3">
48
+ <a :href="`/admin/settings/globals/${s.handle}`" class="text-zinc-700 hover:underline">Edit</a>
49
+ <button type="button" class="text-red-600 hover:underline" @click="destroy(s.handle)">Delete</button>
50
+ </div>
51
+ </div>
52
+ <p v-if="sets.length === 0" class="px-4 py-6 text-sm text-zinc-500">No global sets yet.</p>
53
+ </div>
54
+ </div>
55
+ </template>