@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,33 @@
1
+ <script setup lang="ts">
2
+ import { useToast } from '../composables/toast.js'
3
+
4
+ const { toasts, dismiss } = useToast()
5
+ </script>
6
+
7
+ <template>
8
+ <div
9
+ class="pointer-events-none fixed bottom-4 right-4 z-50 flex w-full max-w-sm flex-col gap-2"
10
+ aria-live="polite"
11
+ aria-relevant="additions"
12
+ >
13
+ <div
14
+ v-for="toast in toasts"
15
+ :key="toast.id"
16
+ class="pointer-events-auto flex items-start gap-3 rounded-lg border px-4 py-3 text-sm shadow-lg"
17
+ :class="toast.kind === 'success'
18
+ ? 'border-emerald-200 bg-emerald-50 text-emerald-900'
19
+ : 'border-red-200 bg-red-50 text-red-900'"
20
+ role="status"
21
+ >
22
+ <span class="flex-1">{{ toast.message }}</span>
23
+ <button
24
+ type="button"
25
+ class="rounded px-1 text-xs opacity-70 hover:opacity-100"
26
+ aria-label="Dismiss notification"
27
+ @click="dismiss(toast.id)"
28
+ >
29
+
30
+ </button>
31
+ </div>
32
+ </div>
33
+ </template>
@@ -0,0 +1,163 @@
1
+ <script setup lang="ts">
2
+ import { computed } from 'vue'
3
+ import type { EntryNode } from '../../core/repos/entries.js'
4
+
5
+ const props = defineProps<{
6
+ node: EntryNode
7
+ handle: string
8
+ depth: number
9
+ expandedSet: Set<string>
10
+ draggingId: string | null
11
+ disabled: boolean
12
+ }>()
13
+
14
+ const emit = defineEmits<{
15
+ toggle: [id: string]
16
+ 'move-up': [id: string]
17
+ 'move-down': [id: string]
18
+ outdent: [id: string]
19
+ indent: [id: string]
20
+ 'drag-start': [event: DragEvent, id: string]
21
+ 'drag-over': [event: DragEvent]
22
+ 'drop-onto': [event: DragEvent, id: string | null]
23
+ destroy: [id: string, label: string]
24
+ }>()
25
+
26
+ const hasChildren = computed(() => props.node.children.length > 0)
27
+ const isOpen = computed(() => props.expandedSet.has(props.node.id))
28
+ const isDragging = computed(() => props.draggingId === props.node.id)
29
+
30
+ function label(): string {
31
+ const c = props.node.content as Record<string, unknown>
32
+ return (
33
+ (c.title as string | undefined) ??
34
+ (c.name as string | undefined) ??
35
+ (c.label as string | undefined) ??
36
+ props.node.slug ??
37
+ props.node.id
38
+ )
39
+ }
40
+ </script>
41
+
42
+ <template>
43
+ <li
44
+ :data-testid="`tree-row-${node.id}`"
45
+ :draggable="!disabled"
46
+ :class="['flex flex-col', isDragging ? 'opacity-50' : '']"
47
+ @dragstart="emit('drag-start', $event, node.id)"
48
+ >
49
+ <div
50
+ class="flex items-center gap-1 px-2 py-1.5 hover:bg-zinc-50"
51
+ :style="{ paddingLeft: `${depth * 1.25 + 0.5}rem` }"
52
+ @dragover="emit('drag-over', $event)"
53
+ @drop="emit('drop-onto', $event, node.id)"
54
+ >
55
+ <button
56
+ v-if="hasChildren"
57
+ type="button"
58
+ class="flex h-5 w-5 items-center justify-center rounded text-zinc-500 hover:bg-zinc-200"
59
+ :aria-expanded="isOpen"
60
+ :data-testid="`tree-toggle-${node.id}`"
61
+ @click="emit('toggle', node.id)"
62
+ >
63
+ {{ isOpen ? '▾' : '▸' }}
64
+ </button>
65
+ <span v-else class="inline-block h-5 w-5"></span>
66
+ <a
67
+ :href="`/admin/collections/${handle}/${node.id}`"
68
+ class="flex-1 truncate text-sm text-zinc-800 hover:underline"
69
+ :data-testid="`tree-link-${node.id}`"
70
+ >
71
+ {{ label() }}
72
+ </a>
73
+ <span
74
+ v-if="node.hasUnpublishedChanges"
75
+ class="rounded bg-amber-50 px-1.5 py-0.5 text-[10px] text-amber-800"
76
+ >
77
+ draft
78
+ </span>
79
+ <span class="hidden text-[10px] text-zinc-400 sm:inline">#{{ node.sortOrder }}</span>
80
+ <div class="flex shrink-0 items-center gap-0.5 text-zinc-400">
81
+ <button
82
+ type="button"
83
+ class="rounded px-1.5 py-0.5 text-xs hover:bg-zinc-200 hover:text-zinc-700 disabled:opacity-40"
84
+ :disabled="disabled"
85
+ title="Move up"
86
+ :data-testid="`tree-up-${node.id}`"
87
+ @click="emit('move-up', node.id)"
88
+ >
89
+
90
+ </button>
91
+ <button
92
+ type="button"
93
+ class="rounded px-1.5 py-0.5 text-xs hover:bg-zinc-200 hover:text-zinc-700 disabled:opacity-40"
94
+ :disabled="disabled"
95
+ title="Move down"
96
+ :data-testid="`tree-down-${node.id}`"
97
+ @click="emit('move-down', node.id)"
98
+ >
99
+
100
+ </button>
101
+ <button
102
+ type="button"
103
+ class="rounded px-1.5 py-0.5 text-xs hover:bg-zinc-200 hover:text-zinc-700 disabled:opacity-40"
104
+ :disabled="disabled || node.parentId === null"
105
+ title="Outdent"
106
+ :data-testid="`tree-outdent-${node.id}`"
107
+ @click="emit('outdent', node.id)"
108
+ >
109
+
110
+ </button>
111
+ <button
112
+ type="button"
113
+ class="rounded px-1.5 py-0.5 text-xs hover:bg-zinc-200 hover:text-zinc-700 disabled:opacity-40"
114
+ :disabled="disabled"
115
+ title="Indent"
116
+ :data-testid="`tree-indent-${node.id}`"
117
+ @click="emit('indent', node.id)"
118
+ >
119
+
120
+ </button>
121
+ <a
122
+ :href="`/admin/collections/${handle}/new?parent_id=${node.id}`"
123
+ class="rounded px-1.5 py-0.5 text-xs hover:bg-zinc-200 hover:text-zinc-700"
124
+ title="Add child"
125
+ :data-testid="`tree-add-child-${node.id}`"
126
+ >
127
+ +
128
+ </a>
129
+ <button
130
+ type="button"
131
+ class="rounded px-1.5 py-0.5 text-xs text-red-500 hover:bg-red-50 disabled:opacity-40"
132
+ :disabled="disabled"
133
+ title="Delete"
134
+ :data-testid="`tree-delete-${node.id}`"
135
+ @click="emit('destroy', node.id, label())"
136
+ >
137
+ ×
138
+ </button>
139
+ </div>
140
+ </div>
141
+ <ul v-if="hasChildren && isOpen" class="border-t border-zinc-100">
142
+ <TreeRow
143
+ v-for="child in node.children"
144
+ :key="child.id"
145
+ :node="child"
146
+ :handle="handle"
147
+ :depth="depth + 1"
148
+ :expanded-set="expandedSet"
149
+ :dragging-id="draggingId"
150
+ :disabled="disabled"
151
+ @toggle="(id) => emit('toggle', id)"
152
+ @move-up="(id) => emit('move-up', id)"
153
+ @move-down="(id) => emit('move-down', id)"
154
+ @outdent="(id) => emit('outdent', id)"
155
+ @indent="(id) => emit('indent', id)"
156
+ @drag-start="(e, id) => emit('drag-start', e, id)"
157
+ @drag-over="(e) => emit('drag-over', e)"
158
+ @drop-onto="(e, id) => emit('drop-onto', e, id)"
159
+ @destroy="(id, lbl) => emit('destroy', id, lbl)"
160
+ />
161
+ </ul>
162
+ </li>
163
+ </template>
@@ -0,0 +1,186 @@
1
+ <script setup lang="ts">
2
+ import { onMounted, reactive, ref } from 'vue'
3
+ import { adminApi } from '../client/api.js'
4
+
5
+ const props = defineProps<{ userId: string }>()
6
+
7
+ interface UserRecord {
8
+ id: string
9
+ email: string
10
+ name: string
11
+ role: 'admin' | 'editor' | 'member'
12
+ displayName: string | null
13
+ }
14
+
15
+ const form = reactive({
16
+ name: '',
17
+ displayName: '',
18
+ role: 'member' as UserRecord['role'],
19
+ })
20
+ const email = ref('')
21
+ const loading = ref(true)
22
+ const saving = ref(false)
23
+ const resetSending = ref(false)
24
+ const settingPassword = ref(false)
25
+ const newPassword = ref('')
26
+ const error = ref<string | null>(null)
27
+ const notice = ref<string | null>(null)
28
+
29
+ async function load() {
30
+ loading.value = true
31
+ error.value = null
32
+ try {
33
+ const user = await adminApi.get<UserRecord>(`/api/vulse/users/${props.userId}`)
34
+ email.value = user.email
35
+ form.name = user.name
36
+ form.displayName = user.displayName ?? ''
37
+ form.role = user.role
38
+ } catch (e) {
39
+ error.value = e instanceof Error ? e.message : 'Failed to load user'
40
+ } finally {
41
+ loading.value = false
42
+ }
43
+ }
44
+
45
+ onMounted(load)
46
+
47
+ async function save() {
48
+ saving.value = true
49
+ error.value = null
50
+ notice.value = null
51
+ try {
52
+ await adminApi.patch(`/api/vulse/users/${props.userId}`, {
53
+ name: form.name,
54
+ displayName: form.displayName || null,
55
+ role: form.role,
56
+ })
57
+ notice.value = 'User saved.'
58
+ } catch (e) {
59
+ error.value = e instanceof Error ? e.message : 'Save failed'
60
+ } finally {
61
+ saving.value = false
62
+ }
63
+ }
64
+
65
+ async function sendResetEmail() {
66
+ resetSending.value = true
67
+ error.value = null
68
+ notice.value = null
69
+ try {
70
+ await adminApi.post(`/api/vulse/users/${props.userId}/reset-password`, { action: 'email' })
71
+ notice.value = 'Password reset email sent (or logged in development).'
72
+ } catch (e) {
73
+ error.value = e instanceof Error ? e.message : 'Could not send reset email'
74
+ } finally {
75
+ resetSending.value = false
76
+ }
77
+ }
78
+
79
+ async function setPassword() {
80
+ if (newPassword.value.length < 8) {
81
+ error.value = 'Password must be at least 8 characters.'
82
+ return
83
+ }
84
+ settingPassword.value = true
85
+ error.value = null
86
+ notice.value = null
87
+ try {
88
+ await adminApi.post(`/api/vulse/users/${props.userId}/reset-password`, {
89
+ action: 'set',
90
+ password: newPassword.value,
91
+ })
92
+ newPassword.value = ''
93
+ notice.value = 'Password updated.'
94
+ } catch (e) {
95
+ error.value = e instanceof Error ? e.message : 'Could not set password'
96
+ } finally {
97
+ settingPassword.value = false
98
+ }
99
+ }
100
+ </script>
101
+
102
+ <template>
103
+ <div>
104
+ <div class="mb-6 flex items-center gap-3">
105
+ <a href="/admin/users" class="text-sm text-zinc-500 hover:text-zinc-800">← Users</a>
106
+ </div>
107
+
108
+ <div v-if="loading" class="text-sm text-zinc-500">Loading…</div>
109
+
110
+ <div v-else class="max-w-xl space-y-6">
111
+ <h1 class="text-2xl font-semibold">Edit user</h1>
112
+
113
+ <div class="space-y-4 rounded-xl border border-zinc-200 bg-white p-4">
114
+ <label class="block">
115
+ <span class="text-sm font-medium text-zinc-700">Email</span>
116
+ <input :value="email" disabled class="mt-1 w-full rounded-lg border border-zinc-300 bg-zinc-50 px-3 py-2 text-sm" />
117
+ </label>
118
+ <label class="block">
119
+ <span class="text-sm font-medium text-zinc-700">Name</span>
120
+ <input v-model="form.name" required class="mt-1 w-full rounded-lg border border-zinc-300 px-3 py-2 text-sm" />
121
+ </label>
122
+ <label class="block">
123
+ <span class="text-sm font-medium text-zinc-700">Display name</span>
124
+ <input v-model="form.displayName" class="mt-1 w-full rounded-lg border border-zinc-300 px-3 py-2 text-sm" />
125
+ <span class="mt-1 block text-xs text-zinc-500">Optional public-facing name.</span>
126
+ </label>
127
+ <label class="block">
128
+ <span class="text-sm font-medium text-zinc-700">Role</span>
129
+ <select v-model="form.role" class="mt-1 w-full rounded-lg border border-zinc-300 px-3 py-2 text-sm">
130
+ <option value="admin">admin</option>
131
+ <option value="editor">editor</option>
132
+ <option value="member">member</option>
133
+ </select>
134
+ </label>
135
+ </div>
136
+
137
+ <div class="space-y-4 rounded-xl border border-zinc-200 bg-white p-4">
138
+ <h2 class="text-sm font-semibold text-zinc-700">Password</h2>
139
+ <p class="text-sm text-zinc-500">Send a reset link to the user's email, or set a new password directly.</p>
140
+ <div class="flex flex-wrap gap-2">
141
+ <button
142
+ type="button"
143
+ class="rounded-lg border border-zinc-300 px-4 py-2 text-sm"
144
+ :disabled="resetSending"
145
+ @click="sendResetEmail"
146
+ >
147
+ {{ resetSending ? 'Sending…' : 'Send reset email' }}
148
+ </button>
149
+ </div>
150
+ <div class="flex flex-wrap items-end gap-2">
151
+ <label class="block flex-1 min-w-[12rem]">
152
+ <span class="text-sm font-medium text-zinc-700">Set new password</span>
153
+ <input
154
+ v-model="newPassword"
155
+ type="password"
156
+ minlength="8"
157
+ autocomplete="new-password"
158
+ class="mt-1 w-full rounded-lg border border-zinc-300 px-3 py-2 text-sm"
159
+ placeholder="At least 8 characters"
160
+ />
161
+ </label>
162
+ <button
163
+ type="button"
164
+ class="rounded-lg border border-zinc-300 px-4 py-2 text-sm"
165
+ :disabled="settingPassword || !newPassword"
166
+ @click="setPassword"
167
+ >
168
+ {{ settingPassword ? 'Updating…' : 'Set password' }}
169
+ </button>
170
+ </div>
171
+ </div>
172
+
173
+ <div v-if="error" class="rounded-lg bg-red-50 px-3 py-2 text-sm text-red-700">{{ error }}</div>
174
+ <div v-if="notice" class="rounded-lg bg-green-50 px-3 py-2 text-sm text-green-700">{{ notice }}</div>
175
+
176
+ <button
177
+ type="button"
178
+ class="vulse-button-primary rounded-lg px-4 py-2 text-sm font-medium"
179
+ :disabled="saving"
180
+ @click="save"
181
+ >
182
+ {{ saving ? 'Saving…' : 'Save changes' }}
183
+ </button>
184
+ </div>
185
+ </div>
186
+ </template>
@@ -0,0 +1,46 @@
1
+ <script setup lang="ts">
2
+ import { ref, onMounted } from 'vue'
3
+ import { adminApi } from '../client/api'
4
+
5
+ const users = ref<{ id: string; email: string; name: string; role: string }[]>([])
6
+
7
+ onMounted(async () => { users.value = await adminApi.get('/api/vulse/users') })
8
+
9
+ async function setRole(id: string, role: string) {
10
+ await adminApi.post(`/api/vulse/users/${id}/role`, { role })
11
+ users.value = users.value.map((u) => (u.id === id ? { ...u, role } : u))
12
+ }
13
+ </script>
14
+
15
+ <template>
16
+ <table class="w-full bg-white border rounded text-sm">
17
+ <thead>
18
+ <tr class="border-b text-left">
19
+ <th class="p-3">Email</th>
20
+ <th class="p-3">Name</th>
21
+ <th class="p-3">Role</th>
22
+ <th class="p-3 w-24" />
23
+ </tr>
24
+ </thead>
25
+ <tbody>
26
+ <tr v-for="u in users" :key="u.id" class="border-b">
27
+ <td class="p-3">{{ u.email }}</td>
28
+ <td class="p-3">{{ u.name }}</td>
29
+ <td class="p-3">
30
+ <select
31
+ :value="u.role"
32
+ class="rounded border px-2 py-1"
33
+ @change="setRole(u.id, ($event.target as HTMLSelectElement).value)"
34
+ >
35
+ <option>admin</option>
36
+ <option>editor</option>
37
+ <option>member</option>
38
+ </select>
39
+ </td>
40
+ <td class="p-3 text-right">
41
+ <a :href="`/admin/users/${u.id}`" class="text-brand hover:underline">Edit</a>
42
+ </td>
43
+ </tr>
44
+ </tbody>
45
+ </table>
46
+ </template>
@@ -0,0 +1,32 @@
1
+ <script setup lang="ts">
2
+ import type { Block } from '../../../core/blocks/schema'
3
+ import HeadingEdit from './edit/HeadingEdit.vue'
4
+ import ParagraphEdit from './edit/ParagraphEdit.vue'
5
+ import ImageEdit from './edit/ImageEdit.vue'
6
+ import CodeEdit from './edit/CodeEdit.vue'
7
+ import EmbedEdit from './edit/EmbedEdit.vue'
8
+ import QuoteEdit from './edit/QuoteEdit.vue'
9
+ import ListEdit from './edit/ListEdit.vue'
10
+
11
+ defineProps<{ block: Block; index: number; total: number }>()
12
+ defineEmits<{ (e: 'update', b: Block): void; (e: 'remove'): void; (e: 'move', dir: -1 | 1): void }>()
13
+ </script>
14
+
15
+ <template>
16
+ <div class="p-4 flex gap-3 group">
17
+ <div class="flex flex-col gap-1 text-zinc-400">
18
+ <button type="button" @click="$emit('move', -1)" :disabled="index === 0" class="text-sm">↑</button>
19
+ <button type="button" @click="$emit('move', 1)" :disabled="index === total - 1" class="text-sm">↓</button>
20
+ <button type="button" @click="$emit('remove')" class="text-sm text-red-600 opacity-0 group-hover:opacity-100">×</button>
21
+ </div>
22
+ <div class="flex-1">
23
+ <HeadingEdit v-if="block.type === 'heading'" :model-value="block" @update:modelValue="$emit('update', $event)" />
24
+ <ParagraphEdit v-else-if="block.type === 'paragraph'" :model-value="block" @update:modelValue="$emit('update', $event)" />
25
+ <ImageEdit v-else-if="block.type === 'image'" :model-value="block" @update:modelValue="$emit('update', $event)" />
26
+ <CodeEdit v-else-if="block.type === 'code'" :model-value="block" @update:modelValue="$emit('update', $event)" />
27
+ <EmbedEdit v-else-if="block.type === 'embed'" :model-value="block" @update:modelValue="$emit('update', $event)" />
28
+ <QuoteEdit v-else-if="block.type === 'quote'" :model-value="block" @update:modelValue="$emit('update', $event)" />
29
+ <ListEdit v-else-if="block.type === 'list'" :model-value="block" @update:modelValue="$emit('update', $event)" />
30
+ </div>
31
+ </div>
32
+ </template>
@@ -0,0 +1,12 @@
1
+ <script setup lang="ts">
2
+ import type { BlockType } from '../../../core/blocks/schema'
3
+ import { BUILT_IN_BLOCK_TYPES } from '../../../core/blocks/schema'
4
+ defineEmits<{ (e: 'add', t: BlockType): void }>()
5
+ </script>
6
+ <template>
7
+ <div class="p-2 flex flex-wrap gap-1 border-t bg-zinc-50">
8
+ <button v-for="t in BUILT_IN_BLOCK_TYPES" :key="t" type="button"
9
+ @click="$emit('add', t)"
10
+ class="px-3 py-1 rounded border bg-white text-sm hover:bg-zinc-100">+ {{ t }}</button>
11
+ </div>
12
+ </template>
@@ -0,0 +1,18 @@
1
+ <script setup lang="ts">
2
+ import type { z } from 'astro/zod'
3
+ import { codeBlock } from '../../../../core/blocks/schema'
4
+ type Block = z.infer<typeof codeBlock>
5
+ const props = defineProps<{ modelValue: Block }>()
6
+ const emit = defineEmits<{ (e: 'update:modelValue', v: Block): void }>()
7
+ function update<K extends keyof Block>(k: K, v: Block[K]) {
8
+ emit('update:modelValue', { ...props.modelValue, [k]: v })
9
+ }
10
+ </script>
11
+ <template>
12
+ <div class="space-y-2">
13
+ <input :value="modelValue.language" @input="update('language', ($event.target as HTMLInputElement).value)"
14
+ placeholder="Language" class="w-full rounded border px-3 py-2 text-sm" />
15
+ <textarea :value="modelValue.code" @input="update('code', ($event.target as HTMLTextAreaElement).value)"
16
+ rows="6" placeholder="Code…" class="w-full rounded border px-3 py-2 font-mono text-sm"></textarea>
17
+ </div>
18
+ </template>
@@ -0,0 +1,14 @@
1
+ <script setup lang="ts">
2
+ import type { z } from 'astro/zod'
3
+ import { embedBlock } from '../../../../core/blocks/schema'
4
+ type Block = z.infer<typeof embedBlock>
5
+ const props = defineProps<{ modelValue: Block }>()
6
+ const emit = defineEmits<{ (e: 'update:modelValue', v: Block): void }>()
7
+ function update<K extends keyof Block>(k: K, v: Block[K]) {
8
+ emit('update:modelValue', { ...props.modelValue, [k]: v })
9
+ }
10
+ </script>
11
+ <template>
12
+ <input :value="modelValue.url" @input="update('url', ($event.target as HTMLInputElement).value)"
13
+ type="url" placeholder="https://…" class="w-full rounded border px-3 py-2" />
14
+ </template>
@@ -0,0 +1,19 @@
1
+ <script setup lang="ts">
2
+ import type { z } from 'astro/zod'
3
+ import { headingBlock } from '../../../../core/blocks/schema'
4
+ type Block = z.infer<typeof headingBlock>
5
+ const props = defineProps<{ modelValue: Block }>()
6
+ const emit = defineEmits<{ (e: 'update:modelValue', v: Block): void }>()
7
+ function update<K extends keyof Block>(k: K, v: Block[K]) {
8
+ emit('update:modelValue', { ...props.modelValue, [k]: v })
9
+ }
10
+ </script>
11
+ <template>
12
+ <div class="space-y-2">
13
+ <select :value="modelValue.level" @change="update('level', Number(($event.target as HTMLSelectElement).value) as Block['level'])" class="rounded border px-2 py-1 text-sm">
14
+ <option :value="1">H1</option><option :value="2">H2</option><option :value="3">H3</option><option :value="4">H4</option>
15
+ </select>
16
+ <input :value="modelValue.text" @input="update('text', ($event.target as HTMLInputElement).value)"
17
+ placeholder="Heading…" class="w-full rounded border px-3 py-2" />
18
+ </div>
19
+ </template>
@@ -0,0 +1,40 @@
1
+ <script setup lang="ts">
2
+ import type { z } from 'astro/zod'
3
+ import { imageBlock } from '../../../../core/blocks/schema'
4
+ import MediaField from '../../fields/MediaField.vue'
5
+
6
+ type Block = z.infer<typeof imageBlock>
7
+
8
+ const props = defineProps<{ modelValue: Block }>()
9
+ const emit = defineEmits<{ (e: 'update:modelValue', v: Block): void }>()
10
+
11
+ function update<K extends keyof Block>(k: K, v: Block[K]) {
12
+ emit('update:modelValue', { ...props.modelValue, [k]: v })
13
+ }
14
+ </script>
15
+
16
+ <template>
17
+ <div class="space-y-2">
18
+ <MediaField
19
+ :model-value="modelValue.mediaId || null"
20
+ label="Image"
21
+ @update:modelValue="update('mediaId', $event ?? '')"
22
+ />
23
+ <label class="block">
24
+ <span class="vulse-label">Alt text</span>
25
+ <input
26
+ :value="modelValue.alt"
27
+ class="vulse-input mt-1"
28
+ @input="update('alt', ($event.target as HTMLInputElement).value)"
29
+ />
30
+ </label>
31
+ <label class="block">
32
+ <span class="vulse-label">Caption</span>
33
+ <input
34
+ :value="modelValue.caption ?? ''"
35
+ class="vulse-input mt-1"
36
+ @input="update('caption', ($event.target as HTMLInputElement).value)"
37
+ />
38
+ </label>
39
+ </div>
40
+ </template>
@@ -0,0 +1,36 @@
1
+ <script setup lang="ts">
2
+ import type { z } from 'astro/zod'
3
+ import { listBlock } from '../../../../core/blocks/schema'
4
+ type Block = z.infer<typeof listBlock>
5
+ const props = defineProps<{ modelValue: Block }>()
6
+ const emit = defineEmits<{ (e: 'update:modelValue', v: Block): void }>()
7
+ function updateItems(items: string[]) {
8
+ emit('update:modelValue', { ...props.modelValue, items })
9
+ }
10
+ function updateItem(i: number, v: string) {
11
+ const next = [...props.modelValue.items]
12
+ next[i] = v
13
+ updateItems(next)
14
+ }
15
+ function addItem() { updateItems([...props.modelValue.items, '']) }
16
+ function removeItem(i: number) {
17
+ const next = [...props.modelValue.items]
18
+ next.splice(i, 1)
19
+ updateItems(next.length ? next : [''])
20
+ }
21
+ </script>
22
+ <template>
23
+ <div class="space-y-2">
24
+ <label class="flex items-center gap-2 text-sm">
25
+ <input type="checkbox" :checked="modelValue.ordered"
26
+ @change="emit('update:modelValue', { ...modelValue, ordered: ($event.target as HTMLInputElement).checked })" />
27
+ Ordered list
28
+ </label>
29
+ <div v-for="(item, i) in modelValue.items" :key="i" class="flex gap-2">
30
+ <input :value="item" @input="updateItem(i, ($event.target as HTMLInputElement).value)"
31
+ class="flex-1 rounded border px-3 py-2" />
32
+ <button type="button" @click="removeItem(i)" class="text-sm text-red-600">×</button>
33
+ </div>
34
+ <button type="button" @click="addItem" class="text-sm rounded border px-3 py-1">Add item</button>
35
+ </div>
36
+ </template>
@@ -0,0 +1,14 @@
1
+ <script setup lang="ts">
2
+ import type { z } from 'astro/zod'
3
+ import { paragraphBlock } from '../../../../core/blocks/schema'
4
+ type Block = z.infer<typeof paragraphBlock>
5
+ const props = defineProps<{ modelValue: Block }>()
6
+ const emit = defineEmits<{ (e: 'update:modelValue', v: Block): void }>()
7
+ function update<K extends keyof Block>(k: K, v: Block[K]) {
8
+ emit('update:modelValue', { ...props.modelValue, [k]: v })
9
+ }
10
+ </script>
11
+ <template>
12
+ <textarea :value="modelValue.text" @input="update('text', ($event.target as HTMLTextAreaElement).value)"
13
+ rows="4" placeholder="Paragraph…" class="w-full rounded border px-3 py-2"></textarea>
14
+ </template>
@@ -0,0 +1,18 @@
1
+ <script setup lang="ts">
2
+ import type { z } from 'astro/zod'
3
+ import { quoteBlock } from '../../../../core/blocks/schema'
4
+ type Block = z.infer<typeof quoteBlock>
5
+ const props = defineProps<{ modelValue: Block }>()
6
+ const emit = defineEmits<{ (e: 'update:modelValue', v: Block): void }>()
7
+ function update<K extends keyof Block>(k: K, v: Block[K]) {
8
+ emit('update:modelValue', { ...props.modelValue, [k]: v })
9
+ }
10
+ </script>
11
+ <template>
12
+ <div class="space-y-2">
13
+ <textarea :value="modelValue.text" @input="update('text', ($event.target as HTMLTextAreaElement).value)"
14
+ rows="3" placeholder="Quote…" class="w-full rounded border px-3 py-2"></textarea>
15
+ <input :value="modelValue.cite ?? ''" @input="update('cite', ($event.target as HTMLInputElement).value)"
16
+ placeholder="Citation" class="w-full rounded border px-3 py-2 text-sm" />
17
+ </div>
18
+ </template>