@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,137 @@
1
+ <script setup lang="ts">
2
+ import { computed, onMounted, reactive, ref, watch } from 'vue'
3
+ import { adminApi } from '../client/api.js'
4
+ import type { NestedFieldDefinition } from '../../core/blueprints/definition.js'
5
+ import type { SetDefinition } from '../../core/sets/definition.js'
6
+
7
+ const props = defineProps<{ handle: string | null }>()
8
+
9
+ const handle = ref('')
10
+ const label = ref('')
11
+ const fields = reactive<NestedFieldDefinition[]>([])
12
+ const saving = ref(false)
13
+ const error = ref<string | null>(null)
14
+ const handleLocked = ref(false)
15
+
16
+ const isCreate = computed(() => props.handle === null)
17
+
18
+ function slugify(input: string): string {
19
+ return input.toLowerCase().normalize('NFKD').replace(/[\u0300-\u036f]/g, '')
20
+ .replace(/[^a-z0-9_-]+/g, '-').replace(/^-+|-+$/g, '').replace(/^[^a-z]+/, '')
21
+ }
22
+
23
+ watch(label, (v) => {
24
+ if (isCreate.value && !handleLocked.value) handle.value = slugify(v)
25
+ })
26
+
27
+ function onHandleInput(e: Event) {
28
+ handleLocked.value = true
29
+ handle.value = (e.target as HTMLInputElement).value
30
+ }
31
+
32
+ async function load() {
33
+ if (props.handle === null) {
34
+ handle.value = ''
35
+ label.value = ''
36
+ fields.splice(0)
37
+ handleLocked.value = false
38
+ return
39
+ }
40
+ const s = await adminApi.get<SetDefinition>(`/api/vulse/sets/${props.handle}`)
41
+ handle.value = s.handle
42
+ label.value = s.label
43
+ handleLocked.value = true
44
+ fields.splice(0, fields.length, ...s.fields)
45
+ }
46
+
47
+ onMounted(load)
48
+ watch(() => props.handle, load)
49
+
50
+ function addField() {
51
+ fields.push({ name: '', ui: { kind: 'text' }, optional: false })
52
+ }
53
+
54
+ async function save() {
55
+ saving.value = true
56
+ error.value = null
57
+ try {
58
+ const body = { handle: handle.value, label: label.value, fields: [...fields] }
59
+ if (isCreate.value) {
60
+ await adminApi.post('/api/vulse/sets', body)
61
+ window.location.href = '/admin/settings/sets'
62
+ } else {
63
+ await adminApi.patch(`/api/vulse/sets/${props.handle}`, body)
64
+ window.location.href = '/admin/settings/sets'
65
+ }
66
+ } catch (e) {
67
+ error.value = e instanceof Error ? e.message : 'Save failed'
68
+ } finally {
69
+ saving.value = false
70
+ }
71
+ }
72
+
73
+ async function destroy() {
74
+ if (!props.handle || !confirm(`Delete set "${props.handle}"?`)) return
75
+ await adminApi.delete(`/api/vulse/sets/${props.handle}`)
76
+ window.location.href = '/admin/settings/sets'
77
+ }
78
+ </script>
79
+
80
+ <template>
81
+ <div>
82
+ <h1 class="mb-6 text-2xl font-semibold">{{ isCreate ? 'New set' : `Edit ${handle}` }}</h1>
83
+ <div class="max-w-3xl space-y-4">
84
+ <div class="space-y-3 rounded-xl border border-zinc-200 bg-white p-4">
85
+ <label class="block">
86
+ <span class="text-sm font-medium text-zinc-700">Label</span>
87
+ <input v-model="label" class="mt-1 w-full rounded-lg border border-zinc-300 px-3 py-2 text-sm" />
88
+ </label>
89
+ <label class="block">
90
+ <span class="text-sm font-medium text-zinc-700">Handle</span>
91
+ <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" />
92
+ <span class="mt-1 block text-xs text-zinc-500">
93
+ <template v-if="isCreate">
94
+ Stable identifier referenced by blueprints (in <code>blocks</code> and <code>replicator</code> fields) and any frontend code that renders this set.
95
+ </template>
96
+ <template v-else>
97
+ Locked — changing it would break every blueprint and frontend reference to this set. Create a new set and migrate to rename.
98
+ </template>
99
+ </span>
100
+ </label>
101
+ </div>
102
+
103
+ <div class="rounded-xl border border-zinc-200 bg-white p-4">
104
+ <div class="mb-3 flex items-center justify-between">
105
+ <h2 class="text-sm font-semibold text-zinc-700">Fields</h2>
106
+ <button type="button" class="rounded-lg border border-zinc-300 px-2 py-1 text-xs" @click="addField">+ Add field</button>
107
+ </div>
108
+ <div v-for="(f, i) in fields" :key="i" class="mb-3 rounded-lg border border-zinc-100 p-3">
109
+ <div class="flex flex-wrap items-center gap-2">
110
+ <input v-model="f.name" placeholder="name" class="flex-1 rounded border border-zinc-300 px-2 py-1 text-sm" />
111
+ <select v-model="f.ui.kind" class="rounded border border-zinc-300 px-2 py-1 text-sm">
112
+ <option value="text">text</option>
113
+ <option value="textarea">textarea</option>
114
+ <option value="blocks">blocks</option>
115
+ <option value="date">date</option>
116
+ <option value="boolean">boolean</option>
117
+ <option value="select">select</option>
118
+ <option value="relationship">relationship</option>
119
+ <option value="entry">entry</option>
120
+ <option value="entries">entries</option>
121
+ <option value="link">link</option>
122
+ <option value="asset">asset</option>
123
+ </select>
124
+ <label class="flex items-center gap-1 text-xs"><input v-model="f.optional" type="checkbox" /> optional</label>
125
+ <button type="button" class="text-xs text-red-600" @click="fields.splice(i, 1)">Remove</button>
126
+ </div>
127
+ </div>
128
+ </div>
129
+
130
+ <div v-if="error" class="rounded-lg bg-red-50 px-3 py-2 text-sm text-red-700">{{ error }}</div>
131
+ <div class="flex items-center gap-2">
132
+ <button type="button" class="vulse-button-primary rounded-lg px-4 py-2 text-sm font-medium" :disabled="saving" @click="save">{{ saving ? 'Saving…' : 'Save' }}</button>
133
+ <button v-if="!isCreate" type="button" class="ml-auto rounded-lg border border-red-300 px-4 py-2 text-sm text-red-600" @click="destroy">Delete</button>
134
+ </div>
135
+ </div>
136
+ </div>
137
+ </template>
@@ -0,0 +1,32 @@
1
+ <script setup lang="ts">
2
+ import { onMounted, ref } from 'vue'
3
+ import { adminApi } from '../client/api.js'
4
+ import type { SetDefinition } from '../../core/sets/definition.js'
5
+
6
+ const sets = ref<SetDefinition[]>([])
7
+
8
+ onMounted(async () => {
9
+ sets.value = await adminApi.get<SetDefinition[]>('/api/vulse/sets')
10
+ })
11
+ </script>
12
+
13
+ <template>
14
+ <div>
15
+ <div class="mb-6 flex items-center justify-between">
16
+ <h1 class="text-2xl font-semibold tracking-tight">Sets</h1>
17
+ <a href="/admin/settings/sets/new" class="vulse-button-primary rounded-lg px-4 py-2 text-sm font-medium">+ New set</a>
18
+ </div>
19
+ <div class="rounded-xl border border-zinc-200 bg-white">
20
+ <a
21
+ v-for="s in sets"
22
+ :key="s.handle"
23
+ :href="`/admin/settings/sets/${s.handle}`"
24
+ class="flex items-center justify-between border-b border-zinc-100 px-4 py-3 text-sm last:border-0 hover:bg-zinc-50"
25
+ >
26
+ <span class="font-medium">{{ s.label }}</span>
27
+ <span class="font-mono text-xs text-zinc-500">{{ s.handle }}</span>
28
+ </a>
29
+ <p v-if="sets.length === 0" class="px-4 py-6 text-sm text-zinc-500">No sets yet.</p>
30
+ </div>
31
+ </div>
32
+ </template>
@@ -0,0 +1,189 @@
1
+ <script setup lang="ts">
2
+ import { computed, onMounted, ref, watch } from 'vue'
3
+ import { adminApi, AdminApiError } from '../client/api'
4
+
5
+ interface Values {
6
+ siteName: string
7
+ deployHookUrl: string
8
+ defaultLocale: string
9
+ locales: string[]
10
+ }
11
+
12
+ const values = ref<Values>({ siteName: '', deployHookUrl: '', defaultLocale: 'default', locales: ['default'] })
13
+ const initial = ref<Values>({ siteName: '', deployHookUrl: '', defaultLocale: 'default', locales: ['default'] })
14
+ const loading = ref(true)
15
+ const saving = ref(false)
16
+ const saved = ref(false)
17
+ const error = ref<string | null>(null)
18
+ const localesText = ref('default')
19
+
20
+ const LOCALE_RE = /^[a-z]{2,3}(-[A-Z]{2})?$|^default$/
21
+
22
+ const localesValid = computed(() => {
23
+ const codes = parseLocales(localesText.value)
24
+ if (codes.length === 0) return 'Add at least one locale.'
25
+ for (const c of codes) {
26
+ if (!LOCALE_RE.test(c)) return `Invalid locale code: ${c}`
27
+ }
28
+ if (!codes.includes(values.value.defaultLocale)) return `Default locale "${values.value.defaultLocale}" must appear in the supported list.`
29
+ return null
30
+ })
31
+
32
+ function parseLocales(text: string): string[] {
33
+ return text.split(',').map((s) => s.trim()).filter(Boolean)
34
+ }
35
+
36
+ const supportedLocales = computed(() => parseLocales(localesText.value))
37
+
38
+ watch(localesText, () => {
39
+ const codes = supportedLocales.value
40
+ if (codes.length > 0 && !codes.includes(values.value.defaultLocale)) {
41
+ values.value.defaultLocale = codes[0]
42
+ }
43
+ })
44
+
45
+ async function load() {
46
+ loading.value = true
47
+ try {
48
+ const all = await adminApi.get<Record<string, unknown>>('/api/vulse/settings')
49
+ const locales = Array.isArray(all.locales) && all.locales.length
50
+ ? (all.locales as string[])
51
+ : ['default']
52
+ const defaultLocale = typeof all.defaultLocale === 'string' ? all.defaultLocale : 'default'
53
+ const next: Values = {
54
+ siteName: String(all.siteName ?? ''),
55
+ deployHookUrl: String(all.deployHookUrl ?? ''),
56
+ defaultLocale,
57
+ locales,
58
+ }
59
+ values.value = { ...next }
60
+ initial.value = { ...next, locales: [...next.locales] }
61
+ localesText.value = locales.join(', ')
62
+ } finally {
63
+ loading.value = false
64
+ }
65
+ }
66
+
67
+ function dirty(key: keyof Values): boolean {
68
+ if (key === 'locales') {
69
+ const a = parseLocales(localesText.value)
70
+ const b = initial.value.locales
71
+ return a.length !== b.length || a.some((v, i) => v !== b[i])
72
+ }
73
+ return values.value[key] !== initial.value[key]
74
+ }
75
+
76
+ function anyDirty(): boolean {
77
+ return dirty('siteName') || dirty('deployHookUrl') || dirty('defaultLocale') || dirty('locales')
78
+ }
79
+
80
+ async function save() {
81
+ if (!anyDirty()) return
82
+ if (localesValid.value) {
83
+ error.value = localesValid.value
84
+ return
85
+ }
86
+ saving.value = true
87
+ saved.value = false
88
+ error.value = null
89
+ try {
90
+ values.value.locales = parseLocales(localesText.value)
91
+ if (dirty('siteName')) await adminApi.put('/api/vulse/settings/siteName', { value: values.value.siteName })
92
+ if (dirty('deployHookUrl')) await adminApi.put('/api/vulse/settings/deployHookUrl', { value: values.value.deployHookUrl })
93
+ if (dirty('defaultLocale')) await adminApi.put('/api/vulse/settings/defaultLocale', { value: values.value.defaultLocale })
94
+ if (dirty('locales')) await adminApi.put('/api/vulse/settings/locales', { value: values.value.locales })
95
+ initial.value = { ...values.value, locales: [...values.value.locales] }
96
+ saved.value = true
97
+ } catch (e) {
98
+ error.value = e instanceof AdminApiError ? e.message : 'Save failed'
99
+ } finally {
100
+ saving.value = false
101
+ }
102
+ }
103
+
104
+ function onInput() {
105
+ saved.value = false
106
+ error.value = null
107
+ }
108
+
109
+ onMounted(load)
110
+ </script>
111
+
112
+ <template>
113
+ <form class="vulse-panel max-w-md space-y-4" @submit.prevent="save">
114
+ <p v-if="loading" class="text-sm text-zinc-500">Loading…</p>
115
+ <template v-else>
116
+ <label class="block">
117
+ <span class="text-sm font-medium text-zinc-700">Site name</span>
118
+ <input
119
+ v-model="values.siteName"
120
+ class="mt-1 w-full rounded border border-zinc-300 px-3 py-2 text-sm"
121
+ @input="onInput"
122
+ />
123
+ </label>
124
+ <label class="block">
125
+ <span class="text-sm font-medium text-zinc-700">Deploy hook URL</span>
126
+ <input
127
+ v-model="values.deployHookUrl"
128
+ class="mt-1 w-full rounded border border-zinc-300 px-3 py-2 text-sm"
129
+ placeholder="https://api.cloudflare.com/client/v4/pages/…/deploy_hooks/…"
130
+ @input="onInput"
131
+ />
132
+ <span class="mt-1 block text-xs text-zinc-500">
133
+ Called after publishing entries to trigger a rebuild (e.g. a Cloudflare Pages deploy hook).
134
+ </span>
135
+ </label>
136
+
137
+ <fieldset class="space-y-3 rounded border border-zinc-200 bg-white p-4">
138
+ <legend class="text-sm font-semibold text-zinc-700">Locales</legend>
139
+ <p class="text-xs text-zinc-500">
140
+ Each entry can be authored once per supported locale. The default locale is used when callers don't pass one.
141
+ </p>
142
+ <label class="block">
143
+ <span class="text-sm font-medium text-zinc-700">Supported locales</span>
144
+ <input
145
+ v-model="localesText"
146
+ class="mt-1 w-full rounded border border-zinc-300 px-3 py-2 font-mono text-sm"
147
+ placeholder="default, en, nb-NO"
148
+ @input="onInput"
149
+ />
150
+ <span class="mt-1 block text-xs text-zinc-500">
151
+ Comma-separated BCP-47 codes (e.g. <code>en</code>, <code>nb-NO</code>). The literal <code>default</code> is allowed for sites that don't ship multilingual content.
152
+ </span>
153
+ </label>
154
+ <label class="block">
155
+ <span class="text-sm font-medium text-zinc-700">Default locale</span>
156
+ <select
157
+ v-if="supportedLocales.length"
158
+ v-model="values.defaultLocale"
159
+ class="mt-1 w-full rounded border border-zinc-300 px-3 py-2 font-mono text-sm"
160
+ @change="onInput"
161
+ >
162
+ <option v-for="code in supportedLocales" :key="code" :value="code">{{ code }}</option>
163
+ </select>
164
+ <input
165
+ v-else
166
+ v-model="values.defaultLocale"
167
+ class="mt-1 w-full rounded border border-zinc-300 px-3 py-2 font-mono text-sm"
168
+ disabled
169
+ placeholder="Add supported locales first"
170
+ />
171
+ </label>
172
+ <p v-if="localesValid" class="rounded bg-red-50 px-3 py-2 text-sm text-red-700">{{ localesValid }}</p>
173
+ </fieldset>
174
+
175
+ <p v-if="error" class="rounded bg-red-50 px-3 py-2 text-sm text-red-700">{{ error }}</p>
176
+ <div class="flex items-center gap-3 pt-2">
177
+ <button
178
+ type="submit"
179
+ class="vulse-button-primary rounded px-4 py-2 text-sm font-medium disabled:opacity-50"
180
+ :disabled="saving || !anyDirty() || !!localesValid"
181
+ >
182
+ {{ saving ? 'Saving…' : 'Save' }}
183
+ </button>
184
+ <span v-if="saved" class="text-sm text-zinc-500">Saved.</span>
185
+ <span v-else-if="anyDirty()" class="text-sm text-amber-600">Unsaved changes</span>
186
+ </div>
187
+ </template>
188
+ </form>
189
+ </template>
@@ -0,0 +1,152 @@
1
+ <script setup lang="ts">
2
+ import { onMounted, ref, watch } from 'vue'
3
+ import CollectionKindIcon from './CollectionKindIcon.vue'
4
+
5
+ const logoUrl = new URL('../assets/logo-mark.svg', import.meta.url).href
6
+
7
+ const props = defineProps<{
8
+ collections: { name: string; label: string; singleton?: boolean }[]
9
+ activePath?: string
10
+ userEmail?: string
11
+ isAdmin?: boolean
12
+ }>()
13
+
14
+ const schemaOpen = ref(false)
15
+
16
+ const SCHEMA_OPEN_KEY = 'vulse.sidebar.schema.open'
17
+
18
+ onMounted(() => {
19
+ try {
20
+ schemaOpen.value = localStorage.getItem(SCHEMA_OPEN_KEY) === '1'
21
+ } catch {
22
+ // ignore
23
+ }
24
+ })
25
+
26
+ watch(schemaOpen, (v) => {
27
+ try { localStorage.setItem(SCHEMA_OPEN_KEY, v ? '1' : '0') } catch { /* ignore */ }
28
+ })
29
+
30
+ function navClass(href: string) {
31
+ const active = props.activePath === href || (href !== '/admin' && props.activePath?.startsWith(href))
32
+ return ['vulse-nav-link rounded-xl text-sm text-zinc-800', active && 'vulse-nav-link-active'].filter(Boolean)
33
+ }
34
+
35
+ function subNavClass(href: string, exact = false) {
36
+ const active = exact ? props.activePath === href : props.activePath?.startsWith(href)
37
+ return ['block rounded px-2 py-1.5 text-sm hover:bg-zinc-100', active && 'bg-zinc-100 font-medium']
38
+ .filter(Boolean)
39
+ .join(' ')
40
+ }
41
+
42
+ async function signOut() {
43
+ await fetch('/api/auth/sign-out', {
44
+ method: 'POST',
45
+ credentials: 'same-origin',
46
+ headers: { 'content-type': 'application/json' },
47
+ body: '{}',
48
+ })
49
+ window.location.href = '/admin/login'
50
+ }
51
+ </script>
52
+
53
+ <template>
54
+ <aside class="w-[var(--vulse-sidebar-width)] min-h-screen border-r border-zinc-200 bg-white shrink-0">
55
+ <div class="px-4 py-3 font-semibold tracking-tight flex items-center gap-2">
56
+ <img class="h-8 w-8" :src="logoUrl" alt="Vulse" />
57
+ Vulse
58
+ </div>
59
+
60
+ <div v-if="userEmail" class="border-y border-zinc-100 px-4 py-2 text-xs">
61
+ <div class="font-mono text-zinc-700">{{ userEmail }}</div>
62
+ <button type="button" class="mt-1 text-zinc-500 hover:text-zinc-900" @click="signOut">
63
+ Sign out
64
+ </button>
65
+ </div>
66
+
67
+ <nav class="px-2 pb-6">
68
+ <div class="px-2 pt-2 text-xs uppercase tracking-wide text-zinc-500">Collections</div>
69
+ <a
70
+ v-for="c in collections"
71
+ :key="`coll-${c.name}`"
72
+ :href="`/admin/collections/${c.name}`"
73
+ :class="navClass(`/admin/collections/${c.name}`)"
74
+ >
75
+ <span class="flex items-center gap-2">
76
+ <CollectionKindIcon :singleton="c.singleton" />
77
+ <span>{{ c.label }}</span>
78
+ </span>
79
+ </a>
80
+
81
+ <div class="px-2 pt-4 text-xs uppercase tracking-wide text-zinc-500">Forms</div>
82
+ <a href="/admin/forms" :class="navClass('/admin/forms')">
83
+ <span class="flex items-center gap-2">
84
+ <svg class="h-4 w-4 shrink-0 text-zinc-500" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true">
85
+ <path d="M2.5 4A1.5 1.5 0 0 1 4 2.5h12A1.5 1.5 0 0 1 17.5 4v12a1.5 1.5 0 0 1-1.5 1.5H4A1.5 1.5 0 0 1 2.5 16V4ZM4 4v12h12V4H4Zm2 2h8v1.5H6V6Zm0 3h8v1.5H6V9Zm0 3h5v1.5H6V12Z" />
86
+ </svg>
87
+ <span>Forms</span>
88
+ </span>
89
+ </a>
90
+
91
+ <div class="px-2 pt-4 text-xs uppercase tracking-wide text-zinc-500">Media</div>
92
+ <a href="/admin/media" :class="navClass('/admin/media')">
93
+ <span class="flex items-center gap-2">
94
+ <svg class="h-4 w-4 shrink-0 text-zinc-500" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true">
95
+ <path fill-rule="evenodd" d="M3 5a2 2 0 0 1 2-2h10a2 2 0 0 1 2 2v10a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5Zm2 0v8.586l2.293-2.293a1 1 0 0 1 1.414 0L11 13.586l2.293-2.293a1 1 0 0 1 1.414 0L15 11.586V5H5Zm9 3a1 1 0 1 1-2 0 1 1 0 0 1 2 0Z" clip-rule="evenodd" />
96
+ </svg>
97
+ <span>Assets</span>
98
+ </span>
99
+ </a>
100
+
101
+ <div class="px-2 pt-4 text-xs uppercase tracking-wide text-zinc-500">Schema</div>
102
+ <button
103
+ type="button"
104
+ class="flex w-full items-center gap-1 rounded px-2 py-1.5 text-left text-sm hover:bg-zinc-100"
105
+ :aria-expanded="schemaOpen"
106
+ @click="schemaOpen = !schemaOpen"
107
+ >
108
+ <span class="inline-block w-3 text-zinc-400">{{ schemaOpen ? '▾' : '▸' }}</span>
109
+ <span>Collections</span>
110
+ </button>
111
+ <div v-if="schemaOpen" class="ml-4">
112
+ <a
113
+ v-for="c in collections"
114
+ :key="`schema-${c.name}`"
115
+ :href="`/admin/schema/${c.name}`"
116
+ :class="navClass(`/admin/schema/${c.name}`)"
117
+ >
118
+ <span class="flex items-center gap-2">
119
+ <CollectionKindIcon :singleton="c.singleton" />
120
+ <span>{{ c.label }}</span>
121
+ </span>
122
+ </a>
123
+ <a href="/admin/schema/new" :class="navClass('/admin/schema/new')" class="text-zinc-600">
124
+ + New collection
125
+ </a>
126
+ </div>
127
+ <a
128
+ v-if="isAdmin"
129
+ href="/admin/settings/sets"
130
+ :class="subNavClass('/admin/settings/sets')"
131
+ >
132
+ Sets
133
+ </a>
134
+ <a
135
+ v-if="isAdmin"
136
+ href="/admin/settings/globals"
137
+ :class="subNavClass('/admin/settings/globals')"
138
+ >
139
+ Globals
140
+ </a>
141
+
142
+ <template v-if="isAdmin">
143
+ <div class="px-2 pt-4 text-xs uppercase tracking-wide text-zinc-500">Users</div>
144
+ <a href="/admin/users" :class="subNavClass('/admin/users')">Users</a>
145
+
146
+ <div class="px-2 pt-4 text-xs uppercase tracking-wide text-zinc-500">Settings</div>
147
+ <a href="/admin/settings" :class="subNavClass('/admin/settings', true)">Site</a>
148
+ <a href="/admin/settings/auth" :class="subNavClass('/admin/settings/auth', true)">Auth</a>
149
+ </template>
150
+ </nav>
151
+ </aside>
152
+ </template>
@@ -0,0 +1,45 @@
1
+ <script setup lang="ts">
2
+ import { onMounted, ref } from 'vue'
3
+ import { adminApi } from '../client/api.js'
4
+
5
+ const props = defineProps<{ formHandle: string; submissionId: string }>()
6
+
7
+ interface SubmissionRow {
8
+ id: string
9
+ payload: Record<string, unknown>
10
+ fileRefs: { field: string; mediaId: string }[]
11
+ status: string
12
+ error: string | null
13
+ createdAt: string
14
+ }
15
+
16
+ const row = ref<SubmissionRow | null>(null)
17
+
18
+ onMounted(async () => {
19
+ row.value = await adminApi.get<SubmissionRow>(`/api/vulse/forms/${props.formHandle}/submissions/${props.submissionId}`)
20
+ })
21
+
22
+ async function destroy() {
23
+ if (!confirm('Delete this submission?')) return
24
+ await adminApi.delete(`/api/vulse/forms/${props.formHandle}/submissions/${props.submissionId}`)
25
+ window.location.href = `/admin/forms/${props.formHandle}/submissions`
26
+ }
27
+ </script>
28
+
29
+ <template>
30
+ <div v-if="row">
31
+ <div class="mb-6 flex items-center justify-between">
32
+ <h1 class="text-2xl font-semibold">Submission</h1>
33
+ <button type="button" class="rounded border border-red-200 px-3 py-1 text-sm text-red-700" @click="destroy">Delete</button>
34
+ </div>
35
+ <p class="mb-2 text-sm text-zinc-500">Status: <span class="rounded bg-zinc-100 px-2 py-0.5">{{ row.status }}</span></p>
36
+ <p v-if="row.error" class="mb-4 text-sm text-red-600">{{ row.error }}</p>
37
+ <pre class="rounded-xl border border-zinc-200 bg-white p-4 text-xs">{{ JSON.stringify(row.payload, null, 2) }}</pre>
38
+ <div v-if="row.fileRefs.length" class="mt-4">
39
+ <h2 class="text-sm font-semibold">Files</h2>
40
+ <ul class="mt-2 text-sm">
41
+ <li v-for="f in row.fileRefs" :key="f.mediaId">{{ f.field }}: {{ f.mediaId }}</li>
42
+ </ul>
43
+ </div>
44
+ </div>
45
+ </template>
@@ -0,0 +1,89 @@
1
+ <script setup lang="ts">
2
+ import { computed, onMounted, ref } from 'vue'
3
+ import { adminApi } from '../client/api.js'
4
+
5
+ const props = defineProps<{ formHandle: string }>()
6
+
7
+ interface SubmissionRow {
8
+ id: string
9
+ payload: Record<string, unknown>
10
+ status: string
11
+ createdAt: string
12
+ }
13
+
14
+ const rows = ref<SubmissionRow[]>([])
15
+ const selected = ref<Set<string>>(new Set())
16
+ const loading = ref(true)
17
+
18
+ const previewField = computed(() => {
19
+ const first = rows.value[0]
20
+ if (!first) return null
21
+ const entry = Object.entries(first.payload).find(([k]) => !k.startsWith('_'))
22
+ return entry?.[1]
23
+ })
24
+
25
+ onMounted(async () => {
26
+ rows.value = await adminApi.get<SubmissionRow[]>(`/api/vulse/forms/${props.formHandle}/submissions`)
27
+ loading.value = false
28
+ })
29
+
30
+ function toggle(id: string) {
31
+ if (selected.value.has(id)) selected.value.delete(id)
32
+ else selected.value.add(id)
33
+ }
34
+
35
+ async function bulkDelete() {
36
+ if (selected.value.size === 0 || !confirm(`Delete ${selected.value.size} submission(s)?`)) return
37
+ await adminApi.post(`/api/vulse/forms/${props.formHandle}/submissions/delete`, { ids: [...selected.value] })
38
+ rows.value = rows.value.filter((r) => !selected.value.has(r.id))
39
+ selected.value.clear()
40
+ }
41
+
42
+ function exportCsv() {
43
+ if (rows.value.length === 0) return
44
+ const keys = [...new Set(rows.value.flatMap((r) => Object.keys(r.payload)))]
45
+ const lines = [keys.join(',')]
46
+ for (const r of rows.value) {
47
+ lines.push(keys.map((k) => JSON.stringify(r.payload[k] ?? '')).join(','))
48
+ }
49
+ const blob = new Blob([lines.join('\n')], { type: 'text/csv' })
50
+ const a = document.createElement('a')
51
+ a.href = URL.createObjectURL(blob)
52
+ a.download = `${props.formHandle}-submissions.csv`
53
+ a.click()
54
+ }
55
+ </script>
56
+
57
+ <template>
58
+ <div>
59
+ <div class="mb-6 flex items-center justify-between">
60
+ <h1 class="text-2xl font-semibold">Submissions</h1>
61
+ <div class="flex gap-2">
62
+ <button type="button" class="rounded border border-zinc-300 px-3 py-1 text-sm" @click="exportCsv">Export CSV</button>
63
+ <button type="button" class="rounded border border-red-200 px-3 py-1 text-sm text-red-700" :disabled="selected.size === 0" @click="bulkDelete">Delete selected</button>
64
+ </div>
65
+ </div>
66
+ <p v-if="loading" class="text-sm text-zinc-500">Loading…</p>
67
+ <table v-else class="w-full text-sm">
68
+ <thead>
69
+ <tr class="border-b border-zinc-200 text-left text-zinc-500">
70
+ <th class="py-2 pr-2"></th>
71
+ <th class="py-2 pr-4">Preview</th>
72
+ <th class="py-2 pr-4">Status</th>
73
+ <th class="py-2 pr-4">Created</th>
74
+ <th class="py-2"></th>
75
+ </tr>
76
+ </thead>
77
+ <tbody>
78
+ <tr v-for="r in rows" :key="r.id" class="border-b border-zinc-100">
79
+ <td class="py-2 pr-2"><input type="checkbox" :checked="selected.has(r.id)" @change="toggle(r.id)" /></td>
80
+ <td class="py-2 pr-4 max-w-xs truncate">{{ Object.values(r.payload)[0] }}</td>
81
+ <td class="py-2 pr-4"><span class="rounded bg-zinc-100 px-2 py-0.5 text-xs">{{ r.status }}</span></td>
82
+ <td class="py-2 pr-4 text-xs text-zinc-500">{{ new Date(r.createdAt).toLocaleString() }}</td>
83
+ <td class="py-2"><a :href="`/admin/forms/${formHandle}/submissions/${r.id}`" class="hover:underline">View</a></td>
84
+ </tr>
85
+ </tbody>
86
+ </table>
87
+ <p v-if="!loading && rows.length === 0" class="text-sm text-zinc-500">No submissions yet.</p>
88
+ </div>
89
+ </template>