@plutocms/core 0.40.0 → 0.42.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 +20 -0
- package/app/components/Pluto/Admin/PlutoAdminNavTabs.vue +77 -0
- package/app/components/Pluto/Content/PlutoContentList.vue +2 -0
- package/app/composables/pluto-admin-nav.ts +94 -0
- package/app/pages/admin/settings.vue +6 -33
- package/i18n/locales/en.json +0 -1
- package/i18n/locales/pt-BR.json +0 -1
- package/layers.ts +1 -2
- package/nuxt.config.ts +4 -7
- package/package.json +1 -2
- package/shared/utils/content-slug.ts +1 -2
- package/shared/utils/url.ts +13 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,6 +1,26 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
3
|
|
|
4
|
+
## [0.42.0](https://github.com/plutocms/pluto/compare/v0.41.0...v0.42.0) (2026-10-31)
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
### Features
|
|
8
|
+
|
|
9
|
+
* @plutocms/docs moves into the monorepo ([f409bdf](https://github.com/plutocms/pluto/commit/f409bdf3f1ded0a1c146e9ad2724ac5222c30c1d))
|
|
10
|
+
* @plutocms/docs moves into the monorepo (packages/layers/docs) ([5117e4e](https://github.com/plutocms/pluto/commit/5117e4e507559bd3b8859401665a176e2749cbc5))
|
|
11
|
+
|
|
12
|
+
## [0.41.0](https://github.com/plutocms/pluto/compare/v0.40.0...v0.41.0) (2026-10-31)
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
### ⚠ BREAKING CHANGES
|
|
16
|
+
|
|
17
|
+
* @plutocms/core does not extend @plutocms/utils anymore. Its auto-imports (formatCurrency, slugify, fileNameToKebabCase) are gone from apps that relied on core to bring them. domainFromUrl is now in core.
|
|
18
|
+
|
|
19
|
+
### Features
|
|
20
|
+
|
|
21
|
+
* **admin:** section tabs for every menu item with a submenu ([#315](https://github.com/plutocms/pluto/issues/315)) ([98afbe4](https://github.com/plutocms/pluto/commit/98afbe45833caa6f834aa70703611970eadd5ccb))
|
|
22
|
+
* core no longer extends @plutocms/utils (retired); domainFromUrl is in core ([#314](https://github.com/plutocms/pluto/issues/314)) ([5fe3947](https://github.com/plutocms/pluto/commit/5fe394707c009d4686e6e7fe6f72af900dfe4035))
|
|
23
|
+
|
|
4
24
|
## [0.40.0](https://github.com/plutocms/pluto/compare/v0.39.2...v0.40.0) (2026-10-31)
|
|
5
25
|
|
|
6
26
|
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
// The tabs of the section of the page: the children of the menu item whose
|
|
3
|
+
// submenu holds the page (usePlutoAdminNavTabs). The "new" child of the
|
|
4
|
+
// section is a button at the end of the tabs, not a tab. Nothing shows when the
|
|
5
|
+
// page is in no submenu. `page`: under the title of a page (Settings, Users).
|
|
6
|
+
// `toolbar`: a bar under the toolbar of a list page.
|
|
7
|
+
import type { NavigationMenuItem } from '@nuxt/ui'
|
|
8
|
+
|
|
9
|
+
withDefaults(
|
|
10
|
+
defineProps<{
|
|
11
|
+
variant?: 'page' | 'toolbar'
|
|
12
|
+
}>(),
|
|
13
|
+
{ variant: 'page' }
|
|
14
|
+
)
|
|
15
|
+
|
|
16
|
+
const { tabs, action, label } = usePlutoAdminNavTabs()
|
|
17
|
+
|
|
18
|
+
// The active tab is set by hand (`active`), not by the router: a parent path
|
|
19
|
+
// such as `/admin/settings` must not be active on the pages below it.
|
|
20
|
+
const items = computed<NavigationMenuItem[]>(() =>
|
|
21
|
+
tabs.value.map((tab) => ({
|
|
22
|
+
label: tab.label,
|
|
23
|
+
icon: tab.icon,
|
|
24
|
+
href: tab.to,
|
|
25
|
+
active: tab.active,
|
|
26
|
+
onSelect: () => navigateTo(tab.to),
|
|
27
|
+
}))
|
|
28
|
+
)
|
|
29
|
+
</script>
|
|
30
|
+
|
|
31
|
+
<template>
|
|
32
|
+
<template v-if="items.length > 0">
|
|
33
|
+
<UDashboardToolbar
|
|
34
|
+
v-if="variant === 'toolbar'"
|
|
35
|
+
:ui="{ root: 'border-muted' }"
|
|
36
|
+
>
|
|
37
|
+
<UNavigationMenu
|
|
38
|
+
:items="items"
|
|
39
|
+
:aria-label="label"
|
|
40
|
+
orientation="horizontal"
|
|
41
|
+
variant="link"
|
|
42
|
+
class="-mx-1 min-w-0 flex-1 overflow-x-auto"
|
|
43
|
+
highlight
|
|
44
|
+
/>
|
|
45
|
+
<UButton
|
|
46
|
+
v-if="action"
|
|
47
|
+
:to="action.to"
|
|
48
|
+
:label="action.label"
|
|
49
|
+
icon="i-lucide-plus"
|
|
50
|
+
color="neutral"
|
|
51
|
+
variant="ghost"
|
|
52
|
+
size="sm"
|
|
53
|
+
class="shrink-0"
|
|
54
|
+
/>
|
|
55
|
+
</UDashboardToolbar>
|
|
56
|
+
|
|
57
|
+
<div v-else class="border-default -mt-2 flex items-center gap-2 border-b">
|
|
58
|
+
<UNavigationMenu
|
|
59
|
+
:items="items"
|
|
60
|
+
:aria-label="label"
|
|
61
|
+
orientation="horizontal"
|
|
62
|
+
variant="link"
|
|
63
|
+
class="min-w-0 flex-1 overflow-x-auto"
|
|
64
|
+
/>
|
|
65
|
+
<UButton
|
|
66
|
+
v-if="action"
|
|
67
|
+
:to="action.to"
|
|
68
|
+
:label="action.label"
|
|
69
|
+
icon="i-lucide-plus"
|
|
70
|
+
color="neutral"
|
|
71
|
+
variant="ghost"
|
|
72
|
+
size="sm"
|
|
73
|
+
class="shrink-0"
|
|
74
|
+
/>
|
|
75
|
+
</div>
|
|
76
|
+
</template>
|
|
77
|
+
</template>
|
|
@@ -266,6 +266,8 @@ const footer = computed(() => {
|
|
|
266
266
|
</UButton>
|
|
267
267
|
</template>
|
|
268
268
|
</PlutoViewToolbar>
|
|
269
|
+
<!-- The tabs of the menu item that holds this list (for example Pages). -->
|
|
270
|
+
<PlutoAdminNavTabs variant="toolbar" />
|
|
269
271
|
</template>
|
|
270
272
|
|
|
271
273
|
<PlutoSkeletonList
|
|
@@ -178,6 +178,7 @@ export function usePlutoAdminNav() {
|
|
|
178
178
|
children: settingsPages.value.map((page) => ({
|
|
179
179
|
label: page.label,
|
|
180
180
|
to: page.to,
|
|
181
|
+
icon: page.icon,
|
|
181
182
|
})),
|
|
182
183
|
}
|
|
183
184
|
: item
|
|
@@ -207,3 +208,96 @@ export function usePlutoAdminNav() {
|
|
|
207
208
|
|
|
208
209
|
return { items }
|
|
209
210
|
}
|
|
211
|
+
|
|
212
|
+
/** One tab of the section of the current page. See usePlutoAdminNavTabs. */
|
|
213
|
+
export interface PlutoAdminNavTab {
|
|
214
|
+
label: string
|
|
215
|
+
icon?: string
|
|
216
|
+
to: string
|
|
217
|
+
active: boolean
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
/** The "new" link of a section: a button at the end of its tabs. */
|
|
221
|
+
export interface PlutoAdminNavAction {
|
|
222
|
+
label: string
|
|
223
|
+
to: string
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
// Trailing slashes off, so `/admin/users/` matches `/admin/users`.
|
|
227
|
+
function trimPath(path: string): string {
|
|
228
|
+
return path.replace(/\/+$/, '') || '/'
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
/**
|
|
232
|
+
* The tabs of the section of the current page: the children of the menu item
|
|
233
|
+
* whose submenu holds the page. Settings, the Users item of a backend layer and
|
|
234
|
+
* a group of content types (Pages) all get them.
|
|
235
|
+
*
|
|
236
|
+
* - A child matches the path exactly, or as a prefix (`/admin/users` matches
|
|
237
|
+
* `/admin/users/5`). The longest match wins, so General (`/admin/settings`)
|
|
238
|
+
* is not active on `/admin/settings/email`.
|
|
239
|
+
* - A "new" child (its `to` is the `newTo` of the item, or ends with `/new`)
|
|
240
|
+
* is not a tab: it is `action`, a button at the end of the tabs.
|
|
241
|
+
* - The tabs show only with two or more of them. A section of one list and its
|
|
242
|
+
* "new" page (a content type: All posts, New post) shows none: the list page
|
|
243
|
+
* has its own New button.
|
|
244
|
+
*
|
|
245
|
+
* `label` is the label of the section (for `aria-label`). A page shows the
|
|
246
|
+
* tabs with `PlutoAdminNavTabs`.
|
|
247
|
+
*/
|
|
248
|
+
export function usePlutoAdminNavTabs() {
|
|
249
|
+
const { items } = usePlutoAdminNav()
|
|
250
|
+
const route = useRoute()
|
|
251
|
+
|
|
252
|
+
const isNewChild = (item: PlutoNavItem, child: PlutoNavChild): boolean =>
|
|
253
|
+
(item.newTo !== undefined && trimPath(child.to) === trimPath(item.newTo)) ||
|
|
254
|
+
trimPath(child.to).endsWith('/new')
|
|
255
|
+
|
|
256
|
+
const section = computed(() => {
|
|
257
|
+
const path = trimPath(route.path)
|
|
258
|
+
let best: { item: PlutoNavItem; to: string } | undefined
|
|
259
|
+
for (const item of items.value) {
|
|
260
|
+
const pages = (item.children ?? []).filter(
|
|
261
|
+
(child) => !isNewChild(item, child)
|
|
262
|
+
)
|
|
263
|
+
if (pages.length < 2) {
|
|
264
|
+
continue
|
|
265
|
+
}
|
|
266
|
+
for (const child of pages) {
|
|
267
|
+
const to = trimPath(child.to)
|
|
268
|
+
const matches = path === to || path.startsWith(`${to}/`)
|
|
269
|
+
if (matches && (!best || to.length > best.to.length)) {
|
|
270
|
+
best = { item, to }
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
return best
|
|
275
|
+
})
|
|
276
|
+
|
|
277
|
+
const tabs = computed<PlutoAdminNavTab[]>(() => {
|
|
278
|
+
const current = section.value
|
|
279
|
+
if (!current) {
|
|
280
|
+
return []
|
|
281
|
+
}
|
|
282
|
+
return (current.item.children ?? [])
|
|
283
|
+
.filter((child) => !isNewChild(current.item, child))
|
|
284
|
+
.map((child) => ({
|
|
285
|
+
label: child.label,
|
|
286
|
+
icon: child.icon,
|
|
287
|
+
to: child.to,
|
|
288
|
+
active: trimPath(child.to) === current.to,
|
|
289
|
+
}))
|
|
290
|
+
})
|
|
291
|
+
|
|
292
|
+
const action = computed<PlutoAdminNavAction | undefined>(() => {
|
|
293
|
+
const item = section.value?.item
|
|
294
|
+
const child = item?.children?.find((entry) => isNewChild(item, entry))
|
|
295
|
+
return child ? { label: child.label, to: child.to } : undefined
|
|
296
|
+
})
|
|
297
|
+
|
|
298
|
+
return {
|
|
299
|
+
tabs,
|
|
300
|
+
action,
|
|
301
|
+
label: computed(() => section.value?.item.label ?? ''),
|
|
302
|
+
}
|
|
303
|
+
}
|
|
@@ -1,36 +1,16 @@
|
|
|
1
1
|
<script setup lang="ts">
|
|
2
2
|
// The parent of every Settings page: the title, the tabs, and the child route.
|
|
3
3
|
// A layer adds a tab with a file at `pages/admin/settings/<x>.vue` and a
|
|
4
|
-
// `settingsPages` entry.
|
|
5
|
-
|
|
6
|
-
|
|
4
|
+
// `settingsPages` entry. The tabs are the children of the Settings item of the
|
|
5
|
+
// menu (PlutoAdminNavTabs), like every item with a submenu. See the
|
|
6
|
+
// extension-registry skill.
|
|
7
7
|
const { t } = usePlutoI18n()
|
|
8
|
-
const route = useRoute()
|
|
9
8
|
|
|
10
9
|
useHead({
|
|
11
10
|
title: () => t('pluto.pages.settings'),
|
|
12
11
|
})
|
|
13
12
|
|
|
14
|
-
const {
|
|
15
|
-
|
|
16
|
-
// Active state by hand: General (`/admin/settings`) matches exactly, so it is
|
|
17
|
-
// not active on the pages below it. Any other tab matches by prefix.
|
|
18
|
-
function isActive(to: string): boolean {
|
|
19
|
-
const path = route.path.replace(/\/+$/, '') || '/'
|
|
20
|
-
return to === '/admin/settings'
|
|
21
|
-
? path === to
|
|
22
|
-
: path === to || path.startsWith(`${to}/`)
|
|
23
|
-
}
|
|
24
|
-
|
|
25
|
-
const tabs = computed<NavigationMenuItem[]>(() =>
|
|
26
|
-
items.value.map((page) => ({
|
|
27
|
-
label: page.label,
|
|
28
|
-
icon: page.icon,
|
|
29
|
-
href: page.to,
|
|
30
|
-
active: isActive(page.to),
|
|
31
|
-
onSelect: () => navigateTo(page.to),
|
|
32
|
-
}))
|
|
33
|
-
)
|
|
13
|
+
const { current, allowed } = usePlutoSettingsPages()
|
|
34
14
|
</script>
|
|
35
15
|
|
|
36
16
|
<template>
|
|
@@ -40,15 +20,8 @@ const tabs = computed<NavigationMenuItem[]>(() =>
|
|
|
40
20
|
{{ t('pluto.pages.settings') }}
|
|
41
21
|
</h1>
|
|
42
22
|
|
|
43
|
-
<!-- One page (General only)
|
|
44
|
-
<
|
|
45
|
-
v-if="tabs.length >= 2"
|
|
46
|
-
:items="tabs"
|
|
47
|
-
:aria-label="t('pluto.settings.tabs.label')"
|
|
48
|
-
orientation="horizontal"
|
|
49
|
-
variant="link"
|
|
50
|
-
class="border-default -mt-2 overflow-x-auto border-b"
|
|
51
|
-
/>
|
|
23
|
+
<!-- One page (General only) has no submenu, so no tabs. -->
|
|
24
|
+
<PlutoAdminNavTabs />
|
|
52
25
|
|
|
53
26
|
<UAlert
|
|
54
27
|
v-if="!allowed"
|
package/i18n/locales/en.json
CHANGED
package/i18n/locales/pt-BR.json
CHANGED
package/layers.ts
CHANGED
|
@@ -28,12 +28,11 @@ export type PlutoLayerEntry = [source: string, options: PlutoLayerEntryOptions]
|
|
|
28
28
|
* without the scope. A package of a monorepo is in a folder of its repo.
|
|
29
29
|
*/
|
|
30
30
|
export const PLUTO_LAYER_PATHS: Readonly<Record<string, string>> = {
|
|
31
|
-
utils: 'utils',
|
|
32
31
|
core: 'pluto/packages/core',
|
|
33
32
|
supabase: 'supabase',
|
|
34
33
|
'supabase-shop': 'supabase-shop',
|
|
35
34
|
'supabase-blog': 'supabase-blog',
|
|
36
|
-
docs: 'docs',
|
|
35
|
+
docs: 'pluto/packages/layers/docs',
|
|
37
36
|
'3d': 'pluto/packages/layers/addons/3d',
|
|
38
37
|
}
|
|
39
38
|
|
package/nuxt.config.ts
CHANGED
|
@@ -1,13 +1,9 @@
|
|
|
1
1
|
const websiteName = process.env.WEBSITE_NAME || 'Untitled'
|
|
2
2
|
|
|
3
|
-
// Point PLUTO_UTILS_PATH at a local checkout (e.g. `../../../utils`) to test
|
|
4
|
-
// unpublished changes; unset, it resolves to the published npm package.
|
|
5
|
-
const utilsLayer = process.env.PLUTO_UTILS_PATH || '@plutocms/utils'
|
|
6
|
-
|
|
7
3
|
// https://nuxt.com/docs/api/configuration/nuxt-config
|
|
4
|
+
// Core extends no other layer. The helpers of the retired `@plutocms/utils` that
|
|
5
|
+
// core uses are in shared/utils (`domainFromUrl`).
|
|
8
6
|
export default defineNuxtConfig({
|
|
9
|
-
extends: [utilsLayer],
|
|
10
|
-
|
|
11
7
|
// No @nuxt/eslint: a module of a layer runs in every app that extends it. The
|
|
12
8
|
// repo lints with @nuxt/eslint-config (eslint.config.mjs at the root).
|
|
13
9
|
modules: [
|
|
@@ -104,7 +100,8 @@ export default defineNuxtConfig({
|
|
|
104
100
|
compatibilityDate: '2025-08-15',
|
|
105
101
|
|
|
106
102
|
typescript: {
|
|
107
|
-
includeWorkspace
|
|
103
|
+
// No `includeWorkspace`: in the monorepo it would read every package. Each package has its own typecheck.
|
|
104
|
+
includeWorkspace: false,
|
|
108
105
|
typeCheck: false,
|
|
109
106
|
},
|
|
110
107
|
|
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.42.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",
|
|
@@ -43,7 +43,6 @@
|
|
|
43
43
|
"@lezer/highlight": "^1.2.5",
|
|
44
44
|
"@nuxt/image": "^2.1.0",
|
|
45
45
|
"@nuxt/ui": "^4.12.0",
|
|
46
|
-
"@plutocms/utils": ">=0.3.0 <1.0.0",
|
|
47
46
|
"@tanstack/charts": "^1.1.0",
|
|
48
47
|
"@vueuse/nuxt": "^15.0.0",
|
|
49
48
|
"image-meta": "^0.3.0",
|
|
@@ -6,8 +6,7 @@ export const SLUG_MAX_LENGTH = 100
|
|
|
6
6
|
* "pao-de-mel". The result is lowercase ASCII, with single dashes. It can
|
|
7
7
|
* be an empty string.
|
|
8
8
|
*
|
|
9
|
-
* The form and the GitHub CMS both use this function.
|
|
10
|
-
* `slugify`: `@plutocms/utils` already auto-imports that name.
|
|
9
|
+
* The form and the GitHub CMS both use this function.
|
|
11
10
|
*/
|
|
12
11
|
export function slugifyTitle(value: string): string {
|
|
13
12
|
return value
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The host of a URL, without the scheme and `www.`: `https://www.example.com/a`
|
|
3
|
+
* gives `example.com`. No URL gives `undefined`; a value that does not parse
|
|
4
|
+
* gives an empty string. The admin bar shows it next to "View site".
|
|
5
|
+
*/
|
|
6
|
+
export function domainFromUrl(url: string | null | undefined) {
|
|
7
|
+
if (!url) {
|
|
8
|
+
return
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
const match = url.match(/^(?:https?:\/\/)?(?:www\.)?([^/]+)/i)
|
|
12
|
+
return match ? match[1] : ''
|
|
13
|
+
}
|