feat(SPA): i18n internationzalization, english and french
This commit is contained in:
1 parent
7f1e8e572d
commit
721311a643
88 files changed
+4438
-1148
No files matched your search
@@ -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))
|
||||
})
|
||||
})
|
||||
@@ -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()
|
||||
@@ -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
|
||||
@@ -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 }
|
||||
}
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
Reference in new issue
Block a user