@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,15 +1,42 @@
1
1
  <script setup lang="ts">
2
2
  useHead({
3
- title: `Admin`,
3
+ title: 'Dashboard',
4
4
  })
5
+
6
+ const today = new Intl.DateTimeFormat(undefined, { weekday: 'long', month: 'long', day: 'numeric' }).format(new Date())
7
+
8
+ // The header's Create button reuses the command palette's Create commands.
9
+ const groups = usePlutoCommandPalette()
10
+ const createItems = computed(() => groups.value.find((group) => group.id === 'pluto-create')?.items ?? [])
11
+
12
+ function run(item: {
13
+ onSelect?: (event: Event) => void
14
+ }) {
15
+ item.onSelect?.(new Event('select'))
16
+ }
5
17
  </script>
6
18
 
7
19
  <template>
8
- <main>
9
- <AdminView>
10
- <h1 class="text-3xl font-bold lg:text-4xl">Admin Dashboard</h1>
20
+ <PlutoViewAdminPanel>
21
+ <header class="flex flex-col gap-4 sm:flex-row sm:items-end sm:justify-between">
22
+ <div>
23
+ <p class="text-muted text-sm">{{ today }}</p>
24
+ <h1 class="text-highlighted text-2xl font-semibold tracking-tight lg:text-3xl">Dashboard</h1>
25
+ </div>
26
+
27
+ <UButton
28
+ v-if="createItems.length === 1"
29
+ :label="createItems[0]!.label"
30
+ icon="lucide:plus"
31
+ size="md"
32
+ @click="run(createItems[0]!)"
33
+ />
34
+
35
+ <UDropdownMenu v-else-if="createItems.length > 1" :items="createItems" :content="{ align: 'end' }">
36
+ <UButton label="Create" icon="lucide:plus" trailing-icon="lucide:chevron-down" size="md" />
37
+ </UDropdownMenu>
38
+ </header>
11
39
 
12
- <PlutoDashboardWidgets />
13
- </AdminView>
14
- </main>
40
+ <PlutoDashboardWidgets />
41
+ </PlutoViewAdminPanel>
15
42
  </template>
@@ -5,6 +5,9 @@ import PlutoFieldReference from '../components/Pluto/Content/fields/PlutoFieldRe
5
5
  import PlutoFieldSelect from '../components/Pluto/Content/fields/PlutoFieldSelect.vue'
6
6
  import PlutoFieldSlug from '../components/Pluto/Content/fields/PlutoFieldSlug.vue'
7
7
  import PlutoFieldText from '../components/Pluto/Content/fields/PlutoFieldText.vue'
8
+ import PlutoDashboardContentActivity from '../components/Pluto/Dashboard/PlutoDashboardContentActivity.vue'
9
+ import PlutoDashboardContentOverview from '../components/Pluto/Dashboard/PlutoDashboardContentOverview.vue'
10
+ import PlutoDashboardQuickActions from '../components/Pluto/Dashboard/PlutoDashboardQuickActions.vue'
8
11
 
9
12
  // Registers core's own Home and Settings anchors, plus the eight built-in
10
13
  // content field widgets. `order: 0` and `order: 1000` are reserved for the
@@ -41,5 +44,36 @@ export default defineNuxtPlugin(() => {
41
44
  { id: 'select', fieldType: 'select', component: PlutoFieldSelect },
42
45
  { id: 'reference', fieldType: 'reference', component: PlutoFieldReference },
43
46
  ],
47
+ // `order: 0` puts the content overview first. It spans the full row
48
+ // because it lays out its own grid of tiles. It is disabled while no
49
+ // content type is registered, so the dashboard shows its own empty
50
+ // message instead of a blank cell.
51
+ dashboardWidgets: [
52
+ {
53
+ id: 'content-overview',
54
+ order: 0,
55
+ title: 'Content',
56
+ colSpan: 3,
57
+ component: PlutoDashboardContentOverview,
58
+ enabled: () => usePlutoContentTypes().items.value.length > 0,
59
+ },
60
+ // The activity chart and the quick actions share the next row (2 + 1
61
+ // columns). A layer widget with the default order (100) goes below.
62
+ {
63
+ id: 'content-activity',
64
+ order: 10,
65
+ title: 'Content activity',
66
+ colSpan: 2,
67
+ component: PlutoDashboardContentActivity,
68
+ enabled: () => usePlutoContentTypes().items.value.length > 0,
69
+ },
70
+ {
71
+ id: 'quick-actions',
72
+ order: 20,
73
+ title: 'Quick actions',
74
+ colSpan: 1,
75
+ component: PlutoDashboardQuickActions,
76
+ },
77
+ ],
44
78
  })
45
79
  })
@@ -0,0 +1,182 @@
1
+ // Pure helpers for the pluto-css module. They do not import `nuxt/kit`, so
2
+ // the unit tests can call them directly.
3
+ import { dirname, isAbsolute, join, relative, resolve } from 'node:path'
4
+
5
+ /** A layer's own CSS file, by convention. See the single-tailwind-build skill. */
6
+ export const LAYER_CSS_FILE = 'assets/css/pluto-layer.css'
7
+
8
+ function toPosix(path: string): string {
9
+ return path.replaceAll('\\', '/')
10
+ }
11
+
12
+ /**
13
+ * Returns the `pluto-layer.css` file of every layer that has one, base
14
+ * layer first. Nuxt lists the layers top first (the app, then each layer it
15
+ * extends), so the list is reversed: a higher layer's `@theme` then comes
16
+ * later in the build and wins.
17
+ */
18
+ export function findLayerCssFiles(layerAppDirs: string[], exists: (path: string) => boolean): string[] {
19
+ return [...layerAppDirs]
20
+ .reverse()
21
+ .map((appDir) => toPosix(join(appDir, LAYER_CSS_FILE)))
22
+ .filter((file) => exists(file))
23
+ }
24
+
25
+ /**
26
+ * Renders `#build/pluto.css`: one `@import` for each layer CSS file. Each
27
+ * path is relative to the build dir, where the file is written. A path on
28
+ * another drive (Windows) stays absolute.
29
+ */
30
+ export function renderPlutoCss(files: string[], buildDir: string): string {
31
+ const lines = files.map((file) => {
32
+ const fromBuild = toPosix(relative(buildDir, file))
33
+ const path = isAbsolute(fromBuild) ? fromBuild : fromBuild.startsWith('.') ? fromBuild : `./${fromBuild}`
34
+ return `@import "${path}";`
35
+ })
36
+ return [
37
+ '/* Generated by @plutocms/pluto (modules/pluto-css). Do not edit. */',
38
+ '/* Each Pluto layer\'s app/assets/css/pluto-layer.css, base layer first. */',
39
+ ...lines,
40
+ '',
41
+ ].join('\n')
42
+ }
43
+
44
+ /** The fallback entry, used only when the app has no Tailwind entry. */
45
+ export function renderPlutoEntryCss(): string {
46
+ return [
47
+ '/* Generated by @plutocms/pluto (modules/pluto-css). Do not edit. */',
48
+ '/* Fallback Tailwind entry: the app has no CSS entry that imports Tailwind. */',
49
+ '@import "tailwindcss";',
50
+ '@import "@nuxt/ui";',
51
+ '@import "#build/pluto.css";',
52
+ '',
53
+ ].join('\n')
54
+ }
55
+
56
+ /** Removes CSS comments. */
57
+ function stripComments(css: string): string {
58
+ return css.replace(/\/\*[\s\S]*?\*\//g, '')
59
+ }
60
+
61
+ /**
62
+ * Returns the specifier of every `@import` in a CSS text: `@import 'x'`,
63
+ * `@import "x" source(none)`, `@import url(x)`, and `@import url('x')`.
64
+ */
65
+ export function parseCssImports(css: string): string[] {
66
+ const imports: string[] = []
67
+ // Two simple steps keep both patterns linear: take the body of each
68
+ // `@import ...;`, then read the specifier at its start.
69
+ for (const statement of stripComments(css).matchAll(/@import\s([^;]*);/g)) {
70
+ const body = statement[1]!.trim().replace(/^url\(\s*/, '')
71
+ const specifier = /^(['"])(.*?)\1/.exec(body)?.[2] ?? /^[^\s)]+/.exec(body)?.[0]
72
+ if (specifier) {
73
+ imports.push(specifier)
74
+ }
75
+ }
76
+ return imports
77
+ }
78
+
79
+ export interface CssEntryInfo {
80
+ /** The CSS entry, as resolved to a file. */
81
+ file: string
82
+ /** It imports Tailwind (`@import 'tailwindcss'`), so it is a Tailwind build. */
83
+ tailwind: boolean
84
+ /** It imports `@nuxt/ui`. */
85
+ nuxtUi: boolean
86
+ /** It imports `#build/pluto.css`. */
87
+ plutoCss: boolean
88
+ }
89
+
90
+ /**
91
+ * Follows the local `@import`s of a CSS entry, to `maxDepth`, and reports
92
+ * what it imports. `resolveImport` turns a specifier into a file path, or
93
+ * returns `undefined` for a package import (such as `tailwindcss`), which is
94
+ * not followed.
95
+ */
96
+ export function analyzeCssEntry(
97
+ file: string,
98
+ read: (path: string) => string | undefined,
99
+ resolveImport: (specifier: string, fromFile: string) => string | undefined,
100
+ maxDepth = 3
101
+ ): CssEntryInfo {
102
+ const info: CssEntryInfo = { file, tailwind: false, nuxtUi: false, plutoCss: false }
103
+ const seen = new Set<string>()
104
+
105
+ function visit(path: string, depth: number) {
106
+ if (seen.has(path) || depth > maxDepth) {
107
+ return
108
+ }
109
+ seen.add(path)
110
+ const css = read(path)
111
+ if (css === undefined) {
112
+ return
113
+ }
114
+ for (const specifier of parseCssImports(css)) {
115
+ if (specifier === 'tailwindcss') {
116
+ info.tailwind = true
117
+ } else if (specifier === '@nuxt/ui') {
118
+ info.nuxtUi = true
119
+ } else if (/(?:^|\/)pluto\.css$/.test(specifier) && specifier.includes('#build')) {
120
+ info.plutoCss = true
121
+ } else {
122
+ const next = resolveImport(specifier, path)
123
+ if (next) {
124
+ visit(next, depth + 1)
125
+ }
126
+ }
127
+ }
128
+ }
129
+
130
+ visit(file, 0)
131
+ return info
132
+ }
133
+
134
+ /** Resolves a relative `@import` (`./x.css`, `../x.css`) against its file. */
135
+ export function resolveRelativeImport(specifier: string, fromFile: string): string | undefined {
136
+ return specifier.startsWith('./') || specifier.startsWith('../') ? toPosix(resolve(dirname(fromFile), specifier)) : undefined
137
+ }
138
+
139
+ export interface CssSetupReport {
140
+ /** Inject the fallback entry: no CSS entry imports Tailwind. */
141
+ injectFallback: boolean
142
+ warnings: string[]
143
+ }
144
+
145
+ /**
146
+ * Checks the app's CSS setup against the single-build rules and returns
147
+ * what to do. `layerRoots` are the root dirs of every layer except the app.
148
+ */
149
+ export function checkCssSetup(entries: CssEntryInfo[], layerRoots: string[]): CssSetupReport {
150
+ const warnings: string[] = []
151
+ const tailwindEntries = entries.filter((entry) => entry.tailwind)
152
+ const snippet = ' @import \'tailwindcss\';\n @import \'@nuxt/ui\';\n @import \'#build/pluto.css\';'
153
+
154
+ if (tailwindEntries.length === 0) {
155
+ return { injectFallback: true, warnings }
156
+ }
157
+
158
+ for (const entry of tailwindEntries) {
159
+ const layerRoot = layerRoots.find((root) => toPosix(entry.file).startsWith(toPosix(root)))
160
+ if (layerRoot) {
161
+ warnings.push(
162
+ `Layer ${layerRoot} registers its own Tailwind build (${entry.file}). Remove it from the layer's \`css\` option, and move its tokens to app/${LAYER_CSS_FILE}.`
163
+ )
164
+ }
165
+ }
166
+
167
+ if (tailwindEntries.length > 1) {
168
+ warnings.push(
169
+ `Found ${tailwindEntries.length} Tailwind builds. Keep one, in the app. Builds:\n${tailwindEntries.map((entry) => ` - ${entry.file}`).join('\n')}`
170
+ )
171
+ }
172
+
173
+ if (!tailwindEntries.some((entry) => entry.plutoCss)) {
174
+ warnings.push(`No CSS entry imports #build/pluto.css. The admin theme and layer tokens are missing. Start the app's CSS entry with:\n${snippet}`)
175
+ }
176
+
177
+ if (!tailwindEntries.some((entry) => entry.nuxtUi)) {
178
+ warnings.push(`No CSS entry imports @nuxt/ui. Nuxt UI styles, the \`light:\` variant, and the layer sources are missing. Start the app's CSS entry with:\n${snippet}`)
179
+ }
180
+
181
+ return { injectFallback: false, warnings }
182
+ }
@@ -0,0 +1,86 @@
1
+ import { existsSync, readFileSync } from 'node:fs'
2
+ import { addTemplate, defineNuxtModule, getLayerDirectories, resolvePath, useLogger } from '@nuxt/kit'
3
+ import {
4
+ analyzeCssEntry,
5
+ checkCssSetup,
6
+ findLayerCssFiles,
7
+ renderPlutoCss,
8
+ renderPlutoEntryCss,
9
+ resolveRelativeImport,
10
+ } from './css-entries'
11
+
12
+ export interface PlutoCssOptions {
13
+ /** Warn at build time about a CSS setup that breaks the single build. Default true. */
14
+ check?: boolean
15
+ }
16
+
17
+ /**
18
+ * One Tailwind build per app. The app's CSS entry owns the build:
19
+ *
20
+ * @import 'tailwindcss';
21
+ * @import '@nuxt/ui';
22
+ * @import '#build/pluto.css';
23
+ *
24
+ * - `#build/pluto.css` imports each layer's app/assets/css/pluto-layer.css
25
+ * (tokens, the admin theme). Nuxt UI already adds `@source` for every
26
+ * layer's app/ dir, so this file adds no sources.
27
+ * - An app with no Tailwind entry (a layer repo run on its own, or an app
28
+ * with no CSS) gets the fallback entry `#build/pluto-entry.css`.
29
+ * - The module warns about a layer that registers its own Tailwind build,
30
+ * more than one build, or a missing import.
31
+ *
32
+ * See the single-tailwind-build skill.
33
+ */
34
+ export default defineNuxtModule<PlutoCssOptions>({
35
+ meta: { name: 'pluto-css', configKey: 'plutoCss' },
36
+ defaults: { check: true },
37
+ async setup(options, nuxt) {
38
+ const logger = useLogger('pluto-css')
39
+ const layers = getLayerDirectories(nuxt)
40
+ const layerCssFiles = findLayerCssFiles(layers.map((layer) => layer.app), existsSync)
41
+
42
+ addTemplate({
43
+ filename: 'pluto.css',
44
+ write: true,
45
+ getContents: () => renderPlutoCss(layerCssFiles, nuxt.options.buildDir),
46
+ })
47
+
48
+ addTemplate({
49
+ filename: 'pluto-entry.css',
50
+ write: true,
51
+ getContents: renderPlutoEntryCss,
52
+ })
53
+
54
+ const entries = []
55
+ for (const entry of nuxt.options.css) {
56
+ const file = typeof entry === 'string' ? await resolvePath(entry).catch(() => undefined) : undefined
57
+ if (!file || !file.endsWith('.css') || !existsSync(file)) {
58
+ continue
59
+ }
60
+ entries.push(
61
+ analyzeCssEntry(
62
+ file,
63
+ (path) => (existsSync(path) ? readFileSync(path, 'utf8') : undefined),
64
+ resolveRelativeImport
65
+ )
66
+ )
67
+ }
68
+
69
+ const appRoot = nuxt.options.rootDir.replaceAll('\\', '/').replace(/\/?$/, '/')
70
+ const layerRoots = layers.map((layer) => layer.root.replaceAll('\\', '/')).filter((root) => root !== appRoot)
71
+ const report = checkCssSetup(entries, layerRoots)
72
+
73
+ if (report.injectFallback) {
74
+ nuxt.options.css.push('#build/pluto-entry.css')
75
+ if (!nuxt.options._prepare) {
76
+ logger.info('No CSS entry imports Tailwind. Using the Pluto fallback entry (#build/pluto-entry.css).')
77
+ }
78
+ }
79
+
80
+ if (options.check && !nuxt.options._prepare) {
81
+ for (const warning of report.warnings) {
82
+ logger.warn(warning)
83
+ }
84
+ }
85
+ },
86
+ })
package/nuxt.config.ts CHANGED
@@ -40,7 +40,9 @@ export default defineNuxtConfig({
40
40
  },
41
41
  },
42
42
 
43
- css: ['#layers/pluto/app/assets/css/tailwind.css'],
43
+ // No `css` entry: the app's CSS entry owns the one Tailwind build, and
44
+ // modules/pluto-css adds this layer's pluto-layer.css to it through
45
+ // #build/pluto.css. See the single-tailwind-build skill.
44
46
 
45
47
  runtimeConfig: {
46
48
  public: {
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@plutocms/pluto",
3
3
  "type": "module",
4
- "version": "0.10.0",
4
+ "version": "0.12.0",
5
5
  "description": "The layerable CMS",
6
6
  "author": "Victor Ribeiro",
7
7
  "license": "MIT",
@@ -42,31 +42,32 @@
42
42
  "vue-demi"
43
43
  ],
44
44
  "dependencies": {
45
- "@iconify-json/lucide": "^1.2.116",
46
- "@iconify-json/simple-icons": "^1.2.89",
47
- "@internationalized/date": "^3.12.2",
48
- "@nuxt/eslint": "^1.16.0",
49
- "@nuxt/ui": "^4.9.0",
45
+ "@iconify-json/lucide": "^1.2.144",
46
+ "@iconify-json/simple-icons": "^1.2.100",
47
+ "@internationalized/date": "^3.12.4",
48
+ "@nuxt/eslint": "^1.17.0",
49
+ "@nuxt/ui": "^4.12.0",
50
50
  "@plutocms/utils": "^0.2.1",
51
- "@vueuse/nuxt": "^14.3.0",
52
- "reka-ui": "^2.10.1",
53
- "tailwind-merge": "^3.6.0"
51
+ "@tanstack/charts": "^1.1.0",
52
+ "@vueuse/nuxt": "^14.4.0",
53
+ "reka-ui": "^2.11.0",
54
+ "tailwind-merge": "^3.7.0"
54
55
  },
55
56
  "devDependencies": {
56
- "@antfu/eslint-config": "^9.1.0",
57
- "@tailwindcss/vite": "^4.2.1",
58
- "@types/bun": "^1.3.14",
59
- "eslint": "^10.6.0",
60
- "mysql2": "^3.22.6",
61
- "nuxt": "^4.3.1",
62
- "prettier": "^3.9.4",
63
- "prettier-plugin-tailwindcss": "^0.8.0",
64
- "tailwindcss": "^4.3.2",
57
+ "@antfu/eslint-config": "^9.5.1",
58
+ "@tailwindcss/vite": "^4.3.3",
59
+ "@types/bun": "^1.4.3",
60
+ "eslint": "^10.12.0",
61
+ "mysql2": "^3.24.6",
62
+ "nuxt": "^4.6.1",
63
+ "prettier": "^3.9.10",
64
+ "prettier-plugin-tailwindcss": "^0.8.1",
65
+ "tailwindcss": "^4.3.3",
65
66
  "typescript": "6.0.3",
66
- "vitest": "^5.0.0",
67
- "vue": "^3.5.39",
68
- "vue-router": "^5.1.0",
69
- "vue-tsc": "^3.3.7"
67
+ "vitest": "^5.0.5",
68
+ "vue": "^3.5.44",
69
+ "vue-router": "^5.4.0",
70
+ "vue-tsc": "^3.3.13"
70
71
  },
71
72
  "overrides": {
72
73
  "eslint-flat-config-utils": "^3.2.0"
@@ -0,0 +1,96 @@
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
+ 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': n(900), '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' }
17
+ const tokens = (o) => Object.entries(o).map(([k, val]) => ` --ui-${k}: ${val};`).join('\n')
18
+ // Validated with the dataviz palette checks on white (light) and zinc-900
19
+ // (dark). Re-run the checks before you change a slot.
20
+ const chartLight = ['amber-600', 'teal-600', 'rose-500', 'sky-600', 'lime-700']
21
+ const chartDark = ['amber-600', 'teal-600', 'rose-500', 'sky-600', 'lime-600']
22
+ const chart = (list) => ` --ui-chart-1: var(--ui-primary);\n${list.map((c, i) => ` --ui-chart-${i + 2}: ${v(c)}; /* ${c} */`).join('\n')}`
23
+
24
+ const css = `/*
25
+ * The Pluto admin theme.
26
+ *
27
+ * Two selectors carry it:
28
+ * - \`:root[data-pluto-admin]\`: usePlutoAdminTheme() sets this attribute on
29
+ * <html> while an admin layout is mounted, so the theme also covers
30
+ * overlays (modals, slideovers, menus, toasts, the command palette) that
31
+ * render at the end of <body>.
32
+ * - \`[data-pluto-admin-scope]\`: for admin chrome that also shows on the
33
+ * public site, such as the admin navbar for a logged-in admin.
34
+ *
35
+ * Every value is a literal. The consumer app styles its public site through
36
+ * its own app.config colors and @theme. Both reach the admin unless the admin
37
+ * pins its own values: app.config colors set Nuxt UI's --ui-color-* scales on
38
+ * :root, and @theme can redefine a Tailwind color such as --color-red-400.
39
+ * These selectors are more specific than Nuxt UI's :root and .dark rules.
40
+ *
41
+ * The semantic tokens (--ui-primary, --ui-bg, --ui-text, ...) are declared
42
+ * again here, not only the color scales. A custom property inherits as a
43
+ * resolved value, so on a scoped element the :root tokens would still hold
44
+ * the app's colors.
45
+ *
46
+ * Chart colors are a validated categorical palette: lightness band, chroma
47
+ * floor, colorblind separation, and 3:1 contrast pass on white (light) and
48
+ * on zinc-900 (dark). This file is generated; keep the values in sync with
49
+ * scripts/generate-admin-theme.cjs: run it from the repo root, never edit this
50
+ * file by hand.
51
+ */
52
+ :root[data-pluto-admin],
53
+ [data-pluto-admin-scope] {
54
+ ${scales}
55
+
56
+ ${semantic(500)}
57
+
58
+ ${tokens(neutralLight)}
59
+
60
+ ${chart(chartLight)}
61
+
62
+ /* Surfaces */
63
+ --pluto-admin-canvas: #fff;
64
+ --pluto-admin-sidebar: ${v('zinc-50')};
65
+ --pluto-admin-navbar: #fff;
66
+
67
+ --ui-radius: 0.375rem;
68
+ --font-sans: "Outfit", ui-sans-serif, system-ui, sans-serif;
69
+ }
70
+
71
+ :root[data-pluto-admin].dark,
72
+ .dark [data-pluto-admin-scope] {
73
+ ${semantic(400)}
74
+
75
+ ${tokens(neutralDark)}
76
+
77
+ ${chart(chartDark)}
78
+
79
+ /* Cards (--ui-bg, zinc-900) sit one step above the canvas (zinc-950). */
80
+ --pluto-admin-canvas: ${v('zinc-950')};
81
+ --pluto-admin-sidebar: ${v('zinc-950')};
82
+ --pluto-admin-navbar: ${v('zinc-950')};
83
+ }
84
+
85
+ :root[data-pluto-admin] body {
86
+ background-color: var(--pluto-admin-canvas);
87
+ }
88
+
89
+ :root[data-pluto-admin] body,
90
+ [data-pluto-admin-scope] {
91
+ font-family: var(--font-sans);
92
+ color: var(--ui-text);
93
+ }
94
+ `
95
+ fs.writeFileSync('app/assets/css/admin-theme.css', css)
96
+ 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
+ }