Compare commits

...
Author SHA1 Message Date
tdgao 5bb1293e0b fix: analytics tooltip not pinned after mobile chart drag 2026-07-14 10:15:31 -07: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
aecsocketandGitHub 818a9db9f7 fix: add more logging to index-billing task (#6708)
* more logging to index billing

* schema update
2026-07-14 07:52:50 +00:00
Truman GaoandProspector c431d1fc89 feat: add shift click to toggle file selection (#6714) 2026-07-13 15:26:04 -07:00
Prospector 4a09ee7ac9 changelog 2026-07-13 15:26:04 -07:00
RobinandProspector 31ef22767e feat: add norisk client source support (#6707)
Signed-off-by: Robin <me@orb1n.dev>
2026-07-13 15:25:51 -07:00
Modrinth BotandGitHub 706d46a6e2 New translations from Crowdin (main) (#6705) 2026-07-13 18:24:48 +00:00
coolbotandGitHub ed9394f2f7 Moderation: add quick reply for misused permission groups (#6696)
* add quick reply for misused groups + organize

* Update misused-group.md
2026-07-13 18:20:51 +00:00
François-Xavier TalbotandGitHub 9e1e8788e2 feat(labrinth): return tax amount in user subscription listing (#6496)
* feat(labrinth): include next charge's tax amount

* chore(api-client): add next_charge_tax_amount

* fix
2026-07-13 17:06:22 +00:00
7c4df7442b fix(labrinth): fix filtering not working unless breakdown set to same field (#6608)
* fix(labrinth): return columns used in filters

fixes modrinth/code#6607

* chore(labrinth): sqlx query data update

* don't implicitly breakdown when filtering by a metric

* fix docs

---------

Co-authored-by: aecsocket <43144841+aecsocket@users.noreply.github.com>
2026-07-12 11:31:56 +02:00
aecsocketandGitHub e1556a8ee3 fix: attribution scanning details (#6651)
* key override file sources on file path, allow deleting groups

* dont scan modrinth files

* delete attr group button

* return what override files already exist on platform

* intl:extract

* uppercase queries

* fix dupe migration key
2026-07-12 09:04:54 +00:00
coolbotandGitHub 1741d12b7b Moderation: Update quick replies (#6693)
* Add new externals quick replies

* handle non commercially licensed external content.

* Move quick reply definitions

* fix linebreak issues in quick replies
2026-07-11 02:22:30 +00:00
ThatGravyBoatandGitHub 0b38cecc74 fix: keybind structuredClone only working the first time (#6691) 2026-07-11 02:22:05 +00:00
ThatGravyBoatandGitHub 8680e61883 chore: misc moderation changes (#6690)
* fix: tech card not updating project status correctly

* feat: pull out quick actions into buttons for reports

* fix: reports duplicating in the reports queue

* feat: allow for moderation keybinds to be rebindable

* run prepr
2026-07-10 20:25:39 +00:00
Prospector ea86db450b changelog 2026-07-10 10:43:00 -07:00
ProspectorandGitHub bbdaa171e3 fix: remove unused facets from documentation (#6685) 2026-07-10 10:37:48 -07:00
ProspectorandGitHub af8daca059 fix: make search revalidate & change cache to be 10 minutes (#6682)
* fix: make search revalidate & change cache to be 10 minutes

* prepr
2026-07-10 16:58:45 +00:00
Truman GaoandGitHub 2434f0c45b fix: ads consent close button color (#6678)
* fix: ads consent close button

* update color
2026-07-10 16:57:56 +00:00
332dfe1074 Fix: fix passkey translation keys (#6512)
* fix: common message mentioning passkey

* devex: split title and header translation keys

* chore: rerun pnpm prepr:frontend

---------

Co-authored-by: Calum H. <calum@modrinth.com>
Co-authored-by: Prospector <6166773+Prospector@users.noreply.github.com>
2026-07-10 16:35:59 +00:00
Calum H.andGitHub c07727aa49 feat: install flow improvements (#6669)
* feat: better error handling + copy details btn for info

* refactor: clean up error handling into diagnostics

* feat: extra info for failure states + queuing properly

* fix: cleanup

* fix: lint

* fix: fmt

* fix: cleanup

* fix: cleanup

* fix: lint

* feat: use bon builder
2026-07-10 11:54:56 +00:00
aecsocketandGitHub 57a977f7f3 feat: use v1 version manifest for Quilt (#6650)
* wip: app-side logic

* lints

* fmt

* fmt

* Split Daedalus changes out of frontend branch

* Remove Daedalus changes from frontend branch
2026-07-10 11:17:41 +00:00
NitrrineandGitHub 323d088ebd fix: light mode icon shows in dark mode (#6614)
* fix: light mode icon shows in dark mode

* feat: use os icons from assets repo

* revert initial changes

* fix: lint
2026-07-09 21:43:13 +00:00
Truman GaoandGitHub 46f94bd067 fix: download modal no compatible empty state (#6680) 2026-07-09 21:37:09 +00:00
aecsocketandGitHub e877167db7 feat: global trace database frontend + improvements to tech review status sync (#6671)
* wip

* ui edits

* better logic for syncing tech review state in a single centralized place

* never automatically finish a tech review report

* fix css

* fix clippy

* prepr
2026-07-09 16:09:02 +00:00
Prospector 623b51e6ea update ads.txt 2026-07-09 08:37:23 -07:00
Prospector 5face9e56a changelog 2026-07-08 15:58:31 -07:00
40aff1e8bb feat: download modal changes (#6652)
* update download with modrinth app button

* feat: implement compatible version card and update download dependency buttons

* fix: download multiple files immediately triggers blocking

* refactor: implement download modal provider

* fix: duplicate dependencies and style update for dependecy section

* feat: improve loading flow when there are dependencies, to not show compatible version until all data is loaded

* fix: fallback to download compatible version if dependency lookup fails

* feat: implement selecting compatible versions when there are multiple

* fix: update dependencies version table to any compatible version

* fix: version showing before depedencies loaded

* feat: add platform options sort order

* pnpm prepr

* feat: keep previous result showing until new one loads in when change compatibility options

* feat: implement better handling for showing required/recommended resource packs with additional files

* pnpm prepr

* fix: required resource pack not showing footer actions

* fix: download with deps is downloading all files

* feat: add joined button for downloading with recommended

* feat: add admontion for dependency that has resource pack to download

* pnpm prepr

* fix: lint

* feat: append "-EXTRACT_ME.zip" to zipped project with dependencies

* fix: date published capitalization

* pnpm prepr

---------

Co-authored-by: Prospector <6166773+Prospector@users.noreply.github.com>
2026-07-08 22:54:23 +00:00
SychicandGitHub 5f8837604d feat(labrinth): shared instance icon (#6665) 2026-07-08 22:45:15 +00:00
ProspectorandGitHub 795dad040f feat: add complementary installer source support (#6667) 2026-07-08 15:44:59 -07:00
Truman GaoandGitHub 9ca181dc1a fix: privacy popup in app bounded to tiny webview (#6657) 2026-07-08 16:29:41 +00:00
aecsocketandGitHub c84b658e41 feat: tech review global trace database (#6648)
* global detail verdicts system

* global traces frontend

* split global routes into own mod

* tweak migration to only do no-key check at app level

* prepr
2026-07-08 16:06:45 +00:00
Prospector 9006dce2b0 changelog 2026-07-08 08:06:13 -07:00
ProspectorandGitHub efdfb51149 fix: add DEI (downloads, equality, and inequality) (#6664)
fix: add DEI
2026-07-08 15:04:21 +00:00
ProspectorandGitHub 79cc5fef72 feat: exclude project types filter (#6660)
* feat: exclude project types filter

* fix merge issue
2026-07-08 14:30:47 +00:00
Truman GaoandGitHub ddefc28ca6 fix: instance edits not emitting edit event to reload state (#6658) 2026-07-08 09:16:32 +00:00
654757f9fb docs: replace broken loc count with static count (#6615)
* docs: replace broken loc count with static count

Signed-off-by: Nitrrine <43351072+Nitrrine@users.noreply.github.com>

* feat: update to use loctopus api

credits to @creeperkatze <3

Signed-off-by: Nitrrine <43351072+Nitrrine@users.noreply.github.com>

---------

Signed-off-by: Nitrrine <43351072+Nitrrine@users.noreply.github.com>
Co-authored-by: Prospector <6166773+Prospector@users.noreply.github.com>
2026-07-08 05:21:47 +00:00
François-Xavier TalbotandGitHub 407641ee7c fix(labrinth): serde(default) new search document fields (#6656) 2026-07-07 15:28:54 -07:00
ProspectorandGitHub 96ab120cd0 feat: add all_project_types to search results (#6654)
* feat: add all_project_types to search results

* update docs
2026-07-07 21:30:42 +00:00
François-Xavier TalbotandGitHub 3ed75a146a fix(labrinth): fix routing config for /v3/content (#6649) 2026-07-07 16:48:14 +00:00
aecsocketandGitHub 5a1c2a46bc Generate different Daedalus Quilt templates for 26.x/non-26.x (#6554)
* Generate different Daedalus Quilt templates for 26.x/non-26.x

* add docs

* wip: daedalus version groups

* wip: app-side logic

* finish app-side logic, fix certain libs not included

* lints

* fmt

* fmt

* Split frontend changes out of Daedalus branch
2026-07-07 16:45:33 +00:00
ProspectorandGitHub df8ebbd3e0 feat: add /build route (#6644) 2026-07-07 15:43:26 +00:00
François-Xavier TalbotandGitHub 517c3d2d72 fix(labrinth): fix prefix stripping in mrpack scanning (#6646) 2026-07-07 01:31:07 +00:00
Prospector 7f15772f59 changelog 2026-07-06 15:19:29 -07:00
ProspectorandGitHub 0905ea72f6 fix: project page in app not receiving instance context & port version page to app (#6618)
* fix: project page in app not receiving instance context

* prepr

* feat: port version page to app

* commonize keys

* whoops bring those back
2026-07-06 15:17:25 -07:00
8d20fd82db fix: moderation/projects endpoint slow (#6639)
* fix: moderation/projects endpoint slow

* fix: re-add tech rev toggle off by default

* fix: i18n

* prepare

---------

Co-authored-by: aecsocket <43144841+aecsocket@users.noreply.github.com>
2026-07-06 19:16:52 +00:00
aecsocketandGitHub c831e38e32 fix: file scans only consider published files as on-platform (#6638) 2026-07-06 18:13:21 +00:00
Calum H.andGitHub 40b8fb3a4a fix: use tanstack for server pinging (#6631) 2026-07-06 18:09:51 +00:00
François-Xavier TalbotandGitHub 977bb2ff58 ci: fix (#6640)
* ci: use apt

* fix(ci): sudo

* bruhj
2026-07-06 17:51:09 +00:00
Calum H.andGitHub 732bdfc79b fix: warm middleware on server-side (#6636) 2026-07-06 17:39:43 +00:00
Calum H.andGitHub 1cce1eb248 fix: strip runtime filters to be authoratative (#6635) 2026-07-06 17:39:17 +00:00
Calum H.andGitHub 76320f227f fix: dont prefetch file contents if it's not editable/viewable (#6632) 2026-07-06 17:06:41 +00:00
Calum H.andGitHub 34228a5b6c fix: use next tick in delete confirm (#6633) 2026-07-06 17:06:10 +00:00
Calum H.andGitHub 6d66aee4ec fix: properly select correct databack versions (#6634) 2026-07-06 16:59:49 +00:00
SychicandGitHub fc7be043c7 fix(labrinth): add invited_by field (#6625) 2026-07-06 16:33:00 +00:00
7fa88e5d4d feat: moderation modpack scan button (#6627)
* feat: moderation modpack scan button

* fix: ui

* fix: lint

---------

Co-authored-by: Calum H. (IMB11) <contact@cal.engineer>
2026-07-06 13:19:39 +00:00
ThatGravyBoatandGitHub 21508e0637 feat: move tech quick actions to dedicated buttons (#6628) 2026-07-06 08:31:51 +00:00
Prospector ad4912d64d changelog + one more fix 2026-07-04 19:09:58 -07:00
ProspectorandGitHub 0002ae2479 fix: env filter showing in hosting search (#6612) 2026-07-04 18:47:30 -07:00
Prospector e4568330a7 changelog 2026-07-04 16:16:02 -07:00
ProspectorandGitHub b0108e0624 fix: environment filters (#6611) 2026-07-04 16:15:28 -07:00
Prospector ac63abadb7 changelog 2026-07-04 16:00:11 -07:00
fa749b5aff fix: respect height attribute on images in markdown body (#6506)
Co-authored-by: Truman Gao <106889354+tdgao@users.noreply.github.com>
2026-07-04 22:47:17 +00:00
a2adbe18c4 Request: Add cdn/workflow.serilum.com to allowedHostnames whitelist to bypass wsrv.nl caching (#6567)
* Add cdn/workflow.serilum.com to allowedHostnames whitelist

Signed-off-by: Rick South <rick@natamus.com>

* Update parse.ts

Signed-off-by: Prospector <6166773+Prospector@users.noreply.github.com>

---------

Signed-off-by: Rick South <rick@natamus.com>
Signed-off-by: Prospector <6166773+Prospector@users.noreply.github.com>
Co-authored-by: Prospector <6166773+Prospector@users.noreply.github.com>
2026-07-04 22:40:34 +00:00
Truman GaoandGitHub 0fcbe51816 fix: version table overflow issues (#6583)
* feat: shorten ModLoader

* fix: game version and platform tag overflow issues

* feat: add table version row below and move version files to version row below

* fix: platform plural

* pnpm prepr
2026-07-04 22:06:09 +00:00
Truman GaoandGitHub 23cfeca91d feat: project download modal (#6602)
* refactor: componentize project download modal

* feat: implement new project download modal

* fix: button trigger placeholder text styles

* feat: dependency download button

* refactor: move project download modal into folder

* refactor: separate out download dependencies section

* refactor: split out download project and install with modrinth app sections

* feat+refactor: add nested dependencies route and move logic into related subcomponents

* feat: handle download project dependencies recursively and properly pass all game versions/platforms compatible on the selected version

* feat: if only dependency project is listed, find latest compatible dependency version to download in version page

* refactor: use ts and also have modal own get project/versions queries

* feat: implement opening project download modal in place in version page

* feat: implement incompatible options due to base project

* feat: auto select loader after selecting a game version, if only one loader is left compatible

* feat: add prop to stop route selection being persisted in url param

* pnpm prepr

* feat: update tooltip copy

* feat: polish some styles

* fix: fabric dependency showing when quilt is selected

* fix: tooltip hover area

* fix: when there are multiple compatible of same game version + loader, give latest

* feat: add link to version page

* feat: add truncated tooltip

* pnpm prepr

* feat: add warning icon for incompatible selection

* feat: polish loading state and tooltip

* feat: implement download all dependencies

* pnpm prepr

* fix: light theme card

* fix: additional files label

* refactor: move additional files into download dependencies section

* feat: move download all button in heading with compatible version

* fix color

* fix: dependencies not appearing when open modal second time

* fix: dependency lines when 3 nested

* feat: remove duplicated dependencies

* feat: add download count indicateor on download all button

* feat: add tooltip for downloading file with filename and size

* pnpm prepr

* feat: only show duplicate tooltip if there are duplicates
2026-07-04 21:45:34 +00:00
Truman GaoandGitHub db830ef65c feat: prevent .mrpack being uploaded as additional file (#6582) 2026-07-04 21:35:08 +00:00
ArthurandGitHub 08f44b3582 fix: make update pill button size to content clean (#6574)
* Fix update pill sizing

* Refactor as component
2026-07-04 21:33:36 +00:00
ArthurandGitHub 8ffc4e2599 chore: add modfolio to allowed hostnames (#6584)
Add modfolio to allowed hostnames
2026-07-04 21:33:24 +00:00
coolbotandGitHub 58a5619073 add alt keybind for resetting moderation progress. (#6606) 2026-07-04 21:28:08 +00:00
4a6fa9fc3d feat: better api docs (#6586)
* feat: docs

* fix tombi

* chore: fix some of the routes rendering with missing /

* response schemas

* fix: restore labrinth docs routes

* Fix path parameter docs in routes

* remove utoipa-actix-web

* consistency

* improve version intros

* improve formatting, examples

* better hash examples, move openapi stuff to openapi.rs

* more utoipa param fixes

* request body docs

* chore: remove moderation route from v2, remove ingest & webhooks from v3 spec

* fixes

* chore: tweak sources titles

* fix

* fix test

* improve examples

* increase compiler spawned thread stack size

* remove unused tests & script

* test

* bro what

* fix

---------

Co-authored-by: aecsocket <43144841+aecsocket@users.noreply.github.com>
2026-07-04 14:19:21 +00:00
aecsocketandGitHub b26d048a63 Remove AutoMod code (#6604)
* 🦀 automod is gone 🦀

* prepare
2026-07-03 21:32:11 +00:00
François-Xavier TalbotandGitHub d5be2bd739 fix(labrinth): include SharedInstanceInvite notifications for ws (#6603)
* fix(labrinth): include SharedInstanceInvite notifications for ws

* chore: fmt
2026-07-03 17:06:58 +00:00
Prospector 0dac5684af changelog 2026-07-03 08:16:29 -07:00
coolbotandGitHub ca0b8d5ca4 add excessive languages warning nag (#6587) 2026-07-03 12:12:07 +00:00
coolbotandGitHub b6651efe52 Moderation Fix: Handle more license cases (#6590)
Apply correct behavior in edge cases
2026-07-02 17:26:10 +00:00
aecsocketandGitHub eb28121c0a fix: scan logic file whitelist + rescan route (#6585)
* explicitly only scan jar/zip/disabled files for attributions

* add route to force scan file

* prepare
2026-07-02 16:33:16 +00:00
SychicandGitHub a5ebc85356 refactor(labrinth): combine logic for external notification endpoints (#6578)
* refactor(labrinth): combine logic for external notification endpoints

also introduce new notification that doesn't send an email

* style(labrinth): derive default

* feat(labrinth): return more detailed failure information for external
notifications
2026-07-02 15:44:47 +00:00
Prospector c530671be1 fix collection widget on mobile 2026-07-01 18:09:12 -07:00
Prospector bb6d8a6fc4 changelog 2026-07-01 17:44:29 -07:00
ProspectorandGitHub 256e8f946a chore: pride month fundraiser conclusion (#6588)
* pride end blog post

* Fix drago link

* prepr oops
2026-07-01 17:43:44 -07:00
Calum H.andGitHub 7e5590b8b9 fix: improve observability during fetch failure (#6555)
* fix: improve observability during fetch failure

+ remove analytics log spam

* fix: rev comments
2026-07-01 15:58:20 +00:00
SychicandGitHub d20f05fb04 feat(labrinth): instance share notification (#6581) 2026-07-01 14:21:56 +00:00
Truman GaoandGitHub a00a7971d1 feat: add tooltip for unknown dependents (#6572) 2026-07-01 05:21:42 +00:00
aecsocketandGitHub 26e98b7155 fix: change Tiltify campaign errors to quieter log level v2 (#6576)
import
2026-06-30 21:36:47 +02:00
aecsocketandGitHub 77b06f679e fix: change Tiltify campaign errors to quieter log level (#6575) 2026-06-30 21:34:37 +02:00
Truman GaoandGitHub 50e9472dd9 fix: cannot set owner for creating projects (#6573) 2026-06-30 17:00:10 +00:00
476 changed files with 38513 additions and 7446 deletions
+6 -4
View File
@@ -79,6 +79,7 @@ jobs:
SCCACHE_DIR: ${{ needs.skip-if-clean.outputs.internal == 'true' && '/mnt/sccache' || '' }}
SCCACHE_CACHE_SIZE: ${{ needs.skip-if-clean.outputs.internal == 'true' && '10G' || '' }}
RUSTC_WRAPPER: ${{ needs.skip-if-clean.outputs.internal == 'true' && 'sccache' || '' }}
RUST_MIN_STACK: '16777216'
steps:
- name: Check out code
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
@@ -93,10 +94,11 @@ jobs:
uses: rui314/setup-mold@9c9c13bf4c3f1adef0cc596abc155580bcb04444 # v1 / Mold 2.41.0
- name: Install build dependencies
uses: awalsh128/cache-apt-pkgs-action@acb598e5ddbc6f68a970c5da0688d2f3a9f04d05 # v1.6.0
with:
packages: cmake libcurl4-openssl-dev
version: v2 # cache key
run: sudo apt-get update && sudo apt-get install -y --no-install-recommends cmake libcurl4-openssl-dev
#uses: awalsh128/cache-apt-pkgs-action@acb598e5ddbc6f68a970c5da0688d2f3a9f04d05 # v1.6.0
#with:
# packages: cmake libcurl4-openssl-dev
# version: v2 # cache key
- name: Cache Cargo registry and index
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
Generated
+173 -64
View File
@@ -59,9 +59,9 @@ dependencies = [
[[package]]
name = "actix-http"
version = "3.11.2"
version = "3.13.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7926860314cbe2fb5d1f13731e387ab43bd32bca224e82e6e2db85de0a3dba49"
checksum = "48e2faa3e7418ed780cca54829d32782a4008a077230f67457caa063415e99c2"
dependencies = [
"actix-codec",
"actix-rt",
@@ -75,7 +75,7 @@ dependencies = [
"derive_more 2.1.1",
"encoding_rs",
"flate2",
"foldhash 0.1.5",
"foldhash 0.2.0",
"futures-core",
"h2 0.3.27",
"http 0.2.12",
@@ -87,8 +87,8 @@ dependencies = [
"mime",
"percent-encoding",
"pin-project-lite",
"rand 0.9.2",
"sha1",
"rand 0.10.1",
"sha1 0.11.0",
"smallvec",
"tokio",
"tokio-util",
@@ -146,9 +146,9 @@ dependencies = [
[[package]]
name = "actix-router"
version = "0.5.3"
version = "0.5.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "13d324164c51f63867b57e73ba5936ea151b8a41a1d23d1031eeb9f70d0236f8"
checksum = "14f8c75c51892f18d9c46150c5ac7beb81c95f78c8b83a634d49f4ca32551fe7"
dependencies = [
"bytestring",
"cfg-if",
@@ -209,9 +209,9 @@ dependencies = [
[[package]]
name = "actix-web"
version = "4.11.0"
version = "4.14.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a597b77b5c6d6a1e1097fddde329a83665e25c5437c696a3a9a4aa514a614dea"
checksum = "df09e2d9239703dd64056359c920c7f3fba6535ec61a0059e0f44e095ffe02b4"
dependencies = [
"actix-codec",
"actix-http",
@@ -228,7 +228,7 @@ dependencies = [
"cookie 0.16.2",
"derive_more 2.1.1",
"encoding_rs",
"foldhash 0.1.5",
"foldhash 0.2.0",
"futures-core",
"futures-util",
"impl-more",
@@ -244,7 +244,7 @@ dependencies = [
"serde_json",
"serde_urlencoded",
"smallvec",
"socket2 0.5.10",
"socket2 0.6.1",
"time",
"tracing",
"url",
@@ -1127,7 +1127,7 @@ dependencies = [
"http-body-util",
"md-5",
"pin-project-lite",
"sha1",
"sha1 0.10.6",
"sha2",
"tracing",
]
@@ -1499,7 +1499,7 @@ version = "0.10.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "46502ad458c9a52b69d4d4d32775c788b7a1b85e8bc9d482d92250fc0e3f8efe"
dependencies = [
"digest",
"digest 0.10.7",
]
[[package]]
@@ -1511,6 +1511,15 @@ dependencies = [
"generic-array",
]
[[package]]
name = "block-buffer"
version = "0.12.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d2f6c7dbe95a6ed67ad9f18e57daf93a2f034c524b99fd2b76d18fdfeb6660aa"
dependencies = [
"hybrid-array",
]
[[package]]
name = "block2"
version = "0.5.1"
@@ -1619,6 +1628,31 @@ dependencies = [
"serde_with",
]
[[package]]
name = "bon"
version = "3.9.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a602c73c7b0148ec6d12af6fd5cc7a46e2eacc8878271a999abac56eed12f561"
dependencies = [
"bon-macros",
"rustversion",
]
[[package]]
name = "bon-macros"
version = "3.9.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6dee98b0db6a962de883bf5d20362dee4d7ca0d12fe39a7c6c73c844e1cd7c1f"
dependencies = [
"darling 0.23.0",
"ident_case",
"prettyplease",
"proc-macro2",
"quote",
"rustversion",
"syn 2.0.106",
]
[[package]]
name = "borsh"
version = "1.5.7"
@@ -2224,6 +2258,12 @@ version = "0.9.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8"
[[package]]
name = "const-oid"
version = "0.10.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a6ef517f0926dd24a1582492c791b6a4818a4d94e789a334894aa15b0d12f55c"
[[package]]
name = "const-random"
version = "0.1.18"
@@ -2424,7 +2464,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2fd92aca2c6001b1bf5ba0ff84ee74ec8501b52bbef0cac80bf25a6c1d87a83d"
dependencies = [
"crc",
"digest",
"digest 0.10.7",
"rustversion",
"spin 0.10.0",
]
@@ -2515,6 +2555,15 @@ dependencies = [
"typenum",
]
[[package]]
name = "crypto-common"
version = "0.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ce6e4c961d6cd6c9a86db418387425e8bdeaf05b3c8bc1411e6dca4c252f1453"
dependencies = [
"hybrid-array",
]
[[package]]
name = "cssparser"
version = "0.29.6"
@@ -2791,7 +2840,7 @@ version = "0.7.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb"
dependencies = [
"const-oid",
"const-oid 0.9.6",
"pem-rfc7468",
"zeroize",
]
@@ -2904,12 +2953,23 @@ version = "0.10.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292"
dependencies = [
"block-buffer",
"const-oid",
"crypto-common",
"block-buffer 0.10.4",
"const-oid 0.9.6",
"crypto-common 0.1.6",
"subtle",
]
[[package]]
name = "digest"
version = "0.11.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f1dd6dbb5841937940781866fa1281a1ff7bd3bf827091440879f9994983d5c2"
dependencies = [
"block-buffer 0.12.1",
"const-oid 0.10.2",
"crypto-common 0.2.2",
]
[[package]]
name = "directories"
version = "6.0.0"
@@ -3127,7 +3187,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ee27f32b5c5292967d2d4a9d7f1e0b0aed2c15daded5a60300e4abb9d8020bca"
dependencies = [
"der",
"digest",
"digest 0.10.7",
"elliptic-curve",
"rfc6979",
"signature",
@@ -3151,7 +3211,7 @@ checksum = "b5e6043086bf7973472e0c7dff2142ea0b680d30e18d9cc40f267efbf222bd47"
dependencies = [
"base16ct",
"crypto-bigint",
"digest",
"digest 0.10.7",
"ff",
"generic-array",
"group",
@@ -4402,7 +4462,7 @@ version = "0.12.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e"
dependencies = [
"digest",
"digest 0.10.7",
]
[[package]]
@@ -4532,6 +4592,15 @@ version = "1.0.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9"
[[package]]
name = "hybrid-array"
version = "0.4.13"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "818356c5132c1fede50f837ca96afbe78ff42413047f4abb886217845e1b6c8c"
dependencies = [
"typenum",
]
[[package]]
name = "hyper"
version = "0.14.32"
@@ -4893,9 +4962,9 @@ checksum = "e7c5cedc30da3a610cac6b4ba17597bdf7152cf974e8aab3afb3d54455e371c8"
[[package]]
name = "impl-more"
version = "0.1.9"
version = "0.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e8a5a9a0ff0086c7a148acb942baaabeadf9504d10400b5a05645853729b9cd2"
checksum = "35a84fd5aa25fae5c0f4a33d9cac2ca017fc622cbd089be2229993514990f870"
[[package]]
name = "indenter"
@@ -5390,11 +5459,12 @@ dependencies = [
"rust_iso3166",
"rustls 0.23.32",
"rusty-money",
"scalar_api_reference",
"sentry",
"serde",
"serde_json",
"serde_with",
"sha1",
"sha1 0.10.6",
"sha2",
"spdx",
"sqlx",
@@ -5411,8 +5481,6 @@ dependencies = [
"url",
"urlencoding",
"utoipa",
"utoipa-actix-web",
"utoipa-scalar",
"uuid 1.23.3",
"validator",
"webauthn-rs",
@@ -5822,7 +5890,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d89e7ee0cfbedfc4da3340218492196241d89eefb6dab27de5df917a6d2e78cf"
dependencies = [
"cfg-if",
"digest",
"digest 0.10.7",
]
[[package]]
@@ -8494,8 +8562,8 @@ version = "0.9.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "78928ac1ed176a5ca1d17e578a1825f3d81ca54cf41053a592584b020cfd691b"
dependencies = [
"const-oid",
"digest",
"const-oid 0.9.6",
"digest 0.10.7",
"num-bigint-dig",
"num-integer",
"num-traits",
@@ -8508,6 +8576,40 @@ dependencies = [
"zeroize",
]
[[package]]
name = "rust-embed"
version = "8.11.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "04113cb9355a377d83f06ef1f0a45b8ab8cd7d8b1288160717d66df5c7988d27"
dependencies = [
"rust-embed-impl",
"rust-embed-utils",
"walkdir",
]
[[package]]
name = "rust-embed-impl"
version = "8.11.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "da0902e4c7c8e997159ab384e6d0fc91c221375f6894346ae107f47dd0f3ccaa"
dependencies = [
"proc-macro2",
"quote",
"rust-embed-utils",
"syn 2.0.106",
"walkdir",
]
[[package]]
name = "rust-embed-utils"
version = "8.11.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5bcdef0be6fe7f6fa333b1073c949729274b05f123a0ad7efcb8efd878e5c3b1"
dependencies = [
"sha2",
"walkdir",
]
[[package]]
name = "rust-ini"
version = "0.21.3"
@@ -8827,6 +8929,18 @@ dependencies = [
"winapi-util",
]
[[package]]
name = "scalar_api_reference"
version = "0.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3d67c85edd56c41f364603eff688a330f01dee99bdfc84c005d3bc3d0721d6b2"
dependencies = [
"actix-web",
"rust-embed",
"serde",
"serde_json",
]
[[package]]
name = "scc"
version = "2.4.0"
@@ -9252,15 +9366,15 @@ dependencies = [
[[package]]
name = "serde_json"
version = "1.0.145"
version = "1.0.150"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "402a6f66d8c709116cf22f558eab210f5a50187f702eb4d7e5ef38d9a7f1c79c"
checksum = "e8014e44b4736ed0538adeecded0fce2a272f22dc9578a7eb6b2d9993c74cfb9"
dependencies = [
"itoa",
"memchr",
"ryu",
"serde",
"serde_core",
"zmij",
]
[[package]]
@@ -9455,7 +9569,18 @@ checksum = "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba"
dependencies = [
"cfg-if",
"cpufeatures 0.2.17",
"digest",
"digest 0.10.7",
]
[[package]]
name = "sha1"
version = "0.11.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "aacc4cc499359472b4abe1bf11d0b12e688af9a805fa5e3016f9a386dc2d0214"
dependencies = [
"cfg-if",
"cpufeatures 0.3.0",
"digest 0.11.3",
]
[[package]]
@@ -9472,7 +9597,7 @@ checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283"
dependencies = [
"cfg-if",
"cpufeatures 0.2.17",
"digest",
"digest 0.10.7",
]
[[package]]
@@ -9505,7 +9630,7 @@ version = "2.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de"
dependencies = [
"digest",
"digest 0.10.7",
"rand_core 0.6.4",
]
@@ -9782,7 +9907,7 @@ dependencies = [
"bytes",
"chrono",
"crc",
"digest",
"digest 0.10.7",
"dotenvy",
"either",
"futures-channel",
@@ -9803,7 +9928,7 @@ dependencies = [
"rsa",
"rust_decimal",
"serde",
"sha1",
"sha1 0.10.6",
"sha2",
"smallvec",
"sqlx-core",
@@ -10749,6 +10874,7 @@ dependencies = [
"async-walkdir",
"async_zip",
"base64 0.22.1",
"bon",
"bytemuck",
"bytes",
"chardetng",
@@ -11290,7 +11416,7 @@ dependencies = [
"constant_time_eq",
"hmac",
"rand 0.9.2",
"sha1",
"sha1 0.10.6",
"sha2",
]
@@ -11505,7 +11631,7 @@ dependencies = [
"rand 0.9.2",
"rustls 0.23.32",
"rustls-pki-types",
"sha1",
"sha1 0.10.6",
"thiserror 2.0.17",
"utf-8",
]
@@ -11524,9 +11650,9 @@ checksum = "bc7d623258602320d5c55d1bc22793b57daff0ec7efc270ea7d55ce1d5f5471c"
[[package]]
name = "typenum"
version = "1.19.0"
version = "1.20.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "562d481066bde0658276a35467c4af00bdc6ee726305698a55b86e61d7ad82bb"
checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20"
[[package]]
name = "uds_windows"
@@ -11757,17 +11883,6 @@ dependencies = [
"utoipa-gen",
]
[[package]]
name = "utoipa-actix-web"
version = "0.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b7eda9c23c05af0fb812f6a177514047331dac4851a2c8e9c4b895d6d826967f"
dependencies = [
"actix-service",
"actix-web",
"utoipa",
]
[[package]]
name = "utoipa-gen"
version = "5.4.0"
@@ -11782,18 +11897,6 @@ dependencies = [
"uuid 1.23.3",
]
[[package]]
name = "utoipa-scalar"
version = "0.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "59559e1509172f6b26c1cdbc7247c4ddd1ac6560fe94b584f81ee489b141f719"
dependencies = [
"actix-web",
"serde",
"serde_json",
"utoipa",
]
[[package]]
name = "uuid"
version = "0.8.2"
@@ -13450,6 +13553,12 @@ version = "0.5.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2f06ae92f42f5e5c42443fd094f245eb656abf56dd7cce9b8b263236565e00f2"
[[package]]
name = "zmij"
version = "1.0.21"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa"
[[package]]
name = "zopfli"
version = "0.8.2"
+2 -2
View File
@@ -51,6 +51,7 @@ aws-sdk-s3 = { version = "=1.122.0", default-features = false, features = [
] }
base64 = "0.22.1"
bitflags = "2.9.4"
bon = "3.9.3"
bytemuck = "1.24.0"
bytes = "1.10.1"
censor = "0.3.0"
@@ -158,6 +159,7 @@ rust-s3 = { version = "0.37.0", default-features = false, features = [
] }
rustls = "0.23.32"
rusty-money = "0.4.1"
scalar_api_reference = { version = "0.2.2", default-features = false }
secrecy = "0.10.3"
sentry = { version = "0.45.0", default-features = false, features = [
"backtrace",
@@ -217,8 +219,6 @@ typed-path = "0.12.0"
url = "2.5.7"
urlencoding = "2.1.3"
utoipa = { version = "5.4.0", features = ["actix_extras", "chrono", "decimal"] }
utoipa-actix-web = { version = "0.1.2" }
utoipa-scalar = { version = "0.3.0", default-features = false }
uuid = "1.18.1"
validator = "0.20.0"
webauthn-rs = "0.5.5"
+2 -2
View File
@@ -3,13 +3,13 @@
![Issues](https://img.shields.io/github/issues-raw/Modrinth/code?color=c78aff&label=issues&style=for-the-badge)
![Pull Requests](https://img.shields.io/github/issues-pr-raw/Modrinth/code?color=c78aff&label=PRs&style=for-the-badge)
![Contributors](https://img.shields.io/github/contributors/Modrinth/code?color=c78aff&label=contributors&style=for-the-badge)
![Lines](https://img.shields.io/endpoint?url=https://ghloc.vercel.app/api/modrinth/code/badge?style=flat&logoColor=white&color=c78aff&style=for-the-badge)
![Lines of Code](https://img.shields.io/endpoint?url=https://loctopus.creeperkatze.dev/github/modrinth/code/badge?style=flat&logoColor=white&color=c78aff&style=for-the-badge)
![Commit Activity](https://img.shields.io/github/commit-activity/m/Modrinth/code?color=c78aff&label=commits&style=for-the-badge)
![Last Commit](https://img.shields.io/github/last-commit/Modrinth/code?color=c78aff&label=last%20commit&style=for-the-badge)
## Modrinth Monorepo
Welcome to the Modrinth Monorepo, the primary codebase for the Modrinth web interface and app. It contains ![Lines](https://img.shields.io/endpoint?url=https://ghloc.vercel.app/api/modrinth/olympus/badge?logoColor=white&color=black&label=) lines of code and has ![Contributors](https://img.shields.io/github/contributors/Modrinth/code?color=black&label=) contributors!
Welcome to the Modrinth Monorepo, the primary codebase for the Modrinth web interface and app. It contains ![Lines of Code](https://img.shields.io/endpoint?url=https://loctopus.creeperkatze.dev/github/modrinth/code/badge%3Fformat%3Dhuman&logoColor=white&color=black&label=) lines of code and has ![Contributors](https://img.shields.io/github/contributors/Modrinth/code?color=black&label=) contributors!
If you're not a developer and you've stumbled upon this repository, you can access the web interface on the [Modrinth website](https://modrinth.com) and download the latest release of the app [here](https://modrinth.com/app).
@@ -14,31 +14,7 @@
<UnplugIcon class="text-secondary" />
<span class="text-sm text-contrast"> {{ formatMessage(messages.offline) }} </span>
</div>
<ButtonStyled color="brand" type="outlined" hover-color-fill="background">
<button
v-if="showUpdatePill"
type="button"
class="!h-[34px] overflow-hidden text-sm !transition-[width,opacity,transform,background-color,color,filter] !duration-200 ease-out"
:class="[
updatePillWidthClass,
{
'update-pill-ready-hidden': finishedDownloading && !animateReadyPill,
'update-pill-ready-visible': finishedDownloading && animateReadyPill,
},
]"
:disabled="isUpdateDownloading"
:aria-busy="isUpdateDownloading"
@click="handleUpdateClick"
>
<RefreshCwIcon v-if="finishedDownloading" :class="{ 'animate-spin': restarting }" />
<DownloadIcon v-else />
<span v-if="isUpdateDownloading">
{{ formatMessage(messages.downloadingUpdate) }}
<span class="inline-block w-[3ch] text-right tabular-nums">{{ downloadPercent }}%</span>
</span>
<span v-else>{{ updateLabel }}</span>
</button>
</ButtonStyled>
<AppUpdateButton />
<div
class="flex border-solid border-surface-5 text-sm items-center gap-2 py-1.5 px-3 rounded-xl border"
>
@@ -144,7 +120,6 @@ import {
DownloadIcon,
DropdownIcon,
OnlineIndicatorIcon,
RefreshCwIcon,
StarIcon,
StopCircleIcon,
TerminalSquareIcon,
@@ -161,9 +136,10 @@ import {
} from '@modrinth/ui'
import { convertFileSrc } from '@tauri-apps/api/core'
import { Dropdown } from 'floating-vue'
import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue'
import { computed, onBeforeUnmount, onMounted, ref } from 'vue'
import { useRouter } from 'vue-router'
import AppUpdateButton from '@/components/ui/app-update-button/index.vue'
import { useInstallJobNotifications } from '@/composables/browse/install-job-notifications'
import { trackEvent } from '@/helpers/analytics'
import { loading_listener, process_listener } from '@/helpers/events'
@@ -172,11 +148,6 @@ import { get_all as getRunningProcesses, kill as killProcess } from '@/helpers/p
import type { LoadingBar } from '@/helpers/state'
import { progress_bars_list } from '@/helpers/state'
import type { GameInstance } from '@/helpers/types'
import {
appUpdateState,
downloadAvailableAppUpdate,
installAvailableAppUpdate,
} from '@/providers/app-update'
const { handleError } = injectNotificationManager()
const popupNotificationManager = injectPopupNotificationManager()
@@ -241,96 +212,8 @@ const messages = defineMessages({
id: 'app.action-bar.view-active-downloads',
defaultMessage: 'View active downloads',
},
update: {
id: 'app.action-bar.update',
defaultMessage: 'Update',
},
downloadingUpdate: {
id: 'app.action-bar.downloading-update',
defaultMessage: 'Downloading update',
},
reloadToUpdate: {
id: 'app.action-bar.reload-to-update',
defaultMessage: 'Reload to update',
},
})
const {
downloading,
downloadPercent,
downloadProgress,
finishedDownloading,
isVisible: isUpdateVisible,
metered,
restarting,
} = appUpdateState
const isUpdateDownloading = computed(
() =>
downloading.value ||
(downloadProgress.value > 0 && downloadProgress.value < 1 && !finishedDownloading.value),
)
const showUpdatePill = computed(
() => isUpdateVisible.value && (finishedDownloading.value || metered.value),
)
const animateReadyPill = ref(false)
const updateLabel = computed(() => {
if (isUpdateDownloading.value) {
return formatMessage(messages.downloadingUpdate)
}
if (finishedDownloading.value) {
return formatMessage(messages.reloadToUpdate)
}
return formatMessage(messages.update)
})
const updatePillWidthClass = computed(() => {
if (isUpdateDownloading.value) {
return 'w-[219px]'
}
if (finishedDownloading.value) {
return 'w-[166px]'
}
return '!w-[96px]'
})
let readyPillAnimationFrame: number | null = null
watch([showUpdatePill, finishedDownloading], async ([show, ready], [wasShown, wasReady]) => {
if (readyPillAnimationFrame !== null) {
cancelAnimationFrame(readyPillAnimationFrame)
readyPillAnimationFrame = null
}
if (!show || !ready) {
animateReadyPill.value = false
return
}
if (wasShown && wasReady) {
return
}
animateReadyPill.value = false
await nextTick()
readyPillAnimationFrame = requestAnimationFrame(() => {
animateReadyPill.value = true
readyPillAnimationFrame = null
})
})
async function handleUpdateClick() {
if (isUpdateDownloading.value) {
return
}
if (finishedDownloading.value) {
await installAvailableAppUpdate()
} else {
await downloadAvailableAppUpdate()
}
}
const currentProcesses = ref<RunningProcess[]>([])
const selectedProcess = ref<RunningProcess | undefined>()
@@ -619,20 +502,5 @@ onBeforeUnmount(() => {
unlistenProcess()
unlistenLoading()
installJobNotifications.dispose()
if (readyPillAnimationFrame !== null) {
cancelAnimationFrame(readyPillAnimationFrame)
}
})
</script>
<style scoped>
.update-pill-ready-hidden {
opacity: 0;
transform: scale(0.96);
}
.update-pill-ready-visible {
opacity: 1;
transform: scale(1);
}
</style>
@@ -0,0 +1,124 @@
<template>
<ButtonStyled color="brand" type="outlined" hover-color-fill="background">
<button
v-if="showUpdatePill"
type="button"
class="!h-[34px] text-sm !transition-[opacity,transform,background-color,color,filter] !duration-200 ease-out"
:class="{
'opacity-0 scale-[0.96]': finishedDownloading && !animateReadyPill,
'opacity-100 scale-100': finishedDownloading && animateReadyPill,
}"
:disabled="isUpdateDownloading"
:aria-busy="isUpdateDownloading"
@click="handleUpdateClick"
>
<RefreshCwIcon v-if="finishedDownloading" :class="{ 'animate-spin': restarting }" />
<DownloadIcon v-else />
<span v-if="isUpdateDownloading">
{{ formatMessage(messages.downloadingUpdate) }}
<span class="inline-block w-[3ch] text-right tabular-nums">{{ downloadPercent }}%</span>
</span>
<span v-else>{{ updateLabel }}</span>
</button>
</ButtonStyled>
</template>
<script setup lang="ts">
import { DownloadIcon, RefreshCwIcon } from '@modrinth/assets'
import { ButtonStyled, defineMessages, useVIntl } from '@modrinth/ui'
import { computed, nextTick, onBeforeUnmount, ref, watch } from 'vue'
import {
appUpdateState,
downloadAvailableAppUpdate,
installAvailableAppUpdate,
} from '@/providers/app-update'
const { formatMessage } = useVIntl()
const messages = defineMessages({
update: {
id: 'app.action-bar.update',
defaultMessage: 'Update',
},
downloadingUpdate: {
id: 'app.action-bar.downloading-update',
defaultMessage: 'Downloading update',
},
reloadToUpdate: {
id: 'app.action-bar.reload-to-update',
defaultMessage: 'Reload to update',
},
})
const {
downloading,
downloadPercent,
downloadProgress,
finishedDownloading,
isVisible: isUpdateVisible,
metered,
restarting,
} = appUpdateState
const isUpdateDownloading = computed(
() =>
downloading.value ||
(downloadProgress.value > 0 && downloadProgress.value < 1 && !finishedDownloading.value),
)
const showUpdatePill = computed(
() => isUpdateVisible.value && (finishedDownloading.value || metered.value),
)
const animateReadyPill = ref(false)
const updateLabel = computed(() => {
if (isUpdateDownloading.value) {
return formatMessage(messages.downloadingUpdate)
}
if (finishedDownloading.value) {
return formatMessage(messages.reloadToUpdate)
}
return formatMessage(messages.update)
})
let readyPillAnimationFrame: number | null = null
watch([showUpdatePill, finishedDownloading], async ([show, ready], [wasShown, wasReady]) => {
if (readyPillAnimationFrame !== null) {
cancelAnimationFrame(readyPillAnimationFrame)
readyPillAnimationFrame = null
}
if (!show || !ready) {
animateReadyPill.value = false
return
}
if (wasShown && wasReady) {
return
}
animateReadyPill.value = false
await nextTick()
readyPillAnimationFrame = requestAnimationFrame(() => {
animateReadyPill.value = true
readyPillAnimationFrame = null
})
})
async function handleUpdateClick() {
if (isUpdateDownloading.value) {
return
}
if (finishedDownloading.value) {
await installAvailableAppUpdate()
} else {
await downloadAvailableAppUpdate()
}
}
onBeforeUnmount(() => {
if (readyPillAnimationFrame !== null) {
cancelAnimationFrame(readyPillAnimationFrame)
}
})
</script>
@@ -269,16 +269,31 @@ provideInstallationSettings({
debug('resolveLoaderVersions: no manifest', { loader, gameVersion })
return []
}
if (loader === 'fabric' || loader === 'quilt') {
const result = manifest.gameVersions[0]?.loaders ?? []
debug('resolveLoaderVersions: fabric/quilt result', {
const entry = manifest.gameVersions?.find((item) => item.id === gameVersion)
if (entry?.versionGroup) {
const result =
manifest.versionGroups?.find((group) => group.id === entry.versionGroup)?.loaders ?? []
debug('resolveLoaderVersions: version group result', {
loader,
gameVersion,
versionGroup: entry.versionGroup,
count: result.length,
})
return result
}
const placeholder = manifest.gameVersions?.find((item) => item.id === '${modrinth.gameVersion}')
if (placeholder) {
const result = manifest.gameVersions?.some((item) => item.id === gameVersion)
? placeholder.loaders
: []
debug('resolveLoaderVersions: placeholder result', {
loader,
gameVersion,
count: result.length,
})
return result
}
const result = manifest.gameVersions?.find((item) => item.id === gameVersion)?.loaders ?? []
const result = entry?.loaders ?? []
debug('resolveLoaderVersions: result', { loader, gameVersion, count: result.length })
return result
},
@@ -1,4 +1,4 @@
import { UpdatedIcon } from '@modrinth/assets'
import { CheckIcon, CopyIcon, UpdatedIcon } from '@modrinth/assets'
import {
defineMessages,
type PopupNotificationButton,
@@ -15,9 +15,11 @@ import {
install_job_dismiss,
install_job_list,
install_job_retry,
install_job_support_details,
installJobInstanceId,
type InstallJobSnapshot,
type InstallJobStatus,
type InstallPhaseId,
type InstallProgress,
} from '@/helpers/install'
import { get_many as getInstances } from '@/helpers/instance'
@@ -31,6 +33,14 @@ const messages = defineMessages({
id: 'app.action-bar.install.retry',
defaultMessage: 'Retry',
},
copyDetails: {
id: 'app.action-bar.install.copy-details',
defaultMessage: 'Copy details',
},
copied: {
id: 'app.action-bar.install.copied-details',
defaultMessage: 'Copied',
},
dismiss: {
id: 'app.action-bar.install.dismiss',
defaultMessage: 'Dismiss',
@@ -39,22 +49,6 @@ const messages = defineMessages({
id: 'app.action-bar.install.open-instance',
defaultMessage: 'Open instance',
},
installFailed: {
id: 'app.action-bar.install.failed',
defaultMessage: 'Install failed',
},
installFailedAppClosed: {
id: 'app.action-bar.install.failed-app-closed',
defaultMessage: 'Installation failed due to app closing.',
},
installFailedNetwork: {
id: 'app.action-bar.install.failed-network',
defaultMessage: 'Installation failed due to a network error.',
},
installFailedUnknown: {
id: 'app.action-bar.install.failed-unknown',
defaultMessage: 'Installation failed due to an unknown error.',
},
unknownInstance: {
id: 'app.action-bar.install.unknown-instance',
defaultMessage: 'Unknown instance',
@@ -64,7 +58,7 @@ const messages = defineMessages({
const phaseMessages = defineMessages({
preparing_instance: {
id: 'app.install.phase.preparing_instance',
defaultMessage: 'Preparing instance',
defaultMessage: 'Queued to install',
},
resolving_pack: {
id: 'app.install.phase.resolving_pack',
@@ -139,7 +133,97 @@ const javaStepMessages = defineMessages({
},
})
const failureSummaryMessages = defineMessages({
canceled: {
id: 'app.action-bar.install.summary.canceled',
defaultMessage: 'Canceled',
},
appClosed: {
id: 'app.action-bar.install.summary.app-closing',
defaultMessage: 'Canceled due to app closing',
},
downloadFailed: {
id: 'app.action-bar.install.summary.download-failed',
defaultMessage: "Download couldn't finish",
},
modrinthUnreachable: {
id: 'app.action-bar.install.summary.modrinth-unreachable',
defaultMessage: "Couldn't reach Modrinth",
},
packDownloadFailed: {
id: 'app.action-bar.install.summary.pack-download-failed',
defaultMessage: "Couldn't download pack",
},
badModpackFile: {
id: 'app.action-bar.install.summary.bad-modpack-file',
defaultMessage: "Couldn't read modpack",
},
invalidModpack: {
id: 'app.action-bar.install.summary.invalid-modpack',
defaultMessage: 'Modpack data invalid',
},
contentDownloadFailed: {
id: 'app.action-bar.install.summary.content-download-failed',
defaultMessage: "Couldn't download files",
},
corruptDownload: {
id: 'app.action-bar.install.summary.corrupt-download',
defaultMessage: 'Downloaded file is corrupt',
},
invalidModpackFiles: {
id: 'app.action-bar.install.summary.invalid-modpack-files',
defaultMessage: 'Modpack files have invalid metadata',
},
noWritePermission: {
id: 'app.action-bar.install.summary.no-write-permission',
defaultMessage: 'No permission to write',
},
couldNotSaveFiles: {
id: 'app.action-bar.install.summary.could-not-save-files',
defaultMessage: "Couldn't save files",
},
invalidFilePath: {
id: 'app.action-bar.install.summary.invalid-file-path',
defaultMessage: 'File path is invalid',
},
instanceNotFound: {
id: 'app.action-bar.install.summary.instance-not-found',
defaultMessage: "Instance couldn't be found",
},
cleanupIncomplete: {
id: 'app.action-bar.install.summary.cleanup-incomplete',
defaultMessage: "Cleanup didn't finish",
},
javaSetupFailed: {
id: 'app.action-bar.install.summary.java-setup-failed',
defaultMessage: "Java setup couldn't finish",
},
minecraftSetupFailed: {
id: 'app.action-bar.install.summary.minecraft-setup-failed',
defaultMessage: 'Minecraft setup failed',
},
loaderSetupFailed: {
id: 'app.action-bar.install.summary.loader-setup-failed',
defaultMessage: 'Loader setup failed',
},
localDataError: {
id: 'app.action-bar.install.summary.local-data-error',
defaultMessage: "Couldn't update local data",
},
unexpectedError: {
id: 'app.action-bar.install.summary.unexpected-error',
defaultMessage: 'Something went wrong',
},
})
const visibleJobStatuses = new Set<InstallJobStatus>(['queued', 'running', 'failed', 'interrupted'])
const copyDetailsStallMs = 30_000
interface ProgressSnapshot {
signature: string
changedAt: number
timeout: number | null
}
function getDisplayIconUrl(icon: string | null | undefined): string | null {
if (!icon) return null
@@ -156,10 +240,13 @@ export async function useInstallJobNotifications(opts: {
const jobs = ref<InstallJobSnapshot[]>([])
const iconUrls = ref<Record<string, string | null>>({})
const instanceNames = ref<Record<string, string>>({})
const copiedJobIds = ref<Set<string>>(new Set())
const jobOrder = new Map<string, number>()
let refreshRequest = 0
let metadataRequest = 0
let nextJobOrder = 0
const copiedResetTimeouts = new Map<string, number>()
const progressSnapshots = new Map<string, ProgressSnapshot>()
function getTitle(job: InstallJobSnapshot): string {
if (job.display?.title) return job.display.title
@@ -174,13 +261,7 @@ export async function useInstallJobNotifications(opts: {
function getText(job: InstallJobSnapshot): string {
if (job.status === 'failed' || job.status === 'interrupted') {
if (job.error?.code === 'interrupted') {
return formatMessage(messages.installFailedAppClosed)
}
if (job.error?.code === 'network_error') {
return formatMessage(messages.installFailedNetwork)
}
return formatMessage(messages.installFailedUnknown)
return getFailureSummary(job)
}
if (job.phase === 'preparing_java' && job.details.type === 'java') {
return formatMessage(javaStepMessages[job.details.step], {
@@ -190,6 +271,104 @@ export async function useInstallJobNotifications(opts: {
return formatMessage(phaseMessages[job.phase])
}
function getFailureSummary(job: InstallJobSnapshot): string {
const code = job.error?.code
const phase = job.error?.phase ?? job.phase
if (code === 'app_closed' || (job.status === 'interrupted' && code === 'interrupted')) {
return formatMessage(failureSummaryMessages.appClosed)
}
if (code === 'canceled') {
return formatMessage(failureSummaryMessages.canceled)
}
if (job.rollback_error || code === 'rollback_error') {
return formatMessage(failureSummaryMessages.cleanupIncomplete)
}
if (hasPermissionError(job)) {
return formatMessage(failureSummaryMessages.noWritePermission)
}
switch (code) {
case 'network_error':
return formatMessage(
phase === 'downloading_pack_file'
? failureSummaryMessages.packDownloadFailed
: failureSummaryMessages.downloadFailed,
)
case 'api_error':
return formatMessage(failureSummaryMessages.modrinthUnreachable)
case 'pack_error':
return formatMessage(
phase === 'downloading_pack_file'
? failureSummaryMessages.packDownloadFailed
: failureSummaryMessages.invalidModpack,
)
case 'archive_error':
return formatMessage(failureSummaryMessages.badModpackFile)
case 'parse_error':
return formatMessage(failureSummaryMessages.invalidModpack)
case 'content_error':
return formatMessage(failureSummaryMessages.invalidModpackFiles)
case 'hash_error':
return formatMessage(failureSummaryMessages.corruptDownload)
case 'filesystem_error':
return formatMessage(failureSummaryMessages.couldNotSaveFiles)
case 'path_error':
return formatMessage(failureSummaryMessages.invalidFilePath)
case 'instance_error':
return formatMessage(failureSummaryMessages.instanceNotFound)
case 'java_error':
return formatMessage(failureSummaryMessages.javaSetupFailed)
case 'loader_error':
case 'processor_error':
return formatMessage(failureSummaryMessages.loaderSetupFailed)
case 'database_error':
return formatMessage(failureSummaryMessages.localDataError)
case 'launcher_error':
case 'metadata_error':
return getFailureSummaryForPhase(phase)
default:
return getFailureSummaryForPhase(phase)
}
}
function getFailureSummaryForPhase(phase: InstallPhaseId): string {
switch (phase) {
case 'downloading_pack_file':
return formatMessage(failureSummaryMessages.packDownloadFailed)
case 'resolving_pack':
case 'reading_pack_manifest':
return formatMessage(failureSummaryMessages.invalidModpack)
case 'downloading_content':
return formatMessage(failureSummaryMessages.contentDownloadFailed)
case 'extracting_overrides':
return formatMessage(failureSummaryMessages.couldNotSaveFiles)
case 'resolving_minecraft':
case 'downloading_minecraft':
return formatMessage(failureSummaryMessages.minecraftSetupFailed)
case 'resolving_loader':
case 'running_loader_processors':
return formatMessage(failureSummaryMessages.loaderSetupFailed)
case 'preparing_java':
return formatMessage(failureSummaryMessages.javaSetupFailed)
case 'preparing_instance':
return formatMessage(failureSummaryMessages.instanceNotFound)
case 'rolling_back':
return formatMessage(failureSummaryMessages.cleanupIncomplete)
default:
return formatMessage(failureSummaryMessages.unexpectedError)
}
}
function hasPermissionError(job: InstallJobSnapshot): boolean {
const message = job.error?.message.toLowerCase() ?? ''
return (
message.includes('permission denied') ||
message.includes('access is denied') ||
message.includes('operation not permitted')
)
}
function getProgressType(job: InstallJobSnapshot): PopupNotificationProgressType | undefined {
if (!getEffectiveProgress(job)) return undefined
if (
@@ -235,11 +414,165 @@ export async function useInstallJobNotifications(opts: {
return job.status === 'failed' || job.status === 'interrupted'
}
function getTerminalButtons(job: InstallJobSnapshot): PopupNotificationButton[] | undefined {
if (!isTerminalJob(job)) return undefined
function canShowStalledProgressDetails(job: InstallJobSnapshot): boolean {
return (
job.status === 'running' &&
job.phase !== 'preparing_instance' &&
job.phase !== 'finalizing' &&
job.phase !== 'rolling_back'
)
}
function getJobSortRank(job: InstallJobSnapshot): number {
if (isTerminalJob(job)) return 0
if (job.status === 'queued' || job.phase === 'preparing_instance') return 2
return 1
}
function progressSignature(job: InstallJobSnapshot): string {
const progress = job.progress
const secondary = progress?.secondary
return [
{
job.status,
job.phase,
JSON.stringify(job.details),
progress?.current ?? '',
progress?.total ?? '',
secondary?.current ?? '',
secondary?.total ?? '',
].join(':')
}
function clearCopied(jobId: string) {
if (!copiedJobIds.value.has(jobId)) {
return
}
const timeout = copiedResetTimeouts.get(jobId)
if (timeout != null) {
window.clearTimeout(timeout)
copiedResetTimeouts.delete(jobId)
}
const nextCopiedJobIds = new Set(copiedJobIds.value)
nextCopiedJobIds.delete(jobId)
copiedJobIds.value = nextCopiedJobIds
}
function clearProgressSnapshot(jobId: string) {
const snapshot = progressSnapshots.get(jobId)
if (snapshot?.timeout != null) {
window.clearTimeout(snapshot.timeout)
}
progressSnapshots.delete(jobId)
}
function scheduleStaleProgressRefresh(jobId: string) {
const snapshot = progressSnapshots.get(jobId)
if (!snapshot) {
return
}
snapshot.timeout = window.setTimeout(() => {
const snapshot = progressSnapshots.get(jobId)
if (!snapshot) {
return
}
snapshot.timeout = null
opts.onChange()
}, copyDetailsStallMs)
}
function syncProgressSnapshots(nextJobs: InstallJobSnapshot[]) {
const trackedJobIds = new Set<string>()
const now = Date.now()
for (const job of nextJobs) {
if (!canShowStalledProgressDetails(job)) {
continue
}
trackedJobIds.add(job.job_id)
const signature = progressSignature(job)
const snapshot = progressSnapshots.get(job.job_id)
if (snapshot?.signature === signature) {
continue
}
clearProgressSnapshot(job.job_id)
clearCopied(job.job_id)
progressSnapshots.set(job.job_id, {
signature,
changedAt: now,
timeout: null,
})
scheduleStaleProgressRefresh(job.job_id)
}
for (const jobId of progressSnapshots.keys()) {
if (!trackedJobIds.has(jobId)) {
clearProgressSnapshot(jobId)
}
}
}
function hasStalledProgress(job: InstallJobSnapshot): boolean {
const snapshot = progressSnapshots.get(job.job_id)
return !!snapshot && Date.now() - snapshot.changedAt >= copyDetailsStallMs
}
function shouldShowCopyDetails(job: InstallJobSnapshot): boolean {
return isTerminalJob(job) || (canShowStalledProgressDetails(job) && hasStalledProgress(job))
}
function isCopied(job: InstallJobSnapshot): boolean {
return copiedJobIds.value.has(job.job_id)
}
function setCopied(job: InstallJobSnapshot) {
copiedJobIds.value = new Set([...copiedJobIds.value, job.job_id])
const existingTimeout = copiedResetTimeouts.get(job.job_id)
if (existingTimeout != null) {
window.clearTimeout(existingTimeout)
}
copiedResetTimeouts.set(
job.job_id,
window.setTimeout(() => {
copiedResetTimeouts.delete(job.job_id)
if (!copiedJobIds.value.has(job.job_id)) {
return
}
const nextCopiedJobIds = new Set(copiedJobIds.value)
nextCopiedJobIds.delete(job.job_id)
copiedJobIds.value = nextCopiedJobIds
opts.onChange()
}, 1_000),
)
opts.onChange()
}
async function copyJobDetails(job: InstallJobSnapshot) {
const details = await install_job_support_details(job.job_id).catch((error) => {
opts.handleError(error)
return null
})
if (!details) {
return
}
try {
await navigator.clipboard.writeText(details)
setCopied(job)
} catch (error) {
opts.handleError(error)
}
}
function getButtons(job: InstallJobSnapshot): PopupNotificationButton[] {
const buttons: PopupNotificationButton[] = []
if (isTerminalJob(job)) {
buttons.push({
label: formatMessage(messages.retry),
icon: UpdatedIcon,
color: 'brand',
@@ -248,8 +581,23 @@ export async function useInstallJobNotifications(opts: {
await install_job_retry(job.job_id).catch(opts.handleError)
await refresh()
},
},
]
})
}
if (shouldShowCopyDetails(job)) {
const copied = isCopied(job)
buttons.push({
label: formatMessage(copied ? messages.copied : messages.copyDetails),
icon: copied ? CheckIcon : CopyIcon,
color: 'standard',
keepOpen: true,
action: async () => {
await copyJobDetails(job)
},
})
}
return buttons
}
function setJobs(nextJobs: InstallJobSnapshot[]) {
@@ -259,13 +607,15 @@ export async function useInstallJobNotifications(opts: {
}
}
jobs.value = nextJobs
.filter((job) => visibleJobStatuses.has(job.status))
.sort(
(a, b) =>
a.created.localeCompare(b.created) ||
(jobOrder.get(a.job_id) ?? 0) - (jobOrder.get(b.job_id) ?? 0),
)
const visibleJobs = nextJobs.filter((job) => visibleJobStatuses.has(job.status))
syncProgressSnapshots(visibleJobs)
jobs.value = visibleJobs.sort(
(a, b) =>
getJobSortRank(a) - getJobSortRank(b) ||
a.created.localeCompare(b.created) ||
(jobOrder.get(a.job_id) ?? 0) - (jobOrder.get(b.job_id) ?? 0),
)
}
const progressItems = computed<PopupNotificationProgressItem[]>(() =>
@@ -284,7 +634,7 @@ export async function useInstallJobNotifications(opts: {
progressType: isTerminalJob(job) ? undefined : getProgressType(job),
progressCurrent: isTerminalJob(job) ? undefined : progress?.current,
progressTotal: isTerminalJob(job) ? undefined : progress?.total,
buttons: getTerminalButtons(job),
buttons: getButtons(job),
onDismiss: isTerminalJob(job)
? async () => {
await install_job_dismiss(job.job_id).catch(opts.handleError)
@@ -382,6 +732,14 @@ export async function useInstallJobNotifications(opts: {
progressItems,
buttons,
refresh,
dispose: () => unlisten(),
dispose: () => {
for (const timeout of copiedResetTimeouts.values()) {
window.clearTimeout(timeout)
}
for (const jobId of progressSnapshots.keys()) {
clearProgressSnapshot(jobId)
}
unlisten()
},
}
}
@@ -8,13 +8,18 @@ import type { ComputedRef, Ref } from 'vue'
import { onUnmounted, ref, shallowRef } from 'vue'
import type { Router } from 'vue-router'
import {
fetchCachedServerStatus,
getFreshCachedServerStatus,
} from '@/composables/instances/use-server-status-query'
import { process_listener } from '@/helpers/events'
import { kill, list as listInstances } from '@/helpers/instance'
import { get_by_instance_id } from '@/helpers/process'
import type { GameInstance } from '@/helpers/types'
import { add_server_to_instance, getServerAddress, getServerLatency } from '@/helpers/worlds'
import { add_server_to_instance, getServerAddress } from '@/helpers/worlds'
interface BrowseServerInstance {
id: string
name: string
path: string
}
@@ -68,12 +73,10 @@ export function useAppServerBrowse(options: UseAppServerBrowseOptions) {
const queryClient = useQueryClient()
const debugLog = useDebugLogger('BrowseServer')
const serverPings = shallowRef<Record<string, number | undefined>>({})
const serverPingCache = new Map<string, number | undefined>()
const pendingServerPings = new Map<string, Promise<number | undefined>>()
const runningServerProjects = ref<Record<string, string>>({})
const lastServerHits = shallowRef<Labrinth.Search.v3.ResultSearchProject[]>([])
const contextMenuRef = ref<ContextMenuHandle | null>(null)
let serverPingCacheActive = true
let serverPingsActive = true
let unlistenProcesses: (() => void) | null = null
async function checkServerRunningStates(hits: Labrinth.Search.v3.ResultSearchProject[]) {
@@ -146,37 +149,26 @@ export function useAppServerBrowse(options: UseAppServerBrowseOptions) {
})
const nextPings = { ...serverPings.value }
for (const { hit, address } of pingsToFetch) {
if (serverPingCache.has(address)) {
nextPings[hit.project_id] = serverPingCache.get(address)
const cachedStatus = getFreshCachedServerStatus(queryClient, address)
if (cachedStatus) {
nextPings[hit.project_id] = cachedStatus.ping
}
}
serverPings.value = nextPings
await Promise.all(
pingsToFetch.map(async ({ hit, address }) => {
if (serverPingCache.has(address)) return
if (getFreshCachedServerStatus(queryClient, address)) return
let pending = pendingServerPings.get(address)
if (!pending) {
pending = getServerLatency(address)
.then((latency) => {
if (serverPingCacheActive) serverPingCache.set(address, latency)
return latency
})
.catch((error) => {
console.error(`Failed to ping server ${address}:`, error)
if (serverPingCacheActive) serverPingCache.set(address, undefined)
return undefined
})
.finally(() => {
pendingServerPings.delete(address)
})
pendingServerPings.set(address, pending)
try {
const status = await fetchCachedServerStatus(queryClient, address)
if (!serverPingsActive) return
serverPings.value = { ...serverPings.value, [hit.project_id]: status.ping }
} catch (error) {
console.error(`Failed to ping server ${address}:`, error)
if (!serverPingsActive) return
serverPings.value = { ...serverPings.value, [hit.project_id]: undefined }
}
const latency = await pending
if (!serverPingCacheActive) return
serverPings.value = { ...serverPings.value, [hit.project_id]: latency }
}),
)
}
@@ -308,10 +300,8 @@ export function useAppServerBrowse(options: UseAppServerBrowseOptions) {
.catch(options.handleError)
onUnmounted(() => {
serverPingCacheActive = false
serverPingsActive = false
unlistenProcesses?.()
serverPingCache.clear()
pendingServerPings.clear()
})
return {
@@ -0,0 +1,50 @@
import type { QueryClient } from '@tanstack/vue-query'
import {
get_server_status,
normalizeServerAddress,
type ProtocolVersion,
type ServerStatus,
} from '@/helpers/worlds'
export const SERVER_STATUS_CACHE_MS = 10 * 60 * 1000
function getProtocolVersionKey(protocolVersion: ProtocolVersion | null) {
if (!protocolVersion) return 'default'
return `${protocolVersion.version}:${protocolVersion.legacy ? 'legacy' : 'modern'}`
}
export function getServerStatusQueryKey(
address: string,
protocolVersion: ProtocolVersion | null = null,
) {
return [
'minecraft-server-status',
normalizeServerAddress(address) || address.trim().toLowerCase(),
getProtocolVersionKey(protocolVersion),
] as const
}
export function getFreshCachedServerStatus(
queryClient: QueryClient,
address: string,
protocolVersion: ProtocolVersion | null = null,
) {
const queryKey = getServerStatusQueryKey(address, protocolVersion)
const updatedAt = queryClient.getQueryState(queryKey)?.dataUpdatedAt ?? 0
if (!updatedAt || Date.now() - updatedAt >= SERVER_STATUS_CACHE_MS) return undefined
return queryClient.getQueryData<ServerStatus>(queryKey)
}
export async function fetchCachedServerStatus(
queryClient: QueryClient,
address: string,
protocolVersion: ProtocolVersion | null = null,
) {
return await queryClient.fetchQuery({
queryKey: getServerStatusQueryKey(address, protocolVersion),
queryFn: () => get_server_status(address, protocolVersion),
staleTime: SERVER_STATUS_CACHE_MS,
gcTime: SERVER_STATUS_CACHE_MS,
})
}
+36 -1
View File
@@ -85,6 +85,36 @@ export type InstallJavaStep =
| 'extracting'
| 'validating'
export interface InstallErrorView {
code: string
phase?: InstallPhaseId | null
message: string
api?: {
error: string
status?: number | null
method?: string | null
url?: string | null
route?: string | null
} | null
context?: {
operation: string
source_path?: string | null
target_path?: string | null
file_path?: string | null
entry_path?: string | null
urls?: string[]
expected_hash?: string | null
expected_size?: number | null
project_id?: string | null
version_id?: string | null
minecraft_version?: string | null
loader?: string | null
java_version?: number | null
os?: string | null
arch?: string | null
} | null
}
export interface InstallJobSnapshot {
job_id: string
instance_id?: string | null
@@ -114,7 +144,8 @@ export interface InstallJobSnapshot {
}
| { type: 'import'; launcher_type: string; instance_folder: string }
display?: { title: string; icon?: string | null } | null
error?: { code: string; message: string } | null
error?: InstallErrorView | null
rollback_error?: InstallErrorView | null
created: string
modified: string
finished?: string | null
@@ -197,6 +228,10 @@ export async function install_job_dismiss(jobId: string) {
return await invoke<void>('plugin:install|install_job_dismiss', { jobId })
}
export async function install_job_support_details(jobId: string) {
return await invoke<string>('plugin:install|install_job_support_details', { jobId })
}
export function installJobInstanceId(job: InstallJobSnapshot): string | null {
return job.instance_id ?? job.target.instance_id ?? null
}
+7
View File
@@ -133,11 +133,18 @@ type Hooks = {
type Manifest = {
gameVersions: ManifestGameVersion[]
versionGroups?: ManifestVersionGroup[]
}
type ManifestGameVersion = {
id: string
stable: boolean
versionGroup?: string
loaders: ManifestLoaderVersion[]
}
type ManifestVersionGroup = {
id: string
loaders: ManifestLoaderVersion[]
}
+400 -1
View File
@@ -11,6 +11,87 @@
"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": "التجاهل"
},
"app.action-bar.install.open-instance": {
"message": "فتح المثيل"
},
"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": "مثيل غير معروف"
},
"app.action-bar.installs": {
"message": "عمليات التثبيت"
},
"app.action-bar.make-primary-instance": {
"message": "إنشاء نسخة أساسية"
},
@@ -74,12 +155,63 @@
"app.appearance-settings.hide-nametag.title": {
"message": "إخفاء عرض الاسم"
},
"app.appearance-settings.jump-back-into-worlds.description": {
"message": "يتضمن العوالم الحديثة في قسم \"العودة إلى اللعب\" على الصفحة الرئيسية."
},
"app.appearance-settings.jump-back-into-worlds.title": {
"message": "العودة إلى العوالم"
},
"app.appearance-settings.minimize-launcher.description": {
"message": "قم بتصغير نافذة المشغل عند بدء تشغيل عملية Minecraft."
},
"app.appearance-settings.minimize-launcher.title": {
"message": "تصغير نافذة المشغل"
},
"app.appearance-settings.native-decorations.description": {
"message": "استخدام إطار نافذة النظام (يتطلب إعادة تشغيل التطبيق)."
},
"app.appearance-settings.native-decorations.title": {
"message": "الزخارف التقليدية"
},
"app.appearance-settings.select-option": {
"message": "حدد خياراً"
},
"app.appearance-settings.show-play-time.description": {
"message": "يعرض مقدار الوقت الذي قضيته في لعب إحدى المهام."
},
"app.appearance-settings.show-play-time.title": {
"message": "إظهار مدة التشغيل"
},
"app.appearance-settings.skip-non-essential-warnings.description": {
"message": "يتخطى تلقائيًا عمليات التأكيد ذات المخاطر المنخفضة، مثل تثبيت حزم التعديلات المكررة، وحذف المحتوى العادي، والتحديثات الجماعية، وإلغاء ربط حزم التعديلات، ومطالبات الإصلاح. وسيستمر عرض التحذيرات الخطيرة."
},
"app.appearance-settings.skip-non-essential-warnings.title": {
"message": "تخطي التحذيرات غير الضرورية"
},
"app.appearance-settings.toggle-sidebar.description": {
"message": "يتيح إمكانية إظهار الشريط الجانبي أو إخفائه."
},
"app.appearance-settings.toggle-sidebar.title": {
"message": "تبديل الشريط الجانبي"
},
"app.appearance-settings.unknown-pack-warning.description": {
"message": "إذا حاولت تثبيت ملف Modrinth Pack (.mrpack) غير مستضاف على Modrinth، فسنتأكد من أنك على دراية بالمخاطر قبل تثبيته."
},
"app.appearance-settings.unknown-pack-warning.title": {
"message": "أخبرني قبل تثبيت حزم التعديلات غير المعروفة"
},
"app.auth-servers.unreachable.body": {
"message": "قد تكون خوادم مصادقة ماينكرافت معطلة حاليًا. تحقق من اتصالك بالإنترنت وحاول مرة أخرى لاحقًا."
},
"app.auth-servers.unreachable.header": {
"message": "تعذر الوصول إلى خوادم المصادقة"
},
"app.browse.add-servers-to-instance": {
"message": "إضافة خادم إلى المثيل"
},
"app.browse.add-to-an-instance": {
"message": "إضافة إلى مثيل"
},
"app.browse.add-to-instance": {
"message": "أضف للنموذج"
},
@@ -102,11 +234,20 @@
"message": "استكشف خوادم"
},
"app.browse.hide-added-servers": {
"message": "إخفاء الخوادم المضافة مسبقًا"
"message": "إخفاء الخوادم التي تمت إضافتها بالفعل"
},
"app.browse.project-type.modpacks": {
"message": "حزم التعديل"
},
"app.browse.server-instance-content-warning": {
"message": "قد تؤدي إضافة المحتوى إلى حدوث مشاكل في التوافق عند الاتصال بالخادم. كما سيُفقد أي محتوى تمت إضافته عند تحديث محتوى مثيل الخادم."
},
"app.browse.server.installing": {
"message": "جاري التثبيت"
},
"app.content-install.no-compatible-versions": {
"message": "لا توجد إصدارات متاحة تتوافق مع {compatibilityLabel}. اختر إصدارًا لتثبيته على أي حال. لن يتم تثبيت التبعيات تلقائيًا."
},
"app.export-modal.description-placeholder": {
"message": "أدخل وصف التعديل..."
},
@@ -128,6 +269,60 @@
"app.export-modal.version-number-placeholder": {
"message": "1.0.0"
},
"app.install.phase.downloading_content": {
"message": "تنزيل المحتوى"
},
"app.install.phase.downloading_minecraft": {
"message": "تنزيل لعبة Minecraft"
},
"app.install.phase.downloading_pack_file": {
"message": "تنزيل ملف الحزمة"
},
"app.install.phase.extracting_overrides": {
"message": "استخراج التجاوزات"
},
"app.install.phase.finalizing": {
"message": "وضع اللمسات الأخيرة"
},
"app.install.phase.preparing_instance": {
"message": "في قائمة الانتظار للتثبيت"
},
"app.install.phase.preparing_java": {
"message": "التحضير لـ Java"
},
"app.install.phase.preparing_java.downloading": {
"message": "تنزيل Java {version}"
},
"app.install.phase.preparing_java.extracting": {
"message": "استخراج Java {version}"
},
"app.install.phase.preparing_java.fetching-metadata": {
"message": "جلب Java {version}"
},
"app.install.phase.preparing_java.resolving": {
"message": "التحضير لـ Java {version}"
},
"app.install.phase.preparing_java.validating": {
"message": "التحقق من صحة إصدار Java {version}"
},
"app.install.phase.reading_pack_manifest": {
"message": "قراءة قائمة المحتويات"
},
"app.install.phase.resolving_loader": {
"message": "معالج التحميل"
},
"app.install.phase.resolving_minecraft": {
"message": "حل مشكلة Minecraft"
},
"app.install.phase.resolving_pack": {
"message": "معالجة المحتوى"
},
"app.install.phase.rolling_back": {
"message": "التراجع"
},
"app.install.phase.running_loader_processors": {
"message": "تشغيل معالجات التحميل"
},
"app.instance.confirm-delete.admonition-body": {
"message": "سيتم حذف جميع البيانات الخاصة لنموذجك نهائيًا، بما في ذلك عوالمك والتكوينات وكل المحتوى المثبت."
},
@@ -152,6 +347,15 @@
"app.instance.modpack-already-installed.instance": {
"message": "النموذج"
},
"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": "مشروع"
},
@@ -176,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": "معدّل"
},
@@ -194,6 +410,18 @@
"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} عوالم..."
},
@@ -233,12 +461,30 @@
"app.modal.update-to-play.update-required-description": {
"message": "هناك تحديث مطلوب للعب بـ {name}. الرجاء التحديث إلى أحدث اصدار لتشغيل اللعبة."
},
"app.project.install-button.already-installed": {
"message": "هذا المشروع مُثبَّت بالفعل"
},
"app.project.install-button.switch-version": {
"message": "تغيير الإصدار"
},
"app.project.install-context.back-to-browse": {
"message": "العودة إلى الاستكشاف"
},
"app.project.install-context.install-content-to-instance": {
"message": "تثبيت المحتويات الى النموذج"
},
"app.project.version.all-versions": {
"message": "جميع الإصدارات"
},
"app.project.version.download-in-browser": {
"message": "التنزيل من المتصفح"
},
"app.project.version.installing": {
"message": "التثبيت"
},
"app.project.versions.already-installed": {
"message": "مثبت بالفعل"
},
"app.settings.developer-mode-enabled": {
"message": "تم تفعيل وضع المطوّر."
},
@@ -281,6 +527,12 @@
"app.skins.delete-modal.title": {
"message": "هل أنت متأكد من حذف المظهر هذا؟"
},
"app.skins.dropped-file-error.text": {
"message": "فشل قراءة الملف الذي تم إسقاطه."
},
"app.skins.dropped-file-error.title": {
"message": "حدث خطأ أثناء معالجة الملف"
},
"app.skins.edit-button": {
"message": "تعديل المظهر"
},
@@ -290,6 +542,15 @@
"app.skins.modal.add-title": {
"message": "إضافة المظهر"
},
"app.skins.modal.arm-style-section": {
"message": "طراز الأذرع"
},
"app.skins.modal.arm-style-slim": {
"message": "نحيف"
},
"app.skins.modal.arm-style-wide": {
"message": "عريض"
},
"app.skins.modal.cape-fallback-name": {
"message": "رداء"
},
@@ -308,12 +569,18 @@
"app.skins.modal.none-cape-option": {
"message": "لا شيء"
},
"app.skins.modal.replace-texture-button": {
"message": "استبدال النسيج"
},
"app.skins.modal.save-skin-button": {
"message": "حفظ المظهر"
},
"app.skins.modal.saving-tooltip": {
"message": "حفظ..."
},
"app.skins.modal.texture-section": {
"message": "النسيج"
},
"app.skins.modal.upload-skin-first-tooltip": {
"message": "أرفع المظهر أولا !"
},
@@ -323,9 +590,69 @@
"app.skins.previewing-badge": {
"message": "أستعراض"
},
"app.skins.rate-limit.text": {
"message": "أنت تقوم بتغيير مظهرك بشكل متكرر جدًّا. وقد قامت خوادم Mojang بحظر الطلبات الإضافية مؤقتًا. يرجى الانتظار قليلاً قبل إعادة المحاولة."
},
"app.skins.rate-limit.title": {
"message": "تمهل !"
},
"app.skins.reorder-error.text": {
"message": "تعذر حفظ طلبك المتعلق بشكل الشخصية."
},
"app.skins.reorder-error.title": {
"message": "فشل إعادة ترتيب أشكال الشخصيات"
},
"app.skins.section.builders-and-biomes": {
"message": "البناة والبيئات الحيوية"
},
"app.skins.section.chaos-cubed": {
"message": "الفوضى المربعة"
},
"app.skins.section.chase-the-skies": {
"message": "انطلق نحو السماء"
},
"app.skins.section.default-skins": {
"message": "شكل الشخصية الافتراضية"
},
"app.skins.section.minecon-earth-2017": {
"message": "MINECON Earth 2017"
},
"app.skins.section.modrinth": {
"message": "Modrinth"
},
"app.skins.section.modrinth-pride": {
"message": "فخر Modrinth"
},
"app.skins.section.modrinth-pride.tooltip": {
"message": "لقد حصلت على هذه أشكال الشخصيات مقابل تبرعك لحملة جمع التبرعات التي نظمتها Modrinth Pride خلال شهر الفخر."
},
"app.skins.section.mounts-of-mayhem": {
"message": "جبال الفوضى"
},
"app.skins.section.saved-skins": {
"message": "المظاهر المحفوظة"
},
"app.skins.section.striding-hero": {
"message": "البطل السائر بخطوات واسعة"
},
"app.skins.section.the-copper-age": {
"message": "العصر النحاسي"
},
"app.skins.section.the-garden-awakens": {
"message": "الحديقة تستيقظ"
},
"app.skins.section.tiny-takeover": {
"message": "الاستحواذ الصغير"
},
"app.skins.sign-in.button": {
"message": "تسجيل الدخول"
},
"app.skins.sign-in.description": {
"message": "يرجى تسجيل الدخول إلى حسابك في Minecraft لاستخدام ميزات إدارة أشكال الشخصيات في تطبيق Modrinth."
},
"app.skins.sign-in.rinthbot-alt": {
"message": "روبوت Modrinth المتحمس"
},
"app.skins.sign-in.title": {
"message": "الرجاء تسجيل الدخول"
},
@@ -350,6 +677,9 @@
"app.update-popup.download-complete": {
"message": "اكتمل التنزيل"
},
"app.update-popup.reload": {
"message": "أعد التحميل للتحديث"
},
"app.update-popup.title": {
"message": "تحديث متاح"
},
@@ -548,6 +878,30 @@
"instance.settings.tabs.general.name": {
"message": "الاسم"
},
"instance.settings.tabs.general.update-channel": {
"message": "تحديث القناة"
},
"instance.settings.tabs.general.update-channel.alpha": {
"message": "نسخة أولية"
},
"instance.settings.tabs.general.update-channel.alpha.description": {
"message": "ستظهر الإصدارات النهائية والإصدارات التجريبية والإصدارات الأولية كـ«تحديثات متاحة»."
},
"instance.settings.tabs.general.update-channel.beta": {
"message": "بيتا"
},
"instance.settings.tabs.general.update-channel.beta.description": {
"message": "ستظهر الإصدارات النهائية والإصدارات التجريبية كـ«تحديثات متاحة»."
},
"instance.settings.tabs.general.update-channel.release": {
"message": "إصدار ثابت"
},
"instance.settings.tabs.general.update-channel.release.description": {
"message": "لن تظهر سوى الإصدارات النهائية ضمن التحديثات المتاحة."
},
"instance.settings.tabs.general.update-channel.select": {
"message": "اختر قناة التحديث"
},
"instance.settings.tabs.hooks": {
"message": "إجراءات تشغيل إضافية"
},
@@ -596,6 +950,24 @@
"instance.settings.tabs.java": {
"message": "جافا والذاكرة"
},
"instance.settings.tabs.java.custom-environment-variables": {
"message": "متغيرات البيئة المخصصة"
},
"instance.settings.tabs.java.custom-java-arguments": {
"message": "معلمات Java المخصصة"
},
"instance.settings.tabs.java.custom-java-installation": {
"message": "تثبيت Java المخصص"
},
"instance.settings.tabs.java.custom-memory-allocation": {
"message": "تخصيص الذاكرة حسب الطلب"
},
"instance.settings.tabs.java.enter-environment-variables": {
"message": "أدخل المتغيرات البيئية..."
},
"instance.settings.tabs.java.enter-java-arguments": {
"message": "أدخل معلمات Java..."
},
"instance.settings.tabs.java.environment-variables": {
"message": "المتغيرات البيئية"
},
@@ -653,6 +1025,9 @@
"instance.worlds.copy_address": {
"message": "نسخ العنوان"
},
"instance.worlds.create_shortcut": {
"message": "إنشاء اختصار"
},
"instance.worlds.dont_show_on_home": {
"message": "عدم العرض في الصفحة الرئيسية"
},
@@ -692,6 +1067,12 @@
"minecraft-account.label": {
"message": "حساب ماينكرافت"
},
"minecraft-account.not-signed-in": {
"message": "لم يتم تسجيل الدخول"
},
"minecraft-account.remove-account": {
"message": "حذف الحساب"
},
"minecraft-account.select-account": {
"message": "اختيار الحساب"
},
@@ -722,7 +1103,25 @@
"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": "تحذير بشأن ملف مجهول"
}
}
+22 -16
View File
@@ -11,27 +11,21 @@
"app.action-bar.hide-more-running-instances": {
"message": "Skrýt více spuštěných instancí"
},
"app.action-bar.install.copied-details": {
"message": "Zkopírováno"
},
"app.action-bar.install.dismiss": {
"message": "Zavřít"
},
"app.action-bar.install.failed": {
"message": "Instalace selhala"
},
"app.action-bar.install.failed-app-closed": {
"message": "Instalace selhala kvůli zavření aplikace."
},
"app.action-bar.install.failed-network": {
"message": "Instalace selhala kvůli chybě sítě."
},
"app.action-bar.install.failed-unknown": {
"message": "Instalace selhala kvůli neznámé chybě."
},
"app.action-bar.install.open-instance": {
"message": "Otevřít instanci"
},
"app.action-bar.install.retry": {
"message": "Zkusit znovu"
},
"app.action-bar.install.summary.canceled": {
"message": "Zrušeno"
},
"app.action-bar.install.unknown-instance": {
"message": "Neznámá instance"
},
@@ -179,9 +173,6 @@
"app.browse.discover-servers": {
"message": "Objevit servery"
},
"app.browse.hide-added-servers": {
"message": "Skrýt již přidané servery"
},
"app.browse.project-type.modpacks": {
"message": "Modpacky"
},
@@ -231,7 +222,7 @@
"message": "Dokončení instalace"
},
"app.install.phase.preparing_instance": {
"message": "Příprava instance"
"message": "Ve frontě na instalaci"
},
"app.install.phase.preparing_java": {
"message": "Příprava Javy"
@@ -410,12 +401,24 @@
"app.project.install-button.already-installed": {
"message": "Tento projekt je již nainstalován"
},
"app.project.install-button.switch-version": {
"message": "Změnit verzi"
},
"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"
},
"app.project.version.download-in-browser": {
"message": "Stáhnout v prohlížeči"
},
"app.project.versions.already-installed": {
"message": "Již staženo"
},
"app.settings.developer-mode-enabled": {
"message": "Vývojářský režim povolen."
},
@@ -536,6 +539,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"
},
+41 -2
View File
@@ -11,6 +11,18 @@
"app.action-bar.hide-more-running-instances": {
"message": "Gem flere kørende instances"
},
"app.action-bar.install.dismiss": {
"message": "Tilsidesæt"
},
"app.action-bar.install.open-instance": {
"message": "Åben instance"
},
"app.action-bar.install.retry": {
"message": "Prøv igen"
},
"app.action-bar.install.unknown-instance": {
"message": "Ukendt instance"
},
"app.action-bar.make-primary-instance": {
"message": "Gør til primære instance"
},
@@ -141,7 +153,7 @@
"message": "Opdag servere"
},
"app.browse.hide-added-servers": {
"message": "Gem allerede tilføjet til servere"
"message": "Gem servere som allerede er tilføjet"
},
"app.browse.project-type.modpacks": {
"message": "Modpacks"
@@ -165,7 +177,7 @@
"message": "Eksporter modpack"
},
"app.export-modal.modpack-name-label": {
"message": "Modpack Navn"
"message": "Modpack navn"
},
"app.export-modal.modpack-name-placeholder": {
"message": "Modpack navn"
@@ -176,6 +188,15 @@
"app.export-modal.version-number-placeholder": {
"message": "1.0.0"
},
"app.install.phase.downloading_content": {
"message": "Downloader indhold"
},
"app.install.phase.downloading_minecraft": {
"message": "Downloader Minecraft"
},
"app.install.phase.preparing_java.downloading": {
"message": "Downloader Java {version}"
},
"app.instance.confirm-delete.admonition-body": {
"message": "Al' data for din instance vil blive permanent slettet, dette inkludere dine verdener, konfigurationer, og alt installeret indhold."
},
@@ -200,6 +221,9 @@
"app.instance.modpack-already-installed.instance": {
"message": "Instance"
},
"app.instance.mods.bulk-update.downloading-projects": {
"message": "Downloader {current, number}/{total, number} projekter..."
},
"app.instance.mods.content-type-project": {
"message": "projekt"
},
@@ -290,6 +314,9 @@
"app.project.install-context.install-content-to-instance": {
"message": "Installer indhold til instance"
},
"app.project.versions.already-installed": {
"message": "Allerede installeret"
},
"app.settings.developer-mode-enabled": {
"message": "Udvikler-tilstand aktiveret."
},
@@ -395,6 +422,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"
},
@@ -464,6 +494,9 @@
"app.update-popup.download-complete": {
"message": "Download færdiggjort"
},
"app.update-popup.reload": {
"message": "Genindlæs for at opdater"
},
"app.update-popup.title": {
"message": "Opdatering tilgængelig"
},
@@ -662,6 +695,12 @@
"instance.settings.tabs.general.name": {
"message": "Navn"
},
"instance.settings.tabs.general.update-channel.alpha": {
"message": "Alpha"
},
"instance.settings.tabs.general.update-channel.beta": {
"message": "Beta"
},
"instance.settings.tabs.hooks": {
"message": "Startkommandoer"
},
+105 -33
View File
@@ -11,27 +11,81 @@
"app.action-bar.hide-more-running-instances": {
"message": "Weitere laufende Instanzen ausblenden"
},
"app.action-bar.install.copied-details": {
"message": "Kopiert"
},
"app.action-bar.install.copy-details": {
"message": "Details kopieren"
},
"app.action-bar.install.dismiss": {
"message": "Ignorieren"
},
"app.action-bar.install.failed": {
"message": "Installation fehlgeschlagen"
},
"app.action-bar.install.failed-app-closed": {
"message": "Installation aufgrund des Schließens der App fehlgeschlagen."
},
"app.action-bar.install.failed-network": {
"message": "Installation aufgrund eines Netzwerkfehlers fehlgeschlagen."
},
"app.action-bar.install.failed-unknown": {
"message": "Installation aufgrund eines unbekannten Fehlers fehlgeschlagen."
},
"app.action-bar.install.open-instance": {
"message": "Instanz öffnen"
},
"app.action-bar.install.retry": {
"message": "Wiederholen"
},
"app.action-bar.install.summary.app-closing": {
"message": "Aufgrund von Schließung der App abgebrochen"
},
"app.action-bar.install.summary.bad-modpack-file": {
"message": "Modpack konnte nicht gelesen werden"
},
"app.action-bar.install.summary.canceled": {
"message": "Abgebrochen"
},
"app.action-bar.install.summary.cleanup-incomplete": {
"message": "Bereinigung konnte nicht abgeschlossen werden"
},
"app.action-bar.install.summary.content-download-failed": {
"message": "Dateien konnte nicht heruntergeladen werden"
},
"app.action-bar.install.summary.corrupt-download": {
"message": "Heruntergeladene Datei ist beschädigt"
},
"app.action-bar.install.summary.could-not-save-files": {
"message": "Dateien konnten nicht gespeichert werden"
},
"app.action-bar.install.summary.download-failed": {
"message": "Download konnte nicht abgeschlossen werden"
},
"app.action-bar.install.summary.instance-not-found": {
"message": "Instanz konnte nicht gefunden werden"
},
"app.action-bar.install.summary.invalid-file-path": {
"message": "Dateipfad ist ungültig"
},
"app.action-bar.install.summary.invalid-modpack": {
"message": "Daten des Modpacks sind ungültig"
},
"app.action-bar.install.summary.invalid-modpack-files": {
"message": "Dateien des Modpacks enthalten ungültige Metadaten"
},
"app.action-bar.install.summary.java-setup-failed": {
"message": "Die Einrichtung von Java konnte nicht abgeschlossen werden"
},
"app.action-bar.install.summary.loader-setup-failed": {
"message": "Einrichtung des Loaders ist fehlgeschlagen"
},
"app.action-bar.install.summary.local-data-error": {
"message": "Lokale Daten konnten nicht aktualisiert werden"
},
"app.action-bar.install.summary.minecraft-setup-failed": {
"message": "Einrichtung von Minecraft ist fehlgeschlagen"
},
"app.action-bar.install.summary.modrinth-unreachable": {
"message": "Modrinth konnte nicht erreicht werden"
},
"app.action-bar.install.summary.no-write-permission": {
"message": "Keine Berechtigung zum Schreiben"
},
"app.action-bar.install.summary.pack-download-failed": {
"message": "Das Pack konnte nicht heruntergeladen werden"
},
"app.action-bar.install.summary.unexpected-error": {
"message": "Irgendwas ist schiefgelaufen"
},
"app.action-bar.install.unknown-instance": {
"message": "Unbekannte Instanz"
},
@@ -129,10 +183,10 @@
"message": "Spielzeit anzeigen"
},
"app.appearance-settings.skip-non-essential-warnings.description": {
"message": "Überspringt automatisch Bestätigungen mit geringem Risiko, wie z. B. die doppelte Installation von Modpacks, das Löschen normaler Inhalte, Massenaktualisierungen, das Trennen von Modpacks und Reparaturen. Warnungen bei gefährlichen Aktionen werden weiterhin angezeigt."
"message": "Bestätigungen mit geringem Risiko, wie z. B. die Installation doppelter Modpacks, das Löschen normaler Inhalte, Massenaktualisierungen, das Aufheben von Verknüpfungen zu Modpacks und Reparaturaufrufe, werden automatisch übersprungen. Gefährliche Warnungen werden weiterhin angezeigt."
},
"app.appearance-settings.skip-non-essential-warnings.title": {
"message": "Nicht essenzielle Warnungen überspringen"
"message": "Unwichtige Warnungen überspringen"
},
"app.appearance-settings.toggle-sidebar.description": {
"message": "Ermöglicht das Umschalten der Seitenleiste."
@@ -204,7 +258,7 @@
"message": "Modpack exportieren"
},
"app.export-modal.modpack-name-label": {
"message": "Modpaketname"
"message": "Name des Modpacks"
},
"app.export-modal.modpack-name-placeholder": {
"message": "Modpaketname"
@@ -216,40 +270,40 @@
"message": "1.0.0"
},
"app.install.phase.downloading_content": {
"message": "Lade Inhalte herunter"
"message": "Inhalte werden heruntergeladen"
},
"app.install.phase.downloading_minecraft": {
"message": "Lade Minecraft herunter"
"message": "Minecraft wird heruntergeladen"
},
"app.install.phase.downloading_pack_file": {
"message": "Lade Packdatei herunter"
"message": "Paketdatei wird herunterladen"
},
"app.install.phase.extracting_overrides": {
"message": "Überschreibungen extrahieren"
"message": "Überschreibungen werden extrahiert"
},
"app.install.phase.finalizing": {
"message": "Abschliessen"
"message": "Wird finalisiert"
},
"app.install.phase.preparing_instance": {
"message": "Instanz präparieren"
"message": "Installation in Warteschlange"
},
"app.install.phase.preparing_java": {
"message": "Java präparieren"
"message": "Java wird vorbereitet"
},
"app.install.phase.preparing_java.downloading": {
"message": "Java {version} herunterladen"
},
"app.install.phase.preparing_java.extracting": {
"message": "Java {version} extrahieren"
},
"app.install.phase.preparing_java.fetching-metadata": {
"message": "Java {version} wird heruntergeladen"
},
"app.install.phase.preparing_java.extracting": {
"message": "Java {version} wird extrahiert"
},
"app.install.phase.preparing_java.fetching-metadata": {
"message": "Java {version} wird abgerufen"
},
"app.install.phase.preparing_java.resolving": {
"message": "Java {version} präparieren"
"message": "Java {version} wird vorbereitet"
},
"app.install.phase.preparing_java.validating": {
"message": "Java {version} validieren"
"message": "Java {version} wird validiert"
},
"app.install.phase.reading_pack_manifest": {
"message": "Paketmanifest wird gelesen"
@@ -294,10 +348,10 @@
"message": "Instanz"
},
"app.instance.mods.bulk-update.downloading-projects": {
"message": "Lade {current, number}/{total, number} Projekte..."
"message": "{current, number}/{total, number} Projekte werden heruntergeladen..."
},
"app.instance.mods.bulk-update.finishing": {
"message": "Update abschliessen..."
"message": "Update wird fertiggestellt..."
},
"app.instance.mods.bulk-update.resolving-versions": {
"message": "Versionen werden ermittelt..."
@@ -333,7 +387,7 @@
"message": "Welt löschen"
},
"app.instance.worlds.delete-world-modal.warning-body": {
"message": "Diese Welt wird permanent gelöscht von dieser Instanz, dies kann nicht rückgängig gemacht werden."
"message": "Diese Welt wird permanent von dieser Instanz gelöscht. Diese Aktion kann nicht rückgängig gemacht werden."
},
"app.instance.worlds.delete-world-modal.warning-header": {
"message": "Lösche {name}"
@@ -410,12 +464,27 @@
"app.project.install-button.already-installed": {
"message": "Dieses Projekt ist bereits installiert"
},
"app.project.install-button.switch-version": {
"message": "Version wechseln"
},
"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"
},
"app.project.version.download-in-browser": {
"message": "Im Browser herunterladen"
},
"app.project.version.installing": {
"message": "Wird installiert"
},
"app.project.versions.already-installed": {
"message": "Bereits installiert"
},
"app.settings.developer-mode-enabled": {
"message": "Entwicklermodus aktiviert."
},
@@ -536,6 +605,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"
},
+92 -20
View File
@@ -11,27 +11,81 @@
"app.action-bar.hide-more-running-instances": {
"message": "Weitere laufende Instanzen ausblenden"
},
"app.action-bar.install.copied-details": {
"message": "Kopiert"
},
"app.action-bar.install.copy-details": {
"message": "Details kopieren"
},
"app.action-bar.install.dismiss": {
"message": "Ausblenden"
},
"app.action-bar.install.failed": {
"message": "Installation fehlgeschlagen"
},
"app.action-bar.install.failed-app-closed": {
"message": "Installation aufgrund des Schließens der App fehlgeschlagen."
},
"app.action-bar.install.failed-network": {
"message": "Installation aufgrund eines Netzwerkfehlers fehlgeschlagen."
},
"app.action-bar.install.failed-unknown": {
"message": "Installation aufgrund eines unbekannten Fehlers fehlgeschlagen."
},
"app.action-bar.install.open-instance": {
"message": "Instanz öffnen"
},
"app.action-bar.install.retry": {
"message": "Erneut versuchen"
},
"app.action-bar.install.summary.app-closing": {
"message": "Aufgrund von Schließung der App abgebrochen"
},
"app.action-bar.install.summary.bad-modpack-file": {
"message": "Modpack konnte nicht gelesen werden"
},
"app.action-bar.install.summary.canceled": {
"message": "Abgebrochen"
},
"app.action-bar.install.summary.cleanup-incomplete": {
"message": "Bereinigung konnte nicht abgeschlossen werden"
},
"app.action-bar.install.summary.content-download-failed": {
"message": "Dateien konnte nicht heruntergeladen werden"
},
"app.action-bar.install.summary.corrupt-download": {
"message": "Heruntergeladene Datei ist beschädigt"
},
"app.action-bar.install.summary.could-not-save-files": {
"message": "Dateien konnten nicht gespeichert werden"
},
"app.action-bar.install.summary.download-failed": {
"message": "Download konnte nicht abgeschlossen werden"
},
"app.action-bar.install.summary.instance-not-found": {
"message": "Instanz konnte nicht gefunden werden"
},
"app.action-bar.install.summary.invalid-file-path": {
"message": "Dateipfad ist ungültig"
},
"app.action-bar.install.summary.invalid-modpack": {
"message": "Daten des Modpacks sind ungültig"
},
"app.action-bar.install.summary.invalid-modpack-files": {
"message": "Dateien des Modpacks enthalten ungültige Metadaten"
},
"app.action-bar.install.summary.java-setup-failed": {
"message": "Die Einrichtung von Java konnte nicht abgeschlossen werden"
},
"app.action-bar.install.summary.loader-setup-failed": {
"message": "Einrichtung des Loaders ist fehlgeschlagen"
},
"app.action-bar.install.summary.local-data-error": {
"message": "Lokale Daten konnten nicht aktualisiert werden"
},
"app.action-bar.install.summary.minecraft-setup-failed": {
"message": "Einrichtung von Minecraft ist fehlgeschlagen"
},
"app.action-bar.install.summary.modrinth-unreachable": {
"message": "Modrinth konnte nicht erreicht werden"
},
"app.action-bar.install.summary.no-write-permission": {
"message": "Keine Berechtigung zum Schreiben"
},
"app.action-bar.install.summary.pack-download-failed": {
"message": "Das Pack konnte nicht heruntergeladen werden"
},
"app.action-bar.install.summary.unexpected-error": {
"message": "Irgendwas ist schiefgelaufen"
},
"app.action-bar.install.unknown-instance": {
"message": "Unbekannte Instanz"
},
@@ -114,7 +168,7 @@
"message": "Launcher minimieren"
},
"app.appearance-settings.native-decorations.description": {
"message": "Fensterdekorationen des Systems verwenden (Neustart der App erforderlich)."
"message": "Fensterrahmen des Systems verwenden (Neustart der App erforderlich)."
},
"app.appearance-settings.native-decorations.title": {
"message": "Native Dekorationen"
@@ -129,7 +183,7 @@
"message": "Spielzeit anzeigen"
},
"app.appearance-settings.skip-non-essential-warnings.description": {
"message": "Überspringt automatisch Bestätigungen mit geringem Risiko, wie z. B. die doppelte Installation von Modpacks, das Löschen normaler Inhalte, Massenaktualisierungen, das Trennen von Modpacks und Reparaturen. Warnungen bei gefährlichen Aktionen werden weiterhin angezeigt."
"message": "Bestätigungen mit geringem Risiko, wie z. B. die Installation doppelter Modpacks, das Löschen normaler Inhalte, Massenaktualisierungen, das Aufheben von Verknüpfungen zu Modpacks und Reparaturaufrufe, werden automatisch übersprungen. Gefährliche Warnungen werden weiterhin angezeigt."
},
"app.appearance-settings.skip-non-essential-warnings.title": {
"message": "Unwichtige Warnungen überspringen"
@@ -204,10 +258,10 @@
"message": "Modpack exportieren"
},
"app.export-modal.modpack-name-label": {
"message": "Modpackname"
"message": "Name des Modpacks"
},
"app.export-modal.modpack-name-placeholder": {
"message": "Modpackname"
"message": "Name des Modpacks"
},
"app.export-modal.version-number-label": {
"message": "Versionsnummer"
@@ -231,7 +285,7 @@
"message": "Wird finalisiert"
},
"app.install.phase.preparing_instance": {
"message": "Instanz wird vorbereitet"
"message": "Installation in Warteschlange"
},
"app.install.phase.preparing_java": {
"message": "Java wird vorbereitet"
@@ -243,7 +297,7 @@
"message": "Java {version} wird extrahiert"
},
"app.install.phase.preparing_java.fetching-metadata": {
"message": "Java {version} wird heruntergeladen"
"message": "Java {version} wird abgerufen"
},
"app.install.phase.preparing_java.resolving": {
"message": "Java {version} wird vorbereitet"
@@ -410,12 +464,27 @@
"app.project.install-button.already-installed": {
"message": "Dieses Projekt ist bereits installiert"
},
"app.project.install-button.switch-version": {
"message": "Version wechseln"
},
"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"
},
"app.project.version.download-in-browser": {
"message": "Im Browser herunterladen"
},
"app.project.version.installing": {
"message": "Wird installiert"
},
"app.project.versions.already-installed": {
"message": "Bereits installiert"
},
"app.settings.developer-mode-enabled": {
"message": "Entwicklermodus aktiviert."
},
@@ -536,6 +605,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"
},
@@ -588,7 +660,7 @@
"message": "Skin-Auswahl"
},
"app.update-popup.body.download-complete": {
"message": "Modrinth App v{version} wurde heruntergeladen. Neu laden, um jetzt zu aktualisieren, oder automatisch aktualisieren, wenn du die Modrinth App schließt."
"message": "Modrinth App v{version} wurde heruntergeladen. Jetzt neu laden, um sofort zu aktualisieren, oder beim Schließen der Modrinth-App automatisch aktualisieren."
},
"app.update-popup.body.linux": {
"message": "Modrinth App v{version} ist verfügbar. Verwende deinen Paketmanager, um die neuesten Funktionen und Fehlerbehebungen zu installieren!"
@@ -621,7 +693,7 @@
"message": "example.modrinth.gg"
},
"app.world.server-modal.select-an-option": {
"message": "Wählen eine Option"
"message": "Wähle eine Option"
},
"app.world.world-item.incompatible-version": {
"message": "Inkompatible Version {version}"
+83 -14
View File
@@ -11,27 +11,81 @@
"app.action-bar.hide-more-running-instances": {
"message": "Hide more running instances"
},
"app.action-bar.install.copied-details": {
"message": "Copied"
},
"app.action-bar.install.copy-details": {
"message": "Copy details"
},
"app.action-bar.install.dismiss": {
"message": "Dismiss"
},
"app.action-bar.install.failed": {
"message": "Install failed"
},
"app.action-bar.install.failed-app-closed": {
"message": "Installation failed due to app closing."
},
"app.action-bar.install.failed-network": {
"message": "Installation failed due to a network error."
},
"app.action-bar.install.failed-unknown": {
"message": "Installation failed due to an unknown error."
},
"app.action-bar.install.open-instance": {
"message": "Open instance"
},
"app.action-bar.install.retry": {
"message": "Retry"
},
"app.action-bar.install.summary.app-closing": {
"message": "Canceled due to app closing"
},
"app.action-bar.install.summary.bad-modpack-file": {
"message": "Couldn't read modpack"
},
"app.action-bar.install.summary.canceled": {
"message": "Canceled"
},
"app.action-bar.install.summary.cleanup-incomplete": {
"message": "Cleanup didn't finish"
},
"app.action-bar.install.summary.content-download-failed": {
"message": "Couldn't download files"
},
"app.action-bar.install.summary.corrupt-download": {
"message": "Downloaded file is corrupt"
},
"app.action-bar.install.summary.could-not-save-files": {
"message": "Couldn't save files"
},
"app.action-bar.install.summary.download-failed": {
"message": "Download couldn't finish"
},
"app.action-bar.install.summary.instance-not-found": {
"message": "Instance couldn't be found"
},
"app.action-bar.install.summary.invalid-file-path": {
"message": "File path is invalid"
},
"app.action-bar.install.summary.invalid-modpack": {
"message": "Modpack data invalid"
},
"app.action-bar.install.summary.invalid-modpack-files": {
"message": "Modpack files have invalid metadata"
},
"app.action-bar.install.summary.java-setup-failed": {
"message": "Java setup couldn't finish"
},
"app.action-bar.install.summary.loader-setup-failed": {
"message": "Loader setup failed"
},
"app.action-bar.install.summary.local-data-error": {
"message": "Couldn't update local data"
},
"app.action-bar.install.summary.minecraft-setup-failed": {
"message": "Minecraft setup failed"
},
"app.action-bar.install.summary.modrinth-unreachable": {
"message": "Couldn't reach Modrinth"
},
"app.action-bar.install.summary.no-write-permission": {
"message": "No permission to write"
},
"app.action-bar.install.summary.pack-download-failed": {
"message": "Couldn't download pack"
},
"app.action-bar.install.summary.unexpected-error": {
"message": "Something went wrong"
},
"app.action-bar.install.unknown-instance": {
"message": "Unknown instance"
},
@@ -180,7 +234,7 @@
"message": "Discover servers"
},
"app.browse.hide-added-servers": {
"message": "Hide already added servers"
"message": "Hide servers already added"
},
"app.browse.project-type.modpacks": {
"message": "Modpacks"
@@ -231,7 +285,7 @@
"message": "Finalizing"
},
"app.install.phase.preparing_instance": {
"message": "Preparing instance"
"message": "Queued to install"
},
"app.install.phase.preparing_java": {
"message": "Preparing Java"
@@ -410,12 +464,27 @@
"app.project.install-button.already-installed": {
"message": "This project is already installed"
},
"app.project.install-button.switch-version": {
"message": "Switch version"
},
"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"
},
"app.project.version.download-in-browser": {
"message": "Download in browser"
},
"app.project.version.installing": {
"message": "Installing"
},
"app.project.versions.already-installed": {
"message": "Already installed"
},
"app.settings.developer-mode-enabled": {
"message": "Developer mode enabled."
},
+86 -14
View File
@@ -11,27 +11,81 @@
"app.action-bar.hide-more-running-instances": {
"message": "Ocultar más instancias en ejecución"
},
"app.action-bar.install.copied-details": {
"message": "Copiado"
},
"app.action-bar.install.copy-details": {
"message": "Copiar detalles"
},
"app.action-bar.install.dismiss": {
"message": "Descartar"
},
"app.action-bar.install.failed": {
"message": "La instalación falló"
},
"app.action-bar.install.failed-app-closed": {
"message": "La instalación falló porque la aplicación se cerró."
},
"app.action-bar.install.failed-network": {
"message": "La instalación falló debido a un error de red."
},
"app.action-bar.install.failed-unknown": {
"message": "La instalación falló debido a un error desconocido."
},
"app.action-bar.install.open-instance": {
"message": "Abrir instancia"
},
"app.action-bar.install.retry": {
"message": "Reintentar"
},
"app.action-bar.install.summary.app-closing": {
"message": "Cancelado debido al cierre de la aplicación"
},
"app.action-bar.install.summary.bad-modpack-file": {
"message": "No se pudo leer el modpack"
},
"app.action-bar.install.summary.canceled": {
"message": "Cancelado"
},
"app.action-bar.install.summary.cleanup-incomplete": {
"message": "La limpieza no se completó"
},
"app.action-bar.install.summary.content-download-failed": {
"message": "No se pudieron descargar los archivos"
},
"app.action-bar.install.summary.corrupt-download": {
"message": "El archivo descargado está dañado"
},
"app.action-bar.install.summary.could-not-save-files": {
"message": "No se pudieron guardar los archivos"
},
"app.action-bar.install.summary.download-failed": {
"message": "No se pudo completar la descarga"
},
"app.action-bar.install.summary.instance-not-found": {
"message": "No se pudo encontrar la instancia"
},
"app.action-bar.install.summary.invalid-file-path": {
"message": "La ruta del archivo no es válida"
},
"app.action-bar.install.summary.invalid-modpack": {
"message": "Los datos del modpack no son válidos"
},
"app.action-bar.install.summary.invalid-modpack-files": {
"message": "Los archivos del modpack tienen metadatos no válidos"
},
"app.action-bar.install.summary.java-setup-failed": {
"message": "No se pudo completar la configuración de Java"
},
"app.action-bar.install.summary.loader-setup-failed": {
"message": "No se pudo configurar el cargador"
},
"app.action-bar.install.summary.local-data-error": {
"message": "No se pudieron actualizar los datos locales"
},
"app.action-bar.install.summary.minecraft-setup-failed": {
"message": "La instalación de Minecraft falló"
},
"app.action-bar.install.summary.modrinth-unreachable": {
"message": "No pude comunicarme con Modrinth"
},
"app.action-bar.install.summary.no-write-permission": {
"message": "No tiene permiso para escribir"
},
"app.action-bar.install.summary.pack-download-failed": {
"message": "No se pudo descargar el modpack"
},
"app.action-bar.install.summary.unexpected-error": {
"message": "Algo salió mal"
},
"app.action-bar.install.unknown-instance": {
"message": "Instancia desconocida"
},
@@ -180,7 +234,7 @@
"message": "Descubrir servidores"
},
"app.browse.hide-added-servers": {
"message": "Ocultar servidores ya añadidos"
"message": "Ocultar los servidores ya agregados"
},
"app.browse.project-type.modpacks": {
"message": "Modpacks"
@@ -231,7 +285,7 @@
"message": "Finalizando"
},
"app.install.phase.preparing_instance": {
"message": "Preparando la instancia"
"message": "En cola para instalarse"
},
"app.install.phase.preparing_java": {
"message": "Preparando Java"
@@ -410,12 +464,27 @@
"app.project.install-button.already-installed": {
"message": "Este proyecto ya está instalado"
},
"app.project.install-button.switch-version": {
"message": "Cambiar versión"
},
"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"
},
"app.project.version.download-in-browser": {
"message": "Descargar en el navegador"
},
"app.project.version.installing": {
"message": "Instalando"
},
"app.project.versions.already-installed": {
"message": "Ya instalado"
},
"app.settings.developer-mode-enabled": {
"message": "Modo desarrollador activado."
},
@@ -536,6 +605,9 @@
"app.skins.section.builders-and-biomes": {
"message": "Constructores y Biomas"
},
"app.skins.section.chaos-cubed": {
"message": "Caos al cubo"
},
"app.skins.section.chase-the-skies": {
"message": "Persigue los Cielos"
},
+15 -9
View File
@@ -14,14 +14,17 @@
"app.action-bar.install.dismiss": {
"message": "Descartar"
},
"app.action-bar.install.failed": {
"message": "La instalación falló"
"app.action-bar.install.open-instance": {
"message": "Abrir instancia"
},
"app.action-bar.install.failed-app-closed": {
"message": "La instalación falló porque la aplicación se cerró."
"app.action-bar.install.retry": {
"message": "Reintentar"
},
"app.action-bar.install.failed-network": {
"message": "La instalación falló debido a un error de red."
"app.action-bar.install.unknown-instance": {
"message": "Instancia desconocida"
},
"app.action-bar.installs": {
"message": "Instalaciones"
},
"app.action-bar.make-primary-instance": {
"message": "Establecer como instancia principal"
@@ -113,6 +116,12 @@
"app.appearance-settings.show-play-time.title": {
"message": "Mostrar tiempo de juego"
},
"app.appearance-settings.skip-non-essential-warnings.description": {
"message": "Automáticamente omite confirmaciones de bajo riesgo como modpacks duplicados, eliminación de contenido normal, actualizaciones masivas, desvinculando modpacks, y mensajes de reparación. Advertencias importantes seguirán siendo mostradas."
},
"app.appearance-settings.skip-non-essential-warnings.title": {
"message": "Omitir advertencias no esenciales"
},
"app.appearance-settings.toggle-sidebar.description": {
"message": "Activa la posibilidad de alternar la barra lateral."
},
@@ -158,9 +167,6 @@
"app.browse.discover-servers": {
"message": "Descubrir servidores"
},
"app.browse.hide-added-servers": {
"message": "Ocultar servidores ya añadidos"
},
"app.browse.project-type.modpacks": {
"message": "Modpacks"
},
@@ -14,18 +14,6 @@
"app.action-bar.install.dismiss": {
"message": "Hylkää"
},
"app.action-bar.install.failed": {
"message": "Asennus epäonnistui"
},
"app.action-bar.install.failed-app-closed": {
"message": "Asennus epäonnistui sovelluksen sulkeutumisen vuoksi."
},
"app.action-bar.install.failed-network": {
"message": "Asennus epäonnistui verkkovirheen vuoksi."
},
"app.action-bar.install.failed-unknown": {
"message": "Asennus epäonnistui tuntemattoman virheen vuoksi."
},
"app.action-bar.install.open-instance": {
"message": "Avaa instanssi"
},
@@ -179,9 +167,6 @@
"app.browse.discover-servers": {
"message": "Löydä palvelimia"
},
"app.browse.hide-added-servers": {
"message": "Piilota lisätyt palvelimet"
},
"app.browse.project-type.modpacks": {
"message": "Modipaketit"
},
@@ -203,9 +188,6 @@
"app.export-modal.header": {
"message": "Vie modipaketti"
},
"app.export-modal.modpack-name-label": {
"message": "Modipaketin nimi"
},
"app.export-modal.modpack-name-placeholder": {
"message": "Modipaketin nimi"
},
@@ -230,9 +212,6 @@
"app.install.phase.finalizing": {
"message": "Viimeistellään"
},
"app.install.phase.preparing_instance": {
"message": "Valmistellaan instanssia"
},
"app.install.phase.preparing_java": {
"message": "Valmistellaan Javaa"
},
@@ -11,6 +11,9 @@
"app.action-bar.hide-more-running-instances": {
"message": "Itago ang mas marami pang tumatakbong instansiya"
},
"app.action-bar.install.retry": {
"message": "Subukan muli"
},
"app.action-bar.make-primary-instance": {
"message": "Gumawa ng pangunahing instansiya"
},
@@ -143,9 +146,6 @@
"app.browse.discover-servers": {
"message": "Tumuklas ng mga server"
},
"app.browse.hide-added-servers": {
"message": "Itago ang mga nailagay na mga servers"
},
"app.browse.project-type.modpacks": {
"message": "Modpacks"
},
@@ -164,9 +164,6 @@
"app.export-modal.header": {
"message": "Iluwas ang modpack"
},
"app.export-modal.modpack-name-label": {
"message": "Pangalan ng Modpack"
},
"app.export-modal.modpack-name-placeholder": {
"message": "Pangalan ng modpack"
},
@@ -567,7 +564,7 @@
"message": "Mga launch hook"
},
"instance.settings.tabs.hooks.custom-hooks": {
"message": "Mga custom na launch hook"
"message": "Mga pasadyang launch hook"
},
"instance.settings.tabs.hooks.description": {
"message": "Binibigyan-daan ng mga hook ang mga ekspertong tagagamit na makapagtakbo ng mga pansistemang utos o system command bago at pagkatapos ma-launch ang laro."
@@ -630,7 +627,7 @@
"message": "Window"
},
"instance.settings.tabs.window.custom-window-settings": {
"message": "Mga setting sa custom na window"
"message": "Mga setting sa pasadyang window"
},
"instance.settings.tabs.window.fullscreen": {
"message": "Fullscreen"
+65 -17
View File
@@ -11,27 +11,60 @@
"app.action-bar.hide-more-running-instances": {
"message": "Masquer plus d'instances en exécution"
},
"app.action-bar.install.copied-details": {
"message": "Copié"
},
"app.action-bar.install.copy-details": {
"message": "Copier les détails"
},
"app.action-bar.install.dismiss": {
"message": "Rejeter"
},
"app.action-bar.install.failed": {
"message": "Échec de l'installation"
},
"app.action-bar.install.failed-app-closed": {
"message": "Échec de l'installation dû à la fermeture de l'app."
},
"app.action-bar.install.failed-network": {
"message": "Échec de l'installation dû à une erreur réseau."
},
"app.action-bar.install.failed-unknown": {
"message": "Échec de l'installation dû à une erreur inconnue."
},
"app.action-bar.install.open-instance": {
"message": "Ouvrir l'instance"
},
"app.action-bar.install.retry": {
"message": "Réessayer"
},
"app.action-bar.install.summary.app-closing": {
"message": "Annulé en raison de la fermeture de l'application"
},
"app.action-bar.install.summary.bad-modpack-file": {
"message": "Impossible de lire le modpack"
},
"app.action-bar.install.summary.canceled": {
"message": "Annulé"
},
"app.action-bar.install.summary.cleanup-incomplete": {
"message": "Le nettoyage n'est pas terminé"
},
"app.action-bar.install.summary.content-download-failed": {
"message": "Impossible de télécharger les fichiers"
},
"app.action-bar.install.summary.corrupt-download": {
"message": "Le fichier téléchargé est corrompu"
},
"app.action-bar.install.summary.could-not-save-files": {
"message": "Impossible de sauvegarder les fichiers"
},
"app.action-bar.install.summary.download-failed": {
"message": "Impossible de terminer le téléchargement"
},
"app.action-bar.install.summary.instance-not-found": {
"message": "L'instance est introuvable"
},
"app.action-bar.install.summary.invalid-file-path": {
"message": "Le chemin d'accès au fichier est invalide"
},
"app.action-bar.install.summary.invalid-modpack": {
"message": "Les données du modpack sont invalides"
},
"app.action-bar.install.summary.invalid-modpack-files": {
"message": "Les fichiers du modpack contiennent des métadonnées invalides"
},
"app.action-bar.install.summary.java-setup-failed": {
"message": "L'installation de Java n'a pas pu se terminer"
},
"app.action-bar.install.unknown-instance": {
"message": "Instance inconnue"
},
@@ -180,7 +213,7 @@
"message": "Découvrir des serveurs"
},
"app.browse.hide-added-servers": {
"message": "Masquer les serveurs déjà ajoutés"
"message": "Masquer les serveurs déjà installés"
},
"app.browse.project-type.modpacks": {
"message": "Modpacks"
@@ -230,9 +263,6 @@
"app.install.phase.finalizing": {
"message": "Finalisation"
},
"app.install.phase.preparing_instance": {
"message": "Préparation de l'instance"
},
"app.install.phase.preparing_java": {
"message": "Préparation de Java"
},
@@ -410,12 +440,27 @@
"app.project.install-button.already-installed": {
"message": "Ce projet a déjà été installé"
},
"app.project.install-button.switch-version": {
"message": "Changer de version"
},
"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"
},
"app.project.version.download-in-browser": {
"message": "Télécharger dans le navigateur"
},
"app.project.version.installing": {
"message": "Installation"
},
"app.project.versions.already-installed": {
"message": "Déjà installé"
},
"app.settings.developer-mode-enabled": {
"message": "Mode développeur activé."
},
@@ -536,6 +581,9 @@
"app.skins.section.builders-and-biomes": {
"message": "Builders & Biomes"
},
"app.skins.section.chaos-cubed": {
"message": "Cubes du chaos"
},
"app.skins.section.chase-the-skies": {
"message": "Chassez les cieux"
},
@@ -564,7 +612,7 @@
"message": "Striding Hero"
},
"app.skins.section.the-copper-age": {
"message": "The Copper Age"
"message": "L'âge du cuivre"
},
"app.skins.section.the-garden-awakens": {
"message": "L'éveil du jardin"
@@ -59,9 +59,6 @@
"app.export-modal.header": {
"message": "יצוא חבילת מודים"
},
"app.export-modal.modpack-name-label": {
"message": "שם חבילת המודים"
},
"app.export-modal.modpack-name-placeholder": {
"message": "שם חבילת המודים"
},
+209 -17
View File
@@ -11,12 +11,87 @@
"app.action-bar.hide-more-running-instances": {
"message": "További futó példányok elrejtése"
},
"app.action-bar.install.copied-details": {
"message": "Másolva"
},
"app.action-bar.install.copy-details": {
"message": "Részletek másolása"
},
"app.action-bar.install.dismiss": {
"message": "Elutasítás"
},
"app.action-bar.install.open-instance": {
"message": "Példány megnyitása"
},
"app.action-bar.install.retry": {
"message": "Újra"
},
"app.action-bar.install.summary.app-closing": {
"message": "Az alkalmazás bezárása miatt megszakítva"
},
"app.action-bar.install.summary.bad-modpack-file": {
"message": "Nem sikerült beolvasni a modcsomagot"
},
"app.action-bar.install.summary.canceled": {
"message": "Megszakítva"
},
"app.action-bar.install.summary.cleanup-incomplete": {
"message": "A tisztítás nem fejeződött be"
},
"app.action-bar.install.summary.content-download-failed": {
"message": "Nem sikerült letölteni a fájlokat"
},
"app.action-bar.install.summary.corrupt-download": {
"message": "A letöltött fájl sérült"
},
"app.action-bar.install.summary.could-not-save-files": {
"message": "Nem sikerült elmenteni a fájlokat"
},
"app.action-bar.install.summary.download-failed": {
"message": "A letöltés nem sikerült"
},
"app.action-bar.install.summary.instance-not-found": {
"message": "A példány nem található"
},
"app.action-bar.install.summary.invalid-file-path": {
"message": "A fájl elérési útja érvénytelen"
},
"app.action-bar.install.summary.invalid-modpack": {
"message": "A modcsomag adatai érvénytelenek"
},
"app.action-bar.install.summary.invalid-modpack-files": {
"message": "A modcsomag fájljainak metaadatai érvénytelenek"
},
"app.action-bar.install.summary.java-setup-failed": {
"message": "A Java telepítése nem sikerült"
},
"app.action-bar.install.summary.loader-setup-failed": {
"message": "A betöltő beállítása sikertelen volt"
},
"app.action-bar.install.summary.local-data-error": {
"message": "A helyi adatok frissítése nem sikerült"
},
"app.action-bar.install.summary.minecraft-setup-failed": {
"message": "A Minecraft telepítése sikertelen volt"
},
"app.action-bar.install.summary.modrinth-unreachable": {
"message": "Nem sikerült elérni Modrinthot"
},
"app.action-bar.install.summary.no-write-permission": {
"message": "Nincs felülírási jogosultság"
},
"app.action-bar.install.summary.pack-download-failed": {
"message": "Nem sikerült letölteni a csomagot"
},
"app.action-bar.install.summary.unexpected-error": {
"message": "Valami hiba történt"
},
"app.action-bar.install.unknown-instance": {
"message": "Ismeretlen profil"
},
"app.action-bar.installs": {
"message": "Telepítések"
},
"app.action-bar.make-primary-instance": {
"message": "Beállítás elsődleges példányként"
},
@@ -48,13 +123,13 @@
"message": "Példányok megtekintése"
},
"app.action-bar.view-logs": {
"message": "Naplók megtekintése"
"message": "Naplózások megtekintése"
},
"app.appearance-settings.advanced-rendering.description": {
"message": "Lehetővé teszi olyan speciális megjelenítési effektusok használatát, mint például az elmosódás, amelyek hardveres gyorsítás nélkül teljesítménycsökkenést okozhatnak."
},
"app.appearance-settings.advanced-rendering.title": {
"message": "Fejlett renderelés"
"message": "Haladó megjelenítési beállítások"
},
"app.appearance-settings.color-theme.description": {
"message": "Válaszd ki a Modrinth Apphoz a kívánt színsémát."
@@ -84,7 +159,7 @@
"message": "A kezdőlap „Folytasd itt” részében a legújabb világok is megtalálhatók."
},
"app.appearance-settings.jump-back-into-worlds.title": {
"message": "Visszaugrás a világokba"
"message": "„Folytasd itt” a világokat is"
},
"app.appearance-settings.minimize-launcher.description": {
"message": "Minimalizáld az indítót a Minecraft indításakor."
@@ -102,11 +177,17 @@
"message": "Válassz egy lehetőséget"
},
"app.appearance-settings.show-play-time.description": {
"message": "Megmutatja, mennyi időt töltöttél játszva egy profillal."
"message": "Megmutatja, mennyi időt töltöttél egy példányban."
},
"app.appearance-settings.show-play-time.title": {
"message": "Játékidő megjelenítése"
},
"app.appearance-settings.skip-non-essential-warnings.description": {
"message": "Az alacsony kockázatú megerősítéseket például a modcsomagok ismételt telepítését, a szokásos tartalomtörlést, a tömeges frissítéseket, a modcsomagok kapcsolatának megszüntetését és a javítási felhívásokat a rendszer automatikusan kihagyja. A veszélyes figyelmeztetéseket továbbra is megjeleníti."
},
"app.appearance-settings.skip-non-essential-warnings.title": {
"message": "Hagyd ki a lényegtelen figyelmeztetéseket"
},
"app.appearance-settings.toggle-sidebar.description": {
"message": "Lehetővé teszi az oldalsáv be- és kikapcsolását."
},
@@ -153,16 +234,16 @@
"message": "Szerverek böngészése"
},
"app.browse.hide-added-servers": {
"message": "Már hozzáadott szerverek elrejtése"
"message": "A már hozzáadott szerverek elrejtése"
},
"app.browse.project-type.modpacks": {
"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 szerverprofil 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 szerverpéldány tartalmát."
},
"app.browse.server.installing": {
"message": "Letöltés..."
"message": "Letöltés"
},
"app.content-install.no-compatible-versions": {
"message": "Nincs a(z) {compatibilityLabel} verziónak megfelelő elérhető verzió. Válassz ki egy verziót, ha mégis telepíteni szeretnéd. A függőségek nem települnek automatikusan."
@@ -177,7 +258,7 @@
"message": "Modcsomag exportálása"
},
"app.export-modal.modpack-name-label": {
"message": "A modcsomag neve"
"message": "Modcsamag neve"
},
"app.export-modal.modpack-name-placeholder": {
"message": "A modcsomag neve"
@@ -188,9 +269,60 @@
"app.export-modal.version-number-placeholder": {
"message": "1.0.0"
},
"app.install.phase.downloading_content": {
"message": "Tartalom letöltése"
},
"app.install.phase.downloading_minecraft": {
"message": "Minecraft letöltése"
},
"app.install.phase.downloading_pack_file": {
"message": "Csomagfájlok letöltése"
},
"app.install.phase.extracting_overrides": {
"message": "Felülírások kibontása"
},
"app.install.phase.finalizing": {
"message": "Befejezés"
},
"app.install.phase.preparing_instance": {
"message": "Telepítésre vár"
},
"app.install.phase.preparing_java": {
"message": "Java előkészítése"
},
"app.install.phase.preparing_java.downloading": {
"message": "Java verzió {version} letöltése"
},
"app.install.phase.preparing_java.extracting": {
"message": "Java verzió {version} kibontása"
},
"app.install.phase.preparing_java.fetching-metadata": {
"message": "Java verzió ellőkészítése {version}"
},
"app.install.phase.preparing_java.resolving": {
"message": "Java verzió {version} előkészítése"
},
"app.install.phase.preparing_java.validating": {
"message": "A Java {version} ellenőrzése"
},
"app.install.phase.reading_pack_manifest": {
"message": "Csomag adati kiolvasása"
},
"app.install.phase.resolving_loader": {
"message": ""
},
"app.install.phase.resolving_minecraft": {
"message": "Minecraft Betöltése"
},
"app.install.phase.resolving_pack": {
"message": "Tartalom betöltése"
},
"app.install.phase.rolling_back": {
"message": "Visszavonás"
},
"app.install.phase.running_loader_processors": {
"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."
},
@@ -215,6 +347,15 @@
"app.instance.modpack-already-installed.instance": {
"message": "Példány"
},
"app.instance.mods.bulk-update.downloading-projects": {
"message": "{current, number}/{total, number} projekt letöltése..."
},
"app.instance.mods.bulk-update.finishing": {
"message": "Frissítés befejezése..."
},
"app.instance.mods.bulk-update.resolving-versions": {
"message": "Verziók meghatározása..."
},
"app.instance.mods.content-type-project": {
"message": "projekt"
},
@@ -239,6 +380,18 @@
"app.instance.worlds.browse-servers": {
"message": "Szerverek böngészése"
},
"app.instance.worlds.delete-world-modal.delete-button": {
"message": "Törlés"
},
"app.instance.worlds.delete-world-modal.title": {
"message": "Világ törlése"
},
"app.instance.worlds.delete-world-modal.warning-body": {
"message": "Ez a világ véglegesen törölve lesz ebből a példányból. Ezt NEM tudod visszavonni!"
},
"app.instance.worlds.delete-world-modal.warning-header": {
"message": "{name} törlése"
},
"app.instance.worlds.filter-modded": {
"message": "Modolt"
},
@@ -257,6 +410,18 @@
"app.instance.worlds.no-worlds-heading": {
"message": "Nincs szerver vagy világ"
},
"app.instance.worlds.remove-server-modal.remove-button": {
"message": "Szerver eltávolítása"
},
"app.instance.worlds.remove-server-modal.title": {
"message": "Szerver eltávolítása"
},
"app.instance.worlds.remove-server-modal.warning-body": {
"message": "Ez a szerver el lesz távolítva a szerverlistádból és a játékbeli szerverlistából is. Később újra hozzáadhatod, ha tudod a címet."
},
"app.instance.worlds.remove-server-modal.warning-header": {
"message": "{name} eltávolítása"
},
"app.instance.worlds.search-worlds-placeholder": {
"message": "Keresés {count} világ között..."
},
@@ -282,7 +447,7 @@
"message": "Megosztott profil"
},
"app.modal.install-to-play.shared-server-instance": {
"message": "Megosztott szerverprofil"
"message": "Megosztott szerverpéldány"
},
"app.modal.install-to-play.view-contents": {
"message": "Tartalom megtekintése"
@@ -299,11 +464,26 @@
"app.project.install-button.already-installed": {
"message": "Ez a projekt már telepítve van"
},
"app.project.install-button.switch-version": {
"message": "Verzió váltása"
},
"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 profilba"
"message": "Tartalom telepítése a példányba"
},
"app.project.version.all-versions": {
"message": "Összes verzió"
},
"app.project.version.download-in-browser": {
"message": "Letöltés a böngészőben"
},
"app.project.version.installing": {
"message": "Telepítés..."
},
"app.project.versions.already-installed": {
"message": "Már le van töltve"
},
"app.settings.developer-mode-enabled": {
"message": "Fejlesztői mód bekapcsolva."
@@ -416,9 +596,18 @@
"app.skins.rate-limit.title": {
"message": "Lassíts!"
},
"app.skins.reorder-error.text": {
"message": "A kinézeted sorrendjét nem sikerült menteni."
},
"app.skins.reorder-error.title": {
"message": "A skinek átrendezése sikertelen"
},
"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"
},
@@ -462,7 +651,7 @@
"message": "Kérlek jelentkezz be a Minecraft-fiókodba, hogy használni tudd a Modrinth App kinézetkezelési funkcióit."
},
"app.skins.sign-in.rinthbot-alt": {
"message": "Izgatott Modrinth-bot"
"message": "Izgatott Modrinth bot"
},
"app.skins.sign-in.title": {
"message": "Kérlek jelentkezz be"
@@ -708,7 +897,7 @@
"message": "Kiadás"
},
"instance.settings.tabs.general.update-channel.release.description": {
"message": "Csak a release verziók fognak megjelenni az elérhető frissítések között."
"message": "Csak a 'kiadás' verziók fognak megjelenni az elérhető frissítések között."
},
"instance.settings.tabs.general.update-channel.select": {
"message": "Válaszd ki a frissítési csatornát"
@@ -795,7 +984,7 @@
"message": "Hozzárendelt memória"
},
"instance.settings.tabs.java.java-path-placeholder": {
"message": "/path/to/java"
"message": "/eleresi/ut/a/javahoz"
},
"instance.settings.tabs.window": {
"message": "Ablak"
@@ -836,11 +1025,14 @@
"instance.worlds.copy_address": {
"message": "Cím másolása"
},
"instance.worlds.create_shortcut": {
"message": "Gyorshivatkozás létrehozása"
},
"instance.worlds.dont_show_on_home": {
"message": "Ne mutasd a kezdőlapon"
},
"instance.worlds.game_already_open": {
"message": "A példány már el van indítva"
"message": "A profil már el van indítva"
},
"instance.worlds.hardcore": {
"message": "Hardcore mód"
@@ -858,7 +1050,7 @@
"message": "Csak Minecraft Alpha 1.0.5+-tól tudsz egyből szerverhez csatlakozni"
},
"instance.worlds.no_singleplayer_quick_play": {
"message": "Csak Minecraft 1.20+-tól tudsz egyből egyjátékos világba belépni"
"message": "Használj 1.20+ verziót az azonnali egyjátékos módba lépéshez"
},
"instance.worlds.play_instance": {
"message": "Játék a profillal"
@@ -906,10 +1098,10 @@
"message": "Csak kliensoldali modok adhatók hozzá a szerverprofilhoz"
},
"search.filter.locked.server-game-version.title": {
"message": "A játékverziót a szerver biztosítja"
"message": "A játékverzió a szerver által van megadva"
},
"search.filter.locked.server-loader.title": {
"message": "A betöltőt a szerver biztosítja"
"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)."
@@ -146,9 +146,6 @@
"app.browse.discover-servers": {
"message": "Temukan server"
},
"app.browse.hide-added-servers": {
"message": "Sembunyikan server yang sudah ditambah"
},
"app.browse.project-type.modpacks": {
"message": "Paket Mod"
},
@@ -170,9 +167,6 @@
"app.export-modal.header": {
"message": "Ekspor paket mod"
},
"app.export-modal.modpack-name-label": {
"message": "Nama Paket Mod"
},
"app.export-modal.modpack-name-placeholder": {
"message": "Nama paket mod"
},
+87 -15
View File
@@ -11,27 +11,81 @@
"app.action-bar.hide-more-running-instances": {
"message": "Mostra meno istanze in esecuzione"
},
"app.action-bar.install.copied-details": {
"message": "Copiati"
},
"app.action-bar.install.copy-details": {
"message": "Copia dettagli"
},
"app.action-bar.install.dismiss": {
"message": "Ignora"
},
"app.action-bar.install.failed": {
"message": "Installazione fallita"
},
"app.action-bar.install.failed-app-closed": {
"message": "Installazione fallita: l'app è stata chiusa."
},
"app.action-bar.install.failed-network": {
"message": "Installazione fallita: errore di rete."
},
"app.action-bar.install.failed-unknown": {
"message": "Installazione fallita: errore sconosciuto."
},
"app.action-bar.install.open-instance": {
"message": "Apri istanza"
},
"app.action-bar.install.retry": {
"message": "Riprova"
},
"app.action-bar.install.summary.app-closing": {
"message": "Annullato per app chiusa"
},
"app.action-bar.install.summary.bad-modpack-file": {
"message": "Impossibile leggere il pacchetto"
},
"app.action-bar.install.summary.canceled": {
"message": "Annullato"
},
"app.action-bar.install.summary.cleanup-incomplete": {
"message": "La pulizia è stata interrotta"
},
"app.action-bar.install.summary.content-download-failed": {
"message": "Impossibile scaricare i file"
},
"app.action-bar.install.summary.corrupt-download": {
"message": "Il file scaricato è corrotto"
},
"app.action-bar.install.summary.could-not-save-files": {
"message": "Impossibile salvare i file"
},
"app.action-bar.install.summary.download-failed": {
"message": "Il download è stato interrotto"
},
"app.action-bar.install.summary.instance-not-found": {
"message": "L'istanza non è stata trovata"
},
"app.action-bar.install.summary.invalid-file-path": {
"message": "Percorso del file errato"
},
"app.action-bar.install.summary.invalid-modpack": {
"message": "Dati del pacchetto non validi"
},
"app.action-bar.install.summary.invalid-modpack-files": {
"message": "Metadati del pacchetto non validi"
},
"app.action-bar.install.summary.java-setup-failed": {
"message": "La configurazione di Java è stata interrotta"
},
"app.action-bar.install.summary.loader-setup-failed": {
"message": "Il loader non è stato configurato"
},
"app.action-bar.install.summary.local-data-error": {
"message": "Impossibile aggiornare i dati locali"
},
"app.action-bar.install.summary.minecraft-setup-failed": {
"message": "Minecraft non è stato configurato"
},
"app.action-bar.install.summary.modrinth-unreachable": {
"message": "Impossibile collegarsi a Modrinth"
},
"app.action-bar.install.summary.no-write-permission": {
"message": "Permessi di scrittura mancanti"
},
"app.action-bar.install.summary.pack-download-failed": {
"message": "Impossibile scaricare il pacchetto"
},
"app.action-bar.install.summary.unexpected-error": {
"message": "Qualcosa è andato storto"
},
"app.action-bar.install.unknown-instance": {
"message": "Istanza sconosciuta"
},
@@ -225,13 +279,13 @@
"message": "Download del pacchetto"
},
"app.install.phase.extracting_overrides": {
"message": "Estrazione degli override"
"message": "Estrazione delle sovrascritture"
},
"app.install.phase.finalizing": {
"message": "Finalizzazione"
},
"app.install.phase.preparing_instance": {
"message": "Preparazione dell'istanza"
"message": "In coda per l'installazione"
},
"app.install.phase.preparing_java": {
"message": "Preparazione di Java"
@@ -264,7 +318,7 @@
"message": "Risoluzione dei contenuti"
},
"app.install.phase.rolling_back": {
"message": "Tornando indietro"
"message": "Annullamento"
},
"app.install.phase.running_loader_processors": {
"message": "Esecuzione dei processori del loader"
@@ -407,12 +461,27 @@
"app.project.install-button.already-installed": {
"message": "Questo progetto è già stato installato"
},
"app.project.install-button.switch-version": {
"message": "Cambia versione"
},
"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"
},
"app.project.version.download-in-browser": {
"message": "Scarica col browser"
},
"app.project.version.installing": {
"message": "Installazione"
},
"app.project.versions.already-installed": {
"message": "Già installate"
},
"app.settings.developer-mode-enabled": {
"message": "Modalità sviluppatore attiva."
},
@@ -533,6 +602,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"
},
+72 -6
View File
@@ -11,6 +11,51 @@
"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": "閉じる"
},
"app.action-bar.install.open-instance": {
"message": "インスタンスを表示"
},
"app.action-bar.install.retry": {
"message": "再試行"
},
"app.action-bar.install.summary.app-closing": {
"message": "アプリの終了によりキャンセルされました"
},
"app.action-bar.install.summary.bad-modpack-file": {
"message": "ModPackを読み込むことができませんでした"
},
"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.unknown-instance": {
"message": "不明なインスタンス"
},
"app.action-bar.installs": {
"message": "インストール数"
},
"app.action-bar.make-primary-instance": {
"message": "デフォルトのインスタンスを作成"
},
@@ -29,6 +74,9 @@
"app.action-bar.stop-instance": {
"message": "インスタンスを停止"
},
"app.action-bar.update": {
"message": "アップデート"
},
"app.action-bar.view-active-downloads": {
"message": "進行中のダウンロードを表示"
},
@@ -131,9 +179,6 @@
"app.browse.discover-servers": {
"message": "サーバーを探す"
},
"app.browse.hide-added-servers": {
"message": "既に追加済みのサーバーを非表示にする"
},
"app.browse.project-type.modpacks": {
"message": "Modパック"
},
@@ -149,9 +194,6 @@
"app.export-modal.header": {
"message": "Modパックを書き出す"
},
"app.export-modal.modpack-name-label": {
"message": "Modパック名"
},
"app.export-modal.modpack-name-placeholder": {
"message": "Modパック名"
},
@@ -161,6 +203,9 @@
"app.export-modal.version-number-placeholder": {
"message": "1.0.0"
},
"app.install.phase.preparing_java.downloading": {
"message": "Java {version} をダウンロードしています"
},
"app.instance.confirm-delete.admonition-body": {
"message": "インスタンス内のすべてのデータは、ワールド、設定、インストール済みのコンテンツを含め、完全に削除されます。"
},
@@ -209,6 +254,9 @@
"app.instance.worlds.browse-servers": {
"message": "サーバーを探す"
},
"app.instance.worlds.delete-world-modal.warning-header": {
"message": "{name} を削除します"
},
"app.instance.worlds.filter-modded": {
"message": "Mod導入済み"
},
@@ -266,6 +314,9 @@
"app.modal.update-to-play.update-required-description": {
"message": "{name}をプレイするには更新が必要です。ゲームを起動するには最新版に更新してください。"
},
"app.project.version.all-versions": {
"message": "すべてのバージョン"
},
"app.settings.developer-mode-enabled": {
"message": "開発者モードがオンになっています。"
},
@@ -305,9 +356,18 @@
"app.skins.modal.no-cape-tooltip": {
"message": "マントなし"
},
"app.skins.modal.none-cape-option": {
"message": "無し"
},
"app.skins.modal.replace-texture-button": {
"message": "アップロード"
},
"app.skins.modal.save-skin-button": {
"message": "スキンを保存する"
},
"app.skins.modal.saving-tooltip": {
"message": "保存しています…"
},
"app.skins.modal.texture-section": {
"message": "テクスチャ"
},
@@ -566,6 +626,12 @@
"instance.settings.tabs.general.name": {
"message": "名前"
},
"instance.settings.tabs.general.update-channel.beta": {
"message": "ベータ"
},
"instance.settings.tabs.general.update-channel.release": {
"message": "リリース"
},
"instance.settings.tabs.hooks": {
"message": "起動フック"
},
+152 -2
View File
@@ -3,7 +3,7 @@
"message": "Java {version} 다운로드 중"
},
"app.action-bar.downloading-update": {
"message": "업데이트 다운로드중"
"message": "업데이트 다운로드 중"
},
"app.action-bar.downloads": {
"message": "다운로드"
@@ -11,6 +11,21 @@
"app.action-bar.hide-more-running-instances": {
"message": "실행 중인 인스턴스 숨기기"
},
"app.action-bar.install.dismiss": {
"message": "무시"
},
"app.action-bar.install.open-instance": {
"message": "인스턴스 열기"
},
"app.action-bar.install.retry": {
"message": "재시도"
},
"app.action-bar.install.unknown-instance": {
"message": "알 수 없는 인스턴스"
},
"app.action-bar.installs": {
"message": "설치"
},
"app.action-bar.make-primary-instance": {
"message": "기본 인스턴스로 지정"
},
@@ -23,12 +38,18 @@
"app.action-bar.primary-instance": {
"message": "기본 인스턴스"
},
"app.action-bar.reload-to-update": {
"message": "업데이트하려면 다시 로드"
},
"app.action-bar.show-more-running-instances": {
"message": "실행 중인 인스턴스 보이기"
},
"app.action-bar.stop-instance": {
"message": "인스턴스 멈추기"
},
"app.action-bar.update": {
"message": "업데이트"
},
"app.action-bar.view-active-downloads": {
"message": "다운로드 목록 보기"
},
@@ -95,6 +116,12 @@
"app.appearance-settings.show-play-time.title": {
"message": "플레이 시간 표시"
},
"app.appearance-settings.skip-non-essential-warnings.description": {
"message": "중복되는 모드팩 설치와 일반적인 콘텐츠 삭제, 동시 업데이트, 모드팩 링크 제거, 프롬프트 복구처럼 리스크가 적은 행위에 대한 확인 절차를 자동으로 넘깁니다. 위험한 경고는 여전히 표시됩니다."
},
"app.appearance-settings.skip-non-essential-warnings.title": {
"message": "중요하지 않은 경고 넘기기"
},
"app.appearance-settings.toggle-sidebar.description": {
"message": "사이드바를 접거나 펼칠 수 있는 기능을 활성화합니다."
},
@@ -141,7 +168,7 @@
"message": "서버 탐색하기"
},
"app.browse.hide-added-servers": {
"message": "이미 추가 서버 숨기기"
"message": "이미 추가 서버 숨기기"
},
"app.browse.project-type.modpacks": {
"message": "모드팩"
@@ -176,6 +203,57 @@
"app.export-modal.version-number-placeholder": {
"message": "1.0.0"
},
"app.install.phase.downloading_content": {
"message": "콘텐츠 다운로드 중"
},
"app.install.phase.downloading_minecraft": {
"message": "Minecraft 다운로드 중"
},
"app.install.phase.downloading_pack_file": {
"message": "팩 파일 다운로드 중"
},
"app.install.phase.extracting_overrides": {
"message": "재정의 콘텐츠 추출 중"
},
"app.install.phase.finalizing": {
"message": "마무리 중"
},
"app.install.phase.preparing_java": {
"message": "Java 준비 중"
},
"app.install.phase.preparing_java.downloading": {
"message": "Java {version} 다운로드 중"
},
"app.install.phase.preparing_java.extracting": {
"message": "Java {version} 추출 중"
},
"app.install.phase.preparing_java.fetching-metadata": {
"message": "Java {version} 가져오는 중"
},
"app.install.phase.preparing_java.resolving": {
"message": "Java {version} 준비 중"
},
"app.install.phase.preparing_java.validating": {
"message": "Java {version} 유효성 검사 중"
},
"app.install.phase.reading_pack_manifest": {
"message": "팩 manifest 읽는 중"
},
"app.install.phase.resolving_loader": {
"message": "로더 해결 중"
},
"app.install.phase.resolving_minecraft": {
"message": "Minecraft 해결 중"
},
"app.install.phase.resolving_pack": {
"message": "콘텐츠 해결 중"
},
"app.install.phase.rolling_back": {
"message": "뒤로 돌리는 중"
},
"app.install.phase.running_loader_processors": {
"message": "로더 진행기 실행 중"
},
"app.instance.confirm-delete.admonition-body": {
"message": "인스턴스의 모든 데이터가 삭제됩니다. 세계 폴더, 설정 폴더 등 설치된 모든 컨텐츠가 포함됩니다."
},
@@ -200,6 +278,15 @@
"app.instance.modpack-already-installed.instance": {
"message": "인스턴스"
},
"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": "프로젝트"
},
@@ -224,6 +311,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": "수정됨"
},
@@ -242,6 +341,18 @@
"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}개의 세계 검색..."
},
@@ -401,9 +512,18 @@
"app.skins.rate-limit.title": {
"message": "잠시 기다려주세요."
},
"app.skins.reorder-error.text": {
"message": "이 스킨 정렬은 저장되지 않습니다."
},
"app.skins.reorder-error.title": {
"message": "스킨 재정렬 실패"
},
"app.skins.section.builders-and-biomes": {
"message": "Builders & Biomes"
},
"app.skins.section.chaos-cubed": {
"message": "카오스 큐브"
},
"app.skins.section.chase-the-skies": {
"message": "Chase the Skies"
},
@@ -473,6 +593,9 @@
"app.update-popup.download-complete": {
"message": "다운로드 완료"
},
"app.update-popup.reload": {
"message": "업데이트하려면 다시 로드"
},
"app.update-popup.title": {
"message": "업데이트 가능"
},
@@ -671,6 +794,30 @@
"instance.settings.tabs.general.name": {
"message": "이름"
},
"instance.settings.tabs.general.update-channel": {
"message": "채널 업데이트"
},
"instance.settings.tabs.general.update-channel.alpha": {
"message": "알파"
},
"instance.settings.tabs.general.update-channel.alpha.description": {
"message": "릴리스와 베타, 알파 버전이 이용 가능한 업데이트에 표시됩니다."
},
"instance.settings.tabs.general.update-channel.beta": {
"message": "베타"
},
"instance.settings.tabs.general.update-channel.beta.description": {
"message": "릴리스와 베타 버전이 이용 가능한 업데이트에 표시됩니다."
},
"instance.settings.tabs.general.update-channel.release": {
"message": "릴리스"
},
"instance.settings.tabs.general.update-channel.release.description": {
"message": "릴리스 버전만 이용 가능한 업데이트에 표시됩니다."
},
"instance.settings.tabs.general.update-channel.select": {
"message": "업데이트 채널 선택"
},
"instance.settings.tabs.hooks": {
"message": "실행 후크"
},
@@ -794,6 +941,9 @@
"instance.worlds.copy_address": {
"message": "주소 복사"
},
"instance.worlds.create_shortcut": {
"message": "바로가기 만들기"
},
"instance.worlds.dont_show_on_home": {
"message": "홈에서 숨기기"
},
+39 -6
View File
@@ -8,6 +8,12 @@
"app.action-bar.hide-more-running-instances": {
"message": "Sembunyikan lebih banyak pemasangan yang berjalan"
},
"app.action-bar.install.dismiss": {
"message": "Ketepikan"
},
"app.action-bar.install.retry": {
"message": "Cuba semula"
},
"app.action-bar.make-primary-instance": {
"message": "Jadikan pemasangan utama"
},
@@ -26,6 +32,9 @@
"app.action-bar.stop-instance": {
"message": "Hentikan pemasangan"
},
"app.action-bar.update": {
"message": "Kemas kini"
},
"app.action-bar.view-active-downloads": {
"message": "Lihat muat turun aktif"
},
@@ -86,6 +95,9 @@
"app.appearance-settings.select-option": {
"message": "Pilih satu pilihan"
},
"app.appearance-settings.skip-non-essential-warnings.description": {
"message": "Melangkau pengesahan berisiko rendah secara automatik seperti pemasangan pek mod pendua, pemadaman kandungan biasa, kemas kini pukal, menyahpautkan pek mod dan gesaan pembaikan. Amaran berbahaya masih akan ditunjukkan."
},
"app.appearance-settings.toggle-sidebar.description": {
"message": "Membolehkan keupayaan untuk menogol bar sisi."
},
@@ -131,9 +143,6 @@
"app.browse.discover-servers": {
"message": "Temui pelayan"
},
"app.browse.hide-added-servers": {
"message": "Sembunyikan pelayan yang sudah ditambah"
},
"app.browse.project-type.modpacks": {
"message": "Pek Mod"
},
@@ -155,9 +164,6 @@
"app.export-modal.header": {
"message": "Eksport pek mod"
},
"app.export-modal.modpack-name-label": {
"message": "Nama Pek Mod"
},
"app.export-modal.modpack-name-placeholder": {
"message": "Nama pek mod"
},
@@ -167,6 +173,24 @@
"app.export-modal.version-number-placeholder": {
"message": "1.0.0"
},
"app.install.phase.preparing_java.downloading": {
"message": "Sedang memuat turun Java {version}"
},
"app.install.phase.preparing_java.extracting": {
"message": "Sedang mengekstrak Java {version}"
},
"app.install.phase.preparing_java.fetching-metadata": {
"message": "Sedang mengambil Java {version}"
},
"app.install.phase.preparing_java.resolving": {
"message": "Sedang menyediakan Java {version}"
},
"app.install.phase.preparing_java.validating": {
"message": "Sedang mengesahkan Java {version}"
},
"app.install.phase.reading_pack_manifest": {
"message": "Sedang membaca manifest pek"
},
"app.instance.confirm-delete.admonition-body": {
"message": "Semua data untuk pemasangan anda akan dipadamkan secara kekal, termasuk dunia, konfigurasi dan semua kandungan yang dipasang."
},
@@ -626,6 +650,15 @@
"instance.settings.tabs.general.name": {
"message": "Nama"
},
"instance.settings.tabs.general.update-channel.alpha": {
"message": "Alfa"
},
"instance.settings.tabs.general.update-channel.beta": {
"message": "Beta"
},
"instance.settings.tabs.general.update-channel.release": {
"message": "Terbitan"
},
"instance.settings.tabs.hooks": {
"message": "Cangkuk pelancaran"
},
@@ -146,9 +146,6 @@
"app.browse.discover-servers": {
"message": "Ontdek servers"
},
"app.browse.hide-added-servers": {
"message": "Verberg al toegevoegde servers"
},
"app.browse.project-type.modpacks": {
"message": "Modpacks"
},
@@ -170,9 +167,6 @@
"app.export-modal.header": {
"message": "Exporteer modpack"
},
"app.export-modal.modpack-name-label": {
"message": "Modpack Naam"
},
"app.export-modal.modpack-name-placeholder": {
"message": "Modpack naam"
},
+226 -13
View File
@@ -1,10 +1,220 @@
{
"app.action-bar.downloading-java": {
"message": "Laster ned Java {version}"
},
"app.action-bar.downloading-update": {
"message": "Laster ned oppdatering"
},
"app.action-bar.downloads": {
"message": "Nedlastninger"
},
"app.action-bar.hide-more-running-instances": {
"message": "Skjul flere kjørende tilfeller"
},
"app.action-bar.install.open-instance": {
"message": "Åpne tilfelle"
},
"app.action-bar.install.retry": {
"message": "Prøv igjen"
},
"app.action-bar.install.unknown-instance": {
"message": "Ukjent tilfelle"
},
"app.action-bar.installs": {
"message": "Installeringer"
},
"app.action-bar.make-primary-instance": {
"message": "Gjør om til primærtilfelle"
},
"app.action-bar.no-instances-running": {
"message": "Ingen kjørende tilfeller"
},
"app.action-bar.offline": {
"message": "Uten nett"
},
"app.action-bar.primary-instance": {
"message": "Primærtilfelle"
},
"app.action-bar.reload-to-update": {
"message": "Last inn på nytt for å oppdatere"
},
"app.action-bar.show-more-running-instances": {
"message": "Vis flere kjørende tilfeller"
},
"app.action-bar.stop-instance": {
"message": "Stopp tilfelle"
},
"app.action-bar.update": {
"message": "Oppdater"
},
"app.action-bar.view-active-downloads": {
"message": "Vis aktive nedlastninger"
},
"app.action-bar.view-instance": {
"message": "Vis tilfelle"
},
"app.action-bar.view-logs": {
"message": "Vis logger"
},
"app.appearance-settings.advanced-rendering.description": {
"message": "Skrur på avansert rendering, som for eksempel uskarphetseffekter, som kan forårsake ytelsesproblemer om man ikke har maskinvareaksellerert rendering."
},
"app.appearance-settings.advanced-rendering.title": {
"message": "Avansert rendering"
},
"app.appearance-settings.color-theme.description": {
"message": "Velg ditt foretrukne fargetema for Modrinth App."
},
"app.appearance-settings.color-theme.title": {
"message": "Fargetema"
},
"app.appearance-settings.default-landing-page.description": {
"message": "Endre sida som vises når launcheren åpnes."
},
"app.appearance-settings.default-landing-page.home": {
"message": "Hjem"
},
"app.appearance-settings.default-landing-page.library": {
"message": "Bibliotek"
},
"app.appearance-settings.default-landing-page.title": {
"message": "Åpningsside"
},
"app.appearance-settings.hide-nametag.description": {
"message": "Skjuler navnelappen over din spiller på utseende siden."
},
"app.appearance-settings.hide-nametag.title": {
"message": "Gjøm navnelapp"
},
"app.appearance-settings.minimize-launcher.title": {
"message": "Minimer launcheren"
},
"app.appearance-settings.select-option": {
"message": "Velg et alternativ"
},
"app.appearance-settings.show-play-time.description": {
"message": "Viser hvor lenge du har spilt et tilfelle."
},
"app.appearance-settings.show-play-time.title": {
"message": "Vis spilletid"
},
"app.appearance-settings.skip-non-essential-warnings.description": {
"message": "Hopper automatisk over lav-rissiko fastslåelser som modpakkeinstalasjonsduplikat, vanlig innholdsletting, bolkoppdateringer, avkoble modpakker, og reparasjonsprompter. Farlige advarsler vil fortsatt vises."
},
"app.appearance-settings.skip-non-essential-warnings.title": {
"message": "Hopp over mindre viktige advarsler"
},
"app.appearance-settings.toggle-sidebar.description": {
"message": "Aktiverer evnen til å skru sidefeltet av/på."
},
"app.appearance-settings.toggle-sidebar.title": {
"message": "Sidefeltsveksling"
},
"app.appearance-settings.unknown-pack-warning.description": {
"message": "Hvis du prøver å laste ned en Modrinth Pakke fil (.mrpack) som er ukjent for Modrinth så kommer vi til å forsikre oss om at du forstår risikoene før du installerer den."
},
"app.appearance-settings.unknown-pack-warning.title": {
"message": "Advar meg før jeg installerer ukjente modpakker"
},
"app.auth-servers.unreachable.body": {
"message": "Minecraft-autentiseringsservere kan være nede for øyeblikket. Kontroller internettforbindelsen din og prøv igjen senere."
},
"app.auth-servers.unreachable.header": {
"message": "Kan ikke nå autentiseringsservere"
},
"app.browse.add-servers-to-instance": {
"message": "Legger til server til tilfelle"
},
"app.browse.add-to-an-instance": {
"message": "Legg til til et tilfelle"
},
"app.browse.add-to-instance": {
"message": "Legg til til tilfelle"
},
"app.browse.add-to-instance-name": {
"message": "Legg til til {instanceName}"
},
"app.browse.added": {
"message": "Lagt til"
},
"app.browse.already-added": {
"message": "Allerede lagt til"
},
"app.browse.back-to-instance": {
"message": "Tilbake til tilfelle"
},
"app.browse.discover-content": {
"message": "Oppdag innhold"
},
"app.browse.discover-servers": {
"message": "Oppdag servere"
},
"app.browse.hide-added-servers": {
"message": "Gjem servere som allerede er lagt til"
},
"app.browse.project-type.modpacks": {
"message": "Modpakker"
},
"app.browse.server-instance-content-warning": {
"message": "Å legge til innhold kan ødelegge komatibilitet når du blir med i serveren. Alt tilleggsinnhold vil også forsvinne når du oppdaterer servertilfelleinnholdet."
},
"app.browse.server.installing": {
"message": "Installerer"
},
"app.content-install.no-compatible-versions": {
"message": "Ingen tilgjengelige versjoner matcher {compatibilityLabel}. Velg en versjon for å installere uansett. Avhengigheter vil ikke bli installert automatisk."
},
"app.export-modal.description-placeholder": {
"message": "Skriv en modpakkebeskrivelse..."
},
"app.export-modal.export-button": {
"message": "Eksperter"
},
"app.export-modal.header": {
"message": "Eksperter modpakke"
},
"app.export-modal.modpack-name-label": {
"message": "Navn på modpakke"
},
"app.install.phase.preparing_java.validating": {
"message": "Validerer Java {version}"
},
"app.install.phase.reading_pack_manifest": {
"message": "Leser pakkemanifest"
},
"app.instance.confirm-delete.delete-button": {
"message": "Slett tilfelle"
},
"app.instance.confirm-delete.header": {
"message": "Slett tilfelle"
},
"app.instance.modpack-already-installed.create": {
"message": "Opprett"
},
"app.instance.modpack-already-installed.header": {
"message": "Modpakka er allerede installert"
},
"app.instance.modpack-already-installed.instance": {
"message": "Tilfelle"
},
"app.instance.mods.bulk-update.downloading-projects": {
"message": "Laster ned {current, number}/{total, number} prosjekter..."
},
"app.instance.mods.bulk-update.finishing": {
"message": "Fullfører oppdatering..."
},
"app.instance.mods.content-type-project": {
"message": "prosjekt"
},
"app.instance.mods.project-was-added": {
"message": "\"{name}\" ble lagt til"
},
"app.instance.mods.projects-were-added": {
"message": "{count} prosjekter ble lagt til"
},
"app.instance.mods.share-text": {
"message": "Sjekk ut prosjektene jeg bruker i min modpakke!"
},
"app.modal.install-to-play.header": {
"message": "Installer for å spille"
},
@@ -12,13 +222,13 @@
"message": "Installer"
},
"app.modal.install-to-play.mod-count": {
"message": "{count, plural, one {#mod} other {# mods}}"
"message": "{count, plural, one {#mod} other {# modder}}"
},
"app.modal.install-to-play.shared-instance": {
"message": "Delt instans"
"message": "Delt tilfelle"
},
"app.modal.install-to-play.shared-server-instance": {
"message": "Delt serverinstans"
"message": "Delt servertilfelle"
},
"app.modal.update-to-play.header": {
"message": "Oppdater for å spille"
@@ -39,7 +249,7 @@
"message": "Utseende"
},
"app.settings.tabs.default-instance-options": {
"message": "Standard instansinstillinger"
"message": "Vanlige tilfelleinstillinger"
},
"app.settings.tabs.java-installations": {
"message": "Java installasjoner"
@@ -102,10 +312,10 @@
"message": "Aktiv"
},
"friends.heading.offline": {
"message": "Offline"
"message": "Av"
},
"friends.heading.online": {
"message": "Online"
"message": ""
},
"friends.heading.pending": {
"message": "Ventende"
@@ -246,10 +456,10 @@
"message": "Før oppstart"
},
"instance.settings.tabs.hooks.pre-launch.description": {
"message": "Kørt før instansen startes."
"message": "Kjøres før tilfellet startes."
},
"instance.settings.tabs.hooks.pre-launch.enter": {
"message": "Skriv inn kommando før start..."
"message": "Skriv inn forstartskommando..."
},
"instance.settings.tabs.hooks.title": {
"message": "Spillstartkroker"
@@ -258,7 +468,7 @@
"message": "Wrapper"
},
"instance.settings.tabs.hooks.wrapper.description": {
"message": "Wrapper-kommando for å starte Minecraft."
"message": "Innpakningskommando for å starte Minecraft."
},
"instance.settings.tabs.hooks.wrapper.enter": {
"message": "Skriv inn wrapper-kommando..."
@@ -272,6 +482,9 @@
"instance.settings.tabs.java": {
"message": "Java og minne"
},
"instance.settings.tabs.java.custom-memory-allocation": {
"message": "Egendefinert minneallokasjon"
},
"instance.settings.tabs.java.environment-variables": {
"message": "Miljøvariabler"
},
@@ -303,7 +516,7 @@
"message": "Høyde"
},
"instance.settings.tabs.window.height.description": {
"message": "Høyde av spillvindu når spillet startes."
"message": "Høyden til spillvinduet når spillet startes."
},
"instance.settings.tabs.window.height.enter": {
"message": "Angi høyde..."
@@ -312,7 +525,7 @@
"message": "Bredde"
},
"instance.settings.tabs.window.width.description": {
"message": "Bredde av spillvindu når spillet startes."
"message": "Bredden til spillvinduet når spillet startes."
},
"instance.settings.tabs.window.width.enter": {
"message": "Angi bredde..."
@@ -330,7 +543,7 @@
"message": "Ikke vis på Hjem"
},
"instance.worlds.game_already_open": {
"message": "Instansen er allerede open"
"message": "Tilfellet er allerede open"
},
"instance.worlds.hardcore": {
"message": "Hardbarket modus"
@@ -348,7 +561,7 @@
"message": "Du kan bare hoppe rett inn i enkeltspiller-verdener på Minecraft 1.20+"
},
"instance.worlds.play_instance": {
"message": "Spill instans"
"message": "Spill tilfelle"
},
"instance.worlds.view_instance": {
"message": "Vis instans"
+113 -41
View File
@@ -9,29 +9,83 @@
"message": "Pobrania"
},
"app.action-bar.hide-more-running-instances": {
"message": "Ukryj włączone instancje"
"message": "Ukryj aktywne instancje"
},
"app.action-bar.install.copied-details": {
"message": "Skopiowano"
},
"app.action-bar.install.copy-details": {
"message": "Kopiuj szczegóły"
},
"app.action-bar.install.dismiss": {
"message": "Odrzuć"
},
"app.action-bar.install.failed": {
"message": "Instalacja nie powiodła się"
},
"app.action-bar.install.failed-app-closed": {
"message": "Instalacja nie powiodła się z powodu zamknięcia aplikacji."
},
"app.action-bar.install.failed-network": {
"message": "Instalacja nie powiodła się z powodu problemów z połączeniem."
},
"app.action-bar.install.failed-unknown": {
"message": "Instalacja nie powiodła się z nieznanych przyczyn."
},
"app.action-bar.install.open-instance": {
"message": "Otwórz instancję"
},
"app.action-bar.install.retry": {
"message": "Spróbuj ponownie"
},
"app.action-bar.install.summary.app-closing": {
"message": "Anulowano z powodu zamknięcia aplikacji"
},
"app.action-bar.install.summary.bad-modpack-file": {
"message": "Nie można było odczytać paczki modów"
},
"app.action-bar.install.summary.canceled": {
"message": "Anulowano"
},
"app.action-bar.install.summary.cleanup-incomplete": {
"message": "Czyszczenie nie powiodło się"
},
"app.action-bar.install.summary.content-download-failed": {
"message": "Nie udało się pobrać plików"
},
"app.action-bar.install.summary.corrupt-download": {
"message": "Pobrany plik jest uszkodzony"
},
"app.action-bar.install.summary.could-not-save-files": {
"message": "Nie udało się zapisać plików"
},
"app.action-bar.install.summary.download-failed": {
"message": "Nie można było ukończyć pobierania"
},
"app.action-bar.install.summary.instance-not-found": {
"message": "Nie znaleziono instancji"
},
"app.action-bar.install.summary.invalid-file-path": {
"message": "Ścieżka plików jest niewłaściwa"
},
"app.action-bar.install.summary.invalid-modpack": {
"message": "Niewłaściwe dane paczki modów"
},
"app.action-bar.install.summary.invalid-modpack-files": {
"message": "Pliki paczki modów zawierają niewłaściwe metadane"
},
"app.action-bar.install.summary.java-setup-failed": {
"message": "Ustawianie Javy nie powiodło się"
},
"app.action-bar.install.summary.loader-setup-failed": {
"message": "Ustawianie loadera nie powiodło się"
},
"app.action-bar.install.summary.local-data-error": {
"message": "Nie udało się zaktualizować danych lokalnych"
},
"app.action-bar.install.summary.minecraft-setup-failed": {
"message": "Ustawianie Minecraft nie powiodło się"
},
"app.action-bar.install.summary.modrinth-unreachable": {
"message": "Nie udało się połączyć z serwerami Modrinth"
},
"app.action-bar.install.summary.no-write-permission": {
"message": "Brak permisji, aby zapisać pliki"
},
"app.action-bar.install.summary.pack-download-failed": {
"message": "Pobieranie paczki nie powiodło się"
},
"app.action-bar.install.summary.unexpected-error": {
"message": "Coś poszło nie tak"
},
"app.action-bar.install.unknown-instance": {
"message": "Nieznana instancja"
},
@@ -42,7 +96,7 @@
"message": "Ustaw jako główną instancję"
},
"app.action-bar.no-instances-running": {
"message": "Żadna instancja nie jest włączona"
"message": "Brak włączonych instancji"
},
"app.action-bar.offline": {
"message": "Offline"
@@ -51,7 +105,7 @@
"message": "Główna instancja"
},
"app.action-bar.reload-to-update": {
"message": "Załaduj ponownie, by zaktualizować"
"message": "Załaduj ponownie, aby zaktualizować"
},
"app.action-bar.show-more-running-instances": {
"message": "Pokaż więcej włączonych instancji"
@@ -78,7 +132,7 @@
"message": "Zaawansowane renderowanie"
},
"app.appearance-settings.color-theme.description": {
"message": "Wybierz preferowany motyw w Modrinth app."
"message": "Wybierz preferowany motyw kolorystyczny dla Modrinth App."
},
"app.appearance-settings.color-theme.title": {
"message": "Motyw"
@@ -96,10 +150,10 @@
"message": "Domyślna strona główna"
},
"app.appearance-settings.hide-nametag.description": {
"message": "Wyłącza nazwę użytkownika na stronie skórek."
"message": "Wyłącza nazwę użytkownika nad modelem gracza na stronie skórek."
},
"app.appearance-settings.hide-nametag.title": {
"message": "Schowaj nazwę"
"message": "Ukryj nazwę"
},
"app.appearance-settings.jump-back-into-worlds.description": {
"message": "Pokazuje ostatnie światy w sekcji \"Wskocz z powrotem do światów\" na stronie głównej."
@@ -231,7 +285,7 @@
"message": "Finalizowanie"
},
"app.install.phase.preparing_instance": {
"message": "Przygotowywanie instancji"
"message": "Oczekuje na instalację"
},
"app.install.phase.preparing_java": {
"message": "Przygotowywanie środowiska Java"
@@ -240,10 +294,10 @@
"message": "Pobieranie Java {version}"
},
"app.install.phase.preparing_java.extracting": {
"message": "Wypakowywanie Javy {version}"
"message": "Wypakowywanie Java {version}"
},
"app.install.phase.preparing_java.fetching-metadata": {
"message": "Pobieranie Javy {version}"
"message": "Pobieranie Java {version}"
},
"app.install.phase.preparing_java.resolving": {
"message": "Przygotowywanie Java {version}"
@@ -252,10 +306,10 @@
"message": "Sprawdzanie Java {version}"
},
"app.install.phase.reading_pack_manifest": {
"message": "Ustalanie manifestu paczki"
"message": "Sprawdzanie manifestu paczki"
},
"app.install.phase.resolving_loader": {
"message": "Ustalanie programu ładującego"
"message": "Ustalanie loadera"
},
"app.install.phase.resolving_minecraft": {
"message": "Ustalanie wersji Minecraft"
@@ -267,13 +321,13 @@
"message": "Przywracanie"
},
"app.install.phase.running_loader_processors": {
"message": "Uruchamianie procesorów programu ładującego"
"message": "Uruchamianie procesorów loadera"
},
"app.instance.confirm-delete.admonition-body": {
"message": "Wszystkie dane z Twojej instancji zostaną trwale usunięte, w tym Twoje światy, pliki konfiguracji i jakakolwiek dodana zawartość."
},
"app.instance.confirm-delete.admonition-header": {
"message": "Tej akcji nie można odwrócić"
"message": "Tej akcji nie można cofnąć"
},
"app.instance.confirm-delete.delete-button": {
"message": "Usuń instancję"
@@ -309,13 +363,13 @@
"message": "Dodano \"{name}\""
},
"app.instance.mods.share-text": {
"message": "Sprawdź projekty, które używam w mojej paczce modów!"
"message": "Sprawdź projekty, których używam w mojej paczce modów!"
},
"app.instance.mods.share-title": {
"message": "Udostępnianie zawartości paczki modów"
},
"app.instance.mods.successfully-uploaded": {
"message": "Przesyłanie powiodło się"
"message": "Przesyłanie zakończone sukcesem"
},
"app.instance.worlds.add-server": {
"message": "Dodaj serwer"
@@ -407,12 +461,27 @@
"app.project.install-button.already-installed": {
"message": "Ten projekt jest już zainstalowany"
},
"app.project.install-button.switch-version": {
"message": "Zmień wersję"
},
"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"
},
"app.project.version.download-in-browser": {
"message": "Pobierz w przeglądarce"
},
"app.project.version.installing": {
"message": "Instalowanie"
},
"app.project.versions.already-installed": {
"message": "Już zainstalowano"
},
"app.settings.developer-mode-enabled": {
"message": "Tryb dewelopera włączony."
},
@@ -450,25 +519,25 @@
"message": "Usuń skórkę"
},
"app.skins.delete-modal.description": {
"message": "To trwale usunie wybraną skórkę. Tej akcji nie można odwrócić."
"message": "To trwale usunie wybraną skórkę. Tej akcji nie można cofnąć."
},
"app.skins.delete-modal.title": {
"message": "Czy na pewno chcesz usunąć tę skórkę?"
},
"app.skins.dropped-file-error.text": {
"message": "Nie udało się odczytać wybranego pliku."
"message": "Nie udało się odczytać upuszczonego pliku."
},
"app.skins.dropped-file-error.title": {
"message": "Błąd podczas przetwarzania pliku"
},
"app.skins.edit-button": {
"message": "Edytuj skina"
"message": "Edytuj skórkę"
},
"app.skins.modal.add-skin-button": {
"message": "Dodaj skina"
"message": "Dodaj skórkę"
},
"app.skins.modal.add-title": {
"message": "Dodawanie skina"
"message": "Dodawanie skórki"
},
"app.skins.modal.arm-style-section": {
"message": "Styl ramion"
@@ -486,10 +555,10 @@
"message": "Peleryny"
},
"app.skins.modal.edit-title": {
"message": "Edytuj skina"
"message": "Edytowanie skórki"
},
"app.skins.modal.make-edit-first-tooltip": {
"message": "Wpierw zmień jakoś skórkę!"
"message": "Zmodyfikuj najpierw skórkę!"
},
"app.skins.modal.no-cape-tooltip": {
"message": "Brak peleryny"
@@ -501,7 +570,7 @@
"message": "Zamień teksturę"
},
"app.skins.modal.save-skin-button": {
"message": "Zapisz skina"
"message": "Zapisz skórkę"
},
"app.skins.modal.saving-tooltip": {
"message": "Zapisywanie..."
@@ -533,6 +602,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"
},
@@ -582,7 +654,7 @@
"message": "Zaloguj się"
},
"app.skins.title": {
"message": "Wybierz skórkę"
"message": "Wybór skórki"
},
"app.update-popup.body.download-complete": {
"message": "Wersja Modrinth App v{version} została pobrana. Załaduj ponownie, żeby zaktualizować teraz, albo automatycznie, gdy zamkniesz Modrinth App."
@@ -882,7 +954,7 @@
"message": "Niestandardowe argumenty Java"
},
"instance.settings.tabs.java.custom-java-installation": {
"message": "Niestandardowa instalacja Javy"
"message": "Niestandardowa instalacja Java"
},
"instance.settings.tabs.java.custom-memory-allocation": {
"message": "Niestandardowa alokacja pamięci"
@@ -891,7 +963,7 @@
"message": "Zmienne środowiskowe..."
},
"instance.settings.tabs.java.enter-java-arguments": {
"message": "Argumenty Javy..."
"message": "Argumenty Java..."
},
"instance.settings.tabs.java.environment-variables": {
"message": "Zmienne środowiskowe"
@@ -1029,10 +1101,10 @@
"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 formatu (w tym pliki .mrpack)."
"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"
"message": "Nie pokazuj ponownie tego ostrzeżenia"
},
"unknown-pack-warning-modal.header": {
"message": "Potwierdź instalację"
@@ -1041,7 +1113,7 @@
"message": "Instaluj mimo to"
},
"unknown-pack-warning-modal.malware-statement": {
"message": "Wirusy są często kryte w plikach paczek modów przesyłanych na platformach takich jak Discord."
"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."
+86 -14
View File
@@ -11,27 +11,81 @@
"app.action-bar.hide-more-running-instances": {
"message": "Ocultar mais instâncias em execução"
},
"app.action-bar.install.copied-details": {
"message": "Copiado"
},
"app.action-bar.install.copy-details": {
"message": "Copiar detalhes"
},
"app.action-bar.install.dismiss": {
"message": "Dispensar"
},
"app.action-bar.install.failed": {
"message": "Instalação falha"
},
"app.action-bar.install.failed-app-closed": {
"message": "A instalação falhou devido ao aplicativo ter fechado."
},
"app.action-bar.install.failed-network": {
"message": "A instalação falhou devido a um erro na rede."
},
"app.action-bar.install.failed-unknown": {
"message": "A instalação falhou devido a um erro desconhecido."
},
"app.action-bar.install.open-instance": {
"message": "Abrir instância"
},
"app.action-bar.install.retry": {
"message": "Tentar novamente"
},
"app.action-bar.install.summary.app-closing": {
"message": "Cancelado devido ao aplicativo ter fechado"
},
"app.action-bar.install.summary.bad-modpack-file": {
"message": "Não foi possível ler o pacote de mods"
},
"app.action-bar.install.summary.canceled": {
"message": "Cancelado"
},
"app.action-bar.install.summary.cleanup-incomplete": {
"message": "A limpeza não foi finalizada"
},
"app.action-bar.install.summary.content-download-failed": {
"message": "Não foi possível baixar os arquivos"
},
"app.action-bar.install.summary.corrupt-download": {
"message": "O arquivo baixado é corrupto"
},
"app.action-bar.install.summary.could-not-save-files": {
"message": "Não foi possível salvar os arquivos"
},
"app.action-bar.install.summary.download-failed": {
"message": "A transferência não foi finalizada"
},
"app.action-bar.install.summary.instance-not-found": {
"message": "Instância não encontrada"
},
"app.action-bar.install.summary.invalid-file-path": {
"message": "O caminho do arquivo é inválido"
},
"app.action-bar.install.summary.invalid-modpack": {
"message": "Os dados do pacote de mods é inválido"
},
"app.action-bar.install.summary.invalid-modpack-files": {
"message": "Os arquivos do pacote de mods possui metadados inválidos"
},
"app.action-bar.install.summary.java-setup-failed": {
"message": "Configuração Java não foi finalizada"
},
"app.action-bar.install.summary.loader-setup-failed": {
"message": "Falha na configuração do loader"
},
"app.action-bar.install.summary.local-data-error": {
"message": "Não foi atualizar os dados locais"
},
"app.action-bar.install.summary.minecraft-setup-failed": {
"message": "Falha na configuração do Minecraft"
},
"app.action-bar.install.summary.modrinth-unreachable": {
"message": "Não foi possível alcançar o Modrinth"
},
"app.action-bar.install.summary.no-write-permission": {
"message": "Sem permissão para escrita"
},
"app.action-bar.install.summary.pack-download-failed": {
"message": "Não foi possível baixar o pacote"
},
"app.action-bar.install.summary.unexpected-error": {
"message": "Algo deu errado"
},
"app.action-bar.install.unknown-instance": {
"message": "Instância desconhecida"
},
@@ -231,7 +285,7 @@
"message": "Finalizando"
},
"app.install.phase.preparing_instance": {
"message": "Preparando instância"
"message": "Na fila"
},
"app.install.phase.preparing_java": {
"message": "Preparando Java"
@@ -410,12 +464,27 @@
"app.project.install-button.already-installed": {
"message": "Este projeto já foi instalado"
},
"app.project.install-button.switch-version": {
"message": "Trocar versão"
},
"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"
},
"app.project.version.download-in-browser": {
"message": "Baixar no navegador"
},
"app.project.version.installing": {
"message": "Instalando"
},
"app.project.versions.already-installed": {
"message": "Já instalado"
},
"app.settings.developer-mode-enabled": {
"message": "Modo desenvolvedor ativado."
},
@@ -536,6 +605,9 @@
"app.skins.section.builders-and-biomes": {
"message": "Construtores & Biomas"
},
"app.skins.section.chaos-cubed": {
"message": "Chaos Cubed"
},
"app.skins.section.chase-the-skies": {
"message": "Chase the Skies"
},
@@ -600,7 +672,7 @@
"message": "Mudanças"
},
"app.update-popup.download": {
"message": "Baixar ({size})"
"message": "Instalar ({size})"
},
"app.update-popup.download-complete": {
"message": "Instalação concluída"
@@ -110,9 +110,6 @@
"app.export-modal.header": {
"message": "Exportar modpack"
},
"app.export-modal.modpack-name-label": {
"message": "Nome do Modpack"
},
"app.export-modal.modpack-name-placeholder": {
"message": "Nome do Modpack"
},
+101 -26
View File
@@ -3,7 +3,7 @@
"message": "Скачивание Java {version}"
},
"app.action-bar.downloading-update": {
"message": "Скачивание"
"message": "Скачивание обновления"
},
"app.action-bar.downloads": {
"message": "Загрузки"
@@ -11,27 +11,81 @@
"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": "Закрыть"
},
"app.action-bar.install.failed": {
"message": "Установка не удалась"
},
"app.action-bar.install.failed-app-closed": {
"message": "Установка не удалась из-за закрытия приложения."
},
"app.action-bar.install.failed-network": {
"message": "Установка не удалась из-за сетевой ошибки."
},
"app.action-bar.install.failed-unknown": {
"message": "Установка не удалась из-за неизвестной ошибки."
},
"app.action-bar.install.open-instance": {
"message": "Открыть сборку"
},
"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": "Неизвестная сборка"
},
@@ -51,7 +105,7 @@
"message": "Основная сборка"
},
"app.action-bar.reload-to-update": {
"message": "Перезапустить"
"message": "Перезапустить и обновить"
},
"app.action-bar.show-more-running-instances": {
"message": "Показать другие активные сборки"
@@ -129,10 +183,10 @@
"message": "Время игры"
},
"app.appearance-settings.skip-non-essential-warnings.description": {
"message": "Автоматически пропускает подтверждения с низким уровнем риска, такие как установка дубликатов сборок, обычное удаление контента, массовые обновления, отключение сборок и запросы на восстановление. Опасные предупреждения по-прежнему будут отображаться."
"message": "Скрывать малозначимые подтверждения: установку копий сборок, обычное удаление контента, массовые обновления, отвязку и исправление сборок. Критические предупреждения останутся."
},
"app.appearance-settings.skip-non-essential-warnings.title": {
"message": "Пропустить несущественные предупреждения"
"message": "Только опасные предупреждения"
},
"app.appearance-settings.toggle-sidebar.description": {
"message": "Показывать кнопку скрытия боковой панели."
@@ -225,13 +279,13 @@
"message": "Скачивание файла сборки"
},
"app.install.phase.extracting_overrides": {
"message": "Извлечение переопределений"
"message": "Распаковка переопределений"
},
"app.install.phase.finalizing": {
"message": "Завершение"
},
"app.install.phase.preparing_instance": {
"message": "Подготовка сборки"
"message": "В очереди на установку"
},
"app.install.phase.preparing_java": {
"message": "Подготовка Java"
@@ -254,11 +308,14 @@
"app.install.phase.reading_pack_manifest": {
"message": "Чтение манифеста сборки"
},
"app.install.phase.resolving_loader": {
"message": "Разрешение загрузчика"
},
"app.install.phase.resolving_minecraft": {
"message": "Загрузка версий"
"message": "Разрешение Minecraft"
},
"app.install.phase.resolving_pack": {
"message": "Загрузка контента"
"message": "Разрешение контента"
},
"app.install.phase.rolling_back": {
"message": "Откат"
@@ -291,13 +348,13 @@
"message": "Сборка"
},
"app.instance.mods.bulk-update.downloading-projects": {
"message": "Скачивание {current, number}/{total, number} проектов..."
"message": "Скачивание проектов: {current, number}/{total, number}..."
},
"app.instance.mods.bulk-update.finishing": {
"message": "Завершение обновления..."
},
"app.instance.mods.bulk-update.resolving-versions": {
"message": "Загрузка версий..."
"message": "Разрешение версий..."
},
"app.instance.mods.content-type-project": {
"message": "проект"
@@ -321,7 +378,7 @@
"message": "Найти серверы"
},
"app.instance.worlds.delete-world-modal.delete-button": {
"message": "Удалить"
"message": "Удалить мир"
},
"app.instance.worlds.delete-world-modal.title": {
"message": "Удаление мира"
@@ -351,7 +408,7 @@
"message": "Нет серверов и миров"
},
"app.instance.worlds.remove-server-modal.remove-button": {
"message": "Удалить"
"message": "Удалить сервер"
},
"app.instance.worlds.remove-server-modal.title": {
"message": "Удаление сервера"
@@ -401,12 +458,27 @@
"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.project.version.all-versions": {
"message": "Все версии"
},
"app.project.version.download-in-browser": {
"message": "Скачать в браузере"
},
"app.project.version.installing": {
"message": "Установка"
},
"app.project.versions.already-installed": {
"message": "Уже установлено"
},
"app.settings.developer-mode-enabled": {
"message": "Режим разработчика включён."
},
@@ -519,7 +591,7 @@
"message": "Не так быстро!"
},
"app.skins.reorder-error.text": {
"message": "Ваш порядок скинов не удалось сохранить."
"message": "Порядок скинов не сохранился."
},
"app.skins.reorder-error.title": {
"message": "Не удалось изменить порядок скинов"
@@ -527,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"
},
@@ -900,7 +975,7 @@
"message": "Выделенная память"
},
"instance.settings.tabs.java.java-path-placeholder": {
"message": "/path/to/java"
"message": "/путь/к/java"
},
"instance.settings.tabs.window": {
"message": "Окно"
File diff suppressed because it is too large Load Diff
+91 -22
View File
@@ -11,27 +11,63 @@
"app.action-bar.hide-more-running-instances": {
"message": "Göm fler aktiva instanser"
},
"app.action-bar.install.copied-details": {
"message": "Kopierat"
},
"app.action-bar.install.copy-details": {
"message": "Kopiera detaljer"
},
"app.action-bar.install.dismiss": {
"message": "Avvisa"
},
"app.action-bar.install.failed": {
"message": "Installationen misslyckades"
},
"app.action-bar.install.failed-app-closed": {
"message": "Installationen misslyckades för att appen stängdes."
},
"app.action-bar.install.failed-network": {
"message": "Installationen misslyckades av ett nätverksfel."
},
"app.action-bar.install.failed-unknown": {
"message": "Installationen misslyckades med ett okänt fel."
},
"app.action-bar.install.open-instance": {
"message": "Öppna instans"
},
"app.action-bar.install.retry": {
"message": "Försök igen"
},
"app.action-bar.install.summary.app-closing": {
"message": "Avbröts på grund av att appen stängdes"
},
"app.action-bar.install.summary.bad-modpack-file": {
"message": "Kunde inte läsa modpaket"
},
"app.action-bar.install.summary.canceled": {
"message": "Avbruten"
},
"app.action-bar.install.summary.content-download-failed": {
"message": "Kunde inte ladda ner filer"
},
"app.action-bar.install.summary.corrupt-download": {
"message": "Nedladdad fil är korrupt"
},
"app.action-bar.install.summary.could-not-save-files": {
"message": "Kunde inte spara filer"
},
"app.action-bar.install.summary.download-failed": {
"message": "Nedladdning kunde inte slutföras"
},
"app.action-bar.install.summary.instance-not-found": {
"message": "Instans kunde inte hittas"
},
"app.action-bar.install.summary.invalid-file-path": {
"message": "Sökväg är ogiltig"
},
"app.action-bar.install.summary.local-data-error": {
"message": "Kunde inte uppdatera lokal data"
},
"app.action-bar.install.summary.modrinth-unreachable": {
"message": "Kunde inte nå Modrinth"
},
"app.action-bar.install.summary.no-write-permission": {
"message": "Ingen behörighet att skriva"
},
"app.action-bar.install.summary.pack-download-failed": {
"message": "Kunde inte ladda ner paket"
},
"app.action-bar.install.summary.unexpected-error": {
"message": "Någonting gick fel"
},
"app.action-bar.install.unknown-instance": {
"message": "Okänd instans"
},
@@ -42,7 +78,7 @@
"message": "Gör till primärinstans"
},
"app.action-bar.no-instances-running": {
"message": "Ingen aktiva instans"
"message": "Inga aktiva instanser"
},
"app.action-bar.offline": {
"message": "Offline"
@@ -180,7 +216,7 @@
"message": "Upptäck servrar"
},
"app.browse.hide-added-servers": {
"message": "Dölj servrar som redan är tillagda"
"message": "m redan tillagda servrar"
},
"app.browse.project-type.modpacks": {
"message": "Modpaket"
@@ -231,7 +267,7 @@
"message": "Avslutar"
},
"app.install.phase.preparing_instance": {
"message": "Förbereder instans"
"message": "Köad för installering"
},
"app.install.phase.preparing_java": {
"message": "Förbereder Java"
@@ -255,19 +291,25 @@
"message": "Läser pack information"
},
"app.install.phase.resolving_loader": {
"message": "Laddar ner loadern"
"message": "Fastställer loader"
},
"app.install.phase.resolving_minecraft": {
"message": "Laddar ner Minecraft"
"message": "Fastställer Minecraft"
},
"app.install.phase.resolving_pack": {
"message": "Laddar ner innehåll"
"message": "Fastställer innehåll"
},
"app.install.phase.rolling_back": {
"message": "Återställer"
},
"app.install.phase.running_loader_processors": {
"message": "Kör loader-processorer"
},
"app.instance.confirm-delete.admonition-body": {
"message": "All data från din instans kommer permanent raderas, däribland dina världar, konfigurationer samt allt installerat innehåll."
},
"app.instance.confirm-delete.admonition-header": {
"message": "Detta kan inte ogöras"
"message": "Detta kan inte ångras"
},
"app.instance.confirm-delete.delete-button": {
"message": "Radera instans"
@@ -294,7 +336,7 @@
"message": "Slutför uppdatering..."
},
"app.instance.mods.bulk-update.resolving-versions": {
"message": "Laddar versioner..."
"message": "Fastställer versioner..."
},
"app.instance.mods.content-type-project": {
"message": "projekt"
@@ -326,6 +368,9 @@
"app.instance.worlds.delete-world-modal.title": {
"message": "Ta bort värld"
},
"app.instance.worlds.delete-world-modal.warning-body": {
"message": "Denna värld kommer permanent raderas från instansen. Detta kan inte ångras."
},
"app.instance.worlds.delete-world-modal.warning-header": {
"message": "Raderar {name}"
},
@@ -353,6 +398,9 @@
"app.instance.worlds.remove-server-modal.title": {
"message": "Ta bort server"
},
"app.instance.worlds.remove-server-modal.warning-body": {
"message": "Denna server kommer att tas bort från din serverlista och serverlistan i spelet. Du kan lägga till den igen senare om du känner till adressen."
},
"app.instance.worlds.remove-server-modal.warning-header": {
"message": "Raderar {name}"
},
@@ -398,12 +446,27 @@
"app.project.install-button.already-installed": {
"message": "Detta projekt är redan installerat"
},
"app.project.install-button.switch-version": {
"message": "Byt version"
},
"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"
},
"app.project.version.download-in-browser": {
"message": "Ladda ner i webbläsare"
},
"app.project.version.installing": {
"message": "Installerar"
},
"app.project.versions.already-installed": {
"message": "Redan installerat"
},
"app.settings.developer-mode-enabled": {
"message": "Utvecklarläge aktiverat."
},
@@ -516,11 +579,17 @@
"message": "Sakta ner!"
},
"app.skins.reorder-error.text": {
"message": "Ditt utseende kunde inte sparas."
"message": "Din ordning av utseenden kunde inte sparas."
},
"app.skins.reorder-error.title": {
"message": "Misslyckades med att ordna om utseenden"
},
"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"
},
@@ -999,7 +1068,7 @@
"message": "Spel version är tillhandahållet av instansen"
},
"search.filter.locked.instance-loader.title": {
"message": "Laddaren tillhandahålls av instansen"
"message": "Loader tillhandahålls av instansen"
},
"search.filter.locked.instance.sync": {
"message": "Synkronisera med instansen"
@@ -146,9 +146,6 @@
"app.browse.discover-servers": {
"message": "สำรวจเซิร์ฟเวอร์"
},
"app.browse.hide-added-servers": {
"message": "ซ่อนเซิร์ฟเวอร์ที่ดำเนินการเพิ่มแล้ว"
},
"app.browse.project-type.modpacks": {
"message": "แพ็กม็อด"
},
@@ -170,9 +167,6 @@
"app.export-modal.header": {
"message": "ส่งออกแพ็กม็อด"
},
"app.export-modal.modpack-name-label": {
"message": "ชื่อแพ็กม็อด"
},
"app.export-modal.modpack-name-placeholder": {
"message": "ชื่อแพ็กม็อด"
},
+86 -14
View File
@@ -11,27 +11,81 @@
"app.action-bar.hide-more-running-instances": {
"message": "Diğer çalışan kurulumları gizle"
},
"app.action-bar.install.copied-details": {
"message": "Kopyalandı"
},
"app.action-bar.install.copy-details": {
"message": "Detayları kopyala"
},
"app.action-bar.install.dismiss": {
"message": "Reddet"
},
"app.action-bar.install.failed": {
"message": "Yükleme yapılamadı"
},
"app.action-bar.install.failed-app-closed": {
"message": "Yükleme uygulama kapandığından yapılamadı."
},
"app.action-bar.install.failed-network": {
"message": "Yükleme ağ hatasından dolayı yapılamadı."
},
"app.action-bar.install.failed-unknown": {
"message": "Yükleme bilinmeyen bir hatadan yapılamadı."
},
"app.action-bar.install.open-instance": {
"message": "Kurulumu aç"
},
"app.action-bar.install.retry": {
"message": "Yeniden Dene"
},
"app.action-bar.install.summary.app-closing": {
"message": "Uygulama kapatıldığı için iptal edildi"
},
"app.action-bar.install.summary.bad-modpack-file": {
"message": "Mod paketi okunamadı"
},
"app.action-bar.install.summary.canceled": {
"message": "İptal edildi"
},
"app.action-bar.install.summary.cleanup-incomplete": {
"message": "Temizleme bitmedi"
},
"app.action-bar.install.summary.content-download-failed": {
"message": "Dosyalar indirilemedi"
},
"app.action-bar.install.summary.corrupt-download": {
"message": "İndirilmiş dosya bozuk"
},
"app.action-bar.install.summary.could-not-save-files": {
"message": "Dosyalar kaydedilemedi"
},
"app.action-bar.install.summary.download-failed": {
"message": "İndirme tamamlanamadı"
},
"app.action-bar.install.summary.instance-not-found": {
"message": "Kurulum bulunamadı"
},
"app.action-bar.install.summary.invalid-file-path": {
"message": "Dosya yolu geçersiz"
},
"app.action-bar.install.summary.invalid-modpack": {
"message": "Mod paketi verileri geçersiz"
},
"app.action-bar.install.summary.invalid-modpack-files": {
"message": "Mod paketi dosyaları geçersiz meta verilere sahip"
},
"app.action-bar.install.summary.java-setup-failed": {
"message": "Java kurulumu bitirilemedi"
},
"app.action-bar.install.summary.loader-setup-failed": {
"message": "Yükleyici kurulumu başarısız oldu"
},
"app.action-bar.install.summary.local-data-error": {
"message": "Yerel veriler güncellenemedi"
},
"app.action-bar.install.summary.minecraft-setup-failed": {
"message": "Minecraft kurulumu başarısız oldu"
},
"app.action-bar.install.summary.modrinth-unreachable": {
"message": "Modrinth'e erişilemiyor"
},
"app.action-bar.install.summary.no-write-permission": {
"message": "Yazmak için yetkiniz yok"
},
"app.action-bar.install.summary.pack-download-failed": {
"message": "Paket indirilemedi"
},
"app.action-bar.install.summary.unexpected-error": {
"message": "Bir sorun oluştu"
},
"app.action-bar.install.unknown-instance": {
"message": "Bilinmeyen kurulum"
},
@@ -204,7 +258,7 @@
"message": "Mod paketini dışa aktar"
},
"app.export-modal.modpack-name-label": {
"message": "Mod Paketi Adı"
"message": "Mod paketi adı"
},
"app.export-modal.modpack-name-placeholder": {
"message": "Mod paketi adı"
@@ -231,7 +285,7 @@
"message": "Tamamlanıyor"
},
"app.install.phase.preparing_instance": {
"message": "Kurulum hazırlanıyor"
"message": "İndirmek için sıraya alındı"
},
"app.install.phase.preparing_java": {
"message": "Java Hazırlanıyor"
@@ -410,12 +464,27 @@
"app.project.install-button.already-installed": {
"message": "Bu proje zaten kurulu"
},
"app.project.install-button.switch-version": {
"message": "Sürüm değiştir"
},
"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"
},
"app.project.version.download-in-browser": {
"message": "Tarayıcıdan indir"
},
"app.project.version.installing": {
"message": "Yükleniyor"
},
"app.project.versions.already-installed": {
"message": "Zaten yüklenmiş"
},
"app.settings.developer-mode-enabled": {
"message": "Geliştirici modu açıldı."
},
@@ -536,6 +605,9 @@
"app.skins.section.builders-and-biomes": {
"message": "İnşaatçılar & Biyomlar"
},
"app.skins.section.chaos-cubed": {
"message": "Kaos Küpü"
},
"app.skins.section.chase-the-skies": {
"message": "Gökyüzünü kovala"
},
+141 -3
View File
@@ -11,6 +11,81 @@
"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": "Відхилити"
},
"app.action-bar.install.open-instance": {
"message": "Відкрити екземпляр"
},
"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.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": "Невідомий екземпляр"
},
"app.action-bar.make-primary-instance": {
"message": "Зробити основним профілем"
},
@@ -101,6 +176,12 @@
"app.appearance-settings.show-play-time.title": {
"message": "Час гри"
},
"app.appearance-settings.skip-non-essential-warnings.description": {
"message": "Автоматично пропускає підтвердження з низьким рівнем ризику, як-от повторне встановлення збірки модів, видалення звичайного вмісту, масові оновлення, відвʼязування збірки модів та записи на відновлення. Небезпечні попередження все одно зображуватимуться."
},
"app.appearance-settings.skip-non-essential-warnings.title": {
"message": "Пропустити несуттєві попередження"
},
"app.appearance-settings.toggle-sidebar.description": {
"message": "Умикає можливість перемикання бокової панелі."
},
@@ -147,7 +228,7 @@
"message": "Дослідити сервера"
},
"app.browse.hide-added-servers": {
"message": "Сховати наявні сервери"
"message": "Приховати вже додані сервери"
},
"app.browse.project-type.modpacks": {
"message": "Збірки"
@@ -171,7 +252,7 @@
"message": "Експортувати збірку"
},
"app.export-modal.modpack-name-label": {
"message": "Назва збірки"
"message": "Назва збірки модів"
},
"app.export-modal.modpack-name-placeholder": {
"message": "Назва збірки"
@@ -182,6 +263,60 @@
"app.export-modal.version-number-placeholder": {
"message": "1.0.0"
},
"app.install.phase.downloading_content": {
"message": "Завантаження вмісту"
},
"app.install.phase.downloading_minecraft": {
"message": "Завантаження Minecraft"
},
"app.install.phase.downloading_pack_file": {
"message": "Завантаження файлу пакета"
},
"app.install.phase.extracting_overrides": {
"message": "Розпакування файлів заміщення"
},
"app.install.phase.finalizing": {
"message": "Завершення"
},
"app.install.phase.preparing_instance": {
"message": "У черзі на встановлення"
},
"app.install.phase.preparing_java": {
"message": "Підготовка Java"
},
"app.install.phase.preparing_java.downloading": {
"message": "Завантаження Java {version}"
},
"app.install.phase.preparing_java.extracting": {
"message": "Розпакування Java {version}"
},
"app.install.phase.preparing_java.fetching-metadata": {
"message": "Отримання Java {version}"
},
"app.install.phase.preparing_java.resolving": {
"message": "Підготовка Java {version}"
},
"app.install.phase.preparing_java.validating": {
"message": "Перевірка Java {version}"
},
"app.install.phase.reading_pack_manifest": {
"message": "Читання маніфесту пакета"
},
"app.install.phase.resolving_loader": {
"message": "Визначення завантажувача"
},
"app.install.phase.resolving_minecraft": {
"message": "Узгодження версії Minecraft"
},
"app.install.phase.resolving_pack": {
"message": "Узгодження вмісту"
},
"app.install.phase.rolling_back": {
"message": "Повернення"
},
"app.install.phase.running_loader_processors": {
"message": "Запуск процесорів завантажувача"
},
"app.instance.confirm-delete.admonition-body": {
"message": "Усі дані вашого профілю будуть видалені назавжди, включно з вашими світами, налаштуваннями та всім установленим умістом."
},
@@ -206,6 +341,9 @@
"app.instance.modpack-already-installed.instance": {
"message": "Профіль"
},
"app.instance.mods.bulk-update.downloading-projects": {
"message": "Завантаження проєктів: {current, number}/{total, number}..."
},
"app.instance.mods.content-type-project": {
"message": "проєкт"
},
@@ -618,7 +756,7 @@
"message": "Додавання файлів ({completed}/{total})"
},
"instance.files.save-as": {
"message": "Зберегти як"
"message": "Зберегти як..."
},
"instance.server-modal.address": {
"message": "Адреса"
@@ -146,9 +146,6 @@
"app.browse.discover-servers": {
"message": "Khám phá máy chủ"
},
"app.browse.hide-added-servers": {
"message": "Ẩn các server đã thêm"
},
"app.browse.project-type.modpacks": {
"message": "Modpack"
},
@@ -170,9 +167,6 @@
"app.export-modal.header": {
"message": "Xuất modpack"
},
"app.export-modal.modpack-name-label": {
"message": "Tên modpack"
},
"app.export-modal.modpack-name-placeholder": {
"message": "Tên modpack"
},
+87 -15
View File
@@ -11,27 +11,81 @@
"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": "忽略"
},
"app.action-bar.install.failed": {
"message": "安装失败"
},
"app.action-bar.install.failed-app-closed": {
"message": "由于应用关闭,安装失败。"
},
"app.action-bar.install.failed-network": {
"message": "由于网络错误,安装失败。"
},
"app.action-bar.install.failed-unknown": {
"message": "由于未知错误,安装失败。"
},
"app.action-bar.install.open-instance": {
"message": "打开实例"
},
"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": "未知的实例"
},
@@ -231,7 +285,7 @@
"message": "完成"
},
"app.install.phase.preparing_instance": {
"message": "准备实例"
"message": "已加入安装队列"
},
"app.install.phase.preparing_java": {
"message": "正在准备 Java"
@@ -339,7 +393,7 @@
"message": "正在删除 {name}"
},
"app.instance.worlds.filter-modded": {
"message": "模组适配"
"message": "模组"
},
"app.instance.worlds.filter-offline": {
"message": "离线"
@@ -410,12 +464,27 @@
"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.project.version.all-versions": {
"message": "所有版本"
},
"app.project.version.download-in-browser": {
"message": "在浏览器中下载"
},
"app.project.version.installing": {
"message": "安装中"
},
"app.project.versions.already-installed": {
"message": "已安装"
},
"app.settings.developer-mode-enabled": {
"message": "开发者模式已启用。"
},
@@ -536,6 +605,9 @@
"app.skins.section.builders-and-biomes": {
"message": "建造者与生物群系"
},
"app.skins.section.chaos-cubed": {
"message": "混沌立方"
},
"app.skins.section.chase-the-skies": {
"message": "追逐天空"
},
@@ -954,7 +1026,7 @@
"message": "复制地址"
},
"instance.worlds.create_shortcut": {
"message": "创建快"
"message": "创建快捷方式"
},
"instance.worlds.dont_show_on_home": {
"message": "在主界面隐藏"
+88 -16
View File
@@ -11,27 +11,81 @@
"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": "忽略"
},
"app.action-bar.install.failed": {
"message": "無法安裝"
},
"app.action-bar.install.failed-app-closed": {
"message": "由於應用程式關閉,無法安裝。"
},
"app.action-bar.install.failed-network": {
"message": "由於網路錯誤,無法安裝。"
},
"app.action-bar.install.failed-unknown": {
"message": "由於未知錯誤,無法安裝。"
},
"app.action-bar.install.open-instance": {
"message": "開啟實例"
},
"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": "未知的實例"
},
@@ -231,7 +285,7 @@
"message": "即將完成"
},
"app.install.phase.preparing_instance": {
"message": "正在準備實例"
"message": "已排入安裝佇列"
},
"app.install.phase.preparing_java": {
"message": "正在準備 Java"
@@ -333,10 +387,10 @@
"message": "刪除世界"
},
"app.instance.worlds.delete-world-modal.warning-body": {
"message": "世界將會被永久刪除,此操作無法復原。"
"message": "這個世界將會被永久刪除,此操作無法復原。"
},
"app.instance.worlds.delete-world-modal.warning-header": {
"message": "正在刪除 {name}"
"message": "即將刪除 {name}"
},
"app.instance.worlds.filter-modded": {
"message": "模組"
@@ -366,7 +420,7 @@
"message": "這個伺服器將從你的伺服器清單中移除。如果你記得伺服器位址,稍後可以將其重新新增回來。"
},
"app.instance.worlds.remove-server-modal.warning-header": {
"message": "正在移除 {name}"
"message": "即將移除 {name}"
},
"app.instance.worlds.search-worlds-placeholder": {
"message": "搜尋 {count} 個世界..."
@@ -410,12 +464,27 @@
"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.project.version.all-versions": {
"message": "所有版本"
},
"app.project.version.download-in-browser": {
"message": "在瀏覽器中下載"
},
"app.project.version.installing": {
"message": "正在安裝"
},
"app.project.versions.already-installed": {
"message": "已安裝"
},
"app.settings.developer-mode-enabled": {
"message": "開發人員模式已啟用。"
},
@@ -536,6 +605,9 @@
"app.skins.section.builders-and-biomes": {
"message": "建造者&生態域"
},
"app.skins.section.chaos-cubed": {
"message": "混沌立方"
},
"app.skins.section.chase-the-skies": {
"message": "追逐天空"
},
+25 -5
View File
@@ -22,6 +22,8 @@ import {
preferencesDiffer,
provideBrowseManager,
requestInstall,
stripServerRuntimeInstallFilters,
stripServerRuntimeInstallOverrides,
useBrowseSearch,
useDebugLogger,
useVIntl,
@@ -47,6 +49,7 @@ import {
get_installed_project_ids as getInstalledProjectIds,
} from '@/helpers/instance'
import { get_loader_versions as getLoaderManifest } from '@/helpers/metadata'
import { get as getSettings, set as setSettings } from '@/helpers/settings.ts'
import { get_categories, get_game_versions, get_loaders } from '@/helpers/tags'
import { get_instance_worlds } from '@/helpers/worlds'
import { injectContentInstall } from '@/providers/content-install'
@@ -403,7 +406,7 @@ const messages = defineMessages({
},
hideAddedServers: {
id: 'app.browse.hide-added-servers',
defaultMessage: 'Hide already added servers',
defaultMessage: 'Hide servers already added',
},
installingToServer: {
id: 'app.browse.server.installing',
@@ -680,7 +683,6 @@ async function chooseInstanceInstallVersion(
const selectedVersion = getLatestMatchingInstallVersion(
await getInstallProjectVersions(project.project_id),
selectedPreferences,
projectTypeValue,
)
if (!selectedVersion) {
@@ -763,11 +765,15 @@ function getCardActions(
project: projectResult,
contentType,
mode: isModpack ? 'immediate' : 'queue',
selectedFilters: isModpack ? [] : searchState.currentFilters.value,
selectedFilters: isModpack
? []
: stripServerRuntimeInstallFilters(searchState.currentFilters.value),
providedFilters: isModpack ? [] : combinedProvidedFilters.value,
overriddenProvidedFilterTypes: isModpack
? []
: searchState.overriddenProvidedFilterTypes.value,
: stripServerRuntimeInstallOverrides(
searchState.overriddenProvidedFilterTypes.value,
),
targetPreferences: getServerInstallTargetPreferences(contentType),
getProjectVersions: getInstallProjectVersions,
queue: serverInstallQueue,
@@ -878,7 +884,7 @@ async function search(requestParams: string) {
const rawResults = await queryClient.fetchQuery({
queryKey: ['search', 'v3', requestParams],
queryFn: () =>
get_search_results_v3(requestParams) as Promise<{
get_search_results_v3(requestParams, 'must_revalidate') as Promise<{
result: Labrinth.Search.v3.SearchResults & {
hits: (Labrinth.Search.v3.ResultSearchProject & { installed?: boolean })[]
}
@@ -1036,10 +1042,24 @@ function getProjectBrowseQuery() {
}
}
const advancedFiltersCollapsed = computed({
get: () => themeStore.getFeatureFlag('advanced_filters_collapsed'),
set: (value) => {
themeStore.featureFlags['advanced_filters_collapsed'] = value
getSettings()
.then((settings) => {
settings.feature_flags['advanced_filters_collapsed'] = value
return setSettings(settings)
})
.catch(handleError)
},
})
provideBrowseManager({
tags,
projectType,
...searchState,
advancedFiltersCollapsed,
getProjectLink: (result: Labrinth.Search.v2.ResultSearchProject) => ({
path: `/project/${result.project_id ?? result.slug}`,
query: getProjectBrowseQuery(),
+43 -10
View File
@@ -317,6 +317,10 @@ import ContextMenu from '@/components/ui/ContextMenu.vue'
import ExportModal from '@/components/ui/ExportModal.vue'
import InstanceSettingsModal from '@/components/ui/modal/InstanceSettingsModal.vue'
import UpdateToPlayModal from '@/components/ui/modal/UpdateToPlayModal.vue'
import {
fetchCachedServerStatus,
getFreshCachedServerStatus,
} from '@/composables/instances/use-server-status-query'
import { useInstanceConsole } from '@/composables/useInstanceConsole'
import { trackEvent } from '@/helpers/analytics'
import { get_project_v3 } from '@/helpers/cache.js'
@@ -327,7 +331,7 @@ import { type InstanceContentData, loadInstanceContentData } from '@/helpers/ins
import { get_by_instance_id } from '@/helpers/process'
import type { GameInstance } from '@/helpers/types'
import { createInstanceShortcut, showInstanceInFolder } from '@/helpers/utils.js'
import { get_server_status, refreshWorlds } from '@/helpers/worlds'
import { refreshWorlds, type ServerStatus } from '@/helpers/worlds'
import { injectServerInstall } from '@/providers/server-install'
import { handleSevereError } from '@/store/error.js'
import { useBreadcrumbs, useTheming } from '@/store/state'
@@ -372,13 +376,15 @@ const selected = ref<unknown[]>([])
const minecraftServer = computed(() => linkedProjectV3.value?.minecraft_server)
const javaServerPingData = computed(() => linkedProjectV3.value?.minecraft_java_server?.ping?.data)
const statusOnline = computed(() => !!javaServerPingData.value)
const liveServerStatusOnline = ref(false)
const statusOnline = computed(() => liveServerStatusOnline.value || !!javaServerPingData.value)
const recentPlays = computed(
() => linkedProjectV3.value?.minecraft_java_server?.verified_plays_2w ?? undefined,
)
const playersOnline = ref<number | undefined>(undefined)
const ping = ref<number | undefined>(undefined)
const loadingServerPing = ref(false)
const activeInstanceId = ref<string>()
watch(
() => router.currentRoute.value,
@@ -390,6 +396,20 @@ watch(
{ immediate: true },
)
function applyServerStatus(status: ServerStatus) {
playersOnline.value = status.players?.online
ping.value = status.ping
liveServerStatusOnline.value = true
loadingServerPing.value = true
}
function resetServerStatus() {
ping.value = undefined
playersOnline.value = undefined
liveServerStatusOnline.value = false
loadingServerPing.value = false
}
function isContentSubpageRoute(routeName = displayedInstanceRoute.value.name) {
return typeof routeName === 'string' && contentSubpageRouteNames.has(routeName)
}
@@ -398,9 +418,7 @@ async function fetchInstance() {
isServerInstance.value = false
linkedProjectV3.value = undefined
preloadedContent.value = null
ping.value = undefined
playersOnline.value = undefined
loadingServerPing.value = false
resetServerStatus()
const nextInstance = await get(route.params.id as string).catch(handleError)
let nextLinkedProjectV3: Labrinth.Projects.v3.Project | undefined
@@ -429,8 +447,9 @@ async function fetchInstance() {
linkedProjectV3.value = nextLinkedProjectV3
isServerInstance.value = nextIsServerInstance
preloadedContent.value = nextPreloadedContent
activeInstanceId.value = nextInstance?.id
fetchDeferredData()
fetchDeferredData(nextInstance?.id)
if (nextInstance) {
queryClient.prefetchQuery({
@@ -441,18 +460,32 @@ async function fetchInstance() {
}
}
function fetchDeferredData() {
function fetchDeferredData(instanceId?: string) {
const serverAddress = linkedProjectV3.value?.minecraft_java_server?.address
if (isServerInstance.value && serverAddress) {
get_server_status(serverAddress)
const cachedStatus = getFreshCachedServerStatus(queryClient, serverAddress)
if (cachedStatus) {
applyServerStatus(cachedStatus)
} else {
playersOnline.value = undefined
ping.value = undefined
loadingServerPing.value = false
}
fetchCachedServerStatus(queryClient, serverAddress)
.then((status) => {
playersOnline.value = status.players?.online
ping.value = status.ping
if (
activeInstanceId.value !== instanceId ||
linkedProjectV3.value?.minecraft_java_server?.address !== serverAddress
)
return
applyServerStatus(status)
})
.catch((error) => {
console.error(`Failed to fetch server status for ${serverAddress}:`, error)
})
.finally(() => {
if (activeInstanceId.value !== instanceId) return
loadingServerPing.value = true
})
} else {
@@ -1351,7 +1351,9 @@ provideContentManager({
title: item.file_name.replace('.disabled', ''),
icon_url: null,
},
projectLink: item.project?.id ? { path: `/project/${item.project.id}` } : undefined,
projectLink: item.project?.id
? { path: `/project/${item.project.id}`, query: { i: props.instance.id } }
: undefined,
version: item.version ?? {
id: item.file_name,
version_number: formatMessage(commonMessages.unknownLabel),
@@ -1361,6 +1363,7 @@ provideContentManager({
item.project?.id && item.version?.id
? {
path: `/project/${item.project.id}/version/${item.version.id}`,
query: { i: props.instance.id },
}
: undefined,
owner: item.owner
+51 -8
View File
@@ -116,13 +116,27 @@
aria-label="More options"
>
<MoreVerticalIcon aria-hidden="true" />
<template #open-in-browser> <ExternalIcon /> Open in browser </template>
<template #open-in-browser>
<ExternalIcon /> {{ formatMessage(commonMessages.openInBrowserButton) }}
</template>
<template #report> <ReportIcon /> Report </template>
</OverflowMenu>
</ButtonStyled>
</template>
<template v-else #actions>
<ButtonStyled size="large" color="brand">
<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"
@@ -171,7 +185,9 @@
aria-label="More options"
>
<MoreVerticalIcon aria-hidden="true" />
<template #open-in-browser> <ExternalIcon /> Open in browser </template>
<template #open-in-browser>
<ExternalIcon /> {{ formatMessage(commonMessages.openInBrowserButton) }}
</template>
<template #follow> <HeartIcon /> Follow </template>
<template #save> <BookmarkIcon /> Save </template>
<template #report> <ReportIcon /> Report </template>
@@ -286,6 +302,7 @@ import {
SelectedProjectsFloatingBar,
useVIntl,
} from '@modrinth/ui'
import { useQueryClient } from '@tanstack/vue-query'
import { convertFileSrc } from '@tauri-apps/api/core'
import { openUrl } from '@tauri-apps/plugin-opener'
import dayjs from 'dayjs'
@@ -293,8 +310,13 @@ import relativeTime from 'dayjs/plugin/relativeTime'
import { computed, onUnmounted, ref, shallowRef, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { SwapIcon } from '@/assets/icons/index.js'
import ContextMenu from '@/components/ui/ContextMenu.vue'
import InstanceIndicator from '@/components/ui/InstanceIndicator.vue'
import {
fetchCachedServerStatus,
getFreshCachedServerStatus,
} from '@/composables/instances/use-server-status-query'
import {
get_organization,
get_project,
@@ -313,7 +335,7 @@ import {
import { get_loader_versions as getLoaderManifest } from '@/helpers/metadata'
import { get_by_instance_id } from '@/helpers/process'
import { get_categories, get_game_versions, get_loaders } from '@/helpers/tags'
import { getServerAddress, getServerLatency } from '@/helpers/worlds'
import { getServerAddress } from '@/helpers/worlds'
import { injectContentInstall } from '@/providers/content-install'
import { injectServerInstall } from '@/providers/server-install'
import { createServerInstallContent } from '@/providers/setup/server-install-content'
@@ -326,6 +348,7 @@ const { handleError } = injectNotificationManager()
const { install: installVersion } = injectContentInstall()
const route = useRoute()
const router = useRouter()
const queryClient = useQueryClient()
const breadcrumbs = useBreadcrumbs()
const themeStore = useTheming()
const { formatMessage } = useVIntl()
@@ -343,6 +366,10 @@ const messages = defineMessages({
id: 'app.project.install-button.already-installed',
defaultMessage: 'This project is already installed',
},
switchVersion: {
id: 'app.project.install-button.switch-version',
defaultMessage: 'Switch version',
},
})
const { installingServerProjects, playServerProject, showAddServerToInstanceModal } =
@@ -514,6 +541,13 @@ const installButtonTooltip = computed(() => {
return null
})
const showSwitchVersion = computed(() => !!instance.value && installed.value)
const onVersionsPage = computed(() => route.name === 'Versions')
function goToVersions() {
router.push(versionsHref.value)
}
const [allLoaders, allGameVersions] = await Promise.all([
get_loaders().catch(handleError).then(ref),
get_game_versions().catch(handleError).then(ref),
@@ -600,10 +634,19 @@ async function fetchProjectData() {
function fetchDeferredServerData(project) {
const serverAddress = projectV3.value?.minecraft_java_server?.address
if (serverAddress) {
serverPing.value = undefined
getServerLatency(serverAddress)
.then((latency) => {
serverPing.value = latency
const cachedStatus = getFreshCachedServerStatus(queryClient, serverAddress)
if (cachedStatus) {
serverPing.value = cachedStatus.ping
serverStatusOnline.value = true
} else {
serverPing.value = undefined
}
fetchCachedServerStatus(queryClient, serverAddress)
.then((status) => {
if (projectV3.value?.minecraft_java_server?.address !== serverAddress) return
serverPing.value = status.ping
serverStatusOnline.value = true
})
.catch((error) => {
console.error(`Failed to ping server ${serverAddress}:`, error)
+165 -429
View File
@@ -1,259 +1,152 @@
<template>
<div>
<Card>
<Breadcrumbs
:current-title="version.name"
:link-stack="[
{
href: buildProjectHref(`/project/${route.params.id}/versions`),
label: 'Versions',
},
]"
/>
<div class="version-title">
<h2>{{ version.name }}</h2>
</div>
<div class="button-group">
<div class="flex flex-col">
<router-link
class="mb-4 flex w-fit items-center gap-2 rounded-lg px-2 py-0.5 pl-0 text-link"
:to="buildProjectHref(`/project/${route.params.id}/versions`)"
>
<ChevronLeftIcon class="shrink-0" /> {{ formatMessage(messages.allVersions) }}
</router-link>
<VersionPage
v-if="version"
:version="version"
:enrichment="enrichment"
:enrichment-loading="enrichmentLoading"
:members="members"
:dependency-link-creator="createDependencyLink"
>
<template #headerActions>
<ButtonStyled color="brand">
<button
:disabled="installing || (installed && installedVersion === version.id)"
@click="() => install(version.id)"
@click="() => version && install(version.id)"
>
<DownloadIcon v-if="!installed" />
<SwapIcon v-else-if="installedVersion !== version.id" />
<CheckIcon v-else />
{{
installing
? 'Installing...'
? formatMessage(messages.installing)
: installed && installedVersion === version.id
? 'Installed'
: 'Install'
? formatMessage(commonMessages.installedLabel)
: installed
? formatMessage(commonMessages.switchToVersionButton)
: formatMessage(commonMessages.installButton)
}}
</button>
</ButtonStyled>
<ButtonStyled>
<button>
<ReportIcon />
Report
</button>
</ButtonStyled>
<ButtonStyled>
<a
:href="`https://modrinth.com/mod/${route.params.id}/version/${route.params.version}`"
rel="external"
<ButtonStyled type="outlined" circular>
<OverflowMenu
v-tooltip="formatMessage(commonMessages.moreOptionsButton)"
:options="[
{
id: 'open-in-browser',
link: `https://modrinth.com/${project.project_type}/${project.slug}/version/${version.id}`,
external: true,
},
{
id: 'report',
color: 'red',
hoverFilled: true,
link: `https://modrinth.com/report?item=version&itemID=${version.id}`,
external: true,
},
]"
aria-label="More options"
>
Modrinth website
<ExternalIcon />
<MoreVerticalIcon aria-hidden="true" />
<template #open-in-browser>
<ExternalIcon aria-hidden="true" />
{{ formatMessage(commonMessages.openInBrowserButton) }}
</template>
<template #report>
<ReportIcon aria-hidden="true" /> {{ formatMessage(commonMessages.reportButton) }}
</template>
</OverflowMenu>
</ButtonStyled>
</template>
<template #supplementaryResourceActions="{ file }">
<ButtonStyled>
<a :href="file.url" :download="file.filename" target="_blank">
<DownloadIcon aria-hidden="true" />
{{ formatMessage(messages.downloadInBrowser) }}
</a>
</ButtonStyled>
</div>
</Card>
<div class="version-container">
<div class="description-cards">
<Card>
<h3 class="card-title">Changelog</h3>
<div class="markdown-body" v-html="renderString(version.changelog ?? '')" />
</Card>
<Card>
<h3 class="card-title">Files</h3>
<Card
v-for="file in version.files"
:key="file.id"
:class="{ primary: file.primary }"
class="file"
>
<span class="label">
<FileIcon />
<span>
<span class="title">
{{ file.filename }}
</span>
({{ formatBytes(file.size) }})
<span v-if="file.primary" class="primary-label"> Primary </span>
</span>
</span>
<ButtonStyled v-if="project.project_type !== 'modpack' || file.primary" color="brand">
<button class="download" :disabled="installed" @click="() => install(version.id)">
<DownloadIcon v-if="!installed" />
<CheckIcon v-else />
{{ installed ? 'Installed' : 'Install' }}
</button>
</ButtonStyled>
</Card>
</Card>
<Card v-if="displayDependencies.length > 0">
<h2>Dependencies</h2>
<div v-for="dependency in displayDependencies" :key="dependency.title">
<router-link v-if="dependency.link" class="btn dependency" :to="dependency.link">
<Avatar size="sm" :src="dependency.icon" />
<div>
<span class="title"> {{ dependency.title }} </span> <br />
<span> {{ dependency.subtitle }} </span>
</div>
</router-link>
<div v-else class="dependency disabled" disabled="">
<Avatar size="sm" :src="dependency.icon" />
<div class="text">
<div class="title">{{ dependency.title }}</div>
<div>{{ dependency.subtitle }}</div>
</div>
</div>
</div>
</Card>
</div>
<Card class="metadata-card">
<h3 class="card-title">Metadata</h3>
<div class="metadata">
<div class="metadata-item">
<span class="metadata-label">Release Channel</span>
<span class="metadata-value"
><Badge
:color="releaseColor(version.version_type)"
:type="
version.version_type.charAt(0).toUpperCase() + version.version_type.slice(1)
"
/></span>
</div>
<div class="metadata-item">
<span class="metadata-label">Version Number</span>
<span class="metadata-value">{{ version.version_number }}</span>
</div>
<div class="metadata-item">
<span class="metadata-label">Loaders</span>
<span class="metadata-value">{{
version.loaders
.map((loader) => loader.charAt(0).toUpperCase() + loader.slice(1))
.join(', ')
}}</span>
</div>
<div class="metadata-item">
<span class="metadata-label">Game Versions</span>
<span class="metadata-value"> {{ version.game_versions.join(', ') }} </span>
</div>
<div class="metadata-item">
<span class="metadata-label">Downloads</span>
<span class="metadata-value">{{ version.downloads }}</span>
</div>
<div class="metadata-item">
<span class="metadata-label">Publication Date</span>
<span class="metadata-value">
{{ formatDateTime(version.date_published) }}
</span>
</div>
<div v-if="author" class="metadata-item">
<span class="metadata-label">Author</span>
<a
:href="`https://modrinth.com/user/${author.user.username}`"
rel="external"
class="metadata-value btn author"
>
<Avatar size="sm" :src="author.user.avatar_url" circle />
<span>
<strong>
{{ author.user.username }}
</strong>
<br />
{{ author.role }}
</span>
</a>
</div>
<div class="metadata-item">
<span class="metadata-label">Version ID</span>
<span class="metadata-value"><CopyCode class="copycode" :text="version.id" /></span>
</div>
</div>
</Card>
</div>
</template>
</VersionPage>
</div>
</template>
<script setup>
import { CheckIcon, DownloadIcon, ExternalIcon, FileIcon, ReportIcon } from '@modrinth/assets'
<script setup lang="ts">
import type { Labrinth } from '@modrinth/api-client'
import {
CheckIcon,
ChevronLeftIcon,
DownloadIcon,
ExternalIcon,
MoreVerticalIcon,
ReportIcon,
} from '@modrinth/assets'
import {
Avatar,
Badge,
Breadcrumbs,
ButtonStyled,
Card,
CopyCode,
useFormatBytes,
useFormatDateTime,
commonMessages,
defineMessages,
type DependencyContext,
OverflowMenu,
useVIntl,
VersionPage,
} from '@modrinth/ui'
import { renderString } from '@modrinth/utils'
import { computed, ref, watch } from 'vue'
import { ref, watch } from 'vue'
import { useRoute } from 'vue-router'
import { SwapIcon } from '@/assets/icons'
import { get_project_many, get_version_many } from '@/helpers/cache.js'
import { releaseColor } from '@/helpers/utils'
import { useBreadcrumbs } from '@/store/breadcrumbs'
const formatDateTime = useFormatDateTime({
timeStyle: 'short',
dateStyle: 'long',
})
const formatBytes = useFormatBytes()
const { formatMessage } = useVIntl()
const breadcrumbs = useBreadcrumbs()
const route = useRoute()
const props = defineProps({
project: {
type: Object,
required: true,
},
versions: {
type: Array,
required: true,
},
members: {
type: Array,
required: true,
},
install: {
type: Function,
required: true,
},
installed: {
type: Boolean,
required: true,
const messages = defineMessages({
allVersions: {
id: 'app.project.version.all-versions',
defaultMessage: 'All versions',
},
installing: {
type: Boolean,
required: true,
id: 'app.project.version.installing',
defaultMessage: 'Installing',
},
installedVersion: {
type: String,
required: true,
downloadInBrowser: {
id: 'app.project.version.download-in-browser',
defaultMessage: 'Download in browser',
},
})
const breadcrumbs = useBreadcrumbs()
const route = useRoute()
const props = defineProps<{
project: Labrinth.Projects.v2.Project
versions: Labrinth.Versions.v3.Version[]
members: Labrinth.Projects.v3.TeamMember[]
install: (version: string | null) => void
installed: boolean
installing: boolean
installedVersion: string
}>()
const version = ref(props.versions.find((version) => version.id === route.params.version))
breadcrumbs.setName('Version', version.value.name)
if (version.value) {
breadcrumbs.setName('Version', version.value.name)
}
watch(
() => props.versions,
async () => {
if (route.params.version) {
version.value = props.versions.find((version) => version.id === route.params.version)
await refreshDisplayDependencies()
breadcrumbs.setName('Version', version.value.name)
}
},
)
const enrichment = ref<Labrinth.Projects.v2.DependencyInfo | undefined>(undefined)
const enrichmentLoading = ref(false)
const author = computed(() =>
props.members ? props.members.find((member) => member.user.id === version.value.author_id) : null,
)
const displayDependencies = ref({})
function buildProjectHref(path) {
function buildProjectHref(path: string): string {
const params = new URLSearchParams()
for (const [key, val] of Object.entries(route.query)) {
if (Array.isArray(val)) {
for (const v of val) params.append(key, v)
for (const v of val) {
if (v != null) params.append(key, v)
}
} else if (val) {
params.append(key, String(val))
}
@@ -262,222 +155,65 @@ function buildProjectHref(path) {
return qs ? `${path}?${qs}` : path
}
async function refreshDisplayDependencies() {
const projectIds = new Set()
const versionIds = new Set()
if (version.value.dependencies) {
for (const dependency of version.value.dependencies) {
if (dependency.project_id) {
projectIds.add(dependency.project_id)
}
if (dependency.version_id) {
versionIds.add(dependency.version_id)
}
}
function createDependencyLink(context: DependencyContext): string | undefined {
if (context.version) {
return buildProjectHref(`/project/${context.version.project_id}/version/${context.version.id}`)
}
const [projectDeps, versionDeps] = await Promise.all([
get_project_many([...projectIds]),
get_version_many([...versionIds]),
])
const dependencies = {
projects: projectDeps,
versions: versionDeps,
if (context.project) {
return buildProjectHref(`/project/${context.project.id}`)
}
displayDependencies.value = version.value.dependencies.map((dependency) => {
const version = dependencies.versions.find((obj) => obj.id === dependency.version_id)
if (version) {
const project = dependencies.projects.find(
(obj) => obj.id === version.project_id || obj.id === dependency.project_id,
)
return {
icon: project?.icon_url,
title: project?.title || project?.name,
subtitle: `Version ${version.version_number} is ${dependency.dependency_type}`,
link: buildProjectHref(`/project/${project.slug}/version/${version.id}`),
}
} else {
const project = dependencies.projects.find((obj) => obj.id === dependency.project_id)
if (project) {
return {
icon: project?.icon_url,
title: project?.title || project?.name,
subtitle: `${dependency.dependency_type}`,
link: buildProjectHref(`/project/${project.slug}`),
}
} else {
return {
icon: null,
title: dependency.file_name,
subtitle: `Added via overrides`,
link: null,
}
}
}
})
return undefined
}
await refreshDisplayDependencies()
async function refreshEnrichment() {
if (!version.value) return
const projectIds = new Set<string>()
const versionIds = new Set<string>()
for (const dependency of version.value.dependencies ?? []) {
if (dependency.project_id) {
projectIds.add(dependency.project_id)
}
if (dependency.version_id) {
versionIds.add(dependency.version_id)
}
}
if (projectIds.size === 0 && versionIds.size === 0) {
enrichment.value = { projects: [], versions: [] }
return
}
enrichmentLoading.value = true
try {
const versionResults = versionIds.size > 0 ? await get_version_many([...versionIds]) : []
for (const dependencyVersion of versionResults ?? []) {
if (dependencyVersion.project_id) {
projectIds.add(dependencyVersion.project_id)
}
}
const projectResults = projectIds.size > 0 ? await get_project_many([...projectIds]) : []
enrichment.value = {
projects: projectResults ?? [],
versions: versionResults ?? [],
}
} finally {
enrichmentLoading.value = false
}
}
watch(
() => props.versions,
async () => {
if (route.params.version) {
version.value = props.versions.find((v) => v.id === route.params.version)
if (version.value) {
breadcrumbs.setName('Version', version.value.name)
}
await refreshEnrichment()
}
},
)
await refreshEnrichment()
</script>
<style scoped lang="scss">
.version-container {
display: flex;
flex-direction: row;
gap: 1rem;
}
.version-title {
margin-bottom: 1rem;
h2 {
font-size: var(--font-size-2xl);
font-weight: 700;
color: var(--color-contrast);
margin: 0;
}
}
.dependency {
display: flex;
padding: 0.5rem 1rem 0.5rem 0.5rem;
gap: 0.5rem;
background: var(--color-raised-bg);
color: var(--color-base);
width: 100%;
.title {
font-weight: bolder;
}
:deep(svg) {
margin-right: 0 !important;
}
}
.file {
display: flex;
flex-direction: row;
gap: 0.5rem;
background: var(--color-button-bg);
color: var(--color-base);
padding: 0.5rem 1rem;
.download {
margin-left: auto;
background-color: var(--color-raised-bg);
}
.label {
display: flex;
margin: auto 0 auto;
gap: 0.5rem;
.title {
font-weight: bolder;
word-break: break-all;
}
svg {
min-width: 1.1rem;
min-height: 1.1rem;
width: 1.1rem;
height: 1.1rem;
margin: auto 0;
}
.primary-label {
font-style: italic;
}
}
}
.primary {
background: var(--color-brand-highlight);
color: var(--color-contrast);
}
.button-group {
display: flex;
flex-wrap: wrap;
flex-direction: row;
gap: 0.5rem;
}
.card-title {
font-size: var(--font-size-lg);
color: var(--color-contrast);
margin: 0 0 0.5rem;
}
.description-cards {
width: 100%;
}
.metadata-card {
width: 20rem;
height: min-content;
}
.metadata {
display: flex;
flex-direction: column;
flex-wrap: wrap;
gap: 1rem;
.metadata-item {
display: flex;
flex-direction: column;
gap: 0.5rem;
.metadata-label {
font-weight: bold;
}
}
}
.author {
display: flex;
flex-direction: row;
gap: 0.5rem;
align-items: center;
text-decoration: none;
color: var(--color-base);
background: var(--color-raised-bg);
padding: 0.5rem;
width: 100%;
box-shadow: none;
}
.markdown-body {
:deep(hr),
:deep(h1),
:deep(h2),
img {
max-width: max(60rem, 90%) !important;
}
:deep(ul),
:deep(ol) {
margin-left: 2rem;
}
}
.copycode {
border: 0;
color: var(--color-contrast);
}
.disabled {
display: flex;
flex-direction: row;
vertical-align: center;
align-items: center;
cursor: not-allowed;
border-radius: var(--radius-lg);
.text {
filter: brightness(0.5);
}
}
</style>
@@ -9,10 +9,18 @@
:version-link="(version) => buildProjectHref(`/project/${project.id}/version/${version.id}`)"
>
<template #actions="{ version }">
<ButtonStyled circular type="transparent" color="green">
<ButtonStyled
circular
type="transparent"
:color="installed && version.id === installedVersion ? 'standard' : 'green'"
>
<button
v-tooltip="
!installed ? 'Install' : version.id !== installedVersion ? 'Swap version' : ''
!installed
? formatMessage(commonMessages.installButton)
: version.id !== installedVersion
? formatMessage(commonMessages.switchToVersionButton)
: formatMessage(messages.alreadyInstalled)
"
:disabled="installing || (installed && version.id === installedVersion)"
@click.stop="() => install(version.id)"
@@ -45,11 +53,13 @@
<DownloadIcon aria-hidden="true" />
Add to another instance
</template>
<template #open-in-browser> <ExternalIcon /> Open in browser </template>
<template #open-in-browser>
<ExternalIcon /> {{ formatMessage(commonMessages.openInBrowserButton) }}
</template>
</OverflowMenu>
<a
v-else
v-tooltip="`Open in browser`"
v-tooltip="formatMessage(commonMessages.openInBrowserButton)"
:href="`https://modrinth.com/${project.project_type}/${project.slug}/version/${version.id}`"
target="_blank"
>
@@ -65,9 +75,12 @@
import { CheckIcon, DownloadIcon, ExternalIcon, MoreVerticalIcon } from '@modrinth/assets'
import {
ButtonStyled,
commonMessages,
defineMessages,
injectNotificationManager,
OverflowMenu,
ProjectPageVersions,
useVIntl,
} from '@modrinth/ui'
import { ref } from 'vue'
import { useRoute } from 'vue-router'
@@ -76,8 +89,16 @@ import { SwapIcon } from '@/assets/icons/index.js'
import { get_game_versions, get_loaders } from '@/helpers/tags.js'
import { useTheming } from '@/store/theme.ts'
const { formatMessage } = useVIntl()
const themeStore = useTheming()
const messages = defineMessages({
alreadyInstalled: {
id: 'app.project.versions.already-installed',
defaultMessage: 'Already installed',
},
})
defineProps({
project: {
type: Object,
+1
View File
@@ -16,6 +16,7 @@ export const DEFAULT_FEATURE_FLAGS = {
pride_fundraiser: true,
i18n_debug: false,
show_instance_play_time: true,
advanced_filters_collapsed: true,
}
export const THEME_OPTIONS = ['dark', 'light', 'oled', 'system'] as const
+3
View File
@@ -157,6 +157,7 @@ fn main() {
"install_job_retry",
"install_job_cancel",
"install_job_dismiss",
"install_job_support_details",
])
.default_permission(
DefaultPermissionRule::AllowAllCommands,
@@ -276,6 +277,8 @@ fn main() {
"hide_ads_window",
"scroll_ads_window",
"show_ads_window",
"show_ads_consent_overlay",
"hide_ads_consent_overlay",
"record_ads_click",
"open_link",
"get_ads_personalization",
+99 -3
View File
@@ -1,14 +1,16 @@
const MODRINTH_ORIGIN = 'https://modrinth.com'
document.addEventListener(
'click',
function (e) {
window.top.postMessage({ modrinthAdClick: true }, 'https://modrinth.com')
window.top.postMessage({ modrinthAdClick: true }, MODRINTH_ORIGIN)
let target = e.target
while (target != null) {
if (target.matches('a')) {
e.preventDefault()
if (target.href) {
window.top.postMessage({ modrinthOpenUrl: target.href }, 'https://modrinth.com')
window.top.postMessage({ modrinthOpenUrl: target.href }, MODRINTH_ORIGIN)
}
break
}
@@ -19,7 +21,97 @@ document.addEventListener(
)
window.open = (url, target, features) => {
window.top.postMessage({ modrinthOpenUrl: url }, 'https://modrinth.com')
window.top.postMessage({ modrinthOpenUrl: url }, MODRINTH_ORIGIN)
}
let modrinthAdsConsentOverlayShown = false
let modrinthTcfListenerInstalled = false
let modrinthTcfListenerAttempts = 0
function installAdsConsentOverlayStyle() {
if (document.getElementById('modrinth-ads-consent-overlay-style')) {
return
}
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;
}
.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;
}
`
document.documentElement.appendChild(style)
}
function getTauriInvoke() {
return window.__TAURI__?.core?.invoke ?? window.__TAURI_INTERNALS__?.invoke
}
function invokeAdsConsentOverlayCommand(shown) {
const invoke = getTauriInvoke()
if (typeof invoke !== 'function') {
return
}
const command = shown ? 'show_ads_consent_overlay' : 'hide_ads_consent_overlay'
const args = shown ? {} : { dpr: window.devicePixelRatio }
invoke(`plugin:ads|${command}`, args).catch(() => {})
}
function setAdsConsentOverlay(shown) {
if (modrinthAdsConsentOverlayShown === shown) return
modrinthAdsConsentOverlayShown = shown
installAdsConsentOverlayStyle()
document.documentElement.classList.toggle('modrinth-ads-consent-overlay', shown)
if (window.top === window) {
invokeAdsConsentOverlayCommand(shown)
} else {
window.top.postMessage({ modrinthAdsConsentOverlay: shown }, MODRINTH_ORIGIN)
}
}
if (window.top === window) {
window.addEventListener('message', (event) => {
if (
event.origin === MODRINTH_ORIGIN &&
typeof event.data?.modrinthAdsConsentOverlay === 'boolean'
) {
setAdsConsentOverlay(event.data.modrinthAdsConsentOverlay)
}
})
}
function handleTcfConsentEvent(tcData, success) {
if (!success || !tcData) return
if (tcData.eventStatus === 'cmpuishown') {
setAdsConsentOverlay(true)
} else if (tcData.eventStatus === 'useractioncomplete' || tcData.eventStatus === 'tcloaded') {
setAdsConsentOverlay(false)
}
}
// polling to install listener on tcf api
function installTcfConsentListener() {
if (modrinthTcfListenerInstalled) return
if (typeof window.__tcfapi === 'function') {
modrinthTcfListenerInstalled = true
window.__tcfapi('addEventListener', 2, handleTcfConsentEvent)
return
}
if (modrinthTcfListenerAttempts < 60) {
modrinthTcfListenerAttempts += 1
setTimeout(installTcfConsentListener, 500)
}
}
function muteAudioContext() {
@@ -98,9 +190,13 @@ function muteVideos() {
}
document.addEventListener('DOMContentLoaded', () => {
installAdsConsentOverlayStyle()
muteVideos()
muteAudioContext()
installTcfConsentListener()
const observer = new MutationObserver(muteVideos)
observer.observe(document.body, { childList: true, subtree: true })
})
installTcfConsentListener()
+157 -18
View File
@@ -11,12 +11,14 @@ use tokio::sync::RwLock;
pub struct AdsState {
pub shown: bool,
pub modal_shown: bool,
pub consent_overlay_shown: bool,
pub occluded: bool,
pub last_click: Option<Instant>,
pub malicious_origins: HashSet<String>,
}
const AD_LINK: &str = "https://modrinth.com/wrapper/app-ads-cookie";
const APP_TITLE_BAR_HEIGHT: f32 = 48.0;
#[cfg(any(windows, target_os = "macos"))]
pub(super) const OCCLUDED_AREA_THRESHOLD: f64 = 0.5;
#[cfg(not(target_os = "linux"))]
@@ -131,13 +133,16 @@ fn set_webview_visible_for_window<R: Runtime>(
.and_then(|window| window.is_minimized().ok())
.unwrap_or(false);
let is_occluded = app
let (is_occluded, consent_overlay_shown) = app
.state::<RwLock<AdsState>>()
.try_read()
.map(|state| state.occluded)
.unwrap_or(false);
.map(|state| (state.occluded, state.consent_overlay_shown))
.unwrap_or((false, false));
set_webview_visible(webview, visible && !is_minimized && !is_occluded);
set_webview_visible(
webview,
visible && !is_minimized && (!is_occluded || consent_overlay_shown),
);
}
#[cfg(any(windows, target_os = "macos"))]
@@ -195,7 +200,8 @@ async fn sync_ads_occlusion<R: Runtime>(app: &tauri::AppHandle<R>) {
}
state.occluded = occluded;
let visible = state.shown && !state.modal_shown;
let visible =
state.shown && (!state.modal_shown || state.consent_overlay_shown);
drop(state);
if let Some(webview) = app.webviews().get("ads-window") {
@@ -211,21 +217,36 @@ fn sync_webview_visibility_for_main_window<R: Runtime>(
let is_minimized = main_window.is_minimized().unwrap_or(false);
let was = was_minimized.load(Ordering::SeqCst);
let ads_state = if is_minimized {
None
} else {
match app.state::<RwLock<AdsState>>().try_read() {
Ok(state) => Some((
state.shown
&& (!state.modal_shown || state.consent_overlay_shown)
&& (!state.occluded || state.consent_overlay_shown),
state.consent_overlay_shown,
)),
Err(_) => None,
}
};
let ads_visible = ads_state.map(|state| state.0).unwrap_or(false);
if ads_state.map(|state| state.1).unwrap_or(false)
&& let Some(webview) = app.webviews().get("ads-window")
&& let Ok((position, size)) =
get_overlay_webview_position_for_window(main_window)
{
webview.set_position(position).ok();
webview.set_size(size).ok();
}
if is_minimized == was {
return;
}
was_minimized.store(is_minimized, Ordering::SeqCst);
let ads_visible = if is_minimized {
false
} else {
match app.state::<RwLock<AdsState>>().try_read() {
Ok(state) => state.shown && !state.modal_shown && !state.occluded,
Err(_) => false,
}
};
let mut webviews = Vec::new();
let mut seen_webviews = HashSet::new();
@@ -254,6 +275,7 @@ pub fn init<R: Runtime>() -> TauriPlugin<R> {
app.manage(RwLock::new(AdsState {
shown: true,
modal_shown: false,
consent_overlay_shown: false,
occluded: false,
last_click: None,
malicious_origins: HashSet::new(),
@@ -269,7 +291,10 @@ pub fn init<R: Runtime>() -> TauriPlugin<R> {
.state::<RwLock<AdsState>>()
.try_read()
.map(|state| {
state.shown && !state.modal_shown && !state.occluded
state.shown
&& !state.modal_shown
&& !state.consent_overlay_shown
&& !state.occluded
})
.unwrap_or(false);
@@ -332,6 +357,8 @@ pub fn init<R: Runtime>() -> TauriPlugin<R> {
init_ads_window,
hide_ads_window,
show_ads_window,
show_ads_consent_overlay,
hide_ads_consent_overlay,
record_ads_click,
open_link,
get_ads_personalization,
@@ -358,6 +385,42 @@ fn get_webview_position<R: Runtime>(
))
}
fn get_overlay_webview_position_for_window<R: Runtime>(
main_window: &tauri::Window<R>,
) -> crate::api::Result<(PhysicalPosition<f32>, PhysicalSize<f32>)> {
let main_window_size = main_window.outer_size()?;
let title_bar_height =
APP_TITLE_BAR_HEIGHT * main_window.scale_factor()? as f32;
Ok((
PhysicalPosition::new(0.0, title_bar_height),
PhysicalSize::new(
main_window_size.width as f32,
(main_window_size.height as f32 - title_bar_height).max(0.0),
),
))
}
fn get_overlay_webview_position<R: Runtime>(
app: &tauri::AppHandle<R>,
) -> crate::api::Result<(PhysicalPosition<f32>, PhysicalSize<f32>)> {
let main_window = app.get_window("main").unwrap();
get_overlay_webview_position_for_window(&main_window)
}
fn get_device_pixel_ratio<R: Runtime>(
app: &tauri::AppHandle<R>,
dpr: Option<f32>,
) -> f32 {
dpr.unwrap_or_else(|| {
app.get_window("main")
.and_then(|window| window.scale_factor().ok())
.map(|scale_factor| scale_factor as f32)
.unwrap_or(1.0)
})
}
#[tauri::command]
#[cfg(not(target_os = "linux"))]
pub async fn init_ads_window<R: Runtime>(
@@ -374,11 +437,17 @@ pub async fn init_ads_window<R: Runtime>(
state.shown = true;
}
if state.modal_shown {
if state.modal_shown && !state.consent_overlay_shown {
return Ok(());
}
if let Ok((position, size)) = get_webview_position(&app, dpr) {
let layout = if state.consent_overlay_shown {
get_overlay_webview_position(&app)
} else {
get_webview_position(&app, dpr)
};
if let Ok((position, size)) = layout {
let webview = if let Some(webview) = app.webviews().get("ads-window") {
// set both the `hide`/`show` state and `position`,
// to ensure that the webview is actually shown/hidden
@@ -586,7 +655,11 @@ pub async fn show_ads_window<R: Runtime>(
state.modal_shown = false;
if state.shown {
let (position, size) = get_webview_position(&app, dpr)?;
let (position, size) = if state.consent_overlay_shown {
get_overlay_webview_position(&app)?
} else {
get_webview_position(&app, dpr)?
};
// set both the `hide`/`show` state and `position`,
// to ensure that the webview is actually shown/hidden
webview.set_size(size).ok();
@@ -610,8 +683,19 @@ pub async fn hide_ads_window<R: Runtime>(
if reset.unwrap_or(false) {
state.shown = false;
state.consent_overlay_shown = false;
} else {
state.modal_shown = true;
if state.consent_overlay_shown {
let (position, size) = get_overlay_webview_position(&app)?;
webview.set_size(size).ok();
webview.set_position(position).ok();
webview.show().ok();
set_webview_visible_for_window(&app, webview, true);
return Ok(());
}
}
// set both the `hide`/`show` state and `position`,
@@ -625,6 +709,61 @@ pub async fn hide_ads_window<R: Runtime>(
Ok(())
}
#[tauri::command]
pub async fn show_ads_consent_overlay<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;
// dont show for hidden ads so consent events cannot re-enable the webview.
if !state.shown {
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.show().ok();
set_webview_visible_for_window(&app, webview, true);
}
Ok(())
}
#[tauri::command]
pub async fn hide_ads_consent_overlay<R: Runtime>(
app: tauri::AppHandle<R>,
dpr: Option<f32>,
) -> 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, dpr);
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);
} else {
webview
.set_position(PhysicalPosition::new(-1000, -1000))
.ok();
webview.hide().ok();
}
}
Ok(())
}
#[tauri::command]
pub async fn record_ads_click<R: Runtime>(
app: tauri::AppHandle<R>,
+6
View File
@@ -25,6 +25,7 @@ pub fn init<R: tauri::Runtime>() -> tauri::plugin::TauriPlugin<R> {
install_job_retry,
install_job_cancel,
install_job_dismiss,
install_job_support_details,
])
.build()
}
@@ -169,3 +170,8 @@ pub async fn install_job_cancel(job_id: Uuid) -> Result<InstallJobSnapshot> {
pub async fn install_job_dismiss(job_id: Uuid) -> Result<()> {
Ok(theseus::install::dismiss_job(job_id).await?)
}
#[tauri::command]
pub async fn install_job_support_details(job_id: Uuid) -> Result<String> {
Ok(theseus::install::job_support_details(job_id).await?)
}
+2
View File
@@ -27,6 +27,8 @@ pub enum ErrorKind {
inner: Box<s3::error::S3Error>,
file: String,
},
#[error("I/O error: {0}")]
Io(#[from] std::io::Error),
#[error("Error acquiring semaphore: {0}")]
Acquire(#[from] tokio::sync::AcquireError),
#[error("Tracing error: {0}")]
+300 -93
View File
@@ -1,3 +1,19 @@
//! Fetches Fabric-compatible loader metadata.
//!
//! Fabric and Quilt both expose loader profiles for a concrete Minecraft
//! version, but Daedalus publishes templated profiles using
//! `${modrinth.gameVersion}`. A group is a set of Minecraft versions whose
//! upstream loader profiles have the same structure after the concrete
//! Minecraft version is replaced with `${modrinth.gameVersion}`. Fabric uses
//! one universal group, so its public profile paths stay as
//! `versions/{loader}.json`. Quilt has more than one group: versions before
//! 26.x include hashed/intermediary libraries, while 26.x versions do not. For
//! Quilt, Daedalus writes one templated profile per group at
//! `version-group/{group}/loader-version/{loader}`.
use crate::metadata_groups::{
UNIVERSAL_METADATA_GROUP, metadata_group_for_game_version, metadata_groups,
};
use crate::util::{download_file, fetch_json, format_url};
use crate::{
Error, FetchResult, MirrorArtifact, UploadFile, insert_mirrored_artifact,
@@ -64,7 +80,112 @@ async fn fetch(
&semaphore,
)
.await?;
let all_loader_versions = fabric_manifest.loader.clone();
let all_game_versions = fabric_manifest.game.clone();
let metadata_groups = metadata_groups(
mod_loader,
all_game_versions.iter().map(|x| x.version.as_str()),
);
if metadata_groups
.iter()
.any(|group| group.id != UNIVERSAL_METADATA_GROUP)
{
let loaders = all_loader_versions
.iter()
.filter(|x| !skip_versions.contains(&&*x.version))
.collect::<Vec<_>>();
let profile_requests = metadata_groups
.iter()
.flat_map(|group| {
loaders.iter().map(move |loader| ProfileRequest {
group: group.id.to_string(),
loader_profile_template_game_version: group
.loader_profile_template_game_version
.clone(),
game_versions: group.game_versions.clone(),
loader_version: loader.version.clone(),
url: format!(
"{}/versions/loader/{}/{}/profile/json",
meta_url,
group.loader_profile_template_game_version,
loader.version
),
})
})
.collect::<Vec<_>>();
fetch_metadata_profiles(
mod_loader,
format_version,
maven_url,
profile_requests,
&upload_files,
&mirror_artifacts,
&semaphore,
)
.await?;
let version_groups = metadata_groups
.iter()
.map(|group| daedalus::modded::VersionGroup {
id: group.id.to_string(),
loaders: loaders
.iter()
.map(|loader| {
let version_path = metadata_version_path(
mod_loader,
format_version,
&loader.version,
group.id,
);
daedalus::modded::LoaderVersion {
id: loader.version.clone(),
url: format_url(&version_path),
stable: loader.stable,
}
})
.collect(),
})
.collect();
let manifest = daedalus::modded::Manifest {
game_versions: all_game_versions
.into_iter()
.map(|game_version| {
let group = metadata_group_for_game_version(
&metadata_groups,
mod_loader,
&game_version.version,
)
.expect("game version should have a metadata group");
daedalus::modded::Version {
id: game_version.version.clone(),
stable: game_version.stable,
version_group: Some(group.id.to_string()),
loaders: Vec::new(),
}
})
.collect(),
version_groups,
};
upload_files.insert(
format!("{mod_loader}/v{format_version}/manifest.json"),
UploadFile {
file: bytes::Bytes::from(serde_json::to_vec(&manifest)?),
content_type: Some("application/json".to_string()),
},
);
return Ok(FetchResult {
upload_files,
mirror_artifacts,
});
}
// We check Modrinth's manifest to find newly added loader versions,
// intermediary/mapping artifacts, and game versions.
let (
@@ -125,8 +246,6 @@ async fn fetch(
)
};
const DUMMY_GAME_VERSION: &str = "1.21";
if !fetch_intermediary_versions.is_empty() {
for x in &fetch_intermediary_versions {
insert_mirrored_artifact(
@@ -140,94 +259,38 @@ async fn fetch(
}
if !fetch_fabric_versions.is_empty() {
let fabric_version_manifest_urls = fetch_fabric_versions
let universal_group = metadata_groups
.iter()
.map(|x| {
format!(
.find(|group| group.id == UNIVERSAL_METADATA_GROUP)
.expect("fabric metadata should have a universal group");
let profile_requests = fetch_fabric_versions
.iter()
.map(|loader| ProfileRequest {
group: universal_group.id.to_string(),
loader_profile_template_game_version: universal_group
.loader_profile_template_game_version
.clone(),
game_versions: universal_group.game_versions.clone(),
loader_version: loader.version.clone(),
url: format!(
"{}/versions/loader/{}/{}/profile/json",
meta_url, DUMMY_GAME_VERSION, x.version
)
meta_url,
universal_group.loader_profile_template_game_version,
loader.version
),
})
.collect::<Vec<_>>();
let fabric_version_manifests = futures::future::try_join_all(
fabric_version_manifest_urls
.iter()
.map(|x| download_file(x, None, &semaphore)),
fetch_metadata_profiles(
mod_loader,
format_version,
maven_url,
profile_requests,
&upload_files,
&mirror_artifacts,
&semaphore,
)
.await?
.into_iter()
.map(|x| serde_json::from_slice(&x))
.collect::<Result<Vec<PartialVersionInfo>, serde_json::Error>>()?;
let patched_version_manifests = fabric_version_manifests
.into_iter()
.map(|mut version_info| {
for lib in &mut version_info.libraries {
let new_name = lib
.name
.replace(DUMMY_GAME_VERSION, DUMMY_REPLACE_STRING);
// Hard-code: This library is not present on fabric's maven, so we fetch it from MC libraries
if &*lib.name == "net.minecraft:launchwrapper:1.12" {
lib.url = Some(
"https://libraries.minecraft.net/".to_string(),
);
}
// If a library is not intermediary, we add it to mirror artifacts to be mirrored
if lib.name == new_name {
insert_mirrored_artifact(
&new_name,
None,
vec![
lib.url
.clone()
.unwrap_or_else(|| maven_url.to_string()),
],
false,
&mirror_artifacts,
)?;
} else {
lib.name = new_name;
}
lib.url = Some(format_url("maven/"));
}
version_info.id = version_info
.id
.replace(DUMMY_GAME_VERSION, DUMMY_REPLACE_STRING);
version_info.inherits_from = version_info
.inherits_from
.replace(DUMMY_GAME_VERSION, DUMMY_REPLACE_STRING);
Ok(version_info)
})
.collect::<Result<Vec<_>, Error>>()?;
let serialized_version_manifests = patched_version_manifests
.iter()
.map(|x| serde_json::to_vec(x).map(bytes::Bytes::from))
.collect::<Result<Vec<_>, serde_json::Error>>()?;
serialized_version_manifests
.into_iter()
.enumerate()
.for_each(|(index, bytes)| {
let loader = fetch_fabric_versions[index];
let version_path = format!(
"{mod_loader}/v{format_version}/versions/{}.json",
loader.version
);
upload_files.insert(
version_path,
UploadFile {
file: bytes,
content_type: Some("application/json".to_string()),
},
);
});
.await?;
}
if !fetch_fabric_versions.is_empty()
@@ -240,17 +303,20 @@ async fn fetch(
let loader_versions = daedalus::modded::Version {
id: DUMMY_REPLACE_STRING.to_string(),
stable: true,
loaders: fabric_manifest
.loader
.into_iter()
version_group: None,
loaders: all_loader_versions
.iter()
.filter(|x| !skip_versions.contains(&&*x.version))
.map(|x| {
let version_path = format!(
"{mod_loader}/v{format_version}/versions/{}.json",
x.version,
let version_path = metadata_version_path(
mod_loader,
format_version,
&x.version,
UNIVERSAL_METADATA_GROUP,
);
daedalus::modded::LoaderVersion {
id: x.version,
id: x.version.clone(),
url: format_url(&version_path),
stable: x.stable,
}
@@ -260,14 +326,16 @@ async fn fetch(
let manifest = daedalus::modded::Manifest {
game_versions: std::iter::once(loader_versions)
.chain(fabric_manifest.game.into_iter().map(|x| {
.chain(all_game_versions.into_iter().map(|x| {
daedalus::modded::Version {
id: x.version,
stable: x.stable,
version_group: None,
loaders: vec![],
}
}))
.collect(),
version_groups: Vec::new(),
};
upload_files.insert(
@@ -285,6 +353,145 @@ async fn fetch(
})
}
struct ProfileRequest {
group: String,
loader_profile_template_game_version: String,
game_versions: Vec<String>,
loader_version: String,
url: String,
}
fn metadata_version_path(
mod_loader: &str,
format_version: usize,
loader_version: &str,
group: &str,
) -> String {
if group == UNIVERSAL_METADATA_GROUP {
format!("{mod_loader}/v{format_version}/versions/{loader_version}.json")
} else {
format!(
"{mod_loader}/v{format_version}/version-group/{group}/loader-version/{loader_version}"
)
}
}
async fn fetch_metadata_profiles(
mod_loader: &str,
format_version: usize,
maven_url: &str,
profile_requests: Vec<ProfileRequest>,
upload_files: &DashMap<String, UploadFile>,
mirror_artifacts: &DashMap<String, MirrorArtifact>,
semaphore: &Arc<Semaphore>,
) -> Result<(), Error> {
let version_manifests = futures::future::try_join_all(
profile_requests
.iter()
.map(|x| download_file(&x.url, None, semaphore)),
)
.await?
.into_iter()
.map(|x| serde_json::from_slice(&x))
.collect::<Result<Vec<PartialVersionInfo>, serde_json::Error>>()?;
let patched_version_manifests = version_manifests
.into_iter()
.zip(profile_requests.iter())
.map(|(mut version_info, request)| {
patch_version_info(
&mut version_info,
&request.loader_profile_template_game_version,
&request.game_versions,
maven_url,
mirror_artifacts,
)?;
Ok(version_info)
})
.collect::<Result<Vec<_>, Error>>()?;
let serialized_version_manifests = patched_version_manifests
.iter()
.map(|x| serde_json::to_vec(x).map(bytes::Bytes::from))
.collect::<Result<Vec<_>, serde_json::Error>>()?;
serialized_version_manifests
.into_iter()
.zip(profile_requests)
.for_each(|(bytes, request)| {
let version_path = metadata_version_path(
mod_loader,
format_version,
&request.loader_version,
&request.group,
);
upload_files.insert(
version_path,
UploadFile {
file: bytes,
content_type: Some("application/json".to_string()),
},
);
});
Ok(())
}
fn patch_version_info(
version_info: &mut PartialVersionInfo,
game_version: &str,
game_versions: &[String],
maven_url: &str,
mirror_artifacts: &DashMap<String, MirrorArtifact>,
) -> Result<(), Error> {
for lib in &mut version_info.libraries {
let new_name = lib.name.replace(game_version, DUMMY_REPLACE_STRING);
// Hard-code: This library is not present on fabric's maven, so we fetch it from MC libraries
if &*lib.name == "net.minecraft:launchwrapper:1.12" {
lib.url = Some("https://libraries.minecraft.net/".to_string());
}
let source_url =
lib.url.clone().unwrap_or_else(|| maven_url.to_string());
if lib.name == new_name {
insert_mirrored_artifact(
&new_name,
None,
vec![source_url],
false,
mirror_artifacts,
)?;
} else {
for concrete_game_version in game_versions {
let concrete_name =
lib.name.replace(game_version, concrete_game_version);
insert_mirrored_artifact(
&concrete_name,
None,
vec![source_url.clone()],
false,
mirror_artifacts,
)?;
}
lib.name = new_name;
}
lib.url = Some(format_url("maven/"));
}
version_info.id =
version_info.id.replace(game_version, DUMMY_REPLACE_STRING);
version_info.inherits_from = version_info
.inherits_from
.replace(game_version, DUMMY_REPLACE_STRING);
Ok(())
}
#[derive(Deserialize, Debug, Clone)]
struct FabricVersions {
pub loader: Vec<FabricLoaderVersion>,
+58 -20
View File
@@ -12,7 +12,10 @@ use itertools::Itertools;
use serde::Deserialize;
use serde::de::DeserializeOwned;
use std::collections::HashMap;
use std::sync::Arc;
use std::sync::{
Arc,
atomic::{AtomicUsize, Ordering},
};
use tokio::sync::Semaphore;
#[tracing::instrument(skip(semaphore))]
@@ -243,12 +246,45 @@ async fn fetch(
};
if !fetch_versions.is_empty() {
let forge_installers = futures::future::try_join_all(
fetch_versions
.iter()
.map(|x| download_file(&x.installer_url, None, &semaphore)),
)
.await?;
let total_installers = fetch_versions.len();
let downloaded_installers = Arc::new(AtomicUsize::new(0));
tracing::info!(
mod_loader,
total_files = total_installers,
"Downloading loader installers"
);
let forge_installers =
futures::future::try_join_all(fetch_versions.iter().map(|x| {
let downloaded_installers = downloaded_installers.clone();
let semaphore = semaphore.clone();
async move {
let installer =
download_file(&x.installer_url, None, &semaphore)
.await?;
let downloaded = downloaded_installers
.fetch_add(1, Ordering::Relaxed)
+ 1;
if downloaded.is_multiple_of(100)
|| downloaded == total_installers
{
tracing::info!(
mod_loader,
downloaded_files = downloaded,
remaining_files =
total_installers.saturating_sub(downloaded),
total_files = total_installers,
"Downloaded loader installers"
);
}
Ok::<_, Error>(installer)
}
}))
.await?;
#[tracing::instrument(skip(raw, upload_files, mirror_artifacts))]
async fn read_forge_installer(
@@ -761,21 +797,23 @@ async fn fetch(
.into_iter()
.map(|(game_version, loaders)| {
daedalus::modded::Version {
id: game_version,
stable: true,
loaders: loaders
.map(|x| daedalus::modded::LoaderVersion {
url: format_url(&format!(
"{mod_loader}/v{format_version}/versions/{}.json",
x.loader_version
)),
id: x.loader_version,
stable: false,
})
.collect(),
}
id: game_version,
stable: true,
version_group: None,
loaders: loaders
.map(|x| daedalus::modded::LoaderVersion {
url: format_url(&format!(
"{mod_loader}/v{format_version}/versions/{}.json",
x.loader_version
)),
id: x.loader_version,
stable: false,
})
.collect(),
}
})
.collect(),
version_groups: Vec::new(),
};
upload_files.insert(
+208 -41
View File
@@ -1,10 +1,14 @@
use crate::util::{
REQWEST_CLIENT, format_url, upload_file_to_bucket,
upload_url_to_bucket_mirrors,
upload_url_to_bucket_mirrors, write_file_to_local_output,
write_url_to_local_output_mirrors,
};
use daedalus::get_path_from_artifact;
use dashmap::{DashMap, DashSet};
use std::sync::Arc;
use std::sync::{
Arc,
atomic::{AtomicUsize, Ordering},
};
use tokio::sync::Semaphore;
use tracing_error::ErrorLayer;
use tracing_subscriber::{EnvFilter, fmt, prelude::*};
@@ -12,6 +16,7 @@ use tracing_subscriber::{EnvFilter, fmt, prelude::*};
mod error;
mod fabric;
mod forge;
mod metadata_groups;
mod minecraft;
pub mod util;
@@ -44,51 +49,92 @@ async fn main() -> Result<()> {
));
let mut fetch_result = FetchResult::default();
let only_loader = dotenvy::var("DAEDALUS_ONLY").ok();
match minecraft::fetch(semaphore.clone()).await {
Ok(fetched) => merge_fetch_result(&mut fetch_result, fetched),
Err(err) => tracing::warn!(error = %err, "Minecraft fetch failed"),
if should_fetch(only_loader.as_deref(), "minecraft") {
match minecraft::fetch(semaphore.clone()).await {
Ok(fetched) => merge_fetch_result(&mut fetch_result, fetched),
Err(err) => tracing::warn!(error = %err, "Minecraft fetch failed"),
}
}
match fabric::fetch_fabric(semaphore.clone()).await {
Ok(fetched) => merge_fetch_result(&mut fetch_result, fetched),
Err(err) => tracing::warn!(error = %err, "Fabric fetch failed"),
if should_fetch(only_loader.as_deref(), "fabric") {
match fabric::fetch_fabric(semaphore.clone()).await {
Ok(fetched) => merge_fetch_result(&mut fetch_result, fetched),
Err(err) => tracing::warn!(error = %err, "Fabric fetch failed"),
}
}
match fabric::fetch_quilt(semaphore.clone()).await {
Ok(fetched) => merge_fetch_result(&mut fetch_result, fetched),
Err(err) => tracing::warn!(error = %err, "Quilt fetch failed"),
if should_fetch(only_loader.as_deref(), "quilt") {
match fabric::fetch_quilt(semaphore.clone()).await {
Ok(fetched) => merge_fetch_result(&mut fetch_result, fetched),
Err(err) => tracing::warn!(error = %err, "Quilt fetch failed"),
}
}
match forge::fetch_neo(semaphore.clone()).await {
Ok(fetched) => merge_fetch_result(&mut fetch_result, fetched),
Err(err) => tracing::warn!(error = %err, "NeoForge fetch failed"),
if should_fetch(only_loader.as_deref(), "neo") {
match forge::fetch_neo(semaphore.clone()).await {
Ok(fetched) => merge_fetch_result(&mut fetch_result, fetched),
Err(err) => tracing::warn!(error = %err, "NeoForge fetch failed"),
}
}
match forge::fetch_forge(semaphore.clone()).await {
Ok(fetched) => merge_fetch_result(&mut fetch_result, fetched),
Err(err) => tracing::warn!(error = %err, "Forge fetch failed"),
if should_fetch(only_loader.as_deref(), "forge") {
match forge::fetch_forge(semaphore.clone()).await {
Ok(fetched) => merge_fetch_result(&mut fetch_result, fetched),
Err(err) => tracing::warn!(error = %err, "Forge fetch failed"),
}
}
let FetchResult {
upload_files,
mirror_artifacts,
} = fetch_result;
let upload_file_total = upload_files.len();
let mirror_file_total = mirror_artifacts.len();
futures::future::try_join_all(upload_files.iter().map(|entry| {
upload_file_to_bucket(
entry.key().clone(),
entry.value().file.clone(),
entry.value().content_type.clone(),
&semaphore,
)
}))
.await?;
if dotenvy::var("LOCAL_OUTPUT_DIR").is_ok() {
let written_files = Arc::new(AtomicUsize::new(0));
futures::future::try_join_all(mirror_artifacts.iter().map(|entry| {
upload_url_to_bucket_mirrors(
format!("maven/{}", entry.key()),
entry
tracing::info!(
total_files = upload_file_total,
"Writing local metadata files"
);
futures::future::try_join_all(upload_files.iter().map(|entry| {
let path = entry.key().clone();
let file = entry.value().file.clone();
let written_files = written_files.clone();
async move {
write_file_to_local_output(&path, file).await?;
let written = written_files.fetch_add(1, Ordering::Relaxed) + 1;
if written.is_multiple_of(100) || written == upload_file_total {
tracing::info!(
written_files = written,
remaining_files =
upload_file_total.saturating_sub(written),
total_files = upload_file_total,
"Wrote local metadata files"
);
}
Ok::<_, Error>(())
}
}))
.await?;
let written_mirror_files = Arc::new(AtomicUsize::new(0));
tracing::info!(
total_files = mirror_file_total,
"Writing local mirror files"
);
futures::future::try_join_all(mirror_artifacts.iter().map(|entry| {
let path = format!("maven/{}", entry.key());
let mirrors = entry
.value()
.mirrors
.iter()
@@ -99,12 +145,117 @@ async fn main() -> Result<()> {
format!("{}{}", mirror.path, entry.key())
}
})
.collect(),
entry.value().sha1.clone(),
&semaphore,
)
}))
.await?;
.collect();
let sha1 = entry.value().sha1.clone();
let written_mirror_files = written_mirror_files.clone();
let semaphore = semaphore.clone();
async move {
write_url_to_local_output_mirrors(
path, mirrors, sha1, &semaphore,
)
.await?;
let written =
written_mirror_files.fetch_add(1, Ordering::Relaxed) + 1;
if written.is_multiple_of(100) || written == mirror_file_total {
tracing::info!(
written_files = written,
remaining_files =
mirror_file_total.saturating_sub(written),
total_files = mirror_file_total,
"Wrote local mirror files"
);
}
Ok::<_, Error>(())
}
}))
.await?;
} else {
let uploaded_files = Arc::new(AtomicUsize::new(0));
tracing::info!(
total_files = upload_file_total,
"Uploading metadata files"
);
futures::future::try_join_all(upload_files.iter().map(|entry| {
let path = entry.key().clone();
let file = entry.value().file.clone();
let content_type = entry.value().content_type.clone();
let uploaded_files = uploaded_files.clone();
let semaphore = semaphore.clone();
async move {
upload_file_to_bucket(path, file, content_type, &semaphore)
.await?;
let uploaded =
uploaded_files.fetch_add(1, Ordering::Relaxed) + 1;
if uploaded.is_multiple_of(100) || uploaded == upload_file_total
{
tracing::info!(
uploaded_files = uploaded,
remaining_files =
upload_file_total.saturating_sub(uploaded),
total_files = upload_file_total,
"Uploaded metadata files"
);
}
Ok::<_, Error>(())
}
}))
.await?;
let uploaded_mirror_files = Arc::new(AtomicUsize::new(0));
tracing::info!(
total_files = mirror_file_total,
"Uploading mirror files"
);
futures::future::try_join_all(mirror_artifacts.iter().map(|entry| {
let path = format!("maven/{}", entry.key());
let mirrors = entry
.value()
.mirrors
.iter()
.map(|mirror| {
if mirror.entire_url {
mirror.path.clone()
} else {
format!("{}{}", mirror.path, entry.key())
}
})
.collect();
let sha1 = entry.value().sha1.clone();
let uploaded_mirror_files = uploaded_mirror_files.clone();
let semaphore = semaphore.clone();
async move {
upload_url_to_bucket_mirrors(path, mirrors, sha1, &semaphore)
.await?;
let uploaded =
uploaded_mirror_files.fetch_add(1, Ordering::Relaxed) + 1;
if uploaded.is_multiple_of(100) || uploaded == mirror_file_total
{
tracing::info!(
uploaded_files = uploaded,
remaining_files =
mirror_file_total.saturating_sub(uploaded),
total_files = mirror_file_total,
"Uploaded mirror files"
);
}
Ok::<_, Error>(())
}
}))
.await?;
}
if dotenvy::var("CLOUDFLARE_INTEGRATION")
.ok()
@@ -151,6 +302,20 @@ async fn main() -> Result<()> {
Ok(())
}
fn should_fetch(only_loader: Option<&str>, loader: &str) -> bool {
let Some(only_loader) = only_loader else {
return true;
};
only_loader.split(',').any(|entry| {
let entry = entry.trim();
entry.eq_ignore_ascii_case("all")
|| entry.eq_ignore_ascii_case(loader)
|| (loader == "neo" && entry.eq_ignore_ascii_case("neoforge"))
})
}
pub struct UploadFile {
file: bytes::Bytes,
content_type: Option<String>,
@@ -248,11 +413,13 @@ fn check_env_vars() -> bool {
failed |= check_var::<String>("BASE_URL");
failed |= check_var::<String>("S3_ACCESS_TOKEN");
failed |= check_var::<String>("S3_SECRET");
failed |= check_var::<String>("S3_URL");
failed |= check_var::<String>("S3_REGION");
failed |= check_var::<String>("S3_BUCKET_NAME");
if dotenvy::var("LOCAL_OUTPUT_DIR").is_err() {
failed |= check_var::<String>("S3_ACCESS_TOKEN");
failed |= check_var::<String>("S3_SECRET");
failed |= check_var::<String>("S3_URL");
failed |= check_var::<String>("S3_REGION");
failed |= check_var::<String>("S3_BUCKET_NAME");
}
if dotenvy::var("CLOUDFLARE_INTEGRATION")
.ok()
+145
View File
@@ -0,0 +1,145 @@
//! Determines how version info is generated for pairs of game and loader
//! versions.
//!
//! When a user installs a version of the game, they install two things: the
//! game (of some specific version), and a loader (of some other specific
//! version). Each combination of game and loader version requires a specific
//! configuration, like a specific set of libraries that must be downloaded and
//! run along with the game. However, some versions of the game or loader may
//! change configuration requirements without the other version being affected.
//! For example, pre-26.x game versions with Quilt require the Quilt `hashed`
//! libraries to also be downloaded. However, 26.x and later don't require the
//! `hashed` libraries, and don't even have a download for them. The problem is
//! that Quilt loader 0.30.0 can be used for both pre-26.x and 26.x - but our v0
//! manifest files can't differentiate the two. The result is that you either
//! break compatibility for 0.30.0 game versions pre-26.x, or break 0.30.0
//! on 26.x and later.
//!
//! To fix this, v1 introduces the concept of *version groups*: game versions
//! before 26.x are version group v1, and 26.x and later are v2. Then, we
//! parameterize our version info on both version group and loader version,
//! letting us specify the right configuration based on both game version and
//! loader version.
//!
//! Why not parameterize on game version and loader version directly? Most game
//! versions have the same configuration as their surrounding game versions, so
//! we'd end up with many duplicate configurations: the number of game versions
//! multiplied by the number of loader versions.
//!
//! This file lets you configure what game versions are grouped together.
//!
//! Each version group is templated from a specific game version - e.g. game
//! version 1.21 is used as the template file for 1.20, 1.19, etc.
pub const UNIVERSAL_METADATA_GROUP: &str = "universal";
pub const QUILT_LEGACY_METADATA_GROUP: &str = "v1";
pub const QUILT_MODERN_METADATA_GROUP: &str = "v2";
pub struct MetadataGroup {
pub id: &'static str,
/// Minecraft version used to fetch and template this group's loader profiles.
pub loader_profile_template_game_version: String,
pub game_versions: Vec<String>,
}
pub fn metadata_groups<'a>(
mod_loader: &str,
game_versions: impl IntoIterator<Item = &'a str>,
) -> Vec<MetadataGroup> {
// Non-Quilt loaders don't need the concept of version groups, so we just
// make one "universal" group, and template it on 1.21.
if mod_loader != "quilt" {
return vec![MetadataGroup {
id: UNIVERSAL_METADATA_GROUP,
loader_profile_template_game_version: "1.21".to_string(),
game_versions: game_versions
.into_iter()
.map(|x| x.to_string())
.collect(),
}];
}
let game_versions = game_versions.into_iter().collect::<Vec<_>>();
let legacy_game_versions = game_versions
.iter()
.copied()
.filter(|game_version| {
metadata_group_id_for_game_version(mod_loader, game_version)
== QUILT_LEGACY_METADATA_GROUP
})
.collect::<Vec<_>>();
let modern_game_versions = game_versions
.iter()
.copied()
.filter(|game_version| {
metadata_group_id_for_game_version(mod_loader, game_version)
== QUILT_MODERN_METADATA_GROUP
})
.collect::<Vec<_>>();
let mut groups = Vec::new();
if !legacy_game_versions.is_empty() {
groups.push(MetadataGroup {
id: QUILT_LEGACY_METADATA_GROUP,
loader_profile_template_game_version: legacy_game_versions
.iter()
.find(|x| **x == "1.21")
.copied()
.unwrap_or(legacy_game_versions[0])
.to_string(),
game_versions: legacy_game_versions
.iter()
.map(|x| x.to_string())
.collect(),
});
}
if !modern_game_versions.is_empty() {
groups.push(MetadataGroup {
id: QUILT_MODERN_METADATA_GROUP,
loader_profile_template_game_version: modern_game_versions[0]
.to_string(),
game_versions: modern_game_versions
.iter()
.map(|x| x.to_string())
.collect(),
});
}
groups
}
pub fn metadata_group_for_game_version<'a>(
groups: &'a [MetadataGroup],
mod_loader: &str,
game_version: &str,
) -> Option<&'a MetadataGroup> {
let group_id = metadata_group_id_for_game_version(mod_loader, game_version);
groups.iter().find(|group| group.id == group_id)
}
fn metadata_group_id_for_game_version(
mod_loader: &str,
game_version: &str,
) -> &'static str {
if mod_loader == "quilt" && is_modern_quilt_game_version(game_version) {
QUILT_MODERN_METADATA_GROUP
} else if mod_loader == "quilt" {
QUILT_LEGACY_METADATA_GROUP
} else {
UNIVERSAL_METADATA_GROUP
}
}
// Update these Quilt group boundaries if upstream loader profiles gain another
// structural incompatibility between Minecraft versions.
fn is_modern_quilt_game_version(game_version: &str) -> bool {
let major = game_version
.split(['.', 'w'])
.next()
.and_then(|x| x.parse::<usize>().ok());
major.is_some_and(|x| x >= 26)
}
+85 -1
View File
@@ -3,7 +3,11 @@ use bytes::Bytes;
use s3::creds::Credentials;
use s3::{Bucket, Region};
use serde::de::DeserializeOwned;
use std::sync::{Arc, LazyLock};
use std::path::PathBuf;
use std::sync::{
Arc, LazyLock,
atomic::{AtomicUsize, Ordering},
};
use tokio::sync::Semaphore;
static BUCKET: LazyLock<Bucket> = LazyLock::new(|| {
@@ -55,6 +59,8 @@ pub static REQWEST_CLIENT: LazyLock<reqwest::Client> = LazyLock::new(|| {
.unwrap()
});
static DOWNLOADED_FILE_COUNT: AtomicUsize = AtomicUsize::new(0);
#[tracing::instrument(skip(bytes, semaphore))]
pub async fn upload_file_to_bucket(
path: String,
@@ -135,6 +141,74 @@ pub async fn upload_url_to_bucket(
Ok(())
}
pub async fn write_file_to_local_output(
path: &str,
bytes: Bytes,
) -> Result<(), Error> {
let output_path = local_output_path(path)?;
if let Some(parent) = output_path.parent() {
tokio::fs::create_dir_all(parent).await?;
}
tokio::fs::write(output_path, bytes).await?;
Ok(())
}
pub async fn write_url_to_local_output_mirrors(
output_path: String,
mirrors: Vec<String>,
sha1: Option<String>,
semaphore: &Arc<Semaphore>,
) -> Result<(), Error> {
if mirrors.is_empty() {
return Err(ErrorKind::InvalidInput(
"No mirrors provided!".to_string(),
)
.into());
}
for (index, mirror) in mirrors.iter().enumerate() {
let result = write_url_to_local_output(
output_path.clone(),
mirror.clone(),
sha1.clone(),
semaphore,
)
.await;
if result.is_ok() || (result.is_err() && index == (mirrors.len() - 1)) {
return result;
}
}
unreachable!()
}
async fn write_url_to_local_output(
path: String,
url: String,
sha1: Option<String>,
semaphore: &Arc<Semaphore>,
) -> Result<(), Error> {
let data = download_file(&url, sha1.as_deref(), semaphore).await?;
write_file_to_local_output(&path, data).await?;
Ok(())
}
fn local_output_path(path: &str) -> Result<PathBuf, Error> {
let output_dir = dotenvy::var("LOCAL_OUTPUT_DIR").map_err(|_| {
ErrorKind::InvalidInput(
"LOCAL_OUTPUT_DIR is required for local output".to_string(),
)
})?;
Ok(PathBuf::from(output_dir).join(path))
}
#[tracing::instrument(skip(bytes))]
pub async fn sha1_async(bytes: Bytes) -> Result<String, Error> {
let hash = tokio::task::spawn_blocking(move || {
@@ -182,6 +256,16 @@ pub async fn download_file(
}
}
let downloaded = DOWNLOADED_FILE_COUNT
.fetch_add(1, Ordering::Relaxed)
+ 1;
if downloaded.is_multiple_of(100) {
tracing::info!(
downloaded_files = downloaded,
"Downloaded metadata files"
);
}
return Ok(bytes);
} else if attempt <= RETRIES {
continue;
+8 -3
View File
@@ -692,6 +692,12 @@ components:
type: string
description: The ID of the project
example: AABBCCDD
all_project_types:
type: array
items:
type: string
description: All project types across every version of the project, unlike `project_type` which only reflects a version-specific type
example: [mod, plugin, datapack]
author:
type: string
description: The username of the project's author
@@ -748,6 +754,7 @@ components:
- client_side
- server_side
- project_id
- all_project_types
- author
- versions
- follows
@@ -1948,6 +1955,7 @@ paths:
These are the most commonly used facet types:
- `project_type`
- `all_project_types` (matches against every project type across all of the project's versions, not just the primary/version-specific type)
- `categories` (loaders are lumped in with categories in search)
- `versions`
- `client_side`
@@ -1961,11 +1969,8 @@ paths:
- `project_id`
- `license`
- `downloads`
- `color`
- `created_timestamp` (uses Unix timestamp)
- `modified_timestamp` (uses Unix timestamp)
- `date_created` (uses ISO-8601 timestamp)
- `date_modified` (uses ISO-8601 timestamp)
In order to then use these facets, you need a value to filter by, as well as an operation to perform on this value.
The most common operation is `:` (same as `=`), though you can also use `!=`, `>=`, `>`, `<=`, and `<`.
@@ -97,7 +97,7 @@ If you're prepared to contribute by submitting a pull request, ensure you have m
- `cargo fmt --all` has been run.
- `cargo clippy --all-targets` has been run.
- `cargo sqlx prepare` has been run.
- `cargo sqlx prepare -- --tests` has been run.
> Note: If you encounter issues with `sqlx` saying 'no queries found' after running `cargo sqlx prepare`, you may need to ensure the installed version of `sqlx-cli` matches the current version of `sqlx` used [in labrinth](https://github.com/modrinth/labrinth/blob/master/Cargo.toml).
@@ -482,3 +482,8 @@ input {
.button-transparent {
box-shadow: none;
}
.qc-cmp2-close-tooltip {
background-color: transparent;
color: hsl(145, 78%, 28%);
}
@@ -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) {
@@ -566,7 +566,7 @@ export function buildChartDatasets(
? isNoDependentAnalyticsBreakdownValue(dependentProjectId)
? formatMessage(analyticsMessages.noDependentTooltip)
: isUnknownAnalyticsBreakdownValue(dependentProjectId)
? formatMessage(analyticsMessages.unknown)
? formatMessage(analyticsMessages.unknownDependentTooltip)
: formatDependentProjectDatasetTooltip(
versionName,
dependentProjectName,
@@ -92,6 +92,11 @@ export const analyticsMessages = defineMessages({
id: 'analytics.value.no-dependent-tooltip',
defaultMessage: 'Downloaded for reasons other than being a dependency',
},
unknownDependentTooltip: {
id: 'analytics.value.unknown-dependent-tooltip',
defaultMessage:
"There's no metadata to determine which dependent project this download attributes to.",
},
other: {
id: 'analytics.value.other',
defaultMessage: 'Other',
@@ -515,7 +515,7 @@ function getDependentProjectTooltip(row: AnalyticsTableRow) {
return formatMessage(analyticsMessages.noDependentTooltip)
}
if (isUnknownAnalyticsBreakdownValue(row.breakdownValues.dependent_project_download)) {
return formatMessage(analyticsMessages.unknown)
return formatMessage(analyticsMessages.unknownDependentTooltip)
}
const dependencyProjectIds = new Set(row.dependentOnProjectIds)
@@ -0,0 +1,184 @@
<template>
<div
:role="selectable ? 'radio' : undefined"
:aria-checked="selectable ? selected : undefined"
:tabindex="selectable ? 0 : undefined"
class="grid items-center gap-3 rounded-2xl border border-solid px-3 py-3 transition-all"
:class="{
'grid-cols-[min-content_minmax(0,1fr)_min-content]': selectable,
'grid-cols-[minmax(0,1fr)_min-content]': !selectable,
'cursor-pointer border-brand bg-surface-4 text-contrast': selectable && selected,
'cursor-pointer border-surface-5 bg-surface-4 hover:brightness-[115%]':
selectable && !selected,
'border-transparent bg-surface-2': !selectable,
}"
@click="select"
@keydown.enter.self.prevent="select"
@keydown.space.self.prevent="select"
>
<template v-if="selectable">
<RadioButtonCheckedIcon v-if="selected" aria-hidden="true" class="size-5 text-brand" />
<RadioButtonIcon v-else aria-hidden="true" class="size-5 text-secondary" />
</template>
<div class="flex min-w-0 flex-col gap-1">
<div class="flex min-w-0 items-center gap-2">
<nuxt-link
v-tooltip="truncatedTooltip(versionNumberRef, version.version_number)"
:to="`/${project.project_type}/${project.slug || project.id}/version/${version.id}`"
target="_blank"
rel="noopener noreferrer"
class="block min-w-0 text-contrast no-underline hover:underline"
>
<span ref="versionNumberRef" class="block truncate font-semibold">
{{ version.version_number }}
</span>
</nuxt-link>
<VersionChannelTag :channel="version.version_type" class="relative -top-px !py-1" />
</div>
<div class="flex min-w-0 items-center gap-1.5 text-sm text-secondary">
<span v-tooltip="publishedTooltip" class="min-w-0 truncate">
{{ publishedLabel }}
</span>
<div
v-if="primaryFile"
class="h-1.5 w-1.5 flex-shrink-0 rounded-full bg-primary opacity-30"
></div>
<span v-if="primaryFile" class="flex-shrink-0">
{{ primaryFileSizeLabel }}
</span>
</div>
</div>
<ButtonStyled
v-if="primaryFile && showDownload"
:color="color"
:type="type"
:circular="circular"
>
<a
v-tooltip="circular ? formatMessage(messages.download) : null"
:href="primaryFileDownloadUrl"
:download="primaryFile.filename"
:aria-label="
formatMessage(messages.downloadVersion, {
version: version.version_number,
})
"
@click="emit('download')"
>
<DownloadIcon aria-hidden="true" />
<template v-if="!circular">
{{ formatMessage(messages.download) }}
</template>
</a>
</ButtonStyled>
</div>
</template>
<script setup lang="ts">
import type { Labrinth } from '@modrinth/api-client'
import { DownloadIcon, RadioButtonCheckedIcon, RadioButtonIcon } from '@modrinth/assets'
import {
ButtonStyled,
type CdnDownloadReason,
defineMessages,
truncatedTooltip,
useFormatBytes,
useFormatDateTime,
useRelativeTime,
useVIntl,
} from '@modrinth/ui'
import VersionChannelTag from '@modrinth/ui/src/components/version/VersionChannelTag.vue'
import { capitalizeString, type DisplayProjectType } from '@modrinth/utils'
import { computed, ref } from 'vue'
defineOptions({
name: 'CompatibleVersionCard',
})
type DownloadModalProject = Omit<Labrinth.Projects.v2.Project, 'project_type'> & {
project_type: DisplayProjectType
actualProjectType: Labrinth.Projects.v2.ProjectType
}
const props = withDefaults(
defineProps<{
project: DownloadModalProject
version: Labrinth.Versions.v3.Version
downloadReason?: CdnDownloadReason
currentGameVersion?: string | null
currentPlatform?: string | null
color?: 'brand' | 'standard'
type?: 'standard' | 'transparent'
circular?: boolean
selectable?: boolean
selected?: boolean
showDownload?: boolean
}>(),
{
downloadReason: 'standalone',
currentGameVersion: null,
currentPlatform: null,
color: 'brand',
type: 'standard',
circular: false,
selectable: false,
selected: false,
showDownload: true,
},
)
const emit = defineEmits<{
download: []
select: []
}>()
const { createProjectDownloadUrl } = useCdnDownloadContext()
const { formatMessage } = useVIntl()
const formatBytes = useFormatBytes()
const formatDateTime = useFormatDateTime({
timeStyle: 'short',
dateStyle: 'long',
})
const formatRelativeTime = useRelativeTime()
const versionNumberRef = ref<HTMLElement | null>(null)
const primaryFile = computed<Labrinth.Versions.v3.VersionFile | null>(() => {
return props.version.files?.find((file) => file.primary) || props.version.files?.[0] || null
})
const primaryFileDownloadUrl = computed(() => {
if (!primaryFile.value) return '#'
return createProjectDownloadUrl(primaryFile.value.url, {
reason: props.downloadReason,
gameVersion: props.currentGameVersion ?? undefined,
loader: props.currentPlatform ?? undefined,
})
})
const publishedLabel = computed(() =>
capitalizeString(formatRelativeTime(props.version.date_published)),
)
const publishedTooltip = computed(() => formatDateTime(props.version.date_published))
const primaryFileSizeLabel = computed(() => {
if (!primaryFile.value) return ''
return formatBytes(primaryFile.value.size)
})
function select() {
if (!props.selectable) return
emit('select')
}
const messages = defineMessages({
downloadVersion: {
id: 'project.download.download-version',
defaultMessage: 'Download {version}',
},
download: {
id: 'project.download.download',
defaultMessage: 'Download',
},
})
</script>
@@ -0,0 +1,111 @@
<template>
<div v-if="downloadRows.length > 0 || recommendedRows.length > 0" class="flex flex-col gap-4">
<div v-if="downloadRows.length > 0" class="flex flex-col gap-2.5">
<div v-if="showTitle" class="flex flex-wrap items-center justify-between gap-2">
<h3 class="m-0 flex items-center gap-1.5 text-base font-semibold text-contrast">
{{ formatMessage(messages.dependenciesTitle) }}
<InfoIcon
v-if="duplicateDependencyRowsHidden"
v-tooltip="formatMessage(messages.duplicateDependenciesHidden)"
aria-hidden="true"
class="size-4 text-secondary"
/>
</h3>
</div>
<Admonition v-if="requiredResourcePackAdmonitionVisible" type="info">
<IntlFormatted :message-id="messages.requiredResourcePackAdmonition">
<template #folder>
<code class="text-sm">resourcepacks</code>
</template>
</IntlFormatted>
</Admonition>
<Admonition v-if="dependencyResourcePackAdmonitionVisible" type="info">
<IntlFormatted :message-id="messages.dependencyResourcePackAdmonition">
<template #folder>
<code class="text-sm">resourcepacks</code>
</template>
</IntlFormatted>
</Admonition>
<div class="rounded-2xl bg-surface-2 p-2 pl-4 pr-3">
<DownloadDependency
v-for="dependency in downloadRows"
:key="dependency.key"
:dependency="dependency"
@download="emit('download')"
/>
</div>
</div>
<div v-if="recommendedRows.length > 0" class="flex flex-col gap-2.5">
<h3 class="m-0 text-base font-semibold text-contrast">
{{ formatMessage(messages.recommendedTitle) }}
</h3>
<div class="rounded-2xl bg-surface-2 p-2 pl-4 pr-3">
<DownloadDependency
v-for="dependency in recommendedRows"
:key="dependency.key"
:dependency="dependency"
@download="emit('download')"
/>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { InfoIcon } from '@modrinth/assets'
import { Admonition, defineMessages, IntlFormatted, useVIntl } from '@modrinth/ui'
import { injectDownloadModalProvider } from './download-modal-provider'
import DownloadDependency from './DownloadDependency.vue'
defineOptions({
name: 'DownloadDependencies',
})
withDefaults(
defineProps<{
showTitle?: boolean
}>(),
{
showTitle: true,
},
)
const emit = defineEmits<{
download: []
}>()
const { formatMessage } = useVIntl()
const {
dependencyResourcePackAdmonitionVisible,
duplicateDependencyRowsHidden,
downloadRows,
recommendedRows,
requiredResourcePackAdmonitionVisible,
} = injectDownloadModalProvider()
const messages = defineMessages({
dependenciesTitle: {
id: 'project.download.dependencies-title',
defaultMessage: 'Dependencies',
},
recommendedTitle: {
id: 'project.download.recommended-title',
defaultMessage: 'Recommended',
},
duplicateDependenciesHidden: {
id: 'project.download.duplicate-dependencies-hidden',
defaultMessage: 'Duplicate dependencies are hidden',
},
requiredResourcePackAdmonition: {
id: 'project.download.required-resource-pack-admonition',
defaultMessage:
'This data pack also requires a resource pack. Download it and place it in your {folder} folder.',
},
dependencyResourcePackAdmonition: {
id: 'project.download.dependency-resource-pack-admonition',
defaultMessage:
'This project has a dependency with a required resource pack. Download it and place it in your {folder} folder.',
},
})
</script>
@@ -0,0 +1,175 @@
<template>
<div class="flex min-w-0 flex-col">
<div
class="z-10 grid h-11 grid-cols-[minmax(0,1fr)_min-content] items-center gap-1 text-primary"
>
<span class="flex min-w-0 items-center gap-2">
<Avatar
v-if="dependency.icon && !dependency.hideIcon"
:src="dependency.icon"
:alt="dependency.name"
size="24px"
class="!rounded-lg !shadow-none"
/>
<span
v-else-if="!dependency.hideIcon"
class="flex size-6 flex-shrink-0 items-center justify-center rounded-lg border border-solid border-surface-5 text-secondary"
>
<component
:is="dependency.fallbackIcon ?? PackageIcon"
aria-hidden="true"
class="size-4"
/>
</span>
<a
v-if="dependency.projectHref"
ref="dependencyNameRef"
v-tooltip="truncatedTooltip(dependencyNameRef, dependency.name)"
:href="dependency.projectHref"
target="_blank"
rel="noopener noreferrer"
class="min-w-0 truncate bg-surface-2 text-base font-semibold text-contrast hover:underline"
>
{{ dependency.name }}
</a>
<span
v-else
ref="dependencyNameRef"
v-tooltip="truncatedTooltip(dependencyNameRef, dependency.name)"
class="min-w-0 truncate bg-surface-2 text-base text-contrast"
:class="dependency.isAdditionalFile ? 'font-medium' : 'font-semibold'"
>
{{ dependency.name }}
</span>
<TagItem
v-if="dependency.isAdditionalFile"
v-tooltip="metadataTooltip"
class="min-w-0 max-w-[50%] shrink-0 truncate border !border-solid border-surface-5"
>
{{ metadataLabel }}
</TagItem>
<span
v-else
v-tooltip="metadataTooltip"
class="min-w-0 max-w-[50%] truncate text-sm text-secondary"
>
{{ metadataLabel }}
</span>
</span>
<ButtonStyled v-if="dependency.downloadHref" circular type="transparent">
<a
v-tooltip="downloadTooltip"
:href="dependency.downloadHref"
:download="dependency.filename"
:aria-label="downloadTooltip"
@click="emit('download')"
>
<DownloadIcon aria-hidden="true" class="size-6 text-secondary" />
</a>
</ButtonStyled>
<ButtonStyled v-else circular type="transparent">
<button
v-tooltip="dependency.unavailableTooltip"
disabled
:aria-label="dependency.unavailableTooltip"
>
<DownloadIcon aria-hidden="true" class="size-6 text-secondary" />
</button>
</ButtonStyled>
</div>
<div
v-for="childDependency in dependency.dependencies"
:key="childDependency.key"
class="group/dependency relative pl-8"
>
<DownloadDependency
:dependency="childDependency"
class="relative z-10"
@download="emit('download')"
/>
<div
aria-hidden="true"
class="absolute -top-2.5 left-3 z-0 h-full w-0.5 bg-surface-5 group-last/dependency:h-8"
/>
<div aria-hidden="true" class="absolute left-3 top-[21px] z-0 h-0.5 w-7 bg-surface-5" />
</div>
</div>
</template>
<script setup lang="ts">
import { DownloadIcon, PackageIcon } from '@modrinth/assets'
import {
Avatar,
ButtonStyled,
defineMessages,
TagItem,
truncatedTooltip,
useFormatBytes,
useVIntl,
} from '@modrinth/ui'
import { type Component, computed, ref } from 'vue'
defineOptions({
name: 'DownloadDependency',
})
interface DownloadDependencyRow {
key: string
name: string
icon?: string
fallbackIcon?: Component
hideIcon?: boolean
isAdditionalFile?: boolean
projectHref?: string
downloadHref?: string
filename?: string
fileSize?: number
metadataLabel?: string
typeLabel: string
unavailableTooltip: string
dependencies: DownloadDependencyRow[]
}
const props = defineProps<{
dependency: DownloadDependencyRow
}>()
const emit = defineEmits<{
download: []
}>()
const { formatMessage } = useVIntl()
const formatBytes = useFormatBytes()
const dependencyNameRef = ref<HTMLElement | null>(null)
const metadataLabel = computed(() => props.dependency.metadataLabel ?? props.dependency.typeLabel)
const metadataTooltip = computed(() => {
if (props.dependency.isAdditionalFile) return null
if (metadataLabel.value === props.dependency.typeLabel) return null
return metadataLabel.value
})
const downloadTooltip = computed(() => {
const filename = props.dependency.filename || props.dependency.name
if (typeof props.dependency.fileSize === 'number') {
return formatMessage(messages.downloadFileWithSize, {
filename,
size: formatBytes(props.dependency.fileSize, 1),
})
}
return formatMessage(messages.downloadFile, { filename })
})
const messages = defineMessages({
downloadFile: {
id: 'project.download.dependency-download-file',
defaultMessage: 'Download {filename}',
},
downloadFileWithSize: {
id: 'project.download.dependency-download-file-with-size',
defaultMessage: 'Download {filename} ({size})',
},
})
</script>
@@ -0,0 +1,692 @@
<template>
<div class="flex w-full gap-2 max-sm:flex-wrap">
<Combobox
:model-value="currentGameVersion || undefined"
class="w-full"
:options="gameVersionOptions"
:placeholder="formatMessage(messages.selectGameVersion)"
:searchable="project.game_versions.length > 4"
search-autocomplete="off"
:search-placeholder="formatMessage(messages.searchGameVersions)"
:no-options-message="formatMessage(messages.noGameVersionsFound)"
trigger-class="!rounded-xl !bg-button-bg !px-3 !py-2"
dropdown-class="!rounded-xl"
select-search-text-on-focus
@update:model-value="selectGameVersion"
@search-input="versionFilter = $event"
@close="versionFilter = ''"
>
<template #option="{ item, isSelected }">
<div
v-tooltip="gameVersionOptionTooltip(item.value)"
class="flex w-full items-center justify-between gap-2 px-4 py-2"
:class="{
'text-brand-red opacity-40': isGameVersionUnavailable(item.value),
'text-green': isSelected,
'!opacity-100': isSelected,
'text-primary': !isGameVersionUnavailable(item.value) && !isSelected,
}"
>
<span class="min-w-0 truncate font-semibold leading-tight">{{ item.label }}</span>
<TriangleAlertIcon
v-if="isSelected && isGameVersionUnavailable(item.value)"
aria-hidden="true"
class="size-5 shrink-0 text-orange"
/>
</div>
</template>
<template #dropdown-footer>
<div
v-if="showVersionsCheckbox"
class="border-0 border-t border-solid border-surface-5 p-3"
>
<Checkbox
v-model="showAllVersionsModel"
:label="formatMessage(messages.showAllVersions)"
:disabled="!!versionFilter"
/>
</div>
</template>
</Combobox>
<Combobox
v-if="project.project_type !== 'resourcepack'"
:model-value="currentPlatform || undefined"
class="w-full"
:options="platformOptions"
:placeholder="formatMessage(messages.selectPlatform)"
trigger-class="!rounded-xl !bg-button-bg !px-3 !py-2"
dropdown-class="!rounded-xl"
@update:model-value="selectPlatform"
>
<template #option="{ item, isSelected }">
<div
v-tooltip="platformOptionTooltip(item.value, item.label)"
class="flex w-full items-center justify-between gap-2 px-4 py-2"
:class="{
'text-brand-red opacity-40': isPlatformUnavailable(item.value),
'text-green': isSelected,
'!opacity-100': isSelected,
'text-primary': !isPlatformUnavailable(item.value) && !isSelected,
}"
>
<span class="min-w-0 truncate font-semibold leading-tight">{{ item.label }}</span>
<TriangleAlertIcon
v-if="isSelected && isPlatformUnavailable(item.value)"
aria-hidden="true"
class="size-5 shrink-0 text-orange"
/>
</div>
</template>
</Combobox>
</div>
<div
v-if="selectedVersion && downloadDataLoaded"
:role="compatibleVersions.length > 1 ? 'radiogroup' : undefined"
:aria-label="
compatibleVersions.length > 1 ? formatMessage(messages.compatibleVersionTitle) : undefined
"
class="flex flex-col gap-2.5"
>
<h3
v-if="compatibleVersions.length > 1"
class="relative top-0.5 m-0 text-base font-semibold text-contrast"
>
{{ formatMessage(messages.compatibleVersionTitle) }}
</h3>
<CompatibleVersionCard
v-for="compatibleVersion in compatibleVersions"
:key="compatibleVersion.id"
:project="project"
:version="compatibleVersion"
:download-reason="downloadReason"
:current-game-version="currentGameVersion"
:current-platform="currentPlatform"
:selectable="compatibleVersions.length > 1"
:selected="compatibleVersion.id === selectedVersion.id"
:show-download="
compatibleVersions.length === 1 || compatibleVersion.id === selectedVersion.id
"
:color="
compatibleVersion.id === selectedVersion.id &&
compatibleVersions.length === 1 &&
!hasAdditionalDownloads
? 'brand'
: 'standard'
"
:type="
compatibleVersion.id === selectedVersion.id &&
compatibleVersions.length === 1 &&
!hasAdditionalDownloads
? 'standard'
: 'transparent'
"
:circular="hasAdditionalDownloads || compatibleVersions.length > 1"
@select="selectCompatibleVersion(compatibleVersion)"
@download="emit('download')"
/>
</div>
<div v-else-if="showNoCompatibleVersions" class="pl-1 text-base text-primary" role="status">
{{ noCompatibleVersionsDescription }}
</div>
</template>
<script setup lang="ts">
import type { Labrinth } from '@modrinth/api-client'
import { TriangleAlertIcon } from '@modrinth/assets'
import {
type CdnDownloadReason,
Checkbox,
Combobox,
type ComboboxOption,
defineMessages,
getTagMessage,
useDebugLogger,
useVIntl,
} from '@modrinth/ui'
import type { DisplayProjectType } from '@modrinth/utils'
import dayjs from 'dayjs'
import { computed, ref, watch } from 'vue'
import CompatibleVersionCard from './CompatibleVersionCard.vue'
defineOptions({
name: 'DownloadProject',
})
type DownloadModalProject = Omit<Labrinth.Projects.v2.Project, 'project_type'> & {
project_type: DisplayProjectType
actualProjectType: Labrinth.Projects.v2.ProjectType
}
type ProjectDownloadSelection = {
currentGameVersion: string | null
currentPlatform: string | null
selectedVersion: Labrinth.Versions.v3.Version | null
selectedPrimaryFile: Labrinth.Versions.v3.VersionFile | null
}
type DownloadableFile = {
href: string
filename: string
}
const props = withDefaults(
defineProps<{
project: DownloadModalProject
versions?: Labrinth.Versions.v3.Version[]
dependencyDownloadFiles?: DownloadableFile[]
downloadDataLoaded?: boolean
versionsLoaded?: boolean
downloadReason?: CdnDownloadReason
initialGameVersion?: string | null
initialPlatform?: string | null
incompatibleGameVersions?: string[]
incompatibleLoaders?: string[]
resetKey?: number
}>(),
{
versions: () => [],
dependencyDownloadFiles: () => [],
downloadDataLoaded: false,
versionsLoaded: false,
downloadReason: 'standalone',
initialGameVersion: null,
initialPlatform: null,
incompatibleGameVersions: () => [],
incompatibleLoaders: () => [],
resetKey: 0,
},
)
const emit = defineEmits<{
download: []
selectGameVersion: [gameVersion: string]
selectPlatform: [platform: string]
'update:selection': [selection: ProjectDownloadSelection]
}>()
const { formatMessage } = useVIntl()
const debug = useDebugLogger('DownloadProject')
const tags = useGeneratedState()
const userSelectedGameVersion = ref<string | null>(props.initialGameVersion)
const userSelectedPlatform = ref<string | null>(props.initialPlatform)
const userSelectedCompatibleVersionId = ref<string | null>(null)
const showAllVersions = ref(defaultShowAllVersions())
const versionFilter = ref('')
const preferredPlatformRanks = new Map([
['fabric', 0],
['forge', 1],
['neoforge', 2],
])
const incompatibleGameVersionsSet = computed(() => new Set(props.incompatibleGameVersions))
const incompatibleLoadersSet = computed(() => new Set(props.incompatibleLoaders))
const showAllVersionsModel = computed({
get() {
return showAllVersions.value
},
set(value) {
showAllVersions.value = value
},
})
const selectedPlatform = computed<string | null>(() => {
if (userSelectedPlatform.value) return userSelectedPlatform.value
return props.project.loaders.length === 1 ? props.project.loaders[0] : null
})
const selectedGameVersion = computed<string | null>(() => {
if (userSelectedGameVersion.value) return userSelectedGameVersion.value
return props.project.game_versions.length === 1 ? props.project.game_versions[0] : null
})
const compatiblePlatforms = computed<string[]>(() => {
return props.project.loaders.filter(
(platform) =>
props.versions.some(
(version) =>
version.loaders.includes(platform) &&
(!selectedGameVersion.value || version.game_versions.includes(selectedGameVersion.value)),
) && !incompatibleLoadersSet.value.has(platform),
)
})
const currentPlatform = computed<string | null>(() => {
if (selectedPlatform.value) return selectedPlatform.value
return compatiblePlatforms.value.length === 1 ? compatiblePlatforms.value[0] : null
})
const possibleGameVersions = computed<string[]>(() => {
return props.versions
.filter((x) => !currentPlatform.value || x.loaders.includes(currentPlatform.value))
.flatMap((x) => x.game_versions)
})
const compatibleGameVersions = computed<string[]>(() => {
return props.project.game_versions.filter(
(gameVersion) =>
possibleGameVersions.value.includes(gameVersion) &&
!incompatibleGameVersionsSet.value.has(gameVersion),
)
})
const currentGameVersion = computed<string | null>(() => {
if (selectedGameVersion.value) return selectedGameVersion.value
return compatibleGameVersions.value.length === 1 ? compatibleGameVersions.value[0] : null
})
const possiblePlatforms = computed<string[]>(() => {
return props.versions
.filter((x) => !currentGameVersion.value || x.game_versions.includes(currentGameVersion.value))
.flatMap((x) => x.loaders)
})
const currentPlatformText = computed(() => {
if (!currentPlatform.value) return ''
return loaderLabel(currentPlatform.value)
})
const releaseVersions = computed<Set<string>>(() => {
const set = new Set<string>()
for (const gameVersion of tags.value.gameVersions || []) {
if (gameVersion?.version && gameVersion.version_type === 'release') {
set.add(gameVersion.version)
}
}
return set
})
const nonReleaseVersions = computed<Set<string>>(() => {
const set = new Set<string>()
for (const gameVersion of tags.value.gameVersions || []) {
if (gameVersion?.version && gameVersion.version_type !== 'release') {
set.add(gameVersion.version)
}
}
return set
})
const showVersionsCheckbox = computed(() => {
let hasRelease = false
let hasNonRelease = false
for (const version of props.project.game_versions) {
if (isReleaseGameVersion(version)) {
hasRelease = true
} else {
hasNonRelease = true
}
if (hasRelease && hasNonRelease) return true
}
return false
})
const filteredGameVersions = computed(() => {
return props.project.game_versions
.filter(
(x) =>
(versionFilter.value && x.includes(versionFilter.value)) ||
(!versionFilter.value && (showAllVersions.value || isReleaseGameVersion(x))),
)
.slice()
.reverse()
})
const gameVersionOptions = computed<ComboboxOption<string>[]>(() => {
return filteredGameVersions.value.map((gameVersion) => ({
value: gameVersion,
label: gameVersion,
class: '!px-0 !py-1',
}))
})
const platformOptions = computed<ComboboxOption<string>[]>(() => {
return props.project.loaders
.map((platform) => ({
value: platform,
label: loaderLabel(platform),
class: '!px-0 !py-1',
}))
.sort(comparePlatformOptions)
})
const filteredVersions = computed<Labrinth.Versions.v3.Version[]>(() => {
const gameVersion = currentGameVersion.value
if (!gameVersion) return []
const platform = currentPlatform.value
const result = props.versions.filter((x) => {
const matchesPlatform =
props.project.project_type === 'resourcepack' || (!!platform && x.loaders.includes(platform))
return x.game_versions.includes(gameVersion) && matchesPlatform
})
debug('filteredVersions', {
total: props.versions.length,
filtered: result.length,
currentGameVersion: currentGameVersion.value,
currentPlatform: currentPlatform.value,
sampleLoaders: props.versions.slice(0, 3).map((v) => v.loaders),
})
return result
})
const filteredRelease = computed<Labrinth.Versions.v3.Version | undefined>(() => {
return latestVersionByType('release')
})
const filteredBeta = computed<Labrinth.Versions.v3.Version | undefined>(() => {
return latestVersionByType('beta')
})
const filteredAlpha = computed<Labrinth.Versions.v3.Version | undefined>(() => {
return latestVersionByType('alpha')
})
const defaultSelectedVersion = computed<Labrinth.Versions.v3.Version | null>(() => {
return filteredRelease.value || filteredBeta.value || filteredAlpha.value || null
})
const suggestedPreReleaseVersions = computed<Labrinth.Versions.v3.Version[]>(() => {
if (!defaultSelectedVersion.value || defaultSelectedVersion.value.version_type !== 'release')
return []
const versions: Labrinth.Versions.v3.Version[] = []
const beta = filteredBeta.value
if (beta && isNewerThan(beta, defaultSelectedVersion.value)) {
versions.push(beta)
}
const alpha = filteredAlpha.value
if (alpha && isNewerThan(alpha, defaultSelectedVersion.value)) {
versions.push(alpha)
}
return versions
})
const compatibleVersions = computed<Labrinth.Versions.v3.Version[]>(() => {
if (!defaultSelectedVersion.value) return []
return [defaultSelectedVersion.value, ...suggestedPreReleaseVersions.value]
})
const showNoCompatibleVersions = computed(() => {
return (
props.versionsLoaded &&
compatibleVersions.value.length === 0 &&
!!currentGameVersion.value &&
!!currentPlatform.value
)
})
const noCompatibleVersionsDescription = computed(() => {
const gameVersion = currentGameVersion.value
if (!gameVersion || !currentPlatform.value) return ''
return formatMessage(messages.noVersionsAvailable, {
gameVersion,
platform: currentPlatformText.value,
})
})
const selectedVersion = computed<Labrinth.Versions.v3.Version | null>(() => {
return (
compatibleVersions.value.find(
(version) => version.id === userSelectedCompatibleVersionId.value,
) ||
defaultSelectedVersion.value ||
null
)
})
const selectedPrimaryFile = computed<Labrinth.Versions.v3.VersionFile | null>(() => {
return (
selectedVersion.value?.files?.find((file) => file.primary) ||
selectedVersion.value?.files?.[0] ||
null
)
})
const requiredResourcePackFile = computed<Labrinth.Versions.v3.VersionFile | null>(() => {
if (props.project.project_type !== 'datapack') return null
return (
selectedVersion.value?.files?.find(
(file) => file !== selectedPrimaryFile.value && file.file_type === 'required-resource-pack',
) || null
)
})
const recommendedResourcePackFiles = computed<Labrinth.Versions.v3.VersionFile[]>(() => {
if (props.project.project_type !== 'datapack') return []
return (
selectedVersion.value?.files?.filter(
(file) => file !== selectedPrimaryFile.value && file.file_type === 'optional-resource-pack',
) || []
)
})
const hasAdditionalDownloads = computed(() => {
const hrefs = new Set<string>()
if (selectedPrimaryFile.value) {
hrefs.add(selectedPrimaryFile.value.url)
}
if (requiredResourcePackFile.value) {
hrefs.add(requiredResourcePackFile.value.url)
}
for (const file of recommendedResourcePackFiles.value) {
hrefs.add(file.url)
}
for (const file of props.dependencyDownloadFiles) {
if (hrefs.has(file.href)) continue
hrefs.add(file.href)
}
return hrefs.size > 1
})
watch(
[currentGameVersion, currentPlatform, selectedVersion, selectedPrimaryFile],
() => {
emit('update:selection', {
currentGameVersion: currentGameVersion.value,
currentPlatform: currentPlatform.value,
selectedVersion: selectedVersion.value,
selectedPrimaryFile: selectedPrimaryFile.value,
})
},
{ immediate: true },
)
watch([currentGameVersion, currentPlatform], () => {
userSelectedCompatibleVersionId.value = null
})
watch(
() => props.resetKey,
() => {
userSelectedGameVersion.value = props.initialGameVersion
userSelectedPlatform.value = props.initialPlatform
userSelectedCompatibleVersionId.value = null
showAllVersions.value = defaultShowAllVersions()
versionFilter.value = ''
},
)
function selectGameVersion(gameVersion?: string) {
if (!gameVersion) return
userSelectedGameVersion.value = gameVersion
emit('selectGameVersion', gameVersion)
selectOnlyCompatiblePlatform()
}
function selectPlatform(platform?: string) {
if (!platform) return
userSelectedPlatform.value = platform
emit('selectPlatform', platform)
}
function selectCompatibleVersion(version: Labrinth.Versions.v3.Version) {
userSelectedCompatibleVersionId.value = version.id
}
function latestVersionByType(type: Labrinth.Versions.v3.VersionChannel) {
return filteredVersions.value
.filter((version) => version.version_type === type)
.reduce<Labrinth.Versions.v3.Version | undefined>((latest, version) => {
if (!latest || isNewerThan(version, latest)) return version
return latest
}, undefined)
}
function isNewerThan(
version: Labrinth.Versions.v3.Version,
comparison: Labrinth.Versions.v3.Version,
) {
return dayjs(version.date_published).isAfter(dayjs(comparison.date_published))
}
function loaderLabel(loader: string) {
return formatMessage(getTagMessage(loader, 'loader') ?? messages.unknownLoader)
}
function comparePlatformOptions(a: ComboboxOption<string>, b: ComboboxOption<string>) {
const aRank = preferredPlatformRanks.get(a.value) ?? Number.MAX_SAFE_INTEGER
const bRank = preferredPlatformRanks.get(b.value) ?? Number.MAX_SAFE_INTEGER
if (aRank !== bRank) return aRank - bRank
return a.label.localeCompare(b.label)
}
function isReleaseGameVersion(version: string) {
if (releaseVersions.value.has(version)) return true
if (nonReleaseVersions.value.has(version)) return false
return true
}
function defaultShowAllVersions() {
return (
props.project.game_versions.length > 0 &&
props.project.game_versions.every((projectVersion) => {
const gameVersion = tags.value.gameVersions?.find((x) => x.version === projectVersion)
return !!gameVersion?.version_type && gameVersion.version_type !== 'release'
})
)
}
function isGameVersionUnavailable(gameVersion: string) {
return (
incompatibleGameVersionsSet.value.has(gameVersion) ||
!possibleGameVersions.value.includes(gameVersion)
)
}
function isPlatformUnavailable(platform: string) {
return incompatibleLoadersSet.value.has(platform) || !possiblePlatforms.value.includes(platform)
}
function selectOnlyCompatiblePlatform() {
const compatiblePlatforms = props.project.loaders.filter(
(platform) =>
possiblePlatforms.value.includes(platform) && !incompatibleLoadersSet.value.has(platform),
)
if (compatiblePlatforms.length !== 1) return
userSelectedPlatform.value = compatiblePlatforms[0]
emit('selectPlatform', compatiblePlatforms[0])
}
function gameVersionOptionTooltip(gameVersion: string) {
if (incompatibleGameVersionsSet.value.has(gameVersion)) {
return formatMessage(messages.baseGameVersionIncompatibleTooltip)
}
if (!possibleGameVersions.value.includes(gameVersion)) {
return formatMessage(messages.gameVersionUnsupportedTooltip, {
title: props.project.title,
gameVersion,
platform: currentPlatformText.value,
})
}
return null
}
function platformOptionTooltip(platform: string, platformLabel: string) {
if (incompatibleLoadersSet.value.has(platform)) {
return formatMessage(messages.baseLoaderIncompatibleTooltip)
}
if (!possiblePlatforms.value.includes(platform)) {
return formatMessage(messages.platformUnsupportedTooltip, {
title: props.project.title,
platform: platformLabel,
gameVersion: currentGameVersion.value,
})
}
return null
}
const messages = defineMessages({
baseGameVersionIncompatibleTooltip: {
id: 'project.download.base-game-version-incompatible-tooltip',
defaultMessage: 'This game version is incompatible with the base project.',
},
baseLoaderIncompatibleTooltip: {
id: 'project.download.base-loader-incompatible-tooltip',
defaultMessage: 'This loader is incompatible with the base project.',
},
gameVersionUnsupportedTooltip: {
id: 'project.download.game-version-unsupported-tooltip',
defaultMessage: '{title} does not support {gameVersion} for {platform}',
},
compatibleVersionTitle: {
id: 'project.download.compatible-version-title',
defaultMessage: 'Compatible versions',
},
noGameVersionsFound: {
id: 'project.download.no-game-versions-found',
defaultMessage: 'No game versions found',
},
noVersionsAvailable: {
id: 'project.download.no-versions-available',
defaultMessage: 'No versions available for {gameVersion} and {platform}.',
},
platformUnsupportedTooltip: {
id: 'project.download.platform-unsupported-tooltip',
defaultMessage: '{title} does not support {platform} for {gameVersion}',
},
searchGameVersions: {
id: 'project.download.search-game-versions',
defaultMessage: 'Select game version',
},
selectGameVersion: {
id: 'project.download.select-game-version',
defaultMessage: 'Select game version',
},
selectPlatform: {
id: 'project.download.select-platform',
defaultMessage: 'Select platform',
},
showAllVersions: {
id: 'project.download.show-all-versions',
defaultMessage: 'Show all versions',
},
unknownLoader: {
id: 'project.download.unknown-loader',
defaultMessage: 'Unknown loader',
},
})
</script>
@@ -0,0 +1,93 @@
<template>
<div
v-if="
project.project_type !== 'plugin' ||
project.loaders.some((x) => !tags.loaderData.allPluginLoaders.includes(x))
"
class="modrinth-app-section contents"
>
<div class="flex flex-col items-center">
<ButtonStyled color="brand">
<a
class="!min-h-10 w-fit no-underline"
:href="`modrinth://mod/${project.slug}`"
@click="installWithApp"
>
<ModrinthIcon aria-hidden="true" />
<span class="min-w-0 text-center">
{{ formatMessage(messages.installWithModrinthApp) }}
</span>
</a>
</ButtonStyled>
<Accordion ref="getModrinthAppAccordion">
<nuxt-link class="mt-2 flex justify-center text-brand-blue hover:underline" to="/app">
{{ formatMessage(messages.dontHaveModrinthApp) }}
</nuxt-link>
</Accordion>
</div>
<div class="flex items-center gap-4">
<div class="flex h-[2px] w-full rounded-2xl bg-button-bg"></div>
<span class="flex-shrink-0 text-sm font-medium text-secondary">
{{ formatMessage(messages.downloadManually) }}
</span>
<div class="flex h-[2px] w-full rounded-2xl bg-button-bg"></div>
</div>
</div>
</template>
<script setup lang="ts">
import type { Labrinth } from '@modrinth/api-client'
import { ModrinthIcon } from '@modrinth/assets'
import { ButtonStyled, defineMessages, useVIntl } from '@modrinth/ui'
import type { DisplayProjectType } from '@modrinth/utils'
import { ref } from 'vue'
import Accordion from '~/components/ui/Accordion.vue'
defineOptions({
name: 'InstallWithModrinthApp',
})
type DownloadModalProject = Omit<Labrinth.Projects.v2.Project, 'project_type'> & {
project_type: DisplayProjectType
actualProjectType: Labrinth.Projects.v2.ProjectType
}
defineProps<{
project: DownloadModalProject
}>()
const { formatMessage } = useVIntl()
const tags = useGeneratedState()
const getModrinthAppAccordion = ref<InstanceType<typeof Accordion> | null>(null)
const messages = defineMessages({
installWithModrinthApp: {
id: 'project.download.install-with-app',
defaultMessage: 'Install with Modrinth App',
},
dontHaveModrinthApp: {
id: 'project.download.no-app',
defaultMessage: "Don't have Modrinth App?",
},
downloadManually: {
id: 'project.download.manually',
defaultMessage: 'Download manually',
},
})
function installWithApp() {
setTimeout(() => {
getModrinthAppAccordion.value?.open()
}, 1500)
}
</script>
<style lang="scss" scoped>
@media (hover: none) and (max-width: 767px) {
.modrinth-app-section {
display: none;
}
}
</style>
@@ -0,0 +1,728 @@
import type { AbstractModrinthClient, Labrinth } from '@modrinth/api-client'
import {
type CdnDownloadReason,
createContext,
defineMessages,
fileTypeMessages,
injectModrinthClient,
useVIntl,
} from '@modrinth/ui'
import type { DisplayProjectType } from '@modrinth/utils'
import { useQuery, useQueryClient } from '@tanstack/vue-query'
import { type Component, computed, type ComputedRef } from 'vue'
import { STALE_TIME } from '~/composables/queries/project'
type DownloadModalProject = Omit<Labrinth.Projects.v2.Project, 'project_type'> & {
project_type: DisplayProjectType
actualProjectType: Labrinth.Projects.v2.ProjectType
}
type ResolvedContent = Labrinth.Content.v3.ResolvedContent | Labrinth.Content.v3.SkippedContent
export interface DownloadDependencyRow {
key: string
name: string
icon?: string
fallbackIcon?: Component
hideIcon?: boolean
isAdditionalFile?: boolean
projectHref?: string
downloadHref?: string
filename?: string
fileSize?: number
metadataLabel?: string
typeLabel: string
unavailableTooltip: string
dependencies: DownloadDependencyRow[]
}
export interface DownloadableDependencyFile {
href: string
filename: string
name: string
}
export interface ProjectDownloadSelection {
currentGameVersion: string | null
currentPlatform: string | null
selectedVersion: Labrinth.Versions.v3.Version | null
selectedPrimaryFile: Labrinth.Versions.v3.VersionFile | null
}
interface DownloadModalProviderOptions {
project: ComputedRef<DownloadModalProject | null>
selectedVersion: ComputedRef<Labrinth.Versions.v3.Version | null>
selectedPrimaryFile: ComputedRef<Labrinth.Versions.v3.VersionFile | null>
currentGameVersion: ComputedRef<string | null>
currentPlatform: ComputedRef<string | null>
downloadReason: ComputedRef<CdnDownloadReason>
additionalFiles: ComputedRef<Labrinth.Versions.v3.VersionFile[]>
}
export interface DownloadModalProvider {
visibleDependencyRows: ComputedRef<DownloadDependencyRow[]>
duplicateDependencyRowsHidden: ComputedRef<boolean>
downloadRows: ComputedRef<DownloadDependencyRow[]>
recommendedRows: ComputedRef<DownloadDependencyRow[]>
downloadRowsLoaded: ComputedRef<boolean>
requiredResourcePackAdmonitionVisible: ComputedRef<boolean>
dependencyResourcePackAdmonitionVisible: ComputedRef<boolean>
downloadableDependencyFiles: ComputedRef<DownloadableDependencyFile[]>
downloadableDependencyFilesLoaded: ComputedRef<boolean>
preloadDependenciesForSelection: (selection: ProjectDownloadSelection) => Promise<void>
}
export const [injectDownloadModalProvider, provideDownloadModalContext] =
createContext<DownloadModalProvider>('DownloadModal')
export function provideDownloadModalProvider(
options: DownloadModalProviderOptions,
): DownloadModalProvider {
const client = injectModrinthClient()
const queryClient = useQueryClient()
const { createProjectDownloadUrl } = useCdnDownloadContext()
const { formatMessage } = useVIntl()
const tags = useGeneratedState()
const shouldResolveDependencies = computed(
() => !!options.project.value && !!options.selectedVersion.value,
)
const dependencyResolutionPreferences = computed(() =>
createResolutionPreferences(options.selectedVersion.value, options.currentPlatform.value),
)
const { data: dependencyResolution, isFetching: dependencyResolutionFetching } = useQuery({
...dependencyResolutionQueryOptions(
client,
options.project,
options.selectedVersion,
dependencyResolutionPreferences,
),
enabled: shouldResolveDependencies,
})
const visibleResolvedDependencies = computed<ResolvedContent[]>(() =>
visibleDependencies(dependencyResolution.value),
)
const dependencyVersionIds = computed(() =>
sortedUnique(
visibleResolvedDependencies.value
.filter((dependency) => !('reason' in dependency))
.map((dependency) => dependency.version_id)
.filter((versionId): versionId is string => !!versionId),
),
)
const { data: dependencyVersions, isFetching: dependencyVersionsFetching } = useQuery({
...dependencyVersionsQueryOptions(client, dependencyVersionIds),
enabled: computed(
() => shouldResolveDependencies.value && dependencyVersionIds.value.length > 0,
),
})
const dependencyVersionById = computed(() => {
const map = new Map<string, Labrinth.Versions.v3.Version>()
for (const version of dependencyVersions.value || []) {
if (!version) continue
map.set(version.id, version)
}
return map
})
const dependencyProjectIds = computed(() =>
sortedUnique(
visibleResolvedDependencies.value
.map((dependency) => dependency.project_id)
.filter((projectId): projectId is string => !!projectId),
),
)
const { data: dependencyProjects, isFetching: dependencyProjectsFetching } = useQuery({
...dependencyProjectsQueryOptions(client, dependencyProjectIds),
enabled: computed(
() => shouldResolveDependencies.value && dependencyProjectIds.value.length > 0,
),
})
const dependencyProjectById = computed(() => {
const map = new Map<string, Labrinth.Projects.v2.Project>()
for (const project of dependencyProjects.value || []) {
map.set(project.id, project)
}
return map
})
const dependenciesByParentVersionId = computed(() => {
const map = new Map<string, ResolvedContent[]>()
for (const dependency of visibleResolvedDependencies.value) {
if (!dependency.dependent_on_version_id) continue
const dependencies = map.get(dependency.dependent_on_version_id) || []
dependencies.push(dependency)
map.set(dependency.dependent_on_version_id, dependencies)
}
return map
})
const dependenciesLoaded = computed(() => {
if (!shouldResolveDependencies.value) return false
if (dependencyResolutionFetching.value) return false
if (!dependencyResolution.value) return false
if (
dependencyResolution.value.primary.version_id &&
dependencyResolution.value.primary.version_id !== options.selectedVersion.value?.id
) {
return false
}
if (
dependencyVersionsFetching.value ||
!dependencyVersionIds.value.every((versionId) => dependencyVersionById.value.has(versionId))
) {
return false
}
if (
dependencyProjectsFetching.value ||
!dependencyProjectIds.value.every((projectId) => dependencyProjectById.value.has(projectId))
) {
return false
}
return true
})
const selectedDownloadRowsLoaded = computed(() => {
if (!options.selectedPrimaryFile.value) return false
return dependenciesLoaded.value
})
const keepPreviousDownloadRows = computed(
() => shouldResolveDependencies.value && !!options.selectedPrimaryFile.value,
)
const resolvedDependencyRows = computed<DownloadDependencyRow[]>(() => {
if (!selectedDownloadRowsLoaded.value) return []
const primaryVersionId =
dependencyResolution.value?.primary.version_id || options.selectedVersion.value?.id
if (!primaryVersionId) return []
const dependencies = dependenciesByParentVersionId.value.get(primaryVersionId) || []
return dependencies.flatMap((dependency) => {
const row = createDependencyRow(dependency)
return row ? [row] : []
})
})
const visibleDependencyRows = computed<DownloadDependencyRow[]>((previous) => {
if (selectedDownloadRowsLoaded.value) {
return dedupeDependencyRows(resolvedDependencyRows.value)
}
return keepPreviousDownloadRows.value ? (previous ?? []) : []
})
const duplicateDependencyRowsHidden = computed<boolean>((previous) => {
if (selectedDownloadRowsLoaded.value) {
return (
hasSkippedDuplicateDependency(dependencyResolution.value) ||
hasDuplicateDependencyRows(resolvedDependencyRows.value)
)
}
return keepPreviousDownloadRows.value ? (previous ?? false) : false
})
const visibleRequiredResourcePackFiles = computed(() => {
if (options.project.value?.project_type !== 'datapack') return []
return options.additionalFiles.value.filter(
(file) => file.file_type === 'required-resource-pack',
)
})
const visibleRecommendedResourcePackFiles = computed(() => {
if (options.project.value?.project_type !== 'datapack') return []
return options.additionalFiles.value.filter(
(file) => file.file_type === 'optional-resource-pack',
)
})
const additionalFileRows = computed<DownloadDependencyRow[]>(() =>
selectedDownloadRowsLoaded.value
? visibleRequiredResourcePackFiles.value.map(createAdditionalFileRow)
: [],
)
const recommendedRows = computed<DownloadDependencyRow[]>(() =>
selectedDownloadRowsLoaded.value
? visibleRecommendedResourcePackFiles.value.map(createAdditionalFileRow)
: [],
)
function createAdditionalFileRow(file: Labrinth.Versions.v3.VersionFile): DownloadDependencyRow {
return {
key: `additional-file-${additionalFileKey(file)}`,
name: file.filename,
hideIcon: true,
isAdditionalFile: true,
downloadHref: getDownloadUrl(file.url),
filename: file.filename,
fileSize: file.size,
metadataLabel: fileTypeDisplayLabel(file.file_type),
typeLabel: fileTypeLabel(file.file_type),
unavailableTooltip: formatMessage(messages.unavailableFile),
dependencies: [],
}
}
const downloadRows = computed<DownloadDependencyRow[]>((previous) => {
if (selectedDownloadRowsLoaded.value) {
return [...visibleDependencyRows.value, ...additionalFileRows.value]
}
return keepPreviousDownloadRows.value ? (previous ?? []) : []
})
const downloadRowsLoaded = computed<boolean>((previous) => {
if (selectedDownloadRowsLoaded.value) return true
return keepPreviousDownloadRows.value ? (previous ?? false) : false
})
const requiredResourcePackAdmonitionVisible = computed(() => {
return selectedDownloadRowsLoaded.value && visibleRequiredResourcePackFiles.value.length > 0
})
const dependencyResourcePackAdmonitionVisible = computed(() => {
return selectedDownloadRowsLoaded.value && hasAdditionalFileRows(visibleDependencyRows.value)
})
const downloadableDependencyFiles = computed<DownloadableDependencyFile[]>(() =>
collectDownloadableDependencyFiles(visibleDependencyRows.value),
)
const downloadableDependencyFilesLoaded = computed(() => {
return selectedDownloadRowsLoaded.value
})
async function preloadDependenciesForSelection(selection: ProjectDownloadSelection) {
if (!options.project.value || !selection.selectedVersion) return
const preferences = createResolutionPreferences(
selection.selectedVersion,
selection.currentPlatform,
)
const resolution = await queryClient.ensureQueryData({
queryKey: [
'project-download-modal',
'content-resolve',
options.project.value.id,
selection.selectedVersion.id,
options.project.value.project_type,
preferences,
],
queryFn: () =>
client.labrinth.content_v3.resolve({
project_id: options.project.value!.id,
version_id: selection.selectedVersion!.id,
content_type: resolveContentType(options.project.value!.project_type),
selected: preferences,
target: preferences,
}),
staleTime: STALE_TIME,
})
const visible = visibleDependencies(resolution)
const versionIds = getDependencyVersionIds(visible)
const projectIds = getDependencyProjectIds(visible)
await Promise.all([
versionIds.length > 0
? queryClient.ensureQueryData({
queryKey: ['project-download-modal', 'resolved-versions', versionIds],
queryFn: () => client.labrinth.versions_v3.getVersions(versionIds),
staleTime: STALE_TIME,
})
: Promise.resolve(),
projectIds.length > 0
? queryClient.ensureQueryData({
queryKey: ['project-download-modal', 'resolved-projects', projectIds],
queryFn: () => client.labrinth.projects_v2.getMultiple(projectIds),
staleTime: STALE_TIME,
})
: Promise.resolve(),
])
}
function createDependencyRow(dependency: ResolvedContent): DownloadDependencyRow | null {
const versionId = dependency.version_id ?? undefined
const version = versionId ? dependencyVersionById.value.get(versionId) : undefined
const project = dependencyProjectById.value.get(dependency.project_id)
if (!project) return null
const primaryFile = primaryFileForVersion(version)
const unavailableTooltip =
'reason' in dependency && dependency.reason
? skippedReasonLabel(dependency.reason)
: formatMessage(messages.unavailableDependency)
const name = project.title
const metadataLabel = isProjectOnlyDependencyReference(dependency)
? formatMessage(messages.anyCompatibleDependency)
: (version?.version_number ?? formatMessage(messages.anyCompatibleDependency))
const childDependencies = (
versionId && dependenciesByParentVersionId.value.get(versionId)
? dependenciesByParentVersionId.value.get(versionId)!
: []
).flatMap((subDependency) => {
const row = createDependencyRow(subDependency)
return row ? [row] : []
})
return {
key: `${dependency.project_id}-${versionId ?? 'unresolved'}-${
'reason' in dependency ? dependency.reason : 'resolved'
}`,
name,
icon: project.icon_url ?? undefined,
projectHref: `/${project.project_type}/${project.slug || project.id}`,
downloadHref:
'reason' in dependency || !primaryFile ? undefined : getDownloadUrl(primaryFile.url),
filename: primaryFile?.filename,
fileSize: primaryFile?.size,
metadataLabel,
typeLabel: 'Required',
unavailableTooltip,
dependencies: [
...childDependencies,
...createRequiredResourcePackRowsForDependency(project, version, primaryFile),
],
}
}
function createRequiredResourcePackRowsForDependency(
project: Labrinth.Projects.v2.Project,
version: Labrinth.Versions.v3.Version | undefined,
primaryFile: Labrinth.Versions.v3.VersionFile | undefined,
): DownloadDependencyRow[] {
if (!version || !isDataPackProject(project)) return []
return version.files
.filter((file) => file !== primaryFile && file.file_type === 'required-resource-pack')
.map((file) => ({
key: `dependency-resource-pack-${version.id}-${additionalFileKey(file)}`,
name: file.filename,
hideIcon: true,
isAdditionalFile: true,
downloadHref: getDownloadUrl(file.url),
filename: file.filename,
fileSize: file.size,
metadataLabel: fileTypeDisplayLabel(file.file_type),
typeLabel: fileTypeLabel(file.file_type),
unavailableTooltip: formatMessage(messages.unavailableFile),
dependencies: [],
}))
}
function isDataPackProject(project: Labrinth.Projects.v2.Project) {
return (
project.project_type === 'datapack' ||
project.loaders.some((loader) => tags.value.loaderData.dataPackLoaders.includes(loader))
)
}
function isProjectOnlyDependencyReference(dependency: ResolvedContent) {
const parentVersionId = dependency.dependent_on_version_id
const parentVersion =
parentVersionId === options.selectedVersion.value?.id
? options.selectedVersion.value
: parentVersionId
? dependencyVersionById.value.get(parentVersionId)
: undefined
return !!parentVersion?.dependencies?.some(
(parentDependency) =>
parentDependency.project_id === dependency.project_id && !parentDependency.version_id,
)
}
function skippedReasonLabel(reason: Labrinth.Content.v3.SkippedContent['reason']) {
return (
{
already_installed: formatMessage(messages.alreadyInstalledDependency),
duplicate_project: formatMessage(messages.duplicateDependency),
conflicting_dependency: formatMessage(messages.conflictingDependency),
no_compatible_version: formatMessage(messages.noCompatibleDependency),
missing_version: formatMessage(messages.missingDependencyVersion),
quilt_fabric_api: formatMessage(messages.quiltFabricApiDependency),
}[reason] || formatMessage(messages.unavailableDependency)
)
}
function getDownloadUrl(url: string) {
return createProjectDownloadUrl(url, {
reason: options.downloadReason.value,
gameVersion: options.currentGameVersion.value ?? undefined,
loader: options.currentPlatform.value ?? undefined,
})
}
function fileTypeLabel(type?: Labrinth.Versions.v3.FileType | null) {
return formatMessage(fileTypeMessages[type ?? 'unknown'] ?? fileTypeMessages.unknown)
}
function fileTypeDisplayLabel(type?: Labrinth.Versions.v3.FileType | null) {
if (type === 'required-resource-pack') return formatMessage(messages.requiredResourcePackShort)
return fileTypeLabel(type)
}
const provider = {
visibleDependencyRows,
duplicateDependencyRowsHidden,
downloadRows,
recommendedRows,
downloadRowsLoaded,
requiredResourcePackAdmonitionVisible,
dependencyResourcePackAdmonitionVisible,
downloadableDependencyFiles,
downloadableDependencyFilesLoaded,
preloadDependenciesForSelection,
}
provideDownloadModalContext(provider)
return provider
}
function dependencyResolutionQueryOptions(
client: AbstractModrinthClient,
project: ComputedRef<DownloadModalProject | null>,
selectedVersion: ComputedRef<Labrinth.Versions.v3.Version | null>,
preferences: ComputedRef<Labrinth.Content.v3.ResolutionPreferences>,
) {
return {
queryKey: computed(() => [
'project-download-modal',
'content-resolve',
project.value?.id,
selectedVersion.value?.id,
project.value?.project_type,
preferences.value,
]),
queryFn: () =>
client.labrinth.content_v3.resolve({
project_id: project.value!.id,
version_id: selectedVersion.value!.id,
content_type: resolveContentType(project.value!.project_type),
selected: preferences.value,
target: preferences.value,
}),
staleTime: STALE_TIME,
}
}
function dependencyVersionsQueryOptions(
client: AbstractModrinthClient,
versionIds: ComputedRef<string[]>,
) {
return {
queryKey: computed(() => ['project-download-modal', 'resolved-versions', versionIds.value]),
queryFn: () => client.labrinth.versions_v3.getVersions(versionIds.value),
staleTime: STALE_TIME,
}
}
function dependencyProjectsQueryOptions(
client: AbstractModrinthClient,
projectIds: ComputedRef<string[]>,
) {
return {
queryKey: computed(() => ['project-download-modal', 'resolved-projects', projectIds.value]),
queryFn: () => client.labrinth.projects_v2.getMultiple(projectIds.value),
staleTime: STALE_TIME,
}
}
function createResolutionPreferences(
version: Labrinth.Versions.v3.Version | null,
currentPlatform: string | null,
): Labrinth.Content.v3.ResolutionPreferences {
return {
game_versions: version?.game_versions || [],
loaders: currentPlatform ? [currentPlatform] : version?.loaders || [],
}
}
function visibleDependencies(resolution?: Labrinth.Content.v3.ResolveContentPlan) {
return [...(resolution?.dependencies || []), ...(resolution?.skipped || [])].filter(
shouldShowDependency,
)
}
function getDependencyVersionIds(dependencies: ResolvedContent[]) {
return sortedUnique(
dependencies
.filter((dependency) => !('reason' in dependency))
.map((dependency) => dependency.version_id)
.filter((versionId): versionId is string => !!versionId),
)
}
function getDependencyProjectIds(dependencies: ResolvedContent[]) {
return sortedUnique(
dependencies
.map((dependency) => dependency.project_id)
.filter((projectId): projectId is string => !!projectId),
)
}
function primaryFileForVersion(version?: Labrinth.Versions.v3.Version) {
return version?.files?.find((file) => file.primary) || version?.files?.[0]
}
function shouldShowDependency(dependency: ResolvedContent) {
return !(
'reason' in dependency && ['duplicate_project', 'quilt_fabric_api'].includes(dependency.reason)
)
}
function hasSkippedDuplicateDependency(resolution?: Labrinth.Content.v3.ResolveContentPlan) {
return (resolution?.skipped || []).some((dependency) => dependency.reason === 'duplicate_project')
}
function resolveContentType(projectType: DisplayProjectType): Labrinth.Content.v3.ContentType {
return ['mod', 'plugin', 'datapack', 'resourcepack', 'shader', 'modpack'].includes(projectType)
? (projectType as Labrinth.Content.v3.ContentType)
: 'mod'
}
function additionalFileKey(file: Labrinth.Versions.v3.VersionFile) {
return file.hashes?.sha1 ?? file.filename
}
function dedupeDependencyRows(
rows: DownloadDependencyRow[],
seenDependencies = new Set<string>(),
): DownloadDependencyRow[] {
return rows.flatMap((row) => {
const identity = dependencyRowIdentity(row)
if (seenDependencies.has(identity)) return []
seenDependencies.add(identity)
return [
{
...row,
dependencies: dedupeDependencyRows(row.dependencies, seenDependencies),
},
]
})
}
function dependencyRowIdentity(row: DownloadDependencyRow) {
return row.projectHref ?? row.downloadHref ?? row.key
}
function hasDuplicateDependencyRows(
rows: DownloadDependencyRow[],
seenDependencies = new Set<string>(),
): boolean {
for (const row of rows) {
const rowId = dependencyRowIdentity(row)
if (seenDependencies.has(rowId)) return true
seenDependencies.add(rowId)
if (hasDuplicateDependencyRows(row.dependencies, seenDependencies)) return true
}
return false
}
function hasAdditionalFileRows(rows: DownloadDependencyRow[]): boolean {
for (const row of rows) {
if (row.isAdditionalFile) return true
if (hasAdditionalFileRows(row.dependencies)) return true
}
return false
}
function collectDownloadableDependencyFiles(
rows: DownloadDependencyRow[],
seenHrefs = new Set<string>(),
): DownloadableDependencyFile[] {
const files: DownloadableDependencyFile[] = []
for (const row of rows) {
if (row.downloadHref && !seenHrefs.has(row.downloadHref)) {
seenHrefs.add(row.downloadHref)
files.push({
href: row.downloadHref,
filename: row.filename || filenameFromUrl(row.downloadHref),
name: row.name,
})
}
files.push(...collectDownloadableDependencyFiles(row.dependencies, seenHrefs))
}
return files
}
function filenameFromUrl(url: string) {
try {
const filename = new URL(url).pathname.split('/').pop()
return filename ? decodeURIComponent(filename) : 'dependency.jar'
} catch {
return 'dependency.jar'
}
}
function sortedUnique(values: string[]) {
return [...new Set(values)].sort()
}
const messages = defineMessages({
anyCompatibleDependency: {
id: 'project.download.dependency-any-compatible',
defaultMessage: 'Any compatible',
},
alreadyInstalledDependency: {
id: 'project.download.dependency-already-installed',
defaultMessage: 'This dependency is already installed',
},
conflictingDependency: {
id: 'project.download.dependency-conflicting',
defaultMessage: 'This dependency conflicts with another dependency',
},
duplicateDependency: {
id: 'project.download.dependency-duplicate',
defaultMessage: 'This dependency is already included',
},
missingDependencyVersion: {
id: 'project.download.dependency-missing-version',
defaultMessage: 'This dependency version is unavailable',
},
noCompatibleDependency: {
id: 'project.download.dependency-no-compatible-version',
defaultMessage: 'No compatible version is available for this dependency',
},
quiltFabricApiDependency: {
id: 'project.download.dependency-quilt-fabric-api',
defaultMessage: 'Fabric API is skipped for Quilt',
},
unavailableDependency: {
id: 'project.download.dependency-unavailable',
defaultMessage: 'This dependency cannot be downloaded',
},
unavailableFile: {
id: 'project.download.file-unavailable',
defaultMessage: 'This file cannot be downloaded',
},
requiredResourcePackShort: {
id: 'project.download.required-resource-pack-short',
defaultMessage: 'Resource pack',
},
})
@@ -0,0 +1,963 @@
<template>
<NewModal ref="modal" :on-show="onShow" :on-hide="onHide" width="544px" actions-divider>
<template #title>
<template v-if="project">
<Avatar :src="project.icon_url" :alt="project.title" class="icon" size="32px" />
<div
ref="downloadTitleRef"
v-tooltip="truncatedTooltip(downloadTitleRef, downloadTitle)"
class="truncate text-lg font-extrabold text-contrast"
>
{{ downloadTitle }}
</div>
</template>
</template>
<template #default>
<div v-if="project" class="mx-auto flex w-full flex-col gap-4">
<InstallWithModrinthApp :project="project" />
<DownloadProject
:project="project"
:versions="versions"
:dependency-download-files="dependencyDownloadFiles"
:download-data-loaded="downloadRowsLoaded"
:versions-loaded="versionsLoaded"
:download-reason="downloadReason"
:initial-game-version="initialGameVersion"
:initial-platform="initialPlatform"
:incompatible-game-versions="showOptions.incompatibleGameVersions"
:incompatible-loaders="showOptions.incompatibleLoaders"
:reset-key="downloadProjectResetKey"
@select-game-version="selectGameVersion"
@select-platform="selectPlatform"
@update:selection="updateProjectDownloadSelection"
@download="onDownload"
/>
<div class="flex flex-col gap-4">
<DownloadDependencies @download="onDownload" />
</div>
<ServersPromo
v-if="flags.showProjectPageDownloadModalServersPromo"
:link="`/hosting#plan`"
@close="
() => {
flags.showProjectPageDownloadModalServersPromo = false
saveFeatureFlags()
}
"
/>
</div>
</template>
<template v-if="showDependencyDownloadActions" #actions>
<div class="flex flex-wrap justify-end gap-2 p-2">
<ButtonStyled>
<button
class="!shadow-none"
:disabled="!!downloadingActionType || !dependencyDownloadFilesLoaded"
@click="downloadSelectedVersionZip"
>
<SpinnerIcon
v-if="downloadingActionType === 'zip'"
aria-hidden="true"
class="animate-spin"
/>
<DownloadIcon v-else aria-hidden="true" />
{{ formatMessage(messages.downloadAsZip) }}
</button>
</ButtonStyled>
<JoinedButtons
v-if="hasRecommendedDownloadFiles"
color="brand"
:actions="downloadWithRecommendedActions"
:disabled="!!downloadingActionType || !dependencyDownloadFilesLoaded"
/>
<ButtonStyled v-else color="brand">
<button
class="!shadow-none"
:disabled="!!downloadingActionType || !dependencyDownloadFilesLoaded"
@click="downloadFilesWithDependencies"
>
<SpinnerIcon
v-if="downloadingActionType === 'dependencies'"
aria-hidden="true"
class="animate-spin"
/>
<DownloadIcon v-else aria-hidden="true" />
{{ formatMessage(messages.downloadWithDependencies) }}
</button>
</ButtonStyled>
</div>
</template>
</NewModal>
</template>
<script setup lang="ts">
import type { Labrinth } from '@modrinth/api-client'
import { DownloadIcon, SpinnerIcon } from '@modrinth/assets'
import {
Avatar,
ButtonStyled,
type CdnDownloadReason,
defineMessages,
injectModrinthClient,
injectNotificationManager,
type JoinedButtonAction,
JoinedButtons,
NewModal,
ServersPromo,
truncatedTooltip,
useDebugLogger,
useVIntl,
} from '@modrinth/ui'
import type { DisplayProjectType } from '@modrinth/utils'
import { useQuery, useQueryClient } from '@tanstack/vue-query'
import dayjs from 'dayjs'
import JSZip from 'jszip'
import { computed, nextTick, onUnmounted, ref, watch } from 'vue'
import { navigateTo } from '#app'
import { saveFeatureFlags } from '~/composables/featureFlags.ts'
import { STALE_TIME, STALE_TIME_LONG } from '~/composables/queries/project'
import { provideDownloadModalProvider } from './download-modal-provider'
import DownloadDependencies from './DownloadDependencies.vue'
import DownloadProject from './DownloadProject.vue'
import InstallWithModrinthApp from './InstallWithModrinthApp.vue'
type DownloadModalProject = Omit<Labrinth.Projects.v2.Project, 'project_type'> & {
project_type: DisplayProjectType
actualProjectType: Labrinth.Projects.v2.ProjectType
}
type ProjectDownloadSelection = {
currentGameVersion: string | null
currentPlatform: string | null
selectedVersion: Labrinth.Versions.v3.Version | null
selectedPrimaryFile: Labrinth.Versions.v3.VersionFile | null
}
type DownloadableFile = {
href: string
filename: string
}
type DownloadedFile = DownloadableFile & {
blob: Blob
}
type DownloadActionType = 'zip' | 'dependencies' | 'recommended'
type NewModalRef = {
show: (event?: MouseEvent) => void
hide: () => void
}
type ProjectDownloadModalShowOptions = {
projectId?: string
incompatibleGameVersions?: string[]
incompatibleLoaders?: string[]
}
type ResolvedProjectDownloadModalShowOptions = {
projectId?: string
incompatibleGameVersions: string[]
incompatibleLoaders: string[]
}
const props = withDefaults(
defineProps<{
projectId?: string
downloadReason?: CdnDownloadReason
useRouteHash?: boolean
updateRouteSelection?: boolean
}>(),
{
downloadReason: 'standalone',
useRouteHash: true,
updateRouteSelection: true,
},
)
const emit = defineEmits<{
download: []
}>()
const route = useRoute()
const flags = useFeatureFlags()
const tags = useGeneratedState()
const client = injectModrinthClient()
const queryClient = useQueryClient()
const { createProjectDownloadUrl } = useCdnDownloadContext()
const { addNotification } = injectNotificationManager()
const { formatMessage } = useVIntl()
const debug = useDebugLogger('DownloadModal')
const modal = ref<NewModalRef | null>(null)
const downloadTitleRef = ref<HTMLElement | null>(null)
const modalOpening = ref(false)
const modalOpen = ref(false)
const showProjectId = ref<string | null>(null)
const showOptions = ref<ResolvedProjectDownloadModalShowOptions>(getDefaultShowOptions())
const downloadProjectResetKey = ref(0)
const projectDownloadSelection = ref<ProjectDownloadSelection>(getDefaultProjectDownloadSelection())
const pendingRouteSelection = ref({
gameVersion: getStringQueryValue(route.query.version),
platform: getStringQueryValue(route.query.loader),
})
const downloadingActionType = ref<DownloadActionType | null>(null)
const MODAL_CLOSE_STATE_RESET_MS = 350
const DOWNLOAD_URL_REVOKE_MS = 60000
const DOWNLOAD_STAGGER_MS = 500
let closeStateResetTimeout: ReturnType<typeof setTimeout> | null = null
let modalShowRequestId = 0
let unmounted = false
const routeProjectId = computed(() => showProjectId.value ?? props.projectId ?? null)
const {
data: projectRaw,
error: projectV2Error,
refetch: refetchProject,
} = useQuery({
queryKey: computed(() => ['project', 'v2', routeProjectId.value]),
queryFn: () => client.labrinth.projects_v2.get(routeProjectId.value!),
enabled: computed(() => !!routeProjectId.value),
staleTime: STALE_TIME,
})
const resolvedProjectId = computed(() => projectRaw.value?.id)
const project = computed<DownloadModalProject | null>(() => {
if (!projectRaw.value) return null
return {
...projectRaw.value,
actualProjectType: projectRaw.value.project_type,
project_type: getProjectTypeForUrl(projectRaw.value.project_type, projectRaw.value.loaders),
}
})
const downloadTitle = computed(() => {
if (!project.value) return ''
return formatMessage(messages.downloadTitle, { title: project.value.title })
})
const versionsEnabled = ref(false)
const { data: versionsV3, isFetching: versionsV3Loading } = useQuery({
queryKey: computed(() => ['project', resolvedProjectId.value, 'versions', 'v3']),
queryFn: () =>
client.labrinth.versions_v3.getProjectVersions(resolvedProjectId.value!, {
include_changelog: false,
apiVersion: 3,
}),
staleTime: STALE_TIME_LONG,
enabled: computed(() => !!resolvedProjectId.value && versionsEnabled.value),
})
const versions = computed<Labrinth.Versions.v3.Version[]>(() =>
normalizeVersionsForDownload(versionsV3.value ?? []),
)
const versionsLoaded = computed(
() => versionsEnabled.value && !versionsV3Loading.value && Array.isArray(versionsV3.value),
)
const initialGameVersion = computed(() => {
const version = route.query.version
if (typeof version !== 'string' || !project.value?.game_versions.includes(version)) return null
return version
})
const initialPlatform = computed(() => {
const loader = route.query.loader
if (typeof loader !== 'string' || !project.value?.loaders.includes(loader)) return null
return loader
})
const currentGameVersion = computed(() => projectDownloadSelection.value.currentGameVersion)
const currentPlatform = computed(() => projectDownloadSelection.value.currentPlatform)
const selectedVersion = computed(() => projectDownloadSelection.value.selectedVersion)
const selectedPrimaryFile = computed(() => projectDownloadSelection.value.selectedPrimaryFile)
const additionalFiles = computed(() => {
if (!selectedVersion.value || !selectedPrimaryFile.value) return []
return selectedVersion.value.files.filter((file) => file !== selectedPrimaryFile.value)
})
const hasRequiredResourcePackAdditionalFile = computed(() =>
additionalFiles.value.some((file) => file.file_type === 'required-resource-pack'),
)
const downloadModalProvider = provideDownloadModalProvider({
project,
selectedVersion,
selectedPrimaryFile,
currentGameVersion,
currentPlatform,
downloadReason: computed(() => props.downloadReason),
additionalFiles,
})
const dependencyDownloadFiles = downloadModalProvider.downloadableDependencyFiles
const dependencyDownloadFilesLoaded = downloadModalProvider.downloadableDependencyFilesLoaded
const downloadRowsLoaded = downloadModalProvider.downloadRowsLoaded
const selectedVersionDownloadFiles = computed<DownloadableFile[]>(() => {
if (!selectedVersion.value) return []
return selectedVersion.value.files
.filter(
(file) => file === selectedPrimaryFile.value || file.file_type === 'required-resource-pack',
)
.map((file) => ({
href: createProjectDownloadUrl(file.url, {
reason: props.downloadReason,
gameVersion: currentGameVersion.value ?? undefined,
loader: currentPlatform.value ?? undefined,
}),
filename: file.filename,
}))
})
const recommendedDownloadFiles = computed<DownloadableFile[]>(() => {
if (project.value?.project_type !== 'datapack' || !selectedVersion.value) return []
return selectedVersion.value.files
.filter(
(file) => file !== selectedPrimaryFile.value && file.file_type === 'optional-resource-pack',
)
.map((file) => ({
href: createProjectDownloadUrl(file.url, {
reason: props.downloadReason,
gameVersion: currentGameVersion.value ?? undefined,
loader: currentPlatform.value ?? undefined,
}),
filename: file.filename,
}))
})
const hasRecommendedDownloadFiles = computed(() => recommendedDownloadFiles.value.length > 0)
const showDependencyDownloadActions = computed(
() =>
selectedVersionDownloadFiles.value.length > 0 &&
(dependencyDownloadFiles.value.length > 0 ||
hasRequiredResourcePackAdditionalFile.value ||
hasRecommendedDownloadFiles.value),
)
const downloadWithRecommendedActions = computed<JoinedButtonAction[]>(() => [
{
id: 'download-with-dependencies',
label: formatMessage(messages.downloadWithDependencies),
icon: downloadingActionType.value === 'dependencies' ? SpinnerIcon : DownloadIcon,
action: () => void downloadFilesWithDependencies(),
},
{
id: 'download-with-recommended',
label: formatMessage(messages.downloadWithRecommended),
icon: DownloadIcon,
action: () => void downloadFilesWithRecommended(),
},
{
id: 'download-with-recommended-zip',
label: formatMessage(messages.downloadWithRecommendedAsZip),
icon: DownloadIcon,
action: () => void downloadSelectedVersionZip(),
},
])
watch(projectV2Error, (error) => {
if (error) {
debug('project query failed', error)
}
})
const messages = defineMessages({
downloadTitle: {
id: 'project.download.title',
defaultMessage: 'Download {title}',
},
downloadAsZip: {
id: 'project.download.download-as-zip',
defaultMessage: 'Download as .zip',
},
downloadWithDependencies: {
id: 'project.download.download-with-dependencies',
defaultMessage: 'Download with deps',
},
downloadWithRecommended: {
id: 'project.download.download-with-recommended',
defaultMessage: 'Download with recommended',
},
downloadWithRecommendedAsZip: {
id: 'project.download.download-with-recommended-as-zip',
defaultMessage: 'Download with recommended as .zip',
},
downloadZipFailedTitle: {
id: 'project.download.zip-failed-title',
defaultMessage: 'Could not download files',
},
downloadZipFailedText: {
id: 'project.download.zip-failed-text',
defaultMessage: 'One or more files could not be downloaded. Please try again.',
},
})
function getProjectTypeForUrl(
type: Labrinth.Projects.v2.ProjectType,
loaders: string[],
): DisplayProjectType {
if (type !== 'mod') return type as DisplayProjectType
const isMod = loaders.some((loader) => tags.value.loaderData.modLoaders.includes(loader))
const isPlugin = loaders.some((loader) => tags.value.loaderData.allPluginLoaders.includes(loader))
const isDataPack = loaders.some((loader) =>
tags.value.loaderData.dataPackLoaders.includes(loader),
)
if (isDataPack) return 'datapack'
if (isPlugin) return 'plugin'
if (isMod) return 'mod'
return 'mod'
}
function updateDownloadQuery({
gameVersion,
platform,
}: {
gameVersion: string | null
platform: string | null
}) {
if (!props.updateRouteSelection) return
const nextGameVersion =
gameVersion ??
pendingRouteSelection.value.gameVersion ??
getStringQueryValue(route.query.version)
const nextPlatform =
platform ?? pendingRouteSelection.value.platform ?? getStringQueryValue(route.query.loader)
pendingRouteSelection.value = {
gameVersion: nextGameVersion,
platform: nextPlatform,
}
navigateTo(
{
query: {
...route.query,
...(nextGameVersion && {
version: nextGameVersion,
}),
...(nextPlatform && {
loader: nextPlatform,
}),
},
hash: route.hash,
},
{ replace: true },
)
}
function selectGameVersion(gameVersion: string) {
updateDownloadQuery({
gameVersion,
platform: null,
})
}
function selectPlatform(platform: string) {
updateDownloadQuery({
gameVersion: null,
platform,
})
}
function updateProjectDownloadSelection(selection: ProjectDownloadSelection) {
projectDownloadSelection.value = selection
pendingRouteSelection.value = {
gameVersion: selection.currentGameVersion,
platform: selection.currentPlatform,
}
}
function onShow() {
clearCloseStateResetTimeout()
modalOpen.value = true
debug('on-show fired')
versionsEnabled.value = true
if (props.useRouteHash && !showProjectId.value) {
navigateTo({ query: route.query, hash: '#download' }, { replace: true })
}
}
function onHide() {
const hadShowProjectId = !!showProjectId.value
modalOpen.value = false
clearCloseStateResetTimeout()
closeStateResetTimeout = setTimeout(() => {
showProjectId.value = null
showOptions.value = getDefaultShowOptions()
closeStateResetTimeout = null
}, MODAL_CLOSE_STATE_RESET_MS)
if (props.useRouteHash && !hadShowProjectId) {
navigateTo({ query: route.query, hash: '' }, { replace: true })
}
}
async function show(
event?: MouseEvent,
options: ProjectDownloadModalShowOptions = {},
): Promise<void> {
if (!modal.value || modalOpening.value || modalOpen.value) return
const showRequestId = ++modalShowRequestId
modalOpening.value = true
try {
await waitForCloseStateReset()
if (!isActiveShowRequest(showRequestId)) return
showOptions.value = {
...getDefaultShowOptions(),
...options,
}
showProjectId.value = showOptions.value.projectId ?? null
await nextTick()
if (!isActiveShowRequest(showRequestId)) return
if (!(await loadProjectForModal(!!showOptions.value.projectId))) return
if (!isActiveShowRequest(showRequestId)) return
resetDownloadState()
await preloadRouteSelectedDownload()
if (!isActiveShowRequest(showRequestId)) return
modalOpen.value = true
modal.value.show(event)
} finally {
if (modalShowRequestId === showRequestId) {
modalOpening.value = false
}
}
}
function hide() {
modalShowRequestId += 1
modalOpening.value = false
if (!modal.value || !modalOpen.value) return
modal.value?.hide()
}
function onDownload() {
emit('download')
}
async function downloadSelectedVersionZip() {
if (downloadingActionType.value || !dependencyDownloadFilesLoaded.value) return
downloadingActionType.value = 'zip'
const files = dedupeDownloadFiles([
...selectedVersionDownloadFiles.value,
...dependencyDownloadFiles.value,
...recommendedDownloadFiles.value,
])
try {
const zip = new JSZip()
const usedFilenames = new Set<string>()
const downloadedFiles = await downloadFileBlobs(files)
for (const file of downloadedFiles) {
zip.file(uniqueFilename(file.filename, usedFilenames), file.blob)
}
downloadBlob(
await zip.generateAsync({
type: 'blob',
mimeType: 'application/zip',
}),
selectedVersionZipFilename(),
)
emit('download')
} catch (error) {
console.error('Failed to download selected version files:', error)
addNotification({
title: formatMessage(messages.downloadZipFailedTitle),
text: formatMessage(messages.downloadZipFailedText),
type: 'error',
})
} finally {
downloadingActionType.value = null
}
}
async function downloadFilesWithDependencies() {
if (downloadingActionType.value || !dependencyDownloadFilesLoaded.value) return
downloadingActionType.value = 'dependencies'
try {
const files = dedupeDownloadFiles([
...selectedVersionDownloadFiles.value,
...dependencyDownloadFiles.value,
])
await downloadFiles(files)
emit('download')
} catch (error) {
console.error('Failed to download selected version files:', error)
addNotification({
title: formatMessage(messages.downloadZipFailedTitle),
text: formatMessage(messages.downloadZipFailedText),
type: 'error',
})
} finally {
downloadingActionType.value = null
}
}
async function downloadFilesWithRecommended() {
if (downloadingActionType.value || !dependencyDownloadFilesLoaded.value) return
downloadingActionType.value = 'recommended'
try {
const files = dedupeDownloadFiles([
...selectedVersionDownloadFiles.value,
...dependencyDownloadFiles.value,
...recommendedDownloadFiles.value,
])
await downloadFiles(files)
emit('download')
} catch (error) {
console.error('Failed to download selected version files:', error)
addNotification({
title: formatMessage(messages.downloadZipFailedTitle),
text: formatMessage(messages.downloadZipFailedText),
type: 'error',
})
} finally {
downloadingActionType.value = null
}
}
async function downloadFileBlobs(files: DownloadableFile[]): Promise<DownloadedFile[]> {
return Promise.all(files.map((file) => downloadFileBlob(file)))
}
async function downloadFileBlob(file: DownloadableFile): Promise<DownloadedFile> {
const response = await fetch(file.href)
if (!response.ok) {
throw new Error(`Failed to download ${file.filename}`)
}
return {
...file,
blob: await response.blob(),
}
}
async function downloadFiles(files: DownloadableFile[]) {
await Promise.all(
files.map(async (file, index) => {
await delay(DOWNLOAD_STAGGER_MS * index)
downloadFileLink(file)
}),
)
}
function dedupeDownloadFiles(files: DownloadableFile[]) {
const result: DownloadableFile[] = []
const hrefs = new Set<string>()
for (const file of files) {
if (hrefs.has(file.href)) continue
hrefs.add(file.href)
result.push(file)
}
return result
}
function downloadBlob(blob: Blob, filename: string) {
const url = URL.createObjectURL(blob)
const link = document.createElement('a')
link.href = url
link.download = filename
document.body.appendChild(link)
link.click()
link.remove()
window.setTimeout(() => URL.revokeObjectURL(url), DOWNLOAD_URL_REVOKE_MS)
}
function downloadFileLink(file: DownloadableFile) {
const link = document.createElement('a')
link.href = file.href
link.download = file.filename
document.body.appendChild(link)
link.click()
link.remove()
}
function selectedVersionZipFilename() {
if (!project.value || !selectedVersion.value) return 'download.zip'
return `${sanitizeFilename(project.value.title)} ${sanitizeFilename(
selectedVersion.value.version_number,
)}-EXTRACT_ME.zip`
}
function sanitizeFilename(value: string) {
const sanitized = value
.replace(/[<>:"/\\|?*]/g, '')
.replace(/\s+/g, ' ')
.trim()
return sanitized || 'download'
}
function uniqueFilename(filename: string, usedFilenames: Set<string>) {
const sanitizedFilename = sanitizeFilename(filename)
if (!usedFilenames.has(sanitizedFilename)) {
usedFilenames.add(sanitizedFilename)
return sanitizedFilename
}
const extensionIndex = sanitizedFilename.lastIndexOf('.')
const basename =
extensionIndex > 0 ? sanitizedFilename.slice(0, extensionIndex) : sanitizedFilename
const extension = extensionIndex > 0 ? sanitizedFilename.slice(extensionIndex) : ''
let index = 2
let candidate = `${basename} (${index})${extension}`
while (usedFilenames.has(candidate)) {
index += 1
candidate = `${basename} (${index})${extension}`
}
usedFilenames.add(candidate)
return candidate
}
function getDefaultProjectDownloadSelection(): ProjectDownloadSelection {
return {
currentGameVersion: null,
currentPlatform: null,
selectedVersion: null,
selectedPrimaryFile: null,
}
}
function getDefaultShowOptions(): ResolvedProjectDownloadModalShowOptions {
return {
projectId: undefined,
incompatibleGameVersions: [],
incompatibleLoaders: [],
}
}
function getStringQueryValue(value: unknown) {
return typeof value === 'string' ? value : null
}
function shouldPreloadRouteSelectedDownload() {
return (
props.useRouteHash &&
!showOptions.value.projectId &&
!!getStringQueryValue(route.query.version) &&
!!getStringQueryValue(route.query.loader)
)
}
function clearCloseStateResetTimeout() {
if (!closeStateResetTimeout) return
clearTimeout(closeStateResetTimeout)
closeStateResetTimeout = null
}
async function waitForCloseStateReset() {
if (!closeStateResetTimeout) return
await delay(MODAL_CLOSE_STATE_RESET_MS)
}
async function delay(ms: number) {
await new Promise((resolve) => setTimeout(resolve, ms))
}
async function loadProjectForModal(forceRefetch: boolean) {
if (!routeProjectId.value) return false
if (!forceRefetch && projectRaw.value) return true
const { data } = await refetchProject()
return !!data
}
async function loadVersionsForModal() {
if (!resolvedProjectId.value) return null
versionsEnabled.value = true
if (versionsV3.value) return versions.value
const data = await queryClient.ensureQueryData({
queryKey: ['project', resolvedProjectId.value, 'versions', 'v3'],
queryFn: () =>
client.labrinth.versions_v3.getProjectVersions(resolvedProjectId.value!, {
include_changelog: false,
apiVersion: 3,
}),
staleTime: STALE_TIME_LONG,
})
return Array.isArray(data) ? normalizeVersionsForDownload(data) : null
}
async function preloadRouteSelectedDownload() {
if (!shouldPreloadRouteSelectedDownload()) return
try {
const routeVersions = await loadVersionsForModal()
if (!routeVersions) return
const selection = getRouteSelectedDownloadSelection(routeVersions)
if (!selection) return
await preloadDependenciesForSelection(selection)
} catch (error) {
debug('failed to preload selected route download', error)
}
}
function getRouteSelectedDownloadSelection(
versionList: Labrinth.Versions.v3.Version[] = versions.value,
): ProjectDownloadSelection | null {
const gameVersion = initialGameVersion.value
const platform = initialPlatform.value
const version = getSelectedRouteVersion(gameVersion, platform, versionList)
const primaryFile = version?.files?.find((file) => file.primary) || version?.files?.[0] || null
if (!gameVersion || !platform || !version || !primaryFile) return null
return {
currentGameVersion: gameVersion,
currentPlatform: platform,
selectedVersion: version,
selectedPrimaryFile: primaryFile,
}
}
function getSelectedRouteVersion(
gameVersion: string | null,
platform: string | null,
versionList: Labrinth.Versions.v3.Version[],
) {
if (!gameVersion || !platform || !project.value) return null
const filteredVersions = versionList.filter((version) => {
const matchesPlatform =
project.value?.project_type === 'resourcepack' ||
(!!platform && version.loaders.includes(platform))
return version.game_versions.includes(gameVersion) && matchesPlatform
})
return (
latestVersionByType(filteredVersions, 'release') ||
latestVersionByType(filteredVersions, 'beta') ||
latestVersionByType(filteredVersions, 'alpha') ||
null
)
}
function normalizeVersionsForDownload(
versionList: Labrinth.Versions.v3.Version[],
): Labrinth.Versions.v3.Version[] {
const isModpack =
project.value?.actualProjectType === 'modpack' || project.value?.project_type === 'modpack'
return versionList.map((version) => {
const files = Array.isArray(version.files) ? version.files : []
const gameVersions = Array.isArray(version.game_versions) ? version.game_versions : []
const loaders = Array.isArray(version.loaders) ? version.loaders : []
const mrpackLoaders = Array.isArray(version.mrpack_loaders) ? version.mrpack_loaders : []
return {
...version,
files,
game_versions: gameVersions,
loaders: isModpack && mrpackLoaders.length ? mrpackLoaders : loaders,
}
})
}
function latestVersionByType(
versionList: Labrinth.Versions.v3.Version[],
type: Labrinth.Versions.v3.VersionChannel,
) {
return versionList
.filter((version) => version.version_type === type)
.reduce<Labrinth.Versions.v3.Version | undefined>((latest, version) => {
if (!latest || dayjs(version.date_published).isAfter(dayjs(latest.date_published))) {
return version
}
return latest
}, undefined)
}
async function preloadDependenciesForSelection(selection: ProjectDownloadSelection) {
await downloadModalProvider.preloadDependenciesForSelection(selection)
}
function isActiveShowRequest(showRequestId: number) {
return !unmounted && modalShowRequestId === showRequestId && !!modal.value && !modalOpen.value
}
function resetDownloadState() {
projectDownloadSelection.value = getDefaultProjectDownloadSelection()
downloadProjectResetKey.value += 1
}
function openFromHash() {
if (
!props.useRouteHash ||
!modal.value ||
modalOpening.value ||
modalOpen.value ||
showProjectId.value ||
route.hash !== '#download'
) {
return
}
debug('hash #download watch fired, opening modal')
show()
}
if (
props.useRouteHash &&
(route.hash === '#download' ||
route.query.version !== undefined ||
route.query.loader !== undefined)
) {
debug('eager loadVersions from setup', {
hash: route.hash,
version: route.query.version,
loader: route.query.loader,
loading: versionsV3Loading.value,
})
versionsEnabled.value = true
}
watch(modal, openFromHash)
watch(() => route.hash, openFromHash)
watch(routeProjectId, () => {
projectDownloadSelection.value = getDefaultProjectDownloadSelection()
downloadProjectResetKey.value += 1
})
onUnmounted(() => {
unmounted = true
modalShowRequestId += 1
clearCloseStateResetTimeout()
})
defineExpose({ show, hide })
</script>
@@ -6,7 +6,7 @@
:description="
formatMessage(messages.removePasskeyConfirmDescription, { name: passkeyToRemove?.name })
"
:proceed-label="formatMessage(commonMessages.removeButton)"
:proceed-label="formatMessage(messages.deletePasskeyButton)"
@proceed="removePasskey()"
/>
@@ -70,7 +70,7 @@
</ButtonStyled>
<ButtonStyled circular>
<button
v-tooltip="formatMessage(commonMessages.removeButton)"
v-tooltip="formatMessage(messages.deletePasskeyButton)"
@click="
() => {
passkeyToRemove = passkey
@@ -88,7 +88,7 @@
<ButtonStyled>
<button @click="registerPasskey()">
<PlusIcon />
{{ formatMessage(messages.managePasskeyAddPasskey) }}
{{ formatMessage(messages.managePasskeyAddPasskeyButton) }}
</button>
</ButtonStyled>
<ButtonStyled>
@@ -104,7 +104,7 @@
<NewModal
ref="addPasskeyModal"
width="500px"
:header="formatMessage(messages.managePasskeyAddPasskey)"
:header="formatMessage(messages.passkeyAddModalTitle)"
>
<div class="flex flex-col gap-6">
<div class="flex flex-col gap-2.5">
@@ -133,7 +133,7 @@
<ButtonStyled color="brand">
<button :disabled="!pendingPasskeyName" @click="finishRegisterPasskey()">
<PlusIcon />
{{ formatMessage(messages.managePasskeyAddPasskey) }}
{{ formatMessage(messages.managePasskeyAddPasskeyButton) }}
</button>
</ButtonStyled>
</div>
@@ -189,7 +189,7 @@
<div>
<ButtonStyled>
<button id="manage-passkeys" @click="showPasskeyModal">
<UserKeyIcon /> {{ formatMessage(messages.managePasskeyTitle) }}
<UserKeyIcon /> {{ formatMessage(messages.managePasskeyButton) }}
</button>
</ButtonStyled>
</div>
@@ -247,17 +247,21 @@ const messages = defineMessages({
id: 'settings.account.security.passkey.title',
defaultMessage: 'Manage passkeys',
},
managePasskeyButton: {
id: 'settings.account.security.passkey.button',
defaultMessage: 'Manage passkeys',
},
managePasskeyDescription: {
id: 'settings.account.security.passkey.description',
defaultMessage: 'Manage your registered passkeys, or add a new one.',
},
managePasskeyAddPasskey: {
id: 'settings.account.security.passkey.add',
managePasskeyAddPasskeyButton: {
id: 'settings.account.security.passkey.add.button',
defaultMessage: 'Add passkey',
},
managePasskeyModalLoading: {
id: 'settings.account.security.passkey.modal.loading',
defaultMessage: 'Loading passkeys',
defaultMessage: 'Loading passkeys...',
},
managePasskeyModalNoPasskeys: {
id: 'settings.account.security.passkey.modal.no-passkeys',
@@ -275,6 +279,10 @@ const messages = defineMessages({
id: 'settings.account.security.passkey.modal.never-used',
defaultMessage: 'Never used',
},
passkeyAddModalTitle: {
id: 'settings.account.security.passkey.add-modal.title',
defaultMessage: 'Add passkey',
},
passkeyNameLabel: {
id: 'settings.account.security.passkey.add-modal.name.label',
defaultMessage: 'Name',
@@ -292,6 +300,10 @@ const messages = defineMessages({
id: 'settings.account.security.passkey.rename-modal.header',
defaultMessage: 'Rename passkey',
},
deletePasskeyButton: {
id: 'settings.account.security.passkey.remove.button',
defaultMessage: 'Delete passkey',
},
removePasskeyConfirmTitle: {
id: 'settings.account.security.passkey.remove.title',
defaultMessage: 'Are you sure you want to remove this passkey?',
@@ -73,6 +73,7 @@
:options="[userOption, ...ownerOptions]"
searchable
:disabled="hasHitLimit"
select-search-text-on-focus
show-icon-in-selected
/>
<span>{{ formatMessage(messages.ownerDescription) }}</span>
@@ -129,7 +130,7 @@
<script setup lang="ts">
import type { Labrinth } from '@modrinth/api-client'
import { PlusIcon, XIcon } from '@modrinth/assets'
import { OrganizationIcon, PlusIcon, XIcon } from '@modrinth/assets'
import {
ButtonStyled,
Chips,
@@ -362,7 +363,13 @@ async function fetchOrganizations() {
}),
),
)
: undefined,
: markRaw(
defineAsyncComponent(() =>
Promise.resolve({
setup: () => () => h(OrganizationIcon, { class: 'size-5' }),
}),
),
),
}))
if (props.organizationId) owner.value = props.organizationId
} catch (err) {
@@ -395,6 +402,7 @@ async function createProject() {
server_side: 'required',
license_id: 'LicenseRef-Unknown',
is_draft: true,
organization_id: owner.value !== 'self' ? owner.value : undefined,
}
formData.append('data', JSON.stringify(projectData))
@@ -0,0 +1,63 @@
<template>
<div class="rounded-lg border border-divider bg-bg-raised p-3">
<div class="flex flex-wrap items-start justify-between gap-3">
<div class="min-w-0">
<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>
</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" />
<ButtonStyled>
<NuxtLink :to="localTraceLink">
<ExternalIcon aria-hidden="true" />
View
</NuxtLink>
</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 { Badge, ButtonStyled } from '@modrinth/ui'
const props = defineProps<{
trace: Labrinth.TechReview.Internal.GlobalIssueDetailTrace
}>()
const localTraceLink = computed(
() =>
`/moderation/technical-review/${props.trace.project_id}?detail=${encodeURIComponent(
props.trace.detail_id,
)}`,
)
</script>
@@ -0,0 +1,226 @@
<template>
<div>
<form class="flex flex-col gap-2 sm:flex-row" @submit.prevent="executeSearch">
<StyledInput
v-model="query"
:icon="SearchIcon"
type="text"
autocomplete="off"
placeholder="Search global trace keys..."
clearable
wrapper-class="flex-1 w-full"
/>
<ButtonStyled color="brand">
<button type="submit" :disabled="isLoading">
<SearchIcon aria-hidden="true" />
Search
</button>
</ButtonStyled>
</form>
<div
v-if="!isLoading && !loadError && total > 0"
class="mt-4 flex flex-wrap items-center justify-between gap-3"
>
<p class="m-0 text-sm text-secondary">Showing {{ pageStart }}-{{ pageEnd }} of {{ total }}</p>
<Pagination :page="currentPage" :count="pageCount" @switch-page="switchPage" />
</div>
<EmptyState
v-if="isLoading"
type="no-search-result"
heading="Loading global detail traces..."
/>
<EmptyState
v-else-if="loadError"
type="no-search-result"
heading="Failed to load global detail traces"
/>
<div v-else-if="traces.length > 0" class="mt-4 flex flex-col gap-3">
<article
v-for="trace in traces"
: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="min-w-0">
<div class="flex min-w-0 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>
</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">
<Badge :type="trace.verdict" />
<ButtonStyled color="red">
<button
:disabled="removingTraceKeys.has(trace.detail_key)"
@click="removeGlobalTrace(trace)"
>
<TrashIcon aria-hidden="true" />
Remove
</button>
</ButtonStyled>
</div>
</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"
>
<p class="m-0 text-sm text-secondary">
Showing first {{ getPreviewLocalTraces(trace).length }} of
{{ getVisibleLocalTraceTotal(trace) }} local traces
</p>
<ButtonStyled>
<NuxtLink :to="getGlobalTraceLink(trace)">
<ListIcon aria-hidden="true" />
View all
</NuxtLink>
</ButtonStyled>
</div>
<GlobalDetailLocalTraceCard
v-for="localTrace in getPreviewLocalTraces(trace)"
:key="localTrace.detail_id"
:trace="localTrace"
/>
</div>
<EmptyState
v-else
type="no-search-result"
heading="No local traces currently match this key"
/>
</article>
</div>
<EmptyState v-else type="no-search-result" heading="No global detail traces found" />
<div v-if="!isLoading && !loadError && total > 0" class="mt-4 flex justify-end">
<Pagination :page="currentPage" :count="pageCount" @switch-page="switchPage" />
</div>
</div>
</template>
<script setup lang="ts">
import type { Labrinth } from '@modrinth/api-client'
import { HashIcon, ListIcon, SearchIcon, TrashIcon } from '@modrinth/assets'
import {
Badge,
ButtonStyled,
EmptyState,
injectModrinthClient,
injectNotificationManager,
Pagination,
StyledInput,
} from '@modrinth/ui'
import GlobalDetailLocalTraceCard from '~/components/ui/moderation/GlobalDetailLocalTraceCard.vue'
const client = injectModrinthClient()
const { addNotification } = injectNotificationManager()
const query = ref('')
const activeQuery = ref<string | null>(null)
const isLoading = ref(false)
const loadError = ref(false)
const currentPage = ref(1)
const itemsPerPage = 20
const localTracePreviewLimit = 10
const total = ref(0)
const traces = ref<Labrinth.TechReview.Internal.GlobalIssueDetail[]>([])
const removingTraceKeys = reactive<Set<string>>(new Set())
const pageCount = computed(() => Math.max(Math.ceil(total.value / itemsPerPage), 1))
const pageStart = computed(() =>
total.value === 0 ? 0 : (currentPage.value - 1) * itemsPerPage + 1,
)
const pageEnd = computed(() => Math.min(currentPage.value * itemsPerPage, total.value))
function formatTraceCount(count: number) {
return `${count} local ${count === 1 ? 'trace' : 'traces'}`
}
function getPreviewLocalTraces(trace: Labrinth.TechReview.Internal.GlobalIssueDetail) {
return trace.local_traces.slice(0, localTracePreviewLimit)
}
function getVisibleLocalTraceTotal(trace: Labrinth.TechReview.Internal.GlobalIssueDetail) {
return Math.max(trace.local_trace_count, trace.local_traces.length)
}
function getGlobalTraceLink(trace: Labrinth.TechReview.Internal.GlobalIssueDetail) {
return `/moderation/global-traces/${encodeURIComponent(trace.detail_key)}`
}
async function loadTraces() {
isLoading.value = true
loadError.value = false
try {
const response = await client.labrinth.tech_review_internal.searchGlobalIssueDetails({
query: activeQuery.value,
limit: itemsPerPage,
page: currentPage.value - 1,
})
traces.value = response.traces
total.value = response.total
} catch (error) {
console.error('Failed to load global detail traces', error)
traces.value = []
total.value = 0
loadError.value = true
} finally {
isLoading.value = false
}
}
async function executeSearch() {
activeQuery.value = query.value.trim() || null
currentPage.value = 1
await loadTraces()
}
async function switchPage(page: number) {
currentPage.value = page
await loadTraces()
}
async function removeGlobalTrace(trace: Labrinth.TechReview.Internal.GlobalIssueDetail) {
if (removingTraceKeys.has(trace.detail_key)) return
removingTraceKeys.add(trace.detail_key)
try {
await client.labrinth.tech_review_internal.updateGlobalIssueDetails([
{ detail_key: trace.detail_key, verdict: 'pending' },
])
addNotification({
type: 'success',
title: 'Global trace removed',
text: 'The global verdict for this trace key has been removed.',
})
if (traces.value.length === 1 && currentPage.value > 1) {
currentPage.value--
}
await loadTraces()
} catch (error) {
console.error('Failed to remove global trace', error)
addNotification({
type: 'error',
title: 'Failed to remove global trace',
text: 'An error occurred while removing the global trace verdict.',
})
} finally {
removingTraceKeys.delete(trace.detail_key)
}
}
onMounted(loadTraces)
</script>
@@ -2,12 +2,7 @@
<div class="shadow-card rounded-2xl border border-solid border-surface-4 bg-surface-3 p-4">
<div class="flex items-center justify-between">
<div class="flex items-center gap-4">
<NuxtLink
:to="`/project/${queueEntry.project.slug}`"
target="_blank"
tabindex="-1"
class="flex"
>
<NuxtLink :to="`/project/${projectRouteParam}`" target="_blank" tabindex="-1" class="flex">
<Avatar
:src="queueEntry.project.icon_url"
size="4rem"
@@ -17,7 +12,7 @@
<div class="flex flex-col gap-1.5">
<div class="flex items-center gap-2">
<NuxtLink
:to="`/project/${queueEntry.project.slug}`"
:to="`/project/${projectRouteParam}`"
target="_blank"
class="text-lg font-semibold text-contrast hover:underline"
>
@@ -177,7 +172,7 @@ function getDaysQueued(date: Date): number {
const queuedDate = computed(() => {
return dayjs(
props.queueEntry.project.queued ||
props.queueEntry.project.created ||
props.queueEntry.project.published ||
props.queueEntry.project.updated,
)
})
@@ -186,10 +181,14 @@ const daysInQueue = computed(() => {
return getDaysQueued(queuedDate.value.toDate())
})
const projectRouteParam = computed(
() => props.queueEntry.project.slug || props.queueEntry.project.id,
)
const formattedDate = computed(() => {
const date =
props.queueEntry.project.queued ||
props.queueEntry.project.created ||
props.queueEntry.project.published ||
props.queueEntry.project.updated
if (!date) return 'Unknown'
@@ -202,7 +201,7 @@ const formattedDate = computed(() => {
function copyLink() {
const base = window.location.origin
const projectUrl = `${base}/project/${props.queueEntry.project.slug}`
const projectUrl = `${base}/project/${projectRouteParam.value}`
navigator.clipboard.writeText(projectUrl).then(() => {
addNotification({
type: 'success',
@@ -37,21 +37,22 @@
>
{{ formatRelativeTime(report.created) }}
</span>
<ButtonStyled circular>
<OverflowMenu :options="quickActions">
<template #default>
<EllipsisVerticalIcon class="size-4" />
</template>
<template #copy-id>
<div class="flex items-center gap-2">
<ButtonStyled circular>
<button v-tooltip="'Copy ID'" @click="copyId">
<ClipboardCopyIcon />
<span class="hidden sm:inline">Copy ID</span>
</template>
<template #copy-link>
<LinkIcon />
<span class="hidden sm:inline">Copy link</span>
</template>
</OverflowMenu>
</ButtonStyled>
</button>
</ButtonStyled>
<ButtonStyled circular>
<a
v-tooltip="'Open in new tab'"
:href="`/moderation/reports/${props.report.id}`"
target="_blank"
>
<ExternalIcon />
</a>
</ButtonStyled>
</div>
</div>
</div>
@@ -183,12 +184,7 @@
</div>
</template>
<script setup lang="ts">
import {
CheckCircleIcon,
ClipboardCopyIcon,
EllipsisVerticalIcon,
LinkIcon,
} from '@modrinth/assets'
import { CheckCircleIcon, ClipboardCopyIcon, ExternalIcon } from '@modrinth/assets'
import { type ExtendedReport, reportQuickReplies } from '@modrinth/moderation'
import {
Avatar,
@@ -196,8 +192,6 @@ import {
CollapsibleRegion,
getProjectTypeIcon,
injectNotificationManager,
OverflowMenu,
type OverflowMenuOption,
useFormatDateTime,
useRelativeTime,
} from '@modrinth/ui'
@@ -328,35 +322,6 @@ function updateThread(newThread: any) {
}
}
const quickActions: OverflowMenuOption[] = [
{
id: 'copy-link',
action: () => {
const base = window.location.origin
const reportUrl = `${base}/moderation/reports/${props.report.id}`
navigator.clipboard.writeText(reportUrl).then(() => {
addNotification({
type: 'success',
title: 'Report link copied',
text: 'The link to this report has been copied to your clipboard.',
})
})
},
},
{
id: 'copy-id',
action: () => {
navigator.clipboard.writeText(props.report.id).then(() => {
addNotification({
type: 'success',
title: 'Report ID copied',
text: 'The ID of this report has been copied to your clipboard.',
})
})
},
},
]
const reportItemAvatarUrl = computed(() => {
switch (props.report.item_type) {
case 'project':
@@ -395,4 +360,14 @@ const formattedReportType = computed(() => {
const words = reportType.includes('-') ? reportType.split('-') : reportType.split(' ')
return words.map((word) => word.charAt(0).toUpperCase() + word.slice(1)).join(' ')
})
function copyId() {
navigator.clipboard.writeText(props.report.id).then(() => {
addNotification({
type: 'success',
title: 'Report ID copied',
text: 'The ID of this report has been copied to your clipboard.',
})
})
}
</script>
@@ -1,7 +1,9 @@
<script setup lang="ts">
import type { Labrinth } from '@modrinth/api-client'
import {
BanIcon,
BugIcon,
CheckCheckIcon,
CheckCircleIcon,
CheckIcon,
ChevronDownIcon,
@@ -10,11 +12,11 @@ import {
CodeIcon,
CopyIcon,
DownloadIcon,
EllipsisVerticalIcon,
ExternalIcon,
EyeOffIcon,
LinkIcon,
LoaderCircleIcon,
ScaleIcon,
ShieldAlertIcon,
ShieldCheckIcon,
SpinnerIcon,
TimerIcon,
@@ -46,7 +48,7 @@ import {
type User,
} from '@modrinth/utils'
import dayjs from 'dayjs'
import { computed, reactive, ref, watch } from 'vue'
import { computed, nextTick, reactive, ref, watch } from 'vue'
import type { UnsafeFile } from '~/components/ui/moderation/MaliciousSummaryModal.vue'
import ThreadView from '~/components/ui/thread/ThreadView.vue'
@@ -90,6 +92,7 @@ const props = defineProps<{
thread: Labrinth.TechReview.Internal.Thread
reports: FlattenedFileReport[]
}
focusedDetailId?: string | null
loadingIssues: Set<string>
decompiledSources: Map<string, string>
}>()
@@ -113,51 +116,6 @@ const isProjectApproved = computed(() => {
)
})
const quickActions = computed<OverflowMenuOption[]>(() => {
const actions: OverflowMenuOption[] = []
const sourceUrl = props.item.project.link_urls?.['source']?.url
if (sourceUrl) {
actions.push({
id: 'view-source',
action: () => {
window.open(sourceUrl, '_blank', 'noopener,noreferrer')
},
})
}
actions.push(
{
id: 'copy-link',
action: () => {
const base = window.location.origin
const reportUrl = `${base}/moderation/technical-review/${props.item.project.id}`
navigator.clipboard.writeText(reportUrl).then(() => {
addNotification({
type: 'success',
title: 'Technical Review link copied',
text: 'The link to this review has been copied to your clipboard.',
})
})
},
},
{
id: 'copy-id',
action: () => {
navigator.clipboard.writeText(props.item.project.id).then(() => {
addNotification({
type: 'success',
title: 'Project ID copied',
text: 'The ID of this project has been copied to your clipboard.',
})
})
},
},
)
return actions
})
const isLoadingStatusAction = ref(false)
const projectStatusActions = computed<OverflowMenuOption[]>(() => [
{
@@ -165,30 +123,36 @@ const projectStatusActions = computed<OverflowMenuOption[]>(() => [
color: 'green',
action: () => setStatus('approved'),
hoverFilled: true,
disabled: isProjectApproved.value || isLoadingStatusAction.value,
disabled: isStatusActionDisabled('approved'),
},
{
id: 'withhold',
color: 'orange',
action: () => setStatus('withheld'),
hoverFilled: true,
disabled: projectStatus.value === 'withheld' || isLoadingStatusAction.value,
disabled: isStatusActionDisabled('withheld'),
},
{
id: 'send-to-review',
action: () => setStatus('processing'),
hoverFilled: true,
disabled: projectStatus.value === 'processing' || isLoadingStatusAction.value,
disabled: isStatusActionDisabled('processing'),
},
{
id: 'reject',
color: 'red',
action: () => setStatus('rejected'),
hoverFilled: true,
disabled: projectStatus.value === 'rejected' || isLoadingStatusAction.value,
disabled: isStatusActionDisabled('rejected'),
},
])
function isStatusActionDisabled(status: Labrinth.Projects.v2.ProjectStatus): boolean {
const currentStatus = projectStatus.value
const isLoading = isLoadingStatusAction.value
return currentStatus === status || isLoading
}
async function setStatus(status: Labrinth.Projects.v2.ProjectStatus) {
isLoadingStatusAction.value = true
try {
@@ -238,7 +202,12 @@ watch(selectedFile, (newFile) => {
const client = injectModrinthClient()
async function updateIssueDetails(data: { detail_id: string; verdict: 'safe' | 'unsafe' }[]) {
async function updateIssueDetails(
data: {
detail_id: string
verdict: Labrinth.TechReview.Internal.DelphiReportIssueStatus
}[],
) {
await client.request('/moderation/tech-review/issue-detail', {
api: 'labrinth',
version: 'internal',
@@ -247,15 +216,31 @@ async function updateIssueDetails(data: { detail_id: string; verdict: 'safe' | '
})
}
async function updateGlobalIssueDetail(
detailKey: string,
verdict: Labrinth.TechReview.Internal.DelphiReportIssueStatus,
) {
await client.labrinth.tech_review_internal.updateGlobalIssueDetails([
{ detail_key: detailKey, verdict },
])
}
const severityOrder = { severe: 3, high: 2, medium: 1, low: 0 } as Record<string, number>
type DetailDecision = 'safe' | 'malware'
type DetailDecision = 'safe' | 'malware' | 'pending'
type DetailDecisionScope = 'local' | 'global'
const detailDecisions = reactive<Map<string, DetailDecision>>(new Map())
const detailDecisionScopes = reactive<Map<string, DetailDecisionScope>>(new Map())
const updatingDetails = reactive<Set<string>>(new Set())
const updatingGlobalDetailKeys = reactive<Set<string>>(new Set())
function verdictToDecision(verdict: 'safe' | 'unsafe'): DetailDecision {
return verdict === 'safe' ? 'safe' : 'malware'
function verdictToDecision(
verdict: Labrinth.TechReview.Internal.DelphiReportIssueStatus,
): DetailDecision {
if (verdict === 'safe') return 'safe'
if (verdict === 'unsafe') return 'malware'
return 'pending'
}
function getAllDetails(): Labrinth.TechReview.Internal.ReportIssueDetail[] {
@@ -265,6 +250,7 @@ function getAllDetails(): Labrinth.TechReview.Internal.ReportIssueDetail[] {
function applyDecisionToRelatedDetails(
detailIds: string[],
decision: DetailDecision,
scope: DetailDecisionScope,
): { otherMatchedCount: number } {
const allDetails = getAllDetails()
const selectedDetailIds = new Set(detailIds)
@@ -282,12 +268,14 @@ function applyDecisionToRelatedDetails(
if (matchingDetails.length === 0) {
detailDecisions.set(detailId, decision)
detailDecisionScopes.set(detailId, scope)
updatedDetailIds.add(detailId)
continue
}
for (const matchingDetail of matchingDetails) {
detailDecisions.set(matchingDetail.id, decision)
detailDecisionScopes.set(matchingDetail.id, scope)
updatedDetailIds.add(matchingDetail.id)
}
}
@@ -298,6 +286,98 @@ function applyDecisionToRelatedDetails(
}
}
function statusMatchesDecision(
status: Labrinth.TechReview.Internal.DelphiReportIssueStatus | null,
decision: DetailDecision,
): boolean {
if (status === 'safe') return decision === 'safe'
if (status === 'unsafe') return decision === 'malware'
return false
}
function isDetailActionSelected(
detail: Labrinth.TechReview.Internal.ReportIssueDetail,
decision: DetailDecision,
scope: DetailDecisionScope,
): boolean {
const localDecision = detailDecisions.get(detail.id)
const localScope = detailDecisionScopes.get(detail.id)
if (localDecision && localScope) {
if (localDecision === 'pending') {
if (localScope === 'local') {
if (scope === 'local') return false
return statusMatchesDecision(detail.global_status, decision)
}
if (scope === 'global') return false
return statusMatchesDecision(detail.local_status, decision)
}
return localDecision === decision && localScope === scope
}
if (scope === 'global') {
return statusMatchesDecision(detail.global_status, decision)
}
if (detail.global_status) {
return false
}
return statusMatchesDecision(detail.local_status, decision)
}
function decisionToVerdict(
decision: Exclude<DetailDecision, 'pending'>,
): Labrinth.TechReview.Internal.DelphiReportIssueStatus {
return decision === 'safe' ? 'safe' : 'unsafe'
}
function getToggledDetailVerdict(
detail: Labrinth.TechReview.Internal.ReportIssueDetail,
decision: Exclude<DetailDecision, 'pending'>,
scope: DetailDecisionScope,
): Labrinth.TechReview.Internal.DelphiReportIssueStatus {
return isDetailActionSelected(detail, decision, scope) ? 'pending' : decisionToVerdict(decision)
}
function getDetailActionTooltip(
detail: Labrinth.TechReview.Internal.ReportIssueDetail,
decision: Exclude<DetailDecision, 'pending'>,
scope: DetailDecisionScope,
): string {
const action = decision === 'safe' ? 'pass' : 'fail'
const scopeLabel = scope === 'global' ? 'Global' : 'Local'
if (scope === 'global' && !canUpdateGlobalDetail(detail)) {
return 'Global verdict unavailable for generated trace keys'
}
if (isDetailActionSelected(detail, decision, scope)) {
return `Unset ${scopeLabel.toLowerCase()} ${action}`
}
return `${scopeLabel} ${action}`
}
function updateLocalDetailAction(
detail: Labrinth.TechReview.Internal.ReportIssueDetail,
decision: Exclude<DetailDecision, 'pending'>,
) {
return updateDetailStatus(detail.id, getToggledDetailVerdict(detail, decision, 'local'))
}
function updateGlobalDetailAction(
detail: Labrinth.TechReview.Internal.ReportIssueDetail,
decision: Exclude<DetailDecision, 'pending'>,
) {
return updateGlobalDetailStatus(detail, getToggledDetailVerdict(detail, decision, 'global'))
}
function canUpdateGlobalDetail(detail: Labrinth.TechReview.Internal.ReportIssueDetail): boolean {
return detail.key.length > 0 && !detail.key.startsWith('<no-key-')
}
function getFileHighestSeverity(
file: FlattenedFileReport,
): Labrinth.TechReview.Internal.DelphiSeverity {
@@ -427,6 +507,49 @@ function viewFileFlags(file: FlattenedFileReport) {
currentTab.value = 'File'
}
function getDetailElementId(detailId: string) {
return `tech-review-detail-${detailId}`
}
function findFileForDetail(detailId: string): FlattenedFileReport | null {
for (const report of props.item.reports) {
for (const issue of report.issues) {
if (issue.details.some((detail) => detail.id === detailId)) {
return report
}
}
}
return null
}
async function focusDetail(detailId: string) {
const file = findFileForDetail(detailId)
if (!file) return
viewFileFlags(file)
await nextTick()
const classItem = groupedByClass.value.find((group) =>
group.flags.some((flag) => flag.detail.id === detailId),
)
if (classItem) {
expandClass(classItem)
}
await nextTick()
if (!import.meta.client) return
window.requestAnimationFrame(() => {
document.getElementById(getDetailElementId(detailId))?.scrollIntoView({
behavior: 'smooth',
block: 'center',
})
})
}
function backToFileList() {
selectedFileId.value = null
if (currentTab.value === 'File') {
@@ -492,6 +615,18 @@ const remainingUnmarkedCount = computed(() => {
return getFileDetailCount(selectedFile.value) - getFileMarkedCount(selectedFile.value)
})
function getJarFlags(jarGroup: JarGroup): ClassGroup['flags'] {
return jarGroup.classes.flatMap((classItem) => classItem.flags)
}
function getJarMarkedCount(jarGroup: JarGroup): number {
return getMarkedFlagsCount(getJarFlags(jarGroup))
}
function getJarRemainingUnmarkedCount(jarGroup: JarGroup): number {
return getJarFlags(jarGroup).length - getJarMarkedCount(jarGroup)
}
const isBatchUpdating = ref(false)
async function batchMarkRemaining(verdict: 'safe' | 'unsafe') {
@@ -515,7 +650,7 @@ async function batchMarkRemaining(verdict: 'safe' | 'unsafe') {
try {
await updateIssueDetails(detailIds.map((detailId) => ({ detail_id: detailId, verdict })))
applyDecisionToRelatedDetails(detailIds, verdictToDecision(verdict))
applyDecisionToRelatedDetails(detailIds, verdictToDecision(verdict), 'local')
addNotification({
type: 'success',
@@ -545,7 +680,54 @@ async function batchMarkRemaining(verdict: 'safe' | 'unsafe') {
}
}
async function updateDetailStatus(detailId: string, verdict: 'safe' | 'unsafe') {
async function batchMarkRemainingInJar(jarGroup: JarGroup, verdict: 'safe' | 'unsafe') {
if (isBatchUpdating.value) return
const detailIds = getJarFlags(jarGroup)
.filter((flag) => getDetailDecision(flag.detail.id, flag.detail.status) === 'pending')
.map((flag) => flag.detail.id)
if (detailIds.length === 0) return
isBatchUpdating.value = true
try {
await updateIssueDetails(detailIds.map((detailId) => ({ detail_id: detailId, verdict })))
applyDecisionToRelatedDetails(detailIds, verdictToDecision(verdict), 'local')
addNotification({
type: 'success',
title: `Marked ${detailIds.length} traces as ${verdict}`,
text: `All remaining traces in this JAR have been marked as ${
verdict === 'safe' ? 'false positives' : 'malicious'
}.`,
})
if (selectedFile.value) {
const markedCount = getFileMarkedCount(selectedFile.value)
const totalCount = getFileDetailCount(selectedFile.value)
if (markedCount === totalCount) {
backToFileList()
}
}
emit('refetch')
} catch (error) {
console.error('Failed to batch update JAR traces:', error)
addNotification({
type: 'error',
title: 'Batch update failed',
text: 'An error occurred while updating JAR traces.',
})
} finally {
isBatchUpdating.value = false
}
}
async function updateDetailStatus(
detailId: string,
verdict: Labrinth.TechReview.Internal.DelphiReportIssueStatus,
) {
let priorDecision: 'safe' | 'malware' | 'pending' = 'pending'
outer: for (const report of props.item.reports) {
for (const issue of report.issues) {
@@ -565,10 +747,11 @@ async function updateDetailStatus(detailId: string, verdict: 'safe' | 'unsafe')
const { otherMatchedCount } = applyDecisionToRelatedDetails(
[detailId],
verdictToDecision(verdict),
'local',
)
// Only collapse if the prior state was 'pending' (new decision, not updating existing)
if (priorDecision === 'pending') {
if (verdict !== 'pending' && priorDecision === 'pending') {
for (const classGroup of groupedByClass.value) {
const hasThisDetail = classGroup.flags.some((f) => f.detail.id === detailId)
if (hasThisDetail && getMarkedFlagsCount(classGroup.flags) === classGroup.flags.length) {
@@ -579,7 +762,7 @@ async function updateDetailStatus(detailId: string, verdict: 'safe' | 'unsafe')
}
// Jump back to Files tab when all flags in the current file are marked
if (selectedFile.value) {
if (verdict !== 'pending' && selectedFile.value) {
const markedCount = getFileMarkedCount(selectedFile.value)
const totalCount = getFileDetailCount(selectedFile.value)
if (markedCount === totalCount) {
@@ -592,7 +775,13 @@ async function updateDetailStatus(detailId: string, verdict: 'safe' | 'unsafe')
? ` (${otherMatchedCount} other trace${otherMatchedCount === 1 ? '' : 's'} also marked)`
: ''
if (verdict === 'safe') {
if (verdict === 'pending') {
addNotification({
type: 'success',
title: 'Local trace verdict unset',
text: `The project-local verdict has been removed.${otherText}`,
})
} else if (verdict === 'safe') {
addNotification({
type: 'success',
title: 'Issue marked as pass',
@@ -619,6 +808,82 @@ async function updateDetailStatus(detailId: string, verdict: 'safe' | 'unsafe')
}
}
async function updateGlobalDetailStatus(
detail: Labrinth.TechReview.Internal.ReportIssueDetail,
verdict: Labrinth.TechReview.Internal.DelphiReportIssueStatus,
) {
if (!canUpdateGlobalDetail(detail)) {
addNotification({
type: 'error',
title: 'Global update unavailable',
text: 'Generated trace keys cannot be marked globally.',
})
return
}
updatingGlobalDetailKeys.add(detail.key)
try {
await updateGlobalIssueDetail(detail.key, verdict)
const { otherMatchedCount } = applyDecisionToRelatedDetails(
[detail.id],
verdictToDecision(verdict),
'global',
)
if (verdict !== 'pending') {
for (const classGroup of groupedByClass.value) {
if (getMarkedFlagsCount(classGroup.flags) === classGroup.flags.length) {
expandedClasses.delete(classGroup.key)
}
}
}
if (verdict !== 'pending' && selectedFile.value) {
const markedCount = getFileMarkedCount(selectedFile.value)
const totalCount = getFileDetailCount(selectedFile.value)
if (markedCount === totalCount) {
backToFileList()
}
}
const otherText =
otherMatchedCount > 0
? ` (${otherMatchedCount} other trace${otherMatchedCount === 1 ? '' : 's'} also marked in this project)`
: ''
if (verdict === 'pending') {
addNotification({
type: 'success',
title: 'Global trace verdict unset',
text: `The global verdict for this trace key has been removed.${otherText}`,
})
} else {
addNotification({
type: 'success',
title:
verdict === 'safe' ? 'Trace globally marked as pass' : 'Trace globally marked as fail',
text:
verdict === 'safe'
? `This trace key has been marked as a global false positive.${otherText}`
: `This trace key has been globally flagged as malicious.${otherText}`,
})
}
emit('refetch')
} catch (error) {
console.error('Failed to update global detail status:', error)
addNotification({
type: 'error',
title: 'Failed to update global trace',
text: 'An error occurred while updating the global trace status.',
})
} finally {
updatingGlobalDetailKeys.delete(detail.key)
}
}
const expandedClasses = reactive<Set<string>>(new Set())
const autoExpandedFileIds = reactive<Set<string>>(new Set())
const showCopyFeedback = reactive<Map<string, boolean>>(new Map())
@@ -737,6 +1002,16 @@ const groupedByJar = computed<JarGroup[]>(() => {
})
})
watch(
() => props.focusedDetailId,
(detailId) => {
if (detailId) {
focusDetail(detailId)
}
},
{ immediate: true },
)
// Auto-expand/load source for small files; keep larger files lazy.
watch(
[selectedFileId, groupedByClass],
@@ -1020,6 +1295,16 @@ async function handleSubmitReview(verdict: 'safe' | 'unsafe') {
}
}
}
function copyId() {
navigator.clipboard.writeText(props.item.project.id).then(() => {
addNotification({
type: 'success',
title: 'Project ID copied',
text: 'The ID of this project has been copied to your clipboard.',
})
})
}
</script>
<template>
@@ -1105,25 +1390,31 @@ async function handleSubmitReview(verdict: 'safe' | 'unsafe') {
<div class="flex items-center gap-3">
<span class="text-base text-secondary">{{ formattedDate }}</span>
<ButtonStyled circular>
<OverflowMenu :options="quickActions" class="!shadow-none">
<template #default>
<EllipsisVerticalIcon class="size-4" />
</template>
<template #copy-id>
<ClipboardCopyIcon />
<span class="hidden sm:inline">Copy ID</span>
</template>
<template #copy-link>
<LinkIcon />
<span class="hidden sm:inline">Copy link</span>
</template>
<template #view-source>
<div class="flex items-center gap-2">
<ButtonStyled v-if="props.item.project.link_urls?.['source']?.url" circular>
<a
v-tooltip="'Open sources in new tab'"
:href="props.item.project.link_urls?.['source']?.url"
target="_blank"
>
<CodeIcon />
<span class="hidden sm:inline">View source</span>
</template>
</OverflowMenu>
</ButtonStyled>
</a>
</ButtonStyled>
<ButtonStyled circular>
<button v-tooltip="'Copy ID'" @click="copyId">
<ClipboardCopyIcon />
</button>
</ButtonStyled>
<ButtonStyled circular>
<a
v-tooltip="'Open in new tab'"
:href="`/moderation/technical-review/${props.item.project.id}`"
target="_blank"
>
<ExternalIcon />
</a>
</ButtonStyled>
</div>
</div>
</div>
@@ -1323,26 +1614,49 @@ async function handleSubmitReview(verdict: 'safe' | 'unsafe') {
v-if="jarGroup.segments.length > 0"
class="border-b border-solid border-surface-1 px-4 py-3"
>
<div class="flex flex-wrap items-center gap-1">
<template
v-for="(segment, index) in jarGroup.segments"
:key="`${jarGroup.key}-${index}`"
>
<span
class="font-mono text-sm"
:class="
index === jarGroup.segments.length - 1
? 'font-semibold text-contrast'
: 'text-secondary'
"
<div class="flex flex-wrap items-center justify-between gap-3">
<div class="flex flex-wrap items-center gap-1">
<template
v-for="(segment, index) in jarGroup.segments"
:key="`${jarGroup.key}-${index}`"
>
{{ segment }}
</span>
<ChevronRightIcon
v-if="index < jarGroup.segments.length - 1"
class="size-4 text-secondary"
/>
</template>
<span
class="font-mono text-sm"
:class="
index === jarGroup.segments.length - 1
? 'font-semibold text-contrast'
: 'text-secondary'
"
>
{{ segment }}
</span>
<ChevronRightIcon
v-if="index < jarGroup.segments.length - 1"
class="size-4 text-secondary"
/>
</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>
</div>
</div>
@@ -1414,8 +1728,12 @@ async function handleSubmitReview(verdict: 'safe' | 'unsafe') {
>
<div
v-for="flag in classItem.flags"
:id="getDetailElementId(flag.detail.id)"
:key="`${flag.issueId}-${flag.detail.id}`"
class="flex flex-col gap-2 rounded-lg border-[1px] border-b border-solid border-surface-5 bg-surface-3 py-2 pl-4 last:border-b-0"
:class="{
'!border-brand bg-brand-highlight': props.focusedDetailId === flag.detail.id,
}"
>
<div class="grid grid-cols-[1fr_auto] items-center">
<div
@@ -1437,38 +1755,94 @@ async function handleSubmitReview(verdict: 'safe' | 'unsafe') {
</div>
</div>
<div class="flex w-40 items-center justify-center gap-2">
<ButtonStyled
color="brand"
:type="
getDetailDecision(flag.detail.id, flag.detail.status) === 'safe'
? undefined
: 'outlined'
"
<div class="detail-verdict-action-groups">
<div
class="detail-verdict-buttons"
role="group"
aria-label="Trace verdict actions"
>
<button
:disabled="updatingDetails.has(flag.detail.id)"
@click="updateDetailStatus(flag.detail.id, 'safe')"
v-tooltip="getDetailActionTooltip(flag.detail, 'safe', 'global')"
class="detail-verdict-button detail-verdict-button--safe"
:class="{
'detail-verdict-button--selected': isDetailActionSelected(
flag.detail,
'safe',
'global',
),
}"
aria-label="Global pass"
:disabled="
!canUpdateGlobalDetail(flag.detail) ||
updatingGlobalDetailKeys.has(flag.detail.key) ||
updatingDetails.has(flag.detail.id)
"
@click="updateGlobalDetailAction(flag.detail, 'safe')"
>
Pass
<CheckCheckIcon aria-hidden="true" />
</button>
</ButtonStyled>
<ButtonStyled
color="red"
:type="
getDetailDecision(flag.detail.id, flag.detail.status) === 'malware'
? undefined
: 'outlined'
"
>
<button
:disabled="updatingDetails.has(flag.detail.id)"
@click="updateDetailStatus(flag.detail.id, 'unsafe')"
v-tooltip="getDetailActionTooltip(flag.detail, 'safe', 'local')"
class="detail-verdict-button detail-verdict-button--safe"
:class="{
'detail-verdict-button--selected': isDetailActionSelected(
flag.detail,
'safe',
'local',
),
}"
aria-label="Local pass"
:disabled="
updatingDetails.has(flag.detail.id) ||
updatingGlobalDetailKeys.has(flag.detail.key)
"
@click="updateLocalDetailAction(flag.detail, 'safe')"
>
Fail
<CheckIcon aria-hidden="true" />
</button>
</ButtonStyled>
<button
v-tooltip="getDetailActionTooltip(flag.detail, 'malware', 'local')"
class="detail-verdict-button detail-verdict-button--unsafe"
:class="{
'detail-verdict-button--selected': isDetailActionSelected(
flag.detail,
'malware',
'local',
),
}"
aria-label="Local fail"
:disabled="
updatingDetails.has(flag.detail.id) ||
updatingGlobalDetailKeys.has(flag.detail.key)
"
@click="updateLocalDetailAction(flag.detail, 'malware')"
>
<BanIcon aria-hidden="true" />
</button>
<button
v-tooltip="getDetailActionTooltip(flag.detail, 'malware', 'global')"
class="detail-verdict-button detail-verdict-button--unsafe"
:class="{
'detail-verdict-button--selected': isDetailActionSelected(
flag.detail,
'malware',
'global',
),
}"
aria-label="Global fail"
:disabled="
!canUpdateGlobalDetail(flag.detail) ||
updatingGlobalDetailKeys.has(flag.detail.key) ||
updatingDetails.has(flag.detail.id)
"
@click="updateGlobalDetailAction(flag.detail, 'malware')"
>
<ShieldAlertIcon aria-hidden="true" />
</button>
</div>
</div>
</div>
<div
@@ -1576,4 +1950,90 @@ pre {
.fade-leave-to {
opacity: 0;
}
.detail-verdict-action-groups {
display: flex;
align-items: center;
justify-content: flex-end;
gap: 0.5rem;
margin-inline-end: 0.5rem;
}
.detail-verdict-buttons {
display: flex;
align-items: center;
overflow: hidden;
border: 1px solid var(--surface-5);
border-radius: var(--radius-md);
background: var(--surface-3);
}
.detail-verdict-button {
display: flex;
width: 2rem;
height: 2rem;
align-items: center;
justify-content: center;
border: 0;
border-left: 1px solid var(--surface-5);
background: transparent;
padding: 0;
cursor: pointer;
transition:
background-color 0.15s ease-in-out,
filter 0.15s ease-in-out;
}
.detail-verdict-button:first-child {
border-left: 0;
border-start-start-radius: calc(var(--radius-md) - 1px);
border-end-start-radius: calc(var(--radius-md) - 1px);
}
.detail-verdict-button:last-child {
border-start-end-radius: calc(var(--radius-md) - 1px);
border-end-end-radius: calc(var(--radius-md) - 1px);
}
.detail-verdict-button:hover,
.detail-verdict-button:focus-visible {
background: var(--surface-4);
}
.detail-verdict-button--selected {
background: var(--color-green-bg);
box-shadow: inset 0 0 0 1px var(--color-green);
}
.detail-verdict-button--selected:hover,
.detail-verdict-button--selected:focus-visible {
background: var(--color-green-bg);
}
.detail-verdict-button:focus-visible {
outline: none;
box-shadow: inset 0 0 0 2px var(--color-brand);
}
.detail-verdict-button--selected:focus-visible {
box-shadow: inset 0 0 0 2px var(--color-green);
}
.detail-verdict-button:disabled {
cursor: not-allowed;
opacity: 0.5;
}
.detail-verdict-button svg {
width: 1rem;
height: 1rem;
}
.detail-verdict-button--safe {
color: var(--color-green);
}
.detail-verdict-button--unsafe {
color: var(--color-red);
}
</style>
@@ -0,0 +1,326 @@
<script setup lang="ts">
import type { Labrinth } from '@modrinth/api-client'
import { FolderSearchIcon, StarIcon } from '@modrinth/assets'
import {
ButtonStyled,
defineMessages,
injectModrinthClient,
NewModal,
Table,
type TableColumn,
useVIntl,
} from '@modrinth/ui'
import { computed, ref, useTemplateRef } from 'vue'
const messages = defineMessages({
title: {
id: 'modpack-scan-modal.title',
defaultMessage: 'Modpack Scan ({scanned}/{total} Files)',
},
scanAllFiles: {
id: 'modpack-scan-modal.scan-all-files',
defaultMessage: 'Scan All Files',
},
packFileName: {
id: 'modpack-scan-modal.pack-file-name',
defaultMessage: 'Pack File Name',
},
newFiles: {
id: 'modpack-scan-modal.new-files',
defaultMessage: 'New Files',
},
newGroups: {
id: 'modpack-scan-modal.new-groups',
defaultMessage: 'New Groups',
},
loadingVersions: {
id: 'modpack-scan-modal.loading-versions',
defaultMessage: 'Loading versions...',
},
noFiles: {
id: 'modpack-scan-modal.no-files',
defaultMessage: 'No files found.',
},
notScanned: {
id: 'modpack-scan-modal.not-scanned',
defaultMessage: 'Not scanned',
},
scanning: {
id: 'modpack-scan-modal.scanning',
defaultMessage: 'Scanning...',
},
failed: {
id: 'modpack-scan-modal.failed',
defaultMessage: 'Failed',
},
overrideFiles: {
id: 'modpack-scan-modal.override-files',
defaultMessage: 'Override Files ({count})',
},
loadVersionsError: {
id: 'modpack-scan-modal.load-versions-error',
defaultMessage: 'Failed to load versions: {error}',
},
scanError: {
id: 'modpack-scan-modal.scan-error',
defaultMessage: 'Some files failed to scan: {error}',
},
})
type ScanTableColumn = 'filename' | 'newFiles' | 'newGroups'
type ScanRow = {
id: string
filename: string
primary: boolean
scan?: Labrinth.Attribution.Internal.FileScanResponse
isScanning: boolean
error?: string
newFiles?: number
newGroups?: number
}
const props = defineProps<{
project_id: string
}>()
const client = injectModrinthClient()
const modalRef = useTemplateRef<InstanceType<typeof NewModal>>('modalRef')
const { formatMessage } = useVIntl()
const rows = ref<ScanRow[]>([])
const isLoadingVersions = ref(false)
const isScanning = ref(false)
const versionLoadError = ref<string | null>(null)
const scanError = ref<string | null>(null)
const requestId = ref(0)
const scanRequestId = ref(0)
const columns = computed<TableColumn<ScanTableColumn>[]>(() => [
{ key: 'filename', label: formatMessage(messages.packFileName), width: '60%' },
{ key: 'newFiles', label: formatMessage(messages.newFiles), align: 'center', width: '20%' },
{ key: 'newGroups', label: formatMessage(messages.newGroups), align: 'center', width: '20%' },
])
const scannedCount = computed(() => rows.value.filter((row) => row.scan || row.error).length)
const isBusy = computed(() => isLoadingVersions.value || isScanning.value)
function getErrorMessage(error: unknown) {
if (error instanceof Error) {
return error.message
}
if (typeof error === 'object' && error !== null && 'data' in error) {
const data = (error as { data?: { description?: string } }).data
if (data?.description) {
return data.description
}
}
return String(error)
}
async function fetchAllVersions() {
const currentRequestId = ++requestId.value
isLoadingVersions.value = true
versionLoadError.value = null
scanError.value = null
rows.value = []
try {
const versions = await client.labrinth.versions_v2.getProjectVersions(props.project_id)
if (currentRequestId !== requestId.value) {
return
}
rows.value = versions
.flatMap((version) => version.files)
.filter((file): file is Labrinth.Versions.v2.VersionFile & { id: string } => Boolean(file.id))
.map((file) => ({
id: file.id,
filename: file.filename,
primary: file.primary,
isScanning: false,
newFiles: undefined,
newGroups: undefined,
}))
} catch (error) {
if (currentRequestId === requestId.value) {
versionLoadError.value = formatMessage(messages.loadVersionsError, {
error: getErrorMessage(error),
})
}
} finally {
if (currentRequestId === requestId.value) {
isLoadingVersions.value = false
}
}
}
async function fetchAllScans() {
if (isBusy.value) {
return
}
const currentScanRequestId = ++scanRequestId.value
isScanning.value = true
scanError.value = null
rows.value = rows.value.map((row) => ({
...row,
scan: undefined,
isScanning: false,
error: undefined,
newFiles: undefined,
newGroups: undefined,
}))
try {
for (const row of rows.value) {
if (currentScanRequestId !== scanRequestId.value) {
return
}
row.isScanning = true
try {
const scan = await client.labrinth.attribution_internal.scanFile(row.id)
if (currentScanRequestId !== scanRequestId.value) {
return
}
row.scan = scan
row.newFiles = scan.new_attribution_files
row.newGroups = scan.new_attribution_groups
} catch (error) {
if (currentScanRequestId !== scanRequestId.value) {
return
}
row.error = getErrorMessage(error)
scanError.value = formatMessage(messages.scanError, { error: row.error })
} finally {
row.isScanning = false
}
}
} finally {
if (currentScanRequestId === scanRequestId.value) {
isScanning.value = false
}
}
}
function show() {
scanRequestId.value++
isScanning.value = false
rows.value = []
void fetchAllVersions()
modalRef.value?.show()
}
function hide() {
modalRef.value?.hide()
}
defineExpose({ show, hide })
</script>
<template>
<NewModal
ref="modalRef"
width="60vw"
:close-on-click-outside="false"
:close-on-esc="false"
:disable-close="isBusy"
>
<template #title>
<div class="flex w-full items-center justify-between gap-2">
<span class="text-2xl font-semibold text-contrast">
{{
formatMessage(messages.title, {
scanned: scannedCount,
total: rows.length,
})
}}
</span>
<div>
<ButtonStyled circular>
<button
v-tooltip="formatMessage(messages.scanAllFiles)"
:disabled="isBusy || rows.length === 0"
@click="fetchAllScans"
>
<FolderSearchIcon aria-hidden="true" />
</button>
</ButtonStyled>
</div>
</div>
</template>
<div class="w-full">
<div
v-if="versionLoadError || scanError"
class="mb-3 rounded-xl bg-highlight-red p-3 text-red"
>
{{ versionLoadError || scanError }}
</div>
<Table
:columns="columns"
:data="rows"
row-key="id"
:row-below-visible="
(row) => Boolean(row.scan?.scanned_file_names && row.scan.scanned_file_names.length > 0)
"
table-min-width="42rem"
>
<template #cell-filename="{ row }">
<div class="flex min-w-0 items-center gap-1 text-contrast">
<StarIcon v-if="row.primary" class="size-4 shrink-0" aria-hidden="true" />
<span class="min-w-0 truncate">{{ row.filename }}</span>
</div>
</template>
<template #cell-newFiles="{ row }">
<span v-if="row.isScanning">{{ formatMessage(messages.scanning) }}</span>
<span v-else-if="row.error" v-tooltip="row.error" class="text-red">
{{ formatMessage(messages.failed) }}
</span>
<span v-else-if="row.scan">{{ row.scan.new_attribution_files }}</span>
<span v-else>{{ formatMessage(messages.notScanned) }}</span>
</template>
<template #cell-newGroups="{ row }">
<span v-if="row.isScanning">{{ formatMessage(messages.scanning) }}</span>
<span v-else-if="row.error" v-tooltip="row.error" class="text-red">
{{ formatMessage(messages.failed) }}
</span>
<span v-else-if="row.scan">{{ row.scan.new_attribution_groups }}</span>
<span v-else>{{ formatMessage(messages.notScanned) }}</span>
</template>
<template #row-below="{ row }">
<div class="border-0 border-t border-solid border-surface-4 px-4 py-3">
<details>
<summary>
{{
formatMessage(messages.overrideFiles, {
count: row.scan?.scanned_file_names.length ?? 0,
})
}}
</summary>
<div class="flex flex-wrap gap-1 pt-2">
<span
v-for="name of row.scan?.scanned_file_names ?? []"
:key="name"
v-tooltip="name"
class="flex items-center gap-1 text-wrap rounded-full bg-button-bg px-2 py-0.5 text-xs font-medium text-contrast"
>
{{ name }}
</span>
</div>
</details>
</div>
</template>
<template #empty-state>
<div class="flex h-64 items-center justify-center text-secondary">
{{ formatMessage(isLoadingVersions ? messages.loadingVersions : messages.noFiles) }}
</div>
</template>
</Table>
</div>
</NewModal>
</template>
@@ -0,0 +1,116 @@
<template>
<div class="flex flex-row items-center gap-2">
<kbd
v-for="(definition, index) in definitions"
:key="`keybind-${index}`"
ref="keybinding"
class="cursor-pointer border-2 !text-lg font-bold"
:class="{
editing: editing === index,
}"
@click="startEditing(index)"
>
{{ toDisplay(definition) }}
</kbd>
</div>
</template>
<script setup lang="ts">
import { type KeybindDefinition, toKeybindDefinition } from '@modrinth/moderation'
import { onUnmounted } from 'vue'
const props = defineProps<{
definitions: KeybindDefinition[]
onChange: (definitions: KeybindDefinition[]) => void
}>()
const keybinding = useTemplateRef('keybinding')
const definitions = ref(JSON.parse(JSON.stringify(props.definitions)))
const editing = ref(-1)
function startEditing(index: number) {
if (editing.value === index) {
stopEditing()
} else {
editing.value = index
window.addEventListener('keyup', handleKeybinds)
window.addEventListener('click', handleMouse)
}
}
function stopEditing() {
console.log('stop editing')
editing.value = -1
window.removeEventListener('keyup', handleKeybinds)
window.removeEventListener('click', handleMouse)
}
function handleMouse(event: MouseEvent) {
if (keybinding.value && event.target && editing.value != -1) {
const editingRef = keybinding.value[editing.value]
if (editingRef === event.target || editingRef.contains(event.target)) {
return
}
}
stopEditing()
}
function handleKeybinds(event: KeyboardEvent) {
definitions.value[editing.value] = toKeybindDefinition(event)
props.onChange(definitions.value)
stopEditing()
event.preventDefault()
event.stopPropagation()
}
function toDisplay(definition: KeybindDefinition): string {
const keys = []
if (definition.ctrl || definition.meta) {
keys.push(isMac() ? 'CMD' : 'CTRL')
}
if (definition.shift) keys.push('SHIFT')
if (definition.alt) keys.push('ALT')
const mainKey = definition.key
.toUpperCase()
.replace('ARROWLEFT', '←')
.replace('ARROWRIGHT', '→')
.replace('ARROWUP', '↑')
.replace('ARROWDOWN', '↓')
.replace('ENTER', '↵')
.replace('ESCAPE', 'ESC')
keys.push(mainKey)
return keys.join(' + ')
}
function isMac() {
return navigator.platform.toUpperCase().includes('MAC')
}
onUnmounted(stopEditing)
</script>
<style scoped lang="scss">
.editing {
animation: blink 1s step-end infinite;
}
@keyframes blink {
0%,
100% {
border-color: var(--color-red);
box-shadow: 0 0 10px 1px var(--color-red);
}
50% {
border-color: transparent;
box-shadow: none;
}
}
</style>
@@ -1,26 +1,32 @@
<template>
<NewModal ref="modal" header="Moderation shortcuts" :closable="true">
<div>
<div id="moderation-checklist-keybinds-modal">
<div class="keybinds-sections">
<div class="grid grid-cols-2 gap-x-12 gap-y-3">
<div
v-for="keybind in keybinds"
:key="keybind.id"
class="keybind-item flex items-center justify-between gap-4"
v-for="[id, keybind] in Object.entries(keybinds)"
:key="id"
class="keybind-item flex flex-wrap items-center justify-between gap-4"
:class="{
'col-span-2': keybinds.length % 2 === 1 && keybinds[keybinds.length - 1] === keybind,
'col-span-2':
Object.keys(keybinds).length % 2 === 1 &&
Object.keys(keybinds)[Object.keys(keybinds).length - 1] === id,
}"
>
<span class="text-sm text-secondary">{{ keybind.description }}</span>
<div class="flex items-center gap-1">
<kbd
v-for="(key, index) in parseKeybindDisplay(keybind.keybind)"
:key="`${keybind.id}-key-${index}`"
class="keybind-key"
>
{{ key }}
</kbd>
</div>
<ChecklistKeybind
:definitions="
(!Array.isArray(keybind.keybind) ? [keybind.keybind] : keybind.keybind).map(
normalizeKeybind,
)
"
:on-change="
(definitions) => {
keybinds[id].keybind = definitions
saveModerationKeybinds()
}
"
/>
</div>
</div>
</div>
@@ -29,43 +35,15 @@
</template>
<script setup lang="ts">
import { type KeybindListener, keybinds, normalizeKeybind } from '@modrinth/moderation'
import { normalizeKeybind } from '@modrinth/moderation'
import NewModal from '@modrinth/ui/src/components/modal/NewModal.vue'
import { ref } from 'vue'
import { saveModerationKeybinds } from '#imports'
import ChecklistKeybind from '~/components/ui/moderation/checklist/ChecklistKeybind.vue'
const modal = ref<InstanceType<typeof NewModal>>()
function parseKeybindDisplay(keybind: KeybindListener['keybind']): string[] {
const keybinds = Array.isArray(keybind) ? keybind : [keybind]
const normalized = keybinds[0]
const def = normalizeKeybind(normalized)
const keys = []
if (def.ctrl || def.meta) {
keys.push(isMac() ? 'CMD' : 'CTRL')
}
if (def.shift) keys.push('SHIFT')
if (def.alt) keys.push('ALT')
const mainKey = def.key
.replace('ArrowLeft', '←')
.replace('ArrowRight', '→')
.replace('ArrowUp', '↑')
.replace('ArrowDown', '↓')
.replace('Enter', '↵')
.replace('Space', 'SPACE')
.replace('Escape', 'ESC')
.toUpperCase()
keys.push(mainKey)
return keys
}
function isMac() {
return navigator.platform.toUpperCase().includes('MAC')
}
const keybinds = useModerationKeybinds()
function show(event?: MouseEvent) {
modal.value?.show(event)
@@ -82,29 +60,6 @@ defineExpose({
</script>
<style scoped lang="scss">
.keybind-key {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 2rem;
padding: 0.25rem 0.5rem;
background-color: var(--color-bg);
border: 1px solid var(--color-divider);
border-radius: 0.375rem;
font-size: 0.75rem;
font-weight: 600;
text-transform: uppercase;
color: var(--color-contrast);
+ .keybind-key {
margin-left: 0.25rem;
}
}
.keybind-item {
min-height: 2rem;
}
@media (max-width: 768px) {
.keybinds-sections {
.grid {
@@ -478,7 +478,6 @@ import {
handleKeybind,
initializeActionState,
kebabToTitleCase,
keybinds,
type MultiSelectChipsAction,
processMessage,
type Stage,
@@ -533,6 +532,7 @@ import ModpackPermissionsFlow from './ModpackPermissionsFlow.vue'
const notifications = injectNotificationManager()
const { addNotification } = notifications
const debug = useDebugLogger('ModerationChecklist')
const keybinds = useModerationKeybinds()
const keybindsModal = ref<InstanceType<typeof KeybindsModal>>()
const takeOverModal = ref<InstanceType<typeof ConfirmModal>>()
@@ -1207,6 +1207,29 @@ function handleKeybinds(event: KeyboardEvent) {
tryWithhold: () => sendMessage('withheld'),
tryEditMessage: goBackToStages,
tryCopyLink: async (permalink: boolean, relative: boolean, page: boolean) => {
let url = ``
if (relative) {
url += `${globalThis.location.origin}`
} else {
url += `https://modrinth.com`
}
if (permalink) {
url += `/project/${projectV2.value.id}`
} else {
url += `/${projectV2.value.project_type}/${projectV2.value.slug}`
}
if (page) {
url += `/${globalThis.location.pathname.split('/').slice(3).join('/')}`
}
await navigator.clipboard.writeText(url)
},
tryCopyId: async () => await navigator.clipboard.writeText(projectV2.value.id),
tryToggleAction: (actionIndex: number) => {
const action = visibleActions.value[actionIndex]
if (action) {
@@ -1266,7 +1289,7 @@ function handleKeybinds(event: KeyboardEvent) {
},
},
},
keybinds,
Object.values(keybinds.value),
)
}
@@ -1949,7 +1972,10 @@ function generateModpackMessage(allFiles: {
const hasNextProject = ref(false)
async function refreshModerationCaches(threadId?: string) {
const refreshes: Promise<unknown>[] = [invalidate(), refreshNuxtData('moderation-projects')]
const refreshes: Promise<unknown>[] = [
invalidate(),
queryClient.invalidateQueries({ queryKey: ['moderation-projects'] }),
]
if (threadId) {
refreshes.push(queryClient.invalidateQueries({ queryKey: ['thread', threadId] }))
@@ -54,15 +54,21 @@
class="message__icon backed-svg circle moderation-color"
:class="{
raised: raised,
'system-message-icon': ['tech_review_entered', 'tech_review_exit_file_deleted'].includes(
message.body.type,
),
'system-message-icon': [
'tech_review_entered',
'tech_review_exited',
'tech_review_exit_file_deleted',
].includes(message.body.type),
}"
>
<ScaleIcon />
</div>
<span
v-if="!['tech_review_entered', 'tech_review_exit_file_deleted'].includes(message.body.type)"
v-if="
!['tech_review_entered', 'tech_review_exited', 'tech_review_exit_file_deleted'].includes(
message.body.type,
)
"
class="message__author moderation-color"
>
Moderator
@@ -100,6 +106,9 @@
<span v-else-if="message.body.type === 'tech_review_entered'">
The project has entered the technical review queue.
</span>
<span v-else-if="message.body.type === 'tech_review_exited'">
The project has left the technical review queue as all pending traces have been resolved.
</span>
<span v-else-if="message.body.type === 'tech_review_exit_file_deleted'">
The project has left the technical review queue as all files pending review were deleted by
the user.
@@ -214,9 +223,12 @@ const timeSincePosted = ref(formatRelativeTime(props.message.created))
const isPrivateMessage = computed(() => {
return (
props.message.body.private ||
['tech_review', 'tech_review_entered', 'tech_review_exit_file_deleted'].includes(
props.message.body.type,
)
[
'tech_review',
'tech_review_entered',
'tech_review_exited',
'tech_review_exit_file_deleted',
].includes(props.message.body.type)
)
})
@@ -58,6 +58,7 @@ export const DEFAULT_FEATURE_FLAGS = validateValues({
showHostingAccessInstanceAuditLog: false,
versionDevInfoCollapsed: true,
alwaysShowVersionDevInfo: false,
advancedFiltersCollapsed: true,
} as const)
export type FeatureFlag = keyof typeof DEFAULT_FEATURE_FLAGS
@@ -0,0 +1,61 @@
import {
type KeybindDefinition,
type KeybindListener,
keybinds,
normalizeKeybind,
} from '@modrinth/moderation'
import type { CookieOptions } from '#app'
const moderationKeybindsId = 'moderation-keybinds'
type StoredKeybinds = { [id: string]: KeybindDefinition[] }
type PartialStoredKeybinds = Partial<StoredKeybinds>
const getCookieOptions = () =>
({
maxAge: 60 * 60 * 24 * 365 * 10,
sameSite: 'lax',
secure: useRuntimeConfig().public.cookieSecure,
httpOnly: false,
path: '/',
}) satisfies CookieOptions<PartialStoredKeybinds>
export const useModerationKeybinds = () =>
useState<{ [id: string]: KeybindListener }>(moderationKeybindsId, () => {
const storedKeybinds = useCookie<PartialStoredKeybinds>(
moderationKeybindsId,
getCookieOptions(),
)
if (!storedKeybinds.value) {
storedKeybinds.value = {}
}
const output: { [id: string]: KeybindListener } = {}
for (const [id, keybind] of Object.entries(keybinds)) {
const definitions = storedKeybinds.value[id]
output[id] = {
keybind: definitions !== undefined ? definitions : keybind.keybind,
description: keybind.description,
enabled: keybind.enabled,
action: keybind.action,
}
}
return output
})
export const saveModerationKeybinds = () => {
const keybinds = useModerationKeybinds()
const cookie = useCookie<PartialStoredKeybinds>(moderationKeybindsId, getCookieOptions())
const storedKeybinds: PartialStoredKeybinds = {}
for (const [id, keybind] of Object.entries(keybinds.value)) {
storedKeybinds[id] = (Array.isArray(keybind.keybind) ? keybind.keybind : [keybind.keybind]).map(
normalizeKeybind,
)
}
cookie.value = storedKeybinds
}
@@ -4,6 +4,7 @@ import type {
BrowseInstallPlan,
BrowseSearchState,
CreationFlowContextValue,
EnvironmentSearchOverride,
FilterValue,
PendingServerContentInstall,
PendingServerContentInstallType,
@@ -21,6 +22,8 @@ import {
readStoredServerInstallQueue,
removePendingServerContentInstall,
requestInstall,
stripServerRuntimeInstallFilters,
stripServerRuntimeInstallOverrides,
useVIntl,
writePendingServerContentInstallBaseline,
writeStoredServerInstallQueue,
@@ -160,6 +163,7 @@ export function useServerInstallContent({
})
const serverHideInstalled = ref(false)
const serverContentServerOnly = ref(false)
const hideSelectedServerInstalls = ref(false)
const installingProjectIds = ref<Set<string>>(new Set())
const optimisticallyInstalledProjectIds = ref<Set<string>>(new Set())
@@ -348,10 +352,6 @@ export function useServerInstallContent({
filters.push({ type: 'plugin_loader', option: platform })
}
if (projectType.value?.id === 'mod') {
filters.push({ type: 'environment', option: 'server' })
}
if (serverHideInstalled.value && hiddenInstalledProjectIds.value.size > 0) {
for (const x of hiddenInstalledProjectIds.value) {
filters.push({
@@ -383,6 +383,28 @@ export function useServerInstallContent({
return filters
})
const showServerOnlyToggle = computed(() => !!serverData.value && projectType.value?.id === 'mod')
const serverEnvironmentOverride = computed<EnvironmentSearchOverride | undefined>(() => {
if (!showServerOnlyToggle.value) return undefined
if (serverContentServerOnly.value) {
return {
mode: 'include',
values: [
'server_only',
'dedicated_server_only',
'server_only_client_optional',
'client_or_server_prefers_both',
'client_or_server',
],
}
}
return {
mode: 'exclude',
values: ['client_only', 'singleplayer_only'],
}
})
function getCurrentServerInstallType(): BrowseInstallContentType {
const type = projectType.value?.id
if (type === 'modpack' || type === 'mod' || type === 'plugin' || type === 'datapack') {
@@ -584,11 +606,15 @@ export function useServerInstallContent({
project,
contentType,
mode: isModpack ? 'immediate' : 'queue',
selectedFilters: isModpack ? [] : browseSearchState.currentFilters.value,
selectedFilters: isModpack
? []
: stripServerRuntimeInstallFilters(browseSearchState.currentFilters.value),
providedFilters: isModpack ? [] : serverFilters.value,
overriddenProvidedFilterTypes: isModpack
? []
: browseSearchState.overriddenProvidedFilterTypes.value,
: stripServerRuntimeInstallOverrides(
browseSearchState.overriddenProvidedFilterTypes.value,
),
targetPreferences: getServerInstallTargetPreferences(contentType),
getProjectVersions: getInstallProjectVersions,
queue: serverInstallQueue,
@@ -737,6 +763,10 @@ export function useServerInstallContent({
serverHideInstalled.value = route.query.shi === 'true'
}
if (route.query.so && projectType.value?.id === 'mod') {
serverContentServerOnly.value = route.query.so === 'true'
}
watch(serverHideInstalled, (hideInstalled) => {
if (hideInstalled) {
syncHiddenInstalledProjectIds()
@@ -763,6 +793,9 @@ export function useServerInstallContent({
serverContentData,
serverFilters,
serverHideInstalled,
serverContentServerOnly,
showServerOnlyToggle,
serverEnvironmentOverride,
hideSelectedServerInstalls,
installingProjectIds,
optimisticallyInstalledProjectIds,
+3 -6
View File
@@ -1,3 +1,4 @@
import type { Labrinth } from '@modrinth/api-client'
import type { ExtendedReport, OwnershipTarget } from '@modrinth/moderation'
import type {
Organization,
@@ -197,14 +198,10 @@ export interface ModerationOwnershipOrganization {
export type ModerationOwnership = ModerationOwnershipUser | ModerationOwnershipOrganization
export interface ProjectWithOwnership {
ownership: ModerationOwnership
external_dependencies_count: number
[key: string]: any
}
export type ProjectWithOwnership = Labrinth.Moderation.Internal.QueueProject
export interface ModerationProject {
project: any
project: Omit<ProjectWithOwnership, 'ownership' | 'external_dependencies_count'>
ownership: ModerationOwnership | null
external_dependencies_count: number
}
+48
View File
@@ -5,6 +5,10 @@
<div class="pointer-events-none absolute inset-0 z-[-1]">
<div id="absolute-background-teleport" class="relative"></div>
</div>
<div
class="pride-backdrop pointer-events-none absolute inset-0 z-[-1]"
:class="{ shown: showPrideBackdrop }"
></div>
<div class="pointer-events-none absolute inset-0 z-50">
<div
class="over-the-top-random-animation"
@@ -801,6 +805,7 @@ import ProjectCreateModal from '~/components/ui/create/ProjectCreateModal.vue'
import ModrinthFooter from '~/components/ui/ModrinthFooter.vue'
import { getSignInRouteObj } from '~/composables/auth.ts'
import { errors as generatedStateErrors } from '~/generated/state.json'
import { provideCurrentProjectId } from '~/providers/current-project.ts'
import { getProjectTypeMessage } from '~/utils/i18n-project-type.ts'
import { hasActiveMidas } from '~/utils/user-membership.ts'
@@ -867,6 +872,32 @@ const showTinMismatchBanner = computed(() => {
return !!auth.value.user && status === 'tin-mismatch'
})
const PRIDE_COLLECTION_ID = 'M4c3ITvd'
const PRIDE_ARTICLE_SLUGS = ['pride-campaign-2025', 'pride-campaign-2026', 'proud-of-you-2026']
const PRIDE_CACHE_TIME = 1000 * 60 * 60 * 24
const { data: prideCollection } = useQuery({
queryKey: computed(() => ['collection', PRIDE_COLLECTION_ID]),
queryFn: () => client.labrinth.collections.get(PRIDE_COLLECTION_ID),
staleTime: PRIDE_CACHE_TIME,
gcTime: PRIDE_CACHE_TIME,
})
const prideProjectIds = computed(() => new Set(prideCollection.value?.projects ?? []))
const currentProjectId = ref()
provideCurrentProjectId(currentProjectId)
const showPrideBackdrop = computed(() => {
if (PRIDE_ARTICLE_SLUGS.includes(route.params.slug)) {
return true
}
if (route.params.collection === PRIDE_COLLECTION_ID) {
return true
}
return !!currentProjectId.value && prideProjectIds.value.has(currentProjectId.value)
})
const basePopoutId = useId()
async function fetchIntercomToken() {
@@ -1695,4 +1726,21 @@ const { cycle: changeTheme } = useTheme()
transform: translateY(0);
}
}
.pride-backdrop {
background-image: linear-gradient(to right, #c20732, #f57203, #ffd632, #21ca8b, #2f9ff2, #e420fc);
mask-image: linear-gradient(to bottom, rgba(0, 0, 0, 1), rgba(0, 0, 0, 0) 80%);
height: 30rem;
opacity: 0;
transition: opacity 1s ease;
}
.pride-backdrop.shown {
opacity: 0.08;
}
.light-mode .pride-backdrop.shown,
.light .pride-backdrop.shown {
opacity: 0.15;
}
</style>
+300
View File
@@ -23,6 +23,15 @@
"analytics.breakdown.country": {
"message": "الدولة"
},
"analytics.breakdown.dependent-on": {
"message": "يعتمد على"
},
"analytics.breakdown.dependent-project-download": {
"message": "مشروع تابع"
},
"analytics.breakdown.dependent-project-type": {
"message": "نوع المشروع التابع"
},
"analytics.breakdown.download-reason": {
"message": "سبب التنزيل"
},
@@ -32,6 +41,21 @@
"analytics.breakdown.game-version": {
"message": "إصدار اللُعبة"
},
"analytics.breakdown.generic": {
"message": "التفاصيل"
},
"analytics.breakdown.loader": {
"message": "المحمل"
},
"analytics.breakdown.members": {
"message": "عضو"
},
"analytics.breakdown.monetization": {
"message": "تحقيق الدخل"
},
"analytics.breakdown.none.selected": {
"message": "لا توجد أعطال"
},
"analytics.breakdown.project": {
"message": "المشروع"
},
@@ -41,24 +65,246 @@
"analytics.breakdown.project-version": {
"message": "إصدار المشروع"
},
"analytics.breakdown.selected": {
"message": "التفصيل حسب {breakdown}"
},
"analytics.chart.action.show-all": {
"message": "أظهار الكل"
},
"analytics.chart.action.show-limited": {
"message": "عرض محدود"
},
"analytics.chart.action.show-top-eight": {
"message": "عرض أفضل 8"
},
"analytics.chart.axis.playtime-hours": {
"message": "{hours} ساعة"
},
"analytics.chart.controls.active-count": {
"message": "نشط {count} "
},
"analytics.chart.controls.annotations": {
"message": "التعليقات التوضيحية"
},
"analytics.chart.controls.aria": {
"message": "عناصر التحكم في الرسوم البيانية التحليلية، {activeCount}"
},
"analytics.chart.controls.button": {
"message": "عناصر التحكم"
},
"analytics.chart.controls.dialog-aria": {
"message": "عناصر التحكم في الرسوم البيانية التحليلية"
},
"analytics.chart.controls.display": {
"message": "عرض"
},
"analytics.chart.controls.modrinth-events": {
"message": "فعاليات Modrinth"
},
"analytics.chart.controls.no-modrinth-events": {
"message": "لا توجد حالات Modrinth في الرسوم البيانية."
},
"analytics.chart.controls.no-project-events": {
"message": "لا توجد أحداث للمشروع في الرسم البياني."
},
"analytics.chart.controls.previous-period": {
"message": "الفترة السابقة"
},
"analytics.chart.controls.project-events": {
"message": "فعاليات المشروع"
},
"analytics.chart.controls.ratio": {
"message": "النسبة"
},
"analytics.chart.empty.select-table-items": {
"message": "اختر عناصر من الجدول أدناه لتصور بياناتك."
},
"analytics.chart.events.count-aria": {
"message": "{count, plural, one {# حدث تحليلي} other {# أحداث تحليلية}}"
},
"analytics.chart.events.project-title": {
"message": "<project>{projectName}</project>: {title}"
},
"analytics.chart.events.see-announcement": {
"message": "انظر الإعلان"
},
"analytics.chart.legend.monetization-details.aria": {
"message": "عرض تفاصيل التحليلات المتعلقة بتحقيق الدخل"
},
"analytics.chart.legend.monetization-details.description": {
"message": "لا تُحتسب في عملية تحقيق الدخل سوى المشاهدات وعمليات التنزيل التي تتم عبر Modrinth، ويجب على المستخدمين تسجيل الدخول لإجراء عمليات التنزيل."
},
"analytics.chart.legend.monetization-details.title": {
"message": "تفاصيل التحليلات المُدرة للدخل"
},
"analytics.chart.legend.previous-period-suffix": {
"message": "{name} (السابق)"
},
"analytics.chart.render-limit.description": {
"message": "قد يؤدي عرض جميع الأسطر المحددة من الجدول إلى انخفاض أداء الصفحة."
},
"analytics.chart.render-limit.header": {
"message": "هل تُعرض جميع {count} الأسطر في الرسم البياني؟"
},
"analytics.chart.table-selection.all": {
"message": "يتم عرض جميع {itemType, select, project {{count, plural, one {مشروع} other {مشاريع}}} country {{count, plural, one {دولة} other {دول}}} monetization {{count, plural, one {قيمة الربح} other {قيم الربح}}} downloadSource {{count, plural, one {مصدر تحميل} other {مصادر تحميل}}} downloadReason {{count, plural, one {سبب تحميل} other {أسباب تحميل}}} member {{count, plural, one {عضو} other {أعضاء}}} projectVersion {{count, plural, one {نسخة مشروع} other {نسخ مشاريع}}} loader {{count, plural, one {محمل} other {محملات}}} gameVersion {{count, plural, one {نسخة لعبة} other {نسخ ألعاب}}} other {{count, plural, one {عنصر} other {عناصر}}}} من الجدول"
},
"analytics.chart.table-selection.count": {
"message": "يتم عرض {count} {itemType, select, project {{count, plural, one {مشروع} other {مشاريع}}} country {{count, plural, one {دولة} other {دول}}} monetization {{count, plural, one {قيمة الربح} other {قيم الربح}}} downloadSource {{count, plural, one {مصدر تحميل} other {مصادر تحميل}}} downloadReason {{count, plural, one {سبب تحميل} other {أسباب تحميل}}} member {{count, plural, one {عضو} other {أعضاء}}} projectVersion {{count, plural, one {نسخة مشروع} other {نسخ مشاريع}}} loader {{count, plural, one {محمل} other {محملات}}} gameVersion {{count, plural, one {نسخة لعبة} other {نسخ ألعاب}}} other {{count, plural, one {عنصر} other {عناصر}}}} من الجدول"
},
"analytics.chart.table-selection.limited": {
"message": "يتم عرض {limit} {itemType, select, project {{limit, plural, one {مشروع} other {مشاريع}}} country {{limit, plural, one {دولة} other {دول}}} monetization {{limit, plural, one {قيمة الربح} other {قيم الربح}}} downloadSource {{limit, plural, one {مصدر تحميل} other {مصادر تحميل}}} downloadReason {{limit, plural, one {سبب تحميل} other {أسباب تحميل}}} member {{limit, plural, one {عضو} other {أعضاء}}} projectVersion {{limit, plural, one {نسخة مشروع} other {نسخ مشاريع}}} loader {{limit, plural, one {محمل} other {محملات}}} gameVersion {{limit, plural, one {نسخة لعبة} other {نسخ ألعاب}}} other {{limit, plural, one {عنصر} other {عناصر}}}} من الجدول"
},
"analytics.chart.table-selection.top": {
"message": "يتم عرض أعلى {count} {itemType, select, project {{count, plural, one {مشروع} other {مشاريع}}} country {{count, plural, one {دولة} other {دول}}} monetization {{count, plural, one {قيمة الربح} other {قيم الربح}}} downloadSource {{count, plural, one {مصدر تحميل} other {مصادر تحميل}}} downloadReason {{count, plural, one {سبب تحميل} other {أسباب تحميل}}} member {{count, plural, one {عضو} other {أعضاء}}} projectVersion {{count, plural, one {نسخة مشروع} other {نسخ مشاريع}}} loader {{count, plural, one {محمل} other {محملات}}} gameVersion {{count, plural, one {نسخة لعبة} other {نسخ ألعاب}}} other {{count, plural, one {عنصر} other {عناصر}}}} من الجدول"
},
"analytics.chart.tooltip.dependent-on-project": {
"message": "يعتمد على {project}"
},
"analytics.chart.tooltip.dependent-project-version": {
"message": "{dependentProject} يعتمد على {dependencyProject}، {version}"
},
"analytics.chart.tooltip.duration.days": {
"message": "{count, plural, one {# يوم} other {# أيام}}"
},
"analytics.chart.tooltip.duration.hours": {
"message": "{count, plural, one {# ساعة} other {# ساعات}}"
},
"analytics.chart.tooltip.duration.minutes": {
"message": "{count, plural, one {# دقيقة} other {# دقائق}}"
},
"analytics.chart.tooltip.hide-entry": {
"message": "إخفاء {name} في الرسم البياني"
},
"analytics.chart.tooltip.pinned": {
"message": "تلميح الرسم البياني مثبت"
},
"analytics.chart.tooltip.pinned-aria": {
"message": "مثبت"
},
"analytics.chart.tooltip.previous-period-short": {
"message": "(السابق)"
},
"analytics.chart.tooltip.show-entry": {
"message": "إظهار {name} في الرسم البياني"
},
"analytics.chart.tooltip.total": {
"message": "مجموع"
},
"analytics.chart.view.area": {
"message": "المنطقة"
},
"analytics.chart.view.bar": {
"message": "شريط"
},
"analytics.chart.view.line": {
"message": "خط"
},
"analytics.download-reason.dependency": {
"message": "التبعية"
},
"analytics.download-reason.standalone": {
"message": "مستقل"
},
"analytics.download-reason.update": {
"message": "تحديث"
},
"analytics.download-source.app": {
"message": "تطبيق Modrinth"
},
"analytics.download-source.website": {
"message": "موقع Modrinth الإلكتروني"
},
"analytics.downloads.suffix": {
"message": "التنزيلات"
},
"analytics.empty.no-data": {
"message": "لا توجد بيانات متاحة"
},
"analytics.empty.no-data-for-analytics": {
"message": "لا توجد بيانات متاحة للتحليلات"
},
"analytics.empty.no-projects": {
"message": "لا توجد مشاريع متاحة"
},
"analytics.empty.no-projects-for-analytics": {
"message": "لا توجد مشاريع متاحة للتحليلات"
},
"analytics.empty.select-project": {
"message": "حدد مشروعًا واحدًا على الأقل لعرض البيانات"
},
"analytics.filter.game-version-type": {
"message": "نوع إصدار اللعبة"
},
"analytics.filter.game-version-type.all": {
"message": "الكل"
},
"analytics.filter.game-version-type.release": {
"message": "إصدار نهائي"
},
"analytics.filter.search.countries": {
"message": "البحث عن الدول..."
},
"analytics.filter.search.dependent-projects": {
"message": "البحث عن المشاريع..."
},
"analytics.filter.search.download-sources": {
"message": "البحث عن مصادر التنزيل..."
},
"analytics.filter.search.members": {
"message": "البحث عن الأعضاء..."
},
"analytics.filter.search.project-versions": {
"message": "البحث عن إصدارات المشاريع..."
},
"analytics.filter.search.versions": {
"message": "البحث عن الإصدارات..."
},
"analytics.graph.title.downloads": {
"message": "التنزيلات بمرور الوقت"
},
"analytics.graph.title.playtime": {
"message": "وقت اللعب بمرور الوقت"
},
"analytics.graph.title.revenue": {
"message": "الإيرادات بمرور الوقت"
},
"analytics.graph.title.views": {
"message": "المشاهدات بمرور الوقت"
},
"analytics.group-by.1h": {
"message": "ساعة 1"
},
"analytics.group-by.6h": {
"message": "6 ساعات"
},
"analytics.group-by.date": {
"message": "التاريخ"
},
"analytics.group-by.day": {
"message": "يوم"
},
"analytics.group-by.month": {
"message": "شهر"
},
"analytics.group-by.selected.day": {
"message": "التجميع حسب اليوم"
},
"analytics.group-by.selected.hour": {
"message": "التجميع حسب الساعة"
},
"analytics.group-by.selected.month": {
"message": "التجميع حسب الشهر"
},
"analytics.group-by.selected.six-hours": {
"message": "التجميع كل 6 ساعات"
},
"analytics.group-by.selected.week": {
"message": "التجميع حسب الأسبوع"
},
"analytics.group-by.selected.year": {
"message": "التجميع حسب السنة"
},
"analytics.group-by.week": {
"message": "الأسبوع"
},
@@ -80,27 +326,81 @@
"analytics.project-event.project-status-changed": {
"message": "تم تغيير حالة المشروع"
},
"analytics.project-event.project-unlisted": {
"message": "المشروع غير مُدرج"
},
"analytics.project-event.version-released": {
"message": ""
},
"analytics.project-event.version-uploaded": {
"message": "تم رفع الإصدار"
},
"analytics.project-status.approved": {
"message": "تمت الموافقة"
},
"analytics.project-status.archived": {
"message": "مؤرشف"
},
"analytics.project-status.draft": {
"message": "مسودة"
},
"analytics.project-status.other": {
"message": "أخرى"
},
"analytics.project-status.private": {
"message": "خاص"
},
"analytics.project-status.rejected": {
"message": "رُفض المشروع"
},
"analytics.project-status.unlisted": {
"message": "غير مُدرج"
},
"analytics.project-status.withheld": {
"message": "محجوب"
},
"analytics.project.all": {
"message": "جميع المشاريع"
},
"analytics.project.count": {
"message": "{count, plural, one {# مشروع} other {# مشاريع}}"
},
"analytics.project.icon-alt": {
"message": "أيقونة {name}"
},
"analytics.project.select": {
"message": "تحديد المشاريع"
},
"analytics.project.user": {
"message": "مشاريع {username}"
},
"analytics.project.your": {
"message": "مشاريعك"
},
"analytics.query.filter.add": {
"message": "إضافة عامل تصفية"
},
"analytics.query.label.breakdown": {
"message": "التفصيل:"
},
"analytics.query.label.grouped-by": {
"message": "مجمّع حسب"
},
"analytics.query.label.project": {
"message": "المشروع:"
},
"analytics.query.label.timeframe": {
"message": "الفترة الزمنية:"
},
"analytics.stat.downloads": {
"message": "التنزيلات"
},
"analytics.stat.monetization-banner.body": {
"message": "المشاهدات وعمليات التنزيل التي تتم عبر Modrinth فقط هي المؤهلة لتحقيق الأرباح، ويجب أن تجتاز عملية تصفية لمنع الاحتيال. كما تتطلب عمليات التنزيل من تطبيق Modrinth تسجيل دخول المستخدم. ونظرًا لأن جميع المشاريع لديها نسبة متشابهة من التنزيلات القابلة لتحقيق الأرباح، فلن يتغير إيرادك بشكل ملحوظ إذا تم احتساب جميع عمليات التنزيل."
},
"analytics.stat.monetization-banner.learn-more": {
"message": "معرفة المزيد"
},
"analytics.stat.playtime": {
"message": "وقت اللعب"
},
+13 -31
View File
@@ -89,6 +89,9 @@
"analytics.chart.controls.button": {
"message": "Ovládání"
},
"analytics.chart.controls.display": {
"message": "Zobrazit"
},
"analytics.chart.controls.modrinth-events": {
"message": "Události Modrinthu"
},
@@ -137,6 +140,9 @@
"analytics.chart.tooltip.hide-entry": {
"message": "Skrýt {name} v grafu"
},
"analytics.chart.tooltip.pinned-aria": {
"message": "Připnuto"
},
"analytics.chart.tooltip.previous-period-short": {
"message": "(předch.)"
},
@@ -290,6 +296,9 @@
"analytics.project-status.private": {
"message": "Soukromý"
},
"analytics.project-status.rejected": {
"message": "Zamítnuto"
},
"analytics.project-status.unlisted": {
"message": "Neveřejný"
},
@@ -1268,6 +1277,9 @@
"dashboard.creator-withdraw-modal.tremendous-details.reward-plural": {
"message": "Odměny"
},
"dashboard.creator-withdraw-modal.tremendous-details.search-amount-label": {
"message": "Vyhledat množství"
},
"dashboard.creator-withdraw-modal.tremendous-details.unverified-email-header": {
"message": "Neověřený e-mail"
},
@@ -1524,7 +1536,7 @@
"message": "Peněženka"
},
"dashboard.withdraw.error.email-verification.title": {
"message": "Ověření e-mailu je povinné"
"message": "Požadováno ověření emailu"
},
"dashboard.withdraw.error.insufficient-balance.title": {
"message": "Nedostatečný zůstatek"
@@ -2357,42 +2369,15 @@
"project.details.licensed": {
"message": "Licencováno"
},
"project.download.game-version": {
"message": "Verze hry: {version}"
},
"project.download.game-version-error": {
"message": "Chyba: žádné verze hry nebyly nalezeny"
},
"project.download.game-version-tooltip": {
"message": "{title} je k dispozici pouze pro verzi {version}"
},
"project.download.game-version-unsupported-tooltip": {
"message": "{title} nepodporuje {gameVersion} pro {platform}"
},
"project.download.install-with-app": {
"message": "Instalovat přes Modrinth App"
},
"project.download.no-app": {
"message": "Nemáte Modrinth App?"
},
"project.download.platform": {
"message": "Platforma: {platform}"
},
"project.download.platform-error": {
"message": "Chyba: žádné platformy nenalezeny"
},
"project.download.platform-tooltip": {
"message": "{title} je k dispozici pouze pro {platform}"
},
"project.download.platform-unsupported-tooltip": {
"message": "{title} nepodporuje {platform} pro {gameVersion}"
},
"project.download.search-game-versions": {
"message": "Hledat verze hry..."
},
"project.download.search-game-versions-label": {
"message": "Hledat verze hry..."
},
"project.download.select-game-version": {
"message": "Vybrat verzi hry"
},
@@ -3164,9 +3149,6 @@
"version.dependency.view-version": {
"message": "Zobrazit verzi"
},
"version.download.download-dependency": {
"message": "Stáhnout vyžadovaný obsah"
},
"version.download.optional-resource-pack": {
"message": "Nepovinný resource pack"
},
@@ -1760,45 +1760,15 @@
"project.description.title": {
"message": "Beskrivelse"
},
"project.download.game-version": {
"message": "Spille version: {version}"
},
"project.download.game-version-error": {
"message": "Fejl: ingen spil versioner fundet"
},
"project.download.game-version-tooltip": {
"message": "{title} er kun tilgængelig til {version}"
},
"project.download.game-version-unsupported-tooltip": {
"message": "{title} understøtter ikke {gameVersion} til {platform}"
},
"project.download.install-with-app": {
"message": "Installer med Modrinth App"
},
"project.download.no-app": {
"message": "Har ikke Modrinth App?"
},
"project.download.no-versions-available": {
"message": "Ingen versioner tilgængelig til {gameVersion} og {platform}."
},
"project.download.platform": {
"message": "Platform: {platform}"
},
"project.download.platform-error": {
"message": "Fejl: ingen platforme fundet"
},
"project.download.platform-tooltip": {
"message": "{title} er kun tilgængelig til {platform}"
},
"project.download.platform-unsupported-tooltip": {
"message": "{title} understøtter ikke {platform} til {gameVersion}"
},
"project.download.search-game-versions": {
"message": "Søg spil versioner..."
},
"project.download.search-game-versions-label": {
"message": "Søg spil versioner..."
},
"project.download.select-game-version": {
"message": "Vælg spil version"
},
+175 -34
View File
@@ -518,6 +518,9 @@
"analytics.value.unknown": {
"message": "Unbekannt"
},
"analytics.value.unknown-dependent-tooltip": {
"message": "Es gibt keine Metadaten, um zu bestimmen, zu welchem abhängigen Projekt dieser Download gehört."
},
"analytics.value.unmonetized": {
"message": "Nicht monetarisiert"
},
@@ -855,10 +858,10 @@
"message": "Zuletzt benutzt"
},
"auth.sign-in.no-account": {
"message": "Kein Account?"
"message": "Du hast kein Konto?"
},
"auth.sign-in.sign-in-with": {
"message": "Einloggen"
"message": "Bei Modrinth anmelden"
},
"auth.sign-in.title": {
"message": "Anmelden"
@@ -873,10 +876,10 @@
"message": "Durch das erstellen eines Kontos stimmst du den <terms-link>Nutzungsbedingungen</terms-link> und <privacy-policy-link>Datenschutzrichtlinien</privacy-policy-link> von Modrinth zu."
},
"auth.sign-up.show-fewer-options": {
"message": "Weniger Optionen anzeigen"
"message": "Zeige weniger Optionen"
},
"auth.sign-up.show-other-options": {
"message": "Andere Optionen anzeigen"
"message": "Zeige weitere Optionen"
},
"auth.sign-up.sign-in-option.title": {
"message": "Du hast bereits ein Konto?"
@@ -885,7 +888,7 @@
"message": "Registrieren"
},
"auth.sign-up.title.sign-up-with": {
"message": "Account erstellen"
"message": "Ein Konto erstellen"
},
"auth.verify-email.action.account-settings": {
"message": "Kontoeinstellungen"
@@ -912,7 +915,7 @@
"message": "E-Mail-Veifizierung fehlgeschlagen"
},
"auth.verify-email.notification.email-sent.description": {
"message": "Eine E-Mail an {email} wurde zur Verifikation deines Accounts geschickt."
"message": "Eine E-Mail mit einem Link zur Verifizierung deines Accounts wurde an {email} gesendet."
},
"auth.verify-email.notification.email-sent.title": {
"message": "E-Mail gesendet"
@@ -1709,12 +1712,21 @@
"dashboard.projects.project.review-environment-metadata": {
"message": "Bitte überprüfe die Umgebungsmetadaten"
},
"dashboard.projects.selection-bar.aria-label": {
"message": "Aktionen für ausgewählte Projekte"
},
"dashboard.projects.selection-bar.selected-count": {
"message": "{count, plural, one {# Projekt} other {# Projekte}} ausgewählt"
},
"dashboard.projects.table.id": {
"message": "ID"
},
"dashboard.projects.table.name": {
"message": "Name"
},
"dashboard.projects.table.select-all-bulk-editable": {
"message": "Wähle alle Projekte aus, die die Massenbearbeitung unterstützen"
},
"dashboard.projects.table.status": {
"message": "Status"
},
@@ -2660,12 +2672,18 @@
"layout.nav.upgrade-to-modrinth-plus": {
"message": "Auf Modrinth+ upgraden"
},
"moderation.exclude-technical-review": {
"message": "TR ausschließen"
},
"moderation.moderate": {
"message": "Moderieren"
},
"moderation.page.external-projects": {
"message": "Externe Projekte"
},
"moderation.page.global-detail-traces": {
"message": "Globale Traces"
},
"moderation.page.projects": {
"message": "Projekte"
},
@@ -2675,6 +2693,45 @@
"moderation.page.technicalReview": {
"message": "Technische Überprüfung"
},
"modpack-scan-modal.failed": {
"message": "Fehlgeschlagen"
},
"modpack-scan-modal.load-versions-error": {
"message": "Versionen konnten nicht geladen werden: {error}"
},
"modpack-scan-modal.loading-versions": {
"message": "Versionen werden geladen..."
},
"modpack-scan-modal.new-files": {
"message": "Neue Dateien"
},
"modpack-scan-modal.new-groups": {
"message": "Neue Gruppen"
},
"modpack-scan-modal.no-files": {
"message": "Keine Dateien gefunden."
},
"modpack-scan-modal.not-scanned": {
"message": "Nicht gescannt"
},
"modpack-scan-modal.override-files": {
"message": "Überschreibungsdateien ({count})"
},
"modpack-scan-modal.pack-file-name": {
"message": "Name der Pack-Datei"
},
"modpack-scan-modal.scan-all-files": {
"message": "Alle Dateien scannen"
},
"modpack-scan-modal.scan-error": {
"message": "Einige Dateien konnten nicht gescannt werden: {error}"
},
"modpack-scan-modal.scanning": {
"message": "Wird gescannt..."
},
"modpack-scan-modal.title": {
"message": "Modpack-Scan ({scanned}/{total} Dateien)"
},
"muralpay.account-type.checking": {
"message": "Girokonto"
},
@@ -3221,14 +3278,74 @@
"project.details.licensed": {
"message": "Lizenziert unter"
},
"project.download.game-version": {
"message": "Spielversion: {version}"
"project.download.base-game-version-incompatible-tooltip": {
"message": "Diese Spielversion ist mit dem Basisprojekt nicht kompatibel."
},
"project.download.game-version-error": {
"message": "Fehler: Keine Spielversion gefunden"
"project.download.base-loader-incompatible-tooltip": {
"message": "Dieser Loader ist mit dem Basisprojekt nicht kompatibel."
},
"project.download.game-version-tooltip": {
"message": "{title} ist nur für {version} verfügbar"
"project.download.compatible-version-title": {
"message": "Kompatible Versionen"
},
"project.download.dependencies-title": {
"message": "Abhängigkeiten"
},
"project.download.dependency-already-installed": {
"message": "Diese Abhängigkeit ist bereits installiert"
},
"project.download.dependency-any-compatible": {
"message": "Beliebige kompatible"
},
"project.download.dependency-conflicting": {
"message": "Diese Abhängigkeit steht im Konflikt mit einer anderen Abhängigkeit"
},
"project.download.dependency-download-file": {
"message": "{filename} herunterladen"
},
"project.download.dependency-download-file-with-size": {
"message": "{filename} ({size}) herunterladen"
},
"project.download.dependency-duplicate": {
"message": "Diese Abhängigkeit ist bereits enthalten"
},
"project.download.dependency-missing-version": {
"message": "Diese Version der Abhängigkeit ist nicht verfügbar"
},
"project.download.dependency-no-compatible-version": {
"message": "Keine kompatible Version ist für diese Abhängigkeit verfügbar"
},
"project.download.dependency-quilt-fabric-api": {
"message": "Die Fabric-API wird für Quilt ignoriert"
},
"project.download.dependency-resource-pack-admonition": {
"message": "Dieses Projekt hat eine Abhängigkeit mit einem erforderlichen Ressourcenpaket. Lade es herunter und speichere es in deinem {folder}-Ordner."
},
"project.download.dependency-unavailable": {
"message": "Diese Abhängigkeit kann nicht heruntergeladen werden"
},
"project.download.download": {
"message": "Herunterladen"
},
"project.download.download-as-zip": {
"message": "Als .zip herunterladen"
},
"project.download.download-version": {
"message": "{version} herunterladen"
},
"project.download.download-with-dependencies": {
"message": "Mit Abhängigkeiten herunterladen"
},
"project.download.download-with-recommended": {
"message": "Mit empfohlenen herunterladen"
},
"project.download.download-with-recommended-as-zip": {
"message": "Mit empfohlenen als .zip herunterladen"
},
"project.download.duplicate-dependencies-hidden": {
"message": "Doppelte Abhängigkeiten werden ausgeblendet"
},
"project.download.file-unavailable": {
"message": "Diese Datei kann nicht heruntergeladen werden"
},
"project.download.game-version-unsupported-tooltip": {
"message": "{title} unterstützt {gameVersion} für {platform} nicht"
@@ -3236,29 +3353,32 @@
"project.download.install-with-app": {
"message": "Mit Modrinth App installieren"
},
"project.download.manually": {
"message": "Manuell herunterladen"
},
"project.download.no-app": {
"message": "Du hast die Modrinth App nicht?"
},
"project.download.no-game-versions-found": {
"message": "Keine Spielversionen gefunden"
},
"project.download.no-versions-available": {
"message": "Keinen versionen verfügbar für {gameVersion} und {platform}."
},
"project.download.platform": {
"message": "Platform: {platform}"
},
"project.download.platform-error": {
"message": "Fehler: Keine Plattform gefunden"
},
"project.download.platform-tooltip": {
"message": "{title} ist nur für {platform} verfügbar"
"message": "Keine Versionen für {gameVersion} und {platform} verfügbar."
},
"project.download.platform-unsupported-tooltip": {
"message": "{title} unterstützt {platform} für {gameVersion} nicht"
},
"project.download.search-game-versions": {
"message": "Spielversionen durchsuchen..."
"project.download.recommended-title": {
"message": "Empfohlen"
},
"project.download.search-game-versions-label": {
"message": "Spielversionen durchuschen..."
"project.download.required-resource-pack-admonition": {
"message": "Dieses Datenpaket benötigt ebenfalls ein Ressourcenpaket. Lade es herunter und speichere es in deinem {folder}-Ordner."
},
"project.download.required-resource-pack-short": {
"message": "Ressourcenpaket"
},
"project.download.search-game-versions": {
"message": "Spielversion auswählen"
},
"project.download.select-game-version": {
"message": "Spielversion wählen"
@@ -3272,6 +3392,15 @@
"project.download.title": {
"message": "{title} herunterladen"
},
"project.download.unknown-loader": {
"message": "Unbekannter Loader"
},
"project.download.zip-failed-text": {
"message": "Eine oder mehrere Dateien konnten nicht heruntergeladen werden. Bitte versuche es erneut."
},
"project.download.zip-failed-title": {
"message": "Dateien konnten nicht heruntergeladen werden"
},
"project.environment.migration-no-permission.message": {
"message": "Wir haben das Umgebungssystem auf Modrinth überarbeitet und neue Optionen sind jetzt verfügbar. Du hast keine Berechtigung, diese Einstellungen zu ändern, aber lasse bitte ein anderes Projektmitglied wissen, dass die Umgebungsmetadaten verifiziert werden müssen."
},
@@ -3462,7 +3591,7 @@
"message": "Das Moderationsteam von Modrinth hat die von dir für einige externe Inhalte vorgelegten Berechtigungsnachweise abgelehnt. Bitte überprüfe die unten aufgeführten abgelehnten Einträge und lege zulässige Nachweise vor oder entfernen die Inhalte."
},
"project.settings.permissions.bad-proof.title": {
"message": "Manche Beweise wurden abgelehnt"
"message": "Einige Nachweise wurden abgelehnt"
},
"project.settings.permissions.collapse-all": {
"message": "Alle minimieren"
@@ -3498,7 +3627,7 @@
"message": "Mehr erfahren"
},
"project.settings.permissions.no-results": {
"message": "Keine externen Dateien übereinstimmen deiner Suche."
"message": "Keine externen Dateien entsprechen deiner Suche."
},
"project.settings.permissions.not-allowed.description": {
"message": "Einige der enthaltenen externen Inhalte dürfen auf Modrinth nicht verbreitet werden, da sie gegen unsere Inhaltsrichtlinien verstoßen und daher entfernt werden müssen."
@@ -3513,7 +3642,7 @@
"message": "Meiste Dateien"
},
"project.settings.permissions.sort.recently-edited": {
"message": "Letztens bearbeitet"
"message": "Kürzlich bearbeitet"
},
"project.settings.permissions.sort.rejected": {
"message": "Abgelehnt"
@@ -4079,9 +4208,6 @@
"settings.account.security.email.title": {
"message": "E-Mail"
},
"settings.account.security.passkey.add": {
"message": "Passkey hinzufügen"
},
"settings.account.security.passkey.add-modal.name.description": {
"message": "Stell sicher, dass du etwas nimmst, was du dir gut merken kannst, damit du den Passkey später identifizieren kannst."
},
@@ -4091,6 +4217,15 @@
"settings.account.security.passkey.add-modal.name.placeholder": {
"message": "Mein Passkey"
},
"settings.account.security.passkey.add-modal.title": {
"message": "Passkey hinzufügen"
},
"settings.account.security.passkey.add.button": {
"message": "Passkey hinzufügen"
},
"settings.account.security.passkey.button": {
"message": "Passkeys verwalten"
},
"settings.account.security.passkey.description": {
"message": "Verwalte deine gespeicherten Passkeys oder füge einen neuen hinzu."
},
@@ -4101,7 +4236,7 @@
"message": "Zuletzt benutzt {ago}"
},
"settings.account.security.passkey.modal.loading": {
"message": "Passkeys laden..."
"message": "Passkeys werden geladen..."
},
"settings.account.security.passkey.modal.never-used": {
"message": "Nie benutzt"
@@ -4109,6 +4244,9 @@
"settings.account.security.passkey.modal.no-passkeys": {
"message": "Du hast keine registrierten Passkeys."
},
"settings.account.security.passkey.remove.button": {
"message": "Passkey löschen"
},
"settings.account.security.passkey.remove.description": {
"message": "Dadurch wird der Passkey „{name}“ endgültig gelöscht. Du kannst dich mit ihm nicht mehr anmelden."
},
@@ -4752,7 +4890,7 @@
"message": "Version anzeigen"
},
"version.download.download-dependency": {
"message": "Abhängigkeit herunterladen"
"message": "Wähle eine Version zum Herunterladen aus"
},
"version.download.no-primary-file": {
"message": "Fehler: Keine primäre Datei gefunden"
@@ -4763,6 +4901,9 @@
"version.download.required-resource-pack": {
"message": "Erforderliches Ressourcenpaket"
},
"version.download.version": {
"message": "{version} ({size}) herunterladen"
},
"version.edit.button": {
"message": "Bearbeiten"
},
+168 -27
View File
@@ -36,7 +36,7 @@
"message": "Downloadgrund"
},
"analytics.breakdown.download-source": {
"message": "Downloadquelle"
"message": "Download-Quelle"
},
"analytics.breakdown.game-version": {
"message": "Spielversion"
@@ -96,7 +96,7 @@
"message": "Einstellungselemente für Analysediagramm"
},
"analytics.chart.controls.display": {
"message": "Anzeigen"
"message": "Anzeige"
},
"analytics.chart.controls.modrinth-events": {
"message": "Modrinth-Ereignisse"
@@ -498,7 +498,7 @@
"message": "Projekte über"
},
"analytics.title": {
"message": "Analysen"
"message": "Analytik"
},
"analytics.value.monetized": {
"message": "Monetarisiert"
@@ -518,6 +518,9 @@
"analytics.value.unknown": {
"message": "Unbekannt"
},
"analytics.value.unknown-dependent-tooltip": {
"message": "Es gibt keine Metadaten, um zu bestimmen, zu welchem abhängigen Projekt dieser Download gehört."
},
"analytics.value.unmonetized": {
"message": "Nicht monetarisiert"
},
@@ -1709,12 +1712,21 @@
"dashboard.projects.project.review-environment-metadata": {
"message": "Bitte überprüfe die Umgebungsmetadaten"
},
"dashboard.projects.selection-bar.aria-label": {
"message": "Aktionen für ausgewählte Projekte"
},
"dashboard.projects.selection-bar.selected-count": {
"message": "{count, plural, one {# Projekt} other {# Projekte}} ausgewählt"
},
"dashboard.projects.table.id": {
"message": "ID"
},
"dashboard.projects.table.name": {
"message": "Name"
},
"dashboard.projects.table.select-all-bulk-editable": {
"message": "Wähle alle Projekte aus, die die Massenbearbeitung unterstützen"
},
"dashboard.projects.table.status": {
"message": "Status"
},
@@ -2660,12 +2672,18 @@
"layout.nav.upgrade-to-modrinth-plus": {
"message": "Auf Modrinth+ upgraden"
},
"moderation.exclude-technical-review": {
"message": "TR ausschließen"
},
"moderation.moderate": {
"message": "Moderieren"
},
"moderation.page.external-projects": {
"message": "Externe Projekte"
},
"moderation.page.global-detail-traces": {
"message": "Globale Traces"
},
"moderation.page.projects": {
"message": "Projekte"
},
@@ -2675,6 +2693,45 @@
"moderation.page.technicalReview": {
"message": "Technische Überprüfung"
},
"modpack-scan-modal.failed": {
"message": "Fehlgeschlagen"
},
"modpack-scan-modal.load-versions-error": {
"message": "Versionen konnten nicht geladen werden: {error}"
},
"modpack-scan-modal.loading-versions": {
"message": "Versionen werden geladen..."
},
"modpack-scan-modal.new-files": {
"message": "Neue Dateien"
},
"modpack-scan-modal.new-groups": {
"message": "Neue Gruppen"
},
"modpack-scan-modal.no-files": {
"message": "Keine Dateien gefunden."
},
"modpack-scan-modal.not-scanned": {
"message": "Nicht gescannt"
},
"modpack-scan-modal.override-files": {
"message": "Überschreibungsdateien ({count})"
},
"modpack-scan-modal.pack-file-name": {
"message": "Name der Pack-Datei"
},
"modpack-scan-modal.scan-all-files": {
"message": "Alle Dateien scannen"
},
"modpack-scan-modal.scan-error": {
"message": "Einige Dateien konnten nicht gescannt werden: {error}"
},
"modpack-scan-modal.scanning": {
"message": "Wird gescannt..."
},
"modpack-scan-modal.title": {
"message": "Modpack-Scan ({scanned}/{total} Dateien)"
},
"muralpay.account-type.checking": {
"message": "Girokonto"
},
@@ -3221,14 +3278,74 @@
"project.details.licensed": {
"message": "Lizenziert unter"
},
"project.download.game-version": {
"message": "Spielversion: {version}"
"project.download.base-game-version-incompatible-tooltip": {
"message": "Diese Spielversion ist mit dem Basisprojekt nicht kompatibel."
},
"project.download.game-version-error": {
"message": "Fehler: Keine Spielversion gefunden"
"project.download.base-loader-incompatible-tooltip": {
"message": "Dieser Loader ist mit dem Basisprojekt nicht kompatibel."
},
"project.download.game-version-tooltip": {
"message": "{title} ist nur für {version} verfügbar"
"project.download.compatible-version-title": {
"message": "Kompatible Versionen"
},
"project.download.dependencies-title": {
"message": "Abhängigkeiten"
},
"project.download.dependency-already-installed": {
"message": "Diese Abhängigkeit ist bereits installiert"
},
"project.download.dependency-any-compatible": {
"message": "Beliebige kompatible"
},
"project.download.dependency-conflicting": {
"message": "Diese Abhängigkeit steht im Konflikt mit einer anderen Abhängigkeit"
},
"project.download.dependency-download-file": {
"message": "{filename} herunterladen"
},
"project.download.dependency-download-file-with-size": {
"message": "{filename} ({size}) herunterladen"
},
"project.download.dependency-duplicate": {
"message": "Diese Abhängigkeit ist bereits enthalten"
},
"project.download.dependency-missing-version": {
"message": "Diese Version der Abhängigkeit ist nicht verfügbar"
},
"project.download.dependency-no-compatible-version": {
"message": "Keine kompatible Version ist für diese Abhängigkeit verfügbar"
},
"project.download.dependency-quilt-fabric-api": {
"message": "Die Fabric-API wird für Quilt ignoriert"
},
"project.download.dependency-resource-pack-admonition": {
"message": "Dieses Projekt hat eine Abhängigkeit mit einem erforderlichen Ressourcenpaket. Lade es herunter und speichere es in deinem {folder}-Ordner."
},
"project.download.dependency-unavailable": {
"message": "Diese Abhängigkeit kann nicht heruntergeladen werden"
},
"project.download.download": {
"message": "Herunterladen"
},
"project.download.download-as-zip": {
"message": "Als .zip herunterladen"
},
"project.download.download-version": {
"message": "{version} herunterladen"
},
"project.download.download-with-dependencies": {
"message": "Mit Abhängigkeiten herunterladen"
},
"project.download.download-with-recommended": {
"message": "Mit empfohlenen herunterladen"
},
"project.download.download-with-recommended-as-zip": {
"message": "Mit empfohlenen als .zip herunterladen"
},
"project.download.duplicate-dependencies-hidden": {
"message": "Doppelte Abhängigkeiten werden ausgeblendet"
},
"project.download.file-unavailable": {
"message": "Diese Datei kann nicht heruntergeladen werden"
},
"project.download.game-version-unsupported-tooltip": {
"message": "{title} unterstützt {gameVersion} für {platform} nicht"
@@ -3236,29 +3353,32 @@
"project.download.install-with-app": {
"message": "Mit Modrinth App installieren"
},
"project.download.manually": {
"message": "Manuell herunterladen"
},
"project.download.no-app": {
"message": "Du hast die Modrinth App nicht?"
},
"project.download.no-game-versions-found": {
"message": "Keine Spielversionen gefunden"
},
"project.download.no-versions-available": {
"message": "Keine Versionen für {gameVersion} und {platform} verfügbar."
},
"project.download.platform": {
"message": "Plattform: {platform}"
},
"project.download.platform-error": {
"message": "Fehler: Keine Platform gefunden"
},
"project.download.platform-tooltip": {
"message": "{title} ist nur für {platform} verfügbar"
},
"project.download.platform-unsupported-tooltip": {
"message": "{title} unterstützt {platform} für die {gameVersion} nicht"
},
"project.download.search-game-versions": {
"message": "Spielversionen durchsuchen..."
"project.download.recommended-title": {
"message": "Empfohlen"
},
"project.download.search-game-versions-label": {
"message": "Spielversionen durchuschen..."
"project.download.required-resource-pack-admonition": {
"message": "Dieses Datenpaket benötigt ebenfalls ein Ressourcenpaket. Lade es herunter und speichere es in deinem {folder}-Ordner."
},
"project.download.required-resource-pack-short": {
"message": "Ressourcenpaket"
},
"project.download.search-game-versions": {
"message": "Spielversion auswählen"
},
"project.download.select-game-version": {
"message": "Spielversion auswählen"
@@ -3272,6 +3392,15 @@
"project.download.title": {
"message": "{title} herunterladen"
},
"project.download.unknown-loader": {
"message": "Unbekannter Loader"
},
"project.download.zip-failed-text": {
"message": "Eine oder mehrere Dateien konnten nicht heruntergeladen werden. Bitte versuche es erneut."
},
"project.download.zip-failed-title": {
"message": "Dateien konnten nicht heruntergeladen werden"
},
"project.environment.migration-no-permission.message": {
"message": "Wir haben kürzlich das Umgebungssystem auf Modrinth überarbeitet und neue Optionen sind nun verfügbar. Du hast keine Berechtigung, diese Einstellungen zu ändern, aber informiere bitte ein anderes Projektmitglied, dass die Umgebungsmetadaten überprüft werden müssen."
},
@@ -4079,9 +4208,6 @@
"settings.account.security.email.title": {
"message": "E-Mail"
},
"settings.account.security.passkey.add": {
"message": "Passkey hinzufügen"
},
"settings.account.security.passkey.add-modal.name.description": {
"message": "Stell sicher, dass du etwas nimmst, was du dir gut merken kannst, damit du den Passkey später identifizieren kannst."
},
@@ -4091,6 +4217,15 @@
"settings.account.security.passkey.add-modal.name.placeholder": {
"message": "Mein Passkey"
},
"settings.account.security.passkey.add-modal.title": {
"message": "Passkey hinzufügen"
},
"settings.account.security.passkey.add.button": {
"message": "Passkey hinzufügen"
},
"settings.account.security.passkey.button": {
"message": "Passkeys verwalten"
},
"settings.account.security.passkey.description": {
"message": "Verwalte deine gespeicherten Passkeys oder füge einen neuen hinzu."
},
@@ -4101,7 +4236,7 @@
"message": "Zuletzt {ago} verwendet"
},
"settings.account.security.passkey.modal.loading": {
"message": "Passkeys werden geladen"
"message": "Passkeys werden geladen..."
},
"settings.account.security.passkey.modal.never-used": {
"message": "Nie verwendet"
@@ -4109,6 +4244,9 @@
"settings.account.security.passkey.modal.no-passkeys": {
"message": "Du hast keine registrierten Passkeys."
},
"settings.account.security.passkey.remove.button": {
"message": "Passkey löschen"
},
"settings.account.security.passkey.remove.description": {
"message": "Dadurch wird der Passkey „{name}“ endgültig gelöscht. Du kannst dich mit ihm nicht mehr anmelden."
},
@@ -4752,7 +4890,7 @@
"message": "Version anzeigen"
},
"version.download.download-dependency": {
"message": "Abhängigkeit herunterladen"
"message": "Wähle eine Version zum Herunterladen aus"
},
"version.download.no-primary-file": {
"message": "Fehler: Keine primäre Datei gefunden"
@@ -4763,6 +4901,9 @@
"version.download.required-resource-pack": {
"message": "Erforderliches Ressourcenpaket"
},
"version.download.version": {
"message": "{version} ({size}) herunterladen"
},
"version.edit.button": {
"message": "Bearbeiten"
},
+156 -24
View File
@@ -518,6 +518,9 @@
"analytics.value.unknown": {
"message": "Unknown"
},
"analytics.value.unknown-dependent-tooltip": {
"message": "There's no metadata to determine which dependent project this download attributes to."
},
"analytics.value.unmonetized": {
"message": "Unmonetized"
},
@@ -2669,12 +2672,18 @@
"layout.nav.upgrade-to-modrinth-plus": {
"message": "Upgrade to Modrinth+"
},
"moderation.exclude-technical-review": {
"message": "Exclude TR"
},
"moderation.moderate": {
"message": "Moderate"
},
"moderation.page.external-projects": {
"message": "External projects"
},
"moderation.page.global-detail-traces": {
"message": "Global traces"
},
"moderation.page.projects": {
"message": "Projects"
},
@@ -2684,6 +2693,45 @@
"moderation.page.technicalReview": {
"message": "Tech review"
},
"modpack-scan-modal.failed": {
"message": "Failed"
},
"modpack-scan-modal.load-versions-error": {
"message": "Failed to load versions: {error}"
},
"modpack-scan-modal.loading-versions": {
"message": "Loading versions..."
},
"modpack-scan-modal.new-files": {
"message": "New Files"
},
"modpack-scan-modal.new-groups": {
"message": "New Groups"
},
"modpack-scan-modal.no-files": {
"message": "No files found."
},
"modpack-scan-modal.not-scanned": {
"message": "Not scanned"
},
"modpack-scan-modal.override-files": {
"message": "Override Files ({count})"
},
"modpack-scan-modal.pack-file-name": {
"message": "Pack File Name"
},
"modpack-scan-modal.scan-all-files": {
"message": "Scan All Files"
},
"modpack-scan-modal.scan-error": {
"message": "Some files failed to scan: {error}"
},
"modpack-scan-modal.scanning": {
"message": "Scanning..."
},
"modpack-scan-modal.title": {
"message": "Modpack Scan ({scanned}/{total} Files)"
},
"muralpay.account-type.checking": {
"message": "Checking"
},
@@ -3230,14 +3278,74 @@
"project.details.licensed": {
"message": "Licensed"
},
"project.download.game-version": {
"message": "Game version: {version}"
"project.download.base-game-version-incompatible-tooltip": {
"message": "This game version is incompatible with the base project."
},
"project.download.game-version-error": {
"message": "Error: no game versions found"
"project.download.base-loader-incompatible-tooltip": {
"message": "This loader is incompatible with the base project."
},
"project.download.game-version-tooltip": {
"message": "{title} is only available for {version}"
"project.download.compatible-version-title": {
"message": "Compatible versions"
},
"project.download.dependencies-title": {
"message": "Dependencies"
},
"project.download.dependency-already-installed": {
"message": "This dependency is already installed"
},
"project.download.dependency-any-compatible": {
"message": "Any compatible"
},
"project.download.dependency-conflicting": {
"message": "This dependency conflicts with another dependency"
},
"project.download.dependency-download-file": {
"message": "Download {filename}"
},
"project.download.dependency-download-file-with-size": {
"message": "Download {filename} ({size})"
},
"project.download.dependency-duplicate": {
"message": "This dependency is already included"
},
"project.download.dependency-missing-version": {
"message": "This dependency version is unavailable"
},
"project.download.dependency-no-compatible-version": {
"message": "No compatible version is available for this dependency"
},
"project.download.dependency-quilt-fabric-api": {
"message": "Fabric API is skipped for Quilt"
},
"project.download.dependency-resource-pack-admonition": {
"message": "This project has a dependency with a required resource pack. Download it and place it in your {folder} folder."
},
"project.download.dependency-unavailable": {
"message": "This dependency cannot be downloaded"
},
"project.download.download": {
"message": "Download"
},
"project.download.download-as-zip": {
"message": "Download as .zip"
},
"project.download.download-version": {
"message": "Download {version}"
},
"project.download.download-with-dependencies": {
"message": "Download with deps"
},
"project.download.download-with-recommended": {
"message": "Download with recommended"
},
"project.download.download-with-recommended-as-zip": {
"message": "Download with recommended as .zip"
},
"project.download.duplicate-dependencies-hidden": {
"message": "Duplicate dependencies are hidden"
},
"project.download.file-unavailable": {
"message": "This file cannot be downloaded"
},
"project.download.game-version-unsupported-tooltip": {
"message": "{title} does not support {gameVersion} for {platform}"
@@ -3245,29 +3353,32 @@
"project.download.install-with-app": {
"message": "Install with Modrinth App"
},
"project.download.manually": {
"message": "Download manually"
},
"project.download.no-app": {
"message": "Don't have Modrinth App?"
},
"project.download.no-game-versions-found": {
"message": "No game versions found"
},
"project.download.no-versions-available": {
"message": "No versions available for {gameVersion} and {platform}."
},
"project.download.platform": {
"message": "Platform: {platform}"
},
"project.download.platform-error": {
"message": "Error: no platforms found"
},
"project.download.platform-tooltip": {
"message": "{title} is only available for {platform}"
},
"project.download.platform-unsupported-tooltip": {
"message": "{title} does not support {platform} for {gameVersion}"
},
"project.download.search-game-versions": {
"message": "Search game versions..."
"project.download.recommended-title": {
"message": "Recommended"
},
"project.download.search-game-versions-label": {
"message": "Search game versions..."
"project.download.required-resource-pack-admonition": {
"message": "This data pack also requires a resource pack. Download it and place it in your {folder} folder."
},
"project.download.required-resource-pack-short": {
"message": "Resource pack"
},
"project.download.search-game-versions": {
"message": "Select game version"
},
"project.download.select-game-version": {
"message": "Select game version"
@@ -3281,6 +3392,15 @@
"project.download.title": {
"message": "Download {title}"
},
"project.download.unknown-loader": {
"message": "Unknown loader"
},
"project.download.zip-failed-text": {
"message": "One or more files could not be downloaded. Please try again."
},
"project.download.zip-failed-title": {
"message": "Could not download files"
},
"project.environment.migration-no-permission.message": {
"message": "We've just overhauled the Environments system on Modrinth and new options are now available. You don't have permission to modify these settings, but please let another member of the project know that the environment metadata needs to be verified."
},
@@ -4088,9 +4208,6 @@
"settings.account.security.email.title": {
"message": "Email"
},
"settings.account.security.passkey.add": {
"message": "Add passkey"
},
"settings.account.security.passkey.add-modal.name.description": {
"message": "Make sure to pick something memorable, so you can identify this passkey later."
},
@@ -4100,6 +4217,15 @@
"settings.account.security.passkey.add-modal.name.placeholder": {
"message": "My passkey"
},
"settings.account.security.passkey.add-modal.title": {
"message": "Add passkey"
},
"settings.account.security.passkey.add.button": {
"message": "Add passkey"
},
"settings.account.security.passkey.button": {
"message": "Manage passkeys"
},
"settings.account.security.passkey.description": {
"message": "Manage your registered passkeys, or add a new one."
},
@@ -4110,7 +4236,7 @@
"message": "Last used {ago}"
},
"settings.account.security.passkey.modal.loading": {
"message": "Loading passkeys"
"message": "Loading passkeys..."
},
"settings.account.security.passkey.modal.never-used": {
"message": "Never used"
@@ -4118,6 +4244,9 @@
"settings.account.security.passkey.modal.no-passkeys": {
"message": "You do not have any passkeys registered."
},
"settings.account.security.passkey.remove.button": {
"message": "Delete passkey"
},
"settings.account.security.passkey.remove.description": {
"message": "This will permanently remove the passkey \"{name}\". You will no longer be able to sign in with it."
},
@@ -4761,7 +4890,7 @@
"message": "View version"
},
"version.download.download-dependency": {
"message": "Download dependency"
"message": "Select a version to download"
},
"version.download.no-primary-file": {
"message": "Error: No primary file found"
@@ -4772,6 +4901,9 @@
"version.download.required-resource-pack": {
"message": "Required resource pack"
},
"version.download.version": {
"message": "Download {version} ({size})"
},
"version.edit.button": {
"message": "Edit"
},
+166 -25
View File
@@ -518,6 +518,9 @@
"analytics.value.unknown": {
"message": "Desconocido"
},
"analytics.value.unknown-dependent-tooltip": {
"message": "No hay metadatos que permitan determinar a qué proyecto dependiente corresponde esta descarga."
},
"analytics.value.unmonetized": {
"message": "No monetizado"
},
@@ -1709,12 +1712,21 @@
"dashboard.projects.project.review-environment-metadata": {
"message": "Por favor revisa los metadatos de entorno"
},
"dashboard.projects.selection-bar.aria-label": {
"message": "Acciones de los proyectos seleccionados"
},
"dashboard.projects.selection-bar.selected-count": {
"message": "{count, plural, one {# proyecto seleccionado} other {# proyectos seleccionados}}"
},
"dashboard.projects.table.id": {
"message": "ID"
},
"dashboard.projects.table.name": {
"message": "Nombre"
},
"dashboard.projects.table.select-all-bulk-editable": {
"message": "Seleccionar todos los proyectos que admiten la edición masiva"
},
"dashboard.projects.table.status": {
"message": "Estado"
},
@@ -2660,12 +2672,18 @@
"layout.nav.upgrade-to-modrinth-plus": {
"message": "Actualizar a Modrinth+"
},
"moderation.exclude-technical-review": {
"message": "Excluir RT"
},
"moderation.moderate": {
"message": "Moderar"
},
"moderation.page.external-projects": {
"message": "Proyectos externos"
},
"moderation.page.global-detail-traces": {
"message": "Trazas globales"
},
"moderation.page.projects": {
"message": "Proyectos"
},
@@ -2675,6 +2693,45 @@
"moderation.page.technicalReview": {
"message": "Revisión técnica"
},
"modpack-scan-modal.failed": {
"message": "Error"
},
"modpack-scan-modal.load-versions-error": {
"message": "No se pudieron cargar las versiones: {error}"
},
"modpack-scan-modal.loading-versions": {
"message": "Cargando versiones..."
},
"modpack-scan-modal.new-files": {
"message": "Nuevos archivos"
},
"modpack-scan-modal.new-groups": {
"message": "Nuevos grupos"
},
"modpack-scan-modal.no-files": {
"message": "No se encontraron archivos."
},
"modpack-scan-modal.not-scanned": {
"message": "No escaneado"
},
"modpack-scan-modal.override-files": {
"message": "Archivos de sobrescritura ({count})"
},
"modpack-scan-modal.pack-file-name": {
"message": "Nombre del archivo del paquete"
},
"modpack-scan-modal.scan-all-files": {
"message": "Escanear todos los archivos"
},
"modpack-scan-modal.scan-error": {
"message": "No se pudieron escanear algunos archivos: {error}"
},
"modpack-scan-modal.scanning": {
"message": "Escaneando..."
},
"modpack-scan-modal.title": {
"message": "Análisis del modpack ({scanned}/{total} archivos)"
},
"muralpay.account-type.checking": {
"message": "Cuenta de cheques"
},
@@ -3221,44 +3278,107 @@
"project.details.licensed": {
"message": "Con licencia"
},
"project.download.game-version": {
"message": "Versión del juego: {version}"
"project.download.base-game-version-incompatible-tooltip": {
"message": "Esta versión del juego no es compatible con el proyecto base."
},
"project.download.game-version-error": {
"message": "Error: no se encontraron versiones del juego"
"project.download.base-loader-incompatible-tooltip": {
"message": "Este cargador no es compatible con el proyecto base."
},
"project.download.game-version-tooltip": {
"message": "{title} solo está disponible para {version}"
"project.download.compatible-version-title": {
"message": "Versiones compatibles"
},
"project.download.dependencies-title": {
"message": "Dependencias"
},
"project.download.dependency-already-installed": {
"message": "This dependency is already installed"
},
"project.download.dependency-any-compatible": {
"message": "Cualquiera compatible"
},
"project.download.dependency-conflicting": {
"message": "Esta dependencia entra en conflicto con otra dependencia"
},
"project.download.dependency-download-file": {
"message": "Descargar {filename}"
},
"project.download.dependency-download-file-with-size": {
"message": "Descargar {filename} ({size})"
},
"project.download.dependency-duplicate": {
"message": "Esta dependencia ya está incluida"
},
"project.download.dependency-missing-version": {
"message": "Esta versión de la dependencia no está disponible"
},
"project.download.dependency-no-compatible-version": {
"message": "No hay ninguna versión compatible disponible para esta dependencia"
},
"project.download.dependency-quilt-fabric-api": {
"message": "La API de Fabric se omite para Quilt"
},
"project.download.dependency-resource-pack-admonition": {
"message": "Este proyecto depende de un paquete de recursos obligatorio. Descárgalo y colócalo en tu carpeta {folder}."
},
"project.download.dependency-unavailable": {
"message": "Esta dependencia no se puede descargar"
},
"project.download.download": {
"message": "Descargar"
},
"project.download.download-as-zip": {
"message": "Descargar como archivo .zip"
},
"project.download.download-version": {
"message": "Descargar {version}"
},
"project.download.download-with-dependencies": {
"message": "Descargar con dependencias"
},
"project.download.download-with-recommended": {
"message": "Descargar con las recomendaciones"
},
"project.download.download-with-recommended-as-zip": {
"message": "Descargar con los archivos recomendados como .zip"
},
"project.download.duplicate-dependencies-hidden": {
"message": "Las dependencias duplicadas están ocultas"
},
"project.download.file-unavailable": {
"message": "Este archivo no se puede descargar"
},
"project.download.game-version-unsupported-tooltip": {
"message": "{title} no soporta {gameVersion} para {platform}"
},
"project.download.install-with-app": {
"message": "Instalar con la Modrinth App"
"message": "Instalar con la aplicación Modrinth"
},
"project.download.manually": {
"message": "Descargar manualmente"
},
"project.download.no-app": {
"message": "¿No tienes la Modrinth App?"
},
"project.download.no-game-versions-found": {
"message": "No se encontraron versiones del juego"
},
"project.download.no-versions-available": {
"message": "No hay versiones disponibles para {gameVersion} y {platform}."
},
"project.download.platform": {
"message": "Plataforma: {platform}"
},
"project.download.platform-error": {
"message": "Error: no se encontraron plataformas"
},
"project.download.platform-tooltip": {
"message": "{title} solo está disponible para {platform}"
},
"project.download.platform-unsupported-tooltip": {
"message": "{title} no soporta {platform} para {gameVersion}"
},
"project.download.search-game-versions": {
"message": "Buscar versiones del juego..."
"project.download.recommended-title": {
"message": "Recomendado"
},
"project.download.search-game-versions-label": {
"message": "Buscar versiones del juego..."
"project.download.required-resource-pack-admonition": {
"message": "Este paquete de datos también requiere un paquete de recursos. Descárgalo y colócalo en tu carpeta {folder}."
},
"project.download.required-resource-pack-short": {
"message": "Paquete de recursos"
},
"project.download.search-game-versions": {
"message": "Selecciona la versión del juego"
},
"project.download.select-game-version": {
"message": "Selecciona la versión del juego"
@@ -3272,6 +3392,15 @@
"project.download.title": {
"message": "Descargar {title}"
},
"project.download.unknown-loader": {
"message": "Cargador desconocido"
},
"project.download.zip-failed-text": {
"message": "No se pudieron descargar uno o más archivos. Inténtalo de nuevo."
},
"project.download.zip-failed-title": {
"message": "No se pudieron descargar los archivos"
},
"project.environment.migration-no-permission.message": {
"message": "Acabamos de renovar el sistema de entornos en Modrinth y ahora hay nuevas opciones disponibles. No tienes permiso para modificar estos ajustes, pero avisa a otro miembro del proyecto que los metadatos del entorno necesitan ser verificados."
},
@@ -4079,9 +4208,6 @@
"settings.account.security.email.title": {
"message": "Correo electrónico"
},
"settings.account.security.passkey.add": {
"message": "Agregar clave de acceso"
},
"settings.account.security.passkey.add-modal.name.description": {
"message": "Asegúrate de elegir algo fácil de recordar, para que puedas identificar esta contraseña más adelante."
},
@@ -4091,6 +4217,15 @@
"settings.account.security.passkey.add-modal.name.placeholder": {
"message": "Mi clave de acceso"
},
"settings.account.security.passkey.add-modal.title": {
"message": "Agregar clave de acceso"
},
"settings.account.security.passkey.add.button": {
"message": "Agregar clave de acceso"
},
"settings.account.security.passkey.button": {
"message": "Administrar claves de acceso"
},
"settings.account.security.passkey.description": {
"message": "Administra tus claves de acceso registradas o agrega una nueva."
},
@@ -4101,7 +4236,7 @@
"message": "Último uso {ago}"
},
"settings.account.security.passkey.modal.loading": {
"message": "Cargando claves de acceso"
"message": "Cargando claves de acceso..."
},
"settings.account.security.passkey.modal.never-used": {
"message": "Nunca usado"
@@ -4109,6 +4244,9 @@
"settings.account.security.passkey.modal.no-passkeys": {
"message": "No tienes ninguna clave de acceso registrada."
},
"settings.account.security.passkey.remove.button": {
"message": "Eliminar clave de acceso"
},
"settings.account.security.passkey.remove.description": {
"message": "Esto eliminará de forma permanente la clave de acceso \"{name}\". Ya no podrás iniciar sesión con ella."
},
@@ -4752,7 +4890,7 @@
"message": "Ver versión"
},
"version.download.download-dependency": {
"message": "Descargar dependencia"
"message": "Selecciona una versión para descargar"
},
"version.download.no-primary-file": {
"message": "Error: No se encontró el archivo principal"
@@ -4763,6 +4901,9 @@
"version.download.required-resource-pack": {
"message": "Paquete de recursos requerido"
},
"version.download.version": {
"message": "Descargar {version} ({size})"
},
"version.edit.button": {
"message": "Editar"
},

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