@plutocms/core 0.38.1 → 0.39.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 +15 -0
- package/FEATURES.md +1 -1
- package/README.md +6 -6
- package/app/components/Pluto/Admin/PlutoAdminNavMenu.vue +41 -0
- package/app/components/Pluto/Admin/PlutoAdminNavNew.vue +61 -0
- package/app/components/Pluto/Admin/PlutoAdminNavbar.vue +7 -2
- package/app/components/Pluto/Admin/PlutoAdminUserMenu.vue +5 -17
- package/app/components/Pluto/Content/PlutoContentBarActions.vue +173 -14
- package/app/components/Pluto/Github/PlutoGithubSetupPanel.vue +2 -2
- package/app/components/Pluto/Github/PlutoGithubSignIn.vue +1 -1
- package/app/components/Pluto/Navbar/PlutoNavbarAdminMenu.vue +1 -2
- package/app/components/Pluto/Navbar/PlutoNavbarHit.vue +1 -1
- package/app/composables/internal/nav-groups.ts +54 -0
- package/app/composables/internal/new-menu.ts +52 -0
- package/app/composables/pluto-admin-menu.ts +10 -1
- package/app/composables/pluto-admin-nav.ts +103 -25
- package/app/composables/pluto-admin-portal.ts +22 -0
- package/app/composables/pluto-command-palette.ts +1 -1
- package/app/composables/pluto-extension.ts +1 -0
- package/app/composables/pluto-registry.ts +1 -0
- package/app/layouts/admin.vue +1 -2
- package/app/plugins/pluto-core-registrations.ts +20 -2
- package/i18n/locales/en.json +3 -1
- package/i18n/locales/pt-BR.json +3 -1
- package/modules/pluto-github/runtime/app/plugins/pluto-github.ts +1 -1
- package/nuxt.config.ts +19 -0
- package/package.json +1 -2
- package/shared/types/content.ts +9 -0
- package/shared/types/registry.ts +23 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,6 +1,21 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
3
|
|
|
4
|
+
## [0.39.0](https://github.com/plutocms/pluto/compare/v0.38.2...v0.39.0) (2026-10-31)
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
### Features
|
|
8
|
+
|
|
9
|
+
* **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))
|
|
10
|
+
|
|
11
|
+
## [0.38.2](https://github.com/plutocms/pluto/compare/v0.38.1...v0.38.2) (2026-10-30)
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
### Bug Fixes
|
|
15
|
+
|
|
16
|
+
* core no longer bundles the whole simple-icons collection in the server ([#287](https://github.com/plutocms/pluto/issues/287)) ([2322b43](https://github.com/plutocms/pluto/commit/2322b43e4371c34548fdf88124138439c4c7df00))
|
|
17
|
+
* the docs links point to the new /docs URLs ([#289](https://github.com/plutocms/pluto/issues/289)) ([0982146](https://github.com/plutocms/pluto/commit/0982146d82d599c4e7e0f9d2beeec56cd7527be7))
|
|
18
|
+
|
|
4
19
|
## [0.38.1](https://github.com/plutocms/pluto/compare/v0.38.0...v0.38.1) (2026-10-30)
|
|
5
20
|
|
|
6
21
|
|
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
|
package/README.md
CHANGED
|
@@ -34,7 +34,7 @@ export default defineNuxtConfig({
|
|
|
34
34
|
|
|
35
35
|
Core is the base layer every Pluto app extends. With no other layer, Pluto is a Markdown CMS on
|
|
36
36
|
Git: posts are files in your repository, and each save is a commit. It works out of the box with your local Git repo in development. Connect GitHub
|
|
37
|
-
to publish from a deployed site. See [GitHub CMS](https://pluto.vulmix.dev/github-cms/quickstart).
|
|
37
|
+
to publish from a deployed site. See [GitHub CMS](https://pluto.vulmix.dev/docs/github-cms/quickstart).
|
|
38
38
|
|
|
39
39
|
## Use it headless
|
|
40
40
|
|
|
@@ -44,7 +44,7 @@ A site in any framework reads the published content from the public API:
|
|
|
44
44
|
curl https://your-site.dev/api/pluto/v1/content/post?limit=10
|
|
45
45
|
```
|
|
46
46
|
|
|
47
|
-
See [Public API](https://pluto.vulmix.dev/core/public-api).
|
|
47
|
+
See [Public API](https://pluto.vulmix.dev/docs/core/public-api).
|
|
48
48
|
|
|
49
49
|
## Layers
|
|
50
50
|
|
|
@@ -57,13 +57,13 @@ See [Public API](https://pluto.vulmix.dev/core/public-api).
|
|
|
57
57
|
| `@plutocms/supabase-shop` | Products on Supabase. |
|
|
58
58
|
| `@plutocms/3d` | 3D models in the media library, and a viewer. |
|
|
59
59
|
|
|
60
|
-
Read [Layers](https://pluto.vulmix.dev/core/layers) to see how they fit together.
|
|
60
|
+
Read [Layers](https://pluto.vulmix.dev/docs/core/layers) to see how they fit together.
|
|
61
61
|
|
|
62
62
|
## Links
|
|
63
63
|
|
|
64
|
-
- [Create a project](https://pluto.vulmix.dev/getting-started/create-a-project)
|
|
65
|
-
- [Why Pluto](https://pluto.vulmix.dev/getting-started/why-pluto)
|
|
66
|
-
- [Contributing](https://pluto.vulmix.dev/contributing/workspace)
|
|
64
|
+
- [Create a project](https://pluto.vulmix.dev/docs/getting-started/create-a-project)
|
|
65
|
+
- [Why Pluto](https://pluto.vulmix.dev/docs/getting-started/why-pluto)
|
|
66
|
+
- [Contributing](https://pluto.vulmix.dev/docs/contributing/workspace)
|
|
67
67
|
|
|
68
68
|
## License
|
|
69
69
|
|
|
@@ -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,7 +44,7 @@ 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
49
|
<!-- A brand logo (white label), else the Pluto logo. The mark only on a small screen. -->
|
|
50
50
|
<template v-if="brandLogo">
|
|
@@ -80,7 +80,12 @@ const siteDomain = computed(() => domainFromUrl(site.url.value) || host)
|
|
|
80
80
|
</NuxtLink>
|
|
81
81
|
</div>
|
|
82
82
|
|
|
83
|
-
|
|
83
|
+
<!--
|
|
84
|
+
The overflow clip keeps a long list of actions from pushing the bar. The
|
|
85
|
+
padding and the negative margin give the focus ring of the first and the last
|
|
86
|
+
action room inside the clip, without moving them.
|
|
87
|
+
-->
|
|
88
|
+
<div class="-mx-1 h-full min-w-0 overflow-hidden px-1">
|
|
84
89
|
<ul class="flex h-full min-w-0 items-center gap-x-1 text-sm">
|
|
85
90
|
<PlutoNavbarButton
|
|
86
91
|
v-if="isAdmin"
|
|
@@ -48,7 +48,7 @@ const linkItems = computed<DropdownMenuItem[]>(() => [
|
|
|
48
48
|
? [
|
|
49
49
|
{
|
|
50
50
|
label: t('pluto.navbar.github'),
|
|
51
|
-
icon: 'i-
|
|
51
|
+
icon: 'i-pluto-github',
|
|
52
52
|
type: 'link' as const,
|
|
53
53
|
to: 'https://github.com/plutocms/pluto',
|
|
54
54
|
target: '_blank',
|
|
@@ -127,21 +127,8 @@ const items = computed<DropdownMenuItem[][]>(() => [
|
|
|
127
127
|
|
|
128
128
|
defineShortcuts(computed(() => extractShortcuts(items.value)))
|
|
129
129
|
|
|
130
|
-
// The
|
|
131
|
-
|
|
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,147 @@ 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
|
+
let openTimer: ReturnType<typeof setTimeout> | undefined
|
|
37
|
+
let closeTimer: ReturnType<typeof setTimeout> | undefined
|
|
38
|
+
// The time until which a close request is ignored (see onUpdateOpen).
|
|
39
|
+
let confirmUntil = 0
|
|
40
|
+
|
|
41
|
+
function clearTimers() {
|
|
42
|
+
clearTimeout(openTimer)
|
|
43
|
+
clearTimeout(closeTimer)
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function onHoverEnter(event: PointerEvent) {
|
|
47
|
+
if (event.pointerType === 'touch') {
|
|
48
|
+
return
|
|
49
|
+
}
|
|
50
|
+
clearTimers()
|
|
51
|
+
if (!menuOpen.value) {
|
|
52
|
+
openTimer = setTimeout(() => {
|
|
53
|
+
if (!menuOpen.value) {
|
|
54
|
+
openedByHover = true
|
|
55
|
+
menuOpen.value = true
|
|
56
|
+
}
|
|
57
|
+
}, HOVER_OPEN_DELAY)
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function onHoverLeave(event: PointerEvent) {
|
|
62
|
+
if (event.pointerType === 'touch') {
|
|
63
|
+
return
|
|
64
|
+
}
|
|
65
|
+
clearTimers()
|
|
66
|
+
if (menuOpen.value) {
|
|
67
|
+
closeTimer = setTimeout(() => {
|
|
68
|
+
menuOpen.value = false
|
|
69
|
+
}, HOVER_CLOSE_DELAY)
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// The click confirms the menu that the hover opened. The dropdown must not
|
|
74
|
+
// toggle it (pointerdown) and must not see the focus leave its content
|
|
75
|
+
// (mousedown moves the focus to the button).
|
|
76
|
+
function keepsHoverMenu(event: PointerEvent | MouseEvent): boolean {
|
|
77
|
+
return (
|
|
78
|
+
(!('pointerType' in event) || event.pointerType !== 'touch') &&
|
|
79
|
+
menuOpen.value &&
|
|
80
|
+
openedByHover
|
|
81
|
+
)
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function onTriggerPointerDown(event: PointerEvent) {
|
|
85
|
+
// A click is faster than the hover delay: it decides, not the hover.
|
|
86
|
+
clearTimeout(openTimer)
|
|
87
|
+
if (keepsHoverMenu(event)) {
|
|
88
|
+
confirmUntil = Date.now() + 500
|
|
89
|
+
event.stopPropagation()
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// The dropdown asks to close on the click that confirms a hover menu (a pointer
|
|
94
|
+
// or focus event outside its content). Ignore that request for a moment.
|
|
95
|
+
|
|
96
|
+
function onUpdateOpen(open: boolean) {
|
|
97
|
+
if (!open && Date.now() < confirmUntil) {
|
|
98
|
+
return
|
|
99
|
+
}
|
|
100
|
+
menuOpen.value = open
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function onTriggerMouseDown(event: MouseEvent) {
|
|
104
|
+
if (keepsHoverMenu(event)) {
|
|
105
|
+
event.preventDefault()
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
watch(menuOpen, (open) => {
|
|
110
|
+
if (!open) {
|
|
111
|
+
openedByHover = false
|
|
112
|
+
clearTimers()
|
|
113
|
+
}
|
|
114
|
+
})
|
|
115
|
+
|
|
116
|
+
watch(portalTarget, (el, previous) => {
|
|
117
|
+
previous?.removeEventListener('pointerenter', onHoverEnter)
|
|
118
|
+
previous?.removeEventListener('pointerleave', onHoverLeave)
|
|
119
|
+
el?.addEventListener('pointerenter', onHoverEnter)
|
|
120
|
+
el?.addEventListener('pointerleave', onHoverLeave)
|
|
121
|
+
})
|
|
122
|
+
|
|
123
|
+
onBeforeUnmount(() => {
|
|
124
|
+
clearTimers()
|
|
125
|
+
portalTarget.value?.removeEventListener('pointerenter', onHoverEnter)
|
|
126
|
+
portalTarget.value?.removeEventListener('pointerleave', onHoverLeave)
|
|
127
|
+
})
|
|
16
128
|
|
|
17
129
|
// No capability declared means open to anyone who can reach the admin, like the form.
|
|
18
130
|
function canWrite(type: PlutoContentType): boolean {
|
|
19
131
|
return !type.capabilities?.write || can(type.capabilities.write)
|
|
20
132
|
}
|
|
21
133
|
|
|
22
|
-
const
|
|
23
|
-
contentTypes.value
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
134
|
+
const newEntries = computed(() =>
|
|
135
|
+
contentTypes.value.filter(
|
|
136
|
+
({ type }) => contentTypeAdminBar(type).new && canWrite(type)
|
|
137
|
+
)
|
|
138
|
+
)
|
|
139
|
+
|
|
140
|
+
// One type: a "New <type>" button.
|
|
141
|
+
const newButton = computed(() => {
|
|
142
|
+
const type = newEntries.value[0]?.type
|
|
143
|
+
return newEntries.value.length === 1 && type
|
|
144
|
+
? {
|
|
145
|
+
label: texts(type.name).newLabel,
|
|
146
|
+
to: contentTypeNewPath(type),
|
|
147
|
+
}
|
|
148
|
+
: null
|
|
149
|
+
})
|
|
150
|
+
|
|
151
|
+
// Two or more types: one menu (see buildNewMenu). The types without a group are
|
|
152
|
+
// direct items, and a group with two or more types is a submenu.
|
|
153
|
+
const newMenuItems = computed<DropdownMenuItem[]>(() =>
|
|
154
|
+
newEntries.value.length < 2
|
|
155
|
+
? []
|
|
156
|
+
: buildNewMenu(newEntries.value, registry.navGroups.list.value, {
|
|
157
|
+
label: (type) => texts(type.name).label,
|
|
158
|
+
groupLabel: (group) => tx(group.label),
|
|
159
|
+
to: contentTypeNewPath,
|
|
160
|
+
})
|
|
31
161
|
)
|
|
32
162
|
|
|
33
163
|
const editButton = computed(() => {
|
|
@@ -51,12 +181,41 @@ const editButton = computed(() => {
|
|
|
51
181
|
|
|
52
182
|
<template>
|
|
53
183
|
<PlutoNavbarButton
|
|
54
|
-
v-
|
|
55
|
-
:
|
|
56
|
-
:
|
|
57
|
-
:to="button.to"
|
|
184
|
+
v-if="newButton"
|
|
185
|
+
:label="newButton.label"
|
|
186
|
+
:to="newButton.to"
|
|
58
187
|
icon="i-lucide-plus"
|
|
59
188
|
/>
|
|
189
|
+
<li
|
|
190
|
+
v-else-if="newMenuItems.length > 0"
|
|
191
|
+
class="h-full shrink-0"
|
|
192
|
+
@pointerenter="onHoverEnter"
|
|
193
|
+
@pointerleave="onHoverLeave"
|
|
194
|
+
@pointerdown.capture="onTriggerPointerDown"
|
|
195
|
+
@mousedown.capture="onTriggerMouseDown"
|
|
196
|
+
>
|
|
197
|
+
<!--
|
|
198
|
+
The portal and the modal flag follow the user menu. See usePlutoAdminPortal.
|
|
199
|
+
-->
|
|
200
|
+
<UDropdownMenu
|
|
201
|
+
:open="menuOpen"
|
|
202
|
+
:items="newMenuItems"
|
|
203
|
+
:content="{ align: 'start' }"
|
|
204
|
+
:modal="false"
|
|
205
|
+
:portal="portalTarget ?? true"
|
|
206
|
+
:ui="{ content: 'min-w-48' }"
|
|
207
|
+
:arrow="{ width: 14, height: 7 }"
|
|
208
|
+
@update:open="onUpdateOpen"
|
|
209
|
+
>
|
|
210
|
+
<PlutoNavbarHit
|
|
211
|
+
:label="t('pluto.content.newMenu')"
|
|
212
|
+
icon="i-lucide-plus"
|
|
213
|
+
size="sm"
|
|
214
|
+
>
|
|
215
|
+
<span class="hidden lg:inline">{{ t('pluto.content.newMenu') }}</span>
|
|
216
|
+
</PlutoNavbarHit>
|
|
217
|
+
</UDropdownMenu>
|
|
218
|
+
</li>
|
|
60
219
|
<PlutoNavbarButton
|
|
61
220
|
v-if="editButton"
|
|
62
221
|
:label="editButton.label"
|
|
@@ -22,7 +22,7 @@ const props = withDefaults(
|
|
|
22
22
|
}
|
|
23
23
|
)
|
|
24
24
|
|
|
25
|
-
const DOCS_URL = 'https://pluto.vulmix.dev/github-cms/quickstart'
|
|
25
|
+
const DOCS_URL = 'https://pluto.vulmix.dev/docs/github-cms/quickstart'
|
|
26
26
|
|
|
27
27
|
const { t } = usePlutoI18n()
|
|
28
28
|
const setup = usePlutoGithubSetup()
|
|
@@ -166,7 +166,7 @@ async function copyCallback() {
|
|
|
166
166
|
:label="t('pluto.github.setup.create')"
|
|
167
167
|
:color="current.ready ? 'neutral' : 'primary'"
|
|
168
168
|
:variant="current.ready ? 'outline' : 'solid'"
|
|
169
|
-
icon="
|
|
169
|
+
icon="pluto:github"
|
|
170
170
|
class="w-full justify-center"
|
|
171
171
|
external
|
|
172
172
|
/>
|
|
@@ -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
|
-
<
|
|
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
|
|
57
|
-
.
|
|
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
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
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
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
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
|
+
}
|
|
@@ -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
|
|
package/app/layouts/admin.vue
CHANGED
|
@@ -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
|
-
<
|
|
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
|
-
{
|
|
73
|
-
|
|
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 },
|
package/i18n/locales/en.json
CHANGED
|
@@ -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",
|
package/i18n/locales/pt-BR.json
CHANGED
|
@@ -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
|
@@ -122,4 +122,23 @@ export default defineNuxtConfig({
|
|
|
122
122
|
weights: [400, 600, 700],
|
|
123
123
|
},
|
|
124
124
|
},
|
|
125
|
+
|
|
126
|
+
// Core ships its one brand icon (GitHub) as a tiny custom collection, so it
|
|
127
|
+
// does not depend on the whole simple-icons collection (about 4 MB in the
|
|
128
|
+
// server bundle). Lucide stays a normal installed collection. See the
|
|
129
|
+
// admin-theme skill.
|
|
130
|
+
icon: {
|
|
131
|
+
customCollections: [
|
|
132
|
+
{
|
|
133
|
+
prefix: 'pluto',
|
|
134
|
+
width: 24,
|
|
135
|
+
height: 24,
|
|
136
|
+
icons: {
|
|
137
|
+
github: {
|
|
138
|
+
body: '<path fill="currentColor" d="M12 .297c-6.63 0-12 5.373-12 12c0 5.303 3.438 9.8 8.205 11.385c.6.113.82-.258.82-.577c0-.285-.01-1.04-.015-2.04c-3.338.724-4.042-1.61-4.042-1.61C4.422 18.07 3.633 17.7 3.633 17.7c-1.087-.744.084-.729.084-.729c1.205.084 1.838 1.236 1.838 1.236c1.07 1.835 2.809 1.305 3.495.998c.108-.776.417-1.305.76-1.605c-2.665-.3-5.466-1.332-5.466-5.93c0-1.31.465-2.38 1.235-3.22c-.135-.303-.54-1.523.105-3.176c0 0 1.005-.322 3.3 1.23c.96-.267 1.98-.399 3-.405c1.02.006 2.04.138 3 .405c2.28-1.552 3.285-1.23 3.285-1.23c.645 1.653.24 2.873.12 3.176c.765.84 1.23 1.91 1.23 3.22c0 4.61-2.805 5.625-5.475 5.92c.42.36.81 1.096.81 2.22c0 1.606-.015 2.896-.015 3.286c0 .315.21.69.825.57C20.565 22.092 24 17.592 24 12.297c0-6.627-5.373-12-12-12"/>',
|
|
139
|
+
},
|
|
140
|
+
},
|
|
141
|
+
},
|
|
142
|
+
],
|
|
143
|
+
},
|
|
125
144
|
})
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@plutocms/core",
|
|
3
3
|
"type": "module",
|
|
4
|
-
"version": "0.
|
|
4
|
+
"version": "0.39.0",
|
|
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",
|
|
@@ -47,7 +47,6 @@
|
|
|
47
47
|
"@codemirror/state": "^6.7.6",
|
|
48
48
|
"@codemirror/view": "^6.43.15",
|
|
49
49
|
"@iconify-json/lucide": "^1.2.144",
|
|
50
|
-
"@iconify-json/simple-icons": "^1.2.100",
|
|
51
50
|
"@internationalized/date": "^3.12.4",
|
|
52
51
|
"@lezer/highlight": "^1.2.5",
|
|
53
52
|
"@nuxt/eslint": "^1.17.0",
|
package/shared/types/content.ts
CHANGED
|
@@ -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
|
package/shared/types/registry.ts
CHANGED
|
@@ -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
|
}
|