@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.
@@ -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>