Compare commits

...
Author SHA1 Message Date
François-X. T. 514a860309 chore: fixes 2026-07-22 10:49:54 -04:00
François-X. T. 0e1ad0c7c3 chore: clippy 2026-07-21 21:52:52 -04:00
François-X. T. d790bf79ec chore: fixes 2026-07-21 21:49:42 -04:00
François-X. T. 67e3dfb3f4 feat(labrinth): add /_internal/billing/subscriptions/manager/update_many 2026-07-21 21:41:11 -04: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
SychicandGitHub 543cb42210 remove(labrinth): old shared instances impl (#6760) 2026-07-17 19:27:38 +00:00
86c6fb8239 fix: global trace and tech review improvements (#6699)
* remaining globally safe/unsafe buttons

* tweak global traces layout

* deterministic local trace ordering

* fix preview

* ignore severity for ordering

* show globally resolved traces toggle

* minor frontend changes

* prepr

---------

Co-authored-by: Prospector <6166773+Prospector@users.noreply.github.com>
2026-07-17 15:08:59 +00:00
Calum H.andGitHub 14e0abb273 feat: changelog 16th jul (#6750) 2026-07-16 21:48:25 +02:00
Calum H.andGitHub df653d3e43 fix: 26.1+ hardcore not being detected (#6744)
* fix: hardcore data schema changed in 26.1

* fix: lint
2026-07-16 18:32:20 +00:00
Calum H.andGitHub 4aeb6cf9ff fix: storybook ci mem (#6746)
Signed-off-by: Calum H. <calum@modrinth.com>
2026-07-16 16:49:21 +02:00
Calum H.andGitHub cb154e205b fix: ignore notfound errors (#6736)
fix: wait for db change
2026-07-15 18:32:16 +00:00
aecsocketandGitHub dad5970e8d fix: attribution list route permissions (#6731)
* fix: attribution list route permissions

* proper auth
2026-07-15 14:38:08 +00:00
Prospector 1947d083b3 changelog 2026-07-14 15:16:58 -07:00
Calum H.andGitHub 905204cc5f feat: malware warning modal changes (#6721)
* feat: improved warning modals

* fix: qa

* feat: install to play and update to play changes

* fix: dont warn for server projects as already reviewed

* fix: lint
2026-07-14 20:47:10 +00:00
8cca911775 fix(api-client): add explicit extensions to relative imports (#6609)
* Add explicit extensions to relative imports

* Add linting rule

* Fix outdated pnpm lock

* fix: use esbuild

---------

Co-authored-by: Prospector <6166773+Prospector@users.noreply.github.com>
Co-authored-by: Calum H. (IMB11) <contact@cal.engineer>
Co-authored-by: Calum H. <calum@modrinth.com>
2026-07-14 18:52:34 +00:00
coolbotandGitHub cafe58dc3c Moderation: Add keybinds for link copying and setting project status (#6716)
Add keybinds for link copying and setting project status
2026-07-14 10:21:40 +00:00
coolbotandGitHub 13e6ebf4e0 Moderation: Update tec rev quick replies. (#6719)
update tec rev quick replies.
2026-07-14 10:21:23 +00:00
1884912126 feat: rename shader config file on version change (#5636)
* feat: rename shader settings sidecar file on version switch

* fix: cleanup

---------

Co-authored-by: Calum H. <calum@modrinth.com>
Co-authored-by: Calum H. (IMB11) <contact@cal.engineer>
2026-07-14 09:58:07 +00:00
539 changed files with 35320 additions and 10809 deletions
+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
+17 -2
View File
@@ -66,6 +66,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
@@ -85,7 +87,11 @@ jobs:
command: versions upload --preview-alias git-${{ steps.meta.outputs.sha_short }}
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]
steps:
@@ -99,16 +105,25 @@ jobs:
id: urls
env:
HEAD_SHA: ${{ github.event.pull_request.head.sha }}
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)
if [ "$STORYBOOK_RESULT" = "success" ]; then
STORYBOOK_PREVIEW_URL="https://git-${HEAD_SHA::8}-storybook.modrinth.workers.dev"
else
STORYBOOK_PREVIEW_URL="⚠️ Deployment $STORYBOOK_RESULT ([logs]($RUN_URL))"
fi
echo "Production preview URL: $PRODUCTION_PREVIEW_URL"
echo "Staging preview URL: $STAGING_PREVIEW_URL"
echo "Storybook preview URL: $STORYBOOK_PREVIEW_URL"
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 "storybook-preview-url=$STORYBOOK_PREVIEW_URL" >> $GITHUB_OUTPUT
- name: Find comment
if: github.event_name == 'pull_request'
+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
Generated
+114
View File
@@ -2133,6 +2133,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"
@@ -3259,6 +3268,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 +3856,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 +4173,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"
@@ -5440,13 +5490,16 @@ dependencies = [
"json-patch 4.1.0",
"labrinth",
"lettre",
"lz4_flex",
"meilisearch-sdk",
"modrinth-content-management",
"modrinth-util",
"muralpay",
"murmur2",
"neverbounce",
"paste",
"path-util",
"postcard",
"prometheus",
"quick-xml 0.38.3",
"rand 0.8.5",
@@ -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"
@@ -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"
@@ -9794,6 +9897,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 +11008,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",
+11
View File
@@ -13,6 +13,7 @@ members = [
"packages/modrinth-log",
"packages/modrinth-maxmind",
"packages/modrinth-util",
"packages/neverbounce",
"packages/path-util",
]
@@ -88,10 +89,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 +121,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 +135,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 +145,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"
+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]
+122 -24
View File
@@ -76,6 +76,7 @@ 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'
@@ -89,7 +90,14 @@ 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,
get_ads_consent_required,
hide_ads_window,
init_ads_window,
perform_ads_consent_action,
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'
@@ -184,6 +192,8 @@ 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({
@@ -213,9 +223,20 @@ const { data: authenticatedModrinthUser } = useQuery({
enabled: () => !!credentials.value?.session,
retry: 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')),
@@ -295,6 +316,12 @@ const authUnreachable = computed(() => {
onMounted(async () => {
await useCheckDisableMouseover()
try {
unlistenAdsConsent = await ads_consent_listener(handleAdsConsentRequired)
handleAdsConsentRequired(await get_ads_consent_required())
} catch (error) {
handleError(error)
}
document.querySelector('body').addEventListener('click', handleClick)
document.querySelector('body').addEventListener('auxclick', handleAuxClick)
@@ -308,6 +335,7 @@ onUnmounted(async () => {
unsubscribeSidebarToggle()
clearDelayedUpdatePopup()
await unlistenAdsConsent?.()
await unlistenUpdateDownload?.()
})
@@ -332,8 +360,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 +500,7 @@ async function setupApp() {
addNotification({
title: 'Warning',
text: e.message,
type: 'warn',
type: 'warning',
}),
)
@@ -588,6 +685,7 @@ watch(stateInitialized, (ready) => {
const error = useError()
const errorModal = ref()
const minecraftAuthErrorModal = ref()
const minecraftRequiredModal = ref()
const contentInstall = createContentInstall({ router, handleError })
provideContentInstall(contentInstall)
@@ -703,17 +801,6 @@ async function logOut() {
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 +827,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)
@@ -855,12 +951,13 @@ async function handleCommand(e) {
if (e.path.endsWith('.mrpack')) {
const location = { type: 'fromFile', path: e.path }
const preview = await install_get_modpack_preview(location).catch(handleError)
if (preview?.unknownFile) {
if (preview?.unknownFile || preview?.externalFilesInModpack.length > 0) {
const splitPath = e.path.split(/[\\/]/)
const fileName = splitPath ? splitPath[splitPath.length - 1] : e.path
unknownPackWarningModal.value?.show(
() => install_create_modpack_instance(location).then(() => undefined),
fileName,
preview.externalFilesInModpack,
)
} else {
await install_create_modpack_instance(location).catch(handleError)
@@ -1693,6 +1790,7 @@ provideAppUpdateDownloadProgress(appUpdateDownload)
<PopupNotificationPanel :has-sidebar="sidebarVisible" />
<ErrorModal ref="errorModal" />
<MinecraftAuthErrorModal ref="minecraftAuthErrorModal" />
<MinecraftRequiredModal ref="minecraftRequiredModal" />
<ContentInstallModal
ref="modInstallModal"
:instances="contentInstallInstances"
@@ -1736,8 +1834,8 @@ provideAppUpdateDownloadProgress(appUpdateDownload)
@create-anyway="handleContentInstallModpackDuplicateCreateAnyway"
@go-to-instance="handleContentInstallModpackDuplicateGoToInstance"
/>
<InstallToPlayModal ref="installToPlayModal" />
<UpdateToPlayModal ref="updateToPlayModal" />
<InstallToPlayModal ref="installToPlayModal" :show-external-warnings="false" />
<UpdateToPlayModal ref="updateToPlayModal" :show-external-warnings="false" />
</template>
<style lang="scss" scoped>
Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 50 KiB

@@ -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
@@ -1,108 +1,41 @@
<template>
<NewModal ref="modal" :header="formatMessage(messages.header)" :on-hide="reset">
<div class="max-w-[31rem] flex flex-col gap-6">
<Admonition
type="warning"
:header="formatMessage(messages.warningTitle)"
:body="formatMessage(messages.warningBody)"
/>
<div v-if="fileName" class="overflow-x-auto whitespace-nowrap text-sm text-secondary">
{{ fileName }}
</div>
<div>
<p class="mt-0 leading-tight">
{{ formatMessage(messages.body) }}
</p>
<p class="text-orange font-semibold mb-0 leading-tight">
{{ formatMessage(messages.malwareStatement) }}
</p>
</div>
<Checkbox v-model="dontShowAgain" :label="formatMessage(messages.dontShowAgain)" />
<div class="flex gap-2 justify-end">
<ButtonStyled type="outlined">
<button @click="cancel">
<XIcon />
{{ formatMessage(commonMessages.cancelButton) }}
</button>
</ButtonStyled>
<ButtonStyled color="orange">
<button :disabled="isProceeding" @click="proceed">
<SpinnerIcon v-if="isProceeding" class="animate-spin" />
<CircleArrowRightIcon v-else />
{{ formatMessage(messages.installAnyway) }}
</button>
</ButtonStyled>
</div>
</div>
</NewModal>
<UnknownFileWarningModal
ref="modal"
:mode="externalFilesInModpack.length > 0 ? 'modpack' : 'mod'"
:file-name="fileName"
:external-files-in-modpack="externalFilesInModpack"
@cancel="reset"
@continue="proceed"
/>
</template>
<script setup lang="ts">
import { CircleArrowRightIcon, SpinnerIcon, XIcon } from '@modrinth/assets'
import {
Admonition,
ButtonStyled,
Checkbox,
commonMessages,
defineMessages,
NewModal,
useVIntl,
} from '@modrinth/ui'
import { UnknownFileWarningModal } from '@modrinth/ui'
import { ref, useTemplateRef } from 'vue'
import { get as getSettings, set as setSettings } from '@/helpers/settings'
import { useTheming } from '@/store/state'
import type { FeatureFlag } from '@/store/theme.ts'
const { formatMessage } = useVIntl()
const themeStore = useTheming()
const skipUnknownPackWarningFeatureFlag = 'skip_unknown_pack_warning' as FeatureFlag
const dontShowAgain = ref(false)
const modal = useTemplateRef('modal')
const onProceed = ref<() => Promise<void>>()
const isProceeding = ref(false)
const fileName = ref('')
const externalFilesInModpack = ref<string[]>([])
const messages = defineMessages({
header: {
id: 'unknown-pack-warning-modal.header',
defaultMessage: 'Confirm installation',
},
warningTitle: {
id: 'unknown-pack-warning-modal.warning.title',
defaultMessage: 'Unknown file warning',
},
warningBody: {
id: 'unknown-pack-warning-modal.warning.body',
defaultMessage: `We couldn't find this file on Modrinth. We strongly recommend only installing files from sources you trust.`,
},
body: {
id: 'unknown-pack-warning-modal.body',
defaultMessage: `A file is only reviewed if its uploaded to Modrinth, regardless of its file format (including .mrpack).`,
},
malwareStatement: {
id: 'unknown-pack-warning-modal.malware-statement',
defaultMessage: `Malware is often distributed through modpack files by sharing them on platforms like Discord.`,
},
dontShowAgain: {
id: 'unknown-pack-warning-modal.dont-show-again',
defaultMessage: `Don't show this warning again`,
},
installAnyway: {
id: 'unknown-pack-warning-modal.install-anyway',
defaultMessage: `Install anyway`,
},
})
function show(createInstance: () => Promise<void>, selectedFileName = '') {
function show(
createInstance: () => Promise<void>,
selectedFileName = '',
selectedExternalFiles: string[] = [],
) {
onProceed.value = createInstance
fileName.value = selectedFileName
dontShowAgain.value = false
externalFilesInModpack.value = selectedExternalFiles
if (themeStore.getFeatureFlag(skipUnknownPackWarningFeatureFlag)) {
// noinspection ES6MissingAwait
createInstance()
void createInstance()
return
}
@@ -112,18 +45,11 @@ function show(createInstance: () => Promise<void>, selectedFileName = '') {
function reset() {
onProceed.value = undefined
fileName.value = ''
externalFilesInModpack.value = []
}
function cancel() {
modal.value?.hide()
}
async function proceed() {
if (!onProceed.value) {
return
}
if (dontShowAgain.value) {
async function proceed(dontShowAgain: boolean) {
if (dontShowAgain) {
themeStore.featureFlags[skipUnknownPackWarningFeatureFlag] = true
const settings = await getSettings()
settings.feature_flags[skipUnknownPackWarningFeatureFlag] = true
@@ -131,9 +57,8 @@ async function proceed() {
}
const createInstance = onProceed.value
modal.value?.hide()
// noinspection ES6MissingAwait
createInstance()
reset()
if (createInstance) void createInstance()
}
defineExpose({ show })
@@ -0,0 +1,294 @@
<template>
<PageHeader :title="instance.name">
<template #leading>
<Avatar :src="iconSrc" :alt="instance.name" size="64px" :tint-by="instance.id" />
</template>
<template #metadata>
<InstanceHeaderServerMetadata
v-if="isServerInstance"
: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"
/>
<PageHeaderMetadata v-else>
<PageHeaderMetadataItem :icon="Gamepad2Icon" tooltip="Minecraft version">
Minecraft {{ instance.game_version }}
</PageHeaderMetadataItem>
<PageHeaderMetadataItem
:icon="ServerLoaderIcon"
:icon-props="{ loader: loaderDisplayName }"
tooltip="Mod loader"
>
{{ loaderLabel }}
</PageHeaderMetadataItem>
<PageHeaderMetadataItem
v-if="showInstancePlayTime"
:icon="TimerIcon"
tooltip="Total playtime"
>
{{ playtimeLabel }}
</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="instance.install_stage !== 'installed'" color="brand" size="large">
<button type="button" @click="emit('repair')">
<DownloadIcon />
{{ formatMessage(messages.repair) }}
</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>
<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,
MoreVerticalIcon,
PackageIcon,
PlayIcon,
SettingsIcon,
StopCircleIcon,
TagCategoryGamepad2Icon as Gamepad2Icon,
TimerIcon,
} from '@modrinth/assets'
import {
Avatar,
ButtonStyled,
commonMessages,
defineMessages,
formatLoaderLabel,
type JoinedButtonAction,
JoinedButtons,
LoaderIcon as ServerLoaderIcon,
PageHeader,
PageHeaderActions,
PageHeaderMetadata,
PageHeaderMetadataItem,
type ServerLoader,
TeleportOverflowMenu,
type TeleportOverflowMenuItem,
useVIntl,
} from '@modrinth/ui'
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',
},
starting: {
id: 'instance.action.starting',
defaultMessage: 'Starting...',
},
stopping: {
id: 'instance.action.stopping',
defaultMessage: 'Stopping...',
},
})
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
}>(),
{
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,
},
)
const emit = defineEmits<{
repair: []
stop: []
play: []
playServer: []
settings: []
openFolder: []
export: []
createShortcut: []
}>()
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 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[]>(() => [
{
id: 'open-folder',
label: formatMessage(messages.openFolder),
icon: FolderOpenIcon,
action: () => emit('openFolder'),
},
{
id: 'export-mrpack',
label: formatMessage(messages.exportModpack),
icon: PackageIcon,
action: () => emit('export'),
},
{
id: 'create-shortcut',
label: formatMessage(messages.createShortcut),
icon: ExternalIcon,
action: () => emit('createShortcut'),
},
])
</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,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,15 +1,22 @@
<template>
<NewModal ref="modal" :header="formatMessage(messages.installToPlay)" :closable="true">
<div v-if="requiredContentProject" class="flex flex-col gap-6 max-w-[500px]">
<Admonition type="info" :header="formatMessage(messages.contentRequired)">
{{ formatMessage(messages.serverRequiresMods) }}
</Admonition>
<NewModal
ref="modal"
:header="formatMessage(messages.installToPlay)"
:closable="true"
:on-hide="show_ads_window"
max-width="640px"
width="640px"
>
<div v-if="requiredContentProject" class="flex w-full flex-col gap-6">
<p class="m-0 text-primary">
{{ formatMessage(messages.inviteWarning) }}
</p>
<div class="flex flex-col gap-1">
<div class="flex justify-between items-center">
<span class="font-semibold text-contrast">{{
formatMessage(messages.requiredModpack)
}}</span>
<div class="flex flex-col gap-2.5">
<div class="flex items-center justify-between">
<span class="font-semibold text-contrast">
{{ formatMessage(messages.sharedInstance) }}
</span>
<ButtonStyled type="transparent">
<button @click="openViewContents">
@@ -19,14 +26,16 @@
</ButtonStyled>
</div>
<div class="flex items-center gap-3 rounded-xl bg-surface-2 p-3">
<div class="flex items-center gap-3 rounded-2xl bg-surface-2 p-3">
<Avatar
:src="requiredContentProject.icon_url"
:alt="requiredContentProject.title"
size="48px"
size="56px"
no-shadow
class="!rounded-2xl"
/>
<div class="flex flex-col gap-0.5">
<span class="font-semibold text-contrast">
<div class="flex min-w-0 flex-col gap-0.5">
<span class="truncate font-semibold text-contrast">
<template v-if="usingCustomModpack && modpackVersion">
{{ modpackVersion.name }}
</template>
@@ -34,7 +43,7 @@
{{ requiredContentProject.title }}
</template>
</span>
<span class="text-sm text-secondary">
<span class="truncate text-sm font-medium text-secondary">
{{ loaderDisplay }} {{ requiredContentProject.game_versions?.[0] }}
<template v-if="modCount">
· {{ formatMessage(messages.modCount, { count: modCount }) }}
@@ -43,24 +52,105 @@
</div>
</div>
</div>
</div>
<template #actions>
<div class="flex justify-end gap-2">
<ButtonStyled>
<button @click="handleDecline">
<XIcon />
{{ formatMessage(commonMessages.cancelButton) }}
</button>
</ButtonStyled>
<ButtonStyled color="brand">
<button @click="handleAccept">
<DownloadIcon />
{{ formatMessage(messages.installButton) }}
</button>
</ButtonStyled>
<Admonition
v-if="hasExternalFiles"
type="warning"
:header="formatMessage(messages.unknownFilesWarning)"
>
{{ formatMessage(messages.unknownFilesDescription) }}
</Admonition>
<div v-if="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>
</template>
<p v-if="hasExternalFiles" class="m-0 text-primary">
{{ formatMessage(messages.reviewedFiles) }}
</p>
<div class="flex w-full items-center justify-between gap-2">
<ButtonStyled type="transparent" color="red">
<button @click="handleReport">
<ReportIcon />
{{ formatMessage(commonMessages.reportButton) }}
</button>
</ButtonStyled>
<div class="flex items-center gap-2">
<template v-if="hasExternalFiles">
<ButtonStyled type="transparent" color="orange">
<button @click="handleAccept">
{{ formatMessage(messages.installAnyway) }}
</button>
</ButtonStyled>
<ButtonStyled color="brand">
<button @click="handleDecline">
<BanIcon />
{{ formatMessage(messages.dontInstall) }}
</button>
</ButtonStyled>
</template>
<template v-else>
<ButtonStyled>
<button @click="handleDecline">
<XIcon />
{{ formatMessage(commonMessages.cancelButton) }}
</button>
</ButtonStyled>
<ButtonStyled color="brand">
<button @click="handleAccept">
<DownloadIcon />
{{ formatMessage(messages.installButton) }}
</button>
</ButtonStyled>
</template>
</div>
</div>
</div>
</NewModal>
<ModpackContentModal
@@ -72,33 +162,56 @@
<script setup lang="ts">
import type { Labrinth } from '@modrinth/api-client'
import { DownloadIcon, EyeIcon, XIcon } from '@modrinth/assets'
import type { ContentItem } from '@modrinth/ui'
import { BanIcon, DownloadIcon, EyeIcon, ReportIcon, XIcon } from '@modrinth/assets'
import {
Admonition,
Avatar,
ButtonStyled,
commonMessages,
type ContentItem,
defineMessages,
formatLoader,
ModpackContentModal,
NewModal,
Table,
type TableColumn,
useScrollIndicator,
useVIntl,
} from '@modrinth/ui'
import { computed, ref } from 'vue'
import { openUrl } from '@tauri-apps/plugin-opener'
import { computed, nextTick, ref } from 'vue'
import { hide_ads_window, show_ads_window } from '@/helpers/ads'
import { get_project, get_project_many, get_version, get_version_many } from '@/helpers/cache.js'
import { injectServerInstall } from '@/providers/server-install'
type ExternalFileColumn = 'name'
type ExternalFileRow = {
id: string
name: string
}
const modal = ref<InstanceType<typeof NewModal>>()
const modpackVersionId = ref<string | null>(null)
const modpackVersion = ref<Labrinth.Versions.v2.Version | null>(null)
const project = ref<Labrinth.Projects.v3.Project | null>(null)
const requiredContentProject = ref<Labrinth.Projects.v2.Project | null>(null)
const externalFiles = ref<string[]>([])
const externalFileTable = ref<HTMLElement | null>(null)
const onInstallComplete = ref<() => void>(() => {})
const { formatMessage } = useVIntl()
const props = defineProps<{
showExternalWarnings?: boolean
}>()
const { installServerProject, startInstallingServer, stopInstallingServer } = injectServerInstall()
const {
showTopFade: showTableTopFade,
showBottomFade: showTableBottomFade,
checkScrollState: checkTableScrollState,
forceCheck: forceCheckTableScroll,
} = useScrollIndicator(externalFileTable)
const usingCustomModpack = computed(() => {
return requiredContentProject.value?.id === project.value?.id
@@ -111,13 +224,36 @@ const loaderDisplay = computed(() => {
})
const modCount = computed(() => modpackVersion.value?.dependencies?.length)
const hasExternalFiles = computed(
() => Boolean(props.showExternalWarnings) && externalFiles.value.length > 0,
)
const externalFileRows = computed<ExternalFileRow[]>(() =>
externalFiles.value.map((name, index) => ({
id: `${index}-${name}`,
name,
})),
)
async function fetchData(versionId: string) {
// cache is making version null for some reason so bypassing for now
modpackVersion.value = await get_version(versionId, 'bypass')
const version = await get_version(versionId, 'bypass')
modpackVersion.value = version
if (modpackVersion.value?.project_id) {
requiredContentProject.value = await get_project(modpackVersion.value.project_id, 'bypass')
if (version?.project_id) {
requiredContentProject.value = await get_project(version.project_id, 'bypass')
externalFiles.value = [
...new Set(
(version.dependencies ?? [])
.filter(
(dependency) =>
dependency.dependency_type === 'embedded' &&
!dependency.project_id &&
!dependency.version_id &&
dependency.file_name,
)
.flatMap((dependency) => (dependency.file_name ? [dependency.file_name] : [])),
),
].sort((left, right) => left.localeCompare(right))
}
}
@@ -139,6 +275,12 @@ function handleDecline() {
hide()
}
function handleReport() {
if (project.value?.id) {
openUrl(`https://modrinth.com/report?item=project&itemID=${project.value.id}`)
}
}
const modpackContentModal = ref<InstanceType<typeof ModpackContentModal>>()
async function openViewContents() {
@@ -217,17 +359,19 @@ async function show(
modpackVersionId.value = modpackVersionIdVal
modpackVersion.value = null
requiredContentProject.value = null
externalFiles.value = []
onInstallComplete.value = callback
if (modpackVersionIdVal) await fetchData(modpackVersionIdVal)
hide_ads_window()
modal.value?.show(e)
await nextTick()
forceCheckTableScroll()
}
function hide() {
modal.value?.hide()
show_ads_window()
}
const messages = defineMessages({
@@ -235,22 +379,10 @@ const messages = defineMessages({
id: 'app.modal.install-to-play.header',
defaultMessage: 'Install to play',
},
sharedServerInstance: {
id: 'app.modal.install-to-play.shared-server-instance',
defaultMessage: 'Shared server instance',
},
contentRequired: {
id: 'app.modal.install-to-play.content-required',
defaultMessage: 'Content required',
},
serverRequiresMods: {
id: 'app.modal.install-to-play.server-requires-mods',
inviteWarning: {
id: 'app.modal.install-to-play.invite-warning',
defaultMessage:
'This server requires mods to play. Click Install to set up the required files from Modrinth, then launch directly into the server.',
},
requiredModpack: {
id: 'app.modal.install-to-play.required-modpack',
defaultMessage: 'Required modpack',
'This invite was created by another Modrinth user, not Modrinth. Only accept invites from people you trust.',
},
sharedInstance: {
id: 'app.modal.install-to-play.shared-instance',
@@ -268,7 +400,41 @@ const messages = defineMessages({
id: 'app.modal.install-to-play.view-contents',
defaultMessage: 'View contents',
},
unknownFilesWarning: {
id: 'app.modal.install-to-play.unknown-files-warning',
defaultMessage: 'Unknown files warning',
},
unknownFilesDescription: {
id: 'app.modal.install-to-play.unknown-files-description',
defaultMessage:
'This server modpack 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.dont-install',
defaultMessage: 'Dont install',
},
})
const externalFileColumns = computed<TableColumn<ExternalFileColumn>[]>(() => [
{
key: 'name',
label: formatMessage(messages.unrecognizedFiles),
cellClass: '!h-12',
},
])
defineExpose({ show, hide })
</script>
@@ -2,17 +2,17 @@
<ContentDiffModal
ref="diffModal"
:header="formatMessage(messages.updateToPlay)"
:admonition-header="formatMessage(messages.updateRequired)"
:description="
instance ? formatMessage(messages.updateRequiredDescription, { name: instance.name }) : ''
"
:diffs="normalizedDiffs"
:version-date="versionDate"
:show-external-warnings="showExternalWarnings"
:confirm-label="formatMessage(commonMessages.updateButton)"
:confirm-icon="DownloadIcon"
:show-report-button="true"
:removed-label="formatMessage(messages.removed)"
@confirm="handleUpdate"
@cancel="handleDecline"
@report="handleReport"
/>
</template>
@@ -26,9 +26,8 @@ import {
defineMessages,
useVIntl,
} from '@modrinth/ui'
import { openUrl } from '@tauri-apps/plugin-opener'
import dayjs from 'dayjs'
import { computed, ref, watch } from 'vue'
import { computed, ref } from 'vue'
import { get_project_many, get_version, get_version_many } from '@/helpers/cache.js'
import { wait_for_install_job } from '@/helpers/install'
@@ -77,6 +76,10 @@ const { formatMessage } = useVIntl()
const { startInstallingServer, stopInstallingServer } = injectServerInstall()
type UpdateCompleteCallback = () => void | Promise<void>
defineProps<{
showExternalWarnings?: boolean
}>()
const diffModal = ref<InstanceType<typeof ContentDiffModal>>()
const instance = ref<GameInstance | null>(null)
const onUpdateComplete = ref<UpdateCompleteCallback>(() => {})
@@ -87,6 +90,7 @@ const modpackVersion = ref<Version | null>(null)
const normalizedDiffs = computed<ContentDiffItem[]>(() =>
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,
@@ -94,6 +98,12 @@ const normalizedDiffs = computed<ContentDiffItem[]>(() =>
})),
)
const versionDate = computed(() =>
modpackVersion.value?.date_published
? dayjs(modpackVersion.value.date_published).format('MMMM D, YYYY')
: undefined,
)
async function computeDependencyDiffs(
currentDeps: Dependency[],
latestDeps: Dependency[],
@@ -190,6 +200,10 @@ async function computeDependencyDiffs(
}
})
.sort((a, b) => {
const aExternal = Boolean(a.fileName && !a.project)
const bExternal = Boolean(b.fileName && !b.project)
if (aExternal !== bExternal) return aExternal ? -1 : 1
const typeOrder = { added: 0, updated: 1, removed: 2 }
const typeCompare = typeOrder[a.type] - typeOrder[b.type]
if (typeCompare !== 0) return typeCompare
@@ -227,16 +241,6 @@ async function checkUpdateAvailable(inst: GameInstance): Promise<DependencyDiff[
return null
}
watch(
() => instance.value,
async (newInstance) => {
if (!newInstance) return
const result = await checkUpdateAvailable(newInstance)
diffs.value = result || []
},
{ immediate: true, deep: true },
)
async function handleUpdate() {
hide()
const serverProjectId = instance.value?.link?.project_id
@@ -254,12 +258,6 @@ async function handleUpdate() {
}
}
function handleReport() {
if (instance.value?.link?.project_id) {
openUrl(`https://modrinth.com/report?item=project&itemID=${instance.value.link.project_id}`)
}
}
function handleDecline() {
hide()
}
@@ -272,8 +270,15 @@ function show(
) {
instance.value = instanceVal
modpackVersionId.value = modpackVersionIdVal
modpackVersion.value = null
diffs.value = []
onUpdateComplete.value = callback
diffModal.value?.show(e)
void checkUpdateAvailable(instanceVal).then((result) => {
if (instance.value?.id === instanceVal.id && modpackVersionId.value === modpackVersionIdVal) {
diffs.value = result || []
}
})
}
function hide() {
@@ -285,15 +290,15 @@ const messages = defineMessages({
id: 'app.modal.update-to-play.header',
defaultMessage: 'Update to play',
},
updateRequired: {
id: 'app.modal.update-to-play.update-required',
defaultMessage: 'Update required',
},
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.',
},
removed: {
id: 'app.modal.update-to-play.removed',
defaultMessage: 'Removed',
},
})
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">
@@ -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"
+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 get_ads_consent_required() {
return await invoke('plugin:ads|get_ads_consent_required')
}
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')
}
+1
View File
@@ -27,6 +27,7 @@ export interface InstallModpackPreview {
iconUrl?: string | null
link?: InstanceLink | null
unknownFile: boolean
externalFilesInModpack: string[]
}
export interface InstallCreateInstanceRequest {
@@ -242,6 +242,10 @@ export async function add_project_from_path(
})
}
export async function is_file_on_modrinth(projectPath: string): Promise<boolean> {
return await invoke('plugin:instance|instance_is_file_on_modrinth', { projectPath })
}
// Toggle disabling a project
export async function toggle_disable_project(
instanceId: string,
@@ -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
@@ -425,9 +425,6 @@
"app.instance.worlds.search-worlds-placeholder": {
"message": "ابحث بين ال{count} عوالم..."
},
"app.modal.install-to-play.content-required": {
"message": "المحتوى مطلوب"
},
"app.modal.install-to-play.header": {
"message": "نزل للعب"
},
@@ -437,27 +434,15 @@
"app.modal.install-to-play.mod-count": {
"message": "{count, plural, one {# تعديل} other {# تعديلات}}"
},
"app.modal.install-to-play.required-modpack": {
"message": "حُزْمَة التعديل مطلوبة"
},
"app.modal.install-to-play.server-requires-mods": {
"message": "يتطلب هذا الخادم تعديلات للعب. انقر فوق \"تثبيت\" لإعداد الملفات المطلوبة من Modrinth، ثم قم بتشغيله مباشرة إلى الخادم."
},
"app.modal.install-to-play.shared-instance": {
"message": "النماذج المشتركة"
},
"app.modal.install-to-play.shared-server-instance": {
"message": "نماذج الخادم مشتركة"
},
"app.modal.install-to-play.view-contents": {
"message": "عرض المحتويات"
},
"app.modal.update-to-play.header": {
"message": "حدث للعب"
},
"app.modal.update-to-play.update-required": {
"message": "يلزم التحديث"
},
"app.modal.update-to-play.update-required-description": {
"message": "هناك تحديث مطلوب للعب بـ {name}. الرجاء التحديث إلى أحدث اصدار لتشغيل اللعبة."
},
@@ -470,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": "جميع الإصدارات"
},
@@ -1102,26 +1084,5 @@
},
"search.filter.locked.server-loader.title": {
"message": "يتم توفير المحمّل من قبل الخادم"
},
"unknown-pack-warning-modal.body": {
"message": "لا تتم مراجعة الملف إلا إذا تم تحميله على Modrinth، بغض النظر عن تنسيق الملف (بما في ذلك .mrpack)."
},
"unknown-pack-warning-modal.dont-show-again": {
"message": "لا تعرض هذا التحذير مجددا"
},
"unknown-pack-warning-modal.header": {
"message": "تأكيد التثبيت"
},
"unknown-pack-warning-modal.install-anyway": {
"message": "التثبيت على أي حال"
},
"unknown-pack-warning-modal.malware-statement": {
"message": "غالبًا ما يتم توزيع البرامج الضارة عبر ملفات الحزم المعدلة من خلال مشاركتها على منصات مثل Discord."
},
"unknown-pack-warning-modal.warning.body": {
"message": "لم نتمكن من العثور على هذا الملف على موقع Modrinth. نوصي بشدة بعدم تثبيت الملفات إلا من مصادر موثوقة."
},
"unknown-pack-warning-modal.warning.title": {
"message": "تحذير بشأن ملف مجهول"
}
}
@@ -362,9 +362,6 @@
"app.instance.worlds.search-worlds-placeholder": {
"message": "Hledat v {count} světech..."
},
"app.modal.install-to-play.content-required": {
"message": "Požadovaný obsah"
},
"app.modal.install-to-play.header": {
"message": "Nainstaluj pro hraní"
},
@@ -374,27 +371,15 @@
"app.modal.install-to-play.mod-count": {
"message": "{count, plural, one {# mód} few {# módy} other {# módů}}"
},
"app.modal.install-to-play.required-modpack": {
"message": "Požadovaný modpack"
},
"app.modal.install-to-play.server-requires-mods": {
"message": "Tento server k hraní vyžaduje módy. Klikni na instalovat pro získání potřebných módů z Modrinth a poté se rovnou připoj na server."
},
"app.modal.install-to-play.shared-instance": {
"message": "Sdílená instance"
},
"app.modal.install-to-play.shared-server-instance": {
"message": "Sdílená serverová instance"
},
"app.modal.install-to-play.view-contents": {
"message": "Zobrazit obsah"
},
"app.modal.update-to-play.header": {
"message": "Aktualizuj pro hraní"
},
"app.modal.update-to-play.update-required": {
"message": "Je vyžadována aktualizace"
},
"app.modal.update-to-play.update-required-description": {
"message": "Pro hraní {name} je vyžadována aktualizace. Prosím aktualizuj na nejnovější verzi, abys mohl hru spustit."
},
@@ -407,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"
},
@@ -1036,26 +1018,5 @@
},
"search.filter.locked.server-loader.title": {
"message": "Loader zprostředkovává server"
},
"unknown-pack-warning-modal.body": {
"message": "Soubor je zkontrolován pouze v případě, že je nahrán na Modrinth, bez ohledu na jeho formát (včetně formátu .mrpack)."
},
"unknown-pack-warning-modal.dont-show-again": {
"message": "Toto varování znovu nezobrazovat"
},
"unknown-pack-warning-modal.header": {
"message": "Potvrdit instalaci"
},
"unknown-pack-warning-modal.install-anyway": {
"message": "Přesto nainstalovat"
},
"unknown-pack-warning-modal.malware-statement": {
"message": "Malware se často šíří prostřednictvím souborů s modpacky, které se sdílejí na platformách jako Discord."
},
"unknown-pack-warning-modal.warning.body": {
"message": "Tento soubor jsme na Modrinthu nenašli. Důrazně doporučujeme instalovat pouze soubory z důvěryhodných zdrojů."
},
"unknown-pack-warning-modal.warning.title": {
"message": "Upozornění na neznámý soubor"
}
}
@@ -269,9 +269,6 @@
"app.instance.worlds.search-worlds-placeholder": {
"message": "Søg {count} verdener..."
},
"app.modal.install-to-play.content-required": {
"message": "Indhold krævet"
},
"app.modal.install-to-play.header": {
"message": "Installer for at spille"
},
@@ -281,27 +278,15 @@
"app.modal.install-to-play.mod-count": {
"message": "{count, plural, one {# mod} other {# mods}}"
},
"app.modal.install-to-play.required-modpack": {
"message": "Modpack krævet"
},
"app.modal.install-to-play.server-requires-mods": {
"message": "Denne server kræver mods for at spille. Tryk på installer for at sætte de krævet filler fra modrinth op, så lancer direkte til serveren."
},
"app.modal.install-to-play.shared-instance": {
"message": "Delt instance"
},
"app.modal.install-to-play.shared-server-instance": {
"message": "Delt server instance"
},
"app.modal.install-to-play.view-contents": {
"message": "Vis indhold"
},
"app.modal.update-to-play.header": {
"message": "Opdater for at spille"
},
"app.modal.update-to-play.update-required": {
"message": "Opdatering krævet"
},
"app.modal.update-to-play.update-required-description": {
"message": "En opdatering er krævet for at spille {name}. Venligst opdater til den seneste version for at køre spillet."
},
@@ -311,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"
},
@@ -883,20 +865,5 @@
},
"search.filter.locked.server-loader.title": {
"message": "Loader er givet af serveren"
},
"unknown-pack-warning-modal.body": {
"message": "En fil behandles kun, hvis den uploades til Modrinth, uanset filformat (herunder .mrpack)."
},
"unknown-pack-warning-modal.dont-show-again": {
"message": "Vis ikke denne advarsel igen"
},
"unknown-pack-warning-modal.header": {
"message": "Bekræft installation"
},
"unknown-pack-warning-modal.install-anyway": {
"message": "Installer alligevel"
},
"unknown-pack-warning-modal.warning.title": {
"message": "Ukendt fil advarsel"
}
}
+20 -35
View File
@@ -425,29 +425,38 @@
"app.instance.worlds.search-worlds-placeholder": {
"message": "Durchsuche {count} Welten..."
},
"app.modal.install-to-play.content-required": {
"message": "Inhalte benötigt"
"app.modal.install-to-play.dont-install": {
"message": "Nicht Installieren"
},
"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.required-modpack": {
"message": "Benötigtes Modpack"
},
"app.modal.install-to-play.server-requires-mods": {
"message": "Dieser Server benötigt Mods zum spielen. Klicke auf Installieren um die nötigen Dateien von Modrinth herunterzuladen und dannach direkt dem Server beizutreten."
"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.shared-server-instance": {
"message": "Geteilte Server 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"
@@ -455,8 +464,8 @@
"app.modal.update-to-play.header": {
"message": "Aktualisieren zum Spielen"
},
"app.modal.update-to-play.update-required": {
"message": "Aktualisierung benötigt"
"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."
@@ -470,9 +479,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"
},
@@ -1102,26 +1108,5 @@
},
"search.filter.locked.server-loader.title": {
"message": "Loader wird vom Server bereitgestellt"
},
"unknown-pack-warning-modal.body": {
"message": "Eine Datei wird nur geprüft, wenn sie auf Modrinth hochgeladen wird, unabhängig von ihrem Dateiformat (auch .mrpack)."
},
"unknown-pack-warning-modal.dont-show-again": {
"message": "Diese Warnung nicht mehr anzeigen"
},
"unknown-pack-warning-modal.header": {
"message": "Installation bestätigen"
},
"unknown-pack-warning-modal.install-anyway": {
"message": "Trotzdem installieren"
},
"unknown-pack-warning-modal.malware-statement": {
"message": "Schadsoftware wird häufig über Modpack-Dateien verbreitet, indem diese auf Plattformen wie Discord geteilt werden."
},
"unknown-pack-warning-modal.warning.body": {
"message": "Wir konnten diese Datei auf Modrinth nicht finden. Wir empfehlen dringend, nur Dateien aus vertrauenswürdigen Quellen zu installieren."
},
"unknown-pack-warning-modal.warning.title": {
"message": "Warnung vor unbekannter Datei"
}
}
+20 -35
View File
@@ -425,29 +425,38 @@
"app.instance.worlds.search-worlds-placeholder": {
"message": "Durchsuche {count} Welten..."
},
"app.modal.install-to-play.content-required": {
"message": "Inhalte benötigt"
"app.modal.install-to-play.dont-install": {
"message": "Nicht Installieren"
},
"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.required-modpack": {
"message": "Benötigtes Modpack"
},
"app.modal.install-to-play.server-requires-mods": {
"message": "Dieser Server benötigt Mods zum Spielen. Klicke auf Installieren um die nötigen Dateien von Modrinth herunterzuladen und danach direkt dem Server beizutreten."
"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.shared-server-instance": {
"message": "Geteilte Serverinstanz"
"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"
@@ -455,8 +464,8 @@
"app.modal.update-to-play.header": {
"message": "Aktualisieren zum Spielen"
},
"app.modal.update-to-play.update-required": {
"message": "Aktualisierung erforderlich"
"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."
@@ -470,9 +479,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"
},
@@ -1102,26 +1108,5 @@
},
"search.filter.locked.server-loader.title": {
"message": "Loader vom Server vorgegeben"
},
"unknown-pack-warning-modal.body": {
"message": "Eine Datei wird nur geprüft, wenn sie auf Modrinth hochgeladen wird, unabhängig von ihrem Dateiformat (auch .mrpack)."
},
"unknown-pack-warning-modal.dont-show-again": {
"message": "Diese Warnung nicht mehr anzeigen"
},
"unknown-pack-warning-modal.header": {
"message": "Installation bestätigen"
},
"unknown-pack-warning-modal.install-anyway": {
"message": "Trotzdem installieren"
},
"unknown-pack-warning-modal.malware-statement": {
"message": "Schadsoftware wird häufig über Modpack-Dateien verbreitet, indem diese auf Plattformen wie Discord geteilt werden."
},
"unknown-pack-warning-modal.warning.body": {
"message": "Wir konnten diese Datei auf Modrinth nicht finden. Wir empfehlen dringend, nur Dateien aus vertrauenswürdigen Quellen zu installieren."
},
"unknown-pack-warning-modal.warning.title": {
"message": "Warnung vor unbekannter Datei"
}
}
+98 -35
View File
@@ -125,6 +125,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."
},
@@ -425,29 +440,38 @@
"app.instance.worlds.search-worlds-placeholder": {
"message": "Search {count} worlds..."
},
"app.modal.install-to-play.content-required": {
"message": "Content required"
"app.modal.install-to-play.dont-install": {
"message": "Dont install"
},
"app.modal.install-to-play.header": {
"message": "Install to play"
},
"app.modal.install-to-play.install-anyway": {
"message": "Install anyway"
},
"app.modal.install-to-play.install-button": {
"message": "Install"
},
"app.modal.install-to-play.invite-warning": {
"message": "This invite was created by another Modrinth user, not Modrinth. Only accept invites from people you trust."
},
"app.modal.install-to-play.mod-count": {
"message": "{count, plural, one {# mod} other {# mods}}"
},
"app.modal.install-to-play.required-modpack": {
"message": "Required modpack"
},
"app.modal.install-to-play.server-requires-mods": {
"message": "This server requires mods to play. Click Install to set up the required files from Modrinth, then launch directly into the server."
"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-server-instance": {
"message": "Shared server instance"
"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."
},
"app.modal.install-to-play.unknown-files-warning": {
"message": "Unknown files warning"
},
"app.modal.install-to-play.unrecognized-files": {
"message": "Unrecognized files"
},
"app.modal.install-to-play.view-contents": {
"message": "View contents"
@@ -455,8 +479,8 @@
"app.modal.update-to-play.header": {
"message": "Update to play"
},
"app.modal.update-to-play.update-required": {
"message": "Update required"
"app.modal.update-to-play.removed": {
"message": "Removed"
},
"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."
@@ -470,9 +494,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"
},
@@ -491,6 +512,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"
},
@@ -533,6 +557,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"
},
@@ -659,6 +686,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."
},
@@ -770,6 +803,33 @@
"friends.sign-in-to-add-friends": {
"message": "<link>Sign in to a Modrinth account</link> to add friends and see what they're playing!"
},
"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"
},
@@ -812,6 +872,9 @@
"instance.files.save-as": {
"message": "Save as..."
},
"instance.playtime.never-played": {
"message": "Never played"
},
"instance.server-modal.address": {
"message": "Address"
},
@@ -1079,6 +1142,27 @@
"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"
},
"search.filter.locked.instance": {
"message": "Provided by the instance"
},
@@ -1102,26 +1186,5 @@
},
"search.filter.locked.server-loader.title": {
"message": "Loader is provided by the server"
},
"unknown-pack-warning-modal.body": {
"message": "A file is only reviewed if its uploaded to Modrinth, regardless of its file format (including .mrpack)."
},
"unknown-pack-warning-modal.dont-show-again": {
"message": "Don't show this warning again"
},
"unknown-pack-warning-modal.header": {
"message": "Confirm installation"
},
"unknown-pack-warning-modal.install-anyway": {
"message": "Install anyway"
},
"unknown-pack-warning-modal.malware-statement": {
"message": "Malware is often distributed through modpack files by sharing them on platforms like Discord."
},
"unknown-pack-warning-modal.warning.body": {
"message": "We couldn't find this file on Modrinth. We strongly recommend only installing files from sources you trust."
},
"unknown-pack-warning-modal.warning.title": {
"message": "Unknown file warning"
}
}
+20 -35
View File
@@ -425,29 +425,38 @@
"app.instance.worlds.search-worlds-placeholder": {
"message": "Buscar en {count} mundos..."
},
"app.modal.install-to-play.content-required": {
"message": "Contenido requerido"
"app.modal.install-to-play.dont-install": {
"message": "No instalar"
},
"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.required-modpack": {
"message": "Modpack requerido"
},
"app.modal.install-to-play.server-requires-mods": {
"message": "Este servidor requiere mods para poder jugar. Haz clic en Instalar para configurar los archivos requeridos desde Modrinth, después se ejecutará para entrar directamente al servidor."
"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.shared-server-instance": {
"message": "Instancia de servidor 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"
@@ -455,8 +464,8 @@
"app.modal.update-to-play.header": {
"message": "Actualizar para jugar"
},
"app.modal.update-to-play.update-required": {
"message": "Actualización requerida"
"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."
@@ -470,9 +479,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"
},
@@ -1102,26 +1108,5 @@
},
"search.filter.locked.server-loader.title": {
"message": "El loader es proporcionado por el servidor"
},
"unknown-pack-warning-modal.body": {
"message": "Un archivo solo es revisado si es subido a Modrinth, independientemente de su formato de archivo (incluyendo .mrpack)."
},
"unknown-pack-warning-modal.dont-show-again": {
"message": "No volver a mostrar esta advertencia"
},
"unknown-pack-warning-modal.header": {
"message": "Confirmar instalación"
},
"unknown-pack-warning-modal.install-anyway": {
"message": "Instalar de todos modos"
},
"unknown-pack-warning-modal.malware-statement": {
"message": "El malware a menudo es distribuido mediante modpacks compartidos en aplicaciones como Discord."
},
"unknown-pack-warning-modal.warning.body": {
"message": "No pudimos encontrar este archivo en Modrinth. Recomendamos solo instalar archivos de sitios de confianza."
},
"unknown-pack-warning-modal.warning.title": {
"message": "Advertencia de archivo desconocido"
}
}
@@ -269,9 +269,6 @@
"app.instance.worlds.search-worlds-placeholder": {
"message": "Buscar {count} mundos..."
},
"app.modal.install-to-play.content-required": {
"message": "Contenido obligatorio"
},
"app.modal.install-to-play.header": {
"message": "Instala para jugar"
},
@@ -281,27 +278,15 @@
"app.modal.install-to-play.mod-count": {
"message": "{count, plural, one {# mod} other {# mods}}"
},
"app.modal.install-to-play.required-modpack": {
"message": "Modpack requerido"
},
"app.modal.install-to-play.server-requires-mods": {
"message": "Este servidor requiere ciertos mods. Pulsa Instalar para instalar los archivos requeridos de Modrinth y luego el launcher te enviara directo al servidor."
},
"app.modal.install-to-play.shared-instance": {
"message": "Instancia compartida"
},
"app.modal.install-to-play.shared-server-instance": {
"message": "Instancia de servidor compartida"
},
"app.modal.install-to-play.view-contents": {
"message": "Ver contenido"
},
"app.modal.update-to-play.header": {
"message": "Actualiza para jugar"
},
"app.modal.update-to-play.update-required": {
"message": "Actualización requerida"
},
"app.modal.update-to-play.update-required-description": {
"message": "Una actualización es requerida para jugar {name}. Por favor actualízala a la versión más reciente para ejecutar el juego."
},
@@ -311,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."
},
@@ -925,26 +907,5 @@
},
"search.filter.locked.server-loader.title": {
"message": "Loader proporcionado por el servidor"
},
"unknown-pack-warning-modal.body": {
"message": "Un archivo solo es revisado si es subido a Modrinth, independientemente de su formato de archivo (incluyendo .mrpack)."
},
"unknown-pack-warning-modal.dont-show-again": {
"message": "No mostrar esta advertencia otra vez"
},
"unknown-pack-warning-modal.header": {
"message": "Confirmar instalación"
},
"unknown-pack-warning-modal.install-anyway": {
"message": "Instalar de todos modos"
},
"unknown-pack-warning-modal.malware-statement": {
"message": "El malware es normalmente distribuido por archivos de modpack que son normalmente compartidas en aplicaciones como Discord."
},
"unknown-pack-warning-modal.warning.body": {
"message": "No pudimos encontrar este archivo en Modrinth. Recomendamos solo instalar archivos de sitios de confianza."
},
"unknown-pack-warning-modal.warning.title": {
"message": "Advertencia de archivo desconocido"
}
}
@@ -350,9 +350,6 @@
"app.instance.worlds.search-worlds-placeholder": {
"message": "Hae {count} maailmasta..."
},
"app.modal.install-to-play.content-required": {
"message": "Sisältö vaaditaan"
},
"app.modal.install-to-play.header": {
"message": "Asenna pelataksesi"
},
@@ -362,27 +359,15 @@
"app.modal.install-to-play.mod-count": {
"message": "{count, plural, one {# mod} other {# modia}}"
},
"app.modal.install-to-play.required-modpack": {
"message": "Vaadittu modipaketti"
},
"app.modal.install-to-play.server-requires-mods": {
"message": "Tämä palvelin vaatii modeja toimiakseen. Klikkaa Asenna ladataksesi vaaditut tiedostot Modrinthista, ja käynnistä peli suoraan palvelimelle."
},
"app.modal.install-to-play.shared-instance": {
"message": "Jaettu instanssi"
},
"app.modal.install-to-play.shared-server-instance": {
"message": "Jaettu palvelininstanssi"
},
"app.modal.install-to-play.view-contents": {
"message": "Tarkastele sisältöä"
},
"app.modal.update-to-play.header": {
"message": "Päivitä pelataksesi"
},
"app.modal.update-to-play.update-required": {
"message": "Päivitys vaaditaan"
},
"app.modal.update-to-play.update-required-description": {
"message": "Pävitys vaaditaan pelataksesi {name}. Päivitä viimeisimpään versioon pelataksesi."
},
@@ -392,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ä."
},
@@ -1009,26 +991,5 @@
},
"search.filter.locked.server-loader.title": {
"message": "Modialusta on palvelimen tarjoama"
},
"unknown-pack-warning-modal.body": {
"message": "Tiedosto tarkistetaan vain jos se on ladattu Modrinthiin, riippumatta tiedoston muodosta (mukaanlukien .mrpack)."
},
"unknown-pack-warning-modal.dont-show-again": {
"message": "Älä näytä tätä varoitusta uudestaan"
},
"unknown-pack-warning-modal.header": {
"message": "Vahvista asennus"
},
"unknown-pack-warning-modal.install-anyway": {
"message": "Asenna jokatapauksessa"
},
"unknown-pack-warning-modal.malware-statement": {
"message": "Haittaohjelmia levitetään usein modipaketti tiedostojen kautta jakamalla niitä alustoilla kuten Discordissa."
},
"unknown-pack-warning-modal.warning.body": {
"message": "Emme löytäneet tätä tiedostoa Modrinthista. Suosittelemme vahvasti että asennat tiedostoja vain lähteistä joihin luotat."
},
"unknown-pack-warning-modal.warning.title": {
"message": "Tuntematon tiedosto varoitus"
}
}
@@ -242,9 +242,6 @@
"app.instance.worlds.search-worlds-placeholder": {
"message": "Hanapin sa {count} mundo..."
},
"app.modal.install-to-play.content-required": {
"message": "Nangangailangan ng kontento"
},
"app.modal.install-to-play.header": {
"message": "Mag-install upang malaro"
},
@@ -254,27 +251,15 @@
"app.modal.install-to-play.mod-count": {
"message": "{count, plural, one {# mod} other {# na mod}}"
},
"app.modal.install-to-play.required-modpack": {
"message": "Kinailangan na modpack"
},
"app.modal.install-to-play.server-requires-mods": {
"message": "Ang server rna ito ay nangangailangan ng mga mod upang makalaro. Pindutin ang install upang maihanda ang mga kinakailangang file galing sa Modrinth, matapos ay ilunsad nang diretso sa server."
},
"app.modal.install-to-play.shared-instance": {
"message": "Binahaging instansiya"
},
"app.modal.install-to-play.shared-server-instance": {
"message": "Binahaging instansiyang pang-server"
},
"app.modal.install-to-play.view-contents": {
"message": "Tingnan ang mga kontento"
},
"app.modal.update-to-play.header": {
"message": "Mag-update upang malaro"
},
"app.modal.update-to-play.update-required": {
"message": "Kailangang mag-update"
},
"app.modal.update-to-play.update-required-description": {
"message": "Kailangang mag-update upang malaro ang {name}. Mangyaring mag-update sa pinakabagong bersiyon upang ma-launch ang laro."
},
@@ -736,26 +721,5 @@
},
"search.filter.locked.server-loader.title": {
"message": "Ang loader ay handog na ng server"
},
"unknown-pack-warning-modal.body": {
"message": "Ang file ay nasusuri lamang kapag ito ay na-upload sa Modrinth, walang pili sa file format nito (kabilang na ang .mrpack)."
},
"unknown-pack-warning-modal.dont-show-again": {
"message": "Huwag ipakita ang babalang ito ulit"
},
"unknown-pack-warning-modal.header": {
"message": "Kumpirmahin ang installation"
},
"unknown-pack-warning-modal.install-anyway": {
"message": "I-install pa rin"
},
"unknown-pack-warning-modal.malware-statement": {
"message": "Ang malware ay madalas naidadala sa mga modpack files sa pamamagitan ng pag-bigay ng mga ito sa mga platforms kagaya ng Discord."
},
"unknown-pack-warning-modal.warning.body": {
"message": "Hindi namin mahanap ang file na ito sa Modrinth. Mahalagang mag-install ka lamang ng files galing sa mga mapagkakatiwalang sources."
},
"unknown-pack-warning-modal.warning.title": {
"message": "Hindi kilalang file"
}
}
+50 -41
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"
},
@@ -401,29 +425,38 @@
"app.instance.worlds.search-worlds-placeholder": {
"message": "Rechercher {count} mondes..."
},
"app.modal.install-to-play.content-required": {
"message": "Contenu requis"
"app.modal.install-to-play.dont-install": {
"message": "Ne pas installer"
},
"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.required-modpack": {
"message": "Modpack requis"
},
"app.modal.install-to-play.server-requires-mods": {
"message": "Ce serveur a besoin de mods pour jouer. Cliquez sur Installer pour mettre en place les fichiers requis depuis Modrinth, puis lancez directement dans le serveur."
"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.shared-server-instance": {
"message": "Instance serveur 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"
@@ -431,8 +464,8 @@
"app.modal.update-to-play.header": {
"message": "Mettre à jour pour jouer"
},
"app.modal.update-to-play.update-required": {
"message": "Mise à jour requise"
"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."
@@ -446,9 +479,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"
},
@@ -1075,26 +1105,5 @@
},
"search.filter.locked.server-loader.title": {
"message": "Le loader est procuré par le serveur"
},
"unknown-pack-warning-modal.body": {
"message": "Un fichier nest révisé que sil est téléchargé sur Modrinth, quel que soit son format de fichier (y compris .mrpack)."
},
"unknown-pack-warning-modal.dont-show-again": {
"message": "Ne plus m'avertir à ce sujet"
},
"unknown-pack-warning-modal.header": {
"message": "Confirmer l'installation"
},
"unknown-pack-warning-modal.install-anyway": {
"message": "Installer tout de même"
},
"unknown-pack-warning-modal.malware-statement": {
"message": "Les logiciels malveillants sont souvent distribués via des fichiers modpack en les partageant sur des plateformes comme Discord."
},
"unknown-pack-warning-modal.warning.body": {
"message": "Nous ne pouvions pas trouver ce fichier sur Modrinth. Nous vous recommandons fortement de n'installer que des fichiers de sources de confiance."
},
"unknown-pack-warning-modal.warning.title": {
"message": "Avertissement fichier inconnu"
}
}
@@ -137,9 +137,6 @@
"app.instance.worlds.search-worlds-placeholder": {
"message": "חיפוש ב-{count} עולמות..."
},
"app.modal.install-to-play.content-required": {
"message": "תוכן נדרש"
},
"app.modal.install-to-play.header": {
"message": "צריך להתקין כדי לשחק"
},
@@ -149,27 +146,15 @@
"app.modal.install-to-play.mod-count": {
"message": "{count, plural, one {מוד אחד} other {# מודים}}"
},
"app.modal.install-to-play.required-modpack": {
"message": "חבילת מודים נדרשת"
},
"app.modal.install-to-play.server-requires-mods": {
"message": "שרת זה דורש מודים כדי לשחק. לחץ על \"התקן\" כדי להגדיר את הקבצים הנדרשים מ-Modrinth, ולאחר מכן הפעל ישירות לשרת."
},
"app.modal.install-to-play.shared-instance": {
"message": "התקנה משותפת"
},
"app.modal.install-to-play.shared-server-instance": {
"message": "התקנת שרת משותפת"
},
"app.modal.install-to-play.view-contents": {
"message": "הצג תוכן"
},
"app.modal.update-to-play.header": {
"message": "צריך לעדכן כדי לשחק"
},
"app.modal.update-to-play.update-required": {
"message": "עדכון נדרש"
},
"app.modal.update-to-play.update-required-description": {
"message": "עדכון נדרש כדי לשחק ב{name}. ניתן להתחיל את המשחק רק לאחר עדכון לגרסה החדשה."
},
+28 -43
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"
@@ -425,29 +425,38 @@
"app.instance.worlds.search-worlds-placeholder": {
"message": "Keresés {count} világ között..."
},
"app.modal.install-to-play.content-required": {
"message": "Szükséges tartalom"
"app.modal.install-to-play.dont-install": {
"message": "Ne töltse le"
},
"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.required-modpack": {
"message": "Szükséges modcsomag"
},
"app.modal.install-to-play.server-requires-mods": {
"message": "Ehhez a szerverhez modok szükségesek a játékhoz. Kattints a Telepítés gombra, hogy telepítsd a szükséges fájlokat a Modrinth-ról, majd indítsd el közvetlenül a szervert."
"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.shared-server-instance": {
"message": "Megosztott szerverpéldány"
"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"
@@ -455,8 +464,8 @@
"app.modal.update-to-play.header": {
"message": "Frissítsd a játékhoz"
},
"app.modal.update-to-play.update-required": {
"message": "Frissítés szükséges"
"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."
@@ -470,9 +479,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ó"
},
@@ -1102,26 +1108,5 @@
},
"search.filter.locked.server-loader.title": {
"message": "A betöltő a szerver által van megadva"
},
"unknown-pack-warning-modal.body": {
"message": "Egy fájlt csak akkor vizsgálunk meg, ha azt feltöltik a Modrinthra, függetlenül a fájlformátumtól (beleértve a .mrpack formátumot is)."
},
"unknown-pack-warning-modal.dont-show-again": {
"message": "Ne mutasd újra ezt a figyelmeztetést"
},
"unknown-pack-warning-modal.header": {
"message": "Telepítés megerősítése"
},
"unknown-pack-warning-modal.install-anyway": {
"message": "Letöltés mindenképpen"
},
"unknown-pack-warning-modal.malware-statement": {
"message": "A rosszindulatú programokat gyakran modcsomag-fájlokon keresztül terjesztik, például a Discordhoz hasonló platformokon történő megosztás révén."
},
"unknown-pack-warning-modal.warning.body": {
"message": "Ezt a fájlt nem találtuk meg a Modrinthon. Határozottan javasoljuk, hogy kizárólag megbízható forrásokból származó fájlokat telepíts."
},
"unknown-pack-warning-modal.warning.title": {
"message": "Figyelmeztetés ismeretlen fájlról"
}
}
@@ -245,9 +245,6 @@
"app.instance.worlds.search-worlds-placeholder": {
"message": "Cari {count} dunia..."
},
"app.modal.install-to-play.content-required": {
"message": "Konten diperlukan"
},
"app.modal.install-to-play.header": {
"message": "Pasang untuk memainkan"
},
@@ -257,27 +254,15 @@
"app.modal.install-to-play.mod-count": {
"message": "{count, plural, other {# mod}}"
},
"app.modal.install-to-play.required-modpack": {
"message": "Paket mod yang diperlukan"
},
"app.modal.install-to-play.server-requires-mods": {
"message": "Mod diperlukan untuk bermain di server ini. Klik pasang untuk menyiapkan berkas-berkas yang diperlukan dari Modrinth, kemudian luncurkan langsung sari server."
},
"app.modal.install-to-play.shared-instance": {
"message": "Instans terbagi"
},
"app.modal.install-to-play.shared-server-instance": {
"message": "Instans server terbagi"
},
"app.modal.install-to-play.view-contents": {
"message": "Lihat konten"
},
"app.modal.update-to-play.header": {
"message": "Perbarui untuk memainkan"
},
"app.modal.update-to-play.update-required": {
"message": "Perlu diperbarui"
},
"app.modal.update-to-play.update-required-description": {
"message": "{name} perlu diperbarui sebelum dimainkan. Mohon perbarui ke versi terkini untuk meluncurkan permainan."
},
@@ -287,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."
},
@@ -901,26 +883,5 @@
},
"search.filter.locked.server-loader.title": {
"message": "Pemuat disediakan oleh server"
},
"unknown-pack-warning-modal.body": {
"message": "Berkas hanya akan ditinjau bila ia diunggah ke Modrinth, tak peduli format berkasnya (termasuk .mrpack)."
},
"unknown-pack-warning-modal.dont-show-again": {
"message": "Jangan tampilkan peringatan ini lagi"
},
"unknown-pack-warning-modal.header": {
"message": "Konfirmasi pemasangan"
},
"unknown-pack-warning-modal.install-anyway": {
"message": "Tetap pasang"
},
"unknown-pack-warning-modal.malware-statement": {
"message": "Program jahat (malware) dibagikan melalui berkas paket mod dengan membagikannya melalui platform seperti Discord."
},
"unknown-pack-warning-modal.warning.body": {
"message": "Kami tidak dapat menemukan berkas ini di Modrinth. Kami sangat menyarankan Anda untuk hanya memasang berkas dari sumber-sumber terpercaya."
},
"unknown-pack-warning-modal.warning.title": {
"message": "Peringatan berkas tidak dikenal"
}
}
+20 -35
View File
@@ -422,29 +422,38 @@
"app.instance.worlds.search-worlds-placeholder": {
"message": "Cerca tra {count} mondi..."
},
"app.modal.install-to-play.content-required": {
"message": "Contenuto richiesto"
"app.modal.install-to-play.dont-install": {
"message": "Non installare"
},
"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.required-modpack": {
"message": "Pacchetto di mod richiesto"
},
"app.modal.install-to-play.server-requires-mods": {
"message": "Questo server richiede alcune mod. Clicca Installa per scaricarle direttamente da Modrinth, poi sarai pronto a giocare."
"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.shared-server-instance": {
"message": "Istanza del server 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"
@@ -452,8 +461,8 @@
"app.modal.update-to-play.header": {
"message": "Aggiorna per continuare"
},
"app.modal.update-to-play.update-required": {
"message": "Aggiornamento richiesto"
"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."
@@ -467,9 +476,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"
},
@@ -1099,26 +1105,5 @@
},
"search.filter.locked.server-loader.title": {
"message": "Il loader è determinato dal server"
},
"unknown-pack-warning-modal.body": {
"message": "Solo i file caricati su Modrinth vengono esaminati, qualunque sia il loro formato (.mrpack inclusi)."
},
"unknown-pack-warning-modal.dont-show-again": {
"message": "Non mostrare più questo avviso"
},
"unknown-pack-warning-modal.header": {
"message": "Conferma l'installazione"
},
"unknown-pack-warning-modal.install-anyway": {
"message": "Installa comunque"
},
"unknown-pack-warning-modal.malware-statement": {
"message": "Spesso i malware vengono nascosti nei pacchetti di mod, poi distribuiti su piattaforme come Discord."
},
"unknown-pack-warning-modal.warning.body": {
"message": "Non è stato possibile trovare questo file su Modrinth. Consigliamo di installare file solo da fonti attendibili."
},
"unknown-pack-warning-modal.warning.title": {
"message": "Tipo di file sconosciuto"
}
}
+12 -36
View File
@@ -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パック"
},
@@ -278,9 +290,6 @@
"app.instance.worlds.search-worlds-placeholder": {
"message": "{count} 個のワールドを検索…"
},
"app.modal.install-to-play.content-required": {
"message": "必須コンテンツ"
},
"app.modal.install-to-play.header": {
"message": "インストールしてプレイ"
},
@@ -290,27 +299,15 @@
"app.modal.install-to-play.mod-count": {
"message": "{count, plural, other {#個のMod}}"
},
"app.modal.install-to-play.required-modpack": {
"message": "必須のModパック"
},
"app.modal.install-to-play.server-requires-mods": {
"message": "このサーバーをプレイするにはModが必要です。インストールをクリックしてModrinthから必要なファイルを設定し、サーバーに接続してください。"
},
"app.modal.install-to-play.shared-instance": {
"message": "共有インスタンス"
},
"app.modal.install-to-play.shared-server-instance": {
"message": "共有サーバーインスタンス"
},
"app.modal.install-to-play.view-contents": {
"message": "コンテンツを見る"
},
"app.modal.update-to-play.header": {
"message": "更新してプレイ"
},
"app.modal.update-to-play.update-required": {
"message": "更新が必要です"
},
"app.modal.update-to-play.update-required-description": {
"message": "{name}をプレイするには更新が必要です。ゲームを起動するには最新版に更新してください。"
},
@@ -811,26 +808,5 @@
},
"search.filter.locked.server-loader.title": {
"message": "ローダーはサーバーによる条件です"
},
"unknown-pack-warning-modal.body": {
"message": "ファイル形式に関わらず、Modrinthにアップロードされたファイルのみが確認されます。(.mrpackを含む)"
},
"unknown-pack-warning-modal.dont-show-again": {
"message": "この警告を次回から表示しない"
},
"unknown-pack-warning-modal.header": {
"message": "インストールの確認"
},
"unknown-pack-warning-modal.install-anyway": {
"message": "インストールを続行"
},
"unknown-pack-warning-modal.malware-statement": {
"message": "一般的にマルウェアは、Discord等のプラットフォーム上でModパックファイルを配布して拡散されます"
},
"unknown-pack-warning-modal.warning.body": {
"message": "このファイルをModrinth上で見つけることができませんでした。信頼できるソースからインストールすることを強くお勧めします。"
},
"unknown-pack-warning-modal.warning.title": {
"message": "不明なファイルの警告"
}
}
+93 -36
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 준비 중"
},
@@ -356,29 +425,38 @@
"app.instance.worlds.search-worlds-placeholder": {
"message": "{count}개의 세계 검색..."
},
"app.modal.install-to-play.content-required": {
"message": "콘텐츠 설치 필요"
"app.modal.install-to-play.dont-install": {
"message": "설치하지 않음"
},
"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.required-modpack": {
"message": "필요한 모드팩"
},
"app.modal.install-to-play.server-requires-mods": {
"message": "이 서버를 플레이하려면 모드가 필요합니다. '설치'를 클릭하여 Modrinth에서 필수 파일을 내려받은 후, 서버에 바로 접속하세요."
"app.modal.install-to-play.reviewed-files": {
"message": "모든 파일은 형식(.mrpack 포함)에 무관하게 Modrinth에 게시되어야만 검수를 거칩니다."
},
"app.modal.install-to-play.shared-instance": {
"message": "인스턴스 공유됨"
},
"app.modal.install-to-play.shared-server-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": "구성 요소 보기"
@@ -386,8 +464,8 @@
"app.modal.update-to-play.header": {
"message": "업데이트하고 플레이"
},
"app.modal.update-to-play.update-required": {
"message": "업데이트 필요"
"app.modal.update-to-play.removed": {
"message": "제거됨"
},
"app.modal.update-to-play.update-required-description": {
"message": "{name}을(를) 플레이하려면 업데이트가 필요합니다. 게임을 실행하려면 최신 버전으로 업데이트해 주세요."
@@ -395,12 +473,12 @@
"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": "개발자 모드가 활성화되었습니다."
},
@@ -594,7 +672,7 @@
"message": "다운로드 완료"
},
"app.update-popup.reload": {
"message": "업데이트하려면 다시 로드"
"message": "새로고침하여 업데이트"
},
"app.update-popup.title": {
"message": "업데이트 가능"
@@ -1018,26 +1096,5 @@
},
"search.filter.locked.server-loader.title": {
"message": "로더가 서버에 의해 제공됩니다"
},
"unknown-pack-warning-modal.body": {
"message": "모든 파일은 형식(.mrpack 포함)에 무관하게 Modrinth에 업로드되어야만 검수를 거칩니다."
},
"unknown-pack-warning-modal.dont-show-again": {
"message": "이 경고를 다시 표시하지 않음"
},
"unknown-pack-warning-modal.header": {
"message": "설치 확인"
},
"unknown-pack-warning-modal.install-anyway": {
"message": "무시하고 설치"
},
"unknown-pack-warning-modal.malware-statement": {
"message": "악성코드는 흔히 디스코드와 같은 플랫폼을 통해 모드팩 파일을 공유하는 방식으로 유포됩니다."
},
"unknown-pack-warning-modal.warning.body": {
"message": "이 파일을 Modrinth에서 찾을 수 없습니다. 신뢰할 수 있는 출처의 파일만 설치하는 것을 권장합니다."
},
"unknown-pack-warning-modal.warning.title": {
"message": "출처를 알 수 없는 파일 경고"
}
}
@@ -260,9 +260,6 @@
"app.instance.worlds.search-worlds-placeholder": {
"message": "Cari {count} dunia..."
},
"app.modal.install-to-play.content-required": {
"message": "Kandungan yang diperlukan"
},
"app.modal.install-to-play.header": {
"message": "Pasang untuk mainkan"
},
@@ -272,27 +269,15 @@
"app.modal.install-to-play.mod-count": {
"message": "{count, plural, other {# mod}}"
},
"app.modal.install-to-play.required-modpack": {
"message": "Pek mod yang diperlukan"
},
"app.modal.install-to-play.server-requires-mods": {
"message": "Pelayan ini memerlukan mod untuk dimainkan. Klik Pasang untuk menyediakan fail yang diperlukan daripada Modrinth, kemudian lancarkan permainan terus ke dalam pelayan."
},
"app.modal.install-to-play.shared-instance": {
"message": "Pemasangan yang dikongsi"
},
"app.modal.install-to-play.shared-server-instance": {
"message": "Pemasangan pelayan yang dikongsi"
},
"app.modal.install-to-play.view-contents": {
"message": "Lihat kandungan"
},
"app.modal.update-to-play.header": {
"message": "Kemas kini untuk mainkan"
},
"app.modal.update-to-play.update-required": {
"message": "Kemas kini diperlukan"
},
"app.modal.update-to-play.update-required-description": {
"message": "Kemas kini diperlukan untuk memainkan {name}. Sila kemas kini kepada versi terkini untuk melancarkan permainan."
},
@@ -302,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."
},
@@ -856,26 +838,5 @@
},
"search.filter.locked.server-loader.title": {
"message": "Pemuat adalah disediakan oleh pelayan"
},
"unknown-pack-warning-modal.body": {
"message": "Sesuatu fail hanya disemak jika ia dimuat naik ke Modrinth, tanpa mengira format failnya (termasuk .mrpack)."
},
"unknown-pack-warning-modal.dont-show-again": {
"message": "Jangan tunjukkan amaran ini lagi"
},
"unknown-pack-warning-modal.header": {
"message": "Sahkan pemasangan"
},
"unknown-pack-warning-modal.install-anyway": {
"message": "Pasangkan juga"
},
"unknown-pack-warning-modal.malware-statement": {
"message": "Perisian hasad sering diedarkan melalui fail pek mod dengan berkongsinya di platform seperti Discord."
},
"unknown-pack-warning-modal.warning.body": {
"message": "Kami tidak dapat menemui fail ini di Modrinth. Kami sangat mengesyorkan anda untuk hanya memasang fail daripada sumber yang anda percayai."
},
"unknown-pack-warning-modal.warning.title": {
"message": "Amaran fail tidak diketahui"
}
}
+215 -41
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,41 +410,62 @@
"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.content-required": {
"message": "Content vereist"
"app.modal.install-to-play.dont-install": {
"message": "Niet installeren"
},
"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.required-modpack": {
"message": "Vereist modpack"
},
"app.modal.install-to-play.server-requires-mods": {
"message": "Deze server vereist mods om te spelen. Klik op Installeer om de vereiste bestanden van Modrinth in te stellen, en start direct in de server."
"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.shared-server-instance": {
"message": "Gedeelde server 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.update-required": {
"message": "Update vereist"
"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."
@@ -284,12 +473,12 @@
"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."
},
@@ -405,11 +594,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"
},
@@ -636,7 +828,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..."
@@ -827,6 +1019,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"
},
@@ -901,26 +1096,5 @@
},
"search.filter.locked.server-loader.title": {
"message": "Loader is gegeven door de server"
},
"unknown-pack-warning-modal.body": {
"message": "Een bestand wordt alleen beoordeeld als het naar Modrinth is geüpload, ongeacht het bestandsformaat (inclusief .mrpack)."
},
"unknown-pack-warning-modal.dont-show-again": {
"message": "Toon deze waarschuwing niet opnieuw"
},
"unknown-pack-warning-modal.header": {
"message": "Bevestig installatie"
},
"unknown-pack-warning-modal.install-anyway": {
"message": "Installeer toch"
},
"unknown-pack-warning-modal.malware-statement": {
"message": "Malware wordt vaak verspreid via modpack-bestanden door ze te delen op platforms zoals Discord."
},
"unknown-pack-warning-modal.warning.body": {
"message": "We konden dit bestand niet vinden op Modrinth. We raden ten zeerste aan om alleen bestanden te installeren van bronnen die u vertrouwt."
},
"unknown-pack-warning-modal.warning.title": {
"message": "Waarschuwing voor onbekend bestand"
}
}
@@ -227,15 +227,9 @@
"app.modal.install-to-play.shared-instance": {
"message": "Delt tilfelle"
},
"app.modal.install-to-play.shared-server-instance": {
"message": "Delt servertilfelle"
},
"app.modal.update-to-play.header": {
"message": "Oppdater for å spille"
},
"app.modal.update-to-play.update-required": {
"message": "Krever oppdatering"
},
"app.modal.update-to-play.update-required-description": {
"message": "Ei oppdatering er påkrevd for å spille {name}. Vær så snill å oppdater til den siste versjonen av spillet for å spille det."
},
+20 -35
View File
@@ -422,29 +422,38 @@
"app.instance.worlds.search-worlds-placeholder": {
"message": "Szukaj wśród {count} światów..."
},
"app.modal.install-to-play.content-required": {
"message": "Wymagana zawartość"
"app.modal.install-to-play.dont-install": {
"message": "Nie instaluj"
},
"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.required-modpack": {
"message": "Wymagana paczka modów"
},
"app.modal.install-to-play.server-requires-mods": {
"message": "Ten serwer wymaga modów, aby na nim grać. Kliknij \"Zainstaluj\" aby otrzymać potrzebne pliki z Modrinth, a potem dołącz bezpośrednio do serwera."
"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.shared-server-instance": {
"message": "Wspólna instancja serwera"
"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ść"
@@ -452,8 +461,8 @@
"app.modal.update-to-play.header": {
"message": "Zaktualizuj, by grać"
},
"app.modal.update-to-play.update-required": {
"message": "Wymagana jest aktualizacja"
"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ę."
@@ -467,9 +476,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"
},
@@ -1099,26 +1105,5 @@
},
"search.filter.locked.server-loader.title": {
"message": "Loader jest dostarczony przez serwer"
},
"unknown-pack-warning-modal.body": {
"message": "Plik jest sprawdzony tylko, jeżeli został przesłany na Modrinth, niezależnie od jego formatu (w tym pliki .mrpack)."
},
"unknown-pack-warning-modal.dont-show-again": {
"message": "Nie pokazuj ponownie tego ostrzeżenia"
},
"unknown-pack-warning-modal.header": {
"message": "Potwierdź instalację"
},
"unknown-pack-warning-modal.install-anyway": {
"message": "Instaluj mimo to"
},
"unknown-pack-warning-modal.malware-statement": {
"message": "Wirusy są często rozpowszechniane poprzez pliki paczek modów wysyłane na platformach takich jak Discord."
},
"unknown-pack-warning-modal.warning.body": {
"message": "Nie mogliśmy znaleźć tego pliku na Modrinth. Stanowczo zalecamy instalowanie plików tylko ze źródeł, którym ufasz."
},
"unknown-pack-warning-modal.warning.title": {
"message": "Ostrzeżenie o nieznanym pliku"
}
}
+20 -35
View File
@@ -425,29 +425,38 @@
"app.instance.worlds.search-worlds-placeholder": {
"message": "Buscar {count} mundos..."
},
"app.modal.install-to-play.content-required": {
"message": "Conteúdo necessário"
"app.modal.install-to-play.dont-install": {
"message": "Não instalar"
},
"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.required-modpack": {
"message": "Pacote de mods necessário"
},
"app.modal.install-to-play.server-requires-mods": {
"message": "Este servidor exige mods para jogar. Clique em instalar para configurar os arquivos necessários através do Modrinth, e então iniciar diretamente no servidor."
"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.shared-server-instance": {
"message": "Instância de servidor 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"
@@ -455,8 +464,8 @@
"app.modal.update-to-play.header": {
"message": "Atualize para jogar"
},
"app.modal.update-to-play.update-required": {
"message": "Atualização necessária"
"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."
@@ -470,9 +479,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"
},
@@ -1102,26 +1108,5 @@
},
"search.filter.locked.server-loader.title": {
"message": "O loader é fornecido pelo servidor"
},
"unknown-pack-warning-modal.body": {
"message": "Um arquivo só é revisado se for enviado no Modrinth, independente do formato do arquivo (incluindo .mrpack)."
},
"unknown-pack-warning-modal.dont-show-again": {
"message": "Não exibir aviso novamente"
},
"unknown-pack-warning-modal.header": {
"message": "Confirmar instalação"
},
"unknown-pack-warning-modal.install-anyway": {
"message": "Instalar mesmo assim"
},
"unknown-pack-warning-modal.malware-statement": {
"message": "O malware é distribuído frequentemente através de arquivos de pacote de mods compartilhados em plataformas como Discord."
},
"unknown-pack-warning-modal.warning.body": {
"message": "Não encontramos este arquivo no Modrinth. Nós recomendamos fortemente instalar arquivos apenas de fontes confiáveis."
},
"unknown-pack-warning-modal.warning.title": {
"message": "Aviso de arquivo desconhecido"
}
}
@@ -188,9 +188,6 @@
"app.instance.worlds.search-worlds-placeholder": {
"message": "Pesquisar {count} mundos..."
},
"app.modal.install-to-play.content-required": {
"message": "Conteúdo necessário"
},
"app.modal.install-to-play.header": {
"message": "Instala para jogar"
},
@@ -200,27 +197,15 @@
"app.modal.install-to-play.mod-count": {
"message": "{count, plural, one {mod} other {mods}}"
},
"app.modal.install-to-play.required-modpack": {
"message": "Modpack requerido"
},
"app.modal.install-to-play.server-requires-mods": {
"message": "Este servidor requer mods para jogares. Clica Instalar para transferir os ficheiros necessários do Modrinth, e então entrar diretamente no servidor."
},
"app.modal.install-to-play.shared-instance": {
"message": "Instância partilhada"
},
"app.modal.install-to-play.shared-server-instance": {
"message": "Instância de servidor partilhada"
},
"app.modal.install-to-play.view-contents": {
"message": "Ver conteúdo"
},
"app.modal.update-to-play.header": {
"message": "Atualiza para jogar"
},
"app.modal.update-to-play.update-required": {
"message": "Atualização necessária"
},
"app.modal.update-to-play.update-required-description": {
"message": "Uma atualização é necessária para jogar {name}. Por favor atualiza para a versão mais recente para iniciar o jogo."
},
@@ -5,9 +5,6 @@
"app.auth-servers.unreachable.header": {
"message": "Nu se pot accesa serverele de autentificare"
},
"app.modal.install-to-play.content-required": {
"message": "Conținut necesar"
},
"app.modal.install-to-play.header": {
"message": "Instalați pentru a juca"
},
@@ -17,27 +14,15 @@
"app.modal.install-to-play.mod-count": {
"message": "{count, plural,one {#mod} other {# moduri}}"
},
"app.modal.install-to-play.required-modpack": {
"message": "Pachet de mod necesar"
},
"app.modal.install-to-play.server-requires-mods": {
"message": "Acest server necesită modificări pentru a juca. Faceți clic pe Instalare pentru a configura fișierele necesare din Modrinth, apoi lansați direct pe server."
},
"app.modal.install-to-play.shared-instance": {
"message": "Instanță comună"
},
"app.modal.install-to-play.shared-server-instance": {
"message": "Instanță de server partajată"
},
"app.modal.install-to-play.view-contents": {
"message": "Vizualizați conținutul"
},
"app.modal.update-to-play.header": {
"message": "Actualizați pentru a juca"
},
"app.modal.update-to-play.update-required": {
"message": "Actualizare necesară"
},
"app.modal.update-to-play.update-required-description": {
"message": "Este necesară o actualizare pentru a juca {name}. Vă rugăm să actualizați la cea mai recentă versiune pentru a lansa jocul."
},
+20 -35
View File
@@ -419,29 +419,38 @@
"app.instance.worlds.remove-server-modal.warning-header": {
"message": "Удаление {name}"
},
"app.modal.install-to-play.content-required": {
"message": "Требуется дополнительный контент"
"app.modal.install-to-play.dont-install": {
"message": "Не устанавливать"
},
"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.required-modpack": {
"message": "Необходимая сборка"
},
"app.modal.install-to-play.server-requires-mods": {
"message": "Для игры на сервере требуются моды. Установите необходимые файлы с Modrinth, чтобы подключиться."
"app.modal.install-to-play.reviewed-files": {
"message": "Файл проверяется только если он опубликован на Modrinth, независимо от его формата (включая .mrpack)."
},
"app.modal.install-to-play.shared-instance": {
"message": "Сборка"
},
"app.modal.install-to-play.shared-server-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": "Посмотреть содержимое"
@@ -449,8 +458,8 @@
"app.modal.update-to-play.header": {
"message": "Обновление перед запуском"
},
"app.modal.update-to-play.update-required": {
"message": "Требуется обновление"
"app.modal.update-to-play.removed": {
"message": "Удалён"
},
"app.modal.update-to-play.update-required-description": {
"message": "Обновите {name} до последней версии, чтобы запустить игру."
@@ -464,9 +473,6 @@
"app.project.install-context.back-to-browse": {
"message": "Вернуться к поиску"
},
"app.project.install-context.install-content-to-instance": {
"message": "Установка контента в сборку"
},
"app.project.version.all-versions": {
"message": "Все версии"
},
@@ -1093,26 +1099,5 @@
},
"search.filter.locked.server-loader.title": {
"message": "Загрузчик управляется сервером"
},
"unknown-pack-warning-modal.body": {
"message": "Файл проверяется только в том случае, если он загружен на Modrinth, независимо от его формата (включая .mrpack)."
},
"unknown-pack-warning-modal.dont-show-again": {
"message": "Больше не предупреждать"
},
"unknown-pack-warning-modal.header": {
"message": "Подтверждение установки"
},
"unknown-pack-warning-modal.install-anyway": {
"message": "Всё равно установить"
},
"unknown-pack-warning-modal.malware-statement": {
"message": "Вредоносное ПО часто распространяется через сборки на таких платформах, как Discord."
},
"unknown-pack-warning-modal.warning.body": {
"message": "Этот файл не найден на Modrinth. Рекомендуется скачивать файлы только из надёжных источников."
},
"unknown-pack-warning-modal.warning.title": {
"message": "Предупреждение о неизвестном файле"
}
}
+74 -35
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"
},
@@ -371,29 +425,38 @@
"app.instance.worlds.search-worlds-placeholder": {
"message": "Pretraži {count} svetova..."
},
"app.modal.install-to-play.content-required": {
"message": "Potreban sadržaj"
"app.modal.install-to-play.dont-install": {
"message": "Ne instaliraj"
},
"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.required-modpack": {
"message": "Potreban modpack"
},
"app.modal.install-to-play.server-requires-mods": {
"message": "Ovaj server zahteva modove za igranje. Klikni na Instaliraj da bi instalirao potrebne datoteke iz Modrintha, a zatim pokreni direktno na server."
"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.shared-server-instance": {
"message": "Deljena instanca servera"
"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"
@@ -401,8 +464,8 @@
"app.modal.update-to-play.header": {
"message": "Ažuriraj da bi igrao"
},
"app.modal.update-to-play.update-required": {
"message": "Potrebno je ažuriranje"
"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."
@@ -416,9 +479,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"
},
@@ -1048,26 +1108,5 @@
},
"search.filter.locked.server-loader.title": {
"message": "Učitavač je obezbeđen od servera"
},
"unknown-pack-warning-modal.body": {
"message": "Datoteka se pregleda samo ako je postavljena na Modrinth, bez obzira na njen format (uključujući .mrpack)."
},
"unknown-pack-warning-modal.dont-show-again": {
"message": "Ne prikazuj ovo upozorenje ponovo"
},
"unknown-pack-warning-modal.header": {
"message": "Potvrdi instalaciju"
},
"unknown-pack-warning-modal.install-anyway": {
"message": "Svejedno instalirati"
},
"unknown-pack-warning-modal.malware-statement": {
"message": "Zlonamerni softver se često distribuira putem modpacka datoteka deljenjem na platformama poput Discord-a."
},
"unknown-pack-warning-modal.warning.body": {
"message": "Nismo mogli da pronađemo ovu datoteku na Modrinth-u. Preporučujemo da instaliraš datoteke samo iz izvora kojima veruješ."
},
"unknown-pack-warning-modal.warning.title": {
"message": "Upozorenje nepoznatih datoteka"
}
}
+20 -50
View File
@@ -407,38 +407,32 @@
"app.instance.worlds.search-worlds-placeholder": {
"message": "Sök {count} världar..."
},
"app.modal.install-to-play.content-required": {
"message": "Innehåll krävs"
"app.modal.install-to-play.dont-install": {
"message": "Installera inte"
},
"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"
},
"app.modal.install-to-play.mod-count": {
"message": "{count, plural, one {# mod} other {# moddar}}"
},
"app.modal.install-to-play.required-modpack": {
"message": "Modpaket som krävs"
},
"app.modal.install-to-play.server-requires-mods": {
"message": "Servern kräver moddar för att du ska kunna spela. Klicka på Installera för att sätta upp dem nödvändiga filerna från Modrinth, och starta sedan på servern direkt."
},
"app.modal.install-to-play.shared-instance": {
"message": "Delad instans"
},
"app.modal.install-to-play.shared-server-instance": {
"message": "Delad serverinstans"
},
"app.modal.install-to-play.view-contents": {
"message": "Visa innehåll"
},
"app.modal.update-to-play.header": {
"message": "Uppdatera för att spela"
},
"app.modal.update-to-play.update-required": {
"message": "Uppdatering krävs"
"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."
@@ -452,9 +446,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"
},
@@ -630,7 +621,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"
@@ -672,7 +663,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"
@@ -747,7 +738,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!"
@@ -867,19 +858,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"
@@ -945,10 +936,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"
@@ -1011,13 +1002,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"
@@ -1029,10 +1020,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"
@@ -1084,26 +1075,5 @@
},
"search.filter.locked.server-loader.title": {
"message": "Loader tillhandahålls av servern"
},
"unknown-pack-warning-modal.body": {
"message": "En fil granskas bara om den laddas upp till Modrinth, oavsett dess filformat (däribland .mrpack)."
},
"unknown-pack-warning-modal.dont-show-again": {
"message": "Visa inte denna varning igen"
},
"unknown-pack-warning-modal.header": {
"message": "Bekräfta installation"
},
"unknown-pack-warning-modal.install-anyway": {
"message": "Installera ändå"
},
"unknown-pack-warning-modal.malware-statement": {
"message": "Skadeprogram distribueras ofta via modpaketfiler genom att dela dem på plattformar som Discord."
},
"unknown-pack-warning-modal.warning.body": {
"message": "Vi kunde inte hitta filen på Modrinth. Vi rekommenderar starkt att endast installera filer från källor du litar på."
},
"unknown-pack-warning-modal.warning.title": {
"message": "Okänd filvarning"
}
}
@@ -245,9 +245,6 @@
"app.instance.worlds.search-worlds-placeholder": {
"message": "ค้าหาโลกทั้งหมด {count} โลก"
},
"app.modal.install-to-play.content-required": {
"message": "เนื้อหาที่จำเป็น"
},
"app.modal.install-to-play.header": {
"message": "ติดตั้งเพื่อเล่น"
},
@@ -257,27 +254,15 @@
"app.modal.install-to-play.mod-count": {
"message": "{count, plural, other {# ม็อด}}"
},
"app.modal.install-to-play.required-modpack": {
"message": "มอดแพ็กที่จำเป็น"
},
"app.modal.install-to-play.server-requires-mods": {
"message": "เซิร์ฟเวอร์ดังกล่าวจำเป็นต้องใช้ม็อดเพื่อเล่น โปรดติดตั้งและตั้งค่าไฟล์อื่นใดที่จำเป็นจาก Modrinth ก่อน จากนั้นถึงจะสามารถเข้าเล่นเซิร์ฟเวอร์ได้"
},
"app.modal.install-to-play.shared-instance": {
"message": "โปรแกรมที่มีร่วมกัน"
},
"app.modal.install-to-play.shared-server-instance": {
"message": "เซิร์ฟเวอร์ของโปรแกรมที่มีร่วมกัน"
},
"app.modal.install-to-play.view-contents": {
"message": "ดูเนื้อหา"
},
"app.modal.update-to-play.header": {
"message": "อัปเดตเพื่อเล่น"
},
"app.modal.update-to-play.update-required": {
"message": "จำเป็นต้องอัปเดต"
},
"app.modal.update-to-play.update-required-description": {
"message": "จำเป็นต้องอัปเดตเพื่อเล่น {name} กรุณาอัปเดตเป็นเวอร์ชันล่าสุดเพื่อเปิดเกม"
},
@@ -287,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": "โหมดนักพัฒนาเปิดอยู่"
},
@@ -850,26 +832,5 @@
},
"search.filter.locked.server-loader.title": {
"message": "ตัวรันถูกกำหนดโดยเซิร์ฟเวอร์แล้ว"
},
"unknown-pack-warning-modal.body": {
"message": "ไฟล์จะได้รับการตรวจสอบโดยไม่คำนึงถึงประเภทของไฟล์ (รวมทั้ง .mrpack) เมื่อไฟล์ถูกอัปโหลดขึ้น Modrinth"
},
"unknown-pack-warning-modal.dont-show-again": {
"message": "อย่าแสดงคำเตือนนี้อีก"
},
"unknown-pack-warning-modal.header": {
"message": "ยืนยันการติดตั้ง"
},
"unknown-pack-warning-modal.install-anyway": {
"message": "ดำเนินการติดตั้งต่อไป"
},
"unknown-pack-warning-modal.malware-statement": {
"message": "มัลแวร์มักแฝงตัวมากับไฟล์แพ็กม็อดผ่านการแชร์ผ่านแพลตฟอร์มที่ไม่ใช่แพลตฟอร์มเฉพาะ เช่น ดิสคอร์ด"
},
"unknown-pack-warning-modal.warning.body": {
"message": "เราไม่สามารถค้นหาไฟล์ดังกล่าวได้บน Modrinth พวกเราขอแนะนำอย่างมากกว่าควรติดตั้งไฟล์จากแหล่งที่น่าเชื่อถือเท่านั้น"
},
"unknown-pack-warning-modal.warning.title": {
"message": "แจ้งเตือนไฟล์ไม่รู้จัก"
}
}
+20 -35
View File
@@ -425,29 +425,38 @@
"app.instance.worlds.search-worlds-placeholder": {
"message": "{count} dünya ara..."
},
"app.modal.install-to-play.content-required": {
"message": çerik gerekli"
"app.modal.install-to-play.dont-install": {
"message": ndirme"
},
"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.required-modpack": {
"message": "Gerekli mod paketi"
},
"app.modal.install-to-play.server-requires-mods": {
"message": "Bu sunucuya girebilmek için modlar gereklidir. Gerekli dosyaları Modrinth üzerinden kurmak için Yükle butonuna tıkla, ardından doğrudan sunucuya başlat."
"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.shared-server-instance": {
"message": "Paylaşılan Sunucu Kurulumu"
"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"
@@ -455,8 +464,8 @@
"app.modal.update-to-play.header": {
"message": "Oynamak için güncelle"
},
"app.modal.update-to-play.update-required": {
"message": "Güncelleme gerekli"
"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."
@@ -470,9 +479,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"
},
@@ -1102,26 +1108,5 @@
},
"search.filter.locked.server-loader.title": {
"message": "Yükleyici sunucu tarafından sağlanıyor"
},
"unknown-pack-warning-modal.body": {
"message": "Dosya formatı ne olursa olsun (.mrpack dahil), bir dosya yalnızca Modrinth'e yüklendiğinde denetlenir."
},
"unknown-pack-warning-modal.dont-show-again": {
"message": "Bu uyarıyı tekrar gösterme"
},
"unknown-pack-warning-modal.header": {
"message": "İndirmeyi onayla"
},
"unknown-pack-warning-modal.install-anyway": {
"message": "Yine de indir"
},
"unknown-pack-warning-modal.malware-statement": {
"message": "Kötü amaçlı yazılımlar genellikle Discord gibi platformlarda paylaşılan mod paketi dosyaları aracılığıyla yayılır."
},
"unknown-pack-warning-modal.warning.body": {
"message": "Bu dosyayı Modrinth üzerinde bulamadık. Yalnızca güvendiğiniz kaynaklardan gelen dosyaları yüklemenizi şiddetle öneririz."
},
"unknown-pack-warning-modal.warning.title": {
"message": "Bilinmeyen dosya uyarısı"
}
}
+69 -39
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,32 +410,53 @@
"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.content-required": {
"message": "Потрібний уміст"
"app.modal.install-to-play.dont-install": {
"message": "Не встановлювати"
},
"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.required-modpack": {
"message": "Потрібна збірка"
},
"app.modal.install-to-play.server-requires-mods": {
"message": "Для гри на цьому сервері потрібні моди. Натисніть «Установити», щоб налаштувати необхідні файли з Modrinth, а потім запустіть безпосередньо на сервері."
"app.modal.install-to-play.reviewed-files": {
"message": "Файл перевірятиметься, лише якщо його завантажено на Modrinth, незалежно від його формату (включно з .mrpack)."
},
"app.modal.install-to-play.shared-instance": {
"message": "Профіль"
},
"app.modal.install-to-play.shared-server-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": "Дивитися вміст"
@@ -419,8 +464,8 @@
"app.modal.update-to-play.header": {
"message": "Оновлення для гри"
},
"app.modal.update-to-play.update-required": {
"message": "Необхідне оновлення"
"app.modal.update-to-play.removed": {
"message": "Видалено"
},
"app.modal.update-to-play.update-required-description": {
"message": "«{name}» потребує оновлення, щоб грати. Будь ласка, оновіть до останньої версії, щоб запустити гру."
@@ -428,12 +473,12 @@
"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": "Увімкнено режим розробника."
},
@@ -554,6 +599,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"
},
@@ -971,6 +1019,9 @@
"instance.worlds.copy_address": {
"message": "Скопіювати адресу"
},
"instance.worlds.create_shortcut": {
"message": "Створити ярлик"
},
"instance.worlds.dont_show_on_home": {
"message": "Не показувати на головній"
},
@@ -1045,26 +1096,5 @@
},
"search.filter.locked.server-loader.title": {
"message": "Завантажувач наданий сервером"
},
"unknown-pack-warning-modal.body": {
"message": "Файл перевірятиметься лише, якщо його завантажено на Modrinth, незалежно від його формату (включно з .mrpack)."
},
"unknown-pack-warning-modal.dont-show-again": {
"message": "Не показувати більше це попередження"
},
"unknown-pack-warning-modal.header": {
"message": "Підтвердити встановлення"
},
"unknown-pack-warning-modal.install-anyway": {
"message": "Усе одно встановити"
},
"unknown-pack-warning-modal.malware-statement": {
"message": "Шкідливе програмне забезпечення часто поширюють через файли збірок, які публікуються на таких платформах, як Discord."
},
"unknown-pack-warning-modal.warning.body": {
"message": "Ми не змогли знайти цей файл на Modrinth. Ми рекомендуємо встановлювати файли лише з тих джерел яким ви довіряєте."
},
"unknown-pack-warning-modal.warning.title": {
"message": "Попередження про невідомий файл"
}
}
+61 -37
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,41 +278,44 @@
"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.content-required": {
"message": "Nội dung bắt buộc"
"app.modal.install-to-play.dont-install": {
"message": "Ngừng tải"
},
"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"
},
"app.modal.install-to-play.mod-count": {
"message": "{count, plural, other {# mod}}"
},
"app.modal.install-to-play.required-modpack": {
"message": "Yêu cầu modpack"
},
"app.modal.install-to-play.server-requires-mods": {
"message": "Máy chủ này yêu cầu mod để có thể chơi. Vui lòng ấn vào tải xuống và tải các tệp bắt buộc từ Modrinth và khởi chạy trực tiếp để tham gia máy chủ."
},
"app.modal.install-to-play.shared-instance": {
"message": "Chia sẻ hồ sơ"
},
"app.modal.install-to-play.shared-server-instance": {
"message": "Chia sẻ hồ sơ máy chủ"
},
"app.modal.install-to-play.view-contents": {
"message": "Xem nội dung"
},
"app.modal.update-to-play.header": {
"message": "Cập nhật và bắt đầu chơi"
},
"app.modal.update-to-play.update-required": {
"message": "Yêu cầu cập nhật"
"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."
@@ -284,12 +323,12 @@
"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."
},
@@ -410,6 +449,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"
},
@@ -827,6 +869,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ủ"
},
@@ -901,26 +946,5 @@
},
"search.filter.locked.server-loader.title": {
"message": "Loader được cung cấp bởi máy chủ"
},
"unknown-pack-warning-modal.body": {
"message": "Tệp chỉ được xem xét nếu nó được tải lên Modrinth, bất kể định dạng tệp của nó là gì (bao gồm cả .mrpack)."
},
"unknown-pack-warning-modal.dont-show-again": {
"message": "Đừng hiển thị cảnh báo này nữa"
},
"unknown-pack-warning-modal.header": {
"message": "Xác nhận cài đặt"
},
"unknown-pack-warning-modal.install-anyway": {
"message": "Tiếp tục cài đặt"
},
"unknown-pack-warning-modal.malware-statement": {
"message": "Phần mềm độc hại thường được phát tán thông qua các tệp modpack bằng cách chia sẻ chúng trên các nền tảng như Discord."
},
"unknown-pack-warning-modal.warning.body": {
"message": "Chúng tôi không tìm thấy tập tin này trên Modrinth. Chúng tôi đặc biệt khuyên bạn chỉ nên cài đặt các tập tin từ các nguồn đáng tin cậy."
},
"unknown-pack-warning-modal.warning.title": {
"message": "Cảnh báo tệp không xác định"
}
}
+24 -39
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": "无法找到实例"
@@ -425,29 +425,38 @@
"app.instance.worlds.search-worlds-placeholder": {
"message": "搜索 {count} 个世界……"
},
"app.modal.install-to-play.content-required": {
"message": "需求内容"
"app.modal.install-to-play.dont-install": {
"message": "不要安装"
},
"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.required-modpack": {
"message": "需求整合包"
},
"app.modal.install-to-play.server-requires-mods": {
"message": "此服务器需要安装模组才能游玩。点击安装,从 Modrinth 下载所需文件,然后直接进入服务器。"
"app.modal.install-to-play.reviewed-files": {
"message": "只有上传到 Modrinth 的文件才会经过审核,无论其文件格式如何(包括 .mrpack)。"
},
"app.modal.install-to-play.shared-instance": {
"message": "共享实例"
},
"app.modal.install-to-play.shared-server-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": "查看内容"
@@ -455,8 +464,8 @@
"app.modal.update-to-play.header": {
"message": "更新以游玩"
},
"app.modal.update-to-play.update-required": {
"message": "需要更新"
"app.modal.update-to-play.removed": {
"message": "已移除"
},
"app.modal.update-to-play.update-required-description": {
"message": "需要更新至最新版本才能运行 {name}。请更新后启动游戏。"
@@ -470,9 +479,6 @@
"app.project.install-context.back-to-browse": {
"message": "返回发现页"
},
"app.project.install-context.install-content-to-instance": {
"message": "将内容安装到实例中"
},
"app.project.version.all-versions": {
"message": "所有版本"
},
@@ -1102,26 +1108,5 @@
},
"search.filter.locked.server-loader.title": {
"message": "加载器由服务器提供"
},
"unknown-pack-warning-modal.body": {
"message": "只有上传到 Modrinth 的文件才会经过审核,无论其文件格式如何(包括 .mrpack)。"
},
"unknown-pack-warning-modal.dont-show-again": {
"message": "不再显示此警告"
},
"unknown-pack-warning-modal.header": {
"message": "确认安装"
},
"unknown-pack-warning-modal.install-anyway": {
"message": "仍然安装"
},
"unknown-pack-warning-modal.malware-statement": {
"message": "恶意软件常常通过整合包文件在 Discord 等平台上分享而传播。"
},
"unknown-pack-warning-modal.warning.body": {
"message": "我们在 Modrinth 上找不到此文件。强烈建议你仅从可信来源安装文件。"
},
"unknown-pack-warning-modal.warning.title": {
"message": "未知文件警告"
}
}
+20 -35
View File
@@ -425,29 +425,38 @@
"app.instance.worlds.search-worlds-placeholder": {
"message": "搜尋 {count} 個世界..."
},
"app.modal.install-to-play.content-required": {
"message": "所需內容"
"app.modal.install-to-play.dont-install": {
"message": "不要安裝"
},
"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.required-modpack": {
"message": "所需模組包"
},
"app.modal.install-to-play.server-requires-mods": {
"message": "這個伺服器需要模組才能遊玩。請點選「安裝」以從 Modrinth 設定所需的檔案,完成後即可直接加入伺服器。"
"app.modal.install-to-play.reviewed-files": {
"message": "只有在 Modrinth 上發布的檔案才會經過審查,無論其檔案格式為何(包含 .mrpack)。"
},
"app.modal.install-to-play.shared-instance": {
"message": "共用實例"
},
"app.modal.install-to-play.shared-server-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": "檢視內容"
@@ -455,8 +464,8 @@
"app.modal.update-to-play.header": {
"message": "更新以遊玩"
},
"app.modal.update-to-play.update-required": {
"message": "需要更新"
"app.modal.update-to-play.removed": {
"message": "已移除"
},
"app.modal.update-to-play.update-required-description": {
"message": "需要更新才能遊玩「{name}」。請更新至最新版本以啟動遊戲。"
@@ -470,9 +479,6 @@
"app.project.install-context.back-to-browse": {
"message": "返回瀏覽"
},
"app.project.install-context.install-content-to-instance": {
"message": "將內容安裝至實例"
},
"app.project.version.all-versions": {
"message": "所有版本"
},
@@ -1102,26 +1108,5 @@
},
"search.filter.locked.server-loader.title": {
"message": "載入器由伺服器提供"
},
"unknown-pack-warning-modal.body": {
"message": "只有上傳至 Modrinth 的檔案才會經過審查,無論其檔案格式為何(包含 .mrpack)。"
},
"unknown-pack-warning-modal.dont-show-again": {
"message": "不要再顯示這則警告"
},
"unknown-pack-warning-modal.header": {
"message": "確認安裝"
},
"unknown-pack-warning-modal.install-anyway": {
"message": "仍要安裝"
},
"unknown-pack-warning-modal.malware-statement": {
"message": "惡意軟體經常透過 Discord 等平臺分享模組包檔案來進行傳播。"
},
"unknown-pack-warning-modal.warning.body": {
"message": "我們在 Modrinth 上找不到這個檔案。強烈建議你僅安裝來自信任來源的檔案。"
},
"unknown-pack-warning-modal.warning.title": {
"message": "未知檔案警告"
}
}
+9 -1
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
@@ -960,6 +964,7 @@ const lockedFilterMessages = computed(() => ({
const searchState = useBrowseSearch({
projectType,
tags,
active: browseRouteActive,
providedFilters: combinedProvidedFilters,
search,
persistentQueryParams: ['i', 'ai', 'shi', 'sid', 'wid', 'from'],
@@ -1035,6 +1040,9 @@ onUnmounted(() => {
})
function getProjectBrowseQuery() {
if (!browseRouteActive.value) {
return undefined
}
if (!installContext.value) return undefined
return {
...route.query,
@@ -1139,7 +1147,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);
+27 -239
View File
@@ -13,207 +13,31 @@
@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>
<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"
@repair="() => repairInstance()"
@stop="() => stopInstance('InstancePage')"
@play="() => startInstance('InstancePage')"
@play-server="() => handlePlayServer()"
@settings="() => settingsModal?.show()"
@open-folder="() => instance && showInstanceInFolder(instance.id)"
@export="() => exportModal?.show()"
@create-shortcut="() => createShortcut()"
/>
</div>
<div :class="['px-6', { 'shrink-0': isFixedRender }]">
<NavTabs :links="tabs" />
@@ -272,49 +96,30 @@ 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 { injectNotificationManager, NavTabs, useLoadingBarToken } from '@modrinth/ui'
import { 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 InstancePageHeader from '@/components/ui/instance-page-header/index.vue'
import InstanceSettingsModal from '@/components/ui/modal/InstanceSettingsModal.vue'
import UpdateToPlayModal from '@/components/ui/modal/UpdateToPlayModal.vue'
import {
@@ -336,7 +141,6 @@ import { injectServerInstall } from '@/providers/server-install'
import { handleSevereError } from '@/store/error.js'
import { useBreadcrumbs, useTheming } from '@/store/state'
dayjs.extend(duration)
dayjs.extend(relativeTime)
const { addNotification, handleError } = injectNotificationManager()
@@ -758,22 +562,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()
+72 -4
View File
@@ -2,6 +2,13 @@
<ReadyTransition :pending="loading">
<ContentPageLayout>
<template #modals>
<UnknownFileWarningModal
ref="unknownFileWarningModal"
mode="mod"
:file-name="unknownFileName"
@cancel="resolveUnknownFileWarning(false)"
@continue="handleUnknownFileContinue"
/>
<ShareModalWrapper
ref="shareModal"
:share-title="formatMessage(messages.shareTitle)"
@@ -87,6 +94,7 @@ import {
provideAppBackup,
provideContentManager,
ReadyTransition,
UnknownFileWarningModal,
useDebugLogger,
useVIntl,
versionChangesGameVersion,
@@ -113,6 +121,7 @@ import {
add_project_from_path,
edit,
get_linked_modpack_content,
is_file_on_modrinth,
list,
remove_project,
switch_project_version_with_dependencies,
@@ -121,10 +130,12 @@ import {
update_managed_modrinth_version,
} from '@/helpers/instance'
import { type InstanceContentData, loadInstanceContentData } from '@/helpers/instance-content'
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 { useTheming } from '@/store/state'
import type { FeatureFlag } from '@/store/theme'
const messages = defineMessages({
shareTitle: {
@@ -175,6 +186,7 @@ const router = useRouter()
const queryClient = useQueryClient()
const debug = useDebugLogger('Mods:ContentUpdate')
const themeStore = useTheming()
const skipUnknownFileWarningFeatureFlag = 'skip_unknown_pack_warning' as FeatureFlag
const skipNonEssentialWarnings = computed(() =>
themeStore.getFeatureFlag('skip_non_essential_warnings'),
)
@@ -284,6 +296,9 @@ 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 unknownFileWarningModal = ref<InstanceType<typeof UnknownFileWarningModal> | null>()
const unknownFileName = ref('')
let resolveUnknownFileConfirmation: ((confirmed: boolean) => void) | null = null
const modpackContentQueryKey = computed(() => ['linkedModpackContent', props.instance.id])
const modpackContentQuery = useQuery({
@@ -482,14 +497,34 @@ async function handleUploadFiles() {
if (!props.instance) return
const files = await open({ multiple: true })
if (!files) return
const addedFiles: string[] = []
const selectedFiles: Array<{ path: string; filename: string }> = []
for (const file of files) {
const path = (file as { path?: string }).path ?? file
const fileName = typeof path === 'string' ? (path.split('/').pop() ?? path) : String(path)
if (typeof path !== 'string') continue
selectedFiles.push({
path,
filename: path.split(/[\\/]/).pop() ?? path,
})
}
const fileRecognition = await Promise.all(
selectedFiles.map(async ({ path }) => {
try {
return await is_file_on_modrinth(path)
} catch {
return true
}
}),
)
const addedFiles: 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)
addedFiles.push(filename)
} catch (e) {
handleError(e as Error)
}
@@ -514,6 +549,39 @@ async function handleUploadFiles() {
}
}
function confirmUnknownFileInstallation(fileName: string) {
if (themeStore.getFeatureFlag(skipUnknownFileWarningFeatureFlag)) {
return Promise.resolve(true)
}
unknownFileName.value = fileName
return new Promise<boolean>((resolve) => {
resolveUnknownFileConfirmation = resolve
void nextTick(() => unknownFileWarningModal.value?.show())
})
}
function resolveUnknownFileWarning(confirmed: boolean) {
const resolve = resolveUnknownFileConfirmation
resolveUnknownFileConfirmation = null
unknownFileName.value = ''
resolve?.(confirmed)
}
async function handleUnknownFileContinue(dontShowAgain: boolean) {
if (dontShowAgain) {
themeStore.featureFlags[skipUnknownFileWarningFeatureFlag] = true
try {
const settings = await getSettings()
settings.feature_flags[skipUnknownFileWarningFeatureFlag] = true
await setSettings(settings)
} catch (error) {
handleError(error as Error)
}
}
resolveUnknownFileWarning(true)
}
async function toggleDisableMod(mod: ContentItem, desiredEnabled?: boolean) {
if (!mod.file_path) return
const operation = beginContentOperation(mod)
+167 -147
View File
@@ -59,142 +59,100 @@
>
<ProjectBackgroundGradient :project="data" />
</Teleport>
<ProjectHeader
<ProjectPageHeader
v-else
:project="data"
:project-v3="projectV3"
:ping="serverPing"
:show-status-badge="data.status !== 'approved'"
@contextmenu.prevent.stop="handleRightClick"
@category="(category) => router.push(`${projectSearchUrl}?f=categories:${category}`)"
>
<template v-if="isServerProject" #actions>
<ButtonStyled v-if="serverPlaying" size="large" color="red">
<button @click="handleStopServer">
<StopCircleIcon />
{{ formatMessage(commonMessages.stopButton) }}
</button>
</ButtonStyled>
<ButtonStyled v-else size="large" color="brand">
<button
:disabled="data && installingServerProjects.includes(data.id)"
@click="handleClickPlay"
>
<PlayIcon />
{{
data && installingServerProjects.includes(data.id)
? formatMessage(commonMessages.installingLabel)
: formatMessage(commonMessages.playButton)
}}
</button>
</ButtonStyled>
<ButtonStyled size="large" circular>
<button
v-tooltip="formatMessage(commonMessages.addServerToInstanceButton)"
@click="handleAddServerToInstance"
>
<PlusIcon />
</button>
</ButtonStyled>
<ButtonStyled size="large" circular type="transparent">
<OverflowMenu
:tooltip="`More options`"
:options="[
{
id: 'open-in-browser',
link: `https://modrinth.com/project/${data.slug}`,
external: true,
},
{
divider: true,
},
{
id: 'report',
color: 'red',
hoverFilled: true,
link: `https://modrinth.com/report?item=project&itemID=${data.id}`,
},
]"
aria-label="More options"
>
<MoreVerticalIcon aria-hidden="true" />
<template #open-in-browser>
<ExternalIcon /> {{ formatMessage(commonMessages.openInBrowserButton) }}
</template>
<template #report> <ReportIcon /> Report </template>
</OverflowMenu>
</ButtonStyled>
<template #actions>
<template v-if="isServerProject">
<ButtonStyled v-if="serverPlaying" color="red" size="large">
<button type="button" @click="handleStopServer">
<StopCircleIcon />
{{ formatMessage(commonMessages.stopButton) }}
</button>
</ButtonStyled>
<ButtonStyled v-else color="brand" size="large">
<button type="button" :disabled="serverInstallLoading" @click="handleClickPlay">
<PlayIcon />
{{
serverInstallLoading
? formatMessage(commonMessages.installingLabel)
: formatMessage(commonMessages.playButton)
}}
</button>
</ButtonStyled>
<ButtonStyled circular size="large">
<button
v-tooltip="formatMessage(commonMessages.addServerToInstanceButton)"
type="button"
:aria-label="formatMessage(commonMessages.addServerToInstanceButton)"
@click="handleAddServerToInstance"
>
<PlusIcon />
</button>
</ButtonStyled>
<ButtonStyled circular size="large" type="transparent">
<TeleportOverflowMenu
:options="serverProjectHeaderMoreActions"
tooltip="More options"
aria-label="More options"
>
<MoreVerticalIcon />
</TeleportOverflowMenu>
</ButtonStyled>
</template>
<template v-else>
<ButtonStyled v-if="showSwitchVersion && onVersionsPage" size="large">
<button v-tooltip="formatMessage(messages.alreadyInstalled)" type="button" disabled>
<CheckIcon />
{{ formatMessage(commonMessages.installedLabel) }}
</button>
</ButtonStyled>
<ButtonStyled v-else-if="showSwitchVersion" size="large">
<button type="button" @click="goToVersions">
<SwapIcon />
{{ formatMessage(messages.switchVersion) }}
</button>
</ButtonStyled>
<ButtonStyled v-else color="brand" size="large">
<button
v-tooltip="
installButtonInstalled ? formatMessage(messages.alreadyInstalled) : undefined
"
type="button"
:disabled="installButtonDisabled"
@click="install(null)"
>
<component :is="installButtonIcon" :class="installButtonIconClass" />
{{
installButtonInstalled
? formatMessage(commonMessages.installedLabel)
: installButtonValidating
? formatMessage(commonMessages.validatingLabel)
: installButtonLoading
? formatMessage(commonMessages.installingLabel)
: serverProjectSelected
? formatMessage(commonMessages.selectedLabel)
: formatMessage(commonMessages.installButton)
}}
</button>
</ButtonStyled>
<ButtonStyled circular size="large" type="transparent">
<TeleportOverflowMenu
:options="projectHeaderMoreActions"
tooltip="More options"
aria-label="More options"
>
<MoreVerticalIcon />
</TeleportOverflowMenu>
</ButtonStyled>
</template>
</template>
<template v-else #actions>
<ButtonStyled v-if="showSwitchVersion && onVersionsPage" size="large">
<button v-tooltip="installButtonTooltip" disabled>
<CheckIcon />
{{ formatMessage(commonMessages.installedLabel) }}
</button>
</ButtonStyled>
<ButtonStyled v-else-if="showSwitchVersion" size="large">
<button @click="goToVersions">
<SwapIcon />
{{ formatMessage(messages.switchVersion) }}
</button>
</ButtonStyled>
<ButtonStyled v-else size="large" color="brand">
<button
v-tooltip="installButtonTooltip"
:disabled="installButtonDisabled"
@click="install(null)"
>
<SpinnerIcon
v-if="installButtonLoading && !installButtonInstalled"
class="animate-spin"
/>
<DownloadIcon v-else-if="!installButtonInstalled && !serverProjectSelected" />
<CheckIcon v-else />
{{ installButtonLabel }}
</button>
</ButtonStyled>
<ButtonStyled size="large" circular type="transparent">
<OverflowMenu
:tooltip="`More options`"
:options="[
{
id: 'follow',
disabled: true,
tooltip: 'Coming soon',
action: () => {},
},
{
id: 'save',
disabled: true,
tooltip: 'Coming soon',
action: () => {},
},
{
id: 'open-in-browser',
link: `https://modrinth.com/${data.project_type}/${data.slug}`,
external: true,
},
{
divider: true,
},
{
id: 'report',
color: 'red',
hoverFilled: true,
link: `https://modrinth.com/report?item=project&itemID=${data.id}`,
},
]"
aria-label="More options"
>
<MoreVerticalIcon aria-hidden="true" />
<template #open-in-browser>
<ExternalIcon /> {{ formatMessage(commonMessages.openInBrowserButton) }}
</template>
<template #follow> <HeartIcon /> Follow </template>
<template #save> <BookmarkIcon /> Save </template>
<template #report> <ReportIcon /> Report </template>
</OverflowMenu>
</ButtonStyled>
</template>
</ProjectHeader>
</ProjectPageHeader>
<NavTabs
:links="[
{
@@ -289,9 +247,8 @@ import {
getTargetInstallPreferences,
injectNotificationManager,
NavTabs,
OverflowMenu,
ProjectBackgroundGradient,
ProjectHeader,
ProjectPageHeader,
ProjectSidebarCompatibility,
ProjectSidebarCreators,
ProjectSidebarDetails,
@@ -300,6 +257,7 @@ import {
ProjectSidebarTags,
requestInstall,
SelectedProjectsFloatingBar,
TeleportOverflowMenu,
useVIntl,
} from '@modrinth/ui'
import { useQueryClient } from '@tanstack/vue-query'
@@ -358,10 +316,6 @@ const messages = defineMessages({
id: 'app.project.install-context.back-to-browse',
defaultMessage: 'Back to discover',
},
installContentToInstance: {
id: 'app.project.install-context.install-content-to-instance',
defaultMessage: 'Install content to instance',
},
alreadyInstalled: {
id: 'app.project.install-button.already-installed',
defaultMessage: 'This project is already installed',
@@ -492,7 +446,7 @@ const projectInstallContext = computed(() => {
iconSrc: instance.value.icon_path ? convertFileSrc(instance.value.icon_path) : null,
backUrl: projectBrowseBackUrl.value,
backLabel: formatMessage(messages.backToBrowse),
heading: formatMessage(messages.installContentToInstance),
heading: formatMessage(commonMessages.installingContentLabel),
}
}
@@ -529,17 +483,72 @@ const installButtonInstalled = computed(() =>
const installButtonDisabled = computed(
() => installButtonInstalled.value || installButtonLoading.value,
)
const installButtonLabel = computed(() => {
if (installButtonInstalled.value) return formatMessage(commonMessages.installedLabel)
if (installButtonValidating.value) return formatMessage(commonMessages.validatingLabel)
if (installButtonLoading.value) return formatMessage(commonMessages.installingLabel)
if (serverProjectSelected.value) return formatMessage(commonMessages.selectedLabel)
return formatMessage(commonMessages.installButton)
})
const installButtonTooltip = computed(() => {
if (installButtonInstalled.value) return formatMessage(messages.alreadyInstalled)
return null
const serverInstallLoading = computed(
() => !!data.value && installingServerProjects.value.includes(data.value.id),
)
const installButtonIcon = computed(() => {
if (installButtonLoading.value && !installButtonInstalled.value) return SpinnerIcon
if (!installButtonInstalled.value && !serverProjectSelected.value) return DownloadIcon
return CheckIcon
})
const installButtonIconClass = computed(() =>
installButtonLoading.value && !installButtonInstalled.value ? 'animate-spin' : undefined,
)
const serverProjectHeaderMoreActions = computed(() => [
{
id: 'open-in-browser',
label: formatMessage(commonMessages.openInModrinthButton),
icon: ExternalIcon,
action: openProjectInBrowser,
},
{
divider: true,
},
{
id: 'report',
label: formatMessage(commonMessages.reportButton),
icon: ReportIcon,
color: 'red',
action: reportProject,
},
])
const projectHeaderMoreActions = computed(() => [
{
id: 'follow',
label: formatMessage(commonMessages.followButton),
icon: HeartIcon,
disabled: true,
tooltip: 'Coming soon',
action: () => {},
},
{
id: 'save',
label: formatMessage(commonMessages.saveButton),
icon: BookmarkIcon,
disabled: true,
tooltip: 'Coming soon',
action: () => {},
},
{
id: 'open-in-browser',
label: formatMessage(commonMessages.openInModrinthButton),
icon: ExternalIcon,
action: openProjectInBrowser,
},
{
divider: true,
},
{
id: 'report',
label: formatMessage(commonMessages.reportButton),
icon: ReportIcon,
color: 'red',
action: reportProject,
},
])
const projectSearchUrl = computed(
() => `/browse/${isServerProject.value ? 'server' : data.value?.project_type}`,
)
const showSwitchVersion = computed(() => !!instance.value && installed.value)
const onVersionsPage = computed(() => route.name === 'Versions')
@@ -585,6 +594,17 @@ function handleAddServerToInstance() {
showAddServerToInstanceModal(data.value.title, address)
}
function openProjectInBrowser() {
if (!data.value) return
const type = isServerProject.value ? 'project' : data.value.project_type
void openUrl(`https://modrinth.com/${type}/${data.value.slug}`)
}
function reportProject() {
if (!data.value) return
void openUrl(`https://modrinth.com/report?item=project&itemID=${data.value.id}`)
}
async function fetchProjectData() {
const [project, projectV3Result] = await Promise.all([
get_project(route.params.id, 'must_revalidate').catch(handleError),
@@ -807,6 +807,11 @@ export function createContentInstall(opts: {
) {
const project: Labrinth.Projects.v2.Project = await get_project(projectId, 'must_revalidate')
if (!project) {
opts.handleError(`Project not found: '${projectId}'`)
return
}
if (project.project_type === 'modpack') {
const version = versionId ?? project.versions[project.versions.length - 1]
const packs = await list()
@@ -110,7 +110,7 @@ export function setupCreationModal(notificationManager: AbstractWebNotificationM
}
const preview = await install_get_modpack_preview(location)
if (preview.unknownFile) {
if (preview.unknownFile || preview.externalFilesInModpack.length > 0) {
const splitPath = config.modpackFilePath.value.split(/[\\/]/)
const fileName = splitPath
? splitPath[splitPath.length - 1]
@@ -119,6 +119,7 @@ export function setupCreationModal(notificationManager: AbstractWebNotificationM
unknownPackWarningModal.value?.show(
() => install_create_modpack_instance(location).then(() => undefined),
fileName,
preview.externalFilesInModpack,
)
} else {
await install_create_modpack_instance(location)
+13
View File
@@ -6,6 +6,7 @@ export const useError = defineStore('errorsStore', {
state: () => ({
errorModal: null,
minecraftAuthErrorModal: null,
minecraftRequiredModal: null,
}),
actions: {
setErrorModal(ref) {
@@ -14,7 +15,19 @@ export const useError = defineStore('errorsStore', {
setMinecraftAuthErrorModal(ref) {
this.minecraftAuthErrorModal = ref
},
setMinecraftRequiredModal(ref) {
this.minecraftRequiredModal = ref
},
showError(error, context, closable = true, source = null) {
const errorMessage = error.message?.toLowerCase()
if (
(errorMessage?.includes('user is not logged in') ||
errorMessage?.includes('cannot play instance since minecraft is required')) &&
this.minecraftRequiredModal
) {
this.minecraftRequiredModal.show()
return
}
if (
error.message &&
(error.message.includes('Minecraft authentication error:') ||
+6
View File
@@ -202,6 +202,7 @@ fn main() {
"instance_install_project_with_dependencies",
"instance_switch_project_version_with_dependencies",
"instance_add_project_from_path",
"instance_is_file_on_modrinth",
"instance_toggle_disable_project",
"instance_remove_project",
"instance_update_managed_modrinth_version",
@@ -278,7 +279,12 @@ fn main() {
"scroll_ads_window",
"show_ads_window",
"show_ads_consent_overlay",
"show_ads_consent_preferences",
"open_ads_consent_preferences",
"hide_ads_consent_preferences",
"hide_ads_consent_overlay",
"get_ads_consent_required",
"perform_ads_consent_action",
"record_ads_click",
"open_link",
"get_ads_personalization",
+778 -13
View File
@@ -1,11 +1,158 @@
const MODRINTH_ORIGIN = 'https://modrinth.com'
function installAdsConsentThemeStyle() {
if (document.getElementById('modrinth-ads-consent-theme-style')) return
const style = document.createElement('style')
style.id = 'modrinth-ads-consent-theme-style'
style.textContent = `
:root {
--modrinth-usp-bg: #27292e;
--modrinth-usp-surface: #34363c;
--modrinth-usp-divider: #34363c;
--modrinth-usp-text: #b0bac5;
--modrinth-usp-contrast: #ffffff;
--modrinth-usp-brand: #1bd96a;
--modrinth-usp-link: #4f9cff;
--modrinth-usp-accent-contrast: #000000;
--modrinth-usp-shadow: rgba(0, 0, 0, 0.1) 0 4px 6px -1px,
rgba(0, 0, 0, 0.06) 0 2px 4px -1px;
color-scheme: dark;
}
#qc-cmp2-usp {
background: var(--modrinth-usp-bg) !important;
border: 1px solid var(--modrinth-usp-divider) !important;
border-radius: 1rem !important;
box-shadow: var(--modrinth-usp-shadow) !important;
color: var(--modrinth-usp-text) !important;
font-family: Inter, -apple-system, BlinkMacSystemFont, 'Segoe UI', Oxygen, Ubuntu, Roboto,
Cantarell, 'Fira Sans', 'Droid Sans', 'Helvetica Neue', sans-serif !important;
max-width: 660px;
}
#qc-cmp2-usp .qc-usp-ui-content,
#qc-cmp2-usp .qc-usp-ui-form-content,
#qc-cmp2-usp .qc-usp-container {
background: transparent !important;
}
#qc-cmp2-usp .qc-usp-container{
margin-bottom: 12px;
}
#qc-cmp2-usp p,
#qc-cmp2-usp label,
#qc-cmp2-usp .qc-usp-action-description {
color: var(--modrinth-usp-text) !important;
font-family: inherit !important;
}
#qc-cmp2-usp .qc-usp-title,
#qc-cmp2-usp .qc-cmp2-list-item-title {
color: var(--modrinth-usp-contrast) !important;
font-family: inherit !important;
font-weight: 700 !important;
}
#qc-cmp2-usp .qc-usp-title {
font-size: 1.25rem !important;
}
#qc-cmp2-usp a,
#qc-cmp2-usp .qc-usp-alt-action {
color: var(--modrinth-usp-link) !important;
}
#qc-cmp2-usp .qc-cmp2-list-item {
background: var(--modrinth-usp-surface) !important;
border: 1px solid var(--modrinth-usp-divider) !important;
border-radius: 0.75rem !important;
}
#qc-cmp2-usp .qc-cmp2-list-item-header {
background: transparent !important;
border: 0 !important;
color: var(--modrinth-usp-contrast) !important;
}
#qc-cmp2-usp .qc-cmp2-list-item-header svg {
color: var(--modrinth-usp-text) !important;
}
#qc-cmp2-usp .qc-cmp2-toggle {
background: var(--modrinth-usp-bg) !important;
border-color: var(--modrinth-usp-divider) !important;
}
#qc-cmp2-usp .qc-cmp2-toggle .toggle {
background: var(--modrinth-usp-contrast) !important;
}
#qc-cmp2-usp .qc-cmp2-toggle .text {
color: var(--modrinth-usp-contrast) !important;
}
#qc-cmp2-usp .qc-cmp2-toggle[aria-checked='true'] {
background: var(--modrinth-usp-brand) !important;
border-color: var(--modrinth-usp-brand) !important;
}
#qc-cmp2-usp .qc-cmp2-toggle[aria-checked='true'] .text {
color: var(--modrinth-usp-accent-contrast) !important;
}
#qc-cmp2-usp button[mode='primary'] {
background: var(--modrinth-usp-brand) !important;
border: 0 !important;
border-radius: 0.75rem !important;
color: var(--modrinth-usp-accent-contrast) !important;
font-family: inherit !important;
font-weight: 700 !important;
}
#qc-cmp2-usp .qc-usp-close-icon {
border: 0 !important;
background: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='white' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M18 6 6 18'/%3E%3Cpath d='m6 6 12 12'/%3E%3C/svg%3E")
center / 1.5rem 1.5rem no-repeat;
}
#qc-cmp2-usp a:focus-visible,
#qc-cmp2-usp button:focus-visible {
outline: 2px solid var(--modrinth-usp-brand) !important;
outline-offset: 2px !important;
}
#qc-cmp2-usp .qc-usp-ui-content {
max-width: 100% !important;
}
#qc-cmp2-usp .qc-usp-ui-content .qc-usp-ui-form-content {
border: 1px solid transparent !important;
padding: 0 !important;
}
`
document.documentElement.appendChild(style)
}
document.addEventListener(
'click',
function (e) {
window.top.postMessage({ modrinthAdClick: true }, MODRINTH_ORIGIN)
let target = e.target
const uspCloseIcon = target?.closest?.('.qc-usp-close-icon')
if (target?.closest?.('.qc-cmp2-close-icon') || uspCloseIcon) {
if (isAdsConsentReprompt()) {
setTimeout(finishAdsConsentReprompt)
} else if (document.documentElement.classList.contains('modrinth-ads-consent-preferences')) {
setTimeout(() => void restoreAdsConsentNotification())
}
}
if (target?.closest?.('#qc-cmp2-usp .qc-usp-ui-form-content button[mode="primary"]')) {
beginUspConsentCommit()
}
while (target != null) {
if (target.matches('a')) {
e.preventDefault()
@@ -24,9 +171,67 @@ window.open = (url, target, features) => {
window.top.postMessage({ modrinthOpenUrl: url }, MODRINTH_ORIGIN)
}
let modrinthAdsConsentOverlayShown = false
const modrinthAdsConsentState = {
phase: 'idle',
variant: null,
commitTimeout: null,
}
let modrinthTcfListenerInstalled = false
let modrinthTcfListenerAttempts = 0
let modrinthGppListenerInstalled = false
let modrinthGppListenerAttempts = 0
let modrinthAdsConsentActionRequestId = 0
const modrinthAdsConsentActionResolvers = new Map()
function transitionAdsConsent(event) {
const phase = modrinthAdsConsentState.phase
if (event === 'prompt-detected') {
if (phase === 'idle') modrinthAdsConsentState.phase = 'initial'
} else if (event === 'reprompt-started') {
modrinthAdsConsentState.phase = 'reprompt'
} else if (event === 'commit-started') {
modrinthAdsConsentState.phase = isAdsConsentReprompt()
? 'reprompt-committing'
: 'initial-committing'
} else if (event === 'commit-timed-out') {
if (phase === 'reprompt-committing') {
modrinthAdsConsentState.phase = 'reprompt'
} else if (phase === 'initial-committing') {
modrinthAdsConsentState.phase = 'initial'
}
} else if (event === 'completed') {
modrinthAdsConsentState.phase = 'complete'
}
}
function isAdsConsentReprompt() {
return (
modrinthAdsConsentState.phase === 'reprompt' ||
modrinthAdsConsentState.phase === 'reprompt-committing'
)
}
function isUspConsentCommitPending() {
return (
modrinthAdsConsentState.phase === 'initial-committing' ||
modrinthAdsConsentState.phase === 'reprompt-committing'
)
}
function installAdsRailStyle() {
if (document.getElementById('modrinth-ads-rail-style')) {
return
}
const style = document.createElement('style')
style.id = 'modrinth-ads-rail-style'
style.textContent = `
html.modrinth-ads-consent-preferences #modrinth-rail-1 {
display: none !important;
}
`
document.documentElement.appendChild(style)
}
function installAdsConsentOverlayStyle() {
if (document.getElementById('modrinth-ads-consent-overlay-style')) {
@@ -35,12 +240,13 @@ function installAdsConsentOverlayStyle() {
const style = document.createElement('style')
style.id = 'modrinth-ads-consent-overlay-style'
style.textContent = `
html.modrinth-ads-consent-overlay #modrinth-rail-1 {
visibility: hidden !important;
html.modrinth-ads-consent-overlay:not(.modrinth-ads-consent-preferences) #qc-cmp2-container,
html.modrinth-ads-consent-preferences:not(.modrinth-ads-consent-preferences-visible) #qc-cmp2-container {
display: none !important;
}
.qc-cmp2-close-icon {
background: url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24'%3E%3Cpath d='M.5.5l23 23m0-23l-23 23' fill='none' stroke='%23b0bac5' stroke-width='3' stroke-linecap='round' stroke-linejoin='round' stroke-miterlimit='10'/%3E%3Cpath fill='none' d='M0 0h24v24H0z'/%3E%3C/svg%3E") 0% 0% / 66% auto no-repeat !important;
#qc-cmp2-usp .qc-usp-close-icon {
display: none !important;
}
`
document.documentElement.appendChild(style)
@@ -63,12 +269,472 @@ function invokeAdsConsentOverlayCommand(shown) {
invoke(`plugin:ads|${command}`, args).catch(() => {})
}
function prepareAdsConsentPreferences() {
installAdsRailStyle()
installAdsConsentOverlayStyle()
document.documentElement.classList.add('modrinth-ads-consent-preferences')
document.documentElement.classList.remove('modrinth-ads-consent-preferences-visible')
}
function revealAdsConsentPreferences() {
document.documentElement.classList.add('modrinth-ads-consent-preferences-visible')
}
function concealAdsConsentPreferences() {
document.documentElement.classList.remove('modrinth-ads-consent-preferences')
document.documentElement.classList.remove('modrinth-ads-consent-preferences-visible')
installAdsConsentOverlayStyle()
}
async function expandAdsConsentWebview() {
const invoke = getTauriInvoke()
if (typeof invoke !== 'function') {
throw new Error('Tauri invoke is unavailable in the ads webview')
}
await invoke('plugin:ads|show_ads_consent_preferences')
}
function sendAdsConsentCommandToChildFrames(command) {
document.querySelectorAll('iframe').forEach((frame) => {
frame.contentWindow?.postMessage({ modrinthAdsConsentCommand: command }, '*')
})
}
function isDirectChildFrame(source) {
return Array.from(document.querySelectorAll('iframe')).some(
(frame) => frame.contentWindow === source,
)
}
function displayUspConsentUi() {
if (typeof window.__uspapi !== 'function') return false
try {
window.__uspapi('displayUspUi', 1, () => {})
return true
} catch {
return false
}
}
function detectAdsConsentVariant() {
let variant = null
if (document.getElementById('qc-cmp2-usp')) {
variant = 'usp'
} else if (document.getElementById('qc-cmp2-ui')) {
variant = 'tcf'
}
if (variant) {
modrinthAdsConsentState.variant = variant
}
return variant
}
function findTcfConsentButton(action) {
const dialog = document.getElementById('qc-cmp2-ui')
if (!dialog) return null
const buttonIds = {
accept: 'accept-btn',
reject: 'disagree-btn',
manage: 'more-options-btn',
}
const buttonId = buttonIds[action]
if (!buttonId) return null
const button = dialog.querySelector(`#${buttonId}`)
return button && !button.disabled ? button : null
}
function getUspConsentControls() {
const dialog = document.getElementById('qc-cmp2-usp')
if (!dialog) return null
const toggles = Array.from(
dialog.querySelectorAll('.qc-usp-container button.qc-cmp2-toggle[role="switch"]'),
)
const confirmButton = dialog.querySelector('.qc-usp-ui-form-content button[mode="primary"]')
if (
toggles.length === 0 ||
!confirmButton ||
confirmButton.disabled ||
toggles.some(
(toggle) =>
toggle.disabled || !['true', 'false'].includes(toggle.getAttribute('aria-checked')),
)
) {
return null
}
return { toggles, confirmButton }
}
function waitForUspToggleState(index, checked, expectedCount, deadline) {
return new Promise((resolve) => {
function checkState() {
const controls = getUspConsentControls()
if (
controls &&
controls.toggles.length === expectedCount &&
controls.toggles[index]?.getAttribute('aria-checked') === String(checked)
) {
resolve(controls)
} else if (Date.now() >= deadline) {
resolve(null)
} else {
setTimeout(checkState, 50)
}
}
checkState()
})
}
async function setUspToggleStates(checked, controls, timeoutMs) {
const expectedCount = controls.toggles.length
const deadline = Date.now() + timeoutMs
for (let index = 0; index < expectedCount; index += 1) {
controls = getUspConsentControls()
if (!controls || controls.toggles.length !== expectedCount) return null
const toggle = controls.toggles[index]
if (toggle.getAttribute('aria-checked') !== String(checked)) {
toggle.click()
controls = await waitForUspToggleState(index, checked, expectedCount, deadline)
if (!controls) return null
}
}
return controls
}
async function performAdsConsentActionInDocument(action, onHandled) {
const variant = detectAdsConsentVariant()
const unknownDialog = document.querySelector('#qc-cmp2-container [role="dialog"]')
if (action === 'show') {
if (variant || unknownDialog) {
onHandled?.()
return 'handled'
}
return 'not-ready'
}
if (variant === 'usp') {
if (action === 'manage') {
onHandled?.()
return 'handled'
}
if (!['accept', 'reject'].includes(action)) return 'failed'
const controls = getUspConsentControls()
if (!controls) return 'not-ready'
const shouldOptOut = action === 'reject'
const settledControls = await setUspToggleStates(shouldOptOut, controls, 2000)
if (!settledControls) return 'failed'
// CMP navigation can replace this document during the click, so acknowledge it first.
onHandled?.()
beginUspConsentCommit()
settledControls.confirmButton.click()
return 'handled'
}
if (variant === 'tcf') {
const button = findTcfConsentButton(action)
if (!button) return 'not-ready'
// CMP navigation can replace this document during the click, so acknowledge it first.
onHandled?.()
button.click()
return 'handled'
}
if (action === 'manage' && unknownDialog) {
onHandled?.()
return 'handled'
}
return 'not-ready'
}
function performAdsConsentActionWhenReady(action, timeoutMs, onHandled) {
const deadline = Date.now() + timeoutMs
return new Promise((resolve) => {
async function tryAction() {
const result = await performAdsConsentActionInDocument(action, onHandled)
if (result === 'handled') {
resolve(true)
} else if (result === 'failed' || Date.now() >= deadline) {
resolve(false)
} else {
setTimeout(tryAction, 50)
}
}
void tryAction()
})
}
function performAdsConsentActionAcrossFrames(action, timeoutMs) {
const requestId = `${Date.now()}-${++modrinthAdsConsentActionRequestId}`
return new Promise((resolve) => {
let settled = false
const settle = (clicked) => {
if (settled) return
settled = true
clearTimeout(timeout)
modrinthAdsConsentActionResolvers.delete(requestId)
resolve(clicked)
}
const timeout = setTimeout(() => settle(false), timeoutMs)
modrinthAdsConsentActionResolvers.set(requestId, () => settle(true))
sendAdsConsentCommandToChildFrames({ type: 'perform', action, requestId, timeoutMs })
void performAdsConsentActionWhenReady(action, timeoutMs, () => settle(true))
})
}
function waitForAdsConsentLayout() {
return new Promise((resolve) => setTimeout(resolve, 100))
}
async function restoreAdsConsentNotification() {
concealAdsConsentPreferences()
sendAdsConsentCommandToChildFrames({ type: 'conceal' })
const invoke = getTauriInvoke()
if (typeof invoke === 'function') {
await invoke('plugin:ads|hide_ads_consent_preferences')
}
}
async function showNativeAdsConsentUi() {
prepareAdsConsentPreferences()
await waitForAdsConsentLayout()
sendAdsConsentCommandToChildFrames({ type: 'prepare' })
await expandAdsConsentWebview()
await waitForAdsConsentLayout()
revealAdsConsentPreferences()
sendAdsConsentCommandToChildFrames({ type: 'reveal' })
window.dispatchEvent(new Event('resize'))
sendAdsConsentCommandToChildFrames({ type: 'resize' })
const shown = await performAdsConsentActionAcrossFrames('show', 2500)
if (!shown) {
await restoreAdsConsentNotification()
}
return shown
}
function finishAdsConsentReprompt() {
transitionAdsConsent('completed')
clearTimeout(modrinthAdsConsentState.commitTimeout)
modrinthAdsConsentState.commitTimeout = null
document.documentElement.classList.remove('modrinth-ads-consent-overlay')
concealAdsConsentPreferences()
sendAdsConsentCommandToChildFrames({ type: 'conceal' })
invokeAdsConsentOverlayCommand(false)
}
async function openAdsConsentPreferences() {
if (!(await showNativeAdsConsentUi())) return
await performAdsConsentActionAcrossFrames('manage', 2500)
}
async function performAdsConsentAction(action) {
if (!['accept', 'reject', 'manage'].includes(action)) return
if (action === 'manage') {
try {
await openAdsConsentPreferences()
} catch {
await restoreAdsConsentNotification()
}
return
}
const handled = await performAdsConsentActionAcrossFrames(action, 2500)
if (!handled) {
try {
await showNativeAdsConsentUi()
} catch {
await restoreAdsConsentNotification()
}
}
}
window.modrinthAdsConsentAction = (action) => {
void performAdsConsentAction(action)
}
function isUspConsentApplicable() {
if (detectAdsConsentVariant() === 'usp') return Promise.resolve(true)
if (typeof window.__uspapi !== 'function') return Promise.resolve(false)
return new Promise((resolve) => {
let settled = false
const settle = (applicable) => {
if (settled) return
settled = true
clearTimeout(timeout)
resolve(applicable)
}
const timeout = setTimeout(() => settle(false), 500)
try {
window.__uspapi('uspPing', 1, (data, success) => {
if (!success || !data) {
settle(false)
return
}
const modes = Array.isArray(data.mode) ? data.mode : [data.mode]
const jurisdictions = Array.isArray(data.jurisdiction)
? data.jurisdiction
: [data.jurisdiction]
const location = String(data.location ?? '').toUpperCase()
const hasUspMode = modes.some((mode) =>
String(mode ?? '')
.toUpperCase()
.includes('USP'),
)
const locationApplies =
!location ||
jurisdictions.some((jurisdiction) =>
String(jurisdiction ?? '')
.toUpperCase()
.includes(location),
)
settle(hasUspMode && locationApplies)
})
} catch {
settle(false)
}
})
}
async function displayAdsConsentReprompt() {
if (
(modrinthAdsConsentState.variant === 'usp' || (await isUspConsentApplicable())) &&
typeof window.__uspapi === 'function'
) {
modrinthAdsConsentState.variant = 'usp'
return displayUspConsentUi()
}
if (typeof window.__tcfapi === 'function') {
modrinthAdsConsentState.variant = 'tcf'
window.__tcfapi('displayConsentUi', 2, () => {})
return true
}
return false
}
window.modrinthAdsReopenConsentPreferences = async () => {
if (document.documentElement.classList.contains('modrinth-ads-consent-overlay')) {
try {
await openAdsConsentPreferences()
} catch {
await restoreAdsConsentNotification()
}
return
}
transitionAdsConsent('reprompt-started')
prepareAdsConsentPreferences()
sendAdsConsentCommandToChildFrames({ type: 'prepare' })
try {
await waitForAdsConsentLayout()
await expandAdsConsentWebview()
await waitForAdsConsentLayout()
revealAdsConsentPreferences()
sendAdsConsentCommandToChildFrames({ type: 'reveal' })
window.dispatchEvent(new Event('resize'))
sendAdsConsentCommandToChildFrames({ type: 'resize' })
if (!(await displayAdsConsentReprompt())) {
finishAdsConsentReprompt()
return
}
if (!(await performAdsConsentActionAcrossFrames('show', 2500))) {
finishAdsConsentReprompt()
return
}
await performAdsConsentActionAcrossFrames('manage', 2500)
} catch {
finishAdsConsentReprompt()
}
}
window.addEventListener('message', (event) => {
const resultRequestId = event.data?.modrinthAdsConsentResult
if (typeof resultRequestId === 'string' && isDirectChildFrame(event.source)) {
if (window.top === window) {
modrinthAdsConsentActionResolvers.get(resultRequestId)?.()
} else {
window.parent.postMessage({ modrinthAdsConsentResult: resultRequestId }, '*')
}
return
}
if (window.top === window || event.source !== window.parent) return
const command = event.data?.modrinthAdsConsentCommand
if (!command || typeof command !== 'object') return
if (command.type === 'prepare') {
prepareAdsConsentPreferences()
sendAdsConsentCommandToChildFrames(command)
} else if (command.type === 'reveal') {
revealAdsConsentPreferences()
sendAdsConsentCommandToChildFrames(command)
} else if (command.type === 'conceal') {
concealAdsConsentPreferences()
sendAdsConsentCommandToChildFrames(command)
} else if (command.type === 'resize') {
window.dispatchEvent(new Event('resize'))
sendAdsConsentCommandToChildFrames(command)
} else if (
command.type === 'perform' &&
typeof command.action === 'string' &&
typeof command.requestId === 'string' &&
typeof command.timeoutMs === 'number'
) {
sendAdsConsentCommandToChildFrames(command)
performAdsConsentActionWhenReady(command.action, command.timeoutMs, () => {
window.parent.postMessage({ modrinthAdsConsentResult: command.requestId }, '*')
})
}
})
function setAdsConsentOverlay(shown) {
if (modrinthAdsConsentOverlayShown === shown) return
if (document.documentElement.classList.contains('modrinth-ads-consent-overlay') === shown) return
modrinthAdsConsentOverlayShown = shown
installAdsConsentOverlayStyle()
document.documentElement.classList.toggle('modrinth-ads-consent-overlay', shown)
if (!shown) {
document.documentElement.classList.remove('modrinth-ads-consent-preferences')
document.documentElement.classList.remove('modrinth-ads-consent-preferences-visible')
}
if (window.top === window) {
invokeAdsConsentOverlayCommand(shown)
@@ -79,21 +745,111 @@ function setAdsConsentOverlay(shown) {
if (window.top === window) {
window.addEventListener('message', (event) => {
if (
event.origin === MODRINTH_ORIGIN &&
typeof event.data?.modrinthAdsConsentOverlay === 'boolean'
) {
if (event.origin !== MODRINTH_ORIGIN) return
if (typeof event.data?.modrinthAdsConsentOverlay === 'boolean') {
setAdsConsentOverlay(event.data.modrinthAdsConsentOverlay)
}
})
}
function finishUspConsentCommit() {
if (!isUspConsentCommitPending()) return
const wasReprompt = isAdsConsentReprompt()
transitionAdsConsent('completed')
clearTimeout(modrinthAdsConsentState.commitTimeout)
modrinthAdsConsentState.commitTimeout = null
if (wasReprompt) {
finishAdsConsentReprompt()
} else {
setAdsConsentOverlay(false)
}
}
function beginUspConsentCommit() {
if (!document.getElementById('qc-cmp2-usp')) return
modrinthAdsConsentState.variant = 'usp'
transitionAdsConsent('commit-started')
clearTimeout(modrinthAdsConsentState.commitTimeout)
const deadline = Date.now() + 2500
function checkForDialogClosure() {
if (!isUspConsentCommitPending()) return
if (!document.getElementById('qc-cmp2-usp')) {
finishUspConsentCommit()
} else if (Date.now() >= deadline) {
transitionAdsConsent('commit-timed-out')
modrinthAdsConsentState.commitTimeout = null
} else {
modrinthAdsConsentState.commitTimeout = setTimeout(checkForDialogClosure, 50)
}
}
modrinthAdsConsentState.commitTimeout = setTimeout(checkForDialogClosure, 50)
}
function syncAdsConsentUi() {
const variant = detectAdsConsentVariant()
if (variant && !isAdsConsentReprompt() && modrinthAdsConsentState.phase !== 'complete') {
transitionAdsConsent('prompt-detected')
setAdsConsentOverlay(true)
}
if (isUspConsentCommitPending() && !document.getElementById('qc-cmp2-usp')) {
finishUspConsentCommit()
}
}
function handleGppConsentEvent(gppData, success) {
if (
success &&
gppData?.eventName === 'sectionChange' &&
modrinthAdsConsentState.variant === 'usp' &&
isUspConsentCommitPending()
) {
finishUspConsentCommit()
}
}
function installGppConsentListener() {
if (modrinthGppListenerInstalled) return
if (typeof window.__gpp === 'function') {
modrinthGppListenerInstalled = true
window.__gpp('addEventListener', handleGppConsentEvent)
return
}
if (modrinthGppListenerAttempts < 60) {
modrinthGppListenerAttempts += 1
setTimeout(installGppConsentListener, 500)
}
}
function handleTcfConsentEvent(tcData, success) {
if (!success || !tcData) return
detectAdsConsentVariant()
if (tcData.eventStatus === 'cmpuishown') {
if (isAdsConsentReprompt()) return
transitionAdsConsent('prompt-detected')
setAdsConsentOverlay(true)
} else if (tcData.eventStatus === 'useractioncomplete' || tcData.eventStatus === 'tcloaded') {
} else if (
tcData.eventStatus === 'useractioncomplete' &&
modrinthAdsConsentState.variant === 'tcf'
) {
if (isAdsConsentReprompt()) {
finishAdsConsentReprompt()
return
}
transitionAdsConsent('completed')
setAdsConsentOverlay(false)
}
}
@@ -190,13 +946,22 @@ function muteVideos() {
}
document.addEventListener('DOMContentLoaded', () => {
installAdsConsentThemeStyle()
installAdsRailStyle()
installAdsConsentOverlayStyle()
muteVideos()
muteAudioContext()
syncAdsConsentUi()
installTcfConsentListener()
installGppConsentListener()
const observer = new MutationObserver(muteVideos)
const observer = new MutationObserver(() => {
muteVideos()
syncAdsConsentUi()
})
observer.observe(document.body, { childList: true, subtree: true })
})
syncAdsConsentUi()
installTcfConsentListener()
installGppConsentListener()
+177 -10
View File
@@ -3,7 +3,7 @@ use std::sync::Arc;
use std::sync::atomic::{AtomicBool, Ordering};
use std::time::{Duration, Instant};
use tauri::plugin::TauriPlugin;
use tauri::{Manager, PhysicalPosition, PhysicalSize, Runtime};
use tauri::{Emitter, Manager, PhysicalPosition, PhysicalSize, Rect, Runtime};
use tauri_plugin_opener::OpenerExt;
use theseus::settings;
use tokio::sync::RwLock;
@@ -11,6 +11,7 @@ use tokio::sync::RwLock;
pub struct AdsState {
pub shown: bool,
pub modal_shown: bool,
pub consent_required: bool,
pub consent_overlay_shown: bool,
pub occluded: bool,
pub last_click: Option<Instant>,
@@ -18,6 +19,7 @@ pub struct AdsState {
}
const AD_LINK: &str = "https://modrinth.com/wrapper/app-ads-cookie";
const ADS_CONSENT_REQUIRED_EVENT: &str = "ads-consent-required";
const APP_TITLE_BAR_HEIGHT: f32 = 48.0;
#[cfg(any(windows, target_os = "macos"))]
pub(super) const OCCLUDED_AREA_THRESHOLD: f64 = 0.5;
@@ -275,6 +277,7 @@ pub fn init<R: Runtime>() -> TauriPlugin<R> {
app.manage(RwLock::new(AdsState {
shown: true,
modal_shown: false,
consent_required: false,
consent_overlay_shown: false,
occluded: false,
last_click: None,
@@ -293,6 +296,7 @@ pub fn init<R: Runtime>() -> TauriPlugin<R> {
.map(|state| {
state.shown
&& !state.modal_shown
&& !state.consent_required
&& !state.consent_overlay_shown
&& !state.occluded
})
@@ -358,7 +362,12 @@ pub fn init<R: Runtime>() -> TauriPlugin<R> {
hide_ads_window,
show_ads_window,
show_ads_consent_overlay,
show_ads_consent_preferences,
open_ads_consent_preferences,
hide_ads_consent_preferences,
hide_ads_consent_overlay,
get_ads_consent_required,
perform_ads_consent_action,
record_ads_click,
open_link,
get_ads_personalization,
@@ -635,6 +644,10 @@ pub async fn init_ads_window<R: Runtime>(
// });
}
if state.shown && state.consent_required {
app.emit_to("main", ADS_CONSENT_REQUIRED_EVENT, true).ok();
}
Ok(())
}
@@ -648,6 +661,8 @@ pub async fn show_ads_window<R: Runtime>(
app: tauri::AppHandle<R>,
dpr: f32,
) -> crate::api::Result<()> {
let mut consent_required = false;
if let Some(webview) = app.webviews().get("ads-window") {
let state = app.state::<RwLock<AdsState>>();
let mut state = state.write().await;
@@ -667,6 +682,12 @@ pub async fn show_ads_window<R: Runtime>(
webview.show().ok();
set_webview_visible_for_window(&app, webview, true);
}
consent_required = state.shown && state.consent_required;
}
if consent_required {
app.emit_to("main", ADS_CONSENT_REQUIRED_EVENT, true).ok();
}
Ok(())
@@ -677,14 +698,17 @@ pub async fn hide_ads_window<R: Runtime>(
app: tauri::AppHandle<R>,
reset: Option<bool>,
) -> crate::api::Result<()> {
if let Some(webview) = app.webviews().get("ads-window") {
let state = app.state::<RwLock<AdsState>>();
let mut state = state.write().await;
let reset = reset.unwrap_or(false);
let state = app.state::<RwLock<AdsState>>();
let mut state = state.write().await;
if reset.unwrap_or(false) {
state.shown = false;
state.consent_overlay_shown = false;
} else {
if reset {
state.shown = false;
state.consent_overlay_shown = false;
}
if let Some(webview) = app.webviews().get("ads-window") {
if !reset {
state.modal_shown = true;
if state.consent_overlay_shown {
@@ -706,6 +730,10 @@ pub async fn hide_ads_window<R: Runtime>(
webview.hide().ok();
}
if reset {
app.emit_to("main", ADS_CONSENT_REQUIRED_EVENT, false).ok();
}
Ok(())
}
@@ -722,11 +750,45 @@ pub async fn show_ads_consent_overlay<R: Runtime>(
return Ok(());
}
state.consent_required = true;
state.consent_overlay_shown = false;
if !state.modal_shown {
let dpr = get_device_pixel_ratio(&app, None);
let (position, size) = get_webview_position(&app, dpr)?;
webview.set_size(size).ok();
webview.set_position(position).ok();
webview.show().ok();
set_webview_visible_for_window(&app, webview, true);
}
}
app.emit_to("main", ADS_CONSENT_REQUIRED_EVENT, true).ok();
Ok(())
}
#[tauri::command]
pub async fn show_ads_consent_preferences<R: Runtime>(
app: tauri::AppHandle<R>,
) -> crate::api::Result<()> {
if let Some(webview) = app.webviews().get("ads-window") {
let state = app.state::<RwLock<AdsState>>();
let mut state = state.write().await;
if !state.consent_required {
return Ok(());
}
state.consent_overlay_shown = true;
let (position, size) = get_overlay_webview_position(&app)?;
webview.set_size(size).ok();
webview.set_position(position).ok();
webview
.set_bounds(Rect {
position: position.into(),
size: size.into(),
})
.ok();
webview.show().ok();
set_webview_visible_for_window(&app, webview, true);
}
@@ -734,6 +796,62 @@ pub async fn show_ads_consent_overlay<R: Runtime>(
Ok(())
}
#[tauri::command]
pub async fn open_ads_consent_preferences<R: Runtime>(
app: tauri::AppHandle<R>,
) -> crate::api::Result<()> {
let Some(webview) = app.webviews().get("ads-window").cloned() else {
return Ok(());
};
{
let state = app.state::<RwLock<AdsState>>();
let mut state = state.write().await;
state.consent_required = true;
state.consent_overlay_shown = false;
}
webview.eval("window.modrinthAdsReopenConsentPreferences?.()")?;
Ok(())
}
/// Restores the ad inventory bounds without resolving the pending consent request.
#[tauri::command]
pub async fn hide_ads_consent_preferences<R: Runtime>(
app: tauri::AppHandle<R>,
) -> crate::api::Result<()> {
if let Some(webview) = app.webviews().get("ads-window") {
let state = app.state::<RwLock<AdsState>>();
let mut state = state.write().await;
state.consent_overlay_shown = false;
if state.shown && !state.modal_shown {
let dpr = get_device_pixel_ratio(&app, None);
let (position, size) = get_webview_position(&app, dpr)?;
webview
.set_bounds(Rect {
position: position.into(),
size: size.into(),
})
.ok();
webview.show().ok();
set_webview_visible_for_window(&app, webview, true);
} else {
webview
.set_position(PhysicalPosition::new(-1000, -1000))
.ok();
webview.hide().ok();
}
}
app.emit_to("main", ADS_CONSENT_REQUIRED_EVENT, true).ok();
Ok(())
}
#[tauri::command]
pub async fn hide_ads_consent_overlay<R: Runtime>(
app: tauri::AppHandle<R>,
@@ -742,7 +860,9 @@ pub async fn hide_ads_consent_overlay<R: Runtime>(
if let Some(webview) = app.webviews().get("ads-window") {
let state = app.state::<RwLock<AdsState>>();
let mut state = state.write().await;
let should_reload_ads = state.consent_required;
state.consent_required = false;
state.consent_overlay_shown = false;
if state.shown && !state.modal_shown {
@@ -759,6 +879,53 @@ pub async fn hide_ads_consent_overlay<R: Runtime>(
.ok();
webview.hide().ok();
}
drop(state);
if should_reload_ads {
webview.navigate(AD_LINK.parse().unwrap()).ok();
}
}
app.emit_to("main", ADS_CONSENT_REQUIRED_EVENT, false).ok();
Ok(())
}
#[tauri::command]
pub async fn get_ads_consent_required<R: Runtime>(
app: tauri::AppHandle<R>,
) -> crate::api::Result<bool> {
let state = app.state::<RwLock<AdsState>>();
let state = state.read().await;
Ok(state.shown && state.consent_required)
}
#[tauri::command]
pub async fn perform_ads_consent_action<R: Runtime>(
app: tauri::AppHandle<R>,
action: String,
) -> crate::api::Result<()> {
let script = match action.as_str() {
"accept" => "window.modrinthAdsConsentAction?.('accept')",
"reject" => "window.modrinthAdsConsentAction?.('reject')",
"manage" => "window.modrinthAdsConsentAction?.('manage')",
_ => return Ok(()),
};
let state = app.state::<RwLock<AdsState>>();
let should_perform = {
let state = state.read().await;
state.shown && state.consent_required
};
if !should_perform {
return Ok(());
}
if let Some(webview) = app.webviews().get("ads-window") {
webview.eval(script)?;
}
Ok(())
+6
View File
@@ -41,6 +41,7 @@ pub fn init<R: tauri::Runtime>() -> tauri::plugin::TauriPlugin<R> {
instance_install_project_with_dependencies,
instance_switch_project_version_with_dependencies,
instance_add_project_from_path,
instance_is_file_on_modrinth,
instance_toggle_disable_project,
instance_remove_project,
instance_update_managed_modrinth_version,
@@ -633,6 +634,11 @@ pub async fn instance_add_project_from_path(
.await?)
}
#[tauri::command]
pub async fn instance_is_file_on_modrinth(project_path: &Path) -> Result<bool> {
Ok(theseus::instance::is_file_on_modrinth(project_path).await?)
}
#[tauri::command]
pub async fn instance_toggle_disable_project(
instance_id: &str,
+2
View File
@@ -5,6 +5,7 @@
<LoadingBar />
</ClientOnly>
<NotificationPanel />
<AdsConsentNotification />
<I18nDebugPanel />
<NuxtPage />
</NuxtLayout>
@@ -12,6 +13,7 @@
<script setup lang="ts">
import { I18nDebugPanel, LoadingBar, NotificationPanel } from '@modrinth/ui'
import AdsConsentNotification from '~/components/ui/AdsConsentNotification.vue'
import { setupProviders } from '~/providers/setup.ts'
import { useAuth } from './composables/auth'
+106
View File
@@ -487,3 +487,109 @@ input {
background-color: transparent;
color: hsl(145, 78%, 28%);
}
#qc-cmp2-usp {
background: var(--color-raised-bg) !important;
border: 1px solid var(--color-divider) !important;
border-radius: var(--radius-lg) !important;
box-shadow: var(--shadow-floating) !important;
color: var(--color-base) !important;
font-family: var(--font-standard) !important;
max-width: 660px;
.qc-usp-ui-content,
.qc-usp-ui-form-content,
.qc-usp-container {
background: transparent !important;
}
p,
label,
.qc-usp-action-description {
color: var(--color-base) !important;
font-family: var(--font-standard) !important;
}
.qc-usp-title,
.qc-cmp2-list-item-title {
color: var(--color-contrast) !important;
font-family: var(--font-standard) !important;
font-weight: 700 !important;
}
.qc-usp-title {
font-size: 1.25rem !important;
}
a,
.qc-usp-alt-action {
color: var(--color-link) !important;
}
.qc-cmp2-list-item {
background: var(--color-button-bg) !important;
border: 1px solid var(--color-divider) !important;
border-radius: var(--radius-md) !important;
}
.qc-cmp2-list-item-header {
background: transparent !important;
border: 0 !important;
color: var(--color-contrast) !important;
}
.qc-cmp2-list-item-header svg {
color: var(--color-base) !important;
}
.qc-cmp2-toggle {
background: var(--surface-3) !important;
border-color: var(--color-divider) !important;
}
.qc-cmp2-toggle .toggle {
background: var(--color-contrast) !important;
}
.qc-cmp2-toggle .text {
color: var(--color-contrast) !important;
}
.qc-cmp2-toggle[aria-checked='true'] {
background: var(--color-brand) !important;
border-color: var(--color-brand) !important;
}
.qc-cmp2-toggle[aria-checked='true'] .text {
color: var(--color-accent-contrast) !important;
}
button[mode='primary'] {
background: var(--color-brand) !important;
border: 0 !important;
border-radius: var(--radius-md) !important;
color: var(--color-accent-contrast) !important;
font-family: var(--font-standard) !important;
font-weight: 700 !important;
}
.qc-usp-close-icon {
border: 0 !important;
background: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='white' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M18 6 6 18'/%3E%3Cpath d='m6 6 12 12'/%3E%3C/svg%3E")
center / 1.5rem 1.5rem no-repeat;
}
a:focus-visible,
button:focus-visible {
outline: 2px solid var(--color-brand) !important;
outline-offset: 2px !important;
}
.qc-usp-ui-content {
max-width: 100% !important;
}
.qc-usp-ui-content .qc-usp-ui-form-content {
border: 1px solid transparent !important;
padding: 0 !important;
}
}
@@ -775,19 +775,22 @@ function suppressUpcomingChartClick() {
}, 350)
}
function clearUnpinnedTouchInteraction(ignoreClick: boolean) {
function clearUnpinnedTouchInteraction(ignoreClick: boolean, pinHover = false) {
if (props.pinnedSliceIndex !== null) return
if (ignoreClick) {
suppressUpcomingChartClick()
}
if (pinHover) {
emit('touch-drag')
}
clearChartActiveState()
updateChartWithoutGeometry()
if (ignoreClick) {
suppressUpcomingChartClick()
emit('touch-drag')
return
if (!pinHover) {
emit('hover', { visible: false, x: 0, y: 0, sliceIndex: null })
}
emit('hover', { visible: false, x: 0, y: 0, sliceIndex: null })
}
function handleCanvasClickCapture(event: MouseEvent) {
@@ -918,7 +921,7 @@ function handleRangePointerEnd(event: PointerEvent) {
if (isRangeSelecting && rangeSelectPointerType === 'touch') {
event.preventDefault()
clearUnpinnedTouchInteraction(true)
clearUnpinnedTouchInteraction(true, true)
} else if (isRangeSelecting && startSliceIndex !== null && endSliceIndex !== null) {
event.preventDefault()
emit('range-select', { startSliceIndex, endSliceIndex })
@@ -110,6 +110,8 @@ export function useAnalyticsChartInteractions({
function onTouchDragEnd() {
ignoreUpcomingChartClick()
if (!hoverState.visible || hoverState.sliceIndex === null) return
isHoverPinned.value = true
}
function onChartGeometry(payload: AnalyticsChartGeometryPayload) {
@@ -213,6 +213,10 @@ export const analyticsMessages = defineMessages({
id: 'analytics.filter.game-version-type.all',
defaultMessage: 'All',
},
dependentProjectSearchFailedTitle: {
id: 'analytics.filter.dependent-project-search-failed-title',
defaultMessage: 'Dependent projects failed to load',
},
})
export const analyticsStatMessages = defineMessages({
@@ -492,7 +492,7 @@ watch(dependentProjectSearchError, (error) => {
if (!error) return
addNotification({
title: 'Dependent projects failed to load',
title: formatMessage(analyticsMessages.dependentProjectSearchFailedTitle),
text: getDependentProjectSearchErrorMessage(error),
type: 'error',
})
@@ -0,0 +1,603 @@
<script setup lang="ts">
import {
defineMessages,
injectNotificationManager,
useVIntl,
type WebNotification,
} from '@modrinth/ui'
import { onBeforeUnmount, onMounted } from 'vue'
type ConsentAction = 'accept' | 'reject' | 'manage'
type ConsentVariant = 'tcf' | 'usp'
interface TcfData {
eventStatus?: string
listenerId?: number
}
type TcfCallback = (data: TcfData, success: boolean) => void
type TcfApi = (command: string, version: number, callback: TcfCallback, parameter?: unknown) => void
interface UspControls {
toggles: HTMLButtonElement[]
confirmButton: HTMLButtonElement
}
interface GppData {
eventName?: string
listenerId?: number
}
type GppCallback = (data: GppData, success: boolean) => void
type GppApi = (command: string, callback: GppCallback, parameter?: unknown) => void
const CMP_HIDDEN_CLASS = 'modrinth-cmp-summary-hidden'
const notificationManager = injectNotificationManager()
const { formatMessage } = useVIntl()
const messages = defineMessages({
title: {
id: 'ads-consent.title',
defaultMessage: 'Your privacy and how ads support Modrinth',
},
body: {
id: 'ads-consent.body',
defaultMessage:
'Ads make Modrinth possible and fund creator payouts. Our partners may store or access cookies on the website to personalize ads and measure performance.',
},
manage: {
id: 'ads-consent.manage',
defaultMessage: 'Manage preferences',
},
reject: {
id: 'ads-consent.reject',
defaultMessage: 'Reject all',
},
accept: {
id: 'ads-consent.accept',
defaultMessage: 'Accept all',
},
})
let notificationId: WebNotification['id'] | null = null
let tcfListenerId: number | undefined
let gppListenerId: number | undefined
let listenerInstalled = false
let gppListenerInstalled = false
let gppListenerAttempts = 0
let gppInstallTimeout: ReturnType<typeof setTimeout> | undefined
let managingPreferences = false
let consentComplete = false
let consentVariant: ConsentVariant | null = null
let uspConsentCommitPending = false
let uspSuccessModalDismissed = false
let uspCommitTimeout: ReturnType<typeof setTimeout> | undefined
let consentContainerObserver: MutationObserver | undefined
const consentContainerContains = new Map<HTMLElement, HTMLElement['contains']>()
function getTcfApi(): TcfApi | undefined {
return (window as typeof window & { __tcfapi?: TcfApi }).__tcfapi
}
function getGppApi(): GppApi | undefined {
return (window as typeof window & { __gpp?: GppApi }).__gpp
}
function setConsentUiHidden(hidden: boolean) {
document.documentElement.classList.toggle(CMP_HIDDEN_CLASS, hidden)
patchConsentFocusTrapContainer()
}
function patchConsentFocusTrapContainer() {
const containers = document.querySelectorAll<HTMLElement>('#qc-cmp2-ui, #qc-cmp2-usp')
for (const container of containers) {
if (consentContainerContains.has(container)) continue
const originalContains = container.contains
// InMobi's focus trap otherwise cancels clicks outside its hidden container.
container.contains = (node: Node | null) =>
document.documentElement.classList.contains(CMP_HIDDEN_CLASS) ||
originalContains.call(container, node)
consentContainerContains.set(container, originalContains)
}
}
function detectConsentVariant(): ConsentVariant | null {
let variant: ConsentVariant | null = null
if (document.getElementById('qc-cmp2-usp')) {
variant = 'usp'
} else if (document.getElementById('qc-cmp2-ui')) {
variant = 'tcf'
}
if (variant) consentVariant = variant
return variant
}
function getConsentContainers(): ParentNode[] {
const containers = Array.from(
document.querySelectorAll<HTMLElement>(
'#qc-cmp2-container, #qc-cmp2-main, #qc-cmp2-ui, #qc-cmp2-usp',
),
)
return containers.length > 0 ? containers : [document]
}
function getUspConsentControls(): UspControls | null {
const dialog = document.getElementById('qc-cmp2-usp')
if (!dialog) return null
const toggles = Array.from(
dialog.querySelectorAll<HTMLButtonElement>(
'.qc-usp-container button.qc-cmp2-toggle[role="switch"]',
),
)
const confirmButton = dialog.querySelector<HTMLButtonElement>(
'.qc-usp-ui-form-content button[mode="primary"]',
)
if (
toggles.length === 0 ||
!confirmButton ||
confirmButton.disabled ||
toggles.some(
(toggle) =>
toggle.disabled || !['true', 'false'].includes(toggle.getAttribute('aria-checked') ?? ''),
)
) {
return null
}
return { toggles, confirmButton }
}
function waitForUspToggleState(
index: number,
checked: boolean,
expectedCount: number,
deadline: number,
): Promise<UspControls | null> {
return new Promise((resolve) => {
function checkState() {
const controls = getUspConsentControls()
if (
controls &&
controls.toggles.length === expectedCount &&
controls.toggles[index]?.getAttribute('aria-checked') === String(checked)
) {
resolve(controls)
} else if (Date.now() >= deadline) {
resolve(null)
} else {
setTimeout(checkState, 50)
}
}
checkState()
})
}
async function setUspToggleStates(
checked: boolean,
controls: UspControls,
timeoutMs: number,
): Promise<UspControls | null> {
const expectedCount = controls.toggles.length
const deadline = Date.now() + timeoutMs
for (let index = 0; index < expectedCount; index += 1) {
const currentControls = getUspConsentControls()
if (!currentControls || currentControls.toggles.length !== expectedCount) return null
controls = currentControls
const toggle = controls.toggles[index]
if (toggle.getAttribute('aria-checked') !== String(checked)) {
toggle.click()
const settledControls = await waitForUspToggleState(index, checked, expectedCount, deadline)
if (!settledControls) return null
controls = settledControls
}
}
return controls
}
function matchesButtonText(button: HTMLButtonElement, terms: string[]): boolean {
const text = [button.textContent, button.getAttribute('aria-label')]
.filter(Boolean)
.join(' ')
.trim()
.toLowerCase()
return terms.some((term) => text.includes(term))
}
function findConsentButton(action: ConsentAction): HTMLButtonElement | null {
const containers = getConsentContainers()
const summaryButtons = containers.flatMap((container) =>
Array.from(container.querySelectorAll<HTMLButtonElement>('.qc-cmp2-summary-buttons button')),
)
const queryButton = (selector: string) =>
containers
.map((container) => container.querySelector<HTMLButtonElement>(selector))
.find((button) => button && !button.disabled) ?? null
if (action === 'accept') {
const explicitAcceptButton = queryButton(
'[data-testid="accept-all"], [data-testid="agree-button"], #accept-btn',
)
if (explicitAcceptButton) return explicitAcceptButton
const textMatch = summaryButtons.find((button) =>
matchesButtonText(button, ['accept all', 'agree to all', 'allow all']),
)
if (textMatch) return textMatch
if (summaryButtons.length >= 3 && !summaryButtons[2].disabled) return summaryButtons[2]
return queryButton('.qc-cmp2-summary-buttons button[mode="primary"]')
}
if (action === 'reject') {
const explicitRejectButton = queryButton(
'[data-testid="reject-all"], [data-testid="disagree-button"], #disagree-btn, #reject-btn',
)
if (explicitRejectButton) return explicitRejectButton
const textMatch = summaryButtons.find((button) =>
matchesButtonText(button, ['reject all', 'disagree', 'deny all']),
)
if (textMatch) return textMatch
if (summaryButtons.length >= 3 && !summaryButtons[1].disabled) return summaryButtons[1]
const secondaryButtons = containers.flatMap((container) =>
Array.from(
container.querySelectorAll<HTMLButtonElement>(
'.qc-cmp2-summary-buttons button[mode="secondary"]',
),
),
)
if (secondaryButtons.length > 1 && !secondaryButtons[1].disabled) return secondaryButtons[1]
return null
}
return (
queryButton(
'[data-testid="manage-preferences"], [data-testid="show-options"], .qc-cmp2-summary-buttons > button[mode="secondary"]:first-of-type',
) ??
summaryButtons.find((button) =>
matchesButtonText(button, ['manage', 'preference', 'settings', 'options']),
) ??
summaryButtons.find((button) => !button.disabled) ??
null
)
}
function clickConsentButtonWhenReady(action: ConsentAction, timeoutMs: number): Promise<boolean> {
const deadline = Date.now() + timeoutMs
return new Promise((resolve) => {
function tryClick() {
const button = findConsentButton(action)
if (button) {
button.click()
resolve(true)
} else if (Date.now() >= deadline) {
resolve(false)
} else {
setTimeout(tryClick, 50)
}
}
tryClick()
})
}
async function performConsentAction(action: ConsentAction) {
const variant = detectConsentVariant()
if (action === 'manage') {
managingPreferences = true
setConsentUiHidden(false)
if (variant === 'usp') return
}
if (variant === 'usp' && action !== 'manage') {
const controls = getUspConsentControls()
const settledControls = controls
? await setUspToggleStates(action === 'reject', controls, 2000)
: null
if (settledControls) {
beginUspConsentCommit()
settledControls.confirmButton.click()
return
}
managingPreferences = false
setConsentUiHidden(false)
return
}
const clicked = await clickConsentButtonWhenReady(action, action === 'manage' ? 2500 : 1000)
if (clicked) return
managingPreferences = action === 'manage'
setConsentUiHidden(false)
if (action === 'manage') {
const tcfApi = getTcfApi()
if (tcfApi) {
tcfApi('displayConsentUi', 2, () => {})
}
}
}
function showConsentNotification() {
if (consentComplete) return
setConsentUiHidden(true)
if (
notificationId !== null &&
notificationManager
.getNotifications()
.some((notification) => notification.id === notificationId)
)
return
const notification = notificationManager.addNotification({
title: formatMessage(messages.title),
text: formatMessage(messages.body),
type: 'neutral',
autoCloseMs: null,
dismissible: false,
copyable: false,
noIcon: true,
containerClass: 'py-2 !rounded-2xl',
buttons: [
{
label: formatMessage(messages.manage),
action: () => performConsentAction('manage'),
keepOpen: true,
},
{
label: formatMessage(messages.reject),
action: () => performConsentAction('reject'),
color: 'brand',
keepOpen: true,
},
{
label: formatMessage(messages.accept),
action: () => performConsentAction('accept'),
color: 'brand',
keepOpen: true,
},
],
})
notificationId = notification.id
}
function finishConsent() {
managingPreferences = false
consentComplete = true
uspConsentCommitPending = false
clearTimeout(uspCommitTimeout)
uspCommitTimeout = undefined
setConsentUiHidden(false)
if (notificationId !== null) {
notificationManager.removeNotification(notificationId)
notificationId = null
}
}
function beginUspConsentCommit() {
if (!document.getElementById('qc-cmp2-usp')) return
consentVariant = 'usp'
uspConsentCommitPending = true
uspSuccessModalDismissed = false
clearTimeout(uspCommitTimeout)
const deadline = Date.now() + 2500
function checkForDialogClosure() {
if (!uspConsentCommitPending) return
if (!document.getElementById('qc-cmp2-usp')) {
finishConsent()
} else if (Date.now() >= deadline) {
uspConsentCommitPending = false
uspCommitTimeout = undefined
} else {
uspCommitTimeout = setTimeout(checkForDialogClosure, 50)
}
}
uspCommitTimeout = setTimeout(checkForDialogClosure, 50)
}
function dismissUspSuccessModal() {
if ((!uspConsentCommitPending && !consentComplete) || uspSuccessModalDismissed) return
const closeButton = document.querySelector<HTMLElement>(
'#qc-cmp2-usp [aria-label="Close success modal"]',
)
if (!closeButton || (closeButton instanceof HTMLButtonElement && closeButton.disabled)) return
uspSuccessModalDismissed = true
closeButton.click()
}
function syncConsentUi() {
patchConsentFocusTrapContainer()
installGppConsentListener()
const variant = detectConsentVariant()
dismissUspSuccessModal()
if (
variant === 'usp' &&
!consentComplete &&
!uspConsentCommitPending &&
!managingPreferences &&
getUspConsentControls()
) {
showConsentNotification()
}
if (uspConsentCommitPending && !document.getElementById('qc-cmp2-usp')) {
finishConsent()
}
}
function handleTcfConsentEvent(data: TcfData, success: boolean) {
if (!success) return
detectConsentVariant()
if (data.listenerId !== undefined) {
tcfListenerId = data.listenerId
}
if (data.eventStatus === 'cmpuishown') {
if (!managingPreferences) {
showConsentNotification()
}
} else if (data.eventStatus === 'useractioncomplete' && consentVariant === 'tcf') {
finishConsent()
}
}
function handleGppConsentEvent(data: GppData, success: boolean) {
if (data.listenerId !== undefined) {
gppListenerId = data.listenerId
}
if (
success &&
data.eventName === 'sectionChange' &&
consentVariant === 'usp' &&
uspConsentCommitPending
) {
finishConsent()
}
}
function installGppConsentListener() {
if (gppListenerInstalled) return
const gppApi = getGppApi()
if (!gppApi) {
if (gppListenerAttempts < 60 && gppInstallTimeout === undefined) {
gppListenerAttempts += 1
gppInstallTimeout = setTimeout(() => {
gppInstallTimeout = undefined
installGppConsentListener()
}, 500)
}
return
}
gppListenerInstalled = true
clearTimeout(gppInstallTimeout)
gppInstallTimeout = undefined
gppApi('addEventListener', handleGppConsentEvent)
}
function installTcfConsentListener() {
if (listenerInstalled) return
const tcfApi = getTcfApi()
if (!tcfApi) return
listenerInstalled = true
tcfApi('addEventListener', 2, handleTcfConsentEvent)
}
function restoreConsentNotification() {
setTimeout(() => {
managingPreferences = false
showConsentNotification()
})
}
function handleDocumentClick(event: MouseEvent) {
if (!(event.target instanceof Element)) return
if (event.target.closest('#qc-cmp2-usp .qc-usp-ui-form-content button[mode="primary"]')) {
beginUspConsentCommit()
return
}
if (!managingPreferences) return
if (!event.target.closest('.qc-cmp2-close-icon, .qc-usp-close-icon')) return
restoreConsentNotification()
}
function handleDocumentKeydown(event: KeyboardEvent) {
if (event.key === 'Escape' && managingPreferences) {
restoreConsentNotification()
}
}
onMounted(() => {
consentContainerObserver = new MutationObserver(syncConsentUi)
consentContainerObserver.observe(document.body, {
childList: true,
subtree: true,
attributes: true,
attributeFilter: ['aria-label', 'disabled'],
})
syncConsentUi()
installTcfConsentListener()
installGppConsentListener()
window.addEventListener('modrinth-cmp-ready', installTcfConsentListener)
document.addEventListener('click', handleDocumentClick, true)
document.addEventListener('keydown', handleDocumentKeydown, true)
})
onBeforeUnmount(() => {
consentContainerObserver?.disconnect()
clearTimeout(uspCommitTimeout)
clearTimeout(gppInstallTimeout)
window.removeEventListener('modrinth-cmp-ready', installTcfConsentListener)
document.removeEventListener('click', handleDocumentClick, true)
document.removeEventListener('keydown', handleDocumentKeydown, true)
setConsentUiHidden(false)
for (const [container, originalContains] of consentContainerContains) {
container.contains = originalContains
}
consentContainerContains.clear()
const tcfApi = getTcfApi()
if (tcfApi && tcfListenerId !== undefined) {
tcfApi('removeEventListener', 2, () => {}, tcfListenerId)
}
const gppApi = getGppApi()
if (gppApi && gppListenerId !== undefined) {
gppApi('removeEventListener', () => {}, gppListenerId)
}
if (notificationId !== null) {
notificationManager.removeNotification(notificationId)
}
})
</script>
<style>
html.modrinth-cmp-summary-hidden .qc-cmp2-container,
html.modrinth-cmp-summary-hidden #qc-cmp2-container,
html.modrinth-cmp-summary-hidden #qc-cmp2-main,
html.modrinth-cmp-summary-hidden #qc-cmp2-ui,
html.modrinth-cmp-summary-hidden #qc-cmp2-usp {
display: none !important;
z-index: -1 !important;
pointer-events: none !important;
}
</style>
@@ -40,6 +40,30 @@ const messages = defineMessages({
defaultMessage:
'NOT AN OFFICIAL MINECRAFT SERVICE. NOT APPROVED BY OR ASSOCIATED WITH MOJANG OR MICROSOFT.',
},
basedOnLabel: {
id: 'layout.footer.based-on-label',
defaultMessage: 'Based on',
},
unknownCommit: {
id: 'layout.footer.unknown-commit',
defaultMessage: 'unknown',
},
developerModeActivatedTitle: {
id: 'layout.footer.developer-mode-activated.title',
defaultMessage: 'Developer mode activated',
},
developerModeActivatedText: {
id: 'layout.footer.developer-mode-activated.text',
defaultMessage: 'Developer mode has been enabled',
},
developerModeDeactivatedTitle: {
id: 'layout.footer.developer-mode-deactivated.title',
defaultMessage: 'Developer mode deactivated',
},
developerModeDeactivatedText: {
id: 'layout.footer.developer-mode-deactivated.text',
defaultMessage: 'Developer mode has been disabled',
},
})
const socialLinks: {
@@ -215,14 +239,14 @@ function developerModeIncrement() {
saveFeatureFlags()
if (flags.value.developerMode) {
addNotification({
title: 'Developer mode activated',
text: 'Developer mode has been enabled',
title: formatMessage(messages.developerModeActivatedTitle),
text: formatMessage(messages.developerModeActivatedText),
type: 'success',
})
} else {
addNotification({
title: 'Developer mode deactivated',
text: 'Developer mode has been disabled',
title: formatMessage(messages.developerModeDeactivatedTitle),
text: formatMessage(messages.developerModeDeactivatedText),
type: 'success',
})
}
@@ -320,7 +344,7 @@ function developerModeIncrement() {
</div>
</div>
<p v-if="flags.developerMode" class="m-0 text-sm text-secondary">
Based on
{{ formatMessage(messages.basedOnLabel) }}
<a
v-if="config.public.owner && config.public.branch"
class="hover:underline"
@@ -330,7 +354,9 @@ function developerModeIncrement() {
{{ config.public.owner }}/{{ config.public.branch }}
</a>
@
<span v-if="config.public.hash === 'unknown'">unknown</span>
<span v-if="config.public.hash === 'unknown'">{{
formatMessage(messages.unknownCommit)
}}</span>
<AutoLink
v-else
class="text-link"
+1 -1
View File
@@ -1,7 +1,7 @@
<template>
<nav :aria-label="ariaLabel" class="w-full">
<ul
class="card-shadow m-0 flex list-none flex-col items-start gap-1.5 rounded-2xl border border-solid border-surface-4 bg-surface-3 p-4"
class="card-shadow m-0 mb-3 flex list-none flex-col items-start gap-1.5 rounded-2xl border border-solid border-surface-4 bg-surface-3 p-4"
:class="{ 'pt-3': filteredItems?.[0]?.type === 'heading' }"
>
<slot v-if="hasSlotContent" />
@@ -0,0 +1,163 @@
<template>
<PageHeader :title="organization.name" :summary="organization.description">
<template #leading>
<Avatar
:src="organization.icon_url"
:alt="organization.name"
:tint-by="organization.id"
size="96px"
/>
</template>
<template #badges>
<PageHeaderBadgeItem :icon="OrganizationIcon" class="px-0 text-primary">
{{ formatMessage(messages.organizationLabel) }}
</PageHeaderBadgeItem>
</template>
<template #metadata>
<PageHeaderMetadata>
<PageHeaderMetadataNumberItem
:icon="UsersIcon"
:value="membersCount"
:label="formatMessage(messages.membersLabel)"
/>
<PageHeaderMetadataNumberItem
:icon="BoxIcon"
:value="projectsCount"
:label="formatMessage(messages.projectsLabel)"
/>
<PageHeaderMetadataNumberItem
:icon="DownloadIcon"
:value="downloads"
:label="formatMessage(messages.downloadsLabel)"
:tooltip="formatNumber(downloads)"
/>
</PageHeaderMetadata>
</template>
<template #actions>
<PageHeaderActions>
<ButtonStyled v-if="canManage" size="large">
<nuxt-link :to="`/organization/${organization.slug}/settings`">
<SettingsIcon />
{{ formatMessage(messages.manage) }}
</nuxt-link>
</ButtonStyled>
<ButtonStyled circular size="large" type="transparent">
<TeleportOverflowMenu
:options="moreActions"
:tooltip="formatMessage(commonMessages.moreOptionsButton)"
:aria-label="formatMessage(commonMessages.moreOptionsButton)"
>
<MoreVerticalIcon />
</TeleportOverflowMenu>
</ButtonStyled>
</PageHeaderActions>
</template>
</PageHeader>
</template>
<script setup lang="ts">
import {
BoxIcon,
ClipboardCopyIcon,
DownloadIcon,
MoreVerticalIcon,
OrganizationIcon,
SettingsIcon,
UsersIcon,
} from '@modrinth/assets'
import {
Avatar,
ButtonStyled,
commonMessages,
defineMessages,
PageHeader,
PageHeaderActions,
PageHeaderBadgeItem,
PageHeaderMetadata,
PageHeaderMetadataNumberItem,
TeleportOverflowMenu,
type TeleportOverflowMenuItem,
useFormatNumber,
useVIntl,
} from '@modrinth/ui'
import { computed } from 'vue'
const messages = defineMessages({
downloadsLabel: {
id: 'organization.label.downloads',
defaultMessage: 'downloads',
},
manage: {
id: 'organization.button.manage',
defaultMessage: 'Manage',
},
manageProjects: {
id: 'organization.button.manage-projects',
defaultMessage: 'Manage projects',
},
membersLabel: {
id: 'organization.label.members',
defaultMessage: 'members',
},
organizationLabel: {
id: 'organization.label.organization',
defaultMessage: 'Organization',
},
projectsLabel: {
id: 'organization.label.projects',
defaultMessage: 'projects',
},
})
const props = defineProps<{
organization: {
id: string
name: string
slug: string
description?: string | null
icon_url?: string | null
}
membersCount: number
projectsCount: number
downloads: number
canManage?: boolean
}>()
const emit = defineEmits<{
manageProjects: []
copyId: []
copyPermalink: []
}>()
const { formatMessage } = useVIntl()
const formatNumber = useFormatNumber()
const moreActions = computed<TeleportOverflowMenuItem[]>(() => [
{
id: 'manage-projects',
label: formatMessage(messages.manageProjects),
icon: BoxIcon,
action: () => emit('manageProjects'),
shown: props.canManage,
},
{
divider: true,
shown: props.canManage,
},
{
id: 'copy-id',
label: formatMessage(commonMessages.copyIdButton),
icon: ClipboardCopyIcon,
action: () => emit('copyId'),
},
{
id: 'copy-permalink',
label: formatMessage(commonMessages.copyPermalinkButton),
icon: ClipboardCopyIcon,
action: () => emit('copyPermalink'),
},
])
</script>
@@ -1,125 +1,115 @@
<template>
<div>
<Modal ref="modalOpen" header="Transfer Projects">
<div class="universal-modal items">
<div class="table">
<div class="table-head table-row">
<div class="check-cell table-cell">
<Checkbox
:model-value="selectedProjects.length === props.projects.length"
@update:model-value="toggleSelectedProjects()"
/>
<NewModal
ref="modalOpen"
:header="formatMessage(messages.transferProjectsTitle)"
no-padding
width="60rem"
>
<div class="max-h-[70vh] overflow-y-auto">
<Table
class="!rounded-none !border-0"
:columns="projectTableColumns"
:data="props.projects"
row-key="id"
table-min-width="42rem"
table-layout="auto"
>
<template #empty-state>
<div class="flex h-48 items-center justify-center text-secondary">
{{ formatMessage(messages.noProjectsAvailable) }}
</div>
<div class="table-cell">Icon</div>
<div class="table-cell">Name</div>
<div class="table-cell">ID</div>
<div class="table-cell">Type</div>
<div class="table-cell" />
</div>
<div v-for="project in props.projects" :key="`project-${project.id}`" class="table-row">
<div class="check-cell table-cell">
<Checkbox
:disabled="(project.permissions & EDIT_DETAILS) === EDIT_DETAILS"
:model-value="selectedProjects.includes(project)"
@update:model-value="
selectedProjects.includes(project)
? (selectedProjects = selectedProjects.filter((it) => it !== project))
: selectedProjects.push(project)
"
/>
</div>
<div class="table-cell">
<nuxt-link tabindex="-1" :to="`/project/${project.slug ? project.slug : project.id}`">
<Avatar
</template>
<template #header-select>
<Checkbox
class="h-full w-full justify-center"
:model-value="allTransferableProjectsSelected"
@update:model-value="toggleAllTransferableProjects()"
/>
</template>
<template #cell-select="{ row: project }">
<Checkbox
class="h-full w-full justify-center"
:disabled="isProjectTransferDisabled(project)"
:model-value="isProjectSelected(project)"
@update:model-value="toggleProjectSelection(project)"
/>
</template>
<template #cell-name="{ row: project }">
<nuxt-link class="project-name-cell" :to="getProjectUrl(project)">
<span
class="flex size-8 shrink-0 items-center justify-center overflow-hidden rounded text-primary"
>
<img
v-if="project.icon_url"
:src="project.icon_url"
aria-hidden="true"
:alt="'Icon for ' + project.name"
no-shadow
:alt="formatMessage(messages.projectIconAlt, { name: project.title })"
class="h-full w-full rounded object-cover"
/>
</nuxt-link>
</div>
<div class="table-cell">
<span class="project-title">
<nuxt-link
class="hover-link wrap-as-needed"
:to="`/project/${project.slug ? project.slug : project.id}`"
>
{{ project.title }}
</nuxt-link>
<BoxIcon v-else class="h-full w-full" />
</span>
</div>
<div class="table-cell">
<span class="wrap-as-needed">{{ project.title }}</span>
</nuxt-link>
</template>
<template #cell-id="{ row: project }">
<div class="flex items-center">
<CopyCode :text="project.id" />
</div>
<div class="table-cell">
<BoxIcon />
<span>{{
formatProjectType(
$getProjectTypeForDisplay(
project.project_types?.[0] ?? 'project',
project.loaders,
),
)
}}</span>
</template>
<template #cell-type="{ row: project }">
<div class="flex items-center">
{{ getProjectDisplayType(project) }}
</div>
<div class="table-cell">
<ButtonStyled circular>
<nuxt-link :to="`/project/${project.slug ? project.slug : project.id}/settings`">
<SettingsIcon />
</nuxt-link>
</ButtonStyled>
</div>
</div>
</div>
<div class="push-right input-group">
</template>
</Table>
</div>
<template #actions>
<div class="flex justify-end gap-2">
<ButtonStyled type="outlined">
<button @click="$refs.modalOpen?.hide()">
<button @click="hide()">
<XIcon />
Cancel
{{ formatMessage(commonMessages.cancelButton) }}
</button>
</ButtonStyled>
<ButtonStyled color="brand">
<button :disabled="!selectedProjects?.length" @click="onSubmitHandler()">
<button :disabled="selectedProjects.length === 0" @click="submitTransfer()">
<TransferIcon />
<span>
Transfer
<span>
{{
selectedProjects.length === props.projects.length
? 'All'
: selectedProjects.length
}}
</span>
<span>
{{ ' ' }}
{{ selectedProjects.length === 1 ? 'project' : 'projects' }}
</span>
</span>
{{
formatMessage(messages.transferSelectedProjects, {
count: selectedProjects.length,
})
}}
</button>
</ButtonStyled>
</div>
</div>
</Modal>
</template>
</NewModal>
<ButtonStyled>
<button @click="$refs.modalOpen?.show()">
<button @click="show($event)">
<TransferIcon />
<span>Transfer projects</span>
<span>{{ formatMessage(messages.transferProjectsTitle) }}</span>
</button>
</ButtonStyled>
</div>
</template>
<script setup>
import { BoxIcon, SettingsIcon, TransferIcon, XIcon } from '@modrinth/assets'
import { Avatar, ButtonStyled, Checkbox, CopyCode, Modal } from '@modrinth/ui'
import { BoxIcon, TransferIcon, XIcon } from '@modrinth/assets'
import {
ButtonStyled,
Checkbox,
commonMessages,
CopyCode,
defineMessages,
NewModal,
Table,
useVIntl,
} from '@modrinth/ui'
import { formatProjectType } from '@modrinth/utils'
import { getProjectTypeForUrl } from '~/helpers/projects.js'
const EDIT_DETAILS = 1 << 2
const modalOpen = ref(null)
const props = defineProps({
projects: {
@@ -128,126 +118,144 @@ const props = defineProps({
},
})
// define emit for submission
const emit = defineEmits(['submit'])
const { formatMessage } = useVIntl()
const selectedProjects = ref([])
const messages = defineMessages({
transferProjectsTitle: {
id: 'organization.project-transfer.title',
defaultMessage: 'Transfer projects',
},
noProjectsAvailable: {
id: 'organization.project-transfer.no-projects-available',
defaultMessage: 'No projects available to transfer.',
},
projectIconAlt: {
id: 'organization.project-transfer.project-icon-alt',
defaultMessage: 'Icon for {name}',
},
transferSelectedProjects: {
id: 'organization.project-transfer.transfer-selected-projects',
defaultMessage: 'Transfer {count, plural, one {# project} other {# projects}}',
},
nameColumn: {
id: 'organization.project-transfer.name-column',
defaultMessage: 'Name',
},
idColumn: {
id: 'organization.project-transfer.id-column',
defaultMessage: 'ID',
},
typeColumn: {
id: 'organization.project-transfer.type-column',
defaultMessage: 'Type',
},
})
const toggleSelectedProjects = () => {
if (selectedProjects.value.length === props.projects.length) {
selectedProjects.value = []
} else {
selectedProjects.value = props.projects
}
const modalOpen = ref(null)
const selectedProjectIds = ref([])
const projectTableColumns = computed(() => [
{
key: 'select',
width: '3rem',
headerClass: '!p-0',
cellClass: '!overflow-visible !p-0',
},
{
key: 'name',
label: formatMessage(messages.nameColumn),
width: '22rem',
},
{
key: 'id',
label: formatMessage(messages.idColumn),
width: '13rem',
cellClass: '!overflow-visible',
},
{
key: 'type',
label: formatMessage(messages.typeColumn),
width: '10rem',
},
])
const transferableProjects = computed(() =>
props.projects.filter((project) => !isProjectTransferDisabled(project)),
)
const selectedProjects = computed(() =>
props.projects.filter((project) => selectedProjectIds.value.includes(project.id)),
)
const allTransferableProjectsSelected = computed(
() =>
transferableProjects.value.length > 0 &&
transferableProjects.value.every((project) => selectedProjectIds.value.includes(project.id)),
)
function isProjectTransferDisabled(project) {
return (project.permissions & EDIT_DETAILS) === EDIT_DETAILS
}
const onSubmitHandler = () => {
if (selectedProjects.value.length === 0) {
function isProjectSelected(project) {
return selectedProjectIds.value.includes(project.id)
}
function toggleProjectSelection(project) {
if (isProjectTransferDisabled(project)) return
if (isProjectSelected(project)) {
selectedProjectIds.value = selectedProjectIds.value.filter((id) => id !== project.id)
return
}
emit('submit', selectedProjects.value)
selectedProjects.value = []
selectedProjectIds.value = [...selectedProjectIds.value, project.id]
}
function toggleAllTransferableProjects() {
selectedProjectIds.value = allTransferableProjectsSelected.value
? []
: transferableProjects.value.map((project) => project.id)
}
function getProjectUrl(project) {
const projectType = getProjectTypeForUrl(project.project_type, project.loaders)
return '/' + projectType + '/' + (project.slug || project.id)
}
function getProjectDisplayType(project) {
return formatProjectType(getProjectTypeForUrl(project.project_type, project.loaders))
}
function show(event) {
modalOpen.value?.show(event)
}
function hide() {
modalOpen.value?.hide()
}
function submitTransfer() {
if (selectedProjects.value.length === 0) return
emit('submit', selectedProjects.value)
selectedProjectIds.value = []
hide()
}
</script>
<style lang="scss" scoped>
.table {
display: grid;
border-radius: var(--radius-md);
overflow: hidden;
margin-top: var(--gap-md);
border: 1px solid var(--color-divider);
background-color: var(--color-raised-bg);
.table-row {
grid-template-columns: 2.75rem 3.75rem 2fr 1fr 1fr 3.5rem;
}
.table-cell {
display: flex;
align-items: center;
gap: var(--gap-xs);
padding: var(--gap-md);
padding-left: 0;
}
.check-cell {
padding-left: var(--gap-md);
}
@media screen and (max-width: 750px) {
display: flex;
flex-direction: column;
.table-row {
display: grid;
grid-template: 'checkbox icon name type settings' 'checkbox icon id type settings';
grid-template-columns:
min-content min-content minmax(min-content, 2fr)
minmax(min-content, 1fr) min-content;
:nth-child(1) {
grid-area: checkbox;
}
:nth-child(2) {
grid-area: icon;
}
:nth-child(3) {
grid-area: name;
}
:nth-child(4) {
grid-area: id;
padding-top: 0;
}
:nth-child(5) {
grid-area: type;
}
:nth-child(6) {
grid-area: settings;
}
}
.table-head {
grid-template: 'checkbox settings';
grid-template-columns: min-content minmax(min-content, 1fr);
:nth-child(2),
:nth-child(3),
:nth-child(4),
:nth-child(5) {
display: none;
}
}
}
@media screen and (max-width: 560px) {
.table-row {
display: grid;
grid-template: 'checkbox icon name settings' 'checkbox icon id settings' 'checkbox icon type settings' 'checkbox icon status settings';
grid-template-columns: min-content min-content minmax(min-content, 1fr) min-content;
:nth-child(5) {
padding-top: 0;
}
}
.table-head {
grid-template: 'checkbox settings';
grid-template-columns: min-content minmax(min-content, 1fr);
}
}
.project-name-cell {
display: flex;
min-width: 0;
min-height: 3.5rem;
align-items: center;
gap: var(--spacing-card-sm);
color: inherit;
text-decoration: none;
}
.items {
display: flex;
flex-direction: column;
gap: var(--gap-md);
padding: var(--gap-md);
.project-name-cell:hover,
.project-name-cell:focus-visible {
text-decoration: underline;
}
</style>
@@ -0,0 +1,127 @@
<template>
<ButtonStyled size="large" circular>
<PopoutMenu
v-if="authUser"
:tooltip="
saved ? formatMessage(commonMessages.savedLabel) : formatMessage(commonMessages.saveButton)
"
from="top-right"
:aria-label="formatMessage(commonMessages.saveButton)"
:dropdown-id="`${baseId}-save`"
>
<BookmarkIcon aria-hidden="true" :fill="saved ? 'currentColor' : 'none'" />
<template #menu>
<StyledInput
v-model="displayCollectionsSearch"
:placeholder="formatMessage(commonMessages.searchPlaceholder)"
wrapper-class="menu-search"
/>
<div v-if="filteredCollections.length > 0" class="collections-list text-primary">
<Checkbox
v-for="option in filteredCollections"
:key="option.id"
:model-value="option.projects.includes(projectId)"
class="popout-checkbox"
@update:model-value="() => collectProject(option, projectId)"
>
{{ option.name }}
</Checkbox>
</div>
<div v-else class="menu-text">
<p class="popout-text">{{ noCollectionsLabel }}</p>
</div>
<ButtonStyled>
<button class="mx-3 mb-3" @click="createCollection">
<PlusIcon aria-hidden="true" />
{{ createNewCollectionLabel }}
</button>
</ButtonStyled>
</template>
</PopoutMenu>
<nuxt-link
v-else
v-tooltip="formatMessage(commonMessages.saveButton)"
:to="signInRoute"
:aria-label="formatMessage(commonMessages.saveButton)"
>
<BookmarkIcon aria-hidden="true" />
</nuxt-link>
</ButtonStyled>
</template>
<script setup lang="ts">
import { BookmarkIcon, PlusIcon } from '@modrinth/assets'
import {
ButtonStyled,
Checkbox,
commonMessages,
PopoutMenu,
StyledInput,
useVIntl,
} from '@modrinth/ui'
import { computed, ref } from 'vue'
import type { RouteLocationRaw } from 'vue-router'
type CollectionOption = {
id: string
name: string
projects: string[]
}
const props = defineProps<{
authUser?: unknown
signInRoute: RouteLocationRaw
projectId: string
collections: CollectionOption[]
saved: boolean
baseId: string
noCollectionsLabel: string
createNewCollectionLabel: string
collectProject: (option: CollectionOption, projectId: string) => void | Promise<void>
createCollection: (event: MouseEvent) => void
}>()
const { formatMessage } = useVIntl()
const displayCollectionsSearch = ref('')
const filteredCollections = computed(() =>
props.collections
.filter((collection) =>
collection.name.toLowerCase().includes(displayCollectionsSearch.value.toLowerCase()),
)
.slice()
.sort((a, b) => a.name.localeCompare(b.name)),
)
</script>
<style scoped lang="scss">
.popout-checkbox {
padding: var(--gap-sm) var(--gap-md);
white-space: nowrap;
&:hover {
filter: brightness(0.95);
}
}
.menu-text {
padding: 0 var(--gap-md);
font-size: var(--font-size-nm);
color: var(--color-secondary);
}
.menu-search {
margin: var(--gap-sm) var(--gap-md);
width: calc(100% - var(--gap-md) * 2);
}
.collections-list {
max-height: 40rem;
overflow-y: auto;
background-color: var(--color-bg);
border-radius: var(--radius-md);
margin: var(--gap-sm) var(--gap-md);
padding: var(--gap-sm);
}
</style>
@@ -32,7 +32,7 @@
@update:selection="updateProjectDownloadSelection"
@download="onDownload"
/>
<div class="flex flex-col gap-4">
<div class="flex flex-col gap-4 empty:hidden">
<DownloadDependencies @download="onDownload" />
</div>
<ServersPromo
@@ -0,0 +1,294 @@
<template>
<PageHeader :title="user.username" :summary="summary">
<template #leading>
<Avatar
:src="user.avatar_url"
:alt="user.username"
:size="isModrinthUser ? '64px' : '96px'"
:tint-by="user.username"
circle
/>
</template>
<template v-if="isOfficialAccount || showAffiliateBadge" #badges>
<PageHeaderBadgeItem
v-if="isOfficialAccount"
:icon="BadgeCheckIcon"
:icon-props="{ fill: 'var(--color-brand-highlight)' }"
:tooltip="formatMessage(messages.officialAccount)"
class="border-brand-highlight bg-brand-highlight text-brand"
>
{{ formatMessage(messages.officialAccount) }}
</PageHeaderBadgeItem>
<PageHeaderBadgeItem
v-if="showAffiliateBadge"
:icon="AffiliateIcon"
class="border-brand-highlight bg-brand-highlight text-brand"
>
{{ formatMessage(messages.affiliateLabel) }}
</PageHeaderBadgeItem>
</template>
<template v-if="$slots.summary" #summary>
<slot name="summary" />
</template>
<template v-if="!isModrinthUser" #metadata>
<PageHeaderMetadata>
<PageHeaderMetadataNumberItem
:icon="BoxIcon"
:value="projectsCount"
:label="formatMessage(messages.profileProjectCountLabel, { count: projectsCount })"
/>
<PageHeaderMetadataNumberItem
:icon="DownloadIcon"
:value="downloads"
:label="formatMessage(messages.profileDownloadCountLabel, { count: downloads })"
:tooltip="downloadsTooltip"
/>
<PageHeaderMetadataTimeItem
:icon="CalendarIcon"
:date="user.created"
:label="formatMessage(messages.profileJoinedLabel)"
:tooltip="joinedTooltip"
/>
</PageHeaderMetadata>
</template>
<template #actions>
<PageHeaderActions>
<ButtonStyled v-if="isSelf" size="large">
<nuxt-link to="/settings/profile">
<EditIcon />
{{ formatMessage(commonMessages.editButton) }}
</nuxt-link>
</ButtonStyled>
<ButtonStyled circular size="large" type="transparent">
<TeleportOverflowMenu
:options="moreActions"
:tooltip="formatMessage(commonMessages.moreOptionsButton)"
:aria-label="formatMessage(commonMessages.moreOptionsButton)"
>
<MoreVerticalIcon />
</TeleportOverflowMenu>
</ButtonStyled>
</PageHeaderActions>
</template>
</PageHeader>
</template>
<script setup lang="ts">
import type { Labrinth } from '@modrinth/api-client'
import {
AffiliateIcon,
BadgeCheckIcon,
BoxIcon,
CalendarIcon,
ChartIcon,
ClipboardCopyIcon,
CurrencyIcon,
DownloadIcon,
EditIcon,
InfoIcon,
MoreVerticalIcon,
ReportIcon,
} from '@modrinth/assets'
import {
Avatar,
ButtonStyled,
commonMessages,
defineMessages,
PageHeader,
PageHeaderActions,
PageHeaderBadgeItem,
PageHeaderMetadata,
PageHeaderMetadataNumberItem,
PageHeaderMetadataTimeItem,
TeleportOverflowMenu,
type TeleportOverflowMenuItem,
useFormatDateTime,
useFormatNumber,
useVIntl,
} from '@modrinth/ui'
import { computed } from 'vue'
const messages = defineMessages({
affiliateLabel: {
id: 'profile.label.affiliate',
defaultMessage: 'Affiliate',
},
analyticsButton: {
id: 'profile.button.analytics',
defaultMessage: 'View user analytics',
},
billingButton: {
id: 'profile.button.billing',
defaultMessage: 'Manage user billing',
},
editRoleButton: {
id: 'profile.button.edit-role',
defaultMessage: 'Edit role',
},
infoButton: {
id: 'profile.button.info',
defaultMessage: 'View user details',
},
officialAccount: {
id: 'profile.official-account',
defaultMessage: 'Official Modrinth account',
},
profileJoinedLabel: {
id: 'profile.label.joined',
defaultMessage: 'Joined',
},
profileProjectCountLabel: {
id: 'profile.label.project-count',
defaultMessage: '{count, plural, one {project} other {projects}}',
},
profileDownloadCountLabel: {
id: 'profile.label.download-count',
defaultMessage: '{count, plural, one {download} other {downloads}}',
},
profileManageProjectsButton: {
id: 'profile.button.manage-projects',
defaultMessage: 'Manage projects',
},
removeAffiliateButton: {
id: 'profile.button.remove-affiliate',
defaultMessage: 'Remove as affiliate',
},
setAffiliateButton: {
id: 'profile.button.set-affiliate',
defaultMessage: 'Set as affiliate',
},
})
const props = withDefaults(
defineProps<{
user: Labrinth.Users.v3.User
summary?: string | null
authUser?: Labrinth.Users.v3.User | null
isModrinthUser?: boolean
isOfficialAccount?: boolean
showAffiliateBadge?: boolean
isAffiliate?: boolean
isSelf?: boolean
isAdmin?: boolean
isStaff?: boolean
projectsCount?: number
downloads?: number
}>(),
{
summary: null,
authUser: null,
isModrinthUser: false,
isOfficialAccount: false,
showAffiliateBadge: false,
isAffiliate: false,
isSelf: false,
isAdmin: false,
isStaff: false,
projectsCount: 0,
downloads: 0,
},
)
const emit = defineEmits<{
manageProjects: []
report: []
copyId: []
copyPermalink: []
openBilling: []
toggleAffiliate: []
openInfo: []
openAnalytics: []
editRole: []
}>()
const { formatMessage } = useVIntl()
const formatNumber = useFormatNumber()
const formatDateTime = useFormatDateTime({
timeStyle: 'short',
dateStyle: 'long',
})
const downloadsTooltip = computed(() => formatNumber(props.downloads))
const joinedTooltip = computed(() => formatDateTime(props.user.created))
const moreActions = computed<TeleportOverflowMenuItem[]>(() => [
{
id: 'manage-projects',
label: formatMessage(messages.profileManageProjectsButton),
icon: BoxIcon,
action: () => emit('manageProjects'),
shown: props.isSelf,
},
{
divider: true,
shown: props.isSelf,
},
{
id: 'report',
label: formatMessage(commonMessages.reportButton),
icon: ReportIcon,
action: () => emit('report'),
color: 'red',
shown: props.authUser?.id !== props.user.id,
},
{
id: 'copy-id',
label: formatMessage(commonMessages.copyIdButton),
icon: ClipboardCopyIcon,
action: () => emit('copyId'),
},
{
id: 'copy-permalink',
label: formatMessage(commonMessages.copyPermalinkButton),
icon: ClipboardCopyIcon,
action: () => emit('copyPermalink'),
},
{
divider: true,
shown: props.isAdmin,
},
{
id: 'open-billing',
label: formatMessage(messages.billingButton),
icon: CurrencyIcon,
action: () => emit('openBilling'),
shown: props.isStaff,
},
{
id: 'toggle-affiliate',
label: props.isAffiliate
? formatMessage(messages.removeAffiliateButton)
: formatMessage(messages.setAffiliateButton),
icon: AffiliateIcon,
action: () => emit('toggleAffiliate'),
shown: props.isAdmin,
remainOnClick: true,
color: props.isAffiliate ? 'red' : 'orange',
},
{
id: 'open-info',
label: formatMessage(messages.infoButton),
icon: InfoIcon,
action: () => emit('openInfo'),
shown: props.isStaff,
},
{
id: 'open-analytics',
label: formatMessage(messages.analyticsButton),
icon: ChartIcon,
action: () => emit('openAnalytics'),
shown: props.isAdmin,
},
{
id: 'edit-role',
label: formatMessage(messages.editRoleButton),
icon: EditIcon,
action: () => emit('editRole'),
shown: props.isAdmin,
},
])
</script>
@@ -72,6 +72,18 @@
/>
</div>
<div class="hidden" aria-hidden="true">
<input
id="create-account-consent"
v-model="accountConsent"
name="account_consent"
type="checkbox"
tabindex="-1"
autocomplete="off"
/>
<label for="create-account-consent">I agree to receive account updates by email</label>
</div>
<ButtonStyled color="brand">
<button
class="!w-full font-bold"
@@ -97,7 +109,7 @@ import {
StyledInput,
useVIntl,
} from '@modrinth/ui'
import { computed } from 'vue'
import { computed, ref } from 'vue'
import HCaptcha from '@/components/ui/auth/HCaptcha.vue'
@@ -109,7 +121,7 @@ interface AuthGlobals {
interface Props {
globals?: AuthGlobals | null
requiresDob?: boolean
onCompleteSignUp?: () => void
onCompleteSignUp?: (accountConsent: boolean) => void
onSetCaptchaRef?: ((captchaRef: unknown) => void) | undefined
}
@@ -127,6 +139,7 @@ const dateOfBirthModel = defineModel<string | null>('dateOfBirth', { default: ''
const usernameModel = defineModel<string>('username', { default: '' })
const tokenModel = defineModel<string>('token', { default: '' })
const subscribeModel = defineModel<boolean>('subscribe', { default: false })
const accountConsent = ref(false)
const maxInputDate = computed(() => `${new Date().getFullYear()}-12-31`)
@@ -198,7 +211,7 @@ function onCompleteSignUpClick() {
return
}
onCompleteSignUp()
onCompleteSignUp(accountConsent.value)
}
const messages = defineMessages({
@@ -11,19 +11,21 @@
v-if="handlingNewFiles || !(filesToAdd.length || draftVersion.existing_files?.length)"
>
<DropzoneFileInput
aria-label="Upload file"
:aria-label="formatMessage(messages.uploadFileAriaLabel)"
multiple
:accept="acceptFileFromProjectType(projectV2.project_type)"
:max-size="524288000"
primary-prompt="Upload primary and supporting files"
secondary-prompt="Drag and drop files or click to browse"
:primary-prompt="formatMessage(messages.uploadPrimaryPrompt)"
:secondary-prompt="formatMessage(messages.uploadSecondaryPrompt)"
@change="handleNewFiles"
/>
</template>
<template v-else>
<div class="flex flex-col gap-2">
<span class="text-base font-semibold text-contrast">Primary file</span>
<span class="text-base font-semibold text-contrast">{{
formatMessage(messages.primaryFileLabel)
}}</span>
<div class="flex flex-col gap-2.5">
<VersionFileRow
v-if="primaryFile"
@@ -36,7 +38,7 @@
/>
</div>
<span>
The primary file is the default file a user downloads when installing the project.
{{ formatMessage(messages.primaryFileDescription) }}
</span>
</div>
@@ -46,16 +48,18 @@
{{ formatMessage(messages.addFilesAdmonition) }}
</Admonition>
<span class="text-base font-semibold text-contrast">Supplementary files</span>
<span class="text-base font-semibold text-contrast">{{
formatMessage(messages.supplementaryFilesLabel)
}}</span>
<DropzoneFileInput
aria-label="Upload additional file"
:aria-label="formatMessage(messages.uploadAdditionalFileAriaLabel)"
multiple
:accept="acceptFileFromProjectType(projectV2.project_type)"
:max-size="524288000"
size="small"
:primary-prompt="null"
secondary-prompt="Drag and drop files or click to browse"
:secondary-prompt="formatMessage(messages.uploadSecondaryPrompt)"
@change="handleNewFiles"
/>
@@ -84,8 +88,7 @@
</div>
</div>
<span>
You can optionally add supplementary files such as source code, documentation, or required
resource packs.
{{ formatMessage(messages.supplementaryFilesDescription) }}
</span>
</div>
</template>
@@ -124,11 +127,11 @@ const {
handleNewFiles,
} = injectManageVersionContext()
const editTabs: TabsTab[] = [
{ label: 'Metadata', value: 'metadata' },
{ label: 'Details', value: 'add-details' },
{ label: 'Files', value: 'add-files' },
]
const editTabs = computed<TabsTab[]>(() => [
{ label: formatMessage(messages.metadataTab), value: 'metadata' },
{ label: formatMessage(messages.detailsTab), value: 'add-details' },
{ label: formatMessage(messages.filesTab), value: 'add-files' },
])
function setEditTab(tab: TabsTab) {
modal.value?.setStage(tab.value)
@@ -171,5 +174,51 @@ const messages = defineMessages({
defaultMessage:
'Supplementary files are for supporting resources like source code, not for alternative versions or variants.',
},
uploadFileAriaLabel: {
id: 'create-project-version.create-modal.stage.add-files.upload-file-aria-label',
defaultMessage: 'Upload file',
},
uploadAdditionalFileAriaLabel: {
id: 'create-project-version.create-modal.stage.add-files.upload-additional-file-aria-label',
defaultMessage: 'Upload additional file',
},
uploadPrimaryPrompt: {
id: 'create-project-version.create-modal.stage.add-files.upload-primary-prompt',
defaultMessage: 'Upload primary and supporting files',
},
uploadSecondaryPrompt: {
id: 'create-project-version.create-modal.stage.add-files.upload-secondary-prompt',
defaultMessage: 'Drag and drop files or click to browse',
},
primaryFileLabel: {
id: 'create-project-version.create-modal.stage.add-files.primary-file-label',
defaultMessage: 'Primary file',
},
primaryFileDescription: {
id: 'create-project-version.create-modal.stage.add-files.primary-file-description',
defaultMessage:
'The primary file is the default file a user downloads when installing the project.',
},
supplementaryFilesLabel: {
id: 'create-project-version.create-modal.stage.add-files.supplementary-files-label',
defaultMessage: 'Supplementary files',
},
supplementaryFilesDescription: {
id: 'create-project-version.create-modal.stage.add-files.supplementary-files-description',
defaultMessage:
'You can optionally add supplementary files such as source code, documentation, or required resource packs.',
},
metadataTab: {
id: 'create-project-version.create-modal.stage.add-files.metadata-tab',
defaultMessage: 'Metadata',
},
detailsTab: {
id: 'create-project-version.create-modal.stage.add-files.details-tab',
defaultMessage: 'Details',
},
filesTab: {
id: 'create-project-version.create-modal.stage.add-files.files-tab',
defaultMessage: 'Files',
},
})
</script>
@@ -9,7 +9,7 @@
<div class="flex w-full flex-col gap-6">
<div class="flex flex-col gap-2">
<span class="font-semibold text-contrast">
Version type <span class="text-red">*</span>
{{ formatMessage(messages.versionType) }} <span class="text-red">*</span>
</span>
<Chips
v-model="draftVersion.version_type"
@@ -22,31 +22,33 @@
</div>
<div class="flex flex-col gap-2">
<span class="font-semibold text-contrast">
Version number <span class="text-red">*</span>
{{ formatMessage(messages.versionNumber) }} <span class="text-red">*</span>
</span>
<StyledInput
id="version-number"
v-model="draftVersion.version_number"
:disabled="isUploading"
placeholder="Enter version number, e.g. 1.2.3-alpha.1"
:placeholder="formatMessage(messages.versionNumberPlaceholder)"
autocomplete="off"
:maxlength="32"
/>
<span> The version number differentiates this specific version from others. </span>
<span>{{ formatMessage(messages.versionNumberDescription) }}</span>
</div>
<div class="flex flex-col gap-2">
<span class="font-semibold text-contrast"> Version subtitle </span>
<span class="font-semibold text-contrast">{{ formatMessage(messages.versionSubtitle) }}</span>
<StyledInput
id="version-number"
v-model="draftVersion.name"
placeholder="Enter subtitle..."
:placeholder="formatMessage(messages.versionSubtitlePlaceholder)"
autocomplete="off"
:maxlength="256"
:disabled="isUploading"
/>
</div>
<div class="flex flex-col gap-2">
<span class="font-semibold text-contrast"> Version changelog </span>
<span class="font-semibold text-contrast">{{
formatMessage(messages.versionChangelog)
}}</span>
<div class="w-full">
<MarkdownEditor
@@ -61,18 +63,70 @@
</template>
<script lang="ts" setup>
import { Chips, MarkdownEditor, StyledInput, Tabs, type TabsTab } from '@modrinth/ui'
import {
Chips,
defineMessages,
MarkdownEditor,
StyledInput,
Tabs,
type TabsTab,
useVIntl,
} from '@modrinth/ui'
import { useImageUpload } from '~/composables/image-upload.ts'
import { injectManageVersionContext } from '~/providers/version/manage-version-modal'
const { draftVersion, isUploading, editingVersion, modal } = injectManageVersionContext()
const { formatMessage } = useVIntl()
const editTabs: TabsTab[] = [
{ label: 'Metadata', value: 'metadata' },
{ label: 'Details', value: 'add-details' },
{ label: 'Files', value: 'add-files' },
]
const messages = defineMessages({
versionType: {
id: 'create-project-version.create-modal.stage.details.version-type',
defaultMessage: 'Version type',
},
versionNumber: {
id: 'create-project-version.create-modal.stage.details.version-number',
defaultMessage: 'Version number',
},
versionNumberPlaceholder: {
id: 'create-project-version.create-modal.stage.details.version-number-placeholder',
defaultMessage: 'Enter version number, e.g. 1.2.3-alpha.1',
},
versionNumberDescription: {
id: 'create-project-version.create-modal.stage.details.version-number-description',
defaultMessage: 'The version number differentiates this specific version from others.',
},
versionSubtitle: {
id: 'create-project-version.create-modal.stage.details.version-subtitle',
defaultMessage: 'Version subtitle',
},
versionSubtitlePlaceholder: {
id: 'create-project-version.create-modal.stage.details.version-subtitle-placeholder',
defaultMessage: 'Enter subtitle...',
},
versionChangelog: {
id: 'create-project-version.create-modal.stage.details.version-changelog',
defaultMessage: 'Version changelog',
},
metadataTab: {
id: 'create-project-version.create-modal.stage.details.metadata-tab',
defaultMessage: 'Metadata',
},
detailsTab: {
id: 'create-project-version.create-modal.stage.details.details-tab',
defaultMessage: 'Details',
},
filesTab: {
id: 'create-project-version.create-modal.stage.details.files-tab',
defaultMessage: 'Files',
},
})
const editTabs = computed<TabsTab[]>(() => [
{ label: formatMessage(messages.metadataTab), value: 'metadata' },
{ label: formatMessage(messages.detailsTab), value: 'add-details' },
{ label: formatMessage(messages.filesTab), value: 'add-files' },
])
function setEditTab(tab: TabsTab) {
modal.value?.setStage(tab.value)
@@ -9,12 +9,12 @@
<div class="flex flex-col gap-6">
<div v-if="!editingVersion" class="flex flex-col gap-1">
<div class="flex items-center justify-between">
<span class="font-semibold text-contrast"> Uploaded files </span>
<span class="font-semibold text-contrast">{{ formatMessage(messages.uploadedFiles) }}</span>
<ButtonStyled type="transparent" size="standard">
<button @click="editFiles">
<EditIcon />
Edit
{{ formatMessage(messages.editButton) }}
</button>
</ButtonStyled>
</div>
@@ -43,23 +43,27 @@
<div class="flex flex-col gap-1">
<div class="flex items-center justify-between">
<span class="font-semibold text-contrast">
{{ usingDetectedLoaders ? 'Detected loaders' : 'Loaders' }}
{{
usingDetectedLoaders
? formatMessage(messages.detectedLoaders)
: formatMessage(messages.loaders)
}}
</span>
<ButtonStyled type="transparent" size="standard">
<button
v-tooltip="
isModpack
? 'Modpack loaders cannot be edited'
? formatMessage(messages.modpackLoadersTooltip)
: isResourcePack
? 'Resource pack loaders cannot be edited'
? formatMessage(messages.resourcePackLoadersTooltip)
: undefined
"
:disabled="isModpack || isResourcePack"
@click="editLoaders"
>
<EditIcon />
Edit
{{ formatMessage(messages.editButton) }}
</button>
</ButtonStyled>
</div>
@@ -88,9 +92,11 @@
v-if="!draftVersionLoaders.length && projectType === 'modpack'"
class="border !border-solid border-surface-5 hover:no-underline"
>
No mod loader
{{ formatMessage(messages.noModLoader) }}
</TagItem>
<span v-else-if="!draftVersionLoaders.length">No loaders selected.</span>
<span v-else-if="!draftVersionLoaders.length">{{
formatMessage(messages.noLoadersSelected)
}}</span>
</div>
</div>
</div>
@@ -98,17 +104,21 @@
<div class="flex flex-col gap-1">
<div class="flex items-center justify-between">
<span class="font-semibold text-contrast">
{{ usingDetectedVersions ? 'Detected versions' : 'Versions' }}
{{
usingDetectedVersions
? formatMessage(messages.detectedVersions)
: formatMessage(messages.versions)
}}
</span>
<ButtonStyled type="transparent" size="standard">
<button
v-tooltip="isModpack ? 'Modpack versions cannot be edited' : undefined"
v-tooltip="isModpack ? formatMessage(messages.modpackVersionsTooltip) : undefined"
:disabled="isModpack"
@click="editVersions"
>
<EditIcon />
Edit
{{ formatMessage(messages.editButton) }}
</button>
</ButtonStyled>
</div>
@@ -125,7 +135,9 @@
{{ version }}
</TagItem>
<span v-if="!draftVersion.game_versions.length">No versions selected.</span>
<span v-if="!draftVersion.game_versions.length">{{
formatMessage(messages.noVersionsSelected)
}}</span>
</div>
</div>
</div>
@@ -134,14 +146,16 @@
<div class="flex flex-col gap-1">
<div class="flex items-center justify-between">
<div class="flex items-center gap-2">
<span class="font-semibold text-contrast"> Environment </span>
<UnknownIcon v-tooltip="'Pre-filled from a previous similar version'" />
<span class="font-semibold text-contrast">{{
formatMessage(messages.environment)
}}</span>
<UnknownIcon v-tooltip="formatMessage(messages.prefilledEnvironmentTooltip)" />
</div>
<ButtonStyled type="transparent" size="standard">
<button @click="editEnvironment">
<EditIcon />
Edit
{{ formatMessage(messages.editButton) }}
</button>
</ButtonStyled>
</div>
@@ -154,7 +168,9 @@
<div class="text-sm font-medium">{{ environmentCopy.description }}</div>
</div>
<span v-else class="text-sm font-medium">No environment has been set.</span>
<span v-else class="text-sm font-medium">{{
formatMessage(messages.noEnvironmentSet)
}}</span>
</div>
</div>
</template>
@@ -163,12 +179,14 @@
<div class="flex flex-col gap-2.5">
<div class="flex flex-col gap-1">
<div class="flex items-center justify-between">
<span class="font-semibold text-contrast"> Dependencies </span>
<span class="font-semibold text-contrast">{{
formatMessage(messages.dependencies)
}}</span>
<ButtonStyled type="transparent" size="standard">
<button @click="addDependency">
<PlusIcon />
Add dependency
{{ formatMessage(messages.addDependency) }}
</button>
</ButtonStyled>
</div>
@@ -177,13 +195,15 @@
<DependenciesList />
</div>
<div v-else class="flex flex-col gap-1.5 gap-y-4 rounded-xl bg-surface-2 p-3 py-4">
<span class="text-sm font-medium">No dependencies added.</span>
<span class="text-sm font-medium">{{
formatMessage(messages.noDependenciesAdded)
}}</span>
</div>
</div>
<div v-if="visibleSuggestedDependencies.length" class="flex flex-col gap-2.5">
<div class="flex items-center justify-between">
<span class="font-medium"> Suggested </span>
<span class="font-medium">{{ formatMessage(messages.suggested) }}</span>
</div>
<SuggestedDependencies @on-add-suggestion="handleAddSuggestedDependency" />
</div>
@@ -231,11 +251,11 @@ const { projectV2 } = injectProjectPageContext()
const generatedState = useGeneratedState()
const loaders = computed(() => generatedState.value.loaders)
const editTabs: TabsTab[] = [
{ label: 'Metadata', value: 'metadata' },
{ label: 'Details', value: 'add-details' },
{ label: 'Files', value: 'add-files' },
]
const editTabs = computed<TabsTab[]>(() => [
{ label: formatMessage(messages.metadataTab), value: 'metadata' },
{ label: formatMessage(messages.detailsTab), value: 'add-details' },
{ label: formatMessage(messages.filesTab), value: 'add-files' },
])
function setEditTab(tab: TabsTab) {
modal.value?.setStage(tab.value)
@@ -341,6 +361,97 @@ const supplementaryExistingFiles = computed(() => {
const { formatMessage } = useVIntl()
const messages = defineMessages({
uploadedFiles: {
id: 'create-project-version.create-modal.stage.metadata.uploaded-files',
defaultMessage: 'Uploaded files',
},
editButton: {
id: 'create-project-version.create-modal.stage.metadata.edit-button',
defaultMessage: 'Edit',
},
detectedLoaders: {
id: 'create-project-version.create-modal.stage.metadata.detected-loaders',
defaultMessage: 'Detected loaders',
},
loaders: {
id: 'create-project-version.create-modal.stage.metadata.loaders',
defaultMessage: 'Loaders',
},
modpackLoadersTooltip: {
id: 'create-project-version.create-modal.stage.metadata.modpack-loaders-tooltip',
defaultMessage: 'Modpack loaders cannot be edited',
},
resourcePackLoadersTooltip: {
id: 'create-project-version.create-modal.stage.metadata.resource-pack-loaders-tooltip',
defaultMessage: 'Resource pack loaders cannot be edited',
},
noModLoader: {
id: 'create-project-version.create-modal.stage.metadata.no-mod-loader',
defaultMessage: 'No mod loader',
},
noLoadersSelected: {
id: 'create-project-version.create-modal.stage.metadata.no-loaders-selected',
defaultMessage: 'No loaders selected.',
},
detectedVersions: {
id: 'create-project-version.create-modal.stage.metadata.detected-versions',
defaultMessage: 'Detected versions',
},
versions: {
id: 'create-project-version.create-modal.stage.metadata.versions',
defaultMessage: 'Versions',
},
modpackVersionsTooltip: {
id: 'create-project-version.create-modal.stage.metadata.modpack-versions-tooltip',
defaultMessage: 'Modpack versions cannot be edited',
},
noVersionsSelected: {
id: 'create-project-version.create-modal.stage.metadata.no-versions-selected',
defaultMessage: 'No versions selected.',
},
environment: {
id: 'create-project-version.create-modal.stage.metadata.environment',
defaultMessage: 'Environment',
},
prefilledEnvironmentTooltip: {
id: 'create-project-version.create-modal.stage.metadata.prefilled-environment-tooltip',
defaultMessage: 'Pre-filled from a previous similar version',
},
noEnvironmentSet: {
id: 'create-project-version.create-modal.stage.metadata.no-environment-set',
defaultMessage: 'No environment has been set.',
},
dependencies: {
id: 'create-project-version.create-modal.stage.metadata.dependencies',
defaultMessage: 'Dependencies',
},
addDependency: {
id: 'create-project-version.create-modal.stage.metadata.add-dependency',
defaultMessage: 'Add dependency',
},
noDependenciesAdded: {
id: 'create-project-version.create-modal.stage.metadata.no-dependencies-added',
defaultMessage: 'No dependencies added.',
},
suggested: {
id: 'create-project-version.create-modal.stage.metadata.suggested',
defaultMessage: 'Suggested',
},
metadataTab: {
id: 'create-project-version.create-modal.stage.metadata.metadata-tab',
defaultMessage: 'Metadata',
},
detailsTab: {
id: 'create-project-version.create-modal.stage.metadata.details-tab',
defaultMessage: 'Details',
},
filesTab: {
id: 'create-project-version.create-modal.stage.metadata.files-tab',
defaultMessage: 'Files',
},
})
const noEnvironmentMessage = defineMessages({
title: {
id: 'version.environment.none.title',
@@ -246,6 +246,18 @@ const messages = defineMessages({
id: 'create.project.missing-fields-tooltip',
defaultMessage: 'Missing fields: {fields}',
},
unknownUser: {
id: 'create.project.unknown-user',
defaultMessage: 'Unknown user',
},
userAvatarAlt: {
id: 'create.project.user-avatar-alt',
defaultMessage: 'User Avatar',
},
organizationIconAlt: {
id: 'create.project.organization-icon-alt',
defaultMessage: '{name} Icon',
},
})
const props = defineProps<{
@@ -318,7 +330,7 @@ const cancel = () => {
const userOption = computed(() => ({
value: 'self',
label: auth.value.user?.username || 'Unknown user',
label: auth.value.user?.username || formatMessage(messages.unknownUser),
icon: auth.value.user?.avatar_url
? markRaw(
defineAsyncComponent(() =>
@@ -326,7 +338,7 @@ const userOption = computed(() => ({
setup: () => () =>
h('img', {
src: auth.value.user?.avatar_url,
alt: 'User Avatar',
alt: formatMessage(messages.userAvatarAlt),
class: 'h-5 w-5 rounded-full',
}),
}),
@@ -357,7 +369,7 @@ async function fetchOrganizations() {
setup: () => () =>
h('img', {
src: org.icon_url,
alt: `${org.name} Icon`,
alt: formatMessage(messages.organizationIconAlt, { name: org.name }),
class: 'h-5 w-5 rounded',
}),
}),
@@ -137,7 +137,9 @@
</div>
<div class="flex w-full flex-row justify-stretch gap-2">
<ButtonStyled>
<button class="w-full text-contrast" @click="handleClose">{{ closeButtonText }}</button>
<button class="w-full text-contrast" @click="handleClose">
{{ props.closeButtonText ?? formatMessage(messages.closeButton) }}
</button>
</ButtonStyled>
<ButtonStyled color="green">
<button class="w-full text-contrast" @click="downloadTaxForm">
@@ -182,7 +184,7 @@ const props = withDefaults(
emitSuccessOnClose?: boolean
}>(),
{
closeButtonText: 'Close',
closeButtonText: undefined,
emitSuccessOnClose: true,
},
)
@@ -268,6 +270,18 @@ const messages = defineMessages({
id: 'dashboard.creator-tax-form-modal.confirmation.download-button',
defaultMessage: 'Download {formType}',
},
closeButton: {
id: 'dashboard.creator-tax-form-modal.close-button',
defaultMessage: 'Close',
},
incompleteTitle: {
id: 'dashboard.creator-tax-form-modal.incomplete.title',
defaultMessage: 'Tax form incomplete',
},
incompleteText: {
id: 'dashboard.creator-tax-form-modal.incomplete.text',
defaultMessage: 'You have not completed the tax form. Please try again.',
},
})
const isUSCitizen = ref<'yes' | 'no' | null>(null)
@@ -371,8 +385,8 @@ async function continueForm() {
}
addNotification({
title: 'Tax form incomplete',
text: 'You have not completed the tax form. Please try again.',
title: formatMessage(messages.incompleteTitle),
text: formatMessage(messages.incompleteText),
type: 'warning',
})
}
@@ -54,7 +54,7 @@
<MuralpayDetailsStage v-else-if="currentStage === 'muralpay-details'" />
<LegacyPaypalDetailsStage v-else-if="currentStage === 'paypal-details'" />
<CompletionStage v-else-if="currentStage === 'completion'" />
<div v-else>Something went wrong</div>
<div v-else>{{ formatMessage(commonMessages.errorNotificationTitle) }}</div>
</div>
<template #actions>
<div v-if="currentStage === 'completion'" class="mt-4 flex w-full gap-3">
@@ -96,7 +96,7 @@
</NewModal>
<CreatorTaxFormModal
ref="taxFormModal"
close-button-text="Continue"
:close-button-text="formatMessage(commonMessages.continueButton)"
@success="onTaxFormSuccess"
@cancelled="onTaxFormCancelled"
/>
@@ -99,7 +99,7 @@
v-model="formData.dateOfBirth"
:max-date="maxDate"
autocomplete="bday"
placeholder="Select date of birth"
:placeholder="formatMessage(messages.dateOfBirthPlaceholder)"
wrapper-class="w-full"
/>
</div>
@@ -162,7 +162,7 @@
:options="subdivisionOptions"
:placeholder="formatMessage(formFieldPlaceholders.statePlaceholder)"
searchable
search-placeholder="Search subdivisions..."
:search-placeholder="formatMessage(messages.searchSubdivisionsPlaceholder)"
/>
<StyledInput
v-else
@@ -201,7 +201,7 @@
:options="countryOptions"
:placeholder="formatMessage(formFieldPlaceholders.countryPlaceholder)"
searchable
search-placeholder="Search countries..."
:search-placeholder="formatMessage(messages.searchCountriesPlaceholder)"
/>
</div>
</div>
@@ -361,5 +361,17 @@ const messages = defineMessages({
id: 'dashboard.creator-withdraw-modal.kyc.business-entity',
defaultMessage: 'Business entity',
},
dateOfBirthPlaceholder: {
id: 'dashboard.creator-withdraw-modal.kyc.date-of-birth-placeholder',
defaultMessage: 'Select date of birth',
},
searchSubdivisionsPlaceholder: {
id: 'dashboard.creator-withdraw-modal.kyc.search-subdivisions-placeholder',
defaultMessage: 'Search subdivisions...',
},
searchCountriesPlaceholder: {
id: 'dashboard.creator-withdraw-modal.kyc.search-countries-placeholder',
defaultMessage: 'Search countries...',
},
})
</script>
@@ -58,9 +58,17 @@
<Combobox
v-model="selectedGiftCardId"
:options="rewardOptions"
:placeholder="`Select ${categoryLabel.toLowerCase()}`"
:placeholder="
formatMessage(messages.selectCategoryPlaceholder, {
category: categoryLabel.toLowerCase(),
})
"
searchable
:search-placeholder="`Search ${categoryLabelPlural.toLowerCase()}...`"
:search-placeholder="
formatMessage(messages.searchCategoryPlaceholder, {
category: categoryLabelPlural.toLowerCase(),
})
"
class="h-10"
>
<template #selected>
@@ -90,47 +98,21 @@
</Combobox>
</div>
<span v-if="selectedMethodDetails" class="text-secondary">
{{ formatMoney(displayMinUsd)
}}<template v-if="selectedMethodCurrencyCode && selectedMethodCurrencyCode !== 'USD'"
>({{
formatAmountForDisplay(
displayMinLocal,
selectedMethodCurrencyCode,
selectedMethodExchangeRate,
)
}})</template
>
min<template v-if="displayMinUsd <= roundedMaxAmount"
>, {{ formatMoney(displayMaxUsd)
}}<template v-if="selectedMethodCurrencyCode && selectedMethodCurrencyCode !== 'USD'"
>({{
formatAmountForDisplay(
displayMaxLocal,
selectedMethodCurrencyCode,
selectedMethodExchangeRate,
)
}})</template
>
max</template
>
withdrawal amount.
<template v-if="displayMinUsd <= roundedMaxAmount">{{
formatMessage(messages.withdrawalAmountMinMax, {
min: minAmountDisplay,
max: maxAmountDisplay,
})
}}</template>
<template v-else>{{
formatMessage(messages.withdrawalAmountMinOnly, { min: minAmountDisplay })
}}</template>
</span>
<span
v-if="selectedMethodDetails && effectiveMinAmount > roundedMaxAmount"
class="text-sm text-red"
>
You need at least
{{ formatMoney(displayMinUsd)
}}<template v-if="selectedMethodCurrencyCode && selectedMethodCurrencyCode !== 'USD'"
>({{
formatAmountForDisplay(
displayMinLocal,
selectedMethodCurrencyCode,
selectedMethodExchangeRate,
)
}})</template
>
to use this gift card.
{{ formatMessage(messages.needAtLeastGiftCard, { min: minAmountDisplay }) }}
</span>
</div>
@@ -280,19 +262,13 @@
v-if="!useDenominationSuggestions && denominationOptions.length === 0"
class="text-error text-sm"
>
<template v-if="rawFixedDenominationMin !== null">
The minimum denomination is
{{
formatAmountForDisplay(
rawFixedDenominationMin,
selectedMethodCurrencyCode,
selectedMethodExchangeRate,
)
}}<template v-if="selectedMethodCurrencyCode && selectedMethodCurrencyCode !== 'USD'">
({{ formatMoney(convertToUsd(rawFixedDenominationMin)) }})</template
>, which exceeds your balance of {{ formatMoney(roundedMaxAmount) }}.
</template>
<template v-else>No denominations available for your current balance</template>
<template v-if="rawFixedDenominationMin !== null">{{
formatMessage(messages.minimumDenominationExceedsBalance, {
min: minDenominationDisplay,
balance: formatMoney(roundedMaxAmount),
})
}}</template>
<template v-else>{{ formatMessage(messages.noDenominationsAvailable) }}</template>
</span>
</div>
@@ -683,13 +659,50 @@ const noSuggestionsMessage = computed(() => {
selectedMethodCurrencyCode.value,
selectedMethodExchangeRate.value,
)
return `No denominations near this amount. The highest available is ${maxInLocal}.`
return formatMessage(messages.noSuggestionsWithMax, { max: maxInLocal })
}
return 'No denominations near this amount'
return formatMessage(messages.noSuggestionsNoMax)
}
return null
})
const minAmountDisplay = computed(() => {
const base = formatMoney(displayMinUsd.value)
if (selectedMethodCurrencyCode.value && selectedMethodCurrencyCode.value !== 'USD') {
return `${base} (${formatAmountForDisplay(
displayMinLocal.value,
selectedMethodCurrencyCode.value,
selectedMethodExchangeRate.value,
)})`
}
return base
})
const maxAmountDisplay = computed(() => {
const base = formatMoney(displayMaxUsd.value)
if (selectedMethodCurrencyCode.value && selectedMethodCurrencyCode.value !== 'USD') {
return `${base} (${formatAmountForDisplay(
displayMaxLocal.value,
selectedMethodCurrencyCode.value,
selectedMethodExchangeRate.value,
)})`
}
return base
})
const minDenominationDisplay = computed(() => {
if (rawFixedDenominationMin.value === null) return ''
const local = formatAmountForDisplay(
rawFixedDenominationMin.value,
selectedMethodCurrencyCode.value,
selectedMethodExchangeRate.value,
)
if (selectedMethodCurrencyCode.value && selectedMethodCurrencyCode.value !== 'USD') {
return `${local} (${formatMoney(convertToUsd(rawFixedDenominationMin.value))})`
}
return local
})
const hasSelectedDenomination = computed(() => {
return (
formData.value.amount !== undefined &&
@@ -1124,5 +1137,41 @@ const messages = defineMessages({
id: 'dashboard.creator-withdraw-modal.tremendous-details.select-denomination-required',
defaultMessage: 'Please select a denomination to continue',
},
selectCategoryPlaceholder: {
id: 'dashboard.creator-withdraw-modal.tremendous-details.select-category-placeholder',
defaultMessage: 'Select {category}',
},
searchCategoryPlaceholder: {
id: 'dashboard.creator-withdraw-modal.tremendous-details.search-category-placeholder',
defaultMessage: 'Search {category}...',
},
withdrawalAmountMinMax: {
id: 'dashboard.creator-withdraw-modal.tremendous-details.withdrawal-amount-min-max',
defaultMessage: '{min} min, {max} max withdrawal amount.',
},
withdrawalAmountMinOnly: {
id: 'dashboard.creator-withdraw-modal.tremendous-details.withdrawal-amount-min-only',
defaultMessage: '{min} min withdrawal amount.',
},
needAtLeastGiftCard: {
id: 'dashboard.creator-withdraw-modal.tremendous-details.need-at-least-gift-card',
defaultMessage: 'You need at least {min} to use this gift card.',
},
minimumDenominationExceedsBalance: {
id: 'dashboard.creator-withdraw-modal.tremendous-details.minimum-denomination-exceeds-balance',
defaultMessage: 'The minimum denomination is {min}, which exceeds your balance of {balance}.',
},
noDenominationsAvailable: {
id: 'dashboard.creator-withdraw-modal.tremendous-details.no-denominations-available',
defaultMessage: 'No denominations available for your current balance',
},
noSuggestionsWithMax: {
id: 'dashboard.creator-withdraw-modal.tremendous-details.no-suggestions-with-max',
defaultMessage: 'No denominations near this amount. The highest available is {max}.',
},
noSuggestionsNoMax: {
id: 'dashboard.creator-withdraw-modal.tremendous-details.no-suggestions-no-max',
defaultMessage: 'No denominations near this amount',
},
})
</script>
@@ -5,16 +5,21 @@
<p class="m-0 break-words font-semibold text-contrast">
{{ trace.project_name }}
</p>
<p class="m-0 mt-1 break-all text-sm text-secondary">
Project {{ trace.project_slug ?? trace.project_id }} / Version
{{ trace.version_number }} / File {{ trace.file_name }}
<p class="m-0 mt-1 flex flex-wrap items-center gap-1 text-sm text-secondary">
<span class="break-all">{{ trace.version_number }}</span>
<ChevronRightIcon class="size-4 shrink-0" aria-hidden="true" />
<span class="break-all">{{ decodeTracePath(trace.file_name) }}</span>
<template v-if="trace.jar">
<ChevronRightIcon class="size-4 shrink-0" aria-hidden="true" />
<span class="break-all">{{ decodeTracePath(trace.jar) }}</span>
</template>
</p>
</div>
<div class="flex flex-wrap items-center gap-2">
<span class="text-sm text-secondary">Local</span>
<Badge :type="trace.local_status" />
<span class="text-sm text-secondary">Effective</span>
<Badge :type="trace.effective_status" />
<template v-if="trace.local_status !== 'pending'">
<span class="text-sm text-secondary">Local</span>
<Badge :type="trace.local_status" />
</template>
<ButtonStyled>
<NuxtLink :to="localTraceLink">
<ExternalIcon aria-hidden="true" />
@@ -23,31 +28,12 @@
</ButtonStyled>
</div>
</div>
<div class="mt-3 grid gap-2 text-sm text-secondary md:grid-cols-2">
<p class="m-0 break-all">
<span class="font-semibold text-contrast">Issue</span>
{{ trace.issue_type }}
</p>
<p class="m-0 break-all">
<span class="font-semibold text-contrast">Severity</span>
{{ trace.severity }}
</p>
<p class="m-0 break-all">
<span class="font-semibold text-contrast">Path</span>
{{ trace.file_path }}
</p>
<p v-if="trace.jar" class="m-0 break-all">
<span class="font-semibold text-contrast">JAR</span>
{{ trace.jar }}
</p>
</div>
</div>
</template>
<script setup lang="ts">
import type { Labrinth } from '@modrinth/api-client'
import { ExternalIcon } from '@modrinth/assets'
import { ChevronRightIcon, ExternalIcon } from '@modrinth/assets'
import { Badge, ButtonStyled } from '@modrinth/ui'
const props = defineProps<{
@@ -60,4 +46,12 @@ const localTraceLink = computed(
props.trace.detail_id,
)}`,
)
function decodeTracePath(path: string): string {
try {
return decodeURIComponent(path)
} catch {
return path
}
}
</script>
@@ -42,20 +42,34 @@
:key="trace.detail_key"
class="universal-card flex flex-col gap-3"
>
<div class="flex flex-wrap items-start justify-between gap-3">
<div class="grid grid-cols-[minmax(0,1fr)_auto] items-start gap-3">
<div class="min-w-0">
<div class="flex min-w-0 items-center gap-2">
<div class="flex min-w-0 flex-wrap items-center gap-2">
<HashIcon class="shrink-0 text-secondary" aria-hidden="true" />
<h2 class="m-0 min-w-0 text-lg font-semibold text-contrast">
Trace
<span class="break-all font-mono text-base">{{ trace.detail_key }}</span>
</h2>
<span
v-if="getLatestLocalTrace(trace)"
class="rounded-full border border-solid px-2.5 py-1 text-sm font-medium capitalize"
:class="getSeverityBadgeColor(getLatestLocalTrace(trace)?.severity)"
>
{{ getLatestLocalTrace(trace)?.severity }}
</span>
</div>
<div v-if="getLatestLocalTrace(trace)" class="mt-1 flex flex-wrap gap-x-3 text-sm">
<p class="m-0 break-all text-secondary">
<span class="font-semibold text-contrast">Issue</span>
{{ getLatestLocalTrace(trace)?.issue_type }}
</p>
<p class="m-0 break-all text-secondary">
<span class="font-semibold text-contrast">Path</span>
{{ decodeTracePath(getLatestLocalTrace(trace)?.file_path ?? '') }}
</p>
</div>
<p class="m-0 mt-1 text-sm text-secondary">
{{ formatTraceCount(trace.local_trace_count) }}
</p>
</div>
<div class="flex shrink-0 flex-wrap items-center gap-2">
<div class="flex shrink-0 flex-nowrap items-center gap-2">
<Badge :type="trace.verdict" />
<ButtonStyled color="red">
<button
@@ -70,15 +84,14 @@
</div>
<div v-if="getPreviewLocalTraces(trace).length > 0" class="flex flex-col gap-2">
<div
v-if="getVisibleLocalTraceTotal(trace) > getPreviewLocalTraces(trace).length"
class="flex flex-wrap items-center justify-between gap-2"
>
<div class="flex flex-wrap items-center justify-between gap-2">
<p class="m-0 text-sm text-secondary">
Showing first {{ getPreviewLocalTraces(trace).length }} of
{{ getVisibleLocalTraceTotal(trace) }} local traces
Showing {{ getPreviewLocalTraces(trace).length }} of
{{ formatTraceCount(getVisibleLocalTraceTotal(trace)) }}
</p>
<ButtonStyled>
<ButtonStyled
v-if="getVisibleLocalTraceTotal(trace) > getPreviewLocalTraces(trace).length"
>
<NuxtLink :to="getGlobalTraceLink(trace)">
<ListIcon aria-hidden="true" />
View all
@@ -129,7 +142,7 @@ const isLoading = ref(false)
const loadError = ref(false)
const currentPage = ref(1)
const itemsPerPage = 20
const localTracePreviewLimit = 10
const localTracePreviewLimit = 3
const total = ref(0)
const traces = ref<Labrinth.TechReview.Internal.GlobalIssueDetail[]>([])
const removingTraceKeys = reactive<Set<string>>(new Set())
@@ -148,6 +161,34 @@ function getPreviewLocalTraces(trace: Labrinth.TechReview.Internal.GlobalIssueDe
return trace.local_traces.slice(0, localTracePreviewLimit)
}
function getLatestLocalTrace(trace: Labrinth.TechReview.Internal.GlobalIssueDetail) {
return trace.local_traces.at(-1)
}
function decodeTracePath(path: string): string {
try {
return decodeURIComponent(path)
} catch {
return path
}
}
function getSeverityBadgeColor(
severity: Labrinth.TechReview.Internal.DelphiSeverity | undefined,
): string {
switch (severity) {
case 'severe':
return 'border-red/60 bg-highlight-red text-red'
case 'high':
return 'border-orange/60 bg-highlight-orange text-orange'
case 'medium':
return 'border-green/60 bg-highlight-green text-green'
case 'low':
default:
return 'border-blue/60 bg-highlight-blue text-blue'
}
}
function getVisibleLocalTraceTotal(trace: Labrinth.TechReview.Internal.GlobalIssueDetail) {
return Math.max(trace.local_trace_count, trace.local_traces.length)
}
@@ -4,7 +4,6 @@ import {
BanIcon,
BugIcon,
CheckCheckIcon,
CheckCircleIcon,
CheckIcon,
ChevronDownIcon,
ChevronRightIcon,
@@ -20,7 +19,6 @@ import {
ShieldCheckIcon,
SpinnerIcon,
TimerIcon,
TriangleAlertIcon,
XIcon,
} from '@modrinth/assets'
import { type TechReviewContext, techReviewQuickReplies } from '@modrinth/moderation'
@@ -36,6 +34,7 @@ import {
NavTabs,
OverflowMenu,
type OverflowMenuOption,
Toggle,
useFormatBytes,
useFormatDateTime,
useVIntl,
@@ -247,6 +246,42 @@ function getAllDetails(): Labrinth.TechReview.Internal.ReportIssueDetail[] {
return props.item.reports.flatMap((report) => report.issues.flatMap((issue) => issue.details))
}
const hideGloballyPassed = ref(true)
function isDetailGloballyPassed(detail: Labrinth.TechReview.Internal.ReportIssueDetail): boolean {
if (detailDecisionScopes.get(detail.id) === 'global') {
return detailDecisions.get(detail.id) === 'safe'
}
return detail.global_status === 'safe'
}
function isDetailGloballyResolved(detail: Labrinth.TechReview.Internal.ReportIssueDetail): boolean {
if (detailDecisionScopes.get(detail.id) === 'global') {
return detailDecisions.get(detail.id) !== 'pending'
}
return detail.global_status === 'safe' || detail.global_status === 'unsafe'
}
const globallyPassedSelectedFileCount = computed(() => {
if (!selectedFile.value) return 0
return selectedFile.value.issues.reduce(
(count, issue) => count + issue.details.filter(isDetailGloballyPassed).length,
0,
)
})
const globallyResolvedSelectedFileCount = computed(() => {
if (!selectedFile.value) return 0
return selectedFile.value.issues.reduce(
(count, issue) => count + issue.details.filter(isDetailGloballyResolved).length,
0,
)
})
function applyDecisionToRelatedDetails(
detailIds: string[],
decision: DetailDecision,
@@ -397,10 +432,7 @@ watch(
allFiles.value = [...reports].sort((a, b) => {
const aComplete = getFileMarkedCount(a) === getFileDetailCount(a)
const bComplete = getFileMarkedCount(b) === getFileDetailCount(b)
if (aComplete !== bComplete) return aComplete ? 1 : -1
const aSeverity = getFileHighestSeverity(a)
const bSeverity = getFileHighestSeverity(b)
return (severityOrder[bSeverity] ?? 0) - (severityOrder[aSeverity] ?? 0)
return aComplete === bComplete ? 0 : aComplete ? 1 : -1
})
},
{ immediate: true },
@@ -615,6 +647,18 @@ const remainingUnmarkedCount = computed(() => {
return getFileDetailCount(selectedFile.value) - getFileMarkedCount(selectedFile.value)
})
function getSelectedFileFlags(): ClassGroup['flags'] {
if (!selectedFile.value) return []
return selectedFile.value.issues.flatMap((issue) =>
issue.details.map((detail) => ({
issueId: issue.id,
issueType: issue.issue_type,
detail,
})),
)
}
function getJarFlags(jarGroup: JarGroup): ClassGroup['flags'] {
return jarGroup.classes.flatMap((classItem) => classItem.flags)
}
@@ -629,6 +673,74 @@ function getJarRemainingUnmarkedCount(jarGroup: JarGroup): number {
const isBatchUpdating = ref(false)
function getRemainingGlobalDetailCount(flags: ClassGroup['flags']): number {
return new Set(
flags
.filter(
(flag) =>
getDetailDecision(flag.detail.id, flag.detail.status) === 'pending' &&
canUpdateGlobalDetail(flag.detail),
)
.map((flag) => flag.detail.key),
).size
}
async function batchMarkRemainingGlobally(flags: ClassGroup['flags'], verdict: 'safe' | 'unsafe') {
if (isBatchUpdating.value) return
const detailsByKey = new Map(
flags
.filter(
(flag) =>
getDetailDecision(flag.detail.id, flag.detail.status) === 'pending' &&
canUpdateGlobalDetail(flag.detail),
)
.map((flag) => [flag.detail.key, flag.detail]),
)
const details = [...detailsByKey.values()]
if (details.length === 0) return
isBatchUpdating.value = true
try {
await client.labrinth.tech_review_internal.updateGlobalIssueDetails(
details.map((detail) => ({ detail_key: detail.key, verdict })),
)
applyDecisionToRelatedDetails(
details.map((detail) => detail.id),
verdictToDecision(verdict),
'global',
)
addNotification({
type: 'success',
title: `Globally marked ${details.length} trace keys as ${verdict}`,
text: `All remaining eligible traces have been globally marked as ${
verdict === 'safe' ? 'false positives' : 'malicious'
}.`,
})
if (
selectedFile.value &&
getFileMarkedCount(selectedFile.value) === getFileDetailCount(selectedFile.value)
) {
backToFileList()
}
emit('refetch')
} catch (error) {
console.error('Failed to batch update global traces:', error)
addNotification({
type: 'error',
title: 'Global batch update failed',
text: 'An error occurred while globally updating traces.',
})
} finally {
isBatchUpdating.value = false
}
}
async function batchMarkRemaining(verdict: 'safe' | 'unsafe') {
if (!selectedFile.value || isBatchUpdating.value) return
@@ -913,7 +1025,7 @@ interface JarGroup {
function splitJarSegments(jar: string | null, currentFileName: string | null): string[] {
if (!jar) return []
const segments = jar
.split('#')
.split(/[/#]/)
.map((s) => decodeURIComponent(s.trim()))
.filter((s) => s.length > 0)
// Skip the first segment if it matches the current file tab (it's already shown in the file list)
@@ -934,6 +1046,10 @@ const groupedByClass = computed<ClassGroup[]>(() => {
for (const issue of selectedFile.value.issues) {
for (const detail of issue.details) {
if (hideGloballyPassed.value && isDetailGloballyPassed(detail)) {
continue
}
const classKey = `${detail.jar ?? ''}::${detail.file_path}`
if (!classMap.has(classKey)) {
classMap.set(classKey, {
@@ -959,20 +1075,11 @@ const groupedByClass = computed<ClassGroup[]>(() => {
classGroup.flags.sort((a, b) => {
const aPreReviewed = isPreReviewed(a.detail.id, a.detail.status)
const bPreReviewed = isPreReviewed(b.detail.id, b.detail.status)
if (aPreReviewed !== bPreReviewed) {
return aPreReviewed ? 1 : -1
}
return (severityOrder[b.detail.severity] ?? 0) - (severityOrder[a.detail.severity] ?? 0)
return aPreReviewed === bPreReviewed ? 0 : aPreReviewed ? 1 : -1
})
}
return Array.from(classMap.values()).sort((a, b) => {
const aSeverity = getHighestSeverityInClass(a.flags)
const bSeverity = getHighestSeverityInClass(b.flags)
return (severityOrder[bSeverity] ?? 0) - (severityOrder[aSeverity] ?? 0)
})
return Array.from(classMap.values())
})
const groupedByJar = computed<JarGroup[]>(() => {
@@ -994,11 +1101,7 @@ const groupedByJar = computed<JarGroup[]>(() => {
return Array.from(jarMap.values()).sort((a, b) => {
const aRoot = isRootJarGroup(a)
const bRoot = isRootJarGroup(b)
if (aRoot !== bRoot) return aRoot ? -1 : 1
const aSeverity = getHighestSeverityInClass(a.classes.flatMap((classItem) => classItem.flags))
const bSeverity = getHighestSeverityInClass(b.classes.flatMap((classItem) => classItem.flags))
return (severityOrder[bSeverity] ?? 0) - (severityOrder[aSeverity] ?? 0)
return aRoot === bRoot ? 0 : aRoot ? -1 : 1
})
})
@@ -1589,21 +1692,73 @@ function copyId() {
<template v-else-if="currentTab === 'File' && selectedFile">
<div
v-if="remainingUnmarkedCount > 0"
class="flex gap-2 border-x border-b border-t-0 border-solid border-surface-3 bg-surface-2 p-4"
v-if="getFileDetailCount(selectedFile) > 0"
class="flex flex-wrap items-center justify-between gap-3 border-x border-b border-t-0 border-solid border-surface-3 bg-surface-2 p-4"
>
<ButtonStyled color="brand" :disabled="isBatchUpdating">
<button @click="batchMarkRemaining('safe')">
<CheckCircleIcon class="size-5" />
Remaining safe ({{ remainingUnmarkedCount }})
<div
v-if="remainingUnmarkedCount > 0"
class="detail-verdict-buttons"
role="group"
aria-label="Remaining issue actions"
>
<span class="remaining-verdict-label"
>Remaining issues ({{ remainingUnmarkedCount }})</span
>
<button
v-tooltip="'Remaining globally safe'"
class="detail-verdict-button detail-verdict-button--safe"
aria-label="Remaining globally safe"
:disabled="
isBatchUpdating || getRemainingGlobalDetailCount(getSelectedFileFlags()) === 0
"
@click="batchMarkRemainingGlobally(getSelectedFileFlags(), 'safe')"
>
<CheckCheckIcon aria-hidden="true" />
</button>
</ButtonStyled>
<ButtonStyled color="red" :disabled="isBatchUpdating">
<button @click="batchMarkRemaining('unsafe')">
<TriangleAlertIcon class="size-5" />
Remaining malware ({{ remainingUnmarkedCount }})
<button
v-tooltip="'Remaining safe'"
class="detail-verdict-button detail-verdict-button--safe"
aria-label="Remaining safe"
:disabled="isBatchUpdating"
@click="batchMarkRemaining('safe')"
>
<CheckIcon aria-hidden="true" />
</button>
</ButtonStyled>
<button
v-tooltip="'Remaining malware'"
class="detail-verdict-button detail-verdict-button--unsafe"
aria-label="Remaining malware"
:disabled="isBatchUpdating"
@click="batchMarkRemaining('unsafe')"
>
<BanIcon aria-hidden="true" />
</button>
<button
v-tooltip="'Remaining globally unsafe'"
class="detail-verdict-button detail-verdict-button--unsafe"
aria-label="Remaining globally unsafe"
:disabled="
isBatchUpdating || getRemainingGlobalDetailCount(getSelectedFileFlags()) === 0
"
@click="batchMarkRemainingGlobally(getSelectedFileFlags(), 'unsafe')"
>
<ShieldAlertIcon aria-hidden="true" />
</button>
</div>
<label class="ml-auto flex cursor-pointer items-center gap-3 text-sm">
<span class="text-right text-secondary">
Hide globally passed
<span class="text-tertiary block text-xs">
{{ globallyResolvedSelectedFileCount }}/{{ getFileDetailCount(selectedFile) }}
traces globally resolved
</span>
</span>
<Toggle
v-model="hideGloballyPassed"
:disabled="globallyPassedSelectedFileCount === 0"
small
/>
</label>
</div>
<div
v-for="jarGroup in groupedByJar"
@@ -1637,25 +1792,55 @@ function copyId() {
</template>
</div>
<div v-if="getJarRemainingUnmarkedCount(jarGroup) > 0" class="flex gap-2">
<ButtonStyled color="brand" size="small">
<button
:disabled="isBatchUpdating"
@click="batchMarkRemainingInJar(jarGroup, 'safe')"
>
<CheckCircleIcon class="size-4" />
Remaining safe ({{ getJarRemainingUnmarkedCount(jarGroup) }})
</button>
</ButtonStyled>
<ButtonStyled color="red" size="small">
<button
:disabled="isBatchUpdating"
@click="batchMarkRemainingInJar(jarGroup, 'unsafe')"
>
<TriangleAlertIcon class="size-4" />
Remaining malware ({{ getJarRemainingUnmarkedCount(jarGroup) }})
</button>
</ButtonStyled>
<div
v-if="getJarRemainingUnmarkedCount(jarGroup) > 0"
class="detail-verdict-buttons"
role="group"
aria-label="Remaining JAR issue actions"
>
<span class="remaining-verdict-label">
Remaining issues ({{ getJarRemainingUnmarkedCount(jarGroup) }})
</span>
<button
v-tooltip="'Remaining globally safe'"
class="detail-verdict-button detail-verdict-button--safe"
aria-label="Remaining globally safe"
:disabled="
isBatchUpdating || getRemainingGlobalDetailCount(getJarFlags(jarGroup)) === 0
"
@click="batchMarkRemainingGlobally(getJarFlags(jarGroup), 'safe')"
>
<CheckCheckIcon aria-hidden="true" />
</button>
<button
v-tooltip="'Remaining safe'"
class="detail-verdict-button detail-verdict-button--safe"
aria-label="Remaining safe"
:disabled="isBatchUpdating"
@click="batchMarkRemainingInJar(jarGroup, 'safe')"
>
<CheckIcon aria-hidden="true" />
</button>
<button
v-tooltip="'Remaining malware'"
class="detail-verdict-button detail-verdict-button--unsafe"
aria-label="Remaining malware"
:disabled="isBatchUpdating"
@click="batchMarkRemainingInJar(jarGroup, 'unsafe')"
>
<BanIcon aria-hidden="true" />
</button>
<button
v-tooltip="'Remaining globally unsafe'"
class="detail-verdict-button detail-verdict-button--unsafe"
aria-label="Remaining globally unsafe"
:disabled="
isBatchUpdating || getRemainingGlobalDetailCount(getJarFlags(jarGroup)) === 0
"
@click="batchMarkRemainingGlobally(getJarFlags(jarGroup), 'unsafe')"
>
<ShieldAlertIcon aria-hidden="true" />
</button>
</div>
</div>
</div>
@@ -1968,6 +2153,14 @@ pre {
background: var(--surface-3);
}
.remaining-verdict-label {
padding-inline: 0.75rem;
font-size: 0.875rem;
font-weight: 600;
white-space: nowrap;
color: var(--color-secondary);
}
.detail-verdict-button {
display: flex;
width: 2rem;
@@ -1,15 +1,17 @@
<script setup lang="ts">
import type { Labrinth } from '@modrinth/api-client'
import { FolderSearchIcon, StarIcon } from '@modrinth/assets'
import { FolderSearchIcon, StarIcon, TrashIcon } from '@modrinth/assets'
import {
ButtonStyled,
defineMessages,
injectModrinthClient,
injectNotificationManager,
NewModal,
Table,
type TableColumn,
useVIntl,
} from '@modrinth/ui'
import { useQueryClient } from '@tanstack/vue-query'
import { computed, ref, useTemplateRef } from 'vue'
const messages = defineMessages({
@@ -65,6 +67,10 @@ const messages = defineMessages({
id: 'modpack-scan-modal.scan-error',
defaultMessage: 'Some files failed to scan: {error}',
},
clearAllGroups: {
id: 'modpack-scan-modal.clear-all-groups',
defaultMessage: 'Clear All Groups',
},
})
type ScanTableColumn = 'filename' | 'newFiles' | 'newGroups'
@@ -85,12 +91,15 @@ const props = defineProps<{
}>()
const client = injectModrinthClient()
const queryClient = useQueryClient()
const { addNotification } = injectNotificationManager()
const modalRef = useTemplateRef<InstanceType<typeof NewModal>>('modalRef')
const { formatMessage } = useVIntl()
const rows = ref<ScanRow[]>([])
const isLoadingVersions = ref(false)
const isScanning = ref(false)
const isClearing = ref(false)
const versionLoadError = ref<string | null>(null)
const scanError = ref<string | null>(null)
const requestId = ref(0)
@@ -103,7 +112,7 @@ const columns = computed<TableColumn<ScanTableColumn>[]>(() => [
])
const scannedCount = computed(() => rows.value.filter((row) => row.scan || row.error).length)
const isBusy = computed(() => isLoadingVersions.value || isScanning.value)
const isBusy = computed(() => isLoadingVersions.value || isScanning.value || isClearing.value)
function getErrorMessage(error: unknown) {
if (error instanceof Error) {
@@ -208,6 +217,44 @@ async function fetchAllScans() {
}
}
async function clearAllGroups() {
if (isBusy.value) {
return
}
let failed = false
try {
isClearing.value = true
const groups = await client.labrinth.attribution_internal.listProjectAttribution(
props.project_id,
)
for (const group of groups) {
await client.labrinth.attribution_internal.deleteGroup(group.id)
}
await queryClient.invalidateQueries({ queryKey: ['project-attribution', props.project_id] })
} catch (error) {
failed = true
addNotification({
type: 'error',
title: 'An error occurred',
text: `Failed to clear all groups: ${getErrorMessage(error)}`,
})
} finally {
isClearing.value = false
}
if (!failed) {
addNotification({
type: 'success',
title: 'Success',
text: 'All groups cleared successfully.',
})
}
}
function show() {
scanRequestId.value++
isScanning.value = false
@@ -240,7 +287,16 @@ defineExpose({ show, hide })
})
}}
</span>
<div>
<div class="flex items-center gap-2">
<ButtonStyled circular>
<button
v-tooltip="formatMessage(messages.clearAllGroups)"
:disabled="isBusy || rows.length === 0"
@click="clearAllGroups"
>
<TrashIcon aria-hidden="true" />
</button>
</ButtonStyled>
<ButtonStyled circular>
<button
v-tooltip="formatMessage(messages.scanAllFiles)"
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,780 @@
<script lang="ts" setup>
import type {
BooleanNodeBuilder,
ButtonNodeBuilder,
ChildNode,
DropdownNodeBuilder,
GroupNodeBuilder,
IdentifiedNodeBuilder,
InputNodeBuilder,
LabeledNodeBuilder,
NodeState,
NodeStateWithChildren,
OverrideValue,
ValueNodeBuilder,
} from '@modrinth/moderation'
import {
evalSegment,
expandVariables,
flattenProjectV3Variables,
flattenProjectVariables,
flattenStaticVariables,
getBooleanChildState,
NodeBuilder,
resolve,
resolveChildren,
setMessageProject,
} from '@modrinth/moderation'
import {
ButtonStyled,
Checkbox,
Combobox,
injectProjectPageContext,
MarkdownEditor,
StyledInput,
} from '@modrinth/ui'
import { renderHighlightedString, renderString } from '@modrinth/utils'
import { inject, nextTick, onMounted, reactive, watchEffect } from 'vue'
import { NODE_META_KEY, STATE_KEY } from './checklist-context'
const nodeMetaMap = inject(NODE_META_KEY)
const injectedGlobalState = inject(STATE_KEY)
const { projectV3: project, projectV2 } = injectProjectPageContext()
setMessageProject(project, projectV2)
const props = defineProps<{
nodes: ChildNode[]
showContext: Record<string, NodeState>
onImageUpload?: (file: File) => Promise<string>
flex?: boolean
titleDepth?: number
parentStatePath?: string[]
}>()
function titleClass(depth: number): string {
if (depth === 0) return 'text-lg font-extrabold text-contrast'
if (depth === 1) return 'text-base font-semibold'
if (depth === 2) return 'text-sm font-semibold'
return ''
}
function isVisible(node: NodeBuilder): boolean {
if (node._shown !== undefined) return resolve(node._shown)
if (node.type === 'group') {
const children = getChildren(node as IdentifiedNodeBuilder)
return children.some((c) => !(c instanceof NodeBuilder) || isVisible(c))
}
return true
}
function isEnabled(node: IdentifiedNodeBuilder): boolean {
const e = node._enabled
if (e === undefined) return true
if (typeof e === 'function') return e(props.showContext)
return resolve(e)
}
function isButtonEnabled(node: ButtonNodeBuilder): boolean {
const enabled = node._enabled
if (enabled === undefined) return true
if (typeof enabled === 'function') return enabled(props.showContext)
return resolve(enabled)
}
function asBool(node: NodeBuilder): BooleanNodeBuilder {
return node as BooleanNodeBuilder
}
function asButton(node: NodeBuilder): ButtonNodeBuilder {
return node as ButtonNodeBuilder
}
function asIdentified(node: NodeBuilder): IdentifiedNodeBuilder {
return node as IdentifiedNodeBuilder
}
function asLabeled(node: NodeBuilder): LabeledNodeBuilder {
return node as LabeledNodeBuilder
}
function asGroup(node: NodeBuilder): GroupNodeBuilder {
return node as GroupNodeBuilder
}
function asDropdown(node: NodeBuilder): DropdownNodeBuilder {
return node as DropdownNodeBuilder
}
function asInput(node: NodeBuilder): InputNodeBuilder {
return node as InputNodeBuilder
}
function getAtPath(path: string[]): NodeState {
let current: unknown = injectedGlobalState!.value
for (const key of path) {
if (current == null || typeof current !== 'object' || current instanceof Set) return undefined
current = (current as Record<string, unknown>)[key]
}
return current as NodeState
}
function setAtPath(path: string[], value: NodeState): void {
if (path.length === 0) return
const global = injectedGlobalState!.value as unknown as Record<string, unknown>
let current = global
const stack: [Record<string, unknown>, string][] = []
for (let i = 0; i < path.length - 1; i++) {
const key = path[i]
const next = current[key]
if (!next || typeof next !== 'object' || next instanceof Set) {
if (value === undefined) return
current[key] = next !== null && next !== undefined ? { value: next } : {}
current = current[key] as Record<string, unknown>
} else {
stack.push([current, key])
current = next as Record<string, unknown>
}
}
const lastKey = path[path.length - 1]
if (value === undefined) {
Reflect.deleteProperty(current, lastKey)
for (let i = stack.length - 1; i >= 0; i--) {
const [parent, key] = stack[i]
const child = parent[key]
if (
child &&
typeof child === 'object' &&
!(child instanceof Set) &&
Object.keys(child as object).length === 0
) {
Reflect.deleteProperty(parent, key)
} else {
break
}
}
} else {
current[lastKey] = value as unknown
}
}
function getNodeState(node: IdentifiedNodeBuilder): NodeState {
return node._statePath ? getAtPath(node._statePath) : undefined
}
function setNodeState(node: IdentifiedNodeBuilder, value: NodeState): void {
if (node._statePath) setAtPath(node._statePath, value)
}
function getBooleanState(node: BooleanNodeBuilder): boolean {
const state = getNodeState(node)
if (typeof state === 'boolean') return state
if (state && typeof state === 'object' && !(state instanceof Set)) {
const v = (state as NodeStateWithChildren).value
if (typeof v === 'boolean') return v
}
const def = resolveDefault(node)
return (def as boolean | undefined) ?? false
}
function getMultiSelectState(node: IdentifiedNodeBuilder): Set<string> {
const state = getNodeState(node)
return state instanceof Set ? state : new Set<string>()
}
function getSelectState(node: IdentifiedNodeBuilder): string | undefined {
const state = getNodeState(node)
if (typeof state === 'string') return state
const def = resolveDefault(node as ValueNodeBuilder)
return typeof def === 'string' ? def : undefined
}
function getDropdownOptions(node: DropdownNodeBuilder) {
return [
...(node._none !== undefined ? [{ value: '', label: node._none }] : []),
...visibleChildren(node).map((c) => ({
value: asIdentified(c).id!,
label: asLabeled(c).label,
})),
]
}
function getDropdownModelValue(node: DropdownNodeBuilder) {
return getSelectState(node) ?? (node._none !== undefined ? '' : undefined)
}
function toggleSelect(parent: IdentifiedNodeBuilder, child: IdentifiedNodeBuilder) {
const current = getSelectState(parent)
setNodeState(parent, current === child.id ? undefined : child.id)
}
function resolveDefault(node: ValueNodeBuilder): NodeState {
const d = node._defaultValue
return typeof d === 'function' ? d(props.showContext) : d
}
function getTextState(node: IdentifiedNodeBuilder): string {
const state = getNodeState(node)
if (typeof state === 'string') return state
const def = resolveDefault(node as ValueNodeBuilder)
return typeof def === 'string' ? def : ''
}
function getNodeTitle(node: NodeBuilder): string | undefined {
if (node._title === undefined) return undefined
return resolve(node._title) || undefined
}
function getPlaceholder(node: InputNodeBuilder): string | undefined {
if (node._placeholder !== undefined) return resolve(node._placeholder)
const def = resolveDefault(node)
if (typeof def === 'string') return def
return undefined
}
function hasActionableFixes(node: IdentifiedNodeBuilder): boolean {
return nodeMetaMap?.value.get(node)?.isFixActionable ?? false
}
function hasRequiredMissingDescendants(node: IdentifiedNodeBuilder): boolean {
for (const child of getChildren(node)) {
if (!(child instanceof NodeBuilder)) continue
const identified = child as IdentifiedNodeBuilder
if (nodeMetaMap?.value.get(identified)?.hasRequiredMissing) return true
if (identified.id !== undefined && hasRequiredMissingDescendants(identified)) return true
}
return false
}
function nodeHasRequiredMissing(node: IdentifiedNodeBuilder): boolean {
return !!nodeMetaMap?.value.get(node)?.hasRequiredMissing || hasRequiredMissingDescendants(node)
}
function getBooleanColor(node: BooleanNodeBuilder): string {
if (!getBooleanState(node)) return 'standard'
if (hasRequiredMissingDescendants(node)) return 'orange'
return hasActionableFixes(node) ? 'blue' : 'brand'
}
function setTextState(node: IdentifiedNodeBuilder, v: string): void {
const inputNode = node as InputNodeBuilder
const result = inputNode._onChange?.(v, overrideHelpers)
if (isOverrideValue(result)) {
const ov = result.__override
const def = resolveDefault(node as ValueNodeBuilder)
const defStr = typeof def === 'string' ? def : ''
setNodeState(node, ov === defStr ? undefined : ov || undefined)
nextTick(() => textInputRefs.get(node)?.setValue(ov))
return
}
const def = resolveDefault(node as ValueNodeBuilder)
const defStr = typeof def === 'string' ? def : ''
setNodeState(node, v === defStr ? undefined : defStr ? v : v || undefined)
}
function handleButtonClick(node: ButtonNodeBuilder): void {
const before = new Map<NodeBuilder, string>()
for (const inputNode of textInputRefs.keys()) {
before.set(inputNode, getTextState(asIdentified(inputNode)))
}
node._onClick?.(props.showContext)
for (const [inputNode, beforeVal] of before) {
const after = getTextState(asIdentified(inputNode))
if (after !== beforeVal) setTextState(asIdentified(inputNode), after)
}
}
onMounted(() => {
for (const inputNode of textInputRefs.keys()) {
setTextState(asIdentified(inputNode), getTextState(asIdentified(inputNode)))
}
})
function toggleBoolean(node: BooleanNodeBuilder) {
const raw = getNodeState(node)
const next = !getBooleanState(node)
const defaultVal = (resolveDefault(node) as boolean | undefined) ?? false
const isDefault = next === defaultVal
if (raw && typeof raw === 'object' && !(raw instanceof Set)) {
const { value: _v, ...children } = raw as NodeStateWithChildren & Record<string, NodeState>
const hasChildren = Object.keys(children).length > 0
setNodeState(
node,
isDefault && !hasChildren
? undefined
: ({ ...children, ...(isDefault ? {} : { value: next }) } as NodeState),
)
} else {
setNodeState(node, isDefault ? undefined : next)
}
}
function toggleChip(parent: IdentifiedNodeBuilder, child: IdentifiedNodeBuilder) {
const selected = new Set(getMultiSelectState(parent))
if (selected.has(child.id!)) {
selected.delete(child.id!)
if (child._statePath) {
setAtPath(child._statePath, undefined)
}
} else {
selected.add(child.id!)
}
setNodeState(parent, selected.size > 0 ? selected : undefined)
}
const textInputRefs = new Map<NodeBuilder, { setValue: (v: string) => void }>()
const overrideHelpers = { override: (v: string): OverrideValue => ({ __override: v }) }
function isOverrideValue(v: unknown): v is OverrideValue {
return v !== null && typeof v === 'object' && '__override' in v
}
const scopedContextFallbacks = new WeakMap<IdentifiedNodeBuilder, Record<string, NodeState>>()
function childScopedContext(child: IdentifiedNodeBuilder): Record<string, NodeState> {
if (!child._statePath) return props.showContext
const basePath = child._statePath
const state = getAtPath(basePath)
if (state && typeof state === 'object' && !(state instanceof Set)) {
return state as Record<string, NodeState>
}
const existing = scopedContextFallbacks.get(child)
if (existing) return existing
const fallback = new Proxy({} as Record<string, NodeState>, {
set(_target, key, value) {
setAtPath([...basePath, key as string], value as NodeState)
return true
},
})
scopedContextFallbacks.set(child, fallback)
return fallback
}
function getChildrenContext(node: IdentifiedNodeBuilder): Record<string, NodeState> {
if (node.type === 'dropdown') return props.showContext
if (node.type === 'group' && asGroup(node)._selectMode) return props.showContext
return childScopedContext(node)
}
function getChildren(node: IdentifiedNodeBuilder): ChildNode[] {
return resolveChildren(node, getChildrenContext(node))
}
function visibleChildren(node: IdentifiedNodeBuilder): NodeBuilder[] {
return getChildren(node).filter((c): c is NodeBuilder => c instanceof NodeBuilder && isVisible(c))
}
const tooltipHtml = reactive(new Map<NodeBuilder, string>())
function getTooltipConfig(node: NodeBuilder, state?: Record<string, NodeState>) {
const t = node._tooltip
const manual = t === undefined ? undefined : typeof t === 'function' ? t(state ?? {}) : resolve(t)
if (manual)
return {
content: manual,
delay: { show: 500, hide: 0 },
triggers: ['hover', 'focus'],
placement: 'top',
}
const html = tooltipHtml.get(node)
if (!html) return undefined
return {
content: html,
html: true,
delay: { show: 500, hide: 0 },
triggers: ['hover', 'focus'],
placement: 'top',
}
}
watchEffect(async () => {
// Read all reactive state synchronously before any await so Vue tracks dependencies
const buttonTasks: Array<{ node: BooleanNodeBuilder; state: Record<string, NodeState> }> = []
for (const node of props.nodes) {
if (!(node instanceof NodeBuilder)) continue
if (node.type === 'toggle' && isVisible(node)) {
const boolNode = asBool(node)
if (boolNode._segments.some((s) => s.type !== 'collect')) {
const nodeState = getNodeState(boolNode)
const childState =
nodeState && typeof nodeState === 'object' && !(nodeState instanceof Set)
? (() => {
const { value: _v, ...rest } = nodeState as NodeStateWithChildren &
Record<string, NodeState>
return rest
})()
: {}
buttonTasks.push({ node: boolNode, state: childState })
}
}
if (node.type === 'group' && asGroup(node)._selectMode === 'multi' && isVisible(node)) {
for (const child of visibleChildren(asIdentified(node))) {
const opt = child as IdentifiedNodeBuilder
if (opt._segments.some((s) => s.type !== 'collect')) {
const childState = getBooleanChildState(getNodeState(opt)) as Record<string, NodeState>
buttonTasks.push({ node: opt as BooleanNodeBuilder, state: childState })
}
}
}
}
async function evalCollectedChildren(node: IdentifiedNodeBuilder): Promise<string> {
let result = ''
for (const child of getChildren(node)) {
if (!(child instanceof NodeBuilder)) continue
if (!isVisible(child)) continue
const childNode = asIdentified(child)
if (child.type === 'group') {
const grp = asGroup(childNode)
if (grp._selectMode === 'multi') {
const selected = getMultiSelectState(childNode)
for (const opt of getChildren(childNode)) {
if (!(opt instanceof NodeBuilder) || !isVisible(opt)) continue
const optNode = asIdentified(opt)
if (!optNode.id || !selected.has(optNode.id)) continue
result += await evalNodeTooltip(
optNode,
getBooleanChildState(getNodeState(optNode)) as Record<string, NodeState>,
)
}
} else if (grp._selectMode === 'single') {
const selected = getSelectState(childNode)
for (const opt of getChildren(childNode)) {
if (!(opt instanceof NodeBuilder) || !isVisible(opt)) continue
const optNode = asIdentified(opt)
if (optNode.id !== selected) continue
result += await evalNodeTooltip(
optNode,
getBooleanChildState(getNodeState(optNode)) as Record<string, NodeState>,
)
}
} else {
result += await evalCollectedChildren(childNode)
}
} else if (child.type === 'dropdown') {
const selected = getSelectState(childNode)
for (const opt of getChildren(childNode)) {
if (!(opt instanceof NodeBuilder) || !isVisible(opt)) continue
const optNode = asIdentified(opt)
if (optNode.id !== selected) continue
result += await evalNodeTooltip(
optNode,
getBooleanChildState(getNodeState(optNode)) as Record<string, NodeState>,
)
}
} else if (child.type === 'toggle' || child.type === 'check') {
if (!getBooleanState(asBool(childNode))) continue
result += await evalNodeTooltip(
childNode,
getBooleanChildState(getNodeState(childNode)) as Record<string, NodeState>,
)
}
}
return result
}
async function evalNodeTooltip(
node: IdentifiedNodeBuilder,
state: Record<string, NodeState>,
): Promise<string> {
let result = ''
for (const seg of node._segments) {
if (seg.type === 'collect') {
let collected = await evalCollectedChildren(node)
if (!collected.trim() && seg.fallback) {
collected = await evalSegment(seg.fallback, state, node._statePath ?? [])
}
result += collected
} else {
result += await evalSegment(seg, state, node._statePath ?? [])
}
}
return result
}
for (const { node, state } of buttonTasks) {
try {
const raw = await evalNodeTooltip(node as unknown as IdentifiedNodeBuilder, state)
const expanded = expandVariables(raw, projectV2.value, project.value, {
...flattenStaticVariables(),
...flattenProjectVariables(projectV2.value),
...flattenProjectV3Variables(project.value),
})
const trimmed = expanded.trim()
tooltipHtml.set(
node,
trimmed
? `<div class="markdown-body moderation-tooltip-markdown">${renderHighlightedString(trimmed)}</div>`
: '',
)
} catch {
tooltipHtml.set(node, '')
}
}
})
</script>
<template>
<div :class="flex ? 'flex flex-wrap gap-2' : 'space-y-4'">
<template
v-for="(item, idx) in nodes"
:key="
item instanceof NodeBuilder
? item.type === 'button'
? `button-${asButton(item).label}`
: (asIdentified(item)._statePath?.join('/') ?? asIdentified(item).id ?? item.type)
: typeof item === 'string'
? `s-${item}`
: `display-${idx}`
"
>
<!-- Display items: plain strings or zero-arg render functions -->
<template v-if="!(item instanceof NodeBuilder)">
<template v-if="typeof item === 'string'">{{ item }}</template>
<component :is="item" v-else />
</template>
<template v-else-if="isVisible(item)">
<div :class="item.type !== 'group' && !getNodeTitle(item) ? 'contents' : undefined">
<div v-if="getNodeTitle(item)" class="mb-2" :class="titleClass(titleDepth ?? 0)">
<span
v-html="renderString(getNodeTitle(item)!).replace(/^<p>([\s\S]*)<\/p>\n?$/, '$1')"
/><span v-if="nodeHasRequiredMissing(asIdentified(item))" class="text-red">*</span>
</div>
<!-- group -->
<template v-if="item.type === 'group'">
<!-- multi-select (chips) mode -->
<template v-if="asGroup(item)._selectMode === 'multi'">
<div class="flex flex-wrap gap-2">
<template
v-for="child in visibleChildren(asIdentified(item))"
:key="asIdentified(child).id"
>
<ButtonStyled
:color="
getMultiSelectState(asIdentified(item)).has(asIdentified(child).id!)
? hasActionableFixes(asIdentified(child))
? 'blue'
: 'brand'
: 'standard'
"
:circular="!!asIdentified(child)._icon"
@click="toggleChip(asIdentified(item), asIdentified(child))"
>
<button
v-tooltip="getTooltipConfig(asIdentified(child))"
:aria-label="asIdentified(child)._icon ? asLabeled(child).label : undefined"
>
<component :is="asIdentified(child)._icon" v-if="asIdentified(child)._icon" />
<template v-else>{{ asLabeled(child).label }}</template>
</button>
</ButtonStyled>
</template>
</div>
<template
v-for="child in visibleChildren(asIdentified(item))"
:key="`sub-${asIdentified(child).id}`"
>
<NodeRenderer
v-if="
getMultiSelectState(asIdentified(item)).has(asIdentified(child).id!) &&
getChildren(asIdentified(child)).length
"
:nodes="getChildren(asIdentified(child))"
:show-context="getChildrenContext(asIdentified(child))"
:on-image-upload="onImageUpload"
:title-depth="titleDepth"
:parent-state-path="asIdentified(child)._statePath ?? props.parentStatePath ?? []"
class="mt-2"
/>
</template>
</template>
<!-- single-select (button-style) mode -->
<template v-else-if="asGroup(item)._selectMode === 'single'">
<div class="flex flex-wrap gap-2">
<template
v-for="child in visibleChildren(asIdentified(item))"
:key="asIdentified(child).id"
>
<ButtonStyled
:color="
getSelectState(asIdentified(item)) === asIdentified(child).id
? 'brand'
: 'standard'
"
:circular="!!asIdentified(child)._icon"
>
<button
:aria-label="asIdentified(child)._icon ? asLabeled(child).label : undefined"
@click="toggleSelect(asIdentified(item), asIdentified(child))"
>
<component :is="asIdentified(child)._icon" v-if="asIdentified(child)._icon" />
<template v-else>{{ asLabeled(child).label }}</template>
</button>
</ButtonStyled>
</template>
</div>
<template
v-for="child in visibleChildren(asIdentified(item))"
:key="`sub-${asIdentified(child).id}`"
>
<NodeRenderer
v-if="
getSelectState(asIdentified(item)) === asIdentified(child).id &&
getChildren(asIdentified(child)).length
"
:nodes="getChildren(asIdentified(child))"
:show-context="getChildrenContext(asIdentified(child))"
:on-image-upload="onImageUpload"
:title-depth="titleDepth"
:parent-state-path="asIdentified(child)._statePath ?? props.parentStatePath ?? []"
class="mt-2"
/>
</template>
</template>
<!-- plain container mode -->
<NodeRenderer
v-else
:nodes="getChildren(asIdentified(item))"
:show-context="getChildrenContext(asIdentified(item))"
:on-image-upload="onImageUpload"
:flex="asGroup(item)._layout !== 'column'"
:title-depth="item._title !== undefined ? (titleDepth ?? 0) + 1 : titleDepth"
:parent-state-path="asIdentified(item)._statePath ?? props.parentStatePath ?? []"
/>
</template>
<!-- dropdown -->
<template v-else-if="item.type === 'dropdown'">
<Combobox
class="!w-80"
:options="getDropdownOptions(asDropdown(item))"
:model-value="getDropdownModelValue(asDropdown(item))"
trigger-class="!bg-[var(--color-button-bg)] !rounded-[var(--radius-md)] !shadow-[var(--shadow-inset-sm),0_0_0_0_transparent]"
dropdown-class="!rounded-[var(--radius-md)] !bg-[var(--color-button-bg)] !border-0"
@update:model-value="(v) => setNodeState(asIdentified(item), v || undefined)"
/>
<template
v-for="child in visibleChildren(asIdentified(item))"
:key="`sub-${asIdentified(child).id}`"
>
<NodeRenderer
v-if="
getSelectState(asIdentified(item)) === asIdentified(child).id &&
getChildren(asIdentified(child)).length
"
:nodes="getChildren(asIdentified(child))"
:show-context="getChildrenContext(asIdentified(child))"
:on-image-upload="onImageUpload"
:title-depth="titleDepth"
:parent-state-path="asIdentified(child)._statePath ?? props.parentStatePath ?? []"
class="mt-2"
/>
</template>
</template>
<!-- button -->
<template v-else-if="item.type === 'button'">
<ButtonStyled :circular="!!item._icon && !asButton(item).label">
<button
v-tooltip="getTooltipConfig(item, showContext)"
:disabled="!isButtonEnabled(asButton(item))"
:aria-label="item._icon && !asButton(item).label ? asButton(item).label : undefined"
@click="handleButtonClick(asButton(item))"
>
<component :is="item._icon" v-if="item._icon" />
{{ asButton(item).label }}
</button>
</ButtonStyled>
</template>
<!-- toggle -->
<template v-else-if="item.type === 'toggle'">
<ButtonStyled :color="getBooleanColor(asBool(item))" :circular="!!item._icon">
<button
v-tooltip="getTooltipConfig(asBool(item))"
:disabled="!isEnabled(asIdentified(item))"
:aria-label="item._icon ? asLabeled(item).label : undefined"
@click="toggleBoolean(asBool(item))"
>
<component :is="item._icon" v-if="item._icon" />
<template v-else>{{ asLabeled(item).label }}</template>
</button>
</ButtonStyled>
</template>
<!-- check -->
<template v-else-if="item.type === 'check'">
<Checkbox
:model-value="getBooleanState(asBool(item))"
:label="asLabeled(item).label"
:disabled="!isEnabled(asIdentified(item))"
@update:model-value="isEnabled(asIdentified(item)) && toggleBoolean(asBool(item))"
/>
</template>
<!-- text -->
<template v-else-if="item.type === 'text'">
<StyledInput
:id="`node-${asIdentified(item).id}`"
:ref="(el: any) => (el ? textInputRefs.set(item, el) : textInputRefs.delete(item))"
v-tooltip="getTooltipConfig(item, showContext)"
:model-value="getTextState(asIdentified(item))"
:placeholder="getPlaceholder(asInput(item))"
autocomplete="off"
@update:model-value="(v: string) => setTextState(asIdentified(item), v)"
/>
</template>
<!-- markdown -->
<template v-else-if="item.type === 'markdown'">
<MarkdownEditor
:id="`node-${asIdentified(item).id}`"
:aria-label="asLabeled(item).label || undefined"
:model-value="getTextState(asIdentified(item))"
:placeholder="getPlaceholder(asInput(item))"
:max-height="300"
:disabled="false"
:heading-buttons="false"
:on-image-upload="onImageUpload"
@update:model-value="(v: string) => setTextState(asIdentified(item), v)"
/>
</template>
</div>
</template>
</template>
<!-- children of active boolean nodes, rendered after all siblings -->
<template
v-for="(item, idx) in nodes"
:key="
item instanceof NodeBuilder
? item.type === 'button'
? `children-button-${asButton(item).label}`
: `children-${asIdentified(item)._statePath?.join('/') ?? asIdentified(item).id ?? item.type}`
: `children-display-${idx}`
"
>
<NodeRenderer
v-if="
item instanceof NodeBuilder &&
isVisible(item) &&
(item.type === 'toggle' || item.type === 'check') &&
getBooleanState(asBool(item)) &&
getChildren(asIdentified(item)).length
"
:nodes="getChildren(asIdentified(item))"
:show-context="getChildrenContext(asIdentified(item))"
:on-image-upload="onImageUpload"
:title-depth="item._title !== undefined ? (titleDepth ?? 0) + 1 : titleDepth"
:parent-state-path="asIdentified(item)._statePath ?? props.parentStatePath ?? []"
class="w-full"
/>
</template>
</div>
</template>
@@ -0,0 +1,23 @@
import type { IdentifiedNodeBuilder, NodeState } from '@modrinth/moderation'
import type { ComputedRef, InjectionKey, Ref } from 'vue'
export interface ActiveAction {
node: IdentifiedNodeBuilder
state: Record<string, NodeState>
statePath: string[]
}
export interface LiveNode {
isActive: boolean
isVisible: boolean
isFixActionable: boolean
messageCount: number
fixCount: number
hasRequiredMissing: boolean
activeActions: ActiveAction[]
}
export const NODE_META_KEY: InjectionKey<ComputedRef<Map<IdentifiedNodeBuilder, LiveNode>>> =
Symbol('nodeMeta')
export const STATE_KEY: InjectionKey<Ref<Record<string, Record<string, NodeState>>>> =
Symbol('checklistState')
@@ -27,10 +27,14 @@ const messages = defineMessages({
id: 'hosting.plan.select-plan',
defaultMessage: 'Select plan',
},
billedInterval: {
id: 'servers.purchase.step.plan.billed',
perMonthBilling: {
id: 'servers.purchase.step.plan.per-month-billing',
defaultMessage:
'billed {interval, select, monthly {monthly} quarterly {quarterly} yearly {yearly} other {{interval}}}',
'/ month{interval, select, monthly {} other {, billed {interval, select, quarterly {quarterly} yearly {yearly} other {{interval}}}}}',
},
mostPopularBadge: {
id: 'servers.purchase.step.plan.most-popular',
defaultMessage: 'Most popular',
},
})
@@ -131,15 +135,13 @@ const billingMonths = computed(() => {
v-if="plans[plan].mostPopular"
class="rounded-full bg-brand-highlight px-2 py-1 text-xs font-bold text-brand"
>
Most popular
{{ formatMessage(messages.mostPopularBadge) }}
</div>
</div>
<span class="m-0 text-2xl font-bold text-contrast">
{{ formatPrice(price / billingMonths, currency, true) }}
<span class="text-lg font-semibold text-secondary">
/ month<template v-if="interval !== 'monthly'"
>, {{ formatMessage(messages.billedInterval, { interval }) }}</template
>
{{ formatMessage(messages.perMonthBilling, { interval }) }}
</span>
</span>
<p class="m-0 max-w-[18rem]">{{ formatMessage(plans[plan].description) }}</p>
+1
View File
@@ -129,6 +129,7 @@ provideModrinthClient(client)
providePageContext({
hierarchicalSidebarAvailable: ref(false),
showAds: ref(false),
adConsentAvailable: ref(false),
openExternalUrl: (url) => window.open(url, '_blank'),
})
+30 -4
View File
@@ -443,20 +443,23 @@
:options="[
{
id: 'new-project',
action: (event) => $refs.modal_creation.show(event),
action: (event) => requireVerifiedEmail(() => $refs.modal_creation.show(event)),
},
{
id: 'new-server-project',
action: (event) => $refs.modal_creation.show(event, { type: 'server' }),
action: (event) =>
requireVerifiedEmail(() => $refs.modal_creation.show(event, { type: 'server' })),
},
{
id: 'new-collection',
action: (event) => $refs.modal_collection_creation.show(event),
action: (event) =>
requireVerifiedEmail(() => $refs.modal_collection_creation.show(event)),
},
{ divider: true },
{
id: 'new-organization',
action: (event) => $refs.modal_organization_creation.show(event),
action: (event) =>
requireVerifiedEmail(() => $refs.modal_organization_creation.show(event)),
},
]"
>
@@ -777,6 +780,7 @@ import {
createHostingIntercomIdentityKey,
defineMessages,
injectModrinthClient,
injectNotificationManager,
injectPageContext,
OverflowMenu,
providePageContext,
@@ -814,6 +818,7 @@ const generatedState = useGeneratedState()
const country = useUserCountry()
const { formatMessage } = useVIntl()
const { addNotification } = injectNotificationManager()
const auth = await useAuth()
const user = await useUser()
@@ -906,6 +911,19 @@ async function fetchIntercomToken() {
})
}
function requireVerifiedEmail(action) {
if (!auth.value.user?.email_verified) {
addNotification({
title: formatMessage(messages.emailVerificationRequired),
text: formatMessage(messages.verifyEmailBeforePublishing),
type: 'error',
})
return
}
action()
}
const navMenuMessages = defineMessages({
home: {
id: 'layout.nav.home',
@@ -962,6 +980,14 @@ const messages = defineMessages({
id: 'layout.action.publish',
defaultMessage: 'Publish',
},
emailVerificationRequired: {
id: 'layout.publish.email-verification-required.title',
defaultMessage: 'Email verification required',
},
verifyEmailBeforePublishing: {
id: 'layout.publish.email-verification-required.description',
defaultMessage: 'You must verify your email before publishing on Modrinth.',
},
reviewProjects: {
id: 'layout.action.review-projects',
defaultMessage: 'Project review',

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