@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
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
<script setup lang="ts">
|
|
2
|
-
import type { TableColumn } from '@nuxt/ui'
|
|
2
|
+
import type { ButtonProps, TableColumn } from '@nuxt/ui'
|
|
3
3
|
|
|
4
4
|
const props = defineProps<{
|
|
5
5
|
/** A content type's `name` (not a registry entry id) — see `usePlutoContentTypes`. */
|
|
@@ -13,7 +13,9 @@ if (!contentType.value) {
|
|
|
13
13
|
// The generated page (`app/pages/admin/content/[type]/index.vue`) already
|
|
14
14
|
// resolves the type and 404s before this component ever mounts, so this
|
|
15
15
|
// should not normally happen.
|
|
16
|
-
console.error(
|
|
16
|
+
console.error(
|
|
17
|
+
`PlutoContentList: content type "${props.type}" was not found in the registry.`
|
|
18
|
+
)
|
|
17
19
|
}
|
|
18
20
|
|
|
19
21
|
const { can } = usePlutoPermissions()
|
|
@@ -21,24 +23,63 @@ const { can } = usePlutoPermissions()
|
|
|
21
23
|
// "No capability declared" means the operation is open to anyone who can
|
|
22
24
|
// reach this page — never call can('') as a stand-in for that.
|
|
23
25
|
const canWrite = computed(
|
|
24
|
-
() =>
|
|
26
|
+
() =>
|
|
27
|
+
!contentType.value?.capabilities?.write ||
|
|
28
|
+
can(contentType.value.capabilities.write)
|
|
25
29
|
)
|
|
26
30
|
const canDelete = computed(
|
|
27
|
-
() =>
|
|
31
|
+
() =>
|
|
32
|
+
!contentType.value?.capabilities?.delete ||
|
|
33
|
+
can(contentType.value.capabilities.delete)
|
|
28
34
|
)
|
|
29
35
|
|
|
30
|
-
const basePath = computed(
|
|
36
|
+
const basePath = computed(
|
|
37
|
+
() => contentType.value?.basePath ?? `/admin/content/${props.type}`
|
|
38
|
+
)
|
|
31
39
|
|
|
32
40
|
// newPath/editPath exist so an already-deployed layer's URLs never have
|
|
33
41
|
// to change to adopt this component — see the doc comments on
|
|
34
42
|
// PlutoContentType.newPath/editPath for why a single basePath cannot
|
|
35
43
|
// always express both.
|
|
36
|
-
const newPath = computed(
|
|
44
|
+
const newPath = computed(
|
|
45
|
+
() => contentType.value?.newPath ?? `${basePath.value}/new`
|
|
46
|
+
)
|
|
37
47
|
function editPath(id: string | number) {
|
|
38
48
|
return contentType.value?.editPath?.(id) ?? `${basePath.value}/${id}`
|
|
39
49
|
}
|
|
40
50
|
|
|
41
|
-
const { items, pending, refresh, remove } = usePlutoContentList(props.type)
|
|
51
|
+
const { items, pending, error, refresh, remove } = usePlutoContentList(props.type)
|
|
52
|
+
|
|
53
|
+
// What to show while the list has no rows. With rows, the list shows
|
|
54
|
+
// them, and a failed refresh shows an alert above them instead.
|
|
55
|
+
const emptyState = computed<'loading' | 'error' | 'empty' | null>(() => {
|
|
56
|
+
if (items.value.length > 0) {
|
|
57
|
+
return null
|
|
58
|
+
}
|
|
59
|
+
if (pending.value) {
|
|
60
|
+
return 'loading'
|
|
61
|
+
}
|
|
62
|
+
return error.value ? 'error' : 'empty'
|
|
63
|
+
})
|
|
64
|
+
|
|
65
|
+
function retry() {
|
|
66
|
+
refresh().catch(() => {})
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// Buttons navigate with `navigateTo`, not `to`: `to` is typed per app
|
|
70
|
+
// through typed pages, so a computed button list cannot type it here.
|
|
71
|
+
const emptyActions = computed<ButtonProps[]>(() => {
|
|
72
|
+
const type = contentType.value
|
|
73
|
+
if (!type) {
|
|
74
|
+
return []
|
|
75
|
+
}
|
|
76
|
+
if (emptyState.value === 'error') {
|
|
77
|
+
return [{ label: 'Try again', icon: 'lucide:refresh-ccw', color: 'neutral', variant: 'outline', onClick: retry }]
|
|
78
|
+
}
|
|
79
|
+
return canWrite.value
|
|
80
|
+
? [{ label: `New ${type.label}`, icon: 'lucide:plus', onClick: () => navigateTo(newPath.value) }]
|
|
81
|
+
: []
|
|
82
|
+
})
|
|
42
83
|
|
|
43
84
|
const listFields = computed(() =>
|
|
44
85
|
(contentType.value?.fields ?? [])
|
|
@@ -47,7 +88,10 @@ const listFields = computed(() =>
|
|
|
47
88
|
)
|
|
48
89
|
|
|
49
90
|
const columns = computed<TableColumn<PlutoContentItem>[]>(() => [
|
|
50
|
-
...listFields.value.map((field) => ({
|
|
91
|
+
...listFields.value.map((field) => ({
|
|
92
|
+
accessorKey: field.name,
|
|
93
|
+
header: field.label,
|
|
94
|
+
})),
|
|
51
95
|
{ id: 'actions', header: '' },
|
|
52
96
|
])
|
|
53
97
|
|
|
@@ -75,132 +119,172 @@ async function confirmDelete() {
|
|
|
75
119
|
</script>
|
|
76
120
|
|
|
77
121
|
<template>
|
|
78
|
-
<
|
|
79
|
-
<
|
|
80
|
-
<
|
|
81
|
-
|
|
82
|
-
<ModalContent>
|
|
83
|
-
<p>Do you really want to remove this item?</p>
|
|
84
|
-
</ModalContent>
|
|
85
|
-
|
|
86
|
-
<ModalFooter>
|
|
87
|
-
<div class="flex items-center gap-4">
|
|
88
|
-
<UButton icon="lucide:x" variant="ghost" color="neutral" @click="closeDeleteModal">
|
|
89
|
-
Cancel
|
|
90
|
-
</UButton>
|
|
91
|
-
|
|
92
|
-
<UButton icon="lucide:trash" color="error" @click="confirmDelete">Remove</UButton>
|
|
93
|
-
</div>
|
|
94
|
-
</ModalFooter>
|
|
95
|
-
</Modal>
|
|
96
|
-
|
|
97
|
-
<AdminView>
|
|
98
|
-
<div class="flex flex-col gap-4 lg:flex-row lg:items-center lg:justify-between">
|
|
99
|
-
<hgroup class="flex items-center justify-between gap-x-3 lg:justify-start">
|
|
100
|
-
<h1 class="text-3xl font-bold lg:text-4xl">{{ contentType.labelPlural }}</h1>
|
|
101
|
-
|
|
122
|
+
<PlutoViewAdminPanel v-if="contentType">
|
|
123
|
+
<template #toolbar>
|
|
124
|
+
<PlutoViewToolbar :title="contentType.labelPlural">
|
|
125
|
+
<template #trailing>
|
|
102
126
|
<UButton
|
|
103
127
|
:loading="pending"
|
|
104
128
|
icon="lucide:refresh-ccw"
|
|
105
129
|
variant="ghost"
|
|
106
130
|
title="Refresh"
|
|
107
131
|
square
|
|
108
|
-
@click="
|
|
132
|
+
@click="retry"
|
|
109
133
|
/>
|
|
110
|
-
</
|
|
134
|
+
</template>
|
|
111
135
|
|
|
112
|
-
<
|
|
113
|
-
<UButton
|
|
114
|
-
|
|
115
|
-
icon="lucide:plus"
|
|
116
|
-
as="NuxtLink"
|
|
117
|
-
class="flex-1 justify-center lg:flex-none"
|
|
118
|
-
>
|
|
119
|
-
Add {{ contentType.labelPlural }}
|
|
136
|
+
<template v-if="canWrite" #right>
|
|
137
|
+
<UButton :to="newPath" icon="lucide:plus" as="NuxtLink">
|
|
138
|
+
New {{ contentType.label }}
|
|
120
139
|
</UButton>
|
|
121
|
-
</
|
|
122
|
-
</
|
|
140
|
+
</template>
|
|
141
|
+
</PlutoViewToolbar>
|
|
142
|
+
</template>
|
|
123
143
|
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
144
|
+
<UEmpty
|
|
145
|
+
v-if="emptyState === 'loading'"
|
|
146
|
+
:title="`Loading ${contentType.labelPlural.toLowerCase()}…`"
|
|
147
|
+
variant="naked"
|
|
148
|
+
loading
|
|
149
|
+
/>
|
|
150
|
+
|
|
151
|
+
<UEmpty
|
|
152
|
+
v-else-if="emptyState === 'error'"
|
|
153
|
+
:title="`Could not load ${contentType.labelPlural.toLowerCase()}`"
|
|
154
|
+
:actions="emptyActions"
|
|
155
|
+
description="Check your connection and try again."
|
|
156
|
+
icon="lucide:circle-alert"
|
|
157
|
+
variant="naked"
|
|
158
|
+
/>
|
|
159
|
+
|
|
160
|
+
<UEmpty
|
|
161
|
+
v-else-if="emptyState === 'empty'"
|
|
162
|
+
:title="`No ${contentType.labelPlural.toLowerCase()} yet`"
|
|
163
|
+
:description="canWrite ? `Create the first ${contentType.label.toLowerCase()} to see it here.` : 'There is nothing here yet.'"
|
|
164
|
+
:icon="contentType.icon ?? 'lucide:inbox'"
|
|
165
|
+
:actions="emptyActions"
|
|
166
|
+
variant="naked"
|
|
167
|
+
/>
|
|
168
|
+
|
|
169
|
+
<UAlert
|
|
170
|
+
v-if="!emptyState && error"
|
|
171
|
+
:actions="[{ label: 'Try again', color: 'error', variant: 'outline', onClick: retry }]"
|
|
172
|
+
:title="`Could not refresh ${contentType.labelPlural.toLowerCase()}`"
|
|
173
|
+
description="The list below may be out of date."
|
|
174
|
+
color="error"
|
|
175
|
+
variant="subtle"
|
|
176
|
+
icon="lucide:circle-alert"
|
|
177
|
+
/>
|
|
178
|
+
|
|
179
|
+
<div v-if="!emptyState" class="grid gap-3 lg:hidden">
|
|
180
|
+
<UCard v-for="item in items" :key="item.id">
|
|
181
|
+
<div class="flex flex-col gap-4">
|
|
182
|
+
<div class="min-w-0">
|
|
183
|
+
<NuxtLink
|
|
184
|
+
:to="editPath(item.id)"
|
|
185
|
+
class="block truncate text-lg font-semibold hover:underline"
|
|
186
|
+
>
|
|
187
|
+
{{ item[contentType.titleField] }}
|
|
188
|
+
</NuxtLink>
|
|
189
|
+
</div>
|
|
190
|
+
|
|
191
|
+
<dl class="grid grid-cols-2 gap-3 text-sm">
|
|
192
|
+
<div v-for="field in listFields" :key="field.name">
|
|
193
|
+
<dt class="text-muted">{{ field.label }}</dt>
|
|
194
|
+
<dd class="font-medium">{{ item[field.name] }}</dd>
|
|
134
195
|
</div>
|
|
196
|
+
</dl>
|
|
135
197
|
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
198
|
+
<div class="border-default flex gap-2 border-t pt-3">
|
|
199
|
+
<UButton
|
|
200
|
+
v-if="canWrite"
|
|
201
|
+
:to="editPath(item.id)"
|
|
202
|
+
icon="lucide:pen-line"
|
|
203
|
+
color="neutral"
|
|
204
|
+
variant="soft"
|
|
205
|
+
class="flex-1 justify-center"
|
|
206
|
+
>
|
|
207
|
+
Edit
|
|
208
|
+
</UButton>
|
|
209
|
+
|
|
210
|
+
<UButton
|
|
211
|
+
v-if="canDelete"
|
|
212
|
+
icon="lucide:trash"
|
|
213
|
+
color="error"
|
|
214
|
+
variant="soft"
|
|
215
|
+
class="flex-1 justify-center"
|
|
216
|
+
@click="openDeleteModal(item.id)"
|
|
217
|
+
>
|
|
218
|
+
Remove
|
|
219
|
+
</UButton>
|
|
220
|
+
</div>
|
|
221
|
+
</div>
|
|
222
|
+
</UCard>
|
|
223
|
+
</div>
|
|
142
224
|
|
|
143
|
-
|
|
144
|
-
|
|
225
|
+
<UCard v-if="!emptyState" :ui="{ body: 'sm:p-0 p-0' }" class="hidden lg:block">
|
|
226
|
+
<div class="overflow-x-auto">
|
|
227
|
+
<UTable
|
|
228
|
+
:data="items"
|
|
229
|
+
:columns="columns"
|
|
230
|
+
:loading="pending"
|
|
231
|
+
empty="No items yet."
|
|
232
|
+
>
|
|
233
|
+
<template #actions-cell="{ row }">
|
|
234
|
+
<div class="flex justify-end gap-3">
|
|
235
|
+
<NuxtLink
|
|
145
236
|
v-if="canWrite"
|
|
146
|
-
:to="editPath(
|
|
147
|
-
|
|
148
|
-
color="neutral"
|
|
149
|
-
variant="soft"
|
|
150
|
-
class="flex-1 justify-center"
|
|
237
|
+
:to="editPath(row.original.id)"
|
|
238
|
+
class="text-info px-0 py-0.5 hover:underline"
|
|
151
239
|
>
|
|
152
240
|
Edit
|
|
153
|
-
</
|
|
241
|
+
</NuxtLink>
|
|
154
242
|
|
|
155
|
-
<
|
|
243
|
+
<button
|
|
156
244
|
v-if="canDelete"
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
class="flex-1 justify-center"
|
|
161
|
-
@click="openDeleteModal(item.id)"
|
|
245
|
+
type="button"
|
|
246
|
+
class="text-error cursor-pointer px-0 py-0.5 hover:underline"
|
|
247
|
+
@click="openDeleteModal(row.original.id)"
|
|
162
248
|
>
|
|
163
249
|
Remove
|
|
164
|
-
</
|
|
250
|
+
</button>
|
|
165
251
|
</div>
|
|
166
|
-
</
|
|
167
|
-
</
|
|
252
|
+
</template>
|
|
253
|
+
</UTable>
|
|
168
254
|
</div>
|
|
255
|
+
</UCard>
|
|
256
|
+
</PlutoViewAdminPanel>
|
|
169
257
|
|
|
170
|
-
|
|
171
|
-
<div class="overflow-x-auto">
|
|
172
|
-
<UTable :data="items" :columns="columns" :loading="pending" empty="No items yet.">
|
|
173
|
-
<template #actions-cell="{ row }">
|
|
174
|
-
<div class="flex justify-end gap-3">
|
|
175
|
-
<NuxtLink
|
|
176
|
-
v-if="canWrite"
|
|
177
|
-
:to="editPath(row.original.id)"
|
|
178
|
-
class="text-info px-0 py-0.5 hover:underline"
|
|
179
|
-
>
|
|
180
|
-
Edit
|
|
181
|
-
</NuxtLink>
|
|
182
|
-
|
|
183
|
-
<button
|
|
184
|
-
v-if="canDelete"
|
|
185
|
-
type="button"
|
|
186
|
-
class="text-error cursor-pointer px-0 py-0.5 hover:underline"
|
|
187
|
-
@click="openDeleteModal(row.original.id)"
|
|
188
|
-
>
|
|
189
|
-
Remove
|
|
190
|
-
</button>
|
|
191
|
-
</div>
|
|
192
|
-
</template>
|
|
193
|
-
</UTable>
|
|
194
|
-
</div>
|
|
195
|
-
</UCard>
|
|
196
|
-
</AdminView>
|
|
197
|
-
</div>
|
|
198
|
-
|
|
199
|
-
<AdminView v-else>
|
|
258
|
+
<PlutoViewAdminPanel v-else>
|
|
200
259
|
<UAlert
|
|
201
260
|
:description="`No content type named "${type}" is registered.`"
|
|
202
261
|
color="error"
|
|
203
262
|
title="Content type not found"
|
|
204
263
|
/>
|
|
205
|
-
</
|
|
264
|
+
</PlutoViewAdminPanel>
|
|
265
|
+
|
|
266
|
+
<Modal v-model="isDeleteModalOpen" :custom-size="480">
|
|
267
|
+
<ModalHeader @close="closeDeleteModal">Remove item</ModalHeader>
|
|
268
|
+
|
|
269
|
+
<ModalContent>
|
|
270
|
+
<p>Do you really want to remove this item?</p>
|
|
271
|
+
</ModalContent>
|
|
272
|
+
|
|
273
|
+
<ModalFooter>
|
|
274
|
+
<div class="flex items-center gap-4">
|
|
275
|
+
<UButton
|
|
276
|
+
icon="lucide:x"
|
|
277
|
+
variant="ghost"
|
|
278
|
+
color="neutral"
|
|
279
|
+
@click="closeDeleteModal"
|
|
280
|
+
>
|
|
281
|
+
Cancel
|
|
282
|
+
</UButton>
|
|
283
|
+
|
|
284
|
+
<UButton icon="lucide:trash" color="error" @click="confirmDelete">
|
|
285
|
+
Remove
|
|
286
|
+
</UButton>
|
|
287
|
+
</div>
|
|
288
|
+
</ModalFooter>
|
|
289
|
+
</Modal>
|
|
206
290
|
</template>
|
|
@@ -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,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="
|
|
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>
|