@plutocms/pluto 0.9.1 → 0.11.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 CHANGED
@@ -1,6 +1,20 @@
1
1
  # Changelog
2
2
 
3
3
 
4
+ ## [0.11.0](https://github.com/plutocms/pluto/compare/v0.10.0...v0.11.0) (2026-10-16)
5
+
6
+
7
+ ### Features
8
+
9
+ * **admin:** rebuild admin layout on Nuxt UI dashboard components ([#145](https://github.com/plutocms/pluto/issues/145)) ([621e57b](https://github.com/plutocms/pluto/commit/621e57b83d7650b49242c4b83c5374b33c7236bc))
10
+
11
+ ## [0.10.0](https://github.com/plutocms/pluto/compare/v0.9.1...v0.10.0) (2026-09-13)
12
+
13
+
14
+ ### Features
15
+
16
+ * **content:** add virtual field flag for column-less content-type fields ([#143](https://github.com/plutocms/pluto/issues/143)) ([cf9091e](https://github.com/plutocms/pluto/commit/cf9091e57d21520d92197ba562eef073ef469ee1))
17
+
4
18
  ## [0.9.1](https://github.com/plutocms/pluto/compare/v0.9.0...v0.9.1) (2026-09-13)
5
19
 
6
20
 
package/FEATURES.md CHANGED
@@ -19,3 +19,7 @@ policies shared by every project here.
19
19
  admin UI (list/form components, eight built-in field widgets, generated
20
20
  `/admin/content/**` pages, and nav/pages derivation). No real adapter yet.
21
21
  @.claude/skills/content-model/SKILL.md
22
+ - Admin view chrome: per-view shell components (`PlutoViewAdminPanel`, `PlutoViewToolbar`,
23
+ `PlutoViewAside`), the generic content form's publish workflow, and the `contentActions`
24
+ registry bucket that lets a layer add its own buttons to that form.
25
+ @.claude/skills/admin-view-chrome/SKILL.md
@@ -9,3 +9,18 @@
9
9
 
10
10
  --font-outfit: 'Outfit', sans-serif;
11
11
  }
12
+
13
+ /*
14
+ * Set the header height outside a layer. This overrides Nuxt UI's own
15
+ * `@layer theme` value for the same variable. The values match the
16
+ * navbar's own height classes (`h-14` / `lg:h-10`).
17
+ */
18
+ :root {
19
+ --ui-header-height: 3.5rem;
20
+ }
21
+
22
+ @media (width >= 64rem) {
23
+ :root {
24
+ --ui-header-height: 2.5rem;
25
+ }
26
+ }
@@ -1,4 +1,6 @@
1
1
  <script setup lang="ts">
2
+ import type { ButtonProps } from '@nuxt/ui'
3
+
2
4
  const props = defineProps<{
3
5
  /** A content type's `name` (not a registry entry id) — see `usePlutoContentTypes`. */
4
6
  type: string
@@ -13,7 +15,9 @@ if (!contentType.value) {
13
15
  // The generated page (`app/pages/admin/content/[type]/{new,[id]}.vue`)
14
16
  // already resolves the type and 404s before this component ever mounts,
15
17
  // so this should not normally happen.
16
- console.error(`PlutoContentForm: content type "${props.type}" was not found in the registry.`)
18
+ console.error(
19
+ `PlutoContentForm: content type "${props.type}" was not found in the registry.`
20
+ )
17
21
  }
18
22
 
19
23
  const { can } = usePlutoPermissions()
@@ -21,11 +25,26 @@ const { can } = usePlutoPermissions()
21
25
  // "No capability declared" means the operation is open to anyone who can
22
26
  // reach this page — never call can('') as a stand-in for that.
23
27
  const canWrite = computed(
24
- () => !contentType.value?.capabilities?.write || can(contentType.value.capabilities.write)
28
+ () =>
29
+ !contentType.value?.capabilities?.write ||
30
+ can(contentType.value.capabilities.write)
31
+ )
32
+ const canPublish = computed(() => {
33
+ const cap =
34
+ contentType.value?.capabilities?.publish ??
35
+ contentType.value?.capabilities?.write
36
+ return !cap || can(cap)
37
+ })
38
+ const canDelete = computed(
39
+ () =>
40
+ !contentType.value?.capabilities?.delete ||
41
+ can(contentType.value.capabilities.delete)
25
42
  )
26
43
 
27
44
  const editing = computed(() => props.id !== undefined)
28
- const basePath = computed(() => contentType.value?.basePath ?? `/admin/content/${props.type}`)
45
+ const basePath = computed(
46
+ () => contentType.value?.basePath ?? `/admin/content/${props.type}`
47
+ )
29
48
 
30
49
  // editPath exists so an already-deployed layer's edit URL never has to
31
50
  // change to adopt this component — see the doc comment on
@@ -35,12 +54,14 @@ function editPath(id: string | number) {
35
54
  return contentType.value?.editPath?.(id) ?? `${basePath.value}/${id}`
36
55
  }
37
56
 
38
- const { item, save } = usePlutoContentItem(props.type, () => props.id)
57
+ const { item, save, remove } = usePlutoContentItem(props.type, () => props.id)
39
58
 
40
59
  function sortedFields(region: 'main' | 'side') {
41
60
  return computed(() =>
42
61
  (contentType.value?.fields ?? [])
43
- .filter((field) => field.inForm !== false && (field.region ?? 'main') === region)
62
+ .filter(
63
+ (field) => field.inForm !== false && (field.region ?? 'main') === region
64
+ )
44
65
  .sort((a, b) => (a.order ?? 100) - (b.order ?? 100))
45
66
  )
46
67
  }
@@ -63,7 +84,9 @@ const sideFields = sortedFields('side')
63
84
  // the moment a 'slug' field's own `update:modelValue` fires.
64
85
  const slugTouched = ref(false)
65
86
  const slugField = computed(() =>
66
- contentType.value?.fields.find((field): field is PlutoSlugField => field.type === 'slug')
87
+ contentType.value?.fields.find(
88
+ (field): field is PlutoSlugField => field.type === 'slug'
89
+ )
67
90
  )
68
91
 
69
92
  function slugify(value: string): string {
@@ -96,13 +119,40 @@ function updateField(field: PlutoField, value: unknown) {
96
119
  item.value[field.name] = value
97
120
  }
98
121
 
122
+ // --- Status / publish workflow ----------------------------------------------
123
+
124
+ const statusConfig = computed(() => contentType.value?.status || null)
125
+ const publishedValue = computed(
126
+ () => statusConfig.value?.publishedValue ?? 'published'
127
+ )
128
+ const draftValue = computed(
129
+ () =>
130
+ statusConfig.value?.default ??
131
+ statusConfig.value?.values.find(
132
+ (value) => value.value !== publishedValue.value
133
+ )?.value
134
+ )
135
+ const currentStatus = computed(
136
+ () => (item.value.status as string | undefined) ?? statusConfig.value?.default
137
+ )
138
+ const isPublished = computed(
139
+ () =>
140
+ statusConfig.value !== null && currentStatus.value === publishedValue.value
141
+ )
142
+ const statusLabel = computed(
143
+ () =>
144
+ statusConfig.value?.values.find(
145
+ (value) => value.value === currentStatus.value
146
+ )?.label ?? currentStatus.value
147
+ )
148
+
99
149
  // --- Save -------------------------------------------------------------------
100
150
 
101
151
  const toast = useToast()
102
152
  const saving = ref(false)
103
153
  const validationErrors = ref<ContentValidationError[]>([])
104
154
 
105
- function buildPayload(): Record<string, unknown> {
155
+ function buildPayload(statusOverride?: string): Record<string, unknown> {
106
156
  const type = contentType.value
107
157
  if (!type) {
108
158
  return {}
@@ -115,17 +165,23 @@ function buildPayload(): Record<string, unknown> {
115
165
  }
116
166
  }
117
167
 
168
+ if (statusOverride !== undefined) {
169
+ payload.status = statusOverride
170
+ }
171
+
118
172
  return payload
119
173
  }
120
174
 
121
- async function onSave() {
175
+ async function onSave(statusOverride?: string) {
122
176
  const type = contentType.value
123
177
  if (!type) {
124
178
  return
125
179
  }
126
180
 
127
- const payload = buildPayload()
128
- const errors = validateContentPayload(type, payload, { partial: editing.value })
181
+ const payload = buildPayload(statusOverride)
182
+ const errors = validateContentPayload(type, payload, {
183
+ partial: editing.value,
184
+ })
129
185
 
130
186
  if (errors.length > 0) {
131
187
  validationErrors.value = errors
@@ -143,8 +199,15 @@ async function onSave() {
143
199
  return
144
200
  }
145
201
 
202
+ const title =
203
+ statusOverride === publishedValue.value
204
+ ? 'Published'
205
+ : statusOverride === draftValue.value
206
+ ? 'Unpublished'
207
+ : 'Saved'
208
+
146
209
  toast.add({
147
- title: 'Saved',
210
+ title,
148
211
  description: `${type.label} updated successfully.`,
149
212
  color: 'success',
150
213
  })
@@ -161,52 +224,231 @@ async function onSave() {
161
224
  saving.value = false
162
225
  }
163
226
  }
227
+
228
+ // --- Delete -------------------------------------------------------------------
229
+
230
+ const isDeleteModalOpen = ref(false)
231
+ const deleting = ref(false)
232
+
233
+ function openDeleteModal() {
234
+ isDeleteModalOpen.value = true
235
+ }
236
+
237
+ function closeDeleteModal() {
238
+ isDeleteModalOpen.value = false
239
+ }
240
+
241
+ async function confirmDelete() {
242
+ deleting.value = true
243
+
244
+ try {
245
+ await remove()
246
+ closeDeleteModal()
247
+ await navigateTo(basePath.value)
248
+ } catch {
249
+ toast.add({
250
+ title: 'Error',
251
+ description: 'An error occurred while removing.',
252
+ color: 'error',
253
+ })
254
+ } finally {
255
+ deleting.value = false
256
+ }
257
+ }
258
+
259
+ // --- Layout / aside -----------------------------------------------------------
260
+
261
+ const layout = computed(() => contentType.value?.form?.layout ?? 'default')
262
+ const showAside = computed(() => {
263
+ const configured = contentType.value?.form?.aside
264
+ if (configured === true || configured === false) {
265
+ return configured
266
+ }
267
+ return sideFields.value.length > 0
268
+ })
269
+ const asideCollapsedByDefault = computed(
270
+ () => contentType.value?.form?.asideCollapsed ?? layout.value === 'focus'
271
+ )
272
+ // Remember the open/closed state per content type across visits.
273
+ const asideOpen = useCookie<boolean>(`pluto-content-aside-${props.type}`, {
274
+ default: () => !asideCollapsedByDefault.value,
275
+ })
276
+
277
+ function toggleAside() {
278
+ asideOpen.value = !asideOpen.value
279
+ }
280
+
281
+ // --- Registry content actions --------------------------------------------------
282
+
283
+ const toolbarActions = usePlutoContentActions(props.type, 'toolbar')
284
+
285
+ // Only rendered from the template inside the `v-if="contentType"` branch, so
286
+ // `contentType.value` is always set by the time this is read.
287
+ function buildActionContext(): PlutoContentActionContext {
288
+ return {
289
+ type: contentType.value as PlutoContentType,
290
+ item: item.value,
291
+ editing: editing.value,
292
+ saving: saving.value,
293
+ save: () => onSave(),
294
+ remove: () => confirmDelete(),
295
+ }
296
+ }
297
+
298
+ // No explicit `DropdownMenuItem` annotation. A consumer that links this
299
+ // layer from source can resolve a second copy of `@nuxt/ui`. A named type
300
+ // from one copy then fails to match the component from the other copy. An
301
+ // inferred, structural type matches both.
302
+ const unpublishDeleteItems = computed(() => [
303
+ [
304
+ ...(statusConfig.value && isPublished.value && canPublish.value
305
+ ? [
306
+ {
307
+ label: 'Unpublish',
308
+ icon: 'lucide:eye-off',
309
+ onSelect: () => onSave(draftValue.value),
310
+ },
311
+ ]
312
+ : []),
313
+ ...(editing.value && canDelete.value
314
+ ? [
315
+ {
316
+ label: 'Delete',
317
+ icon: 'lucide:trash',
318
+ color: 'error' as const,
319
+ onSelect: () => openDeleteModal(),
320
+ },
321
+ ]
322
+ : []),
323
+ ],
324
+ ])
164
325
  </script>
165
326
 
166
327
  <template>
167
- <div v-if="contentType">
168
- <AdminView>
169
- <UAlert
170
- v-if="validationErrors.length > 0"
171
- color="error"
172
- title="Please fix the following before saving"
328
+ <PlutoViewAdminPanel
329
+ v-if="contentType"
330
+ :width="layout === 'focus' ? 'full' : 'container'"
331
+ >
332
+ <template #toolbar>
333
+ <PlutoViewToolbar
334
+ :back-to="basePath"
335
+ :title="
336
+ layout === 'focus'
337
+ ? editing
338
+ ? `Edit ${contentType.label}`
339
+ : `New ${contentType.label}`
340
+ : undefined
341
+ "
173
342
  >
174
- <template #description>
175
- <ul class="list-disc pl-4">
176
- <li v-for="fieldError in validationErrors" :key="fieldError.field">
177
- {{ fieldError.message }}
178
- </li>
179
- </ul>
180
- </template>
181
- </UAlert>
182
-
183
- <div class="flex flex-col gap-6 lg:flex-row lg:items-start lg:gap-10">
184
- <div class="flex flex-1 flex-col gap-6">
185
- <h1 class="text-3xl font-bold lg:text-4xl">
186
- {{ editing ? `Edit ${contentType.label}` : `New ${contentType.label}` }}
187
- </h1>
343
+ <template #right>
344
+ <UBadge
345
+ v-if="statusConfig"
346
+ :color="isPublished ? 'success' : 'neutral'"
347
+ variant="subtle"
348
+ >
349
+ {{ statusLabel }}
350
+ </UBadge>
351
+
352
+ <template v-for="action in toolbarActions" :key="action.id">
353
+ <!-- prettier-ignore -->
354
+ <UButton
355
+ v-if="action.show?.(buildActionContext()) !== false"
356
+ :icon="action.icon"
357
+ :color="(action.color as ButtonProps['color'])"
358
+ :variant="(action.variant as ButtonProps['variant'])"
359
+ @click="action.onSelect(buildActionContext())"
360
+ >
361
+ {{ action.label }}
362
+ </UButton>
363
+ </template>
188
364
 
189
- <PlutoContentField
190
- v-for="field in mainFields"
191
- :key="field.name"
192
- :field="field"
193
- :model-value="item[field.name]"
194
- :disabled="!canWrite || saving"
195
- @update:model-value="updateField(field, $event)"
196
- />
197
- </div>
365
+ <UButton
366
+ v-if="canWrite && (!statusConfig || !isPublished)"
367
+ :loading="saving"
368
+ icon="lucide:save"
369
+ variant="subtle"
370
+ @click="onSave()"
371
+ >
372
+ Save{{ statusConfig ? ' draft' : '' }}
373
+ </UButton>
198
374
 
199
- <div class="flex w-full shrink-0 flex-col gap-6 lg:max-w-xs">
200
375
  <UButton
201
- v-if="canWrite"
376
+ v-if="canWrite && statusConfig && isPublished"
202
377
  :loading="saving"
203
378
  icon="lucide:save"
204
- class="justify-center"
205
- @click="onSave"
379
+ @click="onSave()"
380
+ >
381
+ Update
382
+ </UButton>
383
+
384
+ <UButton
385
+ v-if="canPublish && statusConfig && !isPublished"
386
+ :loading="saving"
387
+ icon="lucide:upload"
388
+ @click="onSave(publishedValue)"
206
389
  >
207
- Save
390
+ Publish
208
391
  </UButton>
209
392
 
393
+ <UDropdownMenu
394
+ v-if="
395
+ (editing && canDelete) ||
396
+ (statusConfig && isPublished && canPublish)
397
+ "
398
+ :items="unpublishDeleteItems"
399
+ >
400
+ <UButton
401
+ icon="lucide:ellipsis-vertical"
402
+ color="neutral"
403
+ variant="ghost"
404
+ square
405
+ />
406
+ </UDropdownMenu>
407
+
408
+ <UButton
409
+ v-if="showAside"
410
+ :icon="
411
+ asideOpen ? 'lucide:panel-right-close' : 'lucide:panel-right-open'
412
+ "
413
+ color="neutral"
414
+ variant="ghost"
415
+ square
416
+ @click="toggleAside"
417
+ />
418
+ </template>
419
+ </PlutoViewToolbar>
420
+ </template>
421
+
422
+ <UAlert
423
+ v-if="validationErrors.length > 0"
424
+ color="error"
425
+ title="Please fix the following before saving"
426
+ >
427
+ <template #description>
428
+ <ul class="list-disc pl-4">
429
+ <li v-for="fieldError in validationErrors" :key="fieldError.field">
430
+ {{ fieldError.message }}
431
+ </li>
432
+ </ul>
433
+ </template>
434
+ </UAlert>
435
+
436
+ <h1 v-if="layout !== 'focus'" class="text-3xl font-bold lg:text-4xl">
437
+ {{ editing ? `Edit ${contentType.label}` : `New ${contentType.label}` }}
438
+ </h1>
439
+
440
+ <PlutoContentField
441
+ v-for="field in mainFields"
442
+ :key="field.name"
443
+ :field="field"
444
+ :model-value="item[field.name]"
445
+ :disabled="!canWrite || saving"
446
+ @update:model-value="updateField(field, $event)"
447
+ />
448
+
449
+ <template v-if="showAside" #aside>
450
+ <PlutoViewAside v-model:open="asideOpen" title="Settings">
451
+ <div class="flex flex-col gap-6">
210
452
  <PlutoContentField
211
453
  v-for="field in sideFields"
212
454
  :key="field.name"
@@ -216,15 +458,47 @@ async function onSave() {
216
458
  @update:model-value="updateField(field, $event)"
217
459
  />
218
460
  </div>
219
- </div>
220
- </AdminView>
221
- </div>
461
+ </PlutoViewAside>
462
+ </template>
463
+ </PlutoViewAdminPanel>
222
464
 
223
- <AdminView v-else>
465
+ <PlutoViewAdminPanel v-else>
224
466
  <UAlert
225
467
  :description="`No content type named &quot;${type}&quot; is registered.`"
226
468
  color="error"
227
469
  title="Content type not found"
228
470
  />
229
- </AdminView>
471
+ </PlutoViewAdminPanel>
472
+
473
+ <Modal v-model="isDeleteModalOpen" :custom-size="480">
474
+ <ModalHeader @close="closeDeleteModal">
475
+ Remove {{ contentType?.label }}
476
+ </ModalHeader>
477
+
478
+ <ModalContent>
479
+ <p>Do you really want to remove this item?</p>
480
+ </ModalContent>
481
+
482
+ <ModalFooter>
483
+ <div class="flex items-center gap-4">
484
+ <UButton
485
+ icon="lucide:x"
486
+ variant="ghost"
487
+ color="neutral"
488
+ @click="closeDeleteModal"
489
+ >
490
+ Cancel
491
+ </UButton>
492
+
493
+ <UButton
494
+ :loading="deleting"
495
+ icon="lucide:trash"
496
+ color="error"
497
+ @click="confirmDelete"
498
+ >
499
+ Remove
500
+ </UButton>
501
+ </div>
502
+ </ModalFooter>
503
+ </Modal>
230
504
  </template>