@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,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
 
@@ -4,13 +4,14 @@ const typeName = route.params.type as string
4
4
 
5
5
  const { byName } = usePlutoContentTypes()
6
6
  const type = byName(typeName)
7
+ const text = usePlutoContentText(type)
7
8
 
8
9
  if (!type) {
9
10
  throw createError({ statusCode: 404, statusMessage: 'Content type not found.', fatal: true })
10
11
  }
11
12
 
12
13
  useHead({
13
- title: type.labelPlural,
14
+ title: () => text.labelPlural,
14
15
  })
15
16
  </script>
16
17
 
@@ -4,13 +4,14 @@ const typeName = route.params.type as string
4
4
 
5
5
  const { byName } = usePlutoContentTypes()
6
6
  const type = byName(typeName)
7
+ const text = usePlutoContentText(type)
7
8
 
8
9
  if (!type) {
9
10
  throw createError({ statusCode: 404, statusMessage: 'Content type not found.', fatal: true })
10
11
  }
11
12
 
12
13
  useHead({
13
- title: `New ${type.label}`,
14
+ title: () => text.newLabel,
14
15
  })
15
16
  </script>
16
17
 
@@ -1,9 +1,11 @@
1
1
  <script setup lang="ts">
2
+ const { t, d } = usePlutoI18n()
3
+
2
4
  useHead({
3
- title: 'Dashboard',
5
+ title: () => t('pluto.pages.dashboard'),
4
6
  })
5
7
 
6
- const today = new Intl.DateTimeFormat(undefined, { weekday: 'long', month: 'long', day: 'numeric' }).format(new Date())
8
+ const today = computed(() => d(new Date(), { weekday: 'long', month: 'long', day: 'numeric' }))
7
9
 
8
10
  // The header's Create button reuses the command palette's Create commands.
9
11
  const groups = usePlutoCommandPalette()
@@ -21,7 +23,7 @@ function run(item: {
21
23
  <header class="flex flex-col gap-4 sm:flex-row sm:items-end sm:justify-between">
22
24
  <div>
23
25
  <p class="text-muted text-sm">{{ today }}</p>
24
- <h1 class="text-highlighted text-2xl font-semibold tracking-tight lg:text-3xl">Dashboard</h1>
26
+ <h1 class="text-highlighted text-2xl font-semibold tracking-tight lg:text-3xl">{{ t('pluto.pages.dashboard') }}</h1>
25
27
  </div>
26
28
 
27
29
  <UButton
@@ -33,7 +35,7 @@ function run(item: {
33
35
  />
34
36
 
35
37
  <UDropdownMenu v-else-if="createItems.length > 1" :items="createItems" :content="{ align: 'end' }">
36
- <UButton label="Create" icon="lucide:plus" trailing-icon="lucide:chevron-down" size="md" />
38
+ <UButton :label="t('pluto.common.create')" icon="lucide:plus" trailing-icon="lucide:chevron-down" size="md" />
37
39
  </UDropdownMenu>
38
40
  </header>
39
41
 
@@ -1,6 +1,8 @@
1
1
  <script setup lang="ts">
2
+ const { t } = usePlutoI18n()
3
+
2
4
  useHead({
3
- title: 'Settings',
5
+ title: () => t('pluto.pages.settings'),
4
6
  })
5
7
 
6
8
  const { items } = usePlutoSettingsPanels()
@@ -16,13 +18,16 @@ onMounted(() => {
16
18
  <template>
17
19
  <main>
18
20
  <AdminView>
19
- <h1 class="text-3xl font-bold lg:text-4xl">Settings</h1>
21
+ <h1 class="text-3xl font-bold lg:text-4xl">{{ t('pluto.pages.settings') }}</h1>
22
+
23
+ <!-- Per-user, so it needs no capability and no settings driver. -->
24
+ <PlutoSettingsLanguage />
20
25
 
21
26
  <UAlert
22
27
  v-if="!hasDriver"
28
+ :title="t('pluto.settings.noStorage')"
29
+ :description="t('pluto.settings.noStorageHint')"
23
30
  color="warning"
24
- title="No settings storage"
25
- description="No installed layer provides settings storage yet."
26
31
  />
27
32
 
28
33
  <template v-else>
@@ -37,7 +42,7 @@ onMounted(() => {
37
42
 
38
43
  <div class="flex">
39
44
  <UButton :loading="status === 'saving'" icon="lucide:save" @click="save">
40
- Save
45
+ {{ t('pluto.common.save') }}
41
46
  </UButton>
42
47
  </div>
43
48
  </template>
@@ -14,6 +14,9 @@ import PlutoDashboardQuickActions from '../components/Pluto/Dashboard/PlutoDashb
14
14
  // Home/Settings nav entries, so every other layer's nav item sorts between
15
15
  // them by default (default order is 100).
16
16
  //
17
+ // Labels and titles are translation keys, not English text. The consumer
18
+ // composables resolve them with `tx()`. See the i18n skill.
19
+ //
17
20
  // Everything core itself contributes to the registry lives in this one
18
21
  // `definePlutoExtension` call — `definePlutoExtension` replaces a layer's
19
22
  // whole contribution on every call, not just the buckets that call passes,
@@ -27,12 +30,12 @@ export default defineNuxtPlugin(() => {
27
30
  definePlutoExtension({
28
31
  id: 'pluto',
29
32
  nav: [
30
- { id: 'home', order: 0, label: 'Home', icon: 'lucide:house', to: '/admin/home' },
31
- { id: 'settings', order: 1000, label: 'Settings', icon: 'lucide:settings', to: '/admin/settings' },
33
+ { id: 'home', order: 0, label: 'pluto.nav.home', icon: 'lucide:house', to: '/admin/home' },
34
+ { id: 'settings', order: 1000, label: 'pluto.nav.settings', icon: 'lucide:settings', to: '/admin/settings' },
32
35
  ],
33
36
  pages: [
34
- { id: 'home', path: '/admin/home', title: 'Dashboard', icon: 'lucide:house' },
35
- { id: 'settings', path: '/admin/settings', title: 'Settings', icon: 'lucide:settings' },
37
+ { id: 'home', path: '/admin/home', title: 'pluto.pages.dashboard', icon: 'lucide:house' },
38
+ { id: 'settings', path: '/admin/settings', title: 'pluto.pages.settings', icon: 'lucide:settings' },
36
39
  ],
37
40
  contentFieldWidgets: [
38
41
  { id: 'text', fieldType: 'text', component: PlutoFieldText },
@@ -52,7 +55,7 @@ export default defineNuxtPlugin(() => {
52
55
  {
53
56
  id: 'content-overview',
54
57
  order: 0,
55
- title: 'Content',
58
+ title: 'pluto.dashboard.overview.title',
56
59
  colSpan: 3,
57
60
  component: PlutoDashboardContentOverview,
58
61
  enabled: () => usePlutoContentTypes().items.value.length > 0,
@@ -62,7 +65,7 @@ export default defineNuxtPlugin(() => {
62
65
  {
63
66
  id: 'content-activity',
64
67
  order: 10,
65
- title: 'Content activity',
68
+ title: 'pluto.dashboard.activity.title',
66
69
  colSpan: 2,
67
70
  component: PlutoDashboardContentActivity,
68
71
  enabled: () => usePlutoContentTypes().items.value.length > 0,
@@ -70,7 +73,7 @@ export default defineNuxtPlugin(() => {
70
73
  {
71
74
  id: 'quick-actions',
72
75
  order: 20,
73
- title: 'Quick actions',
76
+ title: 'pluto.dashboard.quickActions.title',
74
77
  colSpan: 1,
75
78
  component: PlutoDashboardQuickActions,
76
79
  },
@@ -0,0 +1,20 @@
1
+ import {
2
+ loadPlutoLocale,
3
+ resolveAdminLocale,
4
+ resolveSiteLocale,
5
+ } from '../composables/internal/i18n-runtime'
6
+
7
+ // Resolves the admin locale and the site locale, and loads their messages and
8
+ // Nuxt UI locale before the first render. The locales live in `useState`, so
9
+ // the client reuses the value the server chose: no mismatch on hydration. The
10
+ // resolve runs only when the state is empty (the server, or a client-only
11
+ // route such as /admin/**).
12
+ export default defineNuxtPlugin({
13
+ name: 'pluto-i18n',
14
+ async setup() {
15
+ const admin = useState<string>('pluto-admin-locale', resolveAdminLocale)
16
+ const site = useState<string>('pluto-site-locale', resolveSiteLocale)
17
+
18
+ await Promise.all([loadPlutoLocale(admin.value), loadPlutoLocale(site.value)])
19
+ },
20
+ })
@@ -0,0 +1,137 @@
1
+ {
2
+ "pluto": {
3
+ "app": {
4
+ "name": "Pluto Admin"
5
+ },
6
+ "common": {
7
+ "save": "Save",
8
+ "cancel": "Cancel",
9
+ "delete": "Delete",
10
+ "tryAgain": "Try again",
11
+ "close": "Close",
12
+ "loading": "Loading...",
13
+ "remove": "Remove",
14
+ "edit": "Edit",
15
+ "refresh": "Refresh",
16
+ "settings": "Settings",
17
+ "create": "Create",
18
+ "error": "Error",
19
+ "switchToLightMode": "Switch to light mode",
20
+ "switchToDarkMode": "Switch to dark mode"
21
+ },
22
+ "nav": {
23
+ "home": "Home",
24
+ "settings": "Settings"
25
+ },
26
+ "pages": {
27
+ "dashboard": "Dashboard",
28
+ "settings": "Settings"
29
+ },
30
+ "palette": {
31
+ "goTo": "Go to",
32
+ "placeholder": "Search pages and actions…"
33
+ },
34
+ "content": {
35
+ "new": "New {label}",
36
+ "edit": "Edit {label}",
37
+ "all": "All {labelPlural}",
38
+ "createNew": "Create new {label}",
39
+ "typeNotFound": "Content type not found",
40
+ "typeNotFoundDescription": "No content type named \"{type}\" is registered.",
41
+ "removeConfirm": "Do you really want to remove this item?"
42
+ },
43
+ "form": {
44
+ "published": "Published",
45
+ "unpublished": "Unpublished",
46
+ "saved": "Saved",
47
+ "updated": "{label} updated successfully.",
48
+ "saveError": "An error occurred while saving.",
49
+ "removeError": "An error occurred while removing.",
50
+ "saveDraft": "Save draft",
51
+ "update": "Update",
52
+ "publish": "Publish",
53
+ "unpublish": "Unpublish",
54
+ "fixErrors": "Please fix the following before saving",
55
+ "removeTitle": "Remove {label}"
56
+ },
57
+ "list": {
58
+ "loading": "Loading {labelPlural}…",
59
+ "loadFailed": "Could not load {labelPlural}",
60
+ "loadFailedHint": "Check your connection and try again.",
61
+ "empty": "No {labelPlural} yet",
62
+ "emptyCanWrite": "Create the first {label} to see it here.",
63
+ "emptyReadOnly": "There is nothing here yet.",
64
+ "refreshFailed": "Could not refresh {labelPlural}",
65
+ "refreshFailedHint": "The list below may be out of date.",
66
+ "emptyTable": "No items yet.",
67
+ "removeTitle": "Remove item",
68
+ "showing": "Showing {loaded} {labelPlural}",
69
+ "showingOf": "Showing {loaded} of {total} {labelPlural}",
70
+ "loadingMore": "Loading more…"
71
+ },
72
+ "fields": {
73
+ "noWidget": "No widget registered for field type \"{type}\".",
74
+ "noOptionsSource": "No options source configured for this reference field."
75
+ },
76
+ "dashboard": {
77
+ "noWidgets": "No dashboard widgets are registered yet. A layer can add one with",
78
+ "overview": {
79
+ "title": "Content",
80
+ "label": "Content overview",
81
+ "loadFailed": "Could not load the content counts",
82
+ "thisWeek": "this week",
83
+ "noneThisWeek": "none new this week",
84
+ "meter": "{published} of {total} {labelPlural} published",
85
+ "published": "published | published | published",
86
+ "drafts": "drafts | draft | drafts"
87
+ },
88
+ "activity": {
89
+ "title": "Content activity",
90
+ "subtitle": "Items created per week, last {weeks} weeks",
91
+ "created": "created | created | created",
92
+ "loadFailed": "Could not load the activity",
93
+ "unavailable": "Activity is not available",
94
+ "unavailableHint": "The content backend cannot count items by creation date yet.",
95
+ "none": "Nothing created in the last {weeks} weeks",
96
+ "noneHint": "New items show up here week by week.",
97
+ "weekOf": "Week of"
98
+ },
99
+ "quickActions": {
100
+ "title": "Quick actions",
101
+ "pressBefore": "Press",
102
+ "pressAfter": "to search everything."
103
+ }
104
+ },
105
+ "settings": {
106
+ "noStorage": "No settings storage",
107
+ "noStorageHint": "No installed layer provides settings storage yet.",
108
+ "language": {
109
+ "cardTitle": "Your preferences",
110
+ "label": "Language",
111
+ "description": "Choose the language of the admin. It applies to your account only.",
112
+ "automatic": "Automatic (browser)",
113
+ "saved": "Language updated",
114
+ "saveFailed": "Could not save your language",
115
+ "saveFailedDescription": "The language changed on this device only. {reason}"
116
+ }
117
+ },
118
+ "errors": {
119
+ "unknown": "Something went wrong.",
120
+ "contentTypeNotFound": "Content type \"{name}\" not found.",
121
+ "noContentAdapter": "No content adapter is registered.",
122
+ "notFound": "Not found.",
123
+ "bodyNotObject": "Request body must be a JSON object.",
124
+ "validationFailed": "Validation failed."
125
+ },
126
+ "validation": {
127
+ "required": "\"{label}\" is required.",
128
+ "number": "\"{label}\" must be a number.",
129
+ "integer": "\"{label}\" must be an integer.",
130
+ "min": "\"{label}\" must be at least {min}.",
131
+ "max": "\"{label}\" must be at most {max}.",
132
+ "boolean": "\"{label}\" must be a boolean.",
133
+ "option": "\"{label}\" must be one of the allowed options.",
134
+ "maxLength": "\"{label}\" must be at most {max} characters."
135
+ }
136
+ }
137
+ }