@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,123 @@
1
+ <script setup lang="ts">
2
+ import { EditorContent, useEditor } from '@tiptap/vue-3'
3
+ import { computed, onMounted, watch } from 'vue'
4
+ import { useSets } from '../../composables/useSets.js'
5
+ import { EMPTY_BLOCKS_DOC, blocksEditorExtensions } from './blocks-editor-extensions.js'
6
+ import { sanitizeLinkHref } from './url-utils.js'
7
+
8
+ const props = defineProps<{
9
+ label: string
10
+ modelValue: unknown
11
+ error?: string
12
+ blocksSets?: string[]
13
+ }>()
14
+ const emit = defineEmits<{ (e: 'update:modelValue', v: unknown): void }>()
15
+
16
+ const { get, hydrate } = useSets()
17
+ onMounted(() => { void hydrate() })
18
+
19
+ const availableSetHandles = computed<string[]>(() => {
20
+ const declared = props.blocksSets ?? []
21
+ return declared.filter((h) => !!get(h))
22
+ })
23
+
24
+ function insertSet(handle: string) {
25
+ if (!handle) return
26
+ editor.value?.chain().focus().insertVulseSet(handle).run()
27
+ }
28
+
29
+ const editor = useEditor({
30
+ extensions: blocksEditorExtensions,
31
+ content: (props.modelValue as object) ?? EMPTY_BLOCKS_DOC,
32
+ onUpdate: ({ editor: ed }) => {
33
+ emit('update:modelValue', ed.getJSON())
34
+ },
35
+ })
36
+
37
+ watch(
38
+ () => props.modelValue,
39
+ (v) => {
40
+ if (!editor.value) return
41
+ const current = JSON.stringify(editor.value.getJSON())
42
+ const incoming = JSON.stringify(v)
43
+ if (current !== incoming && v) {
44
+ editor.value.commands.setContent(v as object, false)
45
+ }
46
+ },
47
+ )
48
+
49
+ function insertCallout(tone: 'info' | 'warn') {
50
+ editor.value?.chain().focus().insertVulseCallout(tone).run()
51
+ }
52
+
53
+ function toggleLink() {
54
+ const currentHref = (editor.value?.getAttributes('link').href as string | undefined) ?? ''
55
+ const raw = window.prompt('Link URL', currentHref)
56
+ if (raw === null) return
57
+ const href = sanitizeLinkHref(raw)
58
+ if (!href) {
59
+ editor.value?.chain().focus().unsetVulseLink().run()
60
+ return
61
+ }
62
+ if (editor.value?.state.selection.empty) {
63
+ editor.value?.chain().focus()
64
+ .insertContent({ type: 'text', text: href, marks: [{ type: 'link', attrs: { href } }] })
65
+ .run()
66
+ return
67
+ }
68
+ editor.value?.chain().focus().extendMarkRange('link').setVulseLink(href).run()
69
+ }
70
+
71
+ function insertEmoji() {
72
+ const value = window.prompt('Emoji', '🙂')
73
+ if (!value?.trim()) return
74
+ editor.value?.chain().focus().insertEmoji(value.trim()).run()
75
+ }
76
+
77
+ function insertAccordion() {
78
+ editor.value?.chain().focus().insertVulseAccordionGroup('Accordion').run()
79
+ }
80
+
81
+ function insertIframe() {
82
+ editor.value?.chain().focus().insertVulseIframe().run()
83
+ }
84
+
85
+ function insertVideo() {
86
+ editor.value?.chain().focus().insertVulseVideo().run()
87
+ }
88
+ </script>
89
+
90
+ <template>
91
+ <div :data-testid="`field-${label}`">
92
+ <span class="block text-sm font-medium text-zinc-700 capitalize">{{ label }}</span>
93
+ <div class="mt-1 rounded border border-zinc-300 bg-white">
94
+ <div class="flex flex-wrap gap-1 border-b border-zinc-200 bg-zinc-50 px-2 py-1 text-xs">
95
+ <button type="button" class="rounded px-2 py-1 hover:bg-zinc-200" @click="editor?.chain().focus().toggleBold().run()">B</button>
96
+ <button type="button" class="rounded px-2 py-1 hover:bg-zinc-200 italic" @click="editor?.chain().focus().toggleItalic().run()">I</button>
97
+ <button type="button" class="rounded px-2 py-1 hover:bg-zinc-200" @click="toggleLink">Link</button>
98
+ <button type="button" class="rounded px-2 py-1 hover:bg-zinc-200" @click="editor?.chain().focus().toggleHeading({ level: 2 }).run()">H2</button>
99
+ <button type="button" class="rounded px-2 py-1 hover:bg-zinc-200" @click="editor?.chain().focus().toggleHeading({ level: 3 }).run()">H3</button>
100
+ <button type="button" class="rounded px-2 py-1 hover:bg-zinc-200" @click="editor?.chain().focus().toggleHeading({ level: 4 }).run()">H4</button>
101
+ <button type="button" class="rounded px-2 py-1 hover:bg-zinc-200" @click="editor?.chain().focus().toggleBulletList().run()">• List</button>
102
+ <button type="button" class="rounded px-2 py-1 hover:bg-zinc-200" @click="editor?.chain().focus().toggleOrderedList().run()">1. List</button>
103
+ <span class="mx-1 w-px bg-zinc-300" />
104
+ <button type="button" class="rounded px-2 py-1 hover:bg-zinc-200" @click="insertEmoji">Emoji</button>
105
+ <button type="button" class="rounded px-2 py-1 hover:bg-zinc-200" @click="insertCallout('info')">+ Info</button>
106
+ <button type="button" class="rounded px-2 py-1 hover:bg-zinc-200" @click="insertCallout('warn')">+ Warn</button>
107
+ <button type="button" class="rounded px-2 py-1 hover:bg-zinc-200" @click="insertAccordion">Accordion</button>
108
+ <button type="button" class="rounded px-2 py-1 hover:bg-zinc-200" @click="insertIframe">Iframe</button>
109
+ <button type="button" class="rounded px-2 py-1 hover:bg-zinc-200" @click="insertVideo">Video</button>
110
+ <select
111
+ v-if="availableSetHandles.length > 0"
112
+ class="rounded border border-zinc-300 px-2 py-1 text-xs"
113
+ @change="insertSet(($event.target as HTMLSelectElement).value); ($event.target as HTMLSelectElement).value = ''"
114
+ >
115
+ <option value="" disabled selected>+ Insert set</option>
116
+ <option v-for="h in availableSetHandles" :key="h" :value="h">{{ get(h)?.label ?? h }}</option>
117
+ </select>
118
+ </div>
119
+ <EditorContent v-if="editor" :editor="editor" class="prose max-w-none p-3 text-sm" />
120
+ </div>
121
+ <span v-if="error" class="mt-1 block text-xs text-red-600">{{ error }}</span>
122
+ </div>
123
+ </template>
@@ -0,0 +1,59 @@
1
+ <script setup lang="ts">
2
+ import { computed, onMounted } from 'vue'
3
+ import { useSets } from '../../composables/useSets.js'
4
+
5
+ const props = defineProps<{ modelValue: string[] }>()
6
+ const emit = defineEmits<{ (e: 'update:modelValue', v: string[]): void }>()
7
+
8
+ const { sets, hydrate } = useSets()
9
+
10
+ onMounted(() => {
11
+ void hydrate()
12
+ })
13
+
14
+ const setList = computed(() => [...sets.value.values()])
15
+ const selected = computed(() => props.modelValue ?? [])
16
+
17
+ function toggle(handle: string, checked: boolean) {
18
+ const next = checked
19
+ ? [...selected.value, handle]
20
+ : selected.value.filter((entry) => entry !== handle)
21
+ emit('update:modelValue', next)
22
+ }
23
+ </script>
24
+
25
+ <template>
26
+ <div class="mt-2 rounded border border-zinc-200 bg-zinc-50 px-3 py-3">
27
+ <span class="block text-xs font-medium text-zinc-700">Global sets</span>
28
+ <p class="mt-1 text-xs text-zinc-500">
29
+ Choose reusable sets from
30
+ <a href="/admin/settings/sets" class="text-zinc-700 underline">Settings → Sets</a>.
31
+ Editors can insert them inside this blocks field.
32
+ </p>
33
+ <div v-if="setList.length === 0" class="mt-2 text-xs text-zinc-500">
34
+ No sets defined yet.
35
+ <a href="/admin/settings/sets/new" class="text-zinc-700 underline">Create one</a>.
36
+ </div>
37
+ <div v-else class="mt-2 grid grid-cols-1 gap-1 sm:grid-cols-2">
38
+ <label
39
+ v-for="set in setList"
40
+ :key="set.handle"
41
+ class="flex items-center gap-2 rounded border border-transparent px-2 py-1.5 text-sm hover:border-zinc-200 hover:bg-white"
42
+ >
43
+ <input
44
+ type="checkbox"
45
+ :value="set.handle"
46
+ :checked="selected.includes(set.handle)"
47
+ @change="toggle(set.handle, ($event.target as HTMLInputElement).checked)"
48
+ />
49
+ <span>
50
+ {{ set.label }}
51
+ <span class="font-mono text-xs text-zinc-500">({{ set.handle }})</span>
52
+ </span>
53
+ </label>
54
+ </div>
55
+ <p v-if="selected.length > 0" class="mt-2 text-xs text-zinc-500">
56
+ {{ selected.length }} set{{ selected.length === 1 ? '' : 's' }} selected.
57
+ </p>
58
+ </div>
59
+ </template>
@@ -0,0 +1,10 @@
1
+ <script setup lang="ts">
2
+ defineProps<{ modelValue: boolean; label: string }>()
3
+ defineEmits<{ (e: 'update:modelValue', v: boolean): void }>()
4
+ </script>
5
+ <template>
6
+ <label class="flex items-center gap-2">
7
+ <input type="checkbox" :checked="modelValue" @change="$emit('update:modelValue', ($event.target as HTMLInputElement).checked)" />
8
+ <span class="text-sm text-zinc-600">{{ label }}</span>
9
+ </label>
10
+ </template>
@@ -0,0 +1,22 @@
1
+ <script setup lang="ts">
2
+ import { computed } from 'vue'
3
+ const props = defineProps<{ modelValue: string | Date | null | undefined; label: string }>()
4
+ const emit = defineEmits<{ (e: 'update:modelValue', v: string | null): void }>()
5
+ const local = computed({
6
+ get() {
7
+ if (!props.modelValue) return ''
8
+ const d = props.modelValue instanceof Date ? props.modelValue : new Date(props.modelValue)
9
+ if (Number.isNaN(d.getTime())) return ''
10
+ return d.toISOString().slice(0, 16)
11
+ },
12
+ set(v: string) {
13
+ emit('update:modelValue', v ? new Date(v).toISOString() : null)
14
+ },
15
+ })
16
+ </script>
17
+ <template>
18
+ <label class="block">
19
+ <span class="text-sm text-zinc-600">{{ label }}</span>
20
+ <input v-model="local" type="datetime-local" class="mt-1 w-full rounded border px-3 py-2" />
21
+ </label>
22
+ </template>
@@ -0,0 +1,153 @@
1
+ <script setup lang="ts">
2
+ import { computed, ref, watch } from 'vue'
3
+ import { entryOptionLabel, useEntrySearch } from '../../composables/useEntrySearch.js'
4
+
5
+ const props = defineProps<{
6
+ modelValue: string[]
7
+ label: string
8
+ collections: string[]
9
+ max?: number
10
+ }>()
11
+
12
+ const emit = defineEmits<{ (e: 'update:modelValue', v: string[]): void }>()
13
+
14
+ const selected = ref<Array<{ id: string; collection: string; label: string }>>([])
15
+
16
+ const {
17
+ open,
18
+ query,
19
+ options,
20
+ loading,
21
+ resolveLabel,
22
+ openDropdown,
23
+ closeDropdown,
24
+ onBlur,
25
+ } = useEntrySearch(() => props.collections)
26
+
27
+ const atMax = computed(() => props.max !== undefined && selected.value.length >= props.max)
28
+
29
+ const availableOptions = computed(() =>
30
+ options.value.filter(
31
+ (o) => !selected.value.some((s) => s.id === o.id && s.collection === o.collection),
32
+ ),
33
+ )
34
+
35
+ async function syncSelected(ids: string[]) {
36
+ if (ids.length === 0) {
37
+ selected.value = []
38
+ return
39
+ }
40
+
41
+ const next: Array<{ id: string; collection: string; label: string }> = []
42
+ for (const id of ids) {
43
+ let found = selected.value.find((s) => s.id === id)
44
+ if (found) {
45
+ next.push(found)
46
+ continue
47
+ }
48
+ for (const collection of props.collections) {
49
+ try {
50
+ const label = await resolveLabel(id, collection)
51
+ if (label && label !== id) {
52
+ next.push({ id, collection, label })
53
+ break
54
+ }
55
+ } catch {
56
+ // try next collection
57
+ }
58
+ }
59
+ if (!next.some((s) => s.id === id)) {
60
+ next.push({ id, collection: props.collections[0] ?? '', label: id })
61
+ }
62
+ }
63
+ selected.value = next
64
+ }
65
+
66
+ function addOption(option: { id: string; collection: string; title?: string; email?: string }) {
67
+ if (atMax.value) return
68
+ if (selected.value.some((s) => s.id === option.id)) return
69
+ const next = [
70
+ ...selected.value,
71
+ { id: option.id, collection: option.collection, label: entryOptionLabel(option) },
72
+ ]
73
+ selected.value = next
74
+ emit('update:modelValue', next.map((s) => s.id))
75
+ query.value = ''
76
+ if (props.max === 1) closeDropdown()
77
+ }
78
+
79
+ function removeAt(index: number) {
80
+ const next = [...selected.value]
81
+ next.splice(index, 1)
82
+ selected.value = next
83
+ emit('update:modelValue', next.map((s) => s.id))
84
+ }
85
+
86
+ watch(
87
+ () => props.modelValue,
88
+ (value) => {
89
+ void syncSelected(value ?? [])
90
+ },
91
+ { immediate: true },
92
+ )
93
+ </script>
94
+
95
+ <template>
96
+ <label class="block">
97
+ <span class="text-sm text-zinc-600">{{ label }}</span>
98
+ <div class="relative mt-1" @blur="onBlur">
99
+ <div v-if="selected.length" class="mb-2 flex flex-wrap gap-2">
100
+ <span
101
+ v-for="(item, i) in selected"
102
+ :key="item.id"
103
+ class="inline-flex items-center gap-1 rounded-full border border-zinc-200 bg-zinc-50 px-2 py-1 text-sm"
104
+ >
105
+ {{ item.label }}
106
+ <button type="button" class="text-zinc-400 hover:text-red-600" @click="removeAt(i)">×</button>
107
+ </span>
108
+ </div>
109
+
110
+ <button
111
+ v-if="!atMax"
112
+ type="button"
113
+ class="vulse-input flex w-full items-center justify-between bg-white text-left"
114
+ :class="open && 'border-zinc-400'"
115
+ @click="open ? closeDropdown() : openDropdown()"
116
+ >
117
+ <span class="text-zinc-400">{{ max === 1 ? 'Select entry…' : 'Add entry…' }}</span>
118
+ <span class="text-xs text-zinc-400">{{ open ? '▴' : '▾' }}</span>
119
+ </button>
120
+ <p v-else-if="max" class="text-xs text-zinc-500">Maximum of {{ max }} selected.</p>
121
+
122
+ <div
123
+ v-if="open"
124
+ class="absolute z-20 mt-1 w-full overflow-hidden rounded-md border border-zinc-200 bg-white shadow-lg"
125
+ >
126
+ <div class="border-b border-zinc-200 p-2">
127
+ <input
128
+ v-model="query"
129
+ type="search"
130
+ class="vulse-input bg-white"
131
+ placeholder="Search entries…"
132
+ autofocus
133
+ @keydown.esc.prevent="closeDropdown()"
134
+ />
135
+ </div>
136
+ <ul class="max-h-48 overflow-auto py-1 text-sm">
137
+ <li v-if="loading" class="px-3 py-2 text-zinc-500">Loading…</li>
138
+ <li v-else-if="availableOptions.length === 0" class="px-3 py-2 text-zinc-500">No matches</li>
139
+ <li v-for="option in availableOptions" v-else :key="`${option.collection}:${option.id}`">
140
+ <button
141
+ type="button"
142
+ class="flex w-full items-center px-3 py-2 text-left hover:bg-zinc-100"
143
+ @click="addOption(option)"
144
+ >
145
+ <span v-if="collections.length > 1" class="mr-2 text-xs text-zinc-400">{{ option.collection }}</span>
146
+ {{ entryOptionLabel(option) }}
147
+ </button>
148
+ </li>
149
+ </ul>
150
+ </div>
151
+ </div>
152
+ </label>
153
+ </template>
@@ -0,0 +1,138 @@
1
+ <script setup lang="ts">
2
+ import { ref, toRef, watch } from 'vue'
3
+ import { entryOptionLabel, useEntrySearch } from '../../composables/useEntrySearch.js'
4
+
5
+ const props = defineProps<{
6
+ modelValue: string | null
7
+ label: string
8
+ collections: string[]
9
+ }>()
10
+
11
+ const emit = defineEmits<{ (e: 'update:modelValue', v: string | null): void }>()
12
+
13
+ const selectedCollection = ref(props.collections[0] ?? '')
14
+ const selectedLabel = ref('')
15
+
16
+ const {
17
+ open,
18
+ query,
19
+ options,
20
+ loading,
21
+ resolveLabel,
22
+ openDropdown,
23
+ closeDropdown,
24
+ onBlur,
25
+ } = useEntrySearch(() => {
26
+ const col = selectedCollection.value || props.collections[0]
27
+ return col ? [col] : props.collections
28
+ })
29
+
30
+ function selectOption(option: { id: string; collection: string; title?: string; email?: string }) {
31
+ selectedCollection.value = option.collection
32
+ emit('update:modelValue', option.id)
33
+ selectedLabel.value = entryOptionLabel(option)
34
+ query.value = ''
35
+ closeDropdown()
36
+ }
37
+
38
+ function clearSelection() {
39
+ emit('update:modelValue', null)
40
+ selectedLabel.value = ''
41
+ query.value = ''
42
+ }
43
+
44
+ watch(
45
+ () => props.modelValue,
46
+ (value) => {
47
+ if (!value) {
48
+ selectedLabel.value = ''
49
+ return
50
+ }
51
+ const col = selectedCollection.value || props.collections[0]
52
+ if (!col) return
53
+ void resolveLabel(value, col).then((label) => {
54
+ selectedLabel.value = label
55
+ })
56
+ },
57
+ { immediate: true },
58
+ )
59
+
60
+ watch(
61
+ () => props.collections,
62
+ (cols) => {
63
+ if (cols.length === 1) selectedCollection.value = cols[0]!
64
+ },
65
+ { immediate: true },
66
+ )
67
+ </script>
68
+
69
+ <template>
70
+ <label class="block">
71
+ <span class="text-sm text-zinc-600">{{ label }}</span>
72
+ <div class="mt-1 space-y-2">
73
+ <select
74
+ v-if="collections.length > 1"
75
+ v-model="selectedCollection"
76
+ class="vulse-input bg-white text-sm"
77
+ @change="clearSelection()"
78
+ >
79
+ <option v-for="col in collections" :key="col" :value="col">{{ col }}</option>
80
+ </select>
81
+
82
+ <div class="relative" @blur="onBlur">
83
+ <div class="flex gap-2">
84
+ <button
85
+ type="button"
86
+ class="vulse-input flex flex-1 items-center justify-between bg-white text-left"
87
+ :class="open && 'border-zinc-400'"
88
+ @click="open ? closeDropdown() : openDropdown()"
89
+ >
90
+ <span :class="modelValue ? 'text-zinc-900' : 'text-zinc-400'">
91
+ {{ modelValue ? selectedLabel || modelValue : 'Select entry…' }}
92
+ </span>
93
+ <span class="text-xs text-zinc-400">{{ open ? '▴' : '▾' }}</span>
94
+ </button>
95
+ <button
96
+ v-if="modelValue"
97
+ type="button"
98
+ class="rounded border border-zinc-300 bg-white px-3 py-2 text-sm text-zinc-600 hover:bg-zinc-50"
99
+ @click="clearSelection"
100
+ >
101
+ Clear
102
+ </button>
103
+ </div>
104
+
105
+ <div
106
+ v-if="open"
107
+ class="absolute z-20 mt-1 w-full overflow-hidden rounded-md border border-zinc-200 bg-white shadow-lg"
108
+ >
109
+ <div class="border-b border-zinc-200 p-2">
110
+ <input
111
+ v-model="query"
112
+ type="search"
113
+ class="vulse-input bg-white"
114
+ placeholder="Search entries…"
115
+ autofocus
116
+ @keydown.esc.prevent="closeDropdown()"
117
+ />
118
+ </div>
119
+ <ul class="max-h-48 overflow-auto py-1 text-sm">
120
+ <li v-if="loading" class="px-3 py-2 text-zinc-500">Loading…</li>
121
+ <li v-else-if="options.length === 0" class="px-3 py-2 text-zinc-500">No matches</li>
122
+ <li v-for="option in options" v-else :key="`${option.collection}:${option.id}`">
123
+ <button
124
+ type="button"
125
+ class="flex w-full items-center px-3 py-2 text-left hover:bg-zinc-100"
126
+ :class="option.id === modelValue && 'bg-zinc-50 font-medium'"
127
+ @click="selectOption(option)"
128
+ >
129
+ <span v-if="collections.length > 1" class="mr-2 text-xs text-zinc-400">{{ option.collection }}</span>
130
+ {{ entryOptionLabel(option) }}
131
+ </button>
132
+ </li>
133
+ </ul>
134
+ </div>
135
+ </div>
136
+ </div>
137
+ </label>
138
+ </template>
@@ -0,0 +1,81 @@
1
+ <script setup lang="ts">
2
+ import { computed } from 'vue'
3
+
4
+ const props = defineProps<{
5
+ modelValue: string | string[]
6
+ label: string
7
+ options: { key: string; label: string }[]
8
+ multiple?: boolean
9
+ placeholder?: string
10
+ clearable?: boolean
11
+ required?: boolean
12
+ }>()
13
+
14
+ const emit = defineEmits<{ (e: 'update:modelValue', v: string | string[]): void }>()
15
+
16
+ const normalizedOptions = computed(() =>
17
+ props.options.length > 0 ? props.options : [{ key: '', label: '—' }],
18
+ )
19
+
20
+ function onSingleChange(event: Event) {
21
+ const value = (event.target as HTMLSelectElement).value
22
+ emit('update:modelValue', value)
23
+ }
24
+
25
+ function onMultipleChange(event: Event) {
26
+ const select = event.target as HTMLSelectElement
27
+ emit(
28
+ 'update:modelValue',
29
+ Array.from(select.selectedOptions).map((o) => o.value),
30
+ )
31
+ }
32
+
33
+ function clearSingle() {
34
+ emit('update:modelValue', '')
35
+ }
36
+ </script>
37
+
38
+ <template>
39
+ <label class="block">
40
+ <span class="text-sm text-zinc-600">{{ label }}</span>
41
+ <div v-if="multiple" class="mt-1">
42
+ <select
43
+ multiple
44
+ class="w-full rounded border px-3 py-2"
45
+ :value="modelValue as string[]"
46
+ @change="onMultipleChange"
47
+ >
48
+ <option v-for="o in normalizedOptions" :key="o.key" :value="o.key">{{ o.label }}</option>
49
+ </select>
50
+ <p class="mt-1 text-xs text-zinc-500">Hold Ctrl/Cmd to select multiple options.</p>
51
+ </div>
52
+ <div v-else class="mt-1 flex gap-2">
53
+ <select
54
+ :value="(modelValue as string) ?? ''"
55
+ class="w-full rounded border px-3 py-2"
56
+ :required="required"
57
+ @change="onSingleChange"
58
+ >
59
+ <option v-if="placeholder || clearable" value="" disabled :selected="!(modelValue as string)">
60
+ {{ placeholder || 'Choose…' }}
61
+ </option>
62
+ <option
63
+ v-for="o in normalizedOptions"
64
+ :key="o.key"
65
+ :value="o.key"
66
+ :disabled="!o.key"
67
+ >
68
+ {{ o.label }}
69
+ </option>
70
+ </select>
71
+ <button
72
+ v-if="clearable && modelValue"
73
+ type="button"
74
+ class="rounded border border-zinc-300 bg-white px-3 py-2 text-sm text-zinc-600 hover:bg-zinc-50"
75
+ @click="clearSingle"
76
+ >
77
+ Clear
78
+ </button>
79
+ </div>
80
+ </label>
81
+ </template>
@@ -0,0 +1,87 @@
1
+ <script setup lang="ts">
2
+ import { computed } from 'vue'
3
+ import type { LinkValue } from '../../../core/blueprints/definition.js'
4
+ import type { FieldDescriptor } from '../../client/form-from-zod'
5
+ import TextField from './TextField.vue'
6
+ import TextareaField from './TextareaField.vue'
7
+ import NumberField from './NumberField.vue'
8
+ import BoolField from './BoolField.vue'
9
+ import DateField from './DateField.vue'
10
+ import EnumField from './EnumField.vue'
11
+ import ObjectField from './ObjectField.vue'
12
+ import RepeaterField from './RepeaterField.vue'
13
+ import ReplicatorField from './ReplicatorField.vue'
14
+ import RefField from './RefField.vue'
15
+ import EntryField from './EntryField.vue'
16
+ import EntriesField from './EntriesField.vue'
17
+ import LinkField from './LinkField.vue'
18
+ import MediaField from './MediaField.vue'
19
+ import BlocksField from './BlocksField.vue'
20
+ import GridField from './GridField.vue'
21
+
22
+ const props = defineProps<{
23
+ field: FieldDescriptor
24
+ modelValue: unknown
25
+ fieldErrors?: Record<string, string>
26
+ tree?: boolean
27
+ linkCollections?: string[]
28
+ }>()
29
+ defineEmits<{ (e: 'update:modelValue', v: unknown): void }>()
30
+
31
+ const ownError = computed<string | undefined>(() => props.fieldErrors?.[props.field.path])
32
+
33
+ const selectOptions = computed(() => {
34
+ if (props.field.selectOptions?.length) return props.field.selectOptions
35
+ return (props.field.options ?? []).map((key) => ({ key, label: key }))
36
+ })
37
+ </script>
38
+ <template>
39
+ <div :class="['vulse-field', ownError && 'vulse-field-error']">
40
+ <TextField v-if="field.widget === 'text'" :model-value="(modelValue as string) ?? ''" :label="field.label ?? field.path" :required="field.required" @update:modelValue="$emit('update:modelValue', $event)" />
41
+ <TextareaField v-else-if="field.widget === 'textarea'" :model-value="(modelValue as string) ?? ''" :label="field.label ?? field.path" :required="field.required" @update:modelValue="$emit('update:modelValue', $event)" />
42
+ <NumberField v-else-if="field.widget === 'number'" :model-value="modelValue as number" :label="field.label ?? field.path" :required="field.required" @update:modelValue="$emit('update:modelValue', $event)" />
43
+ <BoolField v-else-if="field.widget === 'bool'" :model-value="!!modelValue" :label="field.label ?? field.path" @update:modelValue="$emit('update:modelValue', $event)" />
44
+ <DateField v-else-if="field.widget === 'date'" :model-value="modelValue as string | null" :label="field.label ?? field.path" @update:modelValue="$emit('update:modelValue', $event)" />
45
+ <EnumField
46
+ v-else-if="field.widget === 'enum'"
47
+ :model-value="field.selectMultiple ? ((modelValue as string[]) ?? []) : ((modelValue as string) ?? '')"
48
+ :label="field.label ?? field.path"
49
+ :options="selectOptions"
50
+ :multiple="field.selectMultiple"
51
+ :placeholder="field.selectPlaceholder"
52
+ :clearable="field.selectClearable"
53
+ :required="field.required"
54
+ @update:modelValue="$emit('update:modelValue', $event)"
55
+ />
56
+ <RefField v-else-if="field.widget === 'ref'" :model-value="modelValue as string | null" :label="field.label ?? field.path" :ref-target="field.refTarget!" @update:modelValue="$emit('update:modelValue', $event)" />
57
+ <EntryField v-else-if="field.widget === 'entry'" :model-value="modelValue as string | null" :label="field.label ?? field.path" :collections="field.entryCollections ?? []" @update:modelValue="$emit('update:modelValue', $event)" />
58
+ <EntriesField v-else-if="field.widget === 'entries'" :model-value="(modelValue as string[]) ?? []" :label="field.label ?? field.path" :collections="field.entriesCollections ?? []" :max="field.entriesMax" @update:modelValue="$emit('update:modelValue', $event)" />
59
+ <LinkField
60
+ v-else-if="field.widget === 'link'"
61
+ :model-value="modelValue as LinkValue | null"
62
+ :label="field.label ?? field.path"
63
+ :collections="field.linkCollections"
64
+ :tree="tree"
65
+ @update:modelValue="$emit('update:modelValue', $event)"
66
+ />
67
+ <MediaField v-else-if="field.widget === 'media'" :model-value="modelValue" :label="field.label ?? field.path" @update:modelValue="$emit('update:modelValue', $event)" />
68
+ <BlocksField v-else-if="field.widget === 'blocks'" :model-value="modelValue" :label="field.label ?? field.path" :blocks-sets="field.blocksSets" @update:modelValue="$emit('update:modelValue', $event)" />
69
+ <ObjectField v-else-if="field.widget === 'object'" :model-value="(modelValue as Record<string, unknown>) ?? {}" :label="field.label ?? field.path" :fields="field.children ?? []" @update:modelValue="$emit('update:modelValue', $event)" />
70
+ <ReplicatorField v-else-if="field.widget === 'replicator'" :model-value="modelValue" :label="field.label ?? field.path" :replicator-sets="field.replicatorSets" @update:modelValue="$emit('update:modelValue', $event)" />
71
+ <RepeaterField v-else-if="field.widget === 'repeater'" :model-value="(modelValue as Record<string, unknown>[]) ?? []" :label="field.label ?? field.path" :item-fields="field.itemFields ?? []" @update:modelValue="$emit('update:modelValue', $event)" />
72
+ <GridField
73
+ v-else-if="field.widget === 'grid'"
74
+ :model-value="(modelValue as Record<string, unknown>[]) ?? []"
75
+ :label="field.label ?? field.path"
76
+ :item-fields="field.itemFields ?? []"
77
+ :mode="field.gridMode"
78
+ :min-rows="field.gridMinRows"
79
+ :max-rows="field.gridMaxRows"
80
+ :add-label="field.gridAddLabel"
81
+ :tree="tree"
82
+ :link-collections="linkCollections"
83
+ @update:modelValue="$emit('update:modelValue', $event)"
84
+ />
85
+ <p v-if="ownError" class="mt-1 text-xs text-red-600">{{ ownError }}</p>
86
+ </div>
87
+ </template>