Compare commits

...
Author SHA1 Message Date
Calum H.andGitHub 5e155d69b6 feat: changelog app 0.16.0 (#6858) 2026-07-24 18:59:23 +02:00
Calum H.andGitHub 707722ded6 fix: better feedback for disabled by provider mural error (#6854)
* fix: better feedback for disabled by provider mural error

* fix: lint
2026-07-24 16:50:46 +00:00
Truman GaoandGitHub dfc2ed3783 fix: handling failures/invalid popup edge cases to show normal gdpr popup fallback (#6857)
* fix: remove drilling actions down iframes

* refactor: split up custom popup logic, trim it down a lot

* fix: bring back TCF useractioncomplete and USP/GPP sectionChange event listeners, and only using dom observer as the fallback

* remove open devtools

* format

* harden CPM fallbacks for unknown layouts

* consolidate timeouts

* refactor: rename functions to make sense

* fix: sidebar closed on launch does not properly set state
2026-07-24 16:48:52 +00:00
Calum H.andGitHub fbe70c6938 fix: pre-release shared instances fixes (#6856)
* fix: dedupe notifications

* fix: notifs + members btn robustness

* fix: temp fix for external file wrongly showing updated

* fix: fmt
2026-07-24 16:47:08 +00:00
Calum H.andGitHub cd56b98975 fix: broken link highlighting in changelog section (#6853)
* fix: broken link highlighting in changelog section

* fix: hide switch vers for external files
2026-07-24 15:54:30 +00:00
Calum H.andGitHub b06b14e0fb fix: tooltip issues in content tab for server panel (#6852) 2026-07-24 15:53:37 +00:00
aecsocketandGitHub a6d9bb7b3e feat: allow bulk deleting attribution groups for moderators (#6839)
* feat: allow bulk deleting attribution groups for moderators

* prepr
2026-07-24 13:42:36 +00:00
e58af98f21 feat: instance sharing thru shared-instances service (#6569)
* feat: implement instance share page + search_users backend call

* feat: invite players modal

* feat: use tanstack queries for friends sync across app pages

* feat: base shared instances implementation

* fix: admon style

* feat: impl instance admonitions like server panel

* fix: impl get + del usage

* feat: support modpack links

* feat: invite notif accepting

* fix: lint + fmt

* feat: impl install to play

* feat: impl usage of UpdateToPlayModal

* feat: warnings on deleting/disabling shared-instance version content

* fix: send instance name

* feat: align with backend

* feat: shared instances qa

* feat: wrong account protection

* feat: qa

* fix: smartly apply updates

* fix: install bug

* fix: 401/404 differentiation

* fix: fmt+prepr

* feat: qa

* feat: qa

* fix: signing out messes up revoke/deleted checks

* feat: qa

* fix: fmt + lint

* feat: lock content if part of shared instance

* fix: lint

* [do not merge] feat: rough invite links impl temp (#6666)

* fix: wrong cmd

* feat: invite page

* fix: server-manager DTO mismatch

* fix: drop anonymous invite link acceptance

* refactor: structured shared-instance unavailable errors

* refactor: centralise error presentations

* refactor: dedupe shared instance diff detection

* fix: logging in reqwests

* refactor: move app.vue shared instances into handler

* refactor: break up Share.vue

* refactor: split up shared instances state outside of instance index

* refactor: dedicated shared instances install/update modals + split up page

* refactor: centralized managed content

* refactor: split up install shared to own runner + shared.rs split up

* refactor: dedupe sql for instance metadata enrichmnt

* refactor: friends composable + dedupe friends logic across usages

* chore: reduced unused code

* fix: align with backend

* fix: lint

* fix: file sha changes

* fix: invite links not working due to icon signed

* feat: qa

* feat: reporting frontend dummy

* fix: try use header

* remove: file hash field

* fix: pin box

* feat: malware warning for shared instances

* fix: cache rule

* feat: config files syncing

* feat: disable config sharing

* fix: header

* fix: use mark ready

* fix: dont cause push update for configs

* fix: lint

* feat: sharing page in settings

* feat: move config + change flow

* fix: qa

* fix: lint prepr

* feat: proxy file upload thru shared instances backend

* fix: use collapisible

* fix: push config

* fix: config

* feat: swap out sign in modal for new one

* fix: report flow

* fix: exclude configs.zip from external warnings

* fix: nuxi init

* fix: config bundle downloading

* fix: error notif

* fix: polling

* fix: qa

* fix: lint + prepr

* feat: shared instances moderation frontend + hook up report flow

* fix: report copy

* fix: lint

* fix: lint

* fix: modrinth ids being undefined

* feat: instance quarantining

* fix: prepr + fmt

* fix: quarantined -> locked terminology

* fix: missing endpoint impls + fmt

* fix: missing api in build.rs

* fix: share tab jittery

* fix: fmt

*PT bug

* fix: invites count as users even if pending

* fix: prepr

* fix: invite page owner in users list

* fix: lint

* fix: qa

* fix: lint

* fix: members stale not clearing

* fix: invite use joined_at field

* fix: lint

* fix: qa

---------

Co-authored-by: sychic <47618543+Sychic@users.noreply.github.com>
2026-07-24 13:06:38 +00:00
Prospector 2e0d797bb0 changelog 2026-07-23 23:01:44 -07:00
Michael H. 508b96c27f chore: skip neverbounce for oauth 2026-07-24 02:15:16 +02:00
Calum H.andGitHub 0512e0b62b fix: make search use v3 everywhere (#6840) 2026-07-23 22:16:23 +00:00
Calum H.andGitHub 5c697f675c fix: various content_row fixes (#6823)
* fix: preserve instance file identity during upsert

* fix: preserve present files when hashing fails

* fix: serialize per-instance content mutations

* fix: prevent duplicate content entries

* fix: make file and content entry writes atomic

* fix: invalidate cached hashes for modified files

* feat: content lock

* fix: boris rev
2026-07-23 20:36:21 +00:00
SychicandGitHub 95f97a44ec feat(labrinth): shared instances moderation (#6784)
* feat(labrinth): shared instance report type

* style(labrinth): cargo fmt

* feat(labrinth): check that instance exists when reporting

* style(labrinth): cleanup unused import

* feat(labrinth): store version id for shared instance reports

* style(labrinth): satisfy clippy

* fix(labrinth): use string id instead of int
2026-07-23 20:20:25 +00:00
Michael H. 054f1b42fd build: disable incremental compilation for sccache compat 2026-07-23 21:42:44 +02:00
François-Xavier TalbotandGitHub b4d681e713 feat(labrinth): Redis Cluster (#6771)
* chore(labrinth): bump to redis 1.4.1

* feat(labrinth): redis cluster

* chore: cleanup

* feat(labrinth): cache locking

* fix(labrinth): clippy

* chore(labrinth): cleanup env, remove postcard support

* chore(ci): fix test env for labrinth

* chore(labrinth): bump all key versions

* chore(labrinth): improve redis key identities handling

* chore(labrinth): simplify deadline handling

* chore(labrinth): remove unused lease tracking

* chore(labrinth): remove distributed cache locking for now

* chore(labrinth): improve redis backend init error

* feat(labrinth): expose redis read replica strategy

* chore(ci): remove other connection mode tests

* chore: split xredis crate

* feat(xredis): primaries routing

* chore: tombi fmt

* chore: clippy

* chore: update query cache
2026-07-23 11:35:02 +02:00
Prospector 11af2651ef changelog 2026-07-22 14:34:31 -07:00
dce20777ef Chyz/improve preview workers (#6802)
* fix: branch url in comment

* fix: use sub not small

* de-hardcode siteUrl

* simplify request hook

* nuxt server plugin?

* pull directly from worker + 2 tables

* debug

* undebug

* single preview worker?

* use pr number instead of name because pr names tend to be long + can be changed

* prepr

---------

Co-authored-by: Calum H. (IMB11) <contact@cal.engineer>
Co-authored-by: Prospector <6166773+Prospector@users.noreply.github.com>
2026-07-22 20:58:14 +00:00
ProspectorandGitHub 56ff97ee6f feat: add license info to app + prefetch license details (#6831)
* update project sidebar details to use common component + prefetch license

* prepr

* fix updated date needing to fetch versions
2026-07-22 13:56:16 -07:00
jadeandGitHub d3839ff953 feat(moderation): autofocus add to existing entry input (#6832) 2026-07-22 20:32:56 +00:00
jadeandGitHub d2fb7114f6 feat(moderation): make clear groups button red on hover (#6829)
* feat(moderation): add confirm modal for project group deletion

* make clear groups button red on hover
2026-07-22 19:06:34 +00:00
jadeandGitHub 6c3208a7a5 feat(moderation): add confirm modal for project group deletion (#6828) 2026-07-22 18:54:19 +00:00
ProspectorandGitHub 46cbb3be0c fix: give error notification bodies a max height (#6826) 2026-07-22 11:54:51 -07:00
François-Xavier TalbotandGitHub 96e2d95d02 feat(labrinth): route to update subscriptions for archon (#6811)
* feat(labrinth): add /_internal/billing/subscriptions/manager/update_many

* chore: fixes

* chore: clippy

* chore: fixes
2026-07-22 16:18:08 +00:00
aecsocketandGitHub 07fd4fae5f fix: make Redis key TTL variable (#6821) 2026-07-22 14:43:39 +00:00
Calum H.andGitHub 3ca8a911da feat: changelog (#6818) 2026-07-22 12:46:49 +02:00
Calum H.andGitHub 56eeb242de fix: page header merge conflict (#6816) 2026-07-22 12:45:02 +02:00
Prospector 68f4b5d86a changelog 2026-07-21 16:27:11 -07:00
ProspectorandGitHub 8f387aa9ea fix: bump skin render version to fix bad cache (#6809)
bump skin render version to fix bad cache
2026-07-21 16:24:15 -07:00
ThatGravyBoatandGitHub 706d4c1a1e fix: license nag showing for datapacks (#6807) 2026-07-21 23:22:54 +00:00
Truman GaoandGitHub 39fb75ebf7 fix: ads showing when sidebar is disabled on startup (#6806) 2026-07-21 22:27:27 +00:00
Prospector 6c86bb933b changelog 2026-07-21 13:11:22 -07:00
Calum H.andGitHub 4bb20e560f fix: skin uv inset (#6803) 2026-07-21 13:10:15 -07:00
mfishmaandGitHub 5302e3a987 Fix deep link installation for mods containing + (plus sign) (#6464)
* ffix: preserve literal '+' in deep link URI thru API call

* chore: check for missing projects during installs

* chore: remove accidental test files and quote error ID

* fix rustfmt formatting for the change
2026-07-21 19:32:13 +00:00
Prospector 9b4c84ddd8 changelog 2026-07-21 12:37:00 -07:00
ProspectorandGitHub 528a9b76bc fix: devtools opening on ads init (#6800) 2026-07-21 12:36:37 -07:00
d322a2b0c8 reduce storybook memory usage and increase build memory (#6798)
reduce memory usage and increase build memory

Co-authored-by: Prospector <6166773+Prospector@users.noreply.github.com>
2026-07-21 19:10:46 +00:00
7ac1d17571 feat: minecraft required modal (#6797)
* feat: minecraft required modal

* pnpm prepr

* refactor: move steve to /assets and turn it to webp

* refactor: rename to steve look up left

---------

Co-authored-by: Prospector <6166773+Prospector@users.noreply.github.com>
2026-07-21 19:09:34 +00:00
Prospector 074699be45 changelog 2026-07-21 11:59:14 -07:00
Calum H.andGitHub 7b2e5deb83 fix: header conflict (#6799)
* fix: header conflict

* fix: lint
2026-07-21 11:58:54 -07:00
Prospector d0bdeb1bc6 changelog 2026-07-21 11:44:19 -07:00
Prospector 832240a45f prepr 2026-07-21 11:42:30 -07:00
fc1aa28aa4 temporary fix for misused slug (#6794)
Co-authored-by: Prospector <6166773+Prospector@users.noreply.github.com>
2026-07-21 11:36:25 -07:00
ArthurandGitHub 84b4f8e474 chore(frontend): i18n pass on mostly-translated frontend components (#6786)
* i18n pass on mostly-translated frontend components

* Run prepr

* Fix SRV-records text and unescape tooltip braces

* Fix changed wording

* Remove unneeded strings

* Use ICU-safe placeholder
2026-07-21 11:36:06 -07:00
Truman GaoandGitHub b14fad4237 fix: analytics tooltip not pinned after mobile chart drag (#6724) 2026-07-21 11:35:49 -07:00
Calum H.andGitHub ff1f86a0f4 feat: project page header when visiting from browse (#6630)
* feat: project page header when visiting from browse

* fix: lint
2026-07-21 11:34:27 -07:00
Truman GaoandGitHub e8f8722b70 fix: download modal spacing (#6727) 2026-07-21 11:33:39 -07:00
NitrrineandGitHub 518bcd414a fix: add margin to devinfo section (#6749) 2026-07-21 11:33:23 -07:00
François-Xavier TalbotandGitHub 79013c21c2 feat(app): handle rate limits (#6790)
* feat(app): handle rate limits + governor rate limits

* feat(app): add hysteresis to rate limiter

* fix(ci): make sure sccache directory exists if mount fail

* fix(ci): also apply sccache mount fix to daedalus-docker

* fix(ci): sudo
2026-07-21 11:33:01 -07:00
2e4a050151 fix: popup styles and jankiness (#6787)
* feat: greatly improve the jankiness

* fix: US variant of consent popup

* fix: US cmp in website and styles for US cmp

* remove incognito

* fix: flashing box in top left

---------

Co-authored-by: Prospector <6166773+Prospector@users.noreply.github.com>
2026-07-21 11:32:47 -07:00
Calum H.andGitHub f780736d89 feat: add missing ears mod features (#6788) 2026-07-21 17:19:52 +00:00
916daa684d fix: add margin bottom to navstack (#6755)
fix/add-margin-to-navstack

Co-authored-by: Prospector <6166773+Prospector@users.noreply.github.com>
2026-07-20 21:23:37 +00:00
8ece0bd31c feat: organization projects page refresh (#6723)
* feat: update org projects page

* feat: refresh the transfer projects to org modal

* fix: allow full checkbox cell to be clicked

* feat: update org link to go to org page

* prepr

---------

Co-authored-by: Prospector <6166773+Prospector@users.noreply.github.com>
2026-07-20 20:55:36 +00:00
e2085c43c5 Feat: Many moderation checklist changes (#6730)
* Rule 1.x, 3.2 and 3.3x support

* reorganize

* Kinda Jank dynamic list stuff that should probably be implemented better but its 1:30 am

* Checklist Stage Title

* Make Stage Title Good

* Rule 1.x, 3.2 and 3.3x support

* reorganize

* Kinda Jank dynamic list stuff that should probably be implemented better but its 1:30 am

* Checklist Stage Title

* Make Stage Title Good

* oops i forgot 3.1

* Insufficient description improvement test

* "i forgor" checklist tooltips tooltips (kinda jank but they work)
update to latest

* only use indexedDb for moderation storage, localStorage forces a bunch of cringe

* store moderation stuff separately + compact checklist stuff

* give stages actual titles + rename what was previously called titles to hints

* Title & Slug, Summary Description, Links, License, Gallery, Versions and Reupload migrated to new system™️

* put stageoptions before nodes

* do state and variablse in tooltips

* checklist fully migrated to new system, not much new has been added (yet)

* forgot to only do modpack permissions for modpacks

* Idk why I wasn't just mutating the builder

* actual node type hierarchy + action abstraction + improve message/text handling

* dropdown + "functional" quick fixes

* dropdown + "functional" quick fixes

* Allow moderators to suggest correct environment.

* remove vestigial function

* Prioritization™️

* quickest fixes in the west

* start of metadata stage + optimize checklist parsing + a couple of qol features

* futureproof™️ environment metadata + just give me project context everywhere

* tidy up after today's refactoring

* tidy up after tidying up after today's refactoring

* move loaders to metadata (from versions)

* better required display + vueify + reorganize messages + tag quick fixes + todo

* fix log spam + concat md

* more bugs

* nested state access

* update description stage, add showcase clarity

* Fix links stage mostly, and tweak metadata stage.

* fix links lag + actions on stages

* setup links bonus buttons

* Change stage order

* Update rules stage

* Move conditional stages to the top where they belong.

* fix some metadata stuff

* fix stage selector

* don't show non-english buttons on servers without english support.

* prepr

* stop linter from trying to break my markdown

* fix: some issues

* fix: lint

* not sure why but link's links weren't links

* fix quick fixes (again) (idk how they keep breaking)

* make messages only actually count as messages if they actually have content

* rename horror button to spoilers button

* Update links name to match the links settings

* re-review stage

* unbreak stage selector

* passing off version stage fixing.

* actually fix the stage selector this time

* fix showcase clarity msg

* redirect when checklist updates slug
fix initial stage not being first if first stage is only shown under certain conditions
move to a consistent naming scheme

* redirect when checklist updates slug
fix initial stage not being first if first stage is only shown under certain conditions
move to a consistent naming scheme

* ok maybe lets use kebab

* move custom description advice stage to first button.

* temp priority on corrections applied

* make metadata page go to non setting versions page

* links stage bonus buttons, src required flag, show link value in msgs, fix msg groupings.

* update re-review button

* reupload stage navigates to project Description.

* fix: link stage using page context too late

* chore: run prepr

* fix(ci): tell typo check to ignore moderation utils

* fix: pr comment if storybook fails

* make complex nodes less complex (for the most part)
fix re-review stage shown condition
new fancy message collection system
all buttons that contribute to messages now have relevant tooltips
don't store default values in checklist db

* make complex nodes less complex (for the most part)
fix re-review stage shown condition
new fancy message collection system
all buttons that contribute to messages now have relevant tooltips
don't store default values in checklist db

* maybe fixed markdown escaping?

* tweak re-review wording.

* Update license stage handling and info

* add mixed option back for env info

* fix: formatEnvironments
fix: formatEnvironments

* update metadata environment group shown

* fix: status alert priority

* fix: display suggested slug in slug/misused message

* actually navigate when we change project slug

* ok like redirect but like don't lose state when slug changes :smart:

* make checklist reset button reset only current stage if there's modifications to it instead of full checklist

* fix versions stage messages not working

* fix donation links not being in a column

* I think slug stuff is working?
also like actually 100% garunteed markdown escaping works now

* prepr

* pseudo stages no longer interact with non-pseudo stages at all
also query projects better I think?
fix repetitive message spam in links stage

* apply quick fixes before refreshing page

* differentiate reset and return to start

* remove random empty lines in some situations

* better inaccurate slug message

* slug correction input reset button resets to current slug again instead of auto

* better keybind navigation

* incorrect environment message tweak

* use environment name(s) in checklist info

* prepr

---------

Signed-off-by: Prospector <6166773+Prospector@users.noreply.github.com>
Co-authored-by: chyzman <chyzalt@gmail.com>
Co-authored-by: Calum H. (IMB11) <contact@cal.engineer>
Co-authored-by: Gravy Boat <gravy@thatgravyboat.tech>
Co-authored-by: Michael H. <michael@iptables.sh>
Co-authored-by: chyz <32403637+chyzman@users.noreply.github.com>
Co-authored-by: Prospector <6166773+Prospector@users.noreply.github.com>
2026-07-20 20:52:46 +00:00
30e23e5cc2 feat: add button to clear all groups (#6729)
* setup

* setup

* feat: write up clear all button

* fix: properly invalidate query and give success notification

* prepr

---------

Co-authored-by: Gravy Boat <gravy@thatgravyboat.tech>
2026-07-20 20:42:10 +00:00
Truman GaoandGitHub 8e44f20eec feat: implement custom notification and popup components (#6768)
* feat: implement custom popup

* feat: add privacy setting

* style fixes

* more style fixes

* feat: implement notification panel changes and better popup in website

* feat: update popup button

* fix: remove unnecessary modal props

* fixes

* pnpm prepr

* remove devtools opening

* update query to find buttons

* update copy

* update copy

* fix: manage permissions depends on showAds booleans instead of managemetn availability
2026-07-20 20:31:10 +00:00
932858b5f5 refactor: introduce PageHeader component (#6629)
* refactor: introduce PageHeader component

* feat: split up according to component structure guide

* fix: lint

* fix: label inconsistencies

* feat: refactor PageHeader (again)

* refactor: old impls

* fix: changes

* fix: rescan

* fix: prepr

* fix: dedupe

* fix: rev

* fix: lint

* fix: lint

---------

Co-authored-by: Prospector <6166773+Prospector@users.noreply.github.com>
2026-07-20 18:25:12 +00:00
Calum H.andGitHub 8b6f0ec695 feat: add Ears mod support to skin selector (#6775)
* feat: add Ears mod support to skin selector

* fix: lint

* feat: ears mod notice + toggle + fix uv issues

* fix: lint
2026-07-20 18:01:04 +00:00
aecsocketandGitHub ee2ad5f05b feat: block disposable email addresses (#6783)
* feat: block disposable email addresses

* fix typos

* remove test
2026-07-20 17:57:36 +00:00
Modrinth BotandGitHub 9bfa7c7e4a New translations from Crowdin (main) (#6780) 2026-07-20 12:33:30 +00:00
Michael H.andGitHub e52ae416a8 feat: email blacklist (#6779)
* feat: email blacklist

* feat: wildcard support
2026-07-20 14:02:55 +02:00
François-Xavier TalbotandGitHub 6319832465 feat: email bounce check (#6774)
* feat: neverbounce API client

* feat(labrinth): integrate neverbounce

* chore: tombi

* chore: typo

* chore: clippy

* chore: remove unused dep

* chore: cleanup
2026-07-20 10:58:46 +02:00
François-Xavier TalbotandGitHub 7144f08f0f feat: require verified email to publish, to prevent spam (#6777)
* feat: require verified email to create projects, collections and organizations

* fix(labrinth): fix tests

* chore: fmt

* chore: intl
2026-07-20 02:51:16 +00:00
François-Xavier TalbotandGitHub c579925f03 feat: honeypot signup field (#6776)
feat: account consent
2026-07-19 21:54:23 +00:00
François-Xavier TalbotandGitHub 6b569e4ee4 redis compression (#6769)
* feat(labrinth): encode/decode redis values with postcard, add version in namespaces

* chore(labrinth): split cache ex special cases without version

* chore(labrinth): simplify get_many

* fix: tombi

* feat(labrinth): compression support

* chore(labrinth): cleanup redis ser/de

* fix(labrinth): use get_deserialized in tests
2026-07-19 01:25:00 +02:00
François-Xavier TalbotandGitHub 6b62e3b480 fix(labrinth): register debug routes (#6766) 2026-07-19 01:24:52 +02:00
825 changed files with 58131 additions and 13667 deletions
+1
View File
@@ -6,6 +6,7 @@ on:
env:
CARGO_TERM_COLOR: always
CARGO_INCREMENTAL: 0
SQLX_OFFLINE: true
jobs:
+4
View File
@@ -101,6 +101,10 @@ jobs:
~/.cargo/bin
key: ${{ runner.os }}-${{ runner.arch }}-cargo-${{ hashFiles('**/Cargo.lock') }}
- name: Ensure sccache directory exists
if: needs.skip-if-clean.outputs.internal == 'true'
run: sudo mkdir -p /mnt/sccache && sudo chown $(id -u):$(id -g) /mnt/sccache
- name: Mount sccache disk cache
if: needs.skip-if-clean.outputs.internal == 'true'
uses: useblacksmith/stickydisk@13af8883542ca949a717e70fef89d15edbb29d88 # v1.2.0
+75 -22
View File
@@ -16,10 +16,10 @@ on:
workflow_dispatch:
workflow_call:
inputs:
environment:
required: true
type: string
description: 'The environment to deploy to (staging-preview or production-preview)'
preview:
required: false
type: boolean
description: 'Whether this is a preview deployment'
head_sha:
required: false
type: string
@@ -28,9 +28,13 @@ on:
required: false
type: string
description: 'The branch name to deploy (defaults to github.ref_name)'
branch_alias:
required: false
type: string
description: 'The persistent preview alias for the branch'
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}-${{ inputs.environment || 'push' }}
group: ${{ github.workflow }}-${{ github.ref }}-${{ inputs.preview && 'preview' || 'push' }}
cancel-in-progress: ${{ github.ref != 'refs/heads/main' && github.ref != 'refs/heads/prod' }}
jobs:
@@ -49,23 +53,31 @@ jobs:
- name: Configure environment
id: meta
env:
ENV_INPUT: ${{ inputs.environment }}
PREVIEW_INPUT: ${{ inputs.preview }}
REF: ${{ github.ref }}
DEPLOY_SHA: ${{ inputs.head_sha || github.sha }}
BRANCH_ALIAS: ${{ inputs.branch_alias }}
run: |
echo "cmd=deploy" >> $GITHUB_OUTPUT
SHA_SHORT="${DEPLOY_SHA::8}"
if [ "$ENV_INPUT" = "staging-preview" ]; then
echo "env=staging" >> $GITHUB_OUTPUT
echo "url=https://git-$SHA_SHORT-frontend-staging.modrinth.workers.dev" >> $GITHUB_OUTPUT
echo "cmd=versions upload --preview-alias git-$SHA_SHORT --var PREVIEW:true" >> $GITHUB_OUTPUT
elif [ "$ENV_INPUT" = "production-preview" ]; then
if [ "$PREVIEW_INPUT" = "true" ]; then
URL="https://git-$SHA_SHORT-frontend.modrinth.workers.dev"
STAGING_URL="https://git-$SHA_SHORT-staging-frontend.modrinth.workers.dev"
echo "env=production" >> $GITHUB_OUTPUT
echo "url=https://git-$SHA_SHORT-frontend.modrinth.workers.dev" >> $GITHUB_OUTPUT
echo "cmd=versions upload --preview-alias git-$SHA_SHORT --var PREVIEW:true" >> $GITHUB_OUTPUT
echo "url=$URL" >> $GITHUB_OUTPUT
echo "staging_url=$STAGING_URL" >> $GITHUB_OUTPUT
echo "cmd=versions upload --preview-alias git-$SHA_SHORT --var PREVIEW:true --var CF_PAGES_URL:$URL" >> $GITHUB_OUTPUT
STAGING_VARS=$(jq -r '.env.staging.vars | to_entries[] | "--var \(.key):\(.value)"' ./apps/frontend/wrangler.jsonc | tr '\n' ' ')
echo "staging_cmd=versions upload --preview-alias git-$SHA_SHORT-staging ${STAGING_VARS}--var PREVIEW:true --var CF_PAGES_URL:$STAGING_URL" >> $GITHUB_OUTPUT
if [ -n "$BRANCH_ALIAS" ]; then
echo "branch_alias_url=https://$BRANCH_ALIAS-frontend.modrinth.workers.dev" >> $GITHUB_OUTPUT
echo "staging_branch_alias_url=https://$BRANCH_ALIAS-staging-frontend.modrinth.workers.dev" >> $GITHUB_OUTPUT
echo "staging_branch_alias_cmd=versions upload --preview-alias $BRANCH_ALIAS-staging ${STAGING_VARS}--var PREVIEW:true --var CF_PAGES_URL:https://$BRANCH_ALIAS-staging-frontend.modrinth.workers.dev" >> $GITHUB_OUTPUT
fi
elif [ "$REF" = "refs/heads/main" ]; then
echo "env=staging" >> $GITHUB_OUTPUT
@@ -120,7 +132,7 @@ jobs:
CF_PAGES_COMMIT_SHA: ${{ inputs.head_sha || github.sha }}
CF_PAGES_URL: ${{ steps.meta.outputs.url }}
BUILD_ENV: ${{ steps.meta.outputs.env }}
PREVIEW: ${{ inputs.environment != '' && 'true' || 'false' }}
PREVIEW: ${{ inputs.preview && 'true' || 'false' }}
SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }}
- name: Create Sentry release and upload sourcemaps
@@ -146,6 +158,39 @@ jobs:
wranglerVersion: '4.54.0'
command: ${{ steps.meta.outputs.cmd }}
- name: Deploy staging Cloudflare Worker preview
if: ${{ inputs.preview }}
uses: cloudflare/wrangler-action@9acf94ace14e7dc412b076f2c5c20b8ce93c79cd # v3.15.0
with:
apiToken: ${{ secrets.CF_API_TOKEN }}
accountId: ${{ secrets.CF_ACCOUNT_ID }}
workingDirectory: ./apps/frontend
packageManager: pnpm
wranglerVersion: '4.54.0'
command: ${{ steps.meta.outputs.staging_cmd }}
- name: Deploy persistent Cloudflare Worker preview
if: ${{ inputs.preview && inputs.branch_alias != '' }}
uses: cloudflare/wrangler-action@9acf94ace14e7dc412b076f2c5c20b8ce93c79cd # v3.15.0
with:
apiToken: ${{ secrets.CF_API_TOKEN }}
accountId: ${{ secrets.CF_ACCOUNT_ID }}
workingDirectory: ./apps/frontend
packageManager: pnpm
wranglerVersion: '4.54.0'
command: versions upload --preview-alias ${{ inputs.branch_alias }} --var PREVIEW:true --var CF_PAGES_URL:${{ steps.meta.outputs.branch_alias_url }}
- name: Deploy persistent staging Cloudflare Worker preview
if: ${{ inputs.preview && inputs.branch_alias != '' }}
uses: cloudflare/wrangler-action@9acf94ace14e7dc412b076f2c5c20b8ce93c79cd # v3.15.0
with:
apiToken: ${{ secrets.CF_API_TOKEN }}
accountId: ${{ secrets.CF_ACCOUNT_ID }}
workingDirectory: ./apps/frontend
packageManager: pnpm
wranglerVersion: '4.54.0'
command: ${{ steps.meta.outputs.staging_branch_alias_cmd }}
- name: Purge cache
if: github.ref == 'refs/heads/prod'
run: |
@@ -155,14 +200,22 @@ jobs:
--data '{"hosts": ["modrinth.com", "www.modrinth.com", "staging.modrinth.com"]}' \
https://api.cloudflare.com/client/v4/zones/e39df17b9c4ef44cbce2646346ee6d33/purge_cache
- name: Write deployment URL to file
if: ${{ inputs.environment != '' }}
- name: Write deployment URLs to files
if: ${{ inputs.preview }}
run: |
echo "${{ steps.meta.outputs.url }}" > deployment-url-${{ inputs.environment }}.txt
echo "${{ steps.meta.outputs.url }}" > deployment-url-production.txt
echo "${{ steps.meta.outputs.staging_url }}" > deployment-url-staging.txt
- name: Upload deployment URL
if: ${{ inputs.environment != '' }}
- name: Upload production deployment URL
if: ${{ inputs.preview }}
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: deployment-url-${{ inputs.environment }}
path: deployment-url-${{ inputs.environment }}.txt
name: deployment-url-production
path: deployment-url-production.txt
- name: Upload staging deployment URL
if: ${{ inputs.preview }}
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: deployment-url-staging
path: deployment-url-staging.txt
+68 -19
View File
@@ -12,23 +12,33 @@ on:
- '.github/workflows/frontend-preview.yml'
jobs:
metadata:
if: github.repository_owner == 'modrinth' && github.event.pull_request.head.repo.full_name == github.repository
runs-on: ubuntu-latest
outputs:
branch-alias: ${{ steps.meta.outputs.branch_alias }}
steps:
- name: Configure branch alias
id: meta
run: echo "branch_alias=pr-${{ github.event.pull_request.number }}" >> $GITHUB_OUTPUT
deploy:
if: github.repository_owner == 'modrinth' && github.event.pull_request.head.repo.full_name == github.repository
needs: metadata
uses: ./.github/workflows/frontend-deploy.yml
secrets: inherit
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}-${{ matrix.environment }}
group: ${{ github.workflow }}-${{ github.ref }}-deploy
cancel-in-progress: true
strategy:
matrix:
environment: [staging-preview, production-preview]
with:
environment: ${{ matrix.environment }}
preview: true
head_sha: ${{ github.event.pull_request.head.sha }}
head_ref: ${{ github.event.pull_request.head.ref }}
branch_alias: ${{ needs.metadata.outputs.branch-alias }}
deploy-storybook:
if: github.repository_owner == 'modrinth' && github.event.pull_request.head.repo.full_name == github.repository
needs: metadata
runs-on: blacksmith-2vcpu-ubuntu-2404
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}-storybook
@@ -66,6 +76,8 @@ jobs:
- name: Build Storybook
working-directory: ./packages/ui
env:
NODE_OPTIONS: --max-old-space-size=6144
run: pnpm run build-storybook
- name: Configure short SHA
@@ -76,8 +88,6 @@ jobs:
- name: Deploy Storybook preview
uses: cloudflare/wrangler-action@9acf94ace14e7dc412b076f2c5c20b8ce93c79cd # v3.15.0
env:
NODE_OPTIONS: --max-old-space-size=6144
with:
apiToken: ${{ secrets.CF_API_TOKEN }}
accountId: ${{ secrets.CF_ACCOUNT_ID }}
@@ -86,10 +96,26 @@ jobs:
wranglerVersion: '4.54.0'
command: versions upload --preview-alias git-${{ steps.meta.outputs.sha_short }}
- name: Deploy persistent Storybook preview
uses: cloudflare/wrangler-action@9acf94ace14e7dc412b076f2c5c20b8ce93c79cd # v3.15.0
env:
NODE_OPTIONS: --max-old-space-size=6144
with:
apiToken: ${{ secrets.CF_API_TOKEN }}
accountId: ${{ secrets.CF_ACCOUNT_ID }}
workingDirectory: ./packages/ui
packageManager: pnpm
wranglerVersion: '4.54.0'
command: versions upload --preview-alias ${{ needs.metadata.outputs.branch-alias }}
comment:
if: github.repository_owner == 'modrinth' && github.event.pull_request.head.repo.full_name == github.repository
if: |
!cancelled()
&& github.repository_owner == 'modrinth'
&& github.event.pull_request.head.repo.full_name == github.repository
&& needs.deploy.result == 'success'
runs-on: ubuntu-latest
needs: [deploy, deploy-storybook]
needs: [metadata, deploy, deploy-storybook]
steps:
- name: Download deployment URLs
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
@@ -101,16 +127,29 @@ jobs:
id: urls
env:
HEAD_SHA: ${{ github.event.pull_request.head.sha }}
BRANCH_ALIAS: ${{ needs.metadata.outputs.branch-alias }}
STORYBOOK_RESULT: ${{ needs.deploy-storybook.result }}
RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
run: |
STAGING_PREVIEW_URL=$(cat deployment-url-staging-preview.txt)
PRODUCTION_PREVIEW_URL=$(cat deployment-url-production-preview.txt)
STAGING_SHA_URL=$(cat deployment-url-staging.txt)
PRODUCTION_SHA_URL=$(cat deployment-url-production.txt)
STAGING_BRANCH_URL="https://$BRANCH_ALIAS-staging-frontend.modrinth.workers.dev"
PRODUCTION_BRANCH_URL="https://$BRANCH_ALIAS-frontend.modrinth.workers.dev"
echo "Production preview URL: $PRODUCTION_PREVIEW_URL"
echo "Staging preview URL: $STAGING_PREVIEW_URL"
if [ "$STORYBOOK_RESULT" = "success" ]; then
STORYBOOK_SHA_URL="https://git-${HEAD_SHA::8}-storybook.modrinth.workers.dev"
STORYBOOK_BRANCH_URL="https://$BRANCH_ALIAS-storybook.modrinth.workers.dev"
else
STORYBOOK_SHA_URL="⚠️ Deployment $STORYBOOK_RESULT ([logs]($RUN_URL))"
STORYBOOK_BRANCH_URL="⚠️ Deployment $STORYBOOK_RESULT ([logs]($RUN_URL))"
fi
echo "staging-preview-url=$STAGING_PREVIEW_URL" >> $GITHUB_OUTPUT
echo "production-preview-url=$PRODUCTION_PREVIEW_URL" >> $GITHUB_OUTPUT
echo "storybook-preview-url=https://git-${HEAD_SHA::8}-storybook.modrinth.workers.dev" >> $GITHUB_OUTPUT
echo "staging-sha-url=$STAGING_SHA_URL" >> $GITHUB_OUTPUT
echo "production-sha-url=$PRODUCTION_SHA_URL" >> $GITHUB_OUTPUT
echo "storybook-sha-url=$STORYBOOK_SHA_URL" >> $GITHUB_OUTPUT
echo "staging-branch-url=$STAGING_BRANCH_URL" >> $GITHUB_OUTPUT
echo "production-branch-url=$PRODUCTION_BRANCH_URL" >> $GITHUB_OUTPUT
echo "storybook-branch-url=$STORYBOOK_BRANCH_URL" >> $GITHUB_OUTPUT
- name: Find comment
if: github.event_name == 'pull_request'
@@ -133,9 +172,19 @@ jobs:
Last deployed commit is [${{ github.event.pull_request.head.sha }}](${{ github.event.pull_request.head.repo.html_url }}/commit/${{ github.event.pull_request.head.sha }})
### Persistent
| Environment | URL |
|-------------|-----|
| staging | ${{ steps.urls.outputs.staging-preview-url }} |
| production | ${{ steps.urls.outputs.production-preview-url }} |
| storybook | ${{ steps.urls.outputs.storybook-preview-url }} |
| staging | ${{ steps.urls.outputs.staging-branch-url }} |
| production | ${{ steps.urls.outputs.production-branch-url }} |
| storybook | ${{ steps.urls.outputs.storybook-branch-url }} |
### Latest commit
| Environment | URL |
|-------------|-----|
| staging | ${{ steps.urls.outputs.staging-sha-url }} |
| production | ${{ steps.urls.outputs.production-sha-url }} |
| storybook | ${{ steps.urls.outputs.storybook-sha-url }} |
edit-mode: replace
+4
View File
@@ -109,6 +109,10 @@ jobs:
~/.cargo/bin
key: ${{ runner.os }}-${{ runner.arch }}-cargo-${{ hashFiles('**/Cargo.lock') }}
- name: Ensure sccache directory exists
if: needs.skip-if-clean.outputs.internal == 'true'
run: sudo mkdir -p /mnt/sccache && sudo chown $(id -u):$(id -g) /mnt/sccache
- name: Mount sccache disk cache
if: needs.skip-if-clean.outputs.internal == 'true'
uses: useblacksmith/stickydisk@13af8883542ca949a717e70fef89d15edbb29d88 # v1.2.0
+8 -1
View File
@@ -82,6 +82,13 @@ jobs:
SCCACHE_DIR: ${{ needs.skip-if-clean.outputs.internal == 'true' && '/mnt/sccache' || '' }}
SCCACHE_CACHE_SIZE: ${{ needs.skip-if-clean.outputs.internal == 'true' && '10G' || '' }}
RUSTC_WRAPPER: ${{ needs.skip-if-clean.outputs.internal == 'true' && 'sccache' || '' }}
CARGO_INCREMENTAL: 0
# Redis config for tests
REDIS_TOPOLOGY: cluster
REDIS_CONNECTION_TYPE: multiplexed
REDIS_URL: redis://127.0.0.1:7000,redis://127.0.0.1:7001,redis://127.0.0.1:7002,redis://127.0.0.1:7003,redis://127.0.0.1:7004,redis://127.0.0.1:7005
# Avoid stack overflows in tests
RUST_MIN_STACK: 16777216
steps:
- name: Check out code
@@ -185,7 +192,7 @@ jobs:
- name: Start services
if: steps.check-labrinth.outputs.needs_services == 'true'
run: docker compose up --wait
run: docker compose --profile clustered-redis up --wait
- name: Setup labrinth environment and database
if: steps.check-labrinth.outputs.needs_services == 'true'
Generated
+184 -42
View File
@@ -244,7 +244,7 @@ dependencies = [
"serde_json",
"serde_urlencoded",
"smallvec",
"socket2 0.6.1",
"socket2 0.6.5",
"time",
"tracing",
"url",
@@ -458,6 +458,12 @@ version = "1.7.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "69f7f8c3906b62b754cd5326047894316021dcfe5a194c8ea52bdd94934a3457"
[[package]]
name = "arcstr"
version = "1.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "03918c3dbd7701a85c6b9887732e2921175f26c350b4563841d0958c21d57e6d"
[[package]]
name = "arg_enum_proc_macro"
version = "0.3.4"
@@ -2133,6 +2139,15 @@ dependencies = [
"cc",
]
[[package]]
name = "cobs"
version = "0.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0fa961b519f0b462e3a3b4a34b64d119eeaca1d59af726fe450bbba07a9fc0a1"
dependencies = [
"thiserror 2.0.17",
]
[[package]]
name = "color-eyre"
version = "0.6.5"
@@ -2469,6 +2484,12 @@ dependencies = [
"spin 0.10.0",
]
[[package]]
name = "crc16"
version = "0.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "338089f42c427b86394a5ee60ff321da23a5c89c9d89514c829687b26359fcff"
[[package]]
name = "crc32fast"
version = "1.5.0"
@@ -2792,7 +2813,7 @@ checksum = "be1e0bca6c3637f992fc1cc7cbc52a78c1ef6db076dbf1059c4323d6a2048376"
[[package]]
name = "deadpool"
version = "0.12.3"
source = "git+https://github.com/modrinth/deadpool?rev=db5fb00b036ecc8fe5f18853c559b745ffe47bde#db5fb00b036ecc8fe5f18853c559b745ffe47bde"
source = "git+https://github.com/modrinth/deadpool?rev=c7209b086829572f368f1194aba54bdea8dc540b#c7209b086829572f368f1194aba54bdea8dc540b"
dependencies = [
"deadpool-runtime",
"num_cpus",
@@ -2802,8 +2823,8 @@ dependencies = [
[[package]]
name = "deadpool-redis"
version = "0.22.1"
source = "git+https://github.com/modrinth/deadpool?rev=db5fb00b036ecc8fe5f18853c559b745ffe47bde#db5fb00b036ecc8fe5f18853c559b745ffe47bde"
version = "0.23.0"
source = "git+https://github.com/modrinth/deadpool?rev=c7209b086829572f368f1194aba54bdea8dc540b#c7209b086829572f368f1194aba54bdea8dc540b"
dependencies = [
"deadpool",
"redis",
@@ -2813,7 +2834,7 @@ dependencies = [
[[package]]
name = "deadpool-runtime"
version = "0.1.5"
source = "git+https://github.com/modrinth/deadpool?rev=db5fb00b036ecc8fe5f18853c559b745ffe47bde#db5fb00b036ecc8fe5f18853c559b745ffe47bde"
source = "git+https://github.com/modrinth/deadpool?rev=c7209b086829572f368f1194aba54bdea8dc540b#c7209b086829572f368f1194aba54bdea8dc540b"
dependencies = [
"tokio",
]
@@ -3259,6 +3280,18 @@ version = "1.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4ef6b89e5b37196644d8796de5268852ff179b44e96276cf4290264843743bb7"
[[package]]
name = "embedded-io"
version = "0.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ef1a6892d9eef45c8fa6b9e0086428a2cca8491aca8f787c534a3d6d0bcb3ced"
[[package]]
name = "embedded-io"
version = "0.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "edd0f118536f44f5ccd48bcb8b111bdc3de888b58c74639dfb034a357d0f206d"
[[package]]
name = "encode_unicode"
version = "1.0.0"
@@ -3835,6 +3868,12 @@ version = "0.3.31"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f90f7dce0722e95104fcb095585910c0977252f286e354b5e3bd38902cd99988"
[[package]]
name = "futures-timer"
version = "3.0.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "af43fadb8a98512d547e37b4e92e0ced13e205c061b87b4623eff01d918d6968"
[[package]]
name = "futures-util"
version = "0.3.31"
@@ -4146,6 +4185,29 @@ dependencies = [
"system-deps",
]
[[package]]
name = "governor"
version = "0.10.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9efcab3c1958580ff1f25a2a41be1668f7603d849bb63af523b208a3cc1223b8"
dependencies = [
"cfg-if",
"dashmap",
"futures-sink",
"futures-timer",
"futures-util",
"getrandom 0.3.3",
"hashbrown 0.16.0",
"nonzero_ext",
"parking_lot",
"portable-atomic",
"quanta",
"rand 0.9.2",
"smallvec",
"spinning_top",
"web-time",
]
[[package]]
name = "group"
version = "0.13.0"
@@ -4744,7 +4806,7 @@ dependencies = [
"libc",
"percent-encoding",
"pin-project-lite",
"socket2 0.6.1",
"socket2 0.5.10",
"system-configuration",
"tokio",
"tower-service",
@@ -5085,17 +5147,6 @@ dependencies = [
"syn 2.0.106",
]
[[package]]
name = "io-uring"
version = "0.7.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4d09b98f7eace8982db770e4408e7470b028ce513ac28fecdc6bf4c30fe92b62"
dependencies = [
"bitflags 2.9.4",
"cfg-if",
"libc",
]
[[package]]
name = "ipconfig"
version = "0.3.2"
@@ -5420,7 +5471,6 @@ dependencies = [
"color-thief",
"const_format",
"dashmap",
"deadpool-redis",
"derive_more 2.1.1",
"dotenv-build",
"dotenvy",
@@ -5445,8 +5495,10 @@ dependencies = [
"modrinth-util",
"muralpay",
"murmur2",
"neverbounce",
"paste",
"path-util",
"postcard",
"prometheus",
"quick-xml 0.38.3",
"rand 0.8.5",
@@ -5487,6 +5539,7 @@ dependencies = [
"webauthn-rs-proto",
"webp",
"woothee",
"xredis",
"yaserde",
"zip 6.0.0",
"zxcvbn",
@@ -5552,7 +5605,7 @@ dependencies = [
"quoted_printable",
"rustls 0.23.32",
"rustls-native-certs 0.8.1",
"socket2 0.6.1",
"socket2 0.6.5",
"tokio",
"tokio-rustls 0.26.4",
"url",
@@ -5590,9 +5643,9 @@ checksum = "2c4a545a15244c7d945065b5d392b2d2d7f21526fba56ce51467b06ed445e8f7"
[[package]]
name = "libc"
version = "0.2.177"
version = "0.2.186"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2874a2af47a2325c2001a6e6fad9b16a53b802102b528163885171cf92b15976"
checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66"
[[package]]
name = "libfuzzer-sys"
@@ -6006,14 +6059,14 @@ dependencies = [
[[package]]
name = "mio"
version = "1.0.4"
version = "1.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "78bed444cc8a2160f01cbcf811ef18cac863ad68ae8ca62092e8db51d51c761c"
checksum = "30d65c71f1ce40ab09135ce117d742b9f8a19ff91a41a8b57ed50bc2de59c427"
dependencies = [
"libc",
"log",
"wasi 0.11.1+wasi-snapshot-preview1",
"windows-sys 0.59.0",
"windows-sys 0.61.2",
]
[[package]]
@@ -6224,6 +6277,14 @@ dependencies = [
"jni-sys",
]
[[package]]
name = "neverbounce"
version = "0.1.0"
dependencies = [
"reqwest 0.12.24",
"serde",
]
[[package]]
name = "new_debug_unreachable"
version = "1.0.6"
@@ -6268,6 +6329,12 @@ dependencies = [
"memchr",
]
[[package]]
name = "nonzero_ext"
version = "0.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "38bf9645c8b145698bb0b18a4637dcacbc421ea49bef2317e4fd8065a387cf21"
[[package]]
name = "noop_proc_macro"
version = "0.3.0"
@@ -7492,6 +7559,18 @@ version = "1.11.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f84267b20a16ea918e43c6a88433c2d54fa145c92a811b5b047ccbe153674483"
[[package]]
name = "postcard"
version = "1.1.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6764c3b5dd454e283a30e6dfe78e9b31096d9e32036b5d1eaac7a6119ccb9a24"
dependencies = [
"cobs",
"embedded-io 0.4.0",
"embedded-io 0.6.1",
"serde",
]
[[package]]
name = "potential_utf"
version = "0.1.3"
@@ -7856,6 +7935,21 @@ dependencies = [
"bytemuck",
]
[[package]]
name = "quanta"
version = "0.12.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f3ab5a9d756f0d97bdc89019bd2e4ea098cf9cde50ee7564dde6b81ccc8f06c7"
dependencies = [
"crossbeam-utils",
"libc",
"once_cell",
"raw-cpuid",
"wasi 0.11.1+wasi-snapshot-preview1",
"web-sys",
"winapi",
]
[[package]]
name = "quartz_nbt"
version = "0.2.9"
@@ -7920,7 +8014,7 @@ dependencies = [
"quinn-udp",
"rustc-hash",
"rustls 0.23.32",
"socket2 0.6.1",
"socket2 0.5.10",
"thiserror 2.0.17",
"tokio",
"tracing",
@@ -7957,7 +8051,7 @@ dependencies = [
"cfg_aliases",
"libc",
"once_cell",
"socket2 0.6.1",
"socket2 0.5.10",
"tracing",
"windows-sys 0.60.2",
]
@@ -8183,6 +8277,15 @@ dependencies = [
"rgb",
]
[[package]]
name = "raw-cpuid"
version = "11.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "498cd0dc59d73224351ee52a95fee0f1a617a2eae0e7d9d720cc622c73a54186"
dependencies = [
"bitflags 2.9.4",
]
[[package]]
name = "raw-window-handle"
version = "0.6.2"
@@ -8242,26 +8345,32 @@ dependencies = [
[[package]]
name = "redis"
version = "0.32.7"
version = "1.4.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "014cc767fefab6a3e798ca45112bccad9c6e0e218fbd49720042716c73cfef44"
checksum = "b0b9503711b03773e43b31668c7b5bd279ee7cd9b7d18cff7c23a42cc1d08e5a"
dependencies = [
"ahash 0.8.12",
"arcstr",
"async-lock",
"bytes",
"cfg-if",
"combine",
"crc16",
"futures-util",
"itoa",
"log",
"num-bigint",
"percent-encoding",
"pin-project-lite",
"r2d2",
"rand 0.10.1",
"ryu",
"sha1_smol",
"socket2 0.6.1",
"socket2 0.6.5",
"tokio",
"tokio-util",
"url",
"xxhash-rust",
]
[[package]]
@@ -9714,12 +9823,12 @@ dependencies = [
[[package]]
name = "socket2"
version = "0.6.1"
version = "0.6.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "17129e116933cf371d018bb80ae557e889637989d8638274fb25622827b03881"
checksum = "c3d1e2c7f27f8d4cb10542a02c49005dbd6e93095799d6f3be745fae9f8fedd4"
dependencies = [
"libc",
"windows-sys 0.60.2",
"windows-sys 0.61.2",
]
[[package]]
@@ -9794,6 +9903,15 @@ version = "0.10.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d5fe4ccb98d9c292d56fec89a5e07da7fc4cf0dc11e156b41793132775d3e591"
[[package]]
name = "spinning_top"
version = "0.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d96d2d1d716fb500937168cc09353ffdc7a012be8475ac7308e1bdf0e3923300"
dependencies = [
"lock_api",
]
[[package]]
name = "spki"
version = "0.7.3"
@@ -10896,8 +11014,10 @@ dependencies = [
"fs4",
"futures",
"futures-lite 2.6.1",
"governor",
"heck 0.5.0",
"hickory-resolver 0.25.2",
"httpdate",
"indicatif",
"itertools 0.14.0",
"modrinth-content-management",
@@ -11158,30 +11278,27 @@ checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20"
[[package]]
name = "tokio"
version = "1.47.1"
version = "1.53.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "89e49afdadebb872d3145a5638b59eb0691ea23e46ca484037cfab3b76b95038"
checksum = "d988bcd52dbe076d3d46903332f58c912b87a2c49b1428419a5845154762ffee"
dependencies = [
"backtrace",
"bytes",
"io-uring",
"libc",
"mio",
"parking_lot",
"pin-project-lite",
"signal-hook-registry",
"slab",
"socket2 0.6.1",
"socket2 0.6.5",
"tokio-macros",
"tracing",
"windows-sys 0.59.0",
"windows-sys 0.61.2",
]
[[package]]
name = "tokio-macros"
version = "2.5.0"
version = "2.7.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6e06d43f1345a3bcd39f6a56dbb7dcab2ba47e68e8ac134855e7e2bdbaf8cab8"
checksum = "6328af13490e73a9b4694030fafd93f8c8c6a9dede33e821c3fc63eddf8042ba"
dependencies = [
"proc-macro2",
"quote",
@@ -11385,7 +11502,7 @@ dependencies = [
"hyper-util",
"percent-encoding",
"pin-project",
"socket2 0.6.1",
"socket2 0.6.5",
"sync_wrapper",
"tokio",
"tokio-stream",
@@ -13315,6 +13432,31 @@ version = "0.13.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "66fee0b777b0f5ac1c69bb06d361268faafa61cd4682ae064a171c16c433e9e4"
[[package]]
name = "xredis"
version = "0.1.0"
dependencies = [
"ariadne",
"chrono",
"dashmap",
"deadpool-redis",
"futures",
"lz4_flex",
"prometheus",
"redis",
"serde",
"serde_json",
"thiserror 2.0.17",
"tokio",
"tracing",
]
[[package]]
name = "xxhash-rust"
version = "0.8.17"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "985eec839aaf2a1270af8f4ebcf63cf9401cfd90f0902f97c28d9f104ffbde72"
[[package]]
name = "yaserde"
version = "0.12.0"
+15 -2
View File
@@ -13,7 +13,9 @@ members = [
"packages/modrinth-log",
"packages/modrinth-maxmind",
"packages/modrinth-util",
"packages/neverbounce",
"packages/path-util",
"packages/xredis",
]
[workspace.package]
@@ -71,7 +73,7 @@ daedalus = { path = "packages/daedalus" }
darling = { version = "0.23" }
dashmap = "6.1.0"
data-url = "0.3.2"
deadpool-redis = { git = "https://github.com/modrinth/deadpool", rev = "db5fb00b036ecc8fe5f18853c559b745ffe47bde", version = "0.22.1" }
deadpool-redis = { git = "https://github.com/modrinth/deadpool", rev = "c7209b086829572f368f1194aba54bdea8dc540b", version = "0.23.0" }
derive_more = "2.1.1"
directories = "6.0.0"
dirs = "6.0.0"
@@ -88,10 +90,12 @@ fs4 = { version = "0.13.1", default-features = false }
futures = "0.3.31"
futures-lite = "2.6.1"
futures-util = "0.3.31"
governor = "0.10.4"
heck = "0.5.0"
hex = "0.4.3"
hickory-resolver = "0.25.2"
hmac = "0.12.1"
httpdate = "1.0.3"
hyper = "1.7.0"
hyper-rustls = { version = "0.27.7", default-features = false, features = [
"aws-lc-rs",
@@ -118,6 +122,12 @@ lettre = { version = "0.11.19", default-features = false, features = [
"tokio1",
"tokio1-rustls",
] }
lz4_flex = { version = "0.11.5", default-features = false, features = [
"checked-decode",
"safe-decode",
"safe-encode",
"std",
] }
maxminddb = "0.26.0"
meilisearch-sdk = { version = "0.30.0", default-features = false }
modrinth-content-management = { path = "packages/modrinth-content-management" }
@@ -126,6 +136,7 @@ modrinth-util = { path = "packages/modrinth-util" }
muralpay = { path = "packages/muralpay" }
murmur2 = "0.1.0"
native-dialog = "0.9.2"
neverbounce = { path = "packages/neverbounce" }
notify = { version = "8.2.0", default-features = false }
notify-debouncer-mini = { version = "0.7.0", default-features = false }
objc2-app-kit = { version = "0.3.2", default-features = false }
@@ -135,6 +146,7 @@ paste = "1.0.15"
path-util = { path = "packages/path-util" }
phf = { version = "0.13.1", features = ["macros"] }
png = "0.18.0"
postcard = { version = "1.1.3", default-features = false, features = ["alloc"] }
proc-macro2 = { version = "1.0" }
prometheus = "0.14.0"
quartz_nbt = "0.2.9"
@@ -143,7 +155,7 @@ quote = { version = "1.0" }
rand = "=0.8.5" # Locked on 0.8 until argon2 and p256 update to 0.9
rand_chacha = "=0.3.1" # Locked on 0.3 until we can update rand to 0.9
rdkafka = { version = "0.36.2", features = ["cmake-build"] }
redis = "0.32.7"
redis = "1.4.1"
regex = "1.12.2"
reqwest = { version = "0.12.24", default-features = false }
rgb = "0.8.52"
@@ -230,6 +242,7 @@ windows = "=0.61.3" # Locked on 0.61 until we can update windows-core to 0.62
windows-core = "=0.61.2" # Locked on 0.61 until webview2-com updates to 0.62
winreg = "0.55.0"
woothee = "0.13.0"
xredis = { path = "packages/xredis" }
yaserde = "0.12.0"
zbus = "5.11.0"
zip = { version = "6.0.0", default-features = false, features = [
+3
View File
@@ -8,8 +8,11 @@ extend-exclude = [
"packages/blog/",
# contains licenses like `CC-BY-ND-4.0`
"packages/moderation/src/data/stages/license.ts",
"packages/moderation/src/utils.ts",
# contains payment card IDs like `IY1VMST1MOXS` which are flagged
"apps/labrinth/src/queue/payouts/mod.rs",
# contains domain names with deliberate typos
"apps/labrinth/assets/disposable_email_blocklist.txt"
]
[default.extend-words]
+235 -57
View File
@@ -76,27 +76,41 @@ import FriendsList from '@/components/ui/friends/FriendsList.vue'
import AddServerToInstanceModal from '@/components/ui/install_flow/AddServerToInstanceModal.vue'
import UnknownPackWarningModal from '@/components/ui/install_flow/UnknownPackWarningModal.vue'
import MinecraftAuthErrorModal from '@/components/ui/minecraft-auth-error-modal/MinecraftAuthErrorModal.vue'
import MinecraftRequiredModal from '@/components/ui/minecraft-required-modal/MinecraftRequiredModal.vue'
import AppSettingsModal from '@/components/ui/modal/AppSettingsModal.vue'
import AuthGrantFlowWaitModal from '@/components/ui/modal/AuthGrantFlowWaitModal.vue'
import InstallToPlayModal from '@/components/ui/modal/InstallToPlayModal.vue'
import ModpackAlreadyInstalledModal from '@/components/ui/modal/ModpackAlreadyInstalledModal.vue'
import ModrinthAccountRequiredModal from '@/components/ui/modal/ModrinthAccountRequiredModal.vue'
import UpdateToPlayModal from '@/components/ui/modal/UpdateToPlayModal.vue'
import NavButton from '@/components/ui/NavButton.vue'
import PrideFundraiserBanner from '@/components/ui/PrideFundraiserBanner.vue'
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 WindowControls from '@/components/ui/WindowControls.vue'
import { useCheckDisableMouseover } from '@/composables/macCssFix.js'
import { config } from '@/config'
import { hide_ads_window, init_ads_window, show_ads_window } from '@/helpers/ads.js'
import {
ads_consent_listener,
hide_ads_window,
init_ads_window,
perform_ads_consent_action,
should_show_ads_consent_popup,
show_ads_window,
} from '@/helpers/ads.js'
import { debugAnalytics, initAnalytics, trackEvent } from '@/helpers/analytics'
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 { list, run } from '@/helpers/instance'
import { cancelLogin, get as getCreds, login, logout } from '@/helpers/mr_auth.ts'
import {
can_current_user_use_shared_instances,
get as getInstance,
list,
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'
import { get_opening_command, initialize_state } from '@/helpers/state'
@@ -147,6 +161,7 @@ const APP_SIDEBAR_WIDTH = 300
const INTERCOM_BUBBLE_DEFAULT_PADDING = 20
const PRIDE_FUNDRAISER_END_DATE = new Date('2026-07-01T00:00:00Z').getTime()
const credentials = ref()
let credentialsRefreshId = 0
const sidebarToggled = ref(true)
const unsubscribeSidebarToggle = themeStore.$subscribe(() => {
sidebarToggled.value = !themeStore.toggleSidebar
@@ -184,12 +199,15 @@ const { handleError, addNotification } = notificationManager
const popupNotificationManager = new AppPopupNotificationManager()
providePopupNotificationManager(popupNotificationManager)
const { addPopupNotification } = popupNotificationManager
let adsConsentPopupId = null
let unlistenAdsConsent
const appVersion = getVersion()
const tauriApiClient = new TauriModrinthClient({
userAgent: async () => `modrinth/theseus/${await appVersion} (support@modrinth.com)`,
labrinthBaseUrl: config.labrinthBaseUrl,
archonBaseUrl: config.archonBaseUrl,
sharedInstancesBaseUrl: config.sharedInstancesBaseUrl,
features: [
new NodeAuthFeature({
getAuth: () => nodeAuthState.getAuth?.() ?? null,
@@ -213,9 +231,30 @@ const { data: authenticatedModrinthUser } = useQuery({
enabled: () => !!credentials.value?.session,
retry: false,
})
useQuery({
queryKey: computed(() => ['shared-instance-eligibility', credentials.value?.user?.id]),
queryFn: can_current_user_use_shared_instances,
enabled: () => !!credentials.value?.session && !!credentials.value?.user?.id,
retry: false,
staleTime: Infinity,
refetchOnMount: false,
refetchOnWindowFocus: false,
refetchOnReconnect: false,
})
const hasPlus = computed(
() =>
!!credentials.value?.user &&
(hasMidasBadge(credentials.value.user) ||
hasActivePride26Midas(authenticatedModrinthUser.value?.campaigns?.pride_26)),
)
const showAd = computed(
() => sidebarVisible.value && !hasPlus.value && credentials.value !== undefined,
)
const adConsentAvailable = computed(() => credentials.value !== undefined && !hasPlus.value)
providePageContext({
hierarchicalSidebarAvailable: ref(true),
showAds: ref(false),
showAds: showAd,
adConsentAvailable,
floatingActionBarOffsets: {
left: ref(APP_LEFT_NAV_WIDTH),
right: computed(() => (sidebarVisible.value ? `${APP_SIDEBAR_WIDTH}px` : '0px')),
@@ -251,6 +290,9 @@ const {
const news = ref([])
const availableSurvey = ref(false)
const displayedServerInviteNotifications = new Set()
const serverInvitePopupNotificationIds = new Set()
let liveNotificationGeneration = 0
let liveNotificationsEnabled = true
const offline = ref(!navigator.onLine)
window.addEventListener('offline', () => {
@@ -295,6 +337,12 @@ const authUnreachable = computed(() => {
onMounted(async () => {
await useCheckDisableMouseover()
try {
unlistenAdsConsent = await ads_consent_listener(handleAdsConsentRequired)
handleAdsConsentRequired(await should_show_ads_consent_popup())
} catch (error) {
handleError(error)
}
document.querySelector('body').addEventListener('click', handleClick)
document.querySelector('body').addEventListener('auxclick', handleAuxClick)
@@ -308,6 +356,7 @@ onUnmounted(async () => {
unsubscribeSidebarToggle()
clearDelayedUpdatePopup()
await unlistenAdsConsent?.()
await unlistenUpdateDownload?.()
})
@@ -332,8 +381,77 @@ const messages = defineMessages({
defaultMessage:
'Minecraft authentication servers may be down right now. Check your internet connection and try again later.',
},
adsConsentTitle: {
id: 'app.ads-consent.title',
defaultMessage: 'Your privacy and how ads support Modrinth',
},
adsConsentBody: {
id: 'app.ads-consent.body',
defaultMessage:
'Ads make Modrinth possible and fund creator payouts. Our partners may store or access cookies in the app to personalize ads and measure performance.',
},
adsConsentManage: {
id: 'app.ads-consent.manage',
defaultMessage: 'Manage preferences',
},
adsConsentReject: {
id: 'app.ads-consent.reject',
defaultMessage: 'Reject all',
},
adsConsentAccept: {
id: 'app.ads-consent.accept',
defaultMessage: 'Accept all',
},
})
function handleAdsConsentRequired(required) {
if (!required) {
if (adsConsentPopupId !== null) {
popupNotificationManager.removeNotification(adsConsentPopupId)
adsConsentPopupId = null
}
return
}
if (
adsConsentPopupId !== null &&
popupNotificationManager.getNotifications().some((item) => item.id === adsConsentPopupId)
) {
return
}
const notification = addPopupNotification({
title: formatMessage(messages.adsConsentTitle),
text: formatMessage(messages.adsConsentBody),
type: 'info',
hideIcon: true,
autoCloseMs: null,
dismissible: false,
buttons: [
{
label: formatMessage(messages.adsConsentManage),
action: () => perform_ads_consent_action('manage').catch(handleError),
color: 'standard',
keepOpen: true,
},
{
label: formatMessage(messages.adsConsentReject),
action: () => perform_ads_consent_action('reject').catch(handleError),
color: 'brand',
keepOpen: true,
},
{
label: formatMessage(messages.adsConsentAccept),
action: () => perform_ads_consent_action('accept').catch(handleError),
color: 'brand',
keepOpen: true,
},
],
})
adsConsentPopupId = notification.id
}
async function setupApp() {
const {
native_decorations,
@@ -403,7 +521,7 @@ async function setupApp() {
addNotification({
title: 'Warning',
text: e.message,
type: 'warn',
type: 'warning',
}),
)
@@ -588,6 +706,7 @@ watch(stateInitialized, (ready) => {
const error = useError()
const errorModal = ref()
const minecraftAuthErrorModal = ref()
const minecraftRequiredModal = ref()
const contentInstall = createContentInstall({ router, handleError })
provideContentInstall(contentInstall)
@@ -637,9 +756,10 @@ const contentInstallModpackAlreadyInstalledModal = ref()
const addServerToInstanceModal = ref()
const incompatibilityWarningModal = ref()
const installToPlayModal = ref()
const sharedInstanceInviteHandler = ref()
const updateToPlayModal = ref()
const modrinthLoginFlowWaitModal = ref()
const modrinthLoginModal = ref()
watch(incompatibilityWarningModal, (modal) => {
if (modal) {
@@ -647,8 +767,12 @@ watch(incompatibilityWarningModal, (modal) => {
}
})
setupAuthProvider(credentials, async (_redirectPath) => {
await signIn()
setupAuthProvider(credentials, async (_redirectPath, flow, options) => {
if (options?.showModal === false) {
await signIn(flow)
} else {
await requestSignIn(flow)
}
})
async function validateSession(sessionToken) {
@@ -665,23 +789,35 @@ async function validateSession(sessionToken) {
}
async function fetchCredentials() {
const hadSession = !!credentials.value?.session
const refreshId = ++credentialsRefreshId
credentials.value = undefined
const creds = await getCreds().catch(handleError)
if (refreshId !== credentialsRefreshId) return
if (!creds && hadSession) clearLiveNotifications()
if (creds && creds.user_id) {
if (creds.session && !(await validateSession(creds.session))) {
if (refreshId !== credentialsRefreshId) return
clearLiveNotifications()
await logout().catch(handleError)
if (refreshId !== credentialsRefreshId) return
credentials.value = null
return
}
creds.user = await get_user(creds.user_id, 'bypass').catch(handleError)
if (refreshId !== credentialsRefreshId) return
}
credentials.value = creds ?? null
liveNotificationsEnabled = !!creds?.session
}
async function signIn() {
modrinthLoginFlowWaitModal.value.show()
async function signIn(flow = 'sign-in') {
try {
await login()
await login(flow)
await fetchCredentials()
} catch (error) {
if (
@@ -693,27 +829,31 @@ async function signIn() {
} else {
handleError(error)
}
} finally {
modrinthLoginFlowWaitModal.value.hide()
}
}
async function requestSignIn(flow = 'sign-in') {
await modrinthLoginModal.value?.showSigningIn(flow)
}
async function requestModrinthAuth(flow = 'sign-in') {
await signIn(flow)
return !!credentials.value?.session
}
async function logOut() {
await performLogOut()
}
async function performLogOut() {
credentialsRefreshId++
credentials.value = undefined
clearLiveNotifications()
await logout().catch(handleError)
await fetchCredentials()
}
const hasPlus = computed(
() =>
!!credentials.value?.user &&
(hasMidasBadge(credentials.value.user) ||
hasActivePride26Midas(authenticatedModrinthUser.value?.campaigns?.pride_26)),
)
const showAd = computed(
() => sidebarVisible.value && !hasPlus.value && credentials.value !== undefined,
)
async function fetchIntercomToken() {
const creds = await getCreds()
if (!creds?.session) {
@@ -740,19 +880,28 @@ async function fetchIntercomToken() {
return await response.json()
}
watch(showAd, () => {
if (!showAd.value) {
hide_ads_window(true)
} else {
init_ads_window(true)
}
})
watch(
[showAd, adConsentAvailable],
async ([showAds, canManageConsent]) => {
if (showAds) {
await init_ads_window(true)
return
}
await hide_ads_window(true)
if (canManageConsent) {
await init_ads_window()
}
},
{ immediate: true },
)
onMounted(() => {
invoke('show_window')
error.setErrorModal(errorModal.value)
error.setMinecraftAuthErrorModal(minecraftAuthErrorModal.value)
error.setMinecraftRequiredModal(minecraftRequiredModal.value)
setContentIncompatibilityWarningModal(incompatibilityWarningModal.value)
setContentInstallModal(modInstallModal.value)
@@ -821,30 +970,48 @@ function openServerInviteInviterProfile(inviterName) {
}
async function handleLiveNotification(notification) {
if (notification?.body?.type !== 'server_invite' || notification.read) return
if (displayedServerInviteNotifications.has(notification.id)) return
if (!liveNotificationsEnabled || !notification?.body || notification.read) return
if (await sharedInstanceInviteHandler.value?.handleNotification(notification)) return
displayedServerInviteNotifications.add(notification.id)
if (notification.body.type === 'server_invite') {
if (displayedServerInviteNotifications.has(notification.id)) return
const serverName =
typeof notification.body.server_name === 'string' ? notification.body.server_name : 'a server'
const inviterId = notification.body.invited_by
const invitedBy =
typeof inviterId === 'string' ? await get_user(inviterId, 'bypass').catch(() => null) : null
const generation = liveNotificationGeneration
displayedServerInviteNotifications.add(notification.id)
addPopupNotification({
title: serverName,
autoCloseMs: null,
toast: {
type: 'server-invite',
actorName: invitedBy?.username ?? null,
actorAvatarUrl: invitedBy?.avatar_url ?? null,
entityName: serverName,
onAccept: () => acceptServerInviteNotification(notification),
onDecline: () => declineServerInviteNotification(notification),
onOpenActor: () => openServerInviteInviterProfile(invitedBy?.username ?? null),
},
})
const serverName =
typeof notification.body.server_name === 'string' ? notification.body.server_name : 'a server'
const inviterId = notification.body.invited_by
const invitedBy =
typeof inviterId === 'string' ? await get_user(inviterId, 'bypass').catch(() => null) : null
if (generation !== liveNotificationGeneration) return
const popupNotification = addPopupNotification({
title: serverName,
autoCloseMs: null,
toast: {
type: 'server-invite',
actorName: invitedBy?.username ?? null,
actorAvatarUrl: invitedBy?.avatar_url ?? null,
entityName: serverName,
onAccept: () => acceptServerInviteNotification(notification),
onDecline: () => declineServerInviteNotification(notification),
onOpenActor: () => openServerInviteInviterProfile(invitedBy?.username ?? null),
},
})
serverInvitePopupNotificationIds.add(popupNotification.id)
}
}
function clearLiveNotifications() {
liveNotificationGeneration++
liveNotificationsEnabled = false
for (const id of serverInvitePopupNotificationIds) {
popupNotificationManager.removeNotification(id)
}
displayedServerInviteNotifications.clear()
serverInvitePopupNotificationIds.clear()
sharedInstanceInviteHandler.value?.clearNotifications()
}
async function handleCommand(e) {
@@ -871,6 +1038,9 @@ async function handleCommand(e) {
})
}
} else if (e.event === 'LaunchInstance') {
const instance = await getInstance(e.id).catch(handleError)
if (!instance || instance.quarantined) return
if (e.server) {
await start_join_server(e.id, e.server).catch(handleError)
} else if (e.singleplayer_world) {
@@ -878,6 +1048,8 @@ async function handleCommand(e) {
} else {
await run(e.id).catch(handleError)
}
} else if (e.event === 'InstallSharedInstanceInvite') {
await sharedInstanceInviteHandler.value?.installFromInviteId(e.invite_id)
} else if (e.event === 'InstallServer') {
await router.push(`/project/${e.id}`)
await playServerProject(e.id).catch(handleError)
@@ -1402,7 +1574,7 @@ provideAppUpdateDownloadProgress(appUpdateDownload)
<AppSettingsModal ref="settingsModal" />
</Suspense>
<Suspense>
<AuthGrantFlowWaitModal ref="modrinthLoginFlowWaitModal" @flow-cancel="cancelLogin" />
<ModrinthAccountRequiredModal ref="modrinthLoginModal" :request-auth="requestModrinthAuth" />
</Suspense>
<CreationFlowModal
ref="installationModal"
@@ -1506,7 +1678,11 @@ provideAppUpdateDownloadProgress(appUpdateDownload)
</template>
<template #sign-out> <LogOutIcon /> Sign out </template>
</OverflowMenu>
<NavButton v-else v-tooltip.right="'Sign in to a Modrinth account'" :to="() => signIn()">
<NavButton
v-else
v-tooltip.right="'Sign in to a Modrinth account'"
:to="() => requestSignIn()"
>
<LogInIcon class="text-brand" />
</NavButton>
</div>
@@ -1653,7 +1829,7 @@ provideAppUpdateDownloadProgress(appUpdateDownload)
</div>
<div class="p-4 border-0 border-b-[1px] border-[--brand-gradient-border] border-solid">
<suspense>
<FriendsList :credentials="credentials" :sign-in="() => signIn()" />
<FriendsList :credentials="credentials" :sign-in="() => requestSignIn()" />
</suspense>
</div>
<PrideFundraiserBanner
@@ -1694,6 +1870,7 @@ provideAppUpdateDownloadProgress(appUpdateDownload)
<PopupNotificationPanel :has-sidebar="sidebarVisible" />
<ErrorModal ref="errorModal" />
<MinecraftAuthErrorModal ref="minecraftAuthErrorModal" />
<MinecraftRequiredModal ref="minecraftRequiredModal" />
<ContentInstallModal
ref="modInstallModal"
:instances="contentInstallInstances"
@@ -1737,6 +1914,7 @@ provideAppUpdateDownloadProgress(appUpdateDownload)
@create-anyway="handleContentInstallModpackDuplicateCreateAnyway"
@go-to-instance="handleContentInstallModpackDuplicateGoToInstance"
/>
<SharedInstanceInviteHandler ref="sharedInstanceInviteHandler" />
<InstallToPlayModal ref="installToPlayModal" :show-external-warnings="false" />
<UpdateToPlayModal ref="updateToPlayModal" :show-external-warnings="false" />
</template>
Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 50 KiB

@@ -65,8 +65,7 @@ async function duplicateInstance(p) {
const handleRightClick = (event, instanceId) => {
const item = instanceComponents.value.find((x) => x.instance.id === instanceId)
const baseOptions = [
{ name: 'add_content' },
{ type: 'divider' },
...(item.instance.quarantined ? [] : [{ name: 'add_content' }, { type: 'divider' }]),
{ name: 'edit' },
{ name: 'duplicate' },
{ name: 'open' },
@@ -90,10 +89,14 @@ const handleRightClick = (event, instanceId) => {
...baseOptions,
]
: [
{
name: 'play',
color: 'primary',
},
...(item.instance.quarantined
? []
: [
{
name: 'play',
color: 'primary',
},
]),
...baseOptions,
],
)
@@ -73,8 +73,7 @@ async function duplicateInstance(p) {
const handleInstanceRightClick = async (event, passedInstance) => {
const baseOptions = [
{ name: 'add_content' },
{ type: 'divider' },
...(passedInstance.quarantined ? [] : [{ name: 'add_content' }, { type: 'divider' }]),
{ name: 'edit' },
{ name: 'duplicate' },
{ name: 'open_folder' },
@@ -98,10 +97,14 @@ const handleInstanceRightClick = async (event, passedInstance) => {
...baseOptions,
]
: [
{
name: 'play',
color: 'primary',
},
...(passedInstance.quarantined
? []
: [
{
name: 'play',
color: 'primary',
},
]),
...baseOptions,
]
@@ -52,10 +52,6 @@ defineExpose({
if (errorVal.message.includes('because the target machine actively refused it')) {
metadata.value.hostsFile = true
}
} else if (errorVal.message && errorVal.message.includes('User is not logged in')) {
title.value = 'Sign in to Minecraft'
errorType.value = 'minecraft_sign_in'
supportLink.value = 'https://support.modrinth.com'
} else if (errorVal.message && errorVal.message.includes('Move directory error:')) {
title.value = 'Could not change app directory'
errorType.value = 'directory_move'
@@ -243,20 +239,6 @@ async function copyToClipboard(text) {
</button>
</div>
</template>
<div v-else-if="errorType === 'minecraft_sign_in'">
<p>
To play this instance, you must sign in through Microsoft below. If you don't have a
Minecraft account, you can purchase the game on the
<a href="https://www.minecraft.net/en-us/store/minecraft-java-bedrock-edition-pc"
>Minecraft website</a
>.
</p>
<div class="cta-button">
<button class="btn btn-primary" :disabled="loadingMinecraft" @click="loginMinecraft">
<LogInIcon /> Sign in to Minecraft
</button>
</div>
</div>
<template v-else-if="errorType === 'state_init'">
<p>
Modrinth App failed to load correctly. This may be because of a corrupted file, or
@@ -66,6 +66,7 @@ const checkProcess = async () => {
const play = async (e, context) => {
e?.stopPropagation()
if (props.instance.quarantined) return
loading.value = true
await run(props.instance.id)
.catch((err) => handleSevereError(err, { instanceId: props.instance.id }))
@@ -94,6 +95,7 @@ const stop = async (e, context) => {
const repair = async (e) => {
e?.stopPropagation()
if (props.instance.quarantined) return
if (
props.instance.install_stage !== 'pack_installed' &&
@@ -116,6 +118,7 @@ const openFolder = async () => {
}
const addContent = async () => {
if (props.instance.quarantined) return
await router.push({
path: `/browse/${props.instance.loader === 'vanilla' ? 'datapack' : 'mod'}`,
query: { i: props.instance.id },
@@ -142,7 +145,9 @@ const unlisten = await process_listener((e) => {
}
})
onMounted(() => checkProcess())
onMounted(() => {
checkProcess()
})
onUnmounted(() => unlisten())
</script>
@@ -173,7 +178,11 @@ onUnmounted(() => unlisten())
<SpinnerIcon class="animate-spin" />
</button>
</ButtonStyled>
<ButtonStyled v-else :color="first ? 'brand' : 'standard'" circular>
<ButtonStyled
v-else-if="!instance.quarantined"
:color="first ? 'brand' : 'standard'"
circular
>
<button
v-tooltip="'Play'"
@click="(e) => play(e, 'InstanceCard')"
@@ -227,7 +236,12 @@ onUnmounted(() => unlisten())
class="animate-spin w-8 h-8"
tabindex="-1"
/>
<ButtonStyled v-else-if="!installed" size="large" color="brand" circular>
<ButtonStyled
v-else-if="!installed && !instance.quarantined"
size="large"
color="brand"
circular
>
<button
v-tooltip="'Repair'"
class="transition-all scale-75 group-hover:scale-100 group-focus-within:scale-100 origin-bottom opacity-0 group-hover:opacity-100 group-focus-within:opacity-100 card-shadow"
@@ -236,7 +250,7 @@ onUnmounted(() => unlisten())
<DownloadIcon />
</button>
</ButtonStyled>
<ButtonStyled v-else size="large" color="brand" circular>
<ButtonStyled v-else-if="!instance.quarantined" size="large" color="brand" circular>
<button
v-tooltip="'Play'"
class="transition-all scale-75 group-hover:scale-100 group-focus-within:scale-100 origin-bottom opacity-0 group-hover:opacity-100 group-focus-within:opacity-100 card-shadow"
@@ -10,18 +10,12 @@ import {
useRelativeTime,
useVIntl,
} from '@modrinth/ui'
import { computed, onUnmounted, ref, watch } from 'vue'
import { computed, ref } from 'vue'
import FriendsSection from '@/components/ui/friends/FriendsSection.vue'
import ModalWrapper from '@/components/ui/modal/ModalWrapper.vue'
import { friend_listener } from '@/helpers/events'
import {
add_friend,
friends,
type FriendWithUserData,
remove_friend,
transformFriends,
} from '@/helpers/friends.ts'
import { useFriends } from '@/composables/use-friends'
import type { FriendWithUserData } from '@/helpers/friends.ts'
import type { ModrinthCredentials } from '@/helpers/mr_auth'
const { formatMessage } = useVIntl()
@@ -35,36 +29,23 @@ const props = defineProps<{
}>()
const userCredentials = computed(() => props.credentials)
const {
friends: userFriends,
loading,
requestFriend,
acceptFriend,
removeFriend: removeFriendRecord,
} = useFriends({
currentUserId: () => userCredentials.value?.user_id,
getCredentials: () => userCredentials.value,
onError: handleError,
})
const search = ref('')
const friendInvitesModal = ref()
const username = ref('')
const addFriendModal = ref()
async function addFriendFromModal() {
addFriendModal.value.hide()
await add_friend(username.value).catch(handleError)
username.value = ''
await loadFriends()
}
async function addFriend(friend: FriendWithUserData) {
const id = friend.id === userCredentials.value?.user_id ? friend.friend_id : friend.id
if (id) {
await add_friend(id).catch(handleError)
await loadFriends()
}
}
async function removeFriend(friend: FriendWithUserData) {
const id = friend.id === userCredentials.value?.user_id ? friend.friend_id : friend.id
if (id) {
await remove_friend(id).catch(handleError)
await loadFriends()
}
}
const userFriends = ref<FriendWithUserData[]>([])
const sortedFriends = computed<FriendWithUserData[]>(() =>
userFriends.value.slice().sort((a, b) => {
if (a.last_updated === null && b.last_updated === null) {
@@ -108,42 +89,22 @@ const incomingRequests = computed(() =>
.sort((a, b) => b.created.diff(a.created)),
)
const loading = ref(true)
async function loadFriends(timeout = false) {
loading.value = timeout
function addFriendFromModal() {
const target = username.value.trim()
if (!target) return
try {
const friendsList = await friends()
userFriends.value = await transformFriends(friendsList, userCredentials.value)
loading.value = false
} catch (e) {
console.error('Error loading friends', e)
if (timeout) {
setTimeout(() => loadFriends(), 15 * 1000)
}
}
addFriendModal.value.hide()
requestFriend({ id: target, username: target })
username.value = ''
}
watch(
userCredentials,
() => {
if (userCredentials.value === undefined) {
userFriends.value = []
loading.value = false
} else if (userCredentials.value === null) {
userFriends.value = []
loading.value = false
} else {
loadFriends(true)
}
},
{ immediate: true },
)
function addFriend(friend: FriendWithUserData) {
acceptFriend(friend)
}
const unlisten = await friend_listener(() => loadFriends())
onUnmounted(() => {
unlisten()
})
function removeFriend(friend: FriendWithUserData) {
removeFriendRecord(friend)
}
const messages = defineMessages({
addFriend: {
@@ -0,0 +1,408 @@
<template>
<PageHeader :title="instance.name">
<template #leading>
<Avatar :src="iconSrc" :alt="instance.name" size="64px" :tint-by="instance.id" />
</template>
<template v-if="instance.shared_instance || instance.quarantined" #badges>
<PageHeaderBadgeItem
v-if="instance.quarantined"
:icon="LockIcon"
aria-label="Locked instance information"
class="!border-orange !bg-highlight-orange !text-orange"
>
Locked
</PageHeaderBadgeItem>
<PageHeaderBadgeItem
v-else
:icon="UnknownIcon"
:tooltip="sharedInstanceTooltip"
aria-label="Shared instance information"
class="!border-blue !bg-highlight-blue !text-blue"
>
Shared
</PageHeaderBadgeItem>
</template>
<template #metadata>
<div v-if="isServerInstance" class="flex flex-wrap items-center gap-2">
<InstanceHeaderServerMetadata
:loading-server-ping="loadingServerPing"
:players-online="playersOnline"
:status-online="statusOnline"
:recent-plays="recentPlays"
:ping="ping"
:minecraft-server="minecraftServer"
:linked-project-v3="linkedProjectV3"
:instance-id="instance.id"
/>
<PageHeaderMetadataItem v-if="sharedInstanceManager" :action="sharedInstanceManagerAction">
{{ sharedInstanceManagerLabel }}
<Avatar
:src="sharedInstanceManager.avatarUrl"
:alt="sharedInstanceManager.name"
:tint-by="sharedInstanceManager.tintBy"
size="24px"
:circle="sharedInstanceManager.type === 'user'"
no-shadow
/>
<span class="min-w-0 truncate">{{ sharedInstanceManager.name }}</span>
</PageHeaderMetadataItem>
</div>
<PageHeaderMetadata v-else>
<PageHeaderMetadataItem :icon="Gamepad2Icon" tooltip="Minecraft version">
Minecraft {{ instance.game_version }}
</PageHeaderMetadataItem>
<PageHeaderMetadataItem
v-if="sharedInstanceManager?.type !== 'user'"
:icon="ServerLoaderIcon"
:icon-props="{ loader: loaderDisplayName }"
tooltip="Mod loader"
>
{{ loaderLabel }}
</PageHeaderMetadataItem>
<PageHeaderMetadataItem
v-if="showInstancePlayTime"
:icon="TimerIcon"
tooltip="Total playtime"
>
{{ playtimeLabel }}
</PageHeaderMetadataItem>
<PageHeaderMetadataItem v-if="sharedInstanceManager" :action="sharedInstanceManagerAction">
{{ sharedInstanceManagerLabel }}
<Avatar
:src="sharedInstanceManager.avatarUrl"
:alt="sharedInstanceManager.name"
:tint-by="sharedInstanceManager.tintBy"
size="24px"
:circle="sharedInstanceManager.type === 'user'"
no-shadow
/>
<span class="min-w-0 truncate">{{ sharedInstanceManager.name }}</span>
</PageHeaderMetadataItem>
</PageHeaderMetadata>
</template>
<template #actions>
<PageHeaderActions>
<ButtonStyled v-if="isInstalling" color="brand" size="large">
<button type="button" disabled>
{{ formatMessage(commonMessages.installingLabel) }}
</button>
</ButtonStyled>
<ButtonStyled v-else-if="playing" color="red" size="large">
<button type="button" :disabled="stopping" @click="emit('stop')">
<StopCircleIcon />
{{
stopping ? formatMessage(messages.stopping) : formatMessage(commonMessages.stopButton)
}}
</button>
</ButtonStyled>
<ButtonStyled v-else-if="instance.quarantined" color="brand" size="large">
<button v-tooltip="formatMessage(messages.lockedPlayTooltip)" type="button" disabled>
<PlayIcon />
{{ formatMessage(commonMessages.playButton) }}
</button>
</ButtonStyled>
<ButtonStyled v-else-if="instance.install_stage !== 'installed'" color="brand" size="large">
<button type="button" @click="emit('repair')">
<DownloadIcon />
{{ formatMessage(messages.repair) }}
</button>
</ButtonStyled>
<JoinedButtons
v-else-if="!loading && isServerInstance"
:actions="serverPlayActions"
color="brand"
size="large"
/>
<ButtonStyled v-else-if="!loading" color="brand" size="large">
<button type="button" @click="emit('play')">
<PlayIcon />
{{ formatMessage(commonMessages.playButton) }}
</button>
</ButtonStyled>
<ButtonStyled v-else color="brand" size="large">
<button type="button" disabled>{{ formatMessage(messages.starting) }}</button>
</ButtonStyled>
<ButtonStyled circular size="large">
<button
v-tooltip="formatMessage(messages.instanceSettings)"
type="button"
:aria-label="formatMessage(messages.instanceSettings)"
@click="emit('settings')"
>
<SettingsIcon />
</button>
</ButtonStyled>
<ButtonStyled circular size="large" type="transparent">
<TeleportOverflowMenu
:options="moreActions"
:tooltip="formatMessage(messages.moreActions)"
:aria-label="formatMessage(messages.moreActions)"
>
<MoreVerticalIcon />
</TeleportOverflowMenu>
</ButtonStyled>
</PageHeaderActions>
</template>
</PageHeader>
</template>
<script setup lang="ts">
import type { Labrinth } from '@modrinth/api-client'
import {
DownloadIcon,
ExternalIcon,
FolderOpenIcon,
LockIcon,
MoreVerticalIcon,
PackageIcon,
PlayIcon,
ReportIcon,
SettingsIcon,
StopCircleIcon,
TagCategoryGamepad2Icon as Gamepad2Icon,
TimerIcon,
UnknownIcon,
} from '@modrinth/assets'
import {
Avatar,
ButtonStyled,
commonMessages,
defineMessages,
formatLoaderLabel,
type JoinedButtonAction,
JoinedButtons,
LoaderIcon as ServerLoaderIcon,
PageHeader,
PageHeaderActions,
PageHeaderBadgeItem,
PageHeaderMetadata,
PageHeaderMetadataItem,
type ServerLoader,
TeleportOverflowMenu,
type TeleportOverflowMenuItem,
useVIntl,
} from '@modrinth/ui'
import { openUrl } from '@tauri-apps/plugin-opener'
import { computed } from 'vue'
import type { GameInstance } from '@/helpers/types'
import InstanceHeaderServerMetadata from './instance-page-header-server-metadata.vue'
const messages = defineMessages({
createShortcut: {
id: 'instance.action.create-shortcut',
defaultMessage: 'Create shortcut',
},
exportModpack: {
id: 'instance.action.export-modpack',
defaultMessage: 'Export modpack',
},
instanceSettings: {
id: 'instance.action.settings',
defaultMessage: 'Instance settings',
},
launchInstance: {
id: 'instance.action.launch-instance',
defaultMessage: 'Launch instance',
},
moreActions: {
id: 'instance.action.more-actions',
defaultMessage: 'More actions',
},
neverPlayed: {
id: 'instance.playtime.never-played',
defaultMessage: 'Never played',
},
openFolder: {
id: 'instance.action.open-folder',
defaultMessage: 'Open folder',
},
repair: {
id: 'instance.action.repair',
defaultMessage: 'Repair',
},
lockedPlayTooltip: {
id: 'instance.locked.play-tooltip',
defaultMessage: 'This instance has been locked',
},
starting: {
id: 'instance.action.starting',
defaultMessage: 'Starting...',
},
stopping: {
id: 'instance.action.stopping',
defaultMessage: 'Stopping...',
},
sharedInstanceTooltip: {
id: 'instance.shared-instance.tooltip',
defaultMessage: "This instance's content is being managed by someone else.",
},
sharedInstanceOwnerTooltip: {
id: 'instance.shared-instance.owner-tooltip',
defaultMessage: "This instance's content is being shared to other users.",
},
})
const props = withDefaults(
defineProps<{
instance: GameInstance
iconSrc?: string | null
isServerInstance?: boolean
showInstancePlayTime?: boolean
timePlayed?: number
playing?: boolean
loading?: boolean
stopping?: boolean
loadingServerPing?: boolean
playersOnline?: number
statusOnline?: boolean
recentPlays?: number
ping?: number
minecraftServer?: Labrinth.Projects.v3.Project['minecraft_server']
linkedProjectV3?: Labrinth.Projects.v3.Project
sharedInstanceManager?: {
type: 'user' | 'server'
name: string
avatarUrl?: string
tintBy: string
} | null
}>(),
{
iconSrc: null,
isServerInstance: false,
showInstancePlayTime: false,
timePlayed: 0,
playing: false,
loading: false,
stopping: false,
loadingServerPing: false,
playersOnline: undefined,
statusOnline: false,
recentPlays: undefined,
ping: undefined,
minecraftServer: undefined,
linkedProjectV3: undefined,
sharedInstanceManager: null,
},
)
const emit = defineEmits<{
repair: []
stop: []
play: []
playServer: []
settings: []
openFolder: []
export: []
createShortcut: []
report: [event?: MouseEvent]
}>()
const installingStages = [
'installing',
'pack_installing',
'pack_installed',
'not_installed',
'minecraft_installing',
]
const { formatMessage } = useVIntl()
const isInstalling = computed(() => installingStages.includes(props.instance.install_stage))
const loaderDisplayName = computed(() => formatLoaderLabel(props.instance.loader) as ServerLoader)
const loaderLabel = computed(() =>
[loaderDisplayName.value, props.instance.loader_version].filter(Boolean).join(' '),
)
const sharedInstanceTooltip = computed(() =>
formatMessage(
props.instance.shared_instance?.role === 'owner'
? messages.sharedInstanceOwnerTooltip
: messages.sharedInstanceTooltip,
),
)
const sharedInstanceManagerLabel = computed(() =>
props.sharedInstanceManager?.type === 'server' ? 'Linked to' : 'Managed by',
)
const sharedInstanceManagerAction = computed(() => {
const manager = props.sharedInstanceManager
if (manager?.type !== 'user') return undefined
return () => openUrl(`https://modrinth.com/user/${encodeURIComponent(manager.name)}`)
})
const playtimeLabel = computed(() => {
if (props.timePlayed <= 0) return formatMessage(messages.neverPlayed)
const hours = Math.floor(props.timePlayed / 3600)
if (hours >= 1) {
return `${hours} hour${hours > 1 ? 's' : ''}`
}
const minutes = Math.floor(props.timePlayed / 60)
if (minutes >= 1) {
return `${minutes} minute${minutes > 1 ? 's' : ''}`
}
const seconds = Math.floor(props.timePlayed)
return `${seconds} second${seconds > 1 ? 's' : ''}`
})
const serverPlayActions = computed<JoinedButtonAction[]>(() => [
{
id: 'join_server',
label: formatMessage(commonMessages.playButton),
icon: PlayIcon,
action: () => emit('playServer'),
},
{
id: 'launch_instance',
label: formatMessage(messages.launchInstance),
icon: PlayIcon,
action: () => emit('play'),
},
])
const moreActions = computed<TeleportOverflowMenuItem[]>(() => {
const actions: TeleportOverflowMenuItem[] = [
{
id: 'open-folder',
label: formatMessage(messages.openFolder),
icon: FolderOpenIcon,
action: () => emit('openFolder'),
},
]
if (!props.instance.quarantined) {
actions.push(
{
id: 'export-mrpack',
label: formatMessage(messages.exportModpack),
icon: PackageIcon,
action: () => emit('export'),
},
{
id: 'create-shortcut',
label: formatMessage(messages.createShortcut),
icon: ExternalIcon,
action: () => emit('createShortcut'),
},
)
}
if (props.instance.shared_instance?.role === 'member') {
actions.push(
{ divider: true },
{
id: 'report-shared-instance',
label: formatMessage(commonMessages.reportButton),
icon: ReportIcon,
color: 'red',
action: (event) => emit('report', event),
},
)
}
return actions
})
</script>
@@ -0,0 +1,63 @@
<template>
<div class="flex items-center flex-wrap gap-2">
<template v-if="loadingServerPing">
<ServerOnlinePlayers
v-if="playersOnline !== undefined"
:online="playersOnline"
:status-online="statusOnline"
hide-label
/>
<ServerRecentPlays :recent-plays="recentPlays ?? 0" hide-label />
<div
v-if="
(playersOnline !== undefined || recentPlays !== undefined) &&
(minecraftServer?.region || ping)
"
class="w-1.5 h-1.5 rounded-full bg-surface-5"
></div>
<ServerPing v-if="ping" :ping="ping" />
</template>
<ServerRegion v-if="minecraftServer?.region" :region="minecraftServer?.region" />
<div v-if="minecraftServer?.region || ping" class="w-1.5 h-1.5 rounded-full bg-surface-5"></div>
<div v-if="linkedProjectV3" class="flex gap-1.5 items-center font-medium text-primary">
Linked to
<Avatar
:src="linkedProjectV3.icon_url"
:alt="linkedProjectV3.name"
:tint-by="instanceId"
size="24px"
/>
<router-link
:to="`/project/${linkedProjectV3.slug ?? linkedProjectV3.id}`"
class="hover:underline text-primary truncate"
>
{{ linkedProjectV3.name }}
</router-link>
</div>
</div>
</template>
<script setup lang="ts">
import type { Labrinth } from '@modrinth/api-client'
import {
Avatar,
ServerOnlinePlayers,
ServerPing,
ServerRecentPlays,
ServerRegion,
} from '@modrinth/ui'
defineProps<{
loadingServerPing?: boolean
playersOnline?: number
statusOnline?: boolean
recentPlays?: number
ping?: number
minecraftServer?: Labrinth.Projects.v3.Project['minecraft_server']
linkedProjectV3?: Labrinth.Projects.v3.Project
instanceId?: string
}>()
</script>
@@ -0,0 +1,116 @@
<template>
<StackedAdmonitions v-bind="$attrs" :items="stackItems" class="w-full">
<template #item="{ item, dismissible }">
<InstanceAdmonitionsSharedInstanceStale
v-if="item.kind === 'shared-instance-stale'"
:instance="instance"
@published="emit('published')"
/>
<InstanceAdmonitionsSharedInstanceWrongAccount
v-else-if="item.kind === 'shared-instance-wrong-account'"
:expected-user-id="sharedInstanceExpectedUserId"
:role="sharedInstanceRole"
:signed-out="sharedInstanceSignedOut"
/>
<InstanceAdmonitionsSharedInstanceUnavailable
v-else-if="item.kind === 'shared-instance-unavailable'"
:reason="displayedSharedInstanceUnavailableReason"
:manager="sharedInstanceUnavailableManager"
:dismissible="dismissible"
@dismiss="sharedInstanceUnavailableDismissed = true"
@delete="emit('delete')"
/>
</template>
</StackedAdmonitions>
</template>
<script setup lang="ts">
import { StackedAdmonitions } from '@modrinth/ui'
import { computed, ref, watch } from 'vue'
import type { SharedInstanceUnavailableReason } from '@/helpers/install'
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 InstanceAdmonitionsSharedInstanceWrongAccount from './instance-admonitions-shared-instance-wrong-account.vue'
import type { InstanceAdmonitionItem, SharedInstanceRole } from './types'
defineOptions({
inheritAttrs: false,
})
const props = defineProps<{
instance: GameInstance
sharedInstanceUnavailableReason?: SharedInstanceUnavailableReason | null
sharedInstanceUnavailableManager?: string | null
sharedInstanceWrongAccount?: boolean
sharedInstanceExpectedUserId?: string | null
sharedInstanceRole?: SharedInstanceRole | null
sharedInstanceSignedOut?: boolean
}>()
const emit = defineEmits<{
published: []
delete: []
}>()
const sharedInstanceWrongAccount = computed(() => props.sharedInstanceWrongAccount ?? false)
const displayedSharedInstanceUnavailableReason = computed<SharedInstanceUnavailableReason | null>(
() =>
props.instance.quarantined ? 'quarantined' : (props.sharedInstanceUnavailableReason ?? null),
)
const sharedInstanceUnavailableDismissed = ref(false)
const showSharedInstancePublishAdmonition = computed(
() =>
!sharedInstanceWrongAccount.value &&
props.instance.install_stage === 'installed' &&
props.instance.shared_instance?.role === 'owner' &&
props.instance.shared_instance.status === 'stale',
)
const stackItems = computed<InstanceAdmonitionItem[]>(() => {
const items: InstanceAdmonitionItem[] = []
if (sharedInstanceWrongAccount.value) {
items.push({
id: 'shared-instance-wrong-account',
type: 'warning',
dismissible: false,
kind: 'shared-instance-wrong-account',
})
}
const unavailableReason = displayedSharedInstanceUnavailableReason.value
const sharedInstanceQuarantined = unavailableReason === 'quarantined'
if (
unavailableReason &&
(sharedInstanceQuarantined || !sharedInstanceUnavailableDismissed.value)
) {
items.push({
id: 'shared-instance-unavailable',
type: 'warning',
dismissible: !sharedInstanceQuarantined,
kind: 'shared-instance-unavailable',
})
}
if (showSharedInstancePublishAdmonition.value) {
items.push({
id: 'shared-instance-stale',
type: 'warning',
dismissible: false,
kind: 'shared-instance-stale',
})
}
return items
})
watch(
() => [props.instance.id, displayedSharedInstanceUnavailableReason.value],
() => {
sharedInstanceUnavailableDismissed.value = false
},
)
</script>
@@ -0,0 +1,69 @@
import { defineMessages } from '@modrinth/ui'
export const instanceAdmonitionsMessages = defineMessages({
sharedInstanceChangesHeader: {
id: 'app.instance.admonitions.shared-instance.changes-header',
defaultMessage: "Your changes haven't been shared yet",
},
sharedInstanceChangesBody: {
id: 'app.instance.admonitions.shared-instance.changes-body',
defaultMessage: "Your local instance is ahead of the users you've shared it with.",
},
sharedInstancePublishButton: {
id: 'app.instance.admonitions.shared-instance.publish-button',
defaultMessage: 'Push update',
},
sharedInstancePublishingButton: {
id: 'app.instance.admonitions.shared-instance.publishing-button',
defaultMessage: 'Pushing...',
},
sharedInstanceReviewingButton: {
id: 'app.instance.admonitions.shared-instance.reviewing-button',
defaultMessage: 'Reviewing...',
},
sharedInstanceReviewHeader: {
id: 'app.instance.admonitions.shared-instance.review-header',
defaultMessage: 'Review changes',
},
sharedInstanceReviewAdmonitionHeader: {
id: 'app.instance.admonitions.shared-instance.review-admonition-header',
defaultMessage: 'Push update',
},
sharedInstanceReviewDescription: {
id: 'app.instance.admonitions.shared-instance.review-description',
defaultMessage:
'Review the content changes that will be shared with everyone using this instance.',
},
sharedInstanceAddedLabel: {
id: 'app.instance.admonitions.shared-instance.added-label',
defaultMessage: 'Added',
},
sharedInstanceRemovedLabel: {
id: 'app.instance.admonitions.shared-instance.removed-label',
defaultMessage: 'Removed',
},
sharedInstanceWrongAccountHeader: {
id: 'app.instance.shared-instance-wrong-account.warning-header',
defaultMessage: 'You are using the wrong Modrinth account',
},
sharedInstanceSignedOutHeader: {
id: 'app.instance.shared-instance-wrong-account.signed-out-header',
defaultMessage: 'You need to sign in to Modrinth',
},
sharedInstanceWrongAccountSignInAs: {
id: 'app.instance.shared-instance-wrong-account.sign-in-as-label',
defaultMessage: 'Sign in as',
},
sharedInstanceWrongAccountUserBody: {
id: 'app.instance.shared-instance-wrong-account.user-admonition-body-v2',
defaultMessage: 'to receive updates for this shared instance.',
},
sharedInstanceWrongAccountOwnerBody: {
id: 'app.instance.shared-instance-wrong-account.owner-admonition-body-v2',
defaultMessage: "to manage this shared instance. You won't be able to push updates to users.",
},
sharedInstanceWrongAccountFallbackUsername: {
id: 'app.instance.shared-instance-wrong-account.fallback-username',
defaultMessage: 'the linked account',
},
})
@@ -0,0 +1,66 @@
<template>
<Admonition
type="info"
inline-actions
:header="formatMessage(messages.sharedInstanceChangesHeader)"
>
{{ formatMessage(messages.sharedInstanceChangesBody) }}
<template #actions>
<ButtonStyled color="blue">
<button class="!h-10" :disabled="isPublishButtonDisabled" @click="reviewChanges">
<SpinnerIcon
v-if="isReviewingPublish || isPublishing"
class="animate-spin"
aria-hidden="true"
/>
<UploadIcon v-else aria-hidden="true" />
{{
isPublishing
? formatMessage(messages.sharedInstancePublishingButton)
: isReviewingPublish
? formatMessage(messages.sharedInstanceReviewingButton)
: formatMessage(messages.sharedInstancePublishButton)
}}
</button>
</ButtonStyled>
</template>
</Admonition>
<SharedInstancePublishModal
ref="publishModal"
:instance="instance"
@published="emit('published')"
@state-change="publishState = $event"
/>
</template>
<script setup lang="ts">
import { SpinnerIcon, UploadIcon } from '@modrinth/assets'
import { Admonition, ButtonStyled, useVIntl } from '@modrinth/ui'
import { computed, ref } from 'vue'
import SharedInstancePublishModal from '@/components/ui/shared-instances/SharedInstancePublishModal.vue'
import type { GameInstance } from '@/helpers/types'
import { instanceAdmonitionsMessages as messages } from './instance-admonitions-messages'
defineProps<{
instance: GameInstance
}>()
const emit = defineEmits<{
published: []
}>()
const { formatMessage } = useVIntl()
const publishModal = ref<InstanceType<typeof SharedInstancePublishModal>>()
const publishState = ref<'idle' | 'reviewing' | 'publishing'>('idle')
const isPublishing = computed(() => publishState.value === 'publishing')
const isReviewingPublish = computed(() => publishState.value === 'reviewing')
const isPublishButtonDisabled = computed(() => isPublishing.value || isReviewingPublish.value)
function reviewChanges(e?: MouseEvent) {
publishModal.value?.show(e)
}
</script>
@@ -0,0 +1,51 @@
<template>
<Admonition
type="warning"
:inline-actions="reason === 'quarantined'"
:header="formatMessage(sharedInstanceUnavailableTitleMessage(reason ?? null))"
:dismissible="dismissible"
@dismiss="emit('dismiss')"
>
{{ formatSharedInstanceUnavailable(reason ?? null, manager) }}
<template v-if="reason === 'quarantined'" #actions>
<ButtonStyled color="orange">
<button class="!h-10" @click="emit('delete')">
<TrashIcon aria-hidden="true" />
{{ formatMessage(messages.deleteInstance) }}
</button>
</ButtonStyled>
</template>
</Admonition>
</template>
<script setup lang="ts">
import { TrashIcon } from '@modrinth/assets'
import { Admonition, ButtonStyled, defineMessages, useVIntl } from '@modrinth/ui'
import type { SharedInstanceUnavailableReason } from '@/helpers/install'
import {
sharedInstanceUnavailableTitleMessage,
useSharedInstanceErrors,
} from '@/helpers/shared-instance-errors'
defineProps<{
reason?: SharedInstanceUnavailableReason | null
manager?: string | null
dismissible?: boolean
}>()
const emit = defineEmits<{
dismiss: []
delete: []
}>()
const { formatMessage } = useVIntl()
const { formatSharedInstanceUnavailable } = useSharedInstanceErrors()
const messages = defineMessages({
deleteInstance: {
id: 'instance.locked.delete-button',
defaultMessage: 'Delete instance',
},
})
</script>
@@ -0,0 +1,78 @@
<template>
<Admonition type="warning" :header="formatMessage(headerMessage)">
<span class="flex flex-wrap items-center gap-x-1.5 gap-y-1">
<span>{{ formatMessage(messages.sharedInstanceWrongAccountSignInAs) }}</span>
<span
v-if="sharedInstanceExpectedUser"
class="inline-flex max-w-full min-w-0 items-center gap-1.5 align-middle font-semibold text-contrast"
>
<Avatar
:src="sharedInstanceExpectedUser.avatarUrl"
:alt="sharedInstanceExpectedUsername"
:tint-by="sharedInstanceExpectedUser.tintBy"
size="20px"
circle
no-shadow
/>
<span class="min-w-0 truncate">{{ sharedInstanceExpectedUsername }}</span>
</span>
<span v-else class="font-semibold">{{ sharedInstanceExpectedUsername }}</span>
<span>{{ formatMessage(bodyMessage) }}</span>
</span>
</Admonition>
</template>
<script setup lang="ts">
import { Admonition, Avatar, useVIntl } from '@modrinth/ui'
import { useQuery } from '@tanstack/vue-query'
import { computed } from 'vue'
import { get_user } from '@/helpers/cache'
import { instanceAdmonitionsMessages as messages } from './instance-admonitions-messages'
import type { SharedInstanceRole } from './types'
const props = defineProps<{
expectedUserId?: string | null
role?: SharedInstanceRole | null
signedOut?: boolean
}>()
const { formatMessage } = useVIntl()
const expectedUserId = computed(() => props.expectedUserId ?? null)
const expectedUserQuery = useQuery({
queryKey: computed(() => ['user', expectedUserId.value]),
queryFn: async () => {
if (!expectedUserId.value) return null
return await get_user(expectedUserId.value, 'bypass').catch(() => null)
},
enabled: () => !!expectedUserId.value,
staleTime: 30_000,
})
const sharedInstanceExpectedUser = computed(() => {
const user = expectedUserQuery.data.value
if (!user) return null
return {
username: user.username,
avatarUrl: user.avatar_url ?? undefined,
tintBy: user.id,
}
})
const sharedInstanceExpectedUsername = computed(
() =>
sharedInstanceExpectedUser.value?.username ||
formatMessage(messages.sharedInstanceWrongAccountFallbackUsername),
)
const headerMessage = computed(() =>
props.signedOut
? messages.sharedInstanceSignedOutHeader
: messages.sharedInstanceWrongAccountHeader,
)
const bodyMessage = computed(() =>
props.role === 'owner'
? messages.sharedInstanceWrongAccountOwnerBody
: messages.sharedInstanceWrongAccountUserBody,
)
</script>
@@ -0,0 +1,12 @@
import type { StackedAdmonitionItem } from '@modrinth/ui'
export type InstanceAdmonitionKind =
| 'shared-instance-stale'
| 'shared-instance-unavailable'
| 'shared-instance-wrong-account'
export type InstanceAdmonitionItem = StackedAdmonitionItem & {
kind: InstanceAdmonitionKind
}
export type SharedInstanceRole = 'owner' | 'member'
@@ -7,7 +7,6 @@ import {
injectFilePicker,
injectNotificationManager,
InstallationSettingsLayout,
provideAppBackup,
provideInstallationSettings,
useDebugLogger,
useVIntl,
@@ -16,24 +15,25 @@ import type { GameVersionTag, PlatformTag } from '@modrinth/utils'
import { useQuery, useQueryClient } from '@tanstack/vue-query'
import { computed, ref } from 'vue'
import SharedInstanceInstallationSettingsControls from '@/components/ui/shared-instances/SharedInstanceInstallationSettingsControls.vue'
import { useManagedContentPolicy } from '@/composables/instances/use-managed-content-policy'
import { trackEvent } from '@/helpers/analytics'
import { get_project_versions, get_version } from '@/helpers/cache'
import {
install_duplicate_instance,
install_existing_instance,
install_pack_to_existing_instance,
installJobInstanceId,
wait_for_install_job,
} from '@/helpers/install'
import {
edit,
get_linked_modpack_info,
list,
unlink_shared_instance,
update_managed_modrinth_version,
update_repair_modrinth,
} from '@/helpers/instance'
import { get_loader_versions } from '@/helpers/metadata'
import { get_game_versions, get_loaders } from '@/helpers/tags'
import { provideInstanceBackup } from '@/providers/instance-backup'
import { injectInstanceSettings } from '@/providers/instance-settings'
import { useTheming } from '@/store/state'
@@ -47,6 +47,7 @@ const debug = useDebugLogger('AppInstallationSettings')
const themeStore = useTheming()
const { instance, offline, isMinecraftServer, onUnlinked, closeModal } = injectInstanceSettings()
const managedContentPolicy = useManagedContentPolicy(instance)
const skipNonEssentialWarnings = computed(() =>
themeStore.getFeatureFlag('skip_non_essential_warnings'),
)
@@ -111,9 +112,14 @@ debug('metadata queries configured', {
const isModrinthLinkedModpack = computed(
() =>
instance.value.link?.type === 'modrinth_modpack' ||
instance.value.link?.type === 'server_project_modpack',
instance.value.link?.type === 'server_project_modpack' ||
(instance.value.link?.type === 'shared_instance' &&
!!instance.value.link.modpack_project_id &&
!!instance.value.link.modpack_version_id),
)
const isImportedModpack = computed(() => instance.value.link?.type === 'imported_modpack')
const isSharedInstanceManagedModpack = managedContentPolicy.isManagedModpack
const canUnlinkSharedInstance = managedContentPolicy.canUnlink
const modpackInfoQuery = useQuery({
queryKey: computed(() => ['linkedModpackInfo', instance.value.id]),
@@ -124,12 +130,43 @@ const modpackInfo = modpackInfoQuery.data
const repairing = ref(false)
const reinstalling = ref(false)
const unlinkingSharedInstance = ref(false)
const installationSettingsBusy = computed(
() =>
instance.value.quarantined ||
instance.value.install_stage !== 'installed' ||
repairing.value ||
reinstalling.value ||
unlinkingSharedInstance.value ||
!!offline,
)
const installationSettingsBusyMessage = computed(() =>
instance.value.quarantined ? formatMessage(messages.locked) : null,
)
async function unlinkSharedInstance() {
unlinkingSharedInstance.value = true
try {
await unlink_shared_instance(instance.value.id)
await queryClient.invalidateQueries({ queryKey: ['sharedInstanceUsers', instance.value.id] })
await queryClient.invalidateQueries({ queryKey: ['linkedModpackInfo', instance.value.id] })
onUnlinked()
} catch (error) {
handleError(error)
} finally {
unlinkingSharedInstance.value = false
}
}
const messages = defineMessages({
loaderVersion: {
id: 'instance.settings.tabs.installation.loader-version',
defaultMessage: '{loader} version',
},
locked: {
id: 'instance.settings.tabs.installation.locked',
defaultMessage: 'Installation settings are unavailable while this instance is locked.',
},
})
function getManifest(loader: string) {
@@ -162,27 +199,7 @@ async function installLocalModpackFromPicker() {
return !!completed
}
provideAppBackup({
async createBackup() {
debug('createBackup: start', {
instanceId: instance.value.id,
instanceName: instance.value.name,
})
const allInstances = await list()
const prefix = `${instance.value.name} - Backup #`
const existingNums = allInstances
.filter((p) => p.name.startsWith(prefix))
.map((p) => parseInt(p.name.slice(prefix.length), 10))
.filter((n) => !isNaN(n))
const nextNum = existingNums.length > 0 ? Math.max(...existingNums) + 1 : 1
const job = await install_duplicate_instance(instance.value.id)
const newInstanceId = installJobInstanceId(job)
if (newInstanceId) {
await edit(newInstanceId, { name: `${prefix}${nextNum}` })
}
debug('createBackup: done', { newInstanceId, backupName: `${prefix}${nextNum}` })
},
})
provideInstanceBackup(instance)
provideInstallationSettings({
closeSettings: closeModal,
@@ -208,14 +225,14 @@ provideInstallationSettings({
}
return rows
}),
isLinked: computed(() => isModrinthLinkedModpack.value || isImportedModpack.value),
isBusy: computed(
isLinked: computed(
() =>
instance.value.install_stage !== 'installed' ||
repairing.value ||
reinstalling.value ||
!!offline,
isModrinthLinkedModpack.value ||
isImportedModpack.value ||
isSharedInstanceManagedModpack.value,
),
isBusy: installationSettingsBusy,
busyMessage: installationSettingsBusyMessage,
skipNonEssentialWarnings,
modpack: computed(() => {
if (isImportedModpack.value && instance.value.link?.type === 'imported_modpack') {
@@ -452,14 +469,28 @@ provideInstallationSettings({
isServer: false,
isApp: true,
showModpackVersionActions: computed(
() => isModrinthLinkedModpack.value && !isMinecraftServer.value,
() =>
isModrinthLinkedModpack.value &&
!isMinecraftServer.value &&
!isSharedInstanceManagedModpack.value,
),
isLocalFile: isImportedModpack,
isManagedModpack: isSharedInstanceManagedModpack,
managedModpackWarning: managedContentPolicy.managedModpackWarning,
repairing,
reinstalling,
})
</script>
<template>
<InstallationSettingsLayout />
<InstallationSettingsLayout>
<template #extra>
<SharedInstanceInstallationSettingsControls
:can-unlink="canUnlinkSharedInstance"
:busy="installationSettingsBusy"
:unlinking="unlinkingSharedInstance"
:unlink="unlinkSharedInstance"
/>
</template>
</InstallationSettingsLayout>
</template>
@@ -0,0 +1,42 @@
<template>
<div>
<SharedInstanceInstallationSettingsControls
can-unpublish
:busy="isBusy"
:unpublishing="unpublishing"
:unpublish="unpublishSharedInstance"
/>
</div>
</template>
<script setup lang="ts">
import { injectNotificationManager } from '@modrinth/ui'
import { useQueryClient } from '@tanstack/vue-query'
import { computed, ref } from 'vue'
import SharedInstanceInstallationSettingsControls from '@/components/ui/shared-instances/SharedInstanceInstallationSettingsControls.vue'
import { unpublish_shared_instance } from '@/helpers/instance'
import { injectInstanceSettings } from '@/providers/instance-settings'
const { instance, offline, onUnlinked } = injectInstanceSettings()
const { handleError } = injectNotificationManager()
const queryClient = useQueryClient()
const unpublishing = ref(false)
const isBusy = computed(
() => instance.value.install_stage !== 'installed' || unpublishing.value || !!offline,
)
async function unpublishSharedInstance() {
unpublishing.value = true
try {
await unpublish_shared_instance(instance.value.id)
queryClient.setQueryData(['sharedInstanceUsers', instance.value.id], [])
await queryClient.invalidateQueries({ queryKey: ['linkedModpackInfo', instance.value.id] })
onUnlinked()
} catch (error) {
handleError(error)
} finally {
unpublishing.value = false
}
}
</script>
@@ -0,0 +1,144 @@
<template>
<NewModal ref="modal" :header="formatMessage(messages.header)" max-width="544px" no-padding>
<div class="grid grid-cols-[1fr_auto] gap-2.5 h-[154px] px-7 pt-4 pb-1 pr-9">
<div class="flex flex-col gap-2.5 items-start justify-center h-min mt-5">
<div class="font-semibold text-xl text-contrast">
{{ formatMessage(messages.descriptionHeader) }}
</div>
<div class="text-secondary leading-6">
{{ formatMessage(messages.description) }}
</div>
</div>
<div class="relative h-full w-[96px] overflow-hidden mx-3">
<div class="absolute top-0 left-0 z-0 w-full flex grow-0 flex-col items-end p-0">
<img :src="steveImage" alt="" class="self-stretch" />
</div>
<div
class="absolute left-0 bottom-0 z-10 order-1 h-6 w-[120px] shrink-0 grow-0 bg-[linear-gradient(180deg,rgba(39,41,46,0)_0%,#27292E_80%,#27292E_100%)]"
></div>
</div>
</div>
<div class="flex flex-col gap-6 px-6 pb-6">
<div class="flex justify-end gap-2">
<ButtonStyled>
<a class="w-full !shadow-none" href="https://support.modrinth.com" @click="modal?.hide()">
<MessagesSquareIcon />
{{ formatMessage(messages.getSupport) }}
</a>
</ButtonStyled>
<ButtonStyled color="brand">
<button class="w-full !shadow-none" :disabled="loadingSignIn" @click="signIn">
<SpinnerIcon v-if="loadingSignIn" class="animate-spin" />
<svg
v-else
width="20"
height="20"
viewBox="0 0 20 20"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<rect width="9.25" height="9.25" fill="black" fill-opacity="0.9" />
<rect x="10.75" width="9.25" height="9.25" fill="black" fill-opacity="0.9" />
<rect y="10.75" width="9.25" height="9.25" fill="black" fill-opacity="0.9" />
<rect
x="10.75"
y="10.75"
width="9.25"
height="9.25"
fill="black"
fill-opacity="0.9"
/>
</svg>
{{ formatMessage(messages.signIn) }}
</button>
</ButtonStyled>
</div>
<p class="m-0 text-center text-sm text-secondary">
{{ formatMessage(messages.dontHaveAccount) }}
<a
class="text-blue font-medium hover:underline"
href="https://www.minecraft.net/en-us/store/minecraft-java-bedrock-edition-pc"
>
{{ formatMessage(messages.getMinecraft) }}
</a>
</p>
</div>
</NewModal>
</template>
<script setup lang="ts">
import { MessagesSquareIcon, SpinnerIcon } from '@modrinth/assets'
import { ButtonStyled, defineMessages, NewModal, useVIntl } from '@modrinth/ui'
import { inject, type Ref, ref } from 'vue'
import steveImage from '@/assets/steve-look-up-left.webp'
import type AccountsCard from '@/components/ui/AccountsCard.vue'
import { trackEvent } from '@/helpers/analytics'
import { login as loginFlow, set_default_user } from '@/helpers/auth.js'
import { handleSevereError } from '@/store/error.js'
const { formatMessage } = useVIntl()
const accountsCard = inject('accountsCard') as Ref<InstanceType<typeof AccountsCard> | null>
const messages = defineMessages({
header: {
id: 'minecraft-required.header',
defaultMessage: 'Minecraft required',
},
descriptionHeader: {
id: 'minecraft-required.description-header',
defaultMessage: 'Sign in to a Microsoft account',
},
description: {
id: 'minecraft-required.description',
defaultMessage:
'You need a Microsoft account that owns Minecraft before you can launch and play.',
},
getSupport: {
id: 'minecraft-required.get-support',
defaultMessage: 'Get support',
},
signIn: {
id: 'minecraft-required.sign-in',
defaultMessage: 'Sign in to Microsoft',
},
dontHaveAccount: {
id: 'minecraft-required.dont-have-account',
defaultMessage: 'Dont have an account?',
},
getMinecraft: {
id: 'minecraft-required.get-minecraft',
defaultMessage: 'Get Minecraft',
},
})
const modal = ref<InstanceType<typeof NewModal>>()
const loadingSignIn = ref(false)
function show() {
modal.value?.show()
}
async function signIn() {
loadingSignIn.value = true
try {
const loggedIn = await loginFlow()
if (!loggedIn) return
await set_default_user(loggedIn.profile.id)
await accountsCard.value?.refreshValues()
await trackEvent('AccountLogIn', { source: 'MinecraftRequiredModal' })
modal.value?.hide()
} catch (error) {
handleSevereError(error)
} finally {
loadingSignIn.value = false
}
}
defineExpose({
show,
})
</script>
@@ -1,43 +0,0 @@
<script setup lang="ts">
import { LogInIcon, SpinnerIcon } from '@modrinth/assets'
import { ref } from 'vue'
import ModalWrapper from '@/components/ui/modal/ModalWrapper.vue'
defineProps({
onFlowCancel: {
type: Function,
default() {
return async () => {}
},
},
})
const modal = ref()
function show() {
modal.value.show()
}
function hide() {
modal.value.hide()
}
defineExpose({ show, hide })
</script>
<template>
<ModalWrapper ref="modal" @hide="onFlowCancel">
<template #title>
<span class="items-center gap-2 text-lg font-extrabold text-contrast">
<LogInIcon /> Sign in
</span>
</template>
<div class="flex justify-center gap-2">
<SpinnerIcon class="w-12 h-12 animate-spin" />
</div>
<p class="text-sm text-secondary">
Please sign in at the browser window that just opened to continue.
</p>
</ModalWrapper>
</template>
@@ -4,8 +4,8 @@
:header="formatMessage(messages.installToPlay)"
:closable="true"
:on-hide="show_ads_window"
max-width="640px"
width="640px"
max-width="544px"
width="544px"
>
<div v-if="requiredContentProject" class="flex w-full flex-col gap-6">
<p class="m-0 text-primary">
@@ -423,8 +423,8 @@ const messages = defineMessages({
defaultMessage: 'Install anyway',
},
dontInstall: {
id: 'app.modal.install-to-play.dont-install',
defaultMessage: 'Dont install',
id: 'app.modal.install-to-play.external-files-dont-install',
defaultMessage: "Don't install",
},
})
@@ -6,6 +6,7 @@ import {
CoffeeIcon,
InfoIcon,
MonitorIcon,
UsersIcon,
WrenchIcon,
} from '@modrinth/assets'
import {
@@ -25,6 +26,7 @@ import GeneralSettings from '@/components/ui/instance_settings/GeneralSettings.v
import HooksSettings from '@/components/ui/instance_settings/HooksSettings.vue'
import InstallationSettings from '@/components/ui/instance_settings/InstallationSettings.vue'
import JavaSettings from '@/components/ui/instance_settings/JavaSettings.vue'
import SharingSettings from '@/components/ui/instance_settings/SharingSettings.vue'
import WindowSettings from '@/components/ui/instance_settings/WindowSettings.vue'
import { get_project_v3 } from '@/helpers/cache'
import { get_linked_modpack_info } from '@/helpers/instance'
@@ -97,6 +99,15 @@ const tabs = computed<TabbedModalTab[]>(() => [
icon: WrenchIcon,
content: InstallationSettings,
},
{
name: defineMessage({
id: 'instance.settings.tabs.sharing',
defaultMessage: 'Sharing',
}),
icon: UsersIcon,
content: SharingSettings,
shown: props.instance.shared_instance?.role === 'owner' && !props.instance.quarantined,
},
{
name: defineMessage({
id: 'instance.settings.tabs.window',
@@ -0,0 +1,265 @@
<template>
<NewModal
ref="modal"
:header="formatMessage(authenticating ? messages.signingInHeader : messages.header)"
:on-hide="handleHide"
no-padding
max-width="548px"
width="100%"
>
<div v-if="authenticating === null" class="flex w-full flex-col gap-6 p-6">
<div class="flex flex-col gap-2 px-3">
<h2 class="m-0 text-xl font-semibold leading-7 text-contrast">
{{ formatMessage(messages.signInHeading) }}
</h2>
<p class="m-0 text-base leading-6 text-primary">
{{ formatMessage(messages.description) }}
</p>
</div>
<div class="flex flex-col gap-6">
<div class="grid grid-cols-1 gap-2 sm:grid-cols-2">
<ButtonStyled>
<button class="w-full !shadow-none" type="button" @click="authenticate('sign-up')">
<UserPlusIcon aria-hidden="true" />
{{ formatMessage(messages.createAccountButton) }}
</button>
</ButtonStyled>
<ButtonStyled color="brand">
<button class="w-full" type="button" @click="authenticate('sign-in')">
<LogInIcon aria-hidden="true" />
{{ formatMessage(messages.signInButton) }}
</button>
</ButtonStyled>
</div>
<p class="m-0 text-center text-base font-medium leading-6 text-primary">
<IntlFormatted :message-id="messages.supportPrompt">
<template #support="{ children }">
<button
type="button"
class="inline cursor-pointer border-0 bg-transparent p-0 text-base font-medium leading-6 text-blue hover:underline"
@click="openSupport"
>
<component :is="() => children" />
</button>
</template>
</IntlFormatted>
</p>
</div>
</div>
<div v-else class="flex w-full flex-col gap-6 p-6">
<div class="flex flex-col gap-2.5 px-3">
<div class="flex flex-col gap-2">
<h2 class="m-0 text-xl font-semibold leading-7 text-contrast">
{{ formatMessage(messages.continueInBrowserHeading) }}
</h2>
<p class="m-0 text-base leading-6 text-primary">
{{ formatMessage(messages.browserDescription) }}
</p>
</div>
<div class="flex items-center gap-1.5 text-primary">
<SpinnerIcon aria-hidden="true" class="h-5 w-5 shrink-0 animate-spin" />
<span class="text-base leading-6">
{{ formatMessage(messages.waitingForBrowser) }}
</span>
</div>
</div>
<div class="flex flex-col gap-6">
<div class="grid grid-cols-1 gap-2 sm:grid-cols-2">
<ButtonStyled type="outlined">
<button class="w-full" type="button" @click="modal?.hide()">
<XIcon aria-hidden="true" />
{{ formatMessage(messages.cancelButton) }}
</button>
</ButtonStyled>
<ButtonStyled>
<button
class="w-full !shadow-none"
type="button"
:disabled="reopeningBrowser"
@click="reopenBrowser"
>
<RefreshCwIcon aria-hidden="true" />
{{ formatMessage(messages.openBrowserAgainButton) }}
</button>
</ButtonStyled>
</div>
<p class="m-0 text-center text-base font-medium leading-6 text-primary">
<IntlFormatted :message-id="messages.supportPrompt">
<template #support="{ children }">
<button
type="button"
class="inline cursor-pointer border-0 bg-transparent p-0 text-base font-medium leading-6 text-blue hover:underline"
@click="openSupport"
>
<component :is="() => children" />
</button>
</template>
</IntlFormatted>
</p>
</div>
</div>
</NewModal>
</template>
<script setup lang="ts">
import { LogInIcon, RefreshCwIcon, SpinnerIcon, UserPlusIcon, XIcon } from '@modrinth/assets'
import { ButtonStyled, defineMessages, IntlFormatted, NewModal, useVIntl } from '@modrinth/ui'
import { openUrl } from '@tauri-apps/plugin-opener'
import { ref } from 'vue'
import { cancelLogin, type ModrinthAuthFlow } from '@/helpers/mr_auth'
const props = defineProps<{
requestAuth: (flow: ModrinthAuthFlow) => Promise<boolean>
}>()
const { formatMessage } = useVIntl()
const modal = ref<InstanceType<typeof NewModal>>()
const authenticating = ref<ModrinthAuthFlow | null>(null)
const reopeningBrowser = ref(false)
let resolveShow: ((signedIn: boolean) => void) | undefined
let authenticationId = 0
let activeAuthentication: Promise<void> | undefined
function show(event?: MouseEvent) {
resetAuthentication(true)
resolveShow?.(false)
const modalInstance = modal.value
if (!modalInstance) return Promise.resolve(false)
return new Promise<boolean>((resolve) => {
resolveShow = resolve
modalInstance.show(event)
})
}
function showSigningIn(flow: ModrinthAuthFlow = 'sign-in', event?: MouseEvent) {
const result = show(event)
authenticate(flow)
return result
}
function finish(signedIn: boolean) {
resolveShow?.(signedIn)
resolveShow = undefined
}
function authenticate(flow: ModrinthAuthFlow) {
const id = ++authenticationId
authenticating.value = flow
const authentication = (async () => {
try {
if ((await props.requestAuth(flow)) && authenticationId === id) {
authenticating.value = null
activeAuthentication = undefined
finish(true)
modal.value?.hide()
}
} finally {
if (authenticationId === id) {
authenticating.value = null
activeAuthentication = undefined
}
}
})()
activeAuthentication = authentication
}
async function reopenBrowser() {
const flow = authenticating.value
if (!flow || reopeningBrowser.value) return
reopeningBrowser.value = true
const previousAuthentication = activeAuthentication
++authenticationId
try {
await cancelLogin()
await previousAuthentication?.catch(() => undefined)
if (authenticating.value === flow) authenticate(flow)
} finally {
reopeningBrowser.value = false
}
}
function resetAuthentication(cancelActive: boolean) {
const wasAuthenticating = authenticating.value !== null
++authenticationId
activeAuthentication = undefined
authenticating.value = null
reopeningBrowser.value = false
if (cancelActive && wasAuthenticating) void cancelLogin()
}
function handleHide() {
resetAuthentication(true)
finish(false)
}
function openSupport() {
openUrl('https://support.modrinth.com')
}
const messages = defineMessages({
header: {
id: 'modal.modrinth-account-required.header',
defaultMessage: 'Account required',
},
signingInHeader: {
id: 'modal.modrinth-account-required.signing-in-header',
defaultMessage: 'Signing in',
},
signInHeading: {
id: 'modal.modrinth-account-required.sign-in-heading',
defaultMessage: 'Sign in to a Modrinth account',
},
description: {
id: 'modal.modrinth-account-required.description',
defaultMessage:
"You'll need to sign into your Modrinth account before you can use this feature.",
},
createAccountButton: {
id: 'modal.modrinth-account-required.create-account-button',
defaultMessage: 'Create an account',
},
signInButton: {
id: 'modal.modrinth-account-required.sign-in-button',
defaultMessage: 'Sign in to Modrinth',
},
continueInBrowserHeading: {
id: 'modal.modrinth-account-required.continue-in-browser-heading',
defaultMessage: 'Continue in your browser',
},
browserDescription: {
id: 'modal.modrinth-account-required.browser-description',
defaultMessage:
'A new tab opened to sign in. Complete the sign in there, then return to the app.',
},
waitingForBrowser: {
id: 'modal.modrinth-account-required.waiting-for-browser',
defaultMessage: 'Waiting for browser confirmation...',
},
cancelButton: {
id: 'modal.modrinth-account-required.cancel-button',
defaultMessage: 'Cancel',
},
openBrowserAgainButton: {
id: 'modal.modrinth-account-required.open-browser-again-button',
defaultMessage: 'Open browser again',
},
supportPrompt: {
id: 'modal.modrinth-account-required.support-prompt',
defaultMessage: 'Having trouble signing in? <support>Get support</support>',
},
})
defineExpose({ show, showSigningIn })
</script>
@@ -8,6 +8,7 @@
:diffs="normalizedDiffs"
:version-date="versionDate"
:show-external-warnings="showExternalWarnings"
:external-warning-description="formatMessage(messages.externalWarningDescription)"
:confirm-label="formatMessage(commonMessages.updateButton)"
:confirm-icon="DownloadIcon"
:removed-label="formatMessage(messages.removed)"
@@ -76,10 +77,15 @@ const { formatMessage } = useVIntl()
const { startInstallingServer, stopInstallingServer } = injectServerInstall()
type UpdateCompleteCallback = () => void | Promise<void>
defineProps<{
const { showExternalWarnings } = defineProps<{
showExternalWarnings?: boolean
}>()
const emit = defineEmits<{
cancel: []
complete: []
}>()
const diffModal = ref<InstanceType<typeof ContentDiffModal>>()
const instance = ref<GameInstance | null>(null)
const onUpdateComplete = ref<UpdateCompleteCallback>(() => {})
@@ -87,16 +93,16 @@ const diffs = ref<DependencyDiff[]>([])
const modpackVersionId = ref<string | null>(null)
const modpackVersion = ref<Version | null>(null)
const normalizedDiffs = computed<ContentDiffItem[]>(() =>
diffs.value.map((diff) => ({
const normalizedDiffs = computed<ContentDiffItem[]>(() => {
return diffs.value.map((diff) => ({
type: diff.type,
external: Boolean(diff.fileName && !diff.project),
projectName: diff.project?.title,
fileName: diff.fileName,
currentVersionName: diff.currentVersion?.version_number,
newVersionName: diff.newVersion?.version_number,
})),
)
}))
})
const versionDate = computed(() =>
modpackVersion.value?.date_published
@@ -242,7 +248,6 @@ async function checkUpdateAvailable(inst: GameInstance): Promise<DependencyDiff[
}
async function handleUpdate() {
hide()
const serverProjectId = instance.value?.link?.project_id
if (serverProjectId) startInstallingServer(serverProjectId)
try {
@@ -255,11 +260,12 @@ async function handleUpdate() {
console.error('Error updating instance:', error)
} finally {
if (serverProjectId) stopInstallingServer(serverProjectId)
emit('complete')
}
}
function handleDecline() {
hide()
emit('cancel')
}
function show(
@@ -293,12 +299,17 @@ const messages = defineMessages({
updateRequiredDescription: {
id: 'app.modal.update-to-play.update-required-description',
defaultMessage:
'An update is required to play {name}. Please update to the latest version to launch the game.',
'An update is required to play {name}. Please update to latest version to launch the game.',
},
removed: {
id: 'app.modal.update-to-play.removed',
defaultMessage: 'Removed',
},
externalWarningDescription: {
id: 'app.modal.update-to-play.server-modpack-unknown-files-description',
defaultMessage:
'This server modpack update contains files that arent published on Modrinth. We strongly recommend only installing files from sources you trust.',
},
})
const hasUpdate = computed(() => {
@@ -1,12 +1,44 @@
<script setup lang="ts">
import { Toggle } from '@modrinth/ui'
import { Settings2Icon } from '@modrinth/assets'
import {
ButtonStyled,
defineMessages,
injectNotificationManager,
injectPageContext,
Toggle,
useVIntl,
} from '@modrinth/ui'
import { ref, watch } from 'vue'
import { open_ads_consent_preferences } from '@/helpers/ads.js'
import { optInAnalytics, optOutAnalytics } from '@/helpers/analytics'
import { get, set } from '@/helpers/settings.ts'
const { formatMessage } = useVIntl()
const { handleError } = injectNotificationManager()
const { adConsentAvailable } = injectPageContext()
const settings = ref(await get())
const messages = defineMessages({
adsConsentTitle: {
id: 'app.ads-consent.title',
defaultMessage: 'Your privacy and how ads support Modrinth',
},
adsConsentIntro: {
id: 'app.settings.privacy.ads-consent.intro',
defaultMessage:
'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.',
},
adsConsentManage: {
id: 'app.ads-consent.manage',
defaultMessage: 'Manage preferences',
},
})
async function manageAdsPreferences() {
await open_ads_consent_preferences().catch(handleError)
}
watch(
settings,
async () => {
@@ -23,18 +55,26 @@ watch(
</script>
<template>
<div class="flex items-center justify-between gap-4">
<div>
<h2 class="m-0 text-lg font-semibold text-contrast">Personalized ads</h2>
<p class="m-0 mt-1 text-sm">
Modrinth's ad provider, Aditude, shows ads based on your preferences. By disabling this
option, you opt out and ads will no longer be shown based on your interests.
</p>
<div v-if="adConsentAvailable">
<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>
<ButtonStyled>
<button class="!shadow-none" @click="manageAdsPreferences">
<Settings2Icon aria-hidden="true" />
{{ formatMessage(messages.adsConsentManage) }}
</button>
</ButtonStyled>
</div>
<Toggle id="personalized-ads" v-model="settings.personalized_ads" />
</div>
<div class="mt-4 flex items-center justify-between gap-4">
<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">
@@ -0,0 +1,100 @@
<template>
<NewModal ref="modal" :header="formatMessage(messages.header)" fade="warning" max-width="500px">
<p class="m-0 text-secondary">
<IntlFormatted :message-id="messages.body" :values="{ instanceName }">
<template #bold="{ children }">
<span class="font-medium text-contrast"><component :is="() => children" /></span>
</template>
</IntlFormatted>
</p>
<template #actions>
<div class="flex justify-end gap-2">
<ButtonStyled type="outlined">
<button @click="handleCancel">
<XIcon />
{{ formatMessage(commonMessages.cancelButton) }}
</button>
</ButtonStyled>
<ButtonStyled>
<button @click="handleGoToInstance">
{{ formatMessage(messages.instance) }}
<RightArrowIcon />
</button>
</ButtonStyled>
<ButtonStyled color="orange">
<button @click="handleInstallAnyway">
<DownloadIcon />
{{ formatMessage(messages.installAnyway) }}
</button>
</ButtonStyled>
</div>
</template>
</NewModal>
</template>
<script setup lang="ts">
import { DownloadIcon, RightArrowIcon, XIcon } from '@modrinth/assets'
import {
ButtonStyled,
commonMessages,
defineMessages,
IntlFormatted,
NewModal,
useVIntl,
} from '@modrinth/ui'
import { ref } from 'vue'
const { formatMessage } = useVIntl()
const messages = defineMessages({
header: {
id: 'app.instance.shared-instance-already-installed.header',
defaultMessage: 'Shared instance already installed',
},
body: {
id: 'app.instance.shared-instance-already-installed.body',
defaultMessage:
'This shared instance is already installed as <bold>{instanceName}</bold>. Are you sure you want to install another copy?',
},
instance: {
id: 'app.instance.shared-instance-already-installed.instance',
defaultMessage: 'Instance',
},
installAnyway: {
id: 'app.instance.shared-instance-already-installed.install-anyway',
defaultMessage: 'Install anyway',
},
})
const emit = defineEmits<{
(e: 'cancel' | 'go-to-instance' | 'install-anyway'): void
}>()
const modal = ref<InstanceType<typeof NewModal>>()
const instanceName = ref('')
function show(name: string) {
instanceName.value = name
modal.value?.show()
}
function handleCancel() {
modal.value?.hide()
emit('cancel')
}
function handleGoToInstance() {
modal.value?.hide()
emit('go-to-instance')
}
function handleInstallAnyway() {
modal.value?.hide()
emit('install-anyway')
}
defineExpose({
show,
})
</script>
@@ -0,0 +1,195 @@
<template>
<div v-if="canUnpublish" class="flex flex-col gap-2.5">
<span class="text-lg font-semibold text-contrast">{{ formatMessage(messages.title) }}</span>
<div>
<ButtonStyled color="orange">
<button :disabled="busy" @click="unpublishModal?.show()">
<SpinnerIcon v-if="unpublishing" class="animate-spin" />
<UnlinkIcon v-else class="size-5" />
{{ formatMessage(unpublishing ? messages.unpublishingButton : messages.unpublishButton) }}
</button>
</ButtonStyled>
</div>
<span class="text-primary">{{ formatMessage(messages.unpublishDescription) }}</span>
</div>
<div v-if="canUnlink" class="flex flex-col gap-2.5">
<span class="text-lg font-semibold text-contrast">{{
formatMessage(messages.linkedTitle)
}}</span>
<div>
<ButtonStyled color="orange">
<button :disabled="busy" @click="unlinkModal?.show()">
<SpinnerIcon v-if="unlinking" class="animate-spin" />
<UnlinkIcon v-else class="size-5" />
{{ formatMessage(unlinking ? messages.unlinkingButton : messages.unlinkButton) }}
</button>
</ButtonStyled>
</div>
<span class="text-primary">{{ formatMessage(messages.unlinkDescription) }}</span>
</div>
<NewModal
ref="unpublishModal"
:header="formatMessage(messages.unpublishModalHeader)"
fade="warning"
max-width="500px"
>
<Admonition type="warning" :header="formatMessage(messages.unpublishModalAdmonitionHeader)">{{
formatMessage(messages.unpublishModalBody)
}}</Admonition>
<template #actions
><div class="flex justify-end gap-2">
<ButtonStyled type="outlined"
><button class="!border" @click="unpublishModal?.hide()">
<XIcon />{{ formatMessage(commonMessages.cancelButton) }}
</button></ButtonStyled
><ButtonStyled color="orange"
><button :disabled="busy" @click="confirmUnpublish">
<UnlinkIcon />{{ formatMessage(messages.unpublishButton) }}
</button></ButtonStyled
>
</div></template
>
</NewModal>
<NewModal
ref="unlinkModal"
:header="formatMessage(messages.unlinkModalHeader)"
fade="warning"
max-width="500px"
:on-hide="() => backupCreator?.cancelBackup()"
>
<div class="flex flex-col gap-6">
<Admonition type="warning" :header="formatMessage(messages.unlinkModalAdmonitionHeader)">{{
formatMessage(messages.unlinkModalBody)
}}</Admonition>
<InlineBackupCreator
ref="backupCreator"
backup-name="Before unlinking shared instance"
@update:buttons-disabled="backupBusy = $event"
/>
</div>
<template #actions
><div class="flex justify-end gap-2">
<ButtonStyled type="outlined"
><button class="!border" @click="unlinkModal?.hide()">
<XIcon />{{ formatMessage(commonMessages.cancelButton) }}
</button></ButtonStyled
><ButtonStyled color="orange"
><button :disabled="busy || backupBusy" @click="confirmUnlink">
<UnlinkIcon />{{ formatMessage(messages.unlinkButton) }}
</button></ButtonStyled
>
</div></template
>
</NewModal>
</template>
<script setup lang="ts">
import { SpinnerIcon, UnlinkIcon, XIcon } from '@modrinth/assets'
import {
Admonition,
ButtonStyled,
commonMessages,
defineMessages,
InlineBackupCreator,
NewModal,
useVIntl,
} from '@modrinth/ui'
import { ref } from 'vue'
const props = withDefaults(
defineProps<{
canUnpublish?: boolean
canUnlink?: boolean
busy: boolean
unpublishing?: boolean
unlinking?: boolean
unpublish?: () => Promise<void>
unlink?: () => Promise<void>
}>(),
{
canUnpublish: false,
canUnlink: false,
unpublishing: false,
unlinking: false,
unpublish: undefined,
unlink: undefined,
},
)
const { formatMessage } = useVIntl()
const unpublishModal = ref<InstanceType<typeof NewModal>>()
const unlinkModal = ref<InstanceType<typeof NewModal>>()
const backupCreator = ref<InstanceType<typeof InlineBackupCreator>>()
const backupBusy = ref(false)
async function confirmUnpublish() {
unpublishModal.value?.hide()
await props.unpublish?.()
}
async function confirmUnlink() {
unlinkModal.value?.hide()
await props.unlink?.()
}
const messages = defineMessages({
title: {
id: 'installation-settings.shared-instance.title',
defaultMessage: 'Unpublish instance',
},
linkedTitle: {
id: 'installation-settings.shared-instance.linked-title',
defaultMessage: 'Linked shared instance',
},
unpublishButton: {
id: 'installation-settings.shared-instance.unpublish-button',
defaultMessage: 'Unpublish shared instance',
},
unpublishingButton: {
id: 'installation-settings.shared-instance.unpublishing-button',
defaultMessage: 'Unpublishing...',
},
unpublishDescription: {
id: 'installation-settings.shared-instance.unpublish-description',
defaultMessage:
'Remove this shared instance from Modrinth and stop sending updates to anyone using it. Your local instance will not be affected.',
},
unlinkButton: {
id: 'installation-settings.shared-instance.unlink-button',
defaultMessage: 'Unlink shared instance',
},
unlinkingButton: {
id: 'installation-settings.shared-instance.unlinking-button',
defaultMessage: 'Unlinking...',
},
unlinkDescription: {
id: 'installation-settings.shared-instance.unlink-description',
defaultMessage: 'Disconnect this local instance from future shared updates.',
},
unpublishModalHeader: {
id: 'installation-settings.unpublish-shared-instance.modal.header',
defaultMessage: 'Unpublish shared instance',
},
unpublishModalAdmonitionHeader: {
id: 'installation-settings.unpublish-shared-instance.modal.admonition-header',
defaultMessage: 'Unpublishing shared instance',
},
unpublishModalBody: {
id: 'installation-settings.unpublish-shared-instance.modal.admonition-body',
defaultMessage:
"This deletes the shared instance from Modrinth's servers. People using it in the Modrinth App will stop receiving updates, but your local instance and its content will stay on this device.",
},
unlinkModalHeader: {
id: 'installation-settings.unlink-shared-instance.modal.header',
defaultMessage: 'Unlink shared instance',
},
unlinkModalAdmonitionHeader: {
id: 'installation-settings.unlink-shared-instance.modal.admonition-header',
defaultMessage: 'Unlinking shared instance',
},
unlinkModalBody: {
id: 'installation-settings.unlink-shared-instance.modal.admonition-body',
defaultMessage:
'This only affects your local instance. Your installed content will stay on this device, and the shared instance and other people using it will not be affected.',
},
})
</script>
@@ -0,0 +1,189 @@
<template>
<ContentDiffModal
ref="publishReviewModal"
:header="formatMessage(messages.header)"
:admonition-header="formatMessage(messages.admonitionHeader)"
:description="formatMessage(messages.description)"
:diffs="publishDiffs"
:confirm-label="formatMessage(messages.publishButton)"
:confirm-icon="UploadIcon"
:confirm-disabled="publishDiffs.length === 0 && selectedConfigPaths.length === 0"
:added-label="formatMessage(messages.addedLabel)"
:removed-label="formatMessage(messages.removedLabel)"
@confirm="publishChanges"
@cancel="finishReview"
>
<template #additional-content>
<Accordion
class="min-w-0"
overflow-visible
button-class="w-full bg-transparent m-0 p-0 border-none"
>
<template #title>
<FileCogIcon class="size-4 shrink-0 text-primary" />
<span class="text-lg font-semibold text-contrast">
{{ formatMessage(messages.configTitle) }}
</span>
</template>
<div class="flex min-w-0 flex-col gap-3 pt-4">
<div class="max-h-[292px] overflow-y-auto rounded-[20px]">
<FileTreeSelect
v-model="selectedConfigPaths"
:items="configFileItems"
:show-size="false"
:show-modified="false"
/>
</div>
</div>
</Accordion>
</template>
</ContentDiffModal>
</template>
<script setup lang="ts">
import { FileCogIcon, UploadIcon } from '@modrinth/assets'
import {
Accordion,
type ContentDiffItem,
ContentDiffModal,
defineMessages,
FileTreeSelect,
type FileTreeSelectItem,
useVIntl,
} from '@modrinth/ui'
import { computed, ref } from 'vue'
import {
getSharedInstanceUnavailableReason,
isSharedInstanceUnavailableError,
} from '@/helpers/install'
import { get_shared_instance_publish_preview, publish_shared_instance } from '@/helpers/instance'
import { useSharedInstanceErrors } from '@/helpers/shared-instance-errors'
import type { GameInstance } from '@/helpers/types'
type SharedInstancePublishState = 'idle' | 'reviewing' | 'publishing'
const props = defineProps<{
instance: GameInstance
}>()
const emit = defineEmits<{
published: []
'state-change': [state: SharedInstancePublishState]
}>()
const { formatMessage } = useVIntl()
const { notifySharedInstanceError, notifySharedInstanceUnavailable } = useSharedInstanceErrors()
const publishReviewModal = ref<InstanceType<typeof ContentDiffModal>>()
const publishDiffs = ref<ContentDiffItem[]>([])
const configFilePaths = ref<string[]>([])
const selectedConfigPaths = ref<string[]>([])
const state = ref<SharedInstancePublishState>('idle')
const configFileItems = computed<FileTreeSelectItem[]>(() =>
configFilePaths.value.map((path) => ({ path, type: 'file' })),
)
async function show(e?: MouseEvent) {
if (state.value !== 'idle') return
setState('reviewing')
let reviewOpened = false
try {
const preview = await get_shared_instance_publish_preview(props.instance.id)
if (!preview) {
notifySharedInstanceUnavailable()
emit('published')
return
}
publishDiffs.value = preview.diffs.map((diff) => ({
type: diff.type,
projectName: diff.projectName ?? undefined,
fileName: diff.fileName ?? undefined,
currentVersionName: diff.currentVersionName ?? undefined,
newVersionName: diff.newVersionName ?? undefined,
fileCount: diff.configFileCount ?? undefined,
disabled: diff.disabled,
}))
configFilePaths.value = preview.configFiles
selectedConfigPaths.value = []
if (!publishReviewModal.value) return
publishReviewModal.value.show(e)
reviewOpened = true
} catch (error) {
handlePublishError(error)
} finally {
if (!reviewOpened) finishReview()
}
}
async function publishChanges() {
if (state.value !== 'reviewing') return
setState('publishing')
try {
await publish_shared_instance(props.instance.id, selectedConfigPaths.value)
emit('published')
} catch (error) {
handlePublishError(error)
} finally {
setState('idle')
}
}
function finishReview() {
if (state.value === 'reviewing') {
setState('idle')
}
}
function handlePublishError(error: unknown) {
if (isSharedInstanceUnavailableError(error)) {
notifySharedInstanceUnavailable(getSharedInstanceUnavailableReason(error))
emit('published')
return
}
notifySharedInstanceError(error)
}
function setState(nextState: SharedInstancePublishState) {
state.value = nextState
emit('state-change', nextState)
}
const messages = defineMessages({
header: {
id: 'instance.shared-instance.publish-review.header',
defaultMessage: 'Review changes',
},
admonitionHeader: {
id: 'instance.shared-instance.publish-review.admonition-header',
defaultMessage: 'Push update to players',
},
description: {
id: 'instance.shared-instance.publish-review.description',
defaultMessage:
'Review the content changes and choose any config files to include in this update.',
},
publishButton: {
id: 'instance.shared-instance.publish-review.publish-button',
defaultMessage: 'Push update',
},
addedLabel: {
id: 'instance.shared-instance.publish-review.added-label',
defaultMessage: 'Added',
},
removedLabel: {
id: 'instance.shared-instance.publish-review.removed-label',
defaultMessage: 'Removed',
},
configTitle: {
id: 'instance.shared-instance.publish-review.config-title-v2',
defaultMessage: 'Select config files',
},
})
defineExpose({ show })
</script>
@@ -0,0 +1,134 @@
<template>
<ContentDiffModal
ref="modal"
:header="formatMessage(messages.updateToPlay)"
:admonition-header="formatMessage(messages.updateRequired)"
:description="instance ? formatMessage(messages.description, { name: instance.name }) : ''"
:diffs="diffs"
:confirm-label="formatMessage(commonMessages.updateButton)"
:confirm-icon="DownloadIcon"
:added-label="formatMessage(messages.addedLabel)"
:removed-label="formatMessage(messages.removedLabel)"
show-report-button
show-external-warnings
:external-warning-description="formatMessage(messages.externalWarningDescription)"
@confirm="update"
@cancel="emit('cancel')"
@report="emit('report', $event)"
/>
</template>
<script setup lang="ts">
import { DownloadIcon } from '@modrinth/assets'
import {
commonMessages,
type ContentDiffItem,
ContentDiffModal,
defineMessages,
useVIntl,
} from '@modrinth/ui'
import { computed, ref } from 'vue'
import {
getSharedInstanceUnavailableReason,
install_update_shared_instance,
isSharedInstanceUnavailableError,
type SharedInstanceUnavailableReason,
type SharedInstanceUpdatePreview,
wait_for_install_job,
} from '@/helpers/install'
import { useSharedInstanceErrors } from '@/helpers/shared-instance-errors'
import type { GameInstance } from '@/helpers/types'
type UpdateCompleteCallback = () => void | Promise<void>
const emit = defineEmits<{
cancel: []
complete: []
report: [event?: MouseEvent]
sharedInstanceUnavailable: [reason: SharedInstanceUnavailableReason | null]
}>()
const modal = ref<InstanceType<typeof ContentDiffModal>>()
const instance = ref<GameInstance | null>(null)
const preview = ref<SharedInstanceUpdatePreview | null>(null)
const onComplete = ref<UpdateCompleteCallback>(() => {})
const { formatMessage } = useVIntl()
const { notifySharedInstanceError } = useSharedInstanceErrors()
const diffs = computed<ContentDiffItem[]>(
() =>
preview.value?.diffs
// TODO: This is TEMP!!! Hashing needs to be done on backend
.filter((diff) => !(diff.type === 'updated' && !diff.projectId && diff.fileName))
.map((diff) => ({
type: diff.type,
projectName: diff.projectName ?? undefined,
fileName: diff.fileName ?? undefined,
currentVersionName: diff.currentVersionName ?? undefined,
newVersionName: diff.newVersionName ?? undefined,
fileCount: diff.configFileCount ?? undefined,
disabled: diff.disabled,
external: diff.type === 'added' && !diff.projectId && !!diff.fileName,
})) ?? [],
)
async function update() {
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()
}
} catch (error) {
if (isSharedInstanceUnavailableError(error)) {
emit('sharedInstanceUnavailable', getSharedInstanceUnavailableReason(error))
return
}
notifySharedInstanceError(error)
} finally {
emit('complete')
}
}
function show(
instanceValue: GameInstance,
previewValue: SharedInstanceUpdatePreview,
callback: UpdateCompleteCallback = () => {},
event?: MouseEvent,
) {
instance.value = instanceValue
preview.value = previewValue
onComplete.value = callback
modal.value?.show(event)
}
function hide() {
modal.value?.hide()
}
const messages = defineMessages({
updateToPlay: { id: 'app.modal.update-to-play.header', defaultMessage: 'Update to play' },
updateRequired: {
id: 'app.modal.update-to-play.update-required',
defaultMessage: 'Update required',
},
description: {
id: 'app.modal.update-to-play.update-required-description',
defaultMessage:
'An update is required to play {name}. Please update to latest version to launch the game.',
},
addedLabel: {
id: 'app.modal.update-to-play.shared-instance-added-label',
defaultMessage: 'Added',
},
removedLabel: {
id: 'app.modal.update-to-play.shared-instance-removed-label',
defaultMessage: 'Removed',
},
externalWarningDescription: {
id: 'app.modal.update-to-play.shared-instance-unknown-files-description',
defaultMessage:
'This shared instance update contains files that arent published on Modrinth. We strongly recommend only installing files from sources you trust.',
},
})
defineExpose({ show, hide })
</script>
@@ -0,0 +1,533 @@
<template>
<NewModal
ref="modal"
:header="
reportMode
? formatMessage(messages.reportSharedInstance)
: formatMessage(messages.installToPlay)
"
:closable="!submitLoading"
:on-hide="handleHide"
:max-width="reportMode ? '816px' : '544px'"
:width="reportMode ? '816px' : '544px'"
:no-padding="reportMode"
:scrollable="reportMode"
>
<div v-if="preview" class="flex w-full flex-col gap-6" :class="{ 'p-6 pb-2': reportMode }">
<Admonition v-if="reportMode" type="info">
<div class="flex flex-col gap-2">
<p class="m-0">
<IntlFormatted :message-id="messages.reportDescription">
<template #rules-link="{ children }">
<AutoLink class="text-link hover:underline" to="https://modrinth.com/legal/rules">
<component :is="() => children" />
</AutoLink>
</template>
<template #terms-link="{ children }">
<AutoLink class="text-link hover:underline" to="https://modrinth.com/legal/terms">
<component :is="() => children" />
</AutoLink>
</template>
</IntlFormatted>
</p>
<ul class="m-0 list-disc pl-5">
<li>
<IntlFormatted :message-id="messages.supportAndBugReports">
<template #support-link="{ children }">
<AutoLink class="text-link hover:underline" to="https://support.modrinth.com">
<component :is="() => children" />
</AutoLink>
</template>
<template #github-link="{ children }">
<AutoLink
class="text-link hover:underline"
to="https://github.com/modrinth/code/issues"
>
<component :is="() => children" />
</AutoLink>
</template>
</IntlFormatted>
</li>
<li>
<IntlFormatted :message-id="messages.legalClaims">
<template #copyright-link="{ children }">
<AutoLink
class="text-link hover:underline"
to="https://modrinth.com/legal/copyright"
>
<component :is="() => children" />
</AutoLink>
</template>
</IntlFormatted>
</li>
</ul>
</div>
</Admonition>
<p v-else class="m-0 text-primary">
{{ formatMessage(messages.inviteWarning) }}
</p>
<SharedInstanceInstallSummary
:preview="preview"
:heading="reportMode ? formatMessage(messages.contentYouAreReporting) : undefined"
@view-contents="openViewContents"
/>
<Transition
enter-active-class="overflow-hidden transition-all duration-200 ease-out"
enter-from-class="max-h-0 opacity-0"
enter-to-class="max-h-[400px] opacity-100"
leave-active-class="overflow-hidden transition-all duration-200 ease-in"
leave-from-class="max-h-[400px] opacity-100"
leave-to-class="max-h-0 opacity-0"
>
<div v-if="reportMode" class="flex flex-col gap-6">
<div class="flex flex-col gap-2.5">
<span class="font-semibold text-contrast">
{{ formatMessage(messages.reportReason) }}
</span>
<Combobox v-model="reportReason" :options="reportReasonOptions" />
</div>
<div class="flex flex-col gap-2.5">
<span class="font-semibold text-contrast">
{{ formatMessage(messages.additionalContext) }}
</span>
<MarkdownEditor
v-model="additionalContext"
:placeholder="formatMessage(messages.additionalContextPlaceholder)"
:on-image-upload="onImageUpload"
:min-height="120"
:max-height="240"
/>
</div>
<div v-if="reportOnly" class="flex flex-col gap-2">
<Checkbox v-model="deleteInstance" :label="formatMessage(messages.deleteInstance)" />
</div>
</div>
</Transition>
<Admonition
v-if="!reportMode && hasExternalFiles"
type="warning"
:header="formatMessage(messages.unknownFilesWarning)"
>
{{ formatMessage(messages.unknownFilesDescription) }}
</Admonition>
<div v-if="!reportMode && hasExternalFiles" class="relative w-full">
<div
ref="externalFileTable"
class="max-h-[242px] overflow-y-auto rounded-2xl"
@scroll="checkTableScrollState"
>
<Table
:columns="externalFileColumns"
:data="externalFileRows"
row-key="id"
virtualized
:virtual-row-height="48"
class="shadow-sm"
>
<template #cell-name="{ value }">
<span class="block truncate" :title="String(value)">{{ value }}</span>
</template>
</Table>
</div>
<Transition
enter-active-class="transition-all duration-200 ease-out"
enter-from-class="opacity-0 max-h-0"
enter-to-class="opacity-100 max-h-2"
leave-active-class="transition-all duration-200 ease-in"
leave-from-class="opacity-100 max-h-2"
leave-to-class="opacity-0 max-h-0"
>
<div
v-if="showTableTopFade"
class="pointer-events-none absolute left-0 right-0 top-0 z-10 h-2 bg-gradient-to-b from-bg-raised to-transparent"
/>
</Transition>
<Transition
enter-active-class="transition-all duration-200 ease-out"
enter-from-class="opacity-0 max-h-0"
enter-to-class="opacity-100 max-h-2"
leave-active-class="transition-all duration-200 ease-in"
leave-from-class="opacity-100 max-h-2"
leave-to-class="opacity-0 max-h-0"
>
<div
v-if="showTableBottomFade"
class="pointer-events-none absolute bottom-0 left-0 right-0 z-10 h-2 bg-gradient-to-t from-bg-raised to-transparent"
/>
</Transition>
</div>
<p v-if="!reportMode && hasExternalFiles" class="m-0 text-primary">
{{ formatMessage(messages.reviewedFiles) }}
</p>
<div v-if="!reportMode" class="flex w-full items-center justify-between gap-2">
<ButtonStyled color="red" type="transparent">
<button @click="reportMode = true">
<ReportIcon />{{ formatMessage(commonMessages.reportButton) }}
</button>
</ButtonStyled>
<div class="flex items-center gap-2">
<template v-if="hasExternalFiles">
<ButtonStyled type="transparent" color="orange">
<button @click="accept">
{{ formatMessage(messages.installAnyway) }}
</button>
</ButtonStyled>
<ButtonStyled color="brand">
<button @click="handleCancel">
<BanIcon />{{ formatMessage(messages.dontInstall) }}
</button>
</ButtonStyled>
</template>
<template v-else>
<ButtonStyled type="outlined">
<button class="!border" @click="handleCancel">
<XIcon />{{ formatMessage(commonMessages.cancelButton) }}
</button>
</ButtonStyled>
<ButtonStyled color="brand">
<button @click="accept">
<DownloadIcon />{{ formatMessage(messages.installButton) }}
</button>
</ButtonStyled>
</template>
</div>
</div>
</div>
<template v-if="reportMode" #actions>
<div class="flex justify-end gap-2">
<ButtonStyled type="outlined">
<button class="!border" :disabled="submitLoading" @click="handleCancel">
<XIcon />{{ formatMessage(commonMessages.cancelButton) }}
</button>
</ButtonStyled>
<ButtonStyled color="brand">
<button :disabled="!canSubmitReport" @click="submitReport">
<SpinnerIcon v-if="submitLoading" class="animate-spin" />
<SendIcon v-else />
{{ formatMessage(commonMessages.reportButton) }}
</button>
</ButtonStyled>
</div>
</template>
</NewModal>
<ModpackContentModal
ref="contentModal"
:header="formatMessage(messages.sharedInstanceContent)"
:modpack-name="preview?.name ?? ''"
:modpack-icon-url="preview?.iconUrl ?? undefined"
/>
</template>
<script setup lang="ts">
import type { Labrinth } from '@modrinth/api-client'
import { BanIcon, DownloadIcon, ReportIcon, SendIcon, SpinnerIcon, XIcon } from '@modrinth/assets'
import {
Admonition,
AutoLink,
ButtonStyled,
Checkbox,
Combobox,
type ComboboxOption,
commonMessages,
defineMessages,
injectModrinthClient,
injectNotificationManager,
IntlFormatted,
MarkdownEditor,
ModpackContentModal,
NewModal,
Table,
type TableColumn,
useScrollIndicator,
useVIntl,
} from '@modrinth/ui'
import { computed, nextTick, ref } from 'vue'
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 SharedInstanceInstallSummary from './shared-instance-install-summary.vue'
import { useSharedInstancePreviewContent } from './use-shared-instance-preview-content'
type ExternalFileColumn = 'name'
type ExternalFileRow = {
id: string
name: string
}
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 install = ref<() => void | Promise<void>>(() => {})
const reportMode = ref(false)
const reportOnly = ref(false)
type ReportReason = 'malicious' | 'inappropriate' | 'spam'
const reportReason = ref<ReportReason>('malicious')
const additionalContext = ref('')
const deleteInstance = ref(true)
const submitLoading = ref(false)
const uploadedImageIDs = ref<string[]>([])
const emit = defineEmits<{
reported: [deleteInstance: boolean]
}>()
const { formatMessage } = useVIntl()
const client = injectModrinthClient()
const { addNotification, handleError } = injectNotificationManager()
const { load } = useSharedInstancePreviewContent()
const {
showTopFade: showTableTopFade,
showBottomFade: showTableBottomFade,
checkScrollState: checkTableScrollState,
forceCheck: forceCheckTableScroll,
} = useScrollIndicator(externalFileTable)
const hasExternalFiles = computed(() => Boolean(preview.value?.externalFiles.length))
const externalFileRows = computed<ExternalFileRow[]>(() =>
(preview.value?.externalFiles ?? [])
.map((file, index) => ({
id: `${index}-${file.fileType}-${file.fileName}`,
name: file.fileName,
}))
.sort((left, right) => left.name.localeCompare(right.name)),
)
const reportReasonOptions = computed<ComboboxOption<ReportReason>[]>(() => [
{ value: 'malicious', label: formatMessage(messages.maliciousReason) },
{ value: 'inappropriate', label: formatMessage(messages.inappropriateReason) },
{ value: 'spam', label: formatMessage(messages.spamReason) },
])
const canSubmitReport = computed(
() => Boolean(preview.value && additionalContext.value.trim()) && !submitLoading.value,
)
async function accept() {
hide()
try {
await install.value()
} catch (error) {
console.error('Failed to install shared instance:', error)
}
}
async function openViewContents() {
if (!preview.value) return
contentModal.value?.showLoading()
try {
contentModal.value?.show(await load(preview.value))
} catch (error) {
console.error('Failed to load shared instance contents:', error)
contentModal.value?.show([])
}
}
async function submitReport() {
const reportPreview = preview.value
const body = additionalContext.value.trim()
if (!reportPreview || !body || submitLoading.value) return
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 shouldDeleteInstance = reportOnly.value && deleteInstance.value
hide()
addNotification({
type: 'success',
title: formatMessage(messages.reportSubmitted),
})
emit('reported', shouldDeleteInstance)
} catch (error) {
handleError(toError(error))
} finally {
submitLoading.value = false
}
}
async function onImageUpload(file: File) {
const imageExtensionByType: Partial<Record<string, Labrinth.Images.v3.ImageExtension>> = {
'image/gif': 'gif',
'image/jpeg': 'jpeg',
'image/png': 'png',
'image/webp': 'webp',
}
const extension = imageExtensionByType[file.type]
if (!extension) {
throw new Error(formatMessage(messages.invalidImageType))
}
if (file.size > 1024 * 1024) {
throw new Error(formatMessage(messages.imageTooLarge))
}
const image = await client.labrinth.images_v3.uploadImage(file, extension, {
context: 'report',
}).promise
uploadedImageIDs.value.push(image.id)
return image.url
}
function handleCancel() {
if (reportMode.value && !reportOnly.value) {
reportMode.value = false
void nextTick(() => forceCheckTableScroll())
return
}
hide()
}
function handleHide() {
resetReportState()
show_ads_window()
}
function resetReportState() {
reportMode.value = false
reportOnly.value = false
reportReason.value = 'malicious'
additionalContext.value = ''
deleteInstance.value = true
submitLoading.value = false
uploadedImageIDs.value = []
}
function show(
previewValue: SharedInstanceInstallPreview,
installValue: () => void | Promise<void>,
event?: MouseEvent,
) {
resetReportState()
install.value = installValue
showPreview(previewValue, event)
}
function showReport(previewValue: SharedInstanceInstallPreview, event?: MouseEvent) {
resetReportState()
reportMode.value = true
reportOnly.value = true
install.value = () => {}
showPreview(previewValue, event)
}
function showPreview(previewValue: SharedInstanceInstallPreview, event?: MouseEvent) {
preview.value = previewValue
hide_ads_window()
modal.value?.show(event)
void nextTick(() => forceCheckTableScroll())
}
function hide() {
modal.value?.hide()
}
const messages = defineMessages({
installToPlay: { id: 'app.modal.install-to-play.header', defaultMessage: 'Install to play' },
reportSharedInstance: {
id: 'app.modal.install-to-play.report-shared-instance-header',
defaultMessage: 'Report shared instance',
},
reportSubmitted: {
id: 'app.modal.install-to-play.report-submitted',
defaultMessage: 'Report submitted',
},
sharedInstanceContent: {
id: 'app.modal.install-to-play.shared-instance-content',
defaultMessage: 'Shared instance content',
},
inviteWarning: {
id: 'app.modal.install-to-play.invite-warning',
defaultMessage:
'This invite was created by another Modrinth user, not Modrinth. Only accept invites from people you trust.',
},
reportDescription: {
id: 'app.modal.install-to-play.report-description',
defaultMessage:
'Use this form to report instances that may violate our <rules-link>Rules</rules-link> or <terms-link>Terms of Use</terms-link>.',
},
supportAndBugReports: {
id: 'app.modal.install-to-play.report-support-and-bugs',
defaultMessage:
'For support requests, contact our <support-link>support team</support-link>. For bug reports, open a <github-link>GitHub issue</github-link>.',
},
legalClaims: {
id: 'app.modal.install-to-play.report-legal-claims',
defaultMessage:
'For DMCA notices or other legal claims, see our <copyright-link>Copyright Policy</copyright-link>.',
},
contentYouAreReporting: {
id: 'app.modal.install-to-play.content-you-are-reporting',
defaultMessage: 'Instance youre reporting',
},
reportReason: {
id: 'app.modal.install-to-play.report-reason',
defaultMessage: 'Which rule does this instance violate?',
},
maliciousReason: {
id: 'app.modal.install-to-play.report-reason.malicious',
defaultMessage: 'Malicious',
},
inappropriateReason: {
id: 'app.modal.install-to-play.report-reason.inappropriate',
defaultMessage: 'Inappropriate',
},
spamReason: {
id: 'app.modal.install-to-play.report-reason.spam',
defaultMessage: 'Spam',
},
additionalContext: {
id: 'app.modal.install-to-play.additional-context',
defaultMessage: 'Additional context',
},
additionalContextPlaceholder: {
id: 'app.modal.install-to-play.additional-context-placeholder',
defaultMessage: 'Include links and images if possible and relevant',
},
invalidImageType: {
id: 'app.modal.install-to-play.report-image-invalid-type',
defaultMessage: 'File is not an accepted image type',
},
imageTooLarge: {
id: 'app.modal.install-to-play.report-image-too-large',
defaultMessage: 'File exceeds the 1 MiB size limit',
},
deleteInstance: {
id: 'app.modal.install-to-play.delete-instance',
defaultMessage: 'Delete instance',
},
unknownFilesWarning: {
id: 'app.modal.install-to-play.unknown-files-warning',
defaultMessage: 'Unknown files warning',
},
unknownFilesDescription: {
id: 'app.modal.install-to-play.shared-instance-unknown-files-description',
defaultMessage:
'This shared instance contains files that arent published on Modrinth. We strongly recommend only installing files from sources you trust.',
},
unrecognizedFiles: {
id: 'app.modal.install-to-play.unrecognized-files',
defaultMessage: 'Unrecognized files',
},
reviewedFiles: {
id: 'app.modal.install-to-play.reviewed-files',
defaultMessage:
'A file is only reviewed if its published to Modrinth, regardless of its file format (including .mrpack).',
},
installAnyway: {
id: 'app.modal.install-to-play.install-anyway',
defaultMessage: 'Install anyway',
},
dontInstall: {
id: 'app.modal.install-to-play.external-files-dont-install',
defaultMessage: "Don't install",
},
installButton: { id: 'app.modal.install-to-play.install-button', defaultMessage: 'Install' },
})
const externalFileColumns = computed<TableColumn<ExternalFileColumn>[]>(() => [
{
key: 'name',
label: formatMessage(messages.unrecognizedFiles),
cellClass: '!h-12',
},
])
defineExpose({ show, showReport, hide })
</script>
@@ -0,0 +1,60 @@
<template>
<div class="flex flex-col gap-2.5">
<div class="flex items-center justify-between">
<span class="font-semibold text-contrast">{{
heading ?? formatMessage(messages.sharedInstance)
}}</span>
<ButtonStyled type="transparent">
<button @click="emit('viewContents')">
<EyeIcon />
{{ formatMessage(messages.viewContents) }}
</button>
</ButtonStyled>
</div>
<div class="flex items-center gap-3 rounded-2xl bg-surface-2 p-3">
<Avatar
:src="preview.iconUrl"
:alt="preview.name"
size="56px"
no-shadow
class="!rounded-2xl"
/>
<div class="flex min-w-0 flex-col gap-0.5">
<span class="truncate font-semibold text-contrast">{{ preview.name }}</span>
<span class="truncate text-sm font-medium text-secondary">
{{ loaderDisplay }} {{ preview.gameVersion }}
<template v-if="preview.modCount">
· {{ formatMessage(messages.modCount, { count: preview.modCount }) }}
</template>
</span>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { EyeIcon } from '@modrinth/assets'
import { Avatar, ButtonStyled, defineMessages, formatLoader, useVIntl } from '@modrinth/ui'
import { computed, toRefs } from 'vue'
import type { SharedInstanceInstallPreview } from '@/helpers/install'
const props = defineProps<{ preview: SharedInstanceInstallPreview; heading?: string }>()
const { preview, heading } = toRefs(props)
const emit = defineEmits<{ viewContents: [] }>()
const { formatMessage } = useVIntl()
const loaderDisplay = computed(() =>
preview.value.loader ? formatLoader(formatMessage, preview.value.loader) : '',
)
const messages = defineMessages({
sharedInstance: {
id: 'app.modal.install-to-play.shared-instance',
defaultMessage: 'Shared instance',
},
viewContents: { id: 'app.modal.install-to-play.view-contents', defaultMessage: 'View contents' },
modCount: {
id: 'app.modal.install-to-play.mod-count',
defaultMessage: '{count, plural, one {# mod} other {# mods}}',
},
})
</script>
@@ -0,0 +1,135 @@
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 type { SharedInstanceInstallPreview } from '@/helpers/install'
type VersionDependency = Labrinth.Versions.v2.Dependency & { version_id?: string }
export function useSharedInstancePreviewContent() {
async function load(preview: SharedInstanceInstallPreview): Promise<ContentItem[]> {
return [
...preview.externalFiles.map(externalFileContentItem),
...(await modpackContentItems(preview)),
...(await contentItemsFromVersionIds(
preview.contentVersionIds.filter((id) => id !== preview.modpackVersionId),
)),
]
}
async function modpackContentItems(preview: SharedInstanceInstallPreview) {
if (!preview.modpackVersionId) return []
const version = await get_version(preview.modpackVersionId, 'must_revalidate')
return await contentItemsFromDependencies(version?.dependencies ?? [])
}
async function contentItemsFromDependencies(dependencies: Labrinth.Versions.v2.Dependency[]) {
const deps = dependencies as VersionDependency[]
const projectIds = unique(deps.map((dep) => dep.project_id).filter((id): id is string => !!id))
const versionIds = unique(deps.map((dep) => dep.version_id).filter((id): id is string => !!id))
const [projects, versions]: [Labrinth.Projects.v2.Project[], Labrinth.Versions.v2.Version[]] =
await Promise.all([
projectIds.length ? get_project_many(projectIds, 'must_revalidate') : [],
versionIds.length ? get_version_many(versionIds, 'must_revalidate') : [],
])
const projectMap = new Map(projects.map((project) => [project.id, project]))
const versionMap = new Map(versions.map((version) => [version.id, version]))
return deps.map((dependency): ContentItem => {
const project = dependency.project_id ? projectMap.get(dependency.project_id) : null
const version = dependency.version_id ? versionMap.get(dependency.version_id) : null
const fileName =
version?.files?.[0]?.filename ?? dependency.file_name ?? project?.title ?? 'Unknown'
return contentItem(
version?.id ?? project?.id ?? fileName,
fileName,
project,
version,
!project && !version,
dependency.project_id ?? fileName,
dependency.file_name ?? fileName,
)
})
}
async function contentItemsFromVersionIds(versionIds: string[]) {
const versions: Labrinth.Versions.v2.Version[] = versionIds.length
? await get_version_many(unique(versionIds), 'must_revalidate')
: []
const projectIds = unique(versions.map((version) => version.project_id).filter(Boolean))
const projects: Labrinth.Projects.v2.Project[] = projectIds.length
? await get_project_many(projectIds, 'must_revalidate')
: []
const projectMap = new Map(projects.map((project) => [project.id, project]))
return versions.map((version): ContentItem => {
const project = projectMap.get(version.project_id)
const fileName = version.files?.[0]?.filename ?? project?.title ?? version.name ?? 'Unknown'
return contentItem(
version.id,
fileName,
project,
version,
false,
version.project_id,
version.name,
)
})
}
return { load }
}
function contentItem(
id: string,
fileName: string,
project?: Labrinth.Projects.v2.Project | null,
version?: Labrinth.Versions.v2.Version | null,
external = false,
fallbackProjectId = id,
fallbackTitle = fileName,
projectType = project?.project_type ?? 'mod',
): ContentItem {
return {
id,
file_name: fileName,
project_type: projectType,
has_update: false,
update_version_id: null,
external,
project: {
id: project?.id ?? fallbackProjectId,
slug: project?.slug ?? fallbackProjectId,
title: project?.title ?? fallbackTitle,
icon_url: project?.icon_url ?? undefined,
},
...(version
? {
version: {
id: version.id,
file_name: fileName,
version_number: version.version_number ?? undefined,
date_published: version.date_published ?? undefined,
},
}
: {}),
}
}
function externalFileContentItem(
file: SharedInstanceInstallPreview['externalFiles'][number],
): ContentItem {
return contentItem(
`external:${file.fileType}:${file.fileName}`,
file.fileName,
null,
null,
true,
file.fileName,
file.fileName,
file.fileType,
)
}
function unique<T>(values: T[]) {
return Array.from(new Set(values))
}
@@ -0,0 +1,48 @@
<template>
<ModrinthAccountRequiredModal ref="accountRequiredModal" :request-auth="requestAuth" />
<SharedInstanceInstallModal ref="installModal" />
<SharedInstanceAlreadyInstalledModal
ref="alreadyInstalledModal"
@cancel="handleAlreadyInstalledCancel"
@go-to-instance="handleAlreadyInstalledGoToInstance"
@install-anyway="handleAlreadyInstalledInstallAnyway"
/>
</template>
<script setup lang="ts">
import { injectAuth } from '@modrinth/ui'
import { nextTick, ref } from 'vue'
import ModrinthAccountRequiredModal from '@/components/ui/modal/ModrinthAccountRequiredModal.vue'
import SharedInstanceInstallModal from '@/components/ui/shared-instances/shared-instance-install-modal/index.vue'
import SharedInstanceAlreadyInstalledModal from '@/components/ui/shared-instances/SharedInstanceAlreadyInstalledModal.vue'
import type { ModrinthAuthFlow } from '@/helpers/mr_auth'
import type { SharedInstanceInviteHandler } from './shared-instance-invite-types'
import { useSharedInstanceInviteHandler } from './use-shared-instance-invite-handler'
const auth = injectAuth()
const installModal = ref<InstanceType<typeof SharedInstanceInstallModal>>()
const alreadyInstalledModal = ref<InstanceType<typeof SharedInstanceAlreadyInstalledModal>>()
const accountRequiredModal = ref<InstanceType<typeof ModrinthAccountRequiredModal>>()
const {
handleNotification,
installFromInviteId,
clearNotifications,
handleAlreadyInstalledCancel,
handleAlreadyInstalledGoToInstance,
handleAlreadyInstalledInstallAnyway,
} = useSharedInstanceInviteHandler(installModal, alreadyInstalledModal, accountRequiredModal)
async function requestAuth(flow: ModrinthAuthFlow) {
await auth.requestSignIn('', flow, { showModal: false })
await nextTick()
return !!auth.session_token.value
}
defineExpose<SharedInstanceInviteHandler>({
handleNotification,
installFromInviteId,
clearNotifications,
})
</script>
@@ -0,0 +1,29 @@
import type {
AppNotification,
SharedInstanceInvite,
SharedInstanceInviteNotificationBody,
} from './shared-instance-invite-types'
function optionalString(value: unknown) {
return typeof value === 'string' ? value : null
}
export function parseSharedInstanceInviteNotification(
notification: AppNotification,
): SharedInstanceInvite | null {
if (notification.body?.type !== 'shared_instance_invite') return null
const body = notification.body as SharedInstanceInviteNotificationBody
const sharedInstanceId = optionalString(body.shared_instance_id)
const sharedInstanceName = optionalString(body.shared_instance_name)
if (!sharedInstanceId || !sharedInstanceName) return null
return {
sharedInstanceId,
sharedInstanceName,
invitedById: optionalString(body.invited_by),
invitedByUsername: optionalString(body.invited_by_username),
invitedByAvatarUrl: optionalString(body.invited_by_avatar_url),
instanceIconUrl: optionalString(body.shared_instance_icon),
}
}
@@ -0,0 +1,30 @@
export type SharedInstanceInviteNotificationBody = {
type: 'shared_instance_invite'
shared_instance_id?: unknown
shared_instance_name?: unknown
invited_by?: unknown
invited_by_username?: unknown
invited_by_avatar_url?: unknown
shared_instance_icon?: unknown
}
export type AppNotification = {
id: string | number
read?: boolean
body?: { type?: unknown } & Record<string, unknown>
}
export type SharedInstanceInvite = {
sharedInstanceId: string
sharedInstanceName: string
invitedById: string | null
invitedByUsername: string | null
invitedByAvatarUrl: string | null
instanceIconUrl: string | null
}
export type SharedInstanceInviteHandler = {
handleNotification(notification: AppNotification): Promise<boolean>
installFromInviteId(inviteId: string): Promise<void>
clearNotifications(): void
}
@@ -0,0 +1,282 @@
import { ModrinthApiError } from '@modrinth/api-client'
import {
injectAuth,
injectModrinthClient,
injectNotificationManager,
injectPopupNotificationManager,
} from '@modrinth/ui'
import { useQueryClient } from '@tanstack/vue-query'
import { openUrl } from '@tauri-apps/plugin-opener'
import { type Ref, watch } from 'vue'
import { useRouter } from 'vue-router'
import { config } from '@/config'
import { get_user } from '@/helpers/cache'
import { toError } from '@/helpers/errors'
import {
install_accept_shared_instance_invite,
install_get_shared_instance_preview,
install_shared_instance,
} from '@/helpers/install'
import { list } from '@/helpers/instance'
import { useTheming } from '@/store/state'
import { parseSharedInstanceInviteNotification } from './shared-instance-invite-parser'
import type { AppNotification, SharedInstanceInvite } from './shared-instance-invite-types'
type InstallModal = {
show(
preview: Awaited<ReturnType<typeof install_get_shared_instance_preview>>,
install: () => Promise<void>,
): void
}
type AccountRequiredModal = {
show(event?: MouseEvent): Promise<boolean>
}
type AlreadyInstalledModal = {
show(instanceName: string): void
}
export function useSharedInstanceInviteHandler(
installModal: Ref<InstallModal | undefined>,
alreadyInstalledModal: Ref<AlreadyInstalledModal | undefined>,
accountRequiredModal: Ref<AccountRequiredModal | undefined>,
) {
const auth = injectAuth()
const client = injectModrinthClient()
const { handleError } = injectNotificationManager()
const popupNotificationManager = injectPopupNotificationManager()
const queryClient = useQueryClient()
const router = useRouter()
const themeStore = useTheming()
const displayedNotifications = new Set<string | number>()
const displayedNotificationKeys = new Set<string>()
const popupNotificationIds = new Set<string | number>()
let notificationGeneration = 0
let pendingAlreadyInstalled:
| {
instanceId: string
preview: Awaited<ReturnType<typeof install_get_shared_instance_preview>>
install: () => Promise<void>
onGoToInstance?: () => void | Promise<void>
}
| undefined
async function markNotificationRead(notification: AppNotification) {
try {
await client.labrinth.notifications_v2.markAsRead(String(notification.id))
} catch (error) {
if (error instanceof ModrinthApiError && error.statusCode === 404) return
throw error
}
}
async function resolveInvite(invite: SharedInstanceInvite) {
const [invitedBy, sharedInstance] = await Promise.all([
!invite.invitedByUsername && invite.invitedById
? get_user(invite.invitedById, 'bypass').catch(() => null)
: null,
client.sharedinstances.instances_v1.get(invite.sharedInstanceId).catch(() => null),
])
return {
...invite,
invitedByUsername: invite.invitedByUsername ?? invitedBy?.username ?? null,
invitedByAvatarUrl: invite.invitedByAvatarUrl ?? invitedBy?.avatar_url ?? null,
instanceIconUrl: sharedInstance ? sharedInstance.icon : invite.instanceIconUrl,
}
}
function showInstall(
preview: Awaited<ReturnType<typeof install_get_shared_instance_preview>>,
install: () => Promise<void>,
) {
if (!installModal.value) throw new Error('Shared instance install modal is not available.')
installModal.value.show(preview, install)
}
async function showInstallOrAlreadyInstalled(
sharedInstanceId: string,
preview: Awaited<ReturnType<typeof install_get_shared_instance_preview>>,
install: () => Promise<void>,
onGoToInstance?: () => void | Promise<void>,
) {
const existingInstance = (await list()).find(
(instance) => instance.shared_instance?.id === sharedInstanceId,
)
if (!existingInstance || themeStore.getFeatureFlag('skip_non_essential_warnings')) {
showInstall(preview, install)
return
}
if (!alreadyInstalledModal.value) {
throw new Error('Shared instance already installed modal is not available.')
}
pendingAlreadyInstalled = {
instanceId: existingInstance.id,
preview,
install,
onGoToInstance,
}
alreadyInstalledModal.value.show(existingInstance.name)
}
function handleAlreadyInstalledCancel() {
pendingAlreadyInstalled = undefined
}
async function handleAlreadyInstalledGoToInstance() {
const pending = pendingAlreadyInstalled
pendingAlreadyInstalled = undefined
if (!pending) return
if (pending.onGoToInstance) {
try {
await pending.onGoToInstance()
} catch (error) {
handleError(toError(error))
}
}
await router.push(`/instance/${encodeURIComponent(pending.instanceId)}/`)
}
function handleAlreadyInstalledInstallAnyway() {
const pending = pendingAlreadyInstalled
pendingAlreadyInstalled = undefined
if (!pending) return
showInstall(pending.preview, pending.install)
}
async function acceptNotification(notification: AppNotification, invite: SharedInstanceInvite) {
try {
const preview = await install_get_shared_instance_preview(
invite.sharedInstanceId,
invite.sharedInstanceName,
)
if (invite.instanceIconUrl) preview.iconUrl = invite.instanceIconUrl
await showInstallOrAlreadyInstalled(
invite.sharedInstanceId,
preview,
async () => {
await install_shared_instance(
invite.sharedInstanceId,
invite.sharedInstanceName,
invite.invitedById,
null,
null,
invite.instanceIconUrl,
)
await markNotificationRead(notification)
await queryClient.invalidateQueries({ queryKey: ['instances'] })
},
() => markNotificationRead(notification),
)
} catch (error) {
handleError(toError(error))
}
}
async function handleNotification(notification: AppNotification) {
const parsedInvite = parseSharedInstanceInviteNotification(notification)
if (!parsedInvite) return false
if (displayedNotifications.has(notification.id)) return true
const generation = notificationGeneration
displayedNotifications.add(notification.id)
const invite = await resolveInvite(parsedInvite)
if (generation !== notificationGeneration) return true
const notificationKey = JSON.stringify([
invite.invitedById ?? invite.invitedByUsername,
invite.sharedInstanceName,
invite.instanceIconUrl,
])
if (displayedNotificationKeys.has(notificationKey)) {
await markNotificationRead(notification).catch((error) => handleError(toError(error)))
return true
}
displayedNotificationKeys.add(notificationKey)
const popupNotification = popupNotificationManager.addPopupNotification({
title: invite.sharedInstanceName,
autoCloseMs: null,
toast: {
type: 'instance-invite',
actorName: invite.invitedByUsername,
actorAvatarUrl: invite.invitedByAvatarUrl ?? undefined,
entityName: invite.sharedInstanceName,
entityIconUrl: invite.instanceIconUrl ?? undefined,
onAccept: () => acceptNotification(notification, invite),
onDecline: () =>
markNotificationRead(notification).catch((error) => handleError(toError(error))),
onOpenActor: () => {
if (invite.invitedByUsername) {
openUrl(`${config.siteUrl}/user/${encodeURIComponent(invite.invitedByUsername)}`)
}
},
},
})
popupNotificationIds.add(popupNotification.id)
return true
}
function clearNotifications() {
notificationGeneration++
for (const id of popupNotificationIds) {
popupNotificationManager.removeNotification(id)
}
displayedNotifications.clear()
displayedNotificationKeys.clear()
popupNotificationIds.clear()
pendingAlreadyInstalled = undefined
}
async function requireAccount() {
if (!auth.isReady?.value) {
await new Promise<void>((resolve) => {
const stop = watch(auth.isReady!, (ready) => {
if (ready) {
stop()
resolve()
}
})
})
}
if (auth.session_token.value) return true
return (await accountRequiredModal.value?.show()) ?? false
}
async function installFromInviteId(inviteId: string) {
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'] })
})
} catch (error) {
handleError(toError(error))
}
}
return {
handleNotification,
installFromInviteId,
clearNotifications,
handleAlreadyInstalledCancel,
handleAlreadyInstalledGoToInstance,
handleAlreadyInstalledInstallAnyway,
}
}
@@ -11,6 +11,7 @@
<SkinPreviewRenderer
:variant="variant"
:texture-src="previewSkin || ''"
:ears-texture-src="uploadedTextureUrl?.original ?? currentSkin?.texture"
:cape-src="selectedCapeTexture"
framing="modal"
:initial-rotation="Math.PI / 8"
@@ -80,6 +80,7 @@ const playing = ref(false)
const play = async (event: MouseEvent) => {
event?.stopPropagation()
if (props.instance.quarantined) return
loading.value = true
await run(props.instance.id)
.catch((err) => handleSevereError(err, { instanceId: props.instance.id }))
@@ -192,8 +193,14 @@ onUnmounted(() => {
</ButtonStyled>
<ButtonStyled v-else>
<button
v-tooltip="playing ? 'Instance is already open' : null"
:disabled="playing || loading"
v-tooltip="
instance.quarantined
? 'This instance has been locked'
: playing
? 'Instance is already open'
: null
"
:disabled="instance.quarantined || playing || loading"
@click="play"
>
<SpinnerIcon v-if="loading" class="animate-spin" />
@@ -176,6 +176,7 @@ function refreshServer(address: string, instanceId: string) {
}
async function joinWorld(world: WorldWithInstance, instance?: GameInstance) {
if (instance?.quarantined) return
console.log(`Joining world ${getWorldIdentifier(world)}`)
if (world.type === 'server') {
await start_join_server(world.instance_id, world.address).catch(handleError)
@@ -192,6 +193,7 @@ async function joinWorld(world: WorldWithInstance, instance?: GameInstance) {
}
async function playInstance(instance: GameInstance) {
if (instance.quarantined) return
await run(instance.id)
.catch((err) => handleSevereError(err, { instanceId: instance.id }))
.finally(() => {
@@ -300,6 +302,7 @@ onUnmounted(() => {
item.world.type === 'singleplayer' &&
hasWorldQuickPlaySupport(gameVersions, item.instance.game_version || '')
"
:quarantined="item.instance.quarantined"
:server-status="
item.world.type === 'server' ? serverData[item.world.address].status : undefined
"
@@ -77,6 +77,7 @@ const props = withDefaults(
startingInstance?: boolean
supportsServerQuickPlay?: boolean
supportsWorldQuickPlay?: boolean
quarantined?: boolean
currentProtocol?: ProtocolVersion | null
highlighted?: boolean
@@ -105,6 +106,7 @@ const props = withDefaults(
startingInstance: false,
supportsServerQuickPlay: true,
supportsWorldQuickPlay: false,
quarantined: false,
currentProtocol: null,
refreshing: false,
@@ -139,7 +141,7 @@ const managed = computed(() => props.managed)
const shortcutInstanceId = computed(() => props.shortcutInstanceId ?? props.instanceId)
async function createShortcut() {
if (!shortcutInstanceId.value) return
if (!shortcutInstanceId.value || props.quarantined) return
try {
const shortcutPath = await createInstanceShortcut(
@@ -422,23 +424,26 @@ const messages = defineMessages({
<ButtonStyled v-else>
<button
v-tooltip="
world.type === 'server'
? !supportsServerQuickPlay
? formatMessage(messages.noServerQuickPlay)
: playingOtherWorld
? formatMessage(messages.gameAlreadyOpen)
: !serverStatus
? formatMessage(messages.noContact)
: serverIncompatible
? formatMessage(messages.incompatibleServer)
: null
: !supportsWorldQuickPlay
? formatMessage(messages.noSingleplayerQuickPlay)
: playingOtherWorld || locked
? formatMessage(messages.gameAlreadyOpen)
: null
quarantined
? 'This instance has been locked'
: world.type === 'server'
? !supportsServerQuickPlay
? formatMessage(messages.noServerQuickPlay)
: playingOtherWorld
? formatMessage(messages.gameAlreadyOpen)
: !serverStatus
? formatMessage(messages.noContact)
: serverIncompatible
? formatMessage(messages.incompatibleServer)
: null
: !supportsWorldQuickPlay
? formatMessage(messages.noSingleplayerQuickPlay)
: playingOtherWorld || locked
? formatMessage(messages.gameAlreadyOpen)
: null
"
:disabled="
quarantined ||
playingOtherWorld ||
startingInstance ||
(world.type == 'server' && !supportsServerQuickPlay) ||
@@ -457,7 +462,7 @@ const messages = defineMessages({
{
id: 'play-instance',
shown: !!instanceId,
disabled: playingInstance,
disabled: playingInstance || quarantined,
action: () => emit('play-instance'),
},
{
@@ -511,7 +516,7 @@ const messages = defineMessages({
},
{
id: 'create-shortcut',
shown: !!shortcutInstanceId,
shown: !!shortcutInstanceId && !quarantined,
action: () => createShortcut(),
},
{
@@ -53,6 +53,10 @@ const messages = defineMessages({
id: 'app.action-bar.install.unknown-instance',
defaultMessage: 'Unknown instance',
},
updatingSharedContent: {
id: 'app.action-bar.install.updating-shared-content',
defaultMessage: 'Updating shared content',
},
})
const phaseMessages = defineMessages({
@@ -268,6 +272,9 @@ export async function useInstallJobNotifications(opts: {
version: job.details.major_version,
})
}
if (job.kind === 'update_shared_instance' && job.phase === 'downloading_content') {
return formatMessage(messages.updatingSharedContent)
}
return formatMessage(phaseMessages[job.phase])
}
@@ -27,14 +27,14 @@ interface BrowseServerInstance {
interface ContextMenuHandle {
showMenu: (
event: MouseEvent,
result: Labrinth.Search.v2.ResultSearchProject | Labrinth.Search.v3.ResultSearchProject,
result: Labrinth.Search.v3.ResultSearchProject,
options: { name: string }[],
) => void
}
interface ContextMenuOptionClick {
option: 'open_link' | 'copy_link'
item: Labrinth.Search.v2.ResultSearchProject | Labrinth.Search.v3.ResultSearchProject
item: Labrinth.Search.v3.ResultSearchProject
}
export interface UseAppServerBrowseOptions {
@@ -263,10 +263,7 @@ export function useAppServerBrowse(options: UseAppServerBrowseOptions) {
return actions
}
function handleRightClick(
event: MouseEvent,
result: Labrinth.Search.v2.ResultSearchProject | Labrinth.Search.v3.ResultSearchProject,
) {
function handleRightClick(event: MouseEvent, result: Labrinth.Search.v3.ResultSearchProject) {
contextMenuRef.value?.showMenu(event, result, [{ name: 'open_link' }, { name: 'copy_link' }])
}
@@ -315,9 +312,7 @@ export function useAppServerBrowse(options: UseAppServerBrowseOptions) {
}
}
function getProjectUrl(
item: Labrinth.Search.v2.ResultSearchProject | Labrinth.Search.v3.ResultSearchProject,
) {
const projectType = 'project_types' in item ? item.project_types?.[0] : item.project_type
function getProjectUrl(item: Labrinth.Search.v3.ResultSearchProject) {
const projectType = item.project_types?.[0]
return `https://modrinth.com/${projectType ?? 'project'}/${item.slug ?? item.project_id}`
}
@@ -0,0 +1,124 @@
import { type ContentActionWarning, type ContentItem, defineMessages, useVIntl } from '@modrinth/ui'
import { computed, type Ref } from 'vue'
import type { GameInstance } from '@/helpers/types'
const managedSourceKinds = new Set(['shared_instance', 'modrinth_modpack', 'imported_modpack'])
export function useManagedContentPolicy(instance: Ref<GameInstance>) {
const { formatMessage } = useVIntl()
const isManagedModpack = computed(() => instance.value.shared_instance?.role === 'member')
const isQuarantined = computed(() => instance.value.quarantined)
const canUnpublish = computed(() => instance.value.shared_instance?.role === 'owner')
const canUnlink = computed(() => instance.value.shared_instance?.role === 'member')
const managedModpackWarning = computed(() => ({
admonitionHeader: formatMessage(messages.warningHeader),
changeVersionBody: formatMessage(messages.changeVersionBody),
unlinkBody: formatMessage(messages.unlinkBody),
}))
function isManagedContent(item: ContentItem) {
return (
isQuarantined.value ||
(isManagedModpack.value && !!item.source_kind && managedSourceKinds.has(item.source_kind))
)
}
function canMutateContent(item: ContentItem) {
return !isManagedContent(item)
}
function canUpdateContent(item: ContentItem) {
return (
canMutateContent(item) && !!item.file_path && !!item.has_update && !!item.update_version_id
)
}
function deleteWarning(items: ContentItem[]): ContentActionWarning | null {
if (!items.some(isManagedContent)) return null
return {
admonitionHeader: formatMessage(messages.warningHeader),
admonitionBody: formatMessage(
items.length === 1 ? messages.deleteSingleBody : messages.deleteBulkBody,
),
actionLabel: formatMessage(
items.length === 1 ? messages.deleteButton : messages.deleteManyButton,
{ count: items.length },
),
}
}
function disableWarning(items: ContentItem[]): ContentActionWarning | null {
if (!items.some(isManagedContent)) return null
return {
admonitionHeader: formatMessage(messages.warningHeader),
admonitionBody: formatMessage(
items.length === 1 ? messages.disableSingleBody : messages.disableBulkBody,
),
actionLabel: formatMessage(
items.length === 1 ? messages.disableButton : messages.disableManyButton,
{ count: items.length },
),
}
}
return {
isManagedModpack,
isQuarantined,
canUnpublish,
canUnlink,
managedModpackWarning,
isManagedContent,
canMutateContent,
canUpdateContent,
deleteWarning,
disableWarning,
}
}
const messages = defineMessages({
warningHeader: {
id: 'content.shared-instance.warning-header',
defaultMessage: 'This is part of the shared instance',
},
changeVersionBody: {
id: 'content.shared-instance.change-version-body',
defaultMessage:
'Changing the version only changes your local copy. Future shared instance updates may restore or change it again.',
},
unlinkBody: {
id: 'content.shared-instance.unlink-body',
defaultMessage:
'Unlinking only changes your local copy. Future shared instance updates may restore or change it again.',
},
deleteSingleBody: {
id: 'content.shared-instance.delete-single-body',
defaultMessage:
'Deleting it only changes your local copy. Future shared instance updates may restore or change it again.',
},
deleteBulkBody: {
id: 'content.shared-instance.delete-bulk-body',
defaultMessage:
'Some selected projects are part of the shared instance. Deleting them only changes your local copy, and future shared instance updates may restore or change them again.',
},
deleteButton: { id: 'content.shared-instance.delete-button', defaultMessage: 'Delete anyway' },
deleteManyButton: {
id: 'content.shared-instance.delete-many-button',
defaultMessage: 'Delete {count, number} projects anyway',
},
disableSingleBody: {
id: 'content.shared-instance.disable-single-body',
defaultMessage:
'Disabling it only changes your local copy. Future shared instance updates may re-enable, restore, or change it again.',
},
disableBulkBody: {
id: 'content.shared-instance.disable-bulk-body',
defaultMessage:
'Some selected projects are part of the shared instance. Disabling them only changes your local copy, and future shared instance updates may re-enable, restore, or change them again.',
},
disableButton: { id: 'content.shared-instance.disable-button', defaultMessage: 'Disable anyway' },
disableManyButton: {
id: 'content.shared-instance.disable-many-button',
defaultMessage: 'Disable {count, number} projects anyway',
},
})
@@ -0,0 +1,148 @@
import { useMutation, useQuery, useQueryClient } from '@tanstack/vue-query'
import { computed, type MaybeRefOrGetter, onUnmounted, toValue } from 'vue'
import { toError } from '@/helpers/errors'
import { friend_listener } from '@/helpers/events.js'
import {
acceptCachedFriend,
add_friend,
createPendingFriend,
type FriendCacheUser,
friendsQueryKey,
type FriendWithUserData,
getFriendsWithUserData,
getFriendUserId,
matchesFriend,
remove_friend,
removeCachedFriend,
upsertCachedFriend,
} from '@/helpers/friends'
import type { ModrinthCredentials } from '@/helpers/mr_auth'
type FriendsMutationContext = {
queryKey: ReturnType<typeof friendsQueryKey>
previousFriends?: FriendWithUserData[]
}
type AddFriendMutationVariables = {
userId: string
user: FriendCacheUser
acceptExisting: boolean
}
type RemoveFriendMutationVariables = {
userId: string
user: FriendWithUserData
}
export function useFriends(options: {
currentUserId: MaybeRefOrGetter<string | null | undefined>
getCredentials: () => ModrinthCredentials | null | Promise<ModrinthCredentials | null>
enabled?: MaybeRefOrGetter<boolean>
onError?: (error: Error) => void
}) {
const queryClient = useQueryClient()
const queryKey = computed(() => friendsQueryKey(toValue(options.currentUserId)))
const query = useQuery({
queryKey,
queryFn: async () => getFriendsWithUserData(await options.getCredentials()),
enabled: () => !!toValue(options.currentUserId) && toValue(options.enabled ?? true),
staleTime: 30_000,
})
const friends = computed(() => query.data.value ?? [])
function restore(context?: FriendsMutationContext) {
if (!context) return
if (context.previousFriends === undefined) {
queryClient.removeQueries({ queryKey: context.queryKey, exact: true })
return
}
queryClient.setQueryData(context.queryKey, context.previousFriends)
}
const addMutation = useMutation({
mutationFn: ({ userId }: AddFriendMutationVariables) => add_friend(userId),
onMutate: async ({ user, acceptExisting }): Promise<FriendsMutationContext> => {
const activeQueryKey = queryKey.value
await queryClient.cancelQueries({ queryKey: activeQueryKey })
const previousFriends = queryClient.getQueryData<FriendWithUserData[]>(activeQueryKey)
const currentUserId = toValue(options.currentUserId)
queryClient.setQueryData<FriendWithUserData[]>(activeQueryKey, (cachedFriends = []) =>
acceptExisting
? acceptCachedFriend(cachedFriends, user.id, user.username, currentUserId)
: upsertCachedFriend(
cachedFriends,
createPendingFriend(user, currentUserId),
currentUserId,
),
)
return { queryKey: activeQueryKey, previousFriends }
},
onError: (error, _variables, context) => {
restore(context)
options.onError?.(toError(error))
},
onSettled: (_data, _error, _variables, context) => {
void queryClient.invalidateQueries({ queryKey: context?.queryKey ?? queryKey.value })
},
})
const removeMutation = useMutation({
mutationFn: ({ userId }: RemoveFriendMutationVariables) => remove_friend(userId),
onMutate: async ({ user, userId }): Promise<FriendsMutationContext> => {
const activeQueryKey = queryKey.value
await queryClient.cancelQueries({ queryKey: activeQueryKey })
const previousFriends = queryClient.getQueryData<FriendWithUserData[]>(activeQueryKey)
queryClient.setQueryData<FriendWithUserData[]>(activeQueryKey, (cachedFriends = []) =>
removeCachedFriend(cachedFriends, userId, user.username, toValue(options.currentUserId)),
)
return { queryKey: activeQueryKey, previousFriends }
},
onError: (error, _variables, context) => {
restore(context)
options.onError?.(toError(error))
},
onSettled: (_data, _error, _variables, context) => {
void queryClient.invalidateQueries({ queryKey: context?.queryKey ?? queryKey.value })
},
})
function requestFriend(user: FriendCacheUser, acceptExisting = false) {
addMutation.mutate({ userId: user.id, user, acceptExisting })
}
function acceptFriend(friend: FriendWithUserData) {
const userId = getFriendUserId(friend, toValue(options.currentUserId))
requestFriend({ id: userId, username: friend.username, avatarUrl: friend.avatar }, true)
}
function removeFriend(friend: FriendWithUserData) {
const userId = getFriendUserId(friend, toValue(options.currentUserId))
removeMutation.mutate({ userId, user: friend })
}
function findFriend(id: string, username: string) {
return friends.value.find((friend) =>
matchesFriend(friend, id, username, toValue(options.currentUserId)),
)
}
let unlisten: (() => void) | undefined
void friend_listener(() => {
void queryClient.invalidateQueries({ queryKey: queryKey.value })
}).then((listener) => {
unlisten = listener
})
onUnmounted(() => unlisten?.())
return {
query,
queryKey,
friends,
loading: computed(() => !!toValue(options.currentUserId) && query.isLoading.value),
requestFriend,
acceptFriend,
removeFriend,
findFriend,
}
}
@@ -0,0 +1,17 @@
import { useQuery } from '@tanstack/vue-query'
import { computed, type Ref } from 'vue'
import { get_user } from '@/helpers/cache.js'
export function useUserQuery(userId: Ref<string | null | undefined>) {
return useQuery({
queryKey: computed(() => ['user', userId.value]),
queryFn: async ({ queryKey }) => {
const id = queryKey[1]
if (typeof id !== 'string') return null
return await get_user(id, 'bypass').catch(() => null)
},
enabled: () => !!userId.value,
staleTime: 30_000,
})
}
+4
View File
@@ -7,6 +7,9 @@ const labrinthBaseUrl = trimTrailingSlash(
const archonBaseUrl = trimTrailingSlash(
import.meta.env.MODRINTH_ARCHON_BASE_URL || 'https://archon.modrinth.com',
)
const sharedInstancesBaseUrl = trimTrailingSlash(
import.meta.env.SHARED_INSTANCES_API_BASE_URL || 'https://shared-instances.modrinth.com',
)
export const config = {
siteUrl,
@@ -15,4 +18,5 @@ export const config = {
'pk_test_51JbFxJJygY5LJFfKV50mnXzz3YLvBVe2Gd1jn7ljWAkaBlRz3VQdxN9mXcPSrFbSqxwAb0svte9yhnsmm7qHfcWn00R611Ce7b',
labrinthBaseUrl,
archonBaseUrl,
sharedInstancesBaseUrl,
}
+17
View File
@@ -1,4 +1,5 @@
import { invoke } from '@tauri-apps/api/core'
import { listen } from '@tauri-apps/api/event'
export async function init_ads_window(overrideShown = false) {
return await invoke('plugin:ads|init_ads_window', {
@@ -15,6 +16,22 @@ export async function hide_ads_window(reset) {
return await invoke('plugin:ads|hide_ads_window', { reset })
}
export async function should_show_ads_consent_popup() {
return await invoke('plugin:ads|should_show_ads_consent_popup')
}
export async function perform_ads_consent_action(action) {
return await invoke('plugin:ads|perform_ads_consent_action', { action })
}
export async function open_ads_consent_preferences() {
return await invoke('plugin:ads|open_ads_consent_preferences')
}
export async function ads_consent_listener(callback) {
return await listen('ads-consent-required', (event) => callback(event.payload))
}
export async function record_ads_click() {
return await invoke('plugin:ads|record_ads_click')
}
+11
View File
@@ -0,0 +1,11 @@
export function toError(error: unknown) {
if (error instanceof Error) return error
if (typeof error === 'string') return new Error(error)
if (error && typeof error === 'object') {
const record = error as Record<string, unknown>
const message = record.message ?? record.error
if (typeof message === 'string') return new Error(message)
return new Error(JSON.stringify(error))
}
return new Error(String(error))
}
+107
View File
@@ -6,6 +6,8 @@ import dayjs from 'dayjs'
import { get_user_many } from '@/helpers/cache'
import type { ModrinthCredentials } from '@/helpers/mr_auth'
export const friendsQueryKey = (userId?: string | null) => ['friends', userId ?? null] as const
export type UserStatus = {
user_id: string
instance_name: string | null
@@ -46,6 +48,111 @@ export type FriendWithUserData = {
online: boolean
avatar: string
}
export type FriendCacheUser = {
id: string
username: string
avatarUrl?: string | null
}
export async function getFriendsWithUserData(
credentials: ModrinthCredentials | null,
): Promise<FriendWithUserData[]> {
if (!credentials) return []
const friendsList = await friends()
return await transformFriends(friendsList, credentials)
}
export function createPendingFriend(
user: FriendCacheUser,
currentUserId?: string | null,
): FriendWithUserData {
return {
id: user.id,
friend_id: currentUserId ?? null,
status: null,
last_updated: null,
created: dayjs(),
username: user.username,
accepted: false,
online: false,
avatar: user.avatarUrl ?? '',
}
}
export function getFriendUserId(
friend: Pick<FriendWithUserData, 'id' | 'friend_id'>,
currentUserId?: string | null,
) {
return friend.id === currentUserId && friend.friend_id ? friend.friend_id : friend.id
}
export function matchesFriend(
friend: FriendWithUserData,
id: string,
username: string,
currentUserId?: string | null,
) {
const friendId = getFriendUserId(friend, currentUserId)
return (
normalizeFriendKey(friendId) === normalizeFriendKey(id) ||
normalizeFriendKey(friend.username) === normalizeFriendKey(username)
)
}
export function upsertCachedFriend(
friends: FriendWithUserData[],
friend: FriendWithUserData,
currentUserId?: string | null,
) {
const existingFriend = friends.find((cachedFriend) =>
matchesFriend(cachedFriend, friend.id, friend.username, currentUserId),
)
if (!existingFriend) return [friend, ...friends]
return friends.map((cachedFriend) =>
matchesFriend(cachedFriend, friend.id, friend.username, currentUserId)
? {
...cachedFriend,
...friend,
id: cachedFriend.id,
friend_id: cachedFriend.friend_id,
}
: cachedFriend,
)
}
export function acceptCachedFriend(
friends: FriendWithUserData[],
id: string,
username: string,
currentUserId?: string | null,
) {
return friends.map((friend) =>
matchesFriend(friend, id, username, currentUserId)
? {
...friend,
accepted: true,
}
: friend,
)
}
export function removeCachedFriend(
friends: FriendWithUserData[],
id: string,
username: string,
currentUserId?: string | null,
) {
return friends.filter((friend) => !matchesFriend(friend, id, username, currentUserId))
}
export function normalizeFriendKey(value: string) {
return value.trim().toLowerCase()
}
export async function transformFriends(
friends: UserFriend[],
credentials: ModrinthCredentials | null,
+143 -1
View File
@@ -45,6 +45,92 @@ export interface InstallPostInstallEdit {
link?: InstanceLink | null
}
export interface SharedInstanceInstallPreview {
sharedInstanceId: string
version: number
name: string
iconUrl?: string | null
gameVersion: string
loader: InstanceLoader
modCount: number
externalFileCount: number
modpackVersionId?: string | null
contentVersionIds: string[]
externalFiles: SharedInstanceExternalFilePreview[]
}
export interface SharedInstanceInviteInstallPreview {
sharedInstanceId: string
managerId?: string | null
serverManagerName?: string | null
serverManagerIconUrl?: string | null
instanceIconUrl?: string | null
preview: SharedInstanceInstallPreview
}
export interface SharedInstanceExternalFilePreview {
fileName: string
fileType: string
}
export interface SharedInstanceUpdatePreview {
sharedInstanceId: string
currentVersion?: number | null
latestVersion: number
updateAvailable: boolean
diffs: SharedInstanceUpdateDiff[]
}
export interface SharedInstanceUpdateDiff {
type:
| 'added'
| 'removed'
| 'updated'
| 'modpack_linked'
| 'modpack_updated'
| 'modpack_unlinked'
| 'game_version_updated'
| 'loader_updated'
| 'config_files_updated'
projectId?: string | null
projectName?: string | null
fileName?: string | null
currentVersionName?: string | null
newVersionName?: string | null
configFileCount?: number | null
disabled?: boolean
}
export const SHARED_INSTANCE_UNAVAILABLE_ERROR_CODE = 'shared_instance_unavailable'
export type SharedInstanceUnavailableReason = 'deleted' | 'access_revoked' | 'quarantined'
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null
}
export function isSharedInstanceUnavailableError(error: unknown) {
return getSharedInstanceUnavailableReason(error) !== null
}
export function getSharedInstanceUnavailableReason(
error: unknown,
): SharedInstanceUnavailableReason | null {
if (!isRecord(error) || error.code !== SHARED_INSTANCE_UNAVAILABLE_ERROR_CODE) return null
return error.reason === 'deleted' ||
error.reason === 'access_revoked' ||
error.reason === 'quarantined'
? error.reason
: null
}
export function getErrorMessage(error: unknown): string {
if (typeof error === 'string') return error
if (error instanceof Error) return error.message || 'Unknown error'
if (isRecord(error) && typeof error.message === 'string') return error.message
return 'Unknown error'
}
export type InstallJobStatus =
| 'queued'
| 'running'
@@ -90,6 +176,7 @@ export interface InstallErrorView {
code: string
phase?: InstallPhaseId | null
message: string
reason?: SharedInstanceUnavailableReason | null
api?: {
error: string
status?: number | null
@@ -122,6 +209,8 @@ export interface InstallJobSnapshot {
kind:
| 'create_instance'
| 'create_modpack_instance'
| 'create_shared_instance'
| 'update_shared_instance'
| 'import_instance'
| 'duplicate_instance'
| 'install_existing_instance'
@@ -172,6 +261,58 @@ export async function install_create_modpack_instance(
})
}
export async function install_get_shared_instance_preview(sharedInstanceId: string, name: string) {
return await invoke<SharedInstanceInstallPreview>(
'plugin:install|install_get_shared_instance_preview',
{
sharedInstanceId,
name,
},
)
}
export async function install_accept_shared_instance_invite(inviteId: string) {
return await invoke<SharedInstanceInviteInstallPreview>(
'plugin:install|install_accept_shared_instance_invite',
{
inviteId,
},
)
}
export async function install_get_shared_instance_update_preview(instanceId: string) {
return await invoke<SharedInstanceUpdatePreview | null>(
'plugin:install|install_get_shared_instance_update_preview',
{
instanceId,
},
)
}
export async function install_shared_instance(
sharedInstanceId: string,
name: string,
managerId?: string | null,
serverManagerName?: string | null,
serverManagerIconUrl?: string | null,
instanceIconUrl?: string | null,
) {
return await invoke<InstallJobSnapshot>('plugin:install|install_shared_instance', {
sharedInstanceId,
name,
managerId,
serverManagerName,
serverManagerIconUrl,
instanceIconUrl,
})
}
export async function install_update_shared_instance(instanceId: string) {
return await invoke<InstallJobSnapshot>('plugin:install|install_update_shared_instance', {
instanceId,
})
}
export async function install_import_instance(
launcherType: string,
basePath: string,
@@ -249,7 +390,8 @@ export function isInstallJobFinished(status: InstallJobStatus) {
function settleInstallJob(job: InstallJobSnapshot) {
if (job.status === 'succeeded') return job
throw new Error(job.error?.message ?? `Install job ${job.job_id} ${job.status}`)
if (job.error) throw job.error
throw new Error(`Install job ${job.job_id} ${job.status}`)
}
export async function wait_for_install_job(jobId: string) {
+92 -1
View File
@@ -7,13 +7,14 @@ import type { Labrinth } from '@modrinth/api-client'
import type { ContentItem, ContentOwner } from '@modrinth/ui'
import { invoke } from '@tauri-apps/api/core'
import type { InstallJobSnapshot } from './install'
import type { InstallJobSnapshot, SharedInstanceUpdateDiff } from './install'
import type {
CacheBehaviour,
ContentFile,
ContentFileProjectType,
GameInstance,
InstanceLoader,
SharedInstanceAttachment,
} from './types'
export async function remove(instanceId: string): Promise<void> {
@@ -335,3 +336,93 @@ export async function edit(instanceId: string, editInstance: Partial<GameInstanc
export async function edit_icon(instanceId: string, iconPath: string | null): Promise<void> {
return await invoke('plugin:instance|instance_edit_icon', { instanceId, iconPath })
}
export type SharedInstanceUsers = {
user_ids: string[]
users: SharedInstanceUser[]
tokens: number
}
export type SharedInstanceJoinType = 'owner' | 'invite' | 'link'
export type SharedInstanceUser = {
id: string
joined_at?: string | null
join_type: SharedInstanceJoinType
last_played?: string | null
}
export interface SharedInstancePublishPreview {
sharedInstanceId: string
latestVersion: number
diffs: SharedInstanceUpdateDiff[]
configFiles: string[]
}
export interface SharedInstanceInviteLink {
inviteId: string
expiresAt: string
maxUses: number
}
export async function can_current_user_use_shared_instances(): Promise<boolean> {
return await invoke('plugin:instance|instance_share_can_current_user_use')
}
export async function get_shared_instance_users(instanceId: string): Promise<SharedInstanceUsers> {
return await invoke('plugin:instance|instance_share_get_users', { instanceId })
}
export async function invite_shared_instance_users(
instanceId: string,
userIds: string[],
): Promise<SharedInstanceUsers> {
return await invoke('plugin:instance|instance_share_invite_users', { instanceId, userIds })
}
export async function create_shared_instance_invite_link(
instanceId: string,
options: {
maxAgeSeconds?: number
maxUses?: number
replaceInviteId?: string
} = {},
): Promise<SharedInstanceInviteLink> {
return await invoke('plugin:instance|instance_share_create_invite_link', {
instanceId,
...options,
})
}
export async function remove_shared_instance_users(
instanceId: string,
userIds: string[],
hasPendingRecipients: boolean,
): Promise<SharedInstanceUsers> {
return await invoke('plugin:instance|instance_share_remove_users', {
instanceId,
userIds,
hasPendingRecipients,
})
}
export async function get_shared_instance_publish_preview(
instanceId: string,
): Promise<SharedInstancePublishPreview | null> {
return await invoke('plugin:instance|instance_share_get_publish_preview', { instanceId })
}
export async function publish_shared_instance(
instanceId: string,
configPaths: string[],
): Promise<SharedInstanceAttachment> {
return await invoke('plugin:instance|instance_share_publish', { instanceId, configPaths })
}
export async function unlink_shared_instance(instanceId: string): Promise<void> {
return await invoke('plugin:instance|instance_share_unlink', { instanceId })
}
export async function unpublish_shared_instance(instanceId: string): Promise<void> {
return await invoke('plugin:instance|instance_share_unpublish', { instanceId })
}
+4 -2
View File
@@ -12,8 +12,10 @@ export type ModrinthCredentials = {
active: boolean
}
export async function login(): Promise<ModrinthCredentials> {
return await invoke('plugin:mr-auth|modrinth_login')
export type ModrinthAuthFlow = 'sign-in' | 'sign-up'
export async function login(flow: ModrinthAuthFlow = 'sign-in'): Promise<ModrinthCredentials> {
return await invoke('plugin:mr-auth|modrinth_login', { flow })
}
export async function logout(): Promise<void> {
@@ -1,8 +1,11 @@
import { ClassicPlayerModel, SlimPlayerModel } from '@modrinth/assets'
import {
applyCapeTexture,
applyEarsMod,
createTransparentTexture,
disposeCaches,
loadTexture,
removeEarsMod,
setupSkinModel,
} from '@modrinth/ui'
import * as THREE from 'three'
@@ -71,12 +74,13 @@ class BatchSkinRenderer {
textureUrl: string,
modelUrl: string,
capeUrl?: string,
earsTextureUrl?: string,
): Promise<RawRenderResult> {
this.initializeRenderer()
this.clearScene()
await this.setupModel(modelUrl, textureUrl, capeUrl)
await this.setupModel(modelUrl, textureUrl, capeUrl, earsTextureUrl)
const headPart = this.currentModel!.getObjectByName('Head')
let lookAtTarget: [number, number, number]
@@ -123,17 +127,29 @@ class BatchSkinRenderer {
})
}
private async setupModel(modelUrl: string, textureUrl: string, capeUrl?: string): Promise<void> {
private async setupModel(
modelUrl: string,
textureUrl: string,
capeUrl?: string,
earsTextureUrl?: string,
): Promise<void> {
if (!this.scene) {
throw new Error('Renderer not initialized')
}
const { model } = await setupSkinModel(modelUrl, textureUrl, capeUrl)
const [{ model }, earsTexture] = await Promise.all([
setupSkinModel(modelUrl, textureUrl, capeUrl),
earsTextureUrl ? loadTexture(earsTextureUrl) : Promise.resolve(null),
])
if (!capeUrl) {
applyCapeTexture(model, null, this.getTransparentTexture())
}
if (earsTexture) {
applyEarsMod(model, earsTexture)
}
const group = new THREE.Group()
group.add(model)
group.position.set(0, 0.3, 1.95)
@@ -154,6 +170,7 @@ class BatchSkinRenderer {
private clearScene(): void {
if (!this.scene || !this.currentModel) return
removeEarsMod(this.currentModel)
this.scene.remove(this.currentModel)
this.currentModel.clear()
this.currentModel = null
@@ -193,6 +210,7 @@ function getModelUrlForVariant(variant: string): string {
export const skinBlobUrlMap = reactive(new Map<string, RenderResult>())
export const headBlobUrlMap = reactive(new Map<string, string>())
const DEBUG_MODE = false
const SKIN_PREVIEW_RENDER_VERSION = 'ears-2-fixed-uvs'
let sharedRenderer: BatchSkinRenderer | null = null
let latestPreviewGeneration = 0
@@ -205,6 +223,10 @@ function getSharedRenderer(): BatchSkinRenderer {
return sharedRenderer
}
export function getSkinPreviewKey(skin: Skin): string {
return `${SKIN_PREVIEW_RENDER_VERSION}+${skin.texture_key}+${skin.variant}+${skin.cape_id ?? 'no-cape'}`
}
export function disposeSharedRenderer(): void {
if (sharedRenderer) {
sharedRenderer.dispose()
@@ -217,7 +239,7 @@ export async function cleanupUnusedPreviews(skins: Skin[]): Promise<void> {
const validHeadKeys = new Set<string>()
for (const skin of skins) {
const key = `${skin.texture_key}+${skin.variant}+${skin.cape_id ?? 'no-cape'}`
const key = getSkinPreviewKey(skin)
const headKey = `${skin.texture_key}-head`
validKeys.add(key)
validHeadKeys.add(headKey)
@@ -382,9 +404,7 @@ async function generateSkinPreviewsForGeneration(
const isCurrentGeneration = () => generation === latestPreviewGeneration
try {
const skinKeys = skins.map(
(skin) => `${skin.texture_key}+${skin.variant}+${skin.cape_id ?? 'no-cape'}`,
)
const skinKeys = skins.map(getSkinPreviewKey)
const headKeys = skins.map((skin) => `${skin.texture_key}-head`)
const [cachedSkinPreviews, cachedHeadPreviews] = await Promise.all([
@@ -415,7 +435,7 @@ async function generateSkinPreviewsForGeneration(
for (const skin of skins) {
if (!isCurrentGeneration()) return
const key = `${skin.texture_key}+${skin.variant}+${skin.cape_id ?? 'no-cape'}`
const key = getSkinPreviewKey(skin)
if (skinBlobUrlMap.has(key)) {
if (DEBUG_MODE) {
@@ -443,6 +463,7 @@ async function generateSkinPreviewsForGeneration(
await get_normalized_skin_texture(skin),
modelUrl,
cape?.texture,
skin.texture,
)
if (!isCurrentGeneration()) return
+19
View File
@@ -0,0 +1,19 @@
import { invoke } from '@tauri-apps/api/core'
export type ReportItemType = 'project' | 'version' | 'user' | 'shared-instance'
export interface CreateReportRequest {
report_type: string
item_id: string
item_type: ReportItemType
body: string
uploaded_images: string[]
}
export interface CreateReportResponse {
id: string
}
export async function create_report(request: CreateReportRequest) {
return await invoke<CreateReportResponse>('plugin:reports|reports_create', { request })
}
@@ -0,0 +1,98 @@
import { defineMessages, injectNotificationManager, useVIntl } from '@modrinth/ui'
import { getErrorMessage, type SharedInstanceUnavailableReason } from '@/helpers/install'
export const sharedInstanceErrorMessages = defineMessages({
unavailableTitle: {
id: 'instance.shared-instance.unavailable.title',
defaultMessage: 'Shared instance no longer available',
},
lockedTitle: {
id: 'instance.shared-instance.unavailable.locked-title',
defaultMessage: 'Instance locked',
},
unavailableText: {
id: 'instance.shared-instance.unavailable.text',
defaultMessage:
"Your local instance is still available, but it is no longer linked and won't receive updates.",
},
deletedText: {
id: 'instance.shared-instance.unavailable.deleted-text',
defaultMessage:
'The primary instance was deleted. This instance is still available, but it is no longer linked and will no longer receive updates.',
},
accessRevokedText: {
id: 'instance.shared-instance.unavailable.access-revoked-text',
defaultMessage:
'Your access to the shared instance was revoked. This instance is still available, but it is no longer linked and will no longer receive updates.',
},
lockedText: {
id: 'instance.shared-instance.unavailable.locked-text',
defaultMessage:
'This shared instance was locked by the Content Moderation team. It will no longer receive updates from the primary instance and cannot be played.',
},
unavailableFallbackManager: {
id: 'instance.shared-instance.unavailable.manager-fallback',
defaultMessage: 'the instance manager',
},
errorTitle: {
id: 'instance.shared-instance.error.title',
defaultMessage: 'Something has gone wrong',
},
})
export function sharedInstanceUnavailableTextMessage(
reason: SharedInstanceUnavailableReason | null,
) {
if (reason === 'deleted') return sharedInstanceErrorMessages.deletedText
if (reason === 'access_revoked') return sharedInstanceErrorMessages.accessRevokedText
if (reason === 'quarantined') return sharedInstanceErrorMessages.lockedText
return sharedInstanceErrorMessages.unavailableText
}
export function sharedInstanceUnavailableTitleMessage(
reason: SharedInstanceUnavailableReason | null,
) {
return reason === 'quarantined'
? sharedInstanceErrorMessages.lockedTitle
: sharedInstanceErrorMessages.unavailableTitle
}
export function useSharedInstanceErrors() {
const { formatMessage } = useVIntl()
const { addNotification } = injectNotificationManager()
function formatSharedInstanceUnavailable(
reason: SharedInstanceUnavailableReason | null = null,
manager?: string | null,
) {
return formatMessage(sharedInstanceUnavailableTextMessage(reason), {
manager: manager || formatMessage(sharedInstanceErrorMessages.unavailableFallbackManager),
})
}
function notifySharedInstanceUnavailable(
reason: SharedInstanceUnavailableReason | null = null,
manager?: string | null,
) {
addNotification({
type: 'warning',
title: formatMessage(sharedInstanceUnavailableTitleMessage(reason)),
text: formatSharedInstanceUnavailable(reason, manager),
})
}
function notifySharedInstanceError(error: unknown) {
addNotification({
type: 'error',
title: formatMessage(sharedInstanceErrorMessages.errorTitle),
text: getErrorMessage(error),
})
}
return {
formatSharedInstanceUnavailable,
notifySharedInstanceError,
notifySharedInstanceUnavailable,
}
}
+32 -1
View File
@@ -17,6 +17,8 @@ export type GameInstance = {
groups: string[]
link?: InstanceLink | null
shared_instance?: SharedInstanceAttachment | null
quarantined: boolean
update_channel: ReleaseChannel
created: Date
@@ -86,18 +88,47 @@ export type InstanceLink = InstanceLinkIdentity &
}
| {
type: 'shared_instance'
shared_instance_id: string
modpack_project_id?: ModrinthId | null
modpack_version_id?: ModrinthId | null
}
)
export type SharedInstanceAttachment = {
id: string
role: 'owner' | 'member'
manager_id?: string | null
server_manager_name?: string | null
server_manager_icon_url?: string | null
linked_user_id?: string | null
status:
| 'unknown'
| 'up_to_date'
| 'update_available'
| 'applying'
| 'stale'
| 'not_ready'
| 'error'
applied_version?: number | null
latest_version?: number | null
}
export type Instance = GameInstance
type ReleaseChannel = 'release' | 'beta' | 'alpha'
export type InstanceLoader = 'vanilla' | 'forge' | 'fabric' | 'quilt' | 'neoforge'
export type ContentSourceKind =
| 'local'
| 'modrinth_modpack'
| 'server_project'
| 'modrinth_hosting'
| 'imported_modpack'
| 'shared_instance'
type ContentFile = {
enabled: boolean
source_kind?: ContentSourceKind | null
metadata?: {
project_id: string
version_id: string
+11
View File
@@ -0,0 +1,11 @@
import { invoke } from '@tauri-apps/api/core'
export type SearchUser = {
id: string
username: string
avatar_url: string | null
}
export async function search_user(query: string): Promise<SearchUser[]> {
return await invoke<SearchUser[]>('plugin:users|search_user', { query })
}
@@ -455,9 +455,6 @@
"app.project.install-context.back-to-browse": {
"message": "العودة إلى الاستكشاف"
},
"app.project.install-context.install-content-to-instance": {
"message": "تثبيت المحتويات الى النموذج"
},
"app.project.version.all-versions": {
"message": "جميع الإصدارات"
},
@@ -392,9 +392,6 @@
"app.project.install-context.back-to-browse": {
"message": "Zpět k Objevování"
},
"app.project.install-context.install-content-to-instance": {
"message": "Nainstalovat obsah do instance"
},
"app.project.version.all-versions": {
"message": "Všechny verze"
},
@@ -296,9 +296,6 @@
"app.project.install-context.back-to-browse": {
"message": "Tilbage til udforsk"
},
"app.project.install-context.install-content-to-instance": {
"message": "Installer indhold til instance"
},
"app.project.versions.already-installed": {
"message": "Allerede installeret"
},
+21 -3
View File
@@ -428,21 +428,42 @@
"app.modal.install-to-play.header": {
"message": "Installieren zum Spielen"
},
"app.modal.install-to-play.install-anyway": {
"message": "Trotzdem Installieren"
},
"app.modal.install-to-play.install-button": {
"message": "Installieren"
},
"app.modal.install-to-play.invite-warning": {
"message": "Diese Einladung wurde von einem anderen Modrinth-Nutzer erstellt, nicht von Modrinth. Nimm nur Einladungen von Personen an, denen du vertraust."
},
"app.modal.install-to-play.mod-count": {
"message": "{count, plural, one {# Mod} other {# Mods}}"
},
"app.modal.install-to-play.reviewed-files": {
"message": "Eine Datei wird nur geprüft, wenn sie auf Modrinth veröffentlicht wird, unabhängig von ihrem Dateiformat (auch .mrpack)."
},
"app.modal.install-to-play.shared-instance": {
"message": "Geteilte Instanz"
},
"app.modal.install-to-play.unknown-files-description": {
"message": "Dieses Server-Modpack enthält Dateien, die nicht auf Modrinth veröffentlicht sind. Wir empfehlen dringend, nur Dateien aus vertrauenswürdigen Quellen zu installieren."
},
"app.modal.install-to-play.unknown-files-warning": {
"message": "Warnung vor unbekannten Dateien"
},
"app.modal.install-to-play.unrecognized-files": {
"message": "Nicht erkannte Dateien"
},
"app.modal.install-to-play.view-contents": {
"message": "Inhalte ansehen"
},
"app.modal.update-to-play.header": {
"message": "Aktualisieren zum Spielen"
},
"app.modal.update-to-play.removed": {
"message": "Entfernt"
},
"app.modal.update-to-play.update-required-description": {
"message": "Eine aktualisierung zum spielen von {name} ist benötigt. Bitte aktualisiere auf die neuste Version um das Spiel zu starten."
},
@@ -455,9 +476,6 @@
"app.project.install-context.back-to-browse": {
"message": "Zurück zu Entdecken"
},
"app.project.install-context.install-content-to-instance": {
"message": "Inhalt in Instanz installieren"
},
"app.project.version.all-versions": {
"message": "Alle Versionen"
},
+21 -3
View File
@@ -428,21 +428,42 @@
"app.modal.install-to-play.header": {
"message": "Installieren zum Spielen"
},
"app.modal.install-to-play.install-anyway": {
"message": "Trotzdem Installieren"
},
"app.modal.install-to-play.install-button": {
"message": "Installieren"
},
"app.modal.install-to-play.invite-warning": {
"message": "Diese Einladung wurde von einem anderen Modrinth-Nutzer erstellt, nicht von Modrinth. Nimm nur Einladungen von Personen an, denen du vertraust."
},
"app.modal.install-to-play.mod-count": {
"message": "{count, plural, one {# Mod} other {# Mods}}"
},
"app.modal.install-to-play.reviewed-files": {
"message": "Eine Datei wird nur geprüft, wenn sie auf Modrinth veröffentlicht wird, unabhängig von ihrem Dateiformat (auch .mrpack)."
},
"app.modal.install-to-play.shared-instance": {
"message": "Geteilte Instanz"
},
"app.modal.install-to-play.unknown-files-description": {
"message": "Dieses Server-Modpack enthält Dateien, die nicht auf Modrinth veröffentlicht sind. Wir empfehlen dringend, nur Dateien aus vertrauenswürdigen Quellen zu installieren."
},
"app.modal.install-to-play.unknown-files-warning": {
"message": "Warnung vor unbekannten Dateien"
},
"app.modal.install-to-play.unrecognized-files": {
"message": "Nicht erkannte Dateien"
},
"app.modal.install-to-play.view-contents": {
"message": "Inhalte ansehen"
},
"app.modal.update-to-play.header": {
"message": "Aktualisieren zum Spielen"
},
"app.modal.update-to-play.removed": {
"message": "Entfernt"
},
"app.modal.update-to-play.update-required-description": {
"message": "Zum Spielen von {name} ist eine Aktualisierung erforderlich. Bitte aktualisiere auf die neueste Version, um das Spiel zu starten."
},
@@ -455,9 +476,6 @@
"app.project.install-context.back-to-browse": {
"message": "Zurück zum Entdecken"
},
"app.project.install-context.install-content-to-instance": {
"message": "Inhalt in Instanz installieren"
},
"app.project.version.all-versions": {
"message": "Alle Versionen"
},
+453 -6
View File
@@ -89,6 +89,9 @@
"app.action-bar.install.unknown-instance": {
"message": "Unknown instance"
},
"app.action-bar.install.updating-shared-content": {
"message": "Updating shared content"
},
"app.action-bar.installs": {
"message": "Installs"
},
@@ -125,6 +128,21 @@
"app.action-bar.view-logs": {
"message": "View logs"
},
"app.ads-consent.accept": {
"message": "Accept all"
},
"app.ads-consent.body": {
"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."
},
"app.ads-consent.manage": {
"message": "Manage preferences"
},
"app.ads-consent.reject": {
"message": "Reject all"
},
"app.ads-consent.title": {
"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."
},
@@ -323,6 +341,36 @@
"app.install.phase.running_loader_processors": {
"message": "Running loader processors"
},
"app.instance.admonitions.shared-instance.added-label": {
"message": "Added"
},
"app.instance.admonitions.shared-instance.changes-body": {
"message": "Your local instance is ahead of the users you've shared it with."
},
"app.instance.admonitions.shared-instance.changes-header": {
"message": "Your changes haven't been shared yet"
},
"app.instance.admonitions.shared-instance.publish-button": {
"message": "Push update"
},
"app.instance.admonitions.shared-instance.publishing-button": {
"message": "Pushing..."
},
"app.instance.admonitions.shared-instance.removed-label": {
"message": "Removed"
},
"app.instance.admonitions.shared-instance.review-admonition-header": {
"message": "Push update"
},
"app.instance.admonitions.shared-instance.review-description": {
"message": "Review the content changes that will be shared with everyone using this instance."
},
"app.instance.admonitions.shared-instance.review-header": {
"message": "Review changes"
},
"app.instance.admonitions.shared-instance.reviewing-button": {
"message": "Reviewing..."
},
"app.instance.confirm-delete.admonition-body": {
"message": "All data for your instance will be permanently deleted, including your worlds, configs, and all installed content."
},
@@ -359,6 +407,9 @@
"app.instance.mods.content-type-project": {
"message": "project"
},
"app.instance.mods.locked-content": {
"message": "Content in locked instances cannot be changed."
},
"app.instance.mods.project-was-added": {
"message": "\"{name}\" was added"
},
@@ -374,6 +425,102 @@
"app.instance.mods.successfully-uploaded": {
"message": "Successfully uploaded"
},
"app.instance.share.empty.description": {
"message": "You can share this instance with your friends!"
},
"app.instance.share.empty.heading": {
"message": "No friends invited"
},
"app.instance.share.empty.invite-friends-button": {
"message": "Invite friends"
},
"app.instance.share.invite-modal.heading": {
"message": "Share {name}"
},
"app.instance.share.locked.empty-description-prefix": {
"message": "You need to sign in as"
},
"app.instance.share.locked.empty-description-suffix": {
"message": "to access this page."
},
"app.instance.share.locked.linked-account-fallback": {
"message": "the linked account"
},
"app.instance.share.locked.signed-out-heading": {
"message": "Not signed in"
},
"app.instance.share.locked.switch-account-button": {
"message": "Switch account"
},
"app.instance.share.locked.wrong-account-heading": {
"message": "Wrong account"
},
"app.instance.share.remove-user-modal.effect-access": {
"message": "They will no longer receive updates for this shared instance"
},
"app.instance.share.remove-user-modal.effect-installed-copy": {
"message": "Any copy they already installed will stay on their device"
},
"app.instance.share.remove-user-modal.effect-invite-again": {
"message": "You can invite them again later"
},
"app.instance.share.remove-user-modal.effect-last-user": {
"message": "This is the last user, sharing will be turned off for this instance"
},
"app.instance.share.remove-user-modal.effects-label": {
"message": "What happens?"
},
"app.instance.share.remove-user-modal.header": {
"message": "Revoke access"
},
"app.instance.share.remove-user-modal.remove-button": {
"message": "Revoke access"
},
"app.instance.share.remove-user-modal.user-avatar-alt": {
"message": "{username}'s avatar"
},
"app.instance.share.remove-user-modal.warning-body": {
"message": "If you revoke {username}'s access to this shared instance, you'll need to invite them again before they can receive updates."
},
"app.instance.share.sign-in.button": {
"message": "Sign in"
},
"app.instance.share.unlink.body": {
"message": "You must unlink this modpack to share your instance"
},
"app.instance.share.unlink.header": {
"message": "Sharing requires unlinking"
},
"app.instance.shared-instance-already-installed.body": {
"message": "This shared instance is already installed as <bold>{instanceName}</bold>. Are you sure you want to install another copy?"
},
"app.instance.shared-instance-already-installed.header": {
"message": "Shared instance already installed"
},
"app.instance.shared-instance-already-installed.install-anyway": {
"message": "Install anyway"
},
"app.instance.shared-instance-already-installed.instance": {
"message": "Instance"
},
"app.instance.shared-instance-wrong-account.fallback-username": {
"message": "the linked account"
},
"app.instance.shared-instance-wrong-account.owner-admonition-body-v2": {
"message": "to manage this shared instance. You won't be able to push updates to users."
},
"app.instance.shared-instance-wrong-account.sign-in-as-label": {
"message": "Sign in as"
},
"app.instance.shared-instance-wrong-account.signed-out-header": {
"message": "You need to sign in to Modrinth"
},
"app.instance.shared-instance-wrong-account.user-admonition-body-v2": {
"message": "to receive updates for this shared instance."
},
"app.instance.shared-instance-wrong-account.warning-header": {
"message": "You are using the wrong Modrinth account"
},
"app.instance.worlds.add-server": {
"message": "Add server"
},
@@ -425,8 +572,20 @@
"app.instance.worlds.search-worlds-placeholder": {
"message": "Search {count} worlds..."
},
"app.modal.install-to-play.dont-install": {
"message": "Dont install"
"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.content-you-are-reporting": {
"message": "Instance youre reporting"
},
"app.modal.install-to-play.delete-instance": {
"message": "Delete instance"
},
"app.modal.install-to-play.external-files-dont-install": {
"message": "Don't install"
},
"app.modal.install-to-play.header": {
"message": "Install to play"
@@ -443,12 +602,51 @@
"app.modal.install-to-play.mod-count": {
"message": "{count, plural, one {# mod} other {# mods}}"
},
"app.modal.install-to-play.report-description": {
"message": "Use this form to report instances that may violate our <rules-link>Rules</rules-link> or <terms-link>Terms of Use</terms-link>."
},
"app.modal.install-to-play.report-image-invalid-type": {
"message": "File is not an accepted image type"
},
"app.modal.install-to-play.report-image-too-large": {
"message": "File exceeds the 1 MiB size limit"
},
"app.modal.install-to-play.report-legal-claims": {
"message": "For DMCA notices or other legal claims, see our <copyright-link>Copyright Policy</copyright-link>."
},
"app.modal.install-to-play.report-reason": {
"message": "Which rule does this instance violate?"
},
"app.modal.install-to-play.report-reason.inappropriate": {
"message": "Inappropriate"
},
"app.modal.install-to-play.report-reason.malicious": {
"message": "Malicious"
},
"app.modal.install-to-play.report-reason.spam": {
"message": "Spam"
},
"app.modal.install-to-play.report-shared-instance-header": {
"message": "Report shared instance"
},
"app.modal.install-to-play.report-submitted": {
"message": "Report submitted"
},
"app.modal.install-to-play.report-support-and-bugs": {
"message": "For support requests, contact our <support-link>support team</support-link>. For bug reports, open a <github-link>GitHub issue</github-link>."
},
"app.modal.install-to-play.reviewed-files": {
"message": "A file is only reviewed if its published to Modrinth, regardless of its file format (including .mrpack)."
},
"app.modal.install-to-play.shared-instance": {
"message": "Shared instance"
},
"app.modal.install-to-play.shared-instance-content": {
"message": "Shared instance content"
},
"app.modal.install-to-play.shared-instance-unknown-files-description": {
"message": "This shared instance contains files that arent published on Modrinth. We strongly recommend only installing files from sources you trust."
},
"app.modal.install-to-play.unknown-files-description": {
"message": "This server modpack contains files that arent published on Modrinth. We strongly recommend only installing files from sources you trust."
},
@@ -467,8 +665,23 @@
"app.modal.update-to-play.removed": {
"message": "Removed"
},
"app.modal.update-to-play.server-modpack-unknown-files-description": {
"message": "This server modpack update contains files that arent published on Modrinth. We strongly recommend only installing files from sources you trust."
},
"app.modal.update-to-play.shared-instance-added-label": {
"message": "Added"
},
"app.modal.update-to-play.shared-instance-removed-label": {
"message": "Removed"
},
"app.modal.update-to-play.shared-instance-unknown-files-description": {
"message": "This shared instance update contains files that arent published on Modrinth. We strongly recommend only installing files from sources you trust."
},
"app.modal.update-to-play.update-required": {
"message": "Update required"
},
"app.modal.update-to-play.update-required-description": {
"message": "An update is required to play {name}. Please update to the latest version to launch the game."
"message": "An update is required to play {name}. Please update to latest version to launch the game."
},
"app.project.install-button.already-installed": {
"message": "This project is already installed"
@@ -479,9 +692,6 @@
"app.project.install-context.back-to-browse": {
"message": "Back to discover"
},
"app.project.install-context.install-content-to-instance": {
"message": "Install content to instance"
},
"app.project.version.all-versions": {
"message": "All versions"
},
@@ -500,6 +710,9 @@
"app.settings.downloading": {
"message": "Downloading v{version}"
},
"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.tabs.appearance": {
"message": "Appearance"
},
@@ -542,6 +755,9 @@
"app.skins.dropped-file-error.title": {
"message": "Error processing file"
},
"app.skins.ears-feature-notice": {
"message": "This skin uses features from the {ears} mod"
},
"app.skins.edit-button": {
"message": "Edit skin"
},
@@ -668,6 +884,12 @@
"app.skins.title": {
"message": "Skin selector"
},
"app.skins.toggle-ears-features-off": {
"message": "Toggle off"
},
"app.skins.toggle-ears-features-on": {
"message": "Toggle on"
},
"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."
},
@@ -716,6 +938,39 @@
"app.world.world-item.players-online": {
"message": "{count} online"
},
"content.shared-instance.change-version-body": {
"message": "Changing the version only changes your local copy. Future shared instance updates may restore or change it again."
},
"content.shared-instance.delete-bulk-body": {
"message": "Some selected projects are part of the shared instance. Deleting them only changes your local copy, and future shared instance updates may restore or change them again."
},
"content.shared-instance.delete-button": {
"message": "Delete anyway"
},
"content.shared-instance.delete-many-button": {
"message": "Delete {count, number} projects anyway"
},
"content.shared-instance.delete-single-body": {
"message": "Deleting it only changes your local copy. Future shared instance updates may restore or change it again."
},
"content.shared-instance.disable-bulk-body": {
"message": "Some selected projects are part of the shared instance. Disabling them only changes your local copy, and future shared instance updates may re-enable, restore, or change them again."
},
"content.shared-instance.disable-button": {
"message": "Disable anyway"
},
"content.shared-instance.disable-many-button": {
"message": "Disable {count, number} projects anyway"
},
"content.shared-instance.disable-single-body": {
"message": "Disabling it only changes your local copy. Future shared instance updates may re-enable, restore, or change it again."
},
"content.shared-instance.unlink-body": {
"message": "Unlinking only changes your local copy. Future shared instance updates may restore or change it again."
},
"content.shared-instance.warning-header": {
"message": "This is part of the shared instance"
},
"friends.action.add-friend": {
"message": "Add a friend"
},
@@ -779,6 +1034,75 @@
"friends.sign-in-to-add-friends": {
"message": "<link>Sign in to a Modrinth account</link> to add friends and see what they're playing!"
},
"installation-settings.shared-instance.linked-title": {
"message": "Linked shared instance"
},
"installation-settings.shared-instance.title": {
"message": "Unpublish instance"
},
"installation-settings.shared-instance.unlink-button": {
"message": "Unlink shared instance"
},
"installation-settings.shared-instance.unlink-description": {
"message": "Disconnect this local instance from future shared updates."
},
"installation-settings.shared-instance.unlinking-button": {
"message": "Unlinking..."
},
"installation-settings.shared-instance.unpublish-button": {
"message": "Unpublish shared instance"
},
"installation-settings.shared-instance.unpublish-description": {
"message": "Remove this shared instance from Modrinth and stop sending updates to anyone using it. Your local instance will not be affected."
},
"installation-settings.shared-instance.unpublishing-button": {
"message": "Unpublishing..."
},
"installation-settings.unlink-shared-instance.modal.admonition-body": {
"message": "This only affects your local instance. Your installed content will stay on this device, and the shared instance and other people using it will not be affected."
},
"installation-settings.unlink-shared-instance.modal.admonition-header": {
"message": "Unlinking shared instance"
},
"installation-settings.unlink-shared-instance.modal.header": {
"message": "Unlink shared instance"
},
"installation-settings.unpublish-shared-instance.modal.admonition-body": {
"message": "This deletes the shared instance from Modrinth's servers. People using it in the Modrinth App will stop receiving updates, but your local instance and its content will stay on this device."
},
"installation-settings.unpublish-shared-instance.modal.admonition-header": {
"message": "Unpublishing shared instance"
},
"installation-settings.unpublish-shared-instance.modal.header": {
"message": "Unpublish shared instance"
},
"instance.action.create-shortcut": {
"message": "Create shortcut"
},
"instance.action.export-modpack": {
"message": "Export modpack"
},
"instance.action.launch-instance": {
"message": "Launch instance"
},
"instance.action.more-actions": {
"message": "More actions"
},
"instance.action.open-folder": {
"message": "Open folder"
},
"instance.action.repair": {
"message": "Repair"
},
"instance.action.settings": {
"message": "Instance settings"
},
"instance.action.starting": {
"message": "Starting..."
},
"instance.action.stopping": {
"message": "Stopping..."
},
"instance.add-server.add-and-play": {
"message": "Add and play"
},
@@ -821,6 +1145,15 @@
"instance.files.save-as": {
"message": "Save as..."
},
"instance.locked.delete-button": {
"message": "Delete instance"
},
"instance.locked.play-tooltip": {
"message": "This instance has been locked"
},
"instance.playtime.never-played": {
"message": "Never played"
},
"instance.server-modal.address": {
"message": "Address"
},
@@ -956,6 +1289,9 @@
"instance.settings.tabs.installation.loader-version": {
"message": "{loader} version"
},
"instance.settings.tabs.installation.locked": {
"message": "Installation settings are unavailable while this instance is locked."
},
"instance.settings.tabs.java": {
"message": "Java and memory"
},
@@ -995,6 +1331,9 @@
"instance.settings.tabs.java.java-path-placeholder": {
"message": "/path/to/java"
},
"instance.settings.tabs.sharing": {
"message": "Sharing"
},
"instance.settings.tabs.window": {
"message": "Window"
},
@@ -1025,6 +1364,57 @@
"instance.settings.tabs.window.width.enter": {
"message": "Enter width..."
},
"instance.shared-instance.error.title": {
"message": "Something has gone wrong"
},
"instance.shared-instance.owner-tooltip": {
"message": "This instance's content is being shared to other users."
},
"instance.shared-instance.publish-review.added-label": {
"message": "Added"
},
"instance.shared-instance.publish-review.admonition-header": {
"message": "Push update to players"
},
"instance.shared-instance.publish-review.config-title-v2": {
"message": "Select config files"
},
"instance.shared-instance.publish-review.description": {
"message": "Review the content changes and choose any config files to include in this update."
},
"instance.shared-instance.publish-review.header": {
"message": "Review changes"
},
"instance.shared-instance.publish-review.publish-button": {
"message": "Push update"
},
"instance.shared-instance.publish-review.removed-label": {
"message": "Removed"
},
"instance.shared-instance.tooltip": {
"message": "This instance's content is being managed by someone else."
},
"instance.shared-instance.unavailable.access-revoked-text": {
"message": "Your access to the shared instance was revoked. This instance is still available, but it is no longer linked and will no longer receive updates."
},
"instance.shared-instance.unavailable.deleted-text": {
"message": "The primary instance was deleted. This instance is still available, but it is no longer linked and will no longer receive updates."
},
"instance.shared-instance.unavailable.locked-text": {
"message": "This shared instance was locked by the Content Moderation team. It will no longer receive updates from the primary instance and cannot be played."
},
"instance.shared-instance.unavailable.locked-title": {
"message": "Instance locked"
},
"instance.shared-instance.unavailable.manager-fallback": {
"message": "the instance manager"
},
"instance.shared-instance.unavailable.text": {
"message": "Your local instance is still available, but it is no longer linked and won't receive updates."
},
"instance.shared-instance.unavailable.title": {
"message": "Shared instance no longer available"
},
"instance.worlds.a_minecraft_server": {
"message": "A Minecraft Server"
},
@@ -1088,6 +1478,63 @@
"minecraft-account.sign-in": {
"message": "Sign in to Minecraft"
},
"minecraft-required.description": {
"message": "You need a Microsoft account that owns Minecraft before you can launch and play."
},
"minecraft-required.description-header": {
"message": "Sign in to a Microsoft account"
},
"minecraft-required.dont-have-account": {
"message": "Dont have an account?"
},
"minecraft-required.get-minecraft": {
"message": "Get Minecraft"
},
"minecraft-required.get-support": {
"message": "Get support"
},
"minecraft-required.header": {
"message": "Minecraft required"
},
"minecraft-required.sign-in": {
"message": "Sign in to Microsoft"
},
"modal.modrinth-account-required.browser-description": {
"message": "A new tab opened to sign in. Complete the sign in there, then return to the app."
},
"modal.modrinth-account-required.cancel-button": {
"message": "Cancel"
},
"modal.modrinth-account-required.continue-in-browser-heading": {
"message": "Continue in your browser"
},
"modal.modrinth-account-required.create-account-button": {
"message": "Create an account"
},
"modal.modrinth-account-required.description": {
"message": "You'll need to sign into your Modrinth account before you can use this feature."
},
"modal.modrinth-account-required.header": {
"message": "Account required"
},
"modal.modrinth-account-required.open-browser-again-button": {
"message": "Open browser again"
},
"modal.modrinth-account-required.sign-in-button": {
"message": "Sign in to Modrinth"
},
"modal.modrinth-account-required.sign-in-heading": {
"message": "Sign in to a Modrinth account"
},
"modal.modrinth-account-required.signing-in-header": {
"message": "Signing in"
},
"modal.modrinth-account-required.support-prompt": {
"message": "Having trouble signing in? <support>Get support</support>"
},
"modal.modrinth-account-required.waiting-for-browser": {
"message": "Waiting for browser confirmation..."
},
"search.filter.locked.instance": {
"message": "Provided by the instance"
},
@@ -428,21 +428,42 @@
"app.modal.install-to-play.header": {
"message": "Instalar para jugar"
},
"app.modal.install-to-play.install-anyway": {
"message": "Instalar de todos modos"
},
"app.modal.install-to-play.install-button": {
"message": "Instalar"
},
"app.modal.install-to-play.invite-warning": {
"message": "Esta invitación la creó otro usuario de Modrinth, no Modrinth. Solo acepta invitaciones de personas en las que confíes."
},
"app.modal.install-to-play.mod-count": {
"message": "{count, plural, one {# mod} other {# mods}}"
},
"app.modal.install-to-play.reviewed-files": {
"message": "Un archivo solo se revisa si se publica en Modrinth, sin importar su formato (incluido el .mrpack)."
},
"app.modal.install-to-play.shared-instance": {
"message": "Instancia compartida"
},
"app.modal.install-to-play.unknown-files-description": {
"message": "Este modpack para servidor contiene archivos que no están publicados en Modrinth. Recomendamos encarecidamente instalar únicamente archivos de fuentes de confianza."
},
"app.modal.install-to-play.unknown-files-warning": {
"message": "Advertencia sobre archivos desconocidos"
},
"app.modal.install-to-play.unrecognized-files": {
"message": "Archivos no reconocidos"
},
"app.modal.install-to-play.view-contents": {
"message": "Ver contenidos"
},
"app.modal.update-to-play.header": {
"message": "Actualizar para jugar"
},
"app.modal.update-to-play.removed": {
"message": "Eliminado"
},
"app.modal.update-to-play.update-required-description": {
"message": "Se requiere una actualización para jugar {name}. Por favor, actualiza a la versión más reciente para iniciar el juego."
},
@@ -455,9 +476,6 @@
"app.project.install-context.back-to-browse": {
"message": "Volver al explorador"
},
"app.project.install-context.install-content-to-instance": {
"message": "Instalar contenido a la instancia"
},
"app.project.version.all-versions": {
"message": "Todas las versiones"
},
@@ -296,9 +296,6 @@
"app.project.install-context.back-to-browse": {
"message": "Volver a descubrir"
},
"app.project.install-context.install-content-to-instance": {
"message": "Instalar contenido"
},
"app.settings.developer-mode-enabled": {
"message": "Modo desarrollador activado."
},
@@ -377,9 +377,6 @@
"app.project.install-context.back-to-browse": {
"message": "Takaisin sisällön löytöön"
},
"app.project.install-context.install-content-to-instance": {
"message": "Asenna sisältöä instanssiin"
},
"app.settings.developer-mode-enabled": {
"message": "Kehittäjätila käytössä."
},
+51 -9
View File
@@ -9,7 +9,7 @@
"message": "Téléchargements"
},
"app.action-bar.hide-more-running-instances": {
"message": "Masquer plus d'instances en exécution"
"message": "Masquer plus d'instances en cours d'exécution"
},
"app.action-bar.install.copied-details": {
"message": "Copié"
@@ -36,7 +36,7 @@
"message": "Annulé"
},
"app.action-bar.install.summary.cleanup-incomplete": {
"message": "Le nettoyage n'est pas terminé"
"message": "Le nettoyage n'a pas terminé"
},
"app.action-bar.install.summary.content-download-failed": {
"message": "Impossible de télécharger les fichiers"
@@ -65,6 +65,27 @@
"app.action-bar.install.summary.java-setup-failed": {
"message": "L'installation de Java n'a pas pu se terminer"
},
"app.action-bar.install.summary.loader-setup-failed": {
"message": "La mise en place du loader a échoué"
},
"app.action-bar.install.summary.local-data-error": {
"message": "Impossible de mettre à jour les données locales"
},
"app.action-bar.install.summary.minecraft-setup-failed": {
"message": "La mise en place de Minecraft a échoué"
},
"app.action-bar.install.summary.modrinth-unreachable": {
"message": "Impossible d'atteindre les serveurs Modrinth"
},
"app.action-bar.install.summary.no-write-permission": {
"message": "Aucune permission d'écriture"
},
"app.action-bar.install.summary.pack-download-failed": {
"message": "Impossible de télécharger le pack"
},
"app.action-bar.install.summary.unexpected-error": {
"message": "Quelque chose s'est mal passé"
},
"app.action-bar.install.unknown-instance": {
"message": "Instance inconnue"
},
@@ -72,22 +93,22 @@
"message": "Installations"
},
"app.action-bar.make-primary-instance": {
"message": "Définir en tante qu'instance première"
"message": "Définir en tant qu'instance principale"
},
"app.action-bar.no-instances-running": {
"message": "Aucune instance en exécution"
"message": "Aucune instance en cours d'exécution"
},
"app.action-bar.offline": {
"message": "Hors ligne"
},
"app.action-bar.primary-instance": {
"message": "Instance première"
"message": "Instance principale"
},
"app.action-bar.reload-to-update": {
"message": "Recharger pour mettre à jour"
},
"app.action-bar.show-more-running-instances": {
"message": "Montrer plus d'instances en exécution"
"message": "Montrer plus d'instances en cours d'exécution"
},
"app.action-bar.stop-instance": {
"message": "Arrêter l'instance"
@@ -263,6 +284,9 @@
"app.install.phase.finalizing": {
"message": "Finalisation"
},
"app.install.phase.preparing_instance": {
"message": "En attente d'installation"
},
"app.install.phase.preparing_java": {
"message": "Préparation de Java"
},
@@ -404,21 +428,42 @@
"app.modal.install-to-play.header": {
"message": "Installer pour jouer"
},
"app.modal.install-to-play.install-anyway": {
"message": "Installer quand même"
},
"app.modal.install-to-play.install-button": {
"message": "Installer"
},
"app.modal.install-to-play.invite-warning": {
"message": "Cette invitation a été crée par un autre utilisateur Modrinth, pas par Modrinth. N'acceptez seulement les invitations de personnes en qui vous avez confiance."
},
"app.modal.install-to-play.mod-count": {
"message": "{count, plural, one {# mod} other {# mods}}"
},
"app.modal.install-to-play.reviewed-files": {
"message": "Un fichier est seulement examiné s'il est publié sur Modrinth, peu importe son extension (y compris .mrpack)."
},
"app.modal.install-to-play.shared-instance": {
"message": "Instance partagée"
},
"app.modal.install-to-play.unknown-files-description": {
"message": "Ce modpack de serveur contient des fichiers qui ne sont pas publiés sur Modrinth. Nous recommandons fortement de seulement installer des fichiers de sources en qui vous avez confiance."
},
"app.modal.install-to-play.unknown-files-warning": {
"message": "Avertissement de fichiers inconnus"
},
"app.modal.install-to-play.unrecognized-files": {
"message": "Fichiers non reconnus"
},
"app.modal.install-to-play.view-contents": {
"message": "Voir le contenu"
},
"app.modal.update-to-play.header": {
"message": "Mettre à jour pour jouer"
},
"app.modal.update-to-play.removed": {
"message": "Supprimé"
},
"app.modal.update-to-play.update-required-description": {
"message": "Une mise à jour est requise pour jouer à {name}. Veuillez mettre à jour à la dernière version pour lancer le jeu."
},
@@ -431,9 +476,6 @@
"app.project.install-context.back-to-browse": {
"message": "Retour vers la page découvrir"
},
"app.project.install-context.install-content-to-instance": {
"message": "Installer du contenu à l'instance"
},
"app.project.version.all-versions": {
"message": "Toutes les versions"
},
+29 -11
View File
@@ -51,7 +51,7 @@
"message": "A letöltés nem sikerült"
},
"app.action-bar.install.summary.instance-not-found": {
"message": "A példány nem található"
"message": "A játékprofil nem található"
},
"app.action-bar.install.summary.invalid-file-path": {
"message": "A fájl elérési útja érvénytelen"
@@ -87,7 +87,7 @@
"message": "Valami hiba történt"
},
"app.action-bar.install.unknown-instance": {
"message": "Ismeretlen profil"
"message": "Ismeretlen játékprofil"
},
"app.action-bar.installs": {
"message": "Telepítések"
@@ -240,7 +240,7 @@
"message": "Modcsomagok"
},
"app.browse.server-instance-content-warning": {
"message": "A tartalom hozzáadása ronthatja a kompatibilitást a szerverhez való csatlakozáskor. A hozzáadott tartalom elveszik, ha frissíted a szerverpéldány tartalmát."
"message": "A tartalom hozzáadása ronthatja a kompatibilitást a szerverhez való csatlakozáskor. A hozzáadott tartalom elveszik, ha frissíted a szerverprofil tartalmát."
},
"app.browse.server.installing": {
"message": "Letöltés"
@@ -291,16 +291,16 @@
"message": "Java előkészítése"
},
"app.install.phase.preparing_java.downloading": {
"message": "Java verzió {version} letöltése"
"message": "A Java {version} letöltése..."
},
"app.install.phase.preparing_java.extracting": {
"message": "Java verzió {version} kibontása"
"message": "A Java {version} kibontása..."
},
"app.install.phase.preparing_java.fetching-metadata": {
"message": "Java verzió ellőkészítése {version}"
"message": "A Java {version} előkészítése..."
},
"app.install.phase.preparing_java.resolving": {
"message": "Java verzió {version} előkészítése"
"message": "A Java {version} előkészítése..."
},
"app.install.phase.preparing_java.validating": {
"message": "A Java {version} ellenőrzése"
@@ -324,7 +324,7 @@
"message": "Betöltő folyamatok futtatása"
},
"app.instance.confirm-delete.admonition-body": {
"message": "Az profilodhoz tartozó összes adat véglegesen törlődik, beleértve a világokat, a beállításokat és az összes telepített tartalmat."
"message": "A profilodhoz tartozó összes adat véglegesen törlődik, beleértve a világokat, a beállításokat és az összes telepített tartalmat."
},
"app.instance.confirm-delete.admonition-header": {
"message": "Ezt a műveletet nem lehet visszavonni"
@@ -428,21 +428,42 @@
"app.modal.install-to-play.header": {
"message": "Telepítés a játékhoz"
},
"app.modal.install-to-play.install-anyway": {
"message": "Telepítés mindenképpen"
},
"app.modal.install-to-play.install-button": {
"message": "Telepítés"
},
"app.modal.install-to-play.invite-warning": {
"message": "Ezt a meghívót nem a Modrinth, hanem egy másik Modrinth-felhasználó hozta létre. Csak olyan személyektől fogadd el a meghívásokat, akikben megbízol."
},
"app.modal.install-to-play.mod-count": {
"message": "{count} mod"
},
"app.modal.install-to-play.reviewed-files": {
"message": "Egy fájlt csak akkor vizsgálnak meg, ha azt a Modrinth-en közzétették, függetlenül a fájlformátumtól (beleértve a .mrpack formátumot is)."
},
"app.modal.install-to-play.shared-instance": {
"message": "Megosztott profil"
},
"app.modal.install-to-play.unknown-files-description": {
"message": "Ez a szerver-modpack olyan fájlokat tartalmaz, amelyek nem szerepelnek a Modrinth oldalon. Határozottan javasoljuk, hogy kizárólag olyan forrásokból telepíts fájlokat, amelyekben megbízol."
},
"app.modal.install-to-play.unknown-files-warning": {
"message": "Figyelmeztetés ismeretlen fájlokról"
},
"app.modal.install-to-play.unrecognized-files": {
"message": "Felismerhetetlen fájlok"
},
"app.modal.install-to-play.view-contents": {
"message": "Tartalom megtekintése"
},
"app.modal.update-to-play.header": {
"message": "Frissítsd a játékhoz"
},
"app.modal.update-to-play.removed": {
"message": "Törölve"
},
"app.modal.update-to-play.update-required-description": {
"message": "Frissítés szükséges ehhez: {name}. Kérlek, frissíts a legújabb verzióra a játék elindításához."
},
@@ -455,9 +476,6 @@
"app.project.install-context.back-to-browse": {
"message": "Vissza a böngészéshez"
},
"app.project.install-context.install-content-to-instance": {
"message": "Tartalom telepítése a példányba"
},
"app.project.version.all-versions": {
"message": "Összes verzió"
},
@@ -272,9 +272,6 @@
"app.project.install-context.back-to-browse": {
"message": "Kembali ke halaman pencarian"
},
"app.project.install-context.install-content-to-instance": {
"message": "Pasang konten ke instans"
},
"app.settings.developer-mode-enabled": {
"message": "Mode pengembang dihidupkan."
},
+21 -3
View File
@@ -425,21 +425,42 @@
"app.modal.install-to-play.header": {
"message": "Installa per continuare"
},
"app.modal.install-to-play.install-anyway": {
"message": "Installa comunque"
},
"app.modal.install-to-play.install-button": {
"message": "Installa"
},
"app.modal.install-to-play.invite-warning": {
"message": "Questo invito è stato creato da un utente e non da Modrinth. Accetta solo gli inviti da persone fidate."
},
"app.modal.install-to-play.mod-count": {
"message": "{count} mod"
},
"app.modal.install-to-play.reviewed-files": {
"message": "Solo i file pubblicati su Modrinth vengono esaminati, qualunque sia il loro formato (.mrpack inclusi)."
},
"app.modal.install-to-play.shared-instance": {
"message": "Istanza condivisa"
},
"app.modal.install-to-play.unknown-files-description": {
"message": "Questo pacchetto contiene dei file che non sono su Modrinth. Consigliamo di installare file solo da fonti attendibili."
},
"app.modal.install-to-play.unknown-files-warning": {
"message": "File sconosciuti"
},
"app.modal.install-to-play.unrecognized-files": {
"message": "File non riconosciuti"
},
"app.modal.install-to-play.view-contents": {
"message": "Mostra contenuti"
},
"app.modal.update-to-play.header": {
"message": "Aggiorna per continuare"
},
"app.modal.update-to-play.removed": {
"message": "Rimosso"
},
"app.modal.update-to-play.update-required-description": {
"message": "{name} richiede degli aggiornamenti. Installa l'ultima versione per poter giocare."
},
@@ -452,9 +473,6 @@
"app.project.install-context.back-to-browse": {
"message": "Torna a Esplora"
},
"app.project.install-context.install-content-to-instance": {
"message": "Installa il contenuto nell'istanza"
},
"app.project.version.all-versions": {
"message": "Tutte le versioni"
},
@@ -143,6 +143,12 @@
"app.appearance-settings.show-play-time.title": {
"message": "プレイ時間を表示"
},
"app.appearance-settings.skip-non-essential-warnings.description": {
"message": "Modパックの重複インストール、通常のコンテンツ削除、一括更新、Modパックのリンク解除、修復の促しなど、リスクの低い確認画面は自動的にスキップされます。危険な警告は引き続き表示されます。"
},
"app.appearance-settings.skip-non-essential-warnings.title": {
"message": "重要でない警告をスキップ"
},
"app.appearance-settings.toggle-sidebar.description": {
"message": "サイドバーの表示/非表示を切り替える機能を有効にします。"
},
@@ -173,12 +179,18 @@
"app.browse.already-added": {
"message": "すでに追加済み"
},
"app.browse.back-to-instance": {
"message": "インスタンスに戻る"
},
"app.browse.discover-content": {
"message": "コンテンツを探す"
},
"app.browse.discover-servers": {
"message": "サーバーを探す"
},
"app.browse.hide-added-servers": {
"message": "既に追加済みのサーバーを非表示にする"
},
"app.browse.project-type.modpacks": {
"message": "Modパック"
},
+94 -4
View File
@@ -11,6 +11,12 @@
"app.action-bar.hide-more-running-instances": {
"message": "실행 중인 인스턴스 숨기기"
},
"app.action-bar.install.copied-details": {
"message": "복사됨"
},
"app.action-bar.install.copy-details": {
"message": "세부 정보 복사"
},
"app.action-bar.install.dismiss": {
"message": "무시"
},
@@ -20,6 +26,66 @@
"app.action-bar.install.retry": {
"message": "재시도"
},
"app.action-bar.install.summary.app-closing": {
"message": "앱이 종료되어 취소되었습니다"
},
"app.action-bar.install.summary.bad-modpack-file": {
"message": "모드팩을 읽을 수 없음"
},
"app.action-bar.install.summary.canceled": {
"message": "취소됨"
},
"app.action-bar.install.summary.cleanup-incomplete": {
"message": "파일 정리가 완료되지 않았습니다"
},
"app.action-bar.install.summary.content-download-failed": {
"message": "파일을 다운로드할 수 없습니다"
},
"app.action-bar.install.summary.corrupt-download": {
"message": "다운로드한 파일이 손상되었습니다"
},
"app.action-bar.install.summary.could-not-save-files": {
"message": "파일을 저장할 수 없습니다"
},
"app.action-bar.install.summary.download-failed": {
"message": "다운로드를 완료할 수 없습니다"
},
"app.action-bar.install.summary.instance-not-found": {
"message": "인스턴스를 찾을 수 없습니다"
},
"app.action-bar.install.summary.invalid-file-path": {
"message": "파일 경로가 올바르지 않습니다"
},
"app.action-bar.install.summary.invalid-modpack": {
"message": "모드팩 데이터가 올바르지 않습니다"
},
"app.action-bar.install.summary.invalid-modpack-files": {
"message": "모드팩 파일이 올바르지 않은 메타데이터를 가지고 있습니다"
},
"app.action-bar.install.summary.java-setup-failed": {
"message": "Java 설치를 완료할 수 없습니다"
},
"app.action-bar.install.summary.loader-setup-failed": {
"message": "로더 설치에 실패했습니다"
},
"app.action-bar.install.summary.local-data-error": {
"message": "로컬 데이터를 업데이트할 수 없습니다"
},
"app.action-bar.install.summary.minecraft-setup-failed": {
"message": "Minecraft 설치에 실패했습니다"
},
"app.action-bar.install.summary.modrinth-unreachable": {
"message": "Modrinth에 연결할 수 없습니다"
},
"app.action-bar.install.summary.no-write-permission": {
"message": "쓰기 권한이 없습니다"
},
"app.action-bar.install.summary.pack-download-failed": {
"message": "팩을 다운로드할 수 없습니다"
},
"app.action-bar.install.summary.unexpected-error": {
"message": "무언가 잘못되었습니다"
},
"app.action-bar.install.unknown-instance": {
"message": "알 수 없는 인스턴스"
},
@@ -218,6 +284,9 @@
"app.install.phase.finalizing": {
"message": "마무리 중"
},
"app.install.phase.preparing_instance": {
"message": "설치 대기열에 추가됨"
},
"app.install.phase.preparing_java": {
"message": "Java 준비 중"
},
@@ -359,33 +428,54 @@
"app.modal.install-to-play.header": {
"message": "설치하고 플레이"
},
"app.modal.install-to-play.install-anyway": {
"message": "무시하고 설치"
},
"app.modal.install-to-play.install-button": {
"message": "설치"
},
"app.modal.install-to-play.invite-warning": {
"message": "이 초대장은 다른 Modrinth 사용자가 생성했으며, Modrinth에서 생성하지 않았습니다. 신뢰할 수 있는 초대장만 수락하십시오."
},
"app.modal.install-to-play.mod-count": {
"message": "{count, plural, one {모드 #개} other {모드 #개}}"
},
"app.modal.install-to-play.reviewed-files": {
"message": "모든 파일은 형식(.mrpack 포함)에 무관하게 Modrinth에 게시되어야만 검수를 거칩니다."
},
"app.modal.install-to-play.shared-instance": {
"message": "인스턴스 공유됨"
},
"app.modal.install-to-play.unknown-files-description": {
"message": "이 서버의 모드팩은 Modrinth에 게시되지 않은 파일을 포함하고 있습니다. 신뢰할 수 있는 출처의 파일만 설치하는 것을 강력히 권장합니다."
},
"app.modal.install-to-play.unknown-files-warning": {
"message": "출처를 알 수 없는 파일"
},
"app.modal.install-to-play.unrecognized-files": {
"message": "확인되지 않은 파일"
},
"app.modal.install-to-play.view-contents": {
"message": "구성 요소 보기"
},
"app.modal.update-to-play.header": {
"message": "업데이트하고 플레이"
},
"app.modal.update-to-play.removed": {
"message": "제거됨"
},
"app.modal.update-to-play.update-required-description": {
"message": "{name}을(를) 플레이하려면 업데이트가 필요합니다. 게임을 실행하려면 최신 버전으로 업데이트해 주세요."
},
"app.project.install-button.already-installed": {
"message": "이미 설치되어 있음"
},
"app.project.install-button.switch-version": {
"message": "버전 변경"
},
"app.project.install-context.back-to-browse": {
"message": "탐색으로 돌아가기"
},
"app.project.install-context.install-content-to-instance": {
"message": "인스턴스에 콘텐츠 설치"
},
"app.settings.developer-mode-enabled": {
"message": "개발자 모드가 활성화되었습니다."
},
@@ -579,7 +669,7 @@
"message": "다운로드 완료"
},
"app.update-popup.reload": {
"message": "업데이트하려면 다시 로드"
"message": "새로고침하여 업데이트"
},
"app.update-popup.title": {
"message": "업데이트 가능"
@@ -287,9 +287,6 @@
"app.project.install-context.back-to-browse": {
"message": "Kembali menemukan"
},
"app.project.install-context.install-content-to-instance": {
"message": "Pasang kandungan ke dalam pemasangan"
},
"app.settings.developer-mode-enabled": {
"message": "Mod pembangun didayakan."
},
+216 -9
View File
@@ -11,6 +11,87 @@
"app.action-bar.hide-more-running-instances": {
"message": "Verberg meer actieve instanties"
},
"app.action-bar.install.copied-details": {
"message": "Gekopieerd"
},
"app.action-bar.install.copy-details": {
"message": "Details kopiëren"
},
"app.action-bar.install.dismiss": {
"message": "Sluiten"
},
"app.action-bar.install.open-instance": {
"message": "Instantie openen"
},
"app.action-bar.install.retry": {
"message": "Opnieuw proberen"
},
"app.action-bar.install.summary.app-closing": {
"message": "Geannuleerd vanwege het sluiten van de app"
},
"app.action-bar.install.summary.bad-modpack-file": {
"message": "Modpack kon niet gelezen worden"
},
"app.action-bar.install.summary.canceled": {
"message": "Geannuleerd"
},
"app.action-bar.install.summary.cleanup-incomplete": {
"message": "Opschonen is niet voltooid"
},
"app.action-bar.install.summary.content-download-failed": {
"message": "Bestanden konden niet gedownload worden"
},
"app.action-bar.install.summary.corrupt-download": {
"message": "Gedownloade bestand is beschadigd"
},
"app.action-bar.install.summary.could-not-save-files": {
"message": "Bestanden konden niet opgeslagen worden"
},
"app.action-bar.install.summary.download-failed": {
"message": "Downloaden kon niet worden voltooid"
},
"app.action-bar.install.summary.instance-not-found": {
"message": "Instantie kon niet gevonden worden"
},
"app.action-bar.install.summary.invalid-file-path": {
"message": "Bestandspad is ongeldig"
},
"app.action-bar.install.summary.invalid-modpack": {
"message": "Modpack-gegevens ongeldig"
},
"app.action-bar.install.summary.invalid-modpack-files": {
"message": "Modpack-bestanden bevatten ongeldige metagegevens"
},
"app.action-bar.install.summary.java-setup-failed": {
"message": "Installatie van Java kon niet worden voltooid"
},
"app.action-bar.install.summary.loader-setup-failed": {
"message": "Instellen van loader is mislukt"
},
"app.action-bar.install.summary.local-data-error": {
"message": "Lokale gegevens konden niet bijgewerkt worden"
},
"app.action-bar.install.summary.minecraft-setup-failed": {
"message": "Instellen van Minecraft is mislukt"
},
"app.action-bar.install.summary.modrinth-unreachable": {
"message": "Modrinth kon niet bereikt worden"
},
"app.action-bar.install.summary.no-write-permission": {
"message": "Geen schrijfrechten"
},
"app.action-bar.install.summary.pack-download-failed": {
"message": "Pack kon niet gedownload worden"
},
"app.action-bar.install.summary.unexpected-error": {
"message": "Er is iets misgegaan"
},
"app.action-bar.install.unknown-instance": {
"message": "Onbekende instantie"
},
"app.action-bar.installs": {
"message": "Installaties"
},
"app.action-bar.make-primary-instance": {
"message": "Maak primaire instantie"
},
@@ -101,6 +182,12 @@
"app.appearance-settings.show-play-time.title": {
"message": "Speeltijd laten zien"
},
"app.appearance-settings.skip-non-essential-warnings.description": {
"message": "Bevestigingen met een laag risico, zoals het installeren van dubbele modpacks, het verwijderen van normale inhoud, bulkupdates, het ontkoppelen van modpacks en reparatieverzoeken, worden automatisch overgeslagen. Waarschuwingen voor gevaarlijke dingen worden nog steeds weergegeven."
},
"app.appearance-settings.skip-non-essential-warnings.title": {
"message": "Niet-essentiële waarschuwingen overslaan"
},
"app.appearance-settings.toggle-sidebar.description": {
"message": "Schakelt de mogelijkheid om de zijbalk in- of uit te schakelen in."
},
@@ -141,16 +228,19 @@
"message": "Terug naar instantie"
},
"app.browse.discover-content": {
"message": "Ontdek content"
"message": "Inhoud ontdekken"
},
"app.browse.discover-servers": {
"message": "Ontdek servers"
},
"app.browse.hide-added-servers": {
"message": "Al toegevoegde servers verbergen"
},
"app.browse.project-type.modpacks": {
"message": "Modpacks"
},
"app.browse.server-instance-content-warning": {
"message": "Het toevoegen van content kan de compatibiliteit verbreken bij het verbinden met de server. Alle toegevoegde inhoud gaat ook verloren wanneer u de content van de serverinstantie bijwerkt."
"message": "Het toevoegen van inhoud kan de compatibiliteit verstoren bij het verbinden met de server. Eventuele toegevoegde inhoud gaat bovendien verloren wanneer je de inhoud van de serverinstantie bijwerkt."
},
"app.browse.server.installing": {
"message": "Installeren"
@@ -167,6 +257,9 @@
"app.export-modal.header": {
"message": "Exporteer modpack"
},
"app.export-modal.modpack-name-label": {
"message": "Naam van modpack"
},
"app.export-modal.modpack-name-placeholder": {
"message": "Modpack naam"
},
@@ -176,8 +269,62 @@
"app.export-modal.version-number-placeholder": {
"message": "1.0.0"
},
"app.install.phase.downloading_content": {
"message": "Inhoud aan het downloaden"
},
"app.install.phase.downloading_minecraft": {
"message": "Minecraft aan het downloaden"
},
"app.install.phase.downloading_pack_file": {
"message": "Pakketbestand aan het downloaden"
},
"app.install.phase.extracting_overrides": {
"message": "Overschrijvingen eruit halen"
},
"app.install.phase.finalizing": {
"message": "Aan het afronden"
},
"app.install.phase.preparing_instance": {
"message": "In de wachtrij voor installatie"
},
"app.install.phase.preparing_java": {
"message": "Java aan het voorbereiden"
},
"app.install.phase.preparing_java.downloading": {
"message": "Java {version} aan het downloaden"
},
"app.install.phase.preparing_java.extracting": {
"message": "Java {version} aan het uitpakken"
},
"app.install.phase.preparing_java.fetching-metadata": {
"message": "Java {version} aan het ophalen"
},
"app.install.phase.preparing_java.resolving": {
"message": "Java {version} aan het voorbereiden"
},
"app.install.phase.preparing_java.validating": {
"message": "Java {version} aan het valideren"
},
"app.install.phase.reading_pack_manifest": {
"message": "Paketmanifest aan het lezen"
},
"app.install.phase.resolving_loader": {
"message": "Loader wordt bepaald"
},
"app.install.phase.resolving_minecraft": {
"message": "Minecraft wordt bepaald"
},
"app.install.phase.resolving_pack": {
"message": "Inhoud aan het bepalen"
},
"app.install.phase.rolling_back": {
"message": "Aan het terugdraaien"
},
"app.install.phase.running_loader_processors": {
"message": "Loader-processors aan het uitvoeren"
},
"app.instance.confirm-delete.admonition-body": {
"message": "Alle data van je instantie zal permanent verwijderd worden, inclusief al je werelden, voorkeuren, en alle geïnstalleerde content."
"message": "Alle gegevens van je instantie worden definitief verwijderd, inclusief je werelden, configuraties en alle geïnstalleerde inhoud."
},
"app.instance.confirm-delete.admonition-header": {
"message": "Deze actie kan niet ongedaan gemaakt worden"
@@ -200,6 +347,15 @@
"app.instance.modpack-already-installed.instance": {
"message": "Instantie"
},
"app.instance.mods.bulk-update.downloading-projects": {
"message": "{current, number}/{total, number} projecten aan het downloaden..."
},
"app.instance.mods.bulk-update.finishing": {
"message": "Update aan het afronden..."
},
"app.instance.mods.bulk-update.resolving-versions": {
"message": "Versies worden bepaald..."
},
"app.instance.mods.content-type-project": {
"message": "project"
},
@@ -224,6 +380,18 @@
"app.instance.worlds.browse-servers": {
"message": "Zoek servers"
},
"app.instance.worlds.delete-world-modal.delete-button": {
"message": "Wereld verwijderen"
},
"app.instance.worlds.delete-world-modal.title": {
"message": "Wereld verwijderen"
},
"app.instance.worlds.delete-world-modal.warning-body": {
"message": "Deze wereld wordt definitief uit deze instantie verwijderd. Deze actie kan niet ongedaan worden gemaakt."
},
"app.instance.worlds.delete-world-modal.warning-header": {
"message": "{name} verwijderen"
},
"app.instance.worlds.filter-modded": {
"message": "Gemod"
},
@@ -242,39 +410,72 @@
"app.instance.worlds.no-worlds-heading": {
"message": "Noch servers noch werelden toegevoegd"
},
"app.instance.worlds.remove-server-modal.remove-button": {
"message": "Server verwijderen"
},
"app.instance.worlds.remove-server-modal.title": {
"message": "Server verwijderen"
},
"app.instance.worlds.remove-server-modal.warning-body": {
"message": "Deze server wordt verwijderd uit je serverlijst en uit de serverlijst in het spel. Je kunt hem later weer toevoegen als je het adres weet."
},
"app.instance.worlds.remove-server-modal.warning-header": {
"message": "{name} verwijderen"
},
"app.instance.worlds.search-worlds-placeholder": {
"message": "Zoek werelden"
},
"app.modal.install-to-play.header": {
"message": "Installeer om te spelen"
},
"app.modal.install-to-play.install-anyway": {
"message": "Toch installeren"
},
"app.modal.install-to-play.install-button": {
"message": "Installeer"
},
"app.modal.install-to-play.invite-warning": {
"message": "Deze uitnodiging is aangemaakt door een andere Modrinth-gebruiker, niet door Modrinth zelf. Accepteer alleen uitnodigingen van mensen die je vertrouwt."
},
"app.modal.install-to-play.mod-count": {
"message": "{count, plural,one {# mod}other {# mods}}"
},
"app.modal.install-to-play.reviewed-files": {
"message": "Een bestand wordt alleen beoordeeld als het op Modrinth wordt gepubliceerd, ongeacht het bestandsformaat (inclusief .mrpack)."
},
"app.modal.install-to-play.shared-instance": {
"message": "Gedeelde instantie"
},
"app.modal.install-to-play.unknown-files-description": {
"message": "Dit server-modpack bevat bestanden die niet op Modrinth zijn gepubliceerd. We raden je ten zeerste aan om alleen bestanden te installeren uit bronnen die je vertrouwt."
},
"app.modal.install-to-play.unknown-files-warning": {
"message": "Waarschuwing voor onbekende bestanden"
},
"app.modal.install-to-play.unrecognized-files": {
"message": "Onbekende bestanden"
},
"app.modal.install-to-play.view-contents": {
"message": "Toon content"
"message": "Inhoud bekijken"
},
"app.modal.update-to-play.header": {
"message": "Update om te spelen"
},
"app.modal.update-to-play.removed": {
"message": "Verwijderde"
},
"app.modal.update-to-play.update-required-description": {
"message": "Een update is vereist om {name} te spelen. Update naar de laatste versie om het spel te starten."
},
"app.project.install-button.already-installed": {
"message": "Dit project is al geïnstalleerd"
},
"app.project.install-button.switch-version": {
"message": "Versie wijzigen"
},
"app.project.install-context.back-to-browse": {
"message": "Terug naar Ontdekken"
},
"app.project.install-context.install-content-to-instance": {
"message": "Installeer content naar instantie"
},
"app.settings.developer-mode-enabled": {
"message": "Ontwikkelaarsmodus ingeschakeld."
},
@@ -390,11 +591,14 @@
"message": "Je skinvolgorde kon niet worden opgeslagen."
},
"app.skins.reorder-error.title": {
"message": "Het herschikken van skins is mislukt."
"message": "Herschikken van skins is mislukt"
},
"app.skins.section.builders-and-biomes": {
"message": "Builders & Biomes"
},
"app.skins.section.chaos-cubed": {
"message": "Chaos Cubed"
},
"app.skins.section.chase-the-skies": {
"message": "Chase the Skies"
},
@@ -621,7 +825,7 @@
"message": "Verwijder instantie"
},
"instance.settings.tabs.general.delete.description": {
"message": "Verwijdert permanent een instantie van je apparaat, inclusief je werelden, instellingen en alle geïnstalleerde inhoud. Wees voorzichtig, zodra je een instantie verwijdert, is het niet meer mogelijk om deze te herstellen."
"message": "Hiermee wordt een instantie definitief van je apparaat verwijderd, inclusief je werelden, instellingen en alle geïnstalleerde inhoud. Wees voorzichtig, want zodra je een instantie hebt verwijderd, kun je deze niet meer herstellen."
},
"instance.settings.tabs.general.deleting.button": {
"message": "Verwijderen..."
@@ -812,6 +1016,9 @@
"instance.worlds.copy_address": {
"message": "Kopieer adres"
},
"instance.worlds.create_shortcut": {
"message": "Snelkoppeling maken"
},
"instance.worlds.dont_show_on_home": {
"message": "Niet tonen op de startpagina"
},
+21 -3
View File
@@ -425,21 +425,42 @@
"app.modal.install-to-play.header": {
"message": "Zainstaluj, aby grać"
},
"app.modal.install-to-play.install-anyway": {
"message": "Instaluj mimo to"
},
"app.modal.install-to-play.install-button": {
"message": "Zainstaluj"
},
"app.modal.install-to-play.invite-warning": {
"message": "To zaproszenie zostało utworzone przez innego użytkownika Modrinth, nie przez firmę Modrinth. Tylko akceptuj zaproszenia od osób, którym ufasz."
},
"app.modal.install-to-play.mod-count": {
"message": "{count, plural, one {# mod} few {# mody} other {# modów}}"
},
"app.modal.install-to-play.reviewed-files": {
"message": "Plik jest sprawdzony tylko, jeżeli został opublikowany na Modrinth, niezależnie od jego formatu (w tym pliki .mrpack)."
},
"app.modal.install-to-play.shared-instance": {
"message": "Wspólna instancja"
},
"app.modal.install-to-play.unknown-files-description": {
"message": "Ta serwerowa paczka modów zawiera pliki, które nie zostały opublikowane na Modrinth. Zalecamy instalowanie plików tylko ze źródeł, którym ufasz."
},
"app.modal.install-to-play.unknown-files-warning": {
"message": "Ostrzeżenie o nieznanych plikach"
},
"app.modal.install-to-play.unrecognized-files": {
"message": "Nieznane plki"
},
"app.modal.install-to-play.view-contents": {
"message": "Pokaż zawartość"
},
"app.modal.update-to-play.header": {
"message": "Zaktualizuj, by grać"
},
"app.modal.update-to-play.removed": {
"message": "Usunięto"
},
"app.modal.update-to-play.update-required-description": {
"message": "Aktualizacja jest wymagana, aby grać w {name}. Proszę zaktualizować do najnowszej wersji, aby uruchomić grę."
},
@@ -452,9 +473,6 @@
"app.project.install-context.back-to-browse": {
"message": "Powróć do odkrywania"
},
"app.project.install-context.install-content-to-instance": {
"message": "Zainstaluj zasoby do instancji"
},
"app.project.version.all-versions": {
"message": "Wszystkie wersje"
},
+21 -3
View File
@@ -428,21 +428,42 @@
"app.modal.install-to-play.header": {
"message": "Instale para jogar"
},
"app.modal.install-to-play.install-anyway": {
"message": "Instalar mesmo assim"
},
"app.modal.install-to-play.install-button": {
"message": "Instalar"
},
"app.modal.install-to-play.invite-warning": {
"message": "Este convite foi criado por outro usuário do Modrinth, não pelo próprio Modrinth. Só aceite convites de pessoas que você confia."
},
"app.modal.install-to-play.mod-count": {
"message": "{count, plural, =0 {Nenhum mod} one {# mod} other {# mods}}"
},
"app.modal.install-to-play.reviewed-files": {
"message": "Um arquivo só é revisado se for publicado no Modrinth, independente do formato de arquivo (incluindo .mrpack)."
},
"app.modal.install-to-play.shared-instance": {
"message": "Instância compartilhada"
},
"app.modal.install-to-play.unknown-files-description": {
"message": "Este pacote de mods do servidor contém arquivos não publicados no Modrinth. Recomendamos fortemente a instalar apenas arquivos de fontes que você confia."
},
"app.modal.install-to-play.unknown-files-warning": {
"message": "Aviso de arquivos desconhecidos"
},
"app.modal.install-to-play.unrecognized-files": {
"message": "Arquivos não reconhecidos"
},
"app.modal.install-to-play.view-contents": {
"message": "Ver conteúdo"
},
"app.modal.update-to-play.header": {
"message": "Atualize para jogar"
},
"app.modal.update-to-play.removed": {
"message": "Removido"
},
"app.modal.update-to-play.update-required-description": {
"message": "Uma atualização é necessária para jogar {name}. Atualize para a versão mais recente para iniciar o jogo."
},
@@ -455,9 +476,6 @@
"app.project.install-context.back-to-browse": {
"message": "Voltar a descobrir"
},
"app.project.install-context.install-content-to-instance": {
"message": "Instalar conteúdo para a instância"
},
"app.project.version.all-versions": {
"message": "Todas as versões"
},
+21 -3
View File
@@ -422,21 +422,42 @@
"app.modal.install-to-play.header": {
"message": "Установка перед запуском"
},
"app.modal.install-to-play.install-anyway": {
"message": "Всё равно установить"
},
"app.modal.install-to-play.install-button": {
"message": "Установить"
},
"app.modal.install-to-play.invite-warning": {
"message": "Это приглашение создано другим пользователем Modrinth. Принимайте приглашения только от тех, кому доверяете."
},
"app.modal.install-to-play.mod-count": {
"message": "{count, plural, one {# мод} few {# мода} other {# модов}}"
},
"app.modal.install-to-play.reviewed-files": {
"message": "Файл проверяется только если он опубликован на Modrinth, независимо от его формата (включая .mrpack)."
},
"app.modal.install-to-play.shared-instance": {
"message": "Сборка"
},
"app.modal.install-to-play.unknown-files-description": {
"message": "Сборка этого сервера содержит файлы, которые не опубликованы на Modrinth. Рекомендуется скачивать файлы только из надёжных источников."
},
"app.modal.install-to-play.unknown-files-warning": {
"message": "Предупреждение о неизвестных файлах"
},
"app.modal.install-to-play.unrecognized-files": {
"message": "Нераспознанные файлы"
},
"app.modal.install-to-play.view-contents": {
"message": "Посмотреть содержимое"
},
"app.modal.update-to-play.header": {
"message": "Обновление перед запуском"
},
"app.modal.update-to-play.removed": {
"message": "Удалён"
},
"app.modal.update-to-play.update-required-description": {
"message": "Обновите {name} до последней версии, чтобы запустить игру."
},
@@ -449,9 +470,6 @@
"app.project.install-context.back-to-browse": {
"message": "Вернуться к поиску"
},
"app.project.install-context.install-content-to-instance": {
"message": "Установка контента в сборку"
},
"app.project.version.all-versions": {
"message": "Все версии"
},
+75 -3
View File
@@ -29,12 +29,63 @@
"app.action-bar.install.summary.app-closing": {
"message": "Otkazano zbog zatvaranja aplikacije"
},
"app.action-bar.install.summary.bad-modpack-file": {
"message": "Nije moglo pročitati modpack"
},
"app.action-bar.install.summary.canceled": {
"message": "Otkazano"
},
"app.action-bar.install.summary.cleanup-incomplete": {
"message": "Čišćenje nije završeno"
},
"app.action-bar.install.summary.content-download-failed": {
"message": "Nije bilo moguće instalirati datoteke"
},
"app.action-bar.install.summary.corrupt-download": {
"message": "Preuzeta datoteka je oštećena"
},
"app.action-bar.install.summary.could-not-save-files": {
"message": "Nije bilo moguće spremiti datoteke"
},
"app.action-bar.install.summary.download-failed": {
"message": "Preuzimanje nije završeno"
},
"app.action-bar.install.summary.instance-not-found": {
"message": "Instanca nije pronađena"
},
"app.action-bar.install.summary.invalid-file-path": {
"message": "Put do datoteke nije ispravan"
},
"app.action-bar.install.summary.invalid-modpack": {
"message": "Podaci modpaka su nevažeći"
},
"app.action-bar.install.summary.invalid-modpack-files": {
"message": "Datoteke modpaka sadrže nevažeće metapodatke"
},
"app.action-bar.install.summary.java-setup-failed": {
"message": "Podešavanje Jave nije moglo biti završeno"
},
"app.action-bar.install.summary.loader-setup-failed": {
"message": "Podešavanje učitavača nije uspelo"
},
"app.action-bar.install.summary.local-data-error": {
"message": "Nije bilo moguće ažurirati lokalne podatke"
},
"app.action-bar.install.summary.minecraft-setup-failed": {
"message": "Podešavanje Minecrafta nije uspelo"
},
"app.action-bar.install.summary.modrinth-unreachable": {
"message": "Nije bilo moguće povezati se sa Modrinth-om"
},
"app.action-bar.install.summary.no-write-permission": {
"message": "Nema dozvole za pisanje"
},
"app.action-bar.install.summary.pack-download-failed": {
"message": "Nije bilo moguće instalirati datoteku"
},
"app.action-bar.install.summary.unexpected-error": {
"message": "Nešto je pošlo po zlu"
},
"app.action-bar.install.unknown-instance": {
"message": "Nepoznata instanca"
},
@@ -233,6 +284,9 @@
"app.install.phase.finalizing": {
"message": "Finaliziranje"
},
"app.install.phase.preparing_instance": {
"message": "Na čekanju za instalaciju"
},
"app.install.phase.preparing_java": {
"message": "Pripremanje Jave"
},
@@ -374,21 +428,42 @@
"app.modal.install-to-play.header": {
"message": "Instaliraj da bi igrao"
},
"app.modal.install-to-play.install-anyway": {
"message": "Svejedno instaliraj"
},
"app.modal.install-to-play.install-button": {
"message": "Instaliraj"
},
"app.modal.install-to-play.invite-warning": {
"message": "Ovu pozivnicu je kreirao drugi korisnik Modrintha, a ne sam Modrinth. Prihvataj pozivnice samo od osoba kojima veruješ."
},
"app.modal.install-to-play.mod-count": {
"message": "{count, plural, one {# mod} other {# modova}}"
},
"app.modal.install-to-play.reviewed-files": {
"message": "Datoteka se pregleda samo ako je postavljena na Modrinth, bez obzira na njen format (uključujući .mrpack)."
},
"app.modal.install-to-play.shared-instance": {
"message": "Deljena instanca"
},
"app.modal.install-to-play.unknown-files-description": {
"message": "Ovaj modpack servera sadrži datoteke koje nisu postavljene na Modrinth-u. Preporučujemo da instaliraš datoteke samo iz izvora kojima veruješ."
},
"app.modal.install-to-play.unknown-files-warning": {
"message": "Upozorenje nepoznatih datoteka"
},
"app.modal.install-to-play.unrecognized-files": {
"message": "Nepoznate datoteke"
},
"app.modal.install-to-play.view-contents": {
"message": "Pogledaj sadržaj"
},
"app.modal.update-to-play.header": {
"message": "Ažuriraj da bi igrao"
},
"app.modal.update-to-play.removed": {
"message": "Smaknuto"
},
"app.modal.update-to-play.update-required-description": {
"message": "Potrebno je ažuriranje da biste igrali {name}. Molimo te da ažuriraš na najnoviju verziju da bi pokrenuo igru."
},
@@ -401,9 +476,6 @@
"app.project.install-context.back-to-browse": {
"message": "Nazad da otkrivanje"
},
"app.project.install-context.install-content-to-instance": {
"message": "Instaliraj sadržaj na instancu"
},
"app.project.version.all-versions": {
"message": "Sve verzije"
},
+19 -16
View File
@@ -410,6 +410,9 @@
"app.modal.install-to-play.header": {
"message": "Installera för att spela"
},
"app.modal.install-to-play.install-anyway": {
"message": "Installera andå"
},
"app.modal.install-to-play.install-button": {
"message": "Installera"
},
@@ -425,6 +428,9 @@
"app.modal.update-to-play.header": {
"message": "Uppdatera för att spela"
},
"app.modal.update-to-play.removed": {
"message": "Tog bort"
},
"app.modal.update-to-play.update-required-description": {
"message": "En uppdatering krävs för att spela {name}. Vänligen uppdatera till senaste version för att starta spelet."
},
@@ -437,9 +443,6 @@
"app.project.install-context.back-to-browse": {
"message": "Tillbaka till upptäck"
},
"app.project.install-context.install-content-to-instance": {
"message": "Installera innehåll till instans"
},
"app.project.version.all-versions": {
"message": "Alla versioner"
},
@@ -615,7 +618,7 @@
"message": "Logga in"
},
"app.skins.sign-in.description": {
"message": "Snälla logga in din Minecraft konto att använda hud hantering förmågor i Modrinth appen."
"message": "Vänligen logga in på ditt Minecraft-konto för att använda utseendehanteringfunktioner i Modrinth appen."
},
"app.skins.sign-in.rinthbot-alt": {
"message": "Exalterad Modrinth bot"
@@ -657,7 +660,7 @@
"message": "Version {version} har installerats!"
},
"app.world.server-modal.placeholder-address": {
"message": "example.modrinth.gg"
"message": "exempel.modrinth.gg"
},
"app.world.server-modal.select-an-option": {
"message": "Välj ett alternativ"
@@ -732,7 +735,7 @@
"message": "Sök efter vänner..."
},
"friends.section.heading": {
"message": "{title}: {count}"
"message": "{title} - {count}"
},
"friends.sign-in-to-add-friends": {
"message": "<link>Logga in på ett Modrinth-konto</link> för att lägga till vänner och se vad de spelar!"
@@ -852,19 +855,19 @@
"message": "Alfa"
},
"instance.settings.tabs.general.update-channel.alpha.description": {
"message": "Utgåvor, beta och alfa versioner visas som tillgängliga uppdateringar."
"message": "Release-, beta- och alfaversioner kommer visas som tillgängliga uppdateringar."
},
"instance.settings.tabs.general.update-channel.beta": {
"message": "Beta"
},
"instance.settings.tabs.general.update-channel.beta.description": {
"message": "Utgåvor och beta versioner visas som tillgängliga uppdateringar."
"message": "Release- och betaversioner kommer visas som tillgängliga uppdateringar."
},
"instance.settings.tabs.general.update-channel.release": {
"message": "Utgåva"
"message": "Release"
},
"instance.settings.tabs.general.update-channel.release.description": {
"message": "Endast utgivna versioner visas som tillgängliga uppdateringar."
"message": "Endast realeseversioner kommer visas som tillgängliga uppdateringar."
},
"instance.settings.tabs.general.update-channel.select": {
"message": "Välj uppdateringskanal"
@@ -930,10 +933,10 @@
"message": "Anpassad minnestilldelning"
},
"instance.settings.tabs.java.enter-environment-variables": {
"message": "Skriv in miljövariabler..."
"message": "Ange miljövariabler..."
},
"instance.settings.tabs.java.enter-java-arguments": {
"message": "Skriv in Java-argument..."
"message": "Ange Java-argument..."
},
"instance.settings.tabs.java.environment-variables": {
"message": "Miljövariabler"
@@ -996,13 +999,13 @@
"message": "Skapa genväg"
},
"instance.worlds.dont_show_on_home": {
"message": "Visa inte på Startsidan"
"message": "Visa inte på startsidan"
},
"instance.worlds.game_already_open": {
"message": "Instans är redan öppen"
},
"instance.worlds.hardcore": {
"message": "Hardcore läge"
"message": "Hardcore-läge"
},
"instance.worlds.incompatible_server": {
"message": "Servern är inkompatibel"
@@ -1014,10 +1017,10 @@
"message": "Servern kunde inte kontaktas"
},
"instance.worlds.no_server_quick_play": {
"message": "Du kan bara hoppa rakt in till servers på Minecraft Alfa 1.0.5+"
"message": "Du kan endast gå direkt in i servrar på Minecraft Alfa 1.0.5+"
},
"instance.worlds.no_singleplayer_quick_play": {
"message": "Du kan bara hoppa rakt in till enspelarläge världar på Minecraft 1,20+"
"message": "Du kan endast gå direkt in i enspelarläge världar på Minecraft 1.20+"
},
"instance.worlds.play_instance": {
"message": "Spela instans"
@@ -272,9 +272,6 @@
"app.project.install-context.back-to-browse": {
"message": "กลับไปที่หน้าค้นหา"
},
"app.project.install-context.install-content-to-instance": {
"message": "ติดตั้งเนื้อหาลงสู่อินสแตนซ์"
},
"app.settings.developer-mode-enabled": {
"message": "โหมดนักพัฒนาเปิดอยู่"
},
+21 -3
View File
@@ -428,21 +428,42 @@
"app.modal.install-to-play.header": {
"message": "Oynamak için yükleyin"
},
"app.modal.install-to-play.install-anyway": {
"message": "Yine de indir"
},
"app.modal.install-to-play.install-button": {
"message": "Yükle"
},
"app.modal.install-to-play.invite-warning": {
"message": "Bu davetiye Modrinth tarafından değil, başka bir Modrinth kullanıcısı tarafından oluşturulmuştur. Sadece güvendiğiniz kişilerden gelen davetleri kabul edin."
},
"app.modal.install-to-play.mod-count": {
"message": "{count, plural, one {#mod} other {#mods}}"
},
"app.modal.install-to-play.reviewed-files": {
"message": "Dosya formatı ne olursa olsun (.mrpack dahil), bir dosya yalnızca Modrinth'e yüklendiğinde denetlenir."
},
"app.modal.install-to-play.shared-instance": {
"message": "Paylaşılan Kurulum"
},
"app.modal.install-to-play.unknown-files-description": {
"message": "Bu sunucunun mod paketi Modrinth'te yayınlanmamış doysaları mevcut. Yalnızca güvendiğiniz kaynaklardan dosya indirmenizi önemle tavsiye ederiz."
},
"app.modal.install-to-play.unknown-files-warning": {
"message": "Bilinmeyen dosya uyarısı"
},
"app.modal.install-to-play.unrecognized-files": {
"message": "Tanımlanamayan dosyalar"
},
"app.modal.install-to-play.view-contents": {
"message": "İçeriği görüntüle"
},
"app.modal.update-to-play.header": {
"message": "Oynamak için güncelle"
},
"app.modal.update-to-play.removed": {
"message": "Silindi"
},
"app.modal.update-to-play.update-required-description": {
"message": "{name} oyununu oynamak için güncelleme gereklidir. Oyunu başlatmak için lütfen en son sürüme güncelleyin."
},
@@ -455,9 +476,6 @@
"app.project.install-context.back-to-browse": {
"message": "Keşfete geri dön"
},
"app.project.install-context.install-content-to-instance": {
"message": "İçeriği kuruluma yükle"
},
"app.project.version.all-versions": {
"message": "Tüm sürümler"
},
+70 -7
View File
@@ -30,11 +30,14 @@
"message": "Скасовано через закриття додатку"
},
"app.action-bar.install.summary.bad-modpack-file": {
"message": "Не вдалось прочитати збірку модів"
"message": "Не вдалось прочитати збірку модифікацій"
},
"app.action-bar.install.summary.canceled": {
"message": "Скасовано"
},
"app.action-bar.install.summary.cleanup-incomplete": {
"message": "Очищення не завершено"
},
"app.action-bar.install.summary.content-download-failed": {
"message": "Не вдалося завантажити файли"
},
@@ -57,7 +60,7 @@
"message": "Дані збірки модів недійсні"
},
"app.action-bar.install.summary.invalid-modpack-files": {
"message": "Файли збірки модів містять не дійсні метадані"
"message": "Файли збірки модифікацій містять не дійсні метадані"
},
"app.action-bar.install.summary.java-setup-failed": {
"message": "Не вдалося завершити встановлення Java"
@@ -86,6 +89,9 @@
"app.action-bar.install.unknown-instance": {
"message": "Невідомий екземпляр"
},
"app.action-bar.installs": {
"message": "Установки"
},
"app.action-bar.make-primary-instance": {
"message": "Зробити основним профілем"
},
@@ -177,7 +183,7 @@
"message": "Час гри"
},
"app.appearance-settings.skip-non-essential-warnings.description": {
"message": "Автоматично пропускає підтвердження з низьким рівнем ризику, як-от повторне встановлення збірки модів, видалення звичайного вмісту, масові оновлення, відвʼязування збірки модів та записи на відновлення. Небезпечні попередження все одно зображуватимуться."
"message": "Автоматично пропускає підтвердження з низьким рівнем ризику, такі як встановлення дублікатів інсталяцій, звичайне видалення вмісту, масові оновлення, від’єднання інсталяцій та запити на відновлення. Попередження про небезпеку й надалі будуть показуватися."
},
"app.appearance-settings.skip-non-essential-warnings.title": {
"message": "Пропустити несуттєві попередження"
@@ -252,7 +258,7 @@
"message": "Експортувати збірку"
},
"app.export-modal.modpack-name-label": {
"message": "Назва збірки модів"
"message": "Назва збірки"
},
"app.export-modal.modpack-name-placeholder": {
"message": "Назва збірки"
@@ -344,6 +350,12 @@
"app.instance.mods.bulk-update.downloading-projects": {
"message": "Завантаження проєктів: {current, number}/{total, number}..."
},
"app.instance.mods.bulk-update.finishing": {
"message": "Завершуємо оновлення…"
},
"app.instance.mods.bulk-update.resolving-versions": {
"message": "Знаходимо версії…"
},
"app.instance.mods.content-type-project": {
"message": "проєкт"
},
@@ -368,6 +380,18 @@
"app.instance.worlds.browse-servers": {
"message": "Переглянути сервери"
},
"app.instance.worlds.delete-world-modal.delete-button": {
"message": "Видалити світ"
},
"app.instance.worlds.delete-world-modal.title": {
"message": "Видалити світ"
},
"app.instance.worlds.delete-world-modal.warning-body": {
"message": "Цей світ буде видалено назавжди з цього екземпляра. Це не можна скасувати."
},
"app.instance.worlds.delete-world-modal.warning-header": {
"message": "Видалення {name}"
},
"app.instance.worlds.filter-modded": {
"message": "Модифіковано"
},
@@ -386,39 +410,72 @@
"app.instance.worlds.no-worlds-heading": {
"message": "Сервера або світи не додано"
},
"app.instance.worlds.remove-server-modal.remove-button": {
"message": "Видалити сервер"
},
"app.instance.worlds.remove-server-modal.title": {
"message": "Видалити сервер"
},
"app.instance.worlds.remove-server-modal.warning-body": {
"message": "Цей сервер буде видалено з вашого списку серверів. Ви зможете повернути його, якщо знаєте адресу."
},
"app.instance.worlds.remove-server-modal.warning-header": {
"message": "Видалення {name}"
},
"app.instance.worlds.search-worlds-placeholder": {
"message": "Пошук {count} світів…"
},
"app.modal.install-to-play.header": {
"message": "Установлення для гри"
},
"app.modal.install-to-play.install-anyway": {
"message": "Усе одно встановити"
},
"app.modal.install-to-play.install-button": {
"message": "Установити"
},
"app.modal.install-to-play.invite-warning": {
"message": "Це запрошення створив інший користувач Modrinth, не сам Modrinth. Приймайте запрошення лише від тих людей, яким довіряєте."
},
"app.modal.install-to-play.mod-count": {
"message": "{count, plural, one {# мод} few {# мода} other {# модів}}"
},
"app.modal.install-to-play.reviewed-files": {
"message": "Файл перевірятиметься, лише якщо його завантажено на Modrinth, незалежно від його формату (включно з .mrpack)."
},
"app.modal.install-to-play.shared-instance": {
"message": "Профіль"
},
"app.modal.install-to-play.unknown-files-description": {
"message": "Ця збірка сервера містить файли, які не є опубліковані на Modrinth. Ми наполегливо рекомендуємо встановлювати лише ті файли, яким довіряєте."
},
"app.modal.install-to-play.unknown-files-warning": {
"message": "Попередження про невідомі файли"
},
"app.modal.install-to-play.unrecognized-files": {
"message": "Нерозпізнані файли"
},
"app.modal.install-to-play.view-contents": {
"message": "Дивитися вміст"
},
"app.modal.update-to-play.header": {
"message": "Оновлення для гри"
},
"app.modal.update-to-play.removed": {
"message": "Видалено"
},
"app.modal.update-to-play.update-required-description": {
"message": "«{name}» потребує оновлення, щоб грати. Будь ласка, оновіть до останньої версії, щоб запустити гру."
},
"app.project.install-button.already-installed": {
"message": "Проєкт уже встановлено"
},
"app.project.install-button.switch-version": {
"message": "Змінити версію"
},
"app.project.install-context.back-to-browse": {
"message": "Повернутися до дослідження"
},
"app.project.install-context.install-content-to-instance": {
"message": "Установити вміст до профілю"
},
"app.settings.developer-mode-enabled": {
"message": "Увімкнено режим розробника."
},
@@ -539,6 +596,9 @@
"app.skins.section.builders-and-biomes": {
"message": "Builders & Biomes"
},
"app.skins.section.chaos-cubed": {
"message": "Chaos Cubed"
},
"app.skins.section.chase-the-skies": {
"message": "Chase the Skies"
},
@@ -956,6 +1016,9 @@
"instance.worlds.copy_address": {
"message": "Скопіювати адресу"
},
"instance.worlds.create_shortcut": {
"message": "Створити ярлик"
},
"instance.worlds.dont_show_on_home": {
"message": "Не показувати на головній"
},
+60 -3
View File
@@ -11,6 +11,33 @@
"app.action-bar.hide-more-running-instances": {
"message": "Đóng các phiên bản đang chạy"
},
"app.action-bar.install.retry": {
"message": "Thử lại"
},
"app.action-bar.install.summary.app-closing": {
"message": "Đã hủy do đóng ứng dụng"
},
"app.action-bar.install.summary.bad-modpack-file": {
"message": "Không đọc được modpack"
},
"app.action-bar.install.summary.canceled": {
"message": "Đã hủy"
},
"app.action-bar.install.summary.cleanup-incomplete": {
"message": "Dọn dẹp không hoàn thện"
},
"app.action-bar.install.summary.content-download-failed": {
"message": "Không tải được tệp"
},
"app.action-bar.install.summary.corrupt-download": {
"message": "Tệp được tải bị hỏng"
},
"app.action-bar.install.summary.could-not-save-files": {
"message": "Không lưu được tệp"
},
"app.action-bar.install.summary.download-failed": {
"message": "Không hoàn tất tải xuống"
},
"app.action-bar.make-primary-instance": {
"message": "Đặt làm bản instance chính"
},
@@ -224,6 +251,15 @@
"app.instance.worlds.browse-servers": {
"message": "Tìm kiếm máy chủ"
},
"app.instance.worlds.delete-world-modal.delete-button": {
"message": "Xóa thế giới"
},
"app.instance.worlds.delete-world-modal.title": {
"message": "Xóa thế giới"
},
"app.instance.worlds.delete-world-modal.warning-header": {
"message": "Đang xóa {name}"
},
"app.instance.worlds.filter-modded": {
"message": "Modded"
},
@@ -242,12 +278,24 @@
"app.instance.worlds.no-worlds-heading": {
"message": "Không có máy chủ hay thế giới nào đã được thêm"
},
"app.instance.worlds.remove-server-modal.remove-button": {
"message": "Bỏ khỏi danh sách"
},
"app.instance.worlds.remove-server-modal.title": {
"message": "Xóa máy chủ"
},
"app.instance.worlds.remove-server-modal.warning-header": {
"message": "{name} sẽ bị xóa"
},
"app.instance.worlds.search-worlds-placeholder": {
"message": "Tìm kiếm {count} world..."
},
"app.modal.install-to-play.header": {
"message": "Tải xuống để chơi"
},
"app.modal.install-to-play.install-anyway": {
"message": "Tải tiếp"
},
"app.modal.install-to-play.install-button": {
"message": "Tải xuống"
},
@@ -263,18 +311,21 @@
"app.modal.update-to-play.header": {
"message": "Cập nhật và bắt đầu chơi"
},
"app.modal.update-to-play.removed": {
"message": "Đã bỏ"
},
"app.modal.update-to-play.update-required-description": {
"message": "Bạn cần cập nhật {name} để có thể chơi. Vui lòng cập nhật lên bản mới nhất để khởi chạy trò chơi."
},
"app.project.install-button.already-installed": {
"message": "Dự án này đã được cài đặt"
},
"app.project.install-button.switch-version": {
"message": "Đổi phiên bản"
},
"app.project.install-context.back-to-browse": {
"message": "Quay lại trang Khám Phá"
},
"app.project.install-context.install-content-to-instance": {
"message": "Cài đặt nội dung vào phiên bản"
},
"app.settings.developer-mode-enabled": {
"message": "Chế độ nhà phát triển đã được bật."
},
@@ -395,6 +446,9 @@
"app.skins.section.builders-and-biomes": {
"message": "Builders & Biomes"
},
"app.skins.section.chaos-cubed": {
"message": "Chaos Cubed"
},
"app.skins.section.chase-the-skies": {
"message": "Chase the Skies"
},
@@ -812,6 +866,9 @@
"instance.worlds.copy_address": {
"message": "Sao chép địa chỉ"
},
"instance.worlds.create_shortcut": {
"message": "Tạo lối tắt"
},
"instance.worlds.dont_show_on_home": {
"message": "Không hiện ở Trang chủ"
},
+25 -7
View File
@@ -15,7 +15,7 @@
"message": "已复制"
},
"app.action-bar.install.copy-details": {
"message": "复制详"
"message": "复制详细信息"
},
"app.action-bar.install.dismiss": {
"message": "忽略"
@@ -30,13 +30,13 @@
"message": "由于应用关闭而取消"
},
"app.action-bar.install.summary.bad-modpack-file": {
"message": "无法读取整合包"
"message": "整合包读取失败"
},
"app.action-bar.install.summary.canceled": {
"message": "已取消"
},
"app.action-bar.install.summary.cleanup-incomplete": {
"message": "清理未完成"
"message": "未完成清理"
},
"app.action-bar.install.summary.content-download-failed": {
"message": "无法下载文件"
@@ -48,7 +48,7 @@
"message": "无法保存文件"
},
"app.action-bar.install.summary.download-failed": {
"message": "下载无法完成"
"message": "无法完成下载"
},
"app.action-bar.install.summary.instance-not-found": {
"message": "无法找到实例"
@@ -428,21 +428,42 @@
"app.modal.install-to-play.header": {
"message": "安装以游玩"
},
"app.modal.install-to-play.install-anyway": {
"message": "无视风险继续安装"
},
"app.modal.install-to-play.install-button": {
"message": "安装"
},
"app.modal.install-to-play.invite-warning": {
"message": "这个邀请是由另一位 Modrinth 用户创建的,而不是 Modrinth 官方。只接受你信任的人的邀请。"
},
"app.modal.install-to-play.mod-count": {
"message": "{count} 个模组"
},
"app.modal.install-to-play.reviewed-files": {
"message": "只有上传到 Modrinth 的文件才会经过审核,无论其文件格式如何(包括 .mrpack)。"
},
"app.modal.install-to-play.shared-instance": {
"message": "共享实例"
},
"app.modal.install-to-play.unknown-files-description": {
"message": "这个整合包包含一些未在 Modrinth 发布的文件。我们强烈建议只从你信任的来源安装文件。"
},
"app.modal.install-to-play.unknown-files-warning": {
"message": "未知文件警告"
},
"app.modal.install-to-play.unrecognized-files": {
"message": "未识别文件"
},
"app.modal.install-to-play.view-contents": {
"message": "查看内容"
},
"app.modal.update-to-play.header": {
"message": "更新以游玩"
},
"app.modal.update-to-play.removed": {
"message": "已移除"
},
"app.modal.update-to-play.update-required-description": {
"message": "需要更新至最新版本才能运行 {name}。请更新后启动游戏。"
},
@@ -455,9 +476,6 @@
"app.project.install-context.back-to-browse": {
"message": "返回发现页"
},
"app.project.install-context.install-content-to-instance": {
"message": "将内容安装到实例中"
},
"app.project.version.all-versions": {
"message": "所有版本"
},
+21 -3
View File
@@ -428,21 +428,42 @@
"app.modal.install-to-play.header": {
"message": "安裝以遊玩"
},
"app.modal.install-to-play.install-anyway": {
"message": "仍要安裝"
},
"app.modal.install-to-play.install-button": {
"message": "安裝"
},
"app.modal.install-to-play.invite-warning": {
"message": "這份邀請是由另一位 Modrinth 使用者建立,並非由 Modrinth 官方建立。請僅接受來自你信任的人的邀請。"
},
"app.modal.install-to-play.mod-count": {
"message": "{count, plural, other {# 個模組}}"
},
"app.modal.install-to-play.reviewed-files": {
"message": "只有在 Modrinth 上發布的檔案才會經過審查,無論其檔案格式為何(包含 .mrpack)。"
},
"app.modal.install-to-play.shared-instance": {
"message": "共用實例"
},
"app.modal.install-to-play.unknown-files-description": {
"message": "這個伺服器模組包中包含未在 Modrinth 上發布的檔案。強烈建議你僅安裝來自信任來源的檔案。"
},
"app.modal.install-to-play.unknown-files-warning": {
"message": "未知檔案警告"
},
"app.modal.install-to-play.unrecognized-files": {
"message": "無法辨識的檔案"
},
"app.modal.install-to-play.view-contents": {
"message": "檢視內容"
},
"app.modal.update-to-play.header": {
"message": "更新以遊玩"
},
"app.modal.update-to-play.removed": {
"message": "已移除"
},
"app.modal.update-to-play.update-required-description": {
"message": "需要更新才能遊玩「{name}」。請更新至最新版本以啟動遊戲。"
},
@@ -455,9 +476,6 @@
"app.project.install-context.back-to-browse": {
"message": "返回瀏覽"
},
"app.project.install-context.install-content-to-instance": {
"message": "將內容安裝至實例"
},
"app.project.version.all-versions": {
"message": "所有版本"
},
+16 -12
View File
@@ -72,6 +72,7 @@ const debugLog = useDebugLogger('Browse')
const router = useRouter()
const route = useRoute()
const themeStore = useTheming()
const browseRouteActive = computed(() => route.path.startsWith('/browse/'))
const serverSetupModalRef = ref<InstanceType<typeof CreationFlowModal> | null>(null)
const serverInstallContent = createServerInstallContent({ serverSetupModalRef })
provideServerInstallContent(serverInstallContent)
@@ -490,6 +491,9 @@ function resetInstanceContext() {
watch(
() => route.params.projectType as ProjectType,
async (newType) => {
if (!browseRouteActive.value) {
return
}
if (isSetupServerContext.value) {
enforceSetupModpackRoute(newType)
if (newType !== 'modpack') return
@@ -666,7 +670,7 @@ async function getInstallProjectVersions(projectId: string) {
}
async function chooseInstanceInstallVersion(
project: Labrinth.Search.v2.ResultSearchProject & Labrinth.Search.v3.ResultSearchProject,
project: Labrinth.Search.v3.ResultSearchProject,
projectTypeValue: string,
) {
const targetInstance = instance.value
@@ -693,16 +697,14 @@ async function chooseInstanceInstallVersion(
}
function getCardActions(
result: Labrinth.Search.v2.ResultSearchProject | Labrinth.Search.v3.ResultSearchProject,
result: Labrinth.Search.v3.ResultSearchProject,
currentProjectType: string,
): CardAction[] {
if (currentProjectType === 'server') {
return getServerCardActions(result as Labrinth.Search.v3.ResultSearchProject)
return getServerCardActions(result)
}
// Non-server project actions
const projectResult = result as (Labrinth.Search.v2.ResultSearchProject &
Labrinth.Search.v3.ResultSearchProject) & {
const projectResult = result as Labrinth.Search.v3.ResultSearchProject & {
installed?: boolean
installing?: boolean
}
@@ -913,11 +915,9 @@ async function search(requestParams: string) {
}
const hits = rawResults.result.hits.map((hit) => {
const mapped = {
const mapped: Labrinth.Search.v3.ResultSearchProject & { installed?: boolean } = {
...hit,
title: hit.name,
description: hit.summary,
} as unknown as Labrinth.Search.v2.ResultSearchProject & { installed?: boolean }
}
if (instance.value || isServerContext.value) {
const installedIds = instance.value
@@ -960,6 +960,7 @@ const lockedFilterMessages = computed(() => ({
const searchState = useBrowseSearch({
projectType,
tags,
active: browseRouteActive,
providedFilters: combinedProvidedFilters,
search,
persistentQueryParams: ['i', 'ai', 'shi', 'sid', 'wid', 'from'],
@@ -1035,6 +1036,9 @@ onUnmounted(() => {
})
function getProjectBrowseQuery() {
if (!browseRouteActive.value) {
return undefined
}
if (!installContext.value) return undefined
return {
...route.query,
@@ -1060,7 +1064,7 @@ provideBrowseManager({
projectType,
...searchState,
advancedFiltersCollapsed,
getProjectLink: (result: Labrinth.Search.v2.ResultSearchProject) => ({
getProjectLink: (result: Labrinth.Search.v3.ResultSearchProject) => ({
path: `/project/${result.project_id ?? result.slug}`,
query: getProjectBrowseQuery(),
}),
@@ -1139,7 +1143,7 @@ provideBrowseManager({
@browse-modpacks="() => {}"
@create="handleServerModpackFlowCreate"
/>
<Teleport to="#sidebar-teleport-target">
<Teleport v-if="browseRouteActive" to="#sidebar-teleport-target">
<BrowseSidebar />
</Teleport>
</div>
+209 -27
View File
@@ -17,6 +17,7 @@ import {
injectModrinthClient,
injectNotificationManager,
SkinPreviewRenderer,
Toggle,
useVIntl,
} from '@modrinth/ui'
import { arrayBufferToBase64 } from '@modrinth/utils'
@@ -26,13 +27,18 @@ import { computedAsync } from '@vueuse/core'
import type { Ref } from 'vue'
import { computed, inject, onMounted, onUnmounted, ref, useTemplateRef, watch } from 'vue'
import EarsModIcon from '@/assets/skins/ears-mod.png'
import type AccountsCard from '@/components/ui/AccountsCard.vue'
import EditSkinModal from '@/components/ui/skin/EditSkinModal.vue'
import VirtualSkinSectionList from '@/components/ui/skin/VirtualSkinSectionList.vue'
import { trackEvent } from '@/helpers/analytics'
import { check_reachable, get_default_user, login as login_flow, users } from '@/helpers/auth'
import type { RenderResult } from '@/helpers/rendering/batch-skin-renderer.ts'
import { generateSkinPreviews, skinBlobUrlMap } from '@/helpers/rendering/batch-skin-renderer.ts'
import {
generateSkinPreviews,
getSkinPreviewKey,
skinBlobUrlMap,
} from '@/helpers/rendering/batch-skin-renderer.ts'
import type { Cape, Skin, SkinTextureUrl } from '@/helpers/skins.ts'
import {
equip_skin,
@@ -60,6 +66,7 @@ type VirtualSkinSectionListExpose = {
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',
@@ -163,6 +170,18 @@ const messages = defineMessages({
id: 'app.skins.preview.edit-button',
defaultMessage: 'Edit skin',
},
earsFeatureNotice: {
id: 'app.skins.ears-feature-notice',
defaultMessage: 'This skin uses features from the {ears} mod',
},
toggleEarsFeaturesOff: {
id: 'app.skins.toggle-ears-features-off',
defaultMessage: 'Toggle off',
},
toggleEarsFeaturesOn: {
id: 'app.skins.toggle-ears-features-on',
defaultMessage: 'Toggle on',
},
excitedRinthbotAlt: {
id: 'app.skins.sign-in.rinthbot-alt',
defaultMessage: 'Excited Modrinth Bot',
@@ -204,8 +223,30 @@ const currentUserId = ref<string | undefined>(undefined)
const username = computed(() => currentUser.value?.profile?.name ?? undefined)
const selectedSkin = ref<Skin | null>(null)
const isApplyingSkin = ref(false)
const earsFeaturesEnabled = ref(true)
const selectedSkinHasEarsFeatures = ref(false)
const originalSelectedSkin = ref<Skin | null>(null)
const earsFeatureNoticeParts = computed(() => {
const notice = formatMessage(messages.earsFeatureNotice, {
ears: EARS_NOTICE_PLACEHOLDER,
})
const placeholderIndex = notice.indexOf(EARS_NOTICE_PLACEHOLDER)
if (placeholderIndex === -1) {
return {
before: notice,
after: '',
hasEarsLink: false,
}
}
return {
before: notice.slice(0, placeholderIndex),
after: notice.slice(placeholderIndex + EARS_NOTICE_PLACEHOLDER.length),
hasEarsLink: true,
}
})
const savedSkins = computed(() => {
try {
@@ -755,8 +796,7 @@ async function loadCurrentUser() {
}
function getBakedSkinTextures(skin: Skin): RenderResult | undefined {
const key = `${skin.texture_key}+${skin.variant}+${skin.cape_id ?? 'no-cape'}`
return skinBlobUrlMap.get(key)
return skinBlobUrlMap.get(getSkinPreviewKey(skin))
}
async function login() {
@@ -940,6 +980,10 @@ watch(
() => {},
)
watch(selectedSkin, () => {
earsFeaturesEnabled.value = true
})
watch(isSkinManagementReadOnly, (readOnly) => {
if (readOnly) {
isDraggingSkinFile.value = false
@@ -1035,9 +1079,12 @@ await loadSkins()
<SkinPreviewRenderer
:cape-src="capeTexture"
:texture-src="skinTexture || ''"
:ears-texture-src="selectedSkin?.texture"
:variant="skinVariant"
:nametag="skinNametag"
:initial-rotation="Math.PI / 8"
:ears-enabled="earsFeaturesEnabled"
@ears-features-detected="selectedSkinHasEarsFeatures = $event"
>
<template v-if="hasPendingSkinChange" #nametag-badge>
<div
@@ -1049,36 +1096,126 @@ await loadSkins()
</template>
<template #subtitle>
<div
v-if="hasPendingSkinChange"
class="flex max-w-[calc(100vw-2rem)] flex-wrap items-center justify-center gap-2 px-2"
class="skin-preview-subtitle flex w-full flex-col items-center gap-6"
:class="{ 'has-ears-features': selectedSkinHasEarsFeatures }"
>
<button
class="flex h-10 min-w-0 cursor-pointer items-center justify-center gap-2 rounded-[14px] border-0 bg-surface-4 px-4 py-2.5 text-base font-semibold leading-5 text-contrast shadow-md transition-[filter,transform] duration-200 enabled:hover:brightness-[--hover-brightness] enabled:focus-visible:brightness-[--hover-brightness] enabled:active:scale-95 disabled:cursor-not-allowed disabled:opacity-50 [&>svg]:size-5 [&>svg]:shrink-0"
:disabled="isApplyingSkin || isSkinManagementReadOnly"
@click="resetSelectedSkin"
<div
v-if="hasPendingSkinChange"
class="skin-preview-actions flex w-full items-center justify-center gap-1.5"
:class="selectedSkinHasEarsFeatures ? 'flex-nowrap' : 'flex-wrap'"
>
<RotateCounterClockwiseIcon />
{{ formatMessage(commonMessages.resetButton) }}
</button>
<button
v-tooltip="
selectedSkinHasEarsFeatures
? formatMessage(commonMessages.resetButton)
: undefined
"
class="skin-preview-action-button flex h-10 min-w-0 cursor-pointer items-center justify-center gap-2 rounded-[14px] border-0 bg-surface-4 px-4 py-2.5 text-base font-semibold leading-5 text-contrast shadow-md transition-[filter,transform] duration-200 enabled:hover:brightness-[--hover-brightness] enabled:focus-visible:brightness-[--hover-brightness] enabled:active:scale-95 disabled:cursor-not-allowed disabled:opacity-50 [&>svg]:size-5 [&>svg]:shrink-0"
:disabled="isApplyingSkin || isSkinManagementReadOnly"
:aria-label="formatMessage(commonMessages.resetButton)"
@click="resetSelectedSkin"
>
<RotateCounterClockwiseIcon />
<span class="skin-preview-action-label">
{{ formatMessage(commonMessages.resetButton) }}
</span>
</button>
<button
v-tooltip="
selectedSkinHasEarsFeatures ? formatMessage(messages.applyButton) : undefined
"
class="skin-preview-action-button flex h-10 min-w-0 cursor-pointer items-center justify-center gap-2 rounded-[14px] border-0 bg-brand px-4 py-2.5 text-base font-semibold leading-5 text-[rgba(0,0,0,0.9)] shadow-md transition-[filter,transform] duration-200 enabled:hover:brightness-[--hover-brightness] enabled:focus-visible:brightness-[--hover-brightness] enabled:active:scale-95 disabled:cursor-not-allowed disabled:opacity-50 [&>svg]:size-5 [&>svg]:shrink-0"
:disabled="isApplyingSkin || isSkinManagementReadOnly"
:aria-label="formatMessage(messages.applyButton)"
@click="applySelectedSkin"
>
<SpinnerIcon v-if="isApplyingSkin" class="animate-spin" />
<CheckIcon v-else />
<span class="skin-preview-action-label">
{{ formatMessage(messages.applyButton) }}
</span>
</button>
</div>
<button
class="flex h-10 min-w-0 cursor-pointer items-center justify-center gap-2 rounded-[14px] border-0 bg-brand px-4 py-2.5 text-base font-semibold leading-5 text-[rgba(0,0,0,0.9)] shadow-md transition-[filter,transform] duration-200 enabled:hover:brightness-[--hover-brightness] enabled:focus-visible:brightness-[--hover-brightness] enabled:active:scale-95 disabled:cursor-not-allowed disabled:opacity-50 [&>svg]:size-5 [&>svg]:shrink-0"
:disabled="isApplyingSkin || isSkinManagementReadOnly"
@click="applySelectedSkin"
v-else
class="flex h-10 min-w-0 cursor-pointer items-center justify-center gap-2 rounded-[14px] border-0 bg-surface-4 px-4 py-2.5 text-base font-semibold leading-5 shadow-md transition-[filter,transform] duration-200 enabled:hover:brightness-[--hover-brightness] enabled:focus-visible:brightness-[--hover-brightness] enabled:active:scale-95 disabled:cursor-not-allowed disabled:opacity-50 [&>svg]:size-5 [&>svg]:shrink-0"
:disabled="!selectedSkin || isSkinManagementReadOnly"
@click="(e: MouseEvent) => selectedSkin && editSkinModal?.show(e, selectedSkin)"
>
<SpinnerIcon v-if="isApplyingSkin" class="animate-spin" />
<CheckIcon v-else />
{{ formatMessage(messages.applyButton) }}
<EditIcon />
{{ formatMessage(messages.editSkinButton) }}
</button>
<div
v-if="selectedSkinHasEarsFeatures"
class="ears-feature-notice box-border flex w-full max-w-[340px] items-center justify-center gap-1.5 px-2"
>
<div class="ears-feature-copy flex min-w-0 flex-1 items-center gap-1.5">
<img
:src="EarsModIcon"
alt=""
class="size-10 shrink-0 rounded-[7px] border border-solid border-surface-5 object-cover"
/>
<p
class="ears-feature-description m-0 min-w-0 flex-1 text-sm font-medium leading-5 text-primary"
>
{{ earsFeatureNoticeParts.before
}}<router-link
v-if="earsFeatureNoticeParts.hasEarsLink"
to="/project/mfzaZK3Z"
class="text-inherit underline"
>Ears</router-link
>{{ earsFeatureNoticeParts.after }}
</p>
<router-link
to="/project/mfzaZK3Z"
class="ears-feature-compact-label hidden min-w-0 flex-1 text-sm font-medium leading-5 text-primary underline"
>Ears</router-link
>
</div>
<ButtonStyled type="outlined">
<button
class="ears-feature-toggle-button !h-10 !rounded-[14px] !px-4 shadow-md"
:aria-pressed="earsFeaturesEnabled"
:aria-label="
formatMessage(
earsFeaturesEnabled
? messages.toggleEarsFeaturesOff
: messages.toggleEarsFeaturesOn,
)
"
@click="earsFeaturesEnabled = !earsFeaturesEnabled"
>
{{
formatMessage(
earsFeaturesEnabled
? messages.toggleEarsFeaturesOff
: messages.toggleEarsFeaturesOn,
)
}}
</button>
</ButtonStyled>
<Toggle
v-model="earsFeaturesEnabled"
v-tooltip="
formatMessage(
earsFeaturesEnabled
? messages.toggleEarsFeaturesOff
: messages.toggleEarsFeaturesOn,
)
"
small
class="ears-feature-toggle-switch"
:aria-label="
formatMessage(
earsFeaturesEnabled
? messages.toggleEarsFeaturesOff
: messages.toggleEarsFeaturesOn,
)
"
/>
</div>
</div>
<button
v-else
class="flex h-10 min-w-0 cursor-pointer items-center justify-center gap-2 rounded-[14px] border-0 bg-surface-4 px-4 py-2.5 text-base font-semibold leading-5 shadow-md transition-[filter,transform] duration-200 enabled:hover:brightness-[--hover-brightness] enabled:focus-visible:brightness-[--hover-brightness] enabled:active:scale-95 disabled:cursor-not-allowed disabled:opacity-50 [&>svg]:size-5 [&>svg]:shrink-0"
:disabled="!selectedSkin || isSkinManagementReadOnly"
@click="(e: MouseEvent) => selectedSkin && editSkinModal?.show(e, selectedSkin)"
>
<EditIcon />
{{ formatMessage(messages.editSkinButton) }}
</button>
</template>
</SkinPreviewRenderer>
</div>
@@ -1147,6 +1284,51 @@ await loadSkins()
</template>
<style lang="scss" scoped>
.skin-preview-subtitle {
container-type: inline-size;
}
.ears-feature-toggle-switch {
display: none;
}
@container (max-width: 300px) {
.skin-preview-subtitle {
gap: 0.75rem;
}
.has-ears-features .skin-preview-action-button {
padding-left: 0 !important;
padding-right: 0 !important;
width: 2.5rem;
}
.has-ears-features .skin-preview-action-label {
display: none;
}
.ears-feature-toggle-button {
display: none !important;
}
.ears-feature-notice {
padding-left: 0;
padding-right: 0;
}
.ears-feature-description {
display: none;
}
.ears-feature-compact-label {
display: block;
}
.ears-feature-toggle-switch {
display: inline-flex;
}
}
.skin-layout {
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(0, 2.5fr);
+304 -287
View File
@@ -4,7 +4,8 @@
:class="['p-6 pr-2 pb-4', { 'shrink-0': isFixedRender }]"
@contextmenu.prevent.stop="(event) => handleRightClick(event)"
>
<ExportModal ref="exportModal" :instance="instance" />
<ExportModal v-if="!instance.quarantined" ref="exportModal" :instance="instance" />
<ConfirmDeleteInstanceModal ref="deleteConfirmModal" @delete="deleteSelectedInstance" />
<InstanceSettingsModal
:key="instance.id"
ref="settingsModal"
@@ -13,210 +14,57 @@
@unlinked="fetchInstance"
/>
<UpdateToPlayModal ref="updateToPlayModal" :instance="instance" />
<ContentPageHeader>
<template #icon>
<Avatar
:src="icon ? icon : undefined"
:alt="instance.name"
size="64px"
:tint-by="instance.id"
/>
</template>
<template #title>
{{ instance.name }}
</template>
<template #stats>
<div class="flex items-center flex-wrap gap-2">
<template v-if="!isServerInstance">
<div class="flex items-center gap-2 capitalize font-medium">
{{ instance.loader }} {{ instance.game_version }}
</div>
<template v-if="showInstancePlayTime">
<div class="w-1.5 h-1.5 rounded-full bg-surface-5"></div>
<div class="flex items-center gap-2 font-medium">
<template v-if="timePlayed > 0">
{{ timePlayedHumanized }}
</template>
<template v-else> Never played </template>
</div>
</template>
</template>
<template v-else>
<template v-if="loadingServerPing">
<ServerOnlinePlayers
v-if="playersOnline !== undefined"
:online="playersOnline"
:status-online="statusOnline"
hide-label
/>
<ServerRecentPlays :recent-plays="recentPlays ?? 0" hide-label />
<div
v-if="
(playersOnline !== undefined || recentPlays !== undefined) &&
(minecraftServer?.region || ping)
"
class="w-1.5 h-1.5 rounded-full bg-surface-5"
></div>
<ServerPing v-if="ping" :ping="ping" />
</template>
<ServerRegion v-if="minecraftServer?.region" :region="minecraftServer?.region" />
<div
v-if="minecraftServer?.region || ping"
class="w-1.5 h-1.5 rounded-full bg-surface-5"
></div>
<div
v-if="linkedProjectV3"
class="flex gap-1.5 items-center font-medium text-primary"
>
Linked to
<Avatar
:src="linkedProjectV3.icon_url"
:alt="linkedProjectV3.name"
:tint-by="instance.id"
size="24px"
/>
<router-link
:to="`/project/${linkedProjectV3.slug ?? linkedProjectV3.id}`"
class="hover:underline text-primary truncate"
>
{{ linkedProjectV3.name }}
</router-link>
</div>
</template>
</div>
</template>
<template #actions>
<div class="flex gap-2">
<ButtonStyled
v-if="
[
'installing',
'pack_installing',
'pack_installed',
'not_installed',
'minecraft_installing',
].includes(instance.install_stage)
"
color="brand"
size="large"
>
<button disabled>Installing...</button>
</ButtonStyled>
<ButtonStyled
v-else-if="instance.install_stage !== 'installed'"
color="brand"
size="large"
>
<button @click="repairInstance()">
<DownloadIcon />
Repair
</button>
</ButtonStyled>
<ButtonStyled v-else-if="playing === true" color="red" size="large">
<button :disabled="stopping" @click="stopInstance('InstancePage')">
<StopCircleIcon />
{{ stopping ? 'Stopping...' : 'Stop' }}
</button>
</ButtonStyled>
<ButtonStyled
v-else-if="playing === false && loading === false && !isServerInstance"
color="brand"
size="large"
>
<button @click="startInstance('InstancePage')">
<PlayIcon />
Play
</button>
</ButtonStyled>
<div
v-else-if="playing === false && loading === false && isServerInstance"
class="joined-buttons"
>
<ButtonStyled color="brand" size="large">
<button @click="handlePlayServer()">
<PlayIcon />
Play
</button>
</ButtonStyled>
<ButtonStyled color="brand" size="large">
<OverflowMenu
:options="[
{
id: 'join_server',
action: () => handlePlayServer(),
},
{
id: 'launch_instance',
action: () => startInstance('InstancePage'),
},
]"
>
<div class="w-0 text-xl relative top-0.5 right-2.5">
<DropdownIcon />
</div>
<template #join_server>
<PlayIcon />
Join server
</template>
<template #launch_instance>
<PlayIcon />
Launch instance
</template>
</OverflowMenu>
</ButtonStyled>
</div>
<ButtonStyled
v-else-if="loading === true && playing === false"
color="brand"
size="large"
>
<button disabled>Starting...</button>
</ButtonStyled>
<ButtonStyled circular size="large">
<button v-tooltip="'Instance settings'" @click="settingsModal?.show()">
<SettingsIcon />
</button>
</ButtonStyled>
<ButtonStyled type="transparent" circular size="large">
<OverflowMenu
:options="[
{
id: 'open-folder',
action: () => {
if (instance) showInstanceInFolder(instance.id)
},
},
{
id: 'export-mrpack',
action: () => exportModal?.show(),
},
{
id: 'create-shortcut',
action: () => createShortcut(),
},
]"
>
<MoreVerticalIcon />
<template #share-instance> <UserPlusIcon /> Share instance </template>
<template #host-a-server> <ServerIcon /> Create a server </template>
<template #open-folder> <FolderOpenIcon /> Open folder </template>
<template #export-mrpack> <PackageIcon /> Export modpack </template>
<template #create-shortcut> <ExternalIcon /> Create shortcut </template>
</OverflowMenu>
</ButtonStyled>
</div>
</template>
</ContentPageHeader>
<SharedInstanceUpdateModal
ref="sharedInstanceUpdateModal"
@shared-instance-unavailable="handleSharedInstanceUnavailable"
@report="(event) => reportSharedInstance(event, true)"
/>
<SharedInstanceInstallModal
ref="sharedInstanceReportModal"
@reported="handleSharedInstanceReported"
/>
<InstancePageHeader
:instance="instance"
:icon-src="icon"
:is-server-instance="isServerInstance"
:show-instance-play-time="showInstancePlayTime"
:time-played="timePlayed"
:playing="playing"
:loading="loading"
:stopping="stopping"
:loading-server-ping="loadingServerPing"
:players-online="playersOnline"
:status-online="statusOnline"
:recent-plays="recentPlays"
:ping="ping"
:minecraft-server="minecraftServer"
:linked-project-v3="linkedProjectV3"
:shared-instance-manager="sharedInstanceManager"
@repair="() => repairInstance()"
@stop="() => stopInstance('InstancePage')"
@play="() => startInstance('InstancePage')"
@play-server="() => handlePlayServer()"
@settings="() => settingsModal?.show()"
@open-folder="() => instance && showInstanceInFolder(instance.id)"
@export="() => !instance.quarantined && exportModal?.show()"
@create-shortcut="() => createShortcut()"
@report="reportSharedInstance"
/>
</div>
<div :class="['px-6', { 'shrink-0': isFixedRender }]">
<NavTabs :links="tabs" />
<InstanceAdmonitions
class="mt-4"
:instance="instance"
:shared-instance-unavailable-reason="sharedInstanceUnavailableReason"
:shared-instance-unavailable-manager="sharedInstanceUnavailableManager"
:shared-instance-wrong-account="sharedInstanceWrongAccount"
:shared-instance-expected-user-id="sharedInstanceExpectedUserId"
:shared-instance-role="instance.shared_instance?.role"
:shared-instance-signed-out="sharedInstanceSignedOut"
@published="fetchInstance"
@delete="requestInstanceDeletion"
/>
</div>
<div :class="['p-6 pt-4', { 'min-h-0 flex-1 overflow-y-auto': isFixedRender }]">
<RouterView v-slot="{ Component }" :key="instance.id" :route="displayedInstanceRoute">
@@ -272,51 +120,37 @@ import {
BoxesIcon,
CheckCircleIcon,
ClipboardCopyIcon,
DownloadIcon,
DropdownIcon,
EditIcon,
ExternalIcon,
EyeIcon,
FolderOpenIcon,
GlobeIcon,
HashIcon,
MoreVerticalIcon,
PackageIcon,
PlayIcon,
PlusIcon,
ServerIcon,
SettingsIcon,
StopCircleIcon,
TerminalSquareIcon,
UpdatedIcon,
UserPlusIcon,
XIcon,
} from '@modrinth/assets'
import {
Avatar,
ButtonStyled,
ContentPageHeader,
injectNotificationManager,
NavTabs,
OverflowMenu,
ServerOnlinePlayers,
ServerPing,
ServerRecentPlays,
ServerRegion,
useLoadingBarToken,
} from '@modrinth/ui'
import { useQueryClient } from '@tanstack/vue-query'
import { injectAuth, injectNotificationManager, NavTabs, useLoadingBarToken } from '@modrinth/ui'
import { useQuery, useQueryClient } from '@tanstack/vue-query'
import { convertFileSrc } from '@tauri-apps/api/core'
import dayjs from 'dayjs'
import duration from 'dayjs/plugin/duration'
import relativeTime from 'dayjs/plugin/relativeTime'
import { computed, onUnmounted, ref, shallowRef, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import ContextMenu from '@/components/ui/ContextMenu.vue'
import ExportModal from '@/components/ui/ExportModal.vue'
import InstanceAdmonitions from '@/components/ui/instance/instance-admonitions/index.vue'
import InstancePageHeader from '@/components/ui/instance-page-header/index.vue'
import ConfirmDeleteInstanceModal from '@/components/ui/modal/ConfirmDeleteInstanceModal.vue'
import InstanceSettingsModal from '@/components/ui/modal/InstanceSettingsModal.vue'
import UpdateToPlayModal from '@/components/ui/modal/UpdateToPlayModal.vue'
import SharedInstanceInstallModal from '@/components/ui/shared-instances/shared-instance-install-modal/index.vue'
import SharedInstanceUpdateModal from '@/components/ui/shared-instances/SharedInstanceUpdateModal.vue'
import {
fetchCachedServerStatus,
getFreshCachedServerStatus,
@@ -325,10 +159,25 @@ import { useInstanceConsole } from '@/composables/useInstanceConsole'
import { trackEvent } from '@/helpers/analytics'
import { get_project_v3 } from '@/helpers/cache.js'
import { instance_listener, process_listener } from '@/helpers/events'
import { install_existing_instance, install_pack_to_existing_instance } from '@/helpers/install'
import { get, get_full_path, kill, run } from '@/helpers/instance'
import {
getSharedInstanceUnavailableReason,
install_existing_instance,
install_get_shared_instance_preview,
install_pack_to_existing_instance,
isSharedInstanceUnavailableError,
type SharedInstanceUnavailableReason,
} from '@/helpers/install'
import {
can_current_user_use_shared_instances,
get,
get_full_path,
kill,
remove,
run,
} from '@/helpers/instance'
import { type InstanceContentData, loadInstanceContentData } from '@/helpers/instance-content'
import { get_by_instance_id } from '@/helpers/process'
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'
@@ -336,11 +185,13 @@ import { injectServerInstall } from '@/providers/server-install'
import { handleSevereError } from '@/store/error.js'
import { useBreadcrumbs, useTheming } from '@/store/state'
dayjs.extend(duration)
import { provideSharedInstanceState, useSharedInstanceState } from './use-shared-instance-state'
dayjs.extend(relativeTime)
const { addNotification, handleError } = injectNotificationManager()
const { playServerProject } = injectServerInstall()
const auth = injectAuth()
const queryClient = useQueryClient()
const route = useRoute()
@@ -363,17 +214,23 @@ const instance = ref<GameInstance>()
const preloadedContent = ref<InstanceContentData | null>(null)
const playing = ref(false)
const loading = ref(false)
const checkingSharedInstanceLaunch = ref(false)
const subpagePending = ref(false)
const stopping = ref(false)
const exportModal = ref<InstanceType<typeof ExportModal>>()
const updateToPlayModal = ref<InstanceType<typeof UpdateToPlayModal>>()
const sharedInstanceUpdateModal = ref<InstanceType<typeof SharedInstanceUpdateModal>>()
const sharedInstanceReportModal = ref<InstanceType<typeof SharedInstanceInstallModal>>()
const deleteConfirmModal = ref<InstanceType<typeof ConfirmDeleteInstanceModal>>()
const selectedInstanceToDelete = ref<GameInstance | null>(null)
const { notifySharedInstanceError, notifySharedInstanceUnavailable } = useSharedInstanceErrors()
useLoadingBarToken(subpagePending)
const isServerInstance = ref(false)
const linkedProjectV3 = ref<Labrinth.Projects.v3.Project>()
const selected = ref<unknown[]>([])
const minecraftServer = computed(() => linkedProjectV3.value?.minecraft_server)
const javaServerPingData = computed(() => linkedProjectV3.value?.minecraft_java_server?.ping?.data)
const liveServerStatusOnline = ref(false)
@@ -385,11 +242,27 @@ const playersOnline = ref<number | undefined>(undefined)
const ping = ref<number | undefined>(undefined)
const loadingServerPing = ref(false)
const activeInstanceId = ref<string>()
const sharedInstanceState = useSharedInstanceState(instance, offline, notifySharedInstanceError)
provideSharedInstanceState(sharedInstanceState)
const {
actionsLocked: sharedInstanceActionsLocked,
expectedUserId: sharedInstanceExpectedUserId,
manager: sharedInstanceManager,
refreshUpdatePreview: refreshSharedInstanceUpdatePreview,
setUnavailable: setSharedInstanceUnavailable,
signedOut: sharedInstanceSignedOut,
unavailableManager: sharedInstanceUnavailableManager,
unavailableReason: sharedInstanceUnavailableReason,
wrongAccount: sharedInstanceWrongAccount,
} = sharedInstanceState
watch(
() => router.currentRoute.value,
(nextRoute) => {
if (nextRoute.path.startsWith('/instance')) {
if (
nextRoute.path.startsWith('/instance') &&
(!instance.value || nextRoute.params.id === instance.value.id)
) {
displayedInstanceRoute.value = nextRoute
}
},
@@ -415,17 +288,15 @@ function isContentSubpageRoute(routeName = displayedInstanceRoute.value.name) {
}
async function fetchInstance() {
isServerInstance.value = false
linkedProjectV3.value = undefined
preloadedContent.value = null
resetServerStatus()
const requestedInstanceId = route.params.id as string
const requestedRouteName = route.name
const nextInstance = await get(route.params.id as string).catch(handleError)
const nextInstance = await get(requestedInstanceId).catch(handleError)
let nextLinkedProjectV3: Labrinth.Projects.v3.Project | undefined
let nextIsServerInstance = false
const contentPreloadPromise =
nextInstance && isContentSubpageRoute()
nextInstance && isContentSubpageRoute(requestedRouteName)
? loadInstanceContentData(nextInstance.id, undefined, handleError)
: Promise.resolve(null)
@@ -441,13 +312,25 @@ async function fetchInstance() {
}
}
const nextPreloadedContent = await contentPreloadPromise
let nextPreloadedContent = await contentPreloadPromise
let nextRoute = router.currentRoute.value
if (nextRoute.params.id !== requestedInstanceId) return
if (nextInstance && isContentSubpageRoute(nextRoute.name) && !nextPreloadedContent) {
nextPreloadedContent = await loadInstanceContentData(nextInstance.id, undefined, handleError)
nextRoute = router.currentRoute.value
if (nextRoute.params.id !== requestedInstanceId) return
}
instance.value = nextInstance ?? undefined
displayedInstanceRoute.value = nextRoute
sharedInstanceState.reset()
sharedInstanceState.refreshAvailability()
linkedProjectV3.value = nextLinkedProjectV3
isServerInstance.value = nextIsServerInstance
preloadedContent.value = nextPreloadedContent
activeInstanceId.value = nextInstance?.id
resetServerStatus()
fetchDeferredData(nextInstance?.id)
@@ -531,29 +414,78 @@ const isFixedRender = computed(() => renderMode.value === 'fixed')
const contentSubpageProps = computed(() =>
isContentSubpageRoute() ? { preloadedContent: preloadedContent.value } : {},
)
const { data: canCurrentUserUseSharedInstances } = useQuery({
queryKey: computed(() => ['shared-instance-eligibility', auth.user.value?.id]),
queryFn: can_current_user_use_shared_instances,
enabled: () => !!auth.session_token.value && !!auth.user.value?.id,
retry: false,
staleTime: Infinity,
refetchOnMount: 'always',
refetchOnWindowFocus: false,
refetchOnReconnect: false,
})
const currentUserCanUseSharedInstances = computed(
() => !auth.session_token.value || canCurrentUserUseSharedInstances.value !== false,
)
const showShareTab = computed(() => {
const linkType = instance.value?.link?.type
const tabs = computed(() => [
{
label: 'Content',
href: `${basePath.value}`,
icon: BoxesIcon,
return (
currentUserCanUseSharedInstances.value &&
!instance.value?.quarantined &&
instance.value?.shared_instance?.role !== 'member' &&
linkType !== 'server_project' &&
linkType !== 'server_project_modpack'
)
})
const tabs = computed(() => {
const instanceTabs = [
{
label: 'Content',
href: `${basePath.value}`,
icon: BoxesIcon,
},
{
label: 'Files',
href: `${basePath.value}/files`,
icon: FolderOpenIcon,
},
{
label: 'Worlds',
href: `${basePath.value}/worlds`,
icon: GlobeIcon,
},
{
label: 'Logs',
href: `${basePath.value}/logs`,
icon: TerminalSquareIcon,
},
]
if (showShareTab.value) {
instanceTabs.push({
label: 'Share',
href: `${basePath.value}/share`,
icon: UserPlusIcon,
})
}
return instanceTabs
})
watch(
() => ({
quarantined: instance.value?.quarantined ?? false,
routeName: router.currentRoute.value.name,
}),
({ quarantined, routeName }) => {
if (quarantined && routeName === 'InstanceShare') {
void router.replace(basePath.value)
}
},
{
label: 'Files',
href: `${basePath.value}/files`,
icon: FolderOpenIcon,
},
{
label: 'Worlds',
href: `${basePath.value}/worlds`,
icon: GlobeIcon,
},
{
label: 'Logs',
href: `${basePath.value}/logs`,
icon: TerminalSquareIcon,
},
])
{ immediate: true },
)
if (instance.value) {
breadcrumbs.setName(
@@ -571,13 +503,8 @@ if (instance.value) {
const options = ref<InstanceType<typeof ContextMenu> | null>(null)
const startInstance = async (context: string) => {
if (!instance.value) return
if (updateToPlayModal.value?.hasUpdate) {
updateToPlayModal.value.show(instance.value)
return
}
const launchInstance = async (context: string) => {
if (!instance.value || instance.value.quarantined) return
loading.value = true
try {
await run(route.params.id as string)
@@ -587,6 +514,7 @@ const startInstance = async (context: string) => {
}
loading.value = false
if (!instance.value) return
trackEvent('InstanceStart', {
loader: instance.value.loader,
game_version: instance.value.game_version,
@@ -594,6 +522,65 @@ const startInstance = async (context: string) => {
})
}
async function handleSharedInstanceUnavailable(
reason: SharedInstanceUnavailableReason | null = null,
) {
notifySharedInstanceUnavailable(reason, sharedInstanceUnavailableManager.value)
await fetchInstance()
setSharedInstanceUnavailable(reason)
}
const startInstance = async (context: string) => {
if (!instance.value || instance.value.quarantined) return
if (checkingSharedInstanceLaunch.value || loading.value || playing.value) return
const instanceId = instance.value.id
const isSharedInstanceMember = instance.value.shared_instance?.role === 'member'
const canCheckSharedInstanceUpdate =
!!instance.value.shared_instance && !sharedInstanceActionsLocked.value && !offline.value
if (canCheckSharedInstanceUpdate) {
let preview: Awaited<ReturnType<typeof refreshSharedInstanceUpdatePreview>>
checkingSharedInstanceLaunch.value = true
try {
preview = await refreshSharedInstanceUpdatePreview()
} catch (error) {
if (isSharedInstanceUnavailableError(error)) {
await handleSharedInstanceUnavailable(getSharedInstanceUnavailableReason(error))
} else {
notifySharedInstanceError(error)
}
return
} finally {
checkingSharedInstanceLaunch.value = false
}
if (instance.value?.id !== instanceId) return
if (preview?.updateAvailable && sharedInstanceUpdateModal.value) {
sharedInstanceUpdateModal.value.show(instance.value, preview, async () => {
await fetchInstance()
await launchInstance(context)
})
return
}
}
if (updateToPlayModal.value?.hasUpdate) {
if (isSharedInstanceMember) {
updateToPlayModal.value.show(instance.value, null, async () => {
await fetchInstance()
await launchInstance(context)
})
} else {
updateToPlayModal.value.show(instance.value)
}
return
}
await launchInstance(context)
}
const stopInstance = async (context: string) => {
stopping.value = true
await kill(route.params.id as string).catch(handleError)
@@ -609,7 +596,7 @@ const stopInstance = async (context: string) => {
}
const handlePlayServer = async () => {
if (!instance.value?.link?.project_id) return
if (!instance.value?.link?.project_id || instance.value.quarantined) return
loading.value = true
try {
await playServerProject(instance.value.link.project_id)
@@ -620,6 +607,7 @@ const handlePlayServer = async () => {
}
const repairInstance = async () => {
if (instance.value.quarantined) return
if (
instance.value.install_stage !== 'pack_installed' &&
(instance.value.link?.type === 'modrinth_modpack' ||
@@ -637,7 +625,7 @@ const repairInstance = async () => {
}
const createShortcut = async () => {
if (!instance.value) return
if (!instance.value || instance.value.quarantined) return
try {
const shortcutPath = await createInstanceShortcut(instance.value.name, instance.value.id)
if (!shortcutPath) return
@@ -655,10 +643,51 @@ const createShortcut = async () => {
}
}
async function reportSharedInstance(event?: MouseEvent, closeUpdateModal = false) {
const reportInstance = instance.value
const sharedInstance = reportInstance?.shared_instance
if (!reportInstance || sharedInstance?.role !== 'member') return
try {
const preview = await install_get_shared_instance_preview(
sharedInstance.id,
reportInstance.name,
)
if (instance.value?.id !== reportInstance.id) return
if (closeUpdateModal) sharedInstanceUpdateModal.value?.hide()
sharedInstanceReportModal.value?.showReport(preview, event)
} catch (error) {
handleError(error as Error)
}
}
function handleSharedInstanceReported(deleteInstance: boolean) {
if (!deleteInstance || !instance.value) return
requestInstanceDeletion()
}
function requestInstanceDeletion() {
if (!instance.value) return
selectedInstanceToDelete.value = instance.value
deleteConfirmModal.value?.show()
}
async function deleteSelectedInstance() {
const selectedInstance = selectedInstanceToDelete.value
selectedInstanceToDelete.value = null
if (!selectedInstance) return
trackEvent('InstanceRemove', {
loader: selectedInstance.loader,
game_version: selectedInstance.game_version,
})
await router.push({ path: '/' })
await remove(selectedInstance.id).catch(handleError)
}
const handleRightClick = (event: MouseEvent) => {
const baseOptions = [
{ name: 'add_content' },
{ type: 'divider' },
...(instance.value?.quarantined ? [] : [{ name: 'add_content' }, { type: 'divider' }]),
{ name: 'edit' },
{ name: 'open_folder' },
{ name: 'copy_path' },
@@ -676,10 +705,14 @@ const handleRightClick = (event: MouseEvent) => {
...baseOptions,
]
: [
{
name: 'play',
color: 'primary',
},
...(instance.value?.quarantined
? []
: [
{
name: 'play',
color: 'primary',
},
]),
...baseOptions,
],
)
@@ -758,22 +791,6 @@ const timePlayed = computed(() => {
: 0
})
const timePlayedHumanized = computed(() => {
const duration = dayjs.duration(timePlayed.value, 'seconds')
const hours = Math.floor(duration.asHours())
if (hours >= 1) {
return hours + ' hour' + (hours > 1 ? 's' : '')
}
const minutes = Math.floor(duration.asMinutes())
if (minutes >= 1) {
return minutes + ' minute' + (minutes > 1 ? 's' : '')
}
const seconds = Math.floor(duration.asSeconds())
return seconds + ' second' + (seconds > 1 ? 's' : '')
})
onUnmounted(() => {
unlistenProcesses()
unlistenInstances()
+111 -54
View File
@@ -19,14 +19,26 @@
ref="modpackContentModal"
:modpack-name="displayedModpackProject?.title"
:modpack-icon-url="displayedModpackProject?.icon_url ?? undefined"
:enable-toggle="!props.isServerInstance"
:enable-toggle="!props.isServerInstance && !isSharedMember && !isQuarantined"
:busy="isBulkOperating"
:get-overflow-options="getOverflowOptions"
:switch-version="handleSwitchVersion"
:switch-version="
props.isServerInstance || isSharedMember || isQuarantined
? undefined
: handleSwitchVersion
"
@update:enabled="handleModpackContentToggle"
@bulk:enable="(items) => handleModpackContentBulkToggle(items, true)"
@bulk:disable="(items) => handleModpackContentBulkToggle(items, false)"
/>
<ConfirmDisableModal
ref="sharedDisableConfirmModal"
:count="pendingModpackDisableItems.length"
:item-type="formatMessage(messages.contentTypeProject)"
:warning="managedContentPolicy.disableWarning(pendingModpackDisableItems)"
:action-disabled="isInstanceBusy"
@disable="confirmPendingModpackContentDisable"
/>
<ConfirmModpackUpdateModal
ref="modpackUpdateConfirmModal"
:downgrade="isModpackUpdateDowngrade"
@@ -38,7 +50,11 @@
@confirm="handleModpackUpdateConfirm"
@cancel="handleModpackUpdateCancel"
/>
<ExportModal v-if="projects.length > 0" ref="exportModal" :instance="instance" />
<ExportModal
v-if="projects.length > 0 && !instance.quarantined"
ref="exportModal"
:instance="instance"
/>
<ContentUpdaterModal
v-if="updatingProject || updatingModpack"
ref="contentUpdaterModal"
@@ -78,6 +94,7 @@ import { ClipboardCopyIcon, FolderOpenIcon } from '@modrinth/assets'
import {
type BulkOperationStatus,
commonMessages,
ConfirmDisableModal,
ConfirmModpackUpdateModal,
ContentCardLayout as ContentPageLayout,
type ContentItem,
@@ -91,7 +108,6 @@ import {
ModpackContentModal,
type ModpackContentModalState,
type OverflowMenuOption,
provideAppBackup,
provideContentManager,
ReadyTransition,
UnknownFileWarningModal,
@@ -109,6 +125,7 @@ import { useRouter } from 'vue-router'
import ExportModal from '@/components/ui/ExportModal.vue'
import ShareModalWrapper from '@/components/ui/modal/ShareModalWrapper.vue'
import { useManagedContentPolicy } from '@/composables/instances/use-managed-content-policy'
import { trackEvent } from '@/helpers/analytics'
import { get_project_versions, get_version, get_version_many } from '@/helpers/cache.js'
import {
@@ -116,13 +133,11 @@ import {
instance_listener,
type InstanceBulkUpdateProgress,
} from '@/helpers/events.js'
import { install_duplicate_instance, installJobInstanceId } from '@/helpers/install'
import {
add_project_from_path,
edit,
get_linked_modpack_content,
is_file_on_modrinth,
list,
remove_project,
switch_project_version_with_dependencies,
toggle_disable_project,
@@ -134,6 +149,7 @@ import { get as getSettings, set as setSettings } from '@/helpers/settings'
import type { CacheBehaviour, GameInstance } from '@/helpers/types'
import { highlightModInInstance } from '@/helpers/utils.js'
import { injectContentInstall } from '@/providers/content-install'
import { provideInstanceBackup } from '@/providers/instance-backup'
import { useTheming } from '@/store/state'
import type { FeatureFlag } from '@/store/theme'
@@ -158,6 +174,10 @@ const messages = defineMessages({
id: 'app.instance.mods.projects-were-added',
defaultMessage: '{count} projects were added',
},
lockedContent: {
id: 'app.instance.mods.locked-content',
defaultMessage: 'Content in locked instances cannot be changed.',
},
contentTypeProject: {
id: 'app.instance.mods.content-type-project',
defaultMessage: 'project',
@@ -197,6 +217,13 @@ const props = defineProps<{
openSettings?: () => void
preloadedContent?: InstanceContentData | null
}>()
const managedContentPolicy = useManagedContentPolicy(computed(() => props.instance))
const {
isManagedModpack: isSharedMember,
isQuarantined,
canMutateContent,
canUpdateContent: canUpdateProject,
} = managedContentPolicy
function hasPreloadedContent(contentData: InstanceContentData | null | undefined) {
return contentData?.path === props.instance.id
@@ -287,6 +314,7 @@ const isBulkOperating = ref(false)
const isInstanceBusy = computed(() => props.instance?.install_stage !== 'installed')
const isPackLocked = computed(
() =>
props.instance.quarantined ||
props.instance?.link?.type === 'modrinth_modpack' ||
props.instance?.link?.type === 'server_project_modpack',
)
@@ -296,6 +324,8 @@ const exportModal = ref(null)
const contentUpdaterModal = ref<InstanceType<typeof ContentUpdaterModal> | null>()
const modpackContentModal = ref<InstanceType<typeof ModpackContentModal> | null>()
const modpackUpdateConfirmModal = ref<InstanceType<typeof ConfirmModpackUpdateModal> | null>()
const sharedDisableConfirmModal = ref<InstanceType<typeof ConfirmDisableModal> | null>()
const pendingModpackDisableItems = ref<ContentItem[]>([])
const unknownFileWarningModal = ref<InstanceType<typeof UnknownFileWarningModal> | null>()
const unknownFileName = ref('')
let resolveUnknownFileConfirmation: ((confirmed: boolean) => void) | null = null
@@ -377,8 +407,8 @@ function hasContentOperation(item: ContentItem) {
return keys.some((key) => activeContentOperationKeys.value.has(key))
}
function canUpdateProject(item: ContentItem) {
return !!item.file_path && !!item.has_update && !!item.update_version_id
function canDeleteContent(item: ContentItem) {
return canMutateContent(item)
}
function setContentItemBusy(item: ContentItem, busy: boolean, originalFileName = item.file_name) {
@@ -486,7 +516,7 @@ async function getUpdaterProjectVersions(projectId: string, pinnedVersionId?: st
}
async function handleBrowseContent() {
if (!props.instance) return
if (!props.instance || props.instance.quarantined) return
await router.push({
path: `/browse/${props.instance.loader === 'vanilla' ? 'resourcepack' : 'mod'}`,
query: { i: props.instance.id },
@@ -494,7 +524,7 @@ async function handleBrowseContent() {
}
async function handleUploadFiles() {
if (!props.instance) return
if (!props.instance || props.instance.quarantined) return
const files = await open({ multiple: true })
if (!files) return
const selectedFiles: Array<{ path: string; filename: string }> = []
@@ -517,26 +547,37 @@ async function handleUploadFiles() {
}),
)
const addedFiles: string[] = []
const confirmedFiles: Array<{ path: string; filename: string }> = []
for (const [index, { path, filename }] of selectedFiles.entries()) {
if (!fileRecognition[index] && !(await confirmUnknownFileInstallation(filename))) {
continue
}
try {
await add_project_from_path(props.instance.id, path)
addedFiles.push(filename)
} catch (e) {
handleError(e as Error)
}
confirmedFiles.push({ path, filename })
}
await initProjects()
if (addedFiles.length > 0) {
const names = addedFiles.map((f) => {
const item = projects.value.find(
(p) => p.file_name === f || p.file_name === f.replace('.zip', '.jar'),
)
return item?.project?.title ?? f
const addedFiles = (
await Promise.all(
confirmedFiles.map(async ({ path, filename }) => {
try {
const installedPath = await add_project_from_path(props.instance.id, path)
return { filename, installedPath }
} catch (error) {
handleError(error as Error)
return null
}
}),
)
).filter((result): result is { filename: string; installedPath: string } => result !== null)
const uniqueAddedFiles = [
...new Map(addedFiles.map((file) => [file.installedPath, file])).values(),
]
await initProjects('must_revalidate')
if (uniqueAddedFiles.length > 0) {
const names = uniqueAddedFiles.map(({ filename, installedPath }) => {
const item = projects.value.find((project) => project.file_path === installedPath)
return item?.project?.title ?? filename
})
addNotification({
type: 'success',
@@ -784,6 +825,7 @@ async function updateProject(mod: ContentItem) {
}
async function switchProjectVersion(mod: ContentItem, version: Labrinth.Versions.v2.Version) {
if (!canMutateContent(mod)) return
if (!mod.file_path) return
const operation = beginContentOperation(mod)
if (!operation) return
@@ -920,6 +962,7 @@ async function handleUpdate(id: string) {
}
async function handleSwitchVersion(item: ContentItem) {
if (!canMutateContent(item)) return
if (!item.project?.id || !item.version?.id) return
const requestId = beginUpdateRequest()
@@ -947,10 +990,32 @@ async function handleSwitchVersion(item: ContentItem) {
}
async function handleModpackContentToggle(item: ContentItem, enabled: boolean) {
if (!enabled && managedContentPolicy.disableWarning([item])) {
pendingModpackDisableItems.value = [item]
sharedDisableConfirmModal.value?.show()
return
}
await toggleDisableDebounced(item, enabled)
}
async function handleModpackContentBulkToggle(items: ContentItem[], enabled: boolean) {
if (!enabled && managedContentPolicy.disableWarning(items)) {
pendingModpackDisableItems.value = items
sharedDisableConfirmModal.value?.show()
return
}
await setModpackContentEnabled(items, enabled)
}
async function confirmPendingModpackContentDisable() {
const items = [...pendingModpackDisableItems.value]
pendingModpackDisableItems.value = []
await setModpackContentEnabled(items, false)
}
async function setModpackContentEnabled(items: ContentItem[], enabled: boolean) {
await Promise.all(items.map((item) => toggleDisableMod(item, enabled)))
}
@@ -1300,29 +1365,7 @@ function applyContentData(contentData: InstanceContentData) {
return true
}
provideAppBackup({
async createBackup() {
const allInstances = await list()
const prefix = `${props.instance.name} - Backup #`
const existingNums = allInstances
.filter((p) => p.name.startsWith(prefix))
.map((p) => parseInt(p.name.slice(prefix.length), 10))
.filter((n) => !isNaN(n))
const nextNum = existingNums.length > 0 ? Math.max(...existingNums) + 1 : 1
const job = await install_duplicate_instance(props.instance.id)
const newInstanceId = installJobInstanceId(job)
if (newInstanceId) {
await edit(newInstanceId, { name: `${prefix}${nextNum}` })
}
},
})
const CONTENT_HINT_KEY = 'content-tab-modpack-hint-dismissed'
const showContentHint = ref(localStorage.getItem(CONTENT_HINT_KEY) === null)
function dismissContentHint() {
showContentHint.value = false
localStorage.setItem(CONTENT_HINT_KEY, 'true')
}
provideInstanceBackup(() => props.instance)
provideContentManager({
items: mergedProjects,
@@ -1378,21 +1421,31 @@ provideContentManager({
}),
isPackLocked,
isBusy: isInstanceBusy,
disableAddContent: isQuarantined,
disableAddContentTooltip: formatMessage(messages.lockedContent),
isBulkOperating,
skipNonEssentialWarnings,
contentTypeLabel: ref(formatMessage(messages.contentTypeProject)),
toggleEnabled: toggleDisableDebounced,
bulkEnableItems: (items: ContentItem[]) =>
Promise.all(
items.filter((item) => !item.enabled).map((item) => toggleDisableMod(item, true)),
items
.filter((item) => canMutateContent(item) && !item.enabled)
.map((item) => toggleDisableMod(item, true)),
).then(() => {}),
bulkDisableItems: (items: ContentItem[]) =>
Promise.all(
items.filter((item) => item.enabled).map((item) => toggleDisableMod(item, false)),
items
.filter((item) => canMutateContent(item) && item.enabled)
.map((item) => toggleDisableMod(item, false)),
).then(() => {}),
deleteItem: removeMod,
bulkDeleteItems: (items: ContentItem[]) =>
Promise.all(items.map((item) => removeMod(item))).then(() => {}),
Promise.all(items.filter(canMutateContent).map((item) => removeMod(item))).then(() => {}),
canDeleteItem: canDeleteContent,
canToggleItem: canMutateContent,
getDeleteWarning: managedContentPolicy.deleteWarning,
getDisableWarning: managedContentPolicy.disableWarning,
getDeleteDependencyWarning,
refresh: () => initProjects('must_revalidate'),
browse: handleBrowseContent,
@@ -1401,14 +1454,15 @@ provideContentManager({
updateItem: handleUpdate,
bulkUpdateAll: bulkUpdateAllProjects,
bulkUpdateItem: updateProject,
updateModpack: props.isServerInstance ? undefined : handleModpackUpdate,
updateModpack:
props.isServerInstance || isSharedMember.value || isQuarantined.value
? undefined
: handleModpackUpdate,
viewModpackContent: handleModpackContent,
unlinkModpack: unpairInstance,
openSettings: props.openSettings,
switchVersion: handleSwitchVersion,
getOverflowOptions,
showContentHint,
dismissContentHint,
shareItems: handleShareItems,
getItemId: getContentItemId,
mapToTableItem: (item: ContentItem) => ({
@@ -1440,8 +1494,11 @@ provideContentManager({
link: () => openUrl(`https://modrinth.com/${item.owner!.type}/${item.owner!.id}`),
}
: undefined,
enabled: item.enabled,
enabled: canMutateContent(item) ? item.enabled : undefined,
installing: item.installing,
hideDelete: !canDeleteContent(item),
hideSwitchVersion: !canMutateContent(item) || !item.project?.id || !item.version?.id,
hasUpdate: canUpdateProject(item),
}),
filterPersistKey: props.instance.id,
})
@@ -88,6 +88,7 @@
:highlighted="highlightedWorld === getWorldIdentifier(world)"
:supports-server-quick-play="supportsServerQuickPlay"
:supports-world-quick-play="supportsWorldQuickPlay"
:quarantined="instance.quarantined"
:current-protocol="protocolVersion"
:playing-instance="playing"
:playing-world="worldsMatch(world, worldPlaying)"
@@ -273,6 +274,7 @@ const instance = computed(() => props.instance)
const playing = computed(() => props.playing)
function play(world: World) {
if (props.instance.quarantined) return
emit('play', world)
}
@@ -523,6 +525,7 @@ function handleJoinError(err: Error) {
}
async function joinWorld(world: World) {
if (instance.value.quarantined) return
console.log(`Joining world ${getWorldIdentifier(world)}`)
startingInstance.value = true
worldPlaying.value = world
@@ -3,6 +3,7 @@ import Index from './Index.vue'
import Logs from './Logs.vue'
import Mods from './Mods.vue'
import Overview from './Overview.vue'
import Share from './share/index.vue'
import Worlds from './Worlds.vue'
export { Files, Index, Logs, Mods, Overview, Worlds }
export { Files, Index, Logs, Mods, Overview, Share, Worlds }

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