diff --git a/.github/workflows/frontend-deploy.yml b/.github/workflows/frontend-deploy.yml
index 703431d240..79b73c57ef 100644
--- a/.github/workflows/frontend-deploy.yml
+++ b/.github/workflows/frontend-deploy.yml
@@ -125,8 +125,7 @@ jobs:
run: pnpm install
- name: Build frontend
- working-directory: ./apps/frontend
- run: pnpm build
+ run: pnpm web:build
env:
CF_PAGES_BRANCH: ${{ inputs.head_ref || github.ref_name }}
CF_PAGES_COMMIT_SHA: ${{ inputs.head_sha || github.sha }}
diff --git a/.github/workflows/frontend-docker.yml b/.github/workflows/frontend-docker.yml
new file mode 100644
index 0000000000..cd63875f11
--- /dev/null
+++ b/.github/workflows/frontend-docker.yml
@@ -0,0 +1,99 @@
+name: Frontend Docker
+
+on:
+ push:
+ branches:
+ - main
+ - prod
+ paths:
+ - 'apps/frontend/**/*'
+ - 'packages/ui/**/*'
+ - 'packages/utils/**/*'
+ - 'packages/assets/**/*'
+ - 'packages/moderation/**/*'
+ - 'packages/api-client/**/*'
+ - 'packages/blog/**/*'
+ - '**/pnpm-*.yaml'
+ - .github/workflows/frontend-docker.yml
+
+concurrency:
+ group: ${{ github.workflow }}-${{ github.ref }}
+ cancel-in-progress: ${{ github.ref != 'refs/heads/main' && github.ref != 'refs/heads/prod' }}
+
+jobs:
+ build:
+ name: Build frontend
+ runs-on: namespace-profile-modrinth-frontend
+ permissions:
+ contents: read
+ steps:
+ - name: Checkout code
+ uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
+
+ - name: Configure environment
+ id: meta
+ env:
+ REF: ${{ github.ref }}
+ run: |
+ if [ "$REF" = "refs/heads/main" ]; then
+ echo "env=staging" >> $GITHUB_OUTPUT
+ else
+ echo "env=production" >> $GITHUB_OUTPUT
+ fi
+
+ - name: Setup Node
+ uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
+ with:
+ node-version-file: .nvmrc
+
+ - name: Enable Corepack
+ run: corepack enable
+
+ - name: Set up caches
+ uses: namespacelabs/nscloud-cache-action@c5f8dab7560444c4bf8dbc64f1b203431873c547 # v1.6.1
+ with:
+ cache: |
+ pnpm
+
+ - name: Inject build variables
+ working-directory: ./apps/frontend
+ run: |
+ if [ "${{ steps.meta.outputs.env }}" == "staging" ]; then
+ echo "Injecting staging variables from wrangler.jsonc..."
+ jq -r '.env.staging.vars | to_entries[] | "\(.key)=\(.value)"' wrangler.jsonc >> $GITHUB_ENV
+ else
+ echo "Injecting production variables from wrangler.jsonc..."
+ jq -r '.vars | to_entries[] | "\(.key)=\(.value)"' wrangler.jsonc >> $GITHUB_ENV
+ fi
+
+ - name: Install dependencies
+ working-directory: ./apps/frontend
+ run: pnpm install
+
+ - name: Build frontend
+ run: pnpm web:build
+ env:
+ NITRO_PRESET: node-server
+ BUILD_ENV: ${{ steps.meta.outputs.env }}
+ CF_PAGES_BRANCH: ${{ github.ref_name }}
+ CF_PAGES_COMMIT_SHA: ${{ github.sha }}
+
+ - name: Stage Docker context
+ run: |
+ mkdir -p apps/frontend/docker-stage
+ cp -r apps/frontend/.output apps/frontend/docker-stage/.output
+
+ - name: Upload Docker context
+ uses: namespace-actions/upload-artifact@f6ccaacc655aec41b93af180d1d7eef21af862d2 # v1.0.3
+ with:
+ name: frontend-docker-context
+ retention-days: 1
+ path: apps/frontend/docker-stage
+
+ docker-build:
+ needs: [build]
+ uses: SparkUniverse/workflows/.github/workflows/docker-build.yaml@main
+ with:
+ image-name: frontend
+ dockerfile-path: apps/frontend/Dockerfile
+ artifacts-name: frontend-docker-context
diff --git a/.github/workflows/frontend-preview.yml b/.github/workflows/frontend-preview.yml
index df17e1786c..16e76136fd 100644
--- a/.github/workflows/frontend-preview.yml
+++ b/.github/workflows/frontend-preview.yml
@@ -70,10 +70,9 @@ jobs:
run: pnpm install
- name: Build Storybook
- working-directory: ./packages/ui
env:
NODE_OPTIONS: --max-old-space-size=6144
- run: pnpm run build-storybook
+ run: pnpm build-storybook
- name: Configure short SHA
id: meta
diff --git a/.github/workflows/i18n-pull.yml b/.github/workflows/i18n-pull.yml
index df5ceffa58..185f7815e6 100644
--- a/.github/workflows/i18n-pull.yml
+++ b/.github/workflows/i18n-pull.yml
@@ -8,6 +8,10 @@ on:
concurrency:
group: i18n-management
+permissions:
+ contents: write
+ pull-requests: write
+
jobs:
pull_translations:
name: 'Pull translations from Crowdin'
@@ -38,23 +42,17 @@ jobs:
flight_failure "CROWDIN_PERSONAL_TOKEN secret is not defined (required to push)"
fi
- if [ "$CROWDIN_GH_TOKEN_DEFINED" != true ]; then
- flight_failure "CROWDIN_GH_TOKEN secret is not defined (required to make pull requests)"
- fi
-
if [ "$PREFLIGHT_CHECK_RESULT" = false ]; then
exit 1
fi
env:
CROWDIN_PROJECT_ID_DEFINED: ${{ vars.CROWDIN_PROJECT_ID != '' }}
CROWDIN_PERSONAL_TOKEN_DEFINED: ${{ secrets.CROWDIN_PERSONAL_TOKEN != '' }}
- CROWDIN_GH_TOKEN_DEFINED: ${{ secrets.CROWDIN_GH_TOKEN != '' }}
- name: Checkout
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
ref: ${{ github.ref }}
- token: ${{ secrets.CROWDIN_GH_TOKEN }}
- name: Setup Node
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
@@ -67,12 +65,6 @@ jobs:
- name: Install script dependencies
run: pnpm install --frozen-lockfile --ignore-scripts
- - name: Configure Git author
- id: git-author
- uses: MarcoIeni/git-config@59144859caf016f8b817a2ac9b051578729173c4 # v0.1.2
- env:
- GITHUB_TOKEN: ${{ secrets.CROWDIN_GH_TOKEN }}
-
# # Because --all flag of Crowdin CLI is currently broken we need to create a fake source file
# # so that the CLI won't omit translations for it. See https://github.com/crowdin/crowdin-cli/issues/724
# - name: Write fake sources
@@ -116,7 +108,4 @@ jobs:
body-path: .github/templates/crowdin-pr.md
commit-message: 'New translations from Crowdin (${{ steps.branch-name.outputs.branch_name }})'
branch: crowdin-pull/${{ steps.branch-name.outputs.branch_name }}
- author: '${{ steps.git-author.outputs.name }} <${{ steps.git-author.outputs.email }}>'
- committer: '${{ steps.git-author.outputs.name }} <${{ steps.git-author.outputs.email }}>'
labels: sync
- token: ${{ secrets.CROWDIN_GH_TOKEN }}
diff --git a/.gitignore b/.gitignore
index 9d27dcaf5e..d442251099 100644
--- a/.gitignore
+++ b/.gitignore
@@ -55,6 +55,7 @@ Thumbs.db
# frontend generated files
apps/frontend/src/generated
+/packages/ui/src/layouts/wrapped/settings/language-settings/language-settings-coverage.generated.ts
.turbo
target
diff --git a/Cargo.lock b/Cargo.lock
index 99ec14bd94..5b10537334 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -2730,10 +2730,10 @@ dependencies = [
"futures",
"indexmap 2.11.4",
"itertools 0.14.0",
+ "quick-xml 0.38.3",
"reqwest 0.12.24",
"rust-s3",
"serde",
- "serde-xml-rs",
"serde_json",
"sha1_smol",
"thiserror 2.0.17",
@@ -9503,18 +9503,6 @@ dependencies = [
"typeid",
]
-[[package]]
-name = "serde-xml-rs"
-version = "0.8.1"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "53630160a98edebde0123eb4dfd0fce6adff091b2305db3154a9e920206eb510"
-dependencies = [
- "log",
- "serde",
- "thiserror 1.0.69",
- "xml-rs",
-]
-
[[package]]
name = "serde_bytes"
version = "0.11.19"
diff --git a/Cargo.toml b/Cargo.toml
index 36aa6975df..a1fa25deea 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -193,7 +193,6 @@ serde_cbor = "0.11.2"
serde_ini = "0.2.0"
serde_json = "1.0.145"
serde_with = "3.15.0"
-serde-xml-rs = "0.8.1" # Also an XML (de)serializer, consider dropping yaserde in favor of this
sha1 = "0.10.6"
sha1_smol = { version = "1.0.1", features = ["std"] }
sha2 = "0.10.9"
diff --git a/apps/app-frontend/package.json b/apps/app-frontend/package.json
index 5a88bd2d92..abe6e55c0f 100644
--- a/apps/app-frontend/package.json
+++ b/apps/app-frontend/package.json
@@ -38,7 +38,6 @@
"intl-messageformat": "^10.7.7",
"ofetch": "^1.3.4",
"overlayscrollbars": "^2.15.1",
- "pinia": "^3.0.0",
"posthog-js": "^1.158.2",
"three": "^0.172.0",
"vite-svg-loader": "^5.1.0",
diff --git a/apps/app-frontend/src/App.vue b/apps/app-frontend/src/App.vue
index eeb37273f6..33afd4d216 100644
--- a/apps/app-frontend/src/App.vue
+++ b/apps/app-frontend/src/App.vue
@@ -94,6 +94,9 @@ import SurveyPopup from '@/components/ui/SurveyPopup.vue'
import WindowControls from '@/components/ui/WindowControls.vue'
import { useCheckDisableMouseover } from '@/composables/macCssFix.js'
import { useAppEvent } from '@/composables/use-app-event'
+import { useAppSettings } from '@/composables/use-app-settings.ts'
+import { useError } from '@/composables/use-error.js'
+import { useTheme } from '@/composables/use-theme.ts'
import { config } from '@/config'
import {
hide_ads_window,
@@ -147,8 +150,7 @@ import { setupProviders } from '@/providers/setup'
import { setupAppEventsProvider } from '@/providers/setup/app-events'
import { setupAuthProvider } from '@/providers/setup/auth'
import { setupLoadingStateProvider } from '@/providers/setup/loading-state'
-import { useError } from '@/store/error.js'
-import { useTheming } from '@/store/state'
+import { setupAppUserPreferencesProvider } from '@/providers/setup/user-preferences.ts'
import { appMessages } from '@/utils/app-messages'
import { generateSkinPreviews } from './helpers/rendering/batch-skin-renderer'
@@ -157,7 +159,8 @@ import { AppNotificationManager } from './providers/app-notifications'
import { AppPopupNotificationManager } from './providers/app-popup-notifications'
import { appSettingsModalOpenProfileKey } from './providers/app-settings-modal'
-const themeStore = useTheming()
+const appSettings = useAppSettings()
+const appTheme = useTheme()
const router = useRouter()
const route = useRoute()
const { channel: appEventChannel, events: appEvents } = setupAppEventsProvider()
@@ -201,7 +204,7 @@ const credentials = ref()
let credentialsRefreshId = 0
const sidebarToggled = ref(true)
watch(
- () => themeStore.toggleSidebar,
+ () => appSettings.toggleSidebar,
(toggleSidebar) => {
sidebarToggled.value = !toggleSidebar
},
@@ -221,7 +224,7 @@ const hostingUpdateRequired = computed(
appUpdateState.updatesEnabled.value,
)
const prideFundraiserEnabled = computed(
- () => themeStore.getFeatureFlag('pride_fundraiser') && Date.now() < PRIDE_FUNDRAISER_END_DATE,
+ () => appSettings.getFeatureFlag('pride_fundraiser') && Date.now() < PRIDE_FUNDRAISER_END_DATE,
)
const hostingIntercomIdentityKey = computed(() => {
const rawServerId = route.params.id
@@ -324,13 +327,13 @@ providePageContext({
intercomBubble: hostingIntercom.intercomBubble,
featureFlags: {
serverRamAsBytesAlwaysOn: computed(() =>
- themeStore.getFeatureFlag('server_ram_as_bytes_always_on'),
+ appSettings.getFeatureFlag('server_ram_as_bytes_always_on'),
),
},
openExternalUrl: (url) => void openUrl(url),
})
provideModalBehavior({
- noblur: computed(() => !themeStore.advancedRendering),
+ noblur: computed(() => !appTheme.advancedRendering),
onShow: () => take_ads_window_hold(),
onHide: () => release_ads_window_hold(),
})
@@ -634,10 +637,11 @@ async function setupApp() {
theme,
locale,
telemetry,
- collapsed_navigation,
hide_nametag_skins_page,
advanced_rendering,
toggle_sidebar,
+ sync_theme_across_devices,
+ sync_behavior_across_devices,
developer_mode,
feature_flags,
pending_update_toast_for_version,
@@ -655,13 +659,14 @@ async function setupApp() {
nativeDecorations.value = native_decorations
if (os.value !== 'MacOS') await getCurrentWindow().setDecorations(native_decorations)
- themeStore.setThemeState(theme)
- themeStore.collapsedNavigation = collapsed_navigation
- themeStore.advancedRendering = advanced_rendering
- themeStore.hideNametagSkinsPage = hide_nametag_skins_page
- themeStore.toggleSidebar = toggle_sidebar
- themeStore.devMode = developer_mode
- themeStore.featureFlags = feature_flags
+ appTheme.preferred = theme
+ appTheme.advancedRendering = advanced_rendering
+ appTheme.syncAcrossDevices = sync_theme_across_devices
+ appSettings.syncBehaviorAcrossDevices = sync_behavior_across_devices
+ appSettings.hideNametagSkinsPage = hide_nametag_skins_page
+ appSettings.toggleSidebar = toggle_sidebar
+ appSettings.devMode = developer_mode
+ Object.assign(appSettings.featureFlags, feature_flags)
stateInitialized.value = true
isMaximized.value = await getCurrentWindow().isMaximized()
@@ -959,7 +964,7 @@ watch(incompatibilityWarningModal, (modal) => {
}
})
-setupAuthProvider(credentials, async (_redirectPath, flow, options) => {
+const authProvider = setupAuthProvider(credentials, async (_redirectPath, flow, options) => {
if (options?.showModal === false) {
await signIn(flow)
} else {
@@ -967,6 +972,80 @@ setupAuthProvider(credentials, async (_redirectPath, flow, options) => {
}
})
+const userPreferences = setupAppUserPreferencesProvider(authProvider, notificationManager)
+let userPreferencesSync = Promise.resolve()
+
+watch(
+ [userPreferences.preferences, stateInitialized],
+ ([preferences, initialized]) => {
+ if (!preferences || !initialized) return
+
+ userPreferencesSync = userPreferencesSync
+ .then(async () => {
+ const settings = await getSettings()
+ const selectedTheme = preferences.appearance.auto ? 'system' : preferences.appearance.theme
+ const locale = preferences.localization.locale
+ const behavior = preferences.behavior
+ let settingsChanged = false
+
+ if (appTheme.syncAcrossDevices && appTheme.preferred !== selectedTheme) {
+ appTheme.preferred = selectedTheme
+ }
+ if (i18n.global.locale.value !== locale) {
+ i18n.global.locale.value = locale
+ }
+
+ if (appTheme.syncAcrossDevices && settings.theme !== selectedTheme) {
+ settings.theme = selectedTheme
+ settingsChanged = true
+ }
+ if (settings.locale !== locale) {
+ settings.locale = locale
+ settingsChanged = true
+ }
+
+ if (behavior && appSettings.syncBehaviorAcrossDevices) {
+ const behaviorFeatureFlags = {
+ worlds_in_home: behavior.show_jump_in,
+ compact_instance_cards: behavior.compact_instance_cards,
+ show_instance_play_time: behavior.show_play_time,
+ skip_unknown_pack_warning: !behavior.warn_on_unknown_modpacks,
+ skip_non_essential_warnings: behavior.skip_non_essential_warnings,
+ }
+
+ appSettings.toggleSidebar = behavior.hide_right_sidebar
+ appSettings.hideNametagSkinsPage = behavior.hide_nametag
+ Object.assign(appSettings.featureFlags, behaviorFeatureFlags)
+
+ if (settings.hide_on_process_start !== behavior.minimize_app) {
+ settings.hide_on_process_start = behavior.minimize_app
+ settingsChanged = true
+ }
+ if (settings.toggle_sidebar !== behavior.hide_right_sidebar) {
+ settings.toggle_sidebar = behavior.hide_right_sidebar
+ settingsChanged = true
+ }
+ if (settings.hide_nametag_skins_page !== behavior.hide_nametag) {
+ settings.hide_nametag_skins_page = behavior.hide_nametag
+ settingsChanged = true
+ }
+ for (const [flag, value] of Object.entries(behaviorFeatureFlags)) {
+ if (settings.feature_flags[flag] !== value) {
+ settings.feature_flags[flag] = value
+ settingsChanged = true
+ }
+ }
+ }
+
+ if (settingsChanged) {
+ await setSettings(settings)
+ }
+ })
+ .catch(handleError)
+ },
+ { immediate: true },
+)
+
async function validateSession(sessionToken) {
try {
const response = await tauriFetch(`${config.labrinthBaseUrl}/v2/user`, {
@@ -1523,9 +1602,8 @@ async function downloadUpdate(versionToDownload) {
.then(() => {
downloading.value = false
finishedDownloading.value = true
- unlistenUpdateDownload?.().then(() => {
- unlistenUpdateDownload = null
- })
+ unlistenUpdateDownload?.()
+ unlistenUpdateDownload = null
console.log('Finished downloading!')
markAppUpdateActionable(versionToDownload.version, 'downloaded')
scheduleDelayedUpdatePopup()
@@ -1642,7 +1720,7 @@ provideAppUpdateDownloadProgress(appUpdateDownload)
@@ -1867,7 +1945,7 @@ provideAppUpdateDownloadProgress(appUpdateDownload)
{{ route.fullPath }}
diff --git a/apps/app-frontend/src/assets/instance-icons/fabric.png b/apps/app-frontend/src/assets/instance-icons/fabric.png
new file mode 100644
index 0000000000..c763735e73
Binary files /dev/null and b/apps/app-frontend/src/assets/instance-icons/fabric.png differ
diff --git a/apps/app-frontend/src/assets/instance-icons/forge.png b/apps/app-frontend/src/assets/instance-icons/forge.png
new file mode 100644
index 0000000000..09f54201e8
Binary files /dev/null and b/apps/app-frontend/src/assets/instance-icons/forge.png differ
diff --git a/apps/app-frontend/src/assets/instance-icons/neoforge.png b/apps/app-frontend/src/assets/instance-icons/neoforge.png
new file mode 100644
index 0000000000..f82d367d38
Binary files /dev/null and b/apps/app-frontend/src/assets/instance-icons/neoforge.png differ
diff --git a/apps/app-frontend/src/assets/instance-icons/quilt.png b/apps/app-frontend/src/assets/instance-icons/quilt.png
new file mode 100644
index 0000000000..a829f58a44
Binary files /dev/null and b/apps/app-frontend/src/assets/instance-icons/quilt.png differ
diff --git a/apps/app-frontend/src/components/ui/AccountsCard.vue b/apps/app-frontend/src/components/ui/AccountsCard.vue
index ac9136f6c2..6dacd0f6a2 100644
--- a/apps/app-frontend/src/components/ui/AccountsCard.vue
+++ b/apps/app-frontend/src/components/ui/AccountsCard.vue
@@ -20,6 +20,7 @@
-
+
-
-
-
-
-
-
-
diff --git a/apps/app-frontend/src/components/ui/ErrorModal.vue b/apps/app-frontend/src/components/ui/ErrorModal.vue
index 475c1f3f76..efac61720c 100644
--- a/apps/app-frontend/src/components/ui/ErrorModal.vue
+++ b/apps/app-frontend/src/components/ui/ErrorModal.vue
@@ -20,11 +20,11 @@ import { computed, ref } from 'vue'
import { ChatIcon } from '@/assets/icons'
import ModalWrapper from '@/components/ui/modal/ModalWrapper.vue'
+import { handleSevereError } from '@/composables/use-error.js'
import { trackEvent } from '@/helpers/analytics'
import { login as login_flow, set_default_user } from '@/helpers/auth.js'
import { install_existing_instance } from '@/helpers/install'
import { cancel_directory_change } from '@/helpers/settings.ts'
-import { handleSevereError } from '@/store/error.js'
const { handleError } = injectNotificationManager()
diff --git a/apps/app-frontend/src/components/ui/Instance.vue b/apps/app-frontend/src/components/ui/Instance.vue
index 2bc32b67fc..9f8338df6d 100644
--- a/apps/app-frontend/src/components/ui/Instance.vue
+++ b/apps/app-frontend/src/components/ui/Instance.vue
@@ -20,12 +20,12 @@ import { computed, onMounted, ref } from 'vue'
import { useRouter } from 'vue-router'
import { useAppEvent } from '@/composables/use-app-event'
+import { handleSevereError } from '@/composables/use-error.js'
import { trackEvent } from '@/helpers/analytics'
import { install_existing_instance, install_pack_to_existing_instance } from '@/helpers/install'
import { getInstanceIconUrl, kill, run } from '@/helpers/instance'
import { get_by_instance_id } from '@/helpers/process'
import { showInstanceInFolder } from '@/helpers/utils.js'
-import { handleSevereError } from '@/store/error.js'
const { handleError } = injectNotificationManager()
const { formatMessage } = useVIntl()
diff --git a/apps/app-frontend/src/components/ui/QuickInstanceSwitcher.vue b/apps/app-frontend/src/components/ui/QuickInstanceSwitcher.vue
index 0bbabbf9c1..8dc9a0e44b 100644
--- a/apps/app-frontend/src/components/ui/QuickInstanceSwitcher.vue
+++ b/apps/app-frontend/src/components/ui/QuickInstanceSwitcher.vue
@@ -11,7 +11,7 @@ import { getInstanceIconUrl, list } from '@/helpers/instance'
import { instanceKeys } from '@/pages/instance/query-options'
const ITEM_SIZE = 52
-const APPROX_USED_VERTICAL_SPACE = 513 // doesn't need to be exact lol just close enough so there's a little gap and no overflow
+const APPROX_USED_VERTICAL_SPACE = 475 // doesn't need to be exact lol just close enough so there's a little gap and no overflow
const STORAGE_KEY = 'modrinth-quick-instance-count'
const { handleError } = injectNotificationManager()
diff --git a/apps/app-frontend/src/components/ui/WindowControls.vue b/apps/app-frontend/src/components/ui/WindowControls.vue
index 81797ba5fa..a10ccffff5 100644
--- a/apps/app-frontend/src/components/ui/WindowControls.vue
+++ b/apps/app-frontend/src/components/ui/WindowControls.vue
@@ -39,17 +39,17 @@ import { getCurrentWindow } from '@tauri-apps/api/window'
import { saveWindowState, StateFlags } from '@tauri-apps/plugin-window-state'
import { computed, onMounted, onUnmounted, ref } from 'vue'
+import { useAppSettings } from '@/composables/use-app-settings.ts'
import { get as getSettings } from '@/helpers/settings.ts'
import { getOS } from '@/helpers/utils.js'
-import { useTheming } from '@/store/state'
-const themeStore = useTheming()
+const appSettings = useAppSettings()
const nativeDecorations = ref(true)
const isMaximized = ref(false)
const os = ref('')
-const alwaysShowAppControls = computed(() => themeStore.getFeatureFlag('always_show_app_controls'))
+const alwaysShowAppControls = computed(() => appSettings.getFeatureFlag('always_show_app_controls'))
const showControls = computed(
() =>
diff --git a/apps/app-frontend/src/components/ui/context-menu/index.vue b/apps/app-frontend/src/components/ui/context-menu/index.vue
new file mode 100644
index 0000000000..93732168bb
--- /dev/null
+++ b/apps/app-frontend/src/components/ui/context-menu/index.vue
@@ -0,0 +1,236 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/apps/app-frontend/src/components/ui/context-menu/types.ts b/apps/app-frontend/src/components/ui/context-menu/types.ts
new file mode 100644
index 0000000000..fe45d2b6ff
--- /dev/null
+++ b/apps/app-frontend/src/components/ui/context-menu/types.ts
@@ -0,0 +1,41 @@
+import type { ComponentPublicInstance } from 'vue'
+
+export type ContextMenuDivider = {
+ type: string
+}
+
+export type ContextMenuAction = {
+ name: string
+ color?: string
+ children?: ContextMenuOption[]
+}
+
+export type ContextMenuParentAction = ContextMenuAction & {
+ children: ContextMenuOption[]
+}
+
+export type ContextMenuOption = ContextMenuDivider | ContextMenuAction
+
+export type ContextMenuSelection = {
+ item: unknown
+ option: string
+}
+
+export type ContextMenuEmit = {
+ (event: 'menu-closed'): void
+ (event: 'option-clicked', selection: ContextMenuSelection): void
+}
+
+export type Point = {
+ x: number
+ y: number
+}
+
+export type ViewportRect = {
+ width: number
+ height: number
+ offsetTop: number
+ offsetLeft: number
+}
+
+export type ButtonRefElement = Element | ComponentPublicInstance | null
diff --git a/apps/app-frontend/src/components/ui/context-menu/use-context-menu-position.ts b/apps/app-frontend/src/components/ui/context-menu/use-context-menu-position.ts
new file mode 100644
index 0000000000..7ffa4fef4f
--- /dev/null
+++ b/apps/app-frontend/src/components/ui/context-menu/use-context-menu-position.ts
@@ -0,0 +1,170 @@
+import type { CSSProperties, Ref } from 'vue'
+import { computed, nextTick, ref } from 'vue'
+
+import type { ContextMenuParentAction, Point, ViewportRect } from './types'
+
+const MENU_GAP = 8
+const VIEWPORT_MARGIN = 10
+
+type ContextMenuPositionOptions = {
+ shown: Ref
+ activeOption: Ref
+ activeOptionIndex: Ref
+ isMobileSubmenuLayout: Ref
+ contextMenu: Ref
+ submenu: Ref
+ optionButtonRefs: Map
+}
+
+export function useContextMenuPosition({
+ shown,
+ activeOption,
+ activeOptionIndex,
+ isMobileSubmenuLayout,
+ contextMenu,
+ submenu,
+ optionButtonRefs,
+}: ContextMenuPositionOptions) {
+ const menuStyle = ref({ left: '0px', top: '0px' })
+ const menuAnchor = ref({ x: 0, y: 0 })
+ const submenuPosition = ref({ x: 0, y: 0 })
+ const hasSubmenuPosition = ref(false)
+ let positionRafId: number | null = null
+
+ const submenuStyle = computed(() => {
+ if (isMobileSubmenuLayout.value) return menuStyle.value
+
+ return {
+ left: `${submenuPosition.value.x}px`,
+ top: `${submenuPosition.value.y}px`,
+ }
+ })
+
+ function updateMenuPosition(x: number, y: number) {
+ if (!contextMenu.value) return
+
+ const viewport = getViewportRect()
+ const menuRect = contextMenu.value.getBoundingClientRect()
+ const viewportLeft = viewport.offsetLeft
+ const viewportTop = viewport.offsetTop
+ const viewportRight = viewport.offsetLeft + viewport.width
+ const viewportBottom = viewport.offsetTop + viewport.height
+ const anchorX = x + viewport.offsetLeft
+ const anchorY = y + viewport.offsetTop
+ const left = Math.min(
+ Math.max(viewportLeft + VIEWPORT_MARGIN, anchorX + MENU_GAP),
+ Math.max(viewportLeft + VIEWPORT_MARGIN, viewportRight - menuRect.width - VIEWPORT_MARGIN),
+ )
+ const top = Math.min(
+ Math.max(viewportTop + VIEWPORT_MARGIN, anchorY + MENU_GAP),
+ Math.max(viewportTop + VIEWPORT_MARGIN, viewportBottom - menuRect.height - VIEWPORT_MARGIN),
+ )
+
+ menuStyle.value = { left: `${left}px`, top: `${top}px` }
+ if (activeOption.value) scheduleSubmenuPositionUpdate()
+ }
+
+ function updateSubmenuPosition() {
+ if (!activeOption.value || activeOptionIndex.value === null) return false
+
+ if (isMobileSubmenuLayout.value) {
+ hasSubmenuPosition.value = true
+ return true
+ }
+
+ const optionButton = optionButtonRefs.get(activeOptionIndex.value)
+ if (!optionButton || !contextMenu.value) return false
+
+ const viewport = getViewportRect()
+ const buttonRect = optionButton.getBoundingClientRect()
+ const menuRect = contextMenu.value.getBoundingClientRect()
+ const submenuRect = submenu.value?.getBoundingClientRect()
+ const submenuWidth = submenuRect?.width ?? menuRect.width
+ const submenuHeight = submenuRect?.height ?? 100
+ const direction = getSubmenuOpenDirection(menuRect, submenuWidth, viewport)
+ const preferredLeft =
+ direction === 'right'
+ ? buttonRect.right + MENU_GAP
+ : buttonRect.left - submenuWidth - MENU_GAP
+ const minLeft = viewport.offsetLeft + VIEWPORT_MARGIN
+ const maxLeft = Math.max(
+ minLeft,
+ viewport.offsetLeft + viewport.width - submenuWidth - VIEWPORT_MARGIN,
+ )
+ const minTop = viewport.offsetTop + VIEWPORT_MARGIN
+ const maxTop = Math.max(
+ minTop,
+ viewport.offsetTop + viewport.height - submenuHeight - VIEWPORT_MARGIN,
+ )
+
+ submenuPosition.value = {
+ x: Math.min(Math.max(minLeft, preferredLeft), maxLeft),
+ y: Math.min(Math.max(minTop, buttonRect.top), maxTop),
+ }
+ hasSubmenuPosition.value = true
+ return true
+ }
+
+ function scheduleSubmenuPositionUpdate(retries = 8) {
+ nextTick(() => {
+ if (!shown.value || !activeOption.value) return
+
+ const hasRenderedSubmenu = submenu.value !== null
+ if (updateSubmenuPosition()) {
+ if (!hasRenderedSubmenu) nextTick(updateSubmenuPosition)
+ return
+ }
+
+ if (retries > 0) setTimeout(() => scheduleSubmenuPositionUpdate(retries - 1), 0)
+ })
+ }
+
+ function schedulePositionUpdate() {
+ if (!shown.value || positionRafId !== null) return
+
+ positionRafId = window.requestAnimationFrame(() => {
+ positionRafId = null
+ updateMenuPosition(menuAnchor.value.x, menuAnchor.value.y)
+ })
+ }
+
+ function cancelPositionUpdate() {
+ if (positionRafId !== null) window.cancelAnimationFrame(positionRafId)
+ }
+
+ return {
+ menuStyle,
+ menuAnchor,
+ submenuStyle,
+ hasSubmenuPosition,
+ updateMenuPosition,
+ scheduleSubmenuPositionUpdate,
+ schedulePositionUpdate,
+ cancelPositionUpdate,
+ }
+}
+
+function getViewportRect(): ViewportRect {
+ const visualViewport = window.visualViewport
+ return {
+ width: visualViewport?.width ?? window.innerWidth,
+ height: visualViewport?.height ?? window.innerHeight,
+ offsetTop: visualViewport?.offsetTop ?? 0,
+ offsetLeft: visualViewport?.offsetLeft ?? 0,
+ }
+}
+
+function getSubmenuOpenDirection(
+ menuRect: DOMRect,
+ submenuWidth: number,
+ viewport: ViewportRect,
+): 'left' | 'right' {
+ const viewportLeft = viewport.offsetLeft
+ const viewportRight = viewport.offsetLeft + viewport.width
+ const rightSpace = viewportRight - menuRect.right - MENU_GAP - VIEWPORT_MARGIN
+ const leftSpace = menuRect.left - viewportLeft - MENU_GAP - VIEWPORT_MARGIN
+
+ if (rightSpace >= submenuWidth) return 'right'
+ if (leftSpace >= submenuWidth) return 'left'
+ return rightSpace >= leftSpace ? 'right' : 'left'
+}
diff --git a/apps/app-frontend/src/components/ui/context-menu/use-context-menu.ts b/apps/app-frontend/src/components/ui/context-menu/use-context-menu.ts
new file mode 100644
index 0000000000..a74b5012e0
--- /dev/null
+++ b/apps/app-frontend/src/components/ui/context-menu/use-context-menu.ts
@@ -0,0 +1,357 @@
+import { computed, nextTick, onBeforeUnmount, onMounted, ref } from 'vue'
+
+import type {
+ ButtonRefElement,
+ ContextMenuAction,
+ ContextMenuEmit,
+ ContextMenuOption,
+ ContextMenuParentAction,
+ Point,
+} from './types'
+import { useContextMenuPosition } from './use-context-menu-position'
+import {
+ focusRelativeButton,
+ getFocusableButtons,
+ hasChildren,
+ isAction,
+ isInstanceLink,
+ isPointInTriangle,
+} from './utils'
+
+const MOBILE_SUBMENU_LAYOUT_QUERY = '(pointer: coarse), (max-width: 800px)'
+const CONTEXT_MENU_OPEN_EVENT = 'modrinth-context-menu-open'
+
+export function useContextMenu(emit: ContextMenuEmit) {
+ const item = ref(null)
+ const contextMenu = ref(null)
+ const submenu = ref(null)
+ const options = ref([])
+ const shown = ref(false)
+ const activeOptionIndex = ref(null)
+ const pendingOptionIndex = ref(null)
+ const isCursorInsideSubmenu = ref(false)
+ const isMobileSubmenuLayout = ref(false)
+ const lastMousePosition = ref(null)
+ const contextMenuId = Symbol()
+ const optionButtonRefs = new Map()
+ const submenuButtonRefs = new Map()
+ let previousMousePosition: Point | null = null
+ let pendingOptionTimeout: ReturnType | null = null
+ let mobileSubmenuMediaQuery: MediaQueryList | null = null
+
+ const activeOption = computed(() => {
+ if (activeOptionIndex.value === null) return null
+ const option = options.value[activeOptionIndex.value]
+ return option && isAction(option) && hasChildren(option) ? option : null
+ })
+
+ const {
+ menuStyle,
+ menuAnchor,
+ submenuStyle,
+ hasSubmenuPosition,
+ updateMenuPosition,
+ scheduleSubmenuPositionUpdate,
+ schedulePositionUpdate,
+ cancelPositionUpdate,
+ } = useContextMenuPosition({
+ shown,
+ activeOption,
+ activeOptionIndex,
+ isMobileSubmenuLayout,
+ contextMenu,
+ submenu,
+ optionButtonRefs,
+ })
+
+ const isMobileActiveSubmenu = computed(
+ () => isMobileSubmenuLayout.value && activeOption.value !== null && hasSubmenuPosition.value,
+ )
+
+ function isOptionVisible(option: ContextMenuOption): option is ContextMenuAction {
+ return isAction(option) && !(isInstanceLink(item.value) && option.name === 'add_content')
+ }
+
+ function setOptionButtonRef(index: number, element: ButtonRefElement) {
+ setButtonRef(optionButtonRefs, index, element)
+ }
+
+ function setSubmenuButtonRef(index: number, element: ButtonRefElement) {
+ setButtonRef(submenuButtonRefs, index, element)
+ }
+
+ function hideMenu() {
+ if (!shown.value) return
+
+ shown.value = false
+ deactivateSubmenu()
+ emit('menu-closed')
+ }
+
+ function showMenu(event: MouseEvent, passedItem: unknown, passedOptions: ContextMenuOption[]) {
+ window.dispatchEvent(new CustomEvent(CONTEXT_MENU_OPEN_EVENT, { detail: contextMenuId }))
+
+ item.value = passedItem
+ options.value = passedOptions
+ menuAnchor.value = { x: event.clientX, y: event.clientY }
+ shown.value = true
+ deactivateSubmenu()
+ syncMobileSubmenuLayout()
+ nextTick(() => updateMenuPosition(event.clientX, event.clientY))
+ }
+
+ function handleOptionClick(option: ContextMenuAction, index: number) {
+ if (hasChildren(option)) {
+ activateSubmenu(index)
+ return
+ }
+
+ optionClicked(option.name)
+ }
+
+ function optionClicked(option: string) {
+ emit('option-clicked', { item: item.value, option })
+ hideMenu()
+ }
+
+ function handleOptionFocus(option: ContextMenuAction, index: number) {
+ if (hasChildren(option) && !isMobileSubmenuLayout.value) {
+ activateSubmenu(index)
+ } else if (!hasChildren(option)) {
+ deactivateSubmenu()
+ }
+ }
+
+ function handleOptionMouseEnter(option: ContextMenuAction, index: number) {
+ if (isMobileSubmenuLayout.value) return
+
+ if (activeOptionIndex.value === null) {
+ if (hasChildren(option)) activateSubmenu(index)
+ return
+ }
+
+ if (activeOptionIndex.value === index) return
+
+ if (!isCursorAimingAtSubmenu(lastMousePosition.value, previousMousePosition)) {
+ commitHoveredOption(index)
+ return
+ }
+
+ pendingOptionIndex.value = index
+ clearPendingOptionTimeout()
+ pendingOptionTimeout = setTimeout(() => {
+ if (pendingOptionIndex.value !== index) return
+ if (isCursorInsideSubmenu.value) {
+ pendingOptionIndex.value = null
+ return
+ }
+
+ commitHoveredOption(index)
+ }, 180)
+ }
+
+ function commitHoveredOption(index: number) {
+ const option = options.value[index]
+ if (option && hasChildren(option)) {
+ activateSubmenu(index)
+ } else {
+ deactivateSubmenu()
+ }
+ }
+
+ function activateSubmenu(index: number) {
+ clearPendingOptionTimeout()
+ pendingOptionIndex.value = null
+ activeOptionIndex.value = index
+ hasSubmenuPosition.value = false
+ scheduleSubmenuPositionUpdate()
+ }
+
+ function deactivateSubmenu() {
+ clearPendingOptionTimeout()
+ activeOptionIndex.value = null
+ pendingOptionIndex.value = null
+ hasSubmenuPosition.value = false
+ isCursorInsideSubmenu.value = false
+ lastMousePosition.value = null
+ previousMousePosition = null
+ }
+
+ function returnToMenu() {
+ const previousIndex = activeOptionIndex.value
+ deactivateSubmenu()
+ nextTick(() => {
+ if (previousIndex !== null) optionButtonRefs.get(previousIndex)?.focus()
+ })
+ }
+
+ function handleSubmenuMouseEnter() {
+ isCursorInsideSubmenu.value = true
+ clearPendingOptionTimeout()
+ pendingOptionIndex.value = null
+ }
+
+ function handleMenuMouseMove(event: MouseEvent, source: 'menu' | 'submenu') {
+ previousMousePosition = lastMousePosition.value
+ lastMousePosition.value = { x: event.clientX, y: event.clientY }
+
+ if (
+ source === 'menu' &&
+ pendingOptionIndex.value !== null &&
+ !isCursorAimingAtSubmenu(lastMousePosition.value, previousMousePosition)
+ ) {
+ commitHoveredOption(pendingOptionIndex.value)
+ }
+ }
+
+ function isCursorAimingAtSubmenu(cursor: Point | null, origin: Point | null) {
+ const submenuRect = submenu.value?.getBoundingClientRect()
+ if (!submenuRect || !cursor || !origin) return false
+
+ const submenuTargetX =
+ origin.x <= submenuRect.left
+ ? submenuRect.left
+ : origin.x >= submenuRect.right
+ ? submenuRect.right
+ : cursor.x <= submenuRect.left
+ ? submenuRect.left
+ : submenuRect.right
+ const upperTarget = { x: submenuTargetX, y: submenuRect.top - 20 }
+ const lowerTarget = { x: submenuTargetX, y: submenuRect.bottom + 20 }
+
+ return isPointInTriangle(cursor, origin, upperTarget, lowerTarget)
+ }
+
+ function handleMenuKeydown(event: KeyboardEvent) {
+ const buttons = getFocusableButtons(optionButtonRefs)
+ if (event.key === 'ArrowDown' || event.key === 'ArrowUp') {
+ event.preventDefault()
+ focusRelativeButton(buttons, event.key === 'ArrowDown' ? 1 : -1)
+ } else if (event.key === 'Home' || event.key === 'End') {
+ event.preventDefault()
+ buttons[event.key === 'Home' ? 0 : buttons.length - 1]?.focus()
+ } else if (event.key === 'ArrowRight') {
+ const focusedIndex = [...optionButtonRefs.entries()].find(
+ ([, button]) => button === document.activeElement,
+ )?.[0]
+ const focusedOption = focusedIndex === undefined ? undefined : options.value[focusedIndex]
+ if (focusedIndex !== undefined && focusedOption && hasChildren(focusedOption)) {
+ event.preventDefault()
+ activateSubmenu(focusedIndex)
+ nextTick(() => getFocusableButtons(submenuButtonRefs)[0]?.focus())
+ }
+ } else if (event.key === 'Escape') {
+ event.preventDefault()
+ hideMenu()
+ }
+ }
+
+ function handleSubmenuKeydown(event: KeyboardEvent) {
+ const buttons = getFocusableButtons(submenuButtonRefs)
+ if (event.key === 'ArrowDown' || event.key === 'ArrowUp') {
+ event.preventDefault()
+ focusRelativeButton(buttons, event.key === 'ArrowDown' ? 1 : -1)
+ } else if (event.key === 'Home' || event.key === 'End') {
+ event.preventDefault()
+ buttons[event.key === 'Home' ? 0 : buttons.length - 1]?.focus()
+ } else if (event.key === 'ArrowLeft') {
+ event.preventDefault()
+ returnToMenu()
+ } else if (event.key === 'Escape') {
+ event.preventDefault()
+ hideMenu()
+ }
+ }
+
+ function syncMobileSubmenuLayout(event?: MediaQueryListEvent) {
+ isMobileSubmenuLayout.value = event?.matches ?? mobileSubmenuMediaQuery?.matches ?? false
+ }
+
+ function handleDocumentKeydown(event: KeyboardEvent) {
+ if (shown.value && event.key === 'Escape') hideMenu()
+ }
+
+ function handleContextMenuOpen(event: Event) {
+ if (shown.value && event instanceof CustomEvent && event.detail !== contextMenuId) hideMenu()
+ }
+
+ function handleClickOutside(event: MouseEvent) {
+ const target = event.target
+ if (!(target instanceof Node)) return
+ if (!contextMenu.value?.contains(target) && !submenu.value?.contains(target)) hideMenu()
+ }
+
+ onMounted(() => {
+ mobileSubmenuMediaQuery = window.matchMedia(MOBILE_SUBMENU_LAYOUT_QUERY)
+ syncMobileSubmenuLayout()
+ mobileSubmenuMediaQuery.addEventListener('change', syncMobileSubmenuLayout)
+ window.addEventListener('click', handleClickOutside)
+ window.addEventListener('resize', schedulePositionUpdate)
+ window.addEventListener('scroll', schedulePositionUpdate, true)
+ window.visualViewport?.addEventListener('scroll', schedulePositionUpdate)
+ window.visualViewport?.addEventListener('resize', schedulePositionUpdate)
+ window.addEventListener(CONTEXT_MENU_OPEN_EVENT, handleContextMenuOpen)
+ document.addEventListener('keydown', handleDocumentKeydown)
+ })
+
+ onBeforeUnmount(() => {
+ clearPendingOptionTimeout()
+ cancelPositionUpdate()
+ mobileSubmenuMediaQuery?.removeEventListener('change', syncMobileSubmenuLayout)
+ window.removeEventListener('click', handleClickOutside)
+ window.removeEventListener('resize', schedulePositionUpdate)
+ window.removeEventListener('scroll', schedulePositionUpdate, true)
+ window.visualViewport?.removeEventListener('scroll', schedulePositionUpdate)
+ window.visualViewport?.removeEventListener('resize', schedulePositionUpdate)
+ window.removeEventListener(CONTEXT_MENU_OPEN_EVENT, handleContextMenuOpen)
+ document.removeEventListener('keydown', handleDocumentKeydown)
+ })
+
+ function clearPendingOptionTimeout() {
+ if (!pendingOptionTimeout) return
+ clearTimeout(pendingOptionTimeout)
+ pendingOptionTimeout = null
+ }
+
+ return {
+ shown,
+ contextMenu,
+ submenu,
+ options,
+ menuStyle,
+ submenuStyle,
+ activeOption,
+ activeOptionIndex,
+ pendingOptionIndex,
+ hasSubmenuPosition,
+ isMobileSubmenuLayout,
+ isMobileActiveSubmenu,
+ isCursorInsideSubmenu,
+ isOptionVisible,
+ setOptionButtonRef,
+ setSubmenuButtonRef,
+ showMenu,
+ hideMenu,
+ handleOptionClick,
+ optionClicked,
+ handleOptionFocus,
+ handleOptionMouseEnter,
+ returnToMenu,
+ handleSubmenuMouseEnter,
+ handleMenuMouseMove,
+ handleMenuKeydown,
+ handleSubmenuKeydown,
+ }
+}
+
+function setButtonRef(
+ buttonRefs: Map,
+ index: number,
+ element: ButtonRefElement,
+) {
+ if (element instanceof HTMLElement) {
+ buttonRefs.set(index, element)
+ } else {
+ buttonRefs.delete(index)
+ }
+}
diff --git a/apps/app-frontend/src/components/ui/context-menu/utils.ts b/apps/app-frontend/src/components/ui/context-menu/utils.ts
new file mode 100644
index 0000000000..e7bd7c79d2
--- /dev/null
+++ b/apps/app-frontend/src/components/ui/context-menu/utils.ts
@@ -0,0 +1,59 @@
+import type {
+ ContextMenuAction,
+ ContextMenuDivider,
+ ContextMenuOption,
+ ContextMenuParentAction,
+ Point,
+} from './types'
+
+export function isAction(option: ContextMenuOption): option is ContextMenuAction {
+ return 'name' in option
+}
+
+export function isDivider(option: ContextMenuOption): option is ContextMenuDivider {
+ return 'type' in option && option.type === 'divider'
+}
+
+export function hasChildren(option: ContextMenuOption): option is ContextMenuParentAction {
+ return isAction(option) && Boolean(option.children?.length)
+}
+
+export function isInstanceLink(value: unknown) {
+ if (!value || typeof value !== 'object') return false
+
+ if ('instance' in value) {
+ const instance = value.instance
+ return Boolean(instance && typeof instance === 'object' && 'link' in instance && instance.link)
+ }
+
+ return 'link' in value && Boolean(value.link)
+}
+
+export function getFocusableButtons(buttonRefs: Map) {
+ return [...buttonRefs.entries()]
+ .sort(([left], [right]) => left - right)
+ .map(([, button]) => button)
+ .filter((button) => button.offsetParent !== null)
+}
+
+export function focusRelativeButton(buttons: HTMLElement[], direction: 1 | -1) {
+ if (!buttons.length) return
+
+ const currentIndex = buttons.indexOf(document.activeElement as HTMLElement)
+ const nextIndex =
+ currentIndex === -1 ? (direction === 1 ? 0 : buttons.length - 1) : currentIndex + direction
+ buttons[(nextIndex + buttons.length) % buttons.length]?.focus()
+}
+
+export function isPointInTriangle(point: Point, a: Point, b: Point, c: Point) {
+ const area = triangleArea(a, b, c)
+ const area1 = triangleArea(point, b, c)
+ const area2 = triangleArea(a, point, c)
+ const area3 = triangleArea(a, b, point)
+
+ return Math.abs(area - (area1 + area2 + area3)) < 0.5
+}
+
+function triangleArea(a: Point, b: Point, c: Point) {
+ return Math.abs((a.x * (b.y - c.y) + b.x * (c.y - a.y) + c.x * (a.y - b.y)) / 2)
+}
diff --git a/apps/app-frontend/src/components/ui/friends/FriendsList.vue b/apps/app-frontend/src/components/ui/friends/FriendsList.vue
index 705b3c16a1..7294781e87 100644
--- a/apps/app-frontend/src/components/ui/friends/FriendsList.vue
+++ b/apps/app-frontend/src/components/ui/friends/FriendsList.vue
@@ -15,17 +15,17 @@ import { computed, ref } from 'vue'
import FriendsSection from '@/components/ui/friends/FriendsSection.vue'
import ModalWrapper from '@/components/ui/modal/ModalWrapper.vue'
+import { useAppSettings } from '@/composables/use-app-settings.ts'
import { useFriends } from '@/composables/use-friends'
import type { FriendWithUserData } from '@/helpers/friends.ts'
import type { ModrinthCredentials } from '@/helpers/mr_auth'
import { get as getSettings, set as setSettings } from '@/helpers/settings.ts'
-import { useTheming } from '@/store/state'
const { formatMessage } = useVIntl()
const { handleError } = injectNotificationManager()
const formatRelativeTime = useRelativeTime()
-const themeStore = useTheming()
+const appSettings = useAppSettings()
const props = defineProps<{
credentials: ModrinthCredentials | null
@@ -39,11 +39,11 @@ type FriendsSectionCollapsedFlag =
| 'friends_pending_collapsed'
function isFriendsSectionCollapsed(flag: FriendsSectionCollapsedFlag) {
- return themeStore.getFeatureFlag(flag)
+ return appSettings.getFeatureFlag(flag)
}
function setFriendsSectionCollapsed(flag: FriendsSectionCollapsedFlag, collapsed: boolean) {
- themeStore.featureFlags[flag] = collapsed
+ appSettings.featureFlags[flag] = collapsed
getSettings()
.then((settings) => {
settings.feature_flags[flag] = collapsed
diff --git a/apps/app-frontend/src/components/ui/friends/FriendsSection.vue b/apps/app-frontend/src/components/ui/friends/FriendsSection.vue
index e53e7cc0f6..e9678ec7b7 100644
--- a/apps/app-frontend/src/components/ui/friends/FriendsSection.vue
+++ b/apps/app-frontend/src/components/ui/friends/FriendsSection.vue
@@ -11,7 +11,7 @@ import {
import { useTemplateRef } from 'vue'
import { useRouter } from 'vue-router'
-import ContextMenu from '@/components/ui/ContextMenu.vue'
+import ContextMenu from '@/components/ui/context-menu/index.vue'
import type { FriendWithUserData } from '@/helpers/friends.ts'
const { formatMessage } = useVIntl()
diff --git a/apps/app-frontend/src/components/ui/install_flow/UnknownPackWarningModal.vue b/apps/app-frontend/src/components/ui/install_flow/UnknownPackWarningModal.vue
index f1b929e050..b1063c805d 100644
--- a/apps/app-frontend/src/components/ui/install_flow/UnknownPackWarningModal.vue
+++ b/apps/app-frontend/src/components/ui/install_flow/UnknownPackWarningModal.vue
@@ -13,11 +13,10 @@
import { UnknownFileWarningModal } from '@modrinth/ui'
import { ref, useTemplateRef } from 'vue'
+import { type FeatureFlag, useAppSettings } from '@/composables/use-app-settings.ts'
import { get as getSettings, set as setSettings } from '@/helpers/settings'
-import { useTheming } from '@/store/state'
-import type { FeatureFlag } from '@/store/theme.ts'
-const themeStore = useTheming()
+const appSettings = useAppSettings()
const skipUnknownPackWarningFeatureFlag = 'skip_unknown_pack_warning' as FeatureFlag
const modal = useTemplateRef('modal')
@@ -34,7 +33,7 @@ function show(
fileName.value = selectedFileName
externalFilesInModpack.value = selectedExternalFiles
- if (themeStore.getFeatureFlag(skipUnknownPackWarningFeatureFlag)) {
+ if (appSettings.getFeatureFlag(skipUnknownPackWarningFeatureFlag)) {
void createInstance()
return
}
@@ -50,7 +49,7 @@ function reset() {
async function proceed(dontShowAgain: boolean) {
if (dontShowAgain) {
- themeStore.featureFlags[skipUnknownPackWarningFeatureFlag] = true
+ appSettings.featureFlags[skipUnknownPackWarningFeatureFlag] = true
const settings = await getSettings()
settings.feature_flags[skipUnknownPackWarningFeatureFlag] = true
await setSettings(settings)
diff --git a/apps/app-frontend/src/components/ui/instance_settings/icon-editor-modal/editor-catalog.ts b/apps/app-frontend/src/components/ui/instance_settings/icon-editor-modal/editor-catalog.ts
index 18f6d06435..7fa45b51a5 100644
--- a/apps/app-frontend/src/components/ui/instance_settings/icon-editor-modal/editor-catalog.ts
+++ b/apps/app-frontend/src/components/ui/instance_settings/icon-editor-modal/editor-catalog.ts
@@ -1,4 +1,4 @@
-import { defineMessages } from '@modrinth/ui'
+import { defineMessages, type MessageDescriptor } from '@modrinth/ui'
import backpack from '@/assets/instance-icons/backpack.png'
import beacon from '@/assets/instance-icons/beacon.png'
@@ -18,6 +18,8 @@ import enchantingTable from '@/assets/instance-icons/enchanting-table.png'
import enderChest from '@/assets/instance-icons/ender-chest.png'
import enderDragon from '@/assets/instance-icons/ender-dragon.png'
import engine from '@/assets/instance-icons/engine.png'
+import fabric from '@/assets/instance-icons/fabric.png'
+import forge from '@/assets/instance-icons/forge.png'
import furnace from '@/assets/instance-icons/furnace.png'
import gizmo from '@/assets/instance-icons/gizmo.png'
import globe from '@/assets/instance-icons/globe.png'
@@ -25,11 +27,13 @@ import grassBlock from '@/assets/instance-icons/grass-block.png'
import lantern from '@/assets/instance-icons/lantern.png'
import moobloom from '@/assets/instance-icons/moobloom.png'
import mrPack from '@/assets/instance-icons/mr-pack.png'
+import neoForge from '@/assets/instance-icons/neoforge.png'
import orb from '@/assets/instance-icons/orb.png'
import oxygenDistributor from '@/assets/instance-icons/oxygen-distributor.png'
import pancakes from '@/assets/instance-icons/pancakes.png'
import pickaxe from '@/assets/instance-icons/pickaxe.png'
import pokeBall from '@/assets/instance-icons/poke-ball.png'
+import quilt from '@/assets/instance-icons/quilt.png'
import redstoneBlock from '@/assets/instance-icons/redstone-block.png'
import sculkSensor from '@/assets/instance-icons/sculk-sensor.png'
import skeleton from '@/assets/instance-icons/skeleton.png'
@@ -197,8 +201,20 @@ const names = defineMessages({
defaultMessage: 'Modrinth Wrench',
},
zombie: { id: 'instance.icon-editor.symbol.zombie', defaultMessage: 'Zombie' },
+ fabric: { id: 'instance.icon-editor.symbol.fabric', defaultMessage: 'Fabric' },
+ forge: { id: 'instance.icon-editor.symbol.forge', defaultMessage: 'Forge' },
+ neoForge: { id: 'instance.icon-editor.symbol.neoforge', defaultMessage: 'NeoForge' },
+ quilt: { id: 'instance.icon-editor.symbol.quilt', defaultMessage: 'Quilt' },
})
+export interface SymbolOption {
+ id: string
+ name: MessageDescriptor
+ asset: string
+ category: 'loader' | 'modded' | 'vanilla'
+ excludeFromRandomization?: boolean
+}
+
export const backgroundOptions = [
{
id: 'rose',
@@ -339,6 +355,39 @@ export const backgroundOptions = [
] as const
export const symbolOptions = [
+ /////////////////////////
+ // loaders
+ /////////////////////////
+
+ {
+ id: 'fabric',
+ name: names.fabric,
+ asset: fabric,
+ category: 'loader',
+ excludeFromRandomization: true,
+ },
+ {
+ id: 'forge',
+ name: names.forge,
+ asset: forge,
+ category: 'loader',
+ excludeFromRandomization: true,
+ },
+ {
+ id: 'neoforge',
+ name: names.neoForge,
+ asset: neoForge,
+ category: 'loader',
+ excludeFromRandomization: true,
+ },
+ {
+ id: 'quilt',
+ name: names.quilt,
+ asset: quilt,
+ category: 'loader',
+ excludeFromRandomization: true,
+ },
+
// Cobblemon: Poké Ball
{ id: 'poke_ball', name: names.pokeBall, asset: pokeBall, category: 'modded' },
@@ -438,7 +487,7 @@ export const symbolOptions = [
{ id: 'lantern', name: names.lantern, asset: lantern, category: 'vanilla' },
{ id: 'tnt', name: names.tnt, asset: tnt, category: 'vanilla' },
{ id: 'command_block', name: names.commandBlock, asset: commandBlock, category: 'vanilla' },
-] as const
+] as const satisfies readonly SymbolOption[]
export type BackgroundId = (typeof backgroundOptions)[number]['id']
export type SymbolId = (typeof symbolOptions)[number]['id']
diff --git a/apps/app-frontend/src/components/ui/instance_settings/icon-editor-modal/index.vue b/apps/app-frontend/src/components/ui/instance_settings/icon-editor-modal/index.vue
index 12c53f983d..7ecf4ba41b 100644
--- a/apps/app-frontend/src/components/ui/instance_settings/icon-editor-modal/index.vue
+++ b/apps/app-frontend/src/components/ui/instance_settings/icon-editor-modal/index.vue
@@ -1,6 +1,7 @@
-
-
- {{ formatMessage(messages.colorThemeTitle) }}
-
- {{ formatMessage(messages.colorThemeDescription) }}
+async function saveAppearanceSettings(): Promise {
+ try {
+ await save()
+ } catch {
+ return
+ }
+}
-
+onMounted(() => {
+ settingsModal?.registerUnsavedChangesController({
+ hasChanges: () => hasChanges.value,
+ getOriginal: () => saved.value,
+ getModified: () => changes.value,
+ isSaving: () => saving.value,
+ reset,
+ save: saveAppearanceSettings,
+ })
+})
-
-
-
- {{ formatMessage(messages.advancedRenderingTitle) }}
-
-
- {{ formatMessage(messages.advancedRenderingDescription) }}
-
-
+onBeforeUnmount(() => {
+ theme.preview = null
+ settingsModal?.registerUnsavedChangesController(null)
+})
-
{
- themeStore.advancedRendering = !!e
- settings.advanced_rendering = themeStore.advancedRendering
+provideAppearanceSettings({
+ deferPersistence: true,
+ theme: {
+ current: computed(() => current.value.theme),
+ options: themeOptions,
+ system: computed(() => theme.native),
+ set: setTheme,
+ syncAcrossDevices: {
+ value: computed(() => current.value.syncAcrossDevices),
+ set: setSyncAcrossDevices,
+ },
+ syncDisabled: computed(() => !auth.user.value),
+ },
+ advancedRendering: {
+ value: computed(() => current.value.advancedRendering),
+ set: setAdvancedRendering,
+ },
+ nativeDecorations:
+ os !== 'MacOS'
+ ? {
+ value: computed(() => current.value.nativeDecorations),
+ set: setNativeDecorations,
}
- "
- />
-
+ : undefined,
+ updatePreferences,
+})
+
-
-
-
- {{ formatMessage(messages.nativeDecorationsTitle) }}
-
-
{{ formatMessage(messages.nativeDecorationsDescription) }}
-
-
-
+
+
diff --git a/apps/app-frontend/src/components/ui/settings/display/BehaviorSettings.vue b/apps/app-frontend/src/components/ui/settings/display/BehaviorSettings.vue
index 80de14a718..d52c4404a6 100644
--- a/apps/app-frontend/src/components/ui/settings/display/BehaviorSettings.vue
+++ b/apps/app-frontend/src/components/ui/settings/display/BehaviorSettings.vue
@@ -1,20 +1,48 @@
-
+
+
+
+
+ {{ formatMessage(messages.syncAcrossDevicesTitle) }}
+
+
+ {{ formatMessage(messages.syncAcrossDevicesDescription) }}
+
+
+
+
+
+
+
+
+
{{ formatMessage(messages.startupAndNavigationTitle) }}
@@ -121,7 +282,7 @@ watch(
{{ formatMessage(messages.minimizeLauncherDescription) }}
-
+
@@ -131,16 +292,7 @@ watch(
{{ formatMessage(messages.toggleSidebarDescription) }}
-
@@ -159,17 +311,17 @@ watch(
{{ formatMessage(messages.jumpBackIntoWorldsDescription) }}
- {
- const newValue = !themeStore.getFeatureFlag(worldsInHomeFlag)
- themeStore.featureFlags[worldsInHomeFlag] = newValue
- settings.feature_flags[worldsInHomeFlag] = newValue
- }
- "
- />
+
+
+
+
+
+
+ {{ formatMessage(messages.compactModeTitle) }}
+
+
{{ formatMessage(messages.compactModeDescription) }}
+
+
@@ -179,17 +331,7 @@ watch(
{{ formatMessage(messages.showPlayTimeDescription) }}
- {
- const newValue = !themeStore.getFeatureFlag(showPlayTimeFlag)
- themeStore.featureFlags[showPlayTimeFlag] = newValue
- settings.feature_flags[showPlayTimeFlag] = newValue
- }
- "
- />
+
@@ -199,16 +341,7 @@ watch(
{{ formatMessage(messages.hideNametagDescription) }}
- {
- themeStore.hideNametagSkinsPage = !!e
- settings.hide_nametag_skins_page = themeStore.hideNametagSkinsPage
- }
- "
- />
+
@@ -229,15 +362,7 @@ watch(
{
- const warnBeforeUnknownPackInstall = !!e
- const skipUnknownPackWarning = !warnBeforeUnknownPackInstall
- themeStore.featureFlags[skipUnknownPackWarningFlag] = skipUnknownPackWarning
- settings.feature_flags[skipUnknownPackWarningFlag] = skipUnknownPackWarning
- }
- "
+ v-model="current.warnOnUnknownModpacks"
/>
@@ -250,17 +375,7 @@ watch(
{{ formatMessage(messages.skipNonEssentialWarningsDescription) }}
- {
- const newValue = !themeStore.getFeatureFlag(skipNonEssentialWarningsFlag)
- themeStore.featureFlags[skipNonEssentialWarningsFlag] = newValue
- settings.feature_flags[skipNonEssentialWarningsFlag] = newValue
- }
- "
- />
+
diff --git a/apps/app-frontend/src/components/ui/settings/display/FeatureFlagSettings.vue b/apps/app-frontend/src/components/ui/settings/display/FeatureFlagSettings.vue
index 29f314f140..aa6f0448f8 100644
--- a/apps/app-frontend/src/components/ui/settings/display/FeatureFlagSettings.vue
+++ b/apps/app-frontend/src/components/ui/settings/display/FeatureFlagSettings.vue
@@ -2,17 +2,20 @@
import { Button, Toggle } from '@modrinth/ui'
import { ref, watch } from 'vue'
+import {
+ DEFAULT_FEATURE_FLAGS,
+ type FeatureFlag,
+ useAppSettings,
+} from '@/composables/use-app-settings.ts'
import { get as getSettings, set as setSettings } from '@/helpers/settings.ts'
-import { useTheming } from '@/store/state'
-import { DEFAULT_FEATURE_FLAGS, type FeatureFlag } from '@/store/theme.ts'
-const themeStore = useTheming()
+const appSettings = useAppSettings()
const settings = ref(await getSettings())
-const options = ref(Object.keys(DEFAULT_FEATURE_FLAGS))
+const options = ref(Object.keys(DEFAULT_FEATURE_FLAGS) as FeatureFlag[])
-function setFeatureFlag(key: string, value: boolean) {
- themeStore.featureFlags[key] = value
+function setFeatureFlag(key: FeatureFlag, value: boolean) {
+ appSettings.featureFlags[key] = value
settings.value.feature_flags[key] = value
}
@@ -35,15 +38,15 @@ watch(
setFeatureFlag(option, !themeStore.getFeatureFlag(option))"
+ :model-value="appSettings.getFeatureFlag(option)"
+ @update:model-value="() => setFeatureFlag(option, !appSettings.getFeatureFlag(option))"
/>
diff --git a/apps/app-frontend/src/components/ui/settings/display/LanguageSettings.vue b/apps/app-frontend/src/components/ui/settings/display/LanguageSettings.vue
index 01f4384bcc..0083a46e7a 100644
--- a/apps/app-frontend/src/components/ui/settings/display/LanguageSettings.vue
+++ b/apps/app-frontend/src/components/ui/settings/display/LanguageSettings.vue
@@ -1,74 +1,35 @@
+
+
+
+
-
-
-
- {{ formatMessage(commonSettingsMessages.language) }}
-
-
-
- {{ formatMessage(languageSelectorMessages.languageWarning, { platform }) }}
-
-
-
-
-
-
-
-
-
-
-
-
-
-
diff --git a/apps/app-frontend/src/components/ui/settings/instances/ResourceManagementSettings.vue b/apps/app-frontend/src/components/ui/settings/instances/ResourceManagementSettings.vue
index 0fc1a131f6..55d6404005 100644
--- a/apps/app-frontend/src/components/ui/settings/instances/ResourceManagementSettings.vue
+++ b/apps/app-frontend/src/components/ui/settings/instances/ResourceManagementSettings.vue
@@ -14,14 +14,14 @@ import { open } from '@tauri-apps/plugin-dialog'
import { ref, watch } from 'vue'
import ConfirmModalWrapper from '@/components/ui/modal/ConfirmModalWrapper.vue'
+import { useAppSettings } from '@/composables/use-app-settings.ts'
import { purge_cache_types } from '@/helpers/cache.js'
import { get, set } from '@/helpers/settings.ts'
import { showAppDbBackupsFolder } from '@/helpers/utils.js'
-import { useTheming } from '@/store/state'
const { handleError } = injectNotificationManager()
const { formatMessage } = useVIntl()
-const themeStore = useTheming()
+const appSettings = useAppSettings()
const settings = ref(await get())
const purgeCacheConfirmModal = ref(null)
const alwaysShowCopyDetailsFlag = 'always_show_copy_details'
@@ -145,7 +145,7 @@ async function purgeCache() {
}
function handlePurgeCacheClick() {
- if (themeStore.getFeatureFlag('skip_non_essential_warnings')) {
+ if (appSettings.getFeatureFlag('skip_non_essential_warnings')) {
void purgeCache()
return
}
@@ -210,11 +210,11 @@ async function findLauncherDir() {
{
- const newValue = !themeStore.getFeatureFlag(alwaysShowCopyDetailsFlag)
- themeStore.featureFlags[alwaysShowCopyDetailsFlag] = newValue
+ const newValue = !appSettings.getFeatureFlag(alwaysShowCopyDetailsFlag)
+ appSettings.featureFlags[alwaysShowCopyDetailsFlag] = newValue
settings.feature_flags[alwaysShowCopyDetailsFlag] = newValue
}
"
diff --git a/apps/app-frontend/src/components/ui/shared-instances/shared-instance-invite-handler/use-shared-instance-invite-handler.ts b/apps/app-frontend/src/components/ui/shared-instances/shared-instance-invite-handler/use-shared-instance-invite-handler.ts
index 7135fa99df..00bd70b9c0 100644
--- a/apps/app-frontend/src/components/ui/shared-instances/shared-instance-invite-handler/use-shared-instance-invite-handler.ts
+++ b/apps/app-frontend/src/components/ui/shared-instances/shared-instance-invite-handler/use-shared-instance-invite-handler.ts
@@ -9,6 +9,7 @@ import { useQueryClient } from '@tanstack/vue-query'
import { type Ref, watch } from 'vue'
import { useRouter } from 'vue-router'
+import { useAppSettings } from '@/composables/use-app-settings.ts'
import { get_user } from '@/helpers/cache'
import { toError } from '@/helpers/errors'
import {
@@ -18,7 +19,6 @@ import {
} from '@/helpers/install'
import { list } from '@/helpers/instance'
import { useSharedInstanceErrors } from '@/helpers/shared-instance-errors'
-import { useTheming } from '@/store/state'
import { parseSharedInstanceInviteNotification } from './shared-instance-invite-parser'
import type { AppNotification, SharedInstanceInvite } from './shared-instance-invite-types'
@@ -58,7 +58,7 @@ export function useSharedInstanceInviteHandler(
const popupNotificationManager = injectPopupNotificationManager()
const queryClient = useQueryClient()
const router = useRouter()
- const themeStore = useTheming()
+ const appSettings = useAppSettings()
const displayedNotifications = new Set()
const displayedNotificationKeys = new Set()
const popupNotificationIds = new Set()
@@ -121,7 +121,7 @@ export function useSharedInstanceInviteHandler(
(instance) => instance.shared_instance?.id === sharedInstanceId,
)
- if (!existingInstance || themeStore.getFeatureFlag('skip_non_essential_warnings')) {
+ if (!existingInstance || appSettings.getFeatureFlag('skip_non_essential_warnings')) {
showInstall(preview, install, creator)
return
}
diff --git a/apps/app-frontend/src/components/ui/world/InstanceItem.vue b/apps/app-frontend/src/components/ui/world/InstanceItem.vue
index 4dc5bd4850..f922cc7809 100644
--- a/apps/app-frontend/src/components/ui/world/InstanceItem.vue
+++ b/apps/app-frontend/src/components/ui/world/InstanceItem.vue
@@ -28,12 +28,12 @@ import { computed, onMounted, ref } from 'vue'
import { useRouter } from 'vue-router'
import { useAppEvent } from '@/composables/use-app-event'
+import { handleSevereError } from '@/composables/use-error.js'
import { trackEvent } from '@/helpers/analytics'
import { getInstanceIconUrl, kill, run } from '@/helpers/instance'
import { get_by_instance_id } from '@/helpers/process'
import type { GameInstance } from '@/helpers/types'
import { showInstanceInFolder } from '@/helpers/utils'
-import { handleSevereError } from '@/store/error'
const { handleError } = injectNotificationManager()
const { formatMessage } = useVIntl()
diff --git a/apps/app-frontend/src/components/ui/world/RecentWorldsList.vue b/apps/app-frontend/src/components/ui/world/RecentWorldsList.vue
index bf6c0d84d8..c6a8549f10 100644
--- a/apps/app-frontend/src/components/ui/world/RecentWorldsList.vue
+++ b/apps/app-frontend/src/components/ui/world/RecentWorldsList.vue
@@ -1,15 +1,23 @@
-
-
- {{ formatMessage(messages.jumpIn) }}
-
-
+
+
+
+ {{ formatMessage(messages.jumpIn) }}
+
+
+
-
-
-
- {{ formatMessage(messages.jumpIn) }}
-
-
-
-
+
+
+
+ populateJumpBackIn()"
- @play="
- () => {
- currentInstance = item.instance.id
- currentWorld = getWorldIdentifier(item.world)
- joinWorld(item.world, item.instance)
- }
- "
- @play-instance="
- () => {
- currentInstance = item.instance.id
- playInstance(item.instance)
- }
- "
- @stop="() => stopInstance(item.instance.id)"
- />
- markInstancePlayed(item)"
- />
-
+ ? serverData[item.world.address].refreshing &&
+ !serverData[item.world.address].status
+ : undefined
+ "
+ :supports-server-quick-play="
+ item.world.type === 'server' &&
+ hasServerQuickPlaySupport(gameVersions, item.instance.game_version || '')
+ "
+ :supports-world-quick-play="
+ item.world.type === 'singleplayer' &&
+ hasWorldQuickPlaySupport(gameVersions, item.instance.game_version || '')
+ "
+ :quarantined="item.instance.quarantined"
+ :server-status="
+ item.world.type === 'server' ? serverData[item.world.address].status : undefined
+ "
+ :rendered-motd="
+ item.world.type === 'server' ? serverData[item.world.address].renderedMotd : undefined
+ "
+ :current-protocol="protocolVersions[item.instance.id]"
+ :game-mode="
+ item.world.type === 'singleplayer' ? GAME_MODES[item.world.game_mode] : undefined
+ "
+ :instance-id="item.instance.id"
+ :instance-name="item.instance.name"
+ :instance-icon="item.instance.icon_path"
+ @refresh="
+ () =>
+ item.world.type === 'server'
+ ? refreshServer(item.world.address, item.instance.id)
+ : {}
+ "
+ @update="() => populateJumpBackIn()"
+ @play="
+ () => {
+ currentInstance = item.instance.id
+ currentWorld = getWorldIdentifier(item.world)
+ joinWorld(item.world, item.instance)
+ }
+ "
+ @play-instance="
+ () => {
+ currentInstance = item.instance.id
+ playInstance(item.instance)
+ }
+ "
+ @stop="() => stopInstance(item.instance.id)"
+ />
+ markInstancePlayed(item)"
+ />
+
+
+
-
+
+
+
diff --git a/apps/app-frontend/src/components/ui/world/WorldItem.vue b/apps/app-frontend/src/components/ui/world/WorldItem.vue
index 919c0b82b0..8147d0bce0 100644
--- a/apps/app-frontend/src/components/ui/world/WorldItem.vue
+++ b/apps/app-frontend/src/components/ui/world/WorldItem.vue
@@ -9,6 +9,7 @@ import {
MoreVerticalIcon,
NoSignalIcon,
PlayIcon,
+ SignalIcon,
SkullIcon,
SpinnerIcon,
StopCircleIcon,
@@ -25,15 +26,17 @@ import {
commonMessages,
defineMessages,
injectNotificationManager,
- ServerOnlinePlayers,
SmartClickable,
TagItem,
TeleportOverflowMenu,
useFormatDateTime,
+ useFormatNumber,
useRelativeTime,
useVIntl,
} from '@modrinth/ui'
+import { getPingLevel } from '@modrinth/utils'
import dayjs from 'dayjs'
+import { Tooltip } from 'floating-vue'
import type { Component } from 'vue'
import { computed } from 'vue'
import { useRouter } from 'vue-router'
@@ -53,6 +56,7 @@ import { LockIcon } from '../../../../../../packages/assets/generated-icons'
const { formatMessage } = useVIntl()
const formatRelativeTime = useRelativeTime()
+const formatNumber = useFormatNumber()
const formatDateTime = useFormatDateTime({
timeStyle: 'short',
dateStyle: 'long',
@@ -121,6 +125,9 @@ const props = withDefaults(
)
const playingOtherWorld = computed(() => props.playingInstance && !props.playingWorld)
+const hasPlayersTooltip = computed(
+ () => !!props.serverStatus?.players?.sample && props.serverStatus.players.sample.length > 0,
+)
const serverIncompatible = computed(
() =>
!!props.serverStatus &&
@@ -238,6 +245,10 @@ const messages = defineMessages({
id: 'app.world.world-item.incompatible-version',
defaultMessage: 'Incompatible version {version}',
},
+ playersOnline: {
+ id: 'app.world.world-item.players-online',
+ defaultMessage: '{count} online',
+ },
offline: {
id: 'app.world.world-item.offline',
defaultMessage: 'Offline',
@@ -315,13 +326,34 @@ const messages = defineMessages({
}}
-
-
+
-
+
+
+ {{
+ formatMessage(messages.playersOnline, {
+ count: formatNumber(serverStatus.players?.online ?? 0),
+ })
+ }}
+
+
+
+
+ {{ player.name }}
+
+
+
+
+
diff --git a/apps/app-frontend/src/composables/browse/install-job-notifications.ts b/apps/app-frontend/src/composables/browse/install-job-notifications.ts
index 7572434d36..c45a61cfff 100644
--- a/apps/app-frontend/src/composables/browse/install-job-notifications.ts
+++ b/apps/app-frontend/src/composables/browse/install-job-notifications.ts
@@ -10,6 +10,7 @@ import { convertFileSrc } from '@tauri-apps/api/core'
import { computed, ref } from 'vue'
import type { Router } from 'vue-router'
+import { useAppSettings } from '@/composables/use-app-settings.ts'
import {
install_job_dismiss,
install_job_list,
@@ -23,7 +24,6 @@ import {
} from '@/helpers/install'
import { get_many as getInstances } from '@/helpers/instance'
import { injectAppEvents } from '@/providers/app-events'
-import { useTheming } from '@/store/state'
const messages = defineMessages({
installs: {
@@ -236,7 +236,7 @@ export async function useInstallJobNotifications(opts: {
}) {
const appEvents = injectAppEvents()
const { formatMessage } = useVIntl()
- const themeStore = useTheming()
+ const appSettings = useAppSettings()
const jobs = ref([])
const iconUrls = ref>({})
const instanceNames = ref>({})
@@ -421,7 +421,7 @@ export async function useInstallJobNotifications(opts: {
}
function shouldShowCopyDetails(job: InstallJobSnapshot): boolean {
- return isTerminalJob(job) || themeStore.getFeatureFlag('always_show_copy_details')
+ return isTerminalJob(job) || appSettings.getFeatureFlag('always_show_copy_details')
}
function isCopied(job: InstallJobSnapshot): boolean {
diff --git a/apps/app-frontend/src/composables/use-app-settings.ts b/apps/app-frontend/src/composables/use-app-settings.ts
new file mode 100644
index 0000000000..d5e9ca22b0
--- /dev/null
+++ b/apps/app-frontend/src/composables/use-app-settings.ts
@@ -0,0 +1,53 @@
+import { reactive, ref } from 'vue'
+
+export const DEFAULT_FEATURE_FLAGS = {
+ project_background: false,
+ page_path: false,
+ worlds_in_home: true,
+ server_project_qa: false,
+ show_version_environment_column: false,
+ server_ram_as_bytes_always_on: false,
+ always_show_app_controls: false,
+ skip_non_essential_warnings: false,
+ skip_unknown_pack_warning: false,
+ pride_fundraiser: true,
+ i18n_debug: false,
+ show_instance_play_time: true,
+ compact_instance_cards: false,
+ advanced_filters_collapsed: true,
+ always_show_copy_details: false,
+ hide_installed_modpacks: false,
+ friends_active_collapsed: false,
+ friends_online_collapsed: false,
+ friends_offline_collapsed: true,
+ friends_pending_collapsed: true,
+ dismissed_photosensitivity_filter_warning: false,
+}
+
+export type FeatureFlag = keyof typeof DEFAULT_FEATURE_FLAGS
+type FeatureFlags = Record
+
+const syncBehaviorAcrossDevices = ref(false)
+const featureFlags = reactive({ ...DEFAULT_FEATURE_FLAGS })
+
+function setBehaviorSyncAcrossDevices(enabled: boolean): void {
+ syncBehaviorAcrossDevices.value = enabled
+}
+
+function getFeatureFlag(key: FeatureFlag): boolean {
+ return featureFlags[key] ?? DEFAULT_FEATURE_FLAGS[key]
+}
+
+const appSettings = reactive({
+ syncBehaviorAcrossDevices,
+ hideNametagSkinsPage: false,
+ toggleSidebar: false,
+ devMode: false,
+ featureFlags,
+ setBehaviorSyncAcrossDevices,
+ getFeatureFlag,
+})
+
+export function useAppSettings() {
+ return appSettings
+}
diff --git a/apps/app-frontend/src/composables/use-error.js b/apps/app-frontend/src/composables/use-error.js
new file mode 100644
index 0000000000..8323630733
--- /dev/null
+++ b/apps/app-frontend/src/composables/use-error.js
@@ -0,0 +1,48 @@
+import { reactive } from 'vue'
+
+import { findMinecraftAuthError } from '@/components/ui/minecraft-auth-error-modal/minecraft-auth-errors'
+
+const errorState = reactive({
+ errorModal: null,
+ minecraftAuthErrorModal: null,
+ minecraftRequiredModal: null,
+ setErrorModal(ref) {
+ this.errorModal = ref
+ },
+ setMinecraftAuthErrorModal(ref) {
+ this.minecraftAuthErrorModal = ref
+ },
+ setMinecraftRequiredModal(ref) {
+ this.minecraftRequiredModal = ref
+ },
+ showError(error, context, closable = true, source = null) {
+ const errorMessage = error.message?.toLowerCase()
+ if (
+ (errorMessage?.includes('user is not logged in') ||
+ errorMessage?.includes('cannot play instance since minecraft is required')) &&
+ this.minecraftRequiredModal
+ ) {
+ this.minecraftRequiredModal.show()
+ return
+ }
+ if (
+ error.message &&
+ (error.message.includes('Minecraft authentication error:') ||
+ findMinecraftAuthError(error.message)) &&
+ this.minecraftAuthErrorModal
+ ) {
+ this.minecraftAuthErrorModal.show(error)
+ return
+ }
+ this.errorModal.show(error, context, closable, source)
+ },
+})
+
+export function useError() {
+ return errorState
+}
+
+export const handleSevereError = (error, context) => {
+ useError().showError(error, context)
+ console.error(error)
+}
diff --git a/apps/app-frontend/src/composables/use-theme.ts b/apps/app-frontend/src/composables/use-theme.ts
new file mode 100644
index 0000000000..8a5aff4263
--- /dev/null
+++ b/apps/app-frontend/src/composables/use-theme.ts
@@ -0,0 +1,47 @@
+import { computed, reactive, ref, watch } from 'vue'
+
+export const THEME_OPTIONS = ['dark', 'light', 'oled', 'retro', 'system'] as const
+
+export type ColorTheme = (typeof THEME_OPTIONS)[number]
+type Theme = Exclude
+
+const preferred = ref('dark')
+const preview = ref(null)
+const advancedRendering = ref(true)
+const syncAcrossDevices = ref(false)
+const nativeThemeQuery = window.matchMedia('(prefers-color-scheme: dark)')
+const native = ref(nativeThemeQuery.matches ? 'dark' : 'light')
+const active = computed(() => {
+ const selectedTheme = preview.value ?? preferred.value
+ return selectedTheme === 'system' ? native.value : selectedTheme
+})
+
+nativeThemeQuery.addEventListener('change', (event) => {
+ native.value = event.matches ? 'dark' : 'light'
+})
+
+watch(
+ active,
+ (theme) => {
+ const html = document.documentElement
+ for (const option of THEME_OPTIONS) {
+ html.classList.remove(`${option}-mode`)
+ }
+ html.classList.add(`${theme}-mode`)
+ },
+ { immediate: true },
+)
+
+const theme = reactive({
+ preferred,
+ preview,
+ active,
+ native,
+ syncAcrossDevices,
+ advancedRendering,
+ options: THEME_OPTIONS,
+})
+
+export function useTheme() {
+ return theme
+}
diff --git a/apps/app-frontend/src/helpers/settings.ts b/apps/app-frontend/src/helpers/settings.ts
index 238d4116e2..340ce8a498 100644
--- a/apps/app-frontend/src/helpers/settings.ts
+++ b/apps/app-frontend/src/helpers/settings.ts
@@ -5,8 +5,9 @@
*/
import { invoke } from '@tauri-apps/api/core'
+import type { FeatureFlag } from '@/composables/use-app-settings.ts'
+import type { ColorTheme } from '@/composables/use-theme.ts'
import type { Hooks, MemorySettings, WindowSize } from '@/helpers/types'
-import type { ColorTheme, FeatureFlag } from '@/store/theme.ts'
// Settings object
/*
@@ -43,6 +44,8 @@ export type AppSettings = {
advanced_rendering: boolean
native_decorations: boolean
toggle_sidebar: boolean
+ sync_theme_across_devices: boolean
+ sync_behavior_across_devices: boolean
telemetry: boolean
discord_rpc: boolean
diff --git a/apps/app-frontend/src/helpers/types.d.ts b/apps/app-frontend/src/helpers/types.d.ts
index becbda9208..5981a51f6e 100644
--- a/apps/app-frontend/src/helpers/types.d.ts
+++ b/apps/app-frontend/src/helpers/types.d.ts
@@ -213,6 +213,8 @@ type AppSettings = {
advanced_rendering: boolean
native_decorations: boolean
worlds_in_home: boolean
+ sync_theme_across_devices: boolean
+ sync_behavior_across_devices: boolean
telemetry: boolean
discord_rpc: boolean
diff --git a/apps/app-frontend/src/helpers/user-preferences.ts b/apps/app-frontend/src/helpers/user-preferences.ts
new file mode 100644
index 0000000000..32261626a8
--- /dev/null
+++ b/apps/app-frontend/src/helpers/user-preferences.ts
@@ -0,0 +1,19 @@
+import type { UserPreferencesContext } from '@modrinth/ui'
+import { invoke } from '@tauri-apps/api/core'
+
+type UserPreferences = NonNullable
+type PartialUserPreferences = Parameters[0]
+
+export async function get_user_preferences(userId: string): Promise {
+ return await invoke('plugin:users|get_user_preferences', { userId })
+}
+
+export async function patch_user_preferences(
+ userId: string,
+ preferences: PartialUserPreferences,
+): Promise {
+ return await invoke('plugin:users|patch_user_preferences', {
+ userId,
+ preferences,
+ })
+}
diff --git a/apps/app-frontend/src/helpers/users.ts b/apps/app-frontend/src/helpers/users.ts
index 4d4b831b39..dd0c5dd776 100644
--- a/apps/app-frontend/src/helpers/users.ts
+++ b/apps/app-frontend/src/helpers/users.ts
@@ -25,8 +25,8 @@ export async function get_user_profile(userId: string): Promise('plugin:users|get_user_profile', { userId })
}
-export async function get_user_projects(userId: string): Promise {
- return await invoke('plugin:users|get_user_projects', {
+export async function get_user_projects(userId: string): Promise {
+ return await invoke('plugin:users|get_user_projects', {
userId,
})
}
diff --git a/apps/app-frontend/src/locales/ar-SA/index.json b/apps/app-frontend/src/locales/ar-SA/index.json
index c85293fc36..c622a6f4a8 100644
--- a/apps/app-frontend/src/locales/ar-SA/index.json
+++ b/apps/app-frontend/src/locales/ar-SA/index.json
@@ -134,12 +134,6 @@
"app.ads-consent.reject": {
"message": "رفض الكل"
},
- "app.appearance-settings.advanced-rendering.title": {
- "message": "العرض المتقدم"
- },
- "app.appearance-settings.color-theme.title": {
- "message": "لون السمة"
- },
"app.appearance-settings.default-landing-page.home": {
"message": "صفحة الرئيسية"
},
@@ -161,12 +155,6 @@
"app.appearance-settings.minimize-launcher.title": {
"message": "تصغير نافذة المشغل"
},
- "app.appearance-settings.native-decorations.description": {
- "message": "استخدام إطار نافذة النظام (يتطلب إعادة تشغيل التطبيق)."
- },
- "app.appearance-settings.native-decorations.title": {
- "message": "الزخارف التقليدية"
- },
"app.appearance-settings.show-play-time.description": {
"message": "يعرض مقدار الوقت الذي قضيته في لعب إحدى المهام."
},
diff --git a/apps/app-frontend/src/locales/cs-CZ/index.json b/apps/app-frontend/src/locales/cs-CZ/index.json
index 58476dde1a..e0feddb4e9 100644
--- a/apps/app-frontend/src/locales/cs-CZ/index.json
+++ b/apps/app-frontend/src/locales/cs-CZ/index.json
@@ -74,12 +74,6 @@
"app.ads-consent.title": {
"message": "Vaše soukromí a způsob, jakým reklamy podporují Modrinth"
},
- "app.appearance-settings.advanced-rendering.title": {
- "message": "Pokročilé vykreslování"
- },
- "app.appearance-settings.color-theme.title": {
- "message": "Barevný motiv"
- },
"app.appearance-settings.default-landing-page.home": {
"message": "Domů"
},
@@ -101,12 +95,6 @@
"app.appearance-settings.minimize-launcher.title": {
"message": "Minimalizovat launcher"
},
- "app.appearance-settings.native-decorations.description": {
- "message": "Použít systémový rámeček okna (vyžaduje restart aplikace)."
- },
- "app.appearance-settings.native-decorations.title": {
- "message": "Systémové dekorace"
- },
"app.appearance-settings.show-play-time.description": {
"message": "Zobrazuje, kolik času jsi strávil hraním instance."
},
diff --git a/apps/app-frontend/src/locales/da-DK/index.json b/apps/app-frontend/src/locales/da-DK/index.json
index 548f236198..24f14fca71 100644
--- a/apps/app-frontend/src/locales/da-DK/index.json
+++ b/apps/app-frontend/src/locales/da-DK/index.json
@@ -137,12 +137,6 @@
"app.ads-consent.title": {
"message": "Dit privatliv og hvordan reklamer hjælper Modrinth"
},
- "app.appearance-settings.advanced-rendering.title": {
- "message": "Avanceret rendering"
- },
- "app.appearance-settings.color-theme.title": {
- "message": "Farvetema"
- },
"app.appearance-settings.default-landing-page.home": {
"message": "Hjem"
},
diff --git a/apps/app-frontend/src/locales/de-CH/index.json b/apps/app-frontend/src/locales/de-CH/index.json
index e55bad3b8d..a773966a46 100644
--- a/apps/app-frontend/src/locales/de-CH/index.json
+++ b/apps/app-frontend/src/locales/de-CH/index.json
@@ -143,18 +143,6 @@
"app.ads-consent.title": {
"message": "Deine Privatsphäre und wie Werbung Modrinth unterstützt"
},
- "app.appearance-settings.advanced-rendering.description": {
- "message": "Visuelle Effekte wie Hintergrundunschärfe aktivieren. Ohne Hardwarebeschleunigung kann dies die Leistung beeinträchtigen."
- },
- "app.appearance-settings.advanced-rendering.title": {
- "message": "Erweitertes Rendering"
- },
- "app.appearance-settings.color-theme.description": {
- "message": "Wähle das Farbschema der Modrinth App aus."
- },
- "app.appearance-settings.color-theme.title": {
- "message": "Farbschema"
- },
"app.appearance-settings.default-landing-page.home": {
"message": "Start"
},
@@ -179,12 +167,6 @@
"app.appearance-settings.minimize-launcher.title": {
"message": "App minimieren"
},
- "app.appearance-settings.native-decorations.description": {
- "message": "Nutzt die Titelleiste und Fenstersteuerung deines Betriebssystems. Erfordert einen App-Neustart."
- },
- "app.appearance-settings.native-decorations.title": {
- "message": "System-Fensterrahmen"
- },
"app.appearance-settings.show-play-time.description": {
"message": "Zeigt, wie lange du jede einzelne Instanz gespielt hast."
},
diff --git a/apps/app-frontend/src/locales/de-DE/index.json b/apps/app-frontend/src/locales/de-DE/index.json
index 043b4e6eb8..8f4d7d6f13 100644
--- a/apps/app-frontend/src/locales/de-DE/index.json
+++ b/apps/app-frontend/src/locales/de-DE/index.json
@@ -143,18 +143,6 @@
"app.ads-consent.title": {
"message": "Deine Privatsphäre und wie Werbung Modrinth unterstützt"
},
- "app.appearance-settings.advanced-rendering.description": {
- "message": "Visuelle Effekte wie Hintergrundunschärfe aktivieren. Ohne Hardwarebeschleunigung kann dies die Leistung beeinträchtigen."
- },
- "app.appearance-settings.advanced-rendering.title": {
- "message": "Erweitertes Rendering"
- },
- "app.appearance-settings.color-theme.description": {
- "message": "Wähle das Farbschema der Modrinth App aus."
- },
- "app.appearance-settings.color-theme.title": {
- "message": "Farbschema"
- },
"app.appearance-settings.default-landing-page.home": {
"message": "Start"
},
@@ -179,12 +167,6 @@
"app.appearance-settings.minimize-launcher.title": {
"message": "App minimieren"
},
- "app.appearance-settings.native-decorations.description": {
- "message": "Nutzt die Titelleiste und Fenstersteuerung deines Betriebssystems. Erfordert einen App-Neustart."
- },
- "app.appearance-settings.native-decorations.title": {
- "message": "System-Fensterrahmen"
- },
"app.appearance-settings.show-play-time.description": {
"message": "Zeigt, wie lange du jede einzelne Instanz gespielt hast."
},
diff --git a/apps/app-frontend/src/locales/en-US/index.json b/apps/app-frontend/src/locales/en-US/index.json
index e366aa6558..b68c532493 100644
--- a/apps/app-frontend/src/locales/en-US/index.json
+++ b/apps/app-frontend/src/locales/en-US/index.json
@@ -149,17 +149,11 @@
"app.ads-consent.title": {
"message": "Your privacy and how ads support Modrinth"
},
- "app.appearance-settings.advanced-rendering.description": {
- "message": "Enable visual effects such as background blur. This may reduce performance without hardware acceleration."
+ "app.appearance-settings.compact-mode.description": {
+ "message": "Display library instances in a compact row layout."
},
- "app.appearance-settings.advanced-rendering.title": {
- "message": "Advanced rendering"
- },
- "app.appearance-settings.color-theme.description": {
- "message": "Choose the color theme used by Modrinth App."
- },
- "app.appearance-settings.color-theme.title": {
- "message": "Color theme"
+ "app.appearance-settings.compact-mode.title": {
+ "message": "Compact mode"
},
"app.appearance-settings.default-landing-page.home": {
"message": "Home"
@@ -185,12 +179,6 @@
"app.appearance-settings.minimize-launcher.title": {
"message": "Minimize app"
},
- "app.appearance-settings.native-decorations.description": {
- "message": "Use your operating system's title bar and window controls. Requires an app restart."
- },
- "app.appearance-settings.native-decorations.title": {
- "message": "System window frame"
- },
"app.appearance-settings.show-play-time.description": {
"message": "Show how long you've played each instance."
},
@@ -230,6 +218,15 @@
"app.behavior-settings.startup-and-navigation.title": {
"message": "Startup and navigation"
},
+ "app.behavior-settings.sync-across-devices.description": {
+ "message": "Use these behavior settings everywhere you're signed in. Turn this off to keep separate settings on this device."
+ },
+ "app.behavior-settings.sync-across-devices.signed-out-tooltip": {
+ "message": "Sign into a Modrinth account to sync settings."
+ },
+ "app.behavior-settings.sync-across-devices.title": {
+ "message": "Sync behavior across devices"
+ },
"app.browse.add-servers-to-instance": {
"message": "Adding server to instance"
},
@@ -332,6 +329,9 @@
"app.home.jump-back-in.new-instance": {
"message": "New instance"
},
+ "app.home.jump-back-in.resize": {
+ "message": "Drag to resize"
+ },
"app.home.jump-back-in.title": {
"message": "Jump in"
},
@@ -1082,6 +1082,9 @@
"app.library.sort.date-modified": {
"message": "Date modified"
},
+ "app.library.sort.game-version": {
+ "message": "Game version"
+ },
"app.library.sort.hours-played": {
"message": "Hours played"
},
@@ -1091,6 +1094,9 @@
"app.library.sort.last-played": {
"message": "Last played"
},
+ "app.library.sort.loader": {
+ "message": "Loader"
+ },
"app.library.sort.name": {
"message": "Name"
},
@@ -1742,6 +1748,9 @@
"app.world.world-item.offline": {
"message": "Offline"
},
+ "app.world.world-item.players-online": {
+ "message": "{count} online"
+ },
"content.shared-instance.change-version-body": {
"message": "Changing the version only changes your local copy. Future shared instance updates may restore or change it again."
},
@@ -2069,6 +2078,12 @@
"instance.icon-editor.symbol.engine": {
"message": "Engine"
},
+ "instance.icon-editor.symbol.fabric": {
+ "message": "Fabric"
+ },
+ "instance.icon-editor.symbol.forge": {
+ "message": "Forge"
+ },
"instance.icon-editor.symbol.furnace": {
"message": "Furnace"
},
@@ -2090,6 +2105,9 @@
"instance.icon-editor.symbol.mr-pack": {
"message": "Mr Pack"
},
+ "instance.icon-editor.symbol.neoforge": {
+ "message": "NeoForge"
+ },
"instance.icon-editor.symbol.orb": {
"message": "Orb"
},
@@ -2105,6 +2123,9 @@
"instance.icon-editor.symbol.poke-ball": {
"message": "Poke Ball"
},
+ "instance.icon-editor.symbol.quilt": {
+ "message": "Quilt"
+ },
"instance.icon-editor.symbol.redstone-block": {
"message": "Redstone Block"
},
diff --git a/apps/app-frontend/src/locales/es-419/index.json b/apps/app-frontend/src/locales/es-419/index.json
index 44c5f555c0..4ac1af9b6c 100644
--- a/apps/app-frontend/src/locales/es-419/index.json
+++ b/apps/app-frontend/src/locales/es-419/index.json
@@ -143,18 +143,6 @@
"app.ads-consent.title": {
"message": "Tu privacidad y como los anuncios apoyan a Modrinth"
},
- "app.appearance-settings.advanced-rendering.description": {
- "message": "Activar efectos visuales como los efectos de desenfoque. Esto puede reducir el rendimiento si no tienes aceleración de hardware."
- },
- "app.appearance-settings.advanced-rendering.title": {
- "message": "Renderizado avanzado"
- },
- "app.appearance-settings.color-theme.description": {
- "message": "Escoge el esquema de colores usado por la aplicación de Modrinth."
- },
- "app.appearance-settings.color-theme.title": {
- "message": "Color de la interfaz"
- },
"app.appearance-settings.default-landing-page.home": {
"message": "Inicio"
},
@@ -179,12 +167,6 @@
"app.appearance-settings.minimize-launcher.title": {
"message": "Minimizar app"
},
- "app.appearance-settings.native-decorations.description": {
- "message": "Usa el borde de ventana de tu sistema. Necesita reiniciar la aplicación."
- },
- "app.appearance-settings.native-decorations.title": {
- "message": "Decoraciones nativas"
- },
"app.appearance-settings.show-play-time.description": {
"message": "Muestra cuanto tiempo has jugado en cada instancia."
},
diff --git a/apps/app-frontend/src/locales/es-ES/index.json b/apps/app-frontend/src/locales/es-ES/index.json
index ea50c2a08c..909750303b 100644
--- a/apps/app-frontend/src/locales/es-ES/index.json
+++ b/apps/app-frontend/src/locales/es-ES/index.json
@@ -95,12 +95,6 @@
"app.action-bar.view-logs": {
"message": "Ver registros"
},
- "app.appearance-settings.advanced-rendering.title": {
- "message": "Renderizado avanzado"
- },
- "app.appearance-settings.color-theme.title": {
- "message": "Tema de color"
- },
"app.appearance-settings.default-landing-page.home": {
"message": "Inicio"
},
@@ -122,12 +116,6 @@
"app.appearance-settings.minimize-launcher.title": {
"message": "Minimizar lanzador"
},
- "app.appearance-settings.native-decorations.description": {
- "message": "Usa el marco de ventana del sistema (requiere reiniciar la aplicación)."
- },
- "app.appearance-settings.native-decorations.title": {
- "message": "Decoraciones nativas"
- },
"app.appearance-settings.show-play-time.description": {
"message": "Muestra cuánto tiempo has estado jugando en una instancia."
},
diff --git a/apps/app-frontend/src/locales/fi-FI/index.json b/apps/app-frontend/src/locales/fi-FI/index.json
index 5a614188ce..3193fa981f 100644
--- a/apps/app-frontend/src/locales/fi-FI/index.json
+++ b/apps/app-frontend/src/locales/fi-FI/index.json
@@ -71,12 +71,6 @@
"app.action-bar.view-logs": {
"message": "Katso lokeja"
},
- "app.appearance-settings.advanced-rendering.title": {
- "message": "Edistynyt renderointi"
- },
- "app.appearance-settings.color-theme.title": {
- "message": "Väriteema"
- },
"app.appearance-settings.default-landing-page.home": {
"message": "Koti"
},
@@ -98,12 +92,6 @@
"app.appearance-settings.minimize-launcher.title": {
"message": "Piilota käynnistysohjelma"
},
- "app.appearance-settings.native-decorations.description": {
- "message": "Käytä järjestelmän ikkuna kehystä (Vaatii sovelluksen uudelleen käynnistämisen)."
- },
- "app.appearance-settings.native-decorations.title": {
- "message": "Natiivit koristukset"
- },
"app.appearance-settings.show-play-time.description": {
"message": "Näyttää, kuinka paljon aikaa olet käyttänyt instanssin pelaamiseen."
},
diff --git a/apps/app-frontend/src/locales/fil-PH/index.json b/apps/app-frontend/src/locales/fil-PH/index.json
index a0181a28b1..16a0dc43ae 100644
--- a/apps/app-frontend/src/locales/fil-PH/index.json
+++ b/apps/app-frontend/src/locales/fil-PH/index.json
@@ -47,12 +47,6 @@
"app.action-bar.view-logs": {
"message": "Tignan ang logs"
},
- "app.appearance-settings.advanced-rendering.title": {
- "message": "Masalimuot na pagrerender"
- },
- "app.appearance-settings.color-theme.title": {
- "message": "Tema ng kulay"
- },
"app.appearance-settings.default-landing-page.home": {
"message": "Tahanan"
},
@@ -74,12 +68,6 @@
"app.appearance-settings.minimize-launcher.title": {
"message": "Liitin ang launcher"
},
- "app.appearance-settings.native-decorations.description": {
- "message": "Gamitin ang system window frame (kailangan i-restart ang app)."
- },
- "app.appearance-settings.native-decorations.title": {
- "message": "Native decorations"
- },
"app.appearance-settings.show-play-time.description": {
"message": "Magpapakita ang katagal ng iyong paglalaro sa isang instansiya."
},
diff --git a/apps/app-frontend/src/locales/fr-FR/index.json b/apps/app-frontend/src/locales/fr-FR/index.json
index 4ffb047de4..f9edddfab1 100644
--- a/apps/app-frontend/src/locales/fr-FR/index.json
+++ b/apps/app-frontend/src/locales/fr-FR/index.json
@@ -143,18 +143,6 @@
"app.ads-consent.title": {
"message": "Votre vie privée et comment les publicités soutiennent Modrinth"
},
- "app.appearance-settings.advanced-rendering.description": {
- "message": "Activer les effets visuels tel que le flou d'arrière-plan. Ceci peut réduire les performances sans accélération matérielle."
- },
- "app.appearance-settings.advanced-rendering.title": {
- "message": "Rendu avancé"
- },
- "app.appearance-settings.color-theme.description": {
- "message": "Choisir la couleur de thème utilisée par Modrinth App."
- },
- "app.appearance-settings.color-theme.title": {
- "message": "Couleur du thème"
- },
"app.appearance-settings.default-landing-page.home": {
"message": "Accueil"
},
@@ -179,12 +167,6 @@
"app.appearance-settings.minimize-launcher.title": {
"message": "Minimiser l'application"
},
- "app.appearance-settings.native-decorations.description": {
- "message": "Utilise la barre de titre et les contrôles de fenêtre de votre système d'exploitation. Nécessite le redémarrage de l'application."
- },
- "app.appearance-settings.native-decorations.title": {
- "message": "Cadre de fenêtre système"
- },
"app.appearance-settings.show-play-time.description": {
"message": "Montre combien de temps vous avez joué à chaque instance."
},
diff --git a/apps/app-frontend/src/locales/hu-HU/index.json b/apps/app-frontend/src/locales/hu-HU/index.json
index e947d24768..fdb6c98d59 100644
--- a/apps/app-frontend/src/locales/hu-HU/index.json
+++ b/apps/app-frontend/src/locales/hu-HU/index.json
@@ -143,18 +143,6 @@
"app.ads-consent.title": {
"message": "Adataid védelme és a hirdetések szerepe a Modrinth működésében"
},
- "app.appearance-settings.advanced-rendering.description": {
- "message": "Engedélyezi a vizuális effektusokat, például a háttér elmosását. Hardveres gyorsítás nélkül ez ronthatja a rendszer teljesítményét."
- },
- "app.appearance-settings.advanced-rendering.title": {
- "message": "Haladó megjelenítési beállítások"
- },
- "app.appearance-settings.color-theme.description": {
- "message": "Válaszd ki a Modrinth App által használt színsémát."
- },
- "app.appearance-settings.color-theme.title": {
- "message": "Téma"
- },
"app.appearance-settings.default-landing-page.home": {
"message": "Kezdőlap"
},
@@ -179,12 +167,6 @@
"app.appearance-settings.minimize-launcher.title": {
"message": "Alkalmazás bezárása"
},
- "app.appearance-settings.native-decorations.description": {
- "message": "Az operációs rendszer címsorának és ablakvezérlőinek használata. A módosítás érvénybe lépéséhez újra kell indítani az alkalmazást."
- },
- "app.appearance-settings.native-decorations.title": {
- "message": "Rendszerablakkeret"
- },
"app.appearance-settings.show-play-time.description": {
"message": "Megjeleníti, hogy mennyi időt töltöttél egy példányban."
},
diff --git a/apps/app-frontend/src/locales/id-ID/index.json b/apps/app-frontend/src/locales/id-ID/index.json
index 323d8aa684..cc176e5bdd 100644
--- a/apps/app-frontend/src/locales/id-ID/index.json
+++ b/apps/app-frontend/src/locales/id-ID/index.json
@@ -44,12 +44,6 @@
"app.action-bar.view-logs": {
"message": "Lihat catatan"
},
- "app.appearance-settings.advanced-rendering.title": {
- "message": "Renderasi tingkat lanjut"
- },
- "app.appearance-settings.color-theme.title": {
- "message": "Tema warna"
- },
"app.appearance-settings.default-landing-page.home": {
"message": "Beranda"
},
@@ -71,12 +65,6 @@
"app.appearance-settings.minimize-launcher.title": {
"message": "Kecilkan peluncur"
},
- "app.appearance-settings.native-decorations.description": {
- "message": "Gunakan bingkai jendela sistem (wajib menjalankan ulang aplikasi)."
- },
- "app.appearance-settings.native-decorations.title": {
- "message": "Hiasan asli"
- },
"app.appearance-settings.show-play-time.description": {
"message": "Menampilkan seberapa lama Anda memainkan sebuah instans."
},
diff --git a/apps/app-frontend/src/locales/it-IT/index.json b/apps/app-frontend/src/locales/it-IT/index.json
index 57d902ac9c..f53f590846 100644
--- a/apps/app-frontend/src/locales/it-IT/index.json
+++ b/apps/app-frontend/src/locales/it-IT/index.json
@@ -143,18 +143,6 @@
"app.ads-consent.title": {
"message": "La tua privacy e come le pubblicità supportano Modrinth"
},
- "app.appearance-settings.advanced-rendering.description": {
- "message": "Abilita effetti come lo sfondo sfocato. Questo potrebbe ridurre le prestazioni senza accelerazione hardware."
- },
- "app.appearance-settings.advanced-rendering.title": {
- "message": "Rendering avanzato"
- },
- "app.appearance-settings.color-theme.description": {
- "message": "Scegli il tema usato da Modrinth App."
- },
- "app.appearance-settings.color-theme.title": {
- "message": "Tema"
- },
"app.appearance-settings.default-landing-page.home": {
"message": "Home"
},
@@ -179,12 +167,6 @@
"app.appearance-settings.minimize-launcher.title": {
"message": "Minimizza app"
},
- "app.appearance-settings.native-decorations.description": {
- "message": "Usa la barra e i controlli delle finestre di sistema. Richiede un riavvio."
- },
- "app.appearance-settings.native-decorations.title": {
- "message": "Usa cornice di sistema"
- },
"app.appearance-settings.show-play-time.description": {
"message": "Mostra quanto tempo hai giocato a un'istanza."
},
diff --git a/apps/app-frontend/src/locales/ja-JP/index.json b/apps/app-frontend/src/locales/ja-JP/index.json
index b999c783bc..73211145f8 100644
--- a/apps/app-frontend/src/locales/ja-JP/index.json
+++ b/apps/app-frontend/src/locales/ja-JP/index.json
@@ -143,18 +143,6 @@
"app.ads-consent.title": {
"message": "プライバシーと広告がModrinthを支える仕組み"
},
- "app.appearance-settings.advanced-rendering.description": {
- "message": "背景のぼかしなどの視覚効果を有効にします。ハードウェアアクセラレーションが無効の場合、パフォーマンスが低下する可能性があります。"
- },
- "app.appearance-settings.advanced-rendering.title": {
- "message": "高度なレンダリング"
- },
- "app.appearance-settings.color-theme.description": {
- "message": "Modrinth Appのテーマを選びましょう。"
- },
- "app.appearance-settings.color-theme.title": {
- "message": "テーマ"
- },
"app.appearance-settings.default-landing-page.home": {
"message": "ホーム"
},
@@ -179,12 +167,6 @@
"app.appearance-settings.minimize-launcher.title": {
"message": "ランチャーを最小化"
},
- "app.appearance-settings.native-decorations.description": {
- "message": "OS標準のタイトルバーとウィンドウ操作ボタンを使用します。反映にはアプリの再起動が必要です。"
- },
- "app.appearance-settings.native-decorations.title": {
- "message": "システムウィンドウフレーム"
- },
"app.appearance-settings.show-play-time.description": {
"message": "各インスタンスのプレイ時間を表示します。"
},
diff --git a/apps/app-frontend/src/locales/ko-KR/index.json b/apps/app-frontend/src/locales/ko-KR/index.json
index 525248e3b2..77cefeb25b 100644
--- a/apps/app-frontend/src/locales/ko-KR/index.json
+++ b/apps/app-frontend/src/locales/ko-KR/index.json
@@ -143,18 +143,6 @@
"app.ads-consent.title": {
"message": "개인정보와 광고가 Modrinth를 지원하는 방식"
},
- "app.appearance-settings.advanced-rendering.description": {
- "message": "배경 흐림 효과와 같은 시각 효과를 활성화합니다. 하드웨어 가속이 지원되지 않는 경우 성능이 저하될 수 있습니다."
- },
- "app.appearance-settings.advanced-rendering.title": {
- "message": "고급 렌더링"
- },
- "app.appearance-settings.color-theme.description": {
- "message": "Modrinth 앱에서 사용할 색상 테마를 선택하세요."
- },
- "app.appearance-settings.color-theme.title": {
- "message": "색상 테마"
- },
"app.appearance-settings.default-landing-page.home": {
"message": "홈"
},
@@ -179,12 +167,6 @@
"app.appearance-settings.minimize-launcher.title": {
"message": "앱 최소화"
},
- "app.appearance-settings.native-decorations.description": {
- "message": "운영 체제의 제목 표시줄 및 창 제어 기능을 사용합니다. 앱 재시작이 필요합니다."
- },
- "app.appearance-settings.native-decorations.title": {
- "message": "시스템 창 프레임"
- },
"app.appearance-settings.show-play-time.description": {
"message": "각 인스턴스를 얼마나 플레이했는지 표시합니다."
},
diff --git a/apps/app-frontend/src/locales/ms-MY/index.json b/apps/app-frontend/src/locales/ms-MY/index.json
index ebda1cbf4a..63685a5d4d 100644
--- a/apps/app-frontend/src/locales/ms-MY/index.json
+++ b/apps/app-frontend/src/locales/ms-MY/index.json
@@ -53,12 +53,6 @@
"app.action-bar.view-logs": {
"message": "Lihat log"
},
- "app.appearance-settings.advanced-rendering.title": {
- "message": "Pemaparan lanjutan"
- },
- "app.appearance-settings.color-theme.title": {
- "message": "Tema warna"
- },
"app.appearance-settings.default-landing-page.home": {
"message": "Laman utama"
},
@@ -80,12 +74,6 @@
"app.appearance-settings.minimize-launcher.title": {
"message": "Minimumkan pelancar"
},
- "app.appearance-settings.native-decorations.description": {
- "message": "Gunakan bingkai tetingkap sistem (aplikasi perlu dimulakan semula)."
- },
- "app.appearance-settings.native-decorations.title": {
- "message": "Hiasan asli"
- },
"app.appearance-settings.skip-non-essential-warnings.description": {
"message": "Melangkau pengesahan berisiko rendah secara automatik seperti pemasangan pek mod pendua, pemadaman kandungan biasa, kemas kini pukal, menyahpautkan pek mod dan gesaan pembaikan. Amaran berbahaya masih akan ditunjukkan."
},
diff --git a/apps/app-frontend/src/locales/nl-NL/index.json b/apps/app-frontend/src/locales/nl-NL/index.json
index fc67b5ff57..d2e18911b0 100644
--- a/apps/app-frontend/src/locales/nl-NL/index.json
+++ b/apps/app-frontend/src/locales/nl-NL/index.json
@@ -143,18 +143,6 @@
"app.ads-consent.title": {
"message": "Je privacy en hoe advertenties Modrinth ondersteunen"
},
- "app.appearance-settings.advanced-rendering.description": {
- "message": "Schakel visuele effecten zoals achtergrondvervaging in. Zonder hardwareversnelling kan dit ten koste gaan van de prestaties."
- },
- "app.appearance-settings.advanced-rendering.title": {
- "message": "Geavanceerde weergave"
- },
- "app.appearance-settings.color-theme.description": {
- "message": "Kies het kleurenthema dat door de Modrinth-app wordt gebruikt."
- },
- "app.appearance-settings.color-theme.title": {
- "message": "Kleurenthema"
- },
"app.appearance-settings.default-landing-page.home": {
"message": "Start"
},
@@ -179,12 +167,6 @@
"app.appearance-settings.minimize-launcher.title": {
"message": "App minimaliseren"
},
- "app.appearance-settings.native-decorations.description": {
- "message": "Gebruik de titelbalk en vensterbedieningselementen van je besturingssysteem. Hiervoor moet de app opnieuw worden opgestart."
- },
- "app.appearance-settings.native-decorations.title": {
- "message": "Systeemvensterframe"
- },
"app.appearance-settings.show-play-time.description": {
"message": "Laat zien hoe lang je elke instantie hebt gespeeld."
},
diff --git a/apps/app-frontend/src/locales/no-NO/index.json b/apps/app-frontend/src/locales/no-NO/index.json
index a12e242ace..d4fbacb96c 100644
--- a/apps/app-frontend/src/locales/no-NO/index.json
+++ b/apps/app-frontend/src/locales/no-NO/index.json
@@ -56,12 +56,6 @@
"app.action-bar.view-logs": {
"message": "Vis logger"
},
- "app.appearance-settings.advanced-rendering.title": {
- "message": "Avansert rendering"
- },
- "app.appearance-settings.color-theme.title": {
- "message": "Fargetema"
- },
"app.appearance-settings.default-landing-page.home": {
"message": "Hjem"
},
diff --git a/apps/app-frontend/src/locales/pl-PL/index.json b/apps/app-frontend/src/locales/pl-PL/index.json
index 7e1bd4bae1..6071ed5c49 100644
--- a/apps/app-frontend/src/locales/pl-PL/index.json
+++ b/apps/app-frontend/src/locales/pl-PL/index.json
@@ -143,18 +143,6 @@
"app.ads-consent.title": {
"message": "Twoja prywatność i jak reklamy wspierają Modrinth"
},
- "app.appearance-settings.advanced-rendering.description": {
- "message": "Włącz efekty wizualne, takie jak rozmycie tła. Może to negatywnie wpłynąć na wydajność, jeżeli akceleracja sprzętowa jest wyłączona."
- },
- "app.appearance-settings.advanced-rendering.title": {
- "message": "Zaawansowane renderowanie"
- },
- "app.appearance-settings.color-theme.description": {
- "message": "Wybierz motyw użyty przez Modrinth App."
- },
- "app.appearance-settings.color-theme.title": {
- "message": "Motyw"
- },
"app.appearance-settings.default-landing-page.home": {
"message": "Strona główna"
},
@@ -179,12 +167,6 @@
"app.appearance-settings.minimize-launcher.title": {
"message": "Minimalizuj aplikację"
},
- "app.appearance-settings.native-decorations.description": {
- "message": "Używaj pasek tytułu i kontrolki okna systemu operacyjnego. Wymaga ponownego uruchomienia aplikacji."
- },
- "app.appearance-settings.native-decorations.title": {
- "message": "Systemowa ramka okna"
- },
"app.appearance-settings.show-play-time.description": {
"message": "Pokazuje czas gry spędzony w danej instancji."
},
diff --git a/apps/app-frontend/src/locales/pt-BR/index.json b/apps/app-frontend/src/locales/pt-BR/index.json
index 4d3959f58e..463096efc8 100644
--- a/apps/app-frontend/src/locales/pt-BR/index.json
+++ b/apps/app-frontend/src/locales/pt-BR/index.json
@@ -143,18 +143,6 @@
"app.ads-consent.title": {
"message": "Sua privacidade e como anúncios apoiam o Modrinth"
},
- "app.appearance-settings.advanced-rendering.description": {
- "message": "Ativa os efeitos visuais como o desfoque de fundo. Isso talvez reduza o desempenho sem o aceleramento de hardware."
- },
- "app.appearance-settings.advanced-rendering.title": {
- "message": "Renderização avançada"
- },
- "app.appearance-settings.color-theme.description": {
- "message": "Escolha o tema usado pelo Modrinth App."
- },
- "app.appearance-settings.color-theme.title": {
- "message": "Tema"
- },
"app.appearance-settings.default-landing-page.home": {
"message": "Início"
},
@@ -179,12 +167,6 @@
"app.appearance-settings.minimize-launcher.title": {
"message": "Minimizar aplicativo"
},
- "app.appearance-settings.native-decorations.description": {
- "message": "Usa a barra de título e os controles de janela do seu sistema operacional. Exige a reinicialização do aplicativo."
- },
- "app.appearance-settings.native-decorations.title": {
- "message": "Moldura de janela do sistema"
- },
"app.appearance-settings.show-play-time.description": {
"message": "Exibe o tempo de jogo de cada instância."
},
diff --git a/apps/app-frontend/src/locales/pt-PT/index.json b/apps/app-frontend/src/locales/pt-PT/index.json
index 4e2c6e6f78..daf5c7a0f4 100644
--- a/apps/app-frontend/src/locales/pt-PT/index.json
+++ b/apps/app-frontend/src/locales/pt-PT/index.json
@@ -140,12 +140,6 @@
"app.ads-consent.title": {
"message": "A sua privacidade e como os anúncios são financiados pela Modrinth"
},
- "app.appearance-settings.advanced-rendering.title": {
- "message": "Renderização avançada"
- },
- "app.appearance-settings.color-theme.title": {
- "message": "Cor de tema"
- },
"app.appearance-settings.default-landing-page.home": {
"message": "Casa"
},
@@ -167,12 +161,6 @@
"app.appearance-settings.minimize-launcher.title": {
"message": "Minimizar launcher"
},
- "app.appearance-settings.native-decorations.description": {
- "message": "Utilizar a moldura da janela do sistema (é necessário reiniciar a aplicação)."
- },
- "app.appearance-settings.native-decorations.title": {
- "message": "Decorações nativas"
- },
"app.appearance-settings.show-play-time.description": {
"message": "Apresenta quanto tempo passou a jogar uma instância."
},
diff --git a/apps/app-frontend/src/locales/ru-RU/index.json b/apps/app-frontend/src/locales/ru-RU/index.json
index 6fd2a36fbf..889ee932f0 100644
--- a/apps/app-frontend/src/locales/ru-RU/index.json
+++ b/apps/app-frontend/src/locales/ru-RU/index.json
@@ -143,18 +143,6 @@
"app.ads-consent.title": {
"message": "Конфиденциальность и реклама на Modrinth"
},
- "app.appearance-settings.advanced-rendering.description": {
- "message": "Использовать эффекты вроде размытия фона. Без аппаратного ускорения возможна потеря производительности."
- },
- "app.appearance-settings.advanced-rendering.title": {
- "message": "Расширенные эффекты"
- },
- "app.appearance-settings.color-theme.description": {
- "message": "Выберите тему для Modrinth App."
- },
- "app.appearance-settings.color-theme.title": {
- "message": "Тема"
- },
"app.appearance-settings.default-landing-page.home": {
"message": "Главная"
},
@@ -179,12 +167,6 @@
"app.appearance-settings.minimize-launcher.title": {
"message": "Сворачивание приложения"
},
- "app.appearance-settings.native-decorations.description": {
- "message": "Использовать системное оформление заголовка и кнопок окна. Требуется перезапуск."
- },
- "app.appearance-settings.native-decorations.title": {
- "message": "Системная рамка окна"
- },
"app.appearance-settings.show-play-time.description": {
"message": "Показывать проведённое время в сборке."
},
diff --git a/apps/app-frontend/src/locales/sr-CS/index.json b/apps/app-frontend/src/locales/sr-CS/index.json
index f5c67b16a5..b4ed7707da 100644
--- a/apps/app-frontend/src/locales/sr-CS/index.json
+++ b/apps/app-frontend/src/locales/sr-CS/index.json
@@ -143,18 +143,6 @@
"app.ads-consent.title": {
"message": "Tvoja bezbednost i kako reklame pomažu Modrinth-u"
},
- "app.appearance-settings.advanced-rendering.description": {
- "message": "Uključi vizuelne efekte kao što je blur pozadine. Ovo može da pogorša performanse bez hardverskog ubrzanja."
- },
- "app.appearance-settings.advanced-rendering.title": {
- "message": "Napredno renderovanje"
- },
- "app.appearance-settings.color-theme.description": {
- "message": "Izaberi boju teme koju koristi Modrinth App."
- },
- "app.appearance-settings.color-theme.title": {
- "message": "Tema boje"
- },
"app.appearance-settings.default-landing-page.home": {
"message": "Početna stranica"
},
@@ -179,12 +167,6 @@
"app.appearance-settings.minimize-launcher.title": {
"message": "Smanji aplikaciju"
},
- "app.appearance-settings.native-decorations.description": {
- "message": "Koristi naslovnu traku i kontrole prozora tvog operativnog sistema. Restart aplikacije obavezan."
- },
- "app.appearance-settings.native-decorations.title": {
- "message": "Sistemski okvir prozora"
- },
"app.appearance-settings.show-play-time.description": {
"message": "Prikaži koliko dugo ste igrali svaku instancu."
},
diff --git a/apps/app-frontend/src/locales/sv-SE/index.json b/apps/app-frontend/src/locales/sv-SE/index.json
index bddb1feab2..d5197c1c09 100644
--- a/apps/app-frontend/src/locales/sv-SE/index.json
+++ b/apps/app-frontend/src/locales/sv-SE/index.json
@@ -140,15 +140,6 @@
"app.ads-consent.title": {
"message": "Din integritet och hur reklam stödjer Modrinth"
},
- "app.appearance-settings.advanced-rendering.title": {
- "message": "Avancerad rendering"
- },
- "app.appearance-settings.color-theme.description": {
- "message": "Välj färgtemat som används av Modrinth App."
- },
- "app.appearance-settings.color-theme.title": {
- "message": "Färgtema"
- },
"app.appearance-settings.default-landing-page.home": {
"message": "Hem"
},
@@ -170,12 +161,6 @@
"app.appearance-settings.minimize-launcher.title": {
"message": "Minimera app"
},
- "app.appearance-settings.native-decorations.description": {
- "message": "Använd systemets fönsterram (omstart av app krävs)."
- },
- "app.appearance-settings.native-decorations.title": {
- "message": "Inbyggda fönsterdekorationer"
- },
"app.appearance-settings.show-play-time.description": {
"message": "Visa hur länge du spelat varje instans."
},
diff --git a/apps/app-frontend/src/locales/th-TH/index.json b/apps/app-frontend/src/locales/th-TH/index.json
index e44beb4a8d..d93134d0c7 100644
--- a/apps/app-frontend/src/locales/th-TH/index.json
+++ b/apps/app-frontend/src/locales/th-TH/index.json
@@ -59,12 +59,6 @@
"app.action-bar.view-logs": {
"message": "ดู Log"
},
- "app.appearance-settings.advanced-rendering.title": {
- "message": "การเรนเดอร์ขั้นสูง"
- },
- "app.appearance-settings.color-theme.title": {
- "message": "ธีมสี"
- },
"app.appearance-settings.default-landing-page.home": {
"message": "หน้าหลัก"
},
@@ -86,12 +80,6 @@
"app.appearance-settings.minimize-launcher.title": {
"message": "ย่อลันเชอร์"
},
- "app.appearance-settings.native-decorations.description": {
- "message": "ใช้กรอบหน้าต่างระบบ (ต้องรีสตาร์ทแอป)"
- },
- "app.appearance-settings.native-decorations.title": {
- "message": "ของตกแต่งดั้งเดิม"
- },
"app.appearance-settings.show-play-time.description": {
"message": "แสดงเวลาที่คุณใช้เล่นในแต่ละอินสแตนซ์"
},
diff --git a/apps/app-frontend/src/locales/tr-TR/index.json b/apps/app-frontend/src/locales/tr-TR/index.json
index 05080edd1b..15f9078f48 100644
--- a/apps/app-frontend/src/locales/tr-TR/index.json
+++ b/apps/app-frontend/src/locales/tr-TR/index.json
@@ -143,18 +143,6 @@
"app.ads-consent.title": {
"message": "Sizin mahremiyetiniz ve reklamlar Modrinth'i nasıl destekler"
},
- "app.appearance-settings.advanced-rendering.description": {
- "message": "Arka plan bulanıklığı gibi görsel efektleri etkinleştirin. Bu, donanım ivmelendirme yokken performansı etkileyebilir."
- },
- "app.appearance-settings.advanced-rendering.title": {
- "message": "Gelişmiş işleme"
- },
- "app.appearance-settings.color-theme.description": {
- "message": "Modrinth App tarafından kullanılacak renk şemasını seçin."
- },
- "app.appearance-settings.color-theme.title": {
- "message": "Renk teması"
- },
"app.appearance-settings.default-landing-page.home": {
"message": "Ana Sayfa"
},
@@ -179,12 +167,6 @@
"app.appearance-settings.minimize-launcher.title": {
"message": "Uygulamayı küçült"
},
- "app.appearance-settings.native-decorations.description": {
- "message": "İşletim sisteminizin başlık çubuğu ve pencere kontrollerini kullanın. Uygulamanın yeniden başlatılmasını gerektirir."
- },
- "app.appearance-settings.native-decorations.title": {
- "message": "Sistem pencere çerçevesi"
- },
"app.appearance-settings.show-play-time.description": {
"message": "Her bir örneği ne kadar süreyle oynadığını göster."
},
diff --git a/apps/app-frontend/src/locales/uk-UA/index.json b/apps/app-frontend/src/locales/uk-UA/index.json
index f2f02758b7..5e16a46475 100644
--- a/apps/app-frontend/src/locales/uk-UA/index.json
+++ b/apps/app-frontend/src/locales/uk-UA/index.json
@@ -143,18 +143,6 @@
"app.ads-consent.title": {
"message": "Ваша приватність та як реклама підтримує Modrinth"
},
- "app.appearance-settings.advanced-rendering.description": {
- "message": "Умикає візуальні ефекти, як-от розмиття фону. Це може знизити продуктивність без апаратного прискорення."
- },
- "app.appearance-settings.advanced-rendering.title": {
- "message": "Розширене промальовування"
- },
- "app.appearance-settings.color-theme.description": {
- "message": "Оберіть тему для Modrinth App."
- },
- "app.appearance-settings.color-theme.title": {
- "message": "Колірна тема"
- },
"app.appearance-settings.default-landing-page.home": {
"message": "Головна"
},
@@ -179,12 +167,6 @@
"app.appearance-settings.minimize-launcher.title": {
"message": "Згортати застосунок"
},
- "app.appearance-settings.native-decorations.description": {
- "message": "Використовувати заголовок вікна та елементи керування вашої операційної системи. Потребує перезапуску застосунку."
- },
- "app.appearance-settings.native-decorations.title": {
- "message": "Системна рамка вікна"
- },
"app.appearance-settings.show-play-time.description": {
"message": "Показувати награний час для кожної збірки."
},
diff --git a/apps/app-frontend/src/locales/vi-VN/index.json b/apps/app-frontend/src/locales/vi-VN/index.json
index 488a8dbaa5..9114a99ea7 100644
--- a/apps/app-frontend/src/locales/vi-VN/index.json
+++ b/apps/app-frontend/src/locales/vi-VN/index.json
@@ -134,12 +134,6 @@
"app.ads-consent.title": {
"message": "Quyền riêng tư của bạn và cách quảng cáo hỗ trợ Modrinth"
},
- "app.appearance-settings.advanced-rendering.title": {
- "message": "Dựng hình nâng cao"
- },
- "app.appearance-settings.color-theme.title": {
- "message": "Màu chủ đề"
- },
"app.appearance-settings.default-landing-page.home": {
"message": "Trang chủ"
},
@@ -161,12 +155,6 @@
"app.appearance-settings.minimize-launcher.title": {
"message": "Thu nhỏ launcher"
},
- "app.appearance-settings.native-decorations.description": {
- "message": "Sử dụng khung cửa sổ hệ thống (yêu cầu khởi động lại ứng dụng)."
- },
- "app.appearance-settings.native-decorations.title": {
- "message": "Giao diện cửa sổ hệ thống"
- },
"app.appearance-settings.show-play-time.description": {
"message": "Hiển thị thời gian bạn đã dành để chơi bằng một cấu hình."
},
diff --git a/apps/app-frontend/src/locales/zh-CN/index.json b/apps/app-frontend/src/locales/zh-CN/index.json
index ac83dc137a..4d12f1cbee 100644
--- a/apps/app-frontend/src/locales/zh-CN/index.json
+++ b/apps/app-frontend/src/locales/zh-CN/index.json
@@ -143,18 +143,6 @@
"app.ads-consent.title": {
"message": "您的隐私以及广告如何支持 Modrinth"
},
- "app.appearance-settings.advanced-rendering.description": {
- "message": "启用模糊效果等高级渲染。可能会在没有硬件加速渲染的情况下造成性能问题。"
- },
- "app.appearance-settings.advanced-rendering.title": {
- "message": "高级渲染"
- },
- "app.appearance-settings.color-theme.description": {
- "message": "为 Modrinth App 选择偏好的色彩主题。"
- },
- "app.appearance-settings.color-theme.title": {
- "message": "色彩主题"
- },
"app.appearance-settings.default-landing-page.home": {
"message": "首页"
},
@@ -179,12 +167,6 @@
"app.appearance-settings.minimize-launcher.title": {
"message": "最小化 App"
},
- "app.appearance-settings.native-decorations.description": {
- "message": "使用操作系统的标题栏和窗口控件。需要重启应用。"
- },
- "app.appearance-settings.native-decorations.title": {
- "message": "系统窗口框架"
- },
"app.appearance-settings.show-play-time.description": {
"message": "显示你在每个实例中游玩的时间。"
},
diff --git a/apps/app-frontend/src/locales/zh-TW/index.json b/apps/app-frontend/src/locales/zh-TW/index.json
index bd4d82f1ca..d970cb8444 100644
--- a/apps/app-frontend/src/locales/zh-TW/index.json
+++ b/apps/app-frontend/src/locales/zh-TW/index.json
@@ -143,18 +143,6 @@
"app.ads-consent.title": {
"message": "關於你的隱私,以及廣告如何幫助 Modrinth"
},
- "app.appearance-settings.advanced-rendering.description": {
- "message": "啟用視覺效果(如背景模糊),若無硬體加速可能會降低效能。"
- },
- "app.appearance-settings.advanced-rendering.title": {
- "message": "進階繪製"
- },
- "app.appearance-settings.color-theme.description": {
- "message": "選擇 Modrinth App 使用的色彩主題。"
- },
- "app.appearance-settings.color-theme.title": {
- "message": "色彩主題"
- },
"app.appearance-settings.default-landing-page.home": {
"message": "首頁"
},
@@ -179,12 +167,6 @@
"app.appearance-settings.minimize-launcher.title": {
"message": "最小化應用程式"
},
- "app.appearance-settings.native-decorations.description": {
- "message": "使用作業系統的標題列和視窗控制項。需要重新啟動應用程式。"
- },
- "app.appearance-settings.native-decorations.title": {
- "message": "系統視窗外框"
- },
"app.appearance-settings.show-play-time.description": {
"message": "顯示你在各個實例的遊玩時數。"
},
diff --git a/apps/app-frontend/src/main.js b/apps/app-frontend/src/main.js
index c9dfd2b342..99f4c88ca4 100644
--- a/apps/app-frontend/src/main.js
+++ b/apps/app-frontend/src/main.js
@@ -5,7 +5,6 @@ import * as Sentry from '@sentry/vue'
import { VueScanPlugin } from '@taijased/vue-render-tracker'
import { VueQueryPlugin } from '@tanstack/vue-query'
import FloatingVue from 'floating-vue'
-import { createPinia } from 'pinia'
import { createApp } from 'vue'
import App from '@/App.vue'
@@ -21,8 +20,6 @@ const vueScan = new VueScanPlugin({
playSound: false, // Play sound on each render
})
-const pinia = createPinia()
-
let app = createApp(App)
Sentry.init({
@@ -35,7 +32,6 @@ Sentry.init({
app.use(VueQueryPlugin)
app.use(vueScan)
app.use(router)
-app.use(pinia)
app.use(FloatingVue, {
themes: {
'ribbit-popout': {
diff --git a/apps/app-frontend/src/pages/Browse.vue b/apps/app-frontend/src/pages/Browse.vue
index 0a7a18263d..dc586a74e9 100644
--- a/apps/app-frontend/src/pages/Browse.vue
+++ b/apps/app-frontend/src/pages/Browse.vue
@@ -34,13 +34,14 @@ import {
} from '@modrinth/ui'
import { useQuery, useQueryClient } from '@tanstack/vue-query'
import type { Ref } from 'vue'
-import { computed, ref, shallowRef, watch } from 'vue'
+import { computed, onBeforeUnmount, ref, shallowRef, watch } from 'vue'
import type { LocationQuery } from 'vue-router'
import { useRoute, useRouter } from 'vue-router'
-import ContextMenu from '@/components/ui/ContextMenu.vue'
+import ContextMenu from '@/components/ui/context-menu/index.vue'
import { useAppServerBrowse } from '@/composables/browse/use-app-server-browse'
import { useAppEvent } from '@/composables/use-app-event'
+import { useAppSettings } from '@/composables/use-app-settings.ts'
import { get_project, get_search_results_v3, get_version_many } from '@/helpers/cache.js'
import {
get_installed_project_ids as getInstalledProjectIds,
@@ -63,7 +64,6 @@ import {
createServerInstallContent,
provideServerInstallContent,
} from '@/providers/setup/server-install-content'
-import { useTheming } from '@/store/state'
const { handleError } = injectNotificationManager()
const { formatMessage } = useVIntl()
@@ -109,7 +109,7 @@ const breadcrumbLabel = computed(() => {
),
})
})
-const themeStore = useTheming()
+const appSettings = useAppSettings()
const browseRouteActive = computed(() => route.path.startsWith('/browse/'))
const serverSetupModalRef = ref | null>(null)
const serverInstallContent = createServerInstallContent({ serverSetupModalRef })
@@ -384,7 +384,7 @@ async function initInstanceContext() {
}
function setBrowseHideInstalledFlag(flag: 'hide_installed_modpacks', value: boolean) {
- themeStore.featureFlags[flag] = value
+ appSettings.featureFlags[flag] = value
getSettings()
.then((settings) => {
settings.feature_flags[flag] = value
@@ -394,7 +394,7 @@ function setBrowseHideInstalledFlag(flag: 'hide_installed_modpacks', value: bool
}
const hideInstalledModpacks = computed({
- get: () => themeStore.getFeatureFlag('hide_installed_modpacks'),
+ get: () => appSettings.getFeatureFlag('hide_installed_modpacks'),
set: (value: boolean) => setBrowseHideInstalledFlag('hide_installed_modpacks', value),
})
@@ -530,13 +530,20 @@ const {
})
const offline = ref(!navigator.onLine)
-window.addEventListener('offline', () => {
+const handleOffline = () => {
debugLog('went offline')
offline.value = true
-})
-window.addEventListener('online', () => {
+}
+const handleOnline = () => {
debugLog('went online')
offline.value = false
+}
+window.addEventListener('offline', handleOffline)
+window.addEventListener('online', handleOnline)
+
+onBeforeUnmount(() => {
+ window.removeEventListener('offline', handleOffline)
+ window.removeEventListener('online', handleOnline)
})
const messages = defineMessages({
@@ -742,7 +749,7 @@ const installContext = computed(() => {
queuedCount: queuedServerInstallCount.value,
selectedProjects: selectedServerInstallProjects.value,
isInstallingSelected: isInstallingQueuedServerInstalls.value,
- skipNonEssentialWarnings: themeStore.getFeatureFlag('skip_non_essential_warnings'),
+ skipNonEssentialWarnings: appSettings.getFeatureFlag('skip_non_essential_warnings'),
installProgress: queuedInstallProgress.value,
clearQueued: clearQueuedServerInstalls,
clearSelected: clearQueuedServerInstalls,
@@ -1222,9 +1229,9 @@ function getProjectBrowseQuery() {
}
const advancedFiltersCollapsed = computed({
- get: () => themeStore.getFeatureFlag('advanced_filters_collapsed'),
+ get: () => appSettings.getFeatureFlag('advanced_filters_collapsed'),
set: (value) => {
- themeStore.featureFlags['advanced_filters_collapsed'] = value
+ appSettings.featureFlags['advanced_filters_collapsed'] = value
getSettings()
.then((settings) => {
settings.feature_flags['advanced_filters_collapsed'] = value
@@ -1235,9 +1242,9 @@ const advancedFiltersCollapsed = computed({
})
const dismissedPhotosensitivityFilterWarning = computed({
- get: () => themeStore.getFeatureFlag('dismissed_photosensitivity_filter_warning'),
+ get: () => appSettings.getFeatureFlag('dismissed_photosensitivity_filter_warning'),
set: (value) => {
- themeStore.featureFlags['dismissed_photosensitivity_filter_warning'] = value
+ appSettings.featureFlags['dismissed_photosensitivity_filter_warning'] = value
getSettings()
.then((settings) => {
settings.feature_flags['dismissed_photosensitivity_filter_warning'] = value
diff --git a/apps/app-frontend/src/pages/Index.vue b/apps/app-frontend/src/pages/Index.vue
index 9e7c51fd1c..c6157960d5 100644
--- a/apps/app-frontend/src/pages/Index.vue
+++ b/apps/app-frontend/src/pages/Index.vue
@@ -1,20 +1,20 @@
diff --git a/apps/frontend/src/components/ui/NavStack.vue b/apps/frontend/src/components/ui/NavStack.vue
index e71314a55a..ab86be9a04 100644
--- a/apps/frontend/src/components/ui/NavStack.vue
+++ b/apps/frontend/src/components/ui/NavStack.vue
@@ -20,7 +20,7 @@
- {{ item.label }}
+
+ {{ item.label }}
+
{{ String(item.badge) }}
@@ -55,10 +61,16 @@
aria-hidden="true"
class="h-5 w-5 shrink-0"
/>
- {{ item.label }}
+
+ {{ item.label }}
+
{{ String(item.badge) }}
@@ -73,8 +85,8 @@
diff --git a/apps/frontend/src/pages/settings/language.vue b/apps/frontend/src/pages/settings/language.vue
index 612b76f86c..f2c353f31d 100644
--- a/apps/frontend/src/pages/settings/language.vue
+++ b/apps/frontend/src/pages/settings/language.vue
@@ -1,80 +1,31 @@
+
+
+
+
+
+
+
-
-
-
-
- {{ formatMessage(commonSettingsMessages.language) }}
-
-
- {{ formatMessage(languageSelectorMessages.languageWarning, { platform }) }}
-
-
-
-
-
-
-
-
-
-
diff --git a/apps/frontend/src/pages/settings/social.vue b/apps/frontend/src/pages/settings/social.vue
index ade20c47ac..256448d006 100644
--- a/apps/frontend/src/pages/settings/social.vue
+++ b/apps/frontend/src/pages/settings/social.vue
@@ -1,10 +1,18 @@
@@ -14,6 +22,7 @@ import {
AccountSocialSettings,
commonSettingsMessages,
injectModrinthClient,
+ UnsavedChangesPopup,
useVIntl,
} from '@modrinth/ui'
@@ -24,6 +33,12 @@ definePageMeta({
const auth = await useAuth()
const client = injectModrinthClient()
const { formatMessage } = useVIntl()
+const socialSettings = ref | null>(null)
+const emptySocialState = {
+ friendPrivacy: 'everyone',
+ sharedInstancesPrivacy: 'everyone',
+ hostingAccessPrivacy: 'everyone',
+}
function getBlockedUsers(): Promise {
return client.labrinth.blocked_users_v3.list()
diff --git a/apps/frontend/src/pages/user/[user].vue b/apps/frontend/src/pages/user/[user].vue
index f776774f41..26fa5084f6 100644
--- a/apps/frontend/src/pages/user/[user].vue
+++ b/apps/frontend/src/pages/user/[user].vue
@@ -31,7 +31,7 @@ const client = injectModrinthClient()
const queryClient = useQueryClient()
const userProfile = provideUserProfile({
getUser: (userId) => client.labrinth.users_v3.get(userId),
- getProjects: (userId) => client.labrinth.users_v2.getProjects(userId),
+ getProjects: (userId) => client.labrinth.users_v3.getProjects(userId),
getOrganizations: (userId) => client.labrinth.users_v2.getOrganizations(userId),
getCollections: (userId) => client.labrinth.users_v2.getCollections(userId),
patchUser: (userId, patch) => client.labrinth.users_v2.patch(userId, patch),
@@ -46,11 +46,19 @@ const auth = await useAuth()
const cosmetics = useCosmetics()
const config = useRuntimeConfig()
-const userId = computed(() => String(route.params.user))
-const projectType = computed(() => {
- const value = route.params.projectType
- return Array.isArray(value) ? value[0] : value
-})
+function readRouteParam(param: unknown): string | undefined {
+ const value = Array.isArray(param) ? param[0] : param
+ return typeof value === 'string' && value.length > 0 ? value : undefined
+}
+
+const userId = ref(readRouteParam(route.params.user) ?? '')
+watch(
+ () => readRouteParam(route.params.user),
+ (next) => {
+ if (next) userId.value = next
+ },
+)
+const projectType = computed(() => readRouteParam(route.params.projectType))
let prefetchedUser: Labrinth.Users.v3.User | undefined
try {
diff --git a/apps/frontend/src/plugins/cosmetics.ts b/apps/frontend/src/plugins/cosmetics.ts
index 7d821eae7f..fded0aa3aa 100644
--- a/apps/frontend/src/plugins/cosmetics.ts
+++ b/apps/frontend/src/plugins/cosmetics.ts
@@ -9,6 +9,7 @@ export type DisplayLocation =
| 'modpack'
| 'shader'
| 'datapack'
+ | 'server'
| 'user'
| 'collection'
diff --git a/apps/frontend/src/plugins/i18n.ts b/apps/frontend/src/plugins/i18n.ts
index e1f3dc7d91..e6e8fa0b0e 100644
--- a/apps/frontend/src/plugins/i18n.ts
+++ b/apps/frontend/src/plugins/i18n.ts
@@ -178,7 +178,10 @@ export default defineNuxtPlugin({
return msg
}
- async function setLocale(newLocale: string): Promise {
+ async function setLocale(
+ newLocale: string,
+ { persist = true }: { persist?: boolean } = {},
+ ): Promise {
debug('setLocale: called', { newLocale, currentLocale: locale.value })
if (!LOCALES.some((l) => l.code === newLocale)) {
@@ -197,7 +200,9 @@ export default defineNuxtPlugin({
})
locale.value = newLocale
- useCookie('locale', { maxAge: 31536000, path: '/' }).value = newLocale
+ if (persist) {
+ useCookie('locale', { maxAge: 31536000, path: '/' }).value = newLocale
+ }
}
// Detect initial locale (cookie > Accept-Language > default)
diff --git a/apps/frontend/src/plugins/theme/index.ts b/apps/frontend/src/plugins/theme/index.ts
index 3fb8bf00e9..72c52b3b7f 100644
--- a/apps/frontend/src/plugins/theme/index.ts
+++ b/apps/frontend/src/plugins/theme/index.ts
@@ -3,7 +3,7 @@ import { ref } from 'vue'
import { useNativeTheme } from './native-theme.ts'
import { usePreferredThemes } from './preferred-theme.ts'
import { useThemeSettings } from './theme-settings.ts'
-import { isDarkTheme } from './themes.ts'
+import { isDarkTheme, type Theme } from './themes.ts'
export * from './themes.ts'
@@ -33,8 +33,13 @@ export default defineNuxtPlugin({
}
const $settings = useThemeSettings(() => getPreferredNativeTheme())
+ const $preview = ref(null)
+ const $active = computed(() => {
+ if ($preview.value === null) return $settings.active
+ return $preview.value === 'system' ? getPreferredNativeTheme() : $preview.value
+ })
- useHead({ htmlAttrs: { class: () => [`${$settings.active}-mode`] } })
+ useHead({ htmlAttrs: { class: () => [`${$active.value}-mode`] } })
function syncTheme() {
$settings.active =
@@ -74,6 +79,8 @@ export default defineNuxtPlugin({
provide: {
theme: reactive({
...toRefs($settings),
+ active: $active,
+ preview: $preview,
/**
* Preferred themes for each mode.
*/
diff --git a/apps/frontend/src/plugins/theme/theme-settings.ts b/apps/frontend/src/plugins/theme/theme-settings.ts
index b8a632288a..a9a5fb9d88 100644
--- a/apps/frontend/src/plugins/theme/theme-settings.ts
+++ b/apps/frontend/src/plugins/theme/theme-settings.ts
@@ -3,6 +3,7 @@ import type { Theme } from './themes.ts'
interface ThemeSettings {
preference: Theme | 'system'
value: Theme
+ syncAcrossDevices?: boolean
}
export function useThemeSettings(getDefaultTheme?: () => Theme) {
@@ -22,6 +23,7 @@ export function useThemeSettings(getDefaultTheme?: () => Theme) {
$settings.value = {
preference: 'system',
value: getDefaultTheme(),
+ syncAcrossDevices: true,
}
}
@@ -35,5 +37,10 @@ export function useThemeSettings(getDefaultTheme?: () => Theme) {
get: () => $settings.value.value ?? getDefaultTheme(),
set: (value) => ($settings.value.value = value),
}),
+
+ syncAcrossDevices: computed({
+ get: () => $settings.value.syncAcrossDevices ?? true,
+ set: (value) => ($settings.value.syncAcrossDevices = value),
+ }),
})
}
diff --git a/apps/frontend/src/providers/setup.ts b/apps/frontend/src/providers/setup.ts
index fe4ba192fd..eb969312dc 100644
--- a/apps/frontend/src/providers/setup.ts
+++ b/apps/frontend/src/providers/setup.ts
@@ -1,4 +1,4 @@
-import { provideNotificationManager } from '@modrinth/ui'
+import { provideNotificationManager, setupUserPreferencesProvider } from '@modrinth/ui'
import { FrontendNotificationManager } from './frontend-notifications'
import { setupAuthProvider } from './setup/auth'
@@ -10,13 +10,23 @@ import { setupTagsProvider } from './setup/tags'
import { setupUserCountryProvider } from './setup/user-country'
export function setupProviders(auth: Awaited>) {
- provideNotificationManager(new FrontendNotificationManager())
+ const notificationManager = new FrontendNotificationManager()
+ provideNotificationManager(notificationManager)
- setupAuthProvider(auth)
- setupModrinthClientProvider(auth)
+ const authProvider = setupAuthProvider(auth)
+ const client = setupModrinthClientProvider(auth)
+ const userPreferences = setupUserPreferencesProvider({
+ auth: authProvider,
+ getPreferences: (userId) => client.labrinth.users_v3.getPreferences(userId),
+ patchPreferences: (userId, preferences) =>
+ client.labrinth.users_v3.patchPreferences(userId, preferences),
+ notificationManager,
+ })
setupTagsProvider()
setupFilePickerProvider()
setupPageContextProvider()
setupLoadingStateProvider()
setupUserCountryProvider()
+
+ return userPreferences
}
diff --git a/apps/frontend/src/providers/setup/auth.ts b/apps/frontend/src/providers/setup/auth.ts
index f31ddf5ba4..6842215de4 100644
--- a/apps/frontend/src/providers/setup/auth.ts
+++ b/apps/frontend/src/providers/setup/auth.ts
@@ -30,4 +30,5 @@ export function setupAuthProvider(auth: Awaited>) {
})
provideAuth(authProvider)
+ return authProvider
}
diff --git a/apps/frontend/src/providers/setup/modrinth-client.ts b/apps/frontend/src/providers/setup/modrinth-client.ts
index a6f2c89118..1d2749a077 100644
--- a/apps/frontend/src/providers/setup/modrinth-client.ts
+++ b/apps/frontend/src/providers/setup/modrinth-client.ts
@@ -8,6 +8,7 @@ export function setupModrinthClientProvider(auth: Awaited {
- try {
- const mod = 'cloudflare:workers'
- const { env } = await import(/* @vite-ignore */ mod)
- const cfEnv = env as any
- const config = useRuntimeConfig(event)
+import { readEnv } from '~/helpers/env'
- if (cfEnv.CF_PAGES_URL) config.public.siteUrl = cfEnv.CF_PAGES_URL
- if (cfEnv.BROWSER_BASE_URL) config.public.apiBaseUrl = cfEnv.BROWSER_BASE_URL
- if (cfEnv.BASE_URL) config.apiBaseUrl = cfEnv.BASE_URL
- if (cfEnv.PYRO_BASE_URL) {
- config.public.pyroBaseUrl = cfEnv.PYRO_BASE_URL
- config.pyroBaseUrl = cfEnv.PYRO_BASE_URL
- }
- if (cfEnv.STRIPE_PUBLISHABLE_KEY)
- config.public.stripePublishableKey = cfEnv.STRIPE_PUBLISHABLE_KEY
- } catch {
- /* empty */
+export default defineEventHandler(async (event) => {
+ const config = useRuntimeConfig(event)
+
+ const siteUrl = await readEnv('CF_PAGES_URL')
+ const browserBaseUrl = await readEnv('BROWSER_BASE_URL')
+ const baseUrl = await readEnv('BASE_URL')
+ const pyroBaseUrl = await readEnv('PYRO_BASE_URL')
+ const stripePublishableKey = await readEnv('STRIPE_PUBLISHABLE_KEY')
+
+ if (siteUrl) config.public.siteUrl = siteUrl
+ if (browserBaseUrl) config.public.apiBaseUrl = browserBaseUrl
+ if (baseUrl) config.apiBaseUrl = baseUrl
+ if (pyroBaseUrl) {
+ config.public.pyroBaseUrl = pyroBaseUrl
+ config.pyroBaseUrl = pyroBaseUrl
}
+ if (stripePublishableKey) config.public.stripePublishableKey = stripePublishableKey
})
diff --git a/apps/frontend/src/server/routes/api/intercom/messenger-jwt.get.ts b/apps/frontend/src/server/routes/api/intercom/messenger-jwt.get.ts
index 25bc71d63c..398a72b2f5 100644
--- a/apps/frontend/src/server/routes/api/intercom/messenger-jwt.get.ts
+++ b/apps/frontend/src/server/routes/api/intercom/messenger-jwt.get.ts
@@ -1,23 +1,13 @@
import { type Labrinth, ModrinthApiError } from '@modrinth/api-client'
import { SignJWT } from 'jose'
+import { readEnv } from '~/helpers/env'
import { useServerModrinthClient } from '~/server/utils/api-client'
type IntercomTokenResponse = {
token: string
}
-async function getIntercomKeyFromSecretsStore(): Promise {
- try {
- const mod = 'cloudflare:workers'
- const { env } = await import(/* @vite-ignore */ mod)
- return await env.INTERCOM_IDENTITY_SECRET?.get()
- } catch {
- // Not running in Cloudflare Workers environment
- return undefined
- }
-}
-
async function signIntercomUserJwt(
user: { id: string; username: string; email?: string; created: string },
secret: string,
@@ -72,7 +62,7 @@ export default defineEventHandler(async (event): Promise
setHeader(event, 'cache-control', 'private, no-store, max-age=0')
const intercomSecret =
- (await getIntercomKeyFromSecretsStore()) ?? useRuntimeConfig(event).intercomIdentitySecret
+ (await readEnv('INTERCOM_IDENTITY_SECRET')) ?? useRuntimeConfig(event).intercomIdentitySecret
if (!intercomSecret) {
throw createError({
diff --git a/apps/frontend/src/server/utils/api-client.ts b/apps/frontend/src/server/utils/api-client.ts
index 193a8d09ad..9350b05cef 100644
--- a/apps/frontend/src/server/utils/api-client.ts
+++ b/apps/frontend/src/server/utils/api-client.ts
@@ -5,17 +5,10 @@ import {
type NuxtClientConfig,
NuxtModrinthClient,
} from '@modrinth/api-client'
-import type { H3Event } from 'h3'
+import { getRequestHeader, type H3Event } from 'h3'
-async function getRateLimitKeyFromSecretsStore(): Promise {
- try {
- const mod = 'cloudflare:workers'
- const { env } = await import(/* @vite-ignore */ mod)
- return await env.RATE_LIMIT_IGNORE_KEY?.get()
- } catch {
- return undefined
- }
-}
+import { readEnv } from '~/helpers/env'
+import { getFrontendUserAgent, VISITOR_USER_AGENT_HEADER } from '~/helpers/user-agent'
export interface ServerModrinthClientOptions {
event?: H3Event
@@ -28,6 +21,10 @@ export function useServerModrinthClient(options?: ServerModrinthClientOptions):
const sharedInstancesBaseUrl =
config.sharedInstancesBaseUrl || config.public.sharedInstancesBaseUrl
+ const visitorUserAgent = options?.event
+ ? getRequestHeader(options.event, 'user-agent')
+ : undefined
+
const features = []
if (options?.authToken) {
@@ -42,7 +39,9 @@ export function useServerModrinthClient(options?: ServerModrinthClientOptions):
const clientConfig: NuxtClientConfig = {
labrinthBaseUrl: apiBaseUrl,
sharedInstancesBaseUrl,
- rateLimitKey: config.rateLimitKey || getRateLimitKeyFromSecretsStore,
+ userAgent: getFrontendUserAgent(config.public.hash),
+ headers: visitorUserAgent ? { [VISITOR_USER_AGENT_HEADER]: visitorUserAgent } : undefined,
+ rateLimitKey: config.rateLimitKey || (() => readEnv('RATE_LIMIT_IGNORE_KEY')),
features,
}
diff --git a/apps/labrinth/.sqlx/query-0d01a3991e7551a8b7936bf8f4cc1760d2e89af99dd71849eda35d6c6820aa43.json b/apps/labrinth/.sqlx/query-0d01a3991e7551a8b7936bf8f4cc1760d2e89af99dd71849eda35d6c6820aa43.json
deleted file mode 100644
index 4a8e5af6aa..0000000000
--- a/apps/labrinth/.sqlx/query-0d01a3991e7551a8b7936bf8f4cc1760d2e89af99dd71849eda35d6c6820aa43.json
+++ /dev/null
@@ -1,23 +0,0 @@
-{
- "db_name": "PostgreSQL",
- "query": "WITH random_id_point AS (\n SELECT POINT(RANDOM() * ((SELECT MAX(id) FROM mods) - (SELECT MIN(id) FROM mods) + 1) + (SELECT MIN(id) FROM mods), 0) AS point\n )\n SELECT id FROM mods\n WHERE status = ANY($1)\n ORDER BY POINT(id, 0) <-> (SELECT point FROM random_id_point)\n LIMIT $2",
- "describe": {
- "columns": [
- {
- "ordinal": 0,
- "name": "id",
- "type_info": "Int8"
- }
- ],
- "parameters": {
- "Left": [
- "TextArray",
- "Int8"
- ]
- },
- "nullable": [
- false
- ]
- },
- "hash": "0d01a3991e7551a8b7936bf8f4cc1760d2e89af99dd71849eda35d6c6820aa43"
-}
diff --git a/apps/labrinth/.sqlx/query-20da3e21ce6115bd80746be3f6e7273771aed45eea03e46c23ef74a0a59ecfe3.json b/apps/labrinth/.sqlx/query-20da3e21ce6115bd80746be3f6e7273771aed45eea03e46c23ef74a0a59ecfe3.json
new file mode 100644
index 0000000000..753d7e6d1f
--- /dev/null
+++ b/apps/labrinth/.sqlx/query-20da3e21ce6115bd80746be3f6e7273771aed45eea03e46c23ef74a0a59ecfe3.json
@@ -0,0 +1,23 @@
+{
+ "db_name": "PostgreSQL",
+ "query": "WITH random_id_point AS (\n SELECT POINT(RANDOM() * ((SELECT MAX(id) FROM mods) - (SELECT MIN(id) FROM mods) + 1) + (SELECT MIN(id) FROM mods), 0) AS point\n )\n SELECT id FROM mods\n WHERE status = ANY($1)\n ORDER BY POINT(id, 0) <-> (SELECT point FROM random_id_point)\n LIMIT $2",
+ "describe": {
+ "columns": [
+ {
+ "ordinal": 0,
+ "name": "id",
+ "type_info": "Int8"
+ }
+ ],
+ "parameters": {
+ "Left": [
+ "TextArray",
+ "Int8"
+ ]
+ },
+ "nullable": [
+ false
+ ]
+ },
+ "hash": "20da3e21ce6115bd80746be3f6e7273771aed45eea03e46c23ef74a0a59ecfe3"
+}
diff --git a/apps/labrinth/.sqlx/query-c97315540d36355668a1fdd33175b946cd026c718bf4c7d16b23953f6ec5b840.json b/apps/labrinth/.sqlx/query-c97315540d36355668a1fdd33175b946cd026c718bf4c7d16b23953f6ec5b840.json
new file mode 100644
index 0000000000..edcb344f5a
--- /dev/null
+++ b/apps/labrinth/.sqlx/query-c97315540d36355668a1fdd33175b946cd026c718bf4c7d16b23953f6ec5b840.json
@@ -0,0 +1,24 @@
+{
+ "db_name": "PostgreSQL",
+ "query": "WITH random_id_point AS (\n SELECT POINT(RANDOM() * ((SELECT MAX(id) FROM mods) - (SELECT MIN(id) FROM mods) + 1) + (SELECT MIN(id) FROM mods), 0) AS point\n )\n SELECT id FROM mods\n WHERE status = ANY($1)\n AND EXISTS (\n SELECT 1 FROM versions v\n INNER JOIN loaders_versions lv ON v.id = lv.version_id\n INNER JOIN loaders_project_types lpt ON lpt.joining_loader_id = lv.loader_id\n INNER JOIN project_types pt ON pt.id = lpt.joining_project_type_id\n WHERE v.mod_id = mods.id AND pt.name = $3\n -- prevents decorrelation, so this stops at the first match instead\n -- of scanning all versions before the outer sort/limit applies\n OFFSET 0\n )\n ORDER BY POINT(id, 0) <-> (SELECT point FROM random_id_point)\n LIMIT $2",
+ "describe": {
+ "columns": [
+ {
+ "ordinal": 0,
+ "name": "id",
+ "type_info": "Int8"
+ }
+ ],
+ "parameters": {
+ "Left": [
+ "TextArray",
+ "Int8",
+ "Text"
+ ]
+ },
+ "nullable": [
+ false
+ ]
+ },
+ "hash": "c97315540d36355668a1fdd33175b946cd026c718bf4c7d16b23953f6ec5b840"
+}
diff --git a/apps/labrinth/src/models/v2/search.rs b/apps/labrinth/src/models/v2/search.rs
index 2edfef56fe..17bbb0fa8e 100644
--- a/apps/labrinth/src/models/v2/search.rs
+++ b/apps/labrinth/src/models/v2/search.rs
@@ -42,6 +42,7 @@ pub struct LegacyResultSearchProject {
pub client_side: String,
pub server_side: String,
pub environment: Vec,
+ pub disclosure_types: Vec,
pub gallery: Vec,
pub featured_gallery: Option,
pub color: Option,
@@ -151,6 +152,7 @@ impl LegacyResultSearchProject {
client_side,
server_side,
environment: environments,
+ disclosure_types: result_search_project.disclosure_types,
versions,
latest_version: result_search_project
.version_id
diff --git a/apps/labrinth/src/models/v3/disclosures.rs b/apps/labrinth/src/models/v3/disclosures.rs
index 52f6c06187..2c7e7b2a45 100644
--- a/apps/labrinth/src/models/v3/disclosures.rs
+++ b/apps/labrinth/src/models/v3/disclosures.rs
@@ -111,8 +111,10 @@ impl DisclosureLockStatus {
pub struct ProjectDisclosureData {
#[serde(flatten)]
pub disclosure: ProjectDisclosure,
- pub set_by_moderator: bool,
- pub lock_status: DisclosureLockStatus,
+ #[serde(skip_serializing_if = "Option::is_none")]
+ pub set_by_moderator: Option,
+ #[serde(skip_serializing_if = "Option::is_none")]
+ pub lock_status: Option,
pub updated_at: DateTime,
#[serde(skip_serializing_if = "Option::is_none")]
pub updated_by: Option,
@@ -124,16 +126,18 @@ impl ProjectDisclosureData {
pub fn from_db(
value: DBProjectDisclosure,
viewer_is_moderator: bool,
+ viewer_is_member: bool,
) -> Self {
- let updated_by = (!value.set_by_moderator || viewer_is_moderator)
- .then_some(value.updated_by.into());
-
Self {
disclosure: value.disclosure,
- set_by_moderator: value.set_by_moderator,
- lock_status: value.lock_status,
+ set_by_moderator: (viewer_is_member || viewer_is_moderator)
+ .then_some(value.set_by_moderator),
+ lock_status: (viewer_is_member || viewer_is_moderator)
+ .then_some(value.lock_status),
updated_at: value.updated_at,
- updated_by,
+ updated_by: ((!value.set_by_moderator && viewer_is_member)
+ || viewer_is_moderator)
+ .then_some(value.updated_by.into()),
deleted_at: value.deleted_at,
}
}
diff --git a/apps/labrinth/src/models/v3/preferences.rs b/apps/labrinth/src/models/v3/preferences.rs
index 1deedfffc2..f2b8e8914a 100644
--- a/apps/labrinth/src/models/v3/preferences.rs
+++ b/apps/labrinth/src/models/v3/preferences.rs
@@ -7,6 +7,8 @@ pub struct UserPreferences {
#[component(nested)]
pub appearance: AppearancePreferences,
#[component(nested)]
+ pub behavior: BehaviorPreferences,
+ #[component(nested)]
pub localization: LocalizationPreferences,
#[component(nested)]
pub layouts: LayoutPreferences,
@@ -34,6 +36,33 @@ pub struct AppearancePreferences {
pub theme: Theme,
}
+#[derive(Debug, Serialize, Deserialize, ToSchema, PartialEq, Component)]
+pub struct BehaviorPreferences {
+ pub minimize_app: bool,
+ pub hide_right_sidebar: bool,
+ pub show_jump_in: bool,
+ pub compact_instance_cards: bool,
+ pub show_play_time: bool,
+ pub hide_nametag: bool,
+ pub warn_on_unknown_modpacks: bool,
+ pub skip_non_essential_warnings: bool,
+}
+
+impl Default for BehaviorPreferences {
+ fn default() -> Self {
+ Self {
+ minimize_app: false,
+ hide_right_sidebar: false,
+ show_jump_in: true,
+ compact_instance_cards: false,
+ show_play_time: true,
+ hide_nametag: false,
+ warn_on_unknown_modpacks: true,
+ skip_non_essential_warnings: false,
+ }
+ }
+}
+
#[derive(
Debug, Serialize, Deserialize, ToSchema, Default, Clone, PartialEq,
)]
diff --git a/apps/labrinth/src/routes/maven.rs b/apps/labrinth/src/routes/maven.rs
index a10e7d724c..369eaf6d1f 100644
--- a/apps/labrinth/src/routes/maven.rs
+++ b/apps/labrinth/src/routes/maven.rs
@@ -277,8 +277,11 @@ fn find_file<'a>(
if file_name.eq_ignore_ascii_case(&formatted_name) {
return filtered_files
- .find(|x| x.primary)
- .or_else(|| filtered_files.next_back());
+ .try_fold(
+ None,
+ |_, x| if x.primary { Err(x) } else { Ok(Some(x)) },
+ )
+ .unwrap_or_else(Some);
} else if file_name.len() > formatted_name.len()
&& file_name.as_bytes()[..formatted_name.len()]
.eq_ignore_ascii_case(formatted_name.as_bytes())
diff --git a/apps/labrinth/src/routes/v2/projects.rs b/apps/labrinth/src/routes/v2/projects.rs
index 1adf08f1c2..679f740ea0 100644
--- a/apps/labrinth/src/routes/v2/projects.rs
+++ b/apps/labrinth/src/routes/v2/projects.rs
@@ -193,7 +193,10 @@ pub async fn random_projects_get(
pool: web::Data,
redis: web::Data,
) -> Result {
- let count = v3::projects::RandomProjects { count: count.count };
+ let count = v3::projects::RandomProjects {
+ count: count.count,
+ project_type: None,
+ };
let response = v3::projects::random_projects_get(
web::Query(count),
@@ -376,7 +379,7 @@ struct DependencyInfo {
get,
operation_id = "getDependencies",
params(
- ("id" = String, Path, description = "The ID or slug of the project")
+ ("project_id" = String, Path, description = "The ID or slug of the project")
),
responses(
(status = 200, description = "Expected response to a valid request", body = DependencyInfo),
diff --git a/apps/labrinth/src/routes/v3/analytics_get/metrics/project_downloads.rs b/apps/labrinth/src/routes/v3/analytics_get/metrics/project_downloads.rs
index 2439d9cb5c..8f3845fcdf 100644
--- a/apps/labrinth/src/routes/v3/analytics_get/metrics/project_downloads.rs
+++ b/apps/labrinth/src/routes/v3/analytics_get/metrics/project_downloads.rs
@@ -661,6 +661,8 @@ static DOWNLOAD_SOURCE_PATTERNS: LazyLock> =
(r"^DawnLauncher/", P::Named("Dawn")),
(r"^Complementary-Installer", P::Named("Complementary Installer")),
(r"^noriskclient-launcher-v3/", P::Named("NoRisk Client")),
+ (r"^Resourcify/", P::Named("Resourcify")),
+ (r"^OneClient", P::Named("OneClient")),
(
r"^(Mozilla/|Chrome/|Chromium/|Firefox/|Safari/|AppleWebKit/|Edg/|OPR/)",
P::Website,
diff --git a/apps/labrinth/src/routes/v3/disclosures.rs b/apps/labrinth/src/routes/v3/disclosures.rs
index 3c790c703d..b81fefdd32 100644
--- a/apps/labrinth/src/routes/v3/disclosures.rs
+++ b/apps/labrinth/src/routes/v3/disclosures.rs
@@ -73,14 +73,15 @@ pub async fn get_project_disclosures(
let viewer_is_moderator =
user_option.as_ref().is_some_and(|user| user.role.is_mod());
- let include_deleted = viewer_is_moderator
+ // Moderators can see regardless of membership, short circuit to avoid extra db call
+ let viewer_is_member = viewer_is_moderator
|| is_team_member_project(&project.inner, &user_option, &pool)
.await
.wrap_internal_err("failed to check project team membership")?;
let disclosures = db_models::DBProjectDisclosure::get_many_for_project(
project.inner.id,
- include_deleted,
+ viewer_is_moderator || viewer_is_member,
&***ro_pool,
)
.await
@@ -90,7 +91,11 @@ pub async fn get_project_disclosures(
disclosures: disclosures
.into_iter()
.map(|disclosure| {
- ProjectDisclosureData::from_db(disclosure, viewer_is_moderator)
+ ProjectDisclosureData::from_db(
+ disclosure,
+ viewer_is_moderator,
+ viewer_is_member,
+ )
})
.collect(),
}))
diff --git a/apps/labrinth/src/routes/v3/notifications.rs b/apps/labrinth/src/routes/v3/notifications.rs
index 1daa7acf3e..90ca9829b4 100644
--- a/apps/labrinth/src/routes/v3/notifications.rs
+++ b/apps/labrinth/src/routes/v3/notifications.rs
@@ -86,7 +86,7 @@ pub async fn notifications_get(
Ok(HttpResponse::Ok().json(notifications))
}
-#[utoipa::path(tag = "notifications", responses((status = OK)))]
+#[utoipa::path(tag = "notifications", params(("id" = NotificationId, Path, description = "Notification id",)), responses((status = OK)))]
#[get("/notification/{id}")]
pub async fn notification_get_route(
req: HttpRequest,
@@ -137,7 +137,7 @@ pub async fn notification_get(
}
}
-#[utoipa::path(tag = "notifications", responses((status = NO_CONTENT)))]
+#[utoipa::path(tag = "notifications", params(("id" = NotificationId, Path, description = "Notification id",)), responses((status = NO_CONTENT)))]
#[patch("/notification/{id}")]
pub async fn notification_read_route(
req: HttpRequest,
@@ -208,7 +208,7 @@ pub async fn notification_read(
}
}
-#[utoipa::path(tag = "notifications", responses((status = NO_CONTENT)))]
+#[utoipa::path(tag = "notifications", params(("id" = NotificationId, Path, description = "Notification id",)), responses((status = NO_CONTENT)))]
#[delete("/notification/{id}")]
pub async fn notification_delete_route(
req: HttpRequest,
diff --git a/apps/labrinth/src/routes/v3/projects.rs b/apps/labrinth/src/routes/v3/projects.rs
index 8ca4fb3ca1..bf69c1a150 100644
--- a/apps/labrinth/src/routes/v3/projects.rs
+++ b/apps/labrinth/src/routes/v3/projects.rs
@@ -44,6 +44,7 @@ use chrono::Utc;
use eyre::eyre;
use futures::TryStreamExt;
use itertools::Itertools;
+use rand::seq::SliceRandom;
use rust_decimal::Decimal;
use serde::{Deserialize, Serialize};
use validator::Validate;
@@ -104,11 +105,15 @@ pub async fn clear_project_cache_and_queue_search(
pub struct RandomProjects {
#[validate(range(min = 1, max = 100))]
pub count: u32,
+ pub project_type: Option,
}
#[utoipa::path(
tag = "projects",
- params(("count" = u32, Query)),
+ params(
+ ("count" = u32, Query),
+ ("project_type" = Option, Query),
+ ),
responses((status = OK))
)]
#[get("/projects_random")]
@@ -120,37 +125,84 @@ pub async fn random_projects_get_route(
random_projects_get(count, pool, redis).await
}
+// Filtered candidates are sparser and unevenly spaced, so the nearest-point pick
+// tends to repeat; oversample a neighborhood and shuffle it down to counter that.
+const RANDOM_PROJECT_TYPE_OVERSAMPLE_FACTOR: u32 = 20;
+
pub async fn random_projects_get(
- web::Query(count): web::Query,
+ web::Query(params): web::Query,
pool: web::Data,
redis: web::Data,
) -> Result {
- count
+ params
.validate()
.map_err(|err| eyre::eyre!(err))
.wrap_request_err("validating request")?;
- let project_ids = sqlx::query!(
- // IDs are randomly generated (see the `generate_ids` macro), so fetching a
- // number of mods nearest to a random point in the ID space is equivalent to
- // random sampling
- "WITH random_id_point AS (
- SELECT POINT(RANDOM() * ((SELECT MAX(id) FROM mods) - (SELECT MIN(id) FROM mods) + 1) + (SELECT MIN(id) FROM mods), 0) AS point
+ let statuses = crate::models::projects::ProjectStatus::iterator()
+ .filter(|x| x.is_searchable())
+ .map(|x| x.to_string())
+ .collect::>();
+
+ let mut project_ids = if let Some(project_type) = ¶ms.project_type {
+ let fetch_limit = params.count * RANDOM_PROJECT_TYPE_OVERSAMPLE_FACTOR;
+
+ sqlx::query!(
+ // IDs are randomly generated (see the `generate_ids` macro), so fetching a
+ // number of mods nearest to a random point in the ID space is equivalent to
+ // random sampling
+ "WITH random_id_point AS (
+ SELECT POINT(RANDOM() * ((SELECT MAX(id) FROM mods) - (SELECT MIN(id) FROM mods) + 1) + (SELECT MIN(id) FROM mods), 0) AS point
+ )
+ SELECT id FROM mods
+ WHERE status = ANY($1)
+ AND EXISTS (
+ SELECT 1 FROM versions v
+ INNER JOIN loaders_versions lv ON v.id = lv.version_id
+ INNER JOIN loaders_project_types lpt ON lpt.joining_loader_id = lv.loader_id
+ INNER JOIN project_types pt ON pt.id = lpt.joining_project_type_id
+ WHERE v.mod_id = mods.id AND pt.name = $3
+ -- prevents decorrelation, so this stops at the first match instead
+ -- of scanning all versions before the outer sort/limit applies
+ OFFSET 0
+ )
+ ORDER BY POINT(id, 0) <-> (SELECT point FROM random_id_point)
+ LIMIT $2",
+ &statuses,
+ fetch_limit as i32,
+ project_type,
)
- SELECT id FROM mods
- WHERE status = ANY($1)
- ORDER BY POINT(id, 0) <-> (SELECT point FROM random_id_point)
- LIMIT $2",
- &*crate::models::projects::ProjectStatus::iterator()
- .filter(|x| x.is_searchable())
- .map(|x| x.to_string())
- .collect::>(),
- count.count as i32,
- )
- .fetch(&**pool)
- .map_ok(|m| db_ids::DBProjectId(m.id))
- .try_collect::>()
- .await.wrap_internal_err("querying random project IDs")?;
+ .fetch(&**pool)
+ .map_ok(|m| db_ids::DBProjectId(m.id))
+ .try_collect::>()
+ .await
+ .wrap_internal_err("querying random project IDs")?
+ } else {
+ sqlx::query!(
+ // IDs are randomly generated (see the `generate_ids` macro), so fetching a
+ // number of mods nearest to a random point in the ID space is equivalent to
+ // random sampling
+ "WITH random_id_point AS (
+ SELECT POINT(RANDOM() * ((SELECT MAX(id) FROM mods) - (SELECT MIN(id) FROM mods) + 1) + (SELECT MIN(id) FROM mods), 0) AS point
+ )
+ SELECT id FROM mods
+ WHERE status = ANY($1)
+ ORDER BY POINT(id, 0) <-> (SELECT point FROM random_id_point)
+ LIMIT $2",
+ &statuses,
+ params.count as i32,
+ )
+ .fetch(&**pool)
+ .map_ok(|m| db_ids::DBProjectId(m.id))
+ .try_collect::>()
+ .await
+ .wrap_internal_err("querying random project IDs")?
+ };
+
+ if params.project_type.is_some() {
+ project_ids.shuffle(&mut rand::thread_rng());
+ project_ids.truncate(params.count as usize);
+ }
let projects_data =
db_models::DBProject::get_many_ids(&project_ids, &**pool, &redis)
diff --git a/package.json b/package.json
index edebadb426..4e84fe7f1d 100644
--- a/package.json
+++ b/package.json
@@ -3,12 +3,12 @@
"version": "0.0.0",
"private": true,
"scripts": {
- "web:dev": "turbo run dev --filter=@modrinth/frontend",
+ "web:dev": "pnpm i18n:coverage && turbo run dev --filter=@modrinth/frontend",
"web:build": "turbo run build --filter=@modrinth/frontend",
"app:dev": "turbo run dev --filter=@modrinth/app",
"app:build": "turbo run build --filter=@modrinth/app",
"docs:dev": "turbo run dev --filter=@modrinth/docs",
- "pages:build": "NITRO_PRESET=cloudflare-pages pnpm --filter frontend run build",
+ "pages:build": "pnpm i18n:coverage && NITRO_PRESET=cloudflare-pages pnpm --filter frontend run build",
"build": "turbo run build --continue",
"lint": "turbo run lint lint:ancillary --continue",
"lint:ancillary": "prettier --check .github *.*",
@@ -21,9 +21,10 @@
"prepr:frontend:lib": "turbo run prepr --filter=@modrinth/ui --filter=@modrinth/moderation --filter=@modrinth/assets --filter=@modrinth/blog --filter=@modrinth/api-client --filter=@modrinth/utils --filter=@modrinth/tooling-config",
"prepr:frontend:web": "turbo run prepr --filter=@modrinth/frontend",
"prepr:frontend:app": "turbo run prepr --filter=@modrinth/app-frontend",
- "storybook": "pnpm --filter @modrinth/ui storybook",
- "build-storybook": "pnpm --filter @modrinth/ui build-storybook",
+ "storybook": "pnpm i18n:coverage && pnpm --filter @modrinth/ui storybook",
+ "build-storybook": "pnpm i18n:coverage && pnpm --filter @modrinth/ui build-storybook",
"icons:add": "pnpm --filter @modrinth/assets icons:add",
+ "i18n:coverage": "node scripts/run.mjs coverage-i18n --write packages/ui/src/layouts/wrapped/settings/language-settings/language-settings-coverage.generated.ts --quiet",
"changelog:collect": "node scripts/run.mjs collect-changelog",
"changelog:combine-for-app": "node scripts/run.mjs build-theseus-release-notes",
"scripts": "node scripts/run.mjs"
diff --git a/packages/api-client/src/modules/archon/index.ts b/packages/api-client/src/modules/archon/index.ts
index 194ea0333e..335bd23434 100644
--- a/packages/api-client/src/modules/archon/index.ts
+++ b/packages/api-client/src/modules/archon/index.ts
@@ -3,6 +3,7 @@ export * from './backups/v1'
export * from './backups-queue/v1'
export * from './content/v1'
export * from './properties/v1'
+export * from './servers/internal'
export * from './servers/v0'
export * from './servers/v1'
export * from './types'
diff --git a/packages/api-client/src/modules/archon/servers/internal.ts b/packages/api-client/src/modules/archon/servers/internal.ts
new file mode 100644
index 0000000000..2be3033882
--- /dev/null
+++ b/packages/api-client/src/modules/archon/servers/internal.ts
@@ -0,0 +1,23 @@
+import { AbstractModule } from '../../../core/abstract-module'
+import type { Archon } from '../types'
+
+export class ArchonServersInternalModule extends AbstractModule {
+ public getModuleID(): string {
+ return 'archon_servers_internal'
+ }
+
+ /**
+ * Get the server assigned to a subdomain.
+ * GET /_internal/servers/by-subdomain/:subdomain
+ */
+ public async getBySubdomain(subdomain: string): Promise {
+ return this.client.request(
+ `/servers/by-subdomain/${encodeURIComponent(subdomain)}`,
+ {
+ api: 'archon',
+ version: 'internal',
+ method: 'GET',
+ },
+ )
+ }
+}
diff --git a/packages/api-client/src/modules/archon/types.ts b/packages/api-client/src/modules/archon/types.ts
index 56df291622..00fb3c7da4 100644
--- a/packages/api-client/src/modules/archon/types.ts
+++ b/packages/api-client/src/modules/archon/types.ts
@@ -573,6 +573,12 @@ export namespace Archon {
}
export namespace Servers {
+ export namespace Internal {
+ export type Lookup = {
+ id: string
+ }
+ }
+
export namespace v0 {
export type ServerGetResponse = {
servers: Server[]
diff --git a/packages/api-client/src/modules/index.ts b/packages/api-client/src/modules/index.ts
index a08a953259..43792728bc 100644
--- a/packages/api-client/src/modules/index.ts
+++ b/packages/api-client/src/modules/index.ts
@@ -9,6 +9,7 @@ import { ArchonNoticesV0Module } from './archon/notices/v0'
import { ArchonOptionsV1Module } from './archon/options/v1'
import { ArchonPropertiesV1Module } from './archon/properties/v1'
import { ArchonServerUsersV1Module } from './archon/server-users/v1'
+import { ArchonServersInternalModule } from './archon/servers/internal'
import { ArchonServersV0Module } from './archon/servers/v0'
import { ArchonServersV1Module } from './archon/servers/v1'
import { ArchonTransfersInternalModule } from './archon/transfers/internal'
@@ -87,6 +88,7 @@ export const MODULE_REGISTRY = {
archon_options_v1: ArchonOptionsV1Module,
archon_properties_v1: ArchonPropertiesV1Module,
archon_server_users_v1: ArchonServerUsersV1Module,
+ archon_servers_internal: ArchonServersInternalModule,
archon_servers_v0: ArchonServersV0Module,
archon_servers_v1: ArchonServersV1Module,
archon_transfers_internal: ArchonTransfersInternalModule,
diff --git a/packages/api-client/src/modules/labrinth/types.ts b/packages/api-client/src/modules/labrinth/types.ts
index e0af1e83ad..6d0d708369 100644
--- a/packages/api-client/src/modules/labrinth/types.ts
+++ b/packages/api-client/src/modules/labrinth/types.ts
@@ -1352,8 +1352,8 @@ export namespace Labrinth {
}
export type ProjectDisclosureData = ProjectDisclosure & {
- set_by_moderator: boolean
- lock_status: DisclosureLockStatus
+ set_by_moderator?: boolean | null
+ lock_status?: DisclosureLockStatus | null
updated_at: string
updated_by?: string | null
deleted_at?: string | null
@@ -1710,6 +1710,70 @@ export namespace Labrinth {
export type Role = Common.Role
export type AuthProvider = Common.AuthProvider
export type UserPayoutData = Common.UserPayoutData
+ export type Theme = 'light' | 'dark' | 'oled' | 'retro'
+ export type LayoutOption = 'grid' | 'rows'
+ export type FriendPrivacy = 'none' | 'mutual' | 'everyone'
+ export type InvitePrivacy = 'none' | 'friends' | 'everyone'
+
+ export type AppearancePreferences = {
+ auto: boolean
+ theme: Theme
+ }
+
+ export type BehaviorPreferences = {
+ minimize_app: boolean
+ hide_right_sidebar: boolean
+ show_jump_in: boolean
+ compact_instance_cards: boolean
+ show_play_time: boolean
+ hide_nametag: boolean
+ warn_on_unknown_modpacks: boolean
+ skip_non_essential_warnings: boolean
+ }
+
+ export type LocalizationPreferences = {
+ locale: string
+ }
+
+ export type LayoutPreferences = {
+ mods: LayoutOption
+ plugins: LayoutOption
+ datapacks: LayoutOption
+ shaders: LayoutOption
+ resourcepacks: LayoutOption
+ modpacks: LayoutOption
+ servers: LayoutOption
+ users: LayoutOption
+ }
+
+ export type SidebarPreferences = {
+ right_aligned_search: boolean
+ left_aligned_content: boolean
+ }
+
+ export type SocialPreferences = {
+ friend_privacy: FriendPrivacy
+ shared_instances_privacy: InvitePrivacy
+ hosting_access_privacy: InvitePrivacy
+ }
+
+ export type UserPreferences = {
+ appearance: AppearancePreferences
+ behavior: BehaviorPreferences
+ localization: LocalizationPreferences
+ layouts: LayoutPreferences
+ sidebars: SidebarPreferences
+ social: SocialPreferences
+ }
+
+ export type PartialUserPreferences = {
+ appearance?: Partial
+ behavior?: Partial
+ localization?: Partial
+ layouts?: Partial
+ sidebars?: Partial
+ social?: Partial
+ }
export type Pride26CampaignDonation = {
last_donated_at: string
@@ -1884,6 +1948,7 @@ export namespace Labrinth {
license: string
client_side: string
server_side: string
+ disclosure_types: string[]
gallery: string[]
featured_gallery: string | null
color: number | null
@@ -1929,6 +1994,7 @@ export namespace Labrinth {
minecraft_java_server?: Projects.v3.MinecraftJavaServer | null
minecraft_bedrock_server?: Projects.v3.MinecraftBedrockServer | null
minecraft_mod?: unknown | null
+ disclosure_types: string[]
}
export interface SearchResults {
diff --git a/packages/api-client/src/modules/labrinth/users/v3.ts b/packages/api-client/src/modules/labrinth/users/v3.ts
index 90dfced927..6f9e983919 100644
--- a/packages/api-client/src/modules/labrinth/users/v3.ts
+++ b/packages/api-client/src/modules/labrinth/users/v3.ts
@@ -37,6 +37,43 @@ export class LabrinthUsersV3Module extends AbstractModule {
)
}
+ /**
+ * Get a user's account preferences. The authenticated user may access their
+ * own preferences, while moderators may access another user's preferences.
+ *
+ * GET /v3/user/{id}/preferences
+ */
+ public async getPreferences(idOrUsername: string): Promise {
+ return this.client.request(
+ `/user/${encodeURIComponent(idOrUsername)}/preferences`,
+ {
+ api: 'labrinth',
+ version: 3,
+ method: 'GET',
+ },
+ )
+ }
+
+ /**
+ * Update a user's account preferences and return the fully resolved result.
+ *
+ * PATCH /v3/user/{id}/preferences
+ */
+ public async patchPreferences(
+ idOrUsername: string,
+ preferences: Labrinth.Users.v3.PartialUserPreferences,
+ ): Promise {
+ return this.client.request(
+ `/user/${encodeURIComponent(idOrUsername)}/preferences`,
+ {
+ api: 'labrinth',
+ version: 3,
+ method: 'PATCH',
+ body: preferences,
+ },
+ )
+ }
+
/**
* Search users by username prefix.
*
@@ -56,6 +93,54 @@ export class LabrinthUsersV3Module extends AbstractModule {
)
}
+ /**
+ * Get a user's projects.
+ *
+ * @param idOrUsername - The user's ID or username
+ * @returns Promise resolving to an array of the user's projects
+ *
+ * GET /v3/user/{id}/projects
+ *
+ * @example
+ * ```typescript
+ * const projects = await client.labrinth.users_v3.getProjects('my_user')
+ * ```
+ */
+ public async getProjects(idOrUsername: string): Promise {
+ return this.client.request(
+ `/user/${encodeURIComponent(idOrUsername)}/projects`,
+ {
+ api: 'labrinth',
+ version: 3,
+ method: 'GET',
+ },
+ )
+ }
+
+ /**
+ * Get projects a user follows.
+ *
+ * @param idOrUsername - The user's ID or username
+ * @returns Promise resolving to an array of followed projects
+ *
+ * GET /v3/user/{id}/follows
+ *
+ * @example
+ * ```typescript
+ * const projects = await client.labrinth.users_v3.getFollowedProjects('my_user')
+ * ```
+ */
+ public async getFollowedProjects(idOrUsername: string): Promise {
+ return this.client.request(
+ `/user/${encodeURIComponent(idOrUsername)}/follows`,
+ {
+ api: 'labrinth',
+ version: 3,
+ method: 'GET',
+ },
+ )
+ }
+
/**
* Get all projects the authenticated user can access directly or through
* their organizations.
diff --git a/packages/app-lib/.sqlx/query-6f8b76b839db48539a70664af549c12be71df20b8cf520019413f6ea751cd594.json b/packages/app-lib/.sqlx/query-29df6a29d9221b9bbea45e024fb26f5b6a3155788880369af940fcd268d3cb94.json
similarity index 90%
rename from packages/app-lib/.sqlx/query-6f8b76b839db48539a70664af549c12be71df20b8cf520019413f6ea751cd594.json
rename to packages/app-lib/.sqlx/query-29df6a29d9221b9bbea45e024fb26f5b6a3155788880369af940fcd268d3cb94.json
index 21417b6d56..2b031b4401 100644
--- a/packages/app-lib/.sqlx/query-6f8b76b839db48539a70664af549c12be71df20b8cf520019413f6ea751cd594.json
+++ b/packages/app-lib/.sqlx/query-29df6a29d9221b9bbea45e024fb26f5b6a3155788880369af940fcd268d3cb94.json
@@ -1,6 +1,6 @@
{
"db_name": "SQLite",
- "query": "\n SELECT\n max_concurrent_writes, max_concurrent_downloads,\n theme, locale, default_page, collapsed_navigation, hide_nametag_skins_page, advanced_rendering, native_decorations,\n discord_rpc, developer_mode, telemetry, personalized_ads,\n json(extra_launch_args) extra_launch_args, json(custom_env_vars) custom_env_vars,\n mc_memory_max, mc_force_fullscreen, mc_game_resolution_x, mc_game_resolution_y, hide_on_process_start,\n hook_pre_launch, hook_wrapper, hook_post_exit,\n custom_dir, prev_custom_dir, migrated, json(feature_flags) feature_flags, toggle_sidebar,\n skipped_update, pending_update_toast_for_version, auto_download_updates,\n version\n FROM settings\n ",
+ "query": "\n SELECT\n max_concurrent_writes, max_concurrent_downloads,\n theme, locale, default_page, collapsed_navigation, hide_nametag_skins_page, advanced_rendering, native_decorations,\n discord_rpc, developer_mode, telemetry, personalized_ads,\n json(extra_launch_args) extra_launch_args, json(custom_env_vars) custom_env_vars,\n mc_memory_max, mc_force_fullscreen, mc_game_resolution_x, mc_game_resolution_y, hide_on_process_start,\n hook_pre_launch, hook_wrapper, hook_post_exit,\n custom_dir, prev_custom_dir, migrated, json(feature_flags) feature_flags, toggle_sidebar,\n skipped_update, pending_update_toast_for_version, auto_download_updates,\n sync_theme_across_devices, sync_behavior_across_devices,\n version\n FROM settings\n ",
"describe": {
"columns": [
{
@@ -159,9 +159,19 @@
"type_info": "Integer"
},
{
- "name": "version",
+ "name": "sync_theme_across_devices",
"ordinal": 31,
"type_info": "Integer"
+ },
+ {
+ "name": "sync_behavior_across_devices",
+ "ordinal": 32,
+ "type_info": "Integer"
+ },
+ {
+ "name": "version",
+ "ordinal": 33,
+ "type_info": "Integer"
}
],
"parameters": {
@@ -199,8 +209,10 @@
true,
true,
true,
+ false,
+ false,
false
]
},
- "hash": "6f8b76b839db48539a70664af549c12be71df20b8cf520019413f6ea751cd594"
+ "hash": "29df6a29d9221b9bbea45e024fb26f5b6a3155788880369af940fcd268d3cb94"
}
diff --git a/packages/app-lib/.sqlx/query-dfdc99cb5ec60bb08c566920628deec7e741eca9490811ebad6594abe8a6126d.json b/packages/app-lib/.sqlx/query-88c6a22aa112f10dce9d42779f06c723b356263ffc83aabb645c9fb5b6d5aa7c.json
similarity index 85%
rename from packages/app-lib/.sqlx/query-dfdc99cb5ec60bb08c566920628deec7e741eca9490811ebad6594abe8a6126d.json
rename to packages/app-lib/.sqlx/query-88c6a22aa112f10dce9d42779f06c723b356263ffc83aabb645c9fb5b6d5aa7c.json
index e4cd418a9e..fe54b8050f 100644
--- a/packages/app-lib/.sqlx/query-dfdc99cb5ec60bb08c566920628deec7e741eca9490811ebad6594abe8a6126d.json
+++ b/packages/app-lib/.sqlx/query-88c6a22aa112f10dce9d42779f06c723b356263ffc83aabb645c9fb5b6d5aa7c.json
@@ -1,12 +1,12 @@
{
"db_name": "SQLite",
- "query": "\n UPDATE settings\n SET\n max_concurrent_writes = $1,\n max_concurrent_downloads = $2,\n\n theme = $3,\n locale = $4,\n default_page = $5,\n collapsed_navigation = $6,\n advanced_rendering = $7,\n native_decorations = $8,\n\n discord_rpc = $9,\n developer_mode = $10,\n telemetry = $11,\n personalized_ads = $12,\n\n extra_launch_args = jsonb($13),\n custom_env_vars = jsonb($14),\n mc_memory_max = $15,\n mc_force_fullscreen = $16,\n mc_game_resolution_x = $17,\n mc_game_resolution_y = $18,\n hide_on_process_start = $19,\n\n hook_pre_launch = $20,\n hook_wrapper = $21,\n hook_post_exit = $22,\n\n custom_dir = $23,\n prev_custom_dir = $24,\n migrated = $25,\n\n toggle_sidebar = $26,\n feature_flags = $27,\n hide_nametag_skins_page = $28,\n\n skipped_update = $29,\n pending_update_toast_for_version = $30,\n auto_download_updates = $31,\n\n version = $32\n ",
+ "query": "\n UPDATE settings\n SET\n max_concurrent_writes = $1,\n max_concurrent_downloads = $2,\n\n theme = $3,\n locale = $4,\n default_page = $5,\n collapsed_navigation = $6,\n advanced_rendering = $7,\n native_decorations = $8,\n\n discord_rpc = $9,\n developer_mode = $10,\n telemetry = $11,\n personalized_ads = $12,\n\n extra_launch_args = jsonb($13),\n custom_env_vars = jsonb($14),\n mc_memory_max = $15,\n mc_force_fullscreen = $16,\n mc_game_resolution_x = $17,\n mc_game_resolution_y = $18,\n hide_on_process_start = $19,\n\n hook_pre_launch = $20,\n hook_wrapper = $21,\n hook_post_exit = $22,\n\n custom_dir = $23,\n prev_custom_dir = $24,\n migrated = $25,\n\n toggle_sidebar = $26,\n feature_flags = $27,\n hide_nametag_skins_page = $28,\n\n skipped_update = $29,\n pending_update_toast_for_version = $30,\n auto_download_updates = $31,\n\n sync_theme_across_devices = $32,\n sync_behavior_across_devices = $33,\n\n version = $34\n ",
"describe": {
"columns": [],
"parameters": {
- "Right": 32
+ "Right": 34
},
"nullable": []
},
- "hash": "dfdc99cb5ec60bb08c566920628deec7e741eca9490811ebad6594abe8a6126d"
+ "hash": "88c6a22aa112f10dce9d42779f06c723b356263ffc83aabb645c9fb5b6d5aa7c"
}
diff --git a/packages/app-lib/migrations/20260818120000_sync-preferences-settings.sql b/packages/app-lib/migrations/20260818120000_sync-preferences-settings.sql
new file mode 100644
index 0000000000..f97419c46b
--- /dev/null
+++ b/packages/app-lib/migrations/20260818120000_sync-preferences-settings.sql
@@ -0,0 +1,5 @@
+ALTER TABLE settings
+ADD COLUMN sync_theme_across_devices INTEGER NOT NULL DEFAULT TRUE;
+
+ALTER TABLE settings
+ADD COLUMN sync_behavior_across_devices INTEGER NOT NULL DEFAULT TRUE;
diff --git a/packages/app-lib/src/api/instance/export_mrpack.rs b/packages/app-lib/src/api/instance/export_mrpack.rs
index 4191983338..e57df7d02b 100644
--- a/packages/app-lib/src/api/instance/export_mrpack.rs
+++ b/packages/app-lib/src/api/instance/export_mrpack.rs
@@ -529,12 +529,12 @@ pub async fn create_mrpack_json(
)
.await?
.into_iter()
- .filter_map(|(path, file)| match file.metadata {
- Some(metadata) => Some((path, metadata.version_id)),
- _ => None,
+ .filter_map(|(path, file)| {
+ file.metadata
+ .map(|metadata| (path, file.hash, metadata.version_id))
})
.collect::>();
- let version_ids = projects.iter().map(|x| &*x.1).collect::>();
+ let version_ids = projects.iter().map(|x| &*x.2).collect::>();
let versions = CachedEntry::get_version_v3_many(
&version_ids,
Some(CacheBehaviour::MustRevalidate),
@@ -544,43 +544,38 @@ pub async fn create_mrpack_json(
.await?;
let files = projects
.into_iter()
- .filter_map(|(path, version_id)| {
- if let Some(version) = versions.iter().find(|x| x.id == version_id)
- {
- let env = get_mrpack_environment(version.environment);
- let Some(primary_file) = version.files.first() else {
- return Some(Err(crate::ErrorKind::OtherError(format!(
- "No primary file found for mod at: {path}"
- ))
- .as_error()));
- };
- let file_size = primary_file.size;
- let downloads = vec![primary_file.url.clone()];
- let hashes = primary_file
- .hashes
- .clone()
- .into_iter()
- .map(|(h1, h2)| (PackFileHash::from(h1), h2))
- .collect();
+ .filter_map(|(path, hash, version_id)| {
+ let version = versions.iter().find(|x| x.id == version_id)?;
+ let env = get_mrpack_environment(version.environment);
+ let file = version.files.iter().find(|file| {
+ file.hashes
+ .get("sha1")
+ .is_some_and(|file_hash| file_hash == &hash)
+ })?;
+ let file_size = file.size;
+ let downloads = vec![file.url.clone()];
+ let hashes = file
+ .hashes
+ .clone()
+ .into_iter()
+ .map(|(h1, h2)| (PackFileHash::from(h1), h2))
+ .collect();
- Some(Ok(PackFile {
- path: match path.try_into() {
- Ok(path) => path,
- Err(_) => {
- return Some(Err(crate::ErrorKind::OtherError(
- "Invalid file path in project".into(),
- )
- .as_error()));
- }
- },
- hashes,
- env: Some(env),
- downloads,
- file_size,
- }))
- } else {
- None
- }
+ Some(Ok(PackFile {
+ path: match path.try_into() {
+ Ok(path) => path,
+ Err(_) => {
+ return Some(Err(crate::ErrorKind::OtherError(
+ "Invalid file path in project".into(),
+ )
+ .as_error()));
+ }
+ },
+ hashes,
+ env: Some(env),
+ downloads,
+ file_size,
+ }))
})
.collect::>>()?;
diff --git a/packages/app-lib/src/api/instance/shared/client.rs b/packages/app-lib/src/api/instance/shared/client.rs
index 132034e982..52fdc788cf 100644
--- a/packages/app-lib/src/api/instance/shared/client.rs
+++ b/packages/app-lib/src/api/instance/shared/client.rs
@@ -823,7 +823,8 @@ pub(super) async fn send_bytes_request(
) -> crate::Result {
let base_url = service_base_url();
let url = service_url(base_url, path);
- send_bytes_request_to_url(operation, method, path, &url, body, state).await
+ send_bytes_request_to_url(operation, method, path, &url, body, None, state)
+ .await
}
pub(super) async fn send_bytes_request_to_url(
@@ -832,6 +833,7 @@ pub(super) async fn send_bytes_request_to_url(
path: &str,
url: &str,
body: Vec,
+ file_sha512: Option<&str>,
state: &State,
) -> crate::Result {
let service_origin = url::Url::parse(service_base_url())
@@ -872,18 +874,20 @@ pub(super) async fn send_bytes_request_to_url(
"Sending shared instances API request"
);
- let response = shared_instances_client(url)
+ let mut request = shared_instances_client(url)
.request(method.clone(), url)
.bearer_auth(credentials.session)
.header(reqwest::header::CONTENT_TYPE, "application/octet-stream")
- .body(body)
- .send()
- .await
- .map_err(|error| {
- crate::ErrorKind::SharedInstancesApiError(
- error.without_url().to_string(),
- )
- })?;
+ .body(body);
+ if let Some(file_sha512) = file_sha512 {
+ request = request.header("x-file-sha512", file_sha512);
+ }
+
+ let response = request.send().await.map_err(|error| {
+ crate::ErrorKind::SharedInstancesApiError(
+ error.without_url().to_string(),
+ )
+ })?;
if response.status().is_success() {
let request_id = response_request_id(&response);
diff --git a/packages/app-lib/src/api/instance/shared/publish.rs b/packages/app-lib/src/api/instance/shared/publish.rs
index c60ae4f806..74b737bd26 100644
--- a/packages/app-lib/src/api/instance/shared/publish.rs
+++ b/packages/app-lib/src/api/instance/shared/publish.rs
@@ -6,6 +6,7 @@ use super::*;
use async_walkdir::WalkDir;
use async_zip::{Compression, ZipEntryBuilder};
use futures::StreamExt;
+use sha2::Digest;
use std::collections::BTreeMap;
#[tracing::instrument]
@@ -1018,12 +1019,18 @@ pub(super) async fn upload_external_files(
"Invalid shared instance external file upload URL: {error}"
))
})?;
+ let (bytes, file_sha512) = tokio::task::spawn_blocking(move || {
+ let hash = format!("{:x}", sha2::Sha512::digest(&bytes));
+ (bytes, hash)
+ })
+ .await?;
let response = send_bytes_request_to_url(
"upload_external_file",
Method::PUT,
upload_url.path(),
&upload.url,
bytes,
+ Some(&file_sha512),
state,
)
.await?;
diff --git a/packages/app-lib/src/api/users.rs b/packages/app-lib/src/api/users.rs
index aa4f1d60ec..7b4c286959 100644
--- a/packages/app-lib/src/api/users.rs
+++ b/packages/app-lib/src/api/users.rs
@@ -57,10 +57,10 @@ pub async fn get_user_projects(user_id: &str) -> crate::Result {
fetch_json(
Method::GET,
- &format!("{}user/{}/projects", env!("MODRINTH_API_URL"), user_id),
+ &format!("{}user/{}/projects", env!("MODRINTH_API_URL_V3"), user_id),
None,
None,
- Some("/v2/user/:id/projects"),
+ Some("/v3/user/:id/projects"),
&state.api_semaphore,
&state.pool,
)
@@ -109,6 +109,51 @@ pub async fn get_user_collections(user_id: &str) -> crate::Result {
.await
}
+#[tracing::instrument]
+pub async fn get_user_preferences(user_id: &str) -> crate::Result {
+ let state = State::get().await?;
+ let user_id = urlencoding::encode(user_id);
+
+ fetch_json(
+ Method::GET,
+ &format!(
+ "{}user/{}/preferences",
+ env!("MODRINTH_API_URL_V3"),
+ user_id
+ ),
+ None,
+ None,
+ Some("/v3/user/:id/preferences"),
+ &state.api_semaphore,
+ &state.pool,
+ )
+ .await
+}
+
+#[tracing::instrument(skip(preferences))]
+pub async fn patch_user_preferences(
+ user_id: &str,
+ preferences: Value,
+) -> crate::Result {
+ let state = State::get().await?;
+ let user_id = urlencoding::encode(user_id);
+
+ fetch_json(
+ Method::PATCH,
+ &format!(
+ "{}user/{}/preferences",
+ env!("MODRINTH_API_URL_V3"),
+ user_id
+ ),
+ None,
+ Some(preferences),
+ Some("/v3/user/:id/preferences"),
+ &state.api_semaphore,
+ &state.pool,
+ )
+ .await
+}
+
#[tracing::instrument(skip(patch))]
pub async fn patch_user(user_id: &str, patch: Value) -> crate::Result<()> {
let state = State::get().await?;
diff --git a/packages/app-lib/src/state/instances/adapters/sqlite/instance_rows.rs b/packages/app-lib/src/state/instances/adapters/sqlite/instance_rows.rs
index 123b67275b..1c829d7308 100644
--- a/packages/app-lib/src/state/instances/adapters/sqlite/instance_rows.rs
+++ b/packages/app-lib/src/state/instances/adapters/sqlite/instance_rows.rs
@@ -730,7 +730,7 @@ pub(crate) async fn update_recent_instance_icon_config(
SELECT rowid
FROM recent_instance_icon_configs
ORDER BY used_at DESC, background, symbol
- LIMIT 8
+ LIMIT 16
)
",
)
@@ -748,7 +748,7 @@ pub(crate) async fn get_recent_instance_icon_configs(
SELECT background, symbol
FROM recent_instance_icon_configs
ORDER BY used_at DESC, background, symbol
- LIMIT 8
+ LIMIT 16
",
)
.fetch_all(pool)
diff --git a/packages/app-lib/src/state/settings.rs b/packages/app-lib/src/state/settings.rs
index 6f64fc7108..7197c7e26b 100644
--- a/packages/app-lib/src/state/settings.rs
+++ b/packages/app-lib/src/state/settings.rs
@@ -19,6 +19,8 @@ pub struct Settings {
pub advanced_rendering: bool,
pub native_decorations: bool,
pub toggle_sidebar: bool,
+ pub sync_theme_across_devices: bool,
+ pub sync_behavior_across_devices: bool,
pub telemetry: bool,
pub discord_rpc: bool,
@@ -60,6 +62,7 @@ pub enum FeatureFlag {
ServerProjectQa,
I18nDebug,
ShowInstancePlayTime,
+ CompactInstanceCards,
SkipNonEssentialWarnings,
AdvancedFiltersCollapsed,
AlwaysShowCopyDetails,
@@ -88,6 +91,7 @@ impl Settings {
hook_pre_launch, hook_wrapper, hook_post_exit,
custom_dir, prev_custom_dir, migrated, json(feature_flags) feature_flags, toggle_sidebar,
skipped_update, pending_update_toast_for_version, auto_download_updates,
+ sync_theme_across_devices, sync_behavior_across_devices,
version
FROM settings
"
@@ -146,6 +150,8 @@ impl Settings {
pending_update_toast_for_version: res
.pending_update_toast_for_version,
auto_download_updates: res.auto_download_updates.map(|x| x == 1),
+ sync_theme_across_devices: res.sync_theme_across_devices == 1,
+ sync_behavior_across_devices: res.sync_behavior_across_devices == 1,
version: res.version as usize,
})
}
@@ -206,7 +212,10 @@ impl Settings {
pending_update_toast_for_version = $30,
auto_download_updates = $31,
- version = $32
+ sync_theme_across_devices = $32,
+ sync_behavior_across_devices = $33,
+
+ version = $34
",
max_concurrent_writes,
max_concurrent_downloads,
@@ -239,6 +248,8 @@ impl Settings {
self.skipped_update,
self.pending_update_toast_for_version,
self.auto_download_updates,
+ self.sync_theme_across_devices,
+ self.sync_behavior_across_devices,
version,
)
.execute(exec)
diff --git a/packages/assets/generated-icons.ts b/packages/assets/generated-icons.ts
index 0bdf625b16..b92e881e3a 100644
--- a/packages/assets/generated-icons.ts
+++ b/packages/assets/generated-icons.ts
@@ -213,6 +213,7 @@ import _PackageIcon from './icons/package.svg?component'
import _PackageClosedIcon from './icons/package-closed.svg?component'
import _PackageOpenIcon from './icons/package-open.svg?component'
import _PackagePlusIcon from './icons/package-plus.svg?component'
+import _PackageSearchIcon from './icons/package-search.svg?component'
import _PageRoundIcon from './icons/page-round.svg?component'
import _PaintbrushIcon from './icons/paintbrush.svg?component'
import _PaletteIcon from './icons/palette.svg?component'
@@ -656,6 +657,7 @@ export const PackageIcon = _PackageIcon
export const PackageClosedIcon = _PackageClosedIcon
export const PackageOpenIcon = _PackageOpenIcon
export const PackagePlusIcon = _PackagePlusIcon
+export const PackageSearchIcon = _PackageSearchIcon
export const PageRoundIcon = _PageRoundIcon
export const PaintbrushIcon = _PaintbrushIcon
export const PaletteIcon = _PaletteIcon
diff --git a/packages/assets/icons/package-search.svg b/packages/assets/icons/package-search.svg
new file mode 100644
index 0000000000..4b5f1eaa0f
--- /dev/null
+++ b/packages/assets/icons/package-search.svg
@@ -0,0 +1,20 @@
+
+
diff --git a/packages/blog/changelog.ts b/packages/blog/changelog.ts
index 50f9932b21..5c828b228d 100644
--- a/packages/blog/changelog.ts
+++ b/packages/blog/changelog.ts
@@ -10,6 +10,65 @@ export type VersionEntry = {
}
const VERSIONS: VersionEntry[] = [
+ {
+ date: `2026-08-20T22:10:10+00:00`,
+ product: 'app',
+ version: '0.18.2',
+ body: `## Security
+- Fixed .mrpack exports replacing the original file with the primary file of a version previously detected on hash lookup.`,
+ },
+ {
+ date: `2026-08-20T21:46:49+00:00`,
+ product: 'app',
+ version: '0.18.1',
+ body: `## Added
+- Added ability to click on the ellipses in pagination controls to go to a specific page.
+- Added compact mode for instances in library.
+- Added drag handle to resize the amount of items in the "Jump in" section.
+- Added icon editing actions for instance context menu.
+- Added back sort by loader and game version.
+- Added new loader symbols for icon editor.
+- Depends on search filter, which filters all projects that depends on the project you've selected.
+- Modpack included content filter, which filters projects that includes a set of projects you've selected.
+- Enabled friend request controls in Social settings - you can now limit who can send you friend requests on Modrinth.
+- Enabled shared instance invite controls in Social settings, letting you limit who can send you shared instance invites.
+- Enabled Modrinth Hosting access invite controls in Social settings - you can now limit who can invite you to manage a Modrinth Hosting server.
+- Added a new option to sync your behaviour & appearance settings across Modrinth App sessions on multiple devices.
+- Added language coverage to the Language settings page.
+
+## Changed
+- Expanded quick instance area now that there is more vertical height in the navbar.
+- Tab bars now display with smaller text and without icons on mobile.
+
+## Fixed
+- Fixed issue where projects not installed display as installed.
+- Fixed tab bars displaying weirdly on mobile.
+- Fixed tab bar shadows being cut off.
+- Fixed tab bars showing the highlight as the wrong size or position sometimes.
+- Fixed missing signal icon and players online in "Jump in" cards.
+- Stopped applying conditional icon padding for user avatars.
+- Fixed memory leak on Discover page`,
+ },
+ {
+ date: `2026-08-20T21:46:49+00:00`,
+ product: 'web',
+ body: `## Added
+- Added ability to click on the ellipses in pagination controls to go to a specific page.
+- Depends on search filter, which filters all projects that depends on the project you've selected.
+- Modpack included content filter, which filters projects that includes a set of projects you've selected.
+- Enabled friend request controls in Social settings - you can now limit who can send you friend requests on Modrinth.
+- Enabled shared instance invite controls in Social settings, letting you limit who can send you shared instance invites.
+- Enabled Modrinth Hosting access invite controls in Social settings - you can now limit who can invite you to manage a Modrinth Hosting server.
+- Added a new option to sync your appearance settings across website sessions on multiple devices.
+- Added language coverage to the Language settings page.
+
+## Fixed
+- Fixed server projects not displaying properly in orgs and collections.
+- Fixed tab bars displaying weirdly on mobile.
+- Fixed tab bar shadows being cut off.
+- Fixed tab bars showing the highlight as the wrong size or position sometimes.
+- Stopped applying conditional icon padding for user avatars.`,
+ },
{
date: `2026-08-18T03:40:19+00:00`,
product: 'web',
diff --git a/packages/ui/src/components/base/Avatar.vue b/packages/ui/src/components/base/Avatar.vue
index ced19b6727..72465ce38b 100644
--- a/packages/ui/src/components/base/Avatar.vue
+++ b/packages/ui/src/components/base/Avatar.vue
@@ -8,7 +8,7 @@
circle: circle,
detecting: !hasDetectedCorners,
'no-shadow': noShadow,
- padded: hasTransparentCorners,
+ padded: hasTransparentCorners && !circle && !disableConditionalIconPadding,
raised: raised,
pixelated: pixelated,
}"
@@ -72,6 +72,7 @@ const props = withDefaults(
size?: string
circle?: boolean
noShadow?: boolean
+ disableConditionalIconPadding?: boolean
loading?: 'eager' | 'lazy'
raised?: boolean
tintBy?: string | null
@@ -82,6 +83,7 @@ const props = withDefaults(
size: '2rem',
circle: false,
noShadow: false,
+ disableConditionalIconPadding: false,
loading: 'eager',
raised: false,
tintBy: null,
diff --git a/packages/ui/src/components/base/Combobox.vue b/packages/ui/src/components/base/Combobox.vue
index 750238a9e1..b25d7adfd3 100644
--- a/packages/ui/src/components/base/Combobox.vue
+++ b/packages/ui/src/components/base/Combobox.vue
@@ -1,10 +1,14 @@
-
+
{
searchTerms?: string[]
}
+export type ComboboxSearchInputVariant = 'surface' | 'button'
+
type OverlayScrollbarsInstance = NonNullable>
type ViewportRect = {
width: number
@@ -274,6 +282,7 @@ const props = withDefaults(
placeholder?: string
disabled?: boolean
searchable?: boolean
+ clearable?: boolean
searchPlaceholder?: string
listbox?: boolean
showChevron?: boolean
@@ -311,11 +320,13 @@ const props = withDefaults(
searchAutocapitalize?: 'none' | 'off' | 'sentences' | 'words' | 'characters'
searchSpellcheck?: boolean
searchInputAttrs?: Record
+ searchInputVariant?: ComboboxSearchInputVariant
}>(),
{
placeholder: 'Select an option',
disabled: false,
searchable: false,
+ clearable: false,
searchPlaceholder: 'Search...',
listbox: true,
showChevron: true,
@@ -327,6 +338,7 @@ const props = withDefaults(
selectSearchTextOnFocus: false,
showSearchIcon: false,
searchType: 'text',
+ searchInputVariant: 'surface',
triggerType: 'base',
triggerSize: 'md',
triggerInteraction: 'surface',
@@ -397,8 +409,8 @@ const searchSelectionOverlayVisible = computed(() => {
return true
})
-const searchableInputClass = computed(() => {
- const parts = ['!bg-transparent']
+const resolvedSearchInputClass = computed(() => {
+ const parts = [props.searchInputVariant === 'button' ? '!bg-button-bg' : '!bg-transparent']
if (props.showChevron) parts.push('!pr-9')
if (searchSelectionOverlayVisible.value) {
parts.push('!text-transparent [caret-color:var(--color-text-primary)] selection:bg-transparent')
@@ -478,7 +490,7 @@ function setInitialFocus() {
function determineOpenDirection(
triggerRect: DOMRect,
- dropdownRect: DOMRect,
+ dropdownRect: { width: number; height: number },
viewport: ViewportRect,
): 'up' | 'down' {
if (props.forceDirection) {
@@ -499,7 +511,7 @@ function determineOpenDirection(
function calculateVerticalPosition(
triggerRect: DOMRect,
- dropdownRect: DOMRect,
+ dropdownRect: { width: number; height: number },
direction: 'up' | 'down',
viewport: ViewportRect,
): number {
@@ -513,7 +525,7 @@ function calculateVerticalPosition(
function calculateHorizontalPosition(
triggerRect: DOMRect,
- dropdownRect: DOMRect,
+ dropdownRect: { width: number; height: number },
viewport: ViewportRect,
): number {
const minLeft = viewport.offsetLeft + DROPDOWN_VIEWPORT_MARGIN
@@ -556,7 +568,7 @@ async function updateDropdownPosition() {
await nextTick()
const triggerRect = effectiveTriggerEl.value.getBoundingClientRect()
- const width = resolveDropdownWidth(triggerRect.width)
+ const width = resolveDropdownWidth(effectiveTriggerEl.value.offsetWidth)
const minWidth = resolveCssSize(props.dropdownMinWidth) ?? '0px'
dropdownStyle.value = {
@@ -567,7 +579,10 @@ async function updateDropdownPosition() {
await nextTick()
- const dropdownRect = dropdownRef.value.getBoundingClientRect()
+ const dropdownRect = {
+ width: dropdownRef.value.offsetWidth,
+ height: dropdownRef.value.offsetHeight,
+ }
const viewport = getViewportRect()
const direction = determineOpenDirection(triggerRect, dropdownRect, viewport)
@@ -838,6 +853,12 @@ function handleSearchInput() {
}
}
+function handleSearchClear() {
+ userHasTyped.value = true
+ emit('searchInput', searchQuery.value)
+ closeDropdown()
+}
+
function handleSearchFocus(event: FocusEvent) {
const target = event.target
if (props.selectSearchTextOnFocus && target instanceof HTMLInputElement) {
diff --git a/packages/ui/src/components/base/NavTabs.vue b/packages/ui/src/components/base/NavTabs.vue
index 005af9d86f..1958a4498b 100644
--- a/packages/ui/src/components/base/NavTabs.vue
+++ b/packages/ui/src/components/base/NavTabs.vue
@@ -1,68 +1,88 @@
-
+
diff --git a/packages/ui/src/components/base/buttons/ButtonLink.vue b/packages/ui/src/components/base/buttons/ButtonLink.vue
index cb11c7c6c6..08d59351cf 100644
--- a/packages/ui/src/components/base/buttons/ButtonLink.vue
+++ b/packages/ui/src/components/base/buttons/ButtonLink.vue
@@ -38,6 +38,11 @@ const resolvedRel = computed(() => {
return props.target === '_blank' ? 'noopener noreferrer' : undefined
})
+// https://github.com/vuejs/vue-router/issues/2005 moment
+const linkAttrs = computed(() =>
+ usesRouter.value ? { to: props.to } : !props.disabled ? { href: props.href } : {},
+)
+
function handleClick(event: MouseEvent) {
if (!props.disabled) return
event.preventDefault()
@@ -52,8 +57,7 @@ function handleClick(event: MouseEvent) {
:color="props.color"
:size="props.size"
:interaction="props.interaction"
- :to="usesRouter ? props.to : undefined"
- :href="!usesRouter && !props.disabled ? props.href : undefined"
+ v-bind="linkAttrs"
:target="props.target"
:rel="resolvedRel"
:download="!props.disabled ? props.download : undefined"
diff --git a/packages/ui/src/components/base/index.ts b/packages/ui/src/components/base/index.ts
index 4788f6715a..5d9d7cb25a 100644
--- a/packages/ui/src/components/base/index.ts
+++ b/packages/ui/src/components/base/index.ts
@@ -38,7 +38,7 @@ export { default as Collapsible } from './Collapsible.vue'
export type { CollapsibleAdmonitionItem } from './CollapsibleAdmonition.vue'
export { default as CollapsibleAdmonition } from './CollapsibleAdmonition.vue'
export { default as CollapsibleRegion } from './CollapsibleRegion.vue'
-export type { ComboboxOption } from './Combobox.vue'
+export type { ComboboxOption, ComboboxSearchInputVariant } from './Combobox.vue'
export { default as Combobox } from './Combobox.vue'
export { default as CopyCode } from './CopyCode.vue'
export { default as DatePicker } from './DatePicker.vue'
diff --git a/packages/ui/src/components/index.ts b/packages/ui/src/components/index.ts
index 6d99b45fcb..4b2ddfcd39 100644
--- a/packages/ui/src/components/index.ts
+++ b/packages/ui/src/components/index.ts
@@ -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'
diff --git a/packages/ui/src/components/modal/TabbedModal.vue b/packages/ui/src/components/modal/TabbedModal.vue
index 7dfc1847a8..04f4291487 100644
--- a/packages/ui/src/components/modal/TabbedModal.vue
+++ b/packages/ui/src/components/modal/TabbedModal.vue
@@ -2,10 +2,11 @@
diff --git a/packages/ui/src/components/project/index.ts b/packages/ui/src/components/project/index.ts
index fc6024d2e6..cf8067ebb3 100644
--- a/packages/ui/src/components/project/index.ts
+++ b/packages/ui/src/components/project/index.ts
@@ -7,6 +7,7 @@ export { default as ProjectCard } from './card/ProjectCard.vue'
export { default as ProjectBackgroundGradient } from './ProjectBackgroundGradient.vue'
export { default as ProjectCardList } from './ProjectCardList.vue'
export { default as ProjectCombobox } from './ProjectCombobox.vue'
+export { default as ProjectList } from './ProjectList.vue'
export { default as ProjectPageDescription } from './ProjectPageDescription.vue'
export { default as ProjectPageHeader } from './ProjectPageHeader.vue'
export { default as ProjectPageVersions } from './ProjectPageVersions.vue'
diff --git a/packages/ui/src/components/search/SearchDependsOnFilter.vue b/packages/ui/src/components/search/SearchDependsOnFilter.vue
new file mode 100644
index 0000000000..996c3eb9c9
--- /dev/null
+++ b/packages/ui/src/components/search/SearchDependsOnFilter.vue
@@ -0,0 +1,401 @@
+
+
+
+
+
+
![]()
+
+
+ {{ project.title }}
+
+
+
+
+
+
+
+
+
+
+
+
+
![]()
+
+
+
+ {{ selectedProject?.title ?? selectedProjectId }}
+
+
+
+
+
+ {{ dependencyTypeLabel }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/packages/ui/src/components/search/SearchFilterControl.vue b/packages/ui/src/components/search/SearchFilterControl.vue
index a2a9186c1f..9d5b6c0e84 100644
--- a/packages/ui/src/components/search/SearchFilterControl.vue
+++ b/packages/ui/src/components/search/SearchFilterControl.vue
@@ -1,15 +1,32 @@
Clear all filters
+
+
+ {{
+ formatMessage(includedProjectsMessage, {
+ projects: formatProjectNames(selectedIncludedProjectItems),
+ })
+ }}
+
+
+
+
+ {{
+ formatMessage(excludedProjectsMessage, {
+ projects: formatProjectNames(selectedExcludedProjectItems),
+ })
+ }}
+
@@ -37,23 +54,28 @@
-
-
-
-
-
-
-
- {{
- isQueryEmpty()
- ? ''
- : formatMessage(messages.searchResultsAnnouncement, {
- matches: $searchResults.get('searchResult')?.length ?? 0,
- })
- }}
-
-
-
-
-
-
- {{ getCategoryName(category) }}
-
-
-
- {{ formatMessage(messages.noResults) }}
-
-
-
- onItemClick(e, loc)"
- @keydown="(e) => onItemKeydown(e, loc)"
- >
-
-
-
-
-
- {{ loc.displayName }}
-
-
-
- {{ loc.translatedName }}
-
-
-
-
-
-
-
-
diff --git a/packages/ui/src/components/settings/index.ts b/packages/ui/src/components/settings/index.ts
deleted file mode 100644
index c7efceb5dc..0000000000
--- a/packages/ui/src/components/settings/index.ts
+++ /dev/null
@@ -1,2 +0,0 @@
-export { default as LanguageSelector } from './LanguageSelector.vue'
-export { default as ThemeSelector } from './ThemeSelector.vue'
diff --git a/packages/ui/src/layouts/index.ts b/packages/ui/src/layouts/index.ts
index d027ea875e..e2cc22785e 100644
--- a/packages/ui/src/layouts/index.ts
+++ b/packages/ui/src/layouts/index.ts
@@ -1,3 +1,4 @@
+export * from './shared/appearance-settings'
export * from './shared/browse-tab'
export * from './shared/console'
export * from './shared/content-tab'
diff --git a/packages/ui/src/layouts/shared/appearance-settings/components/AppearanceSettingRow.vue b/packages/ui/src/layouts/shared/appearance-settings/components/AppearanceSettingRow.vue
new file mode 100644
index 0000000000..1abded2318
--- /dev/null
+++ b/packages/ui/src/layouts/shared/appearance-settings/components/AppearanceSettingRow.vue
@@ -0,0 +1,37 @@
+
+
+
+
+
+
+ {{ title }}
+
+
+ {{ description }}
+
+
+
+
+
+
+
diff --git a/packages/ui/src/layouts/shared/appearance-settings/components/ProjectLayoutSelector.vue b/packages/ui/src/layouts/shared/appearance-settings/components/ProjectLayoutSelector.vue
new file mode 100644
index 0000000000..59a6fa0b1b
--- /dev/null
+++ b/packages/ui/src/layouts/shared/appearance-settings/components/ProjectLayoutSelector.vue
@@ -0,0 +1,76 @@
+
+
+
+
+
diff --git a/packages/ui/src/layouts/shared/appearance-settings/components/appearance-settings-theme-selector.stories.ts b/packages/ui/src/layouts/shared/appearance-settings/components/appearance-settings-theme-selector.stories.ts
new file mode 100644
index 0000000000..69a404bb83
--- /dev/null
+++ b/packages/ui/src/layouts/shared/appearance-settings/components/appearance-settings-theme-selector.stories.ts
@@ -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
+
+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: `
+
+ `,
+ }),
+}
diff --git a/packages/ui/src/components/settings/ThemeSelector.vue b/packages/ui/src/layouts/shared/appearance-settings/components/appearance-settings-theme-selector.vue
similarity index 67%
rename from packages/ui/src/components/settings/ThemeSelector.vue
rename to packages/ui/src/layouts/shared/appearance-settings/components/appearance-settings-theme-selector.vue
index 80ec1be2dc..639dc0df29 100644
--- a/packages/ui/src/components/settings/ThemeSelector.vue
+++ b/packages/ui/src/layouts/shared/appearance-settings/components/appearance-settings-theme-selector.vue
@@ -1,25 +1,22 @@
-
+