Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 17 additions & 0 deletions app/app.vue
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
<script setup lang="ts">
import type { Directions } from '@nuxtjs/i18n'
import { useEventListener } from '@vueuse/core'

const route = useRoute()
const router = useRouter()
const { locale, locales } = useI18n()

// Initialize accent color before hydration to prevent flash
initAccentOnPrehydrate()
Expand All @@ -15,6 +17,21 @@ useHead({
},
})

const localeMap = locales.value.reduce(
(acc, l) => {
acc[l.code!] = l.dir ?? 'ltr'
return acc
},
{} as Record<string, Directions>,
)

useHydratedHead({
htmlAttrs: {
lang: () => locale.value,
dir: () => localeMap[locale.value] ?? 'ltr',
},
})

// Global keyboard shortcut: "/" focuses search or navigates to search page
function handleGlobalKeydown(e: KeyboardEvent) {
const target = e.target as HTMLElement
Expand Down
107 changes: 107 additions & 0 deletions app/composables/i18n.ts
Original file line number Diff line number Diff line change
@@ -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<number>) => fn(unref(num)),
formatNumber: (num: MaybeRef<number>) => n(unref(num), 'smallCounting', locale.value),
formatPercentage: (num: MaybeRef<number>) => n(unref(num), 'percentage', locale.value),
forSR: (num: MaybeRef<number>) => unref(num) > 10000,
}
}

export function useFormattedDateTime(
value: MaybeRefOrGetter<string | number | Date | undefined | null>,
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<false> {
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 }
}
8 changes: 8 additions & 0 deletions app/composables/useSettings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,19 +14,27 @@ 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'

// Shared settings instance (singleton per app)
let settingsRef: RemovableRef<AppSettings> | 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.
Expand Down
68 changes: 68 additions & 0 deletions app/composables/vue.ts
Original file line number Diff line number Diff line change
@@ -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<T extends SchemaAugmentations>(
input: UseHeadInput<T>,
options?: UseHeadOptions,
): ActiveHeadEntry<UseHeadInput<T>> | 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<T>,
options,
)
}
3 changes: 1 addition & 2 deletions app/pages/settings.vue
Original file line number Diff line number Diff line change
Expand Up @@ -142,9 +142,8 @@ useSeoMeta({
<div class="px-2 py-1">
<select
id="language-select"
:value="locale"
v-model="settings.language"
class="w-full bg-bg-muted border border-border rounded-md px-2 py-1.5 text-sm text-fg focus:outline-none focus:ring-2 focus:ring-fg/50 cursor-pointer"
@change="setLocale(($event.target as HTMLSelectElement).value as typeof locale)"
>
<option v-for="loc in availableLocales" :key="loc.code" :value="loc.code">
{{ loc.name }}
Expand Down
5 changes: 5 additions & 0 deletions app/plugins/hydration.client.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
export default defineNuxtPlugin(nuxtApp => {
nuxtApp.hooks.hookOnce('app:suspense:resolve', () => {
isHydrated.value = true
})
})
32 changes: 32 additions & 0 deletions app/plugins/setup-i18n.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
import type { Locale } from '#i18n'

export default defineNuxtPlugin(async nuxt => {
const t = nuxt.vueApp.config.globalProperties.$t
const d = nuxt.vueApp.config.globalProperties.$d
const n = nuxt.vueApp.config.globalProperties.$n

nuxt.vueApp.config.globalProperties.$t = wrapI18n(t)
nuxt.vueApp.config.globalProperties.$d = wrapI18n(d)
nuxt.vueApp.config.globalProperties.$n = wrapI18n(n)

if (import.meta.client) {
const i18n = useNuxtApp().$i18n
const { setLocale, locales } = i18n
const { settings } = useSettings()
const lang = computed(() => settings.value.language as Locale)

const supportLanguages = unref(locales).map(locale => locale.code)
if (!supportLanguages.includes(lang.value))
settings.value.language = getDefaultLanguage(supportLanguages)

if (lang.value !== i18n.locale.value) await setLocale(settings.value.language as Locale)

watch(
[lang, isHydrated],
() => {
if (isHydrated.value && lang.value !== i18n.locale.value) setLocale(lang.value)
},
{ immediate: true },
)
}
})
18 changes: 18 additions & 0 deletions app/utils/i18n.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
import { useI18n as useOriginalI18n } from 'vue-i18n'

export function useI18n() {
const { t, d, n, ...rest } = useOriginalI18n()

return {
...rest,
t: wrapI18n(t),
d: wrapI18n(d),
n: wrapI18n(n),
} satisfies ReturnType<typeof useOriginalI18n>
}

export function wrapI18n<T extends (...args: any[]) => any>(t: T): T {
return <T>((...args: any[]) => {
return import.meta.server ? t(...args) : isHydrated.value ? t(...args) : ''
})
}
35 changes: 35 additions & 0 deletions app/utils/language.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
export function matchLanguages(
languages: string[],
acceptLanguages: readonly string[],
): string | null {
{
// const lang = acceptLanguages.map(userLang => languages.find(lang => lang.startsWith(userLang))).filter(v => !!v)[0]
// TODO: Support es-419, remove this code if we include spanish country variants
const lang = acceptLanguages
.map(userLang =>
languages.find(currentLang => {
if (currentLang === userLang) return currentLang

// Edge browser: case for ca-valencia
if (currentLang === 'ca-valencia' && userLang === 'ca-Es-VALENCIA') return currentLang

if (userLang.startsWith('es-') && userLang !== 'es-ES' && currentLang === 'es-419')
return currentLang

return currentLang.startsWith(userLang) ? currentLang : undefined
}),
)
.find(v => !!v)
if (lang) return lang
}

const lang = acceptLanguages
.map(userLang => {
userLang = userLang.split('-')[0]!
return languages.find(lang => lang.startsWith(userLang))
})
.find(v => !!v)
if (lang) return lang

return null
}
Loading
Loading