Compare commits

...
Author SHA1 Message Date
Michael H. 8b753a52ad fix: build labrinth on prod again 2026-07-31 14:45:16 +02:00
Michael H. a53c9c3771 fix: only deploy if internal 2026-07-31 13:21:35 +02:00
Michael H. 919b235621 chore: address action issues 2026-07-31 13:17:26 +02:00
Michael H. 18b0b2848d feat: labrinth argo 2026-07-31 13:10:58 +02:00
Prospector 65a3ac4b34 changelog 2026-07-30 23:10:28 -07:00
ProspectorandGitHub 295db3fb7a only refresh tokens and clear auth cookies on auth fails, not any ran… (#6947)
only refresh tokens and clear auth cookies on auth fails, not any random error
2026-07-30 23:10:08 -07:00
Michael H. 7abe6f8c73 feat: sccache on windows 2026-07-30 20:27:08 +02:00
Michael H. c5ce5bc9b3 fix: create clickhouse db in tests only 2026-07-30 20:08:49 +02:00
Michael H. 572b8027ff fix: clickhouse migrations should not run normally 2026-07-30 19:23:10 +02:00
aecsocketandGitHub 0a53211389 fix: analytics event endpoint (#6938)
* fix: analytics event endpoint

* split admin-only routes into internal

* fix pr comment
2026-07-30 04:40:52 +00:00
Prospector 22b2b7746e changelog 2026-07-29 14:34:41 -07:00
39b3e20d0a fix: feedback file limit (#6931)
* fix: feedback file limit

* prepr

* prepr again

---------

Co-authored-by: Prospector <6166773+Prospector@users.noreply.github.com>
2026-07-29 20:47:05 +00:00
Calum H.andGitHub 3892b62451 fix: reinstall jobs not cleaning up if instance deleted (#6927) 2026-07-29 20:31:26 +00:00
Calum H.andGitHub 5ffffec2e5 fix: gracefully handle shared instance icons 404ing (#6928)
* fix: icon handling

* fix: cleanup impl

* fix: fmt
2026-07-29 20:30:52 +00:00
Truman GaoandGitHub a0724cc5fb feat: dont show analytics incomplete current day revenue (#6933) 2026-07-29 20:26:25 +00:00
ProspectorandGitHub a0320f425e install version matching filters if possible (#6932) 2026-07-29 13:26:39 -07:00
Michael H. 06262e7bb1 feat: clickhouse partitioning and header TTL 2026-07-29 22:10:20 +02:00
ProspectorandGitHub 9ccaab5fad add toggle to hide already-installed modpacks (#6917)
* add toggle to hide already-installed modpacks, on by default

* make false by default
2026-07-29 17:54:21 +00:00
ProspectorandGitHub 39745086fa fix warning when passing extraneous props to floating action bar (#6919) 2026-07-29 13:02:40 +00:00
Truman GaoandGitHub a8dcfa8a5d fix: download modal suggested dep can use wrong mc version (#6925) 2026-07-29 12:44:34 +00:00
Truman GaoandGitHub 0f6a74b87f fix: pats page code invalidating in same session (#6924) 2026-07-29 12:44:30 +00:00
ProspectorandGitHub d5b763c8e3 Fix tauri overlay in modal closing on drag (#6920)
* Fix tauri overlay in modal closing on drag

* prepr
2026-07-29 12:44:28 +00:00
ProspectorandGitHub 7a98f45c3d add installation settings tooltip (#6916) 2026-07-29 12:44:16 +00:00
ProspectorandGitHub 0ea47ad7ef add new instance button (#6918) 2026-07-29 12:43:17 +00:00
ProspectorandGitHub cc804625ec Replace final usage of legacy modal (#6921)
* Replace final usage of legacy modal

* prepr
2026-07-29 12:42:59 +00:00
Prospector 20235b7d32 changelog 2026-07-28 16:36:16 -07:00
ProspectorandGitHub 920dc8dcbb Fix oauth appliction icon upload (#6913) 2026-07-28 16:30:21 -07:00
Calum H.andGitHub 30f64c0020 fix: remove dummy data (#6915) 2026-07-28 16:30:14 -07:00
ProspectorandGitHub cfafa25ed4 fix moderation messages + add legacy message (#6914) 2026-07-28 16:30:05 -07:00
Prospector 7495b99ddd fix changelog 2026-07-28 15:06:12 -07:00
Prospector 800271a606 0.17.1 2026-07-28 14:01:28 -07:00
Michael H. 36791a33d8 fix: app build ci 2026-07-28 22:59:26 +02:00
Prospector 20ec70b917 comment out 0.17.0 changelog 2026-07-28 13:58:21 -07:00
Prospector ab6edbee48 changelog 2026-07-28 13:47:48 -07:00
b17523e7fd refactor: breadcrumbs system (#6912)
* refactor: breadcrumbs system

* fix: qa

* fix: qa

* fix: qa

* fix: qa

* fix: loading flashes

* fix: qa

* fix: prepr

* fix: qa

* fix: change icon

* use modrinth logo in header for compactness

---------

Co-authored-by: Prospector <6166773+Prospector@users.noreply.github.com>
2026-07-28 20:38:16 +00:00
f89c116bf8 feat: blocking frontend (#6911)
* feat: blocking api interfaces

* feat: block action on user pages + shared instance flows

* feat: safety settings subpage

* feat: interaction source settings

* feat: finish social settings

* feat: profile settings xplat

* fix: prepr

* feat: click on friends

* default instance -> game options & fix feature flag width

* fix: scroll indicators

---------

Co-authored-by: Prospector <6166773+Prospector@users.noreply.github.com>
2026-07-28 20:02:58 +00:00
ProspectorandGitHub cbb31f31c0 cleaup app layout, add missing translations to it (#6904)
* cleaup app layout, add missing translations to it

* remove feature flag in backend

* add app-messages

* clean up nav slightly

* allow more than 3 quick instances

* make quick instances resizable
2026-07-28 19:42:08 +00:00
ThatGravyBoatandGitHub fe69e04785 feat: moderation settings (#6895)
* feat: moderation settings

* chore: move moderation specific feature flag to settings

* feat: force open collapsed regions if opened in new tab

* chore: remove unused field

* chore: run prepr
2026-07-28 19:38:56 +00:00
Calum H.andGitHub 82348fe40a qa: shared instances post release (#6893)
* fix: 20 user cap

* qa: invite modal dropdown + other + clamp limits

* fix: hide filters if just one type

* feat: update req banner

* feat: invite management frontend

* fix: moderation issues with shared instances

* feat: instance icon improvements

* fix: better offline handling

* fix: lint

* feat: show avatar in install to play modal

* feat: smaller qa points

* fix: fmt

* fix: fmt

* fix: yeet svg

* fix: 50 user limit
2026-07-28 14:10:12 +00:00
Calum H.andGitHub 5d34e7902a refactor: app settings layout (#6891)
* refactor: app settings layout

* fix: privacy copy got reverted

* fix: i18n

* fix: modal title

* fix: rev comments
2026-07-28 14:09:41 +00:00
3c8bb0923d fix authorized apps settings, redesign, fix retro theme vars (#6900)
* fix authorized apps settings, redesign, fix retro theme vars

* merge

* fix: retro in app

---------

Co-authored-by: Calum H. (IMB11) <contact@cal.engineer>
2026-07-28 12:16:05 +00:00
301 changed files with 9350 additions and 4983 deletions
+21 -11
View File
@@ -34,13 +34,13 @@ runs:
using: 'composite'
steps:
- name: Checkout code
uses: actions/checkout@v4
uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0
with:
fetch-depth: 0
- name: Extract PR Number and Commit ID
id: extract-pr-info
uses: actions/github-script@v7
uses: actions/github-script@f28e40c7f34bde8b3046d885e986cb6290c5673b # v7.1.0
with:
script: |
const githubRef = process.env.GITHUB_REF;
@@ -90,20 +90,26 @@ runs:
- name: Print PR Branch
if: env.CAN_SKIP_CHECKS != 'false'
shell: bash
env:
PR_BRANCH: ${{ steps.get-pr-branch.outputs.prBranch }}
run: |
echo "PR Branch: ${{ steps.get-pr-branch.outputs.prBranch }}"
echo "PR Branch: $PR_BRANCH"
- name: Check if PR branch contains the Merge Queue target commit ID
if: env.CAN_SKIP_CHECKS != 'false'
shell: bash
env:
PR_BRANCH: ${{ steps.get-pr-branch.outputs.prBranch }}
COMMIT_ID: ${{ steps.extract-pr-info.outputs.commitId }}
TARGET_BRANCH: ${{ steps.extract-pr-info.outputs.targetBranchName }}
run: |
# Get the branch name from previous steps
branch_name="origin/${{ steps.get-pr-branch.outputs.prBranch }}"
commit_id="${{ steps.extract-pr-info.outputs.commitId }}"
branch_name="origin/$PR_BRANCH"
commit_id="$COMMIT_ID"
# Check if the branch history contains the commit
if git branch -r --contains "$commit_id" | grep -q "$branch_name"; then
echo "Branch '$branch_name' contains commit '$commit_id'. It is up to date with ${{ steps.extract-pr-info.outputs.targetBranchName }}."
if git branch -r --contains "$commit_id" | grep -qF "$branch_name"; then
echo "Branch '$branch_name' contains commit '$commit_id'. It is up to date with $TARGET_BRANCH."
else
echo "Branch '$branch_name' does not contain commit '$commit_id'. It is outdated. Setting CAN_SKIP_CHECKS to false."
echo "CAN_SKIP_CHECKS=false" >> "$GITHUB_ENV"
@@ -112,8 +118,10 @@ runs:
- name: Compare PR Branch with Current Branch
if: env.CAN_SKIP_CHECKS != 'false'
shell: bash
env:
PR_BRANCH: ${{ steps.get-pr-branch.outputs.prBranch }}
run: |
if git diff --quiet "origin/${{ steps.get-pr-branch.outputs.prBranch }}"; then
if git diff --quiet "origin/$PR_BRANCH"; then
echo "No differences found. PR branch is identical with this merge queue branch."
else
echo "Differences detected. PR branch has been updated after PR was added to merge queue. Setting CAN_SKIP_CHECKS to false."
@@ -122,9 +130,11 @@ runs:
- name: Compute/publish skip result
id: passed-checks
uses: actions/github-script@v7
uses: actions/github-script@f28e40c7f34bde8b3046d885e986cb6290c5673b # v7.1.0
env:
SECRET: ${{ inputs.secret }}
PR_BRANCH: ${{ steps.get-pr-branch.outputs.prBranch }}
TARGET_BRANCH: ${{ steps.extract-pr-info.outputs.targetBranchName }}
with:
github-token: ${{ inputs.secret != '' && inputs.secret || github.token }}
script: |
@@ -144,7 +154,7 @@ runs:
const { data: branchProtection } = await github.rest.repos.getBranchProtection({
owner: context.repo.owner,
repo: context.repo.repo,
branch: "${{ steps.extract-pr-info.outputs.targetBranchName }}",
branch: process.env.TARGET_BRANCH,
});
const requiredCheckNames = branchProtection.required_status_checks.contexts;
console.log(`requiredCheckNames = ${requiredCheckNames}`);
@@ -152,7 +162,7 @@ runs:
const { data: checks } = await github.rest.checks.listForRef({
owner: context.repo.owner,
repo: context.repo.repo,
ref: "refs/heads/${{ steps.get-pr-branch.outputs.prBranch }}",
ref: `refs/heads/${process.env.PR_BRANCH}`,
});
console.log(`checks.check_runs = ${checks.check_runs.map(check => `${check.status},${check.conclusion},${check.name};`)}`);
+24
View File
@@ -0,0 +1,24 @@
name: Deploy Command
run-name: Deploy PR #${{ github.event.client_payload.github.payload.issue.number }}
on:
repository_dispatch:
types: [deploy-command]
permissions:
contents: read
actions: read
jobs:
deploy:
if: ${{ github.event.client_payload.pull_request.head.repo.full_name == github.repository }}
uses: SparkUniverse/workflows/.github/workflows/deploy-command.yaml@main
secrets:
ARGOCD_DEPLOY_KEY: ${{ secrets.ARGOCD_DEPLOY_KEY }}
CMD_DISPATCH_GH_TOKEN: ${{ secrets.SLASH_CMD_GH_TOKEN }}
with:
application-set: labrinth
build-workflow: labrinth-build.yml
branch: ${{ github.event.client_payload.pull_request.head.ref }}
issue-number: ${{ github.event.client_payload.github.payload.issue.number }}
head-sha: ${{ github.event.client_payload.pull_request.head.sha }}
@@ -1,18 +1,19 @@
name: docker-build
name: Labrinth Build
on:
push:
branches:
- 'main'
- 'prod'
paths:
- .github/workflows/labrinth-docker.yml
- .github/workflows/labrinth-build.yml
- 'apps/labrinth/**'
- Cargo.toml
- Cargo.lock
pull_request:
types: [opened, synchronize]
paths:
- .github/workflows/labrinth-docker.yml
- .github/workflows/labrinth-build.yml
- 'apps/labrinth/**'
- Cargo.toml
- Cargo.lock
@@ -69,7 +70,8 @@ jobs:
echo "skip=false" >> $GITHUB_OUTPUT
fi
docker:
build:
name: Build Labrinth
runs-on: ${{ needs.skip-if-clean.outputs.internal == 'true' && 'namespace-profile-modrinth-labrinth' || 'ubuntu-latest' }}
needs: [skip-if-clean]
if: ${{ needs.skip-if-clean.outputs.skip != 'true' }}
@@ -120,42 +122,30 @@ jobs:
cp -r apps/labrinth/migrations apps/labrinth/docker-stage/migrations
cp -r apps/labrinth/assets apps/labrinth/docker-stage/assets
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4.2.0
- name: Generate Docker image metadata
id: docker-meta
uses: docker/metadata-action@dc802804100637a589fabce1cb79ff13a1411302 # v6.2.0
env:
# GitHub Packages requires annotations metadata in at least the index descriptor to show them
# up properly in its UI it seems, but it's not clear about it, because the docs refer to the
# image manifest only. See:
# https://docs.github.com/en/packages/working-with-a-github-packages-registry/working-with-the-container-registry#adding-a-description-to-multi-arch-images
DOCKER_METADATA_ANNOTATIONS_LEVELS: manifest,index
- name: Upload Docker context
if: needs.skip-if-clean.outputs.internal == 'true'
uses: namespace-actions/upload-artifact@f6ccaacc655aec41b93af180d1d7eef21af862d2 # v1.0.3
with:
images: ghcr.io/modrinth/labrinth
labels: |
org.opencontainers.image.title=labrinth
org.opencontainers.image.description=Modrinth API
org.opencontainers.image.licenses=AGPL-3.0-only
annotations: |
org.opencontainers.image.title=labrinth
org.opencontainers.image.description=Modrinth API
org.opencontainers.image.licenses=AGPL-3.0-only
name: labrinth-docker-context
retention-days: 1
path: apps/labrinth/docker-stage
- name: Login to GitHub Packages
uses: docker/login-action@abd2ef45e78c5afb21d64d4ca52ee8550d9572c7 # v4.5.1
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
docker-build:
needs: [skip-if-clean, build]
if: ${{ needs.skip-if-clean.outputs.internal == 'true' }}
uses: SparkUniverse/workflows/.github/workflows/docker-build.yaml@main
with:
image-name: labrinth
dockerfile-path: apps/labrinth/Dockerfile
artifacts-name: labrinth-docker-context
- name: Build and push
uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7.3.0
with:
context: ./apps/labrinth/docker-stage
file: ./apps/labrinth/Dockerfile
push: true
tags: ${{ steps.docker-meta.outputs.tags }}
labels: ${{ steps.docker-meta.outputs.labels }}
annotations: ${{ steps.docker-meta.outputs.annotations }}
deploy:
needs: [skip-if-clean, docker-build]
if: ${{ needs.skip-if-clean.outputs.internal == 'true' && (github.ref == 'refs/heads/main' || github.ref == 'refs/heads/prod') }}
uses: SparkUniverse/workflows/.github/workflows/argo-update.yaml@main
secrets:
ARGOCD_DEPLOY_KEY: ${{ secrets.ARGOCD_DEPLOY_KEY }}
with:
application-set: labrinth
branch: ${{ github.ref == 'refs/heads/prod' && 'main' || 'develop' }}
environment-name: ${{ github.ref == 'refs/heads/prod' && 'production' || 'staging' }}
+20
View File
@@ -0,0 +1,20 @@
name: Slash Command Dispatch
on:
issue_comment:
types: [created]
permissions: {}
jobs:
dispatch-command:
if: ${{ github.event.sender.type == 'User' && contains(fromJSON('["OWNER", "MEMBER"]'), github.event.comment.author_association) }}
runs-on: namespace-profile-tiny-arm64
steps:
- name: Slash Command Dispatch
uses: peter-evans/slash-command-dispatch@9bdcd7914ec1b75590b790b844aa3b8eee7c683a # v5.0.2
with:
token: ${{ secrets.SLASH_CMD_GH_TOKEN }}
issue-type: pull-request
commands: |
deploy
+8 -10
View File
@@ -74,10 +74,10 @@ jobs:
uses: actions-rust-lang/setup-rust-toolchain@166cdcfd11aee3cb47222f9ddb555ce30ddb9659 # v1.17.0
with:
rustflags: ''
target: ${{ contains(matrix.platform, 'macos') && 'x86_64-apple-darwin' || '' }}
target: ${{ contains(matrix.artifact-target-name, 'darwin') && 'x86_64-apple-darwin' || '' }}
- name: Setup mold
if: contains(matrix.platform, 'ubuntu')
if: contains(matrix.artifact-target-name, 'linux')
uses: rui314/setup-mold@9c9c13bf4c3f1adef0cc596abc155580bcb04444 # v1 / Mold 2.41.0
- name: Setup sccache
@@ -92,7 +92,6 @@ jobs:
run: corepack enable
- name: Set up caches
if: contains(matrix.platform, 'ubuntu') || contains(matrix.platform, 'macos')
uses: namespacelabs/nscloud-cache-action@c5f8dab7560444c4bf8dbc64f1b203431873c547 # v1.6.1
with:
cache: |
@@ -100,7 +99,6 @@ jobs:
pnpm
- name: Configure sccache
if: contains(matrix.platform, 'ubuntu') || contains(matrix.platform, 'macos')
run: nsc cache sccache setup --cache_name default >> "$GITHUB_ENV"
- name: Generate tauri-dev.conf.json
@@ -119,7 +117,7 @@ jobs:
EOF
- name: Install Linux build dependencies
if: contains(matrix.platform, 'ubuntu')
if: contains(matrix.artifact-target-name, 'linux')
run: |
sudo apt-get update
sudo apt-get install -y libwebkit2gtk-4.1-dev libayatana-appindicator3-dev librsvg2-dev
@@ -136,7 +134,7 @@ jobs:
repo: TomWright/dasel
tag: v2.8.1
extension-matching: disable
rename-to: ${{ contains(matrix.platform, 'windows') && 'dasel.exe' || 'dasel' }}
rename-to: ${{ contains(matrix.artifact-target-name, 'windows') && 'dasel.exe' || 'dasel' }}
chmod: 0755
- name: Set application version and environment
@@ -159,7 +157,7 @@ jobs:
run: pnpm install
- name: Set up Windows code signing
if: contains(matrix.platform, 'windows')
if: contains(matrix.artifact-target-name, 'windows')
shell: bash
run: |
if [ '${{ startsWith(github.ref, 'refs/tags/v') || inputs.sign-windows-binaries }}' = 'true' ]; then
@@ -170,7 +168,7 @@ jobs:
- name: Build macOS app
run: ${{ (github.ref == 'refs/heads/main' || startsWith(github.ref, 'refs/tags/v') || inputs.app-version-override != '') && 'pnpm --filter=@modrinth/app run tauri build --target universal-apple-darwin --config tauri-release.conf.json' || 'pnpm --filter=@modrinth/app run tauri build --target universal-apple-darwin --config tauri-dev.conf.json' }}
if: contains(matrix.platform, 'macos')
if: contains(matrix.artifact-target-name, 'darwin')
env:
TAURI_BUNDLER_DMG_IGNORE_CI: 'true'
ENABLE_CODE_SIGNING: ${{ secrets.APPLE_CERTIFICATE }}
@@ -185,7 +183,7 @@ jobs:
- name: Build Linux app
run: ${{ (github.ref == 'refs/heads/main' || startsWith(github.ref, 'refs/tags/v') || inputs.app-version-override != '') && 'pnpm --filter=@modrinth/app run tauri build --config tauri-release.conf.json' || 'pnpm --filter=@modrinth/app run tauri build --config tauri-dev.conf.json' }}
if: contains(matrix.platform, 'ubuntu')
if: contains(matrix.artifact-target-name, 'linux')
env:
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_PRIVATE_KEY }}
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_KEY_PASSWORD }}
@@ -197,7 +195,7 @@ jobs:
$env:JAVA_HOME = "$env:JAVA_HOME_17_X64"
${{ (github.ref == 'refs/heads/main' || startsWith(github.ref, 'refs/tags/v') || inputs.app-version-override != '') && 'pnpm --filter=@modrinth/app run tauri build --config tauri-release.conf.json --verbose --bundles "nsis,updater"' || 'pnpm --filter=@modrinth/app run tauri build --config tauri-dev.conf.json --verbose --bundles "nsis,updater"' }}
Remove-Item -Path signer-client-cert.p12 -ErrorAction SilentlyContinue
if: contains(matrix.platform, 'windows')
if: contains(matrix.artifact-target-name, 'windows')
env:
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_PRIVATE_KEY }}
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_KEY_PASSWORD }}
Generated
+4 -3
View File
@@ -4806,7 +4806,7 @@ dependencies = [
"libc",
"percent-encoding",
"pin-project-lite",
"socket2 0.5.10",
"socket2 0.6.5",
"system-configuration",
"tokio",
"tower-service",
@@ -8016,7 +8016,7 @@ dependencies = [
"quinn-udp",
"rustc-hash",
"rustls 0.23.32",
"socket2 0.5.10",
"socket2 0.6.5",
"thiserror 2.0.17",
"tokio",
"tracing",
@@ -8053,7 +8053,7 @@ dependencies = [
"cfg_aliases",
"libc",
"once_cell",
"socket2 0.5.10",
"socket2 0.6.5",
"tracing",
"windows-sys 0.60.2",
]
@@ -11020,6 +11020,7 @@ dependencies = [
"heck 0.5.0",
"hickory-resolver 0.25.2",
"httpdate",
"image",
"indicatif",
"itertools 0.14.0",
"modrinth-content-management",
+143 -216
View File
@@ -10,24 +10,21 @@ import {
} from '@modrinth/api-client'
import {
ArrowBigUpDashIcon,
ChangeSkinIcon,
ChevronLeftIcon,
ChevronRightIcon,
CompassIcon,
ExternalIcon,
HomeIcon,
LeftArrowIcon,
LibraryIcon,
LogInIcon,
LogOutIcon,
NewspaperIcon,
NotepadTextIcon,
PlusIcon,
RefreshCwIcon,
RightArrowIcon,
ServerStackIcon,
SettingsIcon,
ShirtIcon,
UserIcon,
WorldIcon,
XIcon,
} from '@modrinth/assets'
import {
Admonition,
@@ -39,6 +36,7 @@ import {
CreationFlowModal,
defineMessages,
I18nDebugPanel,
IntlFormatted,
LoadingBar,
NewsArticleCard,
NotificationPanel,
@@ -49,6 +47,7 @@ import {
provideNotificationManager,
providePageContext,
providePopupNotificationManager,
TextLogo,
useDebugLogger,
useFormatBytes,
useHostingIntercom,
@@ -63,11 +62,9 @@ import { fetch as tauriFetch } from '@tauri-apps/plugin-http'
import { openUrl } from '@tauri-apps/plugin-opener'
import { type } from '@tauri-apps/plugin-os'
import { saveWindowState, StateFlags } from '@tauri-apps/plugin-window-state'
import { $fetch } from 'ofetch'
import { computed, onMounted, onUnmounted, provide, ref, watch } from 'vue'
import { RouterView, useRoute, useRouter } from 'vue-router'
import ModrinthAppLogo from '@/assets/modrinth_app.svg?component'
import AccountsCard from '@/components/ui/AccountsCard.vue'
import AppActionBar from '@/components/ui/AppActionBar.vue'
import Breadcrumbs from '@/components/ui/Breadcrumbs.vue'
@@ -88,6 +85,7 @@ import PromotionWrapper from '@/components/ui/PromotionWrapper.vue'
import QuickInstanceSwitcher from '@/components/ui/QuickInstanceSwitcher.vue'
import SharedInstanceInviteHandler from '@/components/ui/shared-instances/shared-instance-invite-handler/index.vue'
import SplashScreen from '@/components/ui/SplashScreen.vue'
import SurveyPopup from '@/components/ui/SurveyPopup.vue'
import WindowControls from '@/components/ui/WindowControls.vue'
import { useCheckDisableMouseover } from '@/composables/macCssFix.js'
import { config } from '@/config'
@@ -104,12 +102,7 @@ import { check_reachable } from '@/helpers/auth.js'
import { get_user, get_version } from '@/helpers/cache.js'
import { command_listener, notification_listener, warning_listener } from '@/helpers/events.js'
import { install_create_modpack_instance, install_get_modpack_preview } from '@/helpers/install'
import {
can_current_user_use_shared_instances,
get as getInstance,
list,
run,
} from '@/helpers/instance'
import { can_current_user_use_shared_instances, get as getInstance, run } from '@/helpers/instance'
import { get as getCreds, login, logout } from '@/helpers/mr_auth.ts'
import { mergeUrlQuery, parseModrinthLink } from '@/helpers/project-links.ts'
import { get as getSettings, set as setSettings } from '@/helpers/settings.ts'
@@ -137,6 +130,7 @@ import {
openAppUpdateChangelog,
setAppUpdateActions,
} from '@/providers/app-update.ts'
import { createBreadcrumbManager, provideBreadcrumbManager } from '@/providers/breadcrumbs'
import { createContentInstall, provideContentInstall } from '@/providers/content-install'
import {
provideAppUpdateDownloadProgress,
@@ -148,15 +142,30 @@ 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 { appMessages } from '@/utils/app-messages'
import { generateSkinPreviews } from './helpers/rendering/batch-skin-renderer'
import { get_available_capes, get_available_skins } from './helpers/skins'
import { AppNotificationManager } from './providers/app-notifications'
import { AppPopupNotificationManager } from './providers/app-popup-notifications'
import { appSettingsModalOpenProfileKey } from './providers/app-settings-modal'
const themeStore = useTheming()
const router = useRouter()
const route = useRoute()
const breadcrumbManager = createBreadcrumbManager()
provideBreadcrumbManager(breadcrumbManager)
const canNavigateBack = ref(false)
const canNavigateForward = ref(false)
function updateHistoryNavigationState() {
const historyState = window.history.state
canNavigateBack.value = historyState?.back != null
canNavigateForward.value = historyState?.forward != null
}
updateHistoryNavigationState()
const APP_LEFT_NAV_WIDTH = '4rem'
const APP_SIDEBAR_WIDTH = 300
const INTERCOM_BUBBLE_DEFAULT_PADDING = 20
@@ -289,7 +298,6 @@ const {
} = setupProviders(notificationManager, popupNotificationManager)
const news = ref([])
const availableSurvey = ref(false)
const displayedServerInviteNotifications = new Set()
const serverInvitePopupNotificationIds = new Set()
let liveNotificationGeneration = 0
@@ -403,6 +411,54 @@ const messages = defineMessages({
id: 'app.ads-consent.accept',
defaultMessage: 'Accept all',
},
home: {
id: 'app.nav.home',
defaultMessage: 'Home',
},
library: {
id: 'app.nav.library',
defaultMessage: 'Library',
},
modrinthHosting: {
id: 'app.nav.modrinth-hosting',
defaultMessage: 'Modrinth Hosting',
},
createNewInstance: {
id: 'app.nav.create-new-instance',
defaultMessage: 'Create new instance',
},
modrinthAccount: {
id: 'app.nav.modrinth-account',
defaultMessage: 'Modrinth account',
},
signedInAs: {
id: 'app.nav.signed-in-as',
defaultMessage: 'Signed in as <user>{username}</user>',
},
signInToModrinthAccount: {
id: 'app.nav.sign-in-to-modrinth-account',
defaultMessage: 'Sign in to a Modrinth account',
},
restarting: {
id: 'app.restarting',
defaultMessage: 'Restarting...',
},
upgradeToModrinthPlus: {
id: 'app.nav.upgrade-to-modrinth-plus',
defaultMessage: 'Upgrade to Modrinth+',
},
news: {
id: 'app.news.title',
defaultMessage: 'News',
},
viewAllNews: {
id: 'app.news.view-all',
defaultMessage: 'View all news',
},
playingAs: {
id: 'app.sidebar.playing-as',
defaultMessage: 'Playing as',
},
})
function handleAdsConsentRequired(required) {
@@ -571,12 +627,6 @@ async function setupApp() {
settings.pending_update_toast_for_version = null
await setSettings(settings)
}
if (osType === 'windows') {
await processPendingSurveys()
} else {
console.info('Skipping user surveys on non-Windows platforms')
}
}
const stateFailed = ref(false)
@@ -620,6 +670,7 @@ router.beforeEach(() => {
routerToken = loading.begin()
})
router.afterEach((to, from, failure) => {
updateHistoryNavigationState()
trackEvent('PageView', {
path: to.path,
fromPath: from.path,
@@ -761,6 +812,8 @@ const sharedInstanceInviteHandler = ref()
const updateToPlayModal = ref()
const modrinthLoginModal = ref()
const appSettingsModal = ref()
provide(appSettingsModalOpenProfileKey, () => appSettingsModal.value?.showProfile())
watch(incompatibilityWarningModal, (modal) => {
if (modal) {
@@ -1439,115 +1492,6 @@ function handleAuxClick(e) {
}
}
function cleanupOldSurveyDisplayData() {
const threeWeeksAgo = new Date()
threeWeeksAgo.setDate(threeWeeksAgo.getDate() - 21)
for (let i = 0; i < localStorage.length; i++) {
const key = localStorage.key(i)
if (key.startsWith('survey-') && key.endsWith('-display')) {
const dateValue = new Date(localStorage.getItem(key))
if (dateValue < threeWeeksAgo) {
localStorage.removeItem(key)
}
}
}
}
async function openSurvey() {
if (!availableSurvey.value) {
console.error('No survey to open')
return
}
const creds = await getCreds().catch(handleError)
const userId = creds?.user_id
const formId = availableSurvey.value.tally_id
const popupOptions = {
layout: 'modal',
width: 700,
autoClose: 2000,
hideTitle: true,
hiddenFields: {
user_id: userId,
},
onOpen: () => console.info('Opened user survey'),
onClose: () => {
console.info('Closed user survey')
show_ads_window()
},
onSubmit: () => console.info('Active user survey submitted'),
}
try {
hide_ads_window()
if (window.Tally?.openPopup) {
console.info(`Opening Tally popup for user survey (form ID: ${formId})`)
dismissSurvey()
window.Tally.openPopup(formId, popupOptions)
} else {
console.warn('Tally script not yet loaded')
show_ads_window()
}
} catch (e) {
console.error('Error opening Tally popup:', e)
show_ads_window()
}
console.info(`Found user survey to show with tally_id: ${formId}`)
window.Tally.openPopup(formId, popupOptions)
}
function dismissSurvey() {
localStorage.setItem(`survey-${availableSurvey.value.id}-display`, new Date())
availableSurvey.value = undefined
}
async function processPendingSurveys() {
function isWithinLastTwoWeeks(date) {
const twoWeeksAgo = new Date()
twoWeeksAgo.setDate(twoWeeksAgo.getDate() - 14)
return date >= twoWeeksAgo
}
cleanupOldSurveyDisplayData()
const creds = await getCreds().catch(handleError)
const userId = creds?.user_id
const instances = (await list().catch(handleError)) ?? []
const isActivePlayer = instances.some(
(instance) =>
isWithinLastTwoWeeks(instance.last_played) && !isWithinLastTwoWeeks(instance.created),
)
let surveys = []
try {
surveys = await $fetch('https://api.modrinth.com/v2/surveys')
} catch (e) {
console.error('Error fetching surveys:', e)
}
const surveyToShow = surveys.find(
(survey) =>
!!(
localStorage.getItem(`survey-${survey.id}-display`) === null &&
survey.type === 'tally_app' &&
((survey.condition === 'active_player' && isActivePlayer) ||
(survey.assigned_users?.includes(userId) && !survey.dismissed_users?.includes(userId)))
),
)
if (surveyToShow) {
availableSurvey.value = surveyToShow
} else {
console.info('No user survey to show')
}
}
provideAppUpdateDownloadProgress(appUpdateDownload)
</script>
@@ -1570,12 +1514,12 @@ provideAppUpdateDownloadProgress(appUpdateDownload)
class="flex items-center gap-4 text-contrast font-semibold text-xl select-none cursor-default"
>
<RefreshCwIcon data-tauri-drag-region class="animate-spin w-6 h-6" />
Restarting...
{{ formatMessage(messages.restarting) }}
</span>
</div>
</Transition>
<Suspense>
<AppSettingsModal ref="settingsModal" />
<AppSettingsModal ref="appSettingsModal" />
</Suspense>
<Suspense>
<ModrinthAccountRequiredModal ref="modrinthLoginModal" :request-auth="requestModrinthAuth" />
@@ -1593,27 +1537,24 @@ provideAppUpdateDownloadProgress(appUpdateDownload)
/>
<UnknownPackWarningModal ref="unknownPackWarningModal" />
<div
class="app-grid-navbar bg-bg-raised flex flex-col p-[0.5rem] pt-0 gap-[0.5rem] w-[--left-bar-width]"
class="app-grid-navbar bg-bg-raised flex flex-col p-[0.5rem] pt-0 gap-[0.25rem] w-[--left-bar-width]"
>
<NavButton v-tooltip.right="'Home'" to="/">
<NavButton v-tooltip.right="formatMessage(messages.home)" to="/">
<HomeIcon />
</NavButton>
<NavButton v-if="themeStore.featureFlags.worlds_tab" v-tooltip.right="'Worlds'" to="/worlds">
<WorldIcon />
</NavButton>
<NavButton
v-tooltip.right="'Discover content'"
v-tooltip.right="formatMessage(commonMessages.discoverContentLabel)"
to="/browse/modpack"
:is-primary="() => route.path.startsWith('/browse') && !route.query.i"
:is-subpage="(route) => route.path.startsWith('/project') && !route.query.i"
>
<CompassIcon />
</NavButton>
<NavButton v-tooltip.right="'Skin selector'" to="/skins">
<ChangeSkinIcon />
<NavButton v-tooltip.right="formatMessage(appMessages.skinSelectorLabel)" to="/skins">
<ShirtIcon />
</NavButton>
<NavButton
v-tooltip.right="'Library'"
v-tooltip.right="formatMessage(messages.library)"
to="/library"
:is-primary="(r) => r.path === '/library' || r.path === '/library'"
:is-subpage="
@@ -1626,19 +1567,18 @@ provideAppUpdateDownloadProgress(appUpdateDownload)
<LibraryIcon />
</NavButton>
<NavButton
v-tooltip.right="'Modrinth Hosting'"
v-tooltip.right="formatMessage(messages.modrinthHosting)"
to="/hosting/manage"
:is-primary="(r) => r.path === '/hosting/manage' || r.path === '/hosting/manage/'"
:is-subpage="(r) => r.path.startsWith('/hosting/manage/') && r.path !== '/hosting/manage/'"
>
<ServerStackIcon />
</NavButton>
<div class="h-px w-6 mx-auto my-2 bg-surface-5"></div>
<suspense>
<QuickInstanceSwitcher />
</suspense>
<NavButton
v-tooltip.right="'Create new instance'"
v-tooltip.right="formatMessage(messages.createNewInstance)"
:to="() => installationModal?.show()"
:disabled="offline"
>
@@ -1647,13 +1587,13 @@ provideAppUpdateDownloadProgress(appUpdateDownload)
<div class="flex flex-grow"></div>
<NavButton
v-tooltip.right="formatMessage(commonMessages.settingsLabel)"
:to="() => $refs.settingsModal.show()"
:to="() => appSettingsModal?.show()"
>
<SettingsIcon />
</NavButton>
<OverflowMenu
v-if="credentials?.user"
v-tooltip.right="`Modrinth account`"
v-tooltip.right="formatMessage(messages.modrinthAccount)"
class="w-12 h-12 text-primary rounded-full flex items-center justify-center text-2xl transition-all bg-transparent hover:bg-button-bg hover:text-contrast border-0 cursor-pointer"
:options="[
{
@@ -1672,42 +1612,64 @@ provideAppUpdateDownloadProgress(appUpdateDownload)
<template #view-profile>
<UserIcon />
<span class="inline-flex items-center gap-1">
Signed in as
<span class="inline-flex items-center gap-1 text-contrast font-semibold">
<Avatar :src="credentials?.user?.avatar_url" alt="" size="20px" circle />
{{ credentials?.user?.username }}
</span>
<IntlFormatted
:message-id="messages.signedInAs"
:values="{ username: credentials?.user?.username }"
>
<template #user="{ children }">
<span class="inline-flex items-center gap-1 text-contrast font-semibold">
<Avatar :src="credentials?.user?.avatar_url" alt="" size="20px" circle />
<component :is="() => children" />
</span>
</template>
</IntlFormatted>
</span>
<ExternalIcon />
</template>
<template #sign-out> <LogOutIcon /> Sign out </template>
<template #sign-out>
<LogOutIcon />
{{ formatMessage(commonMessages.signOutButton) }}
</template>
</OverflowMenu>
<NavButton
v-else
v-tooltip.right="'Sign in to a Modrinth account'"
v-tooltip.right="formatMessage(messages.signInToModrinthAccount)"
:to="() => requestSignIn()"
>
<LogInIcon class="text-brand" />
</NavButton>
</div>
<div data-tauri-drag-region class="app-grid-statusbar bg-bg-raised h-[--top-bar-height] flex">
<div data-tauri-drag-region class="flex min-w-0 flex-1 overflow-hidden p-3">
<ModrinthAppLogo class="h-full w-auto shrink-0 text-contrast pointer-events-none" />
<div data-tauri-drag-region class="flex shrink-0 items-center gap-1 ml-3">
<button
class="cursor-pointer p-0 m-0 text-contrast border-none outline-none bg-button-bg rounded-full flex items-center justify-center w-6 h-6 hover:brightness-75 transition-all"
@click="router.back()"
>
<LeftArrowIcon />
</button>
<button
class="cursor-pointer p-0 m-0 text-contrast border-none outline-none bg-button-bg rounded-full flex items-center justify-center w-6 h-6 hover:brightness-75 transition-all"
@click="router.forward()"
>
<RightArrowIcon />
</button>
<div data-tauri-drag-region class="flex min-w-0 flex-1 items-center overflow-hidden p-2">
<TextLogo class="h-7 w-auto shrink-0 text-contrast pointer-events-none" />
<div data-tauri-drag-region class="ml-2 flex shrink-0 items-center gap-2">
<ButtonStyled type="outlined" circular>
<button
class="!h-7 !min-w-7 !w-7 !border !border-surface-4 !p-0 !opacity-100"
:disabled="!canNavigateBack"
aria-label="Go back"
@click="router.back()"
>
<ChevronLeftIcon
class="!size-4 !text-primary"
:class="{ 'opacity-20': !canNavigateBack }"
/>
</button>
</ButtonStyled>
<ButtonStyled type="outlined" circular>
<button
class="!h-7 !min-w-7 !w-7 !border !border-surface-4 !p-0 !opacity-100"
:disabled="!canNavigateForward"
aria-label="Go forward"
@click="router.forward()"
>
<ChevronRightIcon
class="!size-4 !text-primary"
:class="{ 'opacity-20': !canNavigateForward }"
/>
</button>
</ButtonStyled>
</div>
<Breadcrumbs class="pt-[2px]" />
<Breadcrumbs />
</div>
<section data-tauri-drag-region class="flex shrink-0 ml-auto items-center">
<ButtonStyled
@@ -1741,28 +1703,7 @@ provideAppUpdateDownloadProgress(appUpdateDownload)
}"
>
<div class="app-viewport flex-grow router-view">
<transition name="popup-survey">
<div
v-if="availableSurvey"
class="w-[400px] z-20 fixed -bottom-12 pb-16 right-[--right-bar-width] mr-4 rounded-t-2xl card-shadow bg-bg-raised border-surface-5 border-[1px] border-solid border-b-0 p-4"
>
<h2 class="text-lg font-extrabold mt-0 mb-2">Hey there Modrinth user!</h2>
<p class="m-0 leading-tight">
Would you mind answering a few questions about your experience with Modrinth App?
</p>
<p class="mt-3 mb-4 leading-tight">
This feedback will go directly to the Modrinth team and help guide future updates!
</p>
<div class="flex gap-2">
<ButtonStyled color="brand">
<button @click="openSurvey"><NotepadTextIcon /> Take survey</button>
</ButtonStyled>
<ButtonStyled>
<button @click="dismissSurvey"><XIcon /> No thanks</button>
</ButtonStyled>
</div>
</div>
</transition>
<SurveyPopup />
<div
class="loading-indicator-container h-8 fixed z-50 pointer-events-none"
:style="{
@@ -1826,7 +1767,9 @@ provideAppUpdateDownloadProgress(appUpdateDownload)
<div id="sidebar-teleport-target" class="sidebar-teleport-content"></div>
<div class="sidebar-default-content" :class="{ 'sidebar-enabled': sidebarVisible }">
<div class="p-4 border-0 border-b-[1px] border-[--brand-gradient-border] border-solid">
<h3 class="text-base text-primary font-medium m-0">Playing as</h3>
<h3 class="text-base text-primary font-medium m-0">
{{ formatMessage(messages.playingAs) }}
</h3>
<suspense>
<AccountsCard ref="accounts" />
</suspense>
@@ -1841,7 +1784,9 @@ provideAppUpdateDownloadProgress(appUpdateDownload)
class="p-4 border-0 border-b-[1px] border-[--brand-gradient-border] border-solid"
/>
<div v-if="news && news.length > 0" class="p-4 flex flex-col items-center">
<h3 class="text-base mb-4 text-primary font-medium m-0 text-left w-full">News</h3>
<h3 class="text-base mb-4 text-primary font-medium m-0 text-left w-full">
{{ formatMessage(messages.news) }}
</h3>
<div class="space-y-4 flex flex-col items-center w-full">
<NewsArticleCard
v-for="(item, index) in news"
@@ -1850,7 +1795,8 @@ provideAppUpdateDownloadProgress(appUpdateDownload)
/>
<ButtonStyled color="brand" size="large">
<a href="https://modrinth.com/news" target="_blank" class="my-4">
<NewspaperIcon /> View all news
<NewspaperIcon />
{{ formatMessage(messages.viewAllNews) }}
</a>
</ButtonStyled>
</div>
@@ -1863,7 +1809,8 @@ provideAppUpdateDownloadProgress(appUpdateDownload)
class="absolute bottom-[250px] w-full flex justify-center items-center gap-1 px-4 py-3 text-purple font-medium hover:underline z-10"
target="_blank"
>
<ArrowBigUpDashIcon class="text-2xl" /> Upgrade to Modrinth+
<ArrowBigUpDashIcon class="text-2xl" />
{{ formatMessage(messages.upgradeToModrinthPlus) }}
</a>
<PromotionWrapper />
</template>
@@ -2075,26 +2022,6 @@ provideAppUpdateDownloadProgress(appUpdateDownload)
display: contents;
}
.popup-survey-enter-active {
transition:
opacity 0.25s ease,
transform 0.25s cubic-bezier(0.51, 1.08, 0.35, 1.15);
transform-origin: top center;
}
.popup-survey-leave-active {
transition:
opacity 0.25s ease,
transform 0.25s cubic-bezier(0.68, -0.17, 0.23, 0.11);
transform-origin: top center;
}
.popup-survey-enter-from,
.popup-survey-leave-to {
opacity: 0;
transform: translateY(10rem) scale(0.8) scaleY(1.6);
}
@media (prefers-reduced-motion: no-preference) {
.nav-button-animated-enter-active {
transition: all 0.5s cubic-bezier(0.15, 1.4, 0.64, 0.96);
@@ -2,7 +2,7 @@
<div
ref="outerRef"
data-tauri-drag-region
class="min-w-0 overflow-hidden pl-3"
class="min-w-0 overflow-hidden pl-4"
:class="{ 'breadcrumb-fade-mask': isOverflowing }"
:style="isOverflowing ? { '--scroll-distance': `-${overflowAmount}px` } : undefined"
@mouseenter="onMouseEnter"
@@ -11,30 +11,48 @@
<div
ref="innerRef"
data-tauri-drag-region
class="flex w-fit items-center gap-1"
class="flex w-fit items-center gap-2 pr-4"
:class="{ 'breadcrumbs-scroll': isAnimating }"
@animationiteration="onAnimationIteration"
>
{{ breadcrumbData.resetToNames(breadcrumbs) }}
<template v-for="breadcrumb in breadcrumbs" :key="breadcrumb.name">
<router-link
v-if="breadcrumb.link"
:to="{
path: breadcrumb.link.replace('{id}', encodeURIComponent($route.params.id as string)),
query: breadcrumb.query,
}"
class="shrink-0 whitespace-nowrap text-primary"
<template v-for="(breadcrumb, index) in breadcrumbs" :key="breadcrumb.slot">
<component
:is="index < breadcrumbs.length - 1 && breadcrumb.to ? RouterLink : 'span'"
v-bind="index < breadcrumbs.length - 1 && breadcrumb.to ? { to: breadcrumb.to } : {}"
:data-tauri-drag-region="index === breadcrumbs.length - 1 ? '' : undefined"
class="flex shrink-0 items-center gap-1.5 whitespace-nowrap text-base font-medium leading-6"
:class="
index === breadcrumbs.length - 1
? 'cursor-default select-none text-contrast'
: 'text-primary hover:text-contrast'
"
:aria-current="index === breadcrumbs.length - 1 ? 'page' : undefined"
>
{{ resolveLabel(breadcrumb.name) }}
</router-link>
<span
v-else
<Avatar
v-if="breadcrumb.visual?.type === 'image'"
:src="breadcrumb.visual.src"
:alt="breadcrumb.visual.alt ?? breadcrumb.label"
:circle="breadcrumb.visual.circle"
:tint-by="breadcrumb.visual.tintBy ?? breadcrumb.id"
size="20px"
no-shadow
raised
class="inline-block shrink-0 align-middle"
:class="{ '!rounded-md': !breadcrumb.visual.circle }"
/>
<component
:is="breadcrumb.visual.component"
v-else-if="breadcrumb.visual?.type === 'icon'"
class="size-5 shrink-0 text-primary"
aria-hidden="true"
/>
<span>{{ breadcrumb.label }}</span>
</component>
<ChevronRightIcon
v-if="index < breadcrumbs.length - 1"
data-tauri-drag-region
class="shrink-0 whitespace-nowrap text-contrast font-semibold cursor-default select-none"
>
{{ resolveLabel(breadcrumb.name) }}
</span>
<ChevronRightIcon v-if="breadcrumb.link" data-tauri-drag-region class="w-5 h-5 shrink-0" />
class="size-5 shrink-0 text-primary"
/>
</template>
</div>
</div>
@@ -42,34 +60,13 @@
<script setup lang="ts">
import { ChevronRightIcon } from '@modrinth/assets'
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'
import { useRoute } from 'vue-router'
import { Avatar } from '@modrinth/ui'
import { onBeforeUnmount, onMounted, ref, watch } from 'vue'
import { RouterLink } from 'vue-router'
import { useBreadcrumbs } from '@/store/breadcrumbs'
import { injectBreadcrumbManager } from '@/providers/breadcrumbs'
interface Breadcrumb {
name: string
link?: string
query?: Record<string, string>
}
const route = useRoute()
const breadcrumbData = useBreadcrumbs()
const breadcrumbs = computed<Breadcrumb[]>(() => {
const additionalContext =
route.meta.useContext === true
? breadcrumbData.context
: route.meta.useRootContext === true
? breadcrumbData.rootContext
: null
const crumbs = (route.meta.breadcrumb ?? []) as Breadcrumb[]
return additionalContext ? [additionalContext as Breadcrumb, ...crumbs] : crumbs
})
function resolveLabel(name: string): string {
return name.charAt(0) === '?' ? breadcrumbData.getName(name.slice(1)) : name
}
const { entries: breadcrumbs } = injectBreadcrumbManager()
// Overflow detection
const outerRef = ref<HTMLDivElement | null>(null)
@@ -83,9 +80,13 @@ let stopping = false
function checkOverflow() {
if (!outerRef.value || !innerRef.value) return
const overflow = innerRef.value.scrollWidth - outerRef.value.clientWidth
const outerStyles = window.getComputedStyle(outerRef.value)
const horizontalPadding =
Number.parseFloat(outerStyles.paddingLeft) + Number.parseFloat(outerStyles.paddingRight)
const availableWidth = outerRef.value.clientWidth - horizontalPadding
const overflow = innerRef.value.scrollWidth - availableWidth
isOverflowing.value = overflow > 0
overflowAmount.value = overflow + 12
overflowAmount.value = overflow
}
function onMouseEnter() {
@@ -1,5 +1,9 @@
<template>
<ModalWrapper ref="detectJavaModal" header="Select java version" :show-ad-on-close="false">
<ModalWrapper
ref="detectJavaModal"
:header="formatMessage(messages.title)"
:show-ad-on-close="false"
>
<div class="flex flex-col gap-4">
<Table :columns="javaInstallColumns" :data="chosenInstallOptions" row-key="path">
<template #cell-version="{ value }">
@@ -11,15 +15,23 @@
<template #cell-actions="{ row }">
<div class="flex items-center justify-end">
<ButtonStyled v-if="currentSelected.path === row.path">
<button class="!shadow-none" disabled><CheckIcon /> Selected</button>
<button class="!shadow-none" disabled>
<CheckIcon aria-hidden="true" />
{{ formatMessage(messages.selected) }}
</button>
</ButtonStyled>
<ButtonStyled v-else>
<button class="!shadow-none" @click="setJavaInstall(row)"><PlusIcon /> Select</button>
<button class="!shadow-none" @click="setJavaInstall(row)">
<PlusIcon aria-hidden="true" />
{{ formatMessage(messages.select) }}
</button>
</ButtonStyled>
</div>
</template>
<template #empty-state>
<div class="p-4 text-secondary">No java installations found!</div>
<div class="p-4 text-secondary">
{{ formatMessage(messages.noInstallationsFound) }}
</div>
</template>
</Table>
<div class="flex justify-end">
@@ -28,8 +40,8 @@
class="!shadow-none !border-surface-4 !border"
@click="$refs.detectJavaModal.hide()"
>
<XIcon />
Cancel
<XIcon aria-hidden="true" />
{{ formatMessage(messages.cancel) }}
</button>
</ButtonStyled>
</div>
@@ -38,23 +50,70 @@
</template>
<script setup>
import { CheckIcon, PlusIcon, XIcon } from '@modrinth/assets'
import { ButtonStyled, injectNotificationManager, Table } from '@modrinth/ui'
import { ref } from 'vue'
import {
ButtonStyled,
defineMessages,
injectNotificationManager,
Table,
useVIntl,
} from '@modrinth/ui'
import { computed, ref } from 'vue'
import ModalWrapper from '@/components/ui/modal/ModalWrapper.vue'
import { trackEvent } from '@/helpers/analytics'
import { find_filtered_jres } from '@/helpers/jre.js'
const { handleError } = injectNotificationManager()
const { formatMessage } = useVIntl()
const messages = defineMessages({
title: {
id: 'app.java-detection.title',
defaultMessage: 'Select Java installation',
},
versionColumn: {
id: 'app.java-detection.columns.version',
defaultMessage: 'Version',
},
pathColumn: {
id: 'app.java-detection.columns.path',
defaultMessage: 'Path',
},
actionsColumn: {
id: 'app.java-detection.columns.actions',
defaultMessage: 'Actions',
},
selected: {
id: 'app.java-detection.selected',
defaultMessage: 'Selected',
},
select: {
id: 'app.java-detection.select',
defaultMessage: 'Select',
},
noInstallationsFound: {
id: 'app.java-detection.no-installations-found',
defaultMessage: 'No Java installations found.',
},
cancel: {
id: 'app.java-detection.cancel',
defaultMessage: 'Cancel',
},
})
const chosenInstallOptions = ref([])
const detectJavaModal = ref(null)
const currentSelected = ref({})
const javaInstallColumns = [
{ key: 'version', label: 'Version', width: '9rem' },
{ key: 'path', label: 'Path' },
{ key: 'actions', label: 'Actions', align: 'right', width: '10rem' },
]
const javaInstallColumns = computed(() => [
{ key: 'version', label: formatMessage(messages.versionColumn), width: '9rem' },
{ key: 'path', label: formatMessage(messages.pathColumn) },
{
key: 'actions',
label: formatMessage(messages.actionsColumn),
align: 'right',
width: '10rem',
},
])
defineExpose({
show: async (version, currentSelectedJava) => {
@@ -6,7 +6,7 @@
autocomplete="off"
:disabled="props.disabled"
:model-value="props.modelValue ? props.modelValue.path : ''"
:placeholder="placeholder ?? '/path/to/java'"
:placeholder="placeholder ?? formatMessage(messages.pathPlaceholder)"
wrapper-class="installation-input"
@update:model-value="
(val) => {
@@ -28,6 +28,7 @@
color-fill="text"
>
<button
:aria-label="formatMessage(messages.testJavaInstallation)"
class="!shadow-none"
:disabled="testingJava || props.disabled"
@click="runTest(props.modelValue?.path)"
@@ -47,25 +48,31 @@
<span class="installation-buttons">
<ButtonStyled v-if="props.version">
<button
v-tooltip="testingJavaSuccess === true ? 'Already installed' : undefined"
v-tooltip="
testingJavaSuccess === true ? formatMessage(messages.alreadyInstalled) : undefined
"
class="!shadow-none"
:disabled="props.disabled || installingJava || testingJavaSuccess === true"
@click="reinstallJava"
>
<DownloadIcon />
{{ installingJava ? 'Installing...' : 'Install recommended' }}
{{
installingJava
? formatMessage(messages.installing)
: formatMessage(messages.installRecommended)
}}
</button>
</ButtonStyled>
<ButtonStyled>
<button class="!shadow-none" :disabled="props.disabled" @click="autoDetect">
<SearchIcon />
Detect
{{ formatMessage(messages.detect) }}
</button>
</ButtonStyled>
<ButtonStyled>
<button class="!shadow-none" :disabled="props.disabled" @click="handleJavaFileInput()">
<FolderSearchIcon />
Browse
{{ formatMessage(messages.browse) }}
</button>
</ButtonStyled>
</span>
@@ -82,7 +89,13 @@ import {
SpinnerIcon,
XCircleIcon,
} from '@modrinth/assets'
import { ButtonStyled, injectNotificationManager, StyledInput } from '@modrinth/ui'
import {
ButtonStyled,
defineMessages,
injectNotificationManager,
StyledInput,
useVIntl,
} from '@modrinth/ui'
import { open } from '@tauri-apps/plugin-dialog'
import { ref, watch } from 'vue'
@@ -92,6 +105,38 @@ import { trackEvent } from '@/helpers/analytics'
import { auto_install_java, find_filtered_jres, get_jre } from '@/helpers/jre.js'
const { handleError } = injectNotificationManager()
const { formatMessage } = useVIntl()
const messages = defineMessages({
pathPlaceholder: {
id: 'app.java-selector.path.placeholder',
defaultMessage: '/path/to/java',
},
testJavaInstallation: {
id: 'app.java-selector.test-installation',
defaultMessage: 'Test Java installation',
},
alreadyInstalled: {
id: 'app.java-selector.already-installed',
defaultMessage: 'Already installed',
},
installing: {
id: 'app.java-selector.installing',
defaultMessage: 'Installing...',
},
installRecommended: {
id: 'app.java-selector.install-recommended',
defaultMessage: 'Install recommended',
},
detect: {
id: 'app.java-selector.detect',
defaultMessage: 'Detect',
},
browse: {
id: 'app.java-selector.browse',
defaultMessage: 'Browse',
},
})
const props = defineProps({
id: {
@@ -1,41 +1,151 @@
<script setup>
import { SpinnerIcon } from '@modrinth/assets'
import { Avatar, injectNotificationManager } from '@modrinth/ui'
import { Avatar, defineMessages, injectNotificationManager, useVIntl } from '@modrinth/ui'
import { useQueryClient } from '@tanstack/vue-query'
import { convertFileSrc } from '@tauri-apps/api/core'
import dayjs from 'dayjs'
import { onUnmounted, ref } from 'vue'
import { computed, onMounted, onUnmounted, ref } from 'vue'
import NavButton from '@/components/ui/NavButton.vue'
import { instance_listener } from '@/helpers/events.js'
import { list } from '@/helpers/instance'
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 STORAGE_KEY = 'modrinth-quick-instance-count'
const { handleError } = injectNotificationManager()
const queryClient = useQueryClient()
const { formatMessage } = useVIntl()
const maxAuto = ref(0)
const allInstances = ref([])
const dragging = ref(false)
const stored = localStorage.getItem(STORAGE_KEY)
const userLimit = ref(stored === null ? null : Number(stored))
const maxVisible = computed(() => Math.min(maxAuto.value, allInstances.value.length))
const visibleCount = computed(() => Math.min(userLimit.value ?? maxVisible.value, maxVisible.value))
const recentInstances = computed(() => allInstances.value.slice(0, visibleCount.value))
const canDrag = computed(() => maxVisible.value > 0)
const showOverdrag = ref(false)
const updateMaxAuto = () => {
maxAuto.value = Math.max(
0,
Math.floor((window.innerHeight - APPROX_USED_VERTICAL_SPACE) / ITEM_SIZE),
)
}
const setLimit = (count) => {
const clamped = Math.max(0, Math.min(count, maxVisible.value))
if (clamped >= maxVisible.value) {
userLimit.value = null
localStorage.removeItem(STORAGE_KEY)
} else {
userLimit.value = clamped
localStorage.setItem(STORAGE_KEY, String(clamped))
}
}
let dragStartY = 0
let dragStartCount = 0
let wasOverdragging = false
let overdragTimeout = null
const clearOverdragFlash = () => {
showOverdrag.value = false
if (overdragTimeout !== null) {
clearTimeout(overdragTimeout)
overdragTimeout = null
}
}
const flashOverdrag = () => {
showOverdrag.value = true
if (overdragTimeout !== null) {
clearTimeout(overdragTimeout)
}
overdragTimeout = setTimeout(() => {
showOverdrag.value = false
overdragTimeout = null
}, 500)
}
const onDividerPointerDown = (event) => {
if (!canDrag.value) {
return
}
event.preventDefault()
dragging.value = true
wasOverdragging = false
clearOverdragFlash()
dragStartY = event.clientY
dragStartCount = visibleCount.value
document.body.classList.add('quick-instance-dragging')
event.currentTarget.setPointerCapture(event.pointerId)
}
const onDividerPointerMove = (event) => {
if (!dragging.value) {
return
}
const delta = event.clientY - dragStartY
const target = dragStartCount + Math.round(delta / ITEM_SIZE)
const isOverdragging = target < 0 || target > maxAuto.value
if (isOverdragging && !wasOverdragging) {
flashOverdrag()
}
wasOverdragging = isOverdragging
setLimit(target)
}
const endDrag = (event) => {
if (!dragging.value) {
return
}
dragging.value = false
wasOverdragging = false
clearOverdragFlash()
document.body.classList.remove('quick-instance-dragging')
if (event?.currentTarget?.hasPointerCapture?.(event.pointerId)) {
event.currentTarget.releasePointerCapture(event.pointerId)
}
}
const onDividerPointerUp = (event) => {
endDrag(event)
}
const recentInstances = ref([])
const getInstances = async () => {
const instances = await list().catch(handleError)
recentInstances.value = instances
.sort((a, b) => {
const dateACreated = dayjs(a.created)
const dateAPlayed = a.last_played ? dayjs(a.last_played) : dayjs(0)
for (const instance of instances) {
queryClient.setQueryData(['instances', 'summary', instance.id], instance)
}
const dateBCreated = dayjs(b.created)
const dateBPlayed = b.last_played ? dayjs(b.last_played) : dayjs(0)
allInstances.value = instances.sort((a, b) => {
const dateACreated = dayjs(a.created)
const dateAPlayed = a.last_played ? dayjs(a.last_played) : dayjs(0)
const dateA = dateACreated.isAfter(dateAPlayed) ? dateACreated : dateAPlayed
const dateB = dateBCreated.isAfter(dateBPlayed) ? dateBCreated : dateBPlayed
const dateBCreated = dayjs(b.created)
const dateBPlayed = b.last_played ? dayjs(b.last_played) : dayjs(0)
if (dateA.isSame(dateB)) {
return a.name.localeCompare(b.name)
}
const dateA = dateACreated.isAfter(dateAPlayed) ? dateACreated : dateAPlayed
const dateB = dateBCreated.isAfter(dateBPlayed) ? dateBCreated : dateBPlayed
return dateB - dateA
})
.slice(0, 3)
if (dateA.isSame(dateB)) {
return a.name.localeCompare(b.name)
}
return dateB - dateA
})
}
await getInstances()
updateMaxAuto()
const unlistenInstance = await instance_listener(async (event) => {
if (event.event !== 'synced') {
@@ -43,29 +153,157 @@ const unlistenInstance = await instance_listener(async (event) => {
}
})
onMounted(() => {
window.addEventListener('resize', updateMaxAuto)
})
onUnmounted(() => {
window.removeEventListener('resize', updateMaxAuto)
document.body.classList.remove('quick-instance-dragging')
clearOverdragFlash()
unlistenInstance()
})
const messages = defineMessages({
dragTooltip: {
id: 'app.quick-instance-switcher.drag-tooltip',
defaultMessage: 'Drag to resize',
},
dragShowTooltip: {
id: 'app.quick-instance-switcher.drag-show-tooltip',
defaultMessage: 'Drag to show recent instances',
},
})
const dividerTooltip = computed(() => {
if (!canDrag.value || dragging.value) {
return null
}
return formatMessage(visibleCount.value === 0 ? messages.dragShowTooltip : messages.dragTooltip)
})
</script>
<template>
<div v-for="instance in recentInstances" :key="instance.id" v-tooltip.right="instance.name">
<NavButton :to="`/instance/${encodeURIComponent(instance.id)}`" class="relative">
<Avatar
:src="instance.icon_path ? convertFileSrc(instance.icon_path) : null"
size="28px"
:tint-by="instance.id"
:class="`transition-all ${instance.install_stage !== 'installed' ? `brightness-[0.25] scale-[0.85]` : `group-hover:brightness-75`}`"
/>
<div
v-if="instance.install_stage !== 'installed'"
class="absolute inset-0 flex items-center justify-center z-10 pointer-events-none"
>
<SpinnerIcon class="animate-spin w-4 h-4" />
</div>
</NavButton>
<Transition name="top-divider">
<div
v-if="recentInstances.length > 0"
class="top-divider flex items-center justify-center overflow-hidden"
>
<div class="h-px w-8 bg-surface-5 shrink-0"></div>
</div>
</Transition>
<TransitionGroup name="quick-instance" tag="div" class="flex flex-col items-center">
<div
v-for="instance in recentInstances"
:key="instance.id"
v-tooltip.right="instance.name"
class="quick-instance-item"
>
<NavButton :to="`/instance/${encodeURIComponent(instance.id)}`" class="relative">
<Avatar
:src="instance.icon_path ? convertFileSrc(instance.icon_path) : null"
size="28px"
:tint-by="instance.id"
:class="`transition-all ${instance.install_stage !== 'installed' ? `brightness-[0.25] scale-[0.85]` : `group-hover:brightness-75`}`"
/>
<div
v-if="instance.install_stage !== 'installed'"
class="absolute inset-0 flex items-center justify-center z-10 pointer-events-none"
>
<SpinnerIcon class="animate-spin w-4 h-4" />
</div>
</NavButton>
</div>
</TransitionGroup>
<div
v-tooltip.right="dividerTooltip"
class="flex items-center justify-center py-2 select-none"
:class="canDrag ? 'cursor-ns-resize touch-none group' : ''"
@pointerdown="onDividerPointerDown"
@pointermove="onDividerPointerMove"
@pointerup="onDividerPointerUp"
@pointercancel="onDividerPointerUp"
>
<div
class="h-px w-8 transition-colors duration-200"
:class="
showOverdrag ? 'bg-red' : canDrag ? 'bg-surface-5 group-hover:bg-secondary' : 'bg-surface-5'
"
></div>
</div>
<div v-if="recentInstances.length > 0" class="h-px w-6 mx-auto my-2 bg-divider"></div>
</template>
<style scoped lang="scss"></style>
<style scoped lang="scss">
.top-divider {
height: calc(1rem + 1px);
}
.top-divider-enter-active,
.top-divider-leave-active {
transition:
opacity 0.25s ease,
height 0.25s ease;
}
.top-divider-enter-from,
.top-divider-leave-to {
opacity: 0;
height: 0;
}
.quick-instance-item {
height: 3rem;
overflow: hidden;
& + & {
margin-top: 0.25rem;
}
}
.quick-instance-enter-active,
.quick-instance-leave-active {
transition:
opacity 0.25s ease,
transform 0.25s ease,
height 0.25s ease,
margin-top 0.25s ease;
}
.quick-instance-enter-from,
.quick-instance-leave-to {
opacity: 0;
transform: scale(0.5);
height: 0;
margin-top: 0 !important;
}
@media (prefers-reduced-motion: reduce) {
.top-divider-enter-active,
.top-divider-leave-active,
.quick-instance-enter-active,
.quick-instance-leave-active {
transition: none;
}
.top-divider-enter-from,
.top-divider-leave-to {
opacity: 1;
height: calc(1rem + 1px);
}
.quick-instance-enter-from,
.quick-instance-leave-to {
opacity: 1;
transform: none;
height: 3rem;
margin-top: unset !important;
}
}
</style>
<style lang="scss">
body.quick-instance-dragging,
body.quick-instance-dragging * {
cursor: ns-resize !important;
}
</style>
@@ -0,0 +1,233 @@
<script setup lang="ts">
import { NotepadTextIcon, XIcon } from '@modrinth/assets'
import { ButtonStyled, defineMessages, injectNotificationManager, useVIntl } from '@modrinth/ui'
import { type } from '@tauri-apps/plugin-os'
import { $fetch } from 'ofetch'
import { onMounted, ref } from 'vue'
import { hide_ads_window, show_ads_window } from '@/helpers/ads.js'
import { list } from '@/helpers/instance'
import { get as getCreds } from '@/helpers/mr_auth.ts'
type Survey = {
id: string
tally_id: string
type: string
condition?: string
assigned_users?: string[]
dismissed_users?: string[]
}
type TallyApi = {
openPopup: (formId: string, options: object) => void
}
const tallyWindow = window as Window & { Tally?: TallyApi }
const { formatMessage } = useVIntl()
const { handleError } = injectNotificationManager()
const availableSurvey = ref<Survey | null>(null)
const messages = defineMessages({
surveyTitle: {
id: 'app.survey.title',
defaultMessage: 'Hey there Modrinth user!',
},
surveyBody: {
id: 'app.survey.body',
defaultMessage:
'Would you mind answering a few questions about your experience with Modrinth App?',
},
surveyFooter: {
id: 'app.survey.footer',
defaultMessage:
'This feedback will go directly to the Modrinth team and help guide future updates!',
},
takeSurvey: {
id: 'app.survey.take-survey',
defaultMessage: 'Take survey',
},
surveyNoThanks: {
id: 'app.survey.no-thanks',
defaultMessage: 'No thanks',
},
})
function cleanupOldSurveyDisplayData() {
const threeWeeksAgo = new Date()
threeWeeksAgo.setDate(threeWeeksAgo.getDate() - 21)
for (let i = 0; i < localStorage.length; i++) {
const key = localStorage.key(i)
if (key?.startsWith('survey-') && key.endsWith('-display')) {
const dateValue = new Date(localStorage.getItem(key) ?? '')
if (dateValue < threeWeeksAgo) {
localStorage.removeItem(key)
}
}
}
}
async function openSurvey() {
if (!availableSurvey.value) {
console.error('No survey to open')
return
}
const creds = await getCreds().catch(handleError)
const userId = creds?.user_id
const formId = availableSurvey.value.tally_id
const popupOptions = {
layout: 'modal',
width: 700,
autoClose: 2000,
hideTitle: true,
hiddenFields: {
user_id: userId,
},
onOpen: () => console.info('Opened user survey'),
onClose: () => {
console.info('Closed user survey')
show_ads_window()
},
onSubmit: () => console.info('Active user survey submitted'),
}
try {
hide_ads_window()
if (tallyWindow.Tally?.openPopup) {
console.info(`Opening Tally popup for user survey (form ID: ${formId})`)
dismissSurvey()
tallyWindow.Tally.openPopup(formId, popupOptions)
} else {
console.warn('Tally script not yet loaded')
show_ads_window()
}
} catch (e) {
console.error('Error opening Tally popup:', e)
show_ads_window()
}
console.info(`Found user survey to show with tally_id: ${formId}`)
tallyWindow.Tally?.openPopup(formId, popupOptions)
}
function dismissSurvey() {
if (!availableSurvey.value) return
localStorage.setItem(`survey-${availableSurvey.value.id}-display`, String(new Date()))
availableSurvey.value = null
}
async function processPendingSurveys() {
function isWithinLastTwoWeeks(date: string | Date | null | undefined) {
if (!date) return false
const twoWeeksAgo = new Date()
twoWeeksAgo.setDate(twoWeeksAgo.getDate() - 14)
return new Date(date) >= twoWeeksAgo
}
cleanupOldSurveyDisplayData()
const creds = await getCreds().catch(handleError)
const userId = creds?.user_id
const instances = (await list().catch(handleError)) ?? []
const isActivePlayer = instances.some(
(instance) =>
isWithinLastTwoWeeks(instance.last_played) && !isWithinLastTwoWeeks(instance.created),
)
let surveys: Survey[] = []
try {
surveys = await $fetch('https://api.modrinth.com/v2/surveys')
} catch (e) {
console.error('Error fetching surveys:', e)
}
const surveyToShow = surveys.find(
(survey) =>
!!(
localStorage.getItem(`survey-${survey.id}-display`) === null &&
survey.type === 'tally_app' &&
((survey.condition === 'active_player' && isActivePlayer) ||
(!!userId &&
survey.assigned_users?.includes(userId) &&
!survey.dismissed_users?.includes(userId)))
),
)
if (surveyToShow) {
availableSurvey.value = surveyToShow
} else {
console.info('No user survey to show')
}
}
onMounted(async () => {
const osType = await type()
if (osType === 'windows') {
await processPendingSurveys()
} else {
console.info('Skipping user surveys on non-Windows platforms')
}
})
</script>
<template>
<transition name="popup-survey">
<div
v-if="availableSurvey"
class="w-[400px] z-20 fixed -bottom-12 pb-16 right-[--right-bar-width] mr-4 rounded-t-2xl card-shadow bg-bg-raised border-surface-5 border-[1px] border-solid border-b-0 p-4"
>
<h2 class="text-lg font-extrabold mt-0 mb-2">
{{ formatMessage(messages.surveyTitle) }}
</h2>
<p class="m-0 leading-tight">
{{ formatMessage(messages.surveyBody) }}
</p>
<p class="mt-3 mb-4 leading-tight">
{{ formatMessage(messages.surveyFooter) }}
</p>
<div class="flex gap-2">
<ButtonStyled color="brand">
<button @click="openSurvey">
<NotepadTextIcon />
{{ formatMessage(messages.takeSurvey) }}
</button>
</ButtonStyled>
<ButtonStyled>
<button @click="dismissSurvey">
<XIcon />
{{ formatMessage(messages.surveyNoThanks) }}
</button>
</ButtonStyled>
</div>
</div>
</transition>
</template>
<style scoped>
.popup-survey-enter-active {
transition:
opacity 0.25s ease,
transform 0.25s cubic-bezier(0.51, 1.08, 0.35, 1.15);
transform-origin: top center;
}
.popup-survey-leave-active {
transition:
opacity 0.25s ease,
transform 0.25s cubic-bezier(0.68, -0.17, 0.23, 0.11);
transform-origin: top center;
}
.popup-survey-enter-from,
.popup-survey-leave-to {
opacity: 0;
transform: translateY(10rem) scale(0.8) scaleY(1.6);
}
</style>
@@ -123,45 +123,46 @@ const messages = defineMessages({
<div
v-for="friend in friends"
:key="friend.username"
class="group grid items-center grid-cols-[auto_1fr_auto] gap-2 hover:bg-button-bg transition-colors rounded-full mr-1"
class="group grid items-center grid-cols-[1fr_auto] gap-2 hover:bg-button-bg transition-colors rounded-full mr-1"
@contextmenu.prevent.stop="
(event) => friendOptions?.showMenu(event, friend, createContextMenuOptions(friend))
"
>
<div class="relative">
<Avatar
:src="friend.avatar"
:class="{ grayscale: !friend.online && friend.accepted }"
class="w-12 h-12 rounded-full"
size="32px"
circle
/>
<span
v-if="friend.online"
aria-hidden="true"
class="bottom-[2px] right-[-2px] absolute w-3 h-3 bg-brand border-2 border-black border-solid rounded-full"
/>
</div>
<div class="flex flex-col">
<span
class="text-sm m-0"
:class="friend.online || !friend.accepted ? 'text-contrast' : 'text-primary'"
>
{{ friend.username }}
</span>
<span v-if="!friend.accepted" class="m-0 text-xs">
{{ formatMessage(messages.friendRequestSent) }}
</span>
<span v-else-if="friend.status" class="m-0 text-xs">{{ friend.status }}</span>
</div>
<RouterLink
:to="`/user/${encodeURIComponent(friend.username)}`"
class="grid min-w-0 grid-cols-[auto_1fr] items-center gap-2 text-inherit no-underline"
>
<div class="relative">
<Avatar
:src="friend.avatar"
:class="{ grayscale: !friend.online && friend.accepted }"
class="w-12 h-12 rounded-full"
size="32px"
circle
/>
<span
v-if="friend.online"
aria-hidden="true"
class="bottom-[2px] right-[-2px] absolute w-3 h-3 bg-brand border-2 border-black border-solid rounded-full"
/>
</div>
<div class="flex flex-col">
<span
class="text-sm m-0"
:class="friend.online || !friend.accepted ? 'text-contrast' : 'text-primary'"
>
{{ friend.username }}
</span>
<span v-if="!friend.accepted" class="m-0 text-xs">
{{ formatMessage(messages.friendRequestSent) }}
</span>
<span v-else-if="friend.status" class="m-0 text-xs">{{ friend.status }}</span>
</div>
</RouterLink>
<ButtonStyled v-if="friend.accepted" circular type="transparent">
<OverflowMenu
class="opacity-0 group-hover:opacity-100 transition-opacity"
:options="[
{
id: 'view-profile',
action: () => openProfile(friend.username),
},
{
id: 'remove-friend',
action: () => removeFriend(friend),
@@ -170,10 +171,6 @@ const messages = defineMessages({
]"
>
<MoreVerticalIcon />
<template #view-profile>
<UserIcon />
{{ formatMessage(messages.viewProfile) }}
</template>
<template #remove-friend>
<TrashIcon />
{{ formatMessage(messages.removeFriend) }}
@@ -15,12 +15,12 @@
</PageHeaderBadgeItem>
<PageHeaderBadgeItem
v-else
:icon="UnknownIcon"
:tooltip="sharedInstanceTooltip"
aria-label="Shared instance information"
class="!border-blue !bg-highlight-blue !text-blue"
>
Shared
<UnknownIcon class="block size-4 shrink-0 text-current" aria-hidden="true" />
</PageHeaderBadgeItem>
</template>
@@ -6,6 +6,11 @@
:instance="instance"
@published="emit('published')"
/>
<InstanceAdmonitionsSharedInstanceUpdateAvailable
v-else-if="item.kind === 'shared-instance-update-available'"
:instance-name="instance.name"
@review="emit('review-update', $event)"
/>
<InstanceAdmonitionsSharedInstanceWrongAccount
v-else-if="item.kind === 'shared-instance-wrong-account'"
:expected-user-id="sharedInstanceExpectedUserId"
@@ -33,6 +38,7 @@ import type { GameInstance } from '@/helpers/types'
import InstanceAdmonitionsSharedInstanceStale from './instance-admonitions-shared-instance-stale.vue'
import InstanceAdmonitionsSharedInstanceUnavailable from './instance-admonitions-shared-instance-unavailable.vue'
import InstanceAdmonitionsSharedInstanceUpdateAvailable from './instance-admonitions-shared-instance-update-available.vue'
import InstanceAdmonitionsSharedInstanceWrongAccount from './instance-admonitions-shared-instance-wrong-account.vue'
import type { InstanceAdmonitionItem, SharedInstanceRole } from './types'
@@ -48,11 +54,13 @@ const props = defineProps<{
sharedInstanceExpectedUserId?: string | null
sharedInstanceRole?: SharedInstanceRole | null
sharedInstanceSignedOut?: boolean
sharedInstanceUpdateAvailable?: boolean
}>()
const emit = defineEmits<{
published: []
delete: []
'review-update': [event: MouseEvent]
}>()
const sharedInstanceWrongAccount = computed(() => props.sharedInstanceWrongAccount ?? false)
@@ -68,6 +76,14 @@ const showSharedInstancePublishAdmonition = computed(
props.instance.shared_instance?.role === 'owner' &&
props.instance.shared_instance.status === 'stale',
)
const showSharedInstanceUpdateAdmonition = computed(
() =>
!sharedInstanceWrongAccount.value &&
!displayedSharedInstanceUnavailableReason.value &&
props.instance.install_stage === 'installed' &&
props.sharedInstanceRole === 'member' &&
props.sharedInstanceUpdateAvailable === true,
)
const stackItems = computed<InstanceAdmonitionItem[]>(() => {
const items: InstanceAdmonitionItem[] = []
@@ -104,6 +120,15 @@ const stackItems = computed<InstanceAdmonitionItem[]>(() => {
})
}
if (showSharedInstanceUpdateAdmonition.value) {
items.push({
id: 'shared-instance-update-available',
type: 'info',
dismissible: false,
kind: 'shared-instance-update-available',
})
}
return items
})
@@ -21,6 +21,19 @@ export const instanceAdmonitionsMessages = defineMessages({
id: 'app.instance.admonitions.shared-instance.reviewing-button',
defaultMessage: 'Reviewing...',
},
sharedInstanceUpdateAvailableHeader: {
id: 'app.instance.admonitions.shared-instance.update-available-header',
defaultMessage: 'An update is available',
},
sharedInstanceUpdateAvailableBody: {
id: 'app.instance.admonitions.shared-instance.update-available-body',
defaultMessage:
'An update is required to play {name}. Please update to latest version to launch the game.',
},
sharedInstanceReviewUpdateButton: {
id: 'app.instance.admonitions.shared-instance.review-update-button',
defaultMessage: 'Review update',
},
sharedInstanceReviewHeader: {
id: 'app.instance.admonitions.shared-instance.review-header',
defaultMessage: 'Review changes',
@@ -0,0 +1,34 @@
<template>
<Admonition
type="info"
inline-actions
:header="formatMessage(messages.sharedInstanceUpdateAvailableHeader)"
>
{{ formatMessage(messages.sharedInstanceUpdateAvailableBody, { name: instanceName }) }}
<template #actions>
<ButtonStyled color="blue">
<button class="!h-10" @click="emit('review', $event)">
<DownloadIcon aria-hidden="true" />
{{ formatMessage(messages.sharedInstanceReviewUpdateButton) }}
</button>
</ButtonStyled>
</template>
</Admonition>
</template>
<script setup lang="ts">
import { DownloadIcon } from '@modrinth/assets'
import { Admonition, ButtonStyled, useVIntl } from '@modrinth/ui'
import { instanceAdmonitionsMessages as messages } from './instance-admonitions-messages'
defineProps<{
instanceName: string
}>()
const emit = defineEmits<{
review: [event: MouseEvent]
}>()
const { formatMessage } = useVIntl()
</script>
@@ -2,6 +2,7 @@ import type { StackedAdmonitionItem } from '@modrinth/ui'
export type InstanceAdmonitionKind =
| 'shared-instance-stale'
| 'shared-instance-update-available'
| 'shared-instance-unavailable'
| 'shared-instance-wrong-account'
@@ -109,9 +109,13 @@ watch(selectedReleaseChannel, async (channel, previousChannel) => {
})
async function resetIcon() {
icon.value = undefined
await edit_icon(instance.value.id, null).catch(handleError)
trackEvent('InstanceRemoveIcon')
try {
await edit_icon(instance.value.id, null)
icon.value = undefined
trackEvent('InstanceRemoveIcon')
} catch (error) {
handleError(error)
}
}
async function setIcon() {
@@ -127,10 +131,13 @@ async function setIcon() {
if (!value) return
icon.value = value
await edit_icon(instance.value.id, icon.value).catch(handleError)
trackEvent('InstanceSetIcon')
try {
await edit_icon(instance.value.id, value)
icon.value = value
trackEvent('InstanceSetIcon')
} catch (error) {
handleError(error)
}
}
const editInstanceObject = computed(() => ({
@@ -1,30 +1,193 @@
<template>
<div>
<div class="flex flex-col gap-8">
<section class="flex flex-col gap-4">
<div class="flex flex-col gap-1">
<h3 class="m-0 text-lg font-semibold text-contrast">
{{ formatMessage(messages.activeInvitesTitle) }}
</h3>
<p class="m-0 text-secondary">
{{ formatMessage(messages.activeInvitesDescription) }}
</p>
</div>
<Table :columns="inviteColumns" :data="activeInvites" row-key="id" table-min-width="36rem">
<template #empty-state>
<div class="flex h-40 items-center justify-center text-secondary">
<SpinnerIcon
v-if="activeInvitesQuery.isLoading.value"
class="animate-spin"
aria-hidden="true"
/>
<template v-else>
{{ formatMessage(messages.noActiveInvites) }}
</template>
</div>
</template>
<template #cell-id="{ row }">
<CopyCode
:text="`${config.siteUrl}/share/${encodeURIComponent(row.id)}`"
:display-text="`/${row.id}`"
/>
</template>
<template #cell-uses="{ row }">
<span class="font-medium text-primary">{{ row.uses }}</span>
<span> / {{ row.maxUses }}</span>
</template>
<template #cell-expiration="{ row }">
<span v-tooltip="formatDateTime(row.expiration)" class="whitespace-nowrap">
{{ formatRelativeTime(row.expiration) }}
</span>
</template>
<template #cell-actions="{ row }">
<div class="flex justify-end">
<ButtonStyled circular type="transparent">
<button
v-tooltip="formatMessage(messages.revokeInvite)"
:aria-label="
formatMessage(messages.revokeInviteWithCode, {
code: row.id,
})
"
:disabled="revokeInviteMutation.isPending.value || isBusy"
class="text-secondary hover:!filter-none hover:text-red focus-visible:!filter-none"
@click="revokeInviteModal?.show(row.id)"
>
<SpinnerIcon
v-if="
revokeInviteMutation.isPending.value &&
revokeInviteMutation.variables.value?.inviteId === row.id
"
class="animate-spin"
aria-hidden="true"
/>
<XIcon v-else aria-hidden="true" />
</button>
</ButtonStyled>
</div>
</template>
</Table>
</section>
<SharedInstanceInstallationSettingsControls
can-unpublish
:busy="isBusy"
:unpublishing="unpublishing"
:unpublish="unpublishSharedInstance"
/>
<ConfirmRevokeSharedInstanceInviteModal ref="revokeInviteModal" @revoke="revokeInvite" />
</div>
</template>
<script setup lang="ts">
import { injectNotificationManager } from '@modrinth/ui'
import { useQueryClient } from '@tanstack/vue-query'
import { SpinnerIcon, XIcon } from '@modrinth/assets'
import {
ButtonStyled,
CopyCode,
defineMessages,
Table,
type TableColumn,
useFormatDateTime,
useRelativeTime,
useVIntl,
} from '@modrinth/ui'
import { useMutation, useQuery, useQueryClient } from '@tanstack/vue-query'
import { computed, ref } from 'vue'
import ConfirmRevokeSharedInstanceInviteModal from '@/components/ui/shared-instances/ConfirmRevokeSharedInstanceInviteModal.vue'
import SharedInstanceInstallationSettingsControls from '@/components/ui/shared-instances/SharedInstanceInstallationSettingsControls.vue'
import { unpublish_shared_instance } from '@/helpers/instance'
import { config } from '@/config'
import {
get_shared_instance_invites,
revoke_shared_instance_invite,
type SharedInstanceInvite,
unpublish_shared_instance,
} from '@/helpers/instance'
import { useSharedInstanceErrors } from '@/helpers/shared-instance-errors'
import { injectInstanceSettings } from '@/providers/instance-settings'
const { instance, offline, onUnlinked } = injectInstanceSettings()
const { handleError } = injectNotificationManager()
const { notifySharedInstanceError } = useSharedInstanceErrors()
const { formatMessage } = useVIntl()
const queryClient = useQueryClient()
const unpublishing = ref(false)
const isBusy = computed(
() => instance.value.install_stage !== 'installed' || unpublishing.value || !!offline,
const revokeInviteModal = ref<InstanceType<typeof ConfirmRevokeSharedInstanceInviteModal>>()
const formatRelativeTime = useRelativeTime()
const formatDateTime = useFormatDateTime({ dateStyle: 'medium', timeStyle: 'short' })
type InviteTableColumn = 'id' | 'uses' | 'expiration' | 'actions'
type ActiveInvitesQueryKey = readonly ['sharedInstanceInvites', string]
const activeInvitesQueryKey = computed(
() => ['sharedInstanceInvites', instance.value.id] as const satisfies ActiveInvitesQueryKey,
)
const activeInvitesQuery = useQuery({
queryKey: activeInvitesQueryKey,
queryFn: async ({ queryKey }) => {
try {
return await get_shared_instance_invites(queryKey[1])
} catch (error) {
notifySharedInstanceError(error)
throw error
}
},
enabled: () => !!instance.value.id && !offline,
retry: false,
staleTime: Infinity,
refetchOnMount: 'always',
refetchOnReconnect: false,
refetchOnWindowFocus: false,
})
const activeInvites = computed(() => activeInvitesQuery.data.value ?? [])
const revokeInviteMutation = useMutation({
mutationFn: ({ instanceId, inviteId }: { instanceId: string; inviteId: string }) =>
revoke_shared_instance_invite(instanceId, inviteId),
onSuccess: (_data, { instanceId, inviteId }) => {
queryClient.setQueryData<SharedInstanceInvite[]>(
['sharedInstanceInvites', instanceId],
(invites = []) => invites.filter((invite) => invite.id !== inviteId),
)
},
onError: notifySharedInstanceError,
})
const isBusy = computed(
() =>
instance.value.install_stage !== 'installed' ||
unpublishing.value ||
revokeInviteMutation.isPending.value ||
!!offline,
)
const inviteColumns = computed<TableColumn<InviteTableColumn>[]>(() => [
{
key: 'id',
label: formatMessage(messages.inviteCodeLabel),
width: 'clamp(11rem, 34%, 19rem)',
},
{
key: 'uses',
label: formatMessage(messages.usesLabel),
width: 'clamp(7rem, 18%, 10rem)',
},
{
key: 'expiration',
label: formatMessage(messages.expiresLabel),
width: 'clamp(9rem, 28%, 14rem)',
},
{
key: 'actions',
label: formatMessage(messages.actionsLabel),
align: 'right',
width: '5.5rem',
},
])
function revokeInvite(inviteId: string) {
if (revokeInviteMutation.isPending.value) return
revokeInviteMutation.mutate({ instanceId: instance.value.id, inviteId })
}
async function unpublishSharedInstance() {
unpublishing.value = true
@@ -34,9 +197,48 @@ async function unpublishSharedInstance() {
await queryClient.invalidateQueries({ queryKey: ['linkedModpackInfo', instance.value.id] })
onUnlinked()
} catch (error) {
handleError(error)
notifySharedInstanceError(error)
} finally {
unpublishing.value = false
}
}
const messages = defineMessages({
activeInvitesTitle: {
id: 'instance.settings.sharing.active-invites.title',
defaultMessage: 'Active invites',
},
activeInvitesDescription: {
id: 'instance.settings.sharing.active-invites.description',
defaultMessage: 'Anyone with one of these invite links can join while it remains active.',
},
inviteCodeLabel: {
id: 'instance.settings.sharing.active-invites.code',
defaultMessage: 'Invite link',
},
usesLabel: {
id: 'instance.settings.sharing.active-invites.uses',
defaultMessage: 'Uses',
},
expiresLabel: {
id: 'instance.settings.sharing.active-invites.expires',
defaultMessage: 'Expires',
},
actionsLabel: {
id: 'instance.settings.sharing.active-invites.actions',
defaultMessage: 'Actions',
},
noActiveInvites: {
id: 'instance.settings.sharing.active-invites.empty',
defaultMessage: 'There are no active invites.',
},
revokeInvite: {
id: 'instance.settings.sharing.active-invites.revoke',
defaultMessage: 'Revoke invite',
},
revokeInviteWithCode: {
id: 'instance.settings.sharing.active-invites.revoke-with-code',
defaultMessage: 'Revoke invite {code}',
},
})
</script>
@@ -3,12 +3,14 @@ import {
CoffeeIcon,
GameIcon,
GaugeIcon,
HeartHandshakeIcon,
LanguagesIcon,
ModrinthIcon,
PaintbrushIcon,
SettingsIcon,
Settings2Icon,
ShieldIcon,
ToggleRightIcon,
UserIcon,
} from '@modrinth/assets'
import {
commonMessages,
@@ -17,23 +19,32 @@ import {
defineMessages,
ProgressBar,
TabbedModal,
UnsavedChangesPopup,
useVIntl,
} from '@modrinth/ui'
import { getVersion } from '@tauri-apps/api/app'
import { platform as getOsPlatform, version as getOsVersion } from '@tauri-apps/plugin-os'
import { ref, watch } from 'vue'
import { computed, provide, ref, watch } from 'vue'
import AppearanceSettings from '@/components/ui/settings/AppearanceSettings.vue'
import DefaultInstanceSettings from '@/components/ui/settings/DefaultInstanceSettings.vue'
import FeatureFlagSettings from '@/components/ui/settings/FeatureFlagSettings.vue'
import JavaSettings from '@/components/ui/settings/JavaSettings.vue'
import LanguageSettings from '@/components/ui/settings/LanguageSettings.vue'
import PrivacySettings from '@/components/ui/settings/PrivacySettings.vue'
import ResourceManagementSettings from '@/components/ui/settings/ResourceManagementSettings.vue'
import PrivacySettings from '@/components/ui/settings/account/PrivacySettings.vue'
import ProfileSettings from '@/components/ui/settings/account/ProfileSettings.vue'
import SocialSettings from '@/components/ui/settings/account/SocialSettings.vue'
import AppearanceSettings from '@/components/ui/settings/display/AppearanceSettings.vue'
import BehaviorSettings from '@/components/ui/settings/display/BehaviorSettings.vue'
import FeatureFlagSettings from '@/components/ui/settings/display/FeatureFlagSettings.vue'
import LanguageSettings from '@/components/ui/settings/display/LanguageSettings.vue'
import DefaultInstanceSettings from '@/components/ui/settings/instances/DefaultInstanceSettings.vue'
import JavaSettings from '@/components/ui/settings/instances/JavaSettings.vue'
import ResourceManagementSettings from '@/components/ui/settings/instances/ResourceManagementSettings.vue'
import { get, set } from '@/helpers/settings.ts'
import {
appSettingsModalContextKey,
type UnsavedChangesController,
} from '@/providers/app-settings-modal'
import { injectAppUpdateDownloadProgress } from '@/providers/download-progress.ts'
import { useTheming } from '@/store/state'
// TODO: Apply COMPONENT_STRUCTURE.md here and extract out common setting option components
const themeStore = useTheming()
const { formatMessage } = useVIntl()
@@ -45,71 +56,162 @@ const developerModeEnabled = defineMessage({
defaultMessage: 'Developer mode enabled.',
})
const tabCategories = defineMessages({
display: {
id: 'settings.sidebar.label.display',
defaultMessage: 'Display',
},
account: {
id: 'settings.sidebar.label.account',
defaultMessage: 'Account',
},
instances: {
id: 'app.settings.sidebar.label.instances',
defaultMessage: 'Instances',
},
})
const tabs = [
{
name: defineMessage({
id: 'app.settings.tabs.appearance',
defaultMessage: 'Appearance',
}),
category: tabCategories.display,
icon: PaintbrushIcon,
content: AppearanceSettings,
},
{
name: defineMessage({
id: 'app.settings.tabs.behavior',
defaultMessage: 'Behavior',
}),
category: tabCategories.display,
icon: Settings2Icon,
content: BehaviorSettings,
},
{
name: defineMessage({
id: 'app.settings.tabs.language',
defaultMessage: 'Language',
}),
category: tabCategories.display,
icon: LanguagesIcon,
content: LanguageSettings,
badge: commonMessages.beta,
},
{
name: commonSettingsMessages.featureFlags,
category: tabCategories.display,
icon: ToggleRightIcon,
content: FeatureFlagSettings,
developerOnly: true,
},
{
name: commonSettingsMessages.profile,
category: tabCategories.account,
icon: UserIcon,
content: ProfileSettings,
},
{
name: commonSettingsMessages.social,
category: tabCategories.account,
icon: HeartHandshakeIcon,
content: SocialSettings,
},
{
name: defineMessage({
id: 'app.settings.tabs.privacy',
defaultMessage: 'Privacy',
}),
category: tabCategories.account,
icon: ShieldIcon,
content: PrivacySettings,
},
{
name: defineMessage({
id: 'app.settings.tabs.default-instance-options',
defaultMessage: 'Default game options',
}),
category: tabCategories.instances,
icon: GameIcon,
content: DefaultInstanceSettings,
},
{
name: defineMessage({
id: 'app.settings.tabs.java-installations',
defaultMessage: 'Java installations',
}),
category: tabCategories.instances,
icon: CoffeeIcon,
content: JavaSettings,
},
{
name: defineMessage({
id: 'app.settings.tabs.default-instance-options',
defaultMessage: 'Default instance options',
}),
icon: GameIcon,
content: DefaultInstanceSettings,
},
{
name: defineMessage({
id: 'app.settings.tabs.resource-management',
defaultMessage: 'Resource management',
}),
category: tabCategories.instances,
icon: GaugeIcon,
content: ResourceManagementSettings,
},
{
name: commonSettingsMessages.featureFlags,
icon: ToggleRightIcon,
content: FeatureFlagSettings,
developerOnly: true,
},
]
const availableTabs = computed(() => tabs.filter((tab) => !tab.developerOnly || themeStore.devMode))
const modal = ref<InstanceType<typeof TabbedModal> | null>(null)
const unsavedChangesPopup = ref<{ nudge: () => void } | null>(null)
const unsavedChangesController = ref<UnsavedChangesController | null>(null)
const emptyUnsavedChangesState: Record<string, unknown> = {}
const originalUnsavedChangesState = computed(
() => unsavedChangesController.value?.getOriginal() ?? emptyUnsavedChangesState,
)
const modifiedUnsavedChangesState = computed(
() => unsavedChangesController.value?.getModified() ?? emptyUnsavedChangesState,
)
const savingUnsavedChanges = computed(() => unsavedChangesController.value?.isSaving() ?? false)
const hasUnsavedChanges = computed(() => unsavedChangesController.value?.hasChanges() ?? false)
function canLeaveCurrentTab(): boolean {
if (!unsavedChangesController.value?.hasChanges()) return true
unsavedChangesPopup.value?.nudge()
return false
}
function close(): boolean {
return modal.value?.hide() ?? false
}
function registerUnsavedChangesController(controller: UnsavedChangesController | null): void {
unsavedChangesController.value = controller
}
provide(appSettingsModalContextKey, {
close,
registerUnsavedChangesController,
})
function resetUnsavedChanges(): void {
unsavedChangesController.value?.reset()
}
function saveUnsavedChanges(): void {
void unsavedChangesController.value?.save()
}
function show() {
modal.value?.show()
}
defineExpose({ show })
function showProfile(): void {
const profileTabIndex = availableTabs.value.findIndex((tab) => tab.content === ProfileSettings)
if (profileTabIndex >= 0) {
modal.value?.setTab(profileTabIndex)
}
modal.value?.show()
}
defineExpose({ show, showProfile })
const { progress, version: downloadingVersion } = injectAppUpdateDownloadProgress()
@@ -129,12 +231,15 @@ watch(
function devModeCount() {
devModeCounter.value++
if (devModeCounter.value > 5) {
const selectedTab = modal.value ? availableTabs.value[modal.value.selectedTab] : undefined
themeStore.devMode = !themeStore.devMode
settings.value.developer_mode = !!themeStore.devMode
devModeCounter.value = 0
if (!themeStore.devMode && tabs[modal.value!.selectedTab].developerOnly) {
modal.value!.setTab(0)
if (modal.value) {
const selectedTabIndex = selectedTab ? availableTabs.value.indexOf(selectedTab) : -1
modal.value.setTab(selectedTabIndex >= 0 ? selectedTabIndex : 0)
}
}
}
@@ -144,15 +249,45 @@ const messages = defineMessages({
id: 'app.settings.downloading',
defaultMessage: 'Downloading v{version}',
},
appVersion: {
id: 'app.settings.app-version',
defaultMessage: 'Modrinth App {version}',
},
macos: {
id: 'app.settings.operating-system.macos',
defaultMessage: 'macOS',
},
developerModeButtonLabel: {
id: 'app.settings.developer-mode-button.label',
defaultMessage: 'Toggle developer mode',
},
})
</script>
<template>
<TabbedModal ref="modal" :tabs="tabs.filter((t) => !t.developerOnly || themeStore.devMode)">
<TabbedModal
ref="modal"
:tabs="availableTabs"
:width="'min(928px, calc(95vw - 10rem))'"
:before-hide="canLeaveCurrentTab"
:before-tab-change="canLeaveCurrentTab"
:floating-action-bar-shown="hasUnsavedChanges"
>
<template #title>
<span class="flex items-center gap-2 text-lg font-extrabold text-contrast">
<SettingsIcon /> Settings
<span class="text-2xl font-semibold text-contrast">
{{ formatMessage(commonMessages.settingsLabel) }}
</span>
</template>
<template #floating-action-bar>
<UnsavedChangesPopup
ref="unsavedChangesPopup"
:original="originalUnsavedChangesState"
:modified="modifiedUnsavedChangesState"
:saving="savingUnsavedChanges"
inline
@reset="resetUnsavedChanges"
@save="saveUnsavedChanges"
/>
</template>
<template #footer>
<div class="mt-auto text-secondary text-sm">
<div class="mb-3">
@@ -168,6 +303,7 @@ const messages = defineMessages({
</p>
<div class="flex items-center gap-3">
<button
:aria-label="formatMessage(messages.developerModeButtonLabel)"
class="p-0 m-0 bg-transparent border-none cursor-pointer button-animation"
:class="{
'text-brand': themeStore.devMode,
@@ -175,12 +311,14 @@ const messages = defineMessages({
}"
@click="devModeCount"
>
<ModrinthIcon class="w-6 h-6" />
<ModrinthIcon aria-hidden="true" class="w-6 h-6" />
</button>
<div class="max-w-[200px]">
<p class="m-0">Modrinth App {{ version }}</p>
<p class="m-0">
<span v-if="osPlatform === 'macos'">macOS</span>
{{ formatMessage(messages.appVersion, { version }) }}
</p>
<p class="m-0">
<span v-if="osPlatform === 'macos'">{{ formatMessage(messages.macos) }}</span>
<span v-else class="capitalize">{{ osPlatform }}</span>
{{ osVersion }}
</p>
@@ -1,335 +0,0 @@
<script setup lang="ts">
import { Combobox, defineMessages, ThemeSelector, Toggle, useVIntl } from '@modrinth/ui'
import { ref, watch } from 'vue'
import { get, set } from '@/helpers/settings.ts'
import { getOS } from '@/helpers/utils'
import { useTheming } from '@/store/state'
import type { ColorTheme, FeatureFlag } from '@/store/theme.ts'
const themeStore = useTheming()
const { formatMessage } = useVIntl()
const worldsInHomeFlag: FeatureFlag = 'worlds_in_home'
const skipNonEssentialWarningsFlag: FeatureFlag = 'skip_non_essential_warnings'
const skipUnknownPackWarningFlag: FeatureFlag = 'skip_unknown_pack_warning'
const showPlayTimeFlag: FeatureFlag = 'show_instance_play_time'
const messages = defineMessages({
colorThemeTitle: {
id: 'app.appearance-settings.color-theme.title',
defaultMessage: 'Color theme',
},
colorThemeDescription: {
id: 'app.appearance-settings.color-theme.description',
defaultMessage: 'Select your preferred color theme for Modrinth App.',
},
advancedRenderingTitle: {
id: 'app.appearance-settings.advanced-rendering.title',
defaultMessage: 'Advanced rendering',
},
advancedRenderingDescription: {
id: 'app.appearance-settings.advanced-rendering.description',
defaultMessage:
'Enables advanced rendering such as blur effects that may cause performance issues without hardware-accelerated rendering.',
},
hideNametagTitle: {
id: 'app.appearance-settings.hide-nametag.title',
defaultMessage: 'Hide nametag',
},
hideNametagDescription: {
id: 'app.appearance-settings.hide-nametag.description',
defaultMessage: 'Disables the nametag above your player on the skins page.',
},
nativeDecorationsTitle: {
id: 'app.appearance-settings.native-decorations.title',
defaultMessage: 'Native decorations',
},
nativeDecorationsDescription: {
id: 'app.appearance-settings.native-decorations.description',
defaultMessage: 'Use system window frame (app restart required).',
},
minimizeLauncherTitle: {
id: 'app.appearance-settings.minimize-launcher.title',
defaultMessage: 'Minimize launcher',
},
minimizeLauncherDescription: {
id: 'app.appearance-settings.minimize-launcher.description',
defaultMessage: 'Minimize the launcher when a Minecraft process starts.',
},
defaultLandingPageTitle: {
id: 'app.appearance-settings.default-landing-page.title',
defaultMessage: 'Default landing page',
},
defaultLandingPageDescription: {
id: 'app.appearance-settings.default-landing-page.description',
defaultMessage: 'Change the page to which the launcher opens on.',
},
defaultLandingPageHome: {
id: 'app.appearance-settings.default-landing-page.home',
defaultMessage: 'Home',
},
defaultLandingPageLibrary: {
id: 'app.appearance-settings.default-landing-page.library',
defaultMessage: 'Library',
},
selectOption: {
id: 'app.appearance-settings.select-option',
defaultMessage: 'Select an option',
},
jumpBackIntoWorldsTitle: {
id: 'app.appearance-settings.jump-back-into-worlds.title',
defaultMessage: 'Jump back into worlds',
},
jumpBackIntoWorldsDescription: {
id: 'app.appearance-settings.jump-back-into-worlds.description',
defaultMessage: 'Includes recent worlds in the "Jump back in" section on the Home page.',
},
toggleSidebarTitle: {
id: 'app.appearance-settings.toggle-sidebar.title',
defaultMessage: 'Toggle sidebar',
},
toggleSidebarDescription: {
id: 'app.appearance-settings.toggle-sidebar.description',
defaultMessage: 'Enables the ability to toggle the sidebar.',
},
unknownPackWarningTitle: {
id: 'app.appearance-settings.unknown-pack-warning.title',
defaultMessage: 'Warn me before installing unknown modpacks',
},
unknownPackWarningDescription: {
id: 'app.appearance-settings.unknown-pack-warning.description',
defaultMessage:
"If you attempt to install a Modrinth Pack file (.mrpack) that isn't hosted on Modrinth, we'll make sure you understand the risks before installing it.",
},
skipNonEssentialWarningsTitle: {
id: 'app.appearance-settings.skip-non-essential-warnings.title',
defaultMessage: 'Skip non-essential warnings',
},
skipNonEssentialWarningsDescription: {
id: 'app.appearance-settings.skip-non-essential-warnings.description',
defaultMessage:
'Automatically skips low-risk confirmations like duplicate modpack installs, normal content deletion, bulk updates, unlinking modpacks, and repair prompts. Dangerous warnings will still be shown.',
},
showPlayTimeTitle: {
id: 'app.appearance-settings.show-play-time.title',
defaultMessage: 'Show play time',
},
showPlayTimeDescription: {
id: 'app.appearance-settings.show-play-time.description',
defaultMessage: `Displays how much time you've spent playing an instance.`,
},
})
const os = ref(await getOS())
const settings = ref(await get())
watch(
settings,
async () => {
await set(settings.value)
},
{ deep: true },
)
</script>
<template>
<h2 class="m-0 text-lg font-semibold text-contrast">
{{ formatMessage(messages.colorThemeTitle) }}
</h2>
<p class="m-0 mt-1">{{ formatMessage(messages.colorThemeDescription) }}</p>
<ThemeSelector
:update-color-theme="
(theme: ColorTheme) => {
themeStore.setThemeState(theme)
settings.theme = theme
}
"
:current-theme="settings.theme"
:theme-options="themeStore.getThemeOptions()"
system-theme-color="system"
/>
<div class="mt-6 flex items-center justify-between">
<div>
<h2 class="m-0 text-lg font-semibold text-contrast">
{{ formatMessage(messages.advancedRenderingTitle) }}
</h2>
<p class="m-0 mt-1">
{{ formatMessage(messages.advancedRenderingDescription) }}
</p>
</div>
<Toggle
id="advanced-rendering"
:model-value="themeStore.advancedRendering"
@update:model-value="
(e) => {
themeStore.advancedRendering = !!e
settings.advanced_rendering = themeStore.advancedRendering
}
"
/>
</div>
<div v-if="os !== 'MacOS'" class="mt-6 flex items-center justify-between gap-4">
<div>
<h2 class="m-0 text-lg font-semibold text-contrast">
{{ formatMessage(messages.nativeDecorationsTitle) }}
</h2>
<p class="m-0 mt-1">{{ formatMessage(messages.nativeDecorationsDescription) }}</p>
</div>
<Toggle id="native-decorations" v-model="settings.native_decorations" />
</div>
<div class="mt-6 flex items-center justify-between">
<div>
<h2 class="m-0 text-lg font-semibold text-contrast">
{{ formatMessage(messages.minimizeLauncherTitle) }}
</h2>
<p class="m-0 mt-1">{{ formatMessage(messages.minimizeLauncherDescription) }}</p>
</div>
<Toggle id="minimize-launcher" v-model="settings.hide_on_process_start" />
</div>
<div class="mt-6 flex items-center justify-between">
<div>
<h2 class="m-0 text-lg font-semibold text-contrast">
{{ formatMessage(messages.showPlayTimeTitle) }}
</h2>
<p class="m-0 mt-1">{{ formatMessage(messages.showPlayTimeDescription) }}</p>
</div>
<Toggle
:model-value="themeStore.getFeatureFlag(showPlayTimeFlag)"
@update:model-value="
() => {
const newValue = !themeStore.getFeatureFlag(showPlayTimeFlag)
themeStore.featureFlags[showPlayTimeFlag] = newValue
settings.feature_flags[showPlayTimeFlag] = newValue
}
"
/>
</div>
<div class="mt-6 flex items-center justify-between">
<div>
<h2 class="m-0 text-lg font-semibold text-contrast">
{{ formatMessage(messages.hideNametagTitle) }}
</h2>
<p class="m-0 mt-1">{{ formatMessage(messages.hideNametagDescription) }}</p>
</div>
<Toggle
id="hide-nametag-skins-page"
:model-value="themeStore.hideNametagSkinsPage"
@update:model-value="
(e) => {
themeStore.hideNametagSkinsPage = !!e
settings.hide_nametag_skins_page = themeStore.hideNametagSkinsPage
}
"
/>
</div>
<div class="mt-6 flex items-center justify-between">
<div>
<h2 class="m-0 text-lg font-semibold text-contrast">
{{ formatMessage(messages.defaultLandingPageTitle) }}
</h2>
<p class="m-0 mt-1">{{ formatMessage(messages.defaultLandingPageDescription) }}</p>
</div>
<Combobox
id="opening-page"
v-model="settings.default_page"
name="Opening page dropdown"
class="max-w-40"
:options="[
{
value: 'Home',
label: formatMessage(messages.defaultLandingPageHome),
},
{
value: 'Library',
label: formatMessage(messages.defaultLandingPageLibrary),
},
]"
:display-value="settings.default_page ?? 'Select an option'"
/>
</div>
<div class="mt-6 flex items-center justify-between">
<div>
<h2 class="m-0 text-lg font-semibold text-contrast">
{{ formatMessage(messages.jumpBackIntoWorldsTitle) }}
</h2>
<p class="m-0 mt-1">{{ formatMessage(messages.jumpBackIntoWorldsDescription) }}</p>
</div>
<Toggle
:model-value="themeStore.getFeatureFlag(worldsInHomeFlag)"
@update:model-value="
() => {
const newValue = !themeStore.getFeatureFlag(worldsInHomeFlag)
themeStore.featureFlags[worldsInHomeFlag] = newValue
settings.feature_flags[worldsInHomeFlag] = newValue
}
"
/>
</div>
<div class="mt-6 flex items-center justify-between gap-4">
<div>
<h2 class="m-0 text-lg font-semibold text-contrast">
{{ formatMessage(messages.unknownPackWarningTitle) }}
</h2>
<p class="m-0 mt-1">{{ formatMessage(messages.unknownPackWarningDescription) }}</p>
</div>
<Toggle
:model-value="!themeStore.getFeatureFlag(skipUnknownPackWarningFlag)"
@update:model-value="
(e) => {
const warnBeforeUnknownPackInstall = !!e
const skipUnknownPackWarning = !warnBeforeUnknownPackInstall
themeStore.featureFlags[skipUnknownPackWarningFlag] = skipUnknownPackWarning
settings.feature_flags[skipUnknownPackWarningFlag] = skipUnknownPackWarning
}
"
/>
</div>
<div class="mt-6 flex items-center justify-between gap-4">
<div>
<h2 class="m-0 text-lg font-semibold text-contrast">
{{ formatMessage(messages.skipNonEssentialWarningsTitle) }}
</h2>
<p class="m-0 mt-1">{{ formatMessage(messages.skipNonEssentialWarningsDescription) }}</p>
</div>
<Toggle
:model-value="themeStore.getFeatureFlag(skipNonEssentialWarningsFlag)"
@update:model-value="
() => {
const newValue = !themeStore.getFeatureFlag(skipNonEssentialWarningsFlag)
themeStore.featureFlags[skipNonEssentialWarningsFlag] = newValue
settings.feature_flags[skipNonEssentialWarningsFlag] = newValue
}
"
/>
</div>
<div class="mt-6 flex items-center justify-between">
<div>
<h2 class="m-0 text-lg font-semibold text-contrast">
{{ formatMessage(messages.toggleSidebarTitle) }}
</h2>
<p class="m-0 mt-1">{{ formatMessage(messages.toggleSidebarDescription) }}</p>
</div>
<Toggle
id="toggle-sidebar"
:model-value="settings.toggle_sidebar"
@update:model-value="
(e) => {
settings.toggle_sidebar = !!e
themeStore.toggleSidebar = settings.toggle_sidebar
}
"
/>
</div>
</template>
@@ -1,186 +0,0 @@
<script setup lang="ts">
import { injectNotificationManager, Slider, StyledInput, Toggle } from '@modrinth/ui'
import { ref, watch } from 'vue'
import useMemorySlider from '@/composables/useMemorySlider'
import { get, set } from '@/helpers/settings.ts'
const { handleError } = injectNotificationManager()
const fetchSettings = await get()
fetchSettings.launchArgs = fetchSettings.extra_launch_args.join(' ')
fetchSettings.envVars = fetchSettings.custom_env_vars.map((x) => x.join('=')).join(' ')
const settings = ref(fetchSettings)
const { maxMemory, snapPoints } = (await useMemorySlider().catch(handleError)) as unknown as {
maxMemory: number
snapPoints: number[]
}
watch(
settings,
async () => {
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
}
if (!setSettings.custom_dir) {
setSettings.custom_dir = null
}
await set(setSettings)
},
{ deep: true },
)
</script>
<template>
<div>
<div class="flex flex-col gap-6">
<div class="flex items-center justify-between gap-4">
<div class="flex flex-col gap-1">
<h3 class="m-0 text-lg font-semibold text-contrast">Fullscreen</h3>
<p class="m-0 leading-tight">
Overwrites the options.txt file to start in full screen when launched.
</p>
</div>
<Toggle id="fullscreen" v-model="settings.force_fullscreen" />
</div>
<div class="flex items-center justify-between gap-4">
<div class="flex flex-col gap-1">
<h3 class="m-0 text-lg font-semibold text-contrast">Width</h3>
<p class="m-0 leading-tight">The width of the game window when launched.</p>
</div>
<StyledInput
id="width"
v-model="settings.game_resolution[0]"
:disabled="settings.force_fullscreen"
autocomplete="off"
type="number"
placeholder="Enter width..."
/>
</div>
<div class="flex items-center justify-between gap-4">
<div class="flex flex-col gap-1">
<h3 class="m-0 text-lg font-semibold text-contrast">Height</h3>
<p class="m-0 leading-tight">The height of the game window when launched.</p>
</div>
<StyledInput
id="height"
v-model="settings.game_resolution[1]"
:disabled="settings.force_fullscreen"
autocomplete="off"
type="number"
placeholder="Enter height..."
/>
</div>
</div>
<hr class="my-6 bg-button-border border-none h-[1px]" />
<div class="flex flex-col gap-6">
<div class="flex flex-col gap-2.5">
<h2 class="m-0 text-lg font-semibold text-contrast">Memory allocated</h2>
<Slider
id="max-memory"
v-model="settings.memory.maximum"
:min="512"
:max="maxMemory"
:step="64"
:snap-points="snapPoints"
:snap-range="512"
unit="MB"
/>
<p class="m-0 mt-1 leading-tight">The memory allocated to each instance when it is ran.</p>
</div>
<div class="flex flex-col gap-2.5">
<h2 class="m-0 text-lg font-semibold text-contrast">Java arguments</h2>
<StyledInput
id="java-args"
v-model="settings.launchArgs"
autocomplete="off"
type="text"
placeholder="Enter java arguments..."
wrapper-class="w-full"
/>
</div>
<div class="flex flex-col gap-2.5">
<h2 class="m-0 text-lg font-semibold text-contrast">Environmental variables</h2>
<StyledInput
id="env-vars"
v-model="settings.envVars"
autocomplete="off"
type="text"
placeholder="Enter environmental variables..."
wrapper-class="w-full"
/>
</div>
</div>
<hr class="my-6 bg-button-border border-none h-[1px]" />
<div class="flex flex-col gap-6">
<div class="flex flex-col gap-2.5">
<h3 class="m-0 text-lg font-semibold text-contrast">Pre launch hook</h3>
<StyledInput
id="pre-launch"
v-model="settings.hooks.pre_launch"
autocomplete="off"
type="text"
placeholder="Enter pre-launch command..."
wrapper-class="w-full"
/>
<p class="m-0 leading-tight">Ran before the instance is launched.</p>
</div>
<div class="flex flex-col gap-2.5">
<h3 class="m-0 text-lg font-semibold text-contrast">Wrapper hook</h3>
<StyledInput
id="wrapper"
v-model="settings.hooks.wrapper"
autocomplete="off"
type="text"
placeholder="Enter wrapper command..."
wrapper-class="w-full"
/>
<p class="m-0 leading-tight">Wrapper command for launching Minecraft.</p>
</div>
<div class="flex flex-col gap-2.5">
<h3 class="m-0 text-lg font-semibold text-contrast">Post exit hook</h3>
<StyledInput
id="post-exit"
v-model="settings.hooks.post_exit"
autocomplete="off"
type="text"
placeholder="Enter post-exit command..."
wrapper-class="w-full"
/>
<p class="m-0 leading-tight">Ran after the game closes.</p>
</div>
</div>
</div>
</template>
@@ -1,212 +0,0 @@
<script setup>
import { BoxIcon, FolderOpenIcon, FolderSearchIcon, TrashIcon } from '@modrinth/assets'
import {
ButtonStyled,
defineMessages,
injectNotificationManager,
Slider,
StyledInput,
Toggle,
useVIntl,
} from '@modrinth/ui'
import { open } from '@tauri-apps/plugin-dialog'
import { ref, watch } from 'vue'
import ConfirmModalWrapper from '@/components/ui/modal/ConfirmModalWrapper.vue'
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 settings = ref(await get())
const purgeCacheConfirmModal = ref(null)
const alwaysShowCopyDetailsFlag = 'always_show_copy_details'
const messages = defineMessages({
alwaysShowCopyDetailsTitle: {
id: 'app.resource-management-settings.always-show-copy-details.title',
defaultMessage: 'Always show copy details',
},
alwaysShowCopyDetailsDescription: {
id: 'app.resource-management-settings.always-show-copy-details.description',
defaultMessage:
'Show the Copy details action while an install is queued or running. It is always available for failed or interrupted installs.',
},
})
watch(
settings,
async () => {
const setSettings = JSON.parse(JSON.stringify(settings.value))
if (!setSettings.custom_dir) {
setSettings.custom_dir = null
}
await set(setSettings)
},
{ deep: true },
)
async function purgeCache() {
await purge_cache_types([
'project',
'project_v3',
'version',
'user',
'team',
'organization',
'file',
'loader_manifest',
'minecraft_manifest',
'categories',
'report_types',
'loaders',
'game_versions',
'donation_platforms',
'file_hash',
'file_update',
'search_results',
'search_results_v3',
]).catch(handleError)
}
function handlePurgeCacheClick() {
if (themeStore.getFeatureFlag('skip_non_essential_warnings')) {
void purgeCache()
return
}
purgeCacheConfirmModal.value?.show()
}
async function openDbBackupsFolder() {
await showAppDbBackupsFolder().catch(handleError)
}
async function findLauncherDir() {
const newDir = await open({
multiple: false,
directory: true,
title: 'Select a new app directory',
})
if (newDir) {
settings.value.custom_dir = newDir
}
}
</script>
<template>
<div class="flex flex-col gap-6">
<div class="flex flex-col gap-2.5">
<h2 class="m-0 text-lg font-semibold text-contrast">App directory</h2>
<StyledInput
id="appDir"
v-model="settings.custom_dir"
:icon="BoxIcon"
type="text"
wrapper-class="w-full"
>
<template #right>
<ButtonStyled circular>
<button class="ml-1.5" @click="findLauncherDir">
<FolderSearchIcon />
</button>
</ButtonStyled>
</template>
</StyledInput>
<p class="m-0 leading-tight text-secondary">
The directory where the launcher stores all of its files. Changes will be applied after
restarting the launcher.
</p>
</div>
<div class="flex flex-col gap-2.5">
<ConfirmModalWrapper
ref="purgeCacheConfirmModal"
title="Are you sure you want to purge the cache?"
description="If you proceed, your entire cache will be purged. This may slow down the app temporarily."
:has-to-type="false"
proceed-label="Purge cache"
:show-ad-on-close="false"
@proceed="purgeCache"
/>
<h2 class="m-0 text-lg font-semibold text-contrast">App cache</h2>
<button id="purge-cache" class="btn min-w-max" @click="handlePurgeCacheClick">
<TrashIcon />
Purge cache
</button>
<p class="m-0 leading-tight text-secondary">
The Modrinth app stores a cache of data to speed up loading. This can be purged to force the
app to reload data. This may slow down the app temporarily.
</p>
</div>
<div class="flex flex-col gap-2.5">
<h2 class="m-0 text-lg font-semibold text-contrast mt-4">Maximum concurrent downloads</h2>
<Slider
id="max-downloads"
v-model="settings.max_concurrent_downloads"
:min="1"
:max="10"
:step="1"
/>
<p class="m-0 leading-tight text-secondary">
The maximum amount of files the launcher can download at the same time. Set this to a lower
value if you have a poor internet connection. (app restart required to take effect)
</p>
</div>
<div class="flex flex-col gap-2.5">
<h2 class="mt-0 m-0 text-lg font-semibold text-contrast">Maximum concurrent writes</h2>
<Slider
id="max-writes"
v-model="settings.max_concurrent_writes"
:min="1"
:max="50"
:step="1"
/>
<p class="m-0 leading-tight text-secondary">
The maximum amount of files the launcher can write to the disk at once. Set this to a lower
value if you are frequently getting I/O errors. (app restart required to take effect)
</p>
</div>
<div class="flex items-center justify-between gap-4">
<div>
<h2 class="m-0 text-lg font-semibold text-contrast">
{{ formatMessage(messages.alwaysShowCopyDetailsTitle) }}
</h2>
<p class="m-0 mt-1">
{{ formatMessage(messages.alwaysShowCopyDetailsDescription) }}
</p>
</div>
<Toggle
id="always-show-copy-details"
:model-value="themeStore.getFeatureFlag(alwaysShowCopyDetailsFlag)"
@update:model-value="
() => {
const newValue = !themeStore.getFeatureFlag(alwaysShowCopyDetailsFlag)
themeStore.featureFlags[alwaysShowCopyDetailsFlag] = newValue
settings.feature_flags[alwaysShowCopyDetailsFlag] = newValue
}
"
/>
</div>
<div class="flex flex-col gap-2.5">
<h2 class="mt-0 m-0 text-lg font-semibold text-contrast">App database backups</h2>
<button id="open-db-backups-folder" class="btn min-w-max" @click="openDbBackupsFolder">
<FolderOpenIcon />
Open backups folder
</button>
<p class="m-0 leading-tight text-secondary">
Backups of important app data are stored here in case you need to recover them later.
</p>
</div>
</div>
</template>
@@ -33,6 +33,24 @@ const messages = defineMessages({
id: 'app.ads-consent.manage',
defaultMessage: 'Manage preferences',
},
telemetryTitle: {
id: 'app.settings.privacy.telemetry.title',
defaultMessage: 'Telemetry',
},
telemetryDescription: {
id: 'app.settings.privacy.telemetry.description',
defaultMessage:
'Modrinth collects anonymized analytics and usage data to improve our user experience and customize your experience. By disabling this option, you opt out and your data will no longer be collected.',
},
discordRichPresenceTitle: {
id: 'app.settings.privacy.discord-rich-presence.title',
defaultMessage: 'Discord Rich Presence',
},
discordRichPresenceDescription: {
id: 'app.settings.privacy.discord-rich-presence.description',
defaultMessage:
'Show Modrinth App as your current activity on Discord. This does not affect Rich Presence added to instances by mods. Requires an app restart.',
},
})
async function manageAdsPreferences() {
@@ -59,28 +77,26 @@ watch(
<h2 class="m-0 text-lg font-semibold text-contrast">
{{ formatMessage(messages.adsConsentTitle) }}
</h2>
<div class="mt-1 flex flex-col gap-2.5 items-start">
<div class="flex flex-col gap-1 items-start">
<div class="text-sm">
{{ formatMessage(messages.adsConsentIntro) }}
</div>
</div>
<div class="mt-2 flex flex-col gap-2.5 items-start">
<ButtonStyled>
<button class="!shadow-none" @click="manageAdsPreferences">
<Settings2Icon aria-hidden="true" />
{{ formatMessage(messages.adsConsentManage) }}
</button>
</ButtonStyled>
<div>
{{ formatMessage(messages.adsConsentIntro) }}
</div>
</div>
</div>
<div class="mt-8 flex items-center justify-between gap-4">
<div>
<h2 class="m-0 text-lg font-semibold text-contrast">Telemetry</h2>
<p class="m-0 mt-1 text-sm">
Modrinth collects anonymized analytics and usage data to improve our user experience and
customize your experience. By disabling this option, you opt out and your data will no
longer be collected.
<h2 class="m-0 text-lg font-semibold text-contrast">
{{ formatMessage(messages.telemetryTitle) }}
</h2>
<p class="m-0 mt-1">
{{ formatMessage(messages.telemetryDescription) }}
</p>
</div>
<Toggle id="opt-out-analytics" v-model="settings.telemetry" />
@@ -88,14 +104,11 @@ watch(
<div class="mt-4 flex items-center justify-between gap-4">
<div>
<h2 class="m-0 text-lg font-semibold text-contrast">Discord RPC</h2>
<p class="m-0 mt-1 text-sm">
Manages the Discord Rich Presence integration. Disabling this will cause 'Modrinth' to no
longer show up as a game or app you are using on your Discord profile.
</p>
<p class="m-0 mt-2 text-sm">
Note: This will not prevent any instance-specific Discord Rich Presence integrations, such
as those added by mods. (app restart required to take effect)
<h2 class="m-0 text-lg font-semibold text-contrast">
{{ formatMessage(messages.discordRichPresenceTitle) }}
</h2>
<p class="m-0 mt-1">
{{ formatMessage(messages.discordRichPresenceDescription) }}
</p>
</div>
<Toggle id="disable-discord-rpc" v-model="settings.discord_rpc" />
@@ -0,0 +1,70 @@
<template>
<AccountProfileSettings
ref="profileSettings"
:patch-user="patchUser"
:change-avatar="changeAvatar"
:delete-avatar="deleteAvatar"
:get-authenticated-user="getAuthenticatedUser"
@profile-link-click="handleProfileLinkClick"
/>
</template>
<script setup lang="ts">
import type { Labrinth } from '@modrinth/api-client'
import { AccountProfileSettings, injectAuth } from '@modrinth/ui'
import { inject, onBeforeUnmount, onMounted, ref } from 'vue'
import {
change_user_avatar,
delete_user_avatar,
get_user_profile,
patch_user,
} from '@/helpers/users'
import { appSettingsModalContextKey } from '@/providers/app-settings-modal'
const settingsModal = inject(appSettingsModalContextKey, null)
const auth = injectAuth()
const profileSettings = ref<InstanceType<typeof AccountProfileSettings> | null>(null)
onMounted(() => {
settingsModal?.registerUnsavedChangesController({
hasChanges: () => profileSettings.value?.hasChanges ?? false,
getOriginal: () => profileSettings.value?.originalState ?? {},
getModified: () => profileSettings.value?.modifiedState ?? {},
isSaving: () => profileSettings.value?.saving ?? false,
reset: () => profileSettings.value?.reset(),
save: () => profileSettings.value?.save(),
})
})
onBeforeUnmount(() => {
settingsModal?.registerUnsavedChangesController(null)
})
function handleProfileLinkClick(event: MouseEvent): void {
if (settingsModal && !settingsModal.close()) {
event.preventDefault()
}
}
function patchUser(
userId: string,
patch: Partial<Pick<Labrinth.Users.v2.User, 'bio' | 'username'>>,
): Promise<void> {
return patch_user(userId, patch)
}
async function changeAvatar(userId: string, file: Blob, extension: string): Promise<void> {
await change_user_avatar(userId, new Uint8Array(await file.arrayBuffer()), extension)
}
function deleteAvatar(userId: string): Promise<void> {
return delete_user_avatar(userId)
}
function getAuthenticatedUser(): Promise<Labrinth.Users.v3.User> {
const userId = auth.user.value?.id
if (!userId) throw new Error('Cannot refresh a signed-out user.')
return get_user_profile(userId)
}
</script>
@@ -0,0 +1,20 @@
<template>
<AccountSocialSettings
:get-blocked-users="get_blocked_users"
:get-users="getUsers"
:unblock-user="unblock_user"
/>
</template>
<script setup lang="ts">
import type { Labrinth } from '@modrinth/api-client'
import { AccountSocialSettings, injectModrinthClient } from '@modrinth/ui'
import { get_blocked_users, unblock_user } from '@/helpers/users'
const client = injectModrinthClient()
function getUsers(userIds: string[]): Promise<Labrinth.Users.v2.User[]> {
return client.labrinth.users_v2.getMultiple(userIds)
}
</script>
@@ -0,0 +1,108 @@
<script setup lang="ts">
import { defineMessages, ThemeSelector, Toggle, useVIntl } from '@modrinth/ui'
import { computed, ref, watch } from 'vue'
import { get, set } from '@/helpers/settings.ts'
import { getOS } from '@/helpers/utils'
import { useTheming } from '@/store/state'
import type { ColorTheme } from '@/store/theme.ts'
const themeStore = useTheming()
const { formatMessage } = useVIntl()
const messages = defineMessages({
colorThemeTitle: {
id: 'app.appearance-settings.color-theme.title',
defaultMessage: 'Color theme',
},
colorThemeDescription: {
id: 'app.appearance-settings.color-theme.description',
defaultMessage: 'Choose the color theme used by Modrinth App.',
},
advancedRenderingTitle: {
id: 'app.appearance-settings.advanced-rendering.title',
defaultMessage: 'Advanced rendering',
},
advancedRenderingDescription: {
id: 'app.appearance-settings.advanced-rendering.description',
defaultMessage:
'Enable visual effects such as background blur. This may reduce performance without hardware acceleration.',
},
nativeDecorationsTitle: {
id: 'app.appearance-settings.native-decorations.title',
defaultMessage: 'System window frame',
},
nativeDecorationsDescription: {
id: 'app.appearance-settings.native-decorations.description',
defaultMessage:
"Use your operating system's title bar and window controls. Requires an app restart.",
},
})
const os = ref(await getOS())
const settings = ref(await get())
const themeOptions = computed(() =>
themeStore
.getThemeOptions()
.filter((theme) => theme !== 'retro' || themeStore.devMode || settings.value.theme === 'retro'),
)
watch(
settings,
async () => {
await set(settings.value)
},
{ deep: true },
)
</script>
<template>
<h2 class="m-0 text-lg font-semibold text-contrast">
{{ formatMessage(messages.colorThemeTitle) }}
</h2>
<p class="m-0 mt-1">{{ formatMessage(messages.colorThemeDescription) }}</p>
<ThemeSelector
:update-color-theme="
(theme: ColorTheme) => {
themeStore.setThemeState(theme)
settings.theme = theme
}
"
:current-theme="settings.theme"
:theme-options="themeOptions"
system-theme-color="system"
/>
<div class="mt-6 flex items-center justify-between">
<div>
<h2 class="m-0 text-lg font-semibold text-contrast">
{{ formatMessage(messages.advancedRenderingTitle) }}
</h2>
<p class="m-0 mt-1">
{{ formatMessage(messages.advancedRenderingDescription) }}
</p>
</div>
<Toggle
id="advanced-rendering"
:model-value="themeStore.advancedRendering"
@update:model-value="
(e) => {
themeStore.advancedRendering = !!e
settings.advanced_rendering = themeStore.advancedRendering
}
"
/>
</div>
<div v-if="os !== 'MacOS'" class="mt-6 flex items-center justify-between gap-4">
<div>
<h2 class="m-0 text-lg font-semibold text-contrast">
{{ formatMessage(messages.nativeDecorationsTitle) }}
</h2>
<p class="m-0 mt-1">{{ formatMessage(messages.nativeDecorationsDescription) }}</p>
</div>
<Toggle id="native-decorations" v-model="settings.native_decorations" />
</div>
</template>
@@ -0,0 +1,302 @@
<script setup lang="ts">
import { Chips, defineMessages, Toggle, useVIntl } from '@modrinth/ui'
import { ref, watch } from 'vue'
import { get, set } from '@/helpers/settings.ts'
import { useTheming } from '@/store/state'
import type { FeatureFlag } from '@/store/theme.ts'
const themeStore = useTheming()
const { formatMessage } = useVIntl()
const worldsInHomeFlag: FeatureFlag = 'worlds_in_home'
const skipNonEssentialWarningsFlag: FeatureFlag = 'skip_non_essential_warnings'
const skipUnknownPackWarningFlag: FeatureFlag = 'skip_unknown_pack_warning'
const showPlayTimeFlag: FeatureFlag = 'show_instance_play_time'
type LandingPage = 'Home' | 'Library'
const landingPageOptions: LandingPage[] = ['Home', 'Library']
const messages = defineMessages({
startupAndNavigationTitle: {
id: 'app.behavior-settings.startup-and-navigation.title',
defaultMessage: 'Startup and navigation',
},
contentTitle: {
id: 'app.behavior-settings.content.title',
defaultMessage: 'Home and content',
},
confirmationsTitle: {
id: 'app.behavior-settings.confirmations.title',
defaultMessage: 'Confirmations',
},
minimizeLauncherTitle: {
id: 'app.appearance-settings.minimize-launcher.title',
defaultMessage: 'Minimize app',
},
minimizeLauncherDescription: {
id: 'app.appearance-settings.minimize-launcher.description',
defaultMessage: 'Minimize Modrinth App when Minecraft starts.',
},
defaultLandingPageTitle: {
id: 'app.appearance-settings.default-landing-page.title',
defaultMessage: 'Default landing page',
},
defaultLandingPageDescription: {
id: 'app.appearance-settings.default-landing-page.description',
defaultMessage: 'Choose the page shown when Modrinth App opens.',
},
defaultLandingPageHome: {
id: 'app.appearance-settings.default-landing-page.home',
defaultMessage: 'Home',
},
defaultLandingPageLibrary: {
id: 'app.appearance-settings.default-landing-page.library',
defaultMessage: 'Library',
},
toggleSidebarTitle: {
id: 'app.appearance-settings.toggle-sidebar.title',
defaultMessage: 'Hide right sidebar',
},
toggleSidebarDescription: {
id: 'app.appearance-settings.toggle-sidebar.description',
defaultMessage: 'Hide the right sidebar by default and add a button to show or hide it.',
},
jumpBackIntoWorldsTitle: {
id: 'app.appearance-settings.jump-back-into-worlds.title',
defaultMessage: 'Jump back into worlds',
},
jumpBackIntoWorldsDescription: {
id: 'app.appearance-settings.jump-back-into-worlds.description',
defaultMessage: 'Show recently played worlds in the "Jump back in" section on the Home page.',
},
showPlayTimeTitle: {
id: 'app.appearance-settings.show-play-time.title',
defaultMessage: 'Show play time',
},
showPlayTimeDescription: {
id: 'app.appearance-settings.show-play-time.description',
defaultMessage: `Show how long you've played each instance.`,
},
hideNametagTitle: {
id: 'app.appearance-settings.hide-nametag.title',
defaultMessage: 'Hide nametag',
},
hideNametagDescription: {
id: 'app.appearance-settings.hide-nametag.description',
defaultMessage: 'Hide your username above the player preview on the Skin selector page.',
},
unknownPackWarningTitle: {
id: 'app.appearance-settings.unknown-pack-warning.title',
defaultMessage: 'Warn me before installing unknown modpacks',
},
unknownPackWarningDescription: {
id: 'app.appearance-settings.unknown-pack-warning.description',
defaultMessage:
"Show a safety warning before installing a Modrinth Pack (.mrpack) that isn't hosted on Modrinth.",
},
skipNonEssentialWarningsTitle: {
id: 'app.appearance-settings.skip-non-essential-warnings.title',
defaultMessage: 'Skip non-essential warnings',
},
skipNonEssentialWarningsDescription: {
id: 'app.appearance-settings.skip-non-essential-warnings.description',
defaultMessage:
'Skip confirmations for low-risk actions such as duplicate installs, normal content deletion, bulk updates, unlinking, and repairs. Warnings for dangerous actions are always shown.',
},
})
function formatLandingPageLabel(page: LandingPage) {
switch (page) {
case 'Home':
return formatMessage(messages.defaultLandingPageHome)
case 'Library':
return formatMessage(messages.defaultLandingPageLibrary)
}
}
const settings = ref(await get())
watch(
settings,
async () => {
await set(settings.value)
},
{ deep: true },
)
</script>
<template>
<section>
<h2 class="m-0 text-xl font-semibold text-contrast">
{{ formatMessage(messages.startupAndNavigationTitle) }}
</h2>
<div class="mt-4 flex flex-col gap-6">
<div class="flex flex-col gap-2.5">
<h3 class="m-0 text-lg font-semibold text-contrast">
{{ formatMessage(messages.defaultLandingPageTitle) }}
</h3>
<Chips
v-model="settings.default_page"
:items="landingPageOptions"
:format-label="formatLandingPageLabel"
:capitalize="false"
:aria-label="formatMessage(messages.defaultLandingPageTitle)"
/>
<p class="m-0">
{{ formatMessage(messages.defaultLandingPageDescription) }}
</p>
</div>
<div class="flex items-center justify-between gap-4">
<div>
<h3 class="m-0 text-lg font-semibold text-contrast">
{{ formatMessage(messages.minimizeLauncherTitle) }}
</h3>
<p class="m-0 mt-1">
{{ formatMessage(messages.minimizeLauncherDescription) }}
</p>
</div>
<Toggle id="minimize-launcher" v-model="settings.hide_on_process_start" />
</div>
<div class="flex items-center justify-between gap-4">
<div>
<h3 class="m-0 text-lg font-semibold text-contrast">
{{ formatMessage(messages.toggleSidebarTitle) }}
</h3>
<p class="m-0 mt-1">{{ formatMessage(messages.toggleSidebarDescription) }}</p>
</div>
<Toggle
id="toggle-sidebar"
:model-value="settings.toggle_sidebar"
@update:model-value="
(e) => {
settings.toggle_sidebar = !!e
themeStore.toggleSidebar = settings.toggle_sidebar
}
"
/>
</div>
</div>
</section>
<section class="mt-8 border-0 border-t border-solid border-divider pt-6">
<h2 class="m-0 text-xl font-semibold text-contrast">
{{ formatMessage(messages.contentTitle) }}
</h2>
<div class="mt-4 flex flex-col gap-6">
<div class="flex items-center justify-between gap-4">
<div>
<h3 class="m-0 text-lg font-semibold text-contrast">
{{ formatMessage(messages.jumpBackIntoWorldsTitle) }}
</h3>
<p class="m-0 mt-1">
{{ formatMessage(messages.jumpBackIntoWorldsDescription) }}
</p>
</div>
<Toggle
id="jump-back-into-worlds"
:model-value="themeStore.getFeatureFlag(worldsInHomeFlag)"
@update:model-value="
() => {
const newValue = !themeStore.getFeatureFlag(worldsInHomeFlag)
themeStore.featureFlags[worldsInHomeFlag] = newValue
settings.feature_flags[worldsInHomeFlag] = newValue
}
"
/>
</div>
<div class="flex items-center justify-between gap-4">
<div>
<h3 class="m-0 text-lg font-semibold text-contrast">
{{ formatMessage(messages.showPlayTimeTitle) }}
</h3>
<p class="m-0 mt-1">{{ formatMessage(messages.showPlayTimeDescription) }}</p>
</div>
<Toggle
id="show-play-time"
:model-value="themeStore.getFeatureFlag(showPlayTimeFlag)"
@update:model-value="
() => {
const newValue = !themeStore.getFeatureFlag(showPlayTimeFlag)
themeStore.featureFlags[showPlayTimeFlag] = newValue
settings.feature_flags[showPlayTimeFlag] = newValue
}
"
/>
</div>
<div class="flex items-center justify-between gap-4">
<div>
<h3 class="m-0 text-lg font-semibold text-contrast">
{{ formatMessage(messages.hideNametagTitle) }}
</h3>
<p class="m-0 mt-1">{{ formatMessage(messages.hideNametagDescription) }}</p>
</div>
<Toggle
id="hide-nametag-skins-page"
:model-value="themeStore.hideNametagSkinsPage"
@update:model-value="
(e) => {
themeStore.hideNametagSkinsPage = !!e
settings.hide_nametag_skins_page = themeStore.hideNametagSkinsPage
}
"
/>
</div>
</div>
</section>
<section class="mt-8 border-0 border-t border-solid border-divider pt-6">
<h2 class="m-0 text-xl font-semibold text-contrast">
{{ formatMessage(messages.confirmationsTitle) }}
</h2>
<div class="mt-4 flex flex-col gap-6">
<div class="flex items-center justify-between gap-4">
<div>
<h3 class="m-0 text-lg font-semibold text-contrast">
{{ formatMessage(messages.unknownPackWarningTitle) }}
</h3>
<p class="m-0 mt-1">
{{ formatMessage(messages.unknownPackWarningDescription) }}
</p>
</div>
<Toggle
id="warn-before-installing-unknown-modpacks"
:model-value="!themeStore.getFeatureFlag(skipUnknownPackWarningFlag)"
@update:model-value="
(e) => {
const warnBeforeUnknownPackInstall = !!e
const skipUnknownPackWarning = !warnBeforeUnknownPackInstall
themeStore.featureFlags[skipUnknownPackWarningFlag] = skipUnknownPackWarning
settings.feature_flags[skipUnknownPackWarningFlag] = skipUnknownPackWarning
}
"
/>
</div>
<div class="flex items-center justify-between gap-4">
<div>
<h3 class="m-0 text-lg font-semibold text-contrast">
{{ formatMessage(messages.skipNonEssentialWarningsTitle) }}
</h3>
<p class="m-0 mt-1">
{{ formatMessage(messages.skipNonEssentialWarningsDescription) }}
</p>
</div>
<Toggle
id="skip-non-essential-warnings"
:model-value="themeStore.getFeatureFlag(skipNonEssentialWarningsFlag)"
@update:model-value="
() => {
const newValue = !themeStore.getFeatureFlag(skipNonEssentialWarningsFlag)
themeStore.featureFlags[skipNonEssentialWarningsFlag] = newValue
settings.feature_flags[skipNonEssentialWarningsFlag] = newValue
}
"
/>
</div>
</div>
</section>
</template>
@@ -25,7 +25,7 @@ watch(
)
</script>
<template>
<div class="flex flex-col gap-2.5 min-w-[600px]">
<div class="flex flex-col gap-2.5">
<div v-for="option in options" :key="option" class="flex items-center justify-between">
<div>
<h2 class="m-0 text-lg font-semibold text-contrast capitalize">
@@ -2,6 +2,7 @@
import {
Admonition,
AutoLink,
commonSettingsMessages,
IntlFormatted,
LanguageSelector,
languageSelectorMessages,
@@ -43,7 +44,9 @@ async function onLocaleChange(newLocale: string) {
</script>
<template>
<h2 class="m-0 text-lg font-semibold text-contrast">Language</h2>
<h2 class="m-0 text-lg font-semibold text-contrast">
{{ formatMessage(commonSettingsMessages.language) }}
</h2>
<Admonition type="warning" class="mt-2 mb-4">
{{ formatMessage(languageSelectorMessages.languageWarning, { platform }) }}
@@ -0,0 +1,333 @@
<script setup lang="ts">
import {
defineMessages,
injectNotificationManager,
Slider,
StyledInput,
Toggle,
useVIntl,
} from '@modrinth/ui'
import { ref, watch } from 'vue'
import useMemorySlider from '@/composables/useMemorySlider'
import { get, set } from '@/helpers/settings.ts'
const { handleError } = injectNotificationManager()
const { formatMessage } = useVIntl()
const messages = defineMessages({
fullscreenTitle: {
id: 'app.settings.default-instance-options.fullscreen.title',
defaultMessage: 'Fullscreen',
},
fullscreenDescription: {
id: 'app.settings.default-instance-options.fullscreen.description',
defaultMessage: 'Start instances in fullscreen by updating their options.txt file.',
},
widthTitle: {
id: 'app.settings.default-instance-options.width.title',
defaultMessage: 'Width',
},
widthDescription: {
id: 'app.settings.default-instance-options.width.description',
defaultMessage: 'The width of the game window when launched.',
},
widthPlaceholder: {
id: 'app.settings.default-instance-options.width.placeholder',
defaultMessage: 'Enter width...',
},
heightTitle: {
id: 'app.settings.default-instance-options.height.title',
defaultMessage: 'Height',
},
heightDescription: {
id: 'app.settings.default-instance-options.height.description',
defaultMessage: 'The height of the game window when launched.',
},
heightPlaceholder: {
id: 'app.settings.default-instance-options.height.placeholder',
defaultMessage: 'Enter height...',
},
memoryAllocationTitle: {
id: 'app.settings.default-instance-options.memory-allocation.title',
defaultMessage: 'Memory allocation',
},
memoryAllocationDescription: {
id: 'app.settings.default-instance-options.memory-allocation.description',
defaultMessage: 'Maximum memory available to each instance.',
},
javaArgumentsTitle: {
id: 'app.settings.default-instance-options.java-arguments.title',
defaultMessage: 'Java arguments',
},
javaArgumentsPlaceholder: {
id: 'app.settings.default-instance-options.java-arguments.placeholder',
defaultMessage: 'Enter Java arguments...',
},
javaArgumentsDescription: {
id: 'app.settings.default-instance-options.java-arguments.description',
defaultMessage: 'Arguments passed to Java when launching an instance.',
},
environmentVariablesTitle: {
id: 'app.settings.default-instance-options.environment-variables.title',
defaultMessage: 'Environment variables',
},
environmentVariablesPlaceholder: {
id: 'app.settings.default-instance-options.environment-variables.placeholder',
defaultMessage: 'Enter environment variables...',
},
environmentVariablesDescription: {
id: 'app.settings.default-instance-options.environment-variables.description',
defaultMessage: 'Environment variables set when launching an instance.',
},
preLaunchHookTitle: {
id: 'app.settings.default-instance-options.pre-launch-hook.title',
defaultMessage: 'Pre-launch hook',
},
preLaunchHookPlaceholder: {
id: 'app.settings.default-instance-options.pre-launch-hook.placeholder',
defaultMessage: 'Enter pre-launch command...',
},
preLaunchHookDescription: {
id: 'app.settings.default-instance-options.pre-launch-hook.description',
defaultMessage: 'Runs before the instance starts.',
},
wrapperHookTitle: {
id: 'app.settings.default-instance-options.wrapper-hook.title',
defaultMessage: 'Wrapper hook',
},
wrapperHookPlaceholder: {
id: 'app.settings.default-instance-options.wrapper-hook.placeholder',
defaultMessage: 'Enter wrapper command...',
},
wrapperHookDescription: {
id: 'app.settings.default-instance-options.wrapper-hook.description',
defaultMessage: 'Command used to wrap the Minecraft launch process.',
},
postExitHookTitle: {
id: 'app.settings.default-instance-options.post-exit-hook.title',
defaultMessage: 'Post-exit hook',
},
postExitHookPlaceholder: {
id: 'app.settings.default-instance-options.post-exit-hook.placeholder',
defaultMessage: 'Enter post-exit command...',
},
postExitHookDescription: {
id: 'app.settings.default-instance-options.post-exit-hook.description',
defaultMessage: 'Runs after the game closes.',
},
})
const fetchSettings = await get()
fetchSettings.launchArgs = fetchSettings.extra_launch_args.join(' ')
fetchSettings.envVars = fetchSettings.custom_env_vars.map((x) => x.join('=')).join(' ')
const settings = ref(fetchSettings)
const { maxMemory, snapPoints } = (await useMemorySlider().catch(handleError)) as unknown as {
maxMemory: number
snapPoints: number[]
}
watch(
settings,
async () => {
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
}
if (!setSettings.custom_dir) {
setSettings.custom_dir = null
}
await set(setSettings)
},
{ deep: true },
)
</script>
<template>
<div>
<div class="flex flex-col gap-6">
<div class="flex items-center justify-between gap-4">
<div class="flex flex-col gap-1">
<h3 class="m-0 text-lg font-semibold text-contrast">
{{ formatMessage(messages.fullscreenTitle) }}
</h3>
<p class="m-0 leading-tight">
{{ formatMessage(messages.fullscreenDescription) }}
</p>
</div>
<Toggle id="fullscreen" v-model="settings.force_fullscreen" />
</div>
<div class="flex items-center justify-between gap-4">
<div class="flex flex-col gap-1">
<h3 class="m-0 text-lg font-semibold text-contrast">
{{ formatMessage(messages.widthTitle) }}
</h3>
<p class="m-0 leading-tight">
{{ formatMessage(messages.widthDescription) }}
</p>
</div>
<StyledInput
id="width"
v-model="settings.game_resolution[0]"
:disabled="settings.force_fullscreen"
autocomplete="off"
type="number"
:placeholder="formatMessage(messages.widthPlaceholder)"
/>
</div>
<div class="flex items-center justify-between gap-4">
<div class="flex flex-col gap-1">
<h3 class="m-0 text-lg font-semibold text-contrast">
{{ formatMessage(messages.heightTitle) }}
</h3>
<p class="m-0 leading-tight">
{{ formatMessage(messages.heightDescription) }}
</p>
</div>
<StyledInput
id="height"
v-model="settings.game_resolution[1]"
:disabled="settings.force_fullscreen"
autocomplete="off"
type="number"
:placeholder="formatMessage(messages.heightPlaceholder)"
/>
</div>
</div>
<hr class="my-6 bg-button-border border-none h-[1px]" />
<div class="flex flex-col gap-6">
<div class="flex flex-col gap-2.5">
<h2 class="m-0 text-lg font-semibold text-contrast">
{{ formatMessage(messages.memoryAllocationTitle) }}
</h2>
<Slider
id="max-memory"
v-model="settings.memory.maximum"
:min="512"
:max="maxMemory"
:step="64"
:snap-points="snapPoints"
:snap-range="512"
unit="MB"
/>
<p class="m-0 mt-1 leading-tight">
{{ formatMessage(messages.memoryAllocationDescription) }}
</p>
</div>
<div class="flex flex-col gap-2.5">
<h2 class="m-0 text-lg font-semibold text-contrast">
{{ formatMessage(messages.javaArgumentsTitle) }}
</h2>
<StyledInput
id="java-args"
v-model="settings.launchArgs"
autocomplete="off"
type="text"
:placeholder="formatMessage(messages.javaArgumentsPlaceholder)"
wrapper-class="w-full"
/>
<p class="m-0 leading-tight">
{{ formatMessage(messages.javaArgumentsDescription) }}
</p>
</div>
<div class="flex flex-col gap-2.5">
<h2 class="m-0 text-lg font-semibold text-contrast">
{{ formatMessage(messages.environmentVariablesTitle) }}
</h2>
<StyledInput
id="env-vars"
v-model="settings.envVars"
autocomplete="off"
type="text"
:placeholder="formatMessage(messages.environmentVariablesPlaceholder)"
wrapper-class="w-full"
/>
<p class="m-0 leading-tight">
{{ formatMessage(messages.environmentVariablesDescription) }}
</p>
</div>
</div>
<hr class="my-6 bg-button-border border-none h-[1px]" />
<div class="flex flex-col gap-6">
<div class="flex flex-col gap-2.5">
<h3 class="m-0 text-lg font-semibold text-contrast">
{{ formatMessage(messages.preLaunchHookTitle) }}
</h3>
<StyledInput
id="pre-launch"
v-model="settings.hooks.pre_launch"
autocomplete="off"
type="text"
:placeholder="formatMessage(messages.preLaunchHookPlaceholder)"
wrapper-class="w-full"
/>
<p class="m-0 leading-tight">
{{ formatMessage(messages.preLaunchHookDescription) }}
</p>
</div>
<div class="flex flex-col gap-2.5">
<h3 class="m-0 text-lg font-semibold text-contrast">
{{ formatMessage(messages.wrapperHookTitle) }}
</h3>
<StyledInput
id="wrapper"
v-model="settings.hooks.wrapper"
autocomplete="off"
type="text"
:placeholder="formatMessage(messages.wrapperHookPlaceholder)"
wrapper-class="w-full"
/>
<p class="m-0 leading-tight">
{{ formatMessage(messages.wrapperHookDescription) }}
</p>
</div>
<div class="flex flex-col gap-2.5">
<h3 class="m-0 text-lg font-semibold text-contrast">
{{ formatMessage(messages.postExitHookTitle) }}
</h3>
<StyledInput
id="post-exit"
v-model="settings.hooks.post_exit"
autocomplete="off"
type="text"
:placeholder="formatMessage(messages.postExitHookPlaceholder)"
wrapper-class="w-full"
/>
<p class="m-0 leading-tight">
{{ formatMessage(messages.postExitHookDescription) }}
</p>
</div>
</div>
</div>
</template>
@@ -1,11 +1,19 @@
<script setup>
import { injectNotificationManager } from '@modrinth/ui'
import { defineMessages, injectNotificationManager, useVIntl } from '@modrinth/ui'
import { ref } from 'vue'
import JavaSelector from '@/components/ui/JavaSelector.vue'
import { get_java_versions, set_java_version } from '@/helpers/jre'
const { handleError } = injectNotificationManager()
const { formatMessage } = useVIntl()
const messages = defineMessages({
javaLocation: {
id: 'app.settings.java-installations.location.title',
defaultMessage: 'Java {version, number} location',
},
})
const javaVersions = ref(await get_java_versions().catch(handleError))
async function updateJavaVersion(version) {
@@ -28,7 +36,7 @@ async function updateJavaVersion(version) {
class="flex flex-col gap-2.5"
>
<h2 class="m-0 text-lg font-semibold text-contrast" :class="{ 'mt-4': index !== 0 }">
Java {{ javaVersion }} location
{{ formatMessage(messages.javaLocation, { version: javaVersion }) }}
</h2>
<JavaSelector
:id="'java-selector-' + javaVersion"
@@ -0,0 +1,292 @@
<script setup>
import { BoxIcon, FolderOpenIcon, FolderSearchIcon, TrashIcon } from '@modrinth/assets'
import {
ButtonStyled,
defineMessages,
injectNotificationManager,
Slider,
StyledInput,
Toggle,
useVIntl,
} from '@modrinth/ui'
import { open } from '@tauri-apps/plugin-dialog'
import { ref, watch } from 'vue'
import ConfirmModalWrapper from '@/components/ui/modal/ConfirmModalWrapper.vue'
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 settings = ref(await get())
const purgeCacheConfirmModal = ref(null)
const alwaysShowCopyDetailsFlag = 'always_show_copy_details'
const messages = defineMessages({
appDirectoryTitle: {
id: 'app.settings.resource-management.app-directory.title',
defaultMessage: 'App directory',
},
appDirectoryDescription: {
id: 'app.settings.resource-management.app-directory.description',
defaultMessage:
'Where Modrinth App stores instances and other files. Changes take effect after restarting the app.',
},
selectAppDirectory: {
id: 'app.settings.resource-management.app-directory.select',
defaultMessage: 'Select a new app directory',
},
browseAppDirectory: {
id: 'app.settings.resource-management.app-directory.browse',
defaultMessage: 'Browse for an app directory',
},
appCacheTitle: {
id: 'app.settings.resource-management.app-cache.title',
defaultMessage: 'App cache',
},
purgeCache: {
id: 'app.settings.resource-management.app-cache.purge',
defaultMessage: 'Purge cache',
},
purgeCacheConfirmTitle: {
id: 'app.settings.resource-management.app-cache.confirm.title',
defaultMessage: 'Purge the app cache?',
},
purgeCacheConfirmDescription: {
id: 'app.settings.resource-management.app-cache.confirm.description',
defaultMessage: 'The app may load more slowly until the cache is rebuilt.',
},
appCacheDescription: {
id: 'app.settings.resource-management.app-cache.description',
defaultMessage:
'Clear cached data and download it again from Modrinth. The app may load more slowly until the cache is rebuilt.',
},
maximumConcurrentDownloadsTitle: {
id: 'app.settings.resource-management.maximum-concurrent-downloads.title',
defaultMessage: 'Maximum concurrent downloads',
},
maximumConcurrentDownloadsDescription: {
id: 'app.settings.resource-management.maximum-concurrent-downloads.description',
defaultMessage:
'Number of files the app can download at once. Lower this if downloads are unreliable on your connection. Requires an app restart.',
},
maximumConcurrentWritesTitle: {
id: 'app.settings.resource-management.maximum-concurrent-writes.title',
defaultMessage: 'Maximum concurrent writes',
},
maximumConcurrentWritesDescription: {
id: 'app.settings.resource-management.maximum-concurrent-writes.description',
defaultMessage:
'Number of files the app can write to disk at once. Lower this if you frequently encounter I/O errors. Requires an app restart.',
},
alwaysShowCopyDetailsTitle: {
id: 'app.settings.resource-management.always-show-copy-details.title',
defaultMessage: 'Always show copy details',
},
alwaysShowCopyDetailsDescription: {
id: 'app.settings.resource-management.always-show-copy-details.description',
defaultMessage:
'Show the Copy details action while an install is queued or running. It is always available for failed or interrupted installs.',
},
appDatabaseBackupsTitle: {
id: 'app.settings.resource-management.app-database-backups.title',
defaultMessage: 'App database backups',
},
openBackupsFolder: {
id: 'app.settings.resource-management.app-database-backups.open-folder',
defaultMessage: 'Open backups folder',
},
appDatabaseBackupsDescription: {
id: 'app.settings.resource-management.app-database-backups.description',
defaultMessage:
'Backups of important app data are stored here in case you need to recover them later.',
},
})
watch(
settings,
async () => {
const setSettings = JSON.parse(JSON.stringify(settings.value))
if (!setSettings.custom_dir) {
setSettings.custom_dir = null
}
await set(setSettings)
},
{ deep: true },
)
async function purgeCache() {
await purge_cache_types([
'project',
'project_v3',
'version',
'user',
'team',
'organization',
'file',
'loader_manifest',
'minecraft_manifest',
'categories',
'report_types',
'loaders',
'game_versions',
'donation_platforms',
'file_hash',
'file_update',
'search_results',
'search_results_v3',
]).catch(handleError)
}
function handlePurgeCacheClick() {
if (themeStore.getFeatureFlag('skip_non_essential_warnings')) {
void purgeCache()
return
}
purgeCacheConfirmModal.value?.show()
}
async function openDbBackupsFolder() {
await showAppDbBackupsFolder().catch(handleError)
}
async function findLauncherDir() {
const newDir = await open({
multiple: false,
directory: true,
title: formatMessage(messages.selectAppDirectory),
})
if (newDir) {
settings.value.custom_dir = newDir
}
}
</script>
<template>
<div class="flex flex-col gap-6">
<div class="flex flex-col gap-2.5">
<h2 class="m-0 text-lg font-semibold text-contrast">
{{ formatMessage(messages.appDirectoryTitle) }}
</h2>
<StyledInput
id="appDir"
v-model="settings.custom_dir"
:icon="BoxIcon"
type="text"
wrapper-class="w-full"
>
<template #right>
<ButtonStyled circular>
<button
v-tooltip="formatMessage(messages.browseAppDirectory)"
:aria-label="formatMessage(messages.browseAppDirectory)"
class="ml-1.5"
@click="findLauncherDir"
>
<FolderSearchIcon aria-hidden="true" />
</button>
</ButtonStyled>
</template>
</StyledInput>
<p class="m-0 leading-tight text-secondary">
{{ formatMessage(messages.appDirectoryDescription) }}
</p>
</div>
<div class="flex items-center justify-between gap-4">
<div>
<h2 class="m-0 text-lg font-semibold text-contrast">
{{ formatMessage(messages.alwaysShowCopyDetailsTitle) }}
</h2>
<p class="m-0 mt-1">
{{ formatMessage(messages.alwaysShowCopyDetailsDescription) }}
</p>
</div>
<Toggle
id="always-show-copy-details"
:model-value="themeStore.getFeatureFlag(alwaysShowCopyDetailsFlag)"
@update:model-value="
() => {
const newValue = !themeStore.getFeatureFlag(alwaysShowCopyDetailsFlag)
themeStore.featureFlags[alwaysShowCopyDetailsFlag] = newValue
settings.feature_flags[alwaysShowCopyDetailsFlag] = newValue
}
"
/>
</div>
<div class="flex flex-col gap-2.5">
<ConfirmModalWrapper
ref="purgeCacheConfirmModal"
:title="formatMessage(messages.purgeCacheConfirmTitle)"
:description="formatMessage(messages.purgeCacheConfirmDescription)"
:has-to-type="false"
:proceed-label="formatMessage(messages.purgeCache)"
:show-ad-on-close="false"
@proceed="purgeCache"
/>
<h2 class="m-0 text-lg font-semibold text-contrast">
{{ formatMessage(messages.appCacheTitle) }}
</h2>
<button id="purge-cache" class="btn min-w-max" @click="handlePurgeCacheClick">
<TrashIcon aria-hidden="true" />
{{ formatMessage(messages.purgeCache) }}
</button>
<p class="m-0 leading-tight text-secondary">
{{ formatMessage(messages.appCacheDescription) }}
</p>
</div>
<div class="flex flex-col gap-2.5">
<h2 class="m-0 text-lg font-semibold text-contrast mt-4">
{{ formatMessage(messages.maximumConcurrentDownloadsTitle) }}
</h2>
<Slider
id="max-downloads"
v-model="settings.max_concurrent_downloads"
:min="1"
:max="10"
:step="1"
/>
<p class="m-0 leading-tight text-secondary">
{{ formatMessage(messages.maximumConcurrentDownloadsDescription) }}
</p>
</div>
<div class="flex flex-col gap-2.5">
<h2 class="mt-0 m-0 text-lg font-semibold text-contrast">
{{ formatMessage(messages.maximumConcurrentWritesTitle) }}
</h2>
<Slider
id="max-writes"
v-model="settings.max_concurrent_writes"
:min="1"
:max="50"
:step="1"
/>
<p class="m-0 leading-tight text-secondary">
{{ formatMessage(messages.maximumConcurrentWritesDescription) }}
</p>
</div>
<div class="flex flex-col gap-2.5">
<h2 class="mt-0 m-0 text-lg font-semibold text-contrast">
{{ formatMessage(messages.appDatabaseBackupsTitle) }}
</h2>
<button id="open-db-backups-folder" class="btn min-w-max" @click="openDbBackupsFolder">
<FolderOpenIcon aria-hidden="true" />
{{ formatMessage(messages.openBackupsFolder) }}
</button>
<p class="m-0 leading-tight text-secondary">
{{ formatMessage(messages.appDatabaseBackupsDescription) }}
</p>
</div>
</div>
</template>
@@ -0,0 +1,82 @@
<template>
<NewModal ref="modal" :header="formatMessage(messages.header)" fade="danger" max-width="500px">
<Admonition type="critical" :header="formatMessage(messages.admonitionHeader)">
<IntlFormatted :message-id="messages.admonitionBody" :values="{ code: inviteCode }">
<template #monospace="{ children }">
<code class="font-mono"><component :is="() => children" /></code>
</template>
</IntlFormatted>
</Admonition>
<template #actions>
<div class="flex justify-end gap-2">
<ButtonStyled type="outlined">
<button @click="modal?.hide()">
<XIcon />
{{ formatMessage(commonMessages.cancelButton) }}
</button>
</ButtonStyled>
<ButtonStyled color="red">
<button @click="confirm">
<XIcon />
{{ formatMessage(messages.revokeButton) }}
</button>
</ButtonStyled>
</div>
</template>
</NewModal>
</template>
<script setup lang="ts">
import { XIcon } from '@modrinth/assets'
import {
Admonition,
ButtonStyled,
commonMessages,
defineMessages,
IntlFormatted,
NewModal,
useVIntl,
} from '@modrinth/ui'
import { ref } from 'vue'
const { formatMessage } = useVIntl()
const modal = ref<InstanceType<typeof NewModal>>()
const inviteCode = ref('')
const emit = defineEmits<{
revoke: [inviteCode: string]
}>()
function show(code: string) {
inviteCode.value = code
modal.value?.show()
}
function confirm() {
modal.value?.hide()
emit('revoke', inviteCode.value)
}
const messages = defineMessages({
header: {
id: 'instance.settings.sharing.revoke-invite.header',
defaultMessage: 'Revoke invite',
},
admonitionHeader: {
id: 'instance.settings.sharing.revoke-invite.admonition-header',
defaultMessage: 'This action cannot be undone',
},
admonitionBody: {
id: 'instance.settings.sharing.revoke-invite.admonition-body',
defaultMessage:
'The invite link <monospace>{code}</monospace> will stop working immediately. People who already joined will keep access.',
},
revokeButton: {
id: 'instance.settings.sharing.revoke-invite.confirm',
defaultMessage: 'Revoke invite',
},
})
defineExpose({ show })
</script>
@@ -26,12 +26,13 @@
</span>
</template>
<div class="flex min-w-0 flex-col gap-3 pt-4">
<div class="max-h-[292px] overflow-y-auto rounded-[20px]">
<div ref="configFileTreeContainer" class="max-h-[292px] overflow-y-auto rounded-[20px]">
<FileTreeSelect
v-model="selectedConfigPaths"
:items="configFileItems"
:show-size="false"
:show-modified="false"
@navigate="scrollConfigFileTreeToTop"
/>
</div>
</div>
@@ -75,6 +76,7 @@ const emit = defineEmits<{
const { formatMessage } = useVIntl()
const { notifySharedInstanceError, notifySharedInstanceUnavailable } = useSharedInstanceErrors()
const publishReviewModal = ref<InstanceType<typeof ContentDiffModal>>()
const configFileTreeContainer = ref<HTMLElement>()
const publishDiffs = ref<ContentDiffItem[]>([])
const configFilePaths = ref<string[]>([])
const selectedConfigPaths = ref<string[]>([])
@@ -132,6 +134,12 @@ async function publishChanges() {
}
}
function scrollConfigFileTreeToTop() {
if (configFileTreeContainer.value) {
configFileTreeContainer.value.scrollTop = 0
}
}
function finishReview() {
if (state.value === 'reviewing') {
setState('idle')
@@ -43,8 +43,9 @@ import type { GameInstance } from '@/helpers/types'
type UpdateCompleteCallback = () => void | Promise<void>
const emit = defineEmits<{
accepted: []
cancel: []
complete: []
complete: [successful: boolean]
report: [event?: MouseEvent]
sharedInstanceUnavailable: [reason: SharedInstanceUnavailableReason | null]
}>()
@@ -72,11 +73,14 @@ const diffs = computed<ContentDiffItem[]>(
)
async function update() {
let successful = false
emit('accepted')
try {
if (instance.value) {
const job = await install_update_shared_instance(instance.value.id)
await wait_for_install_job(job.job_id)
await onComplete.value()
successful = true
}
} catch (error) {
if (isSharedInstanceUnavailableError(error)) {
@@ -85,7 +89,7 @@ async function update() {
}
notifySharedInstanceError(error)
} finally {
emit('complete')
emit('complete', successful)
}
}
@@ -64,7 +64,29 @@
</div>
</Admonition>
<p v-else class="m-0 text-primary">
{{ formatMessage(messages.inviteWarning) }}
<IntlFormatted
v-if="creator"
:message-id="messages.inviteWarningWithCreator"
:values="{ username: creator.username }"
>
<template #creator="{ children }">
<AutoLink :to="creatorProfileLink" class="font-medium text-contrast hover:underline">
<Avatar
:src="creator.avatarUrl"
:alt="creator.username"
:tint-by="creator.username"
size="24px"
circle
no-shadow
class="mr-1 inline-block align-middle"
/>
<span><component :is="() => children" /></span>
</AutoLink>
</template>
</IntlFormatted>
<template v-else>
{{ formatMessage(messages.inviteWarning) }}
</template>
</p>
<SharedInstanceInstallSummary
:preview="preview"
@@ -98,8 +120,17 @@
:max-height="240"
/>
</div>
<div v-if="reportOnly" class="flex flex-col gap-2">
<Checkbox v-model="deleteInstance" :label="formatMessage(messages.deleteInstance)" />
<div v-if="reportOnly || blockTargetUserId" class="flex flex-col gap-2">
<Checkbox
v-if="reportOnly"
v-model="deleteInstance"
:label="formatMessage(messages.deleteInstance)"
/>
<Checkbox
v-if="blockTargetUserId"
v-model="blockUser"
:label="formatMessage(messages.blockUser)"
/>
</div>
</div>
</Transition>
@@ -226,12 +257,15 @@ import { BanIcon, DownloadIcon, ReportIcon, SendIcon, SpinnerIcon, XIcon } from
import {
Admonition,
AutoLink,
Avatar,
blockedUsersQueryKey,
ButtonStyled,
Checkbox,
Combobox,
type ComboboxOption,
commonMessages,
defineMessages,
injectAuth,
injectModrinthClient,
injectNotificationManager,
IntlFormatted,
@@ -243,12 +277,16 @@ import {
useScrollIndicator,
useVIntl,
} from '@modrinth/ui'
import { useQueryClient } from '@tanstack/vue-query'
import { openUrl } from '@tauri-apps/plugin-opener'
import { computed, nextTick, ref } from 'vue'
import { config } from '@/config'
import { hide_ads_window, show_ads_window } from '@/helpers/ads'
import { toError } from '@/helpers/errors'
import type { SharedInstanceInstallPreview } from '@/helpers/install'
import { create_report } from '@/helpers/reports'
import { block_user } from '@/helpers/users'
import SharedInstanceInstallSummary from './shared-instance-install-summary.vue'
import { useSharedInstancePreviewContent } from './use-shared-instance-preview-content'
@@ -258,11 +296,17 @@ type ExternalFileRow = {
id: string
name: string
}
type SharedInstanceCreator = {
id: string | null
username: string
avatarUrl: string | null
}
const modal = ref<InstanceType<typeof NewModal>>()
const contentModal = ref<InstanceType<typeof ModpackContentModal>>()
const externalFileTable = ref<HTMLElement | null>(null)
const preview = ref<SharedInstanceInstallPreview | null>(null)
const creator = ref<SharedInstanceCreator | null>(null)
const install = ref<() => void | Promise<void>>(() => {})
const reportMode = ref(false)
const reportOnly = ref(false)
@@ -270,13 +314,17 @@ type ReportReason = 'malicious' | 'inappropriate' | 'spam'
const reportReason = ref<ReportReason>('malicious')
const additionalContext = ref('')
const deleteInstance = ref(true)
const blockUser = ref(true)
const blockTargetUserId = ref<string | null>(null)
const submitLoading = ref(false)
const uploadedImageIDs = ref<string[]>([])
const emit = defineEmits<{
reported: [deleteInstance: boolean]
}>()
const { formatMessage } = useVIntl()
const auth = injectAuth()
const client = injectModrinthClient()
const queryClient = useQueryClient()
const { addNotification, handleError } = injectNotificationManager()
const { load } = useSharedInstancePreviewContent()
const {
@@ -302,6 +350,12 @@ const reportReasonOptions = computed<ComboboxOption<ReportReason>[]>(() => [
const canSubmitReport = computed(
() => Boolean(preview.value && additionalContext.value.trim()) && !submitLoading.value,
)
const creatorProfileLink = computed(() => {
const username = creator.value?.username
return username
? () => openUrl(`${config.siteUrl}/user/${encodeURIComponent(username)}`)
: undefined
})
async function accept() {
hide()
@@ -329,13 +383,39 @@ async function submitReport() {
submitLoading.value = true
try {
const uploadedImages = uploadedImageIDs.value.slice(-10)
await create_report({
report_type: reportReason.value,
item_type: 'shared-instance',
item_id: `${reportPreview.sharedInstanceId}/${reportPreview.version}`,
body,
uploaded_images: uploadedImages,
})
const blockTarget = blockUser.value ? blockTargetUserId.value : null
const [reportResult, blockResult] = await Promise.allSettled([
create_report({
report_type: reportReason.value,
item_type: 'shared-instance',
item_id: `${reportPreview.sharedInstanceId}/${reportPreview.version}`,
body,
uploaded_images: uploadedImages,
}),
blockTarget ? block_user(blockTarget) : Promise.resolve(),
])
if (blockTarget) {
if (blockResult.status === 'fulfilled') {
blockUser.value = false
const authUserId = auth.user.value?.id
if (authUserId) {
queryClient.setQueryData<Labrinth.BlockedUsers.v3.BlockedUserId[]>(
blockedUsersQueryKey(authUserId),
(blockedUsers = []) =>
blockedUsers.includes(blockTarget) ? blockedUsers : [...blockedUsers, blockTarget],
)
}
addNotification({
type: 'success',
title: formatMessage(messages.userBlocked),
})
} else {
handleError(toError(blockResult.reason))
}
}
if (reportResult.status === 'rejected') throw reportResult.reason
const shouldDeleteInstance = reportOnly.value && deleteInstance.value
hide()
@@ -381,6 +461,7 @@ function handleCancel() {
}
function handleHide() {
resetReportState()
creator.value = null
show_ads_window()
}
function resetReportState() {
@@ -389,20 +470,31 @@ function resetReportState() {
reportReason.value = 'malicious'
additionalContext.value = ''
deleteInstance.value = true
blockUser.value = true
blockTargetUserId.value = null
submitLoading.value = false
uploadedImageIDs.value = []
}
function show(
previewValue: SharedInstanceInstallPreview,
installValue: () => void | Promise<void>,
creatorValue?: SharedInstanceCreator,
event?: MouseEvent,
) {
resetReportState()
creator.value = creatorValue ?? null
blockTargetUserId.value = creatorValue?.id ?? null
install.value = installValue
showPreview(previewValue, event)
}
function showReport(previewValue: SharedInstanceInstallPreview, event?: MouseEvent) {
function showReport(
previewValue: SharedInstanceInstallPreview,
blockTargetUserIdValue?: string | null,
event?: MouseEvent,
) {
resetReportState()
creator.value = null
blockTargetUserId.value = blockTargetUserIdValue ?? null
reportMode.value = true
reportOnly.value = true
install.value = () => {}
@@ -437,6 +529,11 @@ const messages = defineMessages({
defaultMessage:
'This invite was created by another Modrinth user, not Modrinth. Only accept invites from people you trust.',
},
inviteWarningWithCreator: {
id: 'app.modal.install-to-play.invite-warning-with-creator',
defaultMessage:
'This invite was created by <creator>{username}</creator>, not Modrinth. Only accept invites from people you trust.',
},
reportDescription: {
id: 'app.modal.install-to-play.report-description',
defaultMessage:
@@ -492,6 +589,14 @@ const messages = defineMessages({
id: 'app.modal.install-to-play.delete-instance',
defaultMessage: 'Delete instance',
},
blockUser: {
id: 'app.modal.install-to-play.block-user',
defaultMessage: 'Block user',
},
userBlocked: {
id: 'app.modal.install-to-play.user-blocked',
defaultMessage: 'User blocked',
},
unknownFilesWarning: {
id: 'app.modal.install-to-play.unknown-files-warning',
defaultMessage: 'Unknown files warning',
@@ -1,7 +1,7 @@
import type { Labrinth } from '@modrinth/api-client'
import type { ContentItem } from '@modrinth/ui'
import { get_project_many, get_version, get_version_many } from '@/helpers/cache.js'
import { get_project, get_project_many, get_version, get_version_many } from '@/helpers/cache.js'
import type { SharedInstanceInstallPreview } from '@/helpers/install'
type VersionDependency = Labrinth.Versions.v2.Dependency & { version_id?: string }
@@ -20,7 +20,18 @@ export function useSharedInstancePreviewContent() {
async function modpackContentItems(preview: SharedInstanceInstallPreview) {
if (!preview.modpackVersionId) return []
const version = await get_version(preview.modpackVersionId, 'must_revalidate')
return await contentItemsFromDependencies(version?.dependencies ?? [])
if (!version) return []
const [project, contentItems] = await Promise.all([
get_project(version.project_id, 'must_revalidate'),
contentItemsFromDependencies(version.dependencies ?? []),
])
if (!project) return contentItems
return contentItems.map((item) => ({
...item,
source: { project },
}))
}
async function contentItemsFromDependencies(dependencies: Labrinth.Versions.v2.Dependency[]) {
@@ -17,6 +17,7 @@ import {
install_shared_instance,
} 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'
@@ -26,9 +27,16 @@ type InstallModal = {
show(
preview: Awaited<ReturnType<typeof install_get_shared_instance_preview>>,
install: () => Promise<void>,
creator?: SharedInstanceCreator,
): void
}
type SharedInstanceCreator = {
id: string | null
username: string
avatarUrl: string | null
}
type AccountRequiredModal = {
show(event?: MouseEvent): Promise<boolean>
}
@@ -45,6 +53,8 @@ export function useSharedInstanceInviteHandler(
const auth = injectAuth()
const client = injectModrinthClient()
const { handleError } = injectNotificationManager()
const { notifySharedInstanceConnectionError, notifySharedInstanceError } =
useSharedInstanceErrors()
const popupNotificationManager = injectPopupNotificationManager()
const queryClient = useQueryClient()
const router = useRouter()
@@ -58,6 +68,7 @@ export function useSharedInstanceInviteHandler(
instanceId: string
preview: Awaited<ReturnType<typeof install_get_shared_instance_preview>>
install: () => Promise<void>
creator?: SharedInstanceCreator
onGoToInstance?: () => void | Promise<void>
}
| undefined
@@ -73,10 +84,13 @@ export function useSharedInstanceInviteHandler(
async function resolveInvite(invite: SharedInstanceInvite) {
const [invitedBy, sharedInstance] = await Promise.all([
!invite.invitedByUsername && invite.invitedById
(!invite.invitedByUsername || !invite.invitedByAvatarUrl) && invite.invitedById
? get_user(invite.invitedById, 'bypass').catch(() => null)
: null,
client.sharedinstances.instances_v1.get(invite.sharedInstanceId).catch(() => null),
client.sharedinstances.instances_v1.get(invite.sharedInstanceId).catch(() => {
notifySharedInstanceConnectionError()
return null
}),
])
return {
@@ -90,15 +104,17 @@ export function useSharedInstanceInviteHandler(
function showInstall(
preview: Awaited<ReturnType<typeof install_get_shared_instance_preview>>,
install: () => Promise<void>,
creator?: SharedInstanceCreator,
) {
if (!installModal.value) throw new Error('Shared instance install modal is not available.')
installModal.value.show(preview, install)
installModal.value.show(preview, install, creator)
}
async function showInstallOrAlreadyInstalled(
sharedInstanceId: string,
preview: Awaited<ReturnType<typeof install_get_shared_instance_preview>>,
install: () => Promise<void>,
creator?: SharedInstanceCreator,
onGoToInstance?: () => void | Promise<void>,
) {
const existingInstance = (await list()).find(
@@ -106,7 +122,7 @@ export function useSharedInstanceInviteHandler(
)
if (!existingInstance || themeStore.getFeatureFlag('skip_non_essential_warnings')) {
showInstall(preview, install)
showInstall(preview, install, creator)
return
}
@@ -118,6 +134,7 @@ export function useSharedInstanceInviteHandler(
instanceId: existingInstance.id,
preview,
install,
creator,
onGoToInstance,
}
alreadyInstalledModal.value.show(existingInstance.name)
@@ -146,7 +163,7 @@ export function useSharedInstanceInviteHandler(
const pending = pendingAlreadyInstalled
pendingAlreadyInstalled = undefined
if (!pending) return
showInstall(pending.preview, pending.install)
showInstall(pending.preview, pending.install, pending.creator)
}
async function acceptNotification(notification: AppNotification, invite: SharedInstanceInvite) {
@@ -172,10 +189,17 @@ export function useSharedInstanceInviteHandler(
await markNotificationRead(notification)
await queryClient.invalidateQueries({ queryKey: ['instances'] })
},
invite.invitedByUsername
? {
id: invite.invitedById,
username: invite.invitedByUsername,
avatarUrl: invite.invitedByAvatarUrl,
}
: undefined,
() => markNotificationRead(notification),
)
} catch (error) {
handleError(toError(error))
notifySharedInstanceError(error)
}
}
@@ -253,19 +277,33 @@ export function useSharedInstanceInviteHandler(
try {
if (!(await requireAccount())) return
const invite = await install_accept_shared_instance_invite(inviteId)
await showInstallOrAlreadyInstalled(invite.sharedInstanceId, invite.preview, async () => {
await install_shared_instance(
invite.sharedInstanceId,
invite.preview.name,
invite.managerId,
invite.serverManagerName,
invite.serverManagerIconUrl,
invite.instanceIconUrl,
)
await queryClient.invalidateQueries({ queryKey: ['instances'] })
})
const manager = invite.managerId
? await get_user(invite.managerId, 'bypass').catch(() => null)
: null
await showInstallOrAlreadyInstalled(
invite.sharedInstanceId,
invite.preview,
async () => {
await install_shared_instance(
invite.sharedInstanceId,
invite.preview.name,
invite.managerId,
invite.serverManagerName,
invite.serverManagerIconUrl,
invite.instanceIconUrl,
)
await queryClient.invalidateQueries({ queryKey: ['instances'] })
},
manager
? {
id: manager.id,
username: manager.username,
avatarUrl: manager.avatar_url ?? null,
}
: undefined,
)
} catch (error) {
handleError(toError(error))
notifySharedInstanceError(error)
}
}
@@ -1,7 +1,7 @@
<script setup lang="ts">
import { LoaderCircleIcon } from '@modrinth/assets'
import type { GameVersion } from '@modrinth/ui'
import { GAME_MODES, HeadingLink, injectNotificationManager } from '@modrinth/ui'
import { GAME_MODES, injectNotificationManager } from '@modrinth/ui'
import { platform } from '@tauri-apps/plugin-os'
import type { Dayjs } from 'dayjs'
import dayjs from 'dayjs'
@@ -268,13 +268,7 @@ onUnmounted(() => {
</div>
</div>
<div v-else-if="jumpBackInItems.length > 0" class="flex flex-col gap-2">
<HeadingLink v-if="theme.getFeatureFlag('worlds_tab')" to="/worlds" class="mt-1">
Jump back in
</HeadingLink>
<span
v-else
class="flex mt-1 mb-3 leading-none items-center gap-1 text-primary text-lg font-bold"
>
<span class="flex mt-1 mb-3 leading-none items-center gap-1 text-primary text-lg font-bold">
Jump back in
</span>
<div class="grid-when-huge flex flex-col w-full gap-2">
+5
View File
@@ -102,6 +102,7 @@ export interface SharedInstanceUpdateDiff {
}
export const SHARED_INSTANCE_UNAVAILABLE_ERROR_CODE = 'shared_instance_unavailable'
export const SHARED_INSTANCES_API_ERROR_CODE = 'shared_instances_api_error'
export type SharedInstanceUnavailableReason = 'deleted' | 'access_revoked' | 'quarantined'
@@ -113,6 +114,10 @@ export function isSharedInstanceUnavailableError(error: unknown) {
return getSharedInstanceUnavailableReason(error) !== null
}
export function isSharedInstancesApiError(error: unknown) {
return isRecord(error) && error.code === SHARED_INSTANCES_API_ERROR_CODE
}
export function getSharedInstanceUnavailableReason(
error: unknown,
): SharedInstanceUnavailableReason | null {
+20
View File
@@ -365,6 +365,13 @@ export interface SharedInstanceInviteLink {
maxUses: number
}
export interface SharedInstanceInvite {
id: string
expiration: string
maxUses: number
uses: number
}
export async function can_current_user_use_shared_instances(): Promise<boolean> {
return await invoke('plugin:instance|instance_share_can_current_user_use')
}
@@ -394,6 +401,19 @@ export async function create_shared_instance_invite_link(
})
}
export async function get_shared_instance_invites(
instanceId: string,
): Promise<SharedInstanceInvite[]> {
return await invoke('plugin:instance|instance_share_get_invites', { instanceId })
}
export async function revoke_shared_instance_invite(
instanceId: string,
inviteId: string,
): Promise<void> {
return await invoke('plugin:instance|instance_share_revoke_invite', { instanceId, inviteId })
}
export async function remove_shared_instance_users(
instanceId: string,
userIds: string[],
+1 -1
View File
@@ -37,7 +37,7 @@ export type AppSettings = {
theme: ColorTheme
locale: string
default_page: 'home' | 'library'
default_page: 'Home' | 'Library'
collapsed_navigation: boolean
hide_nametag_skins_page: boolean
advanced_rendering: boolean
@@ -1,6 +1,10 @@
import { defineMessages, injectNotificationManager, useVIntl } from '@modrinth/ui'
import { getErrorMessage, type SharedInstanceUnavailableReason } from '@/helpers/install'
import {
getErrorMessage,
isSharedInstancesApiError,
type SharedInstanceUnavailableReason,
} from '@/helpers/install'
export const sharedInstanceErrorMessages = defineMessages({
unavailableTitle: {
@@ -39,6 +43,14 @@ export const sharedInstanceErrorMessages = defineMessages({
id: 'instance.shared-instance.error.title',
defaultMessage: 'Something has gone wrong',
},
networkErrorTitle: {
id: 'instance.shared-instance.network-error.title',
defaultMessage: 'Network error',
},
networkErrorText: {
id: 'instance.shared-instance.network-error.text',
defaultMessage: 'Unable to connect to shared instances API',
},
})
export function sharedInstanceUnavailableTextMessage(
@@ -82,7 +94,20 @@ export function useSharedInstanceErrors() {
})
}
function notifySharedInstanceConnectionError() {
addNotification({
type: 'error',
title: formatMessage(sharedInstanceErrorMessages.networkErrorTitle),
text: formatMessage(sharedInstanceErrorMessages.networkErrorText),
})
}
function notifySharedInstanceError(error: unknown) {
if (isSharedInstancesApiError(error)) {
notifySharedInstanceConnectionError()
return
}
addNotification({
type: 'error',
title: formatMessage(sharedInstanceErrorMessages.errorTitle),
@@ -92,6 +117,7 @@ export function useSharedInstanceErrors() {
return {
formatSharedInstanceUnavailable,
notifySharedInstanceConnectionError,
notifySharedInstanceError,
notifySharedInstanceUnavailable,
}
+1 -1
View File
@@ -189,7 +189,7 @@ type AppSettings = {
max_concurrent_downloads: number
max_concurrent_writes: number
theme: 'dark' | 'light' | 'oled'
theme: 'dark' | 'light' | 'oled' | 'retro' | 'system'
default_page: 'Home' | 'Library'
collapsed_navigation: boolean
advanced_rendering: boolean
+25 -1
View File
@@ -50,7 +50,31 @@ export async function get_user_collections(
export async function patch_user(
userId: string,
patch: Partial<Pick<Labrinth.Users.v3.User, 'badges' | 'role'>>,
patch: Partial<Pick<Labrinth.Users.v2.User, 'badges' | 'bio' | 'role' | 'username'>>,
): Promise<void> {
await invoke('plugin:users|patch_user', { userId, patch })
}
export async function change_user_avatar(
userId: string,
image: Uint8Array,
extension: string,
): Promise<void> {
await invoke('plugin:users|change_user_avatar', { userId, image, extension })
}
export async function delete_user_avatar(userId: string): Promise<void> {
await invoke('plugin:users|delete_user_avatar', { userId })
}
export async function block_user(userId: string): Promise<void> {
await invoke('plugin:users|block_user', { userId })
}
export async function unblock_user(userId: string): Promise<void> {
await invoke('plugin:users|unblock_user', { userId })
}
export async function get_blocked_users(): Promise<Labrinth.BlockedUsers.v3.BlockedUserId[]> {
return await invoke<Labrinth.BlockedUsers.v3.BlockedUserId[]>('plugin:users|get_blocked_users')
}
@@ -182,9 +182,6 @@
"app.appearance-settings.native-decorations.title": {
"message": "الزخارف التقليدية"
},
"app.appearance-settings.select-option": {
"message": "حدد خياراً"
},
"app.appearance-settings.show-play-time.description": {
"message": "يعرض مقدار الوقت الذي قضيته في لعب إحدى المهام."
},
@@ -236,9 +233,6 @@
"app.browse.back-to-instance": {
"message": "العودة للنموذج"
},
"app.browse.discover-content": {
"message": "استكشف محتوى"
},
"app.browse.discover-servers": {
"message": "استكشف خوادم"
},
@@ -113,9 +113,6 @@
"app.appearance-settings.native-decorations.title": {
"message": "Systémové dekorace"
},
"app.appearance-settings.select-option": {
"message": "Vyberte možnost"
},
"app.appearance-settings.show-play-time.description": {
"message": "Zobrazuje, kolik času jsi strávil hraním instance."
},
@@ -167,9 +164,6 @@
"app.browse.back-to-instance": {
"message": "Zpět k instanci"
},
"app.browse.discover-content": {
"message": "Objevit obsah"
},
"app.browse.discover-servers": {
"message": "Objevit servery"
},
@@ -173,9 +173,6 @@
"app.appearance-settings.minimize-launcher.title": {
"message": "Minimer launcher"
},
"app.appearance-settings.select-option": {
"message": "Vælg en mulighed"
},
"app.appearance-settings.show-play-time.description": {
"message": "Vis hvor meget tid du har brugt på at spille en instance."
},
@@ -221,9 +218,6 @@
"app.browse.back-to-instance": {
"message": "Tilbage til instance"
},
"app.browse.discover-content": {
"message": "Opdag indhold"
},
"app.browse.discover-servers": {
"message": "Opdag servere"
},
@@ -191,9 +191,6 @@
"app.appearance-settings.native-decorations.title": {
"message": "Native Dekorationen"
},
"app.appearance-settings.select-option": {
"message": "Option wählen"
},
"app.appearance-settings.show-play-time.description": {
"message": "Zeigt an, wie viel Zeit du mit dem Spielen einer Instanz verbracht hast."
},
@@ -245,9 +242,6 @@
"app.browse.back-to-instance": {
"message": "Zu Instanz zurückgehen"
},
"app.browse.discover-content": {
"message": "Inhalte entdecken"
},
"app.browse.discover-servers": {
"message": "Server entdecken"
},
@@ -191,9 +191,6 @@
"app.appearance-settings.native-decorations.title": {
"message": "Native Dekorationen"
},
"app.appearance-settings.select-option": {
"message": "Wähle eine Option"
},
"app.appearance-settings.show-play-time.description": {
"message": "Zeigt an, wie viel Zeit du damit verbracht hast, eine Instanz zu spielen."
},
@@ -245,9 +242,6 @@
"app.browse.back-to-instance": {
"message": "Zurück zur Instanz"
},
"app.browse.discover-content": {
"message": "Inhalte entdecken"
},
"app.browse.discover-servers": {
"message": "Server entdecken"
},
+375 -24
View File
@@ -144,19 +144,19 @@
"message": "Your privacy and how ads support Modrinth"
},
"app.appearance-settings.advanced-rendering.description": {
"message": "Enables advanced rendering such as blur effects that may cause performance issues without hardware-accelerated rendering."
"message": "Enable visual effects such as background blur. This may reduce performance without hardware acceleration."
},
"app.appearance-settings.advanced-rendering.title": {
"message": "Advanced rendering"
},
"app.appearance-settings.color-theme.description": {
"message": "Select your preferred color theme for Modrinth App."
"message": "Choose the color theme used by Modrinth App."
},
"app.appearance-settings.color-theme.title": {
"message": "Color theme"
},
"app.appearance-settings.default-landing-page.description": {
"message": "Change the page to which the launcher opens on."
"message": "Choose the page shown when Modrinth App opens."
},
"app.appearance-settings.default-landing-page.home": {
"message": "Home"
@@ -168,52 +168,49 @@
"message": "Default landing page"
},
"app.appearance-settings.hide-nametag.description": {
"message": "Disables the nametag above your player on the skins page."
"message": "Hide your username above the player preview on the Skin selector page."
},
"app.appearance-settings.hide-nametag.title": {
"message": "Hide nametag"
},
"app.appearance-settings.jump-back-into-worlds.description": {
"message": "Includes recent worlds in the \"Jump back in\" section on the Home page."
"message": "Show recently played worlds in the \"Jump back in\" section on the Home page."
},
"app.appearance-settings.jump-back-into-worlds.title": {
"message": "Jump back into worlds"
},
"app.appearance-settings.minimize-launcher.description": {
"message": "Minimize the launcher when a Minecraft process starts."
"message": "Minimize Modrinth App when Minecraft starts."
},
"app.appearance-settings.minimize-launcher.title": {
"message": "Minimize launcher"
"message": "Minimize app"
},
"app.appearance-settings.native-decorations.description": {
"message": "Use system window frame (app restart required)."
"message": "Use your operating system's title bar and window controls. Requires an app restart."
},
"app.appearance-settings.native-decorations.title": {
"message": "Native decorations"
},
"app.appearance-settings.select-option": {
"message": "Select an option"
"message": "System window frame"
},
"app.appearance-settings.show-play-time.description": {
"message": "Displays how much time you've spent playing an instance."
"message": "Show how long you've played each instance."
},
"app.appearance-settings.show-play-time.title": {
"message": "Show play time"
},
"app.appearance-settings.skip-non-essential-warnings.description": {
"message": "Automatically skips low-risk confirmations like duplicate modpack installs, normal content deletion, bulk updates, unlinking modpacks, and repair prompts. Dangerous warnings will still be shown."
"message": "Skip confirmations for low-risk actions such as duplicate installs, normal content deletion, bulk updates, unlinking, and repairs. Warnings for dangerous actions are always shown."
},
"app.appearance-settings.skip-non-essential-warnings.title": {
"message": "Skip non-essential warnings"
},
"app.appearance-settings.toggle-sidebar.description": {
"message": "Enables the ability to toggle the sidebar."
"message": "Hide the right sidebar by default and add a button to show or hide it."
},
"app.appearance-settings.toggle-sidebar.title": {
"message": "Toggle sidebar"
"message": "Hide right sidebar"
},
"app.appearance-settings.unknown-pack-warning.description": {
"message": "If you attempt to install a Modrinth Pack file (.mrpack) that isn't hosted on Modrinth, we'll make sure you understand the risks before installing it."
"message": "Show a safety warning before installing a Modrinth Pack (.mrpack) that isn't hosted on Modrinth."
},
"app.appearance-settings.unknown-pack-warning.title": {
"message": "Warn me before installing unknown modpacks"
@@ -224,6 +221,15 @@
"app.auth-servers.unreachable.header": {
"message": "Cannot reach authentication servers"
},
"app.behavior-settings.confirmations.title": {
"message": "Confirmations"
},
"app.behavior-settings.content.title": {
"message": "Home and content"
},
"app.behavior-settings.startup-and-navigation.title": {
"message": "Startup and navigation"
},
"app.browse.add-servers-to-instance": {
"message": "Adding server to instance"
},
@@ -245,8 +251,8 @@
"app.browse.back-to-instance": {
"message": "Back to instance"
},
"app.browse.discover-content": {
"message": "Discover content"
"app.browse.discover-project-type": {
"message": "Discover {projectType}"
},
"app.browse.discover-servers": {
"message": "Discover servers"
@@ -254,6 +260,9 @@
"app.browse.hide-added-servers": {
"message": "Hide servers already added"
},
"app.browse.hide-installed-modpacks": {
"message": "Hide already installed"
},
"app.browse.project-type.modpacks": {
"message": "Modpacks"
},
@@ -368,9 +377,18 @@
"app.instance.admonitions.shared-instance.review-header": {
"message": "Review changes"
},
"app.instance.admonitions.shared-instance.review-update-button": {
"message": "Review update"
},
"app.instance.admonitions.shared-instance.reviewing-button": {
"message": "Reviewing..."
},
"app.instance.admonitions.shared-instance.update-available-body": {
"message": "An update is required to play {name}. Please update to latest version to launch the game."
},
"app.instance.admonitions.shared-instance.update-available-header": {
"message": "An update is available"
},
"app.instance.confirm-delete.admonition-body": {
"message": "All data for your instance will be permanently deleted, including your worlds, configs, and all installed content."
},
@@ -437,6 +455,9 @@
"app.instance.share.invite-modal.heading": {
"message": "Share {name}"
},
"app.instance.share.invite-modal.user-limit-reached": {
"message": "This instance has reached the {limit}-user limit."
},
"app.instance.share.locked.empty-description-prefix": {
"message": "You need to sign in as"
},
@@ -455,6 +476,12 @@
"app.instance.share.locked.wrong-account-heading": {
"message": "Wrong account"
},
"app.instance.share.members.empty": {
"message": "No users have joined yet"
},
"app.instance.share.members.no-filter-results": {
"message": "No users match your filters."
},
"app.instance.share.remove-user-modal.effect-access": {
"message": "They will no longer receive updates for this shared instance"
},
@@ -485,6 +512,12 @@
"app.instance.share.sign-in.button": {
"message": "Sign in"
},
"app.instance.share.unable-to-connect.description": {
"message": "The shared instances service is not accessible at the moment, please try again later"
},
"app.instance.share.unable-to-connect.heading": {
"message": "Unable to connect"
},
"app.instance.share.unlink.body": {
"message": "You must unlink this modpack to share your instance"
},
@@ -572,12 +605,60 @@
"app.instance.worlds.search-worlds-placeholder": {
"message": "Search {count} worlds..."
},
"app.java-detection.cancel": {
"message": "Cancel"
},
"app.java-detection.columns.actions": {
"message": "Actions"
},
"app.java-detection.columns.path": {
"message": "Path"
},
"app.java-detection.columns.version": {
"message": "Version"
},
"app.java-detection.no-installations-found": {
"message": "No Java installations found."
},
"app.java-detection.select": {
"message": "Select"
},
"app.java-detection.selected": {
"message": "Selected"
},
"app.java-detection.title": {
"message": "Select Java installation"
},
"app.java-selector.already-installed": {
"message": "Already installed"
},
"app.java-selector.browse": {
"message": "Browse"
},
"app.java-selector.detect": {
"message": "Detect"
},
"app.java-selector.install-recommended": {
"message": "Install recommended"
},
"app.java-selector.installing": {
"message": "Installing..."
},
"app.java-selector.path.placeholder": {
"message": "/path/to/java"
},
"app.java-selector.test-installation": {
"message": "Test Java installation"
},
"app.modal.install-to-play.additional-context": {
"message": "Additional context"
},
"app.modal.install-to-play.additional-context-placeholder": {
"message": "Include links and images if possible and relevant"
},
"app.modal.install-to-play.block-user": {
"message": "Block user"
},
"app.modal.install-to-play.content-you-are-reporting": {
"message": "Instance youre reporting"
},
@@ -599,6 +680,9 @@
"app.modal.install-to-play.invite-warning": {
"message": "This invite was created by another Modrinth user, not Modrinth. Only accept invites from people you trust."
},
"app.modal.install-to-play.invite-warning-with-creator": {
"message": "This invite was created by <creator>{username}</creator>, not Modrinth. Only accept invites from people you trust."
},
"app.modal.install-to-play.mod-count": {
"message": "{count, plural, one {# mod} other {# mods}}"
},
@@ -656,6 +740,9 @@
"app.modal.install-to-play.unrecognized-files": {
"message": "Unrecognized files"
},
"app.modal.install-to-play.user-blocked": {
"message": "User blocked"
},
"app.modal.install-to-play.view-contents": {
"message": "View contents"
},
@@ -683,6 +770,36 @@
"app.modal.update-to-play.update-required-description": {
"message": "An update is required to play {name}. Please update to latest version to launch the game."
},
"app.nav.create-new-instance": {
"message": "Create new instance"
},
"app.nav.home": {
"message": "Home"
},
"app.nav.library": {
"message": "Library"
},
"app.nav.modrinth-account": {
"message": "Modrinth account"
},
"app.nav.modrinth-hosting": {
"message": "Modrinth Hosting"
},
"app.nav.sign-in-to-modrinth-account": {
"message": "Sign in to a Modrinth account"
},
"app.nav.signed-in-as": {
"message": "Signed in as <user>{username}</user>"
},
"app.nav.upgrade-to-modrinth-plus": {
"message": "Upgrade to Modrinth+"
},
"app.news.title": {
"message": "News"
},
"app.news.view-all": {
"message": "View all news"
},
"app.project.install-button.already-installed": {
"message": "This project is already installed"
},
@@ -692,6 +809,9 @@
"app.project.install-context.back-to-browse": {
"message": "Back to discover"
},
"app.project.install-context.back-to-instance": {
"message": "Back to instance"
},
"app.project.version.all-versions": {
"message": "All versions"
},
@@ -704,11 +824,95 @@
"app.project.versions.already-installed": {
"message": "Already installed"
},
"app.resource-management-settings.always-show-copy-details.description": {
"message": "Show the Copy details action while an install is queued or running. It is always available for failed or interrupted installs."
"app.quick-instance-switcher.drag-show-tooltip": {
"message": "Drag to show recent instances"
},
"app.resource-management-settings.always-show-copy-details.title": {
"message": "Always show copy details"
"app.quick-instance-switcher.drag-tooltip": {
"message": "Drag to resize"
},
"app.restarting": {
"message": "Restarting..."
},
"app.settings.app-version": {
"message": "Modrinth App {version}"
},
"app.settings.default-instance-options.environment-variables.description": {
"message": "Environment variables set when launching an instance."
},
"app.settings.default-instance-options.environment-variables.placeholder": {
"message": "Enter environment variables..."
},
"app.settings.default-instance-options.environment-variables.title": {
"message": "Environment variables"
},
"app.settings.default-instance-options.fullscreen.description": {
"message": "Start instances in fullscreen by updating their options.txt file."
},
"app.settings.default-instance-options.fullscreen.title": {
"message": "Fullscreen"
},
"app.settings.default-instance-options.height.description": {
"message": "The height of the game window when launched."
},
"app.settings.default-instance-options.height.placeholder": {
"message": "Enter height..."
},
"app.settings.default-instance-options.height.title": {
"message": "Height"
},
"app.settings.default-instance-options.java-arguments.description": {
"message": "Arguments passed to Java when launching an instance."
},
"app.settings.default-instance-options.java-arguments.placeholder": {
"message": "Enter Java arguments..."
},
"app.settings.default-instance-options.java-arguments.title": {
"message": "Java arguments"
},
"app.settings.default-instance-options.memory-allocation.description": {
"message": "Maximum memory available to each instance."
},
"app.settings.default-instance-options.memory-allocation.title": {
"message": "Memory allocation"
},
"app.settings.default-instance-options.post-exit-hook.description": {
"message": "Runs after the game closes."
},
"app.settings.default-instance-options.post-exit-hook.placeholder": {
"message": "Enter post-exit command..."
},
"app.settings.default-instance-options.post-exit-hook.title": {
"message": "Post-exit hook"
},
"app.settings.default-instance-options.pre-launch-hook.description": {
"message": "Runs before the instance starts."
},
"app.settings.default-instance-options.pre-launch-hook.placeholder": {
"message": "Enter pre-launch command..."
},
"app.settings.default-instance-options.pre-launch-hook.title": {
"message": "Pre-launch hook"
},
"app.settings.default-instance-options.width.description": {
"message": "The width of the game window when launched."
},
"app.settings.default-instance-options.width.placeholder": {
"message": "Enter width..."
},
"app.settings.default-instance-options.width.title": {
"message": "Width"
},
"app.settings.default-instance-options.wrapper-hook.description": {
"message": "Command used to wrap the Minecraft launch process."
},
"app.settings.default-instance-options.wrapper-hook.placeholder": {
"message": "Enter wrapper command..."
},
"app.settings.default-instance-options.wrapper-hook.title": {
"message": "Wrapper hook"
},
"app.settings.developer-mode-button.label": {
"message": "Toggle developer mode"
},
"app.settings.developer-mode-enabled": {
"message": "Developer mode enabled."
@@ -716,14 +920,92 @@
"app.settings.downloading": {
"message": "Downloading v{version}"
},
"app.settings.java-installations.location.title": {
"message": "Java {version, number} location"
},
"app.settings.operating-system.macos": {
"message": "macOS"
},
"app.settings.privacy.ads-consent.intro": {
"message": "Ads make Modrinth possible and fund creator payouts. Our partners may store or access cookies in the app to personalize ads and measure performance. You can opt out or manage your preferences below."
},
"app.settings.privacy.discord-rich-presence.description": {
"message": "Show Modrinth App as your current activity on Discord. This does not affect Rich Presence added to instances by mods. Requires an app restart."
},
"app.settings.privacy.discord-rich-presence.title": {
"message": "Discord Rich Presence"
},
"app.settings.privacy.telemetry.description": {
"message": "Modrinth collects anonymized analytics and usage data to improve our user experience and customize your experience. By disabling this option, you opt out and your data will no longer be collected."
},
"app.settings.privacy.telemetry.title": {
"message": "Telemetry"
},
"app.settings.resource-management.always-show-copy-details.description": {
"message": "Show the Copy details action while an install is queued or running. It is always available for failed or interrupted installs."
},
"app.settings.resource-management.always-show-copy-details.title": {
"message": "Always show copy details"
},
"app.settings.resource-management.app-cache.confirm.description": {
"message": "The app may load more slowly until the cache is rebuilt."
},
"app.settings.resource-management.app-cache.confirm.title": {
"message": "Purge the app cache?"
},
"app.settings.resource-management.app-cache.description": {
"message": "Clear cached data and download it again from Modrinth. The app may load more slowly until the cache is rebuilt."
},
"app.settings.resource-management.app-cache.purge": {
"message": "Purge cache"
},
"app.settings.resource-management.app-cache.title": {
"message": "App cache"
},
"app.settings.resource-management.app-database-backups.description": {
"message": "Backups of important app data are stored here in case you need to recover them later."
},
"app.settings.resource-management.app-database-backups.open-folder": {
"message": "Open backups folder"
},
"app.settings.resource-management.app-database-backups.title": {
"message": "App database backups"
},
"app.settings.resource-management.app-directory.browse": {
"message": "Browse for an app directory"
},
"app.settings.resource-management.app-directory.description": {
"message": "Where Modrinth App stores instances and other files. Changes take effect after restarting the app."
},
"app.settings.resource-management.app-directory.select": {
"message": "Select a new app directory"
},
"app.settings.resource-management.app-directory.title": {
"message": "App directory"
},
"app.settings.resource-management.maximum-concurrent-downloads.description": {
"message": "Number of files the app can download at once. Lower this if downloads are unreliable on your connection. Requires an app restart."
},
"app.settings.resource-management.maximum-concurrent-downloads.title": {
"message": "Maximum concurrent downloads"
},
"app.settings.resource-management.maximum-concurrent-writes.description": {
"message": "Number of files the app can write to disk at once. Lower this if you frequently encounter I/O errors. Requires an app restart."
},
"app.settings.resource-management.maximum-concurrent-writes.title": {
"message": "Maximum concurrent writes"
},
"app.settings.sidebar.label.instances": {
"message": "Instances"
},
"app.settings.tabs.appearance": {
"message": "Appearance"
},
"app.settings.tabs.behavior": {
"message": "Behavior"
},
"app.settings.tabs.default-instance-options": {
"message": "Default instance options"
"message": "Default game options"
},
"app.settings.tabs.java-installations": {
"message": "Java installations"
@@ -737,6 +1019,9 @@
"app.settings.tabs.resource-management": {
"message": "Resource management"
},
"app.sidebar.playing-as": {
"message": "Playing as"
},
"app.skins.add-button": {
"message": "Add skin"
},
@@ -896,6 +1181,21 @@
"app.skins.toggle-ears-features-on": {
"message": "Toggle on"
},
"app.survey.body": {
"message": "Would you mind answering a few questions about your experience with Modrinth App?"
},
"app.survey.footer": {
"message": "This feedback will go directly to the Modrinth team and help guide future updates!"
},
"app.survey.no-thanks": {
"message": "No thanks"
},
"app.survey.take-survey": {
"message": "Take survey"
},
"app.survey.title": {
"message": "Hey there Modrinth user!"
},
"app.update-popup.body.download-complete": {
"message": "Modrinth App v{version} has finished downloading. Reload to update now, or automatically when you close Modrinth App."
},
@@ -1172,6 +1472,45 @@
"instance.server-modal.resource-pack": {
"message": "Resource pack"
},
"instance.settings.sharing.active-invites.actions": {
"message": "Actions"
},
"instance.settings.sharing.active-invites.code": {
"message": "Invite link"
},
"instance.settings.sharing.active-invites.description": {
"message": "Anyone with one of these invite links can join while it remains active."
},
"instance.settings.sharing.active-invites.empty": {
"message": "There are no active invites."
},
"instance.settings.sharing.active-invites.expires": {
"message": "Expires"
},
"instance.settings.sharing.active-invites.revoke": {
"message": "Revoke invite"
},
"instance.settings.sharing.active-invites.revoke-with-code": {
"message": "Revoke invite {code}"
},
"instance.settings.sharing.active-invites.title": {
"message": "Active invites"
},
"instance.settings.sharing.active-invites.uses": {
"message": "Uses"
},
"instance.settings.sharing.revoke-invite.admonition-body": {
"message": "The invite link <monospace>{code}</monospace> will stop working immediately. People who already joined will keep access."
},
"instance.settings.sharing.revoke-invite.admonition-header": {
"message": "This action cannot be undone"
},
"instance.settings.sharing.revoke-invite.confirm": {
"message": "Revoke invite"
},
"instance.settings.sharing.revoke-invite.header": {
"message": "Revoke invite"
},
"instance.settings.tabs.general": {
"message": "General"
},
@@ -1373,6 +1712,12 @@
"instance.shared-instance.error.title": {
"message": "Something has gone wrong"
},
"instance.shared-instance.network-error.text": {
"message": "Unable to connect to shared instances API"
},
"instance.shared-instance.network-error.title": {
"message": "Network error"
},
"instance.shared-instance.owner-tooltip": {
"message": "This instance's content is being shared to other users."
},
@@ -1564,5 +1909,11 @@
},
"search.filter.locked.server-loader.title": {
"message": "Loader is provided by the server"
},
"settings.sidebar.label.account": {
"message": "Account"
},
"settings.sidebar.label.display": {
"message": "Display"
}
}
@@ -173,9 +173,6 @@
"app.appearance-settings.native-decorations.title": {
"message": "Decoraciones nativas"
},
"app.appearance-settings.select-option": {
"message": "Selecciona una opción"
},
"app.appearance-settings.show-play-time.description": {
"message": "Muestra cuánto tiempo has estado jugando en una instancia."
},
@@ -227,9 +224,6 @@
"app.browse.back-to-instance": {
"message": "Volver a instancia"
},
"app.browse.discover-content": {
"message": "Descubrir contenido"
},
"app.browse.discover-servers": {
"message": "Descubrir servidores"
},
@@ -107,9 +107,6 @@
"app.appearance-settings.native-decorations.title": {
"message": "Decoraciones nativas"
},
"app.appearance-settings.select-option": {
"message": "Selecciona una opción"
},
"app.appearance-settings.show-play-time.description": {
"message": "Muestra cuánto tiempo has estado jugando en una instancia."
},
@@ -161,9 +158,6 @@
"app.browse.back-to-instance": {
"message": "Volver a la instancia"
},
"app.browse.discover-content": {
"message": "Descubrir contenido"
},
"app.browse.discover-servers": {
"message": "Descubrir servidores"
},
@@ -119,9 +119,6 @@
"app.appearance-settings.native-decorations.title": {
"message": "Natiivit koristukset"
},
"app.appearance-settings.select-option": {
"message": "Valitse vaihtoehto"
},
"app.appearance-settings.show-play-time.description": {
"message": "Näyttää, kuinka paljon aikaa olet käyttänyt instanssin pelaamiseen."
},
@@ -173,9 +170,6 @@
"app.browse.back-to-instance": {
"message": "Takaisin instanssiin"
},
"app.browse.discover-content": {
"message": "Löydä sisältöä"
},
"app.browse.discover-servers": {
"message": "Löydä palvelimia"
},
@@ -95,9 +95,6 @@
"app.appearance-settings.native-decorations.title": {
"message": "Native decorations"
},
"app.appearance-settings.select-option": {
"message": "Pumili ng opsiyon"
},
"app.appearance-settings.show-play-time.description": {
"message": "Magpapakita ang katagal ng iyong paglalaro sa isang instansiya."
},
@@ -140,9 +137,6 @@
"app.browse.back-to-instance": {
"message": "Bumalik sa instansiya"
},
"app.browse.discover-content": {
"message": "Tumuklas ng kontento"
},
"app.browse.discover-servers": {
"message": "Tumuklas ng mga server"
},
@@ -191,9 +191,6 @@
"app.appearance-settings.native-decorations.title": {
"message": "Décorations natives"
},
"app.appearance-settings.select-option": {
"message": "Sélectionnez une option"
},
"app.appearance-settings.show-play-time.description": {
"message": "Affiche combien de temps vous avez passé sur une instance."
},
@@ -245,9 +242,6 @@
"app.browse.back-to-instance": {
"message": "Retour à l'instance"
},
"app.browse.discover-content": {
"message": "Découvrir du contenu"
},
"app.browse.discover-servers": {
"message": "Découvrir des serveurs"
},
@@ -44,9 +44,6 @@
"app.browse.already-added": {
"message": "כבר נוסף"
},
"app.browse.discover-content": {
"message": "גלה תוכן"
},
"app.browse.discover-servers": {
"message": "גלה שרתים"
},
@@ -191,9 +191,6 @@
"app.appearance-settings.native-decorations.title": {
"message": "Rendszer ablakkeret használata"
},
"app.appearance-settings.select-option": {
"message": "Válassz egy lehetőséget"
},
"app.appearance-settings.show-play-time.description": {
"message": "Megmutatja, mennyi időt töltöttél egy játékpéldányban."
},
@@ -245,9 +242,6 @@
"app.browse.back-to-instance": {
"message": "Vissza a játékpéldányhoz"
},
"app.browse.discover-content": {
"message": "Tartalom böngészése"
},
"app.browse.discover-servers": {
"message": "Szerverek böngészése"
},
@@ -92,9 +92,6 @@
"app.appearance-settings.native-decorations.title": {
"message": "Hiasan asli"
},
"app.appearance-settings.select-option": {
"message": "Pilih opsi"
},
"app.appearance-settings.show-play-time.description": {
"message": "Menampilkan seberapa lama Anda memainkan sebuah instans."
},
@@ -140,9 +137,6 @@
"app.browse.back-to-instance": {
"message": "Kembali ke instans"
},
"app.browse.discover-content": {
"message": "Temukan konten"
},
"app.browse.discover-servers": {
"message": "Temukan server"
},
@@ -191,9 +191,6 @@
"app.appearance-settings.native-decorations.title": {
"message": "Decorazioni native"
},
"app.appearance-settings.select-option": {
"message": "Seleziona"
},
"app.appearance-settings.show-play-time.description": {
"message": "Mostra quanto tempo hai speso giocando a un'istanza."
},
@@ -245,9 +242,6 @@
"app.browse.back-to-instance": {
"message": "Torna all'istanza"
},
"app.browse.discover-content": {
"message": "Esplora i contenuti"
},
"app.browse.discover-servers": {
"message": "Sfoglia i server"
},
@@ -188,9 +188,6 @@
"app.appearance-settings.native-decorations.title": {
"message": "OSのウィンドウUIを使用"
},
"app.appearance-settings.select-option": {
"message": "オプションを選択してください"
},
"app.appearance-settings.show-play-time.description": {
"message": "インスタンスのプレイ時間を表示します。"
},
@@ -242,9 +239,6 @@
"app.browse.back-to-instance": {
"message": "インスタンスに戻る"
},
"app.browse.discover-content": {
"message": "コンテンツを探す"
},
"app.browse.discover-servers": {
"message": "サーバーを探す"
},
@@ -191,9 +191,6 @@
"app.appearance-settings.native-decorations.title": {
"message": "네이티브 제목 표시줄"
},
"app.appearance-settings.select-option": {
"message": "옵션 선택"
},
"app.appearance-settings.show-play-time.description": {
"message": "인스턴스의 플레이 시간을 표시합니다."
},
@@ -245,9 +242,6 @@
"app.browse.back-to-instance": {
"message": "인스턴스로 돌아가기"
},
"app.browse.discover-content": {
"message": "콘텐츠 탐색하기"
},
"app.browse.discover-servers": {
"message": "서버 탐색하기"
},
@@ -92,9 +92,6 @@
"app.appearance-settings.native-decorations.title": {
"message": "Hiasan asli"
},
"app.appearance-settings.select-option": {
"message": "Pilih satu pilihan"
},
"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."
},
@@ -137,9 +134,6 @@
"app.browse.back-to-instance": {
"message": "Kembali ke pemasangan"
},
"app.browse.discover-content": {
"message": "Temui kandungan"
},
"app.browse.discover-servers": {
"message": "Temui pelayan"
},
@@ -191,9 +191,6 @@
"app.appearance-settings.native-decorations.title": {
"message": "Oorspronkelijke versieringen"
},
"app.appearance-settings.select-option": {
"message": "Selecteer een optie"
},
"app.appearance-settings.show-play-time.description": {
"message": "Toont hoeveel tijd je aan een instantie hebt besteed."
},
@@ -245,9 +242,6 @@
"app.browse.back-to-instance": {
"message": "Terug naar instantie"
},
"app.browse.discover-content": {
"message": "Inhoud ontdekken"
},
"app.browse.discover-servers": {
"message": "Ontdek servers"
},
@@ -89,9 +89,6 @@
"app.appearance-settings.minimize-launcher.title": {
"message": "Minimer launcheren"
},
"app.appearance-settings.select-option": {
"message": "Velg et alternativ"
},
"app.appearance-settings.show-play-time.description": {
"message": "Viser hvor lenge du har spilt et tilfelle."
},
@@ -143,9 +140,6 @@
"app.browse.back-to-instance": {
"message": "Tilbake til tilfelle"
},
"app.browse.discover-content": {
"message": "Oppdag innhold"
},
"app.browse.discover-servers": {
"message": "Oppdag servere"
},
@@ -191,9 +191,6 @@
"app.appearance-settings.native-decorations.title": {
"message": "Systemowe dekoracje"
},
"app.appearance-settings.select-option": {
"message": "Wybierz opcję"
},
"app.appearance-settings.show-play-time.description": {
"message": "Pokazuje czas gry spędzony w danej instancji."
},
@@ -245,9 +242,6 @@
"app.browse.back-to-instance": {
"message": "Powrót do instancji"
},
"app.browse.discover-content": {
"message": "Odkrywaj zawartość"
},
"app.browse.discover-servers": {
"message": "Odkryj serwery"
},
@@ -191,9 +191,6 @@
"app.appearance-settings.native-decorations.title": {
"message": "Decorações nativas"
},
"app.appearance-settings.select-option": {
"message": "Selecione uma opção"
},
"app.appearance-settings.show-play-time.description": {
"message": "Exibe a quantidade de tempo jogado de uma instância."
},
@@ -245,9 +242,6 @@
"app.browse.back-to-instance": {
"message": "Voltar à instância"
},
"app.browse.discover-content": {
"message": "Descobrir conteúdo"
},
"app.browse.discover-servers": {
"message": "Descobrir servidores"
},
@@ -188,9 +188,6 @@
"app.appearance-settings.native-decorations.title": {
"message": "Decorações nativas"
},
"app.appearance-settings.select-option": {
"message": "Seleciona uma opção"
},
"app.appearance-settings.show-play-time.description": {
"message": "Apresenta quanto tempo passou a jogar uma instância."
},
@@ -242,9 +239,6 @@
"app.browse.back-to-instance": {
"message": "Voltar à instância"
},
"app.browse.discover-content": {
"message": "Descobrir conteúdo"
},
"app.browse.discover-servers": {
"message": "Descobrir servidores"
},
@@ -191,9 +191,6 @@
"app.appearance-settings.native-decorations.title": {
"message": "Системное оформление"
},
"app.appearance-settings.select-option": {
"message": "Выберите вариант"
},
"app.appearance-settings.show-play-time.description": {
"message": "Показывать время, проведённое в сборке."
},
@@ -245,9 +242,6 @@
"app.browse.back-to-instance": {
"message": "Вернуться к сборке"
},
"app.browse.discover-content": {
"message": "Поиск проектов"
},
"app.browse.discover-servers": {
"message": "Поиск серверов"
},
@@ -173,9 +173,6 @@
"app.appearance-settings.native-decorations.title": {
"message": "Zavičajni ukrasi"
},
"app.appearance-settings.select-option": {
"message": "Izaberi opciju"
},
"app.appearance-settings.show-play-time.description": {
"message": "Pokazuje koliko vremena si proveo igrajući instancu."
},
@@ -227,9 +224,6 @@
"app.browse.back-to-instance": {
"message": "Nazad na instancu"
},
"app.browse.discover-content": {
"message": "Otkrij sadržaj"
},
"app.browse.discover-servers": {
"message": "Otkrij servere"
},
@@ -173,9 +173,6 @@
"app.appearance-settings.native-decorations.title": {
"message": "Inbyggda fönsterdekorationer"
},
"app.appearance-settings.select-option": {
"message": "Välj ett alternativ"
},
"app.appearance-settings.show-play-time.description": {
"message": "Visar hur mycket tid du spelat på en instans."
},
@@ -227,9 +224,6 @@
"app.browse.back-to-instance": {
"message": "Tillbaka till instans"
},
"app.browse.discover-content": {
"message": "Upptäck innehåll"
},
"app.browse.discover-servers": {
"message": "Upptäck servrar"
},
@@ -107,9 +107,6 @@
"app.appearance-settings.native-decorations.title": {
"message": "ของตกแต่งดั้งเดิม"
},
"app.appearance-settings.select-option": {
"message": "เลือกตัวเลือก"
},
"app.appearance-settings.show-play-time.description": {
"message": "แสดงเวลาที่คุณใช้เล่นในแต่ละอินสแตนซ์"
},
@@ -155,9 +152,6 @@
"app.browse.back-to-instance": {
"message": "กลับสู่อินสแตนซ์"
},
"app.browse.discover-content": {
"message": "สำรวจเนื้อหา"
},
"app.browse.discover-servers": {
"message": "สำรวจเซิร์ฟเวอร์"
},
@@ -191,9 +191,6 @@
"app.appearance-settings.native-decorations.title": {
"message": "Yerel pencere öğeleri"
},
"app.appearance-settings.select-option": {
"message": "Bir seçenek seçin"
},
"app.appearance-settings.show-play-time.description": {
"message": "Bir oyun oturumunda ne kadar zaman geçirdiğinizi gösterir."
},
@@ -245,9 +242,6 @@
"app.browse.back-to-instance": {
"message": "Kuruluma dön"
},
"app.browse.discover-content": {
"message": "İçerik keşfet"
},
"app.browse.discover-servers": {
"message": "Sunucuları keşfet"
},
@@ -188,9 +188,6 @@
"app.appearance-settings.native-decorations.title": {
"message": "Системне оформлення"
},
"app.appearance-settings.select-option": {
"message": "Оберіть опцію"
},
"app.appearance-settings.show-play-time.description": {
"message": "Показує, скільки часу ви провели в цьому профілі."
},
@@ -242,9 +239,6 @@
"app.browse.back-to-instance": {
"message": "Назад до профілю"
},
"app.browse.discover-content": {
"message": "Дослідити вміст"
},
"app.browse.discover-servers": {
"message": "Дослідити сервери"
},
@@ -182,9 +182,6 @@
"app.appearance-settings.native-decorations.title": {
"message": "Giao diện cửa sổ hệ thống"
},
"app.appearance-settings.select-option": {
"message": "Chọn một tuỳ chọn"
},
"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."
},
@@ -236,9 +233,6 @@
"app.browse.back-to-instance": {
"message": "Quay lại phiên bản"
},
"app.browse.discover-content": {
"message": "Khám phá nội dung"
},
"app.browse.discover-servers": {
"message": "Khám phá máy chủ"
},
@@ -191,9 +191,6 @@
"app.appearance-settings.native-decorations.title": {
"message": "原生窗口"
},
"app.appearance-settings.select-option": {
"message": "选择一个选项"
},
"app.appearance-settings.show-play-time.description": {
"message": "显示你在每个实例中游玩的时间。"
},
@@ -245,9 +242,6 @@
"app.browse.back-to-instance": {
"message": "返回实例"
},
"app.browse.discover-content": {
"message": "发现内容"
},
"app.browse.discover-servers": {
"message": "发现服务器"
},
@@ -191,9 +191,6 @@
"app.appearance-settings.native-decorations.title": {
"message": "原生視窗"
},
"app.appearance-settings.select-option": {
"message": "選擇選項"
},
"app.appearance-settings.show-play-time.description": {
"message": "顯示你在該實例的遊玩時數。"
},
@@ -245,9 +242,6 @@
"app.browse.back-to-instance": {
"message": "回到實例"
},
"app.browse.discover-content": {
"message": "探索內容"
},
"app.browse.discover-servers": {
"message": "探索伺服器"
},
+250 -86
View File
@@ -3,9 +3,11 @@ import type { Labrinth } from '@modrinth/api-client'
import {
CheckIcon,
ClipboardCopyIcon,
CompassIcon,
ExternalIcon,
GlobeIcon,
PlusIcon,
ServerStackIcon,
SpinnerIcon,
} from '@modrinth/assets'
import type { BrowseInstallContentType, CardAction, ProjectType, Tags } from '@modrinth/ui'
@@ -15,6 +17,7 @@ import {
commonMessages,
CreationFlowModal,
defineMessages,
formatProjectTypeSentence,
getLatestMatchingInstallVersion,
getSelectedInstallPreferences,
getTargetInstallPreferences,
@@ -22,6 +25,7 @@ import {
preferencesDiffer,
provideBrowseManager,
requestInstall,
resolveInstallPlan,
stripServerRuntimeInstallFilters,
stripServerRuntimeInstallOverrides,
useBrowseSearch,
@@ -31,9 +35,9 @@ import {
import { useQueryClient } from '@tanstack/vue-query'
import { convertFileSrc } from '@tauri-apps/api/core'
import type { Ref } from 'vue'
import { computed, onMounted, onUnmounted, ref, watch } from 'vue'
import { computed, onMounted, onUnmounted, ref, shallowRef, watch } from 'vue'
import type { LocationQuery } from 'vue-router'
import { onBeforeRouteLeave, useRoute, useRouter } from 'vue-router'
import { useRoute, useRouter } from 'vue-router'
import ContextMenu from '@/components/ui/ContextMenu.vue'
import { useAppServerBrowse } from '@/composables/browse/use-app-server-browse'
@@ -47,18 +51,23 @@ import { instance_listener } from '@/helpers/events.js'
import {
get as getInstance,
get_installed_project_ids as getInstalledProjectIds,
list as listInstances,
} from '@/helpers/instance'
import { get_loader_versions as getLoaderManifest } from '@/helpers/metadata'
import { get as getSettings, set as setSettings } from '@/helpers/settings.ts'
import { get_categories, get_game_versions, get_loaders } from '@/helpers/tags'
import { get_instance_worlds } from '@/helpers/worlds'
import {
type BreadcrumbDefinition,
useBreadcrumb,
useRootBreadcrumb,
} from '@/providers/breadcrumbs'
import { injectContentInstall } from '@/providers/content-install'
import { injectServerInstall } from '@/providers/server-install'
import {
createServerInstallContent,
provideServerInstallContent,
} from '@/providers/setup/server-install-content'
import { useBreadcrumbs } from '@/store/breadcrumbs'
import { useTheming } from '@/store/state'
const { handleError } = injectNotificationManager()
@@ -71,6 +80,40 @@ const debugLog = useDebugLogger('Browse')
const router = useRouter()
const route = useRoute()
const displayedBrowseRoute = shallowRef(router.currentRoute.value)
watch(
() => router.currentRoute.value,
(nextRoute) => {
if (nextRoute.path.startsWith('/browse/')) {
displayedBrowseRoute.value = nextRoute
}
},
{ immediate: true },
)
const breadcrumbMessages = defineMessages({
discoverProjectType: {
id: 'app.browse.discover-project-type',
defaultMessage: 'Discover {projectType}',
},
discoverServers: {
id: 'app.browse.discover-servers',
defaultMessage: 'Discover servers',
},
})
const breadcrumbLabel = computed(() => {
const browseRoute = displayedBrowseRoute.value
if (browseRoute.query.from === 'worlds' || browseRoute.params.projectType === 'server') {
return formatMessage(breadcrumbMessages.discoverServers)
}
return formatMessage(breadcrumbMessages.discoverProjectType, {
projectType: formatProjectTypeSentence(
formatMessage,
String(browseRoute.params.projectType ?? ''),
2,
),
})
})
const themeStore = useTheming()
const browseRouteActive = computed(() => route.path.startsWith('/browse/'))
const serverSetupModalRef = ref<InstanceType<typeof CreationFlowModal> | null>(null)
@@ -111,6 +154,86 @@ const {
markServerProjectInstalled,
} = serverInstallContent
type Instance = {
game_version: string
loader: string
path: string
install_stage: string
icon_path?: string
name: string
link?: {
type: string
project_id: string
version_id: string
}
}
const initialInstanceId = String(route.query.i ?? '')
const instance: Ref<Instance | null> = ref(
queryClient.getQueryData<Instance>(['instances', 'summary', initialInstanceId]) ?? null,
)
const installedProjectIds: Ref<string[] | null> = ref(null)
const instanceHideInstalled = ref(route.query.ai === 'true')
const newlyInstalled = ref<string[]>([])
const hiddenInstanceProjectIds = ref<Set<string>>(new Set())
const hiddenInstanceProjectIdsInitialized = ref(false)
const isServerInstance = ref(false)
const instanceBreadcrumb = route.query.i
? useBreadcrumb({
slot: 'instance',
id: () => `instance:${String(displayedBrowseRoute.value.query.i ?? '')}`,
label: () => instance.value?.name ?? formatMessage(commonMessages.loadingLabel),
visual: () => ({
type: 'image',
src: instance.value?.icon_path ? convertFileSrc(instance.value.icon_path) : undefined,
alt: instance.value?.name,
tintBy: String(displayedBrowseRoute.value.query.i ?? ''),
}),
to: () => {
const instancePath = `/instance/${encodeURIComponent(
String(displayedBrowseRoute.value.query.i ?? ''),
)}`
return displayedBrowseRoute.value.query.from === 'worlds'
? `${instancePath}/worlds`
: instancePath
},
})
: undefined
const serverBreadcrumbTo = ref(serverBackUrl.value)
watch(serverBackUrl, (value) => {
if (route.path.startsWith('/browse/')) {
serverBreadcrumbTo.value = value
}
})
const serverBreadcrumb =
!instanceBreadcrumb && serverIdQuery.value
? useBreadcrumb({
slot: 'server',
id: () => `server:${String(displayedBrowseRoute.value.query.sid ?? '')}`,
label: () =>
serverContextServerData.value?.name ?? formatMessage(commonMessages.loadingLabel),
visual: { type: 'icon', component: ServerStackIcon },
to: serverBreadcrumbTo,
})
: undefined
const breadcrumbParent = instanceBreadcrumb ?? serverBreadcrumb
const breadcrumbDefinition = {
slot: 'browse',
id: () =>
`browse:${String(displayedBrowseRoute.value.params.projectType ?? '')}:${String(
displayedBrowseRoute.value.query.i ?? '',
)}:${String(displayedBrowseRoute.value.query.sid ?? '')}:${String(
displayedBrowseRoute.value.query.from ?? '',
)}`,
label: breadcrumbLabel,
to: () => displayedBrowseRoute.value.fullPath,
visual: { type: 'icon', component: CompassIcon },
} satisfies BreadcrumbDefinition
const browseBreadcrumb = breadcrumbParent
? useBreadcrumb(breadcrumbDefinition, { parent: breadcrumbParent })
: useRootBreadcrumb(breadcrumbDefinition)
debugLog('fetching tags (categories, loaders, gameVersions)')
const [categories, loaders, availableGameVersions] = await Promise.all([
get_categories()
@@ -130,28 +253,6 @@ const tags: Ref<Tags> = computed(() => ({
categories: categories.value ?? [],
}))
type Instance = {
game_version: string
loader: string
path: string
install_stage: string
icon_path?: string
name: string
link?: {
type: string
project_id: string
version_id: string
}
}
const instance: Ref<Instance | null> = ref(null)
const installedProjectIds: Ref<string[] | null> = ref(null)
const instanceHideInstalled = ref(false)
const newlyInstalled = ref<string[]>([])
const hiddenInstanceProjectIds = ref<Set<string>>(new Set())
const hiddenInstanceProjectIdsInitialized = ref(false)
const isServerInstance = ref(false)
if (isFromWorlds.value && route.params.projectType !== 'server') {
router.replace({
path: '/browse/server',
@@ -189,7 +290,17 @@ watchServerContextChanges()
await initInstanceContext()
async function refreshInstalledProjectIds() {
if (!route.query.i) return
if (!route.query.i) {
const instances = await listInstances().catch(handleError)
if (!instances) return
const ids = instances
.map((gameInstance) => gameInstance.link?.project_id)
.filter((id): id is string => !!id)
debugLog('installedInstanceProjectIds loaded', { count: ids.length })
installedProjectIds.value = ids
return
}
if (route.query.from === 'worlds') {
const worlds = await get_instance_worlds(route.query.i as string).catch(handleError)
@@ -219,6 +330,7 @@ async function initInstanceContext() {
queryFrom: route.query.from,
})
await initServerContext()
await refreshInstalledProjectIds()
if (route.query.i) {
instance.value = (await getInstance(route.query.i as string).catch(handleError)) ?? null
@@ -228,8 +340,6 @@ async function initInstanceContext() {
gameVersion: instance.value?.game_version,
})
await refreshInstalledProjectIds()
if (instance.value?.link?.project_id) {
debugLog('checking linked project for server status', instance.value.link.project_id)
const projectV3 = await get_project_v3(
@@ -242,13 +352,23 @@ async function initInstanceContext() {
}
}
}
if (route.query.ai && !(route.params.projectType === 'modpack')) {
debugLog('setting instanceHideInstalled from query', route.query.ai)
instanceHideInstalled.value = route.query.ai === 'true'
}
}
function setBrowseHideInstalledFlag(flag: 'hide_installed_modpacks', value: boolean) {
themeStore.featureFlags[flag] = value
getSettings()
.then((settings) => {
settings.feature_flags[flag] = value
return setSettings(settings)
})
.catch(handleError)
}
const hideInstalledModpacks = computed({
get: () => themeStore.getFeatureFlag('hide_installed_modpacks'),
set: (value: boolean) => setBrowseHideInstalledFlag('hide_installed_modpacks', value),
})
const instanceFilters = computed(() => {
const filters = []
@@ -268,11 +388,15 @@ const instanceFilters = computed(() => {
if (isServerInstance.value) {
filters.push({ type: 'environment', option: 'client' })
}
}
if (instanceHideInstalled.value && hiddenInstanceProjectIds.value.size > 0) {
for (const id of hiddenInstanceProjectIds.value) {
filters.push({ type: 'project_id', option: `project_id:${id}`, negative: true })
}
if (
(instance.value || projectType.value === 'modpack') &&
(projectType.value === 'modpack' ? hideInstalledModpacks.value : instanceHideInstalled.value) &&
hiddenInstanceProjectIds.value.size > 0
) {
for (const id of hiddenInstanceProjectIds.value) {
filters.push({ type: 'project_id', option: `project_id:${id}`, negative: true })
}
}
@@ -331,6 +455,12 @@ const serverContextFilters = computed(() => {
{ type: 'environment', option: 'client' },
{ type: 'environment', option: 'server' },
)
if (hideInstalledModpacks.value && hiddenInstanceProjectIds.value.size > 0) {
for (const id of hiddenInstanceProjectIds.value) {
filters.push({ type: 'project_id', option: `project_id:${id}`, negative: true })
}
}
}
if (serverHideInstalled.value && hiddenServerContentProjectIds.value.size > 0) {
@@ -385,14 +515,6 @@ const messages = defineMessages({
id: 'app.browse.add-to-an-instance',
defaultMessage: 'Add to an instance',
},
discoverContent: {
id: 'app.browse.discover-content',
defaultMessage: 'Discover content',
},
discoverServers: {
id: 'app.browse.discover-servers',
defaultMessage: 'Discover servers',
},
environmentProvidedByServer: {
id: 'search.filter.locked.server-environment.title',
defaultMessage: 'Only client-side mods can be added to the server instance',
@@ -409,6 +531,10 @@ const messages = defineMessages({
id: 'app.browse.hide-added-servers',
defaultMessage: 'Hide servers already added',
},
hideInstalledModpacks: {
id: 'app.browse.hide-installed-modpacks',
defaultMessage: 'Hide already installed',
},
installingToServer: {
id: 'app.browse.server.installing',
defaultMessage: 'Installing',
@@ -448,29 +574,6 @@ const messages = defineMessages({
},
})
const breadcrumbs = useBreadcrumbs()
const browseTitle = computed(() =>
formatMessage(isFromWorlds.value ? messages.discoverServers : messages.discoverContent),
)
breadcrumbs.setName('BrowseTitle', browseTitle.value)
if (instance.value) {
const instanceLink = `/instance/${encodeURIComponent(instance.value.id)}`
breadcrumbs.setContext({
name: instance.value.name,
link: isFromWorlds.value ? `${instanceLink}/worlds` : instanceLink,
})
} else {
breadcrumbs.setContext(null)
}
onBeforeRouteLeave(() => {
breadcrumbs.setContext({
name: browseTitle.value,
link: `/browse/${projectType.value}`,
query: route.query,
})
})
const projectType = ref<ProjectType>(route.params.projectType as ProjectType)
function resetInstanceContext() {
@@ -484,8 +587,8 @@ function resetInstanceContext() {
hiddenInstanceProjectIds.value = new Set()
hiddenInstanceProjectIdsInitialized.value = false
isServerInstance.value = false
breadcrumbs.setName('BrowseTitle', formatMessage(messages.discoverContent))
breadcrumbs.setContext(null)
browseBreadcrumb.reset()
void refreshInstalledProjectIds()
}
watch(
@@ -696,6 +799,27 @@ async function chooseInstanceInstallVersion(
return { versionId: selectedVersion.id }
}
async function chooseFilterMatchingInstallVersion(
project: Labrinth.Search.v3.ResultSearchProject,
projectTypeValue: string,
) {
const plan = await resolveInstallPlan({
project: {
project_id: project.project_id,
title: project.title,
icon_url: project.icon_url,
},
contentType: projectTypeValue as BrowseInstallContentType,
selectedFilters: searchState.currentFilters.value,
providedFilters: combinedProvidedFilters.value,
overriddenProvidedFilterTypes: searchState.overriddenProvidedFilterTypes.value,
targetPreferences: {},
getProjectVersions: getInstallProjectVersions,
})
return { versionId: plan.versionId }
}
function getCardActions(
result: Labrinth.Search.v3.ResultSearchProject,
currentProjectType: string,
@@ -714,6 +838,7 @@ function getCardActions(
serverContentProjectIds.value.has(projectResult.project_id || '') ||
serverContextServerData.value?.upstream?.project_id === projectResult.project_id
const isInstalling = installingProjectIds.value.has(projectResult.project_id)
const showAsInstalled = isInstalled && currentProjectType !== 'modpack'
if (
isServerContext.value &&
@@ -723,7 +848,7 @@ function getCardActions(
const isInstallingSelection = isInstallingQueuedServerInstalls.value
const validatingInstall =
isInstalling && currentProjectType !== 'modpack' && !isInstallingSelection
const installLabel = isInstalled
const installLabel = showAsInstalled
? commonMessages.installedLabel
: isQueued
? isInstalling || isInstallingSelection
@@ -743,11 +868,11 @@ function getCardActions(
icon:
isInstalling || isInstallingSelection
? SpinnerIcon
: isQueued || isInstalled
: isQueued || showAsInstalled
? CheckIcon
: PlusIcon,
iconClass: isInstalling || isInstallingSelection ? 'animate-spin' : undefined,
disabled: isInstalled || isInstalling || isInstallingSelection,
disabled: showAsInstalled || isInstalling || isInstallingSelection,
color: isQueued && !isInstalling && !isInstallingSelection ? 'green' : 'brand',
type: 'outlined',
onClick: async () => {
@@ -808,15 +933,15 @@ function getCardActions(
label: formatMessage(
isInstalling
? messages.installingToServer
: isInstalled
: showAsInstalled
? commonMessages.installedLabel
: shouldUseInstallIcon
? commonMessages.installButton
: messages.addToAnInstance,
),
icon: isInstalling ? SpinnerIcon : isInstalled ? CheckIcon : PlusIcon,
icon: isInstalling ? SpinnerIcon : showAsInstalled ? CheckIcon : PlusIcon,
iconClass: isInstalling ? 'animate-spin' : undefined,
disabled: isInstalled || isInstalling,
disabled: showAsInstalled || isInstalling,
color: 'brand',
type: 'outlined',
onClick: async () => {
@@ -824,7 +949,9 @@ function getCardActions(
try {
const selectedInstall = instance.value
? await chooseInstanceInstallVersion(projectResult, currentProjectType)
: { versionId: null as string | null }
: isModpack
? await chooseFilterMatchingInstallVersion(projectResult, currentProjectType)
: { versionId: null as string | null }
if (selectedInstall === null) {
setProjectInstalling(projectResult.project_id, false)
return
@@ -903,6 +1030,14 @@ async function search(requestParams: string) {
}
}
for (const hit of rawResults.result.hits) {
for (const identifier of [hit.project_id, hit.slug]) {
if (identifier) {
queryClient.setQueryData(['projects', 'summary', identifier], hit)
}
}
}
if (isServer) {
const hits = rawResults.result.hits ?? []
updateServerHits(hits)
@@ -919,10 +1054,11 @@ async function search(requestParams: string) {
...hit,
}
if (instance.value || isServerContext.value) {
const installedIds = instance.value
? new Set([...newlyInstalled.value, ...(installedProjectIds.value ?? [])])
: serverContentProjectIds.value
if (instance.value || isServerContext.value || projectType.value === 'modpack') {
const installedIds =
isServerContext.value && projectType.value !== 'modpack'
? serverContentProjectIds.value
: new Set([...newlyInstalled.value, ...(installedProjectIds.value ?? [])])
mapped.installed = installedIds.has(hit.project_id)
}
@@ -982,9 +1118,9 @@ watch(
() => projectType.value,
],
() => {
if (isServerContext.value) {
if (isServerContext.value && projectType.value !== 'modpack') {
syncHiddenServerContentProjectIds()
} else if (instance.value) {
} else if (instance.value || projectType.value === 'modpack') {
syncHiddenInstanceProjectIds()
}
},
@@ -1016,6 +1152,18 @@ let unlistenInstances: UnlistenFn | null = null
onMounted(() => {
instance_listener(async (event: { event: string; instance_id: string }) => {
if (event.event === 'added' || event.event === 'created' || event.event === 'removed') {
if (!route.query.i) {
await refreshInstalledProjectIds()
if (projectType.value === 'modpack') {
if (event.event === 'removed') {
syncHiddenInstanceProjectIds()
}
await searchState.refreshSearch()
}
}
}
if (instance.value && event.instance_id === instance.value.id && event.event === 'synced') {
await refreshInstalledProjectIds()
await searchState.refreshSearch()
@@ -1081,8 +1229,17 @@ provideBrowseManager({
installContext,
providedFilters: combinedProvidedFilters,
hideInstalled: computed({
get: () => (isServerContext.value ? serverHideInstalled.value : instanceHideInstalled.value),
get: () => {
if (projectType.value === 'modpack') return hideInstalledModpacks.value
if (isServerContext.value) return serverHideInstalled.value
return instanceHideInstalled.value
},
set: (val: boolean) => {
if (projectType.value === 'modpack') {
hideInstalledModpacks.value = val
if (val) syncHiddenInstanceProjectIds()
return
}
if (isServerContext.value) {
serverHideInstalled.value = val
if (val) syncHiddenServerContentProjectIds()
@@ -1093,11 +1250,18 @@ provideBrowseManager({
},
}),
showHideInstalled: computed(
() => (isServerContext.value && projectType.value !== 'modpack') || !!instance.value,
() =>
projectType.value === 'modpack' ||
(isServerContext.value && projectType.value !== 'modpack') ||
!!instance.value,
),
hideInstalledLabel: computed(() =>
formatMessage(
isFromWorlds.value ? messages.hideAddedServers : commonMessages.hideInstalledContentLabel,
isFromWorlds.value
? messages.hideAddedServers
: projectType.value === 'modpack'
? messages.hideInstalledModpacks
: commonMessages.hideInstalledContentLabel,
),
),
hideSelected: hideSelectedServerInstalls,
+9 -5
View File
@@ -1,9 +1,9 @@
<script setup lang="ts">
import { HomeIcon } from '@modrinth/assets'
import { injectNotificationManager } from '@modrinth/ui'
import type { SearchResult } from '@modrinth/utils'
import dayjs from 'dayjs'
import { computed, onUnmounted, ref } from 'vue'
import { useRoute } from 'vue-router'
import RowDisplay from '@/components/RowDisplay.vue'
import RecentWorldsList from '@/components/ui/world/RecentWorldsList.vue'
@@ -11,13 +11,17 @@ import { get_search_results } from '@/helpers/cache.js'
import { instance_listener } from '@/helpers/events'
import { list } from '@/helpers/instance'
import type { GameInstance } from '@/helpers/types'
import { useBreadcrumbs } from '@/store/breadcrumbs'
import { useRootBreadcrumb } from '@/providers/breadcrumbs'
const { handleError } = injectNotificationManager()
const route = useRoute()
const breadcrumbs = useBreadcrumbs()
breadcrumbs.setRootContext({ name: 'Home', link: route.path })
useRootBreadcrumb({
slot: 'root',
id: 'home',
label: 'Home',
to: '/',
visual: { type: 'icon', component: HomeIcon },
})
const instances = ref<GameInstance[]>([])
+11
View File
@@ -1,15 +1,26 @@
<script setup lang="ts">
import type { Labrinth } from '@modrinth/api-client'
import { ServerStackIcon } from '@modrinth/assets'
import { injectModrinthClient, ServersManagePageIndex } from '@modrinth/ui'
import { useQuery } from '@tanstack/vue-query'
import { computed } from 'vue'
import { useRootBreadcrumb } from '@/providers/breadcrumbs'
import { config } from '../config'
const stripePublishableKey = (config.stripePublishableKey as string) || ''
const client = injectModrinthClient()
useRootBreadcrumb({
slot: 'root',
id: 'servers',
label: 'Servers',
to: '/hosting/manage/',
visual: { type: 'icon', component: ServerStackIcon },
})
const { data: products } = useQuery({
queryKey: ['billing', 'products'],
queryFn: () => client.labrinth.billing_internal.getProducts(),
+12 -5
View File
@@ -6,6 +6,7 @@ import {
EyeIcon,
LogInIcon,
RotateCounterClockwiseIcon,
ShirtIcon,
SpinnerIcon,
} from '@modrinth/assets'
import {
@@ -56,8 +57,18 @@ import {
set_custom_skin_order,
} from '@/helpers/skins.ts'
import { hasPride26Badge } from '@/helpers/user-campaigns.ts'
import { useRootBreadcrumb } from '@/providers/breadcrumbs'
import { handleSevereError } from '@/store/error'
import { useTheming } from '@/store/state'
import { appMessages } from '@/utils/app-messages'
useRootBreadcrumb({
slot: 'root',
id: 'skins',
label: 'Skin selector',
to: '/skins',
visual: { type: 'icon', component: ShirtIcon },
})
type UnlistenFn = () => void
type VirtualSkinSectionListExpose = {
@@ -68,10 +79,6 @@ const PENDING_SKIN_REFRESH_DELAY_MS = 11_000
const DEFAULT_SKIN_SECTION_SORT_ORDER = ['Default skins', 'Modrinth Pride']
const EARS_NOTICE_PLACEHOLDER = '__EARS_MOD_NAME__'
const messages = defineMessages({
skinSelectorTitle: {
id: 'app.skins.title',
defaultMessage: 'Skin selector',
},
modrinthPrideSection: {
id: 'app.skins.section.modrinth-pride',
defaultMessage: 'Modrinth Pride',
@@ -1071,7 +1078,7 @@ await loadSkins()
<div v-if="currentUser" class="skin-layout box-border min-h-full p-4">
<div class="sticky top-6 self-start p-2 pt-0">
<h1 class="m-0 text-2xl font-bold flex items-center gap-2">
{{ formatMessage(messages.skinSelectorTitle) }}
{{ formatMessage(appMessages.skinSelectorLabel) }}
</h1>
<div
class="ml-5 mt-4 flex h-[calc(80vh-1rem)] items-center justify-center max-[700px]:h-[calc(50vh-1rem)]"
+53 -25
View File
@@ -1,40 +1,49 @@
<template>
<div class="w-full pt-2">
<div class="w-full px-2 pt-2">
<UserProfilePageLayout
:user-id="userId"
:project-type="projectType"
variant="app"
site-url="https://modrinth.com"
project-link-mode="app"
:edit-profile-link="openProfileSettings"
external-navigation
/>
</div>
</template>
<script setup lang="ts">
import type { Labrinth } from '@modrinth/api-client'
import { provideUserProfile, UserProfilePageLayout } from '@modrinth/ui'
import { useQuery, useQueryClient } from '@tanstack/vue-query'
import { computed, watch } from 'vue'
import { computed, inject, ref, watch } from 'vue'
import { onBeforeRouteUpdate, useRoute } from 'vue-router'
import {
block_user,
get_blocked_users,
get_user_collections,
get_user_organizations,
get_user_profile,
get_user_projects,
patch_user,
unblock_user,
} from '@/helpers/users'
import { useBreadcrumbs } from '@/store/breadcrumbs'
import { appSettingsModalOpenProfileKey } from '@/providers/app-settings-modal'
import { useBreadcrumb } from '@/providers/breadcrumbs'
const route = useRoute()
const openProfileSettings = inject(appSettingsModalOpenProfileKey, () => {})
const queryClient = useQueryClient()
const breadcrumbs = useBreadcrumbs()
const userProfile = provideUserProfile({
getUser: get_user_profile,
getProjects: get_user_projects,
getOrganizations: get_user_organizations,
getCollections: get_user_collections,
patchUser: patch_user,
getBlockedUsers: get_blocked_users,
blockUser: block_user,
unblockUser: unblock_user,
})
const userId = computed(() => {
@@ -46,17 +55,54 @@ const projectType = computed(() => {
return Array.isArray(value) ? value[0] : value
})
function getCachedUserSummary(id: string) {
return queryClient.getQueryData<Labrinth.Users.v3.User>(['users', 'summary', id])
}
const { data: user } = useQuery({
queryKey: computed(() => ['user', userId.value]),
queryFn: () => userProfile.getUser(userId.value),
enabled: false,
staleTime: 30_000,
})
const breadcrumbUserId = ref(userId.value)
const breadcrumbLabel = ref(getCachedUserSummary(userId.value)?.username ?? userId.value)
const breadcrumbTo = ref(route.fullPath)
watch(
[userId, user, () => route.fullPath],
([currentUserId, currentUser, currentPath]) => {
if (route.name !== 'User') return
breadcrumbUserId.value = currentUserId
breadcrumbLabel.value = currentUser?.username ?? currentUserId
breadcrumbTo.value = currentPath
},
{ immediate: true, flush: 'sync' },
)
useBreadcrumb({
slot: 'user',
id: () => `user:${breadcrumbUserId.value}`,
label: breadcrumbLabel,
to: breadcrumbTo,
visual: () => ({
type: 'image',
src: user.value?.avatar_url ?? getCachedUserSummary(breadcrumbUserId.value)?.avatar_url,
alt: breadcrumbLabel.value,
circle: true,
tintBy: breadcrumbUserId.value,
}),
})
async function ensureUserProfileData(id: string): Promise<void> {
if (!id) return
let breadcrumbName = id
try {
const user = await queryClient.ensureQueryData({
await queryClient.ensureQueryData({
queryKey: ['user', id],
queryFn: () => userProfile.getUser(id),
staleTime: 30_000,
})
breadcrumbName = user.username
} catch {
// Let the mounted layout's useQuery surface errors; do not fail route setup.
}
@@ -78,8 +124,6 @@ async function ensureUserProfileData(id: string): Promise<void> {
staleTime: 30_000,
}),
])
breadcrumbs.setName('User', breadcrumbName)
}
onBeforeRouteUpdate(async (to) => {
@@ -88,21 +132,5 @@ onBeforeRouteUpdate(async (to) => {
await ensureUserProfileData(id)
})
breadcrumbs.setName('User', userId.value)
await ensureUserProfileData(userId.value)
const { data: user } = useQuery({
queryKey: computed(() => ['user', userId.value]),
queryFn: () => userProfile.getUser(userId.value),
enabled: false,
staleTime: 30_000,
})
watch(
[userId, user],
([currentUserId, value]) => {
breadcrumbs.setName('User', value?.username ?? currentUserId)
},
{ immediate: true },
)
</script>
-4
View File
@@ -1,4 +0,0 @@
<script setup lang="ts"></script>
<template>
<div class="p-6 flex flex-col gap-2">Worlds</div>
</template>
@@ -48,15 +48,22 @@
<script setup lang="ts">
import type { Archon, Labrinth } from '@modrinth/api-client'
import { injectAuth, injectModrinthClient, ServersManageRootLayout } from '@modrinth/ui'
import { ServerStackIcon } from '@modrinth/assets'
import {
commonMessages,
injectAuth,
injectModrinthClient,
ServersManageRootLayout,
useVIntl,
} from '@modrinth/ui'
import { useQuery, useQueryClient } from '@tanstack/vue-query'
import { openUrl } from '@tauri-apps/plugin-opener'
import { computed, watch } from 'vue'
import { computed, ref, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { get_user } from '@/helpers/cache'
import { get as getCreds } from '@/helpers/mr_auth'
import { useBreadcrumbs } from '@/store/breadcrumbs'
import { provideBreadcrumbParent, useBreadcrumb } from '@/providers/breadcrumbs'
import { useTheming } from '@/store/theme'
const route = useRoute()
@@ -65,47 +72,63 @@ const auth = injectAuth()
const client = injectModrinthClient()
const queryClient = useQueryClient()
const themeStore = useTheming()
const breadcrumbs = useBreadcrumbs()
const { formatMessage } = useVIntl()
const isContainedServerRoute = computed(() => route.name === 'ServerManageOverview')
const serverId = computed(() => {
const rawId = route.params.id
return Array.isArray(rawId) ? rawId[0] : (rawId ?? '')
return Array.isArray(rawId) ? (rawId[0] ?? '') : (rawId ?? '')
})
if (serverId.value) {
try {
await queryClient.ensureQueryData({
queryKey: ['servers', 'detail', serverId.value],
queryFn: () => client.archon.servers_v0.get(serverId.value)!,
staleTime: 30_000,
})
} catch {
// Let mounted layouts' useQuery surface errors; do not fail route setup.
}
function getCachedServerName(id: string): string | undefined {
return queryClient
.getQueryData<Archon.Servers.v0.ServerGetResponse>(['servers'])
?.servers.find((server) => server.server_id === id)?.name
}
const { data: serverData } = useQuery({
queryKey: computed(() => ['servers', 'detail', serverId.value]),
queryFn: () => null as unknown as Archon.Servers.v0.Server,
enabled: false,
queryFn: () => client.archon.servers_v0.get(serverId.value),
enabled: computed(() => Boolean(serverId.value)),
placeholderData: () =>
queryClient
.getQueryData<Archon.Servers.v0.ServerGetResponse>(['servers'])
?.servers.find((server) => server.server_id === serverId.value),
staleTime: 30_000,
})
const breadcrumbServerId = ref(serverId.value)
const breadcrumbLabel = ref(
getCachedServerName(serverId.value) ?? formatMessage(commonMessages.loadingLabel),
)
watch(
serverId,
(value) => {
if (!route.path.startsWith('/hosting/manage/') || route.name === 'Servers') return
breadcrumbServerId.value = value
breadcrumbLabel.value = getCachedServerName(value) ?? formatMessage(commonMessages.loadingLabel)
},
{ flush: 'sync' },
)
watch(
serverData,
(server) => {
if (server?.name) {
breadcrumbs.setName('Server', server.name)
breadcrumbs.setContext({
name: server.name,
link: `/hosting/manage/${serverId.value}/content`,
})
}
if (!route.path.startsWith('/hosting/manage/') || !server?.name) return
breadcrumbLabel.value = server.name
},
{ immediate: true },
)
const serverBreadcrumb = useBreadcrumb({
slot: 'server',
id: () => `server:${breadcrumbServerId.value}`,
label: breadcrumbLabel,
visual: { type: 'icon', component: ServerStackIcon },
to: () => `/hosting/manage/${encodeURIComponent(breadcrumbServerId.value)}`,
})
provideBreadcrumbParent(serverBreadcrumb)
watch(
() => auth.user.value,
(user, previousUser) => {
+1 -2
View File
@@ -3,6 +3,5 @@ import Index from './Index.vue'
import Servers from './Servers.vue'
import Skins from './Skins.vue'
import User from './User.vue'
import Worlds from './Worlds.vue'
export { Browse, Index, Servers, Skins, User, Worlds }
export { Browse, Index, Servers, Skins, User }
+83 -24
View File
@@ -16,6 +16,8 @@
<UpdateToPlayModal ref="updateToPlayModal" :instance="instance" />
<SharedInstanceUpdateModal
ref="sharedInstanceUpdateModal"
@accepted="hideAcceptedSharedInstanceUpdate"
@complete="handleSharedInstanceUpdateComplete"
@shared-instance-unavailable="handleSharedInstanceUnavailable"
@report="(event) => reportSharedInstance(event, true)"
/>
@@ -62,8 +64,10 @@
:shared-instance-expected-user-id="sharedInstanceExpectedUserId"
:shared-instance-role="instance.shared_instance?.role"
:shared-instance-signed-out="sharedInstanceSignedOut"
:shared-instance-update-available="showSharedInstanceUpdateAdmonition"
@published="fetchInstance"
@delete="requestInstanceDeletion"
@review-update="reviewSharedInstanceUpdate"
/>
</div>
<div :class="['p-6 pt-4', { 'min-h-0 flex-1 overflow-y-auto': isFixedRender }]">
@@ -134,7 +138,14 @@ import {
UserPlusIcon,
XIcon,
} from '@modrinth/assets'
import { injectAuth, injectNotificationManager, NavTabs, useLoadingBarToken } from '@modrinth/ui'
import {
commonMessages,
injectAuth,
injectNotificationManager,
NavTabs,
useLoadingBarToken,
useVIntl,
} from '@modrinth/ui'
import { useQuery, useQueryClient } from '@tanstack/vue-query'
import { convertFileSrc } from '@tauri-apps/api/core'
import dayjs from 'dayjs'
@@ -181,9 +192,10 @@ import { useSharedInstanceErrors } from '@/helpers/shared-instance-errors'
import type { GameInstance } from '@/helpers/types'
import { createInstanceShortcut, showInstanceInFolder } from '@/helpers/utils.js'
import { refreshWorlds, type ServerStatus } from '@/helpers/worlds'
import { useRootBreadcrumb } from '@/providers/breadcrumbs'
import { injectServerInstall } from '@/providers/server-install'
import { handleSevereError } from '@/store/error.js'
import { useBreadcrumbs, useTheming } from '@/store/state'
import { useTheming } from '@/store/state'
import { provideSharedInstanceState, useSharedInstanceState } from './use-shared-instance-state'
@@ -194,10 +206,10 @@ const { playServerProject } = injectServerInstall()
const auth = injectAuth()
const queryClient = useQueryClient()
const route = useRoute()
const { formatMessage } = useVIntl()
const router = useRouter()
const displayedInstanceRoute = shallowRef(router.currentRoute.value)
const breadcrumbs = useBreadcrumbs()
const themeStore = useTheming()
const showInstancePlayTime = computed(() => themeStore.getFeatureFlag('show_instance_play_time'))
const contentSubpageRouteNames = new Set(['Mods', 'ModsFilter'])
@@ -210,7 +222,23 @@ window.addEventListener('online', () => {
offline.value = false
})
const instance = ref<GameInstance>()
const initialInstanceId = String(displayedInstanceRoute.value.params.id ?? '')
const instance = ref<GameInstance | undefined>(
queryClient.getQueryData<GameInstance>(['instances', 'summary', initialInstanceId]),
)
useRootBreadcrumb({
slot: 'instance',
id: () => `instance:${String(displayedInstanceRoute.value.params.id ?? '')}`,
label: () => instance.value?.name ?? formatMessage(commonMessages.loadingLabel),
visual: () => ({
type: 'image',
src: instance.value?.icon_path ? convertFileSrc(instance.value.icon_path) : undefined,
alt: instance.value?.name,
tintBy: instance.value?.id ?? String(displayedInstanceRoute.value.params.id ?? ''),
}),
to: () => `/instance/${encodeURIComponent(String(displayedInstanceRoute.value.params.id ?? ''))}`,
})
const preloadedContent = ref<InstanceContentData | null>(null)
const playing = ref(false)
const loading = ref(false)
@@ -223,6 +251,7 @@ const sharedInstanceUpdateModal = ref<InstanceType<typeof SharedInstanceUpdateMo
const sharedInstanceReportModal = ref<InstanceType<typeof SharedInstanceInstallModal>>()
const deleteConfirmModal = ref<InstanceType<typeof ConfirmDeleteInstanceModal>>()
const selectedInstanceToDelete = ref<GameInstance | null>(null)
const hiddenSharedInstanceUpdateKey = ref<string | null>(null)
const { notifySharedInstanceError, notifySharedInstanceUnavailable } = useSharedInstanceErrors()
@@ -253,8 +282,19 @@ const {
signedOut: sharedInstanceSignedOut,
unavailableManager: sharedInstanceUnavailableManager,
unavailableReason: sharedInstanceUnavailableReason,
updatePreview: sharedInstanceUpdatePreview,
wrongAccount: sharedInstanceWrongAccount,
} = sharedInstanceState
const sharedInstanceUpdateKey = computed(() => {
const instanceId = instance.value?.id
const latestVersion = sharedInstanceUpdatePreview.value?.latestVersion
return instanceId && latestVersion !== undefined ? `${instanceId}:${latestVersion}` : null
})
const showSharedInstanceUpdateAdmonition = computed(
() =>
sharedInstanceUpdatePreview.value?.updateAvailable === true &&
sharedInstanceUpdateKey.value !== hiddenSharedInstanceUpdateKey.value,
)
watch(
() => router.currentRoute.value,
@@ -323,6 +363,9 @@ async function fetchInstance() {
}
instance.value = nextInstance ?? undefined
if (nextInstance) {
queryClient.setQueryData(['instances', 'summary', nextInstance.id], nextInstance)
}
displayedInstanceRoute.value = nextRoute
sharedInstanceState.reset()
sharedInstanceState.refreshAvailability()
@@ -487,20 +530,6 @@ watch(
{ immediate: true },
)
if (instance.value) {
breadcrumbs.setName(
'Instance',
instance.value.name.length > 40
? instance.value.name.substring(0, 40) + '...'
: instance.value.name,
)
breadcrumbs.setContext({
name: instance.value.name,
link: displayedInstanceRoute.value.path,
query: displayedInstanceRoute.value.query,
})
}
const options = ref<InstanceType<typeof ContextMenu> | null>(null)
const launchInstance = async (context: string) => {
@@ -530,6 +559,37 @@ async function handleSharedInstanceUnavailable(
setSharedInstanceUnavailable(reason)
}
function reviewSharedInstanceUpdate(event: MouseEvent) {
const currentInstance = instance.value
const preview = sharedInstanceUpdatePreview.value
if (
!currentInstance ||
currentInstance.shared_instance?.role !== 'member' ||
!preview?.updateAvailable
) {
return
}
sharedInstanceUpdateModal.value?.show(
currentInstance,
preview,
async () => {
await fetchInstance()
},
event,
)
}
function hideAcceptedSharedInstanceUpdate() {
hiddenSharedInstanceUpdateKey.value = sharedInstanceUpdateKey.value
}
function handleSharedInstanceUpdateComplete(successful: boolean) {
if (!successful && hiddenSharedInstanceUpdateKey.value === sharedInstanceUpdateKey.value) {
hiddenSharedInstanceUpdateKey.value = null
}
}
const startInstance = async (context: string) => {
if (!instance.value || instance.value.quarantined) return
if (checkingSharedInstanceLaunch.value || loading.value || playing.value) return
@@ -540,17 +600,16 @@ const startInstance = async (context: string) => {
!!instance.value.shared_instance && !sharedInstanceActionsLocked.value && !offline.value
if (canCheckSharedInstanceUpdate) {
let preview: Awaited<ReturnType<typeof refreshSharedInstanceUpdatePreview>>
let preview: Awaited<ReturnType<typeof refreshSharedInstanceUpdatePreview>> = null
checkingSharedInstanceLaunch.value = true
try {
preview = await refreshSharedInstanceUpdatePreview()
} catch (error) {
if (isSharedInstanceUnavailableError(error)) {
await handleSharedInstanceUnavailable(getSharedInstanceUnavailableReason(error))
} else {
notifySharedInstanceError(error)
return
}
return
notifySharedInstanceError(error)
} finally {
checkingSharedInstanceLaunch.value = false
}
@@ -655,9 +714,9 @@ async function reportSharedInstance(event?: MouseEvent, closeUpdateModal = false
)
if (instance.value?.id !== reportInstance.id) return
if (closeUpdateModal) sharedInstanceUpdateModal.value?.hide()
sharedInstanceReportModal.value?.showReport(preview, event)
sharedInstanceReportModal.value?.showReport(preview, sharedInstance.manager_id, event)
} catch (error) {
handleError(error as Error)
notifySharedInstanceError(error)
}
}
@@ -9,9 +9,19 @@
:link="inviteLink.link.value"
:link-expires-at="inviteLink.details.value?.expiresAt"
:link-max-uses="inviteLink.details.value?.maxUses"
:link-max-uses-limit="remainingUserSlots"
:update-invite-link="inviteLink.update"
:user-profile-link="userProfileLink"
:can-invite="!members.exclusiveMutationPending.value && !inviteLink.pending.value"
:can-invite="
hasRemainingUserSlots &&
!members.exclusiveMutationPending.value &&
!inviteLink.pending.value
"
:invite-disabled-message="
hasRemainingUserSlots
? undefined
: formatMessage(messages.userLimitReached, { limit: SHARED_INSTANCE_USER_LIMIT })
"
@invite="invitePlayer"
@cancel="cancelInvite"
/>
@@ -37,10 +47,19 @@
@state-change="publishState = $event"
/>
<SharedInstanceShareEmptyState
v-if="unableToConnect"
:heading="formatMessage(messages.unableToConnectHeading)"
:description="formatMessage(messages.unableToConnectDescription)"
/>
<div v-else-if="membersTableLoading" class="h-64" aria-hidden="true" />
<SharedInstanceMembersTable
v-if="members.rows.value.length > 0"
v-else-if="showMembersTable"
:rows="members.rows.value"
:actions-locked="sharedInstanceActionsLocked"
:invite-disabled="!hasRemainingUserSlots"
:invite-pending="inviteLink.pending.value"
:push-update-disabled="
instance.install_stage !== 'installed' || publishState !== 'idle' || !!offline
@@ -107,7 +126,7 @@
<ButtonStyled color="brand"
><button
class="!h-10"
:disabled="inviteLink.pending.value"
:disabled="inviteLink.pending.value || !hasRemainingUserSlots"
@click="showInvitePlayers($event)"
>
<SpinnerIcon
@@ -137,16 +156,17 @@ import {
type InvitePlayersUser,
useVIntl,
} from '@modrinth/ui'
import { useQueryClient } from '@tanstack/vue-query'
import { useQuery, useQueryClient } from '@tanstack/vue-query'
import { computed, ref, toRef, watch } from 'vue'
import ModrinthAccountRequiredModal from '@/components/ui/modal/ModrinthAccountRequiredModal.vue'
import SharedInstancePublishModal from '@/components/ui/shared-instances/SharedInstancePublishModal.vue'
import {
getSharedInstanceUnavailableReason,
isSharedInstancesApiError,
isSharedInstanceUnavailableError,
} from '@/helpers/install'
import { edit } from '@/helpers/instance'
import { can_current_user_use_shared_instances, edit } from '@/helpers/instance'
import type { ModrinthAuthFlow } from '@/helpers/mr_auth.ts'
import {
sharedInstanceErrorMessages,
@@ -159,7 +179,7 @@ import { injectSharedInstanceState } from '../use-shared-instance-state'
import SharedInstanceMembersTable from './shared-instance-members-table.vue'
import SharedInstanceRemoveMemberModal from './shared-instance-remove-member-modal.vue'
import SharedInstanceShareEmptyState from './shared-instance-share-empty-state.vue'
import type { ShareRow } from './shared-instance-share-types'
import { SHARED_INSTANCE_USER_LIMIT, type ShareRow } from './shared-instance-share-types'
import { useSharedInstanceInviteCandidates } from './use-shared-instance-invite-candidates'
import { useSharedInstanceInviteLink } from './use-shared-instance-invite-link'
import { useSharedInstanceMembers } from './use-shared-instance-members'
@@ -182,6 +202,7 @@ const actionsLocked = sharedInstanceState.shareActionsLocked
const sharedInstanceActionsLocked = actionsLocked
const currentUserId = computed(() => auth.user.value?.id ?? null)
const isSignedIn = computed(() => !!auth.session_token.value)
const sharedInstancesApiUnavailable = ref(false)
const accountRequiredModal = ref<InstanceType<typeof ModrinthAccountRequiredModal>>()
const invitePlayersModal = ref<InstanceType<typeof InvitePlayersModal>>()
const unlinkModal = ref<InstanceType<typeof ConfirmUnlinkModal>>()
@@ -198,10 +219,22 @@ function notifyOperationError(error: unknown) {
sharedInstanceState.unavailableManager.value,
)
} else {
if (isSharedInstancesApiError(error)) sharedInstancesApiUnavailable.value = true
notifySharedInstanceError(error)
}
}
const eligibilityQuery = useQuery({
queryKey: computed(() => ['shared-instance-eligibility', currentUserId.value]),
queryFn: can_current_user_use_shared_instances,
enabled: () => isSignedIn.value && !!currentUserId.value,
retry: false,
staleTime: Infinity,
refetchOnMount: 'always',
refetchOnWindowFocus: false,
refetchOnReconnect: false,
})
const members = useSharedInstanceMembers({
instance,
currentUserId,
@@ -209,6 +242,10 @@ const members = useSharedInstanceMembers({
actionsLocked,
onError: notifyOperationError,
})
const remainingUserSlots = computed(() =>
Math.max(0, SHARED_INSTANCE_USER_LIMIT - members.rows.value.length),
)
const hasRemainingUserSlots = computed(() => remainingUserSlots.value > 0)
const {
inviteFriends,
search: searchInviteUsers,
@@ -221,6 +258,7 @@ const {
})
const inviteLink = useSharedInstanceInviteLink(
computed(() => props.instance.id),
remainingUserSlots,
notifyOperationError,
)
@@ -239,6 +277,29 @@ const lockedActionButton = computed(() =>
const sharedInstanceUnavailableReason = sharedInstanceState.unavailableReason
const sharedInstanceUnavailable = computed(() => !!sharedInstanceUnavailableReason.value)
const sharedInstanceUnavailableManager = sharedInstanceState.unavailableManager
const unableToConnect = computed(
() =>
sharedInstancesApiUnavailable.value ||
isSharedInstancesApiError(eligibilityQuery.error.value) ||
isSharedInstancesApiError(members.query.error.value),
)
const membersTableLoading = computed(
() =>
members.rows.value.length === 0 &&
!!props.instance.shared_instance &&
(members.query.data.value === undefined || members.query.isFetching.value) &&
!sharedInstanceUnavailable.value &&
!sharedInstanceActionsLocked.value,
)
const showMembersTable = computed(
() =>
members.rows.value.length > 0 ||
(!!props.instance.shared_instance &&
members.query.data.value !== undefined &&
!members.query.isFetching.value &&
!sharedInstanceUnavailable.value &&
!sharedInstanceActionsLocked.value),
)
const requiresUnlink = computed(
() =>
props.instance.link?.type === 'imported_modpack' &&
@@ -253,6 +314,15 @@ const importedModpackBackupTip = computed(() =>
const messages = defineMessages({
signInButton: { id: 'app.instance.share.sign-in.button', defaultMessage: 'Sign in' },
unableToConnectHeading: {
id: 'app.instance.share.unable-to-connect.heading',
defaultMessage: 'Unable to connect',
},
unableToConnectDescription: {
id: 'app.instance.share.unable-to-connect.description',
defaultMessage:
'The shared instances service is not accessible at the moment, please try again later',
},
noFriendsInvitedHeading: {
id: 'app.instance.share.empty.heading',
defaultMessage: 'No friends invited',
@@ -265,6 +335,10 @@ const messages = defineMessages({
id: 'app.instance.share.empty.invite-friends-button',
defaultMessage: 'Invite friends',
},
userLimitReached: {
id: 'app.instance.share.invite-modal.user-limit-reached',
defaultMessage: 'This instance has reached the {limit}-user limit.',
},
shareModalHeader: {
id: 'app.instance.share.invite-modal.heading',
defaultMessage: 'Share {name}',
@@ -304,7 +378,7 @@ const messages = defineMessages({
})
function invitePlayer(payload: InvitePlayersInvitePayload) {
if (actionsLocked.value) return
if (actionsLocked.value || !hasRemainingUserSlots.value) return
if (payload.source === 'search') void requestFriend(payload.user)
members.invite(payload.user)
}
@@ -315,6 +389,7 @@ function cancelInvite(user: InvitePlayersUser) {
async function showInvitePlayers(event?: MouseEvent) {
if (actionsLocked.value) return
if (!isSignedIn.value) return signInToShare(event)
if (!hasRemainingUserSlots.value) return
if (requiresUnlink.value) return unlinkModal.value?.show()
if (await inviteLink.ensure()) invitePlayersModal.value?.show(event)
}
@@ -353,6 +428,20 @@ function signInToShare(event?: MouseEvent) {
void accountRequiredModal.value?.show(event)
}
watch(
[eligibilityQuery.error, members.query.error],
(errors) => {
for (const error of errors) {
if (isSharedInstancesApiError(error)) notifyOperationError(error)
}
},
{ immediate: true },
)
watch([eligibilityQuery.data, members.query.data], ([eligibility, memberRows]) => {
if (eligibility !== undefined && memberRows !== undefined) {
sharedInstancesApiUnavailable.value = false
}
})
watch(
() => props.instance.id,
() => {
@@ -25,7 +25,7 @@
<ButtonStyled color="brand">
<button
class="flex !h-10 shrink-0 items-center gap-2"
:disabled="invitePending"
:disabled="invitePending || inviteDisabled"
@click="emit('invite', $event)"
>
<SpinnerIcon v-if="invitePending" class="animate-spin" aria-hidden="true" />
@@ -35,7 +35,7 @@
</ButtonStyled>
</template>
</div>
<div class="flex flex-wrap items-center gap-1.5">
<div v-if="hasMultipleMethods" class="flex flex-wrap items-center gap-1.5">
<FilterIcon class="size-5 shrink-0 text-secondary" aria-hidden="true" />
<button
:class="filterClass(methodFilter === 'all')"
@@ -67,7 +67,9 @@
>
<template #empty-state
><div class="flex h-64 items-center justify-center text-secondary">
No users match your filters.
{{
formatMessage(rows.length === 0 ? messages.noUsersJoined : messages.noUsersMatchFilters)
}}
</div></template
>
<template #cell-username="{ row }">
@@ -157,7 +159,7 @@ import {
useRelativeTime,
useVIntl,
} from '@modrinth/ui'
import { computed, ref } from 'vue'
import { computed, ref, watch } from 'vue'
import {
type MethodFilter,
@@ -170,6 +172,7 @@ import {
const props = defineProps<{
rows: ShareRow[]
actionsLocked?: boolean
inviteDisabled?: boolean
invitePending?: boolean
pushUpdateDisabled?: boolean
pushUpdatePending?: boolean
@@ -191,6 +194,7 @@ const methodFilterOptions: Array<{ id: ShareMethod; label: string }> = [
{ id: 'direct', label: methodLabels.direct },
{ id: 'link', label: methodLabels.link },
]
const hasMultipleMethods = computed(() => new Set(props.rows.map((row) => row.method)).size > 1)
const columns = computed<TableColumn<ShareTableColumn>[]>(() => {
const result: TableColumn<ShareTableColumn>[] = [
{
@@ -286,11 +290,23 @@ function filterClass(active: boolean) {
]
}
watch(hasMultipleMethods, (multiple) => {
if (!multiple) methodFilter.value = 'all'
})
const messages = defineMessages({
pushUpdate: {
id: 'app.instance.admonitions.shared-instance.publish-button',
defaultMessage: 'Push update',
},
noUsersJoined: {
id: 'app.instance.share.members.empty',
defaultMessage: 'No users have joined yet',
},
noUsersMatchFilters: {
id: 'app.instance.share.members.no-filter-results',
defaultMessage: 'No users match your filters.',
},
})
function userProfileLink(username: string) {
return !username || username.includes('@') ? undefined : `/user/${encodeURIComponent(username)}`
@@ -2,6 +2,8 @@ export type ShareMethod = 'direct' | 'link'
export type MethodFilter = ShareMethod | 'all'
export type ShareTableColumn = 'username' | 'lastPlayed' | 'joined' | 'method' | 'actions'
export const SHARED_INSTANCE_USER_LIMIT = 50
export type ShareRow = {
id: string
username: string
@@ -5,8 +5,11 @@ import { config } from '@/config'
import { toError } from '@/helpers/errors'
import { create_shared_instance_invite_link } from '@/helpers/instance'
const DEFAULT_INVITE_LINK_MAX_USES = 10
export function useSharedInstanceInviteLink(
instanceId: Ref<string>,
maxInviteUses: Ref<number>,
onError: (error: unknown) => void,
) {
const details = ref<Awaited<ReturnType<typeof create_shared_instance_invite_link>>>()
@@ -20,10 +23,12 @@ export function useSharedInstanceInviteLink(
async function ensure() {
if (details.value) return true
if (pending.value) return false
const maxUses = Math.min(DEFAULT_INVITE_LINK_MAX_USES, Math.floor(maxInviteUses.value))
if (maxUses <= 0) return false
pending.value = true
try {
details.value = await create_shared_instance_invite_link(instanceId.value)
details.value = await create_shared_instance_invite_link(instanceId.value, { maxUses })
return true
} catch (error) {
onError(error)
@@ -35,6 +40,8 @@ export function useSharedInstanceInviteLink(
async function update(settings: InviteLinkSettings) {
if (!details.value) return
const maxInviteLinkUses = Math.floor(maxInviteUses.value)
if (maxInviteLinkUses <= 0) return
pending.value = true
try {
@@ -44,7 +51,7 @@ export function useSharedInstanceInviteLink(
)
details.value = await create_shared_instance_invite_link(instanceId.value, {
maxAgeSeconds,
maxUses: settings.maxUses,
maxUses: Math.min(settings.maxUses, maxInviteLinkUses),
replaceInviteId: details.value.inviteId,
})
} catch (error) {
@@ -12,7 +12,11 @@ import {
} from '@/helpers/instance'
import type { GameInstance } from '@/helpers/types'
import { normalizeInviteKey, type ShareRow } from './shared-instance-share-types'
import {
normalizeInviteKey,
SHARED_INSTANCE_USER_LIMIT,
type ShareRow,
} from './shared-instance-share-types'
type MembersQueryKey = readonly ['sharedInstanceUsers', string]
@@ -54,6 +58,7 @@ export function useSharedInstanceMembers(options: {
queryFn: ({ queryKey }) => fetchRows(queryKey),
enabled: () =>
options.isSignedIn.value && !!options.instance.value.id && !options.actionsLocked.value,
retry: false,
staleTime: Infinity,
refetchOnMount: 'always',
refetchOnReconnect: false,
@@ -139,6 +144,7 @@ export function useSharedInstanceMembers(options: {
if (
options.actionsLocked.value ||
exclusiveMutationPending.value ||
rows.value.length >= SHARED_INSTANCE_USER_LIMIT ||
invitingUserIds.has(normalizedId) ||
find(user.id, user.username)
) {
+27 -14
View File
@@ -1,5 +1,5 @@
<script setup lang="ts">
import { PlusIcon } from '@modrinth/assets'
import { LibraryIcon, PlusIcon } from '@modrinth/assets'
import { ButtonStyled, injectNotificationManager, NavTabs } from '@modrinth/ui'
import { inject, onUnmounted, ref, shallowRef } from 'vue'
import { useRoute } from 'vue-router'
@@ -7,14 +7,19 @@ import { useRoute } from 'vue-router'
import { NewInstanceImage } from '@/assets/icons'
import { instance_listener } from '@/helpers/events.js'
import { list } from '@/helpers/instance'
import { useBreadcrumbs } from '@/store/breadcrumbs.js'
import { useRootBreadcrumb } from '@/providers/breadcrumbs'
const { handleError } = injectNotificationManager()
const showCreationModal = inject('showCreationModal')
const route = useRoute()
const breadcrumbs = useBreadcrumbs()
breadcrumbs.setRootContext({ name: 'Library', link: route.path })
useRootBreadcrumb({
slot: 'root',
id: 'library',
label: 'Library',
to: '/library',
visual: { type: 'icon', component: LibraryIcon },
})
const instances = shallowRef(await list().catch(handleError))
@@ -37,16 +42,24 @@ onUnmounted(() => {
<template>
<div class="p-6 flex flex-col gap-3">
<h1 class="m-0 text-2xl hidden">Library</h1>
<NavTabs
:links="[
{ label: 'All instances', href: `/library` },
{ label: 'Modpacks', href: `/library/modpacks` },
{ label: 'Servers', href: `/library/servers` },
{ label: 'Custom', href: `/library/custom` },
{ label: 'Shared with me', href: `/library/shared`, shown: false },
{ label: 'Saved', href: `/library/saved`, shown: false },
]"
/>
<div class="flex items-center justify-between gap-3">
<NavTabs
:links="[
{ label: 'All instances', href: `/library` },
{ label: 'Modpacks', href: `/library/modpacks` },
{ label: 'Servers', href: `/library/servers` },
{ label: 'Custom', href: `/library/custom` },
{ label: 'Shared with me', href: `/library/shared`, shown: false },
{ label: 'Saved', href: `/library/saved`, shown: false },
]"
/>
<ButtonStyled color="brand">
<button :disabled="offline" @click="showCreationModal?.()">
<PlusIcon />
New instance
</button>
</ButtonStyled>
</div>
<template v-if="instances && instances.length > 0">
<RouterView v-if="route.path.startsWith('/library')" :instances="instances" />
</template>
+82 -6
View File
@@ -295,10 +295,10 @@ import { get_loader_versions as getLoaderManifest } from '@/helpers/metadata'
import { get_by_instance_id } from '@/helpers/process'
import { get_categories, get_game_versions, get_loaders } from '@/helpers/tags'
import { getServerAddress } from '@/helpers/worlds'
import { provideBreadcrumbParent, useBreadcrumb } from '@/providers/breadcrumbs'
import { injectContentInstall } from '@/providers/content-install'
import { injectServerInstall } from '@/providers/server-install'
import { createServerInstallContent } from '@/providers/setup/server-install-content'
import { useBreadcrumbs } from '@/store/breadcrumbs'
import { useTheming } from '@/store/state.js'
dayjs.extend(relativeTime)
@@ -307,8 +307,29 @@ const { handleError } = injectNotificationManager()
const { install: installVersion } = injectContentInstall()
const route = useRoute()
const router = useRouter()
const displayedProjectRoute = shallowRef(router.currentRoute.value)
watch(
() => router.currentRoute.value,
(nextRoute) => {
if (nextRoute.path.startsWith('/project/')) {
displayedProjectRoute.value = nextRoute
}
},
{ immediate: true },
)
const projectBreadcrumbTo = computed(() => {
const currentRoute = displayedProjectRoute.value
if (currentRoute.name === 'Version') {
return {
name: 'Versions',
params: { id: currentRoute.params.id },
query: currentRoute.query,
}
}
return currentRoute.fullPath
})
const queryClient = useQueryClient()
const breadcrumbs = useBreadcrumbs()
const themeStore = useTheming()
const { formatMessage } = useVIntl()
@@ -317,6 +338,10 @@ const messages = defineMessages({
id: 'app.project.install-context.back-to-browse',
defaultMessage: 'Back to discover',
},
backToInstance: {
id: 'app.project.install-context.back-to-instance',
defaultMessage: 'Back to instance',
},
alreadyInstalled: {
id: 'app.project.install-button.already-installed',
defaultMessage: 'This project is already installed',
@@ -331,6 +356,40 @@ const { installingServerProjects, playServerProject, showAddServerToInstanceModa
injectServerInstall()
const installing = ref(false)
const data = shallowRef(null)
function getProjectBreadcrumbSummary(projectId) {
const identifier = Array.isArray(projectId) ? projectId[0] : projectId
if (typeof identifier !== 'string' || !identifier) return undefined
return queryClient.getQueryData(['projects', 'summary', identifier])
}
function getProjectBreadcrumbLabel(projectId) {
const summary = getProjectBreadcrumbSummary(projectId)
return summary?.name ?? summary?.title ?? formatMessage(commonMessages.loadingLabel)
}
const projectBreadcrumbLabel = ref(getProjectBreadcrumbLabel(route.params.id))
const projectBreadcrumb = useBreadcrumb({
slot: 'project',
id: () => `project:${String(displayedProjectRoute.value.params.id ?? '')}`,
label: projectBreadcrumbLabel,
visual: () => {
const identifier = String(displayedProjectRoute.value.params.id ?? '')
const loadedProject =
data.value?.id === identifier || data.value?.slug === identifier ? data.value : undefined
const project = loadedProject ?? getProjectBreadcrumbSummary(identifier)
return {
type: 'image',
src: project?.icon_url,
alt: projectBreadcrumbLabel.value,
tintBy: identifier,
}
},
to: projectBreadcrumbTo,
})
provideBreadcrumbParent(projectBreadcrumb)
const versions = shallowRef([])
const members = shallowRef([])
const categories = shallowRef([])
@@ -410,9 +469,18 @@ const projectGalleryHref = computed(() => buildProjectHref(`/project/${route.par
const projectBrowseBackUrl = computed(() => {
const browsePath = route.query.b
if (typeof browsePath === 'string' && browsePath.startsWith('/browse/')) return browsePath
const instanceId = route.query.i
if (typeof instanceId === 'string' && instanceId) {
return `/instance/${encodeURIComponent(instanceId)}`
}
const type = data.value?.project_type ? `${data.value.project_type}` : 'mod'
return buildBrowseHref(`/browse/${type}`)
})
const projectBackLabel = computed(() =>
typeof route.query.i === 'string' && typeof route.query.b !== 'string'
? formatMessage(messages.backToInstance)
: formatMessage(messages.backToBrowse),
)
const projectInstallContext = computed(() => {
const serverData = serverInstallContent.serverContextServerData.value
@@ -426,7 +494,7 @@ const projectInstallContext = computed(() => {
iconSrc: null,
isMedal: serverData.is_medal,
backUrl: projectBrowseBackUrl.value,
backLabel: formatMessage(messages.backToBrowse),
backLabel: projectBackLabel.value,
heading: serverInstallContent.serverBrowseHeading.value,
queuedCount: serverInstallContent.queuedServerInstallCount.value,
selectedProjects: serverInstallContent.selectedServerInstallProjects.value,
@@ -446,7 +514,7 @@ const projectInstallContext = computed(() => {
gameVersion: instance.value.game_version,
iconSrc: instance.value.icon_path ? convertFileSrc(instance.value.icon_path) : null,
backUrl: projectBrowseBackUrl.value,
backLabel: formatMessage(messages.backToBrowse),
backLabel: projectBackLabel.value,
heading: formatMessage(commonMessages.installingContentLabel),
}
}
@@ -607,6 +675,7 @@ function reportProject() {
}
async function fetchProjectData() {
projectBreadcrumbLabel.value = getProjectBreadcrumbLabel(route.params.id)
const [project, projectV3Result] = await Promise.all([
get_project(route.params.id, 'must_revalidate').catch(handleError),
get_project_v3(route.params.id, 'must_revalidate').catch(handleError),
@@ -619,6 +688,7 @@ async function fetchProjectData() {
}
data.value = project
projectBreadcrumbLabel.value = project.title
;[versions.value, members.value, categories.value, instance.value, instanceProjects.value] =
await Promise.all([
get_version_many(project.versions, 'must_revalidate').catch(handleError),
@@ -628,6 +698,14 @@ async function fetchProjectData() {
route.query.i ? getInstanceProjects(route.query.i).catch(handleError) : Promise.resolve(),
])
for (const member of members.value ?? []) {
for (const identifier of [member.user.id, member.user.username]) {
if (identifier) {
queryClient.setQueryData(['users', 'summary', identifier], member.user)
}
}
}
versions.value = versions.value.sort((a, b) => dayjs(b.date_published) - dayjs(a.date_published))
if (instanceProjects.value) {
@@ -647,8 +725,6 @@ async function fetchProjectData() {
isServerProject.value = projectV3.value?.minecraft_server != null
serverStatusOnline.value = !!projectV3.value?.minecraft_java_server?.ping?.data
breadcrumbs.setName('Project', data.value.title)
fetchDeferredServerData(project)
}
+34 -19
View File
@@ -85,6 +85,7 @@ import {
ExternalIcon,
MoreVerticalIcon,
ReportIcon,
VersionIcon,
} from '@modrinth/assets'
import {
ButtonStyled,
@@ -95,12 +96,12 @@ import {
useVIntl,
VersionPage,
} from '@modrinth/ui'
import { ref, watch } from 'vue'
import { useRoute } from 'vue-router'
import { computed, ref, shallowRef, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { SwapIcon } from '@/assets/icons'
import { get_project_many, get_version_many } from '@/helpers/cache.js'
import { useBreadcrumbs } from '@/store/breadcrumbs'
import { useBreadcrumb } from '@/providers/breadcrumbs'
const { formatMessage } = useVIntl()
@@ -119,8 +120,18 @@ const messages = defineMessages({
},
})
const breadcrumbs = useBreadcrumbs()
const route = useRoute()
const router = useRouter()
const displayedVersionRoute = shallowRef(router.currentRoute.value)
watch(
() => router.currentRoute.value,
(nextRoute) => {
if (nextRoute.name === 'Version') {
displayedVersionRoute.value = nextRoute
}
},
{ immediate: true },
)
const props = defineProps<{
project: Labrinth.Projects.v2.Project
@@ -133,9 +144,19 @@ const props = defineProps<{
}>()
const version = ref(props.versions.find((version) => version.id === route.params.version))
if (version.value) {
breadcrumbs.setName('Version', version.value.name)
}
const versionBreadcrumbLabel = computed(() => {
const versionNumber = version.value?.version_number
const versionLabel = formatMessage(commonMessages.versionLabel)
return versionNumber ? `${versionLabel} ${versionNumber}` : versionLabel
})
useBreadcrumb({
slot: 'project-version',
id: () =>
`version:${props.project.id}:${String(displayedVersionRoute.value.params.version ?? '')}`,
label: versionBreadcrumbLabel,
visual: { type: 'icon', component: VersionIcon },
to: () => displayedVersionRoute.value.fullPath,
})
const enrichment = ref<Labrinth.Projects.v2.DependencyInfo | undefined>(undefined)
const enrichmentLoading = ref(false)
@@ -202,18 +223,12 @@ async function refreshEnrichment() {
}
}
watch(
() => props.versions,
async () => {
if (route.params.version) {
version.value = props.versions.find((v) => v.id === route.params.version)
if (version.value) {
breadcrumbs.setName('Version', version.value.name)
}
await refreshEnrichment()
}
},
)
watch([() => props.versions, () => route.params.version], async () => {
if (route.params.version) {
version.value = props.versions.find((v) => v.id === route.params.version)
await refreshEnrichment()
}
})
await refreshEnrichment()
</script>
@@ -0,0 +1,21 @@
import type { InjectionKey } from 'vue'
export type UnsavedChangesController = {
hasChanges: () => boolean
getOriginal: () => Record<string, unknown>
getModified: () => Record<string, unknown>
isSaving: () => boolean
reset: () => void
save: () => void | Promise<void>
}
export type AppSettingsModalContext = {
close: () => boolean
registerUnsavedChangesController: (controller: UnsavedChangesController | null) => void
}
export const appSettingsModalContextKey: InjectionKey<AppSettingsModalContext> =
Symbol('appSettingsModalContext')
export const appSettingsModalOpenProfileKey: InjectionKey<() => void> = Symbol(
'appSettingsModalOpenProfile',
)
@@ -0,0 +1,209 @@
import { createContext } from '@modrinth/ui'
import {
type Component,
computed,
type ComputedRef,
type MaybeRefOrGetter,
shallowRef,
toValue,
watch,
} from 'vue'
import type { RouteLocationRaw } from 'vue-router'
export type BreadcrumbVisual =
| {
type: 'icon'
component: Component
}
| {
type: 'image'
src?: string | null
alt?: string
circle?: boolean
tintBy?: string | null
}
export interface BreadcrumbDefinition {
slot: string
id: MaybeRefOrGetter<string>
label: MaybeRefOrGetter<string>
to?: MaybeRefOrGetter<RouteLocationRaw | undefined>
visual?: MaybeRefOrGetter<BreadcrumbVisual | undefined>
}
export interface ResolvedBreadcrumb {
slot: string
id: string
label: string
to?: RouteLocationRaw
visual?: BreadcrumbVisual
}
export interface BreadcrumbHandle {
readonly slot: string
activate: () => void
reset: () => void
pop: () => void
}
export interface BreadcrumbManager {
readonly entries: ComputedRef<ResolvedBreadcrumb[]>
reset: (definition: BreadcrumbDefinition) => BreadcrumbHandle
push: (
definition: BreadcrumbDefinition,
options?: { parent?: BreadcrumbHandle },
) => BreadcrumbHandle
find: (slot: string) => BreadcrumbHandle | undefined
}
interface InternalBreadcrumb {
token: symbol
definition: BreadcrumbDefinition
parentToken?: symbol
parentSlot?: string
root: boolean
handle: BreadcrumbHandle
}
const [injectBreadcrumbManager, provideBreadcrumbManager] = createContext<BreadcrumbManager>(
'root',
'breadcrumbManager',
)
const [injectBreadcrumbParent, provideBreadcrumbParent] = createContext<BreadcrumbHandle>(
'BreadcrumbParent',
'breadcrumbParent',
)
export { injectBreadcrumbManager, provideBreadcrumbManager, provideBreadcrumbParent }
export function createBreadcrumbManager(): BreadcrumbManager {
const stack = shallowRef<InternalBreadcrumb[]>([])
function findIndex(entry: InternalBreadcrumb): number {
const tokenIndex = stack.value.findIndex((candidate) => candidate.token === entry.token)
if (tokenIndex !== -1) return tokenIndex
return stack.value.findIndex((candidate) => candidate.definition.slot === entry.definition.slot)
}
function findParentIndex(entry: InternalBreadcrumb): number {
if (entry.parentToken) {
const tokenIndex = stack.value.findIndex((candidate) => candidate.token === entry.parentToken)
if (tokenIndex !== -1) return tokenIndex
}
if (entry.parentSlot) {
return stack.value.findIndex((candidate) => candidate.definition.slot === entry.parentSlot)
}
return stack.value.length - 1
}
function activate(entry: InternalBreadcrumb) {
if (entry.root) {
stack.value = [entry]
return
}
const existingIndex = findIndex(entry)
if (existingIndex !== -1) {
stack.value = [...stack.value.slice(0, existingIndex), entry]
return
}
const parentIndex = findParentIndex(entry)
stack.value = [...stack.value.slice(0, parentIndex + 1), entry]
}
function resetTo(entry: InternalBreadcrumb) {
stack.value = [entry]
}
function pop(entry: InternalBreadcrumb) {
const index = stack.value.findIndex((candidate) => candidate.token === entry.token)
if (index !== -1) {
stack.value = stack.value.slice(0, index)
}
}
function createHandle(
definition: BreadcrumbDefinition,
options: { parent?: BreadcrumbHandle; root: boolean },
): BreadcrumbHandle {
const entry = {} as InternalBreadcrumb
const handle: BreadcrumbHandle = {
slot: definition.slot,
activate: () => activate(entry),
reset: () => resetTo(entry),
pop: () => pop(entry),
}
Object.assign(entry, {
token: Symbol(definition.slot),
definition,
parentToken: options.parent
? stack.value.find((candidate) => candidate.handle === options.parent)?.token
: undefined,
parentSlot: options.parent?.slot,
root: options.root,
handle,
})
return handle
}
function reset(definition: BreadcrumbDefinition): BreadcrumbHandle {
const handle = createHandle(definition, { root: true })
handle.reset()
return handle
}
function push(
definition: BreadcrumbDefinition,
options: { parent?: BreadcrumbHandle } = {},
): BreadcrumbHandle {
const handle = createHandle(definition, { ...options, root: false })
handle.activate()
return handle
}
const entries = computed<ResolvedBreadcrumb[]>(() =>
stack.value.map(({ definition }) => ({
slot: definition.slot,
id: toValue(definition.id),
label: toValue(definition.label),
to: definition.to === undefined ? undefined : toValue(definition.to),
visual: definition.visual === undefined ? undefined : toValue(definition.visual),
})),
)
return {
entries,
reset,
push,
find: (slot) => stack.value.find((entry) => entry.definition.slot === slot)?.handle,
}
}
function watchBreadcrumbIdentity(definition: BreadcrumbDefinition, handle: BreadcrumbHandle) {
watch(
() => toValue(definition.id),
() => handle.activate(),
{ flush: 'sync' },
)
}
export function useRootBreadcrumb(definition: BreadcrumbDefinition): BreadcrumbHandle {
const manager = injectBreadcrumbManager()
const handle = manager.reset(definition)
watchBreadcrumbIdentity(definition, handle)
return handle
}
export function useBreadcrumb(
definition: BreadcrumbDefinition,
options: { parent?: BreadcrumbHandle } = {},
): BreadcrumbHandle {
const manager = injectBreadcrumbManager()
const parent = options.parent ?? injectBreadcrumbParent(null) ?? undefined
const handle = manager.push(definition, { parent })
watchBreadcrumbIdentity(definition, handle)
return handle
}
@@ -1,6 +1,11 @@
import type { Labrinth } from '@modrinth/api-client'
import type { ContentInstallInstance, ContentInstallProjectInfo, ContentItem } from '@modrinth/ui'
import { createContext, defineMessage, useVIntl } from '@modrinth/ui'
import {
createContext,
defineMessage,
getLatestMatchingInstallVersion,
useVIntl,
} from '@modrinth/ui'
import { convertFileSrc } from '@tauri-apps/api/core'
import { openUrl } from '@tauri-apps/plugin-opener'
import dayjs from 'dayjs'
@@ -813,7 +818,22 @@ export function createContentInstall(opts: {
}
if (project.project_type === 'modpack') {
const version = versionId ?? project.versions[project.versions.length - 1]
let version = versionId ?? null
if (!version) {
const hasHints = !!(hints?.preferredGameVersion || hints?.preferredLoader)
if (hasHints) {
const versions = (await get_version_many(
project.versions,
'must_revalidate',
)) as Labrinth.Versions.v2.Version[]
const matching = getLatestMatchingInstallVersion(versions, {
gameVersions: hints?.preferredGameVersion ? [hints.preferredGameVersion] : undefined,
loaders: hints?.preferredLoader ? [hints.preferredLoader] : undefined,
})
version = matching?.id ?? null
}
version ??= project.versions[project.versions.length - 1]
}
const packs = await list()
const existingPack = packs.find((pack) => pack.link?.project_id === project.id)
@@ -6,7 +6,7 @@ import {
type AuthUser,
provideAuth,
} from '@modrinth/ui'
import { computed, type Ref, ref, watchEffect } from 'vue'
import { computed, type Ref, ref, watch, watchEffect } from 'vue'
type AppCredentials = {
session?: string | null
@@ -37,5 +37,13 @@ export function setupAuthProvider(
user.value = credentials.value?.user ?? null
})
watch(user, (updatedUser) => {
if (!credentials.value || !updatedUser || credentials.value.user === updatedUser) return
credentials.value = {
...credentials.value,
user: updatedUser,
}
})
provideAuth(authProvider)
}

Some files were not shown because too many files have changed in this diff Show More