@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.
Files changed (39) hide show
  1. package/CHANGELOG.md +27 -0
  2. package/FEATURES.md +17 -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 +325 -50
  8. package/app/components/Pluto/Content/PlutoContentList.vue +192 -108
  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 +119 -0
  14. package/app/components/Pluto/View/PlutoViewAside.vue +79 -0
  15. package/app/components/Pluto/View/PlutoViewToolbar.vue +64 -0
  16. package/app/composables/pluto-admin-theme.ts +14 -0
  17. package/app/composables/pluto-command-palette.ts +59 -0
  18. package/app/composables/pluto-content-actions.ts +17 -0
  19. package/app/composables/pluto-content-activity.ts +37 -0
  20. package/app/composables/pluto-content-stats.ts +78 -0
  21. package/app/composables/pluto-extension.ts +1 -0
  22. package/app/composables/pluto-registry.ts +1 -0
  23. package/app/composables/pluto-view.ts +9 -0
  24. package/app/layouts/admin.vue +59 -62
  25. package/app/layouts/auth.vue +4 -2
  26. package/app/pages/admin/home.vue +34 -7
  27. package/app/plugins/pluto-core-registrations.ts +34 -0
  28. package/modules/pluto-css/css-entries.ts +182 -0
  29. package/modules/pluto-css/index.ts +86 -0
  30. package/nuxt.config.ts +3 -1
  31. package/package.json +23 -22
  32. package/scripts/generate-admin-theme.cjs +96 -0
  33. package/server/api/_pluto/content/[type]/index.get.ts +4 -1
  34. package/server/api/_pluto/content-activity/[type].get.ts +15 -0
  35. package/server/utils/pluto-content-handlers.ts +65 -1
  36. package/shared/types/content.ts +37 -0
  37. package/shared/types/registry.ts +30 -0
  38. package/shared/utils/content-map.ts +15 -0
  39. 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(`PlutoContentList: content type "${props.type}" was not found in the registry.`)
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
- () => !contentType.value?.capabilities?.write || can(contentType.value.capabilities.write)
26
+ () =>
27
+ !contentType.value?.capabilities?.write ||
28
+ can(contentType.value.capabilities.write)
25
29
  )
26
30
  const canDelete = computed(
27
- () => !contentType.value?.capabilities?.delete || can(contentType.value.capabilities.delete)
31
+ () =>
32
+ !contentType.value?.capabilities?.delete ||
33
+ can(contentType.value.capabilities.delete)
28
34
  )
29
35
 
30
- const basePath = computed(() => contentType.value?.basePath ?? `/admin/content/${props.type}`)
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(() => contentType.value?.newPath ?? `${basePath.value}/new`)
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) => ({ accessorKey: field.name, header: field.label })),
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
- <div v-if="contentType">
79
- <Modal v-model="isDeleteModalOpen" :custom-size="480">
80
- <ModalHeader @close="closeDeleteModal">Remove item</ModalHeader>
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="refresh()"
132
+ @click="retry"
109
133
  />
110
- </hgroup>
134
+ </template>
111
135
 
112
- <div v-if="canWrite" class="flex lg:shrink-0">
113
- <UButton
114
- :to="newPath"
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
- </div>
122
- </div>
140
+ </template>
141
+ </PlutoViewToolbar>
142
+ </template>
123
143
 
124
- <div class="grid gap-3 lg:hidden">
125
- <UCard v-for="item in items" :key="item.id">
126
- <div class="flex flex-col gap-4">
127
- <div class="min-w-0">
128
- <NuxtLink
129
- :to="editPath(item.id)"
130
- class="block truncate text-lg font-semibold hover:underline"
131
- >
132
- {{ item[contentType.titleField] }}
133
- </NuxtLink>
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
- <dl class="grid grid-cols-2 gap-3 text-sm">
137
- <div v-for="field in listFields" :key="field.name">
138
- <dt class="text-muted">{{ field.label }}</dt>
139
- <dd class="font-medium">{{ item[field.name] }}</dd>
140
- </div>
141
- </dl>
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
- <div class="flex gap-2 border-t border-default pt-3">
144
- <UButton
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(item.id)"
147
- icon="lucide:pen-line"
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
- </UButton>
241
+ </NuxtLink>
154
242
 
155
- <UButton
243
+ <button
156
244
  v-if="canDelete"
157
- icon="lucide:trash"
158
- color="error"
159
- variant="soft"
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
- </UButton>
250
+ </button>
165
251
  </div>
166
- </div>
167
- </UCard>
252
+ </template>
253
+ </UTable>
168
254
  </div>
255
+ </UCard>
256
+ </PlutoViewAdminPanel>
169
257
 
170
- <UCard :ui="{ body: 'sm:p-0 p-0' }" class="hidden lg:block">
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 &quot;${type}&quot; is registered.`"
202
261
  color="error"
203
262
  title="Content type not found"
204
263
  />
205
- </AdminView>
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="light:bg-white light:border-black/10 font-outfit sticky top-0 z-50 h-14 shrink-0 border-b lg:h-10 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>