@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,171 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'
|
|
3
|
+
import { adminApi, AdminApiError } from '../client/api.js'
|
|
4
|
+
|
|
5
|
+
const props = defineProps<{
|
|
6
|
+
collection: string
|
|
7
|
+
entryId?: string
|
|
8
|
+
previewPath: string
|
|
9
|
+
slug: string
|
|
10
|
+
content: Record<string, unknown>
|
|
11
|
+
entryLocale?: string
|
|
12
|
+
}>()
|
|
13
|
+
|
|
14
|
+
const iframeRef = ref<HTMLIFrameElement | null>(null)
|
|
15
|
+
const iframeSrc = ref('')
|
|
16
|
+
const sessionId = ref<string | null>(null)
|
|
17
|
+
const lastSlug = ref(props.slug)
|
|
18
|
+
const error = ref<string | null>(null)
|
|
19
|
+
const starting = ref(false)
|
|
20
|
+
|
|
21
|
+
let updateTimer: ReturnType<typeof setTimeout> | null = null
|
|
22
|
+
|
|
23
|
+
function buildPreviewUrl(slug: string): string {
|
|
24
|
+
const path = props.previewPath.replace('{slug}', encodeURIComponent(slug))
|
|
25
|
+
const url = new URL(path, window.location.origin)
|
|
26
|
+
if (sessionId.value) url.searchParams.set('vulse_live_preview', sessionId.value)
|
|
27
|
+
return url.toString()
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function postPreviewUpdated() {
|
|
31
|
+
if (!sessionId.value) return
|
|
32
|
+
iframeRef.value?.contentWindow?.postMessage(
|
|
33
|
+
{ name: 'vulse.preview.updated', token: sessionId.value },
|
|
34
|
+
window.location.origin,
|
|
35
|
+
)
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
async function startSession() {
|
|
39
|
+
starting.value = true
|
|
40
|
+
error.value = null
|
|
41
|
+
try {
|
|
42
|
+
const created = await adminApi.post<{ id: string; previewUrl: string }>(
|
|
43
|
+
'/api/vulse/preview/sessions',
|
|
44
|
+
{
|
|
45
|
+
collection: props.collection,
|
|
46
|
+
entryId: props.entryId ?? null,
|
|
47
|
+
slug: props.slug,
|
|
48
|
+
content: props.content,
|
|
49
|
+
...(props.entryLocale ? { locale: props.entryLocale } : {}),
|
|
50
|
+
},
|
|
51
|
+
)
|
|
52
|
+
sessionId.value = created.id
|
|
53
|
+
iframeSrc.value = created.previewUrl
|
|
54
|
+
lastSlug.value = props.slug
|
|
55
|
+
await updateSession()
|
|
56
|
+
} catch (e) {
|
|
57
|
+
if (e instanceof AdminApiError) error.value = e.message
|
|
58
|
+
else error.value = 'Failed to start live preview'
|
|
59
|
+
} finally {
|
|
60
|
+
starting.value = false
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
async function updateSession() {
|
|
65
|
+
if (!sessionId.value) return
|
|
66
|
+
try {
|
|
67
|
+
await adminApi.put(`/api/vulse/preview/sessions/${sessionId.value}`, {
|
|
68
|
+
content: props.content,
|
|
69
|
+
slug: props.slug,
|
|
70
|
+
...(props.entryLocale ? { locale: props.entryLocale } : {}),
|
|
71
|
+
})
|
|
72
|
+
if (props.slug !== lastSlug.value) {
|
|
73
|
+
iframeSrc.value = buildPreviewUrl(props.slug)
|
|
74
|
+
lastSlug.value = props.slug
|
|
75
|
+
}
|
|
76
|
+
postPreviewUpdated()
|
|
77
|
+
error.value = null
|
|
78
|
+
} catch (e) {
|
|
79
|
+
if (e instanceof AdminApiError && (e.status === 403 || e.status === 404)) {
|
|
80
|
+
sessionId.value = null
|
|
81
|
+
iframeSrc.value = ''
|
|
82
|
+
await startSession()
|
|
83
|
+
return
|
|
84
|
+
}
|
|
85
|
+
if (e instanceof AdminApiError) error.value = e.message
|
|
86
|
+
else error.value = 'Failed to update live preview'
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function scheduleUpdate() {
|
|
91
|
+
if (!sessionId.value) return
|
|
92
|
+
if (updateTimer) clearTimeout(updateTimer)
|
|
93
|
+
updateTimer = setTimeout(() => {
|
|
94
|
+
void updateSession()
|
|
95
|
+
}, 100)
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
const showIframe = computed(() => !!iframeSrc.value && !starting.value && !error.value)
|
|
99
|
+
|
|
100
|
+
watch(
|
|
101
|
+
() => ({ slug: props.slug, content: props.content }),
|
|
102
|
+
() => scheduleUpdate(),
|
|
103
|
+
{ deep: true },
|
|
104
|
+
)
|
|
105
|
+
|
|
106
|
+
onMounted(async () => {
|
|
107
|
+
await startSession()
|
|
108
|
+
})
|
|
109
|
+
|
|
110
|
+
onBeforeUnmount(() => {
|
|
111
|
+
if (updateTimer) clearTimeout(updateTimer)
|
|
112
|
+
const id = sessionId.value
|
|
113
|
+
if (!id) return
|
|
114
|
+
void adminApi.delete(`/api/vulse/preview/sessions/${id}`).catch(() => {})
|
|
115
|
+
})
|
|
116
|
+
</script>
|
|
117
|
+
|
|
118
|
+
<template>
|
|
119
|
+
<aside class="flex min-h-[520px] flex-col rounded border border-zinc-200 bg-white">
|
|
120
|
+
<header class="flex items-center justify-between gap-3 border-b border-zinc-200 px-4 py-3">
|
|
121
|
+
<h2 class="text-sm font-semibold text-zinc-900">Live preview</h2>
|
|
122
|
+
<div class="flex items-center gap-3">
|
|
123
|
+
<button
|
|
124
|
+
v-if="error"
|
|
125
|
+
type="button"
|
|
126
|
+
class="text-sm text-zinc-600 underline hover:text-zinc-900"
|
|
127
|
+
@click="startSession"
|
|
128
|
+
>
|
|
129
|
+
Retry
|
|
130
|
+
</button>
|
|
131
|
+
<a
|
|
132
|
+
v-if="iframeSrc"
|
|
133
|
+
:href="iframeSrc"
|
|
134
|
+
target="_blank"
|
|
135
|
+
rel="noreferrer"
|
|
136
|
+
class="text-sm text-zinc-600 underline hover:text-zinc-900"
|
|
137
|
+
>
|
|
138
|
+
Open in tab
|
|
139
|
+
</a>
|
|
140
|
+
</div>
|
|
141
|
+
</header>
|
|
142
|
+
|
|
143
|
+
<div class="flex flex-1 flex-col">
|
|
144
|
+
<div
|
|
145
|
+
v-if="error"
|
|
146
|
+
class="flex min-h-[480px] flex-1 flex-col items-center justify-center gap-3 bg-red-50 px-6 text-sm text-red-700"
|
|
147
|
+
>
|
|
148
|
+
<p>{{ error }}</p>
|
|
149
|
+
<button
|
|
150
|
+
type="button"
|
|
151
|
+
class="rounded border border-red-300 bg-white px-3 py-1.5 text-sm hover:bg-red-50"
|
|
152
|
+
@click="startSession"
|
|
153
|
+
>
|
|
154
|
+
Restart preview
|
|
155
|
+
</button>
|
|
156
|
+
</div>
|
|
157
|
+
<div
|
|
158
|
+
v-else-if="starting || !iframeSrc"
|
|
159
|
+
class="flex min-h-[480px] flex-1 items-center justify-center bg-zinc-50 px-6 text-sm text-zinc-500"
|
|
160
|
+
>
|
|
161
|
+
Starting preview session…
|
|
162
|
+
</div>
|
|
163
|
+
<iframe
|
|
164
|
+
v-else-if="showIframe"
|
|
165
|
+
ref="iframeRef"
|
|
166
|
+
:src="iframeSrc"
|
|
167
|
+
class="min-h-[480px] w-full flex-1 border-0 bg-white"
|
|
168
|
+
/>
|
|
169
|
+
</div>
|
|
170
|
+
</aside>
|
|
171
|
+
</template>
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import { ref } from 'vue'
|
|
3
|
+
const props = defineProps<{ next: string }>()
|
|
4
|
+
const email = ref('')
|
|
5
|
+
const password = ref('')
|
|
6
|
+
const error = ref<string | null>(null)
|
|
7
|
+
const loading = ref(false)
|
|
8
|
+
|
|
9
|
+
function safeNext(raw: string): string {
|
|
10
|
+
// Only allow same-origin path redirects; reject protocol-relative ("//evil")
|
|
11
|
+
// and absolute URLs.
|
|
12
|
+
if (typeof raw !== 'string' || !raw.startsWith('/') || raw.startsWith('//')) return '/admin'
|
|
13
|
+
return raw
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
async function submit(e: Event) {
|
|
17
|
+
e.preventDefault()
|
|
18
|
+
loading.value = true
|
|
19
|
+
error.value = null
|
|
20
|
+
const res = await fetch('/api/auth/sign-in/email', {
|
|
21
|
+
method: 'POST',
|
|
22
|
+
headers: {
|
|
23
|
+
'content-type': 'application/json',
|
|
24
|
+
Origin: window.location.origin,
|
|
25
|
+
},
|
|
26
|
+
body: JSON.stringify({ email: email.value, password: password.value }),
|
|
27
|
+
})
|
|
28
|
+
loading.value = false
|
|
29
|
+
if (!res.ok) { error.value = 'Invalid email or password'; return }
|
|
30
|
+
window.location.href = safeNext(props.next)
|
|
31
|
+
}
|
|
32
|
+
</script>
|
|
33
|
+
|
|
34
|
+
<template>
|
|
35
|
+
<form @submit="submit" class="w-80 p-6 rounded-xl shadow-sm border bg-white space-y-4">
|
|
36
|
+
<h1 class="text-2xl font-semibold">Sign in</h1>
|
|
37
|
+
<label class="block">
|
|
38
|
+
<span class="text-sm text-zinc-600">Email</span>
|
|
39
|
+
<input v-model="email" type="email" required class="mt-1 w-full rounded border px-3 py-2" />
|
|
40
|
+
</label>
|
|
41
|
+
<label class="block">
|
|
42
|
+
<span class="text-sm text-zinc-600">Password</span>
|
|
43
|
+
<input v-model="password" type="password" required class="mt-1 w-full rounded border px-3 py-2" />
|
|
44
|
+
</label>
|
|
45
|
+
<p v-if="error" class="text-sm text-red-600">{{ error }}</p>
|
|
46
|
+
<button :disabled="loading" class="w-full rounded bg-brand py-2 text-white font-medium disabled:opacity-50">
|
|
47
|
+
{{ loading ? 'Signing in…' : 'Sign in' }}
|
|
48
|
+
</button>
|
|
49
|
+
<p class="text-center text-sm text-zinc-500">
|
|
50
|
+
<a href="/forgot-password?next=/admin" class="text-brand hover:underline">Forgot password?</a>
|
|
51
|
+
</p>
|
|
52
|
+
</form>
|
|
53
|
+
</template>
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import { ref, onMounted } from 'vue'
|
|
3
|
+
import { adminApi } from '../client/api.js'
|
|
4
|
+
|
|
5
|
+
interface MediaItem {
|
|
6
|
+
id: string
|
|
7
|
+
r2Key: string
|
|
8
|
+
mime: string
|
|
9
|
+
alt: string | null
|
|
10
|
+
width: number | null
|
|
11
|
+
height: number | null
|
|
12
|
+
deliveryUrl: string | null
|
|
13
|
+
previewUrl: string
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
const items = ref<MediaItem[]>([])
|
|
17
|
+
const uploading = ref(false)
|
|
18
|
+
const error = ref<string | null>(null)
|
|
19
|
+
|
|
20
|
+
function previewSrc(item: MediaItem): string {
|
|
21
|
+
return item.deliveryUrl ?? item.previewUrl
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
async function load() {
|
|
25
|
+
error.value = null
|
|
26
|
+
try {
|
|
27
|
+
items.value = await adminApi.get<MediaItem[]>('/api/vulse/media')
|
|
28
|
+
} catch (e) {
|
|
29
|
+
error.value = e instanceof Error ? e.message : 'Failed to load media'
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
async function onFiles(files: FileList | null) {
|
|
34
|
+
if (!files?.length) return
|
|
35
|
+
uploading.value = true
|
|
36
|
+
error.value = null
|
|
37
|
+
try {
|
|
38
|
+
for (const f of Array.from(files)) {
|
|
39
|
+
const form = new FormData()
|
|
40
|
+
form.append('file', f)
|
|
41
|
+
const res = await fetch('/api/vulse/media', { method: 'POST', body: form, credentials: 'same-origin' })
|
|
42
|
+
const body = await res.json() as { ok: boolean; error?: { message: string } }
|
|
43
|
+
if (!body.ok) throw new Error(body.error?.message ?? 'Upload failed')
|
|
44
|
+
}
|
|
45
|
+
await load()
|
|
46
|
+
} catch (e) {
|
|
47
|
+
error.value = e instanceof Error ? e.message : 'Upload failed'
|
|
48
|
+
} finally {
|
|
49
|
+
uploading.value = false
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
async function softDelete(id: string) {
|
|
54
|
+
if (!confirm('Delete this asset?')) return
|
|
55
|
+
await adminApi.delete(`/api/vulse/media/${id}`)
|
|
56
|
+
await load()
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
async function setAlt(id: string, alt: string) {
|
|
60
|
+
await adminApi.patch(`/api/vulse/media/${id}`, { alt })
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
onMounted(load)
|
|
64
|
+
</script>
|
|
65
|
+
|
|
66
|
+
<template>
|
|
67
|
+
<div>
|
|
68
|
+
<div class="mb-4 flex items-center gap-3">
|
|
69
|
+
<label class="vulse-button-primary cursor-pointer rounded px-4 py-2 text-sm font-medium">
|
|
70
|
+
Upload
|
|
71
|
+
<input
|
|
72
|
+
type="file"
|
|
73
|
+
multiple
|
|
74
|
+
accept="image/*"
|
|
75
|
+
class="hidden"
|
|
76
|
+
@change="onFiles(($event.target as HTMLInputElement).files)"
|
|
77
|
+
/>
|
|
78
|
+
</label>
|
|
79
|
+
<span v-if="uploading" class="text-sm text-zinc-500">Uploading…</span>
|
|
80
|
+
</div>
|
|
81
|
+
|
|
82
|
+
<p v-if="error" class="mb-4 rounded bg-red-50 px-3 py-2 text-sm text-red-700">{{ error }}</p>
|
|
83
|
+
|
|
84
|
+
<div
|
|
85
|
+
v-if="items.length === 0 && !uploading"
|
|
86
|
+
class="rounded border border-dashed border-zinc-300 bg-white p-8 text-center text-sm text-zinc-500"
|
|
87
|
+
>
|
|
88
|
+
No assets yet. Upload images to get started.
|
|
89
|
+
</div>
|
|
90
|
+
|
|
91
|
+
<div v-else class="grid grid-cols-2 gap-3 sm:grid-cols-3 lg:grid-cols-4">
|
|
92
|
+
<div v-for="m in items" :key="m.id" class="space-y-2 rounded border border-zinc-200 bg-white p-2">
|
|
93
|
+
<img :src="previewSrc(m)" :alt="m.alt ?? ''" class="aspect-square w-full rounded object-cover" />
|
|
94
|
+
<input
|
|
95
|
+
:value="m.alt ?? ''"
|
|
96
|
+
placeholder="Alt text"
|
|
97
|
+
class="vulse-input text-xs"
|
|
98
|
+
@change="setAlt(m.id, ($event.target as HTMLInputElement).value)"
|
|
99
|
+
/>
|
|
100
|
+
<button type="button" class="text-xs text-red-600 hover:underline" @click="softDelete(m.id)">
|
|
101
|
+
Delete
|
|
102
|
+
</button>
|
|
103
|
+
</div>
|
|
104
|
+
</div>
|
|
105
|
+
</div>
|
|
106
|
+
</template>
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import { ref, onMounted } from 'vue'
|
|
3
|
+
import { adminApi } from '../client/api.js'
|
|
4
|
+
|
|
5
|
+
const emit = defineEmits<{ (e: 'pick', id: string): void; (e: 'close'): void }>()
|
|
6
|
+
|
|
7
|
+
interface MediaItem {
|
|
8
|
+
id: string
|
|
9
|
+
alt: string | null
|
|
10
|
+
deliveryUrl: string | null
|
|
11
|
+
previewUrl: string
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
const items = ref<MediaItem[]>([])
|
|
15
|
+
|
|
16
|
+
function previewSrc(item: MediaItem): string {
|
|
17
|
+
return item.deliveryUrl ?? item.previewUrl
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
onMounted(async () => {
|
|
21
|
+
items.value = await adminApi.get<MediaItem[]>('/api/vulse/media')
|
|
22
|
+
})
|
|
23
|
+
</script>
|
|
24
|
+
|
|
25
|
+
<template>
|
|
26
|
+
<div class="fixed inset-0 z-50 grid place-items-center bg-black/40" @click.self="$emit('close')">
|
|
27
|
+
<div class="max-h-[80vh] w-[720px] overflow-auto rounded-xl bg-white p-4">
|
|
28
|
+
<div class="mb-3 flex items-center justify-between">
|
|
29
|
+
<h2 class="font-semibold">Pick a media item</h2>
|
|
30
|
+
<button type="button" class="text-zinc-500 hover:text-zinc-800" @click="$emit('close')">×</button>
|
|
31
|
+
</div>
|
|
32
|
+
<div v-if="items.length === 0" class="py-8 text-center text-sm text-zinc-500">
|
|
33
|
+
No media yet. Upload assets from the Media page first.
|
|
34
|
+
</div>
|
|
35
|
+
<div v-else class="grid grid-cols-4 gap-3">
|
|
36
|
+
<button
|
|
37
|
+
v-for="m in items"
|
|
38
|
+
:key="m.id"
|
|
39
|
+
type="button"
|
|
40
|
+
class="rounded border p-2 hover:ring-2 hover:ring-[var(--vulse-color-accent)]"
|
|
41
|
+
@click="$emit('pick', m.id)"
|
|
42
|
+
>
|
|
43
|
+
<img :src="previewSrc(m)" :alt="m.alt ?? ''" class="aspect-square w-full rounded object-cover" />
|
|
44
|
+
<div v-if="m.alt" class="mt-1 truncate text-xs">{{ m.alt }}</div>
|
|
45
|
+
</button>
|
|
46
|
+
</div>
|
|
47
|
+
</div>
|
|
48
|
+
</div>
|
|
49
|
+
</template>
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import { computed } from 'vue'
|
|
3
|
+
import { diffJson } from 'diff'
|
|
4
|
+
|
|
5
|
+
const props = defineProps<{ from: unknown; to: unknown }>()
|
|
6
|
+
const parts = computed(() => diffJson(props.from as object, props.to as object))
|
|
7
|
+
</script>
|
|
8
|
+
<template>
|
|
9
|
+
<pre class="bg-zinc-50 border rounded p-3 text-xs overflow-auto whitespace-pre-wrap"><span v-for="(p, i) in parts" :key="i"
|
|
10
|
+
:class="p.added ? 'bg-green-100 text-green-900' : p.removed ? 'bg-red-100 text-red-900' : ''">{{ p.value }}</span></pre>
|
|
11
|
+
</template>
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import { ref, onMounted, computed } from 'vue'
|
|
3
|
+
import { adminApi, AdminApiError } from '../client/api'
|
|
4
|
+
import { resolveActiveLocale } from '../client/active-locale.js'
|
|
5
|
+
import { useToast } from '../composables/toast.js'
|
|
6
|
+
import RevisionDiff from './RevisionDiff.vue'
|
|
7
|
+
|
|
8
|
+
interface RevisionRow {
|
|
9
|
+
id: string
|
|
10
|
+
version: number
|
|
11
|
+
authorId: string | null
|
|
12
|
+
createdAt: string
|
|
13
|
+
changeSummary: string | null
|
|
14
|
+
content: unknown
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
const props = defineProps<{
|
|
18
|
+
collection: string
|
|
19
|
+
entryId: string
|
|
20
|
+
/** Active locale from the server. Avoid prop name `locale` (Astro/HTML coercion). */
|
|
21
|
+
entryLocale?: string
|
|
22
|
+
supportedLocales?: string[]
|
|
23
|
+
defaultLocale?: string
|
|
24
|
+
}>()
|
|
25
|
+
|
|
26
|
+
const toast = useToast()
|
|
27
|
+
const revisions = ref<RevisionRow[]>([])
|
|
28
|
+
const selected = ref<number | null>(null)
|
|
29
|
+
|
|
30
|
+
const activeLocale = computed(() =>
|
|
31
|
+
resolveActiveLocale(props.supportedLocales, props.entryLocale, props.defaultLocale),
|
|
32
|
+
)
|
|
33
|
+
|
|
34
|
+
function versionOf(r: RevisionRow): number {
|
|
35
|
+
return Number(r.version)
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const localeQuery = () => `?locale=${encodeURIComponent(activeLocale.value)}`
|
|
39
|
+
|
|
40
|
+
const latestVersion = computed(() =>
|
|
41
|
+
revisions.value.reduce((max, r) => Math.max(max, versionOf(r)), 0),
|
|
42
|
+
)
|
|
43
|
+
|
|
44
|
+
const selectedRevision = computed(() =>
|
|
45
|
+
selected.value === null
|
|
46
|
+
? null
|
|
47
|
+
: revisions.value.find((r) => versionOf(r) === selected.value) ?? null,
|
|
48
|
+
)
|
|
49
|
+
|
|
50
|
+
const selectedContent = computed(() => selectedRevision.value?.content ?? null)
|
|
51
|
+
|
|
52
|
+
const isCurrentVersion = computed(() =>
|
|
53
|
+
selected.value !== null && selected.value === latestVersion.value,
|
|
54
|
+
)
|
|
55
|
+
|
|
56
|
+
const canRestore = computed(() =>
|
|
57
|
+
selected.value !== null && revisions.value.length > 1 && !isCurrentVersion.value,
|
|
58
|
+
)
|
|
59
|
+
|
|
60
|
+
const newerContent = computed(() => {
|
|
61
|
+
if (selected.value === null) return null
|
|
62
|
+
const newer = revisions.value
|
|
63
|
+
.filter((r) => versionOf(r) > selected.value!)
|
|
64
|
+
.sort((a, b) => versionOf(a) - versionOf(b))[0]
|
|
65
|
+
return newer?.content ?? null
|
|
66
|
+
})
|
|
67
|
+
|
|
68
|
+
async function load() {
|
|
69
|
+
revisions.value = await adminApi.get<RevisionRow[]>(
|
|
70
|
+
`/api/vulse/entries/${props.collection}/${props.entryId}/revisions${localeQuery()}`,
|
|
71
|
+
)
|
|
72
|
+
if (revisions.value.length && selected.value === null) {
|
|
73
|
+
selected.value = versionOf(revisions.value[0]!)
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function inspect(v: number) {
|
|
78
|
+
selected.value = Number(v)
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
async function restore(v: number) {
|
|
82
|
+
if (!confirm(`Restore version ${v}? A new revision will be written on top — no history is lost.`)) return
|
|
83
|
+
try {
|
|
84
|
+
await adminApi.post(
|
|
85
|
+
`/api/vulse/entries/${props.collection}/${props.entryId}/revisions/${v}/restore${localeQuery()}`,
|
|
86
|
+
{},
|
|
87
|
+
)
|
|
88
|
+
window.location.href = `/admin/collections/${props.collection}/${props.entryId}?locale=${encodeURIComponent(activeLocale.value)}`
|
|
89
|
+
} catch (err) {
|
|
90
|
+
toast.error(err instanceof AdminApiError ? err.message : 'Failed to restore revision')
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
onMounted(load)
|
|
95
|
+
</script>
|
|
96
|
+
|
|
97
|
+
<template>
|
|
98
|
+
<div class="grid grid-cols-[260px_1fr] gap-6">
|
|
99
|
+
<ul class="border rounded bg-white divide-y text-sm min-h-[120px]">
|
|
100
|
+
<li v-if="!revisions.length" class="p-3 text-zinc-500">No revisions yet.</li>
|
|
101
|
+
<li v-for="r in revisions" :key="r.id"
|
|
102
|
+
@click="inspect(r.version)"
|
|
103
|
+
:class="selected === versionOf(r) && 'bg-zinc-100'"
|
|
104
|
+
class="p-3 cursor-pointer hover:bg-zinc-50">
|
|
105
|
+
<div class="flex items-center justify-between gap-2">
|
|
106
|
+
<div class="font-medium">v{{ r.version }}</div>
|
|
107
|
+
<span v-if="versionOf(r) === latestVersion" class="text-xs text-zinc-500">Current</span>
|
|
108
|
+
</div>
|
|
109
|
+
<div class="text-xs text-zinc-500">{{ new Date(r.createdAt).toLocaleString() }}</div>
|
|
110
|
+
<div v-if="r.changeSummary" class="text-xs text-zinc-600 mt-1">{{ r.changeSummary }}</div>
|
|
111
|
+
</li>
|
|
112
|
+
</ul>
|
|
113
|
+
<div v-if="selected !== null" class="space-y-3">
|
|
114
|
+
<div class="flex items-center gap-3">
|
|
115
|
+
<h2 class="text-lg font-semibold">Version {{ selected }}</h2>
|
|
116
|
+
<button
|
|
117
|
+
v-if="canRestore"
|
|
118
|
+
type="button"
|
|
119
|
+
@click="restore(selected!)"
|
|
120
|
+
class="vulse-button-primary rounded px-3 py-1 text-sm font-medium">
|
|
121
|
+
Restore
|
|
122
|
+
</button>
|
|
123
|
+
</div>
|
|
124
|
+
<p v-if="isCurrentVersion && revisions.length > 1" class="text-sm text-zinc-600">
|
|
125
|
+
This is the current version. Select an older version to restore it.
|
|
126
|
+
</p>
|
|
127
|
+
<p v-else-if="revisions.length <= 1" class="text-sm text-zinc-600">
|
|
128
|
+
Only one version exists.
|
|
129
|
+
</p>
|
|
130
|
+
<RevisionDiff v-if="newerContent !== null && selectedContent !== null" :from="selectedContent" :to="newerContent" />
|
|
131
|
+
<pre v-else-if="selectedContent !== null" class="bg-zinc-900 text-zinc-100 rounded p-4 overflow-auto text-xs">{{ JSON.stringify(selectedContent, null, 2) }}</pre>
|
|
132
|
+
</div>
|
|
133
|
+
</div>
|
|
134
|
+
</template>
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import { computed, ref } from 'vue'
|
|
3
|
+
import type { FieldDescriptor } from '../client/form-from-zod.js'
|
|
4
|
+
import type { SeoContent, SeoFieldMapping } from '../../core/blueprints/seo.js'
|
|
5
|
+
import {
|
|
6
|
+
resolveEffectiveSeo,
|
|
7
|
+
resolvedSeoSummary,
|
|
8
|
+
} from '../../core/blueprints/seo.js'
|
|
9
|
+
import TextField from './fields/TextField.vue'
|
|
10
|
+
import TextareaField from './fields/TextareaField.vue'
|
|
11
|
+
import MediaField from './fields/MediaField.vue'
|
|
12
|
+
|
|
13
|
+
const props = defineProps<{
|
|
14
|
+
modelValue: SeoContent | undefined
|
|
15
|
+
content: Record<string, unknown>
|
|
16
|
+
fields: FieldDescriptor[]
|
|
17
|
+
titleField?: string
|
|
18
|
+
seoMapping?: SeoFieldMapping
|
|
19
|
+
fieldLabels?: Record<string, string>
|
|
20
|
+
}>()
|
|
21
|
+
|
|
22
|
+
const emit = defineEmits<{
|
|
23
|
+
(e: 'update:modelValue', v: SeoContent): void
|
|
24
|
+
}>()
|
|
25
|
+
|
|
26
|
+
const expanded = ref(false)
|
|
27
|
+
|
|
28
|
+
const seo = computed(() => props.modelValue ?? {})
|
|
29
|
+
|
|
30
|
+
const resolved = computed(() =>
|
|
31
|
+
resolveEffectiveSeo(
|
|
32
|
+
props.content,
|
|
33
|
+
seo.value,
|
|
34
|
+
props.fields,
|
|
35
|
+
props.titleField ?? 'title',
|
|
36
|
+
props.seoMapping,
|
|
37
|
+
),
|
|
38
|
+
)
|
|
39
|
+
|
|
40
|
+
const summary = computed(() => resolvedSeoSummary(resolved.value))
|
|
41
|
+
|
|
42
|
+
function fieldLabel(path: string | undefined): string {
|
|
43
|
+
if (!path) return 'field'
|
|
44
|
+
return props.fieldLabels?.[path] ?? path
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function sourceHint(key: 'metaTitle' | 'metaDescription' | 'ogImage'): string | null {
|
|
48
|
+
const field = resolved.value[key]
|
|
49
|
+
if (field.overridden || !field.sourceField) return null
|
|
50
|
+
return `Defaults to ${fieldLabel(field.sourceField)}`
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function update<K extends keyof SeoContent>(key: K, value: SeoContent[K]) {
|
|
54
|
+
emit('update:modelValue', { ...seo.value, [key]: value })
|
|
55
|
+
}
|
|
56
|
+
</script>
|
|
57
|
+
|
|
58
|
+
<template>
|
|
59
|
+
<details
|
|
60
|
+
class="rounded border border-zinc-200 bg-zinc-50 text-sm"
|
|
61
|
+
:open="expanded"
|
|
62
|
+
@toggle="expanded = ($event.target as HTMLDetailsElement).open"
|
|
63
|
+
>
|
|
64
|
+
<summary class="cursor-pointer select-none px-3 py-2.5 text-zinc-600">
|
|
65
|
+
<span class="font-medium">SEO</span>
|
|
66
|
+
<span class="ml-2 text-zinc-500">{{ summary }}</span>
|
|
67
|
+
</summary>
|
|
68
|
+
<div class="space-y-4 border-t border-zinc-200 px-3 py-3">
|
|
69
|
+
<p class="text-xs text-zinc-500">
|
|
70
|
+
Leave fields empty to use mapped content fields. Values you enter here override those defaults.
|
|
71
|
+
</p>
|
|
72
|
+
<div>
|
|
73
|
+
<TextField
|
|
74
|
+
label="Meta title"
|
|
75
|
+
:model-value="seo.metaTitle ?? ''"
|
|
76
|
+
@update:modelValue="update('metaTitle', $event || undefined)"
|
|
77
|
+
/>
|
|
78
|
+
<p v-if="sourceHint('metaTitle')" class="mt-1 text-xs text-zinc-400">
|
|
79
|
+
{{ sourceHint('metaTitle') }}
|
|
80
|
+
<span v-if="resolved.metaTitle.value && !resolved.metaTitle.overridden" class="text-zinc-500">
|
|
81
|
+
— currently “{{ resolved.metaTitle.value }}”
|
|
82
|
+
</span>
|
|
83
|
+
</p>
|
|
84
|
+
</div>
|
|
85
|
+
<div>
|
|
86
|
+
<TextareaField
|
|
87
|
+
label="Meta description"
|
|
88
|
+
:model-value="seo.metaDescription ?? ''"
|
|
89
|
+
@update:modelValue="update('metaDescription', $event || undefined)"
|
|
90
|
+
/>
|
|
91
|
+
<p v-if="sourceHint('metaDescription')" class="mt-1 text-xs text-zinc-400">
|
|
92
|
+
{{ sourceHint('metaDescription') }}
|
|
93
|
+
<span v-if="resolved.metaDescription.value && !resolved.metaDescription.overridden" class="text-zinc-500">
|
|
94
|
+
— currently “{{ resolved.metaDescription.value }}”
|
|
95
|
+
</span>
|
|
96
|
+
</p>
|
|
97
|
+
</div>
|
|
98
|
+
<div>
|
|
99
|
+
<MediaField
|
|
100
|
+
label="OG image"
|
|
101
|
+
:model-value="seo.ogImage ?? resolved.ogImage.value"
|
|
102
|
+
@update:modelValue="update('ogImage', $event ?? undefined)"
|
|
103
|
+
/>
|
|
104
|
+
<p v-if="sourceHint('ogImage')" class="mt-1 text-xs text-zinc-400">
|
|
105
|
+
{{ sourceHint('ogImage') }}
|
|
106
|
+
<span v-if="resolved.ogImage.value && !resolved.ogImage.overridden" class="text-zinc-500">
|
|
107
|
+
— using {{ fieldLabel(resolved.ogImage.sourceField) }}
|
|
108
|
+
</span>
|
|
109
|
+
</p>
|
|
110
|
+
</div>
|
|
111
|
+
</div>
|
|
112
|
+
</details>
|
|
113
|
+
</template>
|