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

+17 -5
View File
@@ -1,3 +1,4 @@
import { t, locale, formatNumber } from '@/i18n'
export const MAX_IMAGE_BYTES = 10 * 1024 * 1024
export const ALLOWED_IMAGE_MIME_TYPES = ['image/png', 'image/jpeg', 'image/webp', 'image/svg+xml']
@@ -39,7 +40,13 @@ export function extensionMatchesType(file: File) {
}
export function formatMegabytes(bytes: number) {
return (bytes / (1024 * 1024)).toFixed(1)
const value = bytes / (1024 * 1024)
return locale.value === 'fr'
? formatNumber(value, {
minimumFractionDigits: 1,
maximumFractionDigits: 1,
})
: value.toFixed(1)
}
export function isSizeRejectionBody(body: string) {
@@ -47,16 +54,21 @@ export function isSizeRejectionBody(body: string) {
}
export function rejectionTitle(filename: string) {
return `"${filename}" can't be used`
return t("\u0022{0}\u0022 can't be used", [filename])
}
export function sizeRejectionText(filename: string, bytes: number) {
return `${filename} is ${formatMegabytes(bytes)} MB. Images can be up to 10 MB.`
return t('{0} is {1} MB. Images can be up to 10 MB.', [filename, formatMegabytes(bytes)])
}
export function formatWholeKB(bytes: number): string {
const kb = bytes / 1024
const rounded = Math.round(kb * 10) / 10
if (locale.value === 'fr')
return formatNumber(rounded, {
minimumFractionDigits: rounded === 0 ? 1 : 0,
maximumFractionDigits: 1,
})
if (rounded === 0) {
return '0.0'
}
@@ -65,7 +77,7 @@ export function formatWholeKB(bytes: number): string {
export function formatUploadProgress(loadedBytes: number, totalBytes: number): string {
if (totalBytes < 1024 * 1024) {
return `Uploading ${formatWholeKB(loadedBytes)} of ${formatWholeKB(totalBytes)} KB`
return t('Uploading {0} of {1} KB', [formatWholeKB(loadedBytes), formatWholeKB(totalBytes)])
}
return `Uploading ${formatMegabytes(loadedBytes)} of ${formatMegabytes(totalBytes)} MB`
return t('Uploading {0} of {1} MB', [formatMegabytes(loadedBytes), formatMegabytes(totalBytes)])
}
+14 -2
View File
@@ -1,3 +1,4 @@
import { t } from '@/i18n'
import type { MediaItem } from '@/composables/useMedia'
import { formatDimensions, formatSize } from '@/lib/mediaFormat'
@@ -18,7 +19,11 @@ function byPreference(first: MediaItem, second: MediaItem) {
export function groupHeader(copies: MediaItem[]) {
const [first] = copies
return `${copies.length} identical images · ${formatSize(first!.size)} each · ${formatDimensions(first!.width, first!.height)}`
return t('{0} identical images · {1} each · {2}', [
copies.length,
formatSize(first!.size),
formatDimensions(first!.width, first!.height),
])
}
export function groupDuplicates(items: MediaItem[]): DuplicateGroup[] {
@@ -30,7 +35,14 @@ export function groupDuplicates(items: MediaItem[]): DuplicateGroup[] {
.filter(([, copies]) => copies.length > 1)
.map(([hash, copies]) => {
const sorted = [...copies].sort(byPreference)
return { hash, copies: sorted, suggested: sorted[0]!, header: groupHeader(sorted) }
return {
hash,
copies: sorted,
suggested: sorted[0]!,
get header() {
return groupHeader(sorted)
},
}
})
.sort((first, second) => byAge(first.suggested, second.suggested))
}
+21 -8
View File
@@ -1,3 +1,4 @@
import { t, locale, formatNumber } from '@/i18n'
import { formatAbsoluteDateFromIso } from '@/lib/relativeDate'
const BYTES_PER_KB = 1024
@@ -22,14 +23,22 @@ export interface MediaNameSource {
export function formatSize(bytes: number) {
if (bytes < BYTES_PER_MB) {
return `${Math.round(bytes / BYTES_PER_KB)} KB`
return t('{0} KB', [Math.round(bytes / BYTES_PER_KB)])
}
return `${(bytes / BYTES_PER_MB).toFixed(1)} MB`
const amount = bytes / BYTES_PER_MB
return t('{0} MB', [
locale.value === 'fr'
? formatNumber(amount, {
minimumFractionDigits: 1,
maximumFractionDigits: 1,
})
: amount.toFixed(1),
])
}
export function formatDimensions(width: number | null, height: number | null) {
if (width === null || height === null) {
return VECTOR_DIMENSIONS
return t(VECTOR_DIMENSIONS)
}
return `${width} × ${height}`
}
@@ -41,11 +50,11 @@ export function formatType(contentType: string) {
export const formatMediaDate = formatAbsoluteDateFromIso
export function pluralArticles(count: number) {
return `${count} ${count === 1 ? 'article' : 'articles'}`
return t(count === 1 ? '1 article' : '{0} articles', [count])
}
export function usedInLabel(count: number) {
return count > 0 ? `used in ${pluralArticles(count)}` : 'unused'
return count > 0 ? t('used in {0}', [pluralArticles(count)]) : t('unused')
}
export function accessibleName(item: MediaNameSource) {
@@ -74,13 +83,17 @@ export function joinNames(names: string[]) {
if (names.length <= 1) {
return names.join('')
}
return `${names.slice(0, -1).join(', ')} and ${names[names.length - 1]}`
return t('{0} and {1}', [names.slice(0, -1).join(', '), names[names.length - 1]])
}
export function libraryMatchText(
filename: string,
existing: { created_at: string; uploaded_by: string },
) {
const by = existing.uploaded_by ? ` by ${existing.uploaded_by}` : ''
return `${filename} matches an image uploaded on ${formatMediaDate(existing.created_at)}${by}.`
const by = existing.uploaded_by ? t(' by {0}', [existing.uploaded_by]) : ''
return t('{0} matches an image uploaded on {1}{2}.', [
filename,
formatMediaDate(existing.created_at),
by,
])
}
+32 -20
View File
@@ -1,3 +1,4 @@
import { t } from '@/i18n'
import { formatMegabytes } from '@/lib/imageUpload'
export type UploadRowStatus =
@@ -42,40 +43,40 @@ export function isSettled(status: UploadRowStatus) {
}
function imageCount(total: number) {
return `${total} ${total === 1 ? 'image' : 'images'}`
}
function fileCount(total: number) {
return `${total} ${total === 1 ? 'file' : 'files'}`
return t(total === 1 ? '{0} image' : '{0} images', [total])
}
export function rejectionTitle(kind: UploadRejection, filename: string, bytes: number) {
if (kind === 'size') {
return `${filename} is ${formatMegabytes(bytes)} MB`
return t('{0} is {1} MB', [filename, formatMegabytes(bytes)])
}
if (kind === 'unreadable') {
return `${filename} couldn't be read`
return t("{0} couldn't be read", [filename])
}
return `${filename} isn't an image`
return t("{0} isn't an image", [filename])
}
export function rejectionText(kind: UploadRejection) {
return kind === 'unreadable' ? UNREADABLE_TEXT : REJECTION_TEXT
return kind === 'unreadable' ? t(UNREADABLE_TEXT) : t(REJECTION_TEXT)
}
export function duplicateText(
existing: { filename: string; created_at: string; uploaded_by: string },
formatDate: (iso: string) => string,
) {
const by = existing.uploaded_by ? ` by ${existing.uploaded_by}` : ''
return `Same as "${existing.filename}", uploaded ${formatDate(existing.created_at)}${by}.`
const by = existing.uploaded_by ? t(' by {0}', [existing.uploaded_by]) : ''
return t('Same as \u0022{0}\u0022, uploaded {1}{2}.', [
existing.filename,
formatDate(existing.created_at),
by,
])
}
export function summaryLine(counts: { added: number; duplicate: number; attention: number }) {
const parts = [
...(counts.added > 0 ? [`${counts.added} uploaded`] : []),
...(counts.duplicate > 0 ? [`${counts.duplicate} already in your library`] : []),
...(counts.attention > 0 ? [`${counts.attention} need attention`] : []),
...(counts.added > 0 ? [t('{0} uploaded', [counts.added])] : []),
...(counts.duplicate > 0 ? [t('{0} already in your library', [counts.duplicate])] : []),
...(counts.attention > 0 ? [t('{0} need attention', [counts.attention])] : []),
]
return parts.join(' · ')
}
@@ -87,8 +88,10 @@ export function describeUpload(rows: UploadRowLike[]): UploadHeader {
if (settled < total) {
return {
phase: 'working',
title: `Uploading ${imageCount(total)}`,
sub: `${settled} of ${total} done`,
get title() {
return t('Uploading {0}', [imageCount(total)])
},
sub: t('{0} of {1} done', [settled, total]),
summary: null,
}
}
@@ -98,13 +101,22 @@ export function describeUpload(rows: UploadRowLike[]): UploadHeader {
if (added === 0 && duplicate === 0) {
const sub =
count('rejected') === total
? `${fileCount(total)} ${total === 1 ? 'was' : 'were'} rejected`
: `${fileCount(total)} ${total === 1 ? 'needs' : 'need'} attention`
return { phase: 'settled', title: 'Nothing was uploaded', sub, summary: null }
? t(total === 1 ? '{0} file was rejected' : '{0} files were rejected', [total])
: t(total === 1 ? '{0} file needs attention' : '{0} files need attention', [total])
return {
phase: 'settled',
get title() {
return t('Nothing was uploaded')
},
sub,
summary: null,
}
}
return {
phase: 'settled',
title: 'Upload finished',
get title() {
return t('Upload finished')
},
sub: imageCount(total),
summary: summaryLine({ added, duplicate, attention }),
}
+7 -2
View File
@@ -1,6 +1,11 @@
import { t } from '@/i18n'
export const registrationCancelledToast = {
color: 'success' as const,
icon: 'i-lucide-circle-check',
title: 'Registration cancelled',
description: 'You can create the owner account again.',
get title() {
return t('Registration cancelled')
},
get description() {
return t('You can create the owner account again.')
},
}
+13 -5
View File
@@ -1,3 +1,4 @@
import { t, locale } from '@/i18n'
const MINUTE_MS = 60 * 1000
const HOUR_MS = 60 * MINUTE_MS
const DAY_MS = 24 * HOUR_MS
@@ -19,6 +20,13 @@ const MONTH_NAMES = [
]
function formatAbsoluteDate(date: Date) {
if (locale.value === 'fr') {
return new Intl.DateTimeFormat('fr', {
day: 'numeric',
month: 'short',
year: 'numeric',
}).format(date)
}
return `${date.getDate()} ${MONTH_NAMES[date.getMonth()]} ${date.getFullYear()}`
}
@@ -32,18 +40,18 @@ export function formatRelativeDate(iso: string, now: Date = new Date()) {
if (diffMs < HOUR_MS) {
const minutes = Math.max(1, Math.floor(diffMs / MINUTE_MS))
return minutes === 1 ? '1 minute ago' : `${minutes} minutes ago`
return minutes === 1 ? t('1 minute ago') : t('{0} minutes ago', [minutes])
}
if (diffMs < DAY_MS) {
const hours = Math.floor(diffMs / HOUR_MS)
return hours === 1 ? '1 hour ago' : `${hours} hours ago`
return hours === 1 ? t('1 hour ago') : t('{0} hours ago', [hours])
}
const days = Math.floor(diffMs / DAY_MS)
if (days === 1) {
return 'Yesterday'
return t('Yesterday')
}
if (days < RELATIVE_CUTOFF_DAYS) {
return `${days} days ago`
return t('{0} days ago', [days])
}
return formatAbsoluteDate(date)
}
@@ -58,5 +66,5 @@ function isSameLocalDay(a: Date, b: Date) {
export function formatCreatedDate(iso: string, now: Date = new Date()) {
const date = new Date(iso)
return isSameLocalDay(date, now) ? 'Today' : formatAbsoluteDate(date)
return isSameLocalDay(date, now) ? t('Today') : formatAbsoluteDate(date)
}
+12 -11
View File
@@ -1,3 +1,4 @@
import { t } from '@/i18n'
import { formatAbsoluteDateFromIso, formatRelativeDate } from '@/lib/relativeDate'
export const ENABLE_BROWSER_UPDATES_COMMAND = 'renewcms enable-browser-updates'
@@ -26,12 +27,12 @@ export function formatLastChecked(iso: string, now: Date = new Date()) {
const yesterday = new Date(now)
yesterday.setDate(now.getDate() - 1)
if (isSameLocalDay(date, now)) {
return `Last checked today at ${formatClock(date)}`
return t('Last checked today at {0}', [formatClock(date)])
}
if (isSameLocalDay(date, yesterday)) {
return `Last checked yesterday at ${formatClock(date)}`
return t('Last checked yesterday at {0}', [formatClock(date)])
}
return `Last checked ${formatAbsoluteDateFromIso(iso)} at ${formatClock(date)}`
return t('Last checked {0} at {1}', [formatAbsoluteDateFromIso(iso), formatClock(date)])
}
export function isCheckDue(checkedAt: string | null, now: number = Date.now()) {
@@ -42,7 +43,7 @@ export function isCheckDue(checkedAt: string | null, now: number = Date.now()) {
}
export function formatReleased(iso: string) {
return `Released ${formatAbsoluteDateFromIso(iso)}`
return t('Released {0}', [formatAbsoluteDateFromIso(iso)])
}
const BYTES_PER_MB = 1024 * 1024
@@ -56,7 +57,7 @@ export function formatDownloadProgress(progress: {
}) {
const done = Math.round(progress.downloaded_bytes / BYTES_PER_MB)
const total = Math.round(progress.total_bytes / BYTES_PER_MB)
return `${progress.percent}% · ${done} of ${total} MB`
return t('{0}% · {1} of {2} MB', [progress.percent, done, total])
}
export function downloadAnnouncement(version: string, percent: number) {
@@ -64,8 +65,8 @@ export function downloadAnnouncement(version: string, percent: number) {
Math.floor(percent / ANNOUNCEMENT_STEP_PERCENT) * ANNOUNCEMENT_STEP_PERCENT,
LAST_ANNOUNCED_PERCENT,
)
const base = `Downloading version ${version}`
return step > 0 ? `${base}, ${step} percent` : base
const base = t('Downloading version {0}', [version])
return step > 0 ? t('{0}, {1} percent', [base, step]) : base
}
export const VERIFYING_ANNOUNCEMENT = 'Checking the download'
@@ -84,7 +85,7 @@ export function browserUpdatesDocsUrl(releaseUrl: string) {
}
export function formatStartedAt(iso: string) {
return `Started at ${formatClock(new Date(iso))}`
return t('Started at {0}', [formatClock(new Date(iso))])
}
export function formatStartedWithAge(iso: string, now: Date = new Date()) {
@@ -96,10 +97,10 @@ export function formatUpdatedAt(iso: string, now: Date = new Date()) {
const yesterday = new Date(now)
yesterday.setDate(now.getDate() - 1)
if (isSameLocalDay(date, now)) {
return `Updated today at ${formatClock(date)}`
return t('Updated today at {0}', [formatClock(date)])
}
if (isSameLocalDay(date, yesterday)) {
return `Updated yesterday at ${formatClock(date)}`
return t('Updated yesterday at {0}', [formatClock(date)])
}
return `Updated ${formatAbsoluteDateFromIso(iso)} at ${formatClock(date)}`
return t('Updated {0} at {1}', [formatAbsoluteDateFromIso(iso), formatClock(date)])
}