From 844ac96291a1ccbbbe93c59516687b768afe26ce Mon Sep 17 00:00:00 2001 From: userquin Date: Wed, 28 Jan 2026 18:05:40 +0100 Subject: [PATCH 01/12] feat: change i18n configuration --- app/composables/i18n.ts | 107 ++++++ app/composables/useSettings.ts | 8 + app/composables/vue.ts | 68 ++++ app/pages/settings.vue | 3 +- app/plugins/hydration.client.ts | 5 + app/plugins/setup-i18n.ts | 32 ++ app/utils/i18n.ts | 18 + app/utils/language.ts | 35 ++ config/i18n.ts | 362 +++++++++++++++++++ i18n/i18n.config.ts | 10 +- i18n/locales/en-US.json | 1 + i18n/locales/es-419.json | 1 + i18n/locales/es-ES.json | 1 + i18n/locales/es.json | 512 +++++++++++++++++++++++++++ i18n/locales/{fr.json => fr-FR.json} | 0 i18n/locales/{it.json => it-IT.json} | 0 nuxt.config.ts | 22 +- package.json | 2 + pnpm-lock.yaml | 11 + 19 files changed, 1191 insertions(+), 7 deletions(-) create mode 100644 app/composables/i18n.ts create mode 100644 app/composables/vue.ts create mode 100644 app/plugins/hydration.client.ts create mode 100644 app/plugins/setup-i18n.ts create mode 100644 app/utils/i18n.ts create mode 100644 app/utils/language.ts create mode 100644 config/i18n.ts create mode 100644 i18n/locales/en-US.json create mode 100644 i18n/locales/es-419.json create mode 100644 i18n/locales/es-ES.json create mode 100644 i18n/locales/es.json rename i18n/locales/{fr.json => fr-FR.json} (100%) rename i18n/locales/{it.json => it-IT.json} (100%) diff --git a/app/composables/i18n.ts b/app/composables/i18n.ts new file mode 100644 index 000000000..7e038ec22 --- /dev/null +++ b/app/composables/i18n.ts @@ -0,0 +1,107 @@ +import type { UseTimeAgoOptions } from '@vueuse/core' + +const formatter = Intl.NumberFormat() + +export function formattedNumber(num: number, useFormatter: Intl.NumberFormat = formatter) { + return useFormatter.format(num) +} + +export function useHumanReadableNumber() { + const { n, locale } = useI18n() + + const fn = (num: number) => { + return n( + num, + num < 10000 ? 'smallCounting' : num < 1000000 ? 'kiloCounting' : 'millionCounting', + locale.value, + ) + } + + return { + formatHumanReadableNumber: (num: MaybeRef) => fn(unref(num)), + formatNumber: (num: MaybeRef) => n(unref(num), 'smallCounting', locale.value), + formatPercentage: (num: MaybeRef) => n(unref(num), 'percentage', locale.value), + forSR: (num: MaybeRef) => unref(num) > 10000, + } +} + +export function useFormattedDateTime( + value: MaybeRefOrGetter, + options: Intl.DateTimeFormatOptions = { dateStyle: 'long', timeStyle: 'medium' }, +) { + const { locale } = useI18n() + const formatter = computed(() => Intl.DateTimeFormat(locale.value, options)) + return computed(() => { + const v = toValue(value) + return v ? formatter.value.format(new Date(v)) : '' + }) +} + +export function useTimeAgoOptions(short = false): UseTimeAgoOptions { + const { d, t, n: fnf, locale } = useI18n() + const prefix = short ? 'short_' : '' + + const fn = (n: number, past: boolean, key: string) => { + return t(`time_ago_options.${prefix}${key}_${past ? 'past' : 'future'}`, n, { + named: { + v: fnf(n, 'smallCounting', locale.value), + }, + }) + } + + return { + rounding: 'floor', + showSecond: !short, + updateInterval: short ? 60000 : 1000, + messages: { + justNow: t('time_ago_options.just_now'), + // just return the value + past: n => n, + // just return the value + future: n => n, + second: (n, p) => fn(n, p, 'second'), + minute: (n, p) => fn(n, p, 'minute'), + hour: (n, p) => fn(n, p, 'hour'), + day: (n, p) => fn(n, p, 'day'), + week: (n, p) => fn(n, p, 'week'), + month: (n, p) => fn(n, p, 'month'), + year: (n, p) => fn(n, p, 'year'), + invalid: '', + }, + fullDateFormatter(date) { + return d(date, short ? 'short' : 'long') + }, + } +} + +export function useFileSizeFormatter() { + const { locale } = useI18n() + + const formatters = computed( + () => + [ + Intl.NumberFormat(locale.value, { + style: 'unit', + unit: 'megabyte', + unitDisplay: 'narrow', + maximumFractionDigits: 0, + }), + Intl.NumberFormat(locale.value, { + style: 'unit', + unit: 'kilobyte', + unitDisplay: 'narrow', + maximumFractionDigits: 0, + }), + ] as const, + ) + + const megaByte = 1024 * 1024 + + function formatFileSize(size: number) { + return size >= megaByte + ? formatters.value[0].format(size / megaByte) + : formatters.value[1].format(size / 1024) + } + + return { formatFileSize } +} diff --git a/app/composables/useSettings.ts b/app/composables/useSettings.ts index 330285145..feacbcc82 100644 --- a/app/composables/useSettings.ts +++ b/app/composables/useSettings.ts @@ -14,12 +14,15 @@ export interface AppSettings { includeTypesInInstall: boolean /** Accent color theme */ accentColorId: AccentColorId | null + /** Language code (e.g., "en-US") */ + language: string } const DEFAULT_SETTINGS: AppSettings = { relativeDates: false, includeTypesInInstall: true, accentColorId: null, + language: 'en-US', } const STORAGE_KEY = 'npmx-settings' @@ -27,6 +30,11 @@ const STORAGE_KEY = 'npmx-settings' // Shared settings instance (singleton per app) let settingsRef: RemovableRef | null = null +export function getDefaultLanguage(languages: string[]) { + if (import.meta.server) return 'en-US' + return matchLanguages(languages, navigator.languages) || 'en-US' +} + /** * Composable for managing application settings with localStorage persistence. * Settings are shared across all components that use this composable. diff --git a/app/composables/vue.ts b/app/composables/vue.ts new file mode 100644 index 000000000..ccfedca16 --- /dev/null +++ b/app/composables/vue.ts @@ -0,0 +1,68 @@ +import type { SchemaAugmentations } from '@unhead/schema' +import type { ActiveHeadEntry, UseHeadInput, UseHeadOptions } from '@unhead/vue' +import type { ComponentInternalInstance } from 'vue' +import { onActivated, onDeactivated, ref } from 'vue' + +export const isHydrated = ref(false) + +export function onHydrated(cb: () => unknown) { + watch(isHydrated, () => cb(), { immediate: isHydrated.value, once: true }) +} + +/** + * ### Whether the current component is running in the background + * + * for handling problems caused by the keepalive function + */ +export function useDeactivated() { + const deactivated = ref(false) + onActivated(() => (deactivated.value = false)) + onDeactivated(() => (deactivated.value = true)) + + return deactivated +} + +/** + * ### When the component is restored from the background + * + * for handling problems caused by the keepalive function + * + * @param hook + * @param target + */ +export function onReactivated(hook: () => void, target?: ComponentInternalInstance | null): void { + const initial = ref(true) + onActivated(() => { + if (initial.value) return + hook() + }, target) + onDeactivated(() => (initial.value = false)) +} + +export function useHydratedHead( + input: UseHeadInput, + options?: UseHeadOptions, +): ActiveHeadEntry> | void { + if (input && typeof input === 'object' && !('value' in input)) { + const title = 'title' in input ? input.title : undefined + if (import.meta.server && title) { + input.meta = input.meta || [] + if (Array.isArray(input.meta)) { + input.meta.push({ + property: 'og:title', + content: (typeof input.title === 'function' ? input.title() : input.title) as string, + }) + } + } else if (title) { + ;(input as any).title = () => + isHydrated.value ? (typeof title === 'function' ? title() : title) : '' + } + } + return useHead( + (() => { + if (!isHydrated.value) return {} + return toValue(input) + }) as UseHeadInput, + options, + ) +} diff --git a/app/pages/settings.vue b/app/pages/settings.vue index c1aed8450..ff39e2cbf 100644 --- a/app/pages/settings.vue +++ b/app/pages/settings.vue @@ -100,9 +100,8 @@ useSeoMeta({