- {{ formatCompact(project.downloads) }}
+ {{ formatCompactNumber(project.downloads) }}
- {{ formatCompact(project.followers) }}
+ {{ formatCompactNumber(project.followers) }}
diff --git a/packages/ui/src/components/instances/modals/ContentUpdaterModal.vue b/packages/ui/src/components/instances/modals/ContentUpdaterModal.vue
index d736cb8266..2168771ea7 100644
--- a/packages/ui/src/components/instances/modals/ContentUpdaterModal.vue
+++ b/packages/ui/src/components/instances/modals/ContentUpdaterModal.vue
@@ -180,6 +180,7 @@ import {
import { capitalizeString, renderHighlightedString } from '@modrinth/utils'
import { computed, ref } from 'vue'
+import { useFormatDateTime } from '../../../composables'
import { defineMessages, useVIntl } from '../../../composables/i18n'
import { commonMessages } from '../../../utils/common-messages'
import Avatar from '../../base/Avatar.vue'
@@ -188,6 +189,7 @@ import StyledInput from '../../base/StyledInput.vue'
import NewModal from '../../modal/NewModal.vue'
const { formatMessage } = useVIntl()
+const formatDate = useFormatDateTime({ dateStyle: 'long' })
const messages = defineMessages({
updateVersionHeader: {
@@ -341,11 +343,7 @@ function getBadgeClasses(version: Labrinth.Versions.v2.Version): string {
}
function formatLongDate(dateString: string): string {
- return new Date(dateString).toLocaleDateString('en-US', {
- year: 'numeric',
- month: 'long',
- day: 'numeric',
- })
+ return formatDate(new Date(dateString))
}
function formatLoaderGameVersion(version: Labrinth.Versions.v2.Version): string {
diff --git a/packages/ui/src/components/project/ProjectHeader.vue b/packages/ui/src/components/project/ProjectHeader.vue
index 715529bb5e..33d41b5926 100644
--- a/packages/ui/src/components/project/ProjectHeader.vue
+++ b/packages/ui/src/components/project/ProjectHeader.vue
@@ -17,20 +17,20 @@
v-tooltip="
capitalizeString(
formatMessage(commonMessages.projectDownloads, {
- count: formatNumber(project.downloads, false),
+ count: project.downloads,
}),
)
"
class="flex items-center gap-2 border-0 border-r border-solid border-divider pr-4 font-semibold cursor-help"
>
- {{ formatNumber(project.downloads) }}
+ {{ formatCompactNumber(project.downloads) }}
- {{ formatNumber(project.followers) }}
+ {{ formatCompactNumber(project.followers) }}
@@ -62,10 +62,10 @@
diff --git a/packages/ui/src/components/servers/backups/BackupItem.vue b/packages/ui/src/components/servers/backups/BackupItem.vue
index ca75453c4e..92295e18ef 100644
--- a/packages/ui/src/components/servers/backups/BackupItem.vue
+++ b/packages/ui/src/components/servers/backups/BackupItem.vue
@@ -10,9 +10,9 @@ import {
UserRoundIcon,
XIcon,
} from '@modrinth/assets'
-import dayjs from 'dayjs'
import { computed } from 'vue'
+import { useFormatDateTime } from '../../../composables'
import { defineMessages, useVIntl } from '../../../composables/i18n'
import { commonMessages } from '../../../utils'
import ButtonStyled from '../../base/ButtonStyled.vue'
@@ -20,6 +20,10 @@ import OverflowMenu, { type Option as OverflowOption } from '../../base/Overflow
import ProgressBar from '../../base/ProgressBar.vue'
const { formatMessage } = useVIntl()
+const formatDateTime = useFormatDateTime({
+ timeStyle: 'short',
+ dateStyle: 'long',
+})
const emit = defineEmits<{
(e: 'download' | 'rename' | 'restore' | 'retry'): void
@@ -254,7 +258,7 @@ const messages = defineMessages({
- {{ dayjs(backup.created_at).format('MMMM Do YYYY, h:mm A') }}
+ {{ formatDateTime(backup.created_at) }}
diff --git a/packages/ui/src/components/servers/files/explorer/FileItem.vue b/packages/ui/src/components/servers/files/explorer/FileItem.vue
index 671743e6fe..aabeb8a897 100644
--- a/packages/ui/src/components/servers/files/explorer/FileItem.vue
+++ b/packages/ui/src/components/servers/files/explorer/FileItem.vue
@@ -81,6 +81,7 @@ import {
getFileExtensionIcon,
isEditableFile as isEditableFileExt,
isImageFile,
+ useFormatDateTime,
} from '@modrinth/ui'
import { computed, ref, shallowRef } from 'vue'
import { useRoute, useRouter } from 'vue-router'
@@ -123,6 +124,14 @@ const units = Object.freeze(['B', 'KB', 'MB', 'GB', 'TB', 'PB', 'EB'])
const route = shallowRef(useRoute())
const router = useRouter()
+const formatDateTime = useFormatDateTime({
+ year: '2-digit',
+ month: '2-digit',
+ day: '2-digit',
+ hour: 'numeric',
+ minute: 'numeric',
+})
+
const containerClasses = computed(() => [
'group m-0 flex w-full select-none items-center justify-between overflow-hidden border-0 border-t border-solid border-surface-3 px-4 py-3 focus:!outline-none',
props.selected ? 'bg-surface-3' : props.index % 2 === 0 ? 'bg-surface-2' : 'file-row-alt',
@@ -179,28 +188,12 @@ const iconComponent = computed(() => {
const formattedModifiedDate = computed(() => {
const date = new Date(props.modified * 1000)
- return `${date.toLocaleDateString('en-US', {
- month: '2-digit',
- day: '2-digit',
- year: '2-digit',
- })}, ${date.toLocaleTimeString('en-US', {
- hour: 'numeric',
- minute: 'numeric',
- hour12: true,
- })}`
+ return formatDateTime(date)
})
const formattedCreationDate = computed(() => {
const date = new Date(props.created * 1000)
- return `${date.toLocaleDateString('en-US', {
- month: '2-digit',
- day: '2-digit',
- year: '2-digit',
- })}, ${date.toLocaleTimeString('en-US', {
- hour: 'numeric',
- minute: 'numeric',
- hour12: true,
- })}`
+ return formatDateTime(date)
})
const isEditableFile = computed(() => {
diff --git a/packages/ui/src/composables/format-date-time.ts b/packages/ui/src/composables/format-date-time.ts
new file mode 100644
index 0000000000..6875ac27cc
--- /dev/null
+++ b/packages/ui/src/composables/format-date-time.ts
@@ -0,0 +1,38 @@
+import { LRUCache } from 'lru-cache'
+
+import { injectI18n } from '../providers/i18n'
+
+const formatterCache = new LRUCache({ max: 40 })
+
+export function useFormatDateTime(options?: Intl.DateTimeFormatOptions) {
+ const { locale } = injectI18n()
+
+ const formatter = getFormatter(locale.value, options)
+
+ function format(date?: Date | number | string): string {
+ if (typeof date === 'number' || typeof date === 'string') {
+ date = new Date(date)
+ }
+ return formatter!.format(date)
+ }
+
+ return format
+}
+
+function getFormatter(locale: string, options?: Intl.DateTimeFormatOptions): Intl.DateTimeFormat {
+ let cacheKey = locale
+ if (options) {
+ const entries = Object.entries(options)
+ .filter(([, value]) => value !== undefined)
+ .sort()
+ .map(([key, value]) => `${key}=${value}`)
+ cacheKey = [locale, ...entries].join(':')
+ }
+
+ let formatter = formatterCache.get(cacheKey)
+ if (!formatter) {
+ formatter = new Intl.DateTimeFormat(locale, options)
+ formatterCache.set(cacheKey, formatter)
+ }
+ return formatter
+}
diff --git a/packages/ui/src/composables/format-money.ts b/packages/ui/src/composables/format-money.ts
new file mode 100644
index 0000000000..30abe6a412
--- /dev/null
+++ b/packages/ui/src/composables/format-money.ts
@@ -0,0 +1,78 @@
+import { LRUCache } from 'lru-cache'
+
+import { injectI18n } from '../providers/i18n'
+
+const formatterCache = new LRUCache({ max: 10 })
+const maxDigitsCache = new LRUCache({ max: 10 })
+
+// `formatMoney(1234.56, 'USD')` → `$1,234.56`
+export function useFormatMoney() {
+ const { locale } = injectI18n()
+
+ function format(number: number, currency = 'USD'): string {
+ try {
+ const formatter = getFormatter(locale.value, currency)
+ return formatter!.format(number)
+ } catch {
+ return `${currency} ${number.toFixed(2)}`
+ }
+ }
+
+ return format
+}
+
+// `formatPrice(123456, 'USD')` → `$1,234.56`
+export function useFormatPrice() {
+ const { locale } = injectI18n()
+
+ function format(price: number, currency: string, trimZeros = false): string {
+ const maxDigits = getMaxDigits(currency)
+ const convertedPrice = price / Math.pow(10, maxDigits)
+
+ const minimumFractionDigits = trimZeros && Number.isInteger(convertedPrice) ? 0 : undefined
+
+ try {
+ const formatter = getFormatter(locale.value, currency, minimumFractionDigits)
+ return formatter.format(convertedPrice)
+ } catch {
+ return `${currency} ${convertedPrice}`
+ }
+ }
+
+ return format
+}
+
+function getFormatter(
+ locale: string,
+ currency: string,
+ minimumFractionDigits?: number,
+): Intl.NumberFormat {
+ const cacheKey = `${locale}:${currency}:${minimumFractionDigits}`
+ let formatter = formatterCache.get(cacheKey)
+ if (!formatter) {
+ formatter = new Intl.NumberFormat(locale, {
+ style: 'currency',
+ currency,
+ minimumFractionDigits,
+ })
+ formatterCache.set(cacheKey, formatter)
+ }
+ return formatter
+}
+
+function getMaxDigits(currency: string): number {
+ let maxDigits = maxDigitsCache.get(currency)
+ if (!maxDigits) {
+ try {
+ const formatter = new Intl.NumberFormat(undefined, {
+ style: 'currency',
+ currency,
+ })
+ maxDigits = formatter.resolvedOptions().maximumFractionDigits || 2
+ } catch {
+ maxDigits = 2
+ }
+ maxDigitsCache.set(currency, maxDigits)
+ }
+ return maxDigits
+}
diff --git a/packages/ui/src/composables/format-number.ts b/packages/ui/src/composables/format-number.ts
new file mode 100644
index 0000000000..c5cca33ee9
--- /dev/null
+++ b/packages/ui/src/composables/format-number.ts
@@ -0,0 +1,75 @@
+import { LRUCache } from 'lru-cache'
+
+import { injectI18n } from '../providers/i18n'
+
+const formatterCache = new LRUCache({ max: 15 })
+
+// `formatNumber(1234567)` → `1,234,567`
+export function useFormatNumber() {
+ const { locale } = injectI18n()
+
+ const formatter = getStandardFormatter(locale.value)
+
+ function format(value: number | bigint): string {
+ return formatter!.format(value)
+ }
+
+ return format
+}
+
+// `formatCompactNumber(1234567)` → `1.23M`
+//
+// Use `formatCompactNumberPlural` over `{(here!), plural, one {...} other {...}}`
+export function useCompactNumber() {
+ const { locale } = injectI18n()
+ const currentLocale = locale.value
+
+ const standardFormatter = getStandardFormatter(currentLocale)
+ const oneDigitCompactFormatter = getCompactFormatter(currentLocale, 1)
+ const twoDigitsCompactFormatter = getCompactFormatter(currentLocale, 2)
+
+ function formatCompactNumber(value: number | bigint): string {
+ if (value < 10_000) {
+ return standardFormatter!.format(value)
+ }
+ if (value < 1_000_000) {
+ return oneDigitCompactFormatter!.format(value)
+ }
+ return twoDigitsCompactFormatter!.format(value)
+ }
+
+ function formatCompactNumberPlural(value: number | bigint): string {
+ if (value < 10_000) {
+ return value.toString()
+ }
+ if (value < 1_000_000) {
+ return oneDigitCompactFormatter!.format(value)
+ }
+ return twoDigitsCompactFormatter!.format(value)
+ }
+
+ return { formatCompactNumber, formatCompactNumberPlural }
+}
+
+function getStandardFormatter(locale: string): Intl.NumberFormat {
+ const cacheKey = `${locale}:standard`
+ let formatter = formatterCache.get(cacheKey)
+ if (!formatter) {
+ formatter = new Intl.NumberFormat(locale)
+ formatterCache.set(cacheKey, formatter)
+ }
+ return formatter
+}
+
+function getCompactFormatter(locale: string, maximumFractionDigits: number): Intl.NumberFormat {
+ const cacheKey = `${locale}:compact:${maximumFractionDigits}`
+ let formatter = formatterCache.get(cacheKey)
+ if (!formatter) {
+ formatter = new Intl.NumberFormat(locale, {
+ notation: 'compact',
+ maximumFractionDigits,
+ })
+ formatterCache.set(cacheKey, formatter)
+ }
+ return formatter
+}
diff --git a/packages/ui/src/composables/how-ago.ts b/packages/ui/src/composables/how-ago.ts
index fa8efecb0f..85b6586504 100644
--- a/packages/ui/src/composables/how-ago.ts
+++ b/packages/ui/src/composables/how-ago.ts
@@ -1,32 +1,16 @@
-import { computed, type ComputedRef } from 'vue'
+import { LRUCache } from 'lru-cache'
import { injectI18n } from '../providers/i18n'
import { LOCALES } from './i18n.ts'
-export type Formatter = (value: Date | number | null | undefined, options?: FormatOptions) => string
+const formatterCache = new LRUCache({ max: 5 })
-export interface FormatOptions {
- roundingMode?: 'halfExpand' | 'floor' | 'ceil'
-}
-
-const formatters = new Map>()
-
-export function useRelativeTime(): Formatter {
+export function useRelativeTime() {
const { locale } = injectI18n()
- const formatterRef = computed(() => {
- const localeDefinition = LOCALES.find((loc) => loc.code === locale.value)
- return new Intl.RelativeTimeFormat(locale.value, {
- numeric: localeDefinition?.numeric || 'auto',
- style: 'long',
- })
- })
-
- if (!formatters.has(locale.value)) {
- formatters.set(locale.value, formatterRef)
- }
+ const rtf = getFormatter(locale.value)
- return (value: Date | number | null | undefined) => {
+ return (value: Date | number | string | null | undefined) => {
if (value == null) {
return ''
}
@@ -47,8 +31,6 @@ export function useRelativeTime(): Formatter {
const months = Math.round(diff / 2629746000)
const years = Math.round(diff / 31556952000)
- const rtf = formatterRef.value
-
if (Math.abs(seconds) < 60) {
return rtf.format(seconds, 'second')
} else if (Math.abs(minutes) < 60) {
@@ -66,3 +48,16 @@ export function useRelativeTime(): Formatter {
}
}
}
+
+function getFormatter(locale: string): Intl.RelativeTimeFormat {
+ let formatter = formatterCache.get(locale)
+ if (!formatter) {
+ const localeDefinition = LOCALES.find((loc) => loc.code === locale)
+ formatter = new Intl.RelativeTimeFormat(locale, {
+ numeric: localeDefinition?.numeric || 'auto',
+ style: 'long',
+ })
+ formatterCache.set(locale, formatter)
+ }
+ return formatter
+}
diff --git a/packages/ui/src/composables/index.ts b/packages/ui/src/composables/index.ts
index 69dd6ecf50..ab737b8240 100644
--- a/packages/ui/src/composables/index.ts
+++ b/packages/ui/src/composables/index.ts
@@ -1,5 +1,8 @@
export * from './debug-logger'
export * from './dynamic-font-size'
+export * from './format-date-time'
+export * from './format-money'
+export * from './format-number'
export * from './how-ago'
export * from './i18n'
export * from './i18n-debug'
diff --git a/packages/ui/src/locales/en-US/index.json b/packages/ui/src/locales/en-US/index.json
index 37502c870d..1d19bdfda2 100644
--- a/packages/ui/src/locales/en-US/index.json
+++ b/packages/ui/src/locales/en-US/index.json
@@ -650,6 +650,12 @@
"payment-method.visa": {
"defaultMessage": "Visa"
},
+ "project-card.date.published.tooltip": {
+ "defaultMessage": "Published {date}"
+ },
+ "project-card.date.updated.tooltip": {
+ "defaultMessage": "Updated {date}"
+ },
"project-card.environment.client": {
"defaultMessage": "Client"
},
@@ -822,7 +828,7 @@
"defaultMessage": "Visit wiki"
},
"project.download-count-tooltip": {
- "defaultMessage": "{count} {count, plural, one {download} other {downloads}}"
+ "defaultMessage": "{count, number} {count, plural, one {download} other {downloads}}"
},
"project.environment.client-and-server.description": {
"defaultMessage": "Has some functionality on both the client and server, even if only partially."
@@ -891,7 +897,7 @@
"defaultMessage": "Unknown environment"
},
"project.follower-count-tooltip": {
- "defaultMessage": "{count} {count, plural, one {followers} other {followers}}"
+ "defaultMessage": "{count, number} {count, plural, one {follower} other {followers}}"
},
"project.settings.analytics.title": {
"defaultMessage": "Analytics"
@@ -1619,9 +1625,6 @@
"tag.loader.waterfall": {
"defaultMessage": "Waterfall"
},
- "tooltip.date-at-time": {
- "defaultMessage": "{date, date, long} at {time, time, short}"
- },
"ui.component.unsaved-changes-popup.body": {
"defaultMessage": "You have unsaved changes."
}
diff --git a/packages/ui/src/utils/common-messages.ts b/packages/ui/src/utils/common-messages.ts
index e2c1db7914..80c3a7aeb8 100644
--- a/packages/ui/src/utils/common-messages.ts
+++ b/packages/ui/src/utils/common-messages.ts
@@ -77,10 +77,6 @@ export const commonMessages = defineMessages({
id: 'label.dashboard',
defaultMessage: 'Dashboard',
},
- dateAtTimeTooltip: {
- id: 'tooltip.date-at-time',
- defaultMessage: '{date, date, long} at {time, time, short}',
- },
declineButton: {
id: 'button.decline',
defaultMessage: 'Decline',
@@ -387,11 +383,11 @@ export const commonMessages = defineMessages({
},
projectDownloads: {
id: 'project.download-count-tooltip',
- defaultMessage: '{count} {count, plural, one {download} other {downloads}}',
+ defaultMessage: '{count, number} {count, plural, one {download} other {downloads}}',
},
projectFollowers: {
id: 'project.follower-count-tooltip',
- defaultMessage: '{count} {count, plural, one {followers} other {followers}}',
+ defaultMessage: '{count, number} {count, plural, one {follower} other {followers}}',
},
})
diff --git a/packages/utils/billing.ts b/packages/utils/billing.ts
index f9f9f3ef75..71306036fb 100644
--- a/packages/utils/billing.ts
+++ b/packages/utils/billing.ts
@@ -60,29 +60,6 @@ export const getCurrency = (userCountry) => {
return countryCurrency[userCountry] ?? 'USD'
}
-export const formatPrice = (locale, price, currency, trimZeros = false) => {
- let formatter = new Intl.NumberFormat(locale, {
- style: 'currency',
- currency,
- })
-
- const maxDigits = formatter.resolvedOptions().maximumFractionDigits
- const convertedPrice = price / Math.pow(10, maxDigits)
-
- let minimumFractionDigits = maxDigits
-
- if (trimZeros && Number.isInteger(convertedPrice)) {
- minimumFractionDigits = 0
- }
-
- formatter = new Intl.NumberFormat(locale, {
- style: 'currency',
- currency,
- minimumFractionDigits,
- })
- return formatter.format(convertedPrice)
-}
-
export const calculateSavings = (monthlyPlan, plan, months = 12) => {
const monthlyAnnualized = monthlyPlan * months
diff --git a/packages/utils/utils.ts b/packages/utils/utils.ts
index 25147c456a..0741978df9 100644
--- a/packages/utils/utils.ts
+++ b/packages/utils/utils.ts
@@ -78,40 +78,6 @@ export const sortedCategories = (tags, formatCategoryName, locale) => {
})
}
-export const formatNumber = (number, abbreviate = true) => {
- const x = Number(number)
- if (x >= 1000000 && abbreviate) {
- return `${(x / 1000000).toFixed(2).toString()}M`
- } else if (x >= 10000 && abbreviate) {
- return `${(x / 1000).toFixed(1).toString()}k`
- }
- return x.toString().replace(/\B(?=(\d{3})+(?!\d))/g, ',')
-}
-
-export function formatDate(
- date: dayjs.Dayjs,
- options: Intl.DateTimeFormatOptions = {
- month: 'long',
- day: 'numeric',
- year: 'numeric',
- },
-): string {
- return date.toDate().toLocaleDateString(undefined, options)
-}
-
-export function formatMoney(number, abbreviate = false) {
- const x = Number(number)
- if (x >= 1000000 && abbreviate) {
- return `$${(x / 1000000).toFixed(2).toString()}M`
- } else if (x >= 10000 && abbreviate) {
- return `$${(x / 1000).toFixed(2).toString()}k`
- }
- return `$${x
- .toFixed(2)
- .toString()
- .replace(/\B(?=(\d{3})+(?!\d))/g, ',')}`
-}
-
export const formatBytes = (bytes, decimals = 2) => {
if (bytes === 0) return '0 Bytes'