@plutocms/pluto 0.10.0 → 0.12.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 +27 -0
- package/FEATURES.md +17 -0
- package/README.md +30 -0
- package/app/assets/css/admin-theme.css +207 -0
- package/app/assets/css/pluto-layer.css +40 -0
- package/app/components/ColorModeButton.vue +4 -2
- package/app/components/Pluto/Content/PlutoContentForm.vue +325 -50
- package/app/components/Pluto/Content/PlutoContentList.vue +192 -108
- package/app/components/Pluto/Dashboard/PlutoDashboardContentActivity.vue +114 -0
- package/app/components/Pluto/Dashboard/PlutoDashboardContentOverview.vue +103 -0
- package/app/components/Pluto/Dashboard/PlutoDashboardQuickActions.vue +70 -0
- package/app/components/Pluto/Navbar/PlutoNavbarAdmin.vue +6 -1
- package/app/components/Pluto/View/PlutoViewAdminPanel.vue +119 -0
- package/app/components/Pluto/View/PlutoViewAside.vue +79 -0
- package/app/components/Pluto/View/PlutoViewToolbar.vue +64 -0
- package/app/composables/pluto-admin-theme.ts +14 -0
- package/app/composables/pluto-command-palette.ts +59 -0
- package/app/composables/pluto-content-actions.ts +17 -0
- package/app/composables/pluto-content-activity.ts +37 -0
- package/app/composables/pluto-content-stats.ts +78 -0
- package/app/composables/pluto-extension.ts +1 -0
- package/app/composables/pluto-registry.ts +1 -0
- package/app/composables/pluto-view.ts +9 -0
- package/app/layouts/admin.vue +59 -62
- package/app/layouts/auth.vue +4 -2
- package/app/pages/admin/home.vue +34 -7
- package/app/plugins/pluto-core-registrations.ts +34 -0
- package/modules/pluto-css/css-entries.ts +182 -0
- package/modules/pluto-css/index.ts +86 -0
- package/nuxt.config.ts +3 -1
- package/package.json +23 -22
- package/scripts/generate-admin-theme.cjs +96 -0
- package/server/api/_pluto/content/[type]/index.get.ts +4 -1
- package/server/api/_pluto/content-activity/[type].get.ts +15 -0
- package/server/utils/pluto-content-handlers.ts +65 -1
- package/shared/types/content.ts +37 -0
- package/shared/types/registry.ts +30 -0
- package/shared/utils/content-map.ts +15 -0
- package/app/assets/css/tailwind.css +0 -11
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import type { SplitterItem } from '@nuxt/ui'
|
|
3
|
+
|
|
4
|
+
const props = withDefaults(
|
|
5
|
+
defineProps<{
|
|
6
|
+
/** Drop the max-width container in the main region. Default 'container'. */
|
|
7
|
+
width?: 'container' | 'full'
|
|
8
|
+
/**
|
|
9
|
+
* Whether the aside is open. Pass the same value as the aside's
|
|
10
|
+
* `v-model:open`. While it is open on desktop, the aside sits in a
|
|
11
|
+
* resizable panel. The panel width persists across visits.
|
|
12
|
+
*/
|
|
13
|
+
asideOpen?: boolean
|
|
14
|
+
}>(),
|
|
15
|
+
{
|
|
16
|
+
width: 'container',
|
|
17
|
+
asideOpen: false,
|
|
18
|
+
}
|
|
19
|
+
)
|
|
20
|
+
|
|
21
|
+
const slots = useSlots()
|
|
22
|
+
const isDesktop = useMediaQuery('(min-width: 64rem)')
|
|
23
|
+
|
|
24
|
+
// The splitter works in percentages of the row width. The aside limits
|
|
25
|
+
// are in px, so convert them against the measured row width. Do not use
|
|
26
|
+
// the splitter's `sizeUnit: 'px'`: when the panel joins with the aside
|
|
27
|
+
// already open (a reload), reka-ui skips the px conversion and reads the
|
|
28
|
+
// px value as a raw size.
|
|
29
|
+
const rowEl = ref<HTMLElement>()
|
|
30
|
+
const { width: rowWidth } = useElementSize(rowEl)
|
|
31
|
+
const toPercent = (px: number) => Math.min(100, (px / rowWidth.value) * 100)
|
|
32
|
+
|
|
33
|
+
// On mobile the aside is a slideover, so it needs no panel. Wait for the
|
|
34
|
+
// row to have a width, so the px-to-percent conversion is correct.
|
|
35
|
+
const resizable = computed(() => rowWidth.value > 0 && Boolean(slots.aside) && props.asideOpen && isDesktop.value)
|
|
36
|
+
provide(plutoViewAsideResizableKey, resizable)
|
|
37
|
+
|
|
38
|
+
const ASIDE_MIN = 240
|
|
39
|
+
const ASIDE_MAX = 560
|
|
40
|
+
|
|
41
|
+
// Keep the aside width in px ourselves, so it stays the same when the
|
|
42
|
+
// window size changes between visits.
|
|
43
|
+
const asideWidth = useCookie<number>('pluto-view-aside-width', { default: () => 320 })
|
|
44
|
+
const asideEl = ref<HTMLElement>()
|
|
45
|
+
|
|
46
|
+
// Snapshot the width when the panel opens. A reactive `defaultSize` would
|
|
47
|
+
// rebuild the panel on every save.
|
|
48
|
+
const clampAside = (px: number) => Math.min(ASIDE_MAX, Math.max(ASIDE_MIN, px))
|
|
49
|
+
const asideDefault = ref(0)
|
|
50
|
+
watch(resizable, (value) => {
|
|
51
|
+
if (value) {
|
|
52
|
+
asideDefault.value = toPercent(clampAside(asideWidth.value))
|
|
53
|
+
}
|
|
54
|
+
}, { immediate: true })
|
|
55
|
+
|
|
56
|
+
function onDragging(_index: number, dragging: boolean) {
|
|
57
|
+
if (!dragging && asideEl.value) {
|
|
58
|
+
asideWidth.value = Math.round(asideEl.value.getBoundingClientRect().width)
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// `id` and `order` let the splitter add and remove the aside panel as the
|
|
63
|
+
// aside opens and closes.
|
|
64
|
+
const items = computed<SplitterItem[]>(() => [
|
|
65
|
+
{ id: 'pluto-view-main', slot: 'main', order: 1 },
|
|
66
|
+
...(resizable.value
|
|
67
|
+
? [{ id: 'pluto-view-aside', slot: 'aside', order: 2, defaultSize: asideDefault.value, minSize: toPercent(ASIDE_MIN), maxSize: toPercent(ASIDE_MAX) }]
|
|
68
|
+
: []),
|
|
69
|
+
])
|
|
70
|
+
</script>
|
|
71
|
+
|
|
72
|
+
<template>
|
|
73
|
+
<div class="flex h-full min-h-0 flex-col overflow-hidden">
|
|
74
|
+
<div class="shrink-0">
|
|
75
|
+
<slot name="toolbar" />
|
|
76
|
+
</div>
|
|
77
|
+
|
|
78
|
+
<div ref="rowEl" class="flex min-h-0 flex-1 overflow-hidden">
|
|
79
|
+
<USplitter
|
|
80
|
+
id="pluto-view"
|
|
81
|
+
:items="items"
|
|
82
|
+
:ui="{
|
|
83
|
+
root: 'min-h-0 flex-1',
|
|
84
|
+
handle: 'w-px bg-(--ui-border) transition-colors hover:bg-primary data-[resize-handle-state=drag]:bg-primary',
|
|
85
|
+
}"
|
|
86
|
+
@dragging="onDragging"
|
|
87
|
+
>
|
|
88
|
+
<template #main>
|
|
89
|
+
<main class="min-h-0 w-full overflow-y-auto">
|
|
90
|
+
<!--
|
|
91
|
+
'container' matches AdminView's own padding/gap classes, so a view
|
|
92
|
+
moving from AdminView to this panel looks the same. 'full' adds
|
|
93
|
+
none — a focus-layout consumer (e.g. a rich text editor) supplies
|
|
94
|
+
its own padding instead.
|
|
95
|
+
-->
|
|
96
|
+
<UContainer
|
|
97
|
+
v-if="width === 'container'"
|
|
98
|
+
class="flex max-w-300 flex-col gap-y-6 px-4 py-6 sm:px-6 lg:gap-y-8 lg:px-8 lg:py-10"
|
|
99
|
+
>
|
|
100
|
+
<slot />
|
|
101
|
+
</UContainer>
|
|
102
|
+
<slot v-else />
|
|
103
|
+
</main>
|
|
104
|
+
</template>
|
|
105
|
+
|
|
106
|
+
<template #aside>
|
|
107
|
+
<div ref="asideEl" class="h-full w-full">
|
|
108
|
+
<slot name="aside" />
|
|
109
|
+
</div>
|
|
110
|
+
</template>
|
|
111
|
+
</USplitter>
|
|
112
|
+
|
|
113
|
+
<!-- Closed or mobile: the aside renders outside the splitter. -->
|
|
114
|
+
<div v-if="!resizable" class="shrink-0">
|
|
115
|
+
<slot name="aside" />
|
|
116
|
+
</div>
|
|
117
|
+
</div>
|
|
118
|
+
</div>
|
|
119
|
+
</template>
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
// Do NOT use UDashboardSidebar here. Nuxt UI's UDashboardSidebar listens to
|
|
3
|
+
// a GLOBAL `dashboard:sidebar:toggle` runtime hook, not a per-instance one.
|
|
4
|
+
// A second instance of it (this aside) would open and close in lockstep
|
|
5
|
+
// with the app's own LEFT sidebar from admin.vue. This component is a
|
|
6
|
+
// hand-built stand-in instead: a plain `<aside>` on desktop, a `USlideover`
|
|
7
|
+
// on mobile, both bound to the same `open` model.
|
|
8
|
+
withDefaults(
|
|
9
|
+
defineProps<{
|
|
10
|
+
title?: string
|
|
11
|
+
/**
|
|
12
|
+
* Desktop width, in px. Default 320. Ignored inside a
|
|
13
|
+
* PlutoViewAdminPanel that gets `aside-open`: its resizable panel sets
|
|
14
|
+
* the width.
|
|
15
|
+
*/
|
|
16
|
+
width?: number
|
|
17
|
+
}>(),
|
|
18
|
+
{
|
|
19
|
+
width: 320,
|
|
20
|
+
}
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
const open = defineModel<boolean>('open', { default: false })
|
|
24
|
+
|
|
25
|
+
function close() {
|
|
26
|
+
open.value = false
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
// Open the slideover on mobile only. A `lg:hidden` class alone is not
|
|
30
|
+
// enough: an open slideover is a modal dialog, so it disables pointer
|
|
31
|
+
// events on the rest of the page even while it is hidden. On desktop, the
|
|
32
|
+
// page then stops taking clicks as soon as the aside opens. It also mounts
|
|
33
|
+
// the slot a second time.
|
|
34
|
+
const isDesktop = useMediaQuery('(min-width: 64rem)')
|
|
35
|
+
const resizable = inject(plutoViewAsideResizableKey, ref(false))
|
|
36
|
+
const slideoverOpen = computed({
|
|
37
|
+
get: () => open.value && !isDesktop.value,
|
|
38
|
+
set: (value: boolean) => {
|
|
39
|
+
open.value = value
|
|
40
|
+
},
|
|
41
|
+
})
|
|
42
|
+
</script>
|
|
43
|
+
|
|
44
|
+
<template>
|
|
45
|
+
<!--
|
|
46
|
+
Inside a resizable PlutoViewAdminPanel the splitter panel owns the
|
|
47
|
+
width and its handle draws the border. Elsewhere, keep the fixed width.
|
|
48
|
+
-->
|
|
49
|
+
<aside
|
|
50
|
+
v-if="open"
|
|
51
|
+
:style="resizable ? undefined : { width: `${width}px` }"
|
|
52
|
+
:class="resizable ? 'w-full' : 'border-default shrink-0 border-s'"
|
|
53
|
+
class="hidden h-full flex-col lg:flex"
|
|
54
|
+
>
|
|
55
|
+
<div class="border-default flex h-12 shrink-0 items-center justify-between gap-1.5 border-b px-4">
|
|
56
|
+
<h2 class="text-highlighted truncate text-sm font-semibold">{{ title }}</h2>
|
|
57
|
+
<UButton
|
|
58
|
+
icon="i-lucide-x"
|
|
59
|
+
color="neutral"
|
|
60
|
+
variant="ghost"
|
|
61
|
+
square
|
|
62
|
+
@click="close"
|
|
63
|
+
/>
|
|
64
|
+
</div>
|
|
65
|
+
<div class="min-h-0 flex-1 overflow-y-auto p-4">
|
|
66
|
+
<slot />
|
|
67
|
+
</div>
|
|
68
|
+
</aside>
|
|
69
|
+
|
|
70
|
+
<USlideover
|
|
71
|
+
v-model:open="slideoverOpen"
|
|
72
|
+
:title="title"
|
|
73
|
+
side="right"
|
|
74
|
+
>
|
|
75
|
+
<template #body>
|
|
76
|
+
<slot />
|
|
77
|
+
</template>
|
|
78
|
+
</USlideover>
|
|
79
|
+
</template>
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
defineProps<{
|
|
3
|
+
title?: string
|
|
4
|
+
icon?: string
|
|
5
|
+
/** Path for a back button, rendered before the title. */
|
|
6
|
+
backTo?: string
|
|
7
|
+
}>()
|
|
8
|
+
|
|
9
|
+
defineSlots<{
|
|
10
|
+
left?: () => unknown
|
|
11
|
+
title?: () => unknown
|
|
12
|
+
default?: () => unknown
|
|
13
|
+
right?: () => unknown
|
|
14
|
+
trailing?: () => unknown
|
|
15
|
+
}>()
|
|
16
|
+
</script>
|
|
17
|
+
|
|
18
|
+
<template>
|
|
19
|
+
<!--
|
|
20
|
+
`:toggle="false"` is load-bearing. UDashboardNavbar's default toggle
|
|
21
|
+
button calls Nuxt UI's global `dashboard:sidebar:toggle` hook, which
|
|
22
|
+
controls the app-level LEFT sidebar from admin.vue. This is a SECOND,
|
|
23
|
+
page-local navbar — its own toggle would open and close the wrong
|
|
24
|
+
sidebar, so it must never render one.
|
|
25
|
+
-->
|
|
26
|
+
<UDashboardNavbar
|
|
27
|
+
:title="title"
|
|
28
|
+
:icon="icon"
|
|
29
|
+
:toggle="false"
|
|
30
|
+
:ui="{ root: 'h-12 border-muted' }"
|
|
31
|
+
>
|
|
32
|
+
<template v-if="$slots.left" #left>
|
|
33
|
+
<slot name="left" />
|
|
34
|
+
</template>
|
|
35
|
+
|
|
36
|
+
<template v-if="backTo || icon" #leading>
|
|
37
|
+
<UButton
|
|
38
|
+
v-if="backTo"
|
|
39
|
+
:to="backTo"
|
|
40
|
+
icon="i-lucide-arrow-left"
|
|
41
|
+
color="neutral"
|
|
42
|
+
variant="ghost"
|
|
43
|
+
square
|
|
44
|
+
/>
|
|
45
|
+
<UIcon v-if="icon" :name="icon" class="me-1.5 size-5 shrink-0 self-center" />
|
|
46
|
+
</template>
|
|
47
|
+
|
|
48
|
+
<template v-if="$slots.title" #title>
|
|
49
|
+
<slot name="title" />
|
|
50
|
+
</template>
|
|
51
|
+
|
|
52
|
+
<template v-if="$slots.default" #default>
|
|
53
|
+
<slot />
|
|
54
|
+
</template>
|
|
55
|
+
|
|
56
|
+
<template v-if="$slots.right" #right>
|
|
57
|
+
<slot name="right" />
|
|
58
|
+
</template>
|
|
59
|
+
|
|
60
|
+
<template v-if="$slots.trailing" #trailing>
|
|
61
|
+
<slot name="trailing" />
|
|
62
|
+
</template>
|
|
63
|
+
</UDashboardNavbar>
|
|
64
|
+
</template>
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Puts `data-pluto-admin` on <html> while the calling component is mounted.
|
|
3
|
+
* `app/assets/css/admin-theme.css` keys the admin's own palette, chart
|
|
4
|
+
* colors, surfaces, and font on that attribute, so the consumer app's
|
|
5
|
+
* public-site theme (its app.config colors and @theme) does not reach the
|
|
6
|
+
* admin. The attribute sits on <html>, not on a layout wrapper, so it also
|
|
7
|
+
* covers overlays that render at the end of <body>.
|
|
8
|
+
*
|
|
9
|
+
* Call it from every admin layout. `useHead` removes the attribute when the
|
|
10
|
+
* layout unmounts, so the public site never gets it.
|
|
11
|
+
*/
|
|
12
|
+
export function usePlutoAdminTheme() {
|
|
13
|
+
useHead({ htmlAttrs: { 'data-pluto-admin': '' } })
|
|
14
|
+
}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import type { CommandPaletteGroup, CommandPaletteItem } from '@nuxt/ui'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Builds the admin command palette groups (`UDashboardSearch` in the
|
|
5
|
+
* admin layout, opened with Cmd/Ctrl+K):
|
|
6
|
+
*
|
|
7
|
+
* - "Create": one "New <label>" command for each content type the user
|
|
8
|
+
* can write.
|
|
9
|
+
* - "Go to": every admin nav item and nav child, from `usePlutoAdminNav`.
|
|
10
|
+
*
|
|
11
|
+
* Each destination shows once. A nav link to a create path (for example
|
|
12
|
+
* "Create new post") is dropped, because the "Create" group has it.
|
|
13
|
+
*
|
|
14
|
+
* Commands navigate with `navigateTo` in `onSelect`, not with `to`. `to`
|
|
15
|
+
* is typed per app through typed pages, so it differs in every app that
|
|
16
|
+
* extends this layer. The admin layout's sidebar uses the same approach.
|
|
17
|
+
*/
|
|
18
|
+
export function usePlutoCommandPalette() {
|
|
19
|
+
const { items: navItems } = usePlutoAdminNav()
|
|
20
|
+
const { items: contentTypes } = usePlutoContentTypes()
|
|
21
|
+
const { can } = usePlutoPermissions()
|
|
22
|
+
|
|
23
|
+
return computed<CommandPaletteGroup<CommandPaletteItem>[]>(() => {
|
|
24
|
+
const seen = new Set<string>()
|
|
25
|
+
|
|
26
|
+
function command(label: string, path: string, extra: Partial<CommandPaletteItem> = {}): CommandPaletteItem[] {
|
|
27
|
+
if (seen.has(path)) {
|
|
28
|
+
return []
|
|
29
|
+
}
|
|
30
|
+
seen.add(path)
|
|
31
|
+
return [{ label, ...extra, onSelect: () => navigateTo(path) }]
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const create = contentTypes.value.flatMap(({ type }) => {
|
|
35
|
+
// "No capability declared" means the operation is open to anyone who
|
|
36
|
+
// can reach the admin, the same rule as PlutoContentForm.
|
|
37
|
+
if (type.capabilities?.write && !can(type.capabilities.write)) {
|
|
38
|
+
return []
|
|
39
|
+
}
|
|
40
|
+
const basePath = type.basePath ?? `/admin/content/${type.name}`
|
|
41
|
+
return command(`New ${type.label}`, type.newPath ?? `${basePath}/new`, {
|
|
42
|
+
icon: 'lucide:plus',
|
|
43
|
+
suffix: type.labelPlural,
|
|
44
|
+
})
|
|
45
|
+
})
|
|
46
|
+
|
|
47
|
+
const goTo = navItems.value.flatMap((item) => [
|
|
48
|
+
...(item.to ? command(item.label, item.to, { icon: item.icon }) : []),
|
|
49
|
+
...(item.children ?? []).flatMap((child) =>
|
|
50
|
+
child.to ? command(child.label, child.to, { icon: item.icon, suffix: item.label }) : []
|
|
51
|
+
),
|
|
52
|
+
])
|
|
53
|
+
|
|
54
|
+
return [
|
|
55
|
+
{ id: 'pluto-create', label: 'Create', items: create },
|
|
56
|
+
{ id: 'pluto-go-to', label: 'Go to', items: goTo },
|
|
57
|
+
].filter((group) => group.items.length > 0)
|
|
58
|
+
})
|
|
59
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The `PlutoContentAction` entries that apply to one content type, at one
|
|
3
|
+
* placement. Matches an entry whose `contentType` is `typeName` or `'*'`,
|
|
4
|
+
* the same matching rule `usePlutoContentFieldWidget` uses for field type.
|
|
5
|
+
* `placement` defaults to 'toolbar', matching `PlutoContentAction.placement`.
|
|
6
|
+
*/
|
|
7
|
+
export function usePlutoContentActions(typeName: string, placement: 'toolbar' | 'aside' = 'toolbar') {
|
|
8
|
+
const registry = usePlutoRegistry()
|
|
9
|
+
|
|
10
|
+
return computed(() =>
|
|
11
|
+
registry.contentActions.list.value.filter(
|
|
12
|
+
(action) =>
|
|
13
|
+
(action.contentType === typeName || action.contentType === '*') &&
|
|
14
|
+
(action.placement ?? 'toolbar') === placement
|
|
15
|
+
)
|
|
16
|
+
)
|
|
17
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
export interface PlutoContentActivitySeries {
|
|
2
|
+
name: string
|
|
3
|
+
label: string
|
|
4
|
+
labelPlural: string
|
|
5
|
+
icon?: string
|
|
6
|
+
points: PlutoContentActivityPoint[]
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Weekly creation counts for every registered content type, from
|
|
11
|
+
* `GET /api/_pluto/content-activity/<type>`. Types whose adapter cannot count
|
|
12
|
+
* by creation date are left out.
|
|
13
|
+
*
|
|
14
|
+
* The fixed `useAsyncData` key shares one fetch between every caller, so the
|
|
15
|
+
* dashboard tiles ("+N this week") and the activity chart do not fetch twice.
|
|
16
|
+
*/
|
|
17
|
+
export function usePlutoContentActivity(weeks = 12) {
|
|
18
|
+
const { items: contentTypes } = usePlutoContentTypes()
|
|
19
|
+
|
|
20
|
+
return useAsyncData(
|
|
21
|
+
`pluto-content-activity-${weeks}`,
|
|
22
|
+
async () => {
|
|
23
|
+
const series = await Promise.all(
|
|
24
|
+
contentTypes.value.map(async ({ type }): Promise<PlutoContentActivitySeries | null> => {
|
|
25
|
+
const activity = await $fetch<PlutoContentActivity>(`/api/_pluto/content-activity/${type.name}`, {
|
|
26
|
+
query: { weeks },
|
|
27
|
+
})
|
|
28
|
+
return activity.supported
|
|
29
|
+
? { name: type.name, label: type.label, labelPlural: type.labelPlural, icon: type.icon, points: activity.points }
|
|
30
|
+
: null
|
|
31
|
+
})
|
|
32
|
+
)
|
|
33
|
+
return series.filter((entry): entry is PlutoContentActivitySeries => entry !== null)
|
|
34
|
+
},
|
|
35
|
+
{ default: () => [] as PlutoContentActivitySeries[], server: false }
|
|
36
|
+
)
|
|
37
|
+
}
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
export interface PlutoContentStat {
|
|
2
|
+
name: string
|
|
3
|
+
label: string
|
|
4
|
+
labelPlural: string
|
|
5
|
+
icon?: string
|
|
6
|
+
/** Admin list path for the content type. */
|
|
7
|
+
path: string
|
|
8
|
+
total: number
|
|
9
|
+
/** Present only for a content type with a status workflow. */
|
|
10
|
+
published?: number
|
|
11
|
+
/** Present only for a content type with a status workflow. */
|
|
12
|
+
drafts?: number
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Counts the items of every registered content type, for the dashboard.
|
|
17
|
+
*
|
|
18
|
+
* Each count is a list request with `limit=1`, so the adapter returns the
|
|
19
|
+
* count without the rows. A type with a status workflow gets a second
|
|
20
|
+
* request with `published=1`. Drafts are the total minus the published
|
|
21
|
+
* count. An adapter that does not return `total` gets one request without
|
|
22
|
+
* a limit, and the count is the number of rows.
|
|
23
|
+
*/
|
|
24
|
+
export function usePlutoContentStats() {
|
|
25
|
+
const { items: contentTypes } = usePlutoContentTypes()
|
|
26
|
+
|
|
27
|
+
const stats = ref<PlutoContentStat[]>([])
|
|
28
|
+
const pending = ref(false)
|
|
29
|
+
const error = ref<unknown>(null)
|
|
30
|
+
|
|
31
|
+
async function count(typeName: string, published: boolean): Promise<number> {
|
|
32
|
+
const url = `/api/_pluto/content/${typeName}`
|
|
33
|
+
const query = published ? { limit: 1, published: 1 } : { limit: 1 }
|
|
34
|
+
const result = await $fetch<PlutoContentListResult>(url, { query })
|
|
35
|
+
if (result.total !== undefined) {
|
|
36
|
+
return result.total
|
|
37
|
+
}
|
|
38
|
+
const all = await $fetch<PlutoContentListResult>(url, { query: published ? { published: 1 } : {} })
|
|
39
|
+
return all.data.length
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
async function refresh(): Promise<void> {
|
|
43
|
+
pending.value = true
|
|
44
|
+
error.value = null
|
|
45
|
+
|
|
46
|
+
try {
|
|
47
|
+
stats.value = await Promise.all(
|
|
48
|
+
contentTypes.value.map(async ({ type }) => {
|
|
49
|
+
const [total, published] = await Promise.all([
|
|
50
|
+
count(type.name, false),
|
|
51
|
+
type.status ? count(type.name, true) : Promise.resolve(undefined),
|
|
52
|
+
])
|
|
53
|
+
|
|
54
|
+
return {
|
|
55
|
+
name: type.name,
|
|
56
|
+
label: type.label,
|
|
57
|
+
labelPlural: type.labelPlural,
|
|
58
|
+
icon: type.icon,
|
|
59
|
+
path: type.basePath ?? `/admin/content/${type.name}`,
|
|
60
|
+
total,
|
|
61
|
+
...(published === undefined ? {} : { published, drafts: Math.max(0, total - published) }),
|
|
62
|
+
}
|
|
63
|
+
})
|
|
64
|
+
)
|
|
65
|
+
} catch (caught) {
|
|
66
|
+
error.value = caught
|
|
67
|
+
throw caught
|
|
68
|
+
} finally {
|
|
69
|
+
pending.value = false
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// Same pattern as usePlutoContentList: fetch right away, and keep a
|
|
74
|
+
// failure in `error` instead of an unhandled rejection.
|
|
75
|
+
refresh().catch(() => {})
|
|
76
|
+
|
|
77
|
+
return { stats, pending, error, refresh }
|
|
78
|
+
}
|
|
@@ -28,5 +28,6 @@ export function definePlutoExtension(definition: PlutoExtension): void {
|
|
|
28
28
|
)
|
|
29
29
|
registry.contentTypes.set(definition.id, definition.contentTypes ?? [])
|
|
30
30
|
registry.contentFieldWidgets.set(definition.id, definition.contentFieldWidgets ?? [])
|
|
31
|
+
registry.contentActions.set(definition.id, definition.contentActions ?? [])
|
|
31
32
|
registry.mediaAdapters.set(definition.id, definition.mediaAdapters ?? [])
|
|
32
33
|
}
|
|
@@ -25,6 +25,7 @@ export function usePlutoRegistry(): PlutoRegistry {
|
|
|
25
25
|
permissionsDrivers: createOwnedRegistry<PlutoPermissionsDriver>(),
|
|
26
26
|
contentTypes: createOwnedRegistry<PlutoContentTypeEntry>(),
|
|
27
27
|
contentFieldWidgets: createOwnedRegistry<PlutoContentFieldWidget>(),
|
|
28
|
+
contentActions: createOwnedRegistry<PlutoContentAction>(),
|
|
28
29
|
mediaAdapters: createOwnedRegistry<PlutoMediaAdapterEntry>(),
|
|
29
30
|
}
|
|
30
31
|
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import type { InjectionKey, Ref } from 'vue'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* `PlutoViewAdminPanel` provides this key. It is `true` while the panel
|
|
5
|
+
* holds its aside in a resizable splitter panel (desktop, aside open).
|
|
6
|
+
* `PlutoViewAside` then fills that panel instead of using its own fixed
|
|
7
|
+
* `width`.
|
|
8
|
+
*/
|
|
9
|
+
export const plutoViewAsideResizableKey: InjectionKey<Ref<boolean>> = Symbol('plutoViewAsideResizable')
|
package/app/layouts/admin.vue
CHANGED
|
@@ -28,76 +28,73 @@ function toMenuItem(item: PlutoNavItem): SidebarMenuItem {
|
|
|
28
28
|
const { items: navItems } = usePlutoAdminNav()
|
|
29
29
|
const menu = computed<SidebarMenuItem[]>(() => navItems.value.map(toMenuItem))
|
|
30
30
|
|
|
31
|
-
|
|
31
|
+
// This key is a cross-repo contract: `supabase`'s own mobile navbar
|
|
32
|
+
// (`app/components/navbar/NavbarAdmin.vue`) writes to this exact state key
|
|
33
|
+
// to open the sidebar from its own hamburger button. Keep the name as-is.
|
|
32
34
|
const isSidebarOpen = useState<boolean>('pluto-admin-sidebar-open', () => false)
|
|
33
35
|
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
36
|
+
const commandGroups = usePlutoCommandPalette()
|
|
37
|
+
|
|
38
|
+
// Keep the consumer app's public-site theme out of the admin.
|
|
39
|
+
usePlutoAdminTheme()
|
|
37
40
|
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
41
|
+
// Start the dashboard group below the fixed admin navbar. An inline `top`
|
|
42
|
+
// overrides the `top: 0` from the group's `inset-0` base class, whatever
|
|
43
|
+
// order the classes merge in. Do not use a class replacer function here:
|
|
44
|
+
// in @nuxt/ui 4.12 a replacer only runs for themes with slots, and the
|
|
45
|
+
// group theme has only `base`, so the replacer was silently ignored.
|
|
46
|
+
const groupStyle = { top: 'var(--ui-header-height)' }
|
|
42
47
|
</script>
|
|
43
48
|
|
|
44
49
|
<template>
|
|
45
|
-
<
|
|
46
|
-
|
|
50
|
+
<UDashboardGroup
|
|
51
|
+
:style="groupStyle"
|
|
52
|
+
storage="cookie"
|
|
53
|
+
storage-key="pluto-admin"
|
|
54
|
+
unit="rem"
|
|
47
55
|
>
|
|
48
|
-
<
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
56
|
+
<UDashboardSidebar
|
|
57
|
+
id="admin"
|
|
58
|
+
v-model:open="isSidebarOpen"
|
|
59
|
+
:default-size="16"
|
|
60
|
+
:min-size="12"
|
|
61
|
+
:max-size="24"
|
|
62
|
+
:ui="{
|
|
63
|
+
root: 'min-h-0 bg-(--pluto-admin-sidebar)',
|
|
64
|
+
content: 'lg:hidden w-[min(18rem,85vw)] bg-(--pluto-admin-sidebar)',
|
|
65
|
+
header: 'h-auto py-3',
|
|
66
|
+
}"
|
|
67
|
+
resizable
|
|
68
|
+
>
|
|
69
|
+
<!--
|
|
70
|
+
The search button must sit inside UDashboardGroup. Outside it, the
|
|
71
|
+
button has no dashboard context and its click does nothing.
|
|
72
|
+
-->
|
|
73
|
+
<template #header>
|
|
74
|
+
<UDashboardSearchButton class="w-full" />
|
|
75
|
+
</template>
|
|
58
76
|
|
|
59
|
-
<
|
|
60
|
-
:
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
color="neutral"
|
|
68
|
-
variant="ghost"
|
|
69
|
-
aria-label="Close navigation"
|
|
70
|
-
square
|
|
71
|
-
@click="closeSidebar"
|
|
72
|
-
/>
|
|
73
|
-
</div>
|
|
77
|
+
<UNavigationMenu
|
|
78
|
+
:items="menu"
|
|
79
|
+
:ui="{ link: 'gap-x-2.5 py-2', linkLeadingIcon: 'size-[18px]' }"
|
|
80
|
+
orientation="vertical"
|
|
81
|
+
variant="pill"
|
|
82
|
+
class="data-[orientation=vertical]:w-full"
|
|
83
|
+
/>
|
|
84
|
+
</UDashboardSidebar>
|
|
74
85
|
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
highlight
|
|
83
|
-
/>
|
|
84
|
-
</aside>
|
|
85
|
-
|
|
86
|
-
<div class="min-w-0 grow overflow-x-hidden overflow-y-auto">
|
|
86
|
+
<UDashboardPanel
|
|
87
|
+
:ui="{
|
|
88
|
+
root: 'min-h-0 bg-(--pluto-admin-canvas)',
|
|
89
|
+
body: 'p-0 gap-0 overflow-x-hidden',
|
|
90
|
+
}"
|
|
91
|
+
>
|
|
92
|
+
<template #body>
|
|
87
93
|
<slot />
|
|
88
|
-
</
|
|
89
|
-
</
|
|
90
|
-
</div>
|
|
91
|
-
</template>
|
|
94
|
+
</template>
|
|
95
|
+
</UDashboardPanel>
|
|
92
96
|
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
}
|
|
98
|
-
|
|
99
|
-
.sidebar-fade-enter-from,
|
|
100
|
-
.sidebar-fade-leave-to {
|
|
101
|
-
opacity: 0;
|
|
102
|
-
}
|
|
103
|
-
</style>
|
|
97
|
+
<!-- Cmd/Ctrl+K. See usePlutoCommandPalette for the commands. -->
|
|
98
|
+
<UDashboardSearch :groups="commandGroups" placeholder="Search pages and actions…" />
|
|
99
|
+
</UDashboardGroup>
|
|
100
|
+
</template>
|
package/app/layouts/auth.vue
CHANGED
|
@@ -1,7 +1,9 @@
|
|
|
1
|
-
<script setup lang="ts"
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
usePlutoAdminTheme()
|
|
3
|
+
</script>
|
|
2
4
|
|
|
3
5
|
<template>
|
|
4
|
-
<div class="
|
|
6
|
+
<div class="flex min-h-dvh grow flex-col bg-(--pluto-admin-canvas)">
|
|
5
7
|
<slot />
|
|
6
8
|
</div>
|
|
7
9
|
</template>
|