@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
|
@@ -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
|
-
|
|
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: '
|
|
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:
|
|
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
|
|
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="
|
|
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
|
-
|
|
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="
|
|
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="
|
|
186
|
+
:title="t('pluto.list.loadFailed', { labelPlural: text.labelPluralLower })"
|
|
154
187
|
:actions="emptyActions"
|
|
155
|
-
description="
|
|
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="
|
|
163
|
-
:description="canWrite ?
|
|
195
|
+
:title="t('pluto.list.empty', { labelPlural: text.labelPluralLower })"
|
|
196
|
+
:description="canWrite ? t('pluto.list.emptyCanWrite', { label: text.labelLower }) : t('pluto.list.emptyReadOnly')"
|
|
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: '
|
|
172
|
-
:title="
|
|
173
|
-
description="
|
|
204
|
+
:actions="[{ label: t('pluto.common.tryAgain'), color: 'error', variant: 'outline', onClick: retry }]"
|
|
205
|
+
:title="t('pluto.list.refreshFailed', { labelPlural: text.labelPluralLower })"
|
|
206
|
+
:description="t('pluto.list.refreshFailedHint')"
|
|
174
207
|
color="error"
|
|
175
208
|
variant="subtle"
|
|
176
209
|
icon="lucide:circle-alert"
|
|
177
210
|
/>
|
|
178
211
|
|
|
179
|
-
<div
|
|
180
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
255
|
+
{{ t('pluto.common.remove') }}
|
|
219
256
|
</UButton>
|
|
220
257
|
</div>
|
|
221
258
|
</div>
|
|
222
259
|
</UCard>
|
|
223
260
|
</div>
|
|
224
261
|
|
|
225
|
-
<
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
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
|
-
|
|
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
|
-
|
|
289
|
+
{{ t('pluto.common.remove') }}
|
|
250
290
|
</button>
|
|
251
291
|
</div>
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
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="
|
|
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">
|
|
309
|
+
<ModalHeader @close="closeDeleteModal">{{ t('pluto.list.removeTitle') }}</ModalHeader>
|
|
268
310
|
|
|
269
311
|
<ModalContent>
|
|
270
|
-
<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
|
-
|
|
323
|
+
{{ t('pluto.common.cancel') }}
|
|
282
324
|
</UButton>
|
|
283
325
|
|
|
284
326
|
<UButton icon="lucide:trash" color="error" @click="confirmDelete">
|
|
285
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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">
|
|
49
|
-
<p class="text-muted text-sm">
|
|
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">{{
|
|
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: '
|
|
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="
|
|
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) =>
|
|
92
|
-
:aria-label="
|
|
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>
|
|
101
|
+
<caption>{{ t('pluto.dashboard.activity.subtitle', { weeks: WEEKS }) }}</caption>
|
|
99
102
|
<thead>
|
|
100
103
|
<tr>
|
|
101
|
-
<th scope="col">
|
|
102
|
-
<th v-for="entry in series" :key="entry.name" scope="col">{{ entry
|
|
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
|
|
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="
|
|
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: '
|
|
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
|
-
{{
|
|
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">+{{
|
|
73
|
-
<span class="text-muted">
|
|
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">
|
|
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="
|
|
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">{{
|
|
93
|
-
published ·
|
|
94
|
-
<span class="tabular-nums">{{
|
|
95
|
-
{{ stat.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 !== '
|
|
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">
|
|
27
|
+
<h3 class="text-highlighted font-semibold">{{ t('pluto.dashboard.quickActions.title') }}</h3>
|
|
27
28
|
<p class="text-muted text-sm">
|
|
28
|
-
|
|
29
|
+
{{ t('pluto.dashboard.quickActions.pressBefore') }}
|
|
29
30
|
<UKbd value="meta" size="sm" />
|
|
30
31
|
<UKbd value="k" size="sm" />
|
|
31
|
-
|
|
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">
|
|
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.
|
|
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
|
-
|
|
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>
|