@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 +14 -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 +15 -0
- package/shared/types/registry.ts +30 -0
- package/shared/utils/content-map.ts +29 -0
|
@@ -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>
|