Impove Intl formatting (#5372)

* Improve Intl formatting

* Additional fixes

* Fixed formatters were not updated on locale change

* Fixed formatNumber was not updated on locale change

* Additional formatting and fixes after merge

* Run prepr:frontend

* Remove `'` in icon map

* Run `pnpm install`

* fix: lint + import

* Additional fixes

---------

Co-authored-by: Calum H. <calum@modrinth.com>
Co-authored-by: Calum H. (IMB11) <contact@cal.engineer>
This commit is contained in:
Jerozgen
2026-03-10 00:29:32 +03:00
committed by GitHub
parent 9b2f0c88cd
commit f62c60a681
88 changed files with 839 additions and 621 deletions

View File

@@ -1,12 +1,11 @@
<script setup lang="ts">
import { SpinnerIcon } from '@modrinth/assets'
import { formatPrice } from '@modrinth/utils'
import { computed } from 'vue'
import { useVIntl } from '../../composables/i18n'
import { useFormatPrice } from '../../composables'
import Accordion from '../base/Accordion.vue'
const { locale } = useVIntl()
const formatPrice = useFormatPrice()
export type BillingItem = {
title: string
@@ -38,7 +37,7 @@ const periodSuffix = computed(() => {
<template v-if="loading">
<SpinnerIcon class="animate-spin size-4" />
</template>
<template v-else> {{ formatPrice(locale, total, currency) }} </template
<template v-else> {{ formatPrice(total, currency) }} </template
><span class="text-xs text-secondary">{{ periodSuffix }}</span>
</span>
</div>
@@ -57,7 +56,7 @@ const periodSuffix = computed(() => {
<template v-if="loading">
<SpinnerIcon class="animate-spin size-4" />
</template>
<template v-else> {{ formatPrice(locale, amount, currency) }} </template
<template v-else> {{ formatPrice(amount, currency) }} </template
><span class="text-xs text-secondary">{{ periodSuffix }}</span>
</div>
</div>

View File

@@ -1,10 +1,10 @@
<script setup lang="ts">
import type { Labrinth } from '@modrinth/api-client'
import { InfoIcon } from '@modrinth/assets'
import { formatPrice } from '@modrinth/utils'
import { Menu } from 'floating-vue'
import { computed, inject, type Ref } from 'vue'
import { useFormatPrice } from '../../composables'
import { type MessageDescriptor, useVIntl } from '../../composables/i18n'
import { getPriceForInterval, monthsInInterval } from '../../utils/product-utils'
import type { ServerBillingInterval } from './ModrinthServersPurchaseModal.vue'
@@ -30,7 +30,8 @@ const emit = defineEmits<{
(e: 'select', plan: Labrinth.Billing.Internal.Product): void
}>()
const { formatMessage, locale } = useVIntl()
const { formatMessage } = useVIntl()
const formatPrice = useFormatPrice()
// TODO: Use DI framework when merged.
const selectedInterval = inject<Ref<ServerBillingInterval>>('selectedInterval')
@@ -101,7 +102,7 @@ const mostPopularStyle = computed(() => {
</div>
</div>
<span class="m-0 text-lg font-bold text-contrast">
{{ formatPrice(locale, perMonth, currency, true) }}
{{ formatPrice(perMonth, currency, true) }}
<span class="text-sm font-semibold text-secondary">
/ month{{ selectedInterval !== 'monthly' ? `, billed ${selectedInterval}` : '' }}
</span>

View File

@@ -232,7 +232,7 @@
}}%
</span>
<span class="ml-auto text-lg" :class="{ 'text-secondary': selectedPlan !== interval }">
{{ formatPrice(locale, rawPrice, price.currency_code) }}
{{ formatPrice(rawPrice, price.currency_code) }}
</span>
</div>
</div>
@@ -240,7 +240,7 @@
<span class="text-xl text-secondary">Total</span>
<div class="flex items-baseline gap-2">
<span class="text-2xl font-extrabold text-primary">
{{ formatPrice(locale, price.prices.intervals[selectedPlan], price.currency_code) }}
{{ formatPrice(price.prices.intervals[selectedPlan], price.currency_code) }}
</span>
<span class="text-lg text-secondary">/ {{ selectedPlan }}</span>
</div>
@@ -304,23 +304,21 @@
}}
</span>
<span v-if="existingPlan" class="text-secondary text-end">
{{ formatPrice(locale, total - tax, price.currency_code) }}
{{ formatPrice(total - tax, price.currency_code) }}
</span>
<span v-else class="text-secondary text-end">
{{ formatPrice(locale, total - tax, price.currency_code) }} /
{{ formatPrice(total - tax, price.currency_code) }} /
{{ selectedPlan }}
</span>
</div>
<div class="flex justify-between">
<span class="text-secondary">Tax</span>
<span class="text-secondary text-end">{{
formatPrice(locale, tax, price.currency_code)
}}</span>
<span class="text-secondary text-end">{{ formatPrice(tax, price.currency_code) }}</span>
</div>
<div class="mt-4 flex justify-between border-0 border-t border-solid border-code-bg pt-4">
<span class="text-lg font-bold">Today's total</span>
<span class="text-lg font-extrabold text-primary text-end">
{{ formatPrice(locale, total, price.currency_code) }}
{{ formatPrice(total, price.currency_code) }}
</span>
</div>
</div>
@@ -416,9 +414,9 @@
<strong>By clicking "Subscribe", you are purchasing a recurring subscription.</strong>
<br />
You'll be charged
{{ formatPrice(locale, price.prices.intervals[selectedPlan], price.currency_code) }}
{{ formatPrice(price.prices.intervals[selectedPlan], price.currency_code) }}
/ {{ selectedPlan }} plus applicable taxes starting
{{ existingPlan ? dayjs(renewalDate).format('MMMM D, YYYY') : 'today' }}, until you cancel.
{{ existingPlan ? formatDate(renewalDate) : 'today' }}, until you cancel.
<br />
You can cancel anytime from your settings page.
</p>
@@ -545,12 +543,13 @@ import {
UnknownIcon,
XIcon,
} from '@modrinth/assets'
import { calculateSavings, createStripeElements, formatPrice, getCurrency } from '@modrinth/utils'
import { calculateSavings, createStripeElements, getCurrency } from '@modrinth/utils'
import dayjs from 'dayjs'
import { computed, nextTick, reactive, ref, watch } from 'vue'
import { Multiselect } from 'vue-multiselect'
import { useVIntl } from '../../composables/i18n'
import { useFormatDateTime, useFormatPrice } from '../../composables/index.ts'
import { paymentMethodMessages } from '../../utils/common-messages'
import Admonition from '../base/Admonition.vue'
import Checkbox from '../base/Checkbox.vue'
@@ -560,7 +559,9 @@ import AnimatedLogo from '../brand/AnimatedLogo.vue'
import NewModal from '../modal/NewModal.vue'
import LoaderIcon from '../servers/icons/LoaderIcon.vue'
const { locale, formatMessage } = useVIntl()
const { formatMessage } = useVIntl()
const formatPrice = useFormatPrice()
const formatDate = useFormatDateTime({ dateStyle: 'long' })
const props = defineProps({
product: {

View File

@@ -1,15 +1,16 @@
<script setup lang="ts">
import type { Labrinth } from '@modrinth/api-client'
import { formatPrice } from '@modrinth/utils'
import { computed, provide } from 'vue'
import { useFormatPrice } from '../../composables'
import { defineMessages, useVIntl } from '../../composables/i18n'
import { getPriceForInterval, monthsInInterval } from '../../utils/product-utils'
import OptionGroup from '../base/OptionGroup.vue'
import ModalBasedServerPlan from './ModalBasedServerPlan.vue'
import type { ServerBillingInterval } from './ModrinthServersPurchaseModal.vue'
const { formatMessage, locale } = useVIntl()
const { formatMessage } = useVIntl()
const formatPrice = useFormatPrice()
const props = defineProps<{
availableProducts: Labrinth.Billing.Internal.Product[]
@@ -209,7 +210,7 @@ provide('selectedInterval', selectedInterval)
<span class="text-2xl font-semibold text-contrast">Custom</span>
</div>
<span class="m-0 text-lg font-bold text-contrast">
{{ formatPrice(locale, customStartingPrice, currency, true) }}
{{ formatPrice(customStartingPrice, currency, true) }}
<span class="text-sm font-semibold text-secondary">
/ month<template v-if="selectedInterval !== 'monthly'"
>, billed {{ selectedInterval }}</template
@@ -221,7 +222,7 @@ provide('selectedInterval', selectedInterval)
<div class="flex flex-col gap-2">
<div class="flex items-center gap-3">
<span v-if="customPricePerGb" class="text-sm text-secondary">
From {{ formatPrice(locale, customPricePerGb, currency, true) }} / GB
From {{ formatPrice(customPricePerGb, currency, true) }} / GB
</span>
</div>
</div>

View File

@@ -3,7 +3,7 @@ import type { Archon, Labrinth } from '@modrinth/api-client'
import { InfoIcon, SpinnerIcon, XIcon } from '@modrinth/assets'
import { computed, onMounted, ref, watch } from 'vue'
import { formatPrice } from '../../../../utils'
import { useFormatPrice } from '../../composables'
import { defineMessages, useVIntl } from '../../composables/i18n'
import { getPriceForInterval, monthsInInterval } from '../../utils/product-utils.ts'
import { regionOverrides } from '../../utils/regions.ts'
@@ -14,7 +14,8 @@ import type { RegionPing, ServerBillingInterval } from './ModrinthServersPurchas
import ServersRegionButton from './ServersRegionButton.vue'
import ServersSpecs from './ServersSpecs.vue'
const { formatMessage, locale } = useVIntl()
const { formatMessage } = useVIntl()
const formatPrice = useFormatPrice()
const props = defineProps<{
regions: Archon.Servers.v1.Region[]
@@ -283,7 +284,7 @@ onMounted(() => {
<Slider v-model="selectedRam" :min="minRam" :max="maxRam" :step="2" unit="GB" />
<p v-if="selectedPrice" class="mt-2 mb-0">
<span class="text-contrast text-lg font-bold"
>{{ formatPrice(locale, selectedPrice, currency, true) }} / month</span
>{{ formatPrice(selectedPrice, currency, true) }} / month</span
><span v-if="interval !== 'monthly'">, billed {{ interval }}</span>
</p>
<div class="bg-bg rounded-xl p-4 mt-2 text-secondary h-14">

View File

@@ -10,11 +10,12 @@ import {
SpinnerIcon,
XIcon,
} from '@modrinth/assets'
import { formatPrice, getPingLevel } from '@modrinth/utils'
import { getPingLevel } from '@modrinth/utils'
import dayjs from 'dayjs'
import type Stripe from 'stripe'
import { computed } from 'vue'
import { useFormatPrice } from '../../composables'
import { useVIntl } from '../../composables/i18n'
import { getPriceForInterval, monthsInInterval } from '../../utils/product-utils'
import { regionOverrides } from '../../utils/regions'
@@ -27,8 +28,8 @@ import FormattedPaymentMethod from './FormattedPaymentMethod.vue'
import type { ServerBillingInterval } from './ModrinthServersPurchaseModal.vue'
import ServersSpecs from './ServersSpecs.vue'
const vintl = useVIntl()
const { locale, formatMessage } = vintl
const { formatMessage } = useVIntl()
const formatPrice = useFormatPrice()
const emit = defineEmits<{
(e: 'changePaymentMethod' | 'reloadPaymentIntent'): void
@@ -246,7 +247,7 @@ function setInterval(newInterval: ServerBillingInterval) {
>Pay monthly</span
>
<span class="text-sm text-secondary flex items-center gap-1"
>{{ formatPrice(locale, monthlyPrice, currency, true) }} / month</span
>{{ formatPrice(monthlyPrice, currency, true) }} / month</span
>
</div>
</button>
@@ -268,17 +269,10 @@ function setInterval(newInterval: ServerBillingInterval) {
>{{ interval === 'quarterly' ? 'Saving' : 'Save' }} 16%</span
></span
>
<span class="text-sm text-secondary flex items-center gap-1"
>{{
formatPrice(
locale,
(quarterlyPrice ?? 0) / monthsInInterval['quarterly'],
currency,
true,
)
}}
/ month</span
>
<span class="text-sm text-secondary flex items-center gap-1">
{{ formatPrice((quarterlyPrice ?? 0) / monthsInInterval['quarterly'], currency, true) }} /
month
</span>
</div>
</button>
</div>
@@ -346,14 +340,14 @@ function setInterval(newInterval: ServerBillingInterval) {
Today, you will be charged a prorated amount for the remainder of your current billing cycle.
<br />
Your subscription will renew at
{{ formatPrice(locale, selectedPlanPriceForInterval, currency) }} / {{ period }} plus
applicable taxes at the end of your current billing interval, until you cancel. You can cancel
anytime from your settings page.
{{ formatPrice(selectedPlanPriceForInterval, currency) }} / {{ period }} plus applicable taxes
at the end of your current billing interval, until you cancel. You can cancel anytime from
your settings page.
</template>
<template v-else>
You'll be charged
<SpinnerIcon v-if="loading" class="animate-spin relative top-0.5 mx-2" /><template v-else>{{
formatPrice(locale, total, currency)
formatPrice(total, currency)
}}</template>
every {{ period }} plus applicable taxes starting today, until you cancel. You can cancel
anytime from your settings page.

View File

@@ -47,12 +47,19 @@ import type { VersionEntry } from '@modrinth/utils/changelog'
import dayjs from 'dayjs'
import { computed, ref } from 'vue'
import { useRelativeTime } from '../../composables'
import { useFormatDateTime, useRelativeTime } from '../../composables'
import { defineMessages, useVIntl } from '../../composables/i18n'
import AutoLink from '../base/AutoLink.vue'
const { formatMessage } = useVIntl()
const formatRelativeTime = useRelativeTime()
const formatDateTime = useFormatDateTime({
timeStyle: 'short',
dateStyle: 'long',
})
const formatDate = useFormatDateTime({
dateStyle: 'long',
})
const props = withDefaults(
defineProps<{
@@ -71,10 +78,10 @@ const props = withDefaults(
const currentDate = ref(dayjs())
const recent = computed(() => props.entry.date.isAfter(currentDate.value.subtract(1, 'week')))
const future = computed(() => props.entry.date.isAfter(currentDate.value))
const dateTooltip = computed(() => props.entry.date.format('MMMM D, YYYY [at] h:mm A'))
const dateTooltip = computed(() => formatDateTime(props.entry.date.toDate()))
const relativeDate = computed(() => formatRelativeTime(props.entry.date.toISOString()))
const longDate = computed(() => props.entry.date.format('MMMM D, YYYY'))
const relativeDate = computed(() => formatRelativeTime(props.entry.date.toDate()))
const longDate = computed(() => formatDate(props.entry.date.toDate()))
const versionName = computed(() => props.entry.version ?? longDate.value)
const messages = defineMessages({

View File

@@ -1,13 +1,14 @@
<!-- eslint-disable no-console -->
<script setup>
import { formatNumber } from '@modrinth/utils'
import dayjs from 'dayjs'
import { defineAsyncComponent, ref } from 'vue'
import { useFormatNumber } from '../../composables/index.ts'
import Button from '../base/Button.vue'
import Checkbox from '../base/Checkbox.vue'
const VueApexCharts = defineAsyncComponent(() => import('vue3-apexcharts'))
const formatNumber = useFormatNumber()
const props = defineProps({
name: {
@@ -149,7 +150,7 @@ const chartOptions = ref({
!props.hideTotal
? `<div class="value">
${props.prefix}
${formatNumber(series.reduce((a, b) => a + b[dataPointIndex], 0).toString(), false)}
${formatNumber(series.reduce((a, b) => a + b[dataPointIndex], 0).toString())}
${props.suffix}
</div>`
: ``
@@ -163,7 +164,7 @@ const chartOptions = ref({
</div>
<div class="value">
${props.prefix}
${formatNumber(value[dataPointIndex], false)}
${formatNumber(value[dataPointIndex])}
${props.suffix}
</div>
</div>`

View File

@@ -1,13 +1,15 @@
<!-- eslint-disable eslint-comments/require-description -->
<script setup>
import { formatNumber } from '@modrinth/utils'
import dayjs from 'dayjs'
import { defineAsyncComponent, ref } from 'vue'
import { useFormatNumber } from '../../composables/index.ts'
import Card from '../base/Card.vue'
const VueApexCharts = defineAsyncComponent(() => import('vue3-apexcharts'))
const formatNumber = useFormatNumber()
const props = defineProps({
value: {
type: String,
@@ -124,7 +126,7 @@ const chartOptions = ref({
</div>
<div class="value">
${props.prefix}
${formatNumber(value[dataPointIndex], false)}
${formatNumber(value[dataPointIndex])}
${props.suffix}
</div>
</div>`

View File

@@ -1,8 +1,9 @@
<script setup lang="ts">
import dayjs from 'dayjs'
import { useFormatDateTime } from '../../composables'
import AutoLink from '../base/AutoLink.vue'
const formatDate = useFormatDateTime({ dateStyle: 'long' })
export interface Article {
path: string
thumbnail: string
@@ -34,7 +35,7 @@ defineProps<{
{{ article.summary }}
</p>
<div class="mt-auto text-sm text-secondary">
{{ dayjs(article.date).format('MMMM D, YYYY') }}
{{ formatDate(article.date) }}
</div>
</div>
</article>

View File

@@ -10,6 +10,7 @@ import {
import { computed, getCurrentInstance } from 'vue'
import type { RouteLocationRaw } from 'vue-router'
import { useCompactNumber } from '../../composables'
import { useRelativeTime } from '../../composables/how-ago'
import { defineMessages, useVIntl } from '../../composables/i18n'
import { commonMessages } from '../../utils/common-messages'
@@ -66,11 +67,7 @@ const hasContentListener = computed(() => typeof instance?.vnode.props?.onConten
const hasUnlinkListener = computed(() => typeof instance?.vnode.props?.onUnlink === 'function')
const formatTimeAgo = useRelativeTime()
const formatCompact = (n: number | undefined) => {
if (n === undefined) return ''
return new Intl.NumberFormat('en', { notation: 'compact', maximumFractionDigits: 2 }).format(n)
}
const { formatCompactNumber } = useCompactNumber()
</script>
<template>
@@ -165,12 +162,12 @@ const formatCompact = (n: number | undefined) => {
<div class="flex flex-wrap items-center gap-3">
<div v-if="project.downloads !== undefined" class="flex items-center gap-2 text-secondary">
<DownloadIcon class="size-5" />
<span class="font-medium">{{ formatCompact(project.downloads) }}</span>
<span class="font-medium">{{ formatCompactNumber(project.downloads) }}</span>
</div>
<div v-if="project.followers !== undefined" class="flex items-center gap-2 text-secondary">
<HeartIcon class="size-5" />
<span class="font-medium">{{ formatCompact(project.followers) }}</span>
<span class="font-medium">{{ formatCompactNumber(project.followers) }}</span>
</div>
<div v-if="categories?.length" class="flex flex-wrap gap-2">

View File

@@ -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 {

View File

@@ -50,7 +50,7 @@ const messages = defineMessages({
},
searchPlaceholder: {
id: 'instances.modpack-content-modal.search-placeholder',
defaultMessage: 'Search {count} projects',
defaultMessage: 'Search {count, number} {count, plural, one {project} other {projects}}',
},
loading: {
id: 'instances.modpack-content-modal.loading',
@@ -82,7 +82,7 @@ const messages = defineMessages({
},
selectedCount: {
id: 'instances.modpack-content-modal.selected-count',
defaultMessage: '{count} selected',
defaultMessage: '{count, number} selected',
},
enable: {
id: 'instances.modpack-content-modal.enable',

View File

@@ -27,20 +27,20 @@
v-tooltip="
capitalizeString(
formatMessage(commonMessages.projectDownloads, {
count: formatNumber(project.downloads, false),
count: project.downloads,
}),
)
"
class="flex items-center gap-2 font-semibold cursor-help"
>
<DownloadIcon class="h-6 w-6 text-secondary" />
{{ formatNumber(project.downloads) }}
{{ formatCompactNumber(project.downloads) }}
</div>
<div
v-tooltip="
capitalizeString(
formatMessage(commonMessages.projectFollowers, {
count: formatNumber(project.followers, false),
count: project.followers,
}),
)
"
@@ -49,7 +49,7 @@
>
<HeartIcon class="h-6 w-6 text-secondary" />
<span class="font-semibold">
{{ formatNumber(project.followers) }}
{{ formatCompactNumber(project.followers) }}
</span>
</div>
</template>
@@ -74,11 +74,11 @@
<script setup lang="ts">
import type { Labrinth } from '@modrinth/api-client'
import { DownloadIcon, HeartIcon } from '@modrinth/assets'
import { capitalizeString, formatNumber, type Project } from '@modrinth/utils'
import { capitalizeString, type Project } from '@modrinth/utils'
import { computed } from 'vue'
import { useRouter } from 'vue-router'
import { useVIntl } from '../../composables'
import { useCompactNumber, useVIntl } from '../../composables'
import { commonMessages } from '../../utils'
import Avatar from '../base/Avatar.vue'
import ContentPageHeader from '../base/ContentPageHeader.vue'
@@ -89,6 +89,7 @@ import ServerDetails from './server/ServerDetails.vue'
const router = useRouter()
const { formatMessage } = useVIntl()
const { formatCompactNumber } = useCompactNumber()
const props = withDefaults(
defineProps<{

View File

@@ -171,12 +171,7 @@
class="flex flex-col justify-center gap-1 max-sm:flex-row max-sm:justify-start max-sm:gap-3 xl:contents"
>
<div
v-tooltip="
formatMessage(commonMessages.dateAtTimeTooltip, {
date: new Date(version.date_published),
time: new Date(version.date_published),
})
"
v-tooltip="formatDateTime(version.date_published)"
class="z-[1] flex cursor-help items-center gap-1 text-nowrap font-medium xl:self-center"
>
<CalendarIcon class="xl:hidden" />
@@ -186,7 +181,7 @@
class="pointer-events-none z-[1] flex items-center gap-1 font-medium xl:self-center"
>
<DownloadIcon class="xl:hidden" />
{{ formatNumber(version.downloads) }}
{{ formatCompactNumber(version.downloads) }}
</div>
</div>
</div>
@@ -227,12 +222,13 @@ import {
FormattedTag,
Pagination,
TagItem,
useCompactNumber,
useFormatDateTime,
VersionChannelIndicator,
VersionFilterControl,
} from '@modrinth/ui'
import {
formatBytes,
formatNumber,
formatVersionsForDisplay,
type GameVersionTag,
type Version,
@@ -242,11 +238,15 @@ import { useRoute, useRouter } from 'vue-router'
import { useRelativeTime } from '../../composables'
import { useVIntl } from '../../composables/i18n'
import { commonMessages } from '../../utils/common-messages'
import { getEnvironmentTags } from './settings/environment/environments'
const { formatMessage } = useVIntl()
const formatRelativeTime = useRelativeTime()
const { formatCompactNumber } = useCompactNumber()
const formatDateTime = useFormatDateTime({
timeStyle: 'short',
dateStyle: 'long',
})
type VersionWithDisplayUrlEnding = Version & {
displayUrlEnding: string

View File

@@ -36,10 +36,7 @@
{{ formatMessage(commonMessages.projectFollowers, { count: project.followers }) }}
</div>
</div>
<div
v-if="project.approved"
v-tooltip="dayjs(project.approved).format('MMMM D, YYYY [at] h:mm A')"
>
<div v-if="project.approved" v-tooltip="formatDateTime(project.approved)">
<CalendarIcon aria-hidden="true" />
<div>
{{
@@ -49,7 +46,7 @@
}}
</div>
</div>
<div v-else v-tooltip="dayjs(project.published).format('MMMM D, YYYY [at] h:mm A')">
<div v-else v-tooltip="formatDateTime(project.published)">
<CalendarIcon aria-hidden="true" />
<div>
{{
@@ -59,7 +56,7 @@
</div>
<div
v-if="project.status === 'processing' && project.queued"
v-tooltip="dayjs(project.queued).format('MMMM D, YYYY [at] h:mm A')"
v-tooltip="formatDateTime(project.queued)"
>
<ScaleIcon aria-hidden="true" />
<div>
@@ -70,10 +67,7 @@
}}
</div>
</div>
<div
v-if="hasVersions && project.updated"
v-tooltip="dayjs(project.updated).format('MMMM D, YYYY [at] h:mm A')"
>
<div v-if="hasVersions && project.updated" v-tooltip="formatDateTime(project.updated)">
<VersionIcon aria-hidden="true" />
<div>
{{
@@ -95,16 +89,19 @@ import {
VersionIcon,
} from '@modrinth/assets'
import { capitalizeString } from '@modrinth/utils'
import dayjs from 'dayjs'
import { computed } from 'vue'
import { useRelativeTime } from '../../composables'
import { useFormatDateTime, useRelativeTime } from '../../composables'
import { defineMessages, useVIntl } from '../../composables/i18n'
import { commonMessages } from '../../utils/common-messages'
import { IntlFormatted } from '../base'
const { formatMessage } = useVIntl()
const formatRelativeTime = useRelativeTime()
const formatDateTime = useFormatDateTime({
timeStyle: 'short',
dateStyle: 'long',
})
const props = defineProps<{
project: Labrinth.Projects.v2.Project

View File

@@ -1,36 +1,39 @@
<script setup lang="ts">
import { CalendarIcon, HistoryIcon } from '@modrinth/assets'
import { capitalizeString } from '@modrinth/utils'
import dayjs from 'dayjs'
import { computed } from 'vue'
import { useRelativeTime, useVIntl } from '../../../composables'
import { defineMessage, useFormatDateTime, useRelativeTime, useVIntl } from '../../../composables'
const { formatMessage } = useVIntl()
const formatRelativeTime = useRelativeTime()
const formatDateTime = useFormatDateTime({
timeStyle: 'short',
dateStyle: 'long',
})
const props = defineProps<{
date: Date
type: 'updated' | 'published'
}>()
const formattedDate = computed(() => dayjs(props.date).format('MMMM D, YYYY [at] h:mm A'))
const formattedDate = computed(() => formatDateTime(props.date))
const types = {
updated: {
icon: HistoryIcon,
tooltip: {
tooltip: defineMessage({
id: 'project-card.date.updated.tooltip',
defaultMessage: 'Updated {date}',
},
}),
},
published: {
icon: CalendarIcon,
tooltip: {
tooltip: defineMessage({
id: 'project-card.date.published.tooltip',
defaultMessage: 'Published {date}',
},
}),
},
}

View File

@@ -1,11 +1,12 @@
<script setup lang="ts">
import { DownloadIcon, HeartIcon } from '@modrinth/assets'
import { capitalizeString, formatNumber } from '../../../../../utils'
import { useVIntl } from '../../../composables'
import { capitalizeString } from '../../../../../utils'
import { useCompactNumber, useVIntl } from '../../../composables'
import { commonMessages } from '../../../utils'
const { formatMessage } = useVIntl()
const { formatCompactNumber } = useCompactNumber()
defineProps<{
downloads?: number
@@ -19,7 +20,7 @@ defineProps<{
v-tooltip="
capitalizeString(
formatMessage(commonMessages.projectDownloads, {
count: formatNumber(downloads, false),
count: downloads,
}),
)
"
@@ -27,7 +28,7 @@ defineProps<{
>
<DownloadIcon class="size-5 shrink-0" />
<span class="font-medium">
{{ formatNumber(downloads) }}
{{ formatCompactNumber(downloads) }}
</span>
</div>
<div
@@ -35,7 +36,7 @@ defineProps<{
v-tooltip="
capitalizeString(
formatMessage(commonMessages.projectFollowers, {
count: formatNumber(followers, false),
count: followers,
}),
)
"
@@ -43,7 +44,7 @@ defineProps<{
>
<HeartIcon class="size-5 shrink-0" />
<span class="font-medium">
{{ formatNumber(followers) }}
{{ formatCompactNumber(followers) }}
</span>
</div>
</template>

View File

@@ -1,12 +1,13 @@
<script setup lang="ts">
import { OnlineIndicatorIcon } from '@modrinth/assets'
import { formatNumber } from '../../../../../utils'
import { useVIntl } from '../../../composables'
import { useCompactNumber, useFormatNumber, useVIntl } from '../../../composables'
import { commonMessages } from '../../../utils'
import { StatItem } from '../../base'
const { formatMessage } = useVIntl()
const { formatCompactNumber, formatCompactNumberPlural } = useCompactNumber()
const formatNumber = useFormatNumber()
defineProps<{
online: number
@@ -16,7 +17,12 @@ defineProps<{
</script>
<template>
<StatItem
v-tooltip="`${formatNumber(online, true)} players online`"
v-tooltip="
formatMessage(commonMessages.projectOnlinePlayerCountTooltip, {
count: formatCompactNumber(online),
countPlural: formatCompactNumberPlural(online),
})
"
class="smart-clickable:allow-pointer-events w-max"
>
<OnlineIndicatorIcon
@@ -29,10 +35,8 @@ defineProps<{
/>
{{
hideLabel
? formatNumber(online, false)
: formatMessage(commonMessages.projectOnlinePlayerCount, {
count: formatNumber(online, false),
})
? formatNumber(online)
: formatMessage(commonMessages.projectOnlinePlayerCount, { count: online })
}}
</StatItem>
</template>

View File

@@ -12,7 +12,7 @@ const props = defineProps<{
const pingMessage = defineMessage({
id: 'project.server.ping.ms',
defaultMessage: '{ping} ms',
defaultMessage: '{ping, number} ms',
})
const { formatMessage } = useVIntl()

View File

@@ -1,12 +1,12 @@
<script setup lang="ts">
import { PlayIcon } from '@modrinth/assets'
import { formatNumber } from '../../../../../utils'
import { useVIntl } from '../../../composables'
import { useCompactNumber, useVIntl } from '../../../composables'
import { commonMessages } from '../../../utils'
import { StatItem } from '../../base'
const { formatMessage } = useVIntl()
const { formatCompactNumber, formatCompactNumberPlural } = useCompactNumber()
defineProps<{
recentPlays: number
@@ -16,16 +16,20 @@ defineProps<{
<template>
<StatItem
v-tooltip="
`${formatNumber(recentPlays, true)} recent play${recentPlays === 1 ? '' : 's'} from Modrinth in the past 2 weeks`
formatMessage(commonMessages.projectRecentPlaysTooltip, {
count: formatCompactNumber(recentPlays),
countPlural: formatCompactNumberPlural(recentPlays),
})
"
class="smart-clickable:allow-pointer-events w-max"
>
<PlayIcon />
{{
hideLabel
? formatNumber(recentPlays, true)
? formatCompactNumber(recentPlays)
: formatMessage(commonMessages.projectRecentPlays, {
count: formatNumber(recentPlays, true),
count: formatCompactNumber(recentPlays),
countPlural: formatCompactNumberPlural(recentPlays),
})
}}
</StatItem>

View File

@@ -122,9 +122,9 @@ import {
TriangleAlertIcon,
} from '@modrinth/assets'
import { useQuery } from '@tanstack/vue-query'
import dayjs from 'dayjs'
import { computed } from 'vue'
import { useFormatDateTime } from '../../composables'
import { injectModrinthClient } from '../../providers/api-client'
import Avatar from '../base/Avatar.vue'
import CopyCode from '../base/CopyCode.vue'
@@ -132,6 +132,8 @@ import ServersSpecs from '../billing/ServersSpecs.vue'
import ServerIcon from './icons/ServerIcon.vue'
import ServerInfoLabels from './labels/ServerInfoLabels.vue'
const formatDate = useFormatDateTime({ dateStyle: 'long' })
export type PendingChange = {
planSize: string
cpu: number
@@ -249,12 +251,4 @@ const { data: image } = useQuery({
})
const isConfiguring = computed(() => props.flows?.intro)
const formatDate = (d: unknown) => {
try {
return dayjs(d as string).format('MMMM D, YYYY')
} catch {
return ''
}
}
</script>

View File

@@ -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({
</template>
<template v-else>
<span class="w-full font-medium text-contrast md:text-center">
{{ dayjs(backup.created_at).format('MMMM Do YYYY, h:mm A') }}
{{ formatDateTime(backup.created_at) }}
</span>
<!-- TODO: Uncomment when API supports size field -->
<!-- <span class="text-secondary">{{ formatBytes(backup.size) }}</span> -->

View File

@@ -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(() => {

View File

@@ -0,0 +1,38 @@
import { LRUCache } from 'lru-cache'
import { injectI18n } from '../providers/i18n'
const formatterCache = new LRUCache<string, Intl.DateTimeFormat>({ max: 40 })
export function useFormatDateTime(options?: Intl.DateTimeFormatOptions) {
const { locale } = injectI18n()
function format(date?: Date | number | string): string {
if (typeof date === 'number' || typeof date === 'string') {
date = new Date(date)
}
const formatter = getFormatter(locale.value, options)
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
}

View File

@@ -0,0 +1,78 @@
import { LRUCache } from 'lru-cache'
import { injectI18n } from '../providers/i18n'
const formatterCache = new LRUCache<string, Intl.NumberFormat>({ max: 10 })
const maxDigitsCache = new LRUCache<string, number>({ 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
}

View File

@@ -0,0 +1,74 @@
import { LRUCache } from 'lru-cache'
import { injectI18n } from '../providers/i18n'
const formatterCache = new LRUCache<string, Intl.NumberFormat>({ max: 15 })
// `formatNumber(1234567)` → `1,234,567`
export function useFormatNumber() {
const { locale } = injectI18n()
function format(value: number | bigint): string {
const formatter = getStandardFormatter(locale.value)
return formatter!.format(value)
}
return format
}
// `formatCompactNumber(1234567)` → `1.23M`
//
// Use `formatCompactNumberPlural` over `{(here!), plural, one {...} other {...}}`
export function useCompactNumber() {
const { locale } = injectI18n()
function formatCompactNumber(value: number | bigint): string {
if (value < 10_000) {
const standardFormatter = getStandardFormatter(locale.value)
return standardFormatter.format(value)
}
if (value < 1_000_000) {
const oneDigitCompactFormatter = getCompactFormatter(locale.value, 1)
return oneDigitCompactFormatter.format(value)
}
const twoDigitsCompactFormatter = getCompactFormatter(locale.value, 2)
return twoDigitsCompactFormatter.format(value)
}
function formatCompactNumberPlural(value: number | bigint): string {
if (value < 10_000) {
return value.toString()
}
if (value < 1_000_000) {
const oneDigitCompactFormatter = getCompactFormatter(locale.value, 1)
return oneDigitCompactFormatter.format(value)
}
const twoDigitsCompactFormatter = getCompactFormatter(locale.value, 2)
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
}

View File

@@ -1,35 +1,14 @@
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 | string | undefined,
options?: FormatOptions,
) => string
const formatterCache = new LRUCache<string, Intl.RelativeTimeFormat>({ max: 5 })
export interface FormatOptions {
roundingMode?: 'halfExpand' | 'floor' | 'ceil'
}
const formatters = new Map<string, ComputedRef<Intl.RelativeTimeFormat>>()
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)
}
return (value: Date | number | null | string | undefined) => {
return (value: Date | number | string | null | undefined) => {
if (value == null) {
return ''
}
@@ -50,7 +29,7 @@ export function useRelativeTime(): Formatter {
const months = Math.round(diff / 2629746000)
const years = Math.round(diff / 31556952000)
const rtf = formatterRef.value
const rtf = getFormatter(locale.value)
if (Math.abs(seconds) < 60) {
return rtf.format(seconds, 'second')
@@ -69,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
}

View File

@@ -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'

View File

@@ -360,10 +360,10 @@
"defaultMessage": "No projects match your search."
},
"instances.modpack-content-modal.search-placeholder": {
"defaultMessage": "Search {count} projects"
"defaultMessage": "Search {count, number} {count, plural, one {project} other {projects}}"
},
"instances.modpack-content-modal.selected-count": {
"defaultMessage": "{count} selected"
"defaultMessage": "{count, number} selected"
},
"instances.updater-modal.badge.current": {
"defaultMessage": "Current"
@@ -818,6 +818,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"
},
@@ -1008,7 +1014,7 @@
"defaultMessage": "Server details"
},
"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": "Required on both the client and server."
@@ -1077,16 +1083,22 @@
"defaultMessage": "Unknown environment"
},
"project.follower-count-tooltip": {
"defaultMessage": "{count} {count, plural, one {follower} other {followers}}"
"defaultMessage": "{count, number} {count, plural, one {follower} other {followers}}"
},
"project.online-player-count": {
"defaultMessage": "{count} {count, plural, one {online} other {online}}"
"defaultMessage": "{count, number} online"
},
"project.online-player-count.tooltip": {
"defaultMessage": "{count} {countPlural, plural, one {player} other {players}} online"
},
"project.recent-plays": {
"defaultMessage": "{count} {count, plural, one {recent play} other {recent plays}}"
"defaultMessage": "{count} {countPlural, plural, one {recent play} other {recent plays}}"
},
"project.recent-plays.tooltip": {
"defaultMessage": "{count} {countPlural, plural, one {recent play} other {recent plays}} from Modrinth in the past 2 weeks"
},
"project.server.ping.ms": {
"defaultMessage": "{ping} ms"
"defaultMessage": "{ping, number} ms"
},
"project.settings.analytics.title": {
"defaultMessage": "Analytics"
@@ -1958,9 +1970,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."
}

View File

@@ -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,19 +383,28 @@ 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 {follower} other {followers}}',
defaultMessage: '{count, number} {count, plural, one {follower} other {followers}}',
},
projectOnlinePlayerCount: {
id: 'project.online-player-count',
defaultMessage: '{count} {count, plural, one {online} other {online}}',
defaultMessage: '{count, number} online',
},
projectOnlinePlayerCountTooltip: {
id: 'project.online-player-count.tooltip',
defaultMessage: '{count} {countPlural, plural, one {player} other {players}} online',
},
projectRecentPlays: {
id: 'project.recent-plays',
defaultMessage: '{count} {count, plural, one {recent play} other {recent plays}}',
defaultMessage: '{count} {countPlural, plural, one {recent play} other {recent plays}}',
},
projectRecentPlaysTooltip: {
id: 'project.recent-plays.tooltip',
defaultMessage:
'{count} {countPlural, plural, one {recent play} other {recent plays}} from Modrinth in the past 2 weeks',
},
})