@plutocms/pluto 0.3.2 → 0.4.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 +12 -0
- package/FEATURES.md +4 -0
- package/README.md +18 -3
- package/app/components/Pluto/Admin/PlutoDashboardWidgets.vue +22 -0
- package/app/components/Pluto/Navbar/PlutoNavbarAdminActions.vue +2 -2
- package/app/components/Pluto/PlutoRoot.vue +1 -1
- package/app/composables/internal/registry-factory.ts +63 -0
- package/app/composables/pluto-admin-nav.ts +44 -0
- package/app/composables/pluto-admin-pages.ts +14 -0
- package/app/composables/pluto-dashboard.ts +5 -0
- package/app/composables/pluto-extension.ts +24 -0
- package/app/composables/pluto-navbar.ts +40 -0
- package/app/composables/pluto-registry.ts +32 -0
- package/app/composables/pluto-settings.ts +65 -0
- package/app/layouts/admin.vue +22 -15
- package/app/pages/admin/home.vue +1 -7
- package/app/pages/admin/settings.vue +46 -0
- package/app/plugins/pluto-core-registrations.ts +16 -0
- package/package.json +6 -6
- package/shared/types/registry.d.ts +14 -0
- package/shared/types/registry.ts +94 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,6 +1,18 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
3
|
|
|
4
|
+
## [0.4.0](https://github.com/plutocms/pluto/compare/v0.3.2...v0.4.0) (2026-09-12)
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
### Features
|
|
8
|
+
|
|
9
|
+
* **registry:** add typed extension registry for admin UI ([#127](https://github.com/plutocms/pluto/issues/127)) ([f3c6b0a](https://github.com/plutocms/pluto/commit/f3c6b0a38870ab738b4267e8b6252bb0720dae13))
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
### Bug Fixes
|
|
13
|
+
|
|
14
|
+
* **deps:** move nuxt runtime modules to dependencies ([#125](https://github.com/plutocms/pluto/issues/125)) ([7d66096](https://github.com/plutocms/pluto/commit/7d660962dcd0b8ce7703da3487a861be2a5064a4)), closes [#79](https://github.com/plutocms/pluto/issues/79)
|
|
15
|
+
|
|
4
16
|
## [0.3.2](https://github.com/plutocms/pluto/compare/v0.3.1...v0.3.2) (2026-09-10)
|
|
5
17
|
|
|
6
18
|
|
package/FEATURES.md
CHANGED
|
@@ -5,3 +5,7 @@ with full detail, so this file stays short.
|
|
|
5
5
|
|
|
6
6
|
See the workspace-level `CLAUDE.md` (one directory up) for the delegation, writing, and git
|
|
7
7
|
policies shared by every project here.
|
|
8
|
+
|
|
9
|
+
- Extension registry: a typed, ordered, reactive registry that lets a layer plug its admin-UI
|
|
10
|
+
pieces (navbar, sidebar nav, pages, dashboard widgets, settings panels and driver) into core
|
|
11
|
+
with one function call. @.claude/skills/extension-registry/SKILL.md
|
package/README.md
CHANGED
|
@@ -69,9 +69,24 @@ The core layer provides the foundation for your CMS:
|
|
|
69
69
|
- `<PlutoNavbarAdmin>` and `<PlutoNavbarAdminActions>`
|
|
70
70
|
Provide structure for injecting actions from other layers
|
|
71
71
|
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
72
|
+
A layer plugs into the admin UI with one function, `definePlutoExtension`, called from a
|
|
73
|
+
plugin. It registers any of: the admin navbar, navbar actions, sidebar nav entries, admin page
|
|
74
|
+
metadata, dashboard widgets, and a settings panel or driver:
|
|
75
|
+
|
|
76
|
+
```ts
|
|
77
|
+
// app/plugins/pluto-registrations.ts
|
|
78
|
+
export default defineNuxtPlugin(() => {
|
|
79
|
+
definePlutoExtension({
|
|
80
|
+
id: 'supabase-blog',
|
|
81
|
+
nav: [{ id: 'posts', label: 'Posts', icon: 'lucide:file-text', to: '/admin/posts' }],
|
|
82
|
+
})
|
|
83
|
+
})
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
See `.claude/skills/extension-registry/SKILL.md` for the full type of each registrable thing
|
|
87
|
+
and the ordering/dedupe rules. The old `useNavbarAdmin().registerNavbar(component)` and
|
|
88
|
+
`useNavbarAdminActions().addAction(component)` composables still work, through a compatibility
|
|
89
|
+
bridge, but `definePlutoExtension` is the current recommended way to plug in.
|
|
75
90
|
|
|
76
91
|
### Backend layers
|
|
77
92
|
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
const { items } = usePlutoDashboardWidgets()
|
|
3
|
+
</script>
|
|
4
|
+
|
|
5
|
+
<template>
|
|
6
|
+
<div v-if="items.length" class="grid grid-cols-1 gap-4 lg:grid-cols-3 lg:gap-x-4 lg:gap-y-6">
|
|
7
|
+
<div
|
|
8
|
+
v-for="widget in items"
|
|
9
|
+
:key="widget.id"
|
|
10
|
+
:class="{ 'lg:col-span-2': widget.colSpan === 2, 'lg:col-span-3': widget.colSpan === 3 }"
|
|
11
|
+
>
|
|
12
|
+
<component :is="widget.component" />
|
|
13
|
+
</div>
|
|
14
|
+
</div>
|
|
15
|
+
|
|
16
|
+
<UCard v-else>
|
|
17
|
+
<p class="text-muted text-sm">
|
|
18
|
+
No dashboard widgets are registered yet. A layer can add one with
|
|
19
|
+
<code>definePlutoExtension</code>.
|
|
20
|
+
</p>
|
|
21
|
+
</UCard>
|
|
22
|
+
</template>
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
<script setup lang="ts">
|
|
2
|
-
const {
|
|
2
|
+
const { items } = usePlutoNavbarActions()
|
|
3
3
|
</script>
|
|
4
4
|
|
|
5
5
|
<template>
|
|
6
|
-
<component :is="
|
|
6
|
+
<component :is="item.component" v-for="item in items" :key="item.id" />
|
|
7
7
|
</template>
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
// Nuxt auto-imports `app/composables/*.ts` at depth 1 only, so this file
|
|
2
|
+
// sits under `internal/` on purpose: it stays out of auto-import, and it can
|
|
3
|
+
// be unit-tested directly with an explicit import, outside a Nuxt build
|
|
4
|
+
// context. Import from 'vue' explicitly here, instead of relying on
|
|
5
|
+
// auto-import, so this file works the same way under plain Vitest.
|
|
6
|
+
import type { ComputedRef } from 'vue'
|
|
7
|
+
import { computed, markRaw, shallowRef } from 'vue'
|
|
8
|
+
|
|
9
|
+
export interface OwnedEntry {
|
|
10
|
+
id: string
|
|
11
|
+
order?: number
|
|
12
|
+
enabled?: () => boolean
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* An owner-keyed registry. Each owner (a layer id) contributes a list of
|
|
17
|
+
* entries. Calling `set` again for the same owner replaces its whole
|
|
18
|
+
* contribution — this makes registration idempotent, so re-running a
|
|
19
|
+
* plugin (for example on HMR) never duplicates entries and needs no
|
|
20
|
+
* dedupe warning.
|
|
21
|
+
*
|
|
22
|
+
* `list` merges every owner's entries, drops disabled ones, and sorts by
|
|
23
|
+
* `order` (default 100), breaking ties by first-registration sequence.
|
|
24
|
+
*/
|
|
25
|
+
export function createOwnedRegistry<T extends OwnedEntry>() {
|
|
26
|
+
const owners = shallowRef<Map<string, T[]>>(new Map())
|
|
27
|
+
let sequence = 0
|
|
28
|
+
const seq = new Map<string, number>()
|
|
29
|
+
|
|
30
|
+
function set(owner: string, entries: T[]) {
|
|
31
|
+
const next = new Map(owners.value)
|
|
32
|
+
next.set(
|
|
33
|
+
owner,
|
|
34
|
+
entries.map((entry) => {
|
|
35
|
+
const id = `${owner}:${entry.id}`
|
|
36
|
+
if (!seq.has(id)) {
|
|
37
|
+
seq.set(id, sequence++)
|
|
38
|
+
}
|
|
39
|
+
return markRaw({ ...entry, id }) as T
|
|
40
|
+
})
|
|
41
|
+
)
|
|
42
|
+
owners.value = next
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function remove(owner: string) {
|
|
46
|
+
const next = new Map(owners.value)
|
|
47
|
+
next.delete(owner)
|
|
48
|
+
owners.value = next
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const list: ComputedRef<T[]> = computed(() =>
|
|
52
|
+
[...owners.value.values()]
|
|
53
|
+
.flat()
|
|
54
|
+
.filter((entry) => entry.enabled?.() ?? true)
|
|
55
|
+
.sort(
|
|
56
|
+
(a, b) =>
|
|
57
|
+
(a.order ?? 100) - (b.order ?? 100) ||
|
|
58
|
+
(seq.get(a.id) ?? 0) - (seq.get(b.id) ?? 0)
|
|
59
|
+
)
|
|
60
|
+
)
|
|
61
|
+
|
|
62
|
+
return { set, remove, list }
|
|
63
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
// The old `NavigationMenuItem`/`NavigationMenuChildItem` shape (from
|
|
2
|
+
// `@plutocms/utils`) always carries a plain `href`, never Nuxt's typed `to`.
|
|
3
|
+
// This narrows just the field the bridge below reads.
|
|
4
|
+
interface LegacyHrefLink {
|
|
5
|
+
href?: string
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* The admin sidebar nav. Merges the new registry with the old
|
|
10
|
+
* `useSidebarAdminActions` composable (`@plutocms/utils`), so an unmigrated
|
|
11
|
+
* layer's sidebar entries keep showing up. A bridged entry is skipped when a
|
|
12
|
+
* registered entry already uses the same label, so a layer that migrates
|
|
13
|
+
* does not get a duplicate item.
|
|
14
|
+
*/
|
|
15
|
+
export function usePlutoAdminNav() {
|
|
16
|
+
const registry = usePlutoRegistry()
|
|
17
|
+
const legacy = useSidebarAdminActions()
|
|
18
|
+
|
|
19
|
+
const items = computed<PlutoNavItem[]>(() => {
|
|
20
|
+
const registered = registry.nav.list.value
|
|
21
|
+
const seen = new Set(registered.map((item) => item.label))
|
|
22
|
+
|
|
23
|
+
const bridged = legacy.actions.value
|
|
24
|
+
.filter((action) => !seen.has(String(action.label)))
|
|
25
|
+
.map((action, index) => ({
|
|
26
|
+
id: `legacy:sidebar-${index}`,
|
|
27
|
+
order: 500,
|
|
28
|
+
label: String(action.label ?? ''),
|
|
29
|
+
icon: action.icon,
|
|
30
|
+
to: (action as LegacyHrefLink).href,
|
|
31
|
+
defaultOpen: action.defaultOpen,
|
|
32
|
+
children: (action.children ?? []).map((child) => ({
|
|
33
|
+
label: String(child.label ?? ''),
|
|
34
|
+
to: (child as LegacyHrefLink).href ?? '',
|
|
35
|
+
})),
|
|
36
|
+
}))
|
|
37
|
+
|
|
38
|
+
return [...registered, ...bridged].sort(
|
|
39
|
+
(a, b) => (a.order ?? 100) - (b.order ?? 100)
|
|
40
|
+
)
|
|
41
|
+
})
|
|
42
|
+
|
|
43
|
+
return { items }
|
|
44
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Metadata for admin pages, registered on top of routes Nuxt already
|
|
3
|
+
* creates from files. No layer registers page metadata today, so this has
|
|
4
|
+
* no legacy bridge.
|
|
5
|
+
*/
|
|
6
|
+
export function usePlutoAdminPages() {
|
|
7
|
+
const registry = usePlutoRegistry()
|
|
8
|
+
const route = useRoute()
|
|
9
|
+
|
|
10
|
+
const items = computed(() => registry.pages.list.value)
|
|
11
|
+
const current = computed(() => items.value.find((page) => page.path === route.path))
|
|
12
|
+
|
|
13
|
+
return { items, current }
|
|
14
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The one function a layer author calls to plug into the admin UI. Every
|
|
3
|
+
* bucket gets set for this extension's id on every call, so a layer's whole
|
|
4
|
+
* admin-UI contribution lives in one place.
|
|
5
|
+
*
|
|
6
|
+
* Call this from a plugin (`app/plugins/*.ts`). Calling it again with the
|
|
7
|
+
* same `id` replaces that layer's whole contribution — it does not add to
|
|
8
|
+
* it — so re-running the plugin (for example on HMR) never duplicates
|
|
9
|
+
* entries.
|
|
10
|
+
*/
|
|
11
|
+
export function definePlutoExtension(definition: PlutoExtension): void {
|
|
12
|
+
const registry = usePlutoRegistry()
|
|
13
|
+
|
|
14
|
+
registry.navbarShell.set(definition.id, definition.navbar ? [definition.navbar] : [])
|
|
15
|
+
registry.navbarActions.set(definition.id, definition.navbarActions ?? [])
|
|
16
|
+
registry.nav.set(definition.id, definition.nav ?? [])
|
|
17
|
+
registry.pages.set(definition.id, definition.pages ?? [])
|
|
18
|
+
registry.settingsPanels.set(definition.id, definition.settingsPanels ?? [])
|
|
19
|
+
registry.settingsDrivers.set(
|
|
20
|
+
definition.id,
|
|
21
|
+
definition.settingsDriver ? [definition.settingsDriver] : []
|
|
22
|
+
)
|
|
23
|
+
registry.dashboardWidgets.set(definition.id, definition.dashboardWidgets ?? [])
|
|
24
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The admin navbar shell (the whole top bar, provided by a backend layer).
|
|
3
|
+
* Reads the new registry and falls back to the old `useNavbarAdmin`
|
|
4
|
+
* composable (`@plutocms/utils`) when no layer has migrated yet. The legacy
|
|
5
|
+
* ref is read inside this `computed`, not once at setup, so a navbar
|
|
6
|
+
* registered after this composable first runs is never lost.
|
|
7
|
+
*/
|
|
8
|
+
export function usePlutoNavbarShell() {
|
|
9
|
+
const registry = usePlutoRegistry()
|
|
10
|
+
const legacy = useNavbarAdmin()
|
|
11
|
+
|
|
12
|
+
const component = computed<Component | null>(() => {
|
|
13
|
+
const registered = registry.navbarShell.list.value
|
|
14
|
+
const winner = registered.at(-1) // highest-order wins; ties go to most recent
|
|
15
|
+
return winner?.component ?? legacy.navbar.value ?? null
|
|
16
|
+
})
|
|
17
|
+
|
|
18
|
+
return { component }
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* The small action buttons rendered inside the navbar. Merges the new
|
|
23
|
+
* registry with the old `useNavbarAdminActions` composable
|
|
24
|
+
* (`@plutocms/utils`), so an unmigrated layer's actions keep showing up.
|
|
25
|
+
*/
|
|
26
|
+
export function usePlutoNavbarActions() {
|
|
27
|
+
const registry = usePlutoRegistry()
|
|
28
|
+
const legacy = useNavbarAdminActions()
|
|
29
|
+
|
|
30
|
+
const items = computed<PlutoNavbarAction[]>(() => [
|
|
31
|
+
...registry.navbarActions.list.value,
|
|
32
|
+
...legacy.actions.value.map((component, index) => ({
|
|
33
|
+
id: `legacy:navbar-action-${index}`,
|
|
34
|
+
order: 500,
|
|
35
|
+
component,
|
|
36
|
+
})),
|
|
37
|
+
])
|
|
38
|
+
|
|
39
|
+
return { items }
|
|
40
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { createOwnedRegistry } from './internal/registry-factory'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* The one registry container for this Nuxt app instance. Created lazily on
|
|
5
|
+
* `nuxtApp`, not at module scope, so it is per-request on the server and
|
|
6
|
+
* per-app on the client — never a process-wide singleton, and never
|
|
7
|
+
* serialized into the SSR payload.
|
|
8
|
+
*
|
|
9
|
+
* No plugin-ordering contract is needed: a layer's plugin can call this
|
|
10
|
+
* before or after any other layer's plugin.
|
|
11
|
+
*/
|
|
12
|
+
export function usePlutoRegistry(): PlutoRegistry {
|
|
13
|
+
const nuxtApp = useNuxtApp()
|
|
14
|
+
|
|
15
|
+
if (!nuxtApp._plutoRegistry) {
|
|
16
|
+
nuxtApp._plutoRegistry = {
|
|
17
|
+
navbarShell: createOwnedRegistry<PlutoNavbarShell>(),
|
|
18
|
+
navbarActions: createOwnedRegistry<PlutoNavbarAction>(),
|
|
19
|
+
nav: createOwnedRegistry<PlutoNavItem>(),
|
|
20
|
+
pages: createOwnedRegistry<PlutoAdminPage>(),
|
|
21
|
+
settingsPanels: createOwnedRegistry<PlutoSettingsPanel>(),
|
|
22
|
+
settingsDrivers: createOwnedRegistry<PlutoSettingsDriver>(),
|
|
23
|
+
dashboardWidgets: createOwnedRegistry<PlutoDashboardWidget>(),
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
// Cast rather than rely on control-flow narrowing of a global-augmented
|
|
28
|
+
// property: that narrowing is fragile across vue-tsc's separate project
|
|
29
|
+
// references (app/server/shared), and has been observed to resolve to
|
|
30
|
+
// `unknown` instead of `PlutoRegistry` in some of them.
|
|
31
|
+
return nuxtApp._plutoRegistry as PlutoRegistry
|
|
32
|
+
}
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
/** Settings panels registered by every layer, in registry order. */
|
|
2
|
+
export function usePlutoSettingsPanels() {
|
|
3
|
+
const registry = usePlutoRegistry()
|
|
4
|
+
return { items: registry.settingsPanels.list }
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* The settings value store, backed by whichever settings driver is
|
|
9
|
+
* registered. `useState` is correct here: these are plain strings, not
|
|
10
|
+
* components, and `/admin/**` is client-only per `nuxt.config.ts`, so there
|
|
11
|
+
* is no SSR-serialization concern.
|
|
12
|
+
*/
|
|
13
|
+
export function usePlutoSettings() {
|
|
14
|
+
const registry = usePlutoRegistry()
|
|
15
|
+
const driver = computed(() => registry.settingsDrivers.list.value.at(-1) ?? null)
|
|
16
|
+
|
|
17
|
+
const values = useState<Record<string, string>>('pluto-settings-values', () => ({}))
|
|
18
|
+
const dirty = useState<Record<string, string>>('pluto-settings-dirty', () => ({}))
|
|
19
|
+
const status = ref<'idle' | 'loading' | 'saving'>('idle')
|
|
20
|
+
|
|
21
|
+
async function load() {
|
|
22
|
+
if (!driver.value) {
|
|
23
|
+
return
|
|
24
|
+
}
|
|
25
|
+
status.value = 'loading'
|
|
26
|
+
try {
|
|
27
|
+
values.value = await driver.value.load()
|
|
28
|
+
} finally {
|
|
29
|
+
status.value = 'idle'
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
async function save() {
|
|
34
|
+
if (!driver.value) {
|
|
35
|
+
return
|
|
36
|
+
}
|
|
37
|
+
status.value = 'saving'
|
|
38
|
+
try {
|
|
39
|
+
await driver.value.save(dirty.value)
|
|
40
|
+
values.value = { ...values.value, ...dirty.value }
|
|
41
|
+
dirty.value = {}
|
|
42
|
+
} finally {
|
|
43
|
+
status.value = 'idle'
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function field(key: string, fallback = '') {
|
|
48
|
+
return computed<string>({
|
|
49
|
+
get: () => dirty.value[key] ?? values.value[key] ?? fallback,
|
|
50
|
+
set: (value) => {
|
|
51
|
+
dirty.value[key] = value
|
|
52
|
+
},
|
|
53
|
+
})
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
return {
|
|
57
|
+
values,
|
|
58
|
+
dirty,
|
|
59
|
+
status,
|
|
60
|
+
hasDriver: computed(() => Boolean(driver.value)),
|
|
61
|
+
load,
|
|
62
|
+
save,
|
|
63
|
+
field,
|
|
64
|
+
}
|
|
65
|
+
}
|
package/app/layouts/admin.vue
CHANGED
|
@@ -6,7 +6,28 @@ import type { NavigationMenuItem } from '@nuxt/ui'
|
|
|
6
6
|
// so drop `to` here to avoid a cross-app type mismatch on that field.
|
|
7
7
|
type SidebarMenuItem = Omit<NavigationMenuItem, 'to'>
|
|
8
8
|
|
|
9
|
-
|
|
9
|
+
function toMenuItem(item: PlutoNavItem): SidebarMenuItem {
|
|
10
|
+
return {
|
|
11
|
+
label: item.label,
|
|
12
|
+
icon: item.icon,
|
|
13
|
+
href: item.to,
|
|
14
|
+
// Top-level items SPA-navigate instead of doing a full page load. This
|
|
15
|
+
// matches what supabase-blog and supabase-shop's own (pre-registry)
|
|
16
|
+
// sidebar entries already did — the registry now does it for every
|
|
17
|
+
// item instead of only theirs. Children stay href-only, same as those
|
|
18
|
+
// two layers' own child entries did.
|
|
19
|
+
onSelect: item.to ? () => navigateTo(item.to) : undefined,
|
|
20
|
+
defaultOpen: item.defaultOpen,
|
|
21
|
+
children: item.children?.map((child) => ({
|
|
22
|
+
label: child.label,
|
|
23
|
+
href: child.to,
|
|
24
|
+
})),
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const { items: navItems } = usePlutoAdminNav()
|
|
29
|
+
const menu = computed<SidebarMenuItem[]>(() => navItems.value.map(toMenuItem))
|
|
30
|
+
|
|
10
31
|
const route = useRoute()
|
|
11
32
|
const isSidebarOpen = useState<boolean>('pluto-admin-sidebar-open', () => false)
|
|
12
33
|
|
|
@@ -14,20 +35,6 @@ function closeSidebar() {
|
|
|
14
35
|
isSidebarOpen.value = false
|
|
15
36
|
}
|
|
16
37
|
|
|
17
|
-
const menu = shallowRef<SidebarMenuItem[]>([
|
|
18
|
-
{
|
|
19
|
-
label: 'Home',
|
|
20
|
-
href: '/admin/home',
|
|
21
|
-
icon: 'lucide:house',
|
|
22
|
-
},
|
|
23
|
-
...(sidebarActions.value as unknown as SidebarMenuItem[]),
|
|
24
|
-
{
|
|
25
|
-
label: 'Settings',
|
|
26
|
-
href: '/admin/settings',
|
|
27
|
-
icon: 'lucide:settings',
|
|
28
|
-
},
|
|
29
|
-
])
|
|
30
|
-
|
|
31
38
|
watch(
|
|
32
39
|
() => route.fullPath,
|
|
33
40
|
closeSidebar
|
package/app/pages/admin/home.vue
CHANGED
|
@@ -9,13 +9,7 @@ useHead({
|
|
|
9
9
|
<AdminView>
|
|
10
10
|
<h1 class="text-3xl font-bold lg:text-4xl">Admin Dashboard</h1>
|
|
11
11
|
|
|
12
|
-
<
|
|
13
|
-
<div
|
|
14
|
-
v-for="i in 6"
|
|
15
|
-
:key="i"
|
|
16
|
-
class="h-50 rounded-3xl border border-gray-500"
|
|
17
|
-
/>
|
|
18
|
-
</div>
|
|
12
|
+
<PlutoDashboardWidgets />
|
|
19
13
|
</AdminView>
|
|
20
14
|
</main>
|
|
21
15
|
</template>
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
useHead({
|
|
3
|
+
title: 'Settings',
|
|
4
|
+
})
|
|
5
|
+
|
|
6
|
+
const { items } = usePlutoSettingsPanels()
|
|
7
|
+
const { hasDriver, status, load, save } = usePlutoSettings()
|
|
8
|
+
|
|
9
|
+
onMounted(() => {
|
|
10
|
+
if (hasDriver.value) {
|
|
11
|
+
load()
|
|
12
|
+
}
|
|
13
|
+
})
|
|
14
|
+
</script>
|
|
15
|
+
|
|
16
|
+
<template>
|
|
17
|
+
<main>
|
|
18
|
+
<AdminView>
|
|
19
|
+
<h1 class="text-3xl font-bold lg:text-4xl">Settings</h1>
|
|
20
|
+
|
|
21
|
+
<UAlert
|
|
22
|
+
v-if="!hasDriver"
|
|
23
|
+
color="warning"
|
|
24
|
+
title="No settings storage"
|
|
25
|
+
description="No installed layer provides settings storage yet."
|
|
26
|
+
/>
|
|
27
|
+
|
|
28
|
+
<template v-else>
|
|
29
|
+
<UCard v-for="panel in items" :key="panel.id">
|
|
30
|
+
<template #header>
|
|
31
|
+
<h2 class="font-semibold">{{ panel.title }}</h2>
|
|
32
|
+
<p v-if="panel.description" class="text-muted text-sm">{{ panel.description }}</p>
|
|
33
|
+
</template>
|
|
34
|
+
|
|
35
|
+
<component :is="panel.component" />
|
|
36
|
+
</UCard>
|
|
37
|
+
|
|
38
|
+
<div class="flex">
|
|
39
|
+
<UButton :loading="status === 'saving'" icon="lucide:save" @click="save">
|
|
40
|
+
Save
|
|
41
|
+
</UButton>
|
|
42
|
+
</div>
|
|
43
|
+
</template>
|
|
44
|
+
</AdminView>
|
|
45
|
+
</main>
|
|
46
|
+
</template>
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
// Registers core's own Home and Settings anchors. `order: 0` and
|
|
2
|
+
// `order: 1000` are reserved for these two entries, so every other layer's
|
|
3
|
+
// nav item sorts between them by default (default order is 100).
|
|
4
|
+
export default defineNuxtPlugin(() => {
|
|
5
|
+
definePlutoExtension({
|
|
6
|
+
id: 'pluto',
|
|
7
|
+
nav: [
|
|
8
|
+
{ id: 'home', order: 0, label: 'Home', icon: 'lucide:house', to: '/admin/home' },
|
|
9
|
+
{ id: 'settings', order: 1000, label: 'Settings', icon: 'lucide:settings', to: '/admin/settings' },
|
|
10
|
+
],
|
|
11
|
+
pages: [
|
|
12
|
+
{ id: 'home', path: '/admin/home', title: 'Dashboard', icon: 'lucide:house' },
|
|
13
|
+
{ id: 'settings', path: '/admin/settings', title: 'Settings', icon: 'lucide:settings' },
|
|
14
|
+
],
|
|
15
|
+
})
|
|
16
|
+
})
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@plutocms/pluto",
|
|
3
3
|
"type": "module",
|
|
4
|
-
"version": "0.
|
|
4
|
+
"version": "0.4.0",
|
|
5
5
|
"description": "The layerable CMS",
|
|
6
6
|
"author": "Victor Ribeiro",
|
|
7
7
|
"license": "MIT",
|
|
@@ -44,13 +44,15 @@
|
|
|
44
44
|
"dependencies": {
|
|
45
45
|
"@iconify-json/lucide": "^1.2.116",
|
|
46
46
|
"@iconify-json/simple-icons": "^1.2.89",
|
|
47
|
+
"@nuxt/eslint": "^1.16.0",
|
|
48
|
+
"@nuxt/ui": "^4.9.0",
|
|
47
49
|
"@plutocms/utils": "^0.2.1",
|
|
48
|
-
"@vueuse/nuxt": "^14.3.0"
|
|
50
|
+
"@vueuse/nuxt": "^14.3.0",
|
|
51
|
+
"reka-ui": "^2.10.1",
|
|
52
|
+
"tailwind-merge": "^3.6.0"
|
|
49
53
|
},
|
|
50
54
|
"devDependencies": {
|
|
51
55
|
"@antfu/eslint-config": "^9.1.0",
|
|
52
|
-
"@nuxt/eslint": "^1.16.0",
|
|
53
|
-
"@nuxt/ui": "^4.9.0",
|
|
54
56
|
"@tailwindcss/vite": "^4.2.1",
|
|
55
57
|
"@types/bun": "^1.3.14",
|
|
56
58
|
"eslint": "^10.6.0",
|
|
@@ -58,8 +60,6 @@
|
|
|
58
60
|
"nuxt": "^4.3.1",
|
|
59
61
|
"prettier": "^3.9.4",
|
|
60
62
|
"prettier-plugin-tailwindcss": "^0.8.0",
|
|
61
|
-
"reka-ui": "^2.10.1",
|
|
62
|
-
"tailwind-merge": "^3.6.0",
|
|
63
63
|
"tailwindcss": "^4.3.2",
|
|
64
64
|
"typescript": "6.0.3",
|
|
65
65
|
"vitest": "^5.0.0",
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import type { PlutoRegistry } from './registry'
|
|
2
|
+
|
|
3
|
+
// Lives in `shared/` (as a `.d.ts` file) so every generated Nuxt TS project
|
|
4
|
+
// (app, server, shared) picks it up automatically, the same way
|
|
5
|
+
// `runtime-config.d.ts` augments `@nuxt/schema`'s `RuntimeConfig` in
|
|
6
|
+
// `@plutocms/supabase`. `_plutoRegistry` is created lazily per `NuxtApp`
|
|
7
|
+
// instance by `usePlutoRegistry` — see `app/composables/pluto-registry.ts`.
|
|
8
|
+
declare module '#app' {
|
|
9
|
+
interface NuxtApp {
|
|
10
|
+
_plutoRegistry?: PlutoRegistry
|
|
11
|
+
}
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export {}
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
import type { Component, ComputedRef } from 'vue'
|
|
2
|
+
|
|
3
|
+
export interface PlutoEntry {
|
|
4
|
+
/** Unique inside this extension. Stored as `<extensionId>:<id>`. */
|
|
5
|
+
id: string
|
|
6
|
+
/** Lower comes first. Default 100. Core uses 0 for Home and 1000 for Settings. */
|
|
7
|
+
order?: number
|
|
8
|
+
/** Reactive visibility test. Called inside a computed. */
|
|
9
|
+
enabled?: () => boolean
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export interface PlutoNavChild {
|
|
13
|
+
label: string
|
|
14
|
+
to: string
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export interface PlutoNavItem extends PlutoEntry {
|
|
18
|
+
label: string
|
|
19
|
+
icon?: string
|
|
20
|
+
/** Admin route path, for example '/admin/posts'. Plain string on purpose. */
|
|
21
|
+
to?: string
|
|
22
|
+
children?: PlutoNavChild[]
|
|
23
|
+
defaultOpen?: boolean
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export interface PlutoNavbarShell extends PlutoEntry {
|
|
27
|
+
component: Component
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export interface PlutoNavbarAction extends PlutoEntry {
|
|
31
|
+
component: Component
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export interface PlutoAdminPage extends PlutoEntry {
|
|
35
|
+
/** The route Nuxt already created from a file. This registry only adds metadata. */
|
|
36
|
+
path: string
|
|
37
|
+
title: string
|
|
38
|
+
icon?: string
|
|
39
|
+
/** Full id of a parent page (e.g. 'supabase-blog:posts'), for a breadcrumb trail. */
|
|
40
|
+
parent?: string
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export interface PlutoDashboardWidget extends PlutoEntry {
|
|
44
|
+
component: Component
|
|
45
|
+
title?: string
|
|
46
|
+
/** Grid columns on large screens. 1 to 3. Default 1. */
|
|
47
|
+
colSpan?: 1 | 2 | 3
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export interface PlutoSettingsPanel extends PlutoEntry {
|
|
51
|
+
title: string
|
|
52
|
+
description?: string
|
|
53
|
+
icon?: string
|
|
54
|
+
component: Component
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export interface PlutoSettingsDriver extends PlutoEntry {
|
|
58
|
+
load: () => Promise<Record<string, string>>
|
|
59
|
+
save: (patch: Record<string, string>) => Promise<void>
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export interface PlutoExtension {
|
|
63
|
+
/** Layer name. Use the package's $meta.name, e.g. 'supabase-blog'. */
|
|
64
|
+
id: string
|
|
65
|
+
navbar?: PlutoNavbarShell
|
|
66
|
+
navbarActions?: PlutoNavbarAction[]
|
|
67
|
+
nav?: PlutoNavItem[]
|
|
68
|
+
pages?: PlutoAdminPage[]
|
|
69
|
+
dashboardWidgets?: PlutoDashboardWidget[]
|
|
70
|
+
settingsPanels?: PlutoSettingsPanel[]
|
|
71
|
+
settingsDriver?: PlutoSettingsDriver
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* The shape `createOwnedRegistry` returns, one per registrable thing. Declared
|
|
76
|
+
* again here, rather than imported from `app/composables/internal`, because
|
|
77
|
+
* the shared TS project does not include `app/`. Structural typing keeps this
|
|
78
|
+
* in sync with the real return type.
|
|
79
|
+
*/
|
|
80
|
+
export interface PlutoRegistryBucket<T extends PlutoEntry> {
|
|
81
|
+
set: (owner: string, entries: T[]) => void
|
|
82
|
+
remove: (owner: string) => void
|
|
83
|
+
list: ComputedRef<T[]>
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export interface PlutoRegistry {
|
|
87
|
+
navbarShell: PlutoRegistryBucket<PlutoNavbarShell>
|
|
88
|
+
navbarActions: PlutoRegistryBucket<PlutoNavbarAction>
|
|
89
|
+
nav: PlutoRegistryBucket<PlutoNavItem>
|
|
90
|
+
pages: PlutoRegistryBucket<PlutoAdminPage>
|
|
91
|
+
settingsPanels: PlutoRegistryBucket<PlutoSettingsPanel>
|
|
92
|
+
settingsDrivers: PlutoRegistryBucket<PlutoSettingsDriver>
|
|
93
|
+
dashboardWidgets: PlutoRegistryBucket<PlutoDashboardWidget>
|
|
94
|
+
}
|