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