@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.
Files changed (35) hide show
  1. package/CHANGELOG.md +36 -0
  2. package/FEATURES.md +13 -0
  3. package/README.md +30 -0
  4. package/app/assets/css/admin-theme.css +216 -0
  5. package/app/assets/css/pluto-layer.css +40 -0
  6. package/app/components/ColorModeButton.vue +4 -2
  7. package/app/components/Pluto/Content/PlutoContentForm.vue +1 -0
  8. package/app/components/Pluto/Content/PlutoContentList.vue +122 -20
  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 +9 -1
  13. package/app/components/Pluto/View/PlutoViewAdminPanel.vue +105 -19
  14. package/app/components/Pluto/View/PlutoViewAside.vue +26 -9
  15. package/app/components/modal/Modal.vue +1 -1
  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-activity.ts +37 -0
  19. package/app/composables/pluto-content-list.ts +88 -7
  20. package/app/composables/pluto-content-stats.ts +78 -0
  21. package/app/composables/pluto-view.ts +9 -0
  22. package/app/layouts/admin.vue +30 -6
  23. package/app/layouts/auth.vue +4 -2
  24. package/app/pages/admin/home.vue +34 -7
  25. package/app/plugins/pluto-core-registrations.ts +34 -0
  26. package/modules/pluto-css/css-entries.ts +182 -0
  27. package/modules/pluto-css/index.ts +86 -0
  28. package/nuxt.config.ts +3 -1
  29. package/package.json +2 -1
  30. package/scripts/generate-admin-theme.cjs +110 -0
  31. package/server/api/_pluto/content/[type]/index.get.ts +4 -1
  32. package/server/api/_pluto/content-activity/[type].get.ts +15 -0
  33. package/server/utils/pluto-content-handlers.ts +65 -1
  34. package/shared/types/content.ts +24 -0
  35. package/app/assets/css/tailwind.css +0 -26
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@plutocms/pluto",
3
3
  "type": "module",
4
- "version": "0.11.0",
4
+ "version": "0.13.0",
5
5
  "description": "The layerable CMS",
6
6
  "author": "Victor Ribeiro",
7
7
  "license": "MIT",
@@ -48,6 +48,7 @@
48
48
  "@nuxt/eslint": "^1.17.0",
49
49
  "@nuxt/ui": "^4.12.0",
50
50
  "@plutocms/utils": "^0.2.1",
51
+ "@tanstack/charts": "^1.1.0",
51
52
  "@vueuse/nuxt": "^14.4.0",
52
53
  "reka-ui": "^2.11.0",
53
54
  "tailwind-merge": "^3.7.0"
@@ -0,0 +1,110 @@
1
+ // Generates app/assets/css/admin-theme.css from the installed Tailwind
2
+ // palette. Run from the repo root: node scripts/generate-admin-theme.cjs
3
+ // See the admin-theme skill for the palette choices and how they were
4
+ // validated.
5
+ const fs = require('node:fs')
6
+ const path = require('node:path')
7
+
8
+ const theme = fs.readFileSync(path.join(path.dirname(require.resolve('tailwindcss/package.json', { paths: [process.cwd()] })), 'theme.css'), 'utf8')
9
+ const v = (name) => theme.match(new RegExp(String.raw`--color-${name}: (oklch\([^)]*\))`))[1]
10
+ const roles = { primary: 'indigo', secondary: 'violet', success: 'green', info: 'blue', warning: 'amber', error: 'red', neutral: 'zinc' }
11
+ const shades = [50, 100, 200, 300, 400, 500, 600, 700, 800, 900, 950]
12
+ const scales = Object.entries(roles).map(([role, hue]) => ` /* ${role}: ${hue} */\n${shades.map((s) => ` --ui-color-${role}-${s}: ${v(`${hue}-${s}`)};`).join('\n')}`).join('\n\n')
13
+ const semantic = (shade) => Object.keys(roles).filter((r) => r !== 'neutral').map((r) => ` --ui-${r}: var(--ui-color-${r}-${shade});`).join('\n')
14
+ const n = (s) => `var(--ui-color-neutral-${s})`
15
+ const neutralLight = { 'text-dimmed': n(400), 'text-muted': n(500), 'text-toned': n(600), 'text': n(700), 'text-highlighted': n(900), 'text-inverted': '#fff', 'bg': '#fff', 'bg-muted': n(50), 'bg-elevated': n(100), 'bg-accented': n(200), 'bg-inverted': n(900), 'border': n(200), 'border-muted': n(200), 'border-accented': n(300), 'border-inverted': n(900) }
16
+ // Dark cards sit one step above the zinc-900 canvas: between zinc-900 and
17
+ // zinc-800. The chart palette passes the dataviz checks on this surface.
18
+ const CARD_DARK = 'oklch(24% 0.006 286)'
19
+ // The dark sidebar sits between the zinc-950 navbar and the zinc-900 canvas.
20
+ const SIDEBAR_DARK = 'oklch(17.5% 0.0055 286)'
21
+ const neutralDark = { 'text-dimmed': n(500), 'text-muted': n(400), 'text-toned': n(300), 'text': n(200), 'text-highlighted': '#fff', 'text-inverted': n(900), 'bg': CARD_DARK, 'bg-muted': n(800), 'bg-elevated': n(800), 'bg-accented': n(700), 'bg-inverted': '#fff', 'border': n(800), 'border-muted': n(700), 'border-accented': n(700), 'border-inverted': '#fff' }
22
+ const tokens = (o) => Object.entries(o).map(([k, val]) => ` --ui-${k}: ${val};`).join('\n')
23
+ // Validated with the dataviz palette checks on white (light) and zinc-900
24
+ // (dark). Re-run the checks before you change a slot.
25
+ const chartLight = ['amber-600', 'teal-600', 'rose-500', 'sky-600', 'lime-700']
26
+ const chartDark = ['amber-600', 'teal-600', 'rose-500', 'sky-600', 'lime-600']
27
+ const chart = (list) => ` --ui-chart-1: var(--ui-primary);\n${list.map((c, i) => ` --ui-chart-${i + 2}: ${v(c)}; /* ${c} */`).join('\n')}`
28
+
29
+ const css = `/*
30
+ * The Pluto admin theme.
31
+ *
32
+ * Two selectors carry it:
33
+ * - \`:root[data-pluto-admin]\`: usePlutoAdminTheme() sets this attribute on
34
+ * <html> while an admin layout is mounted, so the theme also covers
35
+ * overlays (modals, slideovers, menus, toasts, the command palette) that
36
+ * render at the end of <body>.
37
+ * - \`[data-pluto-admin-scope]\`: for admin chrome that also shows on the
38
+ * public site, such as the admin navbar for a logged-in admin.
39
+ *
40
+ * \`[data-pluto-admin-dark]\` takes the dark tokens in both color modes. The
41
+ * admin navbar is near black in light mode too, so its text and buttons need
42
+ * the dark values.
43
+ *
44
+ * Every value is a literal. The consumer app styles its public site through
45
+ * its own app.config colors and @theme. Both reach the admin unless the admin
46
+ * pins its own values: app.config colors set Nuxt UI's --ui-color-* scales on
47
+ * :root, and @theme can redefine a Tailwind color such as --color-red-400.
48
+ * These selectors are more specific than Nuxt UI's :root and .dark rules.
49
+ *
50
+ * The semantic tokens (--ui-primary, --ui-bg, --ui-text, ...) are declared
51
+ * again here, not only the color scales. A custom property inherits as a
52
+ * resolved value, so on a scoped element the :root tokens would still hold
53
+ * the app's colors.
54
+ *
55
+ * Chart colors are a validated categorical palette: lightness band, chroma
56
+ * floor, colorblind separation, and 3:1 contrast pass on white (light) and
57
+ * on zinc-900 (dark). This file is generated; keep the values in sync with
58
+ * scripts/generate-admin-theme.cjs: run it from the repo root, never edit this
59
+ * file by hand.
60
+ */
61
+ :root[data-pluto-admin],
62
+ [data-pluto-admin-scope] {
63
+ ${scales}
64
+
65
+ ${semantic(500)}
66
+
67
+ ${tokens(neutralLight)}
68
+
69
+ ${chart(chartLight)}
70
+
71
+ /* Surfaces. The navbar is near black in both modes. */
72
+ --pluto-admin-canvas: #fff;
73
+ --pluto-admin-sidebar: ${v('zinc-50')};
74
+ --pluto-admin-navbar: ${v('zinc-950')};
75
+
76
+ --ui-radius: 0.375rem;
77
+ --font-sans: "Outfit", ui-sans-serif, system-ui, sans-serif;
78
+ }
79
+
80
+ :root[data-pluto-admin].dark,
81
+ .dark [data-pluto-admin-scope],
82
+ [data-pluto-admin-dark] {
83
+ ${semantic(400)}
84
+
85
+ ${tokens(neutralDark)}
86
+
87
+ ${chart(chartDark)}
88
+
89
+ /*
90
+ * Four steps, darkest at the top: a near-black navbar, a darker sidebar,
91
+ * a dim dark gray canvas (not pitch black), and cards (--ui-bg) one step
92
+ * above the canvas.
93
+ */
94
+ --pluto-admin-navbar: ${v('zinc-950')};
95
+ --pluto-admin-sidebar: ${SIDEBAR_DARK};
96
+ --pluto-admin-canvas: ${v('zinc-900')};
97
+ }
98
+
99
+ :root[data-pluto-admin] body {
100
+ background-color: var(--pluto-admin-canvas);
101
+ }
102
+
103
+ :root[data-pluto-admin] body,
104
+ [data-pluto-admin-scope] {
105
+ font-family: var(--font-sans);
106
+ color: var(--ui-text);
107
+ }
108
+ `
109
+ fs.writeFileSync('app/assets/css/admin-theme.css', css)
110
+ console.log('written', css.split('\n').length, 'lines')
@@ -11,6 +11,9 @@ export default defineEventHandler(async (event) => {
11
11
  const limit = query.limit === undefined ? undefined : Number.parseInt(query.limit as string, 10)
12
12
  const offset = query.offset === undefined ? undefined : Number.parseInt(query.offset as string, 10)
13
13
  const search = query.search === undefined ? undefined : String(query.search)
14
+ // `?published=1` (or `true`) narrows to published rows. Ignored for a
15
+ // type with no status workflow, which has no unpublished rows.
16
+ const published = type.status ? query.published === '1' || query.published === 'true' : undefined
14
17
 
15
- return listContentItems({ event, type }, { limit, offset, search })
18
+ return listContentItems({ event, type }, { limit, offset, search, published })
16
19
  })
@@ -0,0 +1,15 @@
1
+ /**
2
+ * Weekly creation counts for one content type, for the dashboard activity
3
+ * chart: `GET /api/_pluto/content-activity/<type>?weeks=12`. A separate path,
4
+ * not `/api/_pluto/content/<type>/activity`, so it never collides with the
5
+ * `[id]` item route for an item whose id or slug is "activity".
6
+ */
7
+ export default defineEventHandler(async (event) => {
8
+ const typeName = getRouterParam(event, 'type')
9
+ const type = requireContentType(typeName ?? '')
10
+
11
+ const query = getQuery(event)
12
+ const weeks = query.weeks === undefined ? 12 : Number.parseInt(String(query.weeks), 10)
13
+
14
+ return contentActivity({ event, type }, { weeks: Number.isNaN(weeks) ? 12 : weeks })
15
+ })
@@ -69,6 +69,10 @@ export async function authorizeContentOperation(event: H3Event, capability: stri
69
69
  * asks the adapter for everything, unfiltered by capability, because it is
70
70
  * admin-surface-only in intent — a public-facing read path is a layer's
71
71
  * own hand-written endpoint, not this generic one.
72
+ *
73
+ * `published: true` narrows the result to published rows. The dashboard
74
+ * uses it, with `limit: 1`, to count published rows next to the total.
75
+ * It only narrows the result, so it needs no capability check either.
72
76
  */
73
77
  export async function listContentItems(
74
78
  ctx: PlutoContentContext,
@@ -76,6 +80,7 @@ export async function listContentItems(
76
80
  limit?: number
77
81
  offset?: number
78
82
  search?: string
83
+ published?: boolean
79
84
  }
80
85
  ): Promise<PlutoContentListResult> {
81
86
  return requireContentAdapter().list(ctx, {
@@ -83,7 +88,7 @@ export async function listContentItems(
83
88
  offset: query.offset,
84
89
  search: query.search,
85
90
  sort: ctx.type.defaultSort,
86
- includeUnpublished: true,
91
+ includeUnpublished: query.published !== true,
87
92
  })
88
93
  }
89
94
 
@@ -166,3 +171,62 @@ export async function deleteContentItem(ctx: PlutoContentContext, id: string | n
166
171
 
167
172
  await requireContentAdapter().remove(ctx, id)
168
173
  }
174
+
175
+ /** Most weeks the activity route accepts. */
176
+ export const CONTENT_ACTIVITY_MAX_WEEKS = 52
177
+
178
+ /** Monday 00:00 UTC of the week that contains `date`. */
179
+ function startOfWeekUtc(date: Date): Date {
180
+ const start = new Date(Date.UTC(date.getUTCFullYear(), date.getUTCMonth(), date.getUTCDate()))
181
+ const daysSinceMonday = (start.getUTCDay() + 6) % 7
182
+ start.setUTCDate(start.getUTCDate() - daysSinceMonday)
183
+ return start
184
+ }
185
+
186
+ /**
187
+ * Counts the items created in each of the last `weeks` weeks, oldest first.
188
+ * The last point is the current week, so far. Weeks start on Monday, UTC.
189
+ *
190
+ * No capability check, for the same reason as `listContentItems`: it only
191
+ * returns counts, and the adapter's backing store already limits what the
192
+ * caller can see.
193
+ *
194
+ * Returns `supported: false` with no points when the adapter has no
195
+ * `countCreated`, or when it returns `null` for this content type.
196
+ */
197
+ export async function contentActivity(
198
+ ctx: PlutoContentContext,
199
+ options: {
200
+ weeks: number
201
+ now?: Date
202
+ }
203
+ ): Promise<PlutoContentActivity> {
204
+ const adapter = requireContentAdapter()
205
+ const unsupported: PlutoContentActivity = { supported: false, interval: 'week', points: [] }
206
+
207
+ if (!adapter.countCreated) {
208
+ return unsupported
209
+ }
210
+
211
+ const weeks = Math.min(CONTENT_ACTIVITY_MAX_WEEKS, Math.max(1, Math.trunc(options.weeks)))
212
+ const thisWeek = startOfWeekUtc(options.now ?? new Date())
213
+ const DAY = 24 * 60 * 60 * 1000
214
+
215
+ const ranges = Array.from({ length: weeks }, (_, index) => {
216
+ const from = new Date(thisWeek.getTime() - (weeks - 1 - index) * 7 * DAY)
217
+ const to = new Date(from.getTime() + 7 * DAY)
218
+ return { from: from.toISOString(), to: to.toISOString() }
219
+ })
220
+
221
+ const counts = await Promise.all(ranges.map((range) => adapter.countCreated!(ctx, range)))
222
+
223
+ if (counts.includes(null)) {
224
+ return unsupported
225
+ }
226
+
227
+ return {
228
+ supported: true,
229
+ interval: 'week',
230
+ points: ranges.map((range, index) => ({ start: range.from.slice(0, 10), count: counts[index] as number })),
231
+ }
232
+ }
@@ -289,4 +289,28 @@ export interface PlutoContentAdapter {
289
289
  /** Throws when `event`'s caller does not hold `capability`. Optional — see the fail-open rule in the content-model skill. */
290
290
  authorize?: (event: H3Event, capability: string) => Promise<void>
291
291
  describe?: (ctx: PlutoContentContext) => Promise<string[] | null>
292
+ /**
293
+ * Counts the items created from `range.from` (inclusive) to `range.to`
294
+ * (exclusive), both ISO timestamps. Optional. Return `null` when the
295
+ * content type has no created timestamp. The dashboard activity chart
296
+ * calls it once per week; without it, the chart leaves the type out.
297
+ */
298
+ countCreated?: (ctx: PlutoContentContext, range: {
299
+ from: string
300
+ to: string
301
+ }) => Promise<number | null>
302
+ }
303
+
304
+ /** One week of the content activity series. */
305
+ export interface PlutoContentActivityPoint {
306
+ /** Start of the week, an ISO date (YYYY-MM-DD), Monday, UTC. */
307
+ start: string
308
+ count: number
309
+ }
310
+
311
+ export interface PlutoContentActivity {
312
+ /** `false` when the adapter, or this content type, cannot count by creation date. */
313
+ supported: boolean
314
+ interval: 'week'
315
+ points: PlutoContentActivityPoint[]
292
316
  }
@@ -1,26 +0,0 @@
1
- @import 'tailwindcss';
2
- @import '@nuxt/ui';
3
-
4
- @source '../..';
5
-
6
- @theme {
7
- --color-admin-content: var(--color-zinc-800);
8
- --color-admin-sidebar: var(--color-zinc-900);
9
-
10
- --font-outfit: 'Outfit', sans-serif;
11
- }
12
-
13
- /*
14
- * Set the header height outside a layer. This overrides Nuxt UI's own
15
- * `@layer theme` value for the same variable. The values match the
16
- * navbar's own height classes (`h-14` / `lg:h-10`).
17
- */
18
- :root {
19
- --ui-header-height: 3.5rem;
20
- }
21
-
22
- @media (width >= 64rem) {
23
- :root {
24
- --ui-header-height: 2.5rem;
25
- }
26
- }