@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
@@ -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
@@ -48,7 +50,29 @@ function editPath(id: string | number) {
48
50
  return contentType.value?.editPath?.(id) ?? `${basePath.value}/${id}`
49
51
  }
50
52
 
51
- const { items, pending, error, refresh, remove } = usePlutoContentList(props.type)
53
+ // Load the list 50 rows at a time. The table is virtualized, so only the
54
+ // visible rows render, and the next page loads when the user scrolls near
55
+ // the end of the table (or of the card list, on mobile).
56
+ const PAGE_SIZE = 50
57
+ const { items, total, pending, loadingMore, hasMore, error, refresh, loadMore, remove } = usePlutoContentList(
58
+ props.type,
59
+ { pageSize: PAGE_SIZE }
60
+ )
61
+
62
+ const tableRef = useTemplateRef<{
63
+ $el: HTMLElement
64
+ }>('table')
65
+ const mobileListRef = useTemplateRef<HTMLElement>('mobileList')
66
+
67
+ function loadMoreSafely() {
68
+ // A failure lands in `error`, and the alert offers "Try again".
69
+ loadMore().catch(() => {})
70
+ }
71
+
72
+ // Stop while an error shows, so a failing page does not load in a loop.
73
+ const canLoadMore = () => hasMore.value && !error.value
74
+ useInfiniteScroll(() => tableRef.value?.$el, loadMoreSafely, { distance: 240, canLoadMore })
75
+ useInfiniteScroll(mobileListRef, loadMoreSafely, { distance: 240, canLoadMore })
52
76
 
53
77
  // What to show while the list has no rows. With rows, the list shows
54
78
  // them, and a failed refresh shows an alert above them instead.
@@ -74,10 +98,10 @@ const emptyActions = computed<ButtonProps[]>(() => {
74
98
  return []
75
99
  }
76
100
  if (emptyState.value === 'error') {
77
- return [{ label: 'Try again', icon: 'lucide:refresh-ccw', color: 'neutral', variant: 'outline', onClick: retry }]
101
+ return [{ label: t('pluto.common.tryAgain'), icon: 'lucide:refresh-ccw', color: 'neutral', variant: 'outline', onClick: retry }]
78
102
  }
79
103
  return canWrite.value
80
- ? [{ label: `New ${type.label}`, icon: 'lucide:plus', onClick: () => navigateTo(newPath.value) }]
104
+ ? [{ label: text.newLabel, icon: 'lucide:plus', onClick: () => navigateTo(newPath.value) }]
81
105
  : []
82
106
  })
83
107
 
@@ -90,7 +114,7 @@ const listFields = computed(() =>
90
114
  const columns = computed<TableColumn<PlutoContentItem>[]>(() => [
91
115
  ...listFields.value.map((field) => ({
92
116
  accessorKey: field.name,
93
- header: field.label,
117
+ header: text.fieldLabel(field),
94
118
  })),
95
119
  { id: 'actions', header: '' },
96
120
  ])
@@ -116,18 +140,27 @@ async function confirmDelete() {
116
140
  await remove(pendingDeleteId.value)
117
141
  closeDeleteModal()
118
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
+ })
119
152
  </script>
120
153
 
121
154
  <template>
122
- <PlutoViewAdminPanel v-if="contentType">
155
+ <PlutoViewAdminPanel v-if="contentType" fill>
123
156
  <template #toolbar>
124
- <PlutoViewToolbar :title="contentType.labelPlural">
157
+ <PlutoViewToolbar :title="text.labelPlural">
125
158
  <template #trailing>
126
159
  <UButton
127
160
  :loading="pending"
161
+ :title="t('pluto.common.refresh')"
128
162
  icon="lucide:refresh-ccw"
129
163
  variant="ghost"
130
- title="Refresh"
131
164
  square
132
165
  @click="retry"
133
166
  />
@@ -135,7 +168,7 @@ async function confirmDelete() {
135
168
 
136
169
  <template v-if="canWrite" #right>
137
170
  <UButton :to="newPath" icon="lucide:plus" as="NuxtLink">
138
- New {{ contentType.label }}
171
+ {{ text.newLabel }}
139
172
  </UButton>
140
173
  </template>
141
174
  </PlutoViewToolbar>
@@ -143,24 +176,24 @@ async function confirmDelete() {
143
176
 
144
177
  <UEmpty
145
178
  v-if="emptyState === 'loading'"
146
- :title="`Loading ${contentType.labelPlural.toLowerCase()}…`"
179
+ :title="t('pluto.list.loading', { labelPlural: text.labelPluralLower })"
147
180
  variant="naked"
148
181
  loading
149
182
  />
150
183
 
151
184
  <UEmpty
152
185
  v-else-if="emptyState === 'error'"
153
- :title="`Could not load ${contentType.labelPlural.toLowerCase()}`"
186
+ :title="t('pluto.list.loadFailed', { labelPlural: text.labelPluralLower })"
154
187
  :actions="emptyActions"
155
- description="Check your connection and try again."
188
+ :description="t('pluto.list.loadFailedHint')"
156
189
  icon="lucide:circle-alert"
157
190
  variant="naked"
158
191
  />
159
192
 
160
193
  <UEmpty
161
194
  v-else-if="emptyState === 'empty'"
162
- :title="`No ${contentType.labelPlural.toLowerCase()} yet`"
163
- :description="canWrite ? `Create the first ${contentType.label.toLowerCase()} to see it here.` : 'There is nothing here yet.'"
195
+ :title="t('pluto.list.empty', { labelPlural: text.labelPluralLower })"
196
+ :description="canWrite ? t('pluto.list.emptyCanWrite', { label: text.labelLower }) : t('pluto.list.emptyReadOnly')"
164
197
  :icon="contentType.icon ?? 'lucide:inbox'"
165
198
  :actions="emptyActions"
166
199
  variant="naked"
@@ -168,16 +201,20 @@ async function confirmDelete() {
168
201
 
169
202
  <UAlert
170
203
  v-if="!emptyState && error"
171
- :actions="[{ label: 'Try again', color: 'error', variant: 'outline', onClick: retry }]"
172
- :title="`Could not refresh ${contentType.labelPlural.toLowerCase()}`"
173
- description="The list below may be out of date."
204
+ :actions="[{ label: t('pluto.common.tryAgain'), color: 'error', variant: 'outline', onClick: retry }]"
205
+ :title="t('pluto.list.refreshFailed', { labelPlural: text.labelPluralLower })"
206
+ :description="t('pluto.list.refreshFailedHint')"
174
207
  color="error"
175
208
  variant="subtle"
176
209
  icon="lucide:circle-alert"
177
210
  />
178
211
 
179
- <div v-if="!emptyState" class="grid gap-3 lg:hidden">
180
- <UCard v-for="item in items" :key="item.id">
212
+ <div
213
+ v-if="!emptyState"
214
+ ref="mobileList"
215
+ class="flex min-h-0 flex-1 flex-col gap-3 overflow-y-auto lg:hidden"
216
+ >
217
+ <UCard v-for="item in items" :key="item.id" class="shrink-0">
181
218
  <div class="flex flex-col gap-4">
182
219
  <div class="min-w-0">
183
220
  <NuxtLink
@@ -190,7 +227,7 @@ async function confirmDelete() {
190
227
 
191
228
  <dl class="grid grid-cols-2 gap-3 text-sm">
192
229
  <div v-for="field in listFields" :key="field.name">
193
- <dt class="text-muted">{{ field.label }}</dt>
230
+ <dt class="text-muted">{{ text.fieldLabel(field) }}</dt>
194
231
  <dd class="font-medium">{{ item[field.name] }}</dd>
195
232
  </div>
196
233
  </dl>
@@ -204,7 +241,7 @@ async function confirmDelete() {
204
241
  variant="soft"
205
242
  class="flex-1 justify-center"
206
243
  >
207
- Edit
244
+ {{ t('pluto.common.edit') }}
208
245
  </UButton>
209
246
 
210
247
  <UButton
@@ -215,29 +252,32 @@ async function confirmDelete() {
215
252
  class="flex-1 justify-center"
216
253
  @click="openDeleteModal(item.id)"
217
254
  >
218
- Remove
255
+ {{ t('pluto.common.remove') }}
219
256
  </UButton>
220
257
  </div>
221
258
  </div>
222
259
  </UCard>
223
260
  </div>
224
261
 
225
- <UCard v-if="!emptyState" :ui="{ body: 'sm:p-0 p-0' }" class="hidden lg:block">
226
- <div class="overflow-x-auto">
227
- <UTable
228
- :data="items"
229
- :columns="columns"
230
- :loading="pending"
231
- empty="No items yet."
232
- >
233
- <template #actions-cell="{ row }">
262
+ <UTable
263
+ v-if="!emptyState"
264
+ ref="table"
265
+ :data="items"
266
+ :columns="columns"
267
+ :loading="pending || loadingMore"
268
+ :empty="t('pluto.list.emptyTable')"
269
+ class="ring-default hidden min-h-0 flex-1 rounded-lg ring lg:block"
270
+ sticky
271
+ virtualize
272
+ >
273
+ <template #actions-cell="{ row }">
234
274
  <div class="flex justify-end gap-3">
235
275
  <NuxtLink
236
276
  v-if="canWrite"
237
277
  :to="editPath(row.original.id)"
238
278
  class="text-info px-0 py-0.5 hover:underline"
239
279
  >
240
- Edit
280
+ {{ t('pluto.common.edit') }}
241
281
  </NuxtLink>
242
282
 
243
283
  <button
@@ -246,28 +286,30 @@ async function confirmDelete() {
246
286
  class="text-error cursor-pointer px-0 py-0.5 hover:underline"
247
287
  @click="openDeleteModal(row.original.id)"
248
288
  >
249
- Remove
289
+ {{ t('pluto.common.remove') }}
250
290
  </button>
251
291
  </div>
252
- </template>
253
- </UTable>
254
- </div>
255
- </UCard>
292
+ </template>
293
+ </UTable>
294
+
295
+ <p v-if="!emptyState" class="text-muted shrink-0 text-xs tabular-nums" aria-live="polite">
296
+ {{ footer }}
297
+ </p>
256
298
  </PlutoViewAdminPanel>
257
299
 
258
300
  <PlutoViewAdminPanel v-else>
259
301
  <UAlert
260
- :description="`No content type named &quot;${type}&quot; is registered.`"
302
+ :description="t('pluto.content.typeNotFoundDescription', { type })"
303
+ :title="t('pluto.content.typeNotFound')"
261
304
  color="error"
262
- title="Content type not found"
263
305
  />
264
306
  </PlutoViewAdminPanel>
265
307
 
266
308
  <Modal v-model="isDeleteModalOpen" :custom-size="480">
267
- <ModalHeader @close="closeDeleteModal">Remove item</ModalHeader>
309
+ <ModalHeader @close="closeDeleteModal">{{ t('pluto.list.removeTitle') }}</ModalHeader>
268
310
 
269
311
  <ModalContent>
270
- <p>Do you really want to remove this item?</p>
312
+ <p>{{ t('pluto.content.removeConfirm') }}</p>
271
313
  </ModalContent>
272
314
 
273
315
  <ModalFooter>
@@ -278,11 +320,11 @@ async function confirmDelete() {
278
320
  color="neutral"
279
321
  @click="closeDeleteModal"
280
322
  >
281
- Cancel
323
+ {{ t('pluto.common.cancel') }}
282
324
  </UButton>
283
325
 
284
326
  <UButton icon="lucide:trash" color="error" @click="confirmDelete">
285
- Remove
327
+ {{ t('pluto.common.remove') }}
286
328
  </UButton>
287
329
  </div>
288
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
  />
@@ -7,11 +7,12 @@
7
7
  const WEEKS = 12
8
8
 
9
9
  const { data: series, status, refresh } = usePlutoContentActivity(WEEKS)
10
+ const { t, n, d } = usePlutoI18n()
11
+ const texts = usePlutoContentTexts()
10
12
 
11
- const number = new Intl.NumberFormat()
12
13
  // The week starts are UTC dates. Format them in UTC so a viewer west of
13
14
  // UTC does not see the previous day.
14
- const weekLabel = new Intl.DateTimeFormat(undefined, { month: 'short', day: 'numeric', timeZone: 'UTC' })
15
+ const weekLabel = (start: string) => d(new Date(`${start}T00:00:00Z`), { month: 'short', day: 'numeric', timeZone: 'UTC' })
15
16
 
16
17
  const rows = computed(() => {
17
18
  const first = series.value[0]
@@ -19,7 +20,7 @@ const rows = computed(() => {
19
20
  return []
20
21
  }
21
22
  return first.points.map((point, index) => {
22
- const row: Record<string, string | number> = { week: weekLabel.format(new Date(`${point.start}T00:00:00Z`)) }
23
+ const row: Record<string, string | number> = { week: weekLabel(point.start) }
23
24
  for (const entry of series.value) {
24
25
  row[entry.name] = entry.points[index]?.count ?? 0
25
26
  }
@@ -27,7 +28,9 @@ const rows = computed(() => {
27
28
  })
28
29
  })
29
30
 
30
- const categories = computed(() => series.value.map((entry) => ({ key: entry.name, label: entry.labelPlural })))
31
+ // The series carry the English labels. The content text resolves the keys.
32
+ const seriesLabel = (entry: PlutoContentActivitySeries) => texts(entry.name).labelPlural
33
+ const categories = computed(() => series.value.map((entry) => ({ key: entry.name, label: seriesLabel(entry) })))
31
34
 
32
35
  const total = computed(() =>
33
36
  series.value.reduce((sum, entry) => sum + entry.points.reduce((acc, point) => acc + point.count, 0), 0)
@@ -45,12 +48,12 @@ function retry() {
45
48
  <template #header>
46
49
  <div class="flex items-start justify-between gap-4">
47
50
  <div>
48
- <h3 class="text-highlighted font-semibold">Content activity</h3>
49
- <p class="text-muted text-sm">Items created per week, last {{ WEEKS }} weeks</p>
51
+ <h3 class="text-highlighted font-semibold">{{ t('pluto.dashboard.activity.title') }}</h3>
52
+ <p class="text-muted text-sm">{{ t('pluto.dashboard.activity.subtitle', { weeks: WEEKS }) }}</p>
50
53
  </div>
51
54
  <div v-if="status === 'success' && series.length" class="text-right">
52
- <p class="text-highlighted text-2xl font-semibold tabular-nums">{{ number.format(total) }}</p>
53
- <p class="text-muted text-xs">created</p>
55
+ <p class="text-highlighted text-2xl font-semibold tabular-nums">{{ n(total) }}</p>
56
+ <p class="text-muted text-xs">{{ t('pluto.dashboard.activity.created', total) }}</p>
54
57
  </div>
55
58
  </div>
56
59
  </template>
@@ -59,26 +62,26 @@ function retry() {
59
62
 
60
63
  <UAlert
61
64
  v-else-if="status === 'error'"
62
- :actions="[{ label: 'Try again', color: 'error', variant: 'outline', onClick: retry }]"
65
+ :actions="[{ label: t('pluto.common.tryAgain'), color: 'error', variant: 'outline', onClick: retry }]"
66
+ :title="t('pluto.dashboard.activity.loadFailed')"
63
67
  color="error"
64
68
  variant="subtle"
65
69
  icon="lucide:circle-alert"
66
- title="Could not load the activity"
67
70
  />
68
71
 
69
72
  <UEmpty
70
73
  v-else-if="!series.length"
74
+ :title="t('pluto.dashboard.activity.unavailable')"
75
+ :description="t('pluto.dashboard.activity.unavailableHint')"
71
76
  icon="lucide:chart-line"
72
- title="Activity is not available"
73
- description="The content backend cannot count items by creation date yet."
74
77
  variant="naked"
75
78
  />
76
79
 
77
80
  <UEmpty
78
81
  v-else-if="total === 0"
79
- :title="`Nothing created in the last ${WEEKS} weeks`"
82
+ :title="t('pluto.dashboard.activity.none', { weeks: WEEKS })"
83
+ :description="t('pluto.dashboard.activity.noneHint')"
80
84
  icon="lucide:chart-line"
81
- description="New items show up here week by week."
82
85
  variant="naked"
83
86
  />
84
87
 
@@ -88,18 +91,18 @@ function retry() {
88
91
  :data="rows"
89
92
  :categories="categories"
90
93
  :height="256"
91
- :format="(value: number) => number.format(value)"
92
- :aria-label="`Items created per week, last ${WEEKS} weeks`"
94
+ :format="(value: number) => n(value)"
95
+ :aria-label="t('pluto.dashboard.activity.subtitle', { weeks: WEEKS })"
93
96
  index="week"
94
97
  curve="monotone"
95
98
  />
96
99
 
97
100
  <table class="sr-only">
98
- <caption>Items created per week, last {{ WEEKS }} weeks</caption>
101
+ <caption>{{ t('pluto.dashboard.activity.subtitle', { weeks: WEEKS }) }}</caption>
99
102
  <thead>
100
103
  <tr>
101
- <th scope="col">Week of</th>
102
- <th v-for="entry in series" :key="entry.name" scope="col">{{ entry.labelPlural }}</th>
104
+ <th scope="col">{{ t('pluto.dashboard.activity.weekOf') }}</th>
105
+ <th v-for="entry in series" :key="entry.name" scope="col">{{ seriesLabel(entry) }}</th>
103
106
  </tr>
104
107
  </thead>
105
108
  <tbody>
@@ -9,7 +9,8 @@ const { items: contentTypes } = usePlutoContentTypes()
9
9
  const { stats, pending, error, refresh } = usePlutoContentStats()
10
10
  const { data: activity } = usePlutoContentActivity()
11
11
 
12
- const number = new Intl.NumberFormat()
12
+ const { t, n } = usePlutoI18n()
13
+ const texts = usePlutoContentTexts()
13
14
 
14
15
  function retry() {
15
16
  refresh().catch(() => {})
@@ -26,14 +27,14 @@ function thisWeek(stat: PlutoContentStat): number | undefined {
26
27
  </script>
27
28
 
28
29
  <template>
29
- <section v-if="contentTypes.length" aria-label="Content overview">
30
+ <section v-if="contentTypes.length" :aria-label="t('pluto.dashboard.overview.label')">
30
31
  <UAlert
31
32
  v-if="error && !stats.length"
32
- :actions="[{ label: 'Try again', color: 'error', variant: 'outline', onClick: retry }]"
33
+ :actions="[{ label: t('pluto.common.tryAgain'), color: 'error', variant: 'outline', onClick: retry }]"
34
+ :title="t('pluto.dashboard.overview.loadFailed')"
33
35
  color="error"
34
36
  variant="subtle"
35
37
  icon="lucide:circle-alert"
36
- title="Could not load the content counts"
37
38
  />
38
39
 
39
40
  <div v-else class="grid grid-cols-1 gap-4 sm:grid-cols-2 xl:grid-cols-3">
@@ -57,7 +58,7 @@ function thisWeek(stat: PlutoContentStat): number | undefined {
57
58
  <UCard class="group-hover:ring-accented h-full transition-shadow group-hover:shadow-sm">
58
59
  <div class="flex flex-col gap-3">
59
60
  <div class="flex items-start justify-between gap-3">
60
- <p class="text-muted text-sm font-medium">{{ stat.labelPlural }}</p>
61
+ <p class="text-muted text-sm font-medium">{{ texts(stat.name).labelPlural }}</p>
61
62
  <span class="bg-primary/10 text-primary flex size-8 shrink-0 items-center justify-center rounded-md">
62
63
  <UIcon :name="stat.icon ?? 'lucide:file'" class="size-4" />
63
64
  </span>
@@ -65,14 +66,14 @@ function thisWeek(stat: PlutoContentStat): number | undefined {
65
66
 
66
67
  <div class="flex items-baseline gap-2">
67
68
  <p class="text-highlighted text-3xl font-semibold tracking-tight tabular-nums">
68
- {{ number.format(stat.total) }}
69
+ {{ n(stat.total) }}
69
70
  </p>
70
71
  <p v-if="thisWeek(stat) !== undefined" class="text-sm">
71
72
  <template v-if="thisWeek(stat)">
72
- <span class="text-success font-medium tabular-nums">+{{ number.format(thisWeek(stat)!) }}</span>
73
- <span class="text-muted"> this week</span>
73
+ <span class="text-success font-medium tabular-nums">+{{ n(thisWeek(stat)!) }}</span>
74
+ <span class="text-muted"> {{ t('pluto.dashboard.overview.thisWeek') }}</span>
74
75
  </template>
75
- <span v-else class="text-dimmed">none new this week</span>
76
+ <span v-else class="text-dimmed">{{ t('pluto.dashboard.overview.noneThisWeek') }}</span>
76
77
  </p>
77
78
  </div>
78
79
 
@@ -80,7 +81,7 @@ function thisWeek(stat: PlutoContentStat): number | undefined {
80
81
  <div
81
82
  :aria-valuenow="stat.published"
82
83
  :aria-valuemax="stat.total"
83
- :aria-label="`${stat.published} of ${stat.total} ${stat.labelPlural.toLowerCase()} published`"
84
+ :aria-label="t('pluto.dashboard.overview.meter', { published: stat.published, total: stat.total, labelPlural: texts(stat.name).labelPluralLower })"
84
85
  role="meter"
85
86
  aria-valuemin="0"
86
87
  class="bg-accented flex h-1.5 overflow-hidden rounded-full"
@@ -89,10 +90,10 @@ function thisWeek(stat: PlutoContentStat): number | undefined {
89
90
  </div>
90
91
 
91
92
  <p class="text-muted text-sm">
92
- <span class="text-highlighted font-medium tabular-nums">{{ number.format(stat.published) }}</span>
93
- published ·
94
- <span class="tabular-nums">{{ number.format(stat.drafts ?? 0) }}</span>
95
- {{ stat.drafts === 1 ? 'draft' : 'drafts' }}
93
+ <span class="text-highlighted font-medium tabular-nums">{{ n(stat.published) }}</span>
94
+ {{ t('pluto.dashboard.overview.published', stat.published) }} ·
95
+ <span class="tabular-nums">{{ n(stat.drafts ?? 0) }}</span>
96
+ {{ t('pluto.dashboard.overview.drafts', stat.drafts ?? 0) }}
96
97
  </p>
97
98
  </template>
98
99
  </div>
@@ -3,13 +3,14 @@
3
3
  // the two never disagree: every "New <label>" command the user can run, and
4
4
  // a few admin destinations.
5
5
  const groups = usePlutoCommandPalette()
6
+ const { t } = usePlutoI18n()
6
7
 
7
8
  const create = computed(() => groups.value.find((group) => group.id === 'pluto-create')?.items ?? [])
8
9
 
9
10
  const goTo = computed(() =>
10
11
  (groups.value.find((group) => group.id === 'pluto-go-to')?.items ?? [])
11
12
  // Home is the page this widget is on.
12
- .filter((item) => item.label !== 'Home')
13
+ .filter((item) => item.label !== t('pluto.nav.home'))
13
14
  .slice(0, 4)
14
15
  )
15
16
 
@@ -23,12 +24,12 @@ function run(item: {
23
24
  <template>
24
25
  <UCard class="h-full">
25
26
  <template #header>
26
- <h3 class="text-highlighted font-semibold">Quick actions</h3>
27
+ <h3 class="text-highlighted font-semibold">{{ t('pluto.dashboard.quickActions.title') }}</h3>
27
28
  <p class="text-muted text-sm">
28
- Press
29
+ {{ t('pluto.dashboard.quickActions.pressBefore') }}
29
30
  <UKbd value="meta" size="sm" />
30
31
  <UKbd value="k" size="sm" />
31
- to search everything.
32
+ {{ t('pluto.dashboard.quickActions.pressAfter') }}
32
33
  </p>
33
34
  </template>
34
35
 
@@ -49,7 +50,7 @@ function run(item: {
49
50
  </div>
50
51
 
51
52
  <div v-if="goTo.length" class="flex flex-col gap-1">
52
- <p class="text-dimmed text-xs font-medium tracking-wide uppercase">Go to</p>
53
+ <p class="text-dimmed text-xs font-medium tracking-wide uppercase">{{ t('pluto.palette.goTo') }}</p>
53
54
  <UButton
54
55
  v-for="item in goTo"
55
56
  :key="item.label"
@@ -3,11 +3,14 @@
3
3
  <template>
4
4
  <!--
5
5
  `data-pluto-admin-scope` keeps the admin theme on this bar when it shows
6
- on the public site for a logged-in admin. See admin-theme.css.
6
+ on the public site for a logged-in admin. The bar is near black in both
7
+ color modes, so `data-pluto-admin-dark` gives it the dark tokens.
8
+ See admin-theme.css.
7
9
  -->
8
10
  <nav
9
11
  class="text-default fixed inset-0 z-50 h-(--ui-header-height) shrink-0 border-b border-default bg-(--pluto-admin-navbar)"
10
12
  data-pluto-admin-scope
13
+ data-pluto-admin-dark
11
14
  >
12
15
  <slot />
13
16
  </nav>
@@ -1,5 +1,6 @@
1
1
  <script setup lang="ts">
2
2
  const { items } = usePlutoDashboardWidgets()
3
+ const { t } = usePlutoI18n()
3
4
  </script>
4
5
 
5
6
  <template>
@@ -15,7 +16,7 @@ const { items } = usePlutoDashboardWidgets()
15
16
 
16
17
  <UCard v-else>
17
18
  <p class="text-muted text-sm">
18
- No dashboard widgets are registered yet. A layer can add one with
19
+ {{ t('pluto.dashboard.noWidgets') }}
19
20
  <code>definePlutoExtension</code>.
20
21
  </p>
21
22
  </UCard>
@@ -1,10 +1,27 @@
1
1
  <script setup lang="ts">
2
2
  const { component: navbar } = usePlutoNavbarShell()
3
+
4
+ // One Nuxt UI locale per page: the admin's on /admin routes, the site's on the
5
+ // rest. The admin navbar on a public page keeps the site locale for Nuxt UI
6
+ // text, but its own labels use the admin locale through usePlutoI18n().
7
+ const route = useRoute()
8
+ const admin = usePlutoI18n()
9
+ const site = usePlutoSiteI18n()
10
+ const isAdmin = computed(() => route.path === '/admin' || route.path.startsWith('/admin/'))
11
+ const active = computed(() => (isAdmin.value ? admin : site))
12
+ const uiLocale = computed(() => active.value.uiLocale.value)
13
+
14
+ useHead({
15
+ htmlAttrs: {
16
+ lang: computed(() => active.value.locale.value),
17
+ dir: computed(() => uiLocale.value?.dir ?? 'ltr'),
18
+ },
19
+ })
3
20
  </script>
4
21
 
5
22
  <template>
6
23
  <div class="flex min-h-0 min-w-0 w-full grow flex-col">
7
- <UApp class="flex min-h-0 min-w-0 w-full grow flex-col">
24
+ <UApp :locale="uiLocale" class="flex min-h-0 min-w-0 w-full grow flex-col">
8
25
  <NuxtRouteAnnouncer />
9
26
  <NuxtLoadingIndicator />
10
27
 
@@ -0,0 +1,51 @@
1
+ <script setup lang="ts">
2
+ import { getLanguageName } from '../../../composables/internal/i18n-core'
3
+
4
+ // The select cannot hold null, so Automatic uses this value. It is not a
5
+ // valid locale code, so it never clashes with one.
6
+ const AUTOMATIC = '__automatic__'
7
+
8
+ const { t, locales, preference, setLocale } = usePlutoI18n()
9
+ const toast = useToast()
10
+
11
+ const items = computed(() => [
12
+ { label: t('pluto.settings.language.automatic'), value: AUTOMATIC },
13
+ // Each language is named in its own language, so a user finds it.
14
+ ...locales.value.map((code) => ({ label: getLanguageName(code), value: code })),
15
+ ])
16
+
17
+ const selected = computed(() => preference.value ?? AUTOMATIC)
18
+
19
+ async function onChange(value: string) {
20
+ try {
21
+ await setLocale(value === AUTOMATIC ? null : value)
22
+ toast.add({ title: t('pluto.settings.language.saved'), color: 'success' })
23
+ } catch (error) {
24
+ // The switch stays on this device. Only the save failed.
25
+ toast.add({
26
+ title: t('pluto.settings.language.saveFailed'),
27
+ description: t('pluto.settings.language.saveFailedDescription', {
28
+ reason: error instanceof Error ? error.message : String(error),
29
+ }),
30
+ color: 'error',
31
+ })
32
+ }
33
+ }
34
+ </script>
35
+
36
+ <template>
37
+ <UCard>
38
+ <template #header>
39
+ <h2 class="font-semibold">{{ t('pluto.settings.language.cardTitle') }}</h2>
40
+ </template>
41
+
42
+ <UFormField :label="t('pluto.settings.language.label')" :description="t('pluto.settings.language.description')">
43
+ <USelect
44
+ :model-value="selected"
45
+ :items="items"
46
+ class="w-full sm:w-64"
47
+ @update:model-value="onChange"
48
+ />
49
+ </UFormField>
50
+ </UCard>
51
+ </template>