@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,4 +1,6 @@
1
1
  <script setup lang="ts">
2
+ import type { ButtonProps } from '@nuxt/ui'
3
+
2
4
  const props = defineProps<{
3
5
  /** A content type's `name` (not a registry entry id) — see `usePlutoContentTypes`. */
4
6
  type: string
@@ -13,7 +15,9 @@ if (!contentType.value) {
13
15
  // The generated page (`app/pages/admin/content/[type]/{new,[id]}.vue`)
14
16
  // already resolves the type and 404s before this component ever mounts,
15
17
  // so this should not normally happen.
16
- console.error(`PlutoContentForm: content type "${props.type}" was not found in the registry.`)
18
+ console.error(
19
+ `PlutoContentForm: content type "${props.type}" was not found in the registry.`
20
+ )
17
21
  }
18
22
 
19
23
  const { can } = usePlutoPermissions()
@@ -21,11 +25,26 @@ const { can } = usePlutoPermissions()
21
25
  // "No capability declared" means the operation is open to anyone who can
22
26
  // reach this page — never call can('') as a stand-in for that.
23
27
  const canWrite = computed(
24
- () => !contentType.value?.capabilities?.write || can(contentType.value.capabilities.write)
28
+ () =>
29
+ !contentType.value?.capabilities?.write ||
30
+ can(contentType.value.capabilities.write)
31
+ )
32
+ const canPublish = computed(() => {
33
+ const cap =
34
+ contentType.value?.capabilities?.publish ??
35
+ contentType.value?.capabilities?.write
36
+ return !cap || can(cap)
37
+ })
38
+ const canDelete = computed(
39
+ () =>
40
+ !contentType.value?.capabilities?.delete ||
41
+ can(contentType.value.capabilities.delete)
25
42
  )
26
43
 
27
44
  const editing = computed(() => props.id !== undefined)
28
- const basePath = computed(() => contentType.value?.basePath ?? `/admin/content/${props.type}`)
45
+ const basePath = computed(
46
+ () => contentType.value?.basePath ?? `/admin/content/${props.type}`
47
+ )
29
48
 
30
49
  // editPath exists so an already-deployed layer's edit URL never has to
31
50
  // change to adopt this component — see the doc comment on
@@ -35,12 +54,14 @@ function editPath(id: string | number) {
35
54
  return contentType.value?.editPath?.(id) ?? `${basePath.value}/${id}`
36
55
  }
37
56
 
38
- const { item, save } = usePlutoContentItem(props.type, () => props.id)
57
+ const { item, save, remove } = usePlutoContentItem(props.type, () => props.id)
39
58
 
40
59
  function sortedFields(region: 'main' | 'side') {
41
60
  return computed(() =>
42
61
  (contentType.value?.fields ?? [])
43
- .filter((field) => field.inForm !== false && (field.region ?? 'main') === region)
62
+ .filter(
63
+ (field) => field.inForm !== false && (field.region ?? 'main') === region
64
+ )
44
65
  .sort((a, b) => (a.order ?? 100) - (b.order ?? 100))
45
66
  )
46
67
  }
@@ -63,7 +84,9 @@ const sideFields = sortedFields('side')
63
84
  // the moment a 'slug' field's own `update:modelValue` fires.
64
85
  const slugTouched = ref(false)
65
86
  const slugField = computed(() =>
66
- contentType.value?.fields.find((field): field is PlutoSlugField => field.type === 'slug')
87
+ contentType.value?.fields.find(
88
+ (field): field is PlutoSlugField => field.type === 'slug'
89
+ )
67
90
  )
68
91
 
69
92
  function slugify(value: string): string {
@@ -96,13 +119,40 @@ function updateField(field: PlutoField, value: unknown) {
96
119
  item.value[field.name] = value
97
120
  }
98
121
 
122
+ // --- Status / publish workflow ----------------------------------------------
123
+
124
+ const statusConfig = computed(() => contentType.value?.status || null)
125
+ const publishedValue = computed(
126
+ () => statusConfig.value?.publishedValue ?? 'published'
127
+ )
128
+ const draftValue = computed(
129
+ () =>
130
+ statusConfig.value?.default ??
131
+ statusConfig.value?.values.find(
132
+ (value) => value.value !== publishedValue.value
133
+ )?.value
134
+ )
135
+ const currentStatus = computed(
136
+ () => (item.value.status as string | undefined) ?? statusConfig.value?.default
137
+ )
138
+ const isPublished = computed(
139
+ () =>
140
+ statusConfig.value !== null && currentStatus.value === publishedValue.value
141
+ )
142
+ const statusLabel = computed(
143
+ () =>
144
+ statusConfig.value?.values.find(
145
+ (value) => value.value === currentStatus.value
146
+ )?.label ?? currentStatus.value
147
+ )
148
+
99
149
  // --- Save -------------------------------------------------------------------
100
150
 
101
151
  const toast = useToast()
102
152
  const saving = ref(false)
103
153
  const validationErrors = ref<ContentValidationError[]>([])
104
154
 
105
- function buildPayload(): Record<string, unknown> {
155
+ function buildPayload(statusOverride?: string): Record<string, unknown> {
106
156
  const type = contentType.value
107
157
  if (!type) {
108
158
  return {}
@@ -115,17 +165,23 @@ function buildPayload(): Record<string, unknown> {
115
165
  }
116
166
  }
117
167
 
168
+ if (statusOverride !== undefined) {
169
+ payload.status = statusOverride
170
+ }
171
+
118
172
  return payload
119
173
  }
120
174
 
121
- async function onSave() {
175
+ async function onSave(statusOverride?: string) {
122
176
  const type = contentType.value
123
177
  if (!type) {
124
178
  return
125
179
  }
126
180
 
127
- const payload = buildPayload()
128
- const errors = validateContentPayload(type, payload, { partial: editing.value })
181
+ const payload = buildPayload(statusOverride)
182
+ const errors = validateContentPayload(type, payload, {
183
+ partial: editing.value,
184
+ })
129
185
 
130
186
  if (errors.length > 0) {
131
187
  validationErrors.value = errors
@@ -143,8 +199,15 @@ async function onSave() {
143
199
  return
144
200
  }
145
201
 
202
+ const title =
203
+ statusOverride === publishedValue.value
204
+ ? 'Published'
205
+ : statusOverride === draftValue.value
206
+ ? 'Unpublished'
207
+ : 'Saved'
208
+
146
209
  toast.add({
147
- title: 'Saved',
210
+ title,
148
211
  description: `${type.label} updated successfully.`,
149
212
  color: 'success',
150
213
  })
@@ -161,52 +224,232 @@ async function onSave() {
161
224
  saving.value = false
162
225
  }
163
226
  }
227
+
228
+ // --- Delete -------------------------------------------------------------------
229
+
230
+ const isDeleteModalOpen = ref(false)
231
+ const deleting = ref(false)
232
+
233
+ function openDeleteModal() {
234
+ isDeleteModalOpen.value = true
235
+ }
236
+
237
+ function closeDeleteModal() {
238
+ isDeleteModalOpen.value = false
239
+ }
240
+
241
+ async function confirmDelete() {
242
+ deleting.value = true
243
+
244
+ try {
245
+ await remove()
246
+ closeDeleteModal()
247
+ await navigateTo(basePath.value)
248
+ } catch {
249
+ toast.add({
250
+ title: 'Error',
251
+ description: 'An error occurred while removing.',
252
+ color: 'error',
253
+ })
254
+ } finally {
255
+ deleting.value = false
256
+ }
257
+ }
258
+
259
+ // --- Layout / aside -----------------------------------------------------------
260
+
261
+ const layout = computed(() => contentType.value?.form?.layout ?? 'default')
262
+ const showAside = computed(() => {
263
+ const configured = contentType.value?.form?.aside
264
+ if (configured === true || configured === false) {
265
+ return configured
266
+ }
267
+ return sideFields.value.length > 0
268
+ })
269
+ const asideCollapsedByDefault = computed(
270
+ () => contentType.value?.form?.asideCollapsed ?? layout.value === 'focus'
271
+ )
272
+ // Remember the open/closed state per content type across visits.
273
+ const asideOpen = useCookie<boolean>(`pluto-content-aside-${props.type}`, {
274
+ default: () => !asideCollapsedByDefault.value,
275
+ })
276
+
277
+ function toggleAside() {
278
+ asideOpen.value = !asideOpen.value
279
+ }
280
+
281
+ // --- Registry content actions --------------------------------------------------
282
+
283
+ const toolbarActions = usePlutoContentActions(props.type, 'toolbar')
284
+
285
+ // Only rendered from the template inside the `v-if="contentType"` branch, so
286
+ // `contentType.value` is always set by the time this is read.
287
+ function buildActionContext(): PlutoContentActionContext {
288
+ return {
289
+ type: contentType.value as PlutoContentType,
290
+ item: item.value,
291
+ editing: editing.value,
292
+ saving: saving.value,
293
+ save: () => onSave(),
294
+ remove: () => confirmDelete(),
295
+ }
296
+ }
297
+
298
+ // No explicit `DropdownMenuItem` annotation. A consumer that links this
299
+ // layer from source can resolve a second copy of `@nuxt/ui`. A named type
300
+ // from one copy then fails to match the component from the other copy. An
301
+ // inferred, structural type matches both.
302
+ const unpublishDeleteItems = computed(() => [
303
+ [
304
+ ...(statusConfig.value && isPublished.value && canPublish.value
305
+ ? [
306
+ {
307
+ label: 'Unpublish',
308
+ icon: 'lucide:eye-off',
309
+ onSelect: () => onSave(draftValue.value),
310
+ },
311
+ ]
312
+ : []),
313
+ ...(editing.value && canDelete.value
314
+ ? [
315
+ {
316
+ label: 'Delete',
317
+ icon: 'lucide:trash',
318
+ color: 'error' as const,
319
+ onSelect: () => openDeleteModal(),
320
+ },
321
+ ]
322
+ : []),
323
+ ],
324
+ ])
164
325
  </script>
165
326
 
166
327
  <template>
167
- <div v-if="contentType">
168
- <AdminView>
169
- <UAlert
170
- v-if="validationErrors.length > 0"
171
- color="error"
172
- title="Please fix the following before saving"
328
+ <PlutoViewAdminPanel
329
+ v-if="contentType"
330
+ :aside-open="showAside && asideOpen"
331
+ :width="layout === 'focus' ? 'full' : 'container'"
332
+ >
333
+ <template #toolbar>
334
+ <PlutoViewToolbar
335
+ :back-to="basePath"
336
+ :title="
337
+ layout === 'focus'
338
+ ? editing
339
+ ? `Edit ${contentType.label}`
340
+ : `New ${contentType.label}`
341
+ : undefined
342
+ "
173
343
  >
174
- <template #description>
175
- <ul class="list-disc pl-4">
176
- <li v-for="fieldError in validationErrors" :key="fieldError.field">
177
- {{ fieldError.message }}
178
- </li>
179
- </ul>
180
- </template>
181
- </UAlert>
182
-
183
- <div class="flex flex-col gap-6 lg:flex-row lg:items-start lg:gap-10">
184
- <div class="flex flex-1 flex-col gap-6">
185
- <h1 class="text-3xl font-bold lg:text-4xl">
186
- {{ editing ? `Edit ${contentType.label}` : `New ${contentType.label}` }}
187
- </h1>
344
+ <template #right>
345
+ <UBadge
346
+ v-if="statusConfig"
347
+ :color="isPublished ? 'success' : 'neutral'"
348
+ variant="subtle"
349
+ >
350
+ {{ statusLabel }}
351
+ </UBadge>
352
+
353
+ <template v-for="action in toolbarActions" :key="action.id">
354
+ <!-- prettier-ignore -->
355
+ <UButton
356
+ v-if="action.show?.(buildActionContext()) !== false"
357
+ :icon="action.icon"
358
+ :color="(action.color as ButtonProps['color'])"
359
+ :variant="(action.variant as ButtonProps['variant'])"
360
+ @click="action.onSelect(buildActionContext())"
361
+ >
362
+ {{ action.label }}
363
+ </UButton>
364
+ </template>
188
365
 
189
- <PlutoContentField
190
- v-for="field in mainFields"
191
- :key="field.name"
192
- :field="field"
193
- :model-value="item[field.name]"
194
- :disabled="!canWrite || saving"
195
- @update:model-value="updateField(field, $event)"
196
- />
197
- </div>
366
+ <UButton
367
+ v-if="canWrite && (!statusConfig || !isPublished)"
368
+ :loading="saving"
369
+ icon="lucide:save"
370
+ variant="subtle"
371
+ @click="onSave()"
372
+ >
373
+ Save{{ statusConfig ? ' draft' : '' }}
374
+ </UButton>
198
375
 
199
- <div class="flex w-full shrink-0 flex-col gap-6 lg:max-w-xs">
200
376
  <UButton
201
- v-if="canWrite"
377
+ v-if="canWrite && statusConfig && isPublished"
202
378
  :loading="saving"
203
379
  icon="lucide:save"
204
- class="justify-center"
205
- @click="onSave"
380
+ @click="onSave()"
381
+ >
382
+ Update
383
+ </UButton>
384
+
385
+ <UButton
386
+ v-if="canPublish && statusConfig && !isPublished"
387
+ :loading="saving"
388
+ icon="lucide:upload"
389
+ @click="onSave(publishedValue)"
206
390
  >
207
- Save
391
+ Publish
208
392
  </UButton>
209
393
 
394
+ <UDropdownMenu
395
+ v-if="
396
+ (editing && canDelete) ||
397
+ (statusConfig && isPublished && canPublish)
398
+ "
399
+ :items="unpublishDeleteItems"
400
+ >
401
+ <UButton
402
+ icon="lucide:ellipsis-vertical"
403
+ color="neutral"
404
+ variant="ghost"
405
+ square
406
+ />
407
+ </UDropdownMenu>
408
+
409
+ <UButton
410
+ v-if="showAside"
411
+ :icon="
412
+ asideOpen ? 'lucide:panel-right-close' : 'lucide:panel-right-open'
413
+ "
414
+ color="neutral"
415
+ variant="ghost"
416
+ square
417
+ @click="toggleAside"
418
+ />
419
+ </template>
420
+ </PlutoViewToolbar>
421
+ </template>
422
+
423
+ <UAlert
424
+ v-if="validationErrors.length > 0"
425
+ color="error"
426
+ title="Please fix the following before saving"
427
+ >
428
+ <template #description>
429
+ <ul class="list-disc pl-4">
430
+ <li v-for="fieldError in validationErrors" :key="fieldError.field">
431
+ {{ fieldError.message }}
432
+ </li>
433
+ </ul>
434
+ </template>
435
+ </UAlert>
436
+
437
+ <h1 v-if="layout !== 'focus'" class="text-3xl font-bold lg:text-4xl">
438
+ {{ editing ? `Edit ${contentType.label}` : `New ${contentType.label}` }}
439
+ </h1>
440
+
441
+ <PlutoContentField
442
+ v-for="field in mainFields"
443
+ :key="field.name"
444
+ :field="field"
445
+ :model-value="item[field.name]"
446
+ :disabled="!canWrite || saving"
447
+ @update:model-value="updateField(field, $event)"
448
+ />
449
+
450
+ <template v-if="showAside" #aside>
451
+ <PlutoViewAside v-model:open="asideOpen" title="Settings">
452
+ <div class="flex flex-col gap-6">
210
453
  <PlutoContentField
211
454
  v-for="field in sideFields"
212
455
  :key="field.name"
@@ -216,15 +459,47 @@ async function onSave() {
216
459
  @update:model-value="updateField(field, $event)"
217
460
  />
218
461
  </div>
219
- </div>
220
- </AdminView>
221
- </div>
462
+ </PlutoViewAside>
463
+ </template>
464
+ </PlutoViewAdminPanel>
222
465
 
223
- <AdminView v-else>
466
+ <PlutoViewAdminPanel v-else>
224
467
  <UAlert
225
468
  :description="`No content type named &quot;${type}&quot; is registered.`"
226
469
  color="error"
227
470
  title="Content type not found"
228
471
  />
229
- </AdminView>
472
+ </PlutoViewAdminPanel>
473
+
474
+ <Modal v-model="isDeleteModalOpen" :custom-size="480">
475
+ <ModalHeader @close="closeDeleteModal">
476
+ Remove {{ contentType?.label }}
477
+ </ModalHeader>
478
+
479
+ <ModalContent>
480
+ <p>Do you really want to remove this item?</p>
481
+ </ModalContent>
482
+
483
+ <ModalFooter>
484
+ <div class="flex items-center gap-4">
485
+ <UButton
486
+ icon="lucide:x"
487
+ variant="ghost"
488
+ color="neutral"
489
+ @click="closeDeleteModal"
490
+ >
491
+ Cancel
492
+ </UButton>
493
+
494
+ <UButton
495
+ :loading="deleting"
496
+ icon="lucide:trash"
497
+ color="error"
498
+ @click="confirmDelete"
499
+ >
500
+ Remove
501
+ </UButton>
502
+ </div>
503
+ </ModalFooter>
504
+ </Modal>
230
505
  </template>