@plutocms/core 0.38.2 → 0.39.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,6 +1,20 @@
1
1
  # Changelog
2
2
 
3
3
 
4
+ ## [0.39.1](https://github.com/plutocms/pluto/compare/v0.39.0...v0.39.1) (2026-10-31)
5
+
6
+
7
+ ### Bug Fixes
8
+
9
+ * **admin:** navbar spinner icon, no focus ring after a hover menu, Pluto mark only ([#299](https://github.com/plutocms/pluto/issues/299)) ([6c87c62](https://github.com/plutocms/pluto/commit/6c87c62e73b633607f3ab13e478aa9711709b446))
10
+
11
+ ## [0.39.0](https://github.com/plutocms/pluto/compare/v0.38.2...v0.39.0) (2026-10-31)
12
+
13
+
14
+ ### Features
15
+
16
+ * **core:** one New menu in the admin bar and nav groups for content types ([#295](https://github.com/plutocms/pluto/issues/295)) ([ca60438](https://github.com/plutocms/pluto/commit/ca60438c8d3675f6f667f5bc177573ac9700ccfa))
17
+
4
18
  ## [0.38.2](https://github.com/plutocms/pluto/compare/v0.38.1...v0.38.2) (2026-10-30)
5
19
 
6
20
 
package/FEATURES.md CHANGED
@@ -101,7 +101,7 @@ policies shared by every project here.
101
101
  `defineAsyncComponent`, shows a skeleton per field, widget and settings panel while a chunk
102
102
  loads, and warns in dev about a registered component that is not async.
103
103
  @.claude/skills/extension-registry/SKILL.md
104
- - Admin shell: core owns the `/admin` parent route, the `/admin/login` page, the route guard, the admin banners, the navbar and the user menu. A backend layer registers an auth driver, account menu items, site info and link actions. The admin is for staff only: a signed-in user with no capability goes to `/admin/no-access`. The bar shows on `/admin/**` for staff, and on public pages for staff only. A route hides the bar with the page meta `plutoAdminBar: false`, and `isPlutoPreviewPath` marks the preview path. The bar height is the token `--pluto-admin-bar-height`, and `<html>` gets `data-pluto-admin-bar` while the bar shows. A hint cookie puts the bar frame in the server HTML of a public page for staff (the actions and the user menu load after the mount). Content types with `adminBar` get "New <type>" and "Edit <type>" buttons (`usePlutoEditLink`). @.claude/skills/admin-shell/SKILL.md
104
+ - Admin shell: core owns the `/admin` parent route, the `/admin/login` page, the route guard, the admin banners, the navbar and the user menu. A backend layer registers an auth driver, account menu items, site info and link actions. The admin is for staff only: a signed-in user with no capability goes to `/admin/no-access`. The bar shows on `/admin/**` for staff, and on public pages for staff only. A route hides the bar with the page meta `plutoAdminBar: false`, and `isPlutoPreviewPath` marks the preview path. The bar height is the token `--pluto-admin-bar-height`, and `<html>` gets `data-pluto-admin-bar` while the bar shows. A hint cookie puts the bar frame in the server HTML of a public page for staff (the actions and the user menu load after the mount). Content types with `adminBar` get "New <type>" and "Edit <type>" buttons (`usePlutoEditLink`). With two or more types, one "New" menu lists them, grouped by `navGroup`. A `navGroup` also puts related types under one sidebar item (registry bucket `navGroups`, core group `pages`). @.claude/skills/admin-shell/SKILL.md
105
105
  - Local layers helper: `plutoLayer(name)` from `@plutocms/core/layers` gives a Nuxt `extends` entry.
106
106
  With `PLUTO_LOCAL_ROOT`, it uses the local checkouts of the Pluto layers. @.claude/skills/local-layers/SKILL.md
107
107
  - Pluto logo: `PlutoLogo` draws the logo inline. The wordmark takes the text color, so the logo works
@@ -0,0 +1,41 @@
1
+ <script setup lang="ts">
2
+ // The admin sidebar menu (UNavigationMenu) with a "+" button at the right end of
3
+ // each content type item (`newTo` of the menu item). See PlutoAdminNavNew for
4
+ // why the button is not inside the item link. The `item` and `childItem` are
5
+ // `relative`, so the button can sit on the row.
6
+ //
7
+ // The `item-trailing` slot replaces the default trailing content, so this
8
+ // component draws the chevron of a group again, with the classes of Nuxt UI.
9
+ defineProps<{
10
+ collapsed?: boolean
11
+ }>()
12
+
13
+ const appConfig = useAppConfig()
14
+ </script>
15
+
16
+ <template>
17
+ <UNavigationMenu
18
+ :collapsed="collapsed"
19
+ :ui="{
20
+ item: 'relative',
21
+ childItem: 'relative',
22
+ link: 'gap-x-2.5 py-2',
23
+ linkLeadingIcon: 'size-4.5',
24
+ }"
25
+ >
26
+ <template #item-trailing="{ item, active, ui }">
27
+ <PlutoAdminNavNew
28
+ v-if="item.newTo && !collapsed"
29
+ :label="item.newLabel ?? ''"
30
+ :to="item.newTo"
31
+ :chevron="Boolean(item.children?.length)"
32
+ />
33
+ <UIcon
34
+ v-if="item.children?.length"
35
+ :name="item.trailingIcon || appConfig.ui.icons.chevronDown"
36
+ :class="ui.linkTrailingIcon({ active })"
37
+ data-slot="linkTrailingIcon"
38
+ />
39
+ </template>
40
+ </UNavigationMenu>
41
+ </template>
@@ -0,0 +1,61 @@
1
+ <script setup lang="ts">
2
+ // The "+" button of a content type item in the admin sidebar. It renders in the
3
+ // `item-trailing` slot of the item, so it knows the item. But a link cannot hold
4
+ // a button (invalid HTML). So the slot holds only an empty spacer that keeps room
5
+ // at the right end of the row. On mount, the component adds a host element right
6
+ // after the item link (a sibling, inside the same <li>) and teleports the button
7
+ // there. The button is then after its link in the tab order and does not sit in
8
+ // it. A click on the button does not reach the link.
9
+ //
10
+ // The button is always in the tab order. It is invisible until the row is hovered,
11
+ // or the link or the button has the focus. A hidden control that cannot be reached
12
+ // would need a changing `tabindex`, and keyboard users would not know it is there.
13
+ // Focus makes it show.
14
+ const props = defineProps<{
15
+ label: string
16
+ to: string
17
+ /** The item has a group chevron at its right end, so the button sits left of it. */
18
+ chevron?: boolean
19
+ }>()
20
+
21
+ const anchor = useTemplateRef<HTMLElement>('anchor')
22
+ const host = shallowRef<HTMLElement>()
23
+
24
+ onMounted(() => {
25
+ const link = anchor.value?.closest('a, button')
26
+ if (!link) {
27
+ return
28
+ }
29
+ const el = document.createElement('div')
30
+ // The <li> is `relative` (the menu `ui`). The row is 34 px high (py-2 and a
31
+ // size-4.5 icon), so the host has that height and centers the button.
32
+ el.className = [
33
+ 'absolute top-0 flex h-8.5 items-center opacity-0',
34
+ // Show on hover or focus of the link, and on hover or focus of the button.
35
+ '[:is(a,button):hover+&]:opacity-100 [:is(a,button):focus-visible+&]:opacity-100',
36
+ 'hover:opacity-100 focus-within:opacity-100',
37
+ props.chevron ? 'right-9' : 'right-2.5',
38
+ ].join(' ')
39
+ link.after(el)
40
+ host.value = el
41
+ })
42
+
43
+ onBeforeUnmount(() => host.value?.remove())
44
+ </script>
45
+
46
+ <template>
47
+ <span ref="anchor" class="size-5 shrink-0" aria-hidden="true" />
48
+ <Teleport v-if="host" :to="host">
49
+ <UButton
50
+ :aria-label="label"
51
+ :title="label"
52
+ class="cursor-pointer"
53
+ icon="i-lucide-plus"
54
+ color="neutral"
55
+ variant="ghost"
56
+ size="xs"
57
+ square
58
+ @click="navigateTo(to)"
59
+ />
60
+ </Teleport>
61
+ </template>
@@ -44,9 +44,12 @@ const siteDomain = computed(() => domainFromUrl(site.url.value) || host)
44
44
  <NuxtLink
45
45
  :title="t('pluto.navbar.goHome')"
46
46
  to="/admin/home"
47
- class="group flex h-full items-center gap-x-2"
47
+ class="group outline-inverted/40 flex h-full items-center gap-x-2 rounded-sm focus-visible:outline-2 focus-visible:-outline-offset-2"
48
48
  >
49
- <!-- A brand logo (white label), else the Pluto logo. The mark only on a small screen. -->
49
+ <!--
50
+ A brand logo (white label), its mark only on a small screen. Else the Pluto
51
+ mark: the site chip next to it names the site.
52
+ -->
50
53
  <template v-if="brandLogo">
51
54
  <img
52
55
  :src="brandMark"
@@ -59,17 +62,12 @@ const siteDomain = computed(() => domainFromUrl(site.url.value) || host)
59
62
  class="hidden h-7 w-auto max-w-40 object-contain sm:block"
60
63
  />
61
64
  </template>
62
- <template v-else>
63
- <PlutoLogo
64
- aria-hidden="true"
65
- class="block h-6.75 w-auto grayscale-100 group-hover:grayscale-0 sm:hidden"
66
- mark
67
- />
68
- <PlutoLogo
69
- aria-hidden="true"
70
- class="text-highlighted hidden h-7.75 w-auto grayscale-100 group-hover:grayscale-0 sm:block"
71
- />
72
- </template>
65
+ <PlutoLogo
66
+ v-else
67
+ aria-hidden="true"
68
+ class="block h-6.75 w-auto grayscale-100 group-hover:grayscale-0"
69
+ mark
70
+ />
73
71
  <!-- The site that this admin edits, as a chip. -->
74
72
  <span
75
73
  v-if="siteName"
@@ -80,7 +78,12 @@ const siteDomain = computed(() => domainFromUrl(site.url.value) || host)
80
78
  </NuxtLink>
81
79
  </div>
82
80
 
83
- <div class="h-full min-w-0 overflow-hidden">
81
+ <!--
82
+ The overflow clip keeps a long list of actions from pushing the bar. The
83
+ padding and the negative margin give the focus ring of the first and the last
84
+ action room inside the clip, without moving them.
85
+ -->
86
+ <div class="-mx-1 h-full min-w-0 overflow-hidden px-1">
84
87
  <ul class="flex h-full min-w-0 items-center gap-x-1 text-sm">
85
88
  <PlutoNavbarButton
86
89
  v-if="isAdmin"
@@ -97,7 +100,11 @@ const siteDomain = computed(() => domainFromUrl(site.url.value) || host)
97
100
  <div
98
101
  class="text-dimmed flex h-full items-center gap-x-2 rounded-sm px-2"
99
102
  >
100
- <Icon name="svg-spinners:ring-resize" />
103
+ <!-- Lucide: an app's icon bundle always has it. -->
104
+ <Icon
105
+ name="i-lucide-loader-circle"
106
+ class="size-4 animate-spin"
107
+ />
101
108
  </div>
102
109
  </li>
103
110
  </template>
@@ -127,21 +127,8 @@ const items = computed<DropdownMenuItem[][]>(() => [
127
127
 
128
128
  defineShortcuts(computed(() => extractShortcuts(items.value)))
129
129
 
130
- // The default portal is the end of <body>, outside the admin scope. This
131
- // target carries `data-pluto-admin-scope`, so the menu gets the admin theme and
132
- // the admin radius on a public page too. The target is at the end of <body>,
133
- // not in the navbar: the navbar is fixed with a z-index, so it makes a
134
- // stacking context, and a menu inside it cannot show above a site header with
135
- // the same z-index. The component makes the element itself. A target that Vue
136
- // renders can appear after the portal looks for it.
137
- const portalTarget = shallowRef<HTMLElement>()
138
- onMounted(() => {
139
- const el = document.createElement('div')
140
- el.setAttribute('data-pluto-admin-scope', '')
141
- document.body.append(el)
142
- portalTarget.value = el
143
- })
144
- onUnmounted(() => portalTarget.value?.remove())
130
+ // The portal target gives the menu the admin theme on a public page too.
131
+ const portalTarget = usePlutoAdminPortal()
145
132
  </script>
146
133
 
147
134
  <template>
@@ -151,6 +138,7 @@ onUnmounted(() => portalTarget.value?.remove())
151
138
  :ui="{ content: 'w-64' }"
152
139
  :modal="false"
153
140
  :portal="portalTarget ?? true"
141
+ :arrow="{ width: 14, height: 7 }"
154
142
  >
155
143
  <!--
156
144
  The button fills the height of the bar. The pill inside it keeps the
@@ -162,7 +150,7 @@ onUnmounted(() => portalTarget.value?.remove())
162
150
  type="button"
163
151
  >
164
152
  <div
165
- class="outline-inverted/25 group-hover:bg-elevated group-focus-visible:bg-elevated flex h-8 items-center gap-x-2 rounded-sm rounded-l-xl pr-2 pl-0.5 group-focus-visible:outline-3"
153
+ class="outline-inverted/25 group-hover:bg-elevated group-focus-visible:bg-elevated group-data-[state=open]:bg-elevated flex h-8 items-center gap-x-2 rounded-sm rounded-l-xl pr-2 pl-0.5 group-focus-visible:outline-3"
166
154
  >
167
155
  <UAvatar
168
156
  :src="avatar"
@@ -1,11 +1,15 @@
1
1
  <script setup lang="ts">
2
2
  // The admin bar buttons of the content types that set `adminBar`, for a user
3
3
  // with the `write` capability of the type:
4
- // - "New <type>" on every page where the bar shows.
4
+ // - "New <type>" on every page where the bar shows. With two or more types, one
5
+ // "New" button opens a menu. The types without a group are direct items. A
6
+ // group (`navGroup`) with two or more types is a submenu.
5
7
  // - "Edit <type>" on a public page that called `usePlutoEditLink`. The admin
6
8
  // form has its own View link, so it does not show on /admin.
7
9
  // Each button is an <li>, because the bar actions sit in a <ul>.
10
+ import type { DropdownMenuItem } from '@nuxt/ui'
8
11
  import { isAdminPath } from '../../../composables/internal/admin-shell'
12
+ import { buildNewMenu } from '../../../composables/internal/new-menu'
9
13
 
10
14
  const route = useRoute()
11
15
  const { items: contentTypes, byName } = usePlutoContentTypes()
@@ -13,21 +17,161 @@ const { can } = usePlutoPermissions()
13
17
  const { t } = usePlutoI18n()
14
18
  const texts = usePlutoContentTexts()
15
19
  const current = usePlutoCurrentContent()
20
+ const registry = usePlutoRegistry()
21
+ const { tx } = usePlutoI18n()
22
+ const portalTarget = usePlutoAdminPortal()
23
+
24
+ // The New menu also opens on hover, after a short delay, and closes a short
25
+ // time after the pointer leaves the button and the menu. A pointer on the portal
26
+ // target counts as inside, because the menu and its submenus render there. This
27
+ // keeps the move from the button into the menu, and into a submenu, open.
28
+ // Touch has no hover: a tap opens the menu as before. Click and keyboard use the
29
+ // open state of the dropdown, so they work as before.
30
+ const HOVER_OPEN_DELAY = 120
31
+ const HOVER_CLOSE_DELAY = 200
32
+ const menuOpen = ref(false)
33
+ // True while the menu is open because of a hover. A click on the button then
34
+ // keeps it open, instead of toggling it closed. The pointer leaving closes it.
35
+ let openedByHover = false
36
+ // True when the last open was a hover. Then the close does not move the focus
37
+ // back to the button: the focus ring would stay on it after the pointer left.
38
+ // A menu opened by a click or the keyboard returns the focus, as before.
39
+ let skipFocusReturn = false
40
+ let openTimer: ReturnType<typeof setTimeout> | undefined
41
+ let closeTimer: ReturnType<typeof setTimeout> | undefined
42
+ // The time until which a close request is ignored (see onUpdateOpen).
43
+ let confirmUntil = 0
44
+
45
+ function clearTimers() {
46
+ clearTimeout(openTimer)
47
+ clearTimeout(closeTimer)
48
+ }
49
+
50
+ function onHoverEnter(event: PointerEvent) {
51
+ if (event.pointerType === 'touch') {
52
+ return
53
+ }
54
+ clearTimers()
55
+ if (!menuOpen.value) {
56
+ openTimer = setTimeout(() => {
57
+ if (!menuOpen.value) {
58
+ openedByHover = true
59
+ skipFocusReturn = true
60
+ menuOpen.value = true
61
+ }
62
+ }, HOVER_OPEN_DELAY)
63
+ }
64
+ }
65
+
66
+ function onHoverLeave(event: PointerEvent) {
67
+ if (event.pointerType === 'touch') {
68
+ return
69
+ }
70
+ clearTimers()
71
+ if (menuOpen.value) {
72
+ closeTimer = setTimeout(() => {
73
+ menuOpen.value = false
74
+ }, HOVER_CLOSE_DELAY)
75
+ }
76
+ }
77
+
78
+ // The click confirms the menu that the hover opened. The dropdown must not
79
+ // toggle it (pointerdown) and must not see the focus leave its content
80
+ // (mousedown moves the focus to the button).
81
+ function keepsHoverMenu(event: PointerEvent | MouseEvent): boolean {
82
+ return (
83
+ (!('pointerType' in event) || event.pointerType !== 'touch') &&
84
+ menuOpen.value &&
85
+ openedByHover
86
+ )
87
+ }
88
+
89
+ function onTriggerPointerDown(event: PointerEvent) {
90
+ // A click is faster than the hover delay: it decides, not the hover.
91
+ clearTimeout(openTimer)
92
+ if (keepsHoverMenu(event)) {
93
+ confirmUntil = Date.now() + 500
94
+ event.stopPropagation()
95
+ }
96
+ }
97
+
98
+ // The dropdown asks to close on the click that confirms a hover menu (a pointer
99
+ // or focus event outside its content). Ignore that request for a moment.
100
+
101
+ function onUpdateOpen(open: boolean) {
102
+ if (!open && Date.now() < confirmUntil) {
103
+ return
104
+ }
105
+ if (open) {
106
+ skipFocusReturn = false
107
+ }
108
+ menuOpen.value = open
109
+ }
110
+
111
+ function onCloseAutoFocus(event: Event) {
112
+ if (skipFocusReturn) {
113
+ event.preventDefault()
114
+ }
115
+ }
116
+
117
+ function onTriggerMouseDown(event: MouseEvent) {
118
+ if (keepsHoverMenu(event)) {
119
+ event.preventDefault()
120
+ }
121
+ }
122
+
123
+ watch(menuOpen, (open) => {
124
+ if (!open) {
125
+ openedByHover = false
126
+ clearTimers()
127
+ }
128
+ })
129
+
130
+ watch(portalTarget, (el, previous) => {
131
+ previous?.removeEventListener('pointerenter', onHoverEnter)
132
+ previous?.removeEventListener('pointerleave', onHoverLeave)
133
+ el?.addEventListener('pointerenter', onHoverEnter)
134
+ el?.addEventListener('pointerleave', onHoverLeave)
135
+ })
136
+
137
+ onBeforeUnmount(() => {
138
+ clearTimers()
139
+ portalTarget.value?.removeEventListener('pointerenter', onHoverEnter)
140
+ portalTarget.value?.removeEventListener('pointerleave', onHoverLeave)
141
+ })
16
142
 
17
143
  // No capability declared means open to anyone who can reach the admin, like the form.
18
144
  function canWrite(type: PlutoContentType): boolean {
19
145
  return !type.capabilities?.write || can(type.capabilities.write)
20
146
  }
21
147
 
22
- const newButtons = computed(() =>
23
- contentTypes.value
24
- .map((entry) => entry.type)
25
- .filter((type) => contentTypeAdminBar(type).new && canWrite(type))
26
- .map((type) => ({
27
- name: type.name,
28
- label: texts(type.name).newLabel,
29
- to: contentTypeNewPath(type),
30
- }))
148
+ const newEntries = computed(() =>
149
+ contentTypes.value.filter(
150
+ ({ type }) => contentTypeAdminBar(type).new && canWrite(type)
151
+ )
152
+ )
153
+
154
+ // One type: a "New <type>" button.
155
+ const newButton = computed(() => {
156
+ const type = newEntries.value[0]?.type
157
+ return newEntries.value.length === 1 && type
158
+ ? {
159
+ label: texts(type.name).newLabel,
160
+ to: contentTypeNewPath(type),
161
+ }
162
+ : null
163
+ })
164
+
165
+ // Two or more types: one menu (see buildNewMenu). The types without a group are
166
+ // direct items, and a group with two or more types is a submenu.
167
+ const newMenuItems = computed<DropdownMenuItem[]>(() =>
168
+ newEntries.value.length < 2
169
+ ? []
170
+ : buildNewMenu(newEntries.value, registry.navGroups.list.value, {
171
+ label: (type) => texts(type.name).label,
172
+ groupLabel: (group) => tx(group.label),
173
+ to: contentTypeNewPath,
174
+ })
31
175
  )
32
176
 
33
177
  const editButton = computed(() => {
@@ -51,12 +195,41 @@ const editButton = computed(() => {
51
195
 
52
196
  <template>
53
197
  <PlutoNavbarButton
54
- v-for="button in newButtons"
55
- :key="button.name"
56
- :label="button.label"
57
- :to="button.to"
198
+ v-if="newButton"
199
+ :label="newButton.label"
200
+ :to="newButton.to"
58
201
  icon="i-lucide-plus"
59
202
  />
203
+ <li
204
+ v-else-if="newMenuItems.length > 0"
205
+ class="h-full shrink-0"
206
+ @pointerenter="onHoverEnter"
207
+ @pointerleave="onHoverLeave"
208
+ @pointerdown.capture="onTriggerPointerDown"
209
+ @mousedown.capture="onTriggerMouseDown"
210
+ >
211
+ <!--
212
+ The portal and the modal flag follow the user menu. See usePlutoAdminPortal.
213
+ -->
214
+ <UDropdownMenu
215
+ :open="menuOpen"
216
+ :items="newMenuItems"
217
+ :content="{ align: 'start', onCloseAutoFocus }"
218
+ :modal="false"
219
+ :portal="portalTarget ?? true"
220
+ :ui="{ content: 'min-w-48' }"
221
+ :arrow="{ width: 14, height: 7 }"
222
+ @update:open="onUpdateOpen"
223
+ >
224
+ <PlutoNavbarHit
225
+ :label="t('pluto.content.newMenu')"
226
+ icon="i-lucide-plus"
227
+ size="sm"
228
+ >
229
+ <span class="hidden lg:inline">{{ t('pluto.content.newMenu') }}</span>
230
+ </PlutoNavbarHit>
231
+ </UDropdownMenu>
232
+ </li>
60
233
  <PlutoNavbarButton
61
234
  v-if="editButton"
62
235
  :label="editButton.label"
@@ -37,10 +37,9 @@ const { t } = usePlutoI18n()
37
37
  </div>
38
38
 
39
39
  <div class="min-h-0 flex-1 overflow-y-auto px-2 pb-4">
40
- <UNavigationMenu
40
+ <PlutoAdminNavMenu
41
41
  v-model="openGroups"
42
42
  :items="menu"
43
- :ui="{ link: 'gap-x-2.5 py-2', linkLeadingIcon: 'size-4.5' }"
44
43
  orientation="vertical"
45
44
  variant="pill"
46
45
  class="data-[orientation=vertical]:w-full"
@@ -36,7 +36,7 @@ defineProps<{
36
36
  :size="size"
37
37
  :square="square"
38
38
  as="span"
39
- class="group-hover:bg-elevated group-active:bg-elevated pointer-events-none group-focus-visible:outline-3"
39
+ class="group-hover:bg-elevated group-active:bg-elevated group-data-[state=open]:bg-elevated pointer-events-none group-focus-visible:outline-3"
40
40
  color="neutral"
41
41
  variant="ghost"
42
42
  aria-hidden="true"
@@ -0,0 +1,54 @@
1
+ import type { PlutoContentType } from '../../../shared/types/content'
2
+ import type { PlutoNavGroup } from '../../../shared/types/registry'
3
+
4
+ /**
5
+ * The group a content type names with `navGroup`. The full id of the group
6
+ * (`pluto:pages`) matches first. A bare id (`pages`) matches when one group
7
+ * has it. No match gives `undefined`, so the type stays a normal item.
8
+ */
9
+ export function findNavGroup(
10
+ groups: readonly PlutoNavGroup[],
11
+ name: string | undefined
12
+ ): PlutoNavGroup | undefined {
13
+ if (!name) {
14
+ return undefined
15
+ }
16
+ const exact = groups.find((group) => group.id === name)
17
+ if (exact) {
18
+ return exact
19
+ }
20
+ const bare = groups.filter((group) => group.id.endsWith(`:${name}`))
21
+ return bare.length === 1 ? bare[0] : undefined
22
+ }
23
+
24
+ export interface NavGroupBucket<T> {
25
+ group: PlutoNavGroup
26
+ items: T[]
27
+ }
28
+
29
+ /**
30
+ * Splits content types into the ones without a known group and the buckets of
31
+ * each group. The buckets follow the order of `groups`. The order of the
32
+ * types inside each part stays as it is.
33
+ */
34
+ export function splitByNavGroup<T extends { type: PlutoContentType }>(
35
+ entries: readonly T[],
36
+ groups: readonly PlutoNavGroup[]
37
+ ): { ungrouped: T[]; grouped: NavGroupBucket<T>[] } {
38
+ const ungrouped: T[] = []
39
+ const buckets = new Map<string, NavGroupBucket<T>>()
40
+ for (const entry of entries) {
41
+ const group = findNavGroup(groups, entry.type.navGroup)
42
+ if (!group) {
43
+ ungrouped.push(entry)
44
+ continue
45
+ }
46
+ const bucket = buckets.get(group.id) ?? { group, items: [] }
47
+ bucket.items.push(entry)
48
+ buckets.set(group.id, bucket)
49
+ }
50
+ return {
51
+ ungrouped,
52
+ grouped: groups.flatMap((group) => buckets.get(group.id) ?? []),
53
+ }
54
+ }
@@ -0,0 +1,52 @@
1
+ import type { DropdownMenuItem } from '@nuxt/ui'
2
+ import type { PlutoContentType } from '../../../shared/types/content'
3
+ import type { PlutoNavGroup } from '../../../shared/types/registry'
4
+ import { splitByNavGroup } from './nav-groups'
5
+
6
+ export interface NewMenuOptions {
7
+ /** The singular label of a type. */
8
+ label: (type: PlutoContentType) => string
9
+ /** The label of a group (already translated). */
10
+ groupLabel: (group: PlutoNavGroup) => string
11
+ /** The link to the new page of a type. */
12
+ to: (type: PlutoContentType) => string
13
+ }
14
+
15
+ /**
16
+ * The items of the "New" menu of the admin bar, for the types that can show a
17
+ * New button. The types without a group come first, as direct links. Each group
18
+ * follows, in the order of `groups`. A group with two or more types is a
19
+ * submenu (label and icon of the group, one link per type). A group with one
20
+ * type shows that type as a direct link. When the only entry is one group, the
21
+ * menu shows the types of the group directly, without the submenu. The caller shows a plain button, not
22
+ * this menu, when there is one type in total.
23
+ */
24
+ export function buildNewMenu(
25
+ entries: ReadonlyArray<{ type: PlutoContentType }>,
26
+ groups: readonly PlutoNavGroup[],
27
+ options: NewMenuOptions
28
+ ): DropdownMenuItem[] {
29
+ const link = ({ type }: { type: PlutoContentType }): DropdownMenuItem => ({
30
+ label: options.label(type),
31
+ icon: type.icon,
32
+ type: 'link',
33
+ to: options.to(type),
34
+ })
35
+ const { ungrouped, grouped } = splitByNavGroup(entries, groups)
36
+ const items: DropdownMenuItem[] = [
37
+ ...ungrouped.map(link),
38
+ ...grouped.flatMap(({ group, items }): DropdownMenuItem[] =>
39
+ items.length >= 2
40
+ ? [
41
+ {
42
+ label: options.groupLabel(group),
43
+ icon: group.icon,
44
+ children: items.map(link),
45
+ },
46
+ ]
47
+ : items.map(link)
48
+ ),
49
+ ]
50
+ const only = items.length === 1 ? items[0] : undefined
51
+ return only?.children ? (only.children as DropdownMenuItem[]) : items
52
+ }
@@ -4,7 +4,11 @@ import { activeNavGroups, nextOpenGroups } from './internal/admin-menu'
4
4
  // `to` is typed per-app via Nuxt's typed pages, so it differs across every
5
5
  // app that extends this layer. This menu only ever uses `href`, so drop `to`
6
6
  // here to avoid a cross-app type mismatch on that field.
7
- export type PlutoAdminMenuItem = Omit<NavigationMenuItem, 'to'>
7
+ export type PlutoAdminMenuItem = Omit<NavigationMenuItem, 'to'> & {
8
+ /** The new page of a content type. PlutoAdminNavMenu shows a "+" for it. */
9
+ newTo?: string
10
+ newLabel?: string
11
+ }
8
12
 
9
13
  function toMenuItem(item: PlutoNavItem, active: boolean): PlutoAdminMenuItem {
10
14
  return {
@@ -17,12 +21,17 @@ function toMenuItem(item: PlutoNavItem, active: boolean): PlutoAdminMenuItem {
17
21
  label: item.label,
18
22
  icon: item.icon,
19
23
  href: item.to,
24
+ newTo: item.newTo,
25
+ newLabel: item.newLabel,
20
26
  // Top-level items SPA-navigate instead of doing a full page load.
21
27
  // Children stay href-only.
22
28
  onSelect: item.to ? () => navigateTo(item.to) : undefined,
23
29
  children: item.children?.map((child) => ({
24
30
  label: child.label,
31
+ icon: child.icon,
25
32
  href: child.to,
33
+ newTo: child.newTo,
34
+ newLabel: child.newLabel,
26
35
  // A child that points at its parent's own path (Settings > General)
27
36
  // must not stay active on the sibling pages below that path.
28
37
  exact: child.to === item.to ? true : undefined,
@@ -1,5 +1,8 @@
1
+ import type { NavGroupBucket } from './internal/nav-groups'
2
+ import { contentTypeNewPath } from '../../shared/utils/content-admin-paths'
1
3
  import { contentTypeMatches } from './internal/admin-menu'
2
4
  import { contentTextFor } from './internal/content-text'
5
+ import { findNavGroup, splitByNavGroup } from './internal/nav-groups'
3
6
 
4
7
  // The id of core's Settings nav item (extension `pluto`, entry `settings`).
5
8
  const SETTINGS_NAV_ID = 'pluto:settings'
@@ -21,6 +24,12 @@ const SETTINGS_NAV_ID = 'pluto:settings'
21
24
  * labels come from `usePlutoContentText`, so the convention keys apply. The
22
25
  * `computed` reads the active locale, so a locale switch updates the labels.
23
26
  *
27
+ * A content type that names a group (`type.navGroup`, see the `navGroups`
28
+ * bucket) joins the group item when the group has two or more visible types.
29
+ * The item has the label and the icon of the group, and one child per type
30
+ * (its plural label, linked to its list page). It is active on the pages of its
31
+ * types. A group with one visible type shows that type as a normal item.
32
+ *
24
33
  * The Settings item (`pluto:settings`) gets one child per visible settings page
25
34
  * (`usePlutoSettingsPages`) when there are two or more. It opens by default
26
35
  * on `/admin/settings/**`.
@@ -36,12 +45,16 @@ const SETTINGS_NAV_ID = 'pluto:settings'
36
45
  export function usePlutoAdminNav() {
37
46
  const registry = usePlutoRegistry()
38
47
  const { items: contentTypes } = usePlutoContentTypes()
48
+ const navGroups = registry.navGroups.list
39
49
  const { can } = usePlutoPermissions()
40
50
  const { items: settingsPages, current: currentSettingsPage } =
41
51
  usePlutoSettingsPages()
42
52
  const i18n = usePlutoI18n()
43
53
  const { t, tx } = i18n
44
54
 
55
+ // Dev only. The groups already warned about, so each warns once.
56
+ const warnedGroups = new Set<string>()
57
+
45
58
  const items = computed<PlutoNavItem[]>(() => {
46
59
  const registered = registry.nav.list.value
47
60
  const resolveLabels = (item: PlutoNavItem): PlutoNavItem => ({
@@ -53,33 +66,98 @@ export function usePlutoAdminNav() {
53
66
  })),
54
67
  })
55
68
 
56
- const contentNav: PlutoNavItem[] = contentTypes.value
57
- .filter((entry) => entry.type.autoRoutes !== false)
58
- .map((entry) => {
59
- const type = entry.type
60
- const text = contentTextFor(type, i18n)
61
- const basePath = type.basePath ?? `/admin/content/${type.name}`
69
+ const isVisible = (type: PlutoContentType): boolean =>
70
+ !type.capabilities?.read || can(type.capabilities.read)
62
71
 
63
- return {
64
- id: `content:${type.name}`,
65
- order: type.navOrder ?? 100,
66
- label: text.labelPlural,
72
+ // The "+" of the sidebar: the new page of a type the user may write.
73
+ const canWrite = (type: PlutoContentType): boolean =>
74
+ !type.capabilities?.write || can(type.capabilities.write)
75
+ const newLink = (type: PlutoContentType) =>
76
+ canWrite(type)
77
+ ? {
78
+ newTo: contentTypeNewPath(type),
79
+ newLabel: contentTextFor(type, i18n).newLabel,
80
+ }
81
+ : {}
82
+
83
+ function typeNav(type: PlutoContentType): PlutoNavItem {
84
+ const text = contentTextFor(type, i18n)
85
+ const basePath = type.basePath ?? `/admin/content/${type.name}`
86
+
87
+ return {
88
+ id: `content:${type.name}`,
89
+ order: type.navOrder ?? 100,
90
+ label: text.labelPlural,
91
+ icon: type.icon,
92
+ to: basePath,
93
+ ...newLink(type),
94
+ enabled: () => isVisible(type),
95
+ children: [
96
+ {
97
+ label: t('pluto.content.all', { labelPlural: text.labelPlural }),
98
+ to: basePath,
99
+ },
100
+ {
101
+ label: t('pluto.content.createNew', { label: text.label }),
102
+ to: `${basePath}/new`,
103
+ },
104
+ ],
105
+ }
106
+ }
107
+
108
+ // One item for the visible types of a group. A child is the list page of a
109
+ // type. The `match` paths keep the item active on the new and edit pages.
110
+ function groupNav(bucket: NavGroupBucket<PlutoContentTypeEntry>) {
111
+ const types = bucket.items.map((entry) => entry.type)
112
+ const basePathOf = (type: PlutoContentType) =>
113
+ type.basePath ?? `/admin/content/${type.name}`
114
+ return {
115
+ id: `group:${bucket.group.id}`,
116
+ order: bucket.group.order ?? 100,
117
+ label: tx(bucket.group.label),
118
+ icon: bucket.group.icon,
119
+ children: types.map((type) => ({
120
+ label: contentTextFor(type, i18n).labelPlural,
121
+ to: basePathOf(type),
67
122
  icon: type.icon,
68
- to: basePath,
69
- enabled: () =>
70
- !type.capabilities?.read || can(type.capabilities.read),
71
- children: [
72
- {
73
- label: t('pluto.content.all', { labelPlural: text.labelPlural }),
74
- to: basePath,
75
- },
76
- {
77
- label: t('pluto.content.createNew', { label: text.label }),
78
- to: `${basePath}/new`,
79
- },
80
- ],
81
- }
82
- })
123
+ ...newLink(type),
124
+ })),
125
+ match: types.flatMap((type) =>
126
+ [type.newPath, type.editPath?.('').replace(/\/+$/, '')].filter(
127
+ (path): path is string => Boolean(path)
128
+ )
129
+ ),
130
+ } satisfies PlutoNavItem
131
+ }
132
+
133
+ const autoRouted = contentTypes.value.filter(
134
+ (entry) => entry.type.autoRoutes !== false && isVisible(entry.type)
135
+ )
136
+ const { ungrouped, grouped } = splitByNavGroup(autoRouted, navGroups.value)
137
+ for (const entry of autoRouted) {
138
+ const name = entry.type.navGroup
139
+ if (
140
+ import.meta.dev &&
141
+ name &&
142
+ !findNavGroup(navGroups.value, name) &&
143
+ !warnedGroups.has(name)
144
+ ) {
145
+ warnedGroups.add(name)
146
+ console.warn(
147
+ `[pluto] The content type "${entry.type.name}" names the nav group "${name}", but no extension declares it.`
148
+ )
149
+ }
150
+ }
151
+
152
+ // A group with one visible type shows that type as a normal item.
153
+ const contentNav: PlutoNavItem[] = [
154
+ ...ungrouped.map((entry) => typeNav(entry.type)),
155
+ ...grouped.flatMap((bucket) =>
156
+ bucket.items.length >= 2
157
+ ? [groupNav(bucket)]
158
+ : bucket.items.map((entry) => typeNav(entry.type))
159
+ ),
160
+ ]
83
161
 
84
162
  // The Settings item lists one child per visible settings page, when there
85
163
  // are two or more. With one page (General), the item stays a plain link.
@@ -0,0 +1,22 @@
1
+ /**
2
+ * A portal target for the dropdown menus of the admin bar. The default portal
3
+ * is the end of `<body>`, outside the admin scope. This target carries
4
+ * `data-pluto-admin-scope`, so a menu gets the admin theme and the admin
5
+ * radius on a public page too. The target is at the end of `<body>`, not in the
6
+ * navbar: the navbar is fixed with a z-index, so it makes a stacking context,
7
+ * and a menu inside it cannot show above a site header with the same z-index.
8
+ * The composable makes the element itself. A target that Vue renders can
9
+ * appear after the portal looks for it. Pass `portalTarget ?? true` to the
10
+ * `portal` prop of `UDropdownMenu`.
11
+ */
12
+ export function usePlutoAdminPortal() {
13
+ const portalTarget = shallowRef<HTMLElement>()
14
+ onMounted(() => {
15
+ const el = document.createElement('div')
16
+ el.setAttribute('data-pluto-admin-scope', '')
17
+ document.body.append(el)
18
+ portalTarget.value = el
19
+ })
20
+ onUnmounted(() => portalTarget.value?.remove())
21
+ return portalTarget
22
+ }
@@ -60,7 +60,7 @@ export function usePlutoCommandPalette() {
60
60
  ...(item.children ?? []).flatMap((child) =>
61
61
  child.to
62
62
  ? command(tx(child.label), child.to, {
63
- icon: item.icon,
63
+ icon: child.icon ?? item.icon,
64
64
  suffix: tx(item.label),
65
65
  })
66
66
  : []
@@ -54,6 +54,7 @@ export function definePlutoExtension(definition: PlutoExtension): void {
54
54
  )
55
55
  registry.contentActions.set(definition.id, definition.contentActions ?? [])
56
56
  registry.mediaKinds.set(definition.id, definition.mediaKinds ?? [])
57
+ registry.navGroups.set(definition.id, definition.navGroups ?? [])
57
58
  registry.contentToolbarItems.set(
58
59
  definition.id,
59
60
  definition.contentToolbarItems ?? []
@@ -34,6 +34,7 @@ export function usePlutoRegistry(): PlutoRegistry {
34
34
  contentActions: createOwnedRegistry<PlutoContentAction>(),
35
35
  contentToolbarItems: createOwnedRegistry<PlutoContentToolbarItem>(),
36
36
  mediaKinds: createOwnedRegistry<PlutoMediaKind>(),
37
+ navGroups: createOwnedRegistry<PlutoNavGroup>(),
37
38
  }
38
39
  }
39
40
 
@@ -63,11 +63,10 @@ const groupStyle = { top: 'var(--pluto-admin-bar-height)' }
63
63
  popover lists the children of a group. Nuxt UI keeps the state in the group cookie.
64
64
  -->
65
65
  <template #default="{ collapsed }">
66
- <UNavigationMenu
66
+ <PlutoAdminNavMenu
67
67
  v-model="openGroups"
68
68
  :items="menu"
69
69
  :collapsed="collapsed"
70
- :ui="{ link: 'gap-x-2.5 py-2', linkLeadingIcon: 'size-4.5' }"
71
70
  orientation="vertical"
72
71
  variant="pill"
73
72
  class="data-[orientation=vertical]:w-full"
@@ -69,8 +69,16 @@ export default defineNuxtPlugin(() => {
69
69
  definePlutoExtension({
70
70
  id: 'pluto',
71
71
  capabilities: [
72
- { id: 'media-manage', key: 'media:manage', label: 'pluto.media.capabilities.manage' },
73
- { id: 'media-delete', key: 'media:delete', label: 'pluto.media.capabilities.delete' },
72
+ {
73
+ id: 'media-manage',
74
+ key: 'media:manage',
75
+ label: 'pluto.media.capabilities.manage',
76
+ },
77
+ {
78
+ id: 'media-delete',
79
+ key: 'media:delete',
80
+ label: 'pluto.media.capabilities.delete',
81
+ },
74
82
  ],
75
83
  nav: [
76
84
  {
@@ -208,6 +216,16 @@ export default defineNuxtPlugin(() => {
208
216
  component: PlutoDashboardQuickActions,
209
217
  },
210
218
  ],
219
+ // The groups of content types in the sidebar and the admin bar. A type names one
220
+ // with `navGroup`. Order 100 puts the group with the content items.
221
+ navGroups: [
222
+ {
223
+ id: 'pages',
224
+ order: 100,
225
+ label: 'pluto.nav.pages',
226
+ icon: 'i-lucide-book-open',
227
+ },
228
+ ],
211
229
  // "New <type>" and "Edit <type>" for the content types that set `adminBar`.
212
230
  navbarActions: [
213
231
  { id: 'content', order: 50, component: PlutoContentBarActions },
@@ -39,7 +39,8 @@
39
39
  "nav": {
40
40
  "home": "Home",
41
41
  "settings": "Settings",
42
- "media": "Media"
42
+ "media": "Media",
43
+ "pages": "Pages"
43
44
  },
44
45
  "pages": {
45
46
  "dashboard": "Dashboard",
@@ -53,6 +54,7 @@
53
54
  "content": {
54
55
  "new": "New {label}",
55
56
  "edit": "Edit {label}",
57
+ "newMenu": "New",
56
58
  "all": "All {labelPlural}",
57
59
  "createNew": "Create new {label}",
58
60
  "typeNotFound": "Content type not found",
@@ -39,7 +39,8 @@
39
39
  "nav": {
40
40
  "home": "Início",
41
41
  "settings": "Configurações",
42
- "media": "Mídia"
42
+ "media": "Mídia",
43
+ "pages": "Páginas"
43
44
  },
44
45
  "pages": {
45
46
  "dashboard": "Painel",
@@ -53,6 +54,7 @@
53
54
  "content": {
54
55
  "new": "Criar {label}",
55
56
  "edit": "Editar {label}",
57
+ "newMenu": "Novo",
56
58
  "all": "Ver {labelPlural}",
57
59
  "createNew": "Criar {label}",
58
60
  "typeNotFound": "Tipo de conteúdo não encontrado",
package/nuxt.config.ts CHANGED
@@ -8,8 +8,9 @@ const utilsLayer = process.env.PLUTO_UTILS_PATH || '@plutocms/utils'
8
8
  export default defineNuxtConfig({
9
9
  extends: [utilsLayer],
10
10
 
11
+ // No @nuxt/eslint: a module of a layer runs in every app that extends it. The
12
+ // repo lints with @nuxt/eslint-config (eslint.config.mjs at the root).
11
13
  modules: [
12
- '@nuxt/eslint',
13
14
  '@nuxt/image',
14
15
  '@nuxt/ui',
15
16
  '@vueuse/nuxt',
@@ -107,15 +108,6 @@ export default defineNuxtConfig({
107
108
  typeCheck: false,
108
109
  },
109
110
 
110
- eslint: {
111
- config: {
112
- nuxt: {
113
- sortConfigKeys: true,
114
- },
115
- standalone: false,
116
- },
117
- },
118
-
119
111
  // Modules configurations
120
112
  fonts: {
121
113
  defaults: {
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@plutocms/core",
3
3
  "type": "module",
4
- "version": "0.38.2",
4
+ "version": "0.39.1",
5
5
  "description": "The base layer every Pluto app extends: the admin, the content model, and the GitHub CMS",
6
6
  "author": "Victor Ribeiro",
7
7
  "license": "MIT",
@@ -27,18 +27,10 @@
27
27
  "dev": "nuxt dev --no-fork",
28
28
  "generate": "nuxt generate",
29
29
  "preview": "nuxt preview",
30
- "postinstall": "nuxt prepare",
30
+ "dev:prepare": "nuxt prepare",
31
31
  "typecheck": "nuxt prepare && vue-tsc -b --noEmit",
32
32
  "test": "vitest run"
33
33
  },
34
- "trustedDependencies": [
35
- "@parcel/watcher",
36
- "@plutocms/utils",
37
- "@tailwindcss/oxide",
38
- "esbuild",
39
- "unrs-resolver",
40
- "vue-demi"
41
- ],
42
34
  "dependencies": {
43
35
  "@codemirror/commands": "^6.11.1",
44
36
  "@codemirror/lang-markdown": "^6.5.2",
@@ -49,7 +41,6 @@
49
41
  "@iconify-json/lucide": "^1.2.144",
50
42
  "@internationalized/date": "^3.12.4",
51
43
  "@lezer/highlight": "^1.2.5",
52
- "@nuxt/eslint": "^1.17.0",
53
44
  "@nuxt/image": "^2.1.0",
54
45
  "@nuxt/ui": "^4.12.0",
55
46
  "@plutocms/utils": ">=0.3.0 <1.0.0",
@@ -246,6 +246,15 @@ export interface PlutoContentType {
246
246
  edit?: boolean
247
247
  }
248
248
  navOrder?: number
249
+ /**
250
+ * The id of a nav group (the registry bucket `navGroups`), for example
251
+ * `'pluto:pages'`. The bare id of a group (`'pages'`) works when one group
252
+ * has it. The types of a group show as one sidebar item with one link per
253
+ * type, and in one "New" menu of the admin bar. A group with one visible type
254
+ * shows that type as a normal item. An unknown group is ignored. Unset: the
255
+ * type is its own item.
256
+ */
257
+ navGroup?: string
249
258
  i18n?: PlutoContentI18n
250
259
  /**
251
260
  * The public URL of an item, for the "View <label>" link in the form toolbar. Return
@@ -30,6 +30,18 @@ export interface PlutoMediaKind extends PlutoEntry {
30
30
  inspect?: (file: Blob) => Promise<string | null>
31
31
  }
32
32
 
33
+ /**
34
+ * A group of content types in the admin menus. A content type names its group
35
+ * with `navGroup`. The sidebar shows the types of a group as one item with one
36
+ * link per type, and the admin bar lists them in one "New" menu. Core declares
37
+ * the group `pages`. See the extension-registry skill.
38
+ */
39
+ export interface PlutoNavGroup extends PlutoEntry {
40
+ /** A translation key or literal: the name of the group ("Pages"). */
41
+ label: string
42
+ icon?: string
43
+ }
44
+
33
45
  export interface PlutoEntry {
34
46
  /** Unique inside this extension. Stored as `<extensionId>:<id>`. */
35
47
  id: string
@@ -42,6 +54,11 @@ export interface PlutoEntry {
42
54
  export interface PlutoNavChild {
43
55
  label: string
44
56
  to: string
57
+ icon?: string
58
+ /** The new page of a content type. The sidebar shows a "+" on hover. */
59
+ newTo?: string
60
+ /** The text of that "+" (`New <type>`). */
61
+ newLabel?: string
45
62
  }
46
63
 
47
64
  export interface PlutoNavItem extends PlutoEntry {
@@ -56,6 +73,10 @@ export interface PlutoNavItem extends PlutoEntry {
56
73
  match?: string[]
57
74
  children?: PlutoNavChild[]
58
75
  defaultOpen?: boolean
76
+ /** The new page of a content type, when the user may write it. The sidebar shows a "+" on hover. */
77
+ newTo?: string
78
+ /** The text of that "+" (`New <type>`). */
79
+ newLabel?: string
59
80
  }
60
81
 
61
82
  /** A navbar action that renders its own component. */
@@ -355,6 +376,7 @@ export interface PlutoExtension {
355
376
  contentActions?: PlutoContentAction[]
356
377
  contentToolbarItems?: PlutoContentToolbarItem[]
357
378
  mediaKinds?: PlutoMediaKind[]
379
+ navGroups?: PlutoNavGroup[]
358
380
  }
359
381
 
360
382
  /**
@@ -390,4 +412,5 @@ export interface PlutoRegistry {
390
412
  contentActions: PlutoRegistryBucket<PlutoContentAction>
391
413
  contentToolbarItems: PlutoRegistryBucket<PlutoContentToolbarItem>
392
414
  mediaKinds: PlutoRegistryBucket<PlutoMediaKind>
415
+ navGroups: PlutoRegistryBucket<PlutoNavGroup>
393
416
  }