@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,173 @@
1
+ <script setup lang="ts">
2
+ import type { FieldDescriptor } from '../../client/form-from-zod'
3
+ import FieldRenderer from './FieldRenderer.vue'
4
+
5
+ const props = defineProps<{
6
+ modelValue: Record<string, unknown>[]
7
+ label: string
8
+ itemFields: FieldDescriptor[]
9
+ mode?: 'table' | 'stacked'
10
+ minRows?: number
11
+ maxRows?: number
12
+ addLabel?: string
13
+ tree?: boolean
14
+ linkCollections?: string[]
15
+ }>()
16
+
17
+ const emit = defineEmits<{ (e: 'update:modelValue', v: Record<string, unknown>[]): void }>()
18
+
19
+ function rows(): Record<string, unknown>[] {
20
+ return props.modelValue ?? []
21
+ }
22
+
23
+ function canAdd(): boolean {
24
+ if (props.maxRows === undefined) return true
25
+ return rows().length < props.maxRows
26
+ }
27
+
28
+ function canRemove(): boolean {
29
+ if (props.minRows === undefined) return true
30
+ return rows().length > props.minRows
31
+ }
32
+
33
+ function updateCell(rowIndex: number, key: string, value: unknown) {
34
+ const next = [...rows()]
35
+ next[rowIndex] = { ...next[rowIndex], [key]: value }
36
+ emit('update:modelValue', next)
37
+ }
38
+
39
+ function addRow() {
40
+ if (!canAdd()) return
41
+ emit('update:modelValue', [...rows(), {}])
42
+ }
43
+
44
+ function removeRow(index: number) {
45
+ if (!canRemove()) return
46
+ const next = [...rows()]
47
+ next.splice(index, 1)
48
+ emit('update:modelValue', next)
49
+ }
50
+
51
+ function moveRow(index: number, direction: -1 | 1) {
52
+ const next = [...rows()]
53
+ const target = index + direction
54
+ if (target < 0 || target >= next.length) return
55
+ const [moved] = next.splice(index, 1)
56
+ next.splice(target, 0, moved!)
57
+ emit('update:modelValue', next)
58
+ }
59
+ </script>
60
+
61
+ <template>
62
+ <div class="space-y-2">
63
+ <div class="text-sm text-zinc-600">{{ label }}</div>
64
+
65
+ <div v-if="mode === 'table' && itemFields.length" class="overflow-x-auto rounded border">
66
+ <table class="min-w-full text-sm">
67
+ <thead class="bg-zinc-50 text-left text-xs text-zinc-600">
68
+ <tr>
69
+ <th class="w-16 px-2 py-2"></th>
70
+ <th v-for="f in itemFields" :key="f.path" class="px-3 py-2 font-medium">{{ f.label ?? f.path }}</th>
71
+ <th class="w-16 px-2 py-2"></th>
72
+ </tr>
73
+ </thead>
74
+ <tbody>
75
+ <tr v-for="(item, i) in rows()" :key="i" class="border-t border-zinc-200 align-top">
76
+ <td class="px-2 py-2">
77
+ <div class="flex flex-col gap-1">
78
+ <button
79
+ type="button"
80
+ class="text-xs text-zinc-500 hover:text-zinc-900 disabled:opacity-30"
81
+ :disabled="i === 0"
82
+ @click="moveRow(i, -1)"
83
+ >
84
+
85
+ </button>
86
+ <button
87
+ type="button"
88
+ class="text-xs text-zinc-500 hover:text-zinc-900 disabled:opacity-30"
89
+ :disabled="i === rows().length - 1"
90
+ @click="moveRow(i, 1)"
91
+ >
92
+
93
+ </button>
94
+ </div>
95
+ </td>
96
+ <td v-for="f in itemFields" :key="f.path" class="px-3 py-2">
97
+ <FieldRenderer
98
+ :field="f"
99
+ :model-value="item?.[f.path]"
100
+ :tree="tree"
101
+ :link-collections="linkCollections"
102
+ @update:modelValue="updateCell(i, f.path, $event)"
103
+ />
104
+ </td>
105
+ <td class="px-2 py-2">
106
+ <button
107
+ type="button"
108
+ class="text-xs text-red-600 disabled:opacity-30"
109
+ :disabled="!canRemove()"
110
+ @click="removeRow(i)"
111
+ >
112
+ Remove
113
+ </button>
114
+ </td>
115
+ </tr>
116
+ </tbody>
117
+ </table>
118
+ </div>
119
+
120
+ <div v-else class="space-y-3">
121
+ <div v-for="(item, i) in rows()" :key="i" class="space-y-2 rounded border p-3">
122
+ <div class="flex items-center justify-between">
123
+ <span class="text-xs font-medium text-zinc-500">Row {{ i + 1 }}</span>
124
+ <div class="flex items-center gap-2">
125
+ <button
126
+ type="button"
127
+ class="text-xs text-zinc-500 hover:text-zinc-900 disabled:opacity-30"
128
+ :disabled="i === 0"
129
+ @click="moveRow(i, -1)"
130
+ >
131
+ Move up
132
+ </button>
133
+ <button
134
+ type="button"
135
+ class="text-xs text-zinc-500 hover:text-zinc-900 disabled:opacity-30"
136
+ :disabled="i === rows().length - 1"
137
+ @click="moveRow(i, 1)"
138
+ >
139
+ Move down
140
+ </button>
141
+ <button
142
+ type="button"
143
+ class="text-xs text-red-600 disabled:opacity-30"
144
+ :disabled="!canRemove()"
145
+ @click="removeRow(i)"
146
+ >
147
+ Remove
148
+ </button>
149
+ </div>
150
+ </div>
151
+ <FieldRenderer
152
+ v-for="f in itemFields"
153
+ :key="f.path"
154
+ :field="f"
155
+ :model-value="item?.[f.path]"
156
+ :tree="tree"
157
+ :link-collections="linkCollections"
158
+ @update:modelValue="updateCell(i, f.path, $event)"
159
+ />
160
+ </div>
161
+ </div>
162
+
163
+ <button
164
+ v-if="canAdd()"
165
+ type="button"
166
+ class="text-sm rounded border px-3 py-1"
167
+ @click="addRow"
168
+ >
169
+ {{ addLabel || 'Add row' }}
170
+ </button>
171
+ <p v-else-if="maxRows" class="text-xs text-zinc-500">Maximum of {{ maxRows }} rows reached.</p>
172
+ </div>
173
+ </template>
@@ -0,0 +1,219 @@
1
+ <script setup lang="ts">
2
+ import { computed, ref, watch } from 'vue'
3
+ import type { LinkValue } from '../../../core/blueprints/definition.js'
4
+ import { entryOptionLabel, useEntrySearch } from '../../composables/useEntrySearch.js'
5
+
6
+ const props = defineProps<{
7
+ modelValue: LinkValue | null | undefined
8
+ label: string
9
+ collections?: string[]
10
+ tree?: boolean
11
+ }>()
12
+
13
+ const emit = defineEmits<{ (e: 'update:modelValue', v: LinkValue | null): void }>()
14
+
15
+ type LinkMode = LinkValue['type']
16
+
17
+ const mode = ref<LinkMode>('url')
18
+ const url = ref('')
19
+ const entryCollection = ref(props.collections?.[0] ?? '')
20
+ const entryLabel = ref('')
21
+
22
+ const entryCollections = computed(() => props.collections ?? [])
23
+
24
+ const {
25
+ open,
26
+ query,
27
+ options,
28
+ loading,
29
+ resolveLabel,
30
+ openDropdown,
31
+ closeDropdown,
32
+ onBlur,
33
+ } = useEntrySearch(() => {
34
+ const col = entryCollection.value || entryCollections.value[0]
35
+ return col ? [col] : []
36
+ })
37
+
38
+ function emitValue(value: LinkValue | null) {
39
+ emit('update:modelValue', value)
40
+ }
41
+
42
+ function onModeChange(next: LinkMode) {
43
+ mode.value = next
44
+ if (next === 'url') {
45
+ emitValue(url.value.trim() ? { type: 'url', url: url.value.trim() } : null)
46
+ return
47
+ }
48
+ if (next === 'first-child') {
49
+ emitValue({ type: 'first-child' })
50
+ return
51
+ }
52
+ emitValue(null)
53
+ }
54
+
55
+ function onUrlInput() {
56
+ const trimmed = url.value.trim()
57
+ emitValue(trimmed ? { type: 'url', url: trimmed } : null)
58
+ }
59
+
60
+ function selectEntry(option: { id: string; collection: string; title?: string; email?: string }) {
61
+ entryCollection.value = option.collection
62
+ entryLabel.value = entryOptionLabel(option)
63
+ emitValue({ type: 'entry', entryId: option.id, collection: option.collection })
64
+ query.value = ''
65
+ closeDropdown()
66
+ }
67
+
68
+ function clearEntry() {
69
+ entryLabel.value = ''
70
+ emitValue(null)
71
+ }
72
+
73
+ watch(
74
+ () => props.modelValue,
75
+ (value) => {
76
+ if (!value) {
77
+ mode.value = 'url'
78
+ url.value = ''
79
+ entryLabel.value = ''
80
+ return
81
+ }
82
+ mode.value = value.type
83
+ if (value.type === 'url') {
84
+ url.value = value.url
85
+ return
86
+ }
87
+ if (value.type === 'entry') {
88
+ entryCollection.value = value.collection
89
+ void resolveLabel(value.entryId, value.collection).then((label) => {
90
+ entryLabel.value = label
91
+ })
92
+ }
93
+ },
94
+ { immediate: true },
95
+ )
96
+
97
+ watch(
98
+ entryCollections,
99
+ (cols) => {
100
+ if (cols.length === 1) entryCollection.value = cols[0]!
101
+ },
102
+ { immediate: true },
103
+ )
104
+ </script>
105
+
106
+ <template>
107
+ <div class="block space-y-2">
108
+ <span class="text-sm text-zinc-600">{{ label }}</span>
109
+
110
+ <div class="flex flex-wrap gap-2">
111
+ <button
112
+ type="button"
113
+ class="rounded border px-3 py-1 text-sm"
114
+ :class="mode === 'url' ? 'border-zinc-900 bg-zinc-900 text-white' : 'border-zinc-300 bg-white text-zinc-700'"
115
+ @click="onModeChange('url')"
116
+ >
117
+ URL
118
+ </button>
119
+ <button
120
+ type="button"
121
+ class="rounded border px-3 py-1 text-sm"
122
+ :class="mode === 'entry' ? 'border-zinc-900 bg-zinc-900 text-white' : 'border-zinc-300 bg-white text-zinc-700'"
123
+ @click="onModeChange('entry')"
124
+ >
125
+ Entry
126
+ </button>
127
+ <button
128
+ v-if="tree"
129
+ type="button"
130
+ class="rounded border px-3 py-1 text-sm"
131
+ :class="mode === 'first-child' ? 'border-zinc-900 bg-zinc-900 text-white' : 'border-zinc-300 bg-white text-zinc-700'"
132
+ @click="onModeChange('first-child')"
133
+ >
134
+ First child
135
+ </button>
136
+ </div>
137
+
138
+ <input
139
+ v-if="mode === 'url'"
140
+ v-model="url"
141
+ type="url"
142
+ class="vulse-input mt-1 bg-white"
143
+ placeholder="https://example.com or /about"
144
+ @input="onUrlInput"
145
+ />
146
+
147
+ <div v-else-if="mode === 'entry'" class="space-y-2">
148
+ <select
149
+ v-if="entryCollections.length > 1"
150
+ v-model="entryCollection"
151
+ class="vulse-input bg-white text-sm"
152
+ @change="clearEntry()"
153
+ >
154
+ <option v-for="col in entryCollections" :key="col" :value="col">{{ col }}</option>
155
+ </select>
156
+
157
+ <div class="relative" @blur="onBlur">
158
+ <div class="flex gap-2">
159
+ <button
160
+ type="button"
161
+ class="vulse-input flex flex-1 items-center justify-between bg-white text-left"
162
+ :class="open && 'border-zinc-400'"
163
+ @click="open ? closeDropdown() : openDropdown()"
164
+ >
165
+ <span :class="modelValue?.type === 'entry' ? 'text-zinc-900' : 'text-zinc-400'">
166
+ {{
167
+ modelValue?.type === 'entry'
168
+ ? entryLabel || modelValue.entryId
169
+ : 'Select entry…'
170
+ }}
171
+ </span>
172
+ <span class="text-xs text-zinc-400">{{ open ? '▴' : '▾' }}</span>
173
+ </button>
174
+ <button
175
+ v-if="modelValue?.type === 'entry'"
176
+ type="button"
177
+ class="rounded border border-zinc-300 bg-white px-3 py-2 text-sm text-zinc-600 hover:bg-zinc-50"
178
+ @click="clearEntry"
179
+ >
180
+ Clear
181
+ </button>
182
+ </div>
183
+
184
+ <div
185
+ v-if="open"
186
+ class="absolute z-20 mt-1 w-full overflow-hidden rounded-md border border-zinc-200 bg-white shadow-lg"
187
+ >
188
+ <div class="border-b border-zinc-200 p-2">
189
+ <input
190
+ v-model="query"
191
+ type="search"
192
+ class="vulse-input bg-white"
193
+ placeholder="Search entries…"
194
+ autofocus
195
+ @keydown.esc.prevent="closeDropdown()"
196
+ />
197
+ </div>
198
+ <ul class="max-h-48 overflow-auto py-1 text-sm">
199
+ <li v-if="loading" class="px-3 py-2 text-zinc-500">Loading…</li>
200
+ <li v-else-if="options.length === 0" class="px-3 py-2 text-zinc-500">No matches</li>
201
+ <li v-for="option in options" v-else :key="option.id">
202
+ <button
203
+ type="button"
204
+ class="flex w-full items-center px-3 py-2 text-left hover:bg-zinc-100"
205
+ @click="selectEntry(option)"
206
+ >
207
+ {{ entryOptionLabel(option) }}
208
+ </button>
209
+ </li>
210
+ </ul>
211
+ </div>
212
+ </div>
213
+ </div>
214
+
215
+ <p v-else-if="mode === 'first-child'" class="text-sm text-zinc-500">
216
+ Links to the first child entry in this collection tree.
217
+ </p>
218
+ </div>
219
+ </template>
@@ -0,0 +1,69 @@
1
+ <script setup lang="ts">
2
+ import { ref, onMounted, watch } from 'vue'
3
+ import { adminApi } from '../../client/api.js'
4
+ import MediaPicker from '../MediaPicker.vue'
5
+
6
+ const props = defineProps<{ modelValue: unknown; label?: string }>()
7
+ const emit = defineEmits<{ (e: 'update:modelValue', v: string | null): void }>()
8
+
9
+ interface MediaItem {
10
+ id: string
11
+ alt: string | null
12
+ deliveryUrl: string | null
13
+ previewUrl: string
14
+ }
15
+
16
+ const showPicker = ref(false)
17
+ const preview = ref<MediaItem | null>(null)
18
+
19
+ const mediaId = () => (typeof props.modelValue === 'string' && props.modelValue ? props.modelValue : null)
20
+
21
+ function previewSrc(item: MediaItem): string {
22
+ return item.deliveryUrl ?? item.previewUrl
23
+ }
24
+
25
+ async function loadPreview() {
26
+ const id = mediaId()
27
+ if (!id) {
28
+ preview.value = null
29
+ return
30
+ }
31
+ const list = await adminApi.get<MediaItem[]>('/api/vulse/media')
32
+ preview.value = list.find((m) => m.id === id) ?? null
33
+ }
34
+
35
+ onMounted(loadPreview)
36
+ watch(() => props.modelValue, loadPreview)
37
+
38
+ function pick(id: string) {
39
+ emit('update:modelValue', id)
40
+ showPicker.value = false
41
+ loadPreview()
42
+ }
43
+ </script>
44
+
45
+ <template>
46
+ <div class="space-y-2">
47
+ <div v-if="label" class="vulse-label">{{ label }}</div>
48
+ <div class="flex items-center gap-3">
49
+ <img
50
+ v-if="preview"
51
+ :src="previewSrc(preview)"
52
+ :alt="preview.alt ?? ''"
53
+ class="h-20 w-20 rounded border object-cover"
54
+ />
55
+ <button type="button" class="rounded border border-zinc-300 bg-white px-3 py-2 text-sm hover:bg-zinc-50" @click="showPicker = true">
56
+ {{ mediaId() ? 'Change…' : 'Pick media…' }}
57
+ </button>
58
+ <button
59
+ v-if="mediaId()"
60
+ type="button"
61
+ class="text-sm text-red-600 hover:underline"
62
+ @click="emit('update:modelValue', null)"
63
+ >
64
+ Clear
65
+ </button>
66
+ </div>
67
+ <MediaPicker v-if="showPicker" @pick="pick" @close="showPicker = false" />
68
+ </div>
69
+ </template>
@@ -0,0 +1,12 @@
1
+ <script setup lang="ts">
2
+ defineProps<{ modelValue: number | null | undefined; label: string; required?: boolean }>()
3
+ defineEmits<{ (e: 'update:modelValue', v: number | null): void }>()
4
+ </script>
5
+ <template>
6
+ <label class="block">
7
+ <span class="text-sm text-zinc-600">{{ label }}<span v-if="required" class="text-red-600">*</span></span>
8
+ <input type="number" :value="modelValue ?? ''"
9
+ @input="$emit('update:modelValue', ($event.target as HTMLInputElement).value === '' ? null : Number(($event.target as HTMLInputElement).value))"
10
+ class="mt-1 w-full rounded border px-3 py-2" />
11
+ </label>
12
+ </template>
@@ -0,0 +1,18 @@
1
+ <script setup lang="ts">
2
+ import type { FieldDescriptor } from '../../client/form-from-zod'
3
+ import FieldRenderer from './FieldRenderer.vue'
4
+ defineProps<{ modelValue: Record<string, unknown>; label: string; fields: FieldDescriptor[] }>()
5
+ const emit = defineEmits<{ (e: 'update:modelValue', v: Record<string, unknown>): void }>()
6
+ function set(path: string, v: unknown, current: Record<string, unknown>) {
7
+ emit('update:modelValue', { ...current, [path]: v })
8
+ }
9
+ </script>
10
+ <template>
11
+ <fieldset class="border rounded p-4 space-y-3">
12
+ <legend class="text-sm font-medium px-2">{{ label }}</legend>
13
+ <FieldRenderer v-for="f in fields" :key="f.path"
14
+ :field="f"
15
+ :model-value="modelValue?.[f.path]"
16
+ @update:modelValue="set(f.path, $event, modelValue ?? {})" />
17
+ </fieldset>
18
+ </template>
@@ -0,0 +1,170 @@
1
+ <script setup lang="ts">
2
+ import { ref, watch } from 'vue'
3
+ import { adminApi } from '../../client/api.js'
4
+
5
+ const props = defineProps<{ modelValue: string | null; label: string; refTarget: string }>()
6
+ const emit = defineEmits<{ (e: 'update:modelValue', v: string | null): void }>()
7
+
8
+ interface RefOption {
9
+ id: string
10
+ title?: string
11
+ email?: string
12
+ }
13
+
14
+ const open = ref(false)
15
+ const query = ref('')
16
+ const options = ref<RefOption[]>([])
17
+ const loading = ref(false)
18
+ const selectedLabel = ref('')
19
+
20
+ function optionLabel(option: RefOption): string {
21
+ return option.title ?? option.email ?? option.id
22
+ }
23
+
24
+ async function loadOptions(search = '') {
25
+ loading.value = true
26
+ try {
27
+ if (props.refTarget === 'user') {
28
+ options.value = await adminApi.get<RefOption[]>(
29
+ `/api/vulse/users?q=${encodeURIComponent(search)}`,
30
+ )
31
+ return
32
+ }
33
+
34
+ const rows = await adminApi.get<{ id: string; content?: { title?: string }; slug?: string }[]>(
35
+ `/api/vulse/entries/${props.refTarget}`,
36
+ )
37
+ const needle = search.trim().toLowerCase()
38
+ options.value = rows
39
+ .map((row) => ({
40
+ id: row.id,
41
+ title: row.content?.title ?? row.slug ?? row.id,
42
+ }))
43
+ .filter((row) => {
44
+ if (!needle) return true
45
+ return optionLabel(row).toLowerCase().includes(needle)
46
+ })
47
+ } finally {
48
+ loading.value = false
49
+ }
50
+ }
51
+
52
+ async function resolveSelectedLabel(id: string | null) {
53
+ if (!id) {
54
+ selectedLabel.value = ''
55
+ return
56
+ }
57
+
58
+ if (props.refTarget === 'user') {
59
+ const users = await adminApi.get<RefOption[]>(`/api/vulse/users?q=${encodeURIComponent(id)}`)
60
+ const match = users.find((user) => user.id === id)
61
+ selectedLabel.value = match ? optionLabel(match) : id
62
+ return
63
+ }
64
+
65
+ const row = await adminApi.get<{ id: string; content?: { title?: string }; slug?: string }>(
66
+ `/api/vulse/entries/${props.refTarget}/${id}`,
67
+ )
68
+ selectedLabel.value = row.content?.title ?? row.slug ?? row.id
69
+ }
70
+
71
+ function openDropdown() {
72
+ open.value = true
73
+ void loadOptions(query.value)
74
+ }
75
+
76
+ function closeDropdown() {
77
+ open.value = false
78
+ }
79
+
80
+ function onBlur(event: FocusEvent) {
81
+ const next = event.relatedTarget as Node | null
82
+ if (next && (event.currentTarget as HTMLElement).contains(next)) return
83
+ closeDropdown()
84
+ }
85
+
86
+ function selectOption(option: RefOption) {
87
+ emit('update:modelValue', option.id)
88
+ selectedLabel.value = optionLabel(option)
89
+ query.value = ''
90
+ closeDropdown()
91
+ }
92
+
93
+ function clearSelection() {
94
+ emit('update:modelValue', null)
95
+ selectedLabel.value = ''
96
+ query.value = ''
97
+ }
98
+
99
+ watch(
100
+ () => props.modelValue,
101
+ (value) => {
102
+ void resolveSelectedLabel(value)
103
+ },
104
+ { immediate: true },
105
+ )
106
+
107
+ watch(query, (value) => {
108
+ if (!open.value) return
109
+ void loadOptions(value)
110
+ })
111
+ </script>
112
+
113
+ <template>
114
+ <label class="block">
115
+ <span class="text-sm text-zinc-600">{{ label }}</span>
116
+ <div class="relative mt-1" @blur="onBlur">
117
+ <div class="flex gap-2">
118
+ <button
119
+ type="button"
120
+ class="vulse-input flex flex-1 items-center justify-between bg-white text-left"
121
+ :class="open && 'border-zinc-400'"
122
+ @click="open ? closeDropdown() : openDropdown()"
123
+ >
124
+ <span :class="modelValue ? 'text-zinc-900' : 'text-zinc-400'">
125
+ {{ modelValue ? selectedLabel || modelValue : `Select ${refTarget}…` }}
126
+ </span>
127
+ <span class="text-xs text-zinc-400">{{ open ? '▴' : '▾' }}</span>
128
+ </button>
129
+ <button
130
+ v-if="modelValue"
131
+ type="button"
132
+ class="rounded border border-zinc-300 bg-white px-3 py-2 text-sm text-zinc-600 hover:bg-zinc-50"
133
+ @click="clearSelection"
134
+ >
135
+ Clear
136
+ </button>
137
+ </div>
138
+
139
+ <div
140
+ v-if="open"
141
+ class="absolute z-20 mt-1 w-full overflow-hidden rounded-md border border-zinc-200 bg-white shadow-lg"
142
+ >
143
+ <div class="border-b border-zinc-200 p-2">
144
+ <input
145
+ v-model="query"
146
+ type="search"
147
+ class="vulse-input bg-white"
148
+ :placeholder="`Search ${refTarget}…`"
149
+ autofocus
150
+ @keydown.esc.prevent="closeDropdown()"
151
+ />
152
+ </div>
153
+ <ul class="max-h-48 overflow-auto py-1 text-sm">
154
+ <li v-if="loading" class="px-3 py-2 text-zinc-500">Loading…</li>
155
+ <li v-else-if="options.length === 0" class="px-3 py-2 text-zinc-500">No matches</li>
156
+ <li v-for="option in options" v-else :key="option.id">
157
+ <button
158
+ type="button"
159
+ class="flex w-full items-center px-3 py-2 text-left hover:bg-zinc-100"
160
+ :class="option.id === modelValue && 'bg-zinc-50 font-medium'"
161
+ @click="selectOption(option)"
162
+ >
163
+ {{ optionLabel(option) }}
164
+ </button>
165
+ </li>
166
+ </ul>
167
+ </div>
168
+ </div>
169
+ </label>
170
+ </template>
@@ -0,0 +1,27 @@
1
+ <script setup lang="ts">
2
+ import type { FieldDescriptor } from '../../client/form-from-zod'
3
+ import FieldRenderer from './FieldRenderer.vue'
4
+ const props = defineProps<{ modelValue: Record<string, unknown>[]; label: string; itemFields: FieldDescriptor[] }>()
5
+ const emit = defineEmits<{ (e: 'update:modelValue', v: Record<string, unknown>[]): void }>()
6
+ function update(i: number, key: string, v: unknown) {
7
+ const next = [...(props.modelValue ?? [])]
8
+ next[i] = { ...next[i], [key]: v }
9
+ emit('update:modelValue', next)
10
+ }
11
+ function add() { emit('update:modelValue', [...(props.modelValue ?? []), {}]) }
12
+ function remove(i: number) {
13
+ const next = [...(props.modelValue ?? [])]; next.splice(i, 1); emit('update:modelValue', next)
14
+ }
15
+ </script>
16
+ <template>
17
+ <div class="space-y-2">
18
+ <div class="text-sm text-zinc-600">{{ label }}</div>
19
+ <div v-for="(item, i) in modelValue ?? []" :key="i" class="border rounded p-3 space-y-2">
20
+ <FieldRenderer v-for="f in itemFields" :key="f.path"
21
+ :field="f" :model-value="item?.[f.path]"
22
+ @update:modelValue="update(i, f.path, $event)" />
23
+ <button type="button" @click="remove(i)" class="text-sm text-red-600">Remove</button>
24
+ </div>
25
+ <button type="button" @click="add" class="text-sm rounded border px-3 py-1">Add</button>
26
+ </div>
27
+ </template>