diff --git a/.github/workflows/check-generic.yml b/.github/workflows/check-generic.yml
index 1953ccedf4..25fc4ad8ef 100644
--- a/.github/workflows/check-generic.yml
+++ b/.github/workflows/check-generic.yml
@@ -20,8 +20,8 @@ jobs:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- - uses: taiki-e/install-action@fd2f5e3d644b484055ebf4268f474c565f148f25 # v2.81.9
+ - uses: taiki-e/install-action@b6ff580856c41316412a0b9b60540fbc6f8c82cc # v2.86.7
with:
- tool: tombi
+ tool: tombi@1.4.1
- run: tombi lint
- run: tombi fmt --check
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/.idea/code.iml b/.idea/code.iml
index 3861923c60..a1d7c854b4 100644
--- a/.idea/code.iml
+++ b/.idea/code.iml
@@ -28,6 +28,7 @@
+
diff --git a/Cargo.lock b/Cargo.lock
index 39f5e354f4..51fb4abe6c 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -2239,6 +2239,16 @@ dependencies = [
"static_assertions",
]
+[[package]]
+name = "component-derive"
+version = "0.0.0"
+dependencies = [
+ "darling 0.23.0",
+ "proc-macro2",
+ "quote",
+ "syn 2.0.106",
+]
+
[[package]]
name = "compression-codecs"
version = "0.4.31"
@@ -2682,10 +2692,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",
@@ -5497,6 +5507,7 @@ dependencies = [
"clickhouse",
"color-eyre",
"color-thief",
+ "component-derive",
"const_format",
"dashmap",
"derive_more 2.1.1",
@@ -5574,16 +5585,6 @@ dependencies = [
"zxcvbn",
]
-[[package]]
-name = "labrinth-derive"
-version = "0.0.0"
-dependencies = [
- "darling 0.23.0",
- "proc-macro2",
- "quote",
- "syn 2.0.106",
-]
-
[[package]]
name = "language-tags"
version = "0.3.2"
@@ -9449,18 +9450,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 0283fe8182..c6483af252 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -8,8 +8,8 @@ members = [
"packages/aditude",
"packages/app-lib",
"packages/ariadne",
+ "packages/component-derive",
"packages/daedalus",
- "packages/labrinth-derive",
"packages/modrinth-content-management",
"packages/modrinth-log",
"packages/modrinth-maxmind",
@@ -43,9 +43,7 @@ async-stripe = { version = "0.41.0", default-features = false, features = [
"runtime-tokio-hyper-rustls",
] }
async-trait = "0.1.89"
-async-tungstenite = { version = "0.31.0", default-features = false, features = [
- "futures-03-sink"
-] }
+async-tungstenite = { version = "0.31.0", default-features = false, features = ["futures-03-sink"] }
async-walkdir = "2.1.0"
async_zip = "0.0.18"
aws-sdk-s3 = { version = "=1.122.0", default-features = false, features = [
@@ -63,13 +61,12 @@ censor = "0.3.0"
chardetng = "0.1.17"
chrono = "0.4.42"
chumsky = "0.9.3"
-cidre = { version = "0.15.0", default-features = false, features = [
- "macos_15_0"
-] }
+cidre = { version = "0.15.0", default-features = false, features = ["macos_15_0"] }
clap = "4.5.48"
clickhouse = "0.14.0"
color-eyre = "0.6.5"
color-thief = "0.2.2"
+component-derive = { path = "packages/component-derive" }
const_format = "0.2.34"
core-foundation = "0.10.1"
core-graphics = "0.24.0"
@@ -164,10 +161,7 @@ redis = "1.4.1"
regex = "1.12.2"
reqwest = { version = "0.12.24", default-features = false }
rgb = "0.8.52"
-rust_decimal = { version = "1.39.0", features = [
- "serde-with-float",
- "serde-with-str"
-] }
+rust_decimal = { version = "1.39.0", features = ["serde-with-float", "serde-with-str"] }
rust_iso3166 = "0.1.14"
rust-s3 = { version = "0.37.0", default-features = false, features = [
"fail-on-err",
@@ -193,7 +187,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/_typos.toml b/_typos.toml
index 7775afa964..944064d355 100644
--- a/_typos.toml
+++ b/_typos.toml
@@ -6,13 +6,16 @@ extend-exclude = [
"packages/utils/",
"packages/ui/",
"packages/blog/",
- # contains licenses like `CC-BY-ND-4.0`
- "packages/moderation/src/data/stages/license.ts",
- "packages/moderation/src/utils.ts",
- # contains payment card IDs like `IY1VMST1MOXS` which are flagged
- "apps/labrinth/src/queue/payouts/mod.rs",
- # contains domain names with deliberate typos
- "apps/labrinth/assets/disposable_email_blocklist.txt"
+]
+
+[default]
+extend-ignore-re = [
+ # Ignore lines that end with `// typos:ignore`
+ "(?Rm)^.*(#|//)\\s*typos:ignore$",
+ # Ignore the line after `// typos:ignore`:
+ "(#|//)\\s*typos:ignore\\n.*",
+ # Ignore blocks between `// typos:off` and `// typos:on`
+ "(?s)(#|//)\\s*typos:off.*?\\n\\s*(#|//)\\s*typos:on",
]
[default.extend-words]
diff --git a/apps/app-frontend/package.json b/apps/app-frontend/package.json
index 18ff367db0..abe6e55c0f 100644
--- a/apps/app-frontend/package.json
+++ b/apps/app-frontend/package.json
@@ -14,6 +14,7 @@
"test": "vue-tsc --noEmit"
},
"dependencies": {
+ "@dnd-kit/vue": "^0.5.0",
"@modrinth/api-client": "workspace:^",
"@modrinth/assets": "workspace:*",
"@modrinth/ui": "workspace:*",
@@ -37,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 3db155776b..33afd4d216 100644
--- a/apps/app-frontend/src/App.vue
+++ b/apps/app-frontend/src/App.vue
@@ -13,11 +13,10 @@ import {
ChevronLeftIcon,
ChevronRightIcon,
CompassIcon,
- HomeIcon,
- LibraryIcon,
LogInIcon,
LogOutIcon,
NewspaperIcon,
+ PlayIcon,
PlusIcon,
RefreshCwIcon,
RightArrowIcon,
@@ -57,7 +56,7 @@ import {
import { renderString } from '@modrinth/utils'
import { useQuery, useQueryClient } from '@tanstack/vue-query'
import { getVersion } from '@tauri-apps/api/app'
-import { invoke } from '@tauri-apps/api/core'
+import { convertFileSrc, invoke } from '@tauri-apps/api/core'
import { getCurrentWindow } from '@tauri-apps/api/window'
import { fetch as tauriFetch } from '@tauri-apps/plugin-http'
import { openUrl } from '@tauri-apps/plugin-opener'
@@ -74,6 +73,7 @@ import FriendsList from '@/components/ui/friends/FriendsList.vue'
import HostingUpdateRequired from '@/components/ui/HostingUpdateRequired.vue'
import AddServerToInstanceModal from '@/components/ui/install_flow/AddServerToInstanceModal.vue'
import UnknownPackWarningModal from '@/components/ui/install_flow/UnknownPackWarningModal.vue'
+import IconEditorModal from '@/components/ui/instance_settings/icon-editor-modal/index.vue'
import MinecraftAuthErrorModal from '@/components/ui/minecraft-auth-error-modal/MinecraftAuthErrorModal.vue'
import MinecraftRequiredModal from '@/components/ui/minecraft-required-modal/MinecraftRequiredModal.vue'
import AppSettingsModal from '@/components/ui/modal/AppSettingsModal.vue'
@@ -82,6 +82,9 @@ import ModpackAlreadyInstalledModal from '@/components/ui/modal/ModpackAlreadyIn
import ModrinthAccountRequiredModal from '@/components/ui/modal/ModrinthAccountRequiredModal.vue'
import UpdateToPlayModal from '@/components/ui/modal/UpdateToPlayModal.vue'
import NavButton from '@/components/ui/NavButton.vue'
+import NewIconEditorNotification from '@/components/ui/new-icon-editor-notification/index.vue'
+import { shouldShowNewIconEditorNotification } from '@/components/ui/new-icon-editor-notification/show-notification'
+import OnboardingChecklist from '@/components/ui/onboarding-checklist/index.vue'
import PrideFundraiserBanner from '@/components/ui/PrideFundraiserBanner.vue'
import PromotionWrapper from '@/components/ui/PromotionWrapper.vue'
import QuickInstanceSwitcher from '@/components/ui/QuickInstanceSwitcher.vue'
@@ -91,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,
@@ -144,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'
@@ -154,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()
@@ -198,7 +204,7 @@ const credentials = ref()
let credentialsRefreshId = 0
const sidebarToggled = ref(true)
watch(
- () => themeStore.toggleSidebar,
+ () => appSettings.toggleSidebar,
(toggleSidebar) => {
sidebarToggled.value = !toggleSidebar
},
@@ -218,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
@@ -248,7 +254,7 @@ useAppEvent(
'warning',
(event) =>
addNotification({
- title: 'Warning',
+ title: formatMessage(messages.warning),
text: event.message,
type: 'warning',
}),
@@ -321,30 +327,79 @@ 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(),
})
+const creationIconEditorModal = ref(null)
+const creationGeneratedIcon = ref(null)
+const creationIconTarget = ref('creation-flow')
+
const {
installationModal,
unknownPackWarningModal,
fetchExistingInstanceNames,
handleCreate,
handleBrowseModpacks,
- searchModpacks,
- getProjectVersions,
+ searchProjects,
getLoaderManifest,
setModpackAlreadyInstalledModal,
handleModpackDuplicateCreateAnyway,
handleModpackDuplicateGoToInstance,
-} = setupProviders(tauriApiClient, notificationManager, popupNotificationManager)
+ onboardingChecklist,
+} = setupProviders(
+ tauriApiClient,
+ notificationManager,
+ popupNotificationManager,
+ appEvents,
+ (iconPath) =>
+ creationGeneratedIcon.value?.path === iconPath ? creationGeneratedIcon.value.config : null,
+)
+const { hasLoggedIntoMinecraft, hasLoggedIntoModrinth, showChecklist } = onboardingChecklist
+const showFriendsList = computed(() => !showChecklist.value || hasLoggedIntoModrinth.value)
+
+async function randomizeCreationIcon() {
+ const generated = await creationIconEditorModal.value?.randomizeAndSave()
+ if (!generated) return null
+
+ creationGeneratedIcon.value = { path: generated.iconPath, config: generated.config }
+ return {
+ path: generated.iconPath,
+ previewUrl: convertFileSrc(generated.iconPath),
+ }
+}
+
+function customizeCreationIcon() {
+ creationIconTarget.value = 'creation-flow'
+ creationIconEditorModal.value?.show()
+}
+
+function customizeContentInstallIcon() {
+ creationIconTarget.value = 'content-install'
+ creationIconEditorModal.value?.show()
+}
+
+function onCreationIconSaved(iconPath, config) {
+ creationGeneratedIcon.value = { path: iconPath, config }
+ if (creationIconTarget.value === 'content-install') {
+ modInstallModal.value?.setIcon(iconPath, convertFileSrc(iconPath))
+ return
+ }
+
+ const context = installationModal.value?.ctx
+ if (!context) return
+
+ context.instanceIcon.value = null
+ context.instanceIconUrl.value = convertFileSrc(iconPath)
+ context.instanceIconPath.value = iconPath
+}
const news = ref([])
const displayedServerInviteNotifications = new Set()
@@ -360,7 +415,6 @@ window.addEventListener('online', () => {
offline.value = false
})
-const showOnboarding = ref(false)
const nativeDecorations = ref(false)
const os = ref('')
@@ -425,6 +479,15 @@ const { formatMessage } = useVIntl()
const formatBytes = useFormatBytes()
const messages = defineMessages({
+ warning: { id: 'app.notification.warning', defaultMessage: 'Warning' },
+ moreOptions: { id: 'app.navigation.more-options', defaultMessage: 'More options' },
+ goBack: { id: 'app.navigation.go-back', defaultMessage: 'Go back' },
+ goForward: { id: 'app.navigation.go-forward', defaultMessage: 'Go forward' },
+ nextImage: { id: 'app.navigation.next-image', defaultMessage: 'Next image' },
+ updateDownloadMissingVersion: {
+ id: 'app.update.download-error.missing-version',
+ defaultMessage: 'Failed to download update: no version available',
+ },
updateInstalledToastTitle: {
id: 'app.update.complete-toast.title',
defaultMessage: 'Version {version} was successfully installed!',
@@ -467,10 +530,6 @@ const messages = defineMessages({
id: 'app.nav.home',
defaultMessage: 'Home',
},
- library: {
- id: 'app.nav.library',
- defaultMessage: 'Library',
- },
modrinthHosting: {
id: 'app.nav.modrinth-hosting',
defaultMessage: 'Modrinth Hosting',
@@ -530,6 +589,7 @@ function handleAdsConsentRequired(required) {
}
const notification = addPopupNotification({
+ contentType: 'standard',
title: formatMessage(messages.adsConsentTitle),
text: formatMessage(messages.adsConsentBody),
type: 'info',
@@ -562,17 +622,26 @@ function handleAdsConsentRequired(required) {
}
async function setupApp() {
+ await onboardingChecklist.initialize()
+
+ if (shouldShowNewIconEditorNotification(showChecklist.value)) {
+ addPopupNotification({
+ contentType: 'custom',
+ component: NewIconEditorNotification,
+ autoCloseMs: null,
+ })
+ }
+
const {
native_decorations,
theme,
locale,
telemetry,
- collapsed_navigation,
hide_nametag_skins_page,
advanced_rendering,
- onboarded,
- default_page,
toggle_sidebar,
+ sync_theme_across_devices,
+ sync_behavior_across_devices,
developer_mode,
feature_flags,
pending_update_toast_for_version,
@@ -583,26 +652,21 @@ async function setupApp() {
i18n.global.locale.value = locale
}
- if (default_page === 'Library') {
- await router.push('/library')
- }
-
os.value = await getOS()
const dev = await isDev()
isDevEnvironment.value = dev
const version = await getVersion()
- showOnboarding.value = !onboarded
-
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()
@@ -614,7 +678,7 @@ async function setupApp() {
if (telemetry) {
initAnalytics()
if (dev) debugAnalytics()
- trackEvent('Launched', { version, dev, onboarded })
+ trackEvent('Launched', { version, dev })
}
if (!dev) document.addEventListener('contextmenu', (event) => event.preventDefault())
@@ -818,6 +882,7 @@ const {
projectInfo: contentInstallProjectInfo,
handleInstallToInstance,
handleCreateAndInstall,
+ prepareNewInstance,
handleNavigate: handleContentInstallNavigate,
handleCancel: handleContentInstallCancel,
setContentInstallModal,
@@ -837,6 +902,35 @@ const {
handleIncompatibilityWarningCancel: handleContentInstallIncompatibilityWarningCancel,
} = contentInstall
+async function prepareCreationProjectInstall(projectId, projectType) {
+ if (projectType === 'modpack') {
+ await contentInstall.install(
+ projectId,
+ null,
+ null,
+ 'CreationModalProject',
+ undefined,
+ (instanceId) => void router.push(`/instance/${encodeURIComponent(instanceId)}`),
+ )
+ return null
+ }
+
+ await prepareNewInstance(projectId)
+ const info = contentInstallProjectInfo.value
+ if (!info) throw new Error(`Project information is unavailable: '${projectId}'`)
+
+ return {
+ projectId,
+ title: info.title,
+ iconUrl: info.iconUrl,
+ link: info.link,
+ owner: info.owner,
+ compatibleLoaders: [...contentInstallLoaders.value],
+ gameVersions: [...contentInstallGameVersions.value],
+ releaseGameVersions: new Set(contentInstallReleaseGameVersions.value),
+ }
+}
+
const serverInstall = createServerInstall({
router,
handleError,
@@ -870,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 {
@@ -878,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`, {
@@ -1090,17 +1258,16 @@ async function handleLiveNotification(notification) {
if (generation !== liveNotificationGeneration) return
const popupNotification = addPopupNotification({
+ contentType: 'toast',
title: serverName,
+ type: 'server-invite',
+ actorName: invitedBy?.username ?? null,
+ actorAvatarUrl: invitedBy?.avatar_url ?? null,
+ entityName: serverName,
autoCloseMs: null,
- toast: {
- type: 'server-invite',
- actorName: invitedBy?.username ?? null,
- actorAvatarUrl: invitedBy?.avatar_url ?? null,
- entityName: serverName,
- onAccept: () => acceptServerInviteNotification(notification),
- onDecline: () => declineServerInviteNotification(notification),
- onOpenActor: () => openServerInviteInviterProfile(invitedBy?.username ?? null),
- },
+ onAccept: () => acceptServerInviteNotification(notification),
+ onDecline: () => declineServerInviteNotification(notification),
+ onOpenActor: () => openServerInviteInviterProfile(invitedBy?.username ?? null),
})
serverInvitePopupNotificationIds.add(popupNotification.id)
}
@@ -1277,6 +1444,7 @@ function showDelayedUpdatePopup() {
if (metered.value && !finishedDownloading.value) {
addPopupNotification({
+ contentType: 'standard',
title: formatMessage(updatePopupMessages.updateAvailable),
text: formatMessage(updatePopupMessages.meteredBody, { version: update.version }),
type: 'info',
@@ -1298,6 +1466,7 @@ function showDelayedUpdatePopup() {
})
} else if (finishedDownloading.value) {
addPopupNotification({
+ contentType: 'standard',
title: formatMessage(updatePopupMessages.downloadComplete),
text: formatMessage(updatePopupMessages.downloadedBody, {
version: update.version,
@@ -1396,6 +1565,7 @@ async function checkLinuxUpdates() {
const nextPopupTime = getNextAppUpdatePopupTime(latestVersion)
if (nextPopupTime !== null && Date.now() >= nextPopupTime) {
addPopupNotification({
+ contentType: 'standard',
title: formatMessage(updatePopupMessages.updateAvailable),
text: formatMessage(updatePopupMessages.linuxBody, { version: latestVersion }),
type: 'info',
@@ -1415,7 +1585,7 @@ async function downloadAvailableUpdate() {
async function downloadUpdate(versionToDownload) {
if (!versionToDownload) {
- handleError(`Failed to download update: no version available`)
+ handleError(formatMessage(messages.updateDownloadMissingVersion))
return
}
@@ -1432,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()
@@ -1551,7 +1720,7 @@ provideAppUpdateDownloadProgress(appUpdateDownload)
+
-
-
+
+
-
-
-
@@ -1773,7 +1945,7 @@ provideAppUpdateDownloadProgress(appUpdateDownload)
{{ route.fullPath }}
@@ -1808,7 +1980,9 @@ provideAppUpdateDownloadProgress(appUpdateDownload)
-
+
+
+
@@ -1823,9 +1997,17 @@ provideAppUpdateDownloadProgress(appUpdateDownload)
:class="{ 'pb-12': !hasPlus }"
data-overlayscrollbars-initialize
>
+
- {
- 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/DefaultInstanceSettings.vue b/apps/app-frontend/src/components/ui/settings/instances/DefaultInstanceSettings.vue
index fcfa509e8b..ebe9e9af7b 100644
--- a/apps/app-frontend/src/components/ui/settings/instances/DefaultInstanceSettings.vue
+++ b/apps/app-frontend/src/components/ui/settings/instances/DefaultInstanceSettings.vue
@@ -2,15 +2,15 @@
import {
defineMessages,
injectNotificationManager,
+ Input,
Slider,
- StyledInput,
Toggle,
useVIntl,
} from '@modrinth/ui'
import { ref, watch } from 'vue'
import useMemorySlider from '@/composables/useMemorySlider'
-import { get, set } from '@/helpers/settings.ts'
+import { get, parseEnvVars, serializeEnvVars, set } from '@/helpers/settings.ts'
const { handleError } = injectNotificationManager()
const { formatMessage } = useVIntl()
@@ -149,7 +149,7 @@ const messages = defineMessages({
const fetchSettings = await get()
fetchSettings.launchArgs = fetchSettings.extra_launch_args.join(' ')
-fetchSettings.envVars = fetchSettings.custom_env_vars.map((x) => x.join('=')).join(' ')
+fetchSettings.envVars = serializeEnvVars(fetchSettings.custom_env_vars)
const settings = ref(fetchSettings)
@@ -164,27 +164,15 @@ watch(
const setSettings = JSON.parse(JSON.stringify(settings.value))
setSettings.extra_launch_args = setSettings.launchArgs.trim().split(/\s+/).filter(Boolean)
- setSettings.custom_env_vars = setSettings.envVars
- .trim()
- .split(/\s+/)
- .filter(Boolean)
- .map((x) => x.split('=').filter(Boolean))
-
- if (!setSettings.hooks.pre_launch) {
- setSettings.hooks.pre_launch = null
- }
- if (!setSettings.hooks.wrapper) {
- setSettings.hooks.wrapper = null
- }
- if (!setSettings.hooks.post_exit) {
- setSettings.hooks.post_exit = null
- }
+ setSettings.custom_env_vars = parseEnvVars(setSettings.envVars)
+ delete setSettings.launchArgs
+ delete setSettings.envVars
if (!setSettings.custom_dir) {
setSettings.custom_dir = null
}
- await set(setSettings)
+ await set(setSettings).catch(handleError)
},
{ deep: true },
)
@@ -216,7 +204,7 @@ watch(
-
-
{{ formatMessage(messages.javaArgumentsTitle) }}
-
{{ formatMessage(messages.environmentVariablesTitle) }}
-
{{ formatMessage(messages.preLaunchHookTitle) }}
-
{{ formatMessage(messages.wrapperHookTitle) }}
-
{{ formatMessage(messages.postExitHookTitle) }}
-
{{ formatMessage(messages.appDirectoryTitle) }}
-
-
+
{{ formatMessage(messages.appDirectoryDescription) }}
@@ -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 5b4ae92df1..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
}
@@ -225,22 +225,21 @@ export function useSharedInstanceInviteHandler(
displayedNotificationKeys.add(notificationKey)
const popupNotification = popupNotificationManager.addPopupNotification({
+ contentType: 'toast',
title: invite.sharedInstanceName,
+ type: 'instance-invite',
+ actorName: invite.invitedByUsername,
+ actorAvatarUrl: invite.invitedByAvatarUrl ?? undefined,
+ entityName: invite.sharedInstanceName,
+ entityIconUrl: invite.instanceIconUrl ?? undefined,
autoCloseMs: null,
- toast: {
- type: 'instance-invite',
- actorName: invite.invitedByUsername,
- actorAvatarUrl: invite.invitedByAvatarUrl ?? undefined,
- entityName: invite.sharedInstanceName,
- entityIconUrl: invite.instanceIconUrl ?? undefined,
- onAccept: () => acceptNotification(notification, invite),
- onDecline: () =>
- markNotificationRead(notification).catch((error) => handleError(toError(error))),
- onOpenActor: () => {
- if (invite.invitedByUsername) {
- void router.push(`/user/${encodeURIComponent(invite.invitedByUsername)}`)
- }
- },
+ onAccept: () => acceptNotification(notification, invite),
+ onDecline: () =>
+ markNotificationRead(notification).catch((error) => handleError(toError(error))),
+ onOpenActor: () => {
+ if (invite.invitedByUsername) {
+ void router.push(`/user/${encodeURIComponent(invite.invitedByUsername)}`)
+ }
},
})
popupNotificationIds.add(popupNotification.id)
diff --git a/apps/app-frontend/src/components/ui/skin/EditSkinModal.vue b/apps/app-frontend/src/components/ui/skin/EditSkinModal.vue
index fe7968a3f7..ee32e2e9cc 100644
--- a/apps/app-frontend/src/components/ui/skin/EditSkinModal.vue
+++ b/apps/app-frontend/src/components/ui/skin/EditSkinModal.vue
@@ -19,7 +19,7 @@
/>
-
+
{{ formatMessage(messages.textureSection) }}