@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
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@plutocms/pluto",
|
|
3
3
|
"type": "module",
|
|
4
|
-
"version": "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
|
+
}
|
package/shared/types/content.ts
CHANGED
|
@@ -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
|
-
}
|