@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,137 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import { computed, onMounted, reactive, ref, watch } from 'vue'
|
|
3
|
+
import { adminApi } from '../client/api.js'
|
|
4
|
+
import type { NestedFieldDefinition } from '../../core/blueprints/definition.js'
|
|
5
|
+
import type { SetDefinition } from '../../core/sets/definition.js'
|
|
6
|
+
|
|
7
|
+
const props = defineProps<{ handle: string | null }>()
|
|
8
|
+
|
|
9
|
+
const handle = ref('')
|
|
10
|
+
const label = ref('')
|
|
11
|
+
const fields = reactive<NestedFieldDefinition[]>([])
|
|
12
|
+
const saving = ref(false)
|
|
13
|
+
const error = ref<string | null>(null)
|
|
14
|
+
const handleLocked = ref(false)
|
|
15
|
+
|
|
16
|
+
const isCreate = computed(() => props.handle === null)
|
|
17
|
+
|
|
18
|
+
function slugify(input: string): string {
|
|
19
|
+
return input.toLowerCase().normalize('NFKD').replace(/[\u0300-\u036f]/g, '')
|
|
20
|
+
.replace(/[^a-z0-9_-]+/g, '-').replace(/^-+|-+$/g, '').replace(/^[^a-z]+/, '')
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
watch(label, (v) => {
|
|
24
|
+
if (isCreate.value && !handleLocked.value) handle.value = slugify(v)
|
|
25
|
+
})
|
|
26
|
+
|
|
27
|
+
function onHandleInput(e: Event) {
|
|
28
|
+
handleLocked.value = true
|
|
29
|
+
handle.value = (e.target as HTMLInputElement).value
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
async function load() {
|
|
33
|
+
if (props.handle === null) {
|
|
34
|
+
handle.value = ''
|
|
35
|
+
label.value = ''
|
|
36
|
+
fields.splice(0)
|
|
37
|
+
handleLocked.value = false
|
|
38
|
+
return
|
|
39
|
+
}
|
|
40
|
+
const s = await adminApi.get<SetDefinition>(`/api/vulse/sets/${props.handle}`)
|
|
41
|
+
handle.value = s.handle
|
|
42
|
+
label.value = s.label
|
|
43
|
+
handleLocked.value = true
|
|
44
|
+
fields.splice(0, fields.length, ...s.fields)
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
onMounted(load)
|
|
48
|
+
watch(() => props.handle, load)
|
|
49
|
+
|
|
50
|
+
function addField() {
|
|
51
|
+
fields.push({ name: '', ui: { kind: 'text' }, optional: false })
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
async function save() {
|
|
55
|
+
saving.value = true
|
|
56
|
+
error.value = null
|
|
57
|
+
try {
|
|
58
|
+
const body = { handle: handle.value, label: label.value, fields: [...fields] }
|
|
59
|
+
if (isCreate.value) {
|
|
60
|
+
await adminApi.post('/api/vulse/sets', body)
|
|
61
|
+
window.location.href = '/admin/settings/sets'
|
|
62
|
+
} else {
|
|
63
|
+
await adminApi.patch(`/api/vulse/sets/${props.handle}`, body)
|
|
64
|
+
window.location.href = '/admin/settings/sets'
|
|
65
|
+
}
|
|
66
|
+
} catch (e) {
|
|
67
|
+
error.value = e instanceof Error ? e.message : 'Save failed'
|
|
68
|
+
} finally {
|
|
69
|
+
saving.value = false
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
async function destroy() {
|
|
74
|
+
if (!props.handle || !confirm(`Delete set "${props.handle}"?`)) return
|
|
75
|
+
await adminApi.delete(`/api/vulse/sets/${props.handle}`)
|
|
76
|
+
window.location.href = '/admin/settings/sets'
|
|
77
|
+
}
|
|
78
|
+
</script>
|
|
79
|
+
|
|
80
|
+
<template>
|
|
81
|
+
<div>
|
|
82
|
+
<h1 class="mb-6 text-2xl font-semibold">{{ isCreate ? 'New set' : `Edit ${handle}` }}</h1>
|
|
83
|
+
<div class="max-w-3xl space-y-4">
|
|
84
|
+
<div class="space-y-3 rounded-xl border border-zinc-200 bg-white p-4">
|
|
85
|
+
<label class="block">
|
|
86
|
+
<span class="text-sm font-medium text-zinc-700">Label</span>
|
|
87
|
+
<input v-model="label" class="mt-1 w-full rounded-lg border border-zinc-300 px-3 py-2 text-sm" />
|
|
88
|
+
</label>
|
|
89
|
+
<label class="block">
|
|
90
|
+
<span class="text-sm font-medium text-zinc-700">Handle</span>
|
|
91
|
+
<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" />
|
|
92
|
+
<span class="mt-1 block text-xs text-zinc-500">
|
|
93
|
+
<template v-if="isCreate">
|
|
94
|
+
Stable identifier referenced by blueprints (in <code>blocks</code> and <code>replicator</code> fields) and any frontend code that renders this set.
|
|
95
|
+
</template>
|
|
96
|
+
<template v-else>
|
|
97
|
+
Locked — changing it would break every blueprint and frontend reference to this set. Create a new set and migrate to rename.
|
|
98
|
+
</template>
|
|
99
|
+
</span>
|
|
100
|
+
</label>
|
|
101
|
+
</div>
|
|
102
|
+
|
|
103
|
+
<div class="rounded-xl border border-zinc-200 bg-white p-4">
|
|
104
|
+
<div class="mb-3 flex items-center justify-between">
|
|
105
|
+
<h2 class="text-sm font-semibold text-zinc-700">Fields</h2>
|
|
106
|
+
<button type="button" class="rounded-lg border border-zinc-300 px-2 py-1 text-xs" @click="addField">+ Add field</button>
|
|
107
|
+
</div>
|
|
108
|
+
<div v-for="(f, i) in fields" :key="i" class="mb-3 rounded-lg border border-zinc-100 p-3">
|
|
109
|
+
<div class="flex flex-wrap items-center gap-2">
|
|
110
|
+
<input v-model="f.name" placeholder="name" class="flex-1 rounded border border-zinc-300 px-2 py-1 text-sm" />
|
|
111
|
+
<select v-model="f.ui.kind" class="rounded border border-zinc-300 px-2 py-1 text-sm">
|
|
112
|
+
<option value="text">text</option>
|
|
113
|
+
<option value="textarea">textarea</option>
|
|
114
|
+
<option value="blocks">blocks</option>
|
|
115
|
+
<option value="date">date</option>
|
|
116
|
+
<option value="boolean">boolean</option>
|
|
117
|
+
<option value="select">select</option>
|
|
118
|
+
<option value="relationship">relationship</option>
|
|
119
|
+
<option value="entry">entry</option>
|
|
120
|
+
<option value="entries">entries</option>
|
|
121
|
+
<option value="link">link</option>
|
|
122
|
+
<option value="asset">asset</option>
|
|
123
|
+
</select>
|
|
124
|
+
<label class="flex items-center gap-1 text-xs"><input v-model="f.optional" type="checkbox" /> optional</label>
|
|
125
|
+
<button type="button" class="text-xs text-red-600" @click="fields.splice(i, 1)">Remove</button>
|
|
126
|
+
</div>
|
|
127
|
+
</div>
|
|
128
|
+
</div>
|
|
129
|
+
|
|
130
|
+
<div v-if="error" class="rounded-lg bg-red-50 px-3 py-2 text-sm text-red-700">{{ error }}</div>
|
|
131
|
+
<div class="flex items-center gap-2">
|
|
132
|
+
<button type="button" class="vulse-button-primary rounded-lg px-4 py-2 text-sm font-medium" :disabled="saving" @click="save">{{ saving ? 'Saving…' : 'Save' }}</button>
|
|
133
|
+
<button v-if="!isCreate" type="button" class="ml-auto rounded-lg border border-red-300 px-4 py-2 text-sm text-red-600" @click="destroy">Delete</button>
|
|
134
|
+
</div>
|
|
135
|
+
</div>
|
|
136
|
+
</div>
|
|
137
|
+
</template>
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import { onMounted, ref } from 'vue'
|
|
3
|
+
import { adminApi } from '../client/api.js'
|
|
4
|
+
import type { SetDefinition } from '../../core/sets/definition.js'
|
|
5
|
+
|
|
6
|
+
const sets = ref<SetDefinition[]>([])
|
|
7
|
+
|
|
8
|
+
onMounted(async () => {
|
|
9
|
+
sets.value = await adminApi.get<SetDefinition[]>('/api/vulse/sets')
|
|
10
|
+
})
|
|
11
|
+
</script>
|
|
12
|
+
|
|
13
|
+
<template>
|
|
14
|
+
<div>
|
|
15
|
+
<div class="mb-6 flex items-center justify-between">
|
|
16
|
+
<h1 class="text-2xl font-semibold tracking-tight">Sets</h1>
|
|
17
|
+
<a href="/admin/settings/sets/new" class="vulse-button-primary rounded-lg px-4 py-2 text-sm font-medium">+ New set</a>
|
|
18
|
+
</div>
|
|
19
|
+
<div class="rounded-xl border border-zinc-200 bg-white">
|
|
20
|
+
<a
|
|
21
|
+
v-for="s in sets"
|
|
22
|
+
:key="s.handle"
|
|
23
|
+
:href="`/admin/settings/sets/${s.handle}`"
|
|
24
|
+
class="flex items-center justify-between border-b border-zinc-100 px-4 py-3 text-sm last:border-0 hover:bg-zinc-50"
|
|
25
|
+
>
|
|
26
|
+
<span class="font-medium">{{ s.label }}</span>
|
|
27
|
+
<span class="font-mono text-xs text-zinc-500">{{ s.handle }}</span>
|
|
28
|
+
</a>
|
|
29
|
+
<p v-if="sets.length === 0" class="px-4 py-6 text-sm text-zinc-500">No sets yet.</p>
|
|
30
|
+
</div>
|
|
31
|
+
</div>
|
|
32
|
+
</template>
|
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import { computed, onMounted, ref, watch } from 'vue'
|
|
3
|
+
import { adminApi, AdminApiError } from '../client/api'
|
|
4
|
+
|
|
5
|
+
interface Values {
|
|
6
|
+
siteName: string
|
|
7
|
+
deployHookUrl: string
|
|
8
|
+
defaultLocale: string
|
|
9
|
+
locales: string[]
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
const values = ref<Values>({ siteName: '', deployHookUrl: '', defaultLocale: 'default', locales: ['default'] })
|
|
13
|
+
const initial = ref<Values>({ siteName: '', deployHookUrl: '', defaultLocale: 'default', locales: ['default'] })
|
|
14
|
+
const loading = ref(true)
|
|
15
|
+
const saving = ref(false)
|
|
16
|
+
const saved = ref(false)
|
|
17
|
+
const error = ref<string | null>(null)
|
|
18
|
+
const localesText = ref('default')
|
|
19
|
+
|
|
20
|
+
const LOCALE_RE = /^[a-z]{2,3}(-[A-Z]{2})?$|^default$/
|
|
21
|
+
|
|
22
|
+
const localesValid = computed(() => {
|
|
23
|
+
const codes = parseLocales(localesText.value)
|
|
24
|
+
if (codes.length === 0) return 'Add at least one locale.'
|
|
25
|
+
for (const c of codes) {
|
|
26
|
+
if (!LOCALE_RE.test(c)) return `Invalid locale code: ${c}`
|
|
27
|
+
}
|
|
28
|
+
if (!codes.includes(values.value.defaultLocale)) return `Default locale "${values.value.defaultLocale}" must appear in the supported list.`
|
|
29
|
+
return null
|
|
30
|
+
})
|
|
31
|
+
|
|
32
|
+
function parseLocales(text: string): string[] {
|
|
33
|
+
return text.split(',').map((s) => s.trim()).filter(Boolean)
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const supportedLocales = computed(() => parseLocales(localesText.value))
|
|
37
|
+
|
|
38
|
+
watch(localesText, () => {
|
|
39
|
+
const codes = supportedLocales.value
|
|
40
|
+
if (codes.length > 0 && !codes.includes(values.value.defaultLocale)) {
|
|
41
|
+
values.value.defaultLocale = codes[0]
|
|
42
|
+
}
|
|
43
|
+
})
|
|
44
|
+
|
|
45
|
+
async function load() {
|
|
46
|
+
loading.value = true
|
|
47
|
+
try {
|
|
48
|
+
const all = await adminApi.get<Record<string, unknown>>('/api/vulse/settings')
|
|
49
|
+
const locales = Array.isArray(all.locales) && all.locales.length
|
|
50
|
+
? (all.locales as string[])
|
|
51
|
+
: ['default']
|
|
52
|
+
const defaultLocale = typeof all.defaultLocale === 'string' ? all.defaultLocale : 'default'
|
|
53
|
+
const next: Values = {
|
|
54
|
+
siteName: String(all.siteName ?? ''),
|
|
55
|
+
deployHookUrl: String(all.deployHookUrl ?? ''),
|
|
56
|
+
defaultLocale,
|
|
57
|
+
locales,
|
|
58
|
+
}
|
|
59
|
+
values.value = { ...next }
|
|
60
|
+
initial.value = { ...next, locales: [...next.locales] }
|
|
61
|
+
localesText.value = locales.join(', ')
|
|
62
|
+
} finally {
|
|
63
|
+
loading.value = false
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function dirty(key: keyof Values): boolean {
|
|
68
|
+
if (key === 'locales') {
|
|
69
|
+
const a = parseLocales(localesText.value)
|
|
70
|
+
const b = initial.value.locales
|
|
71
|
+
return a.length !== b.length || a.some((v, i) => v !== b[i])
|
|
72
|
+
}
|
|
73
|
+
return values.value[key] !== initial.value[key]
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function anyDirty(): boolean {
|
|
77
|
+
return dirty('siteName') || dirty('deployHookUrl') || dirty('defaultLocale') || dirty('locales')
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
async function save() {
|
|
81
|
+
if (!anyDirty()) return
|
|
82
|
+
if (localesValid.value) {
|
|
83
|
+
error.value = localesValid.value
|
|
84
|
+
return
|
|
85
|
+
}
|
|
86
|
+
saving.value = true
|
|
87
|
+
saved.value = false
|
|
88
|
+
error.value = null
|
|
89
|
+
try {
|
|
90
|
+
values.value.locales = parseLocales(localesText.value)
|
|
91
|
+
if (dirty('siteName')) await adminApi.put('/api/vulse/settings/siteName', { value: values.value.siteName })
|
|
92
|
+
if (dirty('deployHookUrl')) await adminApi.put('/api/vulse/settings/deployHookUrl', { value: values.value.deployHookUrl })
|
|
93
|
+
if (dirty('defaultLocale')) await adminApi.put('/api/vulse/settings/defaultLocale', { value: values.value.defaultLocale })
|
|
94
|
+
if (dirty('locales')) await adminApi.put('/api/vulse/settings/locales', { value: values.value.locales })
|
|
95
|
+
initial.value = { ...values.value, locales: [...values.value.locales] }
|
|
96
|
+
saved.value = true
|
|
97
|
+
} catch (e) {
|
|
98
|
+
error.value = e instanceof AdminApiError ? e.message : 'Save failed'
|
|
99
|
+
} finally {
|
|
100
|
+
saving.value = false
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function onInput() {
|
|
105
|
+
saved.value = false
|
|
106
|
+
error.value = null
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
onMounted(load)
|
|
110
|
+
</script>
|
|
111
|
+
|
|
112
|
+
<template>
|
|
113
|
+
<form class="vulse-panel max-w-md space-y-4" @submit.prevent="save">
|
|
114
|
+
<p v-if="loading" class="text-sm text-zinc-500">Loading…</p>
|
|
115
|
+
<template v-else>
|
|
116
|
+
<label class="block">
|
|
117
|
+
<span class="text-sm font-medium text-zinc-700">Site name</span>
|
|
118
|
+
<input
|
|
119
|
+
v-model="values.siteName"
|
|
120
|
+
class="mt-1 w-full rounded border border-zinc-300 px-3 py-2 text-sm"
|
|
121
|
+
@input="onInput"
|
|
122
|
+
/>
|
|
123
|
+
</label>
|
|
124
|
+
<label class="block">
|
|
125
|
+
<span class="text-sm font-medium text-zinc-700">Deploy hook URL</span>
|
|
126
|
+
<input
|
|
127
|
+
v-model="values.deployHookUrl"
|
|
128
|
+
class="mt-1 w-full rounded border border-zinc-300 px-3 py-2 text-sm"
|
|
129
|
+
placeholder="https://api.cloudflare.com/client/v4/pages/…/deploy_hooks/…"
|
|
130
|
+
@input="onInput"
|
|
131
|
+
/>
|
|
132
|
+
<span class="mt-1 block text-xs text-zinc-500">
|
|
133
|
+
Called after publishing entries to trigger a rebuild (e.g. a Cloudflare Pages deploy hook).
|
|
134
|
+
</span>
|
|
135
|
+
</label>
|
|
136
|
+
|
|
137
|
+
<fieldset class="space-y-3 rounded border border-zinc-200 bg-white p-4">
|
|
138
|
+
<legend class="text-sm font-semibold text-zinc-700">Locales</legend>
|
|
139
|
+
<p class="text-xs text-zinc-500">
|
|
140
|
+
Each entry can be authored once per supported locale. The default locale is used when callers don't pass one.
|
|
141
|
+
</p>
|
|
142
|
+
<label class="block">
|
|
143
|
+
<span class="text-sm font-medium text-zinc-700">Supported locales</span>
|
|
144
|
+
<input
|
|
145
|
+
v-model="localesText"
|
|
146
|
+
class="mt-1 w-full rounded border border-zinc-300 px-3 py-2 font-mono text-sm"
|
|
147
|
+
placeholder="default, en, nb-NO"
|
|
148
|
+
@input="onInput"
|
|
149
|
+
/>
|
|
150
|
+
<span class="mt-1 block text-xs text-zinc-500">
|
|
151
|
+
Comma-separated BCP-47 codes (e.g. <code>en</code>, <code>nb-NO</code>). The literal <code>default</code> is allowed for sites that don't ship multilingual content.
|
|
152
|
+
</span>
|
|
153
|
+
</label>
|
|
154
|
+
<label class="block">
|
|
155
|
+
<span class="text-sm font-medium text-zinc-700">Default locale</span>
|
|
156
|
+
<select
|
|
157
|
+
v-if="supportedLocales.length"
|
|
158
|
+
v-model="values.defaultLocale"
|
|
159
|
+
class="mt-1 w-full rounded border border-zinc-300 px-3 py-2 font-mono text-sm"
|
|
160
|
+
@change="onInput"
|
|
161
|
+
>
|
|
162
|
+
<option v-for="code in supportedLocales" :key="code" :value="code">{{ code }}</option>
|
|
163
|
+
</select>
|
|
164
|
+
<input
|
|
165
|
+
v-else
|
|
166
|
+
v-model="values.defaultLocale"
|
|
167
|
+
class="mt-1 w-full rounded border border-zinc-300 px-3 py-2 font-mono text-sm"
|
|
168
|
+
disabled
|
|
169
|
+
placeholder="Add supported locales first"
|
|
170
|
+
/>
|
|
171
|
+
</label>
|
|
172
|
+
<p v-if="localesValid" class="rounded bg-red-50 px-3 py-2 text-sm text-red-700">{{ localesValid }}</p>
|
|
173
|
+
</fieldset>
|
|
174
|
+
|
|
175
|
+
<p v-if="error" class="rounded bg-red-50 px-3 py-2 text-sm text-red-700">{{ error }}</p>
|
|
176
|
+
<div class="flex items-center gap-3 pt-2">
|
|
177
|
+
<button
|
|
178
|
+
type="submit"
|
|
179
|
+
class="vulse-button-primary rounded px-4 py-2 text-sm font-medium disabled:opacity-50"
|
|
180
|
+
:disabled="saving || !anyDirty() || !!localesValid"
|
|
181
|
+
>
|
|
182
|
+
{{ saving ? 'Saving…' : 'Save' }}
|
|
183
|
+
</button>
|
|
184
|
+
<span v-if="saved" class="text-sm text-zinc-500">Saved.</span>
|
|
185
|
+
<span v-else-if="anyDirty()" class="text-sm text-amber-600">Unsaved changes</span>
|
|
186
|
+
</div>
|
|
187
|
+
</template>
|
|
188
|
+
</form>
|
|
189
|
+
</template>
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import { onMounted, ref, watch } from 'vue'
|
|
3
|
+
import CollectionKindIcon from './CollectionKindIcon.vue'
|
|
4
|
+
|
|
5
|
+
const logoUrl = new URL('../assets/logo-mark.svg', import.meta.url).href
|
|
6
|
+
|
|
7
|
+
const props = defineProps<{
|
|
8
|
+
collections: { name: string; label: string; singleton?: boolean }[]
|
|
9
|
+
activePath?: string
|
|
10
|
+
userEmail?: string
|
|
11
|
+
isAdmin?: boolean
|
|
12
|
+
}>()
|
|
13
|
+
|
|
14
|
+
const schemaOpen = ref(false)
|
|
15
|
+
|
|
16
|
+
const SCHEMA_OPEN_KEY = 'vulse.sidebar.schema.open'
|
|
17
|
+
|
|
18
|
+
onMounted(() => {
|
|
19
|
+
try {
|
|
20
|
+
schemaOpen.value = localStorage.getItem(SCHEMA_OPEN_KEY) === '1'
|
|
21
|
+
} catch {
|
|
22
|
+
// ignore
|
|
23
|
+
}
|
|
24
|
+
})
|
|
25
|
+
|
|
26
|
+
watch(schemaOpen, (v) => {
|
|
27
|
+
try { localStorage.setItem(SCHEMA_OPEN_KEY, v ? '1' : '0') } catch { /* ignore */ }
|
|
28
|
+
})
|
|
29
|
+
|
|
30
|
+
function navClass(href: string) {
|
|
31
|
+
const active = props.activePath === href || (href !== '/admin' && props.activePath?.startsWith(href))
|
|
32
|
+
return ['vulse-nav-link rounded-xl text-sm text-zinc-800', active && 'vulse-nav-link-active'].filter(Boolean)
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function subNavClass(href: string, exact = false) {
|
|
36
|
+
const active = exact ? props.activePath === href : props.activePath?.startsWith(href)
|
|
37
|
+
return ['block rounded px-2 py-1.5 text-sm hover:bg-zinc-100', active && 'bg-zinc-100 font-medium']
|
|
38
|
+
.filter(Boolean)
|
|
39
|
+
.join(' ')
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
async function signOut() {
|
|
43
|
+
await fetch('/api/auth/sign-out', {
|
|
44
|
+
method: 'POST',
|
|
45
|
+
credentials: 'same-origin',
|
|
46
|
+
headers: { 'content-type': 'application/json' },
|
|
47
|
+
body: '{}',
|
|
48
|
+
})
|
|
49
|
+
window.location.href = '/admin/login'
|
|
50
|
+
}
|
|
51
|
+
</script>
|
|
52
|
+
|
|
53
|
+
<template>
|
|
54
|
+
<aside class="w-[var(--vulse-sidebar-width)] min-h-screen border-r border-zinc-200 bg-white shrink-0">
|
|
55
|
+
<div class="px-4 py-3 font-semibold tracking-tight flex items-center gap-2">
|
|
56
|
+
<img class="h-8 w-8" :src="logoUrl" alt="Vulse" />
|
|
57
|
+
Vulse
|
|
58
|
+
</div>
|
|
59
|
+
|
|
60
|
+
<div v-if="userEmail" class="border-y border-zinc-100 px-4 py-2 text-xs">
|
|
61
|
+
<div class="font-mono text-zinc-700">{{ userEmail }}</div>
|
|
62
|
+
<button type="button" class="mt-1 text-zinc-500 hover:text-zinc-900" @click="signOut">
|
|
63
|
+
Sign out
|
|
64
|
+
</button>
|
|
65
|
+
</div>
|
|
66
|
+
|
|
67
|
+
<nav class="px-2 pb-6">
|
|
68
|
+
<div class="px-2 pt-2 text-xs uppercase tracking-wide text-zinc-500">Collections</div>
|
|
69
|
+
<a
|
|
70
|
+
v-for="c in collections"
|
|
71
|
+
:key="`coll-${c.name}`"
|
|
72
|
+
:href="`/admin/collections/${c.name}`"
|
|
73
|
+
:class="navClass(`/admin/collections/${c.name}`)"
|
|
74
|
+
>
|
|
75
|
+
<span class="flex items-center gap-2">
|
|
76
|
+
<CollectionKindIcon :singleton="c.singleton" />
|
|
77
|
+
<span>{{ c.label }}</span>
|
|
78
|
+
</span>
|
|
79
|
+
</a>
|
|
80
|
+
|
|
81
|
+
<div class="px-2 pt-4 text-xs uppercase tracking-wide text-zinc-500">Forms</div>
|
|
82
|
+
<a href="/admin/forms" :class="navClass('/admin/forms')">
|
|
83
|
+
<span class="flex items-center gap-2">
|
|
84
|
+
<svg class="h-4 w-4 shrink-0 text-zinc-500" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true">
|
|
85
|
+
<path d="M2.5 4A1.5 1.5 0 0 1 4 2.5h12A1.5 1.5 0 0 1 17.5 4v12a1.5 1.5 0 0 1-1.5 1.5H4A1.5 1.5 0 0 1 2.5 16V4ZM4 4v12h12V4H4Zm2 2h8v1.5H6V6Zm0 3h8v1.5H6V9Zm0 3h5v1.5H6V12Z" />
|
|
86
|
+
</svg>
|
|
87
|
+
<span>Forms</span>
|
|
88
|
+
</span>
|
|
89
|
+
</a>
|
|
90
|
+
|
|
91
|
+
<div class="px-2 pt-4 text-xs uppercase tracking-wide text-zinc-500">Media</div>
|
|
92
|
+
<a href="/admin/media" :class="navClass('/admin/media')">
|
|
93
|
+
<span class="flex items-center gap-2">
|
|
94
|
+
<svg class="h-4 w-4 shrink-0 text-zinc-500" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true">
|
|
95
|
+
<path fill-rule="evenodd" d="M3 5a2 2 0 0 1 2-2h10a2 2 0 0 1 2 2v10a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5Zm2 0v8.586l2.293-2.293a1 1 0 0 1 1.414 0L11 13.586l2.293-2.293a1 1 0 0 1 1.414 0L15 11.586V5H5Zm9 3a1 1 0 1 1-2 0 1 1 0 0 1 2 0Z" clip-rule="evenodd" />
|
|
96
|
+
</svg>
|
|
97
|
+
<span>Assets</span>
|
|
98
|
+
</span>
|
|
99
|
+
</a>
|
|
100
|
+
|
|
101
|
+
<div class="px-2 pt-4 text-xs uppercase tracking-wide text-zinc-500">Schema</div>
|
|
102
|
+
<button
|
|
103
|
+
type="button"
|
|
104
|
+
class="flex w-full items-center gap-1 rounded px-2 py-1.5 text-left text-sm hover:bg-zinc-100"
|
|
105
|
+
:aria-expanded="schemaOpen"
|
|
106
|
+
@click="schemaOpen = !schemaOpen"
|
|
107
|
+
>
|
|
108
|
+
<span class="inline-block w-3 text-zinc-400">{{ schemaOpen ? '▾' : '▸' }}</span>
|
|
109
|
+
<span>Collections</span>
|
|
110
|
+
</button>
|
|
111
|
+
<div v-if="schemaOpen" class="ml-4">
|
|
112
|
+
<a
|
|
113
|
+
v-for="c in collections"
|
|
114
|
+
:key="`schema-${c.name}`"
|
|
115
|
+
:href="`/admin/schema/${c.name}`"
|
|
116
|
+
:class="navClass(`/admin/schema/${c.name}`)"
|
|
117
|
+
>
|
|
118
|
+
<span class="flex items-center gap-2">
|
|
119
|
+
<CollectionKindIcon :singleton="c.singleton" />
|
|
120
|
+
<span>{{ c.label }}</span>
|
|
121
|
+
</span>
|
|
122
|
+
</a>
|
|
123
|
+
<a href="/admin/schema/new" :class="navClass('/admin/schema/new')" class="text-zinc-600">
|
|
124
|
+
+ New collection
|
|
125
|
+
</a>
|
|
126
|
+
</div>
|
|
127
|
+
<a
|
|
128
|
+
v-if="isAdmin"
|
|
129
|
+
href="/admin/settings/sets"
|
|
130
|
+
:class="subNavClass('/admin/settings/sets')"
|
|
131
|
+
>
|
|
132
|
+
Sets
|
|
133
|
+
</a>
|
|
134
|
+
<a
|
|
135
|
+
v-if="isAdmin"
|
|
136
|
+
href="/admin/settings/globals"
|
|
137
|
+
:class="subNavClass('/admin/settings/globals')"
|
|
138
|
+
>
|
|
139
|
+
Globals
|
|
140
|
+
</a>
|
|
141
|
+
|
|
142
|
+
<template v-if="isAdmin">
|
|
143
|
+
<div class="px-2 pt-4 text-xs uppercase tracking-wide text-zinc-500">Users</div>
|
|
144
|
+
<a href="/admin/users" :class="subNavClass('/admin/users')">Users</a>
|
|
145
|
+
|
|
146
|
+
<div class="px-2 pt-4 text-xs uppercase tracking-wide text-zinc-500">Settings</div>
|
|
147
|
+
<a href="/admin/settings" :class="subNavClass('/admin/settings', true)">Site</a>
|
|
148
|
+
<a href="/admin/settings/auth" :class="subNavClass('/admin/settings/auth', true)">Auth</a>
|
|
149
|
+
</template>
|
|
150
|
+
</nav>
|
|
151
|
+
</aside>
|
|
152
|
+
</template>
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import { onMounted, ref } from 'vue'
|
|
3
|
+
import { adminApi } from '../client/api.js'
|
|
4
|
+
|
|
5
|
+
const props = defineProps<{ formHandle: string; submissionId: string }>()
|
|
6
|
+
|
|
7
|
+
interface SubmissionRow {
|
|
8
|
+
id: string
|
|
9
|
+
payload: Record<string, unknown>
|
|
10
|
+
fileRefs: { field: string; mediaId: string }[]
|
|
11
|
+
status: string
|
|
12
|
+
error: string | null
|
|
13
|
+
createdAt: string
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
const row = ref<SubmissionRow | null>(null)
|
|
17
|
+
|
|
18
|
+
onMounted(async () => {
|
|
19
|
+
row.value = await adminApi.get<SubmissionRow>(`/api/vulse/forms/${props.formHandle}/submissions/${props.submissionId}`)
|
|
20
|
+
})
|
|
21
|
+
|
|
22
|
+
async function destroy() {
|
|
23
|
+
if (!confirm('Delete this submission?')) return
|
|
24
|
+
await adminApi.delete(`/api/vulse/forms/${props.formHandle}/submissions/${props.submissionId}`)
|
|
25
|
+
window.location.href = `/admin/forms/${props.formHandle}/submissions`
|
|
26
|
+
}
|
|
27
|
+
</script>
|
|
28
|
+
|
|
29
|
+
<template>
|
|
30
|
+
<div v-if="row">
|
|
31
|
+
<div class="mb-6 flex items-center justify-between">
|
|
32
|
+
<h1 class="text-2xl font-semibold">Submission</h1>
|
|
33
|
+
<button type="button" class="rounded border border-red-200 px-3 py-1 text-sm text-red-700" @click="destroy">Delete</button>
|
|
34
|
+
</div>
|
|
35
|
+
<p class="mb-2 text-sm text-zinc-500">Status: <span class="rounded bg-zinc-100 px-2 py-0.5">{{ row.status }}</span></p>
|
|
36
|
+
<p v-if="row.error" class="mb-4 text-sm text-red-600">{{ row.error }}</p>
|
|
37
|
+
<pre class="rounded-xl border border-zinc-200 bg-white p-4 text-xs">{{ JSON.stringify(row.payload, null, 2) }}</pre>
|
|
38
|
+
<div v-if="row.fileRefs.length" class="mt-4">
|
|
39
|
+
<h2 class="text-sm font-semibold">Files</h2>
|
|
40
|
+
<ul class="mt-2 text-sm">
|
|
41
|
+
<li v-for="f in row.fileRefs" :key="f.mediaId">{{ f.field }}: {{ f.mediaId }}</li>
|
|
42
|
+
</ul>
|
|
43
|
+
</div>
|
|
44
|
+
</div>
|
|
45
|
+
</template>
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import { computed, onMounted, ref } from 'vue'
|
|
3
|
+
import { adminApi } from '../client/api.js'
|
|
4
|
+
|
|
5
|
+
const props = defineProps<{ formHandle: string }>()
|
|
6
|
+
|
|
7
|
+
interface SubmissionRow {
|
|
8
|
+
id: string
|
|
9
|
+
payload: Record<string, unknown>
|
|
10
|
+
status: string
|
|
11
|
+
createdAt: string
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
const rows = ref<SubmissionRow[]>([])
|
|
15
|
+
const selected = ref<Set<string>>(new Set())
|
|
16
|
+
const loading = ref(true)
|
|
17
|
+
|
|
18
|
+
const previewField = computed(() => {
|
|
19
|
+
const first = rows.value[0]
|
|
20
|
+
if (!first) return null
|
|
21
|
+
const entry = Object.entries(first.payload).find(([k]) => !k.startsWith('_'))
|
|
22
|
+
return entry?.[1]
|
|
23
|
+
})
|
|
24
|
+
|
|
25
|
+
onMounted(async () => {
|
|
26
|
+
rows.value = await adminApi.get<SubmissionRow[]>(`/api/vulse/forms/${props.formHandle}/submissions`)
|
|
27
|
+
loading.value = false
|
|
28
|
+
})
|
|
29
|
+
|
|
30
|
+
function toggle(id: string) {
|
|
31
|
+
if (selected.value.has(id)) selected.value.delete(id)
|
|
32
|
+
else selected.value.add(id)
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
async function bulkDelete() {
|
|
36
|
+
if (selected.value.size === 0 || !confirm(`Delete ${selected.value.size} submission(s)?`)) return
|
|
37
|
+
await adminApi.post(`/api/vulse/forms/${props.formHandle}/submissions/delete`, { ids: [...selected.value] })
|
|
38
|
+
rows.value = rows.value.filter((r) => !selected.value.has(r.id))
|
|
39
|
+
selected.value.clear()
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function exportCsv() {
|
|
43
|
+
if (rows.value.length === 0) return
|
|
44
|
+
const keys = [...new Set(rows.value.flatMap((r) => Object.keys(r.payload)))]
|
|
45
|
+
const lines = [keys.join(',')]
|
|
46
|
+
for (const r of rows.value) {
|
|
47
|
+
lines.push(keys.map((k) => JSON.stringify(r.payload[k] ?? '')).join(','))
|
|
48
|
+
}
|
|
49
|
+
const blob = new Blob([lines.join('\n')], { type: 'text/csv' })
|
|
50
|
+
const a = document.createElement('a')
|
|
51
|
+
a.href = URL.createObjectURL(blob)
|
|
52
|
+
a.download = `${props.formHandle}-submissions.csv`
|
|
53
|
+
a.click()
|
|
54
|
+
}
|
|
55
|
+
</script>
|
|
56
|
+
|
|
57
|
+
<template>
|
|
58
|
+
<div>
|
|
59
|
+
<div class="mb-6 flex items-center justify-between">
|
|
60
|
+
<h1 class="text-2xl font-semibold">Submissions</h1>
|
|
61
|
+
<div class="flex gap-2">
|
|
62
|
+
<button type="button" class="rounded border border-zinc-300 px-3 py-1 text-sm" @click="exportCsv">Export CSV</button>
|
|
63
|
+
<button type="button" class="rounded border border-red-200 px-3 py-1 text-sm text-red-700" :disabled="selected.size === 0" @click="bulkDelete">Delete selected</button>
|
|
64
|
+
</div>
|
|
65
|
+
</div>
|
|
66
|
+
<p v-if="loading" class="text-sm text-zinc-500">Loading…</p>
|
|
67
|
+
<table v-else class="w-full text-sm">
|
|
68
|
+
<thead>
|
|
69
|
+
<tr class="border-b border-zinc-200 text-left text-zinc-500">
|
|
70
|
+
<th class="py-2 pr-2"></th>
|
|
71
|
+
<th class="py-2 pr-4">Preview</th>
|
|
72
|
+
<th class="py-2 pr-4">Status</th>
|
|
73
|
+
<th class="py-2 pr-4">Created</th>
|
|
74
|
+
<th class="py-2"></th>
|
|
75
|
+
</tr>
|
|
76
|
+
</thead>
|
|
77
|
+
<tbody>
|
|
78
|
+
<tr v-for="r in rows" :key="r.id" class="border-b border-zinc-100">
|
|
79
|
+
<td class="py-2 pr-2"><input type="checkbox" :checked="selected.has(r.id)" @change="toggle(r.id)" /></td>
|
|
80
|
+
<td class="py-2 pr-4 max-w-xs truncate">{{ Object.values(r.payload)[0] }}</td>
|
|
81
|
+
<td class="py-2 pr-4"><span class="rounded bg-zinc-100 px-2 py-0.5 text-xs">{{ r.status }}</span></td>
|
|
82
|
+
<td class="py-2 pr-4 text-xs text-zinc-500">{{ new Date(r.createdAt).toLocaleString() }}</td>
|
|
83
|
+
<td class="py-2"><a :href="`/admin/forms/${formHandle}/submissions/${r.id}`" class="hover:underline">View</a></td>
|
|
84
|
+
</tr>
|
|
85
|
+
</tbody>
|
|
86
|
+
</table>
|
|
87
|
+
<p v-if="!loading && rows.length === 0" class="text-sm text-zinc-500">No submissions yet.</p>
|
|
88
|
+
</div>
|
|
89
|
+
</template>
|