@plutocms/pluto 0.11.0 → 0.13.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 +36 -0
- package/FEATURES.md +13 -0
- package/README.md +30 -0
- package/app/assets/css/admin-theme.css +216 -0
- package/app/assets/css/pluto-layer.css +40 -0
- package/app/components/ColorModeButton.vue +4 -2
- package/app/components/Pluto/Content/PlutoContentForm.vue +1 -0
- package/app/components/Pluto/Content/PlutoContentList.vue +122 -20
- 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 +9 -1
- package/app/components/Pluto/View/PlutoViewAdminPanel.vue +105 -19
- package/app/components/Pluto/View/PlutoViewAside.vue +26 -9
- package/app/components/modal/Modal.vue +1 -1
- package/app/composables/pluto-admin-theme.ts +14 -0
- package/app/composables/pluto-command-palette.ts +59 -0
- package/app/composables/pluto-content-activity.ts +37 -0
- package/app/composables/pluto-content-list.ts +88 -7
- package/app/composables/pluto-content-stats.ts +78 -0
- package/app/composables/pluto-view.ts +9 -0
- package/app/layouts/admin.vue +30 -6
- 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 +2 -1
- package/scripts/generate-admin-theme.cjs +110 -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 +24 -0
- package/app/assets/css/tailwind.css +0 -26
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
// Core's activity widget: items created per week, one series per content
|
|
3
|
+
// type. A trend over time, so it is a chart: an area for one series, lines
|
|
4
|
+
// for several (each with a legend entry). Series colors come from the admin
|
|
5
|
+
// theme's validated chart palette (--ui-chart-*). A visually hidden table
|
|
6
|
+
// carries the same numbers for assistive technology.
|
|
7
|
+
const WEEKS = 12
|
|
8
|
+
|
|
9
|
+
const { data: series, status, refresh } = usePlutoContentActivity(WEEKS)
|
|
10
|
+
|
|
11
|
+
const number = new Intl.NumberFormat()
|
|
12
|
+
// The week starts are UTC dates. Format them in UTC so a viewer west of
|
|
13
|
+
// UTC does not see the previous day.
|
|
14
|
+
const weekLabel = new Intl.DateTimeFormat(undefined, { month: 'short', day: 'numeric', timeZone: 'UTC' })
|
|
15
|
+
|
|
16
|
+
const rows = computed(() => {
|
|
17
|
+
const first = series.value[0]
|
|
18
|
+
if (!first) {
|
|
19
|
+
return []
|
|
20
|
+
}
|
|
21
|
+
return first.points.map((point, index) => {
|
|
22
|
+
const row: Record<string, string | number> = { week: weekLabel.format(new Date(`${point.start}T00:00:00Z`)) }
|
|
23
|
+
for (const entry of series.value) {
|
|
24
|
+
row[entry.name] = entry.points[index]?.count ?? 0
|
|
25
|
+
}
|
|
26
|
+
return row
|
|
27
|
+
})
|
|
28
|
+
})
|
|
29
|
+
|
|
30
|
+
const categories = computed(() => series.value.map((entry) => ({ key: entry.name, label: entry.labelPlural })))
|
|
31
|
+
|
|
32
|
+
const total = computed(() =>
|
|
33
|
+
series.value.reduce((sum, entry) => sum + entry.points.reduce((acc, point) => acc + point.count, 0), 0)
|
|
34
|
+
)
|
|
35
|
+
|
|
36
|
+
const chartType = computed(() => (series.value.length > 1 ? 'line' : 'area'))
|
|
37
|
+
|
|
38
|
+
function retry() {
|
|
39
|
+
refresh()
|
|
40
|
+
}
|
|
41
|
+
</script>
|
|
42
|
+
|
|
43
|
+
<template>
|
|
44
|
+
<UCard :ui="{ body: 'sm:pt-2' }" class="h-full">
|
|
45
|
+
<template #header>
|
|
46
|
+
<div class="flex items-start justify-between gap-4">
|
|
47
|
+
<div>
|
|
48
|
+
<h3 class="text-highlighted font-semibold">Content activity</h3>
|
|
49
|
+
<p class="text-muted text-sm">Items created per week, last {{ WEEKS }} weeks</p>
|
|
50
|
+
</div>
|
|
51
|
+
<div v-if="status === 'success' && series.length" class="text-right">
|
|
52
|
+
<p class="text-highlighted text-2xl font-semibold tabular-nums">{{ number.format(total) }}</p>
|
|
53
|
+
<p class="text-muted text-xs">created</p>
|
|
54
|
+
</div>
|
|
55
|
+
</div>
|
|
56
|
+
</template>
|
|
57
|
+
|
|
58
|
+
<USkeleton v-if="status === 'pending' || status === 'idle'" class="h-64 w-full" />
|
|
59
|
+
|
|
60
|
+
<UAlert
|
|
61
|
+
v-else-if="status === 'error'"
|
|
62
|
+
:actions="[{ label: 'Try again', color: 'error', variant: 'outline', onClick: retry }]"
|
|
63
|
+
color="error"
|
|
64
|
+
variant="subtle"
|
|
65
|
+
icon="lucide:circle-alert"
|
|
66
|
+
title="Could not load the activity"
|
|
67
|
+
/>
|
|
68
|
+
|
|
69
|
+
<UEmpty
|
|
70
|
+
v-else-if="!series.length"
|
|
71
|
+
icon="lucide:chart-line"
|
|
72
|
+
title="Activity is not available"
|
|
73
|
+
description="The content backend cannot count items by creation date yet."
|
|
74
|
+
variant="naked"
|
|
75
|
+
/>
|
|
76
|
+
|
|
77
|
+
<UEmpty
|
|
78
|
+
v-else-if="total === 0"
|
|
79
|
+
:title="`Nothing created in the last ${WEEKS} weeks`"
|
|
80
|
+
icon="lucide:chart-line"
|
|
81
|
+
description="New items show up here week by week."
|
|
82
|
+
variant="naked"
|
|
83
|
+
/>
|
|
84
|
+
|
|
85
|
+
<template v-else>
|
|
86
|
+
<UChart
|
|
87
|
+
:type="chartType"
|
|
88
|
+
:data="rows"
|
|
89
|
+
:categories="categories"
|
|
90
|
+
:height="256"
|
|
91
|
+
:format="(value: number) => number.format(value)"
|
|
92
|
+
:aria-label="`Items created per week, last ${WEEKS} weeks`"
|
|
93
|
+
index="week"
|
|
94
|
+
curve="monotone"
|
|
95
|
+
/>
|
|
96
|
+
|
|
97
|
+
<table class="sr-only">
|
|
98
|
+
<caption>Items created per week, last {{ WEEKS }} weeks</caption>
|
|
99
|
+
<thead>
|
|
100
|
+
<tr>
|
|
101
|
+
<th scope="col">Week of</th>
|
|
102
|
+
<th v-for="entry in series" :key="entry.name" scope="col">{{ entry.labelPlural }}</th>
|
|
103
|
+
</tr>
|
|
104
|
+
</thead>
|
|
105
|
+
<tbody>
|
|
106
|
+
<tr v-for="row in rows" :key="String(row.week)">
|
|
107
|
+
<th scope="row">{{ row.week }}</th>
|
|
108
|
+
<td v-for="entry in series" :key="entry.name">{{ row[entry.name] }}</td>
|
|
109
|
+
</tr>
|
|
110
|
+
</tbody>
|
|
111
|
+
</table>
|
|
112
|
+
</template>
|
|
113
|
+
</UCard>
|
|
114
|
+
</template>
|
|
@@ -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,16 @@
|
|
|
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. The bar is near black in both
|
|
7
|
+
color modes, so `data-pluto-admin-dark` gives it the dark tokens.
|
|
8
|
+
See admin-theme.css.
|
|
9
|
+
-->
|
|
4
10
|
<nav
|
|
5
|
-
class="
|
|
11
|
+
class="text-default fixed inset-0 z-50 h-(--ui-header-height) shrink-0 border-b border-default bg-(--pluto-admin-navbar)"
|
|
12
|
+
data-pluto-admin-scope
|
|
13
|
+
data-pluto-admin-dark
|
|
6
14
|
>
|
|
7
15
|
<slot />
|
|
8
16
|
</nav>
|
|
@@ -1,13 +1,79 @@
|
|
|
1
1
|
<script setup lang="ts">
|
|
2
|
-
|
|
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
|
|
14
|
+
/**
|
|
15
|
+
* Fill the main region's height, and let the content scroll inside its
|
|
16
|
+
* own region, not the whole page. For a view with one long scrolling
|
|
17
|
+
* list, such as a virtualized table. Default false.
|
|
18
|
+
*/
|
|
19
|
+
fill?: boolean
|
|
6
20
|
}>(),
|
|
7
21
|
{
|
|
8
22
|
width: 'container',
|
|
23
|
+
asideOpen: false,
|
|
24
|
+
fill: false,
|
|
9
25
|
}
|
|
10
26
|
)
|
|
27
|
+
|
|
28
|
+
const slots = useSlots()
|
|
29
|
+
const isDesktop = useMediaQuery('(min-width: 64rem)')
|
|
30
|
+
|
|
31
|
+
// The splitter works in percentages of the row width. The aside limits
|
|
32
|
+
// are in px, so convert them against the measured row width. Do not use
|
|
33
|
+
// the splitter's `sizeUnit: 'px'`: when the panel joins with the aside
|
|
34
|
+
// already open (a reload), reka-ui skips the px conversion and reads the
|
|
35
|
+
// px value as a raw size.
|
|
36
|
+
const rowEl = ref<HTMLElement>()
|
|
37
|
+
const { width: rowWidth } = useElementSize(rowEl)
|
|
38
|
+
const toPercent = (px: number) => Math.min(100, (px / rowWidth.value) * 100)
|
|
39
|
+
|
|
40
|
+
// On mobile the aside is a slideover, so it needs no panel. Wait for the
|
|
41
|
+
// row to have a width, so the px-to-percent conversion is correct.
|
|
42
|
+
const resizable = computed(() => rowWidth.value > 0 && Boolean(slots.aside) && props.asideOpen && isDesktop.value)
|
|
43
|
+
provide(plutoViewAsideResizableKey, resizable)
|
|
44
|
+
|
|
45
|
+
const ASIDE_MIN = 240
|
|
46
|
+
const ASIDE_MAX = 560
|
|
47
|
+
|
|
48
|
+
// Keep the aside width in px ourselves, so it stays the same when the
|
|
49
|
+
// window size changes between visits.
|
|
50
|
+
const asideWidth = useCookie<number>('pluto-view-aside-width', { default: () => 320 })
|
|
51
|
+
const asideEl = ref<HTMLElement>()
|
|
52
|
+
|
|
53
|
+
// Snapshot the width when the panel opens. A reactive `defaultSize` would
|
|
54
|
+
// rebuild the panel on every save.
|
|
55
|
+
const clampAside = (px: number) => Math.min(ASIDE_MAX, Math.max(ASIDE_MIN, px))
|
|
56
|
+
const asideDefault = ref(0)
|
|
57
|
+
watch(resizable, (value) => {
|
|
58
|
+
if (value) {
|
|
59
|
+
asideDefault.value = toPercent(clampAside(asideWidth.value))
|
|
60
|
+
}
|
|
61
|
+
}, { immediate: true })
|
|
62
|
+
|
|
63
|
+
function onDragging(_index: number, dragging: boolean) {
|
|
64
|
+
if (!dragging && asideEl.value) {
|
|
65
|
+
asideWidth.value = Math.round(asideEl.value.getBoundingClientRect().width)
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// `id` and `order` let the splitter add and remove the aside panel as the
|
|
70
|
+
// aside opens and closes.
|
|
71
|
+
const items = computed<SplitterItem[]>(() => [
|
|
72
|
+
{ id: 'pluto-view-main', slot: 'main', order: 1 },
|
|
73
|
+
...(resizable.value
|
|
74
|
+
? [{ id: 'pluto-view-aside', slot: 'aside', order: 2, defaultSize: asideDefault.value, minSize: toPercent(ASIDE_MIN), maxSize: toPercent(ASIDE_MAX) }]
|
|
75
|
+
: []),
|
|
76
|
+
])
|
|
11
77
|
</script>
|
|
12
78
|
|
|
13
79
|
<template>
|
|
@@ -16,24 +82,44 @@ withDefaults(
|
|
|
16
82
|
<slot name="toolbar" />
|
|
17
83
|
</div>
|
|
18
84
|
|
|
19
|
-
<div class="flex
|
|
20
|
-
<
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
85
|
+
<div ref="rowEl" class="flex min-h-0 flex-1 overflow-hidden">
|
|
86
|
+
<USplitter
|
|
87
|
+
id="pluto-view"
|
|
88
|
+
:items="items"
|
|
89
|
+
:ui="{
|
|
90
|
+
root: 'min-h-0 flex-1',
|
|
91
|
+
handle: 'w-px bg-(--ui-border) transition-colors hover:bg-primary data-[resize-handle-state=drag]:bg-primary',
|
|
92
|
+
}"
|
|
93
|
+
@dragging="onDragging"
|
|
94
|
+
>
|
|
95
|
+
<template #main>
|
|
96
|
+
<main :class="fill ? 'flex flex-col overflow-hidden' : 'overflow-y-auto'" class="min-h-0 w-full">
|
|
97
|
+
<!--
|
|
98
|
+
'container' matches AdminView's own padding/gap classes, so a view
|
|
99
|
+
moving from AdminView to this panel looks the same. 'full' adds
|
|
100
|
+
none — a focus-layout consumer (e.g. a rich text editor) supplies
|
|
101
|
+
its own padding instead.
|
|
102
|
+
-->
|
|
103
|
+
<UContainer
|
|
104
|
+
v-if="width === 'container'"
|
|
105
|
+
:class="{ 'h-full min-h-0': fill }"
|
|
106
|
+
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"
|
|
107
|
+
>
|
|
108
|
+
<slot />
|
|
109
|
+
</UContainer>
|
|
110
|
+
<slot v-else />
|
|
111
|
+
</main>
|
|
112
|
+
</template>
|
|
113
|
+
|
|
114
|
+
<template #aside>
|
|
115
|
+
<div ref="asideEl" class="h-full w-full">
|
|
116
|
+
<slot name="aside" />
|
|
117
|
+
</div>
|
|
118
|
+
</template>
|
|
119
|
+
</USplitter>
|
|
120
|
+
|
|
121
|
+
<!-- Closed or mobile: the aside renders outside the splitter. -->
|
|
122
|
+
<div v-if="!resizable" class="shrink-0">
|
|
37
123
|
<slot name="aside" />
|
|
38
124
|
</div>
|
|
39
125
|
</div>
|
|
@@ -8,7 +8,11 @@
|
|
|
8
8
|
withDefaults(
|
|
9
9
|
defineProps<{
|
|
10
10
|
title?: string
|
|
11
|
-
/**
|
|
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
|
{
|
|
@@ -22,12 +26,13 @@ function close() {
|
|
|
22
26
|
open.value = false
|
|
23
27
|
}
|
|
24
28
|
|
|
25
|
-
//
|
|
26
|
-
//
|
|
27
|
-
//
|
|
28
|
-
//
|
|
29
|
-
// the
|
|
29
|
+
// Render the plain aside on desktop only, and the slideover on mobile only,
|
|
30
|
+
// so the slot mounts once. CSS classes (`hidden lg:flex`, `lg:hidden`) are
|
|
31
|
+
// not enough: they hide a copy, but the copy stays mounted and fetches its
|
|
32
|
+
// own data. An open slideover is also a modal dialog. It disables pointer
|
|
33
|
+
// events on the rest of the page even while CSS hides it.
|
|
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
|
-
v-if="open"
|
|
42
|
-
:style="{ width: `${width}px` }"
|
|
43
|
-
class="
|
|
50
|
+
v-if="open && isDesktop"
|
|
51
|
+
:style="resizable ? undefined : { width: `${width}px` }"
|
|
52
|
+
:class="resizable ? 'w-full' : 'border-default shrink-0 border-s'"
|
|
53
|
+
class="flex h-full flex-col"
|
|
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>
|
|
@@ -57,9 +67,16 @@ const slideoverOpen = computed({
|
|
|
57
67
|
</div>
|
|
58
68
|
</aside>
|
|
59
69
|
|
|
70
|
+
<!--
|
|
71
|
+
`unmount-on-hide="false"` keeps the slot mounted after the first open,
|
|
72
|
+
so a reopen keeps the widget state (an open picker, a typed search) and
|
|
73
|
+
does not fetch the field options again.
|
|
74
|
+
-->
|
|
60
75
|
<USlideover
|
|
76
|
+
v-if="!isDesktop"
|
|
61
77
|
v-model:open="slideoverOpen"
|
|
62
78
|
:title="title"
|
|
79
|
+
:unmount-on-hide="false"
|
|
63
80
|
side="right"
|
|
64
81
|
>
|
|
65
82
|
<template #body>
|
|
@@ -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
|
+
}
|