feat: i18n switcher in app-frontend (#4990)

* feat: app i18n stuff

* feat: locale switching on load

* feat: db migration

* feat: polish + fade indicator impl onto TabbedModal

* fix: prepr checks

* fix: remove staging lock for language switching

* fix: lint
This commit is contained in:
Calum H.
2025-12-29 19:41:39 +00:00
committed by GitHub
parent 30106d5f82
commit 042451bad6
21 changed files with 624 additions and 474 deletions

View File

@@ -0,0 +1,12 @@
{
"db_name": "SQLite",
"query": "\n UPDATE settings\n SET\n max_concurrent_writes = $1,\n max_concurrent_downloads = $2,\n\n theme = $3,\n locale = $4,\n default_page = $5,\n collapsed_navigation = $6,\n advanced_rendering = $7,\n native_decorations = $8,\n\n discord_rpc = $9,\n developer_mode = $10,\n telemetry = $11,\n personalized_ads = $12,\n\n onboarded = $13,\n\n extra_launch_args = jsonb($14),\n custom_env_vars = jsonb($15),\n mc_memory_max = $16,\n mc_force_fullscreen = $17,\n mc_game_resolution_x = $18,\n mc_game_resolution_y = $19,\n hide_on_process_start = $20,\n\n hook_pre_launch = $21,\n hook_wrapper = $22,\n hook_post_exit = $23,\n\n custom_dir = $24,\n prev_custom_dir = $25,\n migrated = $26,\n\n toggle_sidebar = $27,\n feature_flags = $28,\n hide_nametag_skins_page = $29,\n\n skipped_update = $30,\n pending_update_toast_for_version = $31,\n auto_download_updates = $32,\n\n version = $33\n ",
"describe": {
"columns": [],
"parameters": {
"Right": 33
},
"nullable": []
},
"hash": "175067f04e775f5469146f3cb77c422c3ab7203409083fd3c9c968b00b46918f"
}

View File

@@ -1,6 +1,6 @@
{
"db_name": "SQLite",
"query": "\n SELECT\n max_concurrent_writes, max_concurrent_downloads,\n theme, default_page, collapsed_navigation, hide_nametag_skins_page, advanced_rendering, native_decorations,\n discord_rpc, developer_mode, telemetry, personalized_ads,\n onboarded,\n json(extra_launch_args) extra_launch_args, json(custom_env_vars) custom_env_vars,\n mc_memory_max, mc_force_fullscreen, mc_game_resolution_x, mc_game_resolution_y, hide_on_process_start,\n hook_pre_launch, hook_wrapper, hook_post_exit,\n custom_dir, prev_custom_dir, migrated, json(feature_flags) feature_flags, toggle_sidebar,\n skipped_update, pending_update_toast_for_version, auto_download_updates,\n version\n FROM settings\n ",
"query": "\n SELECT\n max_concurrent_writes, max_concurrent_downloads,\n theme, locale, default_page, collapsed_navigation, hide_nametag_skins_page, advanced_rendering, native_decorations,\n discord_rpc, developer_mode, telemetry, personalized_ads,\n onboarded,\n json(extra_launch_args) extra_launch_args, json(custom_env_vars) custom_env_vars,\n mc_memory_max, mc_force_fullscreen, mc_game_resolution_x, mc_game_resolution_y, hide_on_process_start,\n hook_pre_launch, hook_wrapper, hook_post_exit,\n custom_dir, prev_custom_dir, migrated, json(feature_flags) feature_flags, toggle_sidebar,\n skipped_update, pending_update_toast_for_version, auto_download_updates,\n version\n FROM settings\n ",
"describe": {
"columns": [
{
@@ -19,148 +19,153 @@
"type_info": "Text"
},
{
"name": "default_page",
"name": "locale",
"ordinal": 3,
"type_info": "Text"
},
{
"name": "collapsed_navigation",
"name": "default_page",
"ordinal": 4,
"type_info": "Integer"
"type_info": "Text"
},
{
"name": "hide_nametag_skins_page",
"name": "collapsed_navigation",
"ordinal": 5,
"type_info": "Integer"
},
{
"name": "advanced_rendering",
"name": "hide_nametag_skins_page",
"ordinal": 6,
"type_info": "Integer"
},
{
"name": "native_decorations",
"name": "advanced_rendering",
"ordinal": 7,
"type_info": "Integer"
},
{
"name": "discord_rpc",
"name": "native_decorations",
"ordinal": 8,
"type_info": "Integer"
},
{
"name": "developer_mode",
"name": "discord_rpc",
"ordinal": 9,
"type_info": "Integer"
},
{
"name": "telemetry",
"name": "developer_mode",
"ordinal": 10,
"type_info": "Integer"
},
{
"name": "personalized_ads",
"name": "telemetry",
"ordinal": 11,
"type_info": "Integer"
},
{
"name": "onboarded",
"name": "personalized_ads",
"ordinal": 12,
"type_info": "Integer"
},
{
"name": "extra_launch_args",
"name": "onboarded",
"ordinal": 13,
"type_info": "Text"
"type_info": "Integer"
},
{
"name": "custom_env_vars",
"name": "extra_launch_args",
"ordinal": 14,
"type_info": "Text"
},
{
"name": "mc_memory_max",
"name": "custom_env_vars",
"ordinal": 15,
"type_info": "Integer"
"type_info": "Text"
},
{
"name": "mc_force_fullscreen",
"name": "mc_memory_max",
"ordinal": 16,
"type_info": "Integer"
},
{
"name": "mc_game_resolution_x",
"name": "mc_force_fullscreen",
"ordinal": 17,
"type_info": "Integer"
},
{
"name": "mc_game_resolution_y",
"name": "mc_game_resolution_x",
"ordinal": 18,
"type_info": "Integer"
},
{
"name": "hide_on_process_start",
"name": "mc_game_resolution_y",
"ordinal": 19,
"type_info": "Integer"
},
{
"name": "hook_pre_launch",
"name": "hide_on_process_start",
"ordinal": 20,
"type_info": "Text"
"type_info": "Integer"
},
{
"name": "hook_wrapper",
"name": "hook_pre_launch",
"ordinal": 21,
"type_info": "Text"
},
{
"name": "hook_post_exit",
"name": "hook_wrapper",
"ordinal": 22,
"type_info": "Text"
},
{
"name": "custom_dir",
"name": "hook_post_exit",
"ordinal": 23,
"type_info": "Text"
},
{
"name": "prev_custom_dir",
"name": "custom_dir",
"ordinal": 24,
"type_info": "Text"
},
{
"name": "migrated",
"name": "prev_custom_dir",
"ordinal": 25,
"type_info": "Text"
},
{
"name": "migrated",
"ordinal": 26,
"type_info": "Integer"
},
{
"name": "feature_flags",
"ordinal": 26,
"ordinal": 27,
"type_info": "Text"
},
{
"name": "toggle_sidebar",
"ordinal": 27,
"ordinal": 28,
"type_info": "Integer"
},
{
"name": "skipped_update",
"ordinal": 28,
"type_info": "Text"
},
{
"name": "pending_update_toast_for_version",
"ordinal": 29,
"type_info": "Text"
},
{
"name": "auto_download_updates",
"name": "pending_update_toast_for_version",
"ordinal": 30,
"type_info": "Text"
},
{
"name": "auto_download_updates",
"ordinal": 31,
"type_info": "Integer"
},
{
"name": "version",
"ordinal": 31,
"ordinal": 32,
"type_info": "Integer"
}
],
@@ -181,6 +186,7 @@
false,
false,
false,
false,
null,
null,
false,
@@ -202,5 +208,5 @@
false
]
},
"hash": "07ea3a644644de61c4ed7c30ee711d29fd49f10534230b1b03097275a30cb50f"
"hash": "8e62fba05f331f91822ec204695ecb40567541c547181a4ef847318845cf3110"
}

View File

@@ -1,12 +0,0 @@
{
"db_name": "SQLite",
"query": "\n UPDATE settings\n SET\n max_concurrent_writes = $1,\n max_concurrent_downloads = $2,\n\n theme = $3,\n default_page = $4,\n collapsed_navigation = $5,\n advanced_rendering = $6,\n native_decorations = $7,\n\n discord_rpc = $8,\n developer_mode = $9,\n telemetry = $10,\n personalized_ads = $11,\n\n onboarded = $12,\n\n extra_launch_args = jsonb($13),\n custom_env_vars = jsonb($14),\n mc_memory_max = $15,\n mc_force_fullscreen = $16,\n mc_game_resolution_x = $17,\n mc_game_resolution_y = $18,\n hide_on_process_start = $19,\n\n hook_pre_launch = $20,\n hook_wrapper = $21,\n hook_post_exit = $22,\n\n custom_dir = $23,\n prev_custom_dir = $24,\n migrated = $25,\n\n toggle_sidebar = $26,\n feature_flags = $27,\n hide_nametag_skins_page = $28,\n\n skipped_update = $29,\n pending_update_toast_for_version = $30,\n auto_download_updates = $31,\n\n version = $32\n ",
"describe": {
"columns": [],
"parameters": {
"Right": 32
},
"nullable": []
},
"hash": "a40e60da6dd1312d4a1ed52fa8fd2394e7ad21de1cb44cf8b93c4b1459cdc716"
}

View File

@@ -0,0 +1,2 @@
-- Add migration script here
ALTER TABLE settings ADD COLUMN locale TEXT NOT NULL DEFAULT 'en-US';

View File

@@ -12,6 +12,7 @@ pub struct Settings {
pub max_concurrent_writes: usize,
pub theme: Theme,
pub locale: String,
pub default_page: DefaultPage,
pub collapsed_navigation: bool,
pub hide_nametag_skins_page: bool,
@@ -66,7 +67,7 @@ impl Settings {
"
SELECT
max_concurrent_writes, max_concurrent_downloads,
theme, default_page, collapsed_navigation, hide_nametag_skins_page, advanced_rendering, native_decorations,
theme, locale, default_page, collapsed_navigation, hide_nametag_skins_page, advanced_rendering, native_decorations,
discord_rpc, developer_mode, telemetry, personalized_ads,
onboarded,
json(extra_launch_args) extra_launch_args, json(custom_env_vars) custom_env_vars,
@@ -85,6 +86,7 @@ impl Settings {
max_concurrent_downloads: res.max_concurrent_downloads as usize,
max_concurrent_writes: res.max_concurrent_writes as usize,
theme: Theme::from_string(&res.theme),
locale: res.locale,
default_page: DefaultPage::from_string(&res.default_page),
collapsed_navigation: res.collapsed_navigation == 1,
hide_nametag_skins_page: res.hide_nametag_skins_page == 1,
@@ -157,47 +159,49 @@ impl Settings {
max_concurrent_downloads = $2,
theme = $3,
default_page = $4,
collapsed_navigation = $5,
advanced_rendering = $6,
native_decorations = $7,
locale = $4,
default_page = $5,
collapsed_navigation = $6,
advanced_rendering = $7,
native_decorations = $8,
discord_rpc = $8,
developer_mode = $9,
telemetry = $10,
personalized_ads = $11,
discord_rpc = $9,
developer_mode = $10,
telemetry = $11,
personalized_ads = $12,
onboarded = $12,
onboarded = $13,
extra_launch_args = jsonb($13),
custom_env_vars = jsonb($14),
mc_memory_max = $15,
mc_force_fullscreen = $16,
mc_game_resolution_x = $17,
mc_game_resolution_y = $18,
hide_on_process_start = $19,
extra_launch_args = jsonb($14),
custom_env_vars = jsonb($15),
mc_memory_max = $16,
mc_force_fullscreen = $17,
mc_game_resolution_x = $18,
mc_game_resolution_y = $19,
hide_on_process_start = $20,
hook_pre_launch = $20,
hook_wrapper = $21,
hook_post_exit = $22,
hook_pre_launch = $21,
hook_wrapper = $22,
hook_post_exit = $23,
custom_dir = $23,
prev_custom_dir = $24,
migrated = $25,
custom_dir = $24,
prev_custom_dir = $25,
migrated = $26,
toggle_sidebar = $26,
feature_flags = $27,
hide_nametag_skins_page = $28,
toggle_sidebar = $27,
feature_flags = $28,
hide_nametag_skins_page = $29,
skipped_update = $29,
pending_update_toast_for_version = $30,
auto_download_updates = $31,
skipped_update = $30,
pending_update_toast_for_version = $31,
auto_download_updates = $32,
version = $32
version = $33
",
max_concurrent_writes,
max_concurrent_downloads,
theme,
self.locale,
default_page,
self.collapsed_navigation,
self.advanced_rendering,

View File

@@ -1,7 +1,8 @@
<script setup lang="ts">
import { type Component, ref } from 'vue'
import { type Component, nextTick, ref } from 'vue'
import { type MessageDescriptor, useVIntl } from '../../composables/i18n'
import { useScrollIndicator } from '../../composables/scroll-indicator'
const { formatMessage } = useVIntl()
@@ -10,6 +11,7 @@ export type Tab<Props> = {
icon: Component
content: Component<Props>
props?: Props
badge?: MessageDescriptor
}
defineProps<{
@@ -19,8 +21,13 @@ defineProps<{
const selectedTab = ref(0)
const scrollContainer = ref<HTMLElement | null>(null)
const { showTopFade, showBottomFade, checkScrollState, forceCheck } =
useScrollIndicator(scrollContainer)
function setTab(index: number) {
selectedTab.value = index
nextTick(() => forceCheck())
}
defineExpose({ selectedTab, setTab })
@@ -34,16 +41,56 @@ defineExpose({ selectedTab, setTab })
v-for="(tab, index) in tabs"
:key="index"
:class="`flex gap-2 items-center text-left rounded-xl px-4 py-2 border-none text-nowrap font-semibold cursor-pointer active:scale-[0.97] transition-all ${selectedTab === index ? 'bg-button-bgSelected text-button-textSelected' : 'bg-transparent text-button-text hover:bg-button-bg hover:text-contrast'}`"
@click="() => (selectedTab = index)"
@click="() => setTab(index)"
>
<component :is="tab.icon" class="w-4 h-4" />
<span>{{ formatMessage(tab.name) }}</span>
<span
v-if="tab.badge"
class="rounded-full px-1.5 py-0.5 text-xs font-bold bg-brand-highlight text-brand-green"
>
{{ formatMessage(tab.badge) }}
</span>
</button>
<slot name="footer" />
</div>
<div class="w-[600px] h-[500px] overflow-y-auto px-4">
<component :is="tabs[selectedTab].content" v-bind="tabs[selectedTab].props ?? {}" />
<div class="relative">
<Transition
enter-active-class="transition-all duration-200 ease-out"
enter-from-class="opacity-0 max-h-0"
enter-to-class="opacity-100 max-h-24"
leave-active-class="transition-all duration-200 ease-in"
leave-from-class="opacity-100 max-h-24"
leave-to-class="opacity-0 max-h-0"
>
<div
v-if="showTopFade"
class="pointer-events-none absolute left-0 right-0 top-0 z-10 h-24 bg-gradient-to-b from-bg-raised to-transparent"
/>
</Transition>
<div
ref="scrollContainer"
class="w-[600px] h-[500px] overflow-y-auto px-4"
@scroll="checkScrollState"
>
<component :is="tabs[selectedTab].content" v-bind="tabs[selectedTab].props ?? {}" />
</div>
<Transition
enter-active-class="transition-all duration-200 ease-out"
enter-from-class="opacity-0 max-h-0"
enter-to-class="opacity-100 max-h-24"
leave-active-class="transition-all duration-200 ease-in"
leave-from-class="opacity-100 max-h-24"
leave-to-class="opacity-0 max-h-0"
>
<div
v-if="showBottomFade"
class="pointer-events-none absolute bottom-0 left-0 right-0 z-10 h-24 bg-gradient-to-t from-bg-raised to-transparent"
/>
</Transition>
</div>
</div>
</template>

View File

@@ -0,0 +1,243 @@
<script setup lang="ts">
import { RadioButtonCheckedIcon, RadioButtonIcon, SearchIcon } from '@modrinth/assets'
import Fuse from 'fuse.js/dist/fuse.basic'
import { computed, ref, watchSyncEffect } from 'vue'
import { defineMessages, type LocaleDefinition, useVIntl } from '../../composables/i18n'
import { isModifierKeyDown } from '../../utils/events'
const { formatMessage } = useVIntl()
const props = defineProps<{
currentLocale: string
locales: LocaleDefinition[]
onLocaleChange: (locale: string) => Promise<void>
isChanging?: boolean
}>()
const messages = defineMessages({
noResults: {
id: 'settings.language.languages.search.no-results',
defaultMessage: 'No languages match your search.',
},
searchFieldPlaceholder: {
id: 'settings.language.languages.search-field.placeholder',
defaultMessage: 'Search for a language...',
},
searchResultsAnnouncement: {
id: 'settings.language.languages.search-results-announcement',
defaultMessage:
'{matches, plural, =0 {No languages match} one {# language matches} other {# languages match}} your search.',
},
standardLanguages: {
id: 'settings.language.categories.default',
defaultMessage: 'Standard languages',
},
searchResults: {
id: 'settings.language.categories.search-result',
defaultMessage: 'Search results',
},
})
type Category = 'default' | 'searchResult'
type LocaleInfo = {
category: Category
tag: string
displayName: string
nativeName: string
searchTerms?: string[]
}
const displayNames = new Intl.DisplayNames(['en'], { type: 'language' })
const $locales = computed(() => {
const result: LocaleInfo[] = []
for (const loc of props.locales) {
const tag = loc.code
const name = loc.name || displayNames.of(tag) || tag
const nativeDisplayNames = new Intl.DisplayNames([tag], { type: 'language' })
const nativeName = nativeDisplayNames.of(tag) || tag
result.push({
tag,
category: 'default',
displayName: name,
nativeName,
searchTerms: [tag, name, nativeName],
})
}
return result
})
const $query = ref('')
const isQueryEmpty = () => $query.value.trim().length === 0
const fuse = new Fuse<LocaleInfo>([], {
keys: ['tag', 'displayName', 'nativeName', 'searchTerms'],
threshold: 0.4,
distance: 100,
})
watchSyncEffect(() => fuse.setCollection($locales.value))
const $categories = computed(() => {
const categories = new Map<Category, LocaleInfo[]>()
categories.set('default', $locales.value)
return categories
})
const $searchResults = computed(() => {
return new Map<Category, LocaleInfo[]>([
['searchResult', isQueryEmpty() ? [] : fuse.search($query.value).map(({ item }) => item)],
])
})
const $displayCategories = computed(() =>
isQueryEmpty() ? $categories.value : $searchResults.value,
)
const $changingTo = ref<string | undefined>()
const isChangingLocale = () => $changingTo.value != null || props.isChanging
const $activeLocale = computed(() => {
if ($changingTo.value != null) return $changingTo.value
return props.currentLocale
})
async function changeLocale(value: string) {
if ($activeLocale.value === value) return
$changingTo.value = value
try {
await props.onLocaleChange(value)
} finally {
$changingTo.value = undefined
}
}
const $languagesList = ref<HTMLDivElement | undefined>()
function onSearchKeydown(e: KeyboardEvent) {
if (e.key !== 'Enter' || isModifierKeyDown(e)) return
const focusableTarget = $languagesList.value?.querySelector(
'input, [tabindex]:not([tabindex="-1"])',
) as HTMLElement | undefined
focusableTarget?.focus()
}
function onItemKeydown(e: KeyboardEvent, loc: LocaleInfo) {
switch (e.key) {
case 'Enter':
case ' ':
break
default:
return
}
if (isModifierKeyDown(e) || isChangingLocale()) return
changeLocale(loc.tag)
}
function onItemClick(e: MouseEvent, loc: LocaleInfo) {
if (isModifierKeyDown(e) || isChangingLocale()) return
changeLocale(loc.tag)
}
function getItemLabel(loc: LocaleInfo) {
return `${loc.nativeName}. ${loc.displayName}`
}
function getCategoryName(category: Category): string {
if (category === 'searchResult') {
return formatMessage(messages.searchResults)
}
return formatMessage(messages.standardLanguages)
}
</script>
<template>
<div class="flex flex-col gap-4">
<div v-if="$locales.length > 1" class="iconified-input w-full -mb-4">
<SearchIcon />
<input
id="language-search"
v-model="$query"
name="language"
type="search"
:placeholder="formatMessage(messages.searchFieldPlaceholder)"
class="input-text-inherit"
:disabled="isChangingLocale()"
@keydown="onSearchKeydown"
/>
<div id="language-search-results-announcements" class="visually-hidden" aria-live="polite">
{{
isQueryEmpty()
? ''
: formatMessage(messages.searchResultsAnnouncement, {
matches: $searchResults.get('searchResult')?.length ?? 0,
})
}}
</div>
</div>
<div ref="$languagesList" class="flex flex-col gap-2.5">
<template v-for="[category, categoryLocales] in $displayCategories" :key="category">
<strong class="mt-4 font-bold">
{{ getCategoryName(category) }}
</strong>
<div
v-if="category === 'searchResult' && categoryLocales.length === 0"
class="p-4 text-secondary"
tabindex="0"
>
{{ formatMessage(messages.noResults) }}
</div>
<template v-for="loc in categoryLocales" :key="loc.tag">
<div
role="button"
:aria-pressed="$activeLocale === loc.tag"
:class="[
'flex items-center gap-2 border-2 rounded-lg bg-surface-4 p-4 py-2 cursor-pointer relative overflow-hidden border-transparent transition-colors duration-100',
'focus-visible:outline focus-visible:outline-2 focus-visible:outline-brand hover:border-surface-5 border-solid',
isChangingLocale() && $changingTo !== loc.tag
? 'opacity-80 pointer-events-none cursor-default'
: '',
]"
:aria-disabled="isChangingLocale() && $changingTo !== loc.tag"
:tabindex="0"
:aria-label="getItemLabel(loc)"
@click="(e) => onItemClick(e, loc)"
@keydown="(e) => onItemKeydown(e, loc)"
>
<RadioButtonCheckedIcon v-if="$activeLocale === loc.tag" class="size-6" />
<RadioButtonIcon v-else class="size-6" />
<div class="flex flex-1 flex-wrap justify-between">
<div class="font-bold">
{{ loc.displayName }}
</div>
<div>
{{ loc.nativeName }}
</div>
</div>
</div>
</template>
</template>
</div>
</div>
</template>

View File

@@ -1 +1,2 @@
export { default as LanguageSelector } from './LanguageSelector.vue'
export { default as ThemeSelector } from './ThemeSelector.vue'

View File

@@ -1037,9 +1037,36 @@
"settings.display.theme.title": {
"defaultMessage": "Color theme"
},
"settings.language.categories.default": {
"defaultMessage": "Standard languages"
},
"settings.language.categories.search-result": {
"defaultMessage": "Search results"
},
"settings.language.description": {
"defaultMessage": "Choose your preferred language for the {platform}. Translations are contributed by volunteers <crowdin-link>on Crowdin</crowdin-link>."
},
"settings.language.languages.search-field.placeholder": {
"defaultMessage": "Search for a language..."
},
"settings.language.languages.search-results-announcement": {
"defaultMessage": "{matches, plural, =0 {No languages match} one {# language matches} other {# languages match}} your search."
},
"settings.language.languages.search.no-results": {
"defaultMessage": "No languages match your search."
},
"settings.language.platform.app": {
"defaultMessage": "app"
},
"settings.language.platform.site": {
"defaultMessage": "site"
},
"settings.language.title": {
"defaultMessage": "Language"
},
"settings.language.warning": {
"defaultMessage": "Changing the {platform} language may cause some content to appear in English if a translation is not available. The {platform} is not yet fully translated, so some content may remain in English for certain languages."
},
"settings.pats.title": {
"defaultMessage": "Personal access tokens"
},

View File

@@ -611,6 +611,48 @@ export const commonProjectSettingsMessages = defineMessages({
},
})
export const languageSelectorMessages = defineMessages({
platformApp: {
id: 'settings.language.platform.app',
defaultMessage: 'app',
},
platformSite: {
id: 'settings.language.platform.site',
defaultMessage: 'site',
},
languagesDescription: {
id: 'settings.language.description',
defaultMessage:
'Choose your preferred language for the {platform}. Translations are contributed by volunteers <crowdin-link>on Crowdin</crowdin-link>.',
},
languageWarning: {
id: 'settings.language.warning',
defaultMessage:
'Changing the {platform} language may cause some content to appear in English if a translation is not available. The {platform} is not yet fully translated, so some content may remain in English for certain languages.',
},
noResults: {
id: 'settings.language.languages.search.no-results',
defaultMessage: 'No languages match your search.',
},
searchFieldPlaceholder: {
id: 'settings.language.languages.search-field.placeholder',
defaultMessage: 'Search for a language...',
},
searchResultsAnnouncement: {
id: 'settings.language.languages.search-results-announcement',
defaultMessage:
'{matches, plural, =0 {No languages match} one {# language matches} other {# languages match}} your search.',
},
standardLanguages: {
id: 'settings.language.categories.default',
defaultMessage: 'Standard languages',
},
searchResults: {
id: 'settings.language.categories.search-result',
defaultMessage: 'Search results',
},
})
export const paymentMethodMessages = defineMessages({
amazon_pay: {
id: 'omorphia.component.purchase_modal.payment_method_type.amazon_pay',

View File

@@ -0,0 +1,8 @@
/**
* Checks if any modifier key (Ctrl, Alt, Meta, or Shift) is held down during an event.
*/
export function isModifierKeyDown(
e: Pick<KeyboardEvent, 'ctrlKey' | 'altKey' | 'metaKey' | 'shiftKey'>,
): boolean {
return e.ctrlKey || e.altKey || e.metaKey || e.shiftKey
}

View File

@@ -1,5 +1,6 @@
export * from './auto-icons'
export * from './common-messages'
export * from './events'
export * from './game-modes'
export * from './notices'
export * from './savable'