@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/CHANGELOG.md CHANGED
@@ -1,6 +1,42 @@
1
1
  # Changelog
2
2
 
3
3
 
4
+ ## [0.13.0](https://github.com/plutocms/pluto/compare/v0.12.0...v0.13.0) (2026-10-18)
5
+
6
+
7
+ ### Features
8
+
9
+ * **admin:** mount the aside once and keep it mounted on mobile ([#158](https://github.com/plutocms/pluto/issues/158)) ([dcca464](https://github.com/plutocms/pluto/commit/dcca464ead4ec5f46b2c26c0eea0f94260553ac9))
10
+ * **content:** page and virtualize long content lists ([#154](https://github.com/plutocms/pluto/issues/154)) ([b8d35b0](https://github.com/plutocms/pluto/commit/b8d35b05230169713a3e7cacbdfd95f52c72936a))
11
+
12
+
13
+ ### Bug Fixes
14
+
15
+ * **admin:** darken the navbar and sidebar in dark mode ([#159](https://github.com/plutocms/pluto/issues/159)) ([011fd2f](https://github.com/plutocms/pluto/commit/011fd2f616daf961486e99f69a0e8f0f0db5e51f))
16
+ * **admin:** make the navbar near black in light mode too ([#160](https://github.com/plutocms/pluto/issues/160)) ([f39656a](https://github.com/plutocms/pluto/commit/f39656a5b52081c05e8128b4d0828970b96a14e5))
17
+ * **admin:** use a dim dark gray instead of pitch black in dark mode ([#156](https://github.com/plutocms/pluto/issues/156)) ([500c900](https://github.com/plutocms/pluto/commit/500c900783980d760d6fade406dfc15334a61a4e))
18
+ * **admin:** use the theme text color in the modal ([#157](https://github.com/plutocms/pluto/issues/157)) ([e73c613](https://github.com/plutocms/pluto/commit/e73c613eead017fabbae66ef6f6a7624ec5a5a04))
19
+
20
+ ## [0.12.0](https://github.com/plutocms/pluto/compare/v0.11.0...v0.12.0) (2026-10-17)
21
+
22
+
23
+ ### ⚠ BREAKING CHANGES
24
+
25
+ * **admin:** An app with its own Tailwind entry must import @nuxt/ui and #build/pluto.css after tailwindcss:
26
+
27
+ ### Features
28
+
29
+ * **admin:** add a Cmd/Ctrl+K command palette ([#150](https://github.com/plutocms/pluto/issues/150)) ([540a4fb](https://github.com/plutocms/pluto/commit/540a4fb5f6fb5ed8b3d32fc05efe70f862222ff8))
30
+ * **admin:** add a content overview widget to the dashboard ([#152](https://github.com/plutocms/pluto/issues/152)) ([b925f83](https://github.com/plutocms/pluto/commit/b925f8383d64b64b47738bfad2617a8ecbeb3172))
31
+ * **admin:** make the view aside resizable ([#147](https://github.com/plutocms/pluto/issues/147)) ([f400cc1](https://github.com/plutocms/pluto/commit/f400cc1fa54fbcab7b4bc30c18d8f3cddad026d9))
32
+ * **admin:** redesign the dashboard, isolate the admin theme, and use one Tailwind build ([#153](https://github.com/plutocms/pluto/issues/153)) ([e1461b9](https://github.com/plutocms/pluto/commit/e1461b9a58267e71b42e2bceadc7c1f6d471861e))
33
+ * **content:** add loading, empty, and error states to the content list ([#151](https://github.com/plutocms/pluto/issues/151)) ([7d26540](https://github.com/plutocms/pluto/commit/7d2654027c4329b8fc08843a672b43d73a4e7e55))
34
+
35
+
36
+ ### Bug Fixes
37
+
38
+ * **admin:** start the dashboard group below the navbar again ([#149](https://github.com/plutocms/pluto/issues/149)) ([32d4970](https://github.com/plutocms/pluto/commit/32d497013c487b317d376376e645a502f9054a35))
39
+
4
40
  ## [0.11.0](https://github.com/plutocms/pluto/compare/v0.10.0...v0.11.0) (2026-10-16)
5
41
 
6
42
 
package/FEATURES.md CHANGED
@@ -23,3 +23,16 @@ policies shared by every project here.
23
23
  `PlutoViewAside`), the generic content form's publish workflow, and the `contentActions`
24
24
  registry bucket that lets a layer add its own buttons to that form.
25
25
  @.claude/skills/admin-view-chrome/SKILL.md
26
+ - Command palette: Cmd/Ctrl+K and a sidebar search button open a `UDashboardSearch` with
27
+ "New <type>" commands for writable content types, every admin nav destination, and theme
28
+ commands. @.claude/skills/command-palette/SKILL.md
29
+ - Dashboard: the admin home with a date and title header, a create menu, and core widgets:
30
+ content tiles (total, +N this week, published/draft meter), a `UChart` weekly activity
31
+ chart backed by the optional adapter `countCreated`, and quick actions.
32
+ @.claude/skills/dashboard/SKILL.md
33
+ - Admin theme: the admin keeps its own palette, chart colors, surfaces, and font under
34
+ `data-pluto-admin`, so the consumer app's site theme does not reach it.
35
+ @.claude/skills/admin-theme/SKILL.md
36
+ - Single Tailwind build: the app owns the only Tailwind build. The pluto-css module generates
37
+ `#build/pluto.css` from each layer's `pluto-layer.css`, adds a fallback entry, and warns
38
+ about a wrong CSS setup. @.claude/skills/single-tailwind-build/SKILL.md
package/README.md CHANGED
@@ -135,6 +135,36 @@ export default defineNuxtConfig({
135
135
  })
136
136
  ```
137
137
 
138
+ ### Styles: one Tailwind build
139
+
140
+ Your app owns the only Tailwind build. Pluto layers do not register their own. Start your
141
+ CSS entry with these three imports, then add your own theme after them:
142
+
143
+ ```css
144
+ /* app/assets/css/main.css */
145
+ @import 'tailwindcss';
146
+ @import '@nuxt/ui';
147
+ @import '#build/pluto.css';
148
+
149
+ @theme {
150
+ /* your site's tokens */
151
+ }
152
+ ```
153
+
154
+ ```ts
155
+ // nuxt.config.ts
156
+ export default defineNuxtConfig({
157
+ css: ['~/assets/css/main.css'],
158
+ })
159
+ ```
160
+
161
+ `#build/pluto.css` brings in the CSS of every Pluto layer, including the admin theme. Nuxt UI
162
+ already scans the source of every layer. If your app has no Tailwind entry, Pluto uses a
163
+ fallback entry. If the setup is wrong, the build prints a `pluto-css` warning with the fix.
164
+
165
+ The admin keeps its own palette and font. Your site's `app.config` colors and `@theme`
166
+ tokens do not change the admin.
167
+
138
168
  ## Dashboard & headless usage
139
169
 
140
170
  Although Pluto is built on Nuxt, it is not limited to Nuxt-only consumption.
@@ -0,0 +1,216 @@
1
+ /*
2
+ * The Pluto admin theme.
3
+ *
4
+ * Two selectors carry it:
5
+ * - `:root[data-pluto-admin]`: usePlutoAdminTheme() sets this attribute on
6
+ * <html> while an admin layout is mounted, so the theme also covers
7
+ * overlays (modals, slideovers, menus, toasts, the command palette) that
8
+ * render at the end of <body>.
9
+ * - `[data-pluto-admin-scope]`: for admin chrome that also shows on the
10
+ * public site, such as the admin navbar for a logged-in admin.
11
+ *
12
+ * `[data-pluto-admin-dark]` takes the dark tokens in both color modes. The
13
+ * admin navbar is near black in light mode too, so its text and buttons need
14
+ * the dark values.
15
+ *
16
+ * Every value is a literal. The consumer app styles its public site through
17
+ * its own app.config colors and @theme. Both reach the admin unless the admin
18
+ * pins its own values: app.config colors set Nuxt UI's --ui-color-* scales on
19
+ * :root, and @theme can redefine a Tailwind color such as --color-red-400.
20
+ * These selectors are more specific than Nuxt UI's :root and .dark rules.
21
+ *
22
+ * The semantic tokens (--ui-primary, --ui-bg, --ui-text, ...) are declared
23
+ * again here, not only the color scales. A custom property inherits as a
24
+ * resolved value, so on a scoped element the :root tokens would still hold
25
+ * the app's colors.
26
+ *
27
+ * Chart colors are a validated categorical palette: lightness band, chroma
28
+ * floor, colorblind separation, and 3:1 contrast pass on white (light) and
29
+ * on zinc-900 (dark). This file is generated; keep the values in sync with
30
+ * scripts/generate-admin-theme.cjs: run it from the repo root, never edit this
31
+ * file by hand.
32
+ */
33
+ :root[data-pluto-admin],
34
+ [data-pluto-admin-scope] {
35
+ /* primary: indigo */
36
+ --ui-color-primary-50: oklch(96.2% 0.018 272.314);
37
+ --ui-color-primary-100: oklch(93% 0.034 272.788);
38
+ --ui-color-primary-200: oklch(87% 0.065 274.039);
39
+ --ui-color-primary-300: oklch(78.5% 0.115 274.713);
40
+ --ui-color-primary-400: oklch(67.3% 0.182 276.935);
41
+ --ui-color-primary-500: oklch(58.5% 0.233 277.117);
42
+ --ui-color-primary-600: oklch(51.1% 0.262 276.966);
43
+ --ui-color-primary-700: oklch(45.7% 0.24 277.023);
44
+ --ui-color-primary-800: oklch(39.8% 0.195 277.366);
45
+ --ui-color-primary-900: oklch(35.9% 0.144 278.697);
46
+ --ui-color-primary-950: oklch(25.7% 0.09 281.288);
47
+
48
+ /* secondary: violet */
49
+ --ui-color-secondary-50: oklch(96.9% 0.016 293.756);
50
+ --ui-color-secondary-100: oklch(94.3% 0.029 294.588);
51
+ --ui-color-secondary-200: oklch(89.4% 0.057 293.283);
52
+ --ui-color-secondary-300: oklch(81.1% 0.111 293.571);
53
+ --ui-color-secondary-400: oklch(70.2% 0.183 293.541);
54
+ --ui-color-secondary-500: oklch(60.6% 0.25 292.717);
55
+ --ui-color-secondary-600: oklch(54.1% 0.281 293.009);
56
+ --ui-color-secondary-700: oklch(49.1% 0.27 292.581);
57
+ --ui-color-secondary-800: oklch(43.2% 0.232 292.759);
58
+ --ui-color-secondary-900: oklch(38% 0.189 293.745);
59
+ --ui-color-secondary-950: oklch(28.3% 0.141 291.089);
60
+
61
+ /* success: green */
62
+ --ui-color-success-50: oklch(98.2% 0.018 155.826);
63
+ --ui-color-success-100: oklch(96.2% 0.044 156.743);
64
+ --ui-color-success-200: oklch(92.5% 0.084 155.995);
65
+ --ui-color-success-300: oklch(87.1% 0.15 154.449);
66
+ --ui-color-success-400: oklch(79.2% 0.209 151.711);
67
+ --ui-color-success-500: oklch(72.3% 0.219 149.579);
68
+ --ui-color-success-600: oklch(62.7% 0.194 149.214);
69
+ --ui-color-success-700: oklch(52.7% 0.154 150.069);
70
+ --ui-color-success-800: oklch(44.8% 0.119 151.328);
71
+ --ui-color-success-900: oklch(39.3% 0.095 152.535);
72
+ --ui-color-success-950: oklch(26.6% 0.065 152.934);
73
+
74
+ /* info: blue */
75
+ --ui-color-info-50: oklch(97% 0.014 254.604);
76
+ --ui-color-info-100: oklch(93.2% 0.032 255.585);
77
+ --ui-color-info-200: oklch(88.2% 0.059 254.128);
78
+ --ui-color-info-300: oklch(80.9% 0.105 251.813);
79
+ --ui-color-info-400: oklch(70.7% 0.165 254.624);
80
+ --ui-color-info-500: oklch(62.3% 0.214 259.815);
81
+ --ui-color-info-600: oklch(54.6% 0.245 262.881);
82
+ --ui-color-info-700: oklch(48.8% 0.243 264.376);
83
+ --ui-color-info-800: oklch(42.4% 0.199 265.638);
84
+ --ui-color-info-900: oklch(37.9% 0.146 265.522);
85
+ --ui-color-info-950: oklch(28.2% 0.091 267.935);
86
+
87
+ /* warning: amber */
88
+ --ui-color-warning-50: oklch(98.7% 0.022 95.277);
89
+ --ui-color-warning-100: oklch(96.2% 0.059 95.617);
90
+ --ui-color-warning-200: oklch(92.4% 0.12 95.746);
91
+ --ui-color-warning-300: oklch(87.9% 0.169 91.605);
92
+ --ui-color-warning-400: oklch(82.8% 0.189 84.429);
93
+ --ui-color-warning-500: oklch(76.9% 0.188 70.08);
94
+ --ui-color-warning-600: oklch(66.6% 0.179 58.318);
95
+ --ui-color-warning-700: oklch(55.5% 0.163 48.998);
96
+ --ui-color-warning-800: oklch(47.3% 0.137 46.201);
97
+ --ui-color-warning-900: oklch(41.4% 0.112 45.904);
98
+ --ui-color-warning-950: oklch(27.9% 0.077 45.635);
99
+
100
+ /* error: red */
101
+ --ui-color-error-50: oklch(97.1% 0.013 17.38);
102
+ --ui-color-error-100: oklch(93.6% 0.032 17.717);
103
+ --ui-color-error-200: oklch(88.5% 0.062 18.334);
104
+ --ui-color-error-300: oklch(80.8% 0.114 19.571);
105
+ --ui-color-error-400: oklch(70.4% 0.191 22.216);
106
+ --ui-color-error-500: oklch(63.7% 0.237 25.331);
107
+ --ui-color-error-600: oklch(57.7% 0.245 27.325);
108
+ --ui-color-error-700: oklch(50.5% 0.213 27.518);
109
+ --ui-color-error-800: oklch(44.4% 0.177 26.899);
110
+ --ui-color-error-900: oklch(39.6% 0.141 25.723);
111
+ --ui-color-error-950: oklch(25.8% 0.092 26.042);
112
+
113
+ /* neutral: zinc */
114
+ --ui-color-neutral-50: oklch(98.5% 0 none);
115
+ --ui-color-neutral-100: oklch(96.7% 0.001 286.375);
116
+ --ui-color-neutral-200: oklch(92% 0.004 286.32);
117
+ --ui-color-neutral-300: oklch(87.1% 0.006 286.286);
118
+ --ui-color-neutral-400: oklch(70.5% 0.015 286.067);
119
+ --ui-color-neutral-500: oklch(55.2% 0.016 285.938);
120
+ --ui-color-neutral-600: oklch(44.2% 0.017 285.786);
121
+ --ui-color-neutral-700: oklch(37% 0.013 285.805);
122
+ --ui-color-neutral-800: oklch(27.4% 0.006 286.033);
123
+ --ui-color-neutral-900: oklch(21% 0.006 285.885);
124
+ --ui-color-neutral-950: oklch(14.1% 0.005 285.823);
125
+
126
+ --ui-primary: var(--ui-color-primary-500);
127
+ --ui-secondary: var(--ui-color-secondary-500);
128
+ --ui-success: var(--ui-color-success-500);
129
+ --ui-info: var(--ui-color-info-500);
130
+ --ui-warning: var(--ui-color-warning-500);
131
+ --ui-error: var(--ui-color-error-500);
132
+
133
+ --ui-text-dimmed: var(--ui-color-neutral-400);
134
+ --ui-text-muted: var(--ui-color-neutral-500);
135
+ --ui-text-toned: var(--ui-color-neutral-600);
136
+ --ui-text: var(--ui-color-neutral-700);
137
+ --ui-text-highlighted: var(--ui-color-neutral-900);
138
+ --ui-text-inverted: #fff;
139
+ --ui-bg: #fff;
140
+ --ui-bg-muted: var(--ui-color-neutral-50);
141
+ --ui-bg-elevated: var(--ui-color-neutral-100);
142
+ --ui-bg-accented: var(--ui-color-neutral-200);
143
+ --ui-bg-inverted: var(--ui-color-neutral-900);
144
+ --ui-border: var(--ui-color-neutral-200);
145
+ --ui-border-muted: var(--ui-color-neutral-200);
146
+ --ui-border-accented: var(--ui-color-neutral-300);
147
+ --ui-border-inverted: var(--ui-color-neutral-900);
148
+
149
+ --ui-chart-1: var(--ui-primary);
150
+ --ui-chart-2: oklch(66.6% 0.179 58.318); /* amber-600 */
151
+ --ui-chart-3: oklch(60% 0.118 184.704); /* teal-600 */
152
+ --ui-chart-4: oklch(64.5% 0.246 16.439); /* rose-500 */
153
+ --ui-chart-5: oklch(58.8% 0.158 241.966); /* sky-600 */
154
+ --ui-chart-6: oklch(53.2% 0.157 131.589); /* lime-700 */
155
+
156
+ /* Surfaces. The navbar is near black in both modes. */
157
+ --pluto-admin-canvas: #fff;
158
+ --pluto-admin-sidebar: oklch(98.5% 0 none);
159
+ --pluto-admin-navbar: oklch(14.1% 0.005 285.823);
160
+
161
+ --ui-radius: 0.375rem;
162
+ --font-sans: "Outfit", ui-sans-serif, system-ui, sans-serif;
163
+ }
164
+
165
+ :root[data-pluto-admin].dark,
166
+ .dark [data-pluto-admin-scope],
167
+ [data-pluto-admin-dark] {
168
+ --ui-primary: var(--ui-color-primary-400);
169
+ --ui-secondary: var(--ui-color-secondary-400);
170
+ --ui-success: var(--ui-color-success-400);
171
+ --ui-info: var(--ui-color-info-400);
172
+ --ui-warning: var(--ui-color-warning-400);
173
+ --ui-error: var(--ui-color-error-400);
174
+
175
+ --ui-text-dimmed: var(--ui-color-neutral-500);
176
+ --ui-text-muted: var(--ui-color-neutral-400);
177
+ --ui-text-toned: var(--ui-color-neutral-300);
178
+ --ui-text: var(--ui-color-neutral-200);
179
+ --ui-text-highlighted: #fff;
180
+ --ui-text-inverted: var(--ui-color-neutral-900);
181
+ --ui-bg: oklch(24% 0.006 286);
182
+ --ui-bg-muted: var(--ui-color-neutral-800);
183
+ --ui-bg-elevated: var(--ui-color-neutral-800);
184
+ --ui-bg-accented: var(--ui-color-neutral-700);
185
+ --ui-bg-inverted: #fff;
186
+ --ui-border: var(--ui-color-neutral-800);
187
+ --ui-border-muted: var(--ui-color-neutral-700);
188
+ --ui-border-accented: var(--ui-color-neutral-700);
189
+ --ui-border-inverted: #fff;
190
+
191
+ --ui-chart-1: var(--ui-primary);
192
+ --ui-chart-2: oklch(66.6% 0.179 58.318); /* amber-600 */
193
+ --ui-chart-3: oklch(60% 0.118 184.704); /* teal-600 */
194
+ --ui-chart-4: oklch(64.5% 0.246 16.439); /* rose-500 */
195
+ --ui-chart-5: oklch(58.8% 0.158 241.966); /* sky-600 */
196
+ --ui-chart-6: oklch(64.8% 0.2 131.684); /* lime-600 */
197
+
198
+ /*
199
+ * Four steps, darkest at the top: a near-black navbar, a darker sidebar,
200
+ * a dim dark gray canvas (not pitch black), and cards (--ui-bg) one step
201
+ * above the canvas.
202
+ */
203
+ --pluto-admin-navbar: oklch(14.1% 0.005 285.823);
204
+ --pluto-admin-sidebar: oklch(17.5% 0.0055 286);
205
+ --pluto-admin-canvas: oklch(21% 0.006 285.885);
206
+ }
207
+
208
+ :root[data-pluto-admin] body {
209
+ background-color: var(--pluto-admin-canvas);
210
+ }
211
+
212
+ :root[data-pluto-admin] body,
213
+ [data-pluto-admin-scope] {
214
+ font-family: var(--font-sans);
215
+ color: var(--ui-text);
216
+ }
@@ -0,0 +1,40 @@
1
+ /*
2
+ * The pluto layer's CSS. It is not a Tailwind entry.
3
+ *
4
+ * The app's CSS entry owns the one Tailwind build of the app:
5
+ *
6
+ * @import 'tailwindcss';
7
+ * @import '@nuxt/ui';
8
+ * @import '#build/pluto.css';
9
+ *
10
+ * `#build/pluto.css` imports this file (and the same file of every other
11
+ * Pluto layer). Rules for this file:
12
+ * - Do not import tailwindcss or @nuxt/ui here.
13
+ * - Use only @theme, @custom-variant, @utility, unlayered rules, and
14
+ * imports of plain CSS files next to this one.
15
+ * - Add no @source for app/: Nuxt UI already scans every layer's app/ dir.
16
+ * See the single-tailwind-build skill.
17
+ */
18
+ @import './admin-theme.css';
19
+
20
+ @theme {
21
+ --color-admin-content: var(--color-zinc-800);
22
+ --color-admin-sidebar: var(--color-zinc-900);
23
+
24
+ --font-outfit: 'Outfit', sans-serif;
25
+ }
26
+
27
+ /*
28
+ * Set the header height outside a layer. This overrides Nuxt UI's own
29
+ * `@layer theme` value for the same variable. The values match the
30
+ * navbar's own height classes (`h-14` / `lg:h-10`).
31
+ */
32
+ :root {
33
+ --ui-header-height: 3.5rem;
34
+ }
35
+
36
+ @media (width >= 64rem) {
37
+ :root {
38
+ --ui-header-height: 2.5rem;
39
+ }
40
+ }
@@ -15,9 +15,11 @@ const isDark = computed({
15
15
  <ClientOnly v-if="!colorMode?.forced">
16
16
  <UButton
17
17
  :icon="isDark ? 'i-lucide-moon' : 'i-lucide-sun'"
18
+ :aria-label="isDark ? 'Switch to light mode' : 'Switch to dark mode'"
18
19
  color="neutral"
19
- variant="solid"
20
- size="sm"
20
+ variant="ghost"
21
+ size="md"
22
+ square
21
23
  @click="isDark = !isDark"
22
24
  />
23
25
 
@@ -327,6 +327,7 @@ const unpublishDeleteItems = computed(() => [
327
327
  <template>
328
328
  <PlutoViewAdminPanel
329
329
  v-if="contentType"
330
+ :aside-open="showAside && asideOpen"
330
331
  :width="layout === 'focus' ? 'full' : 'container'"
331
332
  >
332
333
  <template #toolbar>
@@ -1,5 +1,5 @@
1
1
  <script setup lang="ts">
2
- import type { TableColumn } from '@nuxt/ui'
2
+ import type { ButtonProps, TableColumn } from '@nuxt/ui'
3
3
 
4
4
  const props = defineProps<{
5
5
  /** A content type's `name` (not a registry entry id) — see `usePlutoContentTypes`. */
@@ -48,7 +48,62 @@ function editPath(id: string | number) {
48
48
  return contentType.value?.editPath?.(id) ?? `${basePath.value}/${id}`
49
49
  }
50
50
 
51
- const { items, pending, refresh, remove } = usePlutoContentList(props.type)
51
+ // Load the list 50 rows at a time. The table is virtualized, so only the
52
+ // visible rows render, and the next page loads when the user scrolls near
53
+ // the end of the table (or of the card list, on mobile).
54
+ const PAGE_SIZE = 50
55
+ const { items, total, pending, loadingMore, hasMore, error, refresh, loadMore, remove } = usePlutoContentList(
56
+ props.type,
57
+ { pageSize: PAGE_SIZE }
58
+ )
59
+
60
+ const tableRef = useTemplateRef<{
61
+ $el: HTMLElement
62
+ }>('table')
63
+ const mobileListRef = useTemplateRef<HTMLElement>('mobileList')
64
+
65
+ function loadMoreSafely() {
66
+ // A failure lands in `error`, and the alert offers "Try again".
67
+ loadMore().catch(() => {})
68
+ }
69
+
70
+ // Stop while an error shows, so a failing page does not load in a loop.
71
+ const canLoadMore = () => hasMore.value && !error.value
72
+ useInfiniteScroll(() => tableRef.value?.$el, loadMoreSafely, { distance: 240, canLoadMore })
73
+ useInfiniteScroll(mobileListRef, loadMoreSafely, { distance: 240, canLoadMore })
74
+
75
+ const number = new Intl.NumberFormat()
76
+
77
+ // What to show while the list has no rows. With rows, the list shows
78
+ // them, and a failed refresh shows an alert above them instead.
79
+ const emptyState = computed<'loading' | 'error' | 'empty' | null>(() => {
80
+ if (items.value.length > 0) {
81
+ return null
82
+ }
83
+ if (pending.value) {
84
+ return 'loading'
85
+ }
86
+ return error.value ? 'error' : 'empty'
87
+ })
88
+
89
+ function retry() {
90
+ refresh().catch(() => {})
91
+ }
92
+
93
+ // Buttons navigate with `navigateTo`, not `to`: `to` is typed per app
94
+ // through typed pages, so a computed button list cannot type it here.
95
+ const emptyActions = computed<ButtonProps[]>(() => {
96
+ const type = contentType.value
97
+ if (!type) {
98
+ return []
99
+ }
100
+ if (emptyState.value === 'error') {
101
+ return [{ label: 'Try again', icon: 'lucide:refresh-ccw', color: 'neutral', variant: 'outline', onClick: retry }]
102
+ }
103
+ return canWrite.value
104
+ ? [{ label: `New ${type.label}`, icon: 'lucide:plus', onClick: () => navigateTo(newPath.value) }]
105
+ : []
106
+ })
52
107
 
53
108
  const listFields = computed(() =>
54
109
  (contentType.value?.fields ?? [])
@@ -88,7 +143,7 @@ async function confirmDelete() {
88
143
  </script>
89
144
 
90
145
  <template>
91
- <PlutoViewAdminPanel v-if="contentType">
146
+ <PlutoViewAdminPanel v-if="contentType" fill>
92
147
  <template #toolbar>
93
148
  <PlutoViewToolbar :title="contentType.labelPlural">
94
149
  <template #trailing>
@@ -98,20 +153,59 @@ async function confirmDelete() {
98
153
  variant="ghost"
99
154
  title="Refresh"
100
155
  square
101
- @click="refresh()"
156
+ @click="retry"
102
157
  />
103
158
  </template>
104
159
 
105
160
  <template v-if="canWrite" #right>
106
161
  <UButton :to="newPath" icon="lucide:plus" as="NuxtLink">
107
- Add {{ contentType.labelPlural }}
162
+ New {{ contentType.label }}
108
163
  </UButton>
109
164
  </template>
110
165
  </PlutoViewToolbar>
111
166
  </template>
112
167
 
113
- <div class="grid gap-3 lg:hidden">
114
- <UCard v-for="item in items" :key="item.id">
168
+ <UEmpty
169
+ v-if="emptyState === 'loading'"
170
+ :title="`Loading ${contentType.labelPlural.toLowerCase()}…`"
171
+ variant="naked"
172
+ loading
173
+ />
174
+
175
+ <UEmpty
176
+ v-else-if="emptyState === 'error'"
177
+ :title="`Could not load ${contentType.labelPlural.toLowerCase()}`"
178
+ :actions="emptyActions"
179
+ description="Check your connection and try again."
180
+ icon="lucide:circle-alert"
181
+ variant="naked"
182
+ />
183
+
184
+ <UEmpty
185
+ v-else-if="emptyState === 'empty'"
186
+ :title="`No ${contentType.labelPlural.toLowerCase()} yet`"
187
+ :description="canWrite ? `Create the first ${contentType.label.toLowerCase()} to see it here.` : 'There is nothing here yet.'"
188
+ :icon="contentType.icon ?? 'lucide:inbox'"
189
+ :actions="emptyActions"
190
+ variant="naked"
191
+ />
192
+
193
+ <UAlert
194
+ v-if="!emptyState && error"
195
+ :actions="[{ label: 'Try again', color: 'error', variant: 'outline', onClick: retry }]"
196
+ :title="`Could not refresh ${contentType.labelPlural.toLowerCase()}`"
197
+ description="The list below may be out of date."
198
+ color="error"
199
+ variant="subtle"
200
+ icon="lucide:circle-alert"
201
+ />
202
+
203
+ <div
204
+ v-if="!emptyState"
205
+ ref="mobileList"
206
+ class="flex min-h-0 flex-1 flex-col gap-3 overflow-y-auto lg:hidden"
207
+ >
208
+ <UCard v-for="item in items" :key="item.id" class="shrink-0">
115
209
  <div class="flex flex-col gap-4">
116
210
  <div class="min-w-0">
117
211
  <NuxtLink
@@ -156,15 +250,18 @@ async function confirmDelete() {
156
250
  </UCard>
157
251
  </div>
158
252
 
159
- <UCard :ui="{ body: 'sm:p-0 p-0' }" class="hidden lg:block">
160
- <div class="overflow-x-auto">
161
- <UTable
162
- :data="items"
163
- :columns="columns"
164
- :loading="pending"
165
- empty="No items yet."
166
- >
167
- <template #actions-cell="{ row }">
253
+ <UTable
254
+ v-if="!emptyState"
255
+ ref="table"
256
+ :data="items"
257
+ :columns="columns"
258
+ :loading="pending || loadingMore"
259
+ class="ring-default hidden min-h-0 flex-1 rounded-lg ring lg:block"
260
+ empty="No items yet."
261
+ sticky
262
+ virtualize
263
+ >
264
+ <template #actions-cell="{ row }">
168
265
  <div class="flex justify-end gap-3">
169
266
  <NuxtLink
170
267
  v-if="canWrite"
@@ -183,10 +280,15 @@ async function confirmDelete() {
183
280
  Remove
184
281
  </button>
185
282
  </div>
186
- </template>
187
- </UTable>
188
- </div>
189
- </UCard>
283
+ </template>
284
+ </UTable>
285
+
286
+ <p v-if="!emptyState" class="text-muted shrink-0 text-xs tabular-nums" aria-live="polite">
287
+ Showing {{ number.format(items.length) }}
288
+ <template v-if="total !== undefined">of {{ number.format(total) }}</template>
289
+ {{ contentType.labelPlural.toLowerCase() }}
290
+ <template v-if="loadingMore">· Loading more…</template>
291
+ </p>
190
292
  </PlutoViewAdminPanel>
191
293
 
192
294
  <PlutoViewAdminPanel v-else>