@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 +7 -0
- package/FEATURES.md +4 -0
- package/app/assets/css/tailwind.css +15 -0
- package/app/components/Pluto/Content/PlutoContentForm.vue +324 -50
- package/app/components/Pluto/Content/PlutoContentList.vue +124 -106
- package/app/components/Pluto/Navbar/PlutoNavbarAdmin.vue +1 -1
- package/app/components/Pluto/View/PlutoViewAdminPanel.vue +41 -0
- package/app/components/Pluto/View/PlutoViewAside.vue +69 -0
- package/app/components/Pluto/View/PlutoViewToolbar.vue +64 -0
- package/app/composables/pluto-content-actions.ts +17 -0
- package/app/composables/pluto-extension.ts +1 -0
- package/app/composables/pluto-registry.ts +1 -0
- package/app/layouts/admin.vue +38 -65
- package/package.json +22 -22
- package/shared/types/content.ts +13 -0
- package/shared/types/registry.ts +30 -0
- package/shared/utils/content-map.ts +15 -0
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(
|
|
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
|
-
() =>
|
|
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(
|
|
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(
|
|
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(
|
|
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, {
|
|
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
|
|
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
|
-
<
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
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 #
|
|
175
|
-
<
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
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
|
-
<
|
|
190
|
-
v-
|
|
191
|
-
:
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
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
|
-
|
|
205
|
-
|
|
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
|
-
|
|
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
|
-
</
|
|
220
|
-
</
|
|
221
|
-
</
|
|
461
|
+
</PlutoViewAside>
|
|
462
|
+
</template>
|
|
463
|
+
</PlutoViewAdminPanel>
|
|
222
464
|
|
|
223
|
-
<
|
|
465
|
+
<PlutoViewAdminPanel v-else>
|
|
224
466
|
<UAlert
|
|
225
467
|
:description="`No content type named "${type}" is registered.`"
|
|
226
468
|
color="error"
|
|
227
469
|
title="Content type not found"
|
|
228
470
|
/>
|
|
229
|
-
</
|
|
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(
|
|
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
|
-
() =>
|
|
26
|
+
() =>
|
|
27
|
+
!contentType.value?.capabilities?.write ||
|
|
28
|
+
can(contentType.value.capabilities.write)
|
|
25
29
|
)
|
|
26
30
|
const canDelete = computed(
|
|
27
|
-
() =>
|
|
31
|
+
() =>
|
|
32
|
+
!contentType.value?.capabilities?.delete ||
|
|
33
|
+
can(contentType.value.capabilities.delete)
|
|
28
34
|
)
|
|
29
35
|
|
|
30
|
-
const basePath = computed(
|
|
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(
|
|
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) => ({
|
|
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
|
-
<
|
|
79
|
-
<
|
|
80
|
-
<
|
|
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
|
-
</
|
|
103
|
+
</template>
|
|
111
104
|
|
|
112
|
-
<
|
|
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
|
-
</
|
|
122
|
-
</
|
|
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
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
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
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
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(
|
|
147
|
-
|
|
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
|
-
</
|
|
175
|
+
</NuxtLink>
|
|
154
176
|
|
|
155
|
-
<
|
|
177
|
+
<button
|
|
156
178
|
v-if="canDelete"
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
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
|
-
</
|
|
184
|
+
</button>
|
|
165
185
|
</div>
|
|
166
|
-
</
|
|
167
|
-
</
|
|
186
|
+
</template>
|
|
187
|
+
</UTable>
|
|
168
188
|
</div>
|
|
189
|
+
</UCard>
|
|
190
|
+
</PlutoViewAdminPanel>
|
|
169
191
|
|
|
170
|
-
|
|
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 "${type}" is registered.`"
|
|
202
195
|
color="error"
|
|
203
196
|
title="Content type not found"
|
|
204
197
|
/>
|
|
205
|
-
</
|
|
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
|
|
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
|
}
|
package/app/layouts/admin.vue
CHANGED
|
@@ -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
|
-
|
|
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
|
-
<
|
|
46
|
-
class="
|
|
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
|
-
<
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
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
|
-
|
|
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
|
-
</
|
|
89
|
-
</
|
|
90
|
-
</
|
|
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.
|
|
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.
|
|
46
|
-
"@iconify-json/simple-icons": "^1.2.
|
|
47
|
-
"@internationalized/date": "^3.12.
|
|
48
|
-
"@nuxt/eslint": "^1.
|
|
49
|
-
"@nuxt/ui": "^4.
|
|
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.
|
|
52
|
-
"reka-ui": "^2.
|
|
53
|
-
"tailwind-merge": "^3.
|
|
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
|
|
57
|
-
"@tailwindcss/vite": "^4.
|
|
58
|
-
"@types/bun": "^1.3
|
|
59
|
-
"eslint": "^10.
|
|
60
|
-
"mysql2": "^3.
|
|
61
|
-
"nuxt": "^4.
|
|
62
|
-
"prettier": "^3.9.
|
|
63
|
-
"prettier-plugin-tailwindcss": "^0.8.
|
|
64
|
-
"tailwindcss": "^4.3.
|
|
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.
|
|
67
|
-
"vue": "^3.5.
|
|
68
|
-
"vue-router": "^5.
|
|
69
|
-
"vue-tsc": "^3.3.
|
|
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"
|
package/shared/types/content.ts
CHANGED
|
@@ -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
|
package/shared/types/registry.ts
CHANGED
|
@@ -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
|
}
|