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
@@ -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)])
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
])
|
||||
}
|
||||
@@ -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 }),
|
||||
}
|
||||
|
||||
@@ -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.')
|
||||
},
|
||||
}
|
||||
@@ -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
@@ -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)])
|
||||
}
|
||||
Reference in new issue
Block a user