@plutocms/pluto 0.6.0 → 0.8.0
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/CHANGELOG.md +14 -0
- package/FEATURES.md +4 -1
- package/app/components/Pluto/Content/PlutoContentField.vue +34 -0
- package/app/components/Pluto/Content/PlutoContentForm.vue +222 -0
- package/app/components/Pluto/Content/PlutoContentList.vue +197 -0
- package/app/components/Pluto/Content/fields/PlutoFieldBoolean.vue +23 -0
- package/app/components/Pluto/Content/fields/PlutoFieldDate.vue +41 -0
- package/app/components/Pluto/Content/fields/PlutoFieldNumber.vue +30 -0
- package/app/components/Pluto/Content/fields/PlutoFieldReference.vue +79 -0
- package/app/components/Pluto/Content/fields/PlutoFieldSelect.vue +28 -0
- package/app/components/Pluto/Content/fields/PlutoFieldSlug.vue +33 -0
- package/app/components/Pluto/Content/fields/PlutoFieldText.vue +40 -0
- package/app/composables/pluto-admin-nav.ts +49 -9
- package/app/composables/pluto-admin-pages.ts +28 -3
- package/app/composables/pluto-content-item.ts +98 -0
- package/app/composables/pluto-content-list.ts +60 -0
- package/app/pages/admin/content/[type]/[id].vue +19 -0
- package/app/pages/admin/content/[type]/index.vue +19 -0
- package/app/pages/admin/content/[type]/new.vue +19 -0
- package/app/plugins/pluto-core-registrations.ts +32 -3
- package/package.json +2 -1
- package/server/api/_pluto/content/[type]/[id].delete.ts +15 -0
- package/server/api/_pluto/content/[type]/[id].get.ts +13 -0
- package/server/api/_pluto/content/[type]/[id].patch.ts +14 -0
- package/server/api/_pluto/content/[type]/index.get.ts +16 -0
- package/server/api/_pluto/content/[type]/index.post.ts +13 -0
- package/server/utils/pluto-content-handlers.ts +168 -0
- package/server/utils/pluto-content.ts +44 -0
- package/shared/types/content.ts +57 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,6 +1,20 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
3
|
|
|
4
|
+
## [0.8.0](https://github.com/plutocms/pluto/compare/v0.7.0...v0.8.0) (2026-09-13)
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
### Features
|
|
8
|
+
|
|
9
|
+
* **content:** add generated admin UI for content model (wave 3) ([#135](https://github.com/plutocms/pluto/issues/135)) ([b08c5c2](https://github.com/plutocms/pluto/commit/b08c5c28f58380c805d527c1f7a7c9d194fb08f9))
|
|
10
|
+
|
|
11
|
+
## [0.7.0](https://github.com/plutocms/pluto/compare/v0.6.0...v0.7.0) (2026-09-12)
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
### Features
|
|
15
|
+
|
|
16
|
+
* **content:** add server-side content registry and generic CRUD routes (wave 2) ([#133](https://github.com/plutocms/pluto/issues/133)) ([7846340](https://github.com/plutocms/pluto/commit/784634081f5a267fbe33dde8264e6b848bf7bdda))
|
|
17
|
+
|
|
4
18
|
## [0.6.0](https://github.com/plutocms/pluto/compare/v0.5.0...v0.6.0) (2026-09-12)
|
|
5
19
|
|
|
6
20
|
|
package/FEATURES.md
CHANGED
|
@@ -14,5 +14,8 @@ policies shared by every project here.
|
|
|
14
14
|
permissions driver. @.claude/skills/permissions/SKILL.md
|
|
15
15
|
- Content model: a typed content-type contract (`defineContentType`, field types, payload
|
|
16
16
|
validation, field-to-column mapping) and a media-adapter contract, with registry wiring and
|
|
17
|
-
consumer composables
|
|
17
|
+
consumer composables, a server-side content registry and generic list/get/create/update/
|
|
18
|
+
delete routes backed by a `PlutoContentAdapter` a backend layer implements, and a generated
|
|
19
|
+
admin UI (list/form components, eight built-in field widgets, generated
|
|
20
|
+
`/admin/content/**` pages, and nav/pages derivation). No real adapter yet.
|
|
18
21
|
@.claude/skills/content-model/SKILL.md
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
// Dispatches to whichever widget is registered for `field`, and re-emits
|
|
3
|
+
// `update:modelValue` unchanged. Never needs to know which concrete widget
|
|
4
|
+
// it dispatched to — every widget shares the same field/modelValue/disabled
|
|
5
|
+
// contract (see the content-model skill).
|
|
6
|
+
const props = defineProps<{
|
|
7
|
+
field: PlutoField
|
|
8
|
+
modelValue: unknown
|
|
9
|
+
disabled?: boolean
|
|
10
|
+
}>()
|
|
11
|
+
|
|
12
|
+
const emit = defineEmits<{
|
|
13
|
+
'update:modelValue': [value: unknown]
|
|
14
|
+
}>()
|
|
15
|
+
|
|
16
|
+
const widget = usePlutoContentFieldWidget(props.field)
|
|
17
|
+
</script>
|
|
18
|
+
|
|
19
|
+
<template>
|
|
20
|
+
<component
|
|
21
|
+
:is="widget"
|
|
22
|
+
v-if="widget"
|
|
23
|
+
:field="field"
|
|
24
|
+
:model-value="modelValue"
|
|
25
|
+
:disabled="disabled"
|
|
26
|
+
@update:model-value="emit('update:modelValue', $event)"
|
|
27
|
+
/>
|
|
28
|
+
|
|
29
|
+
<UFormField v-else :label="field.label">
|
|
30
|
+
<p class="text-muted text-sm">
|
|
31
|
+
No widget registered for field type "{{ field.type }}".
|
|
32
|
+
</p>
|
|
33
|
+
</UFormField>
|
|
34
|
+
</template>
|
|
@@ -0,0 +1,222 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
const props = defineProps<{
|
|
3
|
+
/** A content type's `name` (not a registry entry id) — see `usePlutoContentTypes`. */
|
|
4
|
+
type: string
|
|
5
|
+
/** Present in edit mode. Absent in "new entry" mode. */
|
|
6
|
+
id?: string | number
|
|
7
|
+
}>()
|
|
8
|
+
|
|
9
|
+
const { byName } = usePlutoContentTypes()
|
|
10
|
+
const contentType = computed(() => byName(props.type))
|
|
11
|
+
|
|
12
|
+
if (!contentType.value) {
|
|
13
|
+
// The generated page (`app/pages/admin/content/[type]/{new,[id]}.vue`)
|
|
14
|
+
// already resolves the type and 404s before this component ever mounts,
|
|
15
|
+
// so this should not normally happen.
|
|
16
|
+
console.error(`PlutoContentForm: content type "${props.type}" was not found in the registry.`)
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
const { can } = usePlutoPermissions()
|
|
20
|
+
|
|
21
|
+
// "No capability declared" means the operation is open to anyone who can
|
|
22
|
+
// reach this page — never call can('') as a stand-in for that.
|
|
23
|
+
const canWrite = computed(
|
|
24
|
+
() => !contentType.value?.capabilities?.write || can(contentType.value.capabilities.write)
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
const editing = computed(() => props.id !== undefined)
|
|
28
|
+
const basePath = computed(() => contentType.value?.basePath ?? `/admin/content/${props.type}`)
|
|
29
|
+
|
|
30
|
+
const { item, save } = usePlutoContentItem(props.type, () => props.id)
|
|
31
|
+
|
|
32
|
+
function sortedFields(region: 'main' | 'side') {
|
|
33
|
+
return computed(() =>
|
|
34
|
+
(contentType.value?.fields ?? [])
|
|
35
|
+
.filter((field) => field.inForm !== false && (field.region ?? 'main') === region)
|
|
36
|
+
.sort((a, b) => (a.order ?? 100) - (b.order ?? 100))
|
|
37
|
+
)
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const mainFields = sortedFields('main')
|
|
41
|
+
const sideFields = sortedFields('side')
|
|
42
|
+
|
|
43
|
+
// --- Slug auto-derivation, with the PostForm.vue bug fixed -----------------
|
|
44
|
+
//
|
|
45
|
+
// PostForm.vue re-derives its slug from the title on every keystroke, with
|
|
46
|
+
// no gate at all — including while editing an already-published,
|
|
47
|
+
// already-shared post, silently changing a live URL. Auto-derivation here
|
|
48
|
+
// stops the moment either:
|
|
49
|
+
//
|
|
50
|
+
// 1. The form is in edit mode (`editing` is true) — an existing entry's
|
|
51
|
+
// slug is presumed already meaningful, and never auto-changes without
|
|
52
|
+
// the user directly touching the slug field.
|
|
53
|
+
// 2. The user has typed into the slug field directly, even while creating
|
|
54
|
+
// a new entry — tracked by `slugTouched`, flipped by `updateField` below
|
|
55
|
+
// the moment a 'slug' field's own `update:modelValue` fires.
|
|
56
|
+
const slugTouched = ref(false)
|
|
57
|
+
const slugField = computed(() =>
|
|
58
|
+
contentType.value?.fields.find((field): field is PlutoSlugField => field.type === 'slug')
|
|
59
|
+
)
|
|
60
|
+
|
|
61
|
+
function slugify(value: string): string {
|
|
62
|
+
return value
|
|
63
|
+
.toLowerCase()
|
|
64
|
+
.trim()
|
|
65
|
+
.replace(/[^a-z0-9]+/g, '-')
|
|
66
|
+
.replace(/(^-|-$)/g, '')
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
watch(
|
|
70
|
+
() => {
|
|
71
|
+
const from = slugField.value?.from
|
|
72
|
+
return from ? item.value[from] : undefined
|
|
73
|
+
},
|
|
74
|
+
(value) => {
|
|
75
|
+
if (!slugField.value || editing.value || slugTouched.value) {
|
|
76
|
+
return
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
item.value[slugField.value.name] = slugify(String(value ?? ''))
|
|
80
|
+
}
|
|
81
|
+
)
|
|
82
|
+
|
|
83
|
+
function updateField(field: PlutoField, value: unknown) {
|
|
84
|
+
if (field.type === 'slug') {
|
|
85
|
+
slugTouched.value = true
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
item.value[field.name] = value
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// --- Save -------------------------------------------------------------------
|
|
92
|
+
|
|
93
|
+
const toast = useToast()
|
|
94
|
+
const saving = ref(false)
|
|
95
|
+
const validationErrors = ref<ContentValidationError[]>([])
|
|
96
|
+
|
|
97
|
+
function buildPayload(): Record<string, unknown> {
|
|
98
|
+
const type = contentType.value
|
|
99
|
+
if (!type) {
|
|
100
|
+
return {}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
const payload: Record<string, unknown> = {}
|
|
104
|
+
for (const field of type.fields) {
|
|
105
|
+
if (Object.hasOwn(item.value, field.name)) {
|
|
106
|
+
payload[field.name] = item.value[field.name]
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
return payload
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
async function onSave() {
|
|
114
|
+
const type = contentType.value
|
|
115
|
+
if (!type) {
|
|
116
|
+
return
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
const payload = buildPayload()
|
|
120
|
+
const errors = validateContentPayload(type, payload, { partial: editing.value })
|
|
121
|
+
|
|
122
|
+
if (errors.length > 0) {
|
|
123
|
+
validationErrors.value = errors
|
|
124
|
+
return
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
validationErrors.value = []
|
|
128
|
+
saving.value = true
|
|
129
|
+
|
|
130
|
+
try {
|
|
131
|
+
const saved = await save(payload)
|
|
132
|
+
|
|
133
|
+
if (!editing.value) {
|
|
134
|
+
await navigateTo(`${basePath.value}/${saved.id}`)
|
|
135
|
+
return
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
toast.add({
|
|
139
|
+
title: 'Saved',
|
|
140
|
+
description: `${type.label} updated successfully.`,
|
|
141
|
+
color: 'success',
|
|
142
|
+
})
|
|
143
|
+
} catch {
|
|
144
|
+
// A raced permissions check, or any other server-side rejection
|
|
145
|
+
// (a 403 included), surfaces here rather than as an unhandled
|
|
146
|
+
// rejection.
|
|
147
|
+
toast.add({
|
|
148
|
+
title: 'Error',
|
|
149
|
+
description: 'An error occurred while saving.',
|
|
150
|
+
color: 'error',
|
|
151
|
+
})
|
|
152
|
+
} finally {
|
|
153
|
+
saving.value = false
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
</script>
|
|
157
|
+
|
|
158
|
+
<template>
|
|
159
|
+
<div v-if="contentType">
|
|
160
|
+
<AdminView>
|
|
161
|
+
<UAlert
|
|
162
|
+
v-if="validationErrors.length > 0"
|
|
163
|
+
color="error"
|
|
164
|
+
title="Please fix the following before saving"
|
|
165
|
+
>
|
|
166
|
+
<template #description>
|
|
167
|
+
<ul class="list-disc pl-4">
|
|
168
|
+
<li v-for="fieldError in validationErrors" :key="fieldError.field">
|
|
169
|
+
{{ fieldError.message }}
|
|
170
|
+
</li>
|
|
171
|
+
</ul>
|
|
172
|
+
</template>
|
|
173
|
+
</UAlert>
|
|
174
|
+
|
|
175
|
+
<div class="flex flex-col gap-6 lg:flex-row lg:items-start lg:gap-10">
|
|
176
|
+
<div class="flex flex-1 flex-col gap-6">
|
|
177
|
+
<h1 class="text-3xl font-bold lg:text-4xl">
|
|
178
|
+
{{ editing ? `Edit ${contentType.label}` : `New ${contentType.label}` }}
|
|
179
|
+
</h1>
|
|
180
|
+
|
|
181
|
+
<PlutoContentField
|
|
182
|
+
v-for="field in mainFields"
|
|
183
|
+
:key="field.name"
|
|
184
|
+
:field="field"
|
|
185
|
+
:model-value="item[field.name]"
|
|
186
|
+
:disabled="!canWrite || saving"
|
|
187
|
+
@update:model-value="updateField(field, $event)"
|
|
188
|
+
/>
|
|
189
|
+
</div>
|
|
190
|
+
|
|
191
|
+
<div class="flex w-full shrink-0 flex-col gap-6 lg:max-w-xs">
|
|
192
|
+
<UButton
|
|
193
|
+
v-if="canWrite"
|
|
194
|
+
:loading="saving"
|
|
195
|
+
icon="lucide:save"
|
|
196
|
+
class="justify-center"
|
|
197
|
+
@click="onSave"
|
|
198
|
+
>
|
|
199
|
+
Save
|
|
200
|
+
</UButton>
|
|
201
|
+
|
|
202
|
+
<PlutoContentField
|
|
203
|
+
v-for="field in sideFields"
|
|
204
|
+
:key="field.name"
|
|
205
|
+
:field="field"
|
|
206
|
+
:model-value="item[field.name]"
|
|
207
|
+
:disabled="!canWrite || saving"
|
|
208
|
+
@update:model-value="updateField(field, $event)"
|
|
209
|
+
/>
|
|
210
|
+
</div>
|
|
211
|
+
</div>
|
|
212
|
+
</AdminView>
|
|
213
|
+
</div>
|
|
214
|
+
|
|
215
|
+
<AdminView v-else>
|
|
216
|
+
<UAlert
|
|
217
|
+
:description="`No content type named "${type}" is registered.`"
|
|
218
|
+
color="error"
|
|
219
|
+
title="Content type not found"
|
|
220
|
+
/>
|
|
221
|
+
</AdminView>
|
|
222
|
+
</template>
|
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import type { TableColumn } from '@nuxt/ui'
|
|
3
|
+
|
|
4
|
+
const props = defineProps<{
|
|
5
|
+
/** A content type's `name` (not a registry entry id) — see `usePlutoContentTypes`. */
|
|
6
|
+
type: string
|
|
7
|
+
}>()
|
|
8
|
+
|
|
9
|
+
const { byName } = usePlutoContentTypes()
|
|
10
|
+
const contentType = computed(() => byName(props.type))
|
|
11
|
+
|
|
12
|
+
if (!contentType.value) {
|
|
13
|
+
// The generated page (`app/pages/admin/content/[type]/index.vue`) already
|
|
14
|
+
// resolves the type and 404s before this component ever mounts, so this
|
|
15
|
+
// should not normally happen.
|
|
16
|
+
console.error(`PlutoContentList: content type "${props.type}" was not found in the registry.`)
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
const { can } = usePlutoPermissions()
|
|
20
|
+
|
|
21
|
+
// "No capability declared" means the operation is open to anyone who can
|
|
22
|
+
// reach this page — never call can('') as a stand-in for that.
|
|
23
|
+
const canWrite = computed(
|
|
24
|
+
() => !contentType.value?.capabilities?.write || can(contentType.value.capabilities.write)
|
|
25
|
+
)
|
|
26
|
+
const canDelete = computed(
|
|
27
|
+
() => !contentType.value?.capabilities?.delete || can(contentType.value.capabilities.delete)
|
|
28
|
+
)
|
|
29
|
+
|
|
30
|
+
const basePath = computed(() => contentType.value?.basePath ?? `/admin/content/${props.type}`)
|
|
31
|
+
|
|
32
|
+
const { items, pending, refresh, remove } = usePlutoContentList(props.type)
|
|
33
|
+
|
|
34
|
+
const listFields = computed(() =>
|
|
35
|
+
(contentType.value?.fields ?? [])
|
|
36
|
+
.filter((field) => field.inList)
|
|
37
|
+
.sort((a, b) => (a.order ?? 100) - (b.order ?? 100))
|
|
38
|
+
)
|
|
39
|
+
|
|
40
|
+
const columns = computed<TableColumn<PlutoContentItem>[]>(() => [
|
|
41
|
+
...listFields.value.map((field) => ({ accessorKey: field.name, header: field.label })),
|
|
42
|
+
{ id: 'actions', header: '' },
|
|
43
|
+
])
|
|
44
|
+
|
|
45
|
+
const pendingDeleteId = ref<string | number | null>(null)
|
|
46
|
+
const isDeleteModalOpen = ref(false)
|
|
47
|
+
|
|
48
|
+
function openDeleteModal(id: string | number) {
|
|
49
|
+
pendingDeleteId.value = id
|
|
50
|
+
isDeleteModalOpen.value = true
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function closeDeleteModal() {
|
|
54
|
+
pendingDeleteId.value = null
|
|
55
|
+
isDeleteModalOpen.value = false
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
async function confirmDelete() {
|
|
59
|
+
if (pendingDeleteId.value === null) {
|
|
60
|
+
return
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
await remove(pendingDeleteId.value)
|
|
64
|
+
closeDeleteModal()
|
|
65
|
+
}
|
|
66
|
+
</script>
|
|
67
|
+
|
|
68
|
+
<template>
|
|
69
|
+
<div v-if="contentType">
|
|
70
|
+
<Modal v-model="isDeleteModalOpen" :custom-size="480">
|
|
71
|
+
<ModalHeader @close="closeDeleteModal">Remove item</ModalHeader>
|
|
72
|
+
|
|
73
|
+
<ModalContent>
|
|
74
|
+
<p>Do you really want to remove this item?</p>
|
|
75
|
+
</ModalContent>
|
|
76
|
+
|
|
77
|
+
<ModalFooter>
|
|
78
|
+
<div class="flex items-center gap-4">
|
|
79
|
+
<UButton icon="lucide:x" variant="ghost" color="neutral" @click="closeDeleteModal">
|
|
80
|
+
Cancel
|
|
81
|
+
</UButton>
|
|
82
|
+
|
|
83
|
+
<UButton icon="lucide:trash" color="error" @click="confirmDelete">Remove</UButton>
|
|
84
|
+
</div>
|
|
85
|
+
</ModalFooter>
|
|
86
|
+
</Modal>
|
|
87
|
+
|
|
88
|
+
<AdminView>
|
|
89
|
+
<div class="flex flex-col gap-4 lg:flex-row lg:items-center lg:justify-between">
|
|
90
|
+
<hgroup class="flex items-center justify-between gap-x-3 lg:justify-start">
|
|
91
|
+
<h1 class="text-3xl font-bold lg:text-4xl">{{ contentType.labelPlural }}</h1>
|
|
92
|
+
|
|
93
|
+
<UButton
|
|
94
|
+
:loading="pending"
|
|
95
|
+
icon="lucide:refresh-ccw"
|
|
96
|
+
variant="ghost"
|
|
97
|
+
title="Refresh"
|
|
98
|
+
square
|
|
99
|
+
@click="refresh()"
|
|
100
|
+
/>
|
|
101
|
+
</hgroup>
|
|
102
|
+
|
|
103
|
+
<div v-if="canWrite" class="flex lg:shrink-0">
|
|
104
|
+
<UButton
|
|
105
|
+
:to="`${basePath}/new`"
|
|
106
|
+
icon="lucide:plus"
|
|
107
|
+
as="NuxtLink"
|
|
108
|
+
class="flex-1 justify-center lg:flex-none"
|
|
109
|
+
>
|
|
110
|
+
Add {{ contentType.labelPlural }}
|
|
111
|
+
</UButton>
|
|
112
|
+
</div>
|
|
113
|
+
</div>
|
|
114
|
+
|
|
115
|
+
<div class="grid gap-3 lg:hidden">
|
|
116
|
+
<UCard v-for="item in items" :key="item.id">
|
|
117
|
+
<div class="flex flex-col gap-4">
|
|
118
|
+
<div class="min-w-0">
|
|
119
|
+
<NuxtLink
|
|
120
|
+
:to="`${basePath}/${item.id}`"
|
|
121
|
+
class="block truncate text-lg font-semibold hover:underline"
|
|
122
|
+
>
|
|
123
|
+
{{ item[contentType.titleField] }}
|
|
124
|
+
</NuxtLink>
|
|
125
|
+
</div>
|
|
126
|
+
|
|
127
|
+
<dl class="grid grid-cols-2 gap-3 text-sm">
|
|
128
|
+
<div v-for="field in listFields" :key="field.name">
|
|
129
|
+
<dt class="text-muted">{{ field.label }}</dt>
|
|
130
|
+
<dd class="font-medium">{{ item[field.name] }}</dd>
|
|
131
|
+
</div>
|
|
132
|
+
</dl>
|
|
133
|
+
|
|
134
|
+
<div class="flex gap-2 border-t border-default pt-3">
|
|
135
|
+
<UButton
|
|
136
|
+
v-if="canWrite"
|
|
137
|
+
:to="`${basePath}/${item.id}`"
|
|
138
|
+
icon="lucide:pen-line"
|
|
139
|
+
color="neutral"
|
|
140
|
+
variant="soft"
|
|
141
|
+
class="flex-1 justify-center"
|
|
142
|
+
>
|
|
143
|
+
Edit
|
|
144
|
+
</UButton>
|
|
145
|
+
|
|
146
|
+
<UButton
|
|
147
|
+
v-if="canDelete"
|
|
148
|
+
icon="lucide:trash"
|
|
149
|
+
color="error"
|
|
150
|
+
variant="soft"
|
|
151
|
+
class="flex-1 justify-center"
|
|
152
|
+
@click="openDeleteModal(item.id)"
|
|
153
|
+
>
|
|
154
|
+
Remove
|
|
155
|
+
</UButton>
|
|
156
|
+
</div>
|
|
157
|
+
</div>
|
|
158
|
+
</UCard>
|
|
159
|
+
</div>
|
|
160
|
+
|
|
161
|
+
<UCard :ui="{ body: 'sm:p-0 p-0' }" class="hidden lg:block">
|
|
162
|
+
<div class="overflow-x-auto">
|
|
163
|
+
<UTable :data="items" :columns="columns" :loading="pending" empty="No items yet.">
|
|
164
|
+
<template #actions-cell="{ row }">
|
|
165
|
+
<div class="flex justify-end gap-3">
|
|
166
|
+
<NuxtLink
|
|
167
|
+
v-if="canWrite"
|
|
168
|
+
:to="`${basePath}/${row.original.id}`"
|
|
169
|
+
class="text-info px-0 py-0.5 hover:underline"
|
|
170
|
+
>
|
|
171
|
+
Edit
|
|
172
|
+
</NuxtLink>
|
|
173
|
+
|
|
174
|
+
<button
|
|
175
|
+
v-if="canDelete"
|
|
176
|
+
type="button"
|
|
177
|
+
class="text-error cursor-pointer px-0 py-0.5 hover:underline"
|
|
178
|
+
@click="openDeleteModal(row.original.id)"
|
|
179
|
+
>
|
|
180
|
+
Remove
|
|
181
|
+
</button>
|
|
182
|
+
</div>
|
|
183
|
+
</template>
|
|
184
|
+
</UTable>
|
|
185
|
+
</div>
|
|
186
|
+
</UCard>
|
|
187
|
+
</AdminView>
|
|
188
|
+
</div>
|
|
189
|
+
|
|
190
|
+
<AdminView v-else>
|
|
191
|
+
<UAlert
|
|
192
|
+
:description="`No content type named "${type}" is registered.`"
|
|
193
|
+
color="error"
|
|
194
|
+
title="Content type not found"
|
|
195
|
+
/>
|
|
196
|
+
</AdminView>
|
|
197
|
+
</template>
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
const props = defineProps<{
|
|
3
|
+
field: PlutoBooleanField
|
|
4
|
+
modelValue: unknown
|
|
5
|
+
disabled?: boolean
|
|
6
|
+
}>()
|
|
7
|
+
|
|
8
|
+
const emit = defineEmits<{
|
|
9
|
+
'update:modelValue': [value: boolean]
|
|
10
|
+
}>()
|
|
11
|
+
|
|
12
|
+
const value = computed(() => props.modelValue === true)
|
|
13
|
+
</script>
|
|
14
|
+
|
|
15
|
+
<template>
|
|
16
|
+
<UFormField :label="field.label" :description="field.description">
|
|
17
|
+
<USwitch
|
|
18
|
+
:model-value="value"
|
|
19
|
+
:disabled="disabled"
|
|
20
|
+
@update:model-value="emit('update:modelValue', $event)"
|
|
21
|
+
/>
|
|
22
|
+
</UFormField>
|
|
23
|
+
</template>
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
// UInputDate's modelValue is a `DateValue` (from `@internationalized/date`),
|
|
3
|
+
// not a plain string. A content item stores a 'date' field as a plain ISO
|
|
4
|
+
// date string ('YYYY-MM-DD'), so this widget converts both ways at its own
|
|
5
|
+
// boundary — the rest of the content model never sees a `DateValue`.
|
|
6
|
+
import type { CalendarDate, DateValue } from '@internationalized/date'
|
|
7
|
+
import { parseDate } from '@internationalized/date'
|
|
8
|
+
|
|
9
|
+
const props = defineProps<{
|
|
10
|
+
field: PlutoDateField
|
|
11
|
+
modelValue: unknown
|
|
12
|
+
disabled?: boolean
|
|
13
|
+
}>()
|
|
14
|
+
|
|
15
|
+
const emit = defineEmits<{
|
|
16
|
+
'update:modelValue': [value: string | undefined]
|
|
17
|
+
}>()
|
|
18
|
+
|
|
19
|
+
function toCalendarDate(value: unknown): CalendarDate | undefined {
|
|
20
|
+
if (typeof value !== 'string' || value === '') {
|
|
21
|
+
return undefined
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
try {
|
|
25
|
+
return parseDate(value.slice(0, 10))
|
|
26
|
+
} catch {
|
|
27
|
+
return undefined
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const value = computed<DateValue | undefined>({
|
|
32
|
+
get: () => toCalendarDate(props.modelValue),
|
|
33
|
+
set: (next) => emit('update:modelValue', next ? next.toString() : undefined),
|
|
34
|
+
})
|
|
35
|
+
</script>
|
|
36
|
+
|
|
37
|
+
<template>
|
|
38
|
+
<UFormField :label="field.label" :description="field.description" :required="field.required">
|
|
39
|
+
<UInputDate v-model="value" :disabled="disabled" />
|
|
40
|
+
</UFormField>
|
|
41
|
+
</template>
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
const props = defineProps<{
|
|
3
|
+
field: PlutoNumberField
|
|
4
|
+
modelValue: unknown
|
|
5
|
+
disabled?: boolean
|
|
6
|
+
}>()
|
|
7
|
+
|
|
8
|
+
const emit = defineEmits<{
|
|
9
|
+
'update:modelValue': [value: number | null]
|
|
10
|
+
}>()
|
|
11
|
+
|
|
12
|
+
const value = computed(() => {
|
|
13
|
+
const raw = props.modelValue
|
|
14
|
+
return typeof raw === 'number' ? raw : null
|
|
15
|
+
})
|
|
16
|
+
</script>
|
|
17
|
+
|
|
18
|
+
<template>
|
|
19
|
+
<UFormField :label="field.label" :description="field.description" :required="field.required">
|
|
20
|
+
<UInputNumber
|
|
21
|
+
:model-value="value"
|
|
22
|
+
:min="field.min"
|
|
23
|
+
:max="field.max"
|
|
24
|
+
:step="field.integer ? 1 : undefined"
|
|
25
|
+
:disabled="disabled"
|
|
26
|
+
class="w-full"
|
|
27
|
+
@update:model-value="emit('update:modelValue', $event)"
|
|
28
|
+
/>
|
|
29
|
+
</UFormField>
|
|
30
|
+
</template>
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
// Only `field.optionsUrl` is resolved in this wave. `field.target` (a
|
|
3
|
+
// same-app content-type reference with no `optionsUrl`) needs the generic
|
|
4
|
+
// list endpoint of *another* content type, which is out of scope here — see
|
|
5
|
+
// the content-model skill.
|
|
6
|
+
const props = defineProps<{
|
|
7
|
+
field: PlutoReferenceField
|
|
8
|
+
modelValue: unknown
|
|
9
|
+
disabled?: boolean
|
|
10
|
+
}>()
|
|
11
|
+
|
|
12
|
+
defineEmits<{
|
|
13
|
+
'update:modelValue': [value: string | number]
|
|
14
|
+
}>()
|
|
15
|
+
|
|
16
|
+
interface ReferenceOption {
|
|
17
|
+
label: string
|
|
18
|
+
value: string | number
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
interface ReferenceOptionsResponse {
|
|
22
|
+
data: Array<Record<string, unknown>>
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const options = ref<ReferenceOption[]>([])
|
|
26
|
+
const pending = ref(false)
|
|
27
|
+
|
|
28
|
+
async function loadOptions() {
|
|
29
|
+
if (!props.field.optionsUrl) {
|
|
30
|
+
return
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
pending.value = true
|
|
34
|
+
|
|
35
|
+
try {
|
|
36
|
+
const result = await $fetch<ReferenceOptionsResponse>(props.field.optionsUrl)
|
|
37
|
+
const labelKey = props.field.labelKey ?? 'label'
|
|
38
|
+
const valueKey = props.field.valueKey ?? 'id'
|
|
39
|
+
|
|
40
|
+
options.value = result.data.map((row) => ({
|
|
41
|
+
label: String(row[labelKey] ?? ''),
|
|
42
|
+
value: row[valueKey] as string | number,
|
|
43
|
+
}))
|
|
44
|
+
} finally {
|
|
45
|
+
pending.value = false
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
onMounted(loadOptions)
|
|
50
|
+
|
|
51
|
+
const hasOptionsSource = computed(() => Boolean(props.field.optionsUrl))
|
|
52
|
+
const value = computed(() => props.modelValue as string | number | undefined)
|
|
53
|
+
</script>
|
|
54
|
+
|
|
55
|
+
<template>
|
|
56
|
+
<UFormField :label="field.label" :description="field.description" :required="field.required">
|
|
57
|
+
<UInputMenu
|
|
58
|
+
v-if="hasOptionsSource"
|
|
59
|
+
:model-value="value"
|
|
60
|
+
:items="options"
|
|
61
|
+
:loading="pending"
|
|
62
|
+
:disabled="disabled"
|
|
63
|
+
value-key="value"
|
|
64
|
+
label-key="label"
|
|
65
|
+
class="w-full"
|
|
66
|
+
@update:model-value="$emit('update:modelValue', $event)"
|
|
67
|
+
/>
|
|
68
|
+
|
|
69
|
+
<UInputMenu
|
|
70
|
+
v-else
|
|
71
|
+
:items="[]"
|
|
72
|
+
value-key="value"
|
|
73
|
+
label-key="label"
|
|
74
|
+
placeholder="No options source configured for this reference field."
|
|
75
|
+
class="w-full"
|
|
76
|
+
disabled
|
|
77
|
+
/>
|
|
78
|
+
</UFormField>
|
|
79
|
+
</template>
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
const props = defineProps<{
|
|
3
|
+
field: PlutoSelectField
|
|
4
|
+
modelValue: unknown
|
|
5
|
+
disabled?: boolean
|
|
6
|
+
}>()
|
|
7
|
+
|
|
8
|
+
defineEmits<{
|
|
9
|
+
'update:modelValue': [value: string | number]
|
|
10
|
+
}>()
|
|
11
|
+
|
|
12
|
+
const items = computed(() =>
|
|
13
|
+
props.field.options.map((option) => ({ label: option.label, value: option.value }))
|
|
14
|
+
)
|
|
15
|
+
const value = computed(() => props.modelValue as string | number | undefined)
|
|
16
|
+
</script>
|
|
17
|
+
|
|
18
|
+
<template>
|
|
19
|
+
<UFormField :label="field.label" :description="field.description" :required="field.required">
|
|
20
|
+
<USelect
|
|
21
|
+
:model-value="value"
|
|
22
|
+
:items="items"
|
|
23
|
+
:disabled="disabled"
|
|
24
|
+
class="w-full"
|
|
25
|
+
@update:model-value="$emit('update:modelValue', $event)"
|
|
26
|
+
/>
|
|
27
|
+
</UFormField>
|
|
28
|
+
</template>
|