@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,1783 @@
1
+ <script setup lang="ts">
2
+ import { computed, onMounted, reactive, ref, watch } from 'vue'
3
+ import { adminApi } from '../client/api.js'
4
+ import type {
5
+ BlueprintDefinition,
6
+ FieldDefinition,
7
+ FieldUi,
8
+ NestedFieldDefinition,
9
+ NonReplicatorFieldUi,
10
+ ReplicatorSetDefinition,
11
+ } from '../../core/blueprints/definition.js'
12
+ import { useSets } from '../composables/useSets.js'
13
+ import { useToast } from '../composables/toast.js'
14
+ import BlocksSetsPicker from './fields/BlocksSetsPicker.vue'
15
+ import { normalizeFieldHandle } from '../../core/slug.js'
16
+ import {
17
+ defaultScaffoldRoutes,
18
+ generateCollectionScaffoldFiles,
19
+ generateContentConfig,
20
+ scaffoldCliCommand,
21
+ } from '../../scaffold/collection.js'
22
+ import { formatSelectOptionsText, parseSelectOptionsText } from '../../core/blueprints/select-helpers.js'
23
+ import { defaultPreviewPath } from '../../core/blueprints/preview-path.js'
24
+ import type { SeoFieldMapping } from '../../core/blueprints/seo.js'
25
+
26
+ const props = defineProps<{ handle: string | null; isAdmin?: boolean }>()
27
+ const { sets, hydrate: hydrateSets } = useSets()
28
+ const blueprintList = ref<BlueprintDefinition[]>([])
29
+
30
+ async function refreshBlueprints() {
31
+ blueprintList.value = await adminApi.get<BlueprintDefinition[]>('/api/vulse/blueprints')
32
+ }
33
+
34
+ interface EditorNestedField extends NestedFieldDefinition {
35
+ previousName: string | null;
36
+ nameTouched?: boolean;
37
+ }
38
+
39
+ interface EditorReplicatorSet extends Omit<ReplicatorSetDefinition, 'fields'> {
40
+ fields: EditorNestedField[];
41
+ previousName: string | null;
42
+ nameTouched?: boolean;
43
+ }
44
+
45
+ type EditorFieldUi =
46
+ | NonReplicatorFieldUi
47
+ | {
48
+ kind: 'replicator';
49
+ sets: EditorReplicatorSet[];
50
+ }
51
+ | {
52
+ kind: 'grid';
53
+ fields: EditorNestedField[];
54
+ minRows?: number;
55
+ maxRows?: number;
56
+ mode?: 'table' | 'stacked';
57
+ addLabel?: string;
58
+ };
59
+
60
+ interface EditorField extends Omit<FieldDefinition, 'ui'> {
61
+ ui: EditorFieldUi;
62
+ previousName: string | null; // null = newly added; otherwise tracks rename source
63
+ nameTouched?: boolean;
64
+ }
65
+
66
+ type RemovalTarget =
67
+ | {
68
+ kind: 'field';
69
+ index: number;
70
+ name: string;
71
+ requiresVerification: boolean;
72
+ }
73
+ | {
74
+ kind: 'replicator-set';
75
+ fieldIndex: number;
76
+ setIndex: number;
77
+ name: string;
78
+ requiresVerification: boolean;
79
+ }
80
+ | {
81
+ kind: 'replicator-nested-field';
82
+ fieldIndex: number;
83
+ setIndex: number;
84
+ nestedIndex: number;
85
+ name: string;
86
+ requiresVerification: boolean;
87
+ }
88
+ | {
89
+ kind: 'blueprint';
90
+ name: string;
91
+ requiresVerification: true;
92
+ };
93
+
94
+ const handle = ref('');
95
+ const label = ref('');
96
+ const singleton = ref(false);
97
+ const tree = ref(false);
98
+ const drafts = ref(false);
99
+ const seo = ref(false);
100
+ const seoMetaTitleField = ref('');
101
+ const seoMetaDescriptionField = ref('');
102
+ const seoOgImageField = ref('');
103
+ const maxDepth = ref<number | null>(null);
104
+ const previewPath = ref('');
105
+ const previewRootSelector = ref('');
106
+ const previewLive = ref(true);
107
+ const previewPathTouched = ref(false);
108
+ const fields = reactive<EditorField[]>([]);
109
+ const expandedIndex = ref<number | null>(null);
110
+ const expandedReplicatorSets = reactive<Set<string>>(new Set());
111
+ const originalDrafts = ref(false);
112
+
113
+ const seoTitleFieldOptions = computed(() =>
114
+ fields.filter((f) => f.ui.kind === 'text' || f.ui.kind === 'textarea'),
115
+ )
116
+ const seoDescriptionFieldOptions = computed(() =>
117
+ fields.filter((f) => f.ui.kind === 'text' || f.ui.kind === 'textarea' || f.ui.kind === 'blocks'),
118
+ )
119
+ const seoImageFieldOptions = computed(() => fields.filter((f) => f.ui.kind === 'asset'))
120
+
121
+ function buildSeoMappingPayload(): SeoFieldMapping | undefined {
122
+ const mapping: SeoFieldMapping = {}
123
+ if (seoMetaTitleField.value) mapping.metaTitle = seoMetaTitleField.value
124
+ if (seoMetaDescriptionField.value) mapping.metaDescription = seoMetaDescriptionField.value
125
+ if (seoOgImageField.value) mapping.ogImage = seoOgImageField.value
126
+ return Object.keys(mapping).length ? mapping : undefined
127
+ }
128
+
129
+ function setKey(fieldIndex: number, setIndex: number): string {
130
+ return `${fieldIndex}:${setIndex}`;
131
+ }
132
+ function isSetExpanded(fieldIndex: number, setIndex: number): boolean {
133
+ return expandedReplicatorSets.has(setKey(fieldIndex, setIndex));
134
+ }
135
+ function toggleSetExpanded(fieldIndex: number, setIndex: number) {
136
+ const key = setKey(fieldIndex, setIndex);
137
+ if (expandedReplicatorSets.has(key)) expandedReplicatorSets.delete(key);
138
+ else expandedReplicatorSets.add(key);
139
+ }
140
+
141
+ const errors = reactive<Record<string, string>>({});
142
+ const submitError = ref<string | null>(null);
143
+ const saving = ref(false);
144
+ const toast = useToast();
145
+ const hydrated = ref(false);
146
+
147
+ const handleLocked = ref(false);
148
+ const removalTarget = ref<RemovalTarget | null>(null);
149
+ const removalVerification = ref('');
150
+
151
+ function slugify(input: string): string {
152
+ return input
153
+ .toLowerCase()
154
+ .normalize('NFKD')
155
+ .replace(/[̀-ͯ]/g, '')
156
+ .replace(/[^a-z0-9_-]+/g, '-')
157
+ .replace(/^-+|-+$/g, '')
158
+ .replace(/^[^a-z]+/, '');
159
+ }
160
+
161
+ function syncHandleFromLabel(
162
+ target: { label?: string; name: string; previousName: string | null; nameTouched?: boolean },
163
+ ) {
164
+ if (target.previousName !== null || target.nameTouched) return
165
+ target.name = normalizeFieldHandle(target.label ?? '')
166
+ }
167
+
168
+ function onFieldLabelInput(i: number, value: string) {
169
+ const field = fields[i]!
170
+ field.label = value
171
+ syncHandleFromLabel(field)
172
+ }
173
+
174
+ function onFieldHandleInput(i: number) {
175
+ fields[i]!.nameTouched = true
176
+ }
177
+
178
+ function onReplicatorSetLabelInput(fieldIndex: number, setIndex: number, value: string) {
179
+ const set = fields[fieldIndex]!.ui.kind === 'replicator'
180
+ ? fields[fieldIndex]!.ui.sets[setIndex]!
181
+ : null
182
+ if (!set) return
183
+ set.label = value
184
+ syncHandleFromLabel(set)
185
+ }
186
+
187
+ function onReplicatorSetHandleInput(fieldIndex: number, setIndex: number) {
188
+ const set = fields[fieldIndex]!.ui.kind === 'replicator'
189
+ ? fields[fieldIndex]!.ui.sets[setIndex]!
190
+ : null
191
+ if (set) set.nameTouched = true
192
+ }
193
+
194
+ function onNestedFieldLabelInput(fieldIndex: number, setIndex: number, nestedIndex: number, value: string) {
195
+ const nested = fields[fieldIndex]!.ui.kind === 'replicator'
196
+ ? fields[fieldIndex]!.ui.sets[setIndex]!.fields[nestedIndex]!
197
+ : null
198
+ if (!nested) return
199
+ nested.label = value
200
+ syncHandleFromLabel(nested)
201
+ }
202
+
203
+ function onNestedFieldHandleInput(fieldIndex: number, setIndex: number, nestedIndex: number) {
204
+ const nested = fields[fieldIndex]!.ui.kind === 'replicator'
205
+ ? fields[fieldIndex]!.ui.sets[setIndex]!.fields[nestedIndex]!
206
+ : null
207
+ if (nested) nested.nameTouched = true
208
+ }
209
+
210
+ function unlockHandle() {
211
+ handleLocked.value = true;
212
+ }
213
+
214
+ function resetHandle() {
215
+ handleLocked.value = false;
216
+ handle.value = slugify(label.value);
217
+ }
218
+
219
+ const isCreate = computed(() => props.handle === null);
220
+
221
+ const scaffoldShowRoute = ref('')
222
+ const scaffoldIndexRoute = ref('')
223
+ const scaffoldOpen = ref(true)
224
+ const copyNotice = ref<string | null>(null)
225
+
226
+ function syncPreviewPathFromHandle() {
227
+ if (previewPathTouched.value) return
228
+ previewPath.value = defaultPreviewPath(handle.value || 'collection')
229
+ }
230
+
231
+ function onPreviewPathInput() {
232
+ previewPathTouched.value = true
233
+ }
234
+
235
+ function syncScaffoldRoutes() {
236
+ const defaults = defaultScaffoldRoutes(handle.value || 'collection')
237
+ scaffoldShowRoute.value = defaults.showRoute
238
+ scaffoldIndexRoute.value = defaults.indexRoute
239
+ syncPreviewPathFromHandle()
240
+ }
241
+
242
+ const scaffoldInput = computed(() => ({
243
+ handle: handle.value,
244
+ label: label.value || handle.value,
245
+ showRoute: scaffoldShowRoute.value,
246
+ indexRoute: scaffoldIndexRoute.value,
247
+ fields: fields
248
+ .filter((f) => f.name.trim())
249
+ .map((f) => ({ name: f.name, ui: { kind: f.ui.kind } })),
250
+ }))
251
+
252
+ const scaffoldCommand = computed(() => scaffoldCliCommand(scaffoldInput.value))
253
+ const scaffoldFiles = computed(() => generateCollectionScaffoldFiles(scaffoldInput.value, { includeContentConfig: false }))
254
+ const scaffoldContentConfigSnippet = computed(() => generateContentConfig(scaffoldInput.value))
255
+
256
+ async function copyText(text: string, label: string) {
257
+ try {
258
+ await navigator.clipboard.writeText(text)
259
+ copyNotice.value = `Copied ${label}`
260
+ setTimeout(() => { copyNotice.value = null }, 2000)
261
+ } catch {
262
+ copyNotice.value = 'Copy failed'
263
+ }
264
+ }
265
+
266
+ watch(label, (v) => {
267
+ if (isCreate.value && !handleLocked.value) {
268
+ handle.value = slugify(v);
269
+ }
270
+ });
271
+
272
+ watch(handle, () => {
273
+ if (isCreate.value) syncScaffoldRoutes()
274
+ });
275
+
276
+ async function load() {
277
+ for (const k of Object.keys(errors)) delete errors[k];
278
+ fields.splice(0, fields.length);
279
+ if (props.handle === null) {
280
+ handle.value = '';
281
+ label.value = '';
282
+ singleton.value = false;
283
+ tree.value = false;
284
+ drafts.value = false;
285
+ seo.value = false;
286
+ seoMetaTitleField.value = '';
287
+ seoMetaDescriptionField.value = '';
288
+ seoOgImageField.value = '';
289
+ maxDepth.value = null;
290
+ previewPath.value = defaultPreviewPath('');
291
+ previewRootSelector.value = '';
292
+ previewLive.value = true;
293
+ previewPathTouched.value = false;
294
+ handleLocked.value = false;
295
+ originalDrafts.value = false;
296
+ syncScaffoldRoutes();
297
+ return;
298
+ }
299
+ const bp = await adminApi.get<BlueprintDefinition>(`/api/vulse/blueprints/${props.handle}`)
300
+ handle.value = bp.handle;
301
+ label.value = bp.label;
302
+ singleton.value = bp.singleton;
303
+ tree.value = bp.tree ?? false;
304
+ drafts.value = bp.drafts ?? false;
305
+ seo.value = bp.seo ?? false;
306
+ seoMetaTitleField.value = bp.seoMapping?.metaTitle ?? '';
307
+ seoMetaDescriptionField.value = bp.seoMapping?.metaDescription ?? '';
308
+ seoOgImageField.value = bp.seoMapping?.ogImage ?? '';
309
+ maxDepth.value = bp.maxDepth ?? null;
310
+ if (bp.preview) {
311
+ previewPath.value = bp.preview.path
312
+ previewRootSelector.value = bp.preview.rootSelector ?? ''
313
+ previewLive.value = bp.preview.live !== false
314
+ } else {
315
+ previewPath.value = defaultPreviewPath(bp.handle)
316
+ previewRootSelector.value = ''
317
+ previewLive.value = true
318
+ }
319
+ previewPathTouched.value = true;
320
+ handleLocked.value = true;
321
+ originalDrafts.value = drafts.value;
322
+ for (const f of bp.fields) {
323
+ fields.push(toEditorField(f));
324
+ }
325
+ syncScaffoldRoutes()
326
+ }
327
+
328
+ onMounted(async () => {
329
+ const [, setsMap] = await Promise.all([load(), hydrateSets(), refreshBlueprints()])
330
+ sets.value = setsMap
331
+ hydrated.value = true
332
+ })
333
+ watch(() => props.handle, load);
334
+
335
+ function addField() {
336
+ fields.push({
337
+ name: '',
338
+ label: '',
339
+ ui: { kind: 'text' },
340
+ optional: false,
341
+ previousName: null,
342
+ });
343
+ expandedIndex.value = fields.length - 1;
344
+ }
345
+
346
+ function performRemoveField(i: number) {
347
+ fields.splice(i, 1);
348
+ if (expandedIndex.value === i) expandedIndex.value = null;
349
+ else if (expandedIndex.value !== null && expandedIndex.value > i) expandedIndex.value -= 1;
350
+ }
351
+
352
+ function moveUp(i: number) {
353
+ if (i === 0) return;
354
+ const [moved] = fields.splice(i, 1);
355
+ fields.splice(i - 1, 0, moved!);
356
+ if (expandedIndex.value === i) expandedIndex.value = i - 1;
357
+ }
358
+
359
+ function moveDown(i: number) {
360
+ if (i >= fields.length - 1) return;
361
+ const [moved] = fields.splice(i, 1);
362
+ fields.splice(i + 1, 0, moved!);
363
+ if (expandedIndex.value === i) expandedIndex.value = i + 1;
364
+ }
365
+
366
+ function setKind(i: number, kind: FieldUi['kind']) {
367
+ const f = fields[i]!;
368
+ if (kind === 'select') f.ui = { kind, options: [] };
369
+ else if (kind === 'relationship') f.ui = { kind, to: '' };
370
+ else if (kind === 'entry') f.ui = { kind, collections: [] };
371
+ else if (kind === 'entries') f.ui = { kind, collections: [] };
372
+ else if (kind === 'link') f.ui = { kind, collections: [] };
373
+ else if (kind === 'replicator') f.ui = { kind, sets: [] };
374
+ else if (kind === 'grid') f.ui = { kind, fields: [], mode: 'table' };
375
+ else f.ui = { kind };
376
+ if (kind === 'blocks' || kind === 'grid') expandedIndex.value = i;
377
+ }
378
+
379
+ function setNestedKind(
380
+ fieldIndex: number,
381
+ setIndex: number,
382
+ nestedIndex: number,
383
+ kind: NonReplicatorFieldUi['kind'],
384
+ ) {
385
+ const nested =
386
+ fields[fieldIndex]!.ui.kind === 'replicator'
387
+ ? fields[fieldIndex]!.ui.sets[setIndex]!.fields[nestedIndex]!
388
+ : null;
389
+ if (!nested) return;
390
+ if (kind === 'select') nested.ui = { kind, options: [] };
391
+ else if (kind === 'relationship') nested.ui = { kind, to: '' };
392
+ else if (kind === 'entry') nested.ui = { kind, collections: [] };
393
+ else if (kind === 'entries') nested.ui = { kind, collections: [] };
394
+ else if (kind === 'link') nested.ui = { kind, collections: [] };
395
+ else nested.ui = { kind };
396
+ }
397
+
398
+ function setGridNestedKind(fieldIndex: number, nestedIndex: number, kind: NonReplicatorFieldUi['kind']) {
399
+ const field = fields[fieldIndex];
400
+ if (!field || field.ui.kind !== 'grid') return;
401
+ const nested = field.ui.fields[nestedIndex];
402
+ if (!nested) return;
403
+ if (kind === 'select') nested.ui = { kind, options: [] };
404
+ else if (kind === 'relationship') nested.ui = { kind, to: '' };
405
+ else if (kind === 'entry') nested.ui = { kind, collections: [] };
406
+ else if (kind === 'entries') nested.ui = { kind, collections: [] };
407
+ else if (kind === 'link') nested.ui = { kind, collections: [] };
408
+ else nested.ui = { kind };
409
+ }
410
+
411
+ function updateSelectUi(
412
+ ui: Extract<NonReplicatorFieldUi, { kind: 'select' }>,
413
+ text: string,
414
+ ): Extract<NonReplicatorFieldUi, { kind: 'select' }> {
415
+ return {
416
+ kind: 'select',
417
+ options: parseSelectOptionsText(text),
418
+ ...(ui.multiple ? { multiple: true } : {}),
419
+ ...(ui.placeholder ? { placeholder: ui.placeholder } : {}),
420
+ ...(ui.clearable ? { clearable: true } : {}),
421
+ };
422
+ }
423
+
424
+ function toggleCollection(
425
+ ui: { collections?: string[] },
426
+ handle: string,
427
+ checked: boolean,
428
+ ) {
429
+ const current = ui.collections ?? [];
430
+ ui.collections = checked
431
+ ? [...current, handle]
432
+ : current.filter((c) => c !== handle);
433
+ }
434
+
435
+ function addGridColumn(fieldIndex: number) {
436
+ const field = fields[fieldIndex];
437
+ if (!field || field.ui.kind !== 'grid') return;
438
+ field.ui.fields.push({
439
+ name: '',
440
+ label: '',
441
+ ui: { kind: 'text' },
442
+ optional: false,
443
+ previousName: null,
444
+ });
445
+ }
446
+
447
+ function removeGridColumn(fieldIndex: number, nestedIndex: number) {
448
+ const field = fields[fieldIndex];
449
+ if (!field || field.ui.kind !== 'grid') return;
450
+ field.ui.fields.splice(nestedIndex, 1);
451
+ }
452
+
453
+ function updateBlocksSets(fieldIndex: number, handles: string[]) {
454
+ const field = fields[fieldIndex];
455
+ if (!field || field.ui.kind !== 'blocks') return;
456
+ field.ui = { kind: 'blocks', ...(handles.length ? { sets: handles } : {}) };
457
+ }
458
+
459
+ function updateNestedBlocksSets(
460
+ fieldIndex: number,
461
+ setIndex: number,
462
+ nestedIndex: number,
463
+ handles: string[],
464
+ ) {
465
+ const nested =
466
+ fields[fieldIndex]?.ui.kind === 'replicator'
467
+ ? fields[fieldIndex]!.ui.sets[setIndex]?.fields[nestedIndex]
468
+ : null;
469
+ if (!nested || nested.ui.kind !== 'blocks') return;
470
+ nested.ui = { kind: 'blocks', ...(handles.length ? { sets: handles } : {}) };
471
+ }
472
+
473
+ function blocksSetHandles(fieldIndex: number): string[] {
474
+ const field = fields[fieldIndex];
475
+ if (!field || field.ui.kind !== 'blocks') return [];
476
+ return field.ui.sets ?? [];
477
+ }
478
+
479
+ function addReplicatorSet(fieldIndex: number) {
480
+ const field = fields[fieldIndex];
481
+ if (!field || field.ui.kind !== 'replicator') return;
482
+ field.ui.sets.push({
483
+ name: '',
484
+ label: '',
485
+ previousName: null,
486
+ fields: [],
487
+ });
488
+ // Expand the newly added set so the user can fill it in right away.
489
+ expandedReplicatorSets.add(setKey(fieldIndex, field.ui.sets.length - 1));
490
+ }
491
+
492
+ function performRemoveReplicatorSet(fieldIndex: number, setIndex: number) {
493
+ const field = fields[fieldIndex];
494
+ if (!field || field.ui.kind !== 'replicator') return;
495
+ field.ui.sets.splice(setIndex, 1);
496
+ // Rebuild the expanded-set index since indices shift after splice.
497
+ const remaining = Array.from(expandedReplicatorSets)
498
+ .filter((key) => {
499
+ const [f, s] = key.split(':').map(Number);
500
+ return !(f === fieldIndex && s === setIndex);
501
+ })
502
+ .map((key) => {
503
+ const [f, s] = key.split(':').map(Number);
504
+ if (f === fieldIndex && s! > setIndex) return setKey(f!, s! - 1);
505
+ return key;
506
+ });
507
+ expandedReplicatorSets.clear();
508
+ for (const k of remaining) expandedReplicatorSets.add(k);
509
+ }
510
+
511
+ function addReplicatorSetField(fieldIndex: number, setIndex: number) {
512
+ const field = fields[fieldIndex];
513
+ if (!field || field.ui.kind !== 'replicator') return;
514
+ field.ui.sets[setIndex]!.fields.push({
515
+ name: '',
516
+ label: '',
517
+ ui: { kind: 'text' },
518
+ optional: false,
519
+ previousName: null,
520
+ });
521
+ }
522
+
523
+ function performRemoveReplicatorSetField(
524
+ fieldIndex: number,
525
+ setIndex: number,
526
+ nestedIndex: number,
527
+ ) {
528
+ const field = fields[fieldIndex];
529
+ if (!field || field.ui.kind !== 'replicator') return;
530
+ field.ui.sets[setIndex]!.fields.splice(nestedIndex, 1);
531
+ }
532
+
533
+ function openFieldRemovalDialog(index: number) {
534
+ const field = fields[index];
535
+ if (!field) return;
536
+ removalTarget.value = {
537
+ kind: 'field',
538
+ index,
539
+ name: field.name || field.previousName || 'field',
540
+ requiresVerification: field.previousName !== null,
541
+ };
542
+ removalVerification.value = '';
543
+ }
544
+
545
+ function openReplicatorSetRemovalDialog(fieldIndex: number, setIndex: number) {
546
+ const field = fields[fieldIndex];
547
+ const set = field?.ui.kind === 'replicator' ? field.ui.sets[setIndex] : null;
548
+ if (!set) return;
549
+ removalTarget.value = {
550
+ kind: 'replicator-set',
551
+ fieldIndex,
552
+ setIndex,
553
+ name: set.name || set.previousName || 'set',
554
+ requiresVerification: set.previousName !== null,
555
+ };
556
+ removalVerification.value = '';
557
+ }
558
+
559
+ function openReplicatorNestedFieldRemovalDialog(
560
+ fieldIndex: number,
561
+ setIndex: number,
562
+ nestedIndex: number,
563
+ ) {
564
+ const field = fields[fieldIndex];
565
+ const nested =
566
+ field?.ui.kind === 'replicator' ? field.ui.sets[setIndex]?.fields[nestedIndex] : null;
567
+ if (!nested) return;
568
+ removalTarget.value = {
569
+ kind: 'replicator-nested-field',
570
+ fieldIndex,
571
+ setIndex,
572
+ nestedIndex,
573
+ name: nested.name || nested.previousName || 'field',
574
+ requiresVerification: nested.previousName !== null,
575
+ };
576
+ removalVerification.value = '';
577
+ }
578
+
579
+ function openBlueprintRemovalDialog() {
580
+ if (!props.handle) return;
581
+ removalTarget.value = {
582
+ kind: 'blueprint',
583
+ name: props.handle,
584
+ requiresVerification: true,
585
+ };
586
+ removalVerification.value = '';
587
+ }
588
+
589
+ function closeRemovalDialog() {
590
+ removalTarget.value = null;
591
+ removalVerification.value = '';
592
+ }
593
+
594
+ const removalDialogTitle = computed(() => {
595
+ if (!removalTarget.value) return '';
596
+ switch (removalTarget.value.kind) {
597
+ case 'field':
598
+ return `Remove field '${removalTarget.value.name}'?`;
599
+ case 'replicator-set':
600
+ return `Remove set '${removalTarget.value.name}'?`;
601
+ case 'replicator-nested-field':
602
+ return `Remove nested field '${removalTarget.value.name}'?`;
603
+ case 'blueprint':
604
+ return `Delete blueprint '${removalTarget.value.name}'?`;
605
+ }
606
+ });
607
+
608
+ const removalDialogMessage = computed(() => {
609
+ if (!removalTarget.value) return '';
610
+ switch (removalTarget.value.kind) {
611
+ case 'field':
612
+ return 'Removing a schema field can orphan existing values and make them unavailable in the editor.';
613
+ case 'replicator-set':
614
+ return 'Removing a replicator set can strand existing content blocks that use this set and may prevent clean future edits.';
615
+ case 'replicator-nested-field':
616
+ return 'Removing a nested field can hide existing values inside replicator content and later saves may drop them.';
617
+ case 'blueprint':
618
+ return 'Deleting a blueprint removes the schema and permanently deletes every entry in this collection.';
619
+ }
620
+ });
621
+
622
+ const removalConfirmLabel = computed(() =>
623
+ removalTarget.value?.kind === 'blueprint' ? 'Delete' : 'Remove',
624
+ );
625
+
626
+ const removalConfirmDisabled = computed(() => {
627
+ if (!removalTarget.value) return true;
628
+ if (!removalTarget.value.requiresVerification) return false;
629
+ return removalVerification.value !== removalTarget.value.name;
630
+ });
631
+
632
+ async function confirmRemoval() {
633
+ const target = removalTarget.value;
634
+ if (!target || removalConfirmDisabled.value) return;
635
+ switch (target.kind) {
636
+ case 'field':
637
+ performRemoveField(target.index);
638
+ break;
639
+ case 'replicator-set':
640
+ performRemoveReplicatorSet(target.fieldIndex, target.setIndex);
641
+ break;
642
+ case 'replicator-nested-field':
643
+ performRemoveReplicatorSetField(target.fieldIndex, target.setIndex, target.nestedIndex);
644
+ break;
645
+ case 'blueprint':
646
+ await adminApi.delete(`/api/vulse/blueprints/${target.name}`)
647
+ window.location.href = '/admin'
648
+ break
649
+ }
650
+ closeRemovalDialog();
651
+ }
652
+
653
+ function toEditorField(field: FieldDefinition): EditorField {
654
+ const base = {
655
+ name: field.name,
656
+ ...(field.label !== undefined ? { label: field.label } : {}),
657
+ optional: field.optional,
658
+ ...(field.default !== undefined ? { default: field.default } : {}),
659
+ ...(field.validation ? { validation: field.validation } : {}),
660
+ previousName: field.name,
661
+ nameTouched: true,
662
+ };
663
+
664
+ if (field.ui.kind === 'replicator') {
665
+ return {
666
+ ...base,
667
+ ui: {
668
+ kind: 'replicator',
669
+ sets: field.ui.sets.map((set) => ({
670
+ name: set.name,
671
+ ...(set.label !== undefined ? { label: set.label } : {}),
672
+ previousName: set.name,
673
+ nameTouched: true,
674
+ fields: set.fields.map((nested) => ({
675
+ name: nested.name,
676
+ ...(nested.label !== undefined ? { label: nested.label } : {}),
677
+ ui: nested.ui,
678
+ optional: nested.optional,
679
+ ...(nested.default !== undefined ? { default: nested.default } : {}),
680
+ ...(nested.validation ? { validation: nested.validation } : {}),
681
+ previousName: nested.name,
682
+ nameTouched: true,
683
+ })),
684
+ })),
685
+ },
686
+ };
687
+ }
688
+
689
+ if (field.ui.kind === 'grid') {
690
+ return {
691
+ ...base,
692
+ ui: {
693
+ kind: 'grid',
694
+ fields: field.ui.fields.map((nested) => ({
695
+ name: nested.name,
696
+ ...(nested.label !== undefined ? { label: nested.label } : {}),
697
+ ui: nested.ui,
698
+ optional: nested.optional,
699
+ ...(nested.default !== undefined ? { default: nested.default } : {}),
700
+ ...(nested.validation ? { validation: nested.validation } : {}),
701
+ previousName: nested.name,
702
+ nameTouched: true,
703
+ })),
704
+ ...(field.ui.minRows !== undefined ? { minRows: field.ui.minRows } : {}),
705
+ ...(field.ui.maxRows !== undefined ? { maxRows: field.ui.maxRows } : {}),
706
+ ...(field.ui.mode ? { mode: field.ui.mode } : {}),
707
+ ...(field.ui.addLabel ? { addLabel: field.ui.addLabel } : {}),
708
+ },
709
+ };
710
+ }
711
+
712
+ return {
713
+ ...base,
714
+ ui: field.ui,
715
+ };
716
+ }
717
+
718
+ function stripNestedEditorField(field: EditorNestedField): NestedFieldDefinition {
719
+ return {
720
+ name: field.name,
721
+ ...(field.label !== undefined ? { label: field.label } : {}),
722
+ ui: field.ui,
723
+ optional: field.optional,
724
+ ...(field.default !== undefined ? { default: field.default } : {}),
725
+ ...(field.validation ? { validation: field.validation } : {}),
726
+ };
727
+ }
728
+
729
+ function stripEditorField(field: EditorField): Record<string, unknown> {
730
+ const out: Record<string, unknown> = {
731
+ name: field.name,
732
+ label: field.label,
733
+ optional: field.optional,
734
+ };
735
+
736
+ if (field.ui.kind === 'replicator') {
737
+ out.ui = {
738
+ kind: 'replicator',
739
+ sets: field.ui.sets.map((set) => ({
740
+ name: set.name,
741
+ label: set.label,
742
+ fields: set.fields.map(stripNestedEditorField),
743
+ })),
744
+ };
745
+ } else if (field.ui.kind === 'grid') {
746
+ out.ui = {
747
+ kind: 'grid',
748
+ fields: field.ui.fields.map(stripNestedEditorField),
749
+ ...(field.ui.minRows !== undefined ? { minRows: field.ui.minRows } : {}),
750
+ ...(field.ui.maxRows !== undefined ? { maxRows: field.ui.maxRows } : {}),
751
+ ...(field.ui.mode ? { mode: field.ui.mode } : {}),
752
+ ...(field.ui.addLabel ? { addLabel: field.ui.addLabel } : {}),
753
+ };
754
+ } else {
755
+ out.ui = field.ui;
756
+ }
757
+
758
+ if (field.default !== undefined) out.default = field.default;
759
+ if (field.validation) out.validation = field.validation;
760
+ if (field.previousName !== null && field.previousName !== field.name) {
761
+ out.previousName = field.previousName;
762
+ }
763
+ return out;
764
+ }
765
+
766
+ async function save() {
767
+ for (const k of Object.keys(errors)) delete errors[k];
768
+ submitError.value = null;
769
+
770
+ // Skip draft-disable warning for now (requires entry list with draft flags)
771
+ saving.value = true
772
+ try {
773
+ const seoMappingPayload = seo.value ? buildSeoMappingPayload() : undefined
774
+ const payload = {
775
+ handle: handle.value,
776
+ label: label.value,
777
+ singleton: singleton.value,
778
+ ...(tree.value ? { tree: true } : {}),
779
+ ...(tree.value && maxDepth.value !== null && maxDepth.value > 0 ? { maxDepth: maxDepth.value } : {}),
780
+ ...(drafts.value ? { drafts: true } : {}),
781
+ ...(seo.value ? { seo: true } : {}),
782
+ ...(seoMappingPayload ? { seoMapping: seoMappingPayload } : {}),
783
+ ...(props.isAdmin
784
+ ? {
785
+ preview: {
786
+ path: previewPath.value.trim() || defaultPreviewPath(handle.value),
787
+ ...(previewRootSelector.value.trim() ? { rootSelector: previewRootSelector.value.trim() } : {}),
788
+ ...(previewLive.value === false ? { live: false } : {}),
789
+ },
790
+ }
791
+ : {}),
792
+ fields: fields.map(stripEditorField),
793
+ }
794
+ if (isCreate.value) {
795
+ await adminApi.post('/api/vulse/blueprints', payload)
796
+ await refreshBlueprints()
797
+ window.location.href = `/admin/schema/${handle.value}`
798
+ return
799
+ }
800
+ await adminApi.patch(`/api/vulse/blueprints/${props.handle!}`, payload)
801
+ await refreshBlueprints()
802
+ toast.success('Blueprint saved')
803
+ } catch (err) {
804
+ submitError.value = err instanceof Error ? err.message : 'Failed to save'
805
+ } finally {
806
+ saving.value = false
807
+ }
808
+ }
809
+ </script>
810
+
811
+ <template>
812
+ <div class="p-6" data-testid="blueprint-editor">
813
+ <h1 class="mb-4 text-xl font-semibold">{{ isCreate ? 'New collection' : `Edit ${handle}` }}</h1>
814
+
815
+ <form class="max-w-3xl space-y-6" @submit.prevent="save">
816
+ <div class="space-y-3 rounded border border-zinc-200 bg-white p-4">
817
+ <label class="block">
818
+ <span class="block text-sm font-medium text-zinc-700">Label</span>
819
+ <input
820
+ v-model="label"
821
+ class="mt-1 w-full rounded border border-zinc-300 px-3 py-2 text-sm"
822
+ data-testid="blueprint-label"
823
+ />
824
+ <span v-if="errors['label']" class="mt-1 block text-xs text-red-600">{{ errors['label'] }}</span>
825
+ </label>
826
+ <div>
827
+ <div class="flex items-baseline justify-between">
828
+ <span class="block text-sm font-medium text-zinc-700">Handle</span>
829
+ <div v-if="isCreate" class="flex gap-3 text-xs">
830
+ <button
831
+ v-if="!handleLocked"
832
+ type="button"
833
+ class="text-zinc-500 hover:text-zinc-900"
834
+ data-testid="handle-edit"
835
+ @click="unlockHandle"
836
+ >
837
+ Edit
838
+ </button>
839
+ <button
840
+ v-else
841
+ type="button"
842
+ class="text-zinc-500 hover:text-zinc-900"
843
+ data-testid="handle-reset"
844
+ @click="resetHandle"
845
+ >
846
+ Reset
847
+ </button>
848
+ </div>
849
+ </div>
850
+ <input
851
+ v-model="handle"
852
+ :readonly="!isCreate || !handleLocked"
853
+ :disabled="!isCreate"
854
+ class="mt-1 w-full rounded border border-zinc-300 px-3 py-2 text-sm read-only:bg-zinc-50 disabled:bg-zinc-100"
855
+ data-testid="blueprint-handle"
856
+ />
857
+ <p class="mt-1 text-xs text-zinc-500">
858
+ <template v-if="isCreate">
859
+ The collection's stable identifier — used in admin URLs (<code>/admin/collections/{{ handle || 'handle' }}</code>),
860
+ API paths, and your codebase imports. Lowercase letters, numbers, <code>-</code> and <code>_</code> only.
861
+ </template>
862
+ <template v-else>
863
+ Handle is locked because changing it would break admin URLs (<code>/admin/collections/{{ handle }}</code>),
864
+ public API paths, any frontend code that references this collection by name, and routing in generated
865
+ pages. To rename, create a new collection and migrate entries.
866
+ </template>
867
+ </p>
868
+ <span v-if="errors['handle']" class="mt-1 block text-xs text-red-600">{{ errors['handle'] }}</span>
869
+ </div>
870
+ <label class="flex items-center gap-2">
871
+ <input
872
+ v-model="singleton"
873
+ type="checkbox"
874
+ :disabled="tree"
875
+ class="rounded border-zinc-300"
876
+ data-testid="blueprint-singleton"
877
+ />
878
+ <span class="text-sm font-medium text-zinc-700">Singleton (only one entry)</span>
879
+ </label>
880
+ <label class="flex items-center gap-2">
881
+ <input
882
+ v-model="tree"
883
+ type="checkbox"
884
+ :disabled="singleton"
885
+ class="rounded border-zinc-300"
886
+ data-testid="blueprint-tree"
887
+ />
888
+ <span class="text-sm font-medium text-zinc-700">
889
+ Tree structure (entries can be nested under each other)
890
+ </span>
891
+ </label>
892
+ <label class="flex items-center gap-2">
893
+ <input
894
+ v-model="drafts"
895
+ type="checkbox"
896
+ class="rounded border-zinc-300"
897
+ data-testid="blueprint-drafts"
898
+ />
899
+ <span class="text-sm font-medium text-zinc-700">
900
+ Enable drafts (Save changes without affecting the live site)
901
+ </span>
902
+ </label>
903
+ <label class="flex items-center gap-2">
904
+ <input
905
+ v-model="seo"
906
+ type="checkbox"
907
+ class="rounded border-zinc-300"
908
+ data-testid="blueprint-seo"
909
+ />
910
+ <span class="text-sm font-medium text-zinc-700">
911
+ Enable SEO (meta title, description, and OG image per entry)
912
+ </span>
913
+ </label>
914
+ <div v-if="seo" class="space-y-3 rounded border border-zinc-200 bg-zinc-50 p-3">
915
+ <p class="text-xs text-zinc-600">
916
+ Map content fields to SEO defaults. Leave blank to use inferred defaults (title field, first image, etc.).
917
+ </p>
918
+ <label class="block">
919
+ <span class="block text-xs font-medium text-zinc-600">Meta title source</span>
920
+ <select v-model="seoMetaTitleField" class="mt-1 w-full rounded border border-zinc-300 px-2 py-1.5 text-sm">
921
+ <option value="">Default (title field)</option>
922
+ <option v-for="f in seoTitleFieldOptions" :key="f.name" :value="f.name">
923
+ {{ f.label || f.name }}
924
+ </option>
925
+ </select>
926
+ </label>
927
+ <label class="block">
928
+ <span class="block text-xs font-medium text-zinc-600">Meta description source</span>
929
+ <select v-model="seoMetaDescriptionField" class="mt-1 w-full rounded border border-zinc-300 px-2 py-1.5 text-sm">
930
+ <option value="">Inferred default</option>
931
+ <option v-for="f in seoDescriptionFieldOptions" :key="f.name" :value="f.name">
932
+ {{ f.label || f.name }}
933
+ </option>
934
+ </select>
935
+ </label>
936
+ <label class="block">
937
+ <span class="block text-xs font-medium text-zinc-600">OG image source</span>
938
+ <select v-model="seoOgImageField" class="mt-1 w-full rounded border border-zinc-300 px-2 py-1.5 text-sm">
939
+ <option value="">Inferred default</option>
940
+ <option v-for="f in seoImageFieldOptions" :key="f.name" :value="f.name">
941
+ {{ f.label || f.name }}
942
+ </option>
943
+ </select>
944
+ </label>
945
+ </div>
946
+ <label v-if="tree" class="block">
947
+ <span class="block text-xs font-medium text-zinc-600">
948
+ Max nesting depth <span class="text-zinc-400">(optional — leave blank for unlimited)</span>
949
+ </span>
950
+ <input
951
+ :value="maxDepth ?? ''"
952
+ type="number"
953
+ min="1"
954
+ placeholder="e.g. 4"
955
+ class="mt-1 w-32 rounded border border-zinc-300 px-3 py-1.5 text-sm"
956
+ data-testid="blueprint-max-depth"
957
+ @input="
958
+ maxDepth = ($event.target as HTMLInputElement).value === ''
959
+ ? null
960
+ : Math.max(1, Number(($event.target as HTMLInputElement).value))
961
+ "
962
+ />
963
+ </label>
964
+ </div>
965
+
966
+ <div
967
+ v-if="isAdmin"
968
+ class="space-y-3 rounded border border-zinc-200 bg-white p-4"
969
+ data-testid="blueprint-preview-settings"
970
+ >
971
+ <div>
972
+ <h2 class="text-base font-semibold text-zinc-700">Live preview</h2>
973
+ <p class="mt-1 text-xs text-zinc-500">
974
+ Controls where the entry editor opens live preview and the Preview button. This does not create or change
975
+ Astro routes — the path must already exist in your site. A mismatch shows a 404 in preview.
976
+ </p>
977
+ </div>
978
+ <label class="block">
979
+ <span class="block text-sm font-medium text-zinc-700">Preview path</span>
980
+ <input
981
+ v-model="previewPath"
982
+ class="mt-1 w-full rounded border border-zinc-300 px-3 py-2 font-mono text-sm"
983
+ placeholder="/post/{slug}"
984
+ data-testid="blueprint-preview-path"
985
+ @input="onPreviewPathInput"
986
+ />
987
+ <span class="mt-1 block text-xs text-zinc-500">
988
+ Use <code>{slug}</code> for the entry URL slug, e.g. <code>/post/{slug}</code> or <code>/{slug}</code> for
989
+ root-level pages.
990
+ </span>
991
+ </label>
992
+ <label class="block">
993
+ <span class="block text-sm font-medium text-zinc-700">
994
+ Morph target selector <span class="font-normal text-zinc-400">(optional)</span>
995
+ </span>
996
+ <input
997
+ v-model="previewRootSelector"
998
+ class="mt-1 w-full rounded border border-zinc-300 px-3 py-2 font-mono text-sm"
999
+ placeholder="main"
1000
+ data-testid="blueprint-preview-root-selector"
1001
+ />
1002
+ <span class="mt-1 block text-xs text-zinc-500">
1003
+ CSS selector for the element updated as you type. Defaults to <code>main</code>. Change only if your layout
1004
+ uses a different wrapper.
1005
+ </span>
1006
+ </label>
1007
+ <label class="flex items-center gap-2">
1008
+ <input
1009
+ v-model="previewLive"
1010
+ type="checkbox"
1011
+ class="rounded border-zinc-300"
1012
+ data-testid="blueprint-preview-live"
1013
+ />
1014
+ <span class="text-sm font-medium text-zinc-700">Show live preview panel in the entry editor</span>
1015
+ </label>
1016
+ <p class="text-xs text-zinc-500">
1017
+ When disabled, editors still see the Preview button for saved drafts; only the split-panel live preview is
1018
+ hidden.
1019
+ </p>
1020
+ </div>
1021
+
1022
+ <div class="space-y-3">
1023
+ <div class="flex items-center justify-between">
1024
+ <h2 class="text-base font-semibold text-zinc-700">Fields</h2>
1025
+ <button
1026
+ type="button"
1027
+ class="rounded border border-zinc-300 bg-white px-3 py-1.5 text-sm font-medium text-zinc-700 hover:bg-zinc-50"
1028
+ data-testid="add-field"
1029
+ @click="addField"
1030
+ >
1031
+ + Add field
1032
+ </button>
1033
+ </div>
1034
+
1035
+ <div
1036
+ v-if="fields.length === 0"
1037
+ class="rounded border border-dashed border-zinc-300 bg-zinc-50 px-4 py-6 text-sm text-zinc-600"
1038
+ data-testid="fields-empty-state"
1039
+ >
1040
+ <p class="font-medium text-zinc-700">No fields yet.</p>
1041
+ <p class="mt-1">
1042
+ Add at least one field to define what entries in this collection look like.
1043
+ </p>
1044
+ <button
1045
+ type="button"
1046
+ class="mt-3 rounded border border-zinc-300 bg-white px-3 py-1.5 text-sm font-medium text-zinc-700 hover:bg-zinc-50"
1047
+ data-testid="fields-empty-add"
1048
+ @click="addField"
1049
+ >
1050
+ + Add field
1051
+ </button>
1052
+ </div>
1053
+
1054
+ <div
1055
+ v-for="(f, i) in fields"
1056
+ :key="i"
1057
+ class="rounded border border-zinc-200 bg-white"
1058
+ :data-testid="`field-card-${f.name || `new-${i}`}`"
1059
+ >
1060
+ <div class="flex items-center gap-2 px-3 py-2">
1061
+ <button type="button" class="px-2 text-zinc-400 hover:text-zinc-700" :data-testid="`field-up-${i}`" @click="moveUp(i)">↑</button>
1062
+ <button type="button" class="px-2 text-zinc-400 hover:text-zinc-700" :data-testid="`field-down-${i}`" @click="moveDown(i)">↓</button>
1063
+ <div class="flex-1">
1064
+ <button
1065
+ type="button"
1066
+ class="text-left"
1067
+ :data-testid="`field-expand-${i}`"
1068
+ @click="expandedIndex = expandedIndex === i ? null : i"
1069
+ >
1070
+ <span class="font-mono text-sm">{{ f.label || f.name || '(new field)' }}</span>
1071
+ <span class="ml-2 rounded bg-zinc-100 px-1.5 py-0.5 text-xs text-zinc-600">{{ f.ui.kind }}</span>
1072
+ <span
1073
+ v-if="f.ui.kind === 'blocks' && blocksSetHandles(i).length > 0"
1074
+ class="ml-1 rounded bg-sky-50 px-1.5 py-0.5 text-xs text-sky-700"
1075
+ >
1076
+ {{ blocksSetHandles(i).length }} set{{ blocksSetHandles(i).length === 1 ? '' : 's' }}
1077
+ </span>
1078
+ <span v-if="!f.optional" class="ml-1 rounded bg-rose-50 px-1.5 py-0.5 text-xs text-rose-700">required</span>
1079
+ </button>
1080
+ </div>
1081
+ <button
1082
+ type="button"
1083
+ class="rounded px-2 py-1 text-xs text-red-600 hover:bg-red-50"
1084
+ :data-testid="`field-remove-${i}`"
1085
+ @click="openFieldRemovalDialog(i)"
1086
+ >
1087
+ Remove
1088
+ </button>
1089
+ </div>
1090
+
1091
+ <div v-if="expandedIndex === i" class="space-y-3 border-t border-zinc-200 px-3 py-3">
1092
+ <label class="block">
1093
+ <span class="block text-xs font-medium text-zinc-600">Label</span>
1094
+ <input
1095
+ :value="f.label ?? ''"
1096
+ class="mt-1 w-full rounded border border-zinc-300 px-3 py-1.5 text-sm"
1097
+ :data-testid="`field-label-${i}`"
1098
+ @input="onFieldLabelInput(i, ($event.target as HTMLInputElement).value)"
1099
+ />
1100
+ </label>
1101
+ <label class="block">
1102
+ <span class="block text-xs font-medium text-zinc-600">Handle</span>
1103
+ <span class="block text-xs text-zinc-500">The field's template variable.</span>
1104
+ <input
1105
+ v-model="f.name"
1106
+ class="mt-1 w-full rounded border border-zinc-300 px-3 py-1.5 font-mono text-sm read-only:bg-zinc-50"
1107
+ :class="f.previousName === null && !f.nameTouched ? 'text-zinc-500' : 'text-zinc-800'"
1108
+ :readonly="f.previousName !== null"
1109
+ :data-testid="`field-name-${i}`"
1110
+ @input="onFieldHandleInput(i)"
1111
+ />
1112
+ <button
1113
+ v-if="f.previousName === null && f.nameTouched && f.label"
1114
+ type="button"
1115
+ class="mt-1 text-xs text-zinc-600 underline hover:text-zinc-900"
1116
+ @click="f.nameTouched = false; syncHandleFromLabel(f)"
1117
+ >
1118
+ Reset from label
1119
+ </button>
1120
+ </label>
1121
+ <label class="block">
1122
+ <span class="block text-xs font-medium text-zinc-600">Kind</span>
1123
+ <select
1124
+ class="mt-1 w-full rounded border border-zinc-300 px-3 py-1.5 text-sm"
1125
+ :value="f.ui.kind"
1126
+ :data-testid="`field-kind-${i}`"
1127
+ @change="setKind(i, ($event.target as HTMLSelectElement).value as FieldUi['kind'])"
1128
+ >
1129
+ <option value="text">text</option>
1130
+ <option value="textarea">textarea</option>
1131
+ <option value="blocks">blocks</option>
1132
+ <option value="date">date</option>
1133
+ <option value="boolean">boolean</option>
1134
+ <option value="select">select</option>
1135
+ <option value="replicator">replicator</option>
1136
+ <option value="grid">grid</option>
1137
+ <option value="relationship">relationship</option>
1138
+ <option value="entry">entry</option>
1139
+ <option value="entries">entries</option>
1140
+ <option value="link">link</option>
1141
+ <option value="asset">asset</option>
1142
+ </select>
1143
+ </label>
1144
+ <label class="flex items-center gap-2">
1145
+ <input v-model="f.optional" type="checkbox" class="rounded border-zinc-300" :data-testid="`field-optional-${i}`" />
1146
+ <span class="text-xs font-medium text-zinc-600">Optional</span>
1147
+ </label>
1148
+
1149
+ <!-- text/textarea: min/max -->
1150
+ <div v-if="f.ui.kind === 'text' || f.ui.kind === 'textarea'" class="flex gap-3">
1151
+ <label class="block flex-1">
1152
+ <span class="block text-xs font-medium text-zinc-600">Min length</span>
1153
+ <input
1154
+ type="number"
1155
+ :value="f.validation?.min ?? ''"
1156
+ class="mt-1 w-full rounded border border-zinc-300 px-3 py-1.5 text-sm"
1157
+ @input="
1158
+ (function() {
1159
+ const v = ($event.target as HTMLInputElement).value;
1160
+ const next: { min?: number; max?: number } = {};
1161
+ if (v !== '') next.min = Number(v);
1162
+ if (f.validation?.max !== undefined) next.max = f.validation.max;
1163
+ f.validation = next;
1164
+ })()
1165
+ "
1166
+ />
1167
+ </label>
1168
+ <label class="block flex-1">
1169
+ <span class="block text-xs font-medium text-zinc-600">Max length</span>
1170
+ <input
1171
+ type="number"
1172
+ :value="f.validation?.max ?? ''"
1173
+ class="mt-1 w-full rounded border border-zinc-300 px-3 py-1.5 text-sm"
1174
+ @input="
1175
+ (function() {
1176
+ const v = ($event.target as HTMLInputElement).value;
1177
+ const next: { min?: number; max?: number } = {};
1178
+ if (f.validation?.min !== undefined) next.min = f.validation.min;
1179
+ if (v !== '') next.max = Number(v);
1180
+ f.validation = next;
1181
+ })()
1182
+ "
1183
+ />
1184
+ </label>
1185
+ </div>
1186
+
1187
+ <!-- select: options editor -->
1188
+ <div v-if="f.ui.kind === 'select'" class="space-y-2">
1189
+ <div>
1190
+ <span class="block text-xs font-medium text-zinc-600">Options</span>
1191
+ <textarea
1192
+ rows="3"
1193
+ class="mt-1 w-full rounded border border-zinc-300 px-3 py-1.5 font-mono text-xs"
1194
+ :value="formatSelectOptionsText(f.ui.options ?? [])"
1195
+ :data-testid="`field-options-${i}`"
1196
+ @input="f.ui = updateSelectUi(f.ui, ($event.target as HTMLTextAreaElement).value)"
1197
+ />
1198
+ <span class="text-xs text-zinc-500">One option per line. Use <code>key: Label</code> for separate keys and labels.</span>
1199
+ </div>
1200
+ <label class="flex items-center gap-2">
1201
+ <input v-model="f.ui.multiple" type="checkbox" class="rounded border-zinc-300" />
1202
+ <span class="text-xs font-medium text-zinc-600">Allow multiple</span>
1203
+ </label>
1204
+ <label class="block">
1205
+ <span class="block text-xs font-medium text-zinc-600">Placeholder</span>
1206
+ <input
1207
+ v-model="f.ui.placeholder"
1208
+ type="text"
1209
+ class="mt-1 w-full rounded border border-zinc-300 px-3 py-1.5 text-sm"
1210
+ />
1211
+ </label>
1212
+ <label class="flex items-center gap-2">
1213
+ <input v-model="f.ui.clearable" type="checkbox" class="rounded border-zinc-300" />
1214
+ <span class="text-xs font-medium text-zinc-600">Clearable</span>
1215
+ </label>
1216
+ </div>
1217
+
1218
+ <!-- relationship: target picker -->
1219
+ <label v-if="f.ui.kind === 'relationship'" class="block">
1220
+ <span class="block text-xs font-medium text-zinc-600">Target collection</span>
1221
+ <select
1222
+ class="mt-1 w-full rounded border border-zinc-300 px-3 py-1.5 text-sm"
1223
+ :value="f.ui.to ?? ''"
1224
+ :data-testid="`field-to-${i}`"
1225
+ @change="f.ui = { kind: 'relationship', to: ($event.target as HTMLSelectElement).value }"
1226
+ >
1227
+ <option value="" disabled>Choose a collection</option>
1228
+ <option v-for="bp in blueprintList" :key="bp.handle" :value="bp.handle">{{ bp.handle }}</option>
1229
+ </select>
1230
+ </label>
1231
+
1232
+ <!-- entry / entries / link: collection picker -->
1233
+ <div v-if="f.ui.kind === 'entry' || f.ui.kind === 'entries' || f.ui.kind === 'link'" class="space-y-2">
1234
+ <span class="block text-xs font-medium text-zinc-600">
1235
+ {{ f.ui.kind === 'link' ? 'Entry collections (optional)' : 'Collections' }}
1236
+ </span>
1237
+ <div class="flex flex-wrap gap-3">
1238
+ <label
1239
+ v-for="bp in blueprintList"
1240
+ :key="bp.handle"
1241
+ class="flex items-center gap-1 text-sm"
1242
+ >
1243
+ <input
1244
+ type="checkbox"
1245
+ :checked="(f.ui.collections ?? []).includes(bp.handle)"
1246
+ @change="toggleCollection(f.ui, bp.handle, ($event.target as HTMLInputElement).checked)"
1247
+ />
1248
+ {{ bp.handle }}
1249
+ </label>
1250
+ </div>
1251
+ </div>
1252
+
1253
+ <label v-if="f.ui.kind === 'entries'" class="block">
1254
+ <span class="block text-xs font-medium text-zinc-600">Max entries</span>
1255
+ <input
1256
+ v-model.number="f.ui.max"
1257
+ type="number"
1258
+ min="1"
1259
+ class="mt-1 w-full rounded border border-zinc-300 px-3 py-1.5 text-sm"
1260
+ />
1261
+ </label>
1262
+
1263
+ <!-- blocks: attach global sets from Settings → Sets -->
1264
+ <BlocksSetsPicker
1265
+ v-if="f.ui.kind === 'blocks'"
1266
+ :model-value="blocksSetHandles(i)"
1267
+ :data-testid="`blocks-sets-picker-${i}`"
1268
+ @update:model-value="updateBlocksSets(i, $event)"
1269
+ />
1270
+
1271
+ <div v-if="f.ui.kind === 'replicator'" class="space-y-3">
1272
+ <div class="rounded border border-amber-200 bg-amber-50 px-3 py-2 text-xs text-amber-800">
1273
+ Set names and nested field names become locked after the blueprint is saved.
1274
+ </div>
1275
+
1276
+ <div class="flex items-center justify-between">
1277
+ <span class="text-xs font-medium text-zinc-600">Sets</span>
1278
+ <button
1279
+ type="button"
1280
+ class="rounded border border-zinc-300 bg-white px-2.5 py-1 text-xs font-medium text-zinc-700 hover:bg-zinc-50"
1281
+ :data-testid="`replicator-add-set-${i}`"
1282
+ @click="addReplicatorSet(i)"
1283
+ >
1284
+ + Add set
1285
+ </button>
1286
+ </div>
1287
+
1288
+ <div
1289
+ v-if="f.ui.sets.length === 0"
1290
+ class="rounded border border-dashed border-zinc-300 bg-zinc-50 px-3 py-4 text-xs text-zinc-500"
1291
+ >
1292
+ Add at least one set to define repeatable content blocks.
1293
+ </div>
1294
+
1295
+ <div
1296
+ v-for="(set, setIndex) in f.ui.sets"
1297
+ :key="setIndex"
1298
+ class="rounded border border-zinc-200 bg-zinc-50"
1299
+ >
1300
+ <div class="flex items-center justify-between gap-2 px-3 py-2">
1301
+ <button
1302
+ type="button"
1303
+ class="flex flex-1 items-center gap-2 rounded px-1 py-1 text-left hover:bg-zinc-100"
1304
+ :data-testid="`replicator-set-toggle-${i}-${setIndex}`"
1305
+ :aria-expanded="isSetExpanded(i, setIndex)"
1306
+ @click="toggleSetExpanded(i, setIndex)"
1307
+ >
1308
+ <svg
1309
+ class="h-4 w-4 shrink-0 text-zinc-500 transition-transform"
1310
+ :class="{ 'rotate-180': isSetExpanded(i, setIndex) }"
1311
+ viewBox="0 0 20 20"
1312
+ fill="currentColor"
1313
+ aria-hidden="true"
1314
+ >
1315
+ <path fill-rule="evenodd" d="M5.23 7.21a.75.75 0 0 1 1.06.02L10 11.06l3.71-3.83a.75.75 0 1 1 1.08 1.04l-4.25 4.39a.75.75 0 0 1-1.08 0L5.21 8.27a.75.75 0 0 1 .02-1.06Z" clip-rule="evenodd" />
1316
+ </svg>
1317
+ <span class="text-xs font-semibold uppercase tracking-wide text-zinc-500">
1318
+ Set {{ setIndex + 1 }}
1319
+ </span>
1320
+ <span v-if="set.name || set.label" class="text-sm font-medium text-zinc-800">
1321
+ {{ set.label || set.name }}
1322
+ </span>
1323
+ <span class="ml-1 rounded bg-zinc-200 px-1.5 py-0.5 text-[10px] font-medium text-zinc-700">
1324
+ {{ set.fields.length }} field{{ set.fields.length === 1 ? '' : 's' }}
1325
+ </span>
1326
+ <span v-if="!isSetExpanded(i, setIndex)" class="ml-auto text-xs font-medium text-zinc-600">
1327
+ Show fields
1328
+ </span>
1329
+ </button>
1330
+ <button
1331
+ type="button"
1332
+ class="rounded px-2 py-1 text-xs text-red-600 hover:bg-red-50"
1333
+ @click="openReplicatorSetRemovalDialog(i, setIndex)"
1334
+ >
1335
+ Remove set
1336
+ </button>
1337
+ </div>
1338
+
1339
+ <div v-if="isSetExpanded(i, setIndex)" class="space-y-3 border-t border-zinc-200 p-3">
1340
+ <div class="grid gap-3 md:grid-cols-2">
1341
+ <label class="block">
1342
+ <span class="block text-xs font-medium text-zinc-600">Set label</span>
1343
+ <input
1344
+ :value="set.label ?? ''"
1345
+ class="mt-1 w-full rounded border border-zinc-300 px-3 py-1.5 text-sm"
1346
+ @input="onReplicatorSetLabelInput(i, setIndex, ($event.target as HTMLInputElement).value)"
1347
+ />
1348
+ </label>
1349
+ <label class="block">
1350
+ <span class="block text-xs font-medium text-zinc-600">Set handle</span>
1351
+ <span class="block text-xs text-zinc-500">The set's template variable.</span>
1352
+ <input
1353
+ v-model="set.name"
1354
+ class="mt-1 w-full rounded border border-zinc-300 px-3 py-1.5 font-mono text-sm read-only:bg-zinc-100"
1355
+ :readonly="set.previousName !== null"
1356
+ @input="onReplicatorSetHandleInput(i, setIndex)"
1357
+ />
1358
+ </label>
1359
+ </div>
1360
+
1361
+ <div class="space-y-2">
1362
+ <div class="flex items-center justify-between">
1363
+ <span class="text-xs font-medium text-zinc-600">Set fields</span>
1364
+ <button
1365
+ type="button"
1366
+ class="rounded border border-zinc-300 bg-white px-2.5 py-1 text-xs font-medium text-zinc-700 hover:bg-zinc-50"
1367
+ @click="addReplicatorSetField(i, setIndex)"
1368
+ >
1369
+ + Add set field
1370
+ </button>
1371
+ </div>
1372
+
1373
+ <div
1374
+ v-if="set.fields.length === 0"
1375
+ class="rounded border border-dashed border-zinc-300 bg-white px-3 py-4 text-xs text-zinc-500"
1376
+ >
1377
+ Each set needs at least one field.
1378
+ </div>
1379
+
1380
+ <div
1381
+ v-for="(nested, nestedIndex) in set.fields"
1382
+ :key="nestedIndex"
1383
+ class="space-y-3 rounded border border-zinc-200 bg-white p-3"
1384
+ >
1385
+ <div class="grid gap-3 md:grid-cols-2">
1386
+ <label class="block">
1387
+ <span class="block text-xs font-medium text-zinc-600">Field label</span>
1388
+ <input
1389
+ :value="nested.label ?? ''"
1390
+ class="mt-1 w-full rounded border border-zinc-300 px-3 py-1.5 text-sm"
1391
+ @input="onNestedFieldLabelInput(i, setIndex, nestedIndex, ($event.target as HTMLInputElement).value)"
1392
+ />
1393
+ </label>
1394
+ <label class="block">
1395
+ <span class="block text-xs font-medium text-zinc-600">Field handle</span>
1396
+ <span class="block text-xs text-zinc-500">The field's template variable.</span>
1397
+ <input
1398
+ v-model="nested.name"
1399
+ class="mt-1 w-full rounded border border-zinc-300 px-3 py-1.5 font-mono text-sm read-only:bg-zinc-100"
1400
+ :readonly="nested.previousName !== null"
1401
+ @input="onNestedFieldHandleInput(i, setIndex, nestedIndex)"
1402
+ />
1403
+ </label>
1404
+ </div>
1405
+
1406
+ <div class="grid gap-3 md:grid-cols-2">
1407
+ <label class="block">
1408
+ <span class="block text-xs font-medium text-zinc-600">Kind</span>
1409
+ <select
1410
+ class="mt-1 w-full rounded border border-zinc-300 px-3 py-1.5 text-sm"
1411
+ :value="nested.ui.kind"
1412
+ @change="
1413
+ setNestedKind(
1414
+ i,
1415
+ setIndex,
1416
+ nestedIndex,
1417
+ ($event.target as HTMLSelectElement).value as NonReplicatorFieldUi['kind'],
1418
+ )
1419
+ "
1420
+ >
1421
+ <option value="text">text</option>
1422
+ <option value="textarea">textarea</option>
1423
+ <option value="blocks">blocks</option>
1424
+ <option value="date">date</option>
1425
+ <option value="boolean">boolean</option>
1426
+ <option value="select">select</option>
1427
+ <option value="relationship">relationship</option>
1428
+ <option value="entry">entry</option>
1429
+ <option value="entries">entries</option>
1430
+ <option value="link">link</option>
1431
+ <option value="asset">asset</option>
1432
+ </select>
1433
+ </label>
1434
+
1435
+ <label class="flex items-center gap-2 pt-6">
1436
+ <input v-model="nested.optional" type="checkbox" class="rounded border-zinc-300" />
1437
+ <span class="text-xs font-medium text-zinc-600">Optional</span>
1438
+ </label>
1439
+ </div>
1440
+
1441
+ <div v-if="nested.ui.kind === 'text' || nested.ui.kind === 'textarea'" class="flex gap-3">
1442
+ <label class="block flex-1">
1443
+ <span class="block text-xs font-medium text-zinc-600">Min length</span>
1444
+ <input
1445
+ type="number"
1446
+ :value="nested.validation?.min ?? ''"
1447
+ class="mt-1 w-full rounded border border-zinc-300 px-3 py-1.5 text-sm"
1448
+ @input="
1449
+ (function() {
1450
+ const v = ($event.target as HTMLInputElement).value;
1451
+ const next: { min?: number; max?: number } = {};
1452
+ if (v !== '') next.min = Number(v);
1453
+ if (nested.validation?.max !== undefined) next.max = nested.validation.max;
1454
+ nested.validation = next;
1455
+ })()
1456
+ "
1457
+ />
1458
+ </label>
1459
+ <label class="block flex-1">
1460
+ <span class="block text-xs font-medium text-zinc-600">Max length</span>
1461
+ <input
1462
+ type="number"
1463
+ :value="nested.validation?.max ?? ''"
1464
+ class="mt-1 w-full rounded border border-zinc-300 px-3 py-1.5 text-sm"
1465
+ @input="
1466
+ (function() {
1467
+ const v = ($event.target as HTMLInputElement).value;
1468
+ const next: { min?: number; max?: number } = {};
1469
+ if (nested.validation?.min !== undefined) next.min = nested.validation.min;
1470
+ if (v !== '') next.max = Number(v);
1471
+ nested.validation = next;
1472
+ })()
1473
+ "
1474
+ />
1475
+ </label>
1476
+ </div>
1477
+
1478
+ <div v-if="nested.ui.kind === 'select'">
1479
+ <span class="block text-xs font-medium text-zinc-600">Options</span>
1480
+ <textarea
1481
+ rows="3"
1482
+ class="mt-1 w-full rounded border border-zinc-300 px-3 py-1.5 font-mono text-xs"
1483
+ :value="formatSelectOptionsText(nested.ui.options ?? [])"
1484
+ @input="
1485
+ nested.ui = updateSelectUi(nested.ui, ($event.target as HTMLTextAreaElement).value)
1486
+ "
1487
+ />
1488
+ </div>
1489
+
1490
+ <label v-if="nested.ui.kind === 'relationship'" class="block">
1491
+ <span class="block text-xs font-medium text-zinc-600">Target collection</span>
1492
+ <select
1493
+ class="mt-1 w-full rounded border border-zinc-300 px-3 py-1.5 text-sm"
1494
+ :value="nested.ui.to ?? ''"
1495
+ @change="
1496
+ nested.ui = {
1497
+ kind: 'relationship',
1498
+ to: ($event.target as HTMLSelectElement).value,
1499
+ }
1500
+ "
1501
+ >
1502
+ <option value="" disabled>Choose a collection</option>
1503
+ <option v-for="bp in blueprintList" :key="bp.handle" :value="bp.handle">{{ bp.handle }}</option>
1504
+ </select>
1505
+ </label>
1506
+
1507
+ <div v-if="nested.ui.kind === 'entry' || nested.ui.kind === 'entries' || nested.ui.kind === 'link'" class="space-y-2">
1508
+ <span class="block text-xs font-medium text-zinc-600">Collections</span>
1509
+ <div class="flex flex-wrap gap-3">
1510
+ <label v-for="bp in blueprintList" :key="bp.handle" class="flex items-center gap-1 text-sm">
1511
+ <input
1512
+ type="checkbox"
1513
+ :checked="(nested.ui.collections ?? []).includes(bp.handle)"
1514
+ @change="toggleCollection(nested.ui, bp.handle, ($event.target as HTMLInputElement).checked)"
1515
+ />
1516
+ {{ bp.handle }}
1517
+ </label>
1518
+ </div>
1519
+ </div>
1520
+
1521
+ <label v-if="nested.ui.kind === 'entries'" class="block">
1522
+ <span class="block text-xs font-medium text-zinc-600">Max entries</span>
1523
+ <input v-model.number="nested.ui.max" type="number" min="1" class="mt-1 w-full rounded border border-zinc-300 px-3 py-1.5 text-sm" />
1524
+ </label>
1525
+
1526
+ <BlocksSetsPicker
1527
+ v-if="nested.ui.kind === 'blocks'"
1528
+ :model-value="nested.ui.sets ?? []"
1529
+ @update:model-value="updateNestedBlocksSets(i, setIndex, nestedIndex, $event)"
1530
+ />
1531
+
1532
+ <div class="flex justify-end">
1533
+ <button
1534
+ type="button"
1535
+ class="rounded px-2 py-1 text-xs text-red-600 hover:bg-red-50"
1536
+ @click="openReplicatorNestedFieldRemovalDialog(i, setIndex, nestedIndex)"
1537
+ >
1538
+ Remove field
1539
+ </button>
1540
+ </div>
1541
+ </div>
1542
+ </div>
1543
+ </div>
1544
+ </div>
1545
+ </div>
1546
+
1547
+ <div v-if="f.ui.kind === 'grid'" class="space-y-3">
1548
+ <div class="flex flex-wrap gap-3">
1549
+ <label class="block flex-1">
1550
+ <span class="block text-xs font-medium text-zinc-600">Min rows</span>
1551
+ <input v-model.number="f.ui.minRows" type="number" min="0" class="mt-1 w-full rounded border border-zinc-300 px-3 py-1.5 text-sm" />
1552
+ </label>
1553
+ <label class="block flex-1">
1554
+ <span class="block text-xs font-medium text-zinc-600">Max rows</span>
1555
+ <input v-model.number="f.ui.maxRows" type="number" min="1" class="mt-1 w-full rounded border border-zinc-300 px-3 py-1.5 text-sm" />
1556
+ </label>
1557
+ <label class="block flex-1">
1558
+ <span class="block text-xs font-medium text-zinc-600">Layout</span>
1559
+ <select v-model="f.ui.mode" class="mt-1 w-full rounded border border-zinc-300 px-3 py-1.5 text-sm">
1560
+ <option value="table">table</option>
1561
+ <option value="stacked">stacked</option>
1562
+ </select>
1563
+ </label>
1564
+ </div>
1565
+ <label class="block">
1566
+ <span class="block text-xs font-medium text-zinc-600">Add row label</span>
1567
+ <input v-model="f.ui.addLabel" type="text" class="mt-1 w-full rounded border border-zinc-300 px-3 py-1.5 text-sm" placeholder="Add row" />
1568
+ </label>
1569
+
1570
+ <div class="flex items-center justify-between">
1571
+ <span class="text-xs font-medium text-zinc-600">Columns</span>
1572
+ <button type="button" class="rounded border border-zinc-300 bg-white px-2.5 py-1 text-xs font-medium text-zinc-700 hover:bg-zinc-50" @click="addGridColumn(i)">
1573
+ + Add column
1574
+ </button>
1575
+ </div>
1576
+
1577
+ <div v-if="f.ui.fields.length === 0" class="rounded border border-dashed border-zinc-300 bg-zinc-50 px-3 py-4 text-xs text-zinc-500">
1578
+ Add at least one column field.
1579
+ </div>
1580
+
1581
+ <div v-for="(nested, nestedIndex) in f.ui.fields" :key="nestedIndex" class="rounded border border-zinc-200 bg-zinc-50 p-3 space-y-3">
1582
+ <div class="grid gap-3 md:grid-cols-2">
1583
+ <label class="block">
1584
+ <span class="block text-xs font-medium text-zinc-600">Name</span>
1585
+ <input v-model="nested.name" class="mt-1 w-full rounded border border-zinc-300 px-3 py-1.5 font-mono text-sm" :readonly="nested.previousName !== null" />
1586
+ </label>
1587
+ <label class="block">
1588
+ <span class="block text-xs font-medium text-zinc-600">Label</span>
1589
+ <input v-model="nested.label" class="mt-1 w-full rounded border border-zinc-300 px-3 py-1.5 text-sm" />
1590
+ </label>
1591
+ </div>
1592
+ <label class="block">
1593
+ <span class="block text-xs font-medium text-zinc-600">Kind</span>
1594
+ <select
1595
+ class="mt-1 w-full rounded border border-zinc-300 px-3 py-1.5 text-sm"
1596
+ :value="nested.ui.kind"
1597
+ @change="setGridNestedKind(i, nestedIndex, ($event.target as HTMLSelectElement).value as NonReplicatorFieldUi['kind'])"
1598
+ >
1599
+ <option value="text">text</option>
1600
+ <option value="textarea">textarea</option>
1601
+ <option value="blocks">blocks</option>
1602
+ <option value="date">date</option>
1603
+ <option value="boolean">boolean</option>
1604
+ <option value="select">select</option>
1605
+ <option value="relationship">relationship</option>
1606
+ <option value="entry">entry</option>
1607
+ <option value="entries">entries</option>
1608
+ <option value="link">link</option>
1609
+ <option value="asset">asset</option>
1610
+ </select>
1611
+ </label>
1612
+ <div v-if="nested.ui.kind === 'select'">
1613
+ <textarea
1614
+ rows="2"
1615
+ class="mt-1 w-full rounded border border-zinc-300 px-3 py-1.5 font-mono text-xs"
1616
+ :value="formatSelectOptionsText(nested.ui.options ?? [])"
1617
+ @input="nested.ui = updateSelectUi(nested.ui, ($event.target as HTMLTextAreaElement).value)"
1618
+ />
1619
+ </div>
1620
+ <label v-if="nested.ui.kind === 'relationship'" class="block">
1621
+ <span class="block text-xs font-medium text-zinc-600">Target collection</span>
1622
+ <select
1623
+ class="mt-1 w-full rounded border border-zinc-300 px-3 py-1.5 text-sm"
1624
+ :value="nested.ui.to ?? ''"
1625
+ @change="nested.ui = { kind: 'relationship', to: ($event.target as HTMLSelectElement).value }"
1626
+ >
1627
+ <option value="" disabled>Choose a collection</option>
1628
+ <option v-for="bp in blueprintList" :key="bp.handle" :value="bp.handle">{{ bp.handle }}</option>
1629
+ </select>
1630
+ </label>
1631
+ <div v-if="nested.ui.kind === 'entry' || nested.ui.kind === 'entries' || nested.ui.kind === 'link'" class="flex flex-wrap gap-3">
1632
+ <label v-for="bp in blueprintList" :key="bp.handle" class="flex items-center gap-1 text-sm">
1633
+ <input
1634
+ type="checkbox"
1635
+ :checked="(nested.ui.collections ?? []).includes(bp.handle)"
1636
+ @change="toggleCollection(nested.ui, bp.handle, ($event.target as HTMLInputElement).checked)"
1637
+ />
1638
+ {{ bp.handle }}
1639
+ </label>
1640
+ </div>
1641
+ <button type="button" class="text-xs text-red-600 hover:bg-red-50 rounded px-2 py-1" @click="removeGridColumn(i, nestedIndex)">
1642
+ Remove column
1643
+ </button>
1644
+ </div>
1645
+ </div>
1646
+ </div>
1647
+ </div>
1648
+ </div>
1649
+
1650
+ <div v-if="submitError" class="rounded bg-red-50 px-3 py-2 text-sm text-red-700">
1651
+ {{ submitError }}
1652
+ </div>
1653
+
1654
+ <div class="flex items-center gap-2">
1655
+ <button
1656
+ type="submit"
1657
+ class="vulse-button-primary rounded px-4 py-2 text-sm font-medium disabled:opacity-50"
1658
+ :disabled="!hydrated || saving || fields.length === 0"
1659
+ :title="!hydrated || fields.length === 0 ? 'Add at least one field before saving.' : undefined"
1660
+ data-testid="blueprint-save"
1661
+ >
1662
+ {{ saving ? 'Saving…' : 'Save' }}
1663
+ </button>
1664
+ <a
1665
+ href="/admin"
1666
+ class="rounded border border-zinc-300 bg-white px-4 py-2 text-sm font-medium text-zinc-700 hover:bg-zinc-50"
1667
+ data-testid="blueprint-cancel"
1668
+ >
1669
+ Cancel
1670
+ </a>
1671
+ <button
1672
+ v-if="!isCreate"
1673
+ type="button"
1674
+ class="ml-auto rounded border border-red-300 bg-white px-4 py-2 text-sm font-medium text-red-600 hover:bg-red-50"
1675
+ data-testid="blueprint-delete"
1676
+ @click="openBlueprintRemovalDialog"
1677
+ >
1678
+ Delete
1679
+ </button>
1680
+ </div>
1681
+ </form>
1682
+
1683
+ <section v-if="!isCreate && hydrated" class="mt-10 max-w-3xl rounded-xl border border-zinc-200 bg-white p-4">
1684
+ <div class="flex items-start justify-between gap-4">
1685
+ <div>
1686
+ <h2 class="text-sm font-semibold text-zinc-800">Scaffold frontend</h2>
1687
+ <p class="mt-1 text-xs text-zinc-500">
1688
+ Generate a code blueprint, Astro index/show pages, and a content.config entry — like Statamic’s scaffold views.
1689
+ Run the CLI locally or copy the files below.
1690
+ </p>
1691
+ </div>
1692
+ <button
1693
+ type="button"
1694
+ class="text-xs text-zinc-500 hover:text-zinc-900"
1695
+ @click="scaffoldOpen = !scaffoldOpen"
1696
+ >
1697
+ {{ scaffoldOpen ? 'Hide' : 'Show' }}
1698
+ </button>
1699
+ </div>
1700
+
1701
+ <div v-if="scaffoldOpen" class="mt-4 space-y-4">
1702
+ <div class="grid gap-3 sm:grid-cols-2">
1703
+ <label class="block text-sm">
1704
+ <span class="font-medium text-zinc-700">Show route</span>
1705
+ <input v-model="scaffoldShowRoute" class="mt-1 w-full rounded border border-zinc-300 px-2 py-1.5 font-mono text-xs" placeholder="/blog/{slug}" />
1706
+ </label>
1707
+ <label class="block text-sm">
1708
+ <span class="font-medium text-zinc-700">Index route</span>
1709
+ <input v-model="scaffoldIndexRoute" class="mt-1 w-full rounded border border-zinc-300 px-2 py-1.5 font-mono text-xs" placeholder="/blog" />
1710
+ <span class="mt-1 block text-xs text-zinc-400">Leave empty to skip the index page.</span>
1711
+ </label>
1712
+ </div>
1713
+
1714
+ <div>
1715
+ <div class="mb-2 flex items-center justify-between">
1716
+ <span class="text-xs font-medium uppercase tracking-wide text-zinc-500">CLI command</span>
1717
+ <button type="button" class="text-xs text-zinc-600 hover:underline" @click="copyText(scaffoldCommand, 'CLI command')">Copy</button>
1718
+ </div>
1719
+ <pre class="overflow-x-auto rounded bg-zinc-50 p-3 text-xs">{{ scaffoldCommand }}</pre>
1720
+ </div>
1721
+
1722
+ <div v-for="file in scaffoldFiles" :key="file.path">
1723
+ <div class="mb-2 flex items-center justify-between">
1724
+ <span class="font-mono text-xs text-zinc-600">{{ file.path }}</span>
1725
+ <button type="button" class="text-xs text-zinc-600 hover:underline" @click="copyText(file.content, file.path)">Copy</button>
1726
+ </div>
1727
+ <pre class="max-h-64 overflow-auto rounded bg-zinc-50 p-3 text-xs">{{ file.content }}</pre>
1728
+ </div>
1729
+
1730
+ <div>
1731
+ <div class="mb-2 flex items-center justify-between">
1732
+ <span class="font-mono text-xs text-zinc-600">src/content.config.ts</span>
1733
+ <button type="button" class="text-xs text-zinc-600 hover:underline" @click="copyText(scaffoldContentConfigSnippet, 'content.config.ts')">Copy</button>
1734
+ </div>
1735
+ <p class="mb-2 text-xs text-zinc-500">Merge into an existing file or use as-is if you do not have one yet.</p>
1736
+ <pre class="max-h-48 overflow-auto rounded bg-zinc-50 p-3 text-xs">{{ scaffoldContentConfigSnippet }}</pre>
1737
+ </div>
1738
+
1739
+ <p v-if="copyNotice" class="text-xs text-green-700">{{ copyNotice }}</p>
1740
+ </div>
1741
+ </section>
1742
+
1743
+ <div
1744
+ v-if="removalTarget"
1745
+ class="fixed inset-0 z-50 flex items-center justify-center bg-black/30 px-4"
1746
+ data-testid="remove-confirmation-modal"
1747
+ >
1748
+ <div class="w-full max-w-md rounded-2xl border border-zinc-200 bg-white p-5 shadow-xl">
1749
+ <h2 class="text-lg font-semibold text-zinc-900">{{ removalDialogTitle }}</h2>
1750
+ <p class="mt-2 text-sm text-zinc-600">{{ removalDialogMessage }}</p>
1751
+ <p v-if="removalTarget.requiresVerification" class="mt-3 text-sm text-zinc-700">
1752
+ Type <span class="font-mono font-medium">{{ removalTarget.name }}</span> to confirm.
1753
+ </p>
1754
+ <input
1755
+ v-if="removalTarget.requiresVerification"
1756
+ v-model="removalVerification"
1757
+ type="text"
1758
+ class="mt-2 w-full rounded border border-zinc-300 px-3 py-2 text-sm"
1759
+ data-testid="remove-confirmation-input"
1760
+ />
1761
+ <div class="mt-5 flex items-center justify-end gap-2">
1762
+ <button
1763
+ type="button"
1764
+ class="rounded border border-zinc-300 bg-white px-4 py-2 text-sm font-medium text-zinc-700 hover:bg-zinc-50"
1765
+ data-testid="remove-confirmation-cancel"
1766
+ @click="closeRemovalDialog"
1767
+ >
1768
+ Cancel
1769
+ </button>
1770
+ <button
1771
+ type="button"
1772
+ class="rounded border border-red-300 bg-red-600 px-4 py-2 text-sm font-medium text-white hover:bg-red-700 disabled:cursor-not-allowed disabled:opacity-50"
1773
+ :disabled="removalConfirmDisabled"
1774
+ data-testid="remove-confirmation-confirm"
1775
+ @click="confirmRemoval"
1776
+ >
1777
+ {{ removalConfirmLabel }}
1778
+ </button>
1779
+ </div>
1780
+ </div>
1781
+ </div>
1782
+ </div>
1783
+ </template>