@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,411 @@
1
+ <script setup lang="ts">
2
+ import { computed, onMounted, ref, watch } from 'vue'
3
+ import { adminApi, AdminApiError } from '../client/api.js'
4
+ import { resolveActiveLocale } from '../client/active-locale.js'
5
+ import type { FieldDescriptor } from '../client/form-from-zod.js'
6
+ import { normalizeSlug } from '../../core/slug.js'
7
+ import { useToast } from '../composables/toast.js'
8
+ import FieldRenderer from './fields/FieldRenderer.vue'
9
+ import EntryStatusBadge from './EntryStatusBadge.vue'
10
+ import SeoFields from './SeoFields.vue'
11
+ import type { SeoContent } from '../../core/blueprints/seo.js'
12
+
13
+ const props = defineProps<{
14
+ collection: string
15
+ entryId?: string
16
+ fields: FieldDescriptor[]
17
+ initial: Record<string, unknown>
18
+ titleField?: string
19
+ draftsEnabled?: boolean
20
+ seoEnabled?: boolean
21
+ seoMapping?: import('../../core/blueprints/seo.js').SeoFieldMapping
22
+ tree?: boolean
23
+ parentId?: string | null
24
+ hasUnpublishedChanges?: boolean
25
+ wide?: boolean
26
+ /** Active locale from the server. Avoid prop name `locale` (Astro/HTML coercion). */
27
+ entryLocale?: string
28
+ supportedLocales?: string[]
29
+ /** Locales that already have a translation for this entry. */
30
+ existingLocales?: string[]
31
+ defaultLocale?: string
32
+ }>()
33
+
34
+ const activeLocale = computed(() =>
35
+ resolveActiveLocale(props.supportedLocales, props.entryLocale, props.defaultLocale),
36
+ )
37
+ const knownLocales = computed(() => props.supportedLocales ?? [activeLocale.value])
38
+ const hasTranslation = computed(() => (props.existingLocales ?? []).includes(activeLocale.value))
39
+
40
+ const emit = defineEmits<{
41
+ previewChange: [{ content: Record<string, unknown>; slug: string }]
42
+ }>()
43
+
44
+ const content = ref<Record<string, unknown>>({ ...props.initial })
45
+ delete content.value.slug
46
+ delete content.value.status
47
+ delete content.value.hasUnpublishedChanges
48
+
49
+ const slug = ref<string>(String(props.initial?.slug ?? ''))
50
+ const slugTouched = ref(!!props.entryId)
51
+ const slugExpanded = ref(false)
52
+ const slugError = ref<string | null>(null)
53
+ const slugNotice = ref<string | null>(null)
54
+ const status = ref<'draft' | 'published'>((props.initial?.status as 'draft' | 'published') ?? 'draft')
55
+ const hasChanges = ref(props.hasUnpublishedChanges ?? false)
56
+ const error = ref<string | null>(null)
57
+ const fieldErrors = ref<Record<string, string>>({})
58
+ const saving = ref(false)
59
+ const lastAction = ref<'draft' | 'publish' | 'save'>('save')
60
+ const toast = useToast()
61
+
62
+ const fieldLabelByPath = computed<Record<string, string>>(() => {
63
+ const map: Record<string, string> = {}
64
+ for (const f of props.fields) map[f.path] = f.label ?? f.path
65
+ return map
66
+ })
67
+
68
+ function emitPreview() {
69
+ emit('previewChange', {
70
+ content: { ...content.value },
71
+ slug: slug.value,
72
+ })
73
+ }
74
+
75
+ const titleFieldLabel = computed(() => {
76
+ if (!props.titleField) return 'title'
77
+ const field = props.fields.find((f) => f.path === props.titleField)
78
+ return field?.label ?? props.titleField
79
+ })
80
+
81
+ function applyApiError(e: AdminApiError) {
82
+ slugError.value = null
83
+ error.value = null
84
+ fieldErrors.value = {}
85
+ const details = e.details as {
86
+ field?: string
87
+ issues?: Array<{ path?: (string | number)[]; message?: string }>
88
+ } | undefined
89
+ const issues = details?.issues ?? []
90
+
91
+ if (details?.field === 'slug') {
92
+ slugError.value = e.message
93
+ slugExpanded.value = true
94
+ return
95
+ }
96
+
97
+ const nextFieldErrors: Record<string, string> = {}
98
+ const unmapped: string[] = []
99
+ for (const issue of issues) {
100
+ const path = issue.path ?? []
101
+ const message = issue.message ?? 'Invalid value'
102
+ if (path[0] === 'slug') {
103
+ slugError.value = message
104
+ slugExpanded.value = true
105
+ continue
106
+ }
107
+ const topLevel = path[0]
108
+ if (typeof topLevel === 'string' && topLevel in fieldLabelByPath.value) {
109
+ // Keep the first error per field — subsequent ones are noise for the user.
110
+ if (!(topLevel in nextFieldErrors)) nextFieldErrors[topLevel] = message
111
+ } else {
112
+ unmapped.push(path.length ? `${path.join('.')}: ${message}` : message)
113
+ }
114
+ }
115
+
116
+ fieldErrors.value = nextFieldErrors
117
+ const fieldCount = Object.keys(nextFieldErrors).length
118
+ if (fieldCount > 0 && unmapped.length === 0) {
119
+ const names = Object.keys(nextFieldErrors)
120
+ .map((p) => fieldLabelByPath.value[p] ?? p)
121
+ .join(', ')
122
+ error.value = `Please fix ${fieldCount === 1 ? 'the issue' : 'the issues'} below (${names}).`
123
+ return
124
+ }
125
+ if (unmapped.length > 0) {
126
+ error.value = unmapped.join('; ')
127
+ return
128
+ }
129
+ error.value = e.message
130
+ }
131
+
132
+ function syncSlugFromResponse(nextSlug: string, requestedSlug: string) {
133
+ if (nextSlug === requestedSlug) return
134
+ slug.value = nextSlug
135
+ slugNotice.value = `URL slug was adjusted to "${nextSlug}" because "${requestedSlug}" is already in use.`
136
+ slugExpanded.value = true
137
+ }
138
+
139
+ function onSeoUpdate(value: SeoContent) {
140
+ content.value = { ...content.value, seo: value }
141
+ emitPreview()
142
+ }
143
+
144
+ function onFieldUpdate(path: string, value: unknown) {
145
+ content.value = { ...content.value, [path]: value }
146
+ if (path in fieldErrors.value) {
147
+ const next = { ...fieldErrors.value }
148
+ delete next[path]
149
+ fieldErrors.value = next
150
+ }
151
+ if (props.titleField && path === props.titleField && !slugTouched.value && typeof value === 'string') {
152
+ slug.value = normalizeSlug(value)
153
+ }
154
+ emitPreview()
155
+ }
156
+
157
+ function resetSlugFromTitle() {
158
+ if (!props.titleField) return
159
+ const raw = content.value[props.titleField]
160
+ if (typeof raw === 'string') slug.value = normalizeSlug(raw)
161
+ slugTouched.value = false
162
+ slugError.value = null
163
+ }
164
+
165
+ function ensureSlugBeforeSave(): boolean {
166
+ slugError.value = null
167
+ if (!slug.value && props.titleField) {
168
+ const raw = content.value[props.titleField]
169
+ if (typeof raw === 'string' && raw.trim()) {
170
+ slug.value = normalizeSlug(raw)
171
+ }
172
+ }
173
+ if (!slug.value.trim()) {
174
+ slugError.value = `Enter a ${titleFieldLabel.value.toLowerCase()} to generate a URL slug.`
175
+ slugExpanded.value = true
176
+ return false
177
+ }
178
+ return true
179
+ }
180
+
181
+ async function save(publish = false) {
182
+ if (!ensureSlugBeforeSave()) return
183
+ saving.value = true
184
+ error.value = null
185
+ slugError.value = null
186
+ slugNotice.value = null
187
+ fieldErrors.value = {}
188
+ lastAction.value = props.draftsEnabled ? (publish ? 'publish' : 'draft') : 'save'
189
+ const requestedSlug = slug.value
190
+ try {
191
+ const body: Record<string, unknown> = {
192
+ content: content.value,
193
+ slug: slug.value,
194
+ locale: activeLocale.value,
195
+ }
196
+ if (props.draftsEnabled) {
197
+ body.publish = publish
198
+ } else {
199
+ body.status = status.value
200
+ }
201
+ if (props.entryId) {
202
+ // If the entry exists but doesn't yet have a row for the active locale,
203
+ // first create that locale translation; subsequent edits use PUT.
204
+ if (!hasTranslation.value) {
205
+ const created = await adminApi.post<{ slug: string }>(
206
+ `/api/vulse/entries/${props.collection}/${props.entryId}/locales`,
207
+ { locale: activeLocale.value, slug: slug.value, content: content.value, status: status.value },
208
+ )
209
+ syncSlugFromResponse(created.slug, requestedSlug)
210
+ window.location.href = `/admin/collections/${props.collection}/${props.entryId}?locale=${encodeURIComponent(activeLocale.value)}`
211
+ return
212
+ }
213
+ const updated = await adminApi.put<{ slug: string }>(`/api/vulse/entries/${props.collection}/${props.entryId}`, body)
214
+ syncSlugFromResponse(updated.slug, requestedSlug)
215
+ hasChanges.value = props.draftsEnabled && !publish
216
+ if (publish) status.value = 'published'
217
+ if (props.draftsEnabled) {
218
+ toast.success(publish ? 'Entry published' : 'Draft saved')
219
+ } else {
220
+ toast.success('Entry saved')
221
+ }
222
+ } else {
223
+ if (props.tree && props.parentId) body.parentId = props.parentId
224
+ if (props.draftsEnabled) body.publish = publish
225
+ else body.status = status.value
226
+ const created = await adminApi.post<{ id: string; slug: string }>(`/api/vulse/entries/${props.collection}`, body)
227
+ syncSlugFromResponse(created.slug, requestedSlug)
228
+ window.location.href = `/admin/collections/${props.collection}/${created.id}?locale=${encodeURIComponent(activeLocale.value)}`
229
+ return
230
+ }
231
+ } catch (e) {
232
+ if (e instanceof AdminApiError) applyApiError(e)
233
+ else error.value = 'Save failed'
234
+ } finally {
235
+ saving.value = false
236
+ }
237
+ }
238
+
239
+ function switchLocale(next: string) {
240
+ if (next === activeLocale.value) return
241
+ const params = new URLSearchParams(window.location.search)
242
+ params.set('locale', next)
243
+ window.location.search = params.toString()
244
+ }
245
+
246
+ async function publishNow() {
247
+ if (!props.entryId || !props.draftsEnabled) return
248
+ saving.value = true
249
+ error.value = null
250
+ try {
251
+ await adminApi.post(
252
+ `/api/vulse/entries/${props.collection}/${props.entryId}/publish?locale=${encodeURIComponent(activeLocale.value)}`,
253
+ )
254
+ hasChanges.value = false
255
+ status.value = 'published'
256
+ toast.success('Entry published')
257
+ } catch (e) {
258
+ if (e instanceof AdminApiError) applyApiError(e)
259
+ else error.value = 'Publish failed'
260
+ } finally {
261
+ saving.value = false
262
+ }
263
+ }
264
+
265
+ watch(slug, () => emitPreview())
266
+ onMounted(() => emitPreview())
267
+ </script>
268
+
269
+ <template>
270
+ <form
271
+ class="vulse-form space-y-5"
272
+ :class="wide ? 'max-w-none' : 'max-w-3xl'"
273
+ @submit.prevent="draftsEnabled ? save(false) : save()"
274
+ >
275
+ <div v-if="entryId" class="flex flex-wrap items-center gap-3">
276
+ <h2 class="text-lg font-semibold text-zinc-900">Entry details</h2>
277
+ <EntryStatusBadge v-if="draftsEnabled" :status="status" :has-unpublished-changes="hasChanges" />
278
+ <div v-if="knownLocales.length > 1" class="ml-auto flex items-center gap-2 text-sm">
279
+ <span class="text-zinc-500">Locale</span>
280
+ <select
281
+ :value="activeLocale"
282
+ class="rounded border border-zinc-300 bg-white px-2 py-1 font-mono"
283
+ @change="switchLocale(($event.target as HTMLSelectElement).value)"
284
+ >
285
+ <option
286
+ v-for="loc in knownLocales"
287
+ :key="loc"
288
+ :value="loc"
289
+ >
290
+ {{ loc }}{{ (existingLocales ?? []).includes(loc) ? '' : ' (no translation)' }}
291
+ </option>
292
+ </select>
293
+ </div>
294
+ </div>
295
+ <p v-if="entryId && !hasTranslation" class="rounded border border-amber-200 bg-amber-50 px-3 py-2 text-sm text-amber-800">
296
+ No <code>{{ activeLocale }}</code> translation yet — saving will create one.
297
+ </p>
298
+
299
+ <FieldRenderer
300
+ v-for="f in fields"
301
+ :key="f.path"
302
+ :field="f"
303
+ :model-value="content[f.path]"
304
+ :field-errors="fieldErrors"
305
+ :tree="tree"
306
+ @update:modelValue="onFieldUpdate(f.path, $event)"
307
+ />
308
+
309
+ <SeoFields
310
+ v-if="seoEnabled"
311
+ :model-value="content.seo as SeoContent | undefined"
312
+ :content="content"
313
+ :fields="fields"
314
+ :title-field="titleField"
315
+ :seo-mapping="seoMapping"
316
+ :field-labels="fieldLabelByPath"
317
+ @update:modelValue="onSeoUpdate"
318
+ />
319
+
320
+ <details
321
+ class="rounded border border-zinc-200 bg-zinc-50 text-sm"
322
+ :open="slugExpanded"
323
+ @toggle="slugExpanded = ($event.target as HTMLDetailsElement).open"
324
+ >
325
+ <summary class="cursor-pointer select-none px-3 py-2.5 text-zinc-600">
326
+ <span class="font-medium">URL slug</span>
327
+ <span class="ml-2 font-mono text-zinc-500">{{ slug || '…' }}</span>
328
+ <span v-if="titleField && !slugTouched" class="ml-2 text-xs text-zinc-400">
329
+ auto-generated from {{ titleFieldLabel.toLowerCase() }}
330
+ </span>
331
+ </summary>
332
+ <div class="space-y-2 border-t border-zinc-200 px-3 py-3">
333
+ <p class="text-xs text-zinc-500">
334
+ The slug is used in the page URL. It is usually generated from the {{ titleFieldLabel.toLowerCase() }}.
335
+ </p>
336
+ <label class="block">
337
+ <span class="vulse-label text-zinc-500">Slug</span>
338
+ <input
339
+ v-model="slug"
340
+ class="vulse-input mt-1 bg-white font-mono text-zinc-700"
341
+ :class="slugError && 'border-red-400'"
342
+ @input="slugTouched = true; slugError = null; slugNotice = null"
343
+ />
344
+ </label>
345
+ <p v-if="slugError" class="text-xs text-red-600">{{ slugError }}</p>
346
+ <p v-else-if="slugNotice" class="text-xs text-amber-700">{{ slugNotice }}</p>
347
+ <button
348
+ v-if="titleField && slugTouched"
349
+ type="button"
350
+ class="text-xs text-zinc-600 underline hover:text-zinc-900"
351
+ @click="resetSlugFromTitle"
352
+ >
353
+ Reset from {{ titleFieldLabel.toLowerCase() }}
354
+ </button>
355
+ </div>
356
+ </details>
357
+
358
+ <label v-if="!draftsEnabled" class="block">
359
+ <span class="vulse-label">Status</span>
360
+ <select v-model="status" class="vulse-input mt-1">
361
+ <option value="draft">Draft</option>
362
+ <option value="published">Published</option>
363
+ </select>
364
+ </label>
365
+
366
+ <p v-if="error" class="rounded bg-red-50 px-3 py-2 text-sm text-red-700">{{ error }}</p>
367
+
368
+ <div class="flex flex-wrap items-center gap-2 pt-2">
369
+ <template v-if="draftsEnabled">
370
+ <button
371
+ type="submit"
372
+ class="vulse-button-primary rounded px-4 py-2 text-sm font-medium disabled:opacity-50"
373
+ :disabled="saving"
374
+ >
375
+ {{ saving && lastAction === 'draft' ? 'Saving…' : 'Save draft' }}
376
+ </button>
377
+ <button
378
+ type="button"
379
+ class="rounded border border-zinc-300 bg-white px-4 py-2 text-sm font-medium text-zinc-700 hover:bg-zinc-50 disabled:opacity-50"
380
+ :disabled="saving"
381
+ @click="save(true)"
382
+ >
383
+ {{ saving && lastAction === 'publish' ? 'Saving…' : 'Save & publish' }}
384
+ </button>
385
+ <button
386
+ v-if="entryId && (hasChanges || status === 'draft')"
387
+ type="button"
388
+ class="rounded border border-emerald-300 bg-emerald-50 px-4 py-2 text-sm font-medium text-emerald-800 hover:bg-emerald-100 disabled:opacity-50"
389
+ :disabled="saving"
390
+ @click="publishNow"
391
+ >
392
+ Publish
393
+ </button>
394
+ </template>
395
+ <button
396
+ v-else
397
+ type="submit"
398
+ class="vulse-button-primary rounded px-4 py-2 text-sm font-medium disabled:opacity-50"
399
+ :disabled="saving"
400
+ >
401
+ {{ saving ? 'Saving…' : 'Save' }}
402
+ </button>
403
+ <a
404
+ :href="`/admin/collections/${collection}`"
405
+ class="rounded border border-zinc-300 bg-white px-4 py-2 text-sm font-medium text-zinc-700 hover:bg-zinc-50"
406
+ >
407
+ Cancel
408
+ </a>
409
+ </div>
410
+ </form>
411
+ </template>
@@ -0,0 +1,121 @@
1
+ <script setup lang="ts">
2
+ import { computed, ref, onMounted, watch } from 'vue'
3
+ import { adminApi } from '../client/api.js'
4
+ import { resolveActiveLocale } from '../client/active-locale.js'
5
+ import CollectionTree from './CollectionTree.vue'
6
+
7
+ const props = defineProps<{
8
+ collection: string
9
+ label: string
10
+ columns: string[]
11
+ tree?: boolean
12
+ /** Active locale from the server. Avoid prop name `locale` (Astro/HTML coercion). */
13
+ entryLocale?: string
14
+ supportedLocales?: string[]
15
+ defaultLocale?: string
16
+ }>()
17
+
18
+ const rows = ref<{ id: string; status: string; slug?: string; hasUnpublishedChanges?: boolean; content?: Record<string, unknown> }[]>([])
19
+ const loading = ref(true)
20
+ const activeLocale = ref(
21
+ resolveActiveLocale(props.supportedLocales, props.entryLocale, props.defaultLocale),
22
+ )
23
+ const knownLocales = computed(() => props.supportedLocales ?? [activeLocale.value])
24
+
25
+ async function load() {
26
+ loading.value = true
27
+ try {
28
+ const qs = new URLSearchParams()
29
+ qs.set('locale', activeLocale.value)
30
+ rows.value = await adminApi.get(`/api/vulse/entries/${props.collection}?${qs.toString()}`)
31
+ } finally {
32
+ loading.value = false
33
+ }
34
+ }
35
+
36
+ function switchLocale(next: string) {
37
+ if (next === activeLocale.value) return
38
+ activeLocale.value = next
39
+ const params = new URLSearchParams(window.location.search)
40
+ params.set('locale', next)
41
+ history.replaceState(null, '', `${window.location.pathname}?${params.toString()}`)
42
+ }
43
+
44
+ onMounted(() => {
45
+ activeLocale.value = resolveActiveLocale(props.supportedLocales, props.entryLocale, props.defaultLocale)
46
+ if (!props.tree) void load()
47
+ else loading.value = false
48
+ })
49
+
50
+ watch(activeLocale, () => {
51
+ if (!props.tree) void load()
52
+ })
53
+ </script>
54
+
55
+ <template>
56
+ <div>
57
+ <div class="mb-6 flex items-center justify-between gap-3">
58
+ <h1 class="text-2xl font-semibold text-zinc-900">{{ label }}</h1>
59
+ <div class="flex items-center gap-3">
60
+ <div v-if="knownLocales.length > 1" class="flex items-center gap-2 text-sm">
61
+ <span class="text-zinc-500">Locale</span>
62
+ <select
63
+ :value="activeLocale"
64
+ class="rounded border border-zinc-300 bg-white px-2 py-1 font-mono"
65
+ @change="switchLocale(($event.target as HTMLSelectElement).value)"
66
+ >
67
+ <option v-for="loc in knownLocales" :key="loc" :value="loc">{{ loc }}</option>
68
+ </select>
69
+ </div>
70
+ <a
71
+ :href="`/admin/collections/${collection}/new?locale=${encodeURIComponent(activeLocale)}`"
72
+ class="vulse-button-primary rounded px-4 py-2 text-sm font-medium"
73
+ >
74
+ + New
75
+ </a>
76
+ </div>
77
+ </div>
78
+
79
+ <CollectionTree v-if="tree" :handle="collection" :entry-locale="activeLocale" />
80
+
81
+ <div v-else-if="loading" class="text-sm text-zinc-500">Loading…</div>
82
+
83
+ <div
84
+ v-else-if="rows.length === 0"
85
+ class="rounded border border-dashed border-zinc-300 bg-white p-8 text-center text-sm text-zinc-500"
86
+ >
87
+ No <code>{{ activeLocale }}</code> entries yet. Create your first one with “+ New”.
88
+ </div>
89
+
90
+ <div v-else class="overflow-hidden rounded border border-zinc-200 bg-white">
91
+ <table class="w-full text-left text-sm">
92
+ <thead class="border-b border-zinc-200 bg-zinc-50">
93
+ <tr>
94
+ <th v-for="c in columns" :key="c" class="p-3 font-medium text-zinc-600">{{ c }}</th>
95
+ <th class="p-3 font-medium text-zinc-600">Status</th>
96
+ </tr>
97
+ </thead>
98
+ <tbody>
99
+ <tr v-for="r in rows" :key="r.id" class="border-b border-zinc-100 last:border-0 hover:bg-zinc-50">
100
+ <td v-for="c in columns" :key="c" class="p-3">
101
+ <a
102
+ :href="`/admin/collections/${collection}/${r.id}?locale=${encodeURIComponent(activeLocale)}`"
103
+ class="font-medium text-zinc-900 hover:underline"
104
+ >
105
+ {{ r.content?.[c] ?? r.slug ?? '—' }}
106
+ </a>
107
+ </td>
108
+ <td class="p-3">
109
+ <span
110
+ class="rounded px-2 py-0.5 text-xs"
111
+ :class="r.status === 'published' ? 'bg-emerald-50 text-emerald-800' : 'bg-zinc-100 text-zinc-700'"
112
+ >
113
+ {{ r.hasUnpublishedChanges ? `${r.status} · changes` : r.status }}
114
+ </span>
115
+ </td>
116
+ </tr>
117
+ </tbody>
118
+ </table>
119
+ </div>
120
+ </div>
121
+ </template>
@@ -0,0 +1,24 @@
1
+ <script setup lang="ts">
2
+ import { computed } from 'vue'
3
+
4
+ const props = defineProps<{
5
+ status: 'draft' | 'published'
6
+ hasUnpublishedChanges?: boolean
7
+ }>()
8
+
9
+ const label = computed(() => {
10
+ if (props.status === 'draft') return 'Draft'
11
+ if (props.hasUnpublishedChanges) return 'Published · unpublished changes'
12
+ return 'Published'
13
+ })
14
+
15
+ const tone = computed(() => {
16
+ if (props.status === 'draft') return 'bg-zinc-100 text-zinc-700'
17
+ if (props.hasUnpublishedChanges) return 'bg-amber-50 text-amber-800'
18
+ return 'bg-emerald-50 text-emerald-800'
19
+ })
20
+ </script>
21
+
22
+ <template>
23
+ <span class="rounded px-2 py-0.5 text-xs font-medium" :class="tone">{{ label }}</span>
24
+ </template>