@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,173 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import type { FieldDescriptor } from '../../client/form-from-zod'
|
|
3
|
+
import FieldRenderer from './FieldRenderer.vue'
|
|
4
|
+
|
|
5
|
+
const props = defineProps<{
|
|
6
|
+
modelValue: Record<string, unknown>[]
|
|
7
|
+
label: string
|
|
8
|
+
itemFields: FieldDescriptor[]
|
|
9
|
+
mode?: 'table' | 'stacked'
|
|
10
|
+
minRows?: number
|
|
11
|
+
maxRows?: number
|
|
12
|
+
addLabel?: string
|
|
13
|
+
tree?: boolean
|
|
14
|
+
linkCollections?: string[]
|
|
15
|
+
}>()
|
|
16
|
+
|
|
17
|
+
const emit = defineEmits<{ (e: 'update:modelValue', v: Record<string, unknown>[]): void }>()
|
|
18
|
+
|
|
19
|
+
function rows(): Record<string, unknown>[] {
|
|
20
|
+
return props.modelValue ?? []
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function canAdd(): boolean {
|
|
24
|
+
if (props.maxRows === undefined) return true
|
|
25
|
+
return rows().length < props.maxRows
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function canRemove(): boolean {
|
|
29
|
+
if (props.minRows === undefined) return true
|
|
30
|
+
return rows().length > props.minRows
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function updateCell(rowIndex: number, key: string, value: unknown) {
|
|
34
|
+
const next = [...rows()]
|
|
35
|
+
next[rowIndex] = { ...next[rowIndex], [key]: value }
|
|
36
|
+
emit('update:modelValue', next)
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function addRow() {
|
|
40
|
+
if (!canAdd()) return
|
|
41
|
+
emit('update:modelValue', [...rows(), {}])
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function removeRow(index: number) {
|
|
45
|
+
if (!canRemove()) return
|
|
46
|
+
const next = [...rows()]
|
|
47
|
+
next.splice(index, 1)
|
|
48
|
+
emit('update:modelValue', next)
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function moveRow(index: number, direction: -1 | 1) {
|
|
52
|
+
const next = [...rows()]
|
|
53
|
+
const target = index + direction
|
|
54
|
+
if (target < 0 || target >= next.length) return
|
|
55
|
+
const [moved] = next.splice(index, 1)
|
|
56
|
+
next.splice(target, 0, moved!)
|
|
57
|
+
emit('update:modelValue', next)
|
|
58
|
+
}
|
|
59
|
+
</script>
|
|
60
|
+
|
|
61
|
+
<template>
|
|
62
|
+
<div class="space-y-2">
|
|
63
|
+
<div class="text-sm text-zinc-600">{{ label }}</div>
|
|
64
|
+
|
|
65
|
+
<div v-if="mode === 'table' && itemFields.length" class="overflow-x-auto rounded border">
|
|
66
|
+
<table class="min-w-full text-sm">
|
|
67
|
+
<thead class="bg-zinc-50 text-left text-xs text-zinc-600">
|
|
68
|
+
<tr>
|
|
69
|
+
<th class="w-16 px-2 py-2"></th>
|
|
70
|
+
<th v-for="f in itemFields" :key="f.path" class="px-3 py-2 font-medium">{{ f.label ?? f.path }}</th>
|
|
71
|
+
<th class="w-16 px-2 py-2"></th>
|
|
72
|
+
</tr>
|
|
73
|
+
</thead>
|
|
74
|
+
<tbody>
|
|
75
|
+
<tr v-for="(item, i) in rows()" :key="i" class="border-t border-zinc-200 align-top">
|
|
76
|
+
<td class="px-2 py-2">
|
|
77
|
+
<div class="flex flex-col gap-1">
|
|
78
|
+
<button
|
|
79
|
+
type="button"
|
|
80
|
+
class="text-xs text-zinc-500 hover:text-zinc-900 disabled:opacity-30"
|
|
81
|
+
:disabled="i === 0"
|
|
82
|
+
@click="moveRow(i, -1)"
|
|
83
|
+
>
|
|
84
|
+
↑
|
|
85
|
+
</button>
|
|
86
|
+
<button
|
|
87
|
+
type="button"
|
|
88
|
+
class="text-xs text-zinc-500 hover:text-zinc-900 disabled:opacity-30"
|
|
89
|
+
:disabled="i === rows().length - 1"
|
|
90
|
+
@click="moveRow(i, 1)"
|
|
91
|
+
>
|
|
92
|
+
↓
|
|
93
|
+
</button>
|
|
94
|
+
</div>
|
|
95
|
+
</td>
|
|
96
|
+
<td v-for="f in itemFields" :key="f.path" class="px-3 py-2">
|
|
97
|
+
<FieldRenderer
|
|
98
|
+
:field="f"
|
|
99
|
+
:model-value="item?.[f.path]"
|
|
100
|
+
:tree="tree"
|
|
101
|
+
:link-collections="linkCollections"
|
|
102
|
+
@update:modelValue="updateCell(i, f.path, $event)"
|
|
103
|
+
/>
|
|
104
|
+
</td>
|
|
105
|
+
<td class="px-2 py-2">
|
|
106
|
+
<button
|
|
107
|
+
type="button"
|
|
108
|
+
class="text-xs text-red-600 disabled:opacity-30"
|
|
109
|
+
:disabled="!canRemove()"
|
|
110
|
+
@click="removeRow(i)"
|
|
111
|
+
>
|
|
112
|
+
Remove
|
|
113
|
+
</button>
|
|
114
|
+
</td>
|
|
115
|
+
</tr>
|
|
116
|
+
</tbody>
|
|
117
|
+
</table>
|
|
118
|
+
</div>
|
|
119
|
+
|
|
120
|
+
<div v-else class="space-y-3">
|
|
121
|
+
<div v-for="(item, i) in rows()" :key="i" class="space-y-2 rounded border p-3">
|
|
122
|
+
<div class="flex items-center justify-between">
|
|
123
|
+
<span class="text-xs font-medium text-zinc-500">Row {{ i + 1 }}</span>
|
|
124
|
+
<div class="flex items-center gap-2">
|
|
125
|
+
<button
|
|
126
|
+
type="button"
|
|
127
|
+
class="text-xs text-zinc-500 hover:text-zinc-900 disabled:opacity-30"
|
|
128
|
+
:disabled="i === 0"
|
|
129
|
+
@click="moveRow(i, -1)"
|
|
130
|
+
>
|
|
131
|
+
Move up
|
|
132
|
+
</button>
|
|
133
|
+
<button
|
|
134
|
+
type="button"
|
|
135
|
+
class="text-xs text-zinc-500 hover:text-zinc-900 disabled:opacity-30"
|
|
136
|
+
:disabled="i === rows().length - 1"
|
|
137
|
+
@click="moveRow(i, 1)"
|
|
138
|
+
>
|
|
139
|
+
Move down
|
|
140
|
+
</button>
|
|
141
|
+
<button
|
|
142
|
+
type="button"
|
|
143
|
+
class="text-xs text-red-600 disabled:opacity-30"
|
|
144
|
+
:disabled="!canRemove()"
|
|
145
|
+
@click="removeRow(i)"
|
|
146
|
+
>
|
|
147
|
+
Remove
|
|
148
|
+
</button>
|
|
149
|
+
</div>
|
|
150
|
+
</div>
|
|
151
|
+
<FieldRenderer
|
|
152
|
+
v-for="f in itemFields"
|
|
153
|
+
:key="f.path"
|
|
154
|
+
:field="f"
|
|
155
|
+
:model-value="item?.[f.path]"
|
|
156
|
+
:tree="tree"
|
|
157
|
+
:link-collections="linkCollections"
|
|
158
|
+
@update:modelValue="updateCell(i, f.path, $event)"
|
|
159
|
+
/>
|
|
160
|
+
</div>
|
|
161
|
+
</div>
|
|
162
|
+
|
|
163
|
+
<button
|
|
164
|
+
v-if="canAdd()"
|
|
165
|
+
type="button"
|
|
166
|
+
class="text-sm rounded border px-3 py-1"
|
|
167
|
+
@click="addRow"
|
|
168
|
+
>
|
|
169
|
+
{{ addLabel || 'Add row' }}
|
|
170
|
+
</button>
|
|
171
|
+
<p v-else-if="maxRows" class="text-xs text-zinc-500">Maximum of {{ maxRows }} rows reached.</p>
|
|
172
|
+
</div>
|
|
173
|
+
</template>
|
|
@@ -0,0 +1,219 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import { computed, ref, watch } from 'vue'
|
|
3
|
+
import type { LinkValue } from '../../../core/blueprints/definition.js'
|
|
4
|
+
import { entryOptionLabel, useEntrySearch } from '../../composables/useEntrySearch.js'
|
|
5
|
+
|
|
6
|
+
const props = defineProps<{
|
|
7
|
+
modelValue: LinkValue | null | undefined
|
|
8
|
+
label: string
|
|
9
|
+
collections?: string[]
|
|
10
|
+
tree?: boolean
|
|
11
|
+
}>()
|
|
12
|
+
|
|
13
|
+
const emit = defineEmits<{ (e: 'update:modelValue', v: LinkValue | null): void }>()
|
|
14
|
+
|
|
15
|
+
type LinkMode = LinkValue['type']
|
|
16
|
+
|
|
17
|
+
const mode = ref<LinkMode>('url')
|
|
18
|
+
const url = ref('')
|
|
19
|
+
const entryCollection = ref(props.collections?.[0] ?? '')
|
|
20
|
+
const entryLabel = ref('')
|
|
21
|
+
|
|
22
|
+
const entryCollections = computed(() => props.collections ?? [])
|
|
23
|
+
|
|
24
|
+
const {
|
|
25
|
+
open,
|
|
26
|
+
query,
|
|
27
|
+
options,
|
|
28
|
+
loading,
|
|
29
|
+
resolveLabel,
|
|
30
|
+
openDropdown,
|
|
31
|
+
closeDropdown,
|
|
32
|
+
onBlur,
|
|
33
|
+
} = useEntrySearch(() => {
|
|
34
|
+
const col = entryCollection.value || entryCollections.value[0]
|
|
35
|
+
return col ? [col] : []
|
|
36
|
+
})
|
|
37
|
+
|
|
38
|
+
function emitValue(value: LinkValue | null) {
|
|
39
|
+
emit('update:modelValue', value)
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function onModeChange(next: LinkMode) {
|
|
43
|
+
mode.value = next
|
|
44
|
+
if (next === 'url') {
|
|
45
|
+
emitValue(url.value.trim() ? { type: 'url', url: url.value.trim() } : null)
|
|
46
|
+
return
|
|
47
|
+
}
|
|
48
|
+
if (next === 'first-child') {
|
|
49
|
+
emitValue({ type: 'first-child' })
|
|
50
|
+
return
|
|
51
|
+
}
|
|
52
|
+
emitValue(null)
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function onUrlInput() {
|
|
56
|
+
const trimmed = url.value.trim()
|
|
57
|
+
emitValue(trimmed ? { type: 'url', url: trimmed } : null)
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function selectEntry(option: { id: string; collection: string; title?: string; email?: string }) {
|
|
61
|
+
entryCollection.value = option.collection
|
|
62
|
+
entryLabel.value = entryOptionLabel(option)
|
|
63
|
+
emitValue({ type: 'entry', entryId: option.id, collection: option.collection })
|
|
64
|
+
query.value = ''
|
|
65
|
+
closeDropdown()
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function clearEntry() {
|
|
69
|
+
entryLabel.value = ''
|
|
70
|
+
emitValue(null)
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
watch(
|
|
74
|
+
() => props.modelValue,
|
|
75
|
+
(value) => {
|
|
76
|
+
if (!value) {
|
|
77
|
+
mode.value = 'url'
|
|
78
|
+
url.value = ''
|
|
79
|
+
entryLabel.value = ''
|
|
80
|
+
return
|
|
81
|
+
}
|
|
82
|
+
mode.value = value.type
|
|
83
|
+
if (value.type === 'url') {
|
|
84
|
+
url.value = value.url
|
|
85
|
+
return
|
|
86
|
+
}
|
|
87
|
+
if (value.type === 'entry') {
|
|
88
|
+
entryCollection.value = value.collection
|
|
89
|
+
void resolveLabel(value.entryId, value.collection).then((label) => {
|
|
90
|
+
entryLabel.value = label
|
|
91
|
+
})
|
|
92
|
+
}
|
|
93
|
+
},
|
|
94
|
+
{ immediate: true },
|
|
95
|
+
)
|
|
96
|
+
|
|
97
|
+
watch(
|
|
98
|
+
entryCollections,
|
|
99
|
+
(cols) => {
|
|
100
|
+
if (cols.length === 1) entryCollection.value = cols[0]!
|
|
101
|
+
},
|
|
102
|
+
{ immediate: true },
|
|
103
|
+
)
|
|
104
|
+
</script>
|
|
105
|
+
|
|
106
|
+
<template>
|
|
107
|
+
<div class="block space-y-2">
|
|
108
|
+
<span class="text-sm text-zinc-600">{{ label }}</span>
|
|
109
|
+
|
|
110
|
+
<div class="flex flex-wrap gap-2">
|
|
111
|
+
<button
|
|
112
|
+
type="button"
|
|
113
|
+
class="rounded border px-3 py-1 text-sm"
|
|
114
|
+
:class="mode === 'url' ? 'border-zinc-900 bg-zinc-900 text-white' : 'border-zinc-300 bg-white text-zinc-700'"
|
|
115
|
+
@click="onModeChange('url')"
|
|
116
|
+
>
|
|
117
|
+
URL
|
|
118
|
+
</button>
|
|
119
|
+
<button
|
|
120
|
+
type="button"
|
|
121
|
+
class="rounded border px-3 py-1 text-sm"
|
|
122
|
+
:class="mode === 'entry' ? 'border-zinc-900 bg-zinc-900 text-white' : 'border-zinc-300 bg-white text-zinc-700'"
|
|
123
|
+
@click="onModeChange('entry')"
|
|
124
|
+
>
|
|
125
|
+
Entry
|
|
126
|
+
</button>
|
|
127
|
+
<button
|
|
128
|
+
v-if="tree"
|
|
129
|
+
type="button"
|
|
130
|
+
class="rounded border px-3 py-1 text-sm"
|
|
131
|
+
:class="mode === 'first-child' ? 'border-zinc-900 bg-zinc-900 text-white' : 'border-zinc-300 bg-white text-zinc-700'"
|
|
132
|
+
@click="onModeChange('first-child')"
|
|
133
|
+
>
|
|
134
|
+
First child
|
|
135
|
+
</button>
|
|
136
|
+
</div>
|
|
137
|
+
|
|
138
|
+
<input
|
|
139
|
+
v-if="mode === 'url'"
|
|
140
|
+
v-model="url"
|
|
141
|
+
type="url"
|
|
142
|
+
class="vulse-input mt-1 bg-white"
|
|
143
|
+
placeholder="https://example.com or /about"
|
|
144
|
+
@input="onUrlInput"
|
|
145
|
+
/>
|
|
146
|
+
|
|
147
|
+
<div v-else-if="mode === 'entry'" class="space-y-2">
|
|
148
|
+
<select
|
|
149
|
+
v-if="entryCollections.length > 1"
|
|
150
|
+
v-model="entryCollection"
|
|
151
|
+
class="vulse-input bg-white text-sm"
|
|
152
|
+
@change="clearEntry()"
|
|
153
|
+
>
|
|
154
|
+
<option v-for="col in entryCollections" :key="col" :value="col">{{ col }}</option>
|
|
155
|
+
</select>
|
|
156
|
+
|
|
157
|
+
<div class="relative" @blur="onBlur">
|
|
158
|
+
<div class="flex gap-2">
|
|
159
|
+
<button
|
|
160
|
+
type="button"
|
|
161
|
+
class="vulse-input flex flex-1 items-center justify-between bg-white text-left"
|
|
162
|
+
:class="open && 'border-zinc-400'"
|
|
163
|
+
@click="open ? closeDropdown() : openDropdown()"
|
|
164
|
+
>
|
|
165
|
+
<span :class="modelValue?.type === 'entry' ? 'text-zinc-900' : 'text-zinc-400'">
|
|
166
|
+
{{
|
|
167
|
+
modelValue?.type === 'entry'
|
|
168
|
+
? entryLabel || modelValue.entryId
|
|
169
|
+
: 'Select entry…'
|
|
170
|
+
}}
|
|
171
|
+
</span>
|
|
172
|
+
<span class="text-xs text-zinc-400">{{ open ? '▴' : '▾' }}</span>
|
|
173
|
+
</button>
|
|
174
|
+
<button
|
|
175
|
+
v-if="modelValue?.type === 'entry'"
|
|
176
|
+
type="button"
|
|
177
|
+
class="rounded border border-zinc-300 bg-white px-3 py-2 text-sm text-zinc-600 hover:bg-zinc-50"
|
|
178
|
+
@click="clearEntry"
|
|
179
|
+
>
|
|
180
|
+
Clear
|
|
181
|
+
</button>
|
|
182
|
+
</div>
|
|
183
|
+
|
|
184
|
+
<div
|
|
185
|
+
v-if="open"
|
|
186
|
+
class="absolute z-20 mt-1 w-full overflow-hidden rounded-md border border-zinc-200 bg-white shadow-lg"
|
|
187
|
+
>
|
|
188
|
+
<div class="border-b border-zinc-200 p-2">
|
|
189
|
+
<input
|
|
190
|
+
v-model="query"
|
|
191
|
+
type="search"
|
|
192
|
+
class="vulse-input bg-white"
|
|
193
|
+
placeholder="Search entries…"
|
|
194
|
+
autofocus
|
|
195
|
+
@keydown.esc.prevent="closeDropdown()"
|
|
196
|
+
/>
|
|
197
|
+
</div>
|
|
198
|
+
<ul class="max-h-48 overflow-auto py-1 text-sm">
|
|
199
|
+
<li v-if="loading" class="px-3 py-2 text-zinc-500">Loading…</li>
|
|
200
|
+
<li v-else-if="options.length === 0" class="px-3 py-2 text-zinc-500">No matches</li>
|
|
201
|
+
<li v-for="option in options" v-else :key="option.id">
|
|
202
|
+
<button
|
|
203
|
+
type="button"
|
|
204
|
+
class="flex w-full items-center px-3 py-2 text-left hover:bg-zinc-100"
|
|
205
|
+
@click="selectEntry(option)"
|
|
206
|
+
>
|
|
207
|
+
{{ entryOptionLabel(option) }}
|
|
208
|
+
</button>
|
|
209
|
+
</li>
|
|
210
|
+
</ul>
|
|
211
|
+
</div>
|
|
212
|
+
</div>
|
|
213
|
+
</div>
|
|
214
|
+
|
|
215
|
+
<p v-else-if="mode === 'first-child'" class="text-sm text-zinc-500">
|
|
216
|
+
Links to the first child entry in this collection tree.
|
|
217
|
+
</p>
|
|
218
|
+
</div>
|
|
219
|
+
</template>
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import { ref, onMounted, watch } from 'vue'
|
|
3
|
+
import { adminApi } from '../../client/api.js'
|
|
4
|
+
import MediaPicker from '../MediaPicker.vue'
|
|
5
|
+
|
|
6
|
+
const props = defineProps<{ modelValue: unknown; label?: string }>()
|
|
7
|
+
const emit = defineEmits<{ (e: 'update:modelValue', v: string | null): void }>()
|
|
8
|
+
|
|
9
|
+
interface MediaItem {
|
|
10
|
+
id: string
|
|
11
|
+
alt: string | null
|
|
12
|
+
deliveryUrl: string | null
|
|
13
|
+
previewUrl: string
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
const showPicker = ref(false)
|
|
17
|
+
const preview = ref<MediaItem | null>(null)
|
|
18
|
+
|
|
19
|
+
const mediaId = () => (typeof props.modelValue === 'string' && props.modelValue ? props.modelValue : null)
|
|
20
|
+
|
|
21
|
+
function previewSrc(item: MediaItem): string {
|
|
22
|
+
return item.deliveryUrl ?? item.previewUrl
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
async function loadPreview() {
|
|
26
|
+
const id = mediaId()
|
|
27
|
+
if (!id) {
|
|
28
|
+
preview.value = null
|
|
29
|
+
return
|
|
30
|
+
}
|
|
31
|
+
const list = await adminApi.get<MediaItem[]>('/api/vulse/media')
|
|
32
|
+
preview.value = list.find((m) => m.id === id) ?? null
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
onMounted(loadPreview)
|
|
36
|
+
watch(() => props.modelValue, loadPreview)
|
|
37
|
+
|
|
38
|
+
function pick(id: string) {
|
|
39
|
+
emit('update:modelValue', id)
|
|
40
|
+
showPicker.value = false
|
|
41
|
+
loadPreview()
|
|
42
|
+
}
|
|
43
|
+
</script>
|
|
44
|
+
|
|
45
|
+
<template>
|
|
46
|
+
<div class="space-y-2">
|
|
47
|
+
<div v-if="label" class="vulse-label">{{ label }}</div>
|
|
48
|
+
<div class="flex items-center gap-3">
|
|
49
|
+
<img
|
|
50
|
+
v-if="preview"
|
|
51
|
+
:src="previewSrc(preview)"
|
|
52
|
+
:alt="preview.alt ?? ''"
|
|
53
|
+
class="h-20 w-20 rounded border object-cover"
|
|
54
|
+
/>
|
|
55
|
+
<button type="button" class="rounded border border-zinc-300 bg-white px-3 py-2 text-sm hover:bg-zinc-50" @click="showPicker = true">
|
|
56
|
+
{{ mediaId() ? 'Change…' : 'Pick media…' }}
|
|
57
|
+
</button>
|
|
58
|
+
<button
|
|
59
|
+
v-if="mediaId()"
|
|
60
|
+
type="button"
|
|
61
|
+
class="text-sm text-red-600 hover:underline"
|
|
62
|
+
@click="emit('update:modelValue', null)"
|
|
63
|
+
>
|
|
64
|
+
Clear
|
|
65
|
+
</button>
|
|
66
|
+
</div>
|
|
67
|
+
<MediaPicker v-if="showPicker" @pick="pick" @close="showPicker = false" />
|
|
68
|
+
</div>
|
|
69
|
+
</template>
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
defineProps<{ modelValue: number | null | undefined; label: string; required?: boolean }>()
|
|
3
|
+
defineEmits<{ (e: 'update:modelValue', v: number | null): void }>()
|
|
4
|
+
</script>
|
|
5
|
+
<template>
|
|
6
|
+
<label class="block">
|
|
7
|
+
<span class="text-sm text-zinc-600">{{ label }}<span v-if="required" class="text-red-600">*</span></span>
|
|
8
|
+
<input type="number" :value="modelValue ?? ''"
|
|
9
|
+
@input="$emit('update:modelValue', ($event.target as HTMLInputElement).value === '' ? null : Number(($event.target as HTMLInputElement).value))"
|
|
10
|
+
class="mt-1 w-full rounded border px-3 py-2" />
|
|
11
|
+
</label>
|
|
12
|
+
</template>
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import type { FieldDescriptor } from '../../client/form-from-zod'
|
|
3
|
+
import FieldRenderer from './FieldRenderer.vue'
|
|
4
|
+
defineProps<{ modelValue: Record<string, unknown>; label: string; fields: FieldDescriptor[] }>()
|
|
5
|
+
const emit = defineEmits<{ (e: 'update:modelValue', v: Record<string, unknown>): void }>()
|
|
6
|
+
function set(path: string, v: unknown, current: Record<string, unknown>) {
|
|
7
|
+
emit('update:modelValue', { ...current, [path]: v })
|
|
8
|
+
}
|
|
9
|
+
</script>
|
|
10
|
+
<template>
|
|
11
|
+
<fieldset class="border rounded p-4 space-y-3">
|
|
12
|
+
<legend class="text-sm font-medium px-2">{{ label }}</legend>
|
|
13
|
+
<FieldRenderer v-for="f in fields" :key="f.path"
|
|
14
|
+
:field="f"
|
|
15
|
+
:model-value="modelValue?.[f.path]"
|
|
16
|
+
@update:modelValue="set(f.path, $event, modelValue ?? {})" />
|
|
17
|
+
</fieldset>
|
|
18
|
+
</template>
|
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import { ref, watch } from 'vue'
|
|
3
|
+
import { adminApi } from '../../client/api.js'
|
|
4
|
+
|
|
5
|
+
const props = defineProps<{ modelValue: string | null; label: string; refTarget: string }>()
|
|
6
|
+
const emit = defineEmits<{ (e: 'update:modelValue', v: string | null): void }>()
|
|
7
|
+
|
|
8
|
+
interface RefOption {
|
|
9
|
+
id: string
|
|
10
|
+
title?: string
|
|
11
|
+
email?: string
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
const open = ref(false)
|
|
15
|
+
const query = ref('')
|
|
16
|
+
const options = ref<RefOption[]>([])
|
|
17
|
+
const loading = ref(false)
|
|
18
|
+
const selectedLabel = ref('')
|
|
19
|
+
|
|
20
|
+
function optionLabel(option: RefOption): string {
|
|
21
|
+
return option.title ?? option.email ?? option.id
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
async function loadOptions(search = '') {
|
|
25
|
+
loading.value = true
|
|
26
|
+
try {
|
|
27
|
+
if (props.refTarget === 'user') {
|
|
28
|
+
options.value = await adminApi.get<RefOption[]>(
|
|
29
|
+
`/api/vulse/users?q=${encodeURIComponent(search)}`,
|
|
30
|
+
)
|
|
31
|
+
return
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const rows = await adminApi.get<{ id: string; content?: { title?: string }; slug?: string }[]>(
|
|
35
|
+
`/api/vulse/entries/${props.refTarget}`,
|
|
36
|
+
)
|
|
37
|
+
const needle = search.trim().toLowerCase()
|
|
38
|
+
options.value = rows
|
|
39
|
+
.map((row) => ({
|
|
40
|
+
id: row.id,
|
|
41
|
+
title: row.content?.title ?? row.slug ?? row.id,
|
|
42
|
+
}))
|
|
43
|
+
.filter((row) => {
|
|
44
|
+
if (!needle) return true
|
|
45
|
+
return optionLabel(row).toLowerCase().includes(needle)
|
|
46
|
+
})
|
|
47
|
+
} finally {
|
|
48
|
+
loading.value = false
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
async function resolveSelectedLabel(id: string | null) {
|
|
53
|
+
if (!id) {
|
|
54
|
+
selectedLabel.value = ''
|
|
55
|
+
return
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
if (props.refTarget === 'user') {
|
|
59
|
+
const users = await adminApi.get<RefOption[]>(`/api/vulse/users?q=${encodeURIComponent(id)}`)
|
|
60
|
+
const match = users.find((user) => user.id === id)
|
|
61
|
+
selectedLabel.value = match ? optionLabel(match) : id
|
|
62
|
+
return
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const row = await adminApi.get<{ id: string; content?: { title?: string }; slug?: string }>(
|
|
66
|
+
`/api/vulse/entries/${props.refTarget}/${id}`,
|
|
67
|
+
)
|
|
68
|
+
selectedLabel.value = row.content?.title ?? row.slug ?? row.id
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function openDropdown() {
|
|
72
|
+
open.value = true
|
|
73
|
+
void loadOptions(query.value)
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function closeDropdown() {
|
|
77
|
+
open.value = false
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function onBlur(event: FocusEvent) {
|
|
81
|
+
const next = event.relatedTarget as Node | null
|
|
82
|
+
if (next && (event.currentTarget as HTMLElement).contains(next)) return
|
|
83
|
+
closeDropdown()
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function selectOption(option: RefOption) {
|
|
87
|
+
emit('update:modelValue', option.id)
|
|
88
|
+
selectedLabel.value = optionLabel(option)
|
|
89
|
+
query.value = ''
|
|
90
|
+
closeDropdown()
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function clearSelection() {
|
|
94
|
+
emit('update:modelValue', null)
|
|
95
|
+
selectedLabel.value = ''
|
|
96
|
+
query.value = ''
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
watch(
|
|
100
|
+
() => props.modelValue,
|
|
101
|
+
(value) => {
|
|
102
|
+
void resolveSelectedLabel(value)
|
|
103
|
+
},
|
|
104
|
+
{ immediate: true },
|
|
105
|
+
)
|
|
106
|
+
|
|
107
|
+
watch(query, (value) => {
|
|
108
|
+
if (!open.value) return
|
|
109
|
+
void loadOptions(value)
|
|
110
|
+
})
|
|
111
|
+
</script>
|
|
112
|
+
|
|
113
|
+
<template>
|
|
114
|
+
<label class="block">
|
|
115
|
+
<span class="text-sm text-zinc-600">{{ label }}</span>
|
|
116
|
+
<div class="relative mt-1" @blur="onBlur">
|
|
117
|
+
<div class="flex gap-2">
|
|
118
|
+
<button
|
|
119
|
+
type="button"
|
|
120
|
+
class="vulse-input flex flex-1 items-center justify-between bg-white text-left"
|
|
121
|
+
:class="open && 'border-zinc-400'"
|
|
122
|
+
@click="open ? closeDropdown() : openDropdown()"
|
|
123
|
+
>
|
|
124
|
+
<span :class="modelValue ? 'text-zinc-900' : 'text-zinc-400'">
|
|
125
|
+
{{ modelValue ? selectedLabel || modelValue : `Select ${refTarget}…` }}
|
|
126
|
+
</span>
|
|
127
|
+
<span class="text-xs text-zinc-400">{{ open ? '▴' : '▾' }}</span>
|
|
128
|
+
</button>
|
|
129
|
+
<button
|
|
130
|
+
v-if="modelValue"
|
|
131
|
+
type="button"
|
|
132
|
+
class="rounded border border-zinc-300 bg-white px-3 py-2 text-sm text-zinc-600 hover:bg-zinc-50"
|
|
133
|
+
@click="clearSelection"
|
|
134
|
+
>
|
|
135
|
+
Clear
|
|
136
|
+
</button>
|
|
137
|
+
</div>
|
|
138
|
+
|
|
139
|
+
<div
|
|
140
|
+
v-if="open"
|
|
141
|
+
class="absolute z-20 mt-1 w-full overflow-hidden rounded-md border border-zinc-200 bg-white shadow-lg"
|
|
142
|
+
>
|
|
143
|
+
<div class="border-b border-zinc-200 p-2">
|
|
144
|
+
<input
|
|
145
|
+
v-model="query"
|
|
146
|
+
type="search"
|
|
147
|
+
class="vulse-input bg-white"
|
|
148
|
+
:placeholder="`Search ${refTarget}…`"
|
|
149
|
+
autofocus
|
|
150
|
+
@keydown.esc.prevent="closeDropdown()"
|
|
151
|
+
/>
|
|
152
|
+
</div>
|
|
153
|
+
<ul class="max-h-48 overflow-auto py-1 text-sm">
|
|
154
|
+
<li v-if="loading" class="px-3 py-2 text-zinc-500">Loading…</li>
|
|
155
|
+
<li v-else-if="options.length === 0" class="px-3 py-2 text-zinc-500">No matches</li>
|
|
156
|
+
<li v-for="option in options" v-else :key="option.id">
|
|
157
|
+
<button
|
|
158
|
+
type="button"
|
|
159
|
+
class="flex w-full items-center px-3 py-2 text-left hover:bg-zinc-100"
|
|
160
|
+
:class="option.id === modelValue && 'bg-zinc-50 font-medium'"
|
|
161
|
+
@click="selectOption(option)"
|
|
162
|
+
>
|
|
163
|
+
{{ optionLabel(option) }}
|
|
164
|
+
</button>
|
|
165
|
+
</li>
|
|
166
|
+
</ul>
|
|
167
|
+
</div>
|
|
168
|
+
</div>
|
|
169
|
+
</label>
|
|
170
|
+
</template>
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import type { FieldDescriptor } from '../../client/form-from-zod'
|
|
3
|
+
import FieldRenderer from './FieldRenderer.vue'
|
|
4
|
+
const props = defineProps<{ modelValue: Record<string, unknown>[]; label: string; itemFields: FieldDescriptor[] }>()
|
|
5
|
+
const emit = defineEmits<{ (e: 'update:modelValue', v: Record<string, unknown>[]): void }>()
|
|
6
|
+
function update(i: number, key: string, v: unknown) {
|
|
7
|
+
const next = [...(props.modelValue ?? [])]
|
|
8
|
+
next[i] = { ...next[i], [key]: v }
|
|
9
|
+
emit('update:modelValue', next)
|
|
10
|
+
}
|
|
11
|
+
function add() { emit('update:modelValue', [...(props.modelValue ?? []), {}]) }
|
|
12
|
+
function remove(i: number) {
|
|
13
|
+
const next = [...(props.modelValue ?? [])]; next.splice(i, 1); emit('update:modelValue', next)
|
|
14
|
+
}
|
|
15
|
+
</script>
|
|
16
|
+
<template>
|
|
17
|
+
<div class="space-y-2">
|
|
18
|
+
<div class="text-sm text-zinc-600">{{ label }}</div>
|
|
19
|
+
<div v-for="(item, i) in modelValue ?? []" :key="i" class="border rounded p-3 space-y-2">
|
|
20
|
+
<FieldRenderer v-for="f in itemFields" :key="f.path"
|
|
21
|
+
:field="f" :model-value="item?.[f.path]"
|
|
22
|
+
@update:modelValue="update(i, f.path, $event)" />
|
|
23
|
+
<button type="button" @click="remove(i)" class="text-sm text-red-600">Remove</button>
|
|
24
|
+
</div>
|
|
25
|
+
<button type="button" @click="add" class="text-sm rounded border px-3 py-1">Add</button>
|
|
26
|
+
</div>
|
|
27
|
+
</template>
|