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

+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()
})
})