@plutocms/pluto 0.13.0 → 0.14.1

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 (51) hide show
  1. package/.prettierignore +2 -0
  2. package/CHANGELOG.md +15 -0
  3. package/FEATURES.md +7 -0
  4. package/app/app.vue +5 -2
  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 +38 -32
  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/PlutoDashboardWidgets.vue +2 -1
  14. package/app/components/Pluto/PlutoRoot.vue +18 -1
  15. package/app/components/Pluto/Settings/PlutoSettingsLanguage.vue +51 -0
  16. package/app/components/modal/ModalHeader.vue +3 -1
  17. package/app/composables/internal/content-text.ts +111 -0
  18. package/app/composables/internal/error-text.ts +62 -0
  19. package/app/composables/internal/i18n-core.ts +310 -0
  20. package/app/composables/internal/i18n-runtime.ts +87 -0
  21. package/app/composables/pluto-admin-nav.ts +21 -4
  22. package/app/composables/pluto-admin-pages.ts +13 -3
  23. package/app/composables/pluto-command-palette.ts +13 -6
  24. package/app/composables/pluto-content-actions.ts +9 -5
  25. package/app/composables/pluto-content-text.ts +28 -0
  26. package/app/composables/pluto-dashboard.ts +15 -2
  27. package/app/composables/pluto-error.ts +13 -0
  28. package/app/composables/pluto-extension.ts +4 -0
  29. package/app/composables/pluto-i18n.ts +171 -0
  30. package/app/composables/pluto-permissions.ts +16 -2
  31. package/app/composables/pluto-registry.ts +1 -0
  32. package/app/composables/pluto-settings.ts +16 -2
  33. package/app/layouts/admin.vue +2 -1
  34. package/app/pages/admin/content/[type]/[id].vue +3 -1
  35. package/app/pages/admin/content/[type]/index.vue +2 -1
  36. package/app/pages/admin/content/[type]/new.vue +2 -1
  37. package/app/pages/admin/home.vue +6 -4
  38. package/app/pages/admin/settings.vue +10 -5
  39. package/app/plugins/pluto-core-registrations.ts +10 -7
  40. package/app/plugins/pluto-i18n.ts +20 -0
  41. package/i18n/locales/en.json +137 -0
  42. package/i18n/locales/pt-BR.json +137 -0
  43. package/modules/pluto-i18n/index.ts +89 -0
  44. package/modules/pluto-i18n/locale-files.ts +187 -0
  45. package/package.json +2 -2
  46. package/server/utils/pluto-content-handlers.ts +30 -7
  47. package/server/utils/pluto-error.ts +27 -0
  48. package/shared/types/i18n.d.ts +10 -0
  49. package/shared/types/i18n.ts +14 -0
  50. package/shared/types/registry.ts +13 -0
  51. package/shared/utils/content-validate.ts +17 -7
@@ -0,0 +1,2 @@
1
+ # Agent skill files keep their own formatting. Prettier must not rewrite them.
2
+ .agents/**/*.md
package/CHANGELOG.md CHANGED
@@ -1,6 +1,21 @@
1
1
  # Changelog
2
2
 
3
3
 
4
+ ## [0.14.1](https://github.com/plutocms/pluto/compare/v0.14.0...v0.14.1) (2026-10-18)
5
+
6
+
7
+ ### Bug Fixes
8
+
9
+ * **i18n:** prefer the response body message in usePlutoErrorText ([#165](https://github.com/plutocms/pluto/issues/165)) ([0c5d183](https://github.com/plutocms/pluto/commit/0c5d183f66fa328e0e18b1a27ced77f7d0fded89))
10
+
11
+ ## [0.14.0](https://github.com/plutocms/pluto/compare/v0.13.0...v0.14.0) (2026-10-18)
12
+
13
+
14
+ ### Features
15
+
16
+ * **i18n:** add the admin and site translation system ([#161](https://github.com/plutocms/pluto/issues/161)) ([c8d91fb](https://github.com/plutocms/pluto/commit/c8d91fbc1ad34fb14da36328de41b7e449eb5991))
17
+ * **i18n:** translate the core admin UI ([#163](https://github.com/plutocms/pluto/issues/163)) ([d40750d](https://github.com/plutocms/pluto/commit/d40750d80365041d6b17c9e4e8aa0a4a298653aa))
18
+
4
19
  ## [0.13.0](https://github.com/plutocms/pluto/compare/v0.12.0...v0.13.0) (2026-10-18)
5
20
 
6
21
 
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
 
@@ -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>
@@ -8,6 +8,8 @@ const props = defineProps<{
8
8
 
9
9
  const { byName } = usePlutoContentTypes()
10
10
  const contentType = computed(() => byName(props.type))
11
+ const { t, n } = usePlutoI18n()
12
+ const text = usePlutoContentText(contentType)
11
13
 
12
14
  if (!contentType.value) {
13
15
  // The generated page (`app/pages/admin/content/[type]/index.vue`) already
@@ -72,8 +74,6 @@ const canLoadMore = () => hasMore.value && !error.value
72
74
  useInfiniteScroll(() => tableRef.value?.$el, loadMoreSafely, { distance: 240, canLoadMore })
73
75
  useInfiniteScroll(mobileListRef, loadMoreSafely, { distance: 240, canLoadMore })
74
76
 
75
- const number = new Intl.NumberFormat()
76
-
77
77
  // What to show while the list has no rows. With rows, the list shows
78
78
  // them, and a failed refresh shows an alert above them instead.
79
79
  const emptyState = computed<'loading' | 'error' | 'empty' | null>(() => {
@@ -98,10 +98,10 @@ const emptyActions = computed<ButtonProps[]>(() => {
98
98
  return []
99
99
  }
100
100
  if (emptyState.value === 'error') {
101
- return [{ label: 'Try again', icon: 'lucide:refresh-ccw', color: 'neutral', variant: 'outline', onClick: retry }]
101
+ return [{ label: t('pluto.common.tryAgain'), icon: 'lucide:refresh-ccw', color: 'neutral', variant: 'outline', onClick: retry }]
102
102
  }
103
103
  return canWrite.value
104
- ? [{ label: `New ${type.label}`, icon: 'lucide:plus', onClick: () => navigateTo(newPath.value) }]
104
+ ? [{ label: text.newLabel, icon: 'lucide:plus', onClick: () => navigateTo(newPath.value) }]
105
105
  : []
106
106
  })
107
107
 
@@ -114,7 +114,7 @@ const listFields = computed(() =>
114
114
  const columns = computed<TableColumn<PlutoContentItem>[]>(() => [
115
115
  ...listFields.value.map((field) => ({
116
116
  accessorKey: field.name,
117
- header: field.label,
117
+ header: text.fieldLabel(field),
118
118
  })),
119
119
  { id: 'actions', header: '' },
120
120
  ])
@@ -140,18 +140,27 @@ async function confirmDelete() {
140
140
  await remove(pendingDeleteId.value)
141
141
  closeDeleteModal()
142
142
  }
143
+
144
+ // The footer line: "Showing 5 of 20 posts", plus "Loading more…" while a
145
+ // page loads. The phrase has the plural label as a parameter, so a language
146
+ // can put it anywhere.
147
+ const footer = computed(() => {
148
+ const params = { loaded: n(items.value.length), total: total.value === undefined ? '' : n(total.value), labelPlural: text.labelPluralLower }
149
+ const summary = t(total.value === undefined ? 'pluto.list.showing' : 'pluto.list.showingOf', params)
150
+ return loadingMore.value ? `${summary} · ${t('pluto.list.loadingMore')}` : summary
151
+ })
143
152
  </script>
144
153
 
145
154
  <template>
146
155
  <PlutoViewAdminPanel v-if="contentType" fill>
147
156
  <template #toolbar>
148
- <PlutoViewToolbar :title="contentType.labelPlural">
157
+ <PlutoViewToolbar :title="text.labelPlural">
149
158
  <template #trailing>
150
159
  <UButton
151
160
  :loading="pending"
161
+ :title="t('pluto.common.refresh')"
152
162
  icon="lucide:refresh-ccw"
153
163
  variant="ghost"
154
- title="Refresh"
155
164
  square
156
165
  @click="retry"
157
166
  />
@@ -159,7 +168,7 @@ async function confirmDelete() {
159
168
 
160
169
  <template v-if="canWrite" #right>
161
170
  <UButton :to="newPath" icon="lucide:plus" as="NuxtLink">
162
- New {{ contentType.label }}
171
+ {{ text.newLabel }}
163
172
  </UButton>
164
173
  </template>
165
174
  </PlutoViewToolbar>
@@ -167,24 +176,24 @@ async function confirmDelete() {
167
176
 
168
177
  <UEmpty
169
178
  v-if="emptyState === 'loading'"
170
- :title="`Loading ${contentType.labelPlural.toLowerCase()}…`"
179
+ :title="t('pluto.list.loading', { labelPlural: text.labelPluralLower })"
171
180
  variant="naked"
172
181
  loading
173
182
  />
174
183
 
175
184
  <UEmpty
176
185
  v-else-if="emptyState === 'error'"
177
- :title="`Could not load ${contentType.labelPlural.toLowerCase()}`"
186
+ :title="t('pluto.list.loadFailed', { labelPlural: text.labelPluralLower })"
178
187
  :actions="emptyActions"
179
- description="Check your connection and try again."
188
+ :description="t('pluto.list.loadFailedHint')"
180
189
  icon="lucide:circle-alert"
181
190
  variant="naked"
182
191
  />
183
192
 
184
193
  <UEmpty
185
194
  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.'"
195
+ :title="t('pluto.list.empty', { labelPlural: text.labelPluralLower })"
196
+ :description="canWrite ? t('pluto.list.emptyCanWrite', { label: text.labelLower }) : t('pluto.list.emptyReadOnly')"
188
197
  :icon="contentType.icon ?? 'lucide:inbox'"
189
198
  :actions="emptyActions"
190
199
  variant="naked"
@@ -192,9 +201,9 @@ async function confirmDelete() {
192
201
 
193
202
  <UAlert
194
203
  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."
204
+ :actions="[{ label: t('pluto.common.tryAgain'), color: 'error', variant: 'outline', onClick: retry }]"
205
+ :title="t('pluto.list.refreshFailed', { labelPlural: text.labelPluralLower })"
206
+ :description="t('pluto.list.refreshFailedHint')"
198
207
  color="error"
199
208
  variant="subtle"
200
209
  icon="lucide:circle-alert"
@@ -218,7 +227,7 @@ async function confirmDelete() {
218
227
 
219
228
  <dl class="grid grid-cols-2 gap-3 text-sm">
220
229
  <div v-for="field in listFields" :key="field.name">
221
- <dt class="text-muted">{{ field.label }}</dt>
230
+ <dt class="text-muted">{{ text.fieldLabel(field) }}</dt>
222
231
  <dd class="font-medium">{{ item[field.name] }}</dd>
223
232
  </div>
224
233
  </dl>
@@ -232,7 +241,7 @@ async function confirmDelete() {
232
241
  variant="soft"
233
242
  class="flex-1 justify-center"
234
243
  >
235
- Edit
244
+ {{ t('pluto.common.edit') }}
236
245
  </UButton>
237
246
 
238
247
  <UButton
@@ -243,7 +252,7 @@ async function confirmDelete() {
243
252
  class="flex-1 justify-center"
244
253
  @click="openDeleteModal(item.id)"
245
254
  >
246
- Remove
255
+ {{ t('pluto.common.remove') }}
247
256
  </UButton>
248
257
  </div>
249
258
  </div>
@@ -256,8 +265,8 @@ async function confirmDelete() {
256
265
  :data="items"
257
266
  :columns="columns"
258
267
  :loading="pending || loadingMore"
268
+ :empty="t('pluto.list.emptyTable')"
259
269
  class="ring-default hidden min-h-0 flex-1 rounded-lg ring lg:block"
260
- empty="No items yet."
261
270
  sticky
262
271
  virtualize
263
272
  >
@@ -268,7 +277,7 @@ async function confirmDelete() {
268
277
  :to="editPath(row.original.id)"
269
278
  class="text-info px-0 py-0.5 hover:underline"
270
279
  >
271
- Edit
280
+ {{ t('pluto.common.edit') }}
272
281
  </NuxtLink>
273
282
 
274
283
  <button
@@ -277,33 +286,30 @@ async function confirmDelete() {
277
286
  class="text-error cursor-pointer px-0 py-0.5 hover:underline"
278
287
  @click="openDeleteModal(row.original.id)"
279
288
  >
280
- Remove
289
+ {{ t('pluto.common.remove') }}
281
290
  </button>
282
291
  </div>
283
292
  </template>
284
293
  </UTable>
285
294
 
286
295
  <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>
296
+ {{ footer }}
291
297
  </p>
292
298
  </PlutoViewAdminPanel>
293
299
 
294
300
  <PlutoViewAdminPanel v-else>
295
301
  <UAlert
296
- :description="`No content type named &quot;${type}&quot; is registered.`"
302
+ :description="t('pluto.content.typeNotFoundDescription', { type })"
303
+ :title="t('pluto.content.typeNotFound')"
297
304
  color="error"
298
- title="Content type not found"
299
305
  />
300
306
  </PlutoViewAdminPanel>
301
307
 
302
308
  <Modal v-model="isDeleteModalOpen" :custom-size="480">
303
- <ModalHeader @close="closeDeleteModal">Remove item</ModalHeader>
309
+ <ModalHeader @close="closeDeleteModal">{{ t('pluto.list.removeTitle') }}</ModalHeader>
304
310
 
305
311
  <ModalContent>
306
- <p>Do you really want to remove this item?</p>
312
+ <p>{{ t('pluto.content.removeConfirm') }}</p>
307
313
  </ModalContent>
308
314
 
309
315
  <ModalFooter>
@@ -314,11 +320,11 @@ async function confirmDelete() {
314
320
  color="neutral"
315
321
  @click="closeDeleteModal"
316
322
  >
317
- Cancel
323
+ {{ t('pluto.common.cancel') }}
318
324
  </UButton>
319
325
 
320
326
  <UButton icon="lucide:trash" color="error" @click="confirmDelete">
321
- Remove
327
+ {{ t('pluto.common.remove') }}
322
328
  </UButton>
323
329
  </div>
324
330
  </ModalFooter>
@@ -48,6 +48,7 @@ async function loadOptions() {
48
48
 
49
49
  onMounted(loadOptions)
50
50
 
51
+ const { t } = usePlutoI18n()
51
52
  const hasOptionsSource = computed(() => Boolean(props.field.optionsUrl))
52
53
  const value = computed(() => props.modelValue as string | number | undefined)
53
54
  </script>
@@ -69,9 +70,9 @@ const value = computed(() => props.modelValue as string | number | undefined)
69
70
  <UInputMenu
70
71
  v-else
71
72
  :items="[]"
73
+ :placeholder="t('pluto.fields.noOptionsSource')"
72
74
  value-key="value"
73
75
  label-key="label"
74
- placeholder="No options source configured for this reference field."
75
76
  class="w-full"
76
77
  disabled
77
78
  />