@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.
- package/dist/cli/migrate.d.ts.map +1 -1
- package/dist/cli/migrate.js +3 -4
- package/dist/cli/setup.d.ts.map +1 -1
- package/dist/cli/setup.js +11 -10
- package/dist/core/blueprints/compile.d.ts +1 -1
- package/dist/core/blueprints/compile.d.ts.map +1 -1
- package/dist/core/blueprints/compile.js +3 -3
- package/dist/core/blueprints/mutations.js +2 -2
- package/dist/core/forms/rate-limit.d.ts +1 -1
- package/dist/core/forms/rate-limit.d.ts.map +1 -1
- package/dist/core/forms/rate-limit.js +3 -3
- package/dist/core/forms/unique.d.ts +1 -1
- package/dist/core/forms/unique.d.ts.map +1 -1
- package/dist/core/forms/unique.js +4 -4
- package/dist/core/globals/compile.d.ts +1 -1
- package/dist/core/globals/compile.d.ts.map +1 -1
- package/dist/core/globals/compile.js +2 -2
- package/dist/core/globals/definition.d.ts +1 -1
- package/dist/core/globals/definition.d.ts.map +1 -1
- package/dist/core/globals/definition.js +3 -3
- package/dist/core/repos/globals.js +3 -3
- package/dist/core/sha256.d.ts +3 -0
- package/dist/core/sha256.d.ts.map +1 -0
- package/dist/core/sha256.js +9 -0
- package/dist/integration/index.js +1 -1
- package/dist/integration/install-hook.d.ts.map +1 -1
- package/dist/integration/install-hook.js +6 -4
- package/dist/integration/wrangler-config.d.ts +12 -0
- package/dist/integration/wrangler-config.d.ts.map +1 -0
- package/dist/integration/wrangler-config.js +97 -0
- package/dist/integration/wrangler-patch.d.ts +1 -0
- package/dist/integration/wrangler-patch.d.ts.map +1 -1
- package/dist/integration/wrangler-patch.js +2 -1
- package/dist/server/routes/form-submit.js +1 -1
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/package.json +11 -3
- package/src/admin/assets/logo-mark.svg +5 -0
- package/src/admin/client/active-locale.ts +17 -0
- package/src/admin/client/api.ts +21 -0
- package/src/admin/client/form-from-zod.ts +7 -0
- package/src/admin/client/live-preview-enabled.ts +5 -0
- package/src/admin/components/AdminShell.astro +45 -0
- package/src/admin/components/AuthSettings.vue +60 -0
- package/src/admin/components/BlockEditor.vue +53 -0
- package/src/admin/components/BlueprintEditor.vue +1783 -0
- package/src/admin/components/CollectionKindIcon.vue +26 -0
- package/src/admin/components/CollectionTree.vue +220 -0
- package/src/admin/components/EntryEditorWithPreview.vue +130 -0
- package/src/admin/components/EntryForm.vue +411 -0
- package/src/admin/components/EntryList.vue +121 -0
- package/src/admin/components/EntryStatusBadge.vue +24 -0
- package/src/admin/components/FormEditor.vue +233 -0
- package/src/admin/components/FormList.vue +54 -0
- package/src/admin/components/GlobalSetEditor.vue +272 -0
- package/src/admin/components/GlobalSetList.vue +55 -0
- package/src/admin/components/LivePreviewPanel.vue +171 -0
- package/src/admin/components/LoginForm.vue +53 -0
- package/src/admin/components/MediaLibrary.vue +106 -0
- package/src/admin/components/MediaPicker.vue +49 -0
- package/src/admin/components/RevisionDiff.vue +11 -0
- package/src/admin/components/RevisionList.vue +134 -0
- package/src/admin/components/SeoFields.vue +113 -0
- package/src/admin/components/SetEditor.vue +137 -0
- package/src/admin/components/SetList.vue +32 -0
- package/src/admin/components/SettingsForm.vue +189 -0
- package/src/admin/components/SideNav.vue +152 -0
- package/src/admin/components/SubmissionDetail.vue +45 -0
- package/src/admin/components/SubmissionList.vue +89 -0
- package/src/admin/components/ToastHost.vue +33 -0
- package/src/admin/components/TreeRow.vue +163 -0
- package/src/admin/components/UserEditor.vue +186 -0
- package/src/admin/components/UserList.vue +46 -0
- package/src/admin/components/blocks/BlockItem.vue +32 -0
- package/src/admin/components/blocks/BlockToolbar.vue +12 -0
- package/src/admin/components/blocks/edit/CodeEdit.vue +18 -0
- package/src/admin/components/blocks/edit/EmbedEdit.vue +14 -0
- package/src/admin/components/blocks/edit/HeadingEdit.vue +19 -0
- package/src/admin/components/blocks/edit/ImageEdit.vue +40 -0
- package/src/admin/components/blocks/edit/ListEdit.vue +36 -0
- package/src/admin/components/blocks/edit/ParagraphEdit.vue +14 -0
- package/src/admin/components/blocks/edit/QuoteEdit.vue +18 -0
- package/src/admin/components/fields/BlocksField.vue +123 -0
- package/src/admin/components/fields/BlocksSetsPicker.vue +59 -0
- package/src/admin/components/fields/BoolField.vue +10 -0
- package/src/admin/components/fields/DateField.vue +22 -0
- package/src/admin/components/fields/EntriesField.vue +153 -0
- package/src/admin/components/fields/EntryField.vue +138 -0
- package/src/admin/components/fields/EnumField.vue +81 -0
- package/src/admin/components/fields/FieldRenderer.vue +87 -0
- package/src/admin/components/fields/GridField.vue +173 -0
- package/src/admin/components/fields/LinkField.vue +219 -0
- package/src/admin/components/fields/MediaField.vue +69 -0
- package/src/admin/components/fields/NumberField.vue +12 -0
- package/src/admin/components/fields/ObjectField.vue +18 -0
- package/src/admin/components/fields/RefField.vue +170 -0
- package/src/admin/components/fields/RepeaterField.vue +27 -0
- package/src/admin/components/fields/ReplicatorField.vue +121 -0
- package/src/admin/components/fields/TextField.vue +11 -0
- package/src/admin/components/fields/TextareaField.vue +11 -0
- package/src/admin/components/fields/VulseAccordionGroupNodeView.vue +82 -0
- package/src/admin/components/fields/VulseAccordionNodeView.vue +128 -0
- package/src/admin/components/fields/VulseCalloutNodeView.vue +81 -0
- package/src/admin/components/fields/VulseIframeNodeView.vue +112 -0
- package/src/admin/components/fields/VulseSetNodeView.vue +68 -0
- package/src/admin/components/fields/VulseVideoNodeView.vue +104 -0
- package/src/admin/components/fields/blocks-editor-extensions.ts +26 -0
- package/src/admin/components/fields/emoji-extension.ts +54 -0
- package/src/admin/components/fields/link-extension.ts +48 -0
- package/src/admin/components/fields/set-node-utils.ts +115 -0
- package/src/admin/components/fields/url-utils.ts +85 -0
- package/src/admin/components/fields/vulse-accordion-extension.ts +64 -0
- package/src/admin/components/fields/vulse-accordion-group-extension.ts +49 -0
- package/src/admin/components/fields/vulse-callout-extension.ts +53 -0
- package/src/admin/components/fields/vulse-iframe-extension.ts +96 -0
- package/src/admin/components/fields/vulse-set-extension.ts +66 -0
- package/src/admin/components/fields/vulse-video-extension.ts +65 -0
- package/src/admin/composables/toast.ts +35 -0
- package/src/admin/composables/useEntrySearch.ts +112 -0
- package/src/admin/composables/useSets.ts +31 -0
- package/src/admin/pages/collections/[name]/[id]/revisions.astro +27 -0
- package/src/admin/pages/collections/[name]/[id].astro +90 -0
- package/src/admin/pages/collections/[name]/index.astro +31 -0
- package/src/admin/pages/collections/[name]/new.astro +38 -0
- package/src/admin/pages/forms/[handle]/submissions/[id].astro +9 -0
- package/src/admin/pages/forms/[handle]/submissions/index.astro +9 -0
- package/src/admin/pages/forms/[handle].astro +9 -0
- package/src/admin/pages/forms/index.astro +7 -0
- package/src/admin/pages/forms/new.astro +7 -0
- package/src/admin/pages/index.astro +36 -0
- package/src/admin/pages/login.astro +14 -0
- package/src/admin/pages/media.astro +8 -0
- package/src/admin/pages/schema/[handle].astro +10 -0
- package/src/admin/pages/schema/new.astro +9 -0
- package/src/admin/pages/settings/auth.astro +9 -0
- package/src/admin/pages/settings/globals/[handle].astro +9 -0
- package/src/admin/pages/settings/globals/index.astro +7 -0
- package/src/admin/pages/settings/globals/new.astro +7 -0
- package/src/admin/pages/settings/index.astro +8 -0
- package/src/admin/pages/settings/sets/[handle].astro +9 -0
- package/src/admin/pages/settings/sets/index.astro +7 -0
- package/src/admin/pages/settings/sets/new.astro +7 -0
- package/src/admin/pages/users/[id].astro +10 -0
- package/src/admin/pages/users/index.astro +8 -0
- package/src/admin/styles/admin.css +166 -0
- package/src/core/access.ts +9 -0
- package/src/core/blocks/schema.ts +66 -0
- package/src/core/blueprints/code-to-definition.ts +156 -0
- package/src/core/blueprints/compile.ts +176 -0
- package/src/core/blueprints/define.ts +12 -0
- package/src/core/blueprints/definition.ts +185 -0
- package/src/core/blueprints/load.ts +144 -0
- package/src/core/blueprints/mutations.ts +236 -0
- package/src/core/blueprints/preview-path.ts +33 -0
- package/src/core/blueprints/reflect-fields.ts +305 -0
- package/src/core/blueprints/registry.ts +14 -0
- package/src/core/blueprints/seed.ts +20 -0
- package/src/core/blueprints/select-helpers.ts +30 -0
- package/src/core/blueprints/seo.ts +180 -0
- package/src/core/blueprints/types.ts +59 -0
- package/src/core/blueprints/zod-helpers.ts +86 -0
- package/src/core/db.ts +11 -0
- package/src/core/errors.ts +34 -0
- package/src/core/forms/compile.ts +84 -0
- package/src/core/forms/definition.ts +102 -0
- package/src/core/forms/rate-limit.ts +52 -0
- package/src/core/forms/unique.ts +38 -0
- package/src/core/globals/compile.ts +35 -0
- package/src/core/globals/definition.ts +27 -0
- package/src/core/locales.ts +45 -0
- package/src/core/migrations.ts +48 -0
- package/src/core/parse-content.ts +85 -0
- package/src/core/plugins/definition.ts +150 -0
- package/src/core/preview-content.ts +21 -0
- package/src/core/repos/entries.ts +504 -0
- package/src/core/repos/forms.ts +270 -0
- package/src/core/repos/globals.ts +179 -0
- package/src/core/repos/media.ts +106 -0
- package/src/core/repos/preview-sessions.ts +108 -0
- package/src/core/repos/revisions.ts +60 -0
- package/src/core/repos/settings.ts +23 -0
- package/src/core/schema.ts +244 -0
- package/src/core/sets/compile.ts +12 -0
- package/src/core/sets/definition.ts +10 -0
- package/src/core/sets/service.ts +82 -0
- package/src/core/sets/validate-tree.ts +57 -0
- package/src/core/sha256.ts +10 -0
- package/src/core/slug.ts +30 -0
- package/src/scaffold/collection-write.ts +83 -0
- package/src/scaffold/collection.ts +277 -0
- package/src/server/assets/live-preview-bridge.content.ts +2 -0
- package/src/server/assets/live-preview-bridge.js +535 -0
- package/src/server/better-auth.ts +82 -0
- package/src/server/cf-images.ts +34 -0
- package/src/server/cron.ts +37 -0
- package/src/server/email.ts +17 -0
- package/src/server/endpoints/api-auth.ts +10 -0
- package/src/server/endpoints/api-vulse-blueprints.ts +23 -0
- package/src/server/endpoints/api-vulse-entries-locales.ts +12 -0
- package/src/server/endpoints/api-vulse-entries-move.ts +7 -0
- package/src/server/endpoints/api-vulse-entries-publish.ts +7 -0
- package/src/server/endpoints/api-vulse-entries-tree.ts +7 -0
- package/src/server/endpoints/api-vulse-entries.ts +23 -0
- package/src/server/endpoints/api-vulse-form-handle.ts +30 -0
- package/src/server/endpoints/api-vulse-form-public.ts +7 -0
- package/src/server/endpoints/api-vulse-form-submit.ts +7 -0
- package/src/server/endpoints/api-vulse-form-upload.ts +7 -0
- package/src/server/endpoints/api-vulse-forms.ts +12 -0
- package/src/server/endpoints/api-vulse-globals-handle.ts +20 -0
- package/src/server/endpoints/api-vulse-globals-public-handle.ts +7 -0
- package/src/server/endpoints/api-vulse-globals-public.ts +7 -0
- package/src/server/endpoints/api-vulse-globals-value.ts +8 -0
- package/src/server/endpoints/api-vulse-globals.ts +12 -0
- package/src/server/endpoints/api-vulse-media-file.ts +7 -0
- package/src/server/endpoints/api-vulse-media-id.ts +12 -0
- package/src/server/endpoints/api-vulse-media.ts +12 -0
- package/src/server/endpoints/api-vulse-preview-bridge.ts +11 -0
- package/src/server/endpoints/api-vulse-preview-sessions-id.ts +10 -0
- package/src/server/endpoints/api-vulse-preview-sessions.ts +7 -0
- package/src/server/endpoints/api-vulse-preview-start.ts +7 -0
- package/src/server/endpoints/api-vulse-preview-stop.ts +7 -0
- package/src/server/endpoints/api-vulse-revisions-restore.ts +7 -0
- package/src/server/endpoints/api-vulse-revisions.ts +7 -0
- package/src/server/endpoints/api-vulse-search.ts +7 -0
- package/src/server/endpoints/api-vulse-sets.ts +23 -0
- package/src/server/endpoints/api-vulse-settings.ts +12 -0
- package/src/server/endpoints/api-vulse-users-id.ts +12 -0
- package/src/server/endpoints/api-vulse-users-reset-password.ts +7 -0
- package/src/server/endpoints/api-vulse-users-role.ts +9 -0
- package/src/server/endpoints/api-vulse-users.ts +7 -0
- package/src/server/endpoints/with-runtime.ts +11 -0
- package/src/server/env.ts +23 -0
- package/src/server/envelope.ts +21 -0
- package/src/server/forms/email.ts +11 -0
- package/src/server/forms/process-submission.ts +95 -0
- package/src/server/forms/queue.ts +25 -0
- package/src/server/forms/templates.ts +24 -0
- package/src/server/forms/webhook.ts +19 -0
- package/src/server/handler.ts +66 -0
- package/src/server/image-probe.ts +35 -0
- package/src/server/loader.ts +54 -0
- package/src/server/plugins.ts +214 -0
- package/src/server/preview.ts +25 -0
- package/src/server/r2.ts +13 -0
- package/src/server/routes/blueprints.ts +62 -0
- package/src/server/routes/entries.ts +255 -0
- package/src/server/routes/form-submit.ts +168 -0
- package/src/server/routes/form-upload.ts +100 -0
- package/src/server/routes/forms.ts +88 -0
- package/src/server/routes/globals-public.ts +30 -0
- package/src/server/routes/globals.ts +93 -0
- package/src/server/routes/media.ts +145 -0
- package/src/server/routes/preview-sessions.ts +76 -0
- package/src/server/routes/preview.ts +36 -0
- package/src/server/routes/revisions.ts +29 -0
- package/src/server/routes/search.ts +31 -0
- package/src/server/routes/sets.ts +40 -0
- package/src/server/routes/settings.ts +24 -0
- package/src/server/routes/users.ts +127 -0
- package/src/server/runtime.ts +99 -0
- package/src/server/sdk/collections.ts +98 -0
- package/src/server/sdk/index.ts +25 -0
- package/src/server/sdk/media.ts +11 -0
- package/src/server/sdk/search.ts +90 -0
|
@@ -0,0 +1,233 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import { computed, onMounted, reactive, ref, watch } from 'vue'
|
|
3
|
+
import { adminApi, AdminApiError } from '../client/api.js'
|
|
4
|
+
import type { FormDefinition, FormFieldDefinition } from '../../core/forms/definition.js'
|
|
5
|
+
|
|
6
|
+
interface FormRow {
|
|
7
|
+
handle: string
|
|
8
|
+
label: string
|
|
9
|
+
definition: FormDefinition
|
|
10
|
+
enabled: boolean
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
const props = defineProps<{ handle: string | null }>()
|
|
14
|
+
|
|
15
|
+
const tab = ref<'fields' | 'settings' | 'emails' | 'embed'>('fields')
|
|
16
|
+
const handle = ref('')
|
|
17
|
+
const label = ref('')
|
|
18
|
+
const fields = reactive<FormFieldDefinition[]>([])
|
|
19
|
+
const settings = reactive({
|
|
20
|
+
enabled: true,
|
|
21
|
+
successMessage: 'Thank you!',
|
|
22
|
+
redirectTo: '',
|
|
23
|
+
honeypotField: '_hp',
|
|
24
|
+
notifyEmails: [] as string[],
|
|
25
|
+
confirmationEmail: {
|
|
26
|
+
enabled: false,
|
|
27
|
+
toField: 'email',
|
|
28
|
+
subject: 'Thanks for your submission',
|
|
29
|
+
bodyTemplate: 'We received your message.',
|
|
30
|
+
},
|
|
31
|
+
})
|
|
32
|
+
const saving = ref(false)
|
|
33
|
+
const error = ref<string | null>(null)
|
|
34
|
+
const handleLocked = ref(false)
|
|
35
|
+
|
|
36
|
+
const isCreate = computed(() => props.handle === null)
|
|
37
|
+
|
|
38
|
+
const embedSnippet = computed(() => `<FormRenderer form="${handle.value || 'my-form'}">
|
|
39
|
+
<!-- your field markup -->
|
|
40
|
+
</FormRenderer>`)
|
|
41
|
+
|
|
42
|
+
function slugify(input: string): string {
|
|
43
|
+
return input.toLowerCase().normalize('NFKD').replace(/[\u0300-\u036f]/g, '')
|
|
44
|
+
.replace(/[^a-z0-9_-]+/g, '-').replace(/^-+|-+$/g, '').replace(/^[^a-z]+/, '')
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
watch(label, (v) => {
|
|
48
|
+
if (isCreate.value && !handleLocked.value) handle.value = slugify(v)
|
|
49
|
+
})
|
|
50
|
+
|
|
51
|
+
function onHandleInput(e: Event) {
|
|
52
|
+
handleLocked.value = true
|
|
53
|
+
handle.value = (e.target as HTMLInputElement).value
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
async function load() {
|
|
57
|
+
if (props.handle === null) {
|
|
58
|
+
handle.value = ''
|
|
59
|
+
label.value = ''
|
|
60
|
+
fields.splice(0)
|
|
61
|
+
handleLocked.value = false
|
|
62
|
+
return
|
|
63
|
+
}
|
|
64
|
+
const row = await adminApi.get<FormRow>(`/api/vulse/forms/${props.handle}`)
|
|
65
|
+
handle.value = row.handle
|
|
66
|
+
label.value = row.label
|
|
67
|
+
handleLocked.value = true
|
|
68
|
+
fields.splice(0, fields.length, ...row.definition.fields)
|
|
69
|
+
Object.assign(settings, {
|
|
70
|
+
enabled: row.enabled,
|
|
71
|
+
successMessage: row.definition.settings.successMessage ?? 'Thank you!',
|
|
72
|
+
redirectTo: row.definition.settings.redirectTo ?? '',
|
|
73
|
+
honeypotField: row.definition.settings.honeypotField ?? '_hp',
|
|
74
|
+
notifyEmails: row.definition.settings.notifyEmails ?? [],
|
|
75
|
+
confirmationEmail: row.definition.settings.confirmationEmail ?? settings.confirmationEmail,
|
|
76
|
+
})
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function formatApiError(e: unknown): string {
|
|
80
|
+
if (!(e instanceof AdminApiError)) return e instanceof Error ? e.message : 'Save failed'
|
|
81
|
+
const issues = (e.details as { issues?: Array<{ path?: (string | number)[]; message?: string }> } | undefined)?.issues
|
|
82
|
+
if (issues?.length) {
|
|
83
|
+
return issues.map((issue) => {
|
|
84
|
+
const path = issue.path?.length ? issue.path.join('.') : 'form'
|
|
85
|
+
return `${path}: ${issue.message ?? 'invalid'}`
|
|
86
|
+
}).join('; ')
|
|
87
|
+
}
|
|
88
|
+
return e.message
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
onMounted(load)
|
|
92
|
+
watch(() => props.handle, load)
|
|
93
|
+
|
|
94
|
+
function addField() {
|
|
95
|
+
fields.push({ name: '', ui: { kind: 'text' }, optional: false })
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function buildDefinition(): FormDefinition {
|
|
99
|
+
return {
|
|
100
|
+
handle: handle.value,
|
|
101
|
+
label: label.value,
|
|
102
|
+
fields: [...fields],
|
|
103
|
+
settings: {
|
|
104
|
+
enabled: settings.enabled,
|
|
105
|
+
successMessage: settings.successMessage,
|
|
106
|
+
redirectTo: settings.redirectTo || undefined,
|
|
107
|
+
honeypotField: settings.honeypotField,
|
|
108
|
+
notifyEmails: settings.notifyEmails.filter(Boolean),
|
|
109
|
+
confirmationEmail: settings.confirmationEmail.enabled ? settings.confirmationEmail : undefined,
|
|
110
|
+
},
|
|
111
|
+
actions: [],
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
async function save() {
|
|
116
|
+
error.value = null
|
|
117
|
+
for (const field of fields) {
|
|
118
|
+
if (!field.name.trim()) {
|
|
119
|
+
error.value = 'Each field must have a name.'
|
|
120
|
+
return
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
saving.value = true
|
|
124
|
+
try {
|
|
125
|
+
const body = buildDefinition()
|
|
126
|
+
if (isCreate.value) {
|
|
127
|
+
await adminApi.post('/api/vulse/forms', body)
|
|
128
|
+
window.location.href = `/admin/forms/${handle.value}`
|
|
129
|
+
} else {
|
|
130
|
+
await adminApi.put(`/api/vulse/forms/${props.handle}`, body)
|
|
131
|
+
}
|
|
132
|
+
} catch (e) {
|
|
133
|
+
error.value = formatApiError(e)
|
|
134
|
+
} finally {
|
|
135
|
+
saving.value = false
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
async function destroy() {
|
|
140
|
+
if (!props.handle || !confirm(`Delete form "${props.handle}"?`)) return
|
|
141
|
+
await adminApi.delete(`/api/vulse/forms/${props.handle}`)
|
|
142
|
+
window.location.href = '/admin/forms'
|
|
143
|
+
}
|
|
144
|
+
</script>
|
|
145
|
+
|
|
146
|
+
<template>
|
|
147
|
+
<div>
|
|
148
|
+
<div class="mb-4 flex items-center justify-between">
|
|
149
|
+
<h1 class="text-2xl font-semibold">{{ isCreate ? 'New form' : label }}</h1>
|
|
150
|
+
<a v-if="!isCreate" :href="`/admin/forms/${handle}/submissions`" class="text-sm text-zinc-600 hover:underline">View submissions</a>
|
|
151
|
+
</div>
|
|
152
|
+
|
|
153
|
+
<div class="mb-4 flex gap-2 border-b border-zinc-200">
|
|
154
|
+
<button v-for="t in ['fields', 'settings', 'emails', 'embed'] as const" :key="t" type="button"
|
|
155
|
+
class="px-3 py-2 text-sm capitalize" :class="tab === t && 'border-b-2 border-zinc-900 font-medium'" @click="tab = t">
|
|
156
|
+
{{ t }}
|
|
157
|
+
</button>
|
|
158
|
+
</div>
|
|
159
|
+
|
|
160
|
+
<div class="max-w-3xl space-y-4">
|
|
161
|
+
<div class="space-y-3 rounded-xl border border-zinc-200 bg-white p-4">
|
|
162
|
+
<label class="block">
|
|
163
|
+
<span class="text-sm font-medium text-zinc-700">Label</span>
|
|
164
|
+
<input v-model="label" class="mt-1 w-full rounded-lg border border-zinc-300 px-3 py-2 text-sm" />
|
|
165
|
+
</label>
|
|
166
|
+
<label class="block">
|
|
167
|
+
<span class="text-sm font-medium text-zinc-700">Handle</span>
|
|
168
|
+
<input :value="handle" :disabled="!isCreate" class="mt-1 w-full rounded-lg border border-zinc-300 px-3 py-2 text-sm disabled:bg-zinc-100" @input="onHandleInput" />
|
|
169
|
+
</label>
|
|
170
|
+
</div>
|
|
171
|
+
|
|
172
|
+
<div v-show="tab === 'fields'" class="rounded-xl border border-zinc-200 bg-white p-4">
|
|
173
|
+
<div class="mb-3 flex items-center justify-between">
|
|
174
|
+
<h2 class="text-sm font-semibold text-zinc-700">Fields</h2>
|
|
175
|
+
<button type="button" class="rounded-lg border border-zinc-300 px-2 py-1 text-xs" @click="addField">+ Add field</button>
|
|
176
|
+
</div>
|
|
177
|
+
<div v-for="(f, i) in fields" :key="i" class="mb-3 rounded-lg border border-zinc-100 p-3">
|
|
178
|
+
<div class="flex flex-wrap items-center gap-2">
|
|
179
|
+
<input v-model="f.name" placeholder="name" class="flex-1 rounded border border-zinc-300 px-2 py-1 text-sm" />
|
|
180
|
+
<input v-model="f.label" placeholder="label" class="flex-1 rounded border border-zinc-300 px-2 py-1 text-sm" />
|
|
181
|
+
<select v-model="f.ui.kind" class="rounded border border-zinc-300 px-2 py-1 text-sm">
|
|
182
|
+
<option value="text">text</option>
|
|
183
|
+
<option value="textarea">textarea</option>
|
|
184
|
+
<option value="email">email</option>
|
|
185
|
+
<option value="number">number</option>
|
|
186
|
+
<option value="select">select</option>
|
|
187
|
+
<option value="checkbox">checkbox</option>
|
|
188
|
+
<option value="radio">radio</option>
|
|
189
|
+
<option value="date">date</option>
|
|
190
|
+
<option value="file">file</option>
|
|
191
|
+
<option value="hidden">hidden</option>
|
|
192
|
+
<option value="honeypot">honeypot</option>
|
|
193
|
+
<option value="submit">submit</option>
|
|
194
|
+
</select>
|
|
195
|
+
<label class="flex items-center gap-1 text-xs"><input v-model="f.optional" type="checkbox" /> optional</label>
|
|
196
|
+
<label v-if="f.validation" class="flex items-center gap-1 text-xs"><input v-model="f.validation.unique" type="checkbox" /> unique</label>
|
|
197
|
+
<button type="button" class="text-xs text-red-600" @click="fields.splice(i, 1)">Remove</button>
|
|
198
|
+
</div>
|
|
199
|
+
</div>
|
|
200
|
+
</div>
|
|
201
|
+
|
|
202
|
+
<div v-show="tab === 'settings'" class="space-y-3 rounded-xl border border-zinc-200 bg-white p-4">
|
|
203
|
+
<label class="flex items-center gap-2 text-sm"><input v-model="settings.enabled" type="checkbox" /> Enabled</label>
|
|
204
|
+
<label class="block text-sm">Success message<input v-model="settings.successMessage" class="mt-1 w-full rounded border border-zinc-300 px-2 py-1" /></label>
|
|
205
|
+
<label class="block text-sm">Redirect URL (optional)<input v-model="settings.redirectTo" class="mt-1 w-full rounded border border-zinc-300 px-2 py-1" /></label>
|
|
206
|
+
<label class="block text-sm">Honeypot field<input v-model="settings.honeypotField" class="mt-1 w-full rounded border border-zinc-300 px-2 py-1" /></label>
|
|
207
|
+
</div>
|
|
208
|
+
|
|
209
|
+
<div v-show="tab === 'emails'" class="space-y-3 rounded-xl border border-zinc-200 bg-white p-4">
|
|
210
|
+
<label class="block text-sm">Notify emails (comma-separated)
|
|
211
|
+
<input :value="settings.notifyEmails.join(', ')" class="mt-1 w-full rounded border border-zinc-300 px-2 py-1"
|
|
212
|
+
@input="settings.notifyEmails = ($event.target as HTMLInputElement).value.split(',').map((s) => s.trim()).filter(Boolean)" />
|
|
213
|
+
</label>
|
|
214
|
+
<label class="flex items-center gap-2 text-sm"><input v-model="settings.confirmationEmail.enabled" type="checkbox" /> Send confirmation email</label>
|
|
215
|
+
<template v-if="settings.confirmationEmail.enabled">
|
|
216
|
+
<label class="block text-sm">To field<input v-model="settings.confirmationEmail.toField" class="mt-1 w-full rounded border border-zinc-300 px-2 py-1" /></label>
|
|
217
|
+
<label class="block text-sm">Subject<input v-model="settings.confirmationEmail.subject" class="mt-1 w-full rounded border border-zinc-300 px-2 py-1" /></label>
|
|
218
|
+
<label class="block text-sm">Body<textarea v-model="settings.confirmationEmail.bodyTemplate" class="mt-1 w-full rounded border border-zinc-300 px-2 py-1" rows="4" /></label>
|
|
219
|
+
</template>
|
|
220
|
+
</div>
|
|
221
|
+
|
|
222
|
+
<div v-show="tab === 'embed'" class="rounded-xl border border-zinc-200 bg-white p-4">
|
|
223
|
+
<pre class="overflow-x-auto rounded bg-zinc-50 p-3 text-xs">{{ embedSnippet }}</pre>
|
|
224
|
+
</div>
|
|
225
|
+
|
|
226
|
+
<div v-if="error" class="rounded-lg bg-red-50 px-3 py-2 text-sm text-red-700">{{ error }}</div>
|
|
227
|
+
<div class="flex items-center gap-2">
|
|
228
|
+
<button type="button" class="rounded-lg bg-zinc-900 px-4 py-2 text-sm text-white disabled:opacity-50" :disabled="saving" @click="save">{{ saving ? 'Saving…' : 'Save' }}</button>
|
|
229
|
+
<button v-if="!isCreate" type="button" class="rounded-lg border border-red-200 px-4 py-2 text-sm text-red-700" @click="destroy">Delete</button>
|
|
230
|
+
</div>
|
|
231
|
+
</div>
|
|
232
|
+
</div>
|
|
233
|
+
</template>
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import { onMounted, ref } from 'vue'
|
|
3
|
+
import { adminApi } from '../client/api.js'
|
|
4
|
+
|
|
5
|
+
interface FormRow {
|
|
6
|
+
handle: string
|
|
7
|
+
label: string
|
|
8
|
+
enabled: boolean
|
|
9
|
+
submissionCount?: number
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
const forms = ref<FormRow[]>([])
|
|
13
|
+
const loading = ref(true)
|
|
14
|
+
|
|
15
|
+
onMounted(async () => {
|
|
16
|
+
forms.value = await adminApi.get<FormRow[]>('/api/vulse/forms')
|
|
17
|
+
loading.value = false
|
|
18
|
+
})
|
|
19
|
+
</script>
|
|
20
|
+
|
|
21
|
+
<template>
|
|
22
|
+
<div>
|
|
23
|
+
<div class="mb-6 flex items-center justify-between">
|
|
24
|
+
<h1 class="text-2xl font-semibold">Forms</h1>
|
|
25
|
+
<a href="/admin/forms/new" class="rounded-lg bg-zinc-900 px-3 py-2 text-sm text-white">New form</a>
|
|
26
|
+
</div>
|
|
27
|
+
<p v-if="loading" class="text-sm text-zinc-500">Loading…</p>
|
|
28
|
+
<table v-else class="w-full text-sm">
|
|
29
|
+
<thead>
|
|
30
|
+
<tr class="border-b border-zinc-200 text-left text-zinc-500">
|
|
31
|
+
<th class="py-2 pr-4">Label</th>
|
|
32
|
+
<th class="py-2 pr-4">Handle</th>
|
|
33
|
+
<th class="py-2 pr-4">Enabled</th>
|
|
34
|
+
<th class="py-2 pr-4">Submissions</th>
|
|
35
|
+
<th class="py-2"></th>
|
|
36
|
+
</tr>
|
|
37
|
+
</thead>
|
|
38
|
+
<tbody>
|
|
39
|
+
<tr v-for="f in forms" :key="f.handle" class="border-b border-zinc-100">
|
|
40
|
+
<td class="py-2 pr-4">{{ f.label }}</td>
|
|
41
|
+
<td class="py-2 pr-4 font-mono text-xs">{{ f.handle }}</td>
|
|
42
|
+
<td class="py-2 pr-4">{{ f.enabled ? 'Yes' : 'No' }}</td>
|
|
43
|
+
<td class="py-2 pr-4">{{ f.submissionCount ?? 0 }}</td>
|
|
44
|
+
<td class="py-2 text-right">
|
|
45
|
+
<a :href="`/admin/forms/${f.handle}`" class="text-zinc-700 hover:underline">Edit</a>
|
|
46
|
+
·
|
|
47
|
+
<a :href="`/admin/forms/${f.handle}/submissions`" class="text-zinc-700 hover:underline">Submissions</a>
|
|
48
|
+
</td>
|
|
49
|
+
</tr>
|
|
50
|
+
</tbody>
|
|
51
|
+
</table>
|
|
52
|
+
<p v-if="!loading && forms.length === 0" class="mt-4 text-sm text-zinc-500">No forms yet.</p>
|
|
53
|
+
</div>
|
|
54
|
+
</template>
|
|
@@ -0,0 +1,272 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import { computed, onMounted, reactive, ref, watch } from 'vue'
|
|
3
|
+
import { adminApi, AdminApiError } from '../client/api.js'
|
|
4
|
+
import { fieldDescriptorsFromDefinitions } from '../client/form-from-zod.js'
|
|
5
|
+
import type { FieldDescriptor } from '../client/form-from-zod.js'
|
|
6
|
+
import type { FieldDefinition } from '../../core/blueprints/definition.js'
|
|
7
|
+
import FieldRenderer from './fields/FieldRenderer.vue'
|
|
8
|
+
|
|
9
|
+
const props = defineProps<{ handle: string | null }>()
|
|
10
|
+
|
|
11
|
+
const handle = ref('')
|
|
12
|
+
const label = ref('')
|
|
13
|
+
const fields = reactive<FieldDefinition[]>([])
|
|
14
|
+
const content = reactive<Record<string, unknown>>({})
|
|
15
|
+
const fieldErrors = reactive<Record<string, string>>({})
|
|
16
|
+
const savingDefinition = ref(false)
|
|
17
|
+
const savingValue = ref(false)
|
|
18
|
+
const loading = ref(false)
|
|
19
|
+
const error = ref<string | null>(null)
|
|
20
|
+
const handleLocked = ref(false)
|
|
21
|
+
|
|
22
|
+
const isCreate = computed(() => props.handle === null)
|
|
23
|
+
const fieldDescriptors = computed<FieldDescriptor[]>(() => fieldDescriptorsFromDefinitions(fields))
|
|
24
|
+
const canEditValue = computed(() => !isCreate.value && fields.length > 0)
|
|
25
|
+
|
|
26
|
+
function slugify(input: string): string {
|
|
27
|
+
return input.toLowerCase().normalize('NFKD').replace(/[\u0300-\u036f]/g, '')
|
|
28
|
+
.replace(/[^a-z0-9_-]+/g, '-').replace(/^-+|-+$/g, '').replace(/^[^a-z]+/, '')
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function defaultFor(kind: string): unknown {
|
|
32
|
+
if (kind === 'boolean') return false
|
|
33
|
+
if (kind === 'blocks') return { type: 'doc', content: [{ type: 'paragraph' }] }
|
|
34
|
+
if (kind === 'date') {
|
|
35
|
+
const d = new Date()
|
|
36
|
+
const pad = (n: number) => String(n).padStart(2, '0')
|
|
37
|
+
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}`
|
|
38
|
+
}
|
|
39
|
+
return ''
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function ensureContentFields() {
|
|
43
|
+
for (const field of fields) {
|
|
44
|
+
if (!(field.name in content)) content[field.name] = field.default ?? defaultFor(field.ui.kind)
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
watch(label, (v) => {
|
|
49
|
+
if (isCreate.value && !handleLocked.value) handle.value = slugify(v)
|
|
50
|
+
})
|
|
51
|
+
|
|
52
|
+
function onHandleInput(e: Event) {
|
|
53
|
+
handleLocked.value = true
|
|
54
|
+
handle.value = (e.target as HTMLInputElement).value
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
async function load() {
|
|
58
|
+
for (const key of Object.keys(content)) delete content[key]
|
|
59
|
+
for (const key of Object.keys(fieldErrors)) delete fieldErrors[key]
|
|
60
|
+
error.value = null
|
|
61
|
+
|
|
62
|
+
if (props.handle === null) {
|
|
63
|
+
handle.value = ''
|
|
64
|
+
label.value = ''
|
|
65
|
+
fields.splice(0)
|
|
66
|
+
handleLocked.value = false
|
|
67
|
+
return
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
loading.value = true
|
|
71
|
+
try {
|
|
72
|
+
const result = await adminApi.get<{
|
|
73
|
+
set: { handle: string; label: string; fields: FieldDefinition[] }
|
|
74
|
+
value: { content: Record<string, unknown> } | null
|
|
75
|
+
}>(`/api/vulse/globals/${props.handle}`)
|
|
76
|
+
handle.value = result.set.handle
|
|
77
|
+
label.value = result.set.label
|
|
78
|
+
fields.splice(0, fields.length, ...result.set.fields)
|
|
79
|
+
handleLocked.value = true
|
|
80
|
+
ensureContentFields()
|
|
81
|
+
Object.assign(content, result.value?.content ?? {})
|
|
82
|
+
} finally {
|
|
83
|
+
loading.value = false
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
onMounted(load)
|
|
88
|
+
watch(() => props.handle, load)
|
|
89
|
+
|
|
90
|
+
function addField() {
|
|
91
|
+
fields.push({ name: '', ui: { kind: 'text' }, optional: false })
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function removeField(index: number) {
|
|
95
|
+
const [field] = fields.splice(index, 1)
|
|
96
|
+
if (field?.name) delete content[field.name]
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function updateFieldName(index: number, event: Event) {
|
|
100
|
+
const field = fields[index]
|
|
101
|
+
if (!field) return
|
|
102
|
+
const oldName = field.name
|
|
103
|
+
const newName = (event.target as HTMLInputElement).value
|
|
104
|
+
field.name = newName
|
|
105
|
+
if (oldName && oldName in content && !(newName in content)) {
|
|
106
|
+
content[newName] = content[oldName]
|
|
107
|
+
delete content[oldName]
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function formatApiError(e: unknown): string {
|
|
112
|
+
if (!(e instanceof AdminApiError)) return e instanceof Error ? e.message : 'Save failed'
|
|
113
|
+
const issues = (e.details as { issues?: Array<{ path?: (string | number)[]; message?: string }> } | undefined)?.issues
|
|
114
|
+
if (issues?.length) {
|
|
115
|
+
return issues.map((issue) => {
|
|
116
|
+
const path = issue.path?.length ? issue.path.join('.') : 'form'
|
|
117
|
+
return `${path}: ${issue.message ?? 'invalid'}`
|
|
118
|
+
}).join('; ')
|
|
119
|
+
}
|
|
120
|
+
return e.message
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function applyValueErrors(e: AdminApiError) {
|
|
124
|
+
for (const key of Object.keys(fieldErrors)) delete fieldErrors[key]
|
|
125
|
+
const issues = (e.details as { issues?: Array<{ path?: (string | number)[]; message?: string }> } | undefined)?.issues
|
|
126
|
+
if (issues?.length) {
|
|
127
|
+
for (const issue of issues) {
|
|
128
|
+
const field = String(issue.path?.[0] ?? '')
|
|
129
|
+
if (field) fieldErrors[field] = issue.message ?? 'Invalid'
|
|
130
|
+
}
|
|
131
|
+
return
|
|
132
|
+
}
|
|
133
|
+
error.value = e.message
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
async function saveDefinition() {
|
|
137
|
+
error.value = null
|
|
138
|
+
for (const field of fields) {
|
|
139
|
+
if (!field.name.trim()) {
|
|
140
|
+
error.value = 'Each field must have a name.'
|
|
141
|
+
return
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
savingDefinition.value = true
|
|
145
|
+
try {
|
|
146
|
+
const body = { handle: handle.value, label: label.value, fields: [...fields] }
|
|
147
|
+
if (isCreate.value) {
|
|
148
|
+
await adminApi.post('/api/vulse/globals', body)
|
|
149
|
+
window.location.href = `/admin/settings/globals/${handle.value}`
|
|
150
|
+
} else {
|
|
151
|
+
await adminApi.put(`/api/vulse/globals/${props.handle}`, body)
|
|
152
|
+
ensureContentFields()
|
|
153
|
+
}
|
|
154
|
+
} catch (e) {
|
|
155
|
+
error.value = formatApiError(e)
|
|
156
|
+
} finally {
|
|
157
|
+
savingDefinition.value = false
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
async function saveValue() {
|
|
162
|
+
if (isCreate.value) return
|
|
163
|
+
error.value = null
|
|
164
|
+
for (const key of Object.keys(fieldErrors)) delete fieldErrors[key]
|
|
165
|
+
savingValue.value = true
|
|
166
|
+
try {
|
|
167
|
+
await adminApi.put(`/api/vulse/globals/${props.handle}/value`, { ...content })
|
|
168
|
+
} catch (e) {
|
|
169
|
+
if (e instanceof AdminApiError) applyValueErrors(e)
|
|
170
|
+
else error.value = 'Save failed'
|
|
171
|
+
} finally {
|
|
172
|
+
savingValue.value = false
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
async function destroy() {
|
|
177
|
+
if (!props.handle || !confirm(`Delete global set "${props.handle}"?`)) return
|
|
178
|
+
await adminApi.delete(`/api/vulse/globals/${props.handle}`)
|
|
179
|
+
window.location.href = '/admin/settings/globals'
|
|
180
|
+
}
|
|
181
|
+
</script>
|
|
182
|
+
|
|
183
|
+
<template>
|
|
184
|
+
<div>
|
|
185
|
+
<div class="mb-6 flex items-center justify-between">
|
|
186
|
+
<div>
|
|
187
|
+
<h1 class="text-2xl font-semibold">{{ isCreate ? 'New global set' : label }}</h1>
|
|
188
|
+
<p class="mt-1 text-sm text-zinc-500">Globals are site-wide content available to the frontend on every page.</p>
|
|
189
|
+
</div>
|
|
190
|
+
<button v-if="!isCreate" type="button" class="rounded-lg border border-red-200 px-4 py-2 text-sm text-red-700" @click="destroy">Delete</button>
|
|
191
|
+
</div>
|
|
192
|
+
|
|
193
|
+
<p v-if="loading" class="text-sm text-zinc-500">Loading…</p>
|
|
194
|
+
<div v-else class="grid max-w-5xl gap-6 lg:grid-cols-2">
|
|
195
|
+
<section class="space-y-4 rounded-xl border border-zinc-200 bg-white p-4">
|
|
196
|
+
<div>
|
|
197
|
+
<h2 class="text-sm font-semibold text-zinc-700">Definition</h2>
|
|
198
|
+
<p class="mt-1 text-xs text-zinc-500">Define the fields editors can fill in for this global set.</p>
|
|
199
|
+
</div>
|
|
200
|
+
|
|
201
|
+
<label class="block">
|
|
202
|
+
<span class="text-sm font-medium text-zinc-700">Label</span>
|
|
203
|
+
<input v-model="label" class="mt-1 w-full rounded-lg border border-zinc-300 px-3 py-2 text-sm" />
|
|
204
|
+
</label>
|
|
205
|
+
<label class="block">
|
|
206
|
+
<span class="text-sm font-medium text-zinc-700">Handle</span>
|
|
207
|
+
<input :value="handle" :disabled="!isCreate" class="mt-1 w-full rounded-lg border border-zinc-300 px-3 py-2 text-sm disabled:bg-zinc-100" @input="onHandleInput" />
|
|
208
|
+
<span class="mt-1 block text-xs text-zinc-500">
|
|
209
|
+
<template v-if="isCreate">
|
|
210
|
+
Stable identifier exposed by the public globals API (<code>/api/vulse/public/globals/{{ handle || 'handle' }}</code>) and used by any frontend code that reads this global.
|
|
211
|
+
</template>
|
|
212
|
+
<template v-else>
|
|
213
|
+
Locked — changing it would break the public API path and any frontend code that loads this global by handle. Create a new global to rename.
|
|
214
|
+
</template>
|
|
215
|
+
</span>
|
|
216
|
+
</label>
|
|
217
|
+
|
|
218
|
+
<div>
|
|
219
|
+
<div class="mb-3 flex items-center justify-between">
|
|
220
|
+
<h3 class="text-sm font-semibold text-zinc-700">Fields</h3>
|
|
221
|
+
<button type="button" class="rounded-lg border border-zinc-300 px-2 py-1 text-xs" @click="addField">+ Add field</button>
|
|
222
|
+
</div>
|
|
223
|
+
<div v-for="(f, i) in fields" :key="i" class="mb-3 rounded-lg border border-zinc-100 p-3">
|
|
224
|
+
<div class="flex flex-wrap items-center gap-2">
|
|
225
|
+
<input :value="f.name" placeholder="name" class="flex-1 rounded border border-zinc-300 px-2 py-1 text-sm" @input="updateFieldName(i, $event)" />
|
|
226
|
+
<input v-model="f.label" placeholder="label" class="flex-1 rounded border border-zinc-300 px-2 py-1 text-sm" />
|
|
227
|
+
<select v-model="f.ui.kind" class="rounded border border-zinc-300 px-2 py-1 text-sm">
|
|
228
|
+
<option value="text">text</option>
|
|
229
|
+
<option value="textarea">textarea</option>
|
|
230
|
+
<option value="blocks">blocks</option>
|
|
231
|
+
<option value="date">date</option>
|
|
232
|
+
<option value="boolean">boolean</option>
|
|
233
|
+
<option value="select">select</option>
|
|
234
|
+
<option value="asset">asset</option>
|
|
235
|
+
</select>
|
|
236
|
+
<label class="flex items-center gap-1 text-xs"><input v-model="f.optional" type="checkbox" /> optional</label>
|
|
237
|
+
<button type="button" class="text-xs text-red-600" @click="removeField(i)">Remove</button>
|
|
238
|
+
</div>
|
|
239
|
+
</div>
|
|
240
|
+
</div>
|
|
241
|
+
|
|
242
|
+
<div v-if="error" class="rounded-lg bg-red-50 px-3 py-2 text-sm text-red-700">{{ error }}</div>
|
|
243
|
+
<button type="button" class="vulse-button-primary rounded-lg px-4 py-2 text-sm font-medium disabled:opacity-50" :disabled="savingDefinition" @click="saveDefinition">
|
|
244
|
+
{{ savingDefinition ? 'Saving…' : (isCreate ? 'Create set' : 'Save definition') }}
|
|
245
|
+
</button>
|
|
246
|
+
</section>
|
|
247
|
+
|
|
248
|
+
<section class="space-y-4 rounded-xl border border-zinc-200 bg-white p-4">
|
|
249
|
+
<div>
|
|
250
|
+
<h2 class="text-sm font-semibold text-zinc-700">Content</h2>
|
|
251
|
+
<p class="mt-1 text-xs text-zinc-500">Values exposed via the public globals API.</p>
|
|
252
|
+
</div>
|
|
253
|
+
|
|
254
|
+
<p v-if="isCreate" class="text-sm text-zinc-500">Save the definition first to edit content.</p>
|
|
255
|
+
<p v-else-if="fields.length === 0" class="text-sm text-zinc-500">Add fields to the definition to edit content.</p>
|
|
256
|
+
<template v-else>
|
|
257
|
+
<FieldRenderer
|
|
258
|
+
v-for="fd in fieldDescriptors"
|
|
259
|
+
:key="fd.path"
|
|
260
|
+
:field="fd"
|
|
261
|
+
:model-value="content[fd.path]"
|
|
262
|
+
:field-errors="fieldErrors"
|
|
263
|
+
@update:modelValue="content[fd.path] = $event"
|
|
264
|
+
/>
|
|
265
|
+
<button type="button" class="rounded-lg bg-zinc-900 px-4 py-2 text-sm text-white disabled:opacity-50" :disabled="savingValue || !canEditValue" @click="saveValue">
|
|
266
|
+
{{ savingValue ? 'Saving…' : 'Save content' }}
|
|
267
|
+
</button>
|
|
268
|
+
</template>
|
|
269
|
+
</section>
|
|
270
|
+
</div>
|
|
271
|
+
</div>
|
|
272
|
+
</template>
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import { onMounted, ref } from 'vue'
|
|
3
|
+
import { adminApi } from '../client/api.js'
|
|
4
|
+
|
|
5
|
+
interface GlobalSetListItem {
|
|
6
|
+
handle: string
|
|
7
|
+
label: string
|
|
8
|
+
fieldCount: number
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
const sets = ref<GlobalSetListItem[]>([])
|
|
12
|
+
const loading = ref(true)
|
|
13
|
+
|
|
14
|
+
onMounted(async () => {
|
|
15
|
+
sets.value = await adminApi.get<GlobalSetListItem[]>('/api/vulse/globals')
|
|
16
|
+
loading.value = false
|
|
17
|
+
})
|
|
18
|
+
|
|
19
|
+
async function destroy(handle: string) {
|
|
20
|
+
if (!confirm(`Delete global set "${handle}"?`)) return
|
|
21
|
+
await adminApi.delete(`/api/vulse/globals/${handle}`)
|
|
22
|
+
sets.value = sets.value.filter((s) => s.handle !== handle)
|
|
23
|
+
}
|
|
24
|
+
</script>
|
|
25
|
+
|
|
26
|
+
<template>
|
|
27
|
+
<div>
|
|
28
|
+
<div class="mb-6 flex items-center justify-between">
|
|
29
|
+
<div>
|
|
30
|
+
<h1 class="text-2xl font-semibold tracking-tight">Globals</h1>
|
|
31
|
+
<p class="mt-1 text-sm text-zinc-500">Site-wide content available on every page.</p>
|
|
32
|
+
</div>
|
|
33
|
+
<a href="/admin/settings/globals/new" class="vulse-button-primary rounded-lg px-4 py-2 text-sm font-medium">+ New global set</a>
|
|
34
|
+
</div>
|
|
35
|
+
|
|
36
|
+
<p v-if="loading" class="text-sm text-zinc-500">Loading…</p>
|
|
37
|
+
<div v-else class="rounded-xl border border-zinc-200 bg-white">
|
|
38
|
+
<div
|
|
39
|
+
v-for="s in sets"
|
|
40
|
+
:key="s.handle"
|
|
41
|
+
class="flex items-center justify-between border-b border-zinc-100 px-4 py-3 text-sm last:border-0"
|
|
42
|
+
>
|
|
43
|
+
<div>
|
|
44
|
+
<div class="font-medium">{{ s.label }}</div>
|
|
45
|
+
<div class="font-mono text-xs text-zinc-500">{{ s.handle }} · {{ s.fieldCount }} field{{ s.fieldCount === 1 ? '' : 's' }}</div>
|
|
46
|
+
</div>
|
|
47
|
+
<div class="flex items-center gap-3">
|
|
48
|
+
<a :href="`/admin/settings/globals/${s.handle}`" class="text-zinc-700 hover:underline">Edit</a>
|
|
49
|
+
<button type="button" class="text-red-600 hover:underline" @click="destroy(s.handle)">Delete</button>
|
|
50
|
+
</div>
|
|
51
|
+
</div>
|
|
52
|
+
<p v-if="sets.length === 0" class="px-4 py-6 text-sm text-zinc-500">No global sets yet.</p>
|
|
53
|
+
</div>
|
|
54
|
+
</div>
|
|
55
|
+
</template>
|