feat(SPA): i18n internationzalization, english and french

This commit is contained in:
fsylvain committed 2026-10-03 01:53:49 +02:00
1 parent 7f1e8e572d
commit 721311a643
88 files changed
+4438 -1148

No files matched your search

+79
View File
@@ -0,0 +1,79 @@
Feature: English and French throughout the SPA
Background:
Given all existing SPA interface text supports English and French
And English preserves existing copy and French provides equivalent meaning
And user content, API data and transactional emails remain unchanged
And visual checks are waived by the user for this slice
And the language control uses existing design tokens
Scenario Outline: Resolve the language before rendering
Given the browser-local stored language is <stored>
When the SPA starts
Then the interface language and document lang are <locale>
And browser language does not override the choice
Examples:
| stored | locale |
| absent | en |
| en | en |
| fr | fr |
| unsupported | en |
| unreadable | en |
Scenario Outline: Open the language picker in the profile submenu
Given the user opens the <menu>
When they activate the localized Language control
Then a compact scrollable picker offers English and Français
And the selected language is visibly and accessibly identified
And the picker uses Nuxt UI and existing design tokens
Examples:
| menu |
| desktop UserMenu |
| mobile AccountMenuSheet |
Scenario: Switch language without losing work
Given the user is editing an unsaved display name on /profile
When they select Français
Then all SPA interface text immediately uses French
And the document lang is fr
And the route, session, theme and unsaved work remain unchanged
And no reload, navigation or API request is caused by switching
When they select English
Then the interface immediately restores English with the same preserved state
Scenario: Remember language on this browser
Given the user selected Français
When they navigate, reload, log out or return on this browser
Then French remains selected including on public authentication screens
And no language preference is sent to the server
Scenario: Storage failure does not prevent switching
Given preference storage cannot be written
When the user selects Français
Then French is used for the current session without an error
And a fresh load without a readable preference defaults to English
Scenario: Translate the entire interface
Given French is selected
When an existing SPA screen or shared component renders
Then navigation, forms, validation, notifications, dialogs and accessibility labels use French
And counts, human-readable dates and numbers use the selected locale
And Nuxt UI controls use the selected locale
And English and French catalogs have matching keys and interpolation parameters
And an unavailable French message falls back to English
And user content, account details, filenames, URLs, API data and emails remain unchanged
Scenario Outline: Operate the picker accessibly
Given the picker is open in the <menu>
When the user navigates its options with the keyboard and confirms a choice
Then the language changes and the picker closes
And focus returns to its language control
When they reopen it and press Escape or click outside
Then the picker closes without changing language
Examples:
| menu |
| desktop UserMenu |
| mobile AccountMenuSheet |
+21
View File
@@ -0,0 +1,21 @@
# SPA internationalization QA
Accepted by the user on 2026-10-03 together with the slice specification.
## Automated
1. Verify absent, English, French, unsupported and unreadable stored preferences; English defaults independently of browser language and document lang follows selection.
2. Verify desktop UserMenu and mobile AccountMenuSheet offer a localized language entry and a compact bounded scrollable picker with English and Français, selected-state semantics, selection, dismissal and focus return. Preserve profile, theme and logout behavior.
3. Switch both ways while editing an unsaved profile display name. Verify translated UI and unchanged draft, route, session and theme without additional API requests.
4. Verify browser-local persistence across reload initialization, navigation and logout/public authentication. Verify storage write failure allows switching for the current session.
5. Preserve existing English tests and add French checks for login, validation, profile save failure, integration copy feedback, not-found, representative dialogs and notifications.
6. Audit every existing view, shared component, composable and library producing interface text, including editor, media, users, invitations, settings and updates. Verify Nuxt UI controls use the active locale.
7. Assert catalog key/interpolation parity, nonempty translations, pluralization, English fallback and locale-sensitive formatting. Use fixed input dates without freezing system time. Verify displayed alerts and dialogs react to language changes.
8. Verify user content, account details, filenames, URLs, API fields, integration protocol examples, server release notes and transactional emails remain unchanged.
9. Run task test:web, frontend type checks, applicable lint and final build checks. Inspect introduced comments and ensure application changes are SPA-only.
## Visual and manual
Visual checks are SKIPPED by explicit user waiver for this slice because the designer is unavailable. The user authorized a coherent language control using existing design tokens. Do not edit design sources or claim pixel parity.
Manual checklist: desktop/mobile and light/dark coherence, viewport containment, constrained scrolling, French text readability, touch and mouse selection, keyboard movement and confirmation, Escape/outside dismissal, focus return and visible focus. Confirm language retention after reload and logout. Do not freeze dates or investigate clock-sensitive states.
+3 -1
View File
@@ -1,5 +1,7 @@
<script setup lang="ts">
import { computed } from 'vue'
import { en, fr } from '@nuxt/ui/locale'
import { locale } from '@/i18n'
import { RouterView } from 'vue-router'
import { toastsAboveBulkBar } from '@/composables/useToastOffset'
@@ -11,7 +13,7 @@ const toaster = computed(() => ({
</script>
<template>
<UApp class="isolate" :toaster="toaster">
<UApp class="isolate" :toaster="toaster" :locale="locale === 'fr' ? fr : en">
<RouterView />
</UApp>
</template>
+18 -6
View File
@@ -1,9 +1,12 @@
<script setup lang="ts">
import { t } from '@/i18n'
import { ref } from 'vue'
import type { ColorModePreference } from '@/composables/useColorMode'
import { colorModeOptions, useAccountMenu } from '@/composables/useAccountMenu'
import LanguagePicker from '@/components/LanguagePicker.vue'
const ITEM_SELECTOR = '[role="menuitem"], [role="menuitemcheckbox"]'
const ITEM_SELECTOR = '[role="menuitem"], [role="menuitemcheckbox"], [role="combobox"]'
const ITEM_CLASS = 'h-12 justify-start gap-3 px-4 text-[0.9375rem] text-highlighted'
const props = defineProps<{
@@ -43,6 +46,7 @@ function logOut() {
}
function moveFocus(event: KeyboardEvent) {
if ((event.target as HTMLElement).closest('[role="combobox"]')) return
const items = Array.from(menu.value?.querySelectorAll<HTMLElement>(ITEM_SELECTOR) ?? [])
if (items.length === 0) {
return
@@ -68,8 +72,8 @@ function moveFocus(event: KeyboardEvent) {
<USlideover
v-model:open="open"
side="bottom"
title="Account"
description="Your account menu"
:title="t('Account')"
:description="t('Your account menu')"
:close="false"
:ui="{
content: 'max-h-none',
@@ -78,7 +82,13 @@ function moveFocus(event: KeyboardEvent) {
}"
>
<template #body>
<div ref="menu" role="menu" aria-label="Account" class="pt-2 pb-6" @keydown="moveFocus">
<div
ref="menu"
role="menu"
:aria-label="t('Account')"
class="pt-2 pb-6"
@keydown="moveFocus"
>
<div class="mx-auto mb-2 h-1 w-9 bg-accented" aria-hidden="true" />
<div class="mb-1 flex items-center gap-3 border-b border-default px-4 pt-2 pb-3.5">
<UAvatar
@@ -107,9 +117,11 @@ function moveFocus(event: KeyboardEvent) {
:ui="{ leadingIcon: 'size-4.5' }"
@click="goToProfile"
>
Profile
{{ t('Profile') }}
</UButton>
<USeparator class="my-1" />
<LanguagePicker mobile />
<USeparator class="my-1" />
<UButton
@@ -142,7 +154,7 @@ function moveFocus(event: KeyboardEvent) {
:ui="{ leadingIcon: 'size-4.5' }"
@click="logOut"
>
Log out
{{ t('Log out') }}
</UButton>
</div>
</template>
+4 -2
View File
@@ -1,7 +1,9 @@
<script setup lang="ts"></script>
<script setup lang="ts">
import { t } from '@/i18n'
</script>
<template>
<div class="space-y-4 p-6" role="status" aria-label="Loading">
<div class="space-y-4 p-6" role="status" :aria-label="t('Loading')">
<USkeleton class="h-6 w-40" />
<USkeleton class="h-32 w-full" />
<USkeleton class="h-32 w-full" />
+17 -6
View File
@@ -1,4 +1,6 @@
<script setup lang="ts">
import { t } from '@/i18n'
import { useRouter } from 'vue-router'
import ArticleStatusBadge from '@/components/ArticleStatusBadge.vue'
import { formatRelativeDate } from '@/lib/relativeDate'
@@ -39,12 +41,16 @@ function actionsFor(article: Article) {
return [
[
{
label: 'Edit',
get label() {
return t('Edit')
},
icon: 'i-lucide-pencil',
onSelect: () => router.push(editPath(article)),
},
{
label: article.is_online ? 'Unpublish' : 'Publish',
get label() {
return article.is_online ? t('Unpublish') : t('Publish')
},
icon: article.is_online ? 'i-lucide-globe-lock' : 'i-lucide-globe',
disabled: isBusy(article),
onSelect: () => {
@@ -56,14 +62,18 @@ function actionsFor(article: Article) {
},
},
{
label: 'Copy API URL',
get label() {
return t('Copy API URL')
},
icon: 'i-lucide-link',
onSelect: () => emit('copy-url', article.id),
},
],
[
{
label: 'Delete…',
get label() {
return t('Delete…')
},
icon: 'i-lucide-trash-2',
color: 'error' as const,
onSelect: () => emit('delete', article),
@@ -122,7 +132,8 @@ function onRowClick(article: Article, event: MouseEvent) {
:pending="pendingFor(article)"
/>
<span :title="article.updated_at"
>Updated {{ formatRelativeDate(article.updated_at, now) }}</span
>{{ t('Updated') }}
{{ formatRelativeDate(article.updated_at, now) }}</span
>
</div>
</div>
@@ -135,7 +146,7 @@ function onRowClick(article: Article, event: MouseEvent) {
icon="i-lucide-ellipsis-vertical"
color="neutral"
variant="ghost"
:aria-label="`Actions for ${article.title}`"
:aria-label="t('Actions for {0}', [article.title])"
class="size-11 justify-center text-toned"
@click.stop
/>
+50 -31
View File
@@ -1,6 +1,9 @@
<script setup lang="ts">
import { t, locale, formatNumber } from '@/i18n'
import { formatAbsoluteDateFromIso } from '@/lib/relativeDate'
import { computed } from 'vue'
import { useToast } from '@nuxt/ui/composables'
import { useLocalizedToast } from '@/i18n/presentation'
import type { Article } from '@/composables/useArticles'
import { bodyImageCount } from '@/lib/bodyImageIds'
@@ -8,7 +11,7 @@ const props = defineProps<{ article: Article }>()
const emit = defineEmits<{ 'copy-url': [] }>()
const toast = useToast()
const toast = useLocalizedToast()
const open = defineModel<boolean>('open', { default: false })
@@ -43,9 +46,11 @@ function formatDetailDate(iso: string, relative = false) {
const hours = date.getHours().toString().padStart(2, '0')
const minutes = date.getMinutes().toString().padStart(2, '0')
if (relative && isSameDay(date, new Date())) {
return `Today, ${hours}:${minutes}`
return t('Today, {0}:{1}', [hours, minutes])
}
return `${date.getDate()} ${MONTH_NAMES[date.getMonth()]} ${date.getFullYear()}, ${hours}:${minutes}`
return locale.value === 'fr'
? `${formatAbsoluteDateFromIso(iso)}, ${hours}:${minutes}`
: `${date.getDate()} ${MONTH_NAMES[date.getMonth()]} ${date.getFullYear()}, ${hours}:${minutes}`
}
const createdLabel = computed(() => formatDetailDate(props.article.created_at))
@@ -54,29 +59,37 @@ const publicUrl = computed(() => `/v1/article/${props.article.id}`)
const imagesCount = computed(() => bodyImageCount(props.article.body))
const apiNote = computed(() =>
props.article.is_online
? 'Live. Your website can read this article.'
: 'Returns 404 until the article is online.',
? t('Live. Your website can read this article.')
: t('Returns 404 until the article is online.'),
)
async function copyUrl() {
const url = `${window.location.origin}${publicUrl.value}`
try {
await navigator.clipboard.writeText(url)
toast.add({
toast.add(() => ({
id: `article-link-copied-${props.article.id}`,
color: 'success',
icon: 'i-lucide-circle-check',
title: 'Link copied',
description: `The API URL for "${props.article.title}" is on your clipboard.`,
})
get title() {
return t('Link copied')
},
get description() {
return t('The API URL for \u0022{0}\u0022 is on your clipboard.', [
props.article.title,
])
},
}))
emit('copy-url')
} catch {
toast.add({
toast.add(() => ({
id: `article-link-copy-failed-${props.article.id}`,
color: 'error',
icon: 'i-lucide-circle-x',
title: "Couldn't copy the link",
})
get title() {
return t("Couldn't copy the link")
},
}))
}
}
</script>
@@ -93,20 +106,24 @@ async function copyUrl() {
}"
>
<template #header>
<h2 class="text-base font-semibold text-highlighted">Details</h2>
<h2 class="text-base font-semibold text-highlighted">
{{ t('Details') }}
</h2>
</template>
<dl class="grid grid-cols-[auto_1fr] gap-x-3 gap-y-2 text-sm">
<dt class="text-muted">Created</dt>
<dt class="text-muted">{{ t('Created') }}</dt>
<dd class="text-highlighted">{{ createdLabel }}</dd>
<dt class="text-muted">Updated</dt>
<dt class="text-muted">{{ t('Updated') }}</dt>
<dd class="text-highlighted">{{ updatedLabel }}</dd>
<dt class="text-muted">ID</dt>
<dd class="font-mono text-highlighted">{{ article.id }}</dd>
<dt class="text-muted">Images</dt>
<dd class="text-highlighted">{{ imagesCount }}</dd>
<dt class="text-muted">{{ t('Images') }}</dt>
<dd class="text-highlighted">
{{ formatNumber(imagesCount) }}
</dd>
</dl>
<div class="flex flex-col gap-1.5 text-sm">
<p class="text-muted">Public API URL</p>
<p class="text-muted">{{ t('Public API URL') }}</p>
<div class="flex items-center gap-1.5 bg-elevated px-2 py-1.5 font-mono text-xs">
<span class="flex-1">{{ publicUrl }}</span>
<UButton
@@ -114,7 +131,7 @@ async function copyUrl() {
color="neutral"
variant="ghost"
size="sm"
aria-label="Copy the public API URL"
:aria-label="t('Copy the public API URL')"
class="size-5 p-0"
@click="copyUrl"
/>
@@ -122,28 +139,30 @@ async function copyUrl() {
<p class="text-xs/[18px] text-muted">{{ apiNote }}</p>
</div>
<div class="flex flex-col gap-1 border-t border-default pt-3 text-sm">
<p class="text-muted">Title</p>
<p class="text-xs/[18px] text-toned">{{ TITLE_NOTE }}</p>
<p class="text-muted">{{ t('Title') }}</p>
<p class="text-xs/[18px] text-toned">{{ t(TITLE_NOTE) }}</p>
</div>
</UCard>
<USlideover v-model:open="open" side="right" title="Details" class="lg:hidden">
<USlideover v-model:open="open" side="right" :title="t('Details')" class="lg:hidden">
<template #body>
<div class="flex flex-col gap-4 text-sm">
<dl class="grid grid-cols-[auto_1fr] gap-x-3 gap-y-2">
<dt class="text-muted">Created</dt>
<dt class="text-muted">{{ t('Created') }}</dt>
<dd class="text-highlighted">{{ createdLabel }}</dd>
<dt class="text-muted">Updated</dt>
<dt class="text-muted">{{ t('Updated') }}</dt>
<dd class="text-highlighted">{{ updatedLabel }}</dd>
<dt class="text-muted">ID</dt>
<dd class="font-mono text-highlighted">
{{ article.id }}
</dd>
<dt class="text-muted">Images</dt>
<dd class="text-highlighted">{{ imagesCount }}</dd>
<dt class="text-muted">{{ t('Images') }}</dt>
<dd class="text-highlighted">
{{ formatNumber(imagesCount) }}
</dd>
</dl>
<div class="flex flex-col gap-1.5">
<p class="text-muted">Public API URL</p>
<p class="text-muted">{{ t('Public API URL') }}</p>
<div
class="flex items-center gap-1.5 bg-elevated px-2 py-1.5 font-mono text-xs"
>
@@ -153,7 +172,7 @@ async function copyUrl() {
color="neutral"
variant="ghost"
size="sm"
aria-label="Copy the public API URL"
:aria-label="t('Copy the public API URL')"
class="size-5 p-0"
@click="copyUrl"
/>
@@ -161,9 +180,9 @@ async function copyUrl() {
<p class="text-xs/[18px] text-muted">{{ apiNote }}</p>
</div>
<div class="flex flex-col gap-1 border-t border-default pt-3">
<p class="text-muted">Title</p>
<p class="text-muted">{{ t('Title') }}</p>
<p class="text-xs/[18px] text-toned">
{{ TITLE_NOTE }}
{{ t(TITLE_NOTE) }}
</p>
</div>
</div>
+127 -29
View File
@@ -1,4 +1,6 @@
<script setup lang="ts">
import { t } from '@/i18n'
import { ref } from 'vue'
import type { EditorToolbarItem } from '@nuxt/ui'
import type { Editor } from '@tiptap/core'
@@ -42,10 +44,10 @@ function onUpdate(value: string) {
function currentBlockLabel(editor: Editor) {
for (let level = 1; level <= 4; level += 1) {
if (editor.isActive('heading', { level })) {
return `Heading ${level}`
return t('Heading {0}', [level])
}
}
return 'Paragraph'
return t('Paragraph')
}
function isDefaultAlignment(editor: Editor) {
@@ -58,12 +60,20 @@ function buildItems(editor: Editor): EditorToolbarItem[][] {
{
kind: 'undo',
icon: 'i-lucide-undo-2',
tooltip: { text: 'Undo' },
tooltip: {
get text() {
return t('Undo')
},
},
},
{
kind: 'redo',
icon: 'i-lucide-redo-2',
tooltip: { text: 'Redo' },
tooltip: {
get text() {
return t('Redo')
},
},
},
],
[
@@ -72,40 +82,70 @@ function buildItems(editor: Editor): EditorToolbarItem[][] {
label: currentBlockLabel(editor),
trailingIcon: 'i-lucide-chevron-down',
ui: { trailingIcon: 'size-3' },
tooltip: { text: 'Paragraph style' },
tooltip: {
get text() {
return t('Paragraph style')
},
},
items: [
{
icon: 'i-lucide-pilcrow',
label: 'Paragraph',
get label() {
return t('Paragraph')
},
onSelect: () => editor.chain().focus().setParagraph().run(),
},
{
kind: 'heading',
level: 1,
icon: 'i-lucide-heading-1',
label: 'Heading 1',
tooltip: { text: 'Heading 1' },
get label() {
return t('Heading 1')
},
tooltip: {
get text() {
return t('Heading 1')
},
},
},
{
kind: 'heading',
level: 2,
icon: 'i-lucide-heading-2',
label: 'Heading 2',
tooltip: { text: 'Heading 2' },
get label() {
return t('Heading 2')
},
tooltip: {
get text() {
return t('Heading 2')
},
},
},
{
kind: 'heading',
level: 3,
icon: 'i-lucide-heading-3',
label: 'Heading 3',
tooltip: { text: 'Heading 3' },
get label() {
return t('Heading 3')
},
tooltip: {
get text() {
return t('Heading 3')
},
},
},
{
kind: 'heading',
level: 4,
icon: 'i-lucide-heading-4',
label: 'Heading 4',
tooltip: { text: 'Heading 4' },
get label() {
return t('Heading 4')
},
tooltip: {
get text() {
return t('Heading 4')
},
},
},
],
},
@@ -115,25 +155,41 @@ function buildItems(editor: Editor): EditorToolbarItem[][] {
kind: 'mark',
mark: 'bold',
icon: 'i-lucide-bold',
tooltip: { text: 'Bold' },
tooltip: {
get text() {
return t('Bold')
},
},
},
{
kind: 'mark',
mark: 'italic',
icon: 'i-lucide-italic',
tooltip: { text: 'Italic' },
tooltip: {
get text() {
return t('Italic')
},
},
},
{
kind: 'mark',
mark: 'underline',
icon: 'i-lucide-underline',
tooltip: { text: 'Underline' },
tooltip: {
get text() {
return t('Underline')
},
},
},
{
kind: 'mark',
mark: 'strike',
icon: 'i-lucide-strikethrough',
tooltip: { text: 'Strikethrough' },
tooltip: {
get text() {
return t('Strikethrough')
},
},
},
],
[
@@ -142,46 +198,78 @@ function buildItems(editor: Editor): EditorToolbarItem[][] {
align: 'left',
active: isDefaultAlignment(editor),
icon: 'i-lucide-align-left',
tooltip: { text: 'Align left' },
tooltip: {
get text() {
return t('Align left')
},
},
},
{
kind: 'textAlign',
align: 'center',
icon: 'i-lucide-align-center',
tooltip: { text: 'Align center' },
tooltip: {
get text() {
return t('Align center')
},
},
},
{
kind: 'textAlign',
align: 'right',
icon: 'i-lucide-align-right',
tooltip: { text: 'Align right' },
tooltip: {
get text() {
return t('Align right')
},
},
},
{
kind: 'textAlign',
align: 'justify',
icon: 'i-lucide-align-justify',
tooltip: { text: 'Justify' },
tooltip: {
get text() {
return t('Justify')
},
},
},
],
[
{
kind: 'bulletList',
icon: 'i-lucide-list',
tooltip: { text: 'Bullet list' },
tooltip: {
get text() {
return t('Bullet list')
},
},
},
{
kind: 'orderedList',
icon: 'i-lucide-list-ordered',
tooltip: { text: 'Numbered list' },
tooltip: {
get text() {
return t('Numbered list')
},
},
},
{
icon: 'i-lucide-indent-decrease',
tooltip: { text: 'Outdent' },
tooltip: {
get text() {
return t('Outdent')
},
},
onClick: () => editor.chain().focus().outdent().run(),
},
{
icon: 'i-lucide-indent-increase',
tooltip: { text: 'Indent' },
tooltip: {
get text() {
return t('Indent')
},
},
onClick: () => editor.chain().focus().indent().run(),
},
],
@@ -189,12 +277,22 @@ function buildItems(editor: Editor): EditorToolbarItem[][] {
{
kind: 'clearFormatting',
icon: 'i-lucide-remove-formatting',
tooltip: { text: 'Clear formatting' },
tooltip: {
get text() {
return t('Clear formatting')
},
},
},
{
icon: 'i-lucide-image-plus',
label: 'Image',
tooltip: { text: 'Add an image' },
get label() {
return t('Image')
},
tooltip: {
get text() {
return t('Add an image')
},
},
onClick: () => emit('request-image'),
},
],
+15 -4
View File
@@ -1,4 +1,6 @@
<script setup lang="ts">
import { t } from '@/i18n'
import { computed, ref, watch } from 'vue'
import { NodeViewWrapper, nodeViewProps } from '@tiptap/vue-3'
import { articleImageId } from '@/lib/articleImage'
@@ -18,7 +20,14 @@ interface ImageNodeAttrs {
const imageAttrs = computed(() => {
const { src, alt, title, width, height } = props.node.attrs as ImageNodeAttrs
return { src, alt, title, width, height, 'data-image-id': props.node.attrs['data-image-id'] }
return {
src,
alt,
title,
width,
height,
'data-image-id': props.node.attrs['data-image-id'],
}
})
const failed = ref(false)
@@ -56,8 +65,10 @@ function onDeleteClick() {
class="flex min-h-40 flex-col items-center justify-center gap-2 border border-dashed border-accented bg-elevated p-6 text-center font-sans text-sm text-muted"
>
<UIcon name="i-lucide-image-off" class="size-6" />
<span class="font-medium text-highlighted">This image is no longer available</span>
<span>It was deleted from the library. Remove it from the article.</span>
<span class="font-medium text-highlighted">{{
t('This image is no longer available')
}}</span>
<span>{{ t('It was deleted from the library. Remove it from the article.') }}</span>
</div>
<img
v-else
@@ -77,7 +88,7 @@ function onDeleteClick() {
@mousedown.prevent
@click="onDeleteClick"
>
Remove image
{{ t('Remove image') }}
</UButton>
</NodeViewWrapper>
</template>
+5 -3
View File
@@ -1,4 +1,6 @@
<script setup lang="ts">
import { t } from '@/i18n'
withDefaults(
defineProps<{
online: boolean
@@ -21,10 +23,10 @@ withDefaults(
:class="size === 'lg' ? undefined : 'px-2'"
:ui="{ leadingIcon: 'animate-spin' }"
>
{{ pending === 'publish' ? 'Publishing…' : 'Unpublishing…' }}
{{ pending === 'publish' ? t('Publishing…') : t('Unpublishing…') }}
</UBadge>
<UBadge v-else-if="online" color="neutral" variant="solid" icon="i-lucide-globe" :size="size">
Online
{{ t('Online') }}
</UBadge>
<UBadge
v-else
@@ -34,6 +36,6 @@ withDefaults(
:size="size"
:class="size === 'lg' ? undefined : 'px-2'"
>
Draft
{{ t('Draft') }}
</UBadge>
</template>
+54 -15
View File
@@ -1,4 +1,6 @@
<script setup lang="ts">
import { t } from '@/i18n'
import { ref } from 'vue'
import { useRouter } from 'vue-router'
import ArticleStatusBadge from '@/components/ArticleStatusBadge.vue'
@@ -27,10 +29,37 @@ const columnWidths =
const sorting = ref([{ id: 'updated_at', desc: true }])
const columns = [
{ accessorKey: 'title', header: 'Title', enableSorting: true },
{ accessorKey: 'is_online', header: 'Status', size: 110, enableSorting: true },
{ accessorKey: 'created_at', header: 'Created', size: 130, enableSorting: true },
{ accessorKey: 'updated_at', header: 'Updated', size: 140, enableSorting: true },
{
accessorKey: 'title',
get header() {
return t('Title')
},
enableSorting: true,
},
{
accessorKey: 'is_online',
get header() {
return t('Status')
},
size: 110,
enableSorting: true,
},
{
accessorKey: 'created_at',
get header() {
return t('Created')
},
size: 130,
enableSorting: true,
},
{
accessorKey: 'updated_at',
get header() {
return t('Updated')
},
size: 140,
enableSorting: true,
},
{ id: 'actions', header: '', size: 44 },
]
@@ -64,12 +93,16 @@ function actionsFor(article: Article) {
return [
[
{
label: 'Edit',
get label() {
return t('Edit')
},
icon: 'i-lucide-pencil',
onSelect: () => router.push(editPath(article)),
},
{
label: article.is_online ? 'Unpublish' : 'Publish',
get label() {
return article.is_online ? t('Unpublish') : t('Publish')
},
icon: article.is_online ? 'i-lucide-globe-lock' : 'i-lucide-globe',
disabled: isBusy(article),
onSelect: () => {
@@ -81,14 +114,18 @@ function actionsFor(article: Article) {
},
},
{
label: 'Copy API URL',
get label() {
return t('Copy API URL')
},
icon: 'i-lucide-link',
onSelect: () => emit('copy-url', article.id),
},
],
[
{
label: 'Delete…',
get label() {
return t('Delete…')
},
icon: 'i-lucide-trash-2',
color: 'error' as const,
onSelect: () => emit('delete', article),
@@ -104,7 +141,7 @@ function onSelect(_event: Event, row: { original: Article }) {
<template>
<div v-if="loading" aria-busy="true" class="hidden border border-default bg-default md:block">
<span role="status" class="sr-only">Loading articles</span>
<span role="status" class="sr-only">{{ t('Loading articles') }}</span>
<div
v-for="n in 5"
:key="n"
@@ -131,7 +168,9 @@ function onSelect(_event: Event, row: { original: Article }) {
>
<template #caption>
<caption class="sr-only">
Articles, sorted by last updated, newest first
{{
t('Articles, sorted by last updated, newest first')
}}
</caption>
</template>
@@ -141,7 +180,7 @@ function onSelect(_event: Event, row: { original: Article }) {
class="inline-flex items-center gap-1 text-highlighted"
@click.stop="column.toggleSorting()"
>
Title
{{ t('Title') }}
<UIcon v-if="sortIcon(column)" :name="sortIcon(column)!" class="size-3" />
</button>
</template>
@@ -152,7 +191,7 @@ function onSelect(_event: Event, row: { original: Article }) {
class="inline-flex items-center gap-1 text-highlighted"
@click.stop="column.toggleSorting()"
>
Status
{{ t('Status') }}
<UIcon v-if="sortIcon(column)" :name="sortIcon(column)!" class="size-3" />
</button>
</template>
@@ -163,7 +202,7 @@ function onSelect(_event: Event, row: { original: Article }) {
class="inline-flex items-center gap-1 text-highlighted"
@click.stop="column.toggleSorting()"
>
Created
{{ t('Created') }}
<UIcon v-if="sortIcon(column)" :name="sortIcon(column)!" class="size-3" />
</button>
</template>
@@ -174,7 +213,7 @@ function onSelect(_event: Event, row: { original: Article }) {
class="inline-flex items-center gap-1 text-highlighted"
@click.stop="column.toggleSorting()"
>
Updated
{{ t('Updated') }}
<UIcon v-if="sortIcon(column)" :name="sortIcon(column)!" class="size-3" />
</button>
</template>
@@ -217,7 +256,7 @@ function onSelect(_event: Event, row: { original: Article }) {
icon="i-lucide-ellipsis"
color="neutral"
variant="ghost"
:aria-label="`Actions for ${row.original.title}`"
:aria-label="t('Actions for {0}', [row.original.title])"
class="size-8 justify-center text-toned"
@click.stop
/>
+3 -1
View File
@@ -1,4 +1,6 @@
<script setup lang="ts">
import { t } from '@/i18n'
import { computed, onMounted, onUnmounted, ref, useSlots, watch } from 'vue'
import AppLogo from '@/components/AppLogo.vue'
import { isInAppNavigation } from '@/composables/useAuthShellNavigation'
@@ -72,7 +74,7 @@ watch(
if (!title) {
return
}
document.title = `${title} · RenewCMS`
document.title = t('{0} · RenewCMS', [title])
},
{ immediate: true },
)
+22 -14
View File
@@ -1,4 +1,6 @@
<script setup lang="ts">
import { t } from '@/i18n'
import { computed } from 'vue'
import ConfirmDialog from '@/components/ConfirmDialog.vue'
import MediaBadge from '@/components/MediaBadge.vue'
@@ -16,7 +18,7 @@ const props = defineProps<{
const emit = defineEmits<{ close: [confirmed: boolean] }>()
const total = computed(() => props.items.length)
const imageLabel = computed(() => `${total.value} ${total.value === 1 ? 'image' : 'images'}`)
const imageLabel = computed(() => t(total.value === 1 ? '{0} image' : '{0} images', [total.value]))
const inUse = computed(() => props.items.filter((item) => item.used_in > 0))
const articleCount = computed(() => inUse.value.reduce((sum, item) => sum + item.used_in, 0))
const description = computed(() => {
@@ -25,28 +27,34 @@ const description = computed(() => {
const articles = pluralArticles(articleCount.value)
if (usedCount === 0) {
return total.value === 1
? `It isn't used in any article. ${FINAL_WARNING}`
: `None of them are used in any article. ${FINAL_WARNING}`
? t("It isn't used in any article. {0}", [t(FINAL_WARNING)])
: t('None of them are used in any article. {0}', [t(FINAL_WARNING)])
}
if (unusedCount === 0) {
const subject = total.value === 1 ? "It's" : `All ${total.value} are`
return `${subject} used in ${articles}, ${BROKEN_IMAGE} ${FINAL_WARNING}`
const subject = total.value === 1 ? t("It's") : t('All {0} are', [total.value])
return t('{0} used in {1}, {2} {3}', [subject, articles, t(BROKEN_IMAGE), t(FINAL_WARNING)])
}
const rest =
unusedCount === 1
? "The other one isn't used anywhere."
: `The other ${unusedCount} aren't used anywhere.`
return `${usedCount} of them are used in ${articles}, ${BROKEN_IMAGE} ${rest} ${FINAL_WARNING}`
? t("The other one isn't used anywhere.")
: t("The other {0} aren't used anywhere.", [unusedCount])
return t('{0} of them are used in {1}, {2} {3} {4}', [
usedCount,
articles,
t(BROKEN_IMAGE),
rest,
t(FINAL_WARNING),
])
})
</script>
<template>
<ConfirmDialog
:title="`Delete ${imageLabel}?`"
:title="t('Delete {0}?', [imageLabel])"
:description="description"
:confirm-label="`Delete ${imageLabel}`"
cancel-label="Cancel"
pending-label="Deleting…"
:confirm-label="t('Delete {0}', [imageLabel])"
:cancel-label="t('Cancel')"
:pending-label="t('Deleting…')"
tone="danger"
:action="action"
@close="emit('close', $event)"
@@ -66,9 +74,9 @@ const description = computed(() => {
<MediaBadge
v-if="item.used_in > 0"
kind="used"
:label="`Used in ${item.used_in}`"
:label="t('Used in {0}', [item.used_in])"
/>
<MediaBadge v-else kind="unused" label="Unused" />
<MediaBadge v-else kind="unused" :label="t('Unused')" />
</li>
</ul>
</template>
+11 -8
View File
@@ -1,4 +1,6 @@
<script setup lang="ts">
import { t, locale, formatNumber } from '@/i18n'
import { computed, ref, watch } from 'vue'
const props = withDefaults(
@@ -19,6 +21,7 @@ const props = withDefaults(
)
function formatThousands(count: number) {
if (locale.value === 'fr') return formatNumber(count)
return count.toString().replace(/\B(?=(\d{3})+(?!\d))/g, ' ')
}
@@ -38,11 +41,11 @@ const status = computed<'normal' | 'warn' | 'over' | 'under'>(() => {
const overText = computed(() => {
const suffix = props.plainText
? 'Shorten the text.'
: 'Shorten the text or remove an image. Formatting counts too.'
? t('Shorten the text.')
: t('Shorten the text or remove an image. Formatting counts too.')
return overBy.value === 1
? `1 character over. ${suffix}`
: `${formatThousands(overBy.value)} characters over. ${suffix}`
? t('1 character over. {0}', [suffix])
: t('{0} characters over. {1}', [formatThousands(overBy.value), suffix])
})
const warnText = 'Close to the limit. Images and formatting count too.'
@@ -52,21 +55,21 @@ const message = computed(() => {
return overText.value
}
if (status.value === 'warn') {
return warnText
return t(warnText)
}
return ''
})
const announcement = ref('')
watch(status, (next, previous) => {
if (next === previous) {
watch([status, locale], ([next], [previous]) => {
if (next === previous && !announcement.value) {
return
}
if (next === 'over') {
announcement.value = overText.value
} else if (next === 'warn') {
announcement.value = 'Almost at the length limit.'
announcement.value = t('Almost at the length limit.')
} else {
announcement.value = ''
}
+4 -2
View File
@@ -1,4 +1,6 @@
<script setup lang="ts">
import { t } from '@/i18n'
import { computed, ref, watchEffect } from 'vue'
import { useCopyToClipboard } from '@/composables/useCopyToClipboard'
import { highlightToHtml, useShikiHighlighter } from '@/composables/useShikiHighlighter'
@@ -11,7 +13,7 @@ const props = defineProps<{
const { copied, copy } = useCopyToClipboard()
const buttonLabel = computed(() => (copied.value ? 'Copied' : 'Copy'))
const buttonLabel = computed(() => (copied.value ? t('Copied') : t('Copy')))
const buttonIcon = computed(() => (copied.value ? 'i-lucide-check' : 'i-lucide-copy'))
const highlightedHtml = ref<string | null>(null)
@@ -37,7 +39,7 @@ function onCopy() {
color="neutral"
variant="ghost"
size="xs"
:aria-label="`Copy ${label ?? lang} example`"
:aria-label="t('Copy {0} example', [label ?? lang])"
aria-live="polite"
class="gap-1.5 px-1.5 font-medium text-toned"
:ui="{ leadingIcon: 'size-3.5' }"
+10 -3
View File
@@ -1,4 +1,6 @@
<script setup lang="ts">
import { t, translate } from '@/i18n'
import { computed, nextTick, onMounted, ref, watch } from 'vue'
import MediaThumb from '@/components/MediaThumb.vue'
@@ -28,7 +30,9 @@ const cancelButton = ref<{ $el: HTMLElement } | null>(null)
let settled = false
const previewDetail = computed(() =>
props.preview?.size ? `Stays in the library · ${props.preview.size}` : 'Stays in the library',
props.preview?.size
? t('Stays in the library · {0}', [props.preview.size])
: t('Stays in the library'),
)
const previewThumb = computed(() => ({
url: props.preview?.src ?? '',
@@ -111,11 +115,14 @@ function onCancel() {
v-if="error"
color="error"
icon="i-lucide-circle-x"
:description="error"
:description="translate(error)"
variant="soft"
:close="false"
role="alert"
:ui="{ root: 'gap-2 px-3 py-2.5', description: 'text-sm text-toned' }"
:ui="{
root: 'gap-2 px-3 py-2.5',
description: 'text-sm text-toned',
}"
/>
<div
v-if="preview"
+51 -10
View File
@@ -1,4 +1,6 @@
<script setup lang="ts">
import { t, translate } from '@/i18n'
import { computed, nextTick, ref, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { useSessionStore } from '@/stores/session'
@@ -50,19 +52,56 @@ const api = useApi()
const navItems = computed(() =>
[
{ label: 'Home', to: '/home', icon: 'i-lucide-home' },
{ label: 'Articles', to: '/article', icon: 'i-lucide-file-text' },
{ label: 'Media', to: '/media', icon: 'i-lucide-images' },
{ label: 'Users', to: '/users', icon: 'i-lucide-users' },
{ label: 'Integration', to: '/integration', icon: 'i-lucide-plug' },
{ label: 'Settings', to: '/settings', icon: 'i-lucide-settings', slot: 'settings' },
{
get label() {
return t('Home')
},
to: '/home',
icon: 'i-lucide-home',
},
{
get label() {
return t('Articles')
},
to: '/article',
icon: 'i-lucide-file-text',
},
{
get label() {
return t('Media')
},
to: '/media',
icon: 'i-lucide-images',
},
{
get label() {
return t('Users')
},
to: '/users',
icon: 'i-lucide-users',
},
{
get label() {
return t('Integration')
},
to: '/integration',
icon: 'i-lucide-plug',
},
{
get label() {
return t('Settings')
},
to: '/settings',
icon: 'i-lucide-settings',
slot: 'settings',
},
].map((item) => ({
...item,
active: route.path === item.to || route.path.startsWith(`${item.to}/`),
})),
)
const routeTitle = computed(() => (route.meta.title as string) ?? '')
const routeTitle = computed(() => translate((route.meta.title as string) ?? ''))
const pageTitle = computed(() => props.mobileTitle ?? routeTitle.value)
const sidebarCollapsed = useSidebarCollapsed()
@@ -130,7 +169,9 @@ async function onLogout() {
<template>
<UDashboardGroup unit="rem">
<a href="#dashboard-content" class="sr-only focus:not-sr-only">Skip to content</a>
<a href="#dashboard-content" class="sr-only focus:not-sr-only">{{
t('Skip to content')
}}</a>
<UDashboardSidebar
v-model:open="sidebarOpen"
@@ -155,7 +196,7 @@ async function onLogout() {
orientation="vertical"
:items="navItems"
:collapsed="sidebarCollapsed"
aria-label="Main"
:aria-label="t('Main')"
:ui="navUi"
>
<template #settings-trailing>
@@ -199,7 +240,7 @@ async function onLogout() {
<span
v-if="update.showCue"
role="img"
aria-label="Update available"
:aria-label="t('Update available')"
class="absolute top-2.25 right-2.25 size-2.5 border-2 border-(--ui-bg) bg-info"
/>
</span>
+14 -6
View File
@@ -1,4 +1,6 @@
<script setup lang="ts">
import { t } from '@/i18n'
import { computed } from 'vue'
import ConfirmDialog from '@/components/ConfirmDialog.vue'
import MediaArticleStatusBadge from '@/components/MediaArticleStatusBadge.vue'
@@ -15,12 +17,18 @@ const emit = defineEmits<{ close: [confirmed: boolean] }>()
const inUse = computed(() => props.item.used_in > 0)
const title = computed(() =>
inUse.value ? "Delete an image that's in use?" : 'Delete this image?',
inUse.value ? t("Delete an image that's in use?") : t('Delete this image?'),
)
const description = computed(() =>
inUse.value
? `"${props.item.filename}" is used in ${pluralArticles(props.item.used_in)}. If you delete it, they will show a broken image. This can't be undone.`
: `"${props.item.filename}" will be deleted from your library. No article uses it. This can't be undone.`,
? t(
"\u0022{0}\u0022 is used in {1}. If you delete it, they will show a broken image. This can't be undone.",
[props.item.filename, pluralArticles(props.item.used_in)],
)
: t(
"\u0022{0}\u0022 will be deleted from your library. No article uses it. This can't be undone.",
[props.item.filename],
),
)
</script>
@@ -28,9 +36,9 @@ const description = computed(() =>
<ConfirmDialog
:title="title"
:description="description"
confirm-label="Delete image"
cancel-label="Cancel"
pending-label="Deleting…"
:confirm-label="t('Delete image')"
:cancel-label="t('Cancel')"
:pending-label="t('Deleting…')"
tone="danger"
:action="action"
@close="emit('close', $event)"
+13 -8
View File
@@ -1,4 +1,6 @@
<script setup lang="ts">
import { t } from '@/i18n'
import MediaThumb from '@/components/MediaThumb.vue'
import type { MediaItem } from '@/composables/useMedia'
import type { DuplicateGroup } from '@/lib/mediaDuplicates'
@@ -18,8 +20,8 @@ const emit = defineEmits<{
}>()
function uploaded(copy: MediaItem) {
const by = copy.uploaded_by ? ` by ${copy.uploaded_by}` : ''
return `Uploaded ${formatMediaDate(copy.created_at)}${by}`
const by = copy.uploaded_by ? t(' by {0}', [copy.uploaded_by]) : ''
return t('Uploaded {0}{1}', [formatMediaDate(copy.created_at), by])
}
</script>
@@ -32,12 +34,14 @@ function uploaded(copy: MediaItem) {
class="border border-default bg-default"
>
<header class="flex flex-col gap-0.5 border-b border-default px-3.5 py-3">
<h3 class="font-semibold text-highlighted">Group {{ index + 1 }}</h3>
<h3 class="font-semibold text-highlighted">{{ t('Group') }} {{ index + 1 }}</h3>
<span class="text-muted">{{ group.header }}</span>
</header>
<div
class="grid md:grid-cols-(--columns) md:gap-4 md:p-4"
:style="{ '--columns': `repeat(${group.copies.length}, minmax(0, 1fr))` }"
:style="{
'--columns': `repeat(${group.copies.length}, minmax(0, 1fr))`,
}"
>
<div
v-for="copy in group.copies"
@@ -70,7 +74,7 @@ function uploaded(copy: MediaItem) {
<span
v-if="copy.id === group.suggested.id"
class="inline-flex h-5 items-center bg-primary px-1.5 text-xs font-medium text-inverted"
>Suggested</span
>{{ t('Suggested') }}</span
>
</div>
<span
@@ -88,7 +92,8 @@ function uploaded(copy: MediaItem) {
class="self-start font-medium underline underline-offset-3 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-primary"
@click="emit('open', copy.id, $event.currentTarget as HTMLElement)"
>
Used in {{ pluralArticles(copy.used_in) }}
{{ t('Used in') }}
{{ pluralArticles(copy.used_in) }}
</button>
</div>
</div>
@@ -105,12 +110,12 @@ function uploaded(copy: MediaItem) {
"
@click="emit('merge', group, copy)"
>
Merge into this one
{{ t('Merge into this one') }}
</UButton>
</div>
</div>
<footer class="border-t border-default px-3.5 py-2.5 text-xs/5 text-muted">
{{ FOOTER }}
{{ t(FOOTER) }}
</footer>
</section>
</div>
+30 -12
View File
@@ -1,4 +1,6 @@
<script setup lang="ts">
import { t } from '@/i18n'
import { computed } from 'vue'
const QUERY_MAX_LENGTH = 40
@@ -18,30 +20,46 @@ const truncatedQuery = computed(() => {
const variants = computed(() => ({
'no-articles': {
icon: 'i-lucide-file-text',
title: 'No articles yet',
description: 'Write your first one. It stays a draft until you publish it.',
action: 'New article',
get title() {
return t('No articles yet')
},
get description() {
return t('Write your first one. It stays a draft until you publish it.')
},
action: t('New article'),
primary: true,
},
'no-results': {
icon: 'i-lucide-search',
title: `No articles match "${truncatedQuery.value}"`,
description: 'Check the spelling, or search for a shorter part of the title.',
action: 'Clear search',
get title() {
return t('No articles match \u0022{0}\u0022', [truncatedQuery.value])
},
get description() {
return t('Check the spelling, or search for a shorter part of the title.')
},
action: t('Clear search'),
primary: false,
},
'no-drafts': {
icon: 'i-lucide-pencil-line',
title: 'No drafts',
description: "Everything you've written is online.",
action: 'New article',
get title() {
return t('No drafts')
},
get description() {
return t("Everything you've written is online.")
},
action: t('New article'),
primary: false,
},
'no-online': {
icon: 'i-lucide-globe',
title: 'Nothing is online yet',
description: 'Publish a draft and it will appear on your website.',
action: 'Show drafts',
get title() {
return t('Nothing is online yet')
},
get description() {
return t('Publish a draft and it will appear on your website.')
},
action: t('Show drafts'),
primary: false,
},
}))
+8 -1
View File
@@ -1,4 +1,6 @@
<script setup lang="ts">
import { t } from '@/i18n'
import { ref } from 'vue'
import CodeBlock from '@/components/CodeBlock.vue'
@@ -14,7 +16,12 @@ const activeTab = ref('fetch')
const tabItems = [
{ label: 'fetch', value: 'fetch' },
{ label: 'curl', value: 'curl' },
{ label: 'Response', value: 'response' },
{
get label() {
return t('Response')
},
value: 'response',
},
]
</script>
+30 -14
View File
@@ -1,4 +1,6 @@
<script setup lang="ts">
import { t } from '@/i18n'
import { computed } from 'vue'
import { useRouter } from 'vue-router'
@@ -8,7 +10,7 @@ const props = withDefaults(
breadcrumb?: string
}>(),
{
breadcrumb: 'Home',
breadcrumb: undefined,
},
)
@@ -21,25 +23,39 @@ const router = useRouter()
const copy = computed(() => {
if (props.kind === 'not-found') {
return {
title: 'Page not found',
text: 'The link may be mistyped, or the page may have moved.',
primary: 'Go to Home',
secondary: 'All articles',
get title() {
return t('Page not found')
},
get text() {
return t('The link may be mistyped, or the page may have moved.')
},
primary: t('Go to Home'),
secondary: t('All articles'),
}
}
if (props.kind === 'article-not-found') {
return {
title: "This article doesn't exist.",
text: 'It may have been deleted, or the link is wrong.',
primary: 'All articles',
get title() {
return t("This article doesn't exist.")
},
get text() {
return t('It may have been deleted, or the link is wrong.')
},
primary: t('All articles'),
secondary: '',
}
}
return {
title: 'Something went wrong',
text: "This page couldn't load. Your saved work is safe. Try again, and if it keeps happening, restart the RenewCMS server.",
primary: 'Try again',
secondary: 'Go to Home',
get title() {
return t('Something went wrong')
},
get text() {
return t(
"This page couldn't load. Your saved work is safe. Try again, and if it keeps happening, restart the RenewCMS server.",
)
},
primary: t('Try again'),
secondary: t('Go to Home'),
}
})
@@ -66,8 +82,8 @@ function onSecondary() {
<template>
<div role="alert" class="flex max-w-140 flex-col gap-3.5 p-8">
<nav v-if="kind !== 'article-not-found'" aria-label="Breadcrumb">
<span>{{ breadcrumb }}</span>
<nav v-if="kind !== 'article-not-found'" :aria-label="t('Breadcrumb')">
<span>{{ breadcrumb ?? t('Home') }}</span>
</nav>
<span
v-if="kind === 'article-not-found'"
+88 -39
View File
@@ -1,4 +1,6 @@
<script setup lang="ts">
import { t } from '@/i18n'
import { computed, nextTick, onUnmounted, ref, watch } from 'vue'
import MediaPicker from '@/components/MediaPicker.vue'
import MediaThumb from '@/components/MediaThumb.vue'
@@ -72,8 +74,20 @@ const DROP_ZONE_UI = {
const FOOTER_BUTTON_CLASS = 'h-11 justify-center text-sm sm:h-8 sm:w-auto sm:px-3'
const TAB_ITEMS = [
{ label: 'Upload', value: 'upload', icon: 'i-lucide-upload' },
{ label: 'Library', value: 'library', icon: 'i-lucide-images' },
{
get label() {
return t('Upload')
},
value: 'upload',
icon: 'i-lucide-upload',
},
{
get label() {
return t('Library')
},
value: 'library',
icon: 'i-lucide-images',
},
]
const open = defineModel<boolean>('open', { default: false })
@@ -147,7 +161,7 @@ const duplicateText = computed(() =>
: '',
)
const cancelLabel = computed(() => (phase.value === 'uploading' ? 'Cancel upload' : 'Cancel'))
const cancelLabel = computed(() => (phase.value === 'uploading' ? t('Cancel upload') : t('Cancel')))
const showCancel = computed(
() =>
@@ -163,7 +177,9 @@ const showCropActions = computed(() => !onLibraryTab.value && phase.value === 'c
const wideModal = computed(() => showTabs.value || phase.value === 'crop')
const modalTitle = computed(() => (phase.value === 'crop' ? 'Crop and resize' : 'Add an image'))
const modalTitle = computed(() =>
phase.value === 'crop' ? t('Crop and resize') : t('Add an image'),
)
const modalDescription = computed(() => {
if (phase.value !== 'crop' || !chosenFile.value) {
@@ -203,9 +219,12 @@ const outputSizeText = computed(() =>
formatEstimatedSize(estimateOutputBytes(outputSize.value.width, outputSize.value.height)),
)
const monoLine = computed(
() =>
`Uploads as ${outputSize.value.width} × ${outputSize.value.height} px · about ${outputSizeText.value}`,
const monoLine = computed(() =>
t('Uploads as {0} × {1} px · about {2}', [
outputSize.value.width,
outputSize.value.height,
outputSizeText.value,
]),
)
const targetWidthPixels = computed(() => widthPresetPixels(widthLabel.value))
@@ -214,9 +233,11 @@ const showNarrowNote = computed(
() => targetWidthPixels.value !== null && targetWidthPixels.value > cropWidth.value,
)
const narrowNoteText = computed(
() =>
`The crop is ${cropWidth.value} px wide, so it's uploaded at that size. Enlarging it would make it blurry.`,
const narrowNoteText = computed(() =>
t(
"The crop is {0} px wide, so it's uploaded at that size. Enlarging it would make it blurry.",
[cropWidth.value],
),
)
const dimsLabel = computed(() => `${cropWidth.value} × ${cropHeight.value}`)
@@ -505,12 +526,18 @@ function stagePixelScale(): number {
return boundsWidth.value / box.width
}
function pointFromEvent(event: MouseEvent | TouchEvent): { x: number; y: number } {
function pointFromEvent(event: MouseEvent | TouchEvent): {
x: number
y: number
} {
if ('touches' in event && event.touches.length > 0) {
return { x: event.touches[0]!.clientX, y: event.touches[0]!.clientY }
}
if ('changedTouches' in event && event.changedTouches.length > 0) {
return { x: event.changedTouches[0]!.clientX, y: event.changedTouches[0]!.clientY }
return {
x: event.changedTouches[0]!.clientX,
y: event.changedTouches[0]!.clientY,
}
}
const mouseEvent = event as MouseEvent
return { x: mouseEvent.clientX, y: mouseEvent.clientY }
@@ -548,7 +575,11 @@ function onFrameDragMove(event: MouseEvent | TouchEvent) {
const dx = (point.x - moveOrigin.x) * scale
const dy = (point.y - moveOrigin.y) * scale
rect.value = clampRect(
{ ...moveOrigin.rect, x: moveOrigin.rect.x + dx, y: moveOrigin.rect.y + dy },
{
...moveOrigin.rect,
x: moveOrigin.rect.x + dx,
y: moveOrigin.rect.y + dy,
},
boundsWidth.value,
boundsHeight.value,
)
@@ -775,11 +806,13 @@ onUnmounted(() => {
<template #label>
{{
phase === 'dragging-over'
? 'Drop to upload'
: 'Drop an image here, or choose a file'
? t('Drop to upload')
: t('Drop an image here, or choose a file')
}}
</template>
<template #description>PNG, JPEG, WEBP, SVG · max 10 MB</template>
<template #description>{{
t('PNG, JPEG, WEBP, SVG · max 10 MB')
}}</template>
</UFileUpload>
</div>
@@ -795,13 +828,17 @@ onUnmounted(() => {
class="h-13.5 w-18 border border-default"
/>
<div class="flex min-w-0 flex-col gap-0.5">
<p class="font-semibold text-highlighted">Already in your library</p>
<p class="text-xs text-toned">{{ duplicateText }}</p>
<p class="font-semibold text-highlighted">
{{ t('Already in your library') }}
</p>
<p class="text-xs text-toned">
{{ duplicateText }}
</p>
</div>
</div>
<div class="flex flex-col gap-2 md:flex-row">
<UButton class="h-11 justify-center md:h-8" @click="useExisting">
Use existing
{{ t('Use existing') }}
</UButton>
<UButton
color="neutral"
@@ -809,7 +846,7 @@ onUnmounted(() => {
class="h-11 justify-center border border-accented bg-default text-highlighted ring-0 md:h-8"
@click="uploadAnyway"
>
Upload anyway
{{ t('Upload anyway') }}
</UButton>
</div>
</div>
@@ -822,7 +859,9 @@ onUnmounted(() => {
<div
ref="imageBoxRef"
class="relative h-full max-w-full"
:style="{ aspectRatio: `${boundsWidth} / ${boundsHeight}` }"
:style="{
aspectRatio: `${boundsWidth} / ${boundsHeight}`,
}"
:aria-label="`${chosenFile?.name}, ${boundsWidth} × ${boundsHeight} px`"
role="img"
>
@@ -846,7 +885,11 @@ onUnmounted(() => {
data-testid="crop-frame"
tabindex="0"
role="group"
aria-label="Crop area. Arrow keys move it, Shift and arrow keys resize it."
:aria-label="
t(
'Crop area. Arrow keys move it, Shift and arrow keys resize it.',
)
"
class="absolute cursor-move touch-none outline outline-white"
:style="{
left: `${(rect.x / boundsWidth) * 100}%`,
@@ -900,7 +943,9 @@ onUnmounted(() => {
<div class="flex flex-col gap-1.5">
<div class="flex items-center gap-1">
<span class="flex-1 font-medium text-highlighted">Aspect ratio</span>
<span class="flex-1 font-medium text-highlighted">{{
t('Aspect ratio')
}}</span>
<UButton
color="neutral"
variant="ghost"
@@ -908,7 +953,7 @@ onUnmounted(() => {
icon="i-lucide-rotate-ccw-square"
@click="rotateImage"
>
Rotate
{{ t('Rotate') }}
</UButton>
<UButton
color="neutral"
@@ -917,12 +962,12 @@ onUnmounted(() => {
icon="i-lucide-undo-2"
@click="resetCrop"
>
Reset
{{ t('Reset') }}
</UButton>
</div>
<div
role="radiogroup"
aria-label="Aspect ratio"
:aria-label="t('Aspect ratio')"
class="flex w-full border border-accented sm:w-max"
>
<button
@@ -940,16 +985,16 @@ onUnmounted(() => {
"
@click="selectRatio(ratio)"
>
{{ ratio }}
{{ ratio === 'Original' || ratio === 'Free' ? t(ratio) : ratio }}
</button>
</div>
</div>
<div class="flex flex-col gap-1.5">
<span class="font-medium text-highlighted">Width</span>
<span class="font-medium text-highlighted">{{ t('Width') }}</span>
<div
role="radiogroup"
aria-label="Width"
:aria-label="t('Width')"
class="flex w-full border border-accented sm:w-max"
>
<button
@@ -967,7 +1012,7 @@ onUnmounted(() => {
"
@click="selectWidth(widthOption)"
>
{{ widthOption }}
{{ widthOption === 'Original' ? t(widthOption) : widthOption }}
</button>
</div>
<span class="font-mono text-xs text-muted">{{ monoLine }}</span>
@@ -992,7 +1037,7 @@ onUnmounted(() => {
:class="succeeded ? 'size-6 text-success' : 'size-6 text-dimmed'"
/>
<p class="text-sm text-highlighted">
<template v-if="succeeded">Image added</template>
<template v-if="succeeded">{{ t('Image added') }}</template>
<template v-else>{{ chosenFile?.name }} {{ uploadPercent }}%</template>
</p>
<UProgress
@@ -1014,15 +1059,19 @@ onUnmounted(() => {
class="flex flex-col items-center gap-3 rounded-md border border-error px-6 py-8 text-center"
>
<UIcon name="i-lucide-file-x" class="size-6 text-error" />
<p class="text-sm font-semibold text-highlighted">{{ rejectedTitle }}</p>
<p class="text-sm font-semibold text-highlighted">
{{ rejectedTitle }}
</p>
<p class="text-sm text-toned">{{ rejectedText }}</p>
</div>
</div>
<div v-else-if="phase === 'failed'" class="flex flex-col gap-1">
<p class="text-sm font-semibold text-highlighted">Upload failed</p>
<p class="text-sm font-semibold text-highlighted">
{{ t('Upload failed') }}
</p>
<p class="text-sm text-toned">
The server didn't accept the file. Your article hasn't changed.
{{ t("The server didn't accept the file. Your article hasn't changed.") }}
</p>
</div>
</div>
@@ -1047,7 +1096,7 @@ onUnmounted(() => {
:disabled="!selected"
@click="insertSelected"
>
Insert image
{{ t('Insert image') }}
</UButton>
<UButton
v-if="showRejectedActions"
@@ -1055,7 +1104,7 @@ onUnmounted(() => {
:class="FOOTER_BUTTON_CLASS"
@click="chooseAnotherFile"
>
Choose another file
{{ t('Choose another file') }}
</UButton>
<UButton
v-if="showFailedActions"
@@ -1063,7 +1112,7 @@ onUnmounted(() => {
:class="FOOTER_BUTTON_CLASS"
@click="tryAgain"
>
Try again
{{ t('Try again') }}
</UButton>
<template v-if="showCropActions">
<UButton
@@ -1074,10 +1123,10 @@ onUnmounted(() => {
class="text-highlighted"
@click="chooseAnotherFile"
>
Choose another file
{{ t('Choose another file') }}
</UButton>
<UButton block :class="FOOTER_BUTTON_CLASS" @click="confirmCropUpload">
Upload image
{{ t('Upload image') }}
</UButton>
</template>
</div>
+11 -3
View File
@@ -1,4 +1,6 @@
<script setup lang="ts">
import { t } from '@/i18n'
import { computed } from 'vue'
const props = withDefaults(
@@ -13,19 +15,25 @@ const props = withDefaults(
const badges = {
accepted: {
label: 'Accepted',
get label() {
return t('Accepted')
},
icon: 'i-lucide-circle-check',
color: 'success',
class: 'border border-(--rc-success-soft) bg-(--rc-success-soft) text-success',
},
pending: {
label: 'Pending',
get label() {
return t('Pending')
},
icon: 'i-lucide-mail',
color: 'warning',
class: 'border border-(--rc-warning-soft) bg-(--rc-warning-soft) text-warning',
},
expired: {
label: 'Expired',
get label() {
return t('Expired')
},
icon: 'i-lucide-clock-alert',
color: 'neutral',
class: 'border border-accented bg-transparent text-muted',
+20 -12
View File
@@ -1,4 +1,6 @@
<script setup lang="ts">
import { t, translate } from '@/i18n'
import { computed, nextTick, ref, watch } from 'vue'
import { ApiError } from '@/composables/useApi'
import { useUsers, type CreatedInvite } from '@/composables/useUsers'
@@ -24,18 +26,18 @@ const formAlert = ref<'' | 'invalid' | 'rateLimited' | 'failed'>('')
const emailValid = computed(() => EMAIL_PATTERN.test(email.value))
const emailError = computed(
() => serverError.value || (touched.value && !emailValid.value ? INVALID_EMAIL_MESSAGE : ''),
() => serverError.value || (touched.value && !emailValid.value ? t(INVALID_EMAIL_MESSAGE) : ''),
)
const alertMessage = computed(() => {
if (formAlert.value === 'invalid') {
return "Something in the form isn't valid. Check the highlighted fields."
return t("Something in the form isn't valid. Check the highlighted fields.")
}
if (formAlert.value === 'rateLimited') {
return 'Too many invites. Wait a moment, then try again.'
return t('Too many invites. Wait a moment, then try again.')
}
if (formAlert.value === 'failed') {
return "Couldn't send the invite. Try again in a moment."
return t("Couldn't send the invite. Try again in a moment.")
}
return ''
})
@@ -82,7 +84,9 @@ function onBlur() {
}
function messageForConflict(error: ApiError) {
return error.body === PENDING_INVITE_MESSAGE ? PENDING_INVITE_MESSAGE : HAS_ACCOUNT_MESSAGE
return error.body === PENDING_INVITE_MESSAGE
? t(PENDING_INVITE_MESSAGE)
: t(HAS_ACCOUNT_MESSAGE)
}
function showFailure(error: unknown) {
@@ -143,8 +147,12 @@ function onCancel() {
<template>
<UModal
:open="open"
title="Invite a user"
description="We'll email them a link to choose a username and password. The link expires after 3 days."
:title="t('Invite a user')"
:description="
t(
'We\'ll email them a link to choose a username and password. The link expires after 3 days.',
)
"
:dismissible="!submitting"
:close="{ color: 'neutral', variant: 'ghost', class: 'text-muted' }"
@update:open="onOpenChange"
@@ -173,9 +181,9 @@ function onCancel() {
/>
<UForm :state="{ email }" class="flex flex-col gap-4" @submit="onSubmit">
<UFormField
label="Email"
:label="t('Email')"
name="email"
:error="emailError || undefined"
:error="translate(emailError) || undefined"
:ui="{ error: 'mt-1.5 text-xs/5' }"
>
<template v-if="emailError" #error="{ error }">
@@ -184,7 +192,7 @@ function onCancel() {
name="i-lucide-circle-alert"
class="size-3.5 shrink-0 text-error"
/>
{{ error }}
{{ t(error) }}
</span>
</template>
<UInput
@@ -215,7 +223,7 @@ function onCancel() {
:disabled="submitting"
@click="onCancel"
>
Cancel
{{ t('Cancel') }}
</UButton>
<UButton
block
@@ -225,7 +233,7 @@ function onCancel() {
:disabled="submitting"
@click="onSubmit"
>
{{ submitting ? 'Sending…' : 'Send invite' }}
{{ submitting ? t('Sending…') : t('Send invite') }}
</UButton>
</div>
</template>
+44
View File
@@ -0,0 +1,44 @@
<script setup lang="ts">
import { computed } from 'vue'
import { locale, setLocale, t, type Locale } from '@/i18n'
defineProps<{ mobile?: boolean }>()
const languages = [
{ label: 'English', value: 'en' },
{ label: 'Français', value: 'fr' },
]
const selected = computed({
get: () => locale.value,
set: (value: string) => setLocale(value as Locale),
})
</script>
<template>
<div
:class="mobile ? 'flex items-center gap-3 px-4 py-2' : 'flex items-center gap-2 px-2 py-1'"
>
<UIcon name="i-lucide-languages" class="size-4 shrink-0 text-toned" />
<USelect
v-model="selected"
:items="languages"
:aria-label="t('Language')"
:content="{
side: mobile ? 'top' : 'right',
align: 'start',
collisionPadding: 16,
}"
:ui="{
content: 'w-40',
viewport: 'max-h-40 overflow-y-auto',
base: 'min-w-0 flex-1',
}"
:class="mobile ? 'h-11' : 'h-8'"
>
<template #default>
<span class="truncate">{{ t('Language') }}</span>
</template>
</USelect>
</div>
</template>
@@ -1,4 +1,6 @@
<script setup lang="ts">
import { t } from '@/i18n'
import MediaBadge from '@/components/MediaBadge.vue'
defineProps<{
@@ -10,7 +12,7 @@ defineProps<{
<template>
<MediaBadge
:kind="published ? 'online' : 'draft'"
:label="published ? 'Online' : 'Draft'"
:label="published ? t('Online') : t('Draft')"
:size="size"
/>
</template>
+6 -4
View File
@@ -1,4 +1,6 @@
<script setup lang="ts">
import { t, formatNumber } from '@/i18n'
import { computed } from 'vue'
import { useElementBounding, useMediaQuery } from '@vueuse/core'
@@ -24,14 +26,14 @@ const centre = computed(() => `${content.left.value + content.width.value / 2}px
<template>
<div
role="region"
aria-label="Selection"
:aria-label="t('Selection')"
data-testid="media-bulk-bar"
class="fixed bottom-4 z-(--z-banner) flex items-center gap-2 bg-inverted py-2 pr-2 pl-4 text-inverted shadow-(--shadow-popover) max-md:inset-x-4 md:bottom-6 md:left-(--bulk-centre) md:w-100 md:-translate-x-1/2"
:class="{ invisible: obscured }"
:inert="obscured || undefined"
:style="isDesktop ? { '--bulk-centre': centre } : undefined"
>
<span class="flex-1 font-medium">{{ count }} selected</span>
<span class="flex-1 font-medium">{{ formatNumber(count) }} {{ t('selected') }}</span>
<UButton
color="error"
icon="i-lucide-trash-2"
@@ -40,7 +42,7 @@ const centre = computed(() => `${content.left.value + content.width.value / 2}px
class="h-11 px-3 md:h-8"
@click="emit('delete')"
>
Delete
{{ t('Delete') }}
</UButton>
<UButton
color="neutral"
@@ -49,7 +51,7 @@ const centre = computed(() => `${content.left.value + content.width.value / 2}px
class="h-11 border border-(--ui-text-inverted) bg-transparent px-3 text-inverted ring-0 hover:bg-transparent md:h-8"
@click="emit('clear')"
>
Clear
{{ t('Clear') }}
</UButton>
</div>
</template>
+5 -3
View File
@@ -1,4 +1,6 @@
<script setup lang="ts">
import { t } from '@/i18n'
import MediaBadge from '@/components/MediaBadge.vue'
defineProps<{
@@ -7,7 +9,7 @@ defineProps<{
</script>
<template>
<MediaBadge v-if="item.duplicate" kind="duplicate" label="Duplicate" />
<MediaBadge v-if="item.used_in > 0" kind="used" :label="`Used in ${item.used_in}`" />
<MediaBadge v-else kind="unused" label="Unused" />
<MediaBadge v-if="item.duplicate" kind="duplicate" :label="t('Duplicate')" />
<MediaBadge v-if="item.used_in > 0" kind="used" :label="t('Used in {0}', [item.used_in])" />
<MediaBadge v-else kind="unused" :label="t('Unused')" />
</template>
+3 -1
View File
@@ -1,4 +1,6 @@
<script setup lang="ts">
import { t } from '@/i18n'
import MediaCardBadges from '@/components/MediaCardBadges.vue'
import MediaCheckbox from '@/components/MediaCheckbox.vue'
import MediaThumb from '@/components/MediaThumb.vue'
@@ -65,7 +67,7 @@ const emit = defineEmits<{
>
<MediaCheckbox
:state="selected.includes(item.id) ? 'checked' : 'unchecked'"
:label="`Select ${item.filename}`"
:label="t('Select {0}', [item.filename])"
:tabbable="false"
@toggle="emit('toggle', item.id)"
/>
+57 -16
View File
@@ -1,4 +1,6 @@
<script setup lang="ts">
import { t } from '@/i18n'
import { computed } from 'vue'
import { useMediaQuery } from '@vueuse/core'
import MediaArticleStatusBadge from '@/components/MediaArticleStatusBadge.vue'
@@ -47,11 +49,36 @@ const metadata = computed(() => {
return []
}
return [
{ label: 'Type', value: formatType(item.content_type) },
{ label: 'Size', value: formatSize(item.size) },
{ label: 'Dimensions', value: formatDimensions(item.width, item.height) },
{ label: 'Uploaded', value: formatMediaDate(item.created_at) },
{ label: 'Uploaded by', value: item.uploaded_by },
{
get label() {
return t('Type')
},
value: formatType(item.content_type),
},
{
get label() {
return t('Size')
},
value: formatSize(item.size),
},
{
get label() {
return t('Dimensions')
},
value: formatDimensions(item.width, item.height),
},
{
get label() {
return t('Uploaded')
},
value: formatMediaDate(item.created_at),
},
{
get label() {
return t('Uploaded by')
},
value: item.uploaded_by,
},
]
})
@@ -63,8 +90,18 @@ const duplicateAlert = computed(() => {
}
const names = props.copies.map((copy) => copy.filename)
return {
title: `Identical to ${props.copies.length} other ${props.copies.length === 1 ? 'image' : 'images'}`,
description: `${joinNames(names)} ${names.length === 1 ? 'has' : 'have'} the same content.`,
get title() {
return t('Identical to {0} other {1}', [
props.copies.length,
props.copies.length === 1 ? 'image' : 'images',
])
},
get description() {
return t('{0} {1} the same content.', [
joinNames(names),
names.length === 1 ? 'has' : 'have',
])
},
}
})
</script>
@@ -74,7 +111,7 @@ const duplicateAlert = computed(() => {
v-model:open="open"
side="right"
:title="item?.filename ?? ''"
description="Image details"
:description="t('Image details')"
:close="closeButton"
:ui="{
content: 'w-full max-w-none md:max-w-115',
@@ -102,7 +139,7 @@ const duplicateAlert = computed(() => {
:class="ACTION_BUTTON"
@click="emit('copy-url')"
>
Copy URL
{{ t('Copy URL') }}
</UButton>
<UButton
color="neutral"
@@ -112,7 +149,7 @@ const duplicateAlert = computed(() => {
:class="ACTION_BUTTON"
@click="emit('rename')"
>
Rename
{{ t('Rename') }}
</UButton>
<UButton
color="neutral"
@@ -123,7 +160,7 @@ const duplicateAlert = computed(() => {
class="text-error"
@click="emit('delete')"
>
Delete
{{ t('Delete') }}
</UButton>
</div>
@@ -149,7 +186,7 @@ const duplicateAlert = computed(() => {
:ui="{ leadingIcon: 'size-3.5' }"
@click="emit('review-duplicates')"
>
Review duplicates
{{ t('Review duplicates') }}
</UButton>
</template>
</UAlert>
@@ -169,8 +206,8 @@ const duplicateAlert = computed(() => {
<h3 class="text-base font-semibold text-highlighted">
{{
item.used_in > 0
? `Used in ${pluralArticles(item.used_in)}`
: 'Not used in any article'
? t('Used in {0}', [pluralArticles(item.used_in)])
: t('Not used in any article')
}}
</h3>
<template v-if="item.used_in > 0">
@@ -196,11 +233,15 @@ const duplicateAlert = computed(() => {
</li>
</ul>
<p class="text-xs text-muted">
Counted from the text of your articles each time you open this page.
{{
t(
'Counted from the text of your articles each time you open this page.',
)
}}
</p>
</template>
<p v-else class="text-muted">
This image is safe to delete. No article shows it.
{{ t('This image is safe to delete. No article shows it.') }}
</p>
</section>
</div>
+3 -2
View File
@@ -1,4 +1,5 @@
<script setup lang="ts">
import { t } from '@/i18n'
import { DROP_TEXT, DROP_TITLE } from '@/lib/mediaUploadRows'
</script>
@@ -9,7 +10,7 @@ import { DROP_TEXT, DROP_TITLE } from '@/lib/mediaUploadRows'
class="pointer-events-none fixed inset-2 top-16 z-(--z-popover) flex flex-col items-center justify-center gap-2 border-2 border-dashed border-primary bg-secondary-200 text-center text-primary-900 md:inset-3 md:top-15 dark:bg-secondary-300"
>
<UIcon name="i-lucide-upload" class="size-7" />
<span class="text-lg font-semibold">{{ DROP_TITLE }}</span>
<span class="text-primary-600">{{ DROP_TEXT }}</span>
<span class="text-lg font-semibold">{{ t(DROP_TITLE) }}</span>
<span class="text-primary-600">{{ t(DROP_TEXT) }}</span>
</div>
</template>
+27 -11
View File
@@ -1,4 +1,6 @@
<script setup lang="ts">
import { t } from '@/i18n'
import { computed } from 'vue'
const QUERY_MAX_LENGTH = 40
@@ -19,26 +21,40 @@ const current = computed(() => {
if (props.variant === 'first-run') {
return {
icon: 'i-lucide-images',
title: 'No images yet',
description: 'Upload your first image. You can use it in any article.',
action: 'Upload an image',
get title() {
return t('No images yet')
},
get description() {
return t('Upload your first image. You can use it in any article.')
},
action: t('Upload an image'),
}
}
if (props.variant === 'no-duplicates') {
return {
icon: 'i-lucide-copy',
title: 'No duplicates',
description: 'Every image in your library is different.',
action: 'Back to all images',
get title() {
return t('No duplicates')
},
get description() {
return t('Every image in your library is different.')
},
action: t('Back to all images'),
}
}
return {
icon: 'i-lucide-search',
title: truncatedQuery.value
? `No images match "${truncatedQuery.value}"`
: 'No images match this filter',
description: `Nothing matches in ${props.filterLabel ?? 'All'}. Check the spelling, or clear the filters.`,
action: 'Clear filters',
get title() {
return truncatedQuery.value
? t('No images match \u0022{0}\u0022', [truncatedQuery.value])
: t('No images match this filter')
},
get description() {
return t('Nothing matches in {0}. Check the spelling, or clear the filters.', [
props.filterLabel ?? t('All'),
])
},
action: t('Clear filters'),
}
})
</script>
+3 -1
View File
@@ -1,4 +1,6 @@
<script setup lang="ts">
import { t } from '@/i18n'
import { ref } from 'vue'
import MediaCardBadges from '@/components/MediaCardBadges.vue'
import MediaCheckbox from '@/components/MediaCheckbox.vue'
@@ -127,7 +129,7 @@ function onKeyup(event: KeyboardEvent) {
>
<MediaCheckbox
:state="selected.includes(item.id) ? 'checked' : 'unchecked'"
:label="`Select ${item.filename}`"
:label="t('Select {0}', [item.filename])"
:tabbable="false"
@toggle="emit('toggle', item.id)"
/>
+26 -19
View File
@@ -1,4 +1,6 @@
<script setup lang="ts">
import { t } from '@/i18n'
import { computed, nextTick, onBeforeUnmount, ref, watch } from 'vue'
import { useMediaQuery } from '@vueuse/core'
import MediaThumb from '@/components/MediaThumb.vue'
@@ -55,7 +57,7 @@ const firstLoadSkeletons = computed(() =>
)
const searching = computed(() => props.query !== '')
const summary = computed(() =>
searching.value ? 'Newest first' : `Newest first · ${props.total} images`,
searching.value ? t('Newest first') : t('Newest first · {0} images', [props.total]),
)
const scrollHeight = computed(() => (props.selected ? 'max-h-32 md:max-h-49.5' : 'max-h-77'))
const showEmptyLibrary = computed(
@@ -66,7 +68,7 @@ const showNoResults = computed(
)
const showGrid = computed(() => props.status === 'ready' && props.items.length > 0)
const showLoadMore = computed(() => showGrid.value && props.hasMore && !props.selected)
const noResultsTitle = computed(() => `No images match "${props.query}"`)
const noResultsTitle = computed(() => t('No images match \u0022{0}\u0022', [props.query]))
function clearDebounce() {
if (debounceTimer !== null) {
@@ -133,10 +135,13 @@ onBeforeUnmount(clearDebounce)
v-model="searchText"
type="text"
icon="i-lucide-search"
placeholder="Search by filename"
aria-label="Search by filename"
:placeholder="t('Search by filename')"
:aria-label="t('Search by filename')"
class="w-full"
:ui="{ base: 'h-11 text-lg md:h-8 md:text-sm', leadingIcon: SEARCH_ICON_SIZE }"
:ui="{
base: 'h-11 text-lg md:h-8 md:text-sm',
leadingIcon: SEARCH_ICON_SIZE,
}"
>
<template v-if="searchText" #trailing>
<UButton
@@ -144,7 +149,7 @@ onBeforeUnmount(clearDebounce)
variant="link"
size="xs"
icon="i-lucide-x"
aria-label="Clear search"
:aria-label="t('Clear search')"
class="text-muted"
@click="clearSearch"
/>
@@ -159,8 +164,8 @@ onBeforeUnmount(clearDebounce)
orientation="vertical"
:close="false"
icon="i-lucide-circle-x"
title="Couldn't load your library"
description="Check your connection and try again."
:title="t('Couldn\'t load your library')"
:description="t('Check your connection and try again.')"
:ui="{ actions: 'mt-2' }"
>
<template #actions>
@@ -172,7 +177,7 @@ onBeforeUnmount(clearDebounce)
:ui="{ leadingIcon: 'size-3.5' }"
@click="emit('retry')"
>
Try again
{{ t('Try again') }}
</UButton>
</template>
</UAlert>
@@ -181,13 +186,13 @@ onBeforeUnmount(clearDebounce)
v-else-if="showEmptyLibrary"
variant="naked"
icon="i-lucide-images"
title="Your library is empty"
description="Images you upload appear here, ready to use in any article."
:title="t('Your library is empty')"
:description="t('Images you upload appear here, ready to use in any article.')"
:ui="EMPTY_STATE_UI"
>
<template #actions>
<UButton icon="i-lucide-upload" class="h-11 px-4 md:h-8" @click="emit('upload')">
Upload an image
{{ t('Upload an image') }}
</UButton>
</template>
</UEmpty>
@@ -197,7 +202,7 @@ onBeforeUnmount(clearDebounce)
variant="naked"
icon="i-lucide-search"
:title="noResultsTitle"
description="Check the spelling, or search for a shorter part of the filename."
:description="t('Check the spelling, or search for a shorter part of the filename.')"
:ui="EMPTY_STATE_UI"
>
<template #actions>
@@ -207,7 +212,7 @@ onBeforeUnmount(clearDebounce)
class="h-11 border border-accented bg-default px-4 text-highlighted ring-0 md:h-8"
@click="clearSearch"
>
Clear search
{{ t('Clear search') }}
</UButton>
</template>
</UEmpty>
@@ -259,7 +264,7 @@ onBeforeUnmount(clearDebounce)
<span
v-if="isInArticle(item)"
class="absolute bottom-1 left-1 inline-flex h-4.5 items-center bg-secondary-200 px-1.25 text-[0.6875rem] font-medium text-primary-900 dark:bg-secondary-300"
>In this article</span
>{{ t('In this article') }}</span
>
<span
v-if="isSelected(item)"
@@ -291,10 +296,12 @@ onBeforeUnmount(clearDebounce)
:icon="loadingMore ? 'i-lucide-loader-circle' : undefined"
:aria-disabled="loadingMore || undefined"
class="h-11 border border-accented bg-default px-3 text-highlighted ring-0 aria-disabled:opacity-40 md:h-8"
:ui="{ leadingIcon: loadingMore ? 'size-4 animate-spin' : 'size-4' }"
:ui="{
leadingIcon: loadingMore ? 'size-4 animate-spin' : 'size-4',
}"
@click="emit('load-more')"
>
{{ loadingMore ? 'Loading…' : 'Load more' }}
{{ loadingMore ? t('Loading…') : t('Load more') }}
</UButton>
</div>
</template>
@@ -317,7 +324,7 @@ onBeforeUnmount(clearDebounce)
</div>
</div>
<UFormField
label="Alt text (optional)"
:label="t('Alt text (optional)')"
help="Describes the image for people who use a screen reader, and shows if it doesn't load. Leave it empty if the image is only decoration."
:ui="{
label: 'font-medium text-highlighted',
@@ -327,7 +334,7 @@ onBeforeUnmount(clearDebounce)
<UInput
v-model="alt"
type="text"
placeholder="e.g. North wall of the kitchen before plastering"
:placeholder="t('e.g. North wall of the kitchen before plastering')"
class="w-full"
:ui="{ base: 'h-11 text-lg md:h-10 md:text-sm' }"
/>
+4 -2
View File
@@ -1,4 +1,6 @@
<script setup lang="ts">
import { t } from '@/i18n'
import { computed } from 'vue'
import type { SelectAllState } from '@/composables/useMediaSelection'
@@ -15,14 +17,14 @@ const modelValue = computed(() => {
}
return props.state === 'all'
})
const imageCount = computed(() => `${props.count} ${props.count === 1 ? 'image' : 'images'}`)
const imageCount = computed(() => t(props.count === 1 ? '{0} image' : '{0} images', [props.count]))
</script>
<template>
<div class="flex items-center gap-2.5 text-muted">
<UCheckbox
:model-value="modelValue"
label="Select all"
:label="t('Select all')"
color="primary"
class="-ml-2.75 md:ml-0"
:ui="{
+3 -1
View File
@@ -1,4 +1,6 @@
<script setup lang="ts">
import { t } from '@/i18n'
defineProps<{
count: number
}>()
@@ -10,7 +12,7 @@ defineProps<{
data-testid="media-skeleton"
class="grid grid-cols-2 gap-2.5 md:grid-cols-5 md:gap-4"
>
<span role="status" class="sr-only">Loading images</span>
<span role="status" class="sr-only">{{ t('Loading images') }}</span>
<div
v-for="n in count"
:key="n"
+13 -11
View File
@@ -1,4 +1,6 @@
<script setup lang="ts">
import { t } from '@/i18n'
import MediaBadge from '@/components/MediaBadge.vue'
import MediaCheckbox from '@/components/MediaCheckbox.vue'
import MediaThumb from '@/components/MediaThumb.vue'
@@ -44,19 +46,19 @@ function open(event: Event, id: number) {
<span class="flex justify-center">
<MediaCheckbox
size="sm"
label="Select all"
:label="t('Select all')"
:state="ALL_STATES[allState]"
@toggle="emit('toggle-all')"
/>
</span>
<span>Filename</span>
<span>Dimensions</span>
<span>Size</span>
<span>Type</span>
<span>Used in</span>
<span>Uploaded by</span>
<span>{{ t('Filename') }}</span>
<span>{{ t('Dimensions') }}</span>
<span>{{ t('Size') }}</span>
<span>{{ t('Type') }}</span>
<span>{{ t('Used in') }}</span>
<span>{{ t('Uploaded by') }}</span>
<span class="flex items-center gap-1 text-highlighted">
Uploaded
{{ t('Uploaded') }}
<UIcon name="i-lucide-arrow-down" class="size-3 shrink-0" />
</span>
</div>
@@ -77,7 +79,7 @@ function open(event: Event, id: number) {
<span class="relative z-10 flex justify-center">
<MediaCheckbox
size="sm"
:label="`Select ${item.filename}`"
:label="t('Select {0}', [item.filename])"
:state="selected.includes(item.id) ? 'checked' : 'unchecked'"
@toggle="emit('toggle', item.id)"
/>
@@ -94,7 +96,7 @@ function open(event: Event, id: number) {
>
{{ item.filename }}
</button>
<MediaBadge v-if="item.duplicate" kind="duplicate" label="Duplicate" />
<MediaBadge v-if="item.duplicate" kind="duplicate" :label="t('Duplicate')" />
</span>
<span>{{ formatDimensions(item.width, item.height) }}</span>
<span>{{ formatSize(item.size) }}</span>
@@ -109,7 +111,7 @@ function open(event: Event, id: number) {
>
{{ pluralArticles(item.used_in) }}
</button>
<MediaBadge v-else kind="unused" label="Unused" />
<MediaBadge v-else kind="unused" :label="t('Unused')" />
</span>
<span class="truncate pr-2">{{ item.uploaded_by }}</span>
<span>{{ formatMediaDate(item.created_at) }}</span>
+60 -17
View File
@@ -1,4 +1,6 @@
<script setup lang="ts">
import { t, formatNumber } from '@/i18n'
import { computed, onBeforeUnmount, ref, watch } from 'vue'
import type { MediaCounts } from '@/composables/useMedia'
import type { MediaControls, MediaFilter, MediaSort, MediaView } from '@/lib/mediaQuery'
@@ -7,15 +9,35 @@ import { UNDERLINE_TAB_TRIGGER } from '@/lib/tabStyles'
const SEARCH_DEBOUNCE_MS = 120
const SORT_LABELS: Record<MediaSort, string> = {
newest: 'Newest',
oldest: 'Oldest',
name: 'Name',
largest: 'Largest',
get newest() {
return t('Newest')
},
get oldest() {
return t('Oldest')
},
get name() {
return t('Name')
},
get largest() {
return t('Largest')
},
}
const VIEW_OPTIONS: { value: MediaView; icon: string; label: string }[] = [
{ value: 'grid', icon: 'i-lucide-layout-grid', label: 'Grid view' },
{ value: 'list', icon: 'i-lucide-list', label: 'List view' },
{
value: 'grid',
icon: 'i-lucide-layout-grid',
get label() {
return t('Grid view')
},
},
{
value: 'list',
icon: 'i-lucide-list',
get label() {
return t('List view')
},
},
]
const props = defineProps<{
@@ -68,9 +90,27 @@ function clearSearch() {
onBeforeUnmount(clearDebounce)
const tabItems = computed(() => [
{ label: 'All', value: 'all', count: props.counts?.all },
{ label: 'Unused', value: 'unused', count: props.counts?.unused },
{ label: 'Duplicates', value: 'duplicates', count: props.counts?.duplicates },
{
get label() {
return t('All')
},
value: 'all',
count: props.counts?.all,
},
{
get label() {
return t('Unused')
},
value: 'unused',
count: props.counts?.unused,
},
{
get label() {
return t('Duplicates')
},
value: 'duplicates',
count: props.counts?.duplicates,
},
])
const tab = computed({
@@ -115,12 +155,12 @@ const sortItems = computed(() =>
v-if="item.value === 'duplicates' && item.count"
data-testid="duplicates-count"
class="inline-flex h-4.5 min-w-4.5 items-center justify-center bg-(--rc-info-soft) px-1.25 font-mono text-[0.6875rem] font-medium text-info"
>{{ item.count }}</span
>{{ item.count === undefined ? undefined : formatNumber(item.count) }}</span
>
<span
v-else-if="item.value !== 'duplicates' && item.count !== undefined"
class="font-mono text-[0.6875rem] font-medium text-muted"
>{{ item.count }}</span
>{{ item.count === undefined ? undefined : formatNumber(item.count) }}</span
>
</span>
</template>
@@ -130,10 +170,13 @@ const sortItems = computed(() =>
v-model="searchText"
type="text"
icon="i-lucide-search"
placeholder="Search by filename"
aria-label="Search by filename"
:placeholder="t('Search by filename')"
:aria-label="t('Search by filename')"
class="order-1 w-full md:order-none md:mb-1.5 md:w-60"
:ui="{ base: 'h-11 text-lg md:h-8 md:text-sm', leadingIcon: 'size-3.5' }"
:ui="{
base: 'h-11 text-lg md:h-8 md:text-sm',
leadingIcon: 'size-3.5',
}"
>
<template v-if="searchText" #trailing>
<UButton
@@ -141,7 +184,7 @@ const sortItems = computed(() =>
variant="link"
size="xs"
icon="i-lucide-x"
aria-label="Clear search"
:aria-label="t('Clear search')"
class="text-muted"
@click="clearSearch"
/>
@@ -154,7 +197,7 @@ const sortItems = computed(() =>
color="neutral"
variant="outline"
trailing-icon="i-lucide-chevron-down"
aria-label="Sort"
:aria-label="t('Sort')"
class="h-11 flex-1 justify-between border border-accented bg-default px-2.5 text-sm font-normal text-highlighted ring-0 md:h-8 md:w-32 md:flex-none"
:ui="{ trailingIcon: 'size-3.5 text-muted' }"
>
@@ -162,7 +205,7 @@ const sortItems = computed(() =>
</UButton>
</UDropdownMenu>
<div role="group" aria-label="View" class="flex shrink-0 border border-accented">
<div role="group" :aria-label="t('View')" class="flex shrink-0 border border-accented">
<UButton
v-for="option in VIEW_OPTIONS"
:key="option.value"
+17 -12
View File
@@ -1,4 +1,6 @@
<script setup lang="ts">
import { t } from '@/i18n'
import MediaThumb from '@/components/MediaThumb.vue'
import type { UploadRow } from '@/composables/useMediaUpload'
import { formatUploadProgress } from '@/lib/imageUpload'
@@ -48,7 +50,7 @@ function percent(row: UploadRow) {
<template>
<section
data-testid="media-upload-panel"
aria-label="Uploads"
:aria-label="t('Uploads')"
class="flex flex-col border border-default bg-default"
>
<div class="flex items-center gap-3 px-3.5 py-3">
@@ -64,7 +66,7 @@ function percent(row: UploadRow) {
class="h-11 border border-accented bg-default px-3 text-xs text-highlighted ring-0 md:h-7"
@click="header.phase === 'working' ? emit('cancel-all') : emit('dismiss')"
>
{{ header.phase === 'working' ? 'Cancel all' : 'Dismiss' }}
{{ header.phase === 'working' ? t('Cancel all') : t('Dismiss') }}
</UButton>
</div>
@@ -83,7 +85,7 @@ function percent(row: UploadRow) {
</div>
<template v-if="row.status === 'waiting'">
<span class="text-muted">Waiting</span>
<span class="text-muted">{{ t('Waiting') }}</span>
</template>
<template v-else-if="row.status === 'uploading'">
@@ -91,19 +93,22 @@ function percent(row: UploadRow) {
<UProgress
:model-value="percent(row)"
size="sm"
:ui="{ base: 'rounded-none', indicator: 'rounded-none' }"
:ui="{
base: 'rounded-none',
indicator: 'rounded-none',
}"
/>
</template>
<template v-else-if="row.status === 'added'">
<span class="flex items-center gap-1.5 text-success">
<UIcon name="i-lucide-circle-check" class="size-3.5 shrink-0" />
{{ ADDED_TEXT }}
{{ t(ADDED_TEXT) }}
</span>
</template>
<template v-else-if="row.status === 'duplicate' && row.existing">
<span class="font-semibold text-highlighted">{{ DUPLICATE_TITLE }}</span>
<span class="font-semibold text-highlighted">{{ t(DUPLICATE_TITLE) }}</span>
<span class="text-xs/5 text-toned">{{
duplicateText(row.existing, formatMediaDate)
}}</span>
@@ -114,7 +119,7 @@ function percent(row: UploadRow) {
:class="BUTTON"
@click="emit('use-existing', row.key)"
>
Use existing
{{ t('Use existing') }}
</UButton>
<UButton
color="neutral"
@@ -122,7 +127,7 @@ function percent(row: UploadRow) {
:class="BUTTON"
@click="emit('upload-anyway', row.key)"
>
Upload anyway
{{ t('Upload anyway') }}
</UButton>
</div>
</template>
@@ -138,9 +143,9 @@ function percent(row: UploadRow) {
<template v-else-if="row.status === 'failed'">
<span class="flex items-center gap-1.5 font-semibold text-error">
<UIcon name="i-lucide-circle-x" class="size-3.5 shrink-0" />
{{ FAILED_TITLE }}
{{ t(FAILED_TITLE) }}
</span>
<span class="text-xs/5 text-toned">{{ FAILED_TEXT }}</span>
<span class="text-xs/5 text-toned">{{ t(FAILED_TEXT) }}</span>
<div class="flex">
<UButton
color="neutral"
@@ -150,7 +155,7 @@ function percent(row: UploadRow) {
:ui="{ leadingIcon: 'size-3.5' }"
@click="emit('retry', row.key)"
>
Try again
{{ t('Try again') }}
</UButton>
</div>
</template>
@@ -160,7 +165,7 @@ function percent(row: UploadRow) {
color="neutral"
variant="ghost"
icon="i-lucide-x"
:aria-label="`Cancel upload of ${row.name}`"
:aria-label="t('Cancel upload of {0}', [row.name])"
class="size-6 justify-center text-muted"
:ui="{ leadingIcon: 'size-3.5' }"
@click="emit('cancel-row', row.key)"
+18 -9
View File
@@ -1,4 +1,6 @@
<script setup lang="ts">
import { t } from '@/i18n'
import { computed } from 'vue'
import ConfirmDialog from '@/components/ConfirmDialog.vue'
import MediaThumb from '@/components/MediaThumb.vue'
@@ -15,21 +17,27 @@ const props = defineProps<{
const emit = defineEmits<{ close: [confirmed: boolean] }>()
const otherCount = computed(() => props.copies.length - 1)
const others = computed(() => (otherCount.value === 1 ? 'copy' : `${otherCount.value} copies`))
const others = computed(() =>
otherCount.value === 1 ? t('copy') : t('{0} copies', [otherCount.value]),
)
const articles = computed(() => mergeArticleCount(props.copies, props.keep.id))
const description = computed(
() =>
`Articles that use the other ${others.value} will be switched to ${props.keep.filename}, and the other ${others.value} will be deleted. ${pluralArticles(articles.value)} will be updated. This can't be undone.`,
const description = computed(() =>
t(
"Articles that use the other {0} will be switched to {1}, and the other {2} will be deleted. {3} will be updated. This can't be undone.",
[others.value, props.keep.filename, others.value, pluralArticles(articles.value)],
),
)
</script>
<template>
<ConfirmDialog
:title="`Merge into ${keep.filename}?`"
:title="t('Merge into {0}?', [keep.filename])"
:description="description"
:confirm-label="`Merge ${otherCount === 1 ? '1 copy' : `${otherCount} copies`}`"
cancel-label="Cancel"
pending-label="Merging…"
:confirm-label="
t('Merge {0}', [otherCount === 1 ? t('1 copy') : t('{0} copies', [otherCount])])
"
:cancel-label="t('Cancel')"
:pending-label="t('Merging…')"
tone="danger"
:action="action"
@close="emit('close', $event)"
@@ -40,7 +48,8 @@ const description = computed(
<div class="flex min-w-0 flex-col">
<span class="truncate font-medium text-highlighted">{{ keep.filename }}</span>
<span class="text-xs/5 text-muted"
>Kept · used in {{ pluralArticles(keep.used_in) }} now</span
>{{ t('Kept · used in') }} {{ pluralArticles(keep.used_in) }}
{{ t('now') }}</span
>
</div>
</div>
+32 -18
View File
@@ -1,4 +1,6 @@
<script setup lang="ts">
import { t, translate } from '@/i18n'
import { computed, nextTick, ref, watch } from 'vue'
import CharacterCounter from '@/components/CharacterCounter.vue'
import { ApiError, useApi } from '@/composables/useApi'
@@ -34,10 +36,10 @@ const titleLengthError = computed(() => {
return ''
}
if (titleLength.value < TITLE_MIN_LENGTH) {
return 'Use at least 3 characters.'
return t('Use at least 3 characters.')
}
if (titleLength.value > TITLE_MAX_LENGTH) {
return 'Use 3 to 200 characters.'
return t('Use 3 to 200 characters.')
}
return ''
})
@@ -46,7 +48,7 @@ const titleError = computed(() => titleServerError.value || titleLengthError.val
const bodyError = computed(() =>
bodyTouched.value && !bodyValid.value
? 'Add a first paragraph. It can be a single sentence.'
? t('Add a first paragraph. It can be a single sentence.')
: '',
)
@@ -56,10 +58,10 @@ const showHelp = computed(() => !submitting.value && formAlert.value === '')
const alertMessage = computed(() => {
if (formAlert.value === 'invalid') {
return "Something in the form isn't valid. Check the highlighted fields."
return t("Something in the form isn't valid. Check the highlighted fields.")
}
if (formAlert.value === 'failed') {
return "Couldn't create the article. Your text is still here. Try again in a moment."
return t("Couldn't create the article. Your text is still here. Try again in a moment.")
}
return ''
})
@@ -78,7 +80,9 @@ function resetForm() {
async function focusTitleField() {
await nextTick()
await nextTick()
document.querySelector<HTMLInputElement>(`input[placeholder="${TITLE_PLACEHOLDER}"]`)?.focus()
document
.querySelector<HTMLInputElement>(`input[placeholder="${t(TITLE_PLACEHOLDER)}"]`)
?.focus()
titleFocused.value = true
}
@@ -147,8 +151,8 @@ function onCancel() {
<template>
<UModal
v-model:open="open"
title="New article"
description="It's saved as a draft. Titles can't be changed later, so check it now."
:title="t('New article')"
:description="t('It\'s saved as a draft. Titles can\'t be changed later, so check it now.')"
:dismissible="!submitting"
:ui="{
content:
@@ -173,7 +177,11 @@ function onCancel() {
:description="alertMessage"
/>
<UForm :state="{ title, body }" class="flex flex-col gap-4">
<UFormField label="Title" name="title" :error="titleError || undefined">
<UFormField
:label="t('Title')"
name="title"
:error="translate(titleError) || undefined"
>
<template #hint>
<CharacterCounter
:value="titleLength"
@@ -188,13 +196,15 @@ function onCancel() {
name="i-lucide-circle-alert"
class="size-3.5 shrink-0 text-error"
/>
{{ error }}
{{ t(error) }}
</span>
</template>
<template v-else-if="showHelp" #help>3 to 200 characters.</template>
<template v-else-if="showHelp" #help>{{
t('3 to 200 characters.')
}}</template>
<UInput
v-model="title"
:placeholder="TITLE_PLACEHOLDER"
:placeholder="t(TITLE_PLACEHOLDER)"
:disabled="submitting"
color="primary"
:highlight="titleFocused || !!titleError"
@@ -204,22 +214,26 @@ function onCancel() {
@blur="onTitleBlur"
/>
</UFormField>
<UFormField label="First paragraph" name="body" :error="bodyError || undefined">
<UFormField
:label="t('First paragraph')"
name="body"
:error="bodyError || undefined"
>
<template v-if="bodyError" #error="{ error }">
<span class="flex items-center gap-1.5">
<UIcon
name="i-lucide-circle-alert"
class="size-3.5 shrink-0 text-error"
/>
{{ error }}
{{ t(error) }}
</span>
</template>
<template v-else-if="showHelp" #help>
You can format it and add images in the editor.
{{ t('You can format it and add images in the editor.') }}
</template>
<UTextarea
v-model="body"
placeholder="Start writing. You'll continue in the editor."
:placeholder="t('Start writing. You\'ll continue in the editor.')"
:disabled="submitting"
:highlight="!!bodyError"
:rows="3"
@@ -244,7 +258,7 @@ function onCancel() {
:disabled="submitting"
@click="onCancel"
>
Cancel
{{ t('Cancel') }}
</UButton>
<UButton
block
@@ -254,7 +268,7 @@ function onCancel() {
:disabled="submitting"
@click="onSubmit"
>
{{ submitting ? 'Creating…' : 'Create draft' }}
{{ submitting ? t('Creating…') : t('Create draft') }}
</UButton>
</div>
</template>
+3 -1
View File
@@ -1,4 +1,6 @@
<script setup lang="ts">
import { translate } from '@/i18n'
import { onMounted, onUnmounted, ref } from 'vue'
const DEFAULT_MESSAGE =
@@ -43,7 +45,7 @@ onUnmounted(() => {
v-if="isOffline"
color="neutral"
:icon="icon"
:title="message"
:title="translate(message)"
:ui="bleed ? BLEED_UI : undefined"
/>
</template>
+2 -1
View File
@@ -1,4 +1,5 @@
<script setup lang="ts">
import { t } from '@/i18n'
import { computed } from 'vue'
interface PasswordRule {
@@ -47,7 +48,7 @@ const items = computed(() =>
<li v-for="item in items" :key="item.label" class="flex items-center gap-2 text-xs/[18px]">
<UIcon :name="item.icon" :class="['size-3.5 shrink-0', item.iconClass]" />
<span :class="item.textClass">{{ item.label }}</span>
<span class="sr-only">{{ item.met ? 'met' : 'not met' }}</span>
<span class="sr-only">{{ t(item.met ? 'met' : 'not met') }}</span>
</li>
</ul>
</template>
+5 -3
View File
@@ -1,4 +1,6 @@
<script setup lang="ts">
import { t } from '@/i18n'
import { computed } from 'vue'
const props = withDefaults(
@@ -23,12 +25,12 @@ const label = computed(() => {
return props.pending
}
if (props.online) {
return 'Unpublish'
return t('Unpublish')
}
if (props.dirty) {
return 'Save and publish'
return t('Save and publish')
}
return 'Publish'
return t('Publish')
})
const icon = computed(() => {
+3 -1
View File
@@ -1,4 +1,6 @@
<script setup lang="ts">
import { t } from '@/i18n'
import { ref } from 'vue'
defineProps<{
@@ -23,7 +25,7 @@ const open = ref(false)
class="min-h-11 self-start gap-1.5 p-0 font-medium text-highlighted md:min-h-8"
:ui="{ leadingIcon: 'size-3.5' }"
>
What's new in {{ version }}
{{ t("What's new in") }} {{ version }}
</UButton>
<template #content>
+19 -10
View File
@@ -1,4 +1,6 @@
<script setup lang="ts">
import { t } from '@/i18n'
import { computed, ref } from 'vue'
import ConfirmDialog from '@/components/ConfirmDialog.vue'
import type { MediaItem } from '@/composables/useMedia'
@@ -18,10 +20,10 @@ const stem = ref(parts.stem)
const touched = ref(false)
const error = computed(() =>
touched.value && !isValidFilenameStem(stem.value) ? INVALID_FILENAME_MESSAGE : undefined,
touched.value && !isValidFilenameStem(stem.value) ? t(INVALID_FILENAME_MESSAGE) : undefined,
)
const help = computed(
() => `Letters, numbers, dashes and underscores. The ${parts.extension} ending stays.`,
const help = computed(() =>
t('Letters, numbers, dashes and underscores. The {0} ending stays.', [parts.extension]),
)
async function submit() {
@@ -35,11 +37,15 @@ async function submit() {
<template>
<ConfirmDialog
title="Rename image"
description="Only the filename changes. The URL stays the same, so articles that use this image keep working."
confirm-label="Rename"
cancel-label="Cancel"
pending-label="Renaming…"
:title="t('Rename image')"
:description="
t(
'Only the filename changes. The URL stays the same, so articles that use this image keep working.',
)
"
:confirm-label="t('Rename')"
:cancel-label="t('Cancel')"
:pending-label="t('Renaming…')"
tone="neutral"
:action="submit"
@close="emit('close', $event)"
@@ -47,11 +53,14 @@ async function submit() {
<template #body="{ pending, confirm }">
<form class="flex flex-col" novalidate @submit.prevent="confirm">
<UFormField
label="Filename"
:label="t('Filename')"
name="filename"
:error="error"
:help="help"
:ui="{ error: 'mt-1.5 text-xs/5', help: 'mt-1.5 text-xs/5' }"
:ui="{
error: 'mt-1.5 text-xs/5',
help: 'mt-1.5 text-xs/5',
}"
>
<template v-if="error" #error="{ error: message }">
<span class="flex items-center gap-1.5">
+28 -8
View File
@@ -1,4 +1,6 @@
<script setup lang="ts">
import { t } from '@/i18n'
import { computed } from 'vue'
import AppLogo from '@/components/AppLogo.vue'
import type { RestartKind } from '@/composables/useUpdate'
@@ -21,19 +23,37 @@ const emit = defineEmits<{
const copy = computed(() => {
if (props.slow) {
return {
title: 'Still restarting',
text: 'This is taking longer than usual. Keep this page open. It reloads on its own when the site is back, or you can reload it now.',
get title() {
return t('Still restarting')
},
get text() {
return t(
'This is taking longer than usual. Keep this page open. It reloads on its own when the site is back, or you can reload it now.',
)
},
}
}
if (props.kind === 'update') {
return {
title: `Installing version ${props.version}`,
text: 'Your site is restarting with the new version. This takes about 10 seconds. This page reloads on its own when the site is back.',
get title() {
return t('Installing version {0}', [props.version])
},
get text() {
return t(
'Your site is restarting with the new version. This takes about 10 seconds. This page reloads on its own when the site is back.',
)
},
}
}
return {
title: 'Restarting your site',
text: 'This takes about 10 seconds. This page reloads on its own when the site is back.',
get title() {
return t('Restarting your site')
},
get text() {
return t(
'This takes about 10 seconds. This page reloads on its own when the site is back.',
)
},
}
})
@@ -62,7 +82,7 @@ const meta = computed(() =>
</div>
<div
role="progressbar"
aria-label="Restarting"
:aria-label="t('Restarting')"
class="h-1 w-full overflow-hidden bg-accented"
>
<div class="restart-bar h-1 w-7/20 bg-primary" />
@@ -75,7 +95,7 @@ const meta = computed(() =>
class="h-11 gap-1.5 border border-accented bg-default px-3.5 text-highlighted ring-0 md:h-8"
@click="emit('reload')"
>
Reload page
{{ t('Reload page') }}
</UButton>
</div>
</div>
+5 -2
View File
@@ -1,4 +1,5 @@
<script setup lang="ts">
import { t } from '@/i18n'
import { computed } from 'vue'
const props = defineProps<{
@@ -17,8 +18,10 @@ const roleClass = computed(() =>
color="neutral"
variant="soft"
:class="roleClass"
:ui="{ base: 'h-5 rounded-none px-1.75 py-0 font-mono text-[0.6875rem] font-medium' }"
:ui="{
base: 'h-5 rounded-none px-1.75 py-0 font-mono text-[0.6875rem] font-medium',
}"
>
{{ role }}
{{ t(role) }}
</UBadge>
</template>
+13 -11
View File
@@ -1,4 +1,6 @@
<script setup lang="ts">
import { t } from '@/i18n'
import { computed, onMounted, onUnmounted, ref } from 'vue'
const props = defineProps<{
@@ -36,24 +38,24 @@ function formatClock(date: Date) {
function formatElapsed(diffMs: number) {
const minutes = Math.floor(diffMs / MINUTE_MS)
if (minutes < 60) {
return `${minutes} min`
return t('{0} min', [minutes])
}
const hours = Math.floor(minutes / 60)
if (hours < 24) {
return `${hours} h`
return t('{0} h', [hours])
}
return `${Math.floor(hours / 24)} d`
return t('{0} d', [Math.floor(hours / 24)])
}
const savedText = computed(() => {
if (!props.savedAt) {
return 'Saved'
return t('Saved')
}
const diffMs = now.value - props.savedAt.getTime()
if (diffMs < HOUR_MS) {
return `Saved at ${formatClock(props.savedAt)}`
return t('Saved at {0}', [formatClock(props.savedAt)])
}
return `Saved ${formatElapsed(diffMs)} ago`
return t('Saved {0} ago', [formatElapsed(diffMs)])
})
</script>
@@ -73,15 +75,15 @@ const savedText = computed(() => {
</template>
<template v-else-if="state === 'dirty'">
<UIcon name="i-lucide-circle-dot" class="size-3.5" />
<span>Unsaved changes</span>
<span>{{ t('Unsaved changes') }}</span>
</template>
<template v-else-if="state === 'saving'">
<UIcon name="i-lucide-loader-circle" class="size-3.5 animate-spin" />
<span>Saving…</span>
<span>{{ t('Saving…') }}</span>
</template>
<template v-else-if="state === 'error'">
<UIcon name="i-lucide-circle-alert" class="size-3.5" />
<span>Save failed</span>
<span>{{ t('Save failed') }}</span>
<UButton
size="xs"
color="neutral"
@@ -90,12 +92,12 @@ const savedText = computed(() => {
class="h-6 gap-1 px-2 text-xs text-highlighted"
@click="$emit('retry')"
>
Retry
{{ t('Retry') }}
</UButton>
</template>
<template v-else-if="state === 'offline'">
<UIcon name="i-lucide-wifi-off" class="size-3.5" />
<span>Waiting for connection</span>
<span>{{ t('Waiting for connection') }}</span>
</template>
</span>
</template>
+9 -5
View File
@@ -1,4 +1,6 @@
<script setup lang="ts">
import { t } from '@/i18n'
import { computed } from 'vue'
import { useCopyToClipboard } from '@/composables/useCopyToClipboard'
@@ -12,7 +14,7 @@ const emit = defineEmits<{
const { copied, copy } = useCopyToClipboard()
const buttonLabel = computed(() => (copied.value ? 'Copied' : 'Copy'))
const buttonLabel = computed(() => (copied.value ? t('Copied') : t('Copy')))
const buttonIcon = computed(() => (copied.value ? 'i-lucide-check' : 'i-lucide-copy'))
async function copyLine() {
@@ -27,7 +29,9 @@ async function copyLine() {
<template>
<div class="flex flex-col gap-2">
<span class="text-sm font-medium text-highlighted">For whoever manages the server</span>
<span class="text-sm font-medium text-highlighted">{{
t('For whoever manages the server')
}}</span>
<div class="flex flex-col gap-2 md:flex-row md:items-start">
<code
class="min-w-0 flex-1 truncate bg-elevated px-3 py-2 font-mono text-code/5.5 text-highlighted"
@@ -46,8 +50,8 @@ async function copyLine() {
{{ buttonLabel }}
</UButton>
</div>
<span class="text-xs/5 text-muted"
>They run this once on the server. After that, Update and Restart appear here.</span
>
<span class="text-xs/5 text-muted">{{
t('They run this once on the server. After that, Update and Restart appear here.')
}}</span>
</div>
</template>
+19 -8
View File
@@ -1,4 +1,6 @@
<script setup lang="ts">
import { t } from '@/i18n'
import { computed } from 'vue'
export type UpdateBannerState = 'available' | 'ready'
@@ -11,14 +13,23 @@ const props = defineProps<{
const COPY = {
available: {
icon: 'i-lucide-circle-arrow-up',
title: (version: string) => `Version ${version} is available`,
description: 'Update it from Settings. It takes about 2 minutes and the site stays online.',
get title() {
return (version: string) => t('Version {0} is available', [version])
},
get description() {
return t('Update it from Settings. It takes about 2 minutes and the site stays online.')
},
},
ready: {
icon: 'i-lucide-circle-check',
title: (version: string) => `Version ${version} is ready to install`,
description:
'Restart from Settings to finish. The site is unavailable for about 10 seconds.',
get title() {
return (version: string) => t('Version {0} is ready to install', [version])
},
get description() {
return t(
'Restart from Settings to finish. The site is unavailable for about 10 seconds.',
)
},
},
} as const
@@ -33,7 +44,7 @@ const emit = defineEmits<{
<template>
<UAlert
role="region"
aria-label="Update available"
:aria-label="t('Update available')"
color="info"
variant="soft"
orientation="horizontal"
@@ -56,13 +67,13 @@ const emit = defineEmits<{
class="h-11 flex-1 justify-center border border-accented bg-default px-3 text-highlighted ring-0 md:h-8 md:flex-none"
@click="emit('go-to-settings')"
>
Go to Settings
{{ t('Go to Settings') }}
</UButton>
<UButton
color="neutral"
variant="ghost"
icon="i-lucide-x"
aria-label="Dismiss"
:aria-label="t('Dismiss')"
class="size-11 shrink-0 justify-center text-muted md:size-8"
:ui="{ leadingIcon: 'size-4' }"
@click="emit('dismiss')"
+10 -1
View File
@@ -1,3 +1,12 @@
<script setup lang="ts">
import { t } from '@/i18n'
</script>
<template>
<UBadge label="Update" color="info" variant="solid" class="ml-auto h-4.5 px-1.5 py-0 text-xs" />
<UBadge
:label="t('Update')"
color="info"
variant="solid"
class="ml-auto h-4.5 px-1.5 py-0 text-xs"
/>
</template>
+161 -51
View File
@@ -1,4 +1,6 @@
<script setup lang="ts">
import { t } from '@/i18n'
import { computed, nextTick, ref, watch } from 'vue'
import ReleaseNotes from '@/components/ReleaseNotes.vue'
import ServerCommand from '@/components/ServerCommand.vue'
@@ -61,8 +63,12 @@ const READY_HINT =
const LIMITED_BLOCK: Block = {
tone: 'info',
icon: 'i-lucide-info',
title: 'Too many tries',
text: 'Wait a moment before trying again.',
get title() {
return t('Too many tries')
},
get text() {
return t('Wait a moment before trying again.')
},
}
const ZERO_PROGRESS = { percent: 0, downloaded_bytes: 0, total_bytes: 0 }
@@ -147,47 +153,90 @@ const applyBlocks = computed<Record<ApplyView, Block>>(() => ({
available: {
tone: 'info',
icon: 'i-lucide-circle-arrow-up',
title: `Version ${newVersion.value} is available`,
text: `You have ${props.status.current}. Update downloads the new version and checks it. Nothing changes until you restart.`,
get title() {
return t('Version {0} is available', [newVersion.value])
},
get text() {
return t(
'You have {0}. Update downloads the new version and checks it. Nothing changes until you restart.',
[props.status.current],
)
},
meta: lastChecked.value,
},
downloading: {
tone: 'info',
icon: 'i-lucide-download',
title: `Downloading version ${newVersion.value}…`,
text: 'You can leave this page. The download continues and your site stays online.',
get title() {
return t('Downloading version {0}…', [newVersion.value])
},
get text() {
return t('You can leave this page. The download continues and your site stays online.')
},
},
verifying: {
tone: 'info',
icon: 'i-lucide-shield-check',
title: 'Checking the download…',
text: 'Making sure the file is complete and safe to install. This takes a few seconds.',
get title() {
return t('Checking the download…')
},
get text() {
return t(
'Making sure the file is complete and safe to install. This takes a few seconds.',
)
},
},
ready: {
tone: 'success',
icon: 'i-lucide-circle-check',
title: `Version ${newVersion.value} is ready to install`,
text: 'The download is checked. Restart to install it. Your site will be unavailable for about 10 seconds.',
meta: 'Nothing has changed yet.',
get title() {
return t('Version {0} is ready to install', [newVersion.value])
},
get text() {
return t(
'The download is checked. Restart to install it. Your site will be unavailable for about 10 seconds.',
)
},
meta: t('Nothing has changed yet.'),
},
'download-failed': {
tone: 'error',
icon: 'i-lucide-circle-x',
title: "The download didn't finish",
text: 'The connection dropped before the file was complete. Nothing was changed and your site is running as before.',
get title() {
return t("The download didn't finish")
},
get text() {
return t(
'The connection dropped before the file was complete. Nothing was changed and your site is running as before.',
)
},
},
'verification-failed': {
tone: 'warning',
icon: 'i-lucide-triangle-alert',
title: "We didn't use this update",
text: "The file didn't pass our safety check, so it was not installed. Nothing was changed. Try downloading it again. If this keeps happening, contact RenewCMS support.",
get title() {
return t("We didn't use this update")
},
get text() {
return t(
"The file didn't pass our safety check, so it was not installed. Nothing was changed. Try downloading it again. If this keeps happening, contact RenewCMS support.",
)
},
},
'rolled-back': {
tone: 'warning',
icon: 'i-lucide-triangle-alert',
title: "The update didn't install",
text: 'RenewCMS put the previous version back, so your site is running as before. Nothing was lost.',
meta: 'What to do next: try again in a few minutes. If it fails again, ask whoever manages your server to check the update.',
get title() {
return t("The update didn't install")
},
get text() {
return t(
'RenewCMS put the previous version back, so your site is running as before. Nothing was lost.',
)
},
meta: t(
'What to do next: try again in a few minutes. If it fails again, ask whoever manages your server to check the update.',
),
},
limited: LIMITED_BLOCK,
}))
@@ -196,13 +245,23 @@ const progressView = computed<ProgressView | null>(() => {
if (applyView.value === 'downloading') {
const progress = props.status.progress ?? ZERO_PROGRESS
return {
label: `Downloading version ${newVersion.value}`,
get label() {
return t('Downloading version {0}', [newVersion.value])
},
text: props.status.progress ? formatDownloadProgress(progress) : '0%',
value: progress.percent,
}
}
if (applyView.value === 'verifying') {
return { label: VERIFYING_ANNOUNCEMENT, text: 'Almost there', value: null }
return {
get label() {
return t(VERIFYING_ANNOUNCEMENT)
},
get text() {
return t('Almost there')
},
value: null,
}
}
return null
})
@@ -211,23 +270,25 @@ const announcement = computed(() => {
if (applyView.value === 'downloading') {
return downloadAnnouncement(newVersion.value, props.status.progress?.percent ?? 0)
}
return applyView.value === 'verifying' ? VERIFYING_ANNOUNCEMENT : ''
return applyView.value === 'verifying' ? t(VERIFYING_ANNOUNCEMENT) : ''
})
const hint = computed(() => {
if (applyView.value === 'available') {
return AVAILABLE_HINT
return t(AVAILABLE_HINT)
}
if (applyView.value === 'ready') {
return READY_HINT
return t(READY_HINT)
}
return showRestartHint.value ? RESTART_HINT : null
return showRestartHint.value ? t(RESTART_HINT) : null
})
function applyButton(view: ApplyView): ActionButton {
if (isStarting.value || view === 'downloading' || view === 'verifying') {
return {
label: 'Updating…',
get label() {
return t('Updating…')
},
icon: 'i-lucide-loader-circle',
primary: false,
disabled: true,
@@ -236,7 +297,9 @@ function applyButton(view: ApplyView): ActionButton {
}
if (view === 'ready') {
return {
label: 'Restart',
get label() {
return t('Restart')
},
icon: 'i-lucide-power',
primary: true,
disabled: false,
@@ -249,7 +312,9 @@ function applyButton(view: ApplyView): ActionButton {
}
if (view === 'available') {
return {
label: 'Update',
get label() {
return t('Update')
},
icon: 'i-lucide-download',
primary: true,
disabled: false,
@@ -257,7 +322,9 @@ function applyButton(view: ApplyView): ActionButton {
}
}
return {
label: 'Try again',
get label() {
return t('Try again')
},
icon: 'i-lucide-rotate-cw',
primary: true,
disabled: false,
@@ -270,8 +337,12 @@ const block = computed<Block>(() => {
return {
tone: 'neutral',
icon: 'i-lucide-loader-circle',
title: 'Checking for updates…',
text: 'This usually takes a few seconds.',
get title() {
return t('Checking for updates…')
},
get text() {
return t('This usually takes a few seconds.')
},
}
}
if (isOwner.value && props.phase === 'limited') {
@@ -281,8 +352,14 @@ const block = computed<Block>(() => {
return {
tone: 'warning',
icon: 'i-lucide-wifi-off',
title: "Couldn't check for updates",
text: "RenewCMS couldn't reach the update server. Check your connection and try again. Your site isn't affected.",
get title() {
return t("Couldn't check for updates")
},
get text() {
return t(
"RenewCMS couldn't reach the update server. Check your connection and try again. Your site isn't affected.",
)
},
meta: lastChecked.value,
}
}
@@ -293,8 +370,12 @@ const block = computed<Block>(() => {
return {
tone: 'success',
icon: 'i-lucide-circle-check',
title: `You're on version ${displayVersion(props.status.current)}`,
text: 'The update is installed and your site is running again.',
get title() {
return t("You're on version {0}", [displayVersion(props.status.current)])
},
get text() {
return t('The update is installed and your site is running again.')
},
meta: props.status.finished_at ? formatUpdatedAt(props.status.finished_at) : undefined,
}
}
@@ -302,25 +383,42 @@ const block = computed<Block>(() => {
return {
tone: 'info',
icon: 'i-lucide-info',
title: `Version ${newVersion.value} is available`,
text: "This site can't update itself from the browser yet. Whoever manages your server can set it up once, or update RenewCMS for you.",
get title() {
return t('Version {0} is available', [newVersion.value])
},
get text() {
return t(
"This site can't update itself from the browser yet. Whoever manages your server can set it up once, or update RenewCMS for you.",
)
},
}
}
if (props.status.available) {
return {
tone: 'info',
icon: 'i-lucide-circle-arrow-up',
title: `Version ${newVersion.value} is available`,
text: `You have ${props.status.current}. Only the owner can update RenewCMS. Ask the owner of this site.`,
get title() {
return t('Version {0} is available', [newVersion.value])
},
get text() {
return t(
'You have {0}. Only the owner can update RenewCMS. Ask the owner of this site.',
[props.status.current],
)
},
}
}
return {
tone: 'success',
icon: 'i-lucide-circle-check',
title: "You're up to date",
text: isOwner.value
? 'You have the latest version of RenewCMS.'
: 'Only the owner can update or restart the site.',
get title() {
return t("You're up to date")
},
get text() {
return isOwner.value
? t('You have the latest version of RenewCMS.')
: t('Only the owner can update or restart the site.')
},
meta: isOwner.value ? lastChecked.value : undefined,
}
})
@@ -342,7 +440,9 @@ const showServerGuidance = computed(
)
const plainRestartButton: ActionButton = {
label: 'Restart',
get label() {
return t('Restart')
},
icon: 'i-lucide-power',
primary: false,
disabled: false,
@@ -357,7 +457,9 @@ const actionButtons = computed<ActionButton[]>(() => {
if (props.phase === 'checking') {
return [
{
label: 'Checking…',
get label() {
return t('Checking…')
},
icon: 'i-lucide-loader-circle',
primary: false,
disabled: true,
@@ -367,7 +469,9 @@ const actionButtons = computed<ActionButton[]>(() => {
}
if (props.phase === 'failed') {
return withPlainRestart({
label: 'Try again',
get label() {
return t('Try again')
},
icon: 'i-lucide-rotate-cw',
primary: true,
disabled: false,
@@ -384,7 +488,9 @@ const actionButtons = computed<ActionButton[]>(() => {
return []
}
return withPlainRestart({
label: 'Check for updates',
get label() {
return t('Check for updates')
},
icon: 'i-lucide-refresh-cw',
primary: false,
disabled: false,
@@ -401,14 +507,18 @@ function withPlainRestart(button: ActionButton) {
function limitedButton(action: ButtonAction): ActionButton {
return props.retryActive
? {
label: `Try again in ${props.retryLabel}`,
get label() {
return t('Try again in {0}', [props.retryLabel])
},
icon: 'i-lucide-clock',
primary: false,
disabled: true,
action: null,
}
: {
label: 'Try again',
get label() {
return t('Try again')
},
icon: 'i-lucide-rotate-cw',
primary: false,
disabled: false,
@@ -458,7 +568,7 @@ const notes = computed(() => {
tabindex="-1"
class="self-start text-lg font-semibold text-highlighted focus:outline-2 focus:outline-offset-4 focus:outline-primary"
>
Updates
{{ t('Updates') }}
</h2>
<UpdateStatusBlock
@@ -516,7 +626,7 @@ const notes = computed(() => {
rel="noopener noreferrer"
class="inline-flex items-center gap-1.5 self-start text-sm font-medium text-highlighted underline underline-offset-3"
>
How to update
{{ t('How to update') }}
<UIcon name="i-lucide-arrow-up-right" class="size-3.5" />
</a>
@@ -527,7 +637,7 @@ const notes = computed(() => {
rel="noopener noreferrer"
class="inline-flex items-center gap-1.5 self-start text-sm font-medium text-highlighted underline underline-offset-3"
>
Read the release notes
{{ t('Read the release notes') }}
<UIcon name="i-lucide-arrow-up-right" class="size-3.5" />
</a>
</section>
+3 -1
View File
@@ -1,4 +1,6 @@
<script setup lang="ts">
import { t } from '@/i18n'
import InviteStatusBadge from '@/components/InviteStatusBadge.vue'
import RoleBadge from '@/components/RoleBadge.vue'
import UserRowAction from '@/components/UserRowAction.vue'
@@ -62,7 +64,7 @@ function rowKey(item: UserListItem) {
<div class="flex items-center gap-2 text-xs text-muted">
<RoleBadge :role="userRole(item)" />
<InviteStatusBadge :status="item.status" size="sm" />
<span>Created {{ formatCreatedDate(item.created_at) }}</span>
<span>{{ t('Created') }} {{ formatCreatedDate(item.created_at) }}</span>
</div>
<div v-if="canManage && item.kind === 'invite'" class="flex pt-1">
<UserRowAction
+5 -3
View File
@@ -1,4 +1,6 @@
<script setup lang="ts">
import { t } from '@/i18n'
defineProps<{
email: string
isYou: boolean
@@ -13,8 +15,8 @@ defineProps<{
:class="large ? 'text-[0.9375rem]/[21px]' : ''"
>{{ email }}</span
>
<span v-if="isYou" class="shrink-0 font-mono text-[0.6875rem] font-medium text-muted"
>you</span
>
<span v-if="isYou" class="shrink-0 font-mono text-[0.6875rem] font-medium text-muted">{{
t('you')
}}</span>
</span>
</template>
+39 -5
View File
@@ -1,7 +1,10 @@
<script setup lang="ts">
import { t } from '@/i18n'
import { computed, ref } from 'vue'
import { useMediaQuery } from '@vueuse/core'
import { useEventListener, useMediaQuery } from '@vueuse/core'
import { colorModeOptions, useAccountMenu } from '@/composables/useAccountMenu'
import LanguagePicker from '@/components/LanguagePicker.vue'
const DESKTOP_QUERY = '(min-width: 64rem)'
const ITEM_CLASS = 'h-8 items-center py-0'
@@ -23,11 +26,35 @@ const { colorMode, name, initials, onProfile, goToProfile } = useAccountMenu(()
const isDesktop = useMediaQuery(DESKTOP_QUERY)
const open = ref(false)
const chip = ref<{ $el: HTMLElement } | null>(null)
const languagePicker = ref<{ $el: HTMLElement } | null>(null)
function focusLanguage(event: KeyboardEvent) {
const target = event.target
if (!(target instanceof HTMLElement)) return
if (target.closest('[role="menu"]') !== languagePicker.value?.$el.closest('[role="menu"]'))
return
if (target.closest('[role="combobox"]')) return
const entries = Array.from(
target
.closest('[role="menu"]')
?.querySelectorAll<HTMLElement>('[role="menuitem"], [role="menuitemcheckbox"]') ?? [],
)
const movesToLanguage =
event.key === 'End' ||
(event.key === 'ArrowDown' && target === entries[entries.length - 1]) ||
(event.key === 'ArrowUp' && target === entries[0])
if (movesToLanguage) {
event.preventDefault()
languagePicker.value?.$el.querySelector<HTMLElement>('[role="combobox"]')?.focus()
}
}
useEventListener(document, 'keydown', focusLanguage, { capture: true })
const chipActive = computed(() => open.value || onProfile.value)
const triggerAriaLabel = computed(() =>
props.collapsed ? `${name.value} — account menu` : undefined,
props.collapsed ? t('{0} — account menu', [name.value]) : undefined,
)
const chipClass = computed(() => [
@@ -59,7 +86,9 @@ const items = computed(() => [
class: 'mb-1 border-b border-default px-2 pt-2 pb-2.5',
},
{
label: 'Profile',
get label() {
return t('Profile')
},
icon: 'i-lucide-circle-user',
class: onProfile.value ? `${ITEM_CLASS} bg-elevated` : ITEM_CLASS,
onSelect: goToProfile,
@@ -79,7 +108,9 @@ const items = computed(() => [
})),
[
{
label: 'Log out',
get label() {
return t('Log out')
},
icon: 'i-lucide-log-out',
class: ITEM_CLASS,
onSelect: () => emit('logout'),
@@ -101,7 +132,7 @@ const items = computed(() => [
color="neutral"
variant="ghost"
block
title="Account"
:title="t('Account')"
:aria-expanded="chipExpanded"
:class="chipClass"
:trailing-icon="collapsed ? undefined : 'i-lucide-chevrons-up-down'"
@@ -128,5 +159,8 @@ const items = computed(() => [
<span class="truncate text-xs font-normal text-muted">{{ email }}</span>
</span>
</template>
<template #content-bottom>
<LanguagePicker ref="languagePicker" />
</template>
</UDropdownMenu>
</template>
+9 -3
View File
@@ -1,4 +1,6 @@
<script setup lang="ts">
import { t } from '@/i18n'
import { computed } from 'vue'
import type { UserListItem } from '@/composables/useUsers'
@@ -12,13 +14,17 @@ const emit = defineEmits<{ cancel: []; renew: [] }>()
const actions = {
pending: {
label: 'Cancel invite',
get label() {
return t('Cancel invite')
},
icon: 'i-lucide-user-x',
textClass: 'text-error',
event: 'cancel',
},
expired: {
label: 'Renew invite',
get label() {
return t('Renew invite')
},
icon: 'i-lucide-refresh-cw',
textClass: 'text-highlighted',
event: 'renew',
@@ -29,7 +35,7 @@ const action = computed(() => {
return props.item.kind === 'invite' ? actions[props.item.status] : null
})
const label = computed(() => (props.busy ? 'Renewing…' : (action.value?.label ?? '')))
const label = computed(() => (props.busy ? t('Renewing…') : (action.value?.label ?? '')))
const icon = computed(() => (props.busy ? 'i-lucide-loader-circle' : action.value?.icon))
const sizeClass = computed(() =>
props.large ? 'h-11 gap-2 px-3.5 text-sm' : 'h-7 gap-1.5 px-2.25 text-xs',
+8 -6
View File
@@ -1,4 +1,6 @@
<script setup lang="ts">
import { t } from '@/i18n'
import InviteStatusBadge from '@/components/InviteStatusBadge.vue'
import RoleBadge from '@/components/RoleBadge.vue'
import UserRowAction from '@/components/UserRowAction.vue'
@@ -37,18 +39,18 @@ function rowKey(item: UserListItem) {
<template>
<div class="border border-default bg-default" :aria-busy="loading ? 'true' : undefined">
<div :class="ROW_GRID" class="h-9 bg-elevated font-medium text-muted">
<span>Email</span>
<span>Display name</span>
<span>Role</span>
<span>Invite status</span>
<span>{{ t('Email') }}</span>
<span>{{ t('Display name') }}</span>
<span>{{ t('Role') }}</span>
<span>{{ t('Invite status') }}</span>
<span class="flex items-center gap-1 text-highlighted">
Created
{{ t('Created') }}
<UIcon name="i-lucide-arrow-down" class="size-3 shrink-0" />
</span>
<span></span>
</div>
<template v-if="loading">
<span role="status" class="sr-only">Loading users</span>
<span role="status" class="sr-only">{{ t('Loading users') }}</span>
<div
v-for="n in USER_SKELETON_ROWS"
:key="n"
+19 -3
View File
@@ -1,3 +1,4 @@
import { t } from '@/i18n'
import { computed } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { useColorMode, type ColorModePreference } from '@/composables/useColorMode'
@@ -6,9 +7,24 @@ import { accountInitials, accountName } from '@/lib/accountName'
const PROFILE_PATH = '/profile'
export const colorModeOptions: { label: string; value: ColorModePreference }[] = [
{ label: 'System', value: 'system' },
{ label: 'Light', value: 'light' },
{ label: 'Dark', value: 'dark' },
{
get label() {
return t('System')
},
value: 'system',
},
{
get label() {
return t('Light')
},
value: 'light',
},
{
get label() {
return t('Dark')
},
value: 'dark',
},
]
export function useAccountMenu(account: () => { username: string; displayName?: string }) {
+54
View File
@@ -0,0 +1,54 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import { initializeLocale, locale, setLocale, t, formatNumber } from './index'
afterEach(() => {
vi.restoreAllMocks()
localStorage.clear()
initializeLocale()
})
describe('Resolve the language before rendering', () => {
it('keeps English default with a French browser', () => {
vi.spyOn(navigator, 'language', 'get').mockReturnValue('fr-FR')
localStorage.clear()
initializeLocale()
expect([locale.value, document.documentElement.lang]).toEqual(['en', 'en'])
})
it.each([null, 'en', 'fr', 'unsupported'])(
'resolves stored %s independently of browser language',
(stored) => {
if (stored) localStorage.setItem('renewcms-language', stored)
initializeLocale()
expect([locale.value, document.documentElement.lang]).toEqual(
stored === 'fr' ? ['fr', 'fr'] : ['en', 'en'],
)
},
)
it('defaults to English when preference storage is unreadable', () => {
setLocale('fr')
vi.spyOn(Storage.prototype, 'getItem').mockImplementation(() => {
throw new Error('denied')
})
initializeLocale()
expect([locale.value, document.documentElement.lang]).toEqual(['en', 'en'])
})
})
describe('Remember language on this browser', () => {
it('restores the selected French language on initialization', () => {
setLocale('fr')
initializeLocale()
expect(locale.value).toBe('fr')
})
it('switches when storage cannot be written', () => {
vi.spyOn(Storage.prototype, 'setItem').mockImplementation(() => {
throw new Error('denied')
})
setLocale('fr')
expect([t('Log in'), document.documentElement.lang]).toEqual(['Se connecter', 'fr'])
})
it('formats numbers according to the selected locale', () => {
setLocale('fr')
expect(formatNumber(1234.5)).toBe(new Intl.NumberFormat('fr').format(1234.5))
})
})
+51
View File
@@ -0,0 +1,51 @@
import { readonly, ref } from 'vue'
import { messages, type MessageKey } from './messages'
export type Locale = 'en' | 'fr'
const STORAGE_KEY = 'renewcms-language'
const currentLocale = ref<Locale>('en')
export const locale = readonly(currentLocale)
export function initializeLocale() {
let stored: string | null = null
try {
stored = localStorage.getItem(STORAGE_KEY)
} catch {
stored = null
}
currentLocale.value = stored === 'fr' ? 'fr' : 'en'
document.documentElement.lang = currentLocale.value
}
export function setLocale(value: Locale) {
currentLocale.value = value
document.documentElement.lang = value
try {
localStorage.setItem(STORAGE_KEY, value)
} catch {
return
}
}
export function t(key: MessageKey, values: readonly (string | number | undefined)[] = []) {
const message = currentLocale.value === 'fr' ? (messages[key as MessageKey] ?? key) : key
return message.replace(/\{(\d+)\}/g, (placeholder, index: string) => {
const value = values[Number(index)]
return value === undefined
? placeholder
: typeof value === 'number' && currentLocale.value === 'fr'
? formatNumber(value)
: String(value)
})
}
export function translate(key: string) {
const source = Object.entries(messages).find(([, french]) => french === key)?.[0] ?? key
return t(source as MessageKey)
}
export function formatNumber(value: number, options?: Intl.NumberFormatOptions) {
return new Intl.NumberFormat(currentLocale.value, options).format(value)
}
initializeLocale()
+827
View File
@@ -0,0 +1,827 @@
export const messages = {
'Not found': 'Introuvable',
'Edit article': 'Modifier l’article',
unselected: 'désélectionnée',
'Log in': 'Se connecter',
Language: 'Langue',
Profile: 'Profil',
'Log out': 'Se déconnecter',
Account: 'Compte',
'Your account menu': 'Menu de votre compte',
Home: 'Accueil',
Articles: 'Articles',
Media: 'Médias',
Users: 'Utilisateurs',
Integration: 'Intégration',
Settings: 'Paramètres',
System: 'Système',
Light: 'Clair',
Dark: 'Sombre',
Main: 'Principale',
Breadcrumb: 'Fil d’Ariane',
'Skip to content': 'Aller au contenu',
Save: 'Enregistrer',
'Saving…': 'Enregistrement…',
Saved: 'Enregistré',
'Save failed': 'Échec de l’enregistrement',
'Unsaved changes': 'Modifications non enregistrées',
'Display name': 'Nom d’affichage',
Username: 'Nom d’utilisateur',
Email: 'E-mail',
Password: 'Mot de passe',
'Your username': 'Votre nom d’utilisateur',
'First time here?': 'Première visite ?',
'Forgot password?': 'Mot de passe oublié ?',
'Create the owner account': 'Créer le compte propriétaire',
'Welcome back. Log in to manage your articles.':
'Bienvenue. Connectez-vous pour gérer vos articles.',
"That username and password don't match":
'Le nom d’utilisateur et le mot de passe ne correspondent pas',
'Check both and try again. Passwords are case-sensitive.':
'Vérifiez les deux et réessayez. Les mots de passe sont sensibles à la casse.',
'Too many attempts': 'Trop de tentatives',
'For your security, wait a moment before trying again.':
'Pour votre sécurité, patientez avant de réessayer.',
"Couldn't reach RenewCMS": 'Impossible de joindre RenewCMS',
'Check your connection and try again.': 'Vérifiez votre connexion et réessayez.',
'Your session expired': 'Votre session a expiré',
'Log in again to pick up where you left off. Unsaved changes are kept on this device.':
'Reconnectez-vous pour reprendre votre travail. Les modifications non enregistrées sont conservées sur cet appareil.',
'Password changed': 'Mot de passe modifié',
'Log in with your new password.': 'Connectez-vous avec votre nouveau mot de passe.',
'Logging in…': 'Connexion…',
'Try again in {0}': 'Réessayez dans {0}',
'Use at least 3 characters.': 'Utilisez au moins 3 caractères.',
'Enter your password.': 'Saisissez votre mot de passe.',
'Hide password': 'Masquer le mot de passe',
'Show password': 'Afficher le mot de passe',
'Your account on this RenewCMS workspace.': 'Votre compte dans cet espace RenewCMS.',
'Shown instead of your username across the back-office and in the emails RenewCMS sends. Leave it empty to use your username.':
'Remplace votre nom d’utilisateur dans l’administration et les e-mails envoyés par RenewCMS. Laissez ce champ vide pour utiliser votre nom d’utilisateur.',
'(optional)': '(facultatif)',
'e.g. Marta Alves': 'p. ex. Marta Alves',
'Use 60 characters or fewer.': 'Utilisez 60 caractères maximum.',
"Couldn't save your display name. Your text is still here. Try again in a moment.":
'Impossible d’enregistrer votre nom d’affichage. Votre texte est conservé. Réessayez dans un instant.',
'You appear as “{0}”.': 'Vous apparaissez sous le nom « {0} ».',
'You appear as “{0}”, your username.':
'Vous apparaissez sous le nom « {0} », votre nom d’utilisateur.',
'Set when your account was created. You log in with your username, even after adding a display name.':
'Défini à la création de votre compte. Vous vous connectez avec votre nom d’utilisateur, même après l’ajout d’un nom d’affichage.',
"To change it, we'll email a reset link to {0}. The link works once, within 1 hour.":
'Pour le modifier, nous enverrons un lien de réinitialisation à {0}. Ce lien est utilisable une fois, pendant 1 heure.',
'Reset link sent': 'Lien de réinitialisation envoyé',
'Too many requests': 'Trop de demandes',
"You've asked for several links recently. Wait a few minutes, then try again.":
'Vous avez demandé plusieurs liens récemment. Patientez quelques minutes, puis réessayez.',
"Couldn't send the link": 'Impossible d’envoyer le lien',
'Check {0}. The link works once, within 1 hour.':
'Consultez {0}. Ce lien est utilisable une fois, pendant 1 heure.',
'Sending…': 'Envoi…',
'Send again': 'Renvoyer',
'Send reset link': 'Envoyer le lien de réinitialisation',
'Display name saved': 'Nom d’affichage enregistré',
'Display name removed': 'Nom d’affichage supprimé',
'You now appear as {0} across RenewCMS and in emails.':
'Vous apparaissez désormais sous le nom {0} dans RenewCMS et les e-mails.',
'You now appear as {0}, your username.':
'Vous apparaissez désormais sous le nom {0}, votre nom d’utilisateur.',
'Leave without saving?': 'Quitter sans enregistrer ?',
"Your display name change hasn't been saved.":
'Votre nouveau nom d’affichage n’a pas été enregistré.',
'Keep editing': 'Continuer à modifier',
'Discard changes': 'Abandonner les modifications',
Cancel: 'Annuler',
Delete: 'Supprimer',
'Deleting…': 'Suppression…',
Retry: 'Réessayer',
'Try again': 'Réessayer',
'Try again in a moment.': 'Réessayez dans un instant.',
'Welcome to RenewCMS': 'Bienvenue dans RenewCMS',
'Welcome to RenewCMS.': 'Bienvenue dans RenewCMS.',
"This creates the single admin account for this site. Once it's verified, registration closes.":
'Crée le compte administrateur unique de ce site. Une fois vérifié, les inscriptions sont fermées.',
"We'll send a verification link here.": 'Nous enverrons un lien de vérification ici.',
"Letters and numbers, 3 to 20 characters. You'll use it to log in.":
'Lettres et chiffres, de 3 à 20 caractères. Vous l’utiliserez pour vous connecter.',
'At least 8 characters': 'Au moins 8 caractères',
'Passwords match': 'Les mots de passe correspondent',
'Use 3 to 20 characters.': 'Utilisez de 3 à 20 caractères.',
'Use 3 to 200 characters.': 'Utilisez de 3 à 200 caractères.',
'Enter a full email address, like name@example.com.':
'Saisissez une adresse e-mail complète, comme nom@exemple.fr.',
"The passwords don't match.": 'Les mots de passe ne correspondent pas.',
'That username is taken. Try another.':
'Ce nom d’utilisateur est déjà pris. Essayez-en un autre.',
"Something in the form isn't valid. Check the highlighted fields.":
'Un élément du formulaire n’est pas valide. Vérifiez les champs signalés.',
"Couldn't create the account": 'Impossible de créer le compte',
'Already have an account?': 'Vous avez déjà un compte ?',
'Already set up?': 'Déjà configuré ?',
'e.g. marta': 'p. ex. marta',
'e.g. lea': 'p. ex. lea',
'Check your email': 'Consultez vos e-mails',
'We sent a verification link to {0}. It expires in 2 hours.':
'Nous avons envoyé un lien de vérification à {0}. Il expire dans 2 heures.',
'We sent a verification link to the email you registered with. It expires in 2 hours.':
'Nous avons envoyé un lien de vérification à votre adresse d’inscription. Il expire dans 2 heures.',
"Opened it on another device? That works too; you'll log in there first. Can't find it? Check your spam folder. If the link has expired, cancel your registration and start again.":
'Ouvert sur un autre appareil ? Cela fonctionne aussi ; connectez-vous d’abord sur cet appareil. Vous ne le trouvez pas ? Vérifiez vos courriers indésirables. Si le lien a expiré, annulez votre inscription et recommencez.',
'Cancel registration and start over': 'Annuler l’inscription et recommencer',
'Cancel your registration?': 'Annuler votre inscription ?',
'Your account will be deleted. You can register again with the same or a different email.':
'Votre compte sera supprimé. Vous pourrez vous réinscrire avec la même adresse e-mail ou une autre.',
'Cancel registration': 'Annuler l’inscription',
'Keep my account': 'Conserver mon compte',
'Cancelling…': 'Annulation…',
"Couldn't cancel registration": 'Impossible d’annuler l’inscription',
"Couldn't cancel registration, so it's still pending. Try again in a moment.":
'Impossible d’annuler l’inscription, qui reste en attente. Réessayez dans un instant.',
"There's nothing to cancel.": 'Il n’y a rien à annuler.',
'Registration cancelled': 'Inscription annulée',
'You can create the owner account again.': 'Vous pouvez recréer le compte propriétaire.',
'Verifying your account…': 'Vérification de votre compte…',
'This only takes a moment.': 'Cela ne prend qu’un instant.',
"You're verified": 'Votre compte est vérifié',
'Your account is verified': 'Votre compte est vérifié',
'Your account is already verified': 'Votre compte est déjà vérifié',
'Taking you to your dashboard…': 'Redirection vers votre tableau de bord…',
'Go to Home': 'Aller à l’accueil',
"This verification link doesn't work": 'Ce lien de vérification ne fonctionne pas',
'It may have expired (links last 2 hours) or already been used.':
'Il a peut-être expiré (les liens durent 2 heures) ou a déjà été utilisé.',
'Cancel your registration and start over to get a new link.':
'Annulez votre inscription et recommencez pour obtenir un nouveau lien.',
'Log in to finish verifying': 'Connectez-vous pour terminer la vérification',
"You opened this link somewhere you're not logged in. Log in here and we'll verify your account right after.":
'Vous avez ouvert ce lien sans être connecté. Connectez-vous ici et nous vérifierons votre compte aussitôt.',
'Log in and verify': 'Se connecter et vérifier',
'The verification code is missing. Open the link from your email again, or copy the full address into your browser.':
'Le code de vérification manque. Rouvrez le lien de votre e-mail ou copiez l’adresse complète dans votre navigateur.',
'This site already has an owner': 'Ce site a déjà un propriétaire',
'Log in instead.': 'Connectez-vous à la place.',
'Go to log in': 'Aller à la connexion',
'Back to log in': 'Retour à la connexion',
'Reset your password': 'Réinitialiser votre mot de passe',
"Enter the email for your account and we'll send you a link to choose a new password.":
'Saisissez l’adresse e-mail de votre compte et nous vous enverrons un lien pour choisir un nouveau mot de passe.',
'If an account exists for that email, a password reset link has been sent.':
'Si un compte existe pour cette adresse, un lien de réinitialisation a été envoyé.',
'The link expires in 1 hour.': 'Le lien expire dans 1 heure.',
'Wait a moment before asking for another link.': 'Patientez avant de demander un autre lien.',
'Wait a moment before trying again.': 'Patientez avant de réessayer.',
'New password': 'Nouveau mot de passe',
'Confirm new password': 'Confirmer le nouveau mot de passe',
"Pick something you haven't used here before. You'll log in with it next.":
'Choisissez un mot de passe que vous n’avez pas encore utilisé ici. Vous vous connecterez ensuite avec celui-ci.',
'This reset link has expired': 'Ce lien de réinitialisation a expiré',
'Reset links work once, for 1 hour. Request a new one and use it within the hour.':
'Les liens de réinitialisation fonctionnent une fois, pendant 1 heure. Demandez-en un nouveau et utilisez-le dans l’heure.',
'Request a new link': 'Demander un nouveau lien',
'This link is incomplete': 'Ce lien est incomplet',
'Part of the reset link is missing. Open it from your email again, or request a new one.':
'Une partie du lien de réinitialisation manque. Rouvrez-le depuis votre e-mail ou demandez-en un nouveau.',
'Open the link from your email again, or copy the full address into your browser.':
'Rouvrez le lien depuis votre e-mail ou copiez l’adresse complète dans votre navigateur.',
'Accept your invite': 'Accepter votre invitation',
'Checking your invite…': 'Vérification de votre invitation…',
'Choose a username and password to finish setting up your account.':
'Choisissez un nom d’utilisateur et un mot de passe pour terminer la configuration de votre compte.',
'This invite has expired': 'Cette invitation a expiré',
'Invites work once, for 3 days. Ask the person who invited you to send a new one.':
'Les invitations fonctionnent une fois, pendant 3 jours. Demandez à la personne qui vous a invité d’en envoyer une nouvelle.',
"This invite link doesn't work": 'Ce lien d’invitation ne fonctionne pas',
'It may be incomplete, or it may already have been used. If you already set up your account, log in instead.':
'Il est peut-être incomplet ou déjà utilisé. Si vous avez déjà configuré votre compte, connectez-vous.',
"Couldn't check your invite": 'Impossible de vérifier votre invitation',
'Your account is ready': 'Votre compte est prêt',
'Good morning': 'Bonjour',
'Good afternoon': 'Bon après-midi',
'Good evening': 'Bonsoir',
"Here's where things stand on your site.": 'Voici la situation de votre site.',
'Recently updated': 'Modifiés récemment',
'Write your first article': 'Écrire votre premier article',
'Write your first one. It stays a draft until you publish it.':
'Écrivez votre premier article. Il reste un brouillon jusqu’à sa publication.',
"Everything you've written, online and in draft.":
'Tous vos articles, publiés et en brouillon.',
"Everything you've written is online.": 'Tous vos articles sont publiés.',
'Nothing is online yet': 'Aucun article publié',
'Publish a draft and it will appear on your website.':
'Publiez un brouillon pour l’afficher sur votre site.',
'No drafts': 'Aucun brouillon',
Online: 'Publié',
Draft: 'Brouillon',
'Drafts {0}': 'Brouillons {0}',
'Online {0}': 'Publiés {0}',
'All {0}': 'Tous {0}',
All: 'Tous',
'New article': 'Nouvel article',
'All articles': 'Tous les articles',
'No articles yet': 'Aucun article',
'No articles match "{0}"': 'Aucun article ne correspond à « {0} »',
'Check the spelling, or search for a shorter part of the title.':
'Vérifiez l’orthographe ou recherchez une partie plus courte du titre.',
'Search by title': 'Rechercher par titre',
'Clear search': 'Effacer la recherche',
Title: 'Titre',
'First paragraph': 'Premier paragraphe',
'3 to 200 characters.': 'De 3 à 200 caractères.',
"The title can't be changed after creation.":
'Le titre ne peut pas être modifié après la création.',
"Start writing. You'll continue in the editor.":
'Commencez à écrire. Vous poursuivrez dans l’éditeur.',
'You can format it and add images in the editor.':
'Vous pouvez le mettre en forme et ajouter des images dans l’éditeur.',
'It starts as a draft, so nothing appears on your site until you publish it.':
'Il commence comme brouillon : rien n’apparaît sur votre site avant sa publication.',
"It's saved as a draft. Titles can't be changed later, so check it now.":
'Il est enregistré comme brouillon. Les titres ne peuvent pas être modifiés ensuite : vérifiez-le maintenant.',
'e.g. Choosing tiles for a small bathroom':
'p. ex. Choisir du carrelage pour une petite salle de bains',
'Add a first paragraph. It can be a single sentence.':
'Ajoutez un premier paragraphe. Une seule phrase suffit.',
"Couldn't create the article. Your text is still here. Try again in a moment.":
'Impossible de créer l’article. Votre texte est conservé. Réessayez dans un instant.',
'Draft created': 'Brouillon créé',
'"{0}" is saved as a draft.': '« {0} » est enregistré comme brouillon.',
Edit: 'Modifier',
Preview: 'Aperçu',
Editor: 'Éditeur',
Publish: 'Publier',
Unpublish: 'Dépublier',
'Save and publish': 'Enregistrer et publier',
'Publishing…': 'Publication…',
'Unpublishing…': 'Dépublication…',
'Article published': 'Article publié',
'Article unpublished': 'Article dépublié',
'"{0}" is now online.': '« {0} » est maintenant publié.',
'"{0}" is now offline.': '« {0} » est maintenant dépublié.',
"Couldn't publish": 'Impossible de publier',
"Couldn't unpublish": 'Impossible de dépublier',
'That article no longer exists': 'Cet article n’existe plus',
'That article no longer exists.': 'Cet article n’existe plus.',
'It was deleted somewhere else, so we removed it from the list.':
'Il a été supprimé ailleurs ; nous l’avons donc retiré de la liste.',
'Delete article': 'Supprimer l’article',
'Delete this article?': 'Supprimer cet article ?',
'"{0}" will be deleted.{1} This can\'t be undone.':
'« {0} » sera supprimé.{1} Cette action est irréversible.',
' Its images stay in your library.': ' Ses images restent dans votre bibliothèque.',
'Article deleted': 'Article supprimé',
'"{0}" is gone.': '« {0} » a été supprimé.',
"Couldn't delete the article, so it's still there. Try again in a moment.":
'Impossible de supprimer l’article, qui est toujours présent. Réessayez dans un instant.',
'Copy API URL': 'Copier l’URL de l’API',
'Copy the public API URL': 'Copier l’URL de l’API publique',
Copy: 'Copier',
Copied: 'Copié',
'Link copied': 'Lien copié',
'The API URL for "{0}" is on your clipboard.':
'L’URL de l’API de « {0} » est dans votre presse-papiers.',
"Couldn't copy the link": 'Impossible de copier le lien',
'Articles, sorted by last updated, newest first':
'Articles triés par dernière modification, du plus récent au plus ancien',
'Loading articles': 'Chargement des articles',
Loading: 'Chargement',
"Couldn't load your articles": 'Impossible de charger vos articles',
'Something went wrong on the server. Try again in a moment.':
'Une erreur est survenue sur le serveur. Réessayez dans un instant.',
'Live. Your website can read this article.': 'Publié. Votre site peut lire cet article.',
"Titles can't be changed after an article is created. To use a different title, create a new article and delete this one.":
'Les titres ne peuvent pas être modifiés après la création d’un article. Pour changer de titre, créez un nouvel article et supprimez celui-ci.',
'Back to Articles': 'Retour aux articles',
'Your changes to "{0}" haven\'t been saved.':
'Vos modifications de « {0} » n’ont pas été enregistrées.',
'Shorten the text or remove an image. Formatting counts too.':
'Raccourcissez le texte ou retirez une image. La mise en forme compte aussi.',
'Shorten the text.': 'Raccourcissez le texte.',
'Almost at the length limit.': 'La limite de longueur est proche.',
'Close to the limit. Images and formatting count too.':
'La limite est proche. Les images et la mise en forme comptent aussi.',
'1 character over. {0}': '1 caractère en trop. {0}',
'{0} characters over. {1}': '{0} caractères en trop. {1}',
'Saved at {0}': 'Enregistré à {0}',
'Saved {0} ago': 'Enregistré il y a {0}',
Undo: 'Annuler',
Redo: 'Rétablir',
Paragraph: 'Paragraphe',
'Paragraph style': 'Style de paragraphe',
'Heading 1': 'Titre 1',
'Heading 2': 'Titre 2',
'Heading 3': 'Titre 3',
'Heading 4': 'Titre 4',
'Heading {0}': 'Titre {0}',
Bold: 'Gras',
Italic: 'Italique',
Underline: 'Souligné',
Strikethrough: 'Barré',
'Align left': 'Aligner à gauche',
'Align center': 'Centrer',
'Align right': 'Aligner à droite',
Justify: 'Justifier',
'Bullet list': 'Liste à puces',
'Numbered list': 'Liste numérotée',
Indent: 'Augmenter le retrait',
Outdent: 'Réduire le retrait',
'Clear formatting': 'Effacer la mise en forme',
Image: 'Image',
'Add an image': 'Ajouter une image',
'Remove image': 'Retirer l’image',
'Remove this image from the article?': 'Retirer cette image de l’article ?',
"It's removed from this article only. The file stays in your library and in any other article that uses it.":
'Elle est retirée de cet article uniquement. Le fichier reste dans votre bibliothèque et dans les autres articles qui l’utilisent.',
'Removing…': 'Retrait…',
'Image removed': 'Image retirée',
'Save to keep it in the article.': 'Enregistrez pour la conserver dans l’article.',
'This image is no longer available': 'Cette image n’est plus disponible',
'It was deleted from the library. Remove it from the article.':
'Elle a été supprimée de la bibliothèque. Retirez-la de l’article.',
'Upload an image': 'Importer une image',
'Upload image': 'Importer une image',
'Insert image': 'Insérer l’image',
'Alt text (optional)': 'Texte alternatif (facultatif)',
'e.g. North wall of the kitchen before plastering':
'p. ex. Mur nord de la cuisine avant l’enduit',
Library: 'Bibliothèque',
'In this article': 'Dans cet article',
Images: 'Images',
"Every image you've uploaded, ready to use in any article.":
'Toutes vos images importées, prêtes à être utilisées dans vos articles.',
'Drop images to upload': 'Déposez des images pour les importer',
'PNG, JPEG, WEBP, SVG · max 10 MB each': 'PNG, JPEG, WEBP, SVG · 10 Mo maximum par image',
'PNG, JPEG, WEBP, SVG · max 10 MB': 'PNG, JPEG, WEBP, SVG · 10 Mo maximum',
Upload: 'Importer',
'Upload anyway': 'Importer quand même',
'Use existing': 'Utiliser l’image existante',
'Choose another file': 'Choisir un autre fichier',
'Images can be up to 10 MB. Use a PNG, JPEG, WEBP or SVG image.':
'Les images peuvent atteindre 10 Mo. Utilisez une image PNG, JPEG, WEBP ou SVG.',
'Use a PNG, JPEG, WEBP or SVG image, up to 10 MB.':
'Utilisez une image PNG, JPEG, WEBP ou SVG de 10 Mo maximum.',
'Already in your library': 'Déjà dans votre bibliothèque',
'{0} already in your library': '{0} déjà dans votre bibliothèque',
'Same as "{0}", uploaded {1}{2}.': 'Identique à « {0} », importée {1}{2}.',
'Uploaded {0}{1}': 'Importée {0}{1}',
' by {0}': ' par {0}',
'{0} matches an image uploaded on {1}{2}.': '{0} correspond à une image importée le {1}{2}.',
'Upload failed': 'Échec de l’importation',
"{0} couldn't be read": 'Impossible de lire {0}',
"{0} isn't an image": '{0} n’est pas une image',
'{0} is {1} MB': '{0} fait {1} Mo',
'{0} is {1} MB. Images can be up to 10 MB.':
'{0} fait {1} Mo. Les images peuvent atteindre 10 Mo.',
'"{0}" can\'t be used': 'Impossible d’utiliser « {0} »',
"The server couldn't read this image. Save it again as a PNG or JPEG, then try again.":
'Le serveur n’a pas pu lire cette image. Enregistrez-la de nouveau au format PNG ou JPEG, puis réessayez.',
"The server didn't accept the file. Nothing was added to your library.":
'Le serveur a refusé le fichier. Rien n’a été ajouté à votre bibliothèque.',
"The server didn't accept the file. Your article hasn't changed.":
'Le serveur a refusé le fichier. Votre article n’a pas changé.',
'Too many uploads': 'Trop d’importations',
'For your security, wait a moment before uploading again.':
'Pour votre sécurité, patientez avant d’importer à nouveau.',
Waiting: 'En attente',
'Waiting for connection': 'En attente de connexion',
'Cancel upload': 'Annuler l’importation',
'Uploading {0}': 'Importation de {0}',
'Uploading {0} of {1} KB': 'Importation : {0} sur {1} Ko',
'Uploading {0} of {1} MB': 'Importation : {0} sur {1} Mo',
'Upload finished': 'Importation terminée',
'Nothing was uploaded': 'Aucune image importée',
'Added to your library': 'Ajoutée à votre bibliothèque',
'Image added': 'Image ajoutée',
Uploads: 'Importations',
'{0} of {1} done': '{0} sur {1} terminées',
'{0} uploaded': '{0} importées',
'{0} need attention': '{0} nécessitent votre attention',
'No images yet': 'Aucune image',
'No images match "{0}"': 'Aucune image ne correspond à « {0} »',
'No images match this filter': 'Aucune image ne correspond à ce filtre',
'Check the spelling, or search for a shorter part of the filename.':
'Vérifiez l’orthographe ou recherchez une partie plus courte du nom de fichier.',
'Images you upload appear here, ready to use in any article.':
'Vos images importées apparaissent ici, prêtes à être utilisées dans vos articles.',
'Your library is empty': 'Votre bibliothèque est vide',
'Upload your first image. You can use it in any article.':
'Importez votre première image. Vous pourrez l’utiliser dans vos articles.',
"Couldn't load your images": 'Impossible de charger vos images',
"Couldn't load your library": 'Impossible de charger votre bibliothèque',
'Loading images': 'Chargement des images',
'Search by filename': 'Rechercher par nom de fichier',
'Clear filters': 'Effacer les filtres',
Sort: 'Trier',
Newest: 'Plus récentes',
Oldest: 'Plus anciennes',
Largest: 'Plus grandes',
'Newest first': 'Plus récentes d’abord',
'Newest first · {0} images': 'Plus récentes d’abord · {0} images',
'Grid view': 'Vue en grille',
'List view': 'Vue en liste',
Filename: 'Nom de fichier',
Size: 'Taille',
Dimensions: 'Dimensions',
Uploaded: 'Importée',
'Uploaded by': 'Importée par',
'Used in': 'Utilisée dans',
Unused: 'Inutilisées',
unused: 'inutilisée',
Type: 'Type',
Vector: 'Vectoriel',
Selection: 'Sélection',
'Select all': 'Tout sélectionner',
'Selection cleared': 'Sélection effacée',
'{0} selected': '{0} sélectionnées',
'{0} {1}, {2} selected': '{0} {1}, {2} sélectionnées',
Details: 'Détails',
'Image details': 'Détails de l’image',
'Copy URL': 'Copier l’URL',
'URL copied': 'URL copiée',
'Paste it wherever you need the image address.':
'Collez-la là où vous avez besoin de l’adresse de l’image.',
Rename: 'Renommer',
'Rename image': 'Renommer l’image',
'Renaming…': 'Renommage…',
'Only the filename changes. The URL stays the same, so articles that use this image keep working.':
'Seul le nom du fichier change. L’URL reste identique : les articles utilisant cette image continuent de fonctionner.',
'Letters, numbers, dashes and underscores. The {0} ending stays.':
'Lettres, chiffres, tirets et tirets bas. L’extension {0} est conservée.',
'Enter a filename.': 'Saisissez un nom de fichier.',
'Image renamed': 'Image renommée',
'It\'s now "{0}". The URL is the same.':
'Elle s’appelle désormais « {0} ». L’URL est identique.',
"Couldn't rename the image. Your text is still here. Try again in a moment.":
'Impossible de renommer l’image. Votre texte est conservé. Réessayez dans un instant.',
'Delete image': 'Supprimer l’image',
'Delete…': 'Supprimer…',
'Delete this image?': 'Supprimer cette image ?',
"Delete an image that's in use?": 'Supprimer une image utilisée ?',
'"{0}" will be deleted from your library. No article uses it. This can\'t be undone.':
'« {0} » sera supprimée de votre bibliothèque. Aucun article ne l’utilise. Cette action est irréversible.',
'"{0}" is used in {1}. If you delete it, they will show a broken image. This can\'t be undone.':
'« {0} » est utilisée dans {1}. Si vous la supprimez, ces articles afficheront une image manquante. Cette action est irréversible.',
'Image deleted': 'Image supprimée',
'"{0}" is gone from your library.': '« {0} » a été supprimée de votre bibliothèque.',
'That image no longer exists': 'Cette image n’existe plus',
'It was deleted somewhere else, so we removed it from the library.':
'Elle a été supprimée ailleurs ; nous l’avons donc retirée de la bibliothèque.',
"Couldn't delete the image, so it's still there. Try again in a moment.":
'Impossible de supprimer l’image, qui est toujours présente. Réessayez dans un instant.',
"Couldn't delete the images, so they're still there. Try again in a moment.":
'Impossible de supprimer les images, qui sont toujours présentes. Réessayez dans un instant.',
"Couldn't delete {0}": 'Impossible de supprimer {0}',
"They're gone from your library.": 'Elles ont été supprimées de votre bibliothèque.',
"It's still in your library.": 'Elle est toujours dans votre bibliothèque.',
'This image is safe to delete. No article shows it.':
'Cette image peut être supprimée. Aucun article ne l’affiche.',
"This can't be undone.": 'Cette action est irréversible.',
'None of them are used in any article. {0}': 'Aucune n’est utilisée dans un article. {0}',
"It isn't used in any article. {0}": 'Elle n’est utilisée dans aucun article. {0}',
'which will show a broken image.': 'qui afficheront une image manquante.',
'{0} of them are used in {1}, {2} {3} {4}': '{0} sont utilisées dans {1}, {2} {3} {4}',
'{0} deleted': '{0} supprimées',
'{0} deleted. {1} {2} still in your library. Try again.':
'{0}. {1} {2} toujours dans votre bibliothèque. Réessayez.',
'1 image was': '1 image a été supprimée',
is: 'est',
are: 'sont',
'{0} images were': '{0} images ont été supprimées',
Duplicates: 'Doublons',
Duplicate: 'Doublon',
'No duplicates': 'Aucun doublon',
'Every image in your library is different.':
'Toutes les images de votre bibliothèque sont différentes.',
'Review duplicates': 'Examiner les doublons',
'Back to all images': 'Retour à toutes les images',
Group: 'Groupe',
'1 copy': '1 copie',
'{0} copies': '{0} copies',
'Identical to {0} other {1}': 'Identique à {0} autres {1}',
'{0} identical images · {1} each · {2}': '{0} images identiques · {1} chacune · {2}',
Suggested: 'Suggérée',
'Suggested: the most used copy, then the oldest. Choosing another copy switches articles to it.':
'Suggérée : la copie la plus utilisée, puis la plus ancienne. Choisir une autre copie redirige les articles vers celle-ci.',
'Merge into this one': 'Fusionner dans celle-ci',
'Merging…': 'Fusion…',
"Couldn't merge the copies. Nothing was changed. Try again in a moment.":
'Impossible de fusionner les copies. Rien n’a changé. Réessayez dans un instant.',
'One of these images changed, so nothing was merged. The list is up to date now. Check it and try again.':
'Une de ces images a changé : aucune fusion n’a eu lieu. La liste est à jour. Vérifiez-la et réessayez.',
"Articles that use the other {0} will be switched to {1}, and the other {2} will be deleted. {3} will be updated. This can't be undone.":
'Les articles utilisant les {0} autres copies utiliseront {1}, et les {2} autres copies seront supprimées. {3} seront mis à jour. Cette action est irréversible.',
'Copies merged': 'Copies fusionnées',
'{0} merged. {1} updated.': '{0} fusionnées. {1} mis à jour.',
"The other one isn't used anywhere.": 'L’autre copie n’est utilisée nulle part.',
"The other {0} aren't used anywhere.": 'Les {0} autres copies ne sont utilisées nulle part.',
'Stays in the library': 'Reste dans la bibliothèque',
'Stays in the library · {0}': 'Reste dans la bibliothèque · {0}',
'Kept · used in': 'Conservée · utilisée dans',
'Crop and resize': 'Recadrer et redimensionner',
'Crop area. Arrow keys move it, Shift and arrow keys resize it.':
'Zone de recadrage. Déplacez-la avec les flèches ; redimensionnez-la avec Maj et les flèches.',
'Aspect ratio': 'Proportions',
Original: 'Original',
Free: 'Libre',
Width: 'Largeur',
Rotate: 'Pivoter',
Reset: 'Réinitialiser',
"The crop is {0} px wide, so it's uploaded at that size. Enlarging it would make it blurry.":
'Le recadrage fait {0} px de large : il sera importé à cette taille. L’agrandir le rendrait flou.',
'Uploads as {0} × {1} px · about {2}': 'Importée en {0} × {1} px · environ {2}',
'Everyone who can sign in and work on your articles.':
'Toutes les personnes pouvant se connecter et travailler sur vos articles.',
'Invite user': 'Inviter un utilisateur',
'Invite a user': 'Inviter un utilisateur',
'Invite someone so they can sign in with their own account instead of sharing yours.':
'Invitez quelqu’un à se connecter avec son propre compte plutôt que de partager le vôtre.',
"We'll email them a link to choose a username and password. The link expires after 3 days.":
'Nous lui enverrons un lien pour choisir un nom d’utilisateur et un mot de passe. Ce lien expire après 3 jours.',
'Invite sent': 'Invitation envoyée',
'{0} has 3 days to accept.': '{0} a 3 jours pour accepter.',
'Too many invites. Wait a moment, then try again.':
'Trop d’invitations. Patientez, puis réessayez.',
"There's already a pending invite for this address.":
'Une invitation est déjà en attente pour cette adresse.',
'This address already has an account.': 'Cette adresse a déjà un compte.',
"Couldn't send the invite. Try again in a moment.":
'Impossible d’envoyer l’invitation. Réessayez dans un instant.',
'Loading users': 'Chargement des utilisateurs',
"Couldn't load users": 'Impossible de charger les utilisateurs',
Name: 'Nom',
Role: 'Rôle',
'Invite status': 'Statut de l’invitation',
Pending: 'En attente',
Accepted: 'Acceptée',
Expired: 'Expirée',
Owner: 'Propriétaire',
Member: 'Membre',
You: 'Vous',
'Renew invite': 'Renouveler l’invitation',
'Renewing…': 'Renouvellement…',
'Invite renewed': 'Invitation renouvelée',
'{0} has 3 days to accept the new link.': '{0} a 3 jours pour accepter le nouveau lien.',
"Couldn't renew the invite": 'Impossible de renouveler l’invitation',
'Cancel invite': 'Annuler l’invitation',
'Cancel this invite?': 'Annuler cette invitation ?',
'The link sent to {0} will stop working. You can invite the address again later.':
'Le lien envoyé à {0} ne fonctionnera plus. Vous pourrez inviter cette adresse plus tard.',
'Keep invite': 'Conserver l’invitation',
'Invite cancelled': 'Invitation annulée',
'{0} can no longer use the link.': '{0} ne peut plus utiliser le lien.',
"Couldn't cancel the invite. Try again in a moment.":
'Impossible d’annuler l’invitation. Réessayez dans un instant.',
'Workspace information.': 'Informations de l’espace de travail.',
Version: 'Version',
'Connect your website': 'Connecter votre site web',
'Your site reads published articles from RenewCMS. Share this guide with whoever builds it.':
'Votre site lit les articles publiés dans RenewCMS. Partagez ce guide avec la personne qui le développe.',
'Open the guide': 'Ouvrir le guide',
'A guide for the developer who builds your public site.':
'Un guide pour la personne qui développe votre site public.',
"RenewCMS is headless: it stores and publishes your articles but doesn't show them to visitors. Your website fetches published articles from the two addresses below and displays them in its own design.":
'RenewCMS stocke et publie vos articles sans les afficher aux visiteurs. Votre site récupère les articles publiés aux deux adresses ci-dessous et les affiche selon son propre design.',
'Base URL': 'URL de base',
'Public API URL': 'URL de l’API publique',
'Lists every online article.': 'Liste tous les articles publiés.',
'Returns one online article. Drafts and unknown ids both return 404.':
'Renvoie un article publié. Les brouillons et les identifiants inconnus renvoient 404.',
'Anonymous callers only ever get online articles. Drafts stay private until you publish them.':
'Les requêtes anonymes n’obtiennent que les articles publiés. Les brouillons restent privés jusqu’à leur publication.',
"Browsers can only call the API from origins listed in the server's CORS setting. Ask whoever runs the server to add your site's address.":
'Les navigateurs ne peuvent appeler l’API que depuis les origines autorisées par le paramètre CORS du serveur. Demandez à la personne qui gère le serveur d’ajouter l’adresse de votre site.',
Response: 'Réponse',
'Returns 404 until the article is online.': 'Renvoie 404 tant que l’article n’est pas publié.',
Updates: 'Mises à jour',
Update: 'Mettre à jour',
'Update available': 'Mise à jour disponible',
'Version {0} is available': 'La version {0} est disponible',
'Version {0} is ready to install': 'La version {0} est prête à être installée',
"You're on version {0}": 'Vous utilisez la version {0}',
'You have {0}. Update downloads the new version and checks it. Nothing changes until you restart.':
'Vous utilisez {0}. La mise à jour télécharge et vérifie la nouvelle version. Rien ne change avant le redémarrage.',
'You have {0}. Only the owner can update RenewCMS. Ask the owner of this site.':
'Vous utilisez {0}. Seul le propriétaire peut mettre à jour RenewCMS. Contactez le propriétaire de ce site.',
'You have the latest version of RenewCMS.': 'Vous avez la dernière version de RenewCMS.',
"You're up to date": 'Vous êtes à jour',
'You already have the latest version.': 'Vous avez déjà la dernière version.',
'Nothing to update': 'Aucune mise à jour',
'Check for updates': 'Rechercher des mises à jour',
'Checking for updates…': 'Recherche de mises à jour…',
"Couldn't check for updates": 'Impossible de rechercher des mises à jour',
"RenewCMS couldn't reach the update server. Check your connection and try again. Your site isn't affected.":
'RenewCMS n’a pas pu joindre le serveur de mises à jour. Vérifiez votre connexion et réessayez. Votre site n’est pas affecté.',
"Couldn't reach RenewCMS. Check your connection and try again.":
'Impossible de joindre RenewCMS. Vérifiez votre connexion et réessayez.',
'Only the owner can update or restart the site.':
'Seul le propriétaire peut mettre à jour ou redémarrer le site.',
'Only the owner can do this': 'Seul le propriétaire peut effectuer cette action',
'Ask the owner of this site to update RenewCMS.':
'Demandez au propriétaire de ce site de mettre à jour RenewCMS.',
'Only the owner can update RenewCMS.': 'Seul le propriétaire peut mettre à jour RenewCMS.',
'Go to Settings': 'Aller aux paramètres',
'Update it from Settings. It takes about 2 minutes and the site stays online.':
'Mettez à jour depuis les paramètres. Cela prend environ 2 minutes et le site reste disponible.',
'Takes about 2 minutes. Your site stays online.':
'Cela prend environ 2 minutes. Votre site reste disponible.',
"This site can't update itself from the browser yet. Whoever manages your server can set it up once, or update RenewCMS for you.":
'Ce site ne peut pas encore se mettre à jour depuis le navigateur. La personne qui gère votre serveur peut l’activer ou mettre à jour RenewCMS pour vous.',
'How to update': 'Comment mettre à jour',
'For whoever manages the server': 'Pour la personne qui gère le serveur',
'They run this once on the server. After that, Update and Restart appear here.':
'Cette commande s’exécute une fois sur le serveur. Les actions de mise à jour et de redémarrage apparaissent ensuite ici.',
'Read the release notes': 'Lire les notes de version',
"What's new in": 'Nouveautés de',
'Downloading version {0}': 'Téléchargement de la version {0}',
'Downloading version {0}…': 'Téléchargement de la version {0}…',
'{0}% · {1} of {2} MB': '{0} % · {1} sur {2} Mo',
'{0}, {1} percent': '{0}, {1} pour cent',
'You can leave this page. The download continues and your site stays online.':
'Vous pouvez quitter cette page. Le téléchargement continue et votre site reste disponible.',
'Checking the download': 'Vérification du téléchargement',
'Checking the download…': 'Vérification du téléchargement…',
'Making sure the file is complete and safe to install. This takes a few seconds.':
'Vérification de l’intégrité et de la sécurité du fichier. Cela prend quelques secondes.',
"The download didn't finish": 'Le téléchargement n’a pas abouti',
'The connection dropped before the file was complete. Nothing was changed and your site is running as before.':
'La connexion a été interrompue avant la fin du téléchargement. Rien n’a changé et votre site fonctionne comme avant.',
"We didn't use this update": 'Cette mise à jour n’a pas été utilisée',
"The file didn't pass our safety check, so it was not installed. Nothing was changed. Try downloading it again. If this keeps happening, contact RenewCMS support.":
'Le fichier a échoué au contrôle de sécurité et n’a pas été installé. Rien n’a changé. Réessayez le téléchargement. Si le problème persiste, contactez l’assistance RenewCMS.',
'An update is already running': 'Une mise à jour est déjà en cours',
'Another owner started it. Progress shows here.':
'Un autre propriétaire l’a lancée. La progression s’affiche ici.',
'The download is checked. Restart to install it. Your site will be unavailable for about 10 seconds.':
'Le téléchargement est vérifié. Redémarrez pour l’installer. Votre site sera indisponible environ 10 secondes.',
'Not now? Leave this page. The update waits here, and the banner on Home reminds you.':
'Pas maintenant ? Quittez cette page. La mise à jour vous attend ici et la bannière d’accueil vous le rappellera.',
'Restart later': 'Redémarrer plus tard',
'Restart now': 'Redémarrer maintenant',
'Restart site': 'Redémarrer le site',
Restart: 'Redémarrer',
Restarting: 'Redémarrage',
'Restarting…': 'Redémarrage…',
'Restarting your site': 'Redémarrage de votre site',
'Restart to finish the update?': 'Redémarrer pour terminer la mise à jour ?',
'Restart the site?': 'Redémarrer le site ?',
'RenewCMS will install version {0} and restart. {1}':
'RenewCMS installera la version {0} puis redémarrera. {1}',
"Your site will be unavailable for about 10 seconds. This page reloads on its own when the site is back. Save anything you're editing first.":
'Votre site sera indisponible environ 10 secondes. Cette page se recharge automatiquement à son retour. Enregistrez d’abord votre travail.',
'Restart takes about 10 seconds. Your site is unavailable while it restarts.':
'Le redémarrage prend environ 10 secondes. Votre site est indisponible pendant ce temps.',
'Restart from Settings to finish. The site is unavailable for about 10 seconds.':
'Redémarrez depuis les paramètres pour terminer. Le site sera indisponible environ 10 secondes.',
'Installing version {0}': 'Installation de la version {0}',
'Your site is restarting with the new version. This takes about 10 seconds. This page reloads on its own when the site is back.':
'Votre site redémarre avec la nouvelle version. Cela prend environ 10 secondes. Cette page se recharge automatiquement à son retour.',
'This takes about 10 seconds. This page reloads on its own when the site is back.':
'Cela prend environ 10 secondes. Cette page se recharge automatiquement au retour du site.',
'Still restarting': 'Redémarrage toujours en cours',
'This is taking longer than usual. Keep this page open. It reloads on its own when the site is back, or you can reload it now.':
'Cela prend plus longtemps que prévu. Gardez cette page ouverte. Elle se recharge automatiquement au retour du site, ou vous pouvez la recharger maintenant.',
'Reload page': 'Recharger la page',
'Site restarted': 'Site redémarré',
'Your site is running again.': 'Votre site fonctionne à nouveau.',
'Update installed': 'Mise à jour installée',
'RenewCMS is now on version {0}.': 'RenewCMS utilise maintenant la version {0}.',
'The update is installed and your site is running again.':
'La mise à jour est installée et votre site fonctionne à nouveau.',
"The update didn't install": 'La mise à jour n’a pas été installée',
'RenewCMS put the previous version back, so your site is running as before. Nothing was lost.':
'RenewCMS a rétabli la version précédente. Votre site fonctionne comme avant. Rien n’a été perdu.',
'Your site is back on {0}.': 'Votre site utilise à nouveau {0}.',
'What to do next: try again in a few minutes. If it fails again, ask whoever manages your server to check the update.':
'Réessayez dans quelques minutes. Si l’échec se reproduit, demandez à la personne qui gère votre serveur de vérifier la mise à jour.',
"Couldn't start the restart. Nothing was changed and your site is still running. Try again in a moment.":
'Impossible de lancer le redémarrage. Rien n’a changé et votre site fonctionne toujours. Réessayez dans un instant.',
'Nothing has changed yet.': 'Rien n’a encore changé.',
'Released {0}': 'Publiée le {0}',
'Last checked today at {0}': 'Dernière vérification aujourd’hui à {0}',
'Last checked yesterday at {0}': 'Dernière vérification hier à {0}',
'Last checked {0} at {1}': 'Dernière vérification le {0} à {1}',
'Updated today at {0}': 'Mis à jour aujourd’hui à {0}',
'Updated yesterday at {0}': 'Mis à jour hier à {0}',
'Updated {0} at {1}': 'Mis à jour le {0} à {1}',
'Started at {0}': 'Démarré à {0}',
Updated: 'Modifié',
'Updating…': 'Mise à jour…',
Created: 'Créé',
Today: 'Aujourd’hui',
Yesterday: 'Hier',
'Today, {0}:{1}': 'Aujourd’hui, {0}:{1}',
'1 minute ago': 'Il y a 1 minute',
'{0} minutes ago': 'Il y a {0} minutes',
'1 hour ago': 'Il y a 1 heure',
'{0} hours ago': 'Il y a {0} heures',
'{0} days ago': 'Il y a {0} jours',
'{0} d': '{0} j',
'{0} h': '{0} h',
'{0} min': '{0} min',
'{0} KB': '{0} Ko',
'{0} MB': '{0} Mo',
'1 article': '1 article',
'{0} articles': '{0} articles',
'used in {0}': 'utilisée dans {0}',
'{0} and {1}': '{0} et {1}',
'Page not found': 'Page introuvable',
'The link may be mistyped, or the page may have moved.':
'Le lien est peut-être incorrect ou la page a été déplacée.',
'Something went wrong': 'Une erreur est survenue',
'Something went wrong. Try again.': 'Une erreur est survenue. Réessayez.',
"This page couldn't load. Your saved work is safe. Try again, and if it keeps happening, restart the RenewCMS server.":
'Cette page n’a pas pu se charger. Votre travail enregistré est conservé. Réessayez et, si le problème persiste, redémarrez le serveur RenewCMS.',
"This article doesn't exist.": 'Cet article n’existe pas.',
'It may have been deleted, or the link is wrong.':
'Il a peut-être été supprimé ou le lien est incorrect.',
Dismiss: 'Fermer',
"You're offline. You can keep writing, but saving and publishing will wait until you're back online.":
'Vous êtes hors ligne. Vous pouvez continuer à écrire, mais l’enregistrement et la publication attendront le retour de la connexion.',
"You're offline. You can look through the images that are loaded, but uploading, renaming and deleting need a connection.":
'Vous êtes hors ligne. Vous pouvez consulter les images chargées, mais l’importation, le renommage et la suppression nécessitent une connexion.',
"You're offline": 'Vous êtes hors ligne',
"You're back online": 'Vous êtes de nouveau en ligne',
'{0} — account menu': '{0} — menu du compte',
"You're logged in as {0}.": 'Vous êtes connecté en tant que {0}.',
'Counted from the text of your articles each time you open this page.':
'Calculé à partir du texte de vos articles à chaque ouverture de cette page.',
Status: 'Statut',
Files: 'Fichiers',
Clear: 'Effacer',
View: 'Afficher',
'Almost there': 'Presque terminé',
'Too many tries': 'Trop de tentatives',
'Checking…': 'Vérification…',
'Check this title and try again.': 'Vérifiez ce titre et réessayez.',
'Nothing matches in {0}. Check the spelling, or clear the filters.':
'Aucun résultat dans {0}. Vérifiez l’orthographe ou effacez les filtres.',
'Show drafts': 'Afficher les brouillons',
"The server didn't respond. Nothing changed.": 'Le serveur n’a pas répondu. Rien n’a changé.',
'This usually takes a few seconds.': 'Cela prend généralement quelques secondes.',
"It's": 'Elle est',
'All {0} are': 'Les {0} sont',
'{0} used in {1}, {2} {3}': '{0} utilisées dans {1}, {2} {3}',
'{0} {1} attention': '{0} {1} votre attention',
'{0} {1} rejected': '{0} {1} refusées',
'{0} {1} the same content.': '{0} {1} le même contenu.',
'Check your email · RenewCMS': 'Consultez vos e-mails · RenewCMS',
'This site already has an owner · RenewCMS': 'Ce site a déjà un propriétaire · RenewCMS',
'{0} · RenewCMS': '{0} · RenewCMS',
Enter: 'Entrée',
Escape: 'Échap',
'Actions for {0}': 'Actions pour {0}',
'Delete {0}?': 'Supprimer {0} ?',
'Delete {0}': 'Supprimer {0}',
'Used in {0}': 'Utilisée dans {0}',
'Copy {0} example': 'Copier l’exemple {0}',
'Drop to upload': 'Déposez pour importer',
'Drop an image here, or choose a file': 'Déposez une image ici ou choisissez un fichier',
'Send invite': 'Envoyer l’invitation',
'Select {0}': 'Sélectionner {0}',
'Not used in any article': 'Utilisée dans aucun article',
'Loading…': 'Chargement…',
'Load more': 'Charger davantage',
'Cancel all': 'Tout annuler',
'Cancel upload of {0}': 'Annuler l’importation de {0}',
'Merge into {0}?': 'Fusionner dans {0} ?',
'Merge {0}': 'Fusionner {0}',
'Creating…': 'Création…',
'Create draft': 'Créer un brouillon',
'Creating account…': 'Création du compte…',
'Create account': 'Créer le compte',
'Choose a new password': 'Choisir un nouveau mot de passe',
'Changing password…': 'Modification du mot de passe…',
'Change password': 'Modifier le mot de passe',
'Images in body use paths like /static/uploadedImages/<uuid>.png. Prefix them with the base URL before rendering.':
'Les images du contenu utilisent des chemins comme /static/uploadedImages/<uuid>.png. Ajoutez l’URL de base avant de les afficher.',
selected: 'sélectionnées',
you: 'vous',
now: 'maintenant',
'Accept invite': 'Accepter l’invitation',
articles: 'articles',
online: 'publiés',
drafts: 'brouillons',
owner: 'propriétaire',
admin: 'administrateur',
met: 'respecté',
'not met': 'non respecté',
copy: 'copie',
image: 'image',
images: 'images',
'{0} image': '{0} image',
'{0} images': '{0} images',
'{0} file': '{0} fichier',
'{0} files': '{0} fichiers',
'{0} file was rejected': '{0} fichier a été refusé',
'{0} files were rejected': '{0} fichiers ont été refusés',
'{0} file needs attention': '{0} fichier nécessite votre attention',
'{0} files need attention': '{0} fichiers nécessitent votre attention',
'{0} has the same content.': '{0} a le même contenu.',
'{0} have the same content.': '{0} ont le même contenu.',
'{0} images · {1} selected': '{0} images · {1} sélectionnées',
'image, {0} selected': 'image, {0} sélectionnée',
'images, {0} selected': 'images, {0} sélectionnées',
'Used in 1 article': 'Utilisée dans 1 article',
'Used in {0} articles': 'Utilisée dans {0} articles',
} as const
export type MessageKey = keyof typeof messages
+67
View File
@@ -0,0 +1,67 @@
import { shallowReactive, watch, watchEffect } from 'vue'
import { useOverlay, useToast } from '@nuxt/ui/composables'
import { locale } from './index'
type ToastSource = Parameters<ReturnType<typeof useToast>['add']>[0]
type ToastProvider = ToastSource | (() => ToastSource)
const notifications = shallowReactive(
new Map<
string | number,
{ source: () => ToastSource; toast: ReturnType<typeof useToast>; visible: boolean }
>(),
)
watchEffect(() => {
for (const [id, entry] of notifications) {
const active = entry.toast.toasts.value.find((item) => item.id === id && item.open)
if (active) entry.visible = true
else if (entry.visible) notifications.delete(id)
}
})
watch(locale, () => {
for (const [id, { source, toast }] of notifications) {
const active = toast.toasts.value.find((item) => item.id === id)
if (!active) {
notifications.delete(id)
continue
}
const content = source()
active.title = content.title
active.description = content.description
active.actions = content.actions
}
})
export function useLocalizedToast() {
const toast = useToast()
function add(content: ToastProvider) {
const source = typeof content === 'function' ? content : () => content
const result = toast.add(source())
if (result) notifications.set(result.id, { source, toast, visible: false })
return result
}
function update(id: string | number, content: ToastProvider) {
const source = typeof content === 'function' ? content : () => content
toast.update(id, source())
notifications.set(id, { source, toast, visible: false })
}
return { ...toast, add, update }
}
export function useLocalizedOverlay() {
const overlay = useOverlay()
const create: typeof overlay.create = (component, options) => {
const instance = overlay.create(component, options)
const open: typeof instance.open = (props) => {
const stop = watch(locale, () => {
if (overlay.isOpen(instance.id)) instance.patch(options?.props ?? {})
})
const result = instance.open(props)
void result.finally(stop)
return result
}
return { ...instance, open }
}
return { ...overlay, create }
}
+769
View File
@@ -0,0 +1,769 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { flushPromises, mount, type VueWrapper } from '@vue/test-utils'
import { createPinia, setActivePinia } from 'pinia'
import ui from '@nuxt/ui/vue-plugin'
import App from '@/App.vue'
import LanguagePicker from '@/components/LanguagePicker.vue'
import ConfirmDialog from '@/components/ConfirmDialog.vue'
import UserMenu from '@/components/UserMenu.vue'
import AccountMenuSheet from '@/components/AccountMenuSheet.vue'
import { useApi, ApiError } from '@/composables/useApi'
import { useSessionStore } from '@/stores/session'
import { useColorMode } from '@/composables/useColorMode'
import router from '@/router'
import { DESKTOP_WIDTH, setViewportWidth } from '@/components/__tests__/viewport'
import { initializeLocale, locale, setLocale, t } from './index'
import { messages } from './messages'
import { formatAbsoluteDateFromIso } from '@/lib/relativeDate'
import { formatSize, pluralArticles } from '@/lib/mediaFormat'
vi.mock('@/composables/useApi', async () => {
const { ref } = await import('vue')
return {
useApi: vi.fn(),
authRedirectPending: ref(false),
ApiError: class extends Error {
constructor(
public status: number,
public retryAfter?: number,
public body?: string,
) {
super('API failure')
}
},
}
})
let wrapper: VueWrapper | undefined
const request = vi.fn()
beforeEach(() => {
localStorage.clear()
initializeLocale()
setViewportWidth(DESKTOP_WIDTH)
setActivePinia(createPinia())
useSessionStore().status = 'ready'
request.mockReset().mockResolvedValue([])
vi.mocked(useApi).mockReturnValue({
request,
getMe: vi.fn().mockResolvedValue(null),
} as unknown as ReturnType<typeof useApi>)
})
afterEach(async () => {
vi.unstubAllGlobals()
vi.restoreAllMocks()
wrapper?.unmount()
wrapper = undefined
document.body.innerHTML = ''
localStorage.clear()
initializeLocale()
await router.push('/login')
})
async function mountRoute(path: string) {
const session = useSessionStore()
session.session =
path === '/login'
? null
: {
id: 1,
verified: true,
username: 'marta',
displayName: 'Marta',
email: 'marta@example.com',
role: 'owner',
}
session.status = 'ready'
await router.push(path)
wrapper = mount(App, {
attachTo: document.body,
global: { plugins: [ui, router] },
})
await flushPromises()
return wrapper
}
function trigger() {
return document.body.querySelector<HTMLButtonElement>(
'[role="combobox"][aria-label="Language"], [role="combobox"][aria-label="Langue"]',
)!
}
function key(element: Element, value: string) {
element.dispatchEvent(
new KeyboardEvent('keydown', {
key: value,
bubbles: true,
cancelable: true,
}),
)
}
describe('Translate the entire interface', () => {
it('provides nonempty equivalent interpolation parameters for every English and French key', () => {
const placeholders = (text: string) =>
[...text.matchAll(/\{\d+\}/g)].map((match) => match[0]).sort()
expect(
Object.entries(messages).filter(
([english, french]) =>
!french.trim() ||
JSON.stringify(placeholders(english)) !== JSON.stringify(placeholders(french)),
),
).toEqual([])
})
it('formats French dates, decimal sizes and plural counts', () => {
setLocale('fr')
expect([
formatAbsoluteDateFromIso('2025-02-14T12:00:00Z'),
formatSize(1572864),
pluralArticles(1234),
]).toEqual(['14 févr. 2025', '1,5 Mo', '1 234 articles'])
})
it('falls back to the English key for an unavailable French message', () => {
setLocale('fr')
expect(t('Unavailable message' as keyof typeof messages)).toBe('Unavailable message')
})
})
describe('Switch language without losing work', () => {
it('retranslates added and retried bulk failures while preserving filenames and retry targets', async () => {
let items = ['north-wall.jpg', 'kitchen-after.webp', 'kitchen-before.webp'].map(
(filename, index) => ({
id: index + 1,
filename,
url: `/media/${filename}`,
size: 1024,
content_type: 'image/jpeg',
width: 100,
height: 100,
sha256: filename,
uploaded_by: 'Marta',
created_at: '2025-02-14T12:00:00Z',
used_in: 0,
duplicate: false,
}),
)
let retry = false
request.mockImplementation(async (path: string, options?: RequestInit) => {
if (options?.method === 'DELETE') {
const id = Number(path.substring(path.lastIndexOf('/') + 1))
if (id === 3 || (id === 2 && !retry)) throw new ApiError(500)
items = items.filter((item) => item.id !== id)
return undefined
}
return {
items,
next_cursor: null,
counts: { all: items.length, unused: items.length, duplicates: 0 },
}
})
await mountRoute('/media')
for (const item of items) {
document.body
.querySelector<HTMLElement>(
`[role="checkbox"][aria-label="Select ${item.filename}"]`,
)!
.click()
}
await flushPromises()
document.body.querySelector<HTMLButtonElement>('[data-testid="bulk-delete"]')!.click()
await flushPromises()
Array.from(document.body.querySelectorAll<HTMLButtonElement>('[role="alertdialog"] button'))
.find((button) => button.textContent?.trim() === 'Delete 3 images')!
.click()
await flushPromises()
const snapshots: boolean[] = []
async function captureBothWays(english: string, french: string) {
for (const [language, description, label] of [
['en', english, 'Retry'],
['fr', french, 'Réessayer'],
['en', english, 'Retry'],
] as const) {
setLocale(language)
await flushPromises()
snapshots.push(
document.body.textContent!.includes(description) &&
Array.from(document.body.querySelectorAll('button')).some(
(button) => button.textContent?.trim() === label,
),
)
}
}
await captureBothWays(
'1 image was deleted. kitchen-after.webp and kitchen-before.webp are still in your library. Try again.',
'1 image a été supprimée. kitchen-after.webp et kitchen-before.webp sont toujours dans votre bibliothèque. Réessayez.',
)
retry = true
Array.from(document.body.querySelectorAll<HTMLButtonElement>('button'))
.find((button) => button.textContent?.trim() === 'Retry')!
.click()
await flushPromises()
await captureBothWays(
'2 images were deleted. kitchen-before.webp is still in your library. Try again.',
'2 images ont été supprimées. kitchen-before.webp est toujours dans votre bibliothèque. Réessayez.',
)
expect({
snapshots,
deleted: request.mock.calls
.filter(([, options]) => options?.method === 'DELETE')
.map(([path]) => path),
}).toEqual({
snapshots: [true, true, true, true, true, true],
deleted: ['/v1/media/1', '/v1/media/2', '/v1/media/3', '/v1/media/2', '/v1/media/3'],
})
})
it.each(['fallback', 'French catalog error'])(
'updates an active confirmation %s when switching both ways',
async (failure) => {
wrapper = mount(ConfirmDialog, {
attachTo: document.body,
props: {
title: 'Delete',
description: 'Confirm deletion',
confirmLabel: 'Delete',
cancelLabel: 'Cancel',
pendingLabel: 'Deleting',
tone: 'danger',
action: async () => {
if (failure === 'French catalog error') {
setLocale('fr')
throw new Error(t('Something went wrong. Try again.'))
}
throw null
},
},
global: { plugins: [ui] },
})
await flushPromises()
Array.from(
document.body.querySelectorAll<HTMLButtonElement>('[role="alertdialog"] button'),
)
.find((button) => button.textContent?.trim() === 'Delete')!
.click()
await flushPromises()
setLocale('en')
await flushPromises()
const english = document.body.querySelector('[role="alert"]')?.textContent?.trim()
setLocale('fr')
await flushPromises()
const french = document.body.querySelector('[role="alert"]')?.textContent?.trim()
setLocale('en')
await flushPromises()
expect([
english,
french,
document.body.querySelector('[role="alert"]')?.textContent?.trim(),
]).toEqual([
'Something went wrong. Try again.',
'Une erreur est survenue. Réessayez.',
'Something went wrong. Try again.',
])
},
)
it('switches both ways while preserving profile draft, route, session, theme and request count', async () => {
await mountRoute('/profile')
const input = wrapper!.find('input[autocomplete="off"]')
await input.setValue('Unsaved name')
const session = JSON.stringify(useSessionStore().session)
const theme = useColorMode().preference
const count = request.mock.calls.length
setLocale('fr')
await flushPromises()
const french = document.body.textContent!.includes('Nom d’affichage')
setLocale('en')
await flushPromises()
expect({
french,
draft: (input.element as HTMLInputElement).value,
path: router.currentRoute.value.path,
session: JSON.stringify(useSessionStore().session),
theme: useColorMode().preference,
requests: request.mock.calls.length,
english: document.body.textContent!.includes('Display name'),
}).toEqual({
french: true,
draft: 'Unsaved name',
path: '/profile',
session,
theme,
requests: count,
english: true,
})
})
it('localizes profile save failure and retains the user draft', async () => {
setLocale('fr')
await mountRoute('/profile')
await wrapper!.find('input[autocomplete="off"]').setValue('Name stays')
request.mockRejectedValueOnce(new ApiError(500, undefined, 'private backend error'))
await wrapper!.find('form').trigger('submit')
await flushPromises()
expect({
alert: document.body.querySelector('[role="alert"]')?.textContent?.trim(),
draft: (wrapper!.find('input[autocomplete="off"]').element as HTMLInputElement).value,
}).toEqual({
alert: 'Impossible d’enregistrer votre nom d’affichage. Votre texte est conservé. Réessayez dans un instant.',
draft: 'Name stays',
})
})
it('updates an already-visible authentication alert and its validation', async () => {
await mountRoute('/login?reason=expired')
await wrapper!.find('form').trigger('submit')
setLocale('fr')
await flushPromises()
expect([
document.body.textContent!.includes('Votre session a expiré'),
document.body.textContent!.includes('Utilisez au moins 3 caractères.'),
document.body.textContent!.includes('Saisissez votre mot de passe.'),
]).toEqual([true, true, true])
})
})
describe('Operate the picker accessibly', () => {
it.each([false, true])(
'supports keyboard selection and focus return with mobile=%s',
async (mobile) => {
wrapper = mount(LanguagePicker, {
attachTo: document.body,
props: { mobile },
global: { plugins: [ui] },
})
trigger().focus()
key(trigger(), 'ArrowDown')
await flushPromises()
const options = Array.from(
document.body.querySelectorAll<HTMLElement>('[role="option"]'),
)
const selected = options
.find((option) => option.textContent!.includes('English'))
?.getAttribute('aria-selected')
key(document.activeElement!, 'End')
await flushPromises()
await new Promise((resolve) => setTimeout(resolve, 0))
key(document.activeElement!, 'Enter')
await flushPromises()
expect({
options: options.map((option) => option.textContent!.trim()),
selected,
locale: locale.value,
closed: !document.body.querySelector('[role="listbox"]'),
focus: document.activeElement === trigger(),
}).toEqual({
options: ['English', 'Français'],
selected: 'true',
locale: 'fr',
closed: true,
focus: true,
})
},
)
it('dismisses with Escape and preserves the language', async () => {
wrapper = mount(LanguagePicker, {
attachTo: document.body,
global: { plugins: [ui] },
})
trigger().focus()
key(trigger(), 'ArrowDown')
await flushPromises()
key(document.activeElement!, 'Escape')
await flushPromises()
expect([
locale.value,
!document.body.querySelector('[role="listbox"]'),
document.activeElement === trigger(),
]).toEqual(['en', true, true])
})
it.each(['desktop', 'mobile'])('offers the picker in the %s account menu', async (menu) => {
useSessionStore().session = {
verified: true,
username: 'marta',
displayName: '',
}
useSessionStore().status = 'ready'
await router.push('/profile')
wrapper =
menu === 'desktop'
? mount(UserMenu, {
attachTo: document.body,
props: { username: 'marta', collapsed: false },
global: { plugins: [ui, router] },
})
: mount(AccountMenuSheet, {
attachTo: document.body,
props: { username: 'marta', open: true },
global: { plugins: [ui, router] },
})
if (menu === 'desktop')
document.body.querySelector<HTMLButtonElement>('button[title="Account"]')!.click()
await flushPromises()
const profileEntry = Array.from(
document.body.querySelectorAll<HTMLElement>('[role="menuitem"]'),
).find((item) => item.textContent?.trim() === 'Profile')!
profileEntry.focus()
key(profileEntry, menu === 'desktop' ? 'End' : 'ArrowDown')
await new Promise((resolve) => setTimeout(resolve, 0))
const reachedControl = document.activeElement === trigger()
key(trigger(), 'ArrowDown')
await flushPromises()
key(document.activeElement!, 'End')
await new Promise((resolve) => setTimeout(resolve, 0))
key(document.activeElement!, 'Enter')
await flushPromises()
expect([
reachedControl,
locale.value,
document.activeElement === trigger(),
trigger().textContent?.includes('Langue'),
]).toEqual([true, 'fr', true, true])
})
it('retains French after logout and renders the public authentication screen in French', async () => {
setLocale('fr')
await mountRoute('/profile')
document.body.querySelector<HTMLButtonElement>('button[title="Compte"]')!.click()
await flushPromises()
Array.from(document.body.querySelectorAll<HTMLElement>('[role="menuitem"]'))
.find((item) => item.textContent?.trim() === 'Se déconnecter')!
.click()
await flushPromises()
expect([
router.currentRoute.value.path,
locale.value,
localStorage.getItem('renewcms-language'),
document.body.textContent!.includes('Se connecter'),
]).toEqual(['/login', 'fr', 'fr', true])
})
it('updates an existing success notification without translating the saved account name', async () => {
await mountRoute('/profile')
await wrapper!.find('input[autocomplete="off"]').setValue('Log in')
request.mockResolvedValueOnce({ display_name: 'Log in' })
await wrapper!.find('form').trigger('submit')
await flushPromises()
setLocale('fr')
await flushPromises()
expect([
document.body.textContent!.includes('Nom d’affichage enregistré'),
document.body.textContent!.includes(
'Vous apparaissez désormais sous le nom Log in dans RenewCMS et les e-mails.',
),
]).toEqual([true, true])
})
it('updates an already-open confirmation dialog', async () => {
await mountRoute('/profile')
await wrapper!.find('input[autocomplete="off"]').setValue('Unsaved')
const navigation = router.push('/home')
await flushPromises()
setLocale('fr')
await flushPromises()
const translated = document.body
.querySelector('[role="alertdialog"]')
?.textContent?.includes('Quitter sans enregistrer ?')
Array.from(document.body.querySelectorAll<HTMLButtonElement>('[role="alertdialog"] button'))
.find((button) => button.textContent?.trim() === 'Abandonner les modifications')!
.click()
await navigation
expect(translated).toBe(true)
})
})
describe('Hardener localization outcomes', () => {
it.each([false, true])(
'updates not-found breadcrumbs round-trip with authenticated=%s',
async (authenticated) => {
const { default: NotFoundView } = await import('@/views/NotFoundView.vue')
useSessionStore().session = authenticated
? { verified: true, username: 'marta', displayName: '' }
: null
wrapper = mount(NotFoundView, {
attachTo: document.body,
global: { plugins: [ui, router] },
})
await flushPromises()
const snapshots = []
for (const language of ['en', 'fr', 'en'] as const) {
setLocale(language)
await flushPromises()
snapshots.push(
document.body.querySelector('[role="alert"] nav')?.textContent?.trim(),
)
}
expect(snapshots).toEqual(['Not found', 'Introuvable', 'Not found'])
},
)
it('updates the default error breadcrumb round-trip', async () => {
const { default: ErrorView } = await import('@/components/ErrorView.vue')
wrapper = mount(ErrorView, {
attachTo: document.body,
props: { kind: 'error' },
global: { plugins: [ui, router] },
})
const snapshots = []
for (const language of ['en', 'fr', 'en'] as const) {
setLocale(language)
await flushPromises()
snapshots.push(wrapper.find('nav').text())
}
expect(snapshots).toEqual(['Home', 'Accueil', 'Home'])
})
it('focuses the invalid username in French', async () => {
setLocale('fr')
await mountRoute('/login')
await wrapper!.find('form').trigger('submit')
await flushPromises()
expect(document.activeElement).toBe(wrapper!.find('input[autocomplete="username"]').element)
})
it('focuses article search after clearing French no-results', async () => {
request.mockResolvedValue([
{
id: 12,
title: 'Data title',
body: '',
is_online: false,
created_at: '2025-02-14T12:00:00Z',
updated_at: '2025-02-14T12:00:00Z',
},
])
setLocale('fr')
await mountRoute('/article?q=absent')
Array.from(document.body.querySelectorAll<HTMLButtonElement>('button'))
.find((b) => b.textContent?.trim() === 'Effacer la recherche')!
.click()
await flushPromises()
expect([
document.activeElement === document.body.querySelector('input[type="search"]'),
router.currentRoute.value.query.q,
]).toEqual([true, undefined])
})
it('copies the unchanged integration URL with French feedback and protocol examples', async () => {
const writeText = vi.fn().mockResolvedValue(undefined)
Object.defineProperty(navigator, 'clipboard', { value: { writeText }, configurable: true })
setLocale('fr')
useSessionStore().session = { verified: true, username: 'marta', displayName: '' }
const { default: IntegrationView } = await import('@/views/IntegrationView.vue')
wrapper = mount(IntegrationView, {
attachTo: document.body,
global: { plugins: [ui, router] },
})
await flushPromises()
Array.from(document.body.querySelectorAll<HTMLButtonElement>('button'))
.find((b) => b.textContent?.trim() === 'Copier')!
.click()
await flushPromises()
expect([
writeText.mock.calls[0]?.[0],
document.body.textContent?.includes('Copié'),
document.body.textContent?.includes('/v1/article/'),
document.body.textContent?.includes('GET'),
]).toEqual([window.location.origin, true, true, true])
await vi.waitFor(() =>
expect(document.body.textContent).toContain(
`const res = await fetch('${window.location.origin}/v1/article/')`,
),
)
vi.unstubAllGlobals()
})
it.each(['desktop', 'mobile'] as const)(
'dismisses nested picker with Escape and outside press in %s menu',
async (menu) => {
useSessionStore().session = { verified: true, username: 'marta', displayName: '' }
useSessionStore().status = 'ready'
await router.push('/profile')
wrapper =
menu === 'desktop'
? mount(UserMenu, {
attachTo: document.body,
props: { username: 'marta', collapsed: false },
global: { plugins: [ui, router] },
})
: mount(AccountMenuSheet, {
attachTo: document.body,
props: { username: 'marta', open: true },
global: { plugins: [ui, router] },
})
if (menu === 'desktop')
document.body.querySelector<HTMLButtonElement>('button[title="Account"]')!.click()
await flushPromises()
trigger().focus()
key(trigger(), 'ArrowDown')
await flushPromises()
key(document.activeElement!, 'Escape')
await flushPromises()
const escape = [
locale.value,
!document.body.querySelector('[role="listbox"]'),
document.activeElement === trigger(),
]
trigger().focus()
key(trigger(), 'ArrowDown')
await flushPromises()
const outside = document.createElement('button')
outside.textContent = 'Outside'
document.body.append(outside)
outside.dispatchEvent(
Object.assign(new MouseEvent('pointerdown', { bubbles: true, button: 0 }), {
pointerType: 'mouse',
}),
)
outside.click()
await flushPromises()
expect([
escape,
locale.value,
!document.body.querySelector('[role="listbox"]'),
document.activeElement === trigger(),
]).toEqual([['en', true, true], 'en', true, true])
},
)
it.each(['/article', '/article/12/edit'])(
'updates already-open delete descriptions round-trip from %s preserving title and image sentence',
async (path) => {
const article = {
id: 12,
title: 'Log in',
body: '<p><img src="/static/uploadedImages/photo.png"></p>',
is_online: false,
images: [],
created_at: '2025-02-14T12:00:00Z',
updated_at: '2025-02-14T12:00:00Z',
}
request.mockImplementation(async (url: string) =>
url === '/v1/article/' ? [article] : article,
)
await mountRoute(path)
if (path === '/article') {
document.body
.querySelector<HTMLButtonElement>('[aria-label="Actions for Log in"]')!
.click()
await flushPromises()
Array.from(document.body.querySelectorAll<HTMLElement>('[role="menuitem"]'))
.find((b) => b.textContent?.trim() === 'Delete…')!
.click()
} else
Array.from(document.body.querySelectorAll<HTMLButtonElement>('button'))
.find((b) => b.textContent?.trim() === 'Delete')!
.click()
await flushPromises()
const snapshots = []
for (const language of ['en', 'fr', 'en'] as const) {
setLocale(language)
await flushPromises()
snapshots.push(document.body.querySelector('[role="alertdialog"]')?.textContent)
}
expect(
snapshots.map((text) => [
text?.includes('Log in'),
text?.includes('will be deleted'),
text?.includes('sera supprimé'),
]),
).toEqual([
[true, true, false],
[true, false, true],
[true, true, false],
])
expect(snapshots[0]).toContain('Its images stay in your library.')
expect(snapshots[1]).toContain('Ses images restent dans votre bibliothèque.')
Array.from(
document.body.querySelectorAll<HTMLButtonElement>('[role="alertdialog"] button'),
)
.find((b) => b.textContent?.trim() === 'Cancel')!
.click()
await flushPromises()
},
)
it('translates the editor mobile heading', async () => {
request.mockResolvedValue({
id: 12,
title: 'Data title',
body: '',
images: [],
is_online: false,
created_at: '2025-02-14T12:00:00Z',
updated_at: '2025-02-14T12:00:00Z',
})
setViewportWidth(390)
setLocale('fr')
await mountRoute('/article/12/edit')
expect(document.body.textContent).toContain('Modifier l’article')
})
it('updates a visible 429 upload alert round-trip', async () => {
let upload: LimitedUpload | undefined
const captureUpload = (instance: LimitedUpload) => {
upload = instance
}
class LimitedUpload extends EventTarget {
upload = new EventTarget()
status = 0
responseText = ''
withCredentials = false
open() {}
send() {
captureUpload(this)
}
abort() {}
getResponseHeader() {
return '42'
}
respond() {
this.status = 429
this.dispatchEvent(new Event('load'))
}
}
vi.stubGlobal('XMLHttpRequest', LimitedUpload)
vi.stubGlobal('crypto', { subtle: { digest: async () => new Uint8Array(32).buffer } })
request.mockResolvedValue({
items: [
{
id: 1,
filename: 'existing.jpg',
url: '/media/existing.jpg',
content_type: 'image/jpeg',
size: 1024,
width: 100,
height: 100,
created_at: '2025-02-14T12:00:00Z',
used_in: 0,
sha256: 'abcdef',
uploaded_by: 'Marta',
duplicate: false,
},
],
next_cursor: null,
counts: { all: 1, unused: 1, duplicates: 0 },
})
const listing = await request()
request.mockImplementation(async (path: string) =>
path.includes('?hash=')
? { items: [], next_cursor: null, counts: { all: 0, unused: 0, duplicates: 0 } }
: listing,
)
await mountRoute('/media')
const input = document.body.querySelector<HTMLInputElement>('input[type="file"]')!
Object.defineProperty(input, 'files', {
value: [new File(['photo'], 'photo.jpg', { type: 'image/jpeg' })],
configurable: true,
})
input.dispatchEvent(new Event('change', { bubbles: true }))
await vi.waitFor(() => expect(upload).toBeDefined())
upload!.respond()
await flushPromises()
const snapshots = []
for (const language of ['en', 'fr', 'en'] as const) {
setLocale(language)
await flushPromises()
snapshots.push(
Array.from(document.body.querySelectorAll('[role="status"]')).some(
(e) =>
e.textContent?.includes(
language === 'fr' ? 'Trop d’importations' : 'Too many uploads',
) &&
e.textContent.includes(
language === 'fr'
? 'Pour votre sécurité, patientez avant d’importer à nouveau.'
: 'For your security, wait a moment before uploading again.',
),
),
)
}
expect(snapshots).toEqual([true, true, true])
vi.unstubAllGlobals()
})
})
+17 -5
View File
@@ -1,3 +1,4 @@
import { t, locale, formatNumber } from '@/i18n'
export const MAX_IMAGE_BYTES = 10 * 1024 * 1024
export const ALLOWED_IMAGE_MIME_TYPES = ['image/png', 'image/jpeg', 'image/webp', 'image/svg+xml']
@@ -39,7 +40,13 @@ export function extensionMatchesType(file: File) {
}
export function formatMegabytes(bytes: number) {
return (bytes / (1024 * 1024)).toFixed(1)
const value = bytes / (1024 * 1024)
return locale.value === 'fr'
? formatNumber(value, {
minimumFractionDigits: 1,
maximumFractionDigits: 1,
})
: value.toFixed(1)
}
export function isSizeRejectionBody(body: string) {
@@ -47,16 +54,21 @@ export function isSizeRejectionBody(body: string) {
}
export function rejectionTitle(filename: string) {
return `"${filename}" can't be used`
return t("\u0022{0}\u0022 can't be used", [filename])
}
export function sizeRejectionText(filename: string, bytes: number) {
return `${filename} is ${formatMegabytes(bytes)} MB. Images can be up to 10 MB.`
return t('{0} is {1} MB. Images can be up to 10 MB.', [filename, formatMegabytes(bytes)])
}
export function formatWholeKB(bytes: number): string {
const kb = bytes / 1024
const rounded = Math.round(kb * 10) / 10
if (locale.value === 'fr')
return formatNumber(rounded, {
minimumFractionDigits: rounded === 0 ? 1 : 0,
maximumFractionDigits: 1,
})
if (rounded === 0) {
return '0.0'
}
@@ -65,7 +77,7 @@ export function formatWholeKB(bytes: number): string {
export function formatUploadProgress(loadedBytes: number, totalBytes: number): string {
if (totalBytes < 1024 * 1024) {
return `Uploading ${formatWholeKB(loadedBytes)} of ${formatWholeKB(totalBytes)} KB`
return t('Uploading {0} of {1} KB', [formatWholeKB(loadedBytes), formatWholeKB(totalBytes)])
}
return `Uploading ${formatMegabytes(loadedBytes)} of ${formatMegabytes(totalBytes)} MB`
return t('Uploading {0} of {1} MB', [formatMegabytes(loadedBytes), formatMegabytes(totalBytes)])
}
+14 -2
View File
@@ -1,3 +1,4 @@
import { t } from '@/i18n'
import type { MediaItem } from '@/composables/useMedia'
import { formatDimensions, formatSize } from '@/lib/mediaFormat'
@@ -18,7 +19,11 @@ function byPreference(first: MediaItem, second: MediaItem) {
export function groupHeader(copies: MediaItem[]) {
const [first] = copies
return `${copies.length} identical images · ${formatSize(first!.size)} each · ${formatDimensions(first!.width, first!.height)}`
return t('{0} identical images · {1} each · {2}', [
copies.length,
formatSize(first!.size),
formatDimensions(first!.width, first!.height),
])
}
export function groupDuplicates(items: MediaItem[]): DuplicateGroup[] {
@@ -30,7 +35,14 @@ export function groupDuplicates(items: MediaItem[]): DuplicateGroup[] {
.filter(([, copies]) => copies.length > 1)
.map(([hash, copies]) => {
const sorted = [...copies].sort(byPreference)
return { hash, copies: sorted, suggested: sorted[0]!, header: groupHeader(sorted) }
return {
hash,
copies: sorted,
suggested: sorted[0]!,
get header() {
return groupHeader(sorted)
},
}
})
.sort((first, second) => byAge(first.suggested, second.suggested))
}
+21 -8
View File
@@ -1,3 +1,4 @@
import { t, locale, formatNumber } from '@/i18n'
import { formatAbsoluteDateFromIso } from '@/lib/relativeDate'
const BYTES_PER_KB = 1024
@@ -22,14 +23,22 @@ export interface MediaNameSource {
export function formatSize(bytes: number) {
if (bytes < BYTES_PER_MB) {
return `${Math.round(bytes / BYTES_PER_KB)} KB`
return t('{0} KB', [Math.round(bytes / BYTES_PER_KB)])
}
return `${(bytes / BYTES_PER_MB).toFixed(1)} MB`
const amount = bytes / BYTES_PER_MB
return t('{0} MB', [
locale.value === 'fr'
? formatNumber(amount, {
minimumFractionDigits: 1,
maximumFractionDigits: 1,
})
: amount.toFixed(1),
])
}
export function formatDimensions(width: number | null, height: number | null) {
if (width === null || height === null) {
return VECTOR_DIMENSIONS
return t(VECTOR_DIMENSIONS)
}
return `${width} × ${height}`
}
@@ -41,11 +50,11 @@ export function formatType(contentType: string) {
export const formatMediaDate = formatAbsoluteDateFromIso
export function pluralArticles(count: number) {
return `${count} ${count === 1 ? 'article' : 'articles'}`
return t(count === 1 ? '1 article' : '{0} articles', [count])
}
export function usedInLabel(count: number) {
return count > 0 ? `used in ${pluralArticles(count)}` : 'unused'
return count > 0 ? t('used in {0}', [pluralArticles(count)]) : t('unused')
}
export function accessibleName(item: MediaNameSource) {
@@ -74,13 +83,17 @@ export function joinNames(names: string[]) {
if (names.length <= 1) {
return names.join('')
}
return `${names.slice(0, -1).join(', ')} and ${names[names.length - 1]}`
return t('{0} and {1}', [names.slice(0, -1).join(', '), names[names.length - 1]])
}
export function libraryMatchText(
filename: string,
existing: { created_at: string; uploaded_by: string },
) {
const by = existing.uploaded_by ? ` by ${existing.uploaded_by}` : ''
return `${filename} matches an image uploaded on ${formatMediaDate(existing.created_at)}${by}.`
const by = existing.uploaded_by ? t(' by {0}', [existing.uploaded_by]) : ''
return t('{0} matches an image uploaded on {1}{2}.', [
filename,
formatMediaDate(existing.created_at),
by,
])
}
+32 -20
View File
@@ -1,3 +1,4 @@
import { t } from '@/i18n'
import { formatMegabytes } from '@/lib/imageUpload'
export type UploadRowStatus =
@@ -42,40 +43,40 @@ export function isSettled(status: UploadRowStatus) {
}
function imageCount(total: number) {
return `${total} ${total === 1 ? 'image' : 'images'}`
}
function fileCount(total: number) {
return `${total} ${total === 1 ? 'file' : 'files'}`
return t(total === 1 ? '{0} image' : '{0} images', [total])
}
export function rejectionTitle(kind: UploadRejection, filename: string, bytes: number) {
if (kind === 'size') {
return `${filename} is ${formatMegabytes(bytes)} MB`
return t('{0} is {1} MB', [filename, formatMegabytes(bytes)])
}
if (kind === 'unreadable') {
return `${filename} couldn't be read`
return t("{0} couldn't be read", [filename])
}
return `${filename} isn't an image`
return t("{0} isn't an image", [filename])
}
export function rejectionText(kind: UploadRejection) {
return kind === 'unreadable' ? UNREADABLE_TEXT : REJECTION_TEXT
return kind === 'unreadable' ? t(UNREADABLE_TEXT) : t(REJECTION_TEXT)
}
export function duplicateText(
existing: { filename: string; created_at: string; uploaded_by: string },
formatDate: (iso: string) => string,
) {
const by = existing.uploaded_by ? ` by ${existing.uploaded_by}` : ''
return `Same as "${existing.filename}", uploaded ${formatDate(existing.created_at)}${by}.`
const by = existing.uploaded_by ? t(' by {0}', [existing.uploaded_by]) : ''
return t('Same as \u0022{0}\u0022, uploaded {1}{2}.', [
existing.filename,
formatDate(existing.created_at),
by,
])
}
export function summaryLine(counts: { added: number; duplicate: number; attention: number }) {
const parts = [
...(counts.added > 0 ? [`${counts.added} uploaded`] : []),
...(counts.duplicate > 0 ? [`${counts.duplicate} already in your library`] : []),
...(counts.attention > 0 ? [`${counts.attention} need attention`] : []),
...(counts.added > 0 ? [t('{0} uploaded', [counts.added])] : []),
...(counts.duplicate > 0 ? [t('{0} already in your library', [counts.duplicate])] : []),
...(counts.attention > 0 ? [t('{0} need attention', [counts.attention])] : []),
]
return parts.join(' · ')
}
@@ -87,8 +88,10 @@ export function describeUpload(rows: UploadRowLike[]): UploadHeader {
if (settled < total) {
return {
phase: 'working',
title: `Uploading ${imageCount(total)}`,
sub: `${settled} of ${total} done`,
get title() {
return t('Uploading {0}', [imageCount(total)])
},
sub: t('{0} of {1} done', [settled, total]),
summary: null,
}
}
@@ -98,13 +101,22 @@ export function describeUpload(rows: UploadRowLike[]): UploadHeader {
if (added === 0 && duplicate === 0) {
const sub =
count('rejected') === total
? `${fileCount(total)} ${total === 1 ? 'was' : 'were'} rejected`
: `${fileCount(total)} ${total === 1 ? 'needs' : 'need'} attention`
return { phase: 'settled', title: 'Nothing was uploaded', sub, summary: null }
? t(total === 1 ? '{0} file was rejected' : '{0} files were rejected', [total])
: t(total === 1 ? '{0} file needs attention' : '{0} files need attention', [total])
return {
phase: 'settled',
get title() {
return t('Nothing was uploaded')
},
sub,
summary: null,
}
}
return {
phase: 'settled',
title: 'Upload finished',
get title() {
return t('Upload finished')
},
sub: imageCount(total),
summary: summaryLine({ added, duplicate, attention }),
}
+7 -2
View File
@@ -1,6 +1,11 @@
import { t } from '@/i18n'
export const registrationCancelledToast = {
color: 'success' as const,
icon: 'i-lucide-circle-check',
title: 'Registration cancelled',
description: 'You can create the owner account again.',
get title() {
return t('Registration cancelled')
},
get description() {
return t('You can create the owner account again.')
},
}
+13 -5
View File
@@ -1,3 +1,4 @@
import { t, locale } from '@/i18n'
const MINUTE_MS = 60 * 1000
const HOUR_MS = 60 * MINUTE_MS
const DAY_MS = 24 * HOUR_MS
@@ -19,6 +20,13 @@ const MONTH_NAMES = [
]
function formatAbsoluteDate(date: Date) {
if (locale.value === 'fr') {
return new Intl.DateTimeFormat('fr', {
day: 'numeric',
month: 'short',
year: 'numeric',
}).format(date)
}
return `${date.getDate()} ${MONTH_NAMES[date.getMonth()]} ${date.getFullYear()}`
}
@@ -32,18 +40,18 @@ export function formatRelativeDate(iso: string, now: Date = new Date()) {
if (diffMs < HOUR_MS) {
const minutes = Math.max(1, Math.floor(diffMs / MINUTE_MS))
return minutes === 1 ? '1 minute ago' : `${minutes} minutes ago`
return minutes === 1 ? t('1 minute ago') : t('{0} minutes ago', [minutes])
}
if (diffMs < DAY_MS) {
const hours = Math.floor(diffMs / HOUR_MS)
return hours === 1 ? '1 hour ago' : `${hours} hours ago`
return hours === 1 ? t('1 hour ago') : t('{0} hours ago', [hours])
}
const days = Math.floor(diffMs / DAY_MS)
if (days === 1) {
return 'Yesterday'
return t('Yesterday')
}
if (days < RELATIVE_CUTOFF_DAYS) {
return `${days} days ago`
return t('{0} days ago', [days])
}
return formatAbsoluteDate(date)
}
@@ -58,5 +66,5 @@ function isSameLocalDay(a: Date, b: Date) {
export function formatCreatedDate(iso: string, now: Date = new Date()) {
const date = new Date(iso)
return isSameLocalDay(date, now) ? 'Today' : formatAbsoluteDate(date)
return isSameLocalDay(date, now) ? t('Today') : formatAbsoluteDate(date)
}
+12 -11
View File
@@ -1,3 +1,4 @@
import { t } from '@/i18n'
import { formatAbsoluteDateFromIso, formatRelativeDate } from '@/lib/relativeDate'
export const ENABLE_BROWSER_UPDATES_COMMAND = 'renewcms enable-browser-updates'
@@ -26,12 +27,12 @@ export function formatLastChecked(iso: string, now: Date = new Date()) {
const yesterday = new Date(now)
yesterday.setDate(now.getDate() - 1)
if (isSameLocalDay(date, now)) {
return `Last checked today at ${formatClock(date)}`
return t('Last checked today at {0}', [formatClock(date)])
}
if (isSameLocalDay(date, yesterday)) {
return `Last checked yesterday at ${formatClock(date)}`
return t('Last checked yesterday at {0}', [formatClock(date)])
}
return `Last checked ${formatAbsoluteDateFromIso(iso)} at ${formatClock(date)}`
return t('Last checked {0} at {1}', [formatAbsoluteDateFromIso(iso), formatClock(date)])
}
export function isCheckDue(checkedAt: string | null, now: number = Date.now()) {
@@ -42,7 +43,7 @@ export function isCheckDue(checkedAt: string | null, now: number = Date.now()) {
}
export function formatReleased(iso: string) {
return `Released ${formatAbsoluteDateFromIso(iso)}`
return t('Released {0}', [formatAbsoluteDateFromIso(iso)])
}
const BYTES_PER_MB = 1024 * 1024
@@ -56,7 +57,7 @@ export function formatDownloadProgress(progress: {
}) {
const done = Math.round(progress.downloaded_bytes / BYTES_PER_MB)
const total = Math.round(progress.total_bytes / BYTES_PER_MB)
return `${progress.percent}% · ${done} of ${total} MB`
return t('{0}% · {1} of {2} MB', [progress.percent, done, total])
}
export function downloadAnnouncement(version: string, percent: number) {
@@ -64,8 +65,8 @@ export function downloadAnnouncement(version: string, percent: number) {
Math.floor(percent / ANNOUNCEMENT_STEP_PERCENT) * ANNOUNCEMENT_STEP_PERCENT,
LAST_ANNOUNCED_PERCENT,
)
const base = `Downloading version ${version}`
return step > 0 ? `${base}, ${step} percent` : base
const base = t('Downloading version {0}', [version])
return step > 0 ? t('{0}, {1} percent', [base, step]) : base
}
export const VERIFYING_ANNOUNCEMENT = 'Checking the download'
@@ -84,7 +85,7 @@ export function browserUpdatesDocsUrl(releaseUrl: string) {
}
export function formatStartedAt(iso: string) {
return `Started at ${formatClock(new Date(iso))}`
return t('Started at {0}', [formatClock(new Date(iso))])
}
export function formatStartedWithAge(iso: string, now: Date = new Date()) {
@@ -96,10 +97,10 @@ export function formatUpdatedAt(iso: string, now: Date = new Date()) {
const yesterday = new Date(now)
yesterday.setDate(now.getDate() - 1)
if (isSameLocalDay(date, now)) {
return `Updated today at ${formatClock(date)}`
return t('Updated today at {0}', [formatClock(date)])
}
if (isSameLocalDay(date, yesterday)) {
return `Updated yesterday at ${formatClock(date)}`
return t('Updated yesterday at {0}', [formatClock(date)])
}
return `Updated ${formatAbsoluteDateFromIso(iso)} at ${formatClock(date)}`
return t('Updated {0} at {1}', [formatAbsoluteDateFromIso(iso), formatClock(date)])
}
+78 -38
View File
@@ -1,7 +1,9 @@
<script setup lang="ts">
import { t, translate } from '@/i18n'
import { computed, nextTick, onMounted, ref } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { useToast } from '@nuxt/ui/composables'
import { useLocalizedToast } from '@/i18n/presentation'
import AuthShell from '@/components/AuthShell.vue'
import PasswordStrengthHint from '@/components/PasswordStrengthHint.vue'
import ResultPanel from '@/components/ResultPanel.vue'
@@ -19,7 +21,7 @@ const route = useRoute()
const router = useRouter()
const api = useApi()
const session = useSessionStore()
const toast = useToast()
const toast = useLocalizedToast()
const code = typeof route.query.c === 'string' ? route.query.c : ''
@@ -40,22 +42,29 @@ const passwordValid = computed(() => password.value.length >= 8)
const usernameError = computed(() => {
if (usernameTaken.value) {
return USERNAME_TAKEN_MESSAGE
return t(USERNAME_TAKEN_MESSAGE)
}
return usernameTouched.value && !usernameValid.value ? USERNAME_RULE_MESSAGE : ''
return usernameTouched.value && !usernameValid.value ? t(USERNAME_RULE_MESSAGE) : ''
})
const passwordRules = computed(() => [{ label: 'At least 8 characters', met: passwordValid.value }])
const passwordRules = computed(() => [
{
get label() {
return t('At least 8 characters')
},
met: passwordValid.value,
},
])
const formValid = computed(() => usernameValid.value && passwordValid.value)
const showForm = computed(() => state.value === 'form')
const pageTitle = computed(() => (showForm.value ? 'Accept your invite' : ''))
const pageTitle = computed(() => (showForm.value ? t('Accept your invite') : ''))
const pageDescription = computed(() =>
showForm.value
? 'Choose a username and password to finish setting up your account.'
? t('Choose a username and password to finish setting up your account.')
: undefined,
)
@@ -63,11 +72,20 @@ function goToLogin() {
router.push('/login')
}
const invalidActions = [{ label: 'Go to log in', onClick: goToLogin }]
const invalidActions = [
{
get label() {
return t('Go to log in')
},
onClick: goToLogin,
},
]
const expiredActions = [
{
label: 'Go to log in',
get label() {
return t('Go to log in')
},
color: 'neutral' as const,
variant: 'outline' as const,
onClick: goToLogin,
@@ -117,12 +135,16 @@ async function loadInvite() {
async function handleSuccess() {
state.value = 'success'
toast.add({
toast.add(() => ({
color: 'success',
icon: 'i-lucide-circle-check',
title: 'Welcome to RenewCMS',
description: `You're logged in as ${username.value}.`,
})
get title() {
return t('Welcome to RenewCMS')
},
get description() {
return t("You're logged in as {0}.", [username.value])
},
}))
await Promise.all([session.retry(), wait(VERIFIED_REDIRECT_DELAY_MS)])
await router.push('/home')
}
@@ -187,8 +209,8 @@ onMounted(() => {
<UAlert
color="error"
icon="i-lucide-circle-x"
title="Couldn't create the account"
description="Something went wrong on the server. Try again in a moment."
:title="t('Couldn\'t create the account')"
:description="t('Something went wrong on the server. Try again in a moment.')"
variant="soft"
:close="false"
role="alert"
@@ -202,16 +224,20 @@ onMounted(() => {
class="flex flex-col items-center gap-4 py-10 text-center"
>
<UIcon name="i-lucide-loader-2" class="size-8 animate-spin text-highlighted" />
<p class="text-xl/7 font-semibold text-highlighted">Checking your invite…</p>
<p class="text-base text-muted">This only takes a moment.</p>
<p class="text-xl/7 font-semibold text-highlighted">
{{ t('Checking your invite…') }}
</p>
<p class="text-base text-muted">
{{ t('This only takes a moment.') }}
</p>
</div>
<div v-else-if="state === 'failed'" class="flex flex-col gap-4.5">
<UAlert
color="error"
icon="i-lucide-circle-x"
title="Couldn't check your invite"
description="Something went wrong on the server. Try again in a moment."
:title="t('Couldn\'t check your invite')"
:description="t('Something went wrong on the server. Try again in a moment.')"
variant="soft"
:close="false"
role="alert"
@@ -224,7 +250,7 @@ onMounted(() => {
:ui="{ base: controlClass }"
@click="loadInvite"
>
Try again
{{ t('Try again') }}
</UButton>
</div>
@@ -232,12 +258,20 @@ onMounted(() => {
v-else-if="state === 'invalid'"
tone="error"
icon="i-lucide-link-2-off"
title="This invite link doesn't work"
description="It may be incomplete, or it may already have been used. If you already set up your account, log in instead."
:title="t('This invite link doesn\'t work')"
:description="
t(
'It may be incomplete, or it may already have been used. If you already set up your account, log in instead.',
)
"
:actions="invalidActions"
>
<p class="text-sm text-pretty text-muted">
Open the link from your email again, or copy the full address into your browser.
{{
t(
'Open the link from your email again, or copy the full address into your browser.',
)
}}
</p>
</ResultPanel>
@@ -245,8 +279,12 @@ onMounted(() => {
v-else-if="state === 'expired'"
tone="error"
icon="i-lucide-clock"
title="This invite has expired"
description="Invites work once, for 3 days. Ask the person who invited you to send a new one."
:title="t('This invite has expired')"
:description="
t(
'Invites work once, for 3 days. Ask the person who invited you to send a new one.',
)
"
:actions="expiredActions"
/>
@@ -254,8 +292,8 @@ onMounted(() => {
v-else-if="state === 'success'"
tone="success"
icon="i-lucide-circle-check"
title="Your account is ready"
description="Taking you to your dashboard…"
:title="t('Your account is ready')"
:description="t('Taking you to your dashboard…')"
:actions="[]"
/>
@@ -266,7 +304,7 @@ onMounted(() => {
@submit="onSubmit"
>
<UFormField
label="Email"
:label="t('Email')"
name="email"
help="From your invitation."
:size="controlSize"
@@ -288,9 +326,9 @@ onMounted(() => {
</UFormField>
<UFormField
label="Username"
:label="t('Username')"
name="username"
:error="usernameError || undefined"
:error="translate(usernameError) || undefined"
:size="controlSize"
>
<template v-if="usernameError" #error="{ error }">
@@ -299,11 +337,11 @@ onMounted(() => {
name="i-lucide-circle-alert"
class="size-3.5 shrink-0 text-error"
/>
{{ error }}
{{ t(error) }}
</span>
</template>
<template v-else #help>
Letters and numbers, 3 to 20 characters. You'll use it to log in.
{{ t("Letters and numbers, 3 to 20 characters. You'll use it to log in.") }}
</template>
<template #hint>
<span
@@ -317,7 +355,7 @@ onMounted(() => {
</template>
<UInput
v-model="username"
placeholder="e.g. lea"
:placeholder="t('e.g. lea')"
autocomplete="username"
:disabled="submitting"
variant="outline"
@@ -331,7 +369,7 @@ onMounted(() => {
/>
</UFormField>
<UFormField label="Password" name="password" :size="controlSize">
<UFormField :label="t('Password')" name="password" :size="controlSize">
<template #help>
<PasswordStrengthHint
:rules="passwordRules"
@@ -361,7 +399,9 @@ onMounted(() => {
size="sm"
square
class="size-8 justify-center text-toned"
:aria-label="passwordShown ? 'Hide password' : 'Show password'"
:aria-label="
passwordShown ? t('Hide password') : t('Show password')
"
:aria-pressed="passwordShown"
@click="passwordShown = !passwordShown"
/>
@@ -377,19 +417,19 @@ onMounted(() => {
:loading="submitting"
:disabled="submitting"
>
{{ submitting ? 'Creating account…' : 'Accept invite' }}
{{ submitting ? t('Creating account…') : t('Accept invite') }}
</UButton>
</UForm>
</template>
<template v-if="showForm" #footer>
<span class="flex flex-wrap items-center gap-1.5">
Already have an account?
{{ t('Already have an account?') }}
<RouterLink
to="/login"
class="font-medium text-highlighted underline underline-offset-3"
>
Log in
{{ t('Log in') }}
</RouterLink>
</span>
</template>
+112 -48
View File
@@ -1,7 +1,9 @@
<script setup lang="ts">
import { t } from '@/i18n'
import { computed, nextTick, onMounted, ref, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { useOverlay, useToast } from '@nuxt/ui/composables'
import { useLocalizedOverlay, useLocalizedToast } from '@/i18n/presentation'
import DashboardShell from '@/components/DashboardShell.vue'
import ArticleTable from '@/components/ArticleTable.vue'
import ArticleCardList from '@/components/ArticleCardList.vue'
@@ -19,8 +21,8 @@ const route = useRoute()
const router = useRouter()
const { request } = useApi()
const { listArticles } = useArticles()
const overlay = useOverlay()
const toast = useToast()
const overlay = useLocalizedOverlay()
const toast = useLocalizedToast()
const articles = ref<Article[]>([])
const loading = ref(true)
@@ -54,7 +56,9 @@ const activeTab = computed<'' | 'online' | 'draft'>({
get: () =>
route.query.status === 'online' || route.query.status === 'draft' ? route.query.status : '',
set: (value) => {
router.replace({ query: { ...route.query, status: value || undefined } })
router.replace({
query: { ...route.query, status: value || undefined },
})
},
})
@@ -68,9 +72,24 @@ const counts = computed(() => {
})
const tabItems = computed(() => [
{ label: `All ${counts.value.all}`, value: '' },
{ label: `Online ${counts.value.online}`, value: 'online' },
{ label: `Drafts ${counts.value.drafts}`, value: 'draft' },
{
get label() {
return t('All {0}', [counts.value.all])
},
value: '',
},
{
get label() {
return t('Online {0}', [counts.value.online])
},
value: 'online',
},
{
get label() {
return t('Drafts {0}', [counts.value.drafts])
},
value: 'draft',
},
])
const statusFilteredArticles = computed(() => {
@@ -156,8 +175,12 @@ function notFoundToast(article: Article) {
id: `article-not-found-${article.id}`,
color: 'info' as const,
icon: 'i-lucide-info',
title: 'That article no longer exists',
description: 'It was deleted somewhere else, so we removed it from the list.',
get title() {
return t('That article no longer exists')
},
get description() {
return t('It was deleted somewhere else, so we removed it from the list.')
},
}
}
@@ -171,30 +194,45 @@ async function setOnline(article: Article, makeOnline: boolean) {
articles.value = articles.value.map((existing) =>
existing.id === updated.id ? updated : existing,
)
toast.add({
toast.add(() => ({
id: `article-${makeOnline ? 'published' : 'unpublished'}-${article.id}`,
color: 'success',
icon: 'i-lucide-circle-check',
title: makeOnline ? 'Article published' : 'Article unpublished',
description: makeOnline
? `"${article.title}" is now online.`
: `"${article.title}" is now offline.`,
})
get title() {
return makeOnline ? t('Article published') : t('Article unpublished')
},
get description() {
return makeOnline
? t('\u0022{0}\u0022 is now online.', [article.title])
: t('\u0022{0}\u0022 is now offline.', [article.title])
},
}))
} catch (err) {
if (err instanceof ApiError && err.status === 404) {
removeArticle(article.id)
toast.add(notFoundToast(article))
toast.add(() => notFoundToast(article))
return
}
toast.add({
toast.add(() => ({
id: `article-${makeOnline ? 'publish' : 'unpublish'}-failed-${article.id}`,
color: 'error',
icon: 'i-lucide-circle-x',
title: makeOnline ? "Couldn't publish" : "Couldn't unpublish",
description: "The server didn't respond. Nothing changed.",
get title() {
return makeOnline ? t("Couldn't publish") : t("Couldn't unpublish")
},
get description() {
return t("The server didn't respond. Nothing changed.")
},
duration: 0,
actions: [{ label: 'Retry', onClick: () => setOnline(article, makeOnline) }],
})
actions: [
{
get label() {
return t('Retry')
},
onClick: () => setOnline(article, makeOnline),
},
],
}))
} finally {
setBusy(article.id, false)
}
@@ -222,48 +260,62 @@ async function handleCopyUrl(id: number) {
const url = `${window.location.origin}/v1/article/${id}`
try {
await navigator.clipboard.writeText(url)
toast.add({
toast.add(() => ({
id: `article-link-copied-${id}`,
color: 'success',
icon: 'i-lucide-circle-check',
title: 'Link copied',
description: `The API URL for "${article.title}" is on your clipboard.`,
})
get title() {
return t('Link copied')
},
get description() {
return t('The API URL for \u0022{0}\u0022 is on your clipboard.', [article.title])
},
}))
} catch {
toast.add({
toast.add(() => ({
id: `article-link-copy-failed-${id}`,
color: 'error',
icon: 'i-lucide-circle-x',
title: "Couldn't copy the link",
})
get title() {
return t("Couldn't copy the link")
},
}))
}
}
function deleteDescription(article: Article) {
const count = bodyImageCount(article.body)
const imagesPhrase =
count === 0 ? '' : ' Its images stay in your library.'
return `"${article.title}" will be deleted.${imagesPhrase} This can't be undone.`
const imagesPhrase = count === 0 ? '' : t(' Its images stay in your library.')
return t("\u0022{0}\u0022 will be deleted.{1} This can't be undone.", [
article.title,
imagesPhrase,
])
}
async function performDelete(article: Article) {
try {
await request(`/v1/article/${article.id}`, { method: 'DELETE' })
removeArticle(article.id)
toast.add({
toast.add(() => ({
id: `article-deleted-${article.id}`,
color: 'success',
icon: 'i-lucide-circle-check',
title: 'Article deleted',
description: `"${article.title}" is gone.`,
})
get title() {
return t('Article deleted')
},
get description() {
return t('\u0022{0}\u0022 is gone.', [article.title])
},
}))
} catch (err) {
if (err instanceof ApiError && err.status === 404) {
removeArticle(article.id)
toast.add(notFoundToast(article))
toast.add(() => notFoundToast(article))
return
}
throw new Error("Couldn't delete the article, so it's still there. Try again in a moment.")
throw new Error(
t("Couldn't delete the article, so it's still there. Try again in a moment."),
)
}
}
@@ -283,11 +335,21 @@ async function handleDelete(article: Article) {
const confirmed = await overlay
.create(ConfirmDialog, {
props: {
title: 'Delete this article?',
description: deleteDescription(article),
confirmLabel: 'Delete article',
cancelLabel: 'Cancel',
pendingLabel: 'Deleting…',
get title() {
return t('Delete this article?')
},
get description() {
return deleteDescription(article)
},
get confirmLabel() {
return t('Delete article')
},
get cancelLabel() {
return t('Cancel')
},
get pendingLabel() {
return t('Deleting…')
},
tone: 'danger',
action: () => performDelete(article),
},
@@ -311,7 +373,7 @@ function clearSearch() {
debouncedSearch.value = ''
router.replace({ query: { ...route.query, q: undefined } })
nextTick(() => {
document.querySelector<HTMLInputElement>('input[placeholder="Search by title"]')?.focus()
document.querySelector<HTMLInputElement>('input[type="search"]')?.focus()
})
}
@@ -340,16 +402,18 @@ onMounted(loadArticles)
tabindex="-1"
class="text-xl font-semibold text-highlighted"
>
Articles
{{ t('Articles') }}
</h1>
<p class="text-muted">Everything you've written, online and in draft.</p>
<p class="text-muted">
{{ t("Everything you've written, online and in draft.") }}
</p>
</div>
<UButton
icon="i-lucide-plus"
class="hidden h-8 px-3 sm:flex"
@click="openNewArticleModal"
>
New article
{{ t('New article') }}
</UButton>
</div>
@@ -366,7 +430,7 @@ onMounted(loadArticles)
v-model="searchInput"
type="search"
icon="i-lucide-search"
placeholder="Search by title"
:placeholder="t('Search by title')"
class="w-full sm:w-65"
:ui="{ base: 'h-11 text-lg sm:h-8 sm:text-sm' }"
/>
@@ -407,7 +471,7 @@ onMounted(loadArticles)
class="fixed inset-x-4 bottom-4 h-12 justify-center text-[0.9375rem] shadow-(--shadow-popover) sm:hidden"
@click="openNewArticleModal"
>
New article
{{ t('New article') }}
</UButton>
<NewArticleModal v-model:open="newArticleModalOpen" @created="onArticleCreated" />
+136 -60
View File
@@ -1,7 +1,9 @@
<script setup lang="ts">
import { t } from '@/i18n'
import { computed, onBeforeUnmount, onMounted, ref } from 'vue'
import { onBeforeRouteLeave, useRoute, useRouter } from 'vue-router'
import { useOverlay, useToast } from '@nuxt/ui/composables'
import { useLocalizedOverlay, useLocalizedToast } from '@/i18n/presentation'
import DashboardShell from '@/components/DashboardShell.vue'
import ErrorView from '@/components/ErrorView.vue'
import ArticleStatusBadge from '@/components/ArticleStatusBadge.vue'
@@ -28,8 +30,8 @@ type LoadState = 'loading' | 'loaded' | 'notFound' | 'error'
const route = useRoute()
const router = useRouter()
const { request } = useApi()
const overlay = useOverlay()
const toast = useToast()
const overlay = useLocalizedOverlay()
const toast = useLocalizedToast()
const articleId = Number(route.params.id)
const draft = useArticleDraft(articleId)
@@ -91,11 +93,13 @@ const publishDisabled = computed(() => overLimit.value || draft.state.value ===
async function runSave() {
const outcome = await draft.save()
if (outcome === 'not-found') {
toast.add({
toast.add(() => ({
color: 'error',
icon: 'i-lucide-circle-x',
title: 'That article no longer exists.',
})
get title() {
return t('That article no longer exists.')
},
}))
}
}
@@ -121,36 +125,51 @@ async function setOnline(makeOnline: boolean) {
if (!article.value) {
return
}
publishPending.value = makeOnline ? 'Publishing…' : 'Unpublishing…'
publishPending.value = makeOnline ? t('Publishing…') : t('Unpublishing…')
try {
const updated = await request<Article>(
`/v1/article/${articleId}/${makeOnline ? 'publish' : 'unpublish'}`,
{ method: 'PATCH' },
)
article.value = updated
toast.add({
toast.add(() => ({
id: `article-${makeOnline ? 'published' : 'unpublished'}-${articleId}`,
color: 'success',
icon: 'i-lucide-circle-check',
title: makeOnline ? 'Article published' : 'Article unpublished',
description: makeOnline
? `"${updated.title}" is now online.`
: `"${updated.title}" is now offline.`,
})
get title() {
return makeOnline ? t('Article published') : t('Article unpublished')
},
get description() {
return makeOnline
? t('\u0022{0}\u0022 is now online.', [updated.title])
: t('\u0022{0}\u0022 is now offline.', [updated.title])
},
}))
} catch (err) {
if (err instanceof ApiError && err.status === 404) {
loadState.value = 'notFound'
return
}
toast.add({
toast.add(() => ({
id: `article-${makeOnline ? 'publish' : 'unpublish'}-failed-${articleId}`,
color: 'error',
icon: 'i-lucide-circle-x',
title: makeOnline ? "Couldn't publish" : "Couldn't unpublish",
description: "The server didn't respond. Nothing changed.",
get title() {
return makeOnline ? t("Couldn't publish") : t("Couldn't unpublish")
},
get description() {
return t("The server didn't respond. Nothing changed.")
},
duration: 0,
actions: [{ label: 'Retry', onClick: () => setOnline(makeOnline) }],
})
actions: [
{
get label() {
return t('Retry')
},
onClick: () => setOnline(makeOnline),
},
],
}))
} finally {
publishPending.value = undefined
}
@@ -166,28 +185,36 @@ function handleUnpublish() {
function deleteDescription() {
const count = bodyImageCount(article.value?.body ?? '')
const imagesPhrase =
count === 0 ? '' : ' Its images stay in your library.'
return `"${article.value?.title ?? ''}" will be deleted.${imagesPhrase} This can't be undone.`
const imagesPhrase = count === 0 ? '' : t(' Its images stay in your library.')
return t("\u0022{0}\u0022 will be deleted.{1} This can't be undone.", [
article.value?.title ?? '',
imagesPhrase,
])
}
async function performDelete() {
try {
await request(`/v1/article/${articleId}`, { method: 'DELETE' })
toast.add({
toast.add(() => ({
id: `article-deleted-${articleId}`,
color: 'success',
icon: 'i-lucide-circle-check',
title: 'Article deleted',
description: `"${article.value?.title ?? ''}" is gone.`,
})
get title() {
return t('Article deleted')
},
get description() {
return t('\u0022{0}\u0022 is gone.', [article.value?.title ?? ''])
},
}))
router.push('/article')
} catch (err) {
if (err instanceof ApiError && err.status === 404) {
loadState.value = 'notFound'
return
}
throw new Error("Couldn't delete the article, so it's still there. Try again in a moment.")
throw new Error(
t("Couldn't delete the article, so it's still there. Try again in a moment."),
)
}
}
@@ -198,11 +225,21 @@ async function handleDelete() {
await overlay
.create(ConfirmDialog, {
props: {
title: 'Delete this article?',
description: deleteDescription(),
confirmLabel: 'Delete article',
cancelLabel: 'Cancel',
pendingLabel: 'Deleting…',
get title() {
return t('Delete this article?')
},
get description() {
return deleteDescription()
},
get confirmLabel() {
return t('Delete article')
},
get cancelLabel() {
return t('Cancel')
},
get pendingLabel() {
return t('Deleting…')
},
tone: 'danger',
action: performDelete,
},
@@ -229,12 +266,16 @@ function onEditorDrop(event: DragEvent) {
}
function announceImageAdded() {
toast.add({
toast.add(() => ({
color: 'success',
icon: 'i-lucide-circle-check',
title: 'Image added',
description: 'Save to keep it in the article.',
})
get title() {
return t('Image added')
},
get description() {
return t('Save to keep it in the article.')
},
}))
}
function onImageUploaded(item: MediaItem) {
@@ -251,12 +292,16 @@ function onImageInserted(item: MediaItem, alt: string) {
async function performRemoveImage(imageId: number) {
editorRef.value?.removeImage(imageId)
toast.add({
toast.add(() => ({
color: 'success',
icon: 'i-lucide-circle-check',
title: 'Image removed',
description: "It's still in your library.",
})
get title() {
return t('Image removed')
},
get description() {
return t("It's still in your library.")
},
}))
}
function removalPreview(imageId: number) {
@@ -272,12 +317,23 @@ async function onRequestRemoveImage(imageId: number) {
await overlay
.create(ConfirmDialog, {
props: {
title: 'Remove this image from the article?',
description:
"It's removed from this article only. The file stays in your library and in any other article that uses it.",
confirmLabel: 'Remove image',
cancelLabel: 'Cancel',
pendingLabel: 'Removing…',
get title() {
return t('Remove this image from the article?')
},
get description() {
return t(
"It's removed from this article only. The file stays in your library and in any other article that uses it.",
)
},
get confirmLabel() {
return t('Remove image')
},
get cancelLabel() {
return t('Cancel')
},
get pendingLabel() {
return t('Removing…')
},
tone: 'neutral',
preview: removalPreview(imageId),
action: () => performRemoveImage(imageId),
@@ -314,11 +370,23 @@ onBeforeRouteLeave(async () => {
const confirmed = await overlay
.create(ConfirmDialog, {
props: {
title: 'Leave without saving?',
description: `Your changes to "${article.value?.title ?? ''}" haven't been saved.`,
confirmLabel: 'Keep editing',
cancelLabel: 'Discard changes',
pendingLabel: 'Keep editing',
get title() {
return t('Leave without saving?')
},
get description() {
return t("Your changes to \u0022{0}\u0022 haven't been saved.", [
article.value?.title ?? '',
])
},
get confirmLabel() {
return t('Keep editing')
},
get cancelLabel() {
return t('Discard changes')
},
get pendingLabel() {
return t('Keep editing')
},
tone: 'neutral',
action: async () => {},
dismissResolvesTo: 'confirm',
@@ -330,10 +398,18 @@ onBeforeRouteLeave(async () => {
</script>
<template>
<DashboardShell mobile-title="Edit article" full-bleed>
<DashboardShell :mobile-title="t('Edit article')" full-bleed>
<template v-if="loadState === 'loaded'" #breadcrumb>
<UBreadcrumb
:items="[{ label: 'Articles', to: '/article' }, { label: article?.title ?? '' }]"
:items="[
{
get label() {
return t('Articles')
},
to: '/article',
},
{ label: article?.title ?? '' },
]"
color="neutral"
class="hidden lg:block"
/>
@@ -356,7 +432,7 @@ onBeforeRouteLeave(async () => {
color="neutral"
variant="ghost"
class="size-8 justify-center text-toned"
aria-label="Back to Articles"
:aria-label="t('Back to Articles')"
@click="goBack"
/>
<USkeleton class="h-5 w-80 rounded-none" data-testid="skeleton-title" />
@@ -393,13 +469,13 @@ onBeforeRouteLeave(async () => {
color="neutral"
variant="ghost"
class="size-8 justify-center text-toned"
aria-label="Back to Articles"
:aria-label="t('Back to Articles')"
@click="goBack"
/>
<div class="flex min-w-50 flex-1 flex-wrap items-center gap-2.5">
<h1
class="text-[1.125rem]/6.5 font-semibold text-highlighted"
title="The title can't be changed after creation."
:title="t('The title can\'t be changed after creation.')"
>
{{ article.title }}
</h1>
@@ -413,7 +489,7 @@ onBeforeRouteLeave(async () => {
<div class="hidden items-center gap-3 lg:flex">
<USwitch
v-model="previewOn"
label="Preview"
:label="t('Preview')"
:ui="{
root: 'items-center gap-2',
container: 'h-auto',
@@ -432,7 +508,7 @@ onBeforeRouteLeave(async () => {
:disabled="!saveEnabled"
@click="performSave"
>
Save
{{ t('Save') }}
</UButton>
<PublishControl
class="h-8 gap-1.5 px-3"
@@ -450,7 +526,7 @@ onBeforeRouteLeave(async () => {
class="h-8 gap-1.5 px-3"
@click="handleDelete"
>
Delete
{{ t('Delete') }}
</UButton>
</div>
</div>
@@ -513,7 +589,7 @@ onBeforeRouteLeave(async () => {
color="neutral"
variant="outline"
class="size-11 justify-center"
aria-label="Details"
:aria-label="t('Details')"
@click="detailsOpen = true"
/>
<UButton
@@ -524,7 +600,7 @@ onBeforeRouteLeave(async () => {
:disabled="!saveEnabled"
@click="performSave"
>
Save
{{ t('Save') }}
</UButton>
<PublishControl
class="h-11 flex-1 justify-center"
+59 -29
View File
@@ -1,7 +1,9 @@
<script setup lang="ts">
import { t, formatNumber } from '@/i18n'
import { computed, onMounted, ref, watch } from 'vue'
import { useRouter } from 'vue-router'
import { useToast } from '@nuxt/ui/composables'
import { useLocalizedToast } from '@/i18n/presentation'
import DashboardShell from '@/components/DashboardShell.vue'
import ArticleStatusBadge from '@/components/ArticleStatusBadge.vue'
import NewArticleModal from '@/components/NewArticleModal.vue'
@@ -19,7 +21,7 @@ const RECENT_LIST_SIZE = 5
type LoadState = 'loading' | 'default' | 'empty' | 'error'
const router = useRouter()
const toast = useToast()
const toast = useLocalizedToast()
const session = useSessionStore()
const update = useUpdateStore()
const { listArticles } = useArticles()
@@ -32,12 +34,12 @@ let skeletonTimer: ReturnType<typeof setTimeout> | null = null
function greetingForHour(hour: number) {
if (hour < 12) {
return 'Good morning'
return t('Good morning')
}
if (hour < 18) {
return 'Good afternoon'
return t('Good afternoon')
}
return 'Good evening'
return t('Good evening')
}
const greeting = computed(() => {
@@ -48,8 +50,8 @@ const greeting = computed(() => {
const subtitle = computed(() =>
loadState.value === 'empty'
? 'Welcome to RenewCMS.'
: "Here's where things stand on your site.",
? t('Welcome to RenewCMS.')
: t("Here's where things stand on your site."),
)
const stats = computed(() => {
@@ -62,9 +64,24 @@ const stats = computed(() => {
})
const statTiles = computed(() => [
{ label: 'articles', value: stats.value.total },
{ label: 'online', value: stats.value.online },
{ label: 'drafts', value: stats.value.drafts },
{
get label() {
return t('articles')
},
value: stats.value.total,
},
{
get label() {
return t('online')
},
value: stats.value.online,
},
{
get label() {
return t('drafts')
},
value: stats.value.drafts,
},
])
const recentArticles = computed(() =>
@@ -106,13 +123,17 @@ function openNewArticleModal() {
function onArticleCreated(article: Article) {
router.push(`/article/${article.id}/edit`)
toast.add({
toast.add(() => ({
id: `article-created-${article.id}`,
color: 'success',
icon: 'i-lucide-circle-check',
title: 'Draft created',
description: `"${article.title}" is saved as a draft.`,
})
get title() {
return t('Draft created')
},
get description() {
return t('\u0022{0}\u0022 is saved as a draft.', [article.title])
},
}))
}
const readyBannerDismissed = ref(false)
@@ -154,7 +175,7 @@ onMounted(loadArticles)
variant="ghost"
class="size-11 justify-center text-highlighted sm:hidden"
:ui="{ leadingIcon: 'size-5' }"
aria-label="New article"
:aria-label="t('New article')"
@click="openNewArticleModal"
/>
</template>
@@ -172,7 +193,7 @@ onMounted(loadArticles)
class="hidden px-3 sm:flex"
@click="openNewArticleModal"
>
New article
{{ t('New article') }}
</UButton>
</div>
@@ -198,7 +219,7 @@ onMounted(loadArticles)
</template>
<template v-else>
<span class="text-2xl font-semibold text-highlighted">{{
tile.value
formatNumber(tile.value)
}}</span>
<span class="text-muted">{{ tile.label }}</span>
</template>
@@ -207,9 +228,11 @@ onMounted(loadArticles)
<section class="flex flex-col gap-2.5">
<div class="flex items-baseline justify-between">
<h2 class="text-lg font-semibold text-highlighted">Recently updated</h2>
<h2 class="text-lg font-semibold text-highlighted">
{{ t('Recently updated') }}
</h2>
<RouterLink to="/article" class="text-toned underline underline-offset-3">
All articles
{{ t('All articles') }}
</RouterLink>
</div>
<div
@@ -249,8 +272,10 @@ onMounted(loadArticles)
v-else-if="loadState === 'empty'"
variant="outline"
icon="i-lucide-file-text"
title="Write your first article"
description="It starts as a draft, so nothing appears on your site until you publish it."
:title="t('Write your first article')"
:description="
t('It starts as a draft, so nothing appears on your site until you publish it.')
"
:ui="{
root: 'gap-3 rounded-none border border-default px-6 py-12 ring-0 sm:px-6 sm:py-12 lg:px-6 lg:py-12',
header: 'max-w-none gap-3',
@@ -266,7 +291,7 @@ onMounted(loadArticles)
class="h-11 px-4 lg:h-8"
@click="openNewArticleModal"
>
New article
{{ t('New article') }}
</UButton>
</template>
</UEmpty>
@@ -279,8 +304,8 @@ onMounted(loadArticles)
orientation="vertical"
:close="false"
icon="i-lucide-circle-x"
title="Couldn't load your articles"
description="Check your connection and try again."
:title="t('Couldn\'t load your articles')"
:description="t('Check your connection and try again.')"
:ui="{ actions: 'mt-2' }"
>
<template #actions>
@@ -292,7 +317,7 @@ onMounted(loadArticles)
:ui="{ leadingIcon: 'size-3.5' }"
@click="loadArticles"
>
Try again
{{ t('Try again') }}
</UButton>
</template>
</UAlert>
@@ -304,16 +329,21 @@ onMounted(loadArticles)
<UIcon name="i-lucide-plug" class="size-4.5" />
</div>
<div class="flex flex-1 flex-col gap-1">
<h2 class="text-base/5 font-semibold text-highlighted">Connect your website</h2>
<h2 class="text-base/5 font-semibold text-highlighted">
{{ t('Connect your website') }}
</h2>
<p class="text-pretty text-muted">
Your site reads published articles from RenewCMS. Share this guide with
whoever builds it.
{{
t(
'Your site reads published articles from RenewCMS. Share this guide with whoever builds it.',
)
}}
</p>
<RouterLink
to="/integration"
class="mt-1 self-start font-medium text-highlighted underline underline-offset-3"
>
Open the guide
{{ t('Open the guide') }}
</RouterLink>
</div>
</div>
+51 -18
View File
@@ -1,4 +1,6 @@
<script setup lang="ts">
import { t } from '@/i18n'
import { computed } from 'vue'
import DashboardShell from '@/components/DashboardShell.vue'
import EndpointCard from '@/components/EndpointCard.vue'
@@ -8,8 +10,10 @@ const origin = computed(() => window.location.origin)
const { copied, copy } = useCopyToClipboard()
const copyButtonLabel = computed(() => (copied.value ? 'Copied' : 'Copy'))
const copyButtonIcon = computed(() => (copied.value ? 'i-lucide-check' : 'i-lucide-copy'))
const copyButtonLabel = computed(() => (copied.value ? t('Copied') : t('Copy')))
const copyButtonIcon = computed(() =>
copied.value ? 'i-lucide-check' : 'i-lucide-copy',
)
function copyBaseUrl() {
copy(origin.value)
@@ -39,7 +43,9 @@ const endpoints = computed(() => [
{
method: 'GET' as const,
path: '/v1/article/',
description: 'Lists every online article.',
get description() {
return t('Lists every online article.')
},
examples: {
fetch: `const res = await fetch('${origin.value}/v1/article/')\nconst articles = await res.json()`,
curl: `curl ${origin.value}/v1/article/`,
@@ -49,7 +55,11 @@ const endpoints = computed(() => [
{
method: 'GET' as const,
path: '/v1/article/{id}',
description: 'Returns one online article. Drafts and unknown ids both return 404.',
get description() {
return t(
'Returns one online article. Drafts and unknown ids both return 404.',
)
},
examples: {
fetch: `const res = await fetch('${origin.value}/v1/article/12')\nif (res.status === 404) return notFound()\nconst article = await res.json()`,
curl: `curl ${origin.value}/v1/article/12`,
@@ -58,29 +68,47 @@ const endpoints = computed(() => [
},
])
const notes = [
'Anonymous callers only ever get online articles. Drafts stay private until you publish them.',
'Images in body use paths like /static/uploadedImages/<uuid>.png. Prefix them with the base URL before rendering.',
"Browsers can only call the API from origins listed in the server's CORS setting. Ask whoever runs the server to add your site's address.",
]
const notes = computed(() => [
t(
'Anonymous callers only ever get online articles. Drafts stay private until you publish them.',
),
t(
'Images in body use paths like /static/uploadedImages/<uuid>.png. Prefix them with the base URL before rendering.',
),
t(
"Browsers can only call the API from origins listed in the server's CORS setting. Ask whoever runs the server to add your site's address.",
),
])
</script>
<template>
<DashboardShell mobile-title="Integration">
<DashboardShell :mobile-title="t('Integration')">
<div class="flex max-w-199 flex-col gap-6">
<div class="flex flex-col gap-1">
<h1 class="text-xl font-semibold text-highlighted">Connect your website</h1>
<p class="text-muted">A guide for the developer who builds your public site.</p>
<h1 class="text-xl font-semibold text-highlighted">
{{ t('Connect your website') }}
</h1>
<p class="text-muted">
{{
t(
'A guide for the developer who builds your public site.',
)
}}
</p>
</div>
<p class="text-base text-pretty text-toned">
RenewCMS is headless: it stores and publishes your articles but doesn't show them to
visitors. Your website fetches published articles from the two addresses below and
displays them in its own design.
{{
t(
"RenewCMS is headless: it stores and publishes your articles but doesn't show them to visitors. Your website fetches published articles from the two addresses below and displays them in its own design.",
)
}}
</p>
<div class="flex flex-col gap-1.5">
<span class="font-medium text-highlighted">Base URL</span>
<span class="font-medium text-highlighted">{{
t('Base URL')
}}</span>
<div class="flex border border-accented bg-default">
<span
class="h-10 min-w-0 flex-1 truncate px-3 font-mono text-sm leading-10 text-default"
@@ -112,8 +140,13 @@ const notes = [
<div class="flex flex-col gap-2.5">
<div v-for="note in notes" :key="note" class="flex gap-2.5">
<UIcon name="i-lucide-info" class="mt-0.75 size-4 shrink-0 text-info" />
<span class="text-base text-pretty text-toned">{{ note }}</span>
<UIcon
name="i-lucide-info"
class="mt-0.75 size-4 shrink-0 text-info"
/>
<span class="text-base text-pretty text-toned">{{
note
}}</span>
</div>
</div>
</div>
+60 -57
View File
@@ -1,4 +1,6 @@
<script setup lang="ts">
import { t, translate } from '@/i18n'
import { computed, nextTick, onMounted, ref } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import AuthShell from '@/components/AuthShell.vue'
@@ -16,37 +18,58 @@ interface AlertState {
const wrongCredentialsAlert: AlertState = {
tone: 'error',
icon: 'i-lucide-circle-x',
title: "That username and password don't match",
description: 'Check both and try again. Passwords are case-sensitive.',
get title() {
return t("That username and password don't match")
},
get description() {
return t('Check both and try again. Passwords are case-sensitive.')
},
}
const tooManyAttemptsAlert: AlertState = {
tone: 'info',
icon: 'i-lucide-timer',
title: 'Too many attempts',
description: 'For your security, wait a moment before trying again.',
get title() {
return t('Too many attempts')
},
get description() {
return t('For your security, wait a moment before trying again.')
},
}
const serverErrorAlert: AlertState = {
tone: 'error',
icon: 'i-lucide-circle-x',
title: "Couldn't reach RenewCMS",
description: 'Check your connection and try again.',
get title() {
return t("Couldn't reach RenewCMS")
},
get description() {
return t('Check your connection and try again.')
},
}
const sessionExpiredAlert: AlertState = {
tone: 'info',
icon: 'i-lucide-clock',
title: 'Your session expired',
description:
'Log in again to pick up where you left off. Unsaved changes are kept on this device.',
get title() {
return t('Your session expired')
},
get description() {
return t(
'Log in again to pick up where you left off. Unsaved changes are kept on this device.',
)
},
}
const passwordChangedAlert: AlertState = {
tone: 'success',
icon: 'i-lucide-circle-check',
title: 'Password changed',
description: 'Log in with your new password.',
get title() {
return t('Password changed')
},
get description() {
return t('Log in with your new password.')
},
}
const route = useRoute()
@@ -63,8 +86,7 @@ const passwordError = ref('')
const submitting = ref(false)
const passwordShown = ref(false)
const initialReason =
typeof route.query.reason === 'string' ? route.query.reason : null
const initialReason = typeof route.query.reason === 'string' ? route.query.reason : null
const alert = ref<AlertState | null>(
initialReason === 'expired'
@@ -74,23 +96,19 @@ const alert = ref<AlertState | null>(
: null,
)
const alertRole = computed(() =>
alert.value?.tone === 'error' ? 'alert' : 'status',
)
const alertRole = computed(() => (alert.value?.tone === 'error' ? 'alert' : 'status'))
const submitLabel = computed(() => {
if (submitting.value) {
return 'Logging in…'
return t('Logging in…')
}
if (retryAfter.active.value) {
return `Try again in ${retryAfter.formatted.value}`
return t('Try again in {0}', [retryAfter.formatted.value])
}
return 'Log in'
return t('Log in')
})
const submitDisabled = computed(
() => submitting.value || retryAfter.active.value,
)
const submitDisabled = computed(() => submitting.value || retryAfter.active.value)
async function focusField(selector: string) {
await nextTick()
@@ -103,7 +121,7 @@ function focusPassword() {
}
function focusUsername() {
focusField('input[placeholder="Your username"]')
focusField('input[autocomplete="username"]')
}
function focusFirstInvalid() {
@@ -117,18 +135,15 @@ function focusFirstInvalid() {
}
function validate() {
usernameError.value =
username.value.trim().length < 3 ? 'Use at least 3 characters.' : ''
passwordError.value =
password.value.length === 0 ? 'Enter your password.' : ''
usernameError.value = username.value.trim().length < 3 ? 'Use at least 3 characters.' : ''
passwordError.value = password.value.length === 0 ? 'Enter your password.' : ''
return !usernameError.value && !passwordError.value
}
async function afterLoginSuccess() {
await session.retry()
const redirect =
typeof route.query.redirect === 'string' ? route.query.redirect : null
const redirect = typeof route.query.redirect === 'string' ? route.query.redirect : null
if (redirect) {
await router.push(redirect)
return
@@ -193,9 +208,7 @@ onMounted(async () => {
}
try {
const status = await api.request<{ open: boolean }>(
'/v1/auth/registration',
)
const status = await api.request<{ open: boolean }>('/v1/auth/registration')
registrationOpen.value = status.open
} catch {
registrationOpen.value = false
@@ -205,8 +218,8 @@ onMounted(async () => {
<template>
<AuthShell
title="Log in"
description="Welcome back. Log in to manage your articles."
:title="t('Log in')"
:description="t('Welcome back. Log in to manage your articles.')"
>
<template v-if="alert" #notice>
<UAlert
@@ -221,15 +234,11 @@ onMounted(async () => {
</template>
<template #default="{ controlSize, controlClass, inputClass }">
<UForm
:state="{ username, password }"
class="flex flex-col gap-4.5"
@submit="onSubmit"
>
<UForm :state="{ username, password }" class="flex flex-col gap-4.5" @submit="onSubmit">
<UFormField
label="Username"
:label="t('Username')"
name="username"
:error="usernameError || undefined"
:error="translate(usernameError) || undefined"
:size="controlSize"
>
<template v-if="usernameError" #error="{ error }">
@@ -238,12 +247,12 @@ onMounted(async () => {
name="i-lucide-circle-alert"
class="size-3.5 shrink-0 text-error"
/>
{{ error }}
{{ t(error) }}
</span>
</template>
<UInput
v-model="username"
placeholder="Your username"
:placeholder="t('Your username')"
autocomplete="username"
:disabled="submitDisabled"
variant="outline"
@@ -256,9 +265,9 @@ onMounted(async () => {
</UFormField>
<UFormField
label="Password"
:label="t('Password')"
name="password"
:error="passwordError || undefined"
:error="translate(passwordError) || undefined"
:size="controlSize"
>
<template #hint>
@@ -266,7 +275,7 @@ onMounted(async () => {
to="/register/reset/request"
class="text-toned text-sm underline underline-offset-[3px]"
>
Forgot password?
{{ t('Forgot password?') }}
</RouterLink>
</template>
<template v-if="passwordError" #error="{ error }">
@@ -275,7 +284,7 @@ onMounted(async () => {
name="i-lucide-circle-alert"
class="size-3.5 shrink-0 text-error"
/>
{{ error }}
{{ t(error) }}
</span>
</template>
<UInput
@@ -295,20 +304,14 @@ onMounted(async () => {
>
<template #trailing>
<UButton
:icon="
passwordShown
? 'i-lucide-eye-off'
: 'i-lucide-eye'
"
:icon="passwordShown ? 'i-lucide-eye-off' : 'i-lucide-eye'"
variant="ghost"
color="neutral"
size="sm"
square
class="size-8 justify-center text-toned"
:aria-label="
passwordShown
? 'Hide password'
: 'Show password'
passwordShown ? t('Hide password') : t('Show password')
"
:aria-pressed="passwordShown"
@click="passwordShown = !passwordShown"
@@ -332,12 +335,12 @@ onMounted(async () => {
<template v-if="registrationOpen" #footer>
<span class="flex flex-wrap items-center gap-1.5">
First time here?
{{ t('First time here?') }}
<RouterLink
to="/register"
class="font-medium text-highlighted underline underline-offset-3"
>
Create the owner account
{{ t('Create the owner account') }}
</RouterLink>
</span>
</template>
+122 -67
View File
@@ -1,8 +1,10 @@
<script setup lang="ts">
import { t } from '@/i18n'
import { computed, nextTick, onBeforeUnmount, ref, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { useEventListener, useMediaQuery, useOnline } from '@vueuse/core'
import { useToast } from '@nuxt/ui/composables'
import { useLocalizedToast } from '@/i18n/presentation'
import BulkDeleteMediaDialog from '@/components/BulkDeleteMediaDialog.vue'
import DashboardShell from '@/components/DashboardShell.vue'
import DeleteMediaDialog from '@/components/DeleteMediaDialog.vue'
@@ -68,7 +70,7 @@ const OFFLINE_COPY =
const route = useRoute()
const router = useRouter()
const toast = useToast()
const toast = useLocalizedToast()
const media = useMedia()
const selection = useMediaSelection(media.items)
const upload = useMediaUpload({ onAdded: refreshInBackground })
@@ -90,7 +92,8 @@ const bulkDeleteOpen = ref(false)
const bulkTargets = ref<MediaItem[]>([])
const mergeTarget = ref<{ group: DuplicateGroup; keep: MediaItem } | null>(null)
const dragDepth = ref(0)
const announcement = ref('')
const announcementSource = ref<() => string>(() => '')
const announcement = computed(() => announcementSource.value())
const usage = ref<MediaUsageItem[]>([])
const copies = ref<MediaItem[]>([])
let opener: HTMLElement | null = null
@@ -127,13 +130,16 @@ const emptyVariant = computed(() =>
isDuplicates.value && controls.value.q === '' ? 'no-duplicates' : 'no-results',
)
const filterLabel = computed(
() => ({ all: 'All', unused: 'Unused', duplicates: 'Duplicates' })[controls.value.filter],
() =>
({ all: t('All'), unused: t('Unused'), duplicates: t('Duplicates') })[
controls.value.filter
],
)
const showList = computed(() => ready.value && !nothingToShow.value && !emptyLibrary.value)
const dragging = computed(() => dragDepth.value > 0)
const uploadBlocked = computed(() => offline.value || upload.limited.value)
const uploadLabel = computed(() =>
upload.limited.value ? `Try again in ${upload.countdown.value}` : 'Upload',
upload.limited.value ? t('Try again in {0}', [upload.countdown.value]) : t('Upload'),
)
const uploadIcon = computed(() => (upload.limited.value ? 'i-lucide-timer' : 'i-lucide-upload'))
const dialogOpen = computed(
@@ -158,7 +164,9 @@ const gridSelected = computed(() => selection.selectedIds.value)
const showBulkBar = computed(() => hasSelection.value && !emptyLibrary.value)
watch(showBulkBar, (visible) => (toastsAboveBulkBar.value = visible), { immediate: true })
watch(showBulkBar, (visible) => (toastsAboveBulkBar.value = visible), {
immediate: true,
})
onBeforeUnmount(() => (toastsAboveBulkBar.value = false))
function canonicalizeUrl() {
@@ -182,8 +190,8 @@ function clearFilters() {
updateControls({ q: '', filter: 'all' })
}
function announce(text: string) {
announcement.value = text
function announce(source: () => string) {
announcementSource.value = source
}
canonicalizeUrl()
@@ -210,7 +218,11 @@ watch(
(next, previous) => {
const settledNow = next.phase === 'settled' && previous?.phase === 'working'
if (settledNow && upload.rows.value.length > 0) {
announce(next.summary ?? `${next.title}. ${next.sub}`)
announce(
() =>
upload.header.value.summary ??
`${upload.header.value.title}. ${upload.header.value.sub}`,
)
}
},
)
@@ -339,13 +351,17 @@ function removeAndRefocus(item: MediaItem) {
}
function notFoundToast(id: number) {
toast.add({
toast.add(() => ({
id: `media-not-found-${id}`,
color: 'info',
icon: 'i-lucide-info',
title: 'That image no longer exists',
description: 'It was deleted somewhere else, so we removed it from the library.',
})
get title() {
return t('That image no longer exists')
},
get description() {
return t('It was deleted somewhere else, so we removed it from the library.')
},
}))
}
function handleGone(item: MediaItem) {
@@ -363,14 +379,18 @@ async function copyUrl() {
} catch {
return
}
toast.add({
toast.add(() => ({
id: `media-url-copied-${item.id}`,
color: 'success',
icon: 'i-lucide-circle-check',
title: 'URL copied',
description: 'Paste it wherever you need the image address.',
get title() {
return t('URL copied')
},
get description() {
return t('Paste it wherever you need the image address.')
},
duration: TOAST_DURATION_MS,
})
}))
}
async function renameAction(stem: string) {
@@ -380,14 +400,18 @@ async function renameAction(stem: string) {
}
try {
const renamed = await media.renameItem(item.id, stem)
toast.add({
toast.add(() => ({
id: `media-renamed-${item.id}`,
color: 'success',
icon: 'i-lucide-circle-check',
title: 'Image renamed',
description: `It's now "${renamed.filename}". The URL is the same.`,
get title() {
return t('Image renamed')
},
get description() {
return t("It's now \u0022{0}\u0022. The URL is the same.", [renamed.filename])
},
duration: TOAST_DURATION_MS,
})
}))
} catch (error) {
const failure = classifyMediaError(error)
if (failure === 'handled') {
@@ -397,7 +421,7 @@ async function renameAction(stem: string) {
handleGone(item)
return
}
throw new Error(RENAME_FAILED_COPY)
throw new Error(t(RENAME_FAILED_COPY))
}
}
@@ -418,32 +442,40 @@ async function deleteAction() {
handleGone(item)
return
}
throw new Error(DELETE_FAILED_COPY)
throw new Error(t(DELETE_FAILED_COPY))
}
closeEverything()
focusAfterRemoval(index)
toast.add({
toast.add(() => ({
id: `media-deleted-${item.id}`,
color: 'success',
icon: 'i-lucide-circle-check',
title: 'Image deleted',
description: `"${item.filename}" is gone from your library.`,
get title() {
return t('Image deleted')
},
get description() {
return t('\u0022{0}\u0022 is gone from your library.', [item.filename])
},
duration: TOAST_DURATION_MS,
})
}))
refreshInBackground()
}
function imageCount(total: number) {
return `${total} ${total === 1 ? 'image' : 'images'}`
return t(total === 1 ? '{0} image' : '{0} images', [total])
}
function announceSelection(filename: string, selected: boolean) {
if (!selected && selection.count.value === 0) {
announce(SELECTION_CLEARED)
announce(() => t(SELECTION_CLEARED))
return
}
announce(
`${filename} ${selected ? 'selected' : 'unselected'}, ${selection.count.value} selected`,
announce(() =>
t('{0} {1}, {2} selected', [
filename,
selected ? t('selected') : t('unselected'),
selection.count.value,
]),
)
}
@@ -459,7 +491,9 @@ function toggleSelected(id: number) {
function toggleAllSelected() {
selection.toggleAll()
announce(hasSelection.value ? `${selection.count.value} selected` : SELECTION_CLEARED)
announce(() =>
hasSelection.value ? t('{0} selected', [selection.count.value]) : t(SELECTION_CLEARED),
)
}
function clearSelection() {
@@ -467,7 +501,7 @@ function clearSelection() {
return
}
selection.clear()
announce(SELECTION_CLEARED)
announce(() => t(SELECTION_CLEARED))
}
function selectExisting(item: MediaItem) {
@@ -531,7 +565,7 @@ function onFilesChosen(event: Event) {
}
function carriesFiles(event: DragEvent) {
return Array.from(event.dataTransfer?.types ?? []).includes('Files')
return Array.from(event.dataTransfer?.types ?? []).includes(t('Files'))
}
useEventListener(window, 'dragenter', (event: DragEvent) => {
@@ -576,20 +610,31 @@ function openBulkDelete() {
function bulkFailedDescription(failed: MediaItem[]) {
const names = failed.map((item) => item.filename)
const deleted = bulkDeletedTotal === 1 ? '1 image was' : `${bulkDeletedTotal} images were`
return `${deleted} deleted. ${joinNames(names)} ${names.length === 1 ? 'is' : 'are'} still in your library. Try again.`
const deleted =
bulkDeletedTotal === 1 ? t('1 image was') : t('{0} images were', [bulkDeletedTotal])
return t('{0} deleted. {1} {2} still in your library. Try again.', [
deleted,
joinNames(names),
t(names.length === 1 ? 'is' : 'are'),
])
}
function bulkFailedToast(failed: MediaItem[]) {
const content = {
color: 'error' as const,
icon: 'i-lucide-circle-x',
title: `Couldn't delete ${imageCount(failed.length)}`,
description: bulkFailedDescription(failed),
get title() {
return t("Couldn't delete {0}", [imageCount(failed.length)])
},
get description() {
return bulkFailedDescription(failed)
},
duration: PERSISTENT_TOAST,
actions: [
{
label: 'Retry',
get label() {
return t('Retry')
},
icon: 'i-lucide-rotate-cw',
color: 'neutral' as const,
variant: 'outline' as const,
@@ -600,11 +645,11 @@ function bulkFailedToast(failed: MediaItem[]) {
],
}
if (failureToastShown) {
toast.update(BULK_FAILED_TOAST_ID, content)
toast.update(BULK_FAILED_TOAST_ID, () => content)
return
}
failureToastShown = true
toast.add({ id: BULK_FAILED_TOAST_ID, ...content })
toast.add(() => ({ id: BULK_FAILED_TOAST_ID, ...content }))
}
function dismissBulkFailedToast() {
@@ -623,15 +668,19 @@ function finishBulkDelete(result: BulkDeleteResult, firstIndex: number) {
} else {
dismissBulkFailedToast()
selection.clear()
announce(`${imageCount(bulkDeletedTotal)} deleted`)
toast.add({
announce(() => t('{0} deleted', [imageCount(bulkDeletedTotal)]))
toast.add(() => ({
id: 'media-bulk-deleted',
color: 'success',
icon: 'i-lucide-circle-check',
title: `${imageCount(bulkDeletedTotal)} deleted`,
description: "They're gone from your library.",
get title() {
return t('{0} deleted', [imageCount(bulkDeletedTotal)])
},
get description() {
return t("They're gone from your library.")
},
duration: TOAST_DURATION_MS,
})
}))
focusAfterRemoval(firstIndex)
}
refreshInBackground()
@@ -652,7 +701,7 @@ async function bulkDeleteAction() {
return false
}
if (result.deleted.length === 0) {
throw new Error(BULK_DELETE_FAILED_COPY)
throw new Error(t(BULK_DELETE_FAILED_COPY))
}
bulkDeleteOpen.value = false
finishBulkDelete(result, firstIndex)
@@ -672,9 +721,10 @@ function openMerge(group: DuplicateGroup, keep: MediaItem) {
}
function mergeText(merged: number, articlesUpdated: number) {
const copiesText = merged === 1 ? '1 copy' : `${merged} copies`
const articlesText = articlesUpdated === 1 ? '1 article' : `${articlesUpdated} articles`
return `${copiesText} merged. ${articlesText} updated.`
const copiesText = merged === 1 ? t('1 copy') : t('{0} copies', [merged])
const articlesText =
articlesUpdated === 1 ? t('1 article') : t('{0} articles', [articlesUpdated])
return t('{0} merged. {1} updated.', [copiesText, articlesText])
}
async function mergeAction() {
@@ -688,16 +738,19 @@ async function mergeAction() {
const result = await media.mergeCopies(keep.id, mergeIds)
mergeTarget.value = null
media.removeLocalMany(group.copies.map((copy) => copy.id))
const text = mergeText(result.merged, result.articles_updated)
announce(text)
toast.add({
announce(() => mergeText(result.merged, result.articles_updated))
toast.add(() => ({
id: `media-merged-${keep.id}`,
color: 'success',
icon: 'i-lucide-circle-check',
title: 'Copies merged',
description: text,
get title() {
return t('Copies merged')
},
get description() {
return mergeText(result.merged, result.articles_updated)
},
duration: TOAST_DURATION_MS,
})
}))
focusElement('[data-testid="media-heading"]')
refreshInBackground()
} catch (error) {
@@ -713,9 +766,9 @@ async function mergeAction() {
}
if (error instanceof ApiError && error.status === HTTP_CONFLICT) {
refreshInBackground()
throw new Error(MERGE_CONFLICT_COPY)
throw new Error(t(MERGE_CONFLICT_COPY))
}
throw new Error(MERGE_FAILED_COPY)
throw new Error(t(MERGE_FAILED_COPY))
}
}
</script>
@@ -723,7 +776,7 @@ async function mergeAction() {
<template>
<DashboardShell
compact-padding
:offline-message="OFFLINE_COPY"
:offline-message="t(OFFLINE_COPY)"
offline-icon="i-lucide-wifi-off"
>
<div class="flex flex-col gap-4">
@@ -735,10 +788,10 @@ async function mergeAction() {
tabindex="-1"
class="text-xl font-semibold text-highlighted"
>
Media
{{ t('Media') }}
</h1>
<p class="text-muted">
Every image you've uploaded, ready to use in any article.
{{ t("Every image you've uploaded, ready to use in any article.") }}
</p>
</div>
<UButton
@@ -770,7 +823,9 @@ async function mergeAction() {
:limited="upload.limited.value"
@action="openChooser"
/>
<p class="self-center text-xs text-muted">PNG, JPEG, WEBP, SVG · max 10 MB each</p>
<p class="self-center text-xs text-muted">
{{ t('PNG, JPEG, WEBP, SVG · max 10 MB each') }}
</p>
</template>
<template v-else>
@@ -781,8 +836,8 @@ async function mergeAction() {
variant="soft"
:close="false"
icon="i-lucide-timer"
:title="RATE_LIMITED_TITLE"
:description="RATE_LIMITED_TEXT"
:title="t(RATE_LIMITED_TITLE)"
:description="t(RATE_LIMITED_TEXT)"
/>
<UAlert
@@ -793,8 +848,8 @@ async function mergeAction() {
orientation="vertical"
:close="false"
icon="i-lucide-circle-x"
title="Couldn't load your images"
description="Check your connection and try again."
:title="t('Couldn\'t load your images')"
:description="t('Check your connection and try again.')"
:ui="{ actions: 'mt-2' }"
>
<template #actions>
@@ -806,7 +861,7 @@ async function mergeAction() {
:ui="{ leadingIcon: 'size-3.5' }"
@click="media.load(controls)"
>
Try again
{{ t('Try again') }}
</UButton>
</template>
</UAlert>
+5 -3
View File
@@ -1,4 +1,6 @@
<script setup lang="ts">
import { t } from '@/i18n'
import { useSessionStore } from '@/stores/session'
import DashboardShell from '@/components/DashboardShell.vue'
import AuthShell from '@/components/AuthShell.vue'
@@ -9,9 +11,9 @@ const session = useSessionStore()
<template>
<DashboardShell v-if="session.session">
<ErrorView kind="not-found" breadcrumb="Not found" />
<ErrorView kind="not-found" :breadcrumb="t('Not found')" />
</DashboardShell>
<AuthShell v-else title="Page not found">
<ErrorView kind="not-found" breadcrumb="Not found" />
<AuthShell v-else :title="t('Page not found')">
<ErrorView kind="not-found" :breadcrumb="t('Not found')" />
</AuthShell>
</template>
+53 -27
View File
@@ -1,7 +1,9 @@
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue'
import { t } from '@/i18n'
import { computed, onMounted, ref, watchEffect } from 'vue'
import { useRouter } from 'vue-router'
import { useOverlay, useToast } from '@nuxt/ui/composables'
import { useLocalizedOverlay, useLocalizedToast } from '@/i18n/presentation'
import AuthShell from '@/components/AuthShell.vue'
import ConfirmDialog from '@/components/ConfirmDialog.vue'
import ResultPanel from '@/components/ResultPanel.vue'
@@ -13,8 +15,8 @@ import { useSessionStore } from '@/stores/session'
const router = useRouter()
const api = useApi()
const session = useSessionStore()
const overlay = useOverlay()
const toast = useToast()
const overlay = useLocalizedOverlay()
const toast = useLocalizedToast()
const pendingEmail = ref<string | null>(null)
const loggingOut = ref(false)
@@ -23,13 +25,16 @@ const verified = ref(false)
onMounted(() => {
pendingEmail.value = sessionStorage.getItem('renewcms:pendingEmail')
document.title = 'Check your email · RenewCMS'
})
watchEffect(() => {
document.title = t('Check your email · RenewCMS')
})
const description = computed(() =>
pendingEmail.value
? `We sent a verification link to ${pendingEmail.value}. It expires in 2 hours.`
: 'We sent a verification link to the email you registered with. It expires in 2 hours.',
? t('We sent a verification link to {0}. It expires in 2 hours.', [pendingEmail.value])
: t('We sent a verification link to the email you registered with. It expires in 2 hours.'),
)
async function logOut() {
@@ -57,7 +62,7 @@ async function deleteRegistration() {
return
}
throw new Error(
"Couldn't cancel registration, so it's still pending. Try again in a moment.",
t("Couldn't cancel registration, so it's still pending. Try again in a moment."),
)
}
}
@@ -67,12 +72,23 @@ async function cancelRegistration() {
const confirmed = await overlay
.create(ConfirmDialog, {
props: {
title: 'Cancel your registration?',
description:
'Your account will be deleted. You can register again with the same or a different email.',
confirmLabel: 'Cancel registration',
cancelLabel: 'Keep my account',
pendingLabel: 'Cancelling…',
get title() {
return t('Cancel your registration?')
},
get description() {
return t(
'Your account will be deleted. You can register again with the same or a different email.',
)
},
get confirmLabel() {
return t('Cancel registration')
},
get cancelLabel() {
return t('Keep my account')
},
get pendingLabel() {
return t('Cancelling…')
},
tone: 'danger',
action: deleteRegistration,
},
@@ -85,18 +101,22 @@ async function cancelRegistration() {
if (cancelOutcome.value === 'cancelled') {
await router.push('/register')
toast.add(registrationCancelledToast)
toast.add(() => registrationCancelledToast)
return
}
if (cancelOutcome.value === 'alreadyVerified') {
verified.value = true
toast.add({
toast.add(() => ({
color: 'info',
icon: 'i-lucide-info',
title: 'Your account is already verified',
description: "There's nothing to cancel.",
})
get title() {
return t('Your account is already verified')
},
get description() {
return t("There's nothing to cancel.")
},
}))
await wait(VERIFIED_REDIRECT_DELAY_MS)
await router.push('/home')
}
@@ -104,14 +124,18 @@ async function cancelRegistration() {
const actions = computed(() => [
{
label: 'Log out',
get label() {
return t('Log out')
},
color: 'neutral' as const,
variant: 'outline' as const,
disabled: loggingOut.value,
onClick: logOut,
},
{
label: 'Cancel registration',
get label() {
return t('Cancel registration')
},
color: 'neutral' as const,
variant: 'outline' as const,
disabled: loggingOut.value,
@@ -126,22 +150,24 @@ const actions = computed(() => [
v-if="verified"
tone="success"
icon="i-lucide-shield-check"
title="You're verified"
description="Taking you to your dashboard…"
:title="t('You\'re verified')"
:description="t('Taking you to your dashboard…')"
:actions="[]"
/>
<ResultPanel
v-else
tone="success"
icon="i-lucide-mail-check"
title="Check your email"
:title="t('Check your email')"
:description="description"
:actions="actions"
>
<p class="text-sm text-pretty text-muted">
Opened it on another device? That works too; you'll log in there
first. Can't find it? Check your spam folder. If the link has
expired, cancel your registration and start again.
{{
t(
"Opened it on another device? That works too; you'll log in there first. Can't find it? Check your spam folder. If the link has expired, cancel your registration and start again.",
)
}}
</p>
</ResultPanel>
</AuthShell>
+90 -42
View File
@@ -1,7 +1,9 @@
<script setup lang="ts">
import { t } from '@/i18n'
import { computed, nextTick, ref, watch } from 'vue'
import { onBeforeRouteLeave } from 'vue-router'
import { useOverlay, useToast } from '@nuxt/ui/composables'
import { useLocalizedOverlay, useLocalizedToast } from '@/i18n/presentation'
import CharacterCounter from '@/components/CharacterCounter.vue'
import ConfirmDialog from '@/components/ConfirmDialog.vue'
import DashboardShell from '@/components/DashboardShell.vue'
@@ -26,8 +28,8 @@ const SKELETON_CARDS = [
type ResetState = 'idle' | 'sending' | 'sent' | 'limited' | 'error'
const session = useSessionStore()
const toast = useToast()
const overlay = useOverlay()
const toast = useLocalizedToast()
const overlay = useLocalizedOverlay()
const { request } = useApi()
const retryAfter = useRetryAfter()
@@ -81,34 +83,46 @@ const showUnsaved = computed(() => dirty.value && !saving.value)
const statusText = computed(() => {
if (showUnsaved.value) {
return 'Unsaved changes'
return t('Unsaved changes')
}
return savedName.value
? `You appear as “${savedName.value}”.`
: `You appear as “${username.value}”, your username.`
? t('You appear as “{0}”.', [savedName.value])
: t('You appear as “{0}”, your username.', [username.value])
})
const inputUi = { base: 'h-11 text-lg lg:h-10 lg:text-base' }
const readOnlyUi = { base: 'h-11 text-lg disabled:text-toned lg:h-10 lg:text-base' }
const readOnlyUi = {
base: 'h-11 text-lg disabled:text-toned lg:h-10 lg:text-base',
}
const readOnlyLockUi = { trailingIcon: 'size-3.5 text-dimmed' }
const resetNotes = {
sent: {
color: 'success',
icon: 'i-lucide-mail-check',
title: 'Reset link sent',
get title() {
return t('Reset link sent')
},
},
limited: {
color: 'warning',
icon: 'i-lucide-clock-alert',
title: 'Too many requests',
description: "You've asked for several links recently. Wait a few minutes, then try again.",
get title() {
return t('Too many requests')
},
get description() {
return t("You've asked for several links recently. Wait a few minutes, then try again.")
},
},
error: {
color: 'error',
icon: 'i-lucide-circle-x',
title: "Couldn't send the link",
description: 'Check your connection and try again.',
get title() {
return t("Couldn't send the link")
},
get description() {
return t('Check your connection and try again.')
},
},
} as const
@@ -116,7 +130,9 @@ const resetNote = computed(() => {
if (resetState.value === 'sent') {
return {
...resetNotes.sent,
description: `Check ${email.value}. The link works once, within 1 hour.`,
get description() {
return t('Check {0}. The link works once, within 1 hour.', [email.value])
},
}
}
if (resetState.value === 'limited' || resetState.value === 'error') {
@@ -127,9 +143,9 @@ const resetNote = computed(() => {
const resetButtonLabel = computed(() => {
if (resetState.value === 'sending') {
return 'Sending…'
return t('Sending…')
}
return resetState.value === 'sent' ? 'Send again' : 'Send reset link'
return resetState.value === 'sent' ? t('Send again') : t('Send reset link')
})
const resetBusy = computed(() => resetState.value === 'sending')
@@ -140,15 +156,19 @@ function focusField() {
}
function announceSaved(displayName: string) {
toast.add({
toast.add(() => ({
id: 'display-name-saved',
color: 'success',
icon: 'i-lucide-circle-check',
title: displayName ? 'Display name saved' : 'Display name removed',
description: displayName
? `You now appear as ${displayName} across RenewCMS and in emails.`
: `You now appear as ${username.value}, your username.`,
})
get title() {
return displayName ? t('Display name saved') : t('Display name removed')
},
get description() {
return displayName
? t('You now appear as {0} across RenewCMS and in emails.', [displayName])
: t('You now appear as {0}, your username.', [username.value])
},
}))
}
function isHandledGlobally(error: unknown) {
@@ -220,11 +240,21 @@ onBeforeRouteLeave(async () => {
const keepEditing = await overlay
.create(ConfirmDialog, {
props: {
title: 'Leave without saving?',
description: "Your display name change hasn't been saved.",
confirmLabel: 'Keep editing',
cancelLabel: 'Discard changes',
pendingLabel: 'Keep editing',
get title() {
return t('Leave without saving?')
},
get description() {
return t("Your display name change hasn't been saved.")
},
get confirmLabel() {
return t('Keep editing')
},
get cancelLabel() {
return t('Discard changes')
},
get pendingLabel() {
return t('Keep editing')
},
tone: 'neutral',
action: async () => {},
dismissResolvesTo: 'confirm',
@@ -239,8 +269,12 @@ onBeforeRouteLeave(async () => {
<DashboardShell render-while-checking>
<div class="flex max-w-164 flex-col gap-5">
<div class="flex flex-col gap-0.5">
<h1 class="text-xl font-semibold text-highlighted">Profile</h1>
<p class="text-muted">Your account on this RenewCMS workspace.</p>
<h1 class="text-xl font-semibold text-highlighted">
{{ t('Profile') }}
</h1>
<p class="text-muted">
{{ t('Your account on this RenewCMS workspace.') }}
</p>
</div>
<div v-if="!account" aria-busy="true" class="flex flex-col gap-4">
@@ -262,8 +296,12 @@ onBeforeRouteLeave(async () => {
<template v-else>
<UForm :state="{ displayName: field }" @submit="onSave">
<ProfileCard
title="Display name"
description="Shown instead of your username across the back-office and in the emails RenewCMS sends. Leave it empty to use your username."
:title="t('Display name')"
:description="
t(
'Shown instead of your username across the back-office and in the emails RenewCMS sends. Leave it empty to use your username.',
)
"
>
<UAlert
v-if="saveFailed"
@@ -271,19 +309,20 @@ onBeforeRouteLeave(async () => {
color="error"
variant="soft"
icon="i-lucide-circle-x"
:description="SAVE_FAILED_COPY"
:description="t(SAVE_FAILED_COPY)"
:close="false"
:ui="{ root: 'gap-2 px-3 py-2.5' }"
/>
<UFormField name="displayName" :error="showFieldError">
<template #label>
Display name <span class="font-normal text-muted">(optional)</span>
{{ t('Display name') }}
<span class="font-normal text-muted">{{ t('(optional)') }}</span>
</template>
<UInput
ref="inputRef"
v-model="field"
placeholder="e.g. Marta Alves"
:placeholder="t('e.g. Marta Alves')"
autocomplete="off"
:disabled="saving"
class="w-full"
@@ -297,7 +336,7 @@ onBeforeRouteLeave(async () => {
class="flex flex-1 items-center gap-1.5 text-xs/5 text-error"
>
<UIcon name="i-lucide-circle-alert" class="size-3.5 shrink-0" />
{{ DISPLAY_NAME_LIMIT_COPY }}
{{ t(DISPLAY_NAME_LIMIT_COPY) }}
</span>
<span v-else class="flex-1" />
<CharacterCounter
@@ -322,18 +361,22 @@ onBeforeRouteLeave(async () => {
:disabled="!saveEnabled && !saving"
class="h-11 shrink-0 px-3.5 lg:h-8"
>
{{ saving ? 'Saving…' : 'Save' }}
{{ saving ? t('Saving…') : t('Save') }}
</UButton>
</template>
</ProfileCard>
</UForm>
<ProfileCard
title="Account"
description="Set when your account was created. You log in with your username, even after adding a display name."
:title="t('Account')"
:description="
t(
'Set when your account was created. You log in with your username, even after adding a display name.',
)
"
>
<div class="grid gap-3.5 lg:grid-cols-[minmax(0,1fr)_minmax(0,1.4fr)]">
<UFormField label="Username">
<UFormField :label="t('Username')">
<UInput
:model-value="username"
disabled
@@ -342,7 +385,7 @@ onBeforeRouteLeave(async () => {
:ui="{ ...readOnlyUi, ...readOnlyLockUi }"
/>
</UFormField>
<UFormField label="Email">
<UFormField :label="t('Email')">
<UInput
:model-value="email"
disabled
@@ -355,11 +398,16 @@ onBeforeRouteLeave(async () => {
</ProfileCard>
<ProfileCard
title="Password"
:description="`To change it, we'll email a reset link to ${email}. The link works once, within 1 hour.`"
:title="t('Password')"
:description="
t(
'To change it, we\'ll email a reset link to {0}. The link works once, within 1 hour.',
[email],
)
"
>
<div class="flex flex-col gap-3 lg:flex-row lg:items-end">
<UFormField label="Password" class="min-w-0 flex-1">
<UFormField :label="t('Password')" class="min-w-0 flex-1">
<UInput
:model-value="PASSWORD_MASK"
disabled
+48 -48
View File
@@ -1,5 +1,7 @@
<script setup lang="ts">
import { computed, ref } from 'vue'
import { t, translate } from '@/i18n'
import { computed, ref, watchEffect } from 'vue'
import { useRouter } from 'vue-router'
import AuthShell from '@/components/AuthShell.vue'
import PasswordStrengthHint from '@/components/PasswordStrengthHint.vue'
@@ -22,33 +24,35 @@ const passwordTouched = ref(false)
const submitting = ref(false)
const passwordShown = ref(false)
const registrationClosed = ref(false)
watchEffect(() => {
if (registrationClosed.value) document.title = t('This site already has an owner · RenewCMS')
})
const serverErrorAlert = ref(false)
const usernameValid = computed(
() => username.value.length >= 3 && username.value.length <= 20,
)
const usernameValid = computed(() => username.value.length >= 3 && username.value.length <= 20)
const emailValid = computed(() => EMAIL_PATTERN.test(email.value))
const passwordValid = computed(() => password.value.length >= 8)
const usernameError = computed(() =>
usernameTouched.value && !usernameValid.value
? 'Use 3 to 20 characters.'
: '',
usernameTouched.value && !usernameValid.value ? t('Use 3 to 20 characters.') : '',
)
const emailError = computed(() =>
emailTouched.value && !emailValid.value
? 'Enter a full email address, like name@example.com.'
? t('Enter a full email address, like name@example.com.')
: '',
)
const passwordRules = computed(() => [
{ label: 'At least 8 characters', met: passwordValid.value },
{
get label() {
return t('At least 8 characters')
},
met: passwordValid.value,
},
])
const formValid = computed(
() => usernameValid.value && emailValid.value && passwordValid.value,
)
const formValid = computed(() => usernameValid.value && emailValid.value && passwordValid.value)
function touchAll() {
usernameTouched.value = true
@@ -84,7 +88,7 @@ async function onSubmit() {
} catch (error) {
if (error instanceof ApiError && error.status === 403) {
registrationClosed.value = true
document.title = 'This site already has an owner · RenewCMS'
document.title = t('This site already has an owner · RenewCMS')
return
}
serverErrorAlert.value = true
@@ -93,17 +97,24 @@ async function onSubmit() {
}
}
const pageTitle = computed(() =>
registrationClosed.value ? '' : 'Create the owner account',
)
const pageTitle = computed(() => (registrationClosed.value ? '' : t('Create the owner account')))
const pageDescription = computed(() =>
registrationClosed.value
? undefined
: "This creates the single admin account for this site. Once it's verified, registration closes.",
: t(
"This creates the single admin account for this site. Once it's verified, registration closes.",
),
)
const closedPanelActions = [{ label: 'Go to log in', onClick: goToLogin }]
const closedPanelActions = [
{
get label() {
return t('Go to log in')
},
onClick: goToLogin,
},
]
</script>
<template>
@@ -112,8 +123,8 @@ const closedPanelActions = [{ label: 'Go to log in', onClick: goToLogin }]
<UAlert
color="error"
icon="i-lucide-circle-x"
title="Couldn't create the account"
description="Something went wrong on the server. Try again in a moment."
:title="t('Couldn\'t create the account')"
:description="t('Something went wrong on the server. Try again in a moment.')"
variant="soft"
:close="false"
role="alert"
@@ -124,8 +135,8 @@ const closedPanelActions = [{ label: 'Go to log in', onClick: goToLogin }]
<ResultPanel
tone="info"
icon="i-lucide-user-x"
title="This site already has an owner"
description="Log in instead."
:title="t('This site already has an owner')"
:description="t('Log in instead.')"
:actions="closedPanelActions"
/>
</template>
@@ -137,9 +148,9 @@ const closedPanelActions = [{ label: 'Go to log in', onClick: goToLogin }]
@submit="onSubmit"
>
<UFormField
label="Username"
:label="t('Username')"
name="username"
:error="usernameError || undefined"
:error="translate(usernameError) || undefined"
:size="controlSize"
>
<template v-if="usernameError" #error="{ error }">
@@ -148,12 +159,11 @@ const closedPanelActions = [{ label: 'Go to log in', onClick: goToLogin }]
name="i-lucide-circle-alert"
class="size-3.5 shrink-0 text-error"
/>
{{ error }}
{{ t(error) }}
</span>
</template>
<template v-else #help>
Letters and numbers, 3 to 20 characters. You'll use it
to log in.
{{ t("Letters and numbers, 3 to 20 characters. You'll use it to log in.") }}
</template>
<template #hint>
<span
@@ -167,7 +177,7 @@ const closedPanelActions = [{ label: 'Go to log in', onClick: goToLogin }]
</template>
<UInput
v-model="username"
placeholder="e.g. marta"
:placeholder="t('e.g. marta')"
autocomplete="username"
:disabled="submitting"
variant="outline"
@@ -181,9 +191,9 @@ const closedPanelActions = [{ label: 'Go to log in', onClick: goToLogin }]
</UFormField>
<UFormField
label="Email"
:label="t('Email')"
name="email"
:error="emailError || undefined"
:error="translate(emailError) || undefined"
:size="controlSize"
>
<template v-if="emailError" #error="{ error }">
@@ -192,11 +202,11 @@ const closedPanelActions = [{ label: 'Go to log in', onClick: goToLogin }]
name="i-lucide-circle-alert"
class="size-3.5 shrink-0 text-error"
/>
{{ error }}
{{ t(error) }}
</span>
</template>
<template v-else #help>
We'll send a verification link here.
{{ t("We'll send a verification link here.") }}
</template>
<UInput
v-model="email"
@@ -214,11 +224,7 @@ const closedPanelActions = [{ label: 'Go to log in', onClick: goToLogin }]
/>
</UFormField>
<UFormField
label="Password"
name="password"
:size="controlSize"
>
<UFormField :label="t('Password')" name="password" :size="controlSize">
<template #help>
<PasswordStrengthHint
:rules="passwordRules"
@@ -242,20 +248,14 @@ const closedPanelActions = [{ label: 'Go to log in', onClick: goToLogin }]
>
<template #trailing>
<UButton
:icon="
passwordShown
? 'i-lucide-eye-off'
: 'i-lucide-eye'
"
:icon="passwordShown ? 'i-lucide-eye-off' : 'i-lucide-eye'"
variant="ghost"
color="neutral"
size="sm"
square
class="size-8 justify-center text-toned"
:aria-label="
passwordShown
? 'Hide password'
: 'Show password'
passwordShown ? t('Hide password') : t('Show password')
"
:aria-pressed="passwordShown"
@click="passwordShown = !passwordShown"
@@ -272,19 +272,19 @@ const closedPanelActions = [{ label: 'Go to log in', onClick: goToLogin }]
:loading="submitting"
:disabled="submitting"
>
{{ submitting ? 'Creating account…' : 'Create account' }}
{{ submitting ? t('Creating account…') : t('Create account') }}
</UButton>
</UForm>
</template>
<template v-if="!registrationClosed" #footer>
<span class="flex flex-wrap items-center gap-1.5">
Already set up?
{{ t('Already set up?') }}
<RouterLink
to="/login"
class="font-medium text-highlighted underline underline-offset-3"
>
Log in
{{ t('Log in') }}
</RouterLink>
</span>
</template>
+65 -23
View File
@@ -1,4 +1,6 @@
<script setup lang="ts">
import { t, translate } from '@/i18n'
import { computed, ref } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import AuthShell from '@/components/AuthShell.vue'
@@ -32,13 +34,25 @@ const passwordsMatch = computed(() => confirmPassword.value === password.value)
const showLengthError = computed(() => submitFailed.value && !passwordValid.value)
const showMatchError = computed(() => mismatchTouched.value && !passwordsMatch.value)
const passwordRules = computed(() => [{ label: 'At least 8 characters', met: passwordValid.value }])
const confirmPasswordRules = computed(() => [
{ label: 'Passwords match', met: passwordsMatch.value },
const passwordRules = computed(() => [
{
get label() {
return t('At least 8 characters')
},
met: passwordValid.value,
},
])
const confirmError = computed(() => (showMatchError.value ? "The passwords don't match." : ''))
const confirmPasswordRules = computed(() => [
{
get label() {
return t('Passwords match')
},
met: passwordsMatch.value,
},
])
const confirmError = computed(() => (showMatchError.value ? t("The passwords don't match.") : ''))
function goToRequest() {
router.push('/register/reset/request')
@@ -49,16 +63,30 @@ function goToLogin() {
}
const expiredActions = [
{ label: 'Request a new link', onClick: goToRequest },
{
label: 'Back to log in',
get label() {
return t('Request a new link')
},
onClick: goToRequest,
},
{
get label() {
return t('Back to log in')
},
color: 'neutral' as const,
variant: 'outline' as const,
onClick: goToLogin,
},
]
const missingParametersActions = [{ label: 'Request a new link', onClick: goToRequest }]
const missingParametersActions = [
{
get label() {
return t('Request a new link')
},
onClick: goToRequest,
},
]
const confirmDescription =
"Pick something you haven't used here before. You'll log in with it next."
@@ -102,15 +130,15 @@ async function onSubmit() {
<template>
<AuthShell
:title="expired || missingParameters ? '' : 'Choose a new password'"
:description="expired || missingParameters ? undefined : confirmDescription"
:title="expired || missingParameters ? '' : t('Choose a new password')"
:description="expired || missingParameters ? undefined : t(confirmDescription)"
>
<template v-if="serverErrorAlert" #notice>
<UAlert
color="error"
icon="i-lucide-circle-x"
title="Couldn't reach RenewCMS"
description="Check your connection and try again."
:title="t('Couldn\'t reach RenewCMS')"
:description="t('Check your connection and try again.')"
variant="soft"
:close="false"
role="alert"
@@ -121,8 +149,12 @@ async function onSubmit() {
<ResultPanel
tone="error"
icon="i-lucide-link-2-off"
title="This link is incomplete"
description="Part of the reset link is missing. Open it from your email again, or request a new one."
:title="t('This link is incomplete')"
:description="
t(
'Part of the reset link is missing. Open it from your email again, or request a new one.',
)
"
:actions="missingParametersActions"
/>
</template>
@@ -131,8 +163,12 @@ async function onSubmit() {
<ResultPanel
tone="error"
icon="i-lucide-link-2-off"
title="This reset link has expired"
description="Reset links work once, for 1 hour. Request a new one and use it within the hour."
:title="t('This reset link has expired')"
:description="
t(
'Reset links work once, for 1 hour. Request a new one and use it within the hour.',
)
"
:actions="expiredActions"
/>
</template>
@@ -144,7 +180,7 @@ async function onSubmit() {
@submit="onSubmit"
>
<UFormField
label="Email"
:label="t('Email')"
name="email"
help="From your reset link."
:size="controlSize"
@@ -163,7 +199,7 @@ async function onSubmit() {
/>
</UFormField>
<UFormField label="New password" name="password" :size="controlSize">
<UFormField :label="t('New password')" name="password" :size="controlSize">
<template #help>
<PasswordStrengthHint
:rules="passwordRules"
@@ -193,7 +229,9 @@ async function onSubmit() {
size="sm"
square
class="size-8 justify-center text-toned"
:aria-label="passwordShown ? 'Hide password' : 'Show password'"
:aria-label="
passwordShown ? t('Hide password') : t('Show password')
"
:aria-pressed="passwordShown"
@click="passwordShown = !passwordShown"
/>
@@ -201,7 +239,11 @@ async function onSubmit() {
</UInput>
</UFormField>
<UFormField label="Confirm new password" name="confirmPassword" :size="controlSize">
<UFormField
:label="t('Confirm new password')"
name="confirmPassword"
:size="controlSize"
>
<template #help>
<span
v-if="confirmError"
@@ -212,7 +254,7 @@ async function onSubmit() {
name="i-lucide-circle-alert"
class="size-3.5 shrink-0 text-error"
/>
{{ confirmError }}
{{ translate(confirmError) }}
</span>
<PasswordStrengthHint
:rules="confirmPasswordRules"
@@ -245,7 +287,7 @@ async function onSubmit() {
square
class="size-8 justify-center text-toned"
:aria-label="
confirmPasswordShown ? 'Hide password' : 'Show password'
confirmPasswordShown ? t('Hide password') : t('Show password')
"
:aria-pressed="confirmPasswordShown"
@click="confirmPasswordShown = !confirmPasswordShown"
@@ -262,7 +304,7 @@ async function onSubmit() {
:loading="submitting"
:disabled="submitting"
>
{{ submitting ? 'Changing password…' : 'Change password' }}
{{ submitting ? t('Changing password…') : t('Change password') }}
</UButton>
</UForm>
</template>
+33 -17
View File
@@ -1,4 +1,6 @@
<script setup lang="ts">
import { t, translate } from '@/i18n'
import { computed, ref } from 'vue'
import { useRouter } from 'vue-router'
import AuthShell from '@/components/AuthShell.vue'
@@ -18,15 +20,23 @@ interface AlertState {
const tooManyAttemptsAlert: AlertState = {
tone: 'info',
icon: 'i-lucide-timer',
title: 'Too many requests',
description: 'Wait a moment before asking for another link.',
get title() {
return t('Too many requests')
},
get description() {
return t('Wait a moment before asking for another link.')
},
}
const serverErrorAlert: AlertState = {
tone: 'error',
icon: 'i-lucide-circle-x',
title: "Couldn't send the link",
description: 'Check your connection and try again.',
get title() {
return t("Couldn't send the link")
},
get description() {
return t('Check your connection and try again.')
},
}
const router = useRouter()
@@ -45,12 +55,12 @@ const submitDisabled = computed(() => submitting.value || retryAfter.active.valu
const submitLabel = computed(() => {
if (submitting.value) {
return 'Sending…'
return t('Sending…')
}
if (retryAfter.active.value) {
return `Try again in ${retryAfter.formatted.value}`
return t('Try again in {0}', [retryAfter.formatted.value])
}
return 'Send reset link'
return t('Send reset link')
})
function validate() {
@@ -66,7 +76,9 @@ function goToLogin() {
const resultPanelActions = [
{
label: 'Back to log in',
get label() {
return t('Back to log in')
},
color: 'neutral' as const,
variant: 'outline' as const,
onClick: goToLogin,
@@ -108,11 +120,11 @@ async function onSubmit() {
}
}
const pageTitle = computed(() => (sent.value ? '' : 'Reset your password'))
const pageTitle = computed(() => (sent.value ? '' : t('Reset your password')))
const pageDescription = computed(() =>
sent.value
? undefined
: "Enter the email for your account and we'll send you a link to choose a new password.",
: t("Enter the email for your account and we'll send you a link to choose a new password."),
)
</script>
@@ -134,20 +146,24 @@ const pageDescription = computed(() =>
<ResultPanel
tone="success"
icon="i-lucide-mail-check"
title="Check your email"
description="If an account exists for that email, a password reset link has been sent."
:title="t('Check your email')"
:description="
t('If an account exists for that email, a password reset link has been sent.')
"
:actions="resultPanelActions"
>
<p class="text-sm text-muted">The link expires in 1 hour.</p>
<p class="text-sm text-muted">
{{ t('The link expires in 1 hour.') }}
</p>
</ResultPanel>
</template>
<template v-else #default="{ controlSize, controlClass, inputClass }">
<UForm :state="{ email }" class="flex flex-col gap-4.5" @submit="onSubmit">
<UFormField
label="Email"
:label="t('Email')"
name="email"
:error="emailError || undefined"
:error="translate(emailError) || undefined"
:size="controlSize"
>
<template v-if="emailError" #error="{ error }">
@@ -156,7 +172,7 @@ const pageDescription = computed(() =>
name="i-lucide-circle-alert"
class="size-3.5 shrink-0 text-error"
/>
{{ error }}
{{ t(error) }}
</span>
</template>
<UInput
@@ -192,7 +208,7 @@ const pageDescription = computed(() =>
to="/login"
class="font-medium text-highlighted underline underline-offset-3"
>
Back to log in
{{ t('Back to log in') }}
</RouterLink>
</template>
</AuthShell>
+96 -41
View File
@@ -1,6 +1,8 @@
<script setup lang="ts">
import { t } from '@/i18n'
import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue'
import { useOverlay, useToast } from '@nuxt/ui/composables'
import { useLocalizedOverlay, useLocalizedToast } from '@/i18n/presentation'
import ConfirmDialog from '@/components/ConfirmDialog.vue'
import DashboardShell from '@/components/DashboardShell.vue'
import RestartOverlay from '@/components/RestartOverlay.vue'
@@ -22,17 +24,25 @@ const RESTART_FAILED_COPY =
const RESTART_WARNING =
"Your site will be unavailable for about 10 seconds. This page reloads on its own when the site is back. Save anything you're editing first."
const PLAIN_RESTART_DIALOG = {
title: 'Restart the site?',
description: RESTART_WARNING,
confirmLabel: 'Restart site',
cancelLabel: 'Cancel',
get title() {
return t('Restart the site?')
},
get description() {
return t(RESTART_WARNING)
},
get confirmLabel() {
return t('Restart site')
},
get cancelLabel() {
return t('Cancel')
},
}
type LoadState = 'loading' | 'ready' | 'error'
const { getSettings } = useSettings()
const toast = useToast()
const overlay = useOverlay()
const toast = useLocalizedToast()
const overlay = useLocalizedOverlay()
const session = useSessionStore()
const update = useUpdateStore()
const retry = useRetryAfter()
@@ -108,12 +118,16 @@ async function reloadAfterRoleChange() {
}
function notifyOnlyOwner() {
toast.add({
toast.add(() => ({
color: 'error',
icon: 'i-lucide-circle-x',
title: 'Only the owner can do this',
description: 'Ask the owner of this site to update RenewCMS.',
})
get title() {
return t('Only the owner can do this')
},
get description() {
return t('Ask the owner of this site to update RenewCMS.')
},
}))
}
async function onUpdate() {
@@ -127,24 +141,32 @@ async function onUpdate() {
return
}
if (result === 'nothing') {
toast.add({
toast.add(() => ({
color: 'info',
icon: 'i-lucide-info',
title: 'Nothing to update',
description: 'You already have the latest version.',
})
get title() {
return t('Nothing to update')
},
get description() {
return t('You already have the latest version.')
},
}))
await update.load()
return
}
if (result === 'conflict') {
await update.load()
if (update.jobRunning) {
toast.add({
toast.add(() => ({
color: 'info',
icon: 'i-lucide-info',
title: 'An update is already running',
description: 'Another owner started it. Progress shows here.',
})
get title() {
return t('An update is already running')
},
get description() {
return t('Another owner started it. Progress shows here.')
},
}))
}
}
}
@@ -161,10 +183,21 @@ async function onCheck() {
function installRestartDialog() {
const version = displayVersion(update.status?.latest ?? '')
return {
title: 'Restart to finish the update?',
description: `RenewCMS will install version ${version} and restart. ${RESTART_WARNING}`,
confirmLabel: 'Restart now',
cancelLabel: 'Restart later',
get title() {
return t('Restart to finish the update?')
},
get description() {
return t('RenewCMS will install version {0} and restart. {1}', [
version,
t(RESTART_WARNING),
])
},
get confirmLabel() {
return t('Restart now')
},
get cancelLabel() {
return t('Restart later')
},
}
}
@@ -175,12 +208,14 @@ async function onRestart(kind: RestartKind) {
.create(ConfirmDialog, {
props: {
...dialog,
pendingLabel: 'Restarting…',
get pendingLabel() {
return t('Restarting…')
},
tone: 'neutral',
action: async () => {
attempt.result = await update.requestRestart(kind)
if (attempt.result === 'failed') {
throw new Error(RESTART_FAILED_COPY)
throw new Error(t(RESTART_FAILED_COPY))
}
},
},
@@ -209,12 +244,16 @@ async function settleRefusedRestart(result: RestartRequestResult) {
}
await update.load()
if (update.jobRunning || update.restarting) {
toast.add({
toast.add(() => ({
color: 'info',
icon: 'i-lucide-info',
title: 'An update is already running',
description: 'Another owner started it. Progress shows here.',
})
get title() {
return t('An update is already running')
},
get description() {
return t('Another owner started it. Progress shows here.')
},
}))
}
update.resumeRestart()
}
@@ -223,26 +262,40 @@ const OUTCOME_TOASTS = {
updated: () => ({
color: 'success' as const,
icon: 'i-lucide-circle-check',
title: 'Update installed',
description: `RenewCMS is now on version ${displayVersion(update.status?.current ?? '')}.`,
get title() {
return t('Update installed')
},
get description() {
return t('RenewCMS is now on version {0}.', [
displayVersion(update.status?.current ?? ''),
])
},
}),
'rolled-back': () => ({
color: 'error' as const,
icon: 'i-lucide-circle-x',
title: "The update didn't install",
description: `Your site is back on ${update.status?.current ?? ''}.`,
get title() {
return t("The update didn't install")
},
get description() {
return t('Your site is back on {0}.', [update.status?.current ?? ''])
},
duration: 0,
}),
restarted: () => ({
color: 'success' as const,
icon: 'i-lucide-circle-check',
title: 'Site restarted',
description: 'Your site is running again.',
get title() {
return t('Site restarted')
},
get description() {
return t('Your site is running again.')
},
}),
}
async function announceRestartOutcome(outcome: RestartOutcome) {
toast.add(OUTCOME_TOASTS[outcome]())
toast.add(() => OUTCOME_TOASTS[outcome]())
await nextTick()
updatesCard.value?.focusHeading()
}
@@ -330,8 +383,10 @@ onBeforeUnmount(() => {
/>
<div class="flex max-w-164 flex-col gap-5" :inert="update.restartRun !== null">
<div class="flex flex-col gap-0.5">
<h1 class="text-xl font-semibold text-highlighted">Settings</h1>
<p class="text-muted">Workspace information.</p>
<h1 class="text-xl font-semibold text-highlighted">
{{ t('Settings') }}
</h1>
<p class="text-muted">{{ t('Workspace information.') }}</p>
</div>
<div
@@ -347,7 +402,7 @@ onBeforeUnmount(() => {
orientation="vertical"
:close="false"
icon="i-lucide-circle-x"
:description="UNREACHABLE_COPY"
:description="t(UNREACHABLE_COPY)"
:ui="{ actions: 'mt-2' }"
>
<template #actions>
@@ -359,13 +414,13 @@ onBeforeUnmount(() => {
:ui="{ leadingIcon: 'size-3.5' }"
@click="loadSettings"
>
Try again
{{ t('Try again') }}
</UButton>
</template>
</UAlert>
<div v-else class="flex items-center justify-between gap-4">
<span class="text-lg font-semibold text-highlighted">Version</span>
<span class="text-lg font-semibold text-highlighted">{{ t('Version') }}</span>
<USkeleton
v-if="loadState === 'loading' && showSkeleton"
data-testid="settings-skeleton"
+72 -33
View File
@@ -1,7 +1,9 @@
<script setup lang="ts">
import { t } from '@/i18n'
import { computed, nextTick, onBeforeUnmount, onMounted, ref } from 'vue'
import { useMediaQuery } from '@vueuse/core'
import { useOverlay, useToast } from '@nuxt/ui/composables'
import { useLocalizedOverlay, useLocalizedToast } from '@/i18n/presentation'
import ConfirmDialog from '@/components/ConfirmDialog.vue'
import DashboardShell from '@/components/DashboardShell.vue'
import InviteUserModal from '@/components/InviteUserModal.vue'
@@ -19,8 +21,8 @@ type LoadState = 'loading' | 'ready' | 'error'
const AUTH_HANDLED_STATUSES = [401, 403]
const CANCEL_FAILED_COPY = "Couldn't cancel the invite. Try again in a moment."
const toast = useToast()
const overlay = useOverlay()
const toast = useLocalizedToast()
const overlay = useLocalizedOverlay()
const session = useSessionStore()
const { listUsers, cancelInvite, renewInvite } = useUsers()
const isDesktop = useMediaQuery(DESKTOP_QUERY)
@@ -105,13 +107,17 @@ function onInvited(invite: CreatedInvite) {
),
]
highlightedKey.value = userRowKey('invite', invite.id)
toast.add({
toast.add(() => ({
id: `invite-sent-${invite.id}`,
color: 'success',
icon: 'i-lucide-circle-check',
title: 'Invite sent',
description: `${invite.email} has 3 days to accept.`,
})
get title() {
return t('Invite sent')
},
get description() {
return t('{0} has 3 days to accept.', [invite.email])
},
}))
}
function isHandledByApiClient(err: unknown) {
@@ -127,11 +133,24 @@ function confirmCancel(invite: UserListItem) {
overlay
.create(ConfirmDialog, {
props: {
title: 'Cancel this invite?',
description: `The link sent to ${invite.email} will stop working. You can invite the address again later.`,
confirmLabel: 'Cancel invite',
cancelLabel: 'Keep invite',
pendingLabel: 'Cancelling…',
get title() {
return t('Cancel this invite?')
},
get description() {
return t(
'The link sent to {0} will stop working. You can invite the address again later.',
[invite.email],
)
},
get confirmLabel() {
return t('Cancel invite')
},
get cancelLabel() {
return t('Keep invite')
},
get pendingLabel() {
return t('Cancelling…')
},
tone: 'danger',
action: async () => {
try {
@@ -140,16 +159,20 @@ function confirmCancel(invite: UserListItem) {
if (isHandledByApiClient(err)) {
return
}
throw new Error(CANCEL_FAILED_COPY)
throw new Error(t(CANCEL_FAILED_COPY))
}
removeRow(key)
toast.add({
toast.add(() => ({
id: `invite-cancelled-${invite.id}`,
color: 'success',
icon: 'i-lucide-circle-check',
title: 'Invite cancelled',
description: `${invite.email} can no longer use the link.`,
})
get title() {
return t('Invite cancelled')
},
get description() {
return t('{0} can no longer use the link.', [invite.email])
},
}))
},
},
})
@@ -176,22 +199,30 @@ async function renew(invite: UserListItem) {
...items.value.filter((item) => userRowKey(item.kind, item.id) !== key),
]
highlightedKey.value = userRowKey('invite', renewed.id)
toast.add({
toast.add(() => ({
id: `invite-renewed-${renewed.id}`,
color: 'success',
icon: 'i-lucide-circle-check',
title: 'Invite renewed',
description: `${renewed.email} has 3 days to accept the new link.`,
})
get title() {
return t('Invite renewed')
},
get description() {
return t('{0} has 3 days to accept the new link.', [renewed.email])
},
}))
} catch (err) {
if (!isHandledByApiClient(err)) {
toast.add({
toast.add(() => ({
id: `invite-renew-failed-${invite.id}`,
color: 'error',
icon: 'i-lucide-circle-x',
title: "Couldn't renew the invite",
description: 'Try again in a moment.',
})
get title() {
return t("Couldn't renew the invite")
},
get description() {
return t('Try again in a moment.')
},
}))
}
} finally {
busyKeys.value = busyKeys.value.filter((busyKey) => busyKey !== key)
@@ -207,8 +238,12 @@ onBeforeUnmount(clearSkeletonTimer)
<div class="flex flex-col gap-4">
<div class="flex flex-wrap items-end gap-4">
<div class="flex min-w-50 flex-1 flex-col gap-0.5">
<h1 class="text-xl font-semibold text-highlighted">Users</h1>
<p class="text-muted">Everyone who can sign in and work on your articles.</p>
<h1 class="text-xl font-semibold text-highlighted">
{{ t('Users') }}
</h1>
<p class="text-muted">
{{ t('Everyone who can sign in and work on your articles.') }}
</p>
</div>
<UButton
v-if="isDesktop && canInvite"
@@ -216,7 +251,7 @@ onBeforeUnmount(clearSkeletonTimer)
class="h-8 px-3"
@click="openInviteModal"
>
Invite user
{{ t('Invite user') }}
</UButton>
</div>
@@ -228,8 +263,8 @@ onBeforeUnmount(clearSkeletonTimer)
orientation="vertical"
:close="false"
icon="i-lucide-circle-x"
title="Couldn't load users"
description="Check your connection and try again."
:title="t('Couldn\'t load users')"
:description="t('Check your connection and try again.')"
:ui="{ actions: 'mt-2' }"
>
<template #actions>
@@ -241,7 +276,7 @@ onBeforeUnmount(clearSkeletonTimer)
:ui="{ leadingIcon: 'size-3.5' }"
@click="loadUsers"
>
Try again
{{ t('Try again') }}
</UButton>
</template>
</UAlert>
@@ -272,7 +307,11 @@ onBeforeUnmount(clearSkeletonTimer)
</template>
<p v-if="showOnlyYouHint" class="text-pretty text-muted">
Invite someone so they can sign in with their own account instead of sharing yours.
{{
t(
'Invite someone so they can sign in with their own account instead of sharing yours.',
)
}}
</p>
</div>
@@ -284,7 +323,7 @@ onBeforeUnmount(clearSkeletonTimer)
:ui="{ base: 'gap-2', leadingIcon: 'size-4.5' }"
@click="openInviteModal"
>
Invite user
{{ t('Invite user') }}
</UButton>
<InviteUserModal
+82 -63
View File
@@ -1,7 +1,9 @@
<script setup lang="ts">
import { t, translate } from '@/i18n'
import { computed, nextTick, onMounted, ref } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { useToast } from '@nuxt/ui/composables'
import { useLocalizedToast } from '@/i18n/presentation'
import AuthShell from '@/components/AuthShell.vue'
import ResultPanel from '@/components/ResultPanel.vue'
import { ApiError, useApi } from '@/composables/useApi'
@@ -28,36 +30,52 @@ interface AlertState {
const wrongCredentialsAlert: AlertState = {
tone: 'error',
icon: 'i-lucide-circle-x',
title: "That username and password don't match",
description: 'Check both and try again. Passwords are case-sensitive.',
get title() {
return t("That username and password don't match")
},
get description() {
return t('Check both and try again. Passwords are case-sensitive.')
},
}
const tooManyAttemptsAlert: AlertState = {
tone: 'info',
icon: 'i-lucide-timer',
title: 'Too many attempts',
description: 'For your security, wait a moment before trying again.',
get title() {
return t('Too many attempts')
},
get description() {
return t('For your security, wait a moment before trying again.')
},
}
const serverErrorAlert: AlertState = {
tone: 'error',
icon: 'i-lucide-circle-x',
title: "Couldn't reach RenewCMS",
description: 'Check your connection and try again.',
get title() {
return t("Couldn't reach RenewCMS")
},
get description() {
return t('Check your connection and try again.')
},
}
const cancelFailedAlert: AlertState = {
tone: 'error',
icon: 'i-lucide-circle-x',
title: "Couldn't cancel registration",
description: 'Something went wrong on the server. Try again in a moment.',
get title() {
return t("Couldn't cancel registration")
},
get description() {
return t('Something went wrong on the server. Try again in a moment.')
},
}
const route = useRoute()
const router = useRouter()
const api = useApi()
const session = useSessionStore()
const toast = useToast()
const toast = useLocalizedToast()
const retryAfter = useRetryAfter()
const state = ref<VerifyState>('missing')
@@ -71,39 +89,35 @@ const loginSubmitting = ref(false)
const loginAlert = ref<AlertState | null>(null)
const shellTitle = computed(() =>
state.value === 'noSession' ? 'Log in to finish verifying' : '',
state.value === 'noSession' ? t('Log in to finish verifying') : '',
)
const shellDescription = computed(() =>
state.value === 'noSession'
? "You opened this link somewhere you're not logged in. Log in here and we'll verify your account right after."
? t(
"You opened this link somewhere you're not logged in. Log in here and we'll verify your account right after.",
)
: undefined,
)
const usernameError = ref('')
const passwordError = ref('')
const loginSubmitDisabled = computed(
() => loginSubmitting.value || retryAfter.active.value,
)
const loginSubmitDisabled = computed(() => loginSubmitting.value || retryAfter.active.value)
const loginSubmitLabel = computed(() => {
if (loginSubmitting.value) {
return 'Logging in…'
return t('Logging in…')
}
if (retryAfter.active.value) {
return `Try again in ${retryAfter.formatted.value}`
return t('Try again in {0}', [retryAfter.formatted.value])
}
return 'Log in and verify'
return t('Log in and verify')
})
async function focusPassword() {
await nextTick()
await nextTick()
document
.querySelector<HTMLInputElement>(
'input[autocomplete="current-password"]',
)
?.focus()
document.querySelector<HTMLInputElement>('input[autocomplete="current-password"]')?.focus()
}
async function performVerify(code: string) {
@@ -116,12 +130,16 @@ async function performVerify(code: string) {
onForbidden: 'passthrough',
})
state.value = 'success'
toast.add({
toast.add(() => ({
color: 'success',
icon: 'i-lucide-circle-check',
title: 'Your account is verified',
description: 'Welcome to RenewCMS.',
})
get title() {
return t('Your account is verified')
},
get description() {
return t('Welcome to RenewCMS.')
},
}))
await Promise.all([session.retry(), wait(VERIFIED_REDIRECT_DELAY_MS)])
await router.push('/home')
return
@@ -160,10 +178,8 @@ function handleLoginError(error: unknown) {
}
function validateLogin() {
usernameError.value =
username.value.trim().length < 3 ? 'Use at least 3 characters.' : ''
passwordError.value =
password.value.length === 0 ? 'Enter your password.' : ''
usernameError.value = username.value.trim().length < 3 ? 'Use at least 3 characters.' : ''
passwordError.value = password.value.length === 0 ? 'Enter your password.' : ''
return !usernameError.value && !passwordError.value
}
@@ -203,7 +219,7 @@ async function cancelAndStartOver() {
onForbidden: 'passthrough',
})
await router.push('/register')
toast.add(registrationCancelledToast)
toast.add(() => registrationCancelledToast)
} catch {
cancelAlert.value = cancelFailedAlert
} finally {
@@ -224,14 +240,18 @@ async function logOutFromInvalid() {
const invalidActions = computed(() => [
{
label: 'Cancel registration and start over',
get label() {
return t('Cancel registration and start over')
},
color: 'primary' as const,
variant: 'solid' as const,
disabled: invalidActionsDisabled.value,
onClick: cancelAndStartOver,
},
{
label: 'Log out',
get label() {
return t('Log out')
},
color: 'neutral' as const,
variant: 'outline' as const,
disabled: invalidActionsDisabled.value,
@@ -245,7 +265,9 @@ function goToLogin() {
const missingCodeActions = [
{
label: 'Go to log in',
get label() {
return t('Go to log in')
},
color: 'neutral' as const,
variant: 'outline' as const,
onClick: goToLogin,
@@ -294,22 +316,21 @@ onMounted(() => {
role="status"
class="flex flex-col items-center gap-4 py-10 text-center"
>
<UIcon
name="i-lucide-loader-2"
class="size-8 animate-spin text-highlighted"
/>
<UIcon name="i-lucide-loader-2" class="size-8 animate-spin text-highlighted" />
<p class="text-xl/7 font-semibold text-highlighted">
Verifying your account…
{{ t('Verifying your account…') }}
</p>
<p class="text-base text-muted">
{{ t('This only takes a moment.') }}
</p>
<p class="text-base text-muted">This only takes a moment.</p>
</div>
<ResultPanel
v-else-if="state === 'success'"
tone="success"
icon="i-lucide-shield-check"
title="Your account is verified"
description="Taking you to your dashboard…"
:title="t('Your account is verified')"
:description="t('Taking you to your dashboard…')"
:actions="[]"
/>
@@ -317,12 +338,12 @@ onMounted(() => {
v-else-if="state === 'invalid'"
tone="error"
icon="i-lucide-link-2-off"
title="This verification link doesn't work"
description="It may have expired (links last 2 hours) or already been used."
:title="t('This verification link doesn\'t work')"
:description="t('It may have expired (links last 2 hours) or already been used.')"
:actions="invalidActions"
>
<p class="text-sm text-pretty text-muted">
Cancel your registration and start over to get a new link.
{{ t('Cancel your registration and start over to get a new link.') }}
</p>
</ResultPanel>
@@ -330,8 +351,12 @@ onMounted(() => {
v-else-if="state === 'missing'"
tone="error"
icon="i-lucide-link-2-off"
title="This link is incomplete"
description="The verification code is missing. Open the link from your email again, or copy the full address into your browser."
:title="t('This link is incomplete')"
:description="
t(
'The verification code is missing. Open the link from your email again, or copy the full address into your browser.',
)
"
:actions="missingCodeActions"
/>
@@ -342,9 +367,9 @@ onMounted(() => {
@submit="onLoginSubmit"
>
<UFormField
label="Username"
:label="t('Username')"
name="username"
:error="usernameError || undefined"
:error="translate(usernameError) || undefined"
:size="controlSize"
>
<template v-if="usernameError" #error="{ error }">
@@ -353,13 +378,13 @@ onMounted(() => {
name="i-lucide-circle-alert"
class="size-3.5 shrink-0 text-error"
/>
{{ error }}
{{ t(error) }}
</span>
</template>
<UInput
v-model="username"
:highlight="!!usernameError"
placeholder="Your username"
:placeholder="t('Your username')"
autocomplete="username"
:disabled="loginSubmitDisabled"
variant="outline"
@@ -371,9 +396,9 @@ onMounted(() => {
</UFormField>
<UFormField
label="Password"
:label="t('Password')"
name="password"
:error="passwordError || undefined"
:error="translate(passwordError) || undefined"
:size="controlSize"
>
<template v-if="passwordError" #error="{ error }">
@@ -382,7 +407,7 @@ onMounted(() => {
name="i-lucide-circle-alert"
class="size-3.5 shrink-0 text-error"
/>
{{ error }}
{{ t(error) }}
</span>
</template>
<UInput
@@ -402,20 +427,14 @@ onMounted(() => {
>
<template #trailing>
<UButton
:icon="
passwordShown
? 'i-lucide-eye-off'
: 'i-lucide-eye'
"
:icon="passwordShown ? 'i-lucide-eye-off' : 'i-lucide-eye'"
variant="ghost"
color="neutral"
size="sm"
square
class="size-8 justify-center text-toned"
:aria-label="
passwordShown
? 'Hide password'
: 'Show password'
passwordShown ? t('Hide password') : t('Show password')
"
:aria-pressed="passwordShown"
@click="passwordShown = !passwordShown"