@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
@@ -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: type.labelPlural,
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: `All ${type.labelPlural}`, to: basePath },
73
- { label: `Create new ${type.label}`, to: `${basePath}/new` },
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: type.labelPlural },
31
+ { id: `${navId}:list`, path: basePath, title: text.labelPlural },
24
32
  {
25
33
  id: `${navId}:new`,
26
34
  path: `${basePath}/new`,
27
- title: `Create new ${type.label}`,
35
+ title: t('pluto.content.createNew', { label: text.label }),
28
36
  parent: navId,
29
37
  },
30
38
  ]
31
39
  })
32
40
 
33
- return [...registry.pages.list.value, ...contentPages]
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
- return command(`New ${type.label}`, type.newPath ?? `${basePath}/new`, {
47
+ const text = contentTextFor(type, i18n)
48
+ return command(text.newLabel, type.newPath ?? `${basePath}/new`, {
42
49
  icon: 'lucide:plus',
43
- suffix: type.labelPlural,
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: 'Create', items: create },
56
- { id: 'pluto-go-to', label: 'Go to', items: goTo },
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.filter(
12
- (action) =>
13
- (action.contentType === typeName || action.contentType === '*') &&
14
- (action.placement ?? 'toolbar') === placement
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 $fetch<PlutoContentListResult>(`/api/_pluto/content/${typeName}`)
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
- error.value = caught
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
- pending.value = false
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(`/api/_pluto/content/${typeName}/${id}`, { method: 'DELETE' })
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
- await refresh()
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
- /** Widgets shown on the admin dashboard (`/admin/home`). */
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
- return { items: registry.dashboardWidgets.list }
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
- /** Capabilities declared by every layer, in registry order. */
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
- return { items: registry.capabilities.list }
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
- /** Settings panels registered by every layer, in registry order. */
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
- return { items: registry.settingsPanels.list }
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
  /**
@@ -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="Search pages and actions…" />
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: `Edit ${type.label}`,
15
+ title: () => t('pluto.content.edit', { label: text.label }),
14
16
  })
15
17
  </script>
16
18