@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.
- package/CHANGELOG.md +24 -0
- package/FEATURES.md +7 -0
- package/app/app.vue +5 -2
- package/app/assets/css/admin-theme.css +16 -7
- 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 +84 -42
- 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/Navbar/PlutoNavbarAdmin.vue +4 -1
- 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/Pluto/View/PlutoViewAdminPanel.vue +9 -1
- package/app/components/Pluto/View/PlutoViewAside.vue +14 -7
- package/app/components/modal/Modal.vue +1 -1
- package/app/components/modal/ModalHeader.vue +3 -1
- package/app/composables/internal/content-text.ts +111 -0
- package/app/composables/internal/error-text.ts +58 -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-list.ts +88 -7
- 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 +1 -1
- package/scripts/generate-admin-theme.cjs +21 -7
- 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
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { contentTextFor } from './internal/content-text'
|
|
2
|
+
|
|
1
3
|
// The old `NavigationMenuItem`/`NavigationMenuChildItem` shape (from
|
|
2
4
|
// `@plutocms/utils`) always carries a plain `href`, never Nuxt's typed `to`.
|
|
3
5
|
// This narrows just the field the bridge below reads.
|
|
@@ -22,6 +24,11 @@ interface LegacyHrefLink {
|
|
|
22
24
|
* when `type.capabilities.read` is unset, the entry is always visible;
|
|
23
25
|
* when it is set, `can(...)` decides.
|
|
24
26
|
*
|
|
27
|
+
* Labels are key-or-literal. They resolve with `tx()` after the legacy dedupe
|
|
28
|
+
* above, which compares the raw labels. A content type's labels come from
|
|
29
|
+
* `usePlutoContentText`, so the convention keys apply. The `computed` reads
|
|
30
|
+
* the active locale, so a locale switch updates the labels.
|
|
31
|
+
*
|
|
25
32
|
* `entry.enabled` is read and enforced uniformly across all three sources
|
|
26
33
|
* here. Source 1 already filters on it a second time, inside
|
|
27
34
|
* `createOwnedRegistry` itself (see
|
|
@@ -35,6 +42,8 @@ export function usePlutoAdminNav() {
|
|
|
35
42
|
const legacy = useSidebarAdminActions()
|
|
36
43
|
const { items: contentTypes } = usePlutoContentTypes()
|
|
37
44
|
const { can } = usePlutoPermissions()
|
|
45
|
+
const i18n = usePlutoI18n()
|
|
46
|
+
const { t, tx } = i18n
|
|
38
47
|
|
|
39
48
|
const items = computed<PlutoNavItem[]>(() => {
|
|
40
49
|
const registered = registry.nav.list.value
|
|
@@ -55,27 +64,35 @@ export function usePlutoAdminNav() {
|
|
|
55
64
|
})),
|
|
56
65
|
}))
|
|
57
66
|
|
|
67
|
+
// Resolve the labels now, after the dedupe above has compared the raw ones.
|
|
68
|
+
const resolveLabels = (item: PlutoNavItem): PlutoNavItem => ({
|
|
69
|
+
...item,
|
|
70
|
+
label: tx(item.label),
|
|
71
|
+
children: item.children?.map((child) => ({ ...child, label: tx(child.label) })),
|
|
72
|
+
})
|
|
73
|
+
|
|
58
74
|
const contentNav: PlutoNavItem[] = contentTypes.value
|
|
59
75
|
.filter((entry) => entry.type.autoRoutes !== false)
|
|
60
76
|
.map((entry) => {
|
|
61
77
|
const type = entry.type
|
|
78
|
+
const text = contentTextFor(type, i18n)
|
|
62
79
|
const basePath = type.basePath ?? `/admin/content/${type.name}`
|
|
63
80
|
|
|
64
81
|
return {
|
|
65
82
|
id: `content:${type.name}`,
|
|
66
83
|
order: type.navOrder ?? 100,
|
|
67
|
-
label:
|
|
84
|
+
label: text.labelPlural,
|
|
68
85
|
icon: type.icon,
|
|
69
86
|
to: basePath,
|
|
70
87
|
enabled: () => !type.capabilities?.read || can(type.capabilities.read),
|
|
71
88
|
children: [
|
|
72
|
-
{ label:
|
|
73
|
-
{ label:
|
|
89
|
+
{ label: t('pluto.content.all', { labelPlural: text.labelPlural }), to: basePath },
|
|
90
|
+
{ label: t('pluto.content.createNew', { label: text.label }), to: `${basePath}/new` },
|
|
74
91
|
],
|
|
75
92
|
}
|
|
76
93
|
})
|
|
77
94
|
|
|
78
|
-
return [...registered, ...bridged, ...contentNav]
|
|
95
|
+
return [...registered.map(resolveLabels), ...bridged.map(resolveLabels), ...contentNav]
|
|
79
96
|
.filter((item) => item.enabled?.() ?? true)
|
|
80
97
|
.sort((a, b) => (a.order ?? 100) - (b.order ?? 100))
|
|
81
98
|
})
|
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { contentTextFor } from './internal/content-text'
|
|
2
|
+
|
|
1
3
|
/**
|
|
2
4
|
* Metadata for admin pages, registered on top of routes Nuxt already
|
|
3
5
|
* creates from files, plus two synthesized entries per auto-routed content
|
|
@@ -5,32 +7,40 @@
|
|
|
5
7
|
* page-metadata equivalent of the nav entry `usePlutoAdminNav` synthesizes
|
|
6
8
|
* for the same content type. No layer registers page metadata today, so
|
|
7
9
|
* the registry bucket itself has no legacy bridge.
|
|
10
|
+
*
|
|
11
|
+
* Titles are key-or-literal. They resolve with `tx()` inside the `computed`,
|
|
12
|
+
* so a locale switch updates them.
|
|
8
13
|
*/
|
|
9
14
|
export function usePlutoAdminPages() {
|
|
10
15
|
const registry = usePlutoRegistry()
|
|
11
16
|
const route = useRoute()
|
|
12
17
|
const { items: contentTypes } = usePlutoContentTypes()
|
|
18
|
+
const i18n = usePlutoI18n()
|
|
19
|
+
const { t, tx } = i18n
|
|
13
20
|
|
|
14
21
|
const items = computed<PlutoAdminPage[]>(() => {
|
|
15
22
|
const contentPages: PlutoAdminPage[] = contentTypes.value
|
|
16
23
|
.filter((entry) => entry.type.autoRoutes !== false)
|
|
17
24
|
.flatMap((entry) => {
|
|
18
25
|
const type = entry.type
|
|
26
|
+
const text = contentTextFor(type, i18n)
|
|
19
27
|
const basePath = type.basePath ?? `/admin/content/${type.name}`
|
|
20
28
|
const navId = `content:${type.name}`
|
|
21
29
|
|
|
22
30
|
return [
|
|
23
|
-
{ id: `${navId}:list`, path: basePath, title:
|
|
31
|
+
{ id: `${navId}:list`, path: basePath, title: text.labelPlural },
|
|
24
32
|
{
|
|
25
33
|
id: `${navId}:new`,
|
|
26
34
|
path: `${basePath}/new`,
|
|
27
|
-
title:
|
|
35
|
+
title: t('pluto.content.createNew', { label: text.label }),
|
|
28
36
|
parent: navId,
|
|
29
37
|
},
|
|
30
38
|
]
|
|
31
39
|
})
|
|
32
40
|
|
|
33
|
-
|
|
41
|
+
const registered = registry.pages.list.value.map((page) => ({ ...page, title: tx(page.title) }))
|
|
42
|
+
|
|
43
|
+
return [...registered, ...contentPages]
|
|
34
44
|
})
|
|
35
45
|
|
|
36
46
|
const current = computed(() => items.value.find((page) => page.path === route.path))
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { CommandPaletteGroup, CommandPaletteItem } from '@nuxt/ui'
|
|
2
|
+
import { contentTextFor } from './internal/content-text'
|
|
2
3
|
|
|
3
4
|
/**
|
|
4
5
|
* Builds the admin command palette groups (`UDashboardSearch` in the
|
|
@@ -19,7 +20,12 @@ export function usePlutoCommandPalette() {
|
|
|
19
20
|
const { items: navItems } = usePlutoAdminNav()
|
|
20
21
|
const { items: contentTypes } = usePlutoContentTypes()
|
|
21
22
|
const { can } = usePlutoPermissions()
|
|
23
|
+
const i18n = usePlutoI18n()
|
|
24
|
+
const { t, tx } = i18n
|
|
22
25
|
|
|
26
|
+
// Everything the palette shows resolves inside the `computed`, so a locale
|
|
27
|
+
// switch updates it. Nav labels arrive resolved from `usePlutoAdminNav`.
|
|
28
|
+
// `tx()` leaves a resolved label as it is.
|
|
23
29
|
return computed<CommandPaletteGroup<CommandPaletteItem>[]>(() => {
|
|
24
30
|
const seen = new Set<string>()
|
|
25
31
|
|
|
@@ -38,22 +44,23 @@ export function usePlutoCommandPalette() {
|
|
|
38
44
|
return []
|
|
39
45
|
}
|
|
40
46
|
const basePath = type.basePath ?? `/admin/content/${type.name}`
|
|
41
|
-
|
|
47
|
+
const text = contentTextFor(type, i18n)
|
|
48
|
+
return command(text.newLabel, type.newPath ?? `${basePath}/new`, {
|
|
42
49
|
icon: 'lucide:plus',
|
|
43
|
-
suffix:
|
|
50
|
+
suffix: text.labelPlural,
|
|
44
51
|
})
|
|
45
52
|
})
|
|
46
53
|
|
|
47
54
|
const goTo = navItems.value.flatMap((item) => [
|
|
48
|
-
...(item.to ? command(item.label, item.to, { icon: item.icon }) : []),
|
|
55
|
+
...(item.to ? command(tx(item.label), item.to, { icon: item.icon }) : []),
|
|
49
56
|
...(item.children ?? []).flatMap((child) =>
|
|
50
|
-
child.to ? command(child.label, child.to, { icon: item.icon, suffix: item.label }) : []
|
|
57
|
+
child.to ? command(tx(child.label), child.to, { icon: item.icon, suffix: tx(item.label) }) : []
|
|
51
58
|
),
|
|
52
59
|
])
|
|
53
60
|
|
|
54
61
|
return [
|
|
55
|
-
{ id: 'pluto-create', label: '
|
|
56
|
-
{ id: 'pluto-go-to', label: '
|
|
62
|
+
{ id: 'pluto-create', label: t('pluto.common.create'), items: create },
|
|
63
|
+
{ id: 'pluto-go-to', label: t('pluto.palette.goTo'), items: goTo },
|
|
57
64
|
].filter((group) => group.items.length > 0)
|
|
58
65
|
})
|
|
59
66
|
}
|
|
@@ -3,15 +3,19 @@
|
|
|
3
3
|
* placement. Matches an entry whose `contentType` is `typeName` or `'*'`,
|
|
4
4
|
* the same matching rule `usePlutoContentFieldWidget` uses for field type.
|
|
5
5
|
* `placement` defaults to 'toolbar', matching `PlutoContentAction.placement`.
|
|
6
|
+
* The label is key-or-literal. It resolves with `tx()` inside the `computed`.
|
|
6
7
|
*/
|
|
7
8
|
export function usePlutoContentActions(typeName: string, placement: 'toolbar' | 'aside' = 'toolbar') {
|
|
8
9
|
const registry = usePlutoRegistry()
|
|
10
|
+
const { tx } = usePlutoI18n()
|
|
9
11
|
|
|
10
12
|
return computed(() =>
|
|
11
|
-
registry.contentActions.list.value
|
|
12
|
-
(
|
|
13
|
-
(action
|
|
14
|
-
|
|
15
|
-
|
|
13
|
+
registry.contentActions.list.value
|
|
14
|
+
.filter(
|
|
15
|
+
(action) =>
|
|
16
|
+
(action.contentType === typeName || action.contentType === '*') &&
|
|
17
|
+
(action.placement ?? 'toolbar') === placement
|
|
18
|
+
)
|
|
19
|
+
.map((action) => ({ ...action, label: tx(action.label) }))
|
|
16
20
|
)
|
|
17
21
|
}
|
|
@@ -1,3 +1,13 @@
|
|
|
1
|
+
export interface PlutoContentListOptions {
|
|
2
|
+
/**
|
|
3
|
+
* Rows per request. Set it to load the list page by page: the first page
|
|
4
|
+
* on creation, the next pages through `loadMore()`. Leave it unset for one
|
|
5
|
+
* request that returns every row (an adapter can cap that request; the
|
|
6
|
+
* Supabase adapter returns at most 1000 rows).
|
|
7
|
+
*/
|
|
8
|
+
pageSize?: number
|
|
9
|
+
}
|
|
10
|
+
|
|
1
11
|
/**
|
|
2
12
|
* Fetches one content type's list of items, for the generic list page
|
|
3
13
|
* (`PlutoContentList`).
|
|
@@ -15,25 +25,86 @@
|
|
|
15
25
|
* of `await`ing the composable's own return value through a hand-rolled
|
|
16
26
|
* `.then()`.
|
|
17
27
|
*/
|
|
18
|
-
export function usePlutoContentList(typeName: string) {
|
|
28
|
+
export function usePlutoContentList(typeName: string, options: PlutoContentListOptions = {}) {
|
|
29
|
+
const { pageSize } = options
|
|
30
|
+
const url = `/api/_pluto/content/${typeName}`
|
|
31
|
+
|
|
19
32
|
const items = ref<PlutoContentItem[]>([])
|
|
20
33
|
const total = ref<number | undefined>(undefined)
|
|
21
34
|
const pending = ref(false)
|
|
35
|
+
const loadingMore = ref(false)
|
|
36
|
+
/** `true` while more pages exist. Always `false` without `pageSize`. */
|
|
37
|
+
const hasMore = ref(false)
|
|
22
38
|
const error = ref<unknown>(null)
|
|
23
39
|
|
|
40
|
+
// A refresh() while a page is in flight makes that page stale. Each fetch
|
|
41
|
+
// remembers the generation it started in, and drops its result when a
|
|
42
|
+
// newer refresh() has started since.
|
|
43
|
+
let generation = 0
|
|
44
|
+
|
|
45
|
+
function fetchPage(offset: number): Promise<PlutoContentListResult> {
|
|
46
|
+
return pageSize === undefined
|
|
47
|
+
? $fetch<PlutoContentListResult>(url)
|
|
48
|
+
: $fetch<PlutoContentListResult>(url, { query: { limit: pageSize, offset } })
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function updateHasMore(lastPageLength: number) {
|
|
52
|
+
if (pageSize === undefined) {
|
|
53
|
+
hasMore.value = false
|
|
54
|
+
return
|
|
55
|
+
}
|
|
56
|
+
// Without a total, a full page means there may be another one.
|
|
57
|
+
hasMore.value = total.value === undefined ? lastPageLength === pageSize : items.value.length < total.value
|
|
58
|
+
}
|
|
59
|
+
|
|
24
60
|
async function refresh(): Promise<void> {
|
|
61
|
+
const current = ++generation
|
|
25
62
|
pending.value = true
|
|
26
63
|
error.value = null
|
|
27
64
|
|
|
28
65
|
try {
|
|
29
|
-
const result = await
|
|
66
|
+
const result = await fetchPage(0)
|
|
67
|
+
if (current !== generation) {
|
|
68
|
+
return
|
|
69
|
+
}
|
|
30
70
|
items.value = result.data
|
|
31
71
|
total.value = result.total
|
|
72
|
+
updateHasMore(result.data.length)
|
|
32
73
|
} catch (caught) {
|
|
33
|
-
|
|
74
|
+
if (current === generation) {
|
|
75
|
+
error.value = caught
|
|
76
|
+
}
|
|
77
|
+
throw caught
|
|
78
|
+
} finally {
|
|
79
|
+
if (current === generation) {
|
|
80
|
+
pending.value = false
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** Appends the next page. Does nothing without `pageSize`, without more pages, or while a fetch runs. */
|
|
86
|
+
async function loadMore(): Promise<void> {
|
|
87
|
+
if (!hasMore.value || pending.value || loadingMore.value) {
|
|
88
|
+
return
|
|
89
|
+
}
|
|
90
|
+
const current = generation
|
|
91
|
+
loadingMore.value = true
|
|
92
|
+
|
|
93
|
+
try {
|
|
94
|
+
const result = await fetchPage(items.value.length)
|
|
95
|
+
if (current !== generation) {
|
|
96
|
+
return
|
|
97
|
+
}
|
|
98
|
+
items.value = [...items.value, ...result.data]
|
|
99
|
+
total.value = result.total ?? total.value
|
|
100
|
+
updateHasMore(result.data.length)
|
|
101
|
+
} catch (caught) {
|
|
102
|
+
if (current === generation) {
|
|
103
|
+
error.value = caught
|
|
104
|
+
}
|
|
34
105
|
throw caught
|
|
35
106
|
} finally {
|
|
36
|
-
|
|
107
|
+
loadingMore.value = false
|
|
37
108
|
}
|
|
38
109
|
}
|
|
39
110
|
|
|
@@ -47,14 +118,24 @@ export function usePlutoContentList(typeName: string) {
|
|
|
47
118
|
|
|
48
119
|
async function remove(id: string | number): Promise<void> {
|
|
49
120
|
try {
|
|
50
|
-
await $fetch(
|
|
121
|
+
await $fetch(`${url}/${id}`, { method: 'DELETE' })
|
|
51
122
|
} catch (caught) {
|
|
52
123
|
error.value = caught
|
|
53
124
|
throw caught
|
|
54
125
|
}
|
|
55
126
|
|
|
56
|
-
|
|
127
|
+
if (pageSize === undefined) {
|
|
128
|
+
await refresh()
|
|
129
|
+
return
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
// Paged: drop the row in place, so the user keeps the pages already
|
|
133
|
+
// loaded and the scroll position.
|
|
134
|
+
items.value = items.value.filter((item) => item.id !== id)
|
|
135
|
+
if (total.value !== undefined) {
|
|
136
|
+
total.value = Math.max(0, total.value - 1)
|
|
137
|
+
}
|
|
57
138
|
}
|
|
58
139
|
|
|
59
|
-
return { items, total, pending, error, refresh, remove }
|
|
140
|
+
return { items, total, pending, loadingMore, hasMore, error, refresh, loadMore, remove }
|
|
60
141
|
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { createContentText } from './internal/content-text'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* The translated text of one content type. Each value is the convention key
|
|
5
|
+
* (`content.<name>.label`, `.labelPlural`, `.new`, `.fields.<field>.label`
|
|
6
|
+
* and more) when the key exists, else the English literal of the type. A type
|
|
7
|
+
* with no keys keeps its English text.
|
|
8
|
+
*
|
|
9
|
+
* Read the values inside a template or a `computed`, so a locale switch
|
|
10
|
+
* updates them. Server code and code that reads `type.label` directly keep
|
|
11
|
+
* the English text.
|
|
12
|
+
*/
|
|
13
|
+
export function usePlutoContentText(type: MaybeRefOrGetter<PlutoContentType | undefined>) {
|
|
14
|
+
const { t, te, locale } = usePlutoI18n()
|
|
15
|
+
return createContentText(() => toValue(type), { t, te, locale })
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Returns a function that gives the translated text of a content type by its
|
|
20
|
+
* `name`. Use it where one component shows many types, such as the dashboard
|
|
21
|
+
* tiles. See `usePlutoContentText`.
|
|
22
|
+
*/
|
|
23
|
+
export function usePlutoContentTexts() {
|
|
24
|
+
const { t, te, locale } = usePlutoI18n()
|
|
25
|
+
const { byName } = usePlutoContentTypes()
|
|
26
|
+
|
|
27
|
+
return (name: string) => createContentText(() => byName(name), { t, te, locale })
|
|
28
|
+
}
|
|
@@ -1,5 +1,18 @@
|
|
|
1
|
-
/**
|
|
1
|
+
/**
|
|
2
|
+
* Widgets shown on the admin dashboard (`/admin/home`). A widget title is
|
|
3
|
+
* key-or-literal. It resolves with `tx()` inside the `computed`, so a locale
|
|
4
|
+
* switch updates it.
|
|
5
|
+
*/
|
|
2
6
|
export function usePlutoDashboardWidgets() {
|
|
3
7
|
const registry = usePlutoRegistry()
|
|
4
|
-
|
|
8
|
+
const { tx } = usePlutoI18n()
|
|
9
|
+
|
|
10
|
+
const items = computed<PlutoDashboardWidget[]>(() =>
|
|
11
|
+
registry.dashboardWidgets.list.value.map((widget) => ({
|
|
12
|
+
...widget,
|
|
13
|
+
title: widget.title === undefined ? undefined : tx(widget.title),
|
|
14
|
+
}))
|
|
15
|
+
)
|
|
16
|
+
|
|
17
|
+
return { items }
|
|
5
18
|
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { DEFAULT_ERROR_KEY, resolveErrorText } from './internal/error-text'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Returns a function that turns an error into text for the admin. It prefers
|
|
5
|
+
* the translation key that a server `createPlutoError` sent (`data.i18n`),
|
|
6
|
+
* then the server `statusMessage` or `message`, then the fallback key. The
|
|
7
|
+
* default fallback is `pluto.errors.unknown`.
|
|
8
|
+
*/
|
|
9
|
+
export function usePlutoErrorText() {
|
|
10
|
+
const { t, te } = usePlutoI18n()
|
|
11
|
+
|
|
12
|
+
return (error: unknown, fallbackKey: string = DEFAULT_ERROR_KEY): string => resolveErrorText(error, fallbackKey, { t, te })
|
|
13
|
+
}
|
|
@@ -20,6 +20,10 @@ export function definePlutoExtension(definition: PlutoExtension): void {
|
|
|
20
20
|
definition.id,
|
|
21
21
|
definition.settingsDriver ? [definition.settingsDriver] : []
|
|
22
22
|
)
|
|
23
|
+
registry.localeDrivers.set(
|
|
24
|
+
definition.id,
|
|
25
|
+
definition.localeDriver ? [definition.localeDriver] : []
|
|
26
|
+
)
|
|
23
27
|
registry.dashboardWidgets.set(definition.id, definition.dashboardWidgets ?? [])
|
|
24
28
|
registry.capabilities.set(definition.id, definition.capabilities ?? [])
|
|
25
29
|
registry.permissionsDrivers.set(
|
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
import type { MessageParams } from './internal/i18n-core'
|
|
2
|
+
import {
|
|
3
|
+
createTranslator,
|
|
4
|
+
formatDate,
|
|
5
|
+
formatNumber,
|
|
6
|
+
} from './internal/i18n-core'
|
|
7
|
+
import {
|
|
8
|
+
ADMIN_LOCALE_COOKIE,
|
|
9
|
+
detectAdminLocale,
|
|
10
|
+
getConfig,
|
|
11
|
+
loadPlutoLocale,
|
|
12
|
+
messageCache,
|
|
13
|
+
readAdminPreference,
|
|
14
|
+
SITE_LOCALE_COOKIE,
|
|
15
|
+
uiLocaleCache,
|
|
16
|
+
useLocaleCookie,
|
|
17
|
+
} from './internal/i18n-runtime'
|
|
18
|
+
|
|
19
|
+
/** Missing keys already reported. Shared, so a key warns once per process. */
|
|
20
|
+
const warnedKeys = new Set<string>()
|
|
21
|
+
|
|
22
|
+
function warnOnce(message: string) {
|
|
23
|
+
if (import.meta.dev && !warnedKeys.has(message)) {
|
|
24
|
+
warnedKeys.add(message)
|
|
25
|
+
console.warn(message)
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* The state both scopes share: the active locale, the translator, the
|
|
31
|
+
* formatters, and the Nuxt UI locale object. The admin and the site keep a
|
|
32
|
+
* separate active locale and a shared message cache.
|
|
33
|
+
*/
|
|
34
|
+
function useLocaleScope(scope: 'admin' | 'site') {
|
|
35
|
+
const config = getConfig()
|
|
36
|
+
const state = useState<string>(`pluto-${scope}-locale`, () => config.defaultLocale)
|
|
37
|
+
const locales = scope === 'admin' ? config.adminLocales : config.siteLocales
|
|
38
|
+
|
|
39
|
+
const { t, te, tx } = createTranslator({
|
|
40
|
+
locale: () => state.value,
|
|
41
|
+
fallbackLocale: 'en',
|
|
42
|
+
messages: (code) => messageCache.get(code),
|
|
43
|
+
warn: warnOnce,
|
|
44
|
+
})
|
|
45
|
+
|
|
46
|
+
return {
|
|
47
|
+
state,
|
|
48
|
+
locales,
|
|
49
|
+
locale: computed(() => state.value),
|
|
50
|
+
uiLocale: computed(() => uiLocaleCache.get(state.value) ?? uiLocaleCache.get('en')),
|
|
51
|
+
t: (key: string, params?: MessageParams | number) => t(key, params),
|
|
52
|
+
tx: (value: string, params?: MessageParams | number) => tx(value, params),
|
|
53
|
+
te,
|
|
54
|
+
n: (value: number, options?: Intl.NumberFormatOptions) => formatNumber(state.value, value, options),
|
|
55
|
+
d: (value: Date | number | string, options?: Intl.DateTimeFormatOptions) => formatDate(state.value, value, options),
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Translation for the admin. The admin locale is per user: a stored choice
|
|
61
|
+
* (cookie, and the registered locale driver), else the browser language, else
|
|
62
|
+
* the default locale.
|
|
63
|
+
*
|
|
64
|
+
* `tx(value)` translates a key-or-literal string. It translates only when the
|
|
65
|
+
* value looks like a key and the key exists. Any other value returns as it is.
|
|
66
|
+
*/
|
|
67
|
+
export function usePlutoI18n() {
|
|
68
|
+
const scope = useLocaleScope('admin')
|
|
69
|
+
const registry = usePlutoRegistry()
|
|
70
|
+
const cookie = useLocaleCookie(ADMIN_LOCALE_COOKIE)
|
|
71
|
+
const driver = computed(() => registry.localeDrivers.list.value.at(-1) ?? null)
|
|
72
|
+
|
|
73
|
+
/** The stored choice. Null means Automatic. */
|
|
74
|
+
const preference = useState<string | null>('pluto-admin-locale-preference', () => readAdminPreference())
|
|
75
|
+
|
|
76
|
+
async function switchTo(code: string) {
|
|
77
|
+
await loadPlutoLocale(code)
|
|
78
|
+
scope.state.value = code
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Switches the admin language at once. A code is an explicit choice. Null is
|
|
83
|
+
* Automatic: the browser language is detected again. The cookie keeps the
|
|
84
|
+
* explicit code, or is cleared for Automatic. Then the registered locale
|
|
85
|
+
* driver, if any, saves the choice. A driver error rejects the promise, and
|
|
86
|
+
* the switch stays.
|
|
87
|
+
*/
|
|
88
|
+
async function setLocale(code: string | null) {
|
|
89
|
+
if (code !== null && !scope.locales.includes(code)) {
|
|
90
|
+
return
|
|
91
|
+
}
|
|
92
|
+
await switchTo(code ?? detectAdminLocale())
|
|
93
|
+
preference.value = code
|
|
94
|
+
cookie.value = code
|
|
95
|
+
await driver.value?.save(code)
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Reads the user's stored language from the locale driver. A stored code
|
|
100
|
+
* switches the admin to it and sets the cookie. No stored code means
|
|
101
|
+
* Automatic: the cookie is cleared, and the browser language applies. With
|
|
102
|
+
* no driver, or on a driver error, nothing changes.
|
|
103
|
+
*/
|
|
104
|
+
async function syncUserLocale() {
|
|
105
|
+
if (!driver.value) {
|
|
106
|
+
return
|
|
107
|
+
}
|
|
108
|
+
let stored: string | null
|
|
109
|
+
try {
|
|
110
|
+
stored = await driver.value.load()
|
|
111
|
+
} catch (error) {
|
|
112
|
+
warnOnce(`[pluto-i18n] Could not load the user's language: ${error instanceof Error ? error.message : String(error)}`)
|
|
113
|
+
return
|
|
114
|
+
}
|
|
115
|
+
if (stored && scope.locales.includes(stored)) {
|
|
116
|
+
await switchTo(stored)
|
|
117
|
+
preference.value = stored
|
|
118
|
+
cookie.value = stored
|
|
119
|
+
} else {
|
|
120
|
+
await switchTo(detectAdminLocale())
|
|
121
|
+
preference.value = null
|
|
122
|
+
cookie.value = null
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
return {
|
|
127
|
+
locale: scope.locale,
|
|
128
|
+
locales: computed(() => scope.locales),
|
|
129
|
+
uiLocale: scope.uiLocale,
|
|
130
|
+
preference: computed(() => preference.value),
|
|
131
|
+
t: scope.t,
|
|
132
|
+
tx: scope.tx,
|
|
133
|
+
te: scope.te,
|
|
134
|
+
n: scope.n,
|
|
135
|
+
d: scope.d,
|
|
136
|
+
setLocale,
|
|
137
|
+
syncUserLocale,
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* Translation for the public site. The site locale belongs to the app: the
|
|
143
|
+
* `pluto-site-locale` cookie, else the `plutoI18n.siteLocale` option, else the
|
|
144
|
+
* default locale. It does not follow the admin language.
|
|
145
|
+
*/
|
|
146
|
+
export function usePlutoSiteI18n() {
|
|
147
|
+
const scope = useLocaleScope('site')
|
|
148
|
+
const cookie = useLocaleCookie(SITE_LOCALE_COOKIE)
|
|
149
|
+
|
|
150
|
+
/** Switches the site language, and keeps it in the `pluto-site-locale` cookie. */
|
|
151
|
+
async function setLocale(code: string) {
|
|
152
|
+
if (!scope.locales.includes(code)) {
|
|
153
|
+
return
|
|
154
|
+
}
|
|
155
|
+
await loadPlutoLocale(code)
|
|
156
|
+
scope.state.value = code
|
|
157
|
+
cookie.value = code
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
return {
|
|
161
|
+
locale: scope.locale,
|
|
162
|
+
locales: computed(() => scope.locales),
|
|
163
|
+
uiLocale: scope.uiLocale,
|
|
164
|
+
t: scope.t,
|
|
165
|
+
tx: scope.tx,
|
|
166
|
+
te: scope.te,
|
|
167
|
+
n: scope.n,
|
|
168
|
+
d: scope.d,
|
|
169
|
+
setLocale,
|
|
170
|
+
}
|
|
171
|
+
}
|
|
@@ -1,7 +1,21 @@
|
|
|
1
|
-
/**
|
|
1
|
+
/**
|
|
2
|
+
* Capabilities declared by every layer, in registry order. The label and the
|
|
3
|
+
* description are key-or-literal. They resolve with `tx()` inside the
|
|
4
|
+
* `computed`, so a locale switch updates them.
|
|
5
|
+
*/
|
|
2
6
|
export function usePlutoCapabilities() {
|
|
3
7
|
const registry = usePlutoRegistry()
|
|
4
|
-
|
|
8
|
+
const { tx } = usePlutoI18n()
|
|
9
|
+
|
|
10
|
+
const items = computed<PlutoCapability[]>(() =>
|
|
11
|
+
registry.capabilities.list.value.map((capability) => ({
|
|
12
|
+
...capability,
|
|
13
|
+
label: tx(capability.label),
|
|
14
|
+
description: capability.description === undefined ? undefined : tx(capability.description),
|
|
15
|
+
}))
|
|
16
|
+
)
|
|
17
|
+
|
|
18
|
+
return { items }
|
|
5
19
|
}
|
|
6
20
|
|
|
7
21
|
/**
|
|
@@ -20,6 +20,7 @@ export function usePlutoRegistry(): PlutoRegistry {
|
|
|
20
20
|
pages: createOwnedRegistry<PlutoAdminPage>(),
|
|
21
21
|
settingsPanels: createOwnedRegistry<PlutoSettingsPanel>(),
|
|
22
22
|
settingsDrivers: createOwnedRegistry<PlutoSettingsDriver>(),
|
|
23
|
+
localeDrivers: createOwnedRegistry<PlutoLocaleDriver>(),
|
|
23
24
|
dashboardWidgets: createOwnedRegistry<PlutoDashboardWidget>(),
|
|
24
25
|
capabilities: createOwnedRegistry<PlutoCapability>(),
|
|
25
26
|
permissionsDrivers: createOwnedRegistry<PlutoPermissionsDriver>(),
|
|
@@ -1,7 +1,21 @@
|
|
|
1
|
-
/**
|
|
1
|
+
/**
|
|
2
|
+
* Settings panels registered by every layer, in registry order. The title and
|
|
3
|
+
* the description are key-or-literal. They resolve with `tx()` inside the
|
|
4
|
+
* `computed`, so a locale switch updates them.
|
|
5
|
+
*/
|
|
2
6
|
export function usePlutoSettingsPanels() {
|
|
3
7
|
const registry = usePlutoRegistry()
|
|
4
|
-
|
|
8
|
+
const { tx } = usePlutoI18n()
|
|
9
|
+
|
|
10
|
+
const items = computed<PlutoSettingsPanel[]>(() =>
|
|
11
|
+
registry.settingsPanels.list.value.map((panel) => ({
|
|
12
|
+
...panel,
|
|
13
|
+
title: tx(panel.title),
|
|
14
|
+
description: panel.description === undefined ? undefined : tx(panel.description),
|
|
15
|
+
}))
|
|
16
|
+
)
|
|
17
|
+
|
|
18
|
+
return { items }
|
|
5
19
|
}
|
|
6
20
|
|
|
7
21
|
/**
|
package/app/layouts/admin.vue
CHANGED
|
@@ -34,6 +34,7 @@ const menu = computed<SidebarMenuItem[]>(() => navItems.value.map(toMenuItem))
|
|
|
34
34
|
const isSidebarOpen = useState<boolean>('pluto-admin-sidebar-open', () => false)
|
|
35
35
|
|
|
36
36
|
const commandGroups = usePlutoCommandPalette()
|
|
37
|
+
const { t } = usePlutoI18n()
|
|
37
38
|
|
|
38
39
|
// Keep the consumer app's public-site theme out of the admin.
|
|
39
40
|
usePlutoAdminTheme()
|
|
@@ -95,6 +96,6 @@ const groupStyle = { top: 'var(--ui-header-height)' }
|
|
|
95
96
|
</UDashboardPanel>
|
|
96
97
|
|
|
97
98
|
<!-- Cmd/Ctrl+K. See usePlutoCommandPalette for the commands. -->
|
|
98
|
-
<UDashboardSearch :groups="commandGroups" placeholder="
|
|
99
|
+
<UDashboardSearch :groups="commandGroups" :placeholder="t('pluto.palette.placeholder')" />
|
|
99
100
|
</UDashboardGroup>
|
|
100
101
|
</template>
|
|
@@ -3,14 +3,16 @@ const route = useRoute()
|
|
|
3
3
|
const typeName = route.params.type as string
|
|
4
4
|
|
|
5
5
|
const { byName } = usePlutoContentTypes()
|
|
6
|
+
const { t } = usePlutoI18n()
|
|
6
7
|
const type = byName(typeName)
|
|
8
|
+
const text = usePlutoContentText(type)
|
|
7
9
|
|
|
8
10
|
if (!type) {
|
|
9
11
|
throw createError({ statusCode: 404, statusMessage: 'Content type not found.', fatal: true })
|
|
10
12
|
}
|
|
11
13
|
|
|
12
14
|
useHead({
|
|
13
|
-
title:
|
|
15
|
+
title: () => t('pluto.content.edit', { label: text.label }),
|
|
14
16
|
})
|
|
15
17
|
</script>
|
|
16
18
|
|