@plutocms/pluto 0.10.0 → 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,13 @@
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
+
4
11
  ## [0.10.0](https://github.com/plutocms/pluto/compare/v0.9.1...v0.10.0) (2026-09-13)
5
12
 
6
13
 
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>
@@ -13,7 +13,9 @@ if (!contentType.value) {
13
13
  // The generated page (`app/pages/admin/content/[type]/index.vue`) already
14
14
  // resolves the type and 404s before this component ever mounts, so this
15
15
  // should not normally happen.
16
- console.error(`PlutoContentList: content type "${props.type}" was not found in the registry.`)
16
+ console.error(
17
+ `PlutoContentList: content type "${props.type}" was not found in the registry.`
18
+ )
17
19
  }
18
20
 
19
21
  const { can } = usePlutoPermissions()
@@ -21,19 +23,27 @@ const { can } = usePlutoPermissions()
21
23
  // "No capability declared" means the operation is open to anyone who can
22
24
  // reach this page — never call can('') as a stand-in for that.
23
25
  const canWrite = computed(
24
- () => !contentType.value?.capabilities?.write || can(contentType.value.capabilities.write)
26
+ () =>
27
+ !contentType.value?.capabilities?.write ||
28
+ can(contentType.value.capabilities.write)
25
29
  )
26
30
  const canDelete = computed(
27
- () => !contentType.value?.capabilities?.delete || can(contentType.value.capabilities.delete)
31
+ () =>
32
+ !contentType.value?.capabilities?.delete ||
33
+ can(contentType.value.capabilities.delete)
28
34
  )
29
35
 
30
- const basePath = computed(() => contentType.value?.basePath ?? `/admin/content/${props.type}`)
36
+ const basePath = computed(
37
+ () => contentType.value?.basePath ?? `/admin/content/${props.type}`
38
+ )
31
39
 
32
40
  // newPath/editPath exist so an already-deployed layer's URLs never have
33
41
  // to change to adopt this component — see the doc comments on
34
42
  // PlutoContentType.newPath/editPath for why a single basePath cannot
35
43
  // always express both.
36
- const newPath = computed(() => contentType.value?.newPath ?? `${basePath.value}/new`)
44
+ const newPath = computed(
45
+ () => contentType.value?.newPath ?? `${basePath.value}/new`
46
+ )
37
47
  function editPath(id: string | number) {
38
48
  return contentType.value?.editPath?.(id) ?? `${basePath.value}/${id}`
39
49
  }
@@ -47,7 +57,10 @@ const listFields = computed(() =>
47
57
  )
48
58
 
49
59
  const columns = computed<TableColumn<PlutoContentItem>[]>(() => [
50
- ...listFields.value.map((field) => ({ accessorKey: field.name, header: field.label })),
60
+ ...listFields.value.map((field) => ({
61
+ accessorKey: field.name,
62
+ header: field.label,
63
+ })),
51
64
  { id: 'actions', header: '' },
52
65
  ])
53
66
 
@@ -75,30 +88,10 @@ async function confirmDelete() {
75
88
  </script>
76
89
 
77
90
  <template>
78
- <div v-if="contentType">
79
- <Modal v-model="isDeleteModalOpen" :custom-size="480">
80
- <ModalHeader @close="closeDeleteModal">Remove item</ModalHeader>
81
-
82
- <ModalContent>
83
- <p>Do you really want to remove this item?</p>
84
- </ModalContent>
85
-
86
- <ModalFooter>
87
- <div class="flex items-center gap-4">
88
- <UButton icon="lucide:x" variant="ghost" color="neutral" @click="closeDeleteModal">
89
- Cancel
90
- </UButton>
91
-
92
- <UButton icon="lucide:trash" color="error" @click="confirmDelete">Remove</UButton>
93
- </div>
94
- </ModalFooter>
95
- </Modal>
96
-
97
- <AdminView>
98
- <div class="flex flex-col gap-4 lg:flex-row lg:items-center lg:justify-between">
99
- <hgroup class="flex items-center justify-between gap-x-3 lg:justify-start">
100
- <h1 class="text-3xl font-bold lg:text-4xl">{{ contentType.labelPlural }}</h1>
101
-
91
+ <PlutoViewAdminPanel v-if="contentType">
92
+ <template #toolbar>
93
+ <PlutoViewToolbar :title="contentType.labelPlural">
94
+ <template #trailing>
102
95
  <UButton
103
96
  :loading="pending"
104
97
  icon="lucide:refresh-ccw"
@@ -107,100 +100,125 @@ async function confirmDelete() {
107
100
  square
108
101
  @click="refresh()"
109
102
  />
110
- </hgroup>
103
+ </template>
111
104
 
112
- <div v-if="canWrite" class="flex lg:shrink-0">
113
- <UButton
114
- :to="newPath"
115
- icon="lucide:plus"
116
- as="NuxtLink"
117
- class="flex-1 justify-center lg:flex-none"
118
- >
105
+ <template v-if="canWrite" #right>
106
+ <UButton :to="newPath" icon="lucide:plus" as="NuxtLink">
119
107
  Add {{ contentType.labelPlural }}
120
108
  </UButton>
121
- </div>
122
- </div>
109
+ </template>
110
+ </PlutoViewToolbar>
111
+ </template>
112
+
113
+ <div class="grid gap-3 lg:hidden">
114
+ <UCard v-for="item in items" :key="item.id">
115
+ <div class="flex flex-col gap-4">
116
+ <div class="min-w-0">
117
+ <NuxtLink
118
+ :to="editPath(item.id)"
119
+ class="block truncate text-lg font-semibold hover:underline"
120
+ >
121
+ {{ item[contentType.titleField] }}
122
+ </NuxtLink>
123
+ </div>
123
124
 
124
- <div class="grid gap-3 lg:hidden">
125
- <UCard v-for="item in items" :key="item.id">
126
- <div class="flex flex-col gap-4">
127
- <div class="min-w-0">
128
- <NuxtLink
129
- :to="editPath(item.id)"
130
- class="block truncate text-lg font-semibold hover:underline"
131
- >
132
- {{ item[contentType.titleField] }}
133
- </NuxtLink>
125
+ <dl class="grid grid-cols-2 gap-3 text-sm">
126
+ <div v-for="field in listFields" :key="field.name">
127
+ <dt class="text-muted">{{ field.label }}</dt>
128
+ <dd class="font-medium">{{ item[field.name] }}</dd>
134
129
  </div>
135
-
136
- <dl class="grid grid-cols-2 gap-3 text-sm">
137
- <div v-for="field in listFields" :key="field.name">
138
- <dt class="text-muted">{{ field.label }}</dt>
139
- <dd class="font-medium">{{ item[field.name] }}</dd>
140
- </div>
141
- </dl>
142
-
143
- <div class="flex gap-2 border-t border-default pt-3">
144
- <UButton
130
+ </dl>
131
+
132
+ <div class="border-default flex gap-2 border-t pt-3">
133
+ <UButton
134
+ v-if="canWrite"
135
+ :to="editPath(item.id)"
136
+ icon="lucide:pen-line"
137
+ color="neutral"
138
+ variant="soft"
139
+ class="flex-1 justify-center"
140
+ >
141
+ Edit
142
+ </UButton>
143
+
144
+ <UButton
145
+ v-if="canDelete"
146
+ icon="lucide:trash"
147
+ color="error"
148
+ variant="soft"
149
+ class="flex-1 justify-center"
150
+ @click="openDeleteModal(item.id)"
151
+ >
152
+ Remove
153
+ </UButton>
154
+ </div>
155
+ </div>
156
+ </UCard>
157
+ </div>
158
+
159
+ <UCard :ui="{ body: 'sm:p-0 p-0' }" class="hidden lg:block">
160
+ <div class="overflow-x-auto">
161
+ <UTable
162
+ :data="items"
163
+ :columns="columns"
164
+ :loading="pending"
165
+ empty="No items yet."
166
+ >
167
+ <template #actions-cell="{ row }">
168
+ <div class="flex justify-end gap-3">
169
+ <NuxtLink
145
170
  v-if="canWrite"
146
- :to="editPath(item.id)"
147
- icon="lucide:pen-line"
148
- color="neutral"
149
- variant="soft"
150
- class="flex-1 justify-center"
171
+ :to="editPath(row.original.id)"
172
+ class="text-info px-0 py-0.5 hover:underline"
151
173
  >
152
174
  Edit
153
- </UButton>
175
+ </NuxtLink>
154
176
 
155
- <UButton
177
+ <button
156
178
  v-if="canDelete"
157
- icon="lucide:trash"
158
- color="error"
159
- variant="soft"
160
- class="flex-1 justify-center"
161
- @click="openDeleteModal(item.id)"
179
+ type="button"
180
+ class="text-error cursor-pointer px-0 py-0.5 hover:underline"
181
+ @click="openDeleteModal(row.original.id)"
162
182
  >
163
183
  Remove
164
- </UButton>
184
+ </button>
165
185
  </div>
166
- </div>
167
- </UCard>
186
+ </template>
187
+ </UTable>
168
188
  </div>
189
+ </UCard>
190
+ </PlutoViewAdminPanel>
169
191
 
170
- <UCard :ui="{ body: 'sm:p-0 p-0' }" class="hidden lg:block">
171
- <div class="overflow-x-auto">
172
- <UTable :data="items" :columns="columns" :loading="pending" empty="No items yet.">
173
- <template #actions-cell="{ row }">
174
- <div class="flex justify-end gap-3">
175
- <NuxtLink
176
- v-if="canWrite"
177
- :to="editPath(row.original.id)"
178
- class="text-info px-0 py-0.5 hover:underline"
179
- >
180
- Edit
181
- </NuxtLink>
182
-
183
- <button
184
- v-if="canDelete"
185
- type="button"
186
- class="text-error cursor-pointer px-0 py-0.5 hover:underline"
187
- @click="openDeleteModal(row.original.id)"
188
- >
189
- Remove
190
- </button>
191
- </div>
192
- </template>
193
- </UTable>
194
- </div>
195
- </UCard>
196
- </AdminView>
197
- </div>
198
-
199
- <AdminView v-else>
192
+ <PlutoViewAdminPanel v-else>
200
193
  <UAlert
201
194
  :description="`No content type named &quot;${type}&quot; is registered.`"
202
195
  color="error"
203
196
  title="Content type not found"
204
197
  />
205
- </AdminView>
198
+ </PlutoViewAdminPanel>
199
+
200
+ <Modal v-model="isDeleteModalOpen" :custom-size="480">
201
+ <ModalHeader @close="closeDeleteModal">Remove item</ModalHeader>
202
+
203
+ <ModalContent>
204
+ <p>Do you really want to remove this item?</p>
205
+ </ModalContent>
206
+
207
+ <ModalFooter>
208
+ <div class="flex items-center gap-4">
209
+ <UButton
210
+ icon="lucide:x"
211
+ variant="ghost"
212
+ color="neutral"
213
+ @click="closeDeleteModal"
214
+ >
215
+ Cancel
216
+ </UButton>
217
+
218
+ <UButton icon="lucide:trash" color="error" @click="confirmDelete">
219
+ Remove
220
+ </UButton>
221
+ </div>
222
+ </ModalFooter>
223
+ </Modal>
206
224
  </template>
@@ -2,7 +2,7 @@
2
2
 
3
3
  <template>
4
4
  <nav
5
- class="light:bg-white light:border-black/10 font-outfit sticky top-0 z-50 h-14 shrink-0 border-b lg:h-10 dark:border-white/5 dark:bg-black dark:text-white"
5
+ class="light:bg-white light:border-black/10 font-outfit fixed inset-0 z-50 h-(--ui-header-height) shrink-0 border-b dark:border-white/5 dark:bg-black dark:text-white"
6
6
  >
7
7
  <slot />
8
8
  </nav>
@@ -0,0 +1,41 @@
1
+ <script setup lang="ts">
2
+ withDefaults(
3
+ defineProps<{
4
+ /** Drop the max-width container in the main region. Default 'container'. */
5
+ width?: 'container' | 'full'
6
+ }>(),
7
+ {
8
+ width: 'container',
9
+ }
10
+ )
11
+ </script>
12
+
13
+ <template>
14
+ <div class="flex h-full min-h-0 flex-col overflow-hidden">
15
+ <div class="shrink-0">
16
+ <slot name="toolbar" />
17
+ </div>
18
+
19
+ <div class="flex flex-1 min-h-0 overflow-hidden">
20
+ <main class="flex-1 min-h-0 overflow-y-auto">
21
+ <!--
22
+ 'container' matches AdminView's own padding/gap classes, so a view
23
+ moving from AdminView to this panel looks the same. 'full' adds
24
+ none — a focus-layout consumer (e.g. a rich text editor) supplies
25
+ its own padding instead.
26
+ -->
27
+ <UContainer
28
+ v-if="width === 'container'"
29
+ class="flex max-w-300 flex-col gap-y-6 px-4 py-6 sm:px-6 lg:gap-y-8 lg:px-8 lg:py-10"
30
+ >
31
+ <slot />
32
+ </UContainer>
33
+ <slot v-else />
34
+ </main>
35
+
36
+ <div class="shrink-0">
37
+ <slot name="aside" />
38
+ </div>
39
+ </div>
40
+ </div>
41
+ </template>
@@ -0,0 +1,69 @@
1
+ <script setup lang="ts">
2
+ // Do NOT use UDashboardSidebar here. Nuxt UI's UDashboardSidebar listens to
3
+ // a GLOBAL `dashboard:sidebar:toggle` runtime hook, not a per-instance one.
4
+ // A second instance of it (this aside) would open and close in lockstep
5
+ // with the app's own LEFT sidebar from admin.vue. This component is a
6
+ // hand-built stand-in instead: a plain `<aside>` on desktop, a `USlideover`
7
+ // on mobile, both bound to the same `open` model.
8
+ withDefaults(
9
+ defineProps<{
10
+ title?: string
11
+ /** Desktop width, in px. Default 320. */
12
+ width?: number
13
+ }>(),
14
+ {
15
+ width: 320,
16
+ }
17
+ )
18
+
19
+ const open = defineModel<boolean>('open', { default: false })
20
+
21
+ function close() {
22
+ open.value = false
23
+ }
24
+
25
+ // Open the slideover on mobile only. A `lg:hidden` class alone is not
26
+ // enough: an open slideover is a modal dialog, so it disables pointer
27
+ // events on the rest of the page even while it is hidden. On desktop, the
28
+ // page then stops taking clicks as soon as the aside opens. It also mounts
29
+ // the slot a second time.
30
+ const isDesktop = useMediaQuery('(min-width: 64rem)')
31
+ const slideoverOpen = computed({
32
+ get: () => open.value && !isDesktop.value,
33
+ set: (value: boolean) => {
34
+ open.value = value
35
+ },
36
+ })
37
+ </script>
38
+
39
+ <template>
40
+ <aside
41
+ v-if="open"
42
+ :style="{ width: `${width}px` }"
43
+ class="border-default hidden h-full shrink-0 flex-col border-s lg:flex"
44
+ >
45
+ <div class="border-default flex h-12 shrink-0 items-center justify-between gap-1.5 border-b px-4">
46
+ <h2 class="text-highlighted truncate text-sm font-semibold">{{ title }}</h2>
47
+ <UButton
48
+ icon="i-lucide-x"
49
+ color="neutral"
50
+ variant="ghost"
51
+ square
52
+ @click="close"
53
+ />
54
+ </div>
55
+ <div class="min-h-0 flex-1 overflow-y-auto p-4">
56
+ <slot />
57
+ </div>
58
+ </aside>
59
+
60
+ <USlideover
61
+ v-model:open="slideoverOpen"
62
+ :title="title"
63
+ side="right"
64
+ >
65
+ <template #body>
66
+ <slot />
67
+ </template>
68
+ </USlideover>
69
+ </template>
@@ -0,0 +1,64 @@
1
+ <script setup lang="ts">
2
+ defineProps<{
3
+ title?: string
4
+ icon?: string
5
+ /** Path for a back button, rendered before the title. */
6
+ backTo?: string
7
+ }>()
8
+
9
+ defineSlots<{
10
+ left?: () => unknown
11
+ title?: () => unknown
12
+ default?: () => unknown
13
+ right?: () => unknown
14
+ trailing?: () => unknown
15
+ }>()
16
+ </script>
17
+
18
+ <template>
19
+ <!--
20
+ `:toggle="false"` is load-bearing. UDashboardNavbar's default toggle
21
+ button calls Nuxt UI's global `dashboard:sidebar:toggle` hook, which
22
+ controls the app-level LEFT sidebar from admin.vue. This is a SECOND,
23
+ page-local navbar — its own toggle would open and close the wrong
24
+ sidebar, so it must never render one.
25
+ -->
26
+ <UDashboardNavbar
27
+ :title="title"
28
+ :icon="icon"
29
+ :toggle="false"
30
+ :ui="{ root: 'h-12 border-muted' }"
31
+ >
32
+ <template v-if="$slots.left" #left>
33
+ <slot name="left" />
34
+ </template>
35
+
36
+ <template v-if="backTo || icon" #leading>
37
+ <UButton
38
+ v-if="backTo"
39
+ :to="backTo"
40
+ icon="i-lucide-arrow-left"
41
+ color="neutral"
42
+ variant="ghost"
43
+ square
44
+ />
45
+ <UIcon v-if="icon" :name="icon" class="me-1.5 size-5 shrink-0 self-center" />
46
+ </template>
47
+
48
+ <template v-if="$slots.title" #title>
49
+ <slot name="title" />
50
+ </template>
51
+
52
+ <template v-if="$slots.default" #default>
53
+ <slot />
54
+ </template>
55
+
56
+ <template v-if="$slots.right" #right>
57
+ <slot name="right" />
58
+ </template>
59
+
60
+ <template v-if="$slots.trailing" #trailing>
61
+ <slot name="trailing" />
62
+ </template>
63
+ </UDashboardNavbar>
64
+ </template>
@@ -0,0 +1,17 @@
1
+ /**
2
+ * The `PlutoContentAction` entries that apply to one content type, at one
3
+ * placement. Matches an entry whose `contentType` is `typeName` or `'*'`,
4
+ * the same matching rule `usePlutoContentFieldWidget` uses for field type.
5
+ * `placement` defaults to 'toolbar', matching `PlutoContentAction.placement`.
6
+ */
7
+ export function usePlutoContentActions(typeName: string, placement: 'toolbar' | 'aside' = 'toolbar') {
8
+ const registry = usePlutoRegistry()
9
+
10
+ return computed(() =>
11
+ registry.contentActions.list.value.filter(
12
+ (action) =>
13
+ (action.contentType === typeName || action.contentType === '*') &&
14
+ (action.placement ?? 'toolbar') === placement
15
+ )
16
+ )
17
+ }
@@ -28,5 +28,6 @@ export function definePlutoExtension(definition: PlutoExtension): void {
28
28
  )
29
29
  registry.contentTypes.set(definition.id, definition.contentTypes ?? [])
30
30
  registry.contentFieldWidgets.set(definition.id, definition.contentFieldWidgets ?? [])
31
+ registry.contentActions.set(definition.id, definition.contentActions ?? [])
31
32
  registry.mediaAdapters.set(definition.id, definition.mediaAdapters ?? [])
32
33
  }
@@ -25,6 +25,7 @@ export function usePlutoRegistry(): PlutoRegistry {
25
25
  permissionsDrivers: createOwnedRegistry<PlutoPermissionsDriver>(),
26
26
  contentTypes: createOwnedRegistry<PlutoContentTypeEntry>(),
27
27
  contentFieldWidgets: createOwnedRegistry<PlutoContentFieldWidget>(),
28
+ contentActions: createOwnedRegistry<PlutoContentAction>(),
28
29
  mediaAdapters: createOwnedRegistry<PlutoMediaAdapterEntry>(),
29
30
  }
30
31
  }
@@ -28,76 +28,49 @@ function toMenuItem(item: PlutoNavItem): SidebarMenuItem {
28
28
  const { items: navItems } = usePlutoAdminNav()
29
29
  const menu = computed<SidebarMenuItem[]>(() => navItems.value.map(toMenuItem))
30
30
 
31
- const route = useRoute()
31
+ // This key is a cross-repo contract: `supabase`'s own mobile navbar
32
+ // (`app/components/navbar/NavbarAdmin.vue`) writes to this exact state key
33
+ // to open the sidebar from its own hamburger button. Keep the name as-is.
32
34
  const isSidebarOpen = useState<boolean>('pluto-admin-sidebar-open', () => false)
33
-
34
- function closeSidebar() {
35
- isSidebarOpen.value = false
36
- }
37
-
38
- watch(
39
- () => route.fullPath,
40
- closeSidebar
41
- )
42
35
  </script>
43
36
 
44
37
  <template>
45
- <div
46
- class="dark:bg-admin-content light:bg-white font-outfit flex h-full min-h-0 grow flex-col overflow-hidden"
38
+ <UDashboardGroup
39
+ :class="(base: string) => base.replace('inset-0', 'inset-x-0 bottom-0 top-(--ui-header-height)')"
40
+ storage="cookie"
41
+ storage-key="pluto-admin"
42
+ unit="rem"
47
43
  >
48
- <div class="relative flex min-h-0 flex-1 items-stretch">
49
- <Transition name="sidebar-fade">
50
- <button
51
- v-if="isSidebarOpen"
52
- type="button"
53
- aria-label="Close navigation"
54
- class="fixed inset-x-0 top-14 bottom-0 z-40 bg-black/50 backdrop-blur-xs lg:hidden"
55
- @click="closeSidebar"
56
- />
57
- </Transition>
58
-
59
- <aside
60
- :class="isSidebarOpen ? 'translate-x-0' : '-translate-x-full'"
61
- class="dark:bg-admin-sidebar light:bg-zinc-100 border-default fixed inset-y-14 left-0 z-50 flex w-[min(18rem,85vw)] shrink-0 flex-col border-r px-3 py-4 shadow-2xl transition-transform duration-200 lg:static lg:inset-auto lg:z-auto lg:w-64 lg:translate-x-0 lg:shadow-none"
62
- >
63
- <div class="mb-3 flex items-center justify-between px-2 lg:hidden">
64
- <span class="text-muted text-sm font-semibold">Navigation</span>
65
- <UButton
66
- icon="lucide:x"
67
- color="neutral"
68
- variant="ghost"
69
- aria-label="Close navigation"
70
- square
71
- @click="closeSidebar"
72
- />
73
- </div>
74
-
75
- <UNavigationMenu
76
- :items="menu"
77
- :ui="{
78
- link: 'text-base gap-x-3 font-normal',
79
- }"
80
- orientation="vertical"
81
- class="data-[orientation=vertical]:w-full"
82
- highlight
83
- />
84
- </aside>
44
+ <UDashboardSidebar
45
+ id="admin"
46
+ v-model:open="isSidebarOpen"
47
+ :default-size="16"
48
+ :min-size="12"
49
+ :max-size="24"
50
+ :ui="{
51
+ root: 'min-h-0 dark:bg-admin-sidebar light:bg-zinc-100',
52
+ content: 'lg:hidden w-[min(18rem,85vw)]',
53
+ }"
54
+ resizable
55
+ >
56
+ <UNavigationMenu
57
+ :items="menu"
58
+ :ui="{ link: 'text-base gap-x-3 font-normal' }"
59
+ orientation="vertical"
60
+ class="data-[orientation=vertical]:w-full"
61
+ highlight
62
+ />
63
+ </UDashboardSidebar>
85
64
 
86
- <div class="min-w-0 grow overflow-x-hidden overflow-y-auto">
65
+ <UDashboardPanel
66
+ :ui="{
67
+ root: 'min-h-0 dark:bg-admin-content light:bg-white font-outfit',
68
+ body: 'p-0 gap-0 overflow-x-hidden',
69
+ }"
70
+ >
71
+ <template #body>
87
72
  <slot />
88
- </div>
89
- </div>
90
- </div>
73
+ </template>
74
+ </UDashboardPanel>
75
+ </UDashboardGroup>
91
76
  </template>
92
-
93
- <style scoped>
94
- .sidebar-fade-enter-active,
95
- .sidebar-fade-leave-active {
96
- transition: opacity 200ms ease;
97
- }
98
-
99
- .sidebar-fade-enter-from,
100
- .sidebar-fade-leave-to {
101
- opacity: 0;
102
- }
103
- </style>
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@plutocms/pluto",
3
3
  "type": "module",
4
- "version": "0.10.0",
4
+ "version": "0.11.0",
5
5
  "description": "The layerable CMS",
6
6
  "author": "Victor Ribeiro",
7
7
  "license": "MIT",
@@ -42,31 +42,31 @@
42
42
  "vue-demi"
43
43
  ],
44
44
  "dependencies": {
45
- "@iconify-json/lucide": "^1.2.116",
46
- "@iconify-json/simple-icons": "^1.2.89",
47
- "@internationalized/date": "^3.12.2",
48
- "@nuxt/eslint": "^1.16.0",
49
- "@nuxt/ui": "^4.9.0",
45
+ "@iconify-json/lucide": "^1.2.144",
46
+ "@iconify-json/simple-icons": "^1.2.100",
47
+ "@internationalized/date": "^3.12.4",
48
+ "@nuxt/eslint": "^1.17.0",
49
+ "@nuxt/ui": "^4.12.0",
50
50
  "@plutocms/utils": "^0.2.1",
51
- "@vueuse/nuxt": "^14.3.0",
52
- "reka-ui": "^2.10.1",
53
- "tailwind-merge": "^3.6.0"
51
+ "@vueuse/nuxt": "^14.4.0",
52
+ "reka-ui": "^2.11.0",
53
+ "tailwind-merge": "^3.7.0"
54
54
  },
55
55
  "devDependencies": {
56
- "@antfu/eslint-config": "^9.1.0",
57
- "@tailwindcss/vite": "^4.2.1",
58
- "@types/bun": "^1.3.14",
59
- "eslint": "^10.6.0",
60
- "mysql2": "^3.22.6",
61
- "nuxt": "^4.3.1",
62
- "prettier": "^3.9.4",
63
- "prettier-plugin-tailwindcss": "^0.8.0",
64
- "tailwindcss": "^4.3.2",
56
+ "@antfu/eslint-config": "^9.5.1",
57
+ "@tailwindcss/vite": "^4.3.3",
58
+ "@types/bun": "^1.4.3",
59
+ "eslint": "^10.12.0",
60
+ "mysql2": "^3.24.6",
61
+ "nuxt": "^4.6.1",
62
+ "prettier": "^3.9.10",
63
+ "prettier-plugin-tailwindcss": "^0.8.1",
64
+ "tailwindcss": "^4.3.3",
65
65
  "typescript": "6.0.3",
66
- "vitest": "^5.0.0",
67
- "vue": "^3.5.39",
68
- "vue-router": "^5.1.0",
69
- "vue-tsc": "^3.3.7"
66
+ "vitest": "^5.0.5",
67
+ "vue": "^3.5.44",
68
+ "vue-router": "^5.4.0",
69
+ "vue-tsc": "^3.3.13"
70
70
  },
71
71
  "overrides": {
72
72
  "eslint-flat-config-utils": "^3.2.0"
@@ -110,6 +110,8 @@ export interface PlutoContentTypeCapabilities {
110
110
  read?: string
111
111
  write?: string
112
112
  delete?: string
113
+ /** Capability required to publish. Falls back to `write` when unset. */
114
+ publish?: string
113
115
  }
114
116
 
115
117
  export interface PlutoContentStatus {
@@ -209,6 +211,17 @@ export interface PlutoContentType {
209
211
  editPath?: (id: string | number) => string
210
212
  navOrder?: number
211
213
  i18n?: PlutoContentI18n
214
+ /** Layout options for the generic content form (a later wave). */
215
+ form?: {
216
+ /** 'default' = heading + container. 'focus' = minimal chrome for a writing surface. */
217
+ layout?: 'default' | 'focus'
218
+ /** true = always show. false = never. 'auto' (default) = show when any field has region 'side'. */
219
+ aside?: boolean | 'auto'
220
+ /** Aside starts closed. Default false, or true when layout is 'focus'. */
221
+ asideCollapsed?: boolean
222
+ /** Drop the max-width container in the main region. Default false, or true when layout is 'focus'. */
223
+ width?: 'container' | 'full'
224
+ }
212
225
  /**
213
226
  * Server-only lifecycle hooks, run by the generic write handlers
214
227
  * (`server/utils/pluto-content-handlers.ts`) after the adapter call
@@ -113,6 +113,34 @@ export interface PlutoMediaAdapterEntry extends PlutoEntry {
113
113
  adapter: PlutoMediaAdapter
114
114
  }
115
115
 
116
+ /** State passed to a `PlutoContentAction`'s `show` test and `onSelect` handler. */
117
+ export interface PlutoContentActionContext {
118
+ type: PlutoContentType
119
+ item: Record<string, unknown>
120
+ editing: boolean
121
+ saving: boolean
122
+ save: () => Promise<void>
123
+ remove: () => Promise<void>
124
+ }
125
+
126
+ /**
127
+ * A button a layer adds to the generic content form (a later wave). Matched
128
+ * against a content type the same way `PlutoContentFieldWidget` matches a
129
+ * field: by exact name, or by `'*'` for every content type.
130
+ */
131
+ export interface PlutoContentAction extends PlutoEntry {
132
+ /** Content type `name`, or '*' for every content type. */
133
+ contentType: string | '*'
134
+ label: string
135
+ icon?: string
136
+ color?: string
137
+ variant?: string
138
+ /** Where the action renders. Default 'toolbar'. */
139
+ placement?: 'toolbar' | 'aside'
140
+ show?: (ctx: PlutoContentActionContext) => boolean
141
+ onSelect: (ctx: PlutoContentActionContext) => void | Promise<void>
142
+ }
143
+
116
144
  export interface PlutoExtension {
117
145
  /** Layer name. Use the package's $meta.name, e.g. 'supabase-blog'. */
118
146
  id: string
@@ -127,6 +155,7 @@ export interface PlutoExtension {
127
155
  permissionsDriver?: PlutoPermissionsDriver
128
156
  contentTypes?: PlutoContentTypeEntry[]
129
157
  contentFieldWidgets?: PlutoContentFieldWidget[]
158
+ contentActions?: PlutoContentAction[]
130
159
  mediaAdapters?: PlutoMediaAdapterEntry[]
131
160
  }
132
161
 
@@ -154,5 +183,6 @@ export interface PlutoRegistry {
154
183
  permissionsDrivers: PlutoRegistryBucket<PlutoPermissionsDriver>
155
184
  contentTypes: PlutoRegistryBucket<PlutoContentTypeEntry>
156
185
  contentFieldWidgets: PlutoRegistryBucket<PlutoContentFieldWidget>
186
+ contentActions: PlutoRegistryBucket<PlutoContentAction>
157
187
  mediaAdapters: PlutoRegistryBucket<PlutoMediaAdapterEntry>
158
188
  }
@@ -43,6 +43,14 @@ export function mapFieldsToColumns(
43
43
  * `mapFieldsToColumns` — it has no column to read back from `row` in the
44
44
  * first place, so this only guards against a stray column that happens to
45
45
  * share the field's name.
46
+ *
47
+ * When `type.status` is set, this also maps the status column
48
+ * (`type.status.column`, default `'status'`) to `item.status`, unless a
49
+ * declared field already set `item.status` above. This mirrors the write
50
+ * side: `@plutocms/supabase`'s `content-adapter.ts` already writes the
51
+ * status column for a content type with workflow enabled, even when no
52
+ * field named `status` is declared. Without this, a generic list/form
53
+ * read back a row with no `status` key at all.
46
54
  */
47
55
  export function mapColumnsToFields(
48
56
  type: PlutoContentType,
@@ -61,5 +69,12 @@ export function mapColumnsToFields(
61
69
  }
62
70
  }
63
71
 
72
+ if (type.status) {
73
+ const statusColumn = type.status.column ?? 'status'
74
+ if (!Object.hasOwn(item, 'status') && Object.hasOwn(row, statusColumn)) {
75
+ item.status = row[statusColumn]
76
+ }
77
+ }
78
+
64
79
  return item as PlutoContentItem
65
80
  }