@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,1783 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import { computed, onMounted, reactive, ref, watch } from 'vue'
|
|
3
|
+
import { adminApi } from '../client/api.js'
|
|
4
|
+
import type {
|
|
5
|
+
BlueprintDefinition,
|
|
6
|
+
FieldDefinition,
|
|
7
|
+
FieldUi,
|
|
8
|
+
NestedFieldDefinition,
|
|
9
|
+
NonReplicatorFieldUi,
|
|
10
|
+
ReplicatorSetDefinition,
|
|
11
|
+
} from '../../core/blueprints/definition.js'
|
|
12
|
+
import { useSets } from '../composables/useSets.js'
|
|
13
|
+
import { useToast } from '../composables/toast.js'
|
|
14
|
+
import BlocksSetsPicker from './fields/BlocksSetsPicker.vue'
|
|
15
|
+
import { normalizeFieldHandle } from '../../core/slug.js'
|
|
16
|
+
import {
|
|
17
|
+
defaultScaffoldRoutes,
|
|
18
|
+
generateCollectionScaffoldFiles,
|
|
19
|
+
generateContentConfig,
|
|
20
|
+
scaffoldCliCommand,
|
|
21
|
+
} from '../../scaffold/collection.js'
|
|
22
|
+
import { formatSelectOptionsText, parseSelectOptionsText } from '../../core/blueprints/select-helpers.js'
|
|
23
|
+
import { defaultPreviewPath } from '../../core/blueprints/preview-path.js'
|
|
24
|
+
import type { SeoFieldMapping } from '../../core/blueprints/seo.js'
|
|
25
|
+
|
|
26
|
+
const props = defineProps<{ handle: string | null; isAdmin?: boolean }>()
|
|
27
|
+
const { sets, hydrate: hydrateSets } = useSets()
|
|
28
|
+
const blueprintList = ref<BlueprintDefinition[]>([])
|
|
29
|
+
|
|
30
|
+
async function refreshBlueprints() {
|
|
31
|
+
blueprintList.value = await adminApi.get<BlueprintDefinition[]>('/api/vulse/blueprints')
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
interface EditorNestedField extends NestedFieldDefinition {
|
|
35
|
+
previousName: string | null;
|
|
36
|
+
nameTouched?: boolean;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
interface EditorReplicatorSet extends Omit<ReplicatorSetDefinition, 'fields'> {
|
|
40
|
+
fields: EditorNestedField[];
|
|
41
|
+
previousName: string | null;
|
|
42
|
+
nameTouched?: boolean;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
type EditorFieldUi =
|
|
46
|
+
| NonReplicatorFieldUi
|
|
47
|
+
| {
|
|
48
|
+
kind: 'replicator';
|
|
49
|
+
sets: EditorReplicatorSet[];
|
|
50
|
+
}
|
|
51
|
+
| {
|
|
52
|
+
kind: 'grid';
|
|
53
|
+
fields: EditorNestedField[];
|
|
54
|
+
minRows?: number;
|
|
55
|
+
maxRows?: number;
|
|
56
|
+
mode?: 'table' | 'stacked';
|
|
57
|
+
addLabel?: string;
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
interface EditorField extends Omit<FieldDefinition, 'ui'> {
|
|
61
|
+
ui: EditorFieldUi;
|
|
62
|
+
previousName: string | null; // null = newly added; otherwise tracks rename source
|
|
63
|
+
nameTouched?: boolean;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
type RemovalTarget =
|
|
67
|
+
| {
|
|
68
|
+
kind: 'field';
|
|
69
|
+
index: number;
|
|
70
|
+
name: string;
|
|
71
|
+
requiresVerification: boolean;
|
|
72
|
+
}
|
|
73
|
+
| {
|
|
74
|
+
kind: 'replicator-set';
|
|
75
|
+
fieldIndex: number;
|
|
76
|
+
setIndex: number;
|
|
77
|
+
name: string;
|
|
78
|
+
requiresVerification: boolean;
|
|
79
|
+
}
|
|
80
|
+
| {
|
|
81
|
+
kind: 'replicator-nested-field';
|
|
82
|
+
fieldIndex: number;
|
|
83
|
+
setIndex: number;
|
|
84
|
+
nestedIndex: number;
|
|
85
|
+
name: string;
|
|
86
|
+
requiresVerification: boolean;
|
|
87
|
+
}
|
|
88
|
+
| {
|
|
89
|
+
kind: 'blueprint';
|
|
90
|
+
name: string;
|
|
91
|
+
requiresVerification: true;
|
|
92
|
+
};
|
|
93
|
+
|
|
94
|
+
const handle = ref('');
|
|
95
|
+
const label = ref('');
|
|
96
|
+
const singleton = ref(false);
|
|
97
|
+
const tree = ref(false);
|
|
98
|
+
const drafts = ref(false);
|
|
99
|
+
const seo = ref(false);
|
|
100
|
+
const seoMetaTitleField = ref('');
|
|
101
|
+
const seoMetaDescriptionField = ref('');
|
|
102
|
+
const seoOgImageField = ref('');
|
|
103
|
+
const maxDepth = ref<number | null>(null);
|
|
104
|
+
const previewPath = ref('');
|
|
105
|
+
const previewRootSelector = ref('');
|
|
106
|
+
const previewLive = ref(true);
|
|
107
|
+
const previewPathTouched = ref(false);
|
|
108
|
+
const fields = reactive<EditorField[]>([]);
|
|
109
|
+
const expandedIndex = ref<number | null>(null);
|
|
110
|
+
const expandedReplicatorSets = reactive<Set<string>>(new Set());
|
|
111
|
+
const originalDrafts = ref(false);
|
|
112
|
+
|
|
113
|
+
const seoTitleFieldOptions = computed(() =>
|
|
114
|
+
fields.filter((f) => f.ui.kind === 'text' || f.ui.kind === 'textarea'),
|
|
115
|
+
)
|
|
116
|
+
const seoDescriptionFieldOptions = computed(() =>
|
|
117
|
+
fields.filter((f) => f.ui.kind === 'text' || f.ui.kind === 'textarea' || f.ui.kind === 'blocks'),
|
|
118
|
+
)
|
|
119
|
+
const seoImageFieldOptions = computed(() => fields.filter((f) => f.ui.kind === 'asset'))
|
|
120
|
+
|
|
121
|
+
function buildSeoMappingPayload(): SeoFieldMapping | undefined {
|
|
122
|
+
const mapping: SeoFieldMapping = {}
|
|
123
|
+
if (seoMetaTitleField.value) mapping.metaTitle = seoMetaTitleField.value
|
|
124
|
+
if (seoMetaDescriptionField.value) mapping.metaDescription = seoMetaDescriptionField.value
|
|
125
|
+
if (seoOgImageField.value) mapping.ogImage = seoOgImageField.value
|
|
126
|
+
return Object.keys(mapping).length ? mapping : undefined
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function setKey(fieldIndex: number, setIndex: number): string {
|
|
130
|
+
return `${fieldIndex}:${setIndex}`;
|
|
131
|
+
}
|
|
132
|
+
function isSetExpanded(fieldIndex: number, setIndex: number): boolean {
|
|
133
|
+
return expandedReplicatorSets.has(setKey(fieldIndex, setIndex));
|
|
134
|
+
}
|
|
135
|
+
function toggleSetExpanded(fieldIndex: number, setIndex: number) {
|
|
136
|
+
const key = setKey(fieldIndex, setIndex);
|
|
137
|
+
if (expandedReplicatorSets.has(key)) expandedReplicatorSets.delete(key);
|
|
138
|
+
else expandedReplicatorSets.add(key);
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
const errors = reactive<Record<string, string>>({});
|
|
142
|
+
const submitError = ref<string | null>(null);
|
|
143
|
+
const saving = ref(false);
|
|
144
|
+
const toast = useToast();
|
|
145
|
+
const hydrated = ref(false);
|
|
146
|
+
|
|
147
|
+
const handleLocked = ref(false);
|
|
148
|
+
const removalTarget = ref<RemovalTarget | null>(null);
|
|
149
|
+
const removalVerification = ref('');
|
|
150
|
+
|
|
151
|
+
function slugify(input: string): string {
|
|
152
|
+
return input
|
|
153
|
+
.toLowerCase()
|
|
154
|
+
.normalize('NFKD')
|
|
155
|
+
.replace(/[̀-ͯ]/g, '')
|
|
156
|
+
.replace(/[^a-z0-9_-]+/g, '-')
|
|
157
|
+
.replace(/^-+|-+$/g, '')
|
|
158
|
+
.replace(/^[^a-z]+/, '');
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
function syncHandleFromLabel(
|
|
162
|
+
target: { label?: string; name: string; previousName: string | null; nameTouched?: boolean },
|
|
163
|
+
) {
|
|
164
|
+
if (target.previousName !== null || target.nameTouched) return
|
|
165
|
+
target.name = normalizeFieldHandle(target.label ?? '')
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
function onFieldLabelInput(i: number, value: string) {
|
|
169
|
+
const field = fields[i]!
|
|
170
|
+
field.label = value
|
|
171
|
+
syncHandleFromLabel(field)
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
function onFieldHandleInput(i: number) {
|
|
175
|
+
fields[i]!.nameTouched = true
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
function onReplicatorSetLabelInput(fieldIndex: number, setIndex: number, value: string) {
|
|
179
|
+
const set = fields[fieldIndex]!.ui.kind === 'replicator'
|
|
180
|
+
? fields[fieldIndex]!.ui.sets[setIndex]!
|
|
181
|
+
: null
|
|
182
|
+
if (!set) return
|
|
183
|
+
set.label = value
|
|
184
|
+
syncHandleFromLabel(set)
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
function onReplicatorSetHandleInput(fieldIndex: number, setIndex: number) {
|
|
188
|
+
const set = fields[fieldIndex]!.ui.kind === 'replicator'
|
|
189
|
+
? fields[fieldIndex]!.ui.sets[setIndex]!
|
|
190
|
+
: null
|
|
191
|
+
if (set) set.nameTouched = true
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
function onNestedFieldLabelInput(fieldIndex: number, setIndex: number, nestedIndex: number, value: string) {
|
|
195
|
+
const nested = fields[fieldIndex]!.ui.kind === 'replicator'
|
|
196
|
+
? fields[fieldIndex]!.ui.sets[setIndex]!.fields[nestedIndex]!
|
|
197
|
+
: null
|
|
198
|
+
if (!nested) return
|
|
199
|
+
nested.label = value
|
|
200
|
+
syncHandleFromLabel(nested)
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
function onNestedFieldHandleInput(fieldIndex: number, setIndex: number, nestedIndex: number) {
|
|
204
|
+
const nested = fields[fieldIndex]!.ui.kind === 'replicator'
|
|
205
|
+
? fields[fieldIndex]!.ui.sets[setIndex]!.fields[nestedIndex]!
|
|
206
|
+
: null
|
|
207
|
+
if (nested) nested.nameTouched = true
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
function unlockHandle() {
|
|
211
|
+
handleLocked.value = true;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
function resetHandle() {
|
|
215
|
+
handleLocked.value = false;
|
|
216
|
+
handle.value = slugify(label.value);
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
const isCreate = computed(() => props.handle === null);
|
|
220
|
+
|
|
221
|
+
const scaffoldShowRoute = ref('')
|
|
222
|
+
const scaffoldIndexRoute = ref('')
|
|
223
|
+
const scaffoldOpen = ref(true)
|
|
224
|
+
const copyNotice = ref<string | null>(null)
|
|
225
|
+
|
|
226
|
+
function syncPreviewPathFromHandle() {
|
|
227
|
+
if (previewPathTouched.value) return
|
|
228
|
+
previewPath.value = defaultPreviewPath(handle.value || 'collection')
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
function onPreviewPathInput() {
|
|
232
|
+
previewPathTouched.value = true
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
function syncScaffoldRoutes() {
|
|
236
|
+
const defaults = defaultScaffoldRoutes(handle.value || 'collection')
|
|
237
|
+
scaffoldShowRoute.value = defaults.showRoute
|
|
238
|
+
scaffoldIndexRoute.value = defaults.indexRoute
|
|
239
|
+
syncPreviewPathFromHandle()
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
const scaffoldInput = computed(() => ({
|
|
243
|
+
handle: handle.value,
|
|
244
|
+
label: label.value || handle.value,
|
|
245
|
+
showRoute: scaffoldShowRoute.value,
|
|
246
|
+
indexRoute: scaffoldIndexRoute.value,
|
|
247
|
+
fields: fields
|
|
248
|
+
.filter((f) => f.name.trim())
|
|
249
|
+
.map((f) => ({ name: f.name, ui: { kind: f.ui.kind } })),
|
|
250
|
+
}))
|
|
251
|
+
|
|
252
|
+
const scaffoldCommand = computed(() => scaffoldCliCommand(scaffoldInput.value))
|
|
253
|
+
const scaffoldFiles = computed(() => generateCollectionScaffoldFiles(scaffoldInput.value, { includeContentConfig: false }))
|
|
254
|
+
const scaffoldContentConfigSnippet = computed(() => generateContentConfig(scaffoldInput.value))
|
|
255
|
+
|
|
256
|
+
async function copyText(text: string, label: string) {
|
|
257
|
+
try {
|
|
258
|
+
await navigator.clipboard.writeText(text)
|
|
259
|
+
copyNotice.value = `Copied ${label}`
|
|
260
|
+
setTimeout(() => { copyNotice.value = null }, 2000)
|
|
261
|
+
} catch {
|
|
262
|
+
copyNotice.value = 'Copy failed'
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
watch(label, (v) => {
|
|
267
|
+
if (isCreate.value && !handleLocked.value) {
|
|
268
|
+
handle.value = slugify(v);
|
|
269
|
+
}
|
|
270
|
+
});
|
|
271
|
+
|
|
272
|
+
watch(handle, () => {
|
|
273
|
+
if (isCreate.value) syncScaffoldRoutes()
|
|
274
|
+
});
|
|
275
|
+
|
|
276
|
+
async function load() {
|
|
277
|
+
for (const k of Object.keys(errors)) delete errors[k];
|
|
278
|
+
fields.splice(0, fields.length);
|
|
279
|
+
if (props.handle === null) {
|
|
280
|
+
handle.value = '';
|
|
281
|
+
label.value = '';
|
|
282
|
+
singleton.value = false;
|
|
283
|
+
tree.value = false;
|
|
284
|
+
drafts.value = false;
|
|
285
|
+
seo.value = false;
|
|
286
|
+
seoMetaTitleField.value = '';
|
|
287
|
+
seoMetaDescriptionField.value = '';
|
|
288
|
+
seoOgImageField.value = '';
|
|
289
|
+
maxDepth.value = null;
|
|
290
|
+
previewPath.value = defaultPreviewPath('');
|
|
291
|
+
previewRootSelector.value = '';
|
|
292
|
+
previewLive.value = true;
|
|
293
|
+
previewPathTouched.value = false;
|
|
294
|
+
handleLocked.value = false;
|
|
295
|
+
originalDrafts.value = false;
|
|
296
|
+
syncScaffoldRoutes();
|
|
297
|
+
return;
|
|
298
|
+
}
|
|
299
|
+
const bp = await adminApi.get<BlueprintDefinition>(`/api/vulse/blueprints/${props.handle}`)
|
|
300
|
+
handle.value = bp.handle;
|
|
301
|
+
label.value = bp.label;
|
|
302
|
+
singleton.value = bp.singleton;
|
|
303
|
+
tree.value = bp.tree ?? false;
|
|
304
|
+
drafts.value = bp.drafts ?? false;
|
|
305
|
+
seo.value = bp.seo ?? false;
|
|
306
|
+
seoMetaTitleField.value = bp.seoMapping?.metaTitle ?? '';
|
|
307
|
+
seoMetaDescriptionField.value = bp.seoMapping?.metaDescription ?? '';
|
|
308
|
+
seoOgImageField.value = bp.seoMapping?.ogImage ?? '';
|
|
309
|
+
maxDepth.value = bp.maxDepth ?? null;
|
|
310
|
+
if (bp.preview) {
|
|
311
|
+
previewPath.value = bp.preview.path
|
|
312
|
+
previewRootSelector.value = bp.preview.rootSelector ?? ''
|
|
313
|
+
previewLive.value = bp.preview.live !== false
|
|
314
|
+
} else {
|
|
315
|
+
previewPath.value = defaultPreviewPath(bp.handle)
|
|
316
|
+
previewRootSelector.value = ''
|
|
317
|
+
previewLive.value = true
|
|
318
|
+
}
|
|
319
|
+
previewPathTouched.value = true;
|
|
320
|
+
handleLocked.value = true;
|
|
321
|
+
originalDrafts.value = drafts.value;
|
|
322
|
+
for (const f of bp.fields) {
|
|
323
|
+
fields.push(toEditorField(f));
|
|
324
|
+
}
|
|
325
|
+
syncScaffoldRoutes()
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
onMounted(async () => {
|
|
329
|
+
const [, setsMap] = await Promise.all([load(), hydrateSets(), refreshBlueprints()])
|
|
330
|
+
sets.value = setsMap
|
|
331
|
+
hydrated.value = true
|
|
332
|
+
})
|
|
333
|
+
watch(() => props.handle, load);
|
|
334
|
+
|
|
335
|
+
function addField() {
|
|
336
|
+
fields.push({
|
|
337
|
+
name: '',
|
|
338
|
+
label: '',
|
|
339
|
+
ui: { kind: 'text' },
|
|
340
|
+
optional: false,
|
|
341
|
+
previousName: null,
|
|
342
|
+
});
|
|
343
|
+
expandedIndex.value = fields.length - 1;
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
function performRemoveField(i: number) {
|
|
347
|
+
fields.splice(i, 1);
|
|
348
|
+
if (expandedIndex.value === i) expandedIndex.value = null;
|
|
349
|
+
else if (expandedIndex.value !== null && expandedIndex.value > i) expandedIndex.value -= 1;
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
function moveUp(i: number) {
|
|
353
|
+
if (i === 0) return;
|
|
354
|
+
const [moved] = fields.splice(i, 1);
|
|
355
|
+
fields.splice(i - 1, 0, moved!);
|
|
356
|
+
if (expandedIndex.value === i) expandedIndex.value = i - 1;
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
function moveDown(i: number) {
|
|
360
|
+
if (i >= fields.length - 1) return;
|
|
361
|
+
const [moved] = fields.splice(i, 1);
|
|
362
|
+
fields.splice(i + 1, 0, moved!);
|
|
363
|
+
if (expandedIndex.value === i) expandedIndex.value = i + 1;
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
function setKind(i: number, kind: FieldUi['kind']) {
|
|
367
|
+
const f = fields[i]!;
|
|
368
|
+
if (kind === 'select') f.ui = { kind, options: [] };
|
|
369
|
+
else if (kind === 'relationship') f.ui = { kind, to: '' };
|
|
370
|
+
else if (kind === 'entry') f.ui = { kind, collections: [] };
|
|
371
|
+
else if (kind === 'entries') f.ui = { kind, collections: [] };
|
|
372
|
+
else if (kind === 'link') f.ui = { kind, collections: [] };
|
|
373
|
+
else if (kind === 'replicator') f.ui = { kind, sets: [] };
|
|
374
|
+
else if (kind === 'grid') f.ui = { kind, fields: [], mode: 'table' };
|
|
375
|
+
else f.ui = { kind };
|
|
376
|
+
if (kind === 'blocks' || kind === 'grid') expandedIndex.value = i;
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
function setNestedKind(
|
|
380
|
+
fieldIndex: number,
|
|
381
|
+
setIndex: number,
|
|
382
|
+
nestedIndex: number,
|
|
383
|
+
kind: NonReplicatorFieldUi['kind'],
|
|
384
|
+
) {
|
|
385
|
+
const nested =
|
|
386
|
+
fields[fieldIndex]!.ui.kind === 'replicator'
|
|
387
|
+
? fields[fieldIndex]!.ui.sets[setIndex]!.fields[nestedIndex]!
|
|
388
|
+
: null;
|
|
389
|
+
if (!nested) return;
|
|
390
|
+
if (kind === 'select') nested.ui = { kind, options: [] };
|
|
391
|
+
else if (kind === 'relationship') nested.ui = { kind, to: '' };
|
|
392
|
+
else if (kind === 'entry') nested.ui = { kind, collections: [] };
|
|
393
|
+
else if (kind === 'entries') nested.ui = { kind, collections: [] };
|
|
394
|
+
else if (kind === 'link') nested.ui = { kind, collections: [] };
|
|
395
|
+
else nested.ui = { kind };
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
function setGridNestedKind(fieldIndex: number, nestedIndex: number, kind: NonReplicatorFieldUi['kind']) {
|
|
399
|
+
const field = fields[fieldIndex];
|
|
400
|
+
if (!field || field.ui.kind !== 'grid') return;
|
|
401
|
+
const nested = field.ui.fields[nestedIndex];
|
|
402
|
+
if (!nested) return;
|
|
403
|
+
if (kind === 'select') nested.ui = { kind, options: [] };
|
|
404
|
+
else if (kind === 'relationship') nested.ui = { kind, to: '' };
|
|
405
|
+
else if (kind === 'entry') nested.ui = { kind, collections: [] };
|
|
406
|
+
else if (kind === 'entries') nested.ui = { kind, collections: [] };
|
|
407
|
+
else if (kind === 'link') nested.ui = { kind, collections: [] };
|
|
408
|
+
else nested.ui = { kind };
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
function updateSelectUi(
|
|
412
|
+
ui: Extract<NonReplicatorFieldUi, { kind: 'select' }>,
|
|
413
|
+
text: string,
|
|
414
|
+
): Extract<NonReplicatorFieldUi, { kind: 'select' }> {
|
|
415
|
+
return {
|
|
416
|
+
kind: 'select',
|
|
417
|
+
options: parseSelectOptionsText(text),
|
|
418
|
+
...(ui.multiple ? { multiple: true } : {}),
|
|
419
|
+
...(ui.placeholder ? { placeholder: ui.placeholder } : {}),
|
|
420
|
+
...(ui.clearable ? { clearable: true } : {}),
|
|
421
|
+
};
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
function toggleCollection(
|
|
425
|
+
ui: { collections?: string[] },
|
|
426
|
+
handle: string,
|
|
427
|
+
checked: boolean,
|
|
428
|
+
) {
|
|
429
|
+
const current = ui.collections ?? [];
|
|
430
|
+
ui.collections = checked
|
|
431
|
+
? [...current, handle]
|
|
432
|
+
: current.filter((c) => c !== handle);
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
function addGridColumn(fieldIndex: number) {
|
|
436
|
+
const field = fields[fieldIndex];
|
|
437
|
+
if (!field || field.ui.kind !== 'grid') return;
|
|
438
|
+
field.ui.fields.push({
|
|
439
|
+
name: '',
|
|
440
|
+
label: '',
|
|
441
|
+
ui: { kind: 'text' },
|
|
442
|
+
optional: false,
|
|
443
|
+
previousName: null,
|
|
444
|
+
});
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
function removeGridColumn(fieldIndex: number, nestedIndex: number) {
|
|
448
|
+
const field = fields[fieldIndex];
|
|
449
|
+
if (!field || field.ui.kind !== 'grid') return;
|
|
450
|
+
field.ui.fields.splice(nestedIndex, 1);
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
function updateBlocksSets(fieldIndex: number, handles: string[]) {
|
|
454
|
+
const field = fields[fieldIndex];
|
|
455
|
+
if (!field || field.ui.kind !== 'blocks') return;
|
|
456
|
+
field.ui = { kind: 'blocks', ...(handles.length ? { sets: handles } : {}) };
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
function updateNestedBlocksSets(
|
|
460
|
+
fieldIndex: number,
|
|
461
|
+
setIndex: number,
|
|
462
|
+
nestedIndex: number,
|
|
463
|
+
handles: string[],
|
|
464
|
+
) {
|
|
465
|
+
const nested =
|
|
466
|
+
fields[fieldIndex]?.ui.kind === 'replicator'
|
|
467
|
+
? fields[fieldIndex]!.ui.sets[setIndex]?.fields[nestedIndex]
|
|
468
|
+
: null;
|
|
469
|
+
if (!nested || nested.ui.kind !== 'blocks') return;
|
|
470
|
+
nested.ui = { kind: 'blocks', ...(handles.length ? { sets: handles } : {}) };
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
function blocksSetHandles(fieldIndex: number): string[] {
|
|
474
|
+
const field = fields[fieldIndex];
|
|
475
|
+
if (!field || field.ui.kind !== 'blocks') return [];
|
|
476
|
+
return field.ui.sets ?? [];
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
function addReplicatorSet(fieldIndex: number) {
|
|
480
|
+
const field = fields[fieldIndex];
|
|
481
|
+
if (!field || field.ui.kind !== 'replicator') return;
|
|
482
|
+
field.ui.sets.push({
|
|
483
|
+
name: '',
|
|
484
|
+
label: '',
|
|
485
|
+
previousName: null,
|
|
486
|
+
fields: [],
|
|
487
|
+
});
|
|
488
|
+
// Expand the newly added set so the user can fill it in right away.
|
|
489
|
+
expandedReplicatorSets.add(setKey(fieldIndex, field.ui.sets.length - 1));
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
function performRemoveReplicatorSet(fieldIndex: number, setIndex: number) {
|
|
493
|
+
const field = fields[fieldIndex];
|
|
494
|
+
if (!field || field.ui.kind !== 'replicator') return;
|
|
495
|
+
field.ui.sets.splice(setIndex, 1);
|
|
496
|
+
// Rebuild the expanded-set index since indices shift after splice.
|
|
497
|
+
const remaining = Array.from(expandedReplicatorSets)
|
|
498
|
+
.filter((key) => {
|
|
499
|
+
const [f, s] = key.split(':').map(Number);
|
|
500
|
+
return !(f === fieldIndex && s === setIndex);
|
|
501
|
+
})
|
|
502
|
+
.map((key) => {
|
|
503
|
+
const [f, s] = key.split(':').map(Number);
|
|
504
|
+
if (f === fieldIndex && s! > setIndex) return setKey(f!, s! - 1);
|
|
505
|
+
return key;
|
|
506
|
+
});
|
|
507
|
+
expandedReplicatorSets.clear();
|
|
508
|
+
for (const k of remaining) expandedReplicatorSets.add(k);
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
function addReplicatorSetField(fieldIndex: number, setIndex: number) {
|
|
512
|
+
const field = fields[fieldIndex];
|
|
513
|
+
if (!field || field.ui.kind !== 'replicator') return;
|
|
514
|
+
field.ui.sets[setIndex]!.fields.push({
|
|
515
|
+
name: '',
|
|
516
|
+
label: '',
|
|
517
|
+
ui: { kind: 'text' },
|
|
518
|
+
optional: false,
|
|
519
|
+
previousName: null,
|
|
520
|
+
});
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
function performRemoveReplicatorSetField(
|
|
524
|
+
fieldIndex: number,
|
|
525
|
+
setIndex: number,
|
|
526
|
+
nestedIndex: number,
|
|
527
|
+
) {
|
|
528
|
+
const field = fields[fieldIndex];
|
|
529
|
+
if (!field || field.ui.kind !== 'replicator') return;
|
|
530
|
+
field.ui.sets[setIndex]!.fields.splice(nestedIndex, 1);
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
function openFieldRemovalDialog(index: number) {
|
|
534
|
+
const field = fields[index];
|
|
535
|
+
if (!field) return;
|
|
536
|
+
removalTarget.value = {
|
|
537
|
+
kind: 'field',
|
|
538
|
+
index,
|
|
539
|
+
name: field.name || field.previousName || 'field',
|
|
540
|
+
requiresVerification: field.previousName !== null,
|
|
541
|
+
};
|
|
542
|
+
removalVerification.value = '';
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
function openReplicatorSetRemovalDialog(fieldIndex: number, setIndex: number) {
|
|
546
|
+
const field = fields[fieldIndex];
|
|
547
|
+
const set = field?.ui.kind === 'replicator' ? field.ui.sets[setIndex] : null;
|
|
548
|
+
if (!set) return;
|
|
549
|
+
removalTarget.value = {
|
|
550
|
+
kind: 'replicator-set',
|
|
551
|
+
fieldIndex,
|
|
552
|
+
setIndex,
|
|
553
|
+
name: set.name || set.previousName || 'set',
|
|
554
|
+
requiresVerification: set.previousName !== null,
|
|
555
|
+
};
|
|
556
|
+
removalVerification.value = '';
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
function openReplicatorNestedFieldRemovalDialog(
|
|
560
|
+
fieldIndex: number,
|
|
561
|
+
setIndex: number,
|
|
562
|
+
nestedIndex: number,
|
|
563
|
+
) {
|
|
564
|
+
const field = fields[fieldIndex];
|
|
565
|
+
const nested =
|
|
566
|
+
field?.ui.kind === 'replicator' ? field.ui.sets[setIndex]?.fields[nestedIndex] : null;
|
|
567
|
+
if (!nested) return;
|
|
568
|
+
removalTarget.value = {
|
|
569
|
+
kind: 'replicator-nested-field',
|
|
570
|
+
fieldIndex,
|
|
571
|
+
setIndex,
|
|
572
|
+
nestedIndex,
|
|
573
|
+
name: nested.name || nested.previousName || 'field',
|
|
574
|
+
requiresVerification: nested.previousName !== null,
|
|
575
|
+
};
|
|
576
|
+
removalVerification.value = '';
|
|
577
|
+
}
|
|
578
|
+
|
|
579
|
+
function openBlueprintRemovalDialog() {
|
|
580
|
+
if (!props.handle) return;
|
|
581
|
+
removalTarget.value = {
|
|
582
|
+
kind: 'blueprint',
|
|
583
|
+
name: props.handle,
|
|
584
|
+
requiresVerification: true,
|
|
585
|
+
};
|
|
586
|
+
removalVerification.value = '';
|
|
587
|
+
}
|
|
588
|
+
|
|
589
|
+
function closeRemovalDialog() {
|
|
590
|
+
removalTarget.value = null;
|
|
591
|
+
removalVerification.value = '';
|
|
592
|
+
}
|
|
593
|
+
|
|
594
|
+
const removalDialogTitle = computed(() => {
|
|
595
|
+
if (!removalTarget.value) return '';
|
|
596
|
+
switch (removalTarget.value.kind) {
|
|
597
|
+
case 'field':
|
|
598
|
+
return `Remove field '${removalTarget.value.name}'?`;
|
|
599
|
+
case 'replicator-set':
|
|
600
|
+
return `Remove set '${removalTarget.value.name}'?`;
|
|
601
|
+
case 'replicator-nested-field':
|
|
602
|
+
return `Remove nested field '${removalTarget.value.name}'?`;
|
|
603
|
+
case 'blueprint':
|
|
604
|
+
return `Delete blueprint '${removalTarget.value.name}'?`;
|
|
605
|
+
}
|
|
606
|
+
});
|
|
607
|
+
|
|
608
|
+
const removalDialogMessage = computed(() => {
|
|
609
|
+
if (!removalTarget.value) return '';
|
|
610
|
+
switch (removalTarget.value.kind) {
|
|
611
|
+
case 'field':
|
|
612
|
+
return 'Removing a schema field can orphan existing values and make them unavailable in the editor.';
|
|
613
|
+
case 'replicator-set':
|
|
614
|
+
return 'Removing a replicator set can strand existing content blocks that use this set and may prevent clean future edits.';
|
|
615
|
+
case 'replicator-nested-field':
|
|
616
|
+
return 'Removing a nested field can hide existing values inside replicator content and later saves may drop them.';
|
|
617
|
+
case 'blueprint':
|
|
618
|
+
return 'Deleting a blueprint removes the schema and permanently deletes every entry in this collection.';
|
|
619
|
+
}
|
|
620
|
+
});
|
|
621
|
+
|
|
622
|
+
const removalConfirmLabel = computed(() =>
|
|
623
|
+
removalTarget.value?.kind === 'blueprint' ? 'Delete' : 'Remove',
|
|
624
|
+
);
|
|
625
|
+
|
|
626
|
+
const removalConfirmDisabled = computed(() => {
|
|
627
|
+
if (!removalTarget.value) return true;
|
|
628
|
+
if (!removalTarget.value.requiresVerification) return false;
|
|
629
|
+
return removalVerification.value !== removalTarget.value.name;
|
|
630
|
+
});
|
|
631
|
+
|
|
632
|
+
async function confirmRemoval() {
|
|
633
|
+
const target = removalTarget.value;
|
|
634
|
+
if (!target || removalConfirmDisabled.value) return;
|
|
635
|
+
switch (target.kind) {
|
|
636
|
+
case 'field':
|
|
637
|
+
performRemoveField(target.index);
|
|
638
|
+
break;
|
|
639
|
+
case 'replicator-set':
|
|
640
|
+
performRemoveReplicatorSet(target.fieldIndex, target.setIndex);
|
|
641
|
+
break;
|
|
642
|
+
case 'replicator-nested-field':
|
|
643
|
+
performRemoveReplicatorSetField(target.fieldIndex, target.setIndex, target.nestedIndex);
|
|
644
|
+
break;
|
|
645
|
+
case 'blueprint':
|
|
646
|
+
await adminApi.delete(`/api/vulse/blueprints/${target.name}`)
|
|
647
|
+
window.location.href = '/admin'
|
|
648
|
+
break
|
|
649
|
+
}
|
|
650
|
+
closeRemovalDialog();
|
|
651
|
+
}
|
|
652
|
+
|
|
653
|
+
function toEditorField(field: FieldDefinition): EditorField {
|
|
654
|
+
const base = {
|
|
655
|
+
name: field.name,
|
|
656
|
+
...(field.label !== undefined ? { label: field.label } : {}),
|
|
657
|
+
optional: field.optional,
|
|
658
|
+
...(field.default !== undefined ? { default: field.default } : {}),
|
|
659
|
+
...(field.validation ? { validation: field.validation } : {}),
|
|
660
|
+
previousName: field.name,
|
|
661
|
+
nameTouched: true,
|
|
662
|
+
};
|
|
663
|
+
|
|
664
|
+
if (field.ui.kind === 'replicator') {
|
|
665
|
+
return {
|
|
666
|
+
...base,
|
|
667
|
+
ui: {
|
|
668
|
+
kind: 'replicator',
|
|
669
|
+
sets: field.ui.sets.map((set) => ({
|
|
670
|
+
name: set.name,
|
|
671
|
+
...(set.label !== undefined ? { label: set.label } : {}),
|
|
672
|
+
previousName: set.name,
|
|
673
|
+
nameTouched: true,
|
|
674
|
+
fields: set.fields.map((nested) => ({
|
|
675
|
+
name: nested.name,
|
|
676
|
+
...(nested.label !== undefined ? { label: nested.label } : {}),
|
|
677
|
+
ui: nested.ui,
|
|
678
|
+
optional: nested.optional,
|
|
679
|
+
...(nested.default !== undefined ? { default: nested.default } : {}),
|
|
680
|
+
...(nested.validation ? { validation: nested.validation } : {}),
|
|
681
|
+
previousName: nested.name,
|
|
682
|
+
nameTouched: true,
|
|
683
|
+
})),
|
|
684
|
+
})),
|
|
685
|
+
},
|
|
686
|
+
};
|
|
687
|
+
}
|
|
688
|
+
|
|
689
|
+
if (field.ui.kind === 'grid') {
|
|
690
|
+
return {
|
|
691
|
+
...base,
|
|
692
|
+
ui: {
|
|
693
|
+
kind: 'grid',
|
|
694
|
+
fields: field.ui.fields.map((nested) => ({
|
|
695
|
+
name: nested.name,
|
|
696
|
+
...(nested.label !== undefined ? { label: nested.label } : {}),
|
|
697
|
+
ui: nested.ui,
|
|
698
|
+
optional: nested.optional,
|
|
699
|
+
...(nested.default !== undefined ? { default: nested.default } : {}),
|
|
700
|
+
...(nested.validation ? { validation: nested.validation } : {}),
|
|
701
|
+
previousName: nested.name,
|
|
702
|
+
nameTouched: true,
|
|
703
|
+
})),
|
|
704
|
+
...(field.ui.minRows !== undefined ? { minRows: field.ui.minRows } : {}),
|
|
705
|
+
...(field.ui.maxRows !== undefined ? { maxRows: field.ui.maxRows } : {}),
|
|
706
|
+
...(field.ui.mode ? { mode: field.ui.mode } : {}),
|
|
707
|
+
...(field.ui.addLabel ? { addLabel: field.ui.addLabel } : {}),
|
|
708
|
+
},
|
|
709
|
+
};
|
|
710
|
+
}
|
|
711
|
+
|
|
712
|
+
return {
|
|
713
|
+
...base,
|
|
714
|
+
ui: field.ui,
|
|
715
|
+
};
|
|
716
|
+
}
|
|
717
|
+
|
|
718
|
+
function stripNestedEditorField(field: EditorNestedField): NestedFieldDefinition {
|
|
719
|
+
return {
|
|
720
|
+
name: field.name,
|
|
721
|
+
...(field.label !== undefined ? { label: field.label } : {}),
|
|
722
|
+
ui: field.ui,
|
|
723
|
+
optional: field.optional,
|
|
724
|
+
...(field.default !== undefined ? { default: field.default } : {}),
|
|
725
|
+
...(field.validation ? { validation: field.validation } : {}),
|
|
726
|
+
};
|
|
727
|
+
}
|
|
728
|
+
|
|
729
|
+
function stripEditorField(field: EditorField): Record<string, unknown> {
|
|
730
|
+
const out: Record<string, unknown> = {
|
|
731
|
+
name: field.name,
|
|
732
|
+
label: field.label,
|
|
733
|
+
optional: field.optional,
|
|
734
|
+
};
|
|
735
|
+
|
|
736
|
+
if (field.ui.kind === 'replicator') {
|
|
737
|
+
out.ui = {
|
|
738
|
+
kind: 'replicator',
|
|
739
|
+
sets: field.ui.sets.map((set) => ({
|
|
740
|
+
name: set.name,
|
|
741
|
+
label: set.label,
|
|
742
|
+
fields: set.fields.map(stripNestedEditorField),
|
|
743
|
+
})),
|
|
744
|
+
};
|
|
745
|
+
} else if (field.ui.kind === 'grid') {
|
|
746
|
+
out.ui = {
|
|
747
|
+
kind: 'grid',
|
|
748
|
+
fields: field.ui.fields.map(stripNestedEditorField),
|
|
749
|
+
...(field.ui.minRows !== undefined ? { minRows: field.ui.minRows } : {}),
|
|
750
|
+
...(field.ui.maxRows !== undefined ? { maxRows: field.ui.maxRows } : {}),
|
|
751
|
+
...(field.ui.mode ? { mode: field.ui.mode } : {}),
|
|
752
|
+
...(field.ui.addLabel ? { addLabel: field.ui.addLabel } : {}),
|
|
753
|
+
};
|
|
754
|
+
} else {
|
|
755
|
+
out.ui = field.ui;
|
|
756
|
+
}
|
|
757
|
+
|
|
758
|
+
if (field.default !== undefined) out.default = field.default;
|
|
759
|
+
if (field.validation) out.validation = field.validation;
|
|
760
|
+
if (field.previousName !== null && field.previousName !== field.name) {
|
|
761
|
+
out.previousName = field.previousName;
|
|
762
|
+
}
|
|
763
|
+
return out;
|
|
764
|
+
}
|
|
765
|
+
|
|
766
|
+
async function save() {
|
|
767
|
+
for (const k of Object.keys(errors)) delete errors[k];
|
|
768
|
+
submitError.value = null;
|
|
769
|
+
|
|
770
|
+
// Skip draft-disable warning for now (requires entry list with draft flags)
|
|
771
|
+
saving.value = true
|
|
772
|
+
try {
|
|
773
|
+
const seoMappingPayload = seo.value ? buildSeoMappingPayload() : undefined
|
|
774
|
+
const payload = {
|
|
775
|
+
handle: handle.value,
|
|
776
|
+
label: label.value,
|
|
777
|
+
singleton: singleton.value,
|
|
778
|
+
...(tree.value ? { tree: true } : {}),
|
|
779
|
+
...(tree.value && maxDepth.value !== null && maxDepth.value > 0 ? { maxDepth: maxDepth.value } : {}),
|
|
780
|
+
...(drafts.value ? { drafts: true } : {}),
|
|
781
|
+
...(seo.value ? { seo: true } : {}),
|
|
782
|
+
...(seoMappingPayload ? { seoMapping: seoMappingPayload } : {}),
|
|
783
|
+
...(props.isAdmin
|
|
784
|
+
? {
|
|
785
|
+
preview: {
|
|
786
|
+
path: previewPath.value.trim() || defaultPreviewPath(handle.value),
|
|
787
|
+
...(previewRootSelector.value.trim() ? { rootSelector: previewRootSelector.value.trim() } : {}),
|
|
788
|
+
...(previewLive.value === false ? { live: false } : {}),
|
|
789
|
+
},
|
|
790
|
+
}
|
|
791
|
+
: {}),
|
|
792
|
+
fields: fields.map(stripEditorField),
|
|
793
|
+
}
|
|
794
|
+
if (isCreate.value) {
|
|
795
|
+
await adminApi.post('/api/vulse/blueprints', payload)
|
|
796
|
+
await refreshBlueprints()
|
|
797
|
+
window.location.href = `/admin/schema/${handle.value}`
|
|
798
|
+
return
|
|
799
|
+
}
|
|
800
|
+
await adminApi.patch(`/api/vulse/blueprints/${props.handle!}`, payload)
|
|
801
|
+
await refreshBlueprints()
|
|
802
|
+
toast.success('Blueprint saved')
|
|
803
|
+
} catch (err) {
|
|
804
|
+
submitError.value = err instanceof Error ? err.message : 'Failed to save'
|
|
805
|
+
} finally {
|
|
806
|
+
saving.value = false
|
|
807
|
+
}
|
|
808
|
+
}
|
|
809
|
+
</script>
|
|
810
|
+
|
|
811
|
+
<template>
|
|
812
|
+
<div class="p-6" data-testid="blueprint-editor">
|
|
813
|
+
<h1 class="mb-4 text-xl font-semibold">{{ isCreate ? 'New collection' : `Edit ${handle}` }}</h1>
|
|
814
|
+
|
|
815
|
+
<form class="max-w-3xl space-y-6" @submit.prevent="save">
|
|
816
|
+
<div class="space-y-3 rounded border border-zinc-200 bg-white p-4">
|
|
817
|
+
<label class="block">
|
|
818
|
+
<span class="block text-sm font-medium text-zinc-700">Label</span>
|
|
819
|
+
<input
|
|
820
|
+
v-model="label"
|
|
821
|
+
class="mt-1 w-full rounded border border-zinc-300 px-3 py-2 text-sm"
|
|
822
|
+
data-testid="blueprint-label"
|
|
823
|
+
/>
|
|
824
|
+
<span v-if="errors['label']" class="mt-1 block text-xs text-red-600">{{ errors['label'] }}</span>
|
|
825
|
+
</label>
|
|
826
|
+
<div>
|
|
827
|
+
<div class="flex items-baseline justify-between">
|
|
828
|
+
<span class="block text-sm font-medium text-zinc-700">Handle</span>
|
|
829
|
+
<div v-if="isCreate" class="flex gap-3 text-xs">
|
|
830
|
+
<button
|
|
831
|
+
v-if="!handleLocked"
|
|
832
|
+
type="button"
|
|
833
|
+
class="text-zinc-500 hover:text-zinc-900"
|
|
834
|
+
data-testid="handle-edit"
|
|
835
|
+
@click="unlockHandle"
|
|
836
|
+
>
|
|
837
|
+
Edit
|
|
838
|
+
</button>
|
|
839
|
+
<button
|
|
840
|
+
v-else
|
|
841
|
+
type="button"
|
|
842
|
+
class="text-zinc-500 hover:text-zinc-900"
|
|
843
|
+
data-testid="handle-reset"
|
|
844
|
+
@click="resetHandle"
|
|
845
|
+
>
|
|
846
|
+
Reset
|
|
847
|
+
</button>
|
|
848
|
+
</div>
|
|
849
|
+
</div>
|
|
850
|
+
<input
|
|
851
|
+
v-model="handle"
|
|
852
|
+
:readonly="!isCreate || !handleLocked"
|
|
853
|
+
:disabled="!isCreate"
|
|
854
|
+
class="mt-1 w-full rounded border border-zinc-300 px-3 py-2 text-sm read-only:bg-zinc-50 disabled:bg-zinc-100"
|
|
855
|
+
data-testid="blueprint-handle"
|
|
856
|
+
/>
|
|
857
|
+
<p class="mt-1 text-xs text-zinc-500">
|
|
858
|
+
<template v-if="isCreate">
|
|
859
|
+
The collection's stable identifier — used in admin URLs (<code>/admin/collections/{{ handle || 'handle' }}</code>),
|
|
860
|
+
API paths, and your codebase imports. Lowercase letters, numbers, <code>-</code> and <code>_</code> only.
|
|
861
|
+
</template>
|
|
862
|
+
<template v-else>
|
|
863
|
+
Handle is locked because changing it would break admin URLs (<code>/admin/collections/{{ handle }}</code>),
|
|
864
|
+
public API paths, any frontend code that references this collection by name, and routing in generated
|
|
865
|
+
pages. To rename, create a new collection and migrate entries.
|
|
866
|
+
</template>
|
|
867
|
+
</p>
|
|
868
|
+
<span v-if="errors['handle']" class="mt-1 block text-xs text-red-600">{{ errors['handle'] }}</span>
|
|
869
|
+
</div>
|
|
870
|
+
<label class="flex items-center gap-2">
|
|
871
|
+
<input
|
|
872
|
+
v-model="singleton"
|
|
873
|
+
type="checkbox"
|
|
874
|
+
:disabled="tree"
|
|
875
|
+
class="rounded border-zinc-300"
|
|
876
|
+
data-testid="blueprint-singleton"
|
|
877
|
+
/>
|
|
878
|
+
<span class="text-sm font-medium text-zinc-700">Singleton (only one entry)</span>
|
|
879
|
+
</label>
|
|
880
|
+
<label class="flex items-center gap-2">
|
|
881
|
+
<input
|
|
882
|
+
v-model="tree"
|
|
883
|
+
type="checkbox"
|
|
884
|
+
:disabled="singleton"
|
|
885
|
+
class="rounded border-zinc-300"
|
|
886
|
+
data-testid="blueprint-tree"
|
|
887
|
+
/>
|
|
888
|
+
<span class="text-sm font-medium text-zinc-700">
|
|
889
|
+
Tree structure (entries can be nested under each other)
|
|
890
|
+
</span>
|
|
891
|
+
</label>
|
|
892
|
+
<label class="flex items-center gap-2">
|
|
893
|
+
<input
|
|
894
|
+
v-model="drafts"
|
|
895
|
+
type="checkbox"
|
|
896
|
+
class="rounded border-zinc-300"
|
|
897
|
+
data-testid="blueprint-drafts"
|
|
898
|
+
/>
|
|
899
|
+
<span class="text-sm font-medium text-zinc-700">
|
|
900
|
+
Enable drafts (Save changes without affecting the live site)
|
|
901
|
+
</span>
|
|
902
|
+
</label>
|
|
903
|
+
<label class="flex items-center gap-2">
|
|
904
|
+
<input
|
|
905
|
+
v-model="seo"
|
|
906
|
+
type="checkbox"
|
|
907
|
+
class="rounded border-zinc-300"
|
|
908
|
+
data-testid="blueprint-seo"
|
|
909
|
+
/>
|
|
910
|
+
<span class="text-sm font-medium text-zinc-700">
|
|
911
|
+
Enable SEO (meta title, description, and OG image per entry)
|
|
912
|
+
</span>
|
|
913
|
+
</label>
|
|
914
|
+
<div v-if="seo" class="space-y-3 rounded border border-zinc-200 bg-zinc-50 p-3">
|
|
915
|
+
<p class="text-xs text-zinc-600">
|
|
916
|
+
Map content fields to SEO defaults. Leave blank to use inferred defaults (title field, first image, etc.).
|
|
917
|
+
</p>
|
|
918
|
+
<label class="block">
|
|
919
|
+
<span class="block text-xs font-medium text-zinc-600">Meta title source</span>
|
|
920
|
+
<select v-model="seoMetaTitleField" class="mt-1 w-full rounded border border-zinc-300 px-2 py-1.5 text-sm">
|
|
921
|
+
<option value="">Default (title field)</option>
|
|
922
|
+
<option v-for="f in seoTitleFieldOptions" :key="f.name" :value="f.name">
|
|
923
|
+
{{ f.label || f.name }}
|
|
924
|
+
</option>
|
|
925
|
+
</select>
|
|
926
|
+
</label>
|
|
927
|
+
<label class="block">
|
|
928
|
+
<span class="block text-xs font-medium text-zinc-600">Meta description source</span>
|
|
929
|
+
<select v-model="seoMetaDescriptionField" class="mt-1 w-full rounded border border-zinc-300 px-2 py-1.5 text-sm">
|
|
930
|
+
<option value="">Inferred default</option>
|
|
931
|
+
<option v-for="f in seoDescriptionFieldOptions" :key="f.name" :value="f.name">
|
|
932
|
+
{{ f.label || f.name }}
|
|
933
|
+
</option>
|
|
934
|
+
</select>
|
|
935
|
+
</label>
|
|
936
|
+
<label class="block">
|
|
937
|
+
<span class="block text-xs font-medium text-zinc-600">OG image source</span>
|
|
938
|
+
<select v-model="seoOgImageField" class="mt-1 w-full rounded border border-zinc-300 px-2 py-1.5 text-sm">
|
|
939
|
+
<option value="">Inferred default</option>
|
|
940
|
+
<option v-for="f in seoImageFieldOptions" :key="f.name" :value="f.name">
|
|
941
|
+
{{ f.label || f.name }}
|
|
942
|
+
</option>
|
|
943
|
+
</select>
|
|
944
|
+
</label>
|
|
945
|
+
</div>
|
|
946
|
+
<label v-if="tree" class="block">
|
|
947
|
+
<span class="block text-xs font-medium text-zinc-600">
|
|
948
|
+
Max nesting depth <span class="text-zinc-400">(optional — leave blank for unlimited)</span>
|
|
949
|
+
</span>
|
|
950
|
+
<input
|
|
951
|
+
:value="maxDepth ?? ''"
|
|
952
|
+
type="number"
|
|
953
|
+
min="1"
|
|
954
|
+
placeholder="e.g. 4"
|
|
955
|
+
class="mt-1 w-32 rounded border border-zinc-300 px-3 py-1.5 text-sm"
|
|
956
|
+
data-testid="blueprint-max-depth"
|
|
957
|
+
@input="
|
|
958
|
+
maxDepth = ($event.target as HTMLInputElement).value === ''
|
|
959
|
+
? null
|
|
960
|
+
: Math.max(1, Number(($event.target as HTMLInputElement).value))
|
|
961
|
+
"
|
|
962
|
+
/>
|
|
963
|
+
</label>
|
|
964
|
+
</div>
|
|
965
|
+
|
|
966
|
+
<div
|
|
967
|
+
v-if="isAdmin"
|
|
968
|
+
class="space-y-3 rounded border border-zinc-200 bg-white p-4"
|
|
969
|
+
data-testid="blueprint-preview-settings"
|
|
970
|
+
>
|
|
971
|
+
<div>
|
|
972
|
+
<h2 class="text-base font-semibold text-zinc-700">Live preview</h2>
|
|
973
|
+
<p class="mt-1 text-xs text-zinc-500">
|
|
974
|
+
Controls where the entry editor opens live preview and the Preview button. This does not create or change
|
|
975
|
+
Astro routes — the path must already exist in your site. A mismatch shows a 404 in preview.
|
|
976
|
+
</p>
|
|
977
|
+
</div>
|
|
978
|
+
<label class="block">
|
|
979
|
+
<span class="block text-sm font-medium text-zinc-700">Preview path</span>
|
|
980
|
+
<input
|
|
981
|
+
v-model="previewPath"
|
|
982
|
+
class="mt-1 w-full rounded border border-zinc-300 px-3 py-2 font-mono text-sm"
|
|
983
|
+
placeholder="/post/{slug}"
|
|
984
|
+
data-testid="blueprint-preview-path"
|
|
985
|
+
@input="onPreviewPathInput"
|
|
986
|
+
/>
|
|
987
|
+
<span class="mt-1 block text-xs text-zinc-500">
|
|
988
|
+
Use <code>{slug}</code> for the entry URL slug, e.g. <code>/post/{slug}</code> or <code>/{slug}</code> for
|
|
989
|
+
root-level pages.
|
|
990
|
+
</span>
|
|
991
|
+
</label>
|
|
992
|
+
<label class="block">
|
|
993
|
+
<span class="block text-sm font-medium text-zinc-700">
|
|
994
|
+
Morph target selector <span class="font-normal text-zinc-400">(optional)</span>
|
|
995
|
+
</span>
|
|
996
|
+
<input
|
|
997
|
+
v-model="previewRootSelector"
|
|
998
|
+
class="mt-1 w-full rounded border border-zinc-300 px-3 py-2 font-mono text-sm"
|
|
999
|
+
placeholder="main"
|
|
1000
|
+
data-testid="blueprint-preview-root-selector"
|
|
1001
|
+
/>
|
|
1002
|
+
<span class="mt-1 block text-xs text-zinc-500">
|
|
1003
|
+
CSS selector for the element updated as you type. Defaults to <code>main</code>. Change only if your layout
|
|
1004
|
+
uses a different wrapper.
|
|
1005
|
+
</span>
|
|
1006
|
+
</label>
|
|
1007
|
+
<label class="flex items-center gap-2">
|
|
1008
|
+
<input
|
|
1009
|
+
v-model="previewLive"
|
|
1010
|
+
type="checkbox"
|
|
1011
|
+
class="rounded border-zinc-300"
|
|
1012
|
+
data-testid="blueprint-preview-live"
|
|
1013
|
+
/>
|
|
1014
|
+
<span class="text-sm font-medium text-zinc-700">Show live preview panel in the entry editor</span>
|
|
1015
|
+
</label>
|
|
1016
|
+
<p class="text-xs text-zinc-500">
|
|
1017
|
+
When disabled, editors still see the Preview button for saved drafts; only the split-panel live preview is
|
|
1018
|
+
hidden.
|
|
1019
|
+
</p>
|
|
1020
|
+
</div>
|
|
1021
|
+
|
|
1022
|
+
<div class="space-y-3">
|
|
1023
|
+
<div class="flex items-center justify-between">
|
|
1024
|
+
<h2 class="text-base font-semibold text-zinc-700">Fields</h2>
|
|
1025
|
+
<button
|
|
1026
|
+
type="button"
|
|
1027
|
+
class="rounded border border-zinc-300 bg-white px-3 py-1.5 text-sm font-medium text-zinc-700 hover:bg-zinc-50"
|
|
1028
|
+
data-testid="add-field"
|
|
1029
|
+
@click="addField"
|
|
1030
|
+
>
|
|
1031
|
+
+ Add field
|
|
1032
|
+
</button>
|
|
1033
|
+
</div>
|
|
1034
|
+
|
|
1035
|
+
<div
|
|
1036
|
+
v-if="fields.length === 0"
|
|
1037
|
+
class="rounded border border-dashed border-zinc-300 bg-zinc-50 px-4 py-6 text-sm text-zinc-600"
|
|
1038
|
+
data-testid="fields-empty-state"
|
|
1039
|
+
>
|
|
1040
|
+
<p class="font-medium text-zinc-700">No fields yet.</p>
|
|
1041
|
+
<p class="mt-1">
|
|
1042
|
+
Add at least one field to define what entries in this collection look like.
|
|
1043
|
+
</p>
|
|
1044
|
+
<button
|
|
1045
|
+
type="button"
|
|
1046
|
+
class="mt-3 rounded border border-zinc-300 bg-white px-3 py-1.5 text-sm font-medium text-zinc-700 hover:bg-zinc-50"
|
|
1047
|
+
data-testid="fields-empty-add"
|
|
1048
|
+
@click="addField"
|
|
1049
|
+
>
|
|
1050
|
+
+ Add field
|
|
1051
|
+
</button>
|
|
1052
|
+
</div>
|
|
1053
|
+
|
|
1054
|
+
<div
|
|
1055
|
+
v-for="(f, i) in fields"
|
|
1056
|
+
:key="i"
|
|
1057
|
+
class="rounded border border-zinc-200 bg-white"
|
|
1058
|
+
:data-testid="`field-card-${f.name || `new-${i}`}`"
|
|
1059
|
+
>
|
|
1060
|
+
<div class="flex items-center gap-2 px-3 py-2">
|
|
1061
|
+
<button type="button" class="px-2 text-zinc-400 hover:text-zinc-700" :data-testid="`field-up-${i}`" @click="moveUp(i)">↑</button>
|
|
1062
|
+
<button type="button" class="px-2 text-zinc-400 hover:text-zinc-700" :data-testid="`field-down-${i}`" @click="moveDown(i)">↓</button>
|
|
1063
|
+
<div class="flex-1">
|
|
1064
|
+
<button
|
|
1065
|
+
type="button"
|
|
1066
|
+
class="text-left"
|
|
1067
|
+
:data-testid="`field-expand-${i}`"
|
|
1068
|
+
@click="expandedIndex = expandedIndex === i ? null : i"
|
|
1069
|
+
>
|
|
1070
|
+
<span class="font-mono text-sm">{{ f.label || f.name || '(new field)' }}</span>
|
|
1071
|
+
<span class="ml-2 rounded bg-zinc-100 px-1.5 py-0.5 text-xs text-zinc-600">{{ f.ui.kind }}</span>
|
|
1072
|
+
<span
|
|
1073
|
+
v-if="f.ui.kind === 'blocks' && blocksSetHandles(i).length > 0"
|
|
1074
|
+
class="ml-1 rounded bg-sky-50 px-1.5 py-0.5 text-xs text-sky-700"
|
|
1075
|
+
>
|
|
1076
|
+
{{ blocksSetHandles(i).length }} set{{ blocksSetHandles(i).length === 1 ? '' : 's' }}
|
|
1077
|
+
</span>
|
|
1078
|
+
<span v-if="!f.optional" class="ml-1 rounded bg-rose-50 px-1.5 py-0.5 text-xs text-rose-700">required</span>
|
|
1079
|
+
</button>
|
|
1080
|
+
</div>
|
|
1081
|
+
<button
|
|
1082
|
+
type="button"
|
|
1083
|
+
class="rounded px-2 py-1 text-xs text-red-600 hover:bg-red-50"
|
|
1084
|
+
:data-testid="`field-remove-${i}`"
|
|
1085
|
+
@click="openFieldRemovalDialog(i)"
|
|
1086
|
+
>
|
|
1087
|
+
Remove
|
|
1088
|
+
</button>
|
|
1089
|
+
</div>
|
|
1090
|
+
|
|
1091
|
+
<div v-if="expandedIndex === i" class="space-y-3 border-t border-zinc-200 px-3 py-3">
|
|
1092
|
+
<label class="block">
|
|
1093
|
+
<span class="block text-xs font-medium text-zinc-600">Label</span>
|
|
1094
|
+
<input
|
|
1095
|
+
:value="f.label ?? ''"
|
|
1096
|
+
class="mt-1 w-full rounded border border-zinc-300 px-3 py-1.5 text-sm"
|
|
1097
|
+
:data-testid="`field-label-${i}`"
|
|
1098
|
+
@input="onFieldLabelInput(i, ($event.target as HTMLInputElement).value)"
|
|
1099
|
+
/>
|
|
1100
|
+
</label>
|
|
1101
|
+
<label class="block">
|
|
1102
|
+
<span class="block text-xs font-medium text-zinc-600">Handle</span>
|
|
1103
|
+
<span class="block text-xs text-zinc-500">The field's template variable.</span>
|
|
1104
|
+
<input
|
|
1105
|
+
v-model="f.name"
|
|
1106
|
+
class="mt-1 w-full rounded border border-zinc-300 px-3 py-1.5 font-mono text-sm read-only:bg-zinc-50"
|
|
1107
|
+
:class="f.previousName === null && !f.nameTouched ? 'text-zinc-500' : 'text-zinc-800'"
|
|
1108
|
+
:readonly="f.previousName !== null"
|
|
1109
|
+
:data-testid="`field-name-${i}`"
|
|
1110
|
+
@input="onFieldHandleInput(i)"
|
|
1111
|
+
/>
|
|
1112
|
+
<button
|
|
1113
|
+
v-if="f.previousName === null && f.nameTouched && f.label"
|
|
1114
|
+
type="button"
|
|
1115
|
+
class="mt-1 text-xs text-zinc-600 underline hover:text-zinc-900"
|
|
1116
|
+
@click="f.nameTouched = false; syncHandleFromLabel(f)"
|
|
1117
|
+
>
|
|
1118
|
+
Reset from label
|
|
1119
|
+
</button>
|
|
1120
|
+
</label>
|
|
1121
|
+
<label class="block">
|
|
1122
|
+
<span class="block text-xs font-medium text-zinc-600">Kind</span>
|
|
1123
|
+
<select
|
|
1124
|
+
class="mt-1 w-full rounded border border-zinc-300 px-3 py-1.5 text-sm"
|
|
1125
|
+
:value="f.ui.kind"
|
|
1126
|
+
:data-testid="`field-kind-${i}`"
|
|
1127
|
+
@change="setKind(i, ($event.target as HTMLSelectElement).value as FieldUi['kind'])"
|
|
1128
|
+
>
|
|
1129
|
+
<option value="text">text</option>
|
|
1130
|
+
<option value="textarea">textarea</option>
|
|
1131
|
+
<option value="blocks">blocks</option>
|
|
1132
|
+
<option value="date">date</option>
|
|
1133
|
+
<option value="boolean">boolean</option>
|
|
1134
|
+
<option value="select">select</option>
|
|
1135
|
+
<option value="replicator">replicator</option>
|
|
1136
|
+
<option value="grid">grid</option>
|
|
1137
|
+
<option value="relationship">relationship</option>
|
|
1138
|
+
<option value="entry">entry</option>
|
|
1139
|
+
<option value="entries">entries</option>
|
|
1140
|
+
<option value="link">link</option>
|
|
1141
|
+
<option value="asset">asset</option>
|
|
1142
|
+
</select>
|
|
1143
|
+
</label>
|
|
1144
|
+
<label class="flex items-center gap-2">
|
|
1145
|
+
<input v-model="f.optional" type="checkbox" class="rounded border-zinc-300" :data-testid="`field-optional-${i}`" />
|
|
1146
|
+
<span class="text-xs font-medium text-zinc-600">Optional</span>
|
|
1147
|
+
</label>
|
|
1148
|
+
|
|
1149
|
+
<!-- text/textarea: min/max -->
|
|
1150
|
+
<div v-if="f.ui.kind === 'text' || f.ui.kind === 'textarea'" class="flex gap-3">
|
|
1151
|
+
<label class="block flex-1">
|
|
1152
|
+
<span class="block text-xs font-medium text-zinc-600">Min length</span>
|
|
1153
|
+
<input
|
|
1154
|
+
type="number"
|
|
1155
|
+
:value="f.validation?.min ?? ''"
|
|
1156
|
+
class="mt-1 w-full rounded border border-zinc-300 px-3 py-1.5 text-sm"
|
|
1157
|
+
@input="
|
|
1158
|
+
(function() {
|
|
1159
|
+
const v = ($event.target as HTMLInputElement).value;
|
|
1160
|
+
const next: { min?: number; max?: number } = {};
|
|
1161
|
+
if (v !== '') next.min = Number(v);
|
|
1162
|
+
if (f.validation?.max !== undefined) next.max = f.validation.max;
|
|
1163
|
+
f.validation = next;
|
|
1164
|
+
})()
|
|
1165
|
+
"
|
|
1166
|
+
/>
|
|
1167
|
+
</label>
|
|
1168
|
+
<label class="block flex-1">
|
|
1169
|
+
<span class="block text-xs font-medium text-zinc-600">Max length</span>
|
|
1170
|
+
<input
|
|
1171
|
+
type="number"
|
|
1172
|
+
:value="f.validation?.max ?? ''"
|
|
1173
|
+
class="mt-1 w-full rounded border border-zinc-300 px-3 py-1.5 text-sm"
|
|
1174
|
+
@input="
|
|
1175
|
+
(function() {
|
|
1176
|
+
const v = ($event.target as HTMLInputElement).value;
|
|
1177
|
+
const next: { min?: number; max?: number } = {};
|
|
1178
|
+
if (f.validation?.min !== undefined) next.min = f.validation.min;
|
|
1179
|
+
if (v !== '') next.max = Number(v);
|
|
1180
|
+
f.validation = next;
|
|
1181
|
+
})()
|
|
1182
|
+
"
|
|
1183
|
+
/>
|
|
1184
|
+
</label>
|
|
1185
|
+
</div>
|
|
1186
|
+
|
|
1187
|
+
<!-- select: options editor -->
|
|
1188
|
+
<div v-if="f.ui.kind === 'select'" class="space-y-2">
|
|
1189
|
+
<div>
|
|
1190
|
+
<span class="block text-xs font-medium text-zinc-600">Options</span>
|
|
1191
|
+
<textarea
|
|
1192
|
+
rows="3"
|
|
1193
|
+
class="mt-1 w-full rounded border border-zinc-300 px-3 py-1.5 font-mono text-xs"
|
|
1194
|
+
:value="formatSelectOptionsText(f.ui.options ?? [])"
|
|
1195
|
+
:data-testid="`field-options-${i}`"
|
|
1196
|
+
@input="f.ui = updateSelectUi(f.ui, ($event.target as HTMLTextAreaElement).value)"
|
|
1197
|
+
/>
|
|
1198
|
+
<span class="text-xs text-zinc-500">One option per line. Use <code>key: Label</code> for separate keys and labels.</span>
|
|
1199
|
+
</div>
|
|
1200
|
+
<label class="flex items-center gap-2">
|
|
1201
|
+
<input v-model="f.ui.multiple" type="checkbox" class="rounded border-zinc-300" />
|
|
1202
|
+
<span class="text-xs font-medium text-zinc-600">Allow multiple</span>
|
|
1203
|
+
</label>
|
|
1204
|
+
<label class="block">
|
|
1205
|
+
<span class="block text-xs font-medium text-zinc-600">Placeholder</span>
|
|
1206
|
+
<input
|
|
1207
|
+
v-model="f.ui.placeholder"
|
|
1208
|
+
type="text"
|
|
1209
|
+
class="mt-1 w-full rounded border border-zinc-300 px-3 py-1.5 text-sm"
|
|
1210
|
+
/>
|
|
1211
|
+
</label>
|
|
1212
|
+
<label class="flex items-center gap-2">
|
|
1213
|
+
<input v-model="f.ui.clearable" type="checkbox" class="rounded border-zinc-300" />
|
|
1214
|
+
<span class="text-xs font-medium text-zinc-600">Clearable</span>
|
|
1215
|
+
</label>
|
|
1216
|
+
</div>
|
|
1217
|
+
|
|
1218
|
+
<!-- relationship: target picker -->
|
|
1219
|
+
<label v-if="f.ui.kind === 'relationship'" class="block">
|
|
1220
|
+
<span class="block text-xs font-medium text-zinc-600">Target collection</span>
|
|
1221
|
+
<select
|
|
1222
|
+
class="mt-1 w-full rounded border border-zinc-300 px-3 py-1.5 text-sm"
|
|
1223
|
+
:value="f.ui.to ?? ''"
|
|
1224
|
+
:data-testid="`field-to-${i}`"
|
|
1225
|
+
@change="f.ui = { kind: 'relationship', to: ($event.target as HTMLSelectElement).value }"
|
|
1226
|
+
>
|
|
1227
|
+
<option value="" disabled>Choose a collection</option>
|
|
1228
|
+
<option v-for="bp in blueprintList" :key="bp.handle" :value="bp.handle">{{ bp.handle }}</option>
|
|
1229
|
+
</select>
|
|
1230
|
+
</label>
|
|
1231
|
+
|
|
1232
|
+
<!-- entry / entries / link: collection picker -->
|
|
1233
|
+
<div v-if="f.ui.kind === 'entry' || f.ui.kind === 'entries' || f.ui.kind === 'link'" class="space-y-2">
|
|
1234
|
+
<span class="block text-xs font-medium text-zinc-600">
|
|
1235
|
+
{{ f.ui.kind === 'link' ? 'Entry collections (optional)' : 'Collections' }}
|
|
1236
|
+
</span>
|
|
1237
|
+
<div class="flex flex-wrap gap-3">
|
|
1238
|
+
<label
|
|
1239
|
+
v-for="bp in blueprintList"
|
|
1240
|
+
:key="bp.handle"
|
|
1241
|
+
class="flex items-center gap-1 text-sm"
|
|
1242
|
+
>
|
|
1243
|
+
<input
|
|
1244
|
+
type="checkbox"
|
|
1245
|
+
:checked="(f.ui.collections ?? []).includes(bp.handle)"
|
|
1246
|
+
@change="toggleCollection(f.ui, bp.handle, ($event.target as HTMLInputElement).checked)"
|
|
1247
|
+
/>
|
|
1248
|
+
{{ bp.handle }}
|
|
1249
|
+
</label>
|
|
1250
|
+
</div>
|
|
1251
|
+
</div>
|
|
1252
|
+
|
|
1253
|
+
<label v-if="f.ui.kind === 'entries'" class="block">
|
|
1254
|
+
<span class="block text-xs font-medium text-zinc-600">Max entries</span>
|
|
1255
|
+
<input
|
|
1256
|
+
v-model.number="f.ui.max"
|
|
1257
|
+
type="number"
|
|
1258
|
+
min="1"
|
|
1259
|
+
class="mt-1 w-full rounded border border-zinc-300 px-3 py-1.5 text-sm"
|
|
1260
|
+
/>
|
|
1261
|
+
</label>
|
|
1262
|
+
|
|
1263
|
+
<!-- blocks: attach global sets from Settings → Sets -->
|
|
1264
|
+
<BlocksSetsPicker
|
|
1265
|
+
v-if="f.ui.kind === 'blocks'"
|
|
1266
|
+
:model-value="blocksSetHandles(i)"
|
|
1267
|
+
:data-testid="`blocks-sets-picker-${i}`"
|
|
1268
|
+
@update:model-value="updateBlocksSets(i, $event)"
|
|
1269
|
+
/>
|
|
1270
|
+
|
|
1271
|
+
<div v-if="f.ui.kind === 'replicator'" class="space-y-3">
|
|
1272
|
+
<div class="rounded border border-amber-200 bg-amber-50 px-3 py-2 text-xs text-amber-800">
|
|
1273
|
+
Set names and nested field names become locked after the blueprint is saved.
|
|
1274
|
+
</div>
|
|
1275
|
+
|
|
1276
|
+
<div class="flex items-center justify-between">
|
|
1277
|
+
<span class="text-xs font-medium text-zinc-600">Sets</span>
|
|
1278
|
+
<button
|
|
1279
|
+
type="button"
|
|
1280
|
+
class="rounded border border-zinc-300 bg-white px-2.5 py-1 text-xs font-medium text-zinc-700 hover:bg-zinc-50"
|
|
1281
|
+
:data-testid="`replicator-add-set-${i}`"
|
|
1282
|
+
@click="addReplicatorSet(i)"
|
|
1283
|
+
>
|
|
1284
|
+
+ Add set
|
|
1285
|
+
</button>
|
|
1286
|
+
</div>
|
|
1287
|
+
|
|
1288
|
+
<div
|
|
1289
|
+
v-if="f.ui.sets.length === 0"
|
|
1290
|
+
class="rounded border border-dashed border-zinc-300 bg-zinc-50 px-3 py-4 text-xs text-zinc-500"
|
|
1291
|
+
>
|
|
1292
|
+
Add at least one set to define repeatable content blocks.
|
|
1293
|
+
</div>
|
|
1294
|
+
|
|
1295
|
+
<div
|
|
1296
|
+
v-for="(set, setIndex) in f.ui.sets"
|
|
1297
|
+
:key="setIndex"
|
|
1298
|
+
class="rounded border border-zinc-200 bg-zinc-50"
|
|
1299
|
+
>
|
|
1300
|
+
<div class="flex items-center justify-between gap-2 px-3 py-2">
|
|
1301
|
+
<button
|
|
1302
|
+
type="button"
|
|
1303
|
+
class="flex flex-1 items-center gap-2 rounded px-1 py-1 text-left hover:bg-zinc-100"
|
|
1304
|
+
:data-testid="`replicator-set-toggle-${i}-${setIndex}`"
|
|
1305
|
+
:aria-expanded="isSetExpanded(i, setIndex)"
|
|
1306
|
+
@click="toggleSetExpanded(i, setIndex)"
|
|
1307
|
+
>
|
|
1308
|
+
<svg
|
|
1309
|
+
class="h-4 w-4 shrink-0 text-zinc-500 transition-transform"
|
|
1310
|
+
:class="{ 'rotate-180': isSetExpanded(i, setIndex) }"
|
|
1311
|
+
viewBox="0 0 20 20"
|
|
1312
|
+
fill="currentColor"
|
|
1313
|
+
aria-hidden="true"
|
|
1314
|
+
>
|
|
1315
|
+
<path fill-rule="evenodd" d="M5.23 7.21a.75.75 0 0 1 1.06.02L10 11.06l3.71-3.83a.75.75 0 1 1 1.08 1.04l-4.25 4.39a.75.75 0 0 1-1.08 0L5.21 8.27a.75.75 0 0 1 .02-1.06Z" clip-rule="evenodd" />
|
|
1316
|
+
</svg>
|
|
1317
|
+
<span class="text-xs font-semibold uppercase tracking-wide text-zinc-500">
|
|
1318
|
+
Set {{ setIndex + 1 }}
|
|
1319
|
+
</span>
|
|
1320
|
+
<span v-if="set.name || set.label" class="text-sm font-medium text-zinc-800">
|
|
1321
|
+
{{ set.label || set.name }}
|
|
1322
|
+
</span>
|
|
1323
|
+
<span class="ml-1 rounded bg-zinc-200 px-1.5 py-0.5 text-[10px] font-medium text-zinc-700">
|
|
1324
|
+
{{ set.fields.length }} field{{ set.fields.length === 1 ? '' : 's' }}
|
|
1325
|
+
</span>
|
|
1326
|
+
<span v-if="!isSetExpanded(i, setIndex)" class="ml-auto text-xs font-medium text-zinc-600">
|
|
1327
|
+
Show fields
|
|
1328
|
+
</span>
|
|
1329
|
+
</button>
|
|
1330
|
+
<button
|
|
1331
|
+
type="button"
|
|
1332
|
+
class="rounded px-2 py-1 text-xs text-red-600 hover:bg-red-50"
|
|
1333
|
+
@click="openReplicatorSetRemovalDialog(i, setIndex)"
|
|
1334
|
+
>
|
|
1335
|
+
Remove set
|
|
1336
|
+
</button>
|
|
1337
|
+
</div>
|
|
1338
|
+
|
|
1339
|
+
<div v-if="isSetExpanded(i, setIndex)" class="space-y-3 border-t border-zinc-200 p-3">
|
|
1340
|
+
<div class="grid gap-3 md:grid-cols-2">
|
|
1341
|
+
<label class="block">
|
|
1342
|
+
<span class="block text-xs font-medium text-zinc-600">Set label</span>
|
|
1343
|
+
<input
|
|
1344
|
+
:value="set.label ?? ''"
|
|
1345
|
+
class="mt-1 w-full rounded border border-zinc-300 px-3 py-1.5 text-sm"
|
|
1346
|
+
@input="onReplicatorSetLabelInput(i, setIndex, ($event.target as HTMLInputElement).value)"
|
|
1347
|
+
/>
|
|
1348
|
+
</label>
|
|
1349
|
+
<label class="block">
|
|
1350
|
+
<span class="block text-xs font-medium text-zinc-600">Set handle</span>
|
|
1351
|
+
<span class="block text-xs text-zinc-500">The set's template variable.</span>
|
|
1352
|
+
<input
|
|
1353
|
+
v-model="set.name"
|
|
1354
|
+
class="mt-1 w-full rounded border border-zinc-300 px-3 py-1.5 font-mono text-sm read-only:bg-zinc-100"
|
|
1355
|
+
:readonly="set.previousName !== null"
|
|
1356
|
+
@input="onReplicatorSetHandleInput(i, setIndex)"
|
|
1357
|
+
/>
|
|
1358
|
+
</label>
|
|
1359
|
+
</div>
|
|
1360
|
+
|
|
1361
|
+
<div class="space-y-2">
|
|
1362
|
+
<div class="flex items-center justify-between">
|
|
1363
|
+
<span class="text-xs font-medium text-zinc-600">Set fields</span>
|
|
1364
|
+
<button
|
|
1365
|
+
type="button"
|
|
1366
|
+
class="rounded border border-zinc-300 bg-white px-2.5 py-1 text-xs font-medium text-zinc-700 hover:bg-zinc-50"
|
|
1367
|
+
@click="addReplicatorSetField(i, setIndex)"
|
|
1368
|
+
>
|
|
1369
|
+
+ Add set field
|
|
1370
|
+
</button>
|
|
1371
|
+
</div>
|
|
1372
|
+
|
|
1373
|
+
<div
|
|
1374
|
+
v-if="set.fields.length === 0"
|
|
1375
|
+
class="rounded border border-dashed border-zinc-300 bg-white px-3 py-4 text-xs text-zinc-500"
|
|
1376
|
+
>
|
|
1377
|
+
Each set needs at least one field.
|
|
1378
|
+
</div>
|
|
1379
|
+
|
|
1380
|
+
<div
|
|
1381
|
+
v-for="(nested, nestedIndex) in set.fields"
|
|
1382
|
+
:key="nestedIndex"
|
|
1383
|
+
class="space-y-3 rounded border border-zinc-200 bg-white p-3"
|
|
1384
|
+
>
|
|
1385
|
+
<div class="grid gap-3 md:grid-cols-2">
|
|
1386
|
+
<label class="block">
|
|
1387
|
+
<span class="block text-xs font-medium text-zinc-600">Field label</span>
|
|
1388
|
+
<input
|
|
1389
|
+
:value="nested.label ?? ''"
|
|
1390
|
+
class="mt-1 w-full rounded border border-zinc-300 px-3 py-1.5 text-sm"
|
|
1391
|
+
@input="onNestedFieldLabelInput(i, setIndex, nestedIndex, ($event.target as HTMLInputElement).value)"
|
|
1392
|
+
/>
|
|
1393
|
+
</label>
|
|
1394
|
+
<label class="block">
|
|
1395
|
+
<span class="block text-xs font-medium text-zinc-600">Field handle</span>
|
|
1396
|
+
<span class="block text-xs text-zinc-500">The field's template variable.</span>
|
|
1397
|
+
<input
|
|
1398
|
+
v-model="nested.name"
|
|
1399
|
+
class="mt-1 w-full rounded border border-zinc-300 px-3 py-1.5 font-mono text-sm read-only:bg-zinc-100"
|
|
1400
|
+
:readonly="nested.previousName !== null"
|
|
1401
|
+
@input="onNestedFieldHandleInput(i, setIndex, nestedIndex)"
|
|
1402
|
+
/>
|
|
1403
|
+
</label>
|
|
1404
|
+
</div>
|
|
1405
|
+
|
|
1406
|
+
<div class="grid gap-3 md:grid-cols-2">
|
|
1407
|
+
<label class="block">
|
|
1408
|
+
<span class="block text-xs font-medium text-zinc-600">Kind</span>
|
|
1409
|
+
<select
|
|
1410
|
+
class="mt-1 w-full rounded border border-zinc-300 px-3 py-1.5 text-sm"
|
|
1411
|
+
:value="nested.ui.kind"
|
|
1412
|
+
@change="
|
|
1413
|
+
setNestedKind(
|
|
1414
|
+
i,
|
|
1415
|
+
setIndex,
|
|
1416
|
+
nestedIndex,
|
|
1417
|
+
($event.target as HTMLSelectElement).value as NonReplicatorFieldUi['kind'],
|
|
1418
|
+
)
|
|
1419
|
+
"
|
|
1420
|
+
>
|
|
1421
|
+
<option value="text">text</option>
|
|
1422
|
+
<option value="textarea">textarea</option>
|
|
1423
|
+
<option value="blocks">blocks</option>
|
|
1424
|
+
<option value="date">date</option>
|
|
1425
|
+
<option value="boolean">boolean</option>
|
|
1426
|
+
<option value="select">select</option>
|
|
1427
|
+
<option value="relationship">relationship</option>
|
|
1428
|
+
<option value="entry">entry</option>
|
|
1429
|
+
<option value="entries">entries</option>
|
|
1430
|
+
<option value="link">link</option>
|
|
1431
|
+
<option value="asset">asset</option>
|
|
1432
|
+
</select>
|
|
1433
|
+
</label>
|
|
1434
|
+
|
|
1435
|
+
<label class="flex items-center gap-2 pt-6">
|
|
1436
|
+
<input v-model="nested.optional" type="checkbox" class="rounded border-zinc-300" />
|
|
1437
|
+
<span class="text-xs font-medium text-zinc-600">Optional</span>
|
|
1438
|
+
</label>
|
|
1439
|
+
</div>
|
|
1440
|
+
|
|
1441
|
+
<div v-if="nested.ui.kind === 'text' || nested.ui.kind === 'textarea'" class="flex gap-3">
|
|
1442
|
+
<label class="block flex-1">
|
|
1443
|
+
<span class="block text-xs font-medium text-zinc-600">Min length</span>
|
|
1444
|
+
<input
|
|
1445
|
+
type="number"
|
|
1446
|
+
:value="nested.validation?.min ?? ''"
|
|
1447
|
+
class="mt-1 w-full rounded border border-zinc-300 px-3 py-1.5 text-sm"
|
|
1448
|
+
@input="
|
|
1449
|
+
(function() {
|
|
1450
|
+
const v = ($event.target as HTMLInputElement).value;
|
|
1451
|
+
const next: { min?: number; max?: number } = {};
|
|
1452
|
+
if (v !== '') next.min = Number(v);
|
|
1453
|
+
if (nested.validation?.max !== undefined) next.max = nested.validation.max;
|
|
1454
|
+
nested.validation = next;
|
|
1455
|
+
})()
|
|
1456
|
+
"
|
|
1457
|
+
/>
|
|
1458
|
+
</label>
|
|
1459
|
+
<label class="block flex-1">
|
|
1460
|
+
<span class="block text-xs font-medium text-zinc-600">Max length</span>
|
|
1461
|
+
<input
|
|
1462
|
+
type="number"
|
|
1463
|
+
:value="nested.validation?.max ?? ''"
|
|
1464
|
+
class="mt-1 w-full rounded border border-zinc-300 px-3 py-1.5 text-sm"
|
|
1465
|
+
@input="
|
|
1466
|
+
(function() {
|
|
1467
|
+
const v = ($event.target as HTMLInputElement).value;
|
|
1468
|
+
const next: { min?: number; max?: number } = {};
|
|
1469
|
+
if (nested.validation?.min !== undefined) next.min = nested.validation.min;
|
|
1470
|
+
if (v !== '') next.max = Number(v);
|
|
1471
|
+
nested.validation = next;
|
|
1472
|
+
})()
|
|
1473
|
+
"
|
|
1474
|
+
/>
|
|
1475
|
+
</label>
|
|
1476
|
+
</div>
|
|
1477
|
+
|
|
1478
|
+
<div v-if="nested.ui.kind === 'select'">
|
|
1479
|
+
<span class="block text-xs font-medium text-zinc-600">Options</span>
|
|
1480
|
+
<textarea
|
|
1481
|
+
rows="3"
|
|
1482
|
+
class="mt-1 w-full rounded border border-zinc-300 px-3 py-1.5 font-mono text-xs"
|
|
1483
|
+
:value="formatSelectOptionsText(nested.ui.options ?? [])"
|
|
1484
|
+
@input="
|
|
1485
|
+
nested.ui = updateSelectUi(nested.ui, ($event.target as HTMLTextAreaElement).value)
|
|
1486
|
+
"
|
|
1487
|
+
/>
|
|
1488
|
+
</div>
|
|
1489
|
+
|
|
1490
|
+
<label v-if="nested.ui.kind === 'relationship'" class="block">
|
|
1491
|
+
<span class="block text-xs font-medium text-zinc-600">Target collection</span>
|
|
1492
|
+
<select
|
|
1493
|
+
class="mt-1 w-full rounded border border-zinc-300 px-3 py-1.5 text-sm"
|
|
1494
|
+
:value="nested.ui.to ?? ''"
|
|
1495
|
+
@change="
|
|
1496
|
+
nested.ui = {
|
|
1497
|
+
kind: 'relationship',
|
|
1498
|
+
to: ($event.target as HTMLSelectElement).value,
|
|
1499
|
+
}
|
|
1500
|
+
"
|
|
1501
|
+
>
|
|
1502
|
+
<option value="" disabled>Choose a collection</option>
|
|
1503
|
+
<option v-for="bp in blueprintList" :key="bp.handle" :value="bp.handle">{{ bp.handle }}</option>
|
|
1504
|
+
</select>
|
|
1505
|
+
</label>
|
|
1506
|
+
|
|
1507
|
+
<div v-if="nested.ui.kind === 'entry' || nested.ui.kind === 'entries' || nested.ui.kind === 'link'" class="space-y-2">
|
|
1508
|
+
<span class="block text-xs font-medium text-zinc-600">Collections</span>
|
|
1509
|
+
<div class="flex flex-wrap gap-3">
|
|
1510
|
+
<label v-for="bp in blueprintList" :key="bp.handle" class="flex items-center gap-1 text-sm">
|
|
1511
|
+
<input
|
|
1512
|
+
type="checkbox"
|
|
1513
|
+
:checked="(nested.ui.collections ?? []).includes(bp.handle)"
|
|
1514
|
+
@change="toggleCollection(nested.ui, bp.handle, ($event.target as HTMLInputElement).checked)"
|
|
1515
|
+
/>
|
|
1516
|
+
{{ bp.handle }}
|
|
1517
|
+
</label>
|
|
1518
|
+
</div>
|
|
1519
|
+
</div>
|
|
1520
|
+
|
|
1521
|
+
<label v-if="nested.ui.kind === 'entries'" class="block">
|
|
1522
|
+
<span class="block text-xs font-medium text-zinc-600">Max entries</span>
|
|
1523
|
+
<input v-model.number="nested.ui.max" type="number" min="1" class="mt-1 w-full rounded border border-zinc-300 px-3 py-1.5 text-sm" />
|
|
1524
|
+
</label>
|
|
1525
|
+
|
|
1526
|
+
<BlocksSetsPicker
|
|
1527
|
+
v-if="nested.ui.kind === 'blocks'"
|
|
1528
|
+
:model-value="nested.ui.sets ?? []"
|
|
1529
|
+
@update:model-value="updateNestedBlocksSets(i, setIndex, nestedIndex, $event)"
|
|
1530
|
+
/>
|
|
1531
|
+
|
|
1532
|
+
<div class="flex justify-end">
|
|
1533
|
+
<button
|
|
1534
|
+
type="button"
|
|
1535
|
+
class="rounded px-2 py-1 text-xs text-red-600 hover:bg-red-50"
|
|
1536
|
+
@click="openReplicatorNestedFieldRemovalDialog(i, setIndex, nestedIndex)"
|
|
1537
|
+
>
|
|
1538
|
+
Remove field
|
|
1539
|
+
</button>
|
|
1540
|
+
</div>
|
|
1541
|
+
</div>
|
|
1542
|
+
</div>
|
|
1543
|
+
</div>
|
|
1544
|
+
</div>
|
|
1545
|
+
</div>
|
|
1546
|
+
|
|
1547
|
+
<div v-if="f.ui.kind === 'grid'" class="space-y-3">
|
|
1548
|
+
<div class="flex flex-wrap gap-3">
|
|
1549
|
+
<label class="block flex-1">
|
|
1550
|
+
<span class="block text-xs font-medium text-zinc-600">Min rows</span>
|
|
1551
|
+
<input v-model.number="f.ui.minRows" type="number" min="0" class="mt-1 w-full rounded border border-zinc-300 px-3 py-1.5 text-sm" />
|
|
1552
|
+
</label>
|
|
1553
|
+
<label class="block flex-1">
|
|
1554
|
+
<span class="block text-xs font-medium text-zinc-600">Max rows</span>
|
|
1555
|
+
<input v-model.number="f.ui.maxRows" type="number" min="1" class="mt-1 w-full rounded border border-zinc-300 px-3 py-1.5 text-sm" />
|
|
1556
|
+
</label>
|
|
1557
|
+
<label class="block flex-1">
|
|
1558
|
+
<span class="block text-xs font-medium text-zinc-600">Layout</span>
|
|
1559
|
+
<select v-model="f.ui.mode" class="mt-1 w-full rounded border border-zinc-300 px-3 py-1.5 text-sm">
|
|
1560
|
+
<option value="table">table</option>
|
|
1561
|
+
<option value="stacked">stacked</option>
|
|
1562
|
+
</select>
|
|
1563
|
+
</label>
|
|
1564
|
+
</div>
|
|
1565
|
+
<label class="block">
|
|
1566
|
+
<span class="block text-xs font-medium text-zinc-600">Add row label</span>
|
|
1567
|
+
<input v-model="f.ui.addLabel" type="text" class="mt-1 w-full rounded border border-zinc-300 px-3 py-1.5 text-sm" placeholder="Add row" />
|
|
1568
|
+
</label>
|
|
1569
|
+
|
|
1570
|
+
<div class="flex items-center justify-between">
|
|
1571
|
+
<span class="text-xs font-medium text-zinc-600">Columns</span>
|
|
1572
|
+
<button type="button" class="rounded border border-zinc-300 bg-white px-2.5 py-1 text-xs font-medium text-zinc-700 hover:bg-zinc-50" @click="addGridColumn(i)">
|
|
1573
|
+
+ Add column
|
|
1574
|
+
</button>
|
|
1575
|
+
</div>
|
|
1576
|
+
|
|
1577
|
+
<div v-if="f.ui.fields.length === 0" class="rounded border border-dashed border-zinc-300 bg-zinc-50 px-3 py-4 text-xs text-zinc-500">
|
|
1578
|
+
Add at least one column field.
|
|
1579
|
+
</div>
|
|
1580
|
+
|
|
1581
|
+
<div v-for="(nested, nestedIndex) in f.ui.fields" :key="nestedIndex" class="rounded border border-zinc-200 bg-zinc-50 p-3 space-y-3">
|
|
1582
|
+
<div class="grid gap-3 md:grid-cols-2">
|
|
1583
|
+
<label class="block">
|
|
1584
|
+
<span class="block text-xs font-medium text-zinc-600">Name</span>
|
|
1585
|
+
<input v-model="nested.name" class="mt-1 w-full rounded border border-zinc-300 px-3 py-1.5 font-mono text-sm" :readonly="nested.previousName !== null" />
|
|
1586
|
+
</label>
|
|
1587
|
+
<label class="block">
|
|
1588
|
+
<span class="block text-xs font-medium text-zinc-600">Label</span>
|
|
1589
|
+
<input v-model="nested.label" class="mt-1 w-full rounded border border-zinc-300 px-3 py-1.5 text-sm" />
|
|
1590
|
+
</label>
|
|
1591
|
+
</div>
|
|
1592
|
+
<label class="block">
|
|
1593
|
+
<span class="block text-xs font-medium text-zinc-600">Kind</span>
|
|
1594
|
+
<select
|
|
1595
|
+
class="mt-1 w-full rounded border border-zinc-300 px-3 py-1.5 text-sm"
|
|
1596
|
+
:value="nested.ui.kind"
|
|
1597
|
+
@change="setGridNestedKind(i, nestedIndex, ($event.target as HTMLSelectElement).value as NonReplicatorFieldUi['kind'])"
|
|
1598
|
+
>
|
|
1599
|
+
<option value="text">text</option>
|
|
1600
|
+
<option value="textarea">textarea</option>
|
|
1601
|
+
<option value="blocks">blocks</option>
|
|
1602
|
+
<option value="date">date</option>
|
|
1603
|
+
<option value="boolean">boolean</option>
|
|
1604
|
+
<option value="select">select</option>
|
|
1605
|
+
<option value="relationship">relationship</option>
|
|
1606
|
+
<option value="entry">entry</option>
|
|
1607
|
+
<option value="entries">entries</option>
|
|
1608
|
+
<option value="link">link</option>
|
|
1609
|
+
<option value="asset">asset</option>
|
|
1610
|
+
</select>
|
|
1611
|
+
</label>
|
|
1612
|
+
<div v-if="nested.ui.kind === 'select'">
|
|
1613
|
+
<textarea
|
|
1614
|
+
rows="2"
|
|
1615
|
+
class="mt-1 w-full rounded border border-zinc-300 px-3 py-1.5 font-mono text-xs"
|
|
1616
|
+
:value="formatSelectOptionsText(nested.ui.options ?? [])"
|
|
1617
|
+
@input="nested.ui = updateSelectUi(nested.ui, ($event.target as HTMLTextAreaElement).value)"
|
|
1618
|
+
/>
|
|
1619
|
+
</div>
|
|
1620
|
+
<label v-if="nested.ui.kind === 'relationship'" class="block">
|
|
1621
|
+
<span class="block text-xs font-medium text-zinc-600">Target collection</span>
|
|
1622
|
+
<select
|
|
1623
|
+
class="mt-1 w-full rounded border border-zinc-300 px-3 py-1.5 text-sm"
|
|
1624
|
+
:value="nested.ui.to ?? ''"
|
|
1625
|
+
@change="nested.ui = { kind: 'relationship', to: ($event.target as HTMLSelectElement).value }"
|
|
1626
|
+
>
|
|
1627
|
+
<option value="" disabled>Choose a collection</option>
|
|
1628
|
+
<option v-for="bp in blueprintList" :key="bp.handle" :value="bp.handle">{{ bp.handle }}</option>
|
|
1629
|
+
</select>
|
|
1630
|
+
</label>
|
|
1631
|
+
<div v-if="nested.ui.kind === 'entry' || nested.ui.kind === 'entries' || nested.ui.kind === 'link'" class="flex flex-wrap gap-3">
|
|
1632
|
+
<label v-for="bp in blueprintList" :key="bp.handle" class="flex items-center gap-1 text-sm">
|
|
1633
|
+
<input
|
|
1634
|
+
type="checkbox"
|
|
1635
|
+
:checked="(nested.ui.collections ?? []).includes(bp.handle)"
|
|
1636
|
+
@change="toggleCollection(nested.ui, bp.handle, ($event.target as HTMLInputElement).checked)"
|
|
1637
|
+
/>
|
|
1638
|
+
{{ bp.handle }}
|
|
1639
|
+
</label>
|
|
1640
|
+
</div>
|
|
1641
|
+
<button type="button" class="text-xs text-red-600 hover:bg-red-50 rounded px-2 py-1" @click="removeGridColumn(i, nestedIndex)">
|
|
1642
|
+
Remove column
|
|
1643
|
+
</button>
|
|
1644
|
+
</div>
|
|
1645
|
+
</div>
|
|
1646
|
+
</div>
|
|
1647
|
+
</div>
|
|
1648
|
+
</div>
|
|
1649
|
+
|
|
1650
|
+
<div v-if="submitError" class="rounded bg-red-50 px-3 py-2 text-sm text-red-700">
|
|
1651
|
+
{{ submitError }}
|
|
1652
|
+
</div>
|
|
1653
|
+
|
|
1654
|
+
<div class="flex items-center gap-2">
|
|
1655
|
+
<button
|
|
1656
|
+
type="submit"
|
|
1657
|
+
class="vulse-button-primary rounded px-4 py-2 text-sm font-medium disabled:opacity-50"
|
|
1658
|
+
:disabled="!hydrated || saving || fields.length === 0"
|
|
1659
|
+
:title="!hydrated || fields.length === 0 ? 'Add at least one field before saving.' : undefined"
|
|
1660
|
+
data-testid="blueprint-save"
|
|
1661
|
+
>
|
|
1662
|
+
{{ saving ? 'Saving…' : 'Save' }}
|
|
1663
|
+
</button>
|
|
1664
|
+
<a
|
|
1665
|
+
href="/admin"
|
|
1666
|
+
class="rounded border border-zinc-300 bg-white px-4 py-2 text-sm font-medium text-zinc-700 hover:bg-zinc-50"
|
|
1667
|
+
data-testid="blueprint-cancel"
|
|
1668
|
+
>
|
|
1669
|
+
Cancel
|
|
1670
|
+
</a>
|
|
1671
|
+
<button
|
|
1672
|
+
v-if="!isCreate"
|
|
1673
|
+
type="button"
|
|
1674
|
+
class="ml-auto rounded border border-red-300 bg-white px-4 py-2 text-sm font-medium text-red-600 hover:bg-red-50"
|
|
1675
|
+
data-testid="blueprint-delete"
|
|
1676
|
+
@click="openBlueprintRemovalDialog"
|
|
1677
|
+
>
|
|
1678
|
+
Delete
|
|
1679
|
+
</button>
|
|
1680
|
+
</div>
|
|
1681
|
+
</form>
|
|
1682
|
+
|
|
1683
|
+
<section v-if="!isCreate && hydrated" class="mt-10 max-w-3xl rounded-xl border border-zinc-200 bg-white p-4">
|
|
1684
|
+
<div class="flex items-start justify-between gap-4">
|
|
1685
|
+
<div>
|
|
1686
|
+
<h2 class="text-sm font-semibold text-zinc-800">Scaffold frontend</h2>
|
|
1687
|
+
<p class="mt-1 text-xs text-zinc-500">
|
|
1688
|
+
Generate a code blueprint, Astro index/show pages, and a content.config entry — like Statamic’s scaffold views.
|
|
1689
|
+
Run the CLI locally or copy the files below.
|
|
1690
|
+
</p>
|
|
1691
|
+
</div>
|
|
1692
|
+
<button
|
|
1693
|
+
type="button"
|
|
1694
|
+
class="text-xs text-zinc-500 hover:text-zinc-900"
|
|
1695
|
+
@click="scaffoldOpen = !scaffoldOpen"
|
|
1696
|
+
>
|
|
1697
|
+
{{ scaffoldOpen ? 'Hide' : 'Show' }}
|
|
1698
|
+
</button>
|
|
1699
|
+
</div>
|
|
1700
|
+
|
|
1701
|
+
<div v-if="scaffoldOpen" class="mt-4 space-y-4">
|
|
1702
|
+
<div class="grid gap-3 sm:grid-cols-2">
|
|
1703
|
+
<label class="block text-sm">
|
|
1704
|
+
<span class="font-medium text-zinc-700">Show route</span>
|
|
1705
|
+
<input v-model="scaffoldShowRoute" class="mt-1 w-full rounded border border-zinc-300 px-2 py-1.5 font-mono text-xs" placeholder="/blog/{slug}" />
|
|
1706
|
+
</label>
|
|
1707
|
+
<label class="block text-sm">
|
|
1708
|
+
<span class="font-medium text-zinc-700">Index route</span>
|
|
1709
|
+
<input v-model="scaffoldIndexRoute" class="mt-1 w-full rounded border border-zinc-300 px-2 py-1.5 font-mono text-xs" placeholder="/blog" />
|
|
1710
|
+
<span class="mt-1 block text-xs text-zinc-400">Leave empty to skip the index page.</span>
|
|
1711
|
+
</label>
|
|
1712
|
+
</div>
|
|
1713
|
+
|
|
1714
|
+
<div>
|
|
1715
|
+
<div class="mb-2 flex items-center justify-between">
|
|
1716
|
+
<span class="text-xs font-medium uppercase tracking-wide text-zinc-500">CLI command</span>
|
|
1717
|
+
<button type="button" class="text-xs text-zinc-600 hover:underline" @click="copyText(scaffoldCommand, 'CLI command')">Copy</button>
|
|
1718
|
+
</div>
|
|
1719
|
+
<pre class="overflow-x-auto rounded bg-zinc-50 p-3 text-xs">{{ scaffoldCommand }}</pre>
|
|
1720
|
+
</div>
|
|
1721
|
+
|
|
1722
|
+
<div v-for="file in scaffoldFiles" :key="file.path">
|
|
1723
|
+
<div class="mb-2 flex items-center justify-between">
|
|
1724
|
+
<span class="font-mono text-xs text-zinc-600">{{ file.path }}</span>
|
|
1725
|
+
<button type="button" class="text-xs text-zinc-600 hover:underline" @click="copyText(file.content, file.path)">Copy</button>
|
|
1726
|
+
</div>
|
|
1727
|
+
<pre class="max-h-64 overflow-auto rounded bg-zinc-50 p-3 text-xs">{{ file.content }}</pre>
|
|
1728
|
+
</div>
|
|
1729
|
+
|
|
1730
|
+
<div>
|
|
1731
|
+
<div class="mb-2 flex items-center justify-between">
|
|
1732
|
+
<span class="font-mono text-xs text-zinc-600">src/content.config.ts</span>
|
|
1733
|
+
<button type="button" class="text-xs text-zinc-600 hover:underline" @click="copyText(scaffoldContentConfigSnippet, 'content.config.ts')">Copy</button>
|
|
1734
|
+
</div>
|
|
1735
|
+
<p class="mb-2 text-xs text-zinc-500">Merge into an existing file or use as-is if you do not have one yet.</p>
|
|
1736
|
+
<pre class="max-h-48 overflow-auto rounded bg-zinc-50 p-3 text-xs">{{ scaffoldContentConfigSnippet }}</pre>
|
|
1737
|
+
</div>
|
|
1738
|
+
|
|
1739
|
+
<p v-if="copyNotice" class="text-xs text-green-700">{{ copyNotice }}</p>
|
|
1740
|
+
</div>
|
|
1741
|
+
</section>
|
|
1742
|
+
|
|
1743
|
+
<div
|
|
1744
|
+
v-if="removalTarget"
|
|
1745
|
+
class="fixed inset-0 z-50 flex items-center justify-center bg-black/30 px-4"
|
|
1746
|
+
data-testid="remove-confirmation-modal"
|
|
1747
|
+
>
|
|
1748
|
+
<div class="w-full max-w-md rounded-2xl border border-zinc-200 bg-white p-5 shadow-xl">
|
|
1749
|
+
<h2 class="text-lg font-semibold text-zinc-900">{{ removalDialogTitle }}</h2>
|
|
1750
|
+
<p class="mt-2 text-sm text-zinc-600">{{ removalDialogMessage }}</p>
|
|
1751
|
+
<p v-if="removalTarget.requiresVerification" class="mt-3 text-sm text-zinc-700">
|
|
1752
|
+
Type <span class="font-mono font-medium">{{ removalTarget.name }}</span> to confirm.
|
|
1753
|
+
</p>
|
|
1754
|
+
<input
|
|
1755
|
+
v-if="removalTarget.requiresVerification"
|
|
1756
|
+
v-model="removalVerification"
|
|
1757
|
+
type="text"
|
|
1758
|
+
class="mt-2 w-full rounded border border-zinc-300 px-3 py-2 text-sm"
|
|
1759
|
+
data-testid="remove-confirmation-input"
|
|
1760
|
+
/>
|
|
1761
|
+
<div class="mt-5 flex items-center justify-end gap-2">
|
|
1762
|
+
<button
|
|
1763
|
+
type="button"
|
|
1764
|
+
class="rounded border border-zinc-300 bg-white px-4 py-2 text-sm font-medium text-zinc-700 hover:bg-zinc-50"
|
|
1765
|
+
data-testid="remove-confirmation-cancel"
|
|
1766
|
+
@click="closeRemovalDialog"
|
|
1767
|
+
>
|
|
1768
|
+
Cancel
|
|
1769
|
+
</button>
|
|
1770
|
+
<button
|
|
1771
|
+
type="button"
|
|
1772
|
+
class="rounded border border-red-300 bg-red-600 px-4 py-2 text-sm font-medium text-white hover:bg-red-700 disabled:cursor-not-allowed disabled:opacity-50"
|
|
1773
|
+
:disabled="removalConfirmDisabled"
|
|
1774
|
+
data-testid="remove-confirmation-confirm"
|
|
1775
|
+
@click="confirmRemoval"
|
|
1776
|
+
>
|
|
1777
|
+
{{ removalConfirmLabel }}
|
|
1778
|
+
</button>
|
|
1779
|
+
</div>
|
|
1780
|
+
</div>
|
|
1781
|
+
</div>
|
|
1782
|
+
</div>
|
|
1783
|
+
</template>
|