@plutocms/pluto 0.11.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.
Files changed (33) hide show
  1. package/CHANGELOG.md +20 -0
  2. package/FEATURES.md +13 -0
  3. package/README.md +30 -0
  4. package/app/assets/css/admin-theme.css +207 -0
  5. package/app/assets/css/pluto-layer.css +40 -0
  6. package/app/components/ColorModeButton.vue +4 -2
  7. package/app/components/Pluto/Content/PlutoContentForm.vue +1 -0
  8. package/app/components/Pluto/Content/PlutoContentList.vue +72 -6
  9. package/app/components/Pluto/Dashboard/PlutoDashboardContentActivity.vue +114 -0
  10. package/app/components/Pluto/Dashboard/PlutoDashboardContentOverview.vue +103 -0
  11. package/app/components/Pluto/Dashboard/PlutoDashboardQuickActions.vue +70 -0
  12. package/app/components/Pluto/Navbar/PlutoNavbarAdmin.vue +6 -1
  13. package/app/components/Pluto/View/PlutoViewAdminPanel.vue +97 -19
  14. package/app/components/Pluto/View/PlutoViewAside.vue +13 -3
  15. package/app/composables/pluto-admin-theme.ts +14 -0
  16. package/app/composables/pluto-command-palette.ts +59 -0
  17. package/app/composables/pluto-content-activity.ts +37 -0
  18. package/app/composables/pluto-content-stats.ts +78 -0
  19. package/app/composables/pluto-view.ts +9 -0
  20. package/app/layouts/admin.vue +30 -6
  21. package/app/layouts/auth.vue +4 -2
  22. package/app/pages/admin/home.vue +34 -7
  23. package/app/plugins/pluto-core-registrations.ts +34 -0
  24. package/modules/pluto-css/css-entries.ts +182 -0
  25. package/modules/pluto-css/index.ts +86 -0
  26. package/nuxt.config.ts +3 -1
  27. package/package.json +2 -1
  28. package/scripts/generate-admin-theme.cjs +96 -0
  29. package/server/api/_pluto/content/[type]/index.get.ts +4 -1
  30. package/server/api/_pluto/content-activity/[type].get.ts +15 -0
  31. package/server/utils/pluto-content-handlers.ts +65 -1
  32. package/shared/types/content.ts +24 -0
  33. package/app/assets/css/tailwind.css +0 -26
@@ -0,0 +1,103 @@
1
+ <script setup lang="ts">
2
+ // Core's own dashboard widget: one stat tile per content type. Counts are
3
+ // a handful of headline numbers, so they show as tiles, not as a chart. A
4
+ // type with a status workflow also shows a meter of its published share.
5
+ // The meter has a text label next to it, so the share never depends on
6
+ // color alone. "+N this week" comes from the shared activity data, so it
7
+ // costs no extra request.
8
+ const { items: contentTypes } = usePlutoContentTypes()
9
+ const { stats, pending, error, refresh } = usePlutoContentStats()
10
+ const { data: activity } = usePlutoContentActivity()
11
+
12
+ const number = new Intl.NumberFormat()
13
+
14
+ function retry() {
15
+ refresh().catch(() => {})
16
+ }
17
+
18
+ function publishedPercent(stat: PlutoContentStat): number {
19
+ return stat.total > 0 ? Math.round(((stat.published ?? 0) / stat.total) * 100) : 0
20
+ }
21
+
22
+ /** Items created this week, or `undefined` when activity is not available. */
23
+ function thisWeek(stat: PlutoContentStat): number | undefined {
24
+ return activity.value.find((entry) => entry.name === stat.name)?.points.at(-1)?.count
25
+ }
26
+ </script>
27
+
28
+ <template>
29
+ <section v-if="contentTypes.length" aria-label="Content overview">
30
+ <UAlert
31
+ v-if="error && !stats.length"
32
+ :actions="[{ label: 'Try again', color: 'error', variant: 'outline', onClick: retry }]"
33
+ color="error"
34
+ variant="subtle"
35
+ icon="lucide:circle-alert"
36
+ title="Could not load the content counts"
37
+ />
38
+
39
+ <div v-else class="grid grid-cols-1 gap-4 sm:grid-cols-2 xl:grid-cols-3">
40
+ <template v-if="pending && !stats.length">
41
+ <UCard v-for="entry in contentTypes" :key="entry.id">
42
+ <div class="flex flex-col gap-3">
43
+ <USkeleton class="h-4 w-24" />
44
+ <USkeleton class="h-8 w-16" />
45
+ <USkeleton class="h-1.5 w-full" />
46
+ </div>
47
+ </UCard>
48
+ </template>
49
+
50
+ <NuxtLink
51
+ v-for="stat in stats"
52
+ v-else
53
+ :key="stat.name"
54
+ :to="stat.path"
55
+ class="group focus-visible:outline-primary rounded-lg focus-visible:outline-2"
56
+ >
57
+ <UCard class="group-hover:ring-accented h-full transition-shadow group-hover:shadow-sm">
58
+ <div class="flex flex-col gap-3">
59
+ <div class="flex items-start justify-between gap-3">
60
+ <p class="text-muted text-sm font-medium">{{ stat.labelPlural }}</p>
61
+ <span class="bg-primary/10 text-primary flex size-8 shrink-0 items-center justify-center rounded-md">
62
+ <UIcon :name="stat.icon ?? 'lucide:file'" class="size-4" />
63
+ </span>
64
+ </div>
65
+
66
+ <div class="flex items-baseline gap-2">
67
+ <p class="text-highlighted text-3xl font-semibold tracking-tight tabular-nums">
68
+ {{ number.format(stat.total) }}
69
+ </p>
70
+ <p v-if="thisWeek(stat) !== undefined" class="text-sm">
71
+ <template v-if="thisWeek(stat)">
72
+ <span class="text-success font-medium tabular-nums">+{{ number.format(thisWeek(stat)!) }}</span>
73
+ <span class="text-muted"> this week</span>
74
+ </template>
75
+ <span v-else class="text-dimmed">none new this week</span>
76
+ </p>
77
+ </div>
78
+
79
+ <template v-if="stat.published !== undefined">
80
+ <div
81
+ :aria-valuenow="stat.published"
82
+ :aria-valuemax="stat.total"
83
+ :aria-label="`${stat.published} of ${stat.total} ${stat.labelPlural.toLowerCase()} published`"
84
+ role="meter"
85
+ aria-valuemin="0"
86
+ class="bg-accented flex h-1.5 overflow-hidden rounded-full"
87
+ >
88
+ <div :style="{ width: `${publishedPercent(stat)}%` }" class="bg-primary rounded-full" />
89
+ </div>
90
+
91
+ <p class="text-muted text-sm">
92
+ <span class="text-highlighted font-medium tabular-nums">{{ number.format(stat.published) }}</span>
93
+ published ·
94
+ <span class="tabular-nums">{{ number.format(stat.drafts ?? 0) }}</span>
95
+ {{ stat.drafts === 1 ? 'draft' : 'drafts' }}
96
+ </p>
97
+ </template>
98
+ </div>
99
+ </UCard>
100
+ </NuxtLink>
101
+ </div>
102
+ </section>
103
+ </template>
@@ -0,0 +1,70 @@
1
+ <script setup lang="ts">
2
+ // Core's quick actions widget. It reuses the command palette's commands, so
3
+ // the two never disagree: every "New <label>" command the user can run, and
4
+ // a few admin destinations.
5
+ const groups = usePlutoCommandPalette()
6
+
7
+ const create = computed(() => groups.value.find((group) => group.id === 'pluto-create')?.items ?? [])
8
+
9
+ const goTo = computed(() =>
10
+ (groups.value.find((group) => group.id === 'pluto-go-to')?.items ?? [])
11
+ // Home is the page this widget is on.
12
+ .filter((item) => item.label !== 'Home')
13
+ .slice(0, 4)
14
+ )
15
+
16
+ function run(item: {
17
+ onSelect?: (event: Event) => void
18
+ }) {
19
+ item.onSelect?.(new Event('select'))
20
+ }
21
+ </script>
22
+
23
+ <template>
24
+ <UCard class="h-full">
25
+ <template #header>
26
+ <h3 class="text-highlighted font-semibold">Quick actions</h3>
27
+ <p class="text-muted text-sm">
28
+ Press
29
+ <UKbd value="meta" size="sm" />
30
+ <UKbd value="k" size="sm" />
31
+ to search everything.
32
+ </p>
33
+ </template>
34
+
35
+ <div class="flex flex-col gap-4">
36
+ <div v-if="create.length" class="flex flex-col gap-2">
37
+ <UButton
38
+ v-for="item in create"
39
+ :key="item.label"
40
+ :label="item.label"
41
+ icon="lucide:plus"
42
+ color="neutral"
43
+ variant="soft"
44
+ size="md"
45
+ class="justify-start"
46
+ block
47
+ @click="run(item)"
48
+ />
49
+ </div>
50
+
51
+ <div v-if="goTo.length" class="flex flex-col gap-1">
52
+ <p class="text-dimmed text-xs font-medium tracking-wide uppercase">Go to</p>
53
+ <UButton
54
+ v-for="item in goTo"
55
+ :key="item.label"
56
+ :label="item.label"
57
+ :icon="typeof item.icon === 'string' ? item.icon : 'lucide:arrow-right'"
58
+ :ui="{ trailingIcon: 'ms-auto text-dimmed' }"
59
+ trailing-icon="lucide:chevron-right"
60
+ color="neutral"
61
+ variant="ghost"
62
+ size="md"
63
+ class="justify-start"
64
+ block
65
+ @click="run(item)"
66
+ />
67
+ </div>
68
+ </div>
69
+ </UCard>
70
+ </template>
@@ -1,8 +1,13 @@
1
1
  <script setup lang="ts"></script>
2
2
 
3
3
  <template>
4
+ <!--
5
+ `data-pluto-admin-scope` keeps the admin theme on this bar when it shows
6
+ on the public site for a logged-in admin. See admin-theme.css.
7
+ -->
4
8
  <nav
5
- class="light:bg-white light:border-black/10 font-outfit fixed inset-0 z-50 h-(--ui-header-height) shrink-0 border-b dark:border-white/5 dark:bg-black dark:text-white"
9
+ class="text-default fixed inset-0 z-50 h-(--ui-header-height) shrink-0 border-b border-default bg-(--pluto-admin-navbar)"
10
+ data-pluto-admin-scope
6
11
  >
7
12
  <slot />
8
13
  </nav>
@@ -1,13 +1,72 @@
1
1
  <script setup lang="ts">
2
- withDefaults(
2
+ import type { SplitterItem } from '@nuxt/ui'
3
+
4
+ const props = withDefaults(
3
5
  defineProps<{
4
6
  /** Drop the max-width container in the main region. Default 'container'. */
5
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
6
14
  }>(),
7
15
  {
8
16
  width: 'container',
17
+ asideOpen: false,
9
18
  }
10
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
+ ])
11
70
  </script>
12
71
 
13
72
  <template>
@@ -16,24 +75,43 @@ withDefaults(
16
75
  <slot name="toolbar" />
17
76
  </div>
18
77
 
19
- <div class="flex flex-1 min-h-0 overflow-hidden">
20
- <main class="flex-1 min-h-0 overflow-y-auto">
21
- <!--
22
- 'container' matches AdminView's own padding/gap classes, so a view
23
- moving from AdminView to this panel looks the same. 'full' adds
24
- none — a focus-layout consumer (e.g. a rich text editor) supplies
25
- its own padding instead.
26
- -->
27
- <UContainer
28
- v-if="width === 'container'"
29
- 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"
30
- >
31
- <slot />
32
- </UContainer>
33
- <slot v-else />
34
- </main>
35
-
36
- <div class="shrink-0">
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">
37
115
  <slot name="aside" />
38
116
  </div>
39
117
  </div>
@@ -8,7 +8,11 @@
8
8
  withDefaults(
9
9
  defineProps<{
10
10
  title?: string
11
- /** Desktop width, in px. Default 320. */
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
+ */
12
16
  width?: number
13
17
  }>(),
14
18
  {
@@ -28,6 +32,7 @@ function close() {
28
32
  // page then stops taking clicks as soon as the aside opens. It also mounts
29
33
  // the slot a second time.
30
34
  const isDesktop = useMediaQuery('(min-width: 64rem)')
35
+ const resizable = inject(plutoViewAsideResizableKey, ref(false))
31
36
  const slideoverOpen = computed({
32
37
  get: () => open.value && !isDesktop.value,
33
38
  set: (value: boolean) => {
@@ -37,10 +42,15 @@ const slideoverOpen = computed({
37
42
  </script>
38
43
 
39
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
+ -->
40
49
  <aside
41
50
  v-if="open"
42
- :style="{ width: `${width}px` }"
43
- class="border-default hidden h-full shrink-0 flex-col border-s lg:flex"
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"
44
54
  >
45
55
  <div class="border-default flex h-12 shrink-0 items-center justify-between gap-1.5 border-b px-4">
46
56
  <h2 class="text-highlighted truncate text-sm font-semibold">{{ title }}</h2>
@@ -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,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
+ }
@@ -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')
@@ -32,11 +32,23 @@ const menu = computed<SidebarMenuItem[]>(() => navItems.value.map(toMenuItem))
32
32
  // (`app/components/navbar/NavbarAdmin.vue`) writes to this exact state key
33
33
  // to open the sidebar from its own hamburger button. Keep the name as-is.
34
34
  const isSidebarOpen = useState<boolean>('pluto-admin-sidebar-open', () => false)
35
+
36
+ const commandGroups = usePlutoCommandPalette()
37
+
38
+ // Keep the consumer app's public-site theme out of the admin.
39
+ usePlutoAdminTheme()
40
+
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)' }
35
47
  </script>
36
48
 
37
49
  <template>
38
50
  <UDashboardGroup
39
- :class="(base: string) => base.replace('inset-0', 'inset-x-0 bottom-0 top-(--ui-header-height)')"
51
+ :style="groupStyle"
40
52
  storage="cookie"
41
53
  storage-key="pluto-admin"
42
54
  unit="rem"
@@ -48,23 +60,32 @@ const isSidebarOpen = useState<boolean>('pluto-admin-sidebar-open', () => false)
48
60
  :min-size="12"
49
61
  :max-size="24"
50
62
  :ui="{
51
- root: 'min-h-0 dark:bg-admin-sidebar light:bg-zinc-100',
52
- content: 'lg:hidden w-[min(18rem,85vw)]',
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',
53
66
  }"
54
67
  resizable
55
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>
76
+
56
77
  <UNavigationMenu
57
78
  :items="menu"
58
- :ui="{ link: 'text-base gap-x-3 font-normal' }"
79
+ :ui="{ link: 'gap-x-2.5 py-2', linkLeadingIcon: 'size-[18px]' }"
59
80
  orientation="vertical"
81
+ variant="pill"
60
82
  class="data-[orientation=vertical]:w-full"
61
- highlight
62
83
  />
63
84
  </UDashboardSidebar>
64
85
 
65
86
  <UDashboardPanel
66
87
  :ui="{
67
- root: 'min-h-0 dark:bg-admin-content light:bg-white font-outfit',
88
+ root: 'min-h-0 bg-(--pluto-admin-canvas)',
68
89
  body: 'p-0 gap-0 overflow-x-hidden',
69
90
  }"
70
91
  >
@@ -72,5 +93,8 @@ const isSidebarOpen = useState<boolean>('pluto-admin-sidebar-open', () => false)
72
93
  <slot />
73
94
  </template>
74
95
  </UDashboardPanel>
96
+
97
+ <!-- Cmd/Ctrl+K. See usePlutoCommandPalette for the commands. -->
98
+ <UDashboardSearch :groups="commandGroups" placeholder="Search pages and actions…" />
75
99
  </UDashboardGroup>
76
100
  </template>
@@ -1,7 +1,9 @@
1
- <script setup lang="ts"></script>
1
+ <script setup lang="ts">
2
+ usePlutoAdminTheme()
3
+ </script>
2
4
 
3
5
  <template>
4
- <div class="bg-admin-content font-outfit flex min-h-dvh grow flex-col">
6
+ <div class="flex min-h-dvh grow flex-col bg-(--pluto-admin-canvas)">
5
7
  <slot />
6
8
  </div>
7
9
  </template>