Compare commits

...
Author SHA1 Message Date
François-X. T. 4ec93f538a Put cache in Arc, cache lock size metrics 2026-01-23 20:34:33 -05:00
François-X. T. 581fb85ce4 Local cache lock 2026-01-23 08:54:29 -05:00
Prospector 09dead50d2 changelog 2026-01-22 11:40:47 -08:00
ProspectorandGitHub 772e0ee220 Update app download page to use flathub (#5188) 2026-01-22 11:36:56 -08:00
ProspectorandGitHub 86b0de3cee Update linux icon (#5186) 2026-01-22 19:02:12 +00:00
ProspectorandGitHub d174d96b74 Add flathub verification token (#5184) 2026-01-22 09:47:04 -08:00
aecsocketandGitHub 1d193ed01b More tracing spans for Labrinth Redis (#5182)
* more tracing in redis ops

* Improve Redis tracing

* improve messages

* make lpush and brpop use traced cmds
2026-01-22 17:46:37 +01:00
Prospector adf365d99d changelog 2026-01-21 14:50:48 -08:00
ProspectorandGitHub f3f48c3c6f Fix .some error (#5180) 2026-01-21 14:48:47 -08:00
aecsocketandGitHub e072f2237b Improve Labrinth Sentry integration (#5174)
* Improve Sentry integration

* remove debug routes

* fix ci

* sentry tracing stuff

* Add spans to Sentry logging

* Fix CI

* Redis op instrumentation

* pr comments
2026-01-21 18:59:05 +00:00
aecsocketandGitHub 306eee3a21 Fix changing Delphi report issue detail verdict if already exists (#5172)
* Fix changing Delphi report issue detail verdict if already exists

* cargo sqlx prepare
2026-01-21 16:12:21 +00:00
François-Xavier TalbotandGitHub ca1d66d070 Increase index swap timeout, better index swap task o11y (#5171) 2026-01-21 15:54:34 +00:00
Truman GaoandGitHub 7e1400d111 feat: use tanstack query for changelog tab (#5175)
* use tanstack query for changelog tab

* fix query key
2026-01-21 09:10:05 +00:00
Truman GaoandGitHub 7595e77170 fix: showing dependencies stage for modpack (#5176) 2026-01-21 09:08:54 +00:00
08fcc61d35 Make Maven file resolution case-insensitive (#4917)
* Make Maven file resolution case-insensitive

* fix string reference

* fixed formatting

---------

Co-authored-by: Prospector <6166773+Prospector@users.noreply.github.com>
Co-authored-by: aecsocket <aecsocket@tutanota.com>
2026-01-21 01:15:33 +00:00
ProspectorandGitHub b36801c5ed Reduce some more sentry errors (#5173)
* short-circuit user menu options if no user

* misc null checks
2026-01-20 15:58:45 -08:00
ProspectorandGitHub 4ed1a1ae7f attempt to safeguard some potential errors on user and collection pages (#5169)
* attempt to safeguard some potential errors on user and collection pages

* prepr
2026-01-20 13:51:25 -08:00
ProspectorandGitHub 04db01cb55 Fix settings page error for non-members (#5168)
* Fix settings page error for non-members

* prepr
2026-01-20 13:51:12 -08:00
ProspectorandGitHub 8f5185de1c Error on state fail in prod and log endpoint errors (#5167)
* Error on state fail in prod and log endpoint errors

* brint back eslint suppress

* lint
2026-01-20 21:16:25 +00:00
ProspectorandGitHub 9f6db31785 update Modrinth Hosting issue template + misc comments (#5166) 2026-01-20 12:40:11 -08:00
a869086ce9 polish(frontend): technical review QA (#5097)
* feat: filtering + sorting alignment

* polish: malicious summary modal changes

* feat: better filter row using floating panel

* fix: re-enable request

* fix: lint

* polish: jump back to files tab qol

* feat: scroll to top of next card when done

* fix: show lock icon on preview msg

* feat: download no _blank

* feat: show also marked in notif

* feat: auto expand if only one class in the file

* feat: proper page titles

* fix: text-contrast typo

* fix: lint

* feat: QA changes

* feat: individual report page + more qa

* fix: back btn

* fix: broken import

* feat: quick reply msgs

* fix: in other queue filter

* fix: caching threads wrongly

* fix: flag filter

* feat: toggle enabled by default

* fix: dont make btns opacity 50

---------

Co-authored-by: Prospector <6166773+Prospector@users.noreply.github.com>
2026-01-20 19:56:24 +00:00
Michael H. 2af6a1b36f Fix wrangler formatting 2026-01-20 20:56:56 +01:00
ProspectorandGitHub d4381f513f Add 10 second timeout to requests made server-side (#5164)
* add worker timeout for get requests

* make it timeout only on server-side
2026-01-20 20:52:34 +01:00
Michael H. a281f13f15 Enable smart placement 2026-01-20 20:52:20 +01:00
a9641dadff Meilisearch task management, improved task o11y, timeout & batch size adjustments (#5158)
* Better observability

* Search management routes

* Probably fluke

* Use utoipa routes

* Update apps/labrinth/src/routes/internal/search.rs

Co-authored-by: aecsocket <aecsocket@tutanota.com>
Signed-off-by: François-Xavier Talbot <108630700+fetchfern@users.noreply.github.com>

* Update apps/labrinth/src/search/indexing/mod.rs

Co-authored-by: aecsocket <aecsocket@tutanota.com>
Signed-off-by: François-Xavier Talbot <108630700+fetchfern@users.noreply.github.com>

* Update apps/labrinth/src/routes/internal/search.rs

Co-authored-by: aecsocket <aecsocket@tutanota.com>
Signed-off-by: François-Xavier Talbot <108630700+fetchfern@users.noreply.github.com>

* Fix

---------

Signed-off-by: François-Xavier Talbot <108630700+fetchfern@users.noreply.github.com>
Co-authored-by: aecsocket <aecsocket@tutanota.com>
2026-01-20 19:06:37 +00:00
aecsocketandGitHub c94dde9b47 Fix updating mod version if file hash is the same (#5138)
* Start fixing infinite update glitch

* adjust app cache logic

* more cache logic

* cleanup

* add v2 redirect

* add v2 version file route to config
2026-01-19 21:40:44 +00:00
Calum H.andGitHub 976644d1e6 fix: conditionally fetch (#5157)
* fix: conditionally fetch

* fix: yeet
2026-01-19 13:57:49 -08:00
Michael H. 11fe90a69b deploy: nerf cpu time to 5s 2026-01-19 22:27:38 +01:00
Michael H. 1108086854 Revert "Remove dev check in payload/pojo debugger (#5152)"
This reverts commit 3f3e6f5199.
2026-01-19 22:27:15 +01:00
Michael H. 5aea892a39 deploy: raise cpu limit to 10s 2026-01-19 22:12:31 +01:00
Calum H.andGitHub 3f3e6f5199 Remove dev check in payload/pojo debugger (#5152)
* Remove dev dcheck in payload/pojo debugger

Signed-off-by: Calum H. <contact@cal.engineer>

* feat: better payload debugger

---------

Signed-off-by: Calum H. <contact@cal.engineer>
2026-01-19 10:28:25 -08:00
Modrinth BotandGitHub 2efcd383bb New translations from Crowdin (main) (#5154) 2026-01-19 07:35:48 +00:00
Prospector faec9c2965 changelog 2026-01-18 11:28:51 -08:00
Calum H.andGitHub a0e8c7f924 feat: more o11y for i18n pojo (#5148) 2026-01-18 11:18:07 -08:00
Emma AlexiaandGitHub 6efdfdf17e chore: 100k Modrinth projects 🎉 (#5150)
Signed-off-by: Emma Alexia <emma@modrinth.com>
2026-01-18 19:10:47 +00:00
Truman GaoandGitHub aec268c6e9 fix: duplicate changelogs not grouping (#5146)
* fix: changelogs not grouping

* fix changelog check
2026-01-17 23:42:59 +00:00
François-Xavier TalbotandGitHub 2c096a85d6 Better observability for search indexing, fix remove_documents (#5143)
* Better observability for search timeout, fix remove_documents

* Log client idx
2026-01-16 21:51:51 +00:00
240e5455cc Auto create NeoForge instance from mod version (#5142)
* Auto create NeoForge instance from mod version

* prioritise fabric over nf

* pnpm prepr

---------

Co-authored-by: tdgao <mr.trumgao@gmail.com>
2026-01-16 21:21:14 +00:00
Prospector c538a9ec6d changelog 2026-01-16 11:58:48 -08:00
Calum H.andGitHub 72458f5c41 feat: non-POJO check in rendered hook (#5137)
* feat: non-POJO check in rendered hook

Signed-off-by: Calum H. <contact@cal.engineer>

* fix: lint

* move to plugin

---------

Signed-off-by: Calum H. <contact@cal.engineer>
2026-01-16 11:56:46 -08:00
Calum H.andGitHub 82e4eb7b40 feat: light mode fixes for navtab + files tab (#5134) 2026-01-16 18:23:43 +00:00
Calum H.andGitHub c9bfc4e9b6 fix: analytics.js using old i18n impl (#5132)
* fix i18n

* fix: lint
2026-01-16 09:07:40 +00:00
Calum H.andGitHub 1ea96df00e fix: broken infinite query on files.vue (#5133) 2026-01-16 09:03:56 +00:00
Calum H.andGitHub 75c5316dc3 fix: PAT range error guard (#5098) 2026-01-16 03:30:00 +00:00
4ee7623837 build: deploy on both environments (#5129)
* build: deploy on both environments

* build: missing whitespace

* build: add path filter

* build: add sentry env

* build: inherit secrets

* remove if check

* Revert "remove if check"

This reverts commit b2ffe1d611.

* remove if check 2

* Fix Wrangler env

* Fix Wrangler env but for real this time

* Alternative method of getting URLs

* Check for environment instead

* Fix comment

* Clickable commit

* Set PREVIEW build var

* Fix commit shown in comment

* Fix linting errors

* )

* add preview banner

* prepr

* prepr again

* ..

---------

Co-authored-by: Prospector <6166773+Prospector@users.noreply.github.com>
2026-01-16 01:40:10 +00:00
ProspectorandGitHub f65479ee15 ignore robots.txt (#5130) 2026-01-15 17:15:24 -08:00
a903e46be9 feat: remove nuxt i18n for in house i18n for web (#5131)
* feat: remove nuxt i18n for in house

* cleanup: remove old nuxt/i18n patch

* prepr

---------

Co-authored-by: Prospector <6166773+Prospector@users.noreply.github.com>
2026-01-15 23:49:38 +00:00
Prospector 4497131206 changelog 2026-01-15 15:15:32 -08:00
Truman GaoandGitHub 4871abfb3a fix: worker exceeding resources bug caused by large changelogs (#5121)
* fetch changelog only where actually used

* use query parameter properly

* remove todos

* pnpm prepr
2026-01-15 22:58:06 +00:00
b0ed808745 Fix Discover URL filter parsing, improve search sidebar (#5104)
* fix category parsing on discover

* Make categories (loader, platform, etc) colored in discover, also add i18n

* fix formatting

* add localized strings

---------

Co-authored-by: Creeperkatze <178587183+Creeperkatze@users.noreply.github.com>
Co-authored-by: Prospector <6166773+Prospector@users.noreply.github.com>
2026-01-15 15:01:09 -08:00
ProspectorandGitHub 169224560b Include changelog query docs (#5128)
* Include changelog query docs

* example -> default
2026-01-15 14:50:15 -08:00
Prospector 106edc3a51 app 0.10.26 changelog 2026-01-15 14:43:26 -08:00
Truman GaoandGitHub ede405c650 fix: open path util freezing app (#5111)
* fix open path util crashing app

* use spawn blocking instead

* fmt
2026-01-15 20:43:52 +00:00
François-Xavier TalbotandGitHub 1dd1629884 Server transfer admin UI (#5116)
* Initial frontend

* doc for opus (TO REMOVE)

* Make better

* Clarified language

* Remove agent docs

* No scss

* Fmt

* Remove i18n

* Fmt

* Add transferred node tagging
2026-01-15 20:43:37 +00:00
Calum H.andGitHub 0070c9877b fix: re-enable async context (#5126) 2026-01-15 21:41:01 +01:00
Calum H.andGitHub eb208eed8b fix: try nodejs_compat_v2 (#5125) 2026-01-15 14:16:47 +01:00
aecsocketandGitHub c37bf75853 Implement replied/unreplied filter for tech review (#5006)
* Implement replied/unreplied filter for tech review

* project status filter

* wip: issue type filter

* correct filter field

* wip: break up tech review query

* Improve tech review query

* Get tech review working more properly

* Add get single project reports endpoint
2026-01-15 11:54:20 +00:00
Calum H.andGitHub 7838008396 feat: test fix 2 (#5123) 2026-01-15 02:44:26 +01:00
Calum H.andGitHub 454c708fd6 fix: error "27" (#5122)
* fix: error 27

* fix: sentry only in prod

* fix: sentry env

* Revert "fix: error 27"

This reverts commit 66ee482a05.

* feat: attempt to fix error 27
2026-01-15 01:38:23 +01:00
François-Xavier TalbotandGitHub 3ffa78aa07 Allow many Meilisearch write addrs (#5102)
* Write to many Meilisearch write addrs

* Keep client results ordered

* Attach Read Meilisearch client to actix data

* Load balanced meilisearch Compose profile

* Nginx config (round_robin)

* Fix nginx

* Meilisearch + nginx in same net

* Fix env vars example

* Fix env example again

* Fix env again

* Use try_collect with FuturesOrdered

* maybe fix remove_documents

* Clippy
2026-01-15 01:38:09 +01:00
Michael H. 7dba9cbe54 deploy: match sentry sample rate with wrangler observability 2026-01-14 19:00:08 +01:00
716c4e9a21 deploy: add sentry to frontend server (#5118)
* deploy: add sentry to frontend server

* build: add sentry auth token to env

* fix: use sentry CLI for sourcemap upload instead

* feat: comment deploy

---------

Co-authored-by: Calum H. (IMB11) <contact@cal.engineer>
2026-01-14 17:46:55 +00:00
aecsocketandGitHub f85a2d3ec1 Make changelog in version response optional (#5115)
* Make changelog on version routes optional

* fix clippy

* fix ci
2026-01-14 10:55:20 +00:00
d055dc68dc Payout flows in backend - fix Tremendous forex cards (#5001)
* wip: payouts flow api

* working

* Finish up flow migration

* vibe-coded frontend changes

* fix typos and vue

* fix: types

---------

Co-authored-by: Calum H. (IMB11) <contact@cal.engineer>
2026-01-14 10:53:35 +00:00
Emma AlexiaandGitHub 50a87ba933 Fix user deletion (again) (again) (notifications_deliveries) (#5112)
Doesn't seem to work fully with notification IDs, not sure why.
2026-01-13 22:25:00 +00:00
Truman GaoandGitHub 6030cb560c fix: auto close version modal after create (#5108)
* fix: auto closing version modal

* hide on next tick
2026-01-13 20:50:33 +00:00
Prospector c498230ebf changelog 2026-01-12 16:28:40 -08:00
4bbc5905e4 fix: dont remount components for project page every time route changes (#5105)
* fix: dont remount components for project page every time route changes

* remove ts from macro

---------

Co-authored-by: Prospector <6166773+Prospector@users.noreply.github.com>
2026-01-13 00:13:21 +00:00
Truman GaoandGitHub 40f5db64d8 fix: versions v2 fixes (#5106)
* update dependencies step to show when cannot detect suggested dependencies

* rollback environment to previous copy

* implement disable close when uploading in modal

* pnpm prepr
2026-01-13 00:12:10 +00:00
Calum H.andGitHub 8d72a42be5 fix: checklist fixes (#5103)
* fix: page switching only when stage actually changes

* feat: preload improvements

* fix: dont skip own locks
2026-01-12 13:30:36 -08:00
61c8cd75cd feat: manage project versions v2 (#5049)
* update add files copy and go to next step on just one file

* rename and reorder stages

* add metadata stage and update details stage

* implement files inside metadata stage

* use regular prettier instead of prettier eslint

* remove changelog stage config

* save button on details stage

* update edit buttons in versions table

* add collapse environment selector

* implement dependencies list in metadata step

* move dependencies into provider

* add suggested dependencies to metadata stage

* pnpm prepr

* fix unused var

* Revert "add collapse environment selector"

This reverts commit f90fabc7a5.

* hide resource pack loader only when its the only loader

* fix no dependencies for modpack

* add breadcrumbs with hide breadcrumb option

* wider stages

* add proper horizonal scroll breadcrumbs

* fix titles

* handle save version in version page

* remove box shadow

* add notification provider to storybook

* add drop area for versions to drop file right into page

* fix mobile versions table buttons overflowing

* pnpm prepr

* fix drop file opening modal in wrong stage

* implement invalid file for dropping files

* allow horizontal scroll on breadcrumbs

* update infer.js as best as possible

* add create version button uploading version state

* add extractVersionFromFilename for resource pack and datapack

* allow jars for datapack project

* detect multiple loaders when possible

* iris means compatible with optifine too

* infer environment on loader change as well

* add tooltip

* prevent navigate forward when cannot go to next step

* larger breadcrumb click targets

* hide loaders and mc versions stage until files added

* fix max width in header

* fix add files from metadata step jumping steps

* define width in NewModal instead

* disable remove dependency in metadata stage

* switch metadata and details buttons positions

* fix remove button spacing

* do not allow duplicate suggested dependencies

* fix version detection for fabric minecraft version semvar

* better verion number detection based on filename

* show resource pack loader but uneditable

* remove vanilla shader detection

* refactor: break up large infer.js into ts and modules

* remove duplicated types

* add fill missing from file name step

* pnpm prepr

* fix neoforge loader parse failing and not adding neoforge loader

* add missing pack formats

* handle new pack format

* pnpm prepr

* add another regex where it is version in anywhere in filename

* only show resource pack or data pack options for filetype on datapack project

* add redundant zip folder check

* reject RP and DP if has redundant folder

* fix hide stage in breadcrumb

* add snapshot group key in case no release version. brings out 26.1 snapshots

* pnpm prepr

* open in group if has something selected

* fix resource pack loader uneditable if accidentally selected on different project type

* add new environment tags

* add unknown and not applicable environment tags

* pnpm prepr

* use shared constant on labels

* use ref for timeout

* remove console logs

* remove box shadow only for cm-content

* feat: xhr upload + fix wrangler prettierignore

* fix: upload content type fix

* fix dependencies version width

* fix already added dependencies logic

* add changelog minheight

* set progress percentage on button

* add legacy fabric detection logic

* lint

* small update on create version button label

---------

Co-authored-by: Calum H. (IMB11) <contact@cal.engineer>
Co-authored-by: Prospector <6166773+Prospector@users.noreply.github.com>
2026-01-12 19:41:14 +00:00
Calum H.andGitHub b46f6d0141 feat: moderation locking (#5070)
* feat: base locking impl

* feat: lock logic in place in rev endpoint + fetch rev

* feat: frontend impl and finalize

* feat: auto skip if using the moderation queue page

* fix: qa issues

* fix: async state + locking fix

* fix: lint

* fix: fmt

* fix: qa issue

* fix: qa + redirect bug

* fix: lint

* feat: delete all locks endpoint for admins

* fix: dedupe

* fix: fmt

* fix: project redirect move to middleware

* fix: lint
2026-01-12 17:08:30 +00:00
915d8c68bf Fix theme store race condition (#4885)
Co-authored-by: Calum H. <contact@cal.engineer>
2026-01-12 12:15:34 +00:00
ProspectorandGitHub 21045142cd Disallow all in robots.txt unless prod (#5078) 2026-01-12 11:54:21 +00:00
Modrinth BotandGitHub f171752109 New translations from Crowdin (main) (#5095) 2026-01-12 10:26:48 +00:00
Calum H.andGitHub 82f00f961e feat: switch to api-client to prevent rate limit errors on game-versions (#5089)
* feat: switch to api-client to prevent rate limit errors on game-versions

* feat: sentry temp

* Revert "feat: sentry temp"

This reverts commit aaa21a3f80.
2026-01-11 19:55:07 +00:00
Calum H.andGitHub b55b7fdc1c feat: enable async context + patch i18n to be more resilient (#5084)
* feat: enable async context

* feat: improve handling of $i18n defining (patch)
2026-01-10 17:43:21 +00:00
Michael H.andGitHub 0b60060f65 fix: vite dev server cf workers compatibility (#5080) 2026-01-10 14:11:00 +00:00
Calum H.andGitHub b0f1266a8b feat: normalize paths + add share for crash reports (#5071) 2026-01-09 17:02:42 +00:00
Prospector 863ff2e228 fix date 2026-01-08 14:27:58 -08:00
Prospector 579aa5967f app changelog 2026-01-08 14:26:56 -08:00
François-Xavier TalbotandGitHub 3bf5a6ebec Allow projects in tech review to still show up in moderation queue (#5068)
* Don't prevent projects from appearing in moderation queue if there are pending delphi reports

* Update query cache
2026-01-08 20:47:32 +01:00
François-Xavier TalbotandGitHub ff222aa168 PG/RedisPool configuration/o11y improvements (#5032)
* Don't retain Redis connections while doing database queries

* Optional REDIS_WAIT_TIMEOUT_MS

* Attach more data to CacheTimeout errrors

* Fix locks_released

* Fmt

* Set default REDIS_WAIT_TIMEOUT_MS to 15s

* Fix lint

* Close Redis connections idle for > 5 minutes

* Exponential backoff on cache spin lock
2026-01-08 20:47:13 +01:00
Michael H. ea17534f77 deploy(frontend): reduce sampling rate 2026-01-08 20:07:58 +01:00
Michael H. b91d581928 deploy(frontend): add production route 2026-01-08 19:55:52 +01:00
Michael H. 4f6cb7f26c deploy(frontend): don't wrap quotes around value 2026-01-08 18:37:49 +01:00
Michael H. c1da3e7e95 deploy(frontend): actions is stupid 2026-01-08 18:35:18 +01:00
Michael H. 040c568fdb deploy(frontend): debug variables 2026-01-08 18:31:54 +01:00
Michael H. 7a78565c97 deploy(frontend): merge variable injection & build step 2026-01-08 18:14:04 +01:00
62e56eb27e Worker migration (#5072)
* Worker migration

* Deploy on pnpm changes

* Specify package manager

* Manually bump Wrangler to 4.54

* Get rid of useless Wranglers worker

* I take it back

* Set account ID

* Fix preview alias

* feat: use workers api key

* feat: try fix

* fix: missing imports

* fix: again

* fix: only run push workflow on main or prod

* feat: remove store id?

* Populate secret store IDs

* Use correct key name

* Fix setting PREVIEW variable

* Inject variables from wrangler into shell

* Inject variables from wrangler into shell

* Add git- prefix to preview-alias

* No need to use environments now

* fix: remove test as it's covered by staging deploy

---------

Co-authored-by: Michael H. <michael@iptables.sh>
2026-01-08 16:25:45 +00:00
ProspectorandGitHub 8175120c4c Add fallback for checkbox aria-labels if description is not provided (#5066) 2026-01-08 15:47:32 +00:00
François-Xavier TalbotandGitHub 6221fe5e08 Fix build with updater feature (#5067) 2026-01-08 02:38:02 +00:00
François-Xavier TalbotandGitHub 4a8f882063 Fix underflow in QuickPlay compat check (#5065) 2026-01-08 02:14:05 +00:00
François-Xavier TalbotandGitHub 17db55a0bc Include OS in theseus User-Agent (#5046) 2026-01-08 02:11:18 +00:00
François-Xavier TalbotandGitHub a1d9268d00 Fix daedalus (#5064)
* Support default-user-jvm arguments type

* Use this branch's container

* Allow no rules in Ruled argument variant

* Run main
2026-01-08 01:43:47 +00:00
371 changed files with 23077 additions and 6325 deletions
@@ -1,6 +1,6 @@
name: 👥 Bug with Modrinth Servers
description: For issues with a Modrinth Servers product.
labels: [servers]
name: 👥 Bug with Modrinth Hosting
description: For issues with a Modrinth Hosting product.
labels: [hosting]
type: 'bug'
body:
- type: checkboxes
+1 -1
View File
@@ -19,7 +19,7 @@ body:
options:
- Modrinth App
- Modrinth.com website
- Modrinth Servers
- Modrinth Hosting
- Modrinth API for developers
- type: textarea
attributes:
+143
View File
@@ -0,0 +1,143 @@
name: Deploy frontend
on:
push:
branches:
- main
- prod
paths:
- 'apps/frontend/**/*'
- 'packages/ui/**/*'
- 'packages/utils/**/*'
- 'packages/assets/**/*'
- '**/wrangler.jsonc'
- '**/pnpm-*.yaml'
- '.github/workflows/frontend-deploy.yml'
workflow_dispatch:
workflow_call:
inputs:
environment:
required: true
type: string
description: 'The environment to deploy to (staging-preview or production-preview)'
jobs:
deploy:
runs-on: ubuntu-latest
permissions:
contents: read
deployments: write
pull-requests: write
steps:
- name: Checkout code
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Configure environment
id: meta
run: |
echo "cmd=deploy" >> $GITHUB_OUTPUT
ENV_INPUT="${{ inputs.environment }}"
REF="${{ github.ref }}"
SHA_SHORT="${GITHUB_SHA::8}"
if [ "$ENV_INPUT" = "staging-preview" ]; then
echo "env=staging" >> $GITHUB_OUTPUT
echo "url=https://git-$SHA_SHORT-frontend-staging.modrinth.workers.dev" >> $GITHUB_OUTPUT
echo "cmd=versions upload --preview-alias git-$SHA_SHORT --var PREVIEW:true" >> $GITHUB_OUTPUT
elif [ "$ENV_INPUT" = "production-preview" ]; then
echo "env=production" >> $GITHUB_OUTPUT
echo "url=https://git-$SHA_SHORT-frontend.modrinth.workers.dev" >> $GITHUB_OUTPUT
echo "cmd=versions upload --preview-alias git-$SHA_SHORT --var PREVIEW:true" >> $GITHUB_OUTPUT
elif [ "$REF" = "refs/heads/main" ]; then
echo "env=staging" >> $GITHUB_OUTPUT
echo "url=https://staging.modrinth.com" >> $GITHUB_OUTPUT
else
# Production env (no preview)
echo "env=production" >> $GITHUB_OUTPUT
echo "url=https://modrinth.com" >> $GITHUB_OUTPUT
fi
- name: Setup pnpm
uses: pnpm/action-setup@v4
- name: Setup Node
uses: actions/setup-node@v4
with:
node-version-file: .nvmrc
cache: pnpm
- name: Inject build variables
working-directory: ./apps/frontend
run: |
if [ "${{ steps.meta.outputs.env }}" == "staging" ]; then
echo "Injecting staging variables from wrangler.jsonc..."
jq -r '.env.staging.vars | to_entries[] | "\(.key)=\(.value)"' wrangler.jsonc >> $GITHUB_ENV
else
echo "Injecting production variables from wrangler.jsonc..."
jq -r '.vars | to_entries[] | "\(.key)=\(.value)"' wrangler.jsonc >> $GITHUB_ENV
fi
- name: Install dependencies
working-directory: ./apps/frontend
run: pnpm install
- name: Build frontend
working-directory: ./apps/frontend
run: pnpm build
env:
CF_PAGES_BRANCH: ${{ github.ref_name }}
CF_PAGES_COMMIT_SHA: ${{ github.sha }}
CF_PAGES_URL: ${{ steps.meta.outputs.url }}
BUILD_ENV: ${{ steps.meta.outputs.env }}
PREVIEW: ${{ inputs.environment != '' && 'true' || 'false' }}
SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }}
- name: Create Sentry release and upload sourcemaps
uses: getsentry/action-release@v3
env:
SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }}
SENTRY_ORG: modrinth
SENTRY_PROJECT: knossos-server
with:
environment: ${{ steps.meta.outputs.env }}
sourcemaps: ./apps/frontend/.output/server
url_prefix: '~/'
- name: Deploy Cloudflare Worker
id: wrangler
uses: cloudflare/wrangler-action@v3
with:
apiToken: ${{ secrets.CF_API_TOKEN }}
accountId: ${{ secrets.CF_ACCOUNT_ID }}
environment: ${{ steps.meta.outputs.env != 'production' && steps.meta.outputs.env || '' }}
workingDirectory: ./apps/frontend
packageManager: pnpm
wranglerVersion: '4.54.0'
command: ${{ steps.meta.outputs.cmd }}
- name: Purge cache
if: github.ref == 'refs/heads/prod'
run: |
curl -X POST \
-H "Authorization: Bearer ${{ secrets.CF_API_TOKEN }}" \
-H "Content-Type: application/json" \
--data '{"hosts": ["modrinth.com", "www.modrinth.com", "staging.modrinth.com"]}' \
https://api.cloudflare.com/client/v4/zones/e39df17b9c4ef44cbce2646346ee6d33/purge_cache
- name: Write deployment URL to file
if: ${{ inputs.environment != '' }}
run: |
echo "${{ steps.meta.outputs.url }}" > deployment-url-${{ inputs.environment }}.txt
- name: Upload deployment URL
if: ${{ inputs.environment != '' }}
uses: actions/upload-artifact@v6
with:
name: deployment-url-${{ inputs.environment }}
path: deployment-url-${{ inputs.environment }}.txt
-30
View File
@@ -1,30 +0,0 @@
name: Clear pages cache
on:
push:
branches:
- prod
jobs:
wait:
if: github.repository_owner == 'modrinth'
runs-on: ubuntu-latest
permissions:
contents: read
deployments: write
steps:
- name: Cloudflare Pages deployment
uses: WalshyDev/cf-pages-await@v1
with:
apiToken: ${{ secrets.CF_API_TOKEN }}
accountId: '9ddae624c98677d68d93df6e524a6061'
project: 'frontend'
commitHash: ${{ steps.push-changes.outputs.commit-hash }}
- name: Purge cache
if: github.ref == 'refs/heads/prod'
run: |
curl -X POST \
-H "Authorization: Bearer ${{ secrets.CF_API_TOKEN }}" \
-H "Content-Type: application/json" \
--data '{"hosts": ["modrinth.com", "www.modrinth.com"]}' \
https://api.cloudflare.com/client/v4/zones/e39df17b9c4ef44cbce2646346ee6d33/purge_cache
+72
View File
@@ -0,0 +1,72 @@
name: Deploy frontend preview
on:
pull_request:
paths:
- 'apps/frontend/**/*'
- 'packages/ui/**/*'
- 'packages/utils/**/*'
- 'packages/assets/**/*'
- '**/wrangler.jsonc'
- '**/pnpm-*.yaml'
- '.github/workflows/frontend-preview.yml'
jobs:
deploy:
if: github.repository_owner == 'modrinth' && github.event.pull_request.head.repo.full_name == github.repository
uses: ./.github/workflows/frontend-deploy.yml
secrets: inherit
strategy:
matrix:
environment: [staging-preview, production-preview]
with:
environment: ${{ matrix.environment }}
comment:
if: github.repository_owner == 'modrinth' && github.event.pull_request.head.repo.full_name == github.repository
runs-on: ubuntu-latest
needs: deploy
steps:
- name: Download deployment URLs
uses: actions/download-artifact@v7
with:
pattern: deployment-url-*
merge-multiple: true
- name: Read deployment URLs
id: urls
run: |
STAGING_PREVIEW_URL=$(cat deployment-url-staging-preview.txt)
PRODUCTION_PREVIEW_URL=$(cat deployment-url-production-preview.txt)
echo "Production preview URL: $PRODUCTION_PREVIEW_URL"
echo "Staging preview URL: $STAGING_PREVIEW_URL"
echo "staging-preview-url=$STAGING_PREVIEW_URL" >> $GITHUB_OUTPUT
echo "production-preview-url=$PRODUCTION_PREVIEW_URL" >> $GITHUB_OUTPUT
- name: Find comment
if: github.event_name == 'pull_request'
uses: peter-evans/find-comment@v3
id: fc
with:
issue-number: ${{ github.event.pull_request.number }}
comment-author: 'github-actions[bot]'
body-includes: Frontend previews
- name: Comment deploy URL on PR
if: github.event_name == 'pull_request'
uses: peter-evans/create-or-update-comment@v5
with:
issue-number: ${{ github.event.pull_request.number }}
comment-id: ${{ steps.fc.outputs.comment-id }}
body: |
## Frontend previews
Last deployed commit is [${{ github.sha }}](${{ github.event.pull_request.head.repo.html_url }}/commit/${{ github.sha }})
| Environment | URL |
|-------------|-----|
| staging | ${{ steps.urls.outputs.staging-preview-url }} |
| production | ${{ steps.urls.outputs.production-preview-url }} |
edit-mode: replace
+5
View File
@@ -71,3 +71,8 @@ apps/labrinth/fixtures/demo
*storybook.log
storybook-static
.wrangler/
# frontend robots.txt
apps/frontend/src/public/robots.txt
+5
View File
@@ -12,6 +12,11 @@
<sourceFolder url="file://$MODULE_DIR$/packages/app-lib/src" isTestSource="false" />
<sourceFolder url="file://$MODULE_DIR$/packages/ariadne/src" isTestSource="false" />
<sourceFolder url="file://$MODULE_DIR$/packages/path-util/src" isTestSource="false" />
<sourceFolder url="file://$MODULE_DIR$/packages/modrinth-log/src" isTestSource="false" />
<sourceFolder url="file://$MODULE_DIR$/packages/modrinth-maxmind/examples" isTestSource="false" />
<sourceFolder url="file://$MODULE_DIR$/packages/modrinth-maxmind/src" isTestSource="false" />
<sourceFolder url="file://$MODULE_DIR$/packages/modrinth-util/src" isTestSource="false" />
<sourceFolder url="file://$MODULE_DIR$/packages/muralpay/src" isTestSource="false" />
<excludeFolder url="file://$MODULE_DIR$/target" />
</content>
<orderEntry type="inheritedJdk" />
+6 -6
View File
@@ -11,21 +11,21 @@
"source.fixAll.eslint": "explicit",
"source.organizeImports": "always"
},
"editor.defaultFormatter": "rvest.vs-code-prettier-eslint",
"editor.defaultFormatter": "esbenp.prettier-vscode",
"[vue]": {
"editor.defaultFormatter": "rvest.vs-code-prettier-eslint"
"editor.defaultFormatter": "esbenp.prettier-vscode"
},
"[typescript]": {
"editor.defaultFormatter": "rvest.vs-code-prettier-eslint"
"editor.defaultFormatter": "esbenp.prettier-vscode"
},
"[javascript]": {
"editor.defaultFormatter": "rvest.vs-code-prettier-eslint"
"editor.defaultFormatter": "esbenp.prettier-vscode"
},
"[scss]": {
"editor.defaultFormatter": "rvest.vs-code-prettier-eslint"
"editor.defaultFormatter": "esbenp.prettier-vscode"
},
"[css]": {
"editor.defaultFormatter": "rvest.vs-code-prettier-eslint"
"editor.defaultFormatter": "esbenp.prettier-vscode"
},
"[rust]": {
"editor.defaultFormatter": "rust-lang.rust-analyzer"
Generated
+2 -14
View File
@@ -4523,6 +4523,7 @@ dependencies = [
"const_format",
"dashmap",
"deadpool-redis",
"derive_more 2.0.1",
"dotenv-build",
"dotenvy",
"either",
@@ -4558,7 +4559,6 @@ dependencies = [
"rustls 0.23.32",
"rusty-money",
"sentry",
"sentry-actix",
"serde",
"serde_json",
"serde_with",
@@ -5061,6 +5061,7 @@ version = "0.0.0"
dependencies = [
"dotenvy",
"eyre",
"sentry",
"tracing",
"tracing-ecs",
"tracing-subscriber",
@@ -7846,19 +7847,6 @@ dependencies = [
"ureq",
]
[[package]]
name = "sentry-actix"
version = "0.45.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cc694e6ffc8d5d7fdb2a33923b0358f6ad41c0b428ced034b349b9e2b08260bc"
dependencies = [
"actix-http",
"actix-web",
"bytes",
"futures-util",
"sentry-core",
]
[[package]]
name = "sentry-backtrace"
version = "0.45.0"
+1 -2
View File
@@ -150,7 +150,6 @@ sentry = { version = "0.45.0", default-features = false, features = [
"reqwest",
"rustls",
] }
sentry-actix = "0.45.0"
serde = "1.0.228"
serde_bytes = "0.11.19"
serde_cbor = "0.11.2"
@@ -239,7 +238,7 @@ manual_assert = "warn"
manual_instant_elapsed = "warn"
manual_is_variant_and = "warn"
manual_let_else = "warn"
map_unwrap_or = "warn"
map_unwrap_or = "allow"
match_bool = "warn"
needless_collect = "warn"
negative_feature_names = "warn"
+1 -1
View File
@@ -221,7 +221,6 @@ const messages = defineMessages({
})
async function setupApp() {
stateInitialized.value = true
const {
native_decorations,
theme,
@@ -260,6 +259,7 @@ async function setupApp() {
themeStore.toggleSidebar = toggle_sidebar
themeStore.devMode = developer_mode
themeStore.featureFlags = feature_flags
stateInitialized.value = true
isMaximized.value = await getCurrentWindow().isMaximized()
@@ -159,20 +159,21 @@ const reset_icon = () => {
const createInstance = async () => {
creatingInstance.value = true
const loader =
versions.value[0].loaders[0] !== 'forge' &&
versions.value[0].loaders[0] !== 'fabric' &&
versions.value[0].loaders[0] !== 'quilt'
? 'vanilla'
: versions.value[0].loaders[0]
const gameVersions = versions.value[0].game_versions
const gameVersion = gameVersions[0]
const id = await create(
name.value,
versions.value[0].game_versions[0],
loader,
'latest',
icon.value,
).catch(handleError)
const loaders = versions.value[0].loaders
const loader = loaders.contains('fabric')
? 'fabric'
: loaders.contains('neoforge')
? 'neoforge'
: loaders.contains('forge')
? 'forge'
: loaders.contains('quilt')
? 'quilt'
: 'vanilla'
const id = await create(name.value, gameVersion, loader, 'latest', icon.value).catch(handleError)
await installMod(id, versions.value[0].id).catch(handleError)
+19 -1
View File
@@ -23,6 +23,9 @@
"app.settings.tabs.java-installations": {
"message": "Instalace Javy"
},
"app.settings.tabs.language": {
"message": "Jazyk"
},
"app.settings.tabs.privacy": {
"message": "Soukromí"
},
@@ -74,18 +77,27 @@
"friends.action.add-friend": {
"message": "Přidat přítele"
},
"friends.action.view-friend-requests": {
"message": "{count} {count, plural, one {žádost} few {žádosti} other {žádostí}} o přátelství"
},
"friends.add-friend.submit": {
"message": "Poslat žádost o přátelství"
},
"friends.add-friend.title": {
"message": "Přidávání kamaráda"
},
"friends.add-friend.username.description": {
"message": "Může být jiné než jejich Minecraft jméno!"
},
"friends.add-friend.username.placeholder": {
"message": "Zadejte uživatelské jméno Modrinth..."
},
"friends.add-friend.username.title": {
"message": "Jak se váš kamarád jmenuje na Modrinthu?"
},
"friends.add-friends-to-share": {
"message": "<link>Přidejte si přátelé</link> a podívejte se, co hrají!"
},
"friends.friend.cancel-request": {
"message": "Zrušit žádost"
},
@@ -113,12 +125,18 @@
"friends.heading.pending": {
"message": "Čeká na vyřízení"
},
"friends.no-friends-match": {
"message": "Žádní přátelé neodpovídají '{query}''"
},
"friends.search-friends-placeholder": {
"message": "Hledat přátele..."
},
"friends.section.heading": {
"message": "{title} - {count}"
},
"friends.sign-in-to-add-friends": {
"message": "<link>Přihlašte se ke svému Modrinth účtu</link>, abyste jsi přidali přátele a uviděli co hrají!"
},
"instance.add-server.add-and-play": {
"message": "Přidat a hrát"
},
@@ -486,7 +504,7 @@
"message": "Můžeš rovnou skočit na server pouze v Minecraftu Alpha 1.0.5+"
},
"instance.worlds.no_singleplayer_quick_play": {
"message": "Můžeš rovnou skočit do světa jednoho hráče pouze v Minecraftu 1.20+"
"message": "Můžeš se rovnou připojit do světa jednoho hráče pouze v Minecraftu 1.20+"
},
"instance.worlds.play_instance": {
"message": "Hrát instanci"
@@ -1,6 +1,6 @@
{
"app.auth-servers.unreachable.body": {
"message": "Minecraft autentificeringsservere kan måske være nede lige nu. Tjek din internet forbindelse og prøv igen senere."
"message": "Minecraft authentication servere kan måske være nede lige nu. Tjek din internet forbindelse og prøv igen senere."
},
"app.auth-servers.unreachable.header": {
"message": "Kan ikke nå autentificeringsservere"
@@ -23,6 +23,9 @@
"app.settings.tabs.java-installations": {
"message": "Javainstallationer"
},
"app.settings.tabs.language": {
"message": "Sprog"
},
"app.settings.tabs.privacy": {
"message": "Privatliv"
},
@@ -72,7 +72,7 @@
"message": "Update wird heruntergeladen ({percent}%)"
},
"app.update.reload-to-update": {
"message": "Neu laden um Update zu installieren"
"message": "Neu laden, um das Update zu installieren"
},
"friends.action.add-friend": {
"message": "Freund hinzufügen"
@@ -81,7 +81,7 @@
"message": "{count} {count, plural, one {Freundesanfrage} other {Freundesanfragen}}"
},
"friends.add-friend.submit": {
"message": "Freundesanfrage senden"
"message": "Freundschaftsanfrage senden"
},
"friends.add-friend.title": {
"message": "Einen Freund hinzufügen"
@@ -105,7 +105,7 @@
"message": "Freund entfernen"
},
"friends.friend.request-sent": {
"message": "Freundesanfrage gesendet"
"message": "Freundschaftsanfrage gesendet"
},
"friends.friend.view-profile": {
"message": "Profil anzeigen"
@@ -270,7 +270,7 @@
"message": "Wird vor dem Starten der Instanz ausgeführt."
},
"instance.settings.tabs.hooks.pre-launch.enter": {
"message": "Vor Spielstart auszuführender Befehl eingeben..."
"message": "Vor Spielstart auszuführenden Befehl eingeben..."
},
"instance.settings.tabs.hooks.title": {
"message": "Start-Hooks"
@@ -189,7 +189,7 @@
"message": "Servidor de Minecraft"
},
"instance.server-modal.resource-pack": {
"message": "Resource pack"
"message": "Paquete de recursos"
},
"instance.settings.tabs.general": {
"message": "General"
+10 -4
View File
@@ -1,4 +1,7 @@
{
"app.auth-servers.unreachable.body": {
"message": "Los servidores de autenticación de Minecraft podrían estar inactivos. Comprueba tu conexión a internet e inténtalo más tarde."
},
"app.settings.developer-mode-enabled": {
"message": "Modo desarrollador activado."
},
@@ -17,6 +20,9 @@
"app.settings.tabs.java-installations": {
"message": "Instalaciones de Java"
},
"app.settings.tabs.language": {
"message": "Idioma"
},
"app.settings.tabs.privacy": {
"message": "Privacidad"
},
@@ -30,7 +36,7 @@
"message": "La descarga de la versión v{version} de Modrinth ha finalizado. Actualice ahora o automáticamente al cerrar la aplicación."
},
"app.update-toast.body.metered": {
"message": "¡La versión v{version} de Modrinth ya está disponible! Como estás conectado a una red con límite de datos, no la hemos descargado automáticamente."
"message": "¡La versión v{version} de Modrinth App ya está disponible! Como estás conectado a una red con límite de datos, no la hemos descargado automáticamente."
},
"app.update-toast.changelog": {
"message": "Registro de cambios"
@@ -87,7 +93,7 @@
"message": "¿Cuál es el apodo de tu amigo en Modrinth?"
},
"friends.add-friends-to-share": {
"message": "<link>Añade amigos</link> para ver a qué están jugando."
"message": "<link>Añade amigos</link> para ver a qué están jugando!"
},
"friends.friend.cancel-request": {
"message": "Cancelar petición"
@@ -126,7 +132,7 @@
"message": "{title}: {count}"
},
"friends.sign-in-to-add-friends": {
"message": "<link>Inicia sesión en una cuenta Modrinth</link> para añadir amigos y ver a qué están jugando."
"message": "<link>Inicia sesión en una cuenta Modrinth</link> para añadir amigos y ver a qué están jugando!"
},
"instance.add-server.add-and-play": {
"message": "Añadir y jugar"
@@ -270,7 +276,7 @@
"message": "Wrapper"
},
"instance.settings.tabs.hooks.wrapper.description": {
"message": ".."
"message": "comando Wrapper para lanzar Minecraft."
},
"instance.settings.tabs.hooks.wrapper.enter": {
"message": "Introducir comando para el wrapper..."
@@ -23,6 +23,9 @@
"app.settings.tabs.java-installations": {
"message": "Java asennukset"
},
"app.settings.tabs.language": {
"message": "Kieli"
},
"app.settings.tabs.privacy": {
"message": "Yksityisyys"
},
@@ -48,7 +51,7 @@
"message": "Ladataan..."
},
"app.update-toast.reload": {
"message": "Lataa uudelleen"
"message": "Uudelleen lataa"
},
"app.update-toast.title": {
"message": "Päivitys saatavilla"
@@ -78,13 +81,13 @@
"message": "{count} ystävä{count, plural, one {pyyntö} other {pyyntöä}}"
},
"friends.add-friend.submit": {
"message": "Lähetä ystäväpyyntö"
"message": "Lähetä kaveripyyntö"
},
"friends.add-friend.title": {
"message": "Lisätään ystävää"
"message": "Lisätään kaveria"
},
"friends.add-friend.username.description": {
"message": "Se voi olla eri kuin heidän Minecraft-käyttäjänimensä!"
"message": "Se ei voi olla eri kuin heidän Minecraft-käyttäjänimi!"
},
"friends.add-friend.username.placeholder": {
"message": "Syötä Modrinth-käyttäjätunnus..."
@@ -18,7 +18,7 @@
"message": "Mga pagpipilian sa default na instansiya"
},
"app.settings.tabs.feature-flags": {
"message": "Mga feature flag"
"message": "Mga hudyat ng tampok"
},
"app.settings.tabs.java-installations": {
"message": "Mga instalasyon ng Java"
@@ -30,7 +30,7 @@
"message": "Pribasiya"
},
"app.settings.tabs.resource-management": {
"message": "Pamamahala ng resource"
"message": "Pamamahala ng paglalaan"
},
"app.update-toast.body": {
"message": "Ang Modrinth App v{version} ay handa nang ma-install. Mag-reload upang ma-update ngayon, o awtomatiko sa pagsara ng Modrinth App."
@@ -60,7 +60,7 @@
"message": "Nakumpleto ang pagdownload"
},
"app.update.complete-toast.text": {
"message": "Magpindot rito upang matingnan ang changelog."
"message": "Dito pumindot upang matingnan ang changelog."
},
"app.update.complete-toast.title": {
"message": "Tagumpay na na-install ang bersiyong {version}!"
@@ -195,13 +195,13 @@
"message": "General"
},
"instance.settings.tabs.general.delete": {
"message": "I-delete ang instansiya"
"message": "Tanggalin ang instansiya"
},
"instance.settings.tabs.general.delete.button": {
"message": "I-delete ang instansiya"
"message": "Tanggalin ang instansiya"
},
"instance.settings.tabs.general.delete.description": {
"message": "Permanenteng matatanggal ang instansiya sa iyong device, kasali na ang iyong mga mundo, konpigurasyon, at lahat ng nakainstall na kontento. Mag-ingat, kapag magtanggal ka ng instansiya ay hindi na ito mababawi."
"message": "Habambuhay na matatanggal ang instansiya sa iyong device, kasama ang iyong mga mundo, kompigurasyon, at lahat ng naka-install na kontento. Mag-ingat, kapag nagtanggal ka ng instansiya ay hindi na ito mababawi."
},
"instance.settings.tabs.general.deleting.button": {
"message": "Nagde-delete..."
@@ -216,7 +216,7 @@
"message": "I-duplicate ang instansiya"
},
"instance.settings.tabs.general.duplicate-instance.description": {
"message": "Gagawan ng kopya ng instansiyang ito, kasali na ang mga mundo, konpigurasyon, mods, at iba pa."
"message": "Lilikhaan ng kopya ang instansiyang ito, kasama ang mga mundo, kumpigurasyon, mod, at iba pa."
},
"instance.settings.tabs.general.edit-icon": {
"message": "Baguhin ang ikono"
@@ -234,7 +234,7 @@
"message": "Mga grupo ng librerya"
},
"instance.settings.tabs.general.library-groups.create": {
"message": "Gumawa ng bagong grupo"
"message": "Lumikha ng bagong grupo"
},
"instance.settings.tabs.general.library-groups.description": {
"message": "Binibigyan ng mga grupo ng librerya na iyong maayos ang iyong mga instansiya sa iba't-ibang pangkat in iyong librerya."
@@ -33,13 +33,13 @@
"message": "Gestion des ressources"
},
"app.update-toast.body": {
"message": "L'application Modrinth v{version} est prête à être installée ! Relancez l'application pour faire la mise à jour maintenant, ou automatiquement à la fermeture de l'application Modrinth."
"message": "Modrinth App v{version} est prête à être installée ! Relancez l'application pour faire la mise à jour maintenant, ou automatiquement à la fermeture de Modrinth App."
},
"app.update-toast.body.download-complete": {
"message": "Le téléchargement de l'application Modrinth v{version} est terminé ! Relancez l'application pour faire la mise à jour maintenant, ou automatiquement à la fermeture de l'application Modrinth."
"message": "Le téléchargement de Modrinth App v{version} est terminé ! Relancez l'application pour mettre à jour maintenant, ou automatiquement à la fermeture de Modrinth App."
},
"app.update-toast.body.metered": {
"message": "L'application Modrinth v{version} est disponible dès maintenant ! Lorsque vous êtes sur un réseau limité ou en données mobiles, nous ne téléchargerons pas les mises à jour automatiquement."
"message": "Modrinth App v{version} est disponible dès maintenant ! Lorsque vous êtes sur un réseau limité ou en données mobiles, nous ne téléchargerons pas les mises à jour automatiquement."
},
"app.update-toast.changelog": {
"message": "Journal des modifications"
@@ -78,7 +78,7 @@
"message": "Ajouter un ami"
},
"friends.action.view-friend-requests": {
"message": "{count} {count, plural,one {demande}other {demandes}} d'ami"
"message": "{count} {count, plural, one {demande} other {demandes}} d'ami"
},
"friends.add-friend.submit": {
"message": "Envoyer une demande d'ami"
@@ -126,7 +126,7 @@
"message": "En attente"
},
"friends.no-friends-match": {
"message": "Aucuns amis ne correspondent à \"{query}\""
"message": "Aucuns amis ne correspondent à « {query} »"
},
"friends.search-friends-placeholder": {
"message": "Chercher des amis..."
@@ -249,7 +249,7 @@
"message": "Crochets de lancement"
},
"instance.settings.tabs.hooks.custom-hooks": {
"message": "Crochets de lancement custom"
"message": "Crochets de lancement personnalisés"
},
"instance.settings.tabs.hooks.description": {
"message": "Les crochets permettent aux usagers avancés d'exécuter certaines commandes systèmes avant et après le lancement du jeu."
@@ -258,7 +258,7 @@
"message": "Post-fermeture"
},
"instance.settings.tabs.hooks.post-exit.description": {
"message": "Exécuté après fermeture."
"message": "Exécuté après la fermeture du jeu."
},
"instance.settings.tabs.hooks.post-exit.enter": {
"message": "Entrer commande de post-fermeture..."
@@ -369,7 +369,7 @@
"message": "Réparation"
},
"instance.settings.tabs.installation.repair.confirm.description": {
"message": "La réparation réinstalle les dépendances de Minecraft et vérifie la corruption. Cela peut résoudre les problèmes de lancement du jeu en raison d'erreurs liées au lanceur, mais ne résoudra pas les problèmes ou plantages liés aux mods installés."
"message": "La réparation réinstalle les dépendances de Minecraft et vérifie la corruption. Cela peut résoudre les problèmes de lancement du jeu en raison d'erreurs liées au launcher, mais ne résoudra pas les problèmes ou plantages liés aux mods installés."
},
"instance.settings.tabs.installation.repair.confirm.title": {
"message": "Réparer l'instance ?"
+16 -13
View File
@@ -1,9 +1,9 @@
{
"app.auth-servers.unreachable.body": {
"message": "Peladen autentikasi Minecraft mungkin sedang tidak tersedia saat ini. Periksa koneksi internet Anda dan coba lagi nanti."
"message": "Server autentikasi Minecraft mungkin sedang tidak tersedia saat ini. Periksa koneksi internet Anda dan coba lagi nanti."
},
"app.auth-servers.unreachable.header": {
"message": "Tidak dapat terhubung ke peladen autentikasi"
"message": "Tidak dapat terhubung ke server autentikasi"
},
"app.settings.developer-mode-enabled": {
"message": "Mode pengembang dihidupkan."
@@ -23,6 +23,9 @@
"app.settings.tabs.java-installations": {
"message": "Pemasangan Java"
},
"app.settings.tabs.language": {
"message": "Bahasa"
},
"app.settings.tabs.privacy": {
"message": "Privasi"
},
@@ -90,7 +93,7 @@
"message": "Masukkan nama pengguna Modrinth..."
},
"friends.add-friend.username.title": {
"message": "Apakah nama pengguna Modrinth teman Anda?"
"message": "Apa nama pengguna Modrinth teman Anda?"
},
"friends.add-friends-to-share": {
"message": "<link>Tambah teman</link> untuk melihat apa yang mereka mainkan!"
@@ -138,7 +141,7 @@
"message": "Tambah dan mainkan"
},
"instance.add-server.add-server": {
"message": "Tambah peladen"
"message": "Tambah server"
},
"instance.add-server.resource-pack.disabled": {
"message": "Dimatikan"
@@ -150,10 +153,10 @@
"message": "Konfirmasi"
},
"instance.add-server.title": {
"message": "Tambah peladen"
"message": "Tambah server"
},
"instance.edit-server.title": {
"message": "Sunting peladen"
"message": "Sunting server"
},
"instance.edit-world.hide-from-home": {
"message": "Sembunyikan dari Beranda"
@@ -183,7 +186,7 @@
"message": "Nama"
},
"instance.server-modal.placeholder-name": {
"message": "Peladen Minecraft"
"message": "Server Minecraft"
},
"instance.server-modal.resource-pack": {
"message": "Paket sumber"
@@ -471,10 +474,10 @@
"message": "Pengaturan"
},
"instance.worlds.a_minecraft_server": {
"message": "Peladen Minecraft"
"message": "Server Minecraft"
},
"instance.worlds.cant_connect": {
"message": "Tidak dapat menghubungkan ke peladen"
"message": "Tidak dapat menghubungkan ke server"
},
"instance.worlds.copy_address": {
"message": "Salin alamat"
@@ -492,13 +495,13 @@
"message": "Mode Menantang"
},
"instance.worlds.incompatible_server": {
"message": "Peladen tidak cocok"
"message": "Server tidak cocok"
},
"instance.worlds.no_contact": {
"message": "Peladen tidak dapat dihubungi"
"message": "Server tidak dapat dihubungi"
},
"instance.worlds.no_server_quick_play": {
"message": "Anda hanya dapat memasuki peladen secara langsung pada Minecraft versi Alpha 1.0.5 ke atas"
"message": "Anda hanya dapat memasuki server secara langsung pada Minecraft versi Alpha 1.0.5 ke atas"
},
"instance.worlds.no_singleplayer_quick_play": {
"message": "Anda hanya dapat memasuki dunia bermain sendiri secara langsung pada Minecraft versi Alpha 1.20 ke atas"
@@ -507,7 +510,7 @@
"message": "Mainkan wujud"
},
"instance.worlds.type.server": {
"message": "Peladen"
"message": "Server"
},
"instance.worlds.type.singleplayer": {
"message": "Bermain sendiri"
@@ -42,7 +42,7 @@
"message": "Modrinth App v{version} è ora disponibile! Poiché sei su una rete a consumo, non l''abbiamo scaricata automaticamente."
},
"app.update-toast.changelog": {
"message": "Changelog"
"message": "Novità"
},
"app.update-toast.download": {
"message": "Scarica ({size})"
@@ -60,7 +60,7 @@
"message": "Download completato"
},
"app.update.complete-toast.text": {
"message": "Clicca qui per leggere il changelog."
"message": "Clicca qui per leggere le ultime novità in inglese."
},
"app.update.complete-toast.title": {
"message": "La versione {version} è stata installata con successo!"
+26 -26
View File
@@ -1,15 +1,15 @@
{
"app.auth-servers.unreachable.body": {
"message": "Minecraft 인증 서버가 현재 다운되었을 수 있습니다. 인터넷 연결을 확인한 후 나중에 다시 시도하세요."
"message": "Minecraft 인증 서버가 일시적으로 중단되었을 수 있습니다. 인터넷 연결을 확인한 후 나중에 다시 시도하세요."
},
"app.auth-servers.unreachable.header": {
"message": "인증 서버에 연결할 수 없습니다"
},
"app.settings.developer-mode-enabled": {
"message": "개발자 모드가 활성화되어 있습니다."
"message": "개발자 모드가 활성화되습니다."
},
"app.settings.downloading": {
"message": "v{version} 다운로드 중"
"message": "버전 다운로드 중: v{version}"
},
"app.settings.tabs.appearance": {
"message": "모양"
@@ -42,52 +42,52 @@
"message": "Modrinth App v{version}이 지금 이용 가능합니다! 종량제 네트워크에 있기 때문에, 자동으로 다운로드하지 않았습니다."
},
"app.update-toast.changelog": {
"message": "변경사항"
"message": "변경 내역"
},
"app.update-toast.download": {
"message": "다운로드 ({size})"
"message": "({size}) 다운로드"
},
"app.update-toast.downloading": {
"message": "다운로드 중..."
},
"app.update-toast.reload": {
"message": "리로드"
"message": "새로고침"
},
"app.update-toast.title": {
"message": "업데이트 가능"
},
"app.update-toast.title.download-complete": {
"message": "다운로드 "
"message": "다운로드 완료"
},
"app.update.complete-toast.text": {
"message": "변경내역을 보려면 클릭하세요."
"message": "변경 내역을 보려면 클릭하세요."
},
"app.update.complete-toast.title": {
"message": "버전 {version}이 성공적으로 설치되었습니다!"
"message": "{version} 버전이 성공적으로 설치되었습니다!"
},
"app.update.download-update": {
"message": "업데이트 다운로드"
},
"app.update.downloading-update": {
"message": "업데이트 다운로드중 ({percent}%)"
"message": "업데이트 다운로드 중 ({percent}%)"
},
"app.update.reload-to-update": {
"message": "새로고침하여 업데이트"
"message": "새로고침하여 업데이트 설치"
},
"friends.action.add-friend": {
"message": "친구 추가"
},
"friends.action.view-friend-requests": {
"message": "{count}건의 친구 요청"
"message": "{count} 친구 요청 {count, plural, one {건의} other {건의}}"
},
"friends.add-friend.submit": {
"message": "친구 요청 보내기"
},
"friends.add-friend.title": {
"message": "친구 추가"
"message": "친구 추가"
},
"friends.add-friend.username.description": {
"message": "마인크래프트 사용자명과 다를 수 있습니다!"
"message": "Minecraft 사용자명과 다를 수 있습니다!"
},
"friends.add-friend.username.placeholder": {
"message": "Modrinth 사용자명 입력..."
@@ -96,7 +96,7 @@
"message": "친구의 Modrinth 사용자명이 무엇인가요?"
},
"friends.add-friends-to-share": {
"message": "친구들이 무슨 게임을 하는지 보려면 <link>친구 추가</link>하세요!"
"message": "친구들이 무을 하는지 보려면 <link>친구 추가</link>하세요!"
},
"friends.friend.cancel-request": {
"message": "요청 취소"
@@ -105,7 +105,7 @@
"message": "친구 삭제"
},
"friends.friend.request-sent": {
"message": "친구 요청 보내기"
"message": "이미 친구 요청냈습니다"
},
"friends.friend.view-profile": {
"message": "프로필 보기"
@@ -114,7 +114,7 @@
"message": "친구"
},
"friends.heading.active": {
"message": "활동"
"message": "활동"
},
"friends.heading.offline": {
"message": "오프라인"
@@ -129,7 +129,7 @@
"message": "\"{query}\"와 일치하는 친구가 없습니다"
},
"friends.search-friends-placeholder": {
"message": "친구 검색..."
"message": "친구 찾는 중..."
},
"friends.section.heading": {
"message": "{title} - {count}"
@@ -138,7 +138,7 @@
"message": "친구를 추가하고 무엇을 하는지 보려면 <link>Modrinth 계정에 로그인</link>하세요!"
},
"instance.add-server.add-and-play": {
"message": "추가하고 플레이"
"message": "추가 플레이"
},
"instance.add-server.add-server": {
"message": "서버 추가"
@@ -201,22 +201,22 @@
"message": "인스턴스 삭제"
},
"instance.settings.tabs.general.delete.description": {
"message": "기기에서 인스턴스를 완전히 삭제합니다. 월드, 설정, 설치된 모든 콘텐츠가 함께 제됩니다. 주의하세요, 한 번 삭제하면 인스턴스를 복구할 방법은 없습니다."
"message": "기기에서 인스턴스를 완전히 삭제합니다. 세계, 설정, 설치된 모든 콘텐츠가 함께 제됩니다. 주의하세요, 한 번 삭제하면 인스턴스를 복구할 없습니다."
},
"instance.settings.tabs.general.deleting.button": {
"message": "삭제중..."
"message": "삭제 중..."
},
"instance.settings.tabs.general.duplicate-button": {
"message": "복제"
},
"instance.settings.tabs.general.duplicate-button.tooltip.installing": {
"message": "설치중에는 복제할 수 없습니다."
"message": "설치 중에는 복제할 수 없습니다."
},
"instance.settings.tabs.general.duplicate-instance": {
"message": "인스턴스 복제"
},
"instance.settings.tabs.general.duplicate-instance.description": {
"message": "인스턴스의 복사본을 생성합니다. 월드, 설정, 모드, 기타 등등도 함께 복사됩니다."
"message": "인스턴스의 복사본을 생성합니다. 세계, 설정, 모드, 기타 등등도 함께 복사됩니다."
},
"instance.settings.tabs.general.edit-icon": {
"message": "아이콘 수정"
@@ -279,7 +279,7 @@
"message": "래퍼"
},
"instance.settings.tabs.hooks.wrapper.description": {
"message": "Minecraft 실행을 위한 래퍼 명령어 입니다."
"message": "Minecraft 실행을 위한 래퍼 명령어 입니다."
},
"instance.settings.tabs.hooks.wrapper.enter": {
"message": "래퍼 명령어 입력..."
@@ -351,7 +351,7 @@
"message": "모드팩 재설치 중"
},
"instance.settings.tabs.installation.reinstall.confirm.description": {
"message": "재설치하면 설치 또는 수정된 모든 콘텐츠가 모드팩에서 제공하는 콘텐츠로 초기화되며, 기존 팩에 추가한 모드나 콘텐츠는 모두 제거됩니다. 인스턴스에 변경 사항이 있는 경우 예상치 못한 동작은 해결할 수 있지만, 현재 월드가 추가로 설치된 콘텐츠에 의존하고 있다면 기존 월드가 손상될 수 있습니다."
"message": "재설치하면 설치 또는 수정된 모든 콘텐츠가 모드팩에서 제공하는 콘텐츠로 초기화되며, 기존 팩에 추가한 모드나 콘텐츠는 모두 제거됩니다. 인스턴스에 변경 사항이 있는 경우 예상치 못한 동작은 해결할 수 있지만, 현재 세계가 추가로 설치된 콘텐츠에 의존하고 있다면 기존 세계가 손상될 수 있습니다."
},
"instance.settings.tabs.installation.reinstall.confirm.title": {
"message": "정말로 이 인스턴스를 다시 설치하시겠습니까?"
@@ -519,7 +519,7 @@
"message": "인스턴스 보기"
},
"instance.worlds.world_in_use": {
"message": "사용중인 월드"
"message": "사용 중인 세계"
},
"search.filter.locked.instance": {
"message": "인스턴스에서 관리"
@@ -18,7 +18,7 @@
"message": "Standaardopties voor instantie"
},
"app.settings.tabs.feature-flags": {
"message": "Functie vlaggen"
"message": "Feature flags"
},
"app.settings.tabs.java-installations": {
"message": "Java installaties"
@@ -93,10 +93,10 @@
"message": "Voer Modrinth gebruikersnaam in..."
},
"friends.add-friend.username.title": {
"message": "Wat is de Modrinth gebruikersnaam van uw vriend?"
"message": "Wat is de Modrinth gebruikersnaam van jouw vriend?"
},
"friends.add-friends-to-share": {
"message": "<link>Voeg vrienden</link> om te zien wat hun spelen!"
"message": "<link>Voeg vrienden toe</link> om te zien wat zij spelen!"
},
"friends.friend.cancel-request": {
"message": "Annuleer verzoek"
@@ -135,7 +135,7 @@
"message": "{title} - {count}"
},
"friends.sign-in-to-add-friends": {
"message": "<link>Log in op een Modrinth account</link> om vrienden toe te voegen en te kijken wat zij spelen!"
"message": "<link>Log in op een Modrinth account</link> om vrienden toe te voegen en zien wat zij spelen!"
},
"instance.add-server.add-and-play": {
"message": "Toevoegen en spelen"
@@ -525,7 +525,7 @@
"message": "Podane przez instancję"
},
"search.filter.locked.instance-game-version.title": {
"message": "Wersja gry jest dostarczana przez instancję"
"message": "Wersja gry jest dostarczona przez instancję"
},
"search.filter.locked.instance-loader.title": {
"message": "Loader jest dostarczony przez instancję"
@@ -252,7 +252,7 @@
"message": "Ganchos de inicio personalizados"
},
"instance.settings.tabs.hooks.description": {
"message": "Ganchos permitem a utilizadores avançados executarem certos comandos de sistema antes e depois de abrir o jogo."
"message": "Ganchos permitem a utilizadores avançados executarem certos comandos de sistema antes e após abrir o jogo."
},
"instance.settings.tabs.hooks.post-exit": {
"message": "Pós-saída"
@@ -1,4 +1,10 @@
{
"app.auth-servers.unreachable.body": {
"message": "Serverele de autentificare Minecraft pot fi indisponibile în acest moment. Verificați conexiunea la internet și încercați din nou mai târziu."
},
"app.auth-servers.unreachable.header": {
"message": "Nu se pot accesa serverele de autentificare"
},
"app.settings.developer-mode-enabled": {
"message": "Modul dezvoltator activat."
},
@@ -17,6 +23,9 @@
"app.settings.tabs.java-installations": {
"message": "Instalări Java"
},
"app.settings.tabs.language": {
"message": "Limbă"
},
"app.settings.tabs.privacy": {
"message": "Confidențialitate"
},
@@ -65,6 +74,63 @@
"app.update.reload-to-update": {
"message": "Reîncarcă pentru a instala actualizarea"
},
"friends.action.add-friend": {
"message": "Adaugă un prieten"
},
"friends.add-friend.submit": {
"message": "Trimite cerere de prietenie"
},
"friends.add-friend.title": {
"message": "Adăugarea unui prieten"
},
"friends.add-friend.username.description": {
"message": "Poate fi diferit de numele lor de utilizator Minecraft!"
},
"friends.add-friend.username.placeholder": {
"message": "Introduceți numele de utilizator Modrinth..."
},
"friends.add-friend.username.title": {
"message": "Care este numele de utilizator Modrinth al prietenului tău?"
},
"friends.add-friends-to-share": {
"message": "<link>Adaugă prieteni</link> pentru a vedea ce joacă!"
},
"friends.friend.cancel-request": {
"message": "Anulează cererea"
},
"friends.friend.remove-friend": {
"message": "Eliminare prieten"
},
"friends.friend.request-sent": {
"message": "Cerere de prietenie trimisă"
},
"friends.friend.view-profile": {
"message": "Vizualizați profilul"
},
"friends.heading": {
"message": "Prieteni"
},
"friends.heading.active": {
"message": "Activ"
},
"friends.heading.offline": {
"message": "Inactiv"
},
"friends.heading.online": {
"message": "Online"
},
"friends.heading.pending": {
"message": "În așteptare"
},
"friends.no-friends-match": {
"message": "Nu există prieteni care să corespundă cu \"{query}”"
},
"friends.search-friends-placeholder": {
"message": "Caută prieteni..."
},
"friends.sign-in-to-add-friends": {
"message": "<link>Conectează-te la un cont Modrinth</link> pentru a adăuga prieteni și a vedea ce joacă!"
},
"instance.add-server.add-and-play": {
"message": "Adaugă și joacă"
},
+35 -35
View File
@@ -9,7 +9,7 @@
"message": "Режим разработчика включён."
},
"app.settings.downloading": {
"message": "Скачивание v{version}"
"message": "Скачивание версии {version}"
},
"app.settings.tabs.appearance": {
"message": "Внешний вид"
@@ -33,13 +33,13 @@
"message": "Управление ресурсами"
},
"app.update-toast.body": {
"message": "Версия Modrinth App {version} готова к установке! Перезапустите приложение, чтобы обновить его, или оно обновится автоматически при закрытии."
"message": "Версия Modrinth App {version} готова к установке! Перезапустите приложение, чтобы обновить его, или оно обновится автоматически после закрытия."
},
"app.update-toast.body.download-complete": {
"message": "Скачивание Modrinth App v{version} завершено. Перезапустите приложение, чтобы обновить его, или оно обновится автоматически при закрытии."
"message": "Скачивание версии Modrinth App {version} завершено. Перезапустите приложение, чтобы обновить его, или оно обновится автоматически после закрытия."
},
"app.update-toast.body.metered": {
"message": "Версия Modrinth App {version} доступна для скачивания! Используется сеть с лимитным тарифным планом, поэтому скачивание не началось автоматически."
"message": "Версия Modrinth App {version} доступна для скачивания! Используется сеть с лимитным тарифным планом, поэтому скачивание не началось автоматически."
},
"app.update-toast.changelog": {
"message": "Список изменений"
@@ -87,13 +87,13 @@
"message": "Добавление в друзья"
},
"friends.add-friend.username.description": {
"message": "Оно может быть не таким, как в Minecraft!"
"message": "Оно может быть не таким, как в Minecraft!"
},
"friends.add-friend.username.placeholder": {
"message": "Введите имя на Modrinth..."
},
"friends.add-friend.username.title": {
"message": "Какое имя у твоего друга на Modrinth?"
"message": "Какое имя у друга на Modrinth?"
},
"friends.add-friends-to-share": {
"message": "<link>Добавьте друзей</link>, чтобы видеть их статус игры!"
@@ -123,10 +123,10 @@
"message": "В сети"
},
"friends.heading.pending": {
"message": "В ожидании"
"message": "В ожидании"
},
"friends.no-friends-match": {
"message": "Нет друзей по запросу «{query}»"
"message": "Нет друзей по запросу «{query}»"
},
"friends.search-friends-placeholder": {
"message": "Поиск по друзьям..."
@@ -135,7 +135,7 @@
"message": "{title} — {count}"
},
"friends.sign-in-to-add-friends": {
"message": "<link>Войдите в аккаунт Modrinth</link>, чтобы добавлять друзей и знать, во что они играют!"
"message": "<link>Войдите в Modrinth</link>, чтобы добавлять друзей и знать, во что они играют!"
},
"instance.add-server.add-and-play": {
"message": "Добавить и играть"
@@ -210,25 +210,25 @@
"message": "Создать копию"
},
"instance.settings.tabs.general.duplicate-button.tooltip.installing": {
"message": "Копирование невозможно в процессе установки."
"message": "Создание копии невозможно во время установки."
},
"instance.settings.tabs.general.duplicate-instance": {
"message": "Создание копии сборки"
},
"instance.settings.tabs.general.duplicate-instance.description": {
"message": "Создаёт копию сборки, включая миры, настройки, моды и т. д."
"message": "Создаёт копию сборки, включая миры, настройки, моды и т.д."
},
"instance.settings.tabs.general.edit-icon": {
"message": "Изменить иконку"
},
"instance.settings.tabs.general.edit-icon.remove": {
"message": "Удалить значок"
"message": "Удалить иконку"
},
"instance.settings.tabs.general.edit-icon.replace": {
"message": "Сменить иконку"
"message": "Заменить иконку"
},
"instance.settings.tabs.general.edit-icon.select": {
"message": "Выбрать значок"
"message": "Выбрать иконку"
},
"instance.settings.tabs.general.library-groups": {
"message": "Группировка"
@@ -237,7 +237,7 @@
"message": "Создать новую группу"
},
"instance.settings.tabs.general.library-groups.description": {
"message": "Разделение по группам позволяет организовать сборки по разным разделам в библиотеке."
"message": "Разделение по группам позволяет организовать сборки по разным разделам в библиотеке."
},
"instance.settings.tabs.general.library-groups.enter-name": {
"message": "Введите название группы"
@@ -279,7 +279,7 @@
"message": "Обёртка"
},
"instance.settings.tabs.hooks.wrapper.description": {
"message": "Команда-обёртка для запуска Minecraft."
"message": "Команда-обёртка для запуска Minecraft."
},
"instance.settings.tabs.hooks.wrapper.enter": {
"message": "Введите команду-обёртку..."
@@ -288,7 +288,7 @@
"message": "Установка"
},
"instance.settings.tabs.installation.change-version.already-installed.modded": {
"message": "{platform} {version} для Minecraft {game_version} уже установлен"
"message": "{platform} {version} для Minecraft {game_version} уже установлен"
},
"instance.settings.tabs.installation.change-version.already-installed.vanilla": {
"message": "Ванильный Minecraft {game_version} уже установлен"
@@ -315,7 +315,7 @@
"message": "Отладочная информация:"
},
"instance.settings.tabs.installation.fetching-modpack-details": {
"message": "Получение данных о сборке"
"message": "Получение сведений о сборке"
},
"instance.settings.tabs.installation.game-version": {
"message": "Версия игры"
@@ -333,13 +333,13 @@
"message": "Minecraft {version}"
},
"instance.settings.tabs.installation.no-connection": {
"message": "Не удалось получить данные о сборке. Проверьте соединение с интернетом."
"message": "Не удалось получить сведения о сборке. Проверьте подключение к интернету."
},
"instance.settings.tabs.installation.no-loader-versions": {
"message": "{loader} недоступен для Minecraft {version}. Выберите другой загрузчик."
"message": "{loader} недоступен для Minecraft {version}. Выберите другой загрузчик."
},
"instance.settings.tabs.installation.no-modpack-found": {
"message": "Не удалось найти сборку на Modrinth, с которой связано установленное содержимое."
"message": "Не удалось найти сборку на Modrinth, с которой связано установленное содержимое."
},
"instance.settings.tabs.installation.platform": {
"message": "Платформа"
@@ -351,7 +351,7 @@
"message": "Переустановка сборки"
},
"instance.settings.tabs.installation.reinstall.confirm.description": {
"message": "При переустановке всё установленное содержимое будет сброшено к исходному состоянию сборки, а внесённые в её состав изменения удалены. Это может исправить проблемы с игрой, вызванные изменениями, однако есть риск повреждения миров, зависящих от них."
"message": "Установленное содержимое будет сброшено к исходному состоянию сборки, а внесённые в её состав изменения будут удалены. Это может исправить возникшие после изменений проблемы с игрой, но зависящие от добавленного содержимого миры могут перестать корректно работать."
},
"instance.settings.tabs.installation.reinstall.confirm.title": {
"message": "Вы действительно хотите переустановить сборку?"
@@ -369,10 +369,10 @@
"message": "Исправление"
},
"instance.settings.tabs.installation.repair.confirm.description": {
"message": "Будут переустановлены зависимости Minecraft, а также определены и восстановлены повреждённые файлы. Это может помочь с проблемами запуска игры на стороне лаунчера, но не исправит проблемы и вылеты из-за ошибок в имеющихся модах."
"message": "Будут переустановлены зависимости Minecraft, а также определены и восстановлены повреждённые файлы. Это может помочь с проблемами запуска игры на стороне лаунчера, но не исправит проблемы и вылеты из-за ошибок в имеющихся модах."
},
"instance.settings.tabs.installation.repair.confirm.title": {
"message": "Восстановить сборку?"
"message": "Исправить сборку?"
},
"instance.settings.tabs.installation.repair.in-progress": {
"message": "Выполняется исправление"
@@ -381,10 +381,10 @@
"message": "Сбросить выбор"
},
"instance.settings.tabs.installation.show-all-versions": {
"message": "Показать все версии"
"message": "Показывать все версии"
},
"instance.settings.tabs.installation.tooltip.action.change-version": {
"message": "изменить версию"
"message": "сменить версию"
},
"instance.settings.tabs.installation.tooltip.action.install": {
"message": "установить"
@@ -393,16 +393,16 @@
"message": "переустановить"
},
"instance.settings.tabs.installation.tooltip.action.repair": {
"message": "восстановить"
"message": "исправить"
},
"instance.settings.tabs.installation.tooltip.cannot-while-installing": {
"message": "Невозможно {action} во время установки"
"message": "Невозможно {action} во время установки"
},
"instance.settings.tabs.installation.tooltip.cannot-while-offline": {
"message": "Невозможно {action} без подключения к сети"
"message": "Невозможно {action} без подключения к сети"
},
"instance.settings.tabs.installation.tooltip.cannot-while-repairing": {
"message": "Невозможно {action} во время восстановления"
"message": "Невозможно {action} во время исправления"
},
"instance.settings.tabs.installation.unknown-version": {
"message": "(неизвестная версия)"
@@ -414,13 +414,13 @@
"message": "Если продолжить, восстановить связь будет невозможно без создания новой сборки. Сборка утратит возможность получать обновления и станет локальной."
},
"instance.settings.tabs.installation.unlink.confirm.title": {
"message": "Вы уверены, что хотите отвязать сборку?"
"message": "Вы действительно хотите отвязать сборку?"
},
"instance.settings.tabs.installation.unlink.description": {
"message": "Установленное содержимое связано со сборкой на Modrinth, что блокирует возможность менять версии игры, модов или загрузчик. Нажатие на кнопку ниже необратимо разрушит эту связь."
},
"instance.settings.tabs.installation.unlink.title": {
"message": "Отвязка сборки"
"message": "Отвязка из мод-пака"
},
"instance.settings.tabs.java": {
"message": "Java и память"
@@ -486,7 +486,7 @@
"message": "Не показывать на главной"
},
"instance.worlds.filter.available": {
"message": "Доступны"
"message": "Доступно"
},
"instance.worlds.game_already_open": {
"message": "Сборка уже запущена"
@@ -501,10 +501,10 @@
"message": "Не удалось связаться с сервером"
},
"instance.worlds.no_server_quick_play": {
"message": "Подключиться напрямую можно только к серверам Minecraft Alpha 1.0.5 и выше"
"message": "Подключаться к серверам напрямую можно только в Minecraft Alpha 1.0.5 и выше"
},
"instance.worlds.no_singleplayer_quick_play": {
"message": "Открывать миры напрямую можно только в Minecraft 1.20 и выше"
"message": "Открывать миры напрямую можно только в Minecraft 1.20 и выше"
},
"instance.worlds.play_instance": {
"message": "Запустить сборку"
@@ -1,4 +1,7 @@
{
"app.auth-servers.unreachable.body": {
"message": "Minecrafts autentiseringsservrar kan vara nere just nu. Kontrollera din internetanslutning och försök igen senare."
},
"app.auth-servers.unreachable.header": {
"message": "Kan ej nå autentiseringsservrarna"
},
@@ -270,7 +273,7 @@
"message": "Ange kommando före start..."
},
"instance.settings.tabs.hooks.title": {
"message": "Startkrokar för spelet"
"message": "Hooks för spelstart"
},
"instance.settings.tabs.hooks.wrapper": {
"message": "Omslag"
@@ -17,6 +17,9 @@
"app.settings.tabs.java-installations": {
"message": "การจัดการ Java ที่ติดตั้ง"
},
"app.settings.tabs.language": {
"message": "ภาษา"
},
"app.settings.tabs.privacy": {
"message": "ความเป็นส่วนตัว"
},
@@ -65,9 +68,48 @@
"app.update.reload-to-update": {
"message": "รีโหลดเพื่อติดตั้งอัพเดต"
},
"friends.action.add-friend": {
"message": "เพิ่มเพื่อน"
},
"friends.add-friend.submit": {
"message": "ส่งคำขอเป็นเพื่อน"
},
"friends.add-friend.username.description": {
"message": "อาจจะแตกต่างกับชื่อใน Minecraft"
},
"friends.add-friend.username.placeholder": {
"message": "ใส่ชื่อบน Modrinth"
},
"friends.add-friend.username.title": {
"message": "เพื่อนของคุณมีชื่อบน Modrinth ว่าอะไร"
},
"friends.friend.cancel-request": {
"message": "ยกเลิกคำขอ"
},
"friends.friend.remove-friend": {
"message": "ลบเพื่อน"
},
"friends.friend.request-sent": {
"message": "ส่งคำขอเป็นเพื่อนแล้ว"
},
"friends.friend.view-profile": {
"message": "ดูโปรไฟล์"
},
"friends.heading": {
"message": "เพื่อน"
},
"friends.heading.offline": {
"message": "ออฟไลน์"
},
"friends.no-friends-match": {
"message": "ไม่มีเพื่อนชื่อว่า \"{query}\""
},
"friends.search-friends-placeholder": {
"message": "ค้นหาเพื่อน"
},
"friends.section.heading": {
"message": "{title} - {count}"
},
"instance.add-server.add-and-play": {
"message": "เพิ่มและเล่นทันที"
},
+11 -11
View File
@@ -33,7 +33,7 @@
"message": "Kaynak yönetimi"
},
"app.update-toast.body": {
"message": "Modrinth App v{version} kurulmaya hazır! Şimdi güncellemek için uygulamayı yeniden yükle ya da sen Modrinth App'i kapatınca otomatik olarak güncellensin."
"message": "Modrinth App v{version} kuruluma hazır! Şimdi güncellemek için uygulamayı yeniden yükle ya da sen Modrinth App'i kapatınca otomatik olarak güncellensin."
},
"app.update-toast.body.download-complete": {
"message": "Modrinth App v{version} indirildi. Şimdi güncellemek için uygulamayı yeniden yükle ya da Modrinth App'i kapatınca otomatik olarak güncellensin."
@@ -60,7 +60,7 @@
"message": "Yükleme tamamlandı"
},
"app.update.complete-toast.text": {
"message": "Değişiklikleri görmek için buraya tıklayın."
"message": "Değişiklik günlüğünü görüntülemek için buraya tıklayın."
},
"app.update.complete-toast.title": {
"message": "{version} sürümü başarıyla kuruldu!"
@@ -69,7 +69,7 @@
"message": "Güncellemeyi indir"
},
"app.update.downloading-update": {
"message": "Güncelleme indiriliyor ({percent}%)"
"message": "Güncelleme indiriliyor (%{percent})"
},
"app.update.reload-to-update": {
"message": "Güncellemeyi kurmak için yeniden yükleyin"
@@ -135,7 +135,7 @@
"message": "{title} - {count}"
},
"friends.sign-in-to-add-friends": {
"message": "<link>Modrinth hesabına giriş yaparak</link> arkadaş ekle ve ne oynadıklarını gör!"
"message": "Arkadaş eklemek ve ne oynadıklarını görmek için bir <link>Modrinth hesabına giriş yap</link>!"
},
"instance.add-server.add-and-play": {
"message": "Ekle ve oyna"
@@ -144,10 +144,10 @@
"message": "Sunucu ekle"
},
"instance.add-server.resource-pack.disabled": {
"message": "Kapalı"
"message": "Devre dışı"
},
"instance.add-server.resource-pack.enabled": {
"message": "Açık"
"message": "Etkin"
},
"instance.add-server.resource-pack.prompt": {
"message": "Sor"
@@ -159,7 +159,7 @@
"message": "Sunucuyu düzenle"
},
"instance.edit-world.hide-from-home": {
"message": "Ana Sayfadan gizle"
"message": "Ana sayfada gizle"
},
"instance.edit-world.name": {
"message": "Dünya Adı"
@@ -408,7 +408,7 @@
"message": "(bilinmeyen sürüm)"
},
"instance.settings.tabs.installation.unlink.button": {
"message": "Kurulum bağını koparr"
"message": "Kurulum bağlantısını kes"
},
"instance.settings.tabs.installation.unlink.confirm.description": {
"message": "Devam edersen bu kurulumu geri bağlayamazsın. Mod paketi güncellemeleri almazsın ve bu kurulum normal kuruluma dönüşür."
@@ -474,7 +474,7 @@
"message": "Ayarlar"
},
"instance.worlds.a_minecraft_server": {
"message": "Bir Minecraft Sunucusu"
"message": "Minecraft Sunucusu"
},
"instance.worlds.cant_connect": {
"message": "Sunucuya bağlanılamıyor"
@@ -483,7 +483,7 @@
"message": "Adresi kopyala"
},
"instance.worlds.dont_show_on_home": {
"message": "Ana Sayfada gösterme"
"message": "Ana sayfada gösterme"
},
"instance.worlds.filter.available": {
"message": "Kullanılabilir"
@@ -531,6 +531,6 @@
"message": "Yükleyici veren kurulumlar"
},
"search.filter.locked.instance.sync": {
"message": "Kurulum ile senkronize et"
"message": "Kurulumla senkronize et"
}
}
+49 -49
View File
@@ -1,21 +1,21 @@
{
"app.auth-servers.unreachable.body": {
"message": "Сервери автентифікації Minecraft можуть зараз не працювати. Перевірте підключення до Інтернету та спробуйте пізніше."
"message": "Сервери автентифікації Minecraft можуть зараз не працювати. Перевірте з’єднання з Інтернетом та спробуйте пізніше."
},
"app.auth-servers.unreachable.header": {
"message": "Не вдається зв'язатися зі серверами автентифікації"
"message": "Не вдається звязатися зі серверами автентифікації"
},
"app.settings.developer-mode-enabled": {
"message": "Увімкнено режим розробника."
},
"app.settings.downloading": {
"message": "Завантаження v{version}"
"message": "Завантаження версії {version}"
},
"app.settings.tabs.appearance": {
"message": "Вигляд"
},
"app.settings.tabs.default-instance-options": {
"message": "Налаштування усталеного профілю"
"message": "Усталені налашт. профілю"
},
"app.settings.tabs.feature-flags": {
"message": "Спеціальні функції"
@@ -33,13 +33,13 @@
"message": "Керування ресурсами"
},
"app.update-toast.body": {
"message": "Modrinth App v{version} готова до встановлення! Перезапустіть, щоб оновити зараз. Або, оновлення буде здійснено автоматично, коли закриєте Modrinth App."
"message": "Версія {version} Modrinth App готова до встановлення! Перезапустіть, щоб оновити зараз, або дозвольте оновитися автоматично, коли закриватимете Modrinth App."
},
"app.update-toast.body.download-complete": {
"message": "Modrinth App v{version} уже завантажилась. Перезапустіть, щоб оновити зараз. Або, оновлення буде здійснено автоматично, коли закриєте Modrinth App."
"message": "Версія {version} Modrinth App уже завантажилася. Перезапустіть, щоб оновити зараз, або дозвольте оновитися автоматично, коли закриватимете Modrinth App."
},
"app.update-toast.body.metered": {
"message": "Modrinth App v{version} доступна зараз! Оскільки ви на лімітному з’єднанні, ми не завантажили її автоматично."
"message": "Версія {version} Modrinth App доступна для завантаження! Оскільки ви на лімітному з’єднанні, ми не завантажили її автоматично."
},
"app.update-toast.changelog": {
"message": "Журнал змін"
@@ -54,13 +54,13 @@
"message": "Перезапустити"
},
"app.update-toast.title": {
"message": "Оновлення доступне"
"message": "Доступне оновлення"
},
"app.update-toast.title.download-complete": {
"message": "Завантаження завершено"
},
"app.update.complete-toast.text": {
"message": "Натисніть тут, щоб побачити список змін."
"message": "Натисніть тут, щоб побачити журнал змін."
},
"app.update.complete-toast.title": {
"message": "Версію {version} успішно встановлено!"
@@ -72,7 +72,7 @@
"message": "Завантаження оновлення ({percent}%)"
},
"app.update.reload-to-update": {
"message": "Перезавантажте, щоб установити оновлення"
"message": "Перезавантажте, аби встановити оновлення"
},
"friends.action.add-friend": {
"message": "Додати друга"
@@ -90,13 +90,13 @@
"message": "Може відрізнятися від його або її імені користувача Minecraft!"
},
"friends.add-friend.username.placeholder": {
"message": "Уведіть ім’я користувача Modrinth…"
"message": "Уведіть ім’я користувача…"
},
"friends.add-friend.username.title": {
"message": "Яке ім’я користувача Modrinth у вашого друга?"
},
"friends.add-friends-to-share": {
"message": "<link>Додайте друзів</link>, щоб бачити, у що вони грають!"
"message": "<link>Додайте друзів</link>, аби бачити, у що вони грають!"
},
"friends.friend.cancel-request": {
"message": "Скасувати запит"
@@ -126,7 +126,7 @@
"message": "Очікується"
},
"friends.no-friends-match": {
"message": "Немає друзів, котрі збігаються з «{query}»"
"message": "Немає друзів, які збігаються з «{query}»"
},
"friends.search-friends-placeholder": {
"message": "Шукати друзів…"
@@ -156,7 +156,7 @@
"message": "Додати сервер"
},
"instance.edit-server.title": {
"message": "Змінити сервер"
"message": "Редагувати сервер"
},
"instance.edit-world.hide-from-home": {
"message": "Не показувати на головній сторінці"
@@ -177,7 +177,7 @@
"message": "Вимкнені проєкти"
},
"instance.filter.updates-available": {
"message": "Наявні оновлення"
"message": "Оновлення доступні"
},
"instance.server-modal.address": {
"message": "Адреса"
@@ -195,13 +195,13 @@
"message": "Загальні"
},
"instance.settings.tabs.general.delete": {
"message": "Видалити примірник"
"message": "Видалити профіль"
},
"instance.settings.tabs.general.delete.button": {
"message": "Видалити профіль"
},
"instance.settings.tabs.general.delete.description": {
"message": "Назавжди видаляє профіль з вашого пристрою, включно з вашими світами, налаштуваннями та всім встановленим контентом.\nБудьте обережні, після видалення, відновити профіль буде неможливо."
"message": "Назавжди видаляє профіль з вашого пристрою, включно з вашими світами, налаштуваннями та всім установленим умістом. Будьте обережні, після видалення, відновити профіль буде неможливо."
},
"instance.settings.tabs.general.deleting.button": {
"message": "Видалення…"
@@ -210,13 +210,13 @@
"message": "Клонувати"
},
"instance.settings.tabs.general.duplicate-button.tooltip.installing": {
"message": "Неможливо клонувати під час встановлення."
"message": "Неможливо клонувати під час установлення."
},
"instance.settings.tabs.general.duplicate-instance": {
"message": "Клонувати профіль"
},
"instance.settings.tabs.general.duplicate-instance.description": {
"message": "Створює копію інсталяції, включно зі світами, налаштуваннями, модами тощо."
"message": "Створює копію профілю, включно зі світами, налаштуваннями, модами тощо."
},
"instance.settings.tabs.general.edit-icon": {
"message": "Редагувати значок"
@@ -228,7 +228,7 @@
"message": "Замінити значок"
},
"instance.settings.tabs.general.edit-icon.select": {
"message": "Обрати значок"
"message": "Вибрати значок"
},
"instance.settings.tabs.general.library-groups": {
"message": "Групи бібліотеки"
@@ -270,13 +270,13 @@
"message": "Запускається перед відкриттям профілю."
},
"instance.settings.tabs.hooks.pre-launch.enter": {
"message": "Уведіть команду, що виконується перед запуском..."
"message": "Уведіть команду, що виконуватиметься перед запуском"
},
"instance.settings.tabs.hooks.title": {
"message": "Гуки запуску гри"
},
"instance.settings.tabs.hooks.wrapper": {
"message": "Обгортач"
"message": "Під час гри"
},
"instance.settings.tabs.hooks.wrapper.description": {
"message": "Обгортальна команда для запуску Minecraft."
@@ -285,7 +285,7 @@
"message": "Уведіть обгортальну команду…"
},
"instance.settings.tabs.installation": {
"message": "Інсталяція"
"message": "Установлення"
},
"instance.settings.tabs.installation.change-version.already-installed.modded": {
"message": "{platform} {version} для Minecraft {game_version} вже встановлена"
@@ -324,7 +324,7 @@
"message": "Установити"
},
"instance.settings.tabs.installation.install.in-progress": {
"message": "Відбувається встановлення"
"message": "Установлення триває"
},
"instance.settings.tabs.installation.loader-version": {
"message": "Версія {loader}"
@@ -333,31 +333,31 @@
"message": "Minecraft {version}"
},
"instance.settings.tabs.installation.no-connection": {
"message": "Неможливо отримати деталі пов'язаної збірки. Будь ласка, перевірте ваше з'єднання з інтернетом."
"message": "Не вдається отримати деталі повязаної збірки. Будь ласка, перевірте ваше зєднання з інтернетом."
},
"instance.settings.tabs.installation.no-loader-versions": {
"message": "{loader} недоступний для Minecraft {version}. Спробуйте інший завантажувач модів."
},
"instance.settings.tabs.installation.no-modpack-found": {
"message": я інсталяція пов'язана зі збіркою, проте саму збірку не вдалося знайти на Modrinth."
"message": ей профіль повязаний зі збіркою, проте саму збірку не вдалося знайти на Modrinth."
},
"instance.settings.tabs.installation.platform": {
"message": "Платформа"
},
"instance.settings.tabs.installation.reinstall.button": {
"message": "Перевстановити пакет модів"
"message": "Перевстановити збірку"
},
"instance.settings.tabs.installation.reinstall.button.reinstalling": {
"message": "Перевстановлення пакета модів"
"message": "Перевстановлення збірки"
},
"instance.settings.tabs.installation.reinstall.confirm.description": {
"message": "Перевстановлення скине увесь доданий або модифікований уміст до наданого пакета модів, прибираючи будь-які, додатково встановлені вами моди чи вміст.\nЯкщо ви змінювали екземпляр, це може виправити непередбачувану поведінку, однак, якщо ваші світи залежать від додаткового вмісту, це може зламати ці світи."
"message": "Перевстановлення скине ввесь доданий або модифікований уміст до наданого збіркою, прибираючи будь-які додатково встановлені вами моди чи вміст. Якщо ви змінювали профіль, це може виправити непередбачувану поведінку, однак, якщо ваші світи залежать від додаткового вмісту, світи можуть пошкодитися."
},
"instance.settings.tabs.installation.reinstall.confirm.title": {
"message": "Ви впевнені, що хочете перевстановити цей профіль?"
},
"instance.settings.tabs.installation.reinstall.description": {
"message": "Скидає вміст інсталяції до початкового стану, прибираючи будь-які модифікації чи вміст, що було додано поверх оригінальної збірки."
"message": "Скидає вміст профілю до початкового стану, прибираючи будь-які моди чи вміст, що було додано поверх оригінальної збірки."
},
"instance.settings.tabs.installation.reinstall.title": {
"message": "Перевстановити збірку"
@@ -369,13 +369,13 @@
"message": "Лагодження"
},
"instance.settings.tabs.installation.repair.confirm.description": {
"message": "Відновлення перевстановлює залежності Minecraft та перевіряє їх на наявність пошкоджень. Це може розв'язати проблеми, якщо ваша гра не запускається через помилки завантажувача, але не розв'яже проблеми або збої, пов'язані зі встановленими модами."
"message": "Відновлення перевстановлює залежності Minecraft та перевіряє їх на наявність пошкоджень. Це може розвязати проблеми, якщо ваша гра не запускається через помилки запускача, але не розвяже проблеми або збої, повязані зі встановленими модами."
},
"instance.settings.tabs.installation.repair.confirm.title": {
"message": "Відновити профіль?"
},
"instance.settings.tabs.installation.repair.in-progress": {
"message": "Лагодження в процесі"
"message": "Лагодження триває"
},
"instance.settings.tabs.installation.reset-selections": {
"message": "Скинути до поточного"
@@ -399,31 +399,31 @@
"message": "Неможливо {action} під час установлення"
},
"instance.settings.tabs.installation.tooltip.cannot-while-offline": {
"message": "Неможливо {action} без з'єднання з Інтернетом"
"message": "Неможливо {action} без зєднання з інтернетом"
},
"instance.settings.tabs.installation.tooltip.cannot-while-repairing": {
"message": "Неможливо {action} під час виправлення"
"message": "Неможливо {action} під час лагодження"
},
"instance.settings.tabs.installation.unknown-version": {
"message": "(невідома версія)"
},
"instance.settings.tabs.installation.unlink.button": {
"message": "Відв'язати профіль"
"message": "Відвязати профіль"
},
"instance.settings.tabs.installation.unlink.confirm.description": {
"message": "Якщо ви продовжите, ви не зможете повторно підв'язати його без створення повністю нового екземпляру. Ви більше не будете отримувати оновлення пакета модів та вона стане звичайною."
"message": "Якщо ви продовжите, ви не зможете повторно підвязати його без створення повністю нового профілю. Ви більше не будете отримувати оновлення збірки та цей профіль стане звичайним."
},
"instance.settings.tabs.installation.unlink.confirm.title": {
"message": "Ви впевнені, що хочете відв'язати цей екземпляр?"
"message": "Ви впевнені, що хочете відвязати цей профіль?"
},
"instance.settings.tabs.installation.unlink.description": {
"message": "Цей профіль пов'язаний з пакетом модів, тобто, не можна оновлювати моди та не можна змінювати завантажувач чи версію Minecraft. Відв'язання призведе до остаточного від'єднання цього екземпляру від пакета модів."
"message": "Цей профіль повязаний зі збіркою, тобто, не можна оновлювати моди та не можна змінювати завантажувач чи версію Minecraft. Відвязання призведе до остаточного відєднання цього профілю від збірки."
},
"instance.settings.tabs.installation.unlink.title": {
"message": "Відв'язати від збірки"
"message": "Відвязати від збірки"
},
"instance.settings.tabs.java": {
"message": "Java та пам'ять"
"message": "Java та память"
},
"instance.settings.tabs.java.environment-variables": {
"message": "Змінні середовища"
@@ -435,10 +435,10 @@
"message": "Аргументи Java"
},
"instance.settings.tabs.java.java-installation": {
"message": "Установлення Java"
"message": "Інсталяції Java"
},
"instance.settings.tabs.java.java-memory": {
"message": "Виділена пам'ять"
"message": "Виділена память"
},
"instance.settings.tabs.window": {
"message": "Вікно"
@@ -450,7 +450,7 @@
"message": "Повний екран"
},
"instance.settings.tabs.window.fullscreen.description": {
"message": "Змусити гру запускатися на повний екран (використовуючи options.txt)."
"message": "Запускати гру в повноекранному режимі (за допомогою options.txt)."
},
"instance.settings.tabs.window.height": {
"message": "Висота"
@@ -477,13 +477,13 @@
"message": "Сервер Minecraft"
},
"instance.worlds.cant_connect": {
"message": "Неможливо під'єднатися до сервера"
"message": "Неможливо підєднатися до сервера"
},
"instance.worlds.copy_address": {
"message": "Скопіювати адресу"
},
"instance.worlds.dont_show_on_home": {
"message": "Не показувати на Головній"
"message": "Не показувати на головній"
},
"instance.worlds.filter.available": {
"message": "Доступно"
@@ -495,10 +495,10 @@
"message": "Режим гардкору"
},
"instance.worlds.incompatible_server": {
"message": "Несумісний сервер"
"message": "Сервер несумісний"
},
"instance.worlds.no_contact": {
"message": "Не вдалося зв'язатися з сервером"
"message": "Не вдалося звязатися зі сервером"
},
"instance.worlds.no_server_quick_play": {
"message": "Запускатися у сервери напряму можливо лише починаючи з версії Minecraft Alpha 1.0.5"
@@ -519,7 +519,7 @@
"message": "Переглянути профіль"
},
"instance.worlds.world_in_use": {
"message": "Світ зараз використовується"
"message": "Світ наразі використовується"
},
"search.filter.locked.instance": {
"message": "Надано профілем"
@@ -528,7 +528,7 @@
"message": "Версія гри надана профілем"
},
"search.filter.locked.instance-loader.title": {
"message": "Запускач наданий профілем"
"message": "Завантажувач наданий профілем"
},
"search.filter.locked.instance.sync": {
"message": "Синхронізувати з профілем"
@@ -78,7 +78,7 @@
"message": "Thêm một người bạn"
},
"friends.action.view-friend-requests": {
"message": "{count} yêu cầu kết bạn"
"message": "{count} yêu cầu kết bạn {count, plural, one {request} other {requests}}"
},
"friends.add-friend.submit": {
"message": "Gửi yêu cầu kết bạn"
+23 -23
View File
@@ -36,7 +36,7 @@
"message": "Modrinth App v{version} 更新已就绪!立即重启更新,或退出时自动安装。"
},
"app.update-toast.body.download-complete": {
"message": "Modrinth App v{version} 下载完成。立即重启更新,或退出时自动安装。"
"message": "Modrinth App v{version} 已经完成下载。现在重新启动以更新,或在关闭 Modrinth App 时自动更新。"
},
"app.update-toast.body.metered": {
"message": "Modrinth App v{version} 现已发布!由于你正在使用按流量计费的网络,该更新未自动下载。"
@@ -54,7 +54,7 @@
"message": "重启应用"
},
"app.update-toast.title": {
"message": "更新可用"
"message": "有可用更新"
},
"app.update-toast.title.download-complete": {
"message": "下载完成"
@@ -72,7 +72,7 @@
"message": "下载更新中({percent}%"
},
"app.update.reload-to-update": {
"message": "立即重启安装更新"
"message": "立即重启安装更新"
},
"friends.action.add-friend": {
"message": "添加好友"
@@ -93,10 +93,10 @@
"message": "输入 Modrinth 用户名……"
},
"friends.add-friend.username.title": {
"message": "你朋友的 Modrinth 用户名是什么?"
"message": "友的 Modrinth 用户名是什么?"
},
"friends.add-friends-to-share": {
"message": "<link>添加好友</link>看看他们在玩什么!"
"message": "<link>添加好友</link>看看他们在玩什么!"
},
"friends.friend.cancel-request": {
"message": "取消请求"
@@ -126,10 +126,10 @@
"message": "待确认"
},
"friends.no-friends-match": {
"message": "没有匹配“{query}”的好友"
"message": "没有对应名字“{query}”的好友"
},
"friends.search-friends-placeholder": {
"message": "搜索好友…"
"message": "搜索好友…"
},
"friends.section.heading": {
"message": "{title} - {count}"
@@ -171,13 +171,13 @@
"message": "重置图标"
},
"instance.edit-world.title": {
"message": "编辑世界"
"message": "编辑存档"
},
"instance.filter.disabled": {
"message": "已禁用的项目"
},
"instance.filter.updates-available": {
"message": "有更新可用"
"message": "有可用更新"
},
"instance.server-modal.address": {
"message": "地址"
@@ -201,7 +201,7 @@
"message": "删除实例"
},
"instance.settings.tabs.general.delete.description": {
"message": "从你的设备永久删除实例,包括你的世界,配置和所有已安装内容。注意:你无法恢复被删除的实例。"
"message": "从设备永久删除实例,包括所有存档、配置文件及已安装内容。此操作不可撤销,请谨慎操作。"
},
"instance.settings.tabs.general.deleting.button": {
"message": "删除中……"
@@ -246,13 +246,13 @@
"message": "名称"
},
"instance.settings.tabs.hooks": {
"message": "启动钩子"
"message": "启动Hooks"
},
"instance.settings.tabs.hooks.custom-hooks": {
"message": "自定义启动钩子"
"message": "自定义启动Hooks"
},
"instance.settings.tabs.hooks.description": {
"message": "钩子允许高级用户在启动游戏前后运行特定的系统命令。"
"message": "Hooks允许高级用户在启动游戏前后运行特定的系统命令。"
},
"instance.settings.tabs.hooks.post-exit": {
"message": "退出后"
@@ -276,13 +276,13 @@
"message": "游戏启动钩子"
},
"instance.settings.tabs.hooks.wrapper": {
"message": "包装命令"
"message": "包装命令"
},
"instance.settings.tabs.hooks.wrapper.description": {
"message": "用于启动 Minecraft 的包装命令。"
"message": "用于启动 Minecraft 的包装命令。"
},
"instance.settings.tabs.hooks.wrapper.enter": {
"message": "输入包装命令……"
"message": "输入包装命令……"
},
"instance.settings.tabs.installation": {
"message": "安装"
@@ -351,13 +351,13 @@
"message": "正在重新安装整合包"
},
"instance.settings.tabs.installation.reinstall.confirm.description": {
"message": "重新安装实例将重置所有安装或修改的内容,恢复到整合包的初始状态,移除在原的安装内容上另的所有模组或内容。该操作有可能修复因实例更改导致的异常,但你的世界目前依赖额外安装的内容,此举可能会损坏现有世界。"
"message": "重新安装实例将重置所有安装或修改的内容,恢复到整合包的初始状态,移除在原的安装内容上另外添加的的所有模组或内容。该操作有可能修复因实例更改导致的异常,但如果你的世界依赖已安装的额外内容,此举可能会损坏现有世界。"
},
"instance.settings.tabs.installation.reinstall.confirm.title": {
"message": "你确定要重新安装该实例吗?"
},
"instance.settings.tabs.installation.reinstall.description": {
"message": "重置实例至原始状态,移除你在原整合包上添加的所有模组内容。"
"message": "重置实例至原始状态,移除你在原整合包上添加的所有模组内容。"
},
"instance.settings.tabs.installation.reinstall.title": {
"message": "重新安装整合包"
@@ -375,13 +375,13 @@
"message": "确定修复该实例吗?"
},
"instance.settings.tabs.installation.repair.in-progress": {
"message": "修复"
"message": "正在修复"
},
"instance.settings.tabs.installation.reset-selections": {
"message": "重置为当前版本"
},
"instance.settings.tabs.installation.show-all-versions": {
"message": "列出所有版本"
"message": "显示所有版本"
},
"instance.settings.tabs.installation.tooltip.action.change-version": {
"message": "切换版本"
@@ -438,10 +438,10 @@
"message": "Java 安装路径"
},
"instance.settings.tabs.java.java-memory": {
"message": "分配内存"
"message": "内存分配"
},
"instance.settings.tabs.window": {
"message": "窗口"
"message": "游戏窗口"
},
"instance.settings.tabs.window.custom-window-settings": {
"message": "自定义窗口设置"
@@ -510,7 +510,7 @@
"message": "游玩实例"
},
"instance.worlds.type.server": {
"message": "服务"
"message": "服务"
},
"instance.worlds.type.singleplayer": {
"message": "单人游戏"
@@ -9,7 +9,7 @@
"message": "開發人員模式已啟用。"
},
"app.settings.downloading": {
"message": "正在下載 {version}"
"message": "正在下載 v{version}"
},
"app.settings.tabs.appearance": {
"message": "外觀"
@@ -33,13 +33,13 @@
"message": "資源管理"
},
"app.update-toast.body": {
"message": "Modrinth App {version} 安裝已準備就緒!重新載入立即更新,或是在你關閉 Modrinth App 時自動更新。"
"message": "Modrinth App v{version} 安裝已準備就緒!重新載入立即更新,或是在你關閉 Modrinth App 時自動更新。"
},
"app.update-toast.body.download-complete": {
"message": "Modrinth App {version} 已完成下載。重新載入立即更新,或是在你關閉 Modrinth App 時自動更新。"
"message": "Modrinth App v{version} 已完成下載。重新載入立即更新,或是在你關閉 Modrinth App 時自動更新。"
},
"app.update-toast.body.metered": {
"message": "Modrinth App {version} 現已推出!由於你正使用計量付費的網路,我們沒有自動下載它。"
"message": "Modrinth App v{version} 現已推出!由於你正使用計量付費的網路,我們沒有自動下載它。"
},
"app.update-toast.changelog": {
"message": "變更紀錄"
@@ -63,7 +63,7 @@
"message": "按這裡查看變更紀錄。"
},
"app.update.complete-toast.title": {
"message": "{version} 已成功安裝!"
"message": "版本 {version} 已成功安裝!"
},
"app.update.download-update": {
"message": "下載更新"
@@ -135,7 +135,7 @@
"message": "{title} - {count}"
},
"friends.sign-in-to-add-friends": {
"message": "<link>登入 Modrinth 帳號</link>即可新增好友並查看對方正在玩什麼!"
"message": "<link>登入 Modrinth 帳號</link>即可新增好友並查看他們正在玩什麼!"
},
"instance.add-server.add-and-play": {
"message": "新增並遊玩"
+2 -2
View File
@@ -8,7 +8,7 @@ import { createPinia } from 'pinia'
import { createApp } from 'vue'
import App from '@/App.vue'
import i18n from '@/i18n.config'
import i18nPlugin from '@/plugins/i18n'
import router from '@/routes'
const vueScan = new VueScanPlugin({
@@ -43,6 +43,6 @@ app.use(FloatingVue, {
},
},
})
app.use(i18n)
app.use(i18nPlugin)
app.mount('#app')
+23
View File
@@ -0,0 +1,23 @@
import { I18N_INJECTION_KEY, type I18nContext } from '@modrinth/ui'
import type { App } from 'vue'
import i18n from '@/i18n.config'
export default {
install(app: App) {
// Install vue-i18n as before
app.use(i18n)
// Wrap it in our I18nContext interface
const context: I18nContext = {
locale: i18n.global.locale,
t: (key, values) => i18n.global.t(key, values ?? {}) as string,
setLocale: (newLocale) => {
i18n.global.locale.value = newLocale
},
}
// Provide the context at app-level
app.provide(I18N_INJECTION_KEY, context)
},
}
Binary file not shown.

Before

Width:  |  Height:  |  Size: 11 KiB

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 23 KiB

After

Width:  |  Height:  |  Size: 21 KiB

+12 -7
View File
@@ -93,19 +93,24 @@ pub fn highlight_in_folder<R: Runtime>(
}
#[tauri::command]
pub fn open_path<R: Runtime>(app: tauri::AppHandle<R>, path: PathBuf) {
if let Err(e) = app.opener().open_path(path.to_string_lossy(), None::<&str>)
{
tracing::error!("Failed to open path: {}", e);
}
pub async fn open_path<R: Runtime>(app: tauri::AppHandle<R>, path: PathBuf) {
tauri::async_runtime::spawn_blocking(move || {
if let Err(e) =
app.opener().open_path(path.to_string_lossy(), None::<&str>)
{
tracing::error!("Failed to open path: {}", e);
}
})
.await
.ok();
}
#[tauri::command]
pub fn show_launcher_logs_folder<R: Runtime>(app: tauri::AppHandle<R>) {
pub async fn show_launcher_logs_folder<R: Runtime>(app: tauri::AppHandle<R>) {
let path = DirectoryInfo::launcher_logs_dir().unwrap_or_default();
// failure to get folder just opens filesystem
// (ie: if in debug mode only and launcher_logs never created)
open_path(app, path);
open_path(app, path).await;
}
// Get opening command
+2 -2
View File
@@ -119,12 +119,12 @@ fn main() {
#[cfg(feature = "updater")]
{
use tauri_plugin_http::reqwest::header::{HeaderValue, USER_AGENT};
use theseus::LAUNCHER_USER_AGENT;
use theseus::launcher_user_agent;
builder = builder.plugin(
tauri_plugin_updater::Builder::new()
.header(
USER_AGENT,
HeaderValue::from_str(LAUNCHER_USER_AGENT).unwrap(),
HeaderValue::from_str(&launcher_user_agent()).unwrap(),
)
.unwrap()
.build(),
+2 -2
View File
@@ -8,7 +8,7 @@ use tauri_plugin_http::reqwest::ClientBuilder;
use tauri_plugin_updater::Error;
use tauri_plugin_updater::Update;
use theseus::{
LAUNCHER_USER_AGENT, LoadingBarType, emit_loading, init_loading,
LoadingBarType, emit_loading, init_loading, launcher_user_agent,
};
use tokio::time::Instant;
@@ -31,7 +31,7 @@ pub async fn get_update_size<R: Runtime>(
);
}
let mut request = ClientBuilder::new().user_agent(LAUNCHER_USER_AGENT);
let mut request = ClientBuilder::new().user_agent(launcher_user_agent());
if let Some(timeout) = update.timeout {
request = request.timeout(timeout);
}
+7
View File
@@ -2516,6 +2516,13 @@ paths:
description: 'Allows to filter for featured or non-featured versions only'
schema:
type: boolean
- in: query
name: include_changelog
required: false
description: 'Allows you to toggle the inclusion of the changelog field in the response. It is highly recommended to use include_changelog=false in most cases unless you specifically need the changelog for all versions.'
schema:
type: boolean
default: true
responses:
'200':
description: Expected response to a valid request
+2
View File
@@ -2,7 +2,9 @@
**/dist
**/.output
**/.data
**/.wrangler
src/generated/**
src/locales/**
src/public/news/feed
src/assets/**/*.svg
**/.wrangler
-10
View File
@@ -1,10 +0,0 @@
import { createMessageCompiler } from '@modrinth/ui'
export default defineI18nConfig(() => ({
legacy: false,
locale: 'en-US',
fallbackLocale: 'en-US',
messageCompiler: createMessageCompiler(),
missingWarn: false,
fallbackWarn: false,
}))
-17
View File
@@ -1,17 +0,0 @@
import { type CrowdinMessages, transformCrowdinMessages } from '@modrinth/ui'
// eager:false - only loads the locale requested
const localeModules = import.meta.glob<{ default: CrowdinMessages }>(
'../src/locales/*/index.json',
{ eager: false },
)
export default defineI18nLocale(async (locale) => {
const loader = localeModules[`../src/locales/${locale}/index.json`]
if (!loader) {
console.warn(`Locale ${locale} not found`)
return {}
}
const messages = await loader()
return transformCrowdinMessages(messages.default)
})
+57 -30
View File
@@ -1,8 +1,6 @@
import { GenericModrinthClient, type Labrinth } from '@modrinth/api-client'
// Import directly from utils to avoid loading .vue files at config time
import { LOCALES } from '@modrinth/ui/src/composables/i18n.ts'
import serverSidedVue from '@vitejs/plugin-vue'
import { promises as fs } from 'fs'
import fs from 'fs/promises'
import { defineNuxtConfig } from 'nuxt/config'
import svgLoader from 'vite-svg-loader'
@@ -21,6 +19,9 @@ const favicons = {
'(prefers-color-scheme:dark)': '/favicon.ico',
}
const PROD_MODRINTH_URL = 'https://modrinth.com'
const STAGING_MODRINTH_URL = 'https://staging.modrinth.com'
export default defineNuxtConfig({
srcDir: 'src/',
app: {
@@ -96,6 +97,11 @@ export default defineNuxtConfig({
},
}),
],
build: {
rollupOptions: {
external: ['cloudflare:workers'],
},
},
},
hooks: {
async 'nitro:config'(nitroConfig) {
@@ -160,7 +166,25 @@ export default defineNuxtConfig({
await fs.writeFile('./src/generated/state.json', JSON.stringify(state))
// throw if errors and building for prod (preview & staging allowed to have errors)
if (
process.env.BUILD_ENV === 'production' &&
process.env.PREVIEW !== 'true' &&
generatedState.errors.length > 0
) {
throw new Error(
`Production build failed: State generation encountered errors. Error codes: ${JSON.stringify(generatedState.errors)}; API URL: ${API_URL}`,
)
}
console.log('Tags generated!')
const robotsContent =
getDomain() === PROD_MODRINTH_URL
? 'User-agent: *\nDisallow: /_internal/'
: 'User-agent: *\nDisallow: /'
await fs.writeFile('./src/public/robots.txt', robotsContent)
},
},
runtimeConfig: {
@@ -174,6 +198,8 @@ export default defineNuxtConfig({
pyroBaseUrl: process.env.PYRO_BASE_URL,
siteUrl: getDomain(),
production: isProduction(),
buildEnv: process.env.BUILD_ENV,
preview: process.env.PREVIEW === 'true',
featureFlagOverrides: getFeatureFlagOverrides(),
owner: process.env.VERCEL_GIT_REPO_OWNER || 'modrinth',
@@ -183,7 +209,7 @@ export default defineNuxtConfig({
process.env.CF_PAGES_BRANCH ||
// @ts-ignore
globalThis.CF_PAGES_BRANCH ||
'master',
'main',
hash:
process.env.VERCEL_GIT_COMMIT_SHA ||
process.env.CF_PAGES_COMMIT_SHA ||
@@ -208,7 +234,12 @@ export default defineNuxtConfig({
},
},
},
modules: ['@nuxtjs/i18n', '@pinia/nuxt', 'floating-vue/nuxt'],
modules: [
'@pinia/nuxt',
'floating-vue/nuxt',
// Sentry causes rollup-plugin-inject errors in dev, only enable in production
...(isProduction() ? ['@sentry/nuxt/module'] : []),
],
floatingVue: {
themes: {
'ribbit-popout': {
@@ -223,29 +254,19 @@ export default defineNuxtConfig({
},
},
},
i18n: {
defaultLocale: 'en-US',
lazy: true,
langDir: '.',
locales: LOCALES.map((locale) => ({
...locale,
file: 'locale-loader.ts',
})),
strategy: 'no_prefix',
detectBrowserLanguage: {
useCookie: true,
cookieKey: 'locale',
fallbackLocale: 'en-US',
},
vueI18n: './i18n.config.ts',
bundle: {
optimizeTranslationDirective: false,
},
},
nitro: {
rollupConfig: {
// @ts-expect-error because of rolldown-vite - completely fine though
plugins: [serverSidedVue()],
external: ['cloudflare:workers'],
},
preset: 'cloudflare_module',
cloudflare: {
nodeCompat: true,
},
replace: {
__SENTRY_RELEASE__: JSON.stringify(process.env.CF_PAGES_COMMIT_SHA || 'unknown'),
__SENTRY_ENVIRONMENT__: JSON.stringify(process.env.BUILD_ENV || 'development'),
},
},
devtools: {
@@ -291,6 +312,15 @@ export default defineNuxtConfig({
},
compatibilityDate: '2025-01-01',
telemetry: false,
experimental: {
asyncContext: true,
},
sourcemap: { client: 'hidden' },
sentry: {
sourcemaps: {
disable: true,
},
},
})
function getApiUrl() {
@@ -308,11 +338,8 @@ function getFeatureFlagOverrides() {
function getDomain() {
if (process.env.NODE_ENV === 'production') {
if (process.env.SITE_URL) {
return process.env.SITE_URL
}
// @ts-ignore
else if (process.env.CF_PAGES_URL || globalThis.CF_PAGES_URL) {
if (process.env.CF_PAGES_URL || globalThis.CF_PAGES_URL) {
// @ts-ignore
return process.env.CF_PAGES_URL ?? globalThis.CF_PAGES_URL
} else if (process.env.HEROKU_APP_NAME) {
@@ -320,9 +347,9 @@ function getDomain() {
} else if (process.env.VERCEL_URL) {
return `https://${process.env.VERCEL_URL}`
} else if (getApiUrl() === STAGING_API_URL) {
return 'https://staging.modrinth.com'
return STAGING_MODRINTH_URL
} else {
return 'https://modrinth.com'
return PROD_MODRINTH_URL
}
} else {
const port = process.env.PORT || 3000
+10 -4
View File
@@ -11,15 +11,18 @@
"lint": "eslint . && prettier --check .",
"fix": "eslint . --fix && prettier --write .",
"intl:extract": "formatjs extract \"src/{components,composables,layouts,middleware,modules,pages,plugins,utils}/**/*.{vue,ts,tsx,js,jsx,mts,cts,mjs,cjs}\" \"src/error.vue\" --ignore \"**/*.d.ts\" --ignore node_modules --out-file src/locales/en-US/index.json --format crowdin --preserve-whitespace",
"test": "pnpm build"
"cf-deploy": "pnpm run build && wrangler deploy --env preview",
"cf-dev": "pnpm run build && wrangler dev --env preview",
"cf-typegen": "wrangler types"
},
"devDependencies": {
"@formatjs/cli": "^6.2.12",
"@modrinth/tooling-config": "workspace:*",
"@nuxtjs/i18n": "^9.0.0",
"@types/dompurify": "^3.0.5",
"@types/iso-3166-2": "^1.0.4",
"@types/js-yaml": "^4.0.9",
"@types/node": "^20.1.0",
"@types/semver": "^7.7.1",
"autoprefixer": "^10.4.19",
"glob": "^10.2.7",
"nuxt": "^3.20.2",
@@ -30,7 +33,8 @@
"typescript": "^5.4.5",
"vite-svg-loader": "^5.1.0",
"vue-component-type-helpers": "^3.1.8",
"vue-tsc": "^2.0.24"
"vue-tsc": "^2.0.24",
"wrangler": "^4.54.0"
},
"dependencies": {
"@formatjs/intl-localematcher": "^0.5.4",
@@ -43,12 +47,14 @@
"@modrinth/ui": "workspace:*",
"@modrinth/utils": "workspace:*",
"@pinia/nuxt": "^0.11.3",
"@sentry/nuxt": "^10.33.0",
"@tanstack/vue-query": "^5.90.7",
"@types/three": "^0.172.0",
"@vitejs/plugin-vue": "^6.0.3",
"@vue-email/components": "^0.0.21",
"@vue-email/render": "^0.0.9",
"@vueuse/core": "^11.1.0",
"ace-builds": "^1.36.2",
"ansi-to-html": "^0.7.2",
"dayjs": "^1.11.7",
"dompurify": "^3.1.7",
@@ -59,8 +65,8 @@
"iso-3166-2": "1.0.0",
"js-yaml": "^4.1.0",
"jszip": "^3.10.1",
"lru-cache": "^11.2.4",
"markdown-it": "14.1.0",
"ace-builds": "^1.36.2",
"pathe": "^1.1.2",
"pinia": "^3.0.0",
"pinia-plugin-persistedstate": "^4.4.1",
@@ -1,6 +1,7 @@
<script setup lang="ts">
import { BlueskyIcon, DiscordIcon, GithubIcon, MastodonIcon, TwitterIcon } from '@modrinth/assets'
import {
AutoLink,
ButtonStyled,
defineMessage,
defineMessages,
@@ -15,6 +16,7 @@ import TextLogo from '~/components/brand/TextLogo.vue'
const flags = useFeatureFlags()
const { formatMessage } = useVIntl()
const { addNotification } = injectNotificationManager()
const config = useRuntimeConfig()
const messages = defineMessages({
modrinthInformation: {
@@ -302,6 +304,27 @@ function developerModeIncrement() {
</div>
</div>
</div>
<p v-if="flags.developerMode" class="m-0 text-sm text-secondary">
Based on
<a
v-if="config.public.owner && config.public.branch"
class="hover:underline"
target="_blank"
:href="`https://github.com/${config.public.owner}/code/tree/${config.public.branch}`"
>
{{ config.public.owner }}/{{ config.public.branch }}
</a>
@
<span v-if="config.public.hash === 'unknown'">unknown</span>
<AutoLink
v-else
class="text-link"
target="_blank"
:to="`https://github.com/${config.public.owner}/code/commit/${config.public.hash}`"
>
{{ config.public.hash }}
</AutoLink>
</p>
<div class="flex justify-center text-center text-xs font-medium text-secondary opacity-50">
{{ formatMessage(messages.legalDisclaimer) }}
</div>
+11 -4
View File
@@ -18,11 +18,18 @@
v-if="link.icon"
class="size-5"
:class="{
'text-brand': currentActiveIndex === index && !subpageSelected,
'text-button-textSelected': currentActiveIndex === index && !subpageSelected,
'text-secondary': currentActiveIndex !== index || subpageSelected,
}"
/>
<span class="text-nowrap text-contrast">{{ link.label }}</span>
<span
class="text-nowrap"
:class="{
'text-button-textSelected': currentActiveIndex === index && !subpageSelected,
'text-contrast': currentActiveIndex !== index || subpageSelected,
}"
>{{ link.label }}</span
>
</NuxtLink>
</template>
<template v-else>
@@ -39,14 +46,14 @@
v-if="link.icon"
class="size-5"
:class="{
'text-brand': currentActiveIndex === index && !subpageSelected,
'text-button-textSelected': currentActiveIndex === index && !subpageSelected,
'text-secondary': currentActiveIndex !== index || subpageSelected,
}"
/>
<span
class="text-nowrap"
:class="{
'text-brand': currentActiveIndex === index && !subpageSelected,
'text-button-textSelected': currentActiveIndex === index && !subpageSelected,
'text-contrast': currentActiveIndex !== index || subpageSelected,
}"
>{{ link.label }}</span
@@ -0,0 +1,397 @@
<template>
<NewModal ref="modal">
<template #title>
<span class="text-lg font-extrabold text-contrast">Schedule transfer</span>
</template>
<div class="flex w-[550px] max-w-[90vw] flex-col gap-6">
<div class="flex flex-col gap-2">
<label class="flex flex-col gap-1">
<span class="text-lg font-semibold text-contrast"> Type </span>
<span>Select transfer type.</span>
</label>
<Combobox
v-model="mode"
:options="modeOptions"
placeholder="Select type"
class="max-w-[10rem]"
/>
</div>
<div v-if="mode === 'servers'" class="flex flex-col gap-2">
<label for="server-ids" class="flex flex-col gap-1">
<span class="text-lg font-semibold text-contrast">
Server IDs
<span class="text-brand-red">*</span>
</span>
<span>Server IDs (one per line or comma-separated.)</span>
</label>
<div class="textarea-wrapper">
<textarea
id="server-ids"
v-model="serverIdsInput"
rows="4"
class="w-full bg-surface-3"
placeholder="123e4569-e89b-12d3-a456-426614174005&#10;123e9569-e89b-12d3-a456-413678919876"
/>
</div>
<span v-if="parsedServerIds.length" class="text-sm text-secondary">
{{ parsedServerIds.length }} server{{ parsedServerIds.length === 1 ? '' : 's' }} selected
</span>
</div>
<div v-else class="flex flex-col gap-4">
<div class="flex flex-col gap-2">
<label for="node-input" class="flex flex-col gap-1">
<span class="text-lg font-semibold text-contrast">
Node hostnames
<span class="text-brand-red">*</span>
</span>
<span>Add nodes to transfer.</span>
</label>
<div class="flex items-center gap-2">
<input
id="node-input"
v-model="nodeInput"
class="w-40"
type="text"
autocomplete="off"
placeholder="us-vin200"
@keydown.enter.prevent="addNode"
/>
<ButtonStyled color="blue" color-fill="text">
<button class="shrink-0" @click="addNode">
<PlusIcon />
Add
</button>
</ButtonStyled>
</div>
<div v-if="selectedNodes.length" class="mt-1 flex flex-wrap gap-2">
<TagItem v-for="h in selectedNodes" :key="`node-${h}`" :action="() => removeNode(h)">
<XIcon />
{{ h }}
</TagItem>
</div>
</div>
<div class="flex flex-col gap-3">
<label for="cordon-nodes" class="flex flex-col gap-1">
<span class="text-lg font-semibold text-contrast">Cordon nodes now</span>
<span>
Prevent new servers from being provisioned on the transferred nodes from now on.<br /><br />
Note that if this option isn't chosen, new servers provisioned onto transferred nodes
between now and the scheduled time will still be transferred.
</span>
</label>
<Toggle id="cordon-nodes" v-model="cordonNodes" />
</div>
<div class="flex flex-col gap-2">
<label for="tag-nodes" class="flex flex-col gap-1">
<span class="text-lg font-semibold text-contrast">Tag transferred nodes</span>
<span>Optional tag to add to the transferred nodes.</span>
</label>
<input
id="tag-nodes"
v-model="tagNodes"
class="max-w-[12rem]"
type="text"
autocomplete="off"
/>
</div>
</div>
<div class="flex flex-col gap-2">
<label for="region-select" class="flex flex-col gap-1">
<span class="text-lg font-semibold text-contrast"> Target region </span>
<span>Select the destination region for transferred servers.</span>
</label>
<Combobox
v-model="selectedRegion"
:options="regions"
placeholder="Select region"
class="max-w-[24rem]"
/>
</div>
<div class="flex flex-col gap-2">
<label for="tag-input" class="flex flex-col gap-1">
<span class="text-lg font-semibold text-contrast"> Node tags </span>
<span>Optional preferred node tags for node selection.</span>
</label>
<div class="flex items-center gap-2">
<input
id="tag-input"
v-model="tagInput"
class="w-40"
type="text"
autocomplete="off"
placeholder="ovh-gen4"
@keydown.enter.prevent="addTag"
/>
<ButtonStyled color="blue" color-fill="text">
<button class="shrink-0" @click="addTag">
<PlusIcon />
Add
</button>
</ButtonStyled>
</div>
<div v-if="selectedTags.length" class="mt-1 flex flex-wrap gap-2">
<TagItem v-for="t in selectedTags" :key="`tag-${t}`" :action="() => removeTag(t)">
<XIcon />
{{ t }}
</TagItem>
</div>
</div>
<div class="flex flex-col gap-2">
<label class="flex flex-col gap-1">
<span class="text-lg font-semibold text-contrast"> Schedule </span>
</label>
<Chips
v-model="scheduleOption"
:items="scheduleOptions"
:format-label="(item) => scheduleOptionLabels[item]"
:capitalize="false"
/>
<input
v-if="scheduleOption === 'later'"
v-model="scheduledDate"
type="datetime-local"
class="mt-2 max-w-[16rem]"
autocomplete="off"
/>
</div>
<div class="flex flex-col gap-2">
<label for="reason" class="flex flex-col gap-1">
<span class="text-lg font-semibold text-contrast">
Reason
<span class="text-brand-red">*</span>
</span>
<span>Provide a reason for this transfer batch.</span>
</label>
<div class="textarea-wrapper">
<textarea
id="reason"
v-model="reason"
rows="2"
class="w-full bg-surface-3"
placeholder="Node maintenance scheduled"
/>
</div>
</div>
<div class="flex gap-2">
<ButtonStyled color="brand">
<button :disabled="submitDisabled || submitting" @click="submit">
<SendIcon aria-hidden="true" />
{{ submitting ? 'Scheduling...' : 'Schedule transfer' }}
</button>
</ButtonStyled>
<ButtonStyled>
<button @click="modal?.hide?.()">
<XIcon aria-hidden="true" />
Cancel
</button>
</ButtonStyled>
</div>
</div>
</NewModal>
</template>
<script setup lang="ts">
import { PlusIcon, SendIcon, XIcon } from '@modrinth/assets'
import {
ButtonStyled,
Chips,
Combobox,
injectNotificationManager,
NewModal,
TagItem,
Toggle,
} from '@modrinth/ui'
import dayjs from 'dayjs'
import { computed, ref } from 'vue'
import { useServersFetch } from '~/composables/servers/servers-fetch.ts'
const emit = defineEmits<{
success: []
}>()
const { addNotification } = injectNotificationManager()
const modal = ref<InstanceType<typeof NewModal>>()
const modeOptions = [
{ value: 'servers', label: 'Servers' },
{ value: 'nodes', label: 'Nodes' },
]
const mode = ref<string>('servers')
const serverIdsInput = ref('')
const parsedServerIds = computed(() => {
const input = serverIdsInput.value.trim()
if (!input) return []
return input
.split(/[\n,\s]+/)
.map((s) => s.trim())
.filter((s) => s.length > 0)
})
const nodeInput = ref('')
const selectedNodes = ref<string[]>([])
const cordonNodes = ref(true)
const tagNodes = ref('')
type RegionOpt = { value: string; label: string }
const regions = ref<RegionOpt[]>([])
const selectedRegion = ref<string | null>(null)
const nodeHostnames = ref<string[]>([])
const tagInput = ref('')
const selectedTags = ref<string[]>([])
const scheduleOptions: ('now' | 'later')[] = ['now', 'later']
const scheduleOptionLabels: Record<string, string> = {
now: 'Now',
later: 'Schedule for later',
}
const scheduleOption = ref<'now' | 'later'>('now')
const scheduledDate = ref<string>('')
const reason = ref('')
const submitting = ref(false)
function show(event?: MouseEvent) {
void ensureOverview()
mode.value = 'servers'
serverIdsInput.value = ''
selectedNodes.value = []
cordonNodes.value = true
tagNodes.value = `migration${dayjs().format('YYYYMMDD')}`
selectedTags.value = []
tagInput.value = ''
nodeInput.value = ''
scheduleOption.value = 'now'
scheduledDate.value = ''
reason.value = ''
modal.value?.show(event)
}
function hide() {
modal.value?.hide()
}
function addNode() {
const v = nodeInput.value.trim()
if (!v) return
if (!nodeHostnames.value.includes(v)) {
addNotification({
title: 'Unknown node',
text: "This hostname doesn't exist",
type: 'error',
})
return
}
if (!selectedNodes.value.includes(v)) selectedNodes.value.push(v)
nodeInput.value = ''
}
function removeNode(v: string) {
selectedNodes.value = selectedNodes.value.filter((x) => x !== v)
}
function addTag() {
const v = tagInput.value.trim()
if (!v) return
if (!selectedTags.value.includes(v)) selectedTags.value.push(v)
tagInput.value = ''
}
function removeTag(v: string) {
selectedTags.value = selectedTags.value.filter((x) => x !== v)
}
const submitDisabled = computed(() => {
if (!reason.value.trim()) return true
if (mode.value === 'servers') {
if (parsedServerIds.value.length === 0) return true
} else {
if (selectedNodes.value.length === 0) return true
}
if (scheduleOption.value === 'later' && !scheduledDate.value) return true
return false
})
async function ensureOverview() {
if (regions.value.length || nodeHostnames.value.length) return
try {
const data = await useServersFetch<any>('/nodes/overview', { version: 'internal' })
regions.value = (data.regions || []).map((r: any) => ({
value: r.key,
label: `${r.display_name} (${r.key})`,
}))
nodeHostnames.value = data.node_hostnames || []
if (!selectedRegion.value && regions.value.length) {
selectedRegion.value = regions.value[0].value
}
} catch (err) {
addNotification({ title: 'Failed to load nodes overview', text: String(err), type: 'error' })
}
}
async function submit() {
if (submitDisabled.value || submitting.value) return
submitting.value = true
try {
const scheduledAt =
scheduleOption.value === 'now' ? undefined : dayjs(scheduledDate.value).toISOString()
if (mode.value === 'servers') {
await useServersFetch('/transfers/schedule/servers', {
version: 'internal',
method: 'POST',
body: {
server_ids: parsedServerIds.value,
scheduled_at: scheduledAt,
target_region: selectedRegion.value || undefined,
node_tags: selectedTags.value.length > 0 ? selectedTags.value : undefined,
reason: reason.value.trim(),
},
})
} else {
await useServersFetch('/transfers/schedule/nodes', {
version: 'internal',
method: 'POST',
body: {
node_hostnames: selectedNodes.value.slice(),
scheduled_at: scheduledAt,
target_region: selectedRegion.value || undefined,
node_tags: selectedTags.value.length > 0 ? selectedTags.value : undefined,
reason: reason.value.trim(),
cordon_nodes: cordonNodes.value,
tag_nodes: tagNodes.value.trim() || undefined,
},
})
}
addNotification({ title: 'Transfer scheduled', type: 'success' })
emit('success')
modal.value?.hide()
} catch (err: any) {
addNotification({
title: 'Error scheduling transfer',
text: err?.data?.description ?? err?.message ?? String(err),
type: 'error',
})
} finally {
submitting.value = false
}
}
defineExpose({
show,
hide,
})
</script>
@@ -0,0 +1,86 @@
<script setup lang="ts">
import { XIcon } from '@modrinth/assets'
import {
ButtonStyled,
commonMessages,
defineMessages,
IntlFormatted,
normalizeChildren,
PagewideBanner,
useVIntl,
} from '@modrinth/ui'
const { formatMessage } = useVIntl()
const flags = useFeatureFlags()
const config = useRuntimeConfig()
const messages = defineMessages({
title: {
id: 'layout.banner.preview.title',
defaultMessage: `This is a preview deploy of the Modrinth website.`,
},
description: {
id: 'layout.banner.preview.description',
defaultMessage: `If you meant to access the official Modrinth website, visit <link>https://modrinth.com</link>. This preview deploy is used by Modrinth staff for testing purposes. It was built using <branch-link>{owner}/{branch}</branch-link> @ {commit}.`,
},
})
function hidePreviewBanner() {
flags.value.hidePreviewBanner = true
saveFeatureFlags()
}
</script>
<template>
<PagewideBanner v-if="!flags.hidePreviewBanner" variant="info">
<template #title>
<span>{{ formatMessage(messages.title) }}</span>
</template>
<template #description>
<span>
<IntlFormatted
:message-id="messages.description"
:values="{
owner: config.public.owner,
branch: config.public.branch,
}"
>
<template #link="{ children }">
<a href="https://modrinth.com" target="_blank" rel="noopener" class="text-link">
<component :is="() => normalizeChildren(children)" />
</a>
</template>
<template #branch-link="{ children }">
<a
:href="`https://github.com/${config.public.owner}/code/tree/${config.public.branch}`"
target="_blank"
rel="noopener"
class="hover:underline"
>
<component :is="() => normalizeChildren(children)" />
</a>
</template>
<template #commit>
<span v-if="config.public.hash === 'unknown'">unknown</span>
<a
v-else
:href="`https://github.com/${config.public.owner}/code/commit/${config.public.hash}`"
target="_blank"
rel="noopener"
class="text-link"
>
{{ config.public.hash }}
</a>
</template>
</IntlFormatted>
</span>
</template>
<template #actions_right>
<ButtonStyled type="transparent" circular>
<button :aria-label="formatMessage(commonMessages.closeButton)" @click="hidePreviewBanner">
<XIcon aria-hidden="true" />
</button>
</ButtonStyled>
</template>
</PagewideBanner>
</template>
@@ -1,15 +1,28 @@
<template>
<MultiStageModal ref="modal" :stages="ctx.stageConfigs" :context="ctx" />
<MultiStageModal
ref="modal"
:stages="ctx.stageConfigs"
:context="ctx"
:breadcrumbs="!editingVersion"
@hide="() => (modalOpen = false)"
/>
<DropArea
v-if="!modalOpen"
:accept="acceptFileFromProjectType(projectV2.project_type)"
@change="handleDropArea"
/>
</template>
<script setup lang="ts">
import type { Labrinth } from '@modrinth/api-client'
import {
DropArea,
injectModrinthClient,
injectNotificationManager,
injectProjectPageContext,
MultiStageModal,
} from '@modrinth/ui'
import { acceptFileFromProjectType } from '@modrinth/utils'
import type { ComponentExposed } from 'vue-component-type-helpers'
import {
@@ -17,12 +30,17 @@ import {
provideManageVersionContext,
} from '~/providers/version/manage-version-modal'
const modal = useTemplateRef<ComponentExposed<typeof MultiStageModal>>('modal')
const emit = defineEmits<{
(e: 'save'): void
}>()
const ctx = createManageVersionContext(modal)
const modal = useTemplateRef<ComponentExposed<typeof MultiStageModal>>('modal')
const modalOpen = ref(false)
const ctx = createManageVersionContext(modal, () => emit('save'))
provideManageVersionContext(ctx)
const { newDraftVersion } = ctx
const { newDraftVersion, editingVersion, handleNewFiles } = ctx
const { projectV2 } = injectProjectPageContext()
const { addNotification } = injectNotificationManager()
@@ -64,6 +82,15 @@ function openCreateVersionModal(
newDraftVersion(projectV2.value.id, version)
modal.value?.setStage(stageId ?? 0)
modal.value?.show()
modalOpen.value = true
}
async function handleDropArea(files: FileList) {
newDraftVersion(projectV2.value.id, null)
modal.value?.setStage(0)
await handleNewFiles(Array.from(files))
modal.value?.show()
modalOpen.value = true
}
defineExpose({
@@ -1,6 +1,6 @@
<template>
<div
class="flex items-center justify-between gap-2 rounded-xl bg-button-bg px-4 py-1 text-button-text"
class="flex h-11 items-center justify-between gap-2 rounded-xl bg-button-bg px-4 py-1 text-button-text"
>
<div class="grid max-w-[75%] grid-cols-[auto_1fr_auto] items-center gap-2">
<Avatar v-if="icon" :src="icon" alt="dependency-icon" size="20px" :no-shadow="true" />
@@ -9,7 +9,7 @@
{{ name || 'Unknown Project' }}
</span>
<TagItem class="shrink-0 border !border-solid border-surface-5">
<TagItem class="shrink-0 border !border-solid border-surface-5 capitalize">
{{ dependencyType }}
</TagItem>
</div>
@@ -17,14 +17,15 @@
<span
v-if="versionName"
v-tooltip="versionName"
class="max-w-[35%] truncate whitespace-nowrap font-medium"
class="truncate whitespace-nowrap font-medium"
:class="!hideRemove ? 'max-w-[35%]' : 'max-w-[50%]'"
>
{{ versionName }}
</span>
<div class="flex items-center justify-end gap-1">
<div v-if="!hideRemove" class="flex items-center justify-end gap-1">
<ButtonStyled size="standard" :circular="true">
<button aria-label="Remove file" class="!shadow-none" @click="emitRemove">
<button aria-label="Remove file" class="-mr-2 !shadow-none" @click="emitRemove">
<XIcon aria-hidden="true" />
</button>
</ButtonStyled>
@@ -42,12 +43,13 @@ const emit = defineEmits<{
(e: 'remove'): void
}>()
const { projectId, name, icon, dependencyType, versionName } = defineProps<{
const { projectId, name, icon, dependencyType, versionName, hideRemove } = defineProps<{
projectId: string
name?: string
icon?: string
dependencyType: Labrinth.Versions.v2.DependencyType
versionName?: string
hideRemove?: boolean
}>()
function emitRemove() {
@@ -0,0 +1,57 @@
<template>
<div v-if="addedDependencies.length" class="5 flex flex-col gap-2">
<template v-for="(dependency, index) in addedDependencies">
<AddedDependencyRow
v-if="dependency"
:key="index"
:project-id="dependency.projectId"
:name="dependency.name"
:icon="dependency.icon"
:dependency-type="dependency.dependencyType"
:version-name="dependency.versionName"
:hide-remove="disableRemove"
@remove="() => removeDependency(index)"
/>
</template>
<span v-if="!addedDependencies.length"> No dependencies added. </span>
</div>
</template>
<script setup lang="ts">
import { injectManageVersionContext } from '~/providers/version/manage-version-modal'
import AddedDependencyRow from './AddedDependencyRow.vue'
const { disableRemove } = defineProps<{
disableRemove?: boolean
}>()
const { draftVersion, dependencyProjects, dependencyVersions, projectsFetchLoading } =
injectManageVersionContext()
const addedDependencies = computed(() =>
(draftVersion.value.dependencies || [])
.map((dep) => {
if (!dep.project_id) return null
const dependencyProject = dependencyProjects.value[dep.project_id]
const versionName = dependencyVersions.value[dep.version_id || '']?.name ?? ''
if (!dependencyProject && projectsFetchLoading.value) return null
return {
projectId: dep.project_id,
name: dependencyProject?.name,
icon: dependencyProject?.icon_url,
dependencyType: dep.dependency_type,
versionName,
}
})
.filter(Boolean),
)
const removeDependency = (index: number) => {
if (!draftVersion.value.dependencies) return
draftVersion.value.dependencies.splice(index, 1)
}
</script>
@@ -118,4 +118,17 @@ function groupLoaders(loaders: Labrinth.Tags.v2.Loader[]) {
}
const groupedLoaders = computed(() => groupLoaders(loaders))
onMounted(() => {
if (selectedLoaders.value.length === 0) return
// Find the first group that contains any of the selected loaders
const groups = groupedLoaders.value
for (const [groupName, loadersInGroup] of Object.entries(groups)) {
if (loadersInGroup.some((loader) => selectedLoaders.value.includes(loader.name))) {
loaderGroup.value = groupName as GroupLabels
break
}
}
})
</script>
@@ -66,7 +66,7 @@ import type { Labrinth } from '@modrinth/api-client'
import { SearchIcon } from '@modrinth/assets'
import { ButtonStyled, Chips } from '@modrinth/ui'
import { useMagicKeys } from '@vueuse/core'
import { computed, ref } from 'vue'
import { computed, nextTick, onMounted, ref } from 'vue'
type GameVersion = Labrinth.Tags.v2.GameVersion
@@ -147,9 +147,15 @@ function groupVersions(gameVersions: GameVersion[]) {
)
const getGroupKey = (v: string) => v.split('.').slice(0, 2).join('.')
const getSnapshotGroupKey = (v: string) => {
const cleanVersion = v.split('-')[0]
return cleanVersion.split('.').slice(0, 2).join('.')
}
const groups: Record<string, string[]> = {}
let currentGroupKey = getGroupKey(gameVersions.find((v) => v.major)?.version || '')
let currentGroupKey = getSnapshotGroupKey(gameVersions.find((v) => v.major)?.version || '')
gameVersions.forEach((gameVersion) => {
if (gameVersion.version_type === 'release') {
@@ -157,6 +163,8 @@ function groupVersions(gameVersions: GameVersion[]) {
if (!groups[currentGroupKey]) groups[currentGroupKey] = []
groups[currentGroupKey].push(gameVersion.version)
} else {
if (!currentGroupKey) currentGroupKey = getSnapshotGroupKey(gameVersion.version)
const key = `${currentGroupKey} ${DEV_RELEASE_KEY}`
if (!groups[key]) groups[key] = []
groups[key].push(gameVersion.version)
@@ -205,4 +213,27 @@ function compareGroupKeys(a: string, b: string) {
function searchFilter(gameVersion: Labrinth.Tags.v2.GameVersion) {
return gameVersion.version.toLowerCase().includes(searchQuery.value.toLowerCase())
}
onMounted(async () => {
if (props.modelValue.length === 0) return
// Open non-release tab if any non-release versions are selected
const hasNonReleaseVersions = props.gameVersions.some(
(v) => props.modelValue.includes(v.version) && v.version_type !== 'release',
)
if (hasNonReleaseVersions) {
versionType.value = 'all'
}
await nextTick()
const firstSelectedVersion = allVersionsFlat.value.find((v) => props.modelValue.includes(v))
if (firstSelectedVersion) {
const buttons = Array.from(document.querySelectorAll('button'))
const element = buttons.find((btn) => btn.textContent?.trim() === firstSelectedVersion)
if (element) {
element.scrollIntoView({ behavior: 'smooth', block: 'center' })
}
}
})
</script>
@@ -1,27 +1,24 @@
<template>
<div v-if="visibleDependencies.length" class="flex flex-col gap-4">
<span class="font-semibold text-contrast">Suggested dependencies</span>
<div class="flex flex-col gap-2">
<template v-for="(dependency, index) in visibleDependencies">
<SuggestedDependency
v-if="dependency"
:key="index"
:project-id="dependency.project_id"
:name="dependency.name"
:icon="dependency.icon"
:dependency-type="dependency.dependency_type"
:version-name="dependency.versionName"
@on-add-suggestion="
() =>
handleAddSuggestion({
dependency_type: dependency.dependency_type,
project_id: dependency.project_id,
version_id: dependency.version_id,
})
"
/>
</template>
</div>
<div v-if="visibleSuggestedDependencies.length" class="flex flex-col gap-2">
<template v-for="(dependency, index) in visibleSuggestedDependencies">
<SuggestedDependency
v-if="dependency"
:key="index"
:project-id="dependency.project_id"
:name="dependency.name"
:icon="dependency.icon"
:dependency-type="dependency.dependency_type"
:version-name="dependency.versionName"
@on-add-suggestion="
() =>
handleAddSuggestion({
dependency_type: dependency.dependency_type,
project_id: dependency.project_id,
version_id: dependency.version_id,
})
"
/>
</template>
</div>
</template>
@@ -32,28 +29,7 @@ import { injectManageVersionContext } from '~/providers/version/manage-version-m
import SuggestedDependency from './SuggestedDependency.vue'
export interface SuggestedDependency extends Labrinth.Versions.v3.Dependency {
icon?: string
name?: string
versionName?: string
}
const props = defineProps<{
suggestedDependencies: SuggestedDependency[]
}>()
const { draftVersion } = injectManageVersionContext()
const visibleDependencies = computed<SuggestedDependency[]>(() =>
props.suggestedDependencies
.filter(
(dep) =>
!draftVersion.value.dependencies?.some(
(d) => d.project_id === dep.project_id && d.version_id === dep.version_id,
),
)
.sort((a, b) => (a.name || '').localeCompare(b.name || '')),
)
const { visibleSuggestedDependencies } = injectManageVersionContext()
const emit = defineEmits<{
(e: 'onAddSuggestion', dependency: Labrinth.Versions.v3.Dependency): void
@@ -1,6 +1,6 @@
<template>
<div
class="flex items-center justify-between gap-2 rounded-xl bg-button-bg px-4 py-1 text-button-text"
class="flex items-center justify-between gap-2 rounded-xl border-2 border-dashed border-surface-5 px-4 py-1 text-button-text"
>
<div class="grid max-w-[75%] grid-cols-[auto_1fr_auto] items-center gap-2">
<Avatar v-if="icon" :src="icon" alt="dependency-icon" size="20px" :no-shadow="true" />
@@ -9,7 +9,7 @@
{{ name || 'Unknown Project' }}
</span>
<TagItem class="shrink-0 border !border-solid border-surface-5">
<TagItem class="shrink-0 border !border-solid border-surface-5 capitalize">
{{ dependencyType }}
</TagItem>
</div>
@@ -23,7 +23,7 @@
</span>
<div class="flex items-center justify-end gap-1">
<ButtonStyled size="standard" :circular="true">
<ButtonStyled size="standard" :circular="true" type="transparent">
<button aria-label="Add dependency" class="!shadow-none" @click="emitAddSuggestion">
<PlusIcon aria-hidden="true" />
</button>
@@ -68,10 +68,16 @@
import type { Labrinth } from '@modrinth/api-client'
import { ArrowLeftRightIcon, CheckIcon, XIcon } from '@modrinth/assets'
import { ButtonStyled, Combobox, injectProjectPageContext } from '@modrinth/ui'
import type { DropdownOption } from '@modrinth/ui/src/components/base/Combobox.vue'
import type { ComboboxOption } from '@modrinth/ui/src/components/base/Combobox.vue'
import { acceptFileFromProjectType } from '@modrinth/utils'
import {
fileTypeLabels,
injectManageVersionContext,
} from '~/providers/version/manage-version-modal'
const { projectV2 } = injectProjectPageContext()
const { projectType } = injectManageVersionContext()
const emit = defineEmits<{
(e: 'setPrimaryFile', file?: File): void
@@ -89,16 +95,29 @@ const { name, isPrimary, onRemove, initialFileType, editingVersion } = definePro
const selectedType = ref<Labrinth.Versions.v3.FileType | 'primary'>(initialFileType || 'unknown')
const primaryFileInput = ref<HTMLInputElement>()
const versionTypes = [
!editingVersion && { class: 'text-sm', value: 'primary', label: 'Primary' },
{ class: 'text-sm', value: 'unknown', label: 'Other' },
{ class: 'text-sm', value: 'required-resource-pack', label: 'Required RP' },
{ class: 'text-sm', value: 'optional-resource-pack', label: 'Optional RP' },
{ class: 'text-sm', value: 'sources-jar', label: 'Sources JAR' },
{ class: 'text-sm', value: 'dev-jar', label: 'Dev JAR' },
{ class: 'text-sm', value: 'javadoc-jar', label: 'Javadoc JAR' },
{ class: 'text-sm', value: 'signature', label: 'Signature' },
].filter(Boolean) as DropdownOption<Labrinth.Versions.v3.FileType | 'primary'>[]
const isDatapackProject = computed(() => projectType.value === 'datapack')
const versionTypes = computed(
() =>
[
!editingVersion && { class: 'text-sm', value: 'primary', label: fileTypeLabels.primary },
{ class: 'text-sm', value: 'unknown', label: fileTypeLabels.unknown },
isDatapackProject.value && {
class: 'text-sm',
value: 'required-resource-pack',
label: fileTypeLabels['required-resource-pack'],
},
isDatapackProject.value && {
class: 'text-sm',
value: 'optional-resource-pack',
label: fileTypeLabels['optional-resource-pack'],
},
{ class: 'text-sm', value: 'sources-jar', label: fileTypeLabels['sources-jar'] },
{ class: 'text-sm', value: 'dev-jar', label: fileTypeLabels['dev-jar'] },
{ class: 'text-sm', value: 'javadoc-jar', label: fileTypeLabels['javadoc-jar'] },
{ class: 'text-sm', value: 'signature', label: fileTypeLabels.signature },
].filter(Boolean) as ComboboxOption<Labrinth.Versions.v3.FileType | 'primary'>[],
)
function emitFileTypeChange() {
if (selectedType.value === 'primary') emit('setPrimaryFile')
@@ -0,0 +1,32 @@
<template>
<div
class="flex items-center justify-between gap-2 rounded-xl bg-button-bg px-4 py-2 text-button-text"
>
<div class="flex items-center gap-2 overflow-hidden">
<FileIcon v-if="isPrimary" class="text-lg" />
<FilePlusIcon v-else class="text-lg" />
<span v-tooltip="name" class="overflow-hidden text-ellipsis whitespace-nowrap font-medium">
{{ name }}
</span>
<TagItem class="shrink-0 border !border-solid border-surface-5">
{{ isPrimary ? 'Primary' : fileTypeLabels[fileType ?? 'unknown'] }}
</TagItem>
</div>
</div>
</template>
<script setup lang="ts">
import type { Labrinth } from '@modrinth/api-client'
import { FileIcon, FilePlusIcon } from '@modrinth/assets'
import { TagItem } from '@modrinth/ui'
import { fileTypeLabels } from '~/providers/version/manage-version-modal'
const { name, isPrimary, fileType } = defineProps<{
name: string
isPrimary?: boolean
fileType?: Labrinth.Versions.v3.FileType | 'primary'
}>()
</script>
@@ -1,23 +0,0 @@
<template>
<div class="w-full">
<MarkdownEditor
v-model="draftVersion.changelog"
:on-image-upload="onImageUpload"
:max-height="500"
/>
</div>
</template>
<script lang="ts" setup>
import { MarkdownEditor } from '@modrinth/ui'
import { useImageUpload } from '~/composables/image-upload.ts'
import { injectManageVersionContext } from '~/providers/version/manage-version-modal'
const { draftVersion } = injectManageVersionContext()
async function onImageUpload(file: File) {
const response = await useImageUpload(file, { context: 'version' })
return response.url
}
</script>
@@ -1,272 +0,0 @@
<template>
<div class="flex flex-col gap-6 sm:w-[512px]">
<div class="flex flex-col gap-2">
<span class="font-semibold text-contrast">
Version type <span class="text-red">*</span>
</span>
<Chips
v-model="draftVersion.version_type"
:items="['release', 'beta', 'alpha']"
:never-empty="true"
:capitalize="true"
/>
</div>
<div class="flex flex-col gap-2">
<span class="font-semibold text-contrast">
Version number <span class="text-red">*</span>
</span>
<input
id="version-number"
v-model="draftVersion.version_number"
placeholder="Enter version number, e.g. 1.2.3-alpha.1"
type="text"
autocomplete="off"
maxlength="32"
/>
<span> The version number differentiates this specific version from others. </span>
</div>
<div class="flex flex-col gap-2">
<span class="font-semibold text-contrast"> Version subtitle </span>
<input
id="version-number"
v-model="draftVersion.name"
placeholder="Enter subtitle..."
type="text"
autocomplete="off"
maxlength="256"
/>
</div>
<template v-if="!noLoadersProject && (inferredVersionData?.loaders?.length || editingVersion)">
<div class="flex flex-col gap-1">
<div class="flex items-center justify-between">
<span class="font-semibold text-contrast">
{{ usingDetectedLoaders ? 'Detected loaders' : 'Loaders' }}
</span>
<ButtonStyled type="transparent" size="standard">
<button
v-tooltip="isModpack ? 'Modpack versions cannot be edited' : undefined"
:disabled="isModpack"
@click="editLoaders"
>
<EditIcon />
Edit
</button>
</ButtonStyled>
</div>
<div
class="flex flex-col gap-1.5 gap-y-4 rounded-xl border border-solid border-surface-5 p-3 py-4"
>
<div class="flex flex-wrap gap-2">
<template
v-for="loader in draftVersionLoaders.map((selectedLoader) =>
loaders.find((loader) => selectedLoader === loader.name),
)"
>
<TagItem
v-if="loader"
:key="`loader-${loader.name}`"
class="border !border-solid border-surface-5 hover:no-underline"
:style="`--_color: var(--color-platform-${loader.name})`"
>
<div v-html="loader.icon"></div>
{{ formatCategory(loader.name) }}
</TagItem>
</template>
<span v-if="!draftVersion.loaders.length">No loaders selected.</span>
</div>
</div>
</div>
</template>
<template v-if="inferredVersionData?.game_versions?.length || editingVersion">
<div class="flex flex-col gap-1">
<div class="flex items-center justify-between">
<span class="font-semibold text-contrast">
{{ usingDetectedVersions ? 'Detected versions' : 'Versions' }}
</span>
<ButtonStyled type="transparent" size="standard">
<button
v-tooltip="isModpack ? 'Modpack versions cannot be edited' : undefined"
:disabled="isModpack"
@click="editVersions"
>
<EditIcon />
Edit
</button>
</ButtonStyled>
</div>
<div
class="flex max-h-56 flex-col gap-1.5 gap-y-4 overflow-y-auto rounded-xl border border-solid border-surface-5 p-3 py-4"
>
<div class="flex flex-wrap gap-2">
<TagItem
v-for="version in draftVersion.game_versions"
:key="version"
class="border !border-solid border-surface-5 hover:no-underline"
>
{{ version }}
</TagItem>
<span v-if="!draftVersion.game_versions.length">No versions selected.</span>
</div>
</div>
</div>
</template>
<template
v-if="
!noEnvironmentProject &&
((!editingVersion && inferredVersionData?.environment) ||
(editingVersion && draftVersion.environment))
"
>
<div class="flex flex-col gap-1">
<div class="flex items-center justify-between">
<span class="font-semibold text-contrast"> Environment </span>
<ButtonStyled type="transparent" size="standard">
<button @click="editEnvironment">
<EditIcon />
Edit
</button>
</ButtonStyled>
</div>
<div class="flex flex-col gap-1.5 gap-y-4 rounded-xl bg-surface-2 p-3 py-4">
<div v-if="draftVersion.environment" class="flex flex-col gap-1">
<div class="font-semibold text-contrast">
{{ environmentCopy.title }}
</div>
<div class="text-sm font-medium">{{ environmentCopy.description }}</div>
</div>
<span v-else class="text-sm font-medium">No environment has been set.</span>
</div>
</div>
</template>
</div>
</template>
<script lang="ts" setup>
import { EditIcon } from '@modrinth/assets'
import {
ButtonStyled,
Chips,
defineMessages,
ENVIRONMENTS_COPY,
TagItem,
useVIntl,
} from '@modrinth/ui'
import { formatCategory } from '@modrinth/utils'
import { useGeneratedState } from '~/composables/generated'
import { injectManageVersionContext } from '~/providers/version/manage-version-modal'
const {
draftVersion,
inferredVersionData,
projectType,
editingVersion,
noLoadersProject,
noEnvironmentProject,
modal,
} = injectManageVersionContext()
const generatedState = useGeneratedState()
const loaders = computed(() => generatedState.value.loaders)
const isModpack = computed(() => projectType.value === 'modpack')
const draftVersionLoaders = computed(() =>
[
...new Set([...draftVersion.value.loaders, ...(draftVersion.value.mrpack_loaders ?? [])]),
].filter((loader) => loader !== 'mrpack'),
)
const editLoaders = () => {
modal.value?.setStage('from-details-loaders')
}
const editVersions = () => {
modal.value?.setStage('from-details-mc-versions')
}
const editEnvironment = () => {
modal.value?.setStage('from-details-environment')
}
const usingDetectedVersions = computed(() => {
if (!inferredVersionData.value?.game_versions) return false
const versionsMatch =
draftVersion.value.game_versions.length === inferredVersionData.value.game_versions.length &&
draftVersion.value.game_versions.every((version) =>
inferredVersionData.value?.game_versions?.includes(version),
)
return versionsMatch
})
const usingDetectedLoaders = computed(() => {
if (!inferredVersionData.value?.loaders) return false
const loadersMatch =
draftVersion.value.loaders.length === inferredVersionData.value.loaders.length &&
draftVersion.value.loaders.every((loader) =>
inferredVersionData.value?.loaders?.includes(loader),
)
return loadersMatch
})
const { formatMessage } = useVIntl()
const noEnvironmentMessage = defineMessages({
title: {
id: 'version.environment.none.title',
defaultMessage: 'No environment set',
},
description: {
id: 'version.environment.none.description',
defaultMessage: 'The environment for this version has not been specified.',
},
})
const unknownEnvironmentMessage = defineMessages({
title: {
id: 'version.environment.unknown.title',
defaultMessage: 'Unknown environment',
},
description: {
id: 'version.environment.unknown.description',
defaultMessage: 'The environment: "{environment}" is not recognized.',
},
})
const environmentCopy = computed(() => {
if (!draftVersion.value.environment) {
return {
title: formatMessage(noEnvironmentMessage.title),
description: formatMessage(noEnvironmentMessage.description),
}
}
const envCopy = ENVIRONMENTS_COPY[draftVersion.value.environment]
if (envCopy) {
return {
title: formatMessage(envCopy.title),
description: formatMessage(envCopy.description),
}
}
return {
title: formatMessage(unknownEnvironmentMessage.title),
description: formatMessage(unknownEnvironmentMessage.description, {
environment: draftVersion.value.environment,
}),
}
})
</script>
@@ -1,11 +1,15 @@
<template>
<div class="flex w-full flex-col gap-4 sm:w-[512px]">
<template v-if="!(filesToAdd.length || draftVersion.existing_files?.length)">
<div class="flex w-full flex-col gap-4">
<template
v-if="handlingNewFiles || !(filesToAdd.length || draftVersion.existing_files?.length)"
>
<DropzoneFileInput
aria-label="Upload file"
multiple
:accept="acceptFileFromProjectType(projectV2.project_type)"
:max-size="524288000"
primary-prompt="Upload primary and supporting files"
secondary-prompt="Drag and drop files or click to browse"
@change="handleNewFiles"
/>
</template>
@@ -21,11 +25,7 @@
:is-primary="true"
:editing-version="editingVersion"
:on-remove="undefined"
@set-primary-file="
(file) => {
if (file && !editingVersion) filesToAdd[0] = { file }
}
"
@set-primary-file="(file) => file && replacePrimaryFile(file)"
/>
</div>
<span>
@@ -72,7 +72,7 @@
:editing-version="editingVersion"
:on-remove="() => handleRemoveFile(idx + (primaryFile?.existing ? 0 : 1))"
@set-file-type="(type) => (versionFile.fileType = type)"
@set-primary-file="handleSetPrimaryFile(idx + (primaryFile?.existing ? 0 : 1))"
@set-primary-file="() => swapPrimaryFile(idx + (primaryFile?.existing ? 0 : 1))"
/>
</div>
</div>
@@ -86,7 +86,6 @@
</template>
<script lang="ts" setup>
import type { Labrinth } from '@modrinth/api-client'
import {
Admonition,
defineMessages,
@@ -107,68 +106,18 @@ const {
draftVersion,
filesToAdd,
existingFilesToDelete,
setPrimaryFile,
setInferredVersionData,
handlingNewFiles,
swapPrimaryFile,
replacePrimaryFile,
editingVersion,
primaryFile,
handleNewFiles,
} = injectManageVersionContext()
const addDetectedData = async () => {
if (editingVersion.value) return
const primaryFile = filesToAdd.value[0]?.file
if (!primaryFile) return
try {
const inferredData = await setInferredVersionData(primaryFile, projectV2.value)
const mappedInferredData: Partial<Labrinth.Versions.v3.DraftVersion> = {
...inferredData,
name: inferredData.name || '',
}
draftVersion.value = {
...draftVersion.value,
...mappedInferredData,
}
} catch (err) {
console.error('Error parsing version file data', err)
}
}
// add detected data when the primary file changes
watch(
() => filesToAdd.value[0]?.file,
() => addDetectedData(),
)
function handleNewFiles(newFiles: File[]) {
// detect primary file if no primary file is set
const primaryFileIndex = primaryFile.value ? null : detectPrimaryFileIndex(newFiles)
newFiles.forEach((file) => filesToAdd.value.push({ file }))
if (primaryFileIndex !== null) {
if (primaryFileIndex) setPrimaryFile(primaryFileIndex)
}
}
function handleRemoveFile(index: number) {
filesToAdd.value.splice(index, 1)
}
function detectPrimaryFileIndex(files: File[]): number {
const extensionPriority = ['.jar', '.zip', '.litemod', '.mrpack', '.mrpack-primary']
for (const ext of extensionPriority) {
const matches = files.filter((file) => file.name.toLowerCase().endsWith(ext))
if (matches.length > 0) {
const shortest = matches.reduce((a, b) => (a.name.length < b.name.length ? a : b))
return files.indexOf(shortest)
}
}
return 0
}
function handleRemoveExistingFile(sha1: string) {
existingFilesToDelete.value.push(sha1)
draftVersion.value.existing_files = draftVersion.value.existing_files?.filter(
@@ -176,38 +125,6 @@ function handleRemoveExistingFile(sha1: string) {
)
}
function handleSetPrimaryFile(index: number) {
setPrimaryFile(index)
}
interface PrimaryFile {
name: string
fileType?: string
existing?: boolean
}
const primaryFile = computed<PrimaryFile | null>(() => {
const existingPrimaryFile = draftVersion.value.existing_files?.[0]
if (existingPrimaryFile) {
return {
name: existingPrimaryFile.filename,
fileType: existingPrimaryFile.file_type,
existing: true,
}
}
const addedPrimaryFile = filesToAdd.value[0]
if (addedPrimaryFile) {
return {
name: addedPrimaryFile.file.name,
fileType: addedPrimaryFile.fileType,
existing: false,
}
}
return null
})
const supplementaryNewFiles = computed(() => {
if (primaryFile.value?.existing) {
return filesToAdd.value
@@ -1,10 +1,10 @@
<template>
<div class="flex w-full max-w-full flex-col gap-6 sm:w-[512px]">
<div class="flex w-full max-w-full flex-col gap-6">
<div class="flex flex-col gap-4">
<span class="font-semibold text-contrast">Add dependency</span>
<div class="flex flex-col gap-3 rounded-2xl border border-solid border-surface-5 p-4">
<div class="grid gap-2.5">
<span class="font-semibold text-contrast">Project <span class="text-red">*</span></span>
<span class="font-semibold text-contrast">Project</span>
<DependencySelect v-model="newDependencyProjectId" />
</div>
@@ -33,7 +33,7 @@
/>
</div>
<ButtonStyled>
<ButtonStyled color="green">
<button
class="self-start"
:disabled="!newDependencyProjectId"
@@ -55,28 +55,14 @@
</div>
</div>
<SuggestedDependencies
:suggested-dependencies="suggestedDependencies"
@on-add-suggestion="handleAddSuggestedDependency"
/>
<div v-if="visibleSuggestedDependencies.length" class="flex flex-col gap-4">
<span class="font-semibold text-contrast">Suggested dependencies</span>
<SuggestedDependencies @on-add-suggestion="handleAddSuggestedDependency" />
</div>
<div v-if="addedDependencies.length" class="flex flex-col gap-4">
<span class="font-semibold text-contrast">Added dependencies</span>
<div class="5 flex flex-col gap-2">
<template v-for="(dependency, index) in addedDependencies">
<AddedDependencyRow
v-if="dependency"
:key="index"
:project-id="dependency.projectId"
:name="dependency.name"
:icon="dependency.icon"
:dependency-type="dependency.dependencyType"
:version-name="dependency.versionName"
@remove="() => removeDependency(index)"
/>
</template>
<span v-if="!addedDependencies.length"> No dependencies added. </span>
</div>
<DependenciesList />
</div>
</div>
</template>
@@ -88,19 +74,26 @@ import {
Combobox,
injectModrinthClient,
injectNotificationManager,
injectProjectPageContext,
} from '@modrinth/ui'
import type { DropdownOption } from '@modrinth/ui/src/components/base/Combobox.vue'
import type { ComboboxOption } from '@modrinth/ui/src/components/base/Combobox.vue'
import DependencySelect from '~/components/ui/create-project-version/components/DependencySelect.vue'
import { injectManageVersionContext } from '~/providers/version/manage-version-modal'
import AddedDependencyRow from '../components/AddedDependencyRow.vue'
import DependenciesList from '../components/DependenciesList.vue'
import SuggestedDependencies from '../components/SuggestedDependencies/SuggestedDependencies.vue'
const { addNotification } = injectNotificationManager()
const { labrinth } = injectModrinthClient()
const {
draftVersion,
dependencyProjects,
dependencyVersions,
projectsFetchLoading,
visibleSuggestedDependencies,
} = injectManageVersionContext()
const errorNotification = (err: any) => {
addNotification({
title: 'An error occurred',
@@ -113,12 +106,7 @@ const newDependencyProjectId = ref<string>()
const newDependencyType = ref<Labrinth.Versions.v2.DependencyType>('required')
const newDependencyVersionId = ref<string | null>(null)
const newDependencyVersions = ref<DropdownOption<string>[]>([])
const projectsFetchLoading = ref(false)
const suggestedDependencies = ref<
Array<Labrinth.Versions.v3.Dependency & { name?: string; icon?: string; versionName?: string }>
>([])
const newDependencyVersions = ref<ComboboxOption<string>[]>([])
// reset to defaults when select different project
watch(newDependencyProjectId, async () => {
@@ -140,91 +128,6 @@ watch(newDependencyProjectId, async () => {
}
})
const { draftVersion, dependencyProjects, dependencyVersions, getProject, getVersion } =
injectManageVersionContext()
const { projectV2: project } = injectProjectPageContext()
const getSuggestedDependencies = async () => {
try {
suggestedDependencies.value = []
if (!draftVersion.value.game_versions?.length || !draftVersion.value.loaders?.length) {
return
}
try {
const versions = await labrinth.versions_v3.getProjectVersions(project.value.id, {
loaders: draftVersion.value.loaders,
})
// Get the most recent matching version and extract its dependencies
if (versions.length > 0) {
const mostRecentVersion = versions[0]
for (const dep of mostRecentVersion.dependencies) {
suggestedDependencies.value.push({
project_id: dep.project_id,
version_id: dep.version_id,
dependency_type: dep.dependency_type,
file_name: dep.file_name,
})
}
}
} catch (error: any) {
console.error(`Failed to get versions for project ${project.value.id}:`, error)
}
for (const dep of suggestedDependencies.value) {
try {
if (dep.project_id) {
const proj = await getProject(dep.project_id)
dep.name = proj.name
dep.icon = proj.icon_url
}
if (dep.version_id) {
const version = await getVersion(dep.version_id)
dep.versionName = version.name
}
} catch (error: any) {
console.error(`Failed to fetch project/version data for dependency:`, error)
}
}
} catch (error: any) {
errorNotification(error)
}
}
onMounted(() => {
getSuggestedDependencies()
})
watch(
draftVersion,
async (draftVersion) => {
const deps = draftVersion.dependencies || []
for (const dep of deps) {
if (dep?.project_id) {
try {
await getProject(dep.project_id)
} catch (error: any) {
errorNotification(error)
}
}
if (dep?.version_id) {
try {
await getVersion(dep.version_id)
} catch (error: any) {
errorNotification(error)
}
}
}
projectsFetchLoading.value = false
},
{ immediate: true, deep: true },
)
const addedDependencies = computed(() =>
(draftVersion.value.dependencies || [])
.map((dep) => {
@@ -249,12 +152,13 @@ const addedDependencies = computed(() =>
const addDependency = (dependency: Labrinth.Versions.v3.Dependency) => {
if (!draftVersion.value.dependencies) draftVersion.value.dependencies = []
// already added
if (
draftVersion.value.dependencies.find(
(d) => d.project_id === dependency.project_id && d.version_id === dependency.version_id,
)
) {
const alreadyAdded = draftVersion.value.dependencies.some((existing) => {
if (existing.project_id !== dependency.project_id) return false
if (!existing.version_id && !dependency.version_id) return true
return existing.version_id === dependency.version_id
})
if (alreadyAdded) {
addNotification({
title: 'Dependency already added',
text: 'You cannot add the same dependency twice.',
@@ -268,11 +172,6 @@ const addDependency = (dependency: Labrinth.Versions.v3.Dependency) => {
newDependencyProjectId.value = undefined
}
const removeDependency = (index: number) => {
if (!draftVersion.value.dependencies) return
draftVersion.value.dependencies.splice(index, 1)
}
const handleAddSuggestedDependency = (dependency: Labrinth.Versions.v3.Dependency) => {
draftVersion.value.dependencies?.push({
project_id: dependency.project_id,
@@ -0,0 +1,69 @@
<template>
<div class="flex w-full flex-col gap-6">
<div class="flex flex-col gap-2">
<span class="font-semibold text-contrast">
Version type <span class="text-red">*</span>
</span>
<Chips
v-model="draftVersion.version_type"
:items="['release', 'beta', 'alpha']"
:never-empty="true"
:capitalize="true"
:disabled="isUploading"
/>
</div>
<div class="flex flex-col gap-2">
<span class="font-semibold text-contrast">
Version number <span class="text-red">*</span>
</span>
<input
id="version-number"
v-model="draftVersion.version_number"
:disabled="isUploading"
placeholder="Enter version number, e.g. 1.2.3-alpha.1"
type="text"
autocomplete="off"
maxlength="32"
/>
<span> The version number differentiates this specific version from others. </span>
</div>
<div class="flex flex-col gap-2">
<span class="font-semibold text-contrast"> Version subtitle </span>
<input
id="version-number"
v-model="draftVersion.name"
placeholder="Enter subtitle..."
type="text"
autocomplete="off"
maxlength="256"
:disabled="isUploading"
/>
</div>
<div class="flex flex-col gap-2">
<span class="font-semibold text-contrast"> Version changlog </span>
<div class="w-full">
<MarkdownEditor
v-model="draftVersion.changelog"
:on-image-upload="onImageUpload"
:min-height="150"
:disabled="isUploading"
/>
</div>
</div>
</div>
</template>
<script lang="ts" setup>
import { Chips, MarkdownEditor } from '@modrinth/ui'
import { useImageUpload } from '~/composables/image-upload.ts'
import { injectManageVersionContext } from '~/providers/version/manage-version-modal'
const { draftVersion, isUploading } = injectManageVersionContext()
async function onImageUpload(file: File) {
const response = await useImageUpload(file, { context: 'version' })
return response.url
}
</script>
@@ -1,5 +1,5 @@
<template>
<div class="space-y-6 sm:w-[512px]">
<div class="space-y-6">
<LoaderPicker
v-model="draftVersion.loaders"
:loaders="generatedState.loaders"
@@ -1,5 +1,5 @@
<template>
<div class="flex flex-col gap-6 sm:w-[512px]">
<div class="flex flex-col gap-6">
<McVersionPicker v-model="draftVersion.game_versions" :game-versions="gameVersions" />
<div v-if="draftVersion.game_versions.length" class="space-y-1">
<div class="flex items-center justify-between">
@@ -0,0 +1,380 @@
<template>
<div class="flex flex-col gap-6">
<div v-if="!editingVersion" class="flex flex-col gap-1">
<div class="flex items-center justify-between">
<span class="font-semibold text-contrast"> Uploaded files </span>
<ButtonStyled type="transparent" size="standard">
<button @click="editFiles">
<EditIcon />
Edit
</button>
</ButtonStyled>
</div>
<div class="flex flex-col gap-2.5">
<ViewOnlyFileRow
v-if="primaryFile"
:key="primaryFile.name"
:name="primaryFile.name"
:is-primary="true"
/>
<ViewOnlyFileRow
v-for="file in supplementaryNewFiles"
:key="file.file.name"
:name="file.file.name"
:file-type="file.fileType"
/>
<ViewOnlyFileRow
v-for="file in supplementaryExistingFiles"
:key="file.filename"
:name="file.filename"
:file-type="file.file_type"
/>
</div>
</div>
<div class="flex flex-col gap-1">
<div class="flex items-center justify-between">
<span class="font-semibold text-contrast">
{{ usingDetectedLoaders ? 'Detected loaders' : 'Loaders' }}
</span>
<ButtonStyled type="transparent" size="standard">
<button
v-tooltip="
isModpack
? 'Modpack loaders cannot be edited'
: isResourcePack
? 'Resource pack loaders cannot be edited'
: undefined
"
:disabled="isModpack || isResourcePack"
@click="editLoaders"
>
<EditIcon />
Edit
</button>
</ButtonStyled>
</div>
<div
class="flex flex-col gap-1.5 gap-y-4 rounded-xl border border-solid border-surface-5 p-3 py-4"
>
<div class="flex flex-wrap gap-2">
<template
v-for="loader in draftVersionLoaders.map((selectedLoader) =>
loaders.find((loader) => selectedLoader === loader.name),
)"
>
<TagItem
v-if="loader"
:key="`loader-${loader.name}`"
class="border !border-solid border-surface-5 hover:no-underline"
:style="`--_color: var(--color-platform-${loader.name})`"
>
<div v-html="loader.icon"></div>
{{ formatCategory(loader.name) }}
</TagItem>
</template>
<span v-if="!draftVersion.loaders.length">No loaders selected.</span>
</div>
</div>
</div>
<div class="flex flex-col gap-1">
<div class="flex items-center justify-between">
<span class="font-semibold text-contrast">
{{ usingDetectedVersions ? 'Detected versions' : 'Versions' }}
</span>
<ButtonStyled type="transparent" size="standard">
<button
v-tooltip="isModpack ? 'Modpack versions cannot be edited' : undefined"
:disabled="isModpack"
@click="editVersions"
>
<EditIcon />
Edit
</button>
</ButtonStyled>
</div>
<div
class="flex max-h-56 flex-col gap-1.5 gap-y-4 overflow-y-auto rounded-xl border border-solid border-surface-5 p-3 py-4"
>
<div class="flex flex-wrap gap-2">
<TagItem
v-for="version in draftVersion.game_versions"
:key="version"
class="border !border-solid border-surface-5 hover:no-underline"
>
{{ version }}
</TagItem>
<span v-if="!draftVersion.game_versions.length">No versions selected.</span>
</div>
</div>
</div>
<template v-if="!noEnvironmentProject">
<div class="flex flex-col gap-1">
<div class="flex items-center justify-between">
<div class="flex items-center gap-2">
<span class="font-semibold text-contrast"> Environment </span>
<UnknownIcon v-tooltip="'Pre-filled from a previous similar version'" />
</div>
<ButtonStyled type="transparent" size="standard">
<button @click="editEnvironment">
<EditIcon />
Edit
</button>
</ButtonStyled>
</div>
<div class="flex flex-col gap-1.5 gap-y-4 rounded-xl bg-surface-2 p-3 py-4">
<div v-if="draftVersion.environment" class="flex flex-col gap-1">
<div class="font-semibold text-contrast">
{{ environmentCopy.title }}
</div>
<div class="text-sm font-medium">{{ environmentCopy.description }}</div>
</div>
<span v-else class="text-sm font-medium">No environment has been set.</span>
</div>
</div>
</template>
<template v-if="!noDependenciesProject">
<div v-if="visibleSuggestedDependencies.length" class="flex flex-col gap-1">
<div class="flex items-center justify-between">
<span class="font-semibold text-contrast"> Suggested dependencies </span>
<ButtonStyled type="transparent" size="standard">
<button @click="editDependencies">
<EditIcon />
Edit
</button>
</ButtonStyled>
</div>
<SuggestedDependencies @on-add-suggestion="handleAddSuggestedDependency" />
</div>
<div
v-if="!visibleSuggestedDependencies.length || draftVersion.dependencies?.length"
class="flex flex-col gap-1"
>
<div class="flex items-center justify-between">
<span class="font-semibold text-contrast"> Dependencies </span>
<ButtonStyled type="transparent" size="standard">
<button @click="editDependencies">
<EditIcon />
Edit
</button>
</ButtonStyled>
</div>
<div v-if="draftVersion.dependencies?.length" class="flex flex-col gap-4">
<DependenciesList disable-remove />
</div>
<div v-else class="flex flex-col gap-1.5 gap-y-4 rounded-xl bg-surface-2 p-3 py-4">
<span class="text-sm font-medium">No dependencies added.</span>
</div>
</div>
</template>
</div>
</template>
<script lang="ts" setup>
import type { Labrinth } from '@modrinth/api-client'
import { EditIcon, UnknownIcon } from '@modrinth/assets'
import {
ButtonStyled,
defineMessages,
ENVIRONMENTS_COPY,
injectProjectPageContext,
TagItem,
useVIntl,
} from '@modrinth/ui'
import { formatCategory } from '@modrinth/utils'
import { useGeneratedState } from '~/composables/generated'
import { injectManageVersionContext } from '~/providers/version/manage-version-modal'
import DependenciesList from '../components/DependenciesList.vue'
import SuggestedDependencies from '../components/SuggestedDependencies/SuggestedDependencies.vue'
import ViewOnlyFileRow from '../components/ViewOnlyFileRow.vue'
const {
draftVersion,
inferredVersionData,
projectType,
noEnvironmentProject,
noDependenciesProject,
modal,
filesToAdd,
editingVersion,
visibleSuggestedDependencies,
} = injectManageVersionContext()
const { projectV2 } = injectProjectPageContext()
const generatedState = useGeneratedState()
const loaders = computed(() => generatedState.value.loaders)
const isModpack = computed(() => projectType.value === 'modpack')
const isResourcePack = computed(
() =>
projectType.value === 'resourcepack' &&
(projectV2.value?.project_type === 'resourcepack' ||
projectV2.value?.project_type === 'project'),
)
const draftVersionLoaders = computed(() =>
[
...new Set([...draftVersion.value.loaders, ...(draftVersion.value.mrpack_loaders ?? [])]),
].filter((loader) => loader !== 'mrpack'),
)
const editLoaders = () => {
modal.value?.setStage('from-details-loaders')
}
const editVersions = () => {
modal.value?.setStage('from-details-mc-versions')
}
const editEnvironment = () => {
modal.value?.setStage('from-details-environment')
}
const editFiles = () => {
modal.value?.setStage('from-details-files')
}
const editDependencies = () => {
modal.value?.setStage('from-details-dependencies')
}
const usingDetectedVersions = computed(() => {
if (!inferredVersionData.value?.game_versions) return false
const versionsMatch =
draftVersion.value.game_versions.length === inferredVersionData.value.game_versions.length &&
draftVersion.value.game_versions.every((version) =>
inferredVersionData.value?.game_versions?.includes(version),
)
return versionsMatch
})
const usingDetectedLoaders = computed(() => {
if (!inferredVersionData.value?.loaders) return false
const loadersMatch =
draftVersion.value.loaders.length === inferredVersionData.value.loaders.length &&
draftVersion.value.loaders.every((loader) =>
inferredVersionData.value?.loaders?.includes(loader),
)
return loadersMatch
})
interface PrimaryFile {
name: string
fileType?: string
existing?: boolean
}
const primaryFile = computed<PrimaryFile | null>(() => {
const existingPrimaryFile = draftVersion.value.existing_files?.[0]
if (existingPrimaryFile) {
return {
name: existingPrimaryFile.filename,
fileType: existingPrimaryFile.file_type,
existing: true,
}
}
const addedPrimaryFile = filesToAdd.value[0]
if (addedPrimaryFile) {
return {
name: addedPrimaryFile.file.name,
fileType: addedPrimaryFile.fileType,
existing: false,
}
}
return null
})
const supplementaryNewFiles = computed(() => {
if (primaryFile.value?.existing) {
return filesToAdd.value
} else {
return filesToAdd.value.slice(1)
}
})
const supplementaryExistingFiles = computed(() => {
if (primaryFile.value?.existing) {
return draftVersion.value.existing_files?.slice(1)
} else {
return draftVersion.value.existing_files
}
})
const { formatMessage } = useVIntl()
const noEnvironmentMessage = defineMessages({
title: {
id: 'version.environment.none.title',
defaultMessage: 'No environment set',
},
description: {
id: 'version.environment.none.description',
defaultMessage: 'The environment for this version has not been specified.',
},
})
const unknownEnvironmentMessage = defineMessages({
title: {
id: 'version.environment.unknown.title',
defaultMessage: 'Unknown environment',
},
description: {
id: 'version.environment.unknown.description',
defaultMessage: 'The environment: "{environment}" is not recognized.',
},
})
const environmentCopy = computed(() => {
if (!draftVersion.value.environment) {
return {
title: formatMessage(noEnvironmentMessage.title),
description: formatMessage(noEnvironmentMessage.description),
}
}
const envCopy = ENVIRONMENTS_COPY[draftVersion.value.environment]
if (envCopy) {
return {
title: formatMessage(envCopy.title),
description: formatMessage(envCopy.description),
}
}
return {
title: formatMessage(unknownEnvironmentMessage.title),
description: formatMessage(unknownEnvironmentMessage.description, {
environment: draftVersion.value.environment,
}),
}
})
const handleAddSuggestedDependency = (dependency: Labrinth.Versions.v3.Dependency) => {
if (!draftVersion.value.dependencies) draftVersion.value.dependencies = []
draftVersion.value.dependencies.push({
project_id: dependency.project_id,
version_id: dependency.version_id,
dependency_type: dependency.dependency_type,
})
}
</script>
@@ -12,14 +12,14 @@
<div class="flex items-center justify-between">
<span class="text-primary">{{ formatMessage(messages.feeBreakdownGiftCardValue) }}</span>
<span class="font-semibold text-contrast"
>{{ formatMoney(amount || 0) }} ({{ formattedLocalCurrency }})</span
>{{ formatMoney(amountInUsd) }} ({{ formattedLocalCurrencyAmount }})</span
>
</div>
</template>
<template v-else>
<div class="flex items-center justify-between">
<span class="text-primary">{{ formatMessage(messages.feeBreakdownAmount) }}</span>
<span class="font-semibold text-contrast">{{ formatMoney(amount || 0) }}</span>
<span class="font-semibold text-contrast">{{ formatMoney(amountInUsd) }}</span>
</div>
</template>
@@ -29,7 +29,7 @@
<template v-if="feeLoading">
<LoaderCircleIcon class="size-5 animate-spin !text-secondary" />
</template>
<template v-else>-{{ formatMoney(fee || 0) }}</template>
<template v-else>-{{ formatMoney(feeInUsd) }}</template>
</span>
</div>
@@ -79,9 +79,23 @@ const props = withDefaults(
const { formatMessage } = useVIntl()
const amountInUsd = computed(() => {
if (props.isGiftCard && shouldShowExchangeRate.value) {
return (props.amount || 0) / (props.exchangeRate || 1)
}
return props.amount || 0
})
const feeInUsd = computed(() => {
if (props.isGiftCard && shouldShowExchangeRate.value) {
return (props.fee || 0) / (props.exchangeRate || 1)
}
return props.fee || 0
})
const netAmount = computed(() => {
const amount = props.amount || 0
const fee = props.fee || 0
const amount = amountInUsd.value
const fee = feeInUsd.value
return Math.max(0, amount - fee)
})
@@ -96,6 +110,11 @@ const netAmountInLocalCurrency = computed(() => {
return netAmount.value * (props.exchangeRate || 0)
})
const localCurrencyAmount = computed(() => {
if (!shouldShowExchangeRate.value) return null
return props.amount || 0
})
const formattedLocalCurrency = computed(() => {
if (!shouldShowExchangeRate.value || !netAmountInLocalCurrency.value || !props.localCurrency)
return ''
@@ -112,6 +131,21 @@ const formattedLocalCurrency = computed(() => {
}
})
const formattedLocalCurrencyAmount = computed(() => {
if (!shouldShowExchangeRate.value || !localCurrencyAmount.value || !props.localCurrency) return ''
try {
return new Intl.NumberFormat('en-US', {
style: 'currency',
currency: props.localCurrency,
minimumFractionDigits: 2,
maximumFractionDigits: 2,
}).format(localCurrencyAmount.value)
} catch {
return `${props.localCurrency} ${localCurrencyAmount.value.toFixed(2)}`
}
})
const messages = defineMessages({
feeBreakdownAmount: {
id: 'dashboard.creator-withdraw-modal.fee-breakdown-amount',
@@ -90,7 +90,14 @@
</Combobox>
</div>
<span v-if="selectedMethodDetails" class="text-secondary">
{{ formatMoney(fixedDenominationMin ?? effectiveMinAmount)
{{
formatMoney(
selectedMethodCurrencyCode &&
selectedMethodCurrencyCode !== 'USD' &&
selectedMethodExchangeRate
? (fixedDenominationMin ?? effectiveMinAmount) / selectedMethodExchangeRate
: (fixedDenominationMin ?? effectiveMinAmount),
)
}}<template v-if="selectedMethodCurrencyCode && selectedMethodCurrencyCode !== 'USD'">
({{
formatAmountForDisplay(
@@ -103,9 +110,15 @@
min,
{{
formatMoney(
fixedDenominationMax ??
selectedMethodDetails.interval?.standard?.max ??
effectiveMaxAmount,
selectedMethodCurrencyCode &&
selectedMethodCurrencyCode !== 'USD' &&
selectedMethodExchangeRate
? (fixedDenominationMax ??
selectedMethodDetails.interval?.standard?.max ??
effectiveMaxAmount) / selectedMethodExchangeRate
: (fixedDenominationMax ??
selectedMethodDetails.interval?.standard?.max ??
effectiveMaxAmount),
)
}}<template v-if="selectedMethodCurrencyCode && selectedMethodCurrencyCode !== 'USD'">
({{
@@ -124,7 +137,15 @@
v-if="selectedMethodDetails && effectiveMinAmount > roundedMaxAmount"
class="text-sm text-red"
>
You need at least {{ formatMoney(effectiveMinAmount)
You need at least
{{
formatMoney(
selectedMethodCurrencyCode &&
selectedMethodCurrencyCode !== 'USD' &&
selectedMethodExchangeRate
? effectiveMinAmount / selectedMethodExchangeRate
: effectiveMinAmount,
)
}}<template v-if="selectedMethodCurrencyCode && selectedMethodCurrencyCode !== 'USD'">
({{
formatAmountForDisplay(
@@ -186,7 +207,7 @@
formatMessage(messages.balanceWorthHint, {
usdBalance: formatMoney(roundedMaxAmount),
localBalance: formatAmountForDisplay(
roundedMaxAmount,
roundedMaxAmount * selectedMethodExchangeRate,
selectedMethodCurrencyCode,
selectedMethodExchangeRate,
),
@@ -252,7 +273,7 @@
formatMessage(messages.balanceWorthHint, {
usdBalance: formatMoney(roundedMaxAmount),
localBalance: formatAmountForDisplay(
roundedMaxAmount,
roundedMaxAmount * selectedMethodExchangeRate,
selectedMethodCurrencyCode,
selectedMethodExchangeRate,
),
@@ -573,14 +594,13 @@ const giftCardExchangeRate = computed(() => {
})
function formatAmountForDisplay(
usdAmount: number,
localAmount: number,
currencyCode: string | null | undefined,
rate: number | null | undefined,
): string {
if (!currencyCode || currencyCode === 'USD' || !rate) {
return formatMoney(usdAmount)
return formatMoney(localAmount)
}
const localAmount = usdAmount * rate
try {
return new Intl.NumberFormat('en-US', {
style: 'currency',
@@ -1,6 +1,6 @@
<script setup lang="ts">
import type { Labrinth } from '@modrinth/api-client'
import { ClipboardCopyIcon, LoaderCircleIcon, XIcon } from '@modrinth/assets'
import { ClipboardCopyIcon, DownloadIcon, LoaderCircleIcon, XIcon } from '@modrinth/assets'
import { ButtonStyled, CopyCode, NewModal } from '@modrinth/ui'
import { ref, useTemplateRef } from 'vue'
@@ -38,15 +38,16 @@ async function fetchVersionHashes(versionIds: string[]) {
// TODO: switch to api-client once truman's vers stuff is merged
const version = (await useBaseFetch(`version/${versionId}`)) as {
files: Array<{
id?: string
filename: string
file_name?: string
hashes: { sha512: string; sha1: string }
}>
}
const filesMap = new Map<string, string>()
for (const file of version.files) {
const name = file.file_name ?? file.filename
filesMap.set(name, file.hashes.sha512)
if (file.id) {
filesMap.set(file.id, file.hashes.sha512)
}
}
versionDataCache.value.set(versionId, { files: filesMap, loading: false })
} catch (error) {
@@ -60,8 +61,8 @@ async function fetchVersionHashes(versionIds: string[]) {
}
}
function getFileHash(versionId: string, fileName: string): string | undefined {
return versionDataCache.value.get(versionId)?.files.get(fileName)
function getFileHash(versionId: string, fileId: string): string | undefined {
return versionDataCache.value.get(versionId)?.files.get(fileId)
}
function isHashLoading(versionId: string): boolean {
@@ -114,6 +115,7 @@ defineExpose({ show, hide })
<th class="pb-2">Version ID</th>
<th class="pb-2">File Name</th>
<th class="pb-2">CDN Link</th>
<th class="pb-2">Download</th>
</tr>
</thead>
<tbody>
@@ -124,11 +126,11 @@ defineExpose({ show, hide })
class="size-4 animate-spin text-secondary"
/>
<ButtonStyled
v-else-if="getFileHash(item.file.version_id, item.file.file_name)"
v-else-if="getFileHash(item.file.version_id, item.file.file_id)"
size="small"
type="standard"
>
<button @click="copy(getFileHash(item.file.version_id, item.file.file_name)!)">
<button @click="copy(getFileHash(item.file.version_id, item.file.file_id)!)">
<ClipboardCopyIcon class="size-4" />
Copy
</button>
@@ -141,7 +143,7 @@ defineExpose({ show, hide })
<td class="py-1 pr-2">
<CopyCode :text="item.file.file_name" />
</td>
<td class="py-1">
<td class="py-1 pr-2">
<ButtonStyled size="small" type="standard">
<button @click="copy(item.file.download_url)">
<ClipboardCopyIcon class="size-4" />
@@ -149,6 +151,13 @@ defineExpose({ show, hide })
</button>
</ButtonStyled>
</td>
<td class="py-1">
<ButtonStyled circular size="small">
<a :href="item.file.download_url" :download="item.file.file_name" target="_blank">
<DownloadIcon />
</a>
</ButtonStyled>
</td>
</tr>
</tbody>
</table>
@@ -127,11 +127,9 @@ import dayjs from 'dayjs'
import { computed } from 'vue'
import type { ModerationProject } from '~/helpers/moderation'
import { useModerationStore } from '~/store/moderation.ts'
const { addNotification } = injectNotificationManager()
const formatRelativeTime = useRelativeTime()
const moderationStore = useModerationStore()
const baseId = useId()
@@ -139,6 +137,10 @@ const props = defineProps<{
queueEntry: ModerationProject
}>()
const emit = defineEmits<{
startFromProject: [projectId: string]
}>()
function getDaysQueued(date: Date): number {
const now = new Date()
const diff = now.getTime() - date.getTime()
@@ -201,16 +203,6 @@ const quickActions: OverflowMenuOption[] = [
]
function openProjectForReview() {
moderationStore.setSingleProject(props.queueEntry.project.id)
navigateTo({
name: 'type-id',
params: {
type: 'project',
id: props.queueEntry.project.slug || props.queueEntry.project.id,
},
state: {
showChecklist: true,
},
})
emit('startFromProject', props.queueEntry.project.id)
}
</script>
@@ -12,6 +12,7 @@ import {
LinkIcon,
LoaderCircleIcon,
ShieldCheckIcon,
TimerIcon,
} from '@modrinth/assets'
import { type TechReviewContext, techReviewQuickReplies } from '@modrinth/moderation'
import {
@@ -113,8 +114,8 @@ const quickActions = computed<OverflowMenuOption[]>(() => {
navigator.clipboard.writeText(props.item.project.id).then(() => {
addNotification({
type: 'success',
title: 'Technical Report ID copied',
text: 'The ID of this report has been copied to your clipboard.',
title: 'Project ID copied',
text: 'The ID of this project has been copied to your clipboard.',
})
})
},
@@ -265,6 +266,13 @@ const severityColor = computed(() => {
}
})
const isProjectApproved = computed(() => {
const status = props.item.project.status
return (
status === 'approved' || status === 'archived' || status === 'unlisted' || status === 'private'
)
})
const formattedDate = computed(() => {
const dates = props.item.reports.map((r) => new Date(r.created))
const earliest = new Date(Math.min(...dates.map((d) => d.getTime())))
@@ -369,12 +377,16 @@ async function updateDetailStatus(detailId: string, verdict: 'safe' | 'unsafe')
if (detailKey) break
}
let otherMatchedCount = 0
if (detailKey) {
for (const report of props.item.reports) {
for (const issue of report.issues) {
for (const detail of issue.details) {
if (detail.key === detailKey) {
detailDecisions.value.set(detail.id, decision)
if (detail.id !== detailId) {
otherMatchedCount++
}
}
}
}
@@ -391,17 +403,31 @@ 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) {
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)`
: ''
if (verdict === 'safe') {
addNotification({
type: 'success',
title: 'Issue marked as pass',
text: 'This issue has been marked as a false positive.',
text: `This issue has been marked as a false positive.${otherText}`,
})
} else {
addNotification({
type: 'success',
title: 'Issue marked as fail',
text: 'This issue has been flagged as malicious.',
text: `This issue has been flagged as malicious.${otherText}`,
})
}
} catch (error) {
@@ -472,6 +498,17 @@ const groupedByClass = computed<ClassGroup[]>(() => {
})
})
// Auto-expand if there's only one class in the file
watch(
groupedByClass,
(classes) => {
if (classes.length === 1) {
expandedClasses.value.add(classes[0].filePath)
}
},
{ immediate: true },
)
function getHighestSeverityInClass(
flags: ClassGroup['flags'],
): Labrinth.TechReview.Internal.DelphiSeverity {
@@ -623,7 +660,7 @@ const threadWithPreview = computed(() => {
body: {
type: 'text',
body: reviewSummaryPreview.value,
private: false,
private: true,
replying_to: null,
associated_images: [],
},
@@ -747,9 +784,21 @@ async function handleSubmitReview(verdict: 'safe' | 'unsafe') {
</div>
<div
class="rounded-full border border-solid border-surface-5 bg-surface-4 px-2.5 py-1"
class="flex items-center gap-1 rounded-full border border-solid px-2.5 py-1"
:class="
isProjectApproved
? 'border-green bg-highlight-green'
: 'border-orange bg-highlight-orange'
"
>
<span class="text-sm font-medium text-secondary">Auto-Flagged</span>
<CheckIcon v-if="isProjectApproved" aria-hidden="true" class="h-4 w-4 text-green" />
<TimerIcon v-else aria-hidden="true" class="h-4 w-4 text-orange" />
<span
class="text-sm font-medium"
:class="isProjectApproved ? 'text-green' : 'text-orange'"
>
{{ isProjectApproved ? 'Live' : 'In review' }}
</span>
</div>
<div class="rounded-full px-2.5 py-1" :class="severityColor">
@@ -929,8 +978,6 @@ async function handleSubmitReview(verdict: 'safe' | 'unsafe') {
:href="file.download_url"
:title="`Download ${file.file_name}`"
:download="file.file_name"
target="_blank"
rel="noopener noreferrer"
class="!border-px !border-surface-4"
tabindex="0"
>
@@ -1008,15 +1055,21 @@ async function handleSubmitReview(verdict: 'safe' | 'unsafe') {
v-for="flag in classItem.flags"
:key="`${flag.issueId}-${flag.detail.id}`"
class="grid grid-cols-[1fr_auto_auto] items-center rounded-lg border-[1px] border-b border-solid border-surface-5 bg-surface-3 py-2 pl-4 last:border-b-0"
:class="{
'opacity-50': isPreReviewed(flag.detail.id, flag.detail.status),
}"
>
<span class="text-base font-semibold text-contrast">{{
flag.issueType.replace(/_/g, ' ')
}}</span>
<span
class="text-base font-semibold text-contrast"
:class="{
'opacity-50': isPreReviewed(flag.detail.id, flag.detail.status),
}"
>{{ flag.issueType.replace(/_/g, ' ') }}</span
>
<div class="flex w-20 justify-center">
<div
class="flex w-20 justify-center"
:class="{
'opacity-50': isPreReviewed(flag.detail.id, flag.detail.status),
}"
>
<div
class="rounded-full border-solid px-2.5 py-1"
:class="getSeverityBadgeColor(flag.detail.severity)"
File diff suppressed because it is too large Load Diff
@@ -237,7 +237,7 @@ function navigateToFolder() {
const newPath = currentPath.endsWith('/')
? `${currentPath}${props.name}`
: `${currentPath}/${props.name}`
router.push({ query: { path: newPath, page: 1 } })
router.push({ query: { path: newPath } })
}
const isNavigating = ref(false)
+1 -16
View File
@@ -42,22 +42,7 @@ export const DEFAULT_FEATURE_FLAGS = validateValues({
hideRussiaCensorshipBanner: false,
serverDiscovery: false,
disablePrettyProjectUrlRedirects: false,
// advancedRendering: true,
// externalLinksNewTab: true,
// notUsingBlockers: false,
// hideModrinthAppPromos: false,
// preferredDarkTheme: 'dark',
// hideStagingBanner: false,
// Project display modes
// modSearchDisplayMode: 'list',
// pluginSearchDisplayMode: 'list',
// resourcePackSearchDisplayMode: 'gallery',
// modpackSearchDisplayMode: 'list',
// shaderSearchDisplayMode: 'gallery',
// dataPackSearchDisplayMode: 'list',
// userProjectDisplayMode: 'list',
// collectionProjectDisplayMode: 'list',
hidePreviewBanner: false,
} as const)
export type FeatureFlag = keyof typeof DEFAULT_FEATURE_FLAGS
+28 -2
View File
@@ -1,3 +1,26 @@
let cachedRateLimitKey = undefined
let rateLimitKeyPromise = undefined
async function getRateLimitKey(config) {
if (config.rateLimitKey) return config.rateLimitKey
if (cachedRateLimitKey !== undefined) return cachedRateLimitKey
if (!rateLimitKeyPromise) {
rateLimitKeyPromise = (async () => {
try {
const mod = 'cloudflare:workers'
const { env } = await import(/* @vite-ignore */ mod)
return await env.RATE_LIMIT_IGNORE_KEY?.get()
} catch {
return undefined
}
})()
}
cachedRateLimitKey = await rateLimitKeyPromise
return cachedRateLimitKey
}
export const useBaseFetch = async (url, options = {}, skipAuth = false) => {
const config = useRuntimeConfig()
let base = import.meta.server ? config.apiBaseUrl : config.public.apiBaseUrl
@@ -7,7 +30,7 @@ export const useBaseFetch = async (url, options = {}, skipAuth = false) => {
}
if (import.meta.server) {
options.headers['x-ratelimit-key'] = config.rateLimitKey
options.headers['x-ratelimit-key'] = await getRateLimitKey(config)
}
if (!skipAuth) {
@@ -32,5 +55,8 @@ export const useBaseFetch = async (url, options = {}, skipAuth = false) => {
delete options.apiVersion
}
return await $fetch(`${base}${url}`, options)
return await $fetch(`${base}${url}`, {
timeout: import.meta.server ? 10000 : undefined,
...options,
})
}
+12 -1
View File
@@ -13,6 +13,17 @@ import {
} from '@modrinth/api-client'
import type { Ref } from 'vue'
async function getRateLimitKeyFromSecretsStore(): Promise<string | undefined> {
try {
const mod = 'cloudflare:workers'
const { env } = await import(/* @vite-ignore */ mod)
return await env.RATE_LIMIT_IGNORE_KEY?.get()
} catch {
// Not running in Cloudflare Workers environment
return undefined
}
}
export function createModrinthClient(
auth: Ref<{ token: string | undefined }>,
config: { apiBaseUrl: string; archonBaseUrl: string; rateLimitKey?: string },
@@ -24,7 +35,7 @@ export function createModrinthClient(
const clientConfig: NuxtClientConfig = {
labrinthBaseUrl: config.apiBaseUrl,
archonBaseUrl: config.archonBaseUrl,
rateLimitKey: config.rateLimitKey,
rateLimitKey: config.rateLimitKey || getRateLimitKeyFromSecretsStore,
features: [
// for modrinth hosting
// is skipped for normal reqs
-514
View File
@@ -1,514 +0,0 @@
import { parse as parseTOML } from '@ltd/j-toml'
import yaml from 'js-yaml'
import JSZip from 'jszip'
import { satisfies } from 'semver'
export const inferVersionInfo = async function (rawFile, project, gameVersions) {
function versionType(number) {
if (number.includes('alpha')) {
return 'alpha'
} else if (
number.includes('beta') ||
number.match(/[^A-z](rc)[^A-z]/) || // includes `rc`
number.match(/[^A-z](pre)[^A-z]/) // includes `pre`
) {
return 'beta'
} else {
return 'release'
}
}
function getGameVersionsMatchingSemverRange(range, gameVersions) {
if (!range) {
return []
}
const ranges = Array.isArray(range) ? range : [range]
return gameVersions.filter((version) => {
const semverVersion = version.split('.').length === 2 ? `${version}.0` : version // add patch version if missing (e.g. 1.16 -> 1.16.0)
return ranges.some((v) => satisfies(semverVersion, v))
})
}
function getGameVersionsMatchingMavenRange(range, gameVersions) {
if (!range) {
return []
}
const ranges = []
while (range.startsWith('[') || range.startsWith('(')) {
let index = range.indexOf(')')
const index2 = range.indexOf(']')
if (index === -1 || (index2 !== -1 && index2 < index)) {
index = index2
}
if (index === -1) break
ranges.push(range.substring(0, index + 1))
range = range.substring(index + 1).trim()
if (range.startsWith(',')) {
range = range.substring(1).trim()
}
}
if (range) {
ranges.push(range)
}
const LESS_THAN_EQUAL = /^\(,(.*)]$/
const LESS_THAN = /^\(,(.*)\)$/
const EQUAL = /^\[(.*)]$/
const GREATER_THAN_EQUAL = /^\[(.*),\)$/
const GREATER_THAN = /^\((.*),\)$/
const BETWEEN = /^\((.*),(.*)\)$/
const BETWEEN_EQUAL = /^\[(.*),(.*)]$/
const BETWEEN_LESS_THAN_EQUAL = /^\((.*),(.*)]$/
const BETWEEN_GREATER_THAN_EQUAL = /^\[(.*),(.*)\)$/
const semverRanges = []
for (const range of ranges) {
let result
if ((result = range.match(LESS_THAN_EQUAL))) {
semverRanges.push(`<=${result[1]}`)
} else if ((result = range.match(LESS_THAN))) {
semverRanges.push(`<${result[1]}`)
} else if ((result = range.match(EQUAL))) {
semverRanges.push(`${result[1]}`)
} else if ((result = range.match(GREATER_THAN_EQUAL))) {
semverRanges.push(`>=${result[1]}`)
} else if ((result = range.match(GREATER_THAN))) {
semverRanges.push(`>${result[1]}`)
} else if ((result = range.match(BETWEEN))) {
semverRanges.push(`>${result[1]} <${result[2]}`)
} else if ((result = range.match(BETWEEN_EQUAL))) {
semverRanges.push(`>=${result[1]} <=${result[2]}`)
} else if ((result = range.match(BETWEEN_LESS_THAN_EQUAL))) {
semverRanges.push(`>${result[1]} <=${result[2]}`)
} else if ((result = range.match(BETWEEN_GREATER_THAN_EQUAL))) {
semverRanges.push(`>=${result[1]} <${result[2]}`)
}
}
return getGameVersionsMatchingSemverRange(semverRanges, gameVersions)
}
const simplifiedGameVersions = gameVersions
.filter((it) => it.version_type === 'release')
.map((it) => it.version)
const inferFunctions = {
// NeoForge
'META-INF/neoforge.mods.toml': (file) => {
const metadata = parseTOML(file, { joiner: '\n' })
if (!metadata.mods || metadata.mods.length === 0) {
return {}
}
const neoForgeDependency = Object.values(metadata.dependencies)
.flat()
.find((dependency) => dependency.modId === 'neoforge')
if (!neoForgeDependency) {
return {}
}
// https://docs.neoforged.net/docs/gettingstarted/versioning/#neoforge
const mcVersionRange = neoForgeDependency.versionRange
.replace('-beta', '')
.replace(/(\d+)(?:\.(\d+))?(?:\.(\d+)?)?/g, (_match, major, minor) => {
return `1.${major}${minor ? '.' + minor : ''}`
})
const gameVersions = getGameVersionsMatchingMavenRange(mcVersionRange, simplifiedGameVersions)
const versionNum = metadata.mods[0].version
return {
name: `${project.title} ${versionNum}`,
version_number: versionNum,
loaders: ['neoforge'],
version_type: versionType(versionNum),
game_versions: gameVersions,
}
},
// Forge 1.13+
'META-INF/mods.toml': async (file, zip) => {
const metadata = parseTOML(file, { joiner: '\n' })
if (metadata.mods && metadata.mods.length > 0) {
let versionNum = metadata.mods[0].version
// ${file.jarVersion} -> Implementation-Version from manifest
const manifestFile = zip.file('META-INF/MANIFEST.MF')
if (metadata.mods[0].version.includes('${file.jarVersion}') && manifestFile !== null) {
const manifestText = await manifestFile.async('text')
const regex = /Implementation-Version: (.*)$/m
const match = manifestText.match(regex)
if (match) {
versionNum = versionNum.replace('${file.jarVersion}', match[1])
}
}
let gameVersions = []
const mcDependencies = Object.values(metadata.dependencies)
.flat()
.filter((dependency) => dependency.modId === 'minecraft')
if (mcDependencies.length > 0) {
gameVersions = getGameVersionsMatchingMavenRange(
mcDependencies[0].versionRange,
simplifiedGameVersions,
)
}
return {
name: `${project.title} ${versionNum}`,
version_number: versionNum,
version_type: versionType(versionNum),
loaders: ['forge'],
game_versions: gameVersions,
}
} else {
return {}
}
},
// Old Forge
'mcmod.info': (file) => {
const metadata = JSON.parse(file)
return {
name: metadata.version ? `${project.title} ${metadata.version}` : '',
version_number: metadata.version,
version_type: versionType(metadata.version),
loaders: ['forge'],
game_versions: simplifiedGameVersions.filter((version) =>
version.startsWith(metadata.mcversion),
),
}
},
// Fabric
'fabric.mod.json': (file) => {
const metadata = JSON.parse(file)
return {
name: `${project.title} ${metadata.version}`,
version_number: metadata.version,
loaders: ['fabric'],
version_type: versionType(metadata.version),
game_versions: metadata.depends
? getGameVersionsMatchingSemverRange(metadata.depends.minecraft, simplifiedGameVersions)
: [],
}
},
// Quilt
'quilt.mod.json': (file) => {
const metadata = JSON.parse(file)
return {
name: `${project.title} ${metadata.quilt_loader.version}`,
version_number: metadata.quilt_loader.version,
loaders: ['quilt'],
version_type: versionType(metadata.quilt_loader.version),
game_versions: metadata.quilt_loader.depends
? getGameVersionsMatchingSemverRange(
metadata.quilt_loader.depends.find((x) => x.id === 'minecraft')
? metadata.quilt_loader.depends.find((x) => x.id === 'minecraft').versions
: [],
simplifiedGameVersions,
)
: [],
}
},
// Bukkit + Other Forks
'plugin.yml': (file) => {
const metadata = yaml.load(file)
return {
name: `${project.title} ${metadata.version}`,
version_number: metadata.version,
version_type: versionType(metadata.version),
// We don't know which fork of Bukkit users are using
loaders: [],
game_versions: gameVersions
.filter(
(x) => x.version.startsWith(metadata['api-version']) && x.version_type === 'release',
)
.map((x) => x.version),
}
},
// Paper 1.19.3+
'paper-plugin.yml': (file) => {
const metadata = yaml.load(file)
return {
name: `${project.title} ${metadata.version}`,
version_number: metadata.version,
version_type: versionType(metadata.version),
loaders: ['paper'],
game_versions: gameVersions
.filter(
(x) => x.version.startsWith(metadata['api-version']) && x.version_type === 'release',
)
.map((x) => x.version),
}
},
// Bungeecord + Waterfall
'bungee.yml': (file) => {
const metadata = yaml.load(file)
return {
name: `${project.title} ${metadata.version}`,
version_number: metadata.version,
version_type: versionType(metadata.version),
loaders: ['bungeecord'],
}
},
// Velocity
'velocity-plugin.json': (file) => {
const metadata = JSON.parse(file)
return {
name: `${project.title} ${metadata.version}`,
version_number: metadata.version,
version_type: versionType(metadata.version),
loaders: ['velocity'],
}
},
// Modpacks
'modrinth.index.json': (file) => {
const metadata = JSON.parse(file)
const loaders = []
if ('forge' in metadata.dependencies) {
loaders.push('forge')
}
if ('neoforge' in metadata.dependencies) {
loaders.push('neoforge')
}
if ('fabric-loader' in metadata.dependencies) {
loaders.push('fabric')
}
if ('quilt-loader' in metadata.dependencies) {
loaders.push('quilt')
}
return {
name: `${project.title} ${metadata.versionId}`,
version_number: metadata.versionId,
version_type: versionType(metadata.versionId),
loaders,
game_versions: gameVersions
.filter((x) => x.version === metadata.dependencies.minecraft)
.map((x) => x.version),
}
},
// Resource Packs + Data Packs
'pack.mcmeta': (file) => {
const metadata = JSON.parse(file)
function getRange(versionA, versionB) {
const startingIndex = gameVersions.findIndex((x) => x.version === versionA)
const endingIndex = gameVersions.findIndex((x) => x.version === versionB)
const final = []
const filterOnlyRelease = gameVersions[startingIndex].version_type === 'release'
for (let i = startingIndex; i >= endingIndex; i--) {
if (gameVersions[i].version_type === 'release' || !filterOnlyRelease) {
final.push(gameVersions[i].version)
}
}
return final
}
const loaders = []
let newGameVersions = []
if (project.actualProjectType === 'mod') {
loaders.push('datapack')
switch (metadata.pack.pack_format) {
case 4:
newGameVersions = getRange('1.13', '1.14.4')
break
case 5:
newGameVersions = getRange('1.15', '1.16.1')
break
case 6:
newGameVersions = getRange('1.16.2', '1.16.5')
break
case 7:
newGameVersions = getRange('1.17', '1.17.1')
break
case 8:
newGameVersions = getRange('1.18', '1.18.1')
break
case 9:
newGameVersions.push('1.18.2')
break
case 10:
newGameVersions = getRange('1.19', '1.19.3')
break
case 11:
newGameVersions = getRange('23w03a', '23w05a')
break
case 12:
newGameVersions.push('1.19.4')
break
default:
}
}
if (project.actualProjectType === 'resourcepack') {
loaders.push('minecraft')
switch (metadata.pack.pack_format) {
case 1:
newGameVersions = getRange('1.6.1', '1.8.9')
break
case 2:
newGameVersions = getRange('1.9', '1.10.2')
break
case 3:
newGameVersions = getRange('1.11', '1.12.2')
break
case 4:
newGameVersions = getRange('1.13', '1.14.4')
break
case 5:
newGameVersions = getRange('1.15', '1.16.1')
break
case 6:
newGameVersions = getRange('1.16.2', '1.16.5')
break
case 7:
newGameVersions = getRange('1.17', '1.17.1')
break
case 8:
newGameVersions = getRange('1.18', '1.18.2')
break
case 9:
newGameVersions = getRange('1.19', '1.19.2')
break
case 11:
newGameVersions = getRange('22w42a', '22w44a')
break
case 12:
newGameVersions.push('1.19.3')
break
case 13:
newGameVersions.push('1.19.4')
break
case 14:
newGameVersions = getRange('23w14a', '23w16a')
break
case 15:
newGameVersions = getRange('1.20', '1.20.1')
break
case 16:
newGameVersions.push('23w31a')
break
case 17:
newGameVersions = getRange('23w32a', '1.20.2-pre1')
break
case 18:
newGameVersions.push('1.20.2')
break
case 19:
newGameVersions.push('23w42a')
break
case 20:
newGameVersions = getRange('23w43a', '23w44a')
break
case 21:
newGameVersions = getRange('23w45a', '23w46a')
break
case 22:
newGameVersions = getRange('1.20.3', '1.20.4')
break
case 24:
newGameVersions = getRange('24w03a', '24w04a')
break
case 25:
newGameVersions = getRange('24w05a', '24w05b')
break
case 26:
newGameVersions = getRange('24w06a', '24w07a')
break
case 28:
newGameVersions = getRange('24w09a', '24w10a')
break
case 29:
newGameVersions.push('24w11a')
break
case 30:
newGameVersions.push('24w12a')
break
case 31:
newGameVersions = getRange('24w13a', '1.20.5-pre3')
break
case 32:
newGameVersions = getRange('1.20.5', '1.20.6')
break
case 33:
newGameVersions = getRange('24w18a', '24w20a')
break
case 34:
newGameVersions = getRange('1.21', '1.21.1')
break
case 35:
newGameVersions.push('24w33a')
break
case 36:
newGameVersions = getRange('24w34a', '24w35a')
break
case 37:
newGameVersions.push('24w36a')
break
case 38:
newGameVersions.push('24w37a')
break
case 39:
newGameVersions = getRange('24w38a', '24w39a')
break
case 40:
newGameVersions.push('24w40a')
break
case 41:
newGameVersions = getRange('1.21.2-pre1', '1.21.2-pre2')
break
case 42:
newGameVersions = getRange('1.21.2', '1.21.3')
break
case 43:
newGameVersions.push('24w44a')
break
case 44:
newGameVersions.push('24w45a')
break
case 45:
newGameVersions.push('24w46a')
break
case 46:
newGameVersions.push('1.21.4')
break
default:
}
}
return {
loaders,
game_versions: newGameVersions,
}
},
}
const zipReader = new JSZip()
const zip = await zipReader.loadAsync(rawFile)
for (const fileName in inferFunctions) {
const file = zip.file(fileName)
if (file !== null) {
const text = await file.async('text')
return inferFunctions[fileName](text, zip)
}
}
}
@@ -0,0 +1,123 @@
// Pack format to Minecraft version mappings
// See: https://minecraft.wiki/w/Pack_format
// NOTE: This needs to be continuously updated as new versions are released.
// Resource pack format history (full table including development versions)
export const RESOURCE_PACK_FORMATS = {
1: { min: '1.6.1', max: '1.8.9' },
2: { min: '1.9', max: '1.10.2' },
3: { min: '1.11', max: '1.12.2' },
4: { min: '1.13', max: '1.14.4' },
5: { min: '1.15', max: '1.16.1' },
6: { min: '1.16.2', max: '1.16.5' },
7: { min: '1.17', max: '1.17.1' },
8: { min: '1.18', max: '1.18.2' },
9: { min: '1.19', max: '1.19.2' },
11: { min: '22w42a', max: '22w44a' },
12: { min: '1.19.3', max: '1.19.3' },
13: { min: '1.19.4', max: '1.19.4' },
14: { min: '23w14a', max: '23w16a' },
15: { min: '1.20', max: '1.20.1' },
16: { min: '23w31a', max: '23w31a' },
17: { min: '23w32a', max: '1.20.2-pre1' },
18: { min: '1.20.2', max: '1.20.2' },
19: { min: '23w42a', max: '23w42a' },
20: { min: '23w43a', max: '23w44a' },
21: { min: '23w45a', max: '23w46a' },
22: { min: '1.20.3', max: '1.20.4' },
24: { min: '24w03a', max: '24w04a' },
25: { min: '24w05a', max: '24w05b' },
26: { min: '24w06a', max: '24w07a' },
28: { min: '24w09a', max: '24w10a' },
29: { min: '24w11a', max: '24w11a' },
30: { min: '24w12a', max: '24w12a' },
31: { min: '24w13a', max: '1.20.5-pre3' },
32: { min: '1.20.5', max: '1.20.6' },
33: { min: '24w18a', max: '24w20a' },
34: { min: '1.21', max: '1.21.1' },
35: { min: '24w33a', max: '24w33a' },
36: { min: '24w34a', max: '24w35a' },
37: { min: '24w36a', max: '24w36a' },
38: { min: '24w37a', max: '24w37a' },
39: { min: '24w38a', max: '24w39a' },
40: { min: '24w40a', max: '24w40a' },
41: { min: '1.21.2-pre1', max: '1.21.2-pre2' },
42: { min: '1.21.2', max: '1.21.3' },
43: { min: '24w44a', max: '24w44a' },
44: { min: '24w45a', max: '24w45a' },
45: { min: '24w46a', max: '24w46a' },
46: { min: '1.21.4', max: '1.21.4' },
55: { min: '1.21.5', max: '1.21.5' },
63: { min: '1.21.6', max: '1.21.6' },
64: { min: '1.21.7', max: '1.21.8' },
69.0: { min: '1.21.9', max: '1.21.10' },
75: { min: '1.21.11', max: '1.21.11' },
} as const
// Data pack format history (full table including development versions)
export const DATA_PACK_FORMATS = {
4: { min: '1.13', max: '1.14.4' },
5: { min: '1.15', max: '1.16.1' },
6: { min: '1.16.2', max: '1.16.5' },
7: { min: '1.17', max: '1.17.1' },
8: { min: '1.18', max: '1.18.1' },
9: { min: '1.18.2', max: '1.18.2' },
10: { min: '1.19', max: '1.19.3' },
11: { min: '23w03a', max: '23w05a' },
12: { min: '1.19.4', max: '1.19.4' },
13: { min: '23w12a', max: '23w14a' },
14: { min: '23w16a', max: '23w17a' },
15: { min: '1.20', max: '1.20.1' },
16: { min: '23w31a', max: '23w31a' },
17: { min: '23w32a', max: '1.20.2-pre1' },
18: { min: '1.20.2', max: '1.20.2' },
19: { min: '23w40a', max: '23w40a' },
20: { min: '23w41a', max: '23w41a' },
21: { min: '23w42a', max: '23w42a' },
22: { min: '23w43a', max: '23w44a' },
23: { min: '23w45a', max: '23w46a' },
24: { min: '1.20.3-pre1', max: '1.20.3-pre1' },
25: { min: '1.20.3-pre2', max: '1.20.3-pre4' },
26: { min: '1.20.3', max: '1.20.4' },
27: { min: '23w51a', max: '23w51b' },
28: { min: '24w03a', max: '24w04a' },
29: { min: '24w05a', max: '24w05b' },
30: { min: '24w06a', max: '24w06a' },
31: { min: '24w07a', max: '24w07a' },
32: { min: '24w09a', max: '24w10a' },
33: { min: '24w11a', max: '24w11a' },
34: { min: '24w12a', max: '24w12a' },
35: { min: '24w13a', max: '24w13a' },
36: { min: '24w14a', max: '24w14a' },
37: { min: '1.20.5-pre1', max: '1.20.5-pre1' },
38: { min: '1.20.5-pre2', max: '1.20.5-pre3' },
39: { min: '1.20.5-pre4', max: '1.20.5-rc3' },
40: { min: '1.20.5-rc4', max: '1.20.5-rc4' },
41: { min: '1.20.5', max: '1.20.6' },
42: { min: '24w18a', max: '24w19b' },
43: { min: '24w20a', max: '24w20a' },
44: { min: '24w21a', max: '24w21b' },
45: { min: '1.21-pre1', max: '1.21-pre1' },
46: { min: '1.21-pre2', max: '1.21-pre4' },
47: { min: '1.21-rc1', max: '1.21-rc1' },
48: { min: '1.21', max: '1.21.1' },
49: { min: '24w33a', max: '24w33a' },
50: { min: '24w34a', max: '24w35a' },
51: { min: '24w36a', max: '24w36a' },
52: { min: '24w37a', max: '24w37a' },
53: { min: '24w38a', max: '24w38a' },
54: { min: '24w39a', max: '24w39a' },
55: { min: '24w40a', max: '24w40a' },
56: { min: '1.21.2-pre1', max: '1.21.2-pre2' },
57: { min: '1.21.2', max: '1.21.3' },
58: { min: '24w44a', max: '24w44a' },
59: { min: '24w45a', max: '24w45a' },
60: { min: '24w46a', max: '24w46a' },
61: { min: '1.21.4', max: '1.21.4' },
71: { min: '1.21.5', max: '1.21.5' },
80: { min: '1.21.6', max: '1.21.6' },
81: { min: '1.21.7', max: '1.21.8' },
88.0: { min: '1.21.9', max: '1.21.10' },
94.1: { min: '1.21.11', max: '1.21.11' },
} as const
+3
View File
@@ -0,0 +1,3 @@
export type { InferredVersionInfo } from './infer'
export { inferVersionInfo } from './infer'
export { extractVersionDetailsFromFilename } from './version-utils'
+132
View File
@@ -0,0 +1,132 @@
import JSZip from 'jszip'
import { createLoaderParsers } from './loader-parsers'
import { createMultiFileDetectors } from './multi-file-detectors'
import { createPackParser } from './pack-parsers'
import { extractVersionDetailsFromFilename } from './version-utils'
export type GameVersion = { version: string; version_type: string }
export type Project = { title: string; actualProjectType?: string }
export type RawFile = File | (Blob & { name: string })
export interface InferredVersionInfo {
name?: string
version_number?: string
version_type?: 'alpha' | 'beta' | 'release'
loaders?: string[]
game_versions?: string[]
}
/**
* Fills in missing version information from the filename if not already present.
*/
function fillMissingFromFilename(
result: InferredVersionInfo,
filename: string,
projectTitle: string,
): InferredVersionInfo {
const filenameDetails = extractVersionDetailsFromFilename(filename)
if (!result.version_number && filenameDetails.versionNumber) {
result.version_number = filenameDetails.versionNumber
}
if (!result.version_type) {
result.version_type = filenameDetails.versionType
}
if (!result.name && result.version_number) {
result.name = `${projectTitle} ${result.version_number}`
}
return result
}
/**
* Main function to infer version information from a file.
* Analyzes mod loaders, packs, and other Minecraft-related file formats.
*/
export const inferVersionInfo = async function (
rawFile: RawFile,
project: Project,
gameVersions: GameVersion[],
): Promise<InferredVersionInfo> {
const simplifiedGameVersions = gameVersions
.filter((it) => it.version_type === 'release')
.map((it) => it.version)
const zipReader = new JSZip()
const zip = await zipReader.loadAsync(rawFile)
const loaderParsers = createLoaderParsers(project, gameVersions, simplifiedGameVersions)
const packParser = createPackParser(project, gameVersions, rawFile)
const multiFileDetectors = createMultiFileDetectors(project, gameVersions, rawFile)
const inferFunctions = {
...loaderParsers,
'pack.mcmeta': packParser,
}
// Multi-loader detection
const multiLoaderFiles = [
'META-INF/neoforge.mods.toml',
'META-INF/mods.toml',
'fabric.mod.json',
'quilt.mod.json',
]
const detectedLoaderFiles = multiLoaderFiles.filter((fileName) => zip.file(fileName) !== null)
if (detectedLoaderFiles.length > 1) {
const results: InferredVersionInfo[] = []
for (const fileName of detectedLoaderFiles) {
const file = zip.file(fileName)
if (file !== null) {
const text = await file.async('text')
const parser = inferFunctions[fileName as keyof typeof inferFunctions]
if (parser) {
const result = await parser(text, zip)
if (result && Object.keys(result).length > 0) results.push(result)
}
}
}
if (results.length > 0) {
const combinedLoaders = [...new Set(results.flatMap((r) => r.loaders || []))]
const allGameVersions = [...new Set(results.flatMap((r) => r.game_versions || []))]
const primaryResult = results.find((r) => r.version_number) || results[0]
const mergedResult = {
name: primaryResult.name,
version_number: primaryResult.version_number,
version_type: primaryResult.version_type,
loaders: combinedLoaders,
game_versions: allGameVersions,
}
return fillMissingFromFilename(mergedResult, rawFile.name, project.title)
}
}
// Standard single-loader detection
for (const fileName in inferFunctions) {
const file = zip.file(fileName)
if (file !== null) {
const text = await file.async('text')
const parser = inferFunctions[fileName as keyof typeof inferFunctions]
if (parser) {
const result = await parser(text, zip)
return fillMissingFromFilename(result, rawFile.name, project.title)
}
}
}
// Multi-file detection functions
for (const detector of Object.values(multiFileDetectors)) {
const result = await detector(zip)
if (result !== null) {
return fillMissingFromFilename(result, rawFile.name, project.title)
}
}
return fillMissingFromFilename({}, rawFile.name, project.title)
}
@@ -0,0 +1,268 @@
import { parse as parseTOML } from '@ltd/j-toml'
import yaml from 'js-yaml'
import type JSZip from 'jszip'
import type { GameVersion, InferredVersionInfo, Project } from './infer'
import {
getGameVersionsMatchingMavenRange,
getGameVersionsMatchingSemverRange,
} from './version-ranges'
import { versionType } from './version-utils'
/**
* Creates the inferFunctions object containing all mod loader parsers.
*/
export function createLoaderParsers(
project: Project,
gameVersions: GameVersion[],
simplifiedGameVersions: string[],
) {
return {
// NeoForge
'META-INF/neoforge.mods.toml': (file: string): InferredVersionInfo => {
const metadata = parseTOML(file, { joiner: '\n' }) as any
const versionNum = metadata.mods?.[0]?.version || ''
let newGameVersions: string[] = []
if (metadata.dependencies) {
const neoForgeDependency = Object.values(metadata.dependencies)
.flat()
.find((dependency: any) => dependency.modId === 'neoforge')
if (neoForgeDependency) {
try {
// https://docs.neoforged.net/docs/gettingstarted/versioning/#neoforge
const mcVersionRange = (neoForgeDependency as any).versionRange
.replace('-beta', '')
.replace(
/(\d+)(?:\.(\d+))?(?:\.(\d+)?)?/g,
(_match: string, major: string, minor: string) => {
return `1.${major}${minor ? '.' + minor : ''}`
},
)
newGameVersions = getGameVersionsMatchingMavenRange(
mcVersionRange,
simplifiedGameVersions,
)
} catch {
// Ignore parsing errors, just leave game_versions empty
}
}
}
return {
name: versionNum ? `${project.title} ${versionNum}` : '',
version_number: versionNum,
loaders: ['neoforge'],
version_type: versionType(versionNum),
game_versions: newGameVersions,
}
},
// Forge 1.13+
'META-INF/mods.toml': async (file: string, zip: JSZip): Promise<InferredVersionInfo> => {
const metadata = parseTOML(file, { joiner: '\n' }) as any
if (metadata.mods && metadata.mods.length > 0) {
let versionNum = metadata.mods[0].version
// ${file.jarVersion} -> Implementation-Version from manifest
const manifestFile = zip.file('META-INF/MANIFEST.MF')
if (metadata.mods[0].version.includes('${file.jarVersion}') && manifestFile !== null) {
const manifestText = await manifestFile.async('text')
const regex = /Implementation-Version: (.*)$/m
const match = manifestText.match(regex)
if (match) {
versionNum = versionNum.replace('${file.jarVersion}', match[1])
}
}
let newGameVersions: string[] = []
const mcDependencies = Object.values(metadata.dependencies)
.flat()
.filter((dependency: any) => dependency.modId === 'minecraft')
if (mcDependencies.length > 0) {
newGameVersions = getGameVersionsMatchingMavenRange(
(mcDependencies[0] as any).versionRange,
simplifiedGameVersions,
)
}
return {
name: `${project.title} ${versionNum}`,
version_number: versionNum,
version_type: versionType(versionNum),
loaders: ['forge'],
game_versions: newGameVersions,
}
} else {
return {}
}
},
// Old Forge
'mcmod.info': (file: string): InferredVersionInfo => {
const metadata = JSON.parse(file) as any
return {
name: metadata.version ? `${project.title} ${metadata.version}` : '',
version_number: metadata.version,
version_type: versionType(metadata.version),
loaders: ['forge'],
game_versions: simplifiedGameVersions.filter((version) =>
version.startsWith(metadata.mcversion),
),
}
},
// Fabric
'fabric.mod.json': (file: string): InferredVersionInfo => {
const metadata = JSON.parse(file) as any
const detectedGameVersions = metadata.depends
? getGameVersionsMatchingSemverRange(metadata.depends.minecraft, simplifiedGameVersions)
: []
const loaders: string[] = []
// Detect 1.3-1.13 -> legacy-fabric
const hasLegacyVersions = detectedGameVersions.some((version) => {
const match = version.match(/^1\.(\d+)/)
return match && parseInt(match[1]) >= 3 && parseInt(match[1]) <= 13
})
if (hasLegacyVersions) loaders.push('legacy-fabric')
else loaders.push('fabric')
return {
name: `${project.title} ${metadata.version}`,
version_number: metadata.version,
loaders,
version_type: versionType(metadata.version),
game_versions: detectedGameVersions,
}
},
// Quilt
'quilt.mod.json': (file: string): InferredVersionInfo => {
const metadata = JSON.parse(file) as any
return {
name: `${project.title} ${metadata.quilt_loader.version}`,
version_number: metadata.quilt_loader.version,
loaders: ['quilt'],
version_type: versionType(metadata.quilt_loader.version),
game_versions: metadata.quilt_loader.depends
? getGameVersionsMatchingSemverRange(
metadata.quilt_loader.depends.find((x: any) => x.id === 'minecraft')
? metadata.quilt_loader.depends.find((x: any) => x.id === 'minecraft').versions
: [],
simplifiedGameVersions,
)
: [],
}
},
// Bukkit + Other Forks
'plugin.yml': (file: string): InferredVersionInfo => {
const metadata = yaml.load(file) as any
// Check for Folia support
const loaders = []
if (metadata['folia-supported'] === true) {
loaders.push('folia')
}
// We don't know which fork of Bukkit users are using otherwise
return {
name: `${project.title} ${metadata.version}`,
version_number: metadata.version,
version_type: versionType(metadata.version),
loaders,
game_versions: gameVersions
.filter(
(x) => x.version.startsWith(metadata['api-version']) && x.version_type === 'release',
)
.map((x) => x.version),
}
},
// Paper 1.19.3+
'paper-plugin.yml': (file: string): InferredVersionInfo => {
const metadata = yaml.load(file) as any
return {
name: `${project.title} ${metadata.version}`,
version_number: metadata.version,
version_type: versionType(metadata.version),
loaders: ['paper'],
game_versions: gameVersions
.filter(
(x) => x.version.startsWith(metadata['api-version']) && x.version_type === 'release',
)
.map((x) => x.version),
}
},
// Bungeecord + Waterfall
'bungee.yml': (file: string): InferredVersionInfo => {
const metadata = yaml.load(file) as any
return {
name: `${project.title} ${metadata.version}`,
version_number: metadata.version,
version_type: versionType(metadata.version),
loaders: ['bungeecord'],
}
},
// Velocity
'velocity-plugin.json': (file: string): InferredVersionInfo => {
const metadata = JSON.parse(file) as any
return {
name: `${project.title} ${metadata.version}`,
version_number: metadata.version,
version_type: versionType(metadata.version),
loaders: ['velocity'],
}
},
// Sponge plugin (8+)
'META-INF/sponge_plugins.json': (file: string): InferredVersionInfo => {
const metadata = JSON.parse(file) as any
const plugin = metadata.plugins?.[0]
if (!plugin) {
return {}
}
return {
name: plugin.version ? `${project.title} ${plugin.version}` : '',
version_number: plugin.version,
version_type: versionType(plugin.version),
loaders: ['sponge'],
}
},
// Modpacks
'modrinth.index.json': (file: string): InferredVersionInfo => {
const metadata = JSON.parse(file) as any
const loaders = []
if ('forge' in metadata.dependencies) {
loaders.push('forge')
}
if ('neoforge' in metadata.dependencies) {
loaders.push('neoforge')
}
if ('fabric-loader' in metadata.dependencies) {
loaders.push('fabric')
}
if ('quilt-loader' in metadata.dependencies) {
loaders.push('quilt')
}
return {
name: `${project.title} ${metadata.versionId}`,
version_number: metadata.versionId,
version_type: versionType(metadata.versionId),
loaders,
game_versions: gameVersions
.filter((x) => x.version === metadata.dependencies.minecraft)
.map((x) => x.version),
}
},
}
}
@@ -0,0 +1,131 @@
import type JSZip from 'jszip'
import type { GameVersion, InferredVersionInfo, Project, RawFile } from './infer'
import { extractVersionFromFilename, versionType } from './version-utils'
/**
* Creates multi-file detection functions that scan multiple files in a zip.
*/
export function createMultiFileDetectors(
project: Project,
gameVersions: GameVersion[],
rawFile: RawFile,
) {
return {
// Legacy texture pack (pre-1.6.1)
legacyTexturePack: async (zip: JSZip): Promise<InferredVersionInfo | null> => {
const packTxt = zip.file('pack.txt')
if (!packTxt) return null
// Check for legacy texture pack files/directories
const legacyIndicators = [
'font.txt',
'particles.png',
'achievement/',
'armor/',
'art/',
'environment/',
'font/',
'gui/',
'item/',
'lang/',
'misc/',
'mob/',
'textures/',
'title/',
]
const hasLegacyContent = legacyIndicators.some((indicator) => {
if (indicator.endsWith('/')) {
return zip.file(new RegExp(`^${indicator}`))?.length > 0
}
return zip.file(indicator) !== null
})
if (!hasLegacyContent) return null
// Legacy texture packs are compatible with a1.2.2 to 1.5.2
// We'll return versions from 1.0 to 1.5.2 (as older alpha/beta versions may not be in gameVersions)
const legacyVersions = gameVersions
.filter((v) => {
const version = v.version
// Match 1.0 through 1.5.2
if (version.match(/^1\.[0-4](\.\d+)?$/) || version.match(/^1\.5(\.[0-2])?$/)) {
return true
}
return false
})
.map((v) => v.version)
const versionNum = extractVersionFromFilename(rawFile.name)
return {
name: versionNum ? `${project.title} ${versionNum}` : undefined,
version_number: versionNum || undefined,
version_type: versionType(versionNum),
loaders: ['minecraft'],
game_versions: legacyVersions,
}
},
// Shader pack (OptiFine/Iris)
shaderPack: async (zip: JSZip): Promise<InferredVersionInfo | null> => {
const shadersDir = zip.file(/^shaders\//)
if (!shadersDir || shadersDir.length === 0) return null
const loaders: string[] = []
// Check for Iris-specific features in shaders.properties
const shaderProps = zip.file('shaders/shaders.properties')
if (shaderProps) {
const propsText = await shaderProps.async('text')
if (
propsText.includes('iris.features.required') ||
propsText.includes('iris.features.optional')
) {
loaders.push('iris', 'optifine')
}
}
// If no specific loader detected, it could be OptiFine or Iris
if (loaders.length === 0) {
loaders.push('optifine', 'iris')
}
const versionNum = extractVersionFromFilename(rawFile.name)
return {
name: versionNum ? `${project.title} ${versionNum}` : undefined,
version_number: versionNum || undefined,
version_type: versionType(versionNum),
loaders,
game_versions: [],
}
},
// NilLoader mod
nilLoaderMod: async (zip: JSZip): Promise<InferredVersionInfo | null> => {
const nilModFiles = zip.file(/\.nilmod\.css$/)
if (!nilModFiles || nilModFiles.length === 0) return null
return {
loaders: ['nilloader'],
game_versions: [],
}
},
// Java Agent
javaAgent: async (zip: JSZip): Promise<InferredVersionInfo | null> => {
const manifest = zip.file('META-INF/MANIFEST.MF')
if (!manifest) return null
const manifestText = await manifest.async('text')
if (!manifestText.includes('Premain-Class:')) return null
return {
loaders: ['java-agent'],
game_versions: [],
}
},
}
}
@@ -0,0 +1,266 @@
import type JSZip from 'jszip'
import { DATA_PACK_FORMATS, RESOURCE_PACK_FORMATS } from './constants'
import type { GameVersion, InferredVersionInfo, Project, RawFile } from './infer'
import { extractVersionFromFilename, versionType } from './version-utils'
type PackFormat = number | [number] | [number, number]
/**
* Normalizes a pack format to [major, minor] tuple. See https://minecraft.wiki/w/Pack.mcmeta
* - Single integer: [major, 0] for min, [major, Infinity] for max
* - Array [major]: [major, 0] for min, [major, Infinity] for max
* - Array [major, minor]: returns as-is
*/
function normalizePackFormat(format: PackFormat, isMax: boolean): [number, number] {
if (Array.isArray(format)) {
if (format.length === 1) {
return isMax ? [format[0], Infinity] : [format[0], 0]
}
return [format[0], format[1]]
}
return isMax ? [format, Infinity] : [format, 0]
}
/**
* Compares two pack formats [major, minor].
* Returns: -1 if a < b, 0 if equal, 1 if a > b
*/
function comparePackFormats(a: [number, number], b: [number, number]): number {
if (a[0] !== b[0]) return a[0] - b[0]
return a[1] - b[1]
}
/**
* Checks if a format number falls within the min/max range.
*/
function isFormatInRange(
format: number,
minFormat: [number, number],
maxFormat: [number, number],
): boolean {
// Check if the major version matches
if (format < minFormat[0] || format > maxFormat[0]) {
return false
}
// If major version is exactly min or max, we need to check minor version
// For entries in our map, we treat them as [major, 0]
const formatTuple: [number, number] = [format, 0]
// If the format has a decimal (like 69.0, 88.0), extract it
const formatStr = format.toString()
if (formatStr.includes('.')) {
const [maj, min] = formatStr.split('.').map(Number)
formatTuple[0] = maj
formatTuple[1] = min
}
return (
comparePackFormats(formatTuple, minFormat) >= 0 &&
comparePackFormats(formatTuple, maxFormat) <= 0
)
}
/**
* Helper function to get a range of game versions between two versions.
*/
function getRange(versionA: string, versionB: string, gameVersions: GameVersion[]): string[] {
const startingIndex = gameVersions.findIndex((x) => x.version === versionA)
const endingIndex = gameVersions.findIndex((x) => x.version === versionB)
if (startingIndex === -1 || endingIndex === -1) {
return []
}
const final = []
const filterOnlyRelease = gameVersions[startingIndex]?.version_type === 'release'
for (let i = startingIndex; i >= endingIndex; i--) {
if (gameVersions[i].version_type === 'release' || !filterOnlyRelease) {
final.push(gameVersions[i].version)
}
}
return final
}
/**
* Gets game versions from a single pack format number.
*/
function getVersionsFromPackFormat(
packFormat: number,
formatMap: Record<number, { min: string; max: string }>,
gameVersions: GameVersion[],
): string[] {
const mapping = formatMap[packFormat]
if (!mapping) {
return []
}
return getRange(mapping.min, mapping.max, gameVersions)
}
/**
* Gets game versions from a pack format range (min to max inclusive).
* Supports both integer and [major, minor] format specifications.
*/
function getVersionsFromFormatRange(
minFormat: PackFormat,
maxFormat: PackFormat,
formatMap: Record<number, { min: string; max: string }>,
gameVersions: GameVersion[],
): string[] {
const normalizedMin = normalizePackFormat(minFormat, false)
const normalizedMax = normalizePackFormat(maxFormat, true)
// Get all format numbers from the map that fall within the range
const allVersions: string[] = []
const formatNumbers = Object.keys(formatMap)
.map(Number)
.sort((a, b) => a - b)
for (const format of formatNumbers) {
if (isFormatInRange(format, normalizedMin, normalizedMax)) {
const versions = getVersionsFromPackFormat(format, formatMap, gameVersions)
for (const version of versions) {
if (!allVersions.includes(version)) {
allVersions.push(version)
}
}
}
}
return allVersions
}
/**
* Gets game versions from pack.mcmeta metadata.
* Supports multiple format specifications:
* - min_format + max_format: Can be integers or [major, minor] arrays (since 25w31a)
* - supported_formats: Single int, array of ints, or { min_inclusive, max_inclusive }
* - pack_format: Single format number (legacy)
*/
function getGameVersionsFromPackMeta(
packMeta: any,
formatMap: Record<number, { min: string; max: string }>,
gameVersions: GameVersion[],
): string[] {
const pack = packMeta.pack
if (!pack) return []
// Check for min_format and max_format (25w31a+ format)
// These can be: int (e.g., 82), [int] (e.g., [82]), or [major, minor] (e.g., [88, 0])
if (pack.min_format !== undefined && pack.max_format !== undefined) {
return getVersionsFromFormatRange(pack.min_format, pack.max_format, formatMap, gameVersions)
}
// Check for supported_formats
if (pack.supported_formats !== undefined) {
const formats = pack.supported_formats
// Single integer: major version
if (typeof formats === 'number') {
return getVersionsFromPackFormat(formats, formatMap, gameVersions)
}
// Array of integers or [min, max] range
if (Array.isArray(formats)) {
if (
formats.length === 2 &&
typeof formats[0] === 'number' &&
typeof formats[1] === 'number'
) {
// Could be [major, minor] or [minMajor, maxMajor]
// Based on context, if both are close (within ~50), treat as major version range
// Otherwise, treat as [major, minor]
if (Math.abs(formats[1] - formats[0]) < 50) {
// Likely a major version range like [42, 45]
return getVersionsFromFormatRange(formats[0], formats[1], formatMap, gameVersions)
}
}
// Array of major versions
const allVersions: string[] = []
for (const format of formats) {
if (typeof format === 'number') {
const versions = getVersionsFromPackFormat(format, formatMap, gameVersions)
for (const version of versions) {
if (!allVersions.includes(version)) {
allVersions.push(version)
}
}
}
}
return allVersions
}
// Object format: { min_inclusive, max_inclusive }
if (
typeof formats === 'object' &&
formats.min_inclusive !== undefined &&
formats.max_inclusive !== undefined
) {
return getVersionsFromFormatRange(
formats.min_inclusive,
formats.max_inclusive,
formatMap,
gameVersions,
)
}
}
// Fall back to pack_format (legacy single format)
if (pack.pack_format !== undefined) {
return getVersionsFromPackFormat(pack.pack_format, formatMap, gameVersions)
}
return []
}
/**
* Creates the pack.mcmeta parser function.
*/
export function createPackParser(project: Project, gameVersions: GameVersion[], rawFile: RawFile) {
return async (file: string, zip: JSZip): Promise<InferredVersionInfo> => {
const metadata = JSON.parse(file) as any
// Check for assets/ directory (resource pack) or data/ directory (data pack)
const hasAssetsDir = zip.file(/^assets\//)?.[0] !== undefined
const hasDataDir = zip.file(/^data\//)?.[0] !== undefined
const hasZipExtension = rawFile.name.toLowerCase().endsWith('.zip')
const loaders: string[] = []
let newGameVersions: string[] = []
// Data pack detection: has data/ directory
if (hasDataDir && hasZipExtension) {
loaders.push('datapack')
newGameVersions = getGameVersionsFromPackMeta(metadata, DATA_PACK_FORMATS, gameVersions)
}
// Resource pack detection: has assets/ directory
else if (hasAssetsDir && hasZipExtension) {
loaders.push('minecraft')
newGameVersions = getGameVersionsFromPackMeta(metadata, RESOURCE_PACK_FORMATS, gameVersions)
}
// Fallback to old behavior based on project type
else if (project.actualProjectType === 'mod') {
loaders.push('datapack')
newGameVersions = getGameVersionsFromPackMeta(metadata, DATA_PACK_FORMATS, gameVersions)
} else if (project.actualProjectType === 'resourcepack') {
loaders.push('minecraft')
newGameVersions = getGameVersionsFromPackMeta(metadata, RESOURCE_PACK_FORMATS, gameVersions)
}
// Try to extract version from filename
const versionNum = extractVersionFromFilename(rawFile.name)
return {
name: versionNum ? `${project.title} ${versionNum}` : undefined,
version_number: versionNum || undefined,
version_type: versionType(versionNum),
loaders,
game_versions: newGameVersions,
}
}
}
@@ -0,0 +1,87 @@
import { satisfies } from 'semver'
/**
* Returns game versions that match a semver range or array of ranges.
*/
export function getGameVersionsMatchingSemverRange(
range: string | string[] | undefined,
gameVersions: string[],
): string[] {
if (!range) {
return []
}
const ranges = Array.isArray(range) ? range : [range]
// Normalize ranges: strip trailing hyphens from version numbers used by Fabric for prerelease matching (e.g., ">=1.21.11-" -> ">=1.21.11")
const normalizedRanges = ranges.map((r) => r.replace(/(\d)-(\s|$)/g, '$1$2'))
return gameVersions.filter((version) => {
const semverVersion = version.split('.').length === 2 ? `${version}.0` : version // add patch version if missing (e.g. 1.16 -> 1.16.0)
return normalizedRanges.some((v) => satisfies(semverVersion, v))
})
}
/**
* Returns game versions that match a Maven-style version range.
*/
export function getGameVersionsMatchingMavenRange(
range: string | undefined,
gameVersions: string[],
): string[] {
if (!range) {
return []
}
const ranges = []
while (range.startsWith('[') || range.startsWith('(')) {
let index = range.indexOf(')')
const index2 = range.indexOf(']')
if (index === -1 || (index2 !== -1 && index2 < index)) {
index = index2
}
if (index === -1) break
ranges.push(range.substring(0, index + 1))
range = range.substring(index + 1).trim()
if (range.startsWith(',')) {
range = range.substring(1).trim()
}
}
if (range) {
ranges.push(range)
}
const LESS_THAN_EQUAL = /^\(,(.*)]$/
const LESS_THAN = /^\(,(.*)\)$/
const EQUAL = /^\[(.*)]$/
const GREATER_THAN_EQUAL = /^\[(.*),\)$/
const GREATER_THAN = /^\((.*),\)$/
const BETWEEN = /^\((.*),(.*)\)$/
const BETWEEN_EQUAL = /^\[(.*),(.*)]$/
const BETWEEN_LESS_THAN_EQUAL = /^\((.*),(.*)]$/
const BETWEEN_GREATER_THAN_EQUAL = /^\[(.*),(.*)\)$/
const semverRanges = []
for (const range of ranges) {
let result
if ((result = range.match(LESS_THAN_EQUAL))) {
semverRanges.push(`<=${result[1]}`)
} else if ((result = range.match(LESS_THAN))) {
semverRanges.push(`<${result[1]}`)
} else if ((result = range.match(EQUAL))) {
semverRanges.push(`${result[1]}`)
} else if ((result = range.match(GREATER_THAN_EQUAL))) {
semverRanges.push(`>=${result[1]}`)
} else if ((result = range.match(GREATER_THAN))) {
semverRanges.push(`>${result[1]}`)
} else if ((result = range.match(BETWEEN))) {
semverRanges.push(`>${result[1]} <${result[2]}`)
} else if ((result = range.match(BETWEEN_EQUAL))) {
semverRanges.push(`>=${result[1]} <=${result[2]}`)
} else if ((result = range.match(BETWEEN_LESS_THAN_EQUAL))) {
semverRanges.push(`>${result[1]} <=${result[2]}`)
} else if ((result = range.match(BETWEEN_GREATER_THAN_EQUAL))) {
semverRanges.push(`>=${result[1]} <${result[2]}`)
}
}
return getGameVersionsMatchingSemverRange(semverRanges, gameVersions)
}
@@ -0,0 +1,57 @@
/**
* Determines the version type based on the version string.
*/
export function versionType(number: string | null | undefined): 'alpha' | 'beta' | 'release' {
if (!number) return 'release'
if (number.includes('alpha')) {
return 'alpha'
} else if (
number.includes('beta') ||
number.match(/[^A-z](rc)[^A-z]/) || // includes `rc`
number.match(/[^A-z](pre)[^A-z]/) // includes `pre`
) {
return 'beta'
} else {
return 'release'
}
}
/**
* Extracts version number from a filename.
*/
export function extractVersionFromFilename(filename: string | null | undefined): string | null {
if (!filename) return null
// Remove file extension
let baseName = filename.replace(/\.(zip|jar)$/i, '')
// Remove explicit MC version markers: mc followed by version (e.g., +mc1.21.11, -mc1.21, _mc1.21.4)
baseName = baseName.replace(/[+_-]mc\d+\.\d+(?:\.\d+)?/gi, '')
const versionPatterns = [
/[_\-\s]v(\d+(?:\.\d+)*)/i, // Match version with 'v' anywhere: "Name-v1.2.3-extra" (less strict)
/[_\-\s]r(\d+(?:\.\d+)*)/i, // Match version with 'r' anywhere: "Name-r1.2.3-extra" (less strict)
/[_\-\s](\d+(?:\.\d+)+)$/, // Match version at end after space/separator: "Name 1.2.3"
/(\d+\.\d+(?:\.\d+)*)/, // Match any version pattern x.x or x.x.x.x...: "Name1.2.3extra"
]
for (const pattern of versionPatterns) {
const match = baseName.match(pattern)
if (match && match[1]) {
return match[1]
}
}
return null
}
/**
* Extracts version details from a filename (public API).
*/
export function extractVersionDetailsFromFilename(filename: string | null | undefined) {
const versionNum = extractVersionFromFilename(filename)
return {
versionNumber: versionNum || undefined,
versionType: versionType(versionNum),
}
}
+22 -6
View File
@@ -47,6 +47,7 @@
route.path !== '/settings/billing'
"
/>
<PreviewBanner v-if="config.public.buildEnv === 'production' && config.public.preview" />
<StagingBanner v-if="config.public.apiBaseUrl.startsWith('https://staging-api.modrinth.com')" />
<GeneratedStateErrorsBanner
:errors="generatedStateErrors"
@@ -339,6 +340,12 @@
link: '/admin/servers/notices',
shown: isAdmin(auth.user),
},
{
id: 'servers-transfers',
color: 'primary',
link: '/admin/servers/transfers',
shown: isAdmin(auth.user),
},
{
id: 'servers-nodes',
color: 'primary',
@@ -367,6 +374,9 @@
<template #servers-notices>
<IssuesIcon aria-hidden="true" /> {{ formatMessage(messages.manageServerNotices) }}
</template>
<template #servers-transfers>
<TransferIcon aria-hidden="true" /> Server transfers
</template>
<template #affiliates>
<AffiliateIcon aria-hidden="true" /> {{ formatMessage(messages.manageAffiliates) }}
</template>
@@ -695,6 +705,7 @@ import {
SettingsIcon,
ShieldAlertIcon,
SunIcon,
TransferIcon,
UserIcon,
UserSearchIcon,
XIcon,
@@ -713,6 +724,7 @@ import { isAdmin, isStaff, UserBadge } from '@modrinth/utils'
import TextLogo from '~/components/brand/TextLogo.vue'
import BatchCreditModal from '~/components/ui/admin/BatchCreditModal.vue'
import GeneratedStateErrorsBanner from '~/components/ui/banner/GeneratedStateErrorsBanner.vue'
import PreviewBanner from '~/components/ui/banner/PreviewBanner.vue'
import RussiaBanner from '~/components/ui/banner/RussiaBanner.vue'
import StagingBanner from '~/components/ui/banner/StagingBanner.vue'
import SubscriptionPaymentFailedBanner from '~/components/ui/banner/SubscriptionPaymentFailedBanner.vue'
@@ -742,9 +754,10 @@ const route = useNativeRoute()
const router = useNativeRouter()
const link = config.public.siteUrl + route.path.replace(/\/+$/, '')
const { data: payoutBalance } = await useAsyncData('payout/balance', () =>
useBaseFetch('payout/balance', { apiVersion: 3 }),
)
const { data: payoutBalance } = await useAsyncData('payout/balance', () => {
if (!auth.value.user) return null
return useBaseFetch('payout/balance', { apiVersion: 3 })
})
const showTaxComplianceBanner = computed(() => {
if (flags.value.testTaxForm && auth.value.user) return true
@@ -977,16 +990,19 @@ const navRoutes = computed(() => [
])
const userMenuOptions = computed(() => {
const user = auth.value.user
if (!user) return []
let options = [
{
id: 'profile',
link: `/user/${auth.value.user.username}`,
link: `/user/${user.username}`,
},
{
id: 'plus',
link: '/plus',
color: 'purple',
shown: !flags.value.hidePlusPromoInUserMenu && !isPermission(auth.value.user.badges, 1 << 0),
shown: !flags.value.hidePlusPromoInUserMenu && !isPermission(user.badges, 1 << 0),
},
{
id: 'servers',
@@ -1039,7 +1055,7 @@ const userMenuOptions = computed(() => {
{
id: 'affiliate-links',
link: '/dashboard/affiliate-links',
shown: auth.value.user.badges & UserBadge.AFFILIATE,
shown: user.badges & UserBadge.AFFILIATE,
},
{
id: 'revenue',
@@ -368,9 +368,18 @@
"collection.button.edit-icon": {
"message": "تعديل الأيقونة"
},
"collection.button.remove-icon": {
"message": "حذف الأيقونة"
},
"collection.button.remove-project": {
"message": "إزالة المشروع"
},
"collection.button.replace-icon": {
"message": "تغيير الأيقونة"
},
"collection.button.select-icon": {
"message": "تحديد الأيقونة"
},
"collection.button.unfollow-project": {
"message": "إلغاء متابعة المشروع"
},
+2 -2
View File
@@ -1,10 +1,10 @@
{
"displayName": {
"description": "Please enter the name of the language in its specific variant or regional form (e.g., English (US) for American English, not just English). If the language does not have any specific variant, simply enter the name of the language (e.g., Français, Deutsch).",
"message": "الإنجليزية (الولايات المتحدة)"
"message": "العربية (المملكة العربية السعودية)"
},
"searchTerms": {
"description": "Please provide additional search terms associated with the language, if needed, to enhance the search functionality (e.g., American English, Deutschland). Each search term should be entered on a separate line. Translate as a hyphen (-) if no additional terms are needed.",
"message": "الولايات المتحدة الأمريكية\nالإنجليزية الأمريكية"
"message": "المملكة العربية السعودية \nالعربية السعودية"
}
}
+271 -1
View File
@@ -153,7 +153,7 @@
"message": "Hrajte se svými oblíbenými módy"
},
"app-marketing.features.sharing.description": {
"message": "Vytvářejte, sdílejte a hrajte modpacky s některým z tisíců modů a modpacků hostovaných zde na Modrinthu."
"message": "Vytvářejte, sdílejte a hrajte modpacky s jakýmikoli z tisíců modů a modpacků hostovaných zde na Modrinthu."
},
"app-marketing.features.sharing.modpack": {
"message": "Modpack"
@@ -395,6 +395,9 @@
"collection.description.following": {
"message": "Automaticky generovaná kolekce všech projektů, které sledujete."
},
"collection.editing": {
"message": "Upravit kolekci"
},
"collection.error.not-found": {
"message": "Kolekce nenalezena"
},
@@ -410,9 +413,18 @@
"collection.label.details": {
"message": "Podrobnosti"
},
"collection.label.no-projects": {
"message": "Zatím žádné projekty v kolekci"
},
"collection.label.updated-at": {
"message": "Aktualizováno {ago}"
},
"collection.return-link.dashboard-collections": {
"message": "Vaše kolekce"
},
"collection.return-link.user": {
"message": "Profil {user}"
},
"collection.title": {
"message": "{name} - Kolekce"
},
@@ -446,6 +458,30 @@
"create.collection.summary-label": {
"message": "Souhrn"
},
"create.collection.title": {
"message": "Vytvořit kolekci"
},
"create.limit-alert.approaching-limit": {
"message": "Přibližujete se {type} limitu ({current}/{max})"
},
"create.limit-alert.contact-support": {
"message": "Kontaktujte podporu"
},
"create.limit-alert.limit-reached": {
"message": "limit {type} byl dosažen"
},
"create.limit-alert.type-collection": {
"message": "kolekce"
},
"create.limit-alert.type-organization": {
"message": "organizace"
},
"create.limit-alert.type-plural-collection": {
"message": "kolekce"
},
"create.limit-alert.type-plural-organization": {
"message": "organizace"
},
"create.limit-alert.type-plural-project": {
"message": "projekty"
},
@@ -455,27 +491,93 @@
"create.organization.cancel": {
"message": "Zrušit"
},
"create.organization.create-organization": {
"message": "Vytvořit organizaci"
},
"create.organization.error-title": {
"message": "Došlo k chybě"
},
"create.organization.name-label": {
"message": "Název"
},
"create.organization.name-placeholder": {
"message": "Zadej název organizace..."
},
"create.organization.ownership-info": {
"message": "Budete majitelem této organizace, ale mužete pozvat další členy a převést vlastníctví."
},
"create.organization.summary-description": {
"message": "Popiště vaši organizace dvěma nebo jednou větou."
},
"create.organization.summary-label": {
"message": "Shrnutí"
},
"create.organization.summary-placeholder": {
"message": "Organizace pro..."
},
"create.organization.title": {
"message": "Vytvořit organizaci"
},
"create.organization.url-label": {
"message": "URL"
},
"create.project.cancel": {
"message": "Zrušit"
},
"create.project.create-project": {
"message": "Vytvořit projekt"
},
"create.project.error-title": {
"message": "Došlo k chybě"
},
"create.project.name-label": {
"message": "Název"
},
"create.project.name-placeholder": {
"message": "Zadejte název projektu..."
},
"create.project.summary-description": {
"message": "Popiště vaši organizace dvěma nebo jednou větou."
},
"create.project.summary-label": {
"message": "Shrnutí"
},
"create.project.summary-placeholder": {
"message": "Tento projekt přidává..."
},
"create.project.title": {
"message": "Vytvořit projekt"
},
"create.project.url-label": {
"message": "URL"
},
"create.project.visibility-description": {
"message": "Viditelnost vašeho projektu poté co bude schválena."
},
"create.project.visibility-label": {
"message": "Viditelnost"
},
"create.project.visibility-private": {
"message": "Soukromé"
},
"create.project.visibility-public": {
"message": "Veřejné"
},
"create.project.visibility-unlisted": {
"message": "Neveřejné"
},
"dashboard.affiliate-links.create.button": {
"message": "Vytvořit affiliate odkaz"
},
"dashboard.affiliate-links.error.title": {
"message": "Error při načítáni affiliate odkazů"
},
"dashboard.affiliate-links.header": {
"message": "Vaše affiliate odkazy"
},
"dashboard.affiliate-links.revoke-confirm.button": {
"message": "Odvolat"
},
"dashboard.collections.button.create-new": {
"message": "Vytvořit novou"
},
@@ -488,30 +590,78 @@
"dashboard.collections.long-title": {
"message": "Vaše kolekce"
},
"dashboard.creator-tax-form-modal.confirmation.title": {
"message": "Hotovo! 🎉"
},
"dashboard.creator-tax-form-modal.us-citizen.question": {
"message": "Jste občan USA?"
},
"dashboard.creator-withdraw-modal.fee-breakdown-fee": {
"message": "Poplatek"
},
"dashboard.creator-withdraw-modal.method-selection.country-placeholder": {
"message": "Zvolte vaši zemi"
},
"dashboard.creator-withdraw-modal.method-selection.country-search-placeholder": {
"message": "Vyhladaj štát..."
},
"dashboard.creator-withdraw-modal.method-selection.region": {
"message": "Región"
},
"dashboard.creator-withdraw-modal.muralpay-details.account-owner": {
"message": "Majitel účtu"
},
"dashboard.creator-withdraw-modal.muralpay-details.coin": {
"message": "Kryptoměna"
},
"dashboard.creator-withdraw-modal.muralpay-details.document-number-passport": {
"message": "Číslo vašeho pasu"
},
"dashboard.creator-withdraw-modal.paypal-details.account": {
"message": "Účet"
},
"dashboard.creator-withdraw-modal.paypal-details.disconnect-account": {
"message": "Odpojit účet"
},
"dashboard.creator-withdraw-modal.paypal-details.payment-method": {
"message": "Platební metoda"
},
"dashboard.creator-withdraw-modal.paypal-details.paypal-account": {
"message": "PayPal účet"
},
"dashboard.creator-withdraw-modal.paypal-details.save-button": {
"message": "Uložit"
},
"dashboard.creator-withdraw-modal.paypal-details.saved-button": {
"message": "Uloženo"
},
"dashboard.creator-withdraw-modal.stage.tremendous-details": {
"message": "Podrobnosti"
},
"dashboard.creator-withdraw-modal.tremendous-details.reward": {
"message": "Odměna"
},
"dashboard.creator-withdraw-modal.tremendous-details.reward-plural": {
"message": "Odměny"
},
"dashboard.creator-withdraw-modal.tremendous-details.unverified-email-header": {
"message": "Neověřený email"
},
"dashboard.revenue.stats.transactions": {
"message": "Transakce"
},
"dashboard.withdraw.completion.account": {
"message": "Účet"
},
"dashboard.withdraw.completion.date": {
"message": "Datum"
},
"dashboard.withdraw.completion.fee": {
"message": "Poplatek"
},
"dashboard.withdraw.completion.wallet": {
"message": "Peněženka"
},
"error.collection.404.list_item.1": {
"message": "Možná jste zadali URL adresu kolekce špatně."
},
@@ -611,6 +761,9 @@
"landing.creator.feature.monetization.title": {
"message": "Monetizace"
},
"landing.heading.the-place-for-minecraft.modpacks": {
"message": "modpacky"
},
"landing.heading.the-place-for-minecraft.mods": {
"message": "módy"
},
@@ -635,9 +788,18 @@
"landing.search.sort-by.label": {
"message": "Seřadit podle"
},
"landing.section.for-players.label": {
"message": "Pro hráče"
},
"layout.action.change-theme": {
"message": "Změnit motiv"
},
"layout.action.create-new": {
"message": "Vytvořit nový..."
},
"layout.action.new-project": {
"message": "Nový projekt"
},
"layout.avatar.alt": {
"message": "Váš avatar"
},
@@ -716,6 +878,9 @@
"layout.footer.legal.terms-of-use": {
"message": "Podmínky Užívání"
},
"layout.footer.modrinth-information": {
"message": ""
},
"layout.footer.open-source": {
"message": "Modrinth je <github-link>open source</github-link>."
},
@@ -767,6 +932,12 @@
"layout.meta.og-description": {
"message": "Objevte a publikujte obsah Minecraftu!"
},
"layout.nav.analytics": {
"message": "Analytika"
},
"layout.nav.get-modrinth-app": {
"message": "Stáhnout Modrinth aplikaci"
},
"layout.nav.home": {
"message": "Domů"
},
@@ -776,12 +947,18 @@
"layout.nav.my-servers": {
"message": "Moje servery"
},
"layout.nav.organizations": {
"message": "Organizace"
},
"layout.nav.profile": {
"message": "Profil"
},
"layout.nav.projects": {
"message": "Projekty"
},
"layout.nav.saved-projects": {
"message": "Uložené projekty"
},
"layout.nav.search": {
"message": "Hledat"
},
@@ -809,9 +986,66 @@
"muralpay.country.at": {
"message": "Rakousko"
},
"muralpay.country.fi": {
"message": "Finsko"
},
"muralpay.country.fr": {
"message": "Francie"
},
"muralpay.country.gr": {
"message": "Řecko"
},
"muralpay.country.ie": {
"message": "Irsko"
},
"muralpay.country.it": {
"message": "Itálie"
},
"muralpay.country.lt": {
"message": "Litva"
},
"muralpay.country.lu": {
"message": "Lucembursko"
},
"muralpay.country.lv": {
"message": "Lotyšsko"
},
"muralpay.country.mt": {
"message": "Malta"
},
"muralpay.country.nl": {
"message": "Nizozemsko"
},
"muralpay.country.pt": {
"message": "Portugalsko"
},
"muralpay.country.sk": {
"message": "Slovensko"
},
"muralpay.field.account-type": {
"message": "Typ účtu"
},
"muralpay.field.bank-account-number": {
"message": "Číslo účtu"
},
"muralpay.field.country": {
"message": "Země"
},
"muralpay.field.iban": {
"message": "IBAN"
},
"muralpay.field.phone-number": {
"message": "Telefoní číslo"
},
"muralpay.pix-type.phone": {
"message": "Telefonní číslo"
},
"profile.bio.fallback.creator": {
"message": "Modrinth tvůrce."
},
"profile.bio.fallback.user": {
"message": "Modrinth uživatel."
},
"profile.button.billing": {
"message": "Spravujte fakturaci uživatele"
},
@@ -845,6 +1079,9 @@
"profile.label.organizations": {
"message": "Organizace"
},
"profile.label.saving": {
"message": "Ukládání..."
},
"profile.label.yes": {
"message": "Ano"
},
@@ -893,6 +1130,9 @@
"project-moderation-nags.required": {
"message": "Vyžadováno"
},
"project-moderation-nags.suggestion": {
"message": "Návrh"
},
"project-moderation-nags.warning": {
"message": "Varování"
},
@@ -968,6 +1208,12 @@
"project.actions.create-server-tooltip": {
"message": "Vytvořit server"
},
"project.actions.dont-show-again": {
"message": "Znovu nezobrazovat"
},
"project.actions.servers-promo.title": {
"message": "Vytvořit server"
},
"project.description.title": {
"message": "Popisek"
},
@@ -977,9 +1223,15 @@
"project.download.game-version": {
"message": "Verze hry: {version}"
},
"project.error.project-not-found": {
"message": "Projekt nebyl nalezen"
},
"project.gallery.title": {
"message": "Galerie"
},
"project.license.title": {
"message": "Licence"
},
"project.moderation.title": {
"message": "Moderace"
},
@@ -995,6 +1247,9 @@
"project.settings.general.url.title": {
"message": "URL"
},
"project.settings.title": {
"message": "Nastavení"
},
"project.stats.downloads-label": {
"message": "stažení{count, plural, one {} few {} many {} other {}}"
},
@@ -1145,6 +1400,18 @@
"scopes.projectDelete.label": {
"message": "Odstranit projekty"
},
"scopes.versionCreate.description": {
"message": "Vytvořit nové verze"
},
"scopes.versionCreate.label": {
"message": "Vytvořit verze"
},
"scopes.versionDelete.description": {
"message": "Odstranit verzi"
},
"scopes.versionDelete.label": {
"message": "Odstranit verze"
},
"servers.notice.id": {
"message": "ID"
},
@@ -1157,6 +1424,9 @@
"servers.plan.small.name": {
"message": "Malý"
},
"settings.billing.payment_method.primary": {
"message": "Hlavní"
},
"settings.profile.bio.title": {
"message": "Bio"
},
@@ -191,6 +191,9 @@
"app-marketing.hero.download-modrinth-app-for-os": {
"message": "Hent Modrinth Appen til {os}"
},
"app-marketing.hero.more-download-options": {
"message": "Flere Download Muligheder"
},
"auth.authorize.action.authorize": {
"message": "Godkend"
},
@@ -287,6 +290,66 @@
"auth.sign-up.label.username": {
"message": "Brugernavn"
},
"auth.sign-up.legal-dislaimer": {
"message": "Ved at oprette en konto, accepterer du Modrinth's <terms-link>Vilkår</terms-link> og <privacy-policy-link>Privatlivspolitik</privacy-policy-link>."
},
"auth.sign-up.notification.password-mismatch.text": {
"message": "Adgangskoderne er ikke ens!"
},
"auth.sign-up.password.label": {
"message": "Adgangskode"
},
"auth.sign-up.sign-in-option.title": {
"message": "Har du allerede en konto?"
},
"auth.sign-up.subscribe.label": {
"message": "Abonner på opdateringer om Modrinth"
},
"auth.sign-up.title": {
"message": "Tilmeld"
},
"auth.sign-up.title.create-account": {
"message": "Eller opret en konto selv"
},
"auth.sign-up.title.sign-up-with": {
"message": "Tilmeld dig med"
},
"auth.verify-email.action.account-settings": {
"message": "Kontoindstillinger"
},
"auth.verify-email.action.sign-in": {
"message": "Log ind"
},
"auth.verify-email.already-verified.description": {
"message": "Din e-mail er allerede bekræftet!"
},
"auth.verify-email.already-verified.title": {
"message": "E-mail allerede bekræftet"
},
"auth.verify-email.failed-verification.action": {
"message": "Send bekræftelsesmail igen"
},
"auth.verify-email.failed-verification.description": {
"message": "Vi kunne ikke bekræfte din e-mail. Prøv at sende bekræftelsesmailen igen via dit dashboard ved at logge ind."
},
"auth.verify-email.failed-verification.description.logged-in": {
"message": "Vi kunne ikke bekræfte din e-mail. Prøv at sende bekræftelsesmailen igen via knappen nedenfor."
},
"auth.verify-email.failed-verification.title": {
"message": "E-mailbekræftelse mislykkedes"
},
"auth.verify-email.post-verification.description": {
"message": "Din e-mailadresse er blevet bekræftet!"
},
"auth.verify-email.post-verification.title": {
"message": "E-mailbekræftelse"
},
"auth.verify-email.title": {
"message": "Bekræft E-mail"
},
"auth.welcome.checkbox.subscribe": {
"message": "Abonner på opdateringer om Modrinth"
},
"collection.button.remove-project": {
"message": "Slet projekt"
},
+32 -2
View File
@@ -897,7 +897,7 @@
"message": "Geschätzt {date}"
},
"dashboard.revenue.processing": {
"message": "Verarbeitet"
"message": "In verarbeitung"
},
"dashboard.revenue.processing.tooltip": {
"message": "Einnahmen bleiben in bearbeitung bis zum ende des Monats, und werden 60 Tage danach verfügbar."
@@ -980,12 +980,30 @@
"dashboard.withdraw.completion.wallet": {
"message": "Wallet"
},
"dashboard.withdraw.error.account-not-linked.text": {
"message": "Bitte verknüpfe dein Zahlungskonto, bevor du eine Auszahlung vornimmst."
},
"dashboard.withdraw.error.account-not-linked.title": {
"message": "Konto nicht verknüpft"
},
"dashboard.withdraw.error.email-verification.text": {
"message": "Du musst deine E-Mail-Adresse bestätigen, bevor du Geld auszahlen kannst."
},
"dashboard.withdraw.error.email-verification.title": {
"message": "E-Mail-Bestätigung erforderlich"
},
"dashboard.withdraw.error.generic.text": {
"message": "Wir konnten deine Auszahlungsanforderung nicht übermitteln. Bitte überprüfe deine Angaben oder kontaktiere den Support."
"message": "Wir konnten deine Auszahlungsanforderung nicht übermitteln. Bitte überprüfe deine Angaben oder kontaktiere den Support.\n{error}"
},
"dashboard.withdraw.error.generic.title": {
"message": "Abheben nicht möglich"
},
"dashboard.withdraw.error.insufficient-balance.text": {
"message": "Du hast nicht genug Guthaben, um diese Auszahlung vorzunehmen."
},
"dashboard.withdraw.error.insufficient-balance.title": {
"message": "Unzureichender Kontostand"
},
"dashboard.withdraw.error.invalid-address.text": {
"message": "Die angegebene Adresse konnte nicht bestätigt werden. Bitte überprüfe deine Adressdetails."
},
@@ -1010,6 +1028,12 @@
"dashboard.withdraw.error.minimum-not-met.title": {
"message": "Menge zu gering"
},
"dashboard.withdraw.error.paypal-country-mismatch.text": {
"message": "Bitte nutze die richtige PayPal-Überweisungsoption für deine Region (US oder International)."
},
"dashboard.withdraw.error.paypal-country-mismatch.title": {
"message": "PayPal-Region stimmt nicht überein"
},
"dashboard.withdraw.error.tax-form.text": {
"message": "Du musst ein Steuerformular ausfüllen, um deinen Antrag auf Auszahling einzureichen."
},
@@ -1313,6 +1337,12 @@
"layout.banner.build-fail.title": {
"message": "Fehler beim generieren des Status von der API beim erstellen."
},
"layout.banner.preview.description": {
"message": "Falls du die offizielle Modrinth-Website aufrufen wolltest, besuch <link>https://modrinth.com</link>. Diese Vorschauversion wird von Modrinth-Mitarbeitern zu Testzwecken genutzt. Sie wurde unter Verwendung von <branch-link>{owner}/{branch}</branch-link> @ {commit} erstellt."
},
"layout.banner.preview.title": {
"message": "Dies ist eine Vorschauversion der Modrinth-Webseite."
},
"layout.banner.staging.description": {
"message": "Die Staging-Umgebung ist komplett separat von der Produktions-Datenbank von Modrinth. Sie wird zu Test- und Debugging-Zwecken verwendet und könnte Entwicklungsversionen des Modrinth-Backends oder -Frontends, welche neuer als die Produkionsinstanz sind, ausführen."
},
+42 -12
View File
@@ -27,7 +27,7 @@
"message": "Linux"
},
"app-marketing.download.linux-disclaimer": {
"message": "Die Linuxversionen der Modrinth App <issues-link>haben auf bestimmten Systemen und Konfigurationen bekannte Probleme</issues-link>. Wenn die Modrinth App auf deinem System instabil läuft, empfehlen wir, andere Apps wie <prism-link>Prism Launcher</prism-link> auszuprobieren, um Modrinth-Inhalte einfach zu installieren."
"message": "Die Linuxversionen der Modrinth App haben auf bestimmten Systemen und Konfigurationen <issues-link>bekannte Probleme</issues-link>. Wenn die Modrinth App auf deinem System instabil läuft, empfehlen wir, andere Apps wie <prism-link>Prism Launcher</prism-link> auszuprobieren, um Modrinth-Inhalte einfach zu installieren."
},
"app-marketing.download.mac": {
"message": "Mac"
@@ -99,7 +99,7 @@
"message": "Der Modrinth-Launcher ist vollständig quelloffen. Den Quellcode kannst du auf unserem <github-link>GitHub</github-link> einsehen!"
},
"app-marketing.features.open-source.title": {
"message": "Open Source"
"message": "Quelloffen"
},
"app-marketing.features.performance.activity-monitor": {
"message": "Aktivitätsmonitor"
@@ -621,7 +621,7 @@
"message": "Dein {formType} Steuerformular wurde erfolgreich eingereicht!"
},
"dashboard.creator-tax-form-modal.confirmation.support-text": {
"message": "Du kannst jetzt frei abheben. Wenn du Fragen hast oder deine Angaben aktualisieren musst, <support-link>wende dich an den Support</support-link>."
"message": "Du kannst jetzt jederzeit auszahlen. Falls du Fragen hast oder deine Angaben aktualisieren möchtest, <support-link>kontaktiere den Support</support-link>."
},
"dashboard.creator-tax-form-modal.confirmation.title": {
"message": "Du bist startklar! 🎉"
@@ -897,7 +897,7 @@
"message": "Geschätzt {date}"
},
"dashboard.revenue.processing": {
"message": "Verarbeitet"
"message": "In Verarbeitung"
},
"dashboard.revenue.processing.tooltip": {
"message": "Einnahmen bleiben in bearbeitung bis zum ende des Monats, und werden 60 Tage danach verfügbar."
@@ -912,7 +912,7 @@
"message": "Ausgezahlt"
},
"dashboard.revenue.tos": {
"message": "Durch das hochladen von Projekten auf Modrinth, und das abheben von Geld von deinem Konto, stimmst du den <terms-link>Bedingungen des Rewards-Programm</terms-link> zu. Erfahre mehr über das <info-link>Reward-Programm</info-link>."
"message": "Durch das Hochladen von Projekten auf Modrinth und das Auszahlen von Geld von deinem Konto stimmst du unseren <terms-link>Bedingungen für das Prämienprogramm</terms-link> zu. Erfahre mehr über das <info-link>Prämienprogramm</info-link>."
},
"dashboard.revenue.transactions.btn.download-csv": {
"message": "Als CSV herunterladen"
@@ -936,10 +936,10 @@
"message": "Hebe von deinem Verfügbaren Guthaben über einen Zahlungsmethode ab."
},
"dashboard.revenue.withdraw.card.title": {
"message": "Abheben"
"message": "Auszahlen"
},
"dashboard.revenue.withdraw.header": {
"message": "Abheben"
"message": "Auszahlen"
},
"dashboard.withdraw.completion.account": {
"message": "Konto"
@@ -980,11 +980,29 @@
"dashboard.withdraw.completion.wallet": {
"message": "Wallet"
},
"dashboard.withdraw.error.account-not-linked.text": {
"message": "Bitte verknüpfe dein Zahlungskonto, bevor du eine Auszahlung vornimmst."
},
"dashboard.withdraw.error.account-not-linked.title": {
"message": "Konto nicht verknüpft"
},
"dashboard.withdraw.error.email-verification.text": {
"message": "Du musst deine E-Mail-Adresse bestätigen, bevor du Geld auszahlen kannst."
},
"dashboard.withdraw.error.email-verification.title": {
"message": "E-Mail-Bestätigung erforderlich"
},
"dashboard.withdraw.error.generic.text": {
"message": "Wir konnten deine Auszahlungsanforderung nicht übermitteln. Bitte überprüfe deine Angaben oder kontaktiere den Support."
"message": "Wir konnten deine Auszahlungsanforderung nicht übermitteln. Bitte überprüfe deine Angaben oder wende dich an den Support.\n{error}"
},
"dashboard.withdraw.error.generic.title": {
"message": "Abheben nicht möglich"
"message": "Auszahlung nicht möglich"
},
"dashboard.withdraw.error.insufficient-balance.text": {
"message": "Du hast nicht genug Guthaben, um eine Auszahlung vorzunehmen."
},
"dashboard.withdraw.error.insufficient-balance.title": {
"message": "Unzureichender Kontostand"
},
"dashboard.withdraw.error.invalid-address.text": {
"message": "Die angegebene Adresse konnte nicht bestätigt werden. Bitte überprüfe deine Adressdetails."
@@ -1010,6 +1028,12 @@
"dashboard.withdraw.error.minimum-not-met.title": {
"message": "Menge zu gering"
},
"dashboard.withdraw.error.paypal-country-mismatch.text": {
"message": "Bitte nutze die richtige PayPal-Überweisungsoption für deine Region (USA oder International)."
},
"dashboard.withdraw.error.paypal-country-mismatch.title": {
"message": "PayPal-Region stimmt nicht überein"
},
"dashboard.withdraw.error.tax-form.text": {
"message": "Du musst ein Steuerformular ausfüllen, um deinen Antrag auf Auszahlung einzureichen."
},
@@ -1080,7 +1104,7 @@
"message": "Der Besitzer des Projekts hat eventuell die URL geändert, das Projekt auf privat gestellt oder es gelöscht."
},
"error.project.404.list_item.3": {
"message": "Das Projekt wurde möglicherweise von Modrinths Moderationsteam wegen Verstoßes gegen unsere <tou-link>Nutzungsbedingungen</tou-link> entfernt."
"message": "Das Projekt wurde möglicherweise vom Modrinth-Moderationsteam wegen eines Verstoßes gegen unsere <tou-link>Nutzungsbedingungen</tou-link> entfernt."
},
"error.project.404.list_title": {
"message": "Warum?"
@@ -1110,7 +1134,7 @@
"message": "Ein fotorealistisches Gemälde eines Frosch-Labyrinths"
},
"frog.froggedPeople": {
"message": "{count, plural, one {{count} weitere Person wurde} other {{count} weitere Personen wuden}} ebenfalls gefroscht!"
"message": "{count, plural, one {{count} weitere Person wurde} other {{count} weitere Personen wurden}} ebenfalls gefroscht!"
},
"frog.sinceOpened": {
"message": "Diese Seite wurde {ago} geöffnet"
@@ -1313,6 +1337,12 @@
"layout.banner.build-fail.title": {
"message": "Fehler beim Generieren des Status aus der API beim Erstellen."
},
"layout.banner.preview.description": {
"message": "Falls du die offizielle Modrinth-Website aufrufen wolltest, besuch <link>https://modrinth.com</link>. Diese Vorschauversion wird von Modrinth-Mitarbeitern zu Testzwecken genutzt. Sie wurde unter Verwendung von <branch-link>{owner}/{branch}</branch-link> @ {commit} erstellt."
},
"layout.banner.preview.title": {
"message": "Dies ist eine Vorschauversion der Modrinth-Website."
},
"layout.banner.staging.description": {
"message": "Die Staging-Umgebung ist vollständig von der Modrinth Produktionsdatenbank getrennt. Sie wird zu Test- und Debugging-Zwecken verwendet und kann Entwicklungsversionen des Modrinth Backends oder -Frontends ausführen, die neuer sind als die Produktionsinstanz."
},
@@ -1374,7 +1404,7 @@
"message": "Rechtliches"
},
"layout.footer.legal-disclaimer": {
"message": "KEIN OFFIZIELLER MINECRAFT-DIENST. NICHT VON MOJANG ODER MICROSOFT UNTERSTÜTZT ODER ASSOZIIERT."
"message": "KEIN OFFIZIELLER MINECRAFT-DIENST. NICHT VON MOJANG ODER MICROSOFT UNTERSTÜTZT ODER MIT IHNEN ASSOZIIERT."
},
"layout.footer.legal.copyright-policy": {
"message": "Urheberrechtsrichtlinie und DMCA"
+18 -3
View File
@@ -23,6 +23,9 @@
"app-marketing.download.download-rpm": {
"message": "Download the RPM"
},
"app-marketing.download.flathub": {
"message": "Get it on Flathub"
},
"app-marketing.download.linux": {
"message": "Linux"
},
@@ -38,9 +41,6 @@
"app-marketing.download.terms": {
"message": "By downloading Modrinth App you agree to our <terms-link>Terms</terms-link> and <privacy-link>Privacy Policy</privacy-link>."
},
"app-marketing.download.third-party-packages": {
"message": "Third-party packages"
},
"app-marketing.download.title": {
"message": "Download Modrinth App (Beta)"
},
@@ -197,6 +197,15 @@
"app-marketing.hero.more-download-options": {
"message": "More Download Options"
},
"app-marketing.hide-other-packages": {
"message": "Hide other packages"
},
"app-marketing.not-recommended": {
"message": "We don't recommend you use these unless you know what you're doing."
},
"app-marketing.show-other-packages": {
"message": "Show other packages"
},
"auth.authorize.action.authorize": {
"message": "Authorize"
},
@@ -1337,6 +1346,12 @@
"layout.banner.build-fail.title": {
"message": "Error generating state from API when building."
},
"layout.banner.preview.description": {
"message": "If you meant to access the official Modrinth website, visit <link>https://modrinth.com</link>. This preview deploy is used by Modrinth staff for testing purposes. It was built using <branch-link>{owner}/{branch}</branch-link> @ {commit}."
},
"layout.banner.preview.title": {
"message": "This is a preview deploy of the Modrinth website."
},
"layout.banner.staging.description": {
"message": "The staging environment is completely separate from the production Modrinth database. This is used for testing and debugging purposes, and may be running in-development versions of the Modrinth backend or frontend newer than the production instance."
},
+33 -3
View File
@@ -351,7 +351,7 @@
"message": "Verificar correo electrónico"
},
"auth.welcome.checkbox.subscribe": {
"message": "Suscribirse para recibir actualizaciones sobre Modrinth"
"message": "Suscríbete para recibir actualizaciones sobre Modrinth"
},
"auth.welcome.description": {
"message": "Ahora formas parte de la increíble comunidad de creadores y exploradores que ya están construyendo, descargando y manteniéndose al día con mods asombrosos."
@@ -980,12 +980,30 @@
"dashboard.withdraw.completion.wallet": {
"message": "Cartera"
},
"dashboard.withdraw.error.account-not-linked.text": {
"message": ""
},
"dashboard.withdraw.error.account-not-linked.title": {
"message": "Cuenta no vinculada"
},
"dashboard.withdraw.error.email-verification.text": {
"message": "Debes verificar tu correo electrónico antes de retirar fondos."
},
"dashboard.withdraw.error.email-verification.title": {
"message": "Verificación de correo electrónico requerida"
},
"dashboard.withdraw.error.generic.text": {
"message": "No hemos podido enviar tu solicitud de retiro. Por favor, revisa tus datos o ponte en contacto con el servicio de asistencia."
"message": "No hemos podido enviar tu solicitud para retirar. Por favor, revisa tus datos o contacta con soporte.\n{error}"
},
"dashboard.withdraw.error.generic.title": {
"message": "No se puede retirar"
},
"dashboard.withdraw.error.insufficient-balance.text": {
"message": "No tienes fondos suficientes para realizar este retiro."
},
"dashboard.withdraw.error.insufficient-balance.title": {
"message": "Saldo insuficiente"
},
"dashboard.withdraw.error.invalid-address.text": {
"message": "No se ha podido verificar la dirección que has introducido. Por favor, revisa tus datos de dirección."
},
@@ -1010,6 +1028,12 @@
"dashboard.withdraw.error.minimum-not-met.title": {
"message": "Monto demasiado bajo"
},
"dashboard.withdraw.error.paypal-country-mismatch.text": {
"message": "Por favor, usa el metodo de transferencia de PayPal para tu region (USA o internacional)."
},
"dashboard.withdraw.error.paypal-country-mismatch.title": {
"message": "La region de PayPal no coincide"
},
"dashboard.withdraw.error.tax-form.text": {
"message": "Debes completar un formulario fiscal para poder enviar tu solicitud de retiro."
},
@@ -1113,7 +1137,7 @@
"message": "{count, plural, one {{count} persona más} other {{count} personas más}} ¡han sido raneadas!"
},
"frog.sinceOpened": {
"message": "Esta página se abrió {ago}"
"message": "Esta página fue abierta {ago}"
},
"frog.title": {
"message": "Rana"
@@ -1313,6 +1337,12 @@
"layout.banner.build-fail.title": {
"message": "Error al generar el estado desde la API durante la compilación."
},
"layout.banner.preview.description": {
"message": "Si desea acceder al sitio web oficial de Modrinth, visite <link>https://modrinth.com</link>. El equipo de Modrinth utiliza esta versión preliminar para realizar pruebas. Se creó con <branch-link>{owner}/{branch}</branch-link> @ {commit}.."
},
"layout.banner.preview.title": {
"message": "Esta es una vista previa de la implementación del sitio web de Modrinth."
},
"layout.banner.staging.description": {
"message": "El entorno de prueba está completamente separado de la base de datos de producción de Modrinth. Se utiliza con fines de prueba y depuración, y puede estar ejecutando versiones en desarrollo del backend o frontend de Modrinth más recientes que la instancia de producción."
},
@@ -809,6 +809,9 @@
"dashboard.creator-withdraw-modal.stage.tax-form": {
"message": "Formulario fiscal"
},
"dashboard.creator-withdraw-modal.stage.tremendous-details": {
"message": "Detalles"
},
"dashboard.creator-withdraw-modal.tax-form-required.body": {
"message": "Para retirar el saldo total <b>{available}</b> disponible, rellene el siguiente formulario. Es necesario para la declaración de impuestos y solo hay que hacerlo una vez."
},
@@ -821,6 +824,9 @@
"dashboard.creator-withdraw-modal.withdraw-button": {
"message": "Retirada"
},
"dashboard.revenue.available-now": {
"message": "Ya disponible"
},
"dashboard.revenue.tos": {
"message": "Al subir proyectos a Modrinth y retirar dinero de tu cuenta, aceptas nuestros <terms-link>Términos del programa de recompensas</terms-link>. Más información sobre el <info-link>Programa de recompensas</info-link>."
},
@@ -836,6 +842,9 @@
"dashboard.withdraw.completion.method": {
"message": "Método"
},
"dashboard.withdraw.completion.wallet": {
"message": "Cartera"
},
"dashboard.withdraw.error.invalid-bank.text": {
"message": ""
},
File diff suppressed because it is too large Load Diff

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