feat: preferences syncing frontend (#7192)

* feat: prefs frontend

* fix: DI issue

* fix: lint

* feat: appearance settings cleanup + fix settings, remove pinia

* fix: sync btn when logged out

* fix: prepr

* fix: sidebar issue

* feat: language coverage + cleanup

* feat: cleanup lang settings

* fix: fmt

* fix: CI

* fix: ci

* fix: storybook

* feat: bring back loader/game version sort

---------

Co-authored-by: tdgao <mr.trumgao@gmail.com>
This commit is contained in:
Calum H.
2026-08-20 07:23:01 +00:00
committed by GitHub
co-authored by tdgao
parent 79fcf41316
commit 3e07267e10
191 changed files with 3356 additions and 4372 deletions
-1
View File
@@ -13,7 +13,6 @@ export * from './page'
export * from './project'
export * from './search'
export * from './servers'
export * from './settings'
export * from './sharing'
export * from './skin'
export * from './user'
@@ -2,10 +2,11 @@
<script setup lang="ts">
import { RightArrowIcon } from '@modrinth/assets'
import { type Component, computed, nextTick, ref } from 'vue'
import { type Component, type ComponentPublicInstance, computed, nextTick, ref } from 'vue'
import { type MessageDescriptor, useVIntl } from '../../composables/i18n'
import { useScrollIndicator } from '../../composables/scroll-indicator'
import { truncatedTooltip } from '../../utils/truncate'
import NewModal from './NewModal.vue'
export interface Tab {
name: MessageDescriptor
@@ -48,6 +49,15 @@ const props = withDefaults(
const visibleTabs = computed(() => props.tabs.filter((tab) => tab.shown !== false))
const selectedTab = ref(0)
const tabLabelRefs = ref<Record<number, HTMLElement | null>>({})
function setTabLabelRef(index: number, element: Element | ComponentPublicInstance | null) {
tabLabelRefs.value[index] = element instanceof HTMLElement ? element : null
}
function tabLabelTooltip(index: number, label: string) {
return truncatedTooltip(tabLabelRefs.value[index], label)
}
const scrollContainer = ref<HTMLElement | null>(null)
const { showTopFade, showBottomFade, checkScrollState, forceCheck } =
@@ -99,9 +109,9 @@ defineExpose({ show, hide, selectedTab, setTab })
<template v-if="$slots.title" #title>
<slot name="title" />
</template>
<div class="grid grid-cols-[auto_1fr] p-6 pb-3 pr-0">
<div class="grid grid-cols-[minmax(12.5rem,18rem)_minmax(0,1fr)] p-6 pb-3 pr-0">
<div
class="flex min-w-[200px] max-h-[min(65vh,600px)] flex-col border-0 border-r-[1px] border-solid border-divider pr-4"
class="flex min-w-0 max-h-[min(65vh,600px)] flex-col border-0 border-r-[1px] border-solid border-divider pr-4"
>
<div class="relative min-h-0 flex-1">
<Transition
@@ -126,7 +136,7 @@ defineExpose({ show, hide, selectedTab, setTab })
<template v-for="(tab, index) in visibleTabs" :key="index">
<div
v-if="startsCategory(index) && tab.category"
class="px-4 pb-1 pt-2 text-xs font-bold uppercase tracking-wide text-secondary"
class="truncate px-4 pb-1 pt-2 text-xs font-bold uppercase tracking-wide text-secondary"
>
{{ formatMessage(tab.category) }}
</div>
@@ -135,18 +145,24 @@ defineExpose({ show, hide, selectedTab, setTab })
:href="tab.href ?? undefined"
:target="tab.href ? '_blank' : undefined"
:rel="tab.href ? 'noopener noreferrer' : undefined"
: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 no-underline ${!tab.href && selectedTab === index ? 'bg-button-bgSelected text-button-textSelected' : 'bg-transparent text-button-text hover:bg-button-bg hover:text-contrast'}`"
:class="`flex min-w-0 gap-2 items-center text-left rounded-xl px-4 py-2 border-none font-semibold cursor-pointer active:scale-[0.97] transition-all no-underline ${!tab.href && selectedTab === index ? 'bg-button-bgSelected text-button-textSelected' : 'bg-transparent text-button-text hover:bg-button-bg hover:text-contrast'}`"
@click="!tab.href && setTab(index)"
>
<component :is="tab.icon" class="w-4 h-4 flex-shrink-0" />
<span>{{ formatMessage(tab.name) }}</span>
<span
:ref="(element) => setTabLabelRef(index, element)"
v-tooltip="tabLabelTooltip(index, formatMessage(tab.name))"
class="min-w-0 flex-1 truncate"
>
{{ 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"
class="shrink-0 rounded-full px-1.5 py-0.5 text-xs font-bold bg-brand-highlight text-brand-green"
>
{{ formatMessage(tab.badge) }}
</span>
<RightArrowIcon v-if="tab.href" class="size-4 ml-auto" />
<RightArrowIcon v-if="tab.href" class="ml-auto size-4 shrink-0" />
</component>
</template>
</div>
@@ -1,250 +0,0 @@
<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 {
buildLocaleMessages,
defineMessages,
type LocaleDefinition,
useVIntl,
} from '../../composables/i18n'
import { metaLocaleModules } from '../../locales.ts'
import { isModifierKeyDown } from '../../utils/events'
import StyledInput from '../base/StyledInput.vue'
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',
},
})
const localeMetas = buildLocaleMessages(metaLocaleModules)
type Category = 'default' | 'searchResult'
type LocaleInfo = {
category: Category
tag: string
displayName: string
translatedName: string
searchTerms?: string[]
}
const $locales = computed(() => {
const result: LocaleInfo[] = []
for (const loc of props.locales) {
const tag = loc.code
const meta = localeMetas[tag] ?? null
const displayName = meta?.displayName ?? loc.name
const translatedName = formatMessage(loc.translatedName)
const searchTerms = meta?.searchTerms === '-' ? undefined : meta?.searchTerms?.split('\n')
result.push({
tag,
category: 'default',
displayName,
translatedName,
searchTerms,
})
}
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.translatedName}. ${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="-mb-4">
<StyledInput
id="language-search"
v-model="$query"
:icon="SearchIcon"
name="language"
type="search"
:placeholder="formatMessage(messages.searchFieldPlaceholder)"
:disabled="isChangingLocale()"
wrapper-class="w-full"
@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-semibold text-contrast">
{{ 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 gap-x-6">
<div class="font-medium">
{{ loc.displayName }}
</div>
<div>
{{ loc.translatedName }}
</div>
</div>
</div>
</template>
</template>
</div>
</div>
</template>
@@ -1,2 +0,0 @@
export { default as LanguageSelector } from './LanguageSelector.vue'
export { default as ThemeSelector } from './ThemeSelector.vue'
+1
View File
@@ -1,3 +1,4 @@
export * from './shared/appearance-settings'
export * from './shared/browse-tab'
export * from './shared/console'
export * from './shared/content-tab'
@@ -0,0 +1,37 @@
<script setup lang="ts">
import { computed } from 'vue'
const props = withDefaults(
defineProps<{
controlId: string
title: string
description: string
headingLevel?: 2 | 3
}>(),
{
headingLevel: 2,
},
)
const headingTag = computed(() => `h${props.headingLevel}`)
</script>
<template>
<div class="flex items-center justify-between gap-4">
<div class="min-w-0">
<component
:is="headingTag"
:id="`${controlId}-label`"
class="m-0 text-lg font-semibold text-contrast"
>
{{ title }}
</component>
<p class="m-0 mt-1 text-secondary">
{{ description }}
</p>
</div>
<div class="shrink-0">
<slot :labelled-by="`${controlId}-label`" />
</div>
</div>
</template>
@@ -0,0 +1,76 @@
<script setup lang="ts">
import { RadioButtonCheckedIcon, RadioButtonIcon } from '@modrinth/assets'
import { defineMessages, useVIntl } from '#ui/composables'
import type { ProjectLayout } from '../types'
defineProps<{
title: string
modelValue: ProjectLayout
}>()
const emit = defineEmits<{
'update:modelValue': [layout: ProjectLayout]
}>()
const { formatMessage } = useVIntl()
const layoutOptions = ['rows', 'grid'] as const
const messages = defineMessages({
rows: {
id: 'settings.display.project-list-layouts.mode.rows',
defaultMessage: 'Rows',
},
grid: {
id: 'settings.display.project-list-layouts.mode.grid',
defaultMessage: 'Grid',
},
})
</script>
<template>
<fieldset class="m-0 w-full min-w-0 border-0 p-0">
<legend class="mb-4 p-0 text-lg font-semibold text-contrast">
{{ title }}
</legend>
<div class="grid grid-cols-[repeat(auto-fit,minmax(9.5rem,1fr))] gap-4">
<button
v-for="layout in layoutOptions"
:key="layout"
type="button"
class="flex !w-full cursor-pointer flex-col overflow-hidden rounded-[var(--radius-md)] border border-solid border-divider bg-button-bg p-0 text-left text-primary outline-2 outline-transparent transition-[filter,transform] hover:brightness-[0.85] focus-visible:ring-4 focus-visible:ring-brand-shadow active:scale-[0.97] active:brightness-[0.8]"
:class="{ '!text-contrast': modelValue === layout }"
:aria-pressed="modelValue === layout"
@click="emit('update:modelValue', layout)"
>
<div class="flex w-full items-center justify-center bg-surface-2 p-6">
<div
class="grid h-[4.5rem] w-28 gap-1"
:class="layout === 'rows' ? 'grid-cols-1 grid-rows-4' : 'grid-cols-2 grid-rows-2'"
aria-hidden="true"
>
<div
v-for="previewCard in 4"
:key="previewCard"
class="rounded-lg border-2 border-solid"
:class="
modelValue === layout
? 'border-brand bg-brand-highlight'
: 'border-transparent bg-surface-4'
"
/>
</div>
</div>
<div class="flex grow items-center px-4 py-3 text-left">
<RadioButtonCheckedIcon
v-if="modelValue === layout"
class="mr-2 shrink-0 text-brand"
aria-hidden="true"
/>
<RadioButtonIcon v-else class="mr-2 shrink-0" aria-hidden="true" />
{{ formatMessage(messages[layout]) }}
</div>
</button>
</div>
</fieldset>
</template>
@@ -0,0 +1,31 @@
import type { Meta, StoryObj } from '@storybook/vue3-vite'
import { ref } from 'vue'
import AppearanceSettingsThemeSelector from './appearance-settings-theme-selector.vue'
const meta = {
title: 'Settings/ThemeSelector',
// @ts-ignore - error comes from generically typed component
component: AppearanceSettingsThemeSelector,
} satisfies Meta<typeof AppearanceSettingsThemeSelector>
export default meta
export const Interactive: StoryObj = {
render: () => ({
components: { AppearanceSettingsThemeSelector },
setup() {
const currentTheme = ref('dark')
const themeOptions = ['system', 'light', 'dark', 'oled', 'retro']
return { currentTheme, themeOptions }
},
template: `
<AppearanceSettingsThemeSelector
aria-label="Color theme"
v-model="currentTheme"
:theme-options="themeOptions"
system-theme-color="dark"
/>
`,
}),
}
@@ -1,25 +1,22 @@
<script setup lang="ts" generic="T extends string">
import { MoonIcon, RadioButtonCheckedIcon, RadioButtonIcon, SunIcon } from '@modrinth/assets'
import { defineMessages, useVIntl } from '../../composables/i18n'
import { defineMessages, useVIntl } from '#ui/composables/i18n'
const { formatMessage } = useVIntl()
const { updateColorTheme, currentTheme, themeOptions, systemThemeColor } = defineProps<{
updateColorTheme: (theme: T) => void
currentTheme: T
const { ariaLabel, modelValue, themeOptions, systemThemeColor } = defineProps<{
ariaLabel: string
modelValue: T
themeOptions: readonly T[]
systemThemeColor: T
}>()
const colorTheme = defineMessages({
title: {
id: 'settings.display.theme.title',
defaultMessage: 'Color theme',
},
description: {
id: 'settings.display.theme.description',
defaultMessage: 'Select your preferred color theme for Modrinth on this device.',
},
const emit = defineEmits<{
'update:modelValue': [theme: T]
}>()
const themeLabels = defineMessages({
system: {
id: 'settings.display.theme.system',
defaultMessage: 'Sync with system',
@@ -40,6 +37,9 @@ const colorTheme = defineMessages({
id: 'settings.display.theme.retro',
defaultMessage: 'Retro',
},
})
const themeTooltips = defineMessages({
preferredLight: {
id: 'settings.display.theme.preferred-light-theme',
defaultMessage: 'Preferred light theme',
@@ -50,8 +50,9 @@ const colorTheme = defineMessages({
},
})
function asString(theme: T): string {
return theme
function formatTheme(theme: T): string {
const message = themeLabels[theme as keyof typeof themeLabels]
return message ? formatMessage(message) : theme
}
function getPreviewClass(option: T): string {
@@ -61,34 +62,42 @@ function getPreviewClass(option: T): string {
</script>
<template>
<div class="theme-options mt-4">
<div class="theme-options" role="group" :aria-label="ariaLabel">
<button
v-for="option in themeOptions"
:key="option"
type="button"
class="preview-radio button-base"
:class="{ selected: currentTheme === option }"
@click="() => updateColorTheme(option)"
:class="{ selected: modelValue === option }"
:aria-pressed="modelValue === option"
@click="emit('update:modelValue', option)"
>
<div class="preview" :class="getPreviewClass(option)">
<div class="example-card card card">
<div class="preview" :class="getPreviewClass(option)" aria-hidden="true">
<div class="example-card rounded-lg border border-solid border-surface-4 bg-surface-3">
<div class="example-icon"></div>
<div class="example-text-1"></div>
<div class="example-text-2"></div>
</div>
</div>
<div class="label">
<RadioButtonCheckedIcon v-if="currentTheme === option" class="radio shrink-0" />
<RadioButtonIcon v-else class="radio shrink-0" />
{{ colorTheme[asString(option)] ? formatMessage(colorTheme[asString(option)]) : option }}
<RadioButtonCheckedIcon
v-if="modelValue === option"
class="radio shrink-0"
aria-hidden="true"
/>
<RadioButtonIcon v-else class="radio shrink-0" aria-hidden="true" />
{{ formatTheme(option) }}
<SunIcon
v-if="'light' === option"
v-tooltip="formatMessage(colorTheme.preferredLight)"
v-tooltip="formatMessage(themeTooltips.preferredLight)"
class="theme-icon shrink-0"
aria-hidden="true"
/>
<MoonIcon
v-else-if="'dark' === option"
v-tooltip="formatMessage(colorTheme.preferredDark)"
v-tooltip="formatMessage(themeTooltips.preferredDark)"
class="theme-icon shrink-0"
aria-hidden="true"
/>
</div>
</button>
@@ -0,0 +1,3 @@
export { default as AppearanceSettingsLayout } from './layout.vue'
export * from './providers'
export * from './types'
@@ -0,0 +1,181 @@
<script setup lang="ts">
import Toggle from '#ui/components/base/Toggle.vue'
import { useVIntl } from '#ui/composables'
import AppearanceSettingsThemeSelector from './components/appearance-settings-theme-selector.vue'
import AppearanceSettingRow from './components/AppearanceSettingRow.vue'
import ProjectLayoutSelector from './components/ProjectLayoutSelector.vue'
import { appearanceSettingsMessages as messages } from './messages'
import { injectAppearanceSettings } from './providers'
const appearance = injectAppearanceSettings()
const { formatMessage } = useVIntl()
const theme = appearance.theme
const currentTheme = theme.current
const themeOptions = theme.options
const systemTheme = theme.system
const syncAcrossDevices = theme.syncAcrossDevices.value
const syncDisabled = theme.syncAcrossDevices.disabled
const advancedRendering = appearance.advancedRendering.value
const nativeDecorations = appearance.nativeDecorations
const nativeDecorationsValue = nativeDecorations?.value
const projectLayouts = appearance.projectLayouts
const projectLayoutValues = projectLayouts?.value
const externalLinksNewTab = appearance.externalLinksNewTab
const externalLinksNewTabValue = externalLinksNewTab?.value
const sidebarPreferences = appearance.sidebarPreferences
const sidebarPreferenceValues = sidebarPreferences?.value
</script>
<template>
<div>
<section>
<div class="flex flex-col gap-1">
<h2 class="m-0 text-xl font-semibold text-contrast">
{{ formatMessage(messages.colorThemeTitle) }}
</h2>
<p class="m-0 text-secondary">
{{ formatMessage(messages.colorThemeDescription) }}
</p>
</div>
<AppearanceSettingsThemeSelector
class="mt-4"
:aria-label="formatMessage(messages.colorThemeTitle)"
:model-value="currentTheme"
:theme-options="themeOptions"
:system-theme-color="systemTheme"
@update:model-value="theme.update"
/>
<AppearanceSettingRow
class="mt-6"
control-id="sync-theme-across-devices"
:heading-level="3"
:title="formatMessage(messages.syncAcrossDevicesTitle)"
:description="formatMessage(messages.syncAcrossDevicesDescription)"
>
<template #default="{ labelledBy }">
<span
v-tooltip="
syncDisabled ? formatMessage(messages.syncAcrossDevicesSignedOutTooltip) : undefined
"
class="inline-flex"
>
<Toggle
id="sync-theme-across-devices"
:model-value="syncDisabled ? false : syncAcrossDevices"
:disabled="syncDisabled"
:aria-labelledby="labelledBy"
@update:model-value="theme.syncAcrossDevices.update"
/>
</span>
</template>
</AppearanceSettingRow>
</section>
<section v-if="projectLayouts" class="mt-8 border-0 border-t border-solid border-divider pt-6">
<div class="flex flex-col gap-1">
<h2 class="m-0 text-xl font-semibold text-contrast">
{{ formatMessage(messages.projectListLayoutsTitle) }}
</h2>
<p class="m-0 text-secondary">
{{ formatMessage(messages.projectListLayoutsDescription) }}
</p>
</div>
<div class="mt-4 flex flex-col gap-6">
<ProjectLayoutSelector
v-for="projectLayout in projectLayoutValues"
:key="projectLayout.type"
:title="formatMessage(messages[projectLayout.type])"
:model-value="projectLayout.layout"
@update:model-value="projectLayouts.update(projectLayout.type, $event)"
/>
</div>
</section>
<div class="mt-8 border-0 border-t border-solid border-divider pt-6">
<div class="flex flex-col gap-6">
<AppearanceSettingRow
control-id="advanced-rendering"
:title="formatMessage(messages.advancedRenderingTitle)"
:description="formatMessage(messages.advancedRenderingDescription)"
>
<template #default="{ labelledBy }">
<Toggle
id="advanced-rendering"
:model-value="advancedRendering"
:aria-labelledby="labelledBy"
@update:model-value="appearance.advancedRendering.update"
/>
</template>
</AppearanceSettingRow>
<AppearanceSettingRow
v-if="externalLinksNewTab"
control-id="external-links-new-tab"
:title="formatMessage(messages.externalLinksNewTabTitle)"
:description="formatMessage(messages.externalLinksNewTabDescription)"
>
<template #default="{ labelledBy }">
<Toggle
id="external-links-new-tab"
:model-value="externalLinksNewTabValue ?? false"
:aria-labelledby="labelledBy"
@update:model-value="externalLinksNewTab.update"
/>
</template>
</AppearanceSettingRow>
<template v-if="sidebarPreferences">
<AppearanceSettingRow
control-id="search-layout-toggle"
:title="formatMessage(messages.rightAlignedFiltersSidebarTitle)"
:description="formatMessage(messages.rightAlignedFiltersSidebarDescription)"
>
<template #default="{ labelledBy }">
<Toggle
id="search-layout-toggle"
:model-value="sidebarPreferenceValues?.right_aligned_search ?? false"
:aria-labelledby="labelledBy"
@update:model-value="sidebarPreferences.update('right_aligned_search', $event)"
/>
</template>
</AppearanceSettingRow>
<AppearanceSettingRow
control-id="project-layout-toggle"
:title="formatMessage(messages.leftAlignedContentSidebarTitle)"
:description="formatMessage(messages.leftAlignedContentSidebarDescription)"
>
<template #default="{ labelledBy }">
<Toggle
id="project-layout-toggle"
:model-value="sidebarPreferenceValues?.left_aligned_content ?? false"
:aria-labelledby="labelledBy"
@update:model-value="sidebarPreferences.update('left_aligned_content', $event)"
/>
</template>
</AppearanceSettingRow>
</template>
<AppearanceSettingRow
v-if="nativeDecorations"
control-id="native-decorations"
:title="formatMessage(messages.nativeDecorationsTitle)"
:description="formatMessage(messages.nativeDecorationsDescription)"
>
<template #default="{ labelledBy }">
<Toggle
id="native-decorations"
:model-value="nativeDecorationsValue ?? false"
:aria-labelledby="labelledBy"
@update:model-value="nativeDecorations.update"
/>
</template>
</AppearanceSettingRow>
</div>
</div>
</div>
</template>
@@ -0,0 +1,108 @@
import { defineMessages } from '#ui/composables'
export const appearanceSettingsMessages = defineMessages({
colorThemeTitle: {
id: 'settings.display.theme.title',
defaultMessage: 'Color theme',
},
colorThemeDescription: {
id: 'settings.display.theme.description',
defaultMessage: 'Select your preferred color theme across Modrinth.',
},
syncAcrossDevicesTitle: {
id: 'settings.display.theme.sync-across-devices',
defaultMessage: 'Sync theme across devices',
},
syncAcrossDevicesDescription: {
id: 'settings.display.theme.sync-across-devices.description',
defaultMessage:
"Use this theme everywhere you're signed in. Turn this off to keep a separate theme on this device.",
},
syncAcrossDevicesSignedOutTooltip: {
id: 'settings.display.theme.sync-across-devices.sign-in-tooltip',
defaultMessage: 'Sign into Modrinth to sync theme',
},
projectListLayoutsTitle: {
id: 'settings.display.project-list-layouts.title',
defaultMessage: 'Project list layouts',
},
projectListLayoutsDescription: {
id: 'settings.display.project-list-layouts.description',
defaultMessage: 'Select your preferred layout for each page that displays project lists.',
},
mod: {
id: 'settings.display.project-list-layouts.mod',
defaultMessage: 'Mods page',
},
plugin: {
id: 'settings.display.project-list-layouts.plugin',
defaultMessage: 'Plugins page',
},
datapack: {
id: 'settings.display.project-list-layouts.datapack',
defaultMessage: 'Data Packs page',
},
shader: {
id: 'settings.display.project-list-layouts.shader',
defaultMessage: 'Shaders page',
},
resourcepack: {
id: 'settings.display.project-list-layouts.resourcepack',
defaultMessage: 'Resource Packs page',
},
modpack: {
id: 'settings.display.project-list-layouts.modpack',
defaultMessage: 'Modpacks page',
},
server: {
id: 'settings.display.project-list-layouts.server',
defaultMessage: 'Servers page',
},
user: {
id: 'settings.display.project-list-layouts.user',
defaultMessage: 'User profile pages',
},
advancedRenderingTitle: {
id: 'settings.display.sidebar.advanced-rendering.title',
defaultMessage: 'Advanced rendering',
},
advancedRenderingDescription: {
id: 'settings.display.sidebar.advanced-rendering.description',
defaultMessage:
'Enables advanced rendering such as blur effects that may cause performance issues without hardware-accelerated rendering.',
},
externalLinksNewTabTitle: {
id: 'settings.display.sidebar.external-links-new-tab.title',
defaultMessage: 'Open external links in new tab',
},
externalLinksNewTabDescription: {
id: 'settings.display.sidebar.external-links-new-tab.description',
defaultMessage:
'Make links which go outside of Modrinth open in a new tab. No matter this setting, links on the same domain and in Markdown descriptions will open in the same tab, and links on ads and edit pages will open in a new tab.',
},
rightAlignedFiltersSidebarTitle: {
id: 'settings.display.sidebar.right-aligned-filters-sidebar.title',
defaultMessage: 'Right-aligned filters sidebar on search pages',
},
rightAlignedFiltersSidebarDescription: {
id: 'settings.display.sidebar.right-aligned-filters-sidebar.description',
defaultMessage: 'Aligns the filters sidebar to the right of the search results.',
},
leftAlignedContentSidebarTitle: {
id: 'settings.display.sidebar.left-aligned-content-sidebar.title',
defaultMessage: 'Left-aligned sidebar on content pages',
},
leftAlignedContentSidebarDescription: {
id: 'settings.display.sidebar.left-aligned-content-sidebar.description',
defaultMessage: "Aligns the sidebar to the left of the page's content.",
},
nativeDecorationsTitle: {
id: 'app.appearance-settings.native-decorations.title',
defaultMessage: 'System window frame',
},
nativeDecorationsDescription: {
id: 'app.appearance-settings.native-decorations.description',
defaultMessage:
"Use your operating system's title bar and window controls. Requires an app restart.",
},
})
@@ -0,0 +1,229 @@
import type { Labrinth } from '@modrinth/api-client'
import { toValue } from 'vue'
import { createContext } from '#ui/providers/create-context'
import type {
AppearanceRef,
AppearanceSetter,
AppearanceTheme,
AppearanceThemeSelection,
ProjectDisplayLocation,
ProjectLayout,
ProjectLayoutSetting,
SidebarPreferences,
WritableAppearanceSetting,
} from '../types'
interface AppearanceSetting<T> {
value: AppearanceRef<T>
update: AppearanceSetter<T>
disabled?: AppearanceRef<boolean>
}
interface ThemeSettings {
current: AppearanceRef<AppearanceThemeSelection>
options: AppearanceRef<readonly AppearanceThemeSelection[]>
system: AppearanceRef<AppearanceTheme>
update: AppearanceSetter<AppearanceThemeSelection>
syncAcrossDevices: AppearanceSetting<boolean>
}
interface ProjectLayoutSettings {
value: AppearanceRef<readonly ProjectLayoutSetting[]>
update: (type: ProjectDisplayLocation, layout: ProjectLayout) => Promise<void>
}
interface SidebarSettings {
value: AppearanceRef<SidebarPreferences>
update: (key: keyof SidebarPreferences, enabled: boolean) => Promise<void>
}
export interface AppearanceSettingsContext {
theme: ThemeSettings
advancedRendering: AppearanceSetting<boolean>
nativeDecorations?: AppearanceSetting<boolean>
projectLayouts?: ProjectLayoutSettings
externalLinksNewTab?: AppearanceSetting<boolean>
sidebarPreferences?: SidebarSettings
}
export interface AppearanceSettingsProviderOptions {
deferPersistence?: boolean
theme: {
current: AppearanceRef<AppearanceThemeSelection>
options: AppearanceRef<readonly AppearanceThemeSelection[]>
system: AppearanceRef<AppearanceTheme>
set: AppearanceSetter<AppearanceThemeSelection>
syncAcrossDevices: WritableAppearanceSetting<boolean>
syncDisabled: AppearanceRef<boolean>
}
advancedRendering: WritableAppearanceSetting<boolean>
nativeDecorations?: WritableAppearanceSetting<boolean>
projectLayouts?: {
value: AppearanceRef<readonly ProjectLayoutSetting[]>
set: (type: ProjectDisplayLocation, layout: ProjectLayout) => void | Promise<void>
}
externalLinksNewTab?: WritableAppearanceSetting<boolean>
sidebarPreferences?: {
value: AppearanceRef<SidebarPreferences>
set: (key: keyof SidebarPreferences, enabled: boolean) => void | Promise<void>
}
updatePreferences: (
preferences: Labrinth.Users.v3.PartialUserPreferences,
) => Promise<Labrinth.Users.v3.UserPreferences | undefined>
}
const layoutPreferenceKeys: Record<
ProjectDisplayLocation,
keyof Labrinth.Users.v3.LayoutPreferences
> = {
mod: 'mods',
plugin: 'plugins',
datapack: 'datapacks',
shader: 'shaders',
resourcepack: 'resourcepacks',
modpack: 'modpacks',
server: 'servers',
user: 'users',
}
const [injectAppearanceSettings, provideAppearanceSettingsContext] =
createContext<AppearanceSettingsContext>('AppearanceSettingsLayout', 'appearanceSettings')
export { injectAppearanceSettings }
function createSetting<T>(setting: WritableAppearanceSetting<T>): AppearanceSetting<T> {
return {
value: setting.value,
update: setting.set,
}
}
export function provideAppearanceSettings(
options: AppearanceSettingsProviderOptions,
): AppearanceSettingsContext {
const projectLayouts = options.projectLayouts
const sidebarPreferences = options.sidebarPreferences
async function syncThemePreference(theme: AppearanceThemeSelection): Promise<void> {
await options.updatePreferences({
appearance:
theme === 'system'
? { auto: true }
: {
auto: false,
theme,
},
})
}
async function syncThemeOrDisable(theme: AppearanceThemeSelection): Promise<void> {
try {
await syncThemePreference(theme)
} catch {
await options.theme.syncAcrossDevices.set(false)
}
}
async function updateTheme(theme: AppearanceThemeSelection): Promise<void> {
await options.theme.set(theme)
if (options.deferPersistence) return
if (!toValue(options.theme.syncAcrossDevices.value) || toValue(options.theme.syncDisabled)) {
return
}
await syncThemeOrDisable(theme)
}
async function updateThemeSync(enabled: boolean): Promise<void> {
if (toValue(options.theme.syncDisabled)) return
await options.theme.syncAcrossDevices.set(enabled)
if (options.deferPersistence) return
if (enabled) {
await syncThemeOrDisable(toValue(options.theme.current))
}
}
async function updateProjectLayout(
type: ProjectDisplayLocation,
layout: ProjectLayout,
): Promise<void> {
if (!projectLayouts) return
const previousLayout = toValue(projectLayouts.value).find(
(setting) => setting.type === type,
)?.layout
await projectLayouts.set(type, layout)
if (options.deferPersistence) return
try {
const layouts: Partial<Labrinth.Users.v3.LayoutPreferences> = {}
layouts[layoutPreferenceKeys[type]] = layout
await options.updatePreferences({ layouts })
} catch {
const currentLayout = toValue(projectLayouts.value).find(
(setting) => setting.type === type,
)?.layout
if (previousLayout && currentLayout === layout) {
await projectLayouts.set(type, previousLayout)
}
}
}
async function updateSidebarPreference(
key: keyof SidebarPreferences,
enabled: boolean,
): Promise<void> {
if (!sidebarPreferences) return
const previousValue = toValue(sidebarPreferences.value)[key]
await sidebarPreferences.set(key, enabled)
if (options.deferPersistence) return
try {
const sidebars: Partial<SidebarPreferences> = {}
sidebars[key] = enabled
await options.updatePreferences({ sidebars })
} catch {
if (toValue(sidebarPreferences.value)[key] === enabled) {
await sidebarPreferences.set(key, previousValue)
}
}
}
const context: AppearanceSettingsContext = {
theme: {
current: options.theme.current,
options: options.theme.options,
system: options.theme.system,
update: updateTheme,
syncAcrossDevices: {
value: options.theme.syncAcrossDevices.value,
disabled: options.theme.syncDisabled,
update: updateThemeSync,
},
},
advancedRendering: createSetting(options.advancedRendering),
nativeDecorations: options.nativeDecorations
? createSetting(options.nativeDecorations)
: undefined,
projectLayouts: projectLayouts
? {
value: projectLayouts.value,
update: updateProjectLayout,
}
: undefined,
externalLinksNewTab: options.externalLinksNewTab
? createSetting(options.externalLinksNewTab)
: undefined,
sidebarPreferences: sidebarPreferences
? {
value: sidebarPreferences.value,
update: updateSidebarPreference,
}
: undefined,
}
return provideAppearanceSettingsContext(context)
}
@@ -0,0 +1 @@
export * from './appearance-settings'
@@ -0,0 +1,36 @@
import type { Labrinth } from '@modrinth/api-client'
import type { ComputedRef, Ref } from 'vue'
export type AppearanceTheme = Labrinth.Users.v3.Theme
export type AppearanceThemeSelection = AppearanceTheme | 'system'
export type ProjectLayout = Labrinth.Users.v3.LayoutOption
export type SidebarPreferences = Labrinth.Users.v3.SidebarPreferences
export type AppearanceRef<T> = Ref<T> | ComputedRef<T>
export type AppearanceSetter<T> = (value: T) => void | Promise<void>
export const projectDisplayLocations = [
'mod',
'plugin',
'resourcepack',
'modpack',
'shader',
'datapack',
'server',
'user',
] as const
export type ProjectDisplayLocation = (typeof projectDisplayLocations)[number]
export function isProjectDisplayLocation(value: string): value is ProjectDisplayLocation {
return projectDisplayLocations.some((location) => location === value)
}
export interface ProjectLayoutSetting {
type: ProjectDisplayLocation
layout: ProjectLayout
}
export interface WritableAppearanceSetting<T> {
value: AppearanceRef<T>
set: AppearanceSetter<T>
}
@@ -19,6 +19,7 @@ import {
TrashIcon,
UserIcon,
} from '@modrinth/assets'
import { useSessionStorage } from '@vueuse/core'
import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue'
import Avatar from '#ui/components/base/Avatar.vue'
@@ -179,7 +180,9 @@ function getItemId(item: ContentItem) {
}
type SortMode = 'alphabetical-asc' | 'alphabetical-desc' | 'date-added-newest' | 'date-added-oldest'
const sortMode = ref<SortMode>('alphabetical-asc')
const sortMode = ctx.filterPersistKey
? useSessionStorage<SortMode>(`content-sort:${ctx.filterPersistKey}`, 'alphabetical-asc')
: ref<SortMode>('alphabetical-asc')
const sortLabels: Record<SortMode, () => string> = {
'alphabetical-asc': () => formatMessage(messages.sortAlphabeticalAscending),
@@ -99,7 +99,7 @@ export interface ContentManagerContext {
// Table item mapping (link generation differs per platform)
mapToTableItem: (item: ContentItem) => ContentCardTableItem
// Filter persistence key — when set, selected filters are saved/restored via sessionStorage
// Filter persistence key — when set, filter and sort settings are saved/restored via sessionStorage
filterPersistKey?: string
showSharedContentFilter?: Ref<boolean> | ComputedRef<boolean>
}
+1 -2
View File
@@ -1,5 +1,3 @@
export { default as AccountProfileSettings } from './AccountProfileSettings.vue'
export { default as AccountSocialSettings } from './AccountSocialSettings.vue'
export { default as ServersManageAccessPage } from './hosting/manage/[id]/access/access.vue'
export { default as ServerOnboardingPanelPage } from './hosting/manage/[id]/onboarding.vue'
export { default as ServersManageBackupsPage } from './hosting/manage/backups.vue'
@@ -8,3 +6,4 @@ export { default as ServersManageFilesPage } from './hosting/manage/files.vue'
export { default as ServersManagePageIndex } from './hosting/manage/index.vue'
export { default as ServersManageOverviewPage } from './hosting/manage/overview.vue'
export { default as ServersManageRootLayout } from './hosting/manage/root.vue'
export * from './settings'
@@ -29,13 +29,14 @@
{{ formatMessage(messages.friendRequestsTitle) }}
</h2>
<Chips
v-model="friendRequestSource"
:items="friendRequestSourceOptions"
:model-value="friendPrivacy"
:items="friendPrivacyOptions"
:format-label="formatInteractionSource"
:disabled-items="friendRequestSourceOptions"
:disabled-tooltip="formatMessage(messages.comingSoon)"
:disabled-items="preferenceControlsDisabled ? friendPrivacyOptions : undefined"
:disabled-tooltip="preferenceControlsTooltip"
:capitalize="false"
:aria-label="formatMessage(messages.friendRequestsTitle)"
@update:model-value="setFriendPrivacy"
/>
<p class="m-0 text-secondary">
{{ formatMessage(messages.friendRequestsDescription) }}
@@ -47,18 +48,38 @@
{{ formatMessage(messages.sharedInstanceInvitesTitle) }}
</h2>
<Chips
v-model="sharedInstanceInviteSource"
:items="sharedInstanceInviteSourceOptions"
:model-value="sharedInstancesPrivacy"
:items="invitePrivacyOptions"
:format-label="formatInteractionSource"
:disabled-items="sharedInstanceInviteSourceOptions"
:disabled-tooltip="formatMessage(messages.comingSoon)"
:disabled-items="preferenceControlsDisabled ? invitePrivacyOptions : undefined"
:disabled-tooltip="preferenceControlsTooltip"
:capitalize="false"
:aria-label="formatMessage(messages.sharedInstanceInvitesTitle)"
@update:model-value="setSharedInstancesPrivacy"
/>
<p class="m-0 text-secondary">
{{ formatMessage(messages.sharedInstanceInvitesDescription) }}
</p>
</div>
<div class="flex flex-col gap-2.5">
<h2 class="m-0 text-lg font-semibold text-contrast">
{{ formatMessage(messages.hostingAccessTitle) }}
</h2>
<Chips
:model-value="hostingAccessPrivacy"
:items="invitePrivacyOptions"
:format-label="formatInteractionSource"
:disabled-items="preferenceControlsDisabled ? invitePrivacyOptions : undefined"
:disabled-tooltip="preferenceControlsTooltip"
:capitalize="false"
:aria-label="formatMessage(messages.hostingAccessTitle)"
@update:model-value="setHostingAccessPrivacy"
/>
<p class="m-0 text-secondary">
{{ formatMessage(messages.hostingAccessDescription) }}
</p>
</div>
</section>
<section class="flex flex-col gap-4">
@@ -187,7 +208,7 @@
import type { Labrinth } from '@modrinth/api-client'
import { LogInIcon, SpinnerIcon, ThinkingRinthbot } from '@modrinth/assets'
import { useQuery, useQueryClient } from '@tanstack/vue-query'
import { computed, ref } from 'vue'
import { computed, ref, watch } from 'vue'
import Avatar from '#ui/components/base/Avatar.vue'
import { Button } from '#ui/components/base/buttons'
@@ -195,15 +216,20 @@ import Chips from '#ui/components/base/Chips.vue'
import EmptyState from '#ui/components/base/EmptyState.vue'
import Table, { type TableColumn } from '#ui/components/base/Table.vue'
import { defineMessages, useScrollIndicator, useVIntl } from '#ui/composables'
import { injectAuth, injectNotificationManager } from '#ui/providers'
import { injectAuth, injectNotificationManager, injectUserPreferences } from '#ui/providers'
import { commonMessages } from '#ui/utils'
import { blockedUsersQueryKey } from '../shared/user-profile/providers'
import { blockedUsersQueryKey } from '../../shared/user-profile/providers'
type BlockedUserTableColumn = 'user' | 'actions'
type BlockedUser = Labrinth.Users.v2.User & Record<BlockedUserTableColumn, unknown>
type FriendRequestSource = 'everyone' | 'mutuals' | 'no-one'
type SharedInstanceInviteSource = 'everyone' | 'friends' | 'no-one'
type FriendPrivacy = Labrinth.Users.v3.FriendPrivacy
type InvitePrivacy = Labrinth.Users.v3.InvitePrivacy
type SocialSettingsState = {
friendPrivacy: FriendPrivacy
sharedInstancesPrivacy: InvitePrivacy
hostingAccessPrivacy: InvitePrivacy
}
const props = defineProps<{
getBlockedUsers: () => Promise<Labrinth.BlockedUsers.v3.BlockedUserId[]>
@@ -213,33 +239,120 @@ const props = defineProps<{
const auth = injectAuth()
const notificationManager = injectNotificationManager()
const {
preferences,
isLoading: preferencesLoading,
isUpdating: preferencesUpdating,
updatePreferences,
} = injectUserPreferences()
const queryClient = useQueryClient()
const { formatMessage } = useVIntl()
const blockedUsersTable = ref<HTMLElement | null>(null)
const unblockingUserId = ref<string | null>(null)
const friendRequestSource = ref<FriendRequestSource>('everyone')
const sharedInstanceInviteSource = ref<SharedInstanceInviteSource>('everyone')
const friendRequestSourceOptions: FriendRequestSource[] = ['everyone', 'mutuals', 'no-one']
const sharedInstanceInviteSourceOptions: SharedInstanceInviteSource[] = [
'everyone',
'friends',
'no-one',
]
const saving = ref(false)
const preferencesInitialized = ref(false)
const friendPrivacy = ref<FriendPrivacy>('everyone')
const sharedInstancesPrivacy = ref<InvitePrivacy>('everyone')
const hostingAccessPrivacy = ref<InvitePrivacy>('everyone')
const friendPrivacyOptions: FriendPrivacy[] = ['everyone', 'mutual', 'none']
const invitePrivacyOptions: InvitePrivacy[] = ['everyone', 'friends', 'none']
const preferenceControlsDisabled = computed(
() => preferencesLoading.value || preferencesUpdating.value || saving.value || !preferences.value,
)
const preferenceControlsTooltip = computed(() => {
if (preferencesLoading.value) return formatMessage(messages.loadingPreferences)
if (saving.value || preferencesUpdating.value) return formatMessage(messages.savingPreferences)
return formatMessage(messages.preferencesUnavailable)
})
const { showTopFade, showBottomFade, checkScrollState } = useScrollIndicator(blockedUsersTable)
function formatInteractionSource(source: FriendRequestSource | SharedInstanceInviteSource): string {
const originalState = computed<SocialSettingsState>(() => ({
friendPrivacy: preferences.value?.social.friend_privacy ?? 'everyone',
sharedInstancesPrivacy: preferences.value?.social.shared_instances_privacy ?? 'everyone',
hostingAccessPrivacy: preferences.value?.social.hosting_access_privacy ?? 'everyone',
}))
const modifiedState = computed<Partial<SocialSettingsState>>(() => ({
...(friendPrivacy.value !== originalState.value.friendPrivacy
? { friendPrivacy: friendPrivacy.value }
: {}),
...(sharedInstancesPrivacy.value !== originalState.value.sharedInstancesPrivacy
? { sharedInstancesPrivacy: sharedInstancesPrivacy.value }
: {}),
...(hostingAccessPrivacy.value !== originalState.value.hostingAccessPrivacy
? { hostingAccessPrivacy: hostingAccessPrivacy.value }
: {}),
}))
const hasChanges = computed(() => Object.keys(modifiedState.value).length > 0)
function formatInteractionSource(source: FriendPrivacy | InvitePrivacy): string {
switch (source) {
case 'everyone':
return formatMessage(messages.everyone)
case 'mutuals':
case 'mutual':
return formatMessage(messages.friendsOfFriends)
case 'friends':
return formatMessage(messages.friends)
case 'no-one':
case 'none':
return formatMessage(messages.noOne)
}
}
watch(
preferences,
(value) => {
if (!value) return
if (preferencesInitialized.value && hasChanges.value && !saving.value) return
friendPrivacy.value = value.social.friend_privacy
sharedInstancesPrivacy.value = value.social.shared_instances_privacy
hostingAccessPrivacy.value = value.social.hosting_access_privacy
preferencesInitialized.value = true
},
{ immediate: true, flush: 'sync' },
)
function setFriendPrivacy(value: FriendPrivacy | null): void {
if (!value) return
friendPrivacy.value = value
}
function setSharedInstancesPrivacy(value: InvitePrivacy | null): void {
if (!value) return
sharedInstancesPrivacy.value = value
}
function setHostingAccessPrivacy(value: InvitePrivacy | null): void {
if (!value) return
hostingAccessPrivacy.value = value
}
function reset(): void {
friendPrivacy.value = originalState.value.friendPrivacy
sharedInstancesPrivacy.value = originalState.value.sharedInstancesPrivacy
hostingAccessPrivacy.value = originalState.value.hostingAccessPrivacy
}
async function save(): Promise<void> {
if (!hasChanges.value || saving.value) return
saving.value = true
try {
await updatePreferences({
social: {
friend_privacy: friendPrivacy.value,
shared_instances_privacy: sharedInstancesPrivacy.value,
hosting_access_privacy: hostingAccessPrivacy.value,
},
})
} catch {
return
} finally {
saving.value = false
}
}
defineExpose({ hasChanges, originalState, modifiedState, saving, reset, save })
const columns = computed<TableColumn<BlockedUserTableColumn>[]>(() => [
{
key: 'user',
@@ -342,12 +455,19 @@ const messages = defineMessages({
},
sharedInstanceInvitesTitle: {
id: 'settings.social.shared-instance-invites.title',
defaultMessage: 'Invitations',
defaultMessage: 'Shared instance invites',
},
sharedInstanceInvitesDescription: {
id: 'settings.social.shared-instance-invites.description',
defaultMessage:
'Control who can send you invites to shared instances and Modrinth Hosting panels.',
defaultMessage: 'Control who can send you invites to shared instances.',
},
hostingAccessTitle: {
id: 'settings.social.hosting-access.title',
defaultMessage: 'Hosting access invites',
},
hostingAccessDescription: {
id: 'settings.social.hosting-access.description',
defaultMessage: 'Control who can invite you to manage a Modrinth Hosting server.',
},
everyone: {
id: 'settings.social.interaction-source.everyone',
@@ -365,9 +485,17 @@ const messages = defineMessages({
id: 'settings.social.interaction-source.no-one',
defaultMessage: 'No one',
},
comingSoon: {
id: 'settings.social.interaction-source.coming-soon',
defaultMessage: 'Coming soon!',
savingPreferences: {
id: 'settings.social.interaction-source.saving',
defaultMessage: 'Saving your preferences…',
},
loadingPreferences: {
id: 'settings.social.interaction-source.loading',
defaultMessage: 'Loading your preferences…',
},
preferencesUnavailable: {
id: 'settings.social.interaction-source.unavailable',
defaultMessage: 'Your preferences are currently unavailable.',
},
blockedUsersTitle: {
id: 'settings.social.blocked-users.title',
@@ -0,0 +1,3 @@
export { default as AccountProfileSettings } from './AccountProfileSettings.vue'
export { default as AccountSocialSettings } from './AccountSocialSettings.vue'
export { default as LanguageSettings } from './language-settings/index.vue'
@@ -0,0 +1,121 @@
<template>
<div>
<h2 class="m-0 text-xl font-semibold text-contrast">
{{ formatMessage(commonSettingsMessages.language) }}
</h2>
<Admonition type="warning" class="mb-4 mt-2">
{{ formatMessage(languageSelectorMessages.languageWarning, { platform }) }}
</Admonition>
<p class="m-0 mb-4 text-secondary">
<IntlFormatted
:message-id="languageSelectorMessages.languagesDescription"
:values="{ platform }"
>
<template #~crowdin-link="{ children }">
<AutoLink to="https://translate.modrinth.com" class="text-link">
<component :is="() => children" />
</AutoLink>
</template>
</IntlFormatted>
</p>
<LanguageSettingsSelector
:product="product"
:current-locale="current.locale"
:locales="LOCALES"
:on-locale-change="onLocaleChange"
:is-changing="saving"
:coverage-by-locale="languageCoverage[product]"
/>
</div>
</template>
<script setup lang="ts">
import { computed, onBeforeUnmount, ref, watch } from 'vue'
import { Admonition, AutoLink, IntlFormatted } from '#ui/components/base'
import { LOCALES, useVIntl } from '#ui/composables'
import { injectI18n, injectUserPreferences } from '#ui/providers'
import { commonSettingsMessages, languageSelectorMessages, useSavable } from '#ui/utils'
import { languageCoverage } from './language-settings-coverage.generated'
import LanguageSettingsSelector from './language-settings-selector.vue'
const props = defineProps<{
product: 'app' | 'website'
persistLocale?: (locale: string) => void | Promise<void>
}>()
const { formatMessage } = useVIntl()
const { locale, setLocale } = injectI18n()
const { preferences, updatePreferences } = injectUserPreferences()
const platform = computed(() =>
formatMessage(
props.product === 'app'
? languageSelectorMessages.platformApp
: languageSelectorMessages.platformSite,
),
)
const persistedLocale = ref(locale.value)
let localeChangeQueue = Promise.resolve()
const { saved, current, changes, saving, hasChanges, reset, save } = useSavable(
() => ({ locale: persistedLocale.value }),
async () => {
await updatePreferences({ localization: { locale: current.value.locale } })
await queueLocaleChange(current.value.locale, true)
await props.persistLocale?.(current.value.locale)
persistedLocale.value = current.value.locale
},
)
function queueLocaleChange(newLocale: string, persist = false): Promise<void> {
const request = localeChangeQueue.then(() => Promise.resolve(setLocale(newLocale, { persist })))
localeChangeQueue = request.catch(() => undefined)
return request
}
function onLocaleChange(newLocale: string): void {
current.value.locale = newLocale
void queueLocaleChange(newLocale).catch(() => undefined)
}
function resetLanguageSettings(): void {
reset()
void queueLocaleChange(current.value.locale).catch(() => undefined)
}
async function saveLanguageSettings(): Promise<void> {
try {
await save()
} catch {
return
}
}
watch(
preferences,
(value) => {
if (!value || hasChanges.value) return
persistedLocale.value = value.localization.locale
},
{ immediate: true, flush: 'sync' },
)
onBeforeUnmount(() => {
if (hasChanges.value || locale.value !== persistedLocale.value) {
void queueLocaleChange(persistedLocale.value).catch(() => undefined)
}
})
defineExpose({
originalState: saved,
modifiedState: changes,
hasChanges,
saving,
reset: resetLanguageSettings,
save: saveLanguageSettings,
})
</script>
@@ -0,0 +1,3 @@
import type { LanguageCoverageByProduct } from './language-settings-coverage'
export declare const languageCoverage: LanguageCoverageByProduct
@@ -0,0 +1,15 @@
export type LanguageProduct = 'app' | 'website'
export interface LanguageCoverageStats {
percentage: number
interfaceCoverage: number
translationCoverage: number
translatedMessages: number
totalMessages: number
unlocalizedStrings: number
}
export type LanguageCoverageByProduct = Record<
LanguageProduct,
Record<string, LanguageCoverageStats>
>
@@ -0,0 +1,335 @@
<script setup lang="ts">
import { SearchIcon } from '@modrinth/assets'
import Fuse from 'fuse.js/dist/fuse.basic'
import { computed, onMounted, ref } from 'vue'
import Button from '#ui/components/base/buttons/Button.vue'
import StyledInput from '#ui/components/base/StyledInput.vue'
import {
buildLocaleMessages,
defineMessages,
type LocaleDefinition,
useVIntl,
} from '#ui/composables/i18n'
import { metaLocaleModules } from '#ui/locales.ts'
import { isModifierKeyDown } from '#ui/utils/events'
import type { LanguageCoverageStats } from './language-settings-coverage'
const { formatMessage } = useVIntl()
const props = defineProps<{
product: 'app' | 'website'
currentLocale: string
locales: LocaleDefinition[]
onLocaleChange: (locale: string) => void | Promise<void>
isChanging?: boolean
coverageByLocale?: Record<string, LanguageCoverageStats>
}>()
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',
},
coverageLabel: {
id: 'settings.language.coverage.label',
defaultMessage: '{percentage}% supported',
},
appCoverageTooltip: {
id: 'settings.language.coverage.app-tooltip',
defaultMessage: 'About {percentage}% of the Modrinth App is available in this language.',
},
websiteCoverageTooltip: {
id: 'settings.language.coverage.website-tooltip',
defaultMessage: 'About {percentage}% of the website is available in this language.',
},
})
const localeMetas = buildLocaleMessages(metaLocaleModules)
type Category = 'default' | 'searchResult'
type LocaleInfo = {
category: Category
tag: string
displayName: string
browserDisplayName: string
flagUrl?: string
searchTerms?: string[]
coverage?: LanguageCoverageStats
}
const localeFlagRegions: Record<string, string> = {
'es-419': 'mx',
'sr-CS': 'rs',
}
const $browserLocales = ref([props.currentLocale])
onMounted(() => {
$browserLocales.value = navigator.languages.length
? [...navigator.languages]
: [navigator.language]
})
const $browserDisplayNames = computed(() => {
try {
return new Intl.DisplayNames($browserLocales.value, { type: 'language' })
} catch {
return undefined
}
})
function getBrowserDisplayName(tag: string, fallback: string): string {
try {
return $browserDisplayNames.value?.of(tag) ?? fallback
} catch {
try {
return $browserDisplayNames.value?.of(tag.split('-')[0]) ?? fallback
} catch {
return fallback
}
}
}
function getFlagUrl(tag: string): string | undefined {
const region = localeFlagRegions[tag] ?? tag.split('-').at(-1)
if (!region || !/^[a-z]{2}$/i.test(region)) return undefined
return `https://flagcdn.com/${region.toLowerCase()}.svg`
}
const $locales = computed(() => {
const result: LocaleInfo[] = []
for (const loc of props.locales) {
const tag = loc.code
const meta = localeMetas[tag] ?? null
const displayName = meta?.displayName ?? loc.name
const translatedName = formatMessage(loc.translatedName)
const browserDisplayName = getBrowserDisplayName(tag, translatedName)
const searchTerms = meta?.searchTerms === '-' ? undefined : meta?.searchTerms?.split('\n')
result.push({
tag,
category: 'default',
displayName,
browserDisplayName,
flagUrl: getFlagUrl(tag),
searchTerms,
coverage: props.coverageByLocale?.[tag],
})
}
return result.sort((a, b) => (b.coverage?.percentage ?? -1) - (a.coverage?.percentage ?? -1))
})
const $query = ref('')
const isQueryEmpty = () => $query.value.trim().length === 0
const fuse = computed(
() =>
new Fuse<LocaleInfo>($locales.value, {
keys: ['tag', 'displayName', 'browserDisplayName', 'searchTerms'],
threshold: 0.4,
distance: 100,
}),
)
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.value.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
})
function changeLocale(value: string) {
if ($activeLocale.value === value) return
const result = props.onLocaleChange(value)
if (!result) return
$changingTo.value = value
void result.then(
() => {
$changingTo.value = undefined
},
() => {
$changingTo.value = undefined
},
)
}
const $languagesList = ref<HTMLDivElement | undefined>()
function onSearchKeydown(e: KeyboardEvent) {
if (e.key !== 'Enter' || isModifierKeyDown(e)) return
const focusableTarget = $languagesList.value?.querySelector(
'button:not(:disabled), [tabindex]:not([tabindex="-1"])',
) as HTMLElement | undefined
focusableTarget?.focus()
}
function onItemClick(e: MouseEvent, loc: LocaleInfo) {
if (isModifierKeyDown(e) || isChangingLocale()) return
changeLocale(loc.tag)
}
function showBrowserDisplayName(loc: LocaleInfo): boolean {
return (
loc.browserDisplayName.localeCompare(loc.displayName, undefined, { sensitivity: 'base' }) !== 0
)
}
function getItemLabel(loc: LocaleInfo) {
const coverageLabel = loc.coverage
? `. ${formatMessage(messages.coverageLabel, { percentage: loc.coverage.percentage })}`
: ''
const browserDisplayName = showBrowserDisplayName(loc) ? `. ${loc.browserDisplayName}` : ''
return `${loc.displayName}${browserDisplayName}${coverageLabel}`
}
function getCoverageTooltip(coverage: LanguageCoverageStats): string {
const message =
props.product === 'app' ? messages.appCoverageTooltip : messages.websiteCoverageTooltip
return formatMessage(message, {
percentage: coverage.percentage,
})
}
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="-mb-4">
<StyledInput
id="language-search"
v-model="$query"
:icon="SearchIcon"
name="language"
type="search"
:placeholder="formatMessage(messages.searchFieldPlaceholder)"
:disabled="isChangingLocale()"
wrapper-class="w-full"
@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-semibold text-contrast">
{{ 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">
<Button
:type="$activeLocale === loc.tag ? 'colored' : 'base'"
:color="$activeLocale === loc.tag ? 'green' : undefined"
:aria-pressed="$activeLocale === loc.tag"
:disabled="isChangingLocale() && $changingTo !== loc.tag"
:aria-label="getItemLabel(loc)"
class="w-full !justify-start !gap-2 !text-left sm:!h-10"
:class="
$activeLocale === loc.tag
? '!bg-[var(--color-button-bg-selected)] !text-[var(--color-button-text-selected)]'
: ''
"
@click="(e) => onItemClick(e, loc)"
>
<img
v-if="loc.flagUrl"
:src="loc.flagUrl"
alt=""
aria-hidden="true"
class="h-4 w-6 shrink-0 rounded-sm object-cover"
loading="lazy"
/>
<span class="flex min-w-0 flex-1 items-baseline gap-2 overflow-hidden">
<span class="truncate text-sm sm:text-base">{{ loc.displayName }}</span>
<span
v-if="showBrowserDisplayName(loc)"
class="truncate text-xs font-normal text-secondary sm:text-sm"
>
{{ loc.browserDisplayName }}
</span>
</span>
<span
v-if="loc.coverage"
v-tooltip="getCoverageTooltip(loc.coverage)"
class="ml-auto shrink-0 text-xs font-normal text-secondary sm:text-sm"
>
{{ loc.coverage.percentage }}%
</span>
</Button>
</template>
</template>
</div>
</div>
</template>
-3
View File
@@ -5216,9 +5216,6 @@
"settings.social.friend-requests.title": {
"defaultMessage": "Freundschaftsanfragen"
},
"settings.social.interaction-source.coming-soon": {
"defaultMessage": "Bald verfügbar!"
},
"settings.social.interaction-source.everyone": {
"defaultMessage": "Jeder"
},
-3
View File
@@ -5216,9 +5216,6 @@
"settings.social.friend-requests.title": {
"defaultMessage": "Freundschaftsanfragen"
},
"settings.social.interaction-source.coming-soon": {
"defaultMessage": "Bald verfügbar!"
},
"settings.social.interaction-source.everyone": {
"defaultMessage": "Jeder"
},
+107 -5
View File
@@ -35,6 +35,12 @@
"affiliate.viewAnalytics": {
"defaultMessage": "View analytics"
},
"app.appearance-settings.native-decorations.description": {
"defaultMessage": "Use your operating system's title bar and window controls. Requires an app restart."
},
"app.appearance-settings.native-decorations.title": {
"defaultMessage": "System window frame"
},
"app.server-settings.failed-to-load-server": {
"defaultMessage": "Failed to load server settings"
},
@@ -5540,11 +5546,71 @@
"settings.billing.title": {
"defaultMessage": "Billing and subscriptions"
},
"settings.display.project-list-layouts.datapack": {
"defaultMessage": "Data Packs page"
},
"settings.display.project-list-layouts.description": {
"defaultMessage": "Select your preferred layout for each page that displays project lists."
},
"settings.display.project-list-layouts.mod": {
"defaultMessage": "Mods page"
},
"settings.display.project-list-layouts.mode.grid": {
"defaultMessage": "Grid"
},
"settings.display.project-list-layouts.mode.rows": {
"defaultMessage": "Rows"
},
"settings.display.project-list-layouts.modpack": {
"defaultMessage": "Modpacks page"
},
"settings.display.project-list-layouts.plugin": {
"defaultMessage": "Plugins page"
},
"settings.display.project-list-layouts.resourcepack": {
"defaultMessage": "Resource Packs page"
},
"settings.display.project-list-layouts.server": {
"defaultMessage": "Servers page"
},
"settings.display.project-list-layouts.shader": {
"defaultMessage": "Shaders page"
},
"settings.display.project-list-layouts.title": {
"defaultMessage": "Project list layouts"
},
"settings.display.project-list-layouts.user": {
"defaultMessage": "User profile pages"
},
"settings.display.sidebar.advanced-rendering.description": {
"defaultMessage": "Enables advanced rendering such as blur effects that may cause performance issues without hardware-accelerated rendering."
},
"settings.display.sidebar.advanced-rendering.title": {
"defaultMessage": "Advanced rendering"
},
"settings.display.sidebar.external-links-new-tab.description": {
"defaultMessage": "Make links which go outside of Modrinth open in a new tab. No matter this setting, links on the same domain and in Markdown descriptions will open in the same tab, and links on ads and edit pages will open in a new tab."
},
"settings.display.sidebar.external-links-new-tab.title": {
"defaultMessage": "Open external links in new tab"
},
"settings.display.sidebar.left-aligned-content-sidebar.description": {
"defaultMessage": "Aligns the sidebar to the left of the page's content."
},
"settings.display.sidebar.left-aligned-content-sidebar.title": {
"defaultMessage": "Left-aligned sidebar on content pages"
},
"settings.display.sidebar.right-aligned-filters-sidebar.description": {
"defaultMessage": "Aligns the filters sidebar to the right of the search results."
},
"settings.display.sidebar.right-aligned-filters-sidebar.title": {
"defaultMessage": "Right-aligned filters sidebar on search pages"
},
"settings.display.theme.dark": {
"defaultMessage": "Dark"
},
"settings.display.theme.description": {
"defaultMessage": "Select your preferred color theme for Modrinth on this device."
"defaultMessage": "Select your preferred color theme across Modrinth."
},
"settings.display.theme.light": {
"defaultMessage": "Light"
@@ -5561,6 +5627,15 @@
"settings.display.theme.retro": {
"defaultMessage": "Retro"
},
"settings.display.theme.sync-across-devices": {
"defaultMessage": "Sync theme across devices"
},
"settings.display.theme.sync-across-devices.description": {
"defaultMessage": "Use this theme everywhere you're signed in. Turn this off to keep a separate theme on this device."
},
"settings.display.theme.sync-across-devices.sign-in-tooltip": {
"defaultMessage": "Sign into Modrinth to sync theme"
},
"settings.display.theme.system": {
"defaultMessage": "Sync with system"
},
@@ -5579,6 +5654,15 @@
"settings.language.categories.search-result": {
"defaultMessage": "Search results"
},
"settings.language.coverage.app-tooltip": {
"defaultMessage": "About {percentage}% of the Modrinth App is available in this language."
},
"settings.language.coverage.label": {
"defaultMessage": "{percentage}% supported"
},
"settings.language.coverage.website-tooltip": {
"defaultMessage": "About {percentage}% of the website is available in this language."
},
"settings.language.description": {
"defaultMessage": "Choose your preferred language for the {platform}. Translations are contributed by volunteers <crowdin-link>on Crowdin</crowdin-link>."
},
@@ -5606,6 +5690,12 @@
"settings.pats.title": {
"defaultMessage": "Personal access tokens"
},
"settings.preferences.update-failed.description": {
"defaultMessage": "Your settings could not be saved to your Modrinth account. Please try again."
},
"settings.preferences.update-failed.title": {
"defaultMessage": "Failed to sync settings"
},
"settings.profile.bio.description": {
"defaultMessage": "A short description to tell everyone a little bit about you."
},
@@ -5690,8 +5780,11 @@
"settings.social.friend-requests.title": {
"defaultMessage": "Friend requests"
},
"settings.social.interaction-source.coming-soon": {
"defaultMessage": "Coming soon!"
"settings.social.hosting-access.description": {
"defaultMessage": "Control who can invite you to manage a Modrinth Hosting server."
},
"settings.social.hosting-access.title": {
"defaultMessage": "Hosting access invites"
},
"settings.social.interaction-source.everyone": {
"defaultMessage": "Everyone"
@@ -5702,14 +5795,23 @@
"settings.social.interaction-source.friends-of-friends": {
"defaultMessage": "Friends of friends"
},
"settings.social.interaction-source.loading": {
"defaultMessage": "Loading your preferences…"
},
"settings.social.interaction-source.no-one": {
"defaultMessage": "No one"
},
"settings.social.interaction-source.saving": {
"defaultMessage": "Saving your preferences…"
},
"settings.social.interaction-source.unavailable": {
"defaultMessage": "Your preferences are currently unavailable."
},
"settings.social.shared-instance-invites.description": {
"defaultMessage": "Control who can send you invites to shared instances and Modrinth Hosting panels."
"defaultMessage": "Control who can send you invites to shared instances."
},
"settings.social.shared-instance-invites.title": {
"defaultMessage": "Invitations"
"defaultMessage": "Shared instance invites"
},
"settings.social.sign-in-required.description": {
"defaultMessage": "You can control who can interact with you, and manage blocked users with a Modrinth Account"
@@ -5216,9 +5216,6 @@
"settings.social.friend-requests.title": {
"defaultMessage": "Solicitudes de amistad"
},
"settings.social.interaction-source.coming-soon": {
"defaultMessage": "¡Próximamente!"
},
"settings.social.interaction-source.everyone": {
"defaultMessage": "Todos"
},
-3
View File
@@ -5213,9 +5213,6 @@
"settings.social.friend-requests.title": {
"defaultMessage": "Solicitudes de amistad"
},
"settings.social.interaction-source.coming-soon": {
"defaultMessage": "¡Próximamente!"
},
"settings.social.interaction-source.everyone": {
"defaultMessage": "Todos"
},
-3
View File
@@ -5207,9 +5207,6 @@
"settings.social.friend-requests.title": {
"defaultMessage": "Demandes d'ami"
},
"settings.social.interaction-source.coming-soon": {
"defaultMessage": "À venir !"
},
"settings.social.interaction-source.everyone": {
"defaultMessage": "Tout le monde"
},
-3
View File
@@ -4394,9 +4394,6 @@
"settings.social.friend-requests.title": {
"defaultMessage": "Barátkérelmek"
},
"settings.social.interaction-source.coming-soon": {
"defaultMessage": "Hamarosan érkezik!"
},
"settings.social.interaction-source.everyone": {
"defaultMessage": "Bárki"
},
-3
View File
@@ -5174,9 +5174,6 @@
"settings.social.friend-requests.title": {
"defaultMessage": "Richieste di amicizia"
},
"settings.social.interaction-source.coming-soon": {
"defaultMessage": "In arrivo!"
},
"settings.social.interaction-source.everyone": {
"defaultMessage": "Tutti"
},
-3
View File
@@ -4889,9 +4889,6 @@
"settings.social.blocked-users.unblock": {
"defaultMessage": "ブロックを解除"
},
"settings.social.interaction-source.coming-soon": {
"defaultMessage": "近日公開!"
},
"settings.social.interaction-source.everyone": {
"defaultMessage": "全員"
},
-3
View File
@@ -5198,9 +5198,6 @@
"settings.social.friend-requests.title": {
"defaultMessage": "친구 요청"
},
"settings.social.interaction-source.coming-soon": {
"defaultMessage": "곧 제공할 예정입니다!"
},
"settings.social.interaction-source.everyone": {
"defaultMessage": "모든 사람"
},
-3
View File
@@ -5207,9 +5207,6 @@
"settings.social.friend-requests.title": {
"defaultMessage": "Vriendschapsverzoeken"
},
"settings.social.interaction-source.coming-soon": {
"defaultMessage": "Binnenkort beschikbaar!"
},
"settings.social.interaction-source.everyone": {
"defaultMessage": "Iedereen"
},
-3
View File
@@ -5180,9 +5180,6 @@
"settings.social.friend-requests.title": {
"defaultMessage": "Zaproszenia do znajomych"
},
"settings.social.interaction-source.coming-soon": {
"defaultMessage": "Już wkrótce!"
},
"settings.social.interaction-source.everyone": {
"defaultMessage": "Wszyscy"
},
-3
View File
@@ -5216,9 +5216,6 @@
"settings.social.friend-requests.title": {
"defaultMessage": "Solicitações de amizade"
},
"settings.social.interaction-source.coming-soon": {
"defaultMessage": "Em breve!"
},
"settings.social.interaction-source.everyone": {
"defaultMessage": "Todos"
},
-3
View File
@@ -5129,9 +5129,6 @@
"settings.social.friend-requests.title": {
"defaultMessage": "Запросы в друзья"
},
"settings.social.interaction-source.coming-soon": {
"defaultMessage": "Скоро будет!"
},
"settings.social.interaction-source.everyone": {
"defaultMessage": "Все"
},
-3
View File
@@ -4640,9 +4640,6 @@
"settings.social.friend-requests.title": {
"defaultMessage": "Vänförfrågningar"
},
"settings.social.interaction-source.coming-soon": {
"defaultMessage": "Kommer snart!"
},
"settings.social.interaction-source.everyone": {
"defaultMessage": "Alla"
},
-3
View File
@@ -5195,9 +5195,6 @@
"settings.social.friend-requests.title": {
"defaultMessage": "Запити в друзі"
},
"settings.social.interaction-source.coming-soon": {
"defaultMessage": "Незабаром!"
},
"settings.social.interaction-source.everyone": {
"defaultMessage": "Усі"
},
-3
View File
@@ -5216,9 +5216,6 @@
"settings.social.friend-requests.title": {
"defaultMessage": "好友邀请"
},
"settings.social.interaction-source.coming-soon": {
"defaultMessage": "即将到来!"
},
"settings.social.interaction-source.everyone": {
"defaultMessage": "所有人"
},
-3
View File
@@ -5216,9 +5216,6 @@
"settings.social.friend-requests.title": {
"defaultMessage": "好友邀請"
},
"settings.social.interaction-source.coming-soon": {
"defaultMessage": "即將推出!"
},
"settings.social.interaction-source.everyone": {
"defaultMessage": "所有人"
},
+1 -1
View File
@@ -5,7 +5,7 @@ import { inject, provide } from 'vue'
export interface I18nContext {
locale: Ref<string>
t: (key: string, values?: Record<string, unknown>) => string
setLocale: (locale: string) => Promise<void> | void
setLocale: (locale: string, options?: { persist?: boolean }) => Promise<void> | void
}
export const I18N_INJECTION_KEY: InjectionKey<I18nContext> = Symbol('i18n')
+1
View File
@@ -19,4 +19,5 @@ export * from './server-context'
export * from './server-settings-modal'
export * from './tags'
export * from './user-country'
export * from './user-preferences'
export * from './web-notifications'
@@ -0,0 +1,116 @@
import type { Labrinth } from '@modrinth/api-client'
import { useMutation, useQuery, useQueryClient } from '@tanstack/vue-query'
import { computed, type ComputedRef } from 'vue'
import { defineMessages, useVIntl } from '../composables/i18n'
import type { AuthProvider } from './auth'
import { createContext } from './create-context'
import type { AbstractWebNotificationManager } from './web-notifications'
export const userPreferencesQueryKey = (userId: string | null | undefined) =>
['user', userId ?? null, 'preferences', 'v3'] as const
export interface UserPreferencesContext {
preferences: ComputedRef<Labrinth.Users.v3.UserPreferences | undefined>
isLoading: ComputedRef<boolean>
isUpdating: ComputedRef<boolean>
updatePreferences: (
preferences: Labrinth.Users.v3.PartialUserPreferences,
) => Promise<Labrinth.Users.v3.UserPreferences | undefined>
}
const [injectUserPreferencesContext, provideUserPreferencesContext] =
createContext<UserPreferencesContext>('root', 'userPreferences')
export const injectUserPreferences = injectUserPreferencesContext
const messages = defineMessages({
updateFailedTitle: {
id: 'settings.preferences.update-failed.title',
defaultMessage: 'Failed to sync settings',
},
updateFailedDescription: {
id: 'settings.preferences.update-failed.description',
defaultMessage: 'Your settings could not be saved to your Modrinth account. Please try again.',
},
})
export function setupUserPreferencesProvider({
auth,
getPreferences,
patchPreferences,
notificationManager,
}: {
auth: AuthProvider
getPreferences: (userId: string) => Promise<Labrinth.Users.v3.UserPreferences>
patchPreferences: (
userId: string,
preferences: Labrinth.Users.v3.PartialUserPreferences,
) => Promise<Labrinth.Users.v3.UserPreferences>
notificationManager: AbstractWebNotificationManager
}): UserPreferencesContext {
const queryClient = useQueryClient()
const { formatMessage } = useVIntl()
const userId = computed(() => auth.user.value?.id ?? null)
const preferencesQuery = useQuery({
queryKey: computed(() => userPreferencesQueryKey(userId.value)),
queryFn: () => {
if (!userId.value) {
throw new Error('A signed-in user is required to fetch preferences')
}
return getPreferences(userId.value)
},
enabled: computed(() => Boolean(userId.value)),
staleTime: 30_000,
})
let updateQueue = Promise.resolve()
const preferencesMutation = useMutation({
mutationFn: ({
userId,
preferences,
}: {
userId: string
preferences: Labrinth.Users.v3.PartialUserPreferences
}) => {
const request = updateQueue.then(() => patchPreferences(userId, preferences))
updateQueue = request.then(
() => undefined,
() => undefined,
)
return request
},
onSuccess: (preferences, variables) => {
queryClient.setQueryData(userPreferencesQueryKey(variables.userId), preferences)
},
onError: (_error, variables) => {
void queryClient.invalidateQueries({
queryKey: userPreferencesQueryKey(variables.userId),
})
notificationManager.addNotification({
type: 'warning',
title: formatMessage(messages.updateFailedTitle),
text: formatMessage(messages.updateFailedDescription),
})
},
})
const context: UserPreferencesContext = {
preferences: computed(() => preferencesQuery.data.value),
isLoading: computed(() => preferencesQuery.isLoading.value),
isUpdating: computed(() => preferencesMutation.isPending.value),
updatePreferences: async (preferences) => {
if (!userId.value) return undefined
return preferencesMutation.mutateAsync({
userId: userId.value,
preferences,
})
},
}
provideUserPreferencesContext(context)
return context
}
+11 -1
View File
@@ -1,6 +1,6 @@
import { cloneDeep, isEqual } from 'es-toolkit'
import type { ComputedRef, Ref } from 'vue'
import { computed, ref } from 'vue'
import { computed, ref, watch } from 'vue'
export function useSavable<T extends Record<string, unknown>>(
data: () => T,
@@ -31,6 +31,16 @@ export function useSavable<T extends Record<string, unknown>>(
const hasChanges = computed(() => Object.keys(changes.value).length > 0)
watch(
savedValues,
(value, previousValue) => {
if (isEqual(currentValues.value, previousValue)) {
currentValues.value = cloneDeep(value)
}
},
{ deep: true },
)
const reset = () => {
currentValues.value = cloneDeep(data())
}