feat: preferences syncing frontend (#7192)

* feat: prefs frontend

* fix: DI issue

* fix: lint

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

* fix: sync btn when logged out

* fix: prepr

* fix: sidebar issue

* feat: language coverage + cleanup

* feat: cleanup lang settings

* fix: fmt

* fix: CI

* fix: ci

* fix: storybook

* feat: bring back loader/game version sort

---------

Co-authored-by: tdgao <mr.trumgao@gmail.com>
This commit is contained in:
Calum H.
2026-08-20 07:23:01 +00:00
committed by GitHub
co-authored by tdgao
parent 79fcf41316
commit 3e07267e10
191 changed files with 3356 additions and 4372 deletions
@@ -1,13 +1,27 @@
<script setup lang="ts">
import { defineMessages, Toggle, useVIntl } from '@modrinth/ui'
import { ref, watch } from 'vue'
import {
defineMessages,
injectAuth,
injectUserPreferences,
Toggle,
useSavable,
useVIntl,
} from '@modrinth/ui'
import { inject, onBeforeUnmount, onMounted, ref } from 'vue'
import { get, set } from '@/helpers/settings.ts'
import { useTheming } from '@/store/state'
import type { FeatureFlag } from '@/store/theme.ts'
import {
DEFAULT_FEATURE_FLAGS,
type FeatureFlag,
useAppSettings,
} from '@/composables/use-app-settings.ts'
import { type AppSettings, get, set } from '@/helpers/settings.ts'
import { appSettingsModalContextKey } from '@/providers/app-settings-modal'
const themeStore = useTheming()
const appSettings = useAppSettings()
const { formatMessage } = useVIntl()
const auth = injectAuth()
const { updatePreferences } = injectUserPreferences()
const settingsModal = inject(appSettingsModalContextKey, null)
const worldsInHomeFlag: FeatureFlag = 'worlds_in_home'
const skipNonEssentialWarningsFlag: FeatureFlag = 'skip_non_essential_warnings'
@@ -15,6 +29,19 @@ const skipUnknownPackWarningFlag: FeatureFlag = 'skip_unknown_pack_warning'
const showPlayTimeFlag: FeatureFlag = 'show_instance_play_time'
const messages = defineMessages({
syncAcrossDevicesTitle: {
id: 'app.behavior-settings.sync-across-devices.title',
defaultMessage: 'Sync behavior across devices',
},
syncAcrossDevicesDescription: {
id: 'app.behavior-settings.sync-across-devices.description',
defaultMessage:
"Use these behavior settings everywhere you're signed in. Turn this off to keep separate settings on this device.",
},
syncAcrossDevicesSignedOutTooltip: {
id: 'app.behavior-settings.sync-across-devices.signed-out-tooltip',
defaultMessage: 'Sign into a Modrinth account to sync settings.',
},
startupAndNavigationTitle: {
id: 'app.behavior-settings.startup-and-navigation.title',
defaultMessage: 'Startup and navigation',
@@ -96,18 +123,136 @@ const messages = defineMessages({
},
})
const settings = ref(await get())
type BehaviorSettingsState = {
syncBehaviorAcrossDevices: boolean
minimizeApp: boolean
hideRightSidebar: boolean
showJumpIn: boolean
showPlayTime: boolean
hideNametag: boolean
warnOnUnknownModpacks: boolean
skipNonEssentialWarnings: boolean
}
watch(
settings,
const persistedSettings = ref(await get())
function getBehaviorSettingsState(settings: AppSettings): BehaviorSettingsState {
return {
syncBehaviorAcrossDevices: settings.sync_behavior_across_devices,
minimizeApp: settings.hide_on_process_start,
hideRightSidebar: settings.toggle_sidebar,
showJumpIn: settings.feature_flags[worldsInHomeFlag] ?? DEFAULT_FEATURE_FLAGS[worldsInHomeFlag],
showPlayTime:
settings.feature_flags[showPlayTimeFlag] ?? DEFAULT_FEATURE_FLAGS[showPlayTimeFlag],
hideNametag: settings.hide_nametag_skins_page,
warnOnUnknownModpacks: !(
settings.feature_flags[skipUnknownPackWarningFlag] ??
DEFAULT_FEATURE_FLAGS[skipUnknownPackWarningFlag]
),
skipNonEssentialWarnings:
settings.feature_flags[skipNonEssentialWarningsFlag] ??
DEFAULT_FEATURE_FLAGS[skipNonEssentialWarningsFlag],
}
}
const { saved, current, changes, saving, hasChanges, reset, save } = useSavable(
() => getBehaviorSettingsState(persistedSettings.value),
async () => {
await set(settings.value)
const value = current.value
if (value.syncBehaviorAcrossDevices && auth.user.value) {
await updatePreferences({
behavior: {
minimize_app: value.minimizeApp,
hide_right_sidebar: value.hideRightSidebar,
show_jump_in: value.showJumpIn,
show_play_time: value.showPlayTime,
hide_nametag: value.hideNametag,
warn_on_unknown_modpacks: value.warnOnUnknownModpacks,
skip_non_essential_warnings: value.skipNonEssentialWarnings,
},
})
}
const nextSettings: AppSettings = {
...persistedSettings.value,
sync_behavior_across_devices: value.syncBehaviorAcrossDevices,
hide_on_process_start: value.minimizeApp,
toggle_sidebar: value.hideRightSidebar,
hide_nametag_skins_page: value.hideNametag,
feature_flags: {
...persistedSettings.value.feature_flags,
[worldsInHomeFlag]: value.showJumpIn,
[showPlayTimeFlag]: value.showPlayTime,
[skipUnknownPackWarningFlag]: !value.warnOnUnknownModpacks,
[skipNonEssentialWarningsFlag]: value.skipNonEssentialWarnings,
},
}
await set(nextSettings)
persistedSettings.value = nextSettings
appSettings.setBehaviorSyncAcrossDevices(value.syncBehaviorAcrossDevices)
appSettings.toggleSidebar = value.hideRightSidebar
appSettings.hideNametagSkinsPage = value.hideNametag
appSettings.featureFlags[worldsInHomeFlag] = value.showJumpIn
appSettings.featureFlags[showPlayTimeFlag] = value.showPlayTime
appSettings.featureFlags[skipUnknownPackWarningFlag] = !value.warnOnUnknownModpacks
appSettings.featureFlags[skipNonEssentialWarningsFlag] = value.skipNonEssentialWarnings
},
{ deep: true },
)
async function saveBehaviorSettings(): Promise<void> {
try {
await save()
} catch {
return
}
}
onMounted(() => {
settingsModal?.registerUnsavedChangesController({
hasChanges: () => hasChanges.value,
getOriginal: () => saved.value,
getModified: () => changes.value,
isSaving: () => saving.value,
reset,
save: saveBehaviorSettings,
})
})
onBeforeUnmount(() => {
settingsModal?.registerUnsavedChangesController(null)
})
</script>
<template>
<section>
<section class="border-0 border-b border-solid border-divider pb-6">
<div class="flex items-center justify-between gap-4">
<div>
<h2 id="sync-behavior-across-devices-label" class="m-0 text-lg font-semibold text-contrast">
{{ formatMessage(messages.syncAcrossDevicesTitle) }}
</h2>
<p class="m-0 mt-1 text-secondary">
{{ formatMessage(messages.syncAcrossDevicesDescription) }}
</p>
</div>
<span
v-tooltip="
!auth.user.value ? formatMessage(messages.syncAcrossDevicesSignedOutTooltip) : undefined
"
class="inline-flex shrink-0"
>
<Toggle
id="sync-behavior-across-devices"
:model-value="Boolean(auth.user.value) && current.syncBehaviorAcrossDevices"
:disabled="!auth.user.value"
aria-labelledby="sync-behavior-across-devices-label"
@update:model-value="current.syncBehaviorAcrossDevices = $event"
/>
</span>
</div>
</section>
<section class="mt-6">
<h2 class="m-0 text-xl font-semibold text-contrast">
{{ formatMessage(messages.startupAndNavigationTitle) }}
</h2>
@@ -121,7 +266,7 @@ watch(
{{ formatMessage(messages.minimizeLauncherDescription) }}
</p>
</div>
<Toggle id="minimize-launcher" v-model="settings.hide_on_process_start" />
<Toggle id="minimize-launcher" v-model="current.minimizeApp" />
</div>
<div class="flex items-center justify-between gap-4">
@@ -131,16 +276,7 @@ watch(
</h3>
<p class="m-0 mt-1">{{ formatMessage(messages.toggleSidebarDescription) }}</p>
</div>
<Toggle
id="toggle-sidebar"
:model-value="settings.toggle_sidebar"
@update:model-value="
(e) => {
settings.toggle_sidebar = !!e
themeStore.toggleSidebar = settings.toggle_sidebar
}
"
/>
<Toggle id="toggle-sidebar" v-model="current.hideRightSidebar" />
</div>
</div>
</section>
@@ -159,17 +295,7 @@ watch(
{{ formatMessage(messages.jumpBackIntoWorldsDescription) }}
</p>
</div>
<Toggle
id="jump-back-into-worlds"
:model-value="themeStore.getFeatureFlag(worldsInHomeFlag)"
@update:model-value="
() => {
const newValue = !themeStore.getFeatureFlag(worldsInHomeFlag)
themeStore.featureFlags[worldsInHomeFlag] = newValue
settings.feature_flags[worldsInHomeFlag] = newValue
}
"
/>
<Toggle id="jump-back-into-worlds" v-model="current.showJumpIn" />
</div>
<div class="flex items-center justify-between gap-4">
@@ -179,17 +305,7 @@ watch(
</h3>
<p class="m-0 mt-1">{{ formatMessage(messages.showPlayTimeDescription) }}</p>
</div>
<Toggle
id="show-play-time"
:model-value="themeStore.getFeatureFlag(showPlayTimeFlag)"
@update:model-value="
() => {
const newValue = !themeStore.getFeatureFlag(showPlayTimeFlag)
themeStore.featureFlags[showPlayTimeFlag] = newValue
settings.feature_flags[showPlayTimeFlag] = newValue
}
"
/>
<Toggle id="show-play-time" v-model="current.showPlayTime" />
</div>
<div class="flex items-center justify-between gap-4">
@@ -199,16 +315,7 @@ watch(
</h3>
<p class="m-0 mt-1">{{ formatMessage(messages.hideNametagDescription) }}</p>
</div>
<Toggle
id="hide-nametag-skins-page"
:model-value="themeStore.hideNametagSkinsPage"
@update:model-value="
(e) => {
themeStore.hideNametagSkinsPage = !!e
settings.hide_nametag_skins_page = themeStore.hideNametagSkinsPage
}
"
/>
<Toggle id="hide-nametag-skins-page" v-model="current.hideNametag" />
</div>
</div>
</section>
@@ -229,15 +336,7 @@ watch(
</div>
<Toggle
id="warn-before-installing-unknown-modpacks"
:model-value="!themeStore.getFeatureFlag(skipUnknownPackWarningFlag)"
@update:model-value="
(e) => {
const warnBeforeUnknownPackInstall = !!e
const skipUnknownPackWarning = !warnBeforeUnknownPackInstall
themeStore.featureFlags[skipUnknownPackWarningFlag] = skipUnknownPackWarning
settings.feature_flags[skipUnknownPackWarningFlag] = skipUnknownPackWarning
}
"
v-model="current.warnOnUnknownModpacks"
/>
</div>
@@ -250,17 +349,7 @@ watch(
{{ formatMessage(messages.skipNonEssentialWarningsDescription) }}
</p>
</div>
<Toggle
id="skip-non-essential-warnings"
:model-value="themeStore.getFeatureFlag(skipNonEssentialWarningsFlag)"
@update:model-value="
() => {
const newValue = !themeStore.getFeatureFlag(skipNonEssentialWarningsFlag)
themeStore.featureFlags[skipNonEssentialWarningsFlag] = newValue
settings.feature_flags[skipNonEssentialWarningsFlag] = newValue
}
"
/>
<Toggle id="skip-non-essential-warnings" v-model="current.skipNonEssentialWarnings" />
</div>
</div>
</section>