diff --git a/assets/images/utils/languages.svg b/assets/images/utils/languages.svg new file mode 100644 index 000000000..72ed7aeee --- /dev/null +++ b/assets/images/utils/languages.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/assets/images/utils/radio-button-checked.svg b/assets/images/utils/radio-button-checked.svg new file mode 100644 index 000000000..6571ac46e --- /dev/null +++ b/assets/images/utils/radio-button-checked.svg @@ -0,0 +1 @@ + diff --git a/assets/images/utils/radio-button.svg b/assets/images/utils/radio-button.svg new file mode 100644 index 000000000..c2e74a314 --- /dev/null +++ b/assets/images/utils/radio-button.svg @@ -0,0 +1 @@ + diff --git a/assets/styles/utils.scss b/assets/styles/utils.scss index df98baf2f..ad1b84b7c 100644 --- a/assets/styles/utils.scss +++ b/assets/styles/utils.scss @@ -14,3 +14,22 @@ body { .text-container p { line-height: 1.3; } + +// From the Bootstrap project +// The MIT License (MIT) +// Copyright (c) 2011-2023 The Bootstrap Authors +// https://github.com/twbs/bootstrap/blob/2f617215755b066904248525a8c56ea425dde871/scss/mixins/_visually-hidden.scss#L8 +.visually-hidden { + width: 1px !important; + height: 1px !important; + padding: 0 !important; + margin: -1px !important; + overflow: hidden !important; + clip: rect(0, 0, 0, 0) !important; + white-space: nowrap !important; + border: 0 !important; + + &:not(caption) { + position: absolute !important; + } +} diff --git a/composables/auto-ref.ts b/composables/auto-ref.ts new file mode 100644 index 000000000..c34a0de5e --- /dev/null +++ b/composables/auto-ref.ts @@ -0,0 +1,13 @@ +export type AutoRef = [T] extends [(...args: any[]) => any] + ? Ref | (() => T) + : T | Ref | (() => T) + +/** + * Accepts a value directly, a ref with the value or a getter function and returns a Vue ref. + * @param value The value to use. + * @returns Either the original or newly created ref. + */ +export function useAutoRef(value: AutoRef): Ref { + if (typeof value === 'function') return computed(() => value()) + return isRef(value) ? value : ref(value as any) +} diff --git a/composables/display-names.ts b/composables/display-names.ts new file mode 100644 index 000000000..f0529eeed --- /dev/null +++ b/composables/display-names.ts @@ -0,0 +1,91 @@ +import { useAutoRef, type AutoRef } from './auto-ref.ts' + +const safeTags = new Map() + +function safeTagFor(locale: string) { + let safeTag = safeTags.get(locale) + if (safeTag == null) { + safeTag = new Intl.Locale(locale).baseName + safeTags.set(locale, safeTag) + } + return safeTag +} + +type DisplayNamesWrapper = Intl.DisplayNames & { + of(tag: string): string | undefined +} + +const displayNamesDicts = new Map() + +function getWrapperKey(locale: string, options: Intl.DisplayNamesOptions) { + return JSON.stringify({ ...options, locale }) +} + +export function createDisplayNames( + locale: string, + options: Intl.DisplayNamesOptions = { type: 'language' } +) { + const wrapperKey = getWrapperKey(locale, options) + let wrapper = displayNamesDicts.get(wrapperKey) + + if (wrapper == null) { + const dict = new Intl.DisplayNames(locale, options) + + const badTags: string[] = [] + + wrapper = { + resolvedOptions() { + return dict.resolvedOptions() + }, + of(tag: string) { + let attempt = 0 + + // eslint-disable-next-line no-labels + lookupLoop: do { + let lookup: string + switch (attempt) { + case 0: + lookup = tag + break + case 1: + lookup = safeTagFor(tag) + break + default: + // eslint-disable-next-line no-labels + break lookupLoop + } + + if (badTags.includes(lookup)) continue + + try { + return dict.of(lookup) + } catch (err) { + console.warn( + `Failed to get display name for ${lookup} using dictionary for ${ + this.resolvedOptions().locale + }` + ) + badTags.push(lookup) + continue + } + } while (++attempt < 5) + + return undefined + }, + } + + displayNamesDicts.set(wrapperKey, wrapper) + } + + return wrapper +} + +export function useDisplayNames( + locale: AutoRef, + options?: AutoRef +) { + const $locale = useAutoRef(locale) + const $options = useAutoRef(options) + + return computed(() => createDisplayNames($locale.value, $options.value)) +} diff --git a/helpers/events.ts b/helpers/events.ts new file mode 100644 index 000000000..7a0973e61 --- /dev/null +++ b/helpers/events.ts @@ -0,0 +1,10 @@ +/** + * Checks if any of the modifier keys is down for the event. + * @param e Event that is triggered with the state of modified keys. + * @returns Whether any of the modifier keys is pressed. + */ +export function isModifierKeyDown( + e: Pick +) { + return e.ctrlKey || e.altKey || e.metaKey || e.shiftKey +} diff --git a/locales/en-US/index.json b/locales/en-US/index.json index 82ee14ec0..c52e8e939 100644 --- a/locales/en-US/index.json +++ b/locales/en-US/index.json @@ -13,5 +13,53 @@ }, "frog.title": { "message": "Frog" + }, + "settings.language.categories.auto": { + "message": "Automatic" + }, + "settings.language.categories.default": { + "message": "Standard languages" + }, + "settings.language.categories.experimental": { + "message": "Experimental languages" + }, + "settings.language.categories.fun": { + "message": "Fun languages" + }, + "settings.language.categories.search-result": { + "message": "Search results" + }, + "settings.language.description": { + "message": "Choose your preferred language for the site. Translations are contributed by volunteers on Crowdin." + }, + "settings.language.languages.automatic": { + "message": "Sync with the system language" + }, + "settings.language.languages.language-label": { + "message": "{translatedName}. {displayName}" + }, + "settings.language.languages.language-label-applying": { + "message": "{label}. Applying..." + }, + "settings.language.languages.language-label-error": { + "message": "{label}. Error" + }, + "settings.language.languages.load-failed": { + "message": "Cannot load this language. Try again in a bit." + }, + "settings.language.languages.search-field.description": { + "message": "Submit to focus the first search result" + }, + "settings.language.languages.search-field.placeholder": { + "message": "Search for a language..." + }, + "settings.language.languages.search-results-announcement": { + "message": "{matches, plural, =0 {No languages match} one {# language matches} other {# languages match}} your search." + }, + "settings.language.languages.search.no-results": { + "message": "No languages match your search." + }, + "settings.language.title": { + "message": "Language" } } diff --git a/locales/en-US/languages.json b/locales/en-US/languages.json index 139d95c76..d86a7a1fc 100644 --- a/locales/en-US/languages.json +++ b/locales/en-US/languages.json @@ -1,3 +1,3 @@ { - "en-US": "American English" + "en-US": "English (United States)" } diff --git a/locales/en-US/meta.json b/locales/en-US/meta.json index a171dc516..95716adaa 100644 --- a/locales/en-US/meta.json +++ b/locales/en-US/meta.json @@ -1,6 +1,10 @@ { "displayName": { - "description": "The name of the language in dialect form (e.g. Français canadien for French spoken in Canada, not French (Canada))", - "message": "American English" + "description": "Please enter the name of the language in its specific variant or regional form (e.g., English (US) for American English, not just English). If the language does not have any specific variant, simply enter the name of the language (e.g., Français, Deutsch).", + "message": "English (United States)" + }, + "searchTerms": { + "description": "Please provide additional search terms associated with the language, if needed, to enhance the search functionality (e.g., American English, Deutschland). Each search term should be entered on a separate line. Translate as a hyphen (-) if no additional terms are needed.", + "message": "USA\nAmerican English" } } diff --git a/nuxt.config.ts b/nuxt.config.ts index d9ce2e165..d7c2fd752 100644 --- a/nuxt.config.ts +++ b/nuxt.config.ts @@ -47,7 +47,18 @@ const meta = { * Preferably only the locales that reach a certain threshold of complete * translations would be included in this array. */ -const ENABLED_LOCALES: string[] = [] +const enabledLocales: string[] = [] + +/** + * Overrides for the categories of the certain locales. + */ +const localesCategoriesOverrides: Partial> = { + 'en-x-pirate': 'fun', + 'en-x-updown': 'fun', + 'en-x-lolcat': 'fun', + 'en-x-uwu': 'fun', + 'ru-x-bandit': 'fun', +} export default defineNuxtConfig({ app: { @@ -214,7 +225,7 @@ export default defineNuxtConfig({ for await (const localeDir of globIterate('locales/*/', { posix: true })) { const tag = basename(localeDir) - if (!ENABLED_LOCALES.includes(tag) && opts.defaultLocale !== tag) continue + if (!enabledLocales.includes(tag) && opts.defaultLocale !== tag) continue const locale = opts.locales.find((locale) => locale.tag === tag) ?? @@ -246,6 +257,11 @@ export default defineNuxtConfig({ } } + const categoryOverride = localesCategoriesOverrides[tag] + if (categoryOverride != null) { + ;(locale.meta ??= {}).category = categoryOverride + } + const cnDataImport = resolveCompactNumberDataImport(tag) if (cnDataImport != null) { ;(locale.additionalImports ??= []).push({ diff --git a/package.json b/package.json index ac608384e..9fa5f6c81 100644 --- a/package.json +++ b/package.json @@ -21,7 +21,7 @@ "@typescript-eslint/parser": "^5.59.8", "@vintl/compact-number": "^2.0.4", "@vintl/how-ago": "^2.0.1", - "@vintl/nuxt": "^1.2.3", + "@vintl/nuxt": "^1.3.0", "eslint": "^8.41.0", "eslint-config-prettier": "^8.8.0", "eslint-import-resolver-typescript": "^3.5.5", @@ -41,6 +41,7 @@ "@ltd/j-toml": "^1.38.0", "dayjs": "^1.11.7", "floating-vue": "^2.0.0-beta.20", + "fuse.js": "^6.6.2", "highlight.js": "^11.7.0", "js-yaml": "^4.1.0", "jszip": "^3.10.1", diff --git a/pages/settings.vue b/pages/settings.vue index 559840a6e..e3921cbf5 100644 --- a/pages/settings.vue +++ b/pages/settings.vue @@ -22,6 +22,9 @@ + + + @@ -39,6 +42,7 @@ import UserIcon from '~/assets/images/utils/user.svg' import CurrencyIcon from '~/assets/images/utils/currency.svg' import ShieldIcon from '~/assets/images/utils/shield.svg' import KeyIcon from '~/assets/images/utils/key.svg' +import LanguagesIcon from '~/assets/images/utils/languages.svg' const route = useRoute() const auth = await useAuth() diff --git a/pages/settings/language.vue b/pages/settings/language.vue new file mode 100644 index 000000000..b5248672c --- /dev/null +++ b/pages/settings/language.vue @@ -0,0 +1,537 @@ + + + + + + {{ formatMessage(messages.languagesTitle) }} + + + + + + + + + + + + + + + + {{ formatMessage(messages.searchFieldDescription) }} + + + + {{ + isQueryEmpty() + ? '' + : formatMessage(messages.searchResultsAnnouncement, { + matches: $searchResults.get('searchResult')?.length ?? 0, + }) + }} + + + + + + + {{ formatMessage(categoryNames[category]) }} + + + + {{ formatMessage(messages.noResults) }} + + + + onItemClick(e, locale)" + @keydown="(e) => onItemKeydown(e, locale)" + > + + + + + + {{ locale.auto ? formatMessage(messages.automaticLocale) : locale.displayName }} + + + + {{ locale.translatedName }} + + + + + + {{ formatMessage(messages.loadFailed) }} + + + + + + + + + diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 5dcb5669e..c2afd564f 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -19,6 +19,9 @@ dependencies: floating-vue: specifier: ^2.0.0-beta.20 version: 2.0.0-beta.20(vue@3.3.4) + fuse.js: + specifier: ^6.6.2 + version: 6.6.2 highlight.js: specifier: ^11.7.0 version: 11.7.0 @@ -76,8 +79,8 @@ devDependencies: specifier: ^2.0.1 version: 2.0.1(@formatjs/intl@2.7.2) '@vintl/nuxt': - specifier: ^1.2.3 - version: 1.2.3(typescript@5.0.4)(vite@4.3.9)(vue@3.3.4) + specifier: ^1.3.0 + version: 1.3.0(typescript@5.0.4)(vite@4.3.9)(vue@3.3.4) eslint: specifier: ^8.41.0 version: 8.41.0 @@ -1016,6 +1019,12 @@ packages: tslib: 2.6.0 dev: true + /@formatjs/intl-localematcher@0.4.0: + resolution: {integrity: sha512-bRTd+rKomvfdS4QDlVJ6TA/Jx1F2h/TBVO5LjvhQ7QPPHp19oPNMIum7W2CMEReq/zPxpmCeB31F9+5gl/qtvw==} + dependencies: + tslib: 2.6.0 + dev: true + /@formatjs/intl@2.7.2(typescript@5.0.4): resolution: {integrity: sha512-ziiQfnXwY0/rXhtohSAmYMqDjRsihoMKdl8H2aA+FvxG9638E0XrvfBFCb+1HhimNiuqRz5fTY7F/bZtsJxsjA==} peerDependencies: @@ -2102,10 +2111,11 @@ packages: intl-messageformat: 10.3.5 dev: true - /@vintl/nuxt@1.2.3(typescript@5.0.4)(vite@4.3.9)(vue@3.3.4): - resolution: {integrity: sha512-7grMFQqWc6S9nUU7q9K+fhRZ/SvPzG4Dll/fcCnDbIzGBgzH+Nni2b+yFuoT0sjmXN3lsksHa+5hxyJDxq9Q0A==} + /@vintl/nuxt@1.3.0(typescript@5.0.4)(vite@4.3.9)(vue@3.3.4): + resolution: {integrity: sha512-gH0Db4XB3RCzOaBQtoBf/Sc9bMneyVtt7RBjrLWzzNf7gN1NJNPlrfdCVAp2si/P7dt06wRegGHaSBuQ3oQQZg==} dependencies: '@formatjs/intl': 2.7.2(typescript@5.0.4) + '@formatjs/intl-localematcher': 0.4.0 '@nuxt/kit': 3.6.1 '@vintl/unplugin': 1.2.4(vite@4.3.9) '@vintl/vintl': 4.2.1(typescript@5.0.4)(vue@3.3.4) @@ -4498,6 +4508,11 @@ packages: resolution: {integrity: sha512-xckBUXyTIqT97tq2x2AMb+g163b5JFysYk0x4qxNFwbfQkmNZoiRHb6sPzI9/QV33WeuvVYBUIiD4NzNIyqaRQ==} dev: true + /fuse.js@6.6.2: + resolution: {integrity: sha512-cJaJkxCCxC8qIIcPBF9yGxY0W/tVZS3uEISDxhYIdtk8OL93pe+6Zj7LjCqVV4dzbqcriOZ+kQ/NE4RXZHsIGA==} + engines: {node: '>=10'} + dev: false + /gauge@3.0.2: resolution: {integrity: sha512-+5J6MS/5XksCuXq++uFRsnUd7Ovu1XenbeuIuNRJxYWjgQbPuFhT14lAvsWfqfAmnwluf1OwMjz39HjfLPci0Q==} engines: {node: '>=10'} diff --git a/types/fusejs.d.ts b/types/fusejs.d.ts new file mode 100644 index 000000000..dcdf035a9 --- /dev/null +++ b/types/fusejs.d.ts @@ -0,0 +1,4 @@ +declare module 'fuse.js/dist/fuse.basic' { + import Fuse from 'fuse.js' + export default Fuse +} diff --git a/types/vintl.d.ts b/types/vintl.d.ts index 86298889b..92901bb17 100644 --- a/types/vintl.d.ts +++ b/types/vintl.d.ts @@ -6,5 +6,15 @@ declare global { interface MessageValueTypes { compactNumber: CompactNumber } + + interface LocaleResources { + 'languages.json'?: Partial> + } + + interface LocaleMeta { + displayName?: string + category?: string + searchTerms?: string + } } }