mirror of
https://github.com/modrinth/code.git
synced 2026-08-25 00:55:25 +00:00
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:
@@ -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,3 +1,4 @@
|
||||
export * from './shared/appearance-settings'
|
||||
export * from './shared/browse-tab'
|
||||
export * from './shared/console'
|
||||
export * from './shared/content-tab'
|
||||
|
||||
+37
@@ -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>
|
||||
+76
@@ -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>
|
||||
+31
@@ -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"
|
||||
/>
|
||||
`,
|
||||
}),
|
||||
}
|
||||
+34
-25
@@ -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,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'
|
||||
|
||||
+158
-30
@@ -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>
|
||||
packages/ui/src/layouts/wrapped/settings/language-settings/language-settings-coverage.generated.d.ts
Vendored
+3
@@ -0,0 +1,3 @@
|
||||
import type { LanguageCoverageByProduct } from './language-settings-coverage'
|
||||
|
||||
export declare const languageCoverage: LanguageCoverageByProduct
|
||||
+15
@@ -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>
|
||||
>
|
||||
+335
@@ -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>
|
||||
@@ -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"
|
||||
},
|
||||
|
||||
@@ -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"
|
||||
},
|
||||
|
||||
@@ -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"
|
||||
},
|
||||
|
||||
@@ -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"
|
||||
},
|
||||
|
||||
@@ -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"
|
||||
},
|
||||
|
||||
@@ -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"
|
||||
},
|
||||
|
||||
@@ -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"
|
||||
},
|
||||
|
||||
@@ -4889,9 +4889,6 @@
|
||||
"settings.social.blocked-users.unblock": {
|
||||
"defaultMessage": "ブロックを解除"
|
||||
},
|
||||
"settings.social.interaction-source.coming-soon": {
|
||||
"defaultMessage": "近日公開!"
|
||||
},
|
||||
"settings.social.interaction-source.everyone": {
|
||||
"defaultMessage": "全員"
|
||||
},
|
||||
|
||||
@@ -5198,9 +5198,6 @@
|
||||
"settings.social.friend-requests.title": {
|
||||
"defaultMessage": "친구 요청"
|
||||
},
|
||||
"settings.social.interaction-source.coming-soon": {
|
||||
"defaultMessage": "곧 제공할 예정입니다!"
|
||||
},
|
||||
"settings.social.interaction-source.everyone": {
|
||||
"defaultMessage": "모든 사람"
|
||||
},
|
||||
|
||||
@@ -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"
|
||||
},
|
||||
|
||||
@@ -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"
|
||||
},
|
||||
|
||||
@@ -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"
|
||||
},
|
||||
|
||||
@@ -5129,9 +5129,6 @@
|
||||
"settings.social.friend-requests.title": {
|
||||
"defaultMessage": "Запросы в друзья"
|
||||
},
|
||||
"settings.social.interaction-source.coming-soon": {
|
||||
"defaultMessage": "Скоро будет!"
|
||||
},
|
||||
"settings.social.interaction-source.everyone": {
|
||||
"defaultMessage": "Все"
|
||||
},
|
||||
|
||||
@@ -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"
|
||||
},
|
||||
|
||||
@@ -5195,9 +5195,6 @@
|
||||
"settings.social.friend-requests.title": {
|
||||
"defaultMessage": "Запити в друзі"
|
||||
},
|
||||
"settings.social.interaction-source.coming-soon": {
|
||||
"defaultMessage": "Незабаром!"
|
||||
},
|
||||
"settings.social.interaction-source.everyone": {
|
||||
"defaultMessage": "Усі"
|
||||
},
|
||||
|
||||
@@ -5216,9 +5216,6 @@
|
||||
"settings.social.friend-requests.title": {
|
||||
"defaultMessage": "好友邀请"
|
||||
},
|
||||
"settings.social.interaction-source.coming-soon": {
|
||||
"defaultMessage": "即将到来!"
|
||||
},
|
||||
"settings.social.interaction-source.everyone": {
|
||||
"defaultMessage": "所有人"
|
||||
},
|
||||
|
||||
@@ -5216,9 +5216,6 @@
|
||||
"settings.social.friend-requests.title": {
|
||||
"defaultMessage": "好友邀請"
|
||||
},
|
||||
"settings.social.interaction-source.coming-soon": {
|
||||
"defaultMessage": "即將推出!"
|
||||
},
|
||||
"settings.social.interaction-source.everyone": {
|
||||
"defaultMessage": "所有人"
|
||||
},
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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())
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user