@plutocms/pluto 0.12.0 → 0.14.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 (57) hide show
  1. package/CHANGELOG.md +24 -0
  2. package/FEATURES.md +7 -0
  3. package/app/app.vue +5 -2
  4. package/app/assets/css/admin-theme.css +16 -7
  5. package/app/components/ColorModeButton.vue +2 -1
  6. package/app/components/Pluto/Content/PlutoContentField.vue +17 -3
  7. package/app/components/Pluto/Content/PlutoContentForm.vue +60 -38
  8. package/app/components/Pluto/Content/PlutoContentList.vue +84 -42
  9. package/app/components/Pluto/Content/fields/PlutoFieldReference.vue +2 -1
  10. package/app/components/Pluto/Dashboard/PlutoDashboardContentActivity.vue +22 -19
  11. package/app/components/Pluto/Dashboard/PlutoDashboardContentOverview.vue +15 -14
  12. package/app/components/Pluto/Dashboard/PlutoDashboardQuickActions.vue +6 -5
  13. package/app/components/Pluto/Navbar/PlutoNavbarAdmin.vue +4 -1
  14. package/app/components/Pluto/PlutoDashboardWidgets.vue +2 -1
  15. package/app/components/Pluto/PlutoRoot.vue +18 -1
  16. package/app/components/Pluto/Settings/PlutoSettingsLanguage.vue +51 -0
  17. package/app/components/Pluto/View/PlutoViewAdminPanel.vue +9 -1
  18. package/app/components/Pluto/View/PlutoViewAside.vue +14 -7
  19. package/app/components/modal/Modal.vue +1 -1
  20. package/app/components/modal/ModalHeader.vue +3 -1
  21. package/app/composables/internal/content-text.ts +111 -0
  22. package/app/composables/internal/error-text.ts +58 -0
  23. package/app/composables/internal/i18n-core.ts +310 -0
  24. package/app/composables/internal/i18n-runtime.ts +87 -0
  25. package/app/composables/pluto-admin-nav.ts +21 -4
  26. package/app/composables/pluto-admin-pages.ts +13 -3
  27. package/app/composables/pluto-command-palette.ts +13 -6
  28. package/app/composables/pluto-content-actions.ts +9 -5
  29. package/app/composables/pluto-content-list.ts +88 -7
  30. package/app/composables/pluto-content-text.ts +28 -0
  31. package/app/composables/pluto-dashboard.ts +15 -2
  32. package/app/composables/pluto-error.ts +13 -0
  33. package/app/composables/pluto-extension.ts +4 -0
  34. package/app/composables/pluto-i18n.ts +171 -0
  35. package/app/composables/pluto-permissions.ts +16 -2
  36. package/app/composables/pluto-registry.ts +1 -0
  37. package/app/composables/pluto-settings.ts +16 -2
  38. package/app/layouts/admin.vue +2 -1
  39. package/app/pages/admin/content/[type]/[id].vue +3 -1
  40. package/app/pages/admin/content/[type]/index.vue +2 -1
  41. package/app/pages/admin/content/[type]/new.vue +2 -1
  42. package/app/pages/admin/home.vue +6 -4
  43. package/app/pages/admin/settings.vue +10 -5
  44. package/app/plugins/pluto-core-registrations.ts +10 -7
  45. package/app/plugins/pluto-i18n.ts +20 -0
  46. package/i18n/locales/en.json +137 -0
  47. package/i18n/locales/pt-BR.json +137 -0
  48. package/modules/pluto-i18n/index.ts +89 -0
  49. package/modules/pluto-i18n/locale-files.ts +187 -0
  50. package/package.json +1 -1
  51. package/scripts/generate-admin-theme.cjs +21 -7
  52. package/server/utils/pluto-content-handlers.ts +30 -7
  53. package/server/utils/pluto-error.ts +27 -0
  54. package/shared/types/i18n.d.ts +10 -0
  55. package/shared/types/i18n.ts +14 -0
  56. package/shared/types/registry.ts +13 -0
  57. package/shared/utils/content-validate.ts +17 -7
package/CHANGELOG.md CHANGED
@@ -1,6 +1,30 @@
1
1
  # Changelog
2
2
 
3
3
 
4
+ ## [0.14.0](https://github.com/plutocms/pluto/compare/v0.13.0...v0.14.0) (2026-10-18)
5
+
6
+
7
+ ### Features
8
+
9
+ * **i18n:** add the admin and site translation system ([#161](https://github.com/plutocms/pluto/issues/161)) ([c8d91fb](https://github.com/plutocms/pluto/commit/c8d91fbc1ad34fb14da36328de41b7e449eb5991))
10
+ * **i18n:** translate the core admin UI ([#163](https://github.com/plutocms/pluto/issues/163)) ([d40750d](https://github.com/plutocms/pluto/commit/d40750d80365041d6b17c9e4e8aa0a4a298653aa))
11
+
12
+ ## [0.13.0](https://github.com/plutocms/pluto/compare/v0.12.0...v0.13.0) (2026-10-18)
13
+
14
+
15
+ ### Features
16
+
17
+ * **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))
18
+ * **content:** page and virtualize long content lists ([#154](https://github.com/plutocms/pluto/issues/154)) ([b8d35b0](https://github.com/plutocms/pluto/commit/b8d35b05230169713a3e7cacbdfd95f52c72936a))
19
+
20
+
21
+ ### Bug Fixes
22
+
23
+ * **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))
24
+ * **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))
25
+ * **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))
26
+ * **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))
27
+
4
28
  ## [0.12.0](https://github.com/plutocms/pluto/compare/v0.11.0...v0.12.0) (2026-10-17)
5
29
 
6
30
 
package/FEATURES.md CHANGED
@@ -33,6 +33,13 @@ policies shared by every project here.
33
33
  - Admin theme: the admin keeps its own palette, chart colors, surfaces, and font under
34
34
  `data-pluto-admin`, so the consumer app's site theme does not reach it.
35
35
  @.claude/skills/admin-theme/SKILL.md
36
+ - Internationalization (i18n): a custom engine for the admin and the public site. The pluto-i18n
37
+ module merges each layer's `i18n/locales/<code>.json`, `usePlutoI18n()` and
38
+ `usePlutoSiteI18n()` translate and format, a per-user admin language is chosen on the Settings
39
+ page and stored through a registered locale driver. The core admin UI, the registry entries, the
40
+ content-type convention keys (`usePlutoContentText`), the validation codes, and the server errors
41
+ (`createPlutoError`, `usePlutoErrorText`) are translated, in English and Brazilian Portuguese.
42
+ @.claude/skills/i18n/SKILL.md
36
43
  - Single Tailwind build: the app owns the only Tailwind build. The pluto-css module generates
37
44
  `#build/pluto.css` from each layer's `pluto-layer.css`, adds a fallback entry, and warns
38
45
  about a wrong CSS setup. @.claude/skills/single-tailwind-build/SKILL.md
package/app/app.vue CHANGED
@@ -1,8 +1,11 @@
1
1
  <script setup lang="ts">
2
- const websiteName = 'Pluto Admin'
2
+ const { t } = usePlutoI18n()
3
3
 
4
4
  useHead({
5
- titleTemplate: (title) => (title ? `${title} - ${websiteName}` : websiteName),
5
+ titleTemplate: (title) => {
6
+ const websiteName = t('pluto.app.name')
7
+ return title ? `${title} - ${websiteName}` : websiteName
8
+ },
6
9
  })
7
10
  </script>
8
11
 
@@ -9,6 +9,10 @@
9
9
  * - `[data-pluto-admin-scope]`: for admin chrome that also shows on the
10
10
  * public site, such as the admin navbar for a logged-in admin.
11
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
+ *
12
16
  * Every value is a literal. The consumer app styles its public site through
13
17
  * its own app.config colors and @theme. Both reach the admin unless the admin
14
18
  * pins its own values: app.config colors set Nuxt UI's --ui-color-* scales on
@@ -149,17 +153,18 @@
149
153
  --ui-chart-5: oklch(58.8% 0.158 241.966); /* sky-600 */
150
154
  --ui-chart-6: oklch(53.2% 0.157 131.589); /* lime-700 */
151
155
 
152
- /* Surfaces */
156
+ /* Surfaces. The navbar is near black in both modes. */
153
157
  --pluto-admin-canvas: #fff;
154
158
  --pluto-admin-sidebar: oklch(98.5% 0 none);
155
- --pluto-admin-navbar: #fff;
159
+ --pluto-admin-navbar: oklch(14.1% 0.005 285.823);
156
160
 
157
161
  --ui-radius: 0.375rem;
158
162
  --font-sans: "Outfit", ui-sans-serif, system-ui, sans-serif;
159
163
  }
160
164
 
161
165
  :root[data-pluto-admin].dark,
162
- .dark [data-pluto-admin-scope] {
166
+ .dark [data-pluto-admin-scope],
167
+ [data-pluto-admin-dark] {
163
168
  --ui-primary: var(--ui-color-primary-400);
164
169
  --ui-secondary: var(--ui-color-secondary-400);
165
170
  --ui-success: var(--ui-color-success-400);
@@ -173,7 +178,7 @@
173
178
  --ui-text: var(--ui-color-neutral-200);
174
179
  --ui-text-highlighted: #fff;
175
180
  --ui-text-inverted: var(--ui-color-neutral-900);
176
- --ui-bg: var(--ui-color-neutral-900);
181
+ --ui-bg: oklch(24% 0.006 286);
177
182
  --ui-bg-muted: var(--ui-color-neutral-800);
178
183
  --ui-bg-elevated: var(--ui-color-neutral-800);
179
184
  --ui-bg-accented: var(--ui-color-neutral-700);
@@ -190,10 +195,14 @@
190
195
  --ui-chart-5: oklch(58.8% 0.158 241.966); /* sky-600 */
191
196
  --ui-chart-6: oklch(64.8% 0.2 131.684); /* lime-600 */
192
197
 
193
- /* Cards (--ui-bg, zinc-900) sit one step above the canvas (zinc-950). */
194
- --pluto-admin-canvas: oklch(14.1% 0.005 285.823);
195
- --pluto-admin-sidebar: oklch(14.1% 0.005 285.823);
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
+ */
196
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);
197
206
  }
198
207
 
199
208
  :root[data-pluto-admin] body {
@@ -1,5 +1,6 @@
1
1
  <script setup>
2
2
  const colorMode = useColorMode()
3
+ const { t } = usePlutoI18n()
3
4
 
4
5
  const isDark = computed({
5
6
  get() {
@@ -15,7 +16,7 @@ const isDark = computed({
15
16
  <ClientOnly v-if="!colorMode?.forced">
16
17
  <UButton
17
18
  :icon="isDark ? 'i-lucide-moon' : 'i-lucide-sun'"
18
- :aria-label="isDark ? 'Switch to light mode' : 'Switch to dark mode'"
19
+ :aria-label="isDark ? t('pluto.common.switchToLightMode') : t('pluto.common.switchToDarkMode')"
19
20
  color="neutral"
20
21
  variant="ghost"
21
22
  size="md"
@@ -7,6 +7,12 @@ const props = defineProps<{
7
7
  field: PlutoField
8
8
  modelValue: unknown
9
9
  disabled?: boolean
10
+ /**
11
+ * A content type's `name`. Set it so the field text uses the
12
+ * `content.<name>.fields.*` translation keys. Without it, the field keeps
13
+ * its own English text.
14
+ */
15
+ type?: string
10
16
  }>()
11
17
 
12
18
  const emit = defineEmits<{
@@ -14,21 +20,29 @@ const emit = defineEmits<{
14
20
  }>()
15
21
 
16
22
  const widget = usePlutoContentFieldWidget(props.field)
23
+
24
+ const { t } = usePlutoI18n()
25
+ const { byName } = usePlutoContentTypes()
26
+ const text = usePlutoContentText(() => (props.type ? byName(props.type) : undefined))
27
+
28
+ // The widget gets a translated copy, so a widget from any layer shows the
29
+ // translated text with no change. Without a type, the copy keeps the original.
30
+ const translated = computed(() => (props.type ? text.field(props.field) : props.field))
17
31
  </script>
18
32
 
19
33
  <template>
20
34
  <component
21
35
  :is="widget"
22
36
  v-if="widget"
23
- :field="field"
37
+ :field="translated"
24
38
  :model-value="modelValue"
25
39
  :disabled="disabled"
26
40
  @update:model-value="emit('update:modelValue', $event)"
27
41
  />
28
42
 
29
- <UFormField v-else :label="field.label">
43
+ <UFormField v-else :label="translated.label">
30
44
  <p class="text-muted text-sm">
31
- No widget registered for field type "{{ field.type }}".
45
+ {{ t('pluto.fields.noWidget', { type: field.type }) }}
32
46
  </p>
33
47
  </UFormField>
34
48
  </template>
@@ -10,6 +10,9 @@ const props = defineProps<{
10
10
 
11
11
  const { byName } = usePlutoContentTypes()
12
12
  const contentType = computed(() => byName(props.type))
13
+ const { t } = usePlutoI18n()
14
+ const text = usePlutoContentText(contentType)
15
+ const errorText = usePlutoErrorText()
13
16
 
14
17
  if (!contentType.value) {
15
18
  // The generated page (`app/pages/admin/content/[type]/{new,[id]}.vue`)
@@ -139,12 +142,16 @@ const isPublished = computed(
139
142
  () =>
140
143
  statusConfig.value !== null && currentStatus.value === publishedValue.value
141
144
  )
142
- const statusLabel = computed(
143
- () =>
144
- statusConfig.value?.values.find(
145
- (value) => value.value === currentStatus.value
146
- )?.label ?? currentStatus.value
147
- )
145
+ const statusLabel = computed(() => {
146
+ const value = currentStatus.value
147
+ if (value === undefined) {
148
+ return undefined
149
+ }
150
+ const literal = statusConfig.value?.values.find(
151
+ (entry) => entry.value === value
152
+ )?.label
153
+ return text.statusLabel(value, literal)
154
+ })
148
155
 
149
156
  // --- Save -------------------------------------------------------------------
150
157
 
@@ -152,6 +159,19 @@ const toast = useToast()
152
159
  const saving = ref(false)
153
160
  const validationErrors = ref<ContentValidationError[]>([])
154
161
 
162
+ // A validation error with a `code` shows the translated message. The
163
+ // `message` of the error stays English, and shows when there is no code.
164
+ function validationText(error: ContentValidationError): string {
165
+ if (!error.code) {
166
+ return error.message
167
+ }
168
+ const field = contentType.value?.fields.find((entry) => entry.name === error.field)
169
+ return t(`pluto.validation.${error.code}`, {
170
+ ...error.params,
171
+ label: field ? text.fieldLabel(field) : error.field,
172
+ })
173
+ }
174
+
155
175
  function buildPayload(statusOverride?: string): Record<string, unknown> {
156
176
  const type = contentType.value
157
177
  if (!type) {
@@ -201,23 +221,23 @@ async function onSave(statusOverride?: string) {
201
221
 
202
222
  const title =
203
223
  statusOverride === publishedValue.value
204
- ? 'Published'
224
+ ? t('pluto.form.published')
205
225
  : statusOverride === draftValue.value
206
- ? 'Unpublished'
207
- : 'Saved'
226
+ ? t('pluto.form.unpublished')
227
+ : t('pluto.form.saved')
208
228
 
209
229
  toast.add({
210
230
  title,
211
- description: `${type.label} updated successfully.`,
231
+ description: t('pluto.form.updated', { label: text.label }),
212
232
  color: 'success',
213
233
  })
214
- } catch {
234
+ } catch (error) {
215
235
  // A raced permissions check, or any other server-side rejection
216
236
  // (a 403 included), surfaces here rather than as an unhandled
217
237
  // rejection.
218
238
  toast.add({
219
- title: 'Error',
220
- description: 'An error occurred while saving.',
239
+ title: t('pluto.common.error'),
240
+ description: errorText(error, 'pluto.form.saveError'),
221
241
  color: 'error',
222
242
  })
223
243
  } finally {
@@ -245,10 +265,10 @@ async function confirmDelete() {
245
265
  await remove()
246
266
  closeDeleteModal()
247
267
  await navigateTo(basePath.value)
248
- } catch {
268
+ } catch (error) {
249
269
  toast.add({
250
- title: 'Error',
251
- description: 'An error occurred while removing.',
270
+ title: t('pluto.common.error'),
271
+ description: errorText(error, 'pluto.form.removeError'),
252
272
  color: 'error',
253
273
  })
254
274
  } finally {
@@ -258,6 +278,12 @@ async function confirmDelete() {
258
278
 
259
279
  // --- Layout / aside -----------------------------------------------------------
260
280
 
281
+ const heading = computed(() =>
282
+ editing.value
283
+ ? t('pluto.content.edit', { label: text.label })
284
+ : text.newLabel
285
+ )
286
+
261
287
  const layout = computed(() => contentType.value?.form?.layout ?? 'default')
262
288
  const showAside = computed(() => {
263
289
  const configured = contentType.value?.form?.aside
@@ -304,7 +330,7 @@ const unpublishDeleteItems = computed(() => [
304
330
  ...(statusConfig.value && isPublished.value && canPublish.value
305
331
  ? [
306
332
  {
307
- label: 'Unpublish',
333
+ label: t('pluto.form.unpublish'),
308
334
  icon: 'lucide:eye-off',
309
335
  onSelect: () => onSave(draftValue.value),
310
336
  },
@@ -313,7 +339,7 @@ const unpublishDeleteItems = computed(() => [
313
339
  ...(editing.value && canDelete.value
314
340
  ? [
315
341
  {
316
- label: 'Delete',
342
+ label: t('pluto.common.delete'),
317
343
  icon: 'lucide:trash',
318
344
  color: 'error' as const,
319
345
  onSelect: () => openDeleteModal(),
@@ -333,13 +359,7 @@ const unpublishDeleteItems = computed(() => [
333
359
  <template #toolbar>
334
360
  <PlutoViewToolbar
335
361
  :back-to="basePath"
336
- :title="
337
- layout === 'focus'
338
- ? editing
339
- ? `Edit ${contentType.label}`
340
- : `New ${contentType.label}`
341
- : undefined
342
- "
362
+ :title="layout === 'focus' ? heading : undefined"
343
363
  >
344
364
  <template #right>
345
365
  <UBadge
@@ -370,7 +390,7 @@ const unpublishDeleteItems = computed(() => [
370
390
  variant="subtle"
371
391
  @click="onSave()"
372
392
  >
373
- Save{{ statusConfig ? ' draft' : '' }}
393
+ {{ statusConfig ? t('pluto.form.saveDraft') : t('pluto.common.save') }}
374
394
  </UButton>
375
395
 
376
396
  <UButton
@@ -379,7 +399,7 @@ const unpublishDeleteItems = computed(() => [
379
399
  icon="lucide:save"
380
400
  @click="onSave()"
381
401
  >
382
- Update
402
+ {{ t('pluto.form.update') }}
383
403
  </UButton>
384
404
 
385
405
  <UButton
@@ -388,7 +408,7 @@ const unpublishDeleteItems = computed(() => [
388
408
  icon="lucide:upload"
389
409
  @click="onSave(publishedValue)"
390
410
  >
391
- Publish
411
+ {{ t('pluto.form.publish') }}
392
412
  </UButton>
393
413
 
394
414
  <UDropdownMenu
@@ -422,25 +442,26 @@ const unpublishDeleteItems = computed(() => [
422
442
 
423
443
  <UAlert
424
444
  v-if="validationErrors.length > 0"
445
+ :title="t('pluto.form.fixErrors')"
425
446
  color="error"
426
- title="Please fix the following before saving"
427
447
  >
428
448
  <template #description>
429
449
  <ul class="list-disc pl-4">
430
450
  <li v-for="fieldError in validationErrors" :key="fieldError.field">
431
- {{ fieldError.message }}
451
+ {{ validationText(fieldError) }}
432
452
  </li>
433
453
  </ul>
434
454
  </template>
435
455
  </UAlert>
436
456
 
437
457
  <h1 v-if="layout !== 'focus'" class="text-3xl font-bold lg:text-4xl">
438
- {{ editing ? `Edit ${contentType.label}` : `New ${contentType.label}` }}
458
+ {{ heading }}
439
459
  </h1>
440
460
 
441
461
  <PlutoContentField
442
462
  v-for="field in mainFields"
443
463
  :key="field.name"
464
+ :type="type"
444
465
  :field="field"
445
466
  :model-value="item[field.name]"
446
467
  :disabled="!canWrite || saving"
@@ -448,11 +469,12 @@ const unpublishDeleteItems = computed(() => [
448
469
  />
449
470
 
450
471
  <template v-if="showAside" #aside>
451
- <PlutoViewAside v-model:open="asideOpen" title="Settings">
472
+ <PlutoViewAside v-model:open="asideOpen" :title="t('pluto.common.settings')">
452
473
  <div class="flex flex-col gap-6">
453
474
  <PlutoContentField
454
475
  v-for="field in sideFields"
455
476
  :key="field.name"
477
+ :type="type"
456
478
  :field="field"
457
479
  :model-value="item[field.name]"
458
480
  :disabled="!canWrite || saving"
@@ -465,19 +487,19 @@ const unpublishDeleteItems = computed(() => [
465
487
 
466
488
  <PlutoViewAdminPanel v-else>
467
489
  <UAlert
468
- :description="`No content type named &quot;${type}&quot; is registered.`"
490
+ :description="t('pluto.content.typeNotFoundDescription', { type })"
491
+ :title="t('pluto.content.typeNotFound')"
469
492
  color="error"
470
- title="Content type not found"
471
493
  />
472
494
  </PlutoViewAdminPanel>
473
495
 
474
496
  <Modal v-model="isDeleteModalOpen" :custom-size="480">
475
497
  <ModalHeader @close="closeDeleteModal">
476
- Remove {{ contentType?.label }}
498
+ {{ t('pluto.form.removeTitle', { label: text.label }) }}
477
499
  </ModalHeader>
478
500
 
479
501
  <ModalContent>
480
- <p>Do you really want to remove this item?</p>
502
+ <p>{{ t('pluto.content.removeConfirm') }}</p>
481
503
  </ModalContent>
482
504
 
483
505
  <ModalFooter>
@@ -488,7 +510,7 @@ const unpublishDeleteItems = computed(() => [
488
510
  color="neutral"
489
511
  @click="closeDeleteModal"
490
512
  >
491
- Cancel
513
+ {{ t('pluto.common.cancel') }}
492
514
  </UButton>
493
515
 
494
516
  <UButton
@@ -497,7 +519,7 @@ const unpublishDeleteItems = computed(() => [
497
519
  color="error"
498
520
  @click="confirmDelete"
499
521
  >
500
- Remove
522
+ {{ t('pluto.common.remove') }}
501
523
  </UButton>
502
524
  </div>
503
525
  </ModalFooter>