@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.
- package/CHANGELOG.md +27 -0
- package/FEATURES.md +17 -0
- package/README.md +30 -0
- package/app/assets/css/admin-theme.css +207 -0
- package/app/assets/css/pluto-layer.css +40 -0
- package/app/components/ColorModeButton.vue +4 -2
- package/app/components/Pluto/Content/PlutoContentForm.vue +325 -50
- package/app/components/Pluto/Content/PlutoContentList.vue +192 -108
- 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 +6 -1
- package/app/components/Pluto/View/PlutoViewAdminPanel.vue +119 -0
- package/app/components/Pluto/View/PlutoViewAside.vue +79 -0
- package/app/components/Pluto/View/PlutoViewToolbar.vue +64 -0
- package/app/composables/pluto-admin-theme.ts +14 -0
- package/app/composables/pluto-command-palette.ts +59 -0
- package/app/composables/pluto-content-actions.ts +17 -0
- package/app/composables/pluto-content-activity.ts +37 -0
- package/app/composables/pluto-content-stats.ts +78 -0
- package/app/composables/pluto-extension.ts +1 -0
- package/app/composables/pluto-registry.ts +1 -0
- package/app/composables/pluto-view.ts +9 -0
- package/app/layouts/admin.vue +59 -62
- 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 +23 -22
- package/scripts/generate-admin-theme.cjs +96 -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 +37 -0
- package/shared/types/registry.ts +30 -0
- package/shared/utils/content-map.ts +15 -0
- package/app/assets/css/tailwind.css +0 -11
package/app/pages/admin/home.vue
CHANGED
|
@@ -1,15 +1,42 @@
|
|
|
1
1
|
<script setup lang="ts">
|
|
2
2
|
useHead({
|
|
3
|
-
title:
|
|
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
|
-
<
|
|
9
|
-
<
|
|
10
|
-
<
|
|
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
|
-
|
|
13
|
-
|
|
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:
|
|
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.
|
|
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.
|
|
46
|
-
"@iconify-json/simple-icons": "^1.2.
|
|
47
|
-
"@internationalized/date": "^3.12.
|
|
48
|
-
"@nuxt/eslint": "^1.
|
|
49
|
-
"@nuxt/ui": "^4.
|
|
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
|
-
"@
|
|
52
|
-
"
|
|
53
|
-
"
|
|
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
|
|
57
|
-
"@tailwindcss/vite": "^4.
|
|
58
|
-
"@types/bun": "^1.3
|
|
59
|
-
"eslint": "^10.
|
|
60
|
-
"mysql2": "^3.
|
|
61
|
-
"nuxt": "^4.
|
|
62
|
-
"prettier": "^3.9.
|
|
63
|
-
"prettier-plugin-tailwindcss": "^0.8.
|
|
64
|
-
"tailwindcss": "^4.3.
|
|
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.
|
|
67
|
-
"vue": "^3.5.
|
|
68
|
-
"vue-router": "^5.
|
|
69
|
-
"vue-tsc": "^3.3.
|
|
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
|
+
}
|