Compare commits

..
146 Commits
Author SHA1 Message Date
Calum H. (IMB11) 6f9e9e0de7 feat: server play frontend draft 2026-07-13 14:30:42 +01:00
Calum H. (IMB11) ad969a346a fix: align with backend 2026-07-10 22:08:00 +01:00
Calum H. (IMB11) 877afa7278 chore: reduced unused code 2026-07-10 17:00:20 +01:00
Calum H. (IMB11) fe6772874a refactor: friends composable + dedupe friends logic across usages 2026-07-10 16:56:35 +01:00
Calum H. (IMB11) 93a1fc29f0 refactor: dedupe sql for instance metadata enrichmnt 2026-07-10 16:42:34 +01:00
Calum H. (IMB11) 47cd7508cf refactor: split up install shared to own runner + shared.rs split up 2026-07-10 16:27:53 +01:00
Calum H. (IMB11) 63a60b292c refactor: centralized managed content 2026-07-10 16:17:56 +01:00
Calum H. (IMB11) 913dde963b refactor: dedicated shared instances install/update modals + split up page 2026-07-10 15:58:24 +01:00
Calum H. (IMB11) b50d1d07de refactor: split up shared instances state outside of instance index 2026-07-10 15:44:38 +01:00
Calum H. (IMB11) d1ca6f4933 refactor: break up Share.vue 2026-07-10 15:36:15 +01:00
Calum H. (IMB11) 13588fc33d refactor: move app.vue shared instances into handler 2026-07-10 15:26:37 +01:00
Calum H. (IMB11) d8633df1b3 fix: logging in reqwests 2026-07-10 15:02:40 +01:00
Calum H. (IMB11) 9bdb1ef5bd refactor: dedupe shared instance diff detection 2026-07-10 14:11:39 +01:00
Calum H. (IMB11) 70cc6c3a62 refactor: centralise error presentations 2026-07-10 13:58:13 +01:00
Calum H. (IMB11) e8ea8729f3 Merge remote-tracking branch 'origin/main' into cal/peer-to-peer
# Conflicts:
#	apps/app-frontend/src/helpers/install.ts
#	packages/app-lib/src/install/mod.rs
#	packages/app-lib/src/install/model.rs
#	packages/app-lib/src/install/recovery.rs
#	packages/app-lib/src/install/runner.rs
#	packages/ui/src/components/notifications/NotificationToast.vue
2026-07-10 13:45:14 +01:00
Calum H. (IMB11) ed87b627cf refactor: structured shared-instance unavailable errors 2026-07-10 13:41:55 +01:00
Calum H. (IMB11) dcfa5725ba fix: drop anonymous invite link acceptance 2026-07-10 13:29:09 +01:00
Calum H. (IMB11) 157208768c fix: server-manager DTO mismatch 2026-07-10 13:19:03 +01:00
Calum H. (IMB11) de1bc58b01 feat: invite page 2026-07-10 13:11:13 +01:00
Calum H.andGitHub c07727aa49 feat: install flow improvements (#6669)
* feat: better error handling + copy details btn for info

* refactor: clean up error handling into diagnostics

* feat: extra info for failure states + queuing properly

* fix: cleanup

* fix: lint

* fix: fmt

* fix: cleanup

* fix: cleanup

* fix: lint

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

* lints

* fmt

* fmt

* Split Daedalus changes out of frontend branch

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

* feat: use os icons from assets repo

* revert initial changes

* fix: lint
2026-07-09 21:43:13 +00:00
Truman GaoandGitHub 46f94bd067 fix: download modal no compatible empty state (#6680) 2026-07-09 21:37:09 +00:00
Calum H. (IMB11) adc1094cd5 fix: wrong cmd 2026-07-09 22:13:26 +01:00
Calum H.andGitHub 6da553bc48 [do not merge] feat: rough invite links impl temp (#6666) 2026-07-09 21:52:25 +01:00
aecsocketandGitHub e877167db7 feat: global trace database frontend + improvements to tech review status sync (#6671)
* wip

* ui edits

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

* never automatically finish a tech review report

* fix css

* fix clippy

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

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

* fix: download multiple files immediately triggers blocking

* refactor: implement download modal provider

* fix: duplicate dependencies and style update for dependecy section

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

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

* feat: implement selecting compatible versions when there are multiple

* fix: update dependencies version table to any compatible version

* fix: version showing before depedencies loaded

* feat: add platform options sort order

* pnpm prepr

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

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

* pnpm prepr

* fix: required resource pack not showing footer actions

* fix: download with deps is downloading all files

* feat: add joined button for downloading with recommended

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

* pnpm prepr

* fix: lint

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

* fix: date published capitalization

* pnpm prepr

---------

Co-authored-by: Prospector <6166773+Prospector@users.noreply.github.com>
2026-07-08 22:54:23 +00:00
SychicandGitHub 5f8837604d feat(labrinth): shared instance icon (#6665) 2026-07-08 22:45:15 +00:00
ProspectorandGitHub 795dad040f feat: add complementary installer source support (#6667) 2026-07-08 15:44:59 -07:00
Calum H. (IMB11) 1067894a82 fix: lint 2026-07-08 18:19:54 +01:00
Calum H. (IMB11) dbea158e16 feat: lock content if part of shared instance 2026-07-08 18:19:42 +01:00
Calum H. (IMB11) b945871320 Merge remote-tracking branch 'origin/main' into cal/peer-to-peer 2026-07-08 17:52:50 +01:00
Calum H. (IMB11) 5511662a94 fix: fmt + lint 2026-07-08 17:48:38 +01:00
Truman GaoandGitHub 9ca181dc1a fix: privacy popup in app bounded to tiny webview (#6657) 2026-07-08 16:29:41 +00:00
aecsocketandGitHub c84b658e41 feat: tech review global trace database (#6648)
* global detail verdicts system

* global traces frontend

* split global routes into own mod

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

* prepr
2026-07-08 16:06:45 +00:00
Calum H. (IMB11) a562bf9e8d feat: qa 2026-07-08 16:53:07 +01:00
Calum H. (IMB11) a1876eb3ec fix: signing out messes up revoke/deleted checks 2026-07-08 16:25:36 +01:00
Calum H. (IMB11) 17852f959b feat: qa 2026-07-08 16:15:29 +01:00
Prospector 9006dce2b0 changelog 2026-07-08 08:06:13 -07:00
ProspectorandGitHub efdfb51149 fix: add DEI (downloads, equality, and inequality) (#6664)
fix: add DEI
2026-07-08 15:04:21 +00:00
ProspectorandGitHub 79cc5fef72 feat: exclude project types filter (#6660)
* feat: exclude project types filter

* fix merge issue
2026-07-08 14:30:47 +00:00
Calum H. (IMB11) 20ed8e22cf feat: qa 2026-07-08 12:10:07 +01:00
Truman GaoandGitHub ddefc28ca6 fix: instance edits not emitting edit event to reload state (#6658) 2026-07-08 09:16:32 +00:00
654757f9fb docs: replace broken loc count with static count (#6615)
* docs: replace broken loc count with static count

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

* feat: update to use loctopus api

credits to @creeperkatze <3

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

---------

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

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

* add docs

* wip: daedalus version groups

* wip: app-side logic

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

* lints

* fmt

* fmt

* Split frontend changes out of Daedalus branch
2026-07-07 16:45:33 +00:00
ProspectorandGitHub df8ebbd3e0 feat: add /build route (#6644) 2026-07-07 15:43:26 +00:00
Calum H. (IMB11) a34f5b18c9 fix: fmt+prepr 2026-07-07 16:19:17 +01:00
Calum H. (IMB11) 92f8eb40d5 fix: 401/404 differentiation 2026-07-07 16:12:20 +01:00
Calum H. (IMB11) 38f02ea9a0 fix: install bug 2026-07-07 15:22:16 +01:00
Calum H. (IMB11) 44ce6bfc33 fix: smartly apply updates 2026-07-07 14:33:36 +01:00
Calum H. (IMB11) a1ea6903f5 feat: qa 2026-07-07 14:15:05 +01:00
Calum H. (IMB11) d88d555329 feat: wrong account protection 2026-07-07 13:43:45 +01:00
Calum H. (IMB11) 0723ef0aa5 feat: shared instances qa 2026-07-07 12:40:36 +01:00
Calum H. (IMB11) 5b86bb8c4a Merge remote-tracking branch 'origin/main' into cal/peer-to-peer 2026-07-07 09:33:37 +01:00
François-Xavier TalbotandGitHub 517c3d2d72 fix(labrinth): fix prefix stripping in mrpack scanning (#6646) 2026-07-07 01:31:07 +00:00
Prospector 7f15772f59 changelog 2026-07-06 15:19:29 -07:00
ProspectorandGitHub 0905ea72f6 fix: project page in app not receiving instance context & port version page to app (#6618)
* fix: project page in app not receiving instance context

* prepr

* feat: port version page to app

* commonize keys

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

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

* fix: i18n

* prepare

---------

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

* fix(ci): sudo

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

* fix: ui

* fix: lint

---------

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

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

* Update parse.ts

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

---------

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

* fix: game version and platform tag overflow issues

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

* fix: platform plural

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

* feat: implement new project download modal

* fix: button trigger placeholder text styles

* feat: dependency download button

* refactor: move project download modal into folder

* refactor: separate out download dependencies section

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

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

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

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

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

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

* feat: implement incompatible options due to base project

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

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

* pnpm prepr

* feat: update tooltip copy

* feat: polish some styles

* fix: fabric dependency showing when quilt is selected

* fix: tooltip hover area

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

* feat: add link to version page

* feat: add truncated tooltip

* pnpm prepr

* feat: add warning icon for incompatible selection

* feat: polish loading state and tooltip

* feat: implement download all dependencies

* pnpm prepr

* fix: light theme card

* fix: additional files label

* refactor: move additional files into download dependencies section

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

* fix color

* fix: dependencies not appearing when open modal second time

* fix: dependency lines when 3 nested

* feat: remove duplicated dependencies

* feat: add download count indicateor on download all button

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

* pnpm prepr

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

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

* fix tombi

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

* response schemas

* fix: restore labrinth docs routes

* Fix path parameter docs in routes

* remove utoipa-actix-web

* consistency

* improve version intros

* improve formatting, examples

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

* more utoipa param fixes

* request body docs

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

* fixes

* chore: tweak sources titles

* fix

* fix test

* improve examples

* increase compiler spawned thread stack size

* remove unused tests & script

* test

* bro what

* fix

---------

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

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

* chore: fmt
2026-07-03 17:06:58 +00:00
Calum H. (IMB11) 2dbc982175 Merge remote-tracking branch 'origin' into cal/peer-to-peer 2026-07-03 17:29:27 +01:00
Calum H. (IMB11) 50e776ba4b fix: send instance name 2026-07-03 17:29:20 +01:00
Prospector 0dac5684af changelog 2026-07-03 08:16:29 -07:00
coolbotandGitHub ca0b8d5ca4 add excessive languages warning nag (#6587) 2026-07-03 12:12:07 +00:00
coolbotandGitHub b6651efe52 Moderation Fix: Handle more license cases (#6590)
Apply correct behavior in edge cases
2026-07-02 17:26:10 +00:00
Calum H. (IMB11) 1286480a8a Merge remote-tracking branch 'origin/main' into cal/peer-to-peer 2026-07-02 17:49:34 +01:00
aecsocketandGitHub eb28121c0a fix: scan logic file whitelist + rescan route (#6585)
* explicitly only scan jar/zip/disabled files for attributions

* add route to force scan file

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

also introduce new notification that doesn't send an email

* style(labrinth): derive default

* feat(labrinth): return more detailed failure information for external
notifications
2026-07-02 15:44:47 +00:00
Calum H. (IMB11) 70f08275af feat: warnings on deleting/disabling shared-instance version content 2026-07-02 14:23:32 +01:00
Calum H. (IMB11) 2da493e641 feat: impl usage of UpdateToPlayModal 2026-07-02 10:30:05 +01:00
Calum H. (IMB11) 2351195d70 feat: impl install to play 2026-07-02 09:56:17 +01:00
Calum H. (IMB11) 7726b2a5a3 fix: lint + fmt 2026-07-02 09:45:25 +01:00
Calum H. (IMB11) fa286a7174 feat: invite notif accepting 2026-07-02 09:36:44 +01:00
Calum H. (IMB11) d6bd6a2c60 feat: support modpack links 2026-07-02 09:09:01 +01:00
Prospector c530671be1 fix collection widget on mobile 2026-07-01 18:09:12 -07:00
Prospector bb6d8a6fc4 changelog 2026-07-01 17:44:29 -07:00
ProspectorandGitHub 256e8f946a chore: pride month fundraiser conclusion (#6588)
* pride end blog post

* Fix drago link

* prepr oops
2026-07-01 17:43:44 -07:00
Calum H. (IMB11) 60fa3b7609 fix: impl get + del usage 2026-07-01 19:54:33 +01:00
Calum H.andGitHub 7e5590b8b9 fix: improve observability during fetch failure (#6555)
* fix: improve observability during fetch failure

+ remove analytics log spam

* fix: rev comments
2026-07-01 15:58:20 +00:00
SychicandGitHub d20f05fb04 feat(labrinth): instance share notification (#6581) 2026-07-01 14:21:56 +00:00
Truman GaoandGitHub a00a7971d1 feat: add tooltip for unknown dependents (#6572) 2026-07-01 05:21:42 +00:00
aecsocketandGitHub 26e98b7155 fix: change Tiltify campaign errors to quieter log level v2 (#6576)
import
2026-06-30 21:36:47 +02:00
aecsocketandGitHub 77b06f679e fix: change Tiltify campaign errors to quieter log level (#6575) 2026-06-30 21:34:37 +02:00
Truman GaoandGitHub 50e9472dd9 fix: cannot set owner for creating projects (#6573) 2026-06-30 17:00:10 +00:00
Calum H. (IMB11) 52c38f7506 feat: impl instance admonitions like server panel 2026-06-30 16:39:52 +01:00
Calum H. (IMB11) 4296e3b51f fix: admon style 2026-06-30 16:22:06 +01:00
Calum H. (IMB11) 23ce9bc129 feat: base shared instances implementation 2026-06-30 16:11:40 +01:00
ThatGravyBoatandGitHub b569354793 fix: packs not being validated correctly (#6533) 2026-06-30 14:02:58 +00:00
Calum H. (IMB11) e9c141298d feat: use tanstack queries for friends sync across app pages 2026-06-30 14:01:37 +01:00
Calum H. (IMB11) 7c7f3584ac feat: invite players modal 2026-06-30 12:54:42 +01:00
Calum H. (IMB11) 0ec70d29b9 feat: implement instance share page + search_users backend call 2026-06-30 12:39:45 +01:00
Michael H. b667b3ae9e fix: remove sccache s3 2026-06-30 11:41:08 +02:00
coolbotandGitHub 16de6182e4 Add nag for custom license info. (#6564)
* add nag for custom license info

* preppr
2026-06-30 02:25:13 +00:00
Calum H.andGitHub 364fed52dd feat: changelog (#6563) 2026-06-30 01:57:11 +02:00
Calum H.andGitHub 0fe695721a fix: try fix app freezing on instance page load (#6562)
* fix: app freezing fix

* fix: fmt
2026-06-30 01:55:55 +02:00
Calum H.andGitHub 692f22b749 feat: changelog 0.15.3 (#6560)
feat: changlog 0.15.3
2026-06-30 00:54:16 +02:00
Calum H.andGitHub ed7792097d fix: better handling of larger instances for ExportModal (#6559) 2026-06-30 00:51:25 +02:00
Prospector c56ce6c9ee changelog 2026-06-29 13:45:04 -07:00
Calum H.andGitHub 91bc58b8ee fix: cleanup stale db entry on rename instance file (#6549)
fix: race on renaming instance file
2026-06-29 17:36:46 +00:00
Truman GaoandGitHub 38d757b66a fix: analytics colour pick is not excluding unknown (#6552)
* fix: analytics colour picking not excluding unknown

* feat: improve colours, better accessibility
2026-06-29 17:17:03 +00:00
Calum H.andGitHub 862b54f00d refactor: instance export modal (#6543)
* refactor: export modal w the design system

* fix: lint

* fix: rev

* fix: lint

* fix: qa
2026-06-29 17:01:00 +00:00
Calum H.andGitHub c2f4835fe7 fix: spacing + friends (#6550) 2026-06-29 17:00:59 +00:00
ThatGravyBoatandGitHub b97c191d75 fix(docs): create version endpoint missing file_types field (#6551) 2026-06-29 16:22:08 +00:00
Truman GaoandGitHub b0c6a06909 feat: update versions table (#6502)
* feat: add new versions table, move environments column to be feature flag toggled, and add header/cell class props to Table.vue

* feat: implement much better table column widths

* feat: update version filter control to use multiselect

* feat: implement clicking table row to go to version

* feat: mobile pass on versions table

* feat: use multiselect for platform, and fix game version show all versions when dont exist

* pnpm prepr

* feat: replace dashboard projects table with new table

* feat: update projects so its out of card

* fix: version actions buttons column width

* feat: implement overflow on loaders cell

* update platform and version width

* feat: update project icon

* feat: change edit links to use floating action bar

* pnpm prepr
2026-06-29 15:01:07 +00:00
ThatGravyBoatandGitHub fc3e384f54 fix: tech review card not refreshing on status change (#6508)
fix: tech rev card not refreshing on status change
2026-06-29 14:40:20 +00:00
Calum H.andGitHub 87ff8fd4d9 fix: server panel not using correct endpoint for mrpack reupload (#6539)
fix: server panel not using correct endpoint for local modpack reupload
2026-06-29 14:39:03 +00:00
Calum H.andGitHub 288c1fb970 fix: reduce ping timeout in app to 5 sec not 30 sec (#6545)
* fix: reduce ping timeout in app to 5 sec not 30 sec

* feat: readd comment
2026-06-29 14:38:51 +00:00
Calum H.andGitHub f6e6ac28d4 fix: change fs watcher to rely more on instance ids than path (#6547)
* fix: change fs watcher to rely more on instance ids than path

* fix: fmt
2026-06-29 14:38:44 +00:00
Calum H.andGitHub 4a972bca73 fix: change logic when selecting filters in Worlds.vue (#6548) 2026-06-29 14:38:37 +00:00
Calum H.andGitHub b09c26f616 fix: console sizing issues (#6540)
fix: console sizing issues (for good)
2026-06-29 14:38:30 +00:00
ArthurandGitHub 204bd52a5a fix: modpack content modal toggle state (#6532)
Fix modpack content modal toggle state
2026-06-29 14:04:55 +00:00
Calum H.andGitHub b59f327b04 feat: add chaos cubed skins (#6463) 2026-06-29 12:18:15 +00:00
Modrinth BotandGitHub 89e643fa50 New translations from Crowdin (main) (#6542) 2026-06-29 12:04:10 +00:00
Calum H.andGitHub cee25fdbbc fix: unlinking local mrpack does not show content afterwards (#6541) 2026-06-29 11:58:43 +00:00
Calum H.andGitHub b95a2d875a feat: changelog app 0.15.1 (#6522) 2026-06-26 16:42:19 +00:00
617 changed files with 50082 additions and 10612 deletions
+18
View File
@@ -73,6 +73,10 @@ jobs:
docker:
runs-on: ${{ needs.skip-if-clean.outputs.internal == 'true' && 'blacksmith-4vcpu-ubuntu-2404' || 'ubuntu-latest' }}
env:
SCCACHE_DIR: ${{ needs.skip-if-clean.outputs.internal == 'true' && '/mnt/sccache' || '' }}
SCCACHE_CACHE_SIZE: ${{ needs.skip-if-clean.outputs.internal == 'true' && '10G' || '' }}
RUSTC_WRAPPER: ${{ needs.skip-if-clean.outputs.internal == 'true' && 'sccache' || '' }}
needs: [skip-if-clean]
if: ${{ needs.skip-if-clean.outputs.skip != 'true' }}
steps:
@@ -85,6 +89,9 @@ jobs:
rustflags: ''
cache: false
- name: Setup mold
uses: rui314/setup-mold@9c9c13bf4c3f1adef0cc596abc155580bcb04444 # v1 / Mold 2.41.0
- name: Cache Cargo registry and index
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
with:
@@ -94,6 +101,17 @@ jobs:
~/.cargo/bin
key: ${{ runner.os }}-${{ runner.arch }}-cargo-${{ hashFiles('**/Cargo.lock') }}
- name: Mount sccache disk cache
if: needs.skip-if-clean.outputs.internal == 'true'
uses: useblacksmith/stickydisk@13af8883542ca949a717e70fef89d15edbb29d88 # v1.2.0
with:
key: ${{ github.repository }}-daedalus-sccache
path: /mnt/sccache
- name: Setup sccache
if: needs.skip-if-clean.outputs.internal == 'true'
uses: mozilla-actions/sccache-action@9e7fa8a12102821edf02ca5dbea1acd0f89a2696 # v0.0.10
- name: Build daedalus_client
run: cargo build --release --package daedalus_client
+23 -4
View File
@@ -76,6 +76,10 @@ jobs:
env:
SQLX_OFFLINE: 'true'
GIT_HASH: ${{ github.sha }}
SCCACHE_DIR: ${{ needs.skip-if-clean.outputs.internal == 'true' && '/mnt/sccache' || '' }}
SCCACHE_CACHE_SIZE: ${{ needs.skip-if-clean.outputs.internal == 'true' && '10G' || '' }}
RUSTC_WRAPPER: ${{ needs.skip-if-clean.outputs.internal == 'true' && 'sccache' || '' }}
RUST_MIN_STACK: '16777216'
steps:
- name: Check out code
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
@@ -86,11 +90,15 @@ jobs:
rustflags: ''
cache: false
- name: Setup mold
uses: rui314/setup-mold@9c9c13bf4c3f1adef0cc596abc155580bcb04444 # v1 / Mold 2.41.0
- name: Install build dependencies
uses: awalsh128/cache-apt-pkgs-action@acb598e5ddbc6f68a970c5da0688d2f3a9f04d05 # v1.6.0
with:
packages: cmake libcurl4-openssl-dev
version: v2 # cache key
run: sudo apt-get update && sudo apt-get install -y --no-install-recommends cmake libcurl4-openssl-dev
#uses: awalsh128/cache-apt-pkgs-action@acb598e5ddbc6f68a970c5da0688d2f3a9f04d05 # v1.6.0
#with:
# packages: cmake libcurl4-openssl-dev
# version: v2 # cache key
- name: Cache Cargo registry and index
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
@@ -101,6 +109,17 @@ jobs:
~/.cargo/bin
key: ${{ runner.os }}-${{ runner.arch }}-cargo-${{ hashFiles('**/Cargo.lock') }}
- name: Mount sccache disk cache
if: needs.skip-if-clean.outputs.internal == 'true'
uses: useblacksmith/stickydisk@13af8883542ca949a717e70fef89d15edbb29d88 # v1.2.0
with:
key: ${{ github.repository }}-labrinth-sccache
path: /mnt/sccache
- name: Setup sccache
if: needs.skip-if-clean.outputs.internal == 'true'
uses: mozilla-actions/sccache-action@9e7fa8a12102821edf02ca5dbea1acd0f89a2696 # v0.0.10
- name: Build labrinth
run: cargo build --profile release-labrinth --package labrinth
+17
View File
@@ -44,6 +44,16 @@ jobs:
name: Build
env:
VITE_STRIPE_PUBLISHABLE_KEY: pk_live_51JbFxJJygY5LJFfKLVVldb10HlLt24p421OWRsTOWc5sXYFOnFUXWieSc6HD3PHo25ktx8db1WcHr36XGFvZFVUz00V9ixrCs5
# SCCACHE_DIR: '/mnt/sccache'
# SCCACHE_CACHE_SIZE: '10G'
# SCCACHE_MULTILEVEL_CHAIN: 'disk,s3'
SCCACHE_S3_KEY_PREFIX: '${{ github.repository }}/'
SCCACHE_BUCKET: ${{ secrets.SCCACHE_BUCKET }}
SCCACHE_REGION: ${{ secrets.SCCACHE_REGION }}
SCCACHE_ENDPOINT: ${{ secrets.SCCACHE_ENDPOINT }}
AWS_ACCESS_KEY_ID: ${{ secrets.SCCACHE_S3_ACCESS_KEY_ID }}
AWS_SECRET_ACCESS_KEY: ${{ secrets.SCCACHE_S3_SECRET_ACCESS_KEY }}
RUSTC_WRAPPER: 'sccache'
strategy:
fail-fast: false
matrix:
@@ -74,6 +84,13 @@ jobs:
rustflags: ''
target: ${{ contains(matrix.platform, 'macos') && 'x86_64-apple-darwin' || '' }}
- name: Setup mold
if: contains(matrix.platform, 'ubuntu')
uses: rui314/setup-mold@9c9c13bf4c3f1adef0cc596abc155580bcb04444 # v1 / Mold 2.41.0
- name: Setup sccache
uses: mozilla-actions/sccache-action@9e7fa8a12102821edf02ca5dbea1acd0f89a2696 # v0.0.10
- name: Enable Corepack
run: corepack enable
+35 -1
View File
@@ -18,11 +18,25 @@ jobs:
runs-on: ubuntu-latest
outputs:
skip: ${{ steps.check.outputs.skip }}
internal: ${{ steps.check-internal.outputs.internal }}
if: ${{ always() }}
steps:
- name: Checkout
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Check if workflow runs on an internal branch
id: check-internal
env:
EVENT_NAME: ${{ github.event_name }}
HEAD_REPO: ${{ github.event.pull_request.head.repo.full_name }}
REPO: ${{ github.repository }}
run: |
if [ "$EVENT_NAME" != "pull_request" ] || [ "$HEAD_REPO" = "$REPO" ]; then
echo "internal=true" >> $GITHUB_OUTPUT
else
echo "internal=false" >> $GITHUB_OUTPUT
fi
- name: Merge Queue CI Check Skipper
id: merge-queue-ci-skipper
uses: ./.github/merge-queue-ci-skipper
@@ -48,7 +62,7 @@ jobs:
build:
name: Lint and Test
runs-on: ubuntu-latest
runs-on: ${{ needs.skip-if-clean.outputs.internal == 'true' && 'blacksmith-4vcpu-ubuntu-2404' || 'ubuntu-latest' }}
needs: [skip-if-clean]
if: ${{ needs.skip-if-clean.outputs.skip != 'true' }}
@@ -62,6 +76,12 @@ jobs:
# since we don't want warnings to become errors
# while developing)
RUSTFLAGS: -Dwarnings
# sccache config (only populated for internal branches; secrets aren't
# available to forked PRs, and blacksmith stickydisk requires a
# blacksmith runner)
SCCACHE_DIR: ${{ needs.skip-if-clean.outputs.internal == 'true' && '/mnt/sccache' || '' }}
SCCACHE_CACHE_SIZE: ${{ needs.skip-if-clean.outputs.internal == 'true' && '10G' || '' }}
RUSTC_WRAPPER: ${{ needs.skip-if-clean.outputs.internal == 'true' && 'sccache' || '' }}
steps:
- name: Check out code
@@ -102,6 +122,9 @@ jobs:
components: clippy, rustfmt
cache: false
- name: Setup mold
uses: rui314/setup-mold@9c9c13bf4c3f1adef0cc596abc155580bcb04444 # v1 / Mold 2.41.0
- name: Cache Cargo registry and index
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae #v5.0.5
with:
@@ -111,6 +134,17 @@ jobs:
~/.cargo/bin
key: ${{ runner.os }}-${{ runner.arch }}-cargo-${{ hashFiles('**/Cargo.lock') }}
- name: Mount sccache disk cache
if: needs.skip-if-clean.outputs.internal == 'true'
uses: useblacksmith/stickydisk@13af8883542ca949a717e70fef89d15edbb29d88 # v1.2.0
with:
key: ${{ github.repository }}-turbo-sccache
path: /mnt/sccache
- name: Setup sccache
if: needs.skip-if-clean.outputs.internal == 'true'
uses: mozilla-actions/sccache-action@9e7fa8a12102821edf02ca5dbea1acd0f89a2696 # v0.0.10
- name: Setup binstall
uses: cargo-bins/cargo-binstall@dc19f1e48450eefe5a29b8da6c6b00a87d730b37 # v1.18.1
Generated
+173 -64
View File
@@ -59,9 +59,9 @@ dependencies = [
[[package]]
name = "actix-http"
version = "3.11.2"
version = "3.13.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7926860314cbe2fb5d1f13731e387ab43bd32bca224e82e6e2db85de0a3dba49"
checksum = "48e2faa3e7418ed780cca54829d32782a4008a077230f67457caa063415e99c2"
dependencies = [
"actix-codec",
"actix-rt",
@@ -75,7 +75,7 @@ dependencies = [
"derive_more 2.1.1",
"encoding_rs",
"flate2",
"foldhash 0.1.5",
"foldhash 0.2.0",
"futures-core",
"h2 0.3.27",
"http 0.2.12",
@@ -87,8 +87,8 @@ dependencies = [
"mime",
"percent-encoding",
"pin-project-lite",
"rand 0.9.2",
"sha1",
"rand 0.10.1",
"sha1 0.11.0",
"smallvec",
"tokio",
"tokio-util",
@@ -146,9 +146,9 @@ dependencies = [
[[package]]
name = "actix-router"
version = "0.5.3"
version = "0.5.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "13d324164c51f63867b57e73ba5936ea151b8a41a1d23d1031eeb9f70d0236f8"
checksum = "14f8c75c51892f18d9c46150c5ac7beb81c95f78c8b83a634d49f4ca32551fe7"
dependencies = [
"bytestring",
"cfg-if",
@@ -209,9 +209,9 @@ dependencies = [
[[package]]
name = "actix-web"
version = "4.11.0"
version = "4.14.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a597b77b5c6d6a1e1097fddde329a83665e25c5437c696a3a9a4aa514a614dea"
checksum = "df09e2d9239703dd64056359c920c7f3fba6535ec61a0059e0f44e095ffe02b4"
dependencies = [
"actix-codec",
"actix-http",
@@ -228,7 +228,7 @@ dependencies = [
"cookie 0.16.2",
"derive_more 2.1.1",
"encoding_rs",
"foldhash 0.1.5",
"foldhash 0.2.0",
"futures-core",
"futures-util",
"impl-more",
@@ -244,7 +244,7 @@ dependencies = [
"serde_json",
"serde_urlencoded",
"smallvec",
"socket2 0.5.10",
"socket2 0.6.1",
"time",
"tracing",
"url",
@@ -1127,7 +1127,7 @@ dependencies = [
"http-body-util",
"md-5",
"pin-project-lite",
"sha1",
"sha1 0.10.6",
"sha2",
"tracing",
]
@@ -1499,7 +1499,7 @@ version = "0.10.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "46502ad458c9a52b69d4d4d32775c788b7a1b85e8bc9d482d92250fc0e3f8efe"
dependencies = [
"digest",
"digest 0.10.7",
]
[[package]]
@@ -1511,6 +1511,15 @@ dependencies = [
"generic-array",
]
[[package]]
name = "block-buffer"
version = "0.12.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d2f6c7dbe95a6ed67ad9f18e57daf93a2f034c524b99fd2b76d18fdfeb6660aa"
dependencies = [
"hybrid-array",
]
[[package]]
name = "block2"
version = "0.5.1"
@@ -1619,6 +1628,31 @@ dependencies = [
"serde_with",
]
[[package]]
name = "bon"
version = "3.9.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a602c73c7b0148ec6d12af6fd5cc7a46e2eacc8878271a999abac56eed12f561"
dependencies = [
"bon-macros",
"rustversion",
]
[[package]]
name = "bon-macros"
version = "3.9.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6dee98b0db6a962de883bf5d20362dee4d7ca0d12fe39a7c6c73c844e1cd7c1f"
dependencies = [
"darling 0.23.0",
"ident_case",
"prettyplease",
"proc-macro2",
"quote",
"rustversion",
"syn 2.0.106",
]
[[package]]
name = "borsh"
version = "1.5.7"
@@ -2224,6 +2258,12 @@ version = "0.9.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8"
[[package]]
name = "const-oid"
version = "0.10.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a6ef517f0926dd24a1582492c791b6a4818a4d94e789a334894aa15b0d12f55c"
[[package]]
name = "const-random"
version = "0.1.18"
@@ -2424,7 +2464,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2fd92aca2c6001b1bf5ba0ff84ee74ec8501b52bbef0cac80bf25a6c1d87a83d"
dependencies = [
"crc",
"digest",
"digest 0.10.7",
"rustversion",
"spin 0.10.0",
]
@@ -2515,6 +2555,15 @@ dependencies = [
"typenum",
]
[[package]]
name = "crypto-common"
version = "0.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ce6e4c961d6cd6c9a86db418387425e8bdeaf05b3c8bc1411e6dca4c252f1453"
dependencies = [
"hybrid-array",
]
[[package]]
name = "cssparser"
version = "0.29.6"
@@ -2791,7 +2840,7 @@ version = "0.7.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb"
dependencies = [
"const-oid",
"const-oid 0.9.6",
"pem-rfc7468",
"zeroize",
]
@@ -2904,12 +2953,23 @@ version = "0.10.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292"
dependencies = [
"block-buffer",
"const-oid",
"crypto-common",
"block-buffer 0.10.4",
"const-oid 0.9.6",
"crypto-common 0.1.6",
"subtle",
]
[[package]]
name = "digest"
version = "0.11.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f1dd6dbb5841937940781866fa1281a1ff7bd3bf827091440879f9994983d5c2"
dependencies = [
"block-buffer 0.12.1",
"const-oid 0.10.2",
"crypto-common 0.2.2",
]
[[package]]
name = "directories"
version = "6.0.0"
@@ -3127,7 +3187,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ee27f32b5c5292967d2d4a9d7f1e0b0aed2c15daded5a60300e4abb9d8020bca"
dependencies = [
"der",
"digest",
"digest 0.10.7",
"elliptic-curve",
"rfc6979",
"signature",
@@ -3151,7 +3211,7 @@ checksum = "b5e6043086bf7973472e0c7dff2142ea0b680d30e18d9cc40f267efbf222bd47"
dependencies = [
"base16ct",
"crypto-bigint",
"digest",
"digest 0.10.7",
"ff",
"generic-array",
"group",
@@ -4402,7 +4462,7 @@ version = "0.12.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e"
dependencies = [
"digest",
"digest 0.10.7",
]
[[package]]
@@ -4532,6 +4592,15 @@ version = "1.0.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9"
[[package]]
name = "hybrid-array"
version = "0.4.13"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "818356c5132c1fede50f837ca96afbe78ff42413047f4abb886217845e1b6c8c"
dependencies = [
"typenum",
]
[[package]]
name = "hyper"
version = "0.14.32"
@@ -4893,9 +4962,9 @@ checksum = "e7c5cedc30da3a610cac6b4ba17597bdf7152cf974e8aab3afb3d54455e371c8"
[[package]]
name = "impl-more"
version = "0.1.9"
version = "0.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e8a5a9a0ff0086c7a148acb942baaabeadf9504d10400b5a05645853729b9cd2"
checksum = "35a84fd5aa25fae5c0f4a33d9cac2ca017fc622cbd089be2229993514990f870"
[[package]]
name = "indenter"
@@ -5390,11 +5459,12 @@ dependencies = [
"rust_iso3166",
"rustls 0.23.32",
"rusty-money",
"scalar_api_reference",
"sentry",
"serde",
"serde_json",
"serde_with",
"sha1",
"sha1 0.10.6",
"sha2",
"spdx",
"sqlx",
@@ -5411,8 +5481,6 @@ dependencies = [
"url",
"urlencoding",
"utoipa",
"utoipa-actix-web",
"utoipa-scalar",
"uuid 1.23.3",
"validator",
"webauthn-rs",
@@ -5822,7 +5890,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d89e7ee0cfbedfc4da3340218492196241d89eefb6dab27de5df917a6d2e78cf"
dependencies = [
"cfg-if",
"digest",
"digest 0.10.7",
]
[[package]]
@@ -8494,8 +8562,8 @@ version = "0.9.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "78928ac1ed176a5ca1d17e578a1825f3d81ca54cf41053a592584b020cfd691b"
dependencies = [
"const-oid",
"digest",
"const-oid 0.9.6",
"digest 0.10.7",
"num-bigint-dig",
"num-integer",
"num-traits",
@@ -8508,6 +8576,40 @@ dependencies = [
"zeroize",
]
[[package]]
name = "rust-embed"
version = "8.11.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "04113cb9355a377d83f06ef1f0a45b8ab8cd7d8b1288160717d66df5c7988d27"
dependencies = [
"rust-embed-impl",
"rust-embed-utils",
"walkdir",
]
[[package]]
name = "rust-embed-impl"
version = "8.11.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "da0902e4c7c8e997159ab384e6d0fc91c221375f6894346ae107f47dd0f3ccaa"
dependencies = [
"proc-macro2",
"quote",
"rust-embed-utils",
"syn 2.0.106",
"walkdir",
]
[[package]]
name = "rust-embed-utils"
version = "8.11.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5bcdef0be6fe7f6fa333b1073c949729274b05f123a0ad7efcb8efd878e5c3b1"
dependencies = [
"sha2",
"walkdir",
]
[[package]]
name = "rust-ini"
version = "0.21.3"
@@ -8827,6 +8929,18 @@ dependencies = [
"winapi-util",
]
[[package]]
name = "scalar_api_reference"
version = "0.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3d67c85edd56c41f364603eff688a330f01dee99bdfc84c005d3bc3d0721d6b2"
dependencies = [
"actix-web",
"rust-embed",
"serde",
"serde_json",
]
[[package]]
name = "scc"
version = "2.4.0"
@@ -9252,15 +9366,15 @@ dependencies = [
[[package]]
name = "serde_json"
version = "1.0.145"
version = "1.0.150"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "402a6f66d8c709116cf22f558eab210f5a50187f702eb4d7e5ef38d9a7f1c79c"
checksum = "e8014e44b4736ed0538adeecded0fce2a272f22dc9578a7eb6b2d9993c74cfb9"
dependencies = [
"itoa",
"memchr",
"ryu",
"serde",
"serde_core",
"zmij",
]
[[package]]
@@ -9455,7 +9569,18 @@ checksum = "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba"
dependencies = [
"cfg-if",
"cpufeatures 0.2.17",
"digest",
"digest 0.10.7",
]
[[package]]
name = "sha1"
version = "0.11.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "aacc4cc499359472b4abe1bf11d0b12e688af9a805fa5e3016f9a386dc2d0214"
dependencies = [
"cfg-if",
"cpufeatures 0.3.0",
"digest 0.11.3",
]
[[package]]
@@ -9472,7 +9597,7 @@ checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283"
dependencies = [
"cfg-if",
"cpufeatures 0.2.17",
"digest",
"digest 0.10.7",
]
[[package]]
@@ -9505,7 +9630,7 @@ version = "2.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de"
dependencies = [
"digest",
"digest 0.10.7",
"rand_core 0.6.4",
]
@@ -9782,7 +9907,7 @@ dependencies = [
"bytes",
"chrono",
"crc",
"digest",
"digest 0.10.7",
"dotenvy",
"either",
"futures-channel",
@@ -9803,7 +9928,7 @@ dependencies = [
"rsa",
"rust_decimal",
"serde",
"sha1",
"sha1 0.10.6",
"sha2",
"smallvec",
"sqlx-core",
@@ -10749,6 +10874,7 @@ dependencies = [
"async-walkdir",
"async_zip",
"base64 0.22.1",
"bon",
"bytemuck",
"bytes",
"chardetng",
@@ -11290,7 +11416,7 @@ dependencies = [
"constant_time_eq",
"hmac",
"rand 0.9.2",
"sha1",
"sha1 0.10.6",
"sha2",
]
@@ -11505,7 +11631,7 @@ dependencies = [
"rand 0.9.2",
"rustls 0.23.32",
"rustls-pki-types",
"sha1",
"sha1 0.10.6",
"thiserror 2.0.17",
"utf-8",
]
@@ -11524,9 +11650,9 @@ checksum = "bc7d623258602320d5c55d1bc22793b57daff0ec7efc270ea7d55ce1d5f5471c"
[[package]]
name = "typenum"
version = "1.19.0"
version = "1.20.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "562d481066bde0658276a35467c4af00bdc6ee726305698a55b86e61d7ad82bb"
checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20"
[[package]]
name = "uds_windows"
@@ -11757,17 +11883,6 @@ dependencies = [
"utoipa-gen",
]
[[package]]
name = "utoipa-actix-web"
version = "0.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b7eda9c23c05af0fb812f6a177514047331dac4851a2c8e9c4b895d6d826967f"
dependencies = [
"actix-service",
"actix-web",
"utoipa",
]
[[package]]
name = "utoipa-gen"
version = "5.4.0"
@@ -11782,18 +11897,6 @@ dependencies = [
"uuid 1.23.3",
]
[[package]]
name = "utoipa-scalar"
version = "0.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "59559e1509172f6b26c1cdbc7247c4ddd1ac6560fe94b584f81ee489b141f719"
dependencies = [
"actix-web",
"serde",
"serde_json",
"utoipa",
]
[[package]]
name = "uuid"
version = "0.8.2"
@@ -13450,6 +13553,12 @@ version = "0.5.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2f06ae92f42f5e5c42443fd094f245eb656abf56dd7cce9b8b263236565e00f2"
[[package]]
name = "zmij"
version = "1.0.21"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa"
[[package]]
name = "zopfli"
version = "0.8.2"
+2 -2
View File
@@ -51,6 +51,7 @@ aws-sdk-s3 = { version = "=1.122.0", default-features = false, features = [
] }
base64 = "0.22.1"
bitflags = "2.9.4"
bon = "3.9.3"
bytemuck = "1.24.0"
bytes = "1.10.1"
censor = "0.3.0"
@@ -158,6 +159,7 @@ rust-s3 = { version = "0.37.0", default-features = false, features = [
] }
rustls = "0.23.32"
rusty-money = "0.4.1"
scalar_api_reference = { version = "0.2.2", default-features = false }
secrecy = "0.10.3"
sentry = { version = "0.45.0", default-features = false, features = [
"backtrace",
@@ -217,8 +219,6 @@ typed-path = "0.12.0"
url = "2.5.7"
urlencoding = "2.1.3"
utoipa = { version = "5.4.0", features = ["actix_extras", "chrono", "decimal"] }
utoipa-actix-web = { version = "0.1.2" }
utoipa-scalar = { version = "0.3.0", default-features = false }
uuid = "1.18.1"
validator = "0.20.0"
webauthn-rs = "0.5.5"
+2 -2
View File
@@ -3,13 +3,13 @@
![Issues](https://img.shields.io/github/issues-raw/Modrinth/code?color=c78aff&label=issues&style=for-the-badge)
![Pull Requests](https://img.shields.io/github/issues-pr-raw/Modrinth/code?color=c78aff&label=PRs&style=for-the-badge)
![Contributors](https://img.shields.io/github/contributors/Modrinth/code?color=c78aff&label=contributors&style=for-the-badge)
![Lines](https://img.shields.io/endpoint?url=https://ghloc.vercel.app/api/modrinth/code/badge?style=flat&logoColor=white&color=c78aff&style=for-the-badge)
![Lines of Code](https://img.shields.io/endpoint?url=https://loctopus.creeperkatze.dev/github/modrinth/code/badge?style=flat&logoColor=white&color=c78aff&style=for-the-badge)
![Commit Activity](https://img.shields.io/github/commit-activity/m/Modrinth/code?color=c78aff&label=commits&style=for-the-badge)
![Last Commit](https://img.shields.io/github/last-commit/Modrinth/code?color=c78aff&label=last%20commit&style=for-the-badge)
## Modrinth Monorepo
Welcome to the Modrinth Monorepo, the primary codebase for the Modrinth web interface and app. It contains ![Lines](https://img.shields.io/endpoint?url=https://ghloc.vercel.app/api/modrinth/olympus/badge?logoColor=white&color=black&label=) lines of code and has ![Contributors](https://img.shields.io/github/contributors/Modrinth/code?color=black&label=) contributors!
Welcome to the Modrinth Monorepo, the primary codebase for the Modrinth web interface and app. It contains ![Lines of Code](https://img.shields.io/endpoint?url=https://loctopus.creeperkatze.dev/github/modrinth/code/badge%3Fformat%3Dhuman&logoColor=white&color=black&label=) lines of code and has ![Contributors](https://img.shields.io/github/contributors/Modrinth/code?color=black&label=) contributors!
If you're not a developer and you've stumbled upon this repository, you can access the web interface on the [Modrinth website](https://modrinth.com) and download the latest release of the app [here](https://modrinth.com/app).
+59 -25
View File
@@ -16,6 +16,7 @@ import {
HomeIcon,
LeftArrowIcon,
LibraryIcon,
LinkIcon,
LogInIcon,
LogOutIcon,
NewspaperIcon,
@@ -85,6 +86,7 @@ import NavButton from '@/components/ui/NavButton.vue'
import PrideFundraiserBanner from '@/components/ui/PrideFundraiserBanner.vue'
import PromotionWrapper from '@/components/ui/PromotionWrapper.vue'
import QuickInstanceSwitcher from '@/components/ui/QuickInstanceSwitcher.vue'
import SharedInstanceInviteHandler from '@/components/ui/shared-instances/shared-instance-invite-handler/index.vue'
import SplashScreen from '@/components/ui/SplashScreen.vue'
import WindowControls from '@/components/ui/WindowControls.vue'
import { useCheckDisableMouseover } from '@/composables/macCssFix.js'
@@ -147,6 +149,7 @@ const APP_SIDEBAR_WIDTH = 300
const INTERCOM_BUBBLE_DEFAULT_PADDING = 20
const PRIDE_FUNDRAISER_END_DATE = new Date('2026-07-01T00:00:00Z').getTime()
const credentials = ref()
let credentialsRefreshId = 0
const sidebarToggled = ref(true)
const unsubscribeSidebarToggle = themeStore.$subscribe(() => {
sidebarToggled.value = !themeStore.toggleSidebar
@@ -637,6 +640,7 @@ const contentInstallModpackAlreadyInstalledModal = ref()
const addServerToInstanceModal = ref()
const incompatibilityWarningModal = ref()
const installToPlayModal = ref()
const sharedInstanceInviteHandler = ref()
const updateToPlayModal = ref()
const modrinthLoginFlowWaitModal = ref()
@@ -647,8 +651,8 @@ watch(incompatibilityWarningModal, (modal) => {
}
})
setupAuthProvider(credentials, async (_redirectPath) => {
await signIn()
setupAuthProvider(credentials, async (_redirectPath, flow) => {
await signIn(flow)
})
async function validateSession(sessionToken) {
@@ -665,23 +669,33 @@ async function validateSession(sessionToken) {
}
async function fetchCredentials() {
const refreshId = ++credentialsRefreshId
credentials.value = undefined
const creds = await getCreds().catch(handleError)
if (refreshId !== credentialsRefreshId) return
if (creds && creds.user_id) {
if (creds.session && !(await validateSession(creds.session))) {
if (refreshId !== credentialsRefreshId) return
await logout().catch(handleError)
if (refreshId !== credentialsRefreshId) return
credentials.value = null
return
}
creds.user = await get_user(creds.user_id, 'bypass').catch(handleError)
if (refreshId !== credentialsRefreshId) return
}
credentials.value = creds ?? null
}
async function signIn() {
async function signIn(flow = 'sign-in') {
modrinthLoginFlowWaitModal.value.show()
try {
await login()
await login(flow)
await fetchCredentials()
} catch (error) {
if (
@@ -699,6 +713,13 @@ async function signIn() {
}
async function logOut() {
await performLogOut()
}
async function performLogOut() {
credentialsRefreshId++
credentials.value = undefined
await logout().catch(handleError)
await fetchCredentials()
}
@@ -821,30 +842,34 @@ function openServerInviteInviterProfile(inviterName) {
}
async function handleLiveNotification(notification) {
if (notification?.body?.type !== 'server_invite' || notification.read) return
if (displayedServerInviteNotifications.has(notification.id)) return
if (!notification?.body || notification.read) return
if (await sharedInstanceInviteHandler.value?.handleNotification(notification)) return
displayedServerInviteNotifications.add(notification.id)
if (notification.body.type === 'server_invite') {
if (displayedServerInviteNotifications.has(notification.id)) return
const serverName =
typeof notification.body.server_name === 'string' ? notification.body.server_name : 'a server'
const inviterId = notification.body.invited_by
const invitedBy =
typeof inviterId === 'string' ? await get_user(inviterId, 'bypass').catch(() => null) : null
displayedServerInviteNotifications.add(notification.id)
addPopupNotification({
title: serverName,
autoCloseMs: null,
toast: {
type: 'server-invite',
actorName: invitedBy?.username ?? null,
actorAvatarUrl: invitedBy?.avatar_url ?? null,
entityName: serverName,
onAccept: () => acceptServerInviteNotification(notification),
onDecline: () => declineServerInviteNotification(notification),
onOpenActor: () => openServerInviteInviterProfile(invitedBy?.username ?? null),
},
})
const serverName =
typeof notification.body.server_name === 'string' ? notification.body.server_name : 'a server'
const inviterId = notification.body.invited_by
const invitedBy =
typeof inviterId === 'string' ? await get_user(inviterId, 'bypass').catch(() => null) : null
addPopupNotification({
title: serverName,
autoCloseMs: null,
toast: {
type: 'server-invite',
actorName: invitedBy?.username ?? null,
actorAvatarUrl: invitedBy?.avatar_url ?? null,
entityName: serverName,
onAccept: () => acceptServerInviteNotification(notification),
onDecline: () => declineServerInviteNotification(notification),
onOpenActor: () => openServerInviteInviterProfile(invitedBy?.username ?? null),
},
})
}
}
async function handleCommand(e) {
@@ -877,6 +902,8 @@ async function handleCommand(e) {
} else {
await run(e.id).catch(handleError)
}
} else if (e.event === 'InstallSharedInstanceInvite') {
await sharedInstanceInviteHandler.value?.installFromInviteId(e.invite_id)
} else if (e.event === 'InstallServer') {
await router.push(`/project/${e.id}`)
await playServerProject(e.id).catch(handleError)
@@ -1467,6 +1494,12 @@ provideAppUpdateDownloadProgress(appUpdateDownload)
>
<PlusIcon />
</NavButton>
<NavButton
v-tooltip.right="'Install from invite link'"
:to="(event) => sharedInstanceInviteHandler?.showManualInviteLinkModal(event)"
>
<LinkIcon />
</NavButton>
<div class="flex flex-grow"></div>
<NavButton
v-tooltip.right="formatMessage(commonMessages.settingsLabel)"
@@ -1736,6 +1769,7 @@ provideAppUpdateDownloadProgress(appUpdateDownload)
@create-anyway="handleContentInstallModpackDuplicateCreateAnyway"
@go-to-instance="handleContentInstallModpackDuplicateGoToInstance"
/>
<SharedInstanceInviteHandler ref="sharedInstanceInviteHandler" />
<InstallToPlayModal ref="installToPlayModal" />
<UpdateToPlayModal ref="updateToPlayModal" />
</template>
@@ -14,31 +14,7 @@
<UnplugIcon class="text-secondary" />
<span class="text-sm text-contrast"> {{ formatMessage(messages.offline) }} </span>
</div>
<ButtonStyled color="brand" type="outlined" hover-color-fill="background">
<button
v-if="showUpdatePill"
type="button"
class="!h-[34px] overflow-hidden text-sm !transition-[width,opacity,transform,background-color,color,filter] !duration-200 ease-out"
:class="[
updatePillWidthClass,
{
'update-pill-ready-hidden': finishedDownloading && !animateReadyPill,
'update-pill-ready-visible': finishedDownloading && animateReadyPill,
},
]"
:disabled="isUpdateDownloading"
:aria-busy="isUpdateDownloading"
@click="handleUpdateClick"
>
<RefreshCwIcon v-if="finishedDownloading" :class="{ 'animate-spin': restarting }" />
<DownloadIcon v-else />
<span v-if="isUpdateDownloading">
{{ formatMessage(messages.downloadingUpdate) }}
<span class="inline-block w-[3ch] text-right tabular-nums">{{ downloadPercent }}%</span>
</span>
<span v-else>{{ updateLabel }}</span>
</button>
</ButtonStyled>
<AppUpdateButton />
<div
class="flex border-solid border-surface-5 text-sm items-center gap-2 py-1.5 px-3 rounded-xl border"
>
@@ -144,7 +120,6 @@ import {
DownloadIcon,
DropdownIcon,
OnlineIndicatorIcon,
RefreshCwIcon,
StarIcon,
StopCircleIcon,
TerminalSquareIcon,
@@ -161,9 +136,10 @@ import {
} from '@modrinth/ui'
import { convertFileSrc } from '@tauri-apps/api/core'
import { Dropdown } from 'floating-vue'
import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue'
import { computed, onBeforeUnmount, onMounted, ref } from 'vue'
import { useRouter } from 'vue-router'
import AppUpdateButton from '@/components/ui/app-update-button/index.vue'
import { useInstallJobNotifications } from '@/composables/browse/install-job-notifications'
import { trackEvent } from '@/helpers/analytics'
import { loading_listener, process_listener } from '@/helpers/events'
@@ -172,11 +148,6 @@ import { get_all as getRunningProcesses, kill as killProcess } from '@/helpers/p
import type { LoadingBar } from '@/helpers/state'
import { progress_bars_list } from '@/helpers/state'
import type { GameInstance } from '@/helpers/types'
import {
appUpdateState,
downloadAvailableAppUpdate,
installAvailableAppUpdate,
} from '@/providers/app-update'
const { handleError } = injectNotificationManager()
const popupNotificationManager = injectPopupNotificationManager()
@@ -241,96 +212,8 @@ const messages = defineMessages({
id: 'app.action-bar.view-active-downloads',
defaultMessage: 'View active downloads',
},
update: {
id: 'app.action-bar.update',
defaultMessage: 'Update',
},
downloadingUpdate: {
id: 'app.action-bar.downloading-update',
defaultMessage: 'Downloading update',
},
reloadToUpdate: {
id: 'app.action-bar.reload-to-update',
defaultMessage: 'Reload to update',
},
})
const {
downloading,
downloadPercent,
downloadProgress,
finishedDownloading,
isVisible: isUpdateVisible,
metered,
restarting,
} = appUpdateState
const isUpdateDownloading = computed(
() =>
downloading.value ||
(downloadProgress.value > 0 && downloadProgress.value < 1 && !finishedDownloading.value),
)
const showUpdatePill = computed(
() => isUpdateVisible.value && (finishedDownloading.value || metered.value),
)
const animateReadyPill = ref(false)
const updateLabel = computed(() => {
if (isUpdateDownloading.value) {
return formatMessage(messages.downloadingUpdate)
}
if (finishedDownloading.value) {
return formatMessage(messages.reloadToUpdate)
}
return formatMessage(messages.update)
})
const updatePillWidthClass = computed(() => {
if (isUpdateDownloading.value) {
return 'w-[219px]'
}
if (finishedDownloading.value) {
return 'w-[166px]'
}
return '!w-[96px]'
})
let readyPillAnimationFrame: number | null = null
watch([showUpdatePill, finishedDownloading], async ([show, ready], [wasShown, wasReady]) => {
if (readyPillAnimationFrame !== null) {
cancelAnimationFrame(readyPillAnimationFrame)
readyPillAnimationFrame = null
}
if (!show || !ready) {
animateReadyPill.value = false
return
}
if (wasShown && wasReady) {
return
}
animateReadyPill.value = false
await nextTick()
readyPillAnimationFrame = requestAnimationFrame(() => {
animateReadyPill.value = true
readyPillAnimationFrame = null
})
})
async function handleUpdateClick() {
if (isUpdateDownloading.value) {
return
}
if (finishedDownloading.value) {
await installAvailableAppUpdate()
} else {
await downloadAvailableAppUpdate()
}
}
const currentProcesses = ref<RunningProcess[]>([])
const selectedProcess = ref<RunningProcess | undefined>()
@@ -619,20 +502,5 @@ onBeforeUnmount(() => {
unlistenProcess()
unlistenLoading()
installJobNotifications.dispose()
if (readyPillAnimationFrame !== null) {
cancelAnimationFrame(readyPillAnimationFrame)
}
})
</script>
<style scoped>
.update-pill-ready-hidden {
opacity: 0;
transform: scale(0.96);
}
.update-pill-ready-visible {
opacity: 1;
transform: scale(1);
}
</style>
@@ -1,28 +1,32 @@
<script setup>
import { WrenchIcon, XIcon } from '@modrinth/assets'
import { XIcon } from '@modrinth/assets'
import {
Accordion,
ButtonStyled,
Checkbox,
commonMessages,
defineMessages,
FileTreeSelect,
injectNotificationManager,
NewModal,
StyledInput,
useVIntl,
} from '@modrinth/ui'
import { save } from '@tauri-apps/plugin-dialog'
import { readDir, stat } from '@tauri-apps/plugin-fs'
import { ref } from 'vue'
import { PackageIcon, VersionIcon } from '@/assets/icons'
import ModalWrapper from '@/components/ui/modal/ModalWrapper.vue'
import { export_instance_mrpack, get_pack_export_candidates } from '@/helpers/instance'
import { PackageIcon } from '@/assets/icons'
import {
export_instance_mrpack,
get_full_path,
get_pack_export_candidates,
} from '@/helpers/instance'
const { handleError } = injectNotificationManager()
const { formatMessage } = useVIntl()
const messages = defineMessages({
header: { id: 'app.export-modal.header', defaultMessage: 'Export modpack' },
modpackNameLabel: { id: 'app.export-modal.modpack-name-label', defaultMessage: 'Modpack Name' },
modpackNameLabel: { id: 'app.export-modal.modpack-name-label', defaultMessage: 'Modpack name' },
modpackNamePlaceholder: {
id: 'app.export-modal.modpack-name-placeholder',
defaultMessage: 'Modpack name',
@@ -39,15 +43,7 @@ const messages = defineMessages({
id: 'app.export-modal.description-placeholder',
defaultMessage: 'Enter modpack description...',
},
selectFilesLabel: {
id: 'app.export-modal.select-files-label',
defaultMessage: 'Configure which files are included in this export',
},
exportButton: { id: 'app.export-modal.export-button', defaultMessage: 'Export' },
includeFile: {
id: 'app.export-modal.include-file-accessibility-label',
defaultMessage: 'Include "{file}"?',
},
})
const props = defineProps({
@@ -59,8 +55,9 @@ const props = defineProps({
defineExpose({
show: () => {
resetExportState()
exportModal.value.show()
initFiles()
void initFiles().catch(handleError)
},
})
@@ -69,62 +66,33 @@ const nameInput = ref(props.instance.name)
const exportDescription = ref('')
const versionInput = ref('1.0.0')
const files = ref([])
const folders = ref([])
const selectedFilePaths = ref([])
const fileTreeKey = ref(0)
const filesLoadId = ref(0)
const instanceRoot = ref('')
const loadedDirectories = ref(new Set())
const initFiles = async () => {
const newFolders = new Map()
const sep = '/'
files.value = []
await get_pack_export_candidates(props.instance.id).then((filePaths) =>
filePaths
.map((folder) => ({
path: folder,
name: folder.split(sep).pop(),
selected:
folder.startsWith('mods') ||
folder.startsWith('datapacks') ||
folder.startsWith('resourcepacks') ||
folder.startsWith('shaderpacks') ||
folder.startsWith('config'),
disabled:
folder === 'profile.json' ||
folder.startsWith('modrinth_logs') ||
folder.startsWith('.fabric') ||
folder.startsWith('__MACOSX'),
}))
.forEach((pathData) => {
const parent = pathData.path.split(sep).slice(0, -1).join(sep)
if (parent !== '') {
if (newFolders.has(parent)) {
newFolders.get(parent).push(pathData)
} else {
newFolders.set(parent, [pathData])
}
} else {
files.value.push(pathData)
}
}),
)
folders.value = [...newFolders.entries()].map(([name, value]) => [
{
name,
showingMore: false,
},
value,
async function initFiles() {
const loadId = ++filesLoadId.value
const [filePaths, root] = await Promise.all([
get_pack_export_candidates(props.instance.id),
get_full_path(props.instance.id),
])
if (loadId !== filesLoadId.value) return
instanceRoot.value = root
const exportCandidates = await Promise.all(
filePaths.map((path) => buildExportCandidateItem(root, path)),
)
if (loadId !== filesLoadId.value) return
files.value = exportCandidates
selectedFilePaths.value = files.value
.filter((file) => !file.disabled && isDefaultSelectedExportCandidate(file.path))
.map((file) => file.path)
}
await initFiles()
const exportPack = async () => {
const filesToExport = files.value.filter((file) => file.selected).map((file) => file.path)
folders.value.forEach((args) => {
args[1].forEach((child) => {
if (child.selected) {
filesToExport.push(child.path)
}
})
})
const outputPath = await save({
defaultPath: `${nameInput.value} ${versionInput.value}.mrpack`,
filters: [
@@ -139,7 +107,7 @@ const exportPack = async () => {
export_instance_mrpack(
props.instance.id,
outputPath,
filesToExport,
selectedFilePaths.value,
versionInput.value,
exportDescription.value,
nameInput.value,
@@ -147,94 +115,176 @@ const exportPack = async () => {
exportModal.value.hide()
}
}
function resetExportState() {
nameInput.value = props.instance.name
exportDescription.value = ''
versionInput.value = '1.0.0'
files.value = []
selectedFilePaths.value = []
fileTreeKey.value += 1
instanceRoot.value = ''
loadedDirectories.value = new Set()
}
async function loadExportDirectory(path) {
if (!path || !instanceRoot.value || loadedDirectories.value.has(path)) return
const loadId = filesLoadId.value
loadedDirectories.value.add(path)
try {
const entries = await readDir(`${instanceRoot.value}/${path}`)
const childItems = await Promise.all(
entries.map((entry) => buildExportDirectoryChildItem(instanceRoot.value, path, entry)),
)
if (loadId !== filesLoadId.value) return
appendExportItems(childItems)
} catch {
loadedDirectories.value.delete(path)
}
}
async function buildExportCandidateItem(instanceRoot, path) {
try {
const entries = await readDir(`${instanceRoot}/${path}`)
const metadata = await getExportCandidateMetadata(instanceRoot, path)
return {
path,
type: 'directory',
disabled: isExportCandidateDisabled(path),
modified: metadata.modified,
count: entries.length,
}
} catch {
return buildExportFileItem(instanceRoot, path)
}
}
async function buildExportDirectoryChildItem(instanceRoot, parentPath, entry) {
const path = `${parentPath}/${entry.name}`
if (entry.isDirectory) {
const metadata = await getExportCandidateMetadata(instanceRoot, path)
return {
path,
type: 'directory',
disabled: isExportCandidateDisabled(path),
modified: metadata.modified,
}
}
return buildExportFileItem(instanceRoot, path)
}
async function buildExportFileItem(instanceRoot, path) {
const metadata = await getExportCandidateMetadata(instanceRoot, path)
return {
path,
type: 'file',
disabled: isExportCandidateDisabled(path),
size: metadata.size,
modified: metadata.modified,
}
}
function appendExportItems(items) {
const nextFiles = new Map(files.value.map((file) => [normalizeExportPath(file.path), file]))
for (const item of items) {
nextFiles.set(normalizeExportPath(item.path), item)
}
files.value = [...nextFiles.values()]
}
async function getExportCandidateMetadata(instanceRoot, path) {
try {
const metadata = await stat(`${instanceRoot}/${path}`)
return {
size: metadata.size,
modified: metadata.mtime ? Math.floor(metadata.mtime.getTime() / 1000) : undefined,
}
} catch {
return {}
}
}
function normalizeExportPath(path) {
return path.replaceAll('\\', '/').split('/').filter(Boolean).join('/')
}
function isDefaultSelectedExportCandidate(path) {
return (
path.startsWith('mods') ||
path.startsWith('datapacks') ||
path.startsWith('resourcepacks') ||
path.startsWith('shaderpacks') ||
path.startsWith('config')
)
}
function isExportCandidateDisabled(path) {
return (
path === 'profile.json' ||
path.startsWith('modrinth_logs') ||
path.startsWith('.fabric') ||
path.startsWith('__MACOSX')
)
}
</script>
<template>
<ModalWrapper ref="exportModal" :header="formatMessage(messages.header)">
<div class="flex flex-col gap-4 w-[40rem]">
<NewModal
ref="exportModal"
:header="formatMessage(messages.header)"
scrollable
width="46rem"
max-width="calc(100vw - 2rem)"
>
<div class="flex flex-col gap-4">
<div class="grid grid-cols-2 gap-4">
<div class="labeled_input">
<p>{{ formatMessage(messages.modpackNameLabel) }}</p>
<div class="labeled_input w-full">
<p class="text-contrast font-semibold">{{ formatMessage(messages.modpackNameLabel) }}</p>
<StyledInput
v-model="nameInput"
:icon="PackageIcon"
type="text"
:placeholder="formatMessage(messages.modpackNamePlaceholder)"
clearable
wrapper-class="w-full"
/>
</div>
<div class="labeled_input">
<p>{{ formatMessage(messages.versionNumberLabel) }}</p>
<div class="labeled_input w-full">
<p class="text-contrast font-semibold">
{{ formatMessage(messages.versionNumberLabel) }}
</p>
<StyledInput
v-model="versionInput"
:icon="VersionIcon"
type="text"
:placeholder="formatMessage(messages.versionNumberPlaceholder)"
clearable
wrapper-class="w-full"
/>
</div>
</div>
<div class="flex flex-col gap-2">
<p class="m-0">{{ formatMessage(commonMessages.descriptionLabel) }}</p>
<div class="flex flex-col gap-2 min-w-0">
<p class="m-0 text-contrast font-semibold">
{{ formatMessage(commonMessages.descriptionLabel) }}
</p>
<StyledInput
v-model="exportDescription"
multiline
:placeholder="formatMessage(messages.descriptionPlaceholder)"
wrapper-class="w-full"
/>
</div>
<Accordion
class="w-full bg-surface-4 border border-solid border-surface-5 rounded-2xl overflow-clip"
button-class="p-4 w-full border-b border-solid border-b-surface-5 bg-surface-2 -mb-px hover:brightness-[--hover-brightness] group"
>
<template #title>
<span class="flex items-center gap-3 text-contrast group-active:scale-[0.98]">
<WrenchIcon aria-hidden="true" class="size-5 text-secondary" />
Configure which files are included in this export
</span>
</template>
<div class="flex flex-col [&>*:nth-child(even)]:bg-surface-3">
<div v-for="[path, children] in folders" :key="path.name" class="flex flex-col">
<Accordion
class="flex flex-col"
button-class="flex gap-3 pr-4 hover:bg-surface-5 group"
>
<template #title>
<Checkbox
:model-value="children.every((child) => child.selected)"
:indeterminate="
!children.every((child) => child.selected) &&
children.some((child) => child.selected)
"
:description="formatMessage(messages.includeFile, { file: path.name })"
class="pl-4 py-2"
:disabled="children.every((x) => x.disabled)"
@update:model-value="
(newValue) => children.forEach((child) => (child.selected = newValue))
"
@click.stop
/>
<span class="ml-2 group-active:scale-95">{{ path.name }}/</span>
</template>
<div v-for="child in children" :key="child.path">
<Checkbox
v-model="child.selected"
:label="child.name"
class="w-full px-8 py-2 hover:bg-surface-4 text-primary"
:disabled="child.disabled"
/>
</div>
</Accordion>
</div>
<Checkbox
v-for="file in files"
:key="file.path"
v-model="file.selected"
:label="file.name"
:disabled="file.disabled"
class="w-full px-4 py-2 hover:bg-surface-4 text-primary"
/>
</div>
</Accordion>
<FileTreeSelect
:key="fileTreeKey"
v-model="selectedFilePaths"
class="min-w-0"
:items="files"
@navigate="loadExportDirectory"
/>
</div>
<template #actions>
<div class="flex items-center justify-end gap-2">
<ButtonStyled type="outlined">
<button @click="exportModal.hide">
@@ -249,6 +299,6 @@ const exportPack = async () => {
</button>
</ButtonStyled>
</div>
</div>
</ModalWrapper>
</template>
</NewModal>
</template>
@@ -142,7 +142,9 @@ const unlisten = await process_listener((e) => {
}
})
onMounted(() => checkProcess())
onMounted(() => {
checkProcess()
})
onUnmounted(() => unlisten())
</script>
@@ -0,0 +1,124 @@
<template>
<ButtonStyled color="brand" type="outlined" hover-color-fill="background">
<button
v-if="showUpdatePill"
type="button"
class="!h-[34px] text-sm !transition-[opacity,transform,background-color,color,filter] !duration-200 ease-out"
:class="{
'opacity-0 scale-[0.96]': finishedDownloading && !animateReadyPill,
'opacity-100 scale-100': finishedDownloading && animateReadyPill,
}"
:disabled="isUpdateDownloading"
:aria-busy="isUpdateDownloading"
@click="handleUpdateClick"
>
<RefreshCwIcon v-if="finishedDownloading" :class="{ 'animate-spin': restarting }" />
<DownloadIcon v-else />
<span v-if="isUpdateDownloading">
{{ formatMessage(messages.downloadingUpdate) }}
<span class="inline-block w-[3ch] text-right tabular-nums">{{ downloadPercent }}%</span>
</span>
<span v-else>{{ updateLabel }}</span>
</button>
</ButtonStyled>
</template>
<script setup lang="ts">
import { DownloadIcon, RefreshCwIcon } from '@modrinth/assets'
import { ButtonStyled, defineMessages, useVIntl } from '@modrinth/ui'
import { computed, nextTick, onBeforeUnmount, ref, watch } from 'vue'
import {
appUpdateState,
downloadAvailableAppUpdate,
installAvailableAppUpdate,
} from '@/providers/app-update'
const { formatMessage } = useVIntl()
const messages = defineMessages({
update: {
id: 'app.action-bar.update',
defaultMessage: 'Update',
},
downloadingUpdate: {
id: 'app.action-bar.downloading-update',
defaultMessage: 'Downloading update',
},
reloadToUpdate: {
id: 'app.action-bar.reload-to-update',
defaultMessage: 'Reload to update',
},
})
const {
downloading,
downloadPercent,
downloadProgress,
finishedDownloading,
isVisible: isUpdateVisible,
metered,
restarting,
} = appUpdateState
const isUpdateDownloading = computed(
() =>
downloading.value ||
(downloadProgress.value > 0 && downloadProgress.value < 1 && !finishedDownloading.value),
)
const showUpdatePill = computed(
() => isUpdateVisible.value && (finishedDownloading.value || metered.value),
)
const animateReadyPill = ref(false)
const updateLabel = computed(() => {
if (isUpdateDownloading.value) {
return formatMessage(messages.downloadingUpdate)
}
if (finishedDownloading.value) {
return formatMessage(messages.reloadToUpdate)
}
return formatMessage(messages.update)
})
let readyPillAnimationFrame: number | null = null
watch([showUpdatePill, finishedDownloading], async ([show, ready], [wasShown, wasReady]) => {
if (readyPillAnimationFrame !== null) {
cancelAnimationFrame(readyPillAnimationFrame)
readyPillAnimationFrame = null
}
if (!show || !ready) {
animateReadyPill.value = false
return
}
if (wasShown && wasReady) {
return
}
animateReadyPill.value = false
await nextTick()
readyPillAnimationFrame = requestAnimationFrame(() => {
animateReadyPill.value = true
readyPillAnimationFrame = null
})
})
async function handleUpdateClick() {
if (isUpdateDownloading.value) {
return
}
if (finishedDownloading.value) {
await installAvailableAppUpdate()
} else {
await downloadAvailableAppUpdate()
}
}
onBeforeUnmount(() => {
if (readyPillAnimationFrame !== null) {
cancelAnimationFrame(readyPillAnimationFrame)
}
})
</script>
@@ -1,5 +1,5 @@
<script setup lang="ts">
import { MailIcon, SendIcon, UserIcon, UserPlusIcon, XIcon } from '@modrinth/assets'
import { MailIcon, SearchIcon, SendIcon, UserIcon, UserPlusIcon, XIcon } from '@modrinth/assets'
import {
Avatar,
ButtonStyled,
@@ -10,18 +10,12 @@ import {
useRelativeTime,
useVIntl,
} from '@modrinth/ui'
import { computed, onUnmounted, ref, watch } from 'vue'
import { computed, ref } from 'vue'
import FriendsSection from '@/components/ui/friends/FriendsSection.vue'
import ModalWrapper from '@/components/ui/modal/ModalWrapper.vue'
import { friend_listener } from '@/helpers/events'
import {
add_friend,
friends,
type FriendWithUserData,
remove_friend,
transformFriends,
} from '@/helpers/friends.ts'
import { useFriends } from '@/composables/use-friends'
import type { FriendWithUserData } from '@/helpers/friends.ts'
import type { ModrinthCredentials } from '@/helpers/mr_auth'
const { formatMessage } = useVIntl()
@@ -35,36 +29,23 @@ const props = defineProps<{
}>()
const userCredentials = computed(() => props.credentials)
const {
friends: userFriends,
loading,
requestFriend,
acceptFriend,
removeFriend: removeFriendRecord,
} = useFriends({
currentUserId: () => userCredentials.value?.user_id,
getCredentials: () => userCredentials.value,
onError: handleError,
})
const search = ref('')
const friendInvitesModal = ref()
const username = ref('')
const addFriendModal = ref()
async function addFriendFromModal() {
addFriendModal.value.hide()
await add_friend(username.value).catch(handleError)
username.value = ''
await loadFriends()
}
async function addFriend(friend: FriendWithUserData) {
const id = friend.id === userCredentials.value?.user_id ? friend.friend_id : friend.id
if (id) {
await add_friend(id).catch(handleError)
await loadFriends()
}
}
async function removeFriend(friend: FriendWithUserData) {
const id = friend.id === userCredentials.value?.user_id ? friend.friend_id : friend.id
if (id) {
await remove_friend(id).catch(handleError)
await loadFriends()
}
}
const userFriends = ref<FriendWithUserData[]>([])
const sortedFriends = computed<FriendWithUserData[]>(() =>
userFriends.value.slice().sort((a, b) => {
if (a.last_updated === null && b.last_updated === null) {
@@ -108,42 +89,22 @@ const incomingRequests = computed(() =>
.sort((a, b) => b.created.diff(a.created)),
)
const loading = ref(true)
async function loadFriends(timeout = false) {
loading.value = timeout
function addFriendFromModal() {
const target = username.value.trim()
if (!target) return
try {
const friendsList = await friends()
userFriends.value = await transformFriends(friendsList, userCredentials.value)
loading.value = false
} catch (e) {
console.error('Error loading friends', e)
if (timeout) {
setTimeout(() => loadFriends(), 15 * 1000)
}
}
addFriendModal.value.hide()
requestFriend({ id: target, username: target })
username.value = ''
}
watch(
userCredentials,
() => {
if (userCredentials.value === undefined) {
userFriends.value = []
loading.value = false
} else if (userCredentials.value === null) {
userFriends.value = []
loading.value = false
} else {
loadFriends(true)
}
},
{ immediate: true },
)
function addFriend(friend: FriendWithUserData) {
acceptFriend(friend)
}
const unlisten = await friend_listener(() => loadFriends())
onUnmounted(() => {
unlisten()
})
function removeFriend(friend: FriendWithUserData) {
removeFriendRecord(friend)
}
const messages = defineMessages({
addFriend: {
@@ -301,11 +262,12 @@ const messages = defineMessages({
</ButtonStyled>
<StyledInput
v-model="search"
:icon="SearchIcon"
type="text"
:placeholder="formatMessage(messages.searchFriends)"
clearable
variant="outlined"
wrapper-class="flex-1"
input-class="!bg-transparent !border !border-solid !border-button-bg !text-primary !placeholder:text-primary"
wrapper-class="flex-1 [&>svg]:!text-primary [&>svg]:!opacity-100"
@keyup.esc="search = ''"
/>
</template>
@@ -0,0 +1,105 @@
<template>
<StackedAdmonitions v-bind="$attrs" :items="stackItems" class="w-full">
<template #item="{ item, dismissible }">
<InstanceAdmonitionsSharedInstanceStale
v-if="item.kind === 'shared-instance-stale'"
:instance="instance"
@published="emit('published')"
/>
<InstanceAdmonitionsSharedInstanceWrongAccount
v-else-if="item.kind === 'shared-instance-wrong-account'"
:expected-user-id="sharedInstanceExpectedUserId"
:role="sharedInstanceRole"
:signed-out="sharedInstanceSignedOut"
/>
<InstanceAdmonitionsSharedInstanceUnavailable
v-else-if="item.kind === 'shared-instance-unavailable'"
:reason="sharedInstanceUnavailableReason"
:manager="sharedInstanceUnavailableManager"
:dismissible="dismissible"
@dismiss="sharedInstanceUnavailableDismissed = true"
/>
</template>
</StackedAdmonitions>
</template>
<script setup lang="ts">
import { StackedAdmonitions } from '@modrinth/ui'
import { computed, ref, watch } from 'vue'
import type { SharedInstanceUnavailableReason } from '@/helpers/install'
import type { GameInstance } from '@/helpers/types'
import InstanceAdmonitionsSharedInstanceStale from './instance-admonitions-shared-instance-stale.vue'
import InstanceAdmonitionsSharedInstanceUnavailable from './instance-admonitions-shared-instance-unavailable.vue'
import InstanceAdmonitionsSharedInstanceWrongAccount from './instance-admonitions-shared-instance-wrong-account.vue'
import type { InstanceAdmonitionItem, SharedInstanceRole } from './types'
defineOptions({
inheritAttrs: false,
})
const props = defineProps<{
instance: GameInstance
sharedInstanceUnavailableReason?: SharedInstanceUnavailableReason | null
sharedInstanceUnavailableManager?: string | null
sharedInstanceWrongAccount?: boolean
sharedInstanceExpectedUserId?: string | null
sharedInstanceRole?: SharedInstanceRole | null
sharedInstanceSignedOut?: boolean
}>()
const emit = defineEmits<{
published: []
}>()
const sharedInstanceWrongAccount = computed(() => props.sharedInstanceWrongAccount ?? false)
const sharedInstanceUnavailableDismissed = ref(false)
const showSharedInstancePublishAdmonition = computed(
() =>
!sharedInstanceWrongAccount.value &&
props.instance.install_stage === 'installed' &&
props.instance.shared_instance?.role === 'owner' &&
props.instance.shared_instance.status === 'stale',
)
const stackItems = computed<InstanceAdmonitionItem[]>(() => {
const items: InstanceAdmonitionItem[] = []
if (sharedInstanceWrongAccount.value) {
items.push({
id: 'shared-instance-wrong-account',
type: 'warning',
dismissible: false,
kind: 'shared-instance-wrong-account',
})
}
if (props.sharedInstanceUnavailableReason && !sharedInstanceUnavailableDismissed.value) {
items.push({
id: 'shared-instance-unavailable',
type: 'warning',
dismissible: true,
kind: 'shared-instance-unavailable',
})
}
if (showSharedInstancePublishAdmonition.value) {
items.push({
id: 'shared-instance-stale',
type: 'warning',
dismissible: false,
kind: 'shared-instance-stale',
})
}
return items
})
watch(
() => [props.instance.id, props.sharedInstanceUnavailableReason],
() => {
sharedInstanceUnavailableDismissed.value = false
},
)
</script>
@@ -0,0 +1,69 @@
import { defineMessages } from '@modrinth/ui'
export const instanceAdmonitionsMessages = defineMessages({
sharedInstanceChangesHeader: {
id: 'app.instance.admonitions.shared-instance.changes-header',
defaultMessage: "Your changes haven't been shared yet",
},
sharedInstanceChangesBody: {
id: 'app.instance.admonitions.shared-instance.changes-body',
defaultMessage: "Your local instance is ahead of the users you've shared it with.",
},
sharedInstancePublishButton: {
id: 'app.instance.admonitions.shared-instance.publish-button',
defaultMessage: 'Push update',
},
sharedInstancePublishingButton: {
id: 'app.instance.admonitions.shared-instance.publishing-button',
defaultMessage: 'Pushing...',
},
sharedInstanceReviewingButton: {
id: 'app.instance.admonitions.shared-instance.reviewing-button',
defaultMessage: 'Reviewing...',
},
sharedInstanceReviewHeader: {
id: 'app.instance.admonitions.shared-instance.review-header',
defaultMessage: 'Review changes',
},
sharedInstanceReviewAdmonitionHeader: {
id: 'app.instance.admonitions.shared-instance.review-admonition-header',
defaultMessage: 'Push update',
},
sharedInstanceReviewDescription: {
id: 'app.instance.admonitions.shared-instance.review-description',
defaultMessage:
'Review the content changes that will be shared with everyone using this instance.',
},
sharedInstanceAddedLabel: {
id: 'app.instance.admonitions.shared-instance.added-label',
defaultMessage: 'Added',
},
sharedInstanceRemovedLabel: {
id: 'app.instance.admonitions.shared-instance.removed-label',
defaultMessage: 'Removed',
},
sharedInstanceWrongAccountHeader: {
id: 'app.instance.shared-instance-wrong-account.warning-header',
defaultMessage: 'You are using the wrong Modrinth account',
},
sharedInstanceSignedOutHeader: {
id: 'app.instance.shared-instance-wrong-account.signed-out-header',
defaultMessage: 'You need to sign in to Modrinth',
},
sharedInstanceWrongAccountSignInAs: {
id: 'app.instance.shared-instance-wrong-account.sign-in-as-label',
defaultMessage: 'Sign in as',
},
sharedInstanceWrongAccountUserBody: {
id: 'app.instance.shared-instance-wrong-account.user-admonition-body-v2',
defaultMessage: 'to receive updates for this shared instance.',
},
sharedInstanceWrongAccountOwnerBody: {
id: 'app.instance.shared-instance-wrong-account.owner-admonition-body-v2',
defaultMessage: "to manage this shared instance. You won't be able to push updates to users.",
},
sharedInstanceWrongAccountFallbackUsername: {
id: 'app.instance.shared-instance-wrong-account.fallback-username',
defaultMessage: 'the linked account',
},
})
@@ -0,0 +1,133 @@
<template>
<Admonition
type="warning"
inline-actions
:header="formatMessage(messages.sharedInstanceChangesHeader)"
>
{{ formatMessage(messages.sharedInstanceChangesBody) }}
<template #actions>
<ButtonStyled color="orange">
<button class="!h-10" :disabled="isPublishButtonDisabled" @click="reviewChanges">
<UploadIcon aria-hidden="true" />
{{
isPublishing
? formatMessage(messages.sharedInstancePublishingButton)
: isReviewingPublish
? formatMessage(messages.sharedInstanceReviewingButton)
: formatMessage(messages.sharedInstancePublishButton)
}}
</button>
</ButtonStyled>
</template>
</Admonition>
<ContentDiffModal
ref="publishReviewModal"
:header="formatMessage(messages.sharedInstanceReviewHeader)"
:admonition-header="formatMessage(messages.sharedInstanceReviewAdmonitionHeader)"
:description="formatMessage(messages.sharedInstanceReviewDescription)"
:diffs="publishDiffs"
:confirm-label="formatMessage(messages.sharedInstancePublishButton)"
:confirm-icon="UploadIcon"
:added-label="formatMessage(messages.sharedInstanceAddedLabel)"
:removed-label="formatMessage(messages.sharedInstanceRemovedLabel)"
@confirm="publishChanges"
/>
</template>
<script setup lang="ts">
import { UploadIcon } from '@modrinth/assets'
import {
Admonition,
ButtonStyled,
type ContentDiffItem,
ContentDiffModal,
useVIntl,
} from '@modrinth/ui'
import { computed, ref } from 'vue'
import {
getSharedInstanceUnavailableReason,
isSharedInstanceUnavailableError,
} from '@/helpers/install'
import { get_shared_instance_publish_preview, publish_shared_instance } from '@/helpers/instance'
import { useSharedInstanceErrors } from '@/helpers/shared-instance-errors'
import type { GameInstance } from '@/helpers/types'
import { instanceAdmonitionsMessages as messages } from './instance-admonitions-messages'
const props = defineProps<{
instance: GameInstance
}>()
const emit = defineEmits<{
published: []
}>()
const { formatMessage } = useVIntl()
const { notifySharedInstanceError, notifySharedInstanceUnavailable } = useSharedInstanceErrors()
const isPublishing = ref(false)
const isReviewingPublish = ref(false)
const publishReviewModal = ref<InstanceType<typeof ContentDiffModal>>()
const publishDiffs = ref<ContentDiffItem[]>([])
const isPublishButtonDisabled = computed(() => isPublishing.value || isReviewingPublish.value)
async function reviewChanges(e?: MouseEvent) {
if (isPublishButtonDisabled.value) return
isReviewingPublish.value = true
try {
const preview = await get_shared_instance_publish_preview(props.instance.id)
if (!preview) {
notifySharedInstanceUnavailable()
emit('published')
return
}
if (preview.diffs.length === 0) {
emit('published')
return
}
publishDiffs.value = preview.diffs.map((diff) => ({
type: diff.type,
projectName: diff.projectName ?? undefined,
fileName: diff.fileName ?? undefined,
currentVersionName: diff.currentVersionName ?? undefined,
newVersionName: diff.newVersionName ?? undefined,
disabled: diff.disabled,
}))
publishReviewModal.value?.show(e)
} catch (err) {
if (isSharedInstanceUnavailableError(err)) {
notifySharedInstanceUnavailable(getSharedInstanceUnavailableReason(err))
emit('published')
return
}
notifySharedInstanceError(err)
} finally {
isReviewingPublish.value = false
}
}
async function publishChanges() {
if (isPublishing.value) return
isPublishing.value = true
try {
await publish_shared_instance(props.instance.id)
emit('published')
} catch (err) {
if (isSharedInstanceUnavailableError(err)) {
notifySharedInstanceUnavailable(getSharedInstanceUnavailableReason(err))
emit('published')
return
}
notifySharedInstanceError(err)
} finally {
isPublishing.value = false
}
}
</script>
@@ -0,0 +1,33 @@
<template>
<Admonition
type="warning"
:header="formatMessage(sharedInstanceErrorMessages.unavailableTitle)"
:dismissible="dismissible"
@dismiss="emit('dismiss')"
>
{{ formatSharedInstanceUnavailable(reason ?? null, manager) }}
</Admonition>
</template>
<script setup lang="ts">
import { Admonition, useVIntl } from '@modrinth/ui'
import type { SharedInstanceUnavailableReason } from '@/helpers/install'
import {
sharedInstanceErrorMessages,
useSharedInstanceErrors,
} from '@/helpers/shared-instance-errors'
defineProps<{
reason?: SharedInstanceUnavailableReason | null
manager?: string | null
dismissible?: boolean
}>()
const emit = defineEmits<{
dismiss: []
}>()
const { formatMessage } = useVIntl()
const { formatSharedInstanceUnavailable } = useSharedInstanceErrors()
</script>
@@ -0,0 +1,78 @@
<template>
<Admonition type="warning" :header="formatMessage(headerMessage)">
<span class="flex flex-wrap items-center gap-x-1.5 gap-y-1">
<span>{{ formatMessage(messages.sharedInstanceWrongAccountSignInAs) }}</span>
<span
v-if="sharedInstanceExpectedUser"
class="inline-flex max-w-full min-w-0 items-center gap-1.5 align-middle font-semibold text-contrast"
>
<Avatar
:src="sharedInstanceExpectedUser.avatarUrl"
:alt="sharedInstanceExpectedUsername"
:tint-by="sharedInstanceExpectedUser.tintBy"
size="20px"
circle
no-shadow
/>
<span class="min-w-0 truncate">{{ sharedInstanceExpectedUsername }}</span>
</span>
<span v-else class="font-semibold">{{ sharedInstanceExpectedUsername }}</span>
<span>{{ formatMessage(bodyMessage) }}</span>
</span>
</Admonition>
</template>
<script setup lang="ts">
import { Admonition, Avatar, useVIntl } from '@modrinth/ui'
import { useQuery } from '@tanstack/vue-query'
import { computed } from 'vue'
import { get_user } from '@/helpers/cache'
import { instanceAdmonitionsMessages as messages } from './instance-admonitions-messages'
import type { SharedInstanceRole } from './types'
const props = defineProps<{
expectedUserId?: string | null
role?: SharedInstanceRole | null
signedOut?: boolean
}>()
const { formatMessage } = useVIntl()
const expectedUserId = computed(() => props.expectedUserId ?? null)
const expectedUserQuery = useQuery({
queryKey: computed(() => ['user', expectedUserId.value]),
queryFn: async () => {
if (!expectedUserId.value) return null
return await get_user(expectedUserId.value, 'bypass').catch(() => null)
},
enabled: () => !!expectedUserId.value,
staleTime: 30_000,
})
const sharedInstanceExpectedUser = computed(() => {
const user = expectedUserQuery.data.value
if (!user) return null
return {
username: user.username,
avatarUrl: user.avatar_url ?? undefined,
tintBy: user.id,
}
})
const sharedInstanceExpectedUsername = computed(
() =>
sharedInstanceExpectedUser.value?.username ||
formatMessage(messages.sharedInstanceWrongAccountFallbackUsername),
)
const headerMessage = computed(() =>
props.signedOut
? messages.sharedInstanceSignedOutHeader
: messages.sharedInstanceWrongAccountHeader,
)
const bodyMessage = computed(() =>
props.role === 'owner'
? messages.sharedInstanceWrongAccountOwnerBody
: messages.sharedInstanceWrongAccountUserBody,
)
</script>
@@ -0,0 +1,12 @@
import type { StackedAdmonitionItem } from '@modrinth/ui'
export type InstanceAdmonitionKind =
| 'shared-instance-stale'
| 'shared-instance-unavailable'
| 'shared-instance-wrong-account'
export type InstanceAdmonitionItem = StackedAdmonitionItem & {
kind: InstanceAdmonitionKind
}
export type SharedInstanceRole = 'owner' | 'member'
@@ -7,7 +7,6 @@ import {
injectFilePicker,
injectNotificationManager,
InstallationSettingsLayout,
provideAppBackup,
provideInstallationSettings,
useDebugLogger,
useVIntl,
@@ -16,24 +15,26 @@ import type { GameVersionTag, PlatformTag } from '@modrinth/utils'
import { useQuery, useQueryClient } from '@tanstack/vue-query'
import { computed, ref } from 'vue'
import SharedInstanceInstallationSettingsControls from '@/components/ui/shared-instances/SharedInstanceInstallationSettingsControls.vue'
import { useManagedContentPolicy } from '@/composables/instances/use-managed-content-policy'
import { trackEvent } from '@/helpers/analytics'
import { get_project_versions, get_version } from '@/helpers/cache'
import {
install_duplicate_instance,
install_existing_instance,
install_pack_to_existing_instance,
installJobInstanceId,
wait_for_install_job,
} from '@/helpers/install'
import {
edit,
get_linked_modpack_info,
list,
unlink_shared_instance,
unpublish_shared_instance,
update_managed_modrinth_version,
update_repair_modrinth,
} from '@/helpers/instance'
import { get_loader_versions } from '@/helpers/metadata'
import { get_game_versions, get_loaders } from '@/helpers/tags'
import { provideInstanceBackup } from '@/providers/instance-backup'
import { injectInstanceSettings } from '@/providers/instance-settings'
import { useTheming } from '@/store/state'
@@ -47,6 +48,7 @@ const debug = useDebugLogger('AppInstallationSettings')
const themeStore = useTheming()
const { instance, offline, isMinecraftServer, onUnlinked, closeModal } = injectInstanceSettings()
const managedContentPolicy = useManagedContentPolicy(instance)
const skipNonEssentialWarnings = computed(() =>
themeStore.getFeatureFlag('skip_non_essential_warnings'),
)
@@ -111,9 +113,15 @@ debug('metadata queries configured', {
const isModrinthLinkedModpack = computed(
() =>
instance.value.link?.type === 'modrinth_modpack' ||
instance.value.link?.type === 'server_project_modpack',
instance.value.link?.type === 'server_project_modpack' ||
(instance.value.link?.type === 'shared_instance' &&
!!instance.value.link.modpack_project_id &&
!!instance.value.link.modpack_version_id),
)
const isImportedModpack = computed(() => instance.value.link?.type === 'imported_modpack')
const isSharedInstanceManagedModpack = managedContentPolicy.isManagedModpack
const canUnpublishSharedInstance = managedContentPolicy.canUnpublish
const canUnlinkSharedInstance = managedContentPolicy.canUnlink
const modpackInfoQuery = useQuery({
queryKey: computed(() => ['linkedModpackInfo', instance.value.id]),
@@ -124,6 +132,36 @@ const modpackInfo = modpackInfoQuery.data
const repairing = ref(false)
const reinstalling = ref(false)
const unpublishingSharedInstance = ref(false)
const unlinkingSharedInstance = ref(false)
async function unpublishSharedInstance() {
unpublishingSharedInstance.value = true
try {
await unpublish_shared_instance(instance.value.id)
await queryClient.invalidateQueries({ queryKey: ['sharedInstanceUsers', instance.value.id] })
await queryClient.invalidateQueries({ queryKey: ['linkedModpackInfo', instance.value.id] })
onUnlinked()
} catch (error) {
handleError(error)
} finally {
unpublishingSharedInstance.value = false
}
}
async function unlinkSharedInstance() {
unlinkingSharedInstance.value = true
try {
await unlink_shared_instance(instance.value.id)
await queryClient.invalidateQueries({ queryKey: ['sharedInstanceUsers', instance.value.id] })
await queryClient.invalidateQueries({ queryKey: ['linkedModpackInfo', instance.value.id] })
onUnlinked()
} catch (error) {
handleError(error)
} finally {
unlinkingSharedInstance.value = false
}
}
const messages = defineMessages({
loaderVersion: {
@@ -162,27 +200,7 @@ async function installLocalModpackFromPicker() {
return !!completed
}
provideAppBackup({
async createBackup() {
debug('createBackup: start', {
instanceId: instance.value.id,
instanceName: instance.value.name,
})
const allInstances = await list()
const prefix = `${instance.value.name} - Backup #`
const existingNums = allInstances
.filter((p) => p.name.startsWith(prefix))
.map((p) => parseInt(p.name.slice(prefix.length), 10))
.filter((n) => !isNaN(n))
const nextNum = existingNums.length > 0 ? Math.max(...existingNums) + 1 : 1
const job = await install_duplicate_instance(instance.value.id)
const newInstanceId = installJobInstanceId(job)
if (newInstanceId) {
await edit(newInstanceId, { name: `${prefix}${nextNum}` })
}
debug('createBackup: done', { newInstanceId, backupName: `${prefix}${nextNum}` })
},
})
provideInstanceBackup(instance)
provideInstallationSettings({
closeSettings: closeModal,
@@ -208,12 +226,19 @@ provideInstallationSettings({
}
return rows
}),
isLinked: computed(() => isModrinthLinkedModpack.value || isImportedModpack.value),
isLinked: computed(
() =>
isModrinthLinkedModpack.value ||
isImportedModpack.value ||
isSharedInstanceManagedModpack.value,
),
isBusy: computed(
() =>
instance.value.install_stage !== 'installed' ||
repairing.value ||
reinstalling.value ||
unlinkingSharedInstance.value ||
unpublishingSharedInstance.value ||
!!offline,
),
skipNonEssentialWarnings,
@@ -269,16 +294,31 @@ provideInstallationSettings({
debug('resolveLoaderVersions: no manifest', { loader, gameVersion })
return []
}
if (loader === 'fabric' || loader === 'quilt') {
const result = manifest.gameVersions[0]?.loaders ?? []
debug('resolveLoaderVersions: fabric/quilt result', {
const entry = manifest.gameVersions?.find((item) => item.id === gameVersion)
if (entry?.versionGroup) {
const result =
manifest.versionGroups?.find((group) => group.id === entry.versionGroup)?.loaders ?? []
debug('resolveLoaderVersions: version group result', {
loader,
gameVersion,
versionGroup: entry.versionGroup,
count: result.length,
})
return result
}
const placeholder = manifest.gameVersions?.find((item) => item.id === '${modrinth.gameVersion}')
if (placeholder) {
const result = manifest.gameVersions?.some((item) => item.id === gameVersion)
? placeholder.loaders
: []
debug('resolveLoaderVersions: placeholder result', {
loader,
gameVersion,
count: result.length,
})
return result
}
const result = manifest.gameVersions?.find((item) => item.id === gameVersion)?.loaders ?? []
const result = entry?.loaders ?? []
debug('resolveLoaderVersions: result', { loader, gameVersion, count: result.length })
return result
},
@@ -437,14 +477,38 @@ provideInstallationSettings({
isServer: false,
isApp: true,
showModpackVersionActions: computed(
() => isModrinthLinkedModpack.value && !isMinecraftServer.value,
() =>
isModrinthLinkedModpack.value &&
!isMinecraftServer.value &&
!isSharedInstanceManagedModpack.value,
),
isLocalFile: isImportedModpack,
isManagedModpack: isSharedInstanceManagedModpack,
managedModpackWarning: managedContentPolicy.managedModpackWarning,
repairing,
reinstalling,
})
</script>
<template>
<InstallationSettingsLayout />
<InstallationSettingsLayout>
<template #extra>
<SharedInstanceInstallationSettingsControls
:can-unpublish="canUnpublishSharedInstance"
:can-unlink="canUnlinkSharedInstance"
:busy="
instance.install_stage !== 'installed' ||
repairing ||
reinstalling ||
unpublishingSharedInstance ||
unlinkingSharedInstance ||
!!offline
"
:unpublishing="unpublishingSharedInstance"
:unlinking="unlinkingSharedInstance"
:unpublish="unpublishSharedInstance"
:unlink="unlinkSharedInstance"
/>
</template>
</InstallationSettingsLayout>
</template>
@@ -0,0 +1,152 @@
<template>
<NewModal
ref="modal"
:header="formatMessage(messages.header)"
:on-hide="() => finish(false)"
no-padding
max-width="548px"
width="100%"
>
<div class="flex w-full flex-col gap-6 p-6">
<div class="flex flex-col gap-2 px-3">
<h2 class="m-0 text-xl font-semibold leading-7 text-contrast">
{{ formatMessage(messages.signInHeading) }}
</h2>
<p class="m-0 text-base leading-6 text-primary">
{{ formatMessage(messages.description) }}
</p>
</div>
<div class="flex flex-col gap-6">
<div class="grid grid-cols-1 gap-2 sm:grid-cols-2">
<ButtonStyled>
<button
class="w-full !shadow-none"
type="button"
:disabled="authenticating !== null"
@click="authenticate('sign-up')"
>
<SpinnerIcon
v-if="authenticating === 'sign-up'"
aria-hidden="true"
class="animate-spin"
/>
<UserPlusIcon v-else aria-hidden="true" />
{{ formatMessage(messages.createAccountButton) }}
</button>
</ButtonStyled>
<ButtonStyled color="brand">
<button
class="w-full"
type="button"
:disabled="authenticating !== null"
@click="authenticate('sign-in')"
>
<SpinnerIcon
v-if="authenticating === 'sign-in'"
aria-hidden="true"
class="animate-spin"
/>
<LogInIcon v-else aria-hidden="true" />
{{ formatMessage(messages.signInButton) }}
</button>
</ButtonStyled>
</div>
<p class="m-0 text-center text-base font-medium leading-6 text-primary">
<IntlFormatted :message-id="messages.supportPrompt">
<template #support="{ children }">
<button
type="button"
class="inline cursor-pointer border-0 bg-transparent p-0 text-base font-medium leading-6 text-blue hover:underline"
@click="openSupport"
>
<component :is="() => children" />
</button>
</template>
</IntlFormatted>
</p>
</div>
</div>
</NewModal>
</template>
<script setup lang="ts">
import { LogInIcon, SpinnerIcon, UserPlusIcon } from '@modrinth/assets'
import { ButtonStyled, defineMessages, IntlFormatted, NewModal, useVIntl } from '@modrinth/ui'
import { openUrl } from '@tauri-apps/plugin-opener'
import { ref } from 'vue'
import type { ModrinthAuthFlow } from '@/helpers/mr_auth'
const props = defineProps<{
requestAuth: (flow: ModrinthAuthFlow) => Promise<boolean>
}>()
const { formatMessage } = useVIntl()
const modal = ref<InstanceType<typeof NewModal>>()
const authenticating = ref<ModrinthAuthFlow | null>(null)
let resolveShow: ((signedIn: boolean) => void) | undefined
function show(event?: MouseEvent) {
resolveShow?.(false)
const modalInstance = modal.value
if (!modalInstance) return Promise.resolve(false)
return new Promise<boolean>((resolve) => {
resolveShow = resolve
modalInstance.show(event)
})
}
function finish(signedIn: boolean) {
resolveShow?.(signedIn)
resolveShow = undefined
}
async function authenticate(flow: ModrinthAuthFlow) {
authenticating.value = flow
try {
if (await props.requestAuth(flow)) {
finish(true)
modal.value?.hide()
}
} finally {
authenticating.value = null
}
}
function openSupport() {
openUrl('https://support.modrinth.com')
}
const messages = defineMessages({
header: {
id: 'modal.modrinth-account-required.header',
defaultMessage: 'Account required',
},
signInHeading: {
id: 'modal.modrinth-account-required.sign-in-heading',
defaultMessage: 'Sign in to a Modrinth account',
},
description: {
id: 'modal.modrinth-account-required.description',
defaultMessage:
"You'll need to sign into your Modrinth account before you can use this feature.",
},
createAccountButton: {
id: 'modal.modrinth-account-required.create-account-button',
defaultMessage: 'Create an account',
},
signInButton: {
id: 'modal.modrinth-account-required.sign-in-button',
defaultMessage: 'Sign in to Modrinth',
},
supportPrompt: {
id: 'modal.modrinth-account-required.support-prompt',
defaultMessage: 'Having trouble signing in? <support>Get support</support>',
},
})
defineExpose({ show })
</script>
@@ -77,6 +77,11 @@ const { formatMessage } = useVIntl()
const { startInstallingServer, stopInstallingServer } = injectServerInstall()
type UpdateCompleteCallback = () => void | Promise<void>
const emit = defineEmits<{
cancel: []
complete: []
}>()
const diffModal = ref<InstanceType<typeof ContentDiffModal>>()
const instance = ref<GameInstance | null>(null)
const onUpdateComplete = ref<UpdateCompleteCallback>(() => {})
@@ -84,15 +89,15 @@ const diffs = ref<DependencyDiff[]>([])
const modpackVersionId = ref<string | null>(null)
const modpackVersion = ref<Version | null>(null)
const normalizedDiffs = computed<ContentDiffItem[]>(() =>
diffs.value.map((diff) => ({
const normalizedDiffs = computed<ContentDiffItem[]>(() => {
return diffs.value.map((diff) => ({
type: diff.type,
projectName: diff.project?.title,
fileName: diff.fileName,
currentVersionName: diff.currentVersion?.version_number,
newVersionName: diff.newVersion?.version_number,
})),
)
}))
})
async function computeDependencyDiffs(
currentDeps: Dependency[],
@@ -238,7 +243,6 @@ watch(
)
async function handleUpdate() {
hide()
const serverProjectId = instance.value?.link?.project_id
if (serverProjectId) startInstallingServer(serverProjectId)
try {
@@ -251,6 +255,7 @@ async function handleUpdate() {
console.error('Error updating instance:', error)
} finally {
if (serverProjectId) stopInstallingServer(serverProjectId)
emit('complete')
}
}
@@ -261,7 +266,7 @@ function handleReport() {
}
function handleDecline() {
hide()
emit('cancel')
}
function show(
@@ -292,7 +297,7 @@ const messages = defineMessages({
updateRequiredDescription: {
id: 'app.modal.update-to-play.update-required-description',
defaultMessage:
'An update is required to play {name}. Please update to the latest version to launch the game.',
'An update is required to play {name}. Please update to latest version to launch the game.',
},
})
@@ -0,0 +1,181 @@
<template>
<div v-if="canUnpublish" class="flex flex-col gap-2.5">
<span class="text-lg font-semibold text-contrast">{{ formatMessage(messages.title) }}</span>
<div>
<ButtonStyled color="orange">
<button :disabled="busy" @click="unpublishModal?.show()">
<SpinnerIcon v-if="unpublishing" class="animate-spin" />
<UnlinkIcon v-else class="size-5" />
{{ formatMessage(unpublishing ? messages.unpublishingButton : messages.unpublishButton) }}
</button>
</ButtonStyled>
</div>
<span class="text-primary">{{ formatMessage(messages.unpublishDescription) }}</span>
</div>
<div v-if="canUnlink" class="flex flex-col gap-2.5">
<span class="text-lg font-semibold text-contrast">{{
formatMessage(messages.linkedTitle)
}}</span>
<div>
<ButtonStyled color="orange">
<button :disabled="busy" @click="unlinkModal?.show()">
<SpinnerIcon v-if="unlinking" class="animate-spin" />
<UnlinkIcon v-else class="size-5" />
{{ formatMessage(unlinking ? messages.unlinkingButton : messages.unlinkButton) }}
</button>
</ButtonStyled>
</div>
<span class="text-primary">{{ formatMessage(messages.unlinkDescription) }}</span>
</div>
<NewModal
ref="unpublishModal"
:header="formatMessage(messages.unpublishModalHeader)"
fade="warning"
max-width="500px"
>
<Admonition type="warning" :header="formatMessage(messages.unpublishModalAdmonitionHeader)">{{
formatMessage(messages.unpublishModalBody)
}}</Admonition>
<template #actions
><div class="flex justify-end gap-2">
<ButtonStyled
><button @click="unpublishModal?.hide()">
<XIcon />{{ formatMessage(commonMessages.cancelButton) }}
</button></ButtonStyled
><ButtonStyled color="orange"
><button :disabled="busy" @click="confirmUnpublish">
<UnlinkIcon />{{ formatMessage(messages.unpublishButton) }}
</button></ButtonStyled
>
</div></template
>
</NewModal>
<NewModal
ref="unlinkModal"
:header="formatMessage(messages.unlinkModalHeader)"
fade="warning"
max-width="500px"
:on-hide="() => backupCreator?.cancelBackup()"
>
<div class="flex flex-col gap-6">
<Admonition type="warning" :header="formatMessage(messages.unlinkModalAdmonitionHeader)">{{
formatMessage(messages.unlinkModalBody)
}}</Admonition>
<InlineBackupCreator
ref="backupCreator"
backup-name="Before unlinking shared instance"
@update:buttons-disabled="backupBusy = $event"
/>
</div>
<template #actions
><div class="flex justify-end gap-2">
<ButtonStyled
><button @click="unlinkModal?.hide()">
<XIcon />{{ formatMessage(commonMessages.cancelButton) }}
</button></ButtonStyled
><ButtonStyled color="orange"
><button :disabled="busy || backupBusy" @click="confirmUnlink">
<UnlinkIcon />{{ formatMessage(messages.unlinkButton) }}
</button></ButtonStyled
>
</div></template
>
</NewModal>
</template>
<script setup lang="ts">
import { SpinnerIcon, UnlinkIcon, XIcon } from '@modrinth/assets'
import {
Admonition,
ButtonStyled,
commonMessages,
defineMessages,
InlineBackupCreator,
NewModal,
useVIntl,
} from '@modrinth/ui'
import { ref } from 'vue'
const props = defineProps<{
canUnpublish: boolean
canUnlink: boolean
busy: boolean
unpublishing: boolean
unlinking: boolean
unpublish: () => Promise<void>
unlink: () => Promise<void>
}>()
const { formatMessage } = useVIntl()
const unpublishModal = ref<InstanceType<typeof NewModal>>()
const unlinkModal = ref<InstanceType<typeof NewModal>>()
const backupCreator = ref<InstanceType<typeof InlineBackupCreator>>()
const backupBusy = ref(false)
async function confirmUnpublish() {
unpublishModal.value?.hide()
await props.unpublish()
}
async function confirmUnlink() {
unlinkModal.value?.hide()
await props.unlink()
}
const messages = defineMessages({
title: { id: 'installation-settings.shared-instance.title', defaultMessage: 'Shared instance' },
linkedTitle: {
id: 'installation-settings.shared-instance.linked-title',
defaultMessage: 'Linked shared instance',
},
unpublishButton: {
id: 'installation-settings.shared-instance.unpublish-button',
defaultMessage: 'Unpublish shared instance',
},
unpublishingButton: {
id: 'installation-settings.shared-instance.unpublishing-button',
defaultMessage: 'Unpublishing...',
},
unpublishDescription: {
id: 'installation-settings.shared-instance.unpublish-description',
defaultMessage: 'Stop sharing this instance and remove it from Modrinth.',
},
unlinkButton: {
id: 'installation-settings.shared-instance.unlink-button',
defaultMessage: 'Unlink shared instance',
},
unlinkingButton: {
id: 'installation-settings.shared-instance.unlinking-button',
defaultMessage: 'Unlinking...',
},
unlinkDescription: {
id: 'installation-settings.shared-instance.unlink-description',
defaultMessage: 'Disconnect this local instance from future shared updates.',
},
unpublishModalHeader: {
id: 'installation-settings.unpublish-shared-instance.modal.header',
defaultMessage: 'Unpublish shared instance',
},
unpublishModalAdmonitionHeader: {
id: 'installation-settings.unpublish-shared-instance.modal.admonition-header',
defaultMessage: 'Unpublishing shared instance',
},
unpublishModalBody: {
id: 'installation-settings.unpublish-shared-instance.modal.admonition-body',
defaultMessage:
"This deletes the shared instance from Modrinth's servers. People using it in the Modrinth App will stop receiving updates, but your local instance and its content will stay on this device.",
},
unlinkModalHeader: {
id: 'installation-settings.unlink-shared-instance.modal.header',
defaultMessage: 'Unlink shared instance',
},
unlinkModalAdmonitionHeader: {
id: 'installation-settings.unlink-shared-instance.modal.admonition-header',
defaultMessage: 'Unlinking shared instance',
},
unlinkModalBody: {
id: 'installation-settings.unlink-shared-instance.modal.admonition-body',
defaultMessage:
'This only affects your local instance. Your installed content will stay on this device, and the shared instance and other people using it will not be affected.',
},
})
</script>
@@ -0,0 +1,120 @@
<template>
<ContentDiffModal
ref="modal"
:header="formatMessage(messages.updateToPlay)"
:admonition-header="formatMessage(messages.updateRequired)"
:description="instance ? formatMessage(messages.description, { name: instance.name }) : ''"
:diffs="diffs"
:confirm-label="formatMessage(commonMessages.updateButton)"
:confirm-icon="DownloadIcon"
:added-label="formatMessage(messages.addedLabel)"
:removed-label="formatMessage(messages.removedLabel)"
:show-report-button="false"
@confirm="update"
@cancel="emit('cancel')"
/>
</template>
<script setup lang="ts">
import { DownloadIcon } from '@modrinth/assets'
import {
commonMessages,
type ContentDiffItem,
ContentDiffModal,
defineMessages,
useVIntl,
} from '@modrinth/ui'
import { computed, ref } from 'vue'
import {
getSharedInstanceUnavailableReason,
install_update_shared_instance,
isSharedInstanceUnavailableError,
type SharedInstanceUnavailableReason,
type SharedInstanceUpdatePreview,
wait_for_install_job,
} from '@/helpers/install'
import { useSharedInstanceErrors } from '@/helpers/shared-instance-errors'
import type { GameInstance } from '@/helpers/types'
type UpdateCompleteCallback = () => void | Promise<void>
const emit = defineEmits<{
cancel: []
complete: []
sharedInstanceUnavailable: [reason: SharedInstanceUnavailableReason | null]
}>()
const modal = ref<InstanceType<typeof ContentDiffModal>>()
const instance = ref<GameInstance | null>(null)
const preview = ref<SharedInstanceUpdatePreview | null>(null)
const onComplete = ref<UpdateCompleteCallback>(() => {})
const { formatMessage } = useVIntl()
const { notifySharedInstanceError } = useSharedInstanceErrors()
const diffs = computed<ContentDiffItem[]>(
() =>
preview.value?.diffs.map((diff) => ({
type: diff.type,
projectName: diff.projectName ?? undefined,
fileName: diff.fileName ?? undefined,
currentVersionName: diff.currentVersionName ?? undefined,
newVersionName: diff.newVersionName ?? undefined,
disabled: diff.disabled,
})) ?? [],
)
async function update() {
try {
if (instance.value) {
const job = await install_update_shared_instance(instance.value.id)
await wait_for_install_job(job.job_id)
await onComplete.value()
}
} catch (error) {
if (isSharedInstanceUnavailableError(error)) {
emit('sharedInstanceUnavailable', getSharedInstanceUnavailableReason(error))
return
}
notifySharedInstanceError(error)
} finally {
emit('complete')
}
}
function show(
instanceValue: GameInstance,
previewValue: SharedInstanceUpdatePreview,
callback: UpdateCompleteCallback = () => {},
event?: MouseEvent,
) {
instance.value = instanceValue
preview.value = previewValue
onComplete.value = callback
modal.value?.show(event)
}
function hide() {
modal.value?.hide()
}
const messages = defineMessages({
updateToPlay: { id: 'app.modal.update-to-play.header', defaultMessage: 'Update to play' },
updateRequired: {
id: 'app.modal.update-to-play.update-required',
defaultMessage: 'Update required',
},
description: {
id: 'app.modal.update-to-play.update-required-description',
defaultMessage:
'An update is required to play {name}. Please update to latest version to launch the game.',
},
addedLabel: {
id: 'app.modal.update-to-play.shared-instance-added-label',
defaultMessage: 'Added',
},
removedLabel: {
id: 'app.modal.update-to-play.shared-instance-removed-label',
defaultMessage: 'Removed',
},
})
defineExpose({ show, hide })
</script>
@@ -0,0 +1,121 @@
<template>
<NewModal ref="modal" :header="formatMessage(messages.installToPlay)" :closable="true" no-padding>
<div v-if="preview" class="flex flex-col gap-6 max-w-[500px] p-6 pb-2">
<Admonition type="warning" :header="formatMessage(messages.trustWarningHeader)">
{{ formatMessage(messages.trustWarningDescription) }}
</Admonition>
<SharedInstanceInstallSummary :preview="preview" @view-contents="openViewContents" />
<div v-if="preview.externalFileCount" class="flex items-center gap-2 text-orange">
<IssuesIcon class="h-5 w-5 flex-none" />
<span>{{
formatMessage(messages.externalFileWarning, { count: preview.externalFileCount })
}}</span>
</div>
</div>
<template #actions>
<div class="flex justify-end gap-2">
<ButtonStyled
><button @click="hide">
<XIcon />{{ formatMessage(commonMessages.cancelButton) }}
</button></ButtonStyled
>
<ButtonStyled color="brand"
><button @click="accept">
<DownloadIcon />{{ formatMessage(messages.installButton) }}
</button></ButtonStyled
>
</div>
</template>
</NewModal>
<ModpackContentModal
ref="contentModal"
:header="formatMessage(messages.sharedInstanceContent)"
:modpack-name="preview?.name ?? ''"
:modpack-icon-url="preview?.iconUrl ?? undefined"
/>
</template>
<script setup lang="ts">
import { DownloadIcon, IssuesIcon, XIcon } from '@modrinth/assets'
import {
Admonition,
ButtonStyled,
commonMessages,
defineMessages,
ModpackContentModal,
NewModal,
useVIntl,
} from '@modrinth/ui'
import { ref } from 'vue'
import { hide_ads_window, show_ads_window } from '@/helpers/ads'
import type { SharedInstanceInstallPreview } from '@/helpers/install'
import SharedInstanceInstallSummary from './shared-instance-install-summary.vue'
import { useSharedInstancePreviewContent } from './use-shared-instance-preview-content'
const modal = ref<InstanceType<typeof NewModal>>()
const contentModal = ref<InstanceType<typeof ModpackContentModal>>()
const preview = ref<SharedInstanceInstallPreview | null>(null)
const install = ref<() => void | Promise<void>>(() => {})
const { formatMessage } = useVIntl()
const { load } = useSharedInstancePreviewContent()
async function accept() {
hide()
try {
await install.value()
} catch (error) {
console.error('Failed to install shared instance:', error)
}
}
async function openViewContents() {
if (!preview.value) return
contentModal.value?.showLoading()
try {
contentModal.value?.show(await load(preview.value))
} catch (error) {
console.error('Failed to load shared instance contents:', error)
contentModal.value?.show([])
}
}
function show(
previewValue: SharedInstanceInstallPreview,
installValue: () => void | Promise<void>,
event?: MouseEvent,
) {
preview.value = previewValue
install.value = installValue
hide_ads_window()
modal.value?.show(event)
}
function hide() {
modal.value?.hide()
show_ads_window()
}
const messages = defineMessages({
installToPlay: { id: 'app.modal.install-to-play.header', defaultMessage: 'Install to play' },
sharedInstanceContent: {
id: 'app.modal.install-to-play.shared-instance-content',
defaultMessage: 'Shared instance content',
},
trustWarningHeader: {
id: 'app.modal.install-to-play.trust-warning-header',
defaultMessage: 'Do you trust this user?',
},
trustWarningDescription: {
id: 'app.modal.install-to-play.trust-warning-description',
defaultMessage:
'A shared instance will install files on your computer and may include content not from Modrinth.',
},
externalFileWarning: {
id: 'app.modal.install-to-play.external-file-warning',
defaultMessage:
'{count, plural, one {This instance includes # file that is not from Modrinth.} other {This instance includes # files that are not from Modrinth.}}',
},
installButton: { id: 'app.modal.install-to-play.install-button', defaultMessage: 'Install' },
})
defineExpose({ show, hide })
</script>
@@ -0,0 +1,59 @@
<template>
<div class="flex flex-col gap-1">
<div class="flex justify-between items-center">
<span class="font-semibold text-contrast">{{ formatMessage(messages.sharedInstance) }}</span>
<ButtonStyled type="transparent">
<button @click="emit('viewContents')">
<EyeIcon />
{{ formatMessage(messages.viewContents) }}
</button>
</ButtonStyled>
</div>
<div class="flex items-center gap-3 rounded-xl bg-surface-2 p-3">
<Avatar :src="preview.iconUrl" :alt="preview.name" size="48px" />
<div class="flex flex-col gap-0.5">
<span class="font-semibold text-contrast">{{ preview.name }}</span>
<span class="text-sm text-secondary">
{{ loaderDisplay }} {{ preview.gameVersion }}
<template v-if="preview.modCount">
<BulletDivider />
{{ formatMessage(messages.modCount, { count: preview.modCount }) }}
</template>
</span>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { EyeIcon } from '@modrinth/assets'
import {
Avatar,
BulletDivider,
ButtonStyled,
defineMessages,
formatLoader,
useVIntl,
} from '@modrinth/ui'
import { computed } from 'vue'
import type { SharedInstanceInstallPreview } from '@/helpers/install'
const props = defineProps<{ preview: SharedInstanceInstallPreview }>()
const emit = defineEmits<{ viewContents: [] }>()
const { formatMessage } = useVIntl()
const loaderDisplay = computed(() =>
props.preview.loader ? formatLoader(formatMessage, props.preview.loader) : '',
)
const messages = defineMessages({
sharedInstance: {
id: 'app.modal.install-to-play.shared-instance',
defaultMessage: 'Shared instance',
},
viewContents: { id: 'app.modal.install-to-play.view-contents', defaultMessage: 'View contents' },
modCount: {
id: 'app.modal.install-to-play.mod-count',
defaultMessage: '{count, plural, one {# mod} other {# mods}}',
},
})
</script>
@@ -0,0 +1,135 @@
import type { Labrinth } from '@modrinth/api-client'
import type { ContentItem } from '@modrinth/ui'
import { get_project_many, get_version, get_version_many } from '@/helpers/cache.js'
import type { SharedInstanceInstallPreview } from '@/helpers/install'
type VersionDependency = Labrinth.Versions.v2.Dependency & { version_id?: string }
export function useSharedInstancePreviewContent() {
async function load(preview: SharedInstanceInstallPreview): Promise<ContentItem[]> {
return [
...preview.externalFiles.map(externalFileContentItem),
...(await modpackContentItems(preview)),
...(await contentItemsFromVersionIds(
preview.contentVersionIds.filter((id) => id !== preview.modpackVersionId),
)),
]
}
async function modpackContentItems(preview: SharedInstanceInstallPreview) {
if (!preview.modpackVersionId) return []
const version = await get_version(preview.modpackVersionId, 'must_revalidate')
return await contentItemsFromDependencies(version?.dependencies ?? [])
}
async function contentItemsFromDependencies(dependencies: Labrinth.Versions.v2.Dependency[]) {
const deps = dependencies as VersionDependency[]
const projectIds = unique(deps.map((dep) => dep.project_id).filter((id): id is string => !!id))
const versionIds = unique(deps.map((dep) => dep.version_id).filter((id): id is string => !!id))
const [projects, versions]: [Labrinth.Projects.v2.Project[], Labrinth.Versions.v2.Version[]] =
await Promise.all([
projectIds.length ? get_project_many(projectIds, 'must_revalidate') : [],
versionIds.length ? get_version_many(versionIds, 'must_revalidate') : [],
])
const projectMap = new Map(projects.map((project) => [project.id, project]))
const versionMap = new Map(versions.map((version) => [version.id, version]))
return deps.map((dependency): ContentItem => {
const project = dependency.project_id ? projectMap.get(dependency.project_id) : null
const version = dependency.version_id ? versionMap.get(dependency.version_id) : null
const fileName =
version?.files?.[0]?.filename ?? dependency.file_name ?? project?.title ?? 'Unknown'
return contentItem(
version?.id ?? project?.id ?? fileName,
fileName,
project,
version,
!project && !version,
dependency.project_id ?? fileName,
dependency.file_name ?? fileName,
)
})
}
async function contentItemsFromVersionIds(versionIds: string[]) {
const versions: Labrinth.Versions.v2.Version[] = versionIds.length
? await get_version_many(unique(versionIds), 'must_revalidate')
: []
const projectIds = unique(versions.map((version) => version.project_id).filter(Boolean))
const projects: Labrinth.Projects.v2.Project[] = projectIds.length
? await get_project_many(projectIds, 'must_revalidate')
: []
const projectMap = new Map(projects.map((project) => [project.id, project]))
return versions.map((version): ContentItem => {
const project = projectMap.get(version.project_id)
const fileName = version.files?.[0]?.filename ?? project?.title ?? version.name ?? 'Unknown'
return contentItem(
version.id,
fileName,
project,
version,
false,
version.project_id,
version.name,
)
})
}
return { load }
}
function contentItem(
id: string,
fileName: string,
project?: Labrinth.Projects.v2.Project | null,
version?: Labrinth.Versions.v2.Version | null,
external = false,
fallbackProjectId = id,
fallbackTitle = fileName,
projectType = project?.project_type ?? 'mod',
): ContentItem {
return {
id,
file_name: fileName,
project_type: projectType,
has_update: false,
update_version_id: null,
external,
project: {
id: project?.id ?? fallbackProjectId,
slug: project?.slug ?? fallbackProjectId,
title: project?.title ?? fallbackTitle,
icon_url: project?.icon_url ?? undefined,
},
...(version
? {
version: {
id: version.id,
file_name: fileName,
version_number: version.version_number ?? undefined,
date_published: version.date_published ?? undefined,
},
}
: {}),
}
}
function externalFileContentItem(
file: SharedInstanceInstallPreview['externalFiles'][number],
): ContentItem {
return contentItem(
`external:${file.fileType}:${file.fileName}`,
file.fileName,
null,
null,
true,
file.fileName,
file.fileName,
file.fileType,
)
}
function unique<T>(values: T[]) {
return Array.from(new Set(values))
}
@@ -0,0 +1,43 @@
<template>
<SharedInstanceInviteLinkModal ref="inviteLinkModal" :process-invite="installFromInviteId" />
<ModrinthAccountRequiredModal ref="accountRequiredModal" :request-auth="requestAuth" />
<SharedInstanceInstallModal ref="installModal" />
</template>
<script setup lang="ts">
import { injectAuth } from '@modrinth/ui'
import { nextTick, ref } from 'vue'
import ModrinthAccountRequiredModal from '@/components/ui/modal/ModrinthAccountRequiredModal.vue'
import SharedInstanceInstallModal from '@/components/ui/shared-instances/shared-instance-install-modal/index.vue'
import type { ModrinthAuthFlow } from '@/helpers/mr_auth'
import SharedInstanceInviteLinkModal from './shared-instance-invite-link-modal.vue'
import type { SharedInstanceInviteHandler } from './shared-instance-invite-types'
import { useSharedInstanceInviteHandler } from './use-shared-instance-invite-handler'
const auth = injectAuth()
const inviteLinkModal = ref<InstanceType<typeof SharedInstanceInviteLinkModal>>()
const installModal = ref<InstanceType<typeof SharedInstanceInstallModal>>()
const accountRequiredModal = ref<InstanceType<typeof ModrinthAccountRequiredModal>>()
const { handleNotification, installFromInviteId } = useSharedInstanceInviteHandler(
installModal,
accountRequiredModal,
)
async function requestAuth(flow: ModrinthAuthFlow) {
await auth.requestSignIn('', flow)
await nextTick()
return !!auth.session_token.value
}
function showManualInviteLinkModal(event?: MouseEvent) {
inviteLinkModal.value?.show(event)
}
defineExpose<SharedInstanceInviteHandler>({
handleNotification,
installFromInviteId,
showManualInviteLinkModal,
})
</script>
@@ -0,0 +1,62 @@
<template>
<NewModal ref="modal" header="Install from invite link" max-width="32rem">
<div class="flex flex-col gap-2">
<span class="font-semibold text-contrast">Invite link</span>
<StyledInput
v-model="inviteLink"
placeholder="https://modrinth.com/share/..."
:disabled="processing"
@keydown.enter="process"
/>
<span v-if="inviteLink && !inviteId" class="text-sm text-red">
Enter a valid Modrinth shared-instance invite link.
</span>
</div>
<template #actions>
<div class="flex justify-end gap-2">
<ButtonStyled>
<button :disabled="processing" @click="modal?.hide()"><XIcon /> Cancel</button>
</ButtonStyled>
<ButtonStyled color="brand">
<button :disabled="!inviteId || processing" @click="process">
<LinkIcon />
Process
</button>
</ButtonStyled>
</div>
</template>
</NewModal>
</template>
<script setup lang="ts">
import { LinkIcon, XIcon } from '@modrinth/assets'
import { ButtonStyled, NewModal, StyledInput } from '@modrinth/ui'
import { computed, ref } from 'vue'
import { parseSharedInstanceInviteLink } from './shared-instance-invite-parser'
const props = defineProps<{ processInvite: (inviteId: string) => Promise<void> }>()
const modal = ref<InstanceType<typeof NewModal>>()
const inviteLink = ref('')
const processing = ref(false)
const inviteId = computed(() => parseSharedInstanceInviteLink(inviteLink.value))
function show(event?: MouseEvent) {
inviteLink.value = ''
modal.value?.show(event)
}
async function process() {
if (!inviteId.value) return
processing.value = true
modal.value?.hide()
try {
await props.processInvite(inviteId.value)
} finally {
processing.value = false
}
}
defineExpose({ show })
</script>
@@ -0,0 +1,42 @@
import type {
AppNotification,
SharedInstanceInvite,
SharedInstanceInviteNotificationBody,
} from './shared-instance-invite-types'
function optionalString(value: unknown) {
return typeof value === 'string' ? value : null
}
export function parseSharedInstanceInviteNotification(
notification: AppNotification,
): SharedInstanceInvite | null {
if (notification.body?.type !== 'shared_instance_invite') return null
const body = notification.body as SharedInstanceInviteNotificationBody
const sharedInstanceId = optionalString(body.shared_instance_id)
const sharedInstanceName = optionalString(body.shared_instance_name)
if (!sharedInstanceId || !sharedInstanceName) return null
return {
sharedInstanceId,
sharedInstanceName,
invitedById: optionalString(body.invited_by),
invitedByUsername: optionalString(body.invited_by_username),
invitedByAvatarUrl: optionalString(body.invited_by_avatar_url),
instanceIconUrl: optionalString(body.instance_icon_url),
}
}
export function parseSharedInstanceInviteLink(value: string) {
const trimmedValue = value.trim()
if (!trimmedValue) return null
try {
const url = new URL(trimmedValue)
const match = /^\/share\/([^/]+)\/?$/.exec(url.pathname)
return match ? decodeURIComponent(match[1]) : null
} catch {
return null
}
}
@@ -0,0 +1,30 @@
export type SharedInstanceInviteNotificationBody = {
type: 'shared_instance_invite'
shared_instance_id?: unknown
shared_instance_name?: unknown
invited_by?: unknown
invited_by_username?: unknown
invited_by_avatar_url?: unknown
instance_icon_url?: unknown
}
export type AppNotification = {
id: string | number
read?: boolean
body?: { type?: unknown } & Record<string, unknown>
}
export type SharedInstanceInvite = {
sharedInstanceId: string
sharedInstanceName: string
invitedById: string | null
invitedByUsername: string | null
invitedByAvatarUrl: string | null
instanceIconUrl: string | null
}
export type SharedInstanceInviteHandler = {
handleNotification(notification: AppNotification): Promise<boolean>
installFromInviteId(inviteId: string): Promise<void>
showManualInviteLinkModal(event?: MouseEvent): void
}
@@ -0,0 +1,160 @@
import {
injectAuth,
injectModrinthClient,
injectNotificationManager,
injectPopupNotificationManager,
} from '@modrinth/ui'
import { useQueryClient } from '@tanstack/vue-query'
import { openUrl } from '@tauri-apps/plugin-opener'
import { type Ref, watch } from 'vue'
import { config } from '@/config'
import { get_user } from '@/helpers/cache'
import { toError } from '@/helpers/errors'
import {
install_accept_shared_instance_invite,
install_get_shared_instance_preview,
install_shared_instance,
} from '@/helpers/install'
import { parseSharedInstanceInviteNotification } from './shared-instance-invite-parser'
import type { AppNotification, SharedInstanceInvite } from './shared-instance-invite-types'
type InstallModal = {
show(
preview: Awaited<ReturnType<typeof install_get_shared_instance_preview>>,
install: () => Promise<void>,
): void
}
type AccountRequiredModal = {
show(event?: MouseEvent): Promise<boolean>
}
export function useSharedInstanceInviteHandler(
installModal: Ref<InstallModal | undefined>,
accountRequiredModal: Ref<AccountRequiredModal | undefined>,
) {
const auth = injectAuth()
const client = injectModrinthClient()
const { handleError } = injectNotificationManager()
const { addPopupNotification } = injectPopupNotificationManager()
const queryClient = useQueryClient()
const displayedNotifications = new Set<string | number>()
async function markNotificationRead(notification: AppNotification) {
await client.labrinth.notifications_v2.markAsRead(String(notification.id))
}
async function resolveInvite(invite: SharedInstanceInvite) {
const invitedBy =
!invite.invitedByUsername && invite.invitedById
? await get_user(invite.invitedById, 'bypass').catch(() => null)
: null
return {
...invite,
invitedByUsername: invite.invitedByUsername ?? invitedBy?.username ?? null,
invitedByAvatarUrl: invite.invitedByAvatarUrl ?? invitedBy?.avatar_url ?? null,
}
}
function showInstall(
preview: Awaited<ReturnType<typeof install_get_shared_instance_preview>>,
install: () => Promise<void>,
) {
if (!installModal.value) throw new Error('Shared instance install modal is not available.')
installModal.value.show(preview, install)
}
async function acceptNotification(notification: AppNotification, invite: SharedInstanceInvite) {
try {
const preview = await install_get_shared_instance_preview(
invite.sharedInstanceId,
invite.sharedInstanceName,
)
if (invite.instanceIconUrl) preview.iconUrl = invite.instanceIconUrl
showInstall(preview, async () => {
await install_shared_instance(
invite.sharedInstanceId,
invite.sharedInstanceName,
invite.invitedById,
null,
null,
invite.instanceIconUrl,
)
await markNotificationRead(notification)
await queryClient.invalidateQueries({ queryKey: ['instances'] })
})
} catch (error) {
handleError(toError(error))
}
}
async function handleNotification(notification: AppNotification) {
const parsedInvite = parseSharedInstanceInviteNotification(notification)
if (!parsedInvite) return false
if (displayedNotifications.has(notification.id)) return true
displayedNotifications.add(notification.id)
const invite = await resolveInvite(parsedInvite)
addPopupNotification({
title: invite.sharedInstanceName,
autoCloseMs: null,
toast: {
type: 'instance-invite',
actorName: invite.invitedByUsername,
actorAvatarUrl: invite.invitedByAvatarUrl ?? undefined,
entityName: invite.sharedInstanceName,
entityIconUrl: invite.instanceIconUrl ?? undefined,
onAccept: () => acceptNotification(notification, invite),
onDecline: () =>
markNotificationRead(notification).catch((error) => handleError(toError(error))),
onOpenActor: () => {
if (invite.invitedByUsername) {
openUrl(`${config.siteUrl}/user/${encodeURIComponent(invite.invitedByUsername)}`)
}
},
},
})
return true
}
async function requireAccount() {
if (!auth.isReady?.value) {
await new Promise<void>((resolve) => {
const stop = watch(auth.isReady!, (ready) => {
if (ready) {
stop()
resolve()
}
})
})
}
if (auth.session_token.value) return true
return (await accountRequiredModal.value?.show()) ?? false
}
async function installFromInviteId(inviteId: string) {
try {
if (!(await requireAccount())) return
const invite = await install_accept_shared_instance_invite(inviteId)
showInstall(invite.preview, async () => {
await install_shared_instance(
invite.sharedInstanceId,
invite.preview.name,
invite.managerId,
invite.serverManagerName,
invite.serverManagerIconUrl,
invite.instanceIconUrl,
)
await queryClient.invalidateQueries({ queryKey: ['instances'] })
})
} catch (error) {
handleError(toError(error))
}
}
return { handleNotification, installFromInviteId }
}
@@ -1,4 +1,4 @@
import { UpdatedIcon } from '@modrinth/assets'
import { CheckIcon, CopyIcon, UpdatedIcon } from '@modrinth/assets'
import {
defineMessages,
type PopupNotificationButton,
@@ -15,9 +15,11 @@ import {
install_job_dismiss,
install_job_list,
install_job_retry,
install_job_support_details,
installJobInstanceId,
type InstallJobSnapshot,
type InstallJobStatus,
type InstallPhaseId,
type InstallProgress,
} from '@/helpers/install'
import { get_many as getInstances } from '@/helpers/instance'
@@ -31,6 +33,14 @@ const messages = defineMessages({
id: 'app.action-bar.install.retry',
defaultMessage: 'Retry',
},
copyDetails: {
id: 'app.action-bar.install.copy-details',
defaultMessage: 'Copy details',
},
copied: {
id: 'app.action-bar.install.copied-details',
defaultMessage: 'Copied',
},
dismiss: {
id: 'app.action-bar.install.dismiss',
defaultMessage: 'Dismiss',
@@ -39,32 +49,20 @@ const messages = defineMessages({
id: 'app.action-bar.install.open-instance',
defaultMessage: 'Open instance',
},
installFailed: {
id: 'app.action-bar.install.failed',
defaultMessage: 'Install failed',
},
installFailedAppClosed: {
id: 'app.action-bar.install.failed-app-closed',
defaultMessage: 'Installation failed due to app closing.',
},
installFailedNetwork: {
id: 'app.action-bar.install.failed-network',
defaultMessage: 'Installation failed due to a network error.',
},
installFailedUnknown: {
id: 'app.action-bar.install.failed-unknown',
defaultMessage: 'Installation failed due to an unknown error.',
},
unknownInstance: {
id: 'app.action-bar.install.unknown-instance',
defaultMessage: 'Unknown instance',
},
updatingSharedContent: {
id: 'app.action-bar.install.updating-shared-content',
defaultMessage: 'Updating shared content',
},
})
const phaseMessages = defineMessages({
preparing_instance: {
id: 'app.install.phase.preparing_instance',
defaultMessage: 'Preparing instance',
defaultMessage: 'Queued to install',
},
resolving_pack: {
id: 'app.install.phase.resolving_pack',
@@ -139,7 +137,97 @@ const javaStepMessages = defineMessages({
},
})
const failureSummaryMessages = defineMessages({
canceled: {
id: 'app.action-bar.install.summary.canceled',
defaultMessage: 'Canceled',
},
appClosed: {
id: 'app.action-bar.install.summary.app-closing',
defaultMessage: 'Canceled due to app closing',
},
downloadFailed: {
id: 'app.action-bar.install.summary.download-failed',
defaultMessage: "Download couldn't finish",
},
modrinthUnreachable: {
id: 'app.action-bar.install.summary.modrinth-unreachable',
defaultMessage: "Couldn't reach Modrinth",
},
packDownloadFailed: {
id: 'app.action-bar.install.summary.pack-download-failed',
defaultMessage: "Couldn't download pack",
},
badModpackFile: {
id: 'app.action-bar.install.summary.bad-modpack-file',
defaultMessage: "Couldn't read modpack",
},
invalidModpack: {
id: 'app.action-bar.install.summary.invalid-modpack',
defaultMessage: 'Modpack data invalid',
},
contentDownloadFailed: {
id: 'app.action-bar.install.summary.content-download-failed',
defaultMessage: "Couldn't download files",
},
corruptDownload: {
id: 'app.action-bar.install.summary.corrupt-download',
defaultMessage: 'Downloaded file is corrupt',
},
invalidModpackFiles: {
id: 'app.action-bar.install.summary.invalid-modpack-files',
defaultMessage: 'Modpack files have invalid metadata',
},
noWritePermission: {
id: 'app.action-bar.install.summary.no-write-permission',
defaultMessage: 'No permission to write',
},
couldNotSaveFiles: {
id: 'app.action-bar.install.summary.could-not-save-files',
defaultMessage: "Couldn't save files",
},
invalidFilePath: {
id: 'app.action-bar.install.summary.invalid-file-path',
defaultMessage: 'File path is invalid',
},
instanceNotFound: {
id: 'app.action-bar.install.summary.instance-not-found',
defaultMessage: "Instance couldn't be found",
},
cleanupIncomplete: {
id: 'app.action-bar.install.summary.cleanup-incomplete',
defaultMessage: "Cleanup didn't finish",
},
javaSetupFailed: {
id: 'app.action-bar.install.summary.java-setup-failed',
defaultMessage: "Java setup couldn't finish",
},
minecraftSetupFailed: {
id: 'app.action-bar.install.summary.minecraft-setup-failed',
defaultMessage: 'Minecraft setup failed',
},
loaderSetupFailed: {
id: 'app.action-bar.install.summary.loader-setup-failed',
defaultMessage: 'Loader setup failed',
},
localDataError: {
id: 'app.action-bar.install.summary.local-data-error',
defaultMessage: "Couldn't update local data",
},
unexpectedError: {
id: 'app.action-bar.install.summary.unexpected-error',
defaultMessage: 'Something went wrong',
},
})
const visibleJobStatuses = new Set<InstallJobStatus>(['queued', 'running', 'failed', 'interrupted'])
const copyDetailsStallMs = 30_000
interface ProgressSnapshot {
signature: string
changedAt: number
timeout: number | null
}
function getDisplayIconUrl(icon: string | null | undefined): string | null {
if (!icon) return null
@@ -156,10 +244,13 @@ export async function useInstallJobNotifications(opts: {
const jobs = ref<InstallJobSnapshot[]>([])
const iconUrls = ref<Record<string, string | null>>({})
const instanceNames = ref<Record<string, string>>({})
const copiedJobIds = ref<Set<string>>(new Set())
const jobOrder = new Map<string, number>()
let refreshRequest = 0
let metadataRequest = 0
let nextJobOrder = 0
const copiedResetTimeouts = new Map<string, number>()
const progressSnapshots = new Map<string, ProgressSnapshot>()
function getTitle(job: InstallJobSnapshot): string {
if (job.display?.title) return job.display.title
@@ -174,22 +265,117 @@ export async function useInstallJobNotifications(opts: {
function getText(job: InstallJobSnapshot): string {
if (job.status === 'failed' || job.status === 'interrupted') {
if (job.error?.code === 'interrupted') {
return formatMessage(messages.installFailedAppClosed)
}
if (job.error?.code === 'network_error') {
return formatMessage(messages.installFailedNetwork)
}
return formatMessage(messages.installFailedUnknown)
return getFailureSummary(job)
}
if (job.phase === 'preparing_java' && job.details.type === 'java') {
return formatMessage(javaStepMessages[job.details.step], {
version: job.details.major_version,
})
}
if (job.kind === 'update_shared_instance' && job.phase === 'downloading_content') {
return formatMessage(messages.updatingSharedContent)
}
return formatMessage(phaseMessages[job.phase])
}
function getFailureSummary(job: InstallJobSnapshot): string {
const code = job.error?.code
const phase = job.error?.phase ?? job.phase
if (code === 'app_closed' || (job.status === 'interrupted' && code === 'interrupted')) {
return formatMessage(failureSummaryMessages.appClosed)
}
if (code === 'canceled') {
return formatMessage(failureSummaryMessages.canceled)
}
if (job.rollback_error || code === 'rollback_error') {
return formatMessage(failureSummaryMessages.cleanupIncomplete)
}
if (hasPermissionError(job)) {
return formatMessage(failureSummaryMessages.noWritePermission)
}
switch (code) {
case 'network_error':
return formatMessage(
phase === 'downloading_pack_file'
? failureSummaryMessages.packDownloadFailed
: failureSummaryMessages.downloadFailed,
)
case 'api_error':
return formatMessage(failureSummaryMessages.modrinthUnreachable)
case 'pack_error':
return formatMessage(
phase === 'downloading_pack_file'
? failureSummaryMessages.packDownloadFailed
: failureSummaryMessages.invalidModpack,
)
case 'archive_error':
return formatMessage(failureSummaryMessages.badModpackFile)
case 'parse_error':
return formatMessage(failureSummaryMessages.invalidModpack)
case 'content_error':
return formatMessage(failureSummaryMessages.invalidModpackFiles)
case 'hash_error':
return formatMessage(failureSummaryMessages.corruptDownload)
case 'filesystem_error':
return formatMessage(failureSummaryMessages.couldNotSaveFiles)
case 'path_error':
return formatMessage(failureSummaryMessages.invalidFilePath)
case 'instance_error':
return formatMessage(failureSummaryMessages.instanceNotFound)
case 'java_error':
return formatMessage(failureSummaryMessages.javaSetupFailed)
case 'loader_error':
case 'processor_error':
return formatMessage(failureSummaryMessages.loaderSetupFailed)
case 'database_error':
return formatMessage(failureSummaryMessages.localDataError)
case 'launcher_error':
case 'metadata_error':
return getFailureSummaryForPhase(phase)
default:
return getFailureSummaryForPhase(phase)
}
}
function getFailureSummaryForPhase(phase: InstallPhaseId): string {
switch (phase) {
case 'downloading_pack_file':
return formatMessage(failureSummaryMessages.packDownloadFailed)
case 'resolving_pack':
case 'reading_pack_manifest':
return formatMessage(failureSummaryMessages.invalidModpack)
case 'downloading_content':
return formatMessage(failureSummaryMessages.contentDownloadFailed)
case 'extracting_overrides':
return formatMessage(failureSummaryMessages.couldNotSaveFiles)
case 'resolving_minecraft':
case 'downloading_minecraft':
return formatMessage(failureSummaryMessages.minecraftSetupFailed)
case 'resolving_loader':
case 'running_loader_processors':
return formatMessage(failureSummaryMessages.loaderSetupFailed)
case 'preparing_java':
return formatMessage(failureSummaryMessages.javaSetupFailed)
case 'preparing_instance':
return formatMessage(failureSummaryMessages.instanceNotFound)
case 'rolling_back':
return formatMessage(failureSummaryMessages.cleanupIncomplete)
default:
return formatMessage(failureSummaryMessages.unexpectedError)
}
}
function hasPermissionError(job: InstallJobSnapshot): boolean {
const message = job.error?.message.toLowerCase() ?? ''
return (
message.includes('permission denied') ||
message.includes('access is denied') ||
message.includes('operation not permitted')
)
}
function getProgressType(job: InstallJobSnapshot): PopupNotificationProgressType | undefined {
if (!getEffectiveProgress(job)) return undefined
if (
@@ -235,11 +421,165 @@ export async function useInstallJobNotifications(opts: {
return job.status === 'failed' || job.status === 'interrupted'
}
function getTerminalButtons(job: InstallJobSnapshot): PopupNotificationButton[] | undefined {
if (!isTerminalJob(job)) return undefined
function canShowStalledProgressDetails(job: InstallJobSnapshot): boolean {
return (
job.status === 'running' &&
job.phase !== 'preparing_instance' &&
job.phase !== 'finalizing' &&
job.phase !== 'rolling_back'
)
}
function getJobSortRank(job: InstallJobSnapshot): number {
if (isTerminalJob(job)) return 0
if (job.status === 'queued' || job.phase === 'preparing_instance') return 2
return 1
}
function progressSignature(job: InstallJobSnapshot): string {
const progress = job.progress
const secondary = progress?.secondary
return [
{
job.status,
job.phase,
JSON.stringify(job.details),
progress?.current ?? '',
progress?.total ?? '',
secondary?.current ?? '',
secondary?.total ?? '',
].join(':')
}
function clearCopied(jobId: string) {
if (!copiedJobIds.value.has(jobId)) {
return
}
const timeout = copiedResetTimeouts.get(jobId)
if (timeout != null) {
window.clearTimeout(timeout)
copiedResetTimeouts.delete(jobId)
}
const nextCopiedJobIds = new Set(copiedJobIds.value)
nextCopiedJobIds.delete(jobId)
copiedJobIds.value = nextCopiedJobIds
}
function clearProgressSnapshot(jobId: string) {
const snapshot = progressSnapshots.get(jobId)
if (snapshot?.timeout != null) {
window.clearTimeout(snapshot.timeout)
}
progressSnapshots.delete(jobId)
}
function scheduleStaleProgressRefresh(jobId: string) {
const snapshot = progressSnapshots.get(jobId)
if (!snapshot) {
return
}
snapshot.timeout = window.setTimeout(() => {
const snapshot = progressSnapshots.get(jobId)
if (!snapshot) {
return
}
snapshot.timeout = null
opts.onChange()
}, copyDetailsStallMs)
}
function syncProgressSnapshots(nextJobs: InstallJobSnapshot[]) {
const trackedJobIds = new Set<string>()
const now = Date.now()
for (const job of nextJobs) {
if (!canShowStalledProgressDetails(job)) {
continue
}
trackedJobIds.add(job.job_id)
const signature = progressSignature(job)
const snapshot = progressSnapshots.get(job.job_id)
if (snapshot?.signature === signature) {
continue
}
clearProgressSnapshot(job.job_id)
clearCopied(job.job_id)
progressSnapshots.set(job.job_id, {
signature,
changedAt: now,
timeout: null,
})
scheduleStaleProgressRefresh(job.job_id)
}
for (const jobId of progressSnapshots.keys()) {
if (!trackedJobIds.has(jobId)) {
clearProgressSnapshot(jobId)
}
}
}
function hasStalledProgress(job: InstallJobSnapshot): boolean {
const snapshot = progressSnapshots.get(job.job_id)
return !!snapshot && Date.now() - snapshot.changedAt >= copyDetailsStallMs
}
function shouldShowCopyDetails(job: InstallJobSnapshot): boolean {
return isTerminalJob(job) || (canShowStalledProgressDetails(job) && hasStalledProgress(job))
}
function isCopied(job: InstallJobSnapshot): boolean {
return copiedJobIds.value.has(job.job_id)
}
function setCopied(job: InstallJobSnapshot) {
copiedJobIds.value = new Set([...copiedJobIds.value, job.job_id])
const existingTimeout = copiedResetTimeouts.get(job.job_id)
if (existingTimeout != null) {
window.clearTimeout(existingTimeout)
}
copiedResetTimeouts.set(
job.job_id,
window.setTimeout(() => {
copiedResetTimeouts.delete(job.job_id)
if (!copiedJobIds.value.has(job.job_id)) {
return
}
const nextCopiedJobIds = new Set(copiedJobIds.value)
nextCopiedJobIds.delete(job.job_id)
copiedJobIds.value = nextCopiedJobIds
opts.onChange()
}, 1_000),
)
opts.onChange()
}
async function copyJobDetails(job: InstallJobSnapshot) {
const details = await install_job_support_details(job.job_id).catch((error) => {
opts.handleError(error)
return null
})
if (!details) {
return
}
try {
await navigator.clipboard.writeText(details)
setCopied(job)
} catch (error) {
opts.handleError(error)
}
}
function getButtons(job: InstallJobSnapshot): PopupNotificationButton[] {
const buttons: PopupNotificationButton[] = []
if (isTerminalJob(job)) {
buttons.push({
label: formatMessage(messages.retry),
icon: UpdatedIcon,
color: 'brand',
@@ -248,8 +588,23 @@ export async function useInstallJobNotifications(opts: {
await install_job_retry(job.job_id).catch(opts.handleError)
await refresh()
},
},
]
})
}
if (shouldShowCopyDetails(job)) {
const copied = isCopied(job)
buttons.push({
label: formatMessage(copied ? messages.copied : messages.copyDetails),
icon: copied ? CheckIcon : CopyIcon,
color: 'standard',
keepOpen: true,
action: async () => {
await copyJobDetails(job)
},
})
}
return buttons
}
function setJobs(nextJobs: InstallJobSnapshot[]) {
@@ -259,13 +614,15 @@ export async function useInstallJobNotifications(opts: {
}
}
jobs.value = nextJobs
.filter((job) => visibleJobStatuses.has(job.status))
.sort(
(a, b) =>
a.created.localeCompare(b.created) ||
(jobOrder.get(a.job_id) ?? 0) - (jobOrder.get(b.job_id) ?? 0),
)
const visibleJobs = nextJobs.filter((job) => visibleJobStatuses.has(job.status))
syncProgressSnapshots(visibleJobs)
jobs.value = visibleJobs.sort(
(a, b) =>
getJobSortRank(a) - getJobSortRank(b) ||
a.created.localeCompare(b.created) ||
(jobOrder.get(a.job_id) ?? 0) - (jobOrder.get(b.job_id) ?? 0),
)
}
const progressItems = computed<PopupNotificationProgressItem[]>(() =>
@@ -284,7 +641,7 @@ export async function useInstallJobNotifications(opts: {
progressType: isTerminalJob(job) ? undefined : getProgressType(job),
progressCurrent: isTerminalJob(job) ? undefined : progress?.current,
progressTotal: isTerminalJob(job) ? undefined : progress?.total,
buttons: getTerminalButtons(job),
buttons: getButtons(job),
onDismiss: isTerminalJob(job)
? async () => {
await install_job_dismiss(job.job_id).catch(opts.handleError)
@@ -382,6 +739,14 @@ export async function useInstallJobNotifications(opts: {
progressItems,
buttons,
refresh,
dispose: () => unlisten(),
dispose: () => {
for (const timeout of copiedResetTimeouts.values()) {
window.clearTimeout(timeout)
}
for (const jobId of progressSnapshots.keys()) {
clearProgressSnapshot(jobId)
}
unlisten()
},
}
}
@@ -8,13 +8,18 @@ import type { ComputedRef, Ref } from 'vue'
import { onUnmounted, ref, shallowRef } from 'vue'
import type { Router } from 'vue-router'
import {
fetchCachedServerStatus,
getFreshCachedServerStatus,
} from '@/composables/instances/use-server-status-query'
import { process_listener } from '@/helpers/events'
import { kill, list as listInstances } from '@/helpers/instance'
import { get_by_instance_id } from '@/helpers/process'
import type { GameInstance } from '@/helpers/types'
import { add_server_to_instance, getServerAddress, getServerLatency } from '@/helpers/worlds'
import { add_server_to_instance, getServerAddress } from '@/helpers/worlds'
interface BrowseServerInstance {
id: string
name: string
path: string
}
@@ -68,12 +73,10 @@ export function useAppServerBrowse(options: UseAppServerBrowseOptions) {
const queryClient = useQueryClient()
const debugLog = useDebugLogger('BrowseServer')
const serverPings = shallowRef<Record<string, number | undefined>>({})
const serverPingCache = new Map<string, number | undefined>()
const pendingServerPings = new Map<string, Promise<number | undefined>>()
const runningServerProjects = ref<Record<string, string>>({})
const lastServerHits = shallowRef<Labrinth.Search.v3.ResultSearchProject[]>([])
const contextMenuRef = ref<ContextMenuHandle | null>(null)
let serverPingCacheActive = true
let serverPingsActive = true
let unlistenProcesses: (() => void) | null = null
async function checkServerRunningStates(hits: Labrinth.Search.v3.ResultSearchProject[]) {
@@ -146,37 +149,26 @@ export function useAppServerBrowse(options: UseAppServerBrowseOptions) {
})
const nextPings = { ...serverPings.value }
for (const { hit, address } of pingsToFetch) {
if (serverPingCache.has(address)) {
nextPings[hit.project_id] = serverPingCache.get(address)
const cachedStatus = getFreshCachedServerStatus(queryClient, address)
if (cachedStatus) {
nextPings[hit.project_id] = cachedStatus.ping
}
}
serverPings.value = nextPings
await Promise.all(
pingsToFetch.map(async ({ hit, address }) => {
if (serverPingCache.has(address)) return
if (getFreshCachedServerStatus(queryClient, address)) return
let pending = pendingServerPings.get(address)
if (!pending) {
pending = getServerLatency(address)
.then((latency) => {
if (serverPingCacheActive) serverPingCache.set(address, latency)
return latency
})
.catch((error) => {
console.error(`Failed to ping server ${address}:`, error)
if (serverPingCacheActive) serverPingCache.set(address, undefined)
return undefined
})
.finally(() => {
pendingServerPings.delete(address)
})
pendingServerPings.set(address, pending)
try {
const status = await fetchCachedServerStatus(queryClient, address)
if (!serverPingsActive) return
serverPings.value = { ...serverPings.value, [hit.project_id]: status.ping }
} catch (error) {
console.error(`Failed to ping server ${address}:`, error)
if (!serverPingsActive) return
serverPings.value = { ...serverPings.value, [hit.project_id]: undefined }
}
const latency = await pending
if (!serverPingCacheActive) return
serverPings.value = { ...serverPings.value, [hit.project_id]: latency }
}),
)
}
@@ -308,10 +300,8 @@ export function useAppServerBrowse(options: UseAppServerBrowseOptions) {
.catch(options.handleError)
onUnmounted(() => {
serverPingCacheActive = false
serverPingsActive = false
unlistenProcesses?.()
serverPingCache.clear()
pendingServerPings.clear()
})
return {
@@ -0,0 +1,119 @@
import { type ContentActionWarning, type ContentItem, defineMessages, useVIntl } from '@modrinth/ui'
import { computed, type Ref } from 'vue'
import type { GameInstance } from '@/helpers/types'
const managedSourceKinds = new Set(['shared_instance', 'modrinth_modpack', 'imported_modpack'])
export function useManagedContentPolicy(instance: Ref<GameInstance>) {
const { formatMessage } = useVIntl()
const isManagedModpack = computed(() => instance.value.shared_instance?.role === 'member')
const canUnpublish = computed(() => instance.value.shared_instance?.role === 'owner')
const canUnlink = computed(() => instance.value.shared_instance?.role === 'member')
const managedModpackWarning = computed(() => ({
admonitionHeader: formatMessage(messages.warningHeader),
changeVersionBody: formatMessage(messages.changeVersionBody),
unlinkBody: formatMessage(messages.unlinkBody),
}))
function isManagedContent(item: ContentItem) {
return isManagedModpack.value && !!item.source_kind && managedSourceKinds.has(item.source_kind)
}
function canMutateContent(item: ContentItem) {
return !isManagedContent(item)
}
function canUpdateContent(item: ContentItem) {
return (
canMutateContent(item) && !!item.file_path && !!item.has_update && !!item.update_version_id
)
}
function deleteWarning(items: ContentItem[]): ContentActionWarning | null {
if (!items.some(isManagedContent)) return null
return {
admonitionHeader: formatMessage(messages.warningHeader),
admonitionBody: formatMessage(
items.length === 1 ? messages.deleteSingleBody : messages.deleteBulkBody,
),
actionLabel: formatMessage(
items.length === 1 ? messages.deleteButton : messages.deleteManyButton,
{ count: items.length },
),
}
}
function disableWarning(items: ContentItem[]): ContentActionWarning | null {
if (!items.some(isManagedContent)) return null
return {
admonitionHeader: formatMessage(messages.warningHeader),
admonitionBody: formatMessage(
items.length === 1 ? messages.disableSingleBody : messages.disableBulkBody,
),
actionLabel: formatMessage(
items.length === 1 ? messages.disableButton : messages.disableManyButton,
{ count: items.length },
),
}
}
return {
isManagedModpack,
canUnpublish,
canUnlink,
managedModpackWarning,
isManagedContent,
canMutateContent,
canUpdateContent,
deleteWarning,
disableWarning,
}
}
const messages = defineMessages({
warningHeader: {
id: 'content.shared-instance.warning-header',
defaultMessage: 'This is part of the shared instance',
},
changeVersionBody: {
id: 'content.shared-instance.change-version-body',
defaultMessage:
'Changing the version only changes your local copy. Future shared instance updates may restore or change it again.',
},
unlinkBody: {
id: 'content.shared-instance.unlink-body',
defaultMessage:
'Unlinking only changes your local copy. Future shared instance updates may restore or change it again.',
},
deleteSingleBody: {
id: 'content.shared-instance.delete-single-body',
defaultMessage:
'Deleting it only changes your local copy. Future shared instance updates may restore or change it again.',
},
deleteBulkBody: {
id: 'content.shared-instance.delete-bulk-body',
defaultMessage:
'Some selected projects are part of the shared instance. Deleting them only changes your local copy, and future shared instance updates may restore or change them again.',
},
deleteButton: { id: 'content.shared-instance.delete-button', defaultMessage: 'Delete anyway' },
deleteManyButton: {
id: 'content.shared-instance.delete-many-button',
defaultMessage: 'Delete {count, number} projects anyway',
},
disableSingleBody: {
id: 'content.shared-instance.disable-single-body',
defaultMessage:
'Disabling it only changes your local copy. Future shared instance updates may re-enable, restore, or change it again.',
},
disableBulkBody: {
id: 'content.shared-instance.disable-bulk-body',
defaultMessage:
'Some selected projects are part of the shared instance. Disabling them only changes your local copy, and future shared instance updates may re-enable, restore, or change them again.',
},
disableButton: { id: 'content.shared-instance.disable-button', defaultMessage: 'Disable anyway' },
disableManyButton: {
id: 'content.shared-instance.disable-many-button',
defaultMessage: 'Disable {count, number} projects anyway',
},
})
@@ -0,0 +1,50 @@
import type { QueryClient } from '@tanstack/vue-query'
import {
get_server_status,
normalizeServerAddress,
type ProtocolVersion,
type ServerStatus,
} from '@/helpers/worlds'
export const SERVER_STATUS_CACHE_MS = 10 * 60 * 1000
function getProtocolVersionKey(protocolVersion: ProtocolVersion | null) {
if (!protocolVersion) return 'default'
return `${protocolVersion.version}:${protocolVersion.legacy ? 'legacy' : 'modern'}`
}
export function getServerStatusQueryKey(
address: string,
protocolVersion: ProtocolVersion | null = null,
) {
return [
'minecraft-server-status',
normalizeServerAddress(address) || address.trim().toLowerCase(),
getProtocolVersionKey(protocolVersion),
] as const
}
export function getFreshCachedServerStatus(
queryClient: QueryClient,
address: string,
protocolVersion: ProtocolVersion | null = null,
) {
const queryKey = getServerStatusQueryKey(address, protocolVersion)
const updatedAt = queryClient.getQueryState(queryKey)?.dataUpdatedAt ?? 0
if (!updatedAt || Date.now() - updatedAt >= SERVER_STATUS_CACHE_MS) return undefined
return queryClient.getQueryData<ServerStatus>(queryKey)
}
export async function fetchCachedServerStatus(
queryClient: QueryClient,
address: string,
protocolVersion: ProtocolVersion | null = null,
) {
return await queryClient.fetchQuery({
queryKey: getServerStatusQueryKey(address, protocolVersion),
queryFn: () => get_server_status(address, protocolVersion),
staleTime: SERVER_STATUS_CACHE_MS,
gcTime: SERVER_STATUS_CACHE_MS,
})
}
@@ -0,0 +1,148 @@
import { useMutation, useQuery, useQueryClient } from '@tanstack/vue-query'
import { computed, type MaybeRefOrGetter, onUnmounted, toValue } from 'vue'
import { toError } from '@/helpers/errors'
import { friend_listener } from '@/helpers/events.js'
import {
acceptCachedFriend,
add_friend,
createPendingFriend,
type FriendCacheUser,
friendsQueryKey,
type FriendWithUserData,
getFriendsWithUserData,
getFriendUserId,
matchesFriend,
remove_friend,
removeCachedFriend,
upsertCachedFriend,
} from '@/helpers/friends'
import type { ModrinthCredentials } from '@/helpers/mr_auth'
type FriendsMutationContext = {
queryKey: ReturnType<typeof friendsQueryKey>
previousFriends?: FriendWithUserData[]
}
type AddFriendMutationVariables = {
userId: string
user: FriendCacheUser
acceptExisting: boolean
}
type RemoveFriendMutationVariables = {
userId: string
user: FriendWithUserData
}
export function useFriends(options: {
currentUserId: MaybeRefOrGetter<string | null | undefined>
getCredentials: () => ModrinthCredentials | null | Promise<ModrinthCredentials | null>
enabled?: MaybeRefOrGetter<boolean>
onError?: (error: Error) => void
}) {
const queryClient = useQueryClient()
const queryKey = computed(() => friendsQueryKey(toValue(options.currentUserId)))
const query = useQuery({
queryKey,
queryFn: async () => getFriendsWithUserData(await options.getCredentials()),
enabled: () => !!toValue(options.currentUserId) && toValue(options.enabled ?? true),
staleTime: 30_000,
})
const friends = computed(() => query.data.value ?? [])
function restore(context?: FriendsMutationContext) {
if (!context) return
if (context.previousFriends === undefined) {
queryClient.removeQueries({ queryKey: context.queryKey, exact: true })
return
}
queryClient.setQueryData(context.queryKey, context.previousFriends)
}
const addMutation = useMutation({
mutationFn: ({ userId }: AddFriendMutationVariables) => add_friend(userId),
onMutate: async ({ user, acceptExisting }): Promise<FriendsMutationContext> => {
const activeQueryKey = queryKey.value
await queryClient.cancelQueries({ queryKey: activeQueryKey })
const previousFriends = queryClient.getQueryData<FriendWithUserData[]>(activeQueryKey)
const currentUserId = toValue(options.currentUserId)
queryClient.setQueryData<FriendWithUserData[]>(activeQueryKey, (cachedFriends = []) =>
acceptExisting
? acceptCachedFriend(cachedFriends, user.id, user.username, currentUserId)
: upsertCachedFriend(
cachedFriends,
createPendingFriend(user, currentUserId),
currentUserId,
),
)
return { queryKey: activeQueryKey, previousFriends }
},
onError: (error, _variables, context) => {
restore(context)
options.onError?.(toError(error))
},
onSettled: (_data, _error, _variables, context) => {
void queryClient.invalidateQueries({ queryKey: context?.queryKey ?? queryKey.value })
},
})
const removeMutation = useMutation({
mutationFn: ({ userId }: RemoveFriendMutationVariables) => remove_friend(userId),
onMutate: async ({ user, userId }): Promise<FriendsMutationContext> => {
const activeQueryKey = queryKey.value
await queryClient.cancelQueries({ queryKey: activeQueryKey })
const previousFriends = queryClient.getQueryData<FriendWithUserData[]>(activeQueryKey)
queryClient.setQueryData<FriendWithUserData[]>(activeQueryKey, (cachedFriends = []) =>
removeCachedFriend(cachedFriends, userId, user.username, toValue(options.currentUserId)),
)
return { queryKey: activeQueryKey, previousFriends }
},
onError: (error, _variables, context) => {
restore(context)
options.onError?.(toError(error))
},
onSettled: (_data, _error, _variables, context) => {
void queryClient.invalidateQueries({ queryKey: context?.queryKey ?? queryKey.value })
},
})
function requestFriend(user: FriendCacheUser, acceptExisting = false) {
addMutation.mutate({ userId: user.id, user, acceptExisting })
}
function acceptFriend(friend: FriendWithUserData) {
const userId = getFriendUserId(friend, toValue(options.currentUserId))
requestFriend({ id: userId, username: friend.username, avatarUrl: friend.avatar }, true)
}
function removeFriend(friend: FriendWithUserData) {
const userId = getFriendUserId(friend, toValue(options.currentUserId))
removeMutation.mutate({ userId, user: friend })
}
function findFriend(id: string, username: string) {
return friends.value.find((friend) =>
matchesFriend(friend, id, username, toValue(options.currentUserId)),
)
}
let unlisten: (() => void) | undefined
void friend_listener(() => {
void queryClient.invalidateQueries({ queryKey: queryKey.value })
}).then((listener) => {
unlisten = listener
})
onUnmounted(() => unlisten?.())
return {
query,
queryKey,
friends,
loading: computed(() => !!toValue(options.currentUserId) && query.isLoading.value),
requestFriend,
acceptFriend,
removeFriend,
findFriend,
}
}
@@ -0,0 +1,17 @@
import { useQuery } from '@tanstack/vue-query'
import { computed, type Ref } from 'vue'
import { get_user } from '@/helpers/cache.js'
export function useUserQuery(userId: Ref<string | null | undefined>) {
return useQuery({
queryKey: computed(() => ['user', userId.value]),
queryFn: async ({ queryKey }) => {
const id = queryKey[1]
if (typeof id !== 'string') return null
return await get_user(id, 'bypass').catch(() => null)
},
enabled: () => !!userId.value,
staleTime: 30_000,
})
}
+11
View File
@@ -0,0 +1,11 @@
export function toError(error: unknown) {
if (error instanceof Error) return error
if (typeof error === 'string') return new Error(error)
if (error && typeof error === 'object') {
const record = error as Record<string, unknown>
const message = record.message ?? record.error
if (typeof message === 'string') return new Error(message)
return new Error(JSON.stringify(error))
}
return new Error(String(error))
}
+107
View File
@@ -6,6 +6,8 @@ import dayjs from 'dayjs'
import { get_user_many } from '@/helpers/cache'
import type { ModrinthCredentials } from '@/helpers/mr_auth'
export const friendsQueryKey = (userId?: string | null) => ['friends', userId ?? null] as const
export type UserStatus = {
user_id: string
instance_name: string | null
@@ -46,6 +48,111 @@ export type FriendWithUserData = {
online: boolean
avatar: string
}
export type FriendCacheUser = {
id: string
username: string
avatarUrl?: string | null
}
export async function getFriendsWithUserData(
credentials: ModrinthCredentials | null,
): Promise<FriendWithUserData[]> {
if (!credentials) return []
const friendsList = await friends()
return await transformFriends(friendsList, credentials)
}
export function createPendingFriend(
user: FriendCacheUser,
currentUserId?: string | null,
): FriendWithUserData {
return {
id: user.id,
friend_id: currentUserId ?? null,
status: null,
last_updated: null,
created: dayjs(),
username: user.username,
accepted: false,
online: false,
avatar: user.avatarUrl ?? '',
}
}
export function getFriendUserId(
friend: Pick<FriendWithUserData, 'id' | 'friend_id'>,
currentUserId?: string | null,
) {
return friend.id === currentUserId && friend.friend_id ? friend.friend_id : friend.id
}
export function matchesFriend(
friend: FriendWithUserData,
id: string,
username: string,
currentUserId?: string | null,
) {
const friendId = getFriendUserId(friend, currentUserId)
return (
normalizeFriendKey(friendId) === normalizeFriendKey(id) ||
normalizeFriendKey(friend.username) === normalizeFriendKey(username)
)
}
export function upsertCachedFriend(
friends: FriendWithUserData[],
friend: FriendWithUserData,
currentUserId?: string | null,
) {
const existingFriend = friends.find((cachedFriend) =>
matchesFriend(cachedFriend, friend.id, friend.username, currentUserId),
)
if (!existingFriend) return [friend, ...friends]
return friends.map((cachedFriend) =>
matchesFriend(cachedFriend, friend.id, friend.username, currentUserId)
? {
...cachedFriend,
...friend,
id: cachedFriend.id,
friend_id: cachedFriend.friend_id,
}
: cachedFriend,
)
}
export function acceptCachedFriend(
friends: FriendWithUserData[],
id: string,
username: string,
currentUserId?: string | null,
) {
return friends.map((friend) =>
matchesFriend(friend, id, username, currentUserId)
? {
...friend,
accepted: true,
}
: friend,
)
}
export function removeCachedFriend(
friends: FriendWithUserData[],
id: string,
username: string,
currentUserId?: string | null,
) {
return friends.filter((friend) => !matchesFriend(friend, id, username, currentUserId))
}
export function normalizeFriendKey(value: string) {
return value.trim().toLowerCase()
}
export async function transformFriends(
friends: UserFriend[],
credentials: ModrinthCredentials | null,
+171 -2
View File
@@ -44,6 +44,84 @@ export interface InstallPostInstallEdit {
link?: InstanceLink | null
}
export interface SharedInstanceInstallPreview {
name: string
iconUrl?: string | null
gameVersion: string
loader: InstanceLoader
modCount: number
externalFileCount: number
modpackVersionId?: string | null
contentVersionIds: string[]
externalFiles: SharedInstanceExternalFilePreview[]
}
export interface SharedInstanceInviteInstallPreview {
sharedInstanceId: string
managerId?: string | null
serverManagerName?: string | null
serverManagerIconUrl?: string | null
instanceIconUrl?: string | null
preview: SharedInstanceInstallPreview
}
export interface SharedInstanceExternalFilePreview {
fileName: string
fileType: string
}
export interface SharedInstanceUpdatePreview {
sharedInstanceId: string
currentVersion?: number | null
latestVersion: number
updateAvailable: boolean
diffs: SharedInstanceUpdateDiff[]
}
export interface SharedInstanceUpdateDiff {
type:
| 'added'
| 'removed'
| 'updated'
| 'modpack_linked'
| 'modpack_updated'
| 'modpack_unlinked'
| 'game_version_updated'
| 'loader_updated'
projectId?: string | null
projectName?: string | null
fileName?: string | null
currentVersionName?: string | null
newVersionName?: string | null
disabled?: boolean
}
export const SHARED_INSTANCE_UNAVAILABLE_ERROR_CODE = 'shared_instance_unavailable'
export type SharedInstanceUnavailableReason = 'deleted' | 'access_revoked'
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null
}
export function isSharedInstanceUnavailableError(error: unknown) {
return getSharedInstanceUnavailableReason(error) !== null
}
export function getSharedInstanceUnavailableReason(
error: unknown,
): SharedInstanceUnavailableReason | null {
if (!isRecord(error) || error.code !== SHARED_INSTANCE_UNAVAILABLE_ERROR_CODE) return null
return error.reason === 'deleted' || error.reason === 'access_revoked' ? error.reason : null
}
export function getErrorMessage(error: unknown): string {
if (typeof error === 'string') return error
if (error instanceof Error) return error.message || 'Unknown error'
if (isRecord(error) && typeof error.message === 'string') return error.message
return 'Unknown error'
}
export type InstallJobStatus =
| 'queued'
| 'running'
@@ -85,12 +163,45 @@ export type InstallJavaStep =
| 'extracting'
| 'validating'
export interface InstallErrorView {
code: string
phase?: InstallPhaseId | null
message: string
reason?: SharedInstanceUnavailableReason | null
api?: {
error: string
status?: number | null
method?: string | null
url?: string | null
route?: string | null
} | null
context?: {
operation: string
source_path?: string | null
target_path?: string | null
file_path?: string | null
entry_path?: string | null
urls?: string[]
expected_hash?: string | null
expected_size?: number | null
project_id?: string | null
version_id?: string | null
minecraft_version?: string | null
loader?: string | null
java_version?: number | null
os?: string | null
arch?: string | null
} | null
}
export interface InstallJobSnapshot {
job_id: string
instance_id?: string | null
kind:
| 'create_instance'
| 'create_modpack_instance'
| 'create_shared_instance'
| 'update_shared_instance'
| 'import_instance'
| 'duplicate_instance'
| 'install_existing_instance'
@@ -114,7 +225,8 @@ export interface InstallJobSnapshot {
}
| { type: 'import'; launcher_type: string; instance_folder: string }
display?: { title: string; icon?: string | null } | null
error?: { code: string; message: string } | null
error?: InstallErrorView | null
rollback_error?: InstallErrorView | null
created: string
modified: string
finished?: string | null
@@ -140,6 +252,58 @@ export async function install_create_modpack_instance(
})
}
export async function install_get_shared_instance_preview(sharedInstanceId: string, name: string) {
return await invoke<SharedInstanceInstallPreview>(
'plugin:install|install_get_shared_instance_preview',
{
sharedInstanceId,
name,
},
)
}
export async function install_accept_shared_instance_invite(inviteId: string) {
return await invoke<SharedInstanceInviteInstallPreview>(
'plugin:install|install_accept_shared_instance_invite',
{
inviteId,
},
)
}
export async function install_get_shared_instance_update_preview(instanceId: string) {
return await invoke<SharedInstanceUpdatePreview | null>(
'plugin:install|install_get_shared_instance_update_preview',
{
instanceId,
},
)
}
export async function install_shared_instance(
sharedInstanceId: string,
name: string,
managerId?: string | null,
serverManagerName?: string | null,
serverManagerIconUrl?: string | null,
instanceIconUrl?: string | null,
) {
return await invoke<InstallJobSnapshot>('plugin:install|install_shared_instance', {
sharedInstanceId,
name,
managerId,
serverManagerName,
serverManagerIconUrl,
instanceIconUrl,
})
}
export async function install_update_shared_instance(instanceId: string) {
return await invoke<InstallJobSnapshot>('plugin:install|install_update_shared_instance', {
instanceId,
})
}
export async function install_import_instance(
launcherType: string,
basePath: string,
@@ -197,6 +361,10 @@ export async function install_job_dismiss(jobId: string) {
return await invoke<void>('plugin:install|install_job_dismiss', { jobId })
}
export async function install_job_support_details(jobId: string) {
return await invoke<string>('plugin:install|install_job_support_details', { jobId })
}
export function installJobInstanceId(job: InstallJobSnapshot): string | null {
return job.instance_id ?? job.target.instance_id ?? null
}
@@ -213,7 +381,8 @@ export function isInstallJobFinished(status: InstallJobStatus) {
function settleInstallJob(job: InstallJobSnapshot) {
if (job.status === 'succeeded') return job
throw new Error(job.error?.message ?? `Install job ${job.job_id} ${job.status}`)
if (job.error) throw job.error
throw new Error(`Install job ${job.job_id} ${job.status}`)
}
export async function wait_for_install_job(jobId: string) {
+81 -1
View File
@@ -7,13 +7,14 @@ import type { Labrinth } from '@modrinth/api-client'
import type { ContentItem, ContentOwner } from '@modrinth/ui'
import { invoke } from '@tauri-apps/api/core'
import type { InstallJobSnapshot } from './install'
import type { InstallJobSnapshot, SharedInstanceUpdateDiff } from './install'
import type {
CacheBehaviour,
ContentFile,
ContentFileProjectType,
GameInstance,
InstanceLoader,
SharedInstanceAttachment,
} from './types'
export async function remove(instanceId: string): Promise<void> {
@@ -331,3 +332,82 @@ export async function edit(instanceId: string, editInstance: Partial<GameInstanc
export async function edit_icon(instanceId: string, iconPath: string | null): Promise<void> {
return await invoke('plugin:instance|instance_edit_icon', { instanceId, iconPath })
}
export type SharedInstanceUsers = {
user_ids: string[]
users: SharedInstanceUser[]
tokens: number
}
export type SharedInstanceJoinType = 'owner' | 'invite' | 'link'
export type SharedInstanceUser = {
id: string
joined_at?: string | null
join_type: SharedInstanceJoinType
last_played?: string | null
}
export interface SharedInstancePublishPreview {
sharedInstanceId: string
latestVersion: number
diffs: SharedInstanceUpdateDiff[]
}
export interface SharedInstanceInviteLink {
inviteId: string
expiresAt: string
maxUses: number
}
export async function get_shared_instance_users(instanceId: string): Promise<SharedInstanceUsers> {
return await invoke('plugin:instance|instance_share_get_users', { instanceId })
}
export async function invite_shared_instance_users(
instanceId: string,
userIds: string[],
): Promise<SharedInstanceUsers> {
return await invoke('plugin:instance|instance_share_invite_users', { instanceId, userIds })
}
export async function create_shared_instance_invite_link(
instanceId: string,
options: {
maxAgeSeconds?: number
maxUses?: number
replaceInviteId?: string
} = {},
): Promise<SharedInstanceInviteLink> {
return await invoke('plugin:instance|instance_share_create_invite_link', {
instanceId,
...options,
})
}
export async function remove_shared_instance_users(
instanceId: string,
userIds: string[],
): Promise<SharedInstanceUsers> {
return await invoke('plugin:instance|instance_share_remove_users', { instanceId, userIds })
}
export async function get_shared_instance_publish_preview(
instanceId: string,
): Promise<SharedInstancePublishPreview | null> {
return await invoke('plugin:instance|instance_share_get_publish_preview', { instanceId })
}
export async function publish_shared_instance(
instanceId: string,
): Promise<SharedInstanceAttachment> {
return await invoke('plugin:instance|instance_share_publish', { instanceId })
}
export async function unlink_shared_instance(instanceId: string): Promise<void> {
return await invoke('plugin:instance|instance_share_unlink', { instanceId })
}
export async function unpublish_shared_instance(instanceId: string): Promise<void> {
return await invoke('plugin:instance|instance_share_unpublish', { instanceId })
}
+4 -2
View File
@@ -12,8 +12,10 @@ export type ModrinthCredentials = {
active: boolean
}
export async function login(): Promise<ModrinthCredentials> {
return await invoke('plugin:mr-auth|modrinth_login')
export type ModrinthAuthFlow = 'sign-in' | 'sign-up'
export async function login(flow: ModrinthAuthFlow = 'sign-in'): Promise<ModrinthCredentials> {
return await invoke('plugin:mr-auth|modrinth_login', { flow })
}
export async function logout(): Promise<void> {
@@ -0,0 +1,80 @@
import { defineMessages, injectNotificationManager, useVIntl } from '@modrinth/ui'
import { getErrorMessage, type SharedInstanceUnavailableReason } from '@/helpers/install'
export const sharedInstanceErrorMessages = defineMessages({
unavailableTitle: {
id: 'instance.shared-instance.unavailable.title',
defaultMessage: 'Shared instance no longer available',
},
unavailableText: {
id: 'instance.shared-instance.unavailable.text-v2',
defaultMessage:
"Your local instance is still available, but it is no longer linked and won't receive updates.",
},
deletedText: {
id: 'instance.shared-instance.unavailable.deleted-text-v2',
defaultMessage:
"The shared instance was deleted. Your local instance is still available, but it is no longer linked and won't receive updates.",
},
accessRevokedText: {
id: 'instance.shared-instance.unavailable.access-revoked-text-v2',
defaultMessage:
"Your access to the shared instance was revoked. Your local instance is still available, but it is no longer linked and won't receive updates.",
},
unavailableFallbackManager: {
id: 'instance.shared-instance.unavailable.manager-fallback',
defaultMessage: 'the instance manager',
},
errorTitle: {
id: 'instance.shared-instance.error.title',
defaultMessage: 'Something has gone wrong',
},
})
export function sharedInstanceUnavailableTextMessage(
reason: SharedInstanceUnavailableReason | null,
) {
if (reason === 'deleted') return sharedInstanceErrorMessages.deletedText
if (reason === 'access_revoked') return sharedInstanceErrorMessages.accessRevokedText
return sharedInstanceErrorMessages.unavailableText
}
export function useSharedInstanceErrors() {
const { formatMessage } = useVIntl()
const { addNotification } = injectNotificationManager()
function formatSharedInstanceUnavailable(
reason: SharedInstanceUnavailableReason | null = null,
manager?: string | null,
) {
return formatMessage(sharedInstanceUnavailableTextMessage(reason), {
manager: manager || formatMessage(sharedInstanceErrorMessages.unavailableFallbackManager),
})
}
function notifySharedInstanceUnavailable(
reason: SharedInstanceUnavailableReason | null = null,
manager?: string | null,
) {
addNotification({
type: 'warning',
title: formatMessage(sharedInstanceErrorMessages.unavailableTitle),
text: formatSharedInstanceUnavailable(reason, manager),
})
}
function notifySharedInstanceError(error: unknown) {
addNotification({
type: 'error',
title: formatMessage(sharedInstanceErrorMessages.errorTitle),
text: getErrorMessage(error),
})
}
return {
formatSharedInstanceUnavailable,
notifySharedInstanceError,
notifySharedInstanceUnavailable,
}
}
+38 -1
View File
@@ -17,6 +17,7 @@ export type GameInstance = {
groups: string[]
link?: InstanceLink | null
shared_instance?: SharedInstanceAttachment | null
update_channel: ReleaseChannel
created: Date
@@ -86,18 +87,47 @@ export type InstanceLink = InstanceLinkIdentity &
}
| {
type: 'shared_instance'
shared_instance_id: string
modpack_project_id?: ModrinthId | null
modpack_version_id?: ModrinthId | null
}
)
export type SharedInstanceAttachment = {
id: string
role: 'owner' | 'member'
manager_id?: string | null
server_manager_name?: string | null
server_manager_icon_url?: string | null
linked_user_id?: string | null
status:
| 'unknown'
| 'up_to_date'
| 'update_available'
| 'applying'
| 'stale'
| 'not_ready'
| 'error'
applied_version?: number | null
latest_version?: number | null
}
export type Instance = GameInstance
type ReleaseChannel = 'release' | 'beta' | 'alpha'
export type InstanceLoader = 'vanilla' | 'forge' | 'fabric' | 'quilt' | 'neoforge'
export type ContentSourceKind =
| 'local'
| 'modrinth_modpack'
| 'server_project'
| 'modrinth_hosting'
| 'imported_modpack'
| 'shared_instance'
type ContentFile = {
enabled: boolean
source_kind?: ContentSourceKind | null
metadata?: {
project_id: string
version_id: string
@@ -133,11 +163,18 @@ type Hooks = {
type Manifest = {
gameVersions: ManifestGameVersion[]
versionGroups?: ManifestVersionGroup[]
}
type ManifestGameVersion = {
id: string
stable: boolean
versionGroup?: string
loaders: ManifestLoaderVersion[]
}
type ManifestVersionGroup = {
id: string
loaders: ManifestLoaderVersion[]
}
+11
View File
@@ -0,0 +1,11 @@
import { invoke } from '@tauri-apps/api/core'
export type SearchUser = {
id: string
username: string
avatar_url: string | null
}
export async function search_user(query: string): Promise<SearchUser[]> {
return await invoke<SearchUser[]>('plugin:users|search_user', { query })
}
+30 -23
View File
@@ -404,28 +404,35 @@ export async function refreshServerData(
): Promise<void> {
const refreshTime = Date.now()
serverData.refreshing = true
await get_server_status(address, protocolVersion)
.then((status) => {
if (serverData.lastSuccessfulRefresh && serverData.lastSuccessfulRefresh > refreshTime) {
// Don't update if there was a more recent successful refresh
return
}
serverData.lastSuccessfulRefresh = Date.now()
serverData.status = status
if (status.description) {
serverData.rawMotd = status.description
serverData.renderedMotd = autoToHTML(status.description)
}
})
.finally(() => {
serverData.refreshing = false
})
.catch((err) => {
console.error(`Refreshing addr ${address}`, protocolVersion, err)
if (!protocolVersion?.legacy) {
refreshServerData(serverData, { version: 74, legacy: true }, address)
}
})
try {
const status = await get_server_status(address, protocolVersion)
if (serverData.lastSuccessfulRefresh && serverData.lastSuccessfulRefresh > refreshTime) {
// Don't update if there was a more recent successful refresh
return
}
serverData.lastSuccessfulRefresh = Date.now()
serverData.status = status
if (status.description) {
serverData.rawMotd = status.description
serverData.renderedMotd = autoToHTML(status.description)
} else {
delete serverData.rawMotd
delete serverData.renderedMotd
}
} catch (err) {
console.error(`Refreshing addr ${address}`, protocolVersion, err)
if (!protocolVersion?.legacy) {
await refreshServerData(serverData, { version: 74, legacy: true }, address)
return
}
if (!serverData.lastSuccessfulRefresh || serverData.lastSuccessfulRefresh <= refreshTime) {
delete serverData.status
delete serverData.rawMotd
delete serverData.renderedMotd
}
} finally {
serverData.refreshing = false
}
}
export function refreshServers(
@@ -444,7 +451,7 @@ export function refreshServers(
}
})
// noinspection ES6MissingAwait - handled with .then by refreshServerData already
// noinspection ES6MissingAwait - handled by refreshServerData
Object.keys(serverData).forEach((address) =>
refreshServerData(serverData[address], protocolVersion, address),
)
@@ -122,9 +122,6 @@
"app.export-modal.modpack-name-placeholder": {
"message": "إسم حزمة التعديل"
},
"app.export-modal.select-files-label": {
"message": "أختيار الملفات التي سيتم تصديرها"
},
"app.export-modal.version-number-label": {
"message": "رقم الإصدار"
},
+127 -22
View File
@@ -11,6 +11,21 @@
"app.action-bar.hide-more-running-instances": {
"message": "Skrýt více spuštěných instancí"
},
"app.action-bar.install.dismiss": {
"message": "Zavřít"
},
"app.action-bar.install.open-instance": {
"message": "Otevřít instanci"
},
"app.action-bar.install.retry": {
"message": "Zkusit znovu"
},
"app.action-bar.install.unknown-instance": {
"message": "Neznámá instance"
},
"app.action-bar.installs": {
"message": "Instalace"
},
"app.action-bar.make-primary-instance": {
"message": "Nastavit jako primární instanci"
},
@@ -33,7 +48,7 @@
"message": "Zastavit instanci"
},
"app.action-bar.update": {
"message": "Aktualizace"
"message": "Aktualizovat"
},
"app.action-bar.view-active-downloads": {
"message": "Zobrazit probíhající stahování"
@@ -101,8 +116,14 @@
"app.appearance-settings.show-play-time.title": {
"message": "Zobrazit odehraný čas"
},
"app.appearance-settings.skip-non-essential-warnings.description": {
"message": "Automaticky přeskočí nízko riziková potrvzení jako duplicitní instalace modpacků, běžné mazání obsahu, hromadné aktualizace, odpojování modpacků a výzvy k opravě. Nebezpečná upozornění se budou nadále zobrazovat."
},
"app.appearance-settings.skip-non-essential-warnings.title": {
"message": "Přeskočit nedůležitá upozornění"
},
"app.appearance-settings.toggle-sidebar.description": {
"message": "Umožní přepínat zobrazení postranního panelu."
"message": "Umožňí přepínat zobrazení postranního panelu."
},
"app.appearance-settings.toggle-sidebar.title": {
"message": "Přepnout postranní panel"
@@ -141,10 +162,10 @@
"message": "Zpět k instanci"
},
"app.browse.discover-content": {
"message": "Objevovat obsah"
"message": "Objevit obsah"
},
"app.browse.discover-servers": {
"message": "Objevovat servery"
"message": "Objevit servery"
},
"app.browse.hide-added-servers": {
"message": "Skrýt již přidané servery"
@@ -159,7 +180,7 @@
"message": "Instalování"
},
"app.content-install.no-compatible-versions": {
"message": "Žádná dostupná verze se neshoduje s verzí {compatibilityLabel}. Vyber verzi, abys ji i přesto nainstaloval/a. Závislosti nebudou automaticky nainstalovány."
"message": "Žádné dostupné verze se neshodují s {compatibilityLabel}. Vyberte verzi, kterou chcete přesto nainstalovat. Závislosti nebudou nainstalovány automaticky."
},
"app.export-modal.description-placeholder": {
"message": "Zadej popis modpacku..."
@@ -170,24 +191,72 @@
"app.export-modal.header": {
"message": "Exportovat modpack"
},
"app.export-modal.include-file-accessibility-label": {
"message": "Zahrnout \"{file}\"?"
},
"app.export-modal.modpack-name-label": {
"message": "Název modpacku"
},
"app.export-modal.modpack-name-placeholder": {
"message": "Název modpacku"
},
"app.export-modal.select-files-label": {
"message": "Nastavte, které soubory mají být do tohoto exportu zahrnuty"
},
"app.export-modal.version-number-label": {
"message": "Číslo verze"
},
"app.export-modal.version-number-placeholder": {
"message": "1.0.0"
},
"app.install.phase.downloading_content": {
"message": "Stahování obsahu"
},
"app.install.phase.downloading_minecraft": {
"message": "Stahování Minecraftu"
},
"app.install.phase.downloading_pack_file": {
"message": "Stahování pack souboru"
},
"app.install.phase.extracting_overrides": {
"message": "Rozbalování souborů overrides"
},
"app.install.phase.finalizing": {
"message": "Dokončení instalace"
},
"app.install.phase.preparing_instance": {
"message": "Příprava instance"
},
"app.install.phase.preparing_java": {
"message": "Příprava Javy"
},
"app.install.phase.preparing_java.downloading": {
"message": "Stahování Javy {version}"
},
"app.install.phase.preparing_java.extracting": {
"message": "Rozbalování Javy {version}"
},
"app.install.phase.preparing_java.fetching-metadata": {
"message": "Získávání Javy {version}"
},
"app.install.phase.preparing_java.resolving": {
"message": "Připravení Javy {version}"
},
"app.install.phase.preparing_java.validating": {
"message": "Validace Javy {version}"
},
"app.install.phase.reading_pack_manifest": {
"message": "Čtení pack manifestu"
},
"app.install.phase.resolving_loader": {
"message": "Vyhodnocování loaderu"
},
"app.install.phase.resolving_minecraft": {
"message": "Vyhodnocování Minecraftu"
},
"app.install.phase.resolving_pack": {
"message": "Vyhodnocování obsahu"
},
"app.install.phase.rolling_back": {
"message": "Obnovování do předchozího stavu"
},
"app.install.phase.running_loader_processors": {
"message": "Spuštění loader procesů"
},
"app.instance.confirm-delete.admonition-body": {
"message": "Všechna data tvé instance budou trvale smazána, včetně světů, konfigurací a veškerého nainstalovaného obsahu."
},
@@ -195,7 +264,7 @@
"message": "Tato akce nemůže být vrácena"
},
"app.instance.confirm-delete.delete-button": {
"message": "Odstranit instanci"
"message": "Smazat instanci"
},
"app.instance.confirm-delete.header": {
"message": "Odstranění instance"
@@ -212,6 +281,15 @@
"app.instance.modpack-already-installed.instance": {
"message": "Instance"
},
"app.instance.mods.bulk-update.downloading-projects": {
"message": "Stahování {current, number}/{total, number} projektů..."
},
"app.instance.mods.bulk-update.finishing": {
"message": "Dokončení aktualizace..."
},
"app.instance.mods.bulk-update.resolving-versions": {
"message": "Vyhodnocování verzí..."
},
"app.instance.mods.content-type-project": {
"message": "projekt"
},
@@ -236,8 +314,20 @@
"app.instance.worlds.browse-servers": {
"message": "Procházet servery"
},
"app.instance.worlds.delete-world-modal.delete-button": {
"message": "Smazat svět"
},
"app.instance.worlds.delete-world-modal.title": {
"message": "Smazat svět"
},
"app.instance.worlds.delete-world-modal.warning-body": {
"message": "Tento svět bude trvale smazán z této instance. Tuto akci nelze vrátit zpět."
},
"app.instance.worlds.delete-world-modal.warning-header": {
"message": "Mazání {name}"
},
"app.instance.worlds.filter-modded": {
"message": "Módovaný"
"message": "Módované"
},
"app.instance.worlds.filter-offline": {
"message": "Offline"
@@ -249,10 +339,22 @@
"message": "Vanilla"
},
"app.instance.worlds.no-worlds-description": {
"message": "Přidej server nebo začni procházet"
"message": "Začněte přidáním serveru nebo procházením obsahu"
},
"app.instance.worlds.no-worlds-heading": {
"message": "Žádné servery nebo světy nebyly přidány"
"message": "Nejsou přidané žádné servery nebo světy"
},
"app.instance.worlds.remove-server-modal.remove-button": {
"message": "Odebrat server"
},
"app.instance.worlds.remove-server-modal.title": {
"message": "Odebrat server"
},
"app.instance.worlds.remove-server-modal.warning-body": {
"message": "Tento server bude odebrán z vašeho seznamu serverů i ze seznamu serverů ve hře. Pokud znáte jeho adresu, můžete ho později znovu přidat."
},
"app.instance.worlds.remove-server-modal.warning-header": {
"message": "Odebírání {name}"
},
"app.instance.worlds.search-worlds-placeholder": {
"message": "Hledat v {count} světech..."
@@ -339,7 +441,7 @@
"message": "Smazat skin"
},
"app.skins.delete-modal.description": {
"message": "Tahle akce navždy smaže vybraný skin a nejde vrátit zpět."
"message": "Tímto dojde k trvalému smazání vybreného skinu. Tuto akci nelze vrátit zpět."
},
"app.skins.delete-modal.title": {
"message": "Opravdu chceš smazat tenhle skin?"
@@ -468,19 +570,19 @@
"message": "Nadšený Modrinth Bot"
},
"app.skins.sign-in.title": {
"message": "Přihlas se prosím"
"message": "Přihlaš se prosím"
},
"app.skins.title": {
"message": "Výběr skinu"
},
"app.update-popup.body.download-complete": {
"message": "Stahování aplikace Modrinth v{version} bylo dokončeno. Aktualizujte ji znovunačtením nebo se automaticky aktualizuje po zavření."
"message": "Stahování Modrinth App v{version} bylo dokončeno. Aktualizujte ji znovunačtením nebo se automaticky aktualizuje po zavření."
},
"app.update-popup.body.linux": {
"message": "Aplikace Modrinth v{version} je k dispozici. Aktualizujte ji pomocí svého správce balíčků, abyste získali nejnovější funkce a opravy!"
"message": "Modrinth App v{version} je k dispozici. Aktualizujte ji pomocí svého správce balíčků, abyste získali nejnovější funkce a opravy!"
},
"app.update-popup.body.metered": {
"message": "Aplikace Modrinth v{version} je nyní k dispozici! Protože jste v měřené síti, nebyla stažena automaticky."
"message": "Modrinth App v{version} je nyní k dispozici! Protože jste v měřené síti, nebyla stažena automaticky."
},
"app.update-popup.changelog": {
"message": "Seznam změn"
@@ -693,13 +795,13 @@
"message": "Jméno"
},
"instance.settings.tabs.general.update-channel": {
"message": "Aktualizační kanál"
"message": "Kanál aktualiza"
},
"instance.settings.tabs.general.update-channel.alpha": {
"message": "Alfa"
},
"instance.settings.tabs.general.update-channel.alpha.description": {
"message": "Vydané, beta a alfa verze se zobrazí jako dostupné aktualizace."
"message": "Verze typu release, beta a alpha se budou zobrazovat jako dostupné aktualizace."
},
"instance.settings.tabs.general.update-channel.beta": {
"message": "Beta"
@@ -839,6 +941,9 @@
"instance.worlds.copy_address": {
"message": "Kopírovat adresu"
},
"instance.worlds.create_shortcut": {
"message": "Vytvořit záložku"
},
"instance.worlds.dont_show_on_home": {
"message": "Nezobrazovat na domovské stránce"
},
@@ -164,18 +164,12 @@
"app.export-modal.header": {
"message": "Eksporter modpack"
},
"app.export-modal.include-file-accessibility-label": {
"message": "Inkludere \"{file}\"?"
},
"app.export-modal.modpack-name-label": {
"message": "Modpack Navn"
},
"app.export-modal.modpack-name-placeholder": {
"message": "Modpack navn"
},
"app.export-modal.select-files-label": {
"message": "Konfigurer hvilke filer inkludere i denne eksport"
},
"app.export-modal.version-number-label": {
"message": "Versionsnummer"
},
+111 -6
View File
@@ -11,6 +11,21 @@
"app.action-bar.hide-more-running-instances": {
"message": "Weitere laufende Instanzen ausblenden"
},
"app.action-bar.install.dismiss": {
"message": "Ignorieren"
},
"app.action-bar.install.open-instance": {
"message": "Instanz öffnen"
},
"app.action-bar.install.retry": {
"message": "Wiederholen"
},
"app.action-bar.install.unknown-instance": {
"message": "Unbekannte Instanz"
},
"app.action-bar.installs": {
"message": "Installationen"
},
"app.action-bar.make-primary-instance": {
"message": "Zur primären Instanz machen"
},
@@ -101,6 +116,12 @@
"app.appearance-settings.show-play-time.title": {
"message": "Spielzeit anzeigen"
},
"app.appearance-settings.skip-non-essential-warnings.description": {
"message": "Überspringt automatisch Bestätigungen mit geringem Risiko, wie z. B. die doppelte Installation von Modpacks, das Löschen normaler Inhalte, Massenaktualisierungen, das Trennen von Modpacks und Reparaturen. Warnungen bei gefährlichen Aktionen werden weiterhin angezeigt."
},
"app.appearance-settings.skip-non-essential-warnings.title": {
"message": "Nicht essenzielle Warnungen überspringen"
},
"app.appearance-settings.toggle-sidebar.description": {
"message": "Ermöglicht das Umschalten der Seitenleiste."
},
@@ -170,24 +191,72 @@
"app.export-modal.header": {
"message": "Modpack exportieren"
},
"app.export-modal.include-file-accessibility-label": {
"message": "\"{file}\" einschliessen?"
},
"app.export-modal.modpack-name-label": {
"message": "Modpaketname"
},
"app.export-modal.modpack-name-placeholder": {
"message": "Modpaketname"
},
"app.export-modal.select-files-label": {
"message": "Konfiguriere, welche Dateien in diesem Export miteinbezogen werden"
},
"app.export-modal.version-number-label": {
"message": "Versionsnummer"
},
"app.export-modal.version-number-placeholder": {
"message": "1.0.0"
},
"app.install.phase.downloading_content": {
"message": "Lade Inhalte herunter"
},
"app.install.phase.downloading_minecraft": {
"message": "Lade Minecraft herunter"
},
"app.install.phase.downloading_pack_file": {
"message": "Lade Packdatei herunter"
},
"app.install.phase.extracting_overrides": {
"message": "Überschreibungen extrahieren"
},
"app.install.phase.finalizing": {
"message": "Abschliessen"
},
"app.install.phase.preparing_instance": {
"message": "Instanz präparieren"
},
"app.install.phase.preparing_java": {
"message": "Java präparieren"
},
"app.install.phase.preparing_java.downloading": {
"message": "Java {version} herunterladen"
},
"app.install.phase.preparing_java.extracting": {
"message": "Java {version} extrahieren"
},
"app.install.phase.preparing_java.fetching-metadata": {
"message": "Java {version} wird heruntergeladen"
},
"app.install.phase.preparing_java.resolving": {
"message": "Java {version} präparieren"
},
"app.install.phase.preparing_java.validating": {
"message": "Java {version} validieren"
},
"app.install.phase.reading_pack_manifest": {
"message": "Paketmanifest wird gelesen"
},
"app.install.phase.resolving_loader": {
"message": "Loader wird ermittelt"
},
"app.install.phase.resolving_minecraft": {
"message": "Minecraft wird ermittelt"
},
"app.install.phase.resolving_pack": {
"message": "Inhalte werden ermittelt"
},
"app.install.phase.rolling_back": {
"message": "Wird rückgängig gemacht"
},
"app.install.phase.running_loader_processors": {
"message": "Loader-Prozesse werden ausgeführt"
},
"app.instance.confirm-delete.admonition-body": {
"message": "Alle Daten deiner Instanz werden permanent gelöscht, inlusive deiner Welten, Konfigurationen und allen installierten Inhalten."
},
@@ -212,6 +281,15 @@
"app.instance.modpack-already-installed.instance": {
"message": "Instanz"
},
"app.instance.mods.bulk-update.downloading-projects": {
"message": "Lade {current, number}/{total, number} Projekte..."
},
"app.instance.mods.bulk-update.finishing": {
"message": "Update abschliessen..."
},
"app.instance.mods.bulk-update.resolving-versions": {
"message": "Versionen werden ermittelt..."
},
"app.instance.mods.content-type-project": {
"message": "Projekt"
},
@@ -236,6 +314,18 @@
"app.instance.worlds.browse-servers": {
"message": "Server durchsuchen"
},
"app.instance.worlds.delete-world-modal.delete-button": {
"message": "Welt löschen"
},
"app.instance.worlds.delete-world-modal.title": {
"message": "Welt löschen"
},
"app.instance.worlds.delete-world-modal.warning-body": {
"message": "Diese Welt wird permanent gelöscht von dieser Instanz, dies kann nicht rückgängig gemacht werden."
},
"app.instance.worlds.delete-world-modal.warning-header": {
"message": "Lösche {name}"
},
"app.instance.worlds.filter-modded": {
"message": "Modifiziert"
},
@@ -254,6 +344,18 @@
"app.instance.worlds.no-worlds-heading": {
"message": "Keine Server oder Welten hinzugefügt"
},
"app.instance.worlds.remove-server-modal.remove-button": {
"message": "Server entfernen"
},
"app.instance.worlds.remove-server-modal.title": {
"message": "Server entfernen"
},
"app.instance.worlds.remove-server-modal.warning-body": {
"message": "Dieser Server wird aus deiner Serverliste und aus der Serverliste im Spiel entfernt. Du kannst ihn später wieder hinzufügen, wenn du die Adresse kennst."
},
"app.instance.worlds.remove-server-modal.warning-header": {
"message": "Entferne {name}"
},
"app.instance.worlds.search-worlds-placeholder": {
"message": "Durchsuche {count} Welten..."
},
@@ -839,6 +941,9 @@
"instance.worlds.copy_address": {
"message": "Adresse kopieren"
},
"instance.worlds.create_shortcut": {
"message": "Shortcut erstellen"
},
"instance.worlds.dont_show_on_home": {
"message": "Nicht auf Startseite anzeigen"
},
+111 -6
View File
@@ -11,6 +11,21 @@
"app.action-bar.hide-more-running-instances": {
"message": "Weitere laufende Instanzen ausblenden"
},
"app.action-bar.install.dismiss": {
"message": "Ausblenden"
},
"app.action-bar.install.open-instance": {
"message": "Instanz öffnen"
},
"app.action-bar.install.retry": {
"message": "Erneut versuchen"
},
"app.action-bar.install.unknown-instance": {
"message": "Unbekannte Instanz"
},
"app.action-bar.installs": {
"message": "Installationen"
},
"app.action-bar.make-primary-instance": {
"message": "Zur primären Instanz machen"
},
@@ -101,6 +116,12 @@
"app.appearance-settings.show-play-time.title": {
"message": "Spielzeit anzeigen"
},
"app.appearance-settings.skip-non-essential-warnings.description": {
"message": "Überspringt automatisch Bestätigungen mit geringem Risiko, wie z. B. die doppelte Installation von Modpacks, das Löschen normaler Inhalte, Massenaktualisierungen, das Trennen von Modpacks und Reparaturen. Warnungen bei gefährlichen Aktionen werden weiterhin angezeigt."
},
"app.appearance-settings.skip-non-essential-warnings.title": {
"message": "Unwichtige Warnungen überspringen"
},
"app.appearance-settings.toggle-sidebar.description": {
"message": "Ermöglicht das Umschalten der Seitenleiste."
},
@@ -170,24 +191,72 @@
"app.export-modal.header": {
"message": "Modpack exportieren"
},
"app.export-modal.include-file-accessibility-label": {
"message": "\"{file}\" einschließen?"
},
"app.export-modal.modpack-name-label": {
"message": "Modpackname"
},
"app.export-modal.modpack-name-placeholder": {
"message": "Modpackname"
},
"app.export-modal.select-files-label": {
"message": "Konfiguriere, welche Dateien in diesen Export enthalten sind"
},
"app.export-modal.version-number-label": {
"message": "Versionsnummer"
},
"app.export-modal.version-number-placeholder": {
"message": "1.0.0"
},
"app.install.phase.downloading_content": {
"message": "Inhalte werden heruntergeladen"
},
"app.install.phase.downloading_minecraft": {
"message": "Minecraft wird heruntergeladen"
},
"app.install.phase.downloading_pack_file": {
"message": "Paketdatei wird herunterladen"
},
"app.install.phase.extracting_overrides": {
"message": "Überschreibungen werden extrahiert"
},
"app.install.phase.finalizing": {
"message": "Wird finalisiert"
},
"app.install.phase.preparing_instance": {
"message": "Instanz wird vorbereitet"
},
"app.install.phase.preparing_java": {
"message": "Java wird vorbereitet"
},
"app.install.phase.preparing_java.downloading": {
"message": "Java {version} wird heruntergeladen"
},
"app.install.phase.preparing_java.extracting": {
"message": "Java {version} wird extrahiert"
},
"app.install.phase.preparing_java.fetching-metadata": {
"message": "Java {version} wird heruntergeladen"
},
"app.install.phase.preparing_java.resolving": {
"message": "Java {version} wird vorbereitet"
},
"app.install.phase.preparing_java.validating": {
"message": "Java {version} wird validiert"
},
"app.install.phase.reading_pack_manifest": {
"message": "Paketmanifest wird gelesen"
},
"app.install.phase.resolving_loader": {
"message": "Loader wird ermittelt"
},
"app.install.phase.resolving_minecraft": {
"message": "Minecraft wird ermittelt"
},
"app.install.phase.resolving_pack": {
"message": "Inhalte werden ermittelt"
},
"app.install.phase.rolling_back": {
"message": "Wird rückgängig gemacht"
},
"app.install.phase.running_loader_processors": {
"message": "Loader-Prozesse werden ausgeführt"
},
"app.instance.confirm-delete.admonition-body": {
"message": "Alle Daten deiner Instanz werden permanent gelöscht, einschließlich deiner Welten, Konfigurationen und allen installierten Inhalten."
},
@@ -212,6 +281,15 @@
"app.instance.modpack-already-installed.instance": {
"message": "Instanz"
},
"app.instance.mods.bulk-update.downloading-projects": {
"message": "{current, number}/{total, number} Projekte werden heruntergelanden..."
},
"app.instance.mods.bulk-update.finishing": {
"message": "Update wird fertiggestellt..."
},
"app.instance.mods.bulk-update.resolving-versions": {
"message": "Versionen werden ermittelt..."
},
"app.instance.mods.content-type-project": {
"message": "Projekt"
},
@@ -236,6 +314,18 @@
"app.instance.worlds.browse-servers": {
"message": "Server durchsuchen"
},
"app.instance.worlds.delete-world-modal.delete-button": {
"message": "Welt löschen"
},
"app.instance.worlds.delete-world-modal.title": {
"message": "Welt löschen"
},
"app.instance.worlds.delete-world-modal.warning-body": {
"message": "Die Welt wird permanent von der Instanz gelöscht. Diese Aktion kann nicht rückgängig gemacht werden."
},
"app.instance.worlds.delete-world-modal.warning-header": {
"message": "{name} wird gelöscht"
},
"app.instance.worlds.filter-modded": {
"message": "Modifiziert"
},
@@ -254,6 +344,18 @@
"app.instance.worlds.no-worlds-heading": {
"message": "Keine Server oder Welten hinzugefügt"
},
"app.instance.worlds.remove-server-modal.remove-button": {
"message": "Server entfernen"
},
"app.instance.worlds.remove-server-modal.title": {
"message": "Server entfernen"
},
"app.instance.worlds.remove-server-modal.warning-body": {
"message": "Dieser Server wird aus deiner Serverliste und aus der Serverliste im Spiel entfernt. Du kannst ihn später wieder hinzufügen, wenn du die Adresse kennst."
},
"app.instance.worlds.remove-server-modal.warning-header": {
"message": "{name} wird entfernt"
},
"app.instance.worlds.search-worlds-placeholder": {
"message": "Durchsuche {count} Welten..."
},
@@ -839,6 +941,9 @@
"instance.worlds.copy_address": {
"message": "Adresse kopieren"
},
"instance.worlds.create_shortcut": {
"message": "Verknüpfung erstellen"
},
"instance.worlds.dont_show_on_home": {
"message": "Nicht auf der Startseite anzeigen"
},
+340 -22
View File
@@ -11,30 +11,87 @@
"app.action-bar.hide-more-running-instances": {
"message": "Hide more running instances"
},
"app.action-bar.install.copied-details": {
"message": "Copied"
},
"app.action-bar.install.copy-details": {
"message": "Copy details"
},
"app.action-bar.install.dismiss": {
"message": "Dismiss"
},
"app.action-bar.install.failed": {
"message": "Install failed"
},
"app.action-bar.install.failed-app-closed": {
"message": "Installation failed due to app closing."
},
"app.action-bar.install.failed-network": {
"message": "Installation failed due to a network error."
},
"app.action-bar.install.failed-unknown": {
"message": "Installation failed due to an unknown error."
},
"app.action-bar.install.open-instance": {
"message": "Open instance"
},
"app.action-bar.install.retry": {
"message": "Retry"
},
"app.action-bar.install.summary.app-closing": {
"message": "Canceled due to app closing"
},
"app.action-bar.install.summary.bad-modpack-file": {
"message": "Couldn't read modpack"
},
"app.action-bar.install.summary.canceled": {
"message": "Canceled"
},
"app.action-bar.install.summary.cleanup-incomplete": {
"message": "Cleanup didn't finish"
},
"app.action-bar.install.summary.content-download-failed": {
"message": "Couldn't download files"
},
"app.action-bar.install.summary.corrupt-download": {
"message": "Downloaded file is corrupt"
},
"app.action-bar.install.summary.could-not-save-files": {
"message": "Couldn't save files"
},
"app.action-bar.install.summary.download-failed": {
"message": "Download couldn't finish"
},
"app.action-bar.install.summary.instance-not-found": {
"message": "Instance couldn't be found"
},
"app.action-bar.install.summary.invalid-file-path": {
"message": "File path is invalid"
},
"app.action-bar.install.summary.invalid-modpack": {
"message": "Modpack data invalid"
},
"app.action-bar.install.summary.invalid-modpack-files": {
"message": "Modpack files have invalid metadata"
},
"app.action-bar.install.summary.java-setup-failed": {
"message": "Java setup couldn't finish"
},
"app.action-bar.install.summary.loader-setup-failed": {
"message": "Loader setup failed"
},
"app.action-bar.install.summary.local-data-error": {
"message": "Couldn't update local data"
},
"app.action-bar.install.summary.minecraft-setup-failed": {
"message": "Minecraft setup failed"
},
"app.action-bar.install.summary.modrinth-unreachable": {
"message": "Couldn't reach Modrinth"
},
"app.action-bar.install.summary.no-write-permission": {
"message": "No permission to write"
},
"app.action-bar.install.summary.pack-download-failed": {
"message": "Couldn't download pack"
},
"app.action-bar.install.summary.unexpected-error": {
"message": "Something went wrong"
},
"app.action-bar.install.unknown-instance": {
"message": "Unknown instance"
},
"app.action-bar.install.updating-shared-content": {
"message": "Updating shared content"
},
"app.action-bar.installs": {
"message": "Installs"
},
@@ -180,7 +237,7 @@
"message": "Discover servers"
},
"app.browse.hide-added-servers": {
"message": "Hide already added servers"
"message": "Hide servers already added"
},
"app.browse.project-type.modpacks": {
"message": "Modpacks"
@@ -203,18 +260,12 @@
"app.export-modal.header": {
"message": "Export modpack"
},
"app.export-modal.include-file-accessibility-label": {
"message": "Include \"{file}\"?"
},
"app.export-modal.modpack-name-label": {
"message": "Modpack Name"
"message": "Modpack name"
},
"app.export-modal.modpack-name-placeholder": {
"message": "Modpack name"
},
"app.export-modal.select-files-label": {
"message": "Configure which files are included in this export"
},
"app.export-modal.version-number-label": {
"message": "Version number"
},
@@ -237,7 +288,7 @@
"message": "Finalizing"
},
"app.install.phase.preparing_instance": {
"message": "Preparing instance"
"message": "Queued to install"
},
"app.install.phase.preparing_java": {
"message": "Preparing Java"
@@ -275,6 +326,36 @@
"app.install.phase.running_loader_processors": {
"message": "Running loader processors"
},
"app.instance.admonitions.shared-instance.added-label": {
"message": "Added"
},
"app.instance.admonitions.shared-instance.changes-body": {
"message": "Your local instance is ahead of the users you've shared it with."
},
"app.instance.admonitions.shared-instance.changes-header": {
"message": "Your changes haven't been shared yet"
},
"app.instance.admonitions.shared-instance.publish-button": {
"message": "Push update"
},
"app.instance.admonitions.shared-instance.publishing-button": {
"message": "Pushing..."
},
"app.instance.admonitions.shared-instance.removed-label": {
"message": "Removed"
},
"app.instance.admonitions.shared-instance.review-admonition-header": {
"message": "Push update"
},
"app.instance.admonitions.shared-instance.review-description": {
"message": "Review the content changes that will be shared with everyone using this instance."
},
"app.instance.admonitions.shared-instance.review-header": {
"message": "Review changes"
},
"app.instance.admonitions.shared-instance.reviewing-button": {
"message": "Reviewing..."
},
"app.instance.confirm-delete.admonition-body": {
"message": "All data for your instance will be permanently deleted, including your worlds, configs, and all installed content."
},
@@ -326,6 +407,90 @@
"app.instance.mods.successfully-uploaded": {
"message": "Successfully uploaded"
},
"app.instance.share.empty.description": {
"message": "You can share this instance with your friends!"
},
"app.instance.share.empty.heading": {
"message": "No friends invited"
},
"app.instance.share.empty.invite-friends-button": {
"message": "Invite friends"
},
"app.instance.share.invite-modal.heading": {
"message": "Share {name}"
},
"app.instance.share.locked.empty-description-prefix": {
"message": "You need to sign in as"
},
"app.instance.share.locked.empty-description-suffix": {
"message": "to access this page."
},
"app.instance.share.locked.linked-account-fallback": {
"message": "the linked account"
},
"app.instance.share.locked.signed-out-heading": {
"message": "Not signed in"
},
"app.instance.share.locked.switch-account-button": {
"message": "Switch account"
},
"app.instance.share.locked.wrong-account-heading": {
"message": "Wrong account"
},
"app.instance.share.remove-user-modal.effect-access": {
"message": "They will no longer receive updates for this shared instance"
},
"app.instance.share.remove-user-modal.effect-installed-copy": {
"message": "Any copy they already installed will stay on their device"
},
"app.instance.share.remove-user-modal.effect-invite-again": {
"message": "You can invite them again later"
},
"app.instance.share.remove-user-modal.effect-last-user": {
"message": "If this is the last user, sharing will be turned off for this instance"
},
"app.instance.share.remove-user-modal.effects-label": {
"message": "What happens?"
},
"app.instance.share.remove-user-modal.header": {
"message": "Revoke access"
},
"app.instance.share.remove-user-modal.remove-button": {
"message": "Revoke access"
},
"app.instance.share.remove-user-modal.user-avatar-alt": {
"message": "{username}'s avatar"
},
"app.instance.share.remove-user-modal.warning-body": {
"message": "If you revoke {username}'s access to this shared instance, you'll need to invite them again before they can receive updates."
},
"app.instance.share.sign-in.button": {
"message": "Sign in"
},
"app.instance.share.unlink.body": {
"message": "You must unlink this modpack to share your instance"
},
"app.instance.share.unlink.header": {
"message": "Sharing requires unlinking"
},
"app.instance.shared-instance-wrong-account.fallback-username": {
"message": "the linked account"
},
"app.instance.shared-instance-wrong-account.owner-admonition-body-v2": {
"message": "to manage this shared instance. You won't be able to push updates to users."
},
"app.instance.shared-instance-wrong-account.sign-in-as-label": {
"message": "Sign in as"
},
"app.instance.shared-instance-wrong-account.signed-out-header": {
"message": "You need to sign in to Modrinth"
},
"app.instance.shared-instance-wrong-account.user-admonition-body-v2": {
"message": "to receive updates for this shared instance."
},
"app.instance.shared-instance-wrong-account.warning-header": {
"message": "You are using the wrong Modrinth account"
},
"app.instance.worlds.add-server": {
"message": "Add server"
},
@@ -380,6 +545,9 @@
"app.modal.install-to-play.content-required": {
"message": "Content required"
},
"app.modal.install-to-play.external-file-warning": {
"message": "{count, plural, one {This instance includes # file that is not from Modrinth.} other {This instance includes # files that are not from Modrinth.}}"
},
"app.modal.install-to-play.header": {
"message": "Install to play"
},
@@ -398,30 +566,60 @@
"app.modal.install-to-play.shared-instance": {
"message": "Shared instance"
},
"app.modal.install-to-play.shared-instance-content": {
"message": "Shared instance content"
},
"app.modal.install-to-play.shared-server-instance": {
"message": "Shared server instance"
},
"app.modal.install-to-play.trust-warning-description": {
"message": "A shared instance will install files on your computer and may include content not from Modrinth."
},
"app.modal.install-to-play.trust-warning-header": {
"message": "Do you trust this user?"
},
"app.modal.install-to-play.view-contents": {
"message": "View contents"
},
"app.modal.update-to-play.header": {
"message": "Update to play"
},
"app.modal.update-to-play.shared-instance-added-label": {
"message": "Added"
},
"app.modal.update-to-play.shared-instance-removed-label": {
"message": "Removed"
},
"app.modal.update-to-play.update-required": {
"message": "Update required"
},
"app.modal.update-to-play.update-required-description": {
"message": "An update is required to play {name}. Please update to the latest version to launch the game."
"message": "An update is required to play {name}. Please update to latest version to launch the game."
},
"app.project.install-button.already-installed": {
"message": "This project is already installed"
},
"app.project.install-button.switch-version": {
"message": "Switch version"
},
"app.project.install-context.back-to-browse": {
"message": "Back to discover"
},
"app.project.install-context.install-content-to-instance": {
"message": "Install content to instance"
},
"app.project.version.all-versions": {
"message": "All versions"
},
"app.project.version.download-in-browser": {
"message": "Download in browser"
},
"app.project.version.installing": {
"message": "Installing"
},
"app.project.versions.already-installed": {
"message": "Already installed"
},
"app.settings.developer-mode-enabled": {
"message": "Developer mode enabled."
},
@@ -542,6 +740,9 @@
"app.skins.section.builders-and-biomes": {
"message": "Builders & Biomes"
},
"app.skins.section.chaos-cubed": {
"message": "Chaos Cubed"
},
"app.skins.section.chase-the-skies": {
"message": "Chase the Skies"
},
@@ -641,6 +842,39 @@
"app.world.world-item.players-online": {
"message": "{count} online"
},
"content.shared-instance.change-version-body": {
"message": "Changing the version only changes your local copy. Future shared instance updates may restore or change it again."
},
"content.shared-instance.delete-bulk-body": {
"message": "Some selected projects are part of the shared instance. Deleting them only changes your local copy, and future shared instance updates may restore or change them again."
},
"content.shared-instance.delete-button": {
"message": "Delete anyway"
},
"content.shared-instance.delete-many-button": {
"message": "Delete {count, number} projects anyway"
},
"content.shared-instance.delete-single-body": {
"message": "Deleting it only changes your local copy. Future shared instance updates may restore or change it again."
},
"content.shared-instance.disable-bulk-body": {
"message": "Some selected projects are part of the shared instance. Disabling them only changes your local copy, and future shared instance updates may re-enable, restore, or change them again."
},
"content.shared-instance.disable-button": {
"message": "Disable anyway"
},
"content.shared-instance.disable-many-button": {
"message": "Disable {count, number} projects anyway"
},
"content.shared-instance.disable-single-body": {
"message": "Disabling it only changes your local copy. Future shared instance updates may re-enable, restore, or change it again."
},
"content.shared-instance.unlink-body": {
"message": "Unlinking only changes your local copy. Future shared instance updates may restore or change it again."
},
"content.shared-instance.warning-header": {
"message": "This is part of the shared instance"
},
"friends.action.add-friend": {
"message": "Add a friend"
},
@@ -704,6 +938,48 @@
"friends.sign-in-to-add-friends": {
"message": "<link>Sign in to a Modrinth account</link> to add friends and see what they're playing!"
},
"installation-settings.shared-instance.linked-title": {
"message": "Linked shared instance"
},
"installation-settings.shared-instance.title": {
"message": "Shared instance"
},
"installation-settings.shared-instance.unlink-button": {
"message": "Unlink shared instance"
},
"installation-settings.shared-instance.unlink-description": {
"message": "Disconnect this local instance from future shared updates."
},
"installation-settings.shared-instance.unlinking-button": {
"message": "Unlinking..."
},
"installation-settings.shared-instance.unpublish-button": {
"message": "Unpublish shared instance"
},
"installation-settings.shared-instance.unpublish-description": {
"message": "Stop sharing this instance and remove it from Modrinth."
},
"installation-settings.shared-instance.unpublishing-button": {
"message": "Unpublishing..."
},
"installation-settings.unlink-shared-instance.modal.admonition-body": {
"message": "This only affects your local instance. Your installed content will stay on this device, and the shared instance and other people using it will not be affected."
},
"installation-settings.unlink-shared-instance.modal.admonition-header": {
"message": "Unlinking shared instance"
},
"installation-settings.unlink-shared-instance.modal.header": {
"message": "Unlink shared instance"
},
"installation-settings.unpublish-shared-instance.modal.admonition-body": {
"message": "This deletes the shared instance from Modrinth's servers. People using it in the Modrinth App will stop receiving updates, but your local instance and its content will stay on this device."
},
"installation-settings.unpublish-shared-instance.modal.admonition-header": {
"message": "Unpublishing shared instance"
},
"installation-settings.unpublish-shared-instance.modal.header": {
"message": "Unpublish shared instance"
},
"instance.add-server.add-and-play": {
"message": "Add and play"
},
@@ -950,6 +1226,30 @@
"instance.settings.tabs.window.width.enter": {
"message": "Enter width..."
},
"instance.shared-instance.error.title": {
"message": "Something has gone wrong"
},
"instance.shared-instance.owner-tooltip": {
"message": "This instance's content is being shared to other users."
},
"instance.shared-instance.tooltip": {
"message": "This instance's content is being managed by someone else."
},
"instance.shared-instance.unavailable.access-revoked-text-v2": {
"message": "Your access to the shared instance was revoked. Your local instance is still available, but it is no longer linked and won't receive updates."
},
"instance.shared-instance.unavailable.deleted-text-v2": {
"message": "The shared instance was deleted. Your local instance is still available, but it is no longer linked and won't receive updates."
},
"instance.shared-instance.unavailable.manager-fallback": {
"message": "the instance manager"
},
"instance.shared-instance.unavailable.text-v2": {
"message": "Your local instance is still available, but it is no longer linked and won't receive updates."
},
"instance.shared-instance.unavailable.title": {
"message": "Shared instance no longer available"
},
"instance.worlds.a_minecraft_server": {
"message": "A Minecraft Server"
},
@@ -1013,6 +1313,24 @@
"minecraft-account.sign-in": {
"message": "Sign in to Minecraft"
},
"modal.modrinth-account-required.create-account-button": {
"message": "Create an account"
},
"modal.modrinth-account-required.description": {
"message": "You'll need to sign into your Modrinth account before you can use this feature."
},
"modal.modrinth-account-required.header": {
"message": "Account required"
},
"modal.modrinth-account-required.sign-in-button": {
"message": "Sign in to Modrinth"
},
"modal.modrinth-account-required.sign-in-heading": {
"message": "Sign in to a Modrinth account"
},
"modal.modrinth-account-required.support-prompt": {
"message": "Having trouble signing in? <support>Get support</support>"
},
"search.filter.locked.instance": {
"message": "Provided by the instance"
},
+111 -6
View File
@@ -11,6 +11,21 @@
"app.action-bar.hide-more-running-instances": {
"message": "Ocultar más instancias en ejecución"
},
"app.action-bar.install.dismiss": {
"message": "Descartar"
},
"app.action-bar.install.open-instance": {
"message": "Abrir instancia"
},
"app.action-bar.install.retry": {
"message": "Reintentar"
},
"app.action-bar.install.unknown-instance": {
"message": "Instancia desconocida"
},
"app.action-bar.installs": {
"message": "Instalaciones"
},
"app.action-bar.make-primary-instance": {
"message": "Hacer instancia principal"
},
@@ -101,6 +116,12 @@
"app.appearance-settings.show-play-time.title": {
"message": "Mostrar tiempo de juego"
},
"app.appearance-settings.skip-non-essential-warnings.description": {
"message": "Omite automáticamente las confirmaciones de bajo riesgo, como las instalaciones duplicadas de paquetes de mods, la eliminación de contenido normal, las actualizaciones masivas, la desvinculación de paquetes de mods y las solicitudes de reparación. Las advertencias peligrosas seguirán mostrándose."
},
"app.appearance-settings.skip-non-essential-warnings.title": {
"message": "Omitir advertencias que no sean esenciales"
},
"app.appearance-settings.toggle-sidebar.description": {
"message": "Permite mostrar u ocultar la barra lateral."
},
@@ -170,24 +191,72 @@
"app.export-modal.header": {
"message": "Exportar modpack"
},
"app.export-modal.include-file-accessibility-label": {
"message": "¿Incluir \"{file}\"?"
},
"app.export-modal.modpack-name-label": {
"message": "Nombre del modpack"
},
"app.export-modal.modpack-name-placeholder": {
"message": "Nombre del modpack"
},
"app.export-modal.select-files-label": {
"message": "Configura que archivos incluir en esta exportación"
},
"app.export-modal.version-number-label": {
"message": "Número de la versión"
},
"app.export-modal.version-number-placeholder": {
"message": "1.0.0"
},
"app.install.phase.downloading_content": {
"message": "Descargando contenido"
},
"app.install.phase.downloading_minecraft": {
"message": "Descargando Minecraft"
},
"app.install.phase.downloading_pack_file": {
"message": "Descargando el archivo del paquete"
},
"app.install.phase.extracting_overrides": {
"message": "Extracción de anulaciones"
},
"app.install.phase.finalizing": {
"message": "Finalizando"
},
"app.install.phase.preparing_instance": {
"message": "Preparando la instancia"
},
"app.install.phase.preparing_java": {
"message": "Preparando Java"
},
"app.install.phase.preparing_java.downloading": {
"message": "Descargando Java {version}"
},
"app.install.phase.preparing_java.extracting": {
"message": "Descomprimiendo Java {version}"
},
"app.install.phase.preparing_java.fetching-metadata": {
"message": "Obteniendo Java {version}"
},
"app.install.phase.preparing_java.resolving": {
"message": "Preparando Java {version}"
},
"app.install.phase.preparing_java.validating": {
"message": "Validación de Java {version}"
},
"app.install.phase.reading_pack_manifest": {
"message": "Leer el manifiesto del paquete"
},
"app.install.phase.resolving_loader": {
"message": "Resolviendo el cargador"
},
"app.install.phase.resolving_minecraft": {
"message": "Resolviendo Minecraft"
},
"app.install.phase.resolving_pack": {
"message": "Resolviendo el contenido"
},
"app.install.phase.rolling_back": {
"message": "Revertir"
},
"app.install.phase.running_loader_processors": {
"message": "Ejecutando los procesadores del cargador"
},
"app.instance.confirm-delete.admonition-body": {
"message": "Todos los datos de tu instancia se eliminarán permanentemente, incluidos tus mundos, configuraciones y todo el contenido instalado."
},
@@ -212,6 +281,15 @@
"app.instance.modpack-already-installed.instance": {
"message": "Instancia"
},
"app.instance.mods.bulk-update.downloading-projects": {
"message": "Descargando {current, number}/{total, number} proyectos..."
},
"app.instance.mods.bulk-update.finishing": {
"message": "Finalizando la actualización..."
},
"app.instance.mods.bulk-update.resolving-versions": {
"message": "Resolviendo versiones..."
},
"app.instance.mods.content-type-project": {
"message": "proyecto"
},
@@ -236,6 +314,18 @@
"app.instance.worlds.browse-servers": {
"message": "Explorar servidores"
},
"app.instance.worlds.delete-world-modal.delete-button": {
"message": "Eliminar mundo"
},
"app.instance.worlds.delete-world-modal.title": {
"message": "Eliminar mundo"
},
"app.instance.worlds.delete-world-modal.warning-body": {
"message": "Este mundo se eliminará de forma permanente de esta instancia. Esta acción no se puede deshacer."
},
"app.instance.worlds.delete-world-modal.warning-header": {
"message": "Eliminando {name}"
},
"app.instance.worlds.filter-modded": {
"message": "Con mods"
},
@@ -254,6 +344,18 @@
"app.instance.worlds.no-worlds-heading": {
"message": "No hay servidores ni mundos añadidos"
},
"app.instance.worlds.remove-server-modal.remove-button": {
"message": "Eliminar servidor"
},
"app.instance.worlds.remove-server-modal.title": {
"message": "Eliminar servidor"
},
"app.instance.worlds.remove-server-modal.warning-body": {
"message": "Este servidor se eliminará de tu lista de servidores y de la lista de servidores del juego. Puedes volver a agregarlo más adelante si conoces la dirección."
},
"app.instance.worlds.remove-server-modal.warning-header": {
"message": "Se está eliminando {name}"
},
"app.instance.worlds.search-worlds-placeholder": {
"message": "Buscar en {count} mundos..."
},
@@ -839,6 +941,9 @@
"instance.worlds.copy_address": {
"message": "Copiar dirección"
},
"instance.worlds.create_shortcut": {
"message": "Crear un acceso directo"
},
"instance.worlds.dont_show_on_home": {
"message": "No mostrar en la página de inicio"
},
@@ -11,6 +11,9 @@
"app.action-bar.hide-more-running-instances": {
"message": "Ocultar más instancias en ejecución"
},
"app.action-bar.install.dismiss": {
"message": "Descartar"
},
"app.action-bar.make-primary-instance": {
"message": "Establecer como instancia principal"
},
@@ -170,18 +173,12 @@
"app.export-modal.header": {
"message": "Exportar modpack"
},
"app.export-modal.include-file-accessibility-label": {
"message": "¿Incluir \"{file}\"?"
},
"app.export-modal.modpack-name-label": {
"message": "Nombre del modpack"
},
"app.export-modal.modpack-name-placeholder": {
"message": "Nombre del modpack"
},
"app.export-modal.select-files-label": {
"message": "Configura que archivos se incluirán en esta instancia"
},
"app.export-modal.version-number-label": {
"message": "Número de versión"
},
+273 -6
View File
@@ -2,12 +2,30 @@
"app.action-bar.downloading-java": {
"message": "Ladataan Java-versiota {version}"
},
"app.action-bar.downloading-update": {
"message": "Ladataan päivitystä"
},
"app.action-bar.downloads": {
"message": "Lataukset"
},
"app.action-bar.hide-more-running-instances": {
"message": "Piilota enemmän käynnissä olevia instansseja"
},
"app.action-bar.install.dismiss": {
"message": "Hylkää"
},
"app.action-bar.install.open-instance": {
"message": "Avaa instanssi"
},
"app.action-bar.install.retry": {
"message": "Yritä uudelleen"
},
"app.action-bar.install.unknown-instance": {
"message": "Tuntematon instanssi"
},
"app.action-bar.installs": {
"message": "Asennukset"
},
"app.action-bar.make-primary-instance": {
"message": "Aseta ensisijaiseksi instanssiksi"
},
@@ -20,12 +38,18 @@
"app.action-bar.primary-instance": {
"message": "Ensisijainen instanssi"
},
"app.action-bar.reload-to-update": {
"message": "Lataa uudelleen päivittääksesi"
},
"app.action-bar.show-more-running-instances": {
"message": "Näytä enemmän käynnissä olevia instansseja"
},
"app.action-bar.stop-instance": {
"message": "Pysäytä instanssi"
},
"app.action-bar.update": {
"message": "Päivitä"
},
"app.action-bar.view-active-downloads": {
"message": "Näytä aktiiviset lataukset"
},
@@ -86,6 +110,18 @@
"app.appearance-settings.select-option": {
"message": "Valitse vaihtoehto"
},
"app.appearance-settings.show-play-time.description": {
"message": "Näyttää, kuinka paljon aikaa olet käyttänyt instanssin pelaamiseen."
},
"app.appearance-settings.show-play-time.title": {
"message": "Näyttää peliaika"
},
"app.appearance-settings.skip-non-essential-warnings.description": {
"message": "Ohittaa automaattisesti vähäriskiset vahvistukset, kuten modpackien kaksoisasennukset, tavallisen sisällön poistamisen, massapäivitykset, modpackien linkityksen poistamisen ja korjauskehotteet. Vaaralliset varoitukset näytetään silti."
},
"app.appearance-settings.skip-non-essential-warnings.title": {
"message": "Ohita ei olennaiset varoitukset"
},
"app.appearance-settings.toggle-sidebar.description": {
"message": "Laittaa päälle mahdollisuuden kytkeä sivupalkkia."
},
@@ -155,24 +191,72 @@
"app.export-modal.header": {
"message": "Vie modipaketti"
},
"app.export-modal.include-file-accessibility-label": {
"message": "Sisällytä \"{file}\"?"
},
"app.export-modal.modpack-name-label": {
"message": "Modipaketin nimi"
},
"app.export-modal.modpack-name-placeholder": {
"message": "Modipaketin nimi"
},
"app.export-modal.select-files-label": {
"message": "Muuta mitkä tiedostot sisältyvät tähän vientiin"
},
"app.export-modal.version-number-label": {
"message": "Versio numero"
},
"app.export-modal.version-number-placeholder": {
"message": "1.0.0"
},
"app.install.phase.downloading_content": {
"message": "Ladataan sisältöä"
},
"app.install.phase.downloading_minecraft": {
"message": "Ladataan Minecraftia"
},
"app.install.phase.downloading_pack_file": {
"message": "Ladataan paketti tiedostoja"
},
"app.install.phase.extracting_overrides": {
"message": "Puretaan ohituksia"
},
"app.install.phase.finalizing": {
"message": "Viimeistellään"
},
"app.install.phase.preparing_instance": {
"message": "Valmistellaan instanssia"
},
"app.install.phase.preparing_java": {
"message": "Valmistellaan Javaa"
},
"app.install.phase.preparing_java.downloading": {
"message": "Ladataan Java {version}"
},
"app.install.phase.preparing_java.extracting": {
"message": "Puretaan Java {version}"
},
"app.install.phase.preparing_java.fetching-metadata": {
"message": "Haetaan Java {version}"
},
"app.install.phase.preparing_java.resolving": {
"message": "Valmistellaan Java {version}"
},
"app.install.phase.preparing_java.validating": {
"message": "Tarkistetaan Java {version}"
},
"app.install.phase.reading_pack_manifest": {
"message": "Luetaan paketin manifestia"
},
"app.install.phase.resolving_loader": {
"message": "Ratkaistaan lataajaa"
},
"app.install.phase.resolving_minecraft": {
"message": "Ratkaistaan Minecraftia"
},
"app.install.phase.resolving_pack": {
"message": "Ratkaistaan sisältöä"
},
"app.install.phase.rolling_back": {
"message": "Palautetaan muutoksia"
},
"app.install.phase.running_loader_processors": {
"message": "Suoritetaan lataajan käsittelijöitä"
},
"app.instance.confirm-delete.admonition-body": {
"message": "Kaikki sinun instanssisi data poistetaan pysyvästi, sisältäen maailmasi, konfiguraatiosi ja asennetun sisällön."
},
@@ -197,6 +281,15 @@
"app.instance.modpack-already-installed.instance": {
"message": "Instanssi"
},
"app.instance.mods.bulk-update.downloading-projects": {
"message": "Ladataan {current, number}/{total, number} projekteja..."
},
"app.instance.mods.bulk-update.finishing": {
"message": "Viimeistellään päivitystä..."
},
"app.instance.mods.bulk-update.resolving-versions": {
"message": "Selvitetään versioita..."
},
"app.instance.mods.content-type-project": {
"message": "projekti"
},
@@ -221,6 +314,18 @@
"app.instance.worlds.browse-servers": {
"message": "Selaa palvelimia"
},
"app.instance.worlds.delete-world-modal.delete-button": {
"message": "Poista maailma"
},
"app.instance.worlds.delete-world-modal.title": {
"message": "Poista maailma"
},
"app.instance.worlds.delete-world-modal.warning-body": {
"message": "Tämä maailma poistetaan pysyvästi tästä instanssista. Tätä toimintoa ei voi kumota."
},
"app.instance.worlds.delete-world-modal.warning-header": {
"message": "Poistetaan {name}"
},
"app.instance.worlds.filter-modded": {
"message": "Modattu"
},
@@ -239,6 +344,18 @@
"app.instance.worlds.no-worlds-heading": {
"message": "Ei palvelimia tai maailmoja lisättynä"
},
"app.instance.worlds.remove-server-modal.remove-button": {
"message": "Poista palvelin"
},
"app.instance.worlds.remove-server-modal.title": {
"message": "Poista palvelin"
},
"app.instance.worlds.remove-server-modal.warning-body": {
"message": "Tämä palvelin poistetaan palvelinluettelostasi ja pelin sisäisestä palvelinluettelosta. Voit lisätä sen myöhemmin uudelleen, jos tiedät osoitteen."
},
"app.instance.worlds.remove-server-modal.warning-header": {
"message": "Poistetaan {name}"
},
"app.instance.worlds.search-worlds-placeholder": {
"message": "Hae {count} maailmasta..."
},
@@ -311,15 +428,147 @@
"app.settings.tabs.resource-management": {
"message": "Resurssien hallinta"
},
"app.skins.add-button": {
"message": "Lisää ulkoasu"
},
"app.skins.add-button.drag-and-drop": {
"message": "Raahaa ja pudota"
},
"app.skins.apply-button": {
"message": "Käytä"
},
"app.skins.delete-button": {
"message": "Poista ulkoasu"
},
"app.skins.delete-modal.description": {
"message": "Tämä poistaa valitun skinin pysyvästi. Tätä toimintoa ei voi kumota."
},
"app.skins.delete-modal.title": {
"message": "Haluatko varmasti poistaa ulkoasun?"
},
"app.skins.dropped-file-error.text": {
"message": "Tiedoston lukeminen epäonnistui."
},
"app.skins.dropped-file-error.title": {
"message": "Virhe prosessoidessa tiedostoa"
},
"app.skins.edit-button": {
"message": "Muokkaa ulkoasua"
},
"app.skins.modal.add-skin-button": {
"message": "Lisää ulkoasu"
},
"app.skins.modal.add-title": {
"message": "Lisätään ulkoasua"
},
"app.skins.modal.arm-style-section": {
"message": "Käsien tyyli"
},
"app.skins.modal.arm-style-slim": {
"message": "Ohut"
},
"app.skins.modal.arm-style-wide": {
"message": "Paksu"
},
"app.skins.modal.cape-fallback-name": {
"message": "Viitta"
},
"app.skins.modal.cape-section": {
"message": "Viitta"
},
"app.skins.modal.edit-title": {
"message": "Muokataan ulkoasua"
},
"app.skins.modal.make-edit-first-tooltip": {
"message": "Tee muokkaus ulkoasuun ensin!"
},
"app.skins.modal.no-cape-tooltip": {
"message": "Ei viittaa"
},
"app.skins.modal.none-cape-option": {
"message": "Ei mitään"
},
"app.skins.modal.replace-texture-button": {
"message": "Korvaa tekstuuri"
},
"app.skins.modal.save-skin-button": {
"message": "Tallenna ulkoasu"
},
"app.skins.modal.saving-tooltip": {
"message": "Tallennetaan..."
},
"app.skins.modal.texture-section": {
"message": "Tekstuuri"
},
"app.skins.modal.upload-skin-first-tooltip": {
"message": "Lataa ulkoasu ensin!"
},
"app.skins.preview.edit-button": {
"message": "Muokkaa ulkoasua"
},
"app.skins.previewing-badge": {
"message": "Esikatsellaan"
},
"app.skins.rate-limit.text": {
"message": "Vaihdat ulkoasua liian usein. Mojangin palvelimet ovat estäneet uudet pyynnöt väliaikaisesti. Yritä hetken kuluttua uudelleen."
},
"app.skins.rate-limit.title": {
"message": "Hidasta vähän!"
},
"app.skins.reorder-error.text": {
"message": "Ulkoasujen järjestystä ei voitu tallentaa."
},
"app.skins.reorder-error.title": {
"message": "Skinien järjestyksen muuttaminen epäonnistui"
},
"app.skins.section.builders-and-biomes": {
"message": "Builders & Biomes"
},
"app.skins.section.chase-the-skies": {
"message": "Chase the Skies"
},
"app.skins.section.default-skins": {
"message": "Oletus ulkoasut"
},
"app.skins.section.minecon-earth-2017": {
"message": "MINECON Earth 2017"
},
"app.skins.section.modrinth": {
"message": "Modrinth"
},
"app.skins.section.modrinth-pride": {
"message": "Modrinth Pride"
},
"app.skins.section.modrinth-pride.tooltip": {
"message": "Sait nämä skinit lahjoittamalla Modrinthin Pride-keräykseen Pride-kuukauden aikana."
},
"app.skins.section.mounts-of-mayhem": {
"message": "Sekasorron ratsut"
},
"app.skins.section.saved-skins": {
"message": "Tallennetut ulkoasut"
},
"app.skins.section.striding-hero": {
"message": "Striding Hero"
},
"app.skins.section.the-copper-age": {
"message": "The Copper Age"
},
"app.skins.section.the-garden-awakens": {
"message": "The Garden Awakens"
},
"app.skins.section.tiny-takeover": {
"message": "Pienten vallankumous"
},
"app.skins.sign-in.button": {
"message": "Kirjaudu sisään"
},
"app.skins.sign-in.description": {
"message": "Kirjaudu Minecraft-tilillesi käyttääksesi Modrinth-sovelluksen ulkoasujen hallintaominaisuuksia."
},
"app.skins.sign-in.rinthbot-alt": {
"message": "Innostunut Modrinth Botti"
},
"app.skins.sign-in.title": {
"message": "Kirjaudu sisään"
},
@@ -545,15 +794,30 @@
"instance.settings.tabs.general.name": {
"message": "Nimi"
},
"instance.settings.tabs.general.update-channel": {
"message": "Päivitys kanava"
},
"instance.settings.tabs.general.update-channel.alpha": {
"message": "Alfa"
},
"instance.settings.tabs.general.update-channel.alpha.description": {
"message": "Release-, beta- ja alpha-versiot näytetään saatavilla olevina päivityksinä."
},
"instance.settings.tabs.general.update-channel.beta": {
"message": "Beta"
},
"instance.settings.tabs.general.update-channel.beta.description": {
"message": "Release- ja beta-versiot näytetään saatavilla olevina päivityksinä."
},
"instance.settings.tabs.general.update-channel.release": {
"message": "Julkaisu"
},
"instance.settings.tabs.general.update-channel.release.description": {
"message": "Vain release-versiot näytetään saatavilla olevina päivityksinä."
},
"instance.settings.tabs.general.update-channel.select": {
"message": "Valitse päivitys kanava"
},
"instance.settings.tabs.hooks": {
"message": "Aloitustoiminnot"
},
@@ -677,6 +941,9 @@
"instance.worlds.copy_address": {
"message": "Kopioi osoite"
},
"instance.worlds.create_shortcut": {
"message": "Luo pikakuvake"
},
"instance.worlds.dont_show_on_home": {
"message": "Älä näytä koti näkymässä"
},
@@ -164,18 +164,12 @@
"app.export-modal.header": {
"message": "Iluwas ang modpack"
},
"app.export-modal.include-file-accessibility-label": {
"message": "Salihin ang \"{file}\"?"
},
"app.export-modal.modpack-name-label": {
"message": "Pangalan ng Modpack"
},
"app.export-modal.modpack-name-placeholder": {
"message": "Pangalan ng modpack"
},
"app.export-modal.select-files-label": {
"message": "Isaayos kung anong mga file ang isasali sa pagluwas"
},
"app.export-modal.version-number-label": {
"message": "Numero ng bersiyon"
},
+111 -6
View File
@@ -11,6 +11,21 @@
"app.action-bar.hide-more-running-instances": {
"message": "Masquer plus d'instances en exécution"
},
"app.action-bar.install.dismiss": {
"message": "Rejeter"
},
"app.action-bar.install.open-instance": {
"message": "Ouvrir l'instance"
},
"app.action-bar.install.retry": {
"message": "Réessayer"
},
"app.action-bar.install.unknown-instance": {
"message": "Instance inconnue"
},
"app.action-bar.installs": {
"message": "Installations"
},
"app.action-bar.make-primary-instance": {
"message": "Définir en tante qu'instance première"
},
@@ -101,6 +116,12 @@
"app.appearance-settings.show-play-time.title": {
"message": "Afficher le temps de jeu"
},
"app.appearance-settings.skip-non-essential-warnings.description": {
"message": "Ignore automatiquement les confirmations à faible risque, comme l'installation de modpacks en double, la suppression de contenu standard, les mises à jour groupées, la dissociation des modpacks et les demandes de réparation. Les avertissements concernant les actions dangereuses continueront d'être affichés."
},
"app.appearance-settings.skip-non-essential-warnings.title": {
"message": "Cacher les alertes non essentielles"
},
"app.appearance-settings.toggle-sidebar.description": {
"message": "Permet d'ouvrir ou de fermer la barre latérale."
},
@@ -170,24 +191,72 @@
"app.export-modal.header": {
"message": "Exporter le modpack"
},
"app.export-modal.include-file-accessibility-label": {
"message": "Inclure « {file} » ?"
},
"app.export-modal.modpack-name-label": {
"message": "Nom du modpack"
},
"app.export-modal.modpack-name-placeholder": {
"message": "Nom du modpack"
},
"app.export-modal.select-files-label": {
"message": "Configurez quels fichiers sont inclus dans cette exportation"
},
"app.export-modal.version-number-label": {
"message": "Numéro de version"
},
"app.export-modal.version-number-placeholder": {
"message": "1.0.0"
},
"app.install.phase.downloading_content": {
"message": "Téléchargement du contenu"
},
"app.install.phase.downloading_minecraft": {
"message": "Téléchargement de Minecraft"
},
"app.install.phase.downloading_pack_file": {
"message": "Téléchargement du fichier de pack"
},
"app.install.phase.extracting_overrides": {
"message": "Extraction des fichiers personnalisés"
},
"app.install.phase.finalizing": {
"message": "Finalisation"
},
"app.install.phase.preparing_instance": {
"message": "Préparation de l'instance"
},
"app.install.phase.preparing_java": {
"message": "Préparation de Java"
},
"app.install.phase.preparing_java.downloading": {
"message": "Téléchargement de Java {version}"
},
"app.install.phase.preparing_java.extracting": {
"message": "Extraction de Java {version}"
},
"app.install.phase.preparing_java.fetching-metadata": {
"message": "Récupération de Java {version}"
},
"app.install.phase.preparing_java.resolving": {
"message": "Préparation de Java {version}"
},
"app.install.phase.preparing_java.validating": {
"message": "Validation de Java {version}"
},
"app.install.phase.reading_pack_manifest": {
"message": "Lecture du manifeste du pack"
},
"app.install.phase.resolving_loader": {
"message": "Résolution du loader"
},
"app.install.phase.resolving_minecraft": {
"message": "Résolution de Minecraft"
},
"app.install.phase.resolving_pack": {
"message": "Résolution du contenu"
},
"app.install.phase.rolling_back": {
"message": "Annulation"
},
"app.install.phase.running_loader_processors": {
"message": "Exécution des processeurs du chargeur"
},
"app.instance.confirm-delete.admonition-body": {
"message": "Toutes les données pour votre instance seront supprimées à jamais, y compris vos mondes, vos configurations, et le contenu installé."
},
@@ -212,6 +281,15 @@
"app.instance.modpack-already-installed.instance": {
"message": "Instance"
},
"app.instance.mods.bulk-update.downloading-projects": {
"message": "Téléchargement de {current, number}/{total, number} projets..."
},
"app.instance.mods.bulk-update.finishing": {
"message": "Finalisation de la mise à jour..."
},
"app.instance.mods.bulk-update.resolving-versions": {
"message": "Résolution des versions..."
},
"app.instance.mods.content-type-project": {
"message": "projet"
},
@@ -236,6 +314,18 @@
"app.instance.worlds.browse-servers": {
"message": "Parcourir des serveurs"
},
"app.instance.worlds.delete-world-modal.delete-button": {
"message": "Supprimer le monde"
},
"app.instance.worlds.delete-world-modal.title": {
"message": "Supprimer le monde"
},
"app.instance.worlds.delete-world-modal.warning-body": {
"message": "Ce monde sera définitivement supprimé de cette instance. Cette action est irréversible."
},
"app.instance.worlds.delete-world-modal.warning-header": {
"message": "Suppression de {name}"
},
"app.instance.worlds.filter-modded": {
"message": "Moddé"
},
@@ -254,6 +344,18 @@
"app.instance.worlds.no-worlds-heading": {
"message": "Aucun serveur ou monde ajoutés"
},
"app.instance.worlds.remove-server-modal.remove-button": {
"message": "Enlever le serveur"
},
"app.instance.worlds.remove-server-modal.title": {
"message": "Enlever le serveur"
},
"app.instance.worlds.remove-server-modal.warning-body": {
"message": "Ce serveur sera enlevé de votre liste de serveur et de la liste de serveur en jeu. Vous pourrez le rajouter plus tard si vous connaissez son adresse."
},
"app.instance.worlds.remove-server-modal.warning-header": {
"message": "Retrait de {name}"
},
"app.instance.worlds.search-worlds-placeholder": {
"message": "Rechercher {count} mondes..."
},
@@ -836,6 +938,9 @@
"instance.worlds.copy_address": {
"message": "Copier l'adresse"
},
"instance.worlds.create_shortcut": {
"message": "Créer un raccourci"
},
"instance.worlds.dont_show_on_home": {
"message": "Ne pas montrer dans l'Accueil"
},
@@ -11,6 +11,12 @@
"app.action-bar.hide-more-running-instances": {
"message": "További futó példányok elrejtése"
},
"app.action-bar.install.retry": {
"message": "Újra"
},
"app.action-bar.install.unknown-instance": {
"message": "Ismeretlen profil"
},
"app.action-bar.make-primary-instance": {
"message": "Beállítás elsődleges példányként"
},
@@ -170,24 +176,21 @@
"app.export-modal.header": {
"message": "Modcsomag exportálása"
},
"app.export-modal.include-file-accessibility-label": {
"message": "Tartalmaz „{file}” fájlokat?"
},
"app.export-modal.modpack-name-label": {
"message": "A modcsomag neve"
},
"app.export-modal.modpack-name-placeholder": {
"message": "A modcsomag neve"
},
"app.export-modal.select-files-label": {
"message": "Állítsd be, hogy mely fájlok kerüljenek bele az exportba"
},
"app.export-modal.version-number-label": {
"message": "Verziószám"
},
"app.export-modal.version-number-placeholder": {
"message": "1.0.0"
},
"app.install.phase.resolving_loader": {
"message": ""
},
"app.instance.confirm-delete.admonition-body": {
"message": "Az profilodhoz tartozó összes adat véglegesen törlődik, beleértve a világokat, a beállításokat és az összes telepített tartalmat."
},
@@ -170,18 +170,12 @@
"app.export-modal.header": {
"message": "Ekspor paket mod"
},
"app.export-modal.include-file-accessibility-label": {
"message": "Sertakan \"{file}\"?"
},
"app.export-modal.modpack-name-label": {
"message": "Nama Paket Mod"
},
"app.export-modal.modpack-name-placeholder": {
"message": "Nama paket mod"
},
"app.export-modal.select-files-label": {
"message": "Konfigurasikan berkas mana yang disertakan dalam pengeksporan ini"
},
"app.export-modal.version-number-label": {
"message": "Nomor versi"
},
+111 -6
View File
@@ -11,6 +11,21 @@
"app.action-bar.hide-more-running-instances": {
"message": "Mostra meno istanze in esecuzione"
},
"app.action-bar.install.dismiss": {
"message": "Ignora"
},
"app.action-bar.install.open-instance": {
"message": "Apri istanza"
},
"app.action-bar.install.retry": {
"message": "Riprova"
},
"app.action-bar.install.unknown-instance": {
"message": "Istanza sconosciuta"
},
"app.action-bar.installs": {
"message": "Installazioni"
},
"app.action-bar.make-primary-instance": {
"message": "Rendi primaria"
},
@@ -101,6 +116,12 @@
"app.appearance-settings.show-play-time.title": {
"message": "Mostra le ore di gioco"
},
"app.appearance-settings.skip-non-essential-warnings.description": {
"message": "Salta automaticamente le conferme a basso rischio, come l'installazione di pacchetti di mod duplicati, la normale eliminazione di contenuti, gli aggiornamenti simultanei, lo scollegare i pacchetti di mod e le richieste di riparazione. Le avvertenze verranno comunque mostrate."
},
"app.appearance-settings.skip-non-essential-warnings.title": {
"message": "Salta le conferme poco importanti"
},
"app.appearance-settings.toggle-sidebar.description": {
"message": "Mostra o nascondi la barra laterale."
},
@@ -170,24 +191,72 @@
"app.export-modal.header": {
"message": "Esporta pacchetto"
},
"app.export-modal.include-file-accessibility-label": {
"message": "Includere \"{file}\" nell'esportazione?"
},
"app.export-modal.modpack-name-label": {
"message": "Nome del pacchetto"
},
"app.export-modal.modpack-name-placeholder": {
"message": "Nome del pacchetto"
},
"app.export-modal.select-files-label": {
"message": "Seleziona i file da includere in questa esportazione"
},
"app.export-modal.version-number-label": {
"message": "Numero di versione"
},
"app.export-modal.version-number-placeholder": {
"message": "1.0.0"
},
"app.install.phase.downloading_content": {
"message": "Download dei contenuti"
},
"app.install.phase.downloading_minecraft": {
"message": "Download di Minecraft"
},
"app.install.phase.downloading_pack_file": {
"message": "Download del pacchetto"
},
"app.install.phase.extracting_overrides": {
"message": "Estrazione degli override"
},
"app.install.phase.finalizing": {
"message": "Finalizzazione"
},
"app.install.phase.preparing_instance": {
"message": "Preparazione dell'istanza"
},
"app.install.phase.preparing_java": {
"message": "Preparazione di Java"
},
"app.install.phase.preparing_java.downloading": {
"message": "Download di Java {version}"
},
"app.install.phase.preparing_java.extracting": {
"message": "Estrazione di Java {version}"
},
"app.install.phase.preparing_java.fetching-metadata": {
"message": "Recupero di Java {version}"
},
"app.install.phase.preparing_java.resolving": {
"message": "Preparazione di Java {version}"
},
"app.install.phase.preparing_java.validating": {
"message": "Validazione di Java {version}"
},
"app.install.phase.reading_pack_manifest": {
"message": "Lettura del manifesto"
},
"app.install.phase.resolving_loader": {
"message": "Risoluzione del loader"
},
"app.install.phase.resolving_minecraft": {
"message": "Risoluzione di Minecraft"
},
"app.install.phase.resolving_pack": {
"message": "Risoluzione dei contenuti"
},
"app.install.phase.rolling_back": {
"message": "Tornando indietro"
},
"app.install.phase.running_loader_processors": {
"message": "Esecuzione dei processori del loader"
},
"app.instance.confirm-delete.admonition-body": {
"message": "Tutti i dati della tua istanza verranno eliminati permanentemente, inclusi i tuoi mondi, configurazioni e tutti i contenuti installati."
},
@@ -212,6 +281,15 @@
"app.instance.modpack-already-installed.instance": {
"message": "Istanza"
},
"app.instance.mods.bulk-update.downloading-projects": {
"message": "{current, number}/{total, number} progetti scaricati..."
},
"app.instance.mods.bulk-update.finishing": {
"message": "Finalizzando l'aggiornamento..."
},
"app.instance.mods.bulk-update.resolving-versions": {
"message": "Risolvendo le versioni..."
},
"app.instance.mods.content-type-project": {
"message": "progetto"
},
@@ -233,6 +311,18 @@
"app.instance.worlds.browse-servers": {
"message": "Esplora i server"
},
"app.instance.worlds.delete-world-modal.delete-button": {
"message": "Elimina mondo"
},
"app.instance.worlds.delete-world-modal.title": {
"message": "Eliminare il mondo"
},
"app.instance.worlds.delete-world-modal.warning-body": {
"message": "Questo mondo verrà eliminato permanentemente da questa istanza. Questa azione non può essere annullata."
},
"app.instance.worlds.delete-world-modal.warning-header": {
"message": "Eliminare {name}"
},
"app.instance.worlds.filter-modded": {
"message": "Moddato"
},
@@ -251,6 +341,18 @@
"app.instance.worlds.no-worlds-heading": {
"message": "Nessun server o mondo aggiunto"
},
"app.instance.worlds.remove-server-modal.remove-button": {
"message": "Rimuovi server"
},
"app.instance.worlds.remove-server-modal.title": {
"message": "Rimuovere il server"
},
"app.instance.worlds.remove-server-modal.warning-body": {
"message": "Questo server verrà rimosso dalla tua lista di server nell'app e nel gioco. Potrai aggiungerlo nuovamente se conosci l'indirizzo."
},
"app.instance.worlds.remove-server-modal.warning-header": {
"message": "Rimuovere {name}"
},
"app.instance.worlds.search-worlds-placeholder": {
"message": "Cerca tra {count} mondi..."
},
@@ -836,6 +938,9 @@
"instance.worlds.copy_address": {
"message": "Copia indirizzo"
},
"instance.worlds.create_shortcut": {
"message": "Crea collegamento"
},
"instance.worlds.dont_show_on_home": {
"message": "Non mostrare nella home"
},
@@ -306,7 +306,7 @@
"message": "マントなし"
},
"app.skins.modal.replace-texture-button": {
"message": "置きかえる"
"message": "アップロード"
},
"app.skins.modal.texture-section": {
"message": "テクスチャ"
@@ -164,18 +164,12 @@
"app.export-modal.header": {
"message": "모드팩 내보내기"
},
"app.export-modal.include-file-accessibility-label": {
"message": "\"{file}\"(을)를 포함할까요?"
},
"app.export-modal.modpack-name-label": {
"message": "모드팩 이름"
},
"app.export-modal.modpack-name-placeholder": {
"message": "모드팩 이름"
},
"app.export-modal.select-files-label": {
"message": "내보내기에 어느 파일이 포함될지 구성"
},
"app.export-modal.version-number-label": {
"message": "버전 구분"
},
@@ -155,18 +155,12 @@
"app.export-modal.header": {
"message": "Eksport pek mod"
},
"app.export-modal.include-file-accessibility-label": {
"message": "Sertakan \"{file}\"?"
},
"app.export-modal.modpack-name-label": {
"message": "Nama Pek Mod"
},
"app.export-modal.modpack-name-placeholder": {
"message": "Nama pek mod"
},
"app.export-modal.select-files-label": {
"message": "Konfigurasikan fail yang disertakan dalam eksport ini"
},
"app.export-modal.version-number-label": {
"message": "Nombor versi"
},
@@ -33,7 +33,7 @@
"message": "Stop instantie"
},
"app.action-bar.update": {
"message": "Updaten"
"message": "Bijwerken"
},
"app.action-bar.view-active-downloads": {
"message": "Bekijk actieve downloads"
@@ -60,7 +60,7 @@
"message": "Wijzig de pagina waarop de launcher opent."
},
"app.appearance-settings.default-landing-page.home": {
"message": "Home"
"message": "Start"
},
"app.appearance-settings.default-landing-page.library": {
"message": "Bibliotheek"
@@ -170,18 +170,12 @@
"app.export-modal.header": {
"message": "Exporteer modpack"
},
"app.export-modal.include-file-accessibility-label": {
"message": "Betrek \"{file}\"?"
},
"app.export-modal.modpack-name-label": {
"message": "Modpack Naam"
},
"app.export-modal.modpack-name-placeholder": {
"message": "Modpack naam"
},
"app.export-modal.select-files-label": {
"message": "Configureer welke bestanden bij deze export zijn betrokken"
},
"app.export-modal.version-number-label": {
"message": "Versie nummer"
},
+113 -8
View File
@@ -11,6 +11,21 @@
"app.action-bar.hide-more-running-instances": {
"message": "Ukryj włączone instancje"
},
"app.action-bar.install.dismiss": {
"message": "Odrzuć"
},
"app.action-bar.install.open-instance": {
"message": "Otwórz instancję"
},
"app.action-bar.install.retry": {
"message": "Spróbuj ponownie"
},
"app.action-bar.install.unknown-instance": {
"message": "Nieznana instancja"
},
"app.action-bar.installs": {
"message": "Instalacje"
},
"app.action-bar.make-primary-instance": {
"message": "Ustaw jako główną instancję"
},
@@ -51,7 +66,7 @@
"message": "Zaawansowane renderowanie"
},
"app.appearance-settings.color-theme.description": {
"message": "Wybierz preferowany motyw w Modrinth App."
"message": "Wybierz preferowany motyw w Modrinth app."
},
"app.appearance-settings.color-theme.title": {
"message": "Motyw"
@@ -101,6 +116,12 @@
"app.appearance-settings.show-play-time.title": {
"message": "Pokaż czas gry"
},
"app.appearance-settings.skip-non-essential-warnings.description": {
"message": "Automatycznie pomija mało znaczące potwierdzenia takie jak duplikaty instalacji paczek modów, usuwanie zwykłej zawartości, masowe aktualizacje, odłączanie paczek modów i naprawianie. Ostrzeżenia o potencjalnie niebezpiecznych akcjach będą nadal pokazywane."
},
"app.appearance-settings.skip-non-essential-warnings.title": {
"message": "Pomiń mało znaczące ostrzeżenia"
},
"app.appearance-settings.toggle-sidebar.description": {
"message": "Umożliwia przełączenie paska bocznego."
},
@@ -170,24 +191,72 @@
"app.export-modal.header": {
"message": "Eksportuj paczkę modów"
},
"app.export-modal.include-file-accessibility-label": {
"message": "Dołączyć \"{file}\"?"
},
"app.export-modal.modpack-name-label": {
"message": "Nazwa paczki modów"
},
"app.export-modal.modpack-name-placeholder": {
"message": "Nazwa paczki modów"
},
"app.export-modal.select-files-label": {
"message": "Skonfiguruje jakie pliki zostaną załączone w tym eksporcie"
},
"app.export-modal.version-number-label": {
"message": "Numer wersji"
},
"app.export-modal.version-number-placeholder": {
"message": "1.0.0"
},
"app.install.phase.downloading_content": {
"message": "Pobieranie zawartości"
},
"app.install.phase.downloading_minecraft": {
"message": "Pobieranie Minecraft"
},
"app.install.phase.downloading_pack_file": {
"message": "Pobieranie pliku paczki"
},
"app.install.phase.extracting_overrides": {
"message": "Wyodrębnianie nadpisań"
},
"app.install.phase.finalizing": {
"message": "Finalizowanie"
},
"app.install.phase.preparing_instance": {
"message": "Przygotowywanie instancji"
},
"app.install.phase.preparing_java": {
"message": "Przygotowywanie środowiska Java"
},
"app.install.phase.preparing_java.downloading": {
"message": "Pobieranie Java {version}"
},
"app.install.phase.preparing_java.extracting": {
"message": "Wypakowywanie Javy {version}"
},
"app.install.phase.preparing_java.fetching-metadata": {
"message": "Pobieranie Javy {version}"
},
"app.install.phase.preparing_java.resolving": {
"message": "Przygotowywanie Java {version}"
},
"app.install.phase.preparing_java.validating": {
"message": "Sprawdzanie Java {version}"
},
"app.install.phase.reading_pack_manifest": {
"message": "Ustalanie manifestu paczki"
},
"app.install.phase.resolving_loader": {
"message": "Ustalanie programu ładującego"
},
"app.install.phase.resolving_minecraft": {
"message": "Ustalanie wersji Minecraft"
},
"app.install.phase.resolving_pack": {
"message": "Ustalanie treści"
},
"app.install.phase.rolling_back": {
"message": "Przywracanie"
},
"app.install.phase.running_loader_processors": {
"message": "Uruchamianie procesorów programu ładującego"
},
"app.instance.confirm-delete.admonition-body": {
"message": "Wszystkie dane z Twojej instancji zostaną trwale usunięte, w tym Twoje światy, pliki konfiguracji i jakakolwiek dodana zawartość."
},
@@ -212,6 +281,15 @@
"app.instance.modpack-already-installed.instance": {
"message": "Instalacja"
},
"app.instance.mods.bulk-update.downloading-projects": {
"message": "Pobieranie {current, number}/{total, number} projektów..."
},
"app.instance.mods.bulk-update.finishing": {
"message": "Kończenie aktualizacji..."
},
"app.instance.mods.bulk-update.resolving-versions": {
"message": "Sprawdzanie wersji..."
},
"app.instance.mods.content-type-project": {
"message": "projekt"
},
@@ -233,6 +311,18 @@
"app.instance.worlds.browse-servers": {
"message": "Szukaj serwerów"
},
"app.instance.worlds.delete-world-modal.delete-button": {
"message": "Usuń świat"
},
"app.instance.worlds.delete-world-modal.title": {
"message": "Usuń świat"
},
"app.instance.worlds.delete-world-modal.warning-body": {
"message": "Ten świat zostanie trwale usunięty z tej instancji. Tej akcji nie będzie można cofnąć."
},
"app.instance.worlds.delete-world-modal.warning-header": {
"message": "Usuwanie {name}"
},
"app.instance.worlds.filter-modded": {
"message": "Zmodowany"
},
@@ -251,6 +341,18 @@
"app.instance.worlds.no-worlds-heading": {
"message": "Nie dodano żadnych światów ani serwerów"
},
"app.instance.worlds.remove-server-modal.remove-button": {
"message": "Usuń serwer"
},
"app.instance.worlds.remove-server-modal.title": {
"message": "Usuń serwer"
},
"app.instance.worlds.remove-server-modal.warning-body": {
"message": "Ten serwer zostanie trwale usunięty z tej instancji. Tej akcji nie będzie można cofnąć."
},
"app.instance.worlds.remove-server-modal.warning-header": {
"message": "Usuwanie {name}"
},
"app.instance.worlds.search-worlds-placeholder": {
"message": "Szukaj wśród {count} światów..."
},
@@ -339,7 +441,7 @@
"message": "To trwale usunie wybraną skórkę. Tej akcji nie można odwrócić."
},
"app.skins.delete-modal.title": {
"message": "Czy na pewno chcesz usunąć tą skórkę?"
"message": "Czy na pewno chcesz usunąć tę skórkę?"
},
"app.skins.dropped-file-error.text": {
"message": "Nie udało się odczytać wybranego pliku."
@@ -836,6 +938,9 @@
"instance.worlds.copy_address": {
"message": "Kopiuj adres"
},
"instance.worlds.create_shortcut": {
"message": "Utwórz skrót"
},
"instance.worlds.dont_show_on_home": {
"message": "Nie pokazuj na stronie głównej"
},
+111 -6
View File
@@ -11,6 +11,21 @@
"app.action-bar.hide-more-running-instances": {
"message": "Ocultar mais instâncias em execução"
},
"app.action-bar.install.dismiss": {
"message": "Dispensar"
},
"app.action-bar.install.open-instance": {
"message": "Abrir instância"
},
"app.action-bar.install.retry": {
"message": "Tentar novamente"
},
"app.action-bar.install.unknown-instance": {
"message": "Instância desconhecida"
},
"app.action-bar.installs": {
"message": "Instalações"
},
"app.action-bar.make-primary-instance": {
"message": "Tornar instância principal"
},
@@ -101,6 +116,12 @@
"app.appearance-settings.show-play-time.title": {
"message": "Exibir tempo jogado"
},
"app.appearance-settings.skip-non-essential-warnings.description": {
"message": "Pula automaticamente as confirmações desnecessárias como duplica de instalação de pacotes de mods, exclusão de conteúdo normal, atualizações em massa, desvinculo de pacotes de mods e diálogos de reparo. Os avisos de perigo ainda serão exibidos."
},
"app.appearance-settings.skip-non-essential-warnings.title": {
"message": "Pular avisos desnecessários"
},
"app.appearance-settings.toggle-sidebar.description": {
"message": "Permite alternar a barra lateral."
},
@@ -170,24 +191,72 @@
"app.export-modal.header": {
"message": "Exportar pacote de mods"
},
"app.export-modal.include-file-accessibility-label": {
"message": "Incluir \"{file}\"?"
},
"app.export-modal.modpack-name-label": {
"message": "Nome do pacote de mods"
},
"app.export-modal.modpack-name-placeholder": {
"message": "Nome do pacote de mods"
},
"app.export-modal.select-files-label": {
"message": "Configure quais arquivos serão incluídos na exportação"
},
"app.export-modal.version-number-label": {
"message": "Número da versão"
},
"app.export-modal.version-number-placeholder": {
"message": "1.0.0"
},
"app.install.phase.downloading_content": {
"message": "Instalando conteúdo"
},
"app.install.phase.downloading_minecraft": {
"message": "Instalando Minecraft"
},
"app.install.phase.downloading_pack_file": {
"message": "Instalando arquivo de pacote"
},
"app.install.phase.extracting_overrides": {
"message": "Extraindo substitutos"
},
"app.install.phase.finalizing": {
"message": "Finalizando"
},
"app.install.phase.preparing_instance": {
"message": "Preparando instância"
},
"app.install.phase.preparing_java": {
"message": "Preparando Java"
},
"app.install.phase.preparing_java.downloading": {
"message": "Baixando Java {version}"
},
"app.install.phase.preparing_java.extracting": {
"message": "Extraindo Java {version}"
},
"app.install.phase.preparing_java.fetching-metadata": {
"message": "Obtendo Java {version}"
},
"app.install.phase.preparing_java.resolving": {
"message": "Preparando Java {version}"
},
"app.install.phase.preparing_java.validating": {
"message": "Validando Java {version}"
},
"app.install.phase.reading_pack_manifest": {
"message": "Lendo manifestação de pacote"
},
"app.install.phase.resolving_loader": {
"message": "Resolvendo loader"
},
"app.install.phase.resolving_minecraft": {
"message": "Resolvendo Minecraft"
},
"app.install.phase.resolving_pack": {
"message": "Resolvendo conteúdo"
},
"app.install.phase.rolling_back": {
"message": "Indo de volta"
},
"app.install.phase.running_loader_processors": {
"message": "Executando processadores de loader"
},
"app.instance.confirm-delete.admonition-body": {
"message": "Todos os dados para sua instância serão excluídos permanentemente, incluindo seus mundos, configs e todo o conteúdo instalado."
},
@@ -212,6 +281,15 @@
"app.instance.modpack-already-installed.instance": {
"message": "Instância"
},
"app.instance.mods.bulk-update.downloading-projects": {
"message": "Instalando {current, number}/{total, number} projeto(s)..."
},
"app.instance.mods.bulk-update.finishing": {
"message": "Finalizando atualização..."
},
"app.instance.mods.bulk-update.resolving-versions": {
"message": "Resolvendo versões..."
},
"app.instance.mods.content-type-project": {
"message": "projeto"
},
@@ -236,6 +314,18 @@
"app.instance.worlds.browse-servers": {
"message": "Explorar servidores"
},
"app.instance.worlds.delete-world-modal.delete-button": {
"message": "Excluir mundo"
},
"app.instance.worlds.delete-world-modal.title": {
"message": "Excluir mundo"
},
"app.instance.worlds.delete-world-modal.warning-body": {
"message": "Este mundo será permanentemente excluído desta instância. Esta ação não pode ser desfeita."
},
"app.instance.worlds.delete-world-modal.warning-header": {
"message": "Excluindo {name}"
},
"app.instance.worlds.filter-modded": {
"message": "Modificado"
},
@@ -254,6 +344,18 @@
"app.instance.worlds.no-worlds-heading": {
"message": "Nenhum servidor ou mundo adicionado"
},
"app.instance.worlds.remove-server-modal.remove-button": {
"message": "Remover servidor"
},
"app.instance.worlds.remove-server-modal.title": {
"message": "Remover servidor"
},
"app.instance.worlds.remove-server-modal.warning-body": {
"message": "O servidor será removido da lista de servidores e da lista de servidores no jogo. Você pode re-adicioná-lo depois se souber o endereço."
},
"app.instance.worlds.remove-server-modal.warning-header": {
"message": "Removendo {name}"
},
"app.instance.worlds.search-worlds-placeholder": {
"message": "Buscar {count} mundos..."
},
@@ -839,6 +941,9 @@
"instance.worlds.copy_address": {
"message": "Copiar endereço"
},
"instance.worlds.create_shortcut": {
"message": "Criar atalho"
},
"instance.worlds.dont_show_on_home": {
"message": "Não exibir na página inicial"
},
+108 -6
View File
@@ -11,6 +11,21 @@
"app.action-bar.hide-more-running-instances": {
"message": "Скрыть другие активные сборки"
},
"app.action-bar.install.dismiss": {
"message": "Закрыть"
},
"app.action-bar.install.open-instance": {
"message": "Открыть сборку"
},
"app.action-bar.install.retry": {
"message": "Повторить"
},
"app.action-bar.install.unknown-instance": {
"message": "Неизвестная сборка"
},
"app.action-bar.installs": {
"message": "Установки"
},
"app.action-bar.make-primary-instance": {
"message": "Сделать основной сборкой"
},
@@ -101,6 +116,12 @@
"app.appearance-settings.show-play-time.title": {
"message": "Время игры"
},
"app.appearance-settings.skip-non-essential-warnings.description": {
"message": "Автоматически пропускает подтверждения с низким уровнем риска, такие как установка дубликатов сборок, обычное удаление контента, массовые обновления, отключение сборок и запросы на восстановление. Опасные предупреждения по-прежнему будут отображаться."
},
"app.appearance-settings.skip-non-essential-warnings.title": {
"message": "Пропустить несущественные предупреждения"
},
"app.appearance-settings.toggle-sidebar.description": {
"message": "Показывать кнопку скрытия боковой панели."
},
@@ -170,24 +191,69 @@
"app.export-modal.header": {
"message": "Экспорт сборки"
},
"app.export-modal.include-file-accessibility-label": {
"message": "Добавить «{file}» в экспорт?"
},
"app.export-modal.modpack-name-label": {
"message": "Название сборки"
},
"app.export-modal.modpack-name-placeholder": {
"message": "Название сборки"
},
"app.export-modal.select-files-label": {
"message": "Выберите файлы для экспорта"
},
"app.export-modal.version-number-label": {
"message": "Номер версии"
},
"app.export-modal.version-number-placeholder": {
"message": "1.0.0"
},
"app.install.phase.downloading_content": {
"message": "Скачивание контента"
},
"app.install.phase.downloading_minecraft": {
"message": "Скачивание Minecraft"
},
"app.install.phase.downloading_pack_file": {
"message": "Скачивание файла сборки"
},
"app.install.phase.extracting_overrides": {
"message": "Извлечение переопределений"
},
"app.install.phase.finalizing": {
"message": "Завершение"
},
"app.install.phase.preparing_instance": {
"message": "Подготовка сборки"
},
"app.install.phase.preparing_java": {
"message": "Подготовка Java"
},
"app.install.phase.preparing_java.downloading": {
"message": "Скачивание Java {version}"
},
"app.install.phase.preparing_java.extracting": {
"message": "Распаковка Java {version}"
},
"app.install.phase.preparing_java.fetching-metadata": {
"message": "Получение Java {version}"
},
"app.install.phase.preparing_java.resolving": {
"message": "Подготовка Java {version}"
},
"app.install.phase.preparing_java.validating": {
"message": "Проверка Java {version}"
},
"app.install.phase.reading_pack_manifest": {
"message": "Чтение манифеста сборки"
},
"app.install.phase.resolving_minecraft": {
"message": "Загрузка версий"
},
"app.install.phase.resolving_pack": {
"message": "Загрузка контента"
},
"app.install.phase.rolling_back": {
"message": "Откат"
},
"app.install.phase.running_loader_processors": {
"message": "Запуск процессоров загрузчика"
},
"app.instance.confirm-delete.admonition-body": {
"message": "Все данные сборки будут удалены навсегда, в том числе миры, настройки и весь установленный контент."
},
@@ -212,6 +278,15 @@
"app.instance.modpack-already-installed.instance": {
"message": "Сборка"
},
"app.instance.mods.bulk-update.downloading-projects": {
"message": "Скачивание {current, number}/{total, number} проектов..."
},
"app.instance.mods.bulk-update.finishing": {
"message": "Завершение обновления..."
},
"app.instance.mods.bulk-update.resolving-versions": {
"message": "Загрузка версий..."
},
"app.instance.mods.content-type-project": {
"message": "проект"
},
@@ -233,6 +308,18 @@
"app.instance.worlds.browse-servers": {
"message": "Найти серверы"
},
"app.instance.worlds.delete-world-modal.delete-button": {
"message": "Удалить"
},
"app.instance.worlds.delete-world-modal.title": {
"message": "Удаление мира"
},
"app.instance.worlds.delete-world-modal.warning-body": {
"message": "Этот мир будет удалён навсегда из этой сборки. Отменить это действие нельзя."
},
"app.instance.worlds.delete-world-modal.warning-header": {
"message": "Удаление {name}"
},
"app.instance.worlds.filter-modded": {
"message": "Модовые"
},
@@ -251,6 +338,18 @@
"app.instance.worlds.no-worlds-heading": {
"message": "Нет серверов и миров"
},
"app.instance.worlds.remove-server-modal.remove-button": {
"message": "Удалить"
},
"app.instance.worlds.remove-server-modal.title": {
"message": "Удаление сервера"
},
"app.instance.worlds.remove-server-modal.warning-body": {
"message": "Этот сервер будет удалён из списка серверов, в том числе и в игре. Его можно вернуть, зная адрес."
},
"app.instance.worlds.remove-server-modal.warning-header": {
"message": "Удаление {name}"
},
"app.modal.install-to-play.content-required": {
"message": "Требуется дополнительный контент"
},
@@ -830,6 +929,9 @@
"instance.worlds.copy_address": {
"message": "Копировать адрес"
},
"instance.worlds.create_shortcut": {
"message": "Создать ярлык"
},
"instance.worlds.dont_show_on_home": {
"message": "Не показывать на главной"
},
@@ -156,11 +156,14 @@
"message": "Minecraft svet"
},
"instance.edit-world.title": {
"message": "Izmeni svet"
"message": "Promeni svet"
},
"instance.server-modal.placeholder-name": {
"message": "Minecraft Server"
},
"instance.settings.tabs.general": {
"message": "Generalno"
},
"instance.settings.tabs.general.deleting.button": {
"message": "Brisanje..."
},
+121 -7
View File
@@ -11,6 +11,21 @@
"app.action-bar.hide-more-running-instances": {
"message": "Göm fler aktiva instanser"
},
"app.action-bar.install.dismiss": {
"message": "Avvisa"
},
"app.action-bar.install.open-instance": {
"message": "Öppna instans"
},
"app.action-bar.install.retry": {
"message": "Försök igen"
},
"app.action-bar.install.unknown-instance": {
"message": "Okänd instans"
},
"app.action-bar.installs": {
"message": "Installationer"
},
"app.action-bar.make-primary-instance": {
"message": "Gör till primärinstans"
},
@@ -24,7 +39,7 @@
"message": "Primärinstans"
},
"app.action-bar.reload-to-update": {
"message": "Ladda om för att uppdatera"
"message": "Uppdatera (ladda om)"
},
"app.action-bar.show-more-running-instances": {
"message": "Visa fler aktiva instanser"
@@ -74,9 +89,15 @@
"app.appearance-settings.hide-nametag.title": {
"message": "Dölj namnskylt"
},
"app.appearance-settings.jump-back-into-worlds.description": {
"message": "Inkluderar de senaste världarna i \"Hoppa tillbaka i världar\" på hemsidan."
},
"app.appearance-settings.jump-back-into-worlds.title": {
"message": "Hoppa tillbaka i världar"
},
"app.appearance-settings.minimize-launcher.description": {
"message": "Minimerar launchern när Minecraft startas."
},
"app.appearance-settings.minimize-launcher.title": {
"message": "Minimera programstartare"
},
@@ -95,6 +116,12 @@
"app.appearance-settings.show-play-time.title": {
"message": "Visa speltid"
},
"app.appearance-settings.skip-non-essential-warnings.description": {
"message": "Hoppar över bekräftelser med låg risk, såsom installation av dubbla modpaket, radering av vanligt innehåll, massuppdateringar, avlänkning av modpaket och reparationsmeddelanden. Varningar om farliga händelser visas fortfarande."
},
"app.appearance-settings.skip-non-essential-warnings.title": {
"message": "Hoppa över icke nödvändiga varningar"
},
"app.appearance-settings.toggle-sidebar.description": {
"message": "Låter dig växla sidofältet."
},
@@ -146,6 +173,9 @@
"app.browse.project-type.modpacks": {
"message": "Modpaket"
},
"app.browse.server-instance-content-warning": {
"message": "Att lägga till innehåll kan bryta kompatibiliteten när du ansluter dig till servern. Allt tillagt innehåll kommer att förloras när du uppdaterar serverinstansen."
},
"app.browse.server.installing": {
"message": "Installerar"
},
@@ -161,24 +191,66 @@
"app.export-modal.header": {
"message": "Exportera modpaket"
},
"app.export-modal.include-file-accessibility-label": {
"message": "Inkludera \"{file}\"?"
},
"app.export-modal.modpack-name-label": {
"message": "Modpaketets namn"
},
"app.export-modal.modpack-name-placeholder": {
"message": "Modpaketets namn"
},
"app.export-modal.select-files-label": {
"message": "Konfigurera vilka filer som inkluderas i denna export"
},
"app.export-modal.version-number-label": {
"message": "Versionsnummer"
},
"app.export-modal.version-number-placeholder": {
"message": "1.0.0"
},
"app.install.phase.downloading_content": {
"message": "Laddar ner innehåll"
},
"app.install.phase.downloading_minecraft": {
"message": "Laddar ner Minecraft"
},
"app.install.phase.downloading_pack_file": {
"message": "Laddar ner pack fil"
},
"app.install.phase.extracting_overrides": {
"message": "Extraherar överskrivningar"
},
"app.install.phase.finalizing": {
"message": "Avslutar"
},
"app.install.phase.preparing_instance": {
"message": "Förbereder instans"
},
"app.install.phase.preparing_java": {
"message": "Förbereder Java"
},
"app.install.phase.preparing_java.downloading": {
"message": "Laddar ner Java {version}"
},
"app.install.phase.preparing_java.extracting": {
"message": "Extraherar Java {version}"
},
"app.install.phase.preparing_java.fetching-metadata": {
"message": "Hämtar Java {version}"
},
"app.install.phase.preparing_java.resolving": {
"message": "Förbereder Java {version}"
},
"app.install.phase.preparing_java.validating": {
"message": "Bekräftar Java {version}"
},
"app.install.phase.reading_pack_manifest": {
"message": "Läser pack information"
},
"app.install.phase.resolving_loader": {
"message": "Laddar ner loadern"
},
"app.install.phase.resolving_minecraft": {
"message": "Laddar ner Minecraft"
},
"app.install.phase.resolving_pack": {
"message": "Laddar ner innehåll"
},
"app.instance.confirm-delete.admonition-body": {
"message": "All data från din instans kommer permanent raderas, däribland dina världar, konfigurationer samt allt installerat innehåll."
},
@@ -203,6 +275,15 @@
"app.instance.modpack-already-installed.instance": {
"message": "Instans"
},
"app.instance.mods.bulk-update.downloading-projects": {
"message": "Laddar ner {current, number}/{total, number} projekt..."
},
"app.instance.mods.bulk-update.finishing": {
"message": "Slutför uppdatering..."
},
"app.instance.mods.bulk-update.resolving-versions": {
"message": "Laddar versioner..."
},
"app.instance.mods.content-type-project": {
"message": "projekt"
},
@@ -227,6 +308,15 @@
"app.instance.worlds.browse-servers": {
"message": "Utforska servrar"
},
"app.instance.worlds.delete-world-modal.delete-button": {
"message": "Ta bort värld"
},
"app.instance.worlds.delete-world-modal.title": {
"message": "Ta bort värld"
},
"app.instance.worlds.delete-world-modal.warning-header": {
"message": "Raderar {name}"
},
"app.instance.worlds.filter-modded": {
"message": "Moddad"
},
@@ -245,6 +335,15 @@
"app.instance.worlds.no-worlds-heading": {
"message": "Inga servrar eller världar tillagda"
},
"app.instance.worlds.remove-server-modal.remove-button": {
"message": "Ta bort server"
},
"app.instance.worlds.remove-server-modal.title": {
"message": "Ta bort server"
},
"app.instance.worlds.remove-server-modal.warning-header": {
"message": "Raderar {name}"
},
"app.instance.worlds.search-worlds-placeholder": {
"message": "Sök {count} världar..."
},
@@ -287,6 +386,9 @@
"app.project.install-button.already-installed": {
"message": "Detta projekt är redan installerat"
},
"app.project.install-context.back-to-browse": {
"message": "Tillbaka till upptäck"
},
"app.project.install-context.install-content-to-instance": {
"message": "Installera innehåll till instans"
},
@@ -401,6 +503,12 @@
"app.skins.rate-limit.title": {
"message": "Sakta ner!"
},
"app.skins.reorder-error.text": {
"message": "Ditt utseende kunde inte sparas."
},
"app.skins.section.builders-and-biomes": {
"message": "Builders & Biomes"
},
"app.skins.section.chase-the-skies": {
"message": "Chase the Skies"
},
@@ -425,6 +533,9 @@
"app.skins.section.saved-skins": {
"message": "Sparade utseenden"
},
"app.skins.section.striding-hero": {
"message": "Striding Hero"
},
"app.skins.section.the-copper-age": {
"message": "The Copper Age"
},
@@ -815,6 +926,9 @@
"instance.worlds.copy_address": {
"message": "Kopiera adress"
},
"instance.worlds.create_shortcut": {
"message": "Skapa genväg"
},
"instance.worlds.dont_show_on_home": {
"message": "Visa inte på Startsidan"
},
@@ -170,18 +170,12 @@
"app.export-modal.header": {
"message": "ส่งออกแพ็กม็อด"
},
"app.export-modal.include-file-accessibility-label": {
"message": "รวมถึง \"{file}\" ใช่หรือไม่"
},
"app.export-modal.modpack-name-label": {
"message": "ชื่อแพ็กม็อด"
},
"app.export-modal.modpack-name-placeholder": {
"message": "ชื่อแพ็กม็อด"
},
"app.export-modal.select-files-label": {
"message": "กำหนดไฟล์ที่ต้องการจะส่งออก"
},
"app.export-modal.version-number-label": {
"message": "หมายเลขเวอร์ชัน"
},
+111 -6
View File
@@ -11,6 +11,21 @@
"app.action-bar.hide-more-running-instances": {
"message": "Diğer çalışan kurulumları gizle"
},
"app.action-bar.install.dismiss": {
"message": "Reddet"
},
"app.action-bar.install.open-instance": {
"message": "Kurulumu aç"
},
"app.action-bar.install.retry": {
"message": "Yeniden Dene"
},
"app.action-bar.install.unknown-instance": {
"message": "Bilinmeyen kurulum"
},
"app.action-bar.installs": {
"message": "Yüklenenler"
},
"app.action-bar.make-primary-instance": {
"message": "Birincil kurulum yap"
},
@@ -101,6 +116,12 @@
"app.appearance-settings.show-play-time.title": {
"message": "Oynama Süresini Göster"
},
"app.appearance-settings.skip-non-essential-warnings.description": {
"message": "Mükerrer mod paketi kurulumları, normal içerik silme, toplu güncellemeler, mod paketi bağlantısını kesme ve onarım uyarıları gibi düşük riskli onayları otomatik olarak atlar. Tehlikeli uyarılar gösterilmeye devam edecektir."
},
"app.appearance-settings.skip-non-essential-warnings.title": {
"message": "Gerekli olmayan uyarıları atla"
},
"app.appearance-settings.toggle-sidebar.description": {
"message": "Kenar çubuğunu ayarlama özelliğini aktifleştirir."
},
@@ -170,24 +191,72 @@
"app.export-modal.header": {
"message": "Mod paketini dışa aktar"
},
"app.export-modal.include-file-accessibility-label": {
"message": "\"{file}\" dahil mi?"
},
"app.export-modal.modpack-name-label": {
"message": "Mod Paketi Adı"
},
"app.export-modal.modpack-name-placeholder": {
"message": "Mod paketi adı"
},
"app.export-modal.select-files-label": {
"message": "Bu dışa aktarmaya hangi dosyaların dahil edileceğini yapılandırma"
},
"app.export-modal.version-number-label": {
"message": "Sürüm numarası"
},
"app.export-modal.version-number-placeholder": {
"message": "1.0.0"
},
"app.install.phase.downloading_content": {
"message": "İçerik indiriliyor"
},
"app.install.phase.downloading_minecraft": {
"message": "Minecraft yükleniyor"
},
"app.install.phase.downloading_pack_file": {
"message": "Paket dosyaları yükleniyor"
},
"app.install.phase.extracting_overrides": {
"message": "Geçersiz kılmalar ayıklanıyor"
},
"app.install.phase.finalizing": {
"message": "Tamamlanıyor"
},
"app.install.phase.preparing_instance": {
"message": "Kurulum hazırlanıyor"
},
"app.install.phase.preparing_java": {
"message": "Java Hazırlanıyor"
},
"app.install.phase.preparing_java.downloading": {
"message": "Java {version} İndiriliyor"
},
"app.install.phase.preparing_java.extracting": {
"message": "Java {version} ayıklanıyor"
},
"app.install.phase.preparing_java.fetching-metadata": {
"message": "Java {version} getiriliyor"
},
"app.install.phase.preparing_java.resolving": {
"message": "Java {version} Hazırlanıyor"
},
"app.install.phase.preparing_java.validating": {
"message": "Java {version} doğrulanıyor"
},
"app.install.phase.reading_pack_manifest": {
"message": "Paket manifesti okunuyor"
},
"app.install.phase.resolving_loader": {
"message": "Yükleyici çözümleniyor"
},
"app.install.phase.resolving_minecraft": {
"message": "Minecraft çözümleniyor"
},
"app.install.phase.resolving_pack": {
"message": "İçerik çözümleniyor"
},
"app.install.phase.rolling_back": {
"message": "Değişiklikler geri alınıyor"
},
"app.install.phase.running_loader_processors": {
"message": "Yükleyici işlemcileri çalıştırılıyor"
},
"app.instance.confirm-delete.admonition-body": {
"message": "Dünyalarınız, yapılandırmalarınız ve yüklü tüm içerikler ve tüm veriler kalıcı olarak silinecek."
},
@@ -212,6 +281,15 @@
"app.instance.modpack-already-installed.instance": {
"message": "Kurulum"
},
"app.instance.mods.bulk-update.downloading-projects": {
"message": "{current, number}/{total, number} proje indiriliyor..."
},
"app.instance.mods.bulk-update.finishing": {
"message": "Güncelleme tamamlanıyor..."
},
"app.instance.mods.bulk-update.resolving-versions": {
"message": "Sürümler çözümleniyor..."
},
"app.instance.mods.content-type-project": {
"message": "proje"
},
@@ -236,6 +314,18 @@
"app.instance.worlds.browse-servers": {
"message": "Sunucuları Keşfet"
},
"app.instance.worlds.delete-world-modal.delete-button": {
"message": "Dünya Sil"
},
"app.instance.worlds.delete-world-modal.title": {
"message": "Dünyayı sil"
},
"app.instance.worlds.delete-world-modal.warning-body": {
"message": "Bu dünya, bu profilden kalıcı olarak silinecek. Bu işlem geri alınamaz."
},
"app.instance.worlds.delete-world-modal.warning-header": {
"message": "{name} Siliniyor"
},
"app.instance.worlds.filter-modded": {
"message": "Modlu"
},
@@ -254,6 +344,18 @@
"app.instance.worlds.no-worlds-heading": {
"message": "Eklenmiş sunucu veya dünya yok"
},
"app.instance.worlds.remove-server-modal.remove-button": {
"message": "Sunucuyu kaldır"
},
"app.instance.worlds.remove-server-modal.title": {
"message": "Sunucuyu kaldır"
},
"app.instance.worlds.remove-server-modal.warning-body": {
"message": "Bu sunucu, sunucu listenizden ve oyun içi sunucu listesinden kaldırılacaktır. Adresini biliyorsanız daha sonra tekrar ekleyebilirsiniz."
},
"app.instance.worlds.remove-server-modal.warning-header": {
"message": "{name} kaldırılıyor"
},
"app.instance.worlds.search-worlds-placeholder": {
"message": "{count} dünya ara..."
},
@@ -839,6 +941,9 @@
"instance.worlds.copy_address": {
"message": "Adresi kopyala"
},
"instance.worlds.create_shortcut": {
"message": "Kısayol yap"
},
"instance.worlds.dont_show_on_home": {
"message": "Ana sayfada gösterme"
},
@@ -170,18 +170,12 @@
"app.export-modal.header": {
"message": "Експортувати збірку"
},
"app.export-modal.include-file-accessibility-label": {
"message": "Уключити «{file}»?"
},
"app.export-modal.modpack-name-label": {
"message": "Назва збірки"
},
"app.export-modal.modpack-name-placeholder": {
"message": "Назва збірки"
},
"app.export-modal.select-files-label": {
"message": "Змініть файли, які додані до експорту"
},
"app.export-modal.version-number-label": {
"message": "Номер версії"
},
@@ -170,18 +170,12 @@
"app.export-modal.header": {
"message": "Xuất modpack"
},
"app.export-modal.include-file-accessibility-label": {
"message": "Bao gồm \"{file}\"?"
},
"app.export-modal.modpack-name-label": {
"message": "Tên modpack"
},
"app.export-modal.modpack-name-placeholder": {
"message": "Tên modpack"
},
"app.export-modal.select-files-label": {
"message": "Cấu hình các tệp nào được bao gồm trong quá trình xuất phiên bản này"
},
"app.export-modal.version-number-label": {
"message": "Phiên bản"
},
+120 -15
View File
@@ -11,6 +11,21 @@
"app.action-bar.hide-more-running-instances": {
"message": "隐藏更多运行中的实例"
},
"app.action-bar.install.dismiss": {
"message": "忽略"
},
"app.action-bar.install.open-instance": {
"message": "打开实例"
},
"app.action-bar.install.retry": {
"message": "重试"
},
"app.action-bar.install.unknown-instance": {
"message": "未知的实例"
},
"app.action-bar.installs": {
"message": "安装"
},
"app.action-bar.make-primary-instance": {
"message": "设为主实例"
},
@@ -101,6 +116,12 @@
"app.appearance-settings.show-play-time.title": {
"message": "显示总游玩时间"
},
"app.appearance-settings.skip-non-essential-warnings.description": {
"message": "自动跳过低风险确认,包括重复安装整合包、常规内容删除、批量更新、解除整合包关联及修复提示。危险警告仍会显示。"
},
"app.appearance-settings.skip-non-essential-warnings.title": {
"message": "跳过非必要的警告"
},
"app.appearance-settings.toggle-sidebar.description": {
"message": "启用切换侧边栏的功能。"
},
@@ -108,7 +129,7 @@
"message": "切换侧边栏"
},
"app.appearance-settings.unknown-pack-warning.description": {
"message": "如果你尝试安装一个未托管在 Modrinth 上的 Modrinth 包文件(.mrpack),我们会确保你在安装之前了解相关风险。"
"message": "如果你尝试安装一个未托管在 Modrinth 上的 Modrinth 整合包文件(.mrpack),我们会确保你在安装之前了解相关风险。"
},
"app.appearance-settings.unknown-pack-warning.title": {
"message": "安装未知整合包前提醒我"
@@ -120,7 +141,7 @@
"message": "无法连接到身份验证服务器"
},
"app.browse.add-servers-to-instance": {
"message": "正在添加服务器到实例"
"message": "添加服务器到实例"
},
"app.browse.add-to-an-instance": {
"message": "添加到实例"
@@ -170,24 +191,72 @@
"app.export-modal.header": {
"message": "导出整合包"
},
"app.export-modal.include-file-accessibility-label": {
"message": "包含 \"{file}\""
},
"app.export-modal.modpack-name-label": {
"message": "整合包名称"
},
"app.export-modal.modpack-name-placeholder": {
"message": "整合包名称"
},
"app.export-modal.select-files-label": {
"message": "配置此导出中包含哪些文件"
},
"app.export-modal.version-number-label": {
"message": "版本号"
},
"app.export-modal.version-number-placeholder": {
"message": "1.0.0"
},
"app.install.phase.downloading_content": {
"message": "正在下载内容"
},
"app.install.phase.downloading_minecraft": {
"message": "正在下载 Minecraft"
},
"app.install.phase.downloading_pack_file": {
"message": "正在下载包文件"
},
"app.install.phase.extracting_overrides": {
"message": "提取覆盖项"
},
"app.install.phase.finalizing": {
"message": "完成"
},
"app.install.phase.preparing_instance": {
"message": "准备实例"
},
"app.install.phase.preparing_java": {
"message": "正在准备 Java"
},
"app.install.phase.preparing_java.downloading": {
"message": "正在下载 Java {version}"
},
"app.install.phase.preparing_java.extracting": {
"message": "正在提取 Java {version}"
},
"app.install.phase.preparing_java.fetching-metadata": {
"message": "正在获取 Java {version}"
},
"app.install.phase.preparing_java.resolving": {
"message": "正在准备 Java {version}"
},
"app.install.phase.preparing_java.validating": {
"message": "正在校验 Java {version}"
},
"app.install.phase.reading_pack_manifest": {
"message": "正在读取整合包清单"
},
"app.install.phase.resolving_loader": {
"message": "正在解析加载器"
},
"app.install.phase.resolving_minecraft": {
"message": "正在解析 Minecraft"
},
"app.install.phase.resolving_pack": {
"message": "正在解析内容"
},
"app.install.phase.rolling_back": {
"message": "回滚"
},
"app.install.phase.running_loader_processors": {
"message": "正在运行加载处理器"
},
"app.instance.confirm-delete.admonition-body": {
"message": "你实例的所有数据将被永久删除,包括你的世界、配置和所有已安装的内容。"
},
@@ -212,6 +281,15 @@
"app.instance.modpack-already-installed.instance": {
"message": "实例"
},
"app.instance.mods.bulk-update.downloading-projects": {
"message": "正在下载 {current, number}/{total, number}个 项目……"
},
"app.instance.mods.bulk-update.finishing": {
"message": "正在结束更新……"
},
"app.instance.mods.bulk-update.resolving-versions": {
"message": "正在解析版本……"
},
"app.instance.mods.content-type-project": {
"message": "项目"
},
@@ -236,6 +314,18 @@
"app.instance.worlds.browse-servers": {
"message": "浏览服务器"
},
"app.instance.worlds.delete-world-modal.delete-button": {
"message": "删除世界"
},
"app.instance.worlds.delete-world-modal.title": {
"message": "删除世界"
},
"app.instance.worlds.delete-world-modal.warning-body": {
"message": "这个世界将会被永久删除,此操作无法撤销。"
},
"app.instance.worlds.delete-world-modal.warning-header": {
"message": "正在删除 {name}"
},
"app.instance.worlds.filter-modded": {
"message": "模组适配"
},
@@ -254,6 +344,18 @@
"app.instance.worlds.no-worlds-heading": {
"message": "未添加服务器或世界"
},
"app.instance.worlds.remove-server-modal.remove-button": {
"message": "删除服务器"
},
"app.instance.worlds.remove-server-modal.title": {
"message": "删除服务器"
},
"app.instance.worlds.remove-server-modal.warning-body": {
"message": "该服务器将会被移出你的服务器列表,也包括游戏内的服务器列表。之后如果你记得地址,仍可将其添加回来。"
},
"app.instance.worlds.remove-server-modal.warning-header": {
"message": "正在删除 {name}"
},
"app.instance.worlds.search-worlds-placeholder": {
"message": "搜索 {count} 个世界……"
},
@@ -651,7 +753,7 @@
"message": "将从设备中永久删除该实例,包括所有存档、配置文件及已安装内容。此操作不可撤销,请谨慎操作。"
},
"instance.settings.tabs.general.deleting.button": {
"message": "删除中..."
"message": "删除中……"
},
"instance.settings.tabs.general.duplicate-button": {
"message": "复制"
@@ -732,7 +834,7 @@
"message": "在游戏关闭后运行。"
},
"instance.settings.tabs.hooks.post-exit.enter": {
"message": "输入退出后运行的命令…"
"message": "输入退出后运行的命令…"
},
"instance.settings.tabs.hooks.pre-launch": {
"message": "启动前"
@@ -741,7 +843,7 @@
"message": "在实例启动前运行。"
},
"instance.settings.tabs.hooks.pre-launch.enter": {
"message": "输入启动前命令..."
"message": "输入启动前命令……"
},
"instance.settings.tabs.hooks.title": {
"message": "游戏启动钩子"
@@ -753,7 +855,7 @@
"message": "用于启动 Minecraft 的包装器命令。"
},
"instance.settings.tabs.hooks.wrapper.enter": {
"message": "输入封装命令..."
"message": "输入封装命令……"
},
"instance.settings.tabs.installation": {
"message": "安装"
@@ -819,7 +921,7 @@
"message": "游戏启动时的窗口高度。"
},
"instance.settings.tabs.window.height.enter": {
"message": "输入高度…"
"message": "输入高度…"
},
"instance.settings.tabs.window.width": {
"message": "宽度"
@@ -828,7 +930,7 @@
"message": "游戏启动时的窗口宽度。"
},
"instance.settings.tabs.window.width.enter": {
"message": "输入宽度…"
"message": "输入宽度…"
},
"instance.worlds.a_minecraft_server": {
"message": "Minecraft 服务器"
@@ -839,6 +941,9 @@
"instance.worlds.copy_address": {
"message": "复制地址"
},
"instance.worlds.create_shortcut": {
"message": "创建快照"
},
"instance.worlds.dont_show_on_home": {
"message": "在主界面隐藏"
},
@@ -927,7 +1032,7 @@
"message": "仍然安装"
},
"unknown-pack-warning-modal.malware-statement": {
"message": "恶意软件常常通过模组包文件在 Discord 等平台上分享而传播。"
"message": "恶意软件常常通过整合包文件在 Discord 等平台上分享而传播。"
},
"unknown-pack-warning-modal.warning.body": {
"message": "我们在 Modrinth 上找不到此文件。强烈建议你仅从可信来源安装文件。"
+112 -7
View File
@@ -11,6 +11,21 @@
"app.action-bar.hide-more-running-instances": {
"message": "隱藏更多執行中的實例"
},
"app.action-bar.install.dismiss": {
"message": "忽略"
},
"app.action-bar.install.open-instance": {
"message": "開啟實例"
},
"app.action-bar.install.retry": {
"message": "重試"
},
"app.action-bar.install.unknown-instance": {
"message": "未知的實例"
},
"app.action-bar.installs": {
"message": "安裝"
},
"app.action-bar.make-primary-instance": {
"message": "設為主要實例"
},
@@ -101,6 +116,12 @@
"app.appearance-settings.show-play-time.title": {
"message": "顯示遊玩時數"
},
"app.appearance-settings.skip-non-essential-warnings.description": {
"message": "自動跳過低風險操作的確認提示,例如重複安裝模組包、常規內容刪除、批次更新、取消連結模組包以及修復提示。高風險警告仍會顯示。"
},
"app.appearance-settings.skip-non-essential-warnings.title": {
"message": "跳過非必要的警告"
},
"app.appearance-settings.toggle-sidebar.description": {
"message": "啟用切換側邊欄的功能。"
},
@@ -162,7 +183,7 @@
"message": "沒有符合 {compatibilityLabel} 的可用版本。請選擇一個版本強制安裝。不會自動安裝相依模組。"
},
"app.export-modal.description-placeholder": {
"message": "輸入模組包描述..."
"message": "輸入模組包描述……"
},
"app.export-modal.export-button": {
"message": "匯出"
@@ -170,24 +191,72 @@
"app.export-modal.header": {
"message": "匯出模組包"
},
"app.export-modal.include-file-accessibility-label": {
"message": "要包含「{file}」嗎?"
},
"app.export-modal.modpack-name-label": {
"message": "模組包名稱"
},
"app.export-modal.modpack-name-placeholder": {
"message": "模組包名稱"
},
"app.export-modal.select-files-label": {
"message": "設定要包含在此匯出檔案中的檔案"
},
"app.export-modal.version-number-label": {
"message": "版本號碼"
},
"app.export-modal.version-number-placeholder": {
"message": "1.0.0"
},
"app.install.phase.downloading_content": {
"message": "正在下載內容"
},
"app.install.phase.downloading_minecraft": {
"message": "正在下載 Minecraft"
},
"app.install.phase.downloading_pack_file": {
"message": "正在下載模組包檔案"
},
"app.install.phase.extracting_overrides": {
"message": "正在解壓縮覆寫檔案"
},
"app.install.phase.finalizing": {
"message": "即將完成"
},
"app.install.phase.preparing_instance": {
"message": "正在準備實例"
},
"app.install.phase.preparing_java": {
"message": "正在準備 Java"
},
"app.install.phase.preparing_java.downloading": {
"message": "正在下載 Java {version}"
},
"app.install.phase.preparing_java.extracting": {
"message": "正在解壓縮 Java {version}"
},
"app.install.phase.preparing_java.fetching-metadata": {
"message": "正在擷取 Java {version}"
},
"app.install.phase.preparing_java.resolving": {
"message": "正在準備 Java {version}"
},
"app.install.phase.preparing_java.validating": {
"message": "正在驗證 Java {version}"
},
"app.install.phase.reading_pack_manifest": {
"message": "正在讀取模組包清單"
},
"app.install.phase.resolving_loader": {
"message": "正在解析載入器"
},
"app.install.phase.resolving_minecraft": {
"message": "正在解析 Minecraft"
},
"app.install.phase.resolving_pack": {
"message": "正在解析內容"
},
"app.install.phase.rolling_back": {
"message": "正在復原"
},
"app.install.phase.running_loader_processors": {
"message": "正在執行載入器處理程式"
},
"app.instance.confirm-delete.admonition-body": {
"message": "你實例中的所有資料將被永久刪除,包含你的世界、設定檔以及所有已安裝的內容。"
},
@@ -212,6 +281,15 @@
"app.instance.modpack-already-installed.instance": {
"message": "實例"
},
"app.instance.mods.bulk-update.downloading-projects": {
"message": "正在下載專案 {current, number}/{total, number}..."
},
"app.instance.mods.bulk-update.finishing": {
"message": "正在結束更新..."
},
"app.instance.mods.bulk-update.resolving-versions": {
"message": "正在解析版本..."
},
"app.instance.mods.content-type-project": {
"message": "專案"
},
@@ -236,6 +314,18 @@
"app.instance.worlds.browse-servers": {
"message": "瀏覽伺服器"
},
"app.instance.worlds.delete-world-modal.delete-button": {
"message": "刪除世界"
},
"app.instance.worlds.delete-world-modal.title": {
"message": "刪除世界"
},
"app.instance.worlds.delete-world-modal.warning-body": {
"message": "此世界將會被永久刪除,此操作無法復原。"
},
"app.instance.worlds.delete-world-modal.warning-header": {
"message": "正在刪除 {name}"
},
"app.instance.worlds.filter-modded": {
"message": "模組"
},
@@ -254,6 +344,18 @@
"app.instance.worlds.no-worlds-heading": {
"message": "未新增任何伺服器或世界"
},
"app.instance.worlds.remove-server-modal.remove-button": {
"message": "移除伺服器"
},
"app.instance.worlds.remove-server-modal.title": {
"message": "移除伺服器"
},
"app.instance.worlds.remove-server-modal.warning-body": {
"message": "這個伺服器將從你的伺服器清單中移除。如果你記得伺服器位址,稍後可以將其重新新增回來。"
},
"app.instance.worlds.remove-server-modal.warning-header": {
"message": "正在移除 {name}"
},
"app.instance.worlds.search-worlds-placeholder": {
"message": "搜尋 {count} 個世界..."
},
@@ -839,6 +941,9 @@
"instance.worlds.copy_address": {
"message": "複製位址"
},
"instance.worlds.create_shortcut": {
"message": "建立捷徑"
},
"instance.worlds.dont_show_on_home": {
"message": "不要在首頁顯示"
},
+24 -4
View File
@@ -22,6 +22,8 @@ import {
preferencesDiffer,
provideBrowseManager,
requestInstall,
stripServerRuntimeInstallFilters,
stripServerRuntimeInstallOverrides,
useBrowseSearch,
useDebugLogger,
useVIntl,
@@ -47,6 +49,7 @@ import {
get_installed_project_ids as getInstalledProjectIds,
} from '@/helpers/instance'
import { get_loader_versions as getLoaderManifest } from '@/helpers/metadata'
import { get as getSettings, set as setSettings } from '@/helpers/settings.ts'
import { get_categories, get_game_versions, get_loaders } from '@/helpers/tags'
import { get_instance_worlds } from '@/helpers/worlds'
import { injectContentInstall } from '@/providers/content-install'
@@ -403,7 +406,7 @@ const messages = defineMessages({
},
hideAddedServers: {
id: 'app.browse.hide-added-servers',
defaultMessage: 'Hide already added servers',
defaultMessage: 'Hide servers already added',
},
installingToServer: {
id: 'app.browse.server.installing',
@@ -680,7 +683,6 @@ async function chooseInstanceInstallVersion(
const selectedVersion = getLatestMatchingInstallVersion(
await getInstallProjectVersions(project.project_id),
selectedPreferences,
projectTypeValue,
)
if (!selectedVersion) {
@@ -763,11 +765,15 @@ function getCardActions(
project: projectResult,
contentType,
mode: isModpack ? 'immediate' : 'queue',
selectedFilters: isModpack ? [] : searchState.currentFilters.value,
selectedFilters: isModpack
? []
: stripServerRuntimeInstallFilters(searchState.currentFilters.value),
providedFilters: isModpack ? [] : combinedProvidedFilters.value,
overriddenProvidedFilterTypes: isModpack
? []
: searchState.overriddenProvidedFilterTypes.value,
: stripServerRuntimeInstallOverrides(
searchState.overriddenProvidedFilterTypes.value,
),
targetPreferences: getServerInstallTargetPreferences(contentType),
getProjectVersions: getInstallProjectVersions,
queue: serverInstallQueue,
@@ -1036,10 +1042,24 @@ function getProjectBrowseQuery() {
}
}
const advancedFiltersCollapsed = computed({
get: () => themeStore.getFeatureFlag('advanced_filters_collapsed'),
set: (value) => {
themeStore.featureFlags['advanced_filters_collapsed'] = value
getSettings()
.then((settings) => {
settings.feature_flags['advanced_filters_collapsed'] = value
return setSettings(settings)
})
.catch(handleError)
},
})
provideBrowseManager({
tags,
projectType,
...searchState,
advancedFiltersCollapsed,
getProjectLink: (result: Labrinth.Search.v2.ResultSearchProject) => ({
path: `/project/${result.project_id ?? result.slug}`,
query: getProjectBrowseQuery(),
+6
View File
@@ -114,6 +114,10 @@ const messages = defineMessages({
id: 'app.skins.section.tiny-takeover',
defaultMessage: 'Tiny Takeover',
},
chaosCubedSection: {
id: 'app.skins.section.chaos-cubed',
defaultMessage: 'Chaos Cubed',
},
rateLimitTitle: {
id: 'app.skins.rate-limit.title',
defaultMessage: 'Slow down!',
@@ -447,6 +451,8 @@ function getDefaultSkinSectionTitle(section?: string) {
return formatMessage(messages.mountsOfMayhemSection)
case 'Tiny Takeover':
return formatMessage(messages.tinyTakeoverSection)
case 'Chaos Cubed':
return formatMessage(messages.chaosCubedSection)
case 'Default skins':
return formatMessage(messages.defaultSkinsSection)
default:
@@ -1,10 +1,15 @@
<template>
<div class="h-full w-full pt-6">
<div
class="h-full w-full pt-6"
:class="isContainedServerRoute ? 'box-border min-h-0 overflow-hidden' : ''"
>
<ServersManageRootLayout
:server-id="serverId"
:layout-mode="isContainedServerRoute ? 'contained' : 'page'"
:reload-page="() => router.go(0)"
:resolve-viewer="resolveViewer"
:show-copy-id-action="themeStore.devMode"
:show-play-tab="themeStore.getFeatureFlag('ServerPlayFrontend')"
:auth-user="authUser"
:navigate-to-billing="() => openUrl('https://modrinth.com/settings/billing')"
:navigate-to-servers="() => router.push('/hosting/manage')"
@@ -63,6 +68,8 @@ const queryClient = useQueryClient()
const themeStore = useTheming()
const breadcrumbs = useBreadcrumbs()
const isContainedServerRoute = computed(() => route.name === 'ServerManageOverview')
const serverId = computed(() => {
const rawId = route.params.id
return Array.isArray(rawId) ? rawId[0] : (rawId ?? '')
@@ -3,5 +3,5 @@ import { ServersManageOverviewPage } from '@modrinth/ui'
</script>
<template>
<ServersManageOverviewPage />
<ServersManageOverviewPage contained-console />
</template>
@@ -0,0 +1,18 @@
<script setup lang="ts">
import { ServersManagePlayPage } from '@modrinth/ui'
import { useRoute, useRouter } from 'vue-router'
import { useTheming } from '@/store/theme'
const route = useRoute()
const router = useRouter()
const themeStore = useTheming()
if (!themeStore.getFeatureFlag('ServerPlayFrontend')) {
await router.replace(`/hosting/manage/${String(route.params.id)}`)
}
</script>
<template>
<ServersManagePlayPage />
</template>
@@ -4,5 +4,6 @@ import Content from './Content.vue'
import Files from './Files.vue'
import Index from './Index.vue'
import Overview from './Overview.vue'
import Play from './Play.vue'
export { Access, Backups, Content, Files, Index, Overview }
export { Access, Backups, Content, Files, Index, Overview, Play }
+226 -42
View File
@@ -13,6 +13,10 @@
@unlinked="fetchInstance"
/>
<UpdateToPlayModal ref="updateToPlayModal" :instance="instance" />
<SharedInstanceUpdateModal
ref="sharedInstanceUpdateModal"
@shared-instance-unavailable="handleSharedInstanceUnavailable"
/>
<ContentPageHeader>
<template #icon>
<Avatar
@@ -25,6 +29,18 @@
<template #title>
{{ instance.name }}
</template>
<template v-if="instance.shared_instance" #title-suffix>
<div
class="inline-flex h-7 items-center gap-1 rounded-full border border-solid border-blue bg-highlight-blue px-2.5 !text-base !font-normal leading-none text-blue"
>
Shared
<UnknownIcon
v-tooltip="sharedInstanceTooltip"
class="size-4 cursor-help"
aria-label="Shared instance information"
/>
</div>
</template>
<template #stats>
<div class="flex items-center flex-wrap gap-2">
<template v-if="!isServerInstance">
@@ -89,6 +105,23 @@
</router-link>
</div>
</template>
<template v-if="sharedInstanceManager">
<div class="w-1.5 h-1.5 rounded-full bg-surface-5"></div>
<div class="flex min-w-0 items-center gap-[5px] font-medium">
{{ sharedInstanceManager.type === 'server' ? 'Linked to' : 'Managed by' }}
<Avatar
:src="sharedInstanceManager.avatarUrl"
:alt="sharedInstanceManager.name"
:tint-by="sharedInstanceManager.tintBy"
size="24px"
:circle="sharedInstanceManager.type === 'user'"
no-shadow
/>
<span class="min-w-0 truncate">{{ sharedInstanceManager.name }}</span>
</div>
</template>
</div>
</template>
<template #actions>
@@ -217,6 +250,17 @@
</div>
<div :class="['px-6', { 'shrink-0': isFixedRender }]">
<NavTabs :links="tabs" />
<InstanceAdmonitions
class="mt-4"
:instance="instance"
:shared-instance-unavailable-reason="sharedInstanceUnavailableReason"
:shared-instance-unavailable-manager="sharedInstanceUnavailableManager"
:shared-instance-wrong-account="sharedInstanceWrongAccount"
:shared-instance-expected-user-id="sharedInstanceExpectedUserId"
:shared-instance-role="instance.shared_instance?.role"
:shared-instance-signed-out="sharedInstanceSignedOut"
@published="fetchInstance"
/>
</div>
<div :class="['p-6 pt-4', { 'min-h-0 flex-1 overflow-y-auto': isFixedRender }]">
<RouterView v-slot="{ Component }" :key="instance.id" :route="displayedInstanceRoute">
@@ -288,6 +332,7 @@ import {
SettingsIcon,
StopCircleIcon,
TerminalSquareIcon,
UnknownIcon,
UpdatedIcon,
UserPlusIcon,
XIcon,
@@ -296,6 +341,7 @@ import {
Avatar,
ButtonStyled,
ContentPageHeader,
defineMessages,
injectNotificationManager,
NavTabs,
OverflowMenu,
@@ -304,6 +350,7 @@ import {
ServerRecentPlays,
ServerRegion,
useLoadingBarToken,
useVIntl,
} from '@modrinth/ui'
import { useQueryClient } from '@tanstack/vue-query'
import { convertFileSrc } from '@tauri-apps/api/core'
@@ -315,23 +362,36 @@ import { useRoute, useRouter } from 'vue-router'
import ContextMenu from '@/components/ui/ContextMenu.vue'
import ExportModal from '@/components/ui/ExportModal.vue'
import InstanceAdmonitions from '@/components/ui/instance/instance-admonitions/index.vue'
import InstanceSettingsModal from '@/components/ui/modal/InstanceSettingsModal.vue'
import UpdateToPlayModal from '@/components/ui/modal/UpdateToPlayModal.vue'
import SharedInstanceUpdateModal from '@/components/ui/shared-instances/SharedInstanceUpdateModal.vue'
import {
fetchCachedServerStatus,
getFreshCachedServerStatus,
} from '@/composables/instances/use-server-status-query'
import { useInstanceConsole } from '@/composables/useInstanceConsole'
import { trackEvent } from '@/helpers/analytics'
import { get_project_v3 } from '@/helpers/cache.js'
import { instance_listener, process_listener } from '@/helpers/events'
import { install_existing_instance, install_pack_to_existing_instance } from '@/helpers/install'
import {
install_existing_instance,
install_pack_to_existing_instance,
type SharedInstanceUnavailableReason,
} from '@/helpers/install'
import { get, get_full_path, kill, run } from '@/helpers/instance'
import { type InstanceContentData, loadInstanceContentData } from '@/helpers/instance-content'
import { get_by_instance_id } from '@/helpers/process'
import { useSharedInstanceErrors } from '@/helpers/shared-instance-errors'
import type { GameInstance } from '@/helpers/types'
import { createInstanceShortcut, showInstanceInFolder } from '@/helpers/utils.js'
import { get_server_status, refreshWorlds } from '@/helpers/worlds'
import { refreshWorlds, type ServerStatus } from '@/helpers/worlds'
import { injectServerInstall } from '@/providers/server-install'
import { handleSevereError } from '@/store/error.js'
import { useBreadcrumbs, useTheming } from '@/store/state'
import { provideSharedInstanceState, useSharedInstanceState } from './use-shared-instance-state'
dayjs.extend(duration)
dayjs.extend(relativeTime)
@@ -363,6 +423,21 @@ const subpagePending = ref(false)
const stopping = ref(false)
const exportModal = ref<InstanceType<typeof ExportModal>>()
const updateToPlayModal = ref<InstanceType<typeof UpdateToPlayModal>>()
const sharedInstanceUpdateModal = ref<InstanceType<typeof SharedInstanceUpdateModal>>()
const { formatMessage } = useVIntl()
const { notifySharedInstanceError, notifySharedInstanceUnavailable } = useSharedInstanceErrors()
const messages = defineMessages({
sharedInstanceTooltip: {
id: 'instance.shared-instance.tooltip',
defaultMessage: "This instance's content is being managed by someone else.",
},
sharedInstanceOwnerTooltip: {
id: 'instance.shared-instance.owner-tooltip',
defaultMessage: "This instance's content is being shared to other users.",
},
})
useLoadingBarToken(subpagePending)
@@ -372,13 +447,35 @@ const selected = ref<unknown[]>([])
const minecraftServer = computed(() => linkedProjectV3.value?.minecraft_server)
const javaServerPingData = computed(() => linkedProjectV3.value?.minecraft_java_server?.ping?.data)
const statusOnline = computed(() => !!javaServerPingData.value)
const liveServerStatusOnline = ref(false)
const statusOnline = computed(() => liveServerStatusOnline.value || !!javaServerPingData.value)
const recentPlays = computed(
() => linkedProjectV3.value?.minecraft_java_server?.verified_plays_2w ?? undefined,
)
const playersOnline = ref<number | undefined>(undefined)
const ping = ref<number | undefined>(undefined)
const loadingServerPing = ref(false)
const activeInstanceId = ref<string>()
const sharedInstanceState = useSharedInstanceState(instance, offline, notifySharedInstanceError)
provideSharedInstanceState(sharedInstanceState)
const {
actionsLocked: sharedInstanceActionsLocked,
expectedUserId: sharedInstanceExpectedUserId,
manager: sharedInstanceManager,
setUnavailable: setSharedInstanceUnavailable,
signedOut: sharedInstanceSignedOut,
unavailableManager: sharedInstanceUnavailableManager,
unavailableReason: sharedInstanceUnavailableReason,
updatePreview: sharedInstanceUpdatePreview,
wrongAccount: sharedInstanceWrongAccount,
} = sharedInstanceState
const sharedInstanceTooltip = computed(() =>
formatMessage(
instance.value?.shared_instance?.role === 'owner'
? messages.sharedInstanceOwnerTooltip
: messages.sharedInstanceTooltip,
),
)
watch(
() => router.currentRoute.value,
@@ -390,6 +487,20 @@ watch(
{ immediate: true },
)
function applyServerStatus(status: ServerStatus) {
playersOnline.value = status.players?.online
ping.value = status.ping
liveServerStatusOnline.value = true
loadingServerPing.value = true
}
function resetServerStatus() {
ping.value = undefined
playersOnline.value = undefined
liveServerStatusOnline.value = false
loadingServerPing.value = false
}
function isContentSubpageRoute(routeName = displayedInstanceRoute.value.name) {
return typeof routeName === 'string' && contentSubpageRouteNames.has(routeName)
}
@@ -398,9 +509,8 @@ async function fetchInstance() {
isServerInstance.value = false
linkedProjectV3.value = undefined
preloadedContent.value = null
ping.value = undefined
playersOnline.value = undefined
loadingServerPing.value = false
sharedInstanceState.reset()
resetServerStatus()
const nextInstance = await get(route.params.id as string).catch(handleError)
let nextLinkedProjectV3: Labrinth.Projects.v3.Project | undefined
@@ -424,13 +534,14 @@ async function fetchInstance() {
}
const nextPreloadedContent = await contentPreloadPromise
instance.value = nextInstance ?? undefined
sharedInstanceState.refreshAvailability()
linkedProjectV3.value = nextLinkedProjectV3
isServerInstance.value = nextIsServerInstance
preloadedContent.value = nextPreloadedContent
activeInstanceId.value = nextInstance?.id
fetchDeferredData()
fetchDeferredData(nextInstance?.id)
if (nextInstance) {
queryClient.prefetchQuery({
@@ -441,18 +552,32 @@ async function fetchInstance() {
}
}
function fetchDeferredData() {
function fetchDeferredData(instanceId?: string) {
const serverAddress = linkedProjectV3.value?.minecraft_java_server?.address
if (isServerInstance.value && serverAddress) {
get_server_status(serverAddress)
const cachedStatus = getFreshCachedServerStatus(queryClient, serverAddress)
if (cachedStatus) {
applyServerStatus(cachedStatus)
} else {
playersOnline.value = undefined
ping.value = undefined
loadingServerPing.value = false
}
fetchCachedServerStatus(queryClient, serverAddress)
.then((status) => {
playersOnline.value = status.players?.online
ping.value = status.ping
if (
activeInstanceId.value !== instanceId ||
linkedProjectV3.value?.minecraft_java_server?.address !== serverAddress
)
return
applyServerStatus(status)
})
.catch((error) => {
console.error(`Failed to fetch server status for ${serverAddress}:`, error)
})
.finally(() => {
if (activeInstanceId.value !== instanceId) return
loadingServerPing.value = true
})
} else {
@@ -498,29 +623,50 @@ const isFixedRender = computed(() => renderMode.value === 'fixed')
const contentSubpageProps = computed(() =>
isContentSubpageRoute() ? { preloadedContent: preloadedContent.value } : {},
)
const showShareTab = computed(() => {
const linkType = instance.value?.link?.type
const tabs = computed(() => [
{
label: 'Content',
href: `${basePath.value}`,
icon: BoxesIcon,
},
{
label: 'Files',
href: `${basePath.value}/files`,
icon: FolderOpenIcon,
},
{
label: 'Worlds',
href: `${basePath.value}/worlds`,
icon: GlobeIcon,
},
{
label: 'Logs',
href: `${basePath.value}/logs`,
icon: TerminalSquareIcon,
},
])
return (
instance.value?.shared_instance?.role !== 'member' &&
linkType !== 'server_project' &&
linkType !== 'server_project_modpack'
)
})
const tabs = computed(() => {
const instanceTabs = [
{
label: 'Content',
href: `${basePath.value}`,
icon: BoxesIcon,
},
{
label: 'Files',
href: `${basePath.value}/files`,
icon: FolderOpenIcon,
},
{
label: 'Worlds',
href: `${basePath.value}/worlds`,
icon: GlobeIcon,
},
{
label: 'Logs',
href: `${basePath.value}/logs`,
icon: TerminalSquareIcon,
},
]
if (showShareTab.value) {
instanceTabs.push({
label: 'Share',
href: `${basePath.value}/share`,
icon: UserPlusIcon,
})
}
return instanceTabs
})
if (instance.value) {
breadcrumbs.setName(
@@ -538,13 +684,7 @@ if (instance.value) {
const options = ref<InstanceType<typeof ContextMenu> | null>(null)
const startInstance = async (context: string) => {
if (!instance.value) return
if (updateToPlayModal.value?.hasUpdate) {
updateToPlayModal.value.show(instance.value)
return
}
const launchInstance = async (context: string) => {
loading.value = true
try {
await run(route.params.id as string)
@@ -554,6 +694,7 @@ const startInstance = async (context: string) => {
}
loading.value = false
if (!instance.value) return
trackEvent('InstanceStart', {
loader: instance.value.loader,
game_version: instance.value.game_version,
@@ -561,6 +702,49 @@ const startInstance = async (context: string) => {
})
}
async function handleSharedInstanceUnavailable(
reason: SharedInstanceUnavailableReason | null = null,
) {
notifySharedInstanceUnavailable(reason, sharedInstanceUnavailableManager.value)
await fetchInstance()
setSharedInstanceUnavailable(reason)
}
const startInstance = async (context: string) => {
if (!instance.value) return
if (loading.value || playing.value) return
const isSharedInstanceMember = instance.value.shared_instance?.role === 'member'
const canCheckSharedInstanceUpdate =
isSharedInstanceMember && !sharedInstanceActionsLocked.value && !offline.value
if (canCheckSharedInstanceUpdate) {
const preview = sharedInstanceUpdatePreview.value
if (preview?.updateAvailable && sharedInstanceUpdateModal.value) {
sharedInstanceUpdateModal.value.show(instance.value, preview, async () => {
await fetchInstance()
await launchInstance(context)
})
return
}
}
if (updateToPlayModal.value?.hasUpdate) {
if (isSharedInstanceMember) {
updateToPlayModal.value.show(instance.value, null, async () => {
await fetchInstance()
await launchInstance(context)
})
} else {
updateToPlayModal.value.show(instance.value)
}
return
}
await launchInstance(context)
}
const stopInstance = async (context: string) => {
stopping.value = true
await kill(route.params.id as string).catch(handleError)
@@ -676,7 +860,7 @@ const handleOptionsClick = async (args: { option: string; item: unknown }) => {
break
case 'copy_path': {
if (instance.value) {
const fullPath = await get_full_path(instance.value?.path)
const fullPath = await get_full_path(instance.value.id)
await navigator.clipboard.writeText(fullPath)
}
break
+71 -39
View File
@@ -12,14 +12,24 @@
ref="modpackContentModal"
:modpack-name="displayedModpackProject?.title"
:modpack-icon-url="displayedModpackProject?.icon_url ?? undefined"
:enable-toggle="!props.isServerInstance"
:enable-toggle="!props.isServerInstance && !isSharedMember"
:busy="isBulkOperating"
:get-overflow-options="getOverflowOptions"
:switch-version="handleSwitchVersion"
:switch-version="
props.isServerInstance || isSharedMember ? undefined : handleSwitchVersion
"
@update:enabled="handleModpackContentToggle"
@bulk:enable="(items) => handleModpackContentBulkToggle(items, true)"
@bulk:disable="(items) => handleModpackContentBulkToggle(items, false)"
/>
<ConfirmDisableModal
ref="sharedDisableConfirmModal"
:count="pendingModpackDisableItems.length"
:item-type="formatMessage(messages.contentTypeProject)"
:warning="managedContentPolicy.disableWarning(pendingModpackDisableItems)"
:action-disabled="isInstanceBusy"
@disable="confirmPendingModpackContentDisable"
/>
<ConfirmModpackUpdateModal
ref="modpackUpdateConfirmModal"
:downgrade="isModpackUpdateDowngrade"
@@ -71,6 +81,7 @@ import { ClipboardCopyIcon, FolderOpenIcon } from '@modrinth/assets'
import {
type BulkOperationStatus,
commonMessages,
ConfirmDisableModal,
ConfirmModpackUpdateModal,
ContentCardLayout as ContentPageLayout,
type ContentItem,
@@ -84,7 +95,6 @@ import {
ModpackContentModal,
type ModpackContentModalState,
type OverflowMenuOption,
provideAppBackup,
provideContentManager,
ReadyTransition,
useDebugLogger,
@@ -101,6 +111,7 @@ import { useRouter } from 'vue-router'
import ExportModal from '@/components/ui/ExportModal.vue'
import ShareModalWrapper from '@/components/ui/modal/ShareModalWrapper.vue'
import { useManagedContentPolicy } from '@/composables/instances/use-managed-content-policy'
import { trackEvent } from '@/helpers/analytics'
import { get_project_versions, get_version, get_version_many } from '@/helpers/cache.js'
import {
@@ -108,12 +119,10 @@ import {
instance_listener,
type InstanceBulkUpdateProgress,
} from '@/helpers/events.js'
import { install_duplicate_instance, installJobInstanceId } from '@/helpers/install'
import {
add_project_from_path,
edit,
get_linked_modpack_content,
list,
remove_project,
switch_project_version_with_dependencies,
toggle_disable_project,
@@ -124,6 +133,7 @@ import { type InstanceContentData, loadInstanceContentData } from '@/helpers/ins
import type { CacheBehaviour, GameInstance } from '@/helpers/types'
import { highlightModInInstance } from '@/helpers/utils.js'
import { injectContentInstall } from '@/providers/content-install'
import { provideInstanceBackup } from '@/providers/instance-backup'
import { useTheming } from '@/store/state'
const messages = defineMessages({
@@ -185,6 +195,12 @@ const props = defineProps<{
openSettings?: () => void
preloadedContent?: InstanceContentData | null
}>()
const managedContentPolicy = useManagedContentPolicy(computed(() => props.instance))
const {
isManagedModpack: isSharedMember,
canMutateContent,
canUpdateContent: canUpdateProject,
} = managedContentPolicy
function hasPreloadedContent(contentData: InstanceContentData | null | undefined) {
return contentData?.path === props.instance.id
@@ -284,6 +300,8 @@ const exportModal = ref(null)
const contentUpdaterModal = ref<InstanceType<typeof ContentUpdaterModal> | null>()
const modpackContentModal = ref<InstanceType<typeof ModpackContentModal> | null>()
const modpackUpdateConfirmModal = ref<InstanceType<typeof ConfirmModpackUpdateModal> | null>()
const sharedDisableConfirmModal = ref<InstanceType<typeof ConfirmDisableModal> | null>()
const pendingModpackDisableItems = ref<ContentItem[]>([])
const modpackContentQueryKey = computed(() => ['linkedModpackContent', props.instance.id])
const modpackContentQuery = useQuery({
@@ -362,8 +380,8 @@ function hasContentOperation(item: ContentItem) {
return keys.some((key) => activeContentOperationKeys.value.has(key))
}
function canUpdateProject(item: ContentItem) {
return !!item.file_path && !!item.has_update && !!item.update_version_id
function canDeleteContent(item: ContentItem) {
return canMutateContent(item)
}
function setContentItemBusy(item: ContentItem, busy: boolean, originalFileName = item.file_name) {
@@ -716,6 +734,7 @@ async function updateProject(mod: ContentItem) {
}
async function switchProjectVersion(mod: ContentItem, version: Labrinth.Versions.v2.Version) {
if (!canMutateContent(mod)) return
if (!mod.file_path) return
const operation = beginContentOperation(mod)
if (!operation) return
@@ -852,6 +871,7 @@ async function handleUpdate(id: string) {
}
async function handleSwitchVersion(item: ContentItem) {
if (!canMutateContent(item)) return
if (!item.project?.id || !item.version?.id) return
const requestId = beginUpdateRequest()
@@ -879,10 +899,32 @@ async function handleSwitchVersion(item: ContentItem) {
}
async function handleModpackContentToggle(item: ContentItem, enabled: boolean) {
if (!enabled && managedContentPolicy.disableWarning([item])) {
pendingModpackDisableItems.value = [item]
sharedDisableConfirmModal.value?.show()
return
}
await toggleDisableDebounced(item, enabled)
}
async function handleModpackContentBulkToggle(items: ContentItem[], enabled: boolean) {
if (!enabled && managedContentPolicy.disableWarning(items)) {
pendingModpackDisableItems.value = items
sharedDisableConfirmModal.value?.show()
return
}
await setModpackContentEnabled(items, enabled)
}
async function confirmPendingModpackContentDisable() {
const items = [...pendingModpackDisableItems.value]
pendingModpackDisableItems.value = []
await setModpackContentEnabled(items, false)
}
async function setModpackContentEnabled(items: ContentItem[], enabled: boolean) {
await Promise.all(items.map((item) => toggleDisableMod(item, enabled)))
}
@@ -1232,29 +1274,7 @@ function applyContentData(contentData: InstanceContentData) {
return true
}
provideAppBackup({
async createBackup() {
const allInstances = await list()
const prefix = `${props.instance.name} - Backup #`
const existingNums = allInstances
.filter((p) => p.name.startsWith(prefix))
.map((p) => parseInt(p.name.slice(prefix.length), 10))
.filter((n) => !isNaN(n))
const nextNum = existingNums.length > 0 ? Math.max(...existingNums) + 1 : 1
const job = await install_duplicate_instance(props.instance.id)
const newInstanceId = installJobInstanceId(job)
if (newInstanceId) {
await edit(newInstanceId, { name: `${prefix}${nextNum}` })
}
},
})
const CONTENT_HINT_KEY = 'content-tab-modpack-hint-dismissed'
const showContentHint = ref(localStorage.getItem(CONTENT_HINT_KEY) === null)
function dismissContentHint() {
showContentHint.value = false
localStorage.setItem(CONTENT_HINT_KEY, 'true')
}
provideInstanceBackup(() => props.instance)
provideContentManager({
items: mergedProjects,
@@ -1316,15 +1336,23 @@ provideContentManager({
toggleEnabled: toggleDisableDebounced,
bulkEnableItems: (items: ContentItem[]) =>
Promise.all(
items.filter((item) => !item.enabled).map((item) => toggleDisableMod(item, true)),
items
.filter((item) => canMutateContent(item) && !item.enabled)
.map((item) => toggleDisableMod(item, true)),
).then(() => {}),
bulkDisableItems: (items: ContentItem[]) =>
Promise.all(
items.filter((item) => item.enabled).map((item) => toggleDisableMod(item, false)),
items
.filter((item) => canMutateContent(item) && item.enabled)
.map((item) => toggleDisableMod(item, false)),
).then(() => {}),
deleteItem: removeMod,
bulkDeleteItems: (items: ContentItem[]) =>
Promise.all(items.map((item) => removeMod(item))).then(() => {}),
Promise.all(items.filter(canMutateContent).map((item) => removeMod(item))).then(() => {}),
canDeleteItem: canDeleteContent,
canToggleItem: canMutateContent,
getDeleteWarning: managedContentPolicy.deleteWarning,
getDisableWarning: managedContentPolicy.disableWarning,
getDeleteDependencyWarning,
refresh: () => initProjects('must_revalidate'),
browse: handleBrowseContent,
@@ -1333,14 +1361,12 @@ provideContentManager({
updateItem: handleUpdate,
bulkUpdateAll: bulkUpdateAllProjects,
bulkUpdateItem: updateProject,
updateModpack: props.isServerInstance ? undefined : handleModpackUpdate,
updateModpack: props.isServerInstance || isSharedMember.value ? undefined : handleModpackUpdate,
viewModpackContent: handleModpackContent,
unlinkModpack: unpairInstance,
openSettings: props.openSettings,
switchVersion: handleSwitchVersion,
getOverflowOptions,
showContentHint,
dismissContentHint,
shareItems: handleShareItems,
getItemId: getContentItemId,
mapToTableItem: (item: ContentItem) => ({
@@ -1351,7 +1377,9 @@ provideContentManager({
title: item.file_name.replace('.disabled', ''),
icon_url: null,
},
projectLink: item.project?.id ? { path: `/project/${item.project.id}` } : undefined,
projectLink: item.project?.id
? { path: `/project/${item.project.id}`, query: { i: props.instance.id } }
: undefined,
version: item.version ?? {
id: item.file_name,
version_number: formatMessage(commonMessages.unknownLabel),
@@ -1361,6 +1389,7 @@ provideContentManager({
item.project?.id && item.version?.id
? {
path: `/project/${item.project.id}/version/${item.version.id}`,
query: { i: props.instance.id },
}
: undefined,
owner: item.owner
@@ -1369,8 +1398,11 @@ provideContentManager({
link: () => openUrl(`https://modrinth.com/${item.owner!.type}/${item.owner!.id}`),
}
: undefined,
enabled: item.enabled,
enabled: canMutateContent(item) ? item.enabled : undefined,
installing: item.installing,
hideDelete: !canDeleteContent(item),
hideSwitchVersion: !canMutateContent(item),
hasUpdate: canUpdateProject(item),
}),
filterPersistKey: props.instance.id,
})
@@ -1443,7 +1475,7 @@ onMounted(() => {
props.instance &&
event.instance_id === props.instance.id &&
event.event === 'synced' &&
props.instance.install_stage !== 'pack_installing' &&
props.instance.install_stage === 'installed' &&
!isBulkOperating.value
) {
await initProjects()
@@ -618,10 +618,7 @@ const filterOptions = computed(() => {
const hasSingleplayer = dedupedWorlds.value.some((x) => x.type === 'singleplayer')
const hasServer = dedupedWorlds.value.some((x) => x.type === 'server')
const hasStatusFilter =
selectedFilters.value.includes('online') || selectedFilters.value.includes('offline')
if (hasSingleplayer && hasServer && !hasStatusFilter) {
if (hasSingleplayer && hasServer) {
options.push({ id: 'singleplayer', label: formatMessage(commonMessages.singleplayerLabel) })
}
@@ -633,13 +630,11 @@ const filterOptions = computed(() => {
options.push({ id: 'vanilla', label: formatMessage(messages.vanillaFilter) })
options.push({ id: 'modded', label: formatMessage(messages.moddedFilter) })
}
if (!selectedFilters.value.includes('singleplayer')) {
const hasOnline = servers.some((x) => !!serverData.value[x.address]?.status)
const hasOffline = servers.some((x) => !serverData.value[x.address]?.status)
if (hasOnline && hasOffline) {
options.push({ id: 'online', label: formatMessage(messages.onlineFilter) })
options.push({ id: 'offline', label: formatMessage(messages.offlineFilter) })
}
const hasOnline = servers.some((x) => !!serverData.value[x.address]?.status)
const hasOffline = servers.some((x) => !serverData.value[x.address]?.status)
if (hasOnline && hasOffline) {
options.push({ id: 'online', label: formatMessage(messages.onlineFilter) })
options.push({ id: 'offline', label: formatMessage(messages.offlineFilter) })
}
}
@@ -3,6 +3,7 @@ import Index from './Index.vue'
import Logs from './Logs.vue'
import Mods from './Mods.vue'
import Overview from './Overview.vue'
import Share from './share/index.vue'
import Worlds from './Worlds.vue'
export { Files, Index, Logs, Mods, Overview, Worlds }
export { Files, Index, Logs, Mods, Overview, Share, Worlds }
@@ -0,0 +1,356 @@
<template>
<div class="flex flex-col gap-4">
<ModrinthAccountRequiredModal ref="accountRequiredModal" :request-auth="requestAuth" />
<InvitePlayersModal
ref="invitePlayersModal"
:header="formatMessage(messages.shareModalHeader, { name: instance.name })"
:friends="inviteFriends"
:search-users="searchInviteUsers"
:link="inviteLink.link.value"
:link-expires-at="inviteLink.details.value?.expiresAt"
:link-max-uses="inviteLink.details.value?.maxUses"
:update-invite-link="inviteLink.update"
:user-profile-link="userProfileLink"
@invite="invitePlayer"
@cancel="cancelInvite"
/>
<ConfirmUnlinkModal
ref="unlinkModal"
:warning="{
header: formatMessage(messages.unlinkForShareHeader),
body: formatMessage(messages.unlinkForShareBody),
}"
:backup-tip="importedModpackBackupTip"
@unlink="unlinkImportedModpack"
/>
<SharedInstanceRemoveMemberModal
ref="removeMemberModal"
:row="pendingRemovalRow"
:member-count="members.rows.value.length"
@confirm="removeMember"
@clear="pendingRemovalRow = null"
/>
<SharedPlayersTable
v-if="members.rows.value.length > 0"
:rows="members.rows.value"
:actions-locked="sharedInstanceActionsLocked"
:invite-pending="inviteLink.pending.value"
:user-profile-link="userProfileLink"
@invite="showInvitePlayers"
@remove="showRemoveMemberModal"
/>
<SharedInstanceShareEmptyState
v-else-if="sharedInstanceUnavailable"
:heading="formatMessage(sharedInstanceErrorMessages.unavailableTitle)"
:description="
formatSharedInstanceUnavailable(
sharedInstanceUnavailableReason,
sharedInstanceUnavailableManager,
)
"
/>
<SharedInstanceShareEmptyState
v-else-if="sharedInstanceActionsLocked"
:heading="formatMessage(lockedEmptyHeading)"
>
<template #description>
<span class="flex flex-wrap items-center justify-center gap-x-1.5 gap-y-1">
<span>{{ formatMessage(messages.lockedEmptyDescriptionPrefix) }}</span>
<span
v-if="linkedAccount"
class="inline-flex max-w-full min-w-0 items-center gap-1.5 align-middle font-semibold text-primary"
>
<Avatar
:src="linkedAccount.avatarUrl"
:alt="linkedAccount.username"
:tint-by="linkedAccount.tintBy"
size="20px"
circle
no-shadow
/>
<span class="min-w-0 truncate">{{ linkedAccount.username }}</span>
</span>
<span v-else class="font-semibold text-primary">{{
formatMessage(messages.linkedAccountFallback)
}}</span>
<span>{{ formatMessage(messages.lockedEmptyDescriptionSuffix) }}</span>
</span>
</template>
<template #actions>
<ButtonStyled color="brand"
><button class="!h-10" @click="signInToShare">
<LogInIcon aria-hidden="true" />{{ formatMessage(lockedActionButton) }}
</button></ButtonStyled
>
</template>
</SharedInstanceShareEmptyState>
<SharedInstanceShareEmptyState
v-else
:heading="formatMessage(messages.noFriendsInvitedHeading)"
:description="formatMessage(messages.noFriendsInvitedDescription)"
>
<template #actions>
<ButtonStyled color="brand"
><button
class="!h-10"
:disabled="inviteLink.pending.value"
@click="showInvitePlayers($event)"
>
<SpinnerIcon
v-if="inviteLink.pending.value"
class="animate-spin"
aria-hidden="true"
/><UserPlusIcon v-else aria-hidden="true" />{{
formatMessage(messages.inviteFriendsButton)
}}
</button></ButtonStyled
>
</template>
</SharedInstanceShareEmptyState>
</div>
</template>
<script setup lang="ts">
import { LogInIcon, SpinnerIcon, UserPlusIcon } from '@modrinth/assets'
import {
Avatar,
ButtonStyled,
ConfirmUnlinkModal,
defineMessages,
injectAuth,
injectNotificationManager,
type InvitePlayersInvitePayload,
InvitePlayersModal,
type InvitePlayersUser,
SharedPlayersTable,
useVIntl,
} from '@modrinth/ui'
import { useQueryClient } from '@tanstack/vue-query'
import { openUrl } from '@tauri-apps/plugin-opener'
import { computed, ref, toRef, watch } from 'vue'
import ModrinthAccountRequiredModal from '@/components/ui/modal/ModrinthAccountRequiredModal.vue'
import {
getSharedInstanceUnavailableReason,
isSharedInstanceUnavailableError,
} from '@/helpers/install'
import { edit } from '@/helpers/instance'
import type { ModrinthAuthFlow } from '@/helpers/mr_auth.ts'
import {
sharedInstanceErrorMessages,
useSharedInstanceErrors,
} from '@/helpers/shared-instance-errors'
import type { GameInstance } from '@/helpers/types'
import { provideInstanceBackup } from '@/providers/instance-backup'
import { injectSharedInstanceState } from '../use-shared-instance-state'
import SharedInstanceRemoveMemberModal from './shared-instance-remove-member-modal.vue'
import SharedInstanceShareEmptyState from './shared-instance-share-empty-state.vue'
import type { ShareRow } from './shared-instance-share-types'
import { useSharedInstanceInviteCandidates } from './use-shared-instance-invite-candidates'
import { useSharedInstanceInviteLink } from './use-shared-instance-invite-link'
import { useSharedInstanceMembers } from './use-shared-instance-members'
const props = defineProps<{ instance: GameInstance }>()
const auth = injectAuth()
const queryClient = useQueryClient()
const { handleError } = injectNotificationManager()
const { formatMessage } = useVIntl()
const {
formatSharedInstanceUnavailable,
notifySharedInstanceError,
notifySharedInstanceUnavailable,
} = useSharedInstanceErrors()
const sharedInstanceState = injectSharedInstanceState()
const instance = toRef(props, 'instance')
const actionsLocked = sharedInstanceState.shareActionsLocked
const sharedInstanceActionsLocked = actionsLocked
const currentUserId = computed(() => auth.user.value?.id ?? null)
const isSignedIn = computed(() => !!auth.session_token.value)
const accountRequiredModal = ref<InstanceType<typeof ModrinthAccountRequiredModal>>()
const invitePlayersModal = ref<InstanceType<typeof InvitePlayersModal>>()
const unlinkModal = ref<InstanceType<typeof ConfirmUnlinkModal>>()
const removeMemberModal = ref<InstanceType<typeof SharedInstanceRemoveMemberModal>>()
const pendingRemovalRow = ref<ShareRow | null>(null)
const importedModpackUnlinked = ref(false)
function notifyOperationError(error: unknown) {
if (isSharedInstanceUnavailableError(error)) {
notifySharedInstanceUnavailable(
getSharedInstanceUnavailableReason(error),
sharedInstanceState.unavailableManager.value,
)
} else {
notifySharedInstanceError(error instanceof Error ? error : new Error(String(error)))
}
}
const members = useSharedInstanceMembers({
instance,
currentUserId,
isSignedIn,
actionsLocked,
onError: notifyOperationError,
onHydrationError: handleError,
})
const {
inviteFriends,
search: searchInviteUsers,
requestFriend,
} = useSharedInstanceInviteCandidates({
rows: members.rows,
currentUserId,
isSignedIn,
actionsLocked,
})
const inviteLink = useSharedInstanceInviteLink(
computed(() => props.instance.id),
notifyOperationError,
)
const linkedAccount = computed(() => {
const manager = sharedInstanceState.manager.value
return manager?.type === 'user'
? { username: manager.name, avatarUrl: manager.avatarUrl, tintBy: manager.tintBy }
: null
})
const lockedEmptyHeading = computed(() =>
isSignedIn.value ? messages.lockedWrongAccountHeading : messages.lockedSignedOutHeading,
)
const lockedActionButton = computed(() =>
isSignedIn.value ? messages.switchAccountButton : messages.signInButton,
)
const sharedInstanceUnavailableReason = sharedInstanceState.unavailableReason
const sharedInstanceUnavailable = computed(() => !!sharedInstanceUnavailableReason.value)
const sharedInstanceUnavailableManager = sharedInstanceState.unavailableManager
const requiresUnlink = computed(
() =>
props.instance.link?.type === 'imported_modpack' &&
!props.instance.shared_instance &&
!importedModpackUnlinked.value,
)
const importedModpackBackupTip = computed(() =>
props.instance.link?.type === 'imported_modpack'
? (props.instance.link.name ?? props.instance.link.filename ?? undefined)
: undefined,
)
const messages = defineMessages({
signInButton: { id: 'app.instance.share.sign-in.button', defaultMessage: 'Sign in' },
noFriendsInvitedHeading: {
id: 'app.instance.share.empty.heading',
defaultMessage: 'No friends invited',
},
noFriendsInvitedDescription: {
id: 'app.instance.share.empty.description',
defaultMessage: 'You can share this instance with your friends!',
},
inviteFriendsButton: {
id: 'app.instance.share.empty.invite-friends-button',
defaultMessage: 'Invite friends',
},
shareModalHeader: {
id: 'app.instance.share.invite-modal.heading',
defaultMessage: 'Share {name}',
},
lockedWrongAccountHeading: {
id: 'app.instance.share.locked.wrong-account-heading',
defaultMessage: 'Wrong account',
},
lockedSignedOutHeading: {
id: 'app.instance.share.locked.signed-out-heading',
defaultMessage: 'Not signed in',
},
lockedEmptyDescriptionPrefix: {
id: 'app.instance.share.locked.empty-description-prefix',
defaultMessage: 'You need to sign in as',
},
lockedEmptyDescriptionSuffix: {
id: 'app.instance.share.locked.empty-description-suffix',
defaultMessage: 'to access this page.',
},
linkedAccountFallback: {
id: 'app.instance.share.locked.linked-account-fallback',
defaultMessage: 'the linked account',
},
switchAccountButton: {
id: 'app.instance.share.locked.switch-account-button',
defaultMessage: 'Switch account',
},
unlinkForShareHeader: {
id: 'app.instance.share.unlink.header',
defaultMessage: 'Sharing requires unlinking',
},
unlinkForShareBody: {
id: 'app.instance.share.unlink.body',
defaultMessage: 'You must unlink this modpack to share your instance',
},
})
function invitePlayer(payload: InvitePlayersInvitePayload) {
if (actionsLocked.value) return
if (payload.source === 'search') void requestFriend(payload.user)
members.invite(payload.user)
}
function cancelInvite(user: InvitePlayersUser) {
const row = members.find(user.id, user.username)
if (row) members.remove(row.id)
}
async function showInvitePlayers(event?: MouseEvent) {
if (actionsLocked.value) return
if (!isSignedIn.value) return signInToShare(event)
if (requiresUnlink.value) return unlinkModal.value?.show()
if (await inviteLink.ensure()) invitePlayersModal.value?.show(event)
}
async function unlinkImportedModpack() {
try {
await edit(props.instance.id, { link: null as unknown as undefined })
importedModpackUnlinked.value = true
await queryClient.invalidateQueries({ queryKey: ['linkedModpackInfo', props.instance.id] })
if (await inviteLink.ensure()) invitePlayersModal.value?.show()
} catch (error) {
notifyOperationError(error)
}
}
function showRemoveMemberModal(row: ShareRow) {
if (!actionsLocked.value) {
pendingRemovalRow.value = row
removeMemberModal.value?.show()
}
}
function removeMember(row: ShareRow) {
members.remove(row.id)
}
function userProfileLink(username: string) {
return !username || username.includes('@')
? undefined
: () => openUrl(`https://modrinth.com/user/${encodeURIComponent(username)}`)
}
async function requestAuth(flow: ModrinthAuthFlow) {
await auth.requestSignIn(`/instance/${encodeURIComponent(props.instance.id)}/share`, flow)
return !!auth.session_token.value
}
function signInToShare(event?: MouseEvent) {
void accountRequiredModal.value?.show(event)
}
watch(
() => props.instance.id,
() => {
importedModpackUnlinked.value = false
},
)
watch(
[() => auth.isReady.value, isSignedIn, actionsLocked],
([ready, signedIn, locked]) => {
if (ready && !signedIn && !locked) signInToShare()
},
{ immediate: true, flush: 'post' },
)
provideInstanceBackup(() => props.instance)
</script>
@@ -0,0 +1,122 @@
<template>
<NewModal
ref="modal"
:header="formatMessage(messages.header)"
max-width="470px"
@after-hide="emit('clear')"
>
<div class="flex flex-col gap-4">
<Admonition type="warning">{{ formatMessage(messages.warning, { username }) }}</Admonition>
<div class="flex min-w-0 items-center gap-2 rounded-[20px] bg-surface-2 p-3">
<Avatar
:src="row?.avatarUrl"
:alt="formatMessage(messages.avatarAlt, { username })"
:tint-by="username"
size="40px"
circle
no-shadow
/>
<div class="flex min-w-0 flex-1 flex-col gap-0.5">
<span class="min-w-0 truncate font-medium text-contrast">{{ username }}</span>
<span class="truncate text-sm text-secondary">{{
row ? methodLabels[row.method] : ''
}}</span>
</div>
</div>
<div class="flex flex-col gap-2">
<span class="font-semibold text-contrast">{{ formatMessage(messages.effectsLabel) }}</span>
<ul class="m-0 list-disc pl-6 text-primary">
<li v-for="effect in effects" :key="effect.id" class="leading-6 marker:text-secondary">
{{ formatMessage(effect) }}
</li>
</ul>
</div>
<div class="flex justify-end gap-2 pt-1">
<ButtonStyled type="outlined"
><button class="!border !border-surface-5" @click="modal?.hide()">
<XIcon aria-hidden="true" />{{ formatMessage(commonMessages.cancelButton) }}
</button></ButtonStyled
>
<ButtonStyled color="orange"
><button :disabled="!row" @click="confirm">
<UserXIcon aria-hidden="true" />{{ formatMessage(messages.removeButton) }}
</button></ButtonStyled
>
</div>
</div>
</NewModal>
</template>
<script setup lang="ts">
import { UserXIcon, XIcon } from '@modrinth/assets'
import {
Admonition,
Avatar,
ButtonStyled,
commonMessages,
defineMessages,
NewModal,
useVIntl,
} from '@modrinth/ui'
import { computed, ref } from 'vue'
import { methodLabels, type ShareRow } from './shared-instance-share-types'
const props = defineProps<{ row: ShareRow | null; memberCount: number }>()
const emit = defineEmits<{ confirm: [row: ShareRow]; clear: [] }>()
const modal = ref<InstanceType<typeof NewModal>>()
const { formatMessage } = useVIntl()
const username = computed(() => props.row?.username ?? '')
const messages = defineMessages({
header: { id: 'app.instance.share.remove-user-modal.header', defaultMessage: 'Revoke access' },
warning: {
id: 'app.instance.share.remove-user-modal.warning-body',
defaultMessage:
"If you revoke {username}'s access to this shared instance, you'll need to invite them again before they can receive updates.",
},
avatarAlt: {
id: 'app.instance.share.remove-user-modal.user-avatar-alt',
defaultMessage: "{username}'s avatar",
},
effectsLabel: {
id: 'app.instance.share.remove-user-modal.effects-label',
defaultMessage: 'What happens?',
},
effectAccess: {
id: 'app.instance.share.remove-user-modal.effect-access',
defaultMessage: 'They will no longer receive updates for this shared instance',
},
effectInstalledCopy: {
id: 'app.instance.share.remove-user-modal.effect-installed-copy',
defaultMessage: 'Any copy they already installed will stay on their device',
},
effectInviteAgain: {
id: 'app.instance.share.remove-user-modal.effect-invite-again',
defaultMessage: 'You can invite them again later',
},
effectLastUser: {
id: 'app.instance.share.remove-user-modal.effect-last-user',
defaultMessage: 'If this is the last user, sharing will be turned off for this instance',
},
removeButton: {
id: 'app.instance.share.remove-user-modal.remove-button',
defaultMessage: 'Revoke access',
},
})
const effects = computed(() => {
const result = [messages.effectAccess, messages.effectInstalledCopy, messages.effectInviteAgain]
if (props.memberCount === 1) result.push(messages.effectLastUser)
return result
})
function show(event?: MouseEvent) {
modal.value?.show(event)
}
function confirm() {
if (props.row) {
modal.value?.hide()
emit('confirm', props.row)
}
}
defineExpose({ show })
</script>
@@ -0,0 +1,15 @@
<template>
<EmptyState type="empty-inbox">
<template #heading>{{ heading }}</template>
<template #description
><slot name="description">{{ description }}</slot></template
>
<template v-if="$slots.actions" #actions><slot name="actions" /></template>
</EmptyState>
</template>
<script setup lang="ts">
import { EmptyState } from '@modrinth/ui'
defineProps<{ heading: string; description?: string }>()
</script>
@@ -0,0 +1,8 @@
export {
type SharedPlayersTableMethodFilter as MethodFilter,
sharedPlayersTableMethodLabels as methodLabels,
normalizeInviteKey,
type SharedPlayersTableMethod as ShareMethod,
type SharedPlayersTableRow as ShareRow,
type SharedPlayersTableColumn as ShareTableColumn,
} from '@modrinth/ui'
@@ -0,0 +1,105 @@
import {
injectNotificationManager,
type InvitePlayersSearchUser,
type InvitePlayersUser,
} from '@modrinth/ui'
import { computed, type Ref } from 'vue'
import { useFriends } from '@/composables/use-friends'
import { getFriendUserId } from '@/helpers/friends.ts'
import { get as getCredentials } from '@/helpers/mr_auth.ts'
import { search_user } from '@/helpers/users.ts'
import { normalizeInviteKey, type ShareRow } from './shared-instance-share-types'
export function useSharedInstanceInviteCandidates(options: {
rows: Ref<ShareRow[]>
currentUserId: Ref<string | null>
isSignedIn: Ref<boolean>
actionsLocked: Ref<boolean>
}) {
const { handleError } = injectNotificationManager()
const friendsState = useFriends({
currentUserId: options.currentUserId,
getCredentials,
enabled: computed(
() =>
options.isSignedIn.value && !!options.currentUserId.value && !options.actionsLocked.value,
),
onError: handleError,
})
const friends = friendsState.friends
const invitedRows = computed(() => {
const invited = new Map<string, ShareRow>()
for (const row of options.rows.value) {
invited.set(normalizeInviteKey(row.id), row)
invited.set(normalizeInviteKey(row.username), row)
}
return invited
})
const inviteFriends = computed<InvitePlayersUser[]>(() =>
friends.value
.filter((friend) => friend.username && friend.accepted)
.sort((a, b) => Number(b.online) - Number(a.online))
.map((friend) => {
const id = getFriendUserId(friend, options.currentUserId.value)
const invited =
invitedRows.value.get(normalizeInviteKey(id)) ??
invitedRows.value.get(normalizeInviteKey(friend.username))
return {
id,
username: friend.username,
avatarUrl: friend.avatar,
online: friend.online,
status: invited ? (invited.pending ? 'pending' : 'added') : 'available',
}
}),
)
const candidateKeys = computed(() => {
const keys = new Set<string>()
for (const friend of inviteFriends.value) {
keys.add(normalizeInviteKey(friend.id))
keys.add(normalizeInviteKey(friend.username))
}
return keys
})
async function search(query: string): Promise<InvitePlayersSearchUser[]> {
if (options.actionsLocked.value) return []
const credentials = await getCredentials()
const ownUserId = options.currentUserId.value ?? credentials?.user_id ?? null
return (await search_user(query))
.filter((user) => user.id !== ownUserId)
.filter((user) => {
const id = normalizeInviteKey(user.id)
const username = normalizeInviteKey(user.username)
return (
!candidateKeys.value.has(id) &&
!candidateKeys.value.has(username) &&
!invitedRows.value.has(id) &&
!invitedRows.value.has(username)
)
})
.map((user) => ({
id: user.id,
username: user.username,
avatarUrl: user.avatar_url || undefined,
}))
}
async function requestFriend(user: InvitePlayersUser) {
if (options.actionsLocked.value) return
const credentials = await getCredentials()
const ownUserId = options.currentUserId.value ?? credentials?.user_id ?? null
if (ownUserId && normalizeInviteKey(user.id) === normalizeInviteKey(ownUserId)) return
if (!friendsState.findFriend(user.id, user.username)) {
friendsState.requestFriend({
id: user.id,
username: user.username,
avatarUrl: user.avatarUrl,
})
}
}
return { inviteFriends, search, requestFriend }
}
@@ -0,0 +1,63 @@
import type { InviteLinkSettings } from '@modrinth/ui'
import { computed, type Ref, ref, watch } from 'vue'
import { config } from '@/config'
import { toError } from '@/helpers/errors'
import { create_shared_instance_invite_link } from '@/helpers/instance'
export function useSharedInstanceInviteLink(
instanceId: Ref<string>,
onError: (error: unknown) => void,
) {
const details = ref<Awaited<ReturnType<typeof create_shared_instance_invite_link>>>()
const pending = ref(false)
const link = computed(() =>
details.value
? `${config.siteUrl}/share/${encodeURIComponent(details.value.inviteId)}`
: undefined,
)
async function ensure() {
if (details.value) return true
if (pending.value) return false
pending.value = true
try {
details.value = await create_shared_instance_invite_link(instanceId.value)
return true
} catch (error) {
onError(error)
return false
} finally {
pending.value = false
}
}
async function update(settings: InviteLinkSettings) {
if (!details.value) return
pending.value = true
try {
const maxAgeSeconds = Math.max(
1,
Math.min(604800, Math.floor((settings.expiresAt.getTime() - Date.now()) / 1000)),
)
details.value = await create_shared_instance_invite_link(instanceId.value, {
maxAgeSeconds,
maxUses: settings.maxUses,
replaceInviteId: details.value.inviteId,
})
} catch (error) {
throw toError(error)
} finally {
pending.value = false
}
}
watch(instanceId, () => {
details.value = undefined
pending.value = false
})
return { details, pending, link, ensure, update }
}
@@ -0,0 +1,294 @@
import type { InvitePlayersUser } from '@modrinth/ui'
import { useMutation, useQuery, useQueryClient } from '@tanstack/vue-query'
import { computed, type Ref, ref, watch } from 'vue'
import { get_user_many } from '@/helpers/cache.js'
import { toError } from '@/helpers/errors'
import {
get_shared_instance_users,
invite_shared_instance_users,
remove_shared_instance_users,
type SharedInstanceUser,
type SharedInstanceUsers,
} from '@/helpers/instance'
import type { GameInstance } from '@/helpers/types'
import { normalizeInviteKey, type ShareRow } from './shared-instance-share-types'
type MutationContext = {
queryKey: readonly ['sharedInstanceUsers', string]
previousRows?: ShareRow[]
previousPendingRows: Record<string, ShareRow>
}
export function useSharedInstanceMembers(options: {
instance: Ref<GameInstance>
currentUserId: Ref<string | null>
isSignedIn: Ref<boolean>
actionsLocked: Ref<boolean>
onError: (error: unknown) => void
onHydrationError: (error: Error) => void
}) {
const queryClient = useQueryClient()
const queryKey = computed(() => ['sharedInstanceUsers', options.instance.value.id] as const)
const pendingRows = ref<Record<string, ShareRow>>(loadPendingRows(options.instance.value.id))
async function usersToRows(users: SharedInstanceUsers): Promise<ShareRow[]> {
const excludedIds = new Set(
[options.instance.value.shared_instance?.manager_id, options.currentUserId.value].filter(
(id): id is string => !!id,
),
)
const usersToDisplay = userEntries(users).filter((user) => !excludedIds.has(user.id))
if (usersToDisplay.length === 0) return []
const profiles = (await get_user_many(usersToDisplay.map((user) => user.id))) as Array<{
id: string
username?: string
avatar_url?: string | null
}>
return usersToDisplay.map((user) => {
const profile = profiles.find((candidate) => candidate.id === user.id)
const joinedAt = parseDate(user.joined_at)
return {
id: user.id,
username: profile?.username ?? user.id,
avatarUrl: profile?.avatar_url ?? undefined,
lastPlayedAt: parseDate(user.last_played),
joinedAt,
method: user.join_type === 'link' ? 'link' : 'direct',
pending: !joinedAt,
}
})
}
async function loadRows() {
if (options.actionsLocked.value) return []
const loadedRows = await usersToRows(await get_shared_instance_users(options.instance.value.id))
removePendingRows(loadedRows.map((row) => row.id))
return loadedRows
}
const query = useQuery({
queryKey,
queryFn: loadRows,
enabled: () =>
options.isSignedIn.value && !!options.instance.value.id && !options.actionsLocked.value,
staleTime: Infinity,
})
const remoteRows = computed(
() => query.data.value ?? queryClient.getQueryData<ShareRow[]>(queryKey.value) ?? [],
)
const rows = computed(() => {
if (options.actionsLocked.value) return remoteRows.value
const remoteIds = new Set(remoteRows.value.map((row) => normalizeInviteKey(row.id)))
return [
...Object.values(pendingRows.value).filter(
(row) => !remoteIds.has(normalizeInviteKey(row.id)),
),
...remoteRows.value,
]
})
const inviteMutation = useMutation({
mutationFn: async (user: InvitePlayersUser) => {
if (options.actionsLocked.value) return
return await invite_shared_instance_users(options.instance.value.id, [user.id])
},
onMutate: async (user): Promise<MutationContext> => {
const currentKey = queryKey.value
await queryClient.cancelQueries({ queryKey: currentKey })
const context = {
queryKey: currentKey,
previousRows: queryClient.getQueryData<ShareRow[]>(currentKey),
previousPendingRows: pendingRows.value,
}
setPendingRow(user)
return context
},
onError: (error, user, context) => {
removePendingRow(user.id)
restore(context)
options.onError(error)
},
onSuccess: async (users, user) => {
try {
if (users) {
queryClient.setQueryData<ShareRow[]>(queryKey.value, await usersToRows(users))
} else {
upsert(inviteUserToRow(user))
}
} catch (error) {
options.onHydrationError(toError(error))
upsert(inviteUserToRow(user))
await queryClient.invalidateQueries({ queryKey: queryKey.value })
}
},
})
const removeMutation = useMutation({
mutationFn: async (id: string) => {
if (options.actionsLocked.value) {
return { user_ids: [], users: [], tokens: 0 } satisfies SharedInstanceUsers
}
return await remove_shared_instance_users(options.instance.value.id, [id])
},
onMutate: async (id): Promise<MutationContext> => {
const currentKey = queryKey.value
await queryClient.cancelQueries({ queryKey: currentKey })
const context = {
queryKey: currentKey,
previousRows: queryClient.getQueryData<ShareRow[]>(currentKey),
previousPendingRows: pendingRows.value,
}
queryClient.setQueryData<ShareRow[]>(currentKey, (currentRows = []) =>
currentRows.filter((row) => normalizeInviteKey(row.id) !== normalizeInviteKey(id)),
)
removePendingRow(id)
return context
},
onError: (error, _id, context) => {
restore(context)
options.onError(error)
},
onSuccess: async (users) => {
try {
queryClient.setQueryData<ShareRow[]>(queryKey.value, await usersToRows(users))
} catch (error) {
options.onHydrationError(toError(error))
await queryClient.invalidateQueries({ queryKey: queryKey.value })
}
},
})
function find(id: string, username: string) {
const normalizedId = normalizeInviteKey(id)
const normalizedUsername = normalizeInviteKey(username)
return rows.value.find(
(row) =>
normalizeInviteKey(row.id) === normalizedId ||
normalizeInviteKey(row.username) === normalizedUsername,
)
}
function invite(user: InvitePlayersUser) {
if (!options.actionsLocked.value && !find(user.id, user.username)) inviteMutation.mutate(user)
}
function remove(id: string) {
if (!options.actionsLocked.value) removeMutation.mutate(id)
}
function setPendingRow(user: InvitePlayersUser) {
pendingRows.value = { ...pendingRows.value, [user.id]: inviteUserToRow(user) }
savePendingRows()
}
function removePendingRow(id: string) {
const normalizedId = normalizeInviteKey(id)
pendingRows.value = Object.fromEntries(
Object.entries(pendingRows.value).filter(
([pendingId]) => normalizeInviteKey(pendingId) !== normalizedId,
),
)
savePendingRows()
}
function removePendingRows(ids: string[]) {
if (ids.length === 0) return
const normalizedIds = new Set(ids.map(normalizeInviteKey))
pendingRows.value = Object.fromEntries(
Object.entries(pendingRows.value).filter(
([id]) => !normalizedIds.has(normalizeInviteKey(id)),
),
)
savePendingRows()
}
function upsert(row: ShareRow) {
queryClient.setQueryData<ShareRow[]>(queryKey.value, (currentRows = []) => [
row,
...currentRows.filter(
(existing) => normalizeInviteKey(existing.id) !== normalizeInviteKey(row.id),
),
])
}
function restore(context?: MutationContext) {
if (!context) return
if (context.previousRows === undefined) {
queryClient.removeQueries({ queryKey: context.queryKey, exact: true })
} else {
queryClient.setQueryData(context.queryKey, context.previousRows)
}
pendingRows.value = context.previousPendingRows
savePendingRows()
}
function savePendingRows() {
if (typeof localStorage === 'undefined') return
const key = pendingRowsStorageKey(options.instance.value.id)
const storedRows = Object.values(pendingRows.value)
if (storedRows.length === 0) localStorage.removeItem(key)
else localStorage.setItem(key, JSON.stringify(storedRows))
}
watch(
() => options.instance.value.id,
(instanceId) => {
pendingRows.value = loadPendingRows(instanceId)
},
)
return { rows, query, find, invite, remove }
}
function userEntries(users: SharedInstanceUsers): SharedInstanceUser[] {
if (users.users?.length > 0) return users.users
return users.user_ids.map((id) => ({
id,
joined_at: null,
join_type: 'invite',
last_played: null,
}))
}
function parseDate(value?: string | null) {
if (!value) return null
const date = new Date(value)
return Number.isNaN(date.getTime()) ? null : date
}
function inviteUserToRow(user: InvitePlayersUser): ShareRow {
return {
id: user.id,
username: user.username,
avatarUrl: user.avatarUrl ?? undefined,
lastPlayedAt: null,
joinedAt: null,
method: 'direct',
pending: true,
}
}
function loadPendingRows(instanceId: string): Record<string, ShareRow> {
if (typeof localStorage === 'undefined') return {}
try {
const stored = localStorage.getItem(pendingRowsStorageKey(instanceId))
if (!stored) return {}
const rows = JSON.parse(stored) as ShareRow[]
return Object.fromEntries(
rows.map((row) => [
row.id,
{ ...row, lastPlayedAt: null, joinedAt: null, pending: true } satisfies ShareRow,
]),
)
} catch {
return {}
}
}
function pendingRowsStorageKey(instanceId: string) {
return `modrinth:shared-instance-pending-users:${instanceId}`
}
@@ -0,0 +1,190 @@
import { injectAuth } from '@modrinth/ui'
import { computed, inject, type InjectionKey, provide, type Ref, ref, watch } from 'vue'
import { useUserQuery } from '@/composables/users/use-user-query'
import {
getSharedInstanceUnavailableReason,
install_get_shared_instance_update_preview,
isSharedInstanceUnavailableError,
type SharedInstanceUnavailableReason,
} from '@/helpers/install'
import type { GameInstance } from '@/helpers/types'
export type SharedInstanceManager =
| {
type: 'user'
name: string
avatarUrl?: string
tintBy: string
}
| {
type: 'server'
name: string
avatarUrl?: string
tintBy: string
}
export function useSharedInstanceState(
instance: Ref<GameInstance | undefined>,
offline: Ref<boolean>,
notifyError: (error: unknown) => void,
) {
const auth = injectAuth()
const updatePreview =
ref<Awaited<ReturnType<typeof install_get_shared_instance_update_preview>>>(null)
const unavailableReason = ref<SharedInstanceUnavailableReason | null>(null)
const availabilityCheckKey = ref<string | null>(null)
const availabilityRefresh = ref(0)
let availabilityRequestId = 0
const expectedUserId = computed(() => instance.value?.shared_instance?.linked_user_id ?? null)
const wrongAccount = computed(() => {
if (auth.isReady && !auth.isReady.value) return false
if (!expectedUserId.value) return false
return auth.user.value?.id !== expectedUserId.value
})
const actionsLocked = computed(() => wrongAccount.value)
const shareActionsLocked = computed(() => actionsLocked.value || unavailableReason.value !== null)
const signedOut = computed(() => !auth.session_token.value)
const managerUserId = computed(() => {
const attachment = instance.value?.shared_instance
if (!attachment) return null
if (attachment.role === 'owner') {
return actionsLocked.value ? (attachment.linked_user_id ?? null) : null
}
return attachment.manager_id ?? null
})
const managerUserQuery = useUserQuery(managerUserId)
const manager = computed<SharedInstanceManager | null>(() => {
const attachment = instance.value?.shared_instance
if (!attachment) return null
if (attachment.server_manager_name) {
return {
type: 'server',
name: attachment.server_manager_name,
avatarUrl: attachment.server_manager_icon_url ?? undefined,
tintBy: attachment.server_manager_name,
}
}
const user = managerUserQuery.data.value
if (!user) return null
return {
type: 'user',
name: user.username,
avatarUrl: user.avatar_url ?? undefined,
tintBy: user.id,
}
})
const unavailableManager = computed(() => manager.value?.name ?? null)
function reset() {
availabilityRequestId++
availabilityCheckKey.value = null
updatePreview.value = null
unavailableReason.value = null
}
function refreshAvailability() {
availabilityRefresh.value++
}
function setUnavailable(reason: SharedInstanceUnavailableReason | null) {
availabilityRequestId++
availabilityCheckKey.value = null
updatePreview.value = null
unavailableReason.value = reason
}
async function checkAvailability(instanceId: string, key: string) {
const requestId = ++availabilityRequestId
try {
const preview = await install_get_shared_instance_update_preview(instanceId)
if (!isCurrentRequest(requestId, instanceId, key)) return
updatePreview.value = preview
unavailableReason.value = null
} catch (error) {
if (!isCurrentRequest(requestId, instanceId, key)) return
if (isSharedInstanceUnavailableError(error)) {
updatePreview.value = null
unavailableReason.value = getSharedInstanceUnavailableReason(error)
return
}
notifyError(error)
}
}
function isCurrentRequest(requestId: number, instanceId: string, key: string) {
return (
requestId === availabilityRequestId &&
instance.value?.id === instanceId &&
availabilityCheckKey.value === key
)
}
watch(
() => ({
refresh: availabilityRefresh.value,
instanceId: instance.value?.id,
role: instance.value?.shared_instance?.role,
locked: actionsLocked.value,
offline: offline.value,
signedIn: !!auth.session_token.value,
userId: auth.user.value?.id ?? null,
authReady: auth.isReady?.value ?? true,
}),
async ({ instanceId, role, locked, offline, signedIn, userId, authReady }) => {
if (
!instanceId ||
role !== 'member' ||
locked ||
offline ||
!authReady ||
!signedIn ||
!userId
) {
availabilityRequestId++
availabilityCheckKey.value = null
updatePreview.value = null
if (instanceId && role) unavailableReason.value = null
return
}
const key = `${instanceId}:${userId}`
if (availabilityCheckKey.value === key) return
availabilityCheckKey.value = key
await checkAvailability(instanceId, key)
},
{ immediate: true },
)
return {
actionsLocked,
shareActionsLocked,
unavailableReason,
unavailableManager,
manager,
updatePreview,
expectedUserId,
wrongAccount,
signedOut,
reset,
refreshAvailability,
setUnavailable,
}
}
export type SharedInstanceState = ReturnType<typeof useSharedInstanceState>
const sharedInstanceStateKey: InjectionKey<SharedInstanceState> = Symbol('shared-instance-state')
export function provideSharedInstanceState(state: SharedInstanceState) {
provide(sharedInstanceStateKey, state)
}
export function injectSharedInstanceState() {
const state = inject(sharedInstanceStateKey)
if (!state) throw new Error('Shared instance state has not been provided.')
return state
}
+51 -8
View File
@@ -116,13 +116,27 @@
aria-label="More options"
>
<MoreVerticalIcon aria-hidden="true" />
<template #open-in-browser> <ExternalIcon /> Open in browser </template>
<template #open-in-browser>
<ExternalIcon /> {{ formatMessage(commonMessages.openInBrowserButton) }}
</template>
<template #report> <ReportIcon /> Report </template>
</OverflowMenu>
</ButtonStyled>
</template>
<template v-else #actions>
<ButtonStyled size="large" color="brand">
<ButtonStyled v-if="showSwitchVersion && onVersionsPage" size="large">
<button v-tooltip="installButtonTooltip" disabled>
<CheckIcon />
{{ formatMessage(commonMessages.installedLabel) }}
</button>
</ButtonStyled>
<ButtonStyled v-else-if="showSwitchVersion" size="large">
<button @click="goToVersions">
<SwapIcon />
{{ formatMessage(messages.switchVersion) }}
</button>
</ButtonStyled>
<ButtonStyled v-else size="large" color="brand">
<button
v-tooltip="installButtonTooltip"
:disabled="installButtonDisabled"
@@ -171,7 +185,9 @@
aria-label="More options"
>
<MoreVerticalIcon aria-hidden="true" />
<template #open-in-browser> <ExternalIcon /> Open in browser </template>
<template #open-in-browser>
<ExternalIcon /> {{ formatMessage(commonMessages.openInBrowserButton) }}
</template>
<template #follow> <HeartIcon /> Follow </template>
<template #save> <BookmarkIcon /> Save </template>
<template #report> <ReportIcon /> Report </template>
@@ -286,6 +302,7 @@ import {
SelectedProjectsFloatingBar,
useVIntl,
} from '@modrinth/ui'
import { useQueryClient } from '@tanstack/vue-query'
import { convertFileSrc } from '@tauri-apps/api/core'
import { openUrl } from '@tauri-apps/plugin-opener'
import dayjs from 'dayjs'
@@ -293,8 +310,13 @@ import relativeTime from 'dayjs/plugin/relativeTime'
import { computed, onUnmounted, ref, shallowRef, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { SwapIcon } from '@/assets/icons/index.js'
import ContextMenu from '@/components/ui/ContextMenu.vue'
import InstanceIndicator from '@/components/ui/InstanceIndicator.vue'
import {
fetchCachedServerStatus,
getFreshCachedServerStatus,
} from '@/composables/instances/use-server-status-query'
import {
get_organization,
get_project,
@@ -313,7 +335,7 @@ import {
import { get_loader_versions as getLoaderManifest } from '@/helpers/metadata'
import { get_by_instance_id } from '@/helpers/process'
import { get_categories, get_game_versions, get_loaders } from '@/helpers/tags'
import { getServerAddress, getServerLatency } from '@/helpers/worlds'
import { getServerAddress } from '@/helpers/worlds'
import { injectContentInstall } from '@/providers/content-install'
import { injectServerInstall } from '@/providers/server-install'
import { createServerInstallContent } from '@/providers/setup/server-install-content'
@@ -326,6 +348,7 @@ const { handleError } = injectNotificationManager()
const { install: installVersion } = injectContentInstall()
const route = useRoute()
const router = useRouter()
const queryClient = useQueryClient()
const breadcrumbs = useBreadcrumbs()
const themeStore = useTheming()
const { formatMessage } = useVIntl()
@@ -343,6 +366,10 @@ const messages = defineMessages({
id: 'app.project.install-button.already-installed',
defaultMessage: 'This project is already installed',
},
switchVersion: {
id: 'app.project.install-button.switch-version',
defaultMessage: 'Switch version',
},
})
const { installingServerProjects, playServerProject, showAddServerToInstanceModal } =
@@ -514,6 +541,13 @@ const installButtonTooltip = computed(() => {
return null
})
const showSwitchVersion = computed(() => !!instance.value && installed.value)
const onVersionsPage = computed(() => route.name === 'Versions')
function goToVersions() {
router.push(versionsHref.value)
}
const [allLoaders, allGameVersions] = await Promise.all([
get_loaders().catch(handleError).then(ref),
get_game_versions().catch(handleError).then(ref),
@@ -600,10 +634,19 @@ async function fetchProjectData() {
function fetchDeferredServerData(project) {
const serverAddress = projectV3.value?.minecraft_java_server?.address
if (serverAddress) {
serverPing.value = undefined
getServerLatency(serverAddress)
.then((latency) => {
serverPing.value = latency
const cachedStatus = getFreshCachedServerStatus(queryClient, serverAddress)
if (cachedStatus) {
serverPing.value = cachedStatus.ping
serverStatusOnline.value = true
} else {
serverPing.value = undefined
}
fetchCachedServerStatus(queryClient, serverAddress)
.then((status) => {
if (projectV3.value?.minecraft_java_server?.address !== serverAddress) return
serverPing.value = status.ping
serverStatusOnline.value = true
})
.catch((error) => {
console.error(`Failed to ping server ${serverAddress}:`, error)
+165 -429
View File
@@ -1,259 +1,152 @@
<template>
<div>
<Card>
<Breadcrumbs
:current-title="version.name"
:link-stack="[
{
href: buildProjectHref(`/project/${route.params.id}/versions`),
label: 'Versions',
},
]"
/>
<div class="version-title">
<h2>{{ version.name }}</h2>
</div>
<div class="button-group">
<div class="flex flex-col">
<router-link
class="mb-4 flex w-fit items-center gap-2 rounded-lg px-2 py-0.5 pl-0 text-link"
:to="buildProjectHref(`/project/${route.params.id}/versions`)"
>
<ChevronLeftIcon class="shrink-0" /> {{ formatMessage(messages.allVersions) }}
</router-link>
<VersionPage
v-if="version"
:version="version"
:enrichment="enrichment"
:enrichment-loading="enrichmentLoading"
:members="members"
:dependency-link-creator="createDependencyLink"
>
<template #headerActions>
<ButtonStyled color="brand">
<button
:disabled="installing || (installed && installedVersion === version.id)"
@click="() => install(version.id)"
@click="() => version && install(version.id)"
>
<DownloadIcon v-if="!installed" />
<SwapIcon v-else-if="installedVersion !== version.id" />
<CheckIcon v-else />
{{
installing
? 'Installing...'
? formatMessage(messages.installing)
: installed && installedVersion === version.id
? 'Installed'
: 'Install'
? formatMessage(commonMessages.installedLabel)
: installed
? formatMessage(commonMessages.switchToVersionButton)
: formatMessage(commonMessages.installButton)
}}
</button>
</ButtonStyled>
<ButtonStyled>
<button>
<ReportIcon />
Report
</button>
</ButtonStyled>
<ButtonStyled>
<a
:href="`https://modrinth.com/mod/${route.params.id}/version/${route.params.version}`"
rel="external"
<ButtonStyled type="outlined" circular>
<OverflowMenu
v-tooltip="formatMessage(commonMessages.moreOptionsButton)"
:options="[
{
id: 'open-in-browser',
link: `https://modrinth.com/${project.project_type}/${project.slug}/version/${version.id}`,
external: true,
},
{
id: 'report',
color: 'red',
hoverFilled: true,
link: `https://modrinth.com/report?item=version&itemID=${version.id}`,
external: true,
},
]"
aria-label="More options"
>
Modrinth website
<ExternalIcon />
<MoreVerticalIcon aria-hidden="true" />
<template #open-in-browser>
<ExternalIcon aria-hidden="true" />
{{ formatMessage(commonMessages.openInBrowserButton) }}
</template>
<template #report>
<ReportIcon aria-hidden="true" /> {{ formatMessage(commonMessages.reportButton) }}
</template>
</OverflowMenu>
</ButtonStyled>
</template>
<template #supplementaryResourceActions="{ file }">
<ButtonStyled>
<a :href="file.url" :download="file.filename" target="_blank">
<DownloadIcon aria-hidden="true" />
{{ formatMessage(messages.downloadInBrowser) }}
</a>
</ButtonStyled>
</div>
</Card>
<div class="version-container">
<div class="description-cards">
<Card>
<h3 class="card-title">Changelog</h3>
<div class="markdown-body" v-html="renderString(version.changelog ?? '')" />
</Card>
<Card>
<h3 class="card-title">Files</h3>
<Card
v-for="file in version.files"
:key="file.id"
:class="{ primary: file.primary }"
class="file"
>
<span class="label">
<FileIcon />
<span>
<span class="title">
{{ file.filename }}
</span>
({{ formatBytes(file.size) }})
<span v-if="file.primary" class="primary-label"> Primary </span>
</span>
</span>
<ButtonStyled v-if="project.project_type !== 'modpack' || file.primary" color="brand">
<button class="download" :disabled="installed" @click="() => install(version.id)">
<DownloadIcon v-if="!installed" />
<CheckIcon v-else />
{{ installed ? 'Installed' : 'Install' }}
</button>
</ButtonStyled>
</Card>
</Card>
<Card v-if="displayDependencies.length > 0">
<h2>Dependencies</h2>
<div v-for="dependency in displayDependencies" :key="dependency.title">
<router-link v-if="dependency.link" class="btn dependency" :to="dependency.link">
<Avatar size="sm" :src="dependency.icon" />
<div>
<span class="title"> {{ dependency.title }} </span> <br />
<span> {{ dependency.subtitle }} </span>
</div>
</router-link>
<div v-else class="dependency disabled" disabled="">
<Avatar size="sm" :src="dependency.icon" />
<div class="text">
<div class="title">{{ dependency.title }}</div>
<div>{{ dependency.subtitle }}</div>
</div>
</div>
</div>
</Card>
</div>
<Card class="metadata-card">
<h3 class="card-title">Metadata</h3>
<div class="metadata">
<div class="metadata-item">
<span class="metadata-label">Release Channel</span>
<span class="metadata-value"
><Badge
:color="releaseColor(version.version_type)"
:type="
version.version_type.charAt(0).toUpperCase() + version.version_type.slice(1)
"
/></span>
</div>
<div class="metadata-item">
<span class="metadata-label">Version Number</span>
<span class="metadata-value">{{ version.version_number }}</span>
</div>
<div class="metadata-item">
<span class="metadata-label">Loaders</span>
<span class="metadata-value">{{
version.loaders
.map((loader) => loader.charAt(0).toUpperCase() + loader.slice(1))
.join(', ')
}}</span>
</div>
<div class="metadata-item">
<span class="metadata-label">Game Versions</span>
<span class="metadata-value"> {{ version.game_versions.join(', ') }} </span>
</div>
<div class="metadata-item">
<span class="metadata-label">Downloads</span>
<span class="metadata-value">{{ version.downloads }}</span>
</div>
<div class="metadata-item">
<span class="metadata-label">Publication Date</span>
<span class="metadata-value">
{{ formatDateTime(version.date_published) }}
</span>
</div>
<div v-if="author" class="metadata-item">
<span class="metadata-label">Author</span>
<a
:href="`https://modrinth.com/user/${author.user.username}`"
rel="external"
class="metadata-value btn author"
>
<Avatar size="sm" :src="author.user.avatar_url" circle />
<span>
<strong>
{{ author.user.username }}
</strong>
<br />
{{ author.role }}
</span>
</a>
</div>
<div class="metadata-item">
<span class="metadata-label">Version ID</span>
<span class="metadata-value"><CopyCode class="copycode" :text="version.id" /></span>
</div>
</div>
</Card>
</div>
</template>
</VersionPage>
</div>
</template>
<script setup>
import { CheckIcon, DownloadIcon, ExternalIcon, FileIcon, ReportIcon } from '@modrinth/assets'
<script setup lang="ts">
import type { Labrinth } from '@modrinth/api-client'
import {
CheckIcon,
ChevronLeftIcon,
DownloadIcon,
ExternalIcon,
MoreVerticalIcon,
ReportIcon,
} from '@modrinth/assets'
import {
Avatar,
Badge,
Breadcrumbs,
ButtonStyled,
Card,
CopyCode,
useFormatBytes,
useFormatDateTime,
commonMessages,
defineMessages,
type DependencyContext,
OverflowMenu,
useVIntl,
VersionPage,
} from '@modrinth/ui'
import { renderString } from '@modrinth/utils'
import { computed, ref, watch } from 'vue'
import { ref, watch } from 'vue'
import { useRoute } from 'vue-router'
import { SwapIcon } from '@/assets/icons'
import { get_project_many, get_version_many } from '@/helpers/cache.js'
import { releaseColor } from '@/helpers/utils'
import { useBreadcrumbs } from '@/store/breadcrumbs'
const formatDateTime = useFormatDateTime({
timeStyle: 'short',
dateStyle: 'long',
})
const formatBytes = useFormatBytes()
const { formatMessage } = useVIntl()
const breadcrumbs = useBreadcrumbs()
const route = useRoute()
const props = defineProps({
project: {
type: Object,
required: true,
},
versions: {
type: Array,
required: true,
},
members: {
type: Array,
required: true,
},
install: {
type: Function,
required: true,
},
installed: {
type: Boolean,
required: true,
const messages = defineMessages({
allVersions: {
id: 'app.project.version.all-versions',
defaultMessage: 'All versions',
},
installing: {
type: Boolean,
required: true,
id: 'app.project.version.installing',
defaultMessage: 'Installing',
},
installedVersion: {
type: String,
required: true,
downloadInBrowser: {
id: 'app.project.version.download-in-browser',
defaultMessage: 'Download in browser',
},
})
const breadcrumbs = useBreadcrumbs()
const route = useRoute()
const props = defineProps<{
project: Labrinth.Projects.v2.Project
versions: Labrinth.Versions.v3.Version[]
members: Labrinth.Projects.v3.TeamMember[]
install: (version: string | null) => void
installed: boolean
installing: boolean
installedVersion: string
}>()
const version = ref(props.versions.find((version) => version.id === route.params.version))
breadcrumbs.setName('Version', version.value.name)
if (version.value) {
breadcrumbs.setName('Version', version.value.name)
}
watch(
() => props.versions,
async () => {
if (route.params.version) {
version.value = props.versions.find((version) => version.id === route.params.version)
await refreshDisplayDependencies()
breadcrumbs.setName('Version', version.value.name)
}
},
)
const enrichment = ref<Labrinth.Projects.v2.DependencyInfo | undefined>(undefined)
const enrichmentLoading = ref(false)
const author = computed(() =>
props.members ? props.members.find((member) => member.user.id === version.value.author_id) : null,
)
const displayDependencies = ref({})
function buildProjectHref(path) {
function buildProjectHref(path: string): string {
const params = new URLSearchParams()
for (const [key, val] of Object.entries(route.query)) {
if (Array.isArray(val)) {
for (const v of val) params.append(key, v)
for (const v of val) {
if (v != null) params.append(key, v)
}
} else if (val) {
params.append(key, String(val))
}
@@ -262,222 +155,65 @@ function buildProjectHref(path) {
return qs ? `${path}?${qs}` : path
}
async function refreshDisplayDependencies() {
const projectIds = new Set()
const versionIds = new Set()
if (version.value.dependencies) {
for (const dependency of version.value.dependencies) {
if (dependency.project_id) {
projectIds.add(dependency.project_id)
}
if (dependency.version_id) {
versionIds.add(dependency.version_id)
}
}
function createDependencyLink(context: DependencyContext): string | undefined {
if (context.version) {
return buildProjectHref(`/project/${context.version.project_id}/version/${context.version.id}`)
}
const [projectDeps, versionDeps] = await Promise.all([
get_project_many([...projectIds]),
get_version_many([...versionIds]),
])
const dependencies = {
projects: projectDeps,
versions: versionDeps,
if (context.project) {
return buildProjectHref(`/project/${context.project.id}`)
}
displayDependencies.value = version.value.dependencies.map((dependency) => {
const version = dependencies.versions.find((obj) => obj.id === dependency.version_id)
if (version) {
const project = dependencies.projects.find(
(obj) => obj.id === version.project_id || obj.id === dependency.project_id,
)
return {
icon: project?.icon_url,
title: project?.title || project?.name,
subtitle: `Version ${version.version_number} is ${dependency.dependency_type}`,
link: buildProjectHref(`/project/${project.slug}/version/${version.id}`),
}
} else {
const project = dependencies.projects.find((obj) => obj.id === dependency.project_id)
if (project) {
return {
icon: project?.icon_url,
title: project?.title || project?.name,
subtitle: `${dependency.dependency_type}`,
link: buildProjectHref(`/project/${project.slug}`),
}
} else {
return {
icon: null,
title: dependency.file_name,
subtitle: `Added via overrides`,
link: null,
}
}
}
})
return undefined
}
await refreshDisplayDependencies()
async function refreshEnrichment() {
if (!version.value) return
const projectIds = new Set<string>()
const versionIds = new Set<string>()
for (const dependency of version.value.dependencies ?? []) {
if (dependency.project_id) {
projectIds.add(dependency.project_id)
}
if (dependency.version_id) {
versionIds.add(dependency.version_id)
}
}
if (projectIds.size === 0 && versionIds.size === 0) {
enrichment.value = { projects: [], versions: [] }
return
}
enrichmentLoading.value = true
try {
const versionResults = versionIds.size > 0 ? await get_version_many([...versionIds]) : []
for (const dependencyVersion of versionResults ?? []) {
if (dependencyVersion.project_id) {
projectIds.add(dependencyVersion.project_id)
}
}
const projectResults = projectIds.size > 0 ? await get_project_many([...projectIds]) : []
enrichment.value = {
projects: projectResults ?? [],
versions: versionResults ?? [],
}
} finally {
enrichmentLoading.value = false
}
}
watch(
() => props.versions,
async () => {
if (route.params.version) {
version.value = props.versions.find((v) => v.id === route.params.version)
if (version.value) {
breadcrumbs.setName('Version', version.value.name)
}
await refreshEnrichment()
}
},
)
await refreshEnrichment()
</script>
<style scoped lang="scss">
.version-container {
display: flex;
flex-direction: row;
gap: 1rem;
}
.version-title {
margin-bottom: 1rem;
h2 {
font-size: var(--font-size-2xl);
font-weight: 700;
color: var(--color-contrast);
margin: 0;
}
}
.dependency {
display: flex;
padding: 0.5rem 1rem 0.5rem 0.5rem;
gap: 0.5rem;
background: var(--color-raised-bg);
color: var(--color-base);
width: 100%;
.title {
font-weight: bolder;
}
:deep(svg) {
margin-right: 0 !important;
}
}
.file {
display: flex;
flex-direction: row;
gap: 0.5rem;
background: var(--color-button-bg);
color: var(--color-base);
padding: 0.5rem 1rem;
.download {
margin-left: auto;
background-color: var(--color-raised-bg);
}
.label {
display: flex;
margin: auto 0 auto;
gap: 0.5rem;
.title {
font-weight: bolder;
word-break: break-all;
}
svg {
min-width: 1.1rem;
min-height: 1.1rem;
width: 1.1rem;
height: 1.1rem;
margin: auto 0;
}
.primary-label {
font-style: italic;
}
}
}
.primary {
background: var(--color-brand-highlight);
color: var(--color-contrast);
}
.button-group {
display: flex;
flex-wrap: wrap;
flex-direction: row;
gap: 0.5rem;
}
.card-title {
font-size: var(--font-size-lg);
color: var(--color-contrast);
margin: 0 0 0.5rem;
}
.description-cards {
width: 100%;
}
.metadata-card {
width: 20rem;
height: min-content;
}
.metadata {
display: flex;
flex-direction: column;
flex-wrap: wrap;
gap: 1rem;
.metadata-item {
display: flex;
flex-direction: column;
gap: 0.5rem;
.metadata-label {
font-weight: bold;
}
}
}
.author {
display: flex;
flex-direction: row;
gap: 0.5rem;
align-items: center;
text-decoration: none;
color: var(--color-base);
background: var(--color-raised-bg);
padding: 0.5rem;
width: 100%;
box-shadow: none;
}
.markdown-body {
:deep(hr),
:deep(h1),
:deep(h2),
img {
max-width: max(60rem, 90%) !important;
}
:deep(ul),
:deep(ol) {
margin-left: 2rem;
}
}
.copycode {
border: 0;
color: var(--color-contrast);
}
.disabled {
display: flex;
flex-direction: row;
vertical-align: center;
align-items: center;
cursor: not-allowed;
border-radius: var(--radius-lg);
.text {
filter: brightness(0.5);
}
}
</style>
@@ -5,16 +5,23 @@
:game-versions="gameVersions"
:versions="versions"
:project="project"
:show-environment-column="themeStore.featureFlags.show_version_environment_column"
:version-link="(version) => buildProjectHref(`/project/${project.id}/version/${version.id}`)"
>
<template #actions="{ version }">
<ButtonStyled circular type="transparent">
<ButtonStyled
circular
type="transparent"
:color="installed && version.id === installedVersion ? 'standard' : 'green'"
>
<button
v-tooltip="`Install`"
:class="{
'group-hover:!bg-brand group-hover:[&>svg]:!text-brand-inverted':
!installed || version.id !== installedVersion,
}"
v-tooltip="
!installed
? formatMessage(commonMessages.installButton)
: version.id !== installedVersion
? formatMessage(commonMessages.switchToVersionButton)
: formatMessage(messages.alreadyInstalled)
"
:disabled="installing || (installed && version.id === installedVersion)"
@click.stop="() => install(version.id)"
>
@@ -26,7 +33,6 @@
<ButtonStyled circular type="transparent">
<OverflowMenu
v-if="false"
class="group-hover:!bg-button-bg"
:options="[
{
id: 'install-elsewhere',
@@ -47,12 +53,13 @@
<DownloadIcon aria-hidden="true" />
Add to another instance
</template>
<template #open-in-browser> <ExternalIcon /> Open in browser </template>
<template #open-in-browser>
<ExternalIcon /> {{ formatMessage(commonMessages.openInBrowserButton) }}
</template>
</OverflowMenu>
<a
v-else
v-tooltip="`Open in browser`"
class="group-hover:!bg-button-bg"
v-tooltip="formatMessage(commonMessages.openInBrowserButton)"
:href="`https://modrinth.com/${project.project_type}/${project.slug}/version/${version.id}`"
target="_blank"
>
@@ -68,15 +75,29 @@
import { CheckIcon, DownloadIcon, ExternalIcon, MoreVerticalIcon } from '@modrinth/assets'
import {
ButtonStyled,
commonMessages,
defineMessages,
injectNotificationManager,
OverflowMenu,
ProjectPageVersions,
useVIntl,
} from '@modrinth/ui'
import { ref } from 'vue'
import { useRoute } from 'vue-router'
import { SwapIcon } from '@/assets/icons/index.js'
import { get_game_versions, get_loaders } from '@/helpers/tags.js'
import { useTheming } from '@/store/theme.ts'
const { formatMessage } = useVIntl()
const themeStore = useTheming()
const messages = defineMessages({
alreadyInstalled: {
id: 'app.project.versions.already-installed',
defaultMessage: 'Already installed',
},
})
defineProps({
project: {
@@ -0,0 +1,25 @@
import { provideAppBackup } from '@modrinth/ui'
import { type MaybeRefOrGetter, toValue } from 'vue'
import { install_duplicate_instance, installJobInstanceId } from '@/helpers/install'
import { edit, list } from '@/helpers/instance'
import type { GameInstance } from '@/helpers/types'
export function provideInstanceBackup(instance: MaybeRefOrGetter<GameInstance>) {
provideAppBackup({
async createBackup() {
const source = toValue(instance)
const prefix = `${source.name} - Backup #`
const existingNumbers = (await list())
.filter((candidate) => candidate.name.startsWith(prefix))
.map((candidate) => Number.parseInt(candidate.name.slice(prefix.length), 10))
.filter(Number.isFinite)
const nextNumber = existingNumbers.length ? Math.max(...existingNumbers) + 1 : 1
const job = await install_duplicate_instance(source.id)
const backupInstanceId = installJobInstanceId(job)
if (backupInstanceId) {
await edit(backupInstanceId, { name: `${prefix}${nextNumber}` })
}
},
})
}
@@ -1,5 +1,5 @@
import type { Labrinth } from '@modrinth/api-client'
import { type AuthProvider, type AuthUser, provideAuth } from '@modrinth/ui'
import { type AuthFlow, type AuthProvider, type AuthUser, provideAuth } from '@modrinth/ui'
import { computed, type Ref, ref, watchEffect } from 'vue'
type AppCredentials = {
@@ -9,7 +9,7 @@ type AppCredentials = {
export function setupAuthProvider(
credentials: Ref<AppCredentials | null | undefined>,
requestSignIn: (redirectPath: string) => void | Promise<void>,
requestSignIn: (redirectPath: string, flow?: AuthFlow) => void | Promise<void>,
) {
const sessionToken = ref<string | null>(null)
const user = ref<AuthUser | null>(null)
+18
View File
@@ -41,6 +41,14 @@ export default new createRouter({
name: 'ServerManage',
component: Hosting.Index,
children: [
{
path: 'play',
name: 'ServerManagePlay',
component: Hosting.Play,
meta: {
breadcrumb: [{ name: '?Server' }],
},
},
{
path: '',
name: 'ServerManageOverview',
@@ -215,6 +223,15 @@ export default new createRouter({
breadcrumb: [{ name: '?Instance', link: '/instance/{id}/' }, { name: 'Worlds' }],
},
},
{
path: 'share',
name: 'InstanceShare',
component: Instance.Share,
meta: {
useRootContext: true,
breadcrumb: [{ name: '?Instance', link: '/instance/{id}/' }, { name: 'Share' }],
},
},
{
path: '',
name: 'Mods',
@@ -247,6 +264,7 @@ export default new createRouter({
name: 'Logs',
component: Instance.Logs,
meta: {
renderMode: 'fixed',
useRootContext: true,
breadcrumb: [{ name: '?Instance', link: '/instance/{id}/' }, { name: 'Logs' }],
},

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