@plutocms/pluto 0.7.0 → 0.8.1

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 CHANGED
@@ -1,6 +1,20 @@
1
1
  # Changelog
2
2
 
3
3
 
4
+ ## [0.8.1](https://github.com/plutocms/pluto/compare/v0.8.0...v0.8.1) (2026-09-13)
5
+
6
+
7
+ ### Bug Fixes
8
+
9
+ * **dashboard:** move PlutoDashboardWidgets out of a mismatched subfolder ([#137](https://github.com/plutocms/pluto/issues/137)) ([bd4d3bf](https://github.com/plutocms/pluto/commit/bd4d3bfece1e9ac6680595ea4c6c471b50370bd2))
10
+
11
+ ## [0.8.0](https://github.com/plutocms/pluto/compare/v0.7.0...v0.8.0) (2026-09-13)
12
+
13
+
14
+ ### Features
15
+
16
+ * **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))
17
+
4
18
  ## [0.7.0](https://github.com/plutocms/pluto/compare/v0.6.0...v0.7.0) (2026-09-12)
5
19
 
6
20
 
package/FEATURES.md CHANGED
@@ -14,6 +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, plus a server-side content registry and generic list/get/create/update/
18
- delete routes backed by a `PlutoContentAdapter` a backend layer implements. No real adapter or
19
- generated UI yet. @.claude/skills/content-model/SKILL.md
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.
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 &quot;${type}&quot; 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 &quot;${type}&quot; 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>
@@ -0,0 +1,33 @@
1
+ <script setup lang="ts">
2
+ // A plain text input plus a preview line. This widget does not auto-derive
3
+ // the slug from another field — only `PlutoContentForm` knows about the
4
+ // *other* field (`field.from`) a slug derives from, so that logic lives
5
+ // there, not here. See the content-model skill for the slug bug this
6
+ // separation is meant to avoid repeating.
7
+ const props = defineProps<{
8
+ field: PlutoSlugField
9
+ modelValue: unknown
10
+ disabled?: boolean
11
+ }>()
12
+
13
+ const emit = defineEmits<{
14
+ 'update:modelValue': [value: string]
15
+ }>()
16
+
17
+ const value = computed(() => (props.modelValue as string | undefined) ?? '')
18
+ </script>
19
+
20
+ <template>
21
+ <UFormField :label="field.label" :description="field.description" :required="field.required">
22
+ <UInput
23
+ :model-value="value"
24
+ :disabled="disabled"
25
+ class="w-full"
26
+ @update:model-value="emit('update:modelValue', $event)"
27
+ />
28
+
29
+ <p v-if="field.preview" class="text-muted mt-1 text-sm">
30
+ {{ field.preview }}{{ value }}
31
+ </p>
32
+ </UFormField>
33
+ </template>
@@ -0,0 +1,40 @@
1
+ <script setup lang="ts">
2
+ // Renders both 'text' and 'textarea' fields. `richtext` has no core widget
3
+ // — a layer (for example @plutocms/supabase-blog) registers its own.
4
+ const props = defineProps<{
5
+ field: PlutoTextField
6
+ modelValue: unknown
7
+ disabled?: boolean
8
+ }>()
9
+
10
+ defineEmits<{
11
+ 'update:modelValue': [value: string]
12
+ }>()
13
+
14
+ const isTextarea = computed(() => props.field.type === 'textarea')
15
+ const value = computed(() => (props.modelValue as string | undefined) ?? undefined)
16
+ </script>
17
+
18
+ <template>
19
+ <UFormField :label="field.label" :description="field.description" :required="field.required">
20
+ <UTextarea
21
+ v-if="isTextarea"
22
+ :model-value="value"
23
+ :placeholder="field.placeholder"
24
+ :maxlength="field.maxLength"
25
+ :disabled="disabled"
26
+ class="w-full"
27
+ @update:model-value="$emit('update:modelValue', $event)"
28
+ />
29
+
30
+ <UInput
31
+ v-else
32
+ :model-value="value"
33
+ :placeholder="field.placeholder"
34
+ :maxlength="field.maxLength"
35
+ :disabled="disabled"
36
+ class="w-full"
37
+ @update:model-value="$emit('update:modelValue', $event)"
38
+ />
39
+ </UFormField>
40
+ </template>
@@ -6,21 +6,41 @@ interface LegacyHrefLink {
6
6
  }
7
7
 
8
8
  /**
9
- * The admin sidebar nav. Merges the new registry with the old
10
- * `useSidebarAdminActions` composable (`@plutocms/utils`), so an unmigrated
11
- * layer's sidebar entries keep showing up. A bridged entry is skipped when a
12
- * registered entry already uses the same label, so a layer that migrates
13
- * does not get a duplicate item.
9
+ * The admin sidebar nav. Merges three sources:
10
+ *
11
+ * 1. The registry's own `nav` bucket — a layer's own `definePlutoExtension`
12
+ * call.
13
+ * 2. The old `useSidebarAdminActions` composable (`@plutocms/utils`), so an
14
+ * unmigrated layer's sidebar entries keep showing up. A bridged entry is
15
+ * skipped when a registered entry already uses the same label, so a
16
+ * layer that migrates does not get a duplicate item.
17
+ * 3. One synthesized entry per auto-routed content type (`type.autoRoutes
18
+ * !== false`) — a layer that declares a content type gets a nav entry
19
+ * for free, without calling `definePlutoExtension` itself. Gated on the
20
+ * same "no capability declared = always visible" rule the generic
21
+ * content routes already use server-side (see the content-model skill):
22
+ * when `type.capabilities.read` is unset, the entry is always visible;
23
+ * when it is set, `can(...)` decides.
24
+ *
25
+ * `entry.enabled` is read and enforced uniformly across all three sources
26
+ * here. Source 1 already filters on it a second time, inside
27
+ * `createOwnedRegistry` itself (see
28
+ * `app/composables/internal/registry-factory.ts`) — applying the same
29
+ * filter again here is harmless for that source, and is what makes sources
30
+ * 2 and 3 (plain objects that never pass through that registry) respect
31
+ * `enabled` too, instead of only source 1.
14
32
  */
15
33
  export function usePlutoAdminNav() {
16
34
  const registry = usePlutoRegistry()
17
35
  const legacy = useSidebarAdminActions()
36
+ const { items: contentTypes } = usePlutoContentTypes()
37
+ const { can } = usePlutoPermissions()
18
38
 
19
39
  const items = computed<PlutoNavItem[]>(() => {
20
40
  const registered = registry.nav.list.value
21
41
  const seen = new Set(registered.map((item) => item.label))
22
42
 
23
- const bridged = legacy.actions.value
43
+ const bridged: PlutoNavItem[] = legacy.actions.value
24
44
  .filter((action) => !seen.has(String(action.label)))
25
45
  .map((action, index) => ({
26
46
  id: `legacy:sidebar-${index}`,
@@ -35,9 +55,29 @@ export function usePlutoAdminNav() {
35
55
  })),
36
56
  }))
37
57
 
38
- return [...registered, ...bridged].sort(
39
- (a, b) => (a.order ?? 100) - (b.order ?? 100)
40
- )
58
+ const contentNav: PlutoNavItem[] = contentTypes.value
59
+ .filter((entry) => entry.type.autoRoutes !== false)
60
+ .map((entry) => {
61
+ const type = entry.type
62
+ const basePath = type.basePath ?? `/admin/content/${type.name}`
63
+
64
+ return {
65
+ id: `content:${type.name}`,
66
+ order: type.navOrder ?? 100,
67
+ label: type.labelPlural,
68
+ icon: type.icon,
69
+ to: basePath,
70
+ enabled: () => !type.capabilities?.read || can(type.capabilities.read),
71
+ children: [
72
+ { label: `All ${type.labelPlural}`, to: basePath },
73
+ { label: `Create new ${type.label}`, to: `${basePath}/new` },
74
+ ],
75
+ }
76
+ })
77
+
78
+ return [...registered, ...bridged, ...contentNav]
79
+ .filter((item) => item.enabled?.() ?? true)
80
+ .sort((a, b) => (a.order ?? 100) - (b.order ?? 100))
41
81
  })
42
82
 
43
83
  return { items }
@@ -1,13 +1,38 @@
1
1
  /**
2
2
  * Metadata for admin pages, registered on top of routes Nuxt already
3
- * creates from files. No layer registers page metadata today, so this has
4
- * no legacy bridge.
3
+ * creates from files, plus two synthesized entries per auto-routed content
4
+ * type (`type.autoRoutes !== false`): a list page and a "new" page — the
5
+ * page-metadata equivalent of the nav entry `usePlutoAdminNav` synthesizes
6
+ * for the same content type. No layer registers page metadata today, so
7
+ * the registry bucket itself has no legacy bridge.
5
8
  */
6
9
  export function usePlutoAdminPages() {
7
10
  const registry = usePlutoRegistry()
8
11
  const route = useRoute()
12
+ const { items: contentTypes } = usePlutoContentTypes()
13
+
14
+ const items = computed<PlutoAdminPage[]>(() => {
15
+ const contentPages: PlutoAdminPage[] = contentTypes.value
16
+ .filter((entry) => entry.type.autoRoutes !== false)
17
+ .flatMap((entry) => {
18
+ const type = entry.type
19
+ const basePath = type.basePath ?? `/admin/content/${type.name}`
20
+ const navId = `content:${type.name}`
21
+
22
+ return [
23
+ { id: `${navId}:list`, path: basePath, title: type.labelPlural },
24
+ {
25
+ id: `${navId}:new`,
26
+ path: `${basePath}/new`,
27
+ title: `Create new ${type.label}`,
28
+ parent: navId,
29
+ },
30
+ ]
31
+ })
32
+
33
+ return [...registry.pages.list.value, ...contentPages]
34
+ })
9
35
 
10
- const items = computed(() => registry.pages.list.value)
11
36
  const current = computed(() => items.value.find((page) => page.path === route.path))
12
37
 
13
38
  return { items, current }
@@ -0,0 +1,98 @@
1
+ /**
2
+ * Loads (or creates) one item of one content type, for the generic form page
3
+ * (`PlutoContentForm`).
4
+ *
5
+ * This is one half of the split that replaces the old `usePost`/`useProduct`
6
+ * pattern (see `usePlutoContentList` for the other half). That old pattern
7
+ * fetched a list AND a single item from one composable every time, and threw
8
+ * away whichever one the caller did not need. `usePlutoContentItem` only
9
+ * ever fetches a single item, and only when `id` is defined — a "new entry"
10
+ * form never fires a GET at all.
11
+ *
12
+ * It is also not a "fake thenable": it returns plain refs and explicit
13
+ * methods, so a caller `await`s `save()`/`remove()`/`refresh()` directly,
14
+ * instead of `await`ing the composable's own return value through a
15
+ * hand-rolled `.then()`.
16
+ *
17
+ * `id` is read once, with `toValue`, at composable-creation time. It is not
18
+ * watched for later changes — each of the three generated pages
19
+ * (`app/pages/admin/content/[type]/{index,new,[id]}.vue`) mounts fresh on
20
+ * every route change, so there is no case in this wave where `id` changes
21
+ * under a still-mounted `PlutoContentForm`.
22
+ */
23
+ export function usePlutoContentItem(
24
+ typeName: string,
25
+ id: MaybeRefOrGetter<string | number | undefined>
26
+ ) {
27
+ const resolvedId = toValue(id)
28
+ const isNew = resolvedId === undefined
29
+
30
+ const item = ref<Record<string, unknown>>({})
31
+ const pending = ref(false)
32
+ const error = ref<unknown>(null)
33
+
34
+ async function refresh(): Promise<void> {
35
+ // A new, unsaved entry has nothing to fetch — `item` stays the empty
36
+ // object it started as.
37
+ if (isNew) {
38
+ return
39
+ }
40
+
41
+ pending.value = true
42
+ error.value = null
43
+
44
+ try {
45
+ item.value = await $fetch<PlutoContentItem>(`/api/_pluto/content/${typeName}/${resolvedId}`)
46
+ } catch (caught) {
47
+ error.value = caught
48
+ throw caught
49
+ } finally {
50
+ pending.value = false
51
+ }
52
+ }
53
+
54
+ // Fire the initial fetch right away, same reasoning as
55
+ // usePlutoContentList: /admin/** is client-only, so there is no SSR
56
+ // payload concern. The failure path already lands in `error` for a
57
+ // reactive consumer to read; swallow the rejection here so it never
58
+ // surfaces as an unhandled rejection. A caller invoking `refresh()`
59
+ // again later still gets a real rejection to `await`.
60
+ refresh().catch(() => {})
61
+
62
+ async function save(values: Record<string, unknown>): Promise<PlutoContentItem> {
63
+ error.value = null
64
+
65
+ try {
66
+ const saved = isNew
67
+ ? await $fetch<PlutoContentItem>(`/api/_pluto/content/${typeName}`, {
68
+ method: 'POST',
69
+ body: values,
70
+ })
71
+ : await $fetch<PlutoContentItem>(`/api/_pluto/content/${typeName}/${resolvedId}`, {
72
+ method: 'PATCH',
73
+ body: values,
74
+ })
75
+
76
+ item.value = saved
77
+ return saved
78
+ } catch (caught) {
79
+ error.value = caught
80
+ throw caught
81
+ }
82
+ }
83
+
84
+ async function remove(): Promise<void> {
85
+ if (isNew) {
86
+ throw new Error('Cannot remove an entry that has not been saved yet.')
87
+ }
88
+
89
+ try {
90
+ await $fetch(`/api/_pluto/content/${typeName}/${resolvedId}`, { method: 'DELETE' })
91
+ } catch (caught) {
92
+ error.value = caught
93
+ throw caught
94
+ }
95
+ }
96
+
97
+ return { item, pending, error, refresh, save, remove }
98
+ }
@@ -0,0 +1,60 @@
1
+ /**
2
+ * Fetches one content type's list of items, for the generic list page
3
+ * (`PlutoContentList`).
4
+ *
5
+ * This is one half of the split that replaces the old `usePost`/`useProduct`
6
+ * pattern (see `usePlutoContentItem` for the other half). That old pattern
7
+ * fetched a list AND a single item from one composable every time, and threw
8
+ * away whichever one the caller did not need — a list page paid for an item
9
+ * fetch it never used, and an edit page paid for a list fetch it never used.
10
+ * `usePlutoContentList` only ever fetches a list, because a list page only
11
+ * ever needs a list.
12
+ *
13
+ * It is also not a "fake thenable": it returns plain refs and explicit
14
+ * methods, so a caller `await`s `refresh()` or `remove()` directly, instead
15
+ * of `await`ing the composable's own return value through a hand-rolled
16
+ * `.then()`.
17
+ */
18
+ export function usePlutoContentList(typeName: string) {
19
+ const items = ref<PlutoContentItem[]>([])
20
+ const total = ref<number | undefined>(undefined)
21
+ const pending = ref(false)
22
+ const error = ref<unknown>(null)
23
+
24
+ async function refresh(): Promise<void> {
25
+ pending.value = true
26
+ error.value = null
27
+
28
+ try {
29
+ const result = await $fetch<PlutoContentListResult>(`/api/_pluto/content/${typeName}`)
30
+ items.value = result.data
31
+ total.value = result.total
32
+ } catch (caught) {
33
+ error.value = caught
34
+ throw caught
35
+ } finally {
36
+ pending.value = false
37
+ }
38
+ }
39
+
40
+ // Fire the initial fetch right away. /admin/** is client-only
41
+ // (ssr: false in nuxt.config.ts), so there is no SSR payload concern —
42
+ // this only ever runs in the browser. The failure path already lands in
43
+ // `error` for a reactive consumer to read; swallow the rejection here so
44
+ // it never surfaces as an unhandled rejection. A caller invoking
45
+ // `refresh()` again later still gets a real rejection to `await`.
46
+ refresh().catch(() => {})
47
+
48
+ async function remove(id: string | number): Promise<void> {
49
+ try {
50
+ await $fetch(`/api/_pluto/content/${typeName}/${id}`, { method: 'DELETE' })
51
+ } catch (caught) {
52
+ error.value = caught
53
+ throw caught
54
+ }
55
+
56
+ await refresh()
57
+ }
58
+
59
+ return { items, total, pending, error, refresh, remove }
60
+ }
@@ -0,0 +1,19 @@
1
+ <script setup lang="ts">
2
+ const route = useRoute()
3
+ const typeName = route.params.type as string
4
+
5
+ const { byName } = usePlutoContentTypes()
6
+ const type = byName(typeName)
7
+
8
+ if (!type) {
9
+ throw createError({ statusCode: 404, statusMessage: 'Content type not found.', fatal: true })
10
+ }
11
+
12
+ useHead({
13
+ title: `Edit ${type.label}`,
14
+ })
15
+ </script>
16
+
17
+ <template>
18
+ <PlutoContentForm :id="route.params.id as string" :type="type.name" />
19
+ </template>
@@ -0,0 +1,19 @@
1
+ <script setup lang="ts">
2
+ const route = useRoute()
3
+ const typeName = route.params.type as string
4
+
5
+ const { byName } = usePlutoContentTypes()
6
+ const type = byName(typeName)
7
+
8
+ if (!type) {
9
+ throw createError({ statusCode: 404, statusMessage: 'Content type not found.', fatal: true })
10
+ }
11
+
12
+ useHead({
13
+ title: type.labelPlural,
14
+ })
15
+ </script>
16
+
17
+ <template>
18
+ <PlutoContentList :type="type.name" />
19
+ </template>
@@ -0,0 +1,19 @@
1
+ <script setup lang="ts">
2
+ const route = useRoute()
3
+ const typeName = route.params.type as string
4
+
5
+ const { byName } = usePlutoContentTypes()
6
+ const type = byName(typeName)
7
+
8
+ if (!type) {
9
+ throw createError({ statusCode: 404, statusMessage: 'Content type not found.', fatal: true })
10
+ }
11
+
12
+ useHead({
13
+ title: `New ${type.label}`,
14
+ })
15
+ </script>
16
+
17
+ <template>
18
+ <PlutoContentForm :type="type.name" />
19
+ </template>
@@ -1,6 +1,25 @@
1
- // Registers core's own Home and Settings anchors. `order: 0` and
2
- // `order: 1000` are reserved for these two entries, so every other layer's
3
- // nav item sorts between them by default (default order is 100).
1
+ import PlutoFieldBoolean from '../components/Pluto/Content/fields/PlutoFieldBoolean.vue'
2
+ import PlutoFieldDate from '../components/Pluto/Content/fields/PlutoFieldDate.vue'
3
+ import PlutoFieldNumber from '../components/Pluto/Content/fields/PlutoFieldNumber.vue'
4
+ import PlutoFieldReference from '../components/Pluto/Content/fields/PlutoFieldReference.vue'
5
+ import PlutoFieldSelect from '../components/Pluto/Content/fields/PlutoFieldSelect.vue'
6
+ import PlutoFieldSlug from '../components/Pluto/Content/fields/PlutoFieldSlug.vue'
7
+ import PlutoFieldText from '../components/Pluto/Content/fields/PlutoFieldText.vue'
8
+
9
+ // Registers core's own Home and Settings anchors, plus the eight built-in
10
+ // content field widgets. `order: 0` and `order: 1000` are reserved for the
11
+ // Home/Settings nav entries, so every other layer's nav item sorts between
12
+ // them by default (default order is 100).
13
+ //
14
+ // Everything core itself contributes to the registry lives in this one
15
+ // `definePlutoExtension` call — `definePlutoExtension` replaces a layer's
16
+ // whole contribution on every call, not just the buckets that call passes,
17
+ // so a second `id: 'pluto'` call elsewhere would silently wipe this one out.
18
+ //
19
+ // `richtext` and `media` have no core widget here on purpose — a layer
20
+ // registers those (blog registers `richtext`, storage registers `media`),
21
+ // because core has no real editor or file picker to offer for either. See
22
+ // the content-model skill.
4
23
  export default defineNuxtPlugin(() => {
5
24
  definePlutoExtension({
6
25
  id: 'pluto',
@@ -12,5 +31,15 @@ export default defineNuxtPlugin(() => {
12
31
  { id: 'home', path: '/admin/home', title: 'Dashboard', icon: 'lucide:house' },
13
32
  { id: 'settings', path: '/admin/settings', title: 'Settings', icon: 'lucide:settings' },
14
33
  ],
34
+ contentFieldWidgets: [
35
+ { id: 'text', fieldType: 'text', component: PlutoFieldText },
36
+ { id: 'textarea', fieldType: 'textarea', component: PlutoFieldText },
37
+ { id: 'slug', fieldType: 'slug', component: PlutoFieldSlug },
38
+ { id: 'number', fieldType: 'number', component: PlutoFieldNumber },
39
+ { id: 'boolean', fieldType: 'boolean', component: PlutoFieldBoolean },
40
+ { id: 'date', fieldType: 'date', component: PlutoFieldDate },
41
+ { id: 'select', fieldType: 'select', component: PlutoFieldSelect },
42
+ { id: 'reference', fieldType: 'reference', component: PlutoFieldReference },
43
+ ],
15
44
  })
16
45
  })
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@plutocms/pluto",
3
3
  "type": "module",
4
- "version": "0.7.0",
4
+ "version": "0.8.1",
5
5
  "description": "The layerable CMS",
6
6
  "author": "Victor Ribeiro",
7
7
  "license": "MIT",
@@ -44,6 +44,7 @@
44
44
  "dependencies": {
45
45
  "@iconify-json/lucide": "^1.2.116",
46
46
  "@iconify-json/simple-icons": "^1.2.89",
47
+ "@internationalized/date": "^3.12.2",
47
48
  "@nuxt/eslint": "^1.16.0",
48
49
  "@nuxt/ui": "^4.9.0",
49
50
  "@plutocms/utils": "^0.2.1",