Compare commits

...
119 Commits
Author SHA1 Message Date
Prospector e4cbec6b2c changelog 2026-08-07 13:42:57 -07:00
ThatGravyBoat 5ef999bf8a feat: requested moderation and tech review changes (#7019)
* feat: requested moderation and tech review changes

* chore: run prepr

* chore: run prepr

---------

Signed-off-by: ThatGravyBoat <gravy@thatgravyboat.tech>
2026-08-07 13:34:03 -07:00
Prospector b0394de164 add loading states for creators + server details (#7033)
* add loading states for creators + server details

* prepr
2026-08-07 13:20:31 -07:00
Prospector 9181c155e0 fix license field showing on server projects (#7031)
* fix license field showing on server projects

* random commit to trigger CI hopefully?
2026-08-07 13:20:13 -07:00
Prospector 2e8aaa31be change admin edit role modal to edit user (#7035)
change edit role modal to edit user
2026-08-07 13:20:05 -07:00
Prospector bca5303ae3 improve popout animations (#7040) 2026-08-07 13:19:56 -07:00
Prospector 75335647ee update tags page + sort project types consistently (#7044) 2026-08-07 13:19:36 -07:00
Blodhgarm a71a361e0f feat: Allow for adjustable alignment of main content on certain pages for content moderators (#7034)
* Add type safety and validation for moderation settings

* Add ability for moderators to adjust page layout for discover, project, moderation pages

Primarily for changes to have more tooling present without overlapping important info
2026-08-07 16:45:37 +00:00
Truman Gao 6ed044a54b fix: multiple compatible downloads showing as radio button (#7050) 2026-08-07 16:04:04 +00:00
aecsocket 3fa6905006 refactor: labrinth ApiError and error reporting (#6981)
* refactor: labrinth `ApiError` and error reporting

* fix clippy

* fix ci
2026-08-07 15:47:54 +00:00
Prospector d344cbcb7a make neoforge icon look more fox-like, replace hosting LoaderIcon with TagIcon (#7045)
make neoforge icon look more fox-like, replace hosting LoaderIcon with ui lib TagIcon
2026-08-07 15:29:25 +00:00
Prospector 204f4da33f fix edit profile in app not working (#7046) 2026-08-07 15:16:09 +00:00
aecsocket c57e88ecd4 fix: improve analytics dependents query (#7025)
* fix: improve analytics dependents query

* fix up compose
2026-08-07 10:33:15 -04:00
Michael H. feb3c3ee31 build: trigger labrinth ci on packagess 2026-08-07 11:47:47 +02:00
aecsocket 09f2af61dc fix: Mural payout status schema (#7038) 2026-08-07 11:05:13 +02:00
ArthurandProspector 61b52ecd2c fix: inconsistent color vars (#7028)
* Fix inconsistent color vars

* pointless commit

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

* undo

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

* prepr

---------

Signed-off-by: Prospector <6166773+Prospector@users.noreply.github.com>
Co-authored-by: Prospector <6166773+Prospector@users.noreply.github.com>
2026-08-07 06:30:42 +00:00
ArthurandProspector 9625740906 fix: anchored teleport (#7027)
* Fix anchored teleport

* pointless commit

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

* undo

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

* prepr

---------

Signed-off-by: Prospector <6166773+Prospector@users.noreply.github.com>
Co-authored-by: Prospector <6166773+Prospector@users.noreply.github.com>
2026-08-07 06:29:06 +00:00
Calum H. f234b7a0ac fix: broken plan selector colors (#7013) 2026-08-07 04:43:17 +00:00
Mr_chank 8aa22b9bed refactor(i18n): use common messages for generic button labels (#7020)
A few components defined their own "Cancel", "Close", "Clear" and
"Success" descriptors when commonMessages already has them. Swapped
them over and removed the old ones.

Locale files come from intl:prune-local dropping the now-orphaned
strings across every language.
2026-08-07 04:39:05 +00:00
Michael H. b8d97268cb perf: further reduce memory (#7015)
* feat: server source maps

* perf: strip unused fields from search results

* perf: reduce game version memory usage
2026-08-06 15:39:06 +00:00
Calum H. c671ca52f6 fix: dropdown panel at 0,0 (#7024) 2026-08-06 15:10:46 +00:00
Michael H. a89711841d chore: reduce frontend server memory & tracing (#7014)
* chore: reduce memory usage

* feat: worker tracing

* fix: eslint

* fix: remove compat flag

* fix: prettier
2026-08-05 19:59:15 +02:00
Calum H. 0038ea8b15 fix: remove legacy button classes (#7011)
* fix: remove legacy button-group class for tailwind (conflicts)

* fix: delete legacy button classes from scss

* fix: fmt
2026-08-05 16:34:00 +02:00
Calum H. c0e6f094d9 fix: panel issues w/ backups (#7009) 2026-08-05 13:17:29 +00:00
Calum H. 1c574a1f2d fix: affiliates showing when not meant to (#7008) 2026-08-05 14:22:05 +02:00
Calum H. bca8b1a02b fix: moderation queue endpoint regression (#7003)
fix: regression caused by 2308ac1 changes
2026-08-05 10:47:06 +00:00
aecsocket 5148e8ec35 feat: use postcard for redis serde (#6956)
* redo error handling in xredis

* give proper types to metadata fields

* add round-trip tests

* inline loader enum metadata fields

* postcard roundtrips

* prepare

* bump redis key version

* serde-binhum

* clippy

* fix

* fix frontend checking existence of component fields rather than non-null-ness

* prepare
2026-08-05 10:36:05 +00:00
SlackowandProspector cc53d9a6f8 feat: support supplementary environment variables for hooks in App (#5916)
* feat: Implement launcher supplementary environmental variables, and env var substitution in hooks

* lint fix, prettier reformat

* descriptions added for each environment variable for hook settings

* alphabetize language keys

---------

Co-authored-by: Prospector <6166773+Prospector@users.noreply.github.com>
2026-08-04 22:23:07 +00:00
coolbot 474c6560fc feat: update gallery nags (#6999)
* update nag to require 3 images from shader projects.

* locales
2026-08-04 21:33:26 +00:00
Calum H. f42a3bfde3 fix: moderation fixes (#6952)
* fix: skip deleted projects

* fix: remove unnesecary blocker
2026-08-04 21:33:17 +00:00
Calum H.andProspector 540b3269df fix: breadcrumb issue on browse content via server panel (#6971)
fix: breadcrumb issue on browse/navigation bar

Co-authored-by: Prospector <6166773+Prospector@users.noreply.github.com>
2026-08-04 21:32:52 +00:00
Calum H. 36857a167b fix: content selection bar not showing in ModpackContentModal (#6950) 2026-08-04 21:24:43 +00:00
LarpinWBDandClaude 3487523f74 fix: version tags toggling every supported version and freezing the page (#6967)
Fixes #6601.

Clicking any game version tag on the versions tab passed the release's
entire game_versions array to toggleFilters, so clicking 26.x (or even a
single version tag) selected every version the release supports. On
projects supporting many versions this also froze the page for seconds:
toggleFilters awaited a needlessly-async toggleFilter per version, which
let Vue flush a full re-render between every iteration (~90 flushes on
the reported project).

- formatVersionsForDisplay's grouping now also returns the member
  versions per displayed range (getVersionGroupsForDisplay), so version
  tags toggle exactly the versions they represent
- toggleFilters applies the whole batch synchronously in one update, and
  selects the missing versions unless all are already selected (instead
  of blindly inverting each one)

Co-authored-by: Claude <noreply@anthropic.com>
2026-08-04 21:12:57 +00:00
Calum H.andProspector bb2193b6f5 refactor: Button components (#6929)
* feat: refactor button components

* fix: qa

* fix: storybook

* a11y: pass

* fix: build

* fix: rename default ->md

* fix: lint

* docs: buttons.md

* refactor part 1

* refactor part 2

* fix: undo refactor for fresh restart

* refactor

* Revert "refactor"

This reverts commit 96d65902d7.

* refactor: part 1

* fix: qa

* fix: qa

* fix: qa

* fix: qa

* fix: qa

* fix: qa

* fix: qa

* fix: qa

* fix: remove text-contrast

* fix: qa

* fix: v-tooltip

* fix: lint

* fix: split broken

* fix: passkey qa

* fix: qa

* fix: qa

* fix: prepr

* fix: splitbutton

* improve button group seam

---------

Signed-off-by: Calum H. <calum@modrinth.com>
Co-authored-by: Prospector <6166773+Prospector@users.noreply.github.com>
2026-08-04 13:54:04 -07:00
Calum H. 6cfe999cb3 fix(shared-instances): allow nbt configs (#6993)
fix: allow nbt configs
2026-08-04 18:01:21 +00:00
Modrinth Bot 893ab49fdf New translations from Crowdin (main) (#6972) 2026-08-04 17:59:39 +00:00
Calum H.andtdgao fe0c97190a refactor: use DI for instance page + subpages (#6987)
* refactor: use DI for instance page + subpages

* fix: qa

* refactor: layout.vue bring up a layer

* refactor: rename folders

* import order

* refactor: move settings into instance page

* fix: lint

---------

Co-authored-by: tdgao <mr.trumgao@gmail.com>
2026-08-04 16:52:00 +00:00
Michael H. 99377f8436 Update merge queue configuration (#6989)
Signed-off-by: Michael H. <michael@iptables.sh>
2026-08-04 16:03:28 +00:00
Calum H. 4c3abc62a8 fix: export modal path handling (#6949)
* fix: export modal path handling

* fix: prepr

* fix: NEVER_EXPORTABLE_PATH_SUFFIXES list
2026-08-04 15:34:20 +00:00
Calum H. af7336fffb fix: show "inspecting modpack" (#6979)
* fix: show "inspecting modpack"

Shows when determining external files for modpacks which have many overrides/over gb of content files.

* fix: better progress indicator
2026-08-04 15:21:31 +00:00
Calum H. cfea3aba34 fix: mc account logout on stale (#6986) 2026-08-04 14:29:03 +00:00
aecsocket 6715a9c679 fix: global traces QA notes (#6977)
* updated at cols + search by path

* speed up scanning

* migration does not auto-set updated_at

* merge migrations
2026-08-03 18:15:13 +00:00
aecsocket c3249ee51d feat: ElasticSearch backend (#6903)
* (do not merge) search test branch

* perf

* fix up search pagination

* more parity and perf work

* expand what parity does

* fix author query

* approach parity even without explicit parity enabled

* remove old parity code

* fix shear

* fix

* fmt
2026-08-03 17:14:07 +00:00
Prospector 2e43f6a42b collapse offline friends by default, save collapse value (#6937) 2026-08-03 07:03:45 +00:00
Prospector 779edf0333 Fix server ping race condition before protocol version is available (#6935)
* fix race condition with server ping lookup before protocol resolved

* fix refresh button on worlds page
2026-08-03 07:00:54 +00:00
coco 🐾 ae13d37edc docs: corrections to GetLatestVersionFromHash and GetLatestVersionsFromHashes documentation (#6966)
correct version_files documentation
2026-08-02 23:49:19 +00:00
ThatGravyBoat 1e49d7da7a fix: missing auth providers in account details (#6965)
* fix: missing auth providers in account details

This was missing due to a bad merge in #6889 not merging in #6897 correctly

* chore: run intl:extract
2026-08-02 22:23:03 +00:00
Michael H. 8b753a52ad fix: build labrinth on prod again 2026-07-31 14:45:16 +02:00
Michael H. a53c9c3771 fix: only deploy if internal 2026-07-31 13:21:35 +02:00
Michael H. 919b235621 chore: address action issues 2026-07-31 13:17:26 +02:00
Michael H. 18b0b2848d feat: labrinth argo 2026-07-31 13:10:58 +02:00
Prospector 65a3ac4b34 changelog 2026-07-30 23:10:28 -07:00
Prospector 295db3fb7a only refresh tokens and clear auth cookies on auth fails, not any ran… (#6947)
only refresh tokens and clear auth cookies on auth fails, not any random error
2026-07-30 23:10:08 -07:00
Michael H. 7abe6f8c73 feat: sccache on windows 2026-07-30 20:27:08 +02:00
Michael H. c5ce5bc9b3 fix: create clickhouse db in tests only 2026-07-30 20:08:49 +02:00
Michael H. 572b8027ff fix: clickhouse migrations should not run normally 2026-07-30 19:23:10 +02:00
aecsocket 0a53211389 fix: analytics event endpoint (#6938)
* fix: analytics event endpoint

* split admin-only routes into internal

* fix pr comment
2026-07-30 04:40:52 +00:00
Prospector 22b2b7746e changelog 2026-07-29 14:34:41 -07:00
Calum H.andProspector 39b3e20d0a fix: feedback file limit (#6931)
* fix: feedback file limit

* prepr

* prepr again

---------

Co-authored-by: Prospector <6166773+Prospector@users.noreply.github.com>
2026-07-29 20:47:05 +00:00
Calum H. 3892b62451 fix: reinstall jobs not cleaning up if instance deleted (#6927) 2026-07-29 20:31:26 +00:00
Calum H. 5ffffec2e5 fix: gracefully handle shared instance icons 404ing (#6928)
* fix: icon handling

* fix: cleanup impl

* fix: fmt
2026-07-29 20:30:52 +00:00
Truman Gao a0724cc5fb feat: dont show analytics incomplete current day revenue (#6933) 2026-07-29 20:26:25 +00:00
Prospector a0320f425e install version matching filters if possible (#6932) 2026-07-29 13:26:39 -07:00
Michael H. 06262e7bb1 feat: clickhouse partitioning and header TTL 2026-07-29 22:10:20 +02:00
Prospector 9ccaab5fad add toggle to hide already-installed modpacks (#6917)
* add toggle to hide already-installed modpacks, on by default

* make false by default
2026-07-29 17:54:21 +00:00
Prospector 39745086fa fix warning when passing extraneous props to floating action bar (#6919) 2026-07-29 13:02:40 +00:00
Truman Gao a8dcfa8a5d fix: download modal suggested dep can use wrong mc version (#6925) 2026-07-29 12:44:34 +00:00
Truman Gao 0f6a74b87f fix: pats page code invalidating in same session (#6924) 2026-07-29 12:44:30 +00:00
Prospector d5b763c8e3 Fix tauri overlay in modal closing on drag (#6920)
* Fix tauri overlay in modal closing on drag

* prepr
2026-07-29 12:44:28 +00:00
Prospector 7a98f45c3d add installation settings tooltip (#6916) 2026-07-29 12:44:16 +00:00
Prospector 0ea47ad7ef add new instance button (#6918) 2026-07-29 12:43:17 +00:00
Prospector cc804625ec Replace final usage of legacy modal (#6921)
* Replace final usage of legacy modal

* prepr
2026-07-29 12:42:59 +00:00
Prospector 20235b7d32 changelog 2026-07-28 16:36:16 -07:00
Prospector 920dc8dcbb Fix oauth appliction icon upload (#6913) 2026-07-28 16:30:21 -07:00
Calum H. 30f64c0020 fix: remove dummy data (#6915) 2026-07-28 16:30:14 -07:00
Prospector cfafa25ed4 fix moderation messages + add legacy message (#6914) 2026-07-28 16:30:05 -07:00
Prospector 7495b99ddd fix changelog 2026-07-28 15:06:12 -07:00
Prospector 800271a606 0.17.1 2026-07-28 14:01:28 -07:00
Michael H. 36791a33d8 fix: app build ci 2026-07-28 22:59:26 +02:00
Prospector 20ec70b917 comment out 0.17.0 changelog 2026-07-28 13:58:21 -07:00
Prospector ab6edbee48 changelog 2026-07-28 13:47:48 -07:00
Calum H.andProspector b17523e7fd refactor: breadcrumbs system (#6912)
* refactor: breadcrumbs system

* fix: qa

* fix: qa

* fix: qa

* fix: qa

* fix: loading flashes

* fix: qa

* fix: prepr

* fix: qa

* fix: change icon

* use modrinth logo in header for compactness

---------

Co-authored-by: Prospector <6166773+Prospector@users.noreply.github.com>
2026-07-28 20:38:16 +00:00
Calum H.andProspector f89c116bf8 feat: blocking frontend (#6911)
* feat: blocking api interfaces

* feat: block action on user pages + shared instance flows

* feat: safety settings subpage

* feat: interaction source settings

* feat: finish social settings

* feat: profile settings xplat

* fix: prepr

* feat: click on friends

* default instance -> game options & fix feature flag width

* fix: scroll indicators

---------

Co-authored-by: Prospector <6166773+Prospector@users.noreply.github.com>
2026-07-28 20:02:58 +00:00
Prospector cbb31f31c0 cleaup app layout, add missing translations to it (#6904)
* cleaup app layout, add missing translations to it

* remove feature flag in backend

* add app-messages

* clean up nav slightly

* allow more than 3 quick instances

* make quick instances resizable
2026-07-28 19:42:08 +00:00
ThatGravyBoat fe69e04785 feat: moderation settings (#6895)
* feat: moderation settings

* chore: move moderation specific feature flag to settings

* feat: force open collapsed regions if opened in new tab

* chore: remove unused field

* chore: run prepr
2026-07-28 19:38:56 +00:00
Calum H. 82348fe40a qa: shared instances post release (#6893)
* fix: 20 user cap

* qa: invite modal dropdown + other + clamp limits

* fix: hide filters if just one type

* feat: update req banner

* feat: invite management frontend

* fix: moderation issues with shared instances

* feat: instance icon improvements

* fix: better offline handling

* fix: lint

* feat: show avatar in install to play modal

* feat: smaller qa points

* fix: fmt

* fix: fmt

* fix: yeet svg

* fix: 50 user limit
2026-07-28 14:10:12 +00:00
Calum H. 5d34e7902a refactor: app settings layout (#6891)
* refactor: app settings layout

* fix: privacy copy got reverted

* fix: i18n

* fix: modal title

* fix: rev comments
2026-07-28 14:09:41 +00:00
ProspectorandCalum H. 3c8bb0923d fix authorized apps settings, redesign, fix retro theme vars (#6900)
* fix authorized apps settings, redesign, fix retro theme vars

* merge

* fix: retro in app

---------

Co-authored-by: Calum H. (IMB11) <contact@cal.engineer>
2026-07-28 12:16:05 +00:00
Calum H.andProspector 7c4190fb9c feat: move users page to xplat page system (#6889)
* feat: move users page to xplat

* feat: stop doing tauri plugin open for user links

* feat: qa

* fix: qa

* fix: qa

* fix: comment

* fix: prepr

* prepr

* prepr

* prepr

---------

Co-authored-by: Prospector <6166773+Prospector@users.noreply.github.com>
2026-07-27 20:02:33 +00:00
Sychic ebc7cac8b0 feat(labrinth): user blocking (#6837)
* feat(labrinth): user blocking

still wip, nothing is hooked up

* style(labrinth): cargo fmt

* refactor(labrinth): move block/unblock routes to public api

* feat(labrinth): block friend requests if blocked

* feat(labrinth): remove friend/request on block

* feat(labrinth): get blocked users endpoint
2026-07-27 19:41:57 +00:00
François-Xavier Talbot 224c28f29d feat: more details linked account info for support/moderation (#6897)
* feat: show discord + steam ID + github profile in "Show details" user modal

* pnpm intl:extract
2026-07-27 19:20:38 +00:00
aecsocket e192f6a0af feat: define default search vars in env (#6902)
* feat: define default search vars in env

* fix
2026-07-27 19:17:14 +00:00
Prospector c938ca3ceb update toggle filter design (#6888)
change toggle filters to just use exclude-only filters, same design as the others.
2026-07-27 11:49:52 -07:00
François-Xavier Talbot 20adb0f16a fix(labrinth): let through emails when neverbounce hangs or fails (#6898)
* fix(labrinth): let through emails when neverbounce hangs or fails

* chore(ci): stack size
2026-07-27 18:10:54 +00:00
Calum H. 354940a8c6 fix: search resetting to page 1 (#6896) 2026-07-27 15:09:13 +00:00
Calum H.andProspector 4c2e64e31f fix: try fix install jobs being bricked/improve tracing (#6890)
* fix: try fix install jobs being bricked/improve tracing

* fix: rev cmts

---------

Co-authored-by: Prospector <6166773+Prospector@users.noreply.github.com>
2026-07-27 14:47:46 +00:00
Modrinth Bot 372e2bf47b New translations from Crowdin (main) (#6892) 2026-07-27 10:46:42 +00:00
Magnus Jensen 36d786f504 Fix vertical mis-aligned badge in notifcations (#6887)
vertical align badge
2026-07-26 20:25:19 +00:00
Prospector 1a1e1ea448 changelog 2026-07-26 12:22:50 -07:00
Prospector 24240d514a update 24-48hrs -> within a week to give more realistic estimate (#6886) 2026-07-26 12:06:31 -07:00
aecsocket 158de019a6 fix: search indexing performance and batching (#6521)
* Add consume batching delay

* maybe fix

* max batch size

* more logging

* parallelize remove tasks

* delete/upsert project/version messages

* prepare

* more logging

* log number of docs

* try more targeted, homogenous version change ops

* ensure only necessary fields are serialized into typesense

* disable index background task

* wip: script changes

* don't facet by project id

* fix

* fix clippy

* batch by document and dedup loaders

* fix test

* wip: projects/versions collections

* clean up SearchBackend interface

* cleanup pass

* cleanup pass 2

* standardise fn names

* cleanup pass

* fix compile

* factor out filter rewriting

* query perf

* put categories into the project doc

* wip: search filter AST

* doc comment

* more AST normalization

* (temp) convert search request errors into internal errors

* implement unary NOT

* tombi fmt

* fix tests

* revert request error

* try increase stack size
2026-07-26 14:55:00 +00:00
Prospector 54cc1898fa changelog 2026-07-25 18:32:56 -07:00
ArthurandProspector b96a1cfa58 fix: allow crowdin badges to skip image proxy (#6861)
Add crowdin to allowed hostnames

Co-authored-by: Prospector <6166773+Prospector@users.noreply.github.com>
2026-07-26 01:14:21 +00:00
P4ncakeandProspector 59a794ebb1 Minor UX UI tweaks (#6596)
* feat(ux): Add letter counters to the bio and username

the username letter counter only appears after the 30th letter to not encourage people to have long usernames for no reason

* feat(ux): Added "Expand" button to bio on mobile

* Revert "feat(ux): Added "Expand" button to bio on mobile"

This reverts commit f7e036002a.

* prepr

---------

Co-authored-by: Prospector <6166773+Prospector@users.noreply.github.com>
2026-07-26 00:47:14 +00:00
b4380aee87 feat(moderation): modpack scanning improvements (#6843)
* feat(moderation): make modpack scanning improvements

* oopsie

* feat(moderation): batch size for modpack scan

* feat: i18n for modpack scan modal

* chore: remove debug line

* run intl:extract and prepr

---------

Co-authored-by: coolbot100s <76798835+coolbot100s@users.noreply.github.com>
Co-authored-by: Prospector <6166773+Prospector@users.noreply.github.com>
2026-07-26 00:14:09 +00:00
François-Xavier Talbot 2cf71a53f2 chore(ci): increase rust stack size (#6874) 2026-07-26 00:11:08 +00:00
coolbot 44be49416b feat: update private use moderation msgs and stage (#6872) 2026-07-26 00:10:48 +00:00
Prospector 7f07e66e4f fix: users not seeing moderation page banners and info 🤦 (#6882) 2026-07-25 17:10:24 -07:00
Michael H. 907db9e64a fix: remove sccache from turbo-ci 2026-07-25 00:01:27 +02:00
François-Xavier Talbot 944f99a14e chore(labrinth): add extra logging on muralpay failed txn (#6870) 2026-07-24 23:53:37 +02:00
Michael H. a79736dba9 fix: explicitly install java on theseus build 2026-07-24 21:47:43 +02:00
Michael H. 6f0181d52f fix: app build but actually 2026-07-24 21:40:34 +02:00
Michael H. 70c544b048 fix: app build 2026-07-24 21:38:46 +02:00
Michael H. 5a80df3cd4 build: change runner profiles 2026-07-24 21:32:06 +02:00
Michael H. 1e85ec8649 chore: switch to namespace runners (#6851)
* chore: switch to namespace runners

* fix: sudo

* fix: move to medium runners for frontend

* chore: bump all actions, remove old turbo cache

* fix: bump frontend runners to large

* fix: wrangler

* fix: wrangler version, again

* chore: namespace windows and mac runners

* fix: revert tombi bump

* chore: tweak caching
2026-07-24 21:27:52 +02:00
Calum H. 1dd613e459 feat: changelog (#6863) 2026-07-24 20:04:57 +02:00
Calum H. d632b7bdbc feat: shared instances blog (#6847)
* feat: shared instances blog

* fix: formatting

* fix: authors

* fix: update blog
2026-07-24 17:57:20 +00:00
Modrinth Bot eb8141134a New translations from Crowdin (main) (#6859) 2026-07-24 17:55:52 +00:00
Calum H. cc0d3bf42c fix: post-release shared instances (#6860)
* fix: qa

* fix: lint
2026-07-24 17:54:00 +00:00
1005 changed files with 79174 additions and 33192 deletions
+1 -1
View File
@@ -1,5 +1,5 @@
# Windows has stack overflows when calling from Tauri, so we increase the default stack size used by the compiler
[target.'cfg(windows)']
[target."cfg(windows)"]
rustflags = ["-C", "link-args=/STACK:16777220"]
[target.x86_64-pc-windows-msvc]
+21 -11
View File
@@ -34,13 +34,13 @@ runs:
using: 'composite'
steps:
- name: Checkout code
uses: actions/checkout@v4
uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0
with:
fetch-depth: 0
- name: Extract PR Number and Commit ID
id: extract-pr-info
uses: actions/github-script@v7
uses: actions/github-script@f28e40c7f34bde8b3046d885e986cb6290c5673b # v7.1.0
with:
script: |
const githubRef = process.env.GITHUB_REF;
@@ -90,20 +90,26 @@ runs:
- name: Print PR Branch
if: env.CAN_SKIP_CHECKS != 'false'
shell: bash
env:
PR_BRANCH: ${{ steps.get-pr-branch.outputs.prBranch }}
run: |
echo "PR Branch: ${{ steps.get-pr-branch.outputs.prBranch }}"
echo "PR Branch: $PR_BRANCH"
- name: Check if PR branch contains the Merge Queue target commit ID
if: env.CAN_SKIP_CHECKS != 'false'
shell: bash
env:
PR_BRANCH: ${{ steps.get-pr-branch.outputs.prBranch }}
COMMIT_ID: ${{ steps.extract-pr-info.outputs.commitId }}
TARGET_BRANCH: ${{ steps.extract-pr-info.outputs.targetBranchName }}
run: |
# Get the branch name from previous steps
branch_name="origin/${{ steps.get-pr-branch.outputs.prBranch }}"
commit_id="${{ steps.extract-pr-info.outputs.commitId }}"
branch_name="origin/$PR_BRANCH"
commit_id="$COMMIT_ID"
# Check if the branch history contains the commit
if git branch -r --contains "$commit_id" | grep -q "$branch_name"; then
echo "Branch '$branch_name' contains commit '$commit_id'. It is up to date with ${{ steps.extract-pr-info.outputs.targetBranchName }}."
if git branch -r --contains "$commit_id" | grep -qF "$branch_name"; then
echo "Branch '$branch_name' contains commit '$commit_id'. It is up to date with $TARGET_BRANCH."
else
echo "Branch '$branch_name' does not contain commit '$commit_id'. It is outdated. Setting CAN_SKIP_CHECKS to false."
echo "CAN_SKIP_CHECKS=false" >> "$GITHUB_ENV"
@@ -112,8 +118,10 @@ runs:
- name: Compare PR Branch with Current Branch
if: env.CAN_SKIP_CHECKS != 'false'
shell: bash
env:
PR_BRANCH: ${{ steps.get-pr-branch.outputs.prBranch }}
run: |
if git diff --quiet "origin/${{ steps.get-pr-branch.outputs.prBranch }}"; then
if git diff --quiet "origin/$PR_BRANCH"; then
echo "No differences found. PR branch is identical with this merge queue branch."
else
echo "Differences detected. PR branch has been updated after PR was added to merge queue. Setting CAN_SKIP_CHECKS to false."
@@ -122,9 +130,11 @@ runs:
- name: Compute/publish skip result
id: passed-checks
uses: actions/github-script@v7
uses: actions/github-script@f28e40c7f34bde8b3046d885e986cb6290c5673b # v7.1.0
env:
SECRET: ${{ inputs.secret }}
PR_BRANCH: ${{ steps.get-pr-branch.outputs.prBranch }}
TARGET_BRANCH: ${{ steps.extract-pr-info.outputs.targetBranchName }}
with:
github-token: ${{ inputs.secret != '' && inputs.secret || github.token }}
script: |
@@ -144,7 +154,7 @@ runs:
const { data: branchProtection } = await github.rest.repos.getBranchProtection({
owner: context.repo.owner,
repo: context.repo.repo,
branch: "${{ steps.extract-pr-info.outputs.targetBranchName }}",
branch: process.env.TARGET_BRANCH,
});
const requiredCheckNames = branchProtection.required_status_checks.contexts;
console.log(`requiredCheckNames = ${requiredCheckNames}`);
@@ -152,7 +162,7 @@ runs:
const { data: checks } = await github.rest.checks.listForRef({
owner: context.repo.owner,
repo: context.repo.repo,
ref: "refs/heads/${{ steps.get-pr-branch.outputs.prBranch }}",
ref: `refs/heads/${process.env.PR_BRANCH}`,
});
console.log(`checks.check_runs = ${checks.check_runs.map(check => `${check.status},${check.conclusion},${check.name};`)}`);
+3 -3
View File
@@ -27,7 +27,7 @@ jobs:
BUMP_TYPE: minor
steps:
- name: Check out code
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
fetch-depth: 0
@@ -47,7 +47,7 @@ jobs:
- name: Setup Node
if: steps.changes.outputs.changed == 'true'
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
with:
node-version-file: .nvmrc
registry-url: https://registry.npmjs.org
@@ -63,7 +63,7 @@ jobs:
- name: Restore pnpm cache
if: steps.changes.outputs.changed == 'true'
uses: actions/cache/restore@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
with:
path: ${{ steps.pnpm-store.outputs.store-path }}
key: pnpm-cache-${{ runner.os }}-${{ runner.arch }}-${{ hashFiles('**/pnpm-lock.yaml') }}
@@ -14,7 +14,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Cancel Previous Runs
uses: styfle/cancel-workflow-action@85880fa0301c86cca9da44039ee3bb12d3bedbfa # 0.12.1
uses: styfle/cancel-workflow-action@d07a454dad7609a92316b57b23c9ccfd4f59af66 # 0.13.1
with:
workflow_id: all
access_token: ${{ secrets.GITHUB_TOKEN }}
+1 -1
View File
@@ -17,7 +17,7 @@ jobs:
steps:
- name: Post or update changelog comment
uses: actions/github-script@d746ffe35508b1917358783b479e04febd2b8f71 # v9.0.0
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
with:
github-token: ${{ secrets.CROWDIN_GH_TOKEN }}
script: |
+3 -3
View File
@@ -12,14 +12,14 @@ jobs:
typos:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- uses: crate-ci/typos@6ac2ebd1b93eade61faf7e12688ad87a073fea59 # v1.46.0
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- uses: crate-ci/typos@bee27e3a4fd1ea2111cf90ab89cd076c870fce14 # v1.48.0
# see <https://github.com/influxdata/datafusion-udf-wasm/pull/275>
tombi:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- uses: taiki-e/install-action@fd2f5e3d644b484055ebf4268f474c565f148f25 # v2.81.9
with:
tool: tombi
+3 -3
View File
@@ -13,8 +13,8 @@ jobs:
shear:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- uses: dtolnay/rust-toolchain@29eef336d9b2848a0b548edc03f92a220660cdb8 # stable
- uses: cargo-bins/cargo-binstall@dc19f1e48450eefe5a29b8da6c6b00a87d730b37 # v1.18.1
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- uses: dtolnay/rust-toolchain@4cda84d5c5c54efe2404f9d843567869ab1699d4 # stable
- uses: cargo-bins/cargo-binstall@ead08b90bd7b2e6d81963fb9cf0b7239f66d5db4 # v1.21.0
- run: cargo binstall --no-confirm cargo-shear
- run: cargo shear
+24
View File
@@ -0,0 +1,24 @@
name: Deploy Command
run-name: Deploy PR #${{ github.event.client_payload.github.payload.issue.number }}
on:
repository_dispatch:
types: [deploy-command]
permissions:
contents: read
actions: read
jobs:
deploy:
if: ${{ github.event.client_payload.pull_request.head.repo.full_name == github.repository }}
uses: SparkUniverse/workflows/.github/workflows/deploy-command.yaml@main
secrets:
ARGOCD_DEPLOY_KEY: ${{ secrets.ARGOCD_DEPLOY_KEY }}
CMD_DISPATCH_GH_TOKEN: ${{ secrets.SLASH_CMD_GH_TOKEN }}
with:
application-set: labrinth
build-workflow: labrinth-build.yml
branch: ${{ github.event.client_payload.pull_request.head.ref }}
issue-number: ${{ github.event.client_payload.github.payload.issue.number }}
head-sha: ${{ github.event.client_payload.pull_request.head.sha }}
+8 -36
View File
@@ -35,7 +35,7 @@ jobs:
if: ${{ always() }}
steps:
- name: Checkout
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: Check if workflow runs on an internal branch
id: check-internal
@@ -72,19 +72,15 @@ jobs:
fi
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' || '' }}
runs-on: ${{ needs.skip-if-clean.outputs.internal == 'true' && 'namespace-profile-medium-amd64' || 'ubuntu-latest' }}
needs: [skip-if-clean]
if: ${{ needs.skip-if-clean.outputs.skip != 'true' }}
steps:
- name: Check out code
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: Setup Rust toolchain
uses: actions-rust-lang/setup-rust-toolchain@2b1f5e9b395427c92ee4e3331786ca3c37afe2d7 # v1.16.0
uses: actions-rust-lang/setup-rust-toolchain@166cdcfd11aee3cb47222f9ddb555ce30ddb9659 # v1.17.0
with:
rustflags: ''
cache: false
@@ -92,30 +88,6 @@ jobs:
- 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:
path: |
~/.cargo/registry
~/.cargo/git
~/.cargo/bin
key: ${{ runner.os }}-${{ runner.arch }}-cargo-${{ hashFiles('**/Cargo.lock') }}
- name: Ensure sccache directory exists
if: needs.skip-if-clean.outputs.internal == 'true'
run: sudo mkdir -p /mnt/sccache && sudo chown $(id -u):$(id -g) /mnt/sccache
- name: Mount sccache disk cache
if: needs.skip-if-clean.outputs.internal == 'true'
uses: useblacksmith/stickydisk@13af8883542ca949a717e70fef89d15edbb29d88 # v1.2.0
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
@@ -125,11 +97,11 @@ jobs:
cp target/release/daedalus_client apps/daedalus_client/docker-stage/daedalus_client
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4.0.0
uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4.2.0
- name: Generate Docker image metadata
id: docker-meta
uses: docker/metadata-action@030e881283bb7a6894de51c315a6bfe6a94e05cf # v6.0.0
uses: docker/metadata-action@dc802804100637a589fabce1cb79ff13a1411302 # v6.2.0
env:
DOCKER_METADATA_ANNOTATIONS_LEVELS: manifest,index
with:
@@ -144,14 +116,14 @@ jobs:
org.opencontainers.image.licenses=MIT
- name: Login to GitHub Packages
uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0
uses: docker/login-action@abd2ef45e78c5afb21d64d4ca52ee8550d9572c7 # v4.5.1
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Build and push
uses: docker/build-push-action@bcafcacb16a39f128d818304e6c9c0c18556b85f # v7.1.0
uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7.3.0
with:
context: ./apps/daedalus_client/docker-stage
file: ./apps/daedalus_client/Dockerfile
+2 -2
View File
@@ -12,10 +12,10 @@ jobs:
steps:
- name: Checkout repository
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: Log in to GitHub Container Registry
uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0
uses: docker/login-action@abd2ef45e78c5afb21d64d4ca52ee8550d9572c7 # v4.5.1
with:
registry: ghcr.io
username: ${{ github.actor }}
+65 -62
View File
@@ -39,14 +39,14 @@ concurrency:
jobs:
deploy:
runs-on: blacksmith-2vcpu-ubuntu-2404
runs-on: namespace-profile-modrinth-frontend
permissions:
contents: read
deployments: write
pull-requests: write
steps:
- name: Checkout code
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
fetch-depth: 0
@@ -90,24 +90,23 @@ jobs:
fi
- name: Setup Node
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
with:
node-version-file: .nvmrc
- name: Enable Corepack
run: corepack enable
- name: Get pnpm store path
id: pnpm-store
run: echo "store-path=$(pnpm store path --silent)" >> $GITHUB_OUTPUT
- name: Restore pnpm cache
uses: actions/cache/restore@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
- name: Set up caches
uses: namespacelabs/nscloud-cache-action@c5f8dab7560444c4bf8dbc64f1b203431873c547 # v1.6.1
with:
path: ${{ steps.pnpm-store.outputs.store-path }}
key: pnpm-cache-${{ runner.os }}-${{ runner.arch }}-${{ hashFiles('**/pnpm-lock.yaml') }}
restore-keys: |
pnpm-cache-
cache: |
rust
pnpm
apt
- name: Set up Turborepo caching
uses: namespace-actions/setup-turbocache@f96bd6befa13ae74b2858a5be49573640d4636f5 # v0.0.2
- name: Inject build variables
working-directory: ./apps/frontend
@@ -136,7 +135,7 @@ jobs:
SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }}
- name: Create Sentry release and upload sourcemaps
uses: getsentry/action-release@5657c9e888b4e2cc85f4d29143ea4131fde4a73a # v3.6.0
uses: getsentry/action-release@ff07929a6537bac57790c3451cf4d364aca38528 # v3.7.0
env:
SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }}
SENTRY_ORG: modrinth
@@ -146,50 +145,53 @@ jobs:
sourcemaps: ./apps/frontend/.output/server
url_prefix: '~/'
- name: Deploy Cloudflare Worker
id: wrangler
uses: cloudflare/wrangler-action@9acf94ace14e7dc412b076f2c5c20b8ce93c79cd # v3.15.0
- name: Install Wrangler CLI
uses: cloudflare/wrangler-action@ebbaa1584979971c8614a24965b4405ff95890e0 # v4.0.0
with:
apiToken: ${{ secrets.CF_API_TOKEN }}
accountId: ${{ secrets.CF_ACCOUNT_ID }}
environment: ${{ steps.meta.outputs.env != 'production' && steps.meta.outputs.env || '' }}
workingDirectory: ./apps/frontend
packageManager: pnpm
wranglerVersion: '4.54.0'
command: ${{ steps.meta.outputs.cmd }}
wranglerVersion: '4.112.0'
command: --version
- name: Deploy staging Cloudflare Worker preview
if: ${{ inputs.preview }}
uses: cloudflare/wrangler-action@9acf94ace14e7dc412b076f2c5c20b8ce93c79cd # v3.15.0
with:
apiToken: ${{ secrets.CF_API_TOKEN }}
accountId: ${{ secrets.CF_ACCOUNT_ID }}
workingDirectory: ./apps/frontend
packageManager: pnpm
wranglerVersion: '4.54.0'
command: ${{ steps.meta.outputs.staging_cmd }}
- parallel:
- name: Deploy Cloudflare Worker
id: wrangler
uses: cloudflare/wrangler-action@ebbaa1584979971c8614a24965b4405ff95890e0 # v4.0.0
with:
apiToken: ${{ secrets.CF_API_TOKEN }}
accountId: ${{ secrets.CF_ACCOUNT_ID }}
environment: ${{ steps.meta.outputs.env != 'production' && steps.meta.outputs.env || '' }}
workingDirectory: ./apps/frontend
command: ${{ steps.meta.outputs.cmd }}
- name: Deploy persistent Cloudflare Worker preview
if: ${{ inputs.preview && inputs.branch_alias != '' }}
uses: cloudflare/wrangler-action@9acf94ace14e7dc412b076f2c5c20b8ce93c79cd # v3.15.0
with:
apiToken: ${{ secrets.CF_API_TOKEN }}
accountId: ${{ secrets.CF_ACCOUNT_ID }}
workingDirectory: ./apps/frontend
packageManager: pnpm
wranglerVersion: '4.54.0'
command: versions upload --preview-alias ${{ inputs.branch_alias }} --var PREVIEW:true --var CF_PAGES_URL:${{ steps.meta.outputs.branch_alias_url }}
- name: Deploy staging Cloudflare Worker preview
if: ${{ inputs.preview }}
uses: cloudflare/wrangler-action@ebbaa1584979971c8614a24965b4405ff95890e0 # v4.0.0
with:
apiToken: ${{ secrets.CF_API_TOKEN }}
accountId: ${{ secrets.CF_ACCOUNT_ID }}
workingDirectory: ./apps/frontend
command: ${{ steps.meta.outputs.staging_cmd }}
- name: Deploy persistent staging Cloudflare Worker preview
if: ${{ inputs.preview && inputs.branch_alias != '' }}
uses: cloudflare/wrangler-action@9acf94ace14e7dc412b076f2c5c20b8ce93c79cd # v3.15.0
with:
apiToken: ${{ secrets.CF_API_TOKEN }}
accountId: ${{ secrets.CF_ACCOUNT_ID }}
workingDirectory: ./apps/frontend
packageManager: pnpm
wranglerVersion: '4.54.0'
command: ${{ steps.meta.outputs.staging_branch_alias_cmd }}
- name: Deploy persistent Cloudflare Worker preview
if: ${{ inputs.preview && inputs.branch_alias != '' }}
uses: cloudflare/wrangler-action@ebbaa1584979971c8614a24965b4405ff95890e0 # v4.0.0
with:
apiToken: ${{ secrets.CF_API_TOKEN }}
accountId: ${{ secrets.CF_ACCOUNT_ID }}
workingDirectory: ./apps/frontend
command: versions upload --preview-alias ${{ inputs.branch_alias }} --var PREVIEW:true --var CF_PAGES_URL:${{ steps.meta.outputs.branch_alias_url }}
- name: Deploy persistent staging Cloudflare Worker preview
if: ${{ inputs.preview && inputs.branch_alias != '' }}
uses: cloudflare/wrangler-action@ebbaa1584979971c8614a24965b4405ff95890e0 # v4.0.0
with:
apiToken: ${{ secrets.CF_API_TOKEN }}
accountId: ${{ secrets.CF_ACCOUNT_ID }}
workingDirectory: ./apps/frontend
command: ${{ steps.meta.outputs.staging_branch_alias_cmd }}
- name: Purge cache
if: github.ref == 'refs/heads/prod'
@@ -206,16 +208,17 @@ jobs:
echo "${{ steps.meta.outputs.url }}" > deployment-url-production.txt
echo "${{ steps.meta.outputs.staging_url }}" > deployment-url-staging.txt
- name: Upload production deployment URL
if: ${{ inputs.preview }}
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: deployment-url-production
path: deployment-url-production.txt
- parallel:
- name: Upload production deployment URL
if: ${{ inputs.preview }}
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: deployment-url-production
path: deployment-url-production.txt
- name: Upload staging deployment URL
if: ${{ inputs.preview }}
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: deployment-url-staging
path: deployment-url-staging.txt
- name: Upload staging deployment URL
if: ${{ inputs.preview }}
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: deployment-url-staging
path: deployment-url-staging.txt
+29 -28
View File
@@ -39,7 +39,7 @@ jobs:
deploy-storybook:
if: github.repository_owner == 'modrinth' && github.event.pull_request.head.repo.full_name == github.repository
needs: metadata
runs-on: blacksmith-2vcpu-ubuntu-2404
runs-on: namespace-profile-modrinth-frontend
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}-storybook
cancel-in-progress: true
@@ -48,27 +48,21 @@ jobs:
deployments: write
steps:
- name: Checkout code
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: Setup Node
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
with:
node-version-file: .nvmrc
- name: Enable Corepack
run: corepack enable
- name: Get pnpm store path
id: pnpm-store
run: echo "store-path=$(pnpm store path --silent)" >> $GITHUB_OUTPUT
- name: Restore pnpm cache
uses: actions/cache/restore@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
- name: Set up caches
uses: namespacelabs/nscloud-cache-action@c5f8dab7560444c4bf8dbc64f1b203431873c547 # v1.6.1
with:
path: ${{ steps.pnpm-store.outputs.store-path }}
key: pnpm-cache-${{ runner.os }}-${{ runner.arch }}-${{ hashFiles('**/pnpm-lock.yaml') }}
restore-keys: |
pnpm-cache-
cache: |
pnpm
- name: Install dependencies
working-directory: ./packages/ui
@@ -86,27 +80,34 @@ jobs:
HEAD_SHA: ${{ github.event.pull_request.head.sha }}
run: echo "sha_short=${HEAD_SHA::8}" >> $GITHUB_OUTPUT
- name: Deploy Storybook preview
uses: cloudflare/wrangler-action@9acf94ace14e7dc412b076f2c5c20b8ce93c79cd # v3.15.0
- name: Install Wrangler CLI
uses: cloudflare/wrangler-action@ebbaa1584979971c8614a24965b4405ff95890e0 # v4.0.0
with:
apiToken: ${{ secrets.CF_API_TOKEN }}
accountId: ${{ secrets.CF_ACCOUNT_ID }}
workingDirectory: ./packages/ui
packageManager: pnpm
wranglerVersion: '4.54.0'
command: versions upload --preview-alias git-${{ steps.meta.outputs.sha_short }}
wranglerVersion: '4.112.0'
command: --version
- name: Deploy persistent Storybook preview
uses: cloudflare/wrangler-action@9acf94ace14e7dc412b076f2c5c20b8ce93c79cd # v3.15.0
env:
NODE_OPTIONS: --max-old-space-size=6144
with:
apiToken: ${{ secrets.CF_API_TOKEN }}
accountId: ${{ secrets.CF_ACCOUNT_ID }}
workingDirectory: ./packages/ui
packageManager: pnpm
wranglerVersion: '4.54.0'
command: versions upload --preview-alias ${{ needs.metadata.outputs.branch-alias }}
- parallel:
- name: Deploy Storybook preview
uses: cloudflare/wrangler-action@ebbaa1584979971c8614a24965b4405ff95890e0 # v4.0.0
with:
apiToken: ${{ secrets.CF_API_TOKEN }}
accountId: ${{ secrets.CF_ACCOUNT_ID }}
workingDirectory: ./packages/ui
command: versions upload --preview-alias git-${{ steps.meta.outputs.sha_short }}
- name: Deploy persistent Storybook preview
uses: cloudflare/wrangler-action@ebbaa1584979971c8614a24965b4405ff95890e0 # v4.0.0
env:
NODE_OPTIONS: --max-old-space-size=6144
with:
apiToken: ${{ secrets.CF_API_TOKEN }}
accountId: ${{ secrets.CF_ACCOUNT_ID }}
workingDirectory: ./packages/ui
command: versions upload --preview-alias ${{ needs.metadata.outputs.branch-alias }}
comment:
if: |
+3 -3
View File
@@ -51,13 +51,13 @@ jobs:
CROWDIN_GH_TOKEN_DEFINED: ${{ secrets.CROWDIN_GH_TOKEN != '' }}
- name: Checkout
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
ref: ${{ github.ref }}
token: ${{ secrets.CROWDIN_GH_TOKEN }}
- name: Setup Node
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
with:
node-version-file: .nvmrc
@@ -90,7 +90,7 @@ jobs:
echo "safe_branch_name=$SAFE_BRANCH_NAME" >> "$GITHUB_OUTPUT"
- name: Download translations from Crowdin
uses: crowdin/github-action@8868a33591d21088edfc398968173a3b98d51706 # v2.16.2
uses: crowdin/github-action@c7af9bc98b01694653031fef2a0dc6c7888ce9bc # v2.17.0
with:
upload_sources: false
upload_translations: false
+3 -3
View File
@@ -56,13 +56,13 @@ jobs:
CROWDIN_PERSONAL_TOKEN_DEFINED: ${{ secrets.CROWDIN_PERSONAL_TOKEN != '' }}
- name: Checkout
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
ref: ${{ github.ref }}
fetch-depth: 2
- name: Setup Node
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
with:
node-version-file: .nvmrc
@@ -83,7 +83,7 @@ jobs:
echo "safe_branch_name=$SAFE_BRANCH_NAME" >> "$GITHUB_OUTPUT"
- name: Upload translations to Crowdin
uses: crowdin/github-action@8868a33591d21088edfc398968173a3b98d51706 # v2.16.2
uses: crowdin/github-action@c7af9bc98b01694653031fef2a0dc6c7888ce9bc # v2.17.0
with:
upload_sources: true
upload_translations: false
@@ -1,19 +1,26 @@
name: docker-build
name: Labrinth Build
on:
push:
branches:
- 'main'
- 'prod'
paths:
- .github/workflows/labrinth-docker.yml
- .github/workflows/labrinth-build.yml
- 'apps/labrinth/**'
- 'packages/**'
- '!packages/api-client/**'
- '!packages/app-lib/**'
- Cargo.toml
- Cargo.lock
pull_request:
types: [opened, synchronize]
paths:
- .github/workflows/labrinth-docker.yml
- .github/workflows/labrinth-build.yml
- 'apps/labrinth/**'
- 'packages/**'
- '!packages/api-client/**'
- '!packages/app-lib/**'
- Cargo.toml
- Cargo.lock
merge_group:
@@ -33,7 +40,7 @@ jobs:
if: ${{ always() }}
steps:
- name: Checkout
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: Check if workflow runs on an internal branch
id: check-internal
@@ -69,23 +76,23 @@ jobs:
echo "skip=false" >> $GITHUB_OUTPUT
fi
docker:
runs-on: ${{ needs.skip-if-clean.outputs.internal == 'true' && 'blacksmith-4vcpu-ubuntu-2404' || 'ubuntu-latest' }}
build:
name: Build Labrinth
runs-on: ${{ needs.skip-if-clean.outputs.internal == 'true' && 'namespace-profile-modrinth-labrinth' || 'ubuntu-latest' }}
needs: [skip-if-clean]
if: ${{ needs.skip-if-clean.outputs.skip != 'true' }}
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' || '' }}
SCCACHE_CACHE_SIZE: ${{ needs.skip-if-clean.outputs.internal == 'true' && '20G' || '' }}
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
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: Setup Rust toolchain
uses: actions-rust-lang/setup-rust-toolchain@2b1f5e9b395427c92ee4e3331786ca3c37afe2d7 # v1.16.0
uses: actions-rust-lang/setup-rust-toolchain@166cdcfd11aee3cb47222f9ddb555ce30ddb9659 # v1.17.0
with:
rustflags: ''
cache: false
@@ -93,36 +100,23 @@ jobs:
- name: Setup mold
uses: rui314/setup-mold@9c9c13bf4c3f1adef0cc596abc155580bcb04444 # v1 / Mold 2.41.0
- name: Setup sccache
uses: mozilla-actions/sccache-action@9e7fa8a12102821edf02ca5dbea1acd0f89a2696 # v0.0.10
- name: Set up caches
if: needs.skip-if-clean.outputs.internal == 'true'
uses: namespacelabs/nscloud-cache-action@c5f8dab7560444c4bf8dbc64f1b203431873c547 # v1.6.1
with:
cache: |
rust
apt
- name: Configure sccache
if: needs.skip-if-clean.outputs.internal == 'true'
run: nsc cache sccache setup --cache_name default >> "$GITHUB_ENV"
- name: Install build dependencies
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
with:
path: |
~/.cargo/registry
~/.cargo/git
~/.cargo/bin
key: ${{ runner.os }}-${{ runner.arch }}-cargo-${{ hashFiles('**/Cargo.lock') }}
- name: Ensure sccache directory exists
if: needs.skip-if-clean.outputs.internal == 'true'
run: sudo mkdir -p /mnt/sccache && sudo chown $(id -u):$(id -g) /mnt/sccache
- name: Mount sccache disk cache
if: needs.skip-if-clean.outputs.internal == 'true'
uses: useblacksmith/stickydisk@13af8883542ca949a717e70fef89d15edbb29d88 # v1.2.0
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
@@ -134,42 +128,30 @@ jobs:
cp -r apps/labrinth/migrations apps/labrinth/docker-stage/migrations
cp -r apps/labrinth/assets apps/labrinth/docker-stage/assets
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4.0.0
- name: Generate Docker image metadata
id: docker-meta
uses: docker/metadata-action@030e881283bb7a6894de51c315a6bfe6a94e05cf # v6.0.0
env:
# GitHub Packages requires annotations metadata in at least the index descriptor to show them
# up properly in its UI it seems, but it's not clear about it, because the docs refer to the
# image manifest only. See:
# https://docs.github.com/en/packages/working-with-a-github-packages-registry/working-with-the-container-registry#adding-a-description-to-multi-arch-images
DOCKER_METADATA_ANNOTATIONS_LEVELS: manifest,index
- name: Upload Docker context
if: needs.skip-if-clean.outputs.internal == 'true'
uses: namespace-actions/upload-artifact@f6ccaacc655aec41b93af180d1d7eef21af862d2 # v1.0.3
with:
images: ghcr.io/modrinth/labrinth
labels: |
org.opencontainers.image.title=labrinth
org.opencontainers.image.description=Modrinth API
org.opencontainers.image.licenses=AGPL-3.0-only
annotations: |
org.opencontainers.image.title=labrinth
org.opencontainers.image.description=Modrinth API
org.opencontainers.image.licenses=AGPL-3.0-only
name: labrinth-docker-context
retention-days: 1
path: apps/labrinth/docker-stage
- name: Login to GitHub Packages
uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
docker-build:
needs: [skip-if-clean, build]
if: ${{ needs.skip-if-clean.outputs.internal == 'true' }}
uses: SparkUniverse/workflows/.github/workflows/docker-build.yaml@main
with:
image-name: labrinth
dockerfile-path: apps/labrinth/Dockerfile
artifacts-name: labrinth-docker-context
- name: Build and push
uses: docker/build-push-action@bcafcacb16a39f128d818304e6c9c0c18556b85f # v7.1.0
with:
context: ./apps/labrinth/docker-stage
file: ./apps/labrinth/Dockerfile
push: true
tags: ${{ steps.docker-meta.outputs.tags }}
labels: ${{ steps.docker-meta.outputs.labels }}
annotations: ${{ steps.docker-meta.outputs.annotations }}
deploy:
needs: [skip-if-clean, docker-build]
if: ${{ needs.skip-if-clean.outputs.internal == 'true' && (github.ref == 'refs/heads/main' || github.ref == 'refs/heads/prod') }}
uses: SparkUniverse/workflows/.github/workflows/argo-update.yaml@main
secrets:
ARGOCD_DEPLOY_KEY: ${{ secrets.ARGOCD_DEPLOY_KEY }}
with:
application-set: labrinth
branch: ${{ github.ref == 'refs/heads/prod' && 'main' || 'develop' }}
environment-name: ${{ github.ref == 'refs/heads/prod' && 'production' || 'staging' }}
-41
View File
@@ -1,41 +0,0 @@
name: Prepare pnpm cache
on:
push:
paths:
- .github/workflows/prepare-pnpm-cache.yml
- package.json
- pnpm-lock.yaml
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: ${{ github.ref != 'refs/heads/main' && github.ref != 'refs/heads/prod' }}
jobs:
prepare:
if: github.repository_owner == 'modrinth'
runs-on: blacksmith-2vcpu-ubuntu-2404
steps:
- name: Checkout
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Setup Node
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with:
node-version-file: .nvmrc
- name: Enable Corepack
run: corepack enable
- name: Get pnpm store path
id: pnpm-store
run: echo "store-path=$(pnpm store path --silent)" >> $GITHUB_OUTPUT
- name: Cache pnpm
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
with:
path: ${{ steps.pnpm-store.outputs.store-path }}
key: pnpm-cache-${{ runner.os }}-${{ runner.arch }}-${{ hashFiles('**/pnpm-lock.yaml') }}
- name: Install dependencies
run: pnpm recursive install --frozen-lockfile
+20
View File
@@ -0,0 +1,20 @@
name: Slash Command Dispatch
on:
issue_comment:
types: [created]
permissions: {}
jobs:
dispatch-command:
if: ${{ github.event.sender.type == 'User' && contains(fromJSON('["OWNER", "MEMBER"]'), github.event.comment.author_association) }}
runs-on: namespace-profile-tiny-arm64
steps:
- name: Slash Command Dispatch
uses: peter-evans/slash-command-dispatch@9bdcd7914ec1b75590b790b844aa3b8eee7c683a # v5.0.2
with:
token: ${{ secrets.SLASH_CMD_GH_TOKEN }}
issue-type: pull-request
commands: |
deploy
+39 -36
View File
@@ -44,61 +44,62 @@ 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:
platform: [
blacksmith-6vcpu-macos-26,
blacksmith-8vcpu-windows-2025, # At time of writing, Windows 4 vCPU VMs don't seem to actually exist
blacksmith-4vcpu-ubuntu-2404,
platform:
[
namespace-profile-medium-amd64-macos,
namespace-profile-medium-amd64-windows,
namespace-profile-medium-amd64,
]
include:
- platform: blacksmith-6vcpu-macos-26
- platform: namespace-profile-medium-amd64-macos
artifact-target-name: universal-apple-darwin
- platform: blacksmith-8vcpu-windows-2025
- platform: namespace-profile-medium-amd64-windows
artifact-target-name: x86_64-pc-windows-msvc
- platform: blacksmith-4vcpu-ubuntu-2404
- platform: namespace-profile-medium-amd64
artifact-target-name: x86_64-unknown-linux-gnu
runs-on: ${{ matrix.platform }}
steps:
- name: Check out code
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
fetch-depth: 0
- name: Setup Rust toolchain
uses: actions-rust-lang/setup-rust-toolchain@2b1f5e9b395427c92ee4e3331786ca3c37afe2d7 # v1.16.0
uses: actions-rust-lang/setup-rust-toolchain@166cdcfd11aee3cb47222f9ddb555ce30ddb9659 # v1.17.0
with:
rustflags: ''
target: ${{ contains(matrix.platform, 'macos') && 'x86_64-apple-darwin' || '' }}
target: ${{ contains(matrix.artifact-target-name, 'darwin') && 'x86_64-apple-darwin' || '' }}
- name: Setup mold
if: contains(matrix.platform, 'ubuntu')
if: contains(matrix.artifact-target-name, 'linux')
uses: rui314/setup-mold@9c9c13bf4c3f1adef0cc596abc155580bcb04444 # v1 / Mold 2.41.0
- name: Setup sccache
uses: mozilla-actions/sccache-action@9e7fa8a12102821edf02ca5dbea1acd0f89a2696 # v0.0.10
- name: Setup Node
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
with:
node-version-file: .nvmrc
- name: Enable Corepack
run: corepack enable
- name: Setup Node
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
- name: Set up caches
uses: namespacelabs/nscloud-cache-action@c5f8dab7560444c4bf8dbc64f1b203431873c547 # v1.6.1
with:
node-version-file: .nvmrc
cache: 'pnpm'
cache: |
rust
pnpm
- name: Configure sccache
run: nsc cache sccache setup --cache_name default >> "$GITHUB_ENV"
- name: Generate tauri-dev.conf.json
shell: bash
@@ -116,11 +117,16 @@ jobs:
EOF
- name: Install Linux build dependencies
if: contains(matrix.platform, 'ubuntu')
uses: awalsh128/cache-apt-pkgs-action@acb598e5ddbc6f68a970c5da0688d2f3a9f04d05 # v1.6.0
if: contains(matrix.artifact-target-name, 'linux')
run: |
sudo apt-get update
sudo apt-get install -y libwebkit2gtk-4.1-dev libayatana-appindicator3-dev librsvg2-dev
- name: Setup Java
uses: actions/setup-java@03ad4de0992f5dab5e18fcb136590ce7c4a0ac95 # v5.6.0
with:
packages: libwebkit2gtk-4.1-dev libayatana-appindicator3-dev librsvg2-dev
version: v1 # cache key
distribution: temurin
java-version: 17
- name: Setup Dasel
uses: jaxxstorm/action-install-gh-release@25e24d2d23ae098373794ef1d6faecb48ee52da8 # v3.0.0
@@ -128,7 +134,7 @@ jobs:
repo: TomWright/dasel
tag: v2.8.1
extension-matching: disable
rename-to: ${{ contains(matrix.platform, 'windows') && 'dasel.exe' || 'dasel' }}
rename-to: ${{ contains(matrix.artifact-target-name, 'windows') && 'dasel.exe' || 'dasel' }}
chmod: 0755
- name: Set application version and environment
@@ -147,14 +153,11 @@ jobs:
cp "packages/app-lib/.env.${BUILD_ENVIRONMENT}" packages/app-lib/.env
- name: Setup Turbo cache
uses: rharkor/caching-for-turbo@56219402aacc0d06b650d898c222996dbc1191ec # v2.3.14
- name: Install dependencies
run: pnpm install
- name: Set up Windows code signing
if: contains(matrix.platform, 'windows')
if: contains(matrix.artifact-target-name, 'windows')
shell: bash
run: |
if [ '${{ startsWith(github.ref, 'refs/tags/v') || inputs.sign-windows-binaries }}' = 'true' ]; then
@@ -165,7 +168,7 @@ jobs:
- name: Build macOS app
run: ${{ (github.ref == 'refs/heads/main' || startsWith(github.ref, 'refs/tags/v') || inputs.app-version-override != '') && 'pnpm --filter=@modrinth/app run tauri build --target universal-apple-darwin --config tauri-release.conf.json' || 'pnpm --filter=@modrinth/app run tauri build --target universal-apple-darwin --config tauri-dev.conf.json' }}
if: contains(matrix.platform, 'macos')
if: contains(matrix.artifact-target-name, 'darwin')
env:
TAURI_BUNDLER_DMG_IGNORE_CI: 'true'
ENABLE_CODE_SIGNING: ${{ secrets.APPLE_CERTIFICATE }}
@@ -180,7 +183,7 @@ jobs:
- name: Build Linux app
run: ${{ (github.ref == 'refs/heads/main' || startsWith(github.ref, 'refs/tags/v') || inputs.app-version-override != '') && 'pnpm --filter=@modrinth/app run tauri build --config tauri-release.conf.json' || 'pnpm --filter=@modrinth/app run tauri build --config tauri-dev.conf.json' }}
if: contains(matrix.platform, 'ubuntu')
if: contains(matrix.artifact-target-name, 'linux')
env:
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_PRIVATE_KEY }}
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_KEY_PASSWORD }}
@@ -192,7 +195,7 @@ jobs:
$env:JAVA_HOME = "$env:JAVA_HOME_17_X64"
${{ (github.ref == 'refs/heads/main' || startsWith(github.ref, 'refs/tags/v') || inputs.app-version-override != '') && 'pnpm --filter=@modrinth/app run tauri build --config tauri-release.conf.json --verbose --bundles "nsis,updater"' || 'pnpm --filter=@modrinth/app run tauri build --config tauri-dev.conf.json --verbose --bundles "nsis,updater"' }}
Remove-Item -Path signer-client-cert.p12 -ErrorAction SilentlyContinue
if: contains(matrix.platform, 'windows')
if: contains(matrix.artifact-target-name, 'windows')
env:
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_PRIVATE_KEY }}
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_KEY_PASSWORD }}
+2 -2
View File
@@ -15,7 +15,7 @@ jobs:
github.event.workflow_run.conclusion == 'success' &&
github.event.workflow_run.event == 'push' &&
startsWith(github.event.workflow_run.head_branch, 'v')
runs-on: blacksmith-4vcpu-ubuntu-2404
runs-on: namespace-profile-medium-amd64
env:
VERSION_TAG: ${{ github.event.workflow_run.head_branch }}
LINUX_X64_BUNDLE_ARTIFACT_NAME: App bundle (x86_64-unknown-linux-gnu)
@@ -25,7 +25,7 @@ jobs:
steps:
- name: Check out code
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: Verify ref is a tag
env:
+24 -54
View File
@@ -22,7 +22,7 @@ jobs:
if: ${{ always() }}
steps:
- name: Checkout
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: Check if workflow runs on an internal branch
id: check-internal
@@ -62,7 +62,7 @@ jobs:
build:
name: Lint and Test
runs-on: ${{ needs.skip-if-clean.outputs.internal == 'true' && 'blacksmith-4vcpu-ubuntu-2404' || 'ubuntu-latest' }}
runs-on: ${{ needs.skip-if-clean.outputs.internal == 'true' && 'namespace-profile-modrinth-turbo' || 'ubuntu-latest' }}
needs: [skip-if-clean]
if: ${{ needs.skip-if-clean.outputs.skip != 'true' }}
@@ -76,54 +76,47 @@ 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' || '' }}
CARGO_INCREMENTAL: 0
# Redis config for tests
REDIS_TOPOLOGY: cluster
REDIS_CONNECTION_TYPE: multiplexed
REDIS_URL: redis://127.0.0.1:7000,redis://127.0.0.1:7001,redis://127.0.0.1:7002,redis://127.0.0.1:7003,redis://127.0.0.1:7004,redis://127.0.0.1:7005
# Avoid stack overflows in tests
RUST_MIN_STACK: 16777216
RUST_MIN_STACK: 134217728
steps:
- name: Check out code
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
fetch-depth: 2
- name: Install build dependencies
uses: awalsh128/cache-apt-pkgs-action@acb598e5ddbc6f68a970c5da0688d2f3a9f04d05 # v1.6.0
with:
packages: cmake libcurl4-openssl-dev libwebkit2gtk-4.1-dev libayatana-appindicator3-dev librsvg2-dev
version: v2 # cache key
- name: Setup Node
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
with:
node-version-file: .nvmrc
- name: Enable Corepack
run: corepack enable
- name: Get pnpm store path
id: pnpm-store
run: echo "store-path=$(pnpm store path --silent)" >> $GITHUB_OUTPUT
- name: Restore pnpm cache
uses: actions/cache/restore@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
- name: Set up caches
if: needs.skip-if-clean.outputs.internal == 'true'
uses: namespacelabs/nscloud-cache-action@c5f8dab7560444c4bf8dbc64f1b203431873c547 # v1.6.1
with:
path: ${{ steps.pnpm-store.outputs.store-path }}
key: pnpm-cache-${{ runner.os }}-${{ runner.arch }}-${{ hashFiles('**/pnpm-lock.yaml') }}
restore-keys: |
pnpm-cache-
cache: |
rust
pnpm
apt
- name: Set up Turborepo caching
if: needs.skip-if-clean.outputs.internal == 'true'
uses: namespace-actions/setup-turbocache@f96bd6befa13ae74b2858a5be49573640d4636f5 # v0.0.2
- name: Install apt dependencies
run: |
sudo apt-get update
sudo apt-get install -y cmake libcurl4-openssl-dev libwebkit2gtk-4.1-dev libayatana-appindicator3-dev librsvg2-dev
- name: Setup Rust toolchain
uses: actions-rust-lang/setup-rust-toolchain@2b1f5e9b395427c92ee4e3331786ca3c37afe2d7 # v1.16.0
uses: actions-rust-lang/setup-rust-toolchain@166cdcfd11aee3cb47222f9ddb555ce30ddb9659 # v1.17.0
with:
rustflags: ''
components: clippy, rustfmt
@@ -132,28 +125,8 @@ jobs:
- 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:
path: |
~/.cargo/registry
~/.cargo/git
~/.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
uses: cargo-bins/cargo-binstall@ead08b90bd7b2e6d81963fb9cf0b7239f66d5db4 # v1.21.0
- name: Setup nextest
run: cargo binstall --no-confirm --secure --locked cargo-nextest@0.9.133
@@ -161,16 +134,13 @@ jobs:
# cargo-binstall does not have pre-built binaries for sqlx-cli, so we fall
# back to a cached cargo install
- name: Setup cargo-sqlx
uses: taiki-e/cache-cargo-install-action@f9eed3e4680f27610dc6d8c67be1b88593f7dade # v3.0.6
uses: taiki-e/cache-cargo-install-action@417450f3c33ee20393705369577571770643d4c7 # v3.0.7
with:
tool: sqlx-cli@0.8.6
locked: false
no-default-features: true
features: rustls,postgres
- name: Setup Turbo cache
uses: rharkor/caching-for-turbo@56219402aacc0d06b650d898c222996dbc1191ec # v2.3.14
- name: Install dependencies
run: pnpm install
+23
View File
@@ -0,0 +1,23 @@
merge_queue:
mode: parallel
scopes:
source:
files:
frontend:
include:
- apps/frontend/**/*
- packages/ui/**/*
- packages/utils/**/*
- packages/assets/**/*
- "**/wrangler.jsonc"
- "**/pnpm-*.yaml"
backend:
include:
- apps/labrinth/**
- Cargo.toml
app:
include:
- apps/app/**
- apps/app-frontend/**
- packages/app-lib/**
- packages/app-macros/**
Generated
+19 -90
View File
@@ -2331,15 +2331,6 @@ version = "0.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6245d59a3e82a7fc217c5828a6692dbc6dfb63a0c8c90495621f7b9d79704a0e"
[[package]]
name = "convert_case"
version = "0.8.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "baaaa0ecca5b51987b9423ccdc971514dd8b0bb7b4060b983d3664dad3f1f89f"
dependencies = [
"unicode-segmentation",
]
[[package]]
name = "convert_case"
version = "0.10.0"
@@ -4806,7 +4797,7 @@ dependencies = [
"libc",
"percent-encoding",
"pin-project-lite",
"socket2 0.5.10",
"socket2 0.6.5",
"system-configuration",
"tokio",
"tower-service",
@@ -5217,15 +5208,6 @@ version = "1.70.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7943c866cc5cd64cbc25b2e01621d07fa8eb2a1a23160ee81ce38704e97b8ecf"
[[package]]
name = "iso8601"
version = "0.6.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e1082f0c48f143442a1ac6122f67e360ceee130b967af4d50996e5154a45df46"
dependencies = [
"nom 8.0.0",
]
[[package]]
name = "itertools"
version = "0.12.1"
@@ -5385,19 +5367,6 @@ dependencies = [
"serde_json",
]
[[package]]
name = "jsonwebtoken"
version = "9.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5a87cc7a48537badeae96744432de36f4be2b4a34a05a5ef32e9dd8a1c169dde"
dependencies = [
"base64 0.22.1",
"js-sys",
"ring",
"serde",
"serde_json",
]
[[package]]
name = "keyboard-types"
version = "0.7.0"
@@ -5465,6 +5434,7 @@ dependencies = [
"bytes",
"censor",
"chrono",
"chumsky",
"clap 4.5.48",
"clickhouse",
"color-eyre",
@@ -5490,7 +5460,6 @@ dependencies = [
"json-patch 4.1.0",
"labrinth",
"lettre",
"meilisearch-sdk",
"modrinth-content-management",
"modrinth-util",
"muralpay",
@@ -5514,10 +5483,12 @@ dependencies = [
"scalar_api_reference",
"sentry",
"serde",
"serde-binhum",
"serde_json",
"serde_with",
"sha1 0.10.6",
"sha2",
"smallvec",
"spdx",
"sqlx",
"sqlx-tracing",
@@ -5952,49 +5923,6 @@ version = "0.8.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ae960838283323069879657ca3de837e9f7bbb4c7bf6ea7f1b290d5e9476d2e0"
[[package]]
name = "meilisearch-index-setting-macro"
version = "0.30.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e36e5cad3754ed329feb201c24f26c0694442bef50a90dbcff0ba6d12b5ca133"
dependencies = [
"convert_case 0.8.0",
"proc-macro2",
"quote",
"structmeta",
"syn 2.0.106",
]
[[package]]
name = "meilisearch-sdk"
version = "0.30.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f620c9ed9b790cf292b66c8dab16f0e4aafb377583ec0e589da7c3ee81ff2038"
dependencies = [
"async-trait",
"bytes",
"either",
"futures-channel",
"futures-core",
"futures-io",
"futures-util",
"iso8601",
"jsonwebtoken",
"log",
"meilisearch-index-setting-macro",
"pin-project-lite",
"reqwest 0.12.24",
"serde",
"serde_json",
"thiserror 2.0.17",
"time",
"tokio",
"uuid 1.23.3",
"wasm-bindgen-futures",
"web-sys",
"yaup",
]
[[package]]
name = "memchr"
version = "2.7.6"
@@ -8014,7 +7942,7 @@ dependencies = [
"quinn-udp",
"rustc-hash",
"rustls 0.23.32",
"socket2 0.5.10",
"socket2 0.6.5",
"thiserror 2.0.17",
"tokio",
"tracing",
@@ -8051,7 +7979,7 @@ dependencies = [
"cfg_aliases",
"libc",
"once_cell",
"socket2 0.5.10",
"socket2 0.6.5",
"tracing",
"windows-sys 0.60.2",
]
@@ -9377,6 +9305,16 @@ dependencies = [
"serde_derive",
]
[[package]]
name = "serde-binhum"
version = "0.1.0"
dependencies = [
"darling 0.23.0",
"proc-macro2",
"quote",
"syn 2.0.106",
]
[[package]]
name = "serde-untagged"
version = "0.1.9"
@@ -11018,6 +10956,7 @@ dependencies = [
"heck 0.5.0",
"hickory-resolver 0.25.2",
"httpdate",
"image",
"indicatif",
"itertools 0.14.0",
"modrinth-content-management",
@@ -13440,12 +13379,13 @@ dependencies = [
"chrono",
"dashmap",
"deadpool-redis",
"eyre",
"futures",
"lz4_flex",
"postcard",
"prometheus",
"redis",
"serde",
"serde_json",
"thiserror 2.0.17",
"tokio",
"tracing",
@@ -13484,17 +13424,6 @@ dependencies = [
"xml-rs",
]
[[package]]
name = "yaup"
version = "0.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b0144f1a16a199846cb21024da74edd930b43443463292f536b7110b4855b5c6"
dependencies = [
"form_urlencoded",
"serde",
"thiserror 1.0.69",
]
[[package]]
name = "yoke"
version = "0.8.0"
+4 -1
View File
@@ -15,6 +15,7 @@ members = [
"packages/modrinth-util",
"packages/neverbounce",
"packages/path-util",
"packages/serde-binhum",
"packages/xredis",
]
@@ -59,6 +60,7 @@ bytes = "1.10.1"
censor = "0.3.0"
chardetng = "0.1.17"
chrono = "0.4.42"
chumsky = "0.9.3"
cidre = { version = "0.15.0", default-features = false, features = [
"macos_15_0"
] }
@@ -129,7 +131,6 @@ lz4_flex = { version = "0.11.5", default-features = false, features = [
"std",
] }
maxminddb = "0.26.0"
meilisearch-sdk = { version = "0.30.0", default-features = false }
modrinth-content-management = { path = "packages/modrinth-content-management" }
modrinth-log = { path = "packages/modrinth-log" }
modrinth-util = { path = "packages/modrinth-util" }
@@ -182,6 +183,7 @@ sentry = { version = "0.45.0", default-features = false, features = [
"rustls",
] }
serde = "1.0.228"
serde-binhum = { path = "packages/serde-binhum" }
serde_bytes = "0.11.19"
serde_cbor = "0.11.2"
serde_ini = "0.2.0"
@@ -192,6 +194,7 @@ sha1 = "0.10.6"
sha1_smol = { version = "1.0.1", features = ["std"] }
sha2 = "0.10.9"
shlex = "1.3.0"
smallvec = "1.15.1"
spdx = "0.12.0"
sqlx = { version = "0.8.6", default-features = false }
sqlx-tracing = { path = "packages/sqlx-tracing" }
+1 -1
View File
@@ -9,7 +9,7 @@
## Modrinth Monorepo
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!
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).
+187 -241
View File
@@ -10,45 +10,45 @@ import {
} from '@modrinth/api-client'
import {
ArrowBigUpDashIcon,
ChangeSkinIcon,
ChevronLeftIcon,
ChevronRightIcon,
CompassIcon,
ExternalIcon,
HomeIcon,
LeftArrowIcon,
LibraryIcon,
LogInIcon,
LogOutIcon,
NewspaperIcon,
NotepadTextIcon,
PlusIcon,
RefreshCwIcon,
RightArrowIcon,
ServerStackIcon,
SettingsIcon,
ShirtIcon,
UserIcon,
WorldIcon,
XIcon,
} from '@modrinth/assets'
import {
Admonition,
Avatar,
ButtonStyled,
ButtonLink,
commonMessages,
ContentInstallModal,
ContentUpdaterModal,
CreationFlowModal,
defineMessages,
I18nDebugPanel,
IconButton,
IntlFormatted,
LoadingBar,
NewsArticleCard,
NotificationPanel,
OverflowMenu,
PopupNotificationPanel,
provideModalBehavior,
provideModrinthClient,
provideNotificationManager,
providePageContext,
providePopupNotificationManager,
TeleportOverflowMenu,
TextLogo,
useDebugLogger,
useFormatBytes,
useHostingIntercom,
@@ -63,11 +63,9 @@ import { fetch as tauriFetch } from '@tauri-apps/plugin-http'
import { openUrl } from '@tauri-apps/plugin-opener'
import { type } from '@tauri-apps/plugin-os'
import { saveWindowState, StateFlags } from '@tauri-apps/plugin-window-state'
import { $fetch } from 'ofetch'
import { computed, onMounted, onUnmounted, provide, ref, watch } from 'vue'
import { RouterView, useRoute, useRouter } from 'vue-router'
import ModrinthAppLogo from '@/assets/modrinth_app.svg?component'
import AccountsCard from '@/components/ui/AccountsCard.vue'
import AppActionBar from '@/components/ui/AppActionBar.vue'
import Breadcrumbs from '@/components/ui/Breadcrumbs.vue'
@@ -88,6 +86,7 @@ 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 SurveyPopup from '@/components/ui/SurveyPopup.vue'
import WindowControls from '@/components/ui/WindowControls.vue'
import { useCheckDisableMouseover } from '@/composables/macCssFix.js'
import { config } from '@/config'
@@ -104,17 +103,13 @@ import { check_reachable } from '@/helpers/auth.js'
import { get_user, get_version } from '@/helpers/cache.js'
import { command_listener, notification_listener, warning_listener } from '@/helpers/events.js'
import { install_create_modpack_instance, install_get_modpack_preview } from '@/helpers/install'
import {
can_current_user_use_shared_instances,
get as getInstance,
list,
run,
} from '@/helpers/instance'
import { can_current_user_use_shared_instances, get as getInstance, run } from '@/helpers/instance'
import { get as getCreds, login, logout } from '@/helpers/mr_auth.ts'
import { mergeUrlQuery, parseModrinthLink } from '@/helpers/project-links.ts'
import { get as getSettings, set as setSettings } from '@/helpers/settings.ts'
import { get_opening_command, initialize_state } from '@/helpers/state'
import { hasActivePride26Midas, hasMidasBadge } from '@/helpers/user-campaigns.ts'
import { parse_modrinth_user_link } from '@/helpers/users'
import {
areUpdatesEnabled,
enqueueUpdateForInstallation,
@@ -126,6 +121,7 @@ import {
} from '@/helpers/utils.js'
import { start_join_server, start_join_singleplayer_world } from '@/helpers/worlds.ts'
import i18n from '@/i18n.config'
import { instanceKeys } from '@/pages/instance/query-options'
import {
appUpdateState,
downloadAvailableAppUpdate,
@@ -136,6 +132,7 @@ import {
openAppUpdateChangelog,
setAppUpdateActions,
} from '@/providers/app-update.ts'
import { createBreadcrumbManager, provideBreadcrumbManager } from '@/providers/breadcrumbs'
import { createContentInstall, provideContentInstall } from '@/providers/content-install'
import {
provideAppUpdateDownloadProgress,
@@ -147,15 +144,30 @@ import { setupAuthProvider } from '@/providers/setup/auth'
import { setupLoadingStateProvider } from '@/providers/setup/loading-state'
import { useError } from '@/store/error.js'
import { useTheming } from '@/store/state'
import { appMessages } from '@/utils/app-messages'
import { generateSkinPreviews } from './helpers/rendering/batch-skin-renderer'
import { get_available_capes, get_available_skins } from './helpers/skins'
import { AppNotificationManager } from './providers/app-notifications'
import { AppPopupNotificationManager } from './providers/app-popup-notifications'
import { appSettingsModalOpenProfileKey } from './providers/app-settings-modal'
const themeStore = useTheming()
const router = useRouter()
const route = useRoute()
const breadcrumbManager = createBreadcrumbManager()
provideBreadcrumbManager(breadcrumbManager)
const canNavigateBack = ref(false)
const canNavigateForward = ref(false)
function updateHistoryNavigationState() {
const historyState = window.history.state
canNavigateBack.value = historyState?.back != null
canNavigateForward.value = historyState?.forward != null
}
updateHistoryNavigationState()
const APP_LEFT_NAV_WIDTH = '4rem'
const APP_SIDEBAR_WIDTH = 300
const INTERCOM_BUBBLE_DEFAULT_PADDING = 20
@@ -232,7 +244,7 @@ const { data: authenticatedModrinthUser } = useQuery({
retry: false,
})
useQuery({
queryKey: computed(() => ['shared-instance-eligibility', credentials.value?.user?.id]),
queryKey: computed(() => instanceKeys.sharedEligibility(credentials.value?.user?.id)),
queryFn: can_current_user_use_shared_instances,
enabled: () => !!credentials.value?.session && !!credentials.value?.user?.id,
retry: false,
@@ -265,7 +277,7 @@ providePageContext({
themeStore.getFeatureFlag('server_ram_as_bytes_always_on'),
),
},
openExternalUrl: (url) => openUrl(url),
openExternalUrl: (url) => void openUrl(url),
})
provideModalBehavior({
noblur: computed(() => !themeStore.advancedRendering),
@@ -288,7 +300,6 @@ const {
} = setupProviders(notificationManager, popupNotificationManager)
const news = ref([])
const availableSurvey = ref(false)
const displayedServerInviteNotifications = new Set()
const serverInvitePopupNotificationIds = new Set()
let liveNotificationGeneration = 0
@@ -402,6 +413,54 @@ const messages = defineMessages({
id: 'app.ads-consent.accept',
defaultMessage: 'Accept all',
},
home: {
id: 'app.nav.home',
defaultMessage: 'Home',
},
library: {
id: 'app.nav.library',
defaultMessage: 'Library',
},
modrinthHosting: {
id: 'app.nav.modrinth-hosting',
defaultMessage: 'Modrinth Hosting',
},
createNewInstance: {
id: 'app.nav.create-new-instance',
defaultMessage: 'Create new instance',
},
modrinthAccount: {
id: 'app.nav.modrinth-account',
defaultMessage: 'Modrinth account',
},
signedInAs: {
id: 'app.nav.signed-in-as',
defaultMessage: 'Signed in as <user>{username}</user>',
},
signInToModrinthAccount: {
id: 'app.nav.sign-in-to-modrinth-account',
defaultMessage: 'Sign in to a Modrinth account',
},
restarting: {
id: 'app.restarting',
defaultMessage: 'Restarting...',
},
upgradeToModrinthPlus: {
id: 'app.nav.upgrade-to-modrinth-plus',
defaultMessage: 'Upgrade to Modrinth+',
},
news: {
id: 'app.news.title',
defaultMessage: 'News',
},
viewAllNews: {
id: 'app.news.view-all',
defaultMessage: 'View all news',
},
playingAs: {
id: 'app.sidebar.playing-as',
defaultMessage: 'Playing as',
},
})
function handleAdsConsentRequired(required) {
@@ -570,12 +629,6 @@ async function setupApp() {
settings.pending_update_toast_for_version = null
await setSettings(settings)
}
if (osType === 'windows') {
await processPendingSurveys()
} else {
console.info('Skipping user surveys on non-Windows platforms')
}
}
const stateFailed = ref(false)
@@ -619,6 +672,7 @@ router.beforeEach(() => {
routerToken = loading.begin()
})
router.afterEach((to, from, failure) => {
updateHistoryNavigationState()
trackEvent('PageView', {
path: to.path,
fromPath: from.path,
@@ -760,6 +814,8 @@ const sharedInstanceInviteHandler = ref()
const updateToPlayModal = ref()
const modrinthLoginModal = ref()
const appSettingsModal = ref()
provide(appSettingsModalOpenProfileKey, () => appSettingsModal.value?.showProfile())
watch(incompatibilityWarningModal, (modal) => {
if (modal) {
@@ -966,7 +1022,7 @@ async function declineServerInviteNotification(notification) {
function openServerInviteInviterProfile(inviterName) {
if (!inviterName) return
openUrl(`${config.siteUrl}/user/${encodeURIComponent(inviterName)}`)
void router.push(`/user/${encodeURIComponent(inviterName)}`)
}
async function handleLiveNotification(notification) {
@@ -1407,8 +1463,11 @@ function handleClick(e) {
!target.href.startsWith('https://tauri.localhost') &&
!target.href.startsWith('http://tauri.localhost')
) {
const userPath = parse_modrinth_user_link(target.href)
const parsed = parseModrinthLink(target.href)
if (target.target !== '_blank' && parsed) {
if (userPath) {
void router.push(userPath)
} else if (target.target !== '_blank' && parsed) {
void openModrinthProjectLinkInApp(parsed)
} else {
openUrl(target.href)
@@ -1435,115 +1494,6 @@ function handleAuxClick(e) {
}
}
function cleanupOldSurveyDisplayData() {
const threeWeeksAgo = new Date()
threeWeeksAgo.setDate(threeWeeksAgo.getDate() - 21)
for (let i = 0; i < localStorage.length; i++) {
const key = localStorage.key(i)
if (key.startsWith('survey-') && key.endsWith('-display')) {
const dateValue = new Date(localStorage.getItem(key))
if (dateValue < threeWeeksAgo) {
localStorage.removeItem(key)
}
}
}
}
async function openSurvey() {
if (!availableSurvey.value) {
console.error('No survey to open')
return
}
const creds = await getCreds().catch(handleError)
const userId = creds?.user_id
const formId = availableSurvey.value.tally_id
const popupOptions = {
layout: 'modal',
width: 700,
autoClose: 2000,
hideTitle: true,
hiddenFields: {
user_id: userId,
},
onOpen: () => console.info('Opened user survey'),
onClose: () => {
console.info('Closed user survey')
show_ads_window()
},
onSubmit: () => console.info('Active user survey submitted'),
}
try {
hide_ads_window()
if (window.Tally?.openPopup) {
console.info(`Opening Tally popup for user survey (form ID: ${formId})`)
dismissSurvey()
window.Tally.openPopup(formId, popupOptions)
} else {
console.warn('Tally script not yet loaded')
show_ads_window()
}
} catch (e) {
console.error('Error opening Tally popup:', e)
show_ads_window()
}
console.info(`Found user survey to show with tally_id: ${formId}`)
window.Tally.openPopup(formId, popupOptions)
}
function dismissSurvey() {
localStorage.setItem(`survey-${availableSurvey.value.id}-display`, new Date())
availableSurvey.value = undefined
}
async function processPendingSurveys() {
function isWithinLastTwoWeeks(date) {
const twoWeeksAgo = new Date()
twoWeeksAgo.setDate(twoWeeksAgo.getDate() - 14)
return date >= twoWeeksAgo
}
cleanupOldSurveyDisplayData()
const creds = await getCreds().catch(handleError)
const userId = creds?.user_id
const instances = (await list().catch(handleError)) ?? []
const isActivePlayer = instances.some(
(instance) =>
isWithinLastTwoWeeks(instance.last_played) && !isWithinLastTwoWeeks(instance.created),
)
let surveys = []
try {
surveys = await $fetch('https://api.modrinth.com/v2/surveys')
} catch (e) {
console.error('Error fetching surveys:', e)
}
const surveyToShow = surveys.find(
(survey) =>
!!(
localStorage.getItem(`survey-${survey.id}-display`) === null &&
survey.type === 'tally_app' &&
((survey.condition === 'active_player' && isActivePlayer) ||
(survey.assigned_users?.includes(userId) && !survey.dismissed_users?.includes(userId)))
),
)
if (surveyToShow) {
availableSurvey.value = surveyToShow
} else {
console.info('No user survey to show')
}
}
provideAppUpdateDownloadProgress(appUpdateDownload)
</script>
@@ -1566,12 +1516,12 @@ provideAppUpdateDownloadProgress(appUpdateDownload)
class="flex items-center gap-4 text-contrast font-semibold text-xl select-none cursor-default"
>
<RefreshCwIcon data-tauri-drag-region class="animate-spin w-6 h-6" />
Restarting...
{{ formatMessage(messages.restarting) }}
</span>
</div>
</Transition>
<Suspense>
<AppSettingsModal ref="settingsModal" />
<AppSettingsModal ref="appSettingsModal" />
</Suspense>
<Suspense>
<ModrinthAccountRequiredModal ref="modrinthLoginModal" :request-auth="requestModrinthAuth" />
@@ -1589,27 +1539,26 @@ provideAppUpdateDownloadProgress(appUpdateDownload)
/>
<UnknownPackWarningModal ref="unknownPackWarningModal" />
<div
class="app-grid-navbar bg-bg-raised flex flex-col p-[0.5rem] pt-0 gap-[0.5rem] w-[--left-bar-width]"
class="app-grid-navbar bg-bg-raised flex flex-col p-[0.5rem] pt-0 gap-[0.25rem] w-[--left-bar-width]"
>
<NavButton v-tooltip.right="'Home'" to="/">
<NavButton v-tooltip.right="formatMessage(messages.home)" to="/">
<HomeIcon />
</NavButton>
<NavButton v-if="themeStore.featureFlags.worlds_tab" v-tooltip.right="'Worlds'" to="/worlds">
<WorldIcon />
</NavButton>
<NavButton
v-tooltip.right="'Discover content'"
v-tooltip.right="formatMessage(commonMessages.discoverContentLabel)"
to="/browse/modpack"
:is-primary="() => route.path.startsWith('/browse') && !route.query.i"
:is-subpage="(route) => route.path.startsWith('/project') && !route.query.i"
:is-primary="() => route.path.startsWith('/browse') && !route.query.i && !route.query.sid"
:is-subpage="
(route) => route.path.startsWith('/project') && !route.query.i && !route.query.sid
"
>
<CompassIcon />
</NavButton>
<NavButton v-tooltip.right="'Skin selector'" to="/skins">
<ChangeSkinIcon />
<NavButton v-tooltip.right="formatMessage(appMessages.skinSelectorLabel)" to="/skins">
<ShirtIcon />
</NavButton>
<NavButton
v-tooltip.right="'Library'"
v-tooltip.right="formatMessage(messages.library)"
to="/library"
:is-primary="(r) => r.path === '/library' || r.path === '/library'"
:is-subpage="
@@ -1622,19 +1571,22 @@ provideAppUpdateDownloadProgress(appUpdateDownload)
<LibraryIcon />
</NavButton>
<NavButton
v-tooltip.right="'Modrinth Hosting'"
v-tooltip.right="formatMessage(messages.modrinthHosting)"
to="/hosting/manage"
:is-primary="(r) => r.path === '/hosting/manage' || r.path === '/hosting/manage/'"
:is-subpage="(r) => r.path.startsWith('/hosting/manage/') && r.path !== '/hosting/manage/'"
:is-subpage="
(r) =>
(r.path.startsWith('/hosting/manage/') && r.path !== '/hosting/manage/') ||
((r.path.startsWith('/browse') || r.path.startsWith('/project')) && r.query.sid)
"
>
<ServerStackIcon />
</NavButton>
<div class="h-px w-6 mx-auto my-2 bg-surface-5"></div>
<suspense>
<QuickInstanceSwitcher />
</suspense>
<NavButton
v-tooltip.right="'Create new instance'"
v-tooltip.right="formatMessage(messages.createNewInstance)"
:to="() => installationModal?.show()"
:disabled="offline"
>
@@ -1643,82 +1595,106 @@ provideAppUpdateDownloadProgress(appUpdateDownload)
<div class="flex flex-grow"></div>
<NavButton
v-tooltip.right="formatMessage(commonMessages.settingsLabel)"
:to="() => $refs.settingsModal.show()"
:to="() => appSettingsModal?.show()"
>
<SettingsIcon />
</NavButton>
<OverflowMenu
<TeleportOverflowMenu
v-if="credentials?.user"
v-tooltip.right="`Modrinth account`"
class="w-12 h-12 text-primary rounded-full flex items-center justify-center text-2xl transition-all bg-transparent hover:bg-button-bg hover:text-contrast border-0 cursor-pointer"
v-tooltip.right="formatMessage(messages.modrinthAccount)"
type="quiet"
size="xl"
label="More options"
:options="[
{
id: 'view-profile',
action: () => openUrl('https://modrinth.com/user/' + credentials.user.username),
label: formatMessage(messages.signedInAs, {
username: credentials.user.username,
}),
action: () => router.push(`/user/${encodeURIComponent(credentials.user.username)}`),
},
{
id: 'sign-out',
label: formatMessage(commonMessages.signOutButton),
tone: 'red',
action: () => logOut(),
color: 'danger',
},
]"
placement="right-end"
:distance="4"
>
<Avatar :src="credentials?.user?.avatar_url" alt="" size="32px" circle />
<template #view-profile>
<UserIcon />
<span class="inline-flex items-center gap-1">
Signed in as
<span class="inline-flex items-center gap-1 text-contrast font-semibold">
<Avatar :src="credentials?.user?.avatar_url" alt="" size="20px" circle />
{{ credentials?.user?.username }}
</span>
<IntlFormatted
:message-id="messages.signedInAs"
:values="{ username: credentials?.user?.username }"
>
<template #user="{ children }">
<span class="inline-flex items-center gap-1 text-contrast font-semibold">
<Avatar :src="credentials?.user?.avatar_url" alt="" size="20px" circle />
<component :is="() => children" />
</span>
</template>
</IntlFormatted>
</span>
<ExternalIcon />
</template>
<template #sign-out> <LogOutIcon /> Sign out </template>
</OverflowMenu>
<template #sign-out>
<LogOutIcon />
{{ formatMessage(commonMessages.signOutButton) }}
</template>
</TeleportOverflowMenu>
<NavButton
v-else
v-tooltip.right="'Sign in to a Modrinth account'"
v-tooltip.right="formatMessage(messages.signInToModrinthAccount)"
:to="() => requestSignIn()"
>
<LogInIcon class="text-brand" />
</NavButton>
</div>
<div data-tauri-drag-region class="app-grid-statusbar bg-bg-raised h-[--top-bar-height] flex">
<div data-tauri-drag-region class="flex min-w-0 flex-1 overflow-hidden p-3">
<ModrinthAppLogo class="h-full w-auto shrink-0 text-contrast pointer-events-none" />
<div data-tauri-drag-region class="flex shrink-0 items-center gap-1 ml-3">
<button
class="cursor-pointer p-0 m-0 text-contrast border-none outline-none bg-button-bg rounded-full flex items-center justify-center w-6 h-6 hover:brightness-75 transition-all"
<div data-tauri-drag-region class="flex min-w-0 flex-1 items-center overflow-hidden p-2">
<TextLogo class="h-7 w-auto shrink-0 text-contrast pointer-events-none" />
<div data-tauri-drag-region class="ml-2 flex shrink-0 items-center gap-2">
<IconButton
type="outlined"
label="Go back"
class="!h-7 !min-w-7 !w-7 !border !border-surface-4 !p-0 !opacity-100"
:disabled="!canNavigateBack"
@click="router.back()"
>
<LeftArrowIcon />
</button>
<button
class="cursor-pointer p-0 m-0 text-contrast border-none outline-none bg-button-bg rounded-full flex items-center justify-center w-6 h-6 hover:brightness-75 transition-all"
<ChevronLeftIcon
class="!size-4 !text-primary"
:class="{ 'opacity-20': !canNavigateBack }"
/>
</IconButton>
<IconButton
type="outlined"
label="Go forward"
class="!h-7 !min-w-7 !w-7 !border !border-surface-4 !p-0 !opacity-100"
:disabled="!canNavigateForward"
@click="router.forward()"
>
<RightArrowIcon />
</button>
<ChevronRightIcon
class="!size-4 !text-primary"
:class="{ 'opacity-20': !canNavigateForward }"
/>
</IconButton>
</div>
<Breadcrumbs class="pt-[2px]" />
<Breadcrumbs />
</div>
<section data-tauri-drag-region class="flex shrink-0 ml-auto items-center">
<ButtonStyled
<IconButton
v-if="!forceSidebar && themeStore.toggleSidebar"
:type="sidebarToggled ? 'standard' : 'transparent'"
circular
:type="sidebarToggled ? 'base' : 'quiet'"
label="Next image"
class="mr-3 transition-transform"
:class="{ 'rotate-180': !sidebarToggled }"
@click="sidebarToggled = !sidebarToggled"
>
<button
class="mr-3 transition-transform"
:class="{ 'rotate-180': !sidebarToggled }"
@click="sidebarToggled = !sidebarToggled"
>
<RightArrowIcon />
</button>
</ButtonStyled>
<RightArrowIcon />
</IconButton>
<div class="flex mr-3">
<Suspense>
<AppActionBar />
@@ -1737,28 +1713,7 @@ provideAppUpdateDownloadProgress(appUpdateDownload)
}"
>
<div class="app-viewport flex-grow router-view">
<transition name="popup-survey">
<div
v-if="availableSurvey"
class="w-[400px] z-20 fixed -bottom-12 pb-16 right-[--right-bar-width] mr-4 rounded-t-2xl card-shadow bg-bg-raised border-surface-5 border-[1px] border-solid border-b-0 p-4"
>
<h2 class="text-lg font-extrabold mt-0 mb-2">Hey there Modrinth user!</h2>
<p class="m-0 leading-tight">
Would you mind answering a few questions about your experience with Modrinth App?
</p>
<p class="mt-3 mb-4 leading-tight">
This feedback will go directly to the Modrinth team and help guide future updates!
</p>
<div class="flex gap-2">
<ButtonStyled color="brand">
<button @click="openSurvey"><NotepadTextIcon /> Take survey</button>
</ButtonStyled>
<ButtonStyled>
<button @click="dismissSurvey"><XIcon /> No thanks</button>
</ButtonStyled>
</div>
</div>
</transition>
<SurveyPopup />
<div
class="loading-indicator-container h-8 fixed z-50 pointer-events-none"
:style="{
@@ -1822,7 +1777,9 @@ provideAppUpdateDownloadProgress(appUpdateDownload)
<div id="sidebar-teleport-target" class="sidebar-teleport-content"></div>
<div class="sidebar-default-content" :class="{ 'sidebar-enabled': sidebarVisible }">
<div class="p-4 border-0 border-b-[1px] border-[--brand-gradient-border] border-solid">
<h3 class="text-base text-primary font-medium m-0">Playing as</h3>
<h3 class="text-base text-primary font-medium m-0">
{{ formatMessage(messages.playingAs) }}
</h3>
<suspense>
<AccountsCard ref="accounts" />
</suspense>
@@ -1837,18 +1794,26 @@ provideAppUpdateDownloadProgress(appUpdateDownload)
class="p-4 border-0 border-b-[1px] border-[--brand-gradient-border] border-solid"
/>
<div v-if="news && news.length > 0" class="p-4 flex flex-col items-center">
<h3 class="text-base mb-4 text-primary font-medium m-0 text-left w-full">News</h3>
<h3 class="text-base mb-4 text-primary font-medium m-0 text-left w-full">
{{ formatMessage(messages.news) }}
</h3>
<div class="space-y-4 flex flex-col items-center w-full">
<NewsArticleCard
v-for="(item, index) in news"
:key="`news-${index}`"
:article="item"
/>
<ButtonStyled color="brand" size="large">
<a href="https://modrinth.com/news" target="_blank" class="my-4">
<NewspaperIcon /> View all news
</a>
</ButtonStyled>
<ButtonLink
type="colored"
color="brand"
size="xl"
href="https://modrinth.com/news"
target="_blank"
class="my-4"
>
<NewspaperIcon />
{{ formatMessage(messages.viewAllNews) }}
</ButtonLink>
</div>
</div>
</div>
@@ -1859,7 +1824,8 @@ provideAppUpdateDownloadProgress(appUpdateDownload)
class="absolute bottom-[250px] w-full flex justify-center items-center gap-1 px-4 py-3 text-purple font-medium hover:underline z-10"
target="_blank"
>
<ArrowBigUpDashIcon class="text-2xl" /> Upgrade to Modrinth+
<ArrowBigUpDashIcon class="text-2xl" />
{{ formatMessage(messages.upgradeToModrinthPlus) }}
</a>
<PromotionWrapper />
</template>
@@ -2071,26 +2037,6 @@ provideAppUpdateDownloadProgress(appUpdateDownload)
display: contents;
}
.popup-survey-enter-active {
transition:
opacity 0.25s ease,
transform 0.25s cubic-bezier(0.51, 1.08, 0.35, 1.15);
transform-origin: top center;
}
.popup-survey-leave-active {
transition:
opacity 0.25s ease,
transform 0.25s cubic-bezier(0.68, -0.17, 0.23, 0.11);
transform-origin: top center;
}
.popup-survey-enter-from,
.popup-survey-leave-to {
opacity: 0;
transform: translateY(10rem) scale(0.8) scaleY(1.6);
}
@media (prefers-reduced-motion: no-preference) {
.nav-button-animated-enter-active {
transition: all 0.5s cubic-bezier(0.15, 1.4, 0.64, 0.96);
@@ -4,13 +4,11 @@
class="flex flex-col gap-3 bg-button-bg border border-solid border-surface-5 rounded-xl p-3 mt-2"
>
<span>{{ formatMessage(messages.notSignedIn) }}</span>
<ButtonStyled color="brand">
<button color="primary" :disabled="loginDisabled" @click="login()">
<LogInIcon v-if="!loginDisabled" />
<SpinnerIcon v-else class="animate-spin" />
{{ formatMessage(messages.signInToMinecraft) }}
</button>
</ButtonStyled>
<Button type="colored" color="brand" :disabled="loginDisabled" @click="login()">
<LogInIcon v-if="!loginDisabled" />
<SpinnerIcon v-else class="animate-spin" />
{{ formatMessage(messages.signInToMinecraft) }}
</Button>
</div>
<Accordion
v-else
@@ -60,24 +58,28 @@
{{ account.profile.name }}
</p>
</button>
<ButtonStyled circular color="red" color-fill="none" hover-color-fill="background">
<button
v-tooltip="formatMessage(messages.removeAccount)"
class="mr-2"
@click="logout(account.profile.id)"
>
<TrashIcon />
</button>
</ButtonStyled>
<IconButton
v-tooltip="formatMessage(messages.removeAccount)"
type="quiet"
color="red"
:label="formatMessage(messages.removeAccount)"
class="mr-2 !bg-button-bg !text-primary ![box-shadow:var(--shadow-button)] hover:!bg-red focus-visible:!bg-red hover:!text-[var(--color-accent-contrast)] focus-visible:!text-[var(--color-accent-contrast)]"
@click="logout(account.profile.id)"
>
<TrashIcon />
</IconButton>
</div>
</template>
<div class="flex flex-col gap-2 px-2 pt-2">
<ButtonStyled v-if="accounts.length > 0" class="w-full">
<button :disabled="loginDisabled" @click="login()">
<PlusIcon />
{{ formatMessage(messages.addAccount) }}
</button>
</ButtonStyled>
<Button
v-if="accounts.length > 0"
class="w-full !bg-button-bg !text-primary ![box-shadow:var(--shadow-button)]"
:disabled="loginDisabled"
@click="login()"
>
<PlusIcon />
{{ formatMessage(messages.addAccount) }}
</Button>
</div>
</div>
</Accordion>
@@ -95,8 +97,9 @@ import {
import {
Accordion,
Avatar,
ButtonStyled,
Button,
defineMessages,
IconButton,
injectNotificationManager,
useVIntl,
} from '@modrinth/ui'
@@ -1,6 +1,6 @@
<script setup lang="ts">
import { DropdownIcon, FolderOpenIcon, PlusIcon } from '@modrinth/assets'
import { ButtonStyled, injectNotificationManager, OverflowMenu } from '@modrinth/ui'
import { Button, injectNotificationManager, TeleportOverflowMenu } from '@modrinth/ui'
import { open } from '@tauri-apps/plugin-dialog'
import { useRouter } from 'vue-router'
@@ -36,27 +36,26 @@ const handleSearchContent = async () => {
<template>
<div class="joined-buttons">
<ButtonStyled>
<button @click="handleSearchContent">
<PlusIcon />
Install content
</button>
</ButtonStyled>
<ButtonStyled>
<OverflowMenu
:options="[
{
id: 'from_file',
action: handleAddContentFromFile,
},
]"
>
<DropdownIcon />
<template #from_file>
<FolderOpenIcon />
<span class="no-wrap"> Add from file </span>
</template>
</OverflowMenu>
</ButtonStyled>
<Button @click="handleSearchContent">
<PlusIcon />
Install content
</Button>
<TeleportOverflowMenu
label="More options"
:options="[
{
id: 'from_file',
label: 'Add from file',
action: handleAddContentFromFile,
},
]"
class="!w-auto !px-2.5 !rounded-xl"
>
<DropdownIcon />
<template #from_file>
<FolderOpenIcon />
<span class="no-wrap"> Add from file </span>
</template>
</TeleportOverflowMenu>
</div>
</template>
@@ -1,15 +1,15 @@
<template>
<div class="flex gap-2 items-center">
<ButtonStyled
<IconButton
v-if="hasActiveLoadingBars && !hasVisibleActiveDownloadToasts"
v-tooltip="formatMessage(messages.viewActiveDownloads)"
type="quiet"
color="brand"
type="transparent"
circular
:label="formatMessage(messages.viewActiveDownloads)"
@click="openDownloadToast()"
>
<button v-tooltip="formatMessage(messages.viewActiveDownloads)" @click="openDownloadToast()">
<DownloadIcon />
</button>
</ButtonStyled>
<DownloadIcon />
</IconButton>
<div v-if="offline" class="flex items-center gap-1">
<UnplugIcon class="text-secondary" />
<span class="text-sm text-contrast"> {{ formatMessage(messages.offline) }} </span>
@@ -36,17 +36,23 @@
@show="showInstances = true"
@hide="showInstances = false"
>
<ButtonStyled type="transparent" circular size="small">
<button
v-tooltip="
showInstances
? formatMessage(messages.hideMoreRunningInstances)
: formatMessage(messages.showMoreRunningInstances)
"
>
<DropdownIcon :class="{ 'rotate-180': !!showInstances }" />
</button>
</ButtonStyled>
<IconButton
v-tooltip="
showInstances
? formatMessage(messages.hideMoreRunningInstances)
: formatMessage(messages.showMoreRunningInstances)
"
class="!size-6"
type="quiet"
size="xs"
:label="
showInstances
? formatMessage(messages.hideMoreRunningInstances)
: formatMessage(messages.showMoreRunningInstances)
"
>
<DropdownIcon :class="{ 'rotate-180': !!showInstances }" />
</IconButton>
<template #popper>
<div class="flex w-[20rem] max-h-[24rem] flex-col gap-2 overflow-auto">
<div
@@ -125,8 +131,8 @@ import {
TerminalSquareIcon,
UnplugIcon,
} from '@modrinth/assets'
import { IconButton } from '@modrinth/ui'
import {
ButtonStyled,
defineMessages,
injectNotificationManager,
injectPopupNotificationManager,
@@ -345,7 +351,7 @@ function buildDownloadItems(): PopupNotificationProgressItem[] {
iconUrl: currentLoadingBarIconUrls.value[getLoadingBarKey(bar)] ?? null,
progress: getLoadingProgress(bar),
waiting: !bar.total || bar.total <= 0,
progressType: 'percentage',
progressType: bar.bar_type?.type === 'pack_import' ? 'bytes' : 'percentage',
progressCurrent: bar.current,
progressTotal: bar.total,
})),
@@ -2,7 +2,7 @@
<div
ref="outerRef"
data-tauri-drag-region
class="min-w-0 overflow-hidden pl-3"
class="min-w-0 overflow-hidden pl-4"
:class="{ 'breadcrumb-fade-mask': isOverflowing }"
:style="isOverflowing ? { '--scroll-distance': `-${overflowAmount}px` } : undefined"
@mouseenter="onMouseEnter"
@@ -11,30 +11,48 @@
<div
ref="innerRef"
data-tauri-drag-region
class="flex w-fit items-center gap-1"
class="flex w-fit items-center gap-2 pr-4"
:class="{ 'breadcrumbs-scroll': isAnimating }"
@animationiteration="onAnimationIteration"
>
{{ breadcrumbData.resetToNames(breadcrumbs) }}
<template v-for="breadcrumb in breadcrumbs" :key="breadcrumb.name">
<router-link
v-if="breadcrumb.link"
:to="{
path: breadcrumb.link.replace('{id}', encodeURIComponent($route.params.id as string)),
query: breadcrumb.query,
}"
class="shrink-0 whitespace-nowrap text-primary"
<template v-for="(breadcrumb, index) in breadcrumbs" :key="breadcrumb.slot">
<component
:is="index < breadcrumbs.length - 1 && breadcrumb.to ? RouterLink : 'span'"
v-bind="index < breadcrumbs.length - 1 && breadcrumb.to ? { to: breadcrumb.to } : {}"
:data-tauri-drag-region="index === breadcrumbs.length - 1 ? '' : undefined"
class="flex shrink-0 items-center gap-1.5 whitespace-nowrap text-base font-medium leading-6"
:class="
index === breadcrumbs.length - 1
? 'cursor-default select-none text-contrast'
: 'text-primary hover:text-contrast'
"
:aria-current="index === breadcrumbs.length - 1 ? 'page' : undefined"
>
{{ resolveLabel(breadcrumb.name) }}
</router-link>
<span
v-else
<Avatar
v-if="breadcrumb.visual?.type === 'image'"
:src="breadcrumb.visual.src"
:alt="breadcrumb.visual.alt ?? breadcrumb.label"
:circle="breadcrumb.visual.circle"
:tint-by="breadcrumb.visual.tintBy ?? breadcrumb.id"
size="20px"
no-shadow
raised
class="inline-block shrink-0 align-middle"
:class="{ '!rounded-md': !breadcrumb.visual.circle }"
/>
<component
:is="breadcrumb.visual.component"
v-else-if="breadcrumb.visual?.type === 'icon'"
class="size-5 shrink-0 text-primary"
aria-hidden="true"
/>
<span>{{ breadcrumb.label }}</span>
</component>
<ChevronRightIcon
v-if="index < breadcrumbs.length - 1"
data-tauri-drag-region
class="shrink-0 whitespace-nowrap text-contrast font-semibold cursor-default select-none"
>
{{ resolveLabel(breadcrumb.name) }}
</span>
<ChevronRightIcon v-if="breadcrumb.link" data-tauri-drag-region class="w-5 h-5 shrink-0" />
class="size-5 shrink-0 text-primary"
/>
</template>
</div>
</div>
@@ -42,34 +60,13 @@
<script setup lang="ts">
import { ChevronRightIcon } from '@modrinth/assets'
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'
import { useRoute } from 'vue-router'
import { Avatar } from '@modrinth/ui'
import { onBeforeUnmount, onMounted, ref, watch } from 'vue'
import { RouterLink } from 'vue-router'
import { useBreadcrumbs } from '@/store/breadcrumbs'
import { injectBreadcrumbManager } from '@/providers/breadcrumbs'
interface Breadcrumb {
name: string
link?: string
query?: Record<string, string>
}
const route = useRoute()
const breadcrumbData = useBreadcrumbs()
const breadcrumbs = computed<Breadcrumb[]>(() => {
const additionalContext =
route.meta.useContext === true
? breadcrumbData.context
: route.meta.useRootContext === true
? breadcrumbData.rootContext
: null
const crumbs = (route.meta.breadcrumb ?? []) as Breadcrumb[]
return additionalContext ? [additionalContext as Breadcrumb, ...crumbs] : crumbs
})
function resolveLabel(name: string): string {
return name.charAt(0) === '?' ? breadcrumbData.getName(name.slice(1)) : name
}
const { entries: breadcrumbs } = injectBreadcrumbManager()
// Overflow detection
const outerRef = ref<HTMLDivElement | null>(null)
@@ -83,9 +80,13 @@ let stopping = false
function checkOverflow() {
if (!outerRef.value || !innerRef.value) return
const overflow = innerRef.value.scrollWidth - outerRef.value.clientWidth
const outerStyles = window.getComputedStyle(outerRef.value)
const horizontalPadding =
Number.parseFloat(outerStyles.paddingLeft) + Number.parseFloat(outerStyles.paddingRight)
const availableWidth = outerRef.value.clientWidth - horizontalPadding
const overflow = innerRef.value.scrollWidth - availableWidth
isOverflowing.value = overflow > 0
overflowAmount.value = overflow + 12
overflowAmount.value = overflow
}
function onMouseEnter() {
@@ -9,7 +9,13 @@ import {
WrenchIcon,
XIcon,
} from '@modrinth/assets'
import { ButtonStyled, Collapsible, injectNotificationManager } from '@modrinth/ui'
import {
Button,
ButtonLink,
Collapsible,
IconButton,
injectNotificationManager,
} from '@modrinth/ui'
import { computed, ref } from 'vue'
import { ChatIcon } from '@/assets/icons'
@@ -273,12 +279,10 @@ async function copyToClipboard(text) {
</template>
</div>
<div class="flex items-center gap-2">
<ButtonStyled>
<a :href="supportLink" @click="errorModal.hide()"><ChatIcon /> Get support</a>
</ButtonStyled>
<ButtonStyled v-if="closable">
<button @click="errorModal.hide()"><XIcon /> Close</button>
</ButtonStyled>
<ButtonLink :href="supportLink" @click="errorModal.hide()"
><ChatIcon /> Get support</ButtonLink
>
<Button v-if="closable" @click="errorModal.hide()"><XIcon /> Close</Button>
</div>
<template v-if="hasDebugInfo">
<div class="flex flex-col gap-2">
@@ -307,16 +311,15 @@ async function copyToClipboard(text) {
>
{{ debugInfo }}
</div>
<ButtonStyled circular>
<button
v-tooltip="'Copy debug info'"
:disabled="copied"
@click="copyToClipboard(debugInfo)"
>
<template v-if="copied"> <CheckIcon class="text-green" /> </template>
<template v-else> <CopyIcon /> </template>
</button>
</ButtonStyled>
<IconButton
v-tooltip="'Copy debug info'"
:label="'Copy debug info'"
:disabled="copied"
@click="copyToClipboard(debugInfo)"
>
<template v-if="copied"> <CheckIcon class="text-green" /> </template>
<template v-else> <CopyIcon /> </template>
</IconButton>
</div>
</Collapsible>
</div>
@@ -1,7 +1,7 @@
<script setup>
import { XIcon } from '@modrinth/assets'
import {
ButtonStyled,
Button,
commonMessages,
defineMessages,
FileTreeSelect,
@@ -11,15 +11,10 @@ import {
useVIntl,
} from '@modrinth/ui'
import { save } from '@tauri-apps/plugin-dialog'
import { readDir, stat } from '@tauri-apps/plugin-fs'
import { ref } from 'vue'
import { ref, shallowRef } from 'vue'
import { PackageIcon } from '@/assets/icons'
import {
export_instance_mrpack,
get_full_path,
get_pack_export_candidates,
} from '@/helpers/instance'
import { export_instance_mrpack, get_pack_export_candidates } from '@/helpers/instance'
const { handleError } = injectNotificationManager()
const { formatMessage } = useVIntl()
@@ -65,30 +60,24 @@ const exportModal = ref(null)
const nameInput = ref(props.instance.name)
const exportDescription = ref('')
const versionInput = ref('1.0.0')
const files = ref([])
const selectedFilePaths = ref([])
const files = shallowRef([])
const includedFilePaths = ref([])
const excludedFilePaths = ref([])
const fileTreeKey = ref(0)
const filesLoadId = ref(0)
const instanceRoot = ref('')
const loadedDirectories = ref(new Set())
const directoryEntries = new Map()
const currentDirectory = ref('')
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)),
)
const exportCandidates = await get_pack_export_candidates(props.instance.id)
if (loadId !== filesLoadId.value) return
files.value = exportCandidates
selectedFilePaths.value = files.value
.filter((file) => !file.disabled && isDefaultSelectedExportCandidate(file.path))
directoryEntries.set('', exportCandidates)
currentDirectory.value = ''
includedFilePaths.value = files.value
.filter((file) => !file.disabled && file.defaultSelected)
.map((file) => file.path)
}
@@ -107,7 +96,8 @@ const exportPack = async () => {
export_instance_mrpack(
props.instance.id,
outputPath,
selectedFilePaths.value,
includedFilePaths.value,
excludedFilePaths.value,
versionInput.value,
exportDescription.value,
nameInput.value,
@@ -121,115 +111,45 @@ function resetExportState() {
exportDescription.value = ''
versionInput.value = '1.0.0'
files.value = []
selectedFilePaths.value = []
includedFilePaths.value = []
excludedFilePaths.value = []
fileTreeKey.value += 1
instanceRoot.value = ''
loadedDirectories.value = new Set()
directoryEntries.clear()
currentDirectory.value = ''
}
async function loadExportDirectory(path) {
if (!path || !instanceRoot.value || loadedDirectories.value.has(path)) return
const normalizedPath = normalizeExportPath(path)
currentDirectory.value = normalizedPath
const cachedEntries = directoryEntries.get(normalizedPath)
if (cachedEntries) {
files.value = cachedEntries
return
}
const loadId = filesLoadId.value
loadedDirectories.value.add(path)
files.value = []
try {
const entries = await readDir(`${instanceRoot.value}/${path}`)
const childItems = await Promise.all(
entries.map((entry) => buildExportDirectoryChildItem(instanceRoot.value, path, entry)),
const childItems = await get_pack_export_candidates(
props.instance.id,
normalizedPath || undefined,
)
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,
directoryEntries.set(normalizedPath, childItems)
if (currentDirectory.value === normalizedPath) {
files.value = childItems
}
} 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 {}
if (currentDirectory.value === normalizedPath) files.value = []
}
}
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>
@@ -278,26 +198,24 @@ function isExportCandidateDisabled(path) {
</div>
<FileTreeSelect
:key="fileTreeKey"
v-model="selectedFilePaths"
v-model="includedFilePaths"
v-model:excluded-paths="excludedFilePaths"
class="min-w-0"
:items="files"
lazy
@navigate="loadExportDirectory"
/>
</div>
<template #actions>
<div class="flex items-center justify-end gap-2">
<ButtonStyled type="outlined">
<button @click="exportModal.hide">
<XIcon />
{{ formatMessage(commonMessages.cancelButton) }}
</button>
</ButtonStyled>
<ButtonStyled color="brand">
<button @click="exportPack">
<PackageIcon />
{{ formatMessage(messages.exportButton) }}
</button>
</ButtonStyled>
<Button type="outlined" @click="exportModal.hide">
<XIcon />
{{ formatMessage(commonMessages.cancelButton) }}
</Button>
<Button type="colored" color="brand" @click="exportPack">
<PackageIcon />
{{ formatMessage(messages.exportButton) }}
</Button>
</div>
</template>
</NewModal>
@@ -7,7 +7,7 @@ import {
StopCircleIcon,
TimerIcon,
} from '@modrinth/assets'
import { Avatar, ButtonStyled, injectNotificationManager, useRelativeTime } from '@modrinth/ui'
import { Avatar, IconButton, injectNotificationManager, useRelativeTime } from '@modrinth/ui'
import { convertFileSrc } from '@tauri-apps/api/core'
import dayjs from 'dayjs'
import { computed, onMounted, onUnmounted, ref } from 'vue'
@@ -168,30 +168,37 @@ onUnmounted(() => unlisten())
<span class="line-clamp-2">{{ instance.name }}</span>
</div>
<div class="flex items-center">
<ButtonStyled v-if="playing" color="red" circular @mousehover="checkProcess">
<button v-tooltip="'Stop'" @click="(e) => stop(e, 'InstanceCard')">
<StopCircleIcon />
</button>
</ButtonStyled>
<ButtonStyled v-else-if="modLoading" color="standard" circular>
<button v-tooltip="'Instance is loading...'" disabled>
<SpinnerIcon class="animate-spin" />
</button>
</ButtonStyled>
<ButtonStyled
v-else-if="!instance.quarantined"
:color="first ? 'brand' : 'standard'"
circular
<IconButton
v-if="playing"
v-tooltip="'Stop'"
type="colored"
color="red"
:label="'Stop'"
@mouseenter="checkProcess"
@click="(e) => stop(e, 'InstanceCard')"
>
<button
v-tooltip="'Play'"
@click="(e) => play(e, 'InstanceCard')"
@mousehover="checkProcess"
>
<!-- Translate for optical centering -->
<PlayIcon class="translate-x-[1px]" />
</button>
</ButtonStyled>
<StopCircleIcon />
</IconButton>
<IconButton
v-else-if="modLoading"
v-tooltip="'Instance is loading...'"
:label="'Instance is loading...'"
disabled
>
<SpinnerIcon class="animate-spin" />
</IconButton>
<IconButton
v-else-if="!instance.quarantined"
v-tooltip="'Play'"
:type="first ? 'colored' : 'base'"
:color="first ? 'brand' : undefined"
label="Play"
@click="(e) => play(e, 'InstanceCard')"
@mouseenter="checkProcess"
>
<!-- Translate for optical centering -->
<PlayIcon class="translate-x-[1px]" />
</IconButton>
</div>
<div class="flex items-center col-span-3 gap-1 text-secondary font-semibold">
<TimerIcon />
@@ -219,47 +226,51 @@ onUnmounted(() => unlisten())
:class="`transition-all ${modLoading || installing ? `brightness-[0.25] scale-[0.85]` : `group-hover:brightness-75`}`"
/>
<div class="absolute inset-0 flex items-center justify-center">
<ButtonStyled v-if="playing" size="large" color="red" circular>
<button
v-tooltip="'Stop'"
:class="{ 'scale-100 opacity-100': playing }"
class="transition-all scale-75 origin-bottom opacity-0 card-shadow"
@click="(e) => stop(e, 'InstanceCard')"
@mousehover="checkProcess"
>
<StopCircleIcon />
</button>
</ButtonStyled>
<IconButton
v-if="playing"
v-tooltip="'Stop'"
type="colored"
color="red"
size="xl"
:label="'Stop'"
:class="{ 'scale-100 opacity-100': playing }"
class="transition-all scale-75 origin-bottom opacity-0 card-shadow"
@click="(e) => stop(e, 'InstanceCard')"
@mouseenter="checkProcess"
>
<StopCircleIcon />
</IconButton>
<SpinnerIcon
v-else-if="modLoading || installing"
v-tooltip="modLoading ? 'Instance is loading...' : 'Installing...'"
class="animate-spin w-8 h-8"
tabindex="-1"
/>
<ButtonStyled
<IconButton
v-else-if="!installed && !instance.quarantined"
size="large"
v-tooltip="'Repair'"
type="colored"
color="brand"
circular
size="xl"
:label="'Repair'"
class="transition-all scale-75 group-hover:scale-100 group-focus-within:scale-100 origin-bottom opacity-0 group-hover:opacity-100 group-focus-within:opacity-100 card-shadow"
@click="(e) => repair(e)"
>
<button
v-tooltip="'Repair'"
class="transition-all scale-75 group-hover:scale-100 group-focus-within:scale-100 origin-bottom opacity-0 group-hover:opacity-100 group-focus-within:opacity-100 card-shadow"
@click="(e) => repair(e)"
>
<DownloadIcon />
</button>
</ButtonStyled>
<ButtonStyled v-else-if="!instance.quarantined" size="large" color="brand" circular>
<button
v-tooltip="'Play'"
class="transition-all scale-75 group-hover:scale-100 group-focus-within:scale-100 origin-bottom opacity-0 group-hover:opacity-100 group-focus-within:opacity-100 card-shadow"
@click="(e) => play(e, 'InstanceCard')"
@mousehover="checkProcess"
>
<PlayIcon class="translate-x-[2px]" />
</button>
</ButtonStyled>
<DownloadIcon />
</IconButton>
<IconButton
v-else-if="!instance.quarantined"
v-tooltip="'Play'"
type="colored"
color="brand"
size="xl"
:label="'Play'"
class="transition-all scale-75 group-hover:scale-100 group-focus-within:scale-100 origin-bottom opacity-0 group-hover:opacity-100 group-focus-within:opacity-100 card-shadow"
@click="(e) => play(e, 'InstanceCard')"
@mouseenter="checkProcess"
>
<PlayIcon class="translate-x-[2px]" />
</IconButton>
</div>
</div>
<div class="flex flex-col gap-1">
@@ -1,6 +1,6 @@
<script setup lang="ts">
import { GameIcon, LeftArrowIcon } from '@modrinth/assets'
import { Avatar, ButtonStyled, FormattedTag } from '@modrinth/ui'
import { Avatar, ButtonLink, FormattedTag } from '@modrinth/ui'
import { convertFileSrc } from '@tauri-apps/api/core'
import { computed } from 'vue'
@@ -48,9 +48,7 @@ const instanceLink = computed(() => {
</span>
</span>
</router-link>
<ButtonStyled>
<router-link :to="instanceLink"> <LeftArrowIcon /> Back to instance </router-link>
</ButtonStyled>
<ButtonLink :to="instanceLink"> <LeftArrowIcon /> Back to instance </ButtonLink>
</div>
</template>
@@ -1,5 +1,9 @@
<template>
<ModalWrapper ref="detectJavaModal" header="Select java version" :show-ad-on-close="false">
<ModalWrapper
ref="detectJavaModal"
:header="formatMessage(messages.title)"
:show-ad-on-close="false"
>
<div class="flex flex-col gap-4">
<Table :columns="javaInstallColumns" :data="chosenInstallOptions" row-key="path">
<template #cell-version="{ value }">
@@ -10,51 +14,95 @@
</template>
<template #cell-actions="{ row }">
<div class="flex items-center justify-end">
<ButtonStyled v-if="currentSelected.path === row.path">
<button class="!shadow-none" disabled><CheckIcon /> Selected</button>
</ButtonStyled>
<ButtonStyled v-else>
<button class="!shadow-none" @click="setJavaInstall(row)"><PlusIcon /> Select</button>
</ButtonStyled>
<Button v-if="currentSelected.path === row.path" disabled>
<CheckIcon aria-hidden="true" />
{{ formatMessage(messages.selected) }}
</Button>
<Button v-else @click="setJavaInstall(row)">
<PlusIcon aria-hidden="true" />
{{ formatMessage(messages.select) }}
</Button>
</div>
</template>
<template #empty-state>
<div class="p-4 text-secondary">No java installations found!</div>
<div class="p-4 text-secondary">
{{ formatMessage(messages.noInstallationsFound) }}
</div>
</template>
</Table>
<div class="flex justify-end">
<ButtonStyled type="outlined">
<button
class="!shadow-none !border-surface-4 !border"
@click="$refs.detectJavaModal.hide()"
>
<XIcon />
Cancel
</button>
</ButtonStyled>
<Button
type="outlined"
class="!border-surface-4 !border"
@click="$refs.detectJavaModal.hide()"
>
<XIcon aria-hidden="true" />
{{ formatMessage(messages.cancel) }}
</Button>
</div>
</div>
</ModalWrapper>
</template>
<script setup>
import { CheckIcon, PlusIcon, XIcon } from '@modrinth/assets'
import { ButtonStyled, injectNotificationManager, Table } from '@modrinth/ui'
import { ref } from 'vue'
import { Button, defineMessages, injectNotificationManager, Table, useVIntl } from '@modrinth/ui'
import { computed, ref } from 'vue'
import ModalWrapper from '@/components/ui/modal/ModalWrapper.vue'
import { trackEvent } from '@/helpers/analytics'
import { find_filtered_jres } from '@/helpers/jre.js'
const { handleError } = injectNotificationManager()
const { formatMessage } = useVIntl()
const messages = defineMessages({
title: {
id: 'app.java-detection.title',
defaultMessage: 'Select Java installation',
},
versionColumn: {
id: 'app.java-detection.columns.version',
defaultMessage: 'Version',
},
pathColumn: {
id: 'app.java-detection.columns.path',
defaultMessage: 'Path',
},
actionsColumn: {
id: 'app.java-detection.columns.actions',
defaultMessage: 'Actions',
},
selected: {
id: 'app.java-detection.selected',
defaultMessage: 'Selected',
},
select: {
id: 'app.java-detection.select',
defaultMessage: 'Select',
},
noInstallationsFound: {
id: 'app.java-detection.no-installations-found',
defaultMessage: 'No Java installations found.',
},
cancel: {
id: 'app.java-detection.cancel',
defaultMessage: 'Cancel',
},
})
const chosenInstallOptions = ref([])
const detectJavaModal = ref(null)
const currentSelected = ref({})
const javaInstallColumns = [
{ key: 'version', label: 'Version', width: '9rem' },
{ key: 'path', label: 'Path' },
{ key: 'actions', label: 'Actions', align: 'right', width: '10rem' },
]
const javaInstallColumns = computed(() => [
{ key: 'version', label: formatMessage(messages.versionColumn), width: '9rem' },
{ key: 'path', label: formatMessage(messages.pathColumn) },
{
key: 'actions',
label: formatMessage(messages.actionsColumn),
align: 'right',
width: '10rem',
},
])
defineExpose({
show: async (version, currentSelectedJava) => {
@@ -6,7 +6,7 @@
autocomplete="off"
:disabled="props.disabled"
:model-value="props.modelValue ? props.modelValue.path : ''"
:placeholder="placeholder ?? '/path/to/java'"
:placeholder="placeholder ?? formatMessage(messages.pathPlaceholder)"
wrapper-class="installation-input"
@update:model-value="
(val) => {
@@ -17,57 +17,73 @@
}
"
/>
<ButtonStyled
<Button
type="quiet"
:color="
!hoveringTest && !testingJava
? testingJavaSuccess === true
? 'green'
: 'red'
: 'standard'
: undefined
"
color-fill="text"
:aria-label="formatMessage(messages.testJavaInstallation)"
class="!text-[var(--legacy-button-color,var(--color-base))] [&>svg]:!text-[var(--legacy-button-color,var(--color-primary))]"
:disabled="testingJava || props.disabled"
:style="{
'--legacy-button-color':
(!hoveringTest && !testingJava
? testingJavaSuccess === true
? 'green'
: 'red'
: 'standard') &&
(!hoveringTest && !testingJava
? testingJavaSuccess === true
? 'green'
: 'red'
: 'standard') !== 'standard'
? `var(--color-${
!hoveringTest && !testingJava
? testingJavaSuccess === true
? 'green'
: 'red'
: 'standard'
})`
: undefined,
}"
@click="runTest(props.modelValue?.path)"
@mouseenter="!props.disabled && (hoveringTest = true)"
@mouseleave="hoveringTest = false"
>
<button
class="!shadow-none"
:disabled="testingJava || props.disabled"
@click="runTest(props.modelValue?.path)"
@mouseenter="!props.disabled && (hoveringTest = true)"
@mouseleave="hoveringTest = false"
>
<SpinnerIcon v-if="testingJava" class="animate-spin h-4 w-4" />
<CheckCircleIcon
v-else-if="testingJavaSuccess === true && !hoveringTest"
class="h-4 w-4"
/>
<XCircleIcon v-else-if="testingJavaSuccess !== true && !hoveringTest" class="h-4 w-4" />
<RefreshCwIcon v-else-if="!props.disabled" class="h-4 w-4" />
</button>
</ButtonStyled>
<SpinnerIcon v-if="testingJava" class="animate-spin h-4 w-4" />
<CheckCircleIcon v-else-if="testingJavaSuccess === true && !hoveringTest" class="h-4 w-4" />
<XCircleIcon v-else-if="testingJavaSuccess !== true && !hoveringTest" class="h-4 w-4" />
<RefreshCwIcon v-else-if="!props.disabled" class="h-4 w-4" />
</Button>
</div>
<span class="installation-buttons">
<ButtonStyled v-if="props.version">
<button
v-tooltip="testingJavaSuccess === true ? 'Already installed' : undefined"
class="!shadow-none"
:disabled="props.disabled || installingJava || testingJavaSuccess === true"
@click="reinstallJava"
>
<DownloadIcon />
{{ installingJava ? 'Installing...' : 'Install recommended' }}
</button>
</ButtonStyled>
<ButtonStyled>
<button class="!shadow-none" :disabled="props.disabled" @click="autoDetect">
<SearchIcon />
Detect
</button>
</ButtonStyled>
<ButtonStyled>
<button class="!shadow-none" :disabled="props.disabled" @click="handleJavaFileInput()">
<FolderSearchIcon />
Browse
</button>
</ButtonStyled>
<Button
v-if="props.version"
v-tooltip="
testingJavaSuccess === true ? formatMessage(messages.alreadyInstalled) : undefined
"
:disabled="props.disabled || installingJava || testingJavaSuccess === true"
@click="reinstallJava"
>
<DownloadIcon />
{{
installingJava
? formatMessage(messages.installing)
: formatMessage(messages.installRecommended)
}}
</Button>
<Button :disabled="props.disabled" @click="autoDetect">
<SearchIcon />
{{ formatMessage(messages.detect) }}
</Button>
<Button :disabled="props.disabled" @click="handleJavaFileInput()">
<FolderSearchIcon />
{{ formatMessage(messages.browse) }}
</Button>
</span>
</div>
</template>
@@ -82,7 +98,13 @@ import {
SpinnerIcon,
XCircleIcon,
} from '@modrinth/assets'
import { ButtonStyled, injectNotificationManager, StyledInput } from '@modrinth/ui'
import {
Button,
defineMessages,
injectNotificationManager,
StyledInput,
useVIntl,
} from '@modrinth/ui'
import { open } from '@tauri-apps/plugin-dialog'
import { ref, watch } from 'vue'
@@ -92,6 +114,38 @@ import { trackEvent } from '@/helpers/analytics'
import { auto_install_java, find_filtered_jres, get_jre } from '@/helpers/jre.js'
const { handleError } = injectNotificationManager()
const { formatMessage } = useVIntl()
const messages = defineMessages({
pathPlaceholder: {
id: 'app.java-selector.path.placeholder',
defaultMessage: '/path/to/java',
},
testJavaInstallation: {
id: 'app.java-selector.test-installation',
defaultMessage: 'Test Java installation',
},
alreadyInstalled: {
id: 'app.java-selector.already-installed',
defaultMessage: 'Already installed',
},
installing: {
id: 'app.java-selector.installing',
defaultMessage: 'Installing...',
},
installRecommended: {
id: 'app.java-selector.install-recommended',
defaultMessage: 'Install recommended',
},
detect: {
id: 'app.java-selector.detect',
defaultMessage: 'Detect',
},
browse: {
id: 'app.java-selector.browse',
defaultMessage: 'Browse',
},
})
const props = defineProps({
id: {
@@ -1,6 +1,6 @@
<script setup>
import { CheckIcon } from '@modrinth/assets'
import { Badge, ButtonStyled } from '@modrinth/ui'
import { Badge, IconButton } from '@modrinth/ui'
import { computed, ref } from 'vue'
import { SwapIcon } from '@/assets/icons/index.js'
@@ -74,18 +74,16 @@ const onHide = () => {
@click="$router.push(`/project/${version.project_id}/version/${version.id}`)"
>
<div class="table-cell table-text">
<ButtonStyled
circular
:color="version.id === installedVersion ? 'standard' : 'brand'"
<IconButton
:type="version.id === installedVersion ? 'base' : 'colored'"
:color="version.id === installedVersion ? undefined : 'brand'"
label="Switch version"
:disabled="inProgress || installing || version.id === installedVersion"
@click.stop="() => switchVersion(version.id)"
>
<button
:disabled="inProgress || installing || version.id === installedVersion"
@click.stop="() => switchVersion(version.id)"
>
<SwapIcon v-if="version.id !== installedVersion" />
<CheckIcon v-else />
</button>
</ButtonStyled>
<SwapIcon v-if="version.id !== installedVersion" />
<CheckIcon v-else />
</IconButton>
</div>
<div class="name-cell table-cell table-text">
<div class="version-link">
@@ -1,41 +1,152 @@
<script setup>
import { SpinnerIcon } from '@modrinth/assets'
import { Avatar, injectNotificationManager } from '@modrinth/ui'
import { Avatar, defineMessages, injectNotificationManager, useVIntl } from '@modrinth/ui'
import { useQueryClient } from '@tanstack/vue-query'
import { convertFileSrc } from '@tauri-apps/api/core'
import dayjs from 'dayjs'
import { onUnmounted, ref } from 'vue'
import { computed, onMounted, onUnmounted, ref } from 'vue'
import NavButton from '@/components/ui/NavButton.vue'
import { instance_listener } from '@/helpers/events.js'
import { list } from '@/helpers/instance'
import { instanceKeys } from '@/pages/instance/query-options'
const ITEM_SIZE = 52
const APPROX_USED_VERTICAL_SPACE = 513 // doesn't need to be exact lol just close enough so there's a little gap and no overflow
const STORAGE_KEY = 'modrinth-quick-instance-count'
const { handleError } = injectNotificationManager()
const queryClient = useQueryClient()
const { formatMessage } = useVIntl()
const maxAuto = ref(0)
const allInstances = ref([])
const dragging = ref(false)
const stored = localStorage.getItem(STORAGE_KEY)
const userLimit = ref(stored === null ? null : Number(stored))
const maxVisible = computed(() => Math.min(maxAuto.value, allInstances.value.length))
const visibleCount = computed(() => Math.min(userLimit.value ?? maxVisible.value, maxVisible.value))
const recentInstances = computed(() => allInstances.value.slice(0, visibleCount.value))
const canDrag = computed(() => maxVisible.value > 0)
const showOverdrag = ref(false)
const updateMaxAuto = () => {
maxAuto.value = Math.max(
0,
Math.floor((window.innerHeight - APPROX_USED_VERTICAL_SPACE) / ITEM_SIZE),
)
}
const setLimit = (count) => {
const clamped = Math.max(0, Math.min(count, maxVisible.value))
if (clamped >= maxVisible.value) {
userLimit.value = null
localStorage.removeItem(STORAGE_KEY)
} else {
userLimit.value = clamped
localStorage.setItem(STORAGE_KEY, String(clamped))
}
}
let dragStartY = 0
let dragStartCount = 0
let wasOverdragging = false
let overdragTimeout = null
const clearOverdragFlash = () => {
showOverdrag.value = false
if (overdragTimeout !== null) {
clearTimeout(overdragTimeout)
overdragTimeout = null
}
}
const flashOverdrag = () => {
showOverdrag.value = true
if (overdragTimeout !== null) {
clearTimeout(overdragTimeout)
}
overdragTimeout = setTimeout(() => {
showOverdrag.value = false
overdragTimeout = null
}, 500)
}
const onDividerPointerDown = (event) => {
if (!canDrag.value) {
return
}
event.preventDefault()
dragging.value = true
wasOverdragging = false
clearOverdragFlash()
dragStartY = event.clientY
dragStartCount = visibleCount.value
document.body.classList.add('quick-instance-dragging')
event.currentTarget.setPointerCapture(event.pointerId)
}
const onDividerPointerMove = (event) => {
if (!dragging.value) {
return
}
const delta = event.clientY - dragStartY
const target = dragStartCount + Math.round(delta / ITEM_SIZE)
const isOverdragging = target < 0 || target > maxAuto.value
if (isOverdragging && !wasOverdragging) {
flashOverdrag()
}
wasOverdragging = isOverdragging
setLimit(target)
}
const endDrag = (event) => {
if (!dragging.value) {
return
}
dragging.value = false
wasOverdragging = false
clearOverdragFlash()
document.body.classList.remove('quick-instance-dragging')
if (event?.currentTarget?.hasPointerCapture?.(event.pointerId)) {
event.currentTarget.releasePointerCapture(event.pointerId)
}
}
const onDividerPointerUp = (event) => {
endDrag(event)
}
const recentInstances = ref([])
const getInstances = async () => {
const instances = await list().catch(handleError)
recentInstances.value = instances
.sort((a, b) => {
const dateACreated = dayjs(a.created)
const dateAPlayed = a.last_played ? dayjs(a.last_played) : dayjs(0)
for (const instance of instances) {
queryClient.setQueryData(instanceKeys.detail(instance.id), instance)
}
const dateBCreated = dayjs(b.created)
const dateBPlayed = b.last_played ? dayjs(b.last_played) : dayjs(0)
allInstances.value = instances.sort((a, b) => {
const dateACreated = dayjs(a.created)
const dateAPlayed = a.last_played ? dayjs(a.last_played) : dayjs(0)
const dateA = dateACreated.isAfter(dateAPlayed) ? dateACreated : dateAPlayed
const dateB = dateBCreated.isAfter(dateBPlayed) ? dateBCreated : dateBPlayed
const dateBCreated = dayjs(b.created)
const dateBPlayed = b.last_played ? dayjs(b.last_played) : dayjs(0)
if (dateA.isSame(dateB)) {
return a.name.localeCompare(b.name)
}
const dateA = dateACreated.isAfter(dateAPlayed) ? dateACreated : dateAPlayed
const dateB = dateBCreated.isAfter(dateBPlayed) ? dateBCreated : dateBPlayed
return dateB - dateA
})
.slice(0, 3)
if (dateA.isSame(dateB)) {
return a.name.localeCompare(b.name)
}
return dateB - dateA
})
}
await getInstances()
updateMaxAuto()
const unlistenInstance = await instance_listener(async (event) => {
if (event.event !== 'synced') {
@@ -43,29 +154,157 @@ const unlistenInstance = await instance_listener(async (event) => {
}
})
onMounted(() => {
window.addEventListener('resize', updateMaxAuto)
})
onUnmounted(() => {
window.removeEventListener('resize', updateMaxAuto)
document.body.classList.remove('quick-instance-dragging')
clearOverdragFlash()
unlistenInstance()
})
const messages = defineMessages({
dragTooltip: {
id: 'app.quick-instance-switcher.drag-tooltip',
defaultMessage: 'Drag to resize',
},
dragShowTooltip: {
id: 'app.quick-instance-switcher.drag-show-tooltip',
defaultMessage: 'Drag to show recent instances',
},
})
const dividerTooltip = computed(() => {
if (!canDrag.value || dragging.value) {
return null
}
return formatMessage(visibleCount.value === 0 ? messages.dragShowTooltip : messages.dragTooltip)
})
</script>
<template>
<div v-for="instance in recentInstances" :key="instance.id" v-tooltip.right="instance.name">
<NavButton :to="`/instance/${encodeURIComponent(instance.id)}`" class="relative">
<Avatar
:src="instance.icon_path ? convertFileSrc(instance.icon_path) : null"
size="28px"
:tint-by="instance.id"
:class="`transition-all ${instance.install_stage !== 'installed' ? `brightness-[0.25] scale-[0.85]` : `group-hover:brightness-75`}`"
/>
<div
v-if="instance.install_stage !== 'installed'"
class="absolute inset-0 flex items-center justify-center z-10 pointer-events-none"
>
<SpinnerIcon class="animate-spin w-4 h-4" />
</div>
</NavButton>
<Transition name="top-divider">
<div
v-if="recentInstances.length > 0"
class="top-divider flex items-center justify-center overflow-hidden"
>
<div class="h-px w-8 bg-surface-5 shrink-0"></div>
</div>
</Transition>
<TransitionGroup name="quick-instance" tag="div" class="flex flex-col items-center">
<div
v-for="instance in recentInstances"
:key="instance.id"
v-tooltip.right="instance.name"
class="quick-instance-item"
>
<NavButton :to="`/instance/${encodeURIComponent(instance.id)}`" class="relative">
<Avatar
:src="instance.icon_path ? convertFileSrc(instance.icon_path) : null"
size="28px"
:tint-by="instance.id"
:class="`transition-all ${instance.install_stage !== 'installed' ? `brightness-[0.25] scale-[0.85]` : `group-hover:brightness-75`}`"
/>
<div
v-if="instance.install_stage !== 'installed'"
class="absolute inset-0 flex items-center justify-center z-10 pointer-events-none"
>
<SpinnerIcon class="animate-spin w-4 h-4" />
</div>
</NavButton>
</div>
</TransitionGroup>
<div
v-tooltip.right="dividerTooltip"
class="flex items-center justify-center py-2 select-none"
:class="canDrag ? 'cursor-ns-resize touch-none group' : ''"
@pointerdown="onDividerPointerDown"
@pointermove="onDividerPointerMove"
@pointerup="onDividerPointerUp"
@pointercancel="onDividerPointerUp"
>
<div
class="h-px w-8 transition-colors duration-200"
:class="
showOverdrag ? 'bg-red' : canDrag ? 'bg-surface-5 group-hover:bg-secondary' : 'bg-surface-5'
"
></div>
</div>
<div v-if="recentInstances.length > 0" class="h-px w-6 mx-auto my-2 bg-divider"></div>
</template>
<style scoped lang="scss"></style>
<style scoped lang="scss">
.top-divider {
height: calc(1rem + 1px);
}
.top-divider-enter-active,
.top-divider-leave-active {
transition:
opacity 0.25s ease,
height 0.25s ease;
}
.top-divider-enter-from,
.top-divider-leave-to {
opacity: 0;
height: 0;
}
.quick-instance-item {
height: 3rem;
overflow: hidden;
& + & {
margin-top: 0.25rem;
}
}
.quick-instance-enter-active,
.quick-instance-leave-active {
transition:
opacity 0.25s ease,
transform 0.25s ease,
height 0.25s ease,
margin-top 0.25s ease;
}
.quick-instance-enter-from,
.quick-instance-leave-to {
opacity: 0;
transform: scale(0.5);
height: 0;
margin-top: 0 !important;
}
@media (prefers-reduced-motion: reduce) {
.top-divider-enter-active,
.top-divider-leave-active,
.quick-instance-enter-active,
.quick-instance-leave-active {
transition: none;
}
.top-divider-enter-from,
.top-divider-leave-to {
opacity: 1;
height: calc(1rem + 1px);
}
.quick-instance-enter-from,
.quick-instance-leave-to {
opacity: 1;
transform: none;
height: 3rem;
margin-top: unset !important;
}
}
</style>
<style lang="scss">
body.quick-instance-dragging,
body.quick-instance-dragging * {
cursor: ns-resize !important;
}
</style>
@@ -0,0 +1,229 @@
<script setup lang="ts">
import { NotepadTextIcon, XIcon } from '@modrinth/assets'
import { Button, defineMessages, injectNotificationManager, useVIntl } from '@modrinth/ui'
import { type } from '@tauri-apps/plugin-os'
import { $fetch } from 'ofetch'
import { onMounted, ref } from 'vue'
import { hide_ads_window, show_ads_window } from '@/helpers/ads.js'
import { list } from '@/helpers/instance'
import { get as getCreds } from '@/helpers/mr_auth.ts'
type Survey = {
id: string
tally_id: string
type: string
condition?: string
assigned_users?: string[]
dismissed_users?: string[]
}
type TallyApi = {
openPopup: (formId: string, options: object) => void
}
const tallyWindow = window as Window & { Tally?: TallyApi }
const { formatMessage } = useVIntl()
const { handleError } = injectNotificationManager()
const availableSurvey = ref<Survey | null>(null)
const messages = defineMessages({
surveyTitle: {
id: 'app.survey.title',
defaultMessage: 'Hey there Modrinth user!',
},
surveyBody: {
id: 'app.survey.body',
defaultMessage:
'Would you mind answering a few questions about your experience with Modrinth App?',
},
surveyFooter: {
id: 'app.survey.footer',
defaultMessage:
'This feedback will go directly to the Modrinth team and help guide future updates!',
},
takeSurvey: {
id: 'app.survey.take-survey',
defaultMessage: 'Take survey',
},
surveyNoThanks: {
id: 'app.survey.no-thanks',
defaultMessage: 'No thanks',
},
})
function cleanupOldSurveyDisplayData() {
const threeWeeksAgo = new Date()
threeWeeksAgo.setDate(threeWeeksAgo.getDate() - 21)
for (let i = 0; i < localStorage.length; i++) {
const key = localStorage.key(i)
if (key?.startsWith('survey-') && key.endsWith('-display')) {
const dateValue = new Date(localStorage.getItem(key) ?? '')
if (dateValue < threeWeeksAgo) {
localStorage.removeItem(key)
}
}
}
}
async function openSurvey() {
if (!availableSurvey.value) {
console.error('No survey to open')
return
}
const creds = await getCreds().catch(handleError)
const userId = creds?.user_id
const formId = availableSurvey.value.tally_id
const popupOptions = {
layout: 'modal',
width: 700,
autoClose: 2000,
hideTitle: true,
hiddenFields: {
user_id: userId,
},
onOpen: () => console.info('Opened user survey'),
onClose: () => {
console.info('Closed user survey')
show_ads_window()
},
onSubmit: () => console.info('Active user survey submitted'),
}
try {
hide_ads_window()
if (tallyWindow.Tally?.openPopup) {
console.info(`Opening Tally popup for user survey (form ID: ${formId})`)
dismissSurvey()
tallyWindow.Tally.openPopup(formId, popupOptions)
} else {
console.warn('Tally script not yet loaded')
show_ads_window()
}
} catch (e) {
console.error('Error opening Tally popup:', e)
show_ads_window()
}
console.info(`Found user survey to show with tally_id: ${formId}`)
tallyWindow.Tally?.openPopup(formId, popupOptions)
}
function dismissSurvey() {
if (!availableSurvey.value) return
localStorage.setItem(`survey-${availableSurvey.value.id}-display`, String(new Date()))
availableSurvey.value = null
}
async function processPendingSurveys() {
function isWithinLastTwoWeeks(date: string | Date | null | undefined) {
if (!date) return false
const twoWeeksAgo = new Date()
twoWeeksAgo.setDate(twoWeeksAgo.getDate() - 14)
return new Date(date) >= twoWeeksAgo
}
cleanupOldSurveyDisplayData()
const creds = await getCreds().catch(handleError)
const userId = creds?.user_id
const instances = (await list().catch(handleError)) ?? []
const isActivePlayer = instances.some(
(instance) =>
isWithinLastTwoWeeks(instance.last_played) && !isWithinLastTwoWeeks(instance.created),
)
let surveys: Survey[] = []
try {
surveys = await $fetch('https://api.modrinth.com/v2/surveys')
} catch (e) {
console.error('Error fetching surveys:', e)
}
const surveyToShow = surveys.find(
(survey) =>
!!(
localStorage.getItem(`survey-${survey.id}-display`) === null &&
survey.type === 'tally_app' &&
((survey.condition === 'active_player' && isActivePlayer) ||
(!!userId &&
survey.assigned_users?.includes(userId) &&
!survey.dismissed_users?.includes(userId)))
),
)
if (surveyToShow) {
availableSurvey.value = surveyToShow
} else {
console.info('No user survey to show')
}
}
onMounted(async () => {
const osType = await type()
if (osType === 'windows') {
await processPendingSurveys()
} else {
console.info('Skipping user surveys on non-Windows platforms')
}
})
</script>
<template>
<transition name="popup-survey">
<div
v-if="availableSurvey"
class="w-[400px] z-20 fixed -bottom-12 pb-16 right-[--right-bar-width] mr-4 rounded-t-2xl card-shadow bg-bg-raised border-surface-5 border-[1px] border-solid border-b-0 p-4"
>
<h2 class="text-lg font-extrabold mt-0 mb-2">
{{ formatMessage(messages.surveyTitle) }}
</h2>
<p class="m-0 leading-tight">
{{ formatMessage(messages.surveyBody) }}
</p>
<p class="mt-3 mb-4 leading-tight">
{{ formatMessage(messages.surveyFooter) }}
</p>
<div class="flex gap-2">
<Button type="colored" color="brand" @click="openSurvey">
<NotepadTextIcon />
{{ formatMessage(messages.takeSurvey) }}
</Button>
<Button @click="dismissSurvey">
<XIcon />
{{ formatMessage(messages.surveyNoThanks) }}
</Button>
</div>
</div>
</transition>
</template>
<style scoped>
.popup-survey-enter-active {
transition:
opacity 0.25s ease,
transform 0.25s cubic-bezier(0.51, 1.08, 0.35, 1.15);
transform-origin: top center;
}
.popup-survey-leave-active {
transition:
opacity 0.25s ease,
transform 0.25s cubic-bezier(0.68, -0.17, 0.23, 0.11);
transform-origin: top center;
}
.popup-survey-enter-from,
.popup-survey-leave-to {
opacity: 0;
transform: translateY(10rem) scale(0.8) scaleY(1.6);
}
</style>
@@ -1,5 +1,5 @@
<script setup>
import { ButtonStyled, injectNotificationManager, ProjectCard } from '@modrinth/ui'
import { Button, injectNotificationManager, ProjectCard } from '@modrinth/ui'
import { ref } from 'vue'
import ModalWrapper from '@/components/ui/modal/ModalWrapper.vue'
@@ -69,9 +69,7 @@ async function install() {
</p>
</div>
<div class="button-group">
<ButtonStyled color="brand">
<button @click="install">Install</button>
</ButtonStyled>
<Button type="colored" color="brand" @click="install">Install</Button>
</div>
</div>
</div>
@@ -4,34 +4,38 @@
class="flex items-center gap-2 mr-1.5"
data-tauri-drag-region-exclude
>
<ButtonStyled type="transparent" circular>
<button class="relative expanded-button" @click="() => getCurrentWindow().minimize()">
<MinimizeIcon />
</button>
</ButtonStyled>
<ButtonStyled type="transparent" circular>
<button class="relative expanded-button" @click="() => getCurrentWindow().toggleMaximize()">
<RestoreIcon v-if="isMaximized" />
<MaximizeIcon v-else />
</button>
</ButtonStyled>
<ButtonStyled
type="transparent"
color="red"
color-fill="none"
hover-color-fill="background"
circular
<IconButton
type="quiet"
label="Minimize window"
class="relative expanded-button"
@click="() => getCurrentWindow().minimize()"
>
<button class="relative expanded-button close-button" @click="handleClose">
<XIcon />
</button>
</ButtonStyled>
<MinimizeIcon />
</IconButton>
<IconButton
type="quiet"
label="Toggle maximize window"
class="relative expanded-button"
@click="() => getCurrentWindow().toggleMaximize()"
>
<RestoreIcon v-if="isMaximized" />
<MaximizeIcon v-else />
</IconButton>
<IconButton
type="quiet"
color="red"
label="Close window"
class="relative expanded-button close-button hover:!bg-red focus-visible:!bg-red"
@click="handleClose"
>
<XIcon />
</IconButton>
</section>
</template>
<script setup>
import { MaximizeIcon, MinimizeIcon, RestoreIcon, XIcon } from '@modrinth/assets'
import { ButtonStyled } from '@modrinth/ui'
import { IconButton } from '@modrinth/ui'
import { getCurrentWindow } from '@tauri-apps/api/window'
import { saveWindowState, StateFlags } from '@tauri-apps/plugin-window-state'
import { computed, onMounted, onUnmounted, ref } from 'vue'
@@ -1,31 +1,30 @@
<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>
<Button
v-if="showUpdatePill"
type="outlined"
native-type="button"
class="!h-[34px] text-sm !transition-[opacity,transform,background-color,color,filter] !duration-200 ease-out !text-brand [&>svg]:!text-brand !shadow-[inset_0_0_0_1px_var(--color-brand)] hover:!bg-brand focus-visible:!bg-brand hover:!text-[var(--color-accent-contrast)] focus-visible:!text-[var(--color-accent-contrast)]"
: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>
</template>
<script setup lang="ts">
import { DownloadIcon, RefreshCwIcon } from '@modrinth/assets'
import { ButtonStyled, defineMessages, useVIntl } from '@modrinth/ui'
import { Button, defineMessages, useVIntl } from '@modrinth/ui'
import { computed, nextTick, onBeforeUnmount, ref, watch } from 'vue'
import {
@@ -2,8 +2,9 @@
import { MailIcon, SearchIcon, SendIcon, UserIcon, UserPlusIcon, XIcon } from '@modrinth/assets'
import {
Avatar,
ButtonStyled,
Button,
defineMessages,
IconButton,
injectNotificationManager,
IntlFormatted,
StyledInput,
@@ -17,17 +18,40 @@ import ModalWrapper from '@/components/ui/modal/ModalWrapper.vue'
import { useFriends } from '@/composables/use-friends'
import type { FriendWithUserData } from '@/helpers/friends.ts'
import type { ModrinthCredentials } from '@/helpers/mr_auth'
import { get as getSettings, set as setSettings } from '@/helpers/settings.ts'
import { useTheming } from '@/store/state'
const { formatMessage } = useVIntl()
const { handleError } = injectNotificationManager()
const formatRelativeTime = useRelativeTime()
const themeStore = useTheming()
const props = defineProps<{
credentials: ModrinthCredentials | null
signIn: () => void
}>()
type FriendsSectionCollapsedFlag =
| 'friends_active_collapsed'
| 'friends_online_collapsed'
| 'friends_offline_collapsed'
| 'friends_pending_collapsed'
function isFriendsSectionCollapsed(flag: FriendsSectionCollapsedFlag) {
return themeStore.getFeatureFlag(flag)
}
function setFriendsSectionCollapsed(flag: FriendsSectionCollapsedFlag, collapsed: boolean) {
themeStore.featureFlags[flag] = collapsed
getSettings()
.then((settings) => {
settings.feature_flags[flag] = collapsed
return setSettings(settings)
})
.catch(handleError)
}
const userCredentials = computed(() => props.credentials)
const {
friends: userFriends,
@@ -197,26 +221,20 @@ const messages = defineMessages({
</div>
<div class="flex gap-2">
<template v-if="friend.id === userCredentials?.user_id">
<ButtonStyled color="brand">
<button @click="addFriend(friend)">
<UserPlusIcon />
Accept
</button>
</ButtonStyled>
<ButtonStyled>
<button @click="removeFriend(friend)">
<XIcon />
Ignore
</button>
</ButtonStyled>
<Button type="colored" color="brand" @click="addFriend(friend)">
<UserPlusIcon />
Accept
</Button>
<Button @click="removeFriend(friend)">
<XIcon />
Ignore
</Button>
</template>
<template v-else>
<ButtonStyled>
<button @click="removeFriend(friend)">
<XIcon />
Cancel
</button>
</ButtonStyled>
<Button @click="removeFriend(friend)">
<XIcon />
Cancel
</Button>
</template>
</div>
</div>
@@ -240,26 +258,28 @@ const messages = defineMessages({
wrapper-class="flex-1"
@keyup.enter="addFriendFromModal"
/>
<ButtonStyled color="brand">
<button :disabled="username.length === 0" @click="addFriendFromModal">
<SendIcon />
{{ formatMessage(messages.sendFriendRequest) }}
</button>
</ButtonStyled>
<Button
type="colored"
color="brand"
:disabled="username.length === 0"
@click="addFriendFromModal"
>
<SendIcon />
{{ formatMessage(messages.sendFriendRequest) }}
</Button>
</div>
</div>
</ModalWrapper>
<div v-if="userCredentials && !loading" class="flex gap-1 items-center mb-3 -ml-1">
<template v-if="sortedFriends.length > 0">
<ButtonStyled circular type="transparent">
<button
v-tooltip="formatMessage(messages.addFriend)"
:aria-label="formatMessage(messages.addFriend)"
@click="addFriendModal.show"
>
<UserPlusIcon />
</button>
</ButtonStyled>
<IconButton
v-tooltip="formatMessage(messages.addFriend)"
type="quiet"
:label="formatMessage(messages.addFriend)"
@click="addFriendModal.show"
>
<UserPlusIcon />
</IconButton>
<StyledInput
v-model="search"
:icon="SearchIcon"
@@ -274,23 +294,23 @@ const messages = defineMessages({
<h3 v-else class="w-full text-base text-primary font-medium m-0">
{{ formatMessage(messages.friends) }}
</h3>
<ButtonStyled v-if="incomingRequests.length > 0" circular type="transparent">
<button
v-tooltip="formatMessage(messages.viewFriendRequests, { count: incomingRequests.length })"
class="relative"
:aria-label="formatMessage(messages.viewFriendRequests, { count: incomingRequests.length })"
@click="friendInvitesModal.show"
<IconButton
v-if="incomingRequests.length > 0"
v-tooltip="formatMessage(messages.viewFriendRequests, { count: incomingRequests.length })"
type="quiet"
:label="formatMessage(messages.viewFriendRequests, { count: incomingRequests.length })"
class="relative"
@click="friendInvitesModal.show"
>
<MailIcon />
<span
v-if="incomingRequests.length > 0"
aria-hidden="true"
class="absolute bg-brand text-brand-inverted text-[8px] top-0.5 px-1 right-0.5 min-w-3 h-3 rounded-full flex items-center justify-center font-bold"
>
<MailIcon />
<span
v-if="incomingRequests.length > 0"
aria-hidden="true"
class="absolute bg-brand text-brand-inverted text-[8px] top-0.5 px-1 right-0.5 min-w-3 h-3 rounded-full flex items-center justify-center font-bold"
>
{{ incomingRequests.length }}
</span>
</button>
</ButtonStyled>
{{ incomingRequests.length }}
</span>
</IconButton>
</div>
<div class="flex flex-col gap-3">
<h3 v-if="loading" class="text-base text-primary font-medium m-0">
@@ -331,33 +351,42 @@ const messages = defineMessages({
<FriendsSection
v-if="activeFriends.length > 0"
:is-searching="!!search"
open-by-default
:open-by-default="!isFriendsSectionCollapsed('friends_active_collapsed')"
:friends="activeFriends"
:heading="formatMessage(messages.active)"
:remove-friend="removeFriend"
@on-open="setFriendsSectionCollapsed('friends_active_collapsed', false)"
@on-close="setFriendsSectionCollapsed('friends_active_collapsed', true)"
/>
<FriendsSection
v-if="onlineFriends.length > 0"
:is-searching="!!search"
open-by-default
:open-by-default="!isFriendsSectionCollapsed('friends_online_collapsed')"
:friends="onlineFriends"
:heading="formatMessage(messages.online)"
:remove-friend="removeFriend"
@on-open="setFriendsSectionCollapsed('friends_online_collapsed', false)"
@on-close="setFriendsSectionCollapsed('friends_online_collapsed', true)"
/>
<FriendsSection
v-if="offlineFriends.length > 0"
:is-searching="!!search"
:open-by-default="activeFriends.length + onlineFriends.length < 3"
:open-by-default="!isFriendsSectionCollapsed('friends_offline_collapsed')"
:friends="offlineFriends"
:heading="formatMessage(messages.offline)"
:remove-friend="removeFriend"
@on-open="setFriendsSectionCollapsed('friends_offline_collapsed', false)"
@on-close="setFriendsSectionCollapsed('friends_offline_collapsed', true)"
/>
<FriendsSection
v-if="pendingFriends.length > 0"
:is-searching="!!search"
:open-by-default="!isFriendsSectionCollapsed('friends_pending_collapsed')"
:friends="pendingFriends"
:heading="formatMessage(messages.pending)"
:remove-friend="removeFriend"
@on-open="setFriendsSectionCollapsed('friends_pending_collapsed', false)"
@on-close="setFriendsSectionCollapsed('friends_pending_collapsed', true)"
/>
<p v-if="filteredFriends.length === 0 && search" class="text-sm text-secondary my-1 mx-4">
{{ formatMessage(messages.noFriendsMatch, { query: search }) }}
@@ -3,18 +3,19 @@ import { MoreVerticalIcon, TrashIcon, UserIcon, XIcon } from '@modrinth/assets'
import {
Accordion,
Avatar,
ButtonStyled,
defineMessages,
OverflowMenu,
IconButton,
TeleportOverflowMenu,
useVIntl,
} from '@modrinth/ui'
import { openUrl } from '@tauri-apps/plugin-opener'
import { useTemplateRef } from 'vue'
import { useRouter } from 'vue-router'
import ContextMenu from '@/components/ui/ContextMenu.vue'
import type { FriendWithUserData } from '@/helpers/friends.ts'
const { formatMessage } = useVIntl()
const router = useRouter()
const props = withDefaults(
defineProps<{
@@ -30,6 +31,11 @@ const props = withDefaults(
},
)
const emit = defineEmits<{
onOpen: []
onClose: []
}>()
function createContextMenuOptions(friend: FriendWithUserData) {
if (friend.accepted) {
return [
@@ -54,7 +60,7 @@ function createContextMenuOptions(friend: FriendWithUserData) {
}
function openProfile(username: string) {
openUrl('https://modrinth.com/user/' + username)
void router.push(`/user/${encodeURIComponent(username)}`)
}
const friendOptions = useTemplateRef('friendOptions')
@@ -111,6 +117,8 @@ const messages = defineMessages({
? ''
: ' cursor-pointer hover:brightness-[--hover-brightness] active:scale-[0.98] transition-all')
"
@on-open="emit('onOpen')"
@on-close="emit('onClose')"
>
<template #title>
<h3 class="text-base text-primary font-medium m-0">
@@ -122,68 +130,71 @@ const messages = defineMessages({
<div
v-for="friend in friends"
:key="friend.username"
class="group grid items-center grid-cols-[auto_1fr_auto] gap-2 hover:bg-button-bg transition-colors rounded-full mr-1"
class="group grid items-center grid-cols-[1fr_auto] gap-2 hover:bg-button-bg transition-colors rounded-full mr-1"
@contextmenu.prevent.stop="
(event) => friendOptions?.showMenu(event, friend, createContextMenuOptions(friend))
"
>
<div class="relative">
<Avatar
:src="friend.avatar"
:class="{ grayscale: !friend.online && friend.accepted }"
class="w-12 h-12 rounded-full"
size="32px"
circle
/>
<span
v-if="friend.online"
aria-hidden="true"
class="bottom-[2px] right-[-2px] absolute w-3 h-3 bg-brand border-2 border-black border-solid rounded-full"
/>
</div>
<div class="flex flex-col">
<span
class="text-sm m-0"
:class="friend.online || !friend.accepted ? 'text-contrast' : 'text-primary'"
>
{{ friend.username }}
</span>
<span v-if="!friend.accepted" class="m-0 text-xs">
{{ formatMessage(messages.friendRequestSent) }}
</span>
<span v-else-if="friend.status" class="m-0 text-xs">{{ friend.status }}</span>
</div>
<ButtonStyled v-if="friend.accepted" circular type="transparent">
<OverflowMenu
class="opacity-0 group-hover:opacity-100 transition-opacity"
:options="[
{
id: 'view-profile',
action: () => openProfile(friend.username),
},
{
id: 'remove-friend',
action: () => removeFriend(friend),
color: 'red',
},
]"
>
<MoreVerticalIcon />
<template #view-profile>
<UserIcon />
{{ formatMessage(messages.viewProfile) }}
</template>
<template #remove-friend>
<TrashIcon />
{{ formatMessage(messages.removeFriend) }}
</template>
</OverflowMenu>
</ButtonStyled>
<ButtonStyled v-else type="transparent" circular>
<button v-tooltip="formatMessage(messages.cancelRequest)" @click="removeFriend(friend)">
<XIcon />
</button>
</ButtonStyled>
<RouterLink
:to="`/user/${encodeURIComponent(friend.username)}`"
class="grid min-w-0 grid-cols-[auto_1fr] items-center gap-2 text-inherit no-underline"
>
<div class="relative">
<Avatar
:src="friend.avatar"
:class="{ grayscale: !friend.online && friend.accepted }"
class="w-12 h-12 rounded-full"
size="32px"
circle
/>
<span
v-if="friend.online"
aria-hidden="true"
class="bottom-[2px] right-[-2px] absolute w-3 h-3 bg-brand border-2 border-black border-solid rounded-full"
/>
</div>
<div class="flex flex-col">
<span
class="text-sm m-0"
:class="friend.online || !friend.accepted ? 'text-contrast' : 'text-primary'"
>
{{ friend.username }}
</span>
<span v-if="!friend.accepted" class="m-0 text-xs">
{{ formatMessage(messages.friendRequestSent) }}
</span>
<span v-else-if="friend.status" class="m-0 text-xs">{{ friend.status }}</span>
</div>
</RouterLink>
<TeleportOverflowMenu
v-if="friend.accepted"
type="quiet"
label="More options"
class="opacity-0 group-hover:opacity-100 transition-opacity"
:options="[
{
id: 'remove-friend',
label: formatMessage(messages.removeFriend),
action: () => removeFriend(friend),
tone: 'red',
},
]"
>
<MoreVerticalIcon />
<template #remove-friend>
<TrashIcon />
{{ formatMessage(messages.removeFriend) }}
</template>
</TeleportOverflowMenu>
<IconButton
v-else
v-tooltip="formatMessage(messages.cancelRequest)"
type="quiet"
:label="formatMessage(messages.cancelRequest)"
@click="removeFriend(friend)"
>
<XIcon />
</IconButton>
</div>
</div>
</template>
@@ -1,12 +1,6 @@
<script setup>
import { CheckIcon, PlusIcon, SearchIcon } from '@modrinth/assets'
import {
Admonition,
Avatar,
ButtonStyled,
injectNotificationManager,
StyledInput,
} from '@modrinth/ui'
import { Admonition, Avatar, Button, injectNotificationManager, StyledInput } from '@modrinth/ui'
import { useQueryClient } from '@tanstack/vue-query'
import { convertFileSrc } from '@tauri-apps/api/core'
import { computed, ref } from 'vue'
@@ -15,6 +9,7 @@ import ModalWrapper from '@/components/ui/modal/ModalWrapper.vue'
import { trackEvent } from '@/helpers/analytics'
import { list } from '@/helpers/instance'
import { add_server_to_instance, get_instance_worlds } from '@/helpers/worlds.ts'
import { instanceKeys } from '@/pages/instance/query-options'
const { handleError } = injectNotificationManager()
const queryClient = useQueryClient()
@@ -67,7 +62,7 @@ async function addServer(instance) {
try {
await add_server_to_instance(instance.id, serverName.value, serverAddress.value, 'prompt')
instance.added = true
await queryClient.invalidateQueries({ queryKey: ['worlds', instance.id] })
await queryClient.invalidateQueries({ queryKey: instanceKeys.worlds(instance.id) })
trackEvent('AddServerToInstance', {
server_name: serverName.value,
@@ -109,19 +104,15 @@ async function addServer(instance) {
/>
{{ instance.name }}
</router-link>
<ButtonStyled>
<button :disabled="instance.added || instance.adding" @click="addServer(instance)">
<PlusIcon v-if="!instance.added && !instance.adding" />
<CheckIcon v-else-if="instance.added" />
{{ instance.adding ? 'Adding...' : instance.added ? 'Added' : 'Add' }}
</button>
</ButtonStyled>
<Button :disabled="instance.added || instance.adding" @click="addServer(instance)">
<PlusIcon v-if="!instance.added && !instance.adding" />
<CheckIcon v-else-if="instance.added" />
{{ instance.adding ? 'Adding...' : instance.added ? 'Added' : 'Add' }}
</Button>
</div>
</div>
<div class="input-group push-right">
<ButtonStyled>
<button @click="modal.hide()">Cancel</button>
</ButtonStyled>
<Button @click="modal.hide()">Cancel</Button>
</div>
</div>
</ModalWrapper>
@@ -1,42 +0,0 @@
<template>
<div>
<SharedInstanceInstallationSettingsControls
can-unpublish
:busy="isBusy"
:unpublishing="unpublishing"
:unpublish="unpublishSharedInstance"
/>
</div>
</template>
<script setup lang="ts">
import { injectNotificationManager } from '@modrinth/ui'
import { useQueryClient } from '@tanstack/vue-query'
import { computed, ref } from 'vue'
import SharedInstanceInstallationSettingsControls from '@/components/ui/shared-instances/SharedInstanceInstallationSettingsControls.vue'
import { unpublish_shared_instance } from '@/helpers/instance'
import { injectInstanceSettings } from '@/providers/instance-settings'
const { instance, offline, onUnlinked } = injectInstanceSettings()
const { handleError } = injectNotificationManager()
const queryClient = useQueryClient()
const unpublishing = ref(false)
const isBusy = computed(
() => instance.value.install_stage !== 'installed' || unpublishing.value || !!offline,
)
async function unpublishSharedInstance() {
unpublishing.value = true
try {
await unpublish_shared_instance(instance.value.id)
queryClient.setQueryData(['sharedInstanceUsers', instance.value.id], [])
await queryClient.invalidateQueries({ queryKey: ['linkedModpackInfo', instance.value.id] })
onUnlinked()
} catch (error) {
handleError(error)
} finally {
unpublishing.value = false
}
}
</script>
@@ -7,7 +7,7 @@ import {
MessagesSquareIcon,
WrenchIcon,
} from '@modrinth/assets'
import { Admonition, ButtonStyled, Collapsible, NewModal } from '@modrinth/ui'
import { Admonition, Button, ButtonLink, Collapsible, IconButton, NewModal } from '@modrinth/ui'
import { computed, ref } from 'vue'
import { hide_ads_window, show_ads_window } from '@/helpers/ads.js'
@@ -137,16 +137,18 @@ async function copyToClipboard(text: string) {
<!-- Action buttons -->
<div class="flex items-center gap-2">
<ButtonStyled>
<a href="https://support.modrinth.com" class="!w-full" @click="modal?.hide()">
<MessagesSquareIcon /> Contact support
</a>
</ButtonStyled>
<ButtonStyled color="brand">
<button :disabled="loadingSignIn" class="!w-full" @click="signInAgain">
<LogInIcon /> Sign in again
</button>
</ButtonStyled>
<ButtonLink href="https://support.modrinth.com" class="!w-full" @click="modal?.hide()">
<MessagesSquareIcon /> Contact support
</ButtonLink>
<Button
type="colored"
color="brand"
:disabled="loadingSignIn"
class="!w-full"
@click="signInAgain"
>
<LogInIcon /> Sign in again
</Button>
</div>
<div class="flex flex-col gap-2">
@@ -176,16 +178,15 @@ async function copyToClipboard(text: string) {
>
{{ debugInfo }}
</div>
<ButtonStyled circular>
<button
v-tooltip="'Copy debug info'"
:disabled="copied"
@click="copyToClipboard(debugInfo)"
>
<template v-if="copied"> <CheckIcon class="text-green" /> </template>
<template v-else> <CopyIcon /> </template>
</button>
</ButtonStyled>
<IconButton
v-tooltip="'Copy debug info'"
:label="'Copy debug info'"
:disabled="copied"
@click="copyToClipboard(debugInfo)"
>
<template v-if="copied"> <CheckIcon class="text-green" /> </template>
<template v-else> <CopyIcon /> </template>
</IconButton>
</div>
</Collapsible>
</div>
@@ -21,38 +21,33 @@
<div class="flex flex-col gap-6 px-6 pb-6">
<div class="flex justify-end gap-2">
<ButtonStyled>
<a class="w-full !shadow-none" href="https://support.modrinth.com" @click="modal?.hide()">
<MessagesSquareIcon />
{{ formatMessage(messages.getSupport) }}
</a>
</ButtonStyled>
<ButtonStyled color="brand">
<button class="w-full !shadow-none" :disabled="loadingSignIn" @click="signIn">
<SpinnerIcon v-if="loadingSignIn" class="animate-spin" />
<svg
v-else
width="20"
height="20"
viewBox="0 0 20 20"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<rect width="9.25" height="9.25" fill="black" fill-opacity="0.9" />
<rect x="10.75" width="9.25" height="9.25" fill="black" fill-opacity="0.9" />
<rect y="10.75" width="9.25" height="9.25" fill="black" fill-opacity="0.9" />
<rect
x="10.75"
y="10.75"
width="9.25"
height="9.25"
fill="black"
fill-opacity="0.9"
/>
</svg>
{{ formatMessage(messages.signIn) }}
</button>
</ButtonStyled>
<ButtonLink class="w-full" href="https://support.modrinth.com" @click="modal?.hide()">
<MessagesSquareIcon />
{{ formatMessage(messages.getSupport) }}
</ButtonLink>
<Button
type="colored"
color="brand"
class="w-full"
:disabled="loadingSignIn"
@click="signIn"
>
<SpinnerIcon v-if="loadingSignIn" class="animate-spin" />
<svg
v-else
width="20"
height="20"
viewBox="0 0 20 20"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<rect width="9.25" height="9.25" fill="black" fill-opacity="0.9" />
<rect x="10.75" width="9.25" height="9.25" fill="black" fill-opacity="0.9" />
<rect y="10.75" width="9.25" height="9.25" fill="black" fill-opacity="0.9" />
<rect x="10.75" y="10.75" width="9.25" height="9.25" fill="black" fill-opacity="0.9" />
</svg>
{{ formatMessage(messages.signIn) }}
</Button>
</div>
<p class="m-0 text-center text-sm text-secondary">
{{ formatMessage(messages.dontHaveAccount) }}
@@ -69,7 +64,7 @@
<script setup lang="ts">
import { MessagesSquareIcon, SpinnerIcon } from '@modrinth/assets'
import { ButtonStyled, defineMessages, NewModal, useVIntl } from '@modrinth/ui'
import { Button, ButtonLink, defineMessages, NewModal, useVIntl } from '@modrinth/ui'
import { inject, type Ref, ref } from 'vue'
import steveImage from '@/assets/steve-look-up-left.webp'
@@ -3,12 +3,14 @@ import {
CoffeeIcon,
GameIcon,
GaugeIcon,
HeartHandshakeIcon,
LanguagesIcon,
ModrinthIcon,
PaintbrushIcon,
SettingsIcon,
Settings2Icon,
ShieldIcon,
ToggleRightIcon,
UserIcon,
} from '@modrinth/assets'
import {
commonMessages,
@@ -17,23 +19,32 @@ import {
defineMessages,
ProgressBar,
TabbedModal,
UnsavedChangesPopup,
useVIntl,
} from '@modrinth/ui'
import { getVersion } from '@tauri-apps/api/app'
import { platform as getOsPlatform, version as getOsVersion } from '@tauri-apps/plugin-os'
import { ref, watch } from 'vue'
import { computed, provide, ref, watch } from 'vue'
import AppearanceSettings from '@/components/ui/settings/AppearanceSettings.vue'
import DefaultInstanceSettings from '@/components/ui/settings/DefaultInstanceSettings.vue'
import FeatureFlagSettings from '@/components/ui/settings/FeatureFlagSettings.vue'
import JavaSettings from '@/components/ui/settings/JavaSettings.vue'
import LanguageSettings from '@/components/ui/settings/LanguageSettings.vue'
import PrivacySettings from '@/components/ui/settings/PrivacySettings.vue'
import ResourceManagementSettings from '@/components/ui/settings/ResourceManagementSettings.vue'
import PrivacySettings from '@/components/ui/settings/account/PrivacySettings.vue'
import ProfileSettings from '@/components/ui/settings/account/ProfileSettings.vue'
import SocialSettings from '@/components/ui/settings/account/SocialSettings.vue'
import AppearanceSettings from '@/components/ui/settings/display/AppearanceSettings.vue'
import BehaviorSettings from '@/components/ui/settings/display/BehaviorSettings.vue'
import FeatureFlagSettings from '@/components/ui/settings/display/FeatureFlagSettings.vue'
import LanguageSettings from '@/components/ui/settings/display/LanguageSettings.vue'
import DefaultInstanceSettings from '@/components/ui/settings/instances/DefaultInstanceSettings.vue'
import JavaSettings from '@/components/ui/settings/instances/JavaSettings.vue'
import ResourceManagementSettings from '@/components/ui/settings/instances/ResourceManagementSettings.vue'
import { get, set } from '@/helpers/settings.ts'
import {
appSettingsModalContextKey,
type UnsavedChangesController,
} from '@/providers/app-settings-modal'
import { injectAppUpdateDownloadProgress } from '@/providers/download-progress.ts'
import { useTheming } from '@/store/state'
// TODO: Apply COMPONENT_STRUCTURE.md here and extract out common setting option components
const themeStore = useTheming()
const { formatMessage } = useVIntl()
@@ -45,71 +56,162 @@ const developerModeEnabled = defineMessage({
defaultMessage: 'Developer mode enabled.',
})
const tabCategories = defineMessages({
display: {
id: 'settings.sidebar.label.display',
defaultMessage: 'Display',
},
account: {
id: 'settings.sidebar.label.account',
defaultMessage: 'Account',
},
instances: {
id: 'app.settings.sidebar.label.instances',
defaultMessage: 'Instances',
},
})
const tabs = [
{
name: defineMessage({
id: 'app.settings.tabs.appearance',
defaultMessage: 'Appearance',
}),
category: tabCategories.display,
icon: PaintbrushIcon,
content: AppearanceSettings,
},
{
name: defineMessage({
id: 'app.settings.tabs.behavior',
defaultMessage: 'Behavior',
}),
category: tabCategories.display,
icon: Settings2Icon,
content: BehaviorSettings,
},
{
name: defineMessage({
id: 'app.settings.tabs.language',
defaultMessage: 'Language',
}),
category: tabCategories.display,
icon: LanguagesIcon,
content: LanguageSettings,
badge: commonMessages.beta,
},
{
name: commonSettingsMessages.featureFlags,
category: tabCategories.display,
icon: ToggleRightIcon,
content: FeatureFlagSettings,
developerOnly: true,
},
{
name: commonSettingsMessages.profile,
category: tabCategories.account,
icon: UserIcon,
content: ProfileSettings,
},
{
name: commonSettingsMessages.social,
category: tabCategories.account,
icon: HeartHandshakeIcon,
content: SocialSettings,
},
{
name: defineMessage({
id: 'app.settings.tabs.privacy',
defaultMessage: 'Privacy',
}),
category: tabCategories.account,
icon: ShieldIcon,
content: PrivacySettings,
},
{
name: defineMessage({
id: 'app.settings.tabs.default-instance-options',
defaultMessage: 'Default game options',
}),
category: tabCategories.instances,
icon: GameIcon,
content: DefaultInstanceSettings,
},
{
name: defineMessage({
id: 'app.settings.tabs.java-installations',
defaultMessage: 'Java installations',
}),
category: tabCategories.instances,
icon: CoffeeIcon,
content: JavaSettings,
},
{
name: defineMessage({
id: 'app.settings.tabs.default-instance-options',
defaultMessage: 'Default instance options',
}),
icon: GameIcon,
content: DefaultInstanceSettings,
},
{
name: defineMessage({
id: 'app.settings.tabs.resource-management',
defaultMessage: 'Resource management',
}),
category: tabCategories.instances,
icon: GaugeIcon,
content: ResourceManagementSettings,
},
{
name: commonSettingsMessages.featureFlags,
icon: ToggleRightIcon,
content: FeatureFlagSettings,
developerOnly: true,
},
]
const availableTabs = computed(() => tabs.filter((tab) => !tab.developerOnly || themeStore.devMode))
const modal = ref<InstanceType<typeof TabbedModal> | null>(null)
const unsavedChangesPopup = ref<{ nudge: () => void } | null>(null)
const unsavedChangesController = ref<UnsavedChangesController | null>(null)
const emptyUnsavedChangesState: Record<string, unknown> = {}
const originalUnsavedChangesState = computed(
() => unsavedChangesController.value?.getOriginal() ?? emptyUnsavedChangesState,
)
const modifiedUnsavedChangesState = computed(
() => unsavedChangesController.value?.getModified() ?? emptyUnsavedChangesState,
)
const savingUnsavedChanges = computed(() => unsavedChangesController.value?.isSaving() ?? false)
const hasUnsavedChanges = computed(() => unsavedChangesController.value?.hasChanges() ?? false)
function canLeaveCurrentTab(): boolean {
if (!unsavedChangesController.value?.hasChanges()) return true
unsavedChangesPopup.value?.nudge()
return false
}
function close(): boolean {
return modal.value?.hide() ?? false
}
function registerUnsavedChangesController(controller: UnsavedChangesController | null): void {
unsavedChangesController.value = controller
}
provide(appSettingsModalContextKey, {
close,
registerUnsavedChangesController,
})
function resetUnsavedChanges(): void {
unsavedChangesController.value?.reset()
}
function saveUnsavedChanges(): void {
void unsavedChangesController.value?.save()
}
function show() {
modal.value?.show()
}
defineExpose({ show })
function showProfile(): void {
const profileTabIndex = availableTabs.value.findIndex((tab) => tab.content === ProfileSettings)
if (profileTabIndex >= 0) {
modal.value?.setTab(profileTabIndex)
}
modal.value?.show()
}
defineExpose({ show, showProfile })
const { progress, version: downloadingVersion } = injectAppUpdateDownloadProgress()
@@ -129,12 +231,15 @@ watch(
function devModeCount() {
devModeCounter.value++
if (devModeCounter.value > 5) {
const selectedTab = modal.value ? availableTabs.value[modal.value.selectedTab] : undefined
themeStore.devMode = !themeStore.devMode
settings.value.developer_mode = !!themeStore.devMode
devModeCounter.value = 0
if (!themeStore.devMode && tabs[modal.value!.selectedTab].developerOnly) {
modal.value!.setTab(0)
if (modal.value) {
const selectedTabIndex = selectedTab ? availableTabs.value.indexOf(selectedTab) : -1
modal.value.setTab(selectedTabIndex >= 0 ? selectedTabIndex : 0)
}
}
}
@@ -144,15 +249,45 @@ const messages = defineMessages({
id: 'app.settings.downloading',
defaultMessage: 'Downloading v{version}',
},
appVersion: {
id: 'app.settings.app-version',
defaultMessage: 'Modrinth App {version}',
},
macos: {
id: 'app.settings.operating-system.macos',
defaultMessage: 'macOS',
},
developerModeButtonLabel: {
id: 'app.settings.developer-mode-button.label',
defaultMessage: 'Toggle developer mode',
},
})
</script>
<template>
<TabbedModal ref="modal" :tabs="tabs.filter((t) => !t.developerOnly || themeStore.devMode)">
<TabbedModal
ref="modal"
:tabs="availableTabs"
:width="'min(928px, calc(95vw - 10rem))'"
:before-hide="canLeaveCurrentTab"
:before-tab-change="canLeaveCurrentTab"
:floating-action-bar-shown="hasUnsavedChanges"
>
<template #title>
<span class="flex items-center gap-2 text-lg font-extrabold text-contrast">
<SettingsIcon /> Settings
<span class="text-2xl font-semibold text-contrast">
{{ formatMessage(commonMessages.settingsLabel) }}
</span>
</template>
<template #floating-action-bar>
<UnsavedChangesPopup
ref="unsavedChangesPopup"
:original="originalUnsavedChangesState"
:modified="modifiedUnsavedChangesState"
:saving="savingUnsavedChanges"
inline
@reset="resetUnsavedChanges"
@save="saveUnsavedChanges"
/>
</template>
<template #footer>
<div class="mt-auto text-secondary text-sm">
<div class="mb-3">
@@ -168,6 +303,7 @@ const messages = defineMessages({
</p>
<div class="flex items-center gap-3">
<button
:aria-label="formatMessage(messages.developerModeButtonLabel)"
class="p-0 m-0 bg-transparent border-none cursor-pointer button-animation"
:class="{
'text-brand': themeStore.devMode,
@@ -175,12 +311,14 @@ const messages = defineMessages({
}"
@click="devModeCount"
>
<ModrinthIcon class="w-6 h-6" />
<ModrinthIcon aria-hidden="true" class="w-6 h-6" />
</button>
<div class="max-w-[200px]">
<p class="m-0">Modrinth App {{ version }}</p>
<p class="m-0">
<span v-if="osPlatform === 'macos'">macOS</span>
{{ formatMessage(messages.appVersion, { version }) }}
</p>
<p class="m-0">
<span v-if="osPlatform === 'macos'">{{ formatMessage(messages.macos) }}</span>
<span v-else class="capitalize">{{ osPlatform }}</span>
{{ osVersion }}
</p>
@@ -6,18 +6,14 @@
<template #actions>
<div class="flex gap-2 justify-end">
<ButtonStyled type="outlined">
<button @click="modal?.hide()">
<XIcon />
{{ formatMessage(commonMessages.cancelButton) }}
</button>
</ButtonStyled>
<ButtonStyled color="red">
<button @click="confirm">
<TrashIcon />
{{ formatMessage(messages.deleteButton) }}
</button>
</ButtonStyled>
<Button type="outlined" @click="modal?.hide()">
<XIcon />
{{ formatMessage(commonMessages.cancelButton) }}
</Button>
<Button type="colored" color="red" @click="confirm">
<TrashIcon />
{{ formatMessage(messages.deleteButton) }}
</Button>
</div>
</template>
</NewModal>
@@ -27,7 +23,7 @@
import { TrashIcon, XIcon } from '@modrinth/assets'
import {
Admonition,
ButtonStyled,
Button,
commonMessages,
defineMessages,
NewModal,
@@ -18,12 +18,10 @@
{{ formatMessage(messages.sharedInstance) }}
</span>
<ButtonStyled type="transparent">
<button @click="openViewContents">
<EyeIcon />
{{ formatMessage(messages.viewContents) }}
</button>
</ButtonStyled>
<Button type="quiet" @click="openViewContents">
<EyeIcon />
{{ formatMessage(messages.viewContents) }}
</Button>
</div>
<div class="flex items-center gap-3 rounded-2xl bg-surface-2 p-3">
@@ -113,40 +111,30 @@
</p>
<div class="flex w-full items-center justify-between gap-2">
<ButtonStyled type="transparent" color="red">
<button @click="handleReport">
<ReportIcon />
{{ formatMessage(commonMessages.reportButton) }}
</button>
</ButtonStyled>
<Button type="quiet" color="red" @click="handleReport">
<ReportIcon />
{{ formatMessage(commonMessages.reportButton) }}
</Button>
<div class="flex items-center gap-2">
<template v-if="hasExternalFiles">
<ButtonStyled type="transparent" color="orange">
<button @click="handleAccept">
{{ formatMessage(messages.installAnyway) }}
</button>
</ButtonStyled>
<ButtonStyled color="brand">
<button @click="handleDecline">
<BanIcon />
{{ formatMessage(messages.dontInstall) }}
</button>
</ButtonStyled>
<Button type="quiet" color="orange" @click="handleAccept">
{{ formatMessage(messages.installAnyway) }}
</Button>
<Button type="colored" color="brand" @click="handleDecline">
<BanIcon />
{{ formatMessage(messages.dontInstall) }}
</Button>
</template>
<template v-else>
<ButtonStyled>
<button @click="handleDecline">
<XIcon />
{{ formatMessage(commonMessages.cancelButton) }}
</button>
</ButtonStyled>
<ButtonStyled color="brand">
<button @click="handleAccept">
<DownloadIcon />
{{ formatMessage(messages.installButton) }}
</button>
</ButtonStyled>
<Button @click="handleDecline">
<XIcon />
{{ formatMessage(commonMessages.cancelButton) }}
</Button>
<Button type="colored" color="brand" @click="handleAccept">
<DownloadIcon />
{{ formatMessage(messages.installButton) }}
</Button>
</template>
</div>
</div>
@@ -163,10 +151,10 @@
<script setup lang="ts">
import type { Labrinth } from '@modrinth/api-client'
import { BanIcon, DownloadIcon, EyeIcon, ReportIcon, XIcon } from '@modrinth/assets'
import { Button } from '@modrinth/ui'
import {
Admonition,
Avatar,
ButtonStyled,
commonMessages,
type ContentItem,
defineMessages,
@@ -10,24 +10,18 @@
<template #actions>
<div class="flex gap-2 justify-end">
<ButtonStyled type="outlined">
<button @click="handleCancel">
<XIcon />
{{ formatMessage(commonMessages.cancelButton) }}
</button>
</ButtonStyled>
<ButtonStyled>
<button @click="handleGoToInstance">
{{ formatMessage(messages.instance) }}
<RightArrowIcon />
</button>
</ButtonStyled>
<ButtonStyled color="orange">
<button @click="handleCreateAnyway">
<PlusIcon />
{{ formatMessage(messages.create) }}
</button>
</ButtonStyled>
<Button type="outlined" @click="handleCancel">
<XIcon />
{{ formatMessage(commonMessages.cancelButton) }}
</Button>
<Button @click="handleGoToInstance">
{{ formatMessage(messages.instance) }}
<RightArrowIcon />
</Button>
<Button type="colored" color="orange" @click="handleCreateAnyway">
<PlusIcon />
{{ formatMessage(messages.create) }}
</Button>
</div>
</template>
</NewModal>
@@ -36,7 +30,7 @@
<script setup lang="ts">
import { PlusIcon, RightArrowIcon, XIcon } from '@modrinth/assets'
import {
ButtonStyled,
Button,
commonMessages,
defineMessages,
IntlFormatted,
@@ -19,18 +19,20 @@
<div class="flex flex-col gap-6">
<div class="grid grid-cols-1 gap-2 sm:grid-cols-2">
<ButtonStyled>
<button class="w-full !shadow-none" type="button" @click="authenticate('sign-up')">
<UserPlusIcon aria-hidden="true" />
{{ formatMessage(messages.createAccountButton) }}
</button>
</ButtonStyled>
<ButtonStyled color="brand">
<button class="w-full" type="button" @click="authenticate('sign-in')">
<LogInIcon aria-hidden="true" />
{{ formatMessage(messages.signInButton) }}
</button>
</ButtonStyled>
<Button class="w-full" native-type="button" @click="authenticate('sign-up')">
<UserPlusIcon aria-hidden="true" />
{{ formatMessage(messages.createAccountButton) }}
</Button>
<Button
type="colored"
color="brand"
class="w-full"
native-type="button"
@click="authenticate('sign-in')"
>
<LogInIcon aria-hidden="true" />
{{ formatMessage(messages.signInButton) }}
</Button>
</div>
<p class="m-0 text-center text-base font-medium leading-6 text-primary">
@@ -69,23 +71,19 @@
<div class="flex flex-col gap-6">
<div class="grid grid-cols-1 gap-2 sm:grid-cols-2">
<ButtonStyled type="outlined">
<button class="w-full" type="button" @click="modal?.hide()">
<XIcon aria-hidden="true" />
{{ formatMessage(messages.cancelButton) }}
</button>
</ButtonStyled>
<ButtonStyled>
<button
class="w-full !shadow-none"
type="button"
:disabled="reopeningBrowser"
@click="reopenBrowser"
>
<RefreshCwIcon aria-hidden="true" />
{{ formatMessage(messages.openBrowserAgainButton) }}
</button>
</ButtonStyled>
<Button type="outlined" class="w-full" native-type="button" @click="modal?.hide()">
<XIcon aria-hidden="true" />
{{ formatMessage(commonMessages.cancelButton) }}
</Button>
<Button
class="w-full"
native-type="button"
:disabled="reopeningBrowser"
@click="reopenBrowser"
>
<RefreshCwIcon aria-hidden="true" />
{{ formatMessage(messages.openBrowserAgainButton) }}
</Button>
</div>
<p class="m-0 text-center text-base font-medium leading-6 text-primary">
@@ -108,7 +106,14 @@
<script setup lang="ts">
import { LogInIcon, RefreshCwIcon, SpinnerIcon, UserPlusIcon, XIcon } from '@modrinth/assets'
import { ButtonStyled, defineMessages, IntlFormatted, NewModal, useVIntl } from '@modrinth/ui'
import {
Button,
commonMessages,
defineMessages,
IntlFormatted,
NewModal,
useVIntl,
} from '@modrinth/ui'
import { openUrl } from '@tauri-apps/plugin-opener'
import { ref } from 'vue'
@@ -247,10 +252,6 @@ const messages = defineMessages({
id: 'modal.modrinth-account-required.waiting-for-browser',
defaultMessage: 'Waiting for browser confirmation...',
},
cancelButton: {
id: 'modal.modrinth-account-required.cancel-button',
defaultMessage: 'Cancel',
},
openBrowserAgainButton: {
id: 'modal.modrinth-account-required.open-browser-again-button',
defaultMessage: 'Open browser again',
@@ -1,335 +0,0 @@
<script setup lang="ts">
import { Combobox, defineMessages, ThemeSelector, Toggle, useVIntl } from '@modrinth/ui'
import { ref, watch } from 'vue'
import { get, set } from '@/helpers/settings.ts'
import { getOS } from '@/helpers/utils'
import { useTheming } from '@/store/state'
import type { ColorTheme, FeatureFlag } from '@/store/theme.ts'
const themeStore = useTheming()
const { formatMessage } = useVIntl()
const worldsInHomeFlag: FeatureFlag = 'worlds_in_home'
const skipNonEssentialWarningsFlag: FeatureFlag = 'skip_non_essential_warnings'
const skipUnknownPackWarningFlag: FeatureFlag = 'skip_unknown_pack_warning'
const showPlayTimeFlag: FeatureFlag = 'show_instance_play_time'
const messages = defineMessages({
colorThemeTitle: {
id: 'app.appearance-settings.color-theme.title',
defaultMessage: 'Color theme',
},
colorThemeDescription: {
id: 'app.appearance-settings.color-theme.description',
defaultMessage: 'Select your preferred color theme for Modrinth App.',
},
advancedRenderingTitle: {
id: 'app.appearance-settings.advanced-rendering.title',
defaultMessage: 'Advanced rendering',
},
advancedRenderingDescription: {
id: 'app.appearance-settings.advanced-rendering.description',
defaultMessage:
'Enables advanced rendering such as blur effects that may cause performance issues without hardware-accelerated rendering.',
},
hideNametagTitle: {
id: 'app.appearance-settings.hide-nametag.title',
defaultMessage: 'Hide nametag',
},
hideNametagDescription: {
id: 'app.appearance-settings.hide-nametag.description',
defaultMessage: 'Disables the nametag above your player on the skins page.',
},
nativeDecorationsTitle: {
id: 'app.appearance-settings.native-decorations.title',
defaultMessage: 'Native decorations',
},
nativeDecorationsDescription: {
id: 'app.appearance-settings.native-decorations.description',
defaultMessage: 'Use system window frame (app restart required).',
},
minimizeLauncherTitle: {
id: 'app.appearance-settings.minimize-launcher.title',
defaultMessage: 'Minimize launcher',
},
minimizeLauncherDescription: {
id: 'app.appearance-settings.minimize-launcher.description',
defaultMessage: 'Minimize the launcher when a Minecraft process starts.',
},
defaultLandingPageTitle: {
id: 'app.appearance-settings.default-landing-page.title',
defaultMessage: 'Default landing page',
},
defaultLandingPageDescription: {
id: 'app.appearance-settings.default-landing-page.description',
defaultMessage: 'Change the page to which the launcher opens on.',
},
defaultLandingPageHome: {
id: 'app.appearance-settings.default-landing-page.home',
defaultMessage: 'Home',
},
defaultLandingPageLibrary: {
id: 'app.appearance-settings.default-landing-page.library',
defaultMessage: 'Library',
},
selectOption: {
id: 'app.appearance-settings.select-option',
defaultMessage: 'Select an option',
},
jumpBackIntoWorldsTitle: {
id: 'app.appearance-settings.jump-back-into-worlds.title',
defaultMessage: 'Jump back into worlds',
},
jumpBackIntoWorldsDescription: {
id: 'app.appearance-settings.jump-back-into-worlds.description',
defaultMessage: 'Includes recent worlds in the "Jump back in" section on the Home page.',
},
toggleSidebarTitle: {
id: 'app.appearance-settings.toggle-sidebar.title',
defaultMessage: 'Toggle sidebar',
},
toggleSidebarDescription: {
id: 'app.appearance-settings.toggle-sidebar.description',
defaultMessage: 'Enables the ability to toggle the sidebar.',
},
unknownPackWarningTitle: {
id: 'app.appearance-settings.unknown-pack-warning.title',
defaultMessage: 'Warn me before installing unknown modpacks',
},
unknownPackWarningDescription: {
id: 'app.appearance-settings.unknown-pack-warning.description',
defaultMessage:
"If you attempt to install a Modrinth Pack file (.mrpack) that isn't hosted on Modrinth, we'll make sure you understand the risks before installing it.",
},
skipNonEssentialWarningsTitle: {
id: 'app.appearance-settings.skip-non-essential-warnings.title',
defaultMessage: 'Skip non-essential warnings',
},
skipNonEssentialWarningsDescription: {
id: 'app.appearance-settings.skip-non-essential-warnings.description',
defaultMessage:
'Automatically skips low-risk confirmations like duplicate modpack installs, normal content deletion, bulk updates, unlinking modpacks, and repair prompts. Dangerous warnings will still be shown.',
},
showPlayTimeTitle: {
id: 'app.appearance-settings.show-play-time.title',
defaultMessage: 'Show play time',
},
showPlayTimeDescription: {
id: 'app.appearance-settings.show-play-time.description',
defaultMessage: `Displays how much time you've spent playing an instance.`,
},
})
const os = ref(await getOS())
const settings = ref(await get())
watch(
settings,
async () => {
await set(settings.value)
},
{ deep: true },
)
</script>
<template>
<h2 class="m-0 text-lg font-semibold text-contrast">
{{ formatMessage(messages.colorThemeTitle) }}
</h2>
<p class="m-0 mt-1">{{ formatMessage(messages.colorThemeDescription) }}</p>
<ThemeSelector
:update-color-theme="
(theme: ColorTheme) => {
themeStore.setThemeState(theme)
settings.theme = theme
}
"
:current-theme="settings.theme"
:theme-options="themeStore.getThemeOptions()"
system-theme-color="system"
/>
<div class="mt-6 flex items-center justify-between">
<div>
<h2 class="m-0 text-lg font-semibold text-contrast">
{{ formatMessage(messages.advancedRenderingTitle) }}
</h2>
<p class="m-0 mt-1">
{{ formatMessage(messages.advancedRenderingDescription) }}
</p>
</div>
<Toggle
id="advanced-rendering"
:model-value="themeStore.advancedRendering"
@update:model-value="
(e) => {
themeStore.advancedRendering = !!e
settings.advanced_rendering = themeStore.advancedRendering
}
"
/>
</div>
<div v-if="os !== 'MacOS'" class="mt-6 flex items-center justify-between gap-4">
<div>
<h2 class="m-0 text-lg font-semibold text-contrast">
{{ formatMessage(messages.nativeDecorationsTitle) }}
</h2>
<p class="m-0 mt-1">{{ formatMessage(messages.nativeDecorationsDescription) }}</p>
</div>
<Toggle id="native-decorations" v-model="settings.native_decorations" />
</div>
<div class="mt-6 flex items-center justify-between">
<div>
<h2 class="m-0 text-lg font-semibold text-contrast">
{{ formatMessage(messages.minimizeLauncherTitle) }}
</h2>
<p class="m-0 mt-1">{{ formatMessage(messages.minimizeLauncherDescription) }}</p>
</div>
<Toggle id="minimize-launcher" v-model="settings.hide_on_process_start" />
</div>
<div class="mt-6 flex items-center justify-between">
<div>
<h2 class="m-0 text-lg font-semibold text-contrast">
{{ formatMessage(messages.showPlayTimeTitle) }}
</h2>
<p class="m-0 mt-1">{{ formatMessage(messages.showPlayTimeDescription) }}</p>
</div>
<Toggle
:model-value="themeStore.getFeatureFlag(showPlayTimeFlag)"
@update:model-value="
() => {
const newValue = !themeStore.getFeatureFlag(showPlayTimeFlag)
themeStore.featureFlags[showPlayTimeFlag] = newValue
settings.feature_flags[showPlayTimeFlag] = newValue
}
"
/>
</div>
<div class="mt-6 flex items-center justify-between">
<div>
<h2 class="m-0 text-lg font-semibold text-contrast">
{{ formatMessage(messages.hideNametagTitle) }}
</h2>
<p class="m-0 mt-1">{{ formatMessage(messages.hideNametagDescription) }}</p>
</div>
<Toggle
id="hide-nametag-skins-page"
:model-value="themeStore.hideNametagSkinsPage"
@update:model-value="
(e) => {
themeStore.hideNametagSkinsPage = !!e
settings.hide_nametag_skins_page = themeStore.hideNametagSkinsPage
}
"
/>
</div>
<div class="mt-6 flex items-center justify-between">
<div>
<h2 class="m-0 text-lg font-semibold text-contrast">
{{ formatMessage(messages.defaultLandingPageTitle) }}
</h2>
<p class="m-0 mt-1">{{ formatMessage(messages.defaultLandingPageDescription) }}</p>
</div>
<Combobox
id="opening-page"
v-model="settings.default_page"
name="Opening page dropdown"
class="max-w-40"
:options="[
{
value: 'Home',
label: formatMessage(messages.defaultLandingPageHome),
},
{
value: 'Library',
label: formatMessage(messages.defaultLandingPageLibrary),
},
]"
:display-value="settings.default_page ?? 'Select an option'"
/>
</div>
<div class="mt-6 flex items-center justify-between">
<div>
<h2 class="m-0 text-lg font-semibold text-contrast">
{{ formatMessage(messages.jumpBackIntoWorldsTitle) }}
</h2>
<p class="m-0 mt-1">{{ formatMessage(messages.jumpBackIntoWorldsDescription) }}</p>
</div>
<Toggle
:model-value="themeStore.getFeatureFlag(worldsInHomeFlag)"
@update:model-value="
() => {
const newValue = !themeStore.getFeatureFlag(worldsInHomeFlag)
themeStore.featureFlags[worldsInHomeFlag] = newValue
settings.feature_flags[worldsInHomeFlag] = newValue
}
"
/>
</div>
<div class="mt-6 flex items-center justify-between gap-4">
<div>
<h2 class="m-0 text-lg font-semibold text-contrast">
{{ formatMessage(messages.unknownPackWarningTitle) }}
</h2>
<p class="m-0 mt-1">{{ formatMessage(messages.unknownPackWarningDescription) }}</p>
</div>
<Toggle
:model-value="!themeStore.getFeatureFlag(skipUnknownPackWarningFlag)"
@update:model-value="
(e) => {
const warnBeforeUnknownPackInstall = !!e
const skipUnknownPackWarning = !warnBeforeUnknownPackInstall
themeStore.featureFlags[skipUnknownPackWarningFlag] = skipUnknownPackWarning
settings.feature_flags[skipUnknownPackWarningFlag] = skipUnknownPackWarning
}
"
/>
</div>
<div class="mt-6 flex items-center justify-between gap-4">
<div>
<h2 class="m-0 text-lg font-semibold text-contrast">
{{ formatMessage(messages.skipNonEssentialWarningsTitle) }}
</h2>
<p class="m-0 mt-1">{{ formatMessage(messages.skipNonEssentialWarningsDescription) }}</p>
</div>
<Toggle
:model-value="themeStore.getFeatureFlag(skipNonEssentialWarningsFlag)"
@update:model-value="
() => {
const newValue = !themeStore.getFeatureFlag(skipNonEssentialWarningsFlag)
themeStore.featureFlags[skipNonEssentialWarningsFlag] = newValue
settings.feature_flags[skipNonEssentialWarningsFlag] = newValue
}
"
/>
</div>
<div class="mt-6 flex items-center justify-between">
<div>
<h2 class="m-0 text-lg font-semibold text-contrast">
{{ formatMessage(messages.toggleSidebarTitle) }}
</h2>
<p class="m-0 mt-1">{{ formatMessage(messages.toggleSidebarDescription) }}</p>
</div>
<Toggle
id="toggle-sidebar"
:model-value="settings.toggle_sidebar"
@update:model-value="
(e) => {
settings.toggle_sidebar = !!e
themeStore.toggleSidebar = settings.toggle_sidebar
}
"
/>
</div>
</template>
@@ -1,186 +0,0 @@
<script setup lang="ts">
import { injectNotificationManager, Slider, StyledInput, Toggle } from '@modrinth/ui'
import { ref, watch } from 'vue'
import useMemorySlider from '@/composables/useMemorySlider'
import { get, set } from '@/helpers/settings.ts'
const { handleError } = injectNotificationManager()
const fetchSettings = await get()
fetchSettings.launchArgs = fetchSettings.extra_launch_args.join(' ')
fetchSettings.envVars = fetchSettings.custom_env_vars.map((x) => x.join('=')).join(' ')
const settings = ref(fetchSettings)
const { maxMemory, snapPoints } = (await useMemorySlider().catch(handleError)) as unknown as {
maxMemory: number
snapPoints: number[]
}
watch(
settings,
async () => {
const setSettings = JSON.parse(JSON.stringify(settings.value))
setSettings.extra_launch_args = setSettings.launchArgs.trim().split(/\s+/).filter(Boolean)
setSettings.custom_env_vars = setSettings.envVars
.trim()
.split(/\s+/)
.filter(Boolean)
.map((x) => x.split('=').filter(Boolean))
if (!setSettings.hooks.pre_launch) {
setSettings.hooks.pre_launch = null
}
if (!setSettings.hooks.wrapper) {
setSettings.hooks.wrapper = null
}
if (!setSettings.hooks.post_exit) {
setSettings.hooks.post_exit = null
}
if (!setSettings.custom_dir) {
setSettings.custom_dir = null
}
await set(setSettings)
},
{ deep: true },
)
</script>
<template>
<div>
<div class="flex flex-col gap-6">
<div class="flex items-center justify-between gap-4">
<div class="flex flex-col gap-1">
<h3 class="m-0 text-lg font-semibold text-contrast">Fullscreen</h3>
<p class="m-0 leading-tight">
Overwrites the options.txt file to start in full screen when launched.
</p>
</div>
<Toggle id="fullscreen" v-model="settings.force_fullscreen" />
</div>
<div class="flex items-center justify-between gap-4">
<div class="flex flex-col gap-1">
<h3 class="m-0 text-lg font-semibold text-contrast">Width</h3>
<p class="m-0 leading-tight">The width of the game window when launched.</p>
</div>
<StyledInput
id="width"
v-model="settings.game_resolution[0]"
:disabled="settings.force_fullscreen"
autocomplete="off"
type="number"
placeholder="Enter width..."
/>
</div>
<div class="flex items-center justify-between gap-4">
<div class="flex flex-col gap-1">
<h3 class="m-0 text-lg font-semibold text-contrast">Height</h3>
<p class="m-0 leading-tight">The height of the game window when launched.</p>
</div>
<StyledInput
id="height"
v-model="settings.game_resolution[1]"
:disabled="settings.force_fullscreen"
autocomplete="off"
type="number"
placeholder="Enter height..."
/>
</div>
</div>
<hr class="my-6 bg-button-border border-none h-[1px]" />
<div class="flex flex-col gap-6">
<div class="flex flex-col gap-2.5">
<h2 class="m-0 text-lg font-semibold text-contrast">Memory allocated</h2>
<Slider
id="max-memory"
v-model="settings.memory.maximum"
:min="512"
:max="maxMemory"
:step="64"
:snap-points="snapPoints"
:snap-range="512"
unit="MB"
/>
<p class="m-0 mt-1 leading-tight">The memory allocated to each instance when it is ran.</p>
</div>
<div class="flex flex-col gap-2.5">
<h2 class="m-0 text-lg font-semibold text-contrast">Java arguments</h2>
<StyledInput
id="java-args"
v-model="settings.launchArgs"
autocomplete="off"
type="text"
placeholder="Enter java arguments..."
wrapper-class="w-full"
/>
</div>
<div class="flex flex-col gap-2.5">
<h2 class="m-0 text-lg font-semibold text-contrast">Environmental variables</h2>
<StyledInput
id="env-vars"
v-model="settings.envVars"
autocomplete="off"
type="text"
placeholder="Enter environmental variables..."
wrapper-class="w-full"
/>
</div>
</div>
<hr class="my-6 bg-button-border border-none h-[1px]" />
<div class="flex flex-col gap-6">
<div class="flex flex-col gap-2.5">
<h3 class="m-0 text-lg font-semibold text-contrast">Pre launch hook</h3>
<StyledInput
id="pre-launch"
v-model="settings.hooks.pre_launch"
autocomplete="off"
type="text"
placeholder="Enter pre-launch command..."
wrapper-class="w-full"
/>
<p class="m-0 leading-tight">Ran before the instance is launched.</p>
</div>
<div class="flex flex-col gap-2.5">
<h3 class="m-0 text-lg font-semibold text-contrast">Wrapper hook</h3>
<StyledInput
id="wrapper"
v-model="settings.hooks.wrapper"
autocomplete="off"
type="text"
placeholder="Enter wrapper command..."
wrapper-class="w-full"
/>
<p class="m-0 leading-tight">Wrapper command for launching Minecraft.</p>
</div>
<div class="flex flex-col gap-2.5">
<h3 class="m-0 text-lg font-semibold text-contrast">Post exit hook</h3>
<StyledInput
id="post-exit"
v-model="settings.hooks.post_exit"
autocomplete="off"
type="text"
placeholder="Enter post-exit command..."
wrapper-class="w-full"
/>
<p class="m-0 leading-tight">Ran after the game closes.</p>
</div>
</div>
</div>
</template>
@@ -1,168 +0,0 @@
<script setup>
import { BoxIcon, FolderOpenIcon, FolderSearchIcon, TrashIcon } from '@modrinth/assets'
import { ButtonStyled, injectNotificationManager, Slider, StyledInput } from '@modrinth/ui'
import { open } from '@tauri-apps/plugin-dialog'
import { ref, watch } from 'vue'
import ConfirmModalWrapper from '@/components/ui/modal/ConfirmModalWrapper.vue'
import { purge_cache_types } from '@/helpers/cache.js'
import { get, set } from '@/helpers/settings.ts'
import { showAppDbBackupsFolder } from '@/helpers/utils.js'
import { useTheming } from '@/store/state'
const { handleError } = injectNotificationManager()
const themeStore = useTheming()
const settings = ref(await get())
const purgeCacheConfirmModal = ref(null)
watch(
settings,
async () => {
const setSettings = JSON.parse(JSON.stringify(settings.value))
if (!setSettings.custom_dir) {
setSettings.custom_dir = null
}
await set(setSettings)
},
{ deep: true },
)
async function purgeCache() {
await purge_cache_types([
'project',
'project_v3',
'version',
'user',
'team',
'organization',
'file',
'loader_manifest',
'minecraft_manifest',
'categories',
'report_types',
'loaders',
'game_versions',
'donation_platforms',
'file_hash',
'file_update',
'search_results',
'search_results_v3',
]).catch(handleError)
}
function handlePurgeCacheClick() {
if (themeStore.getFeatureFlag('skip_non_essential_warnings')) {
void purgeCache()
return
}
purgeCacheConfirmModal.value?.show()
}
async function openDbBackupsFolder() {
await showAppDbBackupsFolder().catch(handleError)
}
async function findLauncherDir() {
const newDir = await open({
multiple: false,
directory: true,
title: 'Select a new app directory',
})
if (newDir) {
settings.value.custom_dir = newDir
}
}
</script>
<template>
<div class="flex flex-col gap-6">
<div class="flex flex-col gap-2.5">
<h2 class="m-0 text-lg font-semibold text-contrast">App directory</h2>
<StyledInput
id="appDir"
v-model="settings.custom_dir"
:icon="BoxIcon"
type="text"
wrapper-class="w-full"
>
<template #right>
<ButtonStyled circular>
<button class="ml-1.5" @click="findLauncherDir">
<FolderSearchIcon />
</button>
</ButtonStyled>
</template>
</StyledInput>
<p class="m-0 leading-tight text-secondary">
The directory where the launcher stores all of its files. Changes will be applied after
restarting the launcher.
</p>
</div>
<div class="flex flex-col gap-2.5">
<ConfirmModalWrapper
ref="purgeCacheConfirmModal"
title="Are you sure you want to purge the cache?"
description="If you proceed, your entire cache will be purged. This may slow down the app temporarily."
:has-to-type="false"
proceed-label="Purge cache"
:show-ad-on-close="false"
@proceed="purgeCache"
/>
<h2 class="m-0 text-lg font-semibold text-contrast">App cache</h2>
<button id="purge-cache" class="btn min-w-max" @click="handlePurgeCacheClick">
<TrashIcon />
Purge cache
</button>
<p class="m-0 leading-tight text-secondary">
The Modrinth app stores a cache of data to speed up loading. This can be purged to force the
app to reload data. This may slow down the app temporarily.
</p>
</div>
<div class="flex flex-col gap-2.5">
<h2 class="m-0 text-lg font-semibold text-contrast mt-4">Maximum concurrent downloads</h2>
<Slider
id="max-downloads"
v-model="settings.max_concurrent_downloads"
:min="1"
:max="10"
:step="1"
/>
<p class="m-0 leading-tight text-secondary">
The maximum amount of files the launcher can download at the same time. Set this to a lower
value if you have a poor internet connection. (app restart required to take effect)
</p>
</div>
<div class="flex flex-col gap-2.5">
<h2 class="mt-0 m-0 text-lg font-semibold text-contrast">Maximum concurrent writes</h2>
<Slider
id="max-writes"
v-model="settings.max_concurrent_writes"
:min="1"
:max="50"
:step="1"
/>
<p class="m-0 leading-tight text-secondary">
The maximum amount of files the launcher can write to the disk at once. Set this to a lower
value if you are frequently getting I/O errors. (app restart required to take effect)
</p>
</div>
<div class="flex flex-col gap-2.5">
<h2 class="mt-0 m-0 text-lg font-semibold text-contrast">App database backups</h2>
<button id="open-db-backups-folder" class="btn min-w-max" @click="openDbBackupsFolder">
<FolderOpenIcon />
Open backups folder
</button>
<p class="m-0 leading-tight text-secondary">
Backups of important app data are stored here in case you need to recover them later.
</p>
</div>
</div>
</template>
@@ -1,7 +1,7 @@
<script setup lang="ts">
import { Settings2Icon } from '@modrinth/assets'
import {
ButtonStyled,
Button,
defineMessages,
injectNotificationManager,
injectPageContext,
@@ -33,6 +33,24 @@ const messages = defineMessages({
id: 'app.ads-consent.manage',
defaultMessage: 'Manage preferences',
},
telemetryTitle: {
id: 'app.settings.privacy.telemetry.title',
defaultMessage: 'Telemetry',
},
telemetryDescription: {
id: 'app.settings.privacy.telemetry.description',
defaultMessage:
'Modrinth collects anonymized analytics and usage data to improve our user experience and customize your experience. By disabling this option, you opt out and your data will no longer be collected.',
},
discordRichPresenceTitle: {
id: 'app.settings.privacy.discord-rich-presence.title',
defaultMessage: 'Discord Rich Presence',
},
discordRichPresenceDescription: {
id: 'app.settings.privacy.discord-rich-presence.description',
defaultMessage:
'Show Modrinth App as your current activity on Discord. This does not affect Rich Presence added to instances by mods. Requires an app restart.',
},
})
async function manageAdsPreferences() {
@@ -59,28 +77,24 @@ watch(
<h2 class="m-0 text-lg font-semibold text-contrast">
{{ formatMessage(messages.adsConsentTitle) }}
</h2>
<div class="mt-1 flex flex-col gap-2.5 items-start">
<div class="flex flex-col gap-1 items-start">
<div class="text-sm">
{{ formatMessage(messages.adsConsentIntro) }}
</div>
<div class="mt-2 flex flex-col gap-2.5 items-start">
<Button @click="manageAdsPreferences">
<Settings2Icon aria-hidden="true" />
{{ formatMessage(messages.adsConsentManage) }}
</Button>
<div>
{{ formatMessage(messages.adsConsentIntro) }}
</div>
<ButtonStyled>
<button class="!shadow-none" @click="manageAdsPreferences">
<Settings2Icon aria-hidden="true" />
{{ formatMessage(messages.adsConsentManage) }}
</button>
</ButtonStyled>
</div>
</div>
<div class="mt-8 flex items-center justify-between gap-4">
<div>
<h2 class="m-0 text-lg font-semibold text-contrast">Telemetry</h2>
<p class="m-0 mt-1 text-sm">
Modrinth collects anonymized analytics and usage data to improve our user experience and
customize your experience. By disabling this option, you opt out and your data will no
longer be collected.
<h2 class="m-0 text-lg font-semibold text-contrast">
{{ formatMessage(messages.telemetryTitle) }}
</h2>
<p class="m-0 mt-1">
{{ formatMessage(messages.telemetryDescription) }}
</p>
</div>
<Toggle id="opt-out-analytics" v-model="settings.telemetry" />
@@ -88,14 +102,11 @@ watch(
<div class="mt-4 flex items-center justify-between gap-4">
<div>
<h2 class="m-0 text-lg font-semibold text-contrast">Discord RPC</h2>
<p class="m-0 mt-1 text-sm">
Manages the Discord Rich Presence integration. Disabling this will cause 'Modrinth' to no
longer show up as a game or app you are using on your Discord profile.
</p>
<p class="m-0 mt-2 text-sm">
Note: This will not prevent any instance-specific Discord Rich Presence integrations, such
as those added by mods. (app restart required to take effect)
<h2 class="m-0 text-lg font-semibold text-contrast">
{{ formatMessage(messages.discordRichPresenceTitle) }}
</h2>
<p class="m-0 mt-1">
{{ formatMessage(messages.discordRichPresenceDescription) }}
</p>
</div>
<Toggle id="disable-discord-rpc" v-model="settings.discord_rpc" />
@@ -0,0 +1,70 @@
<template>
<AccountProfileSettings
ref="profileSettings"
:patch-user="patchUser"
:change-avatar="changeAvatar"
:delete-avatar="deleteAvatar"
:get-authenticated-user="getAuthenticatedUser"
@profile-link-click="handleProfileLinkClick"
/>
</template>
<script setup lang="ts">
import type { Labrinth } from '@modrinth/api-client'
import { AccountProfileSettings, injectAuth } from '@modrinth/ui'
import { inject, onBeforeUnmount, onMounted, ref } from 'vue'
import {
change_user_avatar,
delete_user_avatar,
get_user_profile,
patch_user,
} from '@/helpers/users'
import { appSettingsModalContextKey } from '@/providers/app-settings-modal'
const settingsModal = inject(appSettingsModalContextKey, null)
const auth = injectAuth()
const profileSettings = ref<InstanceType<typeof AccountProfileSettings> | null>(null)
onMounted(() => {
settingsModal?.registerUnsavedChangesController({
hasChanges: () => profileSettings.value?.hasChanges ?? false,
getOriginal: () => profileSettings.value?.originalState ?? {},
getModified: () => profileSettings.value?.modifiedState ?? {},
isSaving: () => profileSettings.value?.saving ?? false,
reset: () => profileSettings.value?.reset(),
save: () => profileSettings.value?.save(),
})
})
onBeforeUnmount(() => {
settingsModal?.registerUnsavedChangesController(null)
})
function handleProfileLinkClick(event: MouseEvent): void {
if (settingsModal && !settingsModal.close()) {
event.preventDefault()
}
}
function patchUser(
userId: string,
patch: Partial<Pick<Labrinth.Users.v2.User, 'bio' | 'username'>>,
): Promise<void> {
return patch_user(userId, patch)
}
async function changeAvatar(userId: string, file: Blob, extension: string): Promise<void> {
await change_user_avatar(userId, new Uint8Array(await file.arrayBuffer()), extension)
}
function deleteAvatar(userId: string): Promise<void> {
return delete_user_avatar(userId)
}
function getAuthenticatedUser(): Promise<Labrinth.Users.v3.User> {
const userId = auth.user.value?.id
if (!userId) throw new Error('Cannot refresh a signed-out user.')
return get_user_profile(userId)
}
</script>
@@ -0,0 +1,20 @@
<template>
<AccountSocialSettings
:get-blocked-users="get_blocked_users"
:get-users="getUsers"
:unblock-user="unblock_user"
/>
</template>
<script setup lang="ts">
import type { Labrinth } from '@modrinth/api-client'
import { AccountSocialSettings, injectModrinthClient } from '@modrinth/ui'
import { get_blocked_users, unblock_user } from '@/helpers/users'
const client = injectModrinthClient()
function getUsers(userIds: string[]): Promise<Labrinth.Users.v2.User[]> {
return client.labrinth.users_v2.getMultiple(userIds)
}
</script>
@@ -0,0 +1,108 @@
<script setup lang="ts">
import { defineMessages, ThemeSelector, Toggle, useVIntl } from '@modrinth/ui'
import { computed, ref, watch } from 'vue'
import { get, set } from '@/helpers/settings.ts'
import { getOS } from '@/helpers/utils'
import { useTheming } from '@/store/state'
import type { ColorTheme } from '@/store/theme.ts'
const themeStore = useTheming()
const { formatMessage } = useVIntl()
const messages = defineMessages({
colorThemeTitle: {
id: 'app.appearance-settings.color-theme.title',
defaultMessage: 'Color theme',
},
colorThemeDescription: {
id: 'app.appearance-settings.color-theme.description',
defaultMessage: 'Choose the color theme used by Modrinth App.',
},
advancedRenderingTitle: {
id: 'app.appearance-settings.advanced-rendering.title',
defaultMessage: 'Advanced rendering',
},
advancedRenderingDescription: {
id: 'app.appearance-settings.advanced-rendering.description',
defaultMessage:
'Enable visual effects such as background blur. This may reduce performance without hardware acceleration.',
},
nativeDecorationsTitle: {
id: 'app.appearance-settings.native-decorations.title',
defaultMessage: 'System window frame',
},
nativeDecorationsDescription: {
id: 'app.appearance-settings.native-decorations.description',
defaultMessage:
"Use your operating system's title bar and window controls. Requires an app restart.",
},
})
const os = ref(await getOS())
const settings = ref(await get())
const themeOptions = computed(() =>
themeStore
.getThemeOptions()
.filter((theme) => theme !== 'retro' || themeStore.devMode || settings.value.theme === 'retro'),
)
watch(
settings,
async () => {
await set(settings.value)
},
{ deep: true },
)
</script>
<template>
<h2 class="m-0 text-lg font-semibold text-contrast">
{{ formatMessage(messages.colorThemeTitle) }}
</h2>
<p class="m-0 mt-1">{{ formatMessage(messages.colorThemeDescription) }}</p>
<ThemeSelector
:update-color-theme="
(theme: ColorTheme) => {
themeStore.setThemeState(theme)
settings.theme = theme
}
"
:current-theme="settings.theme"
:theme-options="themeOptions"
system-theme-color="system"
/>
<div class="mt-6 flex items-center justify-between">
<div>
<h2 class="m-0 text-lg font-semibold text-contrast">
{{ formatMessage(messages.advancedRenderingTitle) }}
</h2>
<p class="m-0 mt-1">
{{ formatMessage(messages.advancedRenderingDescription) }}
</p>
</div>
<Toggle
id="advanced-rendering"
:model-value="themeStore.advancedRendering"
@update:model-value="
(e) => {
themeStore.advancedRendering = !!e
settings.advanced_rendering = themeStore.advancedRendering
}
"
/>
</div>
<div v-if="os !== 'MacOS'" class="mt-6 flex items-center justify-between gap-4">
<div>
<h2 class="m-0 text-lg font-semibold text-contrast">
{{ formatMessage(messages.nativeDecorationsTitle) }}
</h2>
<p class="m-0 mt-1">{{ formatMessage(messages.nativeDecorationsDescription) }}</p>
</div>
<Toggle id="native-decorations" v-model="settings.native_decorations" />
</div>
</template>
@@ -0,0 +1,302 @@
<script setup lang="ts">
import { Chips, defineMessages, Toggle, useVIntl } from '@modrinth/ui'
import { ref, watch } from 'vue'
import { get, set } from '@/helpers/settings.ts'
import { useTheming } from '@/store/state'
import type { FeatureFlag } from '@/store/theme.ts'
const themeStore = useTheming()
const { formatMessage } = useVIntl()
const worldsInHomeFlag: FeatureFlag = 'worlds_in_home'
const skipNonEssentialWarningsFlag: FeatureFlag = 'skip_non_essential_warnings'
const skipUnknownPackWarningFlag: FeatureFlag = 'skip_unknown_pack_warning'
const showPlayTimeFlag: FeatureFlag = 'show_instance_play_time'
type LandingPage = 'Home' | 'Library'
const landingPageOptions: LandingPage[] = ['Home', 'Library']
const messages = defineMessages({
startupAndNavigationTitle: {
id: 'app.behavior-settings.startup-and-navigation.title',
defaultMessage: 'Startup and navigation',
},
contentTitle: {
id: 'app.behavior-settings.content.title',
defaultMessage: 'Home and content',
},
confirmationsTitle: {
id: 'app.behavior-settings.confirmations.title',
defaultMessage: 'Confirmations',
},
minimizeLauncherTitle: {
id: 'app.appearance-settings.minimize-launcher.title',
defaultMessage: 'Minimize app',
},
minimizeLauncherDescription: {
id: 'app.appearance-settings.minimize-launcher.description',
defaultMessage: 'Minimize Modrinth App when Minecraft starts.',
},
defaultLandingPageTitle: {
id: 'app.appearance-settings.default-landing-page.title',
defaultMessage: 'Default landing page',
},
defaultLandingPageDescription: {
id: 'app.appearance-settings.default-landing-page.description',
defaultMessage: 'Choose the page shown when Modrinth App opens.',
},
defaultLandingPageHome: {
id: 'app.appearance-settings.default-landing-page.home',
defaultMessage: 'Home',
},
defaultLandingPageLibrary: {
id: 'app.appearance-settings.default-landing-page.library',
defaultMessage: 'Library',
},
toggleSidebarTitle: {
id: 'app.appearance-settings.toggle-sidebar.title',
defaultMessage: 'Hide right sidebar',
},
toggleSidebarDescription: {
id: 'app.appearance-settings.toggle-sidebar.description',
defaultMessage: 'Hide the right sidebar by default and add a button to show or hide it.',
},
jumpBackIntoWorldsTitle: {
id: 'app.appearance-settings.jump-back-into-worlds.title',
defaultMessage: 'Jump back into worlds',
},
jumpBackIntoWorldsDescription: {
id: 'app.appearance-settings.jump-back-into-worlds.description',
defaultMessage: 'Show recently played worlds in the "Jump back in" section on the Home page.',
},
showPlayTimeTitle: {
id: 'app.appearance-settings.show-play-time.title',
defaultMessage: 'Show play time',
},
showPlayTimeDescription: {
id: 'app.appearance-settings.show-play-time.description',
defaultMessage: `Show how long you've played each instance.`,
},
hideNametagTitle: {
id: 'app.appearance-settings.hide-nametag.title',
defaultMessage: 'Hide nametag',
},
hideNametagDescription: {
id: 'app.appearance-settings.hide-nametag.description',
defaultMessage: 'Hide your username above the player preview on the Skin selector page.',
},
unknownPackWarningTitle: {
id: 'app.appearance-settings.unknown-pack-warning.title',
defaultMessage: 'Warn me before installing unknown modpacks',
},
unknownPackWarningDescription: {
id: 'app.appearance-settings.unknown-pack-warning.description',
defaultMessage:
"Show a safety warning before installing a Modrinth Pack (.mrpack) that isn't hosted on Modrinth.",
},
skipNonEssentialWarningsTitle: {
id: 'app.appearance-settings.skip-non-essential-warnings.title',
defaultMessage: 'Skip non-essential warnings',
},
skipNonEssentialWarningsDescription: {
id: 'app.appearance-settings.skip-non-essential-warnings.description',
defaultMessage:
'Skip confirmations for low-risk actions such as duplicate installs, normal content deletion, bulk updates, unlinking, and repairs. Warnings for dangerous actions are always shown.',
},
})
function formatLandingPageLabel(page: LandingPage) {
switch (page) {
case 'Home':
return formatMessage(messages.defaultLandingPageHome)
case 'Library':
return formatMessage(messages.defaultLandingPageLibrary)
}
}
const settings = ref(await get())
watch(
settings,
async () => {
await set(settings.value)
},
{ deep: true },
)
</script>
<template>
<section>
<h2 class="m-0 text-xl font-semibold text-contrast">
{{ formatMessage(messages.startupAndNavigationTitle) }}
</h2>
<div class="mt-4 flex flex-col gap-6">
<div class="flex flex-col gap-2.5">
<h3 class="m-0 text-lg font-semibold text-contrast">
{{ formatMessage(messages.defaultLandingPageTitle) }}
</h3>
<Chips
v-model="settings.default_page"
:items="landingPageOptions"
:format-label="formatLandingPageLabel"
:capitalize="false"
:aria-label="formatMessage(messages.defaultLandingPageTitle)"
/>
<p class="m-0">
{{ formatMessage(messages.defaultLandingPageDescription) }}
</p>
</div>
<div class="flex items-center justify-between gap-4">
<div>
<h3 class="m-0 text-lg font-semibold text-contrast">
{{ formatMessage(messages.minimizeLauncherTitle) }}
</h3>
<p class="m-0 mt-1">
{{ formatMessage(messages.minimizeLauncherDescription) }}
</p>
</div>
<Toggle id="minimize-launcher" v-model="settings.hide_on_process_start" />
</div>
<div class="flex items-center justify-between gap-4">
<div>
<h3 class="m-0 text-lg font-semibold text-contrast">
{{ formatMessage(messages.toggleSidebarTitle) }}
</h3>
<p class="m-0 mt-1">{{ formatMessage(messages.toggleSidebarDescription) }}</p>
</div>
<Toggle
id="toggle-sidebar"
:model-value="settings.toggle_sidebar"
@update:model-value="
(e) => {
settings.toggle_sidebar = !!e
themeStore.toggleSidebar = settings.toggle_sidebar
}
"
/>
</div>
</div>
</section>
<section class="mt-8 border-0 border-t border-solid border-divider pt-6">
<h2 class="m-0 text-xl font-semibold text-contrast">
{{ formatMessage(messages.contentTitle) }}
</h2>
<div class="mt-4 flex flex-col gap-6">
<div class="flex items-center justify-between gap-4">
<div>
<h3 class="m-0 text-lg font-semibold text-contrast">
{{ formatMessage(messages.jumpBackIntoWorldsTitle) }}
</h3>
<p class="m-0 mt-1">
{{ formatMessage(messages.jumpBackIntoWorldsDescription) }}
</p>
</div>
<Toggle
id="jump-back-into-worlds"
:model-value="themeStore.getFeatureFlag(worldsInHomeFlag)"
@update:model-value="
() => {
const newValue = !themeStore.getFeatureFlag(worldsInHomeFlag)
themeStore.featureFlags[worldsInHomeFlag] = newValue
settings.feature_flags[worldsInHomeFlag] = newValue
}
"
/>
</div>
<div class="flex items-center justify-between gap-4">
<div>
<h3 class="m-0 text-lg font-semibold text-contrast">
{{ formatMessage(messages.showPlayTimeTitle) }}
</h3>
<p class="m-0 mt-1">{{ formatMessage(messages.showPlayTimeDescription) }}</p>
</div>
<Toggle
id="show-play-time"
:model-value="themeStore.getFeatureFlag(showPlayTimeFlag)"
@update:model-value="
() => {
const newValue = !themeStore.getFeatureFlag(showPlayTimeFlag)
themeStore.featureFlags[showPlayTimeFlag] = newValue
settings.feature_flags[showPlayTimeFlag] = newValue
}
"
/>
</div>
<div class="flex items-center justify-between gap-4">
<div>
<h3 class="m-0 text-lg font-semibold text-contrast">
{{ formatMessage(messages.hideNametagTitle) }}
</h3>
<p class="m-0 mt-1">{{ formatMessage(messages.hideNametagDescription) }}</p>
</div>
<Toggle
id="hide-nametag-skins-page"
:model-value="themeStore.hideNametagSkinsPage"
@update:model-value="
(e) => {
themeStore.hideNametagSkinsPage = !!e
settings.hide_nametag_skins_page = themeStore.hideNametagSkinsPage
}
"
/>
</div>
</div>
</section>
<section class="mt-8 border-0 border-t border-solid border-divider pt-6">
<h2 class="m-0 text-xl font-semibold text-contrast">
{{ formatMessage(messages.confirmationsTitle) }}
</h2>
<div class="mt-4 flex flex-col gap-6">
<div class="flex items-center justify-between gap-4">
<div>
<h3 class="m-0 text-lg font-semibold text-contrast">
{{ formatMessage(messages.unknownPackWarningTitle) }}
</h3>
<p class="m-0 mt-1">
{{ formatMessage(messages.unknownPackWarningDescription) }}
</p>
</div>
<Toggle
id="warn-before-installing-unknown-modpacks"
:model-value="!themeStore.getFeatureFlag(skipUnknownPackWarningFlag)"
@update:model-value="
(e) => {
const warnBeforeUnknownPackInstall = !!e
const skipUnknownPackWarning = !warnBeforeUnknownPackInstall
themeStore.featureFlags[skipUnknownPackWarningFlag] = skipUnknownPackWarning
settings.feature_flags[skipUnknownPackWarningFlag] = skipUnknownPackWarning
}
"
/>
</div>
<div class="flex items-center justify-between gap-4">
<div>
<h3 class="m-0 text-lg font-semibold text-contrast">
{{ formatMessage(messages.skipNonEssentialWarningsTitle) }}
</h3>
<p class="m-0 mt-1">
{{ formatMessage(messages.skipNonEssentialWarningsDescription) }}
</p>
</div>
<Toggle
id="skip-non-essential-warnings"
:model-value="themeStore.getFeatureFlag(skipNonEssentialWarningsFlag)"
@update:model-value="
() => {
const newValue = !themeStore.getFeatureFlag(skipNonEssentialWarningsFlag)
themeStore.featureFlags[skipNonEssentialWarningsFlag] = newValue
settings.feature_flags[skipNonEssentialWarningsFlag] = newValue
}
"
/>
</div>
</div>
</section>
</template>
@@ -1,5 +1,5 @@
<script setup lang="ts">
import { ButtonStyled, Toggle } from '@modrinth/ui'
import { Button, Toggle } from '@modrinth/ui'
import { ref, watch } from 'vue'
import { get as getSettings, set as setSettings } from '@/helpers/settings.ts'
@@ -25,7 +25,7 @@ watch(
)
</script>
<template>
<div class="flex flex-col gap-2.5 min-w-[600px]">
<div class="flex flex-col gap-2.5">
<div v-for="option in options" :key="option" class="flex items-center justify-between">
<div>
<h2 class="m-0 text-lg font-semibold text-contrast capitalize">
@@ -33,14 +33,13 @@ watch(
</h2>
</div>
<div class="flex items-center gap-2">
<ButtonStyled type="transparent">
<button
:disabled="themeStore.getFeatureFlag(option) === DEFAULT_FEATURE_FLAGS[option]"
@click="setFeatureFlag(option, DEFAULT_FEATURE_FLAGS[option])"
>
Reset to default
</button>
</ButtonStyled>
<Button
type="quiet"
:disabled="themeStore.getFeatureFlag(option) === DEFAULT_FEATURE_FLAGS[option]"
@click="setFeatureFlag(option, DEFAULT_FEATURE_FLAGS[option])"
>
Reset to default
</Button>
<Toggle
id="advanced-rendering"
:model-value="themeStore.getFeatureFlag(option)"
@@ -2,6 +2,7 @@
import {
Admonition,
AutoLink,
commonSettingsMessages,
IntlFormatted,
LanguageSelector,
languageSelectorMessages,
@@ -43,7 +44,9 @@ async function onLocaleChange(newLocale: string) {
</script>
<template>
<h2 class="m-0 text-lg font-semibold text-contrast">Language</h2>
<h2 class="m-0 text-lg font-semibold text-contrast">
{{ formatMessage(commonSettingsMessages.language) }}
</h2>
<Admonition type="warning" class="mt-2 mb-4">
{{ formatMessage(languageSelectorMessages.languageWarning, { platform }) }}
@@ -0,0 +1,374 @@
<script setup lang="ts">
import {
defineMessages,
injectNotificationManager,
Slider,
StyledInput,
Toggle,
useVIntl,
} from '@modrinth/ui'
import { ref, watch } from 'vue'
import useMemorySlider from '@/composables/useMemorySlider'
import { get, set } from '@/helpers/settings.ts'
const { handleError } = injectNotificationManager()
const { formatMessage } = useVIntl()
const messages = defineMessages({
fullscreenTitle: {
id: 'app.settings.default-instance-options.fullscreen.title',
defaultMessage: 'Fullscreen',
},
fullscreenDescription: {
id: 'app.settings.default-instance-options.fullscreen.description',
defaultMessage: 'Start instances in fullscreen by updating their options.txt file.',
},
widthTitle: {
id: 'app.settings.default-instance-options.width.title',
defaultMessage: 'Width',
},
widthDescription: {
id: 'app.settings.default-instance-options.width.description',
defaultMessage: 'The width of the game window when launched.',
},
widthPlaceholder: {
id: 'app.settings.default-instance-options.width.placeholder',
defaultMessage: 'Enter width...',
},
heightTitle: {
id: 'app.settings.default-instance-options.height.title',
defaultMessage: 'Height',
},
heightDescription: {
id: 'app.settings.default-instance-options.height.description',
defaultMessage: 'The height of the game window when launched.',
},
heightPlaceholder: {
id: 'app.settings.default-instance-options.height.placeholder',
defaultMessage: 'Enter height...',
},
memoryAllocationTitle: {
id: 'app.settings.default-instance-options.memory-allocation.title',
defaultMessage: 'Memory allocation',
},
memoryAllocationDescription: {
id: 'app.settings.default-instance-options.memory-allocation.description',
defaultMessage: 'Maximum memory available to each instance.',
},
javaArgumentsTitle: {
id: 'app.settings.default-instance-options.java-arguments.title',
defaultMessage: 'Java arguments',
},
javaArgumentsPlaceholder: {
id: 'app.settings.default-instance-options.java-arguments.placeholder',
defaultMessage: 'Enter Java arguments...',
},
javaArgumentsDescription: {
id: 'app.settings.default-instance-options.java-arguments.description',
defaultMessage: 'Arguments passed to Java when launching an instance.',
},
environmentVariablesTitle: {
id: 'app.settings.default-instance-options.environment-variables.title',
defaultMessage: 'Environment variables',
},
environmentVariablesPlaceholder: {
id: 'app.settings.default-instance-options.environment-variables.placeholder',
defaultMessage: 'Enter environment variables...',
},
environmentVariablesDescription: {
id: 'app.settings.default-instance-options.environment-variables.description',
defaultMessage: 'Environment variables set when launching an instance.',
},
preLaunchHookTitle: {
id: 'app.settings.default-instance-options.pre-launch-hook.title',
defaultMessage: 'Pre-launch hook',
},
preLaunchHookPlaceholder: {
id: 'app.settings.default-instance-options.pre-launch-hook.placeholder',
defaultMessage: 'Enter pre-launch command...',
},
preLaunchHookDescription: {
id: 'app.settings.default-instance-options.pre-launch-hook.description',
defaultMessage: 'Runs before the instance starts.',
},
wrapperHookTitle: {
id: 'app.settings.default-instance-options.wrapper-hook.title',
defaultMessage: 'Wrapper hook',
},
wrapperHookPlaceholder: {
id: 'app.settings.default-instance-options.wrapper-hook.placeholder',
defaultMessage: 'Enter wrapper command...',
},
wrapperHookDescription: {
id: 'app.settings.default-instance-options.wrapper-hook.description',
defaultMessage: 'Command used to wrap the Minecraft launch process.',
},
postExitHookTitle: {
id: 'app.settings.default-instance-options.post-exit-hook.title',
defaultMessage: 'Post-exit hook',
},
postExitHookPlaceholder: {
id: 'app.settings.default-instance-options.post-exit-hook.placeholder',
defaultMessage: 'Enter post-exit command...',
},
postExitHookDescription: {
id: 'app.settings.default-instance-options.post-exit-hook.description',
defaultMessage: 'Runs after the game closes.',
},
hookVariablesDescription: {
id: 'instance.settings.tabs.hooks.variables.description',
defaultMessage:
'Hooks run in the working directory of the instance, with the following variables:',
},
instanceNameDescription: {
id: 'instance.settings.tabs.hooks.variables.inst-name.description',
defaultMessage: '$INST_NAME: The name of the instance',
},
instanceIdDescription: {
id: 'instance.settings.tabs.hooks.variables.inst-id.description',
defaultMessage: "$INST_ID: The name of the instance's folder",
},
instanceDirDescription: {
id: 'instance.settings.tabs.hooks.variables.inst-dir.description',
defaultMessage: "$INST_DIR: The absolute path to the instance's folder",
},
instanceMcDirDescription: {
id: 'instance.settings.tabs.hooks.variables.inst-mc-dir.description',
defaultMessage: '$INST_MC_DIR: An alias for $INST_DIR',
},
instanceJavaDescription: {
id: 'instance.settings.tabs.hooks.variables.inst-java.description',
defaultMessage: '$INST_JAVA: The absolute path to the java binary',
},
instanceJavaArgsDescription: {
id: 'instance.settings.tabs.hooks.variables.inst-java-args.description',
defaultMessage: '$INST_JAVA_ARGS: The JVM Arguments provided to the game',
},
})
const fetchSettings = await get()
fetchSettings.launchArgs = fetchSettings.extra_launch_args.join(' ')
fetchSettings.envVars = fetchSettings.custom_env_vars.map((x) => x.join('=')).join(' ')
const settings = ref(fetchSettings)
const { maxMemory, snapPoints } = (await useMemorySlider().catch(handleError)) as unknown as {
maxMemory: number
snapPoints: number[]
}
watch(
settings,
async () => {
const setSettings = JSON.parse(JSON.stringify(settings.value))
setSettings.extra_launch_args = setSettings.launchArgs.trim().split(/\s+/).filter(Boolean)
setSettings.custom_env_vars = setSettings.envVars
.trim()
.split(/\s+/)
.filter(Boolean)
.map((x) => x.split('=').filter(Boolean))
if (!setSettings.hooks.pre_launch) {
setSettings.hooks.pre_launch = null
}
if (!setSettings.hooks.wrapper) {
setSettings.hooks.wrapper = null
}
if (!setSettings.hooks.post_exit) {
setSettings.hooks.post_exit = null
}
if (!setSettings.custom_dir) {
setSettings.custom_dir = null
}
await set(setSettings)
},
{ deep: true },
)
</script>
<template>
<div>
<div class="flex flex-col gap-6">
<div class="flex items-center justify-between gap-4">
<div class="flex flex-col gap-1">
<h3 class="m-0 text-lg font-semibold text-contrast">
{{ formatMessage(messages.fullscreenTitle) }}
</h3>
<p class="m-0 leading-tight">
{{ formatMessage(messages.fullscreenDescription) }}
</p>
</div>
<Toggle id="fullscreen" v-model="settings.force_fullscreen" />
</div>
<div class="flex items-center justify-between gap-4">
<div class="flex flex-col gap-1">
<h3 class="m-0 text-lg font-semibold text-contrast">
{{ formatMessage(messages.widthTitle) }}
</h3>
<p class="m-0 leading-tight">
{{ formatMessage(messages.widthDescription) }}
</p>
</div>
<StyledInput
id="width"
v-model="settings.game_resolution[0]"
:disabled="settings.force_fullscreen"
autocomplete="off"
type="number"
:placeholder="formatMessage(messages.widthPlaceholder)"
/>
</div>
<div class="flex items-center justify-between gap-4">
<div class="flex flex-col gap-1">
<h3 class="m-0 text-lg font-semibold text-contrast">
{{ formatMessage(messages.heightTitle) }}
</h3>
<p class="m-0 leading-tight">
{{ formatMessage(messages.heightDescription) }}
</p>
</div>
<StyledInput
id="height"
v-model="settings.game_resolution[1]"
:disabled="settings.force_fullscreen"
autocomplete="off"
type="number"
:placeholder="formatMessage(messages.heightPlaceholder)"
/>
</div>
</div>
<hr class="my-6 bg-button-border border-none h-[1px]" />
<div class="flex flex-col gap-6">
<div class="flex flex-col gap-2.5">
<h2 class="m-0 text-lg font-semibold text-contrast">
{{ formatMessage(messages.memoryAllocationTitle) }}
</h2>
<Slider
id="max-memory"
v-model="settings.memory.maximum"
:min="512"
:max="maxMemory"
:step="64"
:snap-points="snapPoints"
:snap-range="512"
unit="MB"
/>
<p class="m-0 mt-1 leading-tight">
{{ formatMessage(messages.memoryAllocationDescription) }}
</p>
</div>
<div class="flex flex-col gap-2.5">
<h2 class="m-0 text-lg font-semibold text-contrast">
{{ formatMessage(messages.javaArgumentsTitle) }}
</h2>
<StyledInput
id="java-args"
v-model="settings.launchArgs"
autocomplete="off"
type="text"
:placeholder="formatMessage(messages.javaArgumentsPlaceholder)"
wrapper-class="w-full"
/>
<p class="m-0 leading-tight">
{{ formatMessage(messages.javaArgumentsDescription) }}
</p>
</div>
<div class="flex flex-col gap-2.5">
<h2 class="m-0 text-lg font-semibold text-contrast">
{{ formatMessage(messages.environmentVariablesTitle) }}
</h2>
<StyledInput
id="env-vars"
v-model="settings.envVars"
autocomplete="off"
type="text"
:placeholder="formatMessage(messages.environmentVariablesPlaceholder)"
wrapper-class="w-full"
/>
<p class="m-0 leading-tight">
{{ formatMessage(messages.environmentVariablesDescription) }}
</p>
</div>
</div>
<hr class="my-6 bg-button-border border-none h-[1px]" />
<div class="flex flex-col gap-6">
<div class="flex flex-col gap-2.5">
<h3 class="m-0 text-lg font-semibold text-contrast">
{{ formatMessage(messages.preLaunchHookTitle) }}
</h3>
<StyledInput
id="pre-launch"
v-model="settings.hooks.pre_launch"
autocomplete="off"
type="text"
:placeholder="formatMessage(messages.preLaunchHookPlaceholder)"
wrapper-class="w-full"
/>
<p class="m-0 leading-tight">
{{ formatMessage(messages.preLaunchHookDescription) }}
</p>
</div>
<div class="flex flex-col gap-2.5">
<h3 class="m-0 text-lg font-semibold text-contrast">
{{ formatMessage(messages.wrapperHookTitle) }}
</h3>
<StyledInput
id="wrapper"
v-model="settings.hooks.wrapper"
autocomplete="off"
type="text"
:placeholder="formatMessage(messages.wrapperHookPlaceholder)"
wrapper-class="w-full"
/>
<p class="m-0 leading-tight">
{{ formatMessage(messages.wrapperHookDescription) }}
</p>
</div>
<div class="flex flex-col gap-2.5">
<h3 class="m-0 text-lg font-semibold text-contrast">
{{ formatMessage(messages.postExitHookTitle) }}
</h3>
<StyledInput
id="post-exit"
v-model="settings.hooks.post_exit"
autocomplete="off"
type="text"
:placeholder="formatMessage(messages.postExitHookPlaceholder)"
wrapper-class="w-full"
/>
<p class="m-0 leading-tight">
{{ formatMessage(messages.postExitHookDescription) }}
</p>
</div>
<div class="m-0 leading-tight">
{{ formatMessage(messages.hookVariablesDescription) }}
<ul>
<li>{{ formatMessage(messages.instanceNameDescription) }}</li>
<li>{{ formatMessage(messages.instanceIdDescription) }}</li>
<li>{{ formatMessage(messages.instanceDirDescription) }}</li>
<li>{{ formatMessage(messages.instanceMcDirDescription) }}</li>
<li>{{ formatMessage(messages.instanceJavaDescription) }}</li>
<li>{{ formatMessage(messages.instanceJavaArgsDescription) }}</li>
</ul>
</div>
</div>
</div>
</template>
@@ -1,11 +1,19 @@
<script setup>
import { injectNotificationManager } from '@modrinth/ui'
import { defineMessages, injectNotificationManager, useVIntl } from '@modrinth/ui'
import { ref } from 'vue'
import JavaSelector from '@/components/ui/JavaSelector.vue'
import { get_java_versions, set_java_version } from '@/helpers/jre'
const { handleError } = injectNotificationManager()
const { formatMessage } = useVIntl()
const messages = defineMessages({
javaLocation: {
id: 'app.settings.java-installations.location.title',
defaultMessage: 'Java {version, number} location',
},
})
const javaVersions = ref(await get_java_versions().catch(handleError))
async function updateJavaVersion(version) {
@@ -28,7 +36,7 @@ async function updateJavaVersion(version) {
class="flex flex-col gap-2.5"
>
<h2 class="m-0 text-lg font-semibold text-contrast" :class="{ 'mt-4': index !== 0 }">
Java {{ javaVersion }} location
{{ formatMessage(messages.javaLocation, { version: javaVersion }) }}
</h2>
<JavaSelector
:id="'java-selector-' + javaVersion"
@@ -0,0 +1,291 @@
<script setup>
import { BoxIcon, FolderOpenIcon, FolderSearchIcon, TrashIcon } from '@modrinth/assets'
import {
Button,
defineMessages,
IconButton,
injectNotificationManager,
Slider,
StyledInput,
Toggle,
useVIntl,
} from '@modrinth/ui'
import { open } from '@tauri-apps/plugin-dialog'
import { ref, watch } from 'vue'
import ConfirmModalWrapper from '@/components/ui/modal/ConfirmModalWrapper.vue'
import { purge_cache_types } from '@/helpers/cache.js'
import { get, set } from '@/helpers/settings.ts'
import { showAppDbBackupsFolder } from '@/helpers/utils.js'
import { useTheming } from '@/store/state'
const { handleError } = injectNotificationManager()
const { formatMessage } = useVIntl()
const themeStore = useTheming()
const settings = ref(await get())
const purgeCacheConfirmModal = ref(null)
const alwaysShowCopyDetailsFlag = 'always_show_copy_details'
const messages = defineMessages({
appDirectoryTitle: {
id: 'app.settings.resource-management.app-directory.title',
defaultMessage: 'App directory',
},
appDirectoryDescription: {
id: 'app.settings.resource-management.app-directory.description',
defaultMessage:
'Where Modrinth App stores instances and other files. Changes take effect after restarting the app.',
},
selectAppDirectory: {
id: 'app.settings.resource-management.app-directory.select',
defaultMessage: 'Select a new app directory',
},
browseAppDirectory: {
id: 'app.settings.resource-management.app-directory.browse',
defaultMessage: 'Browse for an app directory',
},
appCacheTitle: {
id: 'app.settings.resource-management.app-cache.title',
defaultMessage: 'App cache',
},
purgeCache: {
id: 'app.settings.resource-management.app-cache.purge',
defaultMessage: 'Purge cache',
},
purgeCacheConfirmTitle: {
id: 'app.settings.resource-management.app-cache.confirm.title',
defaultMessage: 'Purge the app cache?',
},
purgeCacheConfirmDescription: {
id: 'app.settings.resource-management.app-cache.confirm.description',
defaultMessage: 'The app may load more slowly until the cache is rebuilt.',
},
appCacheDescription: {
id: 'app.settings.resource-management.app-cache.description',
defaultMessage:
'Clear cached data and download it again from Modrinth. The app may load more slowly until the cache is rebuilt.',
},
maximumConcurrentDownloadsTitle: {
id: 'app.settings.resource-management.maximum-concurrent-downloads.title',
defaultMessage: 'Maximum concurrent downloads',
},
maximumConcurrentDownloadsDescription: {
id: 'app.settings.resource-management.maximum-concurrent-downloads.description',
defaultMessage:
'Number of files the app can download at once. Lower this if downloads are unreliable on your connection. Requires an app restart.',
},
maximumConcurrentWritesTitle: {
id: 'app.settings.resource-management.maximum-concurrent-writes.title',
defaultMessage: 'Maximum concurrent writes',
},
maximumConcurrentWritesDescription: {
id: 'app.settings.resource-management.maximum-concurrent-writes.description',
defaultMessage:
'Number of files the app can write to disk at once. Lower this if you frequently encounter I/O errors. Requires an app restart.',
},
alwaysShowCopyDetailsTitle: {
id: 'app.settings.resource-management.always-show-copy-details.title',
defaultMessage: 'Always show copy details',
},
alwaysShowCopyDetailsDescription: {
id: 'app.settings.resource-management.always-show-copy-details.description',
defaultMessage:
'Show the Copy details action while an install is queued or running. It is always available for failed or interrupted installs.',
},
appDatabaseBackupsTitle: {
id: 'app.settings.resource-management.app-database-backups.title',
defaultMessage: 'App database backups',
},
openBackupsFolder: {
id: 'app.settings.resource-management.app-database-backups.open-folder',
defaultMessage: 'Open backups folder',
},
appDatabaseBackupsDescription: {
id: 'app.settings.resource-management.app-database-backups.description',
defaultMessage:
'Backups of important app data are stored here in case you need to recover them later.',
},
})
watch(
settings,
async () => {
const setSettings = JSON.parse(JSON.stringify(settings.value))
if (!setSettings.custom_dir) {
setSettings.custom_dir = null
}
await set(setSettings)
},
{ deep: true },
)
async function purgeCache() {
await purge_cache_types([
'project',
'project_v3',
'version',
'user',
'team',
'organization',
'file',
'loader_manifest',
'minecraft_manifest',
'categories',
'report_types',
'loaders',
'game_versions',
'donation_platforms',
'file_hash',
'file_update',
'search_results',
'search_results_v3',
]).catch(handleError)
}
function handlePurgeCacheClick() {
if (themeStore.getFeatureFlag('skip_non_essential_warnings')) {
void purgeCache()
return
}
purgeCacheConfirmModal.value?.show()
}
async function openDbBackupsFolder() {
await showAppDbBackupsFolder().catch(handleError)
}
async function findLauncherDir() {
const newDir = await open({
multiple: false,
directory: true,
title: formatMessage(messages.selectAppDirectory),
})
if (newDir) {
settings.value.custom_dir = newDir
}
}
</script>
<template>
<div class="flex flex-col gap-6">
<div class="flex flex-col gap-2.5">
<h2 class="m-0 text-lg font-semibold text-contrast">
{{ formatMessage(messages.appDirectoryTitle) }}
</h2>
<StyledInput
id="appDir"
v-model="settings.custom_dir"
:icon="BoxIcon"
type="text"
wrapper-class="w-full"
>
<template #right>
<IconButton
v-tooltip="formatMessage(messages.browseAppDirectory)"
:label="formatMessage(messages.browseAppDirectory)"
class="ml-1.5"
@click="findLauncherDir"
>
<FolderSearchIcon aria-hidden="true" />
</IconButton>
</template>
</StyledInput>
<p class="m-0 leading-tight text-secondary">
{{ formatMessage(messages.appDirectoryDescription) }}
</p>
</div>
<div class="flex items-center justify-between gap-4">
<div>
<h2 class="m-0 text-lg font-semibold text-contrast">
{{ formatMessage(messages.alwaysShowCopyDetailsTitle) }}
</h2>
<p class="m-0 mt-1">
{{ formatMessage(messages.alwaysShowCopyDetailsDescription) }}
</p>
</div>
<Toggle
id="always-show-copy-details"
:model-value="themeStore.getFeatureFlag(alwaysShowCopyDetailsFlag)"
@update:model-value="
() => {
const newValue = !themeStore.getFeatureFlag(alwaysShowCopyDetailsFlag)
themeStore.featureFlags[alwaysShowCopyDetailsFlag] = newValue
settings.feature_flags[alwaysShowCopyDetailsFlag] = newValue
}
"
/>
</div>
<div class="flex flex-col gap-2.5">
<ConfirmModalWrapper
ref="purgeCacheConfirmModal"
:title="formatMessage(messages.purgeCacheConfirmTitle)"
:description="formatMessage(messages.purgeCacheConfirmDescription)"
:has-to-type="false"
:proceed-label="formatMessage(messages.purgeCache)"
:show-ad-on-close="false"
@proceed="purgeCache"
/>
<h2 class="m-0 text-lg font-semibold text-contrast">
{{ formatMessage(messages.appCacheTitle) }}
</h2>
<Button id="purge-cache" class="w-fit" @click="handlePurgeCacheClick">
<TrashIcon aria-hidden="true" />
{{ formatMessage(messages.purgeCache) }}
</Button>
<p class="m-0 leading-tight text-secondary">
{{ formatMessage(messages.appCacheDescription) }}
</p>
</div>
<div class="flex flex-col gap-2.5">
<h2 class="m-0 text-lg font-semibold text-contrast mt-4">
{{ formatMessage(messages.maximumConcurrentDownloadsTitle) }}
</h2>
<Slider
id="max-downloads"
v-model="settings.max_concurrent_downloads"
:min="1"
:max="10"
:step="1"
/>
<p class="m-0 leading-tight text-secondary">
{{ formatMessage(messages.maximumConcurrentDownloadsDescription) }}
</p>
</div>
<div class="flex flex-col gap-2.5">
<h2 class="mt-0 m-0 text-lg font-semibold text-contrast">
{{ formatMessage(messages.maximumConcurrentWritesTitle) }}
</h2>
<Slider
id="max-writes"
v-model="settings.max_concurrent_writes"
:min="1"
:max="50"
:step="1"
/>
<p class="m-0 leading-tight text-secondary">
{{ formatMessage(messages.maximumConcurrentWritesDescription) }}
</p>
</div>
<div class="flex flex-col gap-2.5">
<h2 class="mt-0 m-0 text-lg font-semibold text-contrast">
{{ formatMessage(messages.appDatabaseBackupsTitle) }}
</h2>
<Button id="open-db-backups-folder" class="w-fit" @click="openDbBackupsFolder">
<FolderOpenIcon aria-hidden="true" />
{{ formatMessage(messages.openBackupsFolder) }}
</Button>
<p class="m-0 leading-tight text-secondary">
{{ formatMessage(messages.appDatabaseBackupsDescription) }}
</p>
</div>
</div>
</template>
@@ -10,24 +10,18 @@
<template #actions>
<div class="flex justify-end gap-2">
<ButtonStyled type="outlined">
<button @click="handleCancel">
<XIcon />
{{ formatMessage(commonMessages.cancelButton) }}
</button>
</ButtonStyled>
<ButtonStyled>
<button @click="handleGoToInstance">
{{ formatMessage(messages.instance) }}
<RightArrowIcon />
</button>
</ButtonStyled>
<ButtonStyled color="orange">
<button @click="handleInstallAnyway">
<DownloadIcon />
{{ formatMessage(messages.installAnyway) }}
</button>
</ButtonStyled>
<Button type="outlined" @click="handleCancel">
<XIcon />
{{ formatMessage(commonMessages.cancelButton) }}
</Button>
<Button @click="handleGoToInstance">
{{ formatMessage(messages.instance) }}
<RightArrowIcon />
</Button>
<Button type="colored" color="orange" @click="handleInstallAnyway">
<DownloadIcon />
{{ formatMessage(messages.installAnyway) }}
</Button>
</div>
</template>
</NewModal>
@@ -36,7 +30,7 @@
<script setup lang="ts">
import { DownloadIcon, RightArrowIcon, XIcon } from '@modrinth/assets'
import {
ButtonStyled,
Button,
commonMessages,
defineMessages,
IntlFormatted,
@@ -26,12 +26,14 @@
</span>
</template>
<div class="flex min-w-0 flex-col gap-3 pt-4">
<div class="max-h-[292px] overflow-y-auto rounded-[20px]">
<div ref="configFileTreeContainer" class="max-h-[292px] overflow-y-auto rounded-[20px]">
<FileTreeSelect
v-model="selectedConfigPaths"
v-model="includedConfigPaths"
v-model:excluded-paths="excludedConfigPaths"
:items="configFileItems"
:show-size="false"
:show-modified="false"
@navigate="scrollConfigFileTreeToTop"
/>
</div>
</div>
@@ -75,13 +77,23 @@ const emit = defineEmits<{
const { formatMessage } = useVIntl()
const { notifySharedInstanceError, notifySharedInstanceUnavailable } = useSharedInstanceErrors()
const publishReviewModal = ref<InstanceType<typeof ContentDiffModal>>()
const configFileTreeContainer = ref<HTMLElement>()
const publishDiffs = ref<ContentDiffItem[]>([])
const configFilePaths = ref<string[]>([])
const selectedConfigPaths = ref<string[]>([])
const includedConfigPaths = ref<string[]>([])
const excludedConfigPaths = ref<string[]>([])
const state = ref<SharedInstancePublishState>('idle')
const configFileItems = computed<FileTreeSelectItem[]>(() =>
configFilePaths.value.map((path) => ({ path, type: 'file' })),
)
const selectedConfigPaths = computed(() => {
const includedPaths = new Set(includedConfigPaths.value)
const excludedPaths = new Set(excludedConfigPaths.value)
return configFilePaths.value.filter((path) =>
isConfigPathSelected(path, includedPaths, excludedPaths),
)
})
async function show(e?: MouseEvent) {
if (state.value !== 'idle') return
@@ -106,7 +118,8 @@ async function show(e?: MouseEvent) {
disabled: diff.disabled,
}))
configFilePaths.value = preview.configFiles
selectedConfigPaths.value = []
includedConfigPaths.value = []
excludedConfigPaths.value = []
if (!publishReviewModal.value) return
publishReviewModal.value.show(e)
@@ -132,6 +145,29 @@ async function publishChanges() {
}
}
function isConfigPathSelected(
path: string,
includedPaths: Set<string>,
excludedPaths: Set<string>,
) {
let selected = false
let prefix = ''
for (const segment of path.split('/').filter(Boolean)) {
prefix = prefix ? `${prefix}/${segment}` : segment
if (includedPaths.has(prefix)) selected = true
if (excludedPaths.has(prefix)) selected = false
}
return selected
}
function scrollConfigFileTreeToTop() {
if (configFileTreeContainer.value) {
configFileTreeContainer.value.scrollTop = 0
}
}
function finishReview() {
if (state.value === 'reviewing') {
setState('idle')
@@ -43,8 +43,9 @@ import type { GameInstance } from '@/helpers/types'
type UpdateCompleteCallback = () => void | Promise<void>
const emit = defineEmits<{
accepted: []
cancel: []
complete: []
complete: [successful: boolean]
report: [event?: MouseEvent]
sharedInstanceUnavailable: [reason: SharedInstanceUnavailableReason | null]
}>()
@@ -72,11 +73,14 @@ const diffs = computed<ContentDiffItem[]>(
)
async function update() {
let successful = false
emit('accepted')
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()
successful = true
}
} catch (error) {
if (isSharedInstanceUnavailableError(error)) {
@@ -85,7 +89,7 @@ async function update() {
}
notifySharedInstanceError(error)
} finally {
emit('complete')
emit('complete', successful)
}
}
@@ -64,7 +64,29 @@
</div>
</Admonition>
<p v-else class="m-0 text-primary">
{{ formatMessage(messages.inviteWarning) }}
<IntlFormatted
v-if="creator"
:message-id="messages.inviteWarningWithCreator"
:values="{ username: creator.username }"
>
<template #creator="{ children }">
<AutoLink :to="creatorProfileLink" class="font-medium text-contrast hover:underline">
<Avatar
:src="creator.avatarUrl"
:alt="creator.username"
:tint-by="creator.username"
size="24px"
circle
no-shadow
class="mr-1 inline-block align-middle"
/>
<span><component :is="() => children" /></span>
</AutoLink>
</template>
</IntlFormatted>
<template v-else>
{{ formatMessage(messages.inviteWarning) }}
</template>
</p>
<SharedInstanceInstallSummary
:preview="preview"
@@ -98,8 +120,17 @@
:max-height="240"
/>
</div>
<div v-if="reportOnly" class="flex flex-col gap-2">
<Checkbox v-model="deleteInstance" :label="formatMessage(messages.deleteInstance)" />
<div v-if="reportOnly || blockTargetUserId" class="flex flex-col gap-2">
<Checkbox
v-if="reportOnly"
v-model="deleteInstance"
:label="formatMessage(messages.deleteInstance)"
/>
<Checkbox
v-if="blockTargetUserId"
v-model="blockUser"
:label="formatMessage(messages.blockUser)"
/>
</div>
</div>
</Transition>
@@ -162,53 +193,39 @@
{{ formatMessage(messages.reviewedFiles) }}
</p>
<div v-if="!reportMode" class="flex w-full items-center justify-between gap-2">
<ButtonStyled color="red" type="transparent">
<button @click="reportMode = true">
<ReportIcon />{{ formatMessage(commonMessages.reportButton) }}
</button>
</ButtonStyled>
<Button type="quiet" color="red" @click="reportMode = true">
<ReportIcon />{{ formatMessage(commonMessages.reportButton) }}
</Button>
<div class="flex items-center gap-2">
<template v-if="hasExternalFiles">
<ButtonStyled type="transparent" color="orange">
<button @click="accept">
{{ formatMessage(messages.installAnyway) }}
</button>
</ButtonStyled>
<ButtonStyled color="brand">
<button @click="handleCancel">
<BanIcon />{{ formatMessage(messages.dontInstall) }}
</button>
</ButtonStyled>
<Button type="quiet" color="orange" @click="accept">
{{ formatMessage(messages.installAnyway) }}
</Button>
<Button type="colored" color="brand" @click="handleCancel">
<BanIcon />{{ formatMessage(messages.dontInstall) }}
</Button>
</template>
<template v-else>
<ButtonStyled type="outlined">
<button class="!border" @click="handleCancel">
<XIcon />{{ formatMessage(commonMessages.cancelButton) }}
</button>
</ButtonStyled>
<ButtonStyled color="brand">
<button @click="accept">
<DownloadIcon />{{ formatMessage(messages.installButton) }}
</button>
</ButtonStyled>
<Button type="outlined" class="!border" @click="handleCancel">
<XIcon />{{ formatMessage(commonMessages.cancelButton) }}
</Button>
<Button type="colored" color="brand" @click="accept">
<DownloadIcon />{{ formatMessage(messages.installButton) }}
</Button>
</template>
</div>
</div>
</div>
<template v-if="reportMode" #actions>
<div class="flex justify-end gap-2">
<ButtonStyled type="outlined">
<button class="!border" :disabled="submitLoading" @click="handleCancel">
<XIcon />{{ formatMessage(commonMessages.cancelButton) }}
</button>
</ButtonStyled>
<ButtonStyled color="brand">
<button :disabled="!canSubmitReport" @click="submitReport">
<SpinnerIcon v-if="submitLoading" class="animate-spin" />
<SendIcon v-else />
{{ formatMessage(commonMessages.reportButton) }}
</button>
</ButtonStyled>
<Button type="outlined" class="!border" :disabled="submitLoading" @click="handleCancel">
<XIcon />{{ formatMessage(commonMessages.cancelButton) }}
</Button>
<Button type="colored" color="brand" :disabled="!canSubmitReport" @click="submitReport">
<SpinnerIcon v-if="submitLoading" class="animate-spin" />
<SendIcon v-else />
{{ formatMessage(commonMessages.reportButton) }}
</Button>
</div>
</template>
</NewModal>
@@ -223,15 +240,18 @@
<script setup lang="ts">
import type { Labrinth } from '@modrinth/api-client'
import { BanIcon, DownloadIcon, ReportIcon, SendIcon, SpinnerIcon, XIcon } from '@modrinth/assets'
import { Button } from '@modrinth/ui'
import {
Admonition,
AutoLink,
ButtonStyled,
Avatar,
blockedUsersQueryKey,
Checkbox,
Combobox,
type ComboboxOption,
commonMessages,
defineMessages,
injectAuth,
injectModrinthClient,
injectNotificationManager,
IntlFormatted,
@@ -243,12 +263,16 @@ import {
useScrollIndicator,
useVIntl,
} from '@modrinth/ui'
import { useQueryClient } from '@tanstack/vue-query'
import { openUrl } from '@tauri-apps/plugin-opener'
import { computed, nextTick, ref } from 'vue'
import { config } from '@/config'
import { hide_ads_window, show_ads_window } from '@/helpers/ads'
import { toError } from '@/helpers/errors'
import type { SharedInstanceInstallPreview } from '@/helpers/install'
import { create_report } from '@/helpers/reports'
import { block_user } from '@/helpers/users'
import SharedInstanceInstallSummary from './shared-instance-install-summary.vue'
import { useSharedInstancePreviewContent } from './use-shared-instance-preview-content'
@@ -258,11 +282,17 @@ type ExternalFileRow = {
id: string
name: string
}
type SharedInstanceCreator = {
id: string | null
username: string
avatarUrl: string | null
}
const modal = ref<InstanceType<typeof NewModal>>()
const contentModal = ref<InstanceType<typeof ModpackContentModal>>()
const externalFileTable = ref<HTMLElement | null>(null)
const preview = ref<SharedInstanceInstallPreview | null>(null)
const creator = ref<SharedInstanceCreator | null>(null)
const install = ref<() => void | Promise<void>>(() => {})
const reportMode = ref(false)
const reportOnly = ref(false)
@@ -270,13 +300,17 @@ type ReportReason = 'malicious' | 'inappropriate' | 'spam'
const reportReason = ref<ReportReason>('malicious')
const additionalContext = ref('')
const deleteInstance = ref(true)
const blockUser = ref(true)
const blockTargetUserId = ref<string | null>(null)
const submitLoading = ref(false)
const uploadedImageIDs = ref<string[]>([])
const emit = defineEmits<{
reported: [deleteInstance: boolean]
}>()
const { formatMessage } = useVIntl()
const auth = injectAuth()
const client = injectModrinthClient()
const queryClient = useQueryClient()
const { addNotification, handleError } = injectNotificationManager()
const { load } = useSharedInstancePreviewContent()
const {
@@ -302,6 +336,12 @@ const reportReasonOptions = computed<ComboboxOption<ReportReason>[]>(() => [
const canSubmitReport = computed(
() => Boolean(preview.value && additionalContext.value.trim()) && !submitLoading.value,
)
const creatorProfileLink = computed(() => {
const username = creator.value?.username
return username
? () => openUrl(`${config.siteUrl}/user/${encodeURIComponent(username)}`)
: undefined
})
async function accept() {
hide()
@@ -329,13 +369,39 @@ async function submitReport() {
submitLoading.value = true
try {
const uploadedImages = uploadedImageIDs.value.slice(-10)
await create_report({
report_type: reportReason.value,
item_type: 'shared-instance',
item_id: `${reportPreview.sharedInstanceId}/${reportPreview.version}`,
body,
uploaded_images: uploadedImages,
})
const blockTarget = blockUser.value ? blockTargetUserId.value : null
const [reportResult, blockResult] = await Promise.allSettled([
create_report({
report_type: reportReason.value,
item_type: 'shared-instance',
item_id: `${reportPreview.sharedInstanceId}/${reportPreview.version}`,
body,
uploaded_images: uploadedImages,
}),
blockTarget ? block_user(blockTarget) : Promise.resolve(),
])
if (blockTarget) {
if (blockResult.status === 'fulfilled') {
blockUser.value = false
const authUserId = auth.user.value?.id
if (authUserId) {
queryClient.setQueryData<Labrinth.BlockedUsers.v3.BlockedUserId[]>(
blockedUsersQueryKey(authUserId),
(blockedUsers = []) =>
blockedUsers.includes(blockTarget) ? blockedUsers : [...blockedUsers, blockTarget],
)
}
addNotification({
type: 'success',
title: formatMessage(messages.userBlocked),
})
} else {
handleError(toError(blockResult.reason))
}
}
if (reportResult.status === 'rejected') throw reportResult.reason
const shouldDeleteInstance = reportOnly.value && deleteInstance.value
hide()
@@ -381,6 +447,7 @@ function handleCancel() {
}
function handleHide() {
resetReportState()
creator.value = null
show_ads_window()
}
function resetReportState() {
@@ -389,20 +456,31 @@ function resetReportState() {
reportReason.value = 'malicious'
additionalContext.value = ''
deleteInstance.value = true
blockUser.value = true
blockTargetUserId.value = null
submitLoading.value = false
uploadedImageIDs.value = []
}
function show(
previewValue: SharedInstanceInstallPreview,
installValue: () => void | Promise<void>,
creatorValue?: SharedInstanceCreator,
event?: MouseEvent,
) {
resetReportState()
creator.value = creatorValue ?? null
blockTargetUserId.value = creatorValue?.id ?? null
install.value = installValue
showPreview(previewValue, event)
}
function showReport(previewValue: SharedInstanceInstallPreview, event?: MouseEvent) {
function showReport(
previewValue: SharedInstanceInstallPreview,
blockTargetUserIdValue?: string | null,
event?: MouseEvent,
) {
resetReportState()
creator.value = null
blockTargetUserId.value = blockTargetUserIdValue ?? null
reportMode.value = true
reportOnly.value = true
install.value = () => {}
@@ -437,6 +515,11 @@ const messages = defineMessages({
defaultMessage:
'This invite was created by another Modrinth user, not Modrinth. Only accept invites from people you trust.',
},
inviteWarningWithCreator: {
id: 'app.modal.install-to-play.invite-warning-with-creator',
defaultMessage:
'This invite was created by <creator>{username}</creator>, not Modrinth. Only accept invites from people you trust.',
},
reportDescription: {
id: 'app.modal.install-to-play.report-description',
defaultMessage:
@@ -492,6 +575,14 @@ const messages = defineMessages({
id: 'app.modal.install-to-play.delete-instance',
defaultMessage: 'Delete instance',
},
blockUser: {
id: 'app.modal.install-to-play.block-user',
defaultMessage: 'Block user',
},
userBlocked: {
id: 'app.modal.install-to-play.user-blocked',
defaultMessage: 'User blocked',
},
unknownFilesWarning: {
id: 'app.modal.install-to-play.unknown-files-warning',
defaultMessage: 'Unknown files warning',
@@ -4,12 +4,10 @@
<span class="font-semibold text-contrast">{{
heading ?? formatMessage(messages.sharedInstance)
}}</span>
<ButtonStyled type="transparent">
<button @click="emit('viewContents')">
<EyeIcon />
{{ formatMessage(messages.viewContents) }}
</button>
</ButtonStyled>
<Button type="quiet" @click="emit('viewContents')">
<EyeIcon />
{{ formatMessage(messages.viewContents) }}
</Button>
</div>
<div class="flex items-center gap-3 rounded-2xl bg-surface-2 p-3">
<Avatar
@@ -34,7 +32,7 @@
<script setup lang="ts">
import { EyeIcon } from '@modrinth/assets'
import { Avatar, ButtonStyled, defineMessages, formatLoader, useVIntl } from '@modrinth/ui'
import { Avatar, Button, defineMessages, formatLoader, useVIntl } from '@modrinth/ui'
import { computed, toRefs } from 'vue'
import type { SharedInstanceInstallPreview } from '@/helpers/install'
@@ -1,7 +1,7 @@
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 { get_project, 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 }
@@ -20,7 +20,18 @@ export function useSharedInstancePreviewContent() {
async function modpackContentItems(preview: SharedInstanceInstallPreview) {
if (!preview.modpackVersionId) return []
const version = await get_version(preview.modpackVersionId, 'must_revalidate')
return await contentItemsFromDependencies(version?.dependencies ?? [])
if (!version) return []
const [project, contentItems] = await Promise.all([
get_project(version.project_id, 'must_revalidate'),
contentItemsFromDependencies(version.dependencies ?? []),
])
if (!project) return contentItems
return contentItems.map((item) => ({
...item,
source: { project },
}))
}
async function contentItemsFromDependencies(dependencies: Labrinth.Versions.v2.Dependency[]) {
@@ -6,11 +6,9 @@ import {
injectPopupNotificationManager,
} from '@modrinth/ui'
import { useQueryClient } from '@tanstack/vue-query'
import { openUrl } from '@tauri-apps/plugin-opener'
import { type Ref, watch } from 'vue'
import { useRouter } from 'vue-router'
import { config } from '@/config'
import { get_user } from '@/helpers/cache'
import { toError } from '@/helpers/errors'
import {
@@ -19,6 +17,7 @@ import {
install_shared_instance,
} from '@/helpers/install'
import { list } from '@/helpers/instance'
import { useSharedInstanceErrors } from '@/helpers/shared-instance-errors'
import { useTheming } from '@/store/state'
import { parseSharedInstanceInviteNotification } from './shared-instance-invite-parser'
@@ -28,9 +27,16 @@ type InstallModal = {
show(
preview: Awaited<ReturnType<typeof install_get_shared_instance_preview>>,
install: () => Promise<void>,
creator?: SharedInstanceCreator,
): void
}
type SharedInstanceCreator = {
id: string | null
username: string
avatarUrl: string | null
}
type AccountRequiredModal = {
show(event?: MouseEvent): Promise<boolean>
}
@@ -47,6 +53,8 @@ export function useSharedInstanceInviteHandler(
const auth = injectAuth()
const client = injectModrinthClient()
const { handleError } = injectNotificationManager()
const { notifySharedInstanceConnectionError, notifySharedInstanceError } =
useSharedInstanceErrors()
const popupNotificationManager = injectPopupNotificationManager()
const queryClient = useQueryClient()
const router = useRouter()
@@ -60,6 +68,7 @@ export function useSharedInstanceInviteHandler(
instanceId: string
preview: Awaited<ReturnType<typeof install_get_shared_instance_preview>>
install: () => Promise<void>
creator?: SharedInstanceCreator
onGoToInstance?: () => void | Promise<void>
}
| undefined
@@ -75,10 +84,13 @@ export function useSharedInstanceInviteHandler(
async function resolveInvite(invite: SharedInstanceInvite) {
const [invitedBy, sharedInstance] = await Promise.all([
!invite.invitedByUsername && invite.invitedById
(!invite.invitedByUsername || !invite.invitedByAvatarUrl) && invite.invitedById
? get_user(invite.invitedById, 'bypass').catch(() => null)
: null,
client.sharedinstances.instances_v1.get(invite.sharedInstanceId).catch(() => null),
client.sharedinstances.instances_v1.get(invite.sharedInstanceId).catch(() => {
notifySharedInstanceConnectionError()
return null
}),
])
return {
@@ -92,15 +104,17 @@ export function useSharedInstanceInviteHandler(
function showInstall(
preview: Awaited<ReturnType<typeof install_get_shared_instance_preview>>,
install: () => Promise<void>,
creator?: SharedInstanceCreator,
) {
if (!installModal.value) throw new Error('Shared instance install modal is not available.')
installModal.value.show(preview, install)
installModal.value.show(preview, install, creator)
}
async function showInstallOrAlreadyInstalled(
sharedInstanceId: string,
preview: Awaited<ReturnType<typeof install_get_shared_instance_preview>>,
install: () => Promise<void>,
creator?: SharedInstanceCreator,
onGoToInstance?: () => void | Promise<void>,
) {
const existingInstance = (await list()).find(
@@ -108,7 +122,7 @@ export function useSharedInstanceInviteHandler(
)
if (!existingInstance || themeStore.getFeatureFlag('skip_non_essential_warnings')) {
showInstall(preview, install)
showInstall(preview, install, creator)
return
}
@@ -120,6 +134,7 @@ export function useSharedInstanceInviteHandler(
instanceId: existingInstance.id,
preview,
install,
creator,
onGoToInstance,
}
alreadyInstalledModal.value.show(existingInstance.name)
@@ -148,7 +163,7 @@ export function useSharedInstanceInviteHandler(
const pending = pendingAlreadyInstalled
pendingAlreadyInstalled = undefined
if (!pending) return
showInstall(pending.preview, pending.install)
showInstall(pending.preview, pending.install, pending.creator)
}
async function acceptNotification(notification: AppNotification, invite: SharedInstanceInvite) {
@@ -174,10 +189,17 @@ export function useSharedInstanceInviteHandler(
await markNotificationRead(notification)
await queryClient.invalidateQueries({ queryKey: ['instances'] })
},
invite.invitedByUsername
? {
id: invite.invitedById,
username: invite.invitedByUsername,
avatarUrl: invite.invitedByAvatarUrl,
}
: undefined,
() => markNotificationRead(notification),
)
} catch (error) {
handleError(toError(error))
notifySharedInstanceError(error)
}
}
@@ -216,7 +238,7 @@ export function useSharedInstanceInviteHandler(
markNotificationRead(notification).catch((error) => handleError(toError(error))),
onOpenActor: () => {
if (invite.invitedByUsername) {
openUrl(`${config.siteUrl}/user/${encodeURIComponent(invite.invitedByUsername)}`)
void router.push(`/user/${encodeURIComponent(invite.invitedByUsername)}`)
}
},
},
@@ -255,19 +277,33 @@ export function useSharedInstanceInviteHandler(
try {
if (!(await requireAccount())) return
const invite = await install_accept_shared_instance_invite(inviteId)
await showInstallOrAlreadyInstalled(invite.sharedInstanceId, invite.preview, async () => {
await install_shared_instance(
invite.sharedInstanceId,
invite.preview.name,
invite.managerId,
invite.serverManagerName,
invite.serverManagerIconUrl,
invite.instanceIconUrl,
)
await queryClient.invalidateQueries({ queryKey: ['instances'] })
})
const manager = invite.managerId
? await get_user(invite.managerId, 'bypass').catch(() => null)
: null
await showInstallOrAlreadyInstalled(
invite.sharedInstanceId,
invite.preview,
async () => {
await install_shared_instance(
invite.sharedInstanceId,
invite.preview.name,
invite.managerId,
invite.serverManagerName,
invite.serverManagerIconUrl,
invite.instanceIconUrl,
)
await queryClient.invalidateQueries({ queryKey: ['instances'] })
},
manager
? {
id: manager.id,
username: manager.username,
avatarUrl: manager.avatar_url ?? null,
}
: undefined,
)
} catch (error) {
handleError(toError(error))
notifySharedInstanceError(error)
}
}
@@ -22,11 +22,9 @@
<div class="flex flex-col gap-4 w-full min-h-[20rem]">
<section v-if="mode === 'edit' && canEditTextureAndModel">
<h2 class="text-base font-semibold mb-2">{{ formatMessage(messages.textureSection) }}</h2>
<ButtonStyled>
<button class="!shadow-none" @click="openTextureFileBrowser">
<UploadIcon /> {{ formatMessage(messages.replaceTextureButton) }}
</button>
</ButtonStyled>
<Button @click="openTextureFileBrowser">
<UploadIcon /> {{ formatMessage(messages.replaceTextureButton) }}
</Button>
<input
ref="textureFileInput"
type="file"
@@ -112,19 +110,21 @@
<template #actions>
<div class="flex gap-2 justify-end">
<ButtonStyled type="outlined">
<button :disabled="isSaving" @click="hide">
<XIcon />{{ formatMessage(commonMessages.cancelButton) }}
</button>
</ButtonStyled>
<ButtonStyled color="brand">
<button v-tooltip="saveTooltip" :disabled="disableSave || isSaving" @click="save">
<SpinnerIcon v-if="isSaving" class="animate-spin" />
<CheckIcon v-else-if="mode === 'new'" />
<SaveIcon v-else />
{{ formatMessage(mode === 'new' ? messages.addSkinButton : messages.saveSkinButton) }}
</button>
</ButtonStyled>
<Button type="outlined" :disabled="isSaving" @click="hide">
<XIcon />{{ formatMessage(commonMessages.cancelButton) }}
</Button>
<Button
v-tooltip="saveTooltip"
type="colored"
color="brand"
:disabled="disableSave || isSaving"
@click="save"
>
<SpinnerIcon v-if="isSaving" class="animate-spin" />
<CheckIcon v-else-if="mode === 'new'" />
<SaveIcon v-else />
{{ formatMessage(mode === 'new' ? messages.addSkinButton : messages.saveSkinButton) }}
</Button>
</div>
</template>
</NewModal>
@@ -133,7 +133,7 @@
<script setup lang="ts">
import { CheckIcon, SaveIcon, SpinnerIcon, UploadIcon, XIcon } from '@modrinth/assets'
import {
ButtonStyled,
Button,
CapeButton,
CapeLikeTextButton,
commonMessages,
@@ -2,9 +2,10 @@
import { DropdownIcon, EditIcon, PlusIcon, TrashIcon, UnknownIcon } from '@modrinth/assets'
import {
Accordion,
ButtonStyled,
Button,
commonMessages,
defineMessages,
IconButton,
SkinButton,
SkinLikeTextButton,
useScrollViewport,
@@ -463,25 +464,26 @@ defineExpose({ getAddSkinButtonElement })
@select="emit('select', skin)"
>
<template v-if="!readOnly" #overlay-buttons>
<ButtonStyled color="brand">
<button
:aria-label="formatMessage(messages.editSkinButton)"
class="pointer-events-auto"
@click.stop="(event: MouseEvent) => emit('edit', skin, event)"
>
<EditIcon /> {{ formatMessage(commonMessages.editButton) }}
</button>
</ButtonStyled>
<ButtonStyled v-show="!skin.is_equipped" circular color="red">
<button
v-tooltip="formatMessage(messages.deleteSkinButton)"
:aria-label="formatMessage(messages.deleteSkinButton)"
class="!rounded-[100%] pointer-events-auto"
@click.stop="emit('delete', skin)"
>
<TrashIcon />
</button>
</ButtonStyled>
<Button
type="colored"
color="brand"
:aria-label="formatMessage(messages.editSkinButton)"
class="pointer-events-auto"
@click.stop="(event: MouseEvent) => emit('edit', skin, event)"
>
<EditIcon /> {{ formatMessage(commonMessages.editButton) }}
</Button>
<IconButton
v-show="!skin.is_equipped"
v-tooltip="formatMessage(messages.deleteSkinButton)"
type="colored"
color="red"
:label="formatMessage(messages.deleteSkinButton)"
class="!rounded-[100%] pointer-events-auto"
@click.stop="emit('delete', skin)"
>
<TrashIcon />
</IconButton>
</template>
</SkinButton>
</div>
@@ -503,25 +505,26 @@ defineExpose({ getAddSkinButtonElement })
@select="emit('select', skin)"
>
<template v-if="!readOnly" #overlay-buttons>
<ButtonStyled color="brand">
<button
:aria-label="formatMessage(messages.editSkinButton)"
class="pointer-events-auto"
@click.stop="(event: MouseEvent) => emit('edit', skin, event)"
>
<EditIcon /> {{ formatMessage(commonMessages.editButton) }}
</button>
</ButtonStyled>
<ButtonStyled v-show="!skin.is_equipped" circular color="red">
<button
v-tooltip="formatMessage(messages.deleteSkinButton)"
:aria-label="formatMessage(messages.deleteSkinButton)"
class="!rounded-[100%] pointer-events-auto"
@click.stop="emit('delete', skin)"
>
<TrashIcon />
</button>
</ButtonStyled>
<Button
type="colored"
color="brand"
:aria-label="formatMessage(messages.editSkinButton)"
class="pointer-events-auto"
@click.stop="(event: MouseEvent) => emit('edit', skin, event)"
>
<EditIcon /> {{ formatMessage(commonMessages.editButton) }}
</Button>
<IconButton
v-show="!skin.is_equipped"
v-tooltip="formatMessage(messages.deleteSkinButton)"
type="colored"
color="red"
:label="formatMessage(messages.deleteSkinButton)"
class="!rounded-[100%] pointer-events-auto"
@click.stop="emit('delete', skin)"
>
<TrashIcon />
</IconButton>
</template>
</SkinButton>
</div>
@@ -545,15 +548,15 @@ defineExpose({ getAddSkinButtonElement })
@select="emit('select', skin)"
>
<template #overlay-buttons>
<ButtonStyled color="brand">
<button
:aria-label="formatMessage(messages.editSkinButton)"
class="pointer-events-auto"
@click.stop="(event: MouseEvent) => emit('edit', skin, event)"
>
<EditIcon /> {{ formatMessage(commonMessages.editButton) }}
</button>
</ButtonStyled>
<Button
type="colored"
color="brand"
:aria-label="formatMessage(messages.editSkinButton)"
class="pointer-events-auto"
@click.stop="(event: MouseEvent) => emit('edit', skin, event)"
>
<EditIcon /> {{ formatMessage(commonMessages.editButton) }}
</Button>
</template>
</SkinButton>
</div>
@@ -9,11 +9,11 @@ import {
} from '@modrinth/assets'
import {
Avatar,
ButtonStyled,
Button,
commonMessages,
injectNotificationManager,
OverflowMenu,
SmartClickable,
TeleportOverflowMenu,
useFormatDateTime,
useRelativeTime,
useVIntl,
@@ -185,54 +185,53 @@ onUnmounted(() => {
</div>
</div>
<div class="flex gap-1 justify-end smart-clickable:allow-pointer-events">
<ButtonStyled v-if="playing && !loading" color="red">
<button @click="stop">
<StopCircleIcon aria-hidden="true" />
{{ formatMessage(commonMessages.stopButton) }}
</button>
</ButtonStyled>
<ButtonStyled v-else>
<button
v-tooltip="
instance.quarantined
? 'This instance has been locked'
: playing
? 'Instance is already open'
: null
"
:disabled="instance.quarantined || playing || loading"
@click="play"
>
<SpinnerIcon v-if="loading" class="animate-spin" />
<PlayIcon v-else aria-hidden="true" />
{{ formatMessage(commonMessages.playButton) }}
</button>
</ButtonStyled>
<ButtonStyled circular type="transparent">
<OverflowMenu
:options="[
{
id: 'open-instance',
shown: !!instance.id,
action: () => router.push(encodeURI(`/instance/${instance.id}`)),
},
{
id: 'open-folder',
action: () => showInstanceInFolder(instance.id),
},
]"
>
<MoreVerticalIcon aria-hidden="true" />
<template #open-instance>
<EyeIcon aria-hidden="true" />
View instance
</template>
<template #open-folder>
<FolderOpenIcon aria-hidden="true" />
{{ formatMessage(commonMessages.openFolderButton) }}
</template>
</OverflowMenu>
</ButtonStyled>
<Button v-if="playing && !loading" type="colored" color="red" @click="stop">
<StopCircleIcon aria-hidden="true" />
{{ formatMessage(commonMessages.stopButton) }}
</Button>
<Button
v-else
v-tooltip="
instance.quarantined
? 'This instance has been locked'
: playing
? 'Instance is already open'
: null
"
:disabled="instance.quarantined || playing || loading"
@click="play"
>
<SpinnerIcon v-if="loading" class="animate-spin" />
<PlayIcon v-else aria-hidden="true" />
{{ formatMessage(commonMessages.playButton) }}
</Button>
<TeleportOverflowMenu
type="quiet"
label="More options"
:options="[
{
id: 'open-instance',
label: 'View instance',
shown: !!instance.id,
action: () => router.push(encodeURI(`/instance/${instance.id}`)),
},
{
id: 'open-folder',
label: formatMessage(commonMessages.openFolderButton),
action: () => showInstanceInFolder(instance.id),
},
]"
>
<MoreVerticalIcon aria-hidden="true" />
<template #open-instance>
<EyeIcon aria-hidden="true" />
View instance
</template>
<template #open-folder>
<FolderOpenIcon aria-hidden="true" />
{{ formatMessage(commonMessages.openFolderButton) }}
</template>
</TeleportOverflowMenu>
</div>
</div>
</SmartClickable>
@@ -1,7 +1,7 @@
<script setup lang="ts">
import { LoaderCircleIcon } from '@modrinth/assets'
import type { GameVersion } from '@modrinth/ui'
import { GAME_MODES, HeadingLink, injectNotificationManager } from '@modrinth/ui'
import { GAME_MODES, injectNotificationManager } from '@modrinth/ui'
import { platform } from '@tauri-apps/plugin-os'
import type { Dayjs } from 'dayjs'
import dayjs from 'dayjs'
@@ -268,13 +268,7 @@ onUnmounted(() => {
</div>
</div>
<div v-else-if="jumpBackInItems.length > 0" class="flex flex-col gap-2">
<HeadingLink v-if="theme.getFeatureFlag('worlds_tab')" to="/worlds" class="mt-1">
Jump back in
</HeadingLink>
<span
v-else
class="flex mt-1 mb-3 leading-none items-center gap-1 text-primary text-lg font-bold"
>
<span class="flex mt-1 mb-3 leading-none items-center gap-1 text-primary text-lg font-bold">
Jump back in
</span>
<div class="grid-when-huge flex flex-col w-full gap-2">
@@ -21,13 +21,13 @@ import {
import type { MessageDescriptor } from '@modrinth/ui'
import {
Avatar,
ButtonStyled,
Button,
commonMessages,
defineMessages,
injectNotificationManager,
OverflowMenu,
SmartClickable,
TagItem,
TeleportOverflowMenu,
useFormatDateTime,
useFormatNumber,
useRelativeTime,
@@ -412,177 +412,184 @@ const messages = defineMessages({
</template>
</div>
<div class="flex gap-1 justify-end smart-clickable:allow-pointer-events">
<ButtonStyled
<Button
v-if="(playingWorld || (locked && playingInstance)) && !startingInstance"
type="colored"
color="red"
@click="emit('stop')"
>
<button @click="emit('stop')">
<StopCircleIcon aria-hidden="true" />
{{ formatMessage(commonMessages.stopButton) }}
</button>
</ButtonStyled>
<ButtonStyled v-else>
<button
v-tooltip="
quarantined
? 'This instance has been locked'
: world.type === 'server'
? !supportsServerQuickPlay
? formatMessage(messages.noServerQuickPlay)
: playingOtherWorld
? formatMessage(messages.gameAlreadyOpen)
: !serverStatus
? formatMessage(messages.noContact)
: serverIncompatible
? formatMessage(messages.incompatibleServer)
: null
: !supportsWorldQuickPlay
? formatMessage(messages.noSingleplayerQuickPlay)
: playingOtherWorld || locked
? formatMessage(messages.gameAlreadyOpen)
: null
"
:disabled="
quarantined ||
playingOtherWorld ||
startingInstance ||
(world.type == 'server' && !supportsServerQuickPlay) ||
(world.type == 'singleplayer' && !supportsWorldQuickPlay)
"
@click="emit('play')"
>
<SpinnerIcon v-if="startingInstance && playingWorld" class="animate-spin" />
<PlayIcon v-else aria-hidden="true" />
{{ formatMessage(commonMessages.playButton) }}
</button>
</ButtonStyled>
<ButtonStyled circular type="transparent">
<OverflowMenu
:options="[
{
id: 'play-instance',
shown: !!instanceId,
disabled: playingInstance || quarantined,
action: () => emit('play-instance'),
<StopCircleIcon aria-hidden="true" />
{{ formatMessage(commonMessages.stopButton) }}
</Button>
<Button
v-else
v-tooltip="
quarantined
? 'This instance has been locked'
: world.type === 'server'
? !supportsServerQuickPlay
? formatMessage(messages.noServerQuickPlay)
: playingOtherWorld
? formatMessage(messages.gameAlreadyOpen)
: !serverStatus
? formatMessage(messages.noContact)
: serverIncompatible
? formatMessage(messages.incompatibleServer)
: null
: !supportsWorldQuickPlay
? formatMessage(messages.noSingleplayerQuickPlay)
: playingOtherWorld || locked
? formatMessage(messages.gameAlreadyOpen)
: null
"
:disabled="
quarantined ||
playingOtherWorld ||
startingInstance ||
(world.type == 'server' && !supportsServerQuickPlay) ||
(world.type == 'singleplayer' && !supportsWorldQuickPlay)
"
@click="emit('play')"
>
<SpinnerIcon v-if="startingInstance && playingWorld" class="animate-spin" />
<PlayIcon v-else aria-hidden="true" />
{{ formatMessage(commonMessages.playButton) }}
</Button>
<TeleportOverflowMenu
type="quiet"
label="More options"
:options="[
{
id: 'play-instance',
label: formatMessage(messages.playInstance),
shown: !!instanceId,
disabled: playingInstance || quarantined,
action: () => emit('play-instance'),
},
{
id: 'open-instance',
label: formatMessage(messages.viewInstance),
shown: !!instanceId,
action: () => router.push(`/instance/${encodeURIComponent(instanceId)}`),
},
{
id: 'refresh',
label: formatMessage(commonMessages.refreshButton),
shown: world.type === 'server',
action: () => emit('refresh'),
},
{
id: 'copy-address',
label: formatMessage(messages.copyAddress),
shown: world.type === 'server',
action: () => copyToClipboard((world as ServerWorld).address),
},
{
id: 'edit',
label: formatMessage(commonMessages.editButton),
action: () => emit('edit'),
shown: !instanceId,
disabled: locked || managed,
tooltip: locked
? formatMessage(messages.worldInUse)
: managed
? formatMessage(messages.linkedServer)
: undefined,
},
{
id: 'open-folder',
label: formatMessage(commonMessages.openFolderButton),
shown: world.type === 'singleplayer',
action: () => (world.type === 'singleplayer' ? emit('open-folder', world) : {}),
},
{
type: 'divider',
shown: !!instanceId,
},
{
id: 'dont-show-on-home',
label: formatMessage(messages.dontShowOnHome),
shown: !!instanceId,
action: () => {
set_world_display_status(
instanceId,
world.type,
getWorldIdentifier(world),
'hidden',
).then(() => {
emit('update')
})
},
{
id: 'open-instance',
shown: !!instanceId,
action: () => router.push(`/instance/${encodeURIComponent(instanceId)}`),
},
{
id: 'refresh',
shown: world.type === 'server',
action: () => emit('refresh'),
},
{
id: 'copy-address',
shown: world.type === 'server',
action: () => copyToClipboard((world as ServerWorld).address),
},
{
id: 'edit',
action: () => emit('edit'),
shown: !instanceId,
disabled: locked || managed,
tooltip: locked
? formatMessage(messages.worldInUse)
: managed
? formatMessage(messages.linkedServer)
: undefined,
},
{
id: 'open-folder',
shown: world.type === 'singleplayer',
action: () => (world.type === 'singleplayer' ? emit('open-folder', world) : {}),
},
{
divider: true,
shown: !!instanceId,
},
{
id: 'dont-show-on-home',
shown: !!instanceId,
action: () => {
set_world_display_status(
instanceId,
world.type,
getWorldIdentifier(world),
'hidden',
).then(() => {
emit('update')
})
},
},
{
id: 'create-shortcut',
shown: !!shortcutInstanceId && !quarantined,
action: () => createShortcut(),
},
{
divider: true,
shown: !instanceId,
},
{
id: 'delete',
color: 'red',
hoverFilled: true,
action: () => emit('delete'),
shown: !instanceId,
disabled: locked || managed,
tooltip: locked
? formatMessage(messages.worldInUse)
: managed
? formatMessage(messages.linkedServer)
: undefined,
},
]"
>
<MoreVerticalIcon aria-hidden="true" />
<template #play-instance>
<PlayIcon aria-hidden="true" />
{{ formatMessage(messages.playInstance) }}
</template>
<template #open-instance>
<EyeIcon aria-hidden="true" />
{{ formatMessage(messages.viewInstance) }}
</template>
<template #edit>
<EditIcon aria-hidden="true" />
{{ formatMessage(commonMessages.editButton) }}
</template>
<template #open-folder>
<FolderOpenIcon aria-hidden="true" />
{{ formatMessage(commonMessages.openFolderButton) }}
</template>
<template #copy-address>
<ClipboardCopyIcon aria-hidden="true" />
{{ formatMessage(messages.copyAddress) }}
</template>
<template #refresh>
<UpdatedIcon aria-hidden="true" />
{{ formatMessage(commonMessages.refreshButton) }}
</template>
<template #create-shortcut>
<ExternalIcon aria-hidden="true" />
{{ formatMessage(messages.createShortcut) }}
</template>
<template #dont-show-on-home>
<XIcon aria-hidden="true" />
{{ formatMessage(messages.dontShowOnHome) }}
</template>
<template #delete>
<TrashIcon aria-hidden="true" />
{{
formatMessage(
world.type === 'server'
? commonMessages.removeButton
: commonMessages.deleteLabel,
)
}}
</template>
</OverflowMenu>
</ButtonStyled>
},
{
id: 'create-shortcut',
label: formatMessage(messages.createShortcut),
shown: !!shortcutInstanceId && !quarantined,
action: () => createShortcut(),
},
{
type: 'divider',
shown: !instanceId,
},
{
id: 'delete',
label: formatMessage(
world.type === 'server' ? commonMessages.removeButton : commonMessages.deleteLabel,
),
tone: 'red',
action: () => emit('delete'),
shown: !instanceId,
disabled: locked || managed,
tooltip: locked
? formatMessage(messages.worldInUse)
: managed
? formatMessage(messages.linkedServer)
: undefined,
},
]"
>
<MoreVerticalIcon aria-hidden="true" />
<template #play-instance>
<PlayIcon aria-hidden="true" />
{{ formatMessage(messages.playInstance) }}
</template>
<template #open-instance>
<EyeIcon aria-hidden="true" />
{{ formatMessage(messages.viewInstance) }}
</template>
<template #edit>
<EditIcon aria-hidden="true" />
{{ formatMessage(commonMessages.editButton) }}
</template>
<template #open-folder>
<FolderOpenIcon aria-hidden="true" />
{{ formatMessage(commonMessages.openFolderButton) }}
</template>
<template #copy-address>
<ClipboardCopyIcon aria-hidden="true" />
{{ formatMessage(messages.copyAddress) }}
</template>
<template #refresh>
<UpdatedIcon aria-hidden="true" />
{{ formatMessage(commonMessages.refreshButton) }}
</template>
<template #create-shortcut>
<ExternalIcon aria-hidden="true" />
{{ formatMessage(messages.createShortcut) }}
</template>
<template #dont-show-on-home>
<XIcon aria-hidden="true" />
{{ formatMessage(messages.dontShowOnHome) }}
</template>
<template #delete>
<TrashIcon aria-hidden="true" />
{{
formatMessage(
world.type === 'server' ? commonMessages.removeButton : commonMessages.deleteLabel,
)
}}
</template>
</TeleportOverflowMenu>
</div>
</div>
</SmartClickable>
@@ -1,7 +1,7 @@
<script setup lang="ts">
import { PlayIcon, PlusIcon, XIcon } from '@modrinth/assets'
import {
ButtonStyled,
Button,
commonMessages,
defineMessages,
injectNotificationManager,
@@ -96,24 +96,18 @@ defineExpose({ show, hide })
/>
<template #actions>
<div class="flex gap-2 justify-end">
<ButtonStyled type="outlined">
<button @click="hide()">
<XIcon />
{{ formatMessage(commonMessages.cancelButton) }}
</button>
</ButtonStyled>
<ButtonStyled>
<button :disabled="!address" @click="addServer(false)">
<PlusIcon />
{{ formatMessage(messages.addServer) }}
</button>
</ButtonStyled>
<ButtonStyled color="brand">
<button :disabled="!address" @click="addServer(true)">
<PlayIcon />
{{ formatMessage(messages.addAndPlay) }}
</button>
</ButtonStyled>
<Button type="outlined" @click="hide()">
<XIcon />
{{ formatMessage(commonMessages.cancelButton) }}
</Button>
<Button :disabled="!address" @click="addServer(false)">
<PlusIcon />
{{ formatMessage(messages.addServer) }}
</Button>
<Button type="colored" color="brand" :disabled="!address" @click="addServer(true)">
<PlayIcon />
{{ formatMessage(messages.addAndPlay) }}
</Button>
</div>
</template>
</NewModal>
@@ -2,7 +2,7 @@
import { TrashIcon, XIcon } from '@modrinth/assets'
import {
Admonition,
ButtonStyled,
Button,
commonMessages,
defineMessages,
NewModal,
@@ -106,18 +106,19 @@ defineExpose({ show, hide })
<template #actions>
<div class="flex gap-2 justify-end">
<ButtonStyled type="outlined">
<button @click="hide">
<XIcon />
{{ formatMessage(commonMessages.cancelButton) }}
</button>
</ButtonStyled>
<ButtonStyled color="red">
<button :disabled="!isServer && !isSingleplayer" @click="confirm">
<TrashIcon />
{{ formatMessage(actionMessage) }}
</button>
</ButtonStyled>
<Button type="outlined" @click="hide">
<XIcon />
{{ formatMessage(commonMessages.cancelButton) }}
</Button>
<Button
type="colored"
color="red"
:disabled="!isServer && !isSingleplayer"
@click="confirm"
>
<TrashIcon />
{{ formatMessage(actionMessage) }}
</Button>
</div>
</template>
</NewModal>
@@ -1,7 +1,7 @@
<script setup lang="ts">
import { SaveIcon, XIcon } from '@modrinth/assets'
import {
ButtonStyled,
Button,
commonMessages,
defineMessage,
injectNotificationManager,
@@ -105,18 +105,14 @@ const titleMessage = defineMessage({
<HideFromHomeOption v-model="hideFromHome" class="mt-3" />
<template #actions>
<div class="flex gap-2 justify-end">
<ButtonStyled type="outlined">
<button @click="hide()">
<XIcon />
{{ formatMessage(commonMessages.cancelButton) }}
</button>
</ButtonStyled>
<ButtonStyled color="brand">
<button :disabled="!address" @click="saveServer">
<SaveIcon />
{{ formatMessage(commonMessages.saveChangesButton) }}
</button>
</ButtonStyled>
<Button type="outlined" @click="hide()">
<XIcon />
{{ formatMessage(commonMessages.cancelButton) }}
</Button>
<Button type="colored" color="brand" :disabled="!address" @click="saveServer">
<SaveIcon />
{{ formatMessage(commonMessages.saveChangesButton) }}
</Button>
</div>
</template>
</NewModal>
@@ -2,7 +2,7 @@
import { ChevronRightIcon, SaveIcon, UndoIcon, XIcon } from '@modrinth/assets'
import {
Avatar,
ButtonStyled,
Button,
commonMessages,
defineMessages,
injectNotificationManager,
@@ -113,24 +113,18 @@ const messages = defineMessages({
<HideFromHomeOption v-model="hideFromHome" class="mt-3" />
</div>
<div class="flex gap-2 mt-4">
<ButtonStyled color="brand">
<button @click="saveWorld">
<SaveIcon />
{{ formatMessage(commonMessages.saveChangesButton) }}
</button>
</ButtonStyled>
<ButtonStyled>
<button :disabled="removeIcon || !icon" @click="removeIcon = true">
<UndoIcon />
{{ formatMessage(messages.resetIcon) }}
</button>
</ButtonStyled>
<ButtonStyled>
<button @click="hide()">
<XIcon />
{{ formatMessage(commonMessages.cancelButton) }}
</button>
</ButtonStyled>
<Button type="colored" color="brand" @click="saveWorld">
<SaveIcon />
{{ formatMessage(commonMessages.saveChangesButton) }}
</Button>
<Button :disabled="removeIcon || !icon" @click="removeIcon = true">
<UndoIcon />
{{ formatMessage(messages.resetIcon) }}
</Button>
<Button @click="hide()">
<XIcon />
{{ formatMessage(commonMessages.cancelButton) }}
</Button>
</div>
</ModalWrapper>
</template>
@@ -23,6 +23,7 @@ import {
type InstallProgress,
} from '@/helpers/install'
import { get_many as getInstances } from '@/helpers/instance'
import { useTheming } from '@/store/state'
const messages = defineMessages({
installs: {
@@ -221,13 +222,6 @@ const failureSummaryMessages = defineMessages({
})
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
@@ -241,6 +235,7 @@ export async function useInstallJobNotifications(opts: {
onChange: () => void
}) {
const { formatMessage } = useVIntl()
const themeStore = useTheming()
const jobs = ref<InstallJobSnapshot[]>([])
const iconUrls = ref<Record<string, string | null>>({})
const instanceNames = ref<Record<string, string>>({})
@@ -250,7 +245,6 @@ export async function useInstallJobNotifications(opts: {
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
@@ -421,116 +415,14 @@ export async function useInstallJobNotifications(opts: {
return job.status === 'failed' || job.status === 'interrupted'
}
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))
return isTerminalJob(job) || themeStore.getFeatureFlag('always_show_copy_details')
}
function isCopied(job: InstallJobSnapshot): boolean {
@@ -607,6 +499,16 @@ export async function useInstallJobNotifications(opts: {
return buttons
}
function getDismissHandler(job: InstallJobSnapshot): (() => Promise<void>) | undefined {
if (isTerminalJob(job)) {
return async () => {
await install_job_dismiss(job.job_id).catch(opts.handleError)
await refresh()
}
}
return undefined
}
function setJobs(nextJobs: InstallJobSnapshot[]) {
for (const job of nextJobs) {
if (!jobOrder.has(job.job_id)) {
@@ -615,7 +517,6 @@ export async function useInstallJobNotifications(opts: {
}
const visibleJobs = nextJobs.filter((job) => visibleJobStatuses.has(job.status))
syncProgressSnapshots(visibleJobs)
jobs.value = visibleJobs.sort(
(a, b) =>
@@ -642,12 +543,8 @@ export async function useInstallJobNotifications(opts: {
progressCurrent: isTerminalJob(job) ? undefined : progress?.current,
progressTotal: isTerminalJob(job) ? undefined : progress?.total,
buttons: getButtons(job),
onDismiss: isTerminalJob(job)
? async () => {
await install_job_dismiss(job.job_id).catch(opts.handleError)
await refresh()
}
: undefined,
dismissible: isTerminalJob(job),
onDismiss: getDismissHandler(job),
}
}),
)
@@ -730,8 +627,8 @@ export async function useInstallJobNotifications(opts: {
void refreshMetadata()
}
await refresh(false)
const unlisten = await install_job_listener((job: InstallJobSnapshot) => applyJobUpdate(job))
await refresh(false)
return {
active: computed(() => jobs.value.length > 0),
@@ -743,9 +640,6 @@ export async function useInstallJobNotifications(opts: {
for (const timeout of copiedResetTimeouts.values()) {
window.clearTimeout(timeout)
}
for (const jobId of progressSnapshots.keys()) {
clearProgressSnapshot(jobId)
}
unlisten()
},
}
@@ -17,6 +17,7 @@ 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 } from '@/helpers/worlds'
import { instanceKeys } from '@/pages/instance/query-options'
interface BrowseServerInstance {
id: string
@@ -38,7 +39,7 @@ interface ContextMenuOptionClick {
}
export interface UseAppServerBrowseOptions {
instance: Ref<BrowseServerInstance | null>
instance: Readonly<Ref<BrowseServerInstance | null>>
isFromWorlds: ComputedRef<boolean>
allInstalledIds: ComputedRef<Set<string>>
newlyInstalled: Ref<string[]>
@@ -131,7 +132,7 @@ export function useAppServerBrowse(options: UseAppServerBrowseOptions) {
project.minecraft_java_server?.content?.kind,
)
options.newlyInstalled.value.push(project.project_id)
await queryClient.invalidateQueries({ queryKey: ['worlds', instanceId] })
await queryClient.invalidateQueries({ queryKey: instanceKeys.worlds(instanceId) })
} catch (error) {
options.handleError(error)
}
+5
View File
@@ -102,6 +102,7 @@ export interface SharedInstanceUpdateDiff {
}
export const SHARED_INSTANCE_UNAVAILABLE_ERROR_CODE = 'shared_instance_unavailable'
export const SHARED_INSTANCES_API_ERROR_CODE = 'shared_instances_api_error'
export type SharedInstanceUnavailableReason = 'deleted' | 'access_revoked' | 'quarantined'
@@ -113,6 +114,10 @@ export function isSharedInstanceUnavailableError(error: unknown) {
return getSharedInstanceUnavailableReason(error) !== null
}
export function isSharedInstancesApiError(error: unknown) {
return isRecord(error) && error.code === SHARED_INSTANCES_API_ERROR_CODE
}
export function getSharedInstanceUnavailableReason(
error: unknown,
): SharedInstanceUnavailableReason | null {
@@ -51,7 +51,8 @@ export async function loadInstanceContentData(
}
function handleLoadError(error: unknown, onError?: (error: Error) => unknown) {
onError?.(error as Error)
if (!onError) throw error
onError(error as Error)
return null
}
+43 -11
View File
@@ -282,12 +282,13 @@ export async function update_repair_modrinth(instanceId: string): Promise<Instal
}
// Export an instance to .mrpack
// included_overrides is an array of paths to override folders to include (ie: 'mods', 'resource_packs')
// included_overrides and excluded_overrides are inherited path rules for files in the export.
// Version id is optional (ie: 1.1.5)
export async function export_instance_mrpack(
instanceId: string,
exportLocation: string,
includedOverrides: string[],
excludedOverrides: string[],
versionId?: string,
description?: string,
name?: string,
@@ -296,22 +297,33 @@ export async function export_instance_mrpack(
instanceId,
exportLocation,
includedOverrides,
excludedOverrides,
versionId,
description,
name,
})
}
// Given a folder path, populate an array of all the subfolders
// Intended to be used for finding potential override folders
// profile
// -- mods
// -- resourcepacks
// -- file1
// => [mods, resourcepacks]
// allows selection for 'included_overrides' in export_instance_mrpack
export async function get_pack_export_candidates(instanceId: string): Promise<string[]> {
return await invoke('plugin:instance|instance_get_pack_export_candidates', { instanceId })
export type PackExportCandidate = {
path: string
type: 'directory' | 'file'
size?: number
modified?: number
count?: number
disabled: boolean
defaultSelected: boolean
}
// Given a folder path, populate an array of exportable direct children.
// Allows selection for 'included_overrides' in export_instance_mrpack.
export async function get_pack_export_candidates(
instanceId: string,
parent?: string,
): Promise<PackExportCandidate[]> {
return await invoke('plugin:instance|instance_get_pack_export_candidates', {
instanceId,
parent: parent ?? null,
})
}
// Run Minecraft using an instance
@@ -365,6 +377,13 @@ export interface SharedInstanceInviteLink {
maxUses: number
}
export interface SharedInstanceInvite {
id: string
expiration: string
maxUses: number
uses: number
}
export async function can_current_user_use_shared_instances(): Promise<boolean> {
return await invoke('plugin:instance|instance_share_can_current_user_use')
}
@@ -394,6 +413,19 @@ export async function create_shared_instance_invite_link(
})
}
export async function get_shared_instance_invites(
instanceId: string,
): Promise<SharedInstanceInvite[]> {
return await invoke('plugin:instance|instance_share_get_invites', { instanceId })
}
export async function revoke_shared_instance_invite(
instanceId: string,
inviteId: string,
): Promise<void> {
return await invoke('plugin:instance|instance_share_revoke_invite', { instanceId, inviteId })
}
export async function remove_shared_instance_users(
instanceId: string,
userIds: string[],
+1 -1
View File
@@ -37,7 +37,7 @@ export type AppSettings = {
theme: ColorTheme
locale: string
default_page: 'home' | 'library'
default_page: 'Home' | 'Library'
collapsed_navigation: boolean
hide_nametag_skins_page: boolean
advanced_rendering: boolean
@@ -1,6 +1,10 @@
import { defineMessages, injectNotificationManager, useVIntl } from '@modrinth/ui'
import { getErrorMessage, type SharedInstanceUnavailableReason } from '@/helpers/install'
import {
getErrorMessage,
isSharedInstancesApiError,
type SharedInstanceUnavailableReason,
} from '@/helpers/install'
export const sharedInstanceErrorMessages = defineMessages({
unavailableTitle: {
@@ -39,6 +43,14 @@ export const sharedInstanceErrorMessages = defineMessages({
id: 'instance.shared-instance.error.title',
defaultMessage: 'Something has gone wrong',
},
networkErrorTitle: {
id: 'instance.shared-instance.network-error.title',
defaultMessage: 'Network error',
},
networkErrorText: {
id: 'instance.shared-instance.network-error.text',
defaultMessage: 'Unable to connect to shared instances API',
},
})
export function sharedInstanceUnavailableTextMessage(
@@ -82,7 +94,20 @@ export function useSharedInstanceErrors() {
})
}
function notifySharedInstanceConnectionError() {
addNotification({
type: 'error',
title: formatMessage(sharedInstanceErrorMessages.networkErrorTitle),
text: formatMessage(sharedInstanceErrorMessages.networkErrorText),
})
}
function notifySharedInstanceError(error: unknown) {
if (isSharedInstancesApiError(error)) {
notifySharedInstanceConnectionError()
return
}
addNotification({
type: 'error',
title: formatMessage(sharedInstanceErrorMessages.errorTitle),
@@ -92,6 +117,7 @@ export function useSharedInstanceErrors() {
return {
formatSharedInstanceUnavailable,
notifySharedInstanceConnectionError,
notifySharedInstanceError,
notifySharedInstanceUnavailable,
}
+1 -1
View File
@@ -189,7 +189,7 @@ type AppSettings = {
max_concurrent_downloads: number
max_concurrent_writes: number
theme: 'dark' | 'light' | 'oled'
theme: 'dark' | 'light' | 'oled' | 'retro' | 'system'
default_page: 'Home' | 'Library'
collapsed_navigation: boolean
advanced_rendering: boolean
+75 -6
View File
@@ -1,11 +1,80 @@
import type { Labrinth } from '@modrinth/api-client'
import { invoke } from '@tauri-apps/api/core'
export type SearchUser = {
id: string
username: string
avatar_url: string | null
// Converts user profile links from rendered Markdown/any dynamic content into app routes.
export function parse_modrinth_user_link(href: string): string | null {
try {
const url = new URL(href)
if (url.hostname !== 'modrinth.com' && url.hostname !== 'www.modrinth.com') return null
const segments = url.pathname.split('/').filter(Boolean)
if (segments[0]?.toLowerCase() !== 'user' || !segments[1] || segments.length > 3) return null
const path = `/user/${encodeURIComponent(decodeURIComponent(segments[1]))}`
return segments[2] ? `${path}/${encodeURIComponent(decodeURIComponent(segments[2]))}` : path
} catch {
return null
}
}
export async function search_user(query: string): Promise<SearchUser[]> {
return await invoke<SearchUser[]>('plugin:users|search_user', { query })
export async function search_user(query: string): Promise<Labrinth.Users.v3.SearchUser[]> {
return await invoke<Labrinth.Users.v3.SearchUser[]>('plugin:users|search_user', { query })
}
export async function get_user_profile(userId: string): Promise<Labrinth.Users.v3.User> {
return await invoke<Labrinth.Users.v3.User>('plugin:users|get_user_profile', { userId })
}
export async function get_user_projects(userId: string): Promise<Labrinth.Projects.v2.Project[]> {
return await invoke<Labrinth.Projects.v2.Project[]>('plugin:users|get_user_projects', {
userId,
})
}
export async function get_user_organizations(
userId: string,
): Promise<Labrinth.Organizations.v3.Organization[]> {
return await invoke<Labrinth.Organizations.v3.Organization[]>(
'plugin:users|get_user_organizations',
{ userId },
)
}
export async function get_user_collections(
userId: string,
): Promise<Labrinth.Collections.Collection[]> {
return await invoke<Labrinth.Collections.Collection[]>('plugin:users|get_user_collections', {
userId,
})
}
export async function patch_user(
userId: string,
patch: Partial<Pick<Labrinth.Users.v2.User, 'badges' | 'bio' | 'role' | 'username'>>,
): Promise<void> {
await invoke('plugin:users|patch_user', { userId, patch })
}
export async function change_user_avatar(
userId: string,
image: Uint8Array,
extension: string,
): Promise<void> {
await invoke('plugin:users|change_user_avatar', { userId, image, extension })
}
export async function delete_user_avatar(userId: string): Promise<void> {
await invoke('plugin:users|delete_user_avatar', { userId })
}
export async function block_user(userId: string): Promise<void> {
await invoke('plugin:users|block_user', { userId })
}
export async function unblock_user(userId: string): Promise<void> {
await invoke('plugin:users|unblock_user', { userId })
}
export async function get_blocked_users(): Promise<Labrinth.BlockedUsers.v3.BlockedUserId[]> {
return await invoke<Labrinth.BlockedUsers.v3.BlockedUserId[]>('plugin:users|get_blocked_users')
}
+11 -5
View File
@@ -435,11 +435,12 @@ export async function refreshServerData(
}
}
export function refreshServers(
export async function refreshServers(
worlds: World[],
serverData: Record<string, ServerData>,
protocolVersion: ProtocolVersion | null,
) {
ping = true,
): Promise<void> {
const servers = worlds.filter(isServerWorld)
servers.forEach((server) => {
if (!serverData[server.address]) {
@@ -451,9 +452,14 @@ export function refreshServers(
}
})
// noinspection ES6MissingAwait - handled by refreshServerData
Object.keys(serverData).forEach((address) =>
refreshServerData(serverData[address], protocolVersion, address),
if (!ping) {
return
}
await Promise.all(
Object.keys(serverData).map((address) =>
refreshServerData(serverData[address], protocolVersion, address),
),
)
}
+49 -49
View File
@@ -125,21 +125,21 @@
"app.action-bar.view-logs": {
"message": "عرض التسجيل"
},
"app.appearance-settings.advanced-rendering.description": {
"message": "يتيح العرض المتقدم لتأثيرات مثل التشويش الذي قد تسبب مشكلات في الأداء دون استخدام العرض المسرع بالأجهزة."
"app.ads-consent.accept": {
"message": "قبول الكل"
},
"app.ads-consent.manage": {
"message": "إدارة التفضيلات"
},
"app.ads-consent.reject": {
"message": "رفض الكل"
},
"app.appearance-settings.advanced-rendering.title": {
"message": "العرض المتقدم"
},
"app.appearance-settings.color-theme.description": {
"message": "اختر لون سمتك المفضل من أجل تطبيق Modrinth App."
},
"app.appearance-settings.color-theme.title": {
"message": "لون السمة"
},
"app.appearance-settings.default-landing-page.description": {
"message": "غير الصفحة التي يفتح عليها المشغل."
},
"app.appearance-settings.default-landing-page.home": {
"message": "صفحة الرئيسية"
},
@@ -149,53 +149,23 @@
"app.appearance-settings.default-landing-page.title": {
"message": "الصفحة الرئيسة الافتراضية"
},
"app.appearance-settings.hide-nametag.description": {
"message": "ألغ تفعيل عرض الاسم فوق لاعبك في صفحة المظاهر."
},
"app.appearance-settings.hide-nametag.title": {
"message": "إخفاء عرض الاسم"
},
"app.appearance-settings.jump-back-into-worlds.description": {
"message": "يتضمن العوالم الحديثة في قسم \"العودة إلى اللعب\" على الصفحة الرئيسية."
},
"app.appearance-settings.jump-back-into-worlds.title": {
"message": "العودة إلى العوالم"
},
"app.appearance-settings.minimize-launcher.description": {
"message": "قم بتصغير نافذة المشغل عند بدء تشغيل عملية Minecraft."
},
"app.appearance-settings.minimize-launcher.title": {
"message": "تصغير نافذة المشغل"
},
"app.appearance-settings.native-decorations.description": {
"message": "استخدام إطار نافذة النظام (يتطلب إعادة تشغيل التطبيق)."
},
"app.appearance-settings.native-decorations.title": {
"message": "الزخارف التقليدية"
},
"app.appearance-settings.select-option": {
"message": "حدد خياراً"
},
"app.appearance-settings.show-play-time.description": {
"message": "يعرض مقدار الوقت الذي قضيته في لعب إحدى المهام."
},
"app.appearance-settings.show-play-time.title": {
"message": "إظهار مدة التشغيل"
},
"app.appearance-settings.skip-non-essential-warnings.description": {
"message": "يتخطى تلقائيًا عمليات التأكيد ذات المخاطر المنخفضة، مثل تثبيت حزم التعديلات المكررة، وحذف المحتوى العادي، والتحديثات الجماعية، وإلغاء ربط حزم التعديلات، ومطالبات الإصلاح. وسيستمر عرض التحذيرات الخطيرة."
},
"app.appearance-settings.skip-non-essential-warnings.title": {
"message": "تخطي التحذيرات غير الضرورية"
},
"app.appearance-settings.toggle-sidebar.description": {
"message": "يتيح إمكانية إظهار الشريط الجانبي أو إخفائه."
"message": "قم بإخفاء الشريط الجانبي الأيمن افتراضيًا وأضف زرًا لإظهاره أو إخفائه."
},
"app.appearance-settings.toggle-sidebar.title": {
"message": "تبديل الشريط الجانبي"
},
"app.appearance-settings.unknown-pack-warning.description": {
"message": "إذا حاولت تثبيت ملف Modrinth Pack (.mrpack) غير مستضاف على Modrinth، فسنتأكد من أنك على دراية بالمخاطر قبل تثبيته."
"message": "قم بإخفاء الشريط الجانبي الأيمن"
},
"app.appearance-settings.unknown-pack-warning.title": {
"message": "أخبرني قبل تثبيت حزم التعديلات غير المعروفة"
@@ -227,9 +197,6 @@
"app.browse.back-to-instance": {
"message": "العودة للنموذج"
},
"app.browse.discover-content": {
"message": "استكشف محتوى"
},
"app.browse.discover-servers": {
"message": "استكشف خوادم"
},
@@ -428,24 +395,33 @@
"app.modal.install-to-play.header": {
"message": "نزل للعب"
},
"app.modal.install-to-play.install-anyway": {
"message": "تثبيت على أي حال"
},
"app.modal.install-to-play.install-button": {
"message": "تثبيت"
},
"app.modal.install-to-play.mod-count": {
"message": "{count, plural, one {# تعديل} other {# تعديلات}}"
},
"app.modal.install-to-play.reviewed-files": {
"message": "لا يتم مراجعة الملف إلا إذا تم نشره على Modrinth، بغض النظر عن صيغته (بما في ذلك .mrpack)."
},
"app.modal.install-to-play.shared-instance": {
"message": "النماذج المشتركة"
},
"app.modal.install-to-play.unknown-files-warning": {
"message": "تحذير ملفات غير معروفة"
},
"app.modal.install-to-play.unrecognized-files": {
"message": "ملفات غير متعرّف عليها"
},
"app.modal.install-to-play.view-contents": {
"message": "عرض المحتويات"
},
"app.modal.update-to-play.header": {
"message": "حدث للعب"
},
"app.modal.update-to-play.update-required-description": {
"message": "هناك تحديث مطلوب للعب بـ {name}. الرجاء التحديث إلى أحدث اصدار لتشغيل اللعبة."
},
"app.project.install-button.already-installed": {
"message": "هذا المشروع مُثبَّت بالفعل"
},
@@ -476,9 +452,6 @@
"app.settings.tabs.appearance": {
"message": "المظهر"
},
"app.settings.tabs.default-instance-options": {
"message": "خيارات النسخة الافتراضية"
},
"app.settings.tabs.java-installations": {
"message": "تثبيتات جافا"
},
@@ -641,6 +614,12 @@
"app.skins.title": {
"message": "اختيار المظهر"
},
"app.skins.toggle-ears-features-off": {
"message": "إيقاف"
},
"app.skins.toggle-ears-features-on": {
"message": "تشغيل"
},
"app.update-popup.body.download-complete": {
"message": "انتهى تنزيل تطبيق Modrinth v{version}. أعد التحميل للتحديث الآن، أو تلقائيًا عند إغلاق تطبيق Modrinth."
},
@@ -752,6 +731,24 @@
"friends.sign-in-to-add-friends": {
"message": "<link>سجّل الدخول إلى حساب Modrinth</link> لإضافة الأصدقاء ومعرفة ما الذي يلعبونه!"
},
"instance.action.create-shortcut": {
"message": "إنشاء اختصار"
},
"instance.action.more-actions": {
"message": "مزيد من الخيارات"
},
"instance.action.open-folder": {
"message": "فتح المجلد"
},
"instance.action.repair": {
"message": "إصلاح"
},
"instance.action.starting": {
"message": "قيد التشغيل..."
},
"instance.action.stopping": {
"message": "قيد الإيقاف..."
},
"instance.add-server.add-and-play": {
"message": "إضافة واللعب"
},
@@ -794,6 +791,9 @@
"instance.files.save-as": {
"message": "حفظ ك..."
},
"instance.playtime.never-played": {
"message": "لم تُلعب من قبل"
},
"instance.server-modal.address": {
"message": "العنوان"
},
@@ -65,21 +65,12 @@
"app.action-bar.view-logs": {
"message": "Zobrazit logy"
},
"app.appearance-settings.advanced-rendering.description": {
"message": "Povolí pokročilé vykreslování, například efekty rozmazání, které mohou bez hardwarového zrychlení způsobovat problémy s výkonem."
},
"app.appearance-settings.advanced-rendering.title": {
"message": "Pokročilé vykreslování"
},
"app.appearance-settings.color-theme.description": {
"message": "Vyberte si váš preferovaný motiv pro aplikaci Modrinth."
},
"app.appearance-settings.color-theme.title": {
"message": "Barevný motiv"
},
"app.appearance-settings.default-landing-page.description": {
"message": "Změňte stránku, která se otevře po spuštění launcheru."
},
"app.appearance-settings.default-landing-page.home": {
"message": "Domů"
},
@@ -89,54 +80,18 @@
"app.appearance-settings.default-landing-page.title": {
"message": "Výchozí stránka po spuštění"
},
"app.appearance-settings.hide-nametag.description": {
"message": "Zakáže zobrazení jmenovky nad vaší postavou na stránce skinů."
},
"app.appearance-settings.hide-nametag.title": {
"message": "Skrýt jmenovku"
},
"app.appearance-settings.jump-back-into-worlds.description": {
"message": "Zahrne nedávné světy do sekce „Vrátit se do hry“ na domovské stránce."
},
"app.appearance-settings.jump-back-into-worlds.title": {
"message": "Rychlý návrat do světů"
},
"app.appearance-settings.minimize-launcher.description": {
"message": "Minimalizovat launcher při spuštění Minecraftu."
},
"app.appearance-settings.minimize-launcher.title": {
"message": "Minimalizovat launcher"
},
"app.appearance-settings.native-decorations.description": {
"message": "Použít systémový rámeček okna (vyžaduje restart aplikace)."
},
"app.appearance-settings.native-decorations.title": {
"message": "Systémové dekorace"
},
"app.appearance-settings.select-option": {
"message": "Vyberte možnost"
},
"app.appearance-settings.show-play-time.description": {
"message": "Zobrazuje, kolik času jsi strávil hraním instance."
},
"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žňí přepínat zobrazení postranního panelu."
},
"app.appearance-settings.toggle-sidebar.title": {
"message": "Přepnout postranní panel"
},
"app.appearance-settings.unknown-pack-warning.description": {
"message": "Pokud se pokusíte nainstalovat soubor Modrinth Pack (.mrpack), který není hostován na Modrinth, zajistíme, abyste si před instalací uvědomili související rizika."
},
"app.appearance-settings.unknown-pack-warning.title": {
"message": "Upozorni mě před instalací neznámých modpacků"
},
@@ -167,9 +122,6 @@
"app.browse.back-to-instance": {
"message": "Zpět k instanci"
},
"app.browse.discover-content": {
"message": "Objevit obsah"
},
"app.browse.discover-servers": {
"message": "Objevit servery"
},
@@ -380,9 +332,6 @@
"app.modal.update-to-play.header": {
"message": "Aktualizuj pro hraní"
},
"app.modal.update-to-play.update-required-description": {
"message": "Pro hraní {name} je vyžadována aktualizace. Prosím aktualizuj na nejnovější verzi, abys mohl hru spustit."
},
"app.project.install-button.already-installed": {
"message": "Tento projekt je již nainstalován"
},
@@ -410,9 +359,6 @@
"app.settings.tabs.appearance": {
"message": "Vzhled"
},
"app.settings.tabs.default-instance-options": {
"message": "Výchozí možnosti instance"
},
"app.settings.tabs.java-installations": {
"message": "Instalace Javy"
},
+335 -38
View File
@@ -11,6 +11,12 @@
"app.action-bar.hide-more-running-instances": {
"message": "Gem flere kørende instances"
},
"app.action-bar.install.copied-details": {
"message": "Kopiere"
},
"app.action-bar.install.copy-details": {
"message": "Kopier detaljer"
},
"app.action-bar.install.dismiss": {
"message": "Tilsidesæt"
},
@@ -20,9 +26,72 @@
"app.action-bar.install.retry": {
"message": "Prøv igen"
},
"app.action-bar.install.summary.app-closing": {
"message": "Annulleret på grund af appen blev lukket"
},
"app.action-bar.install.summary.bad-modpack-file": {
"message": "Kunne ikke indlæse modpack"
},
"app.action-bar.install.summary.canceled": {
"message": "Annulleret"
},
"app.action-bar.install.summary.content-download-failed": {
"message": "Kunne ikke download filer"
},
"app.action-bar.install.summary.corrupt-download": {
"message": "Downloaded filer er korrupt"
},
"app.action-bar.install.summary.could-not-save-files": {
"message": "Kunne ikke gemme filer"
},
"app.action-bar.install.summary.download-failed": {
"message": "Download kunne ikke blive fuldført"
},
"app.action-bar.install.summary.instance-not-found": {
"message": "Instance kunne ikke blive fundet"
},
"app.action-bar.install.summary.invalid-file-path": {
"message": "Fil sti er ugyldig"
},
"app.action-bar.install.summary.invalid-modpack": {
"message": "Modpack data ugyldig"
},
"app.action-bar.install.summary.invalid-modpack-files": {
"message": "Modpack har ugyldig metadata"
},
"app.action-bar.install.summary.java-setup-failed": {
"message": "Java setup kunne ikke færdiggøres"
},
"app.action-bar.install.summary.loader-setup-failed": {
"message": "Loader setup fejlede"
},
"app.action-bar.install.summary.local-data-error": {
"message": "Kunne ikke opdatere lokal data"
},
"app.action-bar.install.summary.minecraft-setup-failed": {
"message": "Minecraft setup fejlede"
},
"app.action-bar.install.summary.modrinth-unreachable": {
"message": "Kunne ikke få forbindelse til Modrinth"
},
"app.action-bar.install.summary.no-write-permission": {
"message": "Ingen adgang til at skrive"
},
"app.action-bar.install.summary.pack-download-failed": {
"message": "Kunne ikke download pack"
},
"app.action-bar.install.summary.unexpected-error": {
"message": "Noget gik galt"
},
"app.action-bar.install.unknown-instance": {
"message": "Ukendt instance"
},
"app.action-bar.install.updating-shared-content": {
"message": "Opdatere delt indhold"
},
"app.action-bar.installs": {
"message": "Installeringer"
},
"app.action-bar.make-primary-instance": {
"message": "Gør til primære instance"
},
@@ -56,21 +125,24 @@
"app.action-bar.view-logs": {
"message": "Vis logs"
},
"app.appearance-settings.advanced-rendering.description": {
"message": "Aktiverer avanceret gengivelse som sløringseffekter, der kan forårsage ydelsesproblemer uden hardwareaccelereret gengivelse."
"app.ads-consent.accept": {
"message": "Tillad alle"
},
"app.ads-consent.body": {
"message": "Reklamer gør Modrinth muligt og finansiere skaber udbetalinger. Vores partnere må gemme og tilgå cookies i appen for at personliggøre reklamer og måle effektivitet."
},
"app.ads-consent.reject": {
"message": "Afvis alle"
},
"app.ads-consent.title": {
"message": "Dit privatliv og hvordan reklamer hjælper Modrinth"
},
"app.appearance-settings.advanced-rendering.title": {
"message": "Avanceret rendering"
},
"app.appearance-settings.color-theme.description": {
"message": "Vælg dit foretrukne farvetema til Modrinth App."
},
"app.appearance-settings.color-theme.title": {
"message": "Farvetema"
},
"app.appearance-settings.default-landing-page.description": {
"message": "Skift hvilken side launcheren åbner i."
},
"app.appearance-settings.default-landing-page.home": {
"message": "Hjem"
},
@@ -80,41 +152,23 @@
"app.appearance-settings.default-landing-page.title": {
"message": "Standard startside"
},
"app.appearance-settings.hide-nametag.description": {
"message": "Slår nametagget over din spiller på skins siden fra."
},
"app.appearance-settings.hide-nametag.title": {
"message": "Gem nametag"
},
"app.appearance-settings.jump-back-into-worlds.description": {
"message": "Inkluderer de sidste verdener i afsnittet \"Hop tilbage\" på startsiden."
},
"app.appearance-settings.jump-back-into-worlds.title": {
"message": "Hop tilbage ind i verdener"
},
"app.appearance-settings.minimize-launcher.description": {
"message": "Minimer launcheren når Minecraft starter."
},
"app.appearance-settings.minimize-launcher.title": {
"message": "Minimer launcher"
},
"app.appearance-settings.select-option": {
"message": "Vælg en mulighed"
},
"app.appearance-settings.show-play-time.description": {
"message": "Vis hvor meget tid du har brugt på at spille en instance."
"message": "Minimer app"
},
"app.appearance-settings.show-play-time.title": {
"message": "Vis tid spillet"
},
"app.appearance-settings.toggle-sidebar.description": {
"message": "Aktivere muligheden for at slå sidepanelet til og fra."
"app.appearance-settings.skip-non-essential-warnings.title": {
"message": "Spring ikke nødvendige advarseler over"
},
"app.appearance-settings.toggle-sidebar.title": {
"message": "Slå sidepanel til og fra"
},
"app.appearance-settings.unknown-pack-warning.description": {
"message": "Hvis du prøver at installere en Modrinth Pack fil (.mrpack) som ikke er hosted på Modrinth, så skal vi nok sikre os du forstår riskoen før du installere den."
"message": "Gem højre sidebar"
},
"app.appearance-settings.unknown-pack-warning.title": {
"message": "Advar mig før jeg installere en ukendt modpacks"
@@ -146,9 +200,6 @@
"app.browse.back-to-instance": {
"message": "Tilbage til instance"
},
"app.browse.discover-content": {
"message": "Opdag indhold"
},
"app.browse.discover-servers": {
"message": "Opdag servere"
},
@@ -197,6 +248,42 @@
"app.install.phase.preparing_java.downloading": {
"message": "Downloader Java {version}"
},
"app.install.phase.preparing_java.fetching-metadata": {
"message": "Henter Java {version}"
},
"app.install.phase.preparing_java.resolving": {
"message": "Forbereder Java {version}"
},
"app.instance.admonitions.shared-instance.added-label": {
"message": "Tilføjet"
},
"app.instance.admonitions.shared-instance.publish-button": {
"message": "Skub opdatering"
},
"app.instance.admonitions.shared-instance.publishing-button": {
"message": "Skubber..."
},
"app.instance.admonitions.shared-instance.removed-label": {
"message": "Fjernet"
},
"app.instance.admonitions.shared-instance.review-admonition-header": {
"message": "Skub opdatering"
},
"app.instance.admonitions.shared-instance.review-header": {
"message": "Gennemså ændringer"
},
"app.instance.admonitions.shared-instance.review-update-button": {
"message": "Gennemgå opdatering"
},
"app.instance.admonitions.shared-instance.reviewing-button": {
"message": "Gennemgår..."
},
"app.instance.admonitions.shared-instance.update-available-body": {
"message": "En opdatering er krævet for at spille {name}. Venligst opdater til den seneste version for at køre spillet."
},
"app.instance.admonitions.shared-instance.update-available-header": {
"message": "En opdatering er tilgængelig"
},
"app.instance.confirm-delete.admonition-body": {
"message": "Al' data for din instance vil blive permanent slettet, dette inkludere dine verdener, konfigurationer, og alt installeret indhold."
},
@@ -227,6 +314,9 @@
"app.instance.mods.content-type-project": {
"message": "projekt"
},
"app.instance.mods.locked-content": {
"message": "Indhold i låste instances kan ikke blive ændret."
},
"app.instance.mods.project-was-added": {
"message": "\"{name}\" blev tilføjet"
},
@@ -242,12 +332,63 @@
"app.instance.mods.successfully-uploaded": {
"message": "Uploadet"
},
"app.instance.share.empty.description": {
"message": "Du kan ændre denne instance med dine venner!"
},
"app.instance.share.empty.heading": {
"message": "Ingen venner inviteret"
},
"app.instance.share.empty.invite-friends-button": {
"message": "Inviter venner"
},
"app.instance.share.invite-modal.heading": {
"message": "Del {name}"
},
"app.instance.share.locked.empty-description-suffix": {
"message": "for at tilgå denne side."
},
"app.instance.share.locked.signed-out-heading": {
"message": "Ikke logget ind"
},
"app.instance.share.locked.switch-account-button": {
"message": "Skift konto"
},
"app.instance.share.locked.wrong-account-heading": {
"message": "Forkert konto"
},
"app.instance.share.remove-user-modal.effects-label": {
"message": "Hvad vil der ske?"
},
"app.instance.share.remove-user-modal.user-avatar-alt": {
"message": "{username}'s avatar"
},
"app.instance.share.sign-in.button": {
"message": "Log ind"
},
"app.instance.shared-instance-already-installed.install-anyway": {
"message": "Installer alligevel"
},
"app.instance.shared-instance-already-installed.instance": {
"message": "Instans"
},
"app.instance.shared-instance-wrong-account.sign-in-as-label": {
"message": "Log ind som"
},
"app.instance.worlds.add-server": {
"message": "Tilføj server"
},
"app.instance.worlds.browse-servers": {
"message": "Søg servere"
},
"app.instance.worlds.delete-world-modal.delete-button": {
"message": "Slet verden"
},
"app.instance.worlds.delete-world-modal.title": {
"message": "Slet verden"
},
"app.instance.worlds.delete-world-modal.warning-header": {
"message": "Sletter {name}"
},
"app.instance.worlds.filter-modded": {
"message": "Moddet"
},
@@ -266,18 +407,42 @@
"app.instance.worlds.no-worlds-heading": {
"message": "Ingen servere eller verdener tilføjet"
},
"app.instance.worlds.remove-server-modal.remove-button": {
"message": "Fjern server"
},
"app.instance.worlds.remove-server-modal.title": {
"message": "Fjern server"
},
"app.instance.worlds.remove-server-modal.warning-header": {
"message": "Fjerner {name}"
},
"app.instance.worlds.search-worlds-placeholder": {
"message": "Søg {count} verdener..."
},
"app.modal.install-to-play.additional-context": {
"message": "Yderligere kontekst"
},
"app.modal.install-to-play.delete-instance": {
"message": "Slet instans"
},
"app.modal.install-to-play.header": {
"message": "Installer for at spille"
},
"app.modal.install-to-play.install-anyway": {
"message": "Installer alligevel"
},
"app.modal.install-to-play.install-button": {
"message": "Installer"
},
"app.modal.install-to-play.mod-count": {
"message": "{count, plural, one {# mod} other {# mods}}"
},
"app.modal.install-to-play.report-image-too-large": {
"message": "Filen overskrider 1MiB størrelse begrænsning"
},
"app.modal.install-to-play.report-reason.spam": {
"message": "Spam"
},
"app.modal.install-to-play.shared-instance": {
"message": "Delt instance"
},
@@ -287,15 +452,33 @@
"app.modal.update-to-play.header": {
"message": "Opdater for at spille"
},
"app.modal.update-to-play.update-required-description": {
"message": "En opdatering er krævet for at spille {name}. Venligst opdater til den seneste version for at køre spillet."
"app.modal.update-to-play.shared-instance-added-label": {
"message": "Tilføjede"
},
"app.modal.update-to-play.shared-instance-removed-label": {
"message": "Fjernede"
},
"app.modal.update-to-play.update-required": {
"message": "Opdatering krævet"
},
"app.project.install-button.already-installed": {
"message": "Dette projekt er allerede installeret"
},
"app.project.install-button.switch-version": {
"message": "Skift version"
},
"app.project.install-context.back-to-browse": {
"message": "Tilbage til udforsk"
},
"app.project.version.all-versions": {
"message": "Alle versioner"
},
"app.project.version.download-in-browser": {
"message": "Download i browser"
},
"app.project.version.installing": {
"message": "Installere"
},
"app.project.versions.already-installed": {
"message": "Allerede installeret"
},
@@ -308,9 +491,6 @@
"app.settings.tabs.appearance": {
"message": "Udseende"
},
"app.settings.tabs.default-instance-options": {
"message": "Standardindstillinger for instans"
},
"app.settings.tabs.java-installations": {
"message": "Javainstallationer"
},
@@ -356,6 +536,9 @@
"app.skins.modal.add-title": {
"message": "Tilføjer et skin"
},
"app.skins.modal.arm-style-section": {
"message": "Arm stil"
},
"app.skins.modal.arm-style-slim": {
"message": "Slank"
},
@@ -458,6 +641,12 @@
"app.skins.title": {
"message": "Skin vælger"
},
"app.skins.toggle-ears-features-off": {
"message": "Slå fra"
},
"app.skins.toggle-ears-features-on": {
"message": "Slå til"
},
"app.update-popup.body.download-complete": {
"message": "Modrinth App v{version} er færdig med at download. Genindlæs for at opdatere nu, eller automatisk når du lukker Modrinth App."
},
@@ -506,6 +695,9 @@
"app.world.world-item.players-online": {
"message": "{count} online"
},
"content.shared-instance.delete-button": {
"message": "Slet alligevel"
},
"friends.action.add-friend": {
"message": "Tilføj en ven"
},
@@ -569,6 +761,27 @@
"friends.sign-in-to-add-friends": {
"message": "<link>Log ind på en Modrinth konto</link> for at tilføje venner og se hvad de spiller!"
},
"instance.action.create-shortcut": {
"message": "Opret genvej"
},
"instance.action.export-modpack": {
"message": "Eksporter modpack"
},
"instance.action.more-actions": {
"message": "Flere handlinger"
},
"instance.action.open-folder": {
"message": "Åbn mappe"
},
"instance.action.settings": {
"message": "Instans indstillinger"
},
"instance.action.starting": {
"message": "Starter..."
},
"instance.action.stopping": {
"message": "Stopper..."
},
"instance.add-server.add-and-play": {
"message": "Tilføj og spil"
},
@@ -611,6 +824,15 @@
"instance.files.save-as": {
"message": "Gem som..."
},
"instance.locked.delete-button": {
"message": "Slet instans"
},
"instance.locked.play-tooltip": {
"message": "Denne instans er blevet låst"
},
"instance.playtime.never-played": {
"message": "Aldrig spillet"
},
"instance.server-modal.address": {
"message": "Adresse"
},
@@ -782,6 +1004,33 @@
"instance.settings.tabs.window.width.enter": {
"message": "Indtast bredde..."
},
"instance.shared-instance.error.title": {
"message": "Noget gik galt"
},
"instance.shared-instance.publish-review.added-label": {
"message": "Tilføjede"
},
"instance.shared-instance.publish-review.admonition-header": {
"message": "Skub opdatering til spillere"
},
"instance.shared-instance.publish-review.config-title-v2": {
"message": "Vælg konfigurations filer"
},
"instance.shared-instance.publish-review.header": {
"message": "Gennemså ændringer"
},
"instance.shared-instance.publish-review.publish-button": {
"message": "Skub opdatering"
},
"instance.shared-instance.publish-review.removed-label": {
"message": "Fjernede"
},
"instance.shared-instance.unavailable.locked-title": {
"message": "Instans låst"
},
"instance.shared-instance.unavailable.title": {
"message": "Delt instans er ikke længere tilgængelig"
},
"instance.worlds.a_minecraft_server": {
"message": "En Minecraft server"
},
@@ -791,6 +1040,9 @@
"instance.worlds.copy_address": {
"message": "Kopier adresse"
},
"instance.worlds.create_shortcut": {
"message": "Opret genvej"
},
"instance.worlds.dont_show_on_home": {
"message": "Vis ikke på forsiden"
},
@@ -842,6 +1094,51 @@
"minecraft-account.sign-in": {
"message": "Log ind på Minecraft"
},
"minecraft-required.description-header": {
"message": "Log ind med en Microsoft konto"
},
"minecraft-required.dont-have-account": {
"message": "Har du ikke en konto?"
},
"minecraft-required.get-minecraft": {
"message": "Få Minecraft"
},
"minecraft-required.get-support": {
"message": "Få hjælp"
},
"minecraft-required.header": {
"message": "Minecraft påkrævet"
},
"minecraft-required.sign-in": {
"message": "Log ind på Microsoft"
},
"modal.modrinth-account-required.continue-in-browser-heading": {
"message": "Fortsæt i din browser"
},
"modal.modrinth-account-required.create-account-button": {
"message": "Opret en konto"
},
"modal.modrinth-account-required.header": {
"message": "Konto påkrævet"
},
"modal.modrinth-account-required.open-browser-again-button": {
"message": "Åbn browser igen"
},
"modal.modrinth-account-required.sign-in-button": {
"message": "Log ind på Modrinth"
},
"modal.modrinth-account-required.sign-in-heading": {
"message": "Log ind med en Modrinth konto"
},
"modal.modrinth-account-required.signing-in-header": {
"message": "Logger ind"
},
"modal.modrinth-account-required.support-prompt": {
"message": "Har du problemer med at logge ind? <support>Få hjælp</support>"
},
"modal.modrinth-account-required.waiting-for-browser": {
"message": "Venter på browser bekræftigelse..."
},
"search.filter.locked.instance": {
"message": "Givet af instanceen"
},
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+404 -23
View File
@@ -144,19 +144,19 @@
"message": "Your privacy and how ads support Modrinth"
},
"app.appearance-settings.advanced-rendering.description": {
"message": "Enables advanced rendering such as blur effects that may cause performance issues without hardware-accelerated rendering."
"message": "Enable visual effects such as background blur. This may reduce performance without hardware acceleration."
},
"app.appearance-settings.advanced-rendering.title": {
"message": "Advanced rendering"
},
"app.appearance-settings.color-theme.description": {
"message": "Select your preferred color theme for Modrinth App."
"message": "Choose the color theme used by Modrinth App."
},
"app.appearance-settings.color-theme.title": {
"message": "Color theme"
},
"app.appearance-settings.default-landing-page.description": {
"message": "Change the page to which the launcher opens on."
"message": "Choose the page shown when Modrinth App opens."
},
"app.appearance-settings.default-landing-page.home": {
"message": "Home"
@@ -168,52 +168,49 @@
"message": "Default landing page"
},
"app.appearance-settings.hide-nametag.description": {
"message": "Disables the nametag above your player on the skins page."
"message": "Hide your username above the player preview on the Skin selector page."
},
"app.appearance-settings.hide-nametag.title": {
"message": "Hide nametag"
},
"app.appearance-settings.jump-back-into-worlds.description": {
"message": "Includes recent worlds in the \"Jump back in\" section on the Home page."
"message": "Show recently played worlds in the \"Jump back in\" section on the Home page."
},
"app.appearance-settings.jump-back-into-worlds.title": {
"message": "Jump back into worlds"
},
"app.appearance-settings.minimize-launcher.description": {
"message": "Minimize the launcher when a Minecraft process starts."
"message": "Minimize Modrinth App when Minecraft starts."
},
"app.appearance-settings.minimize-launcher.title": {
"message": "Minimize launcher"
"message": "Minimize app"
},
"app.appearance-settings.native-decorations.description": {
"message": "Use system window frame (app restart required)."
"message": "Use your operating system's title bar and window controls. Requires an app restart."
},
"app.appearance-settings.native-decorations.title": {
"message": "Native decorations"
},
"app.appearance-settings.select-option": {
"message": "Select an option"
"message": "System window frame"
},
"app.appearance-settings.show-play-time.description": {
"message": "Displays how much time you've spent playing an instance."
"message": "Show how long you've played each instance."
},
"app.appearance-settings.show-play-time.title": {
"message": "Show play time"
},
"app.appearance-settings.skip-non-essential-warnings.description": {
"message": "Automatically skips low-risk confirmations like duplicate modpack installs, normal content deletion, bulk updates, unlinking modpacks, and repair prompts. Dangerous warnings will still be shown."
"message": "Skip confirmations for low-risk actions such as duplicate installs, normal content deletion, bulk updates, unlinking, and repairs. Warnings for dangerous actions are always shown."
},
"app.appearance-settings.skip-non-essential-warnings.title": {
"message": "Skip non-essential warnings"
},
"app.appearance-settings.toggle-sidebar.description": {
"message": "Enables the ability to toggle the sidebar."
"message": "Hide the right sidebar by default and add a button to show or hide it."
},
"app.appearance-settings.toggle-sidebar.title": {
"message": "Toggle sidebar"
"message": "Hide right sidebar"
},
"app.appearance-settings.unknown-pack-warning.description": {
"message": "If you attempt to install a Modrinth Pack file (.mrpack) that isn't hosted on Modrinth, we'll make sure you understand the risks before installing it."
"message": "Show a safety warning before installing a Modrinth Pack (.mrpack) that isn't hosted on Modrinth."
},
"app.appearance-settings.unknown-pack-warning.title": {
"message": "Warn me before installing unknown modpacks"
@@ -224,6 +221,15 @@
"app.auth-servers.unreachable.header": {
"message": "Cannot reach authentication servers"
},
"app.behavior-settings.confirmations.title": {
"message": "Confirmations"
},
"app.behavior-settings.content.title": {
"message": "Home and content"
},
"app.behavior-settings.startup-and-navigation.title": {
"message": "Startup and navigation"
},
"app.browse.add-servers-to-instance": {
"message": "Adding server to instance"
},
@@ -245,8 +251,8 @@
"app.browse.back-to-instance": {
"message": "Back to instance"
},
"app.browse.discover-content": {
"message": "Discover content"
"app.browse.discover-project-type": {
"message": "Discover {projectType}"
},
"app.browse.discover-servers": {
"message": "Discover servers"
@@ -254,6 +260,9 @@
"app.browse.hide-added-servers": {
"message": "Hide servers already added"
},
"app.browse.hide-installed-modpacks": {
"message": "Hide already installed"
},
"app.browse.project-type.modpacks": {
"message": "Modpacks"
},
@@ -368,9 +377,18 @@
"app.instance.admonitions.shared-instance.review-header": {
"message": "Review changes"
},
"app.instance.admonitions.shared-instance.review-update-button": {
"message": "Review update"
},
"app.instance.admonitions.shared-instance.reviewing-button": {
"message": "Reviewing..."
},
"app.instance.admonitions.shared-instance.update-available-body": {
"message": "An update is required to play {name}. Please update to latest version to launch the game."
},
"app.instance.admonitions.shared-instance.update-available-header": {
"message": "An update is available"
},
"app.instance.confirm-delete.admonition-body": {
"message": "All data for your instance will be permanently deleted, including your worlds, configs, and all installed content."
},
@@ -437,6 +455,9 @@
"app.instance.share.invite-modal.heading": {
"message": "Share {name}"
},
"app.instance.share.invite-modal.user-limit-reached": {
"message": "This instance has reached the {limit}-user limit."
},
"app.instance.share.locked.empty-description-prefix": {
"message": "You need to sign in as"
},
@@ -455,6 +476,12 @@
"app.instance.share.locked.wrong-account-heading": {
"message": "Wrong account"
},
"app.instance.share.members.empty": {
"message": "No users have joined yet"
},
"app.instance.share.members.no-filter-results": {
"message": "No users match your filters."
},
"app.instance.share.remove-user-modal.effect-access": {
"message": "They will no longer receive updates for this shared instance"
},
@@ -485,6 +512,12 @@
"app.instance.share.sign-in.button": {
"message": "Sign in"
},
"app.instance.share.unable-to-connect.description": {
"message": "The shared instances service is not accessible at the moment, please try again later"
},
"app.instance.share.unable-to-connect.heading": {
"message": "Unable to connect"
},
"app.instance.share.unlink.body": {
"message": "You must unlink this modpack to share your instance"
},
@@ -557,6 +590,9 @@
"app.instance.worlds.no-worlds-heading": {
"message": "No servers or worlds added"
},
"app.instance.worlds.refreshing": {
"message": "Refreshing..."
},
"app.instance.worlds.remove-server-modal.remove-button": {
"message": "Remove server"
},
@@ -572,12 +608,60 @@
"app.instance.worlds.search-worlds-placeholder": {
"message": "Search {count} worlds..."
},
"app.java-detection.cancel": {
"message": "Cancel"
},
"app.java-detection.columns.actions": {
"message": "Actions"
},
"app.java-detection.columns.path": {
"message": "Path"
},
"app.java-detection.columns.version": {
"message": "Version"
},
"app.java-detection.no-installations-found": {
"message": "No Java installations found."
},
"app.java-detection.select": {
"message": "Select"
},
"app.java-detection.selected": {
"message": "Selected"
},
"app.java-detection.title": {
"message": "Select Java installation"
},
"app.java-selector.already-installed": {
"message": "Already installed"
},
"app.java-selector.browse": {
"message": "Browse"
},
"app.java-selector.detect": {
"message": "Detect"
},
"app.java-selector.install-recommended": {
"message": "Install recommended"
},
"app.java-selector.installing": {
"message": "Installing..."
},
"app.java-selector.path.placeholder": {
"message": "/path/to/java"
},
"app.java-selector.test-installation": {
"message": "Test Java installation"
},
"app.modal.install-to-play.additional-context": {
"message": "Additional context"
},
"app.modal.install-to-play.additional-context-placeholder": {
"message": "Include links and images if possible and relevant"
},
"app.modal.install-to-play.block-user": {
"message": "Block user"
},
"app.modal.install-to-play.content-you-are-reporting": {
"message": "Instance youre reporting"
},
@@ -599,6 +683,9 @@
"app.modal.install-to-play.invite-warning": {
"message": "This invite was created by another Modrinth user, not Modrinth. Only accept invites from people you trust."
},
"app.modal.install-to-play.invite-warning-with-creator": {
"message": "This invite was created by <creator>{username}</creator>, not Modrinth. Only accept invites from people you trust."
},
"app.modal.install-to-play.mod-count": {
"message": "{count, plural, one {# mod} other {# mods}}"
},
@@ -656,6 +743,9 @@
"app.modal.install-to-play.unrecognized-files": {
"message": "Unrecognized files"
},
"app.modal.install-to-play.user-blocked": {
"message": "User blocked"
},
"app.modal.install-to-play.view-contents": {
"message": "View contents"
},
@@ -683,6 +773,36 @@
"app.modal.update-to-play.update-required-description": {
"message": "An update is required to play {name}. Please update to latest version to launch the game."
},
"app.nav.create-new-instance": {
"message": "Create new instance"
},
"app.nav.home": {
"message": "Home"
},
"app.nav.library": {
"message": "Library"
},
"app.nav.modrinth-account": {
"message": "Modrinth account"
},
"app.nav.modrinth-hosting": {
"message": "Modrinth Hosting"
},
"app.nav.sign-in-to-modrinth-account": {
"message": "Sign in to a Modrinth account"
},
"app.nav.signed-in-as": {
"message": "Signed in as <user>{username}</user>"
},
"app.nav.upgrade-to-modrinth-plus": {
"message": "Upgrade to Modrinth+"
},
"app.news.title": {
"message": "News"
},
"app.news.view-all": {
"message": "View all news"
},
"app.project.install-button.already-installed": {
"message": "This project is already installed"
},
@@ -692,6 +812,9 @@
"app.project.install-context.back-to-browse": {
"message": "Back to discover"
},
"app.project.install-context.back-to-instance": {
"message": "Back to instance"
},
"app.project.version.all-versions": {
"message": "All versions"
},
@@ -704,20 +827,188 @@
"app.project.versions.already-installed": {
"message": "Already installed"
},
"app.quick-instance-switcher.drag-show-tooltip": {
"message": "Drag to show recent instances"
},
"app.quick-instance-switcher.drag-tooltip": {
"message": "Drag to resize"
},
"app.restarting": {
"message": "Restarting..."
},
"app.settings.app-version": {
"message": "Modrinth App {version}"
},
"app.settings.default-instance-options.environment-variables.description": {
"message": "Environment variables set when launching an instance."
},
"app.settings.default-instance-options.environment-variables.placeholder": {
"message": "Enter environment variables..."
},
"app.settings.default-instance-options.environment-variables.title": {
"message": "Environment variables"
},
"app.settings.default-instance-options.fullscreen.description": {
"message": "Start instances in fullscreen by updating their options.txt file."
},
"app.settings.default-instance-options.fullscreen.title": {
"message": "Fullscreen"
},
"app.settings.default-instance-options.height.description": {
"message": "The height of the game window when launched."
},
"app.settings.default-instance-options.height.placeholder": {
"message": "Enter height..."
},
"app.settings.default-instance-options.height.title": {
"message": "Height"
},
"app.settings.default-instance-options.java-arguments.description": {
"message": "Arguments passed to Java when launching an instance."
},
"app.settings.default-instance-options.java-arguments.placeholder": {
"message": "Enter Java arguments..."
},
"app.settings.default-instance-options.java-arguments.title": {
"message": "Java arguments"
},
"app.settings.default-instance-options.memory-allocation.description": {
"message": "Maximum memory available to each instance."
},
"app.settings.default-instance-options.memory-allocation.title": {
"message": "Memory allocation"
},
"app.settings.default-instance-options.post-exit-hook.description": {
"message": "Runs after the game closes."
},
"app.settings.default-instance-options.post-exit-hook.placeholder": {
"message": "Enter post-exit command..."
},
"app.settings.default-instance-options.post-exit-hook.title": {
"message": "Post-exit hook"
},
"app.settings.default-instance-options.pre-launch-hook.description": {
"message": "Runs before the instance starts."
},
"app.settings.default-instance-options.pre-launch-hook.placeholder": {
"message": "Enter pre-launch command..."
},
"app.settings.default-instance-options.pre-launch-hook.title": {
"message": "Pre-launch hook"
},
"app.settings.default-instance-options.width.description": {
"message": "The width of the game window when launched."
},
"app.settings.default-instance-options.width.placeholder": {
"message": "Enter width..."
},
"app.settings.default-instance-options.width.title": {
"message": "Width"
},
"app.settings.default-instance-options.wrapper-hook.description": {
"message": "Command used to wrap the Minecraft launch process."
},
"app.settings.default-instance-options.wrapper-hook.placeholder": {
"message": "Enter wrapper command..."
},
"app.settings.default-instance-options.wrapper-hook.title": {
"message": "Wrapper hook"
},
"app.settings.developer-mode-button.label": {
"message": "Toggle developer mode"
},
"app.settings.developer-mode-enabled": {
"message": "Developer mode enabled."
},
"app.settings.downloading": {
"message": "Downloading v{version}"
},
"app.settings.java-installations.location.title": {
"message": "Java {version, number} location"
},
"app.settings.operating-system.macos": {
"message": "macOS"
},
"app.settings.privacy.ads-consent.intro": {
"message": "Ads make Modrinth possible and fund creator payouts. Our partners may store or access cookies in the app to personalize ads and measure performance. You can opt out or manage your preferences below."
},
"app.settings.privacy.discord-rich-presence.description": {
"message": "Show Modrinth App as your current activity on Discord. This does not affect Rich Presence added to instances by mods. Requires an app restart."
},
"app.settings.privacy.discord-rich-presence.title": {
"message": "Discord Rich Presence"
},
"app.settings.privacy.telemetry.description": {
"message": "Modrinth collects anonymized analytics and usage data to improve our user experience and customize your experience. By disabling this option, you opt out and your data will no longer be collected."
},
"app.settings.privacy.telemetry.title": {
"message": "Telemetry"
},
"app.settings.resource-management.always-show-copy-details.description": {
"message": "Show the Copy details action while an install is queued or running. It is always available for failed or interrupted installs."
},
"app.settings.resource-management.always-show-copy-details.title": {
"message": "Always show copy details"
},
"app.settings.resource-management.app-cache.confirm.description": {
"message": "The app may load more slowly until the cache is rebuilt."
},
"app.settings.resource-management.app-cache.confirm.title": {
"message": "Purge the app cache?"
},
"app.settings.resource-management.app-cache.description": {
"message": "Clear cached data and download it again from Modrinth. The app may load more slowly until the cache is rebuilt."
},
"app.settings.resource-management.app-cache.purge": {
"message": "Purge cache"
},
"app.settings.resource-management.app-cache.title": {
"message": "App cache"
},
"app.settings.resource-management.app-database-backups.description": {
"message": "Backups of important app data are stored here in case you need to recover them later."
},
"app.settings.resource-management.app-database-backups.open-folder": {
"message": "Open backups folder"
},
"app.settings.resource-management.app-database-backups.title": {
"message": "App database backups"
},
"app.settings.resource-management.app-directory.browse": {
"message": "Browse for an app directory"
},
"app.settings.resource-management.app-directory.description": {
"message": "Where Modrinth App stores instances and other files. Changes take effect after restarting the app."
},
"app.settings.resource-management.app-directory.select": {
"message": "Select a new app directory"
},
"app.settings.resource-management.app-directory.title": {
"message": "App directory"
},
"app.settings.resource-management.maximum-concurrent-downloads.description": {
"message": "Number of files the app can download at once. Lower this if downloads are unreliable on your connection. Requires an app restart."
},
"app.settings.resource-management.maximum-concurrent-downloads.title": {
"message": "Maximum concurrent downloads"
},
"app.settings.resource-management.maximum-concurrent-writes.description": {
"message": "Number of files the app can write to disk at once. Lower this if you frequently encounter I/O errors. Requires an app restart."
},
"app.settings.resource-management.maximum-concurrent-writes.title": {
"message": "Maximum concurrent writes"
},
"app.settings.sidebar.label.instances": {
"message": "Instances"
},
"app.settings.tabs.appearance": {
"message": "Appearance"
},
"app.settings.tabs.behavior": {
"message": "Behavior"
},
"app.settings.tabs.default-instance-options": {
"message": "Default instance options"
"message": "Default game options"
},
"app.settings.tabs.java-installations": {
"message": "Java installations"
@@ -731,6 +1022,9 @@
"app.settings.tabs.resource-management": {
"message": "Resource management"
},
"app.sidebar.playing-as": {
"message": "Playing as"
},
"app.skins.add-button": {
"message": "Add skin"
},
@@ -890,6 +1184,21 @@
"app.skins.toggle-ears-features-on": {
"message": "Toggle on"
},
"app.survey.body": {
"message": "Would you mind answering a few questions about your experience with Modrinth App?"
},
"app.survey.footer": {
"message": "This feedback will go directly to the Modrinth team and help guide future updates!"
},
"app.survey.no-thanks": {
"message": "No thanks"
},
"app.survey.take-survey": {
"message": "Take survey"
},
"app.survey.title": {
"message": "Hey there Modrinth user!"
},
"app.update-popup.body.download-complete": {
"message": "Modrinth App v{version} has finished downloading. Reload to update now, or automatically when you close Modrinth App."
},
@@ -920,6 +1229,9 @@
"app.update.complete-toast.title": {
"message": "Version {version} was successfully installed!"
},
"app.user.project.install-to-instance": {
"message": "Install to instance"
},
"app.world.server-modal.placeholder-address": {
"message": "example.modrinth.gg"
},
@@ -1166,6 +1478,45 @@
"instance.server-modal.resource-pack": {
"message": "Resource pack"
},
"instance.settings.sharing.active-invites.actions": {
"message": "Actions"
},
"instance.settings.sharing.active-invites.code": {
"message": "Invite link"
},
"instance.settings.sharing.active-invites.description": {
"message": "Anyone with one of these invite links can join while it remains active."
},
"instance.settings.sharing.active-invites.empty": {
"message": "There are no active invites."
},
"instance.settings.sharing.active-invites.expires": {
"message": "Expires"
},
"instance.settings.sharing.active-invites.revoke": {
"message": "Revoke invite"
},
"instance.settings.sharing.active-invites.revoke-with-code": {
"message": "Revoke invite {code}"
},
"instance.settings.sharing.active-invites.title": {
"message": "Active invites"
},
"instance.settings.sharing.active-invites.uses": {
"message": "Uses"
},
"instance.settings.sharing.revoke-invite.admonition-body": {
"message": "The invite link <monospace>{code}</monospace> will stop working immediately. People who already joined will keep access."
},
"instance.settings.sharing.revoke-invite.admonition-header": {
"message": "This action cannot be undone"
},
"instance.settings.sharing.revoke-invite.confirm": {
"message": "Revoke invite"
},
"instance.settings.sharing.revoke-invite.header": {
"message": "Revoke invite"
},
"instance.settings.tabs.general": {
"message": "General"
},
@@ -1274,6 +1625,27 @@
"instance.settings.tabs.hooks.title": {
"message": "Game launch hooks"
},
"instance.settings.tabs.hooks.variables.description": {
"message": "Hooks run in the working directory of the instance, with the following variables:"
},
"instance.settings.tabs.hooks.variables.inst-dir.description": {
"message": "$INST_DIR: The absolute path to the instance's folder"
},
"instance.settings.tabs.hooks.variables.inst-id.description": {
"message": "$INST_ID: The name of the instance's folder"
},
"instance.settings.tabs.hooks.variables.inst-java-args.description": {
"message": "$INST_JAVA_ARGS: The JVM Arguments provided to the game"
},
"instance.settings.tabs.hooks.variables.inst-java.description": {
"message": "$INST_JAVA: The absolute path to the java binary"
},
"instance.settings.tabs.hooks.variables.inst-mc-dir.description": {
"message": "$INST_MC_DIR: An alias for $INST_DIR"
},
"instance.settings.tabs.hooks.variables.inst-name.description": {
"message": "$INST_NAME: The name of the instance"
},
"instance.settings.tabs.hooks.wrapper": {
"message": "Wrapper"
},
@@ -1367,6 +1739,12 @@
"instance.shared-instance.error.title": {
"message": "Something has gone wrong"
},
"instance.shared-instance.network-error.text": {
"message": "Unable to connect to shared instances API"
},
"instance.shared-instance.network-error.title": {
"message": "Network error"
},
"instance.shared-instance.owner-tooltip": {
"message": "This instance's content is being shared to other users."
},
@@ -1502,9 +1880,6 @@
"modal.modrinth-account-required.browser-description": {
"message": "A new tab opened to sign in. Complete the sign in there, then return to the app."
},
"modal.modrinth-account-required.cancel-button": {
"message": "Cancel"
},
"modal.modrinth-account-required.continue-in-browser-heading": {
"message": "Continue in your browser"
},
@@ -1558,5 +1933,11 @@
},
"search.filter.locked.server-loader.title": {
"message": "Loader is provided by the server"
},
"settings.sidebar.label.account": {
"message": "Account"
},
"settings.sidebar.label.display": {
"message": "Display"
}
}
+444 -24
View File
@@ -89,6 +89,9 @@
"app.action-bar.install.unknown-instance": {
"message": "Instancia desconocida"
},
"app.action-bar.install.updating-shared-content": {
"message": "Actualizando el contenido compartido"
},
"app.action-bar.installs": {
"message": "Instalaciones"
},
@@ -125,20 +128,35 @@
"app.action-bar.view-logs": {
"message": "Ver registros"
},
"app.ads-consent.accept": {
"message": "Aceptar todas"
},
"app.ads-consent.body": {
"message": "Los anuncios hacen posible a Modrinth y financian los pagos a los creadores. Nuestros socios pueden guardar o acceder a cookies en la aplicación para personalizar anuncios y medir su rendimiento."
},
"app.ads-consent.manage": {
"message": "Gestionar preferencias"
},
"app.ads-consent.reject": {
"message": "Rechazar todas"
},
"app.ads-consent.title": {
"message": "Tu privacidad y como los anuncios apoyan a Modrinth"
},
"app.appearance-settings.advanced-rendering.description": {
"message": "Activa el renderizado avanzado, como los efectos de desenfoque, que pueden afectar el rendimiento sin aceleración por hardware."
"message": "Activar efectos visuales como los efectos de desenfoque. Esto puede reducir el rendimiento si no tienes aceleración de hardware."
},
"app.appearance-settings.advanced-rendering.title": {
"message": "Renderizado avanzado"
},
"app.appearance-settings.color-theme.description": {
"message": "Selecciona el tema de color que prefieras para la Modrinth App."
"message": "Escoge el esquema de colores usado por la aplicación de Modrinth."
},
"app.appearance-settings.color-theme.title": {
"message": "Color de la interfaz"
},
"app.appearance-settings.default-landing-page.description": {
"message": "Cambia la página en la que se abre el launcher."
"message": "Escoge la página mostrada al abrir la aplicación de Modrinth."
},
"app.appearance-settings.default-landing-page.home": {
"message": "Inicio"
@@ -150,52 +168,49 @@
"message": "Página de inicio predeterminada"
},
"app.appearance-settings.hide-nametag.description": {
"message": "Desactiva la etiqueta de nombre arriba de tu personaje en la página de skins."
"message": "Esconde tu nombre de usuario encima de tu personaje en la página del selector de Skin."
},
"app.appearance-settings.hide-nametag.title": {
"message": "Ocultar etiqueta de nombre"
},
"app.appearance-settings.jump-back-into-worlds.description": {
"message": "Incluye los mundos recientes en la sección \"Volver a jugar\" de la página de inicio."
"message": "Muestra los mundos recientes en la sección \"Volver a jugar\" en la página principal."
},
"app.appearance-settings.jump-back-into-worlds.title": {
"message": "Volver a jugar mundos"
},
"app.appearance-settings.minimize-launcher.description": {
"message": "Minimiza el launcher al iniciar un proceso de Minecraft."
"message": "Minimiza la app de Modrinth al iniciar Minecraft."
},
"app.appearance-settings.minimize-launcher.title": {
"message": "Minimizar launcher"
"message": "Minimizar app"
},
"app.appearance-settings.native-decorations.description": {
"message": "Usar el borde de ventana del sistema (requiere reiniciar la aplicación)."
"message": "Usa el borde de ventana de tu sistema. Requiere reiniciar la aplicación."
},
"app.appearance-settings.native-decorations.title": {
"message": "Decoraciones nativas"
},
"app.appearance-settings.select-option": {
"message": "Selecciona una opción"
},
"app.appearance-settings.show-play-time.description": {
"message": "Muestra cuánto tiempo has estado jugando en una instancia."
"message": "Muestra cuanto tiempo has jugado en cada instancia."
},
"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."
"message": "Omite las confirmaciones para acciones 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 de acciones peligrosas siempre se mostrarán."
},
"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."
"message": "Añade un botón para mostrar y esconder la barra lateral."
},
"app.appearance-settings.toggle-sidebar.title": {
"message": "Mostrar u ocultar la barra lateral"
},
"app.appearance-settings.unknown-pack-warning.description": {
"message": "Si intentas instalar un paquete de Modrinth (.mrpack) que no está alojado en Modrinth, te explicaremos los riesgos antes de instalarlo."
"message": "Muestra una advertencia antes de instalar un Modrinth Pack (.mrpack) que no provenga de los servidores de Modrinth."
},
"app.appearance-settings.unknown-pack-warning.title": {
"message": "Advertir antes de instalar modpacks desconocidos"
@@ -206,6 +221,15 @@
"app.auth-servers.unreachable.header": {
"message": "No se puede acceder a los servidores de autenticación"
},
"app.behavior-settings.confirmations.title": {
"message": "Confirmaciones"
},
"app.behavior-settings.content.title": {
"message": "Página principal y contenido"
},
"app.behavior-settings.startup-and-navigation.title": {
"message": "Inicio y navegación"
},
"app.browse.add-servers-to-instance": {
"message": "Añadiendo servidor a instancia"
},
@@ -227,8 +251,8 @@
"app.browse.back-to-instance": {
"message": "Volver a instancia"
},
"app.browse.discover-content": {
"message": "Descubrir contenido"
"app.browse.discover-project-type": {
"message": "Descubrir {projectType}"
},
"app.browse.discover-servers": {
"message": "Descubrir servidores"
@@ -236,6 +260,9 @@
"app.browse.hide-added-servers": {
"message": "Ocultar los servidores ya agregados"
},
"app.browse.hide-installed-modpacks": {
"message": "Esconder los ya instalados"
},
"app.browse.project-type.modpacks": {
"message": "Modpacks"
},
@@ -323,6 +350,45 @@
"app.install.phase.running_loader_processors": {
"message": "Ejecutando los procesadores del cargador"
},
"app.instance.admonitions.shared-instance.added-label": {
"message": "Añadido"
},
"app.instance.admonitions.shared-instance.changes-body": {
"message": "Tu instancia local tiene cambios nuevos que todavía no se han compartido."
},
"app.instance.admonitions.shared-instance.changes-header": {
"message": "Tus cambios todavía no se han compartido"
},
"app.instance.admonitions.shared-instance.publish-button": {
"message": "Enviar actualización"
},
"app.instance.admonitions.shared-instance.publishing-button": {
"message": "Enviando..."
},
"app.instance.admonitions.shared-instance.removed-label": {
"message": "Eliminado"
},
"app.instance.admonitions.shared-instance.review-admonition-header": {
"message": "Enviar actualización"
},
"app.instance.admonitions.shared-instance.review-description": {
"message": "Revisar los cambios de contenido que se compartirán con los usuarios de esta instancia."
},
"app.instance.admonitions.shared-instance.review-header": {
"message": "Revisar cambios"
},
"app.instance.admonitions.shared-instance.review-update-button": {
"message": "Actualizar"
},
"app.instance.admonitions.shared-instance.reviewing-button": {
"message": "Actualizando..."
},
"app.instance.admonitions.shared-instance.update-available-body": {
"message": "Se requiere una actualización para jugar a {name}. por favor actualiza a la versión más reciente para abrir el juego."
},
"app.instance.admonitions.shared-instance.update-available-header": {
"message": "Hay una actualización disponible"
},
"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."
},
@@ -359,6 +425,9 @@
"app.instance.mods.content-type-project": {
"message": "proyecto"
},
"app.instance.mods.locked-content": {
"message": "El contenido en instancias bloqueadas no se puede cambiar."
},
"app.instance.mods.project-was-added": {
"message": "Se añadió \"{name}\""
},
@@ -374,6 +443,117 @@
"app.instance.mods.successfully-uploaded": {
"message": "Cargado correctamente"
},
"app.instance.share.empty.description": {
"message": "¡Comparte esta instancia con tus amigos!"
},
"app.instance.share.empty.heading": {
"message": "No hay amigos invitados"
},
"app.instance.share.empty.invite-friends-button": {
"message": "Invitar amigos"
},
"app.instance.share.invite-modal.heading": {
"message": "Compartir {name}"
},
"app.instance.share.invite-modal.user-limit-reached": {
"message": "Esta instancia ha llegado al límite de {limit} usuarios."
},
"app.instance.share.locked.empty-description-prefix": {
"message": "Necesitas iniciar sesión como"
},
"app.instance.share.locked.empty-description-suffix": {
"message": "para acceder a esta página."
},
"app.instance.share.locked.linked-account-fallback": {
"message": "la cuenta vinculada"
},
"app.instance.share.locked.signed-out-heading": {
"message": "No estás registrado"
},
"app.instance.share.locked.switch-account-button": {
"message": "Cambiar de cuenta"
},
"app.instance.share.locked.wrong-account-heading": {
"message": "Cuenta equivocada"
},
"app.instance.share.members.empty": {
"message": "Nadie se ha unido todavía"
},
"app.instance.share.members.no-filter-results": {
"message": "Ningún usuario coincide con tus filtros."
},
"app.instance.share.remove-user-modal.effect-access": {
"message": "Ya no recibirán actualizaciones para esta instancia compartida"
},
"app.instance.share.remove-user-modal.effect-installed-copy": {
"message": "Cualquier copia que tengan instalada se quedará en su dispositivo"
},
"app.instance.share.remove-user-modal.effect-invite-again": {
"message": "Los puedes volver a invitar más adelante"
},
"app.instance.share.remove-user-modal.effect-last-user": {
"message": "Este es el último usuario, se desactivará el compartir para esta instancia"
},
"app.instance.share.remove-user-modal.effects-label": {
"message": "¿Qué pasará?"
},
"app.instance.share.remove-user-modal.header": {
"message": "Revocar acceso"
},
"app.instance.share.remove-user-modal.remove-button": {
"message": "Revocar acceso"
},
"app.instance.share.remove-user-modal.user-avatar-alt": {
"message": "Avatar de {username}"
},
"app.instance.share.remove-user-modal.warning-body": {
"message": "Si revocas el acceso de {username} a esta instancia, tendrás que invitarlos otra vez para que sigan recibiendo actualizaciones."
},
"app.instance.share.sign-in.button": {
"message": "Iniciar sesión"
},
"app.instance.share.unable-to-connect.description": {
"message": "El servicio de instancias compartidas no está disponible, inténtalo otra vez más tarde"
},
"app.instance.share.unable-to-connect.heading": {
"message": "No se puede conectar"
},
"app.instance.share.unlink.body": {
"message": "Debes desvincular este modpack para compartir tu instancia"
},
"app.instance.share.unlink.header": {
"message": "Compartir requiere desvincular"
},
"app.instance.shared-instance-already-installed.body": {
"message": "Esta instancia compartida ya está instalada como <bold>{instanceName}</bold>. ¿Estás seguro que quieres instalar otra copia?"
},
"app.instance.shared-instance-already-installed.header": {
"message": "Instancia compartida ya instalada"
},
"app.instance.shared-instance-already-installed.install-anyway": {
"message": "Instalar de todas formas"
},
"app.instance.shared-instance-already-installed.instance": {
"message": "Instancia"
},
"app.instance.shared-instance-wrong-account.fallback-username": {
"message": "la cuenta vinculada"
},
"app.instance.shared-instance-wrong-account.owner-admonition-body-v2": {
"message": "para gestionar esta instancia compartida. No podrás enviar actualizaciones a los usuarios."
},
"app.instance.shared-instance-wrong-account.sign-in-as-label": {
"message": "Inicia sesión con"
},
"app.instance.shared-instance-wrong-account.signed-out-header": {
"message": "Debes iniciar sesión en Modrinth"
},
"app.instance.shared-instance-wrong-account.user-admonition-body-v2": {
"message": "para recibir actualizaciones de esta instancia compartida."
},
"app.instance.shared-instance-wrong-account.warning-header": {
"message": "Estás usando la cuenta equivocada"
},
"app.instance.worlds.add-server": {
"message": "Añadir servidor"
},
@@ -417,7 +597,7 @@
"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."
"message": "Este servidor se eliminará de tu lista de servidores y de la lista de servidores del juego. Puedes volver a añadirlo más adelante si conoces la dirección."
},
"app.instance.worlds.remove-server-modal.warning-header": {
"message": "Se está eliminando {name}"
@@ -425,6 +605,69 @@
"app.instance.worlds.search-worlds-placeholder": {
"message": "Buscar en {count} mundos..."
},
"app.java-detection.cancel": {
"message": "Cancelar"
},
"app.java-detection.columns.actions": {
"message": "Acciones"
},
"app.java-detection.columns.path": {
"message": "Directorio"
},
"app.java-detection.columns.version": {
"message": "Versión"
},
"app.java-detection.no-installations-found": {
"message": "No se encontraron instalaciones de Java."
},
"app.java-detection.select": {
"message": "Seleccionar"
},
"app.java-detection.selected": {
"message": "Seleccionado"
},
"app.java-detection.title": {
"message": "Seleccionar instalación de Java"
},
"app.java-selector.already-installed": {
"message": "Ya está instalado"
},
"app.java-selector.browse": {
"message": "Buscar"
},
"app.java-selector.detect": {
"message": "Detectar"
},
"app.java-selector.install-recommended": {
"message": "Instalar recomendado"
},
"app.java-selector.installing": {
"message": "Instalando..."
},
"app.java-selector.path.placeholder": {
"message": "/ruta/a/java"
},
"app.java-selector.test-installation": {
"message": "Probar instalación de Java"
},
"app.modal.install-to-play.additional-context": {
"message": "Contexto adicional"
},
"app.modal.install-to-play.additional-context-placeholder": {
"message": "En lo posible, incluye links e imágenes (si son relevantes)"
},
"app.modal.install-to-play.block-user": {
"message": "Bloquear usuario"
},
"app.modal.install-to-play.content-you-are-reporting": {
"message": "Instancia que estás reportando"
},
"app.modal.install-to-play.delete-instance": {
"message": "Borrar instancia"
},
"app.modal.install-to-play.external-files-dont-install": {
"message": "No instalar"
},
"app.modal.install-to-play.header": {
"message": "Instalar para jugar"
},
@@ -437,15 +680,54 @@
"app.modal.install-to-play.invite-warning": {
"message": "Esta invitación la creó otro usuario de Modrinth, no Modrinth. Solo acepta invitaciones de personas en las que confíes."
},
"app.modal.install-to-play.invite-warning-with-creator": {
"message": "Esta invitación fue creada por <creator>{username}</creator>, no por Modrinth. Nunca aceptes invitaciones de gente que no conozcas."
},
"app.modal.install-to-play.mod-count": {
"message": "{count, plural, one {# mod} other {# mods}}"
},
"app.modal.install-to-play.report-description": {
"message": "Usa este formulario para reportar instancias que violen nuestras <rules-link>Reglas</rules-link> o <terms-link>Terminos de Uso</terms-link>."
},
"app.modal.install-to-play.report-image-invalid-type": {
"message": "El archivo no tiene una extensión de imagen aceptada"
},
"app.modal.install-to-play.report-image-too-large": {
"message": "El archivo excede el límite de 1 MiB"
},
"app.modal.install-to-play.report-legal-claims": {
"message": "Para avisos de DMCA u otros reclamos legales, consulte nuestra <copyright-link>Política de Derechos de Autor</copyright-link>."
},
"app.modal.install-to-play.report-reason": {
"message": "¿Qué regla rompe esta instancia?"
},
"app.modal.install-to-play.report-reason.inappropriate": {
"message": "Inapropiado"
},
"app.modal.install-to-play.report-reason.malicious": {
"message": "Malicioso"
},
"app.modal.install-to-play.report-reason.spam": {
"message": "Spam"
},
"app.modal.install-to-play.report-shared-instance-header": {
"message": "Reportar instancia compartida"
},
"app.modal.install-to-play.report-submitted": {
"message": "Reporte enviado"
},
"app.modal.install-to-play.report-support-and-bugs": {
"message": "Para solicitudes de ayuda, contacte a nuestro <support-link>equipo de soporte</support-link>. Para reportar un bug, abra una <github-link>incidencia (issue) en GitHub</github-link>."
},
"app.modal.install-to-play.reviewed-files": {
"message": "Un archivo solo se revisa si se publica en Modrinth, sin importar su formato (incluido el .mrpack)."
},
"app.modal.install-to-play.shared-instance": {
"message": "Instancia compartida"
},
"app.modal.install-to-play.shared-instance-content": {
"message": "Contenido de la instancia compartida"
},
"app.modal.install-to-play.unknown-files-description": {
"message": "Este modpack para servidor contiene archivos que no están publicados en Modrinth. Recomendamos encarecidamente instalar únicamente archivos de fuentes de confianza."
},
@@ -464,8 +746,8 @@
"app.modal.update-to-play.removed": {
"message": "Eliminado"
},
"app.modal.update-to-play.update-required-description": {
"message": "Se requiere una actualización para jugar {name}. Por favor, actualiza a la versión más reciente para iniciar el juego."
"app.nav.signed-in-as": {
"message": "Sesión iniciada como <user>{username}</user>"
},
"app.project.install-button.already-installed": {
"message": "Este proyecto ya está instalado"
@@ -494,11 +776,23 @@
"app.settings.downloading": {
"message": "Descargando v{version}"
},
"app.settings.privacy.discord-rich-presence.title": {
"message": "Discord Rich Presence"
},
"app.settings.privacy.telemetry.title": {
"message": "Telemetría"
},
"app.settings.resource-management.app-directory.title": {
"message": "Directorio de la aplicación"
},
"app.settings.sidebar.label.instances": {
"message": "Instancias"
},
"app.settings.tabs.appearance": {
"message": "Apariencia"
},
"app.settings.tabs.default-instance-options": {
"message": "Opciones predeterminadas de la instancia"
"app.settings.tabs.behavior": {
"message": "Comportamiento"
},
"app.settings.tabs.java-installations": {
"message": "Instalaciones de Java"
@@ -512,6 +806,9 @@
"app.settings.tabs.resource-management": {
"message": "Gestión de recursos"
},
"app.sidebar.playing-as": {
"message": "Jugando como"
},
"app.skins.add-button": {
"message": "Añadir skin"
},
@@ -536,6 +833,9 @@
"app.skins.dropped-file-error.title": {
"message": "Error al procesar el archivo"
},
"app.skins.ears-feature-notice": {
"message": "Esta skin usa características de el mod {ears}"
},
"app.skins.edit-button": {
"message": "Editar skin"
},
@@ -662,6 +962,21 @@
"app.skins.title": {
"message": "Selector de skin"
},
"app.skins.toggle-ears-features-off": {
"message": "Desactivar"
},
"app.skins.toggle-ears-features-on": {
"message": "Activar"
},
"app.survey.footer": {
"message": "¡Tus comentarios llegarán directamente al equipo de Modrinth, y los ayudará a hacer mejores actualizaciones!"
},
"app.survey.no-thanks": {
"message": "No gracias"
},
"app.survey.title": {
"message": "¡Hola, usuario de Modrinth!"
},
"app.update-popup.body.download-complete": {
"message": "La descarga de la Modrinth App v{version} ha finalizado. Actualiza ahora o automáticamente al cerrar la Modrinth App."
},
@@ -710,6 +1025,18 @@
"app.world.world-item.players-online": {
"message": "{count} en línea"
},
"content.shared-instance.delete-button": {
"message": "Borrar de todas formas"
},
"content.shared-instance.delete-many-button": {
"message": "Borrar {count, number} proyectos de todas formas"
},
"content.shared-instance.disable-button": {
"message": "Desactivar de todas formas"
},
"content.shared-instance.disable-many-button": {
"message": "Desactivar {count, number} proyectos de todas formas"
},
"friends.action.add-friend": {
"message": "Añadir un amigo"
},
@@ -773,6 +1100,15 @@
"friends.sign-in-to-add-friends": {
"message": "<link>¡Inicia sesión en una cuenta de Modrinth</link> para añadir amigos y ver qué están jugando!"
},
"instance.action.settings": {
"message": "Ajustes de la instancia"
},
"instance.action.starting": {
"message": "Iniciando..."
},
"instance.action.stopping": {
"message": "Deteniendo..."
},
"instance.add-server.add-and-play": {
"message": "Añadir y jugar"
},
@@ -815,6 +1151,15 @@
"instance.files.save-as": {
"message": "Guardar como..."
},
"instance.locked.delete-button": {
"message": "Borrar instancia"
},
"instance.locked.play-tooltip": {
"message": "Esta instancia ha sido bloqueada"
},
"instance.playtime.never-played": {
"message": "No se ha jugado"
},
"instance.server-modal.address": {
"message": "Dirección IP"
},
@@ -827,6 +1172,36 @@
"instance.server-modal.resource-pack": {
"message": "Paquete de recursos"
},
"instance.settings.sharing.active-invites.actions": {
"message": "Acciones"
},
"instance.settings.sharing.active-invites.code": {
"message": "Link de invitación"
},
"instance.settings.sharing.active-invites.expires": {
"message": "Expira"
},
"instance.settings.sharing.active-invites.revoke": {
"message": "Revocar invitación"
},
"instance.settings.sharing.active-invites.revoke-with-code": {
"message": "Revocar invitación {code}"
},
"instance.settings.sharing.active-invites.title": {
"message": "Invitaciones activas"
},
"instance.settings.sharing.active-invites.uses": {
"message": "Usos"
},
"instance.settings.sharing.revoke-invite.admonition-header": {
"message": "Esta acción no se puede deshacer"
},
"instance.settings.sharing.revoke-invite.confirm": {
"message": "Revocar invitación"
},
"instance.settings.sharing.revoke-invite.header": {
"message": "Revocar invitación"
},
"instance.settings.tabs.general": {
"message": "General"
},
@@ -885,10 +1260,10 @@
"message": "Canal de actualizaciones"
},
"instance.settings.tabs.general.update-channel.alpha": {
"message": "Alpha"
"message": "Alfa"
},
"instance.settings.tabs.general.update-channel.alpha.description": {
"message": "Las versiones estables, beta y alpha se mostrarán como actualizaciones disponibles. "
"message": "Las versiones estables, beta y alpha se mostrarán como actualizaciones disponibles."
},
"instance.settings.tabs.general.update-channel.beta": {
"message": "Beta"
@@ -1019,6 +1394,15 @@
"instance.settings.tabs.window.width.enter": {
"message": "Ingresa el ancho..."
},
"instance.shared-instance.network-error.title": {
"message": "Error de conexión"
},
"instance.shared-instance.publish-review.added-label": {
"message": "Se añadió"
},
"instance.shared-instance.publish-review.removed-label": {
"message": "Se removió"
},
"instance.worlds.a_minecraft_server": {
"message": "Un servidor de Minecraft"
},
@@ -1082,6 +1466,36 @@
"minecraft-account.sign-in": {
"message": "Inicia sesión en Minecraft"
},
"minecraft-required.dont-have-account": {
"message": "¿No tienes una cuenta?"
},
"minecraft-required.get-minecraft": {
"message": "Compra Minecraft"
},
"minecraft-required.get-support": {
"message": "Conseguir soporte"
},
"minecraft-required.header": {
"message": "Minecraft requerido"
},
"minecraft-required.sign-in": {
"message": "Iniciar sesión con Microsoft"
},
"modal.modrinth-account-required.header": {
"message": "Cuenta requerida"
},
"modal.modrinth-account-required.sign-in-button": {
"message": "Iniciar sesión en Modrinth"
},
"modal.modrinth-account-required.sign-in-heading": {
"message": "Iniciar sesión con una cuenta de Modrinth"
},
"modal.modrinth-account-required.signing-in-header": {
"message": "Iniciando sesión"
},
"modal.modrinth-account-required.support-prompt": {
"message": "¿Tienes problemas con al iniciar sesión? <support>Consigue ayuda</support>"
},
"search.filter.locked.instance": {
"message": "Proporcionado por la instancia"
},
@@ -1105,5 +1519,11 @@
},
"search.filter.locked.server-loader.title": {
"message": "El loader es proporcionado por el servidor"
},
"settings.sidebar.label.account": {
"message": "Cuenta"
},
"settings.sidebar.label.display": {
"message": "Visualización"
}
}
+22 -55
View File
@@ -11,6 +11,12 @@
"app.action-bar.hide-more-running-instances": {
"message": "Ocultar más instancias en ejecución"
},
"app.action-bar.install.copied-details": {
"message": "Copiado"
},
"app.action-bar.install.copy-details": {
"message": "Copiar detalles"
},
"app.action-bar.install.dismiss": {
"message": "Descartar"
},
@@ -20,6 +26,21 @@
"app.action-bar.install.retry": {
"message": "Reintentar"
},
"app.action-bar.install.summary.app-closing": {
"message": "Cancelado por cierre de la aplicación"
},
"app.action-bar.install.summary.bad-modpack-file": {
"message": "No se pudo leer el modpack"
},
"app.action-bar.install.summary.could-not-save-files": {
"message": "No se pudieron guardar los archivos"
},
"app.action-bar.install.summary.download-failed": {
"message": "La descarga no pudo terminar"
},
"app.action-bar.install.summary.instance-not-found": {
"message": "La instancia no se pudo encontrar"
},
"app.action-bar.install.unknown-instance": {
"message": "Instancia desconocida"
},
@@ -59,21 +80,12 @@
"app.action-bar.view-logs": {
"message": "Ver registros"
},
"app.appearance-settings.advanced-rendering.description": {
"message": "Habilita el renderizado avanzado, como los efectos de desenfoque, que pueden causar problemas de rendimiento sin renderizado acelerado por hardware."
},
"app.appearance-settings.advanced-rendering.title": {
"message": "Renderizado avanzado"
},
"app.appearance-settings.color-theme.description": {
"message": "Selecciona tu tema de color preferido para la Modrinth App."
},
"app.appearance-settings.color-theme.title": {
"message": "Tema de color"
},
"app.appearance-settings.default-landing-page.description": {
"message": "Cambia la página en la que el launcher inicia."
},
"app.appearance-settings.default-landing-page.home": {
"message": "Inicio"
},
@@ -83,54 +95,18 @@
"app.appearance-settings.default-landing-page.title": {
"message": "Página de inicio predeterminada"
},
"app.appearance-settings.hide-nametag.description": {
"message": "Desactiva la etiqueta arriba de tu personaje en la página de skins."
},
"app.appearance-settings.hide-nametag.title": {
"message": "Ocultar etiqueta de nombre"
},
"app.appearance-settings.jump-back-into-worlds.description": {
"message": "Incluye los mundos recientes en la sección \"Volver a jugar\" de la página de inicio."
},
"app.appearance-settings.jump-back-into-worlds.title": {
"message": "Volver a jugar en tus mundos"
},
"app.appearance-settings.minimize-launcher.description": {
"message": "Minimiza el lanzador cuando se inicia un proceso de Minecraft."
},
"app.appearance-settings.minimize-launcher.title": {
"message": "Minimizar lanzador"
},
"app.appearance-settings.native-decorations.description": {
"message": "Usa el marco de ventana del sistema (requiere reiniciar la aplicación)."
},
"app.appearance-settings.native-decorations.title": {
"message": "Decoraciones nativas"
},
"app.appearance-settings.select-option": {
"message": "Selecciona una opción"
},
"app.appearance-settings.show-play-time.description": {
"message": "Muestra cuánto tiempo has estado jugando en una instancia."
},
"app.appearance-settings.show-play-time.title": {
"message": "Mostrar tiempo de juego"
},
"app.appearance-settings.skip-non-essential-warnings.description": {
"message": "Automáticamente omite confirmaciones de bajo riesgo como modpacks duplicados, eliminación de contenido normal, actualizaciones masivas, desvinculando modpacks, y mensajes de reparación. Advertencias importantes seguirán siendo mostradas."
},
"app.appearance-settings.skip-non-essential-warnings.title": {
"message": "Omitir advertencias no esenciales"
},
"app.appearance-settings.toggle-sidebar.description": {
"message": "Activa la posibilidad de alternar la barra lateral."
},
"app.appearance-settings.toggle-sidebar.title": {
"message": "Alternar barra lateral"
},
"app.appearance-settings.unknown-pack-warning.description": {
"message": "Si intentas instalar un archivo de paquete de Modrinth (.mrpack) que no esté alojado en Modrinth, debes comprender los riesgos que esto conlleva antes de instalarlo."
},
"app.appearance-settings.unknown-pack-warning.title": {
"message": "Adviérteme antes de instalarme modpacks desconocidos"
},
@@ -161,9 +137,6 @@
"app.browse.back-to-instance": {
"message": "Volver a la instancia"
},
"app.browse.discover-content": {
"message": "Descubrir contenido"
},
"app.browse.discover-servers": {
"message": "Descubrir servidores"
},
@@ -189,7 +162,7 @@
"message": "Exportar modpack"
},
"app.export-modal.modpack-name-label": {
"message": "Nombre del modpack"
"message": "Nombre del Conjunto de modificadores"
},
"app.export-modal.modpack-name-placeholder": {
"message": "Nombre del modpack"
@@ -287,9 +260,6 @@
"app.modal.update-to-play.header": {
"message": "Actualiza para jugar"
},
"app.modal.update-to-play.update-required-description": {
"message": "Una actualización es requerida para jugar {name}. Por favor actualízala a la versión más reciente para ejecutar el juego."
},
"app.project.install-button.already-installed": {
"message": "El proyecto ya está instalado"
},
@@ -305,9 +275,6 @@
"app.settings.tabs.appearance": {
"message": "Apariencia"
},
"app.settings.tabs.default-instance-options": {
"message": "Opciones predeterminadas de la instancia"
},
"app.settings.tabs.java-installations": {
"message": "Instalaciones de Java"
},
+33 -54
View File
@@ -20,6 +20,18 @@
"app.action-bar.install.retry": {
"message": "Yritä uudelleen"
},
"app.action-bar.install.summary.canceled": {
"message": "Peruutettu"
},
"app.action-bar.install.summary.content-download-failed": {
"message": "Ei voitu ladata tiedostoja"
},
"app.action-bar.install.summary.corrupt-download": {
"message": "Ladattu tiedosto on vioittunut"
},
"app.action-bar.install.summary.could-not-save-files": {
"message": "Ei voitu tallentaa tiedostoja"
},
"app.action-bar.install.unknown-instance": {
"message": "Tuntematon instanssi"
},
@@ -59,21 +71,12 @@
"app.action-bar.view-logs": {
"message": "Katso lokeja"
},
"app.appearance-settings.advanced-rendering.description": {
"message": "Mahdollistaa edistyneen renderöinnin, kuten sumennus efektit, jotka voivat aiheuttaa suorituskykyongelmia ilman laitteistokiihdytettyä renderöintiä."
},
"app.appearance-settings.advanced-rendering.title": {
"message": "Edistynyt renderointi"
},
"app.appearance-settings.color-theme.description": {
"message": "Valitse haluamasi väriteema Modrinth Appille."
},
"app.appearance-settings.color-theme.title": {
"message": "Väriteema"
},
"app.appearance-settings.default-landing-page.description": {
"message": "Muuta sivua mille laukaisin avautuu."
},
"app.appearance-settings.default-landing-page.home": {
"message": "Koti"
},
@@ -83,54 +86,18 @@
"app.appearance-settings.default-landing-page.title": {
"message": "Oletus laskeutumissivu"
},
"app.appearance-settings.hide-nametag.description": {
"message": "Kytkee pois päältä nimikyltin pelaajasi päällä skinit sivulla."
},
"app.appearance-settings.hide-nametag.title": {
"message": "Piilota nimikyltti"
},
"app.appearance-settings.jump-back-into-worlds.description": {
"message": "Sisällyttää viimeaikaiset maailmat \"Hyppää takaisin sisään\" osiossa Koti sivulla."
},
"app.appearance-settings.jump-back-into-worlds.title": {
"message": "Hyppää takaisin sisään maailmoihin"
},
"app.appearance-settings.minimize-launcher.description": {
"message": "Pienennä laukaisin kun Minecraft prosessi käynnistyy."
},
"app.appearance-settings.minimize-launcher.title": {
"message": "Piilota käynnistysohjelma"
},
"app.appearance-settings.native-decorations.description": {
"message": "Käytä järjestelmän ikkuna kehystä (Vaatii sovelluksen uudelleen käynnistämisen)."
},
"app.appearance-settings.native-decorations.title": {
"message": "Natiivit koristukset"
},
"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."
},
"app.appearance-settings.toggle-sidebar.title": {
"message": "Kytke sivupalkki"
},
"app.appearance-settings.unknown-pack-warning.description": {
"message": "Jos yrität asentaa Modrinth-pakettitiedoston (.mrpack), jota ei ole isännöity Modrinthissä, varmistamme, että ymmärrät riskit ennen asennusta."
},
"app.appearance-settings.unknown-pack-warning.title": {
"message": "Varoita minua ennen tuntemattomien modipakettien asentamista"
},
@@ -161,9 +128,6 @@
"app.browse.back-to-instance": {
"message": "Takaisin instanssiin"
},
"app.browse.discover-content": {
"message": "Löydä sisältöä"
},
"app.browse.discover-servers": {
"message": "Löydä palvelimia"
},
@@ -299,6 +263,9 @@
"app.instance.mods.successfully-uploaded": {
"message": "Onnistuneesti ladattu"
},
"app.instance.share.remove-user-modal.effect-installed-copy": {
"message": "Kaikki laitteeseen jo asennetut kopiot säilyvät laitteessa"
},
"app.instance.worlds.add-server": {
"message": "Lisää palvelin"
},
@@ -353,6 +320,9 @@
"app.modal.install-to-play.header": {
"message": "Asenna pelataksesi"
},
"app.modal.install-to-play.install-anyway": {
"message": "Asenna silti"
},
"app.modal.install-to-play.install-button": {
"message": "Asenna"
},
@@ -368,15 +338,18 @@
"app.modal.update-to-play.header": {
"message": "Päivitä pelataksesi"
},
"app.modal.update-to-play.update-required-description": {
"message": "Pävitys vaaditaan pelataksesi {name}. Päivitä viimeisimpään versioon pelataksesi."
},
"app.project.install-button.already-installed": {
"message": "Tämä projekti on jo asennettu"
},
"app.project.install-context.back-to-browse": {
"message": "Takaisin sisällön löytöön"
},
"app.project.version.installing": {
"message": "Asennetaan"
},
"app.project.versions.already-installed": {
"message": "On jo asennettu"
},
"app.settings.developer-mode-enabled": {
"message": "Kehittäjätila käytössä."
},
@@ -386,9 +359,6 @@
"app.settings.tabs.appearance": {
"message": "Ulkonäkö"
},
"app.settings.tabs.default-instance-options": {
"message": "Oletusinstanssin asetukset"
},
"app.settings.tabs.java-installations": {
"message": "Java asennukset"
},
@@ -548,6 +518,12 @@
"app.skins.title": {
"message": "Skinivalinta"
},
"app.skins.toggle-ears-features-off": {
"message": "Poista käytöstä"
},
"app.skins.toggle-ears-features-on": {
"message": "Ota käyttöön"
},
"app.update-popup.body.download-complete": {
"message": "Modrinth App versio {version} on ladattu. Voit käynnistää sovelluksen uudelleen päivittääksesi heti tai antaa päivityksen asentua automaattisesti, kun suljet Modrinth Appin."
},
@@ -659,6 +635,9 @@
"friends.sign-in-to-add-friends": {
"message": "<link>Kirjaudu Modrinth-tilille</link> lisätäksesi ystäviä ja nähdäksesi mitä he pelaavat!"
},
"instance.action.create-shortcut": {
"message": "Luo pikakuvake"
},
"instance.add-server.add-and-play": {
"message": "Lisää ja pelaa"
},

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