@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.
- package/.prettierignore +2 -0
- package/CHANGELOG.md +15 -0
- package/FEATURES.md +7 -0
- package/app/app.vue +5 -2
- package/app/components/ColorModeButton.vue +2 -1
- package/app/components/Pluto/Content/PlutoContentField.vue +17 -3
- package/app/components/Pluto/Content/PlutoContentForm.vue +60 -38
- package/app/components/Pluto/Content/PlutoContentList.vue +38 -32
- package/app/components/Pluto/Content/fields/PlutoFieldReference.vue +2 -1
- package/app/components/Pluto/Dashboard/PlutoDashboardContentActivity.vue +22 -19
- package/app/components/Pluto/Dashboard/PlutoDashboardContentOverview.vue +15 -14
- package/app/components/Pluto/Dashboard/PlutoDashboardQuickActions.vue +6 -5
- package/app/components/Pluto/PlutoDashboardWidgets.vue +2 -1
- package/app/components/Pluto/PlutoRoot.vue +18 -1
- package/app/components/Pluto/Settings/PlutoSettingsLanguage.vue +51 -0
- package/app/components/modal/ModalHeader.vue +3 -1
- package/app/composables/internal/content-text.ts +111 -0
- package/app/composables/internal/error-text.ts +62 -0
- package/app/composables/internal/i18n-core.ts +310 -0
- package/app/composables/internal/i18n-runtime.ts +87 -0
- package/app/composables/pluto-admin-nav.ts +21 -4
- package/app/composables/pluto-admin-pages.ts +13 -3
- package/app/composables/pluto-command-palette.ts +13 -6
- package/app/composables/pluto-content-actions.ts +9 -5
- package/app/composables/pluto-content-text.ts +28 -0
- package/app/composables/pluto-dashboard.ts +15 -2
- package/app/composables/pluto-error.ts +13 -0
- package/app/composables/pluto-extension.ts +4 -0
- package/app/composables/pluto-i18n.ts +171 -0
- package/app/composables/pluto-permissions.ts +16 -2
- package/app/composables/pluto-registry.ts +1 -0
- package/app/composables/pluto-settings.ts +16 -2
- package/app/layouts/admin.vue +2 -1
- package/app/pages/admin/content/[type]/[id].vue +3 -1
- package/app/pages/admin/content/[type]/index.vue +2 -1
- package/app/pages/admin/content/[type]/new.vue +2 -1
- package/app/pages/admin/home.vue +6 -4
- package/app/pages/admin/settings.vue +10 -5
- package/app/plugins/pluto-core-registrations.ts +10 -7
- package/app/plugins/pluto-i18n.ts +20 -0
- package/i18n/locales/en.json +137 -0
- package/i18n/locales/pt-BR.json +137 -0
- package/modules/pluto-i18n/index.ts +89 -0
- package/modules/pluto-i18n/locale-files.ts +187 -0
- package/package.json +2 -2
- package/server/utils/pluto-content-handlers.ts +30 -7
- package/server/utils/pluto-error.ts +27 -0
- package/shared/types/i18n.d.ts +10 -0
- package/shared/types/i18n.ts +14 -0
- package/shared/types/registry.ts +13 -0
- package/shared/utils/content-validate.ts +17 -7
package/.prettierignore
ADDED
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
|
|
2
|
+
const { t } = usePlutoI18n()
|
|
3
3
|
|
|
4
4
|
useHead({
|
|
5
|
-
titleTemplate: (title) =>
|
|
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 ? '
|
|
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="
|
|
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="
|
|
43
|
+
<UFormField v-else :label="translated.label">
|
|
30
44
|
<p class="text-muted text-sm">
|
|
31
|
-
|
|
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
|
-
|
|
145
|
-
|
|
146
|
-
|
|
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
|
-
? '
|
|
224
|
+
? t('pluto.form.published')
|
|
205
225
|
: statusOverride === draftValue.value
|
|
206
|
-
? '
|
|
207
|
-
: '
|
|
226
|
+
? t('pluto.form.unpublished')
|
|
227
|
+
: t('pluto.form.saved')
|
|
208
228
|
|
|
209
229
|
toast.add({
|
|
210
230
|
title,
|
|
211
|
-
description:
|
|
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: '
|
|
220
|
-
description:
|
|
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: '
|
|
251
|
-
description:
|
|
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: '
|
|
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: '
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
{{
|
|
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="
|
|
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="
|
|
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
|
-
|
|
498
|
+
{{ t('pluto.form.removeTitle', { label: text.label }) }}
|
|
477
499
|
</ModalHeader>
|
|
478
500
|
|
|
479
501
|
<ModalContent>
|
|
480
|
-
<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
|
-
|
|
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
|
-
|
|
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: '
|
|
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:
|
|
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
|
|
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="
|
|
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
|
-
|
|
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="
|
|
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="
|
|
186
|
+
:title="t('pluto.list.loadFailed', { labelPlural: text.labelPluralLower })"
|
|
178
187
|
:actions="emptyActions"
|
|
179
|
-
description="
|
|
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="
|
|
187
|
-
:description="canWrite ?
|
|
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: '
|
|
196
|
-
:title="
|
|
197
|
-
description="
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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="
|
|
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">
|
|
309
|
+
<ModalHeader @close="closeDeleteModal">{{ t('pluto.list.removeTitle') }}</ModalHeader>
|
|
304
310
|
|
|
305
311
|
<ModalContent>
|
|
306
|
-
<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
|
-
|
|
323
|
+
{{ t('pluto.common.cancel') }}
|
|
318
324
|
</UButton>
|
|
319
325
|
|
|
320
326
|
<UButton icon="lucide:trash" color="error" @click="confirmDelete">
|
|
321
|
-
|
|
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
|
/>
|